SPB Git forge

spb/ka-guardian

Public
26commits 1branches 0releases
4.5 MBsize
maindefault branch
19 days agolast push
Python 57.1% Shell 13.8% CSS 12% JavaScript 10% HTML 7.2%

KA Guardian v1 — ka2/ka4/ka6 renaissent en agents autonomes de réparation des connecteurs

Orchestrateurs (M4M36) + runners claude -p sur les nœuds d'apps + courrier zsh
(contournement Local Network Privacy macOS 26) + dashboards live style Groupe KA
+ efforts commandés (nouveau connecteur / enrichissement).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 mo ago (Aug 23, 2026)

35 changed files +6,831 −0

added .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/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/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/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/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/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/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/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/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 +}
added 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/deploy/deploy.sh +106 −0
@@ -0,0 +1,106 @@
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 + PY=\$(command -v /opt/homebrew/bin/python3 || command -v /opt/homebrew/bin/python3.13)
64 + [[ -d .venv ]] || \$PY -m venv .venv
65 + ./.venv/bin/pip -q install 'fastapi>=0.110' 'uvicorn>=0.29' 'httpx>=0.27' >/dev/null
66 + mkdir -p data logs
67 + # retirer les anciens bots (web+bot), garder les tunnels ngrok
68 + for a in ka2 ka4 ka6; do
69 + launchctl unload ~/Library/LaunchAgents/com.\$a.web.plist 2>/dev/null || true
70 + launchctl unload ~/Library/LaunchAgents/com.\$a.bot.plist 2>/dev/null || true
71 + rm -f ~/Library/LaunchAgents/com.\$a.web.plist ~/Library/LaunchAgents/com.\$a.bot.plist
72 + done"
73 + for a in ka2:8799 ka4:8899 ka6:8999; do
74 + agent=${a%%:*}; port=${a##*:}
75 + ssh $ORCH_NODE "cat > ~/Library/LaunchAgents/com.$agent.guardian.plist <<PLIST
76 +<?xml version=\"1.0\" encoding=\"UTF-8\"?>
77 +<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">
78 +<plist version=\"1.0\"><dict>
79 + <key>Label</key><string>com.$agent.guardian</string>
80 + <key>ProgramArguments</key><array>
81 + <string>/Users/simon-pierreboucher/cluster-projects/ka-guardian/.venv/bin/python</string>
82 + <string>/Users/simon-pierreboucher/cluster-projects/ka-guardian/orchestrator/main.py</string>
83 + </array>
84 + <key>EnvironmentVariables</key><dict>
85 + <key>AGENT</key><string>$agent</string>
86 + <key>KA_GUARDIAN_SELF_IP</key><string>192.168.2.69</string>
87 + <key>PATH</key><string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
88 + </dict>
89 + <key>RunAtLoad</key><true/><key>KeepAlive</key><true/>
90 + <key>StandardOutPath</key><string>/Users/simon-pierreboucher/cluster-projects/ka-guardian/logs/$agent.log</string>
91 + <key>StandardErrorPath</key><string>/Users/simon-pierreboucher/cluster-projects/ka-guardian/logs/$agent.log</string>
92 +</dict></plist>
93 +PLIST
94 + launchctl unload ~/Library/LaunchAgents/com.$agent.guardian.plist 2>/dev/null || true
95 + launchctl load ~/Library/LaunchAgents/com.$agent.guardian.plist"
96 + sleep 2
97 + ssh $ORCH_NODE "curl -sf localhost:$port/health" && echo " ✓ $agent ok (:$port)" || echo " ✗ $agent KO"
98 + done
99 +}
100 +
101 +case "${1:-all}" in
102 + runners) deploy_runners ;;
103 + orchestrators) deploy_orchestrators ;;
104 + all) deploy_runners; deploy_orchestrators ;;
105 +esac
106 +echo "terminé."
added 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/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/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/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/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/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 +}
added 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/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
added M4M36luster-projects/ka-guardian/deploy/deploy.sh +135 −0
@@ -0,0 +1,135 @@
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_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 +
52 +deploy_runners() {
53 + for n in $RUNNER_NODES; do
54 + echo "=== runner → $n"
55 + ssh $n "mkdir -p ~/ka-guardian-runner/transcripts"
56 + scp -q "$ROOT/runner/runner.py" $n:ka-guardian-runner/runner.py
57 + ssh $n "printf '%s\nKA_GUARDIAN_NODE=%s\n' '$TOKEN_LINE' '$n' > ~/.ka-guardian.env
58 + cd ~/ka-guardian-runner
59 + [[ -d .venv ]] || $PY -m venv .venv
60 + ./.venv/bin/pip -q install 'fastapi>=0.110' 'uvicorn>=0.29' 'httpx>=0.27' 'pydantic>=2' >/dev/null
61 + cat > ~/Library/LaunchAgents/com.ka.guardian-runner.plist <<'PLIST'
62 +<?xml version=\"1.0\" encoding=\"UTF-8\"?>
63 +<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">
64 +<plist version=\"1.0\"><dict>
65 + <key>Label</key><string>com.ka.guardian-runner</string>
66 + <key>ProgramArguments</key><array>
67 + <string>/Users/simon-pierreboucher/ka-guardian-runner/.venv/bin/python</string>
68 + <string>/Users/simon-pierreboucher/ka-guardian-runner/runner.py</string>
69 + </array>
70 + <key>EnvironmentVariables</key><dict>
71 + <key>PATH</key><string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
72 + </dict>
73 + <key>RunAtLoad</key><true/><key>KeepAlive</key><true/>
74 + <key>StandardOutPath</key><string>/Users/simon-pierreboucher/ka-guardian-runner/runner.log</string>
75 + <key>StandardErrorPath</key><string>/Users/simon-pierreboucher/ka-guardian-runner/runner.log</string>
76 +</dict></plist>
77 +PLIST
78 + launchctl unload ~/Library/LaunchAgents/com.ka.guardian-runner.plist 2>/dev/null || true
79 + launchctl load ~/Library/LaunchAgents/com.ka.guardian-runner.plist"
80 + sleep 2
81 + ssh $n "curl -sf localhost:7791/health" && echo " ✓ $n runner ok" || echo " ✗ $n runner KO"
82 + done
83 +}
84 +
85 +deploy_orchestrators() {
86 + echo "=== orchestrateurs → $ORCH_NODE"
87 + ssh $ORCH_NODE "printf '%s\n' '$TOKEN_LINE' > ~/.ka-guardian.env; mkdir -p ~/cluster-projects/ka-guardian"
88 + rsync -az --delete --exclude data --exclude .venv --exclude .git \
89 + "$ROOT/" $ORCH_NODE:cluster-projects/ka-guardian/
90 + ssh $ORCH_NODE "cd ~/cluster-projects/ka-guardian
91 + PY=\$(command -v /opt/homebrew/bin/python3 || command -v /opt/homebrew/bin/python3.13)
92 + [[ -d .venv ]] || \$PY -m venv .venv
93 + ./.venv/bin/pip -q install 'fastapi>=0.110' 'uvicorn>=0.29' 'httpx>=0.27' >/dev/null
94 + mkdir -p data logs
95 + # retirer les anciens bots (web+bot), garder les tunnels ngrok
96 + for a in ka2 ka4 ka6; do
97 + launchctl unload ~/Library/LaunchAgents/com.\$a.web.plist 2>/dev/null || true
98 + launchctl unload ~/Library/LaunchAgents/com.\$a.bot.plist 2>/dev/null || true
99 + rm -f ~/Library/LaunchAgents/com.\$a.web.plist ~/Library/LaunchAgents/com.\$a.bot.plist
100 + done"
101 + for a in ka2:8799 ka4:8899 ka6:8999; do
102 + agent=${a%%:*}; port=${a##*:}
103 + ssh $ORCH_NODE "cat > ~/Library/LaunchAgents/com.$agent.guardian.plist <<PLIST
104 +<?xml version=\"1.0\" encoding=\"UTF-8\"?>
105 +<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">
106 +<plist version=\"1.0\"><dict>
107 + <key>Label</key><string>com.$agent.guardian</string>
108 + <key>ProgramArguments</key><array>
109 + <string>/Users/simon-pierreboucher/cluster-projects/ka-guardian/.venv/bin/python</string>
110 + <string>/Users/simon-pierreboucher/cluster-projects/ka-guardian/orchestrator/main.py</string>
111 + </array>
112 + <key>EnvironmentVariables</key><dict>
113 + <key>AGENT</key><string>$agent</string>
114 + <key>KA_GUARDIAN_SELF_IP</key><string>192.168.2.69</string>
115 + <key>PATH</key><string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
116 + </dict>
117 + <key>RunAtLoad</key><true/><key>KeepAlive</key><true/>
118 + <key>StandardOutPath</key><string>/Users/simon-pierreboucher/cluster-projects/ka-guardian/logs/$agent.log</string>
119 + <key>StandardErrorPath</key><string>/Users/simon-pierreboucher/cluster-projects/ka-guardian/logs/$agent.log</string>
120 +</dict></plist>
121 +PLIST
122 + launchctl unload ~/Library/LaunchAgents/com.$agent.guardian.plist 2>/dev/null || true
123 + launchctl load ~/Library/LaunchAgents/com.$agent.guardian.plist"
124 + sleep 2
125 + ssh $ORCH_NODE "curl -sf localhost:$port/health" && echo " ✓ $agent ok (:$port)" || echo " ✗ $agent KO"
126 + done
127 +}
128 +
129 +case "${1:-all}" in
130 + courier) deploy_courier ;;
131 + runners) deploy_runners ;;
132 + orchestrators) deploy_orchestrators ;;
133 + all) deploy_courier; deploy_runners; deploy_orchestrators ;;
134 +esac
135 +echo "terminé."
added M4M36luster-projects/ka-guardian/orchestrator/main.py +772 −0
@@ -0,0 +1,772 @@
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 +
59 +
60 +SPOOL = HOME / "ka-guardian-spool"
61 +
62 +
63 +async def lan_post(ip: str, port: int, path: str, payload: dict[str, Any], timeout: int = 30) -> tuple[int, str]:
64 + """POST JSON vers un autre nœud via le courrier zsh (deploy/courier.sh).
65 +
66 + macOS 26 « Local Network Privacy » refuse le trafic LAN dès que python
67 + (homebrew) est dans la chaîne de processus — même ses enfants ssh/curl.
68 + On dépose donc un job dans un spool disque ; un service launchd 100 % zsh
69 + l'expédie (ssh → curl localhost du nœud cible) et dépose la réponse.
70 + """
71 + for d in ("outbox", "done", "tmp"):
72 + (SPOOL / d).mkdir(parents=True, exist_ok=True)
73 + jid = uuid.uuid4().hex
74 + tmp = SPOOL / "tmp" / f"{jid}.job"
75 + tmp.write_text(f"{ip} {port} {path} {timeout}\n" + jdump(payload))
76 + tmp.rename(SPOOL / "outbox" / f"{jid}.job")
77 + resp = SPOOL / "done" / f"{jid}.resp"
78 + deadline = time.time() + timeout + 25
79 + while time.time() < deadline:
80 + if resp.exists():
81 + txt = resp.read_text()
82 + resp.unlink(missing_ok=True)
83 + body, _, code = txt.strip().rpartition("\n")
84 + if code.startswith("courier_ssh_rc"):
85 + raise ConnectionError(f"{code} vers {ip}:{port}{path}")
86 + return int(code or "0"), body
87 + await asyncio.sleep(0.3)
88 + raise TimeoutError(f"courrier sans réponse pour {ip}:{port}{path}")
89 +DATA = ROOT / "data"
90 +DATA.mkdir(exist_ok=True)
91 +DB_PATH = DATA / f"{AGENT}.db"
92 +
93 +# ---------------------------------------------------------------- SQLite ---
94 +
95 +def db() -> sqlite3.Connection:
96 + conn = sqlite3.connect(DB_PATH)
97 + conn.row_factory = sqlite3.Row
98 + conn.execute("PRAGMA journal_mode=WAL")
99 + return conn
100 +
101 +
102 +def init_db() -> None:
103 + with db() as c:
104 + c.executescript("""
105 + CREATE TABLE IF NOT EXISTS incidents(
106 + id TEXT PRIMARY KEY, service TEXT, source TEXT, status_detected TEXT,
107 + state TEXT, attempts INTEGER DEFAULT 0, created REAL, updated REAL,
108 + resolved REAL, detail TEXT);
109 + CREATE TABLE IF NOT EXISTS missions(
110 + id TEXT PRIMARY KEY, incident_id TEXT, service TEXT, source TEXT,
111 + node TEXT, state TEXT, base_commit TEXT, commits TEXT, verdict TEXT,
112 + cost_usd REAL, num_turns INTEGER, health TEXT, started REAL, ended REAL);
113 + CREATE TABLE IF NOT EXISTS events(
114 + id INTEGER PRIMARY KEY AUTOINCREMENT, mission_id TEXT, ts REAL,
115 + type TEXT, data TEXT);
116 + CREATE TABLE IF NOT EXISTS snapshots(
117 + ts REAL PRIMARY KEY, mine TEXT, ecosystem TEXT);
118 + CREATE INDEX IF NOT EXISTS ev_mission ON events(mission_id);
119 + """)
120 +
121 +
122 +def now() -> float:
123 + return time.time()
124 +
125 +
126 +def jdump(x: Any) -> str:
127 + return json.dumps(x, ensure_ascii=False)
128 +
129 +
130 +# ------------------------------------------------------------------- SSE ---
131 +
132 +class Hub:
133 + def __init__(self) -> None:
134 + self.clients: set[asyncio.Queue] = set()
135 +
136 + async def publish(self, msg: dict[str, Any]) -> None:
137 + for q in list(self.clients):
138 + if q.qsize() < 500:
139 + q.put_nowait(msg)
140 +
141 + def publish_sync(self, msg: dict[str, Any]) -> None:
142 + if LOOP:
143 + asyncio.run_coroutine_threadsafe(self.publish(msg), LOOP)
144 +
145 +
146 +hub = Hub()
147 +LOOP: asyncio.AbstractEventLoop | None = None
148 +LATEST: dict[str, Any] = {"mine": {}, "ecosystem": {}, "polled": 0, "paused": False}
149 +
150 +# ------------------------------------------------------------- missions ----
151 +
152 +def active_incident(c: sqlite3.Connection, service: str, source: str) -> sqlite3.Row | None:
153 + return c.execute(
154 + "SELECT * FROM incidents WHERE service=? AND source=? AND state IN "
155 + "('open','dispatched','fixing','watching','cooldown') ORDER BY created DESC LIMIT 1",
156 + (service, source)).fetchone()
157 +
158 +
159 +def set_incident(c: sqlite3.Connection, iid: str, **kw: Any) -> None:
160 + kw["updated"] = now()
161 + keys = ",".join(f"{k}=?" for k in kw)
162 + c.execute(f"UPDATE incidents SET {keys} WHERE id=?", (*kw.values(), iid))
163 +
164 +
165 +def incident_event(iid: str, service: str, source: str, state: str, note: str = "") -> None:
166 + hub.publish_sync({"kind": "incident", "incident_id": iid, "service": service,
167 + "source": source, "state": state, "note": note, "ts": now()})
168 +
169 +
170 +def service_situation(service: str) -> str:
171 + """Portrait santé de l'app entière — permet à l'agent de distinguer une
172 + panne isolée d'un problème systémique (app entière, réseau, quota)."""
173 + block = LATEST["mine"].get(service) or {}
174 + summary = block.get("summary") or {}
175 + app_row = block.get("app") or {}
176 + sick = [f"{c['source']} ({c['status']})" for c in block.get("connectors", [])
177 + if c.get("status") not in ("ok", None)][:15]
178 + lines = [f"- app entière: {app_row.get('status', '?')} — {summary.get('ok', '?')} ok, "
179 + f"{summary.get('degraded', 0)} dégradés, {summary.get('broken', 0)} cassés, {summary.get('stale', 0)} endormis"]
180 + if sick:
181 + lines.append(f"- autres connecteurs non-ok de cette app: {', '.join(sick)}")
182 + lines.append("- Si BEAUCOUP de connecteurs sont touchés en même temps, le problème est probablement "
183 + "systémique (scheduler de l'app, réseau, quota API): diagnostique au niveau app, pas source par source.")
184 + else:
185 + lines.append("- Tous les autres connecteurs de l'app sont sains: la panne est isolée à cette source.")
186 + return "\n".join(lines)
187 +
188 +
189 +def mission_history(service: str, source: str) -> str:
190 + """Résumé des missions précédentes sur ce connecteur (éviter de refaire ce qui a échoué)."""
191 + with db() as c:
192 + rows = c.execute(
193 + "SELECT m.started, m.verdict, m.commits FROM missions m WHERE m.service=? AND m.source=? "
194 + "AND m.state!='running' ORDER BY m.started DESC LIMIT 3", (service, source)).fetchall()
195 + if not rows:
196 + return "- aucune: c'est la première intervention sur ce connecteur."
197 + out = []
198 + for r in rows:
199 + v = json.loads(r["verdict"] or "{}")
200 + out.append(f"- {time.strftime('%Y-%m-%d %H:%M', time.localtime(r['started']))}: "
201 + f"verdict={v.get('verdict', '?')} — {str(v.get('diagnostic', ''))[:200]} "
202 + f"(actions: {str(v.get('actions', ''))[:150]})")
203 + out.append("NE RÉPÈTE PAS une approche qui a déjà échoué ci-dessus: change d'angle.")
204 + return "\n".join(out)
205 +
206 +
207 +def build_prompt(service: str, source: str, health: dict[str, Any]) -> str:
208 + svc = SERVICES[service]
209 + sync_proc = next((p for p in svc["pm2"] if "sync" in p or "etl" in p), svc["pm2"][-1])
210 + node_alias = NODES[svc["node"]]["alias"]
211 + return f"""Tu es {AGENT}, agent gardien autonome des connecteurs du Groupe KA. Mission: réparer le connecteur « {source} » de l'app {svc['app']} (service api-ka: {service}, site {svc.get('site', '')}).
212 +
213 +== CONTEXTE SANTÉ DU CONNECTEUR (supervision api-ka, scan aux 2 h) ==
214 +- statut détecté: {health.get('status')} | échecs consécutifs: {health.get('consecutive_failures')}
215 +- dernier succès: {health.get('last_success')} | volume au dernier sync: {health.get('found_last')} (médiane historique: {health.get('median_found')})
216 +- message: {health.get('message')}
217 +Rappel des statuts: broken = ≥3 syncs consécutifs en échec ou à 0 résultat; stale = aucun succès depuis > 2× la cadence attendue; degraded = volume < 50 % de la médiane.
218 +
219 +== SITUATION DE L'APP ==
220 +{service_situation(service)}
221 +
222 +== INTERVENTIONS PRÉCÉDENTES SUR CE CONNECTEUR ==
223 +{mission_history(service, source)}
224 +
225 +== TON ENVIRONNEMENT ==
226 +Tu es sur le nœud {node_alias}, ton répertoire courant est le repo de l'app: {svc['dir']} (source de vérité, développement remote-first).
227 +L'app tourne via pm2 ({', '.join(svc['pm2'])}); le process de synchronisation des connecteurs est {sync_proc}; site web local sur le port {svc['web_port']}.
228 +Le venv Python et/ou node_modules du repo sont déjà installés — utilise-les, jamais d'installation globale.
229 +
230 +== DÉMARCHE IMPOSÉE (dans l'ordre) ==
231 +1. IMPRÈGNE-TOI DU REPO: lis le CLAUDE.md et/ou README du repo s'ils existent, et surtout la doc du connecteur si elle existe: cherche `docs/connecteurs/` (fiches générées par app, souvent une par source: URL de la source, stratégie de fetch, format, pièges connus). `grep -ri "{source}"` pour localiser le code du connecteur, sa config et son éventuelle entrée de registre.
232 +2. LIS LES LOGS: dossier logs/ du repo + `pm2 logs {sync_proc} --nostream --lines 300` — cherche les traces d'erreur de « {source} » (traceback, code HTTP, timeout).
233 +3. REPRODUIS: exécute le connecteur ou son fetch directement (la plupart des apps ont un runner par source — la doc/le code du scheduler te montrera comment; sinon appelle la fonction de fetch dans un petit script via le venv du repo). Constate l'erreur réelle.
234 +4. DIAGNOSTIQUE la cause racine: HTML/sélecteurs changés? endpoint JSON déplacé? 403/429 anti-bot? pagination cassée? redirection? certificat/TLS (gotcha connu: certains sites cassent avec requests → utiliser curl via subprocess)? sitemap figé?
235 +5. CORRIGE de façon MINIMALE et ciblée, en respectant les conventions et l'architecture du repo (mêmes patterns que les connecteurs voisins). Si le site bloque, la pile d'escalade du Groupe KA est disponible, clés dans ~/.claude/.env: requêtes directes → curl → Scrapfly (SCRAPFLY_API_KEY, render_js/asp) → Bright Data Web Unlocker → proxies résidentiels Oxylabs (pr.oxylabs.io:7777, -cc-CA) → Serper/Tavily pour retrouver une source déplacée. Regarde comment le repo utilise déjà ces services et fais pareil.
236 +6. RE-TESTE réellement: le connecteur doit rapporter un volume plausible (ordre de grandeur de la médiane {health.get('median_found')}). INTERDIT d'inventer, stubber ou câbler des données en dur. Un test qui retourne 0 ou 3 items quand la médiane est {health.get('median_found')} N'EST PAS une réparation.
237 +7. REDÉMARRE uniquement le process concerné: `pm2 restart {sync_proc}`. Puis vérifie que le site répond: `curl -s -o /dev/null -w '%{{http_code}}' http://localhost:{svc['web_port']}/` (attendu: 200/3xx).
238 +8. COMMITTE: `git add -A && git commit -m "[{AGENT}] fix connecteur {source}: <résumé court>"`. NE PUSH JAMAIS. Si tu as touché plusieurs fichiers pour des raisons distinctes, fais des commits séparés et clairs.
239 +
240 +== INTERDITS ABSOLUS ==
241 +Toucher aux autres connecteurs ou aux autres apps du nœud; modifier la config pm2/ngrok/launchd ou le schéma de la base; installer des paquets globaux; supprimer des données; git push; toucher à ~/.ssh, aux clés API ou aux fichiers hors du repo. Ne désactive JAMAIS un connecteur pour « réparer » sa santé. Si la source est définitivement morte (site fermé, domaine à vendre, 404 permanent confirmé), ne force rien: documente et conclus en verdict site_source_mort.
242 +
243 +== FIN DE MISSION ==
244 +Termine ta TOUTE DERNIÈRE réponse par un bloc JSON exactement de cette forme:
245 +{{"verdict": "repare|echec|site_source_mort|rien_a_faire", "diagnostic": "cause racine en 1-2 phrases", "actions": "ce que tu as fait", "test": "résultat mesuré du test final (volume obtenu)", "fichiers": ["fichiers modifiés"]}}
246 +Sois honnête: si ta réparation n'est pas prouvée par un test réel, le verdict est echec — un faux « repare » sera détecté par la supervision et rollback automatiquement."""
247 +
248 +
249 +COMMON_RULES = """== RÈGLES COMMUNES (non négociables) ==
250 +- Session 100 % AUTONOME: tu ne poses AUCUNE question, tu ne demandes AUCUNE validation, tu travailles jusqu'au bout. S'il faut trancher, tranche selon les conventions du repo et le bon sens, et documente ton choix dans le commit.
251 +- Respecte l'architecture et les patterns du repo (regarde comment les connecteurs voisins sont faits AVANT d'écrire).
252 +- Utilise le venv/node_modules du repo; jamais d'installation globale.
253 +- INTERDIT d'inventer, stubber ou câbler des données en dur. Chaque donnée vient d'un vrai fetch.
254 +- INTERDITS: toucher aux autres apps du nœud, config pm2/ngrok/launchd, schéma de la base (sauf migration prévue par le repo), git push, ~/.ssh, clés API, suppression de données.
255 +- À la fin: `pm2 restart <process de sync>`, vérifie que le site répond (curl localhost), puis committe en messages clairs préfixés [%AGENT%]. NE PUSH JAMAIS."""
256 +
257 +
258 +def effort_stack(service: str) -> str:
259 + svc = SERVICES[service]
260 + return f"""== BOÎTE À OUTILS (clés dans ~/.claude/.env) ==
261 +- DÉCOUVERTE: Serper (SERPER_API_KEY, POST https://google.serper.dev/search, gl=ca hl=fr) pour trouver sources/sitemaps/endpoints; Tavily (TAVILY_API_KEY) en complément.
262 +- FETCH — escalade dans cet ordre: requêtes directes (requests/fetch du repo) → curl via subprocess (contourne les gotchas TLS de requests) → Scrapfly (SCRAPFLY_API_KEY, render_js/asp, backend=auto) → Bright Data Web Unlocker → proxies résidentiels Oxylabs (pr.oxylabs.io:7777, user avec -cc-CA, sessions collantes).
263 +- APIFY (APIFY_TOKEN): en dernier recours pour les plateformes dures, acteurs maison du compte (proxy résidentiel intégré).
264 +- Regarde d'abord comment le repo de {svc['app']} utilise déjà ces services et fais pareil."""
265 +
266 +
267 +def build_effort_prompt(kind: str, service: str, source: str, note: str) -> str:
268 + svc = SERVICES[service]
269 + sync_proc = next((p for p in svc["pm2"] if "sync" in p or "etl" in p), svc["pm2"][-1])
270 + node_alias = NODES[svc["node"]]["alias"]
271 + rules = COMMON_RULES.replace("%AGENT%", AGENT)
272 + env = f"""== TON ENVIRONNEMENT ==
273 +Tu es sur le nœud {node_alias}, répertoire courant = repo de l'app: {svc['dir']} (remote-first, source de vérité).
274 +L'app tourne via pm2 ({', '.join(svc['pm2'])}); process de sync: {sync_proc}; site local port {svc['web_port']}.
275 +Commence par lire le CLAUDE.md / README du repo et docs/connecteurs/ s'ils existent."""
276 + if kind == "effort_new":
277 + return f"""Tu es {AGENT}, agent gardien autonome du Groupe KA. EFFORT COMMANDÉ: ajouter UN NOUVEAU CONNECTEUR de qualité production à l'app {svc['app']} (service {service}, site {svc.get('site', '')}).
278 +{"Consigne de l'opérateur: " + note if note else "Aucune consigne particulière: choisis la source la plus utile."}
279 +
280 +{env}
281 +
282 +== DÉMARCHE ==
283 +1. CARTOGRAPHIE L'EXISTANT: liste les connecteurs actuels de l'app (registre/scheduler + docs/connecteurs) pour comprendre ce qui est déjà couvert et le pattern d'implémentation exact d'un connecteur (structure, signature, enregistrement, cache, dédup).
284 +2. DÉCOUVRE avec Serper: cherche des sources québécoises pertinentes pour {svc['app']} NON couvertes (sitemaps, pages listant des items, endpoints JSON internes découverts via les pages). Évalue 3-5 candidats: volume estimé, faisabilité du fetch, qualité des données, stabilité. CHOISIS le meilleur.
285 +3. IMPLÉMENTE le connecteur en suivant le pattern exact des connecteurs voisins: fetch (avec escalade si bloqué), parsing complet (tous les champs que l'app affiche, fiche détail incluse si le pattern le fait), dédup, enregistrement au registre/scheduler.
286 +4. TESTE réellement: exécute-le au complet, il doit rapporter un volume substantiel et des items complets et corrects (vérifie 3-4 items à la main contre le site source).
287 +5. DOCUMENTE: ajoute la fiche docs/connecteurs/<source>.md si le repo suit cette convention (URL, stratégie, pièges).
288 +6. Redémarre {sync_proc}, vérifie le site, committe.
289 +
290 +{effort_stack(service)}
291 +
292 +{rules}
293 +
294 +== FIN DE MISSION ==
295 +Termine ta TOUTE DERNIÈRE réponse par un bloc JSON:
296 +{{"verdict": "livre|echec", "diagnostic": "source choisie et pourquoi", "actions": "ce qui a été construit", "test": "volume obtenu + vérifications", "fichiers": ["fichiers créés/modifiés"]}}"""
297 + return f"""Tu es {AGENT}, agent gardien autonome du Groupe KA. EFFORT COMMANDÉ: ENRICHIR le connecteur existant « {source} » de l'app {svc['app']} (service {service}, site {svc.get('site', '')}).
298 +{"Consigne de l'opérateur: " + note if note else "Aucune consigne particulière: maximise la valeur (couverture, champs, robustesse)."}
299 +
300 +{env}
301 +
302 +== DÉMARCHE ==
303 +1. ÉTUDIE le connecteur « {source} »: code, fiche docs/connecteurs, logs récents, volume actuel vs médiane, champs remplis vs champs que l'app sait afficher.
304 +2. IDENTIFIE les enrichissements à plus forte valeur, par exemple: pagination complète (couvre TOUT le site source, pas la première page), fiche détail (champs manquants: descriptions, photos, prix, coordonnées, dates), robustesse (retries, escalade anti-bot, tolérance aux changements de DOM), fraîcheur (détection de retraits), dédup plus fine.
305 +3. IMPLÉMENTE proprement dans le pattern du repo, SANS casser le format de sortie existant (les consommateurs avals dépendent du schéma).
306 +4. TESTE réellement: exécute le connecteur au complet, compare avant/après (volume, complétude des champs), vérifie 3-4 items à la main.
307 +5. METS À JOUR la fiche docs/connecteurs/<source>.md si elle existe.
308 +6. Redémarre {sync_proc}, vérifie le site, committe.
309 +
310 +{effort_stack(service)}
311 +
312 +{rules}
313 +
314 +== FIN DE MISSION ==
315 +Termine ta TOUTE DERNIÈRE réponse par un bloc JSON:
316 +{{"verdict": "livre|echec", "diagnostic": "état initial constaté", "actions": "enrichissements livrés", "test": "avant/après mesuré", "fichiers": ["fichiers modifiés"]}}"""
317 +
318 +
319 +async def dispatch(incident: sqlite3.Row, health: dict[str, Any]) -> None:
320 + service, source, iid = incident["service"], incident["source"], incident["id"]
321 + svc = SERVICES[service]
322 + node = svc["node"]
323 + ip = NODES[node]["lan_ip"]
324 + mid = uuid.uuid4().hex[:12]
325 + my_ip = os.environ.get("KA_GUARDIAN_SELF_IP", "192.168.2.69")
326 + kind = incident["status_detected"]
327 + if kind in ("effort_new", "effort_enrich"):
328 + prompt = build_effort_prompt(kind, service, source, health.get("note", ""))
329 + max_turns, timeout = POLICY.get("effort_max_turns", 150), POLICY.get("effort_timeout_seconds", 7200)
330 + else:
331 + prompt = build_prompt(service, source, health)
332 + max_turns, timeout = POLICY["mission_max_turns"], POLICY["mission_timeout_seconds"]
333 + payload = {
334 + "mission_id": mid, "agent": AGENT, "service": service, "source": source,
335 + "dir": svc["dir"], "pm2": svc["pm2"], "web_port": svc["web_port"],
336 + "model": ME.get("model", "sonnet"),
337 + "max_turns": max_turns,
338 + "timeout_seconds": timeout,
339 + "prompt": prompt,
340 + "callback_url": f"http://{my_ip}:{ME['port']}/api/ingest/{mid}",
341 + }
342 + try:
343 + code, body = await lan_post(ip, RUNNER_PORT, "/missions", payload, timeout=25)
344 + if code == 409:
345 + return # runner occupé, on retentera au prochain tick
346 + if code != 200:
347 + raise ConnectionError(f"runner {code}: {body[:200]}")
348 + except Exception as exc:
349 + print(f"[dispatch] {service}/{source} → {type(exc).__name__}: {exc}", flush=True)
350 + hub.publish_sync({"kind": "log", "level": "error", "msg": f"dispatch {source}: {exc}", "ts": now()})
351 + return
352 + with db() as c:
353 + c.execute("INSERT INTO missions(id,incident_id,service,source,node,state,started) VALUES(?,?,?,?,?,?,?)",
354 + (mid, iid, service, source, node, "running", now()))
355 + set_incident(c, iid, state="fixing", attempts=incident["attempts"] + 1)
356 + incident_event(iid, service, source, "fixing", f"mission {mid} dépêchée sur {NODES[node]['alias']}")
357 + hub.publish_sync({"kind": "mission", "mission_id": mid, "service": service,
358 + "source": source, "state": "running", "ts": now()})
359 +
360 +
361 +async def rollback_mission(mission: sqlite3.Row, reason: str) -> dict[str, Any]:
362 + svc = SERVICES[mission["service"]]
363 + ip = NODES[mission["node"]]["lan_ip"]
364 + try:
365 + code, body = await lan_post(ip, RUNNER_PORT, "/rollback",
366 + {"dir": svc["dir"], "base_commit": mission["base_commit"],
367 + "pm2": svc["pm2"], "web_port": svc["web_port"]}, timeout=300)
368 + out = json.loads(body) if code == 200 else {"ok": False, "erreur": f"{code}: {body[:200]}"}
369 + except Exception as exc:
370 + out = {"ok": False, "erreur": str(exc)}
371 + hub.publish_sync({"kind": "rollback", "mission_id": mission["id"], "service": mission["service"],
372 + "source": mission["source"], "reason": reason, "result": out, "ts": now()})
373 + with db() as c:
374 + c.execute("INSERT INTO events(mission_id,ts,type,data) VALUES(?,?,?,?)",
375 + (mission["id"], now(), "rollback", jdump({"reason": reason, "result": out})))
376 + return out
377 +
378 +
379 +# --------------------------------------------------------------- moteur ----
380 +
381 +async def poll_once() -> None:
382 + async with httpx.AsyncClient() as cl:
383 + r = await cl.get(TOPO["apika_monitoring_url"], timeout=15)
384 + data = r.json()["data"]
385 + mine: dict[str, Any] = {}
386 + for service, block in data["services"].items():
387 + assigned_to_me = service in MY_SERVICES or (DEFAULT_AGENT and service not in ASSIGNED)
388 + if assigned_to_me:
389 + mine[service] = block
390 + LATEST.update({"mine": mine, "ecosystem": data["summary"], "polled": now()})
391 + with db() as c:
392 + summary_mine = {s: b["summary"] for s, b in mine.items()}
393 + c.execute("INSERT OR REPLACE INTO snapshots(ts,mine,ecosystem) VALUES(?,?,?)",
394 + (now(), jdump(summary_mine), jdump(data["summary"])))
395 + c.execute("DELETE FROM snapshots WHERE ts < ?", (now() - 30 * 86400,))
396 + hub.publish_sync({"kind": "snapshot", "mine": {s: b["summary"] for s, b in mine.items()},
397 + "ecosystem": data["summary"], "ts": now()})
398 +
399 + trigger = set(POLICY["trigger_statuses"])
400 + with db() as c:
401 + for service, block in mine.items():
402 + if service not in SERVICES:
403 + continue # service inconnu de la topologie: visible au dashboard, pas d'action
404 + for conn in block.get("connectors", []):
405 + source, status = conn["source"], conn["status"]
406 + inc = active_incident(c, service, source)
407 + if status in trigger and inc is None:
408 + iid = uuid.uuid4().hex[:10]
409 + c.execute("INSERT INTO incidents(id,service,source,status_detected,state,created,updated,detail) "
410 + "VALUES(?,?,?,?,?,?,?,?)",
411 + (iid, service, source, status, "open", now(), now(), jdump(conn)))
412 + incident_event(iid, service, source, "open", f"détecté {status}")
413 + elif status in trigger and inc is not None and inc["state"] in ("open", "cooldown"):
414 + # Ne pas rafraîchir watching/fixing: `updated` sert de chrono
415 + # à la fenêtre de surveillance et au cooldown.
416 + c.execute("UPDATE incidents SET status_detected=?, detail=? WHERE id=?",
417 + (status, jdump(conn), inc["id"]))
418 + elif status == "ok" and inc is not None:
419 + if inc["state"] == "watching":
420 + set_incident(c, inc["id"], state="resolved", resolved=now())
421 + incident_event(inc["id"], service, source, "resolved", "connecteur de retour à ok — réparation confirmée")
422 + elif inc["state"] in ("open", "cooldown"):
423 + set_incident(c, inc["id"], state="self_healed", resolved=now())
424 + incident_event(inc["id"], service, source, "self_healed", "revenu à ok sans intervention")
425 +
426 +
427 +async def tick() -> None:
428 + if LATEST["paused"]:
429 + return
430 + try:
431 + await poll_once()
432 + except Exception as exc:
433 + hub.publish_sync({"kind": "log", "level": "warn", "msg": f"poll api-ka échoué: {exc}", "ts": now()})
434 +
435 + with db() as c:
436 + # 1. Missions zombies (runner mort / callback perdu)
437 + for m in c.execute("SELECT * FROM missions WHERE state='running' AND started < ?",
438 + (now() - POLICY.get("effort_timeout_seconds", 7200) - 900,)).fetchall():
439 + c.execute("UPDATE missions SET state='error', ended=? WHERE id=?", (now(), m["id"]))
440 + inc = c.execute("SELECT * FROM incidents WHERE id=?", (m["incident_id"],)).fetchone()
441 + if inc:
442 + set_incident(c, inc["id"], state="cooldown")
443 + incident_event(inc["id"], m["service"], m["source"], "cooldown", "mission sans réponse (zombie)")
444 +
445 + # 2. Watching → rollback si la fenêtre est passée et toujours cassé
446 + for inc in c.execute("SELECT * FROM incidents WHERE state='watching'").fetchall():
447 + if now() - inc["updated"] < POLICY["watch_window_hours"] * 3600:
448 + continue
449 + status = current_status(inc["service"], inc["source"])
450 + m = c.execute("SELECT * FROM missions WHERE incident_id=? AND base_commit IS NOT NULL "
451 + "ORDER BY started DESC LIMIT 1", (inc["id"],)).fetchone()
452 + if status in POLICY["trigger_statuses"] or status == "degraded":
453 + if m and (json.loads(m["commits"] or "[]")):
454 + asyncio.create_task(rollback_mission(m, "toujours cassé après la fenêtre de surveillance"))
455 + set_incident(c, inc["id"], state="cooldown")
456 + incident_event(inc["id"], inc["service"], inc["source"], "cooldown",
457 + f"non guéri après {POLICY['watch_window_hours']}h → rollback + cooldown")
458 + elif status == "ok":
459 + set_incident(c, inc["id"], state="resolved", resolved=now())
460 + incident_event(inc["id"], inc["service"], inc["source"], "resolved", "confirmé ok")
461 +
462 + # 3. Cooldown expiré → réouverture ou abandon
463 + for inc in c.execute("SELECT * FROM incidents WHERE state='cooldown'").fetchall():
464 + if now() - inc["updated"] < POLICY["attempt_cooldown_hours"] * 3600:
465 + continue
466 + status = current_status(inc["service"], inc["source"])
467 + if status == "ok":
468 + set_incident(c, inc["id"], state="resolved", resolved=now())
469 + incident_event(inc["id"], inc["service"], inc["source"], "resolved", "guéri pendant le cooldown")
470 + elif inc["attempts"] >= POLICY["max_attempts_per_incident"]:
471 + set_incident(c, inc["id"], state="abandoned")
472 + incident_event(inc["id"], inc["service"], inc["source"], "abandoned",
473 + f"{inc['attempts']} tentatives épuisées — intervention humaine requise")
474 + else:
475 + set_incident(c, inc["id"], state="open")
476 + incident_event(inc["id"], inc["service"], inc["source"], "open", "cooldown terminé, nouvelle tentative")
477 +
478 + # 4. Dispatch (1 mission à la fois par agent, broken avant stale, plus vieux d'abord)
479 + running = c.execute("SELECT COUNT(*) n FROM missions WHERE state='running'").fetchone()["n"]
480 + if running < POLICY["max_concurrent_missions"]:
481 + busy_nodes = {m["node"] for m in c.execute("SELECT node FROM missions WHERE state='running'").fetchall()}
482 + nxt = c.execute(
483 + "SELECT * FROM incidents WHERE state='open' "
484 + "ORDER BY CASE status_detected WHEN 'manual' THEN 0 WHEN 'effort_new' THEN 0 "
485 + "WHEN 'effort_enrich' THEN 0 WHEN 'broken' THEN 1 ELSE 2 END, created "
486 + ).fetchall()
487 + for inc in nxt:
488 + if inc["service"] not in SERVICES:
489 + continue
490 + if SERVICES[inc["service"]]["node"] in busy_nodes:
491 + continue
492 + set_incident(c, inc["id"], state="dispatched")
493 + asyncio.create_task(dispatch_row(inc["id"]))
494 + break
495 +
496 +
497 +def current_status(service: str, source: str) -> str:
498 + block = LATEST["mine"].get(service) or {}
499 + for conn in block.get("connectors", []):
500 + if conn["source"] == source:
501 + return conn["status"]
502 + return "inconnu"
503 +
504 +
505 +async def dispatch_row(iid: str) -> None:
506 + with db() as c:
507 + inc = c.execute("SELECT * FROM incidents WHERE id=?", (iid,)).fetchone()
508 + if inc:
509 + await dispatch(inc, json.loads(inc["detail"] or "{}"))
510 + # dispatch() remet fixing; si l'appel a échoué/409, on relâche
511 + with db() as c:
512 + cur = c.execute("SELECT state FROM incidents WHERE id=?", (iid,)).fetchone()
513 + if cur and cur["state"] == "dispatched":
514 + set_incident(c, iid, state="open")
515 +
516 +
517 +async def engine() -> None:
518 + global LOOP
519 + LOOP = asyncio.get_running_loop()
520 + await asyncio.sleep(3)
521 + while True:
522 + try:
523 + await tick()
524 + except Exception as exc:
525 + import traceback
526 + traceback.print_exc()
527 + hub.publish_sync({"kind": "log", "level": "error", "msg": f"tick: {exc}", "ts": now()})
528 + await asyncio.sleep(POLICY["poll_interval_seconds"])
529 +
530 +
531 +# ------------------------------------------------------------------- app ---
532 +
533 +app = FastAPI(title=f"KA Guardian — {AGENT}", docs_url=None, redoc_url=None)
534 +
535 +
536 +@app.on_event("startup")
537 +async def startup() -> None:
538 + init_db()
539 + asyncio.create_task(engine())
540 +
541 +
542 +def check_token(tok: str | None) -> None:
543 + if not TOKEN or tok != TOKEN:
544 + raise HTTPException(status_code=401)
545 +
546 +
547 +@app.post("/api/ingest/{mission_id}")
548 +async def ingest(mission_id: str, req: Request, x_ka_token: str | None = Header(default=None)) -> dict[str, Any]:
549 + check_token(x_ka_token)
550 + ev = await req.json()
551 + etype, data = ev.get("type"), ev.get("data", {})
552 + with db() as c:
553 + c.execute("INSERT INTO events(mission_id,ts,type,data) VALUES(?,?,?,?)",
554 + (mission_id, now(), etype, jdump(data)))
555 + m = c.execute("SELECT * FROM missions WHERE id=?", (mission_id,)).fetchone()
556 + if m:
557 + if etype == "start":
558 + c.execute("UPDATE missions SET base_commit=? WHERE id=?", (data.get("base_commit"), mission_id))
559 + elif etype in ("final", "error"):
560 + await finalize(c, m, etype, data)
561 + hub.publish_sync({"kind": "mission_event", "mission_id": mission_id, "type": etype,
562 + "data": data, "ts": now()})
563 + return {"ok": True}
564 +
565 +
566 +async def finalize(c: sqlite3.Connection, m: sqlite3.Row, etype: str, data: dict[str, Any]) -> None:
567 + inc = c.execute("SELECT * FROM incidents WHERE id=?", (m["incident_id"],)).fetchone()
568 + if etype == "error":
569 + c.execute("UPDATE missions SET state='error', ended=? WHERE id=?", (now(), m["id"]))
570 + if inc:
571 + set_incident(c, inc["id"], state="cooldown")
572 + incident_event(inc["id"], m["service"], m["source"], "cooldown", f"erreur mission: {data.get('error')}")
573 + return
574 + verdict = data.get("verdict", {})
575 + commits = data.get("commits", [])
576 + health = data.get("health", {})
577 + c.execute("UPDATE missions SET state='done', ended=?, commits=?, verdict=?, cost_usd=?, num_turns=?, health=?, base_commit=COALESCE(base_commit,?) WHERE id=?",
578 + (now(), jdump(commits), jdump(verdict), data.get("cost_usd"),
579 + data.get("num_turns"), jdump(health), data.get("base_commit"), m["id"]))
580 + m2 = c.execute("SELECT * FROM missions WHERE id=?", (m["id"],)).fetchone()
581 + v = verdict.get("verdict", "inconnu")
582 + if not inc:
583 + return
584 + if inc["status_detected"] in ("effort_new", "effort_enrich"):
585 + # Les efforts commandés ne passent pas par la surveillance api-ka:
586 + # verdict + santé de l'app décident tout de suite.
587 + if not health.get("ok", True):
588 + asyncio.create_task(rollback_mission(m2, "healthcheck app en échec post-effort"))
589 + set_incident(c, inc["id"], state="abandoned")
590 + incident_event(inc["id"], m["service"], m["source"], "abandoned", "app en mauvaise santé → ROLLBACK immédiat")
591 + elif v == "livre":
592 + set_incident(c, inc["id"], state="resolved", resolved=now())
593 + incident_event(inc["id"], m["service"], m["source"], "resolved",
594 + f"effort livré ({len(commits)} commit{'s' if len(commits) > 1 else ''})")
595 + else:
596 + if commits:
597 + asyncio.create_task(rollback_mission(m2, f"effort verdict {v} → rollback préventif"))
598 + set_incident(c, inc["id"], state="abandoned")
599 + incident_event(inc["id"], m["service"], m["source"], "abandoned", f"effort en échec ({v})")
600 + return
601 + if not health.get("ok", True):
602 + # L'app est tombée → rollback immédiat, quoi qu'ait dit l'agent.
603 + asyncio.create_task(rollback_mission(m2, "healthcheck app en échec post-mission"))
604 + set_incident(c, inc["id"], state="cooldown")
605 + incident_event(inc["id"], m["service"], m["source"], "cooldown", "app en mauvaise santé → ROLLBACK immédiat")
606 + elif v == "repare" and commits:
607 + set_incident(c, inc["id"], state="watching")
608 + incident_event(inc["id"], m["service"], m["source"], "watching",
609 + f"réparation déclarée ({len(commits)} commit) — surveillance {POLICY['watch_window_hours']}h")
610 + elif v in ("echec", "inconnu") and commits:
611 + asyncio.create_task(rollback_mission(m2, f"verdict {v} avec commits → annulation par prudence"))
612 + set_incident(c, inc["id"], state="cooldown")
613 + incident_event(inc["id"], m["service"], m["source"], "cooldown", f"verdict {v} → rollback préventif")
614 + elif v == "site_source_mort":
615 + set_incident(c, inc["id"], state="abandoned")
616 + incident_event(inc["id"], m["service"], m["source"], "abandoned", "source définitivement morte (diagnostic agent)")
617 + elif v in ("rien_a_faire", "repare"):
618 + set_incident(c, inc["id"], state="watching")
619 + incident_event(inc["id"], m["service"], m["source"], "watching", f"verdict {v} — surveillance")
620 + else:
621 + set_incident(c, inc["id"], state="cooldown")
622 + incident_event(inc["id"], m["service"], m["source"], "cooldown", f"verdict {v}")
623 +
624 +
625 +@app.get("/api/state")
626 +def state() -> dict[str, Any]:
627 + with db() as c:
628 + incidents = [dict(r) for r in c.execute(
629 + "SELECT * FROM incidents ORDER BY created DESC LIMIT 200").fetchall()]
630 + missions = [dict(r) for r in c.execute(
631 + "SELECT * FROM missions ORDER BY started DESC LIMIT 100").fetchall()]
632 + stats = c.execute("""SELECT
633 + (SELECT COUNT(*) FROM missions) n_missions,
634 + (SELECT COUNT(*) FROM incidents WHERE state='resolved') n_resolved,
635 + (SELECT COUNT(*) FROM incidents WHERE state IN ('open','dispatched','fixing','watching','cooldown')) n_active,
636 + (SELECT COUNT(*) FROM incidents WHERE state='abandoned') n_abandoned,
637 + (SELECT COUNT(*) FROM events WHERE type='rollback') n_rollbacks,
638 + (SELECT ROUND(SUM(cost_usd),2) FROM missions) cost_total,
639 + (SELECT ROUND(AVG(resolved-created)/3600.0,1) FROM incidents WHERE state='resolved' AND resolved IS NOT NULL) mttr_h
640 + """).fetchone()
641 + history = [dict(r) for r in c.execute(
642 + "SELECT ts, mine FROM snapshots WHERE ts > ? ORDER BY ts", (now() - 7 * 86400,)).fetchall()]
643 + return {
644 + "agent": AGENT, "identity": {k: ME[k] for k in ("domain", "accent", "accent2", "tagline", "model")},
645 + "services": {s: {**SERVICES[s], "node_alias": NODES[SERVICES[s]["node"]]["alias"]} for s in ME["services"]},
646 + "siblings": {a: {"domain": v["domain"], "accent": v["accent"], "services": v["services"]}
647 + for a, v in TOPO["agents"].items() if a != AGENT},
648 + "latest": LATEST, "incidents": incidents, "missions": missions,
649 + "stats": dict(stats), "history": history, "policy": POLICY, "now": now(),
650 + }
651 +
652 +
653 +@app.get("/api/missions/{mission_id}")
654 +def mission_detail(mission_id: str) -> dict[str, Any]:
655 + with db() as c:
656 + m = c.execute("SELECT * FROM missions WHERE id=?", (mission_id,)).fetchone()
657 + if not m:
658 + raise HTTPException(status_code=404)
659 + evs = [dict(r) for r in c.execute(
660 + "SELECT ts,type,data FROM events WHERE mission_id=? ORDER BY id", (mission_id,)).fetchall()]
661 + return {"mission": dict(m), "events": evs}
662 +
663 +
664 +@app.get("/events")
665 +async def sse(request: Request) -> StreamingResponse:
666 + q: asyncio.Queue = asyncio.Queue()
667 + hub.clients.add(q)
668 +
669 + async def gen():
670 + try:
671 + yield f"data: {jdump({'kind': 'hello', 'agent': AGENT, 'ts': now()})}\n\n"
672 + while True:
673 + if await request.is_disconnected():
674 + break
675 + try:
676 + msg = await asyncio.wait_for(q.get(), timeout=25)
677 + yield f"data: {jdump(msg)}\n\n"
678 + except asyncio.TimeoutError:
679 + yield ": keepalive\n\n"
680 + finally:
681 + hub.clients.discard(q)
682 +
683 + return StreamingResponse(gen(), media_type="text/event-stream",
684 + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
685 +
686 +
687 +@app.get("/api/connectors/{service}")
688 +def connectors_list(service: str) -> dict[str, Any]:
689 + """Sources connues du service (pour le menu déroulant d'enrichissement)."""
690 + block = LATEST["mine"].get(service) or {}
691 + return {"service": service,
692 + "connectors": sorted(
693 + ({"source": c["source"], "status": c["status"]} for c in block.get("connectors", [])),
694 + key=lambda x: x["source"])}
695 +
696 +
697 +@app.post("/api/admin/effort")
698 +async def admin_effort(req: Request, x_ka_token: str | None = Header(default=None)) -> dict[str, Any]:
699 + """Commande un effort autonome: nouveau connecteur ou enrichissement."""
700 + check_token(x_ka_token)
701 + body = await req.json()
702 + kind = body.get("kind")
703 + service = body.get("service")
704 + note = (body.get("note") or "").strip()[:2000]
705 + if kind not in ("effort_new", "effort_enrich") or service not in SERVICES:
706 + raise HTTPException(status_code=400, detail="kind ou service invalide")
707 + if kind == "effort_enrich":
708 + source = (body.get("source") or "").strip()
709 + if not source:
710 + raise HTTPException(status_code=400, detail="source requise pour un enrichissement")
711 + else:
712 + source = f"nouveau·{uuid.uuid4().hex[:6]}"
713 + iid = uuid.uuid4().hex[:10]
714 + with db() as c:
715 + c.execute("INSERT INTO incidents(id,service,source,status_detected,state,created,updated,detail) "
716 + "VALUES(?,?,?,?,?,?,?,?)",
717 + (iid, service, source, kind, "open", now(), now(), jdump({"note": note, "kind": kind})))
718 + incident_event(iid, service, source, "open",
719 + "effort commandé: " + ("nouveau connecteur" if kind == "effort_new" else f"enrichissement de {source}"))
720 + return {"ok": True, "incident_id": iid}
721 +
722 +
723 +@app.post("/api/admin/mission")
724 +async def admin_mission(req: Request, x_ka_token: str | None = Header(default=None)) -> dict[str, Any]:
725 + """Déclenche manuellement une mission sur un connecteur (même sain)."""
726 + check_token(x_ka_token)
727 + body = await req.json()
728 + service, source = body["service"], body["source"]
729 + if service not in SERVICES:
730 + raise HTTPException(status_code=400, detail="service inconnu")
731 + detail = {"source": source, "status": "manual", "message": body.get("note", "mission manuelle")}
732 + for conn in (LATEST["mine"].get(service) or {}).get("connectors", []):
733 + if conn["source"] == source:
734 + detail = {**conn, "status": "manual"}
735 + with db() as c:
736 + inc = active_incident(c, service, source)
737 + if inc is None:
738 + iid = uuid.uuid4().hex[:10]
739 + c.execute("INSERT INTO incidents(id,service,source,status_detected,state,created,updated,detail) "
740 + "VALUES(?,?,?,?,?,?,?,?)",
741 + (iid, service, source, "manual", "open", now(), now(), jdump(detail)))
742 + else:
743 + iid = inc["id"]
744 + set_incident(c, iid, state="open", status_detected="manual")
745 + incident_event(iid, service, source, "open", "mission manuelle demandée")
746 + return {"ok": True, "incident_id": iid}
747 +
748 +
749 +@app.post("/api/admin/pause")
750 +async def admin_pause(req: Request, x_ka_token: str | None = Header(default=None)) -> dict[str, Any]:
751 + check_token(x_ka_token)
752 + body = await req.json()
753 + LATEST["paused"] = bool(body.get("paused", True))
754 + return {"ok": True, "paused": LATEST["paused"]}
755 +
756 +
757 +@app.get("/health")
758 +def health() -> dict[str, Any]:
759 + return {"ok": True, "agent": AGENT, "polled": LATEST["polled"], "paused": LATEST["paused"]}
760 +
761 +
762 +@app.get("/")
763 +def index() -> FileResponse:
764 + return FileResponse(BASE / "web" / "index.html")
765 +
766 +
767 +app.mount("/static", StaticFiles(directory=BASE / "web"), name="static")
768 +
769 +
770 +if __name__ == "__main__":
771 + import uvicorn
772 + uvicorn.run(app, host="0.0.0.0", port=ME["port"])
added M4M36luster-projects/ka-guardian/orchestrator/web/app.js +222 −0
@@ -0,0 +1,222 @@
1 +/* KA Guardian — dashboard live (style Groupe KA) */
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", manual: "manuel",
10 + effort_new: "effort · nouveau connecteur", effort_enrich: "effort · enrichissement" };
11 +const VERDICT_FR = { repare: "réparé", livre: "livré", echec: "échec", site_source_mort: "source morte",
12 + rien_a_faire: "rien à faire", inconnu: "inconnu", erreur: "erreur" };
13 +
14 +let STATE = null;
15 +
16 +async function load() {
17 + const r = await fetch("/api/state");
18 + STATE = await r.json();
19 + const id = STATE.identity, num = STATE.agent.replace("ka", "");
20 + document.documentElement.style.setProperty("--agent", id.accent);
21 + document.title = `${STATE.agent.toUpperCase()} Guardian — Groupe KA`;
22 + $("#wm-name").textContent = "ka·" + num;
23 + $("#foot-name").textContent = "ka·" + num;
24 + const apps = Object.values(STATE.services).map((s) => s.app);
25 + $("#hero-label").textContent = `${STATE.agent} — ${id.tagline} · veille sur ${apps.join(", ")}`;
26 + $("#screen-title").textContent = `${STATE.agent} — flux en temps réel`;
27 + $("#siblings").innerHTML = Object.entries(STATE.siblings).map(([a, v]) =>
28 + `<a class="gk-badge" href="https://${v.domain}"><span class="dot" style="background:${v.accent}"></span><b>${a}<span class="ka">·G</span></b></a>`).join(" ");
29 + $("#foot-sites").innerHTML =
30 + Object.values(STATE.services).map((s) => `<a href="${s.site}">${esc(s.app)}</a>`).join("") +
31 + Object.entries(STATE.siblings).map(([a, v]) => `<a href="https://${v.domain}">${a}·guardian</a>`).join("");
32 + renderTiles(); renderIncidents(); renderCoverage(); renderMissions(); initEffortForm();
33 +}
34 +
35 +/* ---- commander un effort ---- */
36 +let effortInit = false;
37 +function initEffortForm() {
38 + const svcSel = $("#ef-service");
39 + if (!effortInit) {
40 + svcSel.innerHTML = Object.entries(STATE.services)
41 + .map(([k, s]) => `<option value="${k}">${esc(s.app)} — ${esc(s.node_alias)}</option>`).join("");
42 + $("#ef-token").value = localStorage.getItem("ka_token") || "";
43 + $("#ef-kind").addEventListener("change", syncEffortKind);
44 + svcSel.addEventListener("change", fillSources);
45 + $("#effort-form").addEventListener("submit", submitEffort);
46 + effortInit = true;
47 + }
48 +}
49 +function syncEffortKind() {
50 + const enrich = $("#ef-kind").value === "effort_enrich";
51 + $("#ef-source-wrap").hidden = !enrich;
52 + if (enrich) fillSources();
53 +}
54 +async function fillSources() {
55 + if ($("#ef-kind").value !== "effort_enrich") return;
56 + const r = await fetch("/api/connectors/" + $("#ef-service").value);
57 + const d = await r.json();
58 + $("#ef-source").innerHTML = d.connectors
59 + .map((c) => `<option value="${esc(c.source)}">${esc(c.source)} (${STATUS_FR[c.status] || c.status})</option>`).join("")
60 + || `<option value="">— aucun connecteur connu —</option>`;
61 +}
62 +async function submitEffort(e) {
63 + e.preventDefault();
64 + const msg = $("#ef-msg"), btn = e.target.querySelector("button");
65 + const token = $("#ef-token").value.trim();
66 + if (!token) { msg.className = "cmd-msg err"; msg.textContent = "jeton d'opérateur requis"; return; }
67 + localStorage.setItem("ka_token", token);
68 + btn.disabled = true; msg.className = "cmd-msg"; msg.textContent = "lancement…";
69 + try {
70 + const r = await fetch("/api/admin/effort", {
71 + method: "POST", headers: { "Content-Type": "application/json", "X-KA-Token": token },
72 + body: JSON.stringify({
73 + kind: $("#ef-kind").value, service: $("#ef-service").value,
74 + source: $("#ef-source") ? $("#ef-source").value : "", note: $("#ef-note").value,
75 + }),
76 + });
77 + const d = await r.json();
78 + if (r.ok && d.ok) {
79 + msg.className = "cmd-msg ok";
80 + msg.textContent = `effort accepté (incident ${d.incident_id}) — la session démarre d'ici ~5 min, suis le flux ⚡`;
81 + $("#ef-note").value = "";
82 + refetch();
83 + } else {
84 + msg.className = "cmd-msg err";
85 + msg.textContent = "refusé: " + (d.detail || r.status);
86 + }
87 + } catch (err) {
88 + msg.className = "cmd-msg err"; msg.textContent = "erreur: " + err;
89 + } finally { btn.disabled = false; }
90 +}
91 +
92 +function renderTiles() {
93 + const s = STATE.stats, mine = STATE.latest.mine || {};
94 + let total = 0;
95 + for (const b of Object.values(mine)) for (const st of STATUSES) total += (b.summary || {})[st] || 0;
96 + const tiles = [
97 + [total || "—", "connecteurs sous garde", true],
98 + [s.n_active ?? 0, "incidents actifs"],
99 + [s.n_missions ?? 0, "missions lancées"],
100 + [s.n_resolved ?? 0, "réparations confirmées"],
101 + [s.n_rollbacks ?? 0, "rollbacks assumés"],
102 + [s.mttr_h != null ? s.mttr_h + " h" : "—", "temps moyen de guérison"],
103 + [s.cost_total != null ? s.cost_total + " $" : "0 $", "coût API total"],
104 + ];
105 + $("#tiles").innerHTML = tiles.map(([v, l, hot]) =>
106 + `<div class="tile${hot ? " hot" : ""}"><div class="v">${esc(v)}</div><div class="l">${l}</div></div>`).join("");
107 +}
108 +
109 +function renderIncidents() {
110 + const active = STATE.incidents.filter((i) => !["resolved", "self_healed"].includes(i.state)).slice(0, 12);
111 + const recent = STATE.incidents.filter((i) => ["resolved", "self_healed"].includes(i.state)).slice(0, 5);
112 + const row = (i) => `<div class="inc">
113 + <div><div class="src">${esc(i.source)}</div><div class="svc">${esc(i.service)} · ${esc(STATUS_FR[i.status_detected] || i.status_detected)} · ${i.attempts} tentative${i.attempts > 1 ? "s" : ""}</div></div>
114 + <span class="badge b-${esc(i.state)}">${STATE_FR[i.state] || esc(i.state)}</span></div>`;
115 + $("#incidents").innerHTML = (active.length || recent.length)
116 + ? active.map(row).join("") + recent.map(row).join("")
117 + : `<div class="empty">Aucun incident — tous les connecteurs sous garde sont sains. Le gardien veille.</div>`;
118 +}
119 +
120 +function renderCoverage() {
121 + const mine = STATE.latest.mine || {};
122 + $("#coverage").innerHTML = Object.keys(STATE.services).map((svc) => {
123 + const meta = STATE.services[svc], sum = (mine[svc] || {}).summary || {};
124 + const total = STATUSES.reduce((a, st) => a + (sum[st] || 0), 0);
125 + const strip = STATUSES.filter((st) => sum[st] > 0)
126 + .map((st) => `<span class="s-${st}" style="flex:${sum[st]}" title="${STATUS_FR[st]}: ${sum[st]}"></span>`).join("");
127 + const counts = STATUSES.map((st) => `<span><i class="s-${st}"></i>${sum[st] || 0} ${STATUS_FR[st]}</span>`).join("");
128 + return `<div class="cov"><div class="head"><span class="app">${esc(meta.app)}</span>
129 + <span class="node">${esc(meta.node_alias)} · ${total || "?"} connecteurs</span></div>
130 + <div class="strip">${strip || "<span style='flex:1;background:var(--line)'></span>"}</div>
131 + <div class="counts">${counts}</div></div>`;
132 + }).join("");
133 +}
134 +
135 +function renderMissions() {
136 + const vd = (m) => {
137 + if (m.state === "running") return `<span class="vbadge v-running">en cours…</span>`;
138 + if (m.state === "error") return `<span class="vbadge v-erreur">erreur</span>`;
139 + const v = (JSON.parse(m.verdict || "{}").verdict) || "inconnu";
140 + return `<span class="vbadge v-${esc(v)}">${VERDICT_FR[v] || esc(v)}</span>`;
141 + };
142 + $("#missions tbody").innerHTML = STATE.missions.map((m) => {
143 + const commits = JSON.parse(m.commits || "[]").length;
144 + const dur = m.ended ? Math.round((m.ended - m.started) / 60) + " min" : "—";
145 + return `<tr data-id="${esc(m.id)}"><td class="mono">${fmtD(m.started)}</td>
146 + <td class="mono"><b>${esc(m.source)}</b></td><td>${esc((STATE.services[m.service] || {}).app || m.service)}</td>
147 + <td class="mono">${esc(m.node)}</td><td>${vd(m)}</td><td class="mono">${commits || "—"}</td>
148 + <td class="mono">${m.num_turns ? m.num_turns + " tours · " : ""}${dur}</td>
149 + <td class="mono">${m.cost_usd != null ? m.cost_usd.toFixed(2) + " $" : ""}</td></tr>`;
150 + }).join("") || `<tr><td colspan="8" class="empty" style="padding:18px">Aucune mission encore — la première panne détectée lancera le gardien.</td></tr>`;
151 +}
152 +
153 +/* ---- flux en direct ---- */
154 +const feed = $("#feed");
155 +function feedLine(cls, tag, msg, ts) {
156 + if (feed.querySelector(".feed-empty")) feed.innerHTML = "";
157 + const el = document.createElement("div");
158 + el.className = "fe " + cls;
159 + el.innerHTML = `<span class="t">${fmtT(ts || Date.now() / 1000)}</span><span class="tag">${esc(tag)}</span><span class="m">${esc(msg)}</span>`;
160 + feed.appendChild(el);
161 + while (feed.children.length > 400) feed.removeChild(feed.firstChild);
162 + feed.scrollTop = feed.scrollHeight;
163 +}
164 +
165 +let refetchTimer = null;
166 +const refetch = () => { clearTimeout(refetchTimer); refetchTimer = setTimeout(load, 800); };
167 +
168 +function connectSSE() {
169 + const es = new EventSource("/events");
170 + es.onopen = () => $("#live-dot").classList.add("on");
171 + es.onerror = () => $("#live-dot").classList.remove("on");
172 + es.onmessage = (e) => {
173 + const m = JSON.parse(e.data);
174 + if (m.kind === "mission_event") {
175 + const d = m.data || {};
176 + if (m.type === "start") feedLine("tool", "mission", `démarrage — commit de base ${(d.base_commit || "").slice(0, 8)} sur ${d.node || ""}`, m.ts);
177 + else if (m.type === "tool") feedLine("tool", d.name || "outil", d.input || "", m.ts);
178 + else if (m.type === "text") feedLine("text", "agent", d.text || "", m.ts);
179 + else if (m.type === "result") feedLine("text", "résultat", (d.result || "").slice(0, 600), m.ts);
180 + else if (m.type === "final") { feedLine("text", "verdict", JSON.stringify(d.verdict || {}), m.ts); refetch(); }
181 + else if (m.type === "error") { feedLine("rollback", "erreur", d.error || "", m.ts); refetch(); }
182 + else if (m.type === "rollback") feedLine("rollback", "rollback", JSON.stringify(d), m.ts);
183 + } else if (m.kind === "incident") {
184 + feedLine("incident", "incident", `${m.service}/${m.source} → ${STATE_FR[m.state] || m.state}${m.note ? " — " + m.note : ""}`, m.ts);
185 + refetch();
186 + } else if (m.kind === "rollback") {
187 + feedLine("rollback", "rollback", `${m.service}/${m.source} — ${m.reason} (${(m.result || {}).mode || "?"})`, m.ts);
188 + refetch();
189 + } else if (m.kind === "mission") {
190 + feedLine("tool", "mission", `nouvelle mission sur ${m.service}/${m.source}`, m.ts);
191 + refetch();
192 + } else if (m.kind === "snapshot") {
193 + if (STATE) { STATE.latest.mine = Object.fromEntries(Object.entries(m.mine).map(([s, sum]) => [s, { summary: sum }])); renderCoverage(); renderTiles(); }
194 + } else if (m.kind === "log") {
195 + feedLine("incident", m.level || "log", m.msg, m.ts);
196 + }
197 + };
198 +}
199 +
200 +/* ---- modal mission ---- */
201 +document.addEventListener("click", async (e) => {
202 + const tr = e.target.closest("#missions tbody tr[data-id]");
203 + if (tr) {
204 + const r = await fetch("/api/missions/" + tr.dataset.id);
205 + const { mission, events } = await r.json();
206 + const v = JSON.parse(mission.verdict || "{}");
207 + $("#modal-title").textContent = `Mission ${mission.id} — ${mission.source} (${mission.service})`;
208 + $("#modal-body").innerHTML =
209 + `<div class="meta"><b>verdict:</b> ${esc(VERDICT_FR[v.verdict] || v.verdict || mission.state)}\n<b>diagnostic:</b> ${esc(v.diagnostic || "")}\n<b>actions:</b> ${esc(v.actions || "")}\n<b>test:</b> ${esc(v.test || "")}\n<b>commit de base:</b> ${esc(mission.base_commit || "")}\n<b>commits:</b> ${esc((JSON.parse(mission.commits || "[]")).join(" | ") || "aucun")}</div>` +
210 + events.map((ev) => {
211 + const d = JSON.parse(ev.data || "{}");
212 + const txt = ev.type === "tool" ? `${d.name}: ${d.input}` : ev.type === "text" ? d.text
213 + : ev.type === "result" ? d.result : JSON.stringify(d);
214 + return `<div class="meta"><b>${fmtT(ev.ts)} · ${esc(ev.type)}</b>\n${esc((txt || "").slice(0, 2500))}</div>`;
215 + }).join("");
216 + $("#modal").hidden = false;
217 + }
218 + if (e.target.id === "modal-close" || e.target.id === "modal") $("#modal").hidden = true;
219 +});
220 +
221 +load().then(connectSSE);
222 +setInterval(load, 60000);
added M4M36luster-projects/ka-guardian/orchestrator/web/index.html +147 −0
@@ -0,0 +1,147 @@
1 +<!doctype html>
2 +<html lang="fr-CA">
3 +<head>
4 +<meta charset="utf-8">
5 +<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6 +<title>Guardian — Groupe KA</title>
7 +<meta name="description" content="Agent gardien autonome du Groupe KA : il détecte les connecteurs en panne, les répare avec Claude, surveille la guérison et revient en arrière si ça empire. Tout est public, 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@500;700&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;600;700&display=swap" rel="stylesheet">
11 +<link rel="stylesheet" href="/static/style.css">
12 +</head>
13 +<body>
14 +
15 +<header class="topbar">
16 + <a class="wordmark" href="/"><span id="wm-name">ka·—</span><span class="ka" id="wm-ka">guardian</span></a>
17 + <nav class="topnav">
18 + <a class="gk-badge" href="https://www.groupe-ka.com">groupe<b><span class="ka">·KA</span></b></a>
19 + <span id="siblings"></span>
20 + </nav>
21 +</header>
22 +
23 +<main>
24 + <!-- ============ HÉROS ============ -->
25 + <section class="hero">
26 + <div class="hero-main">
27 + <p class="klabel" id="hero-label">agent gardien autonome</p>
28 + <h1>Il veille. Il répare.<br>Il <span class="hl">rend des comptes</span>.</h1>
29 + <p class="lede" id="lede">Un connecteur tombe en panne quelque part dans l'écosystème ·Ka ?
30 + Cet agent le détecte, ouvre le code, le répare, prouve que ça marche — et si ça empire,
31 + il revient au commit d'avant. Tout ce qu'il fait est public, ici, en direct.</p>
32 + <div class="hero-chips" id="hero-chips">
33 + <span class="chip chip-accent"><span class="live-dot" id="live-dot"></span> en direct</span>
34 + </div>
35 + </div>
36 + <aside class="defcard card">
37 + <p class="klabel">définition</p>
38 + <p class="defword">gar·dien <span class="phon">/ɡaʁ.djɛ̃/</span> <span class="nat">n.m.</span></p>
39 + <ol class="defs">
40 + <li><b>détecte</b> — lit la supervision api-ka toutes les 5 minutes ; un connecteur <i>cassé</i> ou <i>endormi</i> devient un incident.</li>
41 + <li><b>répare</b> — dépêche Claude dans le repo de l'app, sur le nœud même : diagnostic, correctif minimal, test réel, commit.</li>
42 + <li><b>surveille</b> — 8 heures d'observation post-réparation avant de déclarer victoire.</li>
43 + <li><b>recule</b> — l'app tombe ou rien ne guérit ? <i>git reset</i> au commit d'avant, redémarrage, et on le dit publiquement.</li>
44 + </ol>
45 + </aside>
46 + </section>
47 +
48 + <!-- ============ CHIFFRES ============ -->
49 + <section>
50 + <p class="klabel">l'état du gardien</p>
51 + <div class="tiles" id="tiles"></div>
52 + </section>
53 +
54 + <!-- ============ COMMANDER UN EFFORT ============ -->
55 + <section class="commander card">
56 + <div class="cmd-grid">
57 + <div class="cmd-intro">
58 + <p class="klabel">commander un effort</p>
59 + <h2 class="cmd-title">Donne-lui du <span class="hl">travail</span>.</h2>
60 + <p class="cmd-lede">Choisis une plateforme et lance une session Claude Code
61 + <b>100 % autonome</b> sur son nœud : elle découvre (Serper), escalade s'il le faut
62 + (proxys résidentiels, Scrapfly, acteurs Apify), teste pour vrai, committe —
63 + et travaille jusqu'au bout sans rien demander. Tout s'affiche dans le flux.</p>
64 + </div>
65 + <form id="effort-form">
66 + <div class="frow">
67 + <label class="flab">Type d'effort
68 + <select id="ef-kind" class="select">
69 + <option value="effort_new">Nouveau connecteur — découverte + construction</option>
70 + <option value="effort_enrich">Enrichissement d'un connecteur existant</option>
71 + </select>
72 + </label>
73 + <label class="flab">Plateforme
74 + <select id="ef-service" class="select"></select>
75 + </label>
76 + </div>
77 + <label class="flab" id="ef-source-wrap" hidden>Connecteur à enrichir
78 + <select id="ef-source" class="select"></select>
79 + </label>
80 + <label class="flab">Consigne (optionnel)
81 + <input id="ef-note" class="input" placeholder="ex.: vise les microbrasseries de la Côte-Nord, ajoute les fiches détail…">
82 + </label>
83 + <div class="frow frow-end">
84 + <label class="flab">Jeton d'opérateur
85 + <input id="ef-token" type="password" class="input" placeholder="KA_GUARDIAN_TOKEN" autocomplete="off">
86 + </label>
87 + <button type="submit" class="btn-primary">Lancer l'effort →</button>
88 + </div>
89 + <p class="cmd-msg" id="ef-msg"></p>
90 + </form>
91 + </div>
92 + </section>
93 +
94 + <!-- ============ ÉCRAN + INCIDENTS ============ -->
95 + <section class="cols">
96 + <div class="screen card" id="screen">
97 + <div class="screen-head">
98 + <span class="screen-dots"><i></i><i></i><i></i></span>
99 + <span class="screen-title" id="screen-title">flux — tout ce que l'agent fait, en temps réel</span>
100 + </div>
101 + <div class="feed" id="feed"><div class="feed-empty">// en attente d'activité — le gardien scrute api-ka toutes les 5 minutes…</div></div>
102 + </div>
103 + <div class="side">
104 + <div class="card pad">
105 + <p class="klabel">incidents</p>
106 + <div id="incidents" class="incidents"></div>
107 + </div>
108 + <div class="card pad">
109 + <p class="klabel">territoire surveillé</p>
110 + <div id="coverage" class="coverage"></div>
111 + </div>
112 + </div>
113 + </section>
114 +
115 + <!-- ============ MISSIONS ============ -->
116 + <section>
117 + <p class="klabel">registre des missions <span class="hint">— cliquer une ligne pour le déroulé complet, commits inclus</span></p>
118 + <div class="card table-wrap">
119 + <table id="missions">
120 + <thead><tr><th>Quand</th><th>Connecteur</th><th>Service</th><th>Nœud</th><th>Verdict</th><th>Commits</th><th>Durée</th><th>Coût</th></tr></thead>
121 + <tbody></tbody>
122 + </table>
123 + </div>
124 + </section>
125 +</main>
126 +
127 +<footer class="ka-footer">
128 + <div class="foot-in">
129 + <a class="wordmark wordmark-foot" href="/"><span id="foot-name">ka·—</span><span class="ka">guardian</span></a>
130 + <p class="notice"><b>Zéro boîte noire.</b> Chaque diagnostic, chaque commit, chaque rollback de cet agent
131 + est journalisé et affiché ici. Les réparations sont committées dans les repos des plateformes,
132 + jamais poussées sans passage humain.</p>
133 + <p class="sites" id="foot-sites"></p>
134 + <p class="legal">Groupe KA — agrégation automatisée, Québec. <a href="https://www.groupe-ka.com">groupe-ka.com</a></p>
135 + </div>
136 +</footer>
137 +
138 +<div class="modal" id="modal" hidden>
139 + <div class="modal-box card">
140 + <div class="modal-head"><h3 id="modal-title">Mission</h3><button id="modal-close" aria-label="fermer">✕</button></div>
141 + <div class="modal-body" id="modal-body"></div>
142 + </div>
143 +</div>
144 +
145 +<script src="/static/app.js"></script>
146 +</body>
147 +</html>
added M4M36luster-projects/ka-guardian/orchestrator/web/style.css +219 −0
@@ -0,0 +1,219 @@
1 +/* KA Guardian — langage visuel Groupe KA : papier, encre, lime, ombres décalées dures */
2 +:root {
3 + --paper: #f5f3ee; --surface: #fff; --surface-2: #faf9f5;
4 + --ink: #141814; --ink-2: #4d5551; --ink-3: #8b928c;
5 + --line: #14181424; --line-strong: #141814d9;
6 + --green: #1c5c41; --green-soft: #e4f0e9;
7 + --amber: #e8a33d; --amber-soft: #fdf3e2;
8 + --danger: #b3423a; --danger-soft: #fbe9e7;
9 + --stale: #3a5f8a; --stale-soft: #e7edf5;
10 + --lime: #d9f26b; --lime-soft: #f0f9d2; --accent-deep: #123f2e;
11 + --agent: #22d3ee; /* remplacé au chargement par l'accent de l'agent */
12 + --r-card: 10px; --r-ctl: 6px; --r-pill: 999px;
13 + --shadow-off: 6px 6px 0 var(--ink);
14 + --shadow-off-soft: 8px 8px 0 #14181414;
15 + --shadow-off-mid: 4px 4px 0 #1418142e;
16 + --font-display: "Space Grotesk", system-ui, sans-serif;
17 + --font-body: "Inter", system-ui, sans-serif;
18 + --font-mono: "JetBrains Mono", ui-monospace, monospace;
19 + --fs-h1: clamp(30px, 3.2vw + 18px, 52px);
20 +}
21 +* { box-sizing: border-box; margin: 0; }
22 +body { background: var(--paper); color: var(--ink); font: 15px/1.55 var(--font-body);
23 + -webkit-font-smoothing: antialiased; overflow-x: clip; }
24 +a { color: inherit; }
25 +::selection { background: var(--lime); color: var(--ink); }
26 +
27 +.klabel { font-family: var(--font-mono); text-transform: uppercase; letter-spacing: 0.1em;
28 + color: var(--ink-3); font-size: 10px; font-weight: 700; margin-bottom: 10px; }
29 +.hint { text-transform: none; letter-spacing: 0.02em; font-weight: 400; }
30 +.hl { background: var(--lime); color: var(--ink); border-radius: 8px; padding: 0 10px 2px;
31 + display: inline-block; transform: rotate(-1deg); }
32 +
33 +.card { background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card);
34 + box-shadow: var(--shadow-off-soft); overflow: hidden; }
35 +.pad { padding: 16px; }
36 +
37 +.chip { font-family: var(--font-mono); text-transform: uppercase; letter-spacing: 0.06em;
38 + border: 1.5px solid var(--ink); border-radius: var(--r-pill); background: var(--surface);
39 + color: var(--ink); align-items: center; gap: 6px; padding: 4px 11px; font-size: 11px;
40 + font-weight: 700; display: inline-flex; }
41 +.chip-accent { background: var(--lime); }
42 +
43 +/* ---------- header ---------- */
44 +.topbar { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap;
45 + padding: 14px clamp(16px, 4vw, 48px); border-bottom: 1.5px solid var(--ink); background: var(--paper);
46 + position: sticky; top: 0; z-index: 500; }
47 +.wordmark { font-family: var(--font-display); font-weight: 700; font-size: 24px; letter-spacing: -0.04em;
48 + text-decoration: none; display: inline-flex; align-items: center; }
49 +.wordmark .ka { background: var(--ink); color: var(--lime); border-radius: 6px; margin-left: 6px;
50 + padding: 0 8px 2px; display: inline-block; transform: rotate(-2deg); font-size: 0.72em; }
51 +#wm-name b, #wm-name { color: var(--ink); }
52 +.topnav { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
53 +.gk-badge { min-height: 30px; font-family: var(--font-mono); letter-spacing: 0.08em; text-transform: uppercase;
54 + border: 1.5px solid var(--ink); border-radius: var(--r-pill); background: var(--surface); color: var(--ink-2);
55 + white-space: nowrap; align-items: center; gap: 7px; padding: 3px 10px 4px; font-size: 10px; font-weight: 700;
56 + text-decoration: none; display: inline-flex; transition: transform 0.15s, box-shadow 0.15s; }
57 +.gk-badge:hover { transform: translate(-1px, -1px); box-shadow: 3px 3px 0 var(--ink); }
58 +.gk-badge b { font-family: var(--font-display); letter-spacing: -0.02em; text-transform: none;
59 + color: var(--ink); font-size: 12px; }
60 +.gk-badge .ka { background: var(--ink); color: var(--lime); border-radius: 5px; margin-left: 3px;
61 + padding: 0 5px 1px; display: inline-block; transform: rotate(-2deg); }
62 +.gk-badge .dot { width: 8px; height: 8px; border-radius: 50%; border: 1.5px solid var(--ink); }
63 +
64 +/* ---------- héros ---------- */
65 +main { padding: clamp(24px, 4vw, 48px) clamp(16px, 4vw, 48px); max-width: 1440px; margin: 0 auto;
66 + display: grid; gap: clamp(32px, 4vw, 56px); }
67 +.hero { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); gap: clamp(20px, 3vw, 40px);
68 + align-items: start; }
69 +@media (max-width: 900px) { .hero { grid-template-columns: 1fr; } }
70 +.hero h1 { font-family: var(--font-display); font-size: var(--fs-h1); line-height: 1.08;
71 + letter-spacing: -0.03em; margin: 6px 0 16px; }
72 +.lede { color: var(--ink-2); max-width: 56ch; font-size: 16px; }
73 +.hero-chips { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 18px; }
74 +.live-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--ink-3); display: inline-block; }
75 +.live-dot.on { background: var(--green); animation: pulse 1.8s infinite; }
76 +@keyframes pulse { 0%, 100% { box-shadow: 0 0 0 0 #1c5c4166; } 60% { box-shadow: 0 0 0 7px transparent; } }
77 +
78 +.defcard { padding: 18px 20px; box-shadow: var(--shadow-off); transform: rotate(0.4deg); }
79 +.defword { font-family: var(--font-display); font-size: 24px; font-weight: 700; margin-bottom: 10px; }
80 +.phon { color: var(--ink-3); font-weight: 400; font-size: 16px; }
81 +.nat { font-style: italic; color: var(--ink-3); font-size: 14px; }
82 +.defs { margin: 0; padding-left: 20px; display: grid; gap: 8px; font-size: 14px; color: var(--ink-2); }
83 +.defs b { color: var(--ink); }
84 +
85 +/* ---------- tuiles ---------- */
86 +.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 14px; }
87 +.tile { background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card);
88 + padding: 14px 16px; box-shadow: var(--shadow-off-mid); }
89 +.tile .v { font: 700 30px/1.15 var(--font-mono); letter-spacing: -0.02em; }
90 +.tile .l { color: var(--ink-2); font-size: 12px; margin-top: 4px; }
91 +.tile.hot { background: var(--lime); }
92 +
93 +/* ---------- commander un effort ---------- */
94 +.commander { background: var(--lime-soft); box-shadow: var(--shadow-off); }
95 +.cmd-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.3fr); gap: clamp(18px, 3vw, 40px);
96 + padding: clamp(18px, 3vw, 32px); }
97 +@media (max-width: 900px) { .cmd-grid { grid-template-columns: 1fr; } }
98 +.cmd-title { font-family: var(--font-display); font-size: clamp(24px, 2.4vw, 34px); letter-spacing: -0.02em;
99 + margin: 4px 0 12px; }
100 +.cmd-lede { color: var(--ink-2); font-size: 14.5px; max-width: 48ch; }
101 +#effort-form { display: grid; gap: 12px; align-content: start; }
102 +.frow { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
103 +@media (max-width: 640px) { .frow { grid-template-columns: 1fr; } }
104 +.frow-end { align-items: end; }
105 +.flab { display: grid; gap: 5px; font: 700 10px var(--font-mono); text-transform: uppercase;
106 + letter-spacing: 0.08em; color: var(--ink-3); }
107 +.input, .select { width: 100%; min-height: 44px; font: 400 14px var(--font-body); color: var(--ink);
108 + background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-ctl); padding: 10px 14px; }
109 +.input:focus, .select:focus { box-shadow: 3px 3px 0 var(--lime); outline: none; }
110 +.input::placeholder { color: var(--ink-3); }
111 +.btn-primary { min-height: 44px; font: 700 14px var(--font-display); border: 1.5px solid var(--ink);
112 + border-radius: var(--r-ctl); background: var(--ink); color: var(--lime); cursor: pointer; padding: 10px 18px;
113 + transition: transform 0.15s, box-shadow 0.15s, background 0.15s; }
114 +.btn-primary:hover { transform: translate(-2px, -2px); box-shadow: 4px 4px 0 #1418142e; background: var(--accent-deep); }
115 +.btn-primary:disabled { opacity: 0.5; cursor: wait; transform: none; box-shadow: none; }
116 +.cmd-msg { font: 600 12.5px var(--font-mono); min-height: 18px; }
117 +.cmd-msg.ok { color: var(--green); } .cmd-msg.err { color: var(--danger); }
118 +
119 +/* ---------- écran (flux) + side ---------- */
120 +.cols { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr); gap: 20px; align-items: start; }
121 +@media (max-width: 980px) { .cols { grid-template-columns: 1fr; } }
122 +.side { display: grid; gap: 20px; min-width: 0; }
123 +
124 +.screen { background: var(--ink); border-color: var(--ink); box-shadow: var(--shadow-off); min-width: 0; }
125 +.screen-head { display: flex; align-items: center; gap: 12px; padding: 10px 14px;
126 + border-bottom: 1px solid #f5f3ee2b; }
127 +.screen-dots { display: inline-flex; gap: 5px; }
128 +.screen-dots i { width: 10px; height: 10px; border-radius: 50%; background: #f5f3ee33; }
129 +.screen-dots i:first-child { background: var(--agent); }
130 +.screen-title { font-family: var(--font-mono); font-size: 11px; color: #f5f3ee99;
131 + text-transform: uppercase; letter-spacing: 0.08em; }
132 +.feed { font: 12.5px/1.6 var(--font-mono); padding: 14px; height: 560px; overflow-y: auto;
133 + display: flex; flex-direction: column; gap: 7px; color: #f5f3eec9; }
134 +.feed-empty { color: #f5f3ee66; }
135 +.fe { display: flex; gap: 10px; align-items: baseline; animation: fadein 0.3s; }
136 +@keyframes fadein { from { opacity: 0; transform: translateY(4px); } }
137 +.fe .t { color: #f5f3ee59; flex: none; font-size: 10.5px; }
138 +.fe .tag { flex: none; font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em;
139 + padding: 1px 8px; border-radius: 999px; border: 1px solid #f5f3ee38; color: #f5f3ee99; }
140 +.fe.tool .tag { color: var(--lime); border-color: #d9f26b59; }
141 +.fe.text .tag { color: #7fd6a8; border-color: #7fd6a859; }
142 +.fe.incident .tag { color: var(--amber); border-color: #e8a33d66; }
143 +.fe.rollback .tag { color: #ef9d96; border-color: #ef9d9666; }
144 +.fe .m { overflow-wrap: anywhere; white-space: pre-wrap; min-width: 0; }
145 +.fe.text .m { color: #f5f3ee; }
146 +
147 +/* ---------- incidents ---------- */
148 +.incidents { display: grid; gap: 8px; max-height: 320px; overflow-y: auto; }
149 +.inc { display: flex; align-items: center; gap: 10px; background: var(--surface-2);
150 + border: 1.5px solid var(--line); border-radius: var(--r-ctl); padding: 9px 11px; font-size: 13px; }
151 +.inc .src { font: 600 12.5px var(--font-mono); overflow-wrap: anywhere; }
152 +.inc .svc { color: var(--ink-3); font-size: 11px; font-family: var(--font-mono); }
153 +.badge { margin-left: auto; flex: none; font: 700 10px var(--font-mono); text-transform: uppercase;
154 + letter-spacing: 0.05em; padding: 3px 9px; border-radius: var(--r-pill); border: 1.5px solid var(--ink); }
155 +.b-open { background: var(--danger-soft); color: var(--danger); border-color: var(--danger); }
156 +.b-fixing, .b-dispatched { background: var(--lime); color: var(--ink); }
157 +.b-watching { background: var(--stale-soft); color: var(--stale); border-color: var(--stale); }
158 +.b-cooldown { background: var(--amber-soft); color: #8a5f1e; border-color: var(--amber); }
159 +.b-resolved, .b-self_healed { background: var(--green-soft); color: var(--green); border-color: var(--green); }
160 +.b-abandoned { background: var(--surface); color: var(--ink-3); border-color: var(--ink-3); }
161 +.empty { color: var(--ink-3); font-size: 13px; }
162 +
163 +/* ---------- couverture ---------- */
164 +.coverage { display: grid; gap: 12px; }
165 +.cov { border: 1.5px solid var(--line); border-radius: var(--r-ctl); padding: 11px 13px; background: var(--surface-2); }
166 +.cov .head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 8px; gap: 8px; }
167 +.cov .app { font-family: var(--font-display); font-weight: 700; font-size: 15px; }
168 +.cov .node { color: var(--ink-3); font: 10.5px var(--font-mono); text-transform: uppercase; letter-spacing: 0.05em; }
169 +.strip { display: flex; height: 10px; border-radius: 5px; overflow: hidden; gap: 2px; }
170 +.strip span { min-width: 3px; }
171 +.s-ok { background: var(--green); } .s-degraded { background: var(--amber); }
172 +.s-broken { background: var(--danger); } .s-stale { background: var(--stale); }
173 +.counts { display: flex; gap: 11px; margin-top: 8px; flex-wrap: wrap;
174 + font: 600 10.5px var(--font-mono); color: var(--ink-2); text-transform: uppercase; letter-spacing: 0.04em; }
175 +.counts i { display: inline-block; width: 8px; height: 8px; border-radius: 2px; margin-right: 5px; }
176 +
177 +/* ---------- missions ---------- */
178 +.table-wrap { overflow-x: auto; box-shadow: var(--shadow-off-soft); }
179 +table { width: 100%; border-collapse: collapse; font-size: 13px; background: var(--surface); }
180 +th { text-align: left; font: 700 10px var(--font-mono); text-transform: uppercase; letter-spacing: 0.08em;
181 + color: var(--ink-3); padding: 11px 12px; border-bottom: 1.5px solid var(--ink); background: var(--surface-2); }
182 +td { padding: 10px 12px; border-bottom: 1px solid var(--line); }
183 +tbody tr { cursor: pointer; transition: background 0.12s; }
184 +tbody tr:hover { background: var(--lime-soft); }
185 +.mono { font-family: var(--font-mono); font-size: 12px; }
186 +.vbadge { font: 700 10px var(--font-mono); text-transform: uppercase; letter-spacing: 0.05em;
187 + padding: 3px 9px; border-radius: var(--r-pill); border: 1.5px solid currentColor; white-space: nowrap; }
188 +.v-repare { color: var(--green); background: var(--green-soft); }
189 +.v-echec, .v-inconnu, .v-erreur { color: var(--danger); background: var(--danger-soft); }
190 +.v-site_source_mort { color: var(--ink-3); background: var(--surface-2); }
191 +.v-rien_a_faire { color: var(--stale); background: var(--stale-soft); }
192 +.v-running { color: var(--accent-deep); background: var(--lime); }
193 +
194 +/* ---------- footer ---------- */
195 +.ka-footer { margin-top: 24px; background: var(--ink); color: #f5f3eebf; padding: 44px 0; font-size: 13px; }
196 +.foot-in { max-width: 1440px; margin: 0 auto; padding: 0 clamp(16px, 4vw, 48px); display: grid; gap: 16px; }
197 +.wordmark-foot, .wordmark-foot #foot-name { color: var(--paper); font-size: 30px; }
198 +.wordmark-foot .ka { background: var(--lime); color: var(--ink); }
199 +.notice { max-width: 640px; border-left: 2px solid var(--lime); padding-left: 16px; }
200 +.notice b { color: var(--paper); }
201 +.sites { display: flex; gap: 14px; flex-wrap: wrap; }
202 +.sites a, .legal a { color: #f5f3eebf; text-decoration: none; }
203 +.sites a:hover, .legal a:hover { color: var(--lime); text-decoration: underline; text-underline-offset: 4px; }
204 +.legal { color: #f5f3ee73; }
205 +
206 +/* ---------- modal ---------- */
207 +.modal { position: fixed; inset: 0; background: #14181480; display: grid; place-items: center; z-index: 900; padding: 16px; }
208 +.modal[hidden] { display: none; }
209 +.modal-box { width: min(880px, 94vw); max-height: 86vh; display: flex; flex-direction: column; box-shadow: var(--shadow-off); }
210 +.modal-head { display: flex; justify-content: space-between; align-items: center; gap: 10px;
211 + padding: 13px 18px; border-bottom: 1.5px solid var(--ink); background: var(--surface-2); }
212 +.modal-head h3 { font-family: var(--font-display); font-size: 16px; }
213 +.modal-head button { background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-ctl);
214 + width: 32px; height: 32px; font-size: 14px; cursor: pointer; }
215 +.modal-head button:hover { background: var(--lime); }
216 +.modal-body { padding: 16px 18px; overflow-y: auto; font: 12.5px/1.6 var(--font-mono); display: grid; gap: 8px; }
217 +.modal-body .meta { background: var(--surface-2); border: 1px solid var(--line); border-radius: var(--r-ctl);
218 + padding: 10px 12px; color: var(--ink-2); white-space: pre-wrap; overflow-wrap: anywhere; }
219 +.modal-body .meta b { color: var(--ink); }
added M4M36luster-projects/ka-guardian/runner/runner.py +307 −0
@@ -0,0 +1,307 @@
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 +import uuid
26 +from typing import Any
27 +
28 +import httpx
29 +from fastapi import FastAPI, Header, HTTPException, Request
30 +from pydantic import BaseModel
31 +
32 +HOME = pathlib.Path.home()
33 +ENV_FILE = HOME / ".ka-guardian.env"
34 +CLAUDE_ENV_FILE = HOME / ".claude" / ".env"
35 +TRANSCRIPTS = HOME / "ka-guardian-runner" / "transcripts"
36 +TRANSCRIPTS.mkdir(parents=True, exist_ok=True)
37 +CLAUDE_BIN = "/opt/homebrew/bin/claude"
38 +
39 +
40 +def load_env_file(path: pathlib.Path) -> dict[str, str]:
41 + out: dict[str, str] = {}
42 + if path.exists():
43 + for line in path.read_text().splitlines():
44 + line = line.strip()
45 + if line and not line.startswith("#") and "=" in line:
46 + k, v = line.split("=", 1)
47 + out[k.strip()] = v.strip().strip('"').strip("'")
48 + return out
49 +
50 +
51 +LOCAL_ENV = load_env_file(ENV_FILE)
52 +TOKEN = LOCAL_ENV.get("KA_GUARDIAN_TOKEN", "")
53 +NODE = LOCAL_ENV.get("KA_GUARDIAN_NODE", socket.gethostname())
54 +
55 +app = FastAPI(title="KA Guardian Runner", docs_url=None, redoc_url=None)
56 +
57 +_lock = threading.Lock()
58 +_current: dict[str, Any] = {} # mission en cours (métadonnées)
59 +
60 +
61 +def check_token(x_ka_token: str | None) -> None:
62 + if not TOKEN or x_ka_token != TOKEN:
63 + raise HTTPException(status_code=401, detail="token invalide")
64 +
65 +
66 +def expand(dir_: str) -> str:
67 + return os.path.expanduser(dir_)
68 +
69 +
70 +def git(dir_: str, *args: str) -> subprocess.CompletedProcess:
71 + return subprocess.run(
72 + ["git", "-C", expand(dir_), *args],
73 + capture_output=True, text=True, timeout=120,
74 + )
75 +
76 +
77 +def sh(cmd: str, timeout: int = 120) -> subprocess.CompletedProcess:
78 + env = {**os.environ, "PATH": "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"}
79 + return subprocess.run(["/bin/zsh", "-c", cmd], capture_output=True, text=True, timeout=timeout, env=env)
80 +
81 +
82 +def healthcheck(web_port: int, pm2_names: list[str]) -> dict[str, Any]:
83 + """Vérifie que l'app répond en HTTP et que ses process pm2 sont online."""
84 + http_ok = False
85 + try:
86 + r = httpx.get(f"http://127.0.0.1:{web_port}/", timeout=10, follow_redirects=True)
87 + http_ok = r.status_code < 500
88 + except Exception:
89 + http_ok = False
90 + pm2_status: dict[str, str] = {}
91 + try:
92 + out = sh("pm2 jlist").stdout
93 + procs = {p["name"]: p.get("pm2_env", {}).get("status", "?") for p in json.loads(out)}
94 + for name in pm2_names:
95 + pm2_status[name] = procs.get(name, "absent")
96 + except Exception as exc: # pm2 absent ou jlist illisible
97 + pm2_status = {n: f"inconnu ({exc})" for n in pm2_names}
98 + ok = http_ok and all(s in ("online", "launching") for s in pm2_status.values())
99 + return {"ok": ok, "http_ok": http_ok, "pm2": pm2_status}
100 +
101 +
102 +class MissionIn(BaseModel):
103 + mission_id: str
104 + agent: str
105 + service: str
106 + source: str
107 + dir: str
108 + pm2: list[str]
109 + web_port: int
110 + model: str = "sonnet"
111 + max_turns: int = 70
112 + timeout_seconds: int = 3600
113 + prompt: str
114 + callback_url: str # http://<orchestrateur>/api/ingest/<mission_id>
115 +
116 +
117 +SPOOL = HOME / "ka-guardian-spool"
118 +
119 +
120 +def post_event(url: str, payload: dict[str, Any]) -> None:
121 + """Dépose l'événement dans le spool du courrier zsh (deploy/courier.sh).
122 +
123 + macOS 26 « Local Network Privacy » refuse le trafic LAN dès que python
124 + (homebrew) est dans la chaîne de processus — même ses enfants ssh/curl.
125 + Le courrier (launchd 100 % zsh) expédie via ssh → curl localhost.
126 + """
127 + try:
128 + from urllib.parse import urlsplit
129 + u = urlsplit(url)
130 + for d in ("outbox", "done", "tmp"):
131 + (SPOOL / d).mkdir(parents=True, exist_ok=True)
132 + # id préfixé du temps ns: le courrier traite les jobs en ordre lexical,
133 + # les événements arrivent donc dans l'ordre d'émission.
134 + jid = f"{time.time_ns()}-{uuid.uuid4().hex[:8]}"
135 + tmp = SPOOL / "tmp" / f"{jid}.job"
136 + tmp.write_text(f"{u.hostname} {u.port or 80} {u.path} 15\n"
137 + + json.dumps(payload, ensure_ascii=False))
138 + tmp.rename(SPOOL / "outbox" / f"{jid}.job")
139 + except Exception:
140 + pass # l'orchestrateur relira le transcript au besoin
141 +
142 +
143 +def condense_stream_line(obj: dict[str, Any]) -> list[dict[str, Any]]:
144 + """Transforme une ligne stream-json de claude en événements courts pour le feed."""
145 + events: list[dict[str, Any]] = []
146 + t = obj.get("type")
147 + if t == "system" and obj.get("subtype") == "init":
148 + events.append({"type": "init", "data": {"model": obj.get("model", "?")}})
149 + elif t == "assistant":
150 + for block in obj.get("message", {}).get("content", []):
151 + if block.get("type") == "text" and block.get("text", "").strip():
152 + events.append({"type": "text", "data": {"text": block["text"][:1500]}})
153 + elif block.get("type") == "tool_use":
154 + inp = json.dumps(block.get("input", {}), ensure_ascii=False)
155 + events.append({"type": "tool", "data": {"name": block.get("name", "?"), "input": inp[:400]}})
156 + elif t == "result":
157 + events.append({"type": "result", "data": {
158 + "subtype": obj.get("subtype"),
159 + "result": (obj.get("result") or "")[:4000],
160 + "cost_usd": obj.get("total_cost_usd"),
161 + "num_turns": obj.get("num_turns"),
162 + "duration_ms": obj.get("duration_ms"),
163 + }})
164 + return events
165 +
166 +
167 +def extract_verdict(result_text: str) -> dict[str, Any]:
168 + """Extrait le dernier bloc JSON {verdict: ...} de la réponse finale."""
169 + import re
170 + for m in reversed(re.findall(r"\{[^{}]*\"verdict\"[\s\S]*?\}", result_text)):
171 + try:
172 + v = json.loads(m)
173 + if "verdict" in v:
174 + return v
175 + except Exception:
176 + continue
177 + return {"verdict": "inconnu", "diagnostic": result_text[-500:] if result_text else ""}
178 +
179 +
180 +def run_mission(m: MissionIn) -> None:
181 + global _current
182 + workdir = expand(m.dir)
183 + transcript = TRANSCRIPTS / f"{m.mission_id}.jsonl"
184 + base_commit = ""
185 + try:
186 + # Snapshot pré-mission : tree sale → commit de sûreté pour un rollback net.
187 + if git(m.dir, "status", "--porcelain").stdout.strip():
188 + git(m.dir, "add", "-A")
189 + git(m.dir, "commit", "-m", f"[{m.agent}] snapshot pré-mission {m.source}")
190 + base_commit = git(m.dir, "rev-parse", "HEAD").stdout.strip()
191 + post_event(m.callback_url, {"type": "start", "data": {"base_commit": base_commit, "node": NODE}})
192 +
193 + env = {
194 + **os.environ,
195 + **load_env_file(CLAUDE_ENV_FILE),
196 + "PATH": "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin",
197 + "HOME": str(HOME),
198 + }
199 + cmd = [
200 + CLAUDE_BIN, "-p", m.prompt,
201 + "--output-format", "stream-json", "--verbose",
202 + "--model", m.model,
203 + "--max-turns", str(m.max_turns),
204 + "--dangerously-skip-permissions",
205 + ]
206 + proc = subprocess.Popen(
207 + cmd, cwd=workdir, env=env,
208 + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, bufsize=1,
209 + )
210 + _current["pid"] = proc.pid
211 + result_text, cost, turns = "", None, None
212 + deadline = time.time() + m.timeout_seconds
213 + with transcript.open("w") as tf:
214 + for line in proc.stdout: # type: ignore[union-attr]
215 + tf.write(line)
216 + if time.time() > deadline:
217 + proc.kill()
218 + post_event(m.callback_url, {"type": "error", "data": {"error": "timeout mission"}})
219 + break
220 + line = line.strip()
221 + if not line:
222 + continue
223 + try:
224 + obj = json.loads(line)
225 + except Exception:
226 + continue
227 + for ev in condense_stream_line(obj):
228 + if ev["type"] == "result":
229 + result_text = ev["data"].get("result", "")
230 + cost = ev["data"].get("cost_usd")
231 + turns = ev["data"].get("num_turns")
232 + post_event(m.callback_url, ev)
233 + proc.wait(timeout=60)
234 +
235 + commits_raw = git(m.dir, "rev-list", "--oneline", f"{base_commit}..HEAD").stdout.strip()
236 + commits = commits_raw.splitlines() if commits_raw else []
237 + verdict = extract_verdict(result_text)
238 + health = healthcheck(m.web_port, m.pm2)
239 + post_event(m.callback_url, {"type": "final", "data": {
240 + "verdict": verdict, "commits": commits, "base_commit": base_commit,
241 + "cost_usd": cost, "num_turns": turns, "health": health,
242 + "exit_code": proc.returncode,
243 + }})
244 + except Exception as exc:
245 + post_event(m.callback_url, {"type": "error", "data": {"error": str(exc), "base_commit": base_commit}})
246 + finally:
247 + with _lock:
248 + _current.clear()
249 +
250 +
251 +@app.get("/health")
252 +def health() -> dict[str, Any]:
253 + return {"ok": True, "node": NODE, "busy": bool(_current), "current": _current.get("mission_id")}
254 +
255 +
256 +@app.post("/missions")
257 +def missions(m: MissionIn, x_ka_token: str | None = Header(default=None)) -> dict[str, Any]:
258 + check_token(x_ka_token)
259 + workdir = expand(m.dir)
260 + if not os.path.isdir(workdir):
261 + raise HTTPException(status_code=400, detail=f"dir introuvable: {workdir}")
262 + with _lock:
263 + if _current:
264 + raise HTTPException(status_code=409, detail=f"mission déjà en cours: {_current.get('mission_id')}")
265 + _current.update({"mission_id": m.mission_id, "service": m.service, "source": m.source, "started": time.time()})
266 + threading.Thread(target=run_mission, args=(m,), daemon=True).start()
267 + return {"accepted": True, "node": NODE}
268 +
269 +
270 +class RollbackIn(BaseModel):
271 + dir: str
272 + base_commit: str
273 + pm2: list[str]
274 + web_port: int
275 +
276 +
277 +@app.post("/rollback")
278 +def rollback(r: RollbackIn, x_ka_token: str | None = Header(default=None)) -> dict[str, Any]:
279 + """Revient au commit d'avant mission (reset --hard si tous les commits sont de l'agent, sinon revert)."""
280 + check_token(x_ka_token)
281 + log = git(r.dir, "log", "--format=%s", f"{r.base_commit}..HEAD").stdout.strip()
282 + msgs = log.splitlines() if log else []
283 + if not msgs:
284 + mode = "aucun_commit"
285 + elif all(s.startswith("[ka") for s in msgs):
286 + git(r.dir, "reset", "--hard", r.base_commit)
287 + mode = "reset_hard"
288 + else:
289 + rc = git(r.dir, "revert", "--no-edit", f"{r.base_commit}..HEAD")
290 + if rc.returncode != 0:
291 + git(r.dir, "revert", "--abort")
292 + git(r.dir, "reset", "--hard", r.base_commit)
293 + mode = "reset_hard(fallback)"
294 + else:
295 + mode = "revert"
296 + # Pas de git clean : les process de sync de l'app écrivent en continu,
297 + # on ne supprime jamais de fichiers non trackés apparus pendant la mission.
298 + for name in r.pm2:
299 + sh(f"pm2 restart {shlex.quote(name)} --update-env", timeout=180)
300 + time.sleep(8)
301 + h = healthcheck(r.web_port, r.pm2)
302 + return {"ok": True, "mode": mode, "health": h, "head": git(r.dir, "rev-parse", "HEAD").stdout.strip()}
303 +
304 +
305 +if __name__ == "__main__":
306 + import uvicorn
307 + uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("KA_GUARDIAN_RUNNER_PORT", "7791")))
added M4M36luster-projects/ka-guardian/topology.json +64 −0
@@ -0,0 +1,64 @@
1 +{
2 + "comment": "KA Guardian — topologie des agents, services et nœuds. Source de vérité partagée orchestrateurs/runners.",
3 + "apika_monitoring_url": "https://www.api-ka.com/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 + "effort_max_turns": 150,
62 + "effort_timeout_seconds": 7200
63 + }
64 +}
added 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 deploy/courier.sh +37 −0
@@ -0,0 +1,37 @@
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 +export PATH=/usr/bin:/bin:/usr/sbin:/sbin # launchd démarre sans PATH
12 +SPOOL=$HOME/ka-guardian-spool
13 +KEY=$HOME/.ssh/ka_guardian_ed25519
14 +mkdir -p $SPOOL/outbox $SPOOL/done $SPOOL/tmp
15 +source $HOME/.ka-guardian.env 2>/dev/null || true
16 +
17 +while true; do
18 + for f in $SPOOL/outbox/*.job(N); do
19 + id=${f:t:r}
20 + hdr=$(head -1 $f)
21 + parts=(${(z)hdr})
22 + ip=$parts[1]; port=$parts[2]; path=$parts[3]; tmo=${parts[4]:-30}
23 + tail -n +2 $f | /usr/bin/ssh -i $KEY \
24 + -o StrictHostKeyChecking=accept-new -o BatchMode=yes -o ConnectTimeout=8 \
25 + -o ControlMaster=auto -o ControlPath=/tmp/kg-cm-%h -o ControlPersist=120 \
26 + simon-pierreboucher@$ip \
27 + "/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}'" \
28 + > $SPOOL/tmp/$id.resp 2>>$SPOOL/courier.log
29 + rc=$?
30 + [[ $rc -ne 0 ]] && print "\ncourier_ssh_rc_$rc" >> $SPOOL/tmp/$id.resp
31 + mv $SPOOL/tmp/$id.resp $SPOOL/done/$id.resp
32 + rm -f $f
33 + done
34 + # ménage: réponses jamais réclamées depuis > 1 h
35 + for old in $SPOOL/done/*.resp(N.mh+1); do rm -f $old; done
36 + sleep 1
37 +done
added deploy/deploy.sh +139 −0
@@ -0,0 +1,139 @@
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_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 +
52 +deploy_runners() {
53 + for n in $RUNNER_NODES; do
54 + echo "=== runner → $n"
55 + ssh $n "mkdir -p ~/ka-guardian-runner/transcripts"
56 + scp -q "$ROOT/runner/runner.py" $n:ka-guardian-runner/runner.py
57 + ssh $n "printf '%s\nKA_GUARDIAN_NODE=%s\n' '$TOKEN_LINE' '$n' > ~/.ka-guardian.env
58 + cd ~/ka-guardian-runner
59 + [[ -d .venv ]] || $PY -m venv .venv
60 + ./.venv/bin/pip -q install 'fastapi>=0.110' 'uvicorn>=0.29' 'httpx>=0.27' 'pydantic>=2' >/dev/null
61 + cat > ~/Library/LaunchAgents/com.ka.guardian-runner.plist <<'PLIST'
62 +<?xml version=\"1.0\" encoding=\"UTF-8\"?>
63 +<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">
64 +<plist version=\"1.0\"><dict>
65 + <key>Label</key><string>com.ka.guardian-runner</string>
66 + <key>ProgramArguments</key><array>
67 + <string>/Users/simon-pierreboucher/ka-guardian-runner/.venv/bin/python</string>
68 + <string>/Users/simon-pierreboucher/ka-guardian-runner/runner.py</string>
69 + </array>
70 + <key>EnvironmentVariables</key><dict>
71 + <key>PATH</key><string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
72 + </dict>
73 + <key>RunAtLoad</key><true/><key>KeepAlive</key><true/>
74 + <key>StandardOutPath</key><string>/Users/simon-pierreboucher/ka-guardian-runner/runner.log</string>
75 + <key>StandardErrorPath</key><string>/Users/simon-pierreboucher/ka-guardian-runner/runner.log</string>
76 +</dict></plist>
77 +PLIST
78 + launchctl unload ~/Library/LaunchAgents/com.ka.guardian-runner.plist 2>/dev/null || true
79 + launchctl load ~/Library/LaunchAgents/com.ka.guardian-runner.plist"
80 + sleep 2
81 + ssh $n "curl -sf localhost:7791/health" && echo " ✓ $n runner ok" || echo " ✗ $n runner KO"
82 + done
83 +}
84 +
85 +deploy_orchestrators() {
86 + echo "=== orchestrateurs → $ORCH_NODE"
87 + ssh $ORCH_NODE "printf '%s\n' '$TOKEN_LINE' > ~/.ka-guardian.env; mkdir -p ~/cluster-projects/ka-guardian"
88 + cd "$ROOT"
89 + rsync -az --delete --exclude data --exclude .venv --exclude .git --exclude __pycache__ \
90 + ./ $ORCH_NODE:cluster-projects/ka-guardian/
91 + # vérification: le fichier déployé DOIT être identique (gotcha rsync silencieux)
92 + loc=$(md5 -q orchestrator/main.py); rem=$(ssh $ORCH_NODE 'md5 -q ~/cluster-projects/ka-guardian/orchestrator/main.py')
93 + [[ "$loc" == "$rem" ]] || { echo "✗ rsync n'a pas mis à jour main.py ($loc ≠ $rem)"; exit 1; }
94 + ssh $ORCH_NODE "cd ~/cluster-projects/ka-guardian
95 + PY=\$(command -v /opt/homebrew/bin/python3 || command -v /opt/homebrew/bin/python3.13)
96 + [[ -d .venv ]] || \$PY -m venv .venv
97 + ./.venv/bin/pip -q install 'fastapi>=0.110' 'uvicorn>=0.29' 'httpx>=0.27' >/dev/null
98 + mkdir -p data logs
99 + # retirer les anciens bots (web+bot), garder les tunnels ngrok
100 + for a in ka2 ka4 ka6; do
101 + launchctl unload ~/Library/LaunchAgents/com.\$a.web.plist 2>/dev/null || true
102 + launchctl unload ~/Library/LaunchAgents/com.\$a.bot.plist 2>/dev/null || true
103 + rm -f ~/Library/LaunchAgents/com.\$a.web.plist ~/Library/LaunchAgents/com.\$a.bot.plist
104 + done"
105 + for a in ka2:8799 ka4:8899 ka6:8999; do
106 + agent=${a%%:*}; port=${a##*:}
107 + ssh $ORCH_NODE "cat > ~/Library/LaunchAgents/com.$agent.guardian.plist <<PLIST
108 +<?xml version=\"1.0\" encoding=\"UTF-8\"?>
109 +<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">
110 +<plist version=\"1.0\"><dict>
111 + <key>Label</key><string>com.$agent.guardian</string>
112 + <key>ProgramArguments</key><array>
113 + <string>/Users/simon-pierreboucher/cluster-projects/ka-guardian/.venv/bin/python</string>
114 + <string>/Users/simon-pierreboucher/cluster-projects/ka-guardian/orchestrator/main.py</string>
115 + </array>
116 + <key>EnvironmentVariables</key><dict>
117 + <key>AGENT</key><string>$agent</string>
118 + <key>KA_GUARDIAN_SELF_IP</key><string>192.168.2.69</string>
119 + <key>PATH</key><string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
120 + </dict>
121 + <key>RunAtLoad</key><true/><key>KeepAlive</key><true/>
122 + <key>StandardOutPath</key><string>/Users/simon-pierreboucher/cluster-projects/ka-guardian/logs/$agent.log</string>
123 + <key>StandardErrorPath</key><string>/Users/simon-pierreboucher/cluster-projects/ka-guardian/logs/$agent.log</string>
124 +</dict></plist>
125 +PLIST
126 + launchctl unload ~/Library/LaunchAgents/com.$agent.guardian.plist 2>/dev/null || true
127 + launchctl load ~/Library/LaunchAgents/com.$agent.guardian.plist"
128 + sleep 2
129 + ssh $ORCH_NODE "curl -sf localhost:$port/health" && echo " ✓ $agent ok (:$port)" || echo " ✗ $agent KO"
130 + done
131 +}
132 +
133 +case "${1:-all}" in
134 + courier) deploy_courier ;;
135 + runners) deploy_runners ;;
136 + orchestrators) deploy_orchestrators ;;
137 + all) deploy_courier; deploy_runners; deploy_orchestrators ;;
138 +esac
139 +echo "terminé."
added orchestrator/main.py +772 −0
@@ -0,0 +1,772 @@
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 +
59 +
60 +SPOOL = HOME / "ka-guardian-spool"
61 +
62 +
63 +async def lan_post(ip: str, port: int, path: str, payload: dict[str, Any], timeout: int = 30) -> tuple[int, str]:
64 + """POST JSON vers un autre nœud via le courrier zsh (deploy/courier.sh).
65 +
66 + macOS 26 « Local Network Privacy » refuse le trafic LAN dès que python
67 + (homebrew) est dans la chaîne de processus — même ses enfants ssh/curl.
68 + On dépose donc un job dans un spool disque ; un service launchd 100 % zsh
69 + l'expédie (ssh → curl localhost du nœud cible) et dépose la réponse.
70 + """
71 + for d in ("outbox", "done", "tmp"):
72 + (SPOOL / d).mkdir(parents=True, exist_ok=True)
73 + jid = uuid.uuid4().hex
74 + tmp = SPOOL / "tmp" / f"{jid}.job"
75 + tmp.write_text(f"{ip} {port} {path} {timeout}\n" + jdump(payload))
76 + tmp.rename(SPOOL / "outbox" / f"{jid}.job")
77 + resp = SPOOL / "done" / f"{jid}.resp"
78 + deadline = time.time() + timeout + 25
79 + while time.time() < deadline:
80 + if resp.exists():
81 + txt = resp.read_text()
82 + resp.unlink(missing_ok=True)
83 + body, _, code = txt.strip().rpartition("\n")
84 + if code.startswith("courier_ssh_rc"):
85 + raise ConnectionError(f"{code} vers {ip}:{port}{path}")
86 + return int(code or "0"), body
87 + await asyncio.sleep(0.3)
88 + raise TimeoutError(f"courrier sans réponse pour {ip}:{port}{path}")
89 +DATA = ROOT / "data"
90 +DATA.mkdir(exist_ok=True)
91 +DB_PATH = DATA / f"{AGENT}.db"
92 +
93 +# ---------------------------------------------------------------- SQLite ---
94 +
95 +def db() -> sqlite3.Connection:
96 + conn = sqlite3.connect(DB_PATH)
97 + conn.row_factory = sqlite3.Row
98 + conn.execute("PRAGMA journal_mode=WAL")
99 + return conn
100 +
101 +
102 +def init_db() -> None:
103 + with db() as c:
104 + c.executescript("""
105 + CREATE TABLE IF NOT EXISTS incidents(
106 + id TEXT PRIMARY KEY, service TEXT, source TEXT, status_detected TEXT,
107 + state TEXT, attempts INTEGER DEFAULT 0, created REAL, updated REAL,
108 + resolved REAL, detail TEXT);
109 + CREATE TABLE IF NOT EXISTS missions(
110 + id TEXT PRIMARY KEY, incident_id TEXT, service TEXT, source TEXT,
111 + node TEXT, state TEXT, base_commit TEXT, commits TEXT, verdict TEXT,
112 + cost_usd REAL, num_turns INTEGER, health TEXT, started REAL, ended REAL);
113 + CREATE TABLE IF NOT EXISTS events(
114 + id INTEGER PRIMARY KEY AUTOINCREMENT, mission_id TEXT, ts REAL,
115 + type TEXT, data TEXT);
116 + CREATE TABLE IF NOT EXISTS snapshots(
117 + ts REAL PRIMARY KEY, mine TEXT, ecosystem TEXT);
118 + CREATE INDEX IF NOT EXISTS ev_mission ON events(mission_id);
119 + """)
120 +
121 +
122 +def now() -> float:
123 + return time.time()
124 +
125 +
126 +def jdump(x: Any) -> str:
127 + return json.dumps(x, ensure_ascii=False)
128 +
129 +
130 +# ------------------------------------------------------------------- SSE ---
131 +
132 +class Hub:
133 + def __init__(self) -> None:
134 + self.clients: set[asyncio.Queue] = set()
135 +
136 + async def publish(self, msg: dict[str, Any]) -> None:
137 + for q in list(self.clients):
138 + if q.qsize() < 500:
139 + q.put_nowait(msg)
140 +
141 + def publish_sync(self, msg: dict[str, Any]) -> None:
142 + if LOOP:
143 + asyncio.run_coroutine_threadsafe(self.publish(msg), LOOP)
144 +
145 +
146 +hub = Hub()
147 +LOOP: asyncio.AbstractEventLoop | None = None
148 +LATEST: dict[str, Any] = {"mine": {}, "ecosystem": {}, "polled": 0, "paused": False}
149 +
150 +# ------------------------------------------------------------- missions ----
151 +
152 +def active_incident(c: sqlite3.Connection, service: str, source: str) -> sqlite3.Row | None:
153 + return c.execute(
154 + "SELECT * FROM incidents WHERE service=? AND source=? AND state IN "
155 + "('open','dispatched','fixing','watching','cooldown') ORDER BY created DESC LIMIT 1",
156 + (service, source)).fetchone()
157 +
158 +
159 +def set_incident(c: sqlite3.Connection, iid: str, **kw: Any) -> None:
160 + kw["updated"] = now()
161 + keys = ",".join(f"{k}=?" for k in kw)
162 + c.execute(f"UPDATE incidents SET {keys} WHERE id=?", (*kw.values(), iid))
163 +
164 +
165 +def incident_event(iid: str, service: str, source: str, state: str, note: str = "") -> None:
166 + hub.publish_sync({"kind": "incident", "incident_id": iid, "service": service,
167 + "source": source, "state": state, "note": note, "ts": now()})
168 +
169 +
170 +def service_situation(service: str) -> str:
171 + """Portrait santé de l'app entière — permet à l'agent de distinguer une
172 + panne isolée d'un problème systémique (app entière, réseau, quota)."""
173 + block = LATEST["mine"].get(service) or {}
174 + summary = block.get("summary") or {}
175 + app_row = block.get("app") or {}
176 + sick = [f"{c['source']} ({c['status']})" for c in block.get("connectors", [])
177 + if c.get("status") not in ("ok", None)][:15]
178 + lines = [f"- app entière: {app_row.get('status', '?')} — {summary.get('ok', '?')} ok, "
179 + f"{summary.get('degraded', 0)} dégradés, {summary.get('broken', 0)} cassés, {summary.get('stale', 0)} endormis"]
180 + if sick:
181 + lines.append(f"- autres connecteurs non-ok de cette app: {', '.join(sick)}")
182 + lines.append("- Si BEAUCOUP de connecteurs sont touchés en même temps, le problème est probablement "
183 + "systémique (scheduler de l'app, réseau, quota API): diagnostique au niveau app, pas source par source.")
184 + else:
185 + lines.append("- Tous les autres connecteurs de l'app sont sains: la panne est isolée à cette source.")
186 + return "\n".join(lines)
187 +
188 +
189 +def mission_history(service: str, source: str) -> str:
190 + """Résumé des missions précédentes sur ce connecteur (éviter de refaire ce qui a échoué)."""
191 + with db() as c:
192 + rows = c.execute(
193 + "SELECT m.started, m.verdict, m.commits FROM missions m WHERE m.service=? AND m.source=? "
194 + "AND m.state!='running' ORDER BY m.started DESC LIMIT 3", (service, source)).fetchall()
195 + if not rows:
196 + return "- aucune: c'est la première intervention sur ce connecteur."
197 + out = []
198 + for r in rows:
199 + v = json.loads(r["verdict"] or "{}")
200 + out.append(f"- {time.strftime('%Y-%m-%d %H:%M', time.localtime(r['started']))}: "
201 + f"verdict={v.get('verdict', '?')} — {str(v.get('diagnostic', ''))[:200]} "
202 + f"(actions: {str(v.get('actions', ''))[:150]})")
203 + out.append("NE RÉPÈTE PAS une approche qui a déjà échoué ci-dessus: change d'angle.")
204 + return "\n".join(out)
205 +
206 +
207 +def build_prompt(service: str, source: str, health: dict[str, Any]) -> str:
208 + svc = SERVICES[service]
209 + sync_proc = next((p for p in svc["pm2"] if "sync" in p or "etl" in p), svc["pm2"][-1])
210 + node_alias = NODES[svc["node"]]["alias"]
211 + return f"""Tu es {AGENT}, agent gardien autonome des connecteurs du Groupe KA. Mission: réparer le connecteur « {source} » de l'app {svc['app']} (service api-ka: {service}, site {svc.get('site', '')}).
212 +
213 +== CONTEXTE SANTÉ DU CONNECTEUR (supervision api-ka, scan aux 2 h) ==
214 +- statut détecté: {health.get('status')} | échecs consécutifs: {health.get('consecutive_failures')}
215 +- dernier succès: {health.get('last_success')} | volume au dernier sync: {health.get('found_last')} (médiane historique: {health.get('median_found')})
216 +- message: {health.get('message')}
217 +Rappel des statuts: broken = ≥3 syncs consécutifs en échec ou à 0 résultat; stale = aucun succès depuis > 2× la cadence attendue; degraded = volume < 50 % de la médiane.
218 +
219 +== SITUATION DE L'APP ==
220 +{service_situation(service)}
221 +
222 +== INTERVENTIONS PRÉCÉDENTES SUR CE CONNECTEUR ==
223 +{mission_history(service, source)}
224 +
225 +== TON ENVIRONNEMENT ==
226 +Tu es sur le nœud {node_alias}, ton répertoire courant est le repo de l'app: {svc['dir']} (source de vérité, développement remote-first).
227 +L'app tourne via pm2 ({', '.join(svc['pm2'])}); le process de synchronisation des connecteurs est {sync_proc}; site web local sur le port {svc['web_port']}.
228 +Le venv Python et/ou node_modules du repo sont déjà installés — utilise-les, jamais d'installation globale.
229 +
230 +== DÉMARCHE IMPOSÉE (dans l'ordre) ==
231 +1. IMPRÈGNE-TOI DU REPO: lis le CLAUDE.md et/ou README du repo s'ils existent, et surtout la doc du connecteur si elle existe: cherche `docs/connecteurs/` (fiches générées par app, souvent une par source: URL de la source, stratégie de fetch, format, pièges connus). `grep -ri "{source}"` pour localiser le code du connecteur, sa config et son éventuelle entrée de registre.
232 +2. LIS LES LOGS: dossier logs/ du repo + `pm2 logs {sync_proc} --nostream --lines 300` — cherche les traces d'erreur de « {source} » (traceback, code HTTP, timeout).
233 +3. REPRODUIS: exécute le connecteur ou son fetch directement (la plupart des apps ont un runner par source — la doc/le code du scheduler te montrera comment; sinon appelle la fonction de fetch dans un petit script via le venv du repo). Constate l'erreur réelle.
234 +4. DIAGNOSTIQUE la cause racine: HTML/sélecteurs changés? endpoint JSON déplacé? 403/429 anti-bot? pagination cassée? redirection? certificat/TLS (gotcha connu: certains sites cassent avec requests → utiliser curl via subprocess)? sitemap figé?
235 +5. CORRIGE de façon MINIMALE et ciblée, en respectant les conventions et l'architecture du repo (mêmes patterns que les connecteurs voisins). Si le site bloque, la pile d'escalade du Groupe KA est disponible, clés dans ~/.claude/.env: requêtes directes → curl → Scrapfly (SCRAPFLY_API_KEY, render_js/asp) → Bright Data Web Unlocker → proxies résidentiels Oxylabs (pr.oxylabs.io:7777, -cc-CA) → Serper/Tavily pour retrouver une source déplacée. Regarde comment le repo utilise déjà ces services et fais pareil.
236 +6. RE-TESTE réellement: le connecteur doit rapporter un volume plausible (ordre de grandeur de la médiane {health.get('median_found')}). INTERDIT d'inventer, stubber ou câbler des données en dur. Un test qui retourne 0 ou 3 items quand la médiane est {health.get('median_found')} N'EST PAS une réparation.
237 +7. REDÉMARRE uniquement le process concerné: `pm2 restart {sync_proc}`. Puis vérifie que le site répond: `curl -s -o /dev/null -w '%{{http_code}}' http://localhost:{svc['web_port']}/` (attendu: 200/3xx).
238 +8. COMMITTE: `git add -A && git commit -m "[{AGENT}] fix connecteur {source}: <résumé court>"`. NE PUSH JAMAIS. Si tu as touché plusieurs fichiers pour des raisons distinctes, fais des commits séparés et clairs.
239 +
240 +== INTERDITS ABSOLUS ==
241 +Toucher aux autres connecteurs ou aux autres apps du nœud; modifier la config pm2/ngrok/launchd ou le schéma de la base; installer des paquets globaux; supprimer des données; git push; toucher à ~/.ssh, aux clés API ou aux fichiers hors du repo. Ne désactive JAMAIS un connecteur pour « réparer » sa santé. Si la source est définitivement morte (site fermé, domaine à vendre, 404 permanent confirmé), ne force rien: documente et conclus en verdict site_source_mort.
242 +
243 +== FIN DE MISSION ==
244 +Termine ta TOUTE DERNIÈRE réponse par un bloc JSON exactement de cette forme:
245 +{{"verdict": "repare|echec|site_source_mort|rien_a_faire", "diagnostic": "cause racine en 1-2 phrases", "actions": "ce que tu as fait", "test": "résultat mesuré du test final (volume obtenu)", "fichiers": ["fichiers modifiés"]}}
246 +Sois honnête: si ta réparation n'est pas prouvée par un test réel, le verdict est echec — un faux « repare » sera détecté par la supervision et rollback automatiquement."""
247 +
248 +
249 +COMMON_RULES = """== RÈGLES COMMUNES (non négociables) ==
250 +- Session 100 % AUTONOME: tu ne poses AUCUNE question, tu ne demandes AUCUNE validation, tu travailles jusqu'au bout. S'il faut trancher, tranche selon les conventions du repo et le bon sens, et documente ton choix dans le commit.
251 +- Respecte l'architecture et les patterns du repo (regarde comment les connecteurs voisins sont faits AVANT d'écrire).
252 +- Utilise le venv/node_modules du repo; jamais d'installation globale.
253 +- INTERDIT d'inventer, stubber ou câbler des données en dur. Chaque donnée vient d'un vrai fetch.
254 +- INTERDITS: toucher aux autres apps du nœud, config pm2/ngrok/launchd, schéma de la base (sauf migration prévue par le repo), git push, ~/.ssh, clés API, suppression de données.
255 +- À la fin: `pm2 restart <process de sync>`, vérifie que le site répond (curl localhost), puis committe en messages clairs préfixés [%AGENT%]. NE PUSH JAMAIS."""
256 +
257 +
258 +def effort_stack(service: str) -> str:
259 + svc = SERVICES[service]
260 + return f"""== BOÎTE À OUTILS (clés dans ~/.claude/.env) ==
261 +- DÉCOUVERTE: Serper (SERPER_API_KEY, POST https://google.serper.dev/search, gl=ca hl=fr) pour trouver sources/sitemaps/endpoints; Tavily (TAVILY_API_KEY) en complément.
262 +- FETCH — escalade dans cet ordre: requêtes directes (requests/fetch du repo) → curl via subprocess (contourne les gotchas TLS de requests) → Scrapfly (SCRAPFLY_API_KEY, render_js/asp, backend=auto) → Bright Data Web Unlocker → proxies résidentiels Oxylabs (pr.oxylabs.io:7777, user avec -cc-CA, sessions collantes).
263 +- APIFY (APIFY_TOKEN): en dernier recours pour les plateformes dures, acteurs maison du compte (proxy résidentiel intégré).
264 +- Regarde d'abord comment le repo de {svc['app']} utilise déjà ces services et fais pareil."""
265 +
266 +
267 +def build_effort_prompt(kind: str, service: str, source: str, note: str) -> str:
268 + svc = SERVICES[service]
269 + sync_proc = next((p for p in svc["pm2"] if "sync" in p or "etl" in p), svc["pm2"][-1])
270 + node_alias = NODES[svc["node"]]["alias"]
271 + rules = COMMON_RULES.replace("%AGENT%", AGENT)
272 + env = f"""== TON ENVIRONNEMENT ==
273 +Tu es sur le nœud {node_alias}, répertoire courant = repo de l'app: {svc['dir']} (remote-first, source de vérité).
274 +L'app tourne via pm2 ({', '.join(svc['pm2'])}); process de sync: {sync_proc}; site local port {svc['web_port']}.
275 +Commence par lire le CLAUDE.md / README du repo et docs/connecteurs/ s'ils existent."""
276 + if kind == "effort_new":
277 + return f"""Tu es {AGENT}, agent gardien autonome du Groupe KA. EFFORT COMMANDÉ: ajouter UN NOUVEAU CONNECTEUR de qualité production à l'app {svc['app']} (service {service}, site {svc.get('site', '')}).
278 +{"Consigne de l'opérateur: " + note if note else "Aucune consigne particulière: choisis la source la plus utile."}
279 +
280 +{env}
281 +
282 +== DÉMARCHE ==
283 +1. CARTOGRAPHIE L'EXISTANT: liste les connecteurs actuels de l'app (registre/scheduler + docs/connecteurs) pour comprendre ce qui est déjà couvert et le pattern d'implémentation exact d'un connecteur (structure, signature, enregistrement, cache, dédup).
284 +2. DÉCOUVRE avec Serper: cherche des sources québécoises pertinentes pour {svc['app']} NON couvertes (sitemaps, pages listant des items, endpoints JSON internes découverts via les pages). Évalue 3-5 candidats: volume estimé, faisabilité du fetch, qualité des données, stabilité. CHOISIS le meilleur.
285 +3. IMPLÉMENTE le connecteur en suivant le pattern exact des connecteurs voisins: fetch (avec escalade si bloqué), parsing complet (tous les champs que l'app affiche, fiche détail incluse si le pattern le fait), dédup, enregistrement au registre/scheduler.
286 +4. TESTE réellement: exécute-le au complet, il doit rapporter un volume substantiel et des items complets et corrects (vérifie 3-4 items à la main contre le site source).
287 +5. DOCUMENTE: ajoute la fiche docs/connecteurs/<source>.md si le repo suit cette convention (URL, stratégie, pièges).
288 +6. Redémarre {sync_proc}, vérifie le site, committe.
289 +
290 +{effort_stack(service)}
291 +
292 +{rules}
293 +
294 +== FIN DE MISSION ==
295 +Termine ta TOUTE DERNIÈRE réponse par un bloc JSON:
296 +{{"verdict": "livre|echec", "diagnostic": "source choisie et pourquoi", "actions": "ce qui a été construit", "test": "volume obtenu + vérifications", "fichiers": ["fichiers créés/modifiés"]}}"""
297 + return f"""Tu es {AGENT}, agent gardien autonome du Groupe KA. EFFORT COMMANDÉ: ENRICHIR le connecteur existant « {source} » de l'app {svc['app']} (service {service}, site {svc.get('site', '')}).
298 +{"Consigne de l'opérateur: " + note if note else "Aucune consigne particulière: maximise la valeur (couverture, champs, robustesse)."}
299 +
300 +{env}
301 +
302 +== DÉMARCHE ==
303 +1. ÉTUDIE le connecteur « {source} »: code, fiche docs/connecteurs, logs récents, volume actuel vs médiane, champs remplis vs champs que l'app sait afficher.
304 +2. IDENTIFIE les enrichissements à plus forte valeur, par exemple: pagination complète (couvre TOUT le site source, pas la première page), fiche détail (champs manquants: descriptions, photos, prix, coordonnées, dates), robustesse (retries, escalade anti-bot, tolérance aux changements de DOM), fraîcheur (détection de retraits), dédup plus fine.
305 +3. IMPLÉMENTE proprement dans le pattern du repo, SANS casser le format de sortie existant (les consommateurs avals dépendent du schéma).
306 +4. TESTE réellement: exécute le connecteur au complet, compare avant/après (volume, complétude des champs), vérifie 3-4 items à la main.
307 +5. METS À JOUR la fiche docs/connecteurs/<source>.md si elle existe.
308 +6. Redémarre {sync_proc}, vérifie le site, committe.
309 +
310 +{effort_stack(service)}
311 +
312 +{rules}
313 +
314 +== FIN DE MISSION ==
315 +Termine ta TOUTE DERNIÈRE réponse par un bloc JSON:
316 +{{"verdict": "livre|echec", "diagnostic": "état initial constaté", "actions": "enrichissements livrés", "test": "avant/après mesuré", "fichiers": ["fichiers modifiés"]}}"""
317 +
318 +
319 +async def dispatch(incident: sqlite3.Row, health: dict[str, Any]) -> None:
320 + service, source, iid = incident["service"], incident["source"], incident["id"]
321 + svc = SERVICES[service]
322 + node = svc["node"]
323 + ip = NODES[node]["lan_ip"]
324 + mid = uuid.uuid4().hex[:12]
325 + my_ip = os.environ.get("KA_GUARDIAN_SELF_IP", "192.168.2.69")
326 + kind = incident["status_detected"]
327 + if kind in ("effort_new", "effort_enrich"):
328 + prompt = build_effort_prompt(kind, service, source, health.get("note", ""))
329 + max_turns, timeout = POLICY.get("effort_max_turns", 150), POLICY.get("effort_timeout_seconds", 7200)
330 + else:
331 + prompt = build_prompt(service, source, health)
332 + max_turns, timeout = POLICY["mission_max_turns"], POLICY["mission_timeout_seconds"]
333 + payload = {
334 + "mission_id": mid, "agent": AGENT, "service": service, "source": source,
335 + "dir": svc["dir"], "pm2": svc["pm2"], "web_port": svc["web_port"],
336 + "model": ME.get("model", "sonnet"),
337 + "max_turns": max_turns,
338 + "timeout_seconds": timeout,
339 + "prompt": prompt,
340 + "callback_url": f"http://{my_ip}:{ME['port']}/api/ingest/{mid}",
341 + }
342 + try:
343 + code, body = await lan_post(ip, RUNNER_PORT, "/missions", payload, timeout=25)
344 + if code == 409:
345 + return # runner occupé, on retentera au prochain tick
346 + if code != 200:
347 + raise ConnectionError(f"runner {code}: {body[:200]}")
348 + except Exception as exc:
349 + print(f"[dispatch] {service}/{source} → {type(exc).__name__}: {exc}", flush=True)
350 + hub.publish_sync({"kind": "log", "level": "error", "msg": f"dispatch {source}: {exc}", "ts": now()})
351 + return
352 + with db() as c:
353 + c.execute("INSERT INTO missions(id,incident_id,service,source,node,state,started) VALUES(?,?,?,?,?,?,?)",
354 + (mid, iid, service, source, node, "running", now()))
355 + set_incident(c, iid, state="fixing", attempts=incident["attempts"] + 1)
356 + incident_event(iid, service, source, "fixing", f"mission {mid} dépêchée sur {NODES[node]['alias']}")
357 + hub.publish_sync({"kind": "mission", "mission_id": mid, "service": service,
358 + "source": source, "state": "running", "ts": now()})
359 +
360 +
361 +async def rollback_mission(mission: sqlite3.Row, reason: str) -> dict[str, Any]:
362 + svc = SERVICES[mission["service"]]
363 + ip = NODES[mission["node"]]["lan_ip"]
364 + try:
365 + code, body = await lan_post(ip, RUNNER_PORT, "/rollback",
366 + {"dir": svc["dir"], "base_commit": mission["base_commit"],
367 + "pm2": svc["pm2"], "web_port": svc["web_port"]}, timeout=300)
368 + out = json.loads(body) if code == 200 else {"ok": False, "erreur": f"{code}: {body[:200]}"}
369 + except Exception as exc:
370 + out = {"ok": False, "erreur": str(exc)}
371 + hub.publish_sync({"kind": "rollback", "mission_id": mission["id"], "service": mission["service"],
372 + "source": mission["source"], "reason": reason, "result": out, "ts": now()})
373 + with db() as c:
374 + c.execute("INSERT INTO events(mission_id,ts,type,data) VALUES(?,?,?,?)",
375 + (mission["id"], now(), "rollback", jdump({"reason": reason, "result": out})))
376 + return out
377 +
378 +
379 +# --------------------------------------------------------------- moteur ----
380 +
381 +async def poll_once() -> None:
382 + async with httpx.AsyncClient() as cl:
383 + r = await cl.get(TOPO["apika_monitoring_url"], timeout=15)
384 + data = r.json()["data"]
385 + mine: dict[str, Any] = {}
386 + for service, block in data["services"].items():
387 + assigned_to_me = service in MY_SERVICES or (DEFAULT_AGENT and service not in ASSIGNED)
388 + if assigned_to_me:
389 + mine[service] = block
390 + LATEST.update({"mine": mine, "ecosystem": data["summary"], "polled": now()})
391 + with db() as c:
392 + summary_mine = {s: b["summary"] for s, b in mine.items()}
393 + c.execute("INSERT OR REPLACE INTO snapshots(ts,mine,ecosystem) VALUES(?,?,?)",
394 + (now(), jdump(summary_mine), jdump(data["summary"])))
395 + c.execute("DELETE FROM snapshots WHERE ts < ?", (now() - 30 * 86400,))
396 + hub.publish_sync({"kind": "snapshot", "mine": {s: b["summary"] for s, b in mine.items()},
397 + "ecosystem": data["summary"], "ts": now()})
398 +
399 + trigger = set(POLICY["trigger_statuses"])
400 + with db() as c:
401 + for service, block in mine.items():
402 + if service not in SERVICES:
403 + continue # service inconnu de la topologie: visible au dashboard, pas d'action
404 + for conn in block.get("connectors", []):
405 + source, status = conn["source"], conn["status"]
406 + inc = active_incident(c, service, source)
407 + if status in trigger and inc is None:
408 + iid = uuid.uuid4().hex[:10]
409 + c.execute("INSERT INTO incidents(id,service,source,status_detected,state,created,updated,detail) "
410 + "VALUES(?,?,?,?,?,?,?,?)",
411 + (iid, service, source, status, "open", now(), now(), jdump(conn)))
412 + incident_event(iid, service, source, "open", f"détecté {status}")
413 + elif status in trigger and inc is not None and inc["state"] in ("open", "cooldown"):
414 + # Ne pas rafraîchir watching/fixing: `updated` sert de chrono
415 + # à la fenêtre de surveillance et au cooldown.
416 + c.execute("UPDATE incidents SET status_detected=?, detail=? WHERE id=?",
417 + (status, jdump(conn), inc["id"]))
418 + elif status == "ok" and inc is not None:
419 + if inc["state"] == "watching":
420 + set_incident(c, inc["id"], state="resolved", resolved=now())
421 + incident_event(inc["id"], service, source, "resolved", "connecteur de retour à ok — réparation confirmée")
422 + elif inc["state"] in ("open", "cooldown"):
423 + set_incident(c, inc["id"], state="self_healed", resolved=now())
424 + incident_event(inc["id"], service, source, "self_healed", "revenu à ok sans intervention")
425 +
426 +
427 +async def tick() -> None:
428 + if LATEST["paused"]:
429 + return
430 + try:
431 + await poll_once()
432 + except Exception as exc:
433 + hub.publish_sync({"kind": "log", "level": "warn", "msg": f"poll api-ka échoué: {exc}", "ts": now()})
434 +
435 + with db() as c:
436 + # 1. Missions zombies (runner mort / callback perdu)
437 + for m in c.execute("SELECT * FROM missions WHERE state='running' AND started < ?",
438 + (now() - POLICY.get("effort_timeout_seconds", 7200) - 900,)).fetchall():
439 + c.execute("UPDATE missions SET state='error', ended=? WHERE id=?", (now(), m["id"]))
440 + inc = c.execute("SELECT * FROM incidents WHERE id=?", (m["incident_id"],)).fetchone()
441 + if inc:
442 + set_incident(c, inc["id"], state="cooldown")
443 + incident_event(inc["id"], m["service"], m["source"], "cooldown", "mission sans réponse (zombie)")
444 +
445 + # 2. Watching → rollback si la fenêtre est passée et toujours cassé
446 + for inc in c.execute("SELECT * FROM incidents WHERE state='watching'").fetchall():
447 + if now() - inc["updated"] < POLICY["watch_window_hours"] * 3600:
448 + continue
449 + status = current_status(inc["service"], inc["source"])
450 + m = c.execute("SELECT * FROM missions WHERE incident_id=? AND base_commit IS NOT NULL "
451 + "ORDER BY started DESC LIMIT 1", (inc["id"],)).fetchone()
452 + if status in POLICY["trigger_statuses"] or status == "degraded":
453 + if m and (json.loads(m["commits"] or "[]")):
454 + asyncio.create_task(rollback_mission(m, "toujours cassé après la fenêtre de surveillance"))
455 + set_incident(c, inc["id"], state="cooldown")
456 + incident_event(inc["id"], inc["service"], inc["source"], "cooldown",
457 + f"non guéri après {POLICY['watch_window_hours']}h → rollback + cooldown")
458 + elif status == "ok":
459 + set_incident(c, inc["id"], state="resolved", resolved=now())
460 + incident_event(inc["id"], inc["service"], inc["source"], "resolved", "confirmé ok")
461 +
462 + # 3. Cooldown expiré → réouverture ou abandon
463 + for inc in c.execute("SELECT * FROM incidents WHERE state='cooldown'").fetchall():
464 + if now() - inc["updated"] < POLICY["attempt_cooldown_hours"] * 3600:
465 + continue
466 + status = current_status(inc["service"], inc["source"])
467 + if status == "ok":
468 + set_incident(c, inc["id"], state="resolved", resolved=now())
469 + incident_event(inc["id"], inc["service"], inc["source"], "resolved", "guéri pendant le cooldown")
470 + elif inc["attempts"] >= POLICY["max_attempts_per_incident"]:
471 + set_incident(c, inc["id"], state="abandoned")
472 + incident_event(inc["id"], inc["service"], inc["source"], "abandoned",
473 + f"{inc['attempts']} tentatives épuisées — intervention humaine requise")
474 + else:
475 + set_incident(c, inc["id"], state="open")
476 + incident_event(inc["id"], inc["service"], inc["source"], "open", "cooldown terminé, nouvelle tentative")
477 +
478 + # 4. Dispatch (1 mission à la fois par agent, broken avant stale, plus vieux d'abord)
479 + running = c.execute("SELECT COUNT(*) n FROM missions WHERE state='running'").fetchone()["n"]
480 + if running < POLICY["max_concurrent_missions"]:
481 + busy_nodes = {m["node"] for m in c.execute("SELECT node FROM missions WHERE state='running'").fetchall()}
482 + nxt = c.execute(
483 + "SELECT * FROM incidents WHERE state='open' "
484 + "ORDER BY CASE status_detected WHEN 'manual' THEN 0 WHEN 'effort_new' THEN 0 "
485 + "WHEN 'effort_enrich' THEN 0 WHEN 'broken' THEN 1 ELSE 2 END, created "
486 + ).fetchall()
487 + for inc in nxt:
488 + if inc["service"] not in SERVICES:
489 + continue
490 + if SERVICES[inc["service"]]["node"] in busy_nodes:
491 + continue
492 + set_incident(c, inc["id"], state="dispatched")
493 + asyncio.create_task(dispatch_row(inc["id"]))
494 + break
495 +
496 +
497 +def current_status(service: str, source: str) -> str:
498 + block = LATEST["mine"].get(service) or {}
499 + for conn in block.get("connectors", []):
500 + if conn["source"] == source:
501 + return conn["status"]
502 + return "inconnu"
503 +
504 +
505 +async def dispatch_row(iid: str) -> None:
506 + with db() as c:
507 + inc = c.execute("SELECT * FROM incidents WHERE id=?", (iid,)).fetchone()
508 + if inc:
509 + await dispatch(inc, json.loads(inc["detail"] or "{}"))
510 + # dispatch() remet fixing; si l'appel a échoué/409, on relâche
511 + with db() as c:
512 + cur = c.execute("SELECT state FROM incidents WHERE id=?", (iid,)).fetchone()
513 + if cur and cur["state"] == "dispatched":
514 + set_incident(c, iid, state="open")
515 +
516 +
517 +async def engine() -> None:
518 + global LOOP
519 + LOOP = asyncio.get_running_loop()
520 + await asyncio.sleep(3)
521 + while True:
522 + try:
523 + await tick()
524 + except Exception as exc:
525 + import traceback
526 + traceback.print_exc()
527 + hub.publish_sync({"kind": "log", "level": "error", "msg": f"tick: {exc}", "ts": now()})
528 + await asyncio.sleep(POLICY["poll_interval_seconds"])
529 +
530 +
531 +# ------------------------------------------------------------------- app ---
532 +
533 +app = FastAPI(title=f"KA Guardian — {AGENT}", docs_url=None, redoc_url=None)
534 +
535 +
536 +@app.on_event("startup")
537 +async def startup() -> None:
538 + init_db()
539 + asyncio.create_task(engine())
540 +
541 +
542 +def check_token(tok: str | None) -> None:
543 + if not TOKEN or tok != TOKEN:
544 + raise HTTPException(status_code=401)
545 +
546 +
547 +@app.post("/api/ingest/{mission_id}")
548 +async def ingest(mission_id: str, req: Request, x_ka_token: str | None = Header(default=None)) -> dict[str, Any]:
549 + check_token(x_ka_token)
550 + ev = await req.json()
551 + etype, data = ev.get("type"), ev.get("data", {})
552 + with db() as c:
553 + c.execute("INSERT INTO events(mission_id,ts,type,data) VALUES(?,?,?,?)",
554 + (mission_id, now(), etype, jdump(data)))
555 + m = c.execute("SELECT * FROM missions WHERE id=?", (mission_id,)).fetchone()
556 + if m:
557 + if etype == "start":
558 + c.execute("UPDATE missions SET base_commit=? WHERE id=?", (data.get("base_commit"), mission_id))
559 + elif etype in ("final", "error"):
560 + await finalize(c, m, etype, data)
561 + hub.publish_sync({"kind": "mission_event", "mission_id": mission_id, "type": etype,
562 + "data": data, "ts": now()})
563 + return {"ok": True}
564 +
565 +
566 +async def finalize(c: sqlite3.Connection, m: sqlite3.Row, etype: str, data: dict[str, Any]) -> None:
567 + inc = c.execute("SELECT * FROM incidents WHERE id=?", (m["incident_id"],)).fetchone()
568 + if etype == "error":
569 + c.execute("UPDATE missions SET state='error', ended=? WHERE id=?", (now(), m["id"]))
570 + if inc:
571 + set_incident(c, inc["id"], state="cooldown")
572 + incident_event(inc["id"], m["service"], m["source"], "cooldown", f"erreur mission: {data.get('error')}")
573 + return
574 + verdict = data.get("verdict", {})
575 + commits = data.get("commits", [])
576 + health = data.get("health", {})
577 + c.execute("UPDATE missions SET state='done', ended=?, commits=?, verdict=?, cost_usd=?, num_turns=?, health=?, base_commit=COALESCE(base_commit,?) WHERE id=?",
578 + (now(), jdump(commits), jdump(verdict), data.get("cost_usd"),
579 + data.get("num_turns"), jdump(health), data.get("base_commit"), m["id"]))
580 + m2 = c.execute("SELECT * FROM missions WHERE id=?", (m["id"],)).fetchone()
581 + v = verdict.get("verdict", "inconnu")
582 + if not inc:
583 + return
584 + if inc["status_detected"] in ("effort_new", "effort_enrich"):
585 + # Les efforts commandés ne passent pas par la surveillance api-ka:
586 + # verdict + santé de l'app décident tout de suite.
587 + if not health.get("ok", True):
588 + asyncio.create_task(rollback_mission(m2, "healthcheck app en échec post-effort"))
589 + set_incident(c, inc["id"], state="abandoned")
590 + incident_event(inc["id"], m["service"], m["source"], "abandoned", "app en mauvaise santé → ROLLBACK immédiat")
591 + elif v == "livre":
592 + set_incident(c, inc["id"], state="resolved", resolved=now())
593 + incident_event(inc["id"], m["service"], m["source"], "resolved",
594 + f"effort livré ({len(commits)} commit{'s' if len(commits) > 1 else ''})")
595 + else:
596 + if commits:
597 + asyncio.create_task(rollback_mission(m2, f"effort verdict {v} → rollback préventif"))
598 + set_incident(c, inc["id"], state="abandoned")
599 + incident_event(inc["id"], m["service"], m["source"], "abandoned", f"effort en échec ({v})")
600 + return
601 + if not health.get("ok", True):
602 + # L'app est tombée → rollback immédiat, quoi qu'ait dit l'agent.
603 + asyncio.create_task(rollback_mission(m2, "healthcheck app en échec post-mission"))
604 + set_incident(c, inc["id"], state="cooldown")
605 + incident_event(inc["id"], m["service"], m["source"], "cooldown", "app en mauvaise santé → ROLLBACK immédiat")
606 + elif v == "repare" and commits:
607 + set_incident(c, inc["id"], state="watching")
608 + incident_event(inc["id"], m["service"], m["source"], "watching",
609 + f"réparation déclarée ({len(commits)} commit) — surveillance {POLICY['watch_window_hours']}h")
610 + elif v in ("echec", "inconnu") and commits:
611 + asyncio.create_task(rollback_mission(m2, f"verdict {v} avec commits → annulation par prudence"))
612 + set_incident(c, inc["id"], state="cooldown")
613 + incident_event(inc["id"], m["service"], m["source"], "cooldown", f"verdict {v} → rollback préventif")
614 + elif v == "site_source_mort":
615 + set_incident(c, inc["id"], state="abandoned")
616 + incident_event(inc["id"], m["service"], m["source"], "abandoned", "source définitivement morte (diagnostic agent)")
617 + elif v in ("rien_a_faire", "repare"):
618 + set_incident(c, inc["id"], state="watching")
619 + incident_event(inc["id"], m["service"], m["source"], "watching", f"verdict {v} — surveillance")
620 + else:
621 + set_incident(c, inc["id"], state="cooldown")
622 + incident_event(inc["id"], m["service"], m["source"], "cooldown", f"verdict {v}")
623 +
624 +
625 +@app.get("/api/state")
626 +def state() -> dict[str, Any]:
627 + with db() as c:
628 + incidents = [dict(r) for r in c.execute(
629 + "SELECT * FROM incidents ORDER BY created DESC LIMIT 200").fetchall()]
630 + missions = [dict(r) for r in c.execute(
631 + "SELECT * FROM missions ORDER BY started DESC LIMIT 100").fetchall()]
632 + stats = c.execute("""SELECT
633 + (SELECT COUNT(*) FROM missions) n_missions,
634 + (SELECT COUNT(*) FROM incidents WHERE state='resolved') n_resolved,
635 + (SELECT COUNT(*) FROM incidents WHERE state IN ('open','dispatched','fixing','watching','cooldown')) n_active,
636 + (SELECT COUNT(*) FROM incidents WHERE state='abandoned') n_abandoned,
637 + (SELECT COUNT(*) FROM events WHERE type='rollback') n_rollbacks,
638 + (SELECT ROUND(SUM(cost_usd),2) FROM missions) cost_total,
639 + (SELECT ROUND(AVG(resolved-created)/3600.0,1) FROM incidents WHERE state='resolved' AND resolved IS NOT NULL) mttr_h
640 + """).fetchone()
641 + history = [dict(r) for r in c.execute(
642 + "SELECT ts, mine FROM snapshots WHERE ts > ? ORDER BY ts", (now() - 7 * 86400,)).fetchall()]
643 + return {
644 + "agent": AGENT, "identity": {k: ME[k] for k in ("domain", "accent", "accent2", "tagline", "model")},
645 + "services": {s: {**SERVICES[s], "node_alias": NODES[SERVICES[s]["node"]]["alias"]} for s in ME["services"]},
646 + "siblings": {a: {"domain": v["domain"], "accent": v["accent"], "services": v["services"]}
647 + for a, v in TOPO["agents"].items() if a != AGENT},
648 + "latest": LATEST, "incidents": incidents, "missions": missions,
649 + "stats": dict(stats), "history": history, "policy": POLICY, "now": now(),
650 + }
651 +
652 +
653 +@app.get("/api/missions/{mission_id}")
654 +def mission_detail(mission_id: str) -> dict[str, Any]:
655 + with db() as c:
656 + m = c.execute("SELECT * FROM missions WHERE id=?", (mission_id,)).fetchone()
657 + if not m:
658 + raise HTTPException(status_code=404)
659 + evs = [dict(r) for r in c.execute(
660 + "SELECT ts,type,data FROM events WHERE mission_id=? ORDER BY id", (mission_id,)).fetchall()]
661 + return {"mission": dict(m), "events": evs}
662 +
663 +
664 +@app.get("/events")
665 +async def sse(request: Request) -> StreamingResponse:
666 + q: asyncio.Queue = asyncio.Queue()
667 + hub.clients.add(q)
668 +
669 + async def gen():
670 + try:
671 + yield f"data: {jdump({'kind': 'hello', 'agent': AGENT, 'ts': now()})}\n\n"
672 + while True:
673 + if await request.is_disconnected():
674 + break
675 + try:
676 + msg = await asyncio.wait_for(q.get(), timeout=25)
677 + yield f"data: {jdump(msg)}\n\n"
678 + except asyncio.TimeoutError:
679 + yield ": keepalive\n\n"
680 + finally:
681 + hub.clients.discard(q)
682 +
683 + return StreamingResponse(gen(), media_type="text/event-stream",
684 + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
685 +
686 +
687 +@app.get("/api/connectors/{service}")
688 +def connectors_list(service: str) -> dict[str, Any]:
689 + """Sources connues du service (pour le menu déroulant d'enrichissement)."""
690 + block = LATEST["mine"].get(service) or {}
691 + return {"service": service,
692 + "connectors": sorted(
693 + ({"source": c["source"], "status": c["status"]} for c in block.get("connectors", [])),
694 + key=lambda x: x["source"])}
695 +
696 +
697 +@app.post("/api/admin/effort")
698 +async def admin_effort(req: Request, x_ka_token: str | None = Header(default=None)) -> dict[str, Any]:
699 + """Commande un effort autonome: nouveau connecteur ou enrichissement."""
700 + check_token(x_ka_token)
701 + body = await req.json()
702 + kind = body.get("kind")
703 + service = body.get("service")
704 + note = (body.get("note") or "").strip()[:2000]
705 + if kind not in ("effort_new", "effort_enrich") or service not in SERVICES:
706 + raise HTTPException(status_code=400, detail="kind ou service invalide")
707 + if kind == "effort_enrich":
708 + source = (body.get("source") or "").strip()
709 + if not source:
710 + raise HTTPException(status_code=400, detail="source requise pour un enrichissement")
711 + else:
712 + source = f"nouveau·{uuid.uuid4().hex[:6]}"
713 + iid = uuid.uuid4().hex[:10]
714 + with db() as c:
715 + c.execute("INSERT INTO incidents(id,service,source,status_detected,state,created,updated,detail) "
716 + "VALUES(?,?,?,?,?,?,?,?)",
717 + (iid, service, source, kind, "open", now(), now(), jdump({"note": note, "kind": kind})))
718 + incident_event(iid, service, source, "open",
719 + "effort commandé: " + ("nouveau connecteur" if kind == "effort_new" else f"enrichissement de {source}"))
720 + return {"ok": True, "incident_id": iid}
721 +
722 +
723 +@app.post("/api/admin/mission")
724 +async def admin_mission(req: Request, x_ka_token: str | None = Header(default=None)) -> dict[str, Any]:
725 + """Déclenche manuellement une mission sur un connecteur (même sain)."""
726 + check_token(x_ka_token)
727 + body = await req.json()
728 + service, source = body["service"], body["source"]
729 + if service not in SERVICES:
730 + raise HTTPException(status_code=400, detail="service inconnu")
731 + detail = {"source": source, "status": "manual", "message": body.get("note", "mission manuelle")}
732 + for conn in (LATEST["mine"].get(service) or {}).get("connectors", []):
733 + if conn["source"] == source:
734 + detail = {**conn, "status": "manual"}
735 + with db() as c:
736 + inc = active_incident(c, service, source)
737 + if inc is None:
738 + iid = uuid.uuid4().hex[:10]
739 + c.execute("INSERT INTO incidents(id,service,source,status_detected,state,created,updated,detail) "
740 + "VALUES(?,?,?,?,?,?,?,?)",
741 + (iid, service, source, "manual", "open", now(), now(), jdump(detail)))
742 + else:
743 + iid = inc["id"]
744 + set_incident(c, iid, state="open", status_detected="manual")
745 + incident_event(iid, service, source, "open", "mission manuelle demandée")
746 + return {"ok": True, "incident_id": iid}
747 +
748 +
749 +@app.post("/api/admin/pause")
750 +async def admin_pause(req: Request, x_ka_token: str | None = Header(default=None)) -> dict[str, Any]:
751 + check_token(x_ka_token)
752 + body = await req.json()
753 + LATEST["paused"] = bool(body.get("paused", True))
754 + return {"ok": True, "paused": LATEST["paused"]}
755 +
756 +
757 +@app.get("/health")
758 +def health() -> dict[str, Any]:
759 + return {"ok": True, "agent": AGENT, "polled": LATEST["polled"], "paused": LATEST["paused"]}
760 +
761 +
762 +@app.get("/")
763 +def index() -> FileResponse:
764 + return FileResponse(BASE / "web" / "index.html")
765 +
766 +
767 +app.mount("/static", StaticFiles(directory=BASE / "web"), name="static")
768 +
769 +
770 +if __name__ == "__main__":
771 + import uvicorn
772 + uvicorn.run(app, host="0.0.0.0", port=ME["port"])
added orchestrator/web/app.js +222 −0
@@ -0,0 +1,222 @@
1 +/* KA Guardian — dashboard live (style Groupe KA) */
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", manual: "manuel",
10 + effort_new: "effort · nouveau connecteur", effort_enrich: "effort · enrichissement" };
11 +const VERDICT_FR = { repare: "réparé", livre: "livré", echec: "échec", site_source_mort: "source morte",
12 + rien_a_faire: "rien à faire", inconnu: "inconnu", erreur: "erreur" };
13 +
14 +let STATE = null;
15 +
16 +async function load() {
17 + const r = await fetch("/api/state");
18 + STATE = await r.json();
19 + const id = STATE.identity, num = STATE.agent.replace("ka", "");
20 + document.documentElement.style.setProperty("--agent", id.accent);
21 + document.title = `${STATE.agent.toUpperCase()} Guardian — Groupe KA`;
22 + $("#wm-name").textContent = "ka·" + num;
23 + $("#foot-name").textContent = "ka·" + num;
24 + const apps = Object.values(STATE.services).map((s) => s.app);
25 + $("#hero-label").textContent = `${STATE.agent} — ${id.tagline} · veille sur ${apps.join(", ")}`;
26 + $("#screen-title").textContent = `${STATE.agent} — flux en temps réel`;
27 + $("#siblings").innerHTML = Object.entries(STATE.siblings).map(([a, v]) =>
28 + `<a class="gk-badge" href="https://${v.domain}"><span class="dot" style="background:${v.accent}"></span><b>${a}<span class="ka">·G</span></b></a>`).join(" ");
29 + $("#foot-sites").innerHTML =
30 + Object.values(STATE.services).map((s) => `<a href="${s.site}">${esc(s.app)}</a>`).join("") +
31 + Object.entries(STATE.siblings).map(([a, v]) => `<a href="https://${v.domain}">${a}·guardian</a>`).join("");
32 + renderTiles(); renderIncidents(); renderCoverage(); renderMissions(); initEffortForm();
33 +}
34 +
35 +/* ---- commander un effort ---- */
36 +let effortInit = false;
37 +function initEffortForm() {
38 + const svcSel = $("#ef-service");
39 + if (!effortInit) {
40 + svcSel.innerHTML = Object.entries(STATE.services)
41 + .map(([k, s]) => `<option value="${k}">${esc(s.app)} — ${esc(s.node_alias)}</option>`).join("");
42 + $("#ef-token").value = localStorage.getItem("ka_token") || "";
43 + $("#ef-kind").addEventListener("change", syncEffortKind);
44 + svcSel.addEventListener("change", fillSources);
45 + $("#effort-form").addEventListener("submit", submitEffort);
46 + effortInit = true;
47 + }
48 +}
49 +function syncEffortKind() {
50 + const enrich = $("#ef-kind").value === "effort_enrich";
51 + $("#ef-source-wrap").hidden = !enrich;
52 + if (enrich) fillSources();
53 +}
54 +async function fillSources() {
55 + if ($("#ef-kind").value !== "effort_enrich") return;
56 + const r = await fetch("/api/connectors/" + $("#ef-service").value);
57 + const d = await r.json();
58 + $("#ef-source").innerHTML = d.connectors
59 + .map((c) => `<option value="${esc(c.source)}">${esc(c.source)} (${STATUS_FR[c.status] || c.status})</option>`).join("")
60 + || `<option value="">— aucun connecteur connu —</option>`;
61 +}
62 +async function submitEffort(e) {
63 + e.preventDefault();
64 + const msg = $("#ef-msg"), btn = e.target.querySelector("button");
65 + const token = $("#ef-token").value.trim();
66 + if (!token) { msg.className = "cmd-msg err"; msg.textContent = "jeton d'opérateur requis"; return; }
67 + localStorage.setItem("ka_token", token);
68 + btn.disabled = true; msg.className = "cmd-msg"; msg.textContent = "lancement…";
69 + try {
70 + const r = await fetch("/api/admin/effort", {
71 + method: "POST", headers: { "Content-Type": "application/json", "X-KA-Token": token },
72 + body: JSON.stringify({
73 + kind: $("#ef-kind").value, service: $("#ef-service").value,
74 + source: $("#ef-source") ? $("#ef-source").value : "", note: $("#ef-note").value,
75 + }),
76 + });
77 + const d = await r.json();
78 + if (r.ok && d.ok) {
79 + msg.className = "cmd-msg ok";
80 + msg.textContent = `effort accepté (incident ${d.incident_id}) — la session démarre d'ici ~5 min, suis le flux ⚡`;
81 + $("#ef-note").value = "";
82 + refetch();
83 + } else {
84 + msg.className = "cmd-msg err";
85 + msg.textContent = "refusé: " + (d.detail || r.status);
86 + }
87 + } catch (err) {
88 + msg.className = "cmd-msg err"; msg.textContent = "erreur: " + err;
89 + } finally { btn.disabled = false; }
90 +}
91 +
92 +function renderTiles() {
93 + const s = STATE.stats, mine = STATE.latest.mine || {};
94 + let total = 0;
95 + for (const b of Object.values(mine)) for (const st of STATUSES) total += (b.summary || {})[st] || 0;
96 + const tiles = [
97 + [total || "—", "connecteurs sous garde", true],
98 + [s.n_active ?? 0, "incidents actifs"],
99 + [s.n_missions ?? 0, "missions lancées"],
100 + [s.n_resolved ?? 0, "réparations confirmées"],
101 + [s.n_rollbacks ?? 0, "rollbacks assumés"],
102 + [s.mttr_h != null ? s.mttr_h + " h" : "—", "temps moyen de guérison"],
103 + [s.cost_total != null ? s.cost_total + " $" : "0 $", "coût API total"],
104 + ];
105 + $("#tiles").innerHTML = tiles.map(([v, l, hot]) =>
106 + `<div class="tile${hot ? " hot" : ""}"><div class="v">${esc(v)}</div><div class="l">${l}</div></div>`).join("");
107 +}
108 +
109 +function renderIncidents() {
110 + const active = STATE.incidents.filter((i) => !["resolved", "self_healed"].includes(i.state)).slice(0, 12);
111 + const recent = STATE.incidents.filter((i) => ["resolved", "self_healed"].includes(i.state)).slice(0, 5);
112 + const row = (i) => `<div class="inc">
113 + <div><div class="src">${esc(i.source)}</div><div class="svc">${esc(i.service)} · ${esc(STATUS_FR[i.status_detected] || i.status_detected)} · ${i.attempts} tentative${i.attempts > 1 ? "s" : ""}</div></div>
114 + <span class="badge b-${esc(i.state)}">${STATE_FR[i.state] || esc(i.state)}</span></div>`;
115 + $("#incidents").innerHTML = (active.length || recent.length)
116 + ? active.map(row).join("") + recent.map(row).join("")
117 + : `<div class="empty">Aucun incident — tous les connecteurs sous garde sont sains. Le gardien veille.</div>`;
118 +}
119 +
120 +function renderCoverage() {
121 + const mine = STATE.latest.mine || {};
122 + $("#coverage").innerHTML = Object.keys(STATE.services).map((svc) => {
123 + const meta = STATE.services[svc], sum = (mine[svc] || {}).summary || {};
124 + const total = STATUSES.reduce((a, st) => a + (sum[st] || 0), 0);
125 + const strip = STATUSES.filter((st) => sum[st] > 0)
126 + .map((st) => `<span class="s-${st}" style="flex:${sum[st]}" title="${STATUS_FR[st]}: ${sum[st]}"></span>`).join("");
127 + const counts = STATUSES.map((st) => `<span><i class="s-${st}"></i>${sum[st] || 0} ${STATUS_FR[st]}</span>`).join("");
128 + return `<div class="cov"><div class="head"><span class="app">${esc(meta.app)}</span>
129 + <span class="node">${esc(meta.node_alias)} · ${total || "?"} connecteurs</span></div>
130 + <div class="strip">${strip || "<span style='flex:1;background:var(--line)'></span>"}</div>
131 + <div class="counts">${counts}</div></div>`;
132 + }).join("");
133 +}
134 +
135 +function renderMissions() {
136 + const vd = (m) => {
137 + if (m.state === "running") return `<span class="vbadge v-running">en cours…</span>`;
138 + if (m.state === "error") return `<span class="vbadge v-erreur">erreur</span>`;
139 + const v = (JSON.parse(m.verdict || "{}").verdict) || "inconnu";
140 + return `<span class="vbadge v-${esc(v)}">${VERDICT_FR[v] || esc(v)}</span>`;
141 + };
142 + $("#missions tbody").innerHTML = STATE.missions.map((m) => {
143 + const commits = JSON.parse(m.commits || "[]").length;
144 + const dur = m.ended ? Math.round((m.ended - m.started) / 60) + " min" : "—";
145 + return `<tr data-id="${esc(m.id)}"><td class="mono">${fmtD(m.started)}</td>
146 + <td class="mono"><b>${esc(m.source)}</b></td><td>${esc((STATE.services[m.service] || {}).app || m.service)}</td>
147 + <td class="mono">${esc(m.node)}</td><td>${vd(m)}</td><td class="mono">${commits || "—"}</td>
148 + <td class="mono">${m.num_turns ? m.num_turns + " tours · " : ""}${dur}</td>
149 + <td class="mono">${m.cost_usd != null ? m.cost_usd.toFixed(2) + " $" : ""}</td></tr>`;
150 + }).join("") || `<tr><td colspan="8" class="empty" style="padding:18px">Aucune mission encore — la première panne détectée lancera le gardien.</td></tr>`;
151 +}
152 +
153 +/* ---- flux en direct ---- */
154 +const feed = $("#feed");
155 +function feedLine(cls, tag, msg, ts) {
156 + if (feed.querySelector(".feed-empty")) feed.innerHTML = "";
157 + const el = document.createElement("div");
158 + el.className = "fe " + cls;
159 + el.innerHTML = `<span class="t">${fmtT(ts || Date.now() / 1000)}</span><span class="tag">${esc(tag)}</span><span class="m">${esc(msg)}</span>`;
160 + feed.appendChild(el);
161 + while (feed.children.length > 400) feed.removeChild(feed.firstChild);
162 + feed.scrollTop = feed.scrollHeight;
163 +}
164 +
165 +let refetchTimer = null;
166 +const refetch = () => { clearTimeout(refetchTimer); refetchTimer = setTimeout(load, 800); };
167 +
168 +function connectSSE() {
169 + const es = new EventSource("/events");
170 + es.onopen = () => $("#live-dot").classList.add("on");
171 + es.onerror = () => $("#live-dot").classList.remove("on");
172 + es.onmessage = (e) => {
173 + const m = JSON.parse(e.data);
174 + if (m.kind === "mission_event") {
175 + const d = m.data || {};
176 + if (m.type === "start") feedLine("tool", "mission", `démarrage — commit de base ${(d.base_commit || "").slice(0, 8)} sur ${d.node || ""}`, m.ts);
177 + else if (m.type === "tool") feedLine("tool", d.name || "outil", d.input || "", m.ts);
178 + else if (m.type === "text") feedLine("text", "agent", d.text || "", m.ts);
179 + else if (m.type === "result") feedLine("text", "résultat", (d.result || "").slice(0, 600), m.ts);
180 + else if (m.type === "final") { feedLine("text", "verdict", JSON.stringify(d.verdict || {}), m.ts); refetch(); }
181 + else if (m.type === "error") { feedLine("rollback", "erreur", d.error || "", m.ts); refetch(); }
182 + else if (m.type === "rollback") feedLine("rollback", "rollback", JSON.stringify(d), m.ts);
183 + } else if (m.kind === "incident") {
184 + feedLine("incident", "incident", `${m.service}/${m.source} → ${STATE_FR[m.state] || m.state}${m.note ? " — " + m.note : ""}`, m.ts);
185 + refetch();
186 + } else if (m.kind === "rollback") {
187 + feedLine("rollback", "rollback", `${m.service}/${m.source} — ${m.reason} (${(m.result || {}).mode || "?"})`, m.ts);
188 + refetch();
189 + } else if (m.kind === "mission") {
190 + feedLine("tool", "mission", `nouvelle mission sur ${m.service}/${m.source}`, m.ts);
191 + refetch();
192 + } else if (m.kind === "snapshot") {
193 + if (STATE) { STATE.latest.mine = Object.fromEntries(Object.entries(m.mine).map(([s, sum]) => [s, { summary: sum }])); renderCoverage(); renderTiles(); }
194 + } else if (m.kind === "log") {
195 + feedLine("incident", m.level || "log", m.msg, m.ts);
196 + }
197 + };
198 +}
199 +
200 +/* ---- modal mission ---- */
201 +document.addEventListener("click", async (e) => {
202 + const tr = e.target.closest("#missions tbody tr[data-id]");
203 + if (tr) {
204 + const r = await fetch("/api/missions/" + tr.dataset.id);
205 + const { mission, events } = await r.json();
206 + const v = JSON.parse(mission.verdict || "{}");
207 + $("#modal-title").textContent = `Mission ${mission.id} — ${mission.source} (${mission.service})`;
208 + $("#modal-body").innerHTML =
209 + `<div class="meta"><b>verdict:</b> ${esc(VERDICT_FR[v.verdict] || v.verdict || mission.state)}\n<b>diagnostic:</b> ${esc(v.diagnostic || "")}\n<b>actions:</b> ${esc(v.actions || "")}\n<b>test:</b> ${esc(v.test || "")}\n<b>commit de base:</b> ${esc(mission.base_commit || "")}\n<b>commits:</b> ${esc((JSON.parse(mission.commits || "[]")).join(" | ") || "aucun")}</div>` +
210 + events.map((ev) => {
211 + const d = JSON.parse(ev.data || "{}");
212 + const txt = ev.type === "tool" ? `${d.name}: ${d.input}` : ev.type === "text" ? d.text
213 + : ev.type === "result" ? d.result : JSON.stringify(d);
214 + return `<div class="meta"><b>${fmtT(ev.ts)} · ${esc(ev.type)}</b>\n${esc((txt || "").slice(0, 2500))}</div>`;
215 + }).join("");
216 + $("#modal").hidden = false;
217 + }
218 + if (e.target.id === "modal-close" || e.target.id === "modal") $("#modal").hidden = true;
219 +});
220 +
221 +load().then(connectSSE);
222 +setInterval(load, 60000);
added orchestrator/web/index.html +147 −0
@@ -0,0 +1,147 @@
1 +<!doctype html>
2 +<html lang="fr-CA">
3 +<head>
4 +<meta charset="utf-8">
5 +<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6 +<title>Guardian — Groupe KA</title>
7 +<meta name="description" content="Agent gardien autonome du Groupe KA : il détecte les connecteurs en panne, les répare avec Claude, surveille la guérison et revient en arrière si ça empire. Tout est public, 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@500;700&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;600;700&display=swap" rel="stylesheet">
11 +<link rel="stylesheet" href="/static/style.css">
12 +</head>
13 +<body>
14 +
15 +<header class="topbar">
16 + <a class="wordmark" href="/"><span id="wm-name">ka·—</span><span class="ka" id="wm-ka">guardian</span></a>
17 + <nav class="topnav">
18 + <a class="gk-badge" href="https://www.groupe-ka.com">groupe<b><span class="ka">·KA</span></b></a>
19 + <span id="siblings"></span>
20 + </nav>
21 +</header>
22 +
23 +<main>
24 + <!-- ============ HÉROS ============ -->
25 + <section class="hero">
26 + <div class="hero-main">
27 + <p class="klabel" id="hero-label">agent gardien autonome</p>
28 + <h1>Il veille. Il répare.<br>Il <span class="hl">rend des comptes</span>.</h1>
29 + <p class="lede" id="lede">Un connecteur tombe en panne quelque part dans l'écosystème ·Ka ?
30 + Cet agent le détecte, ouvre le code, le répare, prouve que ça marche — et si ça empire,
31 + il revient au commit d'avant. Tout ce qu'il fait est public, ici, en direct.</p>
32 + <div class="hero-chips" id="hero-chips">
33 + <span class="chip chip-accent"><span class="live-dot" id="live-dot"></span> en direct</span>
34 + </div>
35 + </div>
36 + <aside class="defcard card">
37 + <p class="klabel">définition</p>
38 + <p class="defword">gar·dien <span class="phon">/ɡaʁ.djɛ̃/</span> <span class="nat">n.m.</span></p>
39 + <ol class="defs">
40 + <li><b>détecte</b> — lit la supervision api-ka toutes les 5 minutes ; un connecteur <i>cassé</i> ou <i>endormi</i> devient un incident.</li>
41 + <li><b>répare</b> — dépêche Claude dans le repo de l'app, sur le nœud même : diagnostic, correctif minimal, test réel, commit.</li>
42 + <li><b>surveille</b> — 8 heures d'observation post-réparation avant de déclarer victoire.</li>
43 + <li><b>recule</b> — l'app tombe ou rien ne guérit ? <i>git reset</i> au commit d'avant, redémarrage, et on le dit publiquement.</li>
44 + </ol>
45 + </aside>
46 + </section>
47 +
48 + <!-- ============ CHIFFRES ============ -->
49 + <section>
50 + <p class="klabel">l'état du gardien</p>
51 + <div class="tiles" id="tiles"></div>
52 + </section>
53 +
54 + <!-- ============ COMMANDER UN EFFORT ============ -->
55 + <section class="commander card">
56 + <div class="cmd-grid">
57 + <div class="cmd-intro">
58 + <p class="klabel">commander un effort</p>
59 + <h2 class="cmd-title">Donne-lui du <span class="hl">travail</span>.</h2>
60 + <p class="cmd-lede">Choisis une plateforme et lance une session Claude Code
61 + <b>100 % autonome</b> sur son nœud : elle découvre (Serper), escalade s'il le faut
62 + (proxys résidentiels, Scrapfly, acteurs Apify), teste pour vrai, committe —
63 + et travaille jusqu'au bout sans rien demander. Tout s'affiche dans le flux.</p>
64 + </div>
65 + <form id="effort-form">
66 + <div class="frow">
67 + <label class="flab">Type d'effort
68 + <select id="ef-kind" class="select">
69 + <option value="effort_new">Nouveau connecteur — découverte + construction</option>
70 + <option value="effort_enrich">Enrichissement d'un connecteur existant</option>
71 + </select>
72 + </label>
73 + <label class="flab">Plateforme
74 + <select id="ef-service" class="select"></select>
75 + </label>
76 + </div>
77 + <label class="flab" id="ef-source-wrap" hidden>Connecteur à enrichir
78 + <select id="ef-source" class="select"></select>
79 + </label>
80 + <label class="flab">Consigne (optionnel)
81 + <input id="ef-note" class="input" placeholder="ex.: vise les microbrasseries de la Côte-Nord, ajoute les fiches détail…">
82 + </label>
83 + <div class="frow frow-end">
84 + <label class="flab">Jeton d'opérateur
85 + <input id="ef-token" type="password" class="input" placeholder="KA_GUARDIAN_TOKEN" autocomplete="off">
86 + </label>
87 + <button type="submit" class="btn-primary">Lancer l'effort →</button>
88 + </div>
89 + <p class="cmd-msg" id="ef-msg"></p>
90 + </form>
91 + </div>
92 + </section>
93 +
94 + <!-- ============ ÉCRAN + INCIDENTS ============ -->
95 + <section class="cols">
96 + <div class="screen card" id="screen">
97 + <div class="screen-head">
98 + <span class="screen-dots"><i></i><i></i><i></i></span>
99 + <span class="screen-title" id="screen-title">flux — tout ce que l'agent fait, en temps réel</span>
100 + </div>
101 + <div class="feed" id="feed"><div class="feed-empty">// en attente d'activité — le gardien scrute api-ka toutes les 5 minutes…</div></div>
102 + </div>
103 + <div class="side">
104 + <div class="card pad">
105 + <p class="klabel">incidents</p>
106 + <div id="incidents" class="incidents"></div>
107 + </div>
108 + <div class="card pad">
109 + <p class="klabel">territoire surveillé</p>
110 + <div id="coverage" class="coverage"></div>
111 + </div>
112 + </div>
113 + </section>
114 +
115 + <!-- ============ MISSIONS ============ -->
116 + <section>
117 + <p class="klabel">registre des missions <span class="hint">— cliquer une ligne pour le déroulé complet, commits inclus</span></p>
118 + <div class="card table-wrap">
119 + <table id="missions">
120 + <thead><tr><th>Quand</th><th>Connecteur</th><th>Service</th><th>Nœud</th><th>Verdict</th><th>Commits</th><th>Durée</th><th>Coût</th></tr></thead>
121 + <tbody></tbody>
122 + </table>
123 + </div>
124 + </section>
125 +</main>
126 +
127 +<footer class="ka-footer">
128 + <div class="foot-in">
129 + <a class="wordmark wordmark-foot" href="/"><span id="foot-name">ka·—</span><span class="ka">guardian</span></a>
130 + <p class="notice"><b>Zéro boîte noire.</b> Chaque diagnostic, chaque commit, chaque rollback de cet agent
131 + est journalisé et affiché ici. Les réparations sont committées dans les repos des plateformes,
132 + jamais poussées sans passage humain.</p>
133 + <p class="sites" id="foot-sites"></p>
134 + <p class="legal">Groupe KA — agrégation automatisée, Québec. <a href="https://www.groupe-ka.com">groupe-ka.com</a></p>
135 + </div>
136 +</footer>
137 +
138 +<div class="modal" id="modal" hidden>
139 + <div class="modal-box card">
140 + <div class="modal-head"><h3 id="modal-title">Mission</h3><button id="modal-close" aria-label="fermer">✕</button></div>
141 + <div class="modal-body" id="modal-body"></div>
142 + </div>
143 +</div>
144 +
145 +<script src="/static/app.js"></script>
146 +</body>
147 +</html>
added orchestrator/web/style.css +220 −0
@@ -0,0 +1,220 @@
1 +/* KA Guardian — langage visuel Groupe KA : papier, encre, lime, ombres décalées dures */
2 +:root {
3 + --paper: #f5f3ee; --surface: #fff; --surface-2: #faf9f5;
4 + --ink: #141814; --ink-2: #4d5551; --ink-3: #8b928c;
5 + --line: #14181424; --line-strong: #141814d9;
6 + --green: #1c5c41; --green-soft: #e4f0e9;
7 + --amber: #e8a33d; --amber-soft: #fdf3e2;
8 + --danger: #b3423a; --danger-soft: #fbe9e7;
9 + --stale: #3a5f8a; --stale-soft: #e7edf5;
10 + --lime: #d9f26b; --lime-soft: #f0f9d2; --accent-deep: #123f2e;
11 + --agent: #22d3ee; /* remplacé au chargement par l'accent de l'agent */
12 + --r-card: 10px; --r-ctl: 6px; --r-pill: 999px;
13 + --shadow-off: 6px 6px 0 var(--ink);
14 + --shadow-off-soft: 8px 8px 0 #14181414;
15 + --shadow-off-mid: 4px 4px 0 #1418142e;
16 + --font-display: "Space Grotesk", system-ui, sans-serif;
17 + --font-body: "Inter", system-ui, sans-serif;
18 + --font-mono: "JetBrains Mono", ui-monospace, monospace;
19 + --fs-h1: clamp(30px, 3.2vw + 18px, 52px);
20 +}
21 +* { box-sizing: border-box; margin: 0; }
22 +[hidden] { display: none !important; }
23 +body { background: var(--paper); color: var(--ink); font: 15px/1.55 var(--font-body);
24 + -webkit-font-smoothing: antialiased; overflow-x: clip; }
25 +a { color: inherit; }
26 +::selection { background: var(--lime); color: var(--ink); }
27 +
28 +.klabel { font-family: var(--font-mono); text-transform: uppercase; letter-spacing: 0.1em;
29 + color: var(--ink-3); font-size: 10px; font-weight: 700; margin-bottom: 10px; }
30 +.hint { text-transform: none; letter-spacing: 0.02em; font-weight: 400; }
31 +.hl { background: var(--lime); color: var(--ink); border-radius: 8px; padding: 0 10px 2px;
32 + display: inline-block; transform: rotate(-1deg); }
33 +
34 +.card { background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card);
35 + box-shadow: var(--shadow-off-soft); overflow: hidden; }
36 +.pad { padding: 16px; }
37 +
38 +.chip { font-family: var(--font-mono); text-transform: uppercase; letter-spacing: 0.06em;
39 + border: 1.5px solid var(--ink); border-radius: var(--r-pill); background: var(--surface);
40 + color: var(--ink); align-items: center; gap: 6px; padding: 4px 11px; font-size: 11px;
41 + font-weight: 700; display: inline-flex; }
42 +.chip-accent { background: var(--lime); }
43 +
44 +/* ---------- header ---------- */
45 +.topbar { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap;
46 + padding: 14px clamp(16px, 4vw, 48px); border-bottom: 1.5px solid var(--ink); background: var(--paper);
47 + position: sticky; top: 0; z-index: 500; }
48 +.wordmark { font-family: var(--font-display); font-weight: 700; font-size: 24px; letter-spacing: -0.04em;
49 + text-decoration: none; display: inline-flex; align-items: center; }
50 +.wordmark .ka { background: var(--ink); color: var(--lime); border-radius: 6px; margin-left: 6px;
51 + padding: 0 8px 2px; display: inline-block; transform: rotate(-2deg); font-size: 0.72em; }
52 +#wm-name b, #wm-name { color: var(--ink); }
53 +.topnav { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
54 +.gk-badge { min-height: 30px; font-family: var(--font-mono); letter-spacing: 0.08em; text-transform: uppercase;
55 + border: 1.5px solid var(--ink); border-radius: var(--r-pill); background: var(--surface); color: var(--ink-2);
56 + white-space: nowrap; align-items: center; gap: 7px; padding: 3px 10px 4px; font-size: 10px; font-weight: 700;
57 + text-decoration: none; display: inline-flex; transition: transform 0.15s, box-shadow 0.15s; }
58 +.gk-badge:hover { transform: translate(-1px, -1px); box-shadow: 3px 3px 0 var(--ink); }
59 +.gk-badge b { font-family: var(--font-display); letter-spacing: -0.02em; text-transform: none;
60 + color: var(--ink); font-size: 12px; }
61 +.gk-badge .ka { background: var(--ink); color: var(--lime); border-radius: 5px; margin-left: 3px;
62 + padding: 0 5px 1px; display: inline-block; transform: rotate(-2deg); }
63 +.gk-badge .dot { width: 8px; height: 8px; border-radius: 50%; border: 1.5px solid var(--ink); }
64 +
65 +/* ---------- héros ---------- */
66 +main { padding: clamp(24px, 4vw, 48px) clamp(16px, 4vw, 48px); max-width: 1440px; margin: 0 auto;
67 + display: grid; gap: clamp(32px, 4vw, 56px); }
68 +.hero { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); gap: clamp(20px, 3vw, 40px);
69 + align-items: start; }
70 +@media (max-width: 900px) { .hero { grid-template-columns: 1fr; } }
71 +.hero h1 { font-family: var(--font-display); font-size: var(--fs-h1); line-height: 1.08;
72 + letter-spacing: -0.03em; margin: 6px 0 16px; }
73 +.lede { color: var(--ink-2); max-width: 56ch; font-size: 16px; }
74 +.hero-chips { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 18px; }
75 +.live-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--ink-3); display: inline-block; }
76 +.live-dot.on { background: var(--green); animation: pulse 1.8s infinite; }
77 +@keyframes pulse { 0%, 100% { box-shadow: 0 0 0 0 #1c5c4166; } 60% { box-shadow: 0 0 0 7px transparent; } }
78 +
79 +.defcard { padding: 18px 20px; box-shadow: var(--shadow-off); transform: rotate(0.4deg); }
80 +.defword { font-family: var(--font-display); font-size: 24px; font-weight: 700; margin-bottom: 10px; }
81 +.phon { color: var(--ink-3); font-weight: 400; font-size: 16px; }
82 +.nat { font-style: italic; color: var(--ink-3); font-size: 14px; }
83 +.defs { margin: 0; padding-left: 20px; display: grid; gap: 8px; font-size: 14px; color: var(--ink-2); }
84 +.defs b { color: var(--ink); }
85 +
86 +/* ---------- tuiles ---------- */
87 +.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 14px; }
88 +.tile { background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card);
89 + padding: 14px 16px; box-shadow: var(--shadow-off-mid); }
90 +.tile .v { font: 700 30px/1.15 var(--font-mono); letter-spacing: -0.02em; }
91 +.tile .l { color: var(--ink-2); font-size: 12px; margin-top: 4px; }
92 +.tile.hot { background: var(--lime); }
93 +
94 +/* ---------- commander un effort ---------- */
95 +.commander { background: var(--lime-soft); box-shadow: var(--shadow-off); }
96 +.cmd-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.3fr); gap: clamp(18px, 3vw, 40px);
97 + padding: clamp(18px, 3vw, 32px); }
98 +@media (max-width: 900px) { .cmd-grid { grid-template-columns: 1fr; } }
99 +.cmd-title { font-family: var(--font-display); font-size: clamp(24px, 2.4vw, 34px); letter-spacing: -0.02em;
100 + margin: 4px 0 12px; }
101 +.cmd-lede { color: var(--ink-2); font-size: 14.5px; max-width: 48ch; }
102 +#effort-form { display: grid; gap: 12px; align-content: start; }
103 +.frow { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
104 +@media (max-width: 640px) { .frow { grid-template-columns: 1fr; } }
105 +.frow-end { align-items: end; }
106 +.flab { display: grid; gap: 5px; font: 700 10px var(--font-mono); text-transform: uppercase;
107 + letter-spacing: 0.08em; color: var(--ink-3); }
108 +.input, .select { width: 100%; min-height: 44px; font: 400 14px var(--font-body); color: var(--ink);
109 + background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-ctl); padding: 10px 14px; }
110 +.input:focus, .select:focus { box-shadow: 3px 3px 0 var(--lime); outline: none; }
111 +.input::placeholder { color: var(--ink-3); }
112 +.btn-primary { min-height: 44px; font: 700 14px var(--font-display); border: 1.5px solid var(--ink);
113 + border-radius: var(--r-ctl); background: var(--ink); color: var(--lime); cursor: pointer; padding: 10px 18px;
114 + transition: transform 0.15s, box-shadow 0.15s, background 0.15s; }
115 +.btn-primary:hover { transform: translate(-2px, -2px); box-shadow: 4px 4px 0 #1418142e; background: var(--accent-deep); }
116 +.btn-primary:disabled { opacity: 0.5; cursor: wait; transform: none; box-shadow: none; }
117 +.cmd-msg { font: 600 12.5px var(--font-mono); min-height: 18px; }
118 +.cmd-msg.ok { color: var(--green); } .cmd-msg.err { color: var(--danger); }
119 +
120 +/* ---------- écran (flux) + side ---------- */
121 +.cols { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr); gap: 20px; align-items: start; }
122 +@media (max-width: 980px) { .cols { grid-template-columns: 1fr; } }
123 +.side { display: grid; gap: 20px; min-width: 0; }
124 +
125 +.screen { background: var(--ink); border-color: var(--ink); box-shadow: var(--shadow-off); min-width: 0; }
126 +.screen-head { display: flex; align-items: center; gap: 12px; padding: 10px 14px;
127 + border-bottom: 1px solid #f5f3ee2b; }
128 +.screen-dots { display: inline-flex; gap: 5px; }
129 +.screen-dots i { width: 10px; height: 10px; border-radius: 50%; background: #f5f3ee33; }
130 +.screen-dots i:first-child { background: var(--agent); }
131 +.screen-title { font-family: var(--font-mono); font-size: 11px; color: #f5f3ee99;
132 + text-transform: uppercase; letter-spacing: 0.08em; }
133 +.feed { font: 12.5px/1.6 var(--font-mono); padding: 14px; height: 560px; overflow-y: auto;
134 + display: flex; flex-direction: column; gap: 7px; color: #f5f3eec9; }
135 +.feed-empty { color: #f5f3ee66; }
136 +.fe { display: flex; gap: 10px; align-items: baseline; animation: fadein 0.3s; }
137 +@keyframes fadein { from { opacity: 0; transform: translateY(4px); } }
138 +.fe .t { color: #f5f3ee59; flex: none; font-size: 10.5px; }
139 +.fe .tag { flex: none; font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em;
140 + padding: 1px 8px; border-radius: 999px; border: 1px solid #f5f3ee38; color: #f5f3ee99; }
141 +.fe.tool .tag { color: var(--lime); border-color: #d9f26b59; }
142 +.fe.text .tag { color: #7fd6a8; border-color: #7fd6a859; }
143 +.fe.incident .tag { color: var(--amber); border-color: #e8a33d66; }
144 +.fe.rollback .tag { color: #ef9d96; border-color: #ef9d9666; }
145 +.fe .m { overflow-wrap: anywhere; white-space: pre-wrap; min-width: 0; }
146 +.fe.text .m { color: #f5f3ee; }
147 +
148 +/* ---------- incidents ---------- */
149 +.incidents { display: grid; gap: 8px; max-height: 320px; overflow-y: auto; }
150 +.inc { display: flex; align-items: center; gap: 10px; background: var(--surface-2);
151 + border: 1.5px solid var(--line); border-radius: var(--r-ctl); padding: 9px 11px; font-size: 13px; }
152 +.inc .src { font: 600 12.5px var(--font-mono); overflow-wrap: anywhere; }
153 +.inc .svc { color: var(--ink-3); font-size: 11px; font-family: var(--font-mono); }
154 +.badge { margin-left: auto; flex: none; font: 700 10px var(--font-mono); text-transform: uppercase;
155 + letter-spacing: 0.05em; padding: 3px 9px; border-radius: var(--r-pill); border: 1.5px solid var(--ink); }
156 +.b-open { background: var(--danger-soft); color: var(--danger); border-color: var(--danger); }
157 +.b-fixing, .b-dispatched { background: var(--lime); color: var(--ink); }
158 +.b-watching { background: var(--stale-soft); color: var(--stale); border-color: var(--stale); }
159 +.b-cooldown { background: var(--amber-soft); color: #8a5f1e; border-color: var(--amber); }
160 +.b-resolved, .b-self_healed { background: var(--green-soft); color: var(--green); border-color: var(--green); }
161 +.b-abandoned { background: var(--surface); color: var(--ink-3); border-color: var(--ink-3); }
162 +.empty { color: var(--ink-3); font-size: 13px; }
163 +
164 +/* ---------- couverture ---------- */
165 +.coverage { display: grid; gap: 12px; }
166 +.cov { border: 1.5px solid var(--line); border-radius: var(--r-ctl); padding: 11px 13px; background: var(--surface-2); }
167 +.cov .head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 8px; gap: 8px; }
168 +.cov .app { font-family: var(--font-display); font-weight: 700; font-size: 15px; }
169 +.cov .node { color: var(--ink-3); font: 10.5px var(--font-mono); text-transform: uppercase; letter-spacing: 0.05em; }
170 +.strip { display: flex; height: 10px; border-radius: 5px; overflow: hidden; gap: 2px; }
171 +.strip span { min-width: 3px; }
172 +.s-ok { background: var(--green); } .s-degraded { background: var(--amber); }
173 +.s-broken { background: var(--danger); } .s-stale { background: var(--stale); }
174 +.counts { display: flex; gap: 11px; margin-top: 8px; flex-wrap: wrap;
175 + font: 600 10.5px var(--font-mono); color: var(--ink-2); text-transform: uppercase; letter-spacing: 0.04em; }
176 +.counts i { display: inline-block; width: 8px; height: 8px; border-radius: 2px; margin-right: 5px; }
177 +
178 +/* ---------- missions ---------- */
179 +.table-wrap { overflow-x: auto; box-shadow: var(--shadow-off-soft); }
180 +table { width: 100%; border-collapse: collapse; font-size: 13px; background: var(--surface); }
181 +th { text-align: left; font: 700 10px var(--font-mono); text-transform: uppercase; letter-spacing: 0.08em;
182 + color: var(--ink-3); padding: 11px 12px; border-bottom: 1.5px solid var(--ink); background: var(--surface-2); }
183 +td { padding: 10px 12px; border-bottom: 1px solid var(--line); }
184 +tbody tr { cursor: pointer; transition: background 0.12s; }
185 +tbody tr:hover { background: var(--lime-soft); }
186 +.mono { font-family: var(--font-mono); font-size: 12px; }
187 +.vbadge { font: 700 10px var(--font-mono); text-transform: uppercase; letter-spacing: 0.05em;
188 + padding: 3px 9px; border-radius: var(--r-pill); border: 1.5px solid currentColor; white-space: nowrap; }
189 +.v-repare { color: var(--green); background: var(--green-soft); }
190 +.v-echec, .v-inconnu, .v-erreur { color: var(--danger); background: var(--danger-soft); }
191 +.v-site_source_mort { color: var(--ink-3); background: var(--surface-2); }
192 +.v-rien_a_faire { color: var(--stale); background: var(--stale-soft); }
193 +.v-running { color: var(--accent-deep); background: var(--lime); }
194 +
195 +/* ---------- footer ---------- */
196 +.ka-footer { margin-top: 24px; background: var(--ink); color: #f5f3eebf; padding: 44px 0; font-size: 13px; }
197 +.foot-in { max-width: 1440px; margin: 0 auto; padding: 0 clamp(16px, 4vw, 48px); display: grid; gap: 16px; }
198 +.wordmark-foot, .wordmark-foot #foot-name { color: var(--paper); font-size: 30px; }
199 +.wordmark-foot .ka { background: var(--lime); color: var(--ink); }
200 +.notice { max-width: 640px; border-left: 2px solid var(--lime); padding-left: 16px; }
201 +.notice b { color: var(--paper); }
202 +.sites { display: flex; gap: 14px; flex-wrap: wrap; }
203 +.sites a, .legal a { color: #f5f3eebf; text-decoration: none; }
204 +.sites a:hover, .legal a:hover { color: var(--lime); text-decoration: underline; text-underline-offset: 4px; }
205 +.legal { color: #f5f3ee73; }
206 +
207 +/* ---------- modal ---------- */
208 +.modal { position: fixed; inset: 0; background: #14181480; display: grid; place-items: center; z-index: 900; padding: 16px; }
209 +.modal[hidden] { display: none; }
210 +.modal-box { width: min(880px, 94vw); max-height: 86vh; display: flex; flex-direction: column; box-shadow: var(--shadow-off); }
211 +.modal-head { display: flex; justify-content: space-between; align-items: center; gap: 10px;
212 + padding: 13px 18px; border-bottom: 1.5px solid var(--ink); background: var(--surface-2); }
213 +.modal-head h3 { font-family: var(--font-display); font-size: 16px; }
214 +.modal-head button { background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-ctl);
215 + width: 32px; height: 32px; font-size: 14px; cursor: pointer; }
216 +.modal-head button:hover { background: var(--lime); }
217 +.modal-body { padding: 16px 18px; overflow-y: auto; font: 12.5px/1.6 var(--font-mono); display: grid; gap: 8px; }
218 +.modal-body .meta { background: var(--surface-2); border: 1px solid var(--line); border-radius: var(--r-ctl);
219 + padding: 10px 12px; color: var(--ink-2); white-space: pre-wrap; overflow-wrap: anywhere; }
220 +.modal-body .meta b { color: var(--ink); }
added runner/runner.py +307 −0
@@ -0,0 +1,307 @@
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 +import uuid
26 +from typing import Any
27 +
28 +import httpx
29 +from fastapi import FastAPI, Header, HTTPException, Request
30 +from pydantic import BaseModel
31 +
32 +HOME = pathlib.Path.home()
33 +ENV_FILE = HOME / ".ka-guardian.env"
34 +CLAUDE_ENV_FILE = HOME / ".claude" / ".env"
35 +TRANSCRIPTS = HOME / "ka-guardian-runner" / "transcripts"
36 +TRANSCRIPTS.mkdir(parents=True, exist_ok=True)
37 +CLAUDE_BIN = "/opt/homebrew/bin/claude"
38 +
39 +
40 +def load_env_file(path: pathlib.Path) -> dict[str, str]:
41 + out: dict[str, str] = {}
42 + if path.exists():
43 + for line in path.read_text().splitlines():
44 + line = line.strip()
45 + if line and not line.startswith("#") and "=" in line:
46 + k, v = line.split("=", 1)
47 + out[k.strip()] = v.strip().strip('"').strip("'")
48 + return out
49 +
50 +
51 +LOCAL_ENV = load_env_file(ENV_FILE)
52 +TOKEN = LOCAL_ENV.get("KA_GUARDIAN_TOKEN", "")
53 +NODE = LOCAL_ENV.get("KA_GUARDIAN_NODE", socket.gethostname())
54 +
55 +app = FastAPI(title="KA Guardian Runner", docs_url=None, redoc_url=None)
56 +
57 +_lock = threading.Lock()
58 +_current: dict[str, Any] = {} # mission en cours (métadonnées)
59 +
60 +
61 +def check_token(x_ka_token: str | None) -> None:
62 + if not TOKEN or x_ka_token != TOKEN:
63 + raise HTTPException(status_code=401, detail="token invalide")
64 +
65 +
66 +def expand(dir_: str) -> str:
67 + return os.path.expanduser(dir_)
68 +
69 +
70 +def git(dir_: str, *args: str) -> subprocess.CompletedProcess:
71 + return subprocess.run(
72 + ["git", "-C", expand(dir_), *args],
73 + capture_output=True, text=True, timeout=120,
74 + )
75 +
76 +
77 +def sh(cmd: str, timeout: int = 120) -> subprocess.CompletedProcess:
78 + env = {**os.environ, "PATH": "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"}
79 + return subprocess.run(["/bin/zsh", "-c", cmd], capture_output=True, text=True, timeout=timeout, env=env)
80 +
81 +
82 +def healthcheck(web_port: int, pm2_names: list[str]) -> dict[str, Any]:
83 + """Vérifie que l'app répond en HTTP et que ses process pm2 sont online."""
84 + http_ok = False
85 + try:
86 + r = httpx.get(f"http://127.0.0.1:{web_port}/", timeout=10, follow_redirects=True)
87 + http_ok = r.status_code < 500
88 + except Exception:
89 + http_ok = False
90 + pm2_status: dict[str, str] = {}
91 + try:
92 + out = sh("pm2 jlist").stdout
93 + procs = {p["name"]: p.get("pm2_env", {}).get("status", "?") for p in json.loads(out)}
94 + for name in pm2_names:
95 + pm2_status[name] = procs.get(name, "absent")
96 + except Exception as exc: # pm2 absent ou jlist illisible
97 + pm2_status = {n: f"inconnu ({exc})" for n in pm2_names}
98 + ok = http_ok and all(s in ("online", "launching") for s in pm2_status.values())
99 + return {"ok": ok, "http_ok": http_ok, "pm2": pm2_status}
100 +
101 +
102 +class MissionIn(BaseModel):
103 + mission_id: str
104 + agent: str
105 + service: str
106 + source: str
107 + dir: str
108 + pm2: list[str]
109 + web_port: int
110 + model: str = "sonnet"
111 + max_turns: int = 70
112 + timeout_seconds: int = 3600
113 + prompt: str
114 + callback_url: str # http://<orchestrateur>/api/ingest/<mission_id>
115 +
116 +
117 +SPOOL = HOME / "ka-guardian-spool"
118 +
119 +
120 +def post_event(url: str, payload: dict[str, Any]) -> None:
121 + """Dépose l'événement dans le spool du courrier zsh (deploy/courier.sh).
122 +
123 + macOS 26 « Local Network Privacy » refuse le trafic LAN dès que python
124 + (homebrew) est dans la chaîne de processus — même ses enfants ssh/curl.
125 + Le courrier (launchd 100 % zsh) expédie via ssh → curl localhost.
126 + """
127 + try:
128 + from urllib.parse import urlsplit
129 + u = urlsplit(url)
130 + for d in ("outbox", "done", "tmp"):
131 + (SPOOL / d).mkdir(parents=True, exist_ok=True)
132 + # id préfixé du temps ns: le courrier traite les jobs en ordre lexical,
133 + # les événements arrivent donc dans l'ordre d'émission.
134 + jid = f"{time.time_ns()}-{uuid.uuid4().hex[:8]}"
135 + tmp = SPOOL / "tmp" / f"{jid}.job"
136 + tmp.write_text(f"{u.hostname} {u.port or 80} {u.path} 15\n"
137 + + json.dumps(payload, ensure_ascii=False))
138 + tmp.rename(SPOOL / "outbox" / f"{jid}.job")
139 + except Exception:
140 + pass # l'orchestrateur relira le transcript au besoin
141 +
142 +
143 +def condense_stream_line(obj: dict[str, Any]) -> list[dict[str, Any]]:
144 + """Transforme une ligne stream-json de claude en événements courts pour le feed."""
145 + events: list[dict[str, Any]] = []
146 + t = obj.get("type")
147 + if t == "system" and obj.get("subtype") == "init":
148 + events.append({"type": "init", "data": {"model": obj.get("model", "?")}})
149 + elif t == "assistant":
150 + for block in obj.get("message", {}).get("content", []):
151 + if block.get("type") == "text" and block.get("text", "").strip():
152 + events.append({"type": "text", "data": {"text": block["text"][:1500]}})
153 + elif block.get("type") == "tool_use":
154 + inp = json.dumps(block.get("input", {}), ensure_ascii=False)
155 + events.append({"type": "tool", "data": {"name": block.get("name", "?"), "input": inp[:400]}})
156 + elif t == "result":
157 + events.append({"type": "result", "data": {
158 + "subtype": obj.get("subtype"),
159 + "result": (obj.get("result") or "")[:4000],
160 + "cost_usd": obj.get("total_cost_usd"),
161 + "num_turns": obj.get("num_turns"),
162 + "duration_ms": obj.get("duration_ms"),
163 + }})
164 + return events
165 +
166 +
167 +def extract_verdict(result_text: str) -> dict[str, Any]:
168 + """Extrait le dernier bloc JSON {verdict: ...} de la réponse finale."""
169 + import re
170 + for m in reversed(re.findall(r"\{[^{}]*\"verdict\"[\s\S]*?\}", result_text)):
171 + try:
172 + v = json.loads(m)
173 + if "verdict" in v:
174 + return v
175 + except Exception:
176 + continue
177 + return {"verdict": "inconnu", "diagnostic": result_text[-500:] if result_text else ""}
178 +
179 +
180 +def run_mission(m: MissionIn) -> None:
181 + global _current
182 + workdir = expand(m.dir)
183 + transcript = TRANSCRIPTS / f"{m.mission_id}.jsonl"
184 + base_commit = ""
185 + try:
186 + # Snapshot pré-mission : tree sale → commit de sûreté pour un rollback net.
187 + if git(m.dir, "status", "--porcelain").stdout.strip():
188 + git(m.dir, "add", "-A")
189 + git(m.dir, "commit", "-m", f"[{m.agent}] snapshot pré-mission {m.source}")
190 + base_commit = git(m.dir, "rev-parse", "HEAD").stdout.strip()
191 + post_event(m.callback_url, {"type": "start", "data": {"base_commit": base_commit, "node": NODE}})
192 +
193 + env = {
194 + **os.environ,
195 + **load_env_file(CLAUDE_ENV_FILE),
196 + "PATH": "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin",
197 + "HOME": str(HOME),
198 + }
199 + cmd = [
200 + CLAUDE_BIN, "-p", m.prompt,
201 + "--output-format", "stream-json", "--verbose",
202 + "--model", m.model,
203 + "--max-turns", str(m.max_turns),
204 + "--dangerously-skip-permissions",
205 + ]
206 + proc = subprocess.Popen(
207 + cmd, cwd=workdir, env=env,
208 + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, bufsize=1,
209 + )
210 + _current["pid"] = proc.pid
211 + result_text, cost, turns = "", None, None
212 + deadline = time.time() + m.timeout_seconds
213 + with transcript.open("w") as tf:
214 + for line in proc.stdout: # type: ignore[union-attr]
215 + tf.write(line)
216 + if time.time() > deadline:
217 + proc.kill()
218 + post_event(m.callback_url, {"type": "error", "data": {"error": "timeout mission"}})
219 + break
220 + line = line.strip()
221 + if not line:
222 + continue
223 + try:
224 + obj = json.loads(line)
225 + except Exception:
226 + continue
227 + for ev in condense_stream_line(obj):
228 + if ev["type"] == "result":
229 + result_text = ev["data"].get("result", "")
230 + cost = ev["data"].get("cost_usd")
231 + turns = ev["data"].get("num_turns")
232 + post_event(m.callback_url, ev)
233 + proc.wait(timeout=60)
234 +
235 + commits_raw = git(m.dir, "rev-list", "--oneline", f"{base_commit}..HEAD").stdout.strip()
236 + commits = commits_raw.splitlines() if commits_raw else []
237 + verdict = extract_verdict(result_text)
238 + health = healthcheck(m.web_port, m.pm2)
239 + post_event(m.callback_url, {"type": "final", "data": {
240 + "verdict": verdict, "commits": commits, "base_commit": base_commit,
241 + "cost_usd": cost, "num_turns": turns, "health": health,
242 + "exit_code": proc.returncode,
243 + }})
244 + except Exception as exc:
245 + post_event(m.callback_url, {"type": "error", "data": {"error": str(exc), "base_commit": base_commit}})
246 + finally:
247 + with _lock:
248 + _current.clear()
249 +
250 +
251 +@app.get("/health")
252 +def health() -> dict[str, Any]:
253 + return {"ok": True, "node": NODE, "busy": bool(_current), "current": _current.get("mission_id")}
254 +
255 +
256 +@app.post("/missions")
257 +def missions(m: MissionIn, x_ka_token: str | None = Header(default=None)) -> dict[str, Any]:
258 + check_token(x_ka_token)
259 + workdir = expand(m.dir)
260 + if not os.path.isdir(workdir):
261 + raise HTTPException(status_code=400, detail=f"dir introuvable: {workdir}")
262 + with _lock:
263 + if _current:
264 + raise HTTPException(status_code=409, detail=f"mission déjà en cours: {_current.get('mission_id')}")
265 + _current.update({"mission_id": m.mission_id, "service": m.service, "source": m.source, "started": time.time()})
266 + threading.Thread(target=run_mission, args=(m,), daemon=True).start()
267 + return {"accepted": True, "node": NODE}
268 +
269 +
270 +class RollbackIn(BaseModel):
271 + dir: str
272 + base_commit: str
273 + pm2: list[str]
274 + web_port: int
275 +
276 +
277 +@app.post("/rollback")
278 +def rollback(r: RollbackIn, x_ka_token: str | None = Header(default=None)) -> dict[str, Any]:
279 + """Revient au commit d'avant mission (reset --hard si tous les commits sont de l'agent, sinon revert)."""
280 + check_token(x_ka_token)
281 + log = git(r.dir, "log", "--format=%s", f"{r.base_commit}..HEAD").stdout.strip()
282 + msgs = log.splitlines() if log else []
283 + if not msgs:
284 + mode = "aucun_commit"
285 + elif all(s.startswith("[ka") for s in msgs):
286 + git(r.dir, "reset", "--hard", r.base_commit)
287 + mode = "reset_hard"
288 + else:
289 + rc = git(r.dir, "revert", "--no-edit", f"{r.base_commit}..HEAD")
290 + if rc.returncode != 0:
291 + git(r.dir, "revert", "--abort")
292 + git(r.dir, "reset", "--hard", r.base_commit)
293 + mode = "reset_hard(fallback)"
294 + else:
295 + mode = "revert"
296 + # Pas de git clean : les process de sync de l'app écrivent en continu,
297 + # on ne supprime jamais de fichiers non trackés apparus pendant la mission.
298 + for name in r.pm2:
299 + sh(f"pm2 restart {shlex.quote(name)} --update-env", timeout=180)
300 + time.sleep(8)
301 + h = healthcheck(r.web_port, r.pm2)
302 + return {"ok": True, "mode": mode, "health": h, "head": git(r.dir, "rev-parse", "HEAD").stdout.strip()}
303 +
304 +
305 +if __name__ == "__main__":
306 + import uvicorn
307 + uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("KA_GUARDIAN_RUNNER_PORT", "7791")))
added topology.json +64 −0
@@ -0,0 +1,64 @@
1 +{
2 + "comment": "KA Guardian — topologie des agents, services et nœuds. Source de vérité partagée orchestrateurs/runners.",
3 + "apika_monitoring_url": "https://www.api-ka.com/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 + "effort_max_turns": 150,
62 + "effort_timeout_seconds": 7200
63 + }
64 +}
65