# ============================================ # Projet : KA Guardian # Fichier : orchestrator/main.py # Rôle : Cerveau d'un agent gardien (ka2/ka4/ka6) — surveille la santé # des connecteurs via api-ka, ouvre des incidents, dépêche des # missions claude aux runners des nœuds, surveille la guérison, # rollback automatique si ça empire. Sert aussi le dashboard live. # Author : Simon-Pierre Boucher # Date : 2026-08-23 # ============================================ """Instance unique par agent: AGENT=ka2|ka4|ka6 (env), port depuis topology.json.""" from __future__ import annotations import asyncio import json import os import pathlib import sqlite3 import time import uuid from typing import Any import httpx from fastapi import FastAPI, Header, HTTPException, Request from fastapi.responses import FileResponse, HTMLResponse, StreamingResponse from fastapi.staticfiles import StaticFiles import registry # topologie vivante (registre mld) — même dossier que main.py BASE = pathlib.Path(__file__).resolve().parent ROOT = BASE.parent HOME = pathlib.Path.home() AGENT = os.environ.get("AGENT", "ka2") TOPO = json.loads((ROOT / "topology.json").read_text()) ME = TOPO["agents"][AGENT] POLICY = TOPO["policy"] # SERVICES / NODES : dicts mutés EN PLACE par registry.refresh() — l'emplacement # réel (nœud, IP, dir, port, PM2) vient du registre mld, topology.json n'est # que le repli si le registre n'a jamais été reçu. SERVICES: dict[str, Any] = TOPO["services"] NODES: dict[str, Any] = TOPO["nodes"] MY_SERVICES: set[str] = set(ME["services"]) RUNNER_PORT = TOPO["runner_port"] try: # première application au chargement (silencieuse : le hub SSE n'existe pas encore) for _m in registry.refresh(TOPO, SERVICES, NODES): print(f"[registre] {_m}", flush=True) except Exception as _exc: print(f"[registre] indisponible au démarrage ({_exc}) — topology.json en repli", flush=True) def node_alias(node_key: str) -> str: return (NODES.get(node_key) or {}).get("alias", node_key) def node_ip(node_key: str) -> str | None: return (NODES.get(node_key) or {}).get("lan_ip") def apply_registry(announce: bool = True) -> None: """Relit le registre mld s'il a changé et signale les déménagements.""" try: changes = registry.refresh(TOPO, SERVICES, NODES) except Exception as exc: # jamais fatal : on garde la dernière topologie connue print(f"[registre] échec: {exc}", flush=True) return for msg in changes: print(f"[registre] {msg}", flush=True) if announce: hub.publish_sync({"kind": "log", "level": "warn", "msg": f"registre mld — {msg}", "ts": now()}) _RUNNER_WARNED: dict[str, float] = {} def runner_available(node_key: str, what: str) -> bool: """False si la sonde registry-sync dit qu'il n'y a PAS de runner sur ce nœud (app déménagée sur un nœud jamais équipé) — on n'envoie alors rien au courrier, et on avertit au plus une fois par heure par nœud.""" ip = node_ip(node_key) rs = registry.runner_status(ip) if rs is None or rs.get("ok"): return True if now() - _RUNNER_WARNED.get(node_key, 0) > 3600: _RUNNER_WARNED[node_key] = now() msg = (f"aucun runner gardien sur {node_alias(node_key)} ({ip}) — {what} impossible ; " f"déployer avec deploy/deploy.sh runners (le nœud est listé par le registre)") print(f"[runner] {msg}", flush=True) hub.publish_sync({"kind": "log", "level": "error", "msg": msg, "ts": now()}) return False # ka6 (ou autre) ramasse les services non assignés qui apparaîtraient dans api-ka. DEFAULT_AGENT = ME.get("default_for_unknown_services", False) ASSIGNED = {s for a in TOPO["agents"].values() for s in a["services"]} def load_env_file(path: pathlib.Path) -> dict[str, str]: out: dict[str, str] = {} if path.exists(): for line in path.read_text().splitlines(): line = line.strip() if line and not line.startswith("#") and "=" in line: k, v = line.split("=", 1) out[k.strip()] = v.strip().strip('"').strip("'") return out TOKEN = load_env_file(HOME / ".ka-guardian.env").get("KA_GUARDIAN_TOKEN", "") SPOOL = HOME / "ka-guardian-spool" async def lan_post(ip: str, port: int, path: str, payload: dict[str, Any], timeout: int = 30) -> tuple[int, str]: """POST JSON vers un autre nœud via le courrier zsh (deploy/courier.sh). macOS 26 « Local Network Privacy » refuse le trafic LAN dès que python (homebrew) est dans la chaîne de processus — même ses enfants ssh/curl. On dépose donc un job dans un spool disque ; un service launchd 100 % zsh l'expédie (ssh → curl localhost du nœud cible) et dépose la réponse. """ for d in ("outbox", "done", "tmp"): (SPOOL / d).mkdir(parents=True, exist_ok=True) jid = uuid.uuid4().hex tmp = SPOOL / "tmp" / f"{jid}.job" tmp.write_text(f"{ip} {port} {path} {timeout}\n" + jdump(payload)) tmp.rename(SPOOL / "outbox" / f"{jid}.job") resp = SPOOL / "done" / f"{jid}.resp" deadline = time.time() + timeout + 25 while time.time() < deadline: if resp.exists(): txt = resp.read_text() resp.unlink(missing_ok=True) body, _, code = txt.strip().rpartition("\n") if code.startswith("courier_ssh_rc"): raise ConnectionError(f"{code} vers {ip}:{port}{path}") return int(code or "0"), body await asyncio.sleep(0.3) raise TimeoutError(f"courrier sans réponse pour {ip}:{port}{path}") DATA = ROOT / "data" DATA.mkdir(exist_ok=True) DB_PATH = DATA / f"{AGENT}.db" # ---------------------------------------------------------------- SQLite --- def db() -> sqlite3.Connection: conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row conn.execute("PRAGMA journal_mode=WAL") return conn def init_db() -> None: with db() as c: c.executescript(""" CREATE TABLE IF NOT EXISTS incidents( id TEXT PRIMARY KEY, service TEXT, source TEXT, status_detected TEXT, state TEXT, attempts INTEGER DEFAULT 0, created REAL, updated REAL, resolved REAL, detail TEXT); CREATE TABLE IF NOT EXISTS missions( id TEXT PRIMARY KEY, incident_id TEXT, service TEXT, source TEXT, node TEXT, state TEXT, base_commit TEXT, commits TEXT, verdict TEXT, cost_usd REAL, num_turns INTEGER, health TEXT, started REAL, ended REAL); CREATE TABLE IF NOT EXISTS events( id INTEGER PRIMARY KEY AUTOINCREMENT, mission_id TEXT, ts REAL, type TEXT, data TEXT); CREATE TABLE IF NOT EXISTS snapshots( ts REAL PRIMARY KEY, mine TEXT, ecosystem TEXT); CREATE INDEX IF NOT EXISTS ev_mission ON events(mission_id); """) def now() -> float: return time.time() def jdump(x: Any) -> str: return json.dumps(x, ensure_ascii=False) # ------------------------------------------------------------------- SSE --- class Hub: def __init__(self) -> None: self.clients: set[asyncio.Queue] = set() async def publish(self, msg: dict[str, Any]) -> None: for q in list(self.clients): if q.qsize() < 500: q.put_nowait(msg) def publish_sync(self, msg: dict[str, Any]) -> None: if LOOP: asyncio.run_coroutine_threadsafe(self.publish(msg), LOOP) hub = Hub() LOOP: asyncio.AbstractEventLoop | None = None LATEST: dict[str, Any] = {"mine": {}, "ecosystem": {}, "polled": 0, "paused": False} # ------------------------------------------------------------- missions ---- def active_incident(c: sqlite3.Connection, service: str, source: str) -> sqlite3.Row | None: return c.execute( "SELECT * FROM incidents WHERE service=? AND source=? AND state IN " "('open','dispatched','fixing','watching','cooldown') ORDER BY created DESC LIMIT 1", (service, source)).fetchone() def set_incident(c: sqlite3.Connection, iid: str, **kw: Any) -> None: kw["updated"] = now() keys = ",".join(f"{k}=?" for k in kw) c.execute(f"UPDATE incidents SET {keys} WHERE id=?", (*kw.values(), iid)) def incident_event(iid: str, service: str, source: str, state: str, note: str = "") -> None: hub.publish_sync({"kind": "incident", "incident_id": iid, "service": service, "source": source, "state": state, "note": note, "ts": now()}) def service_situation(service: str) -> str: """Portrait santé de l'app entière — permet à l'agent de distinguer une panne isolée d'un problème systémique (app entière, réseau, quota).""" block = LATEST["mine"].get(service) or {} summary = block.get("summary") or {} app_row = block.get("app") or {} sick = [f"{c['source']} ({c['status']})" for c in block.get("connectors", []) if c.get("status") not in ("ok", None)][:15] lines = [f"- app entière: {app_row.get('status', '?')} — {summary.get('ok', '?')} ok, " f"{summary.get('degraded', 0)} dégradés, {summary.get('broken', 0)} cassés, {summary.get('stale', 0)} endormis"] if sick: lines.append(f"- autres connecteurs non-ok de cette app: {', '.join(sick)}") lines.append("- Si BEAUCOUP de connecteurs sont touchés en même temps, le problème est probablement " "systémique (scheduler de l'app, réseau, quota API): diagnostique au niveau app, pas source par source.") else: lines.append("- Tous les autres connecteurs de l'app sont sains: la panne est isolée à cette source.") return "\n".join(lines) def mission_history(service: str, source: str) -> str: """Résumé des missions précédentes sur ce connecteur (éviter de refaire ce qui a échoué).""" with db() as c: rows = c.execute( "SELECT m.started, m.verdict, m.commits FROM missions m WHERE m.service=? AND m.source=? " "AND m.state!='running' ORDER BY m.started DESC LIMIT 3", (service, source)).fetchall() if not rows: return "- aucune: c'est la première intervention sur ce connecteur." out = [] for r in rows: v = json.loads(r["verdict"] or "{}") out.append(f"- {time.strftime('%Y-%m-%d %H:%M', time.localtime(r['started']))}: " f"verdict={v.get('verdict', '?')} — {str(v.get('diagnostic', ''))[:200]} " f"(actions: {str(v.get('actions', ''))[:150]})") out.append("NE RÉPÈTE PAS une approche qui a déjà échoué ci-dessus: change d'angle.") return "\n".join(out) def build_prompt(service: str, source: str, health: dict[str, Any]) -> str: svc = SERVICES[service] sync_proc = next((p for p in svc["pm2"] if "sync" in p or "etl" in p), (svc["pm2"][-1] if svc["pm2"] else "?")) node_alias_ = node_alias(svc["node"]) return f"""Tu es {AGENT}, agent gardien autonome des connecteurs du Groupe KA. Mission: réparer le connecteur « {source} » de l'app {svc['app']} (service api-ka: {service}, site {svc.get('site', '')}). == CONTEXTE SANTÉ DU CONNECTEUR (supervision api-ka, scan aux 2 h) == - statut détecté: {health.get('status')} | échecs consécutifs: {health.get('consecutive_failures')} - dernier succès: {health.get('last_success')} | volume au dernier sync: {health.get('found_last')} (médiane historique: {health.get('median_found')}) - message: {health.get('message')} Rappel des statuts: broken = ≥3 syncs consécutifs en échec ou à 0 résultat; stale = aucun succès depuis > 2× la cadence attendue; degraded = volume < 50 % de la médiane. == SITUATION DE L'APP == {service_situation(service)} == INTERVENTIONS PRÉCÉDENTES SUR CE CONNECTEUR == {mission_history(service, source)} == TON ENVIRONNEMENT == Tu es sur le nœud {node_alias_}, ton répertoire courant est le repo de l'app: {svc['dir']} (source de vérité, développement remote-first). L'app tourne via pm2 ({', '.join(svc['pm2'])}); le process de synchronisation des connecteurs est {sync_proc}; site web local sur le port {svc['web_port']}. Le venv Python et/ou node_modules du repo sont déjà installés — utilise-les, jamais d'installation globale. == DÉMARCHE IMPOSÉE (dans l'ordre) == 1. IMPRÈGNE-TOI DU REPO: lis le CLAUDE.md et/ou README du repo s'ils existent, et surtout la doc du connecteur si elle existe: cherche `docs/connecteurs/` (fiches générées par app, souvent une par source: URL de la source, stratégie de fetch, format, pièges connus). `grep -ri "{source}"` pour localiser le code du connecteur, sa config et son éventuelle entrée de registre. 2. LIS LES LOGS: dossier logs/ du repo + `pm2 logs {sync_proc} --nostream --lines 300` — cherche les traces d'erreur de « {source} » (traceback, code HTTP, timeout). 3. REPRODUIS: exécute le connecteur ou son fetch directement (la plupart des apps ont un runner par source — la doc/le code du scheduler te montrera comment; sinon appelle la fonction de fetch dans un petit script via le venv du repo). Constate l'erreur réelle. 4. DIAGNOSTIQUE la cause racine: HTML/sélecteurs changés? endpoint JSON déplacé? 403/429 anti-bot? pagination cassée? redirection? certificat/TLS (gotcha connu: certains sites cassent avec requests → utiliser curl via subprocess)? sitemap figé? 5. CORRIGE de façon MINIMALE et ciblée, en respectant les conventions et l'architecture du repo (mêmes patterns que les connecteurs voisins). Si le site bloque, la pile d'escalade du Groupe KA est disponible, clés dans ~/.claude/.env: requêtes directes → curl → Scrapfly (SCRAPFLY_API_KEY, render_js/asp) → Bright Data Web Unlocker → proxies résidentiels Oxylabs (pr.oxylabs.io:7777, -cc-CA) → Serper/Tavily pour retrouver une source déplacée. Regarde comment le repo utilise déjà ces services et fais pareil. 6. RE-TESTE réellement: le connecteur doit rapporter un volume plausible (ordre de grandeur de la médiane {health.get('median_found')}). INTERDIT d'inventer, stubber ou câbler des données en dur. Un test qui retourne 0 ou 3 items quand la médiane est {health.get('median_found')} N'EST PAS une réparation. 7. REDÉMARRE uniquement le process concerné: `pm2 restart {sync_proc}`. Puis vérifie que le site répond: `curl -s -o /dev/null -w '%{{http_code}}' http://localhost:{svc['web_port']}/` (attendu: 200/3xx). 8. COMMITTE: `git add -A && git commit -m "[{AGENT}] fix connecteur {source}: "`. Si tu as touché plusieurs fichiers pour des raisons distinctes, fais des commits séparés et clairs. 9. POUSSE sur spbgit (le push fait partie du travail): `git push origin main`. NOTE: sur certains nœuds le push échoue en « no route to host » depuis une mission (macOS 26 Local Network Privacy) — c'est ATTENDU: le pousseur launchd (com.ka.pousseur) poussera tes commits dans les 5 minutes. Si le push échoue, signale-le simplement dans ton rapport final et passe à la suite; n'insiste pas et ne modifie JAMAIS ~/.ssh ni la config git pour « réparer » le push. == INTERDITS ABSOLUS == Toucher aux autres connecteurs ou aux autres apps du nœud; modifier la config pm2/ngrok/launchd ou le schéma de la base; installer des paquets globaux; supprimer des données; `git push --force` ou push d'une autre branche que main; toucher à ~/.ssh, aux clés API ou aux fichiers hors du repo. Ne désactive JAMAIS un connecteur pour « réparer » sa santé. Si la source est définitivement morte (site fermé, domaine à vendre, 404 permanent confirmé), ne force rien: documente et conclus en verdict site_source_mort. == FIN DE MISSION == Termine ta TOUTE DERNIÈRE réponse par un bloc JSON exactement de cette forme: {{"verdict": "repare|echec|site_source_mort|rien_a_faire", "diagnostic": "cause racine en 1-2 phrases", "actions": "ce que tu as fait", "test": "résultat mesuré du test final (volume obtenu)", "fichiers": ["fichiers modifiés"]}} Sois honnête: si ta réparation n'est pas prouvée par un test réel, le verdict est echec — un faux « repare » sera détecté par la supervision et rollback automatiquement.""" COMMON_RULES = """== RÈGLES COMMUNES (non négociables) == - Session 100 % AUTONOME: tu ne poses AUCUNE question, tu ne demandes AUCUNE validation, tu travailles jusqu'au bout. S'il faut trancher, tranche selon les conventions du repo et le bon sens, et documente ton choix dans le commit. - Respecte l'architecture et les patterns du repo (regarde comment les connecteurs voisins sont faits AVANT d'écrire). - Utilise le venv/node_modules du repo; jamais d'installation globale. - INTERDIT d'inventer, stubber ou câbler des données en dur. Chaque donnée vient d'un vrai fetch. - INTERDITS: toucher aux autres apps du nœud, config pm2/ngrok/launchd, schéma de la base (sauf migration prévue par le repo), `git push --force` ou push d'une autre branche que main, ~/.ssh, clés API, suppression de données. - À la fin: `pm2 restart `, vérifie que le site répond (curl localhost), committe en messages clairs préfixés [%AGENT%], puis POUSSE sur spbgit: `git push origin main`. Un échec « no route to host » est ATTENDU depuis une mission (macOS 26 LNP) — le pousseur launchd s'en chargera dans les 5 minutes: signale-le et passe à la suite, sans toucher à ~/.ssh ni à la config git.""" def effort_stack(service: str) -> str: svc = SERVICES[service] return f"""== BOÎTE À OUTILS (clés dans ~/.claude/.env) == - DÉCOUVERTE: Serper (SERPER_API_KEY, POST https://google.serper.dev/search, gl=ca hl=fr) pour trouver sources/sitemaps/endpoints; Tavily (TAVILY_API_KEY) en complément. - FETCH — escalade dans cet ordre: requêtes directes (requests/fetch du repo) → curl via subprocess (contourne les gotchas TLS de requests) → Scrapfly (SCRAPFLY_API_KEY, render_js/asp, backend=auto) → Bright Data Web Unlocker → proxies résidentiels Oxylabs (pr.oxylabs.io:7777, user avec -cc-CA, sessions collantes). - APIFY (APIFY_TOKEN): en dernier recours pour les plateformes dures, acteurs maison du compte (proxy résidentiel intégré). - Regarde d'abord comment le repo de {svc['app']} utilise déjà ces services et fais pareil.""" def build_effort_prompt(kind: str, service: str, source: str, note: str) -> str: svc = SERVICES[service] sync_proc = next((p for p in svc["pm2"] if "sync" in p or "etl" in p), (svc["pm2"][-1] if svc["pm2"] else "?")) node_alias_ = node_alias(svc["node"]) rules = COMMON_RULES.replace("%AGENT%", AGENT) env = f"""== TON ENVIRONNEMENT == Tu es sur le nœud {node_alias_}, répertoire courant = repo de l'app: {svc['dir']} (remote-first, source de vérité). L'app tourne via pm2 ({', '.join(svc['pm2'])}); process de sync: {sync_proc}; site local port {svc['web_port']}. Commence par lire le CLAUDE.md / README du repo et docs/connecteurs/ s'ils existent.""" if kind == "effort_degrade": block = LATEST["mine"].get(service) or {} degraded = [c for c in block.get("connectors", []) if c.get("status") == "degraded"][:25] deg_lines = "\n".join( f"- {c['source']}: dernier volume {c.get('found_last')} vs médiane {round(c.get('median_found') or 0)} " f"(dernier succès: {c.get('last_success')}, message: {c.get('message')})" for c in degraded) or "- (aucun connecteur dégradé au dernier scan — re-vérifie l'état réel dans l'app)" return f"""Tu es {AGENT}, agent gardien autonome du Groupe KA. EFFORT COMMANDÉ: INSPECTER LES CONNECTEURS DÉGRADÉS de l'app {svc['app']} (service {service}, site {svc.get('site', '')}). {"Consigne de l'opérateur: " + note if note else ""} Un connecteur « dégradé » livre encore des données, mais moins de 50 % de sa médiane historique — souvent le signe d'une pagination cassée, d'un filtre qui se resserre, d'une section du site source disparue ou d'un blocage partiel. == CONNECTEURS DÉGRADÉS AU DERNIER SCAN api-ka == {deg_lines} {env} == DÉMARCHE == 1. TRIE: pour chaque connecteur dégradé, regarde vite (logs + un fetch de contrôle) si la baisse est (a) réelle et réparable, (b) légitime (le site source a vraiment moins d'items — saison, inventaire réduit), ou (c) un blocage. 2. PRIORISE les cas (a) au plus fort potentiel de volume récupéré, et répare-les UN PAR UN: cause racine, correctif minimal, test réel avec volume mesuré avant/après. Commit séparé par connecteur réparé ([{AGENT}] fix connecteur : …). 3. Pour les cas (b), ne touche à rien: consigne-les dans ton rapport final comme « baisse légitime ». 4. Traite autant de connecteurs que ton budget de temps le permet, en gardant la qualité: mieux vaut 3 vraies réparations prouvées que 10 rustines. 5. À la fin: `pm2 restart {sync_proc}`, vérifie le site (port {svc['web_port']}), puis pousse tous tes commits sur spbgit: `git push origin main`. {effort_stack(service)} {rules} == FIN DE MISSION == Termine ta TOUTE DERNIÈRE réponse par un bloc JSON: {{"verdict": "livre|echec", "diagnostic": "portrait global des dégradés", "actions": "connecteurs réparés (avec volumes avant/après) / baisses légitimes / blocages", "test": "mesures", "fichiers": ["fichiers modifiés"]}}""" if kind == "effort_new": return f"""Tu es {AGENT}, agent gardien autonome du Groupe KA. EFFORT COMMANDÉ: ajouter UN NOUVEAU CONNECTEUR de qualité production à l'app {svc['app']} (service {service}, site {svc.get('site', '')}). {"Consigne de l'opérateur: " + note if note else "Aucune consigne particulière: choisis la source la plus utile."} {env} == DÉMARCHE == 1. CARTOGRAPHIE L'EXISTANT: liste les connecteurs actuels de l'app (registre/scheduler + docs/connecteurs) pour comprendre ce qui est déjà couvert et le pattern d'implémentation exact d'un connecteur (structure, signature, enregistrement, cache, dédup). 2. DÉCOUVRE avec Serper: cherche des sources québécoises pertinentes pour {svc['app']} NON couvertes (sitemaps, pages listant des items, endpoints JSON internes découverts via les pages). Évalue 3-5 candidats: volume estimé, faisabilité du fetch, qualité des données, stabilité. CHOISIS le meilleur. 3. IMPLÉMENTE le connecteur en suivant le pattern exact des connecteurs voisins: fetch (avec escalade si bloqué), parsing complet (tous les champs que l'app affiche, fiche détail incluse si le pattern le fait), dédup, enregistrement au registre/scheduler. 4. TESTE réellement: exécute-le au complet, il doit rapporter un volume substantiel et des items complets et corrects (vérifie 3-4 items à la main contre le site source). 5. DOCUMENTE: ajoute la fiche docs/connecteurs/.md si le repo suit cette convention (URL, stratégie, pièges). 6. Redémarre {sync_proc}, vérifie le site, committe et pousse sur spbgit (`git push origin main`). {effort_stack(service)} {rules} == FIN DE MISSION == Termine ta TOUTE DERNIÈRE réponse par un bloc JSON: {{"verdict": "livre|echec", "diagnostic": "source choisie et pourquoi", "actions": "ce qui a été construit", "test": "volume obtenu + vérifications", "fichiers": ["fichiers créés/modifiés"]}}""" return f"""Tu es {AGENT}, agent gardien autonome du Groupe KA. EFFORT COMMANDÉ: ENRICHIR le connecteur existant « {source} » de l'app {svc['app']} (service {service}, site {svc.get('site', '')}). {"Consigne de l'opérateur: " + note if note else "Aucune consigne particulière: maximise la valeur (couverture, champs, robustesse)."} {env} == DÉMARCHE == 1. ÉTUDIE le connecteur « {source} »: code, fiche docs/connecteurs, logs récents, volume actuel vs médiane, champs remplis vs champs que l'app sait afficher. 2. IDENTIFIE les enrichissements à plus forte valeur, par exemple: pagination complète (couvre TOUT le site source, pas la première page), fiche détail (champs manquants: descriptions, photos, prix, coordonnées, dates), robustesse (retries, escalade anti-bot, tolérance aux changements de DOM), fraîcheur (détection de retraits), dédup plus fine. 3. IMPLÉMENTE proprement dans le pattern du repo, SANS casser le format de sortie existant (les consommateurs avals dépendent du schéma). 4. TESTE réellement: exécute le connecteur au complet, compare avant/après (volume, complétude des champs), vérifie 3-4 items à la main. 5. METS À JOUR la fiche docs/connecteurs/.md si elle existe. 6. Redémarre {sync_proc}, vérifie le site, committe et pousse sur spbgit (`git push origin main`). {effort_stack(service)} {rules} == FIN DE MISSION == Termine ta TOUTE DERNIÈRE réponse par un bloc JSON: {{"verdict": "livre|echec", "diagnostic": "état initial constaté", "actions": "enrichissements livrés", "test": "avant/après mesuré", "fichiers": ["fichiers modifiés"]}}""" def build_site_down_prompt(service: str, detail: dict[str, Any]) -> str: svc = SERVICES[service] web_proc = svc["pm2"][0] if svc["pm2"] else "?" node_alias_ = node_alias(svc["node"]) return f"""Tu es {AGENT}, agent gardien autonome du Groupe KA. MISSION URGENTE: le SITE {svc.get('site', '')} de l'app {svc['app']} NE RÉPOND PLUS, et le redémarrage pm2 automatique n'a pas suffi. Investigue, trouve la cause racine et remets le site en ligne. Session 100 % AUTONOME: tu ne poses aucune question, tu travailles jusqu'au bout. == CE QUE LE VEILLEUR A DÉJÀ CONSTATÉ == - échecs consécutifs du check public: {detail.get('fails', '?')} (site muet depuis {detail.get('down_since', '?')}) - dernière erreur du check public: {detail.get('error', '?')} - redémarrage pm2 automatique: {detail.get('restart', 'tenté')} — santé locale mesurée juste après: {jdump(detail.get('health')) if detail.get('health') else 'inconnue'} == TON ENVIRONNEMENT == Tu es sur le nœud {node_alias_}, répertoire courant = repo de l'app: {svc['dir']} (remote-first, source de vérité). Process pm2 de l'app: {', '.join(svc['pm2'])} (web: {web_proc}); le site local doit répondre sur http://localhost:{svc['web_port']}/ et le site public est {svc.get('site', '')} (tunnel ngrok sur ce nœud, souvent un process pm2 `-ngrok` ou un service launchd). == DÉMARCHE IMPOSÉE (dans l'ordre) == 1. CONSTATE: `curl -s -o /dev/null -w '%{{http_code}}' http://localhost:{svc['web_port']}/` puis la même chose sur {svc.get('site', 'le site public')}. `pm2 jlist` / `pm2 describe {web_proc}`: statut, nombre de restarts, uptime, mémoire. 2. LIS LES LOGS: `pm2 logs {web_proc} --nostream --lines 300` (stdout ET err) + dossier logs/ du repo. Cherche: crash en boucle, port déjà occupé (EADDRINUSE), exception au démarrage, OOM, module manquant, build corrompu, base de données verrouillée/corrompue, disque plein (`df -h`). 3. DISTINGUE les 3 familles de panne: a. Process web mort ou en boucle de crash → cause racine dans le code/l'env → corrige puis `pm2 restart {web_proc}`. b. Process online mais localhost:{svc['web_port']} muet ou en erreur → mauvais port/bind, build cassé → rebuild si le repo a une étape de build (npm run build…), puis redémarre. c. localhost OK mais site public muet → le tunnel: trouve le process (pm2 `*-ngrok` de CETTE app ou launchd) et relance-le (`pm2 restart -ngrok` ou `launchctl kickstart -k gui/$(id -u)/