SPB Git

spb/ka-guardian

Public

Robustesse: réconciliation des missions fantômes (runner /mission-result + final persisté), retries courrier 3x, deploy refuse un runner occupé, transfert tar-over-ssh

Cause racine du blocage 2026-08-23 14h: kickstart des runners (rotation jeton) a tué la mission claude en vol; l'événement final n'existait pas et la file restait bloquée jusqu'au délai zombie.

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

35 changed files +2,915 −385

35 changed files
added M4M36luster-projects/ka-guardian/.gitignore +4 −0
@@ -0,0 +1,4 @@
1 +__pycache__/
2 +data/
3 +.venv/
4 +*.log
added M4M36luster-projects/ka-guardian/M4M36luster-projects/ka-guardian/M4M36luster-projects/ka-guardian/M4M36luster-projects/ka-guardian/README.md +72 −0
@@ -0,0 +1,72 @@
1 +# KA Guardian — ka2 · ka4 · ka6
2 +
3 +Les trois agents gardiens autonomes du Groupe KA. Depuis le 2026-08-23, ka2/ka4/ka6
4 +ne sont plus des bots de cartographie web : ce sont des **agents de maintenance
5 +autonomes des connecteurs** des plateformes ·Ka, bâtis sur Claude (CLI headless).
6 +
7 +## Ce qu'ils font
8 +
9 +1. **Détection** — toutes les 5 min, chaque agent lit la supervision centralisée
10 + d'api-ka (`/api/v1/monitoring/connectors`, ~2 600 connecteurs classés
11 + ok/degraded/broken/stale toutes les 2 h). Un connecteur `broken` ou `stale`
12 + de son périmètre → incident.
13 +2. **Mission** — l'agent dépêche une mission au **runner** du nœud qui héberge
14 + l'app : `claude -p` headless, lancé dans le repo de l'app, avec un prompt de
15 + mission strict (diagnostiquer, reproduire, corriger minimalement, re-tester,
16 + `pm2 restart` du process de sync, commit `[kaX] fix connecteur …`, jamais de push).
17 +3. **Surveillance** — après réparation déclarée, l'incident passe en `watching`
18 + pendant 8 h. Connecteur de retour à `ok` → résolu.
19 +4. **Rollback automatique** — si l'app tombe (healthcheck) ou si le connecteur
20 + est toujours cassé après la fenêtre : retour au commit d'avant mission
21 + (`git reset --hard`/revert) + `pm2 restart`. 3 tentatives max, puis `abandoned`
22 + (intervention humaine).
23 +5. **Vitrine live** — chaque site (www.ka2.bot / www.ka4.bot / www.ka6.bot) est
24 + une salle de contrôle : flux SSE en direct de chaque action de l'agent
25 + (outils, réflexions, verdicts), board d'incidents, couverture par service,
26 + historique des missions avec transcript et coût.
27 +
28 +## Répartition
29 +
30 +| Agent | Domaine | Services surveillés |
31 +|---|---|---|
32 +| ka2 | www.ka2.bot :8799 | louka, immoka, restoka |
33 +| ka4 | www.ka4.bot :8899 | autoka, foodka, sortika |
34 +| ka6 | www.ka6.bot :8999 | fabrika, jobka, creaka + tout nouveau service (défaut) |
35 +
36 +## Architecture
37 +
38 +- `orchestrator/` — un process FastAPI par agent (M4M36, launchd
39 + `com.kaX.guardian`), SQLite `data/kaX.db`, dashboard servi sur le même port,
40 + tunnels ngrok existants conservés.
41 +- `runner/` — un service par nœud d'app (M3U96a/b, M4M64a/b, port 7791, launchd
42 + `com.ka.guardian-runner`), 1 mission à la fois par nœud, transcripts dans
43 + `~/ka-guardian-runner/transcripts/`. Exécute `claude -p --output-format
44 + stream-json` et relaie chaque événement à l'orchestrateur (`/api/ingest`).
45 +- `topology.json` — source de vérité : agents, services (nœud/dir/pm2/port),
46 + IP LAN, politique (fenêtres, cooldowns, modèle).
47 +- Auth interne : token partagé `~/.ka-guardian.env` (orchestrateurs + runners),
48 + header `X-KA-Token`. Clé Anthropic + clés anti-bot : `~/.claude/.env` des nœuds.
49 +- Communication **par le LAN 192.168.2.x** (SSH/Tailscale inter-nœuds bloqué).
50 +
51 +## Déploiement
52 +
53 +```bash
54 +deploy/deploy.sh all # runners + orchestrateurs
55 +deploy/deploy.sh runners # seulement les 4 runners
56 +deploy/deploy.sh orchestrators
57 +```
58 +
59 +## Admin (token requis)
60 +
61 +```bash
62 +# mission manuelle sur un connecteur
63 +curl -X POST http://M4M36.maclustr.io:8799/api/admin/mission \
64 + -H "X-KA-Token: $TOKEN" -d '{"service":"louka","source":"kijiji"}'
65 +# pause / reprise d'un agent
66 +curl -X POST http://M4M36.maclustr.io:8799/api/admin/pause -H "X-KA-Token: $TOKEN" -d '{"paused":true}'
67 +```
68 +
69 +## Historique
70 +
71 +Les anciens bots (cartographie du web québécois / influenceurs) sont archivés :
72 +`~/ka-bots-backup-20260823.tar.gz` sur M4M36 (code + bases SQLite complètes).
added M4M36luster-projects/ka-guardian/M4M36luster-projects/ka-guardian/M4M36luster-projects/ka-guardian/M4M36luster-projects/ka-guardian/deploy/deploy.sh +105 −0
@@ -0,0 +1,105 @@
1 +#!/bin/zsh
2 +# ============================================
3 +# KA Guardian — déploiement complet depuis le laptop
4 +# ./deploy.sh runners → runners sur M3U96a M3U96b M4M64a M4M64b
5 +# ./deploy.sh orchestrators → ka2/ka4/ka6 sur M4M36 (remplace les anciens bots)
6 +# ./deploy.sh all
7 +# Idempotent. Le token partagé vit dans ~/.ka-guardian.env (laptop) et est
8 +# poussé sur chaque nœud. Les tunnels ngrok existants (www.kaX.bot) sont gardés.
9 +# ============================================
10 +set -e
11 +ROOT="$(cd "$(dirname "$0")/.." && pwd)"
12 +RUNNER_NODES=(M3U96a M3U96b M4M64a M4M64b)
13 +ORCH_NODE=M4M36
14 +PY=/opt/homebrew/bin/python3
15 +
16 +# --- token partagé
17 +TOKEN_FILE="$HOME/.ka-guardian.env"
18 +if [[ ! -f $TOKEN_FILE ]]; then
19 + echo "KA_GUARDIAN_TOKEN=$(openssl rand -hex 24)" > "$TOKEN_FILE"
20 + echo "token généré → $TOKEN_FILE"
21 +fi
22 +TOKEN_LINE=$(grep KA_GUARDIAN_TOKEN "$TOKEN_FILE")
23 +
24 +deploy_runners() {
25 + for n in $RUNNER_NODES; do
26 + echo "=== runner → $n"
27 + ssh $n "mkdir -p ~/ka-guardian-runner/transcripts"
28 + scp -q "$ROOT/runner/runner.py" $n:ka-guardian-runner/runner.py
29 + ssh $n "printf '%s\nKA_GUARDIAN_NODE=%s\n' '$TOKEN_LINE' '$n' > ~/.ka-guardian.env
30 + cd ~/ka-guardian-runner
31 + [[ -d .venv ]] || $PY -m venv .venv
32 + ./.venv/bin/pip -q install 'fastapi>=0.110' 'uvicorn>=0.29' 'httpx>=0.27' 'pydantic>=2' >/dev/null
33 + cat > ~/Library/LaunchAgents/com.ka.guardian-runner.plist <<'PLIST'
34 +<?xml version=\"1.0\" encoding=\"UTF-8\"?>
35 +<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">
36 +<plist version=\"1.0\"><dict>
37 + <key>Label</key><string>com.ka.guardian-runner</string>
38 + <key>ProgramArguments</key><array>
39 + <string>/Users/simon-pierreboucher/ka-guardian-runner/.venv/bin/python</string>
40 + <string>/Users/simon-pierreboucher/ka-guardian-runner/runner.py</string>
41 + </array>
42 + <key>EnvironmentVariables</key><dict>
43 + <key>PATH</key><string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
44 + </dict>
45 + <key>RunAtLoad</key><true/><key>KeepAlive</key><true/>
46 + <key>StandardOutPath</key><string>/Users/simon-pierreboucher/ka-guardian-runner/runner.log</string>
47 + <key>StandardErrorPath</key><string>/Users/simon-pierreboucher/ka-guardian-runner/runner.log</string>
48 +</dict></plist>
49 +PLIST
50 + launchctl unload ~/Library/LaunchAgents/com.ka.guardian-runner.plist 2>/dev/null || true
51 + launchctl load ~/Library/LaunchAgents/com.ka.guardian-runner.plist"
52 + sleep 2
53 + ssh $n "curl -sf localhost:7791/health" && echo " ✓ $n runner ok" || echo " ✗ $n runner KO"
54 + done
55 +}
56 +
57 +deploy_orchestrators() {
58 + echo "=== orchestrateurs → $ORCH_NODE"
59 + ssh $ORCH_NODE "printf '%s\n' '$TOKEN_LINE' > ~/.ka-guardian.env; mkdir -p ~/cluster-projects/ka-guardian"
60 + rsync -az --delete --exclude data --exclude .venv --exclude .git \
61 + "$ROOT/" $ORCH_NODE:cluster-projects/ka-guardian/
62 + ssh $ORCH_NODE "cd ~/cluster-projects/ka-guardian
63 + [[ -d .venv ]] || $PY -m venv .venv
64 + ./.venv/bin/pip -q install 'fastapi>=0.110' 'uvicorn>=0.29' 'httpx>=0.27' >/dev/null
65 + mkdir -p data logs
66 + # retirer les anciens bots (web+bot), garder les tunnels ngrok
67 + for a in ka2 ka4 ka6; do
68 + launchctl unload ~/Library/LaunchAgents/com.\$a.web.plist 2>/dev/null || true
69 + launchctl unload ~/Library/LaunchAgents/com.\$a.bot.plist 2>/dev/null || true
70 + rm -f ~/Library/LaunchAgents/com.\$a.web.plist ~/Library/LaunchAgents/com.\$a.bot.plist
71 + done"
72 + for a in ka2:8799 ka4:8899 ka6:8999; do
73 + agent=${a%%:*}; port=${a##*:}
74 + ssh $ORCH_NODE "cat > ~/Library/LaunchAgents/com.$agent.guardian.plist <<PLIST
75 +<?xml version=\"1.0\" encoding=\"UTF-8\"?>
76 +<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">
77 +<plist version=\"1.0\"><dict>
78 + <key>Label</key><string>com.$agent.guardian</string>
79 + <key>ProgramArguments</key><array>
80 + <string>/Users/simon-pierreboucher/cluster-projects/ka-guardian/.venv/bin/python</string>
81 + <string>/Users/simon-pierreboucher/cluster-projects/ka-guardian/orchestrator/main.py</string>
82 + </array>
83 + <key>EnvironmentVariables</key><dict>
84 + <key>AGENT</key><string>$agent</string>
85 + <key>KA_GUARDIAN_SELF_IP</key><string>192.168.2.69</string>
86 + <key>PATH</key><string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
87 + </dict>
88 + <key>RunAtLoad</key><true/><key>KeepAlive</key><true/>
89 + <key>StandardOutPath</key><string>/Users/simon-pierreboucher/cluster-projects/ka-guardian/logs/$agent.log</string>
90 + <key>StandardErrorPath</key><string>/Users/simon-pierreboucher/cluster-projects/ka-guardian/logs/$agent.log</string>
91 +</dict></plist>
92 +PLIST
93 + launchctl unload ~/Library/LaunchAgents/com.$agent.guardian.plist 2>/dev/null || true
94 + launchctl load ~/Library/LaunchAgents/com.$agent.guardian.plist"
95 + sleep 2
96 + ssh $ORCH_NODE "curl -sf localhost:$port/health" && echo " ✓ $agent ok (:$port)" || echo " ✗ $agent KO"
97 + done
98 +}
99 +
100 +case "${1:-all}" in
101 + runners) deploy_runners ;;
102 + orchestrators) deploy_orchestrators ;;
103 + all) deploy_runners; deploy_orchestrators ;;
104 +esac
105 +echo "terminé."
added M4M36luster-projects/ka-guardian/M4M36luster-projects/ka-guardian/M4M36luster-projects/ka-guardian/M4M36luster-projects/ka-guardian/orchestrator/main.py +560 −0
@@ -0,0 +1,560 @@
1 +# ============================================
2 +# Projet : KA Guardian
3 +# Fichier : orchestrator/main.py
4 +# Rôle : Cerveau d'un agent gardien (ka2/ka4/ka6) — surveille la santé
5 +# des connecteurs via api-ka, ouvre des incidents, dépêche des
6 +# missions claude aux runners des nœuds, surveille la guérison,
7 +# rollback automatique si ça empire. Sert aussi le dashboard live.
8 +# Author : Simon-Pierre Boucher
9 +# Date : 2026-08-23
10 +# ============================================
11 +"""Instance unique par agent: AGENT=ka2|ka4|ka6 (env), port depuis topology.json."""
12 +from __future__ import annotations
13 +
14 +import asyncio
15 +import json
16 +import os
17 +import pathlib
18 +import sqlite3
19 +import time
20 +import uuid
21 +from typing import Any
22 +
23 +import httpx
24 +from fastapi import FastAPI, Header, HTTPException, Request
25 +from fastapi.responses import FileResponse, StreamingResponse
26 +from fastapi.staticfiles import StaticFiles
27 +
28 +BASE = pathlib.Path(__file__).resolve().parent
29 +ROOT = BASE.parent
30 +HOME = pathlib.Path.home()
31 +
32 +AGENT = os.environ.get("AGENT", "ka2")
33 +TOPO = json.loads((ROOT / "topology.json").read_text())
34 +ME = TOPO["agents"][AGENT]
35 +POLICY = TOPO["policy"]
36 +SERVICES: dict[str, Any] = TOPO["services"]
37 +NODES: dict[str, Any] = TOPO["nodes"]
38 +MY_SERVICES: set[str] = set(ME["services"])
39 +RUNNER_PORT = TOPO["runner_port"]
40 +
41 +# ka6 (ou autre) ramasse les services non assignés qui apparaîtraient dans api-ka.
42 +DEFAULT_AGENT = ME.get("default_for_unknown_services", False)
43 +ASSIGNED = {s for a in TOPO["agents"].values() for s in a["services"]}
44 +
45 +
46 +def load_env_file(path: pathlib.Path) -> dict[str, str]:
47 + out: dict[str, str] = {}
48 + if path.exists():
49 + for line in path.read_text().splitlines():
50 + line = line.strip()
51 + if line and not line.startswith("#") and "=" in line:
52 + k, v = line.split("=", 1)
53 + out[k.strip()] = v.strip().strip('"').strip("'")
54 + return out
55 +
56 +
57 +TOKEN = load_env_file(HOME / ".ka-guardian.env").get("KA_GUARDIAN_TOKEN", "")
58 +DATA = ROOT / "data"
59 +DATA.mkdir(exist_ok=True)
60 +DB_PATH = DATA / f"{AGENT}.db"
61 +
62 +# ---------------------------------------------------------------- SQLite ---
63 +
64 +def db() -> sqlite3.Connection:
65 + conn = sqlite3.connect(DB_PATH)
66 + conn.row_factory = sqlite3.Row
67 + conn.execute("PRAGMA journal_mode=WAL")
68 + return conn
69 +
70 +
71 +def init_db() -> None:
72 + with db() as c:
73 + c.executescript("""
74 + CREATE TABLE IF NOT EXISTS incidents(
75 + id TEXT PRIMARY KEY, service TEXT, source TEXT, status_detected TEXT,
76 + state TEXT, attempts INTEGER DEFAULT 0, created REAL, updated REAL,
77 + resolved REAL, detail TEXT);
78 + CREATE TABLE IF NOT EXISTS missions(
79 + id TEXT PRIMARY KEY, incident_id TEXT, service TEXT, source TEXT,
80 + node TEXT, state TEXT, base_commit TEXT, commits TEXT, verdict TEXT,
81 + cost_usd REAL, num_turns INTEGER, health TEXT, started REAL, ended REAL);
82 + CREATE TABLE IF NOT EXISTS events(
83 + id INTEGER PRIMARY KEY AUTOINCREMENT, mission_id TEXT, ts REAL,
84 + type TEXT, data TEXT);
85 + CREATE TABLE IF NOT EXISTS snapshots(
86 + ts REAL PRIMARY KEY, mine TEXT, ecosystem TEXT);
87 + CREATE INDEX IF NOT EXISTS ev_mission ON events(mission_id);
88 + """)
89 +
90 +
91 +def now() -> float:
92 + return time.time()
93 +
94 +
95 +def jdump(x: Any) -> str:
96 + return json.dumps(x, ensure_ascii=False)
97 +
98 +
99 +# ------------------------------------------------------------------- SSE ---
100 +
101 +class Hub:
102 + def __init__(self) -> None:
103 + self.clients: set[asyncio.Queue] = set()
104 +
105 + async def publish(self, msg: dict[str, Any]) -> None:
106 + for q in list(self.clients):
107 + if q.qsize() < 500:
108 + q.put_nowait(msg)
109 +
110 + def publish_sync(self, msg: dict[str, Any]) -> None:
111 + if LOOP:
112 + asyncio.run_coroutine_threadsafe(self.publish(msg), LOOP)
113 +
114 +
115 +hub = Hub()
116 +LOOP: asyncio.AbstractEventLoop | None = None
117 +LATEST: dict[str, Any] = {"mine": {}, "ecosystem": {}, "polled": 0, "paused": False}
118 +
119 +# ------------------------------------------------------------- missions ----
120 +
121 +def active_incident(c: sqlite3.Connection, service: str, source: str) -> sqlite3.Row | None:
122 + return c.execute(
123 + "SELECT * FROM incidents WHERE service=? AND source=? AND state IN "
124 + "('open','dispatched','fixing','watching','cooldown') ORDER BY created DESC LIMIT 1",
125 + (service, source)).fetchone()
126 +
127 +
128 +def set_incident(c: sqlite3.Connection, iid: str, **kw: Any) -> None:
129 + kw["updated"] = now()
130 + keys = ",".join(f"{k}=?" for k in kw)
131 + c.execute(f"UPDATE incidents SET {keys} WHERE id=?", (*kw.values(), iid))
132 +
133 +
134 +def incident_event(iid: str, service: str, source: str, state: str, note: str = "") -> None:
135 + hub.publish_sync({"kind": "incident", "incident_id": iid, "service": service,
136 + "source": source, "state": state, "note": note, "ts": now()})
137 +
138 +
139 +def build_prompt(service: str, source: str, health: dict[str, Any]) -> str:
140 + svc = SERVICES[service]
141 + sync_proc = next((p for p in svc["pm2"] if "sync" in p or "etl" in p), svc["pm2"][-1])
142 + node_alias = NODES[svc["node"]]["alias"]
143 + return f"""Tu es {AGENT}, agent autonome de maintenance des connecteurs du Groupe KA. Mission: réparer le connecteur « {source} » de l'app {svc['app']} (service {service}).
144 +
145 +CONTEXTE SANTÉ (supervision api-ka):
146 +- statut détecté: {health.get('status')} | échecs consécutifs: {health.get('consecutive_failures')}
147 +- dernier succès: {health.get('last_success')} | volume dernier sync: {health.get('found_last')} (médiane: {health.get('median_found')})
148 +- message: {health.get('message')}
149 +
150 +TU ES SUR LE NŒUD {node_alias}, ton répertoire courant est le repo de l'app: {svc['dir']}.
151 +L'app tourne via pm2 ({', '.join(svc['pm2'])}), site web local sur le port {svc['web_port']}.
152 +
153 +DÉMARCHE IMPOSÉE:
154 +1. Localise le code du connecteur « {source} » (grep dans le repo). Lis ses logs récents (dossier logs/ du repo, `pm2 logs {sync_proc} --nostream --lines 200`).
155 +2. Reproduis le problème: exécute le connecteur ou son fetch directement (utilise le venv/node_modules du repo, jamais d'installation globale).
156 +3. Diagnostique la cause (HTML/sélecteurs changés? 403/429 anti-bot? API modifiée? URL morte? timeout?) et corrige de façon MINIMALE et ciblée. Si le site bloque, les clés anti-bot (SCRAPFLY, BRIGHTDATA, OXYLABS, SERPER) sont dans ~/.claude/.env — respecte la stratégie d'escalade déjà utilisée dans le repo.
157 +4. Re-teste le connecteur réellement: il doit rapporter un volume plausible (ordre de la médiane). INTERDIT d'inventer ou de stubber des données.
158 +5. Redémarre UNIQUEMENT le process concerné: `pm2 restart {sync_proc}`. Vérifie ensuite que le site répond: `curl -s -o /dev/null -w '%{{http_code}}' http://localhost:{svc['web_port']}/`.
159 +6. Committe ton travail: `git add -A && git commit -m "[{AGENT}] fix connecteur {source}: <résumé court>"`. NE PUSH JAMAIS.
160 +
161 +INTERDITS ABSOLUS: toucher aux autres connecteurs ou aux autres apps du nœud, modifier la config pm2/ngrok/launchd, installer des paquets globaux, supprimer des données, git push, toucher à ~/.ssh ou aux clés API. Si la source est définitivement morte (site fermé, 404 permanent), ne force rien: documente et conclus.
162 +
163 +FIN DE MISSION — termine ta toute dernière réponse par un bloc JSON exactement de cette forme:
164 +{{"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 du test final (volume obtenu)", "fichiers": ["fichiers modifiés"]}}"""
165 +
166 +
167 +async def dispatch(incident: sqlite3.Row, health: dict[str, Any]) -> None:
168 + service, source, iid = incident["service"], incident["source"], incident["id"]
169 + svc = SERVICES[service]
170 + node = svc["node"]
171 + ip = NODES[node]["lan_ip"]
172 + mid = uuid.uuid4().hex[:12]
173 + my_ip = os.environ.get("KA_GUARDIAN_SELF_IP", "192.168.2.69")
174 + payload = {
175 + "mission_id": mid, "agent": AGENT, "service": service, "source": source,
176 + "dir": svc["dir"], "pm2": svc["pm2"], "web_port": svc["web_port"],
177 + "model": ME.get("model", "sonnet"),
178 + "max_turns": POLICY["mission_max_turns"],
179 + "timeout_seconds": POLICY["mission_timeout_seconds"],
180 + "prompt": build_prompt(service, source, health),
181 + "callback_url": f"http://{my_ip}:{ME['port']}/api/ingest/{mid}",
182 + }
183 + try:
184 + async with httpx.AsyncClient() as cl:
185 + r = await cl.post(f"http://{ip}:{RUNNER_PORT}/missions", json=payload,
186 + headers={"X-KA-Token": TOKEN}, timeout=20)
187 + if r.status_code == 409:
188 + return # runner occupé, on retentera au prochain tick
189 + r.raise_for_status()
190 + except Exception as exc:
191 + with db() as c:
192 + set_incident(c, iid, detail=jdump({"erreur_dispatch": str(exc)}))
193 + return
194 + with db() as c:
195 + c.execute("INSERT INTO missions(id,incident_id,service,source,node,state,started) VALUES(?,?,?,?,?,?,?)",
196 + (mid, iid, service, source, node, "running", now()))
197 + set_incident(c, iid, state="fixing", attempts=incident["attempts"] + 1)
198 + incident_event(iid, service, source, "fixing", f"mission {mid} dépêchée sur {NODES[node]['alias']}")
199 + hub.publish_sync({"kind": "mission", "mission_id": mid, "service": service,
200 + "source": source, "state": "running", "ts": now()})
201 +
202 +
203 +async def rollback_mission(mission: sqlite3.Row, reason: str) -> dict[str, Any]:
204 + svc = SERVICES[mission["service"]]
205 + ip = NODES[mission["node"]]["lan_ip"]
206 + try:
207 + async with httpx.AsyncClient() as cl:
208 + r = await cl.post(f"http://{ip}:{RUNNER_PORT}/rollback",
209 + json={"dir": svc["dir"], "base_commit": mission["base_commit"],
210 + "pm2": svc["pm2"], "web_port": svc["web_port"]},
211 + headers={"X-KA-Token": TOKEN}, timeout=300)
212 + out = r.json()
213 + except Exception as exc:
214 + out = {"ok": False, "erreur": str(exc)}
215 + hub.publish_sync({"kind": "rollback", "mission_id": mission["id"], "service": mission["service"],
216 + "source": mission["source"], "reason": reason, "result": out, "ts": now()})
217 + with db() as c:
218 + c.execute("INSERT INTO events(mission_id,ts,type,data) VALUES(?,?,?,?)",
219 + (mission["id"], now(), "rollback", jdump({"reason": reason, "result": out})))
220 + return out
221 +
222 +
223 +# --------------------------------------------------------------- moteur ----
224 +
225 +async def poll_once() -> None:
226 + async with httpx.AsyncClient() as cl:
227 + r = await cl.get(TOPO["apika_monitoring_url"], timeout=15)
228 + data = r.json()["data"]
229 + mine: dict[str, Any] = {}
230 + for service, block in data["services"].items():
231 + assigned_to_me = service in MY_SERVICES or (DEFAULT_AGENT and service not in ASSIGNED)
232 + if assigned_to_me:
233 + mine[service] = block
234 + LATEST.update({"mine": mine, "ecosystem": data["summary"], "polled": now()})
235 + with db() as c:
236 + summary_mine = {s: b["summary"] for s, b in mine.items()}
237 + c.execute("INSERT OR REPLACE INTO snapshots(ts,mine,ecosystem) VALUES(?,?,?)",
238 + (now(), jdump(summary_mine), jdump(data["summary"])))
239 + c.execute("DELETE FROM snapshots WHERE ts < ?", (now() - 30 * 86400,))
240 + hub.publish_sync({"kind": "snapshot", "mine": {s: b["summary"] for s, b in mine.items()},
241 + "ecosystem": data["summary"], "ts": now()})
242 +
243 + trigger = set(POLICY["trigger_statuses"])
244 + with db() as c:
245 + for service, block in mine.items():
246 + if service not in SERVICES:
247 + continue # service inconnu de la topologie: visible au dashboard, pas d'action
248 + for conn in block.get("connectors", []):
249 + source, status = conn["source"], conn["status"]
250 + inc = active_incident(c, service, source)
251 + if status in trigger and inc is None:
252 + iid = uuid.uuid4().hex[:10]
253 + c.execute("INSERT INTO incidents(id,service,source,status_detected,state,created,updated,detail) "
254 + "VALUES(?,?,?,?,?,?,?,?)",
255 + (iid, service, source, status, "open", now(), now(), jdump(conn)))
256 + incident_event(iid, service, source, "open", f"détecté {status}")
257 + elif status in trigger and inc is not None and inc["state"] in ("open", "cooldown"):
258 + # Ne pas rafraîchir watching/fixing: `updated` sert de chrono
259 + # à la fenêtre de surveillance et au cooldown.
260 + c.execute("UPDATE incidents SET status_detected=?, detail=? WHERE id=?",
261 + (status, jdump(conn), inc["id"]))
262 + elif status == "ok" and inc is not None:
263 + if inc["state"] == "watching":
264 + set_incident(c, inc["id"], state="resolved", resolved=now())
265 + incident_event(inc["id"], service, source, "resolved", "connecteur de retour à ok — réparation confirmée")
266 + elif inc["state"] in ("open", "cooldown"):
267 + set_incident(c, inc["id"], state="self_healed", resolved=now())
268 + incident_event(inc["id"], service, source, "self_healed", "revenu à ok sans intervention")
269 +
270 +
271 +async def tick() -> None:
272 + if LATEST["paused"]:
273 + return
274 + try:
275 + await poll_once()
276 + except Exception as exc:
277 + hub.publish_sync({"kind": "log", "level": "warn", "msg": f"poll api-ka échoué: {exc}", "ts": now()})
278 +
279 + with db() as c:
280 + # 1. Missions zombies (runner mort / callback perdu)
281 + for m in c.execute("SELECT * FROM missions WHERE state='running' AND started < ?",
282 + (now() - POLICY["mission_timeout_seconds"] - 900,)).fetchall():
283 + c.execute("UPDATE missions SET state='error', ended=? WHERE id=?", (now(), m["id"]))
284 + inc = c.execute("SELECT * FROM incidents WHERE id=?", (m["incident_id"],)).fetchone()
285 + if inc:
286 + set_incident(c, inc["id"], state="cooldown")
287 + incident_event(inc["id"], m["service"], m["source"], "cooldown", "mission sans réponse (zombie)")
288 +
289 + # 2. Watching → rollback si la fenêtre est passée et toujours cassé
290 + for inc in c.execute("SELECT * FROM incidents WHERE state='watching'").fetchall():
291 + if now() - inc["updated"] < POLICY["watch_window_hours"] * 3600:
292 + continue
293 + status = current_status(inc["service"], inc["source"])
294 + m = c.execute("SELECT * FROM missions WHERE incident_id=? AND base_commit IS NOT NULL "
295 + "ORDER BY started DESC LIMIT 1", (inc["id"],)).fetchone()
296 + if status in POLICY["trigger_statuses"] or status == "degraded":
297 + if m and (json.loads(m["commits"] or "[]")):
298 + asyncio.create_task(rollback_mission(m, "toujours cassé après la fenêtre de surveillance"))
299 + set_incident(c, inc["id"], state="cooldown")
300 + incident_event(inc["id"], inc["service"], inc["source"], "cooldown",
301 + f"non guéri après {POLICY['watch_window_hours']}h → rollback + cooldown")
302 + elif status == "ok":
303 + set_incident(c, inc["id"], state="resolved", resolved=now())
304 + incident_event(inc["id"], inc["service"], inc["source"], "resolved", "confirmé ok")
305 +
306 + # 3. Cooldown expiré → réouverture ou abandon
307 + for inc in c.execute("SELECT * FROM incidents WHERE state='cooldown'").fetchall():
308 + if now() - inc["updated"] < POLICY["attempt_cooldown_hours"] * 3600:
309 + continue
310 + status = current_status(inc["service"], inc["source"])
311 + if status == "ok":
312 + set_incident(c, inc["id"], state="resolved", resolved=now())
313 + incident_event(inc["id"], inc["service"], inc["source"], "resolved", "guéri pendant le cooldown")
314 + elif inc["attempts"] >= POLICY["max_attempts_per_incident"]:
315 + set_incident(c, inc["id"], state="abandoned")
316 + incident_event(inc["id"], inc["service"], inc["source"], "abandoned",
317 + f"{inc['attempts']} tentatives épuisées — intervention humaine requise")
318 + else:
319 + set_incident(c, inc["id"], state="open")
320 + incident_event(inc["id"], inc["service"], inc["source"], "open", "cooldown terminé, nouvelle tentative")
321 +
322 + # 4. Dispatch (1 mission à la fois par agent, broken avant stale, plus vieux d'abord)
323 + running = c.execute("SELECT COUNT(*) n FROM missions WHERE state='running'").fetchone()["n"]
324 + if running < POLICY["max_concurrent_missions"]:
325 + busy_nodes = {m["node"] for m in c.execute("SELECT node FROM missions WHERE state='running'").fetchall()}
326 + nxt = c.execute(
327 + "SELECT * FROM incidents WHERE state='open' "
328 + "ORDER BY CASE status_detected WHEN 'broken' THEN 0 WHEN 'manual' THEN 0 ELSE 1 END, created "
329 + ).fetchall()
330 + for inc in nxt:
331 + if inc["service"] not in SERVICES:
332 + continue
333 + if SERVICES[inc["service"]]["node"] in busy_nodes:
334 + continue
335 + set_incident(c, inc["id"], state="dispatched")
336 + asyncio.create_task(dispatch_row(inc["id"]))
337 + break
338 +
339 +
340 +def current_status(service: str, source: str) -> str:
341 + block = LATEST["mine"].get(service) or {}
342 + for conn in block.get("connectors", []):
343 + if conn["source"] == source:
344 + return conn["status"]
345 + return "inconnu"
346 +
347 +
348 +async def dispatch_row(iid: str) -> None:
349 + with db() as c:
350 + inc = c.execute("SELECT * FROM incidents WHERE id=?", (iid,)).fetchone()
351 + if inc:
352 + await dispatch(inc, json.loads(inc["detail"] or "{}"))
353 + # dispatch() remet fixing; si l'appel a échoué/409, on relâche
354 + with db() as c:
355 + cur = c.execute("SELECT state FROM incidents WHERE id=?", (iid,)).fetchone()
356 + if cur and cur["state"] == "dispatched":
357 + set_incident(c, iid, state="open")
358 +
359 +
360 +async def engine() -> None:
361 + global LOOP
362 + LOOP = asyncio.get_running_loop()
363 + await asyncio.sleep(3)
364 + while True:
365 + try:
366 + await tick()
367 + except Exception as exc:
368 + hub.publish_sync({"kind": "log", "level": "error", "msg": f"tick: {exc}", "ts": now()})
369 + await asyncio.sleep(POLICY["poll_interval_seconds"])
370 +
371 +
372 +# ------------------------------------------------------------------- app ---
373 +
374 +app = FastAPI(title=f"KA Guardian — {AGENT}", docs_url=None, redoc_url=None)
375 +
376 +
377 +@app.on_event("startup")
378 +async def startup() -> None:
379 + init_db()
380 + asyncio.create_task(engine())
381 +
382 +
383 +def check_token(tok: str | None) -> None:
384 + if not TOKEN or tok != TOKEN:
385 + raise HTTPException(status_code=401)
386 +
387 +
388 +@app.post("/api/ingest/{mission_id}")
389 +async def ingest(mission_id: str, req: Request, x_ka_token: str | None = Header(default=None)) -> dict[str, Any]:
390 + check_token(x_ka_token)
391 + ev = await req.json()
392 + etype, data = ev.get("type"), ev.get("data", {})
393 + with db() as c:
394 + c.execute("INSERT INTO events(mission_id,ts,type,data) VALUES(?,?,?,?)",
395 + (mission_id, now(), etype, jdump(data)))
396 + m = c.execute("SELECT * FROM missions WHERE id=?", (mission_id,)).fetchone()
397 + if m:
398 + if etype == "start":
399 + c.execute("UPDATE missions SET base_commit=? WHERE id=?", (data.get("base_commit"), mission_id))
400 + elif etype in ("final", "error"):
401 + await finalize(c, m, etype, data)
402 + hub.publish_sync({"kind": "mission_event", "mission_id": mission_id, "type": etype,
403 + "data": data, "ts": now()})
404 + return {"ok": True}
405 +
406 +
407 +async def finalize(c: sqlite3.Connection, m: sqlite3.Row, etype: str, data: dict[str, Any]) -> None:
408 + inc = c.execute("SELECT * FROM incidents WHERE id=?", (m["incident_id"],)).fetchone()
409 + if etype == "error":
410 + c.execute("UPDATE missions SET state='error', ended=? WHERE id=?", (now(), m["id"]))
411 + if inc:
412 + set_incident(c, inc["id"], state="cooldown")
413 + incident_event(inc["id"], m["service"], m["source"], "cooldown", f"erreur mission: {data.get('error')}")
414 + return
415 + verdict = data.get("verdict", {})
416 + commits = data.get("commits", [])
417 + health = data.get("health", {})
418 + c.execute("UPDATE missions SET state='done', ended=?, commits=?, verdict=?, cost_usd=?, num_turns=?, health=?, base_commit=COALESCE(base_commit,?) WHERE id=?",
419 + (now(), jdump(commits), jdump(verdict), data.get("cost_usd"),
420 + data.get("num_turns"), jdump(health), data.get("base_commit"), m["id"]))
421 + m2 = c.execute("SELECT * FROM missions WHERE id=?", (m["id"],)).fetchone()
422 + v = verdict.get("verdict", "inconnu")
423 + if not inc:
424 + return
425 + if not health.get("ok", True):
426 + # L'app est tombée → rollback immédiat, quoi qu'ait dit l'agent.
427 + asyncio.create_task(rollback_mission(m2, "healthcheck app en échec post-mission"))
428 + set_incident(c, inc["id"], state="cooldown")
429 + incident_event(inc["id"], m["service"], m["source"], "cooldown", "app en mauvaise santé → ROLLBACK immédiat")
430 + elif v == "repare" and commits:
431 + set_incident(c, inc["id"], state="watching")
432 + incident_event(inc["id"], m["service"], m["source"], "watching",
433 + f"réparation déclarée ({len(commits)} commit) — surveillance {POLICY['watch_window_hours']}h")
434 + elif v in ("echec", "inconnu") and commits:
435 + asyncio.create_task(rollback_mission(m2, f"verdict {v} avec commits → annulation par prudence"))
436 + set_incident(c, inc["id"], state="cooldown")
437 + incident_event(inc["id"], m["service"], m["source"], "cooldown", f"verdict {v} → rollback préventif")
438 + elif v == "site_source_mort":
439 + set_incident(c, inc["id"], state="abandoned")
440 + incident_event(inc["id"], m["service"], m["source"], "abandoned", "source définitivement morte (diagnostic agent)")
441 + elif v in ("rien_a_faire", "repare"):
442 + set_incident(c, inc["id"], state="watching")
443 + incident_event(inc["id"], m["service"], m["source"], "watching", f"verdict {v} — surveillance")
444 + else:
445 + set_incident(c, inc["id"], state="cooldown")
446 + incident_event(inc["id"], m["service"], m["source"], "cooldown", f"verdict {v}")
447 +
448 +
449 +@app.get("/api/state")
450 +def state() -> dict[str, Any]:
451 + with db() as c:
452 + incidents = [dict(r) for r in c.execute(
453 + "SELECT * FROM incidents ORDER BY created DESC LIMIT 200").fetchall()]
454 + missions = [dict(r) for r in c.execute(
455 + "SELECT * FROM missions ORDER BY started DESC LIMIT 100").fetchall()]
456 + stats = c.execute("""SELECT
457 + (SELECT COUNT(*) FROM missions) n_missions,
458 + (SELECT COUNT(*) FROM incidents WHERE state='resolved') n_resolved,
459 + (SELECT COUNT(*) FROM incidents WHERE state IN ('open','dispatched','fixing','watching','cooldown')) n_active,
460 + (SELECT COUNT(*) FROM incidents WHERE state='abandoned') n_abandoned,
461 + (SELECT COUNT(*) FROM events WHERE type='rollback') n_rollbacks,
462 + (SELECT ROUND(SUM(cost_usd),2) FROM missions) cost_total,
463 + (SELECT ROUND(AVG(resolved-created)/3600.0,1) FROM incidents WHERE state='resolved' AND resolved IS NOT NULL) mttr_h
464 + """).fetchone()
465 + history = [dict(r) for r in c.execute(
466 + "SELECT ts, mine FROM snapshots WHERE ts > ? ORDER BY ts", (now() - 7 * 86400,)).fetchall()]
467 + return {
468 + "agent": AGENT, "identity": {k: ME[k] for k in ("domain", "accent", "accent2", "tagline", "model")},
469 + "services": {s: {**SERVICES[s], "node_alias": NODES[SERVICES[s]["node"]]["alias"]} for s in ME["services"]},
470 + "siblings": {a: {"domain": v["domain"], "accent": v["accent"], "services": v["services"]}
471 + for a, v in TOPO["agents"].items() if a != AGENT},
472 + "latest": LATEST, "incidents": incidents, "missions": missions,
473 + "stats": dict(stats), "history": history, "policy": POLICY, "now": now(),
474 + }
475 +
476 +
477 +@app.get("/api/missions/{mission_id}")
478 +def mission_detail(mission_id: str) -> dict[str, Any]:
479 + with db() as c:
480 + m = c.execute("SELECT * FROM missions WHERE id=?", (mission_id,)).fetchone()
481 + if not m:
482 + raise HTTPException(status_code=404)
483 + evs = [dict(r) for r in c.execute(
484 + "SELECT ts,type,data FROM events WHERE mission_id=? ORDER BY id", (mission_id,)).fetchall()]
485 + return {"mission": dict(m), "events": evs}
486 +
487 +
488 +@app.get("/events")
489 +async def sse(request: Request) -> StreamingResponse:
490 + q: asyncio.Queue = asyncio.Queue()
491 + hub.clients.add(q)
492 +
493 + async def gen():
494 + try:
495 + yield f"data: {jdump({'kind': 'hello', 'agent': AGENT, 'ts': now()})}\n\n"
496 + while True:
497 + if await request.is_disconnected():
498 + break
499 + try:
500 + msg = await asyncio.wait_for(q.get(), timeout=25)
501 + yield f"data: {jdump(msg)}\n\n"
502 + except asyncio.TimeoutError:
503 + yield ": keepalive\n\n"
504 + finally:
505 + hub.clients.discard(q)
506 +
507 + return StreamingResponse(gen(), media_type="text/event-stream",
508 + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
509 +
510 +
511 +@app.post("/api/admin/mission")
512 +async def admin_mission(req: Request, x_ka_token: str | None = Header(default=None)) -> dict[str, Any]:
513 + """Déclenche manuellement une mission sur un connecteur (même sain)."""
514 + check_token(x_ka_token)
515 + body = await req.json()
516 + service, source = body["service"], body["source"]
517 + if service not in SERVICES:
518 + raise HTTPException(status_code=400, detail="service inconnu")
519 + detail = {"source": source, "status": "manual", "message": body.get("note", "mission manuelle")}
520 + for conn in (LATEST["mine"].get(service) or {}).get("connectors", []):
521 + if conn["source"] == source:
522 + detail = {**conn, "status": "manual"}
523 + with db() as c:
524 + inc = active_incident(c, service, source)
525 + if inc is None:
526 + iid = uuid.uuid4().hex[:10]
527 + c.execute("INSERT INTO incidents(id,service,source,status_detected,state,created,updated,detail) "
528 + "VALUES(?,?,?,?,?,?,?,?)",
529 + (iid, service, source, "manual", "open", now(), now(), jdump(detail)))
530 + else:
531 + iid = inc["id"]
532 + set_incident(c, iid, state="open", status_detected="manual")
533 + incident_event(iid, service, source, "open", "mission manuelle demandée")
534 + return {"ok": True, "incident_id": iid}
535 +
536 +
537 +@app.post("/api/admin/pause")
538 +async def admin_pause(req: Request, x_ka_token: str | None = Header(default=None)) -> dict[str, Any]:
539 + check_token(x_ka_token)
540 + body = await req.json()
541 + LATEST["paused"] = bool(body.get("paused", True))
542 + return {"ok": True, "paused": LATEST["paused"]}
543 +
544 +
545 +@app.get("/health")
546 +def health() -> dict[str, Any]:
547 + return {"ok": True, "agent": AGENT, "polled": LATEST["polled"], "paused": LATEST["paused"]}
548 +
549 +
550 +@app.get("/")
551 +def index() -> FileResponse:
552 + return FileResponse(BASE / "web" / "index.html")
553 +
554 +
555 +app.mount("/static", StaticFiles(directory=BASE / "web"), name="static")
556 +
557 +
558 +if __name__ == "__main__":
559 + import uvicorn
560 + uvicorn.run(app, host="0.0.0.0", port=ME["port"])
added M4M36luster-projects/ka-guardian/M4M36luster-projects/ka-guardian/M4M36luster-projects/ka-guardian/M4M36luster-projects/ka-guardian/orchestrator/web/app.js +163 −0
@@ -0,0 +1,163 @@
1 +/* KA Guardian — dashboard live */
2 +const $ = (s) => document.querySelector(s);
3 +const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
4 +const fmtT = (ts) => new Date(ts * 1000).toLocaleTimeString("fr-CA", { hour12: false });
5 +const fmtD = (ts) => new Date(ts * 1000).toLocaleString("fr-CA", { dateStyle: "short", timeStyle: "short" });
6 +const STATE_FR = { open: "à réparer", dispatched: "envoi…", fixing: "réparation", watching: "surveillance",
7 + cooldown: "attente", resolved: "résolu", self_healed: "auto-guéri", abandoned: "abandonné" };
8 +const STATUSES = ["ok", "degraded", "broken", "stale"];
9 +const STATUS_FR = { ok: "ok", degraded: "dégradé", broken: "cassé", stale: "endormi" };
10 +
11 +let STATE = null;
12 +
13 +async function load() {
14 + const r = await fetch("/api/state");
15 + STATE = await r.json();
16 + const id = STATE.identity;
17 + document.documentElement.style.setProperty("--accent", id.accent);
18 + document.documentElement.style.setProperty("--accent2", id.accent2);
19 + document.title = `${STATE.agent.toUpperCase()} — Guardian du Groupe KA`;
20 + $("#agent-name").textContent = STATE.agent.toUpperCase() + " · GUARDIAN";
21 + $("#sigil").textContent = "·K" + STATE.agent.replace("ka", "");
22 + $("#foot-name").textContent = STATE.agent.toUpperCase();
23 + const apps = Object.values(STATE.services).map((s) => s.app).join(" · ");
24 + $("#tagline").textContent = `${id.tagline} — veille sur ${apps}`;
25 + $("#siblings").innerHTML = Object.entries(STATE.siblings)
26 + .map(([a, v]) => `<a href="https://${v.domain}" style="color:${v.accent}"><b>${a.toUpperCase()}</b>${v.services.join(", ")}</a>`).join("");
27 + renderTiles(); renderIncidents(); renderCoverage(); renderMissions();
28 +}
29 +
30 +function renderTiles() {
31 + const s = STATE.stats, mine = STATE.latest.mine || {};
32 + let total = 0, sick = 0;
33 + for (const b of Object.values(mine)) for (const st of STATUSES) {
34 + total += b.summary[st] || 0;
35 + if (st !== "ok") sick += b.summary[st] || 0;
36 + }
37 + const tiles = [
38 + [total || "—", "connecteurs surveillés", true],
39 + [s.n_active ?? 0, "incidents actifs"],
40 + [s.n_missions ?? 0, "missions lancées"],
41 + [s.n_resolved ?? 0, "réparations confirmées"],
42 + [s.n_rollbacks ?? 0, "rollbacks"],
43 + [s.mttr_h != null ? s.mttr_h + " h" : "—", "temps moyen de guérison"],
44 + [s.cost_total != null ? s.cost_total + " $" : "0 $", "coût API total"],
45 + ];
46 + $("#tiles").innerHTML = tiles.map(([v, l, hot]) =>
47 + `<div class="tile${hot ? " hot" : ""}"><div class="v">${esc(v)}</div><div class="l">${l}</div></div>`).join("");
48 +}
49 +
50 +function renderIncidents() {
51 + const active = STATE.incidents.filter((i) => !["resolved", "self_healed"].includes(i.state)).slice(0, 12);
52 + const recent = STATE.incidents.filter((i) => ["resolved", "self_healed"].includes(i.state)).slice(0, 5);
53 + const row = (i) => `<div class="inc">
54 + <div><div class="src">${esc(i.source)}</div><div class="svc">${esc(i.service)} · ${esc(i.status_detected)} · tentatives ${i.attempts}</div></div>
55 + <span class="badge b-${esc(i.state)}">${STATE_FR[i.state] || esc(i.state)}</span></div>`;
56 + $("#incidents").innerHTML = (active.length || recent.length)
57 + ? active.map(row).join("") + recent.map(row).join("")
58 + : `<div class="empty">Aucun incident — tous les connecteurs assignés sont sains. L'agent veille.</div>`;
59 +}
60 +
61 +function renderCoverage() {
62 + const mine = STATE.latest.mine || {};
63 + $("#coverage").innerHTML = Object.keys(STATE.services).map((svc) => {
64 + const meta = STATE.services[svc], sum = (mine[svc] || {}).summary || {};
65 + const total = STATUSES.reduce((a, st) => a + (sum[st] || 0), 0) || 1;
66 + const strip = STATUSES.filter((st) => sum[st] > 0)
67 + .map((st) => `<span class="s-${st}" style="flex:${sum[st]}" title="${STATUS_FR[st]}: ${sum[st]}"></span>`).join("");
68 + const counts = STATUSES.map((st) =>
69 + `<span><i class="s-${st}"></i>${sum[st] || 0} ${STATUS_FR[st]}</span>`).join("");
70 + return `<div class="cov"><div class="head"><span class="app">${esc(meta.app)}</span>
71 + <span class="node">${esc(meta.node_alias)} · ${total} connecteurs</span></div>
72 + <div class="strip">${strip || "<span style='flex:1;background:var(--line)'></span>"}</div>
73 + <div class="counts">${counts}</div></div>`;
74 + }).join("");
75 +}
76 +
77 +function renderMissions() {
78 + const vd = (m) => {
79 + if (m.state === "running") return `<span class="v-running">en cours…</span>`;
80 + if (m.state === "error") return `<span class="v-echec">erreur</span>`;
81 + const v = (JSON.parse(m.verdict || "{}").verdict) || "—";
82 + return `<span class="v-${esc(v)}">${esc(v)}</span>`;
83 + };
84 + $("#missions tbody").innerHTML = STATE.missions.map((m) => {
85 + const commits = JSON.parse(m.commits || "[]").length;
86 + const dur = m.ended ? Math.round((m.ended - m.started) / 60) + " min" : "";
87 + return `<tr data-id="${esc(m.id)}"><td class="mono">${fmtD(m.started)}</td>
88 + <td class="mono">${esc(m.source)}</td><td>${esc(m.service)}</td><td class="mono">${esc(m.node)}</td>
89 + <td>${vd(m)}</td><td>${commits || "—"}</td><td>${m.num_turns ?? ""} ${dur ? "· " + dur : ""}</td>
90 + <td class="mono">${m.cost_usd != null ? m.cost_usd.toFixed(2) + " $" : ""}</td></tr>`;
91 + }).join("") || `<tr><td colspan="8" class="empty">Aucune mission encore — la première panne détectée lancera l'agent.</td></tr>`;
92 +}
93 +
94 +/* ---- feed en direct ---- */
95 +const feed = $("#feed");
96 +function feedLine(cls, tag, msg, ts) {
97 + if (feed.querySelector(".feed-empty")) feed.innerHTML = "";
98 + const el = document.createElement("div");
99 + el.className = "fe " + cls;
100 + el.innerHTML = `<span class="t">${fmtT(ts || Date.now() / 1000)}</span><span class="tag">${esc(tag)}</span><span class="m">${esc(msg)}</span>`;
101 + feed.appendChild(el);
102 + while (feed.children.length > 400) feed.removeChild(feed.firstChild);
103 + feed.scrollTop = feed.scrollHeight;
104 +}
105 +
106 +let refetchTimer = null;
107 +const refetch = () => { clearTimeout(refetchTimer); refetchTimer = setTimeout(load, 800); };
108 +
109 +function connectSSE() {
110 + const es = new EventSource("/events");
111 + es.onopen = () => $("#live-dot").classList.add("on");
112 + es.onerror = () => $("#live-dot").classList.remove("on");
113 + es.onmessage = (e) => {
114 + const m = JSON.parse(e.data);
115 + if (m.kind === "mission_event") {
116 + const d = m.data || {};
117 + if (m.type === "start") feedLine("tool", "mission", `démarrage — commit de base ${(d.base_commit || "").slice(0, 8)} sur ${d.node || ""}`, m.ts);
118 + else if (m.type === "tool") feedLine("tool", d.name || "outil", d.input || "", m.ts);
119 + else if (m.type === "text") feedLine("text", "agent", d.text || "", m.ts);
120 + else if (m.type === "result") feedLine("text", "résultat", (d.result || "").slice(0, 600), m.ts);
121 + else if (m.type === "final") { feedLine("text", "verdict", JSON.stringify(d.verdict || {}), m.ts); refetch(); }
122 + else if (m.type === "error") { feedLine("rollback", "erreur", d.error || "", m.ts); refetch(); }
123 + else if (m.type === "rollback") feedLine("rollback", "rollback", JSON.stringify(d), m.ts);
124 + } else if (m.kind === "incident") {
125 + feedLine("incident", "incident", `${m.service}/${m.source} → ${STATE_FR[m.state] || m.state}${m.note ? " — " + m.note : ""}`, m.ts);
126 + refetch();
127 + } else if (m.kind === "rollback") {
128 + feedLine("rollback", "rollback", `${m.service}/${m.source} — ${m.reason} (${(m.result || {}).mode || "?"})`, m.ts);
129 + refetch();
130 + } else if (m.kind === "mission") {
131 + feedLine("tool", "mission", `nouvelle mission sur ${m.service}/${m.source}`, m.ts);
132 + refetch();
133 + } else if (m.kind === "snapshot") {
134 + if (STATE) { STATE.latest.mine = Object.fromEntries(Object.entries(m.mine).map(([s, sum]) => [s, { summary: sum }])); renderCoverage(); }
135 + } else if (m.kind === "log") {
136 + feedLine("incident", m.level || "log", m.msg, m.ts);
137 + }
138 + };
139 +}
140 +
141 +/* ---- modal mission ---- */
142 +document.addEventListener("click", async (e) => {
143 + const tr = e.target.closest("#missions tbody tr[data-id]");
144 + if (tr) {
145 + const r = await fetch("/api/missions/" + tr.dataset.id);
146 + const { mission, events } = await r.json();
147 + const v = JSON.parse(mission.verdict || "{}");
148 + $("#modal-title").textContent = `Mission ${mission.id} — ${mission.source} (${mission.service})`;
149 + $("#modal-body").innerHTML =
150 + `<div class="meta">verdict: ${esc(v.verdict || mission.state)}\ndiagnostic: ${esc(v.diagnostic || "")}\nactions: ${esc(v.actions || "")}\ntest: ${esc(v.test || "")}\ncommit de base: ${esc(mission.base_commit || "")}\ncommits: ${esc((JSON.parse(mission.commits || "[]")).join(" | ") || "aucun")}</div>` +
151 + events.map((ev) => {
152 + const d = JSON.parse(ev.data || "{}");
153 + const txt = ev.type === "tool" ? `${d.name}: ${d.input}` : ev.type === "text" ? d.text
154 + : ev.type === "result" ? d.result : JSON.stringify(d);
155 + return `<div class="meta"><b>${fmtT(ev.ts)} · ${esc(ev.type)}</b>\n${esc((txt || "").slice(0, 2500))}</div>`;
156 + }).join("");
157 + $("#modal").hidden = false;
158 + }
159 + if (e.target.id === "modal-close" || e.target.id === "modal") $("#modal").hidden = true;
160 +});
161 +
162 +load().then(connectSSE);
163 +setInterval(load, 60000);
added M4M36luster-projects/ka-guardian/M4M36luster-projects/ka-guardian/M4M36luster-projects/ka-guardian/M4M36luster-projects/ka-guardian/orchestrator/web/index.html +69 −0
@@ -0,0 +1,69 @@
1 +<!doctype html>
2 +<html lang="fr">
3 +<head>
4 +<meta charset="utf-8">
5 +<meta name="viewport" content="width=device-width, initial-scale=1">
6 +<title>KA Guardian</title>
7 +<meta name="description" content="Agent autonome de maintenance des connecteurs du Groupe KA — incidents, missions et réparations en direct.">
8 +<link rel="preconnect" href="https://fonts.googleapis.com">
9 +<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10 +<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">
11 +<link rel="stylesheet" href="/static/style.css">
12 +</head>
13 +<body>
14 +<header class="top">
15 + <div class="brand">
16 + <div class="sigil" id="sigil">·K</div>
17 + <div>
18 + <h1 id="agent-name">KA·—</h1>
19 + <p class="tagline"><span class="live-dot" id="live-dot"></span><span id="tagline">connexion…</span></p>
20 + </div>
21 + </div>
22 + <nav class="siblings" id="siblings"></nav>
23 +</header>
24 +
25 +<main>
26 + <section class="tiles" id="tiles"></section>
27 +
28 + <section class="cols">
29 + <div class="panel feed-panel">
30 + <h2>⚡ Flux en direct <span class="hint">tout ce que l'agent fait, en temps réel</span></h2>
31 + <div class="feed" id="feed"><div class="feed-empty">En attente d'activité…</div></div>
32 + </div>
33 + <div class="side">
34 + <div class="panel">
35 + <h2>🚨 Incidents</h2>
36 + <div id="incidents" class="incidents"></div>
37 + </div>
38 + <div class="panel">
39 + <h2>🛰 Couverture</h2>
40 + <div id="coverage" class="coverage"></div>
41 + </div>
42 + </div>
43 + </section>
44 +
45 + <section class="panel">
46 + <h2>📜 Missions <span class="hint">cliquer une ligne pour le déroulé complet</span></h2>
47 + <div class="table-wrap">
48 + <table id="missions">
49 + <thead><tr><th>Quand</th><th>Connecteur</th><th>Service</th><th>Nœud</th><th>Verdict</th><th>Commits</th><th>Tours</th><th>Coût</th></tr></thead>
50 + <tbody></tbody>
51 + </table>
52 + </div>
53 + </section>
54 +</main>
55 +
56 +<footer>
57 + <p><strong id="foot-name">KA</strong> — agent gardien autonome du <a href="https://www.groupe-ka.com">Groupe KA</a>. Il détecte les connecteurs en panne, les répare avec Claude, surveille la guérison et annule ses changements si ça empire.</p>
58 +</footer>
59 +
60 +<div class="modal" id="modal" hidden>
61 + <div class="modal-box">
62 + <div class="modal-head"><h3 id="modal-title">Mission</h3><button id="modal-close">✕</button></div>
63 + <div class="modal-body" id="modal-body"></div>
64 + </div>
65 +</div>
66 +
67 +<script src="/static/app.js"></script>
68 +</body>
69 +</html>
added M4M36luster-projects/ka-guardian/M4M36luster-projects/ka-guardian/M4M36luster-projects/ka-guardian/M4M36luster-projects/ka-guardian/orchestrator/web/style.css +118 −0
@@ -0,0 +1,118 @@
1 +/* KA Guardian — salle de contrôle */
2 +:root {
3 + --bg: #0d1017; --bg2: #12161f; --panel: #151a24; --panel2: #1a2030;
4 + --line: #232a3a; --ink: #e8ecf4; --ink2: #9aa5b8; --muted: #667089;
5 + --accent: #22d3ee; --accent2: #0891b2;
6 + --ok: #4ade80; --degraded: #fbbf24; --broken: #f87171; --stale: #60a5fa;
7 + --mono: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
8 + --sans: "Space Grotesk", -apple-system, sans-serif;
9 +}
10 +* { box-sizing: border-box; margin: 0; }
11 +body { background: var(--bg); color: var(--ink); font-family: var(--sans); min-height: 100vh; }
12 +a { color: var(--accent); text-decoration: none; }
13 +
14 +.top { display: flex; justify-content: space-between; align-items: center; gap: 16px; flex-wrap: wrap;
15 + padding: 22px clamp(16px, 4vw, 48px); border-bottom: 1px solid var(--line);
16 + background: radial-gradient(1200px 300px at 20% -50%, color-mix(in oklab, var(--accent) 18%, transparent), transparent), var(--bg2); }
17 +.brand { display: flex; gap: 16px; align-items: center; }
18 +.sigil { width: 54px; height: 54px; border-radius: 14px; display: grid; place-items: center;
19 + font: 700 22px var(--mono); color: #0b0e14;
20 + background: linear-gradient(135deg, var(--accent), var(--accent2));
21 + box-shadow: 0 0 24px color-mix(in oklab, var(--accent) 45%, transparent); }
22 +h1 { font-size: 26px; letter-spacing: 0.04em; }
23 +.tagline { color: var(--ink2); font-size: 14px; display: flex; align-items: center; gap: 8px; }
24 +.live-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--muted); flex: none; }
25 +.live-dot.on { background: var(--ok); animation: pulse 1.8s infinite; }
26 +@keyframes pulse { 0%,100% { box-shadow: 0 0 0 0 color-mix(in oklab, var(--ok) 55%, transparent); }
27 + 60% { box-shadow: 0 0 0 8px transparent; } }
28 +.siblings { display: flex; gap: 10px; }
29 +.siblings a { border: 1px solid var(--line); border-radius: 10px; padding: 7px 13px; font: 600 13px var(--mono);
30 + color: var(--ink2); background: var(--panel); }
31 +.siblings a b { margin-right: 6px; }
32 +
33 +main { padding: 22px clamp(16px, 4vw, 48px); display: grid; gap: 20px; max-width: 1500px; margin: 0 auto; }
34 +
35 +.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; }
36 +.tile { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 14px 16px; }
37 +.tile .v { font: 600 30px/1.15 var(--mono); }
38 +.tile .l { color: var(--ink2); font-size: 12.5px; margin-top: 4px; letter-spacing: 0.03em; text-transform: uppercase; }
39 +.tile.hot .v { color: var(--accent); }
40 +
41 +.cols { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr); gap: 20px; }
42 +@media (max-width: 980px) { .cols { grid-template-columns: 1fr; } }
43 +.side { display: grid; gap: 20px; align-content: start; }
44 +
45 +.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 16px; padding: 18px; min-width: 0; }
46 +.panel h2 { font-size: 16px; margin-bottom: 14px; letter-spacing: 0.02em; }
47 +.hint { color: var(--muted); font-size: 12px; font-weight: 400; margin-left: 8px; }
48 +
49 +/* Flux en direct */
50 +.feed { font: 13px/1.55 var(--mono); background: #0a0d13; border: 1px solid var(--line); border-radius: 12px;
51 + padding: 14px; height: 560px; overflow-y: auto; display: flex; flex-direction: column; gap: 8px; }
52 +.feed-empty { color: var(--muted); }
53 +.fe { display: flex; gap: 10px; align-items: baseline; animation: fadein 0.3s; }
54 +@keyframes fadein { from { opacity: 0; transform: translateY(4px); } }
55 +.fe .t { color: var(--muted); flex: none; font-size: 11px; }
56 +.fe .tag { flex: none; font-size: 11px; font-weight: 600; padding: 1px 8px; border-radius: 999px;
57 + border: 1px solid var(--line); color: var(--ink2); }
58 +.fe.tool .tag { color: var(--accent); border-color: color-mix(in oklab, var(--accent) 40%, var(--line)); }
59 +.fe.text .tag { color: var(--ok); }
60 +.fe.incident .tag { color: var(--degraded); }
61 +.fe.rollback .tag { color: var(--broken); }
62 +.fe .m { color: var(--ink2); overflow-wrap: anywhere; white-space: pre-wrap; }
63 +.fe.text .m { color: var(--ink); }
64 +
65 +/* Incidents */
66 +.incidents { display: grid; gap: 9px; max-height: 330px; overflow-y: auto; }
67 +.inc { display: flex; align-items: center; gap: 10px; background: var(--panel2); border: 1px solid var(--line);
68 + border-radius: 11px; padding: 10px 12px; font-size: 13px; }
69 +.inc .src { font: 600 13px var(--mono); overflow-wrap: anywhere; }
70 +.inc .svc { color: var(--muted); font-size: 11.5px; }
71 +.badge { margin-left: auto; flex: none; font: 600 11px var(--mono); padding: 3px 9px; border-radius: 999px; }
72 +.b-open { background: color-mix(in oklab, var(--broken) 18%, transparent); color: var(--broken); }
73 +.b-fixing { background: color-mix(in oklab, var(--accent) 18%, transparent); color: var(--accent); }
74 +.b-dispatched{ background: color-mix(in oklab, var(--accent) 18%, transparent); color: var(--accent); }
75 +.b-watching { background: color-mix(in oklab, var(--stale) 18%, transparent); color: var(--stale); }
76 +.b-cooldown { background: color-mix(in oklab, var(--degraded) 18%, transparent);color: var(--degraded); }
77 +.b-resolved, .b-self_healed { background: color-mix(in oklab, var(--ok) 16%, transparent); color: var(--ok); }
78 +.b-abandoned { background: #2a2f3d; color: var(--muted); }
79 +.empty { color: var(--muted); font-size: 13px; }
80 +
81 +/* Couverture */
82 +.coverage { display: grid; gap: 12px; }
83 +.cov { background: var(--panel2); border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px; }
84 +.cov .head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 8px; }
85 +.cov .app { font-weight: 700; }
86 +.cov .node { color: var(--muted); font: 11px var(--mono); }
87 +.strip { display: flex; height: 10px; border-radius: 6px; overflow: hidden; gap: 2px; background: var(--bg); }
88 +.strip span { min-width: 3px; }
89 +.s-ok { background: var(--ok); } .s-degraded { background: var(--degraded); }
90 +.s-broken { background: var(--broken); } .s-stale { background: var(--stale); }
91 +.counts { display: flex; gap: 12px; margin-top: 8px; flex-wrap: wrap; font: 11.5px var(--mono); color: var(--ink2); }
92 +.counts i { display: inline-block; width: 8px; height: 8px; border-radius: 2px; margin-right: 5px; }
93 +
94 +/* Missions */
95 +.table-wrap { overflow-x: auto; }
96 +table { width: 100%; border-collapse: collapse; font-size: 13px; }
97 +th { text-align: left; color: var(--muted); font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.05em;
98 + padding: 8px 10px; border-bottom: 1px solid var(--line); }
99 +td { padding: 9px 10px; border-bottom: 1px solid color-mix(in oklab, var(--line) 55%, transparent); }
100 +tbody tr { cursor: pointer; } tbody tr:hover { background: var(--panel2); }
101 +.mono { font-family: var(--mono); font-size: 12.5px; }
102 +.v-repare { color: var(--ok); } .v-echec, .v-inconnu { color: var(--broken); }
103 +.v-site_source_mort { color: var(--muted); } .v-rien_a_faire { color: var(--stale); }
104 +.v-running { color: var(--accent); }
105 +
106 +footer { padding: 26px clamp(16px, 4vw, 48px); border-top: 1px solid var(--line); color: var(--ink2);
107 + font-size: 13.5px; max-width: 1500px; margin: 0 auto; }
108 +
109 +/* Modal */
110 +.modal { position: fixed; inset: 0; background: rgba(5, 7, 11, 0.75); display: grid; place-items: center; z-index: 50; }
111 +.modal-box { background: var(--panel); border: 1px solid var(--line); border-radius: 16px; width: min(880px, 94vw);
112 + max-height: 86vh; display: flex; flex-direction: column; }
113 +.modal-head { display: flex; justify-content: space-between; align-items: center; padding: 14px 18px;
114 + border-bottom: 1px solid var(--line); }
115 +.modal-head button { background: none; border: none; color: var(--ink2); font-size: 18px; cursor: pointer; }
116 +.modal-body { padding: 16px 18px; overflow-y: auto; font: 12.5px/1.6 var(--mono); display: grid; gap: 8px; }
117 +.modal-body .meta { background: var(--panel2); border-radius: 10px; padding: 10px 12px; color: var(--ink2);
118 + white-space: pre-wrap; overflow-wrap: anywhere; }
added M4M36luster-projects/ka-guardian/M4M36luster-projects/ka-guardian/M4M36luster-projects/ka-guardian/M4M36luster-projects/ka-guardian/runner/runner.py +287 −0
@@ -0,0 +1,287 @@
1 +# ============================================
2 +# Projet : KA Guardian
3 +# Fichier : runner/runner.py
4 +# Rôle : Bras d'exécution sur chaque nœud d'app — lance les missions
5 +# claude -p headless dans le repo de l'app, streame les événements
6 +# vers l'orchestrateur (ka2/ka4/ka6), et sait rollback + restart.
7 +# Author : Simon-Pierre Boucher
8 +# Date : 2026-08-23
9 +# ============================================
10 +"""KA Guardian Runner.
11 +
12 +Un seul runner par nœud (port 7791), une seule mission à la fois par nœud.
13 +Auth: header X-KA-Token == $KA_GUARDIAN_TOKEN (fichier ~/.ka-guardian.env).
14 +"""
15 +from __future__ import annotations
16 +
17 +import json
18 +import os
19 +import pathlib
20 +import shlex
21 +import socket
22 +import subprocess
23 +import threading
24 +import time
25 +from typing import Any
26 +
27 +import httpx
28 +from fastapi import FastAPI, Header, HTTPException, Request
29 +from pydantic import BaseModel
30 +
31 +HOME = pathlib.Path.home()
32 +ENV_FILE = HOME / ".ka-guardian.env"
33 +CLAUDE_ENV_FILE = HOME / ".claude" / ".env"
34 +TRANSCRIPTS = HOME / "ka-guardian-runner" / "transcripts"
35 +TRANSCRIPTS.mkdir(parents=True, exist_ok=True)
36 +CLAUDE_BIN = "/opt/homebrew/bin/claude"
37 +
38 +
39 +def load_env_file(path: pathlib.Path) -> dict[str, str]:
40 + out: dict[str, str] = {}
41 + if path.exists():
42 + for line in path.read_text().splitlines():
43 + line = line.strip()
44 + if line and not line.startswith("#") and "=" in line:
45 + k, v = line.split("=", 1)
46 + out[k.strip()] = v.strip().strip('"').strip("'")
47 + return out
48 +
49 +
50 +LOCAL_ENV = load_env_file(ENV_FILE)
51 +TOKEN = LOCAL_ENV.get("KA_GUARDIAN_TOKEN", "")
52 +NODE = LOCAL_ENV.get("KA_GUARDIAN_NODE", socket.gethostname())
53 +
54 +app = FastAPI(title="KA Guardian Runner", docs_url=None, redoc_url=None)
55 +
56 +_lock = threading.Lock()
57 +_current: dict[str, Any] = {} # mission en cours (métadonnées)
58 +
59 +
60 +def check_token(x_ka_token: str | None) -> None:
61 + if not TOKEN or x_ka_token != TOKEN:
62 + raise HTTPException(status_code=401, detail="token invalide")
63 +
64 +
65 +def expand(dir_: str) -> str:
66 + return os.path.expanduser(dir_)
67 +
68 +
69 +def git(dir_: str, *args: str) -> subprocess.CompletedProcess:
70 + return subprocess.run(
71 + ["git", "-C", expand(dir_), *args],
72 + capture_output=True, text=True, timeout=120,
73 + )
74 +
75 +
76 +def sh(cmd: str, timeout: int = 120) -> subprocess.CompletedProcess:
77 + env = {**os.environ, "PATH": "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"}
78 + return subprocess.run(["/bin/zsh", "-c", cmd], capture_output=True, text=True, timeout=timeout, env=env)
79 +
80 +
81 +def healthcheck(web_port: int, pm2_names: list[str]) -> dict[str, Any]:
82 + """Vérifie que l'app répond en HTTP et que ses process pm2 sont online."""
83 + http_ok = False
84 + try:
85 + r = httpx.get(f"http://127.0.0.1:{web_port}/", timeout=10, follow_redirects=True)
86 + http_ok = r.status_code < 500
87 + except Exception:
88 + http_ok = False
89 + pm2_status: dict[str, str] = {}
90 + try:
91 + out = sh("pm2 jlist").stdout
92 + procs = {p["name"]: p.get("pm2_env", {}).get("status", "?") for p in json.loads(out)}
93 + for name in pm2_names:
94 + pm2_status[name] = procs.get(name, "absent")
95 + except Exception as exc: # pm2 absent ou jlist illisible
96 + pm2_status = {n: f"inconnu ({exc})" for n in pm2_names}
97 + ok = http_ok and all(s in ("online", "launching") for s in pm2_status.values())
98 + return {"ok": ok, "http_ok": http_ok, "pm2": pm2_status}
99 +
100 +
101 +class MissionIn(BaseModel):
102 + mission_id: str
103 + agent: str
104 + service: str
105 + source: str
106 + dir: str
107 + pm2: list[str]
108 + web_port: int
109 + model: str = "sonnet"
110 + max_turns: int = 70
111 + timeout_seconds: int = 3600
112 + prompt: str
113 + callback_url: str # http://<orchestrateur>/api/ingest/<mission_id>
114 +
115 +
116 +def post_event(url: str, payload: dict[str, Any]) -> None:
117 + try:
118 + httpx.post(url, json=payload, headers={"X-KA-Token": TOKEN}, timeout=15)
119 + except Exception:
120 + pass # l'orchestrateur relira le transcript au besoin
121 +
122 +
123 +def condense_stream_line(obj: dict[str, Any]) -> list[dict[str, Any]]:
124 + """Transforme une ligne stream-json de claude en événements courts pour le feed."""
125 + events: list[dict[str, Any]] = []
126 + t = obj.get("type")
127 + if t == "system" and obj.get("subtype") == "init":
128 + events.append({"type": "init", "data": {"model": obj.get("model", "?")}})
129 + elif t == "assistant":
130 + for block in obj.get("message", {}).get("content", []):
131 + if block.get("type") == "text" and block.get("text", "").strip():
132 + events.append({"type": "text", "data": {"text": block["text"][:1500]}})
133 + elif block.get("type") == "tool_use":
134 + inp = json.dumps(block.get("input", {}), ensure_ascii=False)
135 + events.append({"type": "tool", "data": {"name": block.get("name", "?"), "input": inp[:400]}})
136 + elif t == "result":
137 + events.append({"type": "result", "data": {
138 + "subtype": obj.get("subtype"),
139 + "result": (obj.get("result") or "")[:4000],
140 + "cost_usd": obj.get("total_cost_usd"),
141 + "num_turns": obj.get("num_turns"),
142 + "duration_ms": obj.get("duration_ms"),
143 + }})
144 + return events
145 +
146 +
147 +def extract_verdict(result_text: str) -> dict[str, Any]:
148 + """Extrait le dernier bloc JSON {verdict: ...} de la réponse finale."""
149 + import re
150 + for m in reversed(re.findall(r"\{[^{}]*\"verdict\"[\s\S]*?\}", result_text)):
151 + try:
152 + v = json.loads(m)
153 + if "verdict" in v:
154 + return v
155 + except Exception:
156 + continue
157 + return {"verdict": "inconnu", "diagnostic": result_text[-500:] if result_text else ""}
158 +
159 +
160 +def run_mission(m: MissionIn) -> None:
161 + global _current
162 + workdir = expand(m.dir)
163 + transcript = TRANSCRIPTS / f"{m.mission_id}.jsonl"
164 + base_commit = ""
165 + try:
166 + # Snapshot pré-mission : tree sale → commit de sûreté pour un rollback net.
167 + if git(m.dir, "status", "--porcelain").stdout.strip():
168 + git(m.dir, "add", "-A")
169 + git(m.dir, "commit", "-m", f"[{m.agent}] snapshot pré-mission {m.source}")
170 + base_commit = git(m.dir, "rev-parse", "HEAD").stdout.strip()
171 + post_event(m.callback_url, {"type": "start", "data": {"base_commit": base_commit, "node": NODE}})
172 +
173 + env = {
174 + **os.environ,
175 + **load_env_file(CLAUDE_ENV_FILE),
176 + "PATH": "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin",
177 + "HOME": str(HOME),
178 + }
179 + cmd = [
180 + CLAUDE_BIN, "-p", m.prompt,
181 + "--output-format", "stream-json", "--verbose",
182 + "--model", m.model,
183 + "--max-turns", str(m.max_turns),
184 + "--dangerously-skip-permissions",
185 + ]
186 + proc = subprocess.Popen(
187 + cmd, cwd=workdir, env=env,
188 + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, bufsize=1,
189 + )
190 + _current["pid"] = proc.pid
191 + result_text, cost, turns = "", None, None
192 + deadline = time.time() + m.timeout_seconds
193 + with transcript.open("w") as tf:
194 + for line in proc.stdout: # type: ignore[union-attr]
195 + tf.write(line)
196 + if time.time() > deadline:
197 + proc.kill()
198 + post_event(m.callback_url, {"type": "error", "data": {"error": "timeout mission"}})
199 + break
200 + line = line.strip()
201 + if not line:
202 + continue
203 + try:
204 + obj = json.loads(line)
205 + except Exception:
206 + continue
207 + for ev in condense_stream_line(obj):
208 + if ev["type"] == "result":
209 + result_text = ev["data"].get("result", "")
210 + cost = ev["data"].get("cost_usd")
211 + turns = ev["data"].get("num_turns")
212 + post_event(m.callback_url, ev)
213 + proc.wait(timeout=60)
214 +
215 + commits_raw = git(m.dir, "rev-list", "--oneline", f"{base_commit}..HEAD").stdout.strip()
216 + commits = commits_raw.splitlines() if commits_raw else []
217 + verdict = extract_verdict(result_text)
218 + health = healthcheck(m.web_port, m.pm2)
219 + post_event(m.callback_url, {"type": "final", "data": {
220 + "verdict": verdict, "commits": commits, "base_commit": base_commit,
221 + "cost_usd": cost, "num_turns": turns, "health": health,
222 + "exit_code": proc.returncode,
223 + }})
224 + except Exception as exc:
225 + post_event(m.callback_url, {"type": "error", "data": {"error": str(exc), "base_commit": base_commit}})
226 + finally:
227 + with _lock:
228 + _current.clear()
229 +
230 +
231 +@app.get("/health")
232 +def health() -> dict[str, Any]:
233 + return {"ok": True, "node": NODE, "busy": bool(_current), "current": _current.get("mission_id")}
234 +
235 +
236 +@app.post("/missions")
237 +def missions(m: MissionIn, x_ka_token: str | None = Header(default=None)) -> dict[str, Any]:
238 + check_token(x_ka_token)
239 + workdir = expand(m.dir)
240 + if not os.path.isdir(workdir):
241 + raise HTTPException(status_code=400, detail=f"dir introuvable: {workdir}")
242 + with _lock:
243 + if _current:
244 + raise HTTPException(status_code=409, detail=f"mission déjà en cours: {_current.get('mission_id')}")
245 + _current.update({"mission_id": m.mission_id, "service": m.service, "source": m.source, "started": time.time()})
246 + threading.Thread(target=run_mission, args=(m,), daemon=True).start()
247 + return {"accepted": True, "node": NODE}
248 +
249 +
250 +class RollbackIn(BaseModel):
251 + dir: str
252 + base_commit: str
253 + pm2: list[str]
254 + web_port: int
255 +
256 +
257 +@app.post("/rollback")
258 +def rollback(r: RollbackIn, x_ka_token: str | None = Header(default=None)) -> dict[str, Any]:
259 + """Revient au commit d'avant mission (reset --hard si tous les commits sont de l'agent, sinon revert)."""
260 + check_token(x_ka_token)
261 + log = git(r.dir, "log", "--format=%s", f"{r.base_commit}..HEAD").stdout.strip()
262 + msgs = log.splitlines() if log else []
263 + if not msgs:
264 + mode = "aucun_commit"
265 + elif all(s.startswith("[ka") for s in msgs):
266 + git(r.dir, "reset", "--hard", r.base_commit)
267 + mode = "reset_hard"
268 + else:
269 + rc = git(r.dir, "revert", "--no-edit", f"{r.base_commit}..HEAD")
270 + if rc.returncode != 0:
271 + git(r.dir, "revert", "--abort")
272 + git(r.dir, "reset", "--hard", r.base_commit)
273 + mode = "reset_hard(fallback)"
274 + else:
275 + mode = "revert"
276 + # Pas de git clean : les process de sync de l'app écrivent en continu,
277 + # on ne supprime jamais de fichiers non trackés apparus pendant la mission.
278 + for name in r.pm2:
279 + sh(f"pm2 restart {shlex.quote(name)} --update-env", timeout=180)
280 + time.sleep(8)
281 + h = healthcheck(r.web_port, r.pm2)
282 + return {"ok": True, "mode": mode, "health": h, "head": git(r.dir, "rev-parse", "HEAD").stdout.strip()}
283 +
284 +
285 +if __name__ == "__main__":
286 + import uvicorn
287 + uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("KA_GUARDIAN_RUNNER_PORT", "7791")))
added M4M36luster-projects/ka-guardian/M4M36luster-projects/ka-guardian/M4M36luster-projects/ka-guardian/M4M36luster-projects/ka-guardian/topology.json +62 −0
@@ -0,0 +1,62 @@
1 +{
2 + "comment": "KA Guardian — topologie des agents, services et nœuds. Source de vérité partagée orchestrateurs/runners.",
3 + "apika_monitoring_url": "http://192.168.2.82:8000/api/v1/monitoring/connectors",
4 + "runner_port": 7791,
5 + "agents": {
6 + "ka2": {
7 + "port": 8799,
8 + "domain": "www.ka2.bot",
9 + "accent": "#22d3ee",
10 + "accent2": "#0891b2",
11 + "tagline": "Gardien immobilier & local",
12 + "model": "sonnet",
13 + "services": ["louka", "immoka", "restoka"]
14 + },
15 + "ka4": {
16 + "port": 8899,
17 + "domain": "www.ka4.bot",
18 + "accent": "#fbbf24",
19 + "accent2": "#d97706",
20 + "tagline": "Gardien mobilité & quotidien",
21 + "model": "sonnet",
22 + "services": ["autoka", "foodka", "sortika"]
23 + },
24 + "ka6": {
25 + "port": 8999,
26 + "domain": "www.ka6.bot",
27 + "accent": "#a78bfa",
28 + "accent2": "#7c3aed",
29 + "tagline": "Gardien flagship — gros volumes",
30 + "model": "sonnet",
31 + "services": ["fabrika", "jobka", "creaka"],
32 + "default_for_unknown_services": true
33 + }
34 + },
35 + "nodes": {
36 + "m3u96a": { "lan_ip": "192.168.2.87", "alias": "M3U96a" },
37 + "m3u96b": { "lan_ip": "192.168.2.82", "alias": "M3U96b" },
38 + "m4m64a": { "lan_ip": "192.168.2.83", "alias": "M4M64a" },
39 + "m4m64b": { "lan_ip": "192.168.2.78", "alias": "M4M64b" }
40 + },
41 + "services": {
42 + "louka": { "app": "Lou·Ka", "node": "m3u96b", "dir": "~/apps/lou-ka", "pm2": ["lou-ka-web", "lou-ka-sync"], "web_port": 8095, "site": "https://www.lou-ka.com" },
43 + "restoka": { "app": "Resto·Ka", "node": "m3u96b", "dir": "~/apps/resto-ka", "pm2": ["resto-ka", "resto-ka-sync"], "web_port": 8115, "site": "https://www.resto-ka.com" },
44 + "creaka": { "app": "Créa·Ka", "node": "m3u96b", "dir": "~/apps/crea-ka", "pm2": ["crea-ka-web", "crea-ka-sync"], "web_port": 8160, "site": "https://www.crea-ka.com" },
45 + "immoka": { "app": "Immo·Ka", "node": "m4m64a", "dir": "~/apps/immo-ka", "pm2": ["immo-ka-web", "immo-ka-sync"], "web_port": 8096, "site": "https://www.immo-ka.com" },
46 + "fabrika": { "app": "Fabri·Ka", "node": "m4m64a", "dir": "~/fabri-ka", "pm2": ["fabri-ka-web", "fabri-ka-sync"], "web_port": 8097, "site": "https://www.fabri-ka.com" },
47 + "autoka": { "app": "Auto·Ka", "node": "m4m64b", "dir": "~/auto-ka", "pm2": ["auto-ka-web", "auto-ka-sync"], "web_port": 8095, "site": "https://www.auto-ka.com" },
48 + "foodka": { "app": "Food·Ka", "node": "m4m64b", "dir": "~/apps/food-ka", "pm2": ["food-ka-web", "food-ka-sync"], "web_port": 8097, "site": "https://www.food-ka.com" },
49 + "sortika": { "app": "Sorti·Ka", "node": "m3u96a", "dir": "~/apps/sorti-ka", "pm2": ["sorti-ka-web", "sorti-ka-sync"], "web_port": 8120, "site": "https://www.sorti-ka.com" },
50 + "jobka": { "app": "Job·Ka", "node": "m3u96a", "dir": "~/apps/job-ka", "pm2": ["job-ka-web", "job-ka-sync"], "web_port": 8096, "site": "https://www.job-ka.com" }
51 + },
52 + "policy": {
53 + "poll_interval_seconds": 300,
54 + "trigger_statuses": ["broken", "stale"],
55 + "max_concurrent_missions": 1,
56 + "max_attempts_per_incident": 3,
57 + "attempt_cooldown_hours": 6,
58 + "watch_window_hours": 8,
59 + "mission_max_turns": 70,
60 + "mission_timeout_seconds": 3600
61 + }
62 +}
modified M4M36luster-projects/ka-guardian/M4M36luster-projects/ka-guardian/M4M36luster-projects/ka-guardian/deploy/deploy.sh +2 −1
@@ -60,7 +60,8 @@ deploy_orchestrators() {
60 60 rsync -az --delete --exclude data --exclude .venv --exclude .git \
61 61 "$ROOT/" $ORCH_NODE:cluster-projects/ka-guardian/
62 62 ssh $ORCH_NODE "cd ~/cluster-projects/ka-guardian
63 − [[ -d .venv ]] || $PY -m venv .venv
63 + PY=\$(command -v /opt/homebrew/bin/python3 || command -v /opt/homebrew/bin/python3.13)
64 + [[ -d .venv ]] || \$PY -m venv .venv
64 65 ./.venv/bin/pip -q install 'fastapi>=0.110' 'uvicorn>=0.29' 'httpx>=0.27' >/dev/null
65 66 mkdir -p data logs
66 67 # retirer les anciens bots (web+bot), garder les tunnels ngrok
added M4M36luster-projects/ka-guardian/M4M36luster-projects/ka-guardian/deploy/courier.sh +36 −0
@@ -0,0 +1,36 @@
1 +#!/bin/zsh
2 +# ============================================
3 +# KA Guardian — courrier inter-nœuds (100 % zsh)
4 +# macOS 26 « Local Network Privacy » refuse le trafic LAN dès que python
5 +# (homebrew) est dans la chaîne de processus — même ses enfants ssh/curl.
6 +# Ce service launchd (binaire responsable: zsh) expédie donc les requêtes:
7 +# spool/outbox/<id>.job → ssh <nœud> curl http://127.0.0.1:<port><path>
8 +# spool/done/<id>.resp ← corps de réponse + dernière ligne = code HTTP
9 +# Format .job: ligne 1 = "<ip> <port> <path> <timeout>", reste = payload JSON.
10 +# ============================================
11 +SPOOL=$HOME/ka-guardian-spool
12 +KEY=$HOME/.ssh/ka_guardian_ed25519
13 +mkdir -p $SPOOL/outbox $SPOOL/done $SPOOL/tmp
14 +source $HOME/.ka-guardian.env 2>/dev/null || true
15 +
16 +while true; do
17 + for f in $SPOOL/outbox/*.job(N); do
18 + id=${f:t:r}
19 + hdr=$(head -1 $f)
20 + parts=(${(z)hdr})
21 + ip=$parts[1]; port=$parts[2]; path=$parts[3]; tmo=${parts[4]:-30}
22 + tail -n +2 $f | /usr/bin/ssh -i $KEY \
23 + -o StrictHostKeyChecking=accept-new -o BatchMode=yes -o ConnectTimeout=8 \
24 + -o ControlMaster=auto -o ControlPath=/tmp/kg-cm-%h -o ControlPersist=120 \
25 + simon-pierreboucher@$ip \
26 + "/usr/bin/curl -s -m $tmo -X POST http://127.0.0.1:$port$path -H 'Content-Type: application/json' -H 'X-KA-Token: $KA_GUARDIAN_TOKEN' -d @- -w '\n%{http_code}'" \
27 + > $SPOOL/tmp/$id.resp 2>>$SPOOL/courier.log
28 + rc=$?
29 + [[ $rc -ne 0 ]] && print "\ncourier_ssh_rc_$rc" >> $SPOOL/tmp/$id.resp
30 + mv $SPOOL/tmp/$id.resp $SPOOL/done/$id.resp
31 + rm -f $f
32 + done
33 + # ménage: réponses jamais réclamées depuis > 1 h
34 + for old in $SPOOL/done/*.resp(N.mh+1); do rm -f $old; done
35 + sleep 1
36 +done
modified M4M36luster-projects/ka-guardian/M4M36luster-projects/ka-guardian/deploy/deploy.sh +30 −1
@@ -21,6 +21,34 @@ if [[ ! -f $TOKEN_FILE ]]; then
21 21 fi
22 22 TOKEN_LINE=$(grep KA_GUARDIAN_TOKEN "$TOKEN_FILE")
23 23
24 +deploy_courier() {
25 + # Courrier zsh sur TOUS les nœuds (orchestrateur + runners): expédie le
26 + # trafic LAN inter-nœuds via ssh (macOS 26 Local Network Privacy).
27 + for n in $RUNNER_NODES $ORCH_NODE; do
28 + echo "=== courrier → $n"
29 + scp -q "$ROOT/deploy/courier.sh" $n:ka-guardian-courier.sh
30 + ssh $n 'chmod +x ~/ka-guardian-courier.sh
31 + cat > ~/Library/LaunchAgents/com.ka.guardian-courier.plist <<PLIST
32 +<?xml version="1.0" encoding="UTF-8"?>
33 +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
34 +<plist version="1.0"><dict>
35 + <key>Label</key><string>com.ka.guardian-courier</string>
36 + <key>ProgramArguments</key><array>
37 + <string>/bin/zsh</string>
38 + <string>/Users/simon-pierreboucher/ka-guardian-courier.sh</string>
39 + </array>
40 + <key>RunAtLoad</key><true/><key>KeepAlive</key><true/>
41 + <key>StandardOutPath</key><string>/Users/simon-pierreboucher/ka-guardian-spool/courier.log</string>
42 + <key>StandardErrorPath</key><string>/Users/simon-pierreboucher/ka-guardian-spool/courier.log</string>
43 +</dict></plist>
44 +PLIST
45 + mkdir -p ~/ka-guardian-spool
46 + launchctl unload ~/Library/LaunchAgents/com.ka.guardian-courier.plist 2>/dev/null || true
47 + launchctl load ~/Library/LaunchAgents/com.ka.guardian-courier.plist' \
48 + && echo " ✓ $n courrier ok" || echo " ✗ $n courrier KO"
49 + done
50 +}
51 +
24 52 deploy_runners() {
25 53 for n in $RUNNER_NODES; do
26 54 echo "=== runner → $n"
@@ -99,8 +127,9 @@ PLIST
99 127 }
100 128
101 129 case "${1:-all}" in
130 + courier) deploy_courier ;;
102 131 runners) deploy_runners ;;
103 132 orchestrators) deploy_orchestrators ;;
104 − all) deploy_runners; deploy_orchestrators ;;
133 + all) deploy_courier; deploy_runners; deploy_orchestrators ;;
105 134 esac
106 135 echo "terminé."
modified M4M36luster-projects/ka-guardian/M4M36luster-projects/ka-guardian/orchestrator/main.py +245 −33
Large diff (352 lines) is not loaded by default to keep the page fast.Load diff
modified M4M36luster-projects/ka-guardian/M4M36luster-projects/ka-guardian/orchestrator/web/app.js +97 −38
Large diff (201 lines) is not loaded by default to keep the page fast.Load diff
modified M4M36luster-projects/ka-guardian/M4M36luster-projects/ka-guardian/orchestrator/web/index.html +108 −30
Large diff (173 lines) is not loaded by default to keep the page fast.Load diff
modified M4M36luster-projects/ka-guardian/M4M36luster-projects/ka-guardian/orchestrator/web/style.css +200 −99
Large diff (318 lines) is not loaded by default to keep the page fast.Load diff
modified M4M36luster-projects/ka-guardian/M4M36luster-projects/ka-guardian/runner/runner.py +21 −1
Large diff (36 lines) is not loaded by default to keep the page fast.Load diff
modified M4M36luster-projects/ka-guardian/M4M36luster-projects/ka-guardian/topology.json +4 −2
Large diff (16 lines) is not loaded by default to keep the page fast.Load diff
modified M4M36luster-projects/ka-guardian/deploy/courier.sh +17 −8
Large diff (37 lines) is not loaded by default to keep the page fast.Load diff
modified M4M36luster-projects/ka-guardian/deploy/deploy.sh +13 −2
Large diff (27 lines) is not loaded by default to keep the page fast.Load diff
modified M4M36luster-projects/ka-guardian/orchestrator/main.py +127 −18
Large diff (225 lines) is not loaded by default to keep the page fast.Load diff
modified M4M36luster-projects/ka-guardian/orchestrator/web/app.js +10 −60
Large diff (88 lines) is not loaded by default to keep the page fast.Load diff
added M4M36luster-projects/ka-guardian/orchestrator/web/commander.html +109 −0
Large diff (109 lines) is not loaded by default to keep the page fast.Load diff
added M4M36luster-projects/ka-guardian/orchestrator/web/commander.js +101 −0
Large diff (101 lines) is not loaded by default to keep the page fast.Load diff
added M4M36luster-projects/ka-guardian/orchestrator/web/favicon-ka2.svg +7 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
added M4M36luster-projects/ka-guardian/orchestrator/web/favicon-ka4.svg +7 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
added M4M36luster-projects/ka-guardian/orchestrator/web/favicon-ka6.svg +7 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
modified M4M36luster-projects/ka-guardian/orchestrator/web/index.html +6 −40
Large diff (64 lines) is not loaded by default to keep the page fast.Load diff
modified M4M36luster-projects/ka-guardian/orchestrator/web/style.css +21 −0
Large diff (39 lines) is not loaded by default to keep the page fast.Load diff
modified M4M36luster-projects/ka-guardian/runner/runner.py +58 −3
Large diff (104 lines) is not loaded by default to keep the page fast.Load diff
modified M4M36luster-projects/ka-guardian/topology.json +148 −28
Large diff (205 lines) is not loaded by default to keep the page fast.Load diff
modified deploy/courier.sh +13 −7
Large diff (26 lines) is not loaded by default to keep the page fast.Load diff
modified deploy/deploy.sh +12 −4
Large diff (29 lines) is not loaded by default to keep the page fast.Load diff
modified orchestrator/main.py +49 −8
Large diff (69 lines) is not loaded by default to keep the page fast.Load diff
modified runner/runner.py +27 −2
Large diff (45 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme