SPB Git

spb/ka-guardian

Public

veilleur de sites: surveillance up/down des sites publics par agent

- topology.json: service vraiprix (M3U96a:8090, site seulement) ajouté a ka6;
  politique du veilleur (intervalle 120s, 2 echecs, throttle restart 10 min,
  fenetres watching/cooldown courtes pour _site)
- orchestrator/main.py: site_engine — check du site public de chaque service,
  down confirme → pm2 restart auto via runner /restart, toujours down →
  incident site_down (priorite max) + mission Claude d investigation
  (build_site_down_prompt); current_status/_site, resolution auto au retour
  en ligne, garde anti-boucle abandonnes 24h; /api/state expose sites
- runner/runner.py: endpoint POST /restart (pm2 restart + healthcheck, sans git)
- app.js: libelle site_down; CLAUDE.md: doc du veilleur

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Aug 25, 2026) parent 5e3f030 Browse files

5 changed files +237 −9

modified CLAUDE.md +15 −2
@@ -1,9 +1,22 @@
1−# KA Guardian — ka2 · ka4 · ka6 (agents gardiens des connecteurs) 1+# KA Guardian — ka2 · ka4 · ka6 (agents gardiens des connecteurs ET des sites)
2 2
3 Un seul repo pour les **3 sites gardiens** : www.ka2.bot, www.ka4.bot, www.ka6.bot. 3 Un seul repo pour les **3 sites gardiens** : www.ka2.bot, www.ka4.bot, www.ka6.bot.
4 Chaque agent est une instance de `orchestrator/main.py` (env `AGENT=ka2|ka4|ka6`), 4 Chaque agent est une instance de `orchestrator/main.py` (env `AGENT=ka2|ka4|ka6`),
5 identité (accent, tagline, services surveillés) dans `topology.json`. 5 identité (accent, tagline, services surveillés) dans `topology.json`.
6 6
7+## Veilleur de sites (2026-08-25)
8+
9+En plus des connecteurs (scan api-ka), chaque agent surveille le **site public**
10+de chacun de ses services (`site_engine` dans main.py, source d'incident `_site`) :
11+
12+1. GET du `site` de chaque service aux `site_check_interval_seconds` (120 s) ; down = ≥500/timeout, confirmé par un 2ᵉ essai puis `site_check_fails` (2) cycles consécutifs.
13+2. Down confirmé → **pm2 restart automatique** via l'endpoint `/restart` du runner du nœud (throttle `site_restart_throttle_seconds`), re-check du site.
14+3. Toujours down → incident `site_down` (priorité max au dispatch) → **mission Claude d'investigation** (`build_site_down_prompt`) sur le nœud : logs pm2, cause racine (crash, port, build, DB, disque, tunnel ngrok), correctif minimal, commit+push.
15+4. Cycle standard ensuite : watching (fenêtre courte `site_watch_window_hours`), rollback si faux « repare », cooldown `site_cooldown_hours`, max 3 tentatives puis abandon (re-examen après `site_abandoned_retry_hours`). Les restarts automatiques continuent pendant les cooldowns.
16+
17+`vraiprix` (M3U96a, port 8090) est un service **site seulement** de ka6 : aucun
18+connecteur sous api-ka, seule la surveillance du site s'applique.
19+
7 ## Production (nœud M4M36) 20 ## Production (nœud M4M36)
8 21
9 - Répertoire : `~/cluster-projects/ka-guardian` (ce repo, origin = gitsrv/spbgit `ka-guardian.git`). 22 - Répertoire : `~/cluster-projects/ka-guardian` (ce repo, origin = gitsrv/spbgit `ka-guardian.git`).
@@ -17,7 +30,7 @@ identité (accent, tagline, services surveillés) dans `topology.json`.
17 2. Vérifier : `curl -sf localhost:8799/health` (8899, 8999) puis le site public. 30 2. Vérifier : `curl -sf localhost:8799/health` (8899, 8999) puis le site public.
18 3. `git add … && git commit && git push origin main` (origin = gitsrv/spbgit, PAS GitHub). 31 3. `git add … && git commit && git push origin main` (origin = gitsrv/spbgit, PAS GitHub).
19 32
20−⚠️ NE JAMAIS redémarrer les **runners** (`com.ka.guardian-runner` sur M3U96a/b, M4M64a/b) : ça tue la mission Claude en vol. Ils ne vivent pas sur ce nœud de toute façon. 33+⚠️ NE JAMAIS redémarrer les **runners** (`com.ka.guardian-runner` sur M3U96a/b, M4M64a/b) pendant qu'ils sont occupés : ça tue la mission Claude en vol. Pour redéployer `runner/runner.py`, vérifier d'abord `curl localhost:7791/health` (`busy:false`) sur chaque nœud, comme le fait `deploy/deploy.sh runners`.
21 ⚠️ Le trafic LAN direct est bloqué par macOS 26 (Local Network Privacy) — le courrier zsh `com.ka.guardian-courier` s'en occupe ; ne pas le contourner. 34 ⚠️ Le trafic LAN direct est bloqué par macOS 26 (Local Network Privacy) — le courrier zsh `com.ka.guardian-courier` s'en occupe ; ne pas le contourner.
22 35
23 ## Pages & image de marque 36 ## Pages & image de marque
modified orchestrator/main.py +178 −5
@@ -348,6 +348,42 @@ Termine ta TOUTE DERNIÈRE réponse par un bloc JSON:
348 {{"verdict": "livre|echec", "diagnostic": "état initial constaté", "actions": "enrichissements livrés", "test": "avant/après mesuré", "fichiers": ["fichiers modifiés"]}}""" 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 async def dispatch(incident: sqlite3.Row, health: dict[str, Any]) -> None: 387 async def dispatch(incident: sqlite3.Row, health: dict[str, Any]) -> None:
352 service, source, iid = incident["service"], incident["source"], incident["id"] 388 service, source, iid = incident["service"], incident["source"], incident["id"]
353 svc = SERVICES[service] 389 svc = SERVICES[service]
@@ -366,6 +402,9 @@ async def dispatch(incident: sqlite3.Row, health: dict[str, Any]) -> None:
366 timeout = min(timeout, int(health["max_minutes"]) * 60) 402 timeout = min(timeout, int(health["max_minutes"]) * 60)
367 if health.get("max_cost_usd"): 403 if health.get("max_cost_usd"):
368 max_cost = float(health["max_cost_usd"]) 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 else: 408 else:
370 prompt = build_prompt(service, source, health) 409 prompt = build_prompt(service, source, health)
371 max_turns, timeout = POLICY["mission_max_turns"], POLICY["mission_timeout_seconds"] 410 max_turns, timeout = POLICY["mission_max_turns"], POLICY["mission_timeout_seconds"]
@@ -518,8 +557,11 @@ async def tick() -> None:
518 asyncio.create_task(reconcile_mission(m["id"])) 557 asyncio.create_task(reconcile_mission(m["id"]))
519 558
520 # 2. Watching → rollback si la fenêtre est passée et toujours cassé 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 for inc in c.execute("SELECT * FROM incidents WHERE state='watching'").fetchall(): 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 continue 565 continue
524 status = current_status(inc["service"], inc["source"]) 566 status = current_status(inc["service"], inc["source"])
525 m = c.execute("SELECT * FROM missions WHERE incident_id=? AND base_commit IS NOT NULL " 567 m = c.execute("SELECT * FROM missions WHERE incident_id=? AND base_commit IS NOT NULL "
@@ -536,7 +578,8 @@ async def tick() -> None:
536 578
537 # 3. Cooldown expiré → réouverture ou abandon 579 # 3. Cooldown expiré → réouverture ou abandon
538 for inc in c.execute("SELECT * FROM incidents WHERE state='cooldown'").fetchall(): 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 continue 583 continue
541 status = current_status(inc["service"], inc["source"]) 584 status = current_status(inc["service"], inc["source"])
542 if status == "ok": 585 if status == "ok":
@@ -556,8 +599,8 @@ async def tick() -> None:
556 busy_nodes = {m["node"] for m in c.execute("SELECT node FROM missions WHERE state='running'").fetchall()} 599 busy_nodes = {m["node"] for m in c.execute("SELECT node FROM missions WHERE state='running'").fetchall()}
557 nxt = c.execute( 600 nxt = c.execute(
558 "SELECT * FROM incidents WHERE state='open' " 601 "SELECT * FROM incidents WHERE state='open' "
559− "ORDER BY CASE status_detected WHEN 'manual' THEN 0 WHEN 'effort_new' THEN 0 " 602+ "ORDER BY CASE status_detected WHEN 'site_down' THEN 0 WHEN 'manual' THEN 0 "
560− "WHEN 'effort_enrich' THEN 0 WHEN 'broken' THEN 1 ELSE 2 END, created " 603+ "WHEN 'effort_new' THEN 0 WHEN 'effort_enrich' THEN 0 WHEN 'broken' THEN 1 ELSE 2 END, created "
561 ).fetchall() 604 ).fetchall()
562 for inc in nxt: 605 for inc in nxt:
563 if inc["service"] not in SERVICES: 606 if inc["service"] not in SERVICES:
@@ -570,6 +613,11 @@ async def tick() -> None:
570 613
571 614
572 def current_status(service: str, source: str) -> str: 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 block = LATEST["mine"].get(service) or {} 621 block = LATEST["mine"].get(service) or {}
574 for conn in block.get("connectors", []): 622 for conn in block.get("connectors", []):
575 if conn["source"] == source: 623 if conn["source"] == source:
@@ -645,6 +693,130 @@ async def engine() -> None:
645 await asyncio.sleep(POLICY["poll_interval_seconds"]) 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 # ------------------------------------------------------------------- app --- 820 # ------------------------------------------------------------------- app ---
649 821
650 app = FastAPI(title=f"KA Guardian — {AGENT}", docs_url=None, redoc_url=None) 822 app = FastAPI(title=f"KA Guardian — {AGENT}", docs_url=None, redoc_url=None)
@@ -654,6 +826,7 @@ app = FastAPI(title=f"KA Guardian — {AGENT}", docs_url=None, redoc_url=None)
654 async def startup() -> None: 826 async def startup() -> None:
655 init_db() 827 init_db()
656 asyncio.create_task(engine()) 828 asyncio.create_task(engine())
829+ asyncio.create_task(site_engine())
657 830
658 831
659 def check_token(tok: str | None) -> None: 832 def check_token(tok: str | None) -> None:
@@ -767,7 +940,7 @@ def state() -> dict[str, Any]:
767 "services": {s: {**SERVICES[s], "node_alias": NODES[SERVICES[s]["node"]]["alias"]} for s in ME["services"]}, 940 "services": {s: {**SERVICES[s], "node_alias": NODES[SERVICES[s]["node"]]["alias"]} for s in ME["services"]},
768 "siblings": {a: {"domain": v["domain"], "accent": v["accent"], "services": v["services"]} 941 "siblings": {a: {"domain": v["domain"], "accent": v["accent"], "services": v["services"]}
769 for a, v in TOPO["agents"].items() if a != AGENT}, 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 "stats": dict(stats), "history": history, "policy": POLICY, "now": now(), 944 "stats": dict(stats), "history": history, "policy": POLICY, "now": now(),
772 } 945 }
773 946
modified orchestrator/web/app.js +1 −0
@@ -7,6 +7,7 @@ const STATE_FR = { open: "à réparer", dispatched: "envoi…", fixing: "répara
7 cooldown: "attente", resolved: "résolu", self_healed: "auto-guéri", abandoned: "abandonné" }; 7 cooldown: "attente", resolved: "résolu", self_healed: "auto-guéri", abandoned: "abandonné" };
8 const STATUSES = ["ok", "degraded", "broken", "stale"]; 8 const STATUSES = ["ok", "degraded", "broken", "stale"];
9 const STATUS_FR = { ok: "ok", degraded: "dégradé", broken: "cassé", stale: "endormi", manual: "manuel", 9 const STATUS_FR = { ok: "ok", degraded: "dégradé", broken: "cassé", stale: "endormi", manual: "manuel",
10+ site_down: "site hors ligne",
10 effort_new: "effort · nouveau connecteur", effort_enrich: "effort · enrichissement", 11 effort_new: "effort · nouveau connecteur", effort_enrich: "effort · enrichissement",
11 effort_degrade: "effort · inspection dégradés" }; 12 effort_degrade: "effort · inspection dégradés" };
12 const VERDICT_FR = { repare: "réparé", livre: "livré", echec: "échec", site_source_mort: "source morte", 13 const VERDICT_FR = { repare: "réparé", livre: "livré", echec: "échec", site_source_mort: "source morte",
modified runner/runner.py +22 −0
@@ -338,6 +338,28 @@ def mission_result(q: MissionResultIn, x_ka_token: str | None = Header(default=N
338 return {"status": "unknown"} 338 return {"status": "unknown"}
339 339
340 340
341+class RestartIn(BaseModel):
342+ pm2: list[str]
343+ web_port: int
344+
345+
346+@app.post("/restart")
347+def restart(r: RestartIn, x_ka_token: str | None = Header(default=None)) -> dict[str, Any]:
348+ """Relance pm2 d'une app dont le site public ne répond plus (veilleur de
349+ sites des orchestrateurs). Léger et sans git — le retour arrière complet
350+ reste /rollback. Pas de verrou mission: relancer les process d'une app
351+ pendant qu'une mission travaille sur une AUTRE app du nœud est sans danger.
352+ """
353+ check_token(x_ka_token)
354+ restarted: dict[str, bool] = {}
355+ for name in r.pm2:
356+ p = sh(f"pm2 restart {shlex.quote(name)} --update-env", timeout=180)
357+ restarted[name] = p.returncode == 0
358+ time.sleep(8)
359+ h = healthcheck(r.web_port, r.pm2)
360+ return {"ok": all(restarted.values()) and h["ok"], "restarted": restarted, "health": h, "node": NODE}
361+
362+
341 class RollbackIn(BaseModel): 363 class RollbackIn(BaseModel):
342 dir: str 364 dir: str
343 base_commit: str 365 base_commit: str
modified topology.json +21 −2
@@ -41,7 +41,8 @@
41 "services": [ 41 "services": [
42 "fabrika", 42 "fabrika",
43 "jobka", 43 "jobka",
44− "creaka" 44+ "creaka",
45+ "vraiprix"
45 ], 46 ],
46 "default_for_unknown_services": true, 47 "default_for_unknown_services": true,
47 "accent_soft": "#f0e8fc" 48 "accent_soft": "#f0e8fc"
@@ -164,6 +165,17 @@
164 ], 165 ],
165 "web_port": 8096, 166 "web_port": 8096,
166 "site": "https://www.job-ka.com" 167 "site": "https://www.job-ka.com"
168+ },
169+ "vraiprix": {
170+ "app": "Vrai·Prix",
171+ "node": "m3u96a",
172+ "dir": "~/apps/vrai-prix",
173+ "pm2": [
174+ "vrai-prix"
175+ ],
176+ "web_port": 8090,
177+ "site": "https://www.vrai-prix.com",
178+ "comment": "Pas de connecteurs sous supervision api-ka (vrai-prix-ingest tourne à la demande): surveillance du SITE seulement. vrai-prix-ngrok exclu de pm2[] pour ne pas relancer le tunnel à chaque restart."
167 } 179 }
168 }, 180 },
169 "policy": { 181 "policy": {
@@ -181,6 +193,13 @@
181 "effort_max_turns": 150, 193 "effort_max_turns": 150,
182 "effort_timeout_seconds": 7200, 194 "effort_timeout_seconds": 7200,
183 "abandoned_retry_hours": 168, 195 "abandoned_retry_hours": 168,
184− "mass_incident_threshold": 20 196+ "mass_incident_threshold": 20,
197+ "site_check_interval_seconds": 120,
198+ "site_check_fails": 2,
199+ "site_restart_throttle_seconds": 600,
200+ "site_restart_wait_seconds": 20,
201+ "site_watch_window_hours": 1,
202+ "site_cooldown_hours": 1,
203+ "site_abandoned_retry_hours": 24
185 } 204 }
186 } 205 }
187 206
Menu

Theme