Spaces:
Configuration error
Configuration error
| """ | |
| backend/api/integrity_manager.py — Snapshot & Integrity Manager (P41) | |
| Cattura VFS + Task + Blackboard in snapshot puntuale. Verifica regressioni. | |
| Self-healing automatico a livello file. | |
| Storage: VFS file /.agent/snapshots/snap_<ts>.json (zero migration richiesta). | |
| Bot handlers: handle_snap_cmd / handle_verify_cmd / handle_heal_cmd | |
| accettano reply_fn callable — zero import ciclici. | |
| Endpoints: | |
| POST /api/integrity/snap — crea snapshot | |
| GET /api/integrity/snapshots — lista snapshot disponibili | |
| GET /api/integrity/verify — diff vs ultimo snapshot | |
| GET /api/integrity/verify/{id} — diff vs snapshot specifico | |
| POST /api/integrity/heal — ripristina file regrediti | |
| POST /api/integrity/heal/{id} — ripristina da snapshot specifico | |
| """ | |
| from __future__ import annotations | |
| import asyncio | |
| import hashlib | |
| import json | |
| import logging | |
| import os | |
| import time | |
| from typing import Any, Callable, Coroutine, Optional | |
| from fastapi import APIRouter, Body | |
| from fastapi.responses import JSONResponse | |
| _logger = logging.getLogger("api.integrity") | |
| router = APIRouter(prefix="/api/integrity", tags=["integrity"]) | |
| # In-memory fallback (usato quando Supabase non disponibile o per rapidita') | |
| _SNAP_CACHE: dict[str, dict] = {} # snap_id -> snapshot dict | |
| _MAX_CACHE = 20 # ultimi N snapshot tenuti in memoria | |
| # ─── Utilities ──────────────────────────────────────────────────────────────── | |
| def _sha16(text: str) -> str: | |
| """SHA-256 troncato a 16 hex — fingerprint leggero per contenuto file.""" | |
| return hashlib.sha256(text.encode("utf-8", errors="replace")).hexdigest()[:16] | |
| def _now_ms() -> int: | |
| return int(time.time() * 1000) | |
| def _snap_id() -> str: | |
| return f"snap_{int(time.time())}" | |
| def _age_str(created_at_ms: int) -> str: | |
| s = (_now_ms() - created_at_ms) // 1000 | |
| if s < 60: return f"{s}s" | |
| if s < 3600: return f"{s//60}m {s%60}s" | |
| return f"{s//3600}h {(s%3600)//60}m" | |
| # ─── VFS (Supabase vfs_files table) ────────────────────────────────────────── | |
| async def _list_vfs_files(conversation_id: Optional[str] = None) -> list[dict]: | |
| """Legge tutti i file VFS da Supabase. Fallback: lista vuota.""" | |
| from .state import _sb | |
| if not _sb: | |
| return [] | |
| try: | |
| q = _sb.table("vfs_files").select( | |
| "id,path,content,language,conversation_id,updated_at" | |
| ) | |
| if conversation_id: | |
| q = q.eq("conversation_id", conversation_id) | |
| data = await asyncio.to_thread(lambda: q.order("path").execute()) | |
| return data.data or [] | |
| except Exception as exc: | |
| _logger.warning("[integrity] list_vfs_files: %s", exc) | |
| return [] | |
| async def _restore_vfs_file(rec: dict, force: bool = False) -> bool: | |
| """Ripristina un file VFS su Supabase dal record snapshot. True = ok. | |
| GAP-VFS-FIX: confronto ottimistico su updated_at prima del restore. | |
| Se il file live ha updated_at > snapshot updated_at, il restore viene | |
| saltato per non sovrascrivere una versione più recente. | |
| force=True sovrascrive comunque (emergenza/rollback manuale esplicito). | |
| """ | |
| from .state import _sb | |
| if not _sb: | |
| return False | |
| try: | |
| snap_updated_at = rec.get("updated_at", 0) | |
| file_id = rec.get("id", "") | |
| # GAP-VFS-FIX: verifica che il file live non sia più recente dello snapshot | |
| if not force and file_id and snap_updated_at > 0: | |
| try: | |
| current = await asyncio.to_thread( | |
| lambda: _sb.table("vfs_files") | |
| .select("updated_at") | |
| .eq("id", file_id) | |
| .maybe_single() | |
| .execute() | |
| ) | |
| if current and current.data: | |
| live_updated_at = current.data.get("updated_at", 0) | |
| if live_updated_at > snap_updated_at: | |
| _logger.info( | |
| "[integrity] GAP-VFS: skip restore %s " | |
| "(live=%d > snap=%d). Usa force=True per rollback.", | |
| rec.get("path"), live_updated_at, snap_updated_at, | |
| ) | |
| return False | |
| except Exception as _chk_exc: | |
| # Errore nel check → procedi con restore (fail-open per sicurezza) | |
| _logger.debug("[integrity] restore pre-check silenced: %s", _chk_exc) | |
| body: dict = { | |
| "path": rec["path"], | |
| "content": rec.get("content", ""), | |
| "language": rec.get("language", "text"), | |
| "conversation_id": rec.get("conversation_id", ""), | |
| "updated_at": _now_ms(), | |
| } | |
| if file_id: | |
| body["id"] = file_id | |
| await asyncio.to_thread(lambda: _sb.table("vfs_files").upsert(body).execute()) | |
| # Background: lint + manifest dopo restore | |
| try: | |
| content = rec.get("content", "") | |
| lang = rec.get("language", "text") | |
| path = rec["path"] | |
| conv = rec.get("conversation_id", "") | |
| if file_id and content: | |
| from .linter import lint_and_store | |
| from .project_manifest import update_manifest | |
| asyncio.create_task(lint_and_store(file_id, content, lang, path, conv)) | |
| asyncio.create_task(update_manifest(conv, path, content, lang)) | |
| except Exception: | |
| pass | |
| return True | |
| except Exception as exc: | |
| _logger.warning("[integrity] restore_vfs_file %s: %s", rec.get("path"), exc) | |
| return False | |
| # ─── Blackboard (Upstash Redis REST) ───────────────────────────────────────── | |
| async def _list_bb_keys(session_pattern: str = "bb:*") -> list[str]: | |
| """SCAN cursored su Upstash — ritorna tutte le chiavi blackboard.""" | |
| import httpx | |
| url = os.getenv("UPSTASH_REDIS_REST_URL", "") | |
| token = os.getenv("UPSTASH_REDIS_REST_TOKEN", "") | |
| if not url or not token: | |
| return [] | |
| keys: list[str] = [] | |
| cursor = "0" | |
| try: | |
| async with httpx.AsyncClient(timeout=4.0) as c: | |
| for _ in range(10): # max 10 scan pages | |
| r = await c.post( | |
| url, | |
| json=["SCAN", cursor, "MATCH", session_pattern, "COUNT", "200"], | |
| headers={"Authorization": f"Bearer {token}"}, | |
| ) | |
| if not r.is_success: | |
| break | |
| result = r.json().get("result", []) | |
| if isinstance(result, list) and len(result) >= 2: | |
| cursor = str(result[0]) | |
| batch = result[1] if isinstance(result[1], list) else [] | |
| keys.extend(batch) | |
| if cursor == "0": # scan completo | |
| break | |
| else: | |
| break | |
| except Exception as exc: | |
| _logger.warning("[integrity] list_bb_keys: %s", exc) | |
| return keys | |
| # ─── Task state ─────────────────────────────────────────────────────────────── | |
| def _active_task_ids() -> list[str]: | |
| try: | |
| from .state import _agent_tasks | |
| return [tid for tid, t in _agent_tasks.items() | |
| if t.get("status") in ("running", "pending")] | |
| except Exception: | |
| return [] | |
| # ─── Snapshot storage (via VFS /.agent/snapshots/) ──────────────────────────── | |
| async def _save_snapshot(snap: dict) -> None: | |
| sid = snap["snapshot_id"] | |
| path = f"/.agent/snapshots/{sid}.json" | |
| body = json.dumps(snap, ensure_ascii=False, default=str) | |
| # Cache in-memory sempre | |
| _SNAP_CACHE[sid] = snap | |
| if len(_SNAP_CACHE) > _MAX_CACHE: | |
| oldest = min(_SNAP_CACHE, key=lambda k: _SNAP_CACHE[k].get("created_at", 0)) | |
| del _SNAP_CACHE[oldest] | |
| # Persist su Supabase via vfs_files | |
| from .state import _sb | |
| if not _sb: | |
| return | |
| try: | |
| await asyncio.to_thread(lambda: _sb.table("vfs_files").upsert({ | |
| "path": path, | |
| "content": body, | |
| "language": "json", | |
| "conversation_id": "__integrity__", | |
| "updated_at": _now_ms(), | |
| "created_at": snap["created_at"], | |
| }).execute()) | |
| except Exception as exc: | |
| _logger.warning("[integrity] save_snapshot %s: %s", sid, exc) | |
| async def _load_all_snapshots() -> list[dict]: | |
| """Lista snapshot da Supabase + cache, ordinati per data desc.""" | |
| merged: dict[str, dict] = dict(_SNAP_CACHE) | |
| from .state import _sb | |
| if _sb: | |
| try: | |
| data = await asyncio.to_thread(lambda: _sb.table("vfs_files") | |
| .select("content,updated_at") | |
| .eq("conversation_id", "__integrity__") | |
| .like("path", "/.agent/snapshots/snap_%.json") | |
| .order("updated_at", desc=True) | |
| .limit(30) | |
| .execute() | |
| ) | |
| for row in (data.data or []): | |
| try: | |
| s = json.loads(row["content"]) | |
| if sid := s.get("snapshot_id"): | |
| merged[sid] = s | |
| except Exception: | |
| pass | |
| except Exception as exc: | |
| _logger.warning("[integrity] load_snapshots: %s", exc) | |
| return sorted(merged.values(), key=lambda s: s.get("created_at", 0), reverse=True) | |
| async def _latest_snapshot() -> Optional[dict]: | |
| snaps = await _load_all_snapshots() | |
| return snaps[0] if snaps else None | |
| async def _snapshot_by_id(snap_id: str) -> Optional[dict]: | |
| if snap_id in _SNAP_CACHE: | |
| return _SNAP_CACHE[snap_id] | |
| snaps = await _load_all_snapshots() | |
| return next((s for s in snaps if s.get("snapshot_id") == snap_id), None) | |
| # ─── Core: take_snapshot ────────────────────────────────────────────────────── | |
| async def take_snapshot(conversation_id: Optional[str] = None) -> dict: | |
| """Cattura snapshot completo: VFS + task attivi + blackboard keys.""" | |
| sid = _snap_id() | |
| ts = _now_ms() | |
| # VFS | |
| vfs_files = await _list_vfs_files(conversation_id) | |
| vfs: dict[str, dict] = {} | |
| for f in vfs_files: | |
| p = f.get("path", "") | |
| if not p or p.startswith("/.agent/snapshots/"): | |
| continue | |
| c = f.get("content") or "" | |
| vfs[p] = { | |
| "id": f.get("id", ""), | |
| "content_sha": _sha16(c), | |
| "content": c, | |
| "language": f.get("language", "text"), | |
| "conversation_id": f.get("conversation_id", ""), | |
| "updated_at": f.get("updated_at", 0), | |
| } | |
| # Task | |
| active = _active_task_ids() | |
| # Blackboard | |
| bb_keys = await _list_bb_keys() | |
| snap = { | |
| "snapshot_id": sid, | |
| "created_at": ts, | |
| "conversation_id": conversation_id, | |
| "vfs": vfs, | |
| "tasks": { | |
| "active_ids": active, | |
| "count": len(active), | |
| }, | |
| "blackboard": { | |
| "keys": bb_keys, | |
| "key_count": len(bb_keys), | |
| }, | |
| "summary": { | |
| "vfs_files": len(vfs), | |
| "active_tasks": len(active), | |
| "bb_keys": len(bb_keys), | |
| }, | |
| } | |
| await _save_snapshot(snap) | |
| _logger.info("[integrity] snapshot %s: %d VFS, %d tasks, %d BB", | |
| sid, len(vfs), len(active), len(bb_keys)) | |
| return snap | |
| # ─── Core: verify_snapshot ──────────────────────────────────────────────────── | |
| async def verify_snapshot(snap_id: Optional[str] = None) -> dict: | |
| """Confronta stato corrente vs snapshot. Ritorna diff report.""" | |
| snap = (await _snapshot_by_id(snap_id) if snap_id | |
| else await _latest_snapshot()) | |
| if not snap: | |
| return {"ok": False, "error": "Nessuno snapshot trovato. Esegui /snap prima di verificare."} | |
| snap_vfs = snap.get("vfs", {}) | |
| conv_id = snap.get("conversation_id") | |
| current_files = await _list_vfs_files(conv_id) | |
| current: dict[str, str] = {} | |
| for f in current_files: | |
| p = f.get("path", "") | |
| if not p or p.startswith("/.agent/snapshots/"): | |
| continue | |
| current[p] = _sha16(f.get("content") or "") | |
| snap_paths = set(snap_vfs) | |
| current_paths = set(current) | |
| missing = sorted(snap_paths - current_paths) | |
| added = sorted(current_paths - snap_paths) | |
| modified = sorted(p for p in snap_paths & current_paths | |
| if current[p] != snap_vfs[p]["content_sha"]) | |
| ok_count = len(snap_paths & current_paths) - len(modified) | |
| regression = bool(missing or modified) | |
| return { | |
| "ok": True, | |
| "snapshot_id": snap["snapshot_id"], | |
| "snapshot_age_s": (_now_ms() - snap["created_at"]) // 1000, | |
| "vfs": { | |
| "missing": missing, | |
| "modified": modified, | |
| "added": added, | |
| "ok": ok_count, | |
| "total_snapshot": len(snap_paths), | |
| "total_current": len(current_paths), | |
| }, | |
| "tasks": { | |
| "snapshot_count": snap.get("tasks", {}).get("count", 0), | |
| "current_count": len(_active_task_ids()), | |
| }, | |
| "regression_detected": regression, | |
| "heal_candidates": missing + modified, | |
| } | |
| # ─── Core: heal_from_snapshot ───────────────────────────────────────────────── | |
| async def heal_from_snapshot( | |
| snap_id: Optional[str] = None, | |
| paths: Optional[list[str]] = None, | |
| ) -> dict: | |
| """Ripristina file regrediti/mancanti dallo snapshot.""" | |
| snap = (await _snapshot_by_id(snap_id) if snap_id | |
| else await _latest_snapshot()) | |
| if not snap: | |
| return {"ok": False, "error": "Nessuno snapshot trovato."} | |
| if paths is None: | |
| report = await verify_snapshot(snap.get("snapshot_id")) | |
| paths = report.get("heal_candidates", []) | |
| if not paths: | |
| return {"ok": True, "healed": [], "failed": [], | |
| "message": "Nessuna regressione — sistema integro."} | |
| snap_vfs = snap.get("vfs", {}) | |
| healed: list[str] = [] | |
| failed: list[dict] = [] | |
| for p in paths: | |
| rec = snap_vfs.get(p) | |
| if not rec: | |
| failed.append({"path": p, "reason": "path non nel snapshot"}) | |
| continue | |
| ok = await _restore_vfs_file(rec) | |
| (healed if ok else failed.append({"path": p, "reason": "restore fallito"}) or []).append(p) if ok else None | |
| _logger.info("[integrity] heal: %d ok, %d failed", len(healed), len(failed)) | |
| return { | |
| "ok": True, | |
| "healed": healed, | |
| "failed": failed, | |
| "message": f"Ripristinati {len(healed)}/{len(paths)} file.", | |
| } | |
| # ─── FastAPI endpoints ──────────────────────────────────────────────────────── | |
| async def api_snap(body: dict = Body(default={})) -> JSONResponse: | |
| """POST /api/integrity/snap — cattura snapshot dell'ambiente.""" | |
| try: | |
| conv_id = body.get("conversation_id") if isinstance(body, dict) else None | |
| snap = await take_snapshot(conv_id) | |
| return JSONResponse({"ok": True, "snapshot_id": snap["snapshot_id"], | |
| "summary": snap["summary"]}) | |
| except Exception as exc: | |
| _logger.error("[integrity] snap: %s", exc) | |
| return JSONResponse({"ok": False, "error": str(exc)[:200]}, status_code=500) | |
| async def api_list_snapshots() -> JSONResponse: | |
| """GET /api/integrity/snapshots — lista snapshot disponibili.""" | |
| try: | |
| snaps = await _load_all_snapshots() | |
| return JSONResponse({"ok": True, "snapshots": [ | |
| {"snapshot_id": s["snapshot_id"], "created_at": s["created_at"], | |
| "summary": s.get("summary", {})} | |
| for s in snaps | |
| ]}) | |
| except Exception as exc: | |
| return JSONResponse({"ok": False, "error": str(exc)[:200]}, status_code=500) | |
| async def api_verify() -> JSONResponse: | |
| """GET /api/integrity/verify — diff vs ultimo snapshot.""" | |
| return JSONResponse(await verify_snapshot()) | |
| async def api_verify_id(snap_id: str) -> JSONResponse: | |
| """GET /api/integrity/verify/{id} — diff vs snapshot specifico.""" | |
| return JSONResponse(await verify_snapshot(snap_id)) | |
| async def api_heal(body: dict = Body(default={})) -> JSONResponse: | |
| """POST /api/integrity/heal — ripristina file regrediti.""" | |
| try: | |
| snap_id = body.get("snap_id") if isinstance(body, dict) else None | |
| paths = body.get("paths") if isinstance(body, dict) else None | |
| return JSONResponse(await heal_from_snapshot(snap_id, paths)) | |
| except Exception as exc: | |
| _logger.error("[integrity] heal: %s", exc) | |
| return JSONResponse({"ok": False, "error": str(exc)[:200]}, status_code=500) | |
| async def api_heal_id(snap_id: str) -> JSONResponse: | |
| """POST /api/integrity/heal/{id} — ripristina da snapshot specifico.""" | |
| return JSONResponse(await heal_from_snapshot(snap_id)) | |
| # ─── Telegram bot command handlers ──────────────────────────────────────────── | |
| # Chiamati da telegram_webhook.py; accettano reply_fn per evitare import ciclici. | |
| ReplyFn = Callable[[Any, str], Coroutine[Any, Any, None]] | |
| async def handle_snap_cmd(chat_id: Any, reply_fn: ReplyFn, | |
| conversation_id: Optional[str] = None) -> None: | |
| try: | |
| await reply_fn(chat_id, "⏳ <b>Acquisisco snapshot...</b>") | |
| snap = await take_snapshot(conversation_id) | |
| s = snap["summary"] | |
| await reply_fn(chat_id, | |
| f"📸 <b>Snapshot acquisito</b>\n" | |
| f"<code>{snap['snapshot_id']}</code>\n\n" | |
| f"• VFS: <b>{s['vfs_files']}</b> file\n" | |
| f"• Task attivi: <b>{s['active_tasks']}</b>\n" | |
| f"• Blackboard keys: <b>{s['bb_keys']}</b>\n\n" | |
| f"Usa /verify per controllare regressioni in qualsiasi momento." | |
| ) | |
| except Exception as exc: | |
| await reply_fn(chat_id, f"❌ Snapshot fallito: <code>{str(exc)[:150]}</code>") | |
| async def handle_verify_cmd(chat_id: Any, reply_fn: ReplyFn, | |
| snap_id: Optional[str] = None) -> None: | |
| try: | |
| await reply_fn(chat_id, "🔍 <b>Verifico integrità...</b>") | |
| r = await verify_snapshot(snap_id) | |
| if not r.get("ok"): | |
| await reply_fn(chat_id, f"⚠️ {r.get('error', 'Errore sconosciuto')}") | |
| return | |
| vfs = r.get("vfs", {}) | |
| age = _age_str(r.get("snapshot_age_s", 0) * 1000 + _now_ms() - r.get("snapshot_age_s", 0) * 1000) | |
| # compute age correctly | |
| age_s = r.get("snapshot_age_s", 0) | |
| if age_s < 60: age = f"{age_s}s" | |
| elif age_s < 3600: age = f"{age_s//60}m {age_s%60}s" | |
| else: age = f"{age_s//3600}h {(age_s%3600)//60}m" | |
| if not r["regression_detected"]: | |
| await reply_fn(chat_id, | |
| f"✅ <b>Sistema integro</b>\n" | |
| f"Snapshot: <code>{r['snapshot_id']}</code> ({age} fa)\n\n" | |
| f"File verificati: {vfs.get('ok',0)}/{vfs.get('total_snapshot',0)} — " | |
| f"nessuna regressione." | |
| ) | |
| return | |
| lines = [f"⚠️ <b>Regressioni rilevate!</b>", | |
| f"Snapshot: <code>{r['snapshot_id']}</code> ({age} fa)\n"] | |
| if vfs.get("missing"): | |
| lines.append(f"🗑 <b>Eliminati ({len(vfs['missing'])}):</b>") | |
| for p in vfs["missing"][:5]: lines.append(f" • <code>{p}</code>") | |
| if len(vfs["missing"]) > 5: lines.append(f" … +{len(vfs['missing'])-5}") | |
| if vfs.get("modified"): | |
| lines.append(f"\n✏️ <b>Modificati ({len(vfs['modified'])}):</b>") | |
| for p in vfs["modified"][:5]: lines.append(f" • <code>{p}</code>") | |
| if len(vfs["modified"]) > 5: lines.append(f" … +{len(vfs['modified'])-5}") | |
| if vfs.get("added"): | |
| lines.append(f"\n➕ <b>Nuovi ({len(vfs['added'])}):</b>") | |
| for p in vfs["added"][:3]: lines.append(f" • <code>{p}</code>") | |
| lines.append(f"\nUsa /heal per ripristinare i file regrediti.") | |
| await reply_fn(chat_id, "\n".join(lines)) | |
| except Exception as exc: | |
| await reply_fn(chat_id, f"❌ Verify fallito: <code>{str(exc)[:150]}</code>") | |
| async def handle_heal_cmd(chat_id: Any, reply_fn: ReplyFn, | |
| snap_id: Optional[str] = None) -> None: | |
| try: | |
| await reply_fn(chat_id, "🔧 <b>Self-healing in corso...</b>") | |
| r = await heal_from_snapshot(snap_id) | |
| if not r.get("ok"): | |
| await reply_fn(chat_id, f"❌ Heal fallito: {r.get('error')}") | |
| return | |
| healed = r.get("healed", []) | |
| failed = r.get("failed", []) | |
| if not healed and not failed: | |
| await reply_fn(chat_id, | |
| "✅ <b>Nessuna regressione</b> — il sistema è già integro.") | |
| return | |
| lines = ["🔧 <b>Self-healing completato</b>\n"] | |
| if healed: | |
| lines.append(f"✅ <b>Ripristinati ({len(healed)}):</b>") | |
| for p in healed[:8]: lines.append(f" • <code>{p}</code>") | |
| if len(healed) > 8: lines.append(f" … +{len(healed)-8}") | |
| if failed: | |
| lines.append(f"\n❌ <b>Falliti ({len(failed)}):</b>") | |
| for f in failed[:3]: | |
| lines.append(f" • <code>{f['path']}</code>: {f['reason']}") | |
| await reply_fn(chat_id, "\n".join(lines)) | |
| except Exception as exc: | |
| await reply_fn(chat_id, f"❌ Heal fallito: <code>{str(exc)[:150]}</code>") | |