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>
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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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 | |