| 348 |
348 |
{{"verdict": "livre|echec", "diagnostic": "état initial constaté", "actions": "enrichissements livrés", "test": "avant/après mesuré", "fichiers": ["fichiers modifiés"]}}""" |
| 349 |
349 |
|
| 350 |
350 |
|
|
351 |
+def build_site_down_prompt(service: str, detail: dict[str, Any]) -> str: |
|
352 |
+ svc = SERVICES[service] |
|
353 |
+ web_proc = svc["pm2"][0] |
|
354 |
+ node_alias = NODES[svc["node"]]["alias"] |
|
355 |
+ 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. |
|
356 |
+Session 100 % AUTONOME: tu ne poses aucune question, tu travailles jusqu'au bout. |
|
357 |
+ |
|
358 |
+== CE QUE LE VEILLEUR A DÉJÀ CONSTATÉ == |
|
359 |
+- échecs consécutifs du check public: {detail.get('fails', '?')} (site muet depuis {detail.get('down_since', '?')}) |
|
360 |
+- dernière erreur du check public: {detail.get('error', '?')} |
|
361 |
+- 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'} |
|
362 |
+ |
|
363 |
+== TON ENVIRONNEMENT == |
|
364 |
+Tu es sur le nœud {node_alias}, répertoire courant = repo de l'app: {svc['dir']} (remote-first, source de vérité). |
|
365 |
+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 `<app>-ngrok` ou un service launchd). |
|
366 |
+ |
|
367 |
+== DÉMARCHE IMPOSÉE (dans l'ordre) == |
|
368 |
+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. |
|
369 |
+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`). |
|
370 |
+3. DISTINGUE les 3 familles de panne: |
|
371 |
+ a. Process web mort ou en boucle de crash → cause racine dans le code/l'env → corrige puis `pm2 restart {web_proc}`. |
|
372 |
+ 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. |
|
373 |
+ c. localhost OK mais site public muet → le tunnel: trouve le process (pm2 `*-ngrok` de CETTE app ou launchd) et relance-le (`pm2 restart <app>-ngrok` ou `launchctl kickstart -k gui/$(id -u)/<label>`). NE MODIFIE JAMAIS sa config ni son domaine. |
|
374 |
+4. CORRIGE la cause racine de façon minimale (pas de rustine qui masque le symptôme). Si des données/DB sont corrompues, répare prudemment SANS perte de données (copie de sauvegarde avant toute opération risquée). |
|
375 |
+5. VÉRIFIE pour de vrai: localhost ET le site public doivent répondre 200/3xx de façon STABLE (2 mesures espacées de 30 s). |
|
376 |
+6. Si tu as modifié du code ou de la config du repo: `git add -A && git commit -m "[{AGENT}] fix site down {service}: <cause>"` puis `git push origin main`. Un échec « no route to host » au push est ATTENDU (macOS 26 LNP) — le pousseur launchd s'en charge dans les 5 minutes: signale-le et passe à la suite, sans toucher à ~/.ssh ni à la config git. |
|
377 |
+ |
|
378 |
+== INTERDITS ABSOLUS == |
|
379 |
+Toucher aux autres apps du nœud; MODIFIER la config pm2/ngrok/launchd (relancer un process existant de CETTE app est permis, changer sa config non); supprimer des données; `git push --force` ou push d'une autre branche que main; ~/.ssh; clés API. Si la panne dépasse l'app (disque plein système, nœud malade), libère de l'espace UNIQUEMENT dans le repo de l'app (logs, caches, artefacts de build) et documente le reste dans ton diagnostic. |
|
380 |
+ |
|
381 |
+== FIN DE MISSION == |
|
382 |
+Termine ta TOUTE DERNIÈRE réponse par un bloc JSON exactement de cette forme: |
|
383 |
+{{"verdict": "repare|echec|rien_a_faire", "diagnostic": "cause racine en 1-2 phrases", "actions": "ce que tu as fait", "test": "codes HTTP local + public mesurés", "fichiers": ["fichiers modifiés"]}} |
|
384 |
+Sois honnête: « repare » exige que le SITE PUBLIC réponde réellement — le veilleur re-vérifie aux 2 minutes et un faux « repare » sera rollback.""" |
|
385 |
+ |
|
386 |
+ |
| 351 |
387 |
async def dispatch(incident: sqlite3.Row, health: dict[str, Any]) -> None: |
| 352 |
388 |
service, source, iid = incident["service"], incident["source"], incident["id"] |
| 353 |
389 |
svc = SERVICES[service] |
| 366 |
402 |
timeout = min(timeout, int(health["max_minutes"]) * 60) |
| 367 |
403 |
if health.get("max_cost_usd"): |
| 368 |
404 |
max_cost = float(health["max_cost_usd"]) |
|
405 |
+ elif kind == "site_down": |
|
406 |
+ prompt = build_site_down_prompt(service, health) |
|
407 |
+ max_turns, timeout = POLICY["mission_max_turns"], POLICY["mission_timeout_seconds"] |
| 369 |
408 |
else: |
| 370 |
409 |
prompt = build_prompt(service, source, health) |
| 371 |
410 |
max_turns, timeout = POLICY["mission_max_turns"], POLICY["mission_timeout_seconds"] |
| 518 |
557 |
asyncio.create_task(reconcile_mission(m["id"])) |
| 519 |
558 |
|
| 520 |
559 |
# 2. Watching → rollback si la fenêtre est passée et toujours cassé |
|
560 |
+ # (fenêtre courte pour les incidents de site: le veilleur mesure aux |
|
561 |
+ # 2 minutes, pas besoin d'attendre le rythme des scans api-ka) |
| 521 |
562 |
for inc in c.execute("SELECT * FROM incidents WHERE state='watching'").fetchall(): |
| 522 |
|
− if now() - inc["updated"] < POLICY["watch_window_hours"] * 3600: |
|
563 |
+ window_h = POLICY.get("site_watch_window_hours", 1) if inc["source"] == "_site" else POLICY["watch_window_hours"] |
|
564 |
+ if now() - inc["updated"] < window_h * 3600: |
| 523 |
565 |
continue |
| 524 |
566 |
status = current_status(inc["service"], inc["source"]) |
| 525 |
567 |
m = c.execute("SELECT * FROM missions WHERE incident_id=? AND base_commit IS NOT NULL " |
| 536 |
578 |
|
| 537 |
579 |
# 3. Cooldown expiré → réouverture ou abandon |
| 538 |
580 |
for inc in c.execute("SELECT * FROM incidents WHERE state='cooldown'").fetchall(): |
| 539 |
|
− if now() - inc["updated"] < POLICY["attempt_cooldown_hours"] * 3600: |
|
581 |
+ cool_h = POLICY.get("site_cooldown_hours", 1) if inc["source"] == "_site" else POLICY["attempt_cooldown_hours"] |
|
582 |
+ if now() - inc["updated"] < cool_h * 3600: |
| 540 |
583 |
continue |
| 541 |
584 |
status = current_status(inc["service"], inc["source"]) |
| 542 |
585 |
if status == "ok": |
| 556 |
599 |
busy_nodes = {m["node"] for m in c.execute("SELECT node FROM missions WHERE state='running'").fetchall()} |
| 557 |
600 |
nxt = c.execute( |
| 558 |
601 |
"SELECT * FROM incidents WHERE state='open' " |
| 559 |
|
− "ORDER BY CASE status_detected WHEN 'manual' THEN 0 WHEN 'effort_new' THEN 0 " |
| 560 |
|
− "WHEN 'effort_enrich' THEN 0 WHEN 'broken' THEN 1 ELSE 2 END, created " |
|
602 |
+ "ORDER BY CASE status_detected WHEN 'site_down' THEN 0 WHEN 'manual' THEN 0 " |
|
603 |
+ "WHEN 'effort_new' THEN 0 WHEN 'effort_enrich' THEN 0 WHEN 'broken' THEN 1 ELSE 2 END, created " |
| 561 |
604 |
).fetchall() |
| 562 |
605 |
for inc in nxt: |
| 563 |
606 |
if inc["service"] not in SERVICES: |
| 570 |
613 |
|
| 571 |
614 |
|
| 572 |
615 |
def current_status(service: str, source: str) -> str: |
|
616 |
+ if source == "_site": |
|
617 |
+ st = SITE.get(service) or {} |
|
618 |
+ if not st.get("checked"): |
|
619 |
+ return "inconnu" |
|
620 |
+ return "ok" if st.get("ok") else "broken" |
| 573 |
621 |
block = LATEST["mine"].get(service) or {} |
| 574 |
622 |
for conn in block.get("connectors", []): |
| 575 |
623 |
if conn["source"] == source: |
| 645 |
693 |
await asyncio.sleep(POLICY["poll_interval_seconds"]) |
| 646 |
694 |
|
| 647 |
695 |
|
|
696 |
+# ----------------------------------------------------- veilleur de sites ---- |
|
697 |
+# Surveillance du SITE WEB de chaque service assigné (indépendante des |
|
698 |
+# connecteurs api-ka). Down confirmé → pm2 restart automatique via le runner |
|
699 |
+# du nœud; toujours down → incident « site_down » et mission d'investigation. |
|
700 |
+ |
|
701 |
+SITE: dict[str, dict[str, Any]] = {} # service → état du dernier check public |
|
702 |
+ |
|
703 |
+ |
|
704 |
+async def check_site(url: str) -> tuple[bool, str]: |
|
705 |
+ try: |
|
706 |
+ async with httpx.AsyncClient(follow_redirects=True, timeout=15) as cl: |
|
707 |
+ r = await cl.get(url, headers={"User-Agent": f"ka-guardian-{AGENT}/1.0"}) |
|
708 |
+ return r.status_code < 500, f"HTTP {r.status_code}" |
|
709 |
+ except Exception as exc: |
|
710 |
+ return False, f"{type(exc).__name__}: {exc}" |
|
711 |
+ |
|
712 |
+ |
|
713 |
+async def runner_restart(service: str) -> dict[str, Any]: |
|
714 |
+ """pm2 restart de l'app via le runner de son nœud (endpoint /restart).""" |
|
715 |
+ svc = SERVICES[service] |
|
716 |
+ ip = NODES[svc["node"]]["lan_ip"] |
|
717 |
+ try: |
|
718 |
+ code, body = await lan_post(ip, RUNNER_PORT, "/restart", |
|
719 |
+ {"pm2": svc["pm2"], "web_port": svc["web_port"]}, timeout=240) |
|
720 |
+ return json.loads(body) if code == 200 else {"ok": False, "erreur": f"runner {code}: {body[:200]}"} |
|
721 |
+ except Exception as exc: |
|
722 |
+ return {"ok": False, "erreur": f"{type(exc).__name__}: {exc}"} |
|
723 |
+ |
|
724 |
+ |
|
725 |
+async def site_check_service(service: str) -> None: |
|
726 |
+ svc = SERVICES[service] |
|
727 |
+ url = svc.get("site") |
|
728 |
+ if not url: |
|
729 |
+ return |
|
730 |
+ up, info = await check_site(url) |
|
731 |
+ if not up: # contre-mesure avant de compter l'échec (blip réseau/ngrok) |
|
732 |
+ await asyncio.sleep(5) |
|
733 |
+ up, info = await check_site(url) |
|
734 |
+ st = SITE.setdefault(service, {"fails": 0, "ok": True, "down_since": None, "last_restart": 0.0}) |
|
735 |
+ st.update({"checked": now(), "ok": up, "info": info}) |
|
736 |
+ if up: |
|
737 |
+ if st["fails"]: |
|
738 |
+ hub.publish_sync({"kind": "log", "level": "info", |
|
739 |
+ "msg": f"site {url} de retour en ligne ({info})", "ts": now()}) |
|
740 |
+ st["fails"], st["down_since"] = 0, None |
|
741 |
+ with db() as c: |
|
742 |
+ inc = active_incident(c, service, "_site") |
|
743 |
+ if inc and inc["state"] in ("open", "cooldown"): |
|
744 |
+ set_incident(c, inc["id"], state="self_healed", resolved=now()) |
|
745 |
+ incident_event(inc["id"], service, "_site", "self_healed", f"site de retour en ligne ({info})") |
|
746 |
+ return |
|
747 |
+ st["fails"] += 1 |
|
748 |
+ st["down_since"] = st["down_since"] or now() |
|
749 |
+ need = POLICY.get("site_check_fails", 2) |
|
750 |
+ hub.publish_sync({"kind": "log", "level": "warn", |
|
751 |
+ "msg": f"site {url} muet ({info}) — échec {st['fails']}/{need}", "ts": now()}) |
|
752 |
+ if st["fails"] < need: |
|
753 |
+ return |
|
754 |
+ with db() as c: |
|
755 |
+ inc = active_incident(c, service, "_site") |
|
756 |
+ if inc and inc["state"] in ("dispatched", "fixing"): |
|
757 |
+ return # une mission d'investigation travaille déjà sur cette app |
|
758 |
+ # Étape 1: pm2 restart automatique (throttlé pour ne pas marteler l'app) |
|
759 |
+ restart_res: dict[str, Any] = {"ok": False, "erreur": "non tenté (throttle)"} |
|
760 |
+ if now() - st["last_restart"] >= POLICY.get("site_restart_throttle_seconds", 600): |
|
761 |
+ st["last_restart"] = now() |
|
762 |
+ hub.publish_sync({"kind": "log", "level": "warn", |
|
763 |
+ "msg": f"{svc['app']}: site down confirmé → pm2 restart automatique ({', '.join(svc['pm2'])})", |
|
764 |
+ "ts": now()}) |
|
765 |
+ restart_res = await runner_restart(service) |
|
766 |
+ await asyncio.sleep(POLICY.get("site_restart_wait_seconds", 20)) |
|
767 |
+ up, info = await check_site(url) |
|
768 |
+ if up: |
|
769 |
+ st.update({"ok": True, "fails": 0, "down_since": None, "info": info}) |
|
770 |
+ with db() as c: |
|
771 |
+ if inc: |
|
772 |
+ set_incident(c, inc["id"], state="resolved", resolved=now()) |
|
773 |
+ incident_event(inc["id"], service, "_site", "resolved", f"site rétabli par pm2 restart ({info})") |
|
774 |
+ else: |
|
775 |
+ # Trace au dashboard: le restart automatique a suffi. |
|
776 |
+ iid = uuid.uuid4().hex[:10] |
|
777 |
+ c.execute("INSERT INTO incidents(id,service,source,status_detected,state,created,updated,resolved,detail) " |
|
778 |
+ "VALUES(?,?,?,?,?,?,?,?,?)", |
|
779 |
+ (iid, service, "_site", "site_down", "resolved", now(), now(), now(), |
|
780 |
+ jdump({"note": "rétabli par pm2 restart automatique", "restart": restart_res}))) |
|
781 |
+ incident_event(iid, service, "_site", "resolved", f"site rétabli par pm2 restart automatique ({info})") |
|
782 |
+ return |
|
783 |
+ # Étape 2: toujours down → incident + mission d'investigation (si aucun actif) |
|
784 |
+ if inc is not None: |
|
785 |
+ return # open/watching: la machinerie d'incidents suit déjà son cours |
|
786 |
+ with db() as c: |
|
787 |
+ # Garde anti-boucle (même logique que les connecteurs, délai plus court: |
|
788 |
+ # un site down est urgent, on re-tente quand même chaque jour). |
|
789 |
+ ab = c.execute("SELECT updated FROM incidents WHERE service=? AND source='_site' " |
|
790 |
+ "AND state='abandoned' ORDER BY updated DESC LIMIT 1", (service,)).fetchone() |
|
791 |
+ if ab and now() - ab["updated"] < POLICY.get("site_abandoned_retry_hours", 24) * 3600: |
|
792 |
+ return |
|
793 |
+ detail = {"fails": st["fails"], |
|
794 |
+ "down_since": time.strftime("%Y-%m-%d %H:%M", time.localtime(st["down_since"])), |
|
795 |
+ "error": info, |
|
796 |
+ "restart": "pm2 restart exécuté, site toujours muet" if restart_res.get("ok") |
|
797 |
+ else f"restart en échec: {restart_res.get('erreur') or restart_res}", |
|
798 |
+ "health": restart_res.get("health")} |
|
799 |
+ iid = uuid.uuid4().hex[:10] |
|
800 |
+ c.execute("INSERT INTO incidents(id,service,source,status_detected,state,created,updated,detail) " |
|
801 |
+ "VALUES(?,?,?,?,?,?,?,?)", |
|
802 |
+ (iid, service, "_site", "site_down", "open", now(), now(), jdump(detail))) |
|
803 |
+ incident_event(iid, service, "_site", "open", |
|
804 |
+ f"site hors ligne malgré le pm2 restart automatique ({info}) — mission d'investigation demandée") |
|
805 |
+ |
|
806 |
+ |
|
807 |
+async def site_engine() -> None: |
|
808 |
+ await asyncio.sleep(10) |
|
809 |
+ while True: |
|
810 |
+ if not LATEST["paused"]: |
|
811 |
+ for service in sorted(MY_SERVICES & set(SERVICES)): |
|
812 |
+ try: |
|
813 |
+ await site_check_service(service) |
|
814 |
+ except Exception as exc: |
|
815 |
+ hub.publish_sync({"kind": "log", "level": "warn", |
|
816 |
+ "msg": f"veilleur site {service}: {exc}", "ts": now()}) |
|
817 |
+ await asyncio.sleep(POLICY.get("site_check_interval_seconds", 120)) |
|
818 |
+ |
|
819 |
+ |
| 648 |
820 |
# ------------------------------------------------------------------- app --- |
| 649 |
821 |
|
| 650 |
822 |
app = FastAPI(title=f"KA Guardian — {AGENT}", docs_url=None, redoc_url=None) |
| 654 |
826 |
async def startup() -> None: |
| 655 |
827 |
init_db() |
| 656 |
828 |
asyncio.create_task(engine()) |
|
829 |
+ asyncio.create_task(site_engine()) |
| 657 |
830 |
|
| 658 |
831 |
|
| 659 |
832 |
def check_token(tok: str | None) -> None: |
| 767 |
940 |
"services": {s: {**SERVICES[s], "node_alias": NODES[SERVICES[s]["node"]]["alias"]} for s in ME["services"]}, |
| 768 |
941 |
"siblings": {a: {"domain": v["domain"], "accent": v["accent"], "services": v["services"]} |
| 769 |
942 |
for a, v in TOPO["agents"].items() if a != AGENT}, |
| 770 |
|
− "latest": LATEST, "incidents": incidents, "missions": missions, |
|
943 |
+ "latest": LATEST, "sites": SITE, "incidents": incidents, "missions": missions, |
| 771 |
944 |
"stats": dict(stats), "history": history, "policy": POLICY, "now": now(), |
| 772 |
945 |
} |
| 773 |
946 |
|