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%

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

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

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

35 changed files +2,915 −385

added M4M36luster-projects/ka-guardian/.gitignore +4 −0
@@ -0,0 +1,4 @@
1 +__pycache__/
2 +data/
3 +.venv/
4 +*.log
added M4M36luster-projects/ka-guardian/M4M36luster-projects/ka-guardian/M4M36luster-projects/ka-guardian/M4M36luster-projects/ka-guardian/README.md +72 −0
@@ -0,0 +1,72 @@
1 +# KA Guardian — ka2 · ka4 · ka6
2 +
3 +Les trois agents gardiens autonomes du Groupe KA. Depuis le 2026-08-23, ka2/ka4/ka6
4 +ne sont plus des bots de cartographie web : ce sont des **agents de maintenance
5 +autonomes des connecteurs** des plateformes ·Ka, bâtis sur Claude (CLI headless).
6 +
7 +## Ce qu'ils font
8 +
9 +1. **Détection** — toutes les 5 min, chaque agent lit la supervision centralisée
10 + d'api-ka (`/api/v1/monitoring/connectors`, ~2 600 connecteurs classés
11 + ok/degraded/broken/stale toutes les 2 h). Un connecteur `broken` ou `stale`
12 + de son périmètre → incident.
13 +2. **Mission** — l'agent dépêche une mission au **runner** du nœud qui héberge
14 + l'app : `claude -p` headless, lancé dans le repo de l'app, avec un prompt de
15 + mission strict (diagnostiquer, reproduire, corriger minimalement, re-tester,
16 + `pm2 restart` du process de sync, commit `[kaX] fix connecteur …`, jamais de push).
17 +3. **Surveillance** — après réparation déclarée, l'incident passe en `watching`
18 + pendant 8 h. Connecteur de retour à `ok` → résolu.
19 +4. **Rollback automatique** — si l'app tombe (healthcheck) ou si le connecteur
20 + est toujours cassé après la fenêtre : retour au commit d'avant mission
21 + (`git reset --hard`/revert) + `pm2 restart`. 3 tentatives max, puis `abandoned`
22 + (intervention humaine).
23 +5. **Vitrine live** — chaque site (www.ka2.bot / www.ka4.bot / www.ka6.bot) est
24 + une salle de contrôle : flux SSE en direct de chaque action de l'agent
25 + (outils, réflexions, verdicts), board d'incidents, couverture par service,
26 + historique des missions avec transcript et coût.
27 +
28 +## Répartition
29 +
30 +| Agent | Domaine | Services surveillés |
31 +|---|---|---|
32 +| ka2 | www.ka2.bot :8799 | louka, immoka, restoka |
33 +| ka4 | www.ka4.bot :8899 | autoka, foodka, sortika |
34 +| ka6 | www.ka6.bot :8999 | fabrika, jobka, creaka + tout nouveau service (défaut) |
35 +
36 +## Architecture
37 +
38 +- `orchestrator/` — un process FastAPI par agent (M4M36, launchd
39 + `com.kaX.guardian`), SQLite `data/kaX.db`, dashboard servi sur le même port,
40 + tunnels ngrok existants conservés.
41 +- `runner/` — un service par nœud d'app (M3U96a/b, M4M64a/b, port 7791, launchd
42 + `com.ka.guardian-runner`), 1 mission à la fois par nœud, transcripts dans
43 + `~/ka-guardian-runner/transcripts/`. Exécute `claude -p --output-format
44 + stream-json` et relaie chaque événement à l'orchestrateur (`/api/ingest`).
45 +- `topology.json` — source de vérité : agents, services (nœud/dir/pm2/port),
46 + IP LAN, politique (fenêtres, cooldowns, modèle).
47 +- Auth interne : token partagé `~/.ka-guardian.env` (orchestrateurs + runners),
48 + header `X-KA-Token`. Clé Anthropic + clés anti-bot : `~/.claude/.env` des nœuds.
49 +- Communication **par le LAN 192.168.2.x** (SSH/Tailscale inter-nœuds bloqué).
50 +
51 +## Déploiement
52 +
53 +```bash
54 +deploy/deploy.sh all # runners + orchestrateurs
55 +deploy/deploy.sh runners # seulement les 4 runners
56 +deploy/deploy.sh orchestrators
57 +```
58 +
59 +## Admin (token requis)
60 +
61 +```bash
62 +# mission manuelle sur un connecteur
63 +curl -X POST http://M4M36.maclustr.io:8799/api/admin/mission \
64 + -H "X-KA-Token: $TOKEN" -d '{"service":"louka","source":"kijiji"}'
65 +# pause / reprise d'un agent
66 +curl -X POST http://M4M36.maclustr.io:8799/api/admin/pause -H "X-KA-Token: $TOKEN" -d '{"paused":true}'
67 +```
68 +
69 +## Historique
70 +
71 +Les anciens bots (cartographie du web québécois / influenceurs) sont archivés :
72 +`~/ka-bots-backup-20260823.tar.gz` sur M4M36 (code + bases SQLite complètes).
added M4M36luster-projects/ka-guardian/M4M36luster-projects/ka-guardian/M4M36luster-projects/ka-guardian/M4M36luster-projects/ka-guardian/deploy/deploy.sh +105 −0
@@ -0,0 +1,105 @@
1 +#!/bin/zsh
2 +# ============================================
3 +# KA Guardian — déploiement complet depuis le laptop
4 +# ./deploy.sh runners → runners sur M3U96a M3U96b M4M64a M4M64b
5 +# ./deploy.sh orchestrators → ka2/ka4/ka6 sur M4M36 (remplace les anciens bots)
6 +# ./deploy.sh all
7 +# Idempotent. Le token partagé vit dans ~/.ka-guardian.env (laptop) et est
8 +# poussé sur chaque nœud. Les tunnels ngrok existants (www.kaX.bot) sont gardés.
9 +# ============================================
10 +set -e
11 +ROOT="$(cd "$(dirname "$0")/.." && pwd)"
12 +RUNNER_NODES=(M3U96a M3U96b M4M64a M4M64b)
13 +ORCH_NODE=M4M36
14 +PY=/opt/homebrew/bin/python3
15 +
16 +# --- token partagé
17 +TOKEN_FILE="$HOME/.ka-guardian.env"
18 +if [[ ! -f $TOKEN_FILE ]]; then
19 + echo "KA_GUARDIAN_TOKEN=$(openssl rand -hex 24)" > "$TOKEN_FILE"
20 + echo "token généré → $TOKEN_FILE"
21 +fi
22 +TOKEN_LINE=$(grep KA_GUARDIAN_TOKEN "$TOKEN_FILE")
23 +
24 +deploy_runners() {
25 + for n in $RUNNER_NODES; do
26 + echo "=== runner → $n"
27 + ssh $n "mkdir -p ~/ka-guardian-runner/transcripts"
28 + scp -q "$ROOT/runner/runner.py" $n:ka-guardian-runner/runner.py
29 + ssh $n "printf '%s\nKA_GUARDIAN_NODE=%s\n' '$TOKEN_LINE' '$n' > ~/.ka-guardian.env
30 + cd ~/ka-guardian-runner
31 + [[ -d .venv ]] || $PY -m venv .venv
32 + ./.venv/bin/pip -q install 'fastapi>=0.110' 'uvicorn>=0.29' 'httpx>=0.27' 'pydantic>=2' >/dev/null
33 + cat > ~/Library/LaunchAgents/com.ka.guardian-runner.plist <<'PLIST'
34 +<?xml version=\"1.0\" encoding=\"UTF-8\"?>
35 +<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">
36 +<plist version=\"1.0\"><dict>
37 + <key>Label</key><string>com.ka.guardian-runner</string>
38 + <key>ProgramArguments</key><array>
39 + <string>/Users/simon-pierreboucher/ka-guardian-runner/.venv/bin/python</string>
40 + <string>/Users/simon-pierreboucher/ka-guardian-runner/runner.py</string>
41 + </array>
42 + <key>EnvironmentVariables</key><dict>
43 + <key>PATH</key><string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
44 + </dict>
45 + <key>RunAtLoad</key><true/><key>KeepAlive</key><true/>
46 + <key>StandardOutPath</key><string>/Users/simon-pierreboucher/ka-guardian-runner/runner.log</string>
47 + <key>StandardErrorPath</key><string>/Users/simon-pierreboucher/ka-guardian-runner/runner.log</string>
48 +</dict></plist>
49 +PLIST
50 + launchctl unload ~/Library/LaunchAgents/com.ka.guardian-runner.plist 2>/dev/null || true
51 + launchctl load ~/Library/LaunchAgents/com.ka.guardian-runner.plist"
52 + sleep 2
53 + ssh $n "curl -sf localhost:7791/health" && echo " ✓ $n runner ok" || echo " ✗ $n runner KO"
54 + done
55 +}
56 +
57 +deploy_orchestrators() {
58 + echo "=== orchestrateurs → $ORCH_NODE"
59 + ssh $ORCH_NODE "printf '%s\n' '$TOKEN_LINE' > ~/.ka-guardian.env; mkdir -p ~/cluster-projects/ka-guardian"
60 + rsync -az --delete --exclude data --exclude .venv --exclude .git \
61 + "$ROOT/" $ORCH_NODE:cluster-projects/ka-guardian/
62 + ssh $ORCH_NODE "cd ~/cluster-projects/ka-guardian
63 + [[ -d .venv ]] || $PY -m venv .venv
64 + ./.venv/bin/pip -q install 'fastapi>=0.110' 'uvicorn>=0.29' 'httpx>=0.27' >/dev/null
65 + mkdir -p data logs
66 + # retirer les anciens bots (web+bot), garder les tunnels ngrok
67 + for a in ka2 ka4 ka6; do
68 + launchctl unload ~/Library/LaunchAgents/com.\$a.web.plist 2>/dev/null || true
69 + launchctl unload ~/Library/LaunchAgents/com.\$a.bot.plist 2>/dev/null || true
70 + rm -f ~/Library/LaunchAgents/com.\$a.web.plist ~/Library/LaunchAgents/com.\$a.bot.plist
71 + done"
72 + for a in ka2:8799 ka4:8899 ka6:8999; do
73 + agent=${a%%:*}; port=${a##*:}
74 + ssh $ORCH_NODE "cat > ~/Library/LaunchAgents/com.$agent.guardian.plist <<PLIST
75 +<?xml version=\"1.0\" encoding=\"UTF-8\"?>
76 +<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">
77 +<plist version=\"1.0\"><dict>
78 + <key>Label</key><string>com.$agent.guardian</string>
79 + <key>ProgramArguments</key><array>
80 + <string>/Users/simon-pierreboucher/cluster-projects/ka-guardian/.venv/bin/python</string>
81 + <string>/Users/simon-pierreboucher/cluster-projects/ka-guardian/orchestrator/main.py</string>
82 + </array>
83 + <key>EnvironmentVariables</key><dict>
84 + <key>AGENT</key><string>$agent</string>
85 + <key>KA_GUARDIAN_SELF_IP</key><string>192.168.2.69</string>
86 + <key>PATH</key><string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
87 + </dict>
88 + <key>RunAtLoad</key><true/><key>KeepAlive</key><true/>
89 + <key>StandardOutPath</key><string>/Users/simon-pierreboucher/cluster-projects/ka-guardian/logs/$agent.log</string>
90 + <key>StandardErrorPath</key><string>/Users/simon-pierreboucher/cluster-projects/ka-guardian/logs/$agent.log</string>
91 +</dict></plist>
92 +PLIST
93 + launchctl unload ~/Library/LaunchAgents/com.$agent.guardian.plist 2>/dev/null || true
94 + launchctl load ~/Library/LaunchAgents/com.$agent.guardian.plist"
95 + sleep 2
96 + ssh $ORCH_NODE "curl -sf localhost:$port/health" && echo " ✓ $agent ok (:$port)" || echo " ✗ $agent KO"
97 + done
98 +}
99 +
100 +case "${1:-all}" in
101 + runners) deploy_runners ;;
102 + orchestrators) deploy_orchestrators ;;
103 + all) deploy_runners; deploy_orchestrators ;;
104 +esac
105 +echo "terminé."
added M4M36luster-projects/ka-guardian/M4M36luster-projects/ka-guardian/M4M36luster-projects/ka-guardian/M4M36luster-projects/ka-guardian/orchestrator/main.py +560 −0
@@ -0,0 +1,560 @@
1 +# ============================================
2 +# Projet : KA Guardian
3 +# Fichier : orchestrator/main.py
4 +# Rôle : Cerveau d'un agent gardien (ka2/ka4/ka6) — surveille la santé
5 +# des connecteurs via api-ka, ouvre des incidents, dépêche des
6 +# missions claude aux runners des nœuds, surveille la guérison,
7 +# rollback automatique si ça empire. Sert aussi le dashboard live.
8 +# Author : Simon-Pierre Boucher
9 +# Date : 2026-08-23
10 +# ============================================
11 +"""Instance unique par agent: AGENT=ka2|ka4|ka6 (env), port depuis topology.json."""
12 +from __future__ import annotations
13 +
14 +import asyncio
15 +import json
16 +import os
17 +import pathlib
18 +import sqlite3
19 +import time
20 +import uuid
21 +from typing import Any
22 +
23 +import httpx
24 +from fastapi import FastAPI, Header, HTTPException, Request
25 +from fastapi.responses import FileResponse, StreamingResponse
26 +from fastapi.staticfiles import StaticFiles
27 +
28 +BASE = pathlib.Path(__file__).resolve().parent
29 +ROOT = BASE.parent
30 +HOME = pathlib.Path.home()
31 +
32 +AGENT = os.environ.get("AGENT", "ka2")
33 +TOPO = json.loads((ROOT / "topology.json").read_text())
34 +ME = TOPO["agents"][AGENT]
35 +POLICY = TOPO["policy"]
36 +SERVICES: dict[str, Any] = TOPO["services"]
37 +NODES: dict[str, Any] = TOPO["nodes"]
38 +MY_SERVICES: set[str] = set(ME["services"])
39 +RUNNER_PORT = TOPO["runner_port"]
40 +
41 +# ka6 (ou autre) ramasse les services non assignés qui apparaîtraient dans api-ka.
42 +DEFAULT_AGENT = ME.get("default_for_unknown_services", False)
43 +ASSIGNED = {s for a in TOPO["agents"].values() for s in a["services"]}
44 +
45 +
46 +def load_env_file(path: pathlib.Path) -> dict[str, str]:
47 + out: dict[str, str] = {}
48 + if path.exists():
49 + for line in path.read_text().splitlines():
50 + line = line.strip()
51 + if line and not line.startswith("#") and "=" in line:
52 + k, v = line.split("=", 1)
53 + out[k.strip()] = v.strip().strip('"').strip("'")
54 + return out
55 +
56 +
57 +TOKEN = load_env_file(HOME / ".ka-guardian.env").get("KA_GUARDIAN_TOKEN", "")
58 +DATA = ROOT / "data"
59 +DATA.mkdir(exist_ok=True)
60 +DB_PATH = DATA / f"{AGENT}.db"
61 +
62 +# ---------------------------------------------------------------- SQLite ---
63 +
64 +def db() -> sqlite3.Connection:
65 + conn = sqlite3.connect(DB_PATH)
66 + conn.row_factory = sqlite3.Row
67 + conn.execute("PRAGMA journal_mode=WAL")
68 + return conn
69 +
70 +
71 +def init_db() -> None:
72 + with db() as c:
73 + c.executescript("""
74 + CREATE TABLE IF NOT EXISTS incidents(
75 + id TEXT PRIMARY KEY, service TEXT, source TEXT, status_detected TEXT,
76 + state TEXT, attempts INTEGER DEFAULT 0, created REAL, updated REAL,
77 + resolved REAL, detail TEXT);
78 + CREATE TABLE IF NOT EXISTS missions(
79 + id TEXT PRIMARY KEY, incident_id TEXT, service TEXT, source TEXT,
80 + node TEXT, state TEXT, base_commit TEXT, commits TEXT, verdict TEXT,
81 + cost_usd REAL, num_turns INTEGER, health TEXT, started REAL, ended REAL);
82 + CREATE TABLE IF NOT EXISTS events(
83 + id INTEGER PRIMARY KEY AUTOINCREMENT, mission_id TEXT, ts REAL,
84 + type TEXT, data TEXT);
85 + CREATE TABLE IF NOT EXISTS snapshots(
86 + ts REAL PRIMARY KEY, mine TEXT, ecosystem TEXT);
87 + CREATE INDEX IF NOT EXISTS ev_mission ON events(mission_id);
88 + """)
89 +
90 +
91 +def now() -> float:
92 + return time.time()
93 +
94 +
95 +def jdump(x: Any) -> str:
96 + return json.dumps(x, ensure_ascii=False)
97 +
98 +
99 +# ------------------------------------------------------------------- SSE ---
100 +
101 +class Hub:
102 + def __init__(self) -> None:
103 + self.clients: set[asyncio.Queue] = set()
104 +
105 + async def publish(self, msg: dict[str, Any]) -> None:
106 + for q in list(self.clients):
107 + if q.qsize() < 500:
108 + q.put_nowait(msg)
109 +
110 + def publish_sync(self, msg: dict[str, Any]) -> None:
111 + if LOOP:
112 + asyncio.run_coroutine_threadsafe(self.publish(msg), LOOP)
113 +
114 +
115 +hub = Hub()
116 +LOOP: asyncio.AbstractEventLoop | None = None
117 +LATEST: dict[str, Any] = {"mine": {}, "ecosystem": {}, "polled": 0, "paused": False}
118 +
119 +# ------------------------------------------------------------- missions ----
120 +
121 +def active_incident(c: sqlite3.Connection, service: str, source: str) -> sqlite3.Row | None:
122 + return c.execute(
123 + "SELECT * FROM incidents WHERE service=? AND source=? AND state IN "
124 + "('open','dispatched','fixing','watching','cooldown') ORDER BY created DESC LIMIT 1",
125 + (service, source)).fetchone()
126 +
127 +
128 +def set_incident(c: sqlite3.Connection, iid: str, **kw: Any) -> None:
129 + kw["updated"] = now()
130 + keys = ",".join(f"{k}=?" for k in kw)
131 + c.execute(f"UPDATE incidents SET {keys} WHERE id=?", (*kw.values(), iid))
132 +
133 +
134 +def incident_event(iid: str, service: str, source: str, state: str, note: str = "") -> None:
135 + hub.publish_sync({"kind": "incident", "incident_id": iid, "service": service,
136 + "source": source, "state": state, "note": note, "ts": now()})
137 +
138 +
139 +def build_prompt(service: str, source: str, health: dict[str, Any]) -> str:
140 + svc = SERVICES[service]
141 + sync_proc = next((p for p in svc["pm2"] if "sync" in p or "etl" in p), svc["pm2"][-1])
142 + node_alias = NODES[svc["node"]]["alias"]
143 + return f"""Tu es {AGENT}, agent autonome de maintenance des connecteurs du Groupe KA. Mission: réparer le connecteur « {source} » de l'app {svc['app']} (service {service}).
144 +
145 +CONTEXTE SANTÉ (supervision api-ka):
146 +- statut détecté: {health.get('status')} | échecs consécutifs: {health.get('consecutive_failures')}
147 +- dernier succès: {health.get('last_success')} | volume dernier sync: {health.get('found_last')} (médiane: {health.get('median_found')})
148 +- message: {health.get('message')}
149 +
150 +TU ES SUR LE NŒUD {node_alias}, ton répertoire courant est le repo de l'app: {svc['dir']}.
151 +L'app tourne via pm2 ({', '.join(svc['pm2'])}), site web local sur le port {svc['web_port']}.
152 +
153 +DÉMARCHE IMPOSÉE:
154 +1. Localise le code du connecteur « {source} » (grep dans le repo). Lis ses logs récents (dossier logs/ du repo, `pm2 logs {sync_proc} --nostream --lines 200`).
155 +2. Reproduis le problème: exécute le connecteur ou son fetch directement (utilise le venv/node_modules du repo, jamais d'installation globale).
156 +3. Diagnostique la cause (HTML/sélecteurs changés? 403/429 anti-bot? API modifiée? URL morte? timeout?) et corrige de façon MINIMALE et ciblée. Si le site bloque, les clés anti-bot (SCRAPFLY, BRIGHTDATA, OXYLABS, SERPER) sont dans ~/.claude/.env — respecte la stratégie d'escalade déjà utilisée dans le repo.
157 +4. Re-teste le connecteur réellement: il doit rapporter un volume plausible (ordre de la médiane). INTERDIT d'inventer ou de stubber des données.
158 +5. Redémarre UNIQUEMENT le process concerné: `pm2 restart {sync_proc}`. Vérifie ensuite que le site répond: `curl -s -o /dev/null -w '%{{http_code}}' http://localhost:{svc['web_port']}/`.
159 +6. Committe ton travail: `git add -A && git commit -m "[{AGENT}] fix connecteur {source}: <résumé court>"`. NE PUSH JAMAIS.
160 +
161 +INTERDITS ABSOLUS: toucher aux autres connecteurs ou aux autres apps du nœud, modifier la config pm2/ngrok/launchd, installer des paquets globaux, supprimer des données, git push, toucher à ~/.ssh ou aux clés API. Si la source est définitivement morte (site fermé, 404 permanent), ne force rien: documente et conclus.
162 +
163 +FIN DE MISSION — termine ta toute dernière réponse par un bloc JSON exactement de cette forme:
164 +{{"verdict": "repare|echec|site_source_mort|rien_a_faire", "diagnostic": "cause racine en 1-2 phrases", "actions": "ce que tu as fait", "test": "résultat du test final (volume obtenu)", "fichiers": ["fichiers modifiés"]}}"""
165 +
166 +
167 +async def dispatch(incident: sqlite3.Row, health: dict[str, Any]) -> None:
168 + service, source, iid = incident["service"], incident["source"], incident["id"]
169 + svc = SERVICES[service]
170 + node = svc["node"]
171 + ip = NODES[node]["lan_ip"]
172 + mid = uuid.uuid4().hex[:12]
173 + my_ip = os.environ.get("KA_GUARDIAN_SELF_IP", "192.168.2.69")
174 + payload = {
175 + "mission_id": mid, "agent": AGENT, "service": service, "source": source,
176 + "dir": svc["dir"], "pm2": svc["pm2"], "web_port": svc["web_port"],
177 + "model": ME.get("model", "sonnet"),
178 + "max_turns": POLICY["mission_max_turns"],
179 + "timeout_seconds": POLICY["mission_timeout_seconds"],
180 + "prompt": build_prompt(service, source, health),
181 + "callback_url": f"http://{my_ip}:{ME['port']}/api/ingest/{mid}",
182 + }
183 + try:
184 + async with httpx.AsyncClient() as cl:
185 + r = await cl.post(f"http://{ip}:{RUNNER_PORT}/missions", json=payload,
186 + headers={"X-KA-Token": TOKEN}, timeout=20)
187 + if r.status_code == 409:
188 + return # runner occupé, on retentera au prochain tick
189 + r.raise_for_status()
190 + except Exception as exc:
191 + with db() as c:
192 + set_incident(c, iid, detail=jdump({"erreur_dispatch": str(exc)}))
193 + return
194 + with db() as c:
195 + c.execute("INSERT INTO missions(id,incident_id,service,source,node,state,started) VALUES(?,?,?,?,?,?,?)",
196 + (mid, iid, service, source, node, "running", now()))
197 + set_incident(c, iid, state="fixing", attempts=incident["attempts"] + 1)
198 + incident_event(iid, service, source, "fixing", f"mission {mid} dépêchée sur {NODES[node]['alias']}")
199 + hub.publish_sync({"kind": "mission", "mission_id": mid, "service": service,
200 + "source": source, "state": "running", "ts": now()})
201 +
202 +
203 +async def rollback_mission(mission: sqlite3.Row, reason: str) -> dict[str, Any]:
204 + svc = SERVICES[mission["service"]]
205 + ip = NODES[mission["node"]]["lan_ip"]
206 + try:
207 + async with httpx.AsyncClient() as cl:
208 + r = await cl.post(f"http://{ip}:{RUNNER_PORT}/rollback",
209 + json={"dir": svc["dir"], "base_commit": mission["base_commit"],
210 + "pm2": svc["pm2"], "web_port": svc["web_port"]},
211 + headers={"X-KA-Token": TOKEN}, timeout=300)
212 + out = r.json()
213 + except Exception as exc:
214 + out = {"ok": False, "erreur": str(exc)}
215 + hub.publish_sync({"kind": "rollback", "mission_id": mission["id"], "service": mission["service"],
216 + "source": mission["source"], "reason": reason, "result": out, "ts": now()})
217 + with db() as c:
218 + c.execute("INSERT INTO events(mission_id,ts,type,data) VALUES(?,?,?,?)",
219 + (mission["id"], now(), "rollback", jdump({"reason": reason, "result": out})))
220 + return out
221 +
222 +
223 +# --------------------------------------------------------------- moteur ----
224 +
225 +async def poll_once() -> None:
226 + async with httpx.AsyncClient() as cl:
227 + r = await cl.get(TOPO["apika_monitoring_url"], timeout=15)
228 + data = r.json()["data"]
229 + mine: dict[str, Any] = {}
230 + for service, block in data["services"].items():
231 + assigned_to_me = service in MY_SERVICES or (DEFAULT_AGENT and service not in ASSIGNED)
232 + if assigned_to_me:
233 + mine[service] = block
234 + LATEST.update({"mine": mine, "ecosystem": data["summary"], "polled": now()})
235 + with db() as c:
236 + summary_mine = {s: b["summary"] for s, b in mine.items()}
237 + c.execute("INSERT OR REPLACE INTO snapshots(ts,mine,ecosystem) VALUES(?,?,?)",
238 + (now(), jdump(summary_mine), jdump(data["summary"])))
239 + c.execute("DELETE FROM snapshots WHERE ts < ?", (now() - 30 * 86400,))
240 + hub.publish_sync({"kind": "snapshot", "mine": {s: b["summary"] for s, b in mine.items()},
241 + "ecosystem": data["summary"], "ts": now()})
242 +
243 + trigger = set(POLICY["trigger_statuses"])
244 + with db() as c:
245 + for service, block in mine.items():
246 + if service not in SERVICES:
247 + continue # service inconnu de la topologie: visible au dashboard, pas d'action
248 + for conn in block.get("connectors", []):
249 + source, status = conn["source"], conn["status"]
250 + inc = active_incident(c, service, source)
251 + if status in trigger and inc is None:
252 + iid = uuid.uuid4().hex[:10]
253 + c.execute("INSERT INTO incidents(id,service,source,status_detected,state,created,updated,detail) "
254 + "VALUES(?,?,?,?,?,?,?,?)",
255 + (iid, service, source, status, "open", now(), now(), jdump(conn)))
256 + incident_event(iid, service, source, "open", f"détecté {status}")
257 + elif status in trigger and inc is not None and inc["state"] in ("open", "cooldown"):
258 + # Ne pas rafraîchir watching/fixing: `updated` sert de chrono
259 + # à la fenêtre de surveillance et au cooldown.
260 + c.execute("UPDATE incidents SET status_detected=?, detail=? WHERE id=?",
261 + (status, jdump(conn), inc["id"]))
262 + elif status == "ok" and inc is not None:
263 + if inc["state"] == "watching":
264 + set_incident(c, inc["id"], state="resolved", resolved=now())
265 + incident_event(inc["id"], service, source, "resolved", "connecteur de retour à ok — réparation confirmée")
266 + elif inc["state"] in ("open", "cooldown"):
267 + set_incident(c, inc["id"], state="self_healed", resolved=now())
268 + incident_event(inc["id"], service, source, "self_healed", "revenu à ok sans intervention")
269 +
270 +
271 +async def tick() -> None:
272 + if LATEST["paused"]:
273 + return
274 + try:
275 + await poll_once()
276 + except Exception as exc:
277 + hub.publish_sync({"kind": "log", "level": "warn", "msg": f"poll api-ka échoué: {exc}", "ts": now()})
278 +
279 + with db() as c:
280 + # 1. Missions zombies (runner mort / callback perdu)
281 + for m in c.execute("SELECT * FROM missions WHERE state='running' AND started < ?",
282 + (now() - POLICY["mission_timeout_seconds"] - 900,)).fetchall():
283 + c.execute("UPDATE missions SET state='error', ended=? WHERE id=?", (now(), m["id"]))
284 + inc = c.execute("SELECT * FROM incidents WHERE id=?", (m["incident_id"],)).fetchone()
285 + if inc:
286 + set_incident(c, inc["id"], state="cooldown")
287 + incident_event(inc["id"], m["service"], m["source"], "cooldown", "mission sans réponse (zombie)")
288 +
289 + # 2. Watching → rollback si la fenêtre est passée et toujours cassé
290 + for inc in c.execute("SELECT * FROM incidents WHERE state='watching'").fetchall():
291 + if now() - inc["updated"] < POLICY["watch_window_hours"] * 3600:
292 + continue
293 + status = current_status(inc["service"], inc["source"])
294 + m = c.execute("SELECT * FROM missions WHERE incident_id=? AND base_commit IS NOT NULL "
295 + "ORDER BY started DESC LIMIT 1", (inc["id"],)).fetchone()
296 + if status in POLICY["trigger_statuses"] or status == "degraded":
297 + if m and (json.loads(m["commits"] or "[]")):
298 + asyncio.create_task(rollback_mission(m, "toujours cassé après la fenêtre de surveillance"))
299 + set_incident(c, inc["id"], state="cooldown")
300 + incident_event(inc["id"], inc["service"], inc["source"], "cooldown",
301 + f"non guéri après {POLICY['watch_window_hours']}h → rollback + cooldown")
302 + elif status == "ok":
303 + set_incident(c, inc["id"], state="resolved", resolved=now())
304 + incident_event(inc["id"], inc["service"], inc["source"], "resolved", "confirmé ok")
305 +
306 + # 3. Cooldown expiré → réouverture ou abandon
307 + for inc in c.execute("SELECT * FROM incidents WHERE state='cooldown'").fetchall():
308 + if now() - inc["updated"] < POLICY["attempt_cooldown_hours"] * 3600:
309 + continue
310 + status = current_status(inc["service"], inc["source"])
311 + if status == "ok":
312 + set_incident(c, inc["id"], state="resolved", resolved=now())
313 + incident_event(inc["id"], inc["service"], inc["source"], "resolved", "guéri pendant le cooldown")
314 + elif inc["attempts"] >= POLICY["max_attempts_per_incident"]:
315 + set_incident(c, inc["id"], state="abandoned")
316 + incident_event(inc["id"], inc["service"], inc["source"], "abandoned",
317 + f"{inc['attempts']} tentatives épuisées — intervention humaine requise")
318 + else:
319 + set_incident(c, inc["id"], state="open")
320 + incident_event(inc["id"], inc["service"], inc["source"], "open", "cooldown terminé, nouvelle tentative")
321 +
322 + # 4. Dispatch (1 mission à la fois par agent, broken avant stale, plus vieux d'abord)
323 + running = c.execute("SELECT COUNT(*) n FROM missions WHERE state='running'").fetchone()["n"]
324 + if running < POLICY["max_concurrent_missions"]:
325 + busy_nodes = {m["node"] for m in c.execute("SELECT node FROM missions WHERE state='running'").fetchall()}
326 + nxt = c.execute(
327 + "SELECT * FROM incidents WHERE state='open' "
328 + "ORDER BY CASE status_detected WHEN 'broken' THEN 0 WHEN 'manual' THEN 0 ELSE 1 END, created "
329 + ).fetchall()
330 + for inc in nxt:
331 + if inc["service"] not in SERVICES:
332 + continue
333 + if SERVICES[inc["service"]]["node"] in busy_nodes:
334 + continue
335 + set_incident(c, inc["id"], state="dispatched")
336 + asyncio.create_task(dispatch_row(inc["id"]))
337 + break
338 +
339 +
340 +def current_status(service: str, source: str) -> str:
341 + block = LATEST["mine"].get(service) or {}
342 + for conn in block.get("connectors", []):
343 + if conn["source"] == source:
344 + return conn["status"]
345 + return "inconnu"
346 +
347 +
348 +async def dispatch_row(iid: str) -> None:
349 + with db() as c:
350 + inc = c.execute("SELECT * FROM incidents WHERE id=?", (iid,)).fetchone()
351 + if inc:
352 + await dispatch(inc, json.loads(inc["detail"] or "{}"))
353 + # dispatch() remet fixing; si l'appel a échoué/409, on relâche
354 + with db() as c:
355 + cur = c.execute("SELECT state FROM incidents WHERE id=?", (iid,)).fetchone()
356 + if cur and cur["state"] == "dispatched":
357 + set_incident(c, iid, state="open")
358 +
359 +
360 +async def engine() -> None:
361 + global LOOP
362 + LOOP = asyncio.get_running_loop()
363 + await asyncio.sleep(3)
364 + while True:
365 + try:
366 + await tick()
367 + except Exception as exc:
368 + hub.publish_sync({"kind": "log", "level": "error", "msg": f"tick: {exc}", "ts": now()})
369 + await asyncio.sleep(POLICY["poll_interval_seconds"])
370 +
371 +
372 +# ------------------------------------------------------------------- app ---
373 +
374 +app = FastAPI(title=f"KA Guardian — {AGENT}", docs_url=None, redoc_url=None)
375 +
376 +
377 +@app.on_event("startup")
378 +async def startup() -> None:
379 + init_db()
380 + asyncio.create_task(engine())
381 +
382 +
383 +def check_token(tok: str | None) -> None:
384 + if not TOKEN or tok != TOKEN:
385 + raise HTTPException(status_code=401)
386 +
387 +
388 +@app.post("/api/ingest/{mission_id}")
389 +async def ingest(mission_id: str, req: Request, x_ka_token: str | None = Header(default=None)) -> dict[str, Any]:
390 + check_token(x_ka_token)
391 + ev = await req.json()
392 + etype, data = ev.get("type"), ev.get("data", {})
393 + with db() as c:
394 + c.execute("INSERT INTO events(mission_id,ts,type,data) VALUES(?,?,?,?)",
395 + (mission_id, now(), etype, jdump(data)))
396 + m = c.execute("SELECT * FROM missions WHERE id=?", (mission_id,)).fetchone()
397 + if m:
398 + if etype == "start":
399 + c.execute("UPDATE missions SET base_commit=? WHERE id=?", (data.get("base_commit"), mission_id))
400 + elif etype in ("final", "error"):
401 + await finalize(c, m, etype, data)
402 + hub.publish_sync({"kind": "mission_event", "mission_id": mission_id, "type": etype,
403 + "data": data, "ts": now()})
404 + return {"ok": True}
405 +
406 +
407 +async def finalize(c: sqlite3.Connection, m: sqlite3.Row, etype: str, data: dict[str, Any]) -> None:
408 + inc = c.execute("SELECT * FROM incidents WHERE id=?", (m["incident_id"],)).fetchone()
409 + if etype == "error":
410 + c.execute("UPDATE missions SET state='error', ended=? WHERE id=?", (now(), m["id"]))
411 + if inc:
412 + set_incident(c, inc["id"], state="cooldown")
413 + incident_event(inc["id"], m["service"], m["source"], "cooldown", f"erreur mission: {data.get('error')}")
414 + return
415 + verdict = data.get("verdict", {})
416 + commits = data.get("commits", [])
417 + health = data.get("health", {})
418 + c.execute("UPDATE missions SET state='done', ended=?, commits=?, verdict=?, cost_usd=?, num_turns=?, health=?, base_commit=COALESCE(base_commit,?) WHERE id=?",
419 + (now(), jdump(commits), jdump(verdict), data.get("cost_usd"),
420 + data.get("num_turns"), jdump(health), data.get("base_commit"), m["id"]))
421 + m2 = c.execute("SELECT * FROM missions WHERE id=?", (m["id"],)).fetchone()
422 + v = verdict.get("verdict", "inconnu")
423 + if not inc:
424 + return
425 + if not health.get("ok", True):
426 + # L'app est tombée → rollback immédiat, quoi qu'ait dit l'agent.
427 + asyncio.create_task(rollback_mission(m2, "healthcheck app en échec post-mission"))
428 + set_incident(c, inc["id"], state="cooldown")
429 + incident_event(inc["id"], m["service"], m["source"], "cooldown", "app en mauvaise santé → ROLLBACK immédiat")
430 + elif v == "repare" and commits:
431 + set_incident(c, inc["id"], state="watching")
432 + incident_event(inc["id"], m["service"], m["source"], "watching",
433 + f"réparation déclarée ({len(commits)} commit) — surveillance {POLICY['watch_window_hours']}h")
434 + elif v in ("echec", "inconnu") and commits:
435 + asyncio.create_task(rollback_mission(m2, f"verdict {v} avec commits → annulation par prudence"))
436 + set_incident(c, inc["id"], state="cooldown")
437 + incident_event(inc["id"], m["service"], m["source"], "cooldown", f"verdict {v} → rollback préventif")
438 + elif v == "site_source_mort":
439 + set_incident(c, inc["id"], state="abandoned")
440 + incident_event(inc["id"], m["service"], m["source"], "abandoned", "source définitivement morte (diagnostic agent)")
441 + elif v in ("rien_a_faire", "repare"):
442 + set_incident(c, inc["id"], state="watching")
443 + incident_event(inc["id"], m["service"], m["source"], "watching", f"verdict {v} — surveillance")
444 + else:
445 + set_incident(c, inc["id"], state="cooldown")
446 + incident_event(inc["id"], m["service"], m["source"], "cooldown", f"verdict {v}")
447 +
448 +
449 +@app.get("/api/state")
450 +def state() -> dict[str, Any]:
451 + with db() as c:
452 + incidents = [dict(r) for r in c.execute(
453 + "SELECT * FROM incidents ORDER BY created DESC LIMIT 200").fetchall()]
454 + missions = [dict(r) for r in c.execute(
455 + "SELECT * FROM missions ORDER BY started DESC LIMIT 100").fetchall()]
456 + stats = c.execute("""SELECT
457 + (SELECT COUNT(*) FROM missions) n_missions,
458 + (SELECT COUNT(*) FROM incidents WHERE state='resolved') n_resolved,
459 + (SELECT COUNT(*) FROM incidents WHERE state IN ('open','dispatched','fixing','watching','cooldown')) n_active,
460 + (SELECT COUNT(*) FROM incidents WHERE state='abandoned') n_abandoned,
461 + (SELECT COUNT(*) FROM events WHERE type='rollback') n_rollbacks,
462 + (SELECT ROUND(SUM(cost_usd),2) FROM missions) cost_total,
463 + (SELECT ROUND(AVG(resolved-created)/3600.0,1) FROM incidents WHERE state='resolved' AND resolved IS NOT NULL) mttr_h
464 + """).fetchone()
465 + history = [dict(r) for r in c.execute(
466 + "SELECT ts, mine FROM snapshots WHERE ts > ? ORDER BY ts", (now() - 7 * 86400,)).fetchall()]
467 + return {
468 + "agent": AGENT, "identity": {k: ME[k] for k in ("domain", "accent", "accent2", "tagline", "model")},
469 + "services": {s: {**SERVICES[s], "node_alias": NODES[SERVICES[s]["node"]]["alias"]} for s in ME["services"]},
470 + "siblings": {a: {"domain": v["domain"], "accent": v["accent"], "services": v["services"]}
471 + for a, v in TOPO["agents"].items() if a != AGENT},
472 + "latest": LATEST, "incidents": incidents, "missions": missions,
473 + "stats": dict(stats), "history": history, "policy": POLICY, "now": now(),
474 + }
475 +
476 +
477 +@app.get("/api/missions/{mission_id}")
478 +def mission_detail(mission_id: str) -> dict[str, Any]:
479 + with db() as c:
480 + m = c.execute("SELECT * FROM missions WHERE id=?", (mission_id,)).fetchone()
481 + if not m:
482 + raise HTTPException(status_code=404)
483 + evs = [dict(r) for r in c.execute(
484 + "SELECT ts,type,data FROM events WHERE mission_id=? ORDER BY id", (mission_id,)).fetchall()]
485 + return {"mission": dict(m), "events": evs}
486 +
487 +
488 +@app.get("/events")
489 +async def sse(request: Request) -> StreamingResponse:
490 + q: asyncio.Queue = asyncio.Queue()
491 + hub.clients.add(q)
492 +
493 + async def gen():
494 + try:
495 + yield f"data: {jdump({'kind': 'hello', 'agent': AGENT, 'ts': now()})}\n\n"
496 + while True:
497 + if await request.is_disconnected():
498 + break
499 + try:
500 + msg = await asyncio.wait_for(q.get(), timeout=25)
501 + yield f"data: {jdump(msg)}\n\n"
502 + except asyncio.TimeoutError:
503 + yield ": keepalive\n\n"
504 + finally:
505 + hub.clients.discard(q)
506 +
507 + return StreamingResponse(gen(), media_type="text/event-stream",
508 + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
509 +
510 +
511 +@app.post("/api/admin/mission")
512 +async def admin_mission(req: Request, x_ka_token: str | None = Header(default=None)) -> dict[str, Any]:
513 + """Déclenche manuellement une mission sur un connecteur (même sain)."""
514 + check_token(x_ka_token)
515 + body = await req.json()
516 + service, source = body["service"], body["source"]
517 + if service not in SERVICES:
518 + raise HTTPException(status_code=400, detail="service inconnu")
519 + detail = {"source": source, "status": "manual", "message": body.get("note", "mission manuelle")}
520 + for conn in (LATEST["mine"].get(service) or {}).get("connectors", []):
521 + if conn["source"] == source:
522 + detail = {**conn, "status": "manual"}
523 + with db() as c:
524 + inc = active_incident(c, service, source)
525 + if inc is None:
526 + iid = uuid.uuid4().hex[:10]
527 + c.execute("INSERT INTO incidents(id,service,source,status_detected,state,created,updated,detail) "
528 + "VALUES(?,?,?,?,?,?,?,?)",
529 + (iid, service, source, "manual", "open", now(), now(), jdump(detail)))
530 + else:
531 + iid = inc["id"]
532 + set_incident(c, iid, state="open", status_detected="manual")
533 + incident_event(iid, service, source, "open", "mission manuelle demandée")
534 + return {"ok": True, "incident_id": iid}
535 +
536 +
537 +@app.post("/api/admin/pause")
538 +async def admin_pause(req: Request, x_ka_token: str | None = Header(default=None)) -> dict[str, Any]:
539 + check_token(x_ka_token)
540 + body = await req.json()
541 + LATEST["paused"] = bool(body.get("paused", True))
542 + return {"ok": True, "paused": LATEST["paused"]}
543 +
544 +
545 +@app.get("/health")
546 +def health() -> dict[str, Any]:
547 + return {"ok": True, "agent": AGENT, "polled": LATEST["polled"], "paused": LATEST["paused"]}
548 +
549 +
550 +@app.get("/")
551 +def index() -> FileResponse:
552 + return FileResponse(BASE / "web" / "index.html")
553 +
554 +
555 +app.mount("/static", StaticFiles(directory=BASE / "web"), name="static")
556 +
557 +
558 +if __name__ == "__main__":
559 + import uvicorn
560 + uvicorn.run(app, host="0.0.0.0", port=ME["port"])
added M4M36luster-projects/ka-guardian/M4M36luster-projects/ka-guardian/M4M36luster-projects/ka-guardian/M4M36luster-projects/ka-guardian/orchestrator/web/app.js +163 −0
@@ -0,0 +1,163 @@
1 +/* KA Guardian — dashboard live */
2 +const $ = (s) => document.querySelector(s);
3 +const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
4 +const fmtT = (ts) => new Date(ts * 1000).toLocaleTimeString("fr-CA", { hour12: false });
5 +const fmtD = (ts) => new Date(ts * 1000).toLocaleString("fr-CA", { dateStyle: "short", timeStyle: "short" });
6 +const STATE_FR = { open: "à réparer", dispatched: "envoi…", fixing: "réparation", watching: "surveillance",
7 + cooldown: "attente", resolved: "résolu", self_healed: "auto-guéri", abandoned: "abandonné" };
8 +const STATUSES = ["ok", "degraded", "broken", "stale"];
9 +const STATUS_FR = { ok: "ok", degraded: "dégradé", broken: "cassé", stale: "endormi" };
10 +
11 +let STATE = null;
12 +
13 +async function load() {
14 + const r = await fetch("/api/state");
15 + STATE = await r.json();
16 + const id = STATE.identity;
17 + document.documentElement.style.setProperty("--accent", id.accent);
18 + document.documentElement.style.setProperty("--accent2", id.accent2);
19 + document.title = `${STATE.agent.toUpperCase()} — Guardian du Groupe KA`;
20 + $("#agent-name").textContent = STATE.agent.toUpperCase() + " · GUARDIAN";
21 + $("#sigil").textContent = "·K" + STATE.agent.replace("ka", "");
22 + $("#foot-name").textContent = STATE.agent.toUpperCase();
23 + const apps = Object.values(STATE.services).map((s) => s.app).join(" · ");
24 + $("#tagline").textContent = `${id.tagline} — veille sur ${apps}`;
25 + $("#siblings").innerHTML = Object.entries(STATE.siblings)
26 + .map(([a, v]) => `<a href="https://${v.domain}" style="color:${v.accent}"><b>${a.toUpperCase()}</b>${v.services.join(", ")}</a>`).join("");
27 + renderTiles(); renderIncidents(); renderCoverage(); renderMissions();
28 +}
29 +
30 +function renderTiles() {
31 + const s = STATE.stats, mine = STATE.latest.mine || {};
32 + let total = 0, sick = 0;
33 + for (const b of Object.values(mine)) for (const st of STATUSES) {
34 + total += b.summary[st] || 0;
35 + if (st !== "ok") sick += b.summary[st] || 0;
36 + }
37 + const tiles = [
38 + [total || "—", "connecteurs surveillés", true],
39 + [s.n_active ?? 0, "incidents actifs"],
40 + [s.n_missions ?? 0, "missions lancées"],
41 + [s.n_resolved ?? 0, "réparations confirmées"],
42 + [s.n_rollbacks ?? 0, "rollbacks"],
43 + [s.mttr_h != null ? s.mttr_h + " h" : "—", "temps moyen de guérison"],
44 + [s.cost_total != null ? s.cost_total + " $" : "0 $", "coût API total"],
45 + ];
46 + $("#tiles").innerHTML = tiles.map(([v, l, hot]) =>
47 + `<div class="tile${hot ? " hot" : ""}"><div class="v">${esc(v)}</div><div class="l">${l}</div></div>`).join("");
48 +}
49 +
50 +function renderIncidents() {
51 + const active = STATE.incidents.filter((i) => !["resolved", "self_healed"].includes(i.state)).slice(0, 12);
52 + const recent = STATE.incidents.filter((i) => ["resolved", "self_healed"].includes(i.state)).slice(0, 5);
53 + const row = (i) => `<div class="inc">
54 + <div><div class="src">${esc(i.source)}</div><div class="svc">${esc(i.service)} · ${esc(i.status_detected)} · tentatives ${i.attempts}</div></div>
55 + <span class="badge b-${esc(i.state)}">${STATE_FR[i.state] || esc(i.state)}</span></div>`;
56 + $("#incidents").innerHTML = (active.length || recent.length)
57 + ? active.map(row).join("") + recent.map(row).join("")
58 + : `<div class="empty">Aucun incident — tous les connecteurs assignés sont sains. L'agent veille.</div>`;
59 +}
60 +
61 +function renderCoverage() {
62 + const mine = STATE.latest.mine || {};
63 + $("#coverage").innerHTML = Object.keys(STATE.services).map((svc) => {
64 + const meta = STATE.services[svc], sum = (mine[svc] || {}).summary || {};
65 + const total = STATUSES.reduce((a, st) => a + (sum[st] || 0), 0) || 1;
66 + const strip = STATUSES.filter((st) => sum[st] > 0)
67 + .map((st) => `<span class="s-${st}" style="flex:${sum[st]}" title="${STATUS_FR[st]}: ${sum[st]}"></span>`).join("");
68 + const counts = STATUSES.map((st) =>
69 + `<span><i class="s-${st}"></i>${sum[st] || 0} ${STATUS_FR[st]}</span>`).join("");
70 + return `<div class="cov"><div class="head"><span class="app">${esc(meta.app)}</span>
71 + <span class="node">${esc(meta.node_alias)} · ${total} connecteurs</span></div>
72 + <div class="strip">${strip || "<span style='flex:1;background:var(--line)'></span>"}</div>
73 + <div class="counts">${counts}</div></div>`;
74 + }).join("");
75 +}
76 +
77 +function renderMissions() {
78 + const vd = (m) => {
79 + if (m.state === "running") return `<span class="v-running">en cours…</span>`;
80 + if (m.state === "error") return `<span class="v-echec">erreur</span>`;
81 + const v = (JSON.parse(m.verdict || "{}").verdict) || "—";
82 + return `<span class="v-${esc(v)}">${esc(v)}</span>`;
83 + };
84 + $("#missions tbody").innerHTML = STATE.missions.map((m) => {
85 + const commits = JSON.parse(m.commits || "[]").length;
86 + const dur = m.ended ? Math.round((m.ended - m.started) / 60) + " min" : "";
87 + return `<tr data-id="${esc(m.id)}"><td class="mono">${fmtD(m.started)}</td>
88 + <td class="mono">${esc(m.source)}</td><td>${esc(m.service)}</td><td class="mono">${esc(m.node)}</td>
89 + <td>${vd(m)}</td><td>${commits || "—"}</td><td>${m.num_turns ?? ""} ${dur ? "· " + dur : ""}</td>
90 + <td class="mono">${m.cost_usd != null ? m.cost_usd.toFixed(2) + " $" : ""}</td></tr>`;
91 + }).join("") || `<tr><td colspan="8" class="empty">Aucune mission encore — la première panne détectée lancera l'agent.</td></tr>`;
92 +}
93 +
94 +/* ---- feed en direct ---- */
95 +const feed = $("#feed");
96 +function feedLine(cls, tag, msg, ts) {
97 + if (feed.querySelector(".feed-empty")) feed.innerHTML = "";
98 + const el = document.createElement("div");
99 + el.className = "fe " + cls;
100 + el.innerHTML = `<span class="t">${fmtT(ts || Date.now() / 1000)}</span><span class="tag">${esc(tag)}</span><span class="m">${esc(msg)}</span>`;
101 + feed.appendChild(el);
102 + while (feed.children.length > 400) feed.removeChild(feed.firstChild);
103 + feed.scrollTop = feed.scrollHeight;
104 +}
105 +
106 +let refetchTimer = null;
107 +const refetch = () => { clearTimeout(refetchTimer); refetchTimer = setTimeout(load, 800); };
108 +
109 +function connectSSE() {
110 + const es = new EventSource("/events");
111 + es.onopen = () => $("#live-dot").classList.add("on");
112 + es.onerror = () => $("#live-dot").classList.remove("on");
113 + es.onmessage = (e) => {
114 + const m = JSON.parse(e.data);
115 + if (m.kind === "mission_event") {
116 + const d = m.data || {};
117 + if (m.type === "start") feedLine("tool", "mission", `démarrage — commit de base ${(d.base_commit || "").slice(0, 8)} sur ${d.node || ""}`, m.ts);
118 + else if (m.type === "tool") feedLine("tool", d.name || "outil", d.input || "", m.ts);
119 + else if (m.type === "text") feedLine("text", "agent", d.text || "", m.ts);
120 + else if (m.type === "result") feedLine("text", "résultat", (d.result || "").slice(0, 600), m.ts);
121 + else if (m.type === "final") { feedLine("text", "verdict", JSON.stringify(d.verdict || {}), m.ts); refetch(); }
122 + else if (m.type === "error") { feedLine("rollback", "erreur", d.error || "", m.ts); refetch(); }
123 + else if (m.type === "rollback") feedLine("rollback", "rollback", JSON.stringify(d), m.ts);
124 + } else if (m.kind === "incident") {
125 + feedLine("incident", "incident", `${m.service}/${m.source} → ${STATE_FR[m.state] || m.state}${m.note ? " — " + m.note : ""}`, m.ts);
126 + refetch();
127 + } else if (m.kind === "rollback") {
128 + feedLine("rollback", "rollback", `${m.service}/${m.source} — ${m.reason} (${(m.result || {}).mode || "?"})`, m.ts);
129 + refetch();
130 + } else if (m.kind === "mission") {
131 + feedLine("tool", "mission", `nouvelle mission sur ${m.service}/${m.source}`, m.ts);
132 + refetch();
133 + } else if (m.kind === "snapshot") {
134 + if (STATE) { STATE.latest.mine = Object.fromEntries(Object.entries(m.mine).map(([s, sum]) => [s, { summary: sum }])); renderCoverage(); }
135 + } else if (m.kind === "log") {
136 + feedLine("incident", m.level || "log", m.msg, m.ts);
137 + }
138 + };
139 +}
140 +
141 +/* ---- modal mission ---- */
142 +document.addEventListener("click", async (e) => {
143 + const tr = e.target.closest("#missions tbody tr[data-id]");
144 + if (tr) {
145 + const r = await fetch("/api/missions/" + tr.dataset.id);
146 + const { mission, events } = await r.json();
147 + const v = JSON.parse(mission.verdict || "{}");
148 + $("#modal-title").textContent = `Mission ${mission.id} — ${mission.source} (${mission.service})`;
149 + $("#modal-body").innerHTML =
150 + `<div class="meta">verdict: ${esc(v.verdict || mission.state)}\ndiagnostic: ${esc(v.diagnostic || "")}\nactions: ${esc(v.actions || "")}\ntest: ${esc(v.test || "")}\ncommit de base: ${esc(mission.base_commit || "")}\ncommits: ${esc((JSON.parse(mission.commits || "[]")).join(" | ") || "aucun")}</div>` +
151 + events.map((ev) => {
152 + const d = JSON.parse(ev.data || "{}");
153 + const txt = ev.type === "tool" ? `${d.name}: ${d.input}` : ev.type === "text" ? d.text
154 + : ev.type === "result" ? d.result : JSON.stringify(d);
155 + return `<div class="meta"><b>${fmtT(ev.ts)} · ${esc(ev.type)}</b>\n${esc((txt || "").slice(0, 2500))}</div>`;
156 + }).join("");
157 + $("#modal").hidden = false;
158 + }
159 + if (e.target.id === "modal-close" || e.target.id === "modal") $("#modal").hidden = true;
160 +});
161 +
162 +load().then(connectSSE);
163 +setInterval(load, 60000);
added M4M36luster-projects/ka-guardian/M4M36luster-projects/ka-guardian/M4M36luster-projects/ka-guardian/M4M36luster-projects/ka-guardian/orchestrator/web/index.html +69 −0
@@ -0,0 +1,69 @@
1 +<!doctype html>
2 +<html lang="fr">
3 +<head>
4 +<meta charset="utf-8">
5 +<meta name="viewport" content="width=device-width, initial-scale=1">
6 +<title>KA Guardian</title>
7 +<meta name="description" content="Agent autonome de maintenance des connecteurs du Groupe KA — incidents, missions et réparations en direct.">
8 +<link rel="preconnect" href="https://fonts.googleapis.com">
9 +<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10 +<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">
11 +<link rel="stylesheet" href="/static/style.css">
12 +</head>
13 +<body>
14 +<header class="top">
15 + <div class="brand">
16 + <div class="sigil" id="sigil">·K</div>
17 + <div>
18 + <h1 id="agent-name">KA·—</h1>
19 + <p class="tagline"><span class="live-dot" id="live-dot"></span><span id="tagline">connexion…</span></p>
20 + </div>
21 + </div>
22 + <nav class="siblings" id="siblings"></nav>
23 +</header>
24 +
25 +<main>
26 + <section class="tiles" id="tiles"></section>
27 +
28 + <section class="cols">
29 + <div class="panel feed-panel">
30 + <h2>⚡ Flux en direct <span class="hint">tout ce que l'agent fait, en temps réel</span></h2>
31 + <div class="feed" id="feed"><div class="feed-empty">En attente d'activité…</div></div>
32 + </div>
33 + <div class="side">
34 + <div class="panel">
35 + <h2>🚨 Incidents</h2>
36 + <div id="incidents" class="incidents"></div>
37 + </div>
38 + <div class="panel">
39 + <h2>🛰 Couverture</h2>
40 + <div id="coverage" class="coverage"></div>
41 + </div>
42 + </div>
43 + </section>
44 +
45 + <section class="panel">
46 + <h2>📜 Missions <span class="hint">cliquer une ligne pour le déroulé complet</span></h2>
47 + <div class="table-wrap">
48 + <table id="missions">
49 + <thead><tr><th>Quand</th><th>Connecteur</th><th>Service</th><th>Nœud</th><th>Verdict</th><th>Commits</th><th>Tours</th><th>Coût</th></tr></thead>
50 + <tbody></tbody>
51 + </table>
52 + </div>
53 + </section>
54 +</main>
55 +
56 +<footer>
57 + <p><strong id="foot-name">KA</strong> — agent gardien autonome du <a href="https://www.groupe-ka.com">Groupe KA</a>. Il détecte les connecteurs en panne, les répare avec Claude, surveille la guérison et annule ses changements si ça empire.</p>
58 +</footer>
59 +
60 +<div class="modal" id="modal" hidden>
61 + <div class="modal-box">
62 + <div class="modal-head"><h3 id="modal-title">Mission</h3><button id="modal-close">✕</button></div>
63 + <div class="modal-body" id="modal-body"></div>
64 + </div>
65 +</div>
66 +
67 +<script src="/static/app.js"></script>
68 +</body>
69 +</html>
added M4M36luster-projects/ka-guardian/M4M36luster-projects/ka-guardian/M4M36luster-projects/ka-guardian/M4M36luster-projects/ka-guardian/orchestrator/web/style.css +118 −0
@@ -0,0 +1,118 @@
1 +/* KA Guardian — salle de contrôle */
2 +:root {
3 + --bg: #0d1017; --bg2: #12161f; --panel: #151a24; --panel2: #1a2030;
4 + --line: #232a3a; --ink: #e8ecf4; --ink2: #9aa5b8; --muted: #667089;
5 + --accent: #22d3ee; --accent2: #0891b2;
6 + --ok: #4ade80; --degraded: #fbbf24; --broken: #f87171; --stale: #60a5fa;
7 + --mono: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
8 + --sans: "Space Grotesk", -apple-system, sans-serif;
9 +}
10 +* { box-sizing: border-box; margin: 0; }
11 +body { background: var(--bg); color: var(--ink); font-family: var(--sans); min-height: 100vh; }
12 +a { color: var(--accent); text-decoration: none; }
13 +
14 +.top { display: flex; justify-content: space-between; align-items: center; gap: 16px; flex-wrap: wrap;
15 + padding: 22px clamp(16px, 4vw, 48px); border-bottom: 1px solid var(--line);
16 + background: radial-gradient(1200px 300px at 20% -50%, color-mix(in oklab, var(--accent) 18%, transparent), transparent), var(--bg2); }
17 +.brand { display: flex; gap: 16px; align-items: center; }
18 +.sigil { width: 54px; height: 54px; border-radius: 14px; display: grid; place-items: center;
19 + font: 700 22px var(--mono); color: #0b0e14;
20 + background: linear-gradient(135deg, var(--accent), var(--accent2));
21 + box-shadow: 0 0 24px color-mix(in oklab, var(--accent) 45%, transparent); }
22 +h1 { font-size: 26px; letter-spacing: 0.04em; }
23 +.tagline { color: var(--ink2); font-size: 14px; display: flex; align-items: center; gap: 8px; }
24 +.live-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--muted); flex: none; }
25 +.live-dot.on { background: var(--ok); animation: pulse 1.8s infinite; }
26 +@keyframes pulse { 0%,100% { box-shadow: 0 0 0 0 color-mix(in oklab, var(--ok) 55%, transparent); }
27 + 60% { box-shadow: 0 0 0 8px transparent; } }
28 +.siblings { display: flex; gap: 10px; }
29 +.siblings a { border: 1px solid var(--line); border-radius: 10px; padding: 7px 13px; font: 600 13px var(--mono);
30 + color: var(--ink2); background: var(--panel); }
31 +.siblings a b { margin-right: 6px; }
32 +
33 +main { padding: 22px clamp(16px, 4vw, 48px); display: grid; gap: 20px; max-width: 1500px; margin: 0 auto; }
34 +
35 +.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; }
36 +.tile { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 14px 16px; }
37 +.tile .v { font: 600 30px/1.15 var(--mono); }
38 +.tile .l { color: var(--ink2); font-size: 12.5px; margin-top: 4px; letter-spacing: 0.03em; text-transform: uppercase; }
39 +.tile.hot .v { color: var(--accent); }
40 +
41 +.cols { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr); gap: 20px; }
42 +@media (max-width: 980px) { .cols { grid-template-columns: 1fr; } }
43 +.side { display: grid; gap: 20px; align-content: start; }
44 +
45 +.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 16px; padding: 18px; min-width: 0; }
46 +.panel h2 { font-size: 16px; margin-bottom: 14px; letter-spacing: 0.02em; }
47 +.hint { color: var(--muted); font-size: 12px; font-weight: 400; margin-left: 8px; }
48 +
49 +/* Flux en direct */
50 +.feed { font: 13px/1.55 var(--mono); background: #0a0d13; border: 1px solid var(--line); border-radius: 12px;
51 + padding: 14px; height: 560px; overflow-y: auto; display: flex; flex-direction: column; gap: 8px; }
52 +.feed-empty { color: var(--muted); }
53 +.fe { display: flex; gap: 10px; align-items: baseline; animation: fadein 0.3s; }
54 +@keyframes fadein { from { opacity: 0; transform: translateY(4px); } }
55 +.fe .t { color: var(--muted); flex: none; font-size: 11px; }
56 +.fe .tag { flex: none; font-size: 11px; font-weight: 600; padding: 1px 8px; border-radius: 999px;
57 + border: 1px solid var(--line); color: var(--ink2); }
58 +.fe.tool .tag { color: var(--accent); border-color: color-mix(in oklab, var(--accent) 40%, var(--line)); }
59 +.fe.text .tag { color: var(--ok); }
60 +.fe.incident .tag { color: var(--degraded); }
61 +.fe.rollback .tag { color: var(--broken); }
62 +.fe .m { color: var(--ink2); overflow-wrap: anywhere; white-space: pre-wrap; }
63 +.fe.text .m { color: var(--ink); }
64 +
65 +/* Incidents */
66 +.incidents { display: grid; gap: 9px; max-height: 330px; overflow-y: auto; }
67 +.inc { display: flex; align-items: center; gap: 10px; background: var(--panel2); border: 1px solid var(--line);
68 + border-radius: 11px; padding: 10px 12px; font-size: 13px; }
69 +.inc .src { font: 600 13px var(--mono); overflow-wrap: anywhere; }
70 +.inc .svc { color: var(--muted); font-size: 11.5px; }
71 +.badge { margin-left: auto; flex: none; font: 600 11px var(--mono); padding: 3px 9px; border-radius: 999px; }
72 +.b-open { background: color-mix(in oklab, var(--broken) 18%, transparent); color: var(--broken); }
73 +.b-fixing { background: color-mix(in oklab, var(--accent) 18%, transparent); color: var(--accent); }
74 +.b-dispatched{ background: color-mix(in oklab, var(--accent) 18%, transparent); color: var(--accent); }
75 +.b-watching { background: color-mix(in oklab, var(--stale) 18%, transparent); color: var(--stale); }
76 +.b-cooldown { background: color-mix(in oklab, var(--degraded) 18%, transparent);color: var(--degraded); }
77 +.b-resolved, .b-self_healed { background: color-mix(in oklab, var(--ok) 16%, transparent); color: var(--ok); }
78 +.b-abandoned { background: #2a2f3d; color: var(--muted); }
79 +.empty { color: var(--muted); font-size: 13px; }
80 +
81 +/* Couverture */
82 +.coverage { display: grid; gap: 12px; }
83 +.cov { background: var(--panel2); border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px; }
84 +.cov .head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 8px; }
85 +.cov .app { font-weight: 700; }
86 +.cov .node { color: var(--muted); font: 11px var(--mono); }
87 +.strip { display: flex; height: 10px; border-radius: 6px; overflow: hidden; gap: 2px; background: var(--bg); }
88 +.strip span { min-width: 3px; }
89 +.s-ok { background: var(--ok); } .s-degraded { background: var(--degraded); }
90 +.s-broken { background: var(--broken); } .s-stale { background: var(--stale); }
91 +.counts { display: flex; gap: 12px; margin-top: 8px; flex-wrap: wrap; font: 11.5px var(--mono); color: var(--ink2); }
92 +.counts i { display: inline-block; width: 8px; height: 8px; border-radius: 2px; margin-right: 5px; }
93 +
94 +/* Missions */
95 +.table-wrap { overflow-x: auto; }
96 +table { width: 100%; border-collapse: collapse; font-size: 13px; }
97 +th { text-align: left; color: var(--muted); font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.05em;
98 + padding: 8px 10px; border-bottom: 1px solid var(--line); }
99 +td { padding: 9px 10px; border-bottom: 1px solid color-mix(in oklab, var(--line) 55%, transparent); }
100 +tbody tr { cursor: pointer; } tbody tr:hover { background: var(--panel2); }
101 +.mono { font-family: var(--mono); font-size: 12.5px; }
102 +.v-repare { color: var(--ok); } .v-echec, .v-inconnu { color: var(--broken); }
103 +.v-site_source_mort { color: var(--muted); } .v-rien_a_faire { color: var(--stale); }
104 +.v-running { color: var(--accent); }
105 +
106 +footer { padding: 26px clamp(16px, 4vw, 48px); border-top: 1px solid var(--line); color: var(--ink2);
107 + font-size: 13.5px; max-width: 1500px; margin: 0 auto; }
108 +
109 +/* Modal */
110 +.modal { position: fixed; inset: 0; background: rgba(5, 7, 11, 0.75); display: grid; place-items: center; z-index: 50; }
111 +.modal-box { background: var(--panel); border: 1px solid var(--line); border-radius: 16px; width: min(880px, 94vw);
112 + max-height: 86vh; display: flex; flex-direction: column; }
113 +.modal-head { display: flex; justify-content: space-between; align-items: center; padding: 14px 18px;
114 + border-bottom: 1px solid var(--line); }
115 +.modal-head button { background: none; border: none; color: var(--ink2); font-size: 18px; cursor: pointer; }
116 +.modal-body { padding: 16px 18px; overflow-y: auto; font: 12.5px/1.6 var(--mono); display: grid; gap: 8px; }
117 +.modal-body .meta { background: var(--panel2); border-radius: 10px; padding: 10px 12px; color: var(--ink2);
118 + white-space: pre-wrap; overflow-wrap: anywhere; }
added M4M36luster-projects/ka-guardian/M4M36luster-projects/ka-guardian/M4M36luster-projects/ka-guardian/M4M36luster-projects/ka-guardian/runner/runner.py +287 −0
@@ -0,0 +1,287 @@
1 +# ============================================
2 +# Projet : KA Guardian
3 +# Fichier : runner/runner.py
4 +# Rôle : Bras d'exécution sur chaque nœud d'app — lance les missions
5 +# claude -p headless dans le repo de l'app, streame les événements
6 +# vers l'orchestrateur (ka2/ka4/ka6), et sait rollback + restart.
7 +# Author : Simon-Pierre Boucher
8 +# Date : 2026-08-23
9 +# ============================================
10 +"""KA Guardian Runner.
11 +
12 +Un seul runner par nœud (port 7791), une seule mission à la fois par nœud.
13 +Auth: header X-KA-Token == $KA_GUARDIAN_TOKEN (fichier ~/.ka-guardian.env).
14 +"""
15 +from __future__ import annotations
16 +
17 +import json
18 +import os
19 +import pathlib
20 +import shlex
21 +import socket
22 +import subprocess
23 +import threading
24 +import time
25 +from typing import Any
26 +
27 +import httpx
28 +from fastapi import FastAPI, Header, HTTPException, Request
29 +from pydantic import BaseModel
30 +
31 +HOME = pathlib.Path.home()
32 +ENV_FILE = HOME / ".ka-guardian.env"
33 +CLAUDE_ENV_FILE = HOME / ".claude" / ".env"
34 +TRANSCRIPTS = HOME / "ka-guardian-runner" / "transcripts"
35 +TRANSCRIPTS.mkdir(parents=True, exist_ok=True)
36 +CLAUDE_BIN = "/opt/homebrew/bin/claude"
37 +
38 +
39 +def load_env_file(path: pathlib.Path) -> dict[str, str]:
40 + out: dict[str, str] = {}
41 + if path.exists():
42 + for line in path.read_text().splitlines():
43 + line = line.strip()
44 + if line and not line.startswith("#") and "=" in line:
45 + k, v = line.split("=", 1)
46 + out[k.strip()] = v.strip().strip('"').strip("'")
47 + return out
48 +
49 +
50 +LOCAL_ENV = load_env_file(ENV_FILE)
51 +TOKEN = LOCAL_ENV.get("KA_GUARDIAN_TOKEN", "")
52 +NODE = LOCAL_ENV.get("KA_GUARDIAN_NODE", socket.gethostname())
53 +
54 +app = FastAPI(title="KA Guardian Runner", docs_url=None, redoc_url=None)
55 +
56 +_lock = threading.Lock()
57 +_current: dict[str, Any] = {} # mission en cours (métadonnées)
58 +
59 +
60 +def check_token(x_ka_token: str | None) -> None:
61 + if not TOKEN or x_ka_token != TOKEN:
62 + raise HTTPException(status_code=401, detail="token invalide")
63 +
64 +
65 +def expand(dir_: str) -> str:
66 + return os.path.expanduser(dir_)
67 +
68 +
69 +def git(dir_: str, *args: str) -> subprocess.CompletedProcess:
70 + return subprocess.run(
71 + ["git", "-C", expand(dir_), *args],
72 + capture_output=True, text=True, timeout=120,
73 + )
74 +
75 +
76 +def sh(cmd: str, timeout: int = 120) -> subprocess.CompletedProcess:
77 + env = {**os.environ, "PATH": "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"}
78 + return subprocess.run(["/bin/zsh", "-c", cmd], capture_output=True, text=True, timeout=timeout, env=env)
79 +
80 +
81 +def healthcheck(web_port: int, pm2_names: list[str]) -> dict[str, Any]:
82 + """Vérifie que l'app répond en HTTP et que ses process pm2 sont online."""
83 + http_ok = False
84 + try:
85 + r = httpx.get(f"http://127.0.0.1:{web_port}/", timeout=10, follow_redirects=True)
86 + http_ok = r.status_code < 500
87 + except Exception:
88 + http_ok = False
89 + pm2_status: dict[str, str] = {}
90 + try:
91 + out = sh("pm2 jlist").stdout
92 + procs = {p["name"]: p.get("pm2_env", {}).get("status", "?") for p in json.loads(out)}
93 + for name in pm2_names:
94 + pm2_status[name] = procs.get(name, "absent")
95 + except Exception as exc: # pm2 absent ou jlist illisible
96 + pm2_status = {n: f"inconnu ({exc})" for n in pm2_names}
97 + ok = http_ok and all(s in ("online", "launching") for s in pm2_status.values())
98 + return {"ok": ok, "http_ok": http_ok, "pm2": pm2_status}
99 +
100 +
101 +class MissionIn(BaseModel):
102 + mission_id: str
103 + agent: str
104 + service: str
105 + source: str
106 + dir: str
107 + pm2: list[str]
108 + web_port: int
109 + model: str = "sonnet"
110 + max_turns: int = 70
111 + timeout_seconds: int = 3600
112 + prompt: str
113 + callback_url: str # http://<orchestrateur>/api/ingest/<mission_id>
114 +
115 +
116 +def post_event(url: str, payload: dict[str, Any]) -> None:
117 + try:
118 + httpx.post(url, json=payload, headers={"X-KA-Token": TOKEN}, timeout=15)
119 + except Exception:
120 + pass # l'orchestrateur relira le transcript au besoin
121 +
122 +
123 +def condense_stream_line(obj: dict[str, Any]) -> list[dict[str, Any]]:
124 + """Transforme une ligne stream-json de claude en événements courts pour le feed."""
125 + events: list[dict[str, Any]] = []
126 + t = obj.get("type")
127 + if t == "system" and obj.get("subtype") == "init":
128 + events.append({"type": "init", "data": {"model": obj.get("model", "?")}})
129 + elif t == "assistant":
130 + for block in obj.get("message", {}).get("content", []):
131 + if block.get("type") == "text" and block.get("text", "").strip():
132 + events.append({"type": "text", "data": {"text": block["text"][:1500]}})
133 + elif block.get("type") == "tool_use":
134 + inp = json.dumps(block.get("input", {}), ensure_ascii=False)
135 + events.append({"type": "tool", "data": {"name": block.get("name", "?"), "input": inp[:400]}})
136 + elif t == "result":
137 + events.append({"type": "result", "data": {
138 + "subtype": obj.get("subtype"),
139 + "result": (obj.get("result") or "")[:4000],
140 + "cost_usd": obj.get("total_cost_usd"),
141 + "num_turns": obj.get("num_turns"),
142 + "duration_ms": obj.get("duration_ms"),
143 + }})
144 + return events
145 +
146 +
147 +def extract_verdict(result_text: str) -> dict[str, Any]:
148 + """Extrait le dernier bloc JSON {verdict: ...} de la réponse finale."""
149 + import re
150 + for m in reversed(re.findall(r"\{[^{}]*\"verdict\"[\s\S]*?\}", result_text)):
151 + try:
152 + v = json.loads(m)
153 + if "verdict" in v:
154 + return v
155 + except Exception:
156 + continue
157 + return {"verdict": "inconnu", "diagnostic": result_text[-500:] if result_text else ""}
158 +
159 +
160 +def run_mission(m: MissionIn) -> None:
161 + global _current
162 + workdir = expand(m.dir)
163 + transcript = TRANSCRIPTS / f"{m.mission_id}.jsonl"
164 + base_commit = ""
165 + try:
166 + # Snapshot pré-mission : tree sale → commit de sûreté pour un rollback net.
167 + if git(m.dir, "status", "--porcelain").stdout.strip():
168 + git(m.dir, "add", "-A")
169 + git(m.dir, "commit", "-m", f"[{m.agent}] snapshot pré-mission {m.source}")
170 + base_commit = git(m.dir, "rev-parse", "HEAD").stdout.strip()
171 + post_event(m.callback_url, {"type": "start", "data": {"base_commit": base_commit, "node": NODE}})
172 +
173 + env = {
174 + **os.environ,
175 + **load_env_file(CLAUDE_ENV_FILE),
176 + "PATH": "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin",
177 + "HOME": str(HOME),
178 + }
179 + cmd = [
180 + CLAUDE_BIN, "-p", m.prompt,
181 + "--output-format", "stream-json", "--verbose",
182 + "--model", m.model,
183 + "--max-turns", str(m.max_turns),
184 + "--dangerously-skip-permissions",
185 + ]
186 + proc = subprocess.Popen(
187 + cmd, cwd=workdir, env=env,
188 + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, bufsize=1,
189 + )
190 + _current["pid"] = proc.pid
191 + result_text, cost, turns = "", None, None
192 + deadline = time.time() + m.timeout_seconds
193 + with transcript.open("w") as tf:
194 + for line in proc.stdout: # type: ignore[union-attr]
195 + tf.write(line)
196 + if time.time() > deadline:
197 + proc.kill()
198 + post_event(m.callback_url, {"type": "error", "data": {"error": "timeout mission"}})
199 + break
200 + line = line.strip()
201 + if not line:
202 + continue
203 + try:
204 + obj = json.loads(line)
205 + except Exception:
206 + continue
207 + for ev in condense_stream_line(obj):
208 + if ev["type"] == "result":
209 + result_text = ev["data"].get("result", "")
210 + cost = ev["data"].get("cost_usd")
211 + turns = ev["data"].get("num_turns")
212 + post_event(m.callback_url, ev)
213 + proc.wait(timeout=60)
214 +
215 + commits_raw = git(m.dir, "rev-list", "--oneline", f"{base_commit}..HEAD").stdout.strip()
216 + commits = commits_raw.splitlines() if commits_raw else []
217 + verdict = extract_verdict(result_text)
218 + health = healthcheck(m.web_port, m.pm2)
219 + post_event(m.callback_url, {"type": "final", "data": {
220 + "verdict": verdict, "commits": commits, "base_commit": base_commit,
221 + "cost_usd": cost, "num_turns": turns, "health": health,
222 + "exit_code": proc.returncode,
223 + }})
224 + except Exception as exc:
225 + post_event(m.callback_url, {"type": "error", "data": {"error": str(exc), "base_commit": base_commit}})
226 + finally:
227 + with _lock:
228 + _current.clear()
229 +
230 +
231 +@app.get("/health")
232 +def health() -> dict[str, Any]:
233 + return {"ok": True, "node": NODE, "busy": bool(_current), "current": _current.get("mission_id")}
234 +
235 +
236 +@app.post("/missions")
237 +def missions(m: MissionIn, x_ka_token: str | None = Header(default=None)) -> dict[str, Any]:
238 + check_token(x_ka_token)
239 + workdir = expand(m.dir)
240 + if not os.path.isdir(workdir):
241 + raise HTTPException(status_code=400, detail=f"dir introuvable: {workdir}")
242 + with _lock:
243 + if _current:
244 + raise HTTPException(status_code=409, detail=f"mission déjà en cours: {_current.get('mission_id')}")
245 + _current.update({"mission_id": m.mission_id, "service": m.service, "source": m.source, "started": time.time()})
246 + threading.Thread(target=run_mission, args=(m,), daemon=True).start()
247 + return {"accepted": True, "node": NODE}
248 +
249 +
250 +class RollbackIn(BaseModel):
251 + dir: str
252 + base_commit: str
253 + pm2: list[str]
254 + web_port: int
255 +
256 +
257 +@app.post("/rollback")
258 +def rollback(r: RollbackIn, x_ka_token: str | None = Header(default=None)) -> dict[str, Any]:
259 + """Revient au commit d'avant mission (reset --hard si tous les commits sont de l'agent, sinon revert)."""
260 + check_token(x_ka_token)
261 + log = git(r.dir, "log", "--format=%s", f"{r.base_commit}..HEAD").stdout.strip()
262 + msgs = log.splitlines() if log else []
263 + if not msgs:
264 + mode = "aucun_commit"
265 + elif all(s.startswith("[ka") for s in msgs):
266 + git(r.dir, "reset", "--hard", r.base_commit)
267 + mode = "reset_hard"
268 + else:
269 + rc = git(r.dir, "revert", "--no-edit", f"{r.base_commit}..HEAD")
270 + if rc.returncode != 0:
271 + git(r.dir, "revert", "--abort")
272 + git(r.dir, "reset", "--hard", r.base_commit)
273 + mode = "reset_hard(fallback)"
274 + else:
275 + mode = "revert"
276 + # Pas de git clean : les process de sync de l'app écrivent en continu,
277 + # on ne supprime jamais de fichiers non trackés apparus pendant la mission.
278 + for name in r.pm2:
279 + sh(f"pm2 restart {shlex.quote(name)} --update-env", timeout=180)
280 + time.sleep(8)
281 + h = healthcheck(r.web_port, r.pm2)
282 + return {"ok": True, "mode": mode, "health": h, "head": git(r.dir, "rev-parse", "HEAD").stdout.strip()}
283 +
284 +
285 +if __name__ == "__main__":
286 + import uvicorn
287 + uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("KA_GUARDIAN_RUNNER_PORT", "7791")))
added M4M36luster-projects/ka-guardian/M4M36luster-projects/ka-guardian/M4M36luster-projects/ka-guardian/M4M36luster-projects/ka-guardian/topology.json +62 −0
@@ -0,0 +1,62 @@
1 +{
2 + "comment": "KA Guardian — topologie des agents, services et nœuds. Source de vérité partagée orchestrateurs/runners.",
3 + "apika_monitoring_url": "http://192.168.2.82:8000/api/v1/monitoring/connectors",
4 + "runner_port": 7791,
5 + "agents": {
6 + "ka2": {
7 + "port": 8799,
8 + "domain": "www.ka2.bot",
9 + "accent": "#22d3ee",
10 + "accent2": "#0891b2",
11 + "tagline": "Gardien immobilier & local",
12 + "model": "sonnet",
13 + "services": ["louka", "immoka", "restoka"]
14 + },
15 + "ka4": {
16 + "port": 8899,
17 + "domain": "www.ka4.bot",
18 + "accent": "#fbbf24",
19 + "accent2": "#d97706",
20 + "tagline": "Gardien mobilité & quotidien",
21 + "model": "sonnet",
22 + "services": ["autoka", "foodka", "sortika"]
23 + },
24 + "ka6": {
25 + "port": 8999,
26 + "domain": "www.ka6.bot",
27 + "accent": "#a78bfa",
28 + "accent2": "#7c3aed",
29 + "tagline": "Gardien flagship — gros volumes",
30 + "model": "sonnet",
31 + "services": ["fabrika", "jobka", "creaka"],
32 + "default_for_unknown_services": true
33 + }
34 + },
35 + "nodes": {
36 + "m3u96a": { "lan_ip": "192.168.2.87", "alias": "M3U96a" },
37 + "m3u96b": { "lan_ip": "192.168.2.82", "alias": "M3U96b" },
38 + "m4m64a": { "lan_ip": "192.168.2.83", "alias": "M4M64a" },
39 + "m4m64b": { "lan_ip": "192.168.2.78", "alias": "M4M64b" }
40 + },
41 + "services": {
42 + "louka": { "app": "Lou·Ka", "node": "m3u96b", "dir": "~/apps/lou-ka", "pm2": ["lou-ka-web", "lou-ka-sync"], "web_port": 8095, "site": "https://www.lou-ka.com" },
43 + "restoka": { "app": "Resto·Ka", "node": "m3u96b", "dir": "~/apps/resto-ka", "pm2": ["resto-ka", "resto-ka-sync"], "web_port": 8115, "site": "https://www.resto-ka.com" },
44 + "creaka": { "app": "Créa·Ka", "node": "m3u96b", "dir": "~/apps/crea-ka", "pm2": ["crea-ka-web", "crea-ka-sync"], "web_port": 8160, "site": "https://www.crea-ka.com" },
45 + "immoka": { "app": "Immo·Ka", "node": "m4m64a", "dir": "~/apps/immo-ka", "pm2": ["immo-ka-web", "immo-ka-sync"], "web_port": 8096, "site": "https://www.immo-ka.com" },
46 + "fabrika": { "app": "Fabri·Ka", "node": "m4m64a", "dir": "~/fabri-ka", "pm2": ["fabri-ka-web", "fabri-ka-sync"], "web_port": 8097, "site": "https://www.fabri-ka.com" },
47 + "autoka": { "app": "Auto·Ka", "node": "m4m64b", "dir": "~/auto-ka", "pm2": ["auto-ka-web", "auto-ka-sync"], "web_port": 8095, "site": "https://www.auto-ka.com" },
48 + "foodka": { "app": "Food·Ka", "node": "m4m64b", "dir": "~/apps/food-ka", "pm2": ["food-ka-web", "food-ka-sync"], "web_port": 8097, "site": "https://www.food-ka.com" },
49 + "sortika": { "app": "Sorti·Ka", "node": "m3u96a", "dir": "~/apps/sorti-ka", "pm2": ["sorti-ka-web", "sorti-ka-sync"], "web_port": 8120, "site": "https://www.sorti-ka.com" },
50 + "jobka": { "app": "Job·Ka", "node": "m3u96a", "dir": "~/apps/job-ka", "pm2": ["job-ka-web", "job-ka-sync"], "web_port": 8096, "site": "https://www.job-ka.com" }
51 + },
52 + "policy": {
53 + "poll_interval_seconds": 300,
54 + "trigger_statuses": ["broken", "stale"],
55 + "max_concurrent_missions": 1,
56 + "max_attempts_per_incident": 3,
57 + "attempt_cooldown_hours": 6,
58 + "watch_window_hours": 8,
59 + "mission_max_turns": 70,
60 + "mission_timeout_seconds": 3600
61 + }
62 +}
modified M4M36luster-projects/ka-guardian/M4M36luster-projects/ka-guardian/M4M36luster-projects/ka-guardian/deploy/deploy.sh +2 −1
@@ -60,7 +60,8 @@ deploy_orchestrators() {
60 60 rsync -az --delete --exclude data --exclude .venv --exclude .git \
61 61 "$ROOT/" $ORCH_NODE:cluster-projects/ka-guardian/
62 62 ssh $ORCH_NODE "cd ~/cluster-projects/ka-guardian
63 − [[ -d .venv ]] || $PY -m venv .venv
63 + PY=\$(command -v /opt/homebrew/bin/python3 || command -v /opt/homebrew/bin/python3.13)
64 + [[ -d .venv ]] || \$PY -m venv .venv
64 65 ./.venv/bin/pip -q install 'fastapi>=0.110' 'uvicorn>=0.29' 'httpx>=0.27' >/dev/null
65 66 mkdir -p data logs
66 67 # retirer les anciens bots (web+bot), garder les tunnels ngrok
added M4M36luster-projects/ka-guardian/M4M36luster-projects/ka-guardian/deploy/courier.sh +36 −0
@@ -0,0 +1,36 @@
1 +#!/bin/zsh
2 +# ============================================
3 +# KA Guardian — courrier inter-nœuds (100 % zsh)
4 +# macOS 26 « Local Network Privacy » refuse le trafic LAN dès que python
5 +# (homebrew) est dans la chaîne de processus — même ses enfants ssh/curl.
6 +# Ce service launchd (binaire responsable: zsh) expédie donc les requêtes:
7 +# spool/outbox/<id>.job → ssh <nœud> curl http://127.0.0.1:<port><path>
8 +# spool/done/<id>.resp ← corps de réponse + dernière ligne = code HTTP
9 +# Format .job: ligne 1 = "<ip> <port> <path> <timeout>", reste = payload JSON.
10 +# ============================================
11 +SPOOL=$HOME/ka-guardian-spool
12 +KEY=$HOME/.ssh/ka_guardian_ed25519
13 +mkdir -p $SPOOL/outbox $SPOOL/done $SPOOL/tmp
14 +source $HOME/.ka-guardian.env 2>/dev/null || true
15 +
16 +while true; do
17 + for f in $SPOOL/outbox/*.job(N); do
18 + id=${f:t:r}
19 + hdr=$(head -1 $f)
20 + parts=(${(z)hdr})
21 + ip=$parts[1]; port=$parts[2]; path=$parts[3]; tmo=${parts[4]:-30}
22 + tail -n +2 $f | /usr/bin/ssh -i $KEY \
23 + -o StrictHostKeyChecking=accept-new -o BatchMode=yes -o ConnectTimeout=8 \
24 + -o ControlMaster=auto -o ControlPath=/tmp/kg-cm-%h -o ControlPersist=120 \
25 + simon-pierreboucher@$ip \
26 + "/usr/bin/curl -s -m $tmo -X POST http://127.0.0.1:$port$path -H 'Content-Type: application/json' -H 'X-KA-Token: $KA_GUARDIAN_TOKEN' -d @- -w '\n%{http_code}'" \
27 + > $SPOOL/tmp/$id.resp 2>>$SPOOL/courier.log
28 + rc=$?
29 + [[ $rc -ne 0 ]] && print "\ncourier_ssh_rc_$rc" >> $SPOOL/tmp/$id.resp
30 + mv $SPOOL/tmp/$id.resp $SPOOL/done/$id.resp
31 + rm -f $f
32 + done
33 + # ménage: réponses jamais réclamées depuis > 1 h
34 + for old in $SPOOL/done/*.resp(N.mh+1); do rm -f $old; done
35 + sleep 1
36 +done
modified M4M36luster-projects/ka-guardian/M4M36luster-projects/ka-guardian/deploy/deploy.sh +30 −1
@@ -21,6 +21,34 @@ if [[ ! -f $TOKEN_FILE ]]; then
21 21 fi
22 22 TOKEN_LINE=$(grep KA_GUARDIAN_TOKEN "$TOKEN_FILE")
23 23
24 +deploy_courier() {
25 + # Courrier zsh sur TOUS les nœuds (orchestrateur + runners): expédie le
26 + # trafic LAN inter-nœuds via ssh (macOS 26 Local Network Privacy).
27 + for n in $RUNNER_NODES $ORCH_NODE; do
28 + echo "=== courrier → $n"
29 + scp -q "$ROOT/deploy/courier.sh" $n:ka-guardian-courier.sh
30 + ssh $n 'chmod +x ~/ka-guardian-courier.sh
31 + cat > ~/Library/LaunchAgents/com.ka.guardian-courier.plist <<PLIST
32 +<?xml version="1.0" encoding="UTF-8"?>
33 +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
34 +<plist version="1.0"><dict>
35 + <key>Label</key><string>com.ka.guardian-courier</string>
36 + <key>ProgramArguments</key><array>
37 + <string>/bin/zsh</string>
38 + <string>/Users/simon-pierreboucher/ka-guardian-courier.sh</string>
39 + </array>
40 + <key>RunAtLoad</key><true/><key>KeepAlive</key><true/>
41 + <key>StandardOutPath</key><string>/Users/simon-pierreboucher/ka-guardian-spool/courier.log</string>
42 + <key>StandardErrorPath</key><string>/Users/simon-pierreboucher/ka-guardian-spool/courier.log</string>
43 +</dict></plist>
44 +PLIST
45 + mkdir -p ~/ka-guardian-spool
46 + launchctl unload ~/Library/LaunchAgents/com.ka.guardian-courier.plist 2>/dev/null || true
47 + launchctl load ~/Library/LaunchAgents/com.ka.guardian-courier.plist' \
48 + && echo " ✓ $n courrier ok" || echo " ✗ $n courrier KO"
49 + done
50 +}
51 +
24 52 deploy_runners() {
25 53 for n in $RUNNER_NODES; do
26 54 echo "=== runner → $n"
@@ -99,8 +127,9 @@ PLIST
99 127 }
100 128
101 129 case "${1:-all}" in
130 + courier) deploy_courier ;;
102 131 runners) deploy_runners ;;
103 132 orchestrators) deploy_orchestrators ;;
104 − all) deploy_runners; deploy_orchestrators ;;
133 + all) deploy_courier; deploy_runners; deploy_orchestrators ;;
105 134 esac
106 135 echo "terminé."
modified M4M36luster-projects/ka-guardian/M4M36luster-projects/ka-guardian/orchestrator/main.py +245 −33
@@ -55,6 +55,37 @@ def load_env_file(path: pathlib.Path) -> dict[str, str]:
55 55
56 56
57 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}")
58 89 DATA = ROOT / "data"
59 90 DATA.mkdir(exist_ok=True)
60 91 DB_PATH = DATA / f"{AGENT}.db"
@@ -136,32 +167,153 @@ def incident_event(iid: str, service: str, source: str, state: str, note: str =
136 167 "source": source, "state": state, "note": note, "ts": now()})
137 168
138 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 +
139 207 def build_prompt(service: str, source: str, health: dict[str, Any]) -> str:
140 208 svc = SERVICES[service]
141 209 sync_proc = next((p for p in svc["pm2"] if "sync" in p or "etl" in p), svc["pm2"][-1])
142 210 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}).
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', '')}).
144 212
145 −CONTEXTE SANTÉ (supervision api-ka):
213 +== CONTEXTE SANTÉ DU CONNECTEUR (supervision api-ka, scan aux 2 h) ==
146 214 - 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')})
215 +- dernier succès: {health.get('last_success')} | volume au dernier sync: {health.get('found_last')} (médiane historique: {health.get('median_found')})
148 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.
149 229
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']}.
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.
152 239
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.
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."}
160 279
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.
280 +{env}
162 281
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"]}}"""
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"]}}"""
165 317
166 318
167 319 async def dispatch(incident: sqlite3.Row, health: dict[str, Any]) -> None:
@@ -171,25 +323,31 @@ async def dispatch(incident: sqlite3.Row, health: dict[str, Any]) -> None:
171 323 ip = NODES[node]["lan_ip"]
172 324 mid = uuid.uuid4().hex[:12]
173 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"]
174 333 payload = {
175 334 "mission_id": mid, "agent": AGENT, "service": service, "source": source,
176 335 "dir": svc["dir"], "pm2": svc["pm2"], "web_port": svc["web_port"],
177 336 "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),
337 + "max_turns": max_turns,
338 + "timeout_seconds": timeout,
339 + "prompt": prompt,
181 340 "callback_url": f"http://{my_ip}:{ME['port']}/api/ingest/{mid}",
182 341 }
183 342 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:
343 + code, body = await lan_post(ip, RUNNER_PORT, "/missions", payload, timeout=25)
344 + if code == 409:
188 345 return # runner occupé, on retentera au prochain tick
189 − r.raise_for_status()
346 + if code != 200:
347 + raise ConnectionError(f"runner {code}: {body[:200]}")
190 348 except Exception as exc:
191 − with db() as c:
192 − set_incident(c, iid, detail=jdump({"erreur_dispatch": str(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()})
193 351 return
194 352 with db() as c:
195 353 c.execute("INSERT INTO missions(id,incident_id,service,source,node,state,started) VALUES(?,?,?,?,?,?,?)",
@@ -204,12 +362,10 @@ async def rollback_mission(mission: sqlite3.Row, reason: str) -> dict[str, Any]:
204 362 svc = SERVICES[mission["service"]]
205 363 ip = NODES[mission["node"]]["lan_ip"]
206 364 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()
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]}"}
213 369 except Exception as exc:
214 370 out = {"ok": False, "erreur": str(exc)}
215 371 hub.publish_sync({"kind": "rollback", "mission_id": mission["id"], "service": mission["service"],
@@ -279,7 +435,7 @@ async def tick() -> None:
279 435 with db() as c:
280 436 # 1. Missions zombies (runner mort / callback perdu)
281 437 for m in c.execute("SELECT * FROM missions WHERE state='running' AND started < ?",
282 − (now() - POLICY["mission_timeout_seconds"] - 900,)).fetchall():
438 + (now() - POLICY.get("effort_timeout_seconds", 7200) - 900,)).fetchall():
283 439 c.execute("UPDATE missions SET state='error', ended=? WHERE id=?", (now(), m["id"]))
284 440 inc = c.execute("SELECT * FROM incidents WHERE id=?", (m["incident_id"],)).fetchone()
285 441 if inc:
@@ -325,7 +481,8 @@ async def tick() -> None:
325 481 busy_nodes = {m["node"] for m in c.execute("SELECT node FROM missions WHERE state='running'").fetchall()}
326 482 nxt = c.execute(
327 483 "SELECT * FROM incidents WHERE state='open' "
328 − "ORDER BY CASE status_detected WHEN 'broken' THEN 0 WHEN 'manual' THEN 0 ELSE 1 END, created "
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 "
329 486 ).fetchall()
330 487 for inc in nxt:
331 488 if inc["service"] not in SERVICES:
@@ -365,6 +522,8 @@ async def engine() -> None:
365 522 try:
366 523 await tick()
367 524 except Exception as exc:
525 + import traceback
526 + traceback.print_exc()
368 527 hub.publish_sync({"kind": "log", "level": "error", "msg": f"tick: {exc}", "ts": now()})
369 528 await asyncio.sleep(POLICY["poll_interval_seconds"])
370 529
@@ -422,6 +581,23 @@ async def finalize(c: sqlite3.Connection, m: sqlite3.Row, etype: str, data: dict
422 581 v = verdict.get("verdict", "inconnu")
423 582 if not inc:
424 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
425 601 if not health.get("ok", True):
426 602 # L'app est tombée → rollback immédiat, quoi qu'ait dit l'agent.
427 603 asyncio.create_task(rollback_mission(m2, "healthcheck app en échec post-mission"))
@@ -508,6 +684,42 @@ async def sse(request: Request) -> StreamingResponse:
508 684 headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
509 685
510 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 +
511 723 @app.post("/api/admin/mission")
512 724 async def admin_mission(req: Request, x_ka_token: str | None = Header(default=None)) -> dict[str, Any]:
513 725 """Déclenche manuellement une mission sur un connecteur (même sain)."""
modified M4M36luster-projects/ka-guardian/M4M36luster-projects/ka-guardian/orchestrator/web/app.js +97 −38
@@ -1,4 +1,4 @@
1 −/* KA Guardian — dashboard live */
1 +/* KA Guardian — dashboard live (style Groupe KA) */
2 2 const $ = (s) => document.querySelector(s);
3 3 const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
4 4 const fmtT = (ts) => new Date(ts * 1000).toLocaleTimeString("fr-CA", { hour12: false });
@@ -6,40 +6,99 @@ const fmtD = (ts) => new Date(ts * 1000).toLocaleString("fr-CA", { dateStyle: "s
6 6 const STATE_FR = { open: "à réparer", dispatched: "envoi…", fixing: "réparation", watching: "surveillance",
7 7 cooldown: "attente", resolved: "résolu", self_healed: "auto-guéri", abandoned: "abandonné" };
8 8 const STATUSES = ["ok", "degraded", "broken", "stale"];
9 −const STATUS_FR = { ok: "ok", degraded: "dégradé", broken: "cassé", stale: "endormi" };
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" };
10 13
11 14 let STATE = null;
12 15
13 16 async function load() {
14 17 const r = await fetch("/api/state");
15 18 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();
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; }
28 90 }
29 91
30 92 function renderTiles() {
31 93 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 − }
94 + let total = 0;
95 + for (const b of Object.values(mine)) for (const st of STATUSES) total += (b.summary || {})[st] || 0;
37 96 const tiles = [
38 − [total || "—", "connecteurs surveillés", true],
97 + [total || "—", "connecteurs sous garde", true],
39 98 [s.n_active ?? 0, "incidents actifs"],
40 99 [s.n_missions ?? 0, "missions lancées"],
41 100 [s.n_resolved ?? 0, "réparations confirmées"],
42 − [s.n_rollbacks ?? 0, "rollbacks"],
101 + [s.n_rollbacks ?? 0, "rollbacks assumés"],
43 102 [s.mttr_h != null ? s.mttr_h + " h" : "—", "temps moyen de guérison"],
44 103 [s.cost_total != null ? s.cost_total + " $" : "0 $", "coût API total"],
45 104 ];
@@ -51,24 +110,23 @@ function renderIncidents() {
51 110 const active = STATE.incidents.filter((i) => !["resolved", "self_healed"].includes(i.state)).slice(0, 12);
52 111 const recent = STATE.incidents.filter((i) => ["resolved", "self_healed"].includes(i.state)).slice(0, 5);
53 112 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>
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>
55 114 <span class="badge b-${esc(i.state)}">${STATE_FR[i.state] || esc(i.state)}</span></div>`;
56 115 $("#incidents").innerHTML = (active.length || recent.length)
57 116 ? 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>`;
117 + : `<div class="empty">Aucun incident — tous les connecteurs sous garde sont sains. Le gardien veille.</div>`;
59 118 }
60 119
61 120 function renderCoverage() {
62 121 const mine = STATE.latest.mine || {};
63 122 $("#coverage").innerHTML = Object.keys(STATE.services).map((svc) => {
64 123 const meta = STATE.services[svc], sum = (mine[svc] || {}).summary || {};
65 − const total = STATUSES.reduce((a, st) => a + (sum[st] || 0), 0) || 1;
124 + const total = STATUSES.reduce((a, st) => a + (sum[st] || 0), 0);
66 125 const strip = STATUSES.filter((st) => sum[st] > 0)
67 126 .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("");
127 + const counts = STATUSES.map((st) => `<span><i class="s-${st}"></i>${sum[st] || 0} ${STATUS_FR[st]}</span>`).join("");
70 128 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>
129 + <span class="node">${esc(meta.node_alias)} · ${total || "?"} connecteurs</span></div>
72 130 <div class="strip">${strip || "<span style='flex:1;background:var(--line)'></span>"}</div>
73 131 <div class="counts">${counts}</div></div>`;
74 132 }).join("");
@@ -76,22 +134,23 @@ function renderCoverage() {
76 134
77 135 function renderMissions() {
78 136 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>`;
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>`;
83 141 };
84 142 $("#missions tbody").innerHTML = STATE.missions.map((m) => {
85 143 const commits = JSON.parse(m.commits || "[]").length;
86 − const dur = m.ended ? Math.round((m.ended - m.started) / 60) + " min" : "";
144 + const dur = m.ended ? Math.round((m.ended - m.started) / 60) + " min" : "—";
87 145 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>
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>
90 149 <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>`;
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>`;
92 151 }
93 152
94 −/* ---- feed en direct ---- */
153 +/* ---- flux en direct ---- */
95 154 const feed = $("#feed");
96 155 function feedLine(cls, tag, msg, ts) {
97 156 if (feed.querySelector(".feed-empty")) feed.innerHTML = "";
@@ -131,7 +190,7 @@ function connectSSE() {
131 190 feedLine("tool", "mission", `nouvelle mission sur ${m.service}/${m.source}`, m.ts);
132 191 refetch();
133 192 } else if (m.kind === "snapshot") {
134 − if (STATE) { STATE.latest.mine = Object.fromEntries(Object.entries(m.mine).map(([s, sum]) => [s, { summary: sum }])); renderCoverage(); }
193 + if (STATE) { STATE.latest.mine = Object.fromEntries(Object.entries(m.mine).map(([s, sum]) => [s, { summary: sum }])); renderCoverage(); renderTiles(); }
135 194 } else if (m.kind === "log") {
136 195 feedLine("incident", m.level || "log", m.msg, m.ts);
137 196 }
@@ -147,7 +206,7 @@ document.addEventListener("click", async (e) => {
147 206 const v = JSON.parse(mission.verdict || "{}");
148 207 $("#modal-title").textContent = `Mission ${mission.id} — ${mission.source} (${mission.service})`;
149 208 $("#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>` +
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>` +
151 210 events.map((ev) => {
152 211 const d = JSON.parse(ev.data || "{}");
153 212 const txt = ev.type === "tool" ? `${d.name}: ${d.input}` : ev.type === "text" ? d.text
modified M4M36luster-projects/ka-guardian/M4M36luster-projects/ka-guardian/orchestrator/web/index.html +108 −30
@@ -1,65 +1,143 @@
1 1 <!doctype html>
2 −<html lang="fr">
2 +<html lang="fr-CA">
3 3 <head>
4 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.">
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 8 <link rel="preconnect" href="https://fonts.googleapis.com">
9 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">
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 11 <link rel="stylesheet" href="/static/style.css">
12 12 </head>
13 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>
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>
23 21 </header>
24 22
25 23 <main>
26 − <section class="tiles" id="tiles"></section>
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>
27 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 ============ -->
28 95 <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>
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>
32 102 </div>
33 103 <div class="side">
34 − <div class="panel">
35 − <h2>🚨 Incidents</h2>
104 + <div class="card pad">
105 + <p class="klabel">incidents</p>
36 106 <div id="incidents" class="incidents"></div>
37 107 </div>
38 − <div class="panel">
39 − <h2>🛰 Couverture</h2>
108 + <div class="card pad">
109 + <p class="klabel">territoire surveillé</p>
40 110 <div id="coverage" class="coverage"></div>
41 111 </div>
42 112 </div>
43 113 </section>
44 114
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">
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">
48 119 <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>
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>
50 121 <tbody></tbody>
51 122 </table>
52 123 </div>
53 124 </section>
54 125 </main>
55 126
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>
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>
58 136 </footer>
59 137
60 138 <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>
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>
63 141 <div class="modal-body" id="modal-body"></div>
64 142 </div>
65 143 </div>
modified M4M36luster-projects/ka-guardian/M4M36luster-projects/ka-guardian/orchestrator/web/style.css +200 −99
@@ -1,118 +1,219 @@
1 −/* KA Guardian — salle de contrôle */
1 +/* KA Guardian — langage visuel Groupe KA : papier, encre, lime, ombres décalées dures */
2 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;
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);
9 20 }
10 21 * { 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; }
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); }
13 26
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; }
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 32
33 −main { padding: 22px clamp(16px, 4vw, 48px); display: grid; gap: 20px; max-width: 1500px; margin: 0 auto; }
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; }
34 36
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); }
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); }
40 42
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; }
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); }
44 84
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; }
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; }
48 123
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); }
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; }
53 135 .fe { display: flex; gap: 10px; align-items: baseline; animation: fadein 0.3s; }
54 136 @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); }
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; }
64 146
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; }
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; }
80 162
81 −/* Couverture */
163 +/* ---------- couverture ---------- */
82 164 .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); }
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; }
88 170 .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); }
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; }
92 175 .counts i { display: inline-block; width: 8px; height: 8px; border-radius: 2px; margin-right: 5px; }
93 176
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); }
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); }
105 193
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; }
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; }
108 205
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; }
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); }
modified M4M36luster-projects/ka-guardian/M4M36luster-projects/ka-guardian/runner/runner.py +21 −1
@@ -22,6 +22,7 @@ import socket
22 22 import subprocess
23 23 import threading
24 24 import time
25 +import uuid
25 26 from typing import Any
26 27
27 28 import httpx
@@ -113,9 +114,28 @@ class MissionIn(BaseModel):
113 114 callback_url: str # http://<orchestrateur>/api/ingest/<mission_id>
114 115
115 116
117 +SPOOL = HOME / "ka-guardian-spool"
118 +
119 +
116 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 + """
117 127 try:
118 − httpx.post(url, json=payload, headers={"X-KA-Token": TOKEN}, timeout=15)
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")
119 139 except Exception:
120 140 pass # l'orchestrateur relira le transcript au besoin
121 141
modified M4M36luster-projects/ka-guardian/M4M36luster-projects/ka-guardian/topology.json +4 −2
@@ -1,6 +1,6 @@
1 1 {
2 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",
3 + "apika_monitoring_url": "https://www.api-ka.com/api/v1/monitoring/connectors",
4 4 "runner_port": 7791,
5 5 "agents": {
6 6 "ka2": {
@@ -57,6 +57,8 @@
57 57 "attempt_cooldown_hours": 6,
58 58 "watch_window_hours": 8,
59 59 "mission_max_turns": 70,
60 − "mission_timeout_seconds": 3600
60 + "mission_timeout_seconds": 3600,
61 + "effort_max_turns": 150,
62 + "effort_timeout_seconds": 7200
61 63 }
62 64 }
modified M4M36luster-projects/ka-guardian/deploy/courier.sh +17 −8
@@ -8,6 +8,7 @@
8 8 # spool/done/<id>.resp ← corps de réponse + dernière ligne = code HTTP
9 9 # Format .job: ligne 1 = "<ip> <port> <path> <timeout>", reste = payload JSON.
10 10 # ============================================
11 +export PATH=/usr/bin:/bin:/usr/sbin:/sbin # launchd démarre sans PATH
11 12 SPOOL=$HOME/ka-guardian-spool
12 13 KEY=$HOME/.ssh/ka_guardian_ed25519
13 14 mkdir -p $SPOOL/outbox $SPOOL/done $SPOOL/tmp
@@ -18,14 +19,22 @@ while true; do
18 19 id=${f:t:r}
19 20 hdr=$(head -1 $f)
20 21 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=$?
22 + # NB: ne JAMAIS nommer une variable `path` en zsh — c'est le tableau
23 + # spécial lié à PATH (l'assigner détruit le PATH du process).
24 + ip=$parts[1]; port=$parts[2]; jpath=$parts[3]; tmo=${parts[4]:-30}
25 + # 3 tentatives: un destinataire qui redémarre ne doit pas perdre le message.
26 + rc=1
27 + for attempt in 1 2 3; do
28 + tail -n +2 $f | /usr/bin/ssh -i $KEY \
29 + -o StrictHostKeyChecking=accept-new -o BatchMode=yes -o ConnectTimeout=8 \
30 + -o ControlMaster=auto -o ControlPath=/tmp/kg-cm-%h -o ControlPersist=120 \
31 + simon-pierreboucher@$ip \
32 + "/usr/bin/curl -s -m $tmo --retry 2 --retry-connrefused -X POST http://127.0.0.1:$port$jpath -H 'Content-Type: application/json' -H 'X-KA-Token: $KA_GUARDIAN_TOKEN' -d @- -w '\n%{http_code}'" \
33 + > $SPOOL/tmp/$id.resp 2>>$SPOOL/courier.log
34 + rc=$?
35 + [[ $rc -eq 0 ]] && break
36 + sleep 3
37 + done
29 38 [[ $rc -ne 0 ]] && print "\ncourier_ssh_rc_$rc" >> $SPOOL/tmp/$id.resp
30 39 mv $SPOOL/tmp/$id.resp $SPOOL/done/$id.resp
31 40 rm -f $f
modified M4M36luster-projects/ka-guardian/deploy/deploy.sh +13 −2
@@ -52,6 +52,13 @@ PLIST
52 52 deploy_runners() {
53 53 for n in $RUNNER_NODES; do
54 54 echo "=== runner → $n"
55 + # GOTCHA: redémarrer un runner TUE la mission claude en cours (cause de la
56 + # mission fantôme du 2026-08-23). On refuse si le runner est occupé.
57 + busy=$(ssh $n "curl -s -m 5 localhost:7791/health 2>/dev/null" | grep -o '"busy":true' || true)
58 + if [[ -n $busy ]]; then
59 + echo " ⏸ $n occupé (mission en cours) — runner NON redéployé, relance plus tard"
60 + continue
61 + fi
55 62 ssh $n "mkdir -p ~/ka-guardian-runner/transcripts"
56 63 scp -q "$ROOT/runner/runner.py" $n:ka-guardian-runner/runner.py
57 64 ssh $n "printf '%s\nKA_GUARDIAN_NODE=%s\n' '$TOKEN_LINE' '$n' > ~/.ka-guardian.env
@@ -85,8 +92,12 @@ PLIST
85 92 deploy_orchestrators() {
86 93 echo "=== orchestrateurs → $ORCH_NODE"
87 94 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/
95 + cd "$ROOT"
96 + rsync -az --delete --exclude data --exclude .venv --exclude .git --exclude __pycache__ \
97 + ./ $ORCH_NODE:cluster-projects/ka-guardian/
98 + # vérification: le fichier déployé DOIT être identique (gotcha rsync silencieux)
99 + loc=$(md5 -q orchestrator/main.py); rem=$(ssh $ORCH_NODE 'md5 -q ~/cluster-projects/ka-guardian/orchestrator/main.py')
100 + [[ "$loc" == "$rem" ]] || { echo "✗ rsync n'a pas mis à jour main.py ($loc ≠ $rem)"; exit 1; }
90 101 ssh $ORCH_NODE "cd ~/cluster-projects/ka-guardian
91 102 PY=\$(command -v /opt/homebrew/bin/python3 || command -v /opt/homebrew/bin/python3.13)
92 103 [[ -d .venv ]] || \$PY -m venv .venv
modified M4M36luster-projects/ka-guardian/orchestrator/main.py +127 −18
@@ -273,6 +273,37 @@ def build_effort_prompt(kind: str, service: str, source: str, note: str) -> str:
273 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 274 L'app tourne via pm2 ({', '.join(svc['pm2'])}); process de sync: {sync_proc}; site local port {svc['web_port']}.
275 275 Commence par lire le CLAUDE.md / README du repo et docs/connecteurs/ s'ils existent."""
276 + if kind == "effort_degrade":
277 + block = LATEST["mine"].get(service) or {}
278 + degraded = [c for c in block.get("connectors", []) if c.get("status") == "degraded"][:25]
279 + deg_lines = "\n".join(
280 + f"- {c['source']}: dernier volume {c.get('found_last')} vs médiane {round(c.get('median_found') or 0)} "
281 + f"(dernier succès: {c.get('last_success')}, message: {c.get('message')})"
282 + for c in degraded) or "- (aucun connecteur dégradé au dernier scan — re-vérifie l'état réel dans l'app)"
283 + return f"""Tu es {AGENT}, agent gardien autonome du Groupe KA. EFFORT COMMANDÉ: INSPECTER LES CONNECTEURS DÉGRADÉS de l'app {svc['app']} (service {service}, site {svc.get('site', '')}).
284 +{"Consigne de l'opérateur: " + note if note else ""}
285 +
286 +Un connecteur « dégradé » livre encore des données, mais moins de 50 % de sa médiane historique — souvent le signe d'une pagination cassée, d'un filtre qui se resserre, d'une section du site source disparue ou d'un blocage partiel.
287 +
288 +== CONNECTEURS DÉGRADÉS AU DERNIER SCAN api-ka ==
289 +{deg_lines}
290 +
291 +{env}
292 +
293 +== DÉMARCHE ==
294 +1. TRIE: pour chaque connecteur dégradé, regarde vite (logs + un fetch de contrôle) si la baisse est (a) réelle et réparable, (b) légitime (le site source a vraiment moins d'items — saison, inventaire réduit), ou (c) un blocage.
295 +2. PRIORISE les cas (a) au plus fort potentiel de volume récupéré, et répare-les UN PAR UN: cause racine, correctif minimal, test réel avec volume mesuré avant/après. Commit séparé par connecteur réparé ([{AGENT}] fix connecteur <source>: …).
296 +3. Pour les cas (b), ne touche à rien: consigne-les dans ton rapport final comme « baisse légitime ».
297 +4. Traite autant de connecteurs que ton budget de temps le permet, en gardant la qualité: mieux vaut 3 vraies réparations prouvées que 10 rustines.
298 +5. À la fin: `pm2 restart {sync_proc}`, vérifie le site (port {svc['web_port']}).
299 +
300 +{effort_stack(service)}
301 +
302 +{rules}
303 +
304 +== FIN DE MISSION ==
305 +Termine ta TOUTE DERNIÈRE réponse par un bloc JSON:
306 +{{"verdict": "livre|echec", "diagnostic": "portrait global des dégradés", "actions": "connecteurs réparés (avec volumes avant/après) / baisses légitimes / blocages", "test": "mesures", "fichiers": ["fichiers modifiés"]}}"""
276 307 if kind == "effort_new":
277 308 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 309 {"Consigne de l'opérateur: " + note if note else "Aucune consigne particulière: choisis la source la plus utile."}
@@ -324,9 +355,16 @@ async def dispatch(incident: sqlite3.Row, health: dict[str, Any]) -> None:
324 355 mid = uuid.uuid4().hex[:12]
325 356 my_ip = os.environ.get("KA_GUARDIAN_SELF_IP", "192.168.2.69")
326 357 kind = incident["status_detected"]
327 − if kind in ("effort_new", "effort_enrich"):
358 + max_cost = None
359 + if kind in ("effort_new", "effort_enrich", "effort_degrade"):
328 360 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)
361 + max_turns = POLICY.get("effort_max_turns", 150)
362 + timeout = POLICY.get("effort_timeout_seconds", 7200)
363 + # Plafonds choisis par l'opérateur au moment de commander l'effort.
364 + if health.get("max_minutes"):
365 + timeout = min(timeout, int(health["max_minutes"]) * 60)
366 + if health.get("max_cost_usd"):
367 + max_cost = float(health["max_cost_usd"])
330 368 else:
331 369 prompt = build_prompt(service, source, health)
332 370 max_turns, timeout = POLICY["mission_max_turns"], POLICY["mission_timeout_seconds"]
@@ -336,6 +374,7 @@ async def dispatch(incident: sqlite3.Row, health: dict[str, Any]) -> None:
336 374 "model": ME.get("model", "sonnet"),
337 375 "max_turns": max_turns,
338 376 "timeout_seconds": timeout,
377 + "max_cost_usd": max_cost,
339 378 "prompt": prompt,
340 379 "callback_url": f"http://{my_ip}:{ME['port']}/api/ingest/{mid}",
341 380 }
@@ -433,14 +472,13 @@ async def tick() -> None:
433 472 hub.publish_sync({"kind": "log", "level": "warn", "msg": f"poll api-ka échoué: {exc}", "ts": now()})
434 473
435 474 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)")
475 + # 1. Réconciliation des missions en cours depuis > 10 min: on demande au
476 + # runner ce qu'il en est (événement final perdu? runner redémarré?).
477 + for m in c.execute("SELECT id FROM missions WHERE state='running' AND started < ?",
478 + (now() - 600,)).fetchall():
479 + if m["id"] not in RECONCILING:
480 + RECONCILING.add(m["id"])
481 + asyncio.create_task(reconcile_mission(m["id"]))
444 482
445 483 # 2. Watching → rollback si la fenêtre est passée et toujours cassé
446 484 for inc in c.execute("SELECT * FROM incidents WHERE state='watching'").fetchall():
@@ -514,6 +552,48 @@ async def dispatch_row(iid: str) -> None:
514 552 set_incident(c, iid, state="open")
515 553
516 554
555 +RECONCILING: set[str] = set()
556 +
557 +
558 +async def reconcile_mission(mid: str) -> None:
559 + """Résout une mission « en cours » suspecte auprès de son runner."""
560 + try:
561 + with db() as c:
562 + m = c.execute("SELECT * FROM missions WHERE id=? AND state='running'", (mid,)).fetchone()
563 + if not m:
564 + return
565 + ip = NODES[m["node"]]["lan_ip"]
566 + try:
567 + code, body = await lan_post(ip, RUNNER_PORT, "/mission-result", {"mission_id": mid}, timeout=20)
568 + resp = json.loads(body) if code == 200 else {"status": "erreur"}
569 + except Exception:
570 + return # courrier/runner injoignable: on retentera au prochain tick
571 + if resp.get("status") == "running":
572 + return
573 + with db() as c:
574 + m = c.execute("SELECT * FROM missions WHERE id=? AND state='running'", (mid,)).fetchone()
575 + if not m:
576 + return
577 + if resp.get("status") == "done":
578 + data = (resp.get("final") or {}).get("data", {})
579 + c.execute("INSERT INTO events(mission_id,ts,type,data) VALUES(?,?,?,?)",
580 + (mid, now(), "final", jdump(data)))
581 + await finalize(c, m, "final", data)
582 + hub.publish_sync({"kind": "mission_event", "mission_id": mid, "type": "final",
583 + "data": data, "ts": now()})
584 + elif now() - m["started"] > 900:
585 + # Le runner ne connaît pas cette mission: tuée en vol
586 + # (redémarrage). On la clôt et on relance l'incident.
587 + c.execute("UPDATE missions SET state='error', ended=? WHERE id=?", (now(), mid))
588 + inc = c.execute("SELECT * FROM incidents WHERE id=?", (m["incident_id"],)).fetchone()
589 + if inc and inc["state"] in ("fixing", "dispatched"):
590 + set_incident(c, inc["id"], state="open")
591 + incident_event(inc["id"], m["service"], m["source"], "open",
592 + "mission perdue (runner interrompu) — remise en file")
593 + finally:
594 + RECONCILING.discard(mid)
595 +
596 +
517 597 async def engine() -> None:
518 598 global LOOP
519 599 LOOP = asyncio.get_running_loop()
@@ -581,7 +661,7 @@ async def finalize(c: sqlite3.Connection, m: sqlite3.Row, etype: str, data: dict
581 661 v = verdict.get("verdict", "inconnu")
582 662 if not inc:
583 663 return
584 − if inc["status_detected"] in ("effort_new", "effort_enrich"):
664 + if inc["status_detected"] in ("effort_new", "effort_enrich", "effort_degrade"):
585 665 # Les efforts commandés ne passent pas par la surveillance api-ka:
586 666 # verdict + santé de l'app décident tout de suite.
587 667 if not health.get("ok", True):
@@ -592,11 +672,16 @@ async def finalize(c: sqlite3.Connection, m: sqlite3.Row, etype: str, data: dict
592 672 set_incident(c, inc["id"], state="resolved", resolved=now())
593 673 incident_event(inc["id"], m["service"], m["source"], "resolved",
594 674 f"effort livré ({len(commits)} commit{'s' if len(commits) > 1 else ''})")
675 + elif v == "echec" and commits:
676 + asyncio.create_task(rollback_mission(m2, "effort en échec déclaré → rollback préventif"))
677 + set_incident(c, inc["id"], state="abandoned")
678 + incident_event(inc["id"], m["service"], m["source"], "abandoned", "effort en échec → rollback")
595 679 else:
596 − if commits:
597 − asyncio.create_task(rollback_mission(m2, f"effort verdict {v} → rollback préventif"))
680 + # Session interrompue (plafond durée/coût) ou verdict illisible:
681 + # l'app est saine, les commits incrémentaux testés sont conservés.
598 682 set_incident(c, inc["id"], state="abandoned")
599 − incident_event(inc["id"], m["service"], m["source"], "abandoned", f"effort en échec ({v})")
683 + incident_event(inc["id"], m["service"], m["source"], "abandoned",
684 + f"effort terminé sans verdict livré ({v}) — {len(commits)} commit(s) conservé(s), app saine")
600 685 return
601 686 if not health.get("ok", True):
602 687 # L'app est tombée → rollback immédiat, quoi qu'ait dit l'agent.
@@ -641,7 +726,7 @@ def state() -> dict[str, Any]:
641 726 history = [dict(r) for r in c.execute(
642 727 "SELECT ts, mine FROM snapshots WHERE ts > ? ORDER BY ts", (now() - 7 * 86400,)).fetchall()]
643 728 return {
644 − "agent": AGENT, "identity": {k: ME[k] for k in ("domain", "accent", "accent2", "tagline", "model")},
729 + "agent": AGENT, "identity": {k: ME.get(k) for k in ("domain", "accent", "accent2", "accent_soft", "tagline", "model")},
645 730 "services": {s: {**SERVICES[s], "node_alias": NODES[SERVICES[s]["node"]]["alias"]} for s in ME["services"]},
646 731 "siblings": {a: {"domain": v["domain"], "accent": v["accent"], "services": v["services"]}
647 732 for a, v in TOPO["agents"].items() if a != AGENT},
@@ -702,21 +787,35 @@ async def admin_effort(req: Request, x_ka_token: str | None = Header(default=Non
702 787 kind = body.get("kind")
703 788 service = body.get("service")
704 789 note = (body.get("note") or "").strip()[:2000]
705 − if kind not in ("effort_new", "effort_enrich") or service not in SERVICES:
790 + if kind not in ("effort_new", "effort_enrich", "effort_degrade") or service not in SERVICES:
706 791 raise HTTPException(status_code=400, detail="kind ou service invalide")
707 792 if kind == "effort_enrich":
708 793 source = (body.get("source") or "").strip()
709 794 if not source:
710 795 raise HTTPException(status_code=400, detail="source requise pour un enrichissement")
796 + elif kind == "effort_degrade":
797 + source = f"dégradés·{uuid.uuid4().hex[:6]}"
711 798 else:
712 799 source = f"nouveau·{uuid.uuid4().hex[:6]}"
800 + detail: dict[str, Any] = {"note": note, "kind": kind}
801 + try:
802 + if body.get("max_minutes"):
803 + detail["max_minutes"] = max(10, min(240, int(body["max_minutes"])))
804 + if body.get("max_cost_usd"):
805 + detail["max_cost_usd"] = max(0.5, min(100.0, float(body["max_cost_usd"])))
806 + except (TypeError, ValueError):
807 + raise HTTPException(status_code=400, detail="max_minutes/max_cost_usd invalides")
713 808 iid = uuid.uuid4().hex[:10]
714 809 with db() as c:
715 810 c.execute("INSERT INTO incidents(id,service,source,status_detected,state,created,updated,detail) "
716 811 "VALUES(?,?,?,?,?,?,?,?)",
717 − (iid, service, source, kind, "open", now(), now(), jdump({"note": note, "kind": kind})))
812 + (iid, service, source, kind, "open", now(), now(), jdump(detail)))
813 + labels = {"effort_new": "nouveau connecteur", "effort_enrich": f"enrichissement de {source}",
814 + "effort_degrade": "inspection des connecteurs dégradés"}
718 815 incident_event(iid, service, source, "open",
719 − "effort commandé: " + ("nouveau connecteur" if kind == "effort_new" else f"enrichissement de {source}"))
816 + f"effort commandé: {labels[kind]}"
817 + + (f" · ≤{detail['max_minutes']} min" if detail.get("max_minutes") else "")
818 + + (f" · ≤{detail['max_cost_usd']} $" if detail.get("max_cost_usd") else ""))
720 819 return {"ok": True, "incident_id": iid}
721 820
722 821
@@ -759,11 +858,21 @@ def health() -> dict[str, Any]:
759 858 return {"ok": True, "agent": AGENT, "polled": LATEST["polled"], "paused": LATEST["paused"]}
760 859
761 860
861 +@app.get("/favicon.svg")
862 +def favicon() -> FileResponse:
863 + return FileResponse(BASE / "web" / f"favicon-{AGENT}.svg", media_type="image/svg+xml")
864 +
865 +
762 866 @app.get("/")
763 867 def index() -> FileResponse:
764 868 return FileResponse(BASE / "web" / "index.html")
765 869
766 870
871 +@app.get("/commander")
872 +def commander() -> FileResponse:
873 + return FileResponse(BASE / "web" / "commander.html")
874 +
875 +
767 876 app.mount("/static", StaticFiles(directory=BASE / "web"), name="static")
768 877
769 878
modified M4M36luster-projects/ka-guardian/orchestrator/web/app.js +10 −60
@@ -7,7 +7,8 @@ const STATE_FR = { open: "à réparer", dispatched: "envoi…", fixing: "répara
7 7 cooldown: "attente", resolved: "résolu", self_healed: "auto-guéri", abandoned: "abandonné" };
8 8 const STATUSES = ["ok", "degraded", "broken", "stale"];
9 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" };
10 + effort_new: "effort · nouveau connecteur", effort_enrich: "effort · enrichissement",
11 + effort_degrade: "effort · inspection dégradés" };
11 12 const VERDICT_FR = { repare: "réparé", livre: "livré", echec: "échec", site_source_mort: "source morte",
12 13 rien_a_faire: "rien à faire", inconnu: "inconnu", erreur: "erreur" };
13 14
@@ -17,7 +18,13 @@ async function load() {
17 18 const r = await fetch("/api/state");
18 19 STATE = await r.json();
19 20 const id = STATE.identity, num = STATE.agent.replace("ka", "");
20 − document.documentElement.style.setProperty("--agent", id.accent);
21 + // Thème par agent (bleu ka2 / orange ka4 / mauve ka6): l'accent de l'agent
22 + // remplace le lime du design system sur tout son site.
23 + const rs = document.documentElement.style;
24 + rs.setProperty("--agent", id.accent);
25 + rs.setProperty("--lime", id.accent);
26 + if (id.accent_soft) rs.setProperty("--lime-soft", id.accent_soft);
27 + if (id.accent2) rs.setProperty("--accent-deep", id.accent2);
21 28 document.title = `${STATE.agent.toUpperCase()} Guardian — Groupe KA`;
22 29 $("#wm-name").textContent = "ka·" + num;
23 30 $("#foot-name").textContent = "ka·" + num;
@@ -29,64 +36,7 @@ async function load() {
29 36 $("#foot-sites").innerHTML =
30 37 Object.values(STATE.services).map((s) => `<a href="${s.site}">${esc(s.app)}</a>`).join("") +
31 38 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; }
39 + renderTiles(); renderIncidents(); renderCoverage(); renderMissions();
90 40 }
91 41
92 42 function renderTiles() {
added M4M36luster-projects/ka-guardian/orchestrator/web/commander.html +109 −0
@@ -0,0 +1,109 @@
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>Commander — KA Guardian</title>
7 +<meta name="description" content="Poste de commande de l'agent gardien : lancer une session Claude Code 100 % autonome — nouveau connecteur, enrichissement ou inspection des connecteurs dégradés — avec plafonds de durée et de coût.">
8 +<link rel="icon" type="image/svg+xml" href="/favicon.svg">
9 +<link rel="preconnect" href="https://fonts.googleapis.com">
10 +<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
11 +<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">
12 +<link rel="stylesheet" href="/static/style.css">
13 +</head>
14 +<body>
15 +
16 +<header class="topbar">
17 + <a class="wordmark" href="/"><span id="wm-name">ka·—</span><span class="ka">guardian</span></a>
18 + <nav class="tabs">
19 + <a class="tab" href="/">⚡ Flux</a>
20 + <a class="tab active" href="/commander">🎛 Commander</a>
21 + </nav>
22 + <nav class="topnav">
23 + <a class="gk-badge" href="https://www.groupe-ka.com">groupe<b><span class="ka">·KA</span></b></a>
24 + <span id="siblings"></span>
25 + </nav>
26 +</header>
27 +
28 +<main class="cmd-page">
29 + <header class="cmd-hero">
30 + <p class="klabel" id="hero-label">poste de commande</p>
31 + <h1>Donne-lui du <span class="hl">travail</span>.</h1>
32 + <p class="lede">Une session Claude Code <b>100 % autonome</b> démarre sur le nœud de la
33 + plateforme choisie : elle découvre (Serper), escalade s'il le faut (proxys résidentiels,
34 + Scrapfly, acteurs Apify), teste pour vrai, committe — et travaille jusqu'au bout sans rien
35 + demander. Tu fixes la laisse : durée max et budget max. Tout s'affiche dans
36 + <a href="/" style="font-weight:700; text-decoration:underline; text-underline-offset:4px">le flux ⚡</a>.</p>
37 + </header>
38 +
39 + <section class="commander card">
40 + <form id="effort-form" class="cmd-form">
41 + <div class="frow frow-3">
42 + <label class="flab">Type d'effort
43 + <select id="ef-kind" class="select">
44 + <option value="effort_new">➕ Nouveau connecteur — découverte + construction</option>
45 + <option value="effort_enrich">⤴ Enrichir un connecteur existant</option>
46 + <option value="effort_degrade">🩺 Inspecter les connecteurs dégradés</option>
47 + </select>
48 + </label>
49 + <label class="flab">Plateforme
50 + <select id="ef-service" class="select"></select>
51 + </label>
52 + <label class="flab" id="ef-source-wrap" hidden>Connecteur à enrichir
53 + <select id="ef-source" class="select"></select>
54 + </label>
55 + </div>
56 + <p class="kind-hint" id="kind-hint"></p>
57 + <label class="flab">Consigne (optionnel)
58 + <input id="ef-note" class="input" placeholder="ex.: vise les microbrasseries de la Côte-Nord, ajoute les fiches détail…">
59 + </label>
60 + <div class="frow frow-3">
61 + <label class="flab">Durée max
62 + <select id="ef-minutes" class="select">
63 + <option value="30">30 minutes</option>
64 + <option value="60" selected>1 heure</option>
65 + <option value="120">2 heures</option>
66 + <option value="240">4 heures</option>
67 + </select>
68 + </label>
69 + <label class="flab">Coût max (API, estimé en direct)
70 + <select id="ef-cost" class="select">
71 + <option value="2">2 $</option>
72 + <option value="5" selected>5 $</option>
73 + <option value="10">10 $</option>
74 + <option value="25">25 $</option>
75 + <option value="50">50 $</option>
76 + </select>
77 + </label>
78 + <label class="flab">Jeton d'opérateur
79 + <input id="ef-token" type="password" class="input" placeholder="KA_GUARDIAN_TOKEN" autocomplete="off">
80 + </label>
81 + </div>
82 + <div class="frow frow-end">
83 + <p class="cmd-note">Plafond atteint → la session s'arrête proprement ; les réparations
84 + déjà committées et testées sont conservées si la plateforme est saine.</p>
85 + <button type="submit" class="btn-primary">Lancer l'effort →</button>
86 + </div>
87 + <p class="cmd-msg" id="ef-msg"></p>
88 + </form>
89 + </section>
90 +
91 + <section>
92 + <p class="klabel">efforts commandés récents</p>
93 + <div id="efforts-list" class="incidents" style="max-height:none"></div>
94 + </section>
95 +</main>
96 +
97 +<footer class="ka-footer">
98 + <div class="foot-in">
99 + <a class="wordmark wordmark-foot" href="/"><span id="foot-name">ka·—</span><span class="ka">guardian</span></a>
100 + <p class="notice"><b>La laisse est réelle.</b> Chaque effort est plafonné en durée et en coût,
101 + journalisé action par action, et annulable par rollback git. L'agent travaille seul ;
102 + il ne décide jamais seul de ce qui compte.</p>
103 + <p class="legal">Groupe KA — agrégation automatisée, Québec. <a href="https://www.groupe-ka.com">groupe-ka.com</a></p>
104 + </div>
105 +</footer>
106 +
107 +<script src="/static/commander.js"></script>
108 +</body>
109 +</html>
added M4M36luster-projects/ka-guardian/orchestrator/web/commander.js +101 −0
@@ -0,0 +1,101 @@
1 +/* KA Guardian — poste de commande (/commander) */
2 +const $ = (s) => document.querySelector(s);
3 +const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
4 +const STATUS_FR = { ok: "ok", degraded: "dégradé", broken: "cassé", stale: "endormi" };
5 +const STATE_FR = { open: "en file", dispatched: "envoi…", fixing: "en cours", watching: "surveillance",
6 + cooldown: "attente", resolved: "livré", self_healed: "auto-guéri", abandoned: "terminé" };
7 +const KIND_FR = { effort_new: "nouveau connecteur", effort_enrich: "enrichissement", effort_degrade: "inspection dégradés" };
8 +const HINTS = {
9 + effort_new: "L'agent cartographie l'existant, découvre des sources québécoises non couvertes (Serper), choisit la meilleure et construit le connecteur complet — testé en volume réel.",
10 + effort_enrich: "L'agent maximise la valeur du connecteur choisi : pagination complète, champs des fiches détail, robustesse, détection des retraits — mesuré avant/après.",
11 + effort_degrade: "L'agent inspecte tous les connecteurs dégradés de la plateforme (volume < 50 % de la médiane), trie baisse réelle / légitime / blocage, et répare un par un — un commit par connecteur.",
12 +};
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 + const rs = document.documentElement.style;
21 + rs.setProperty("--agent", id.accent); rs.setProperty("--lime", id.accent);
22 + if (id.accent_soft) rs.setProperty("--lime-soft", id.accent_soft);
23 + if (id.accent2) rs.setProperty("--accent-deep", id.accent2);
24 + document.title = `Commander — ${STATE.agent.toUpperCase()} Guardian`;
25 + $("#wm-name").textContent = "ka·" + num;
26 + $("#foot-name").textContent = "ka·" + num;
27 + $("#hero-label").textContent = `${STATE.agent} — poste de commande · ${id.tagline}`;
28 + $("#siblings").innerHTML = Object.entries(STATE.siblings).map(([a, v]) =>
29 + `<a class="gk-badge" href="https://${v.domain}/commander"><span class="dot" style="background:${v.accent}"></span><b>${a}<span class="ka">·G</span></b></a>`).join(" ");
30 + const svcSel = $("#ef-service");
31 + if (!svcSel.options.length) {
32 + svcSel.innerHTML = Object.entries(STATE.services)
33 + .map(([k, s]) => `<option value="${k}">${esc(s.app)} — ${esc(s.node_alias)}</option>`).join("");
34 + }
35 + renderEfforts();
36 +}
37 +
38 +function renderEfforts() {
39 + const efforts = STATE.incidents.filter((i) => (i.status_detected || "").startsWith("effort"));
40 + $("#efforts-list").innerHTML = efforts.slice(0, 20).map((i) => {
41 + const d = JSON.parse(i.detail || "{}");
42 + return `<div class="inc">
43 + <div><div class="src">${esc(KIND_FR[i.status_detected] || i.status_detected)} — ${esc((STATE.services[i.service] || {}).app || i.service)}</div>
44 + <div class="svc">${esc(i.source)}${d.note ? " · « " + esc(d.note.slice(0, 80)) + " »" : ""}${d.max_minutes ? " · ≤" + d.max_minutes + " min" : ""}${d.max_cost_usd ? " · ≤" + d.max_cost_usd + " $" : ""}</div></div>
45 + <span class="badge b-${esc(i.state)}">${STATE_FR[i.state] || esc(i.state)}</span></div>`;
46 + }).join("") || `<div class="empty">Aucun effort commandé encore — sois le premier à lui donner du travail.</div>`;
47 +}
48 +
49 +function syncKind() {
50 + const kind = $("#ef-kind").value;
51 + $("#ef-source-wrap").hidden = kind !== "effort_enrich";
52 + $("#kind-hint").textContent = HINTS[kind] || "";
53 + if (kind === "effort_enrich") fillSources();
54 +}
55 +
56 +async function fillSources() {
57 + const r = await fetch("/api/connectors/" + $("#ef-service").value);
58 + const d = await r.json();
59 + $("#ef-source").innerHTML = d.connectors
60 + .map((c) => `<option value="${esc(c.source)}">${esc(c.source)} (${STATUS_FR[c.status] || c.status})</option>`).join("")
61 + || `<option value="">— aucun connecteur connu —</option>`;
62 +}
63 +
64 +async function submitEffort(e) {
65 + e.preventDefault();
66 + const msg = $("#ef-msg"), btn = e.target.querySelector("button");
67 + const token = $("#ef-token").value.trim();
68 + if (!token) { msg.className = "cmd-msg err"; msg.textContent = "jeton d'opérateur requis"; return; }
69 + localStorage.setItem("ka_token", token);
70 + btn.disabled = true; msg.className = "cmd-msg"; msg.textContent = "lancement…";
71 + try {
72 + const r = await fetch("/api/admin/effort", {
73 + method: "POST", headers: { "Content-Type": "application/json", "X-KA-Token": token },
74 + body: JSON.stringify({
75 + kind: $("#ef-kind").value, service: $("#ef-service").value,
76 + source: $("#ef-source").value || "", note: $("#ef-note").value,
77 + max_minutes: parseInt($("#ef-minutes").value, 10),
78 + max_cost_usd: parseFloat($("#ef-cost").value),
79 + }),
80 + });
81 + const d = await r.json();
82 + if (r.ok && d.ok) {
83 + msg.className = "cmd-msg ok";
84 + msg.textContent = `effort accepté (${d.incident_id}) — la session démarre d'ici ~5 min. Suis-la sur le flux ⚡`;
85 + $("#ef-note").value = "";
86 + load();
87 + } else {
88 + msg.className = "cmd-msg err";
89 + msg.textContent = "refusé: " + (d.detail || r.status);
90 + }
91 + } catch (err) {
92 + msg.className = "cmd-msg err"; msg.textContent = "erreur: " + err;
93 + } finally { btn.disabled = false; }
94 +}
95 +
96 +$("#ef-kind").addEventListener("change", syncKind);
97 +$("#ef-service").addEventListener("change", () => { if ($("#ef-kind").value === "effort_enrich") fillSources(); });
98 +$("#effort-form").addEventListener("submit", submitEffort);
99 +$("#ef-token").value = localStorage.getItem("ka_token") || "";
100 +load().then(syncKind);
101 +setInterval(load, 30000);
added M4M36luster-projects/ka-guardian/orchestrator/web/favicon-ka2.svg +7 −0
@@ -0,0 +1,7 @@
1 +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
2 + <!-- KA Guardian ka2 — encre + pastille lime ·Ka + battement (surveillance) -->
3 + <rect width="64" height="64" rx="14" fill="#141814"/>
4 + <circle cx="51" cy="13" r="4.5" fill="#d9f26b"/>
5 + <text x="30" y="36" font-family="'JetBrains Mono','Menlo',ui-monospace,monospace" font-size="26" font-weight="700" fill="#f5f3ee" text-anchor="middle" letter-spacing="-1">K2</text>
6 + <polyline points="10,48 21,48 26,41 32,54 37,44 41,48 54,48" fill="none" stroke="#5b9df5" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"/>
7 +</svg>
added M4M36luster-projects/ka-guardian/orchestrator/web/favicon-ka4.svg +7 −0
@@ -0,0 +1,7 @@
1 +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
2 + <!-- KA Guardian ka4 — encre + pastille lime ·Ka + battement (surveillance) -->
3 + <rect width="64" height="64" rx="14" fill="#141814"/>
4 + <circle cx="51" cy="13" r="4.5" fill="#d9f26b"/>
5 + <text x="30" y="36" font-family="'JetBrains Mono','Menlo',ui-monospace,monospace" font-size="26" font-weight="700" fill="#f5f3ee" text-anchor="middle" letter-spacing="-1">K4</text>
6 + <polyline points="10,48 21,48 26,41 32,54 37,44 41,48 54,48" fill="none" stroke="#ff9f2e" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"/>
7 +</svg>
added M4M36luster-projects/ka-guardian/orchestrator/web/favicon-ka6.svg +7 −0
@@ -0,0 +1,7 @@
1 +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
2 + <!-- KA Guardian ka6 — encre + pastille lime ·Ka + battement (surveillance) -->
3 + <rect width="64" height="64" rx="14" fill="#141814"/>
4 + <circle cx="51" cy="13" r="4.5" fill="#d9f26b"/>
5 + <text x="30" y="36" font-family="'JetBrains Mono','Menlo',ui-monospace,monospace" font-size="26" font-weight="700" fill="#f5f3ee" text-anchor="middle" letter-spacing="-1">K6</text>
6 + <polyline points="10,48 21,48 26,41 32,54 37,44 41,48 54,48" fill="none" stroke="#a78bfa" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"/>
7 +</svg>
modified M4M36luster-projects/ka-guardian/orchestrator/web/index.html +6 −40
@@ -5,6 +5,8 @@
5 5 <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6 6 <title>Guardian — Groupe KA</title>
7 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="icon" type="image/svg+xml" href="/favicon.svg">
9 +<link rel="apple-touch-icon" href="/favicon.svg">
8 10 <link rel="preconnect" href="https://fonts.googleapis.com">
9 11 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10 12 <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">
@@ -14,6 +16,10 @@
14 16
15 17 <header class="topbar">
16 18 <a class="wordmark" href="/"><span id="wm-name">ka·—</span><span class="ka" id="wm-ka">guardian</span></a>
19 + <nav class="tabs">
20 + <a class="tab active" href="/">⚡ Flux</a>
21 + <a class="tab" href="/commander">🎛 Commander</a>
22 + </nav>
17 23 <nav class="topnav">
18 24 <a class="gk-badge" href="https://www.groupe-ka.com">groupe<b><span class="ka">·KA</span></b></a>
19 25 <span id="siblings"></span>
@@ -51,46 +57,6 @@
51 57 <div class="tiles" id="tiles"></div>
52 58 </section>
53 59
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 60 <!-- ============ ÉCRAN + INCIDENTS ============ -->
95 61 <section class="cols">
96 62 <div class="screen card" id="screen">
modified M4M36luster-projects/ka-guardian/orchestrator/web/style.css +21 −0
@@ -19,6 +19,7 @@
19 19 --fs-h1: clamp(30px, 3.2vw + 18px, 52px);
20 20 }
21 21 * { box-sizing: border-box; margin: 0; }
22 +[hidden] { display: none !important; }
22 23 body { background: var(--paper); color: var(--ink); font: 15px/1.55 var(--font-body);
23 24 -webkit-font-smoothing: antialiased; overflow-x: clip; }
24 25 a { color: inherit; }
@@ -50,6 +51,13 @@ a { color: inherit; }
50 51 padding: 0 8px 2px; display: inline-block; transform: rotate(-2deg); font-size: 0.72em; }
51 52 #wm-name b, #wm-name { color: var(--ink); }
52 53 .topnav { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
54 +.tabs { display: flex; gap: 6px; }
55 +.tab { font: 700 13px var(--font-display); text-decoration: none; color: var(--ink-2);
56 + border: 1.5px solid transparent; border-radius: var(--r-pill); padding: 6px 14px;
57 + transition: transform 0.15s, box-shadow 0.15s; }
58 +.tab:hover { border-color: var(--ink); background: var(--surface); transform: translate(-1px, -1px);
59 + box-shadow: 3px 3px 0 var(--ink); }
60 +.tab.active { background: var(--ink); color: var(--lime); border-color: var(--ink); }
53 61 .gk-badge { min-height: 30px; font-family: var(--font-mono); letter-spacing: 0.08em; text-transform: uppercase;
54 62 border: 1.5px solid var(--ink); border-radius: var(--r-pill); background: var(--surface); color: var(--ink-2);
55 63 white-space: nowrap; align-items: center; gap: 7px; padding: 3px 10px 4px; font-size: 10px; font-weight: 700;
@@ -116,6 +124,19 @@ main { padding: clamp(24px, 4vw, 48px) clamp(16px, 4vw, 48px); max-width: 1440px
116 124 .cmd-msg { font: 600 12.5px var(--font-mono); min-height: 18px; }
117 125 .cmd-msg.ok { color: var(--green); } .cmd-msg.err { color: var(--danger); }
118 126
127 +/* ---------- page /commander ---------- */
128 +.cmd-page { max-width: 980px; }
129 +.cmd-hero h1 { font-family: var(--font-display); font-size: clamp(30px, 4.5vw, 48px);
130 + letter-spacing: -0.03em; line-height: 1.06; margin: 6px 0 14px; }
131 +.cmd-hero .lede { color: var(--ink-2); max-width: 62ch; }
132 +.cmd-form { padding: clamp(18px, 3vw, 30px); display: grid; gap: 14px; }
133 +.frow-3 { grid-template-columns: 1fr 1fr 1fr; }
134 +@media (max-width: 800px) { .frow-3 { grid-template-columns: 1fr; } }
135 +.kind-hint { font-size: 13px; color: var(--ink-2); background: var(--surface);
136 + border: 1.5px solid var(--line); border-left: 3px solid var(--lime); border-radius: var(--r-ctl);
137 + padding: 9px 12px; }
138 +.cmd-note { font-size: 12px; color: var(--ink-3); max-width: 52ch; align-self: center; }
139 +
119 140 /* ---------- écran (flux) + side ---------- */
120 141 .cols { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr); gap: 20px; align-items: start; }
121 142 @media (max-width: 980px) { .cols { grid-template-columns: 1fr; } }
modified M4M36luster-projects/ka-guardian/runner/runner.py +58 −3
@@ -110,10 +110,33 @@ class MissionIn(BaseModel):
110 110 model: str = "sonnet"
111 111 max_turns: int = 70
112 112 timeout_seconds: int = 3600
113 + max_cost_usd: float | None = None # plafond opérateur (estimation par usage)
113 114 prompt: str
114 115 callback_url: str # http://<orchestrateur>/api/ingest/<mission_id>
115 116
116 117
118 +# Tarifs $/MTok (entrée, sortie) — pour ESTIMER le coût en cours de mission et
119 +# appliquer le plafond opérateur. Cache: lecture ≈ 0,1× entrée, écriture ≈ 1,25×.
120 +MODEL_RATES = {"fable": (10.0, 50.0), "opus": (5.0, 25.0), "sonnet": (3.0, 15.0), "haiku": (1.0, 5.0)}
121 +
122 +
123 +def rates_for(model: str) -> tuple[float, float]:
124 + for key, r in MODEL_RATES.items():
125 + if key in model:
126 + return r
127 + return MODEL_RATES["fable"]
128 +
129 +
130 +def usage_cost_usd(usage: dict[str, Any], model: str) -> float:
131 + rin, rout = rates_for(model)
132 + return (
133 + (usage.get("input_tokens") or 0) * rin
134 + + (usage.get("output_tokens") or 0) * rout
135 + + (usage.get("cache_read_input_tokens") or 0) * rin * 0.1
136 + + (usage.get("cache_creation_input_tokens") or 0) * rin * 1.25
137 + ) / 1e6
138 +
139 +
117 140 SPOOL = HOME / "ka-guardian-spool"
118 141
119 142
@@ -209,13 +232,14 @@ def run_mission(m: MissionIn) -> None:
209 232 )
210 233 _current["pid"] = proc.pid
211 234 result_text, cost, turns = "", None, None
235 + spent_estimate = 0.0
212 236 deadline = time.time() + m.timeout_seconds
213 237 with transcript.open("w") as tf:
214 238 for line in proc.stdout: # type: ignore[union-attr]
215 239 tf.write(line)
216 240 if time.time() > deadline:
217 241 proc.kill()
218 − post_event(m.callback_url, {"type": "error", "data": {"error": "timeout mission"}})
242 + post_event(m.callback_url, {"type": "error", "data": {"error": f"durée max atteinte ({m.timeout_seconds // 60} min) — session interrompue"}})
219 243 break
220 244 line = line.strip()
221 245 if not line:
@@ -224,6 +248,12 @@ def run_mission(m: MissionIn) -> None:
224 248 obj = json.loads(line)
225 249 except Exception:
226 250 continue
251 + if obj.get("type") == "assistant":
252 + spent_estimate += usage_cost_usd(obj.get("message", {}).get("usage") or {}, m.model)
253 + if m.max_cost_usd and spent_estimate > m.max_cost_usd:
254 + proc.kill()
255 + post_event(m.callback_url, {"type": "error", "data": {"error": f"coût max atteint (~{spent_estimate:.2f} $ estimé, plafond {m.max_cost_usd:.2f} $) — session interrompue"}})
256 + break
227 257 for ev in condense_stream_line(obj):
228 258 if ev["type"] == "result":
229 259 result_text = ev["data"].get("result", "")
@@ -236,11 +266,16 @@ def run_mission(m: MissionIn) -> None:
236 266 commits = commits_raw.splitlines() if commits_raw else []
237 267 verdict = extract_verdict(result_text)
238 268 health = healthcheck(m.web_port, m.pm2)
239 − post_event(m.callback_url, {"type": "final", "data": {
269 + final = {"type": "final", "data": {
240 270 "verdict": verdict, "commits": commits, "base_commit": base_commit,
241 271 "cost_usd": cost, "num_turns": turns, "health": health,
242 272 "exit_code": proc.returncode,
243 − }})
273 + }}
274 + # Résultat final persisté: l'orchestrateur peut le réclamer même si
275 + # l'événement s'est perdu (redémarrage, courrier en panne).
276 + (TRANSCRIPTS / f"{m.mission_id}.final.json").write_text(
277 + json.dumps(final, ensure_ascii=False))
278 + post_event(m.callback_url, final)
244 279 except Exception as exc:
245 280 post_event(m.callback_url, {"type": "error", "data": {"error": str(exc), "base_commit": base_commit}})
246 281 finally:
@@ -267,6 +302,26 @@ def missions(m: MissionIn, x_ka_token: str | None = Header(default=None)) -> dic
267 302 return {"accepted": True, "node": NODE}
268 303
269 304
305 +class MissionResultIn(BaseModel):
306 + mission_id: str
307 +
308 +
309 +@app.post("/mission-result")
310 +def mission_result(q: MissionResultIn, x_ka_token: str | None = Header(default=None)) -> dict[str, Any]:
311 + """Réconciliation: l'orchestrateur réclame le sort d'une mission.
312 +
313 + running = encore en cours ici; done = terminée (payload final joint);
314 + unknown = jamais terminée ici (probablement tuée) — à l'orchestrateur de trancher.
315 + """
316 + check_token(x_ka_token)
317 + if _current.get("mission_id") == q.mission_id:
318 + return {"status": "running"}
319 + fpath = TRANSCRIPTS / f"{q.mission_id}.final.json"
320 + if fpath.exists():
321 + return {"status": "done", "final": json.loads(fpath.read_text())}
322 + return {"status": "unknown"}
323 +
324 +
270 325 class RollbackIn(BaseModel):
271 326 dir: str
272 327 base_commit: str
modified M4M36luster-projects/ka-guardian/topology.json +148 −28
@@ -6,52 +6,172 @@
6 6 "ka2": {
7 7 "port": 8799,
8 8 "domain": "www.ka2.bot",
9 − "accent": "#22d3ee",
10 − "accent2": "#0891b2",
9 + "accent": "#a9d1f7",
10 + "accent2": "#1e4fa3",
11 11 "tagline": "Gardien immobilier & local",
12 − "model": "sonnet",
13 − "services": ["louka", "immoka", "restoka"]
12 + "model": "claude-fable-5",
13 + "services": [
14 + "louka",
15 + "immoka",
16 + "restoka"
17 + ],
18 + "accent_soft": "#e3effc"
14 19 },
15 20 "ka4": {
16 21 "port": 8899,
17 22 "domain": "www.ka4.bot",
18 − "accent": "#fbbf24",
19 − "accent2": "#d97706",
23 + "accent": "#ffc36b",
24 + "accent2": "#a35c00",
20 25 "tagline": "Gardien mobilité & quotidien",
21 − "model": "sonnet",
22 − "services": ["autoka", "foodka", "sortika"]
26 + "model": "claude-fable-5",
27 + "services": [
28 + "autoka",
29 + "foodka",
30 + "sortika"
31 + ],
32 + "accent_soft": "#ffefd9"
23 33 },
24 34 "ka6": {
25 35 "port": 8999,
26 36 "domain": "www.ka6.bot",
27 − "accent": "#a78bfa",
28 − "accent2": "#7c3aed",
37 + "accent": "#cdb4f9",
38 + "accent2": "#5b21b6",
29 39 "tagline": "Gardien flagship — gros volumes",
30 − "model": "sonnet",
31 − "services": ["fabrika", "jobka", "creaka"],
32 − "default_for_unknown_services": true
40 + "model": "claude-fable-5",
41 + "services": [
42 + "fabrika",
43 + "jobka",
44 + "creaka"
45 + ],
46 + "default_for_unknown_services": true,
47 + "accent_soft": "#f0e8fc"
33 48 }
34 49 },
35 50 "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" }
51 + "m3u96a": {
52 + "lan_ip": "192.168.2.87",
53 + "alias": "M3U96a"
54 + },
55 + "m3u96b": {
56 + "lan_ip": "192.168.2.82",
57 + "alias": "M3U96b"
58 + },
59 + "m4m64a": {
60 + "lan_ip": "192.168.2.83",
61 + "alias": "M4M64a"
62 + },
63 + "m4m64b": {
64 + "lan_ip": "192.168.2.78",
65 + "alias": "M4M64b"
66 + }
40 67 },
41 68 "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" }
69 + "louka": {
70 + "app": "Lou·Ka",
71 + "node": "m3u96b",
72 + "dir": "~/apps/lou-ka",
73 + "pm2": [
74 + "lou-ka-web",
75 + "lou-ka-sync"
76 + ],
77 + "web_port": 8095,
78 + "site": "https://www.lou-ka.com"
79 + },
80 + "restoka": {
81 + "app": "Resto·Ka",
82 + "node": "m3u96b",
83 + "dir": "~/apps/resto-ka",
84 + "pm2": [
85 + "resto-ka",
86 + "resto-ka-sync"
87 + ],
88 + "web_port": 8115,
89 + "site": "https://www.resto-ka.com"
90 + },
91 + "creaka": {
92 + "app": "Créa·Ka",
93 + "node": "m3u96b",
94 + "dir": "~/apps/crea-ka",
95 + "pm2": [
96 + "crea-ka-web",
97 + "crea-ka-sync"
98 + ],
99 + "web_port": 8160,
100 + "site": "https://www.crea-ka.com"
101 + },
102 + "immoka": {
103 + "app": "Immo·Ka",
104 + "node": "m4m64a",
105 + "dir": "~/apps/immo-ka",
106 + "pm2": [
107 + "immo-ka-web",
108 + "immo-ka-sync"
109 + ],
110 + "web_port": 8096,
111 + "site": "https://www.immo-ka.com"
112 + },
113 + "fabrika": {
114 + "app": "Fabri·Ka",
115 + "node": "m4m64a",
116 + "dir": "~/fabri-ka",
117 + "pm2": [
118 + "fabri-ka-web",
119 + "fabri-ka-sync"
120 + ],
121 + "web_port": 8097,
122 + "site": "https://www.fabri-ka.com"
123 + },
124 + "autoka": {
125 + "app": "Auto·Ka",
126 + "node": "m4m64b",
127 + "dir": "~/auto-ka",
128 + "pm2": [
129 + "auto-ka-web",
130 + "auto-ka-sync"
131 + ],
132 + "web_port": 8095,
133 + "site": "https://www.auto-ka.com"
134 + },
135 + "foodka": {
136 + "app": "Food·Ka",
137 + "node": "m4m64b",
138 + "dir": "~/apps/food-ka",
139 + "pm2": [
140 + "food-ka-web",
141 + "food-ka-sync"
142 + ],
143 + "web_port": 8097,
144 + "site": "https://www.food-ka.com"
145 + },
146 + "sortika": {
147 + "app": "Sorti·Ka",
148 + "node": "m3u96a",
149 + "dir": "~/apps/sorti-ka",
150 + "pm2": [
151 + "sorti-ka-web",
152 + "sorti-ka-sync"
153 + ],
154 + "web_port": 8120,
155 + "site": "https://www.sorti-ka.com"
156 + },
157 + "jobka": {
158 + "app": "Job·Ka",
159 + "node": "m3u96a",
160 + "dir": "~/apps/job-ka",
161 + "pm2": [
162 + "job-ka-web",
163 + "job-ka-sync"
164 + ],
165 + "web_port": 8096,
166 + "site": "https://www.job-ka.com"
167 + }
51 168 },
52 169 "policy": {
53 170 "poll_interval_seconds": 300,
54 − "trigger_statuses": ["broken", "stale"],
171 + "trigger_statuses": [
172 + "broken",
173 + "stale"
174 + ],
55 175 "max_concurrent_missions": 1,
56 176 "max_attempts_per_incident": 3,
57 177 "attempt_cooldown_hours": 6,
@@ -61,4 +181,4 @@
61 181 "effort_max_turns": 150,
62 182 "effort_timeout_seconds": 7200
63 183 }
64 −}
184 +}
\ No newline at end of file
modified deploy/courier.sh +13 −7
@@ -22,13 +22,19 @@ while true; do
22 22 # NB: ne JAMAIS nommer une variable `path` en zsh — c'est le tableau
23 23 # spécial lié à PATH (l'assigner détruit le PATH du process).
24 24 ip=$parts[1]; port=$parts[2]; jpath=$parts[3]; tmo=${parts[4]:-30}
25 − tail -n +2 $f | /usr/bin/ssh -i $KEY \
26 − -o StrictHostKeyChecking=accept-new -o BatchMode=yes -o ConnectTimeout=8 \
27 − -o ControlMaster=auto -o ControlPath=/tmp/kg-cm-%h -o ControlPersist=120 \
28 − simon-pierreboucher@$ip \
29 − "/usr/bin/curl -s -m $tmo -X POST http://127.0.0.1:$port$jpath -H 'Content-Type: application/json' -H 'X-KA-Token: $KA_GUARDIAN_TOKEN' -d @- -w '\n%{http_code}'" \
30 − > $SPOOL/tmp/$id.resp 2>>$SPOOL/courier.log
31 − rc=$?
25 + # 3 tentatives: un destinataire qui redémarre ne doit pas perdre le message.
26 + rc=1
27 + for attempt in 1 2 3; do
28 + tail -n +2 $f | /usr/bin/ssh -i $KEY \
29 + -o StrictHostKeyChecking=accept-new -o BatchMode=yes -o ConnectTimeout=8 \
30 + -o ControlMaster=auto -o ControlPath=/tmp/kg-cm-%h -o ControlPersist=120 \
31 + simon-pierreboucher@$ip \
32 + "/usr/bin/curl -s -m $tmo --retry 2 --retry-connrefused -X POST http://127.0.0.1:$port$jpath -H 'Content-Type: application/json' -H 'X-KA-Token: $KA_GUARDIAN_TOKEN' -d @- -w '\n%{http_code}'" \
33 + > $SPOOL/tmp/$id.resp 2>>$SPOOL/courier.log
34 + rc=$?
35 + [[ $rc -eq 0 ]] && break
36 + sleep 3
37 + done
32 38 [[ $rc -ne 0 ]] && print "\ncourier_ssh_rc_$rc" >> $SPOOL/tmp/$id.resp
33 39 mv $SPOOL/tmp/$id.resp $SPOOL/done/$id.resp
34 40 rm -f $f
modified deploy/deploy.sh +12 −4
@@ -52,6 +52,13 @@ PLIST
52 52 deploy_runners() {
53 53 for n in $RUNNER_NODES; do
54 54 echo "=== runner → $n"
55 + # GOTCHA: redémarrer un runner TUE la mission claude en cours (cause de la
56 + # mission fantôme du 2026-08-23). On refuse si le runner est occupé.
57 + busy=$(ssh $n "curl -s -m 5 localhost:7791/health 2>/dev/null" | grep -o '"busy":true' || true)
58 + if [[ -n $busy ]]; then
59 + echo " ⏸ $n occupé (mission en cours) — runner NON redéployé, relance plus tard"
60 + continue
61 + fi
55 62 ssh $n "mkdir -p ~/ka-guardian-runner/transcripts"
56 63 scp -q "$ROOT/runner/runner.py" $n:ka-guardian-runner/runner.py
57 64 ssh $n "printf '%s\nKA_GUARDIAN_NODE=%s\n' '$TOKEN_LINE' '$n' > ~/.ka-guardian.env
@@ -86,11 +93,12 @@ deploy_orchestrators() {
86 93 echo "=== orchestrateurs → $ORCH_NODE"
87 94 ssh $ORCH_NODE "printf '%s\n' '$TOKEN_LINE' > ~/.ka-guardian.env; mkdir -p ~/cluster-projects/ka-guardian"
88 95 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)
96 + # tar-over-ssh: déterministe (gotcha: rsync silencieusement inopérant depuis
97 + # ce contexte de script — jamais élucidé, contourné définitivement).
98 + tar czf - --exclude data --exclude .venv --exclude .git --exclude '__pycache__' --exclude logs . \
99 + | ssh $ORCH_NODE 'tar xzf - -C ~/cluster-projects/ka-guardian/'
92 100 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; }
101 + [[ "$loc" == "$rem" ]] || { echo "✗ transfert raté: main.py ($loc ≠ $rem)"; exit 1; }
94 102 ssh $ORCH_NODE "cd ~/cluster-projects/ka-guardian
95 103 PY=\$(command -v /opt/homebrew/bin/python3 || command -v /opt/homebrew/bin/python3.13)
96 104 [[ -d .venv ]] || \$PY -m venv .venv
modified orchestrator/main.py +49 −8
@@ -472,14 +472,13 @@ async def tick() -> None:
472 472 hub.publish_sync({"kind": "log", "level": "warn", "msg": f"poll api-ka échoué: {exc}", "ts": now()})
473 473
474 474 with db() as c:
475 − # 1. Missions zombies (runner mort / callback perdu)
476 − for m in c.execute("SELECT * FROM missions WHERE state='running' AND started < ?",
477 − (now() - POLICY.get("effort_timeout_seconds", 7200) - 900,)).fetchall():
478 − c.execute("UPDATE missions SET state='error', ended=? WHERE id=?", (now(), m["id"]))
479 − inc = c.execute("SELECT * FROM incidents WHERE id=?", (m["incident_id"],)).fetchone()
480 − if inc:
481 − set_incident(c, inc["id"], state="cooldown")
482 − incident_event(inc["id"], m["service"], m["source"], "cooldown", "mission sans réponse (zombie)")
475 + # 1. Réconciliation des missions en cours depuis > 10 min: on demande au
476 + # runner ce qu'il en est (événement final perdu? runner redémarré?).
477 + for m in c.execute("SELECT id FROM missions WHERE state='running' AND started < ?",
478 + (now() - 600,)).fetchall():
479 + if m["id"] not in RECONCILING:
480 + RECONCILING.add(m["id"])
481 + asyncio.create_task(reconcile_mission(m["id"]))
483 482
484 483 # 2. Watching → rollback si la fenêtre est passée et toujours cassé
485 484 for inc in c.execute("SELECT * FROM incidents WHERE state='watching'").fetchall():
@@ -553,6 +552,48 @@ async def dispatch_row(iid: str) -> None:
553 552 set_incident(c, iid, state="open")
554 553
555 554
555 +RECONCILING: set[str] = set()
556 +
557 +
558 +async def reconcile_mission(mid: str) -> None:
559 + """Résout une mission « en cours » suspecte auprès de son runner."""
560 + try:
561 + with db() as c:
562 + m = c.execute("SELECT * FROM missions WHERE id=? AND state='running'", (mid,)).fetchone()
563 + if not m:
564 + return
565 + ip = NODES[m["node"]]["lan_ip"]
566 + try:
567 + code, body = await lan_post(ip, RUNNER_PORT, "/mission-result", {"mission_id": mid}, timeout=20)
568 + resp = json.loads(body) if code == 200 else {"status": "erreur"}
569 + except Exception:
570 + return # courrier/runner injoignable: on retentera au prochain tick
571 + if resp.get("status") == "running":
572 + return
573 + with db() as c:
574 + m = c.execute("SELECT * FROM missions WHERE id=? AND state='running'", (mid,)).fetchone()
575 + if not m:
576 + return
577 + if resp.get("status") == "done":
578 + data = (resp.get("final") or {}).get("data", {})
579 + c.execute("INSERT INTO events(mission_id,ts,type,data) VALUES(?,?,?,?)",
580 + (mid, now(), "final", jdump(data)))
581 + await finalize(c, m, "final", data)
582 + hub.publish_sync({"kind": "mission_event", "mission_id": mid, "type": "final",
583 + "data": data, "ts": now()})
584 + elif now() - m["started"] > 900:
585 + # Le runner ne connaît pas cette mission: tuée en vol
586 + # (redémarrage). On la clôt et on relance l'incident.
587 + c.execute("UPDATE missions SET state='error', ended=? WHERE id=?", (now(), mid))
588 + inc = c.execute("SELECT * FROM incidents WHERE id=?", (m["incident_id"],)).fetchone()
589 + if inc and inc["state"] in ("fixing", "dispatched"):
590 + set_incident(c, inc["id"], state="open")
591 + incident_event(inc["id"], m["service"], m["source"], "open",
592 + "mission perdue (runner interrompu) — remise en file")
593 + finally:
594 + RECONCILING.discard(mid)
595 +
596 +
556 597 async def engine() -> None:
557 598 global LOOP
558 599 LOOP = asyncio.get_running_loop()
modified runner/runner.py +27 −2
@@ -266,11 +266,16 @@ def run_mission(m: MissionIn) -> None:
266 266 commits = commits_raw.splitlines() if commits_raw else []
267 267 verdict = extract_verdict(result_text)
268 268 health = healthcheck(m.web_port, m.pm2)
269 − post_event(m.callback_url, {"type": "final", "data": {
269 + final = {"type": "final", "data": {
270 270 "verdict": verdict, "commits": commits, "base_commit": base_commit,
271 271 "cost_usd": cost, "num_turns": turns, "health": health,
272 272 "exit_code": proc.returncode,
273 − }})
273 + }}
274 + # Résultat final persisté: l'orchestrateur peut le réclamer même si
275 + # l'événement s'est perdu (redémarrage, courrier en panne).
276 + (TRANSCRIPTS / f"{m.mission_id}.final.json").write_text(
277 + json.dumps(final, ensure_ascii=False))
278 + post_event(m.callback_url, final)
274 279 except Exception as exc:
275 280 post_event(m.callback_url, {"type": "error", "data": {"error": str(exc), "base_commit": base_commit}})
276 281 finally:
@@ -297,6 +302,26 @@ def missions(m: MissionIn, x_ka_token: str | None = Header(default=None)) -> dic
297 302 return {"accepted": True, "node": NODE}
298 303
299 304
305 +class MissionResultIn(BaseModel):
306 + mission_id: str
307 +
308 +
309 +@app.post("/mission-result")
310 +def mission_result(q: MissionResultIn, x_ka_token: str | None = Header(default=None)) -> dict[str, Any]:
311 + """Réconciliation: l'orchestrateur réclame le sort d'une mission.
312 +
313 + running = encore en cours ici; done = terminée (payload final joint);
314 + unknown = jamais terminée ici (probablement tuée) — à l'orchestrateur de trancher.
315 + """
316 + check_token(x_ka_token)
317 + if _current.get("mission_id") == q.mission_id:
318 + return {"status": "running"}
319 + fpath = TRANSCRIPTS / f"{q.mission_id}.final.json"
320 + if fpath.exists():
321 + return {"status": "done", "final": json.loads(fpath.read_text())}
322 + return {"status": "unknown"}
323 +
324 +
300 325 class RollbackIn(BaseModel):
301 326 dir: str
302 327 base_commit: str
303 328