topologie vivante : emplacements des services lus dans le registre mld (plus rien en dur)
- orchestrator/registry.py : applique ~/ka-guardian-spool/registry.json (nœud, IP, dir, port, PM2 sans ngrok) à SERVICES/NODES en place, journalise les déménagements, lit la sonde des runners ; topology.json ne garde que service→registry_app (+ pm2_exclude), ses node/dir/port ne sont qu un repli - main.py : refresh à chaque tick, refus de dépêcher vers un nœud sans runner (log 1×/h), rollback sur le nœud ACTUEL du service, bloc registry dans /api/state - deploy/registry-sync.sh + launchd com.ka.registry-sync (zsh pur, LNP) : tire le registre via gitsrv toutes les 2 min + sonde :7791 → runners.json - deploy.sh : nœuds/repos dérivés du registre, cible registry-sync, pousseur fusionné, helper lc_reload (agent gui/uid ou DAEMON système sur nœud sans session graphique, ex. M2U64), filtre <nœud> - dashboard : runner prêt/occupé/absent + fraîcheur du registre par service Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
9 changed files +634 −263
modified
CLAUDE.md
+22 −1
@@ -4,6 +4,27 @@ Un seul repo pour les **3 sites gardiens** : www.ka2.bot, www.ka4.bot, www.ka6.b | ||
| 4 | 4 | Chaque agent est une instance de `orchestrator/main.py` (env `AGENT=ka2|ka4|ka6`), |
| 5 | 5 | identité (accent, tagline, services surveillés) dans `topology.json`. |
| 6 | 6 | |
| 7 | +## Où sont les apps ? → le registre mld, jamais en dur (2026-09-04) | |
| 8 | + | |
| 9 | +L'**emplacement** de chaque service (nœud, IP LAN, répertoire, port web, process PM2) | |
| 10 | +vient du **registre de la passerelle** `M1M32:~/dispatch/registry.json`, appliqué par | |
| 11 | +`orchestrator/registry.py` au démarrage et à chaque tick (`registry.refresh` mute | |
| 12 | +`SERVICES`/`NODES` en place ; les déménagements sont journalisés dans le flux et dans | |
| 13 | +`/api/state → registry.changes`). `topology.json` ne porte que la correspondance | |
| 14 | +`service → registry_app` (+ `pm2_exclude` optionnel) ; ses champs `node/dir/web_port/pm2` | |
| 15 | +sont un **repli** si le registre n'a jamais été reçu — ne pas les « corriger » à la main, | |
| 16 | +c'est `mld` qui fait foi. | |
| 17 | + | |
| 18 | +- Copie locale : `~/ka-guardian-spool/registry.json` — **poussée** par `mld` (abonné `ka2`, | |
| 19 | + `mld subscribers` sur M1M32) et **tirée** toutes les 2 min par le service launchd | |
| 20 | + `com.ka.registry-sync` (`deploy/registry-sync.sh`, zsh pur : LNP interdit le LAN à python). | |
| 21 | +- Le même service sonde `:7791/health` de chaque nœud hébergeur → `~/ka-guardian-spool/runners.json`. | |
| 22 | + Un nœud **sans runner** (app déménagée sur un nœud jamais équipé) est affiché « ⚠ sans runner » | |
| 23 | + dans le territoire surveillé et l'orchestrateur **refuse d'y dépêcher** (log d'erreur 1×/h) : | |
| 24 | + lancer `deploy/deploy.sh runners` (la liste des nœuds est elle-même dérivée du registre). | |
| 25 | +- Les process PM2 = `processes` du registre moins les tunnels `*-ngrok` moins `pm2_exclude`. | |
| 26 | +- Rollback après un déménagement : vise le nœud **actuel** du service (le repo git suit l'app). | |
| 27 | + | |
| 7 | 28 | ## Veilleur de sites (2026-08-25) |
| 8 | 29 | |
| 9 | 30 | En plus des connecteurs (scan api-ka), chaque agent surveille le **site public** |
@@ -30,7 +51,7 @@ connecteur sous api-ka, seule la surveillance du site s'applique. | ||
| 30 | 51 | 2. Vérifier : `curl -sf localhost:8799/health` (8899, 8999) puis le site public. |
| 31 | 52 | 3. `git add … && git commit && git push origin main` (origin = gitsrv/spbgit, PAS GitHub). |
| 32 | 53 | |
| 33 | −⚠️ NE JAMAIS redémarrer les **runners** (`com.ka.guardian-runner` sur M3U96a/b, M4M64a/b, M4M36) pendant qu'ils sont occupés : ça tue la mission Claude en vol. Pour redéployer `runner/runner.py`, vérifier d'abord `curl localhost:7791/health` (`busy:false`) sur chaque nœud, comme le fait `deploy/deploy.sh runners`. | |
| 54 | +⚠️ NE JAMAIS redémarrer les **runners** (`com.ka.guardian-runner` sur chaque nœud hébergeur — liste vivante : `deploy/deploy.sh nodes`, dérivée du registre mld) pendant qu'ils sont occupés : ça tue la mission Claude en vol. Pour redéployer `runner/runner.py`, vérifier d'abord `curl localhost:7791/health` (`busy:false`) sur chaque nœud, comme le fait `deploy/deploy.sh runners`. | |
| 34 | 55 | ⚠️ Le trafic LAN direct est bloqué par macOS 26 (Local Network Privacy) — le courrier zsh `com.ka.guardian-courier` s'en occupe ; ne pas le contourner. |
| 35 | 56 | |
| 36 | 57 | ## Pages & image de marque |
modified
README.md
+36 −10
@@ -22,8 +22,11 @@ Le gardien concerné le détecte, ouvre le code, le répare, prouve que ça marc | ||
| 22 | 22 | | **ka6** | [www.ka6.bot](https://www.ka6.bot) | :8999 | Gardien flagship — gros volumes | Fabri·Ka, Job·Ka, Créa·Ka, Vrai·Prix **+ tout nouveau service** (défaut) | violet `#cdb4f9` / `#5b21b6` | |
| 23 | 23 | |
| 24 | 24 | L'identité de chaque agent (numéro, domaine, tagline, accents, services surveillés, |
| 25 | −modèle Claude) vit dans **`topology.json`**, la source de vérité partagée entre | |
| 26 | −orchestrateurs et runners. Les pages web (`orchestrator/web/index.html`, | |
| 25 | +modèle Claude) vit dans **`topology.json`**. L'**emplacement** des apps (nœud, IP LAN, | |
| 26 | +répertoire, port, process PM2) n'y est PAS codé en dur : il vient du **registre de la | |
| 27 | +passerelle mld** (`M1M32:~/dispatch/registry.json`), appliqué par `orchestrator/registry.py` | |
| 28 | +au démarrage et à chaque tick — quand `mld move` déplace une app, les gardiens suivent | |
| 29 | +sans redéploiement (voir « Où sont les apps ? » plus bas). Les pages web (`orchestrator/web/index.html`, | |
| 27 | 30 | `commander.html`) sont des gabarits communs : les jetons `__NUM__`, `__DOMAIN__`, |
| 28 | 31 | `__TAGLINE__` sont rendus par agent au démarrage (`_render_page` dans `main.py`). |
| 29 | 32 | Chaque agent a sa bannière de partage OG (`orchestrator/web/og-ka{2,4,6}.png`, |
@@ -154,29 +157,52 @@ API équivalente : `POST /api/admin/effort` (header `X-KA-Token`). | ||
| 154 | 157 | │ com.ka6.guardian :8999 (FastAPI + SQLite data/kaX.db) │ |
| 155 | 158 | │ ngrok com.kaX.ngrok → www.ka2.bot / ka4.bot / ka6.bot │ |
| 156 | 159 | │ courrier zsh com.ka.guardian-courier (spool → ssh+curl) │ |
| 160 | + │ registry-sync zsh com.ka.registry-sync (registre + sondes) │ | |
| 157 | 161 | └───────────────┬────────────────────────────────────────────┘ |
| 158 | 162 | │ LAN 192.168.2.x uniquement (via le courrier) |
| 159 | − ┌────────────┼──────────────┬──────────────┬──────────────┐ | |
| 160 | − ▼ ▼ ▼ ▼ ▼ | |
| 161 | − M3U96a M3U96b M4M64a M4M64b M4M36 | |
| 162 | − runner :7791 (com.ka.guardian-runner) sur chaque nœud d'app | |
| 163 | + ┌────────────┼──────────────┬──────────────┐ | |
| 164 | + ▼ ▼ ▼ ▼ … tout nœud que le registre | |
| 165 | + M3U96a M2U64 M4M64a M4M64b mld désigne (liste vivante) | |
| 166 | + runner :7791 (com.ka.guardian-runner) sur chaque nœud hébergeur | |
| 163 | 167 | + pousseur git com.ka.pousseur (nœuds à repos Ka) |
| 168 | + ▲ | |
| 169 | + │ registre poussé (abonné ka2) + tiré toutes les 2 min | |
| 170 | + M1M32 (passerelle) ~/dispatch/registry.json — source de vérité des emplacements | |
| 164 | 171 | ``` |
| 165 | 172 | |
| 173 | +### Où sont les apps ? — le registre mld fait foi (2026-09-04) | |
| 174 | + | |
| 175 | +- `orchestrator/registry.py` lit `~/ka-guardian-spool/registry.json` et applique, EN PLACE, | |
| 176 | + nœud / IP / répertoire / port / PM2 à chaque service de `topology.json` (clé | |
| 177 | + `registry_app` = nom de l'app dans le registre ; `pm2_exclude` retire un process des | |
| 178 | + restarts ; les tunnels `*-ngrok` sont toujours exclus). Les déménagements détectés | |
| 179 | + sont journalisés (flux SSE + `/api/state → registry.changes`). | |
| 180 | +- La copie locale arrive par deux chemins : **poussée** par `mld` à chaque sauvegarde du | |
| 181 | + registre (abonné `ka2`, cf. `mld subscribers` sur M1M32) et **tirée** toutes les 2 min par | |
| 182 | + `deploy/registry-sync.sh` (launchd `com.ka.registry-sync`, zsh pur — LNP). Le même service | |
| 183 | + sonde `:7791/health` sur chaque nœud hébergeur → `runners.json` : un nœud **sans runner** | |
| 184 | + est affiché « ⚠ sans runner » et l'orchestrateur n'y dépêche rien (erreur loggée 1×/h) | |
| 185 | + jusqu'à `deploy/deploy.sh runners` — dont la liste de nœuds est elle aussi dérivée du | |
| 186 | + registre (`deploy/deploy.sh nodes` pour la voir). | |
| 187 | +- Les champs `node/dir/web_port/pm2` de `topology.json` ne servent que de **repli** si le | |
| 188 | + registre n'a jamais été reçu ; ne pas les maintenir à la main. | |
| 189 | + | |
| 166 | 190 | - **`orchestrator/`** — un process FastAPI par agent (M4M36, launchd |
| 167 | 191 | `com.kaX.guardian`, env `AGENT=ka2|ka4|ka6`), SQLite `data/kaX.db`, dashboard |
| 168 | 192 | servi sur le même port (flux SSE, board incidents, couverture, registre des |
| 169 | 193 | missions + transcripts, `/commander`), tunnels ngrok existants conservés. |
| 170 | −- **`runner/`** — un service par nœud d'app (M3U96a/b, M4M64a/b, M4M36 ; :7791, | |
| 194 | +- **`runner/`** — un service par nœud hébergeur (liste dérivée du registre mld ; :7791, | |
| 171 | 195 | launchd `com.ka.guardian-runner`), **1 mission à la fois par nœud**, transcripts |
| 172 | 196 | dans `~/ka-guardian-runner/transcripts/`. Exécute `claude -p --output-format |
| 173 | 197 | stream-json` dans le repo de l'app et relaie chaque événement à l'orchestrateur |
| 174 | 198 | (`/api/ingest`). Expose aussi `/restart` (pm2) pour le veilleur de sites et |
| 175 | 199 | `/health` (`busy`). ⚠️ Ne jamais redémarrer un runner occupé : ça tue la |
| 176 | 200 | mission Claude en vol. |
| 177 | −- **`topology.json`** — source de vérité : agents (port/domaine/accent/tagline/ | |
| 178 | − modèle/services), services (app, nœud, dir, process pm2, port web, site), | |
| 179 | − IP LAN des nœuds, politique (fenêtres, cooldowns, plafonds). | |
| 201 | +- **`topology.json`** — agents (port/domaine/accent/tagline/modèle/services), | |
| 202 | + correspondance service → `registry_app` (+ `pm2_exclude`), politique (fenêtres, | |
| 203 | + cooldowns, plafonds). Nœud/dir/port/PM2 des services = **registre mld** (repli seulement). | |
| 204 | +- **`orchestrator/registry.py`** + **`deploy/registry-sync.sh`** — topologie vivante : | |
| 205 | + lecture/application du registre, journal des déménagements, sonde des runners. | |
| 180 | 206 | - **`deploy/courier.sh`** — courrier inter-nœuds 100 % zsh (launchd |
| 181 | 207 | `com.ka.guardian-courier`). macOS 26 « Local Network Privacy » refuse le trafic |
| 182 | 208 | LAN dès que python (homebrew) est dans la chaîne de processus ; le courrier |
modified
deploy/deploy.sh
+171 −41
@@ -1,25 +1,128 @@ | ||
| 1 | 1 | #!/bin/zsh |
| 2 | 2 | # ============================================ |
| 3 | 3 | # KA Guardian — déploiement complet depuis le laptop |
| 4 | −# ./deploy.sh runners → runners sur M3U96a M3U96b M4M64a M4M64b M4M36 | |
| 5 | −# ./deploy.sh orchestrators → ka2/ka4/ka6 sur M4M36 (remplace les anciens bots) | |
| 4 | +# ./deploy.sh runners → runners sur CHAQUE nœud qui héberge un service surveillé (d'après le registre mld) | |
| 5 | +# ./deploy.sh courier → courrier zsh sur les nœuds runners + M4M36 | |
| 6 | +# ./deploy.sh registry-sync → service launchd com.ka.registry-sync sur M4M36 (tirage du registre + sonde des runners) | |
| 7 | +# ./deploy.sh orchestrators → ka2/ka4/ka6 sur M4M36 | |
| 8 | +# ./deploy.sh pousseur → pousseur git sur les nœuds runners (repos Ka du registre, fusion avec ~/.ka-pousseur-repos) | |
| 6 | 9 | # ./deploy.sh all |
| 10 | +# ./deploy.sh nodes → affiche nœuds + repos calculés depuis le registre (sans rien déployer) | |
| 11 | +# ./deploy.sh <cible> <nœud> → même chose limité à UN nœud (ex. `deploy.sh runners M2U64` après un `mld move`) | |
| 7 | 12 | # Idempotent. Le token partagé vit dans ~/.ka-guardian.env (laptop) et est |
| 8 | 13 | # poussé sur chaque nœud. Les tunnels ngrok existants (www.kaX.bot) sont gardés. |
| 14 | +# | |
| 15 | +# 2026-09-04 — la liste des nœuds n'est PLUS codée en dur : elle est dérivée du | |
| 16 | +# registre mld (M1M32:~/dispatch/registry.json) croisé avec topology.json | |
| 17 | +# (services → registry_app). Un service qui déménage sur un nœud jamais équipé | |
| 18 | +# apparaît ici automatiquement → relancer `deploy.sh runners` (et courier/pousseur). | |
| 19 | +# | |
| 20 | +# launchd : sur un nœud AVEC session graphique → LaunchAgent gui/<uid> (bootstrap, repli `load`). | |
| 21 | +# Sur un nœud SANS session graphique (personne de connecté à la console, ex. M2U64 : le domaine | |
| 22 | +# gui/<uid> n'existe pas, `launchctl load` avorte rc 134) → LaunchDaemon système exécuté sous | |
| 23 | +# notre utilisateur (UserName + HOME), via sudo (mot de passe KA_SUDO_PW dans ~/.ka-guardian.env | |
| 24 | +# du laptop — jamais poussé sur les nœuds). Les daemons ne sont pas soumis à Local Network Privacy. | |
| 9 | 25 | # ============================================ |
| 10 | 26 | set -e |
| 11 | 27 | ROOT="$(cd "$(dirname "$0")/.." && pwd)" |
| 12 | −RUNNER_NODES=(M3U96a M3U96b M4M64a M4M64b M4M36) | |
| 13 | 28 | ORCH_NODE=M4M36 |
| 14 | −PY=/opt/homebrew/bin/python3 | |
| 29 | +GATEWAY=M1M32 | |
| 15 | 30 | |
| 16 | −# --- token partagé | |
| 31 | +# --- token partagé (+ mot de passe sudo des nœuds pour le mode LaunchDaemon) | |
| 17 | 32 | TOKEN_FILE="$HOME/.ka-guardian.env" |
| 18 | 33 | if [[ ! -f $TOKEN_FILE ]]; then |
| 19 | 34 | echo "KA_GUARDIAN_TOKEN=$(openssl rand -hex 24)" > "$TOKEN_FILE" |
| 20 | 35 | echo "token généré → $TOKEN_FILE" |
| 21 | 36 | fi |
| 22 | −TOKEN_LINE=$(grep KA_GUARDIAN_TOKEN "$TOKEN_FILE") | |
| 37 | +TOKEN_LINE=$(grep '^KA_GUARDIAN_TOKEN=' "$TOKEN_FILE") | |
| 38 | +SUDO_PW=$(grep '^KA_SUDO_PW=' "$TOKEN_FILE" | cut -d= -f2- || true) | |
| 39 | + | |
| 40 | +# --- registre mld : source de vérité des emplacements | |
| 41 | +REG_JSON="$(mktemp /tmp/ka-registry.XXXXXX)" | |
| 42 | +if ! ssh -o ConnectTimeout=8 $GATEWAY 'cat ~/dispatch/registry.json' > "$REG_JSON" 2>/dev/null || ! grep -q '"apps"' "$REG_JSON"; then | |
| 43 | + if [[ -f "$HOME/Desktop/cluster-skill/cluster-registry.json" ]]; then | |
| 44 | + echo "⚠ passerelle $GATEWAY injoignable — copie laptop cluster-skill/cluster-registry.json utilisée" | |
| 45 | + cp "$HOME/Desktop/cluster-skill/cluster-registry.json" "$REG_JSON" | |
| 46 | + else | |
| 47 | + echo "✗ registre mld introuvable (ni $GATEWAY, ni copie laptop) — impossible de déterminer les nœuds"; exit 1 | |
| 48 | + fi | |
| 49 | +fi | |
| 50 | + | |
| 51 | +# nœuds hébergeant au moins un service surveillé (+ liste "nœud<TAB>dir" des repos Ka pour le pousseur) | |
| 52 | +registry_lines() { # $1 = nodes | repos | |
| 53 | + /usr/bin/python3 - "$ROOT/topology.json" "$REG_JSON" "$1" <<'PYEOF' | |
| 54 | +import json, sys | |
| 55 | +topo = json.load(open(sys.argv[1])); reg = json.load(open(sys.argv[2]))["apps"]; mode = sys.argv[3] | |
| 56 | +rows = [] | |
| 57 | +for key, svc in topo["services"].items(): | |
| 58 | + app = svc.get("registry_app") or key | |
| 59 | + e = reg.get(app) | |
| 60 | + if not e or not e.get("node"): | |
| 61 | + print(f"# {app}: absent du registre — repli topology.json ({svc.get('node')})", file=sys.stderr) | |
| 62 | + alias = next((n["alias"] for k, n in topo["nodes"].items() if k == svc.get("node")), None) | |
| 63 | + d = svc.get("dir") | |
| 64 | + else: | |
| 65 | + alias, d = e["node"], e.get("dir") | |
| 66 | + if alias: | |
| 67 | + rows.append((alias, d)) | |
| 68 | +if mode == "nodes": | |
| 69 | + print("\n".join(sorted({a for a, _ in rows}))) | |
| 70 | +else: | |
| 71 | + for a, d in sorted(set(rows)): | |
| 72 | + print(f"{a}\t{d}") | |
| 73 | +PYEOF | |
| 74 | +} | |
| 75 | +RUNNER_NODES=(${(f)"$(registry_lines nodes)"}) | |
| 76 | +if (( ${#RUNNER_NODES} == 0 )); then echo "✗ aucun nœud dérivé du registre"; exit 1; fi | |
| 77 | +# 2e argument optionnel = restreindre à un nœud (ex. `deploy.sh runners M2U64` après un déménagement) | |
| 78 | +if [[ -n ${2:-} ]]; then | |
| 79 | + (( ${RUNNER_NODES[(Ie)$2]} )) || echo "⚠ $2 n'héberge aucun service surveillé d'après le registre — déploiement forcé" | |
| 80 | + RUNNER_NODES=($2) | |
| 81 | +fi | |
| 82 | +echo "nœuds hébergeurs (registre mld) : ${RUNNER_NODES[*]}" | |
| 83 | + | |
| 84 | +# --- helper distant : (re)charge un service launchd, agent gui/<uid> ou daemon système selon le nœud. | |
| 85 | +# Injecté en tête de chaque script distant ; usage : lc_reload <label> <plist-agent> | |
| 86 | +# Le mot de passe sudo n'est transmis que dans la commande ssh (jamais écrit sur le nœud). | |
| 87 | +LC_HELPER=' | |
| 88 | +lc_reload() { | |
| 89 | + local label=$1 plist=$2 uid=$(id -u) | |
| 90 | + if launchctl print gui/$uid >/dev/null 2>&1; then | |
| 91 | + launchctl bootout gui/$uid/$label 2>/dev/null || true | |
| 92 | + # bootout est asynchrone : bootstrap trop tôt échoue « 5: Input/output error » → attendre la disparition | |
| 93 | + for i in {1..20}; do launchctl print gui/$uid/$label >/dev/null 2>&1 || break; sleep 0.5; done | |
| 94 | + if launchctl bootstrap gui/$uid $plist 2>/tmp/$label.lc.err; then | |
| 95 | + echo " launchd: $label (agent gui/$uid)" | |
| 96 | + else | |
| 97 | + echo " launchd: bootstrap $label a échoué ($(tr -d "\n" </tmp/$label.lc.err)) — repli launchctl load" | |
| 98 | + launchctl load $plist && echo " launchd: $label (agent gui/$uid, via load)" | |
| 99 | + fi | |
| 100 | + rm -f /tmp/$label.lc.err | |
| 101 | + else | |
| 102 | + [[ -n "$KA_SUDO_PW" ]] || { echo " ✗ $label : pas de session graphique et KA_SUDO_PW absent (mode daemon impossible)"; return 1; } | |
| 103 | + local tmp=/tmp/$label.daemon.plist | |
| 104 | + cp $plist $tmp | |
| 105 | + /usr/bin/plutil -replace UserName -string "$USER" $tmp | |
| 106 | + /usr/bin/plutil -replace EnvironmentVariables.HOME -string "$HOME" $tmp 2>/dev/null \ | |
| 107 | + || /usr/bin/plutil -insert EnvironmentVariables -json "{\"HOME\":\"$HOME\"}" $tmp | |
| 108 | + # les journaux doivent rester à nous : les créer AVANT que launchd (root) ne les ouvre | |
| 109 | + for k in StandardOutPath StandardErrorPath; do | |
| 110 | + f=$(/usr/bin/plutil -extract $k raw -o - $tmp 2>/dev/null) && [[ -n $f ]] && { mkdir -p ${f:h}; touch $f; } | |
| 111 | + done | |
| 112 | + print -r -- "$KA_SUDO_PW" | sudo -S -p "" launchctl bootout system/$label 2>/dev/null || true | |
| 113 | + for i in {1..20}; do launchctl print system/$label >/dev/null 2>&1 || break; sleep 0.5; done # bootout asynchrone | |
| 114 | + print -r -- "$KA_SUDO_PW" | sudo -S -p "" install -m 644 -o root -g wheel $tmp /Library/LaunchDaemons/$label.plist | |
| 115 | + print -r -- "$KA_SUDO_PW" | sudo -S -p "" launchctl bootstrap system /Library/LaunchDaemons/$label.plist \ | |
| 116 | + && echo " launchd: $label (DAEMON système sous $USER — nœud sans session graphique)" | |
| 117 | + rm -f $tmp | |
| 118 | + fi | |
| 119 | +} | |
| 120 | +' | |
| 121 | +# préfixe des scripts distants : helper + mot de passe (variable d'environnement de la session ssh) | |
| 122 | +remote() { # remote <nœud> <script> | |
| 123 | + ssh "$1" "export KA_SUDO_PW=$(printf %q "$SUDO_PW"); $LC_HELPER | |
| 124 | +$2" | |
| 125 | +} | |
| 23 | 126 | |
| 24 | 127 | deploy_courier() { |
| 25 | 128 | # Courrier zsh sur TOUS les nœuds (orchestrateur + runners): expédie le |
@@ -27,7 +130,7 @@ deploy_courier() { | ||
| 27 | 130 | for n in $RUNNER_NODES $ORCH_NODE; do |
| 28 | 131 | echo "=== courrier → $n" |
| 29 | 132 | scp -q "$ROOT/deploy/courier.sh" $n:ka-guardian-courier.sh |
| 30 | − ssh $n 'chmod +x ~/ka-guardian-courier.sh | |
| 133 | + remote $n 'chmod +x ~/ka-guardian-courier.sh | |
| 31 | 134 | cat > ~/Library/LaunchAgents/com.ka.guardian-courier.plist <<PLIST |
| 32 | 135 | <?xml version="1.0" encoding="UTF-8"?> |
| 33 | 136 | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
@@ -43,15 +146,43 @@ deploy_courier() { | ||
| 43 | 146 | </dict></plist> |
| 44 | 147 | PLIST |
| 45 | 148 | 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' \ | |
| 149 | + lc_reload com.ka.guardian-courier ~/Library/LaunchAgents/com.ka.guardian-courier.plist' \ | |
| 48 | 150 | && echo " ✓ $n courrier ok" || echo " ✗ $n courrier KO" |
| 49 | 151 | done |
| 50 | 152 | } |
| 51 | 153 | |
| 154 | +deploy_registry_sync() { | |
| 155 | + # Tirage périodique du registre mld + sonde des runners, sur le nœud orchestrateur | |
| 156 | + # (chaîne zsh pure — LNP). Le push mld (abonné ka2) reste le chemin rapide. | |
| 157 | + echo "=== registry-sync → $ORCH_NODE" | |
| 158 | + scp -q "$ROOT/deploy/registry-sync.sh" $ORCH_NODE:ka-registry-sync.sh | |
| 159 | + remote $ORCH_NODE 'chmod +x ~/ka-registry-sync.sh | |
| 160 | + cat > ~/Library/LaunchAgents/com.ka.registry-sync.plist <<PLIST | |
| 161 | +<?xml version="1.0" encoding="UTF-8"?> | |
| 162 | +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | |
| 163 | +<plist version="1.0"><dict> | |
| 164 | + <key>Label</key><string>com.ka.registry-sync</string> | |
| 165 | + <key>ProgramArguments</key><array> | |
| 166 | + <string>/bin/zsh</string> | |
| 167 | + <string>/Users/simon-pierreboucher/ka-registry-sync.sh</string> | |
| 168 | + </array> | |
| 169 | + <key>RunAtLoad</key><true/><key>KeepAlive</key><true/> | |
| 170 | + <key>StandardOutPath</key><string>/Users/simon-pierreboucher/ka-guardian-spool/registry-sync.log</string> | |
| 171 | + <key>StandardErrorPath</key><string>/Users/simon-pierreboucher/ka-guardian-spool/registry-sync.log</string> | |
| 172 | +</dict></plist> | |
| 173 | +PLIST | |
| 174 | + mkdir -p ~/ka-guardian-spool | |
| 175 | + lc_reload com.ka.registry-sync ~/Library/LaunchAgents/com.ka.registry-sync.plist | |
| 176 | + sleep 12; ls -la ~/ka-guardian-spool/registry.json ~/ka-guardian-spool/runners.json && tail -2 ~/ka-guardian-spool/registry-sync.log' \ | |
| 177 | + && echo " ✓ registry-sync ok" || echo " ✗ registry-sync KO" | |
| 178 | +} | |
| 179 | + | |
| 52 | 180 | deploy_runners() { |
| 53 | 181 | for n in $RUNNER_NODES; do |
| 54 | 182 | echo "=== runner → $n" |
| 183 | + if ! ssh -o ConnectTimeout=8 -o BatchMode=yes $n true 2>/dev/null; then | |
| 184 | + echo " ✗ $n injoignable en SSH — runner NON déployé"; continue | |
| 185 | + fi | |
| 55 | 186 | # GOTCHA: redémarrer un runner TUE la mission claude en cours (cause de la |
| 56 | 187 | # mission fantôme du 2026-08-23). On refuse si le runner est occupé. |
| 57 | 188 | busy=$(ssh $n "curl -s -m 5 localhost:7791/health 2>/dev/null" | grep -o '"busy":true' || true) |
@@ -59,12 +190,16 @@ deploy_runners() { | ||
| 59 | 190 | echo " ⏸ $n occupé (mission en cours) — runner NON redéployé, relance plus tard" |
| 60 | 191 | continue |
| 61 | 192 | fi |
| 193 | + # pré-requis d'un nœud runner : claude CLI + clés dans ~/.claude/.env | |
| 194 | + ssh $n 'test -x /opt/homebrew/bin/claude && test -s ~/.claude/.env' \ | |
| 195 | + || { echo " ✗ $n : /opt/homebrew/bin/claude ou ~/.claude/.env manquant — équiper le nœud avant (npm i -g @anthropic-ai/claude-code ; copier ~/.claude/.env)"; continue; } | |
| 62 | 196 | ssh $n "mkdir -p ~/ka-guardian-runner/transcripts" |
| 63 | 197 | scp -q "$ROOT/runner/runner.py" $n:ka-guardian-runner/runner.py |
| 64 | − ssh $n "printf '%s\nKA_GUARDIAN_NODE=%s\n' '$TOKEN_LINE' '$n' > ~/.ka-guardian.env | |
| 198 | + remote $n "printf '%s\nKA_GUARDIAN_NODE=%s\n' '$TOKEN_LINE' '$n' > ~/.ka-guardian.env | |
| 65 | 199 | cd ~/ka-guardian-runner |
| 66 | − [[ -d .venv ]] || $PY -m venv .venv | |
| 67 | − ./.venv/bin/pip -q install 'fastapi>=0.110' 'uvicorn>=0.29' 'httpx>=0.27' 'pydantic>=2' >/dev/null | |
| 200 | + PY=\$(command -v /opt/homebrew/bin/python3 || command -v /opt/homebrew/bin/python3.13 || command -v /opt/homebrew/bin/python3.14) | |
| 201 | + [[ -d .venv ]] || \$PY -m venv .venv | |
| 202 | + ./.venv/bin/pip -q install 'fastapi>=0.110' 'uvicorn>=0.29' 'httpx>=0.27' 'pydantic>=2' 2>&1 | grep -v '^\[notice\]' || true | |
| 68 | 203 | cat > ~/Library/LaunchAgents/com.ka.guardian-runner.plist <<'PLIST' |
| 69 | 204 | <?xml version=\"1.0\" encoding=\"UTF-8\"?> |
| 70 | 205 | <!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\"> |
@@ -82,9 +217,8 @@ deploy_runners() { | ||
| 82 | 217 | <key>StandardErrorPath</key><string>/Users/simon-pierreboucher/ka-guardian-runner/runner.log</string> |
| 83 | 218 | </dict></plist> |
| 84 | 219 | PLIST |
| 85 | − launchctl unload ~/Library/LaunchAgents/com.ka.guardian-runner.plist 2>/dev/null || true | |
| 86 | − launchctl load ~/Library/LaunchAgents/com.ka.guardian-runner.plist" | |
| 87 | − sleep 2 | |
| 220 | + lc_reload com.ka.guardian-runner ~/Library/LaunchAgents/com.ka.guardian-runner.plist" | |
| 221 | + sleep 4 | |
| 88 | 222 | ssh $n "curl -sf localhost:7791/health" && echo " ✓ $n runner ok" || echo " ✗ $n runner KO" |
| 89 | 223 | done |
| 90 | 224 | } |
@@ -99,20 +233,22 @@ deploy_orchestrators() { | ||
| 99 | 233 | | ssh $ORCH_NODE 'tar xzf - -C ~/cluster-projects/ka-guardian/' |
| 100 | 234 | loc=$(md5 -q orchestrator/main.py); rem=$(ssh $ORCH_NODE 'md5 -q ~/cluster-projects/ka-guardian/orchestrator/main.py') |
| 101 | 235 | [[ "$loc" == "$rem" ]] || { echo "✗ transfert raté: main.py ($loc ≠ $rem)"; exit 1; } |
| 102 | − ssh $ORCH_NODE "cd ~/cluster-projects/ka-guardian | |
| 236 | + remote $ORCH_NODE "cd ~/cluster-projects/ka-guardian | |
| 103 | 237 | PY=\$(command -v /opt/homebrew/bin/python3 || command -v /opt/homebrew/bin/python3.13) |
| 104 | 238 | [[ -d .venv ]] || \$PY -m venv .venv |
| 105 | 239 | ./.venv/bin/pip -q install 'fastapi>=0.110' 'uvicorn>=0.29' 'httpx>=0.27' >/dev/null |
| 106 | 240 | mkdir -p data logs |
| 107 | 241 | # retirer les anciens bots (web+bot), garder les tunnels ngrok |
| 108 | 242 | for a in ka2 ka4 ka6; do |
| 109 | − launchctl unload ~/Library/LaunchAgents/com.\$a.web.plist 2>/dev/null || true | |
| 110 | − launchctl unload ~/Library/LaunchAgents/com.\$a.bot.plist 2>/dev/null || true | |
| 243 | + launchctl bootout gui/\$(id -u)/com.\$a.web 2>/dev/null || launchctl unload ~/Library/LaunchAgents/com.\$a.web.plist 2>/dev/null || true | |
| 244 | + launchctl bootout gui/\$(id -u)/com.\$a.bot 2>/dev/null || launchctl unload ~/Library/LaunchAgents/com.\$a.bot.plist 2>/dev/null || true | |
| 111 | 245 | rm -f ~/Library/LaunchAgents/com.\$a.web.plist ~/Library/LaunchAgents/com.\$a.bot.plist |
| 112 | 246 | done" |
| 247 | + # IP LAN de l'orchestrateur (callback des runners) : registre mld, repli .69 | |
| 248 | + SELF_IP=$(/usr/bin/python3 -c "import json,sys; r=json.load(open(sys.argv[1]))['apps']; print(r.get('ka2',{}).get('ip') or '192.168.2.69')" "$REG_JSON") | |
| 113 | 249 | for a in ka2:8799 ka4:8899 ka6:8999; do |
| 114 | 250 | agent=${a%%:*}; port=${a##*:} |
| 115 | − ssh $ORCH_NODE "cat > ~/Library/LaunchAgents/com.$agent.guardian.plist <<PLIST | |
| 251 | + remote $ORCH_NODE "cat > ~/Library/LaunchAgents/com.$agent.guardian.plist <<PLIST | |
| 116 | 252 | <?xml version=\"1.0\" encoding=\"UTF-8\"?> |
| 117 | 253 | <!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\"> |
| 118 | 254 | <plist version=\"1.0\"><dict> |
@@ -123,7 +259,7 @@ deploy_orchestrators() { | ||
| 123 | 259 | </array> |
| 124 | 260 | <key>EnvironmentVariables</key><dict> |
| 125 | 261 | <key>AGENT</key><string>$agent</string> |
| 126 | − <key>KA_GUARDIAN_SELF_IP</key><string>192.168.2.69</string> | |
| 262 | + <key>KA_GUARDIAN_SELF_IP</key><string>$SELF_IP</string> | |
| 127 | 263 | <key>PATH</key><string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string> |
| 128 | 264 | </dict> |
| 129 | 265 | <key>RunAtLoad</key><true/><key>KeepAlive</key><true/> |
@@ -131,8 +267,7 @@ deploy_orchestrators() { | ||
| 131 | 267 | <key>StandardErrorPath</key><string>/Users/simon-pierreboucher/cluster-projects/ka-guardian/logs/$agent.log</string> |
| 132 | 268 | </dict></plist> |
| 133 | 269 | PLIST |
| 134 | − launchctl unload ~/Library/LaunchAgents/com.$agent.guardian.plist 2>/dev/null || true | |
| 135 | − launchctl load ~/Library/LaunchAgents/com.$agent.guardian.plist" | |
| 270 | + lc_reload com.$agent.guardian ~/Library/LaunchAgents/com.$agent.guardian.plist" | |
| 136 | 271 | sleep 2 |
| 137 | 272 | ssh $ORCH_NODE "curl -sf localhost:$port/health" && echo " ✓ $agent ok (:$port)" || echo " ✗ $agent KO" |
| 138 | 273 | done |
@@ -142,25 +277,17 @@ deploy_pousseur() { | ||
| 142 | 277 | # Pousseur launchd (binaires Apple) : pousse les commits en attente des repos |
| 143 | 278 | # Ka du nœud — les missions Claude ne peuvent pas pousser elles-mêmes |
| 144 | 279 | # (macOS 26 Local Network Privacy bloque les descendants du runner python). |
| 145 | − typeset -A REPOS | |
| 146 | − REPOS[M3U96a]='~/apps/vrai-prix | |
| 147 | −~/apps/sorti-ka | |
| 148 | −~/apps/job-ka' | |
| 149 | − REPOS[M3U96b]='~/apps/groupe-ka | |
| 150 | −~/apps/lou-ka | |
| 151 | −/opt/api-ka | |
| 152 | −~/apps/crea-ka | |
| 153 | −~/apps/resto-ka' | |
| 154 | − REPOS[M4M64a]='~/apps/immo-ka | |
| 155 | −~/fabri-ka' | |
| 156 | − REPOS[M4M64b]='~/auto-ka | |
| 157 | −~/apps/food-ka' | |
| 280 | + # Les repos viennent du registre mld ; ~/.ka-pousseur-repos est FUSIONNÉ (jamais | |
| 281 | + # écrasé : mld y inscrit aussi les repos à l'arrivée d'une app sur le nœud). | |
| 282 | + local repos_tsv; repos_tsv="$(registry_lines repos)" | |
| 158 | 283 | for n in $RUNNER_NODES; do |
| 159 | 284 | echo "=== pousseur → $n" |
| 285 | + local mine; mine=$(print -r -- "$repos_tsv" | awk -F'\t' -v n="$n" '$1==n{print $2}') | |
| 160 | 286 | scp -q "$ROOT/deploy/pousseur.sh" $n:ka-pousseur.sh |
| 161 | − ssh $n "chmod +x ~/ka-pousseur.sh | |
| 162 | − cat > ~/.ka-pousseur-repos <<'REPOS' | |
| 163 | −${REPOS[$n]} | |
| 287 | + remote $n "chmod +x ~/ka-pousseur.sh | |
| 288 | + touch ~/.ka-pousseur-repos | |
| 289 | + while IFS= read -r d; do [ -n \"\$d\" ] && { grep -qxF \"\$d\" ~/.ka-pousseur-repos || echo \"\$d\" >> ~/.ka-pousseur-repos; }; done <<'REPOS' | |
| 290 | +$mine | |
| 164 | 291 | REPOS |
| 165 | 292 | cat > ~/Library/LaunchAgents/com.ka.pousseur.plist <<'PLIST' |
| 166 | 293 | <?xml version=\"1.0\" encoding=\"UTF-8\"?> |
@@ -177,17 +304,20 @@ REPOS | ||
| 177 | 304 | </dict></plist> |
| 178 | 305 | PLIST |
| 179 | 306 | mkdir -p ~/ka-guardian-spool |
| 180 | − launchctl unload ~/Library/LaunchAgents/com.ka.pousseur.plist 2>/dev/null || true | |
| 181 | − launchctl load ~/Library/LaunchAgents/com.ka.pousseur.plist" \ | |
| 307 | + lc_reload com.ka.pousseur ~/Library/LaunchAgents/com.ka.pousseur.plist | |
| 308 | + echo \" repos: \$(tr '\n' ' ' < ~/.ka-pousseur-repos)\"" \ | |
| 182 | 309 | && echo " ✓ $n pousseur ok" || echo " ✗ $n pousseur KO" |
| 183 | 310 | done |
| 184 | 311 | } |
| 185 | 312 | |
| 186 | 313 | case "${1:-all}" in |
| 314 | + nodes) registry_lines repos ;; | |
| 187 | 315 | courier) deploy_courier ;; |
| 316 | + registry-sync) deploy_registry_sync ;; | |
| 188 | 317 | runners) deploy_runners ;; |
| 189 | 318 | orchestrators) deploy_orchestrators ;; |
| 190 | 319 | pousseur) deploy_pousseur ;; |
| 191 | − all) deploy_courier; deploy_runners; deploy_orchestrators; deploy_pousseur ;; | |
| 320 | + all) deploy_courier; deploy_registry_sync; deploy_runners; deploy_orchestrators; deploy_pousseur ;; | |
| 192 | 321 | esac |
| 322 | +rm -f "$REG_JSON" | |
| 193 | 323 | echo "terminé." |
added
deploy/registry-sync.sh
+66 −0
@@ -0,0 +1,66 @@ | ||
| 1 | +#!/bin/zsh | |
| 2 | +# ============================================ | |
| 3 | +# KA Guardian — synchronisation du registre mld + sonde des runners (100 % zsh) | |
| 4 | +# Le registre des déploiements (M1M32:~/dispatch/registry.json) dit où tourne | |
| 5 | +# chaque app du Groupe Ka. mld le POUSSE déjà dans le spool à chaque sauvegarde ; | |
| 6 | +# ce service le TIRE en plus toutes les 2 min (ceinture et bretelles), puis | |
| 7 | +# sonde le runner :7791 de chaque nœud hébergeur pour que les orchestrateurs | |
| 8 | +# sachent, AVANT de dépêcher une mission, si le nœud est équipé. | |
| 9 | +# Chaîne launchd → zsh → /usr/bin/ssh : macOS 26 « Local Network Privacy » | |
| 10 | +# refuse le LAN aux descendants de python (homebrew) — jamais de python ici. | |
| 11 | +# ~/ka-guardian-spool/registry.json ← copie du registre (écriture atomique) | |
| 12 | +# ~/ka-guardian-spool/runners.json ← {"<ip>": {"ok":bool,"node":…,"busy":…,"ts":epoch}} | |
| 13 | +# ============================================ | |
| 14 | +export PATH=/usr/bin:/bin:/usr/sbin:/sbin | |
| 15 | +SPOOL=$HOME/ka-guardian-spool | |
| 16 | +KEY=$HOME/.ssh/ka_guardian_ed25519 | |
| 17 | +LOG=$SPOOL/registry-sync.log | |
| 18 | +INTERVAL=${KA_REGISTRY_INTERVAL:-120} | |
| 19 | +mkdir -p $SPOOL | |
| 20 | + | |
| 21 | +log() { print "$(date '+%F %T') $*" >> $LOG; } | |
| 22 | + | |
| 23 | +# JSON minimal sans python : extraire les IP uniques des apps du registre. | |
| 24 | +registry_ips() { | |
| 25 | + # les entrées ont la forme "ip": "192.168.2.83" | |
| 26 | + grep -o '"ip": *"[0-9.]*"' $SPOOL/registry.json 2>/dev/null | grep -o '[0-9.]*[0-9]' | sort -u | |
| 27 | +} | |
| 28 | + | |
| 29 | +while true; do | |
| 30 | + # 1. tirer le registre (alias gitsrv = passerelle M1M32, clé ka_guardian autorisée) | |
| 31 | + if /usr/bin/ssh -o BatchMode=yes -o ConnectTimeout=8 gitsrv 'cat ~/dispatch/registry.json' > $SPOOL/registry.json.pull 2>>$LOG \ | |
| 32 | + && grep -q '"apps"' $SPOOL/registry.json.pull; then | |
| 33 | + if ! cmp -s $SPOOL/registry.json.pull $SPOOL/registry.json; then | |
| 34 | + mv -f $SPOOL/registry.json.pull $SPOOL/registry.json | |
| 35 | + log "registre mis à jour ($(grep -o '"updated": *"[^"]*"' $SPOOL/registry.json | head -1))" | |
| 36 | + else | |
| 37 | + rm -f $SPOOL/registry.json.pull | |
| 38 | + fi | |
| 39 | + else | |
| 40 | + rm -f $SPOOL/registry.json.pull | |
| 41 | + log "tirage du registre impossible (gitsrv injoignable) — copie précédente conservée" | |
| 42 | + fi | |
| 43 | + | |
| 44 | + # 2. sonder les runners des nœuds hébergeurs | |
| 45 | + out="{" | |
| 46 | + first=1 | |
| 47 | + for ip in $(registry_ips); do | |
| 48 | + resp=$(/usr/bin/ssh -i $KEY -o BatchMode=yes -o ConnectTimeout=6 -o StrictHostKeyChecking=accept-new \ | |
| 49 | + -o ControlMaster=auto -o ControlPath=/tmp/kg-cm-%h -o ControlPersist=120 \ | |
| 50 | + simon-pierreboucher@$ip "/usr/bin/curl -s -m 4 http://127.0.0.1:7791/health" 2>/dev/null) | |
| 51 | + ts=$(date +%s) | |
| 52 | + if [[ $resp == *'"ok":true'* ]]; then | |
| 53 | + node=$(print -r -- $resp | grep -o '"node":"[^"]*"' | cut -d'"' -f4) | |
| 54 | + busy=$([[ $resp == *'"busy":true'* ]] && echo true || echo false) | |
| 55 | + row="\"$ip\":{\"ok\":true,\"node\":\"$node\",\"busy\":$busy,\"ts\":$ts}" | |
| 56 | + else | |
| 57 | + row="\"$ip\":{\"ok\":false,\"node\":null,\"busy\":false,\"ts\":$ts}" | |
| 58 | + fi | |
| 59 | + [[ $first -eq 1 ]] && first=0 || out+="," | |
| 60 | + out+=$row | |
| 61 | + done | |
| 62 | + out+="}" | |
| 63 | + print -r -- $out > $SPOOL/runners.json.tmp && mv -f $SPOOL/runners.json.tmp $SPOOL/runners.json | |
| 64 | + | |
| 65 | + sleep $INTERVAL | |
| 66 | +done | |
modified
orchestrator/main.py
+94 −16
@@ -25,6 +25,8 @@ from fastapi import FastAPI, Header, HTTPException, Request | ||
| 25 | 25 | from fastapi.responses import FileResponse, HTMLResponse, StreamingResponse |
| 26 | 26 | from fastapi.staticfiles import StaticFiles |
| 27 | 27 | |
| 28 | +import registry # topologie vivante (registre mld) — même dossier que main.py | |
| 29 | + | |
| 28 | 30 | BASE = pathlib.Path(__file__).resolve().parent |
| 29 | 31 | ROOT = BASE.parent |
| 30 | 32 | HOME = pathlib.Path.home() |
@@ -33,10 +35,59 @@ AGENT = os.environ.get("AGENT", "ka2") | ||
| 33 | 35 | TOPO = json.loads((ROOT / "topology.json").read_text()) |
| 34 | 36 | ME = TOPO["agents"][AGENT] |
| 35 | 37 | POLICY = TOPO["policy"] |
| 38 | +# SERVICES / NODES : dicts mutés EN PLACE par registry.refresh() — l'emplacement | |
| 39 | +# réel (nœud, IP, dir, port, PM2) vient du registre mld, topology.json n'est | |
| 40 | +# que le repli si le registre n'a jamais été reçu. | |
| 36 | 41 | SERVICES: dict[str, Any] = TOPO["services"] |
| 37 | 42 | NODES: dict[str, Any] = TOPO["nodes"] |
| 38 | 43 | MY_SERVICES: set[str] = set(ME["services"]) |
| 39 | 44 | RUNNER_PORT = TOPO["runner_port"] |
| 45 | +try: # première application au chargement (silencieuse : le hub SSE n'existe pas encore) | |
| 46 | + for _m in registry.refresh(TOPO, SERVICES, NODES): | |
| 47 | + print(f"[registre] {_m}", flush=True) | |
| 48 | +except Exception as _exc: | |
| 49 | + print(f"[registre] indisponible au démarrage ({_exc}) — topology.json en repli", flush=True) | |
| 50 | + | |
| 51 | + | |
| 52 | +def node_alias(node_key: str) -> str: | |
| 53 | + return (NODES.get(node_key) or {}).get("alias", node_key) | |
| 54 | + | |
| 55 | + | |
| 56 | +def node_ip(node_key: str) -> str | None: | |
| 57 | + return (NODES.get(node_key) or {}).get("lan_ip") | |
| 58 | + | |
| 59 | + | |
| 60 | +def apply_registry(announce: bool = True) -> None: | |
| 61 | + """Relit le registre mld s'il a changé et signale les déménagements.""" | |
| 62 | + try: | |
| 63 | + changes = registry.refresh(TOPO, SERVICES, NODES) | |
| 64 | + except Exception as exc: # jamais fatal : on garde la dernière topologie connue | |
| 65 | + print(f"[registre] échec: {exc}", flush=True) | |
| 66 | + return | |
| 67 | + for msg in changes: | |
| 68 | + print(f"[registre] {msg}", flush=True) | |
| 69 | + if announce: | |
| 70 | + hub.publish_sync({"kind": "log", "level": "warn", "msg": f"registre mld — {msg}", "ts": now()}) | |
| 71 | + | |
| 72 | + | |
| 73 | +_RUNNER_WARNED: dict[str, float] = {} | |
| 74 | + | |
| 75 | + | |
| 76 | +def runner_available(node_key: str, what: str) -> bool: | |
| 77 | + """False si la sonde registry-sync dit qu'il n'y a PAS de runner sur ce nœud | |
| 78 | + (app déménagée sur un nœud jamais équipé) — on n'envoie alors rien au | |
| 79 | + courrier, et on avertit au plus une fois par heure par nœud.""" | |
| 80 | + ip = node_ip(node_key) | |
| 81 | + rs = registry.runner_status(ip) | |
| 82 | + if rs is None or rs.get("ok"): | |
| 83 | + return True | |
| 84 | + if now() - _RUNNER_WARNED.get(node_key, 0) > 3600: | |
| 85 | + _RUNNER_WARNED[node_key] = now() | |
| 86 | + msg = (f"aucun runner gardien sur {node_alias(node_key)} ({ip}) — {what} impossible ; " | |
| 87 | + f"déployer avec deploy/deploy.sh runners (le nœud est listé par le registre)") | |
| 88 | + print(f"[runner] {msg}", flush=True) | |
| 89 | + hub.publish_sync({"kind": "log", "level": "error", "msg": msg, "ts": now()}) | |
| 90 | + return False | |
| 40 | 91 | |
| 41 | 92 | # ka6 (ou autre) ramasse les services non assignés qui apparaîtraient dans api-ka. |
| 42 | 93 | DEFAULT_AGENT = ME.get("default_for_unknown_services", False) |
@@ -206,8 +257,8 @@ def mission_history(service: str, source: str) -> str: | ||
| 206 | 257 | |
| 207 | 258 | def build_prompt(service: str, source: str, health: dict[str, Any]) -> str: |
| 208 | 259 | svc = SERVICES[service] |
| 209 | − sync_proc = next((p for p in svc["pm2"] if "sync" in p or "etl" in p), svc["pm2"][-1]) | |
| 210 | − node_alias = NODES[svc["node"]]["alias"] | |
| 260 | + sync_proc = next((p for p in svc["pm2"] if "sync" in p or "etl" in p), (svc["pm2"][-1] if svc["pm2"] else "?")) | |
| 261 | + node_alias_ = node_alias(svc["node"]) | |
| 211 | 262 | return f"""Tu es {AGENT}, agent gardien autonome des connecteurs du Groupe KA. Mission: réparer le connecteur « {source} » de l'app {svc['app']} (service api-ka: {service}, site {svc.get('site', '')}). |
| 212 | 263 | |
| 213 | 264 | == CONTEXTE SANTÉ DU CONNECTEUR (supervision api-ka, scan aux 2 h) == |
@@ -223,7 +274,7 @@ Rappel des statuts: broken = ≥3 syncs consécutifs en échec ou à 0 résultat | ||
| 223 | 274 | {mission_history(service, source)} |
| 224 | 275 | |
| 225 | 276 | == 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). | |
| 277 | +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 | 278 | 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 | 279 | Le venv Python et/ou node_modules du repo sont déjà installés — utilise-les, jamais d'installation globale. |
| 229 | 280 | |
@@ -267,11 +318,11 @@ def effort_stack(service: str) -> str: | ||
| 267 | 318 | |
| 268 | 319 | def build_effort_prompt(kind: str, service: str, source: str, note: str) -> str: |
| 269 | 320 | svc = SERVICES[service] |
| 270 | − sync_proc = next((p for p in svc["pm2"] if "sync" in p or "etl" in p), svc["pm2"][-1]) | |
| 271 | − node_alias = NODES[svc["node"]]["alias"] | |
| 321 | + sync_proc = next((p for p in svc["pm2"] if "sync" in p or "etl" in p), (svc["pm2"][-1] if svc["pm2"] else "?")) | |
| 322 | + node_alias_ = node_alias(svc["node"]) | |
| 272 | 323 | rules = COMMON_RULES.replace("%AGENT%", AGENT) |
| 273 | 324 | env = f"""== TON ENVIRONNEMENT == |
| 274 | −Tu es sur le nœud {node_alias}, répertoire courant = repo de l'app: {svc['dir']} (remote-first, source de vérité). | |
| 325 | +Tu es sur le nœud {node_alias_}, répertoire courant = repo de l'app: {svc['dir']} (remote-first, source de vérité). | |
| 275 | 326 | L'app tourne via pm2 ({', '.join(svc['pm2'])}); process de sync: {sync_proc}; site local port {svc['web_port']}. |
| 276 | 327 | Commence par lire le CLAUDE.md / README du repo et docs/connecteurs/ s'ils existent.""" |
| 277 | 328 | if kind == "effort_degrade": |
@@ -350,8 +401,8 @@ Termine ta TOUTE DERNIÈRE réponse par un bloc JSON: | ||
| 350 | 401 | |
| 351 | 402 | def build_site_down_prompt(service: str, detail: dict[str, Any]) -> str: |
| 352 | 403 | svc = SERVICES[service] |
| 353 | − web_proc = svc["pm2"][0] | |
| 354 | − node_alias = NODES[svc["node"]]["alias"] | |
| 404 | + web_proc = svc["pm2"][0] if svc["pm2"] else "?" | |
| 405 | + node_alias_ = node_alias(svc["node"]) | |
| 355 | 406 | return f"""Tu es {AGENT}, agent gardien autonome du Groupe KA. MISSION URGENTE: le SITE {svc.get('site', '')} de l'app {svc['app']} NE RÉPOND PLUS, et le redémarrage pm2 automatique n'a pas suffi. Investigue, trouve la cause racine et remets le site en ligne. |
| 356 | 407 | Session 100 % AUTONOME: tu ne poses aucune question, tu travailles jusqu'au bout. |
| 357 | 408 | |
@@ -361,7 +412,7 @@ Session 100 % AUTONOME: tu ne poses aucune question, tu travailles jusqu'au bout | ||
| 361 | 412 | - redémarrage pm2 automatique: {detail.get('restart', 'tenté')} — santé locale mesurée juste après: {jdump(detail.get('health')) if detail.get('health') else 'inconnue'} |
| 362 | 413 | |
| 363 | 414 | == TON ENVIRONNEMENT == |
| 364 | −Tu es sur le nœud {node_alias}, répertoire courant = repo de l'app: {svc['dir']} (remote-first, source de vérité). | |
| 415 | +Tu es sur le nœud {node_alias_}, répertoire courant = repo de l'app: {svc['dir']} (remote-first, source de vérité). | |
| 365 | 416 | Process pm2 de l'app: {', '.join(svc['pm2'])} (web: {web_proc}); le site local doit répondre sur http://localhost:{svc['web_port']}/ et le site public est {svc.get('site', '')} (tunnel ngrok sur ce nœud, souvent un process pm2 `<app>-ngrok` ou un service launchd). |
| 366 | 417 | |
| 367 | 418 | == DÉMARCHE IMPOSÉE (dans l'ordre) == |
@@ -388,9 +439,16 @@ async def dispatch(incident: sqlite3.Row, health: dict[str, Any]) -> None: | ||
| 388 | 439 | service, source, iid = incident["service"], incident["source"], incident["id"] |
| 389 | 440 | svc = SERVICES[service] |
| 390 | 441 | node = svc["node"] |
| 391 | − ip = NODES[node]["lan_ip"] | |
| 442 | + ip = node_ip(node) | |
| 443 | + if not ip: | |
| 444 | + hub.publish_sync({"kind": "log", "level": "error", "ts": now(), | |
| 445 | + "msg": f"dispatch {source}: IP LAN inconnue pour le nœud {node_alias(node)} (registre incomplet)"}) | |
| 446 | + return | |
| 447 | + if not runner_available(node, f"mission {source}"): | |
| 448 | + return # l'incident reste ouvert ; on retentera quand le runner existera | |
| 392 | 449 | mid = uuid.uuid4().hex[:12] |
| 393 | − my_ip = os.environ.get("KA_GUARDIAN_SELF_IP", "192.168.2.69") | |
| 450 | + # IP du gardien lui-même (callback du runner) : env launchd, sinon le registre mld. | |
| 451 | + my_ip = os.environ.get("KA_GUARDIAN_SELF_IP") or (registry.app_entry(AGENT) or {}).get("ip") or "192.168.2.69" | |
| 394 | 452 | kind = incident["status_detected"] |
| 395 | 453 | max_cost = None |
| 396 | 454 | if kind in ("effort_new", "effort_enrich", "effort_degrade"): |
@@ -432,14 +490,25 @@ async def dispatch(incident: sqlite3.Row, health: dict[str, Any]) -> None: | ||
| 432 | 490 | c.execute("INSERT INTO missions(id,incident_id,service,source,node,state,started) VALUES(?,?,?,?,?,?,?)", |
| 433 | 491 | (mid, iid, service, source, node, "running", now())) |
| 434 | 492 | set_incident(c, iid, state="fixing", attempts=incident["attempts"] + 1) |
| 435 | − incident_event(iid, service, source, "fixing", f"mission {mid} dépêchée sur {NODES[node]['alias']}") | |
| 493 | + incident_event(iid, service, source, "fixing", f"mission {mid} dépêchée sur {node_alias(node)}") | |
| 436 | 494 | hub.publish_sync({"kind": "mission", "mission_id": mid, "service": service, |
| 437 | 495 | "source": source, "state": "running", "ts": now()}) |
| 438 | 496 | |
| 439 | 497 | |
| 440 | 498 | async def rollback_mission(mission: sqlite3.Row, reason: str) -> dict[str, Any]: |
| 441 | 499 | svc = SERVICES[mission["service"]] |
| 442 | − ip = NODES[mission["node"]]["lan_ip"] | |
| 500 | + # Le repo (et son historique git) suit l'app : si elle a déménagé depuis la | |
| 501 | + # mission, le rollback vise le nœud ACTUEL du service, pas celui de la mission. | |
| 502 | + node = svc.get("node") or mission["node"] | |
| 503 | + ip = node_ip(node) or node_ip(mission["node"]) | |
| 504 | + if node != mission["node"]: | |
| 505 | + hub.publish_sync({"kind": "log", "level": "warn", "ts": now(), | |
| 506 | + "msg": f"rollback {mission['source']}: l'app a déménagé {node_alias(mission['node'])} → {node_alias(node)}, rollback sur le nœud actuel"}) | |
| 507 | + if not ip or not runner_available(node, f"rollback {mission['source']}"): | |
| 508 | + out = {"ok": False, "erreur": f"runner indisponible sur {node_alias(node)}"} | |
| 509 | + hub.publish_sync({"kind": "rollback", "mission_id": mission["id"], "service": mission["service"], | |
| 510 | + "source": mission["source"], "reason": reason, "result": out, "ts": now()}) | |
| 511 | + return out | |
| 443 | 512 | try: |
| 444 | 513 | code, body = await lan_post(ip, RUNNER_PORT, "/rollback", |
| 445 | 514 | {"dir": svc["dir"], "base_commit": mission["base_commit"], |
@@ -540,6 +609,7 @@ async def poll_once() -> None: | ||
| 540 | 609 | |
| 541 | 610 | |
| 542 | 611 | async def tick() -> None: |
| 612 | + apply_registry() # même en pause : la topologie doit rester juste | |
| 543 | 613 | if LATEST["paused"]: |
| 544 | 614 | return |
| 545 | 615 | try: |
@@ -647,7 +717,9 @@ async def reconcile_mission(mid: str) -> None: | ||
| 647 | 717 | m = c.execute("SELECT * FROM missions WHERE id=? AND state='running'", (mid,)).fetchone() |
| 648 | 718 | if not m: |
| 649 | 719 | return |
| 650 | − ip = NODES[m["node"]]["lan_ip"] | |
| 720 | + ip = node_ip(m["node"]) | |
| 721 | + if not ip: | |
| 722 | + return # nœud disparu du registre et de topology.json : on retentera | |
| 651 | 723 | try: |
| 652 | 724 | code, body = await lan_post(ip, RUNNER_PORT, "/mission-result", {"mission_id": mid}, timeout=20) |
| 653 | 725 | resp = json.loads(body) if code == 200 else {"status": "erreur"} |
@@ -713,7 +785,9 @@ async def check_site(url: str) -> tuple[bool, str]: | ||
| 713 | 785 | async def runner_restart(service: str) -> dict[str, Any]: |
| 714 | 786 | """pm2 restart de l'app via le runner de son nœud (endpoint /restart).""" |
| 715 | 787 | svc = SERVICES[service] |
| 716 | − ip = NODES[svc["node"]]["lan_ip"] | |
| 788 | + ip = node_ip(svc["node"]) | |
| 789 | + if not ip or not runner_available(svc["node"], f"pm2 restart {svc['app']}"): | |
| 790 | + return {"ok": False, "erreur": f"runner indisponible sur {node_alias(svc['node'])}"} | |
| 717 | 791 | try: |
| 718 | 792 | code, body = await lan_post(ip, RUNNER_PORT, "/restart", |
| 719 | 793 | {"pm2": svc["pm2"], "web_port": svc["web_port"]}, timeout=240) |
@@ -937,7 +1011,11 @@ def state() -> dict[str, Any]: | ||
| 937 | 1011 | "SELECT ts, mine FROM snapshots WHERE ts > ? ORDER BY ts", (now() - 7 * 86400,)).fetchall()] |
| 938 | 1012 | return { |
| 939 | 1013 | "agent": AGENT, "identity": {k: ME.get(k) for k in ("domain", "accent", "accent2", "accent_soft", "tagline", "model")}, |
| 940 | − "services": {s: {**SERVICES[s], "node_alias": NODES[SERVICES[s]["node"]]["alias"]} for s in ME["services"]}, | |
| 1014 | + "services": {s: {**SERVICES[s], "node_alias": node_alias(SERVICES[s]["node"]), | |
| 1015 | + "lan_ip": node_ip(SERVICES[s]["node"]), | |
| 1016 | + "runner": registry.runner_status(node_ip(SERVICES[s]["node"]))} | |
| 1017 | + for s in ME["services"] if s in SERVICES}, | |
| 1018 | + "registry": registry.info(), | |
| 941 | 1019 | "siblings": {a: {"domain": v["domain"], "accent": v["accent"], "services": v["services"]} |
| 942 | 1020 | for a, v in TOPO["agents"].items() if a != AGENT}, |
| 943 | 1021 | "latest": LATEST, "sites": SITE, "incidents": incidents, "missions": missions, |
added
orchestrator/registry.py
+174 −0
@@ -0,0 +1,174 @@ | ||
| 1 | +# ============================================ | |
| 2 | +# Projet : KA Guardian | |
| 3 | +# Fichier : orchestrator/registry.py | |
| 4 | +# Rôle : Topologie VIVANTE des services surveillés. Le registre de la | |
| 5 | +# passerelle mld (M1M32:~/dispatch/registry.json) fait foi pour | |
| 6 | +# l'emplacement de chaque app (nœud, IP LAN, répertoire, port, | |
| 7 | +# process PM2) ; topology.json ne garde que ce qui est propre aux | |
| 8 | +# gardiens (agents, politique, correspondance service → app). | |
| 9 | +# Author : Simon-Pierre Boucher | |
| 10 | +# Date : 2026-09-04 | |
| 11 | +# ============================================ | |
| 12 | +"""Le registre arrive dans le spool par deux chemins indépendants : | |
| 13 | + 1. poussé par mld à chaque sauvegarde (abonné `ka2` → ~/ka-guardian-spool/registry.json) ; | |
| 14 | + 2. tiré toutes les 2 min par le service launchd zsh `com.ka.registry-sync` | |
| 15 | + (deploy/registry-sync.sh : `ssh gitsrv cat ~/dispatch/registry.json`) — | |
| 16 | + chaîne 100 % binaires Apple, car macOS 26 LNP refuse le LAN à python. | |
| 17 | +Le même service sonde le runner :7791 de chaque nœud hébergeur → runners.json, | |
| 18 | +ce qui permet de refuser proprement une mission vers un nœud sans runner. | |
| 19 | +""" | |
| 20 | +from __future__ import annotations | |
| 21 | + | |
| 22 | +import json | |
| 23 | +import pathlib | |
| 24 | +import time | |
| 25 | +from typing import Any | |
| 26 | + | |
| 27 | +HOME = pathlib.Path.home() | |
| 28 | +SPOOL = HOME / "ka-guardian-spool" | |
| 29 | +REGISTRY_PATH = SPOOL / "registry.json" | |
| 30 | +RUNNERS_PATH = SPOOL / "runners.json" | |
| 31 | +STALE_AFTER_S = 3 * 3600 # registre plus vieux que ça (mtime) → signalé « périmé » | |
| 32 | + | |
| 33 | +STATE: dict[str, Any] = { | |
| 34 | + "path": str(REGISTRY_PATH), | |
| 35 | + "mtime": 0.0, # mtime du fichier chargé | |
| 36 | + "loaded_at": 0.0, # quand on l'a lu | |
| 37 | + "updated": None, # champ `updated` du registre (heure mld) | |
| 38 | + "gateway": None, | |
| 39 | + "n_apps": 0, | |
| 40 | + "error": None, | |
| 41 | + "resolved": {}, # service → {app, node_alias, ip, dir, web_port, pm2, status} | |
| 42 | + "changes": [], # journal des déménagements détectés (200 derniers) | |
| 43 | +} | |
| 44 | +_APPS: dict[str, Any] = {} | |
| 45 | +_RUNNERS: dict[str, Any] = {"mtime": 0.0, "data": {}} | |
| 46 | + | |
| 47 | + | |
| 48 | +def _pm2_from_processes(processes: list[str], exclude: list[str]) -> list[str]: | |
| 49 | + """Process PM2 « métier » : on écarte les tunnels ngrok (relancer un tunnel | |
| 50 | + à chaque restart casserait le domaine) et les exclusions déclarées.""" | |
| 51 | + return [p for p in processes | |
| 52 | + if not p.endswith("-ngrok") and p != "ngrok" and p not in set(exclude)] | |
| 53 | + | |
| 54 | + | |
| 55 | +def _read_registry() -> dict[str, Any] | None: | |
| 56 | + try: | |
| 57 | + st = REGISTRY_PATH.stat() | |
| 58 | + except FileNotFoundError: | |
| 59 | + STATE["error"] = f"{REGISTRY_PATH} absent — mld ne l'a pas encore poussé / registry-sync inactif" | |
| 60 | + return None | |
| 61 | + if st.st_mtime == STATE["mtime"] and _APPS: | |
| 62 | + return None # inchangé | |
| 63 | + try: | |
| 64 | + data = json.loads(REGISTRY_PATH.read_text()) | |
| 65 | + apps = data.get("apps") or {} | |
| 66 | + if not isinstance(apps, dict) or not apps: | |
| 67 | + raise ValueError("registre sans apps") | |
| 68 | + except Exception as exc: # fichier en cours d'écriture, JSON tronqué… | |
| 69 | + STATE["error"] = f"registre illisible: {exc}" | |
| 70 | + return None | |
| 71 | + STATE.update({"mtime": st.st_mtime, "loaded_at": time.time(), "updated": data.get("updated"), | |
| 72 | + "gateway": data.get("gateway"), "n_apps": len(apps), "error": None}) | |
| 73 | + _APPS.clear() | |
| 74 | + _APPS.update(apps) | |
| 75 | + return apps | |
| 76 | + | |
| 77 | + | |
| 78 | +def refresh(topo: dict[str, Any], services: dict[str, Any], nodes: dict[str, Any]) -> list[str]: | |
| 79 | + """Applique le registre aux dicts SERVICES / NODES de l'orchestrateur, EN PLACE | |
| 80 | + (les autres modules gardent leurs références). Retourne les changements | |
| 81 | + d'emplacement détectés (messages lisibles), vide si rien n'a bougé.""" | |
| 82 | + apps = _read_registry() | |
| 83 | + if apps is None: | |
| 84 | + return [] | |
| 85 | + changes: list[str] = [] | |
| 86 | + for key, svc in topo["services"].items(): | |
| 87 | + app = svc.get("registry_app") or key | |
| 88 | + entry = apps.get(app) | |
| 89 | + cur = services.setdefault(key, dict(svc)) | |
| 90 | + if not entry or not entry.get("node"): | |
| 91 | + cur["registry"] = "absent" | |
| 92 | + if STATE["resolved"].get(key, {}).get("registry") != "absent": | |
| 93 | + changes.append(f"{svc.get('app', key)} ({app}) absente du registre mld — " | |
| 94 | + f"repli sur topology.json ({cur.get('node', '?')})") | |
| 95 | + STATE["resolved"][key] = {"registry": "absent", "app": app} | |
| 96 | + continue | |
| 97 | + alias = entry["node"] | |
| 98 | + nkey = alias.lower() | |
| 99 | + prev_node = nodes.get(nkey, {}) | |
| 100 | + nodes[nkey] = {"alias": alias, "lan_ip": entry.get("ip") or prev_node.get("lan_ip")} | |
| 101 | + exclude = svc.get("pm2_exclude") or [] | |
| 102 | + pm2 = _pm2_from_processes(entry.get("processes") or [], exclude) or list(svc.get("pm2") or []) | |
| 103 | + new = { | |
| 104 | + "node": nkey, | |
| 105 | + "dir": entry.get("dir") or svc.get("dir"), | |
| 106 | + "web_port": entry.get("port") or svc.get("web_port"), | |
| 107 | + "pm2": pm2, | |
| 108 | + "site": (f"https://{entry['domain']}" if entry.get("domain") else svc.get("site")), | |
| 109 | + "app": svc.get("app") or entry.get("label") or app, | |
| 110 | + "registry": "ok", | |
| 111 | + "registry_status": entry.get("status"), | |
| 112 | + "registry_updated": entry.get("updated"), | |
| 113 | + } | |
| 114 | + old = STATE["resolved"].get(key) | |
| 115 | + if old and old.get("registry") == "ok": | |
| 116 | + if old["node"] != nkey: | |
| 117 | + changes.append(f"{new['app']} a déménagé : {nodes.get(old['node'], {}).get('alias', old['node'])} → {alias} " | |
| 118 | + f"(registre mld {entry.get('updated', '?')})") | |
| 119 | + elif old.get("dir") != new["dir"] or old.get("web_port") != new["web_port"]: | |
| 120 | + changes.append(f"{new['app']} : répertoire/port changés sur {alias} " | |
| 121 | + f"({old.get('dir')}:{old.get('web_port')} → {new['dir']}:{new['web_port']})") | |
| 122 | + elif old.get("pm2") != pm2: | |
| 123 | + changes.append(f"{new['app']} : process PM2 = {', '.join(pm2)}") | |
| 124 | + elif old is None or old.get("registry") == "absent": | |
| 125 | + # premier chargement : on signale seulement un écart avec topology.json | |
| 126 | + if svc.get("node") and svc["node"] != nkey: | |
| 127 | + changes.append(f"{new['app']} : topology.json disait {svc['node']}, le registre dit {alias} — registre appliqué") | |
| 128 | + cur.update(new) | |
| 129 | + STATE["resolved"][key] = {**new, "app_registry": app, "node_alias": alias, "ip": nodes[nkey]["lan_ip"]} | |
| 130 | + if changes: | |
| 131 | + stamp = time.strftime("%Y-%m-%d %H:%M") | |
| 132 | + STATE["changes"] = (STATE["changes"] + [{"ts": time.time(), "msg": f"{stamp} {m}"} for m in changes])[-200:] | |
| 133 | + return changes | |
| 134 | + | |
| 135 | + | |
| 136 | +def app_entry(app: str) -> dict[str, Any] | None: | |
| 137 | + """Entrée brute du registre pour une app (ex. l'IP du gardien lui-même).""" | |
| 138 | + if not _APPS: | |
| 139 | + _read_registry() | |
| 140 | + return _APPS.get(app) | |
| 141 | + | |
| 142 | + | |
| 143 | +def runner_status(ip: str | None) -> dict[str, Any] | None: | |
| 144 | + """État du runner :7791 d'un nœud, sondé par registry-sync.sh (runners.json). | |
| 145 | + None = inconnu (jamais sondé / fichier absent / sonde trop vieille).""" | |
| 146 | + if not ip: | |
| 147 | + return None | |
| 148 | + try: | |
| 149 | + st = RUNNERS_PATH.stat() | |
| 150 | + except FileNotFoundError: | |
| 151 | + return None | |
| 152 | + if st.st_mtime != _RUNNERS["mtime"]: | |
| 153 | + try: | |
| 154 | + _RUNNERS["data"] = json.loads(RUNNERS_PATH.read_text()) | |
| 155 | + _RUNNERS["mtime"] = st.st_mtime | |
| 156 | + except Exception: | |
| 157 | + return None | |
| 158 | + row = (_RUNNERS["data"] or {}).get(ip) | |
| 159 | + if not row or time.time() - float(row.get("ts", 0)) > 900: | |
| 160 | + return None | |
| 161 | + return row | |
| 162 | + | |
| 163 | + | |
| 164 | +def info() -> dict[str, Any]: | |
| 165 | + """Bloc `registry` exposé par /api/state (dashboard + admin-ka).""" | |
| 166 | + age = (time.time() - STATE["mtime"]) if STATE["mtime"] else None | |
| 167 | + return { | |
| 168 | + "path": STATE["path"], "updated": STATE["updated"], "gateway": STATE["gateway"], | |
| 169 | + "n_apps": STATE["n_apps"], "loaded_at": STATE["loaded_at"], "file_age_s": age, | |
| 170 | + "stale": bool(age is None or age > STALE_AFTER_S), "error": STATE["error"], | |
| 171 | + "changes": STATE["changes"][-30:], | |
| 172 | + "runners_probe": {ip: {k: v for k, v in row.items() if k in ("ok", "node", "busy", "ts")} | |
| 173 | + for ip, row in (_RUNNERS["data"] or {}).items()}, | |
| 174 | + } | |
modified
orchestrator/web/app.js
+22 −2
@@ -76,11 +76,31 @@ function renderCoverage() { | ||
| 76 | 76 | const strip = STATUSES.filter((st) => sum[st] > 0) |
| 77 | 77 | .map((st) => `<span class="s-${st}" style="flex:${sum[st]}" title="${STATUS_FR[st]}: ${sum[st]}"></span>`).join(""); |
| 78 | 78 | const counts = STATUSES.map((st) => `<span><i class="s-${st}"></i>${sum[st] || 0} ${STATUS_FR[st]}</span>`).join(""); |
| 79 | + // Nœud = registre mld (vivant). Runner = sonde registry-sync : sans runner sur le | |
| 80 | + // nœud, aucune mission ne peut y être dépêchée — on le dit plutôt que de le cacher. | |
| 81 | + const rn = meta.runner; | |
| 82 | + const runner = rn === null || rn === undefined ? "" | |
| 83 | + : rn.ok ? `<span title="runner gardien présent sur ${esc(meta.node_alias)}${rn.busy ? " (mission en cours)" : ""}"> · runner ${rn.busy ? "occupé" : "prêt"}</span>` | |
| 84 | + : `<span class="cov-warn" title="aucun runner :7791 sur ce nœud — missions impossibles jusqu'au déploiement (deploy.sh runners)"> · ⚠ sans runner</span>`; | |
| 85 | + const src = meta.registry === "ok" ? "" : `<span class="cov-warn" title="emplacement issu de topology.json (repli), le registre mld ne connaît pas cette app"> · repli</span>`; | |
| 79 | 86 | return `<div class="cov"><div class="head"><span class="app">${esc(meta.app)}</span> |
| 80 | − <span class="node">${esc(meta.node_alias)} · ${total || "?"} connecteurs</span></div> | |
| 87 | + <span class="node">${esc(meta.node_alias)}${runner}${src} · ${total || "?"} connecteurs</span></div> | |
| 81 | 88 | <div class="strip">${strip || "<span style='flex:1;background:var(--line)'></span>"}</div> |
| 82 | 89 | <div class="counts">${counts}</div></div>`; |
| 83 | − }).join(""); | |
| 90 | + }).join("") + renderRegistryNote(); | |
| 91 | +} | |
| 92 | + | |
| 93 | +function renderRegistryNote() { | |
| 94 | + const r = STATE.registry; | |
| 95 | + if (!r) return ""; | |
| 96 | + const when = r.updated ? r.updated.replace("T", " ") : "jamais reçu"; | |
| 97 | + const age = r.file_age_s == null ? "" : ` · copie locale vieille de ${Math.round(r.file_age_s / 60)} min`; | |
| 98 | + const state = r.error ? `<span class="cov-warn">⚠ ${esc(r.error)}</span>` | |
| 99 | + : r.stale ? `<span class="cov-warn">⚠ registre périmé (${esc(when)}${age})</span>` | |
| 100 | + : `registre mld ${esc(when)} · ${r.n_apps} apps${age}`; | |
| 101 | + const last = (r.changes || []).slice(-3).reverse().map((c) => `<div>${esc(c.msg)}</div>`).join(""); | |
| 102 | + return `<div class="cov-registry" title="Les emplacements (nœud, répertoire, port, PM2) viennent du registre de la passerelle M1M32 (~/dispatch/registry.json), poussé par mld et tiré par com.ka.registry-sync."> | |
| 103 | + <span class="node">emplacements : ${state}</span>${last ? `<div class="cov-changes">${last}</div>` : ""}</div>`; | |
| 84 | 104 | } |
| 85 | 105 | |
| 86 | 106 | function renderMissions() { |
modified
orchestrator/web/style.css
+6 −0
@@ -312,3 +312,9 @@ tbody tr:hover { background: var(--lime-soft); } | ||
| 312 | 312 | /* bouton primaire : fond lime + texte encre (plus de fond encre peu lisible) */ |
| 313 | 313 | .btn-primary { background: var(--lime); color: var(--ink); } |
| 314 | 314 | .btn-primary:hover { background: var(--accent-deep); color: var(--lime); } |
| 315 | + | |
| 316 | +/* territoire surveillé — provenance des emplacements (registre mld) et état des runners */ | |
| 317 | +.cov-warn { color: var(--bad, #b42318); font-weight: 700; } | |
| 318 | +.cov-registry { grid-column: 1 / -1; padding: 8px 0 0; border-top: 1px dashed var(--hairline, var(--line)); } | |
| 319 | +.cov-registry .node { text-transform: none; letter-spacing: 0; } | |
| 320 | +.cov-changes { margin-top: 4px; color: var(--ink-3); font: 10.5px var(--font-mono); } | |
modified
topology.json
+43 −193
@@ -1,219 +1,69 @@ | ||
| 1 | 1 | { |
| 2 | − "comment": "KA Guardian — topologie des agents, services et nœuds. Source de vérité partagée orchestrateurs/runners.", | |
| 2 | + "comment": "KA Guardian — identité des agents, politique et correspondance service → app. L'EMPLACEMENT des apps (nœud, IP LAN, répertoire, port, process PM2) vient du registre mld (M1M32:~/dispatch/registry.json, copie ~/ka-guardian-spool/registry.json) : orchestrator/registry.py l'applique au démarrage et à chaque tick. Les champs node/dir/web_port/pm2 ci-dessous ne sont qu'un REPLI si le registre n'a jamais été reçu (valeurs du 2026-09-04) — ne pas les considérer comme vrais.", | |
| 3 | 3 | "apika_monitoring_url": "https://www.api-ka.com/api/v1/monitoring/connectors", |
| 4 | 4 | "runner_port": 7791, |
| 5 | + "registry": { | |
| 6 | + "source": "M1M32:~/dispatch/registry.json", | |
| 7 | + "local_copy": "~/ka-guardian-spool/registry.json", | |
| 8 | + "runners_probe": "~/ka-guardian-spool/runners.json", | |
| 9 | + "sync_service": "com.ka.registry-sync (deploy/registry-sync.sh, tick 120 s) + push mld (abonné ka2)" | |
| 10 | + }, | |
| 5 | 11 | "agents": { |
| 6 | 12 | "ka2": { |
| 7 | − "port": 8799, | |
| 8 | − "domain": "www.ka2.bot", | |
| 9 | − "accent": "#a9d1f7", | |
| 10 | − "accent2": "#1e4fa3", | |
| 11 | − "tagline": "Gardien immobilier & local", | |
| 12 | − "model": "claude-fable-5", | |
| 13 | − "services": [ | |
| 14 | − "louka", | |
| 15 | − "immoka", | |
| 16 | − "restoka", | |
| 17 | − "houseka", | |
| 18 | − "rentka" | |
| 19 | − ], | |
| 20 | − "accent_soft": "#e3effc" | |
| 13 | + "port": 8799, "domain": "www.ka2.bot", "accent": "#a9d1f7", "accent2": "#1e4fa3", "accent_soft": "#e3effc", | |
| 14 | + "tagline": "Gardien immobilier & local", "model": "claude-fable-5", | |
| 15 | + "services": ["louka", "immoka", "restoka", "houseka", "rentka"] | |
| 21 | 16 | }, |
| 22 | 17 | "ka4": { |
| 23 | − "port": 8899, | |
| 24 | − "domain": "www.ka4.bot", | |
| 25 | − "accent": "#ffc36b", | |
| 26 | − "accent2": "#a35c00", | |
| 27 | − "tagline": "Gardien mobilité & quotidien", | |
| 28 | − "model": "claude-fable-5", | |
| 29 | − "services": [ | |
| 30 | − "autoka", | |
| 31 | − "foodka", | |
| 32 | − "sortika" | |
| 33 | − ], | |
| 34 | − "accent_soft": "#ffefd9" | |
| 18 | + "port": 8899, "domain": "www.ka4.bot", "accent": "#ffc36b", "accent2": "#a35c00", "accent_soft": "#ffefd9", | |
| 19 | + "tagline": "Gardien mobilité & quotidien", "model": "claude-fable-5", | |
| 20 | + "services": ["autoka", "foodka", "sortika"] | |
| 35 | 21 | }, |
| 36 | 22 | "ka6": { |
| 37 | − "port": 8999, | |
| 38 | − "domain": "www.ka6.bot", | |
| 39 | − "accent": "#cdb4f9", | |
| 40 | − "accent2": "#5b21b6", | |
| 41 | − "tagline": "Gardien flagship — gros volumes", | |
| 42 | − "model": "claude-fable-5", | |
| 43 | − "services": [ | |
| 44 | − "fabrika", | |
| 45 | − "jobka", | |
| 46 | − "creaka", | |
| 47 | − "vraiprix" | |
| 48 | − ], | |
| 49 | − "default_for_unknown_services": true, | |
| 50 | − "accent_soft": "#f0e8fc" | |
| 23 | + "port": 8999, "domain": "www.ka6.bot", "accent": "#cdb4f9", "accent2": "#5b21b6", "accent_soft": "#f0e8fc", | |
| 24 | + "tagline": "Gardien flagship — gros volumes", "model": "claude-fable-5", | |
| 25 | + "services": ["fabrika", "jobka", "creaka", "vraiprix"], | |
| 26 | + "default_for_unknown_services": true | |
| 51 | 27 | } |
| 52 | 28 | }, |
| 53 | 29 | "nodes": { |
| 54 | − "m3u96a": { | |
| 55 | − "lan_ip": "192.168.2.87", | |
| 56 | − "alias": "M3U96a" | |
| 57 | − }, | |
| 58 | − "m3u96b": { | |
| 59 | − "lan_ip": "192.168.2.82", | |
| 60 | − "alias": "M3U96b" | |
| 61 | − }, | |
| 62 | − "m4m64a": { | |
| 63 | − "lan_ip": "192.168.2.83", | |
| 64 | − "alias": "M4M64a" | |
| 65 | − }, | |
| 66 | − "m4m64b": { | |
| 67 | − "lan_ip": "192.168.2.78", | |
| 68 | − "alias": "M4M64b" | |
| 69 | − }, | |
| 70 | − "m4m36": { | |
| 71 | − "lan_ip": "192.168.2.69", | |
| 72 | − "alias": "M4M36" | |
| 73 | − } | |
| 30 | + "m3u96a": { "lan_ip": "192.168.2.87", "alias": "M3U96a" }, | |
| 31 | + "m3u96b": { "lan_ip": "192.168.2.82", "alias": "M3U96b" }, | |
| 32 | + "m2u64": { "lan_ip": "192.168.2.107", "alias": "M2U64" }, | |
| 33 | + "m4m64a": { "lan_ip": "192.168.2.83", "alias": "M4M64a" }, | |
| 34 | + "m4m64b": { "lan_ip": "192.168.2.78", "alias": "M4M64b" }, | |
| 35 | + "m4m36": { "lan_ip": "192.168.2.69", "alias": "M4M36" }, | |
| 36 | + "m2m32": { "lan_ip": "192.168.2.90", "alias": "M2M32" } | |
| 74 | 37 | }, |
| 75 | 38 | "services": { |
| 76 | − "louka": { | |
| 77 | − "app": "Lou·Ka", | |
| 78 | − "node": "m3u96b", | |
| 79 | − "dir": "~/apps/lou-ka", | |
| 80 | − "pm2": [ | |
| 81 | − "lou-ka-web", | |
| 82 | − "lou-ka-sync" | |
| 83 | − ], | |
| 84 | − "web_port": 8095, | |
| 85 | − "site": "https://www.lou-ka.com" | |
| 86 | − }, | |
| 87 | − "restoka": { | |
| 88 | − "app": "Resto·Ka", | |
| 89 | − "node": "m3u96b", | |
| 90 | − "dir": "~/apps/resto-ka", | |
| 91 | − "pm2": [ | |
| 92 | − "resto-ka", | |
| 93 | − "resto-ka-sync" | |
| 94 | − ], | |
| 95 | − "web_port": 8115, | |
| 96 | − "site": "https://www.resto-ka.com" | |
| 97 | − }, | |
| 98 | − "creaka": { | |
| 99 | − "app": "Créa·Ka", | |
| 100 | − "node": "m3u96b", | |
| 101 | − "dir": "~/apps/crea-ka", | |
| 102 | − "pm2": [ | |
| 103 | − "crea-ka-web", | |
| 104 | − "crea-ka-sync" | |
| 105 | − ], | |
| 106 | − "web_port": 8160, | |
| 107 | − "site": "https://www.crea-ka.com" | |
| 108 | − }, | |
| 109 | − "immoka": { | |
| 110 | − "app": "Immo·Ka", | |
| 111 | − "node": "m4m64a", | |
| 112 | − "dir": "~/apps/immo-ka", | |
| 113 | − "pm2": [ | |
| 114 | − "immo-ka-web", | |
| 115 | − "immo-ka-sync" | |
| 116 | − ], | |
| 117 | − "web_port": 8096, | |
| 118 | − "site": "https://www.immo-ka.com" | |
| 119 | − }, | |
| 120 | − "fabrika": { | |
| 121 | − "app": "Fabri·Ka", | |
| 122 | − "node": "m4m64a", | |
| 123 | − "dir": "~/fabri-ka", | |
| 124 | − "pm2": [ | |
| 125 | − "fabri-ka-web", | |
| 126 | − "fabri-ka-sync" | |
| 127 | − ], | |
| 128 | − "web_port": 8097, | |
| 129 | − "site": "https://www.fabri-ka.com" | |
| 130 | − }, | |
| 131 | − "autoka": { | |
| 132 | − "app": "Auto·Ka", | |
| 133 | − "node": "m4m64b", | |
| 134 | − "dir": "~/auto-ka", | |
| 135 | − "pm2": [ | |
| 136 | − "auto-ka-web", | |
| 137 | − "auto-ka-sync" | |
| 138 | − ], | |
| 139 | − "web_port": 8095, | |
| 140 | − "site": "https://www.auto-ka.com" | |
| 141 | − }, | |
| 142 | − "foodka": { | |
| 143 | − "app": "Food·Ka", | |
| 144 | − "node": "m4m64b", | |
| 145 | − "dir": "~/apps/food-ka", | |
| 146 | − "pm2": [ | |
| 147 | − "food-ka-web", | |
| 148 | − "food-ka-sync" | |
| 149 | − ], | |
| 150 | − "web_port": 8097, | |
| 151 | − "site": "https://www.food-ka.com" | |
| 152 | − }, | |
| 153 | − "sortika": { | |
| 154 | − "app": "Sorti·Ka", | |
| 155 | − "node": "m3u96a", | |
| 156 | − "dir": "~/apps/sorti-ka", | |
| 157 | − "pm2": [ | |
| 158 | − "sorti-ka-web", | |
| 159 | − "sorti-ka-sync" | |
| 160 | − ], | |
| 161 | − "web_port": 8120, | |
| 162 | − "site": "https://www.sorti-ka.com" | |
| 163 | − }, | |
| 164 | − "jobka": { | |
| 165 | − "app": "Job·Ka", | |
| 166 | − "node": "m3u96a", | |
| 167 | − "dir": "~/apps/job-ka", | |
| 168 | − "pm2": [ | |
| 169 | − "job-ka-web", | |
| 170 | − "job-ka-sync" | |
| 171 | − ], | |
| 172 | − "web_port": 8096, | |
| 173 | − "site": "https://www.job-ka.com" | |
| 174 | − }, | |
| 39 | + "louka": { "app": "Lou·Ka", "registry_app": "lou-ka", "node": "m4m64a", "dir": "~/apps/lou-ka", "pm2": ["lou-ka-web", "lou-ka-sync"], "web_port": 8095, "site": "https://www.lou-ka.com" }, | |
| 40 | + "restoka": { "app": "Resto·Ka", "registry_app": "resto-ka", "node": "m4m64a", "dir": "~/apps/resto-ka", "pm2": ["resto-ka", "resto-ka-sync"], "web_port": 8115, "site": "https://www.resto-ka.com" }, | |
| 41 | + "creaka": { "app": "Créa·Ka", "registry_app": "crea-ka", "node": "m3u96a", "dir": "~/apps/crea-ka", "pm2": ["crea-ka-web", "crea-ka-sync"], "web_port": 8160, "site": "https://www.crea-ka.com" }, | |
| 42 | + "immoka": { "app": "Immo·Ka", "registry_app": "immo-ka", "node": "m4m64b", "dir": "~/apps/immo-ka", "pm2": ["immo-ka-web", "immo-ka-sync"], "web_port": 8096, "site": "https://www.immo-ka.com" }, | |
| 43 | + "fabrika": { "app": "Fabri·Ka", "registry_app": "fabri-ka", "node": "m4m64a", "dir": "~/fabri-ka", "pm2": ["fabri-ka-web", "fabri-ka-sync"], "web_port": 8097, "site": "https://www.fabri-ka.com" }, | |
| 44 | + "autoka": { "app": "Auto·Ka", "registry_app": "auto-ka", "node": "m4m64b", "dir": "~/auto-ka", "pm2": ["auto-ka-web", "auto-ka-sync"], "web_port": 8095, "site": "https://www.auto-ka.com" }, | |
| 45 | + "foodka": { "app": "Food·Ka", "registry_app": "food-ka", "node": "m2u64", "dir": "~/apps/food-ka", "pm2": ["food-ka-web", "food-ka-sync"], "web_port": 8097, "site": "https://www.food-ka.com" }, | |
| 46 | + "sortika": { "app": "Sorti·Ka", "registry_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" }, | |
| 47 | + "jobka": { "app": "Job·Ka", "registry_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" }, | |
| 175 | 48 | "vraiprix": { |
| 176 | − "app": "Vrai·Prix", | |
| 177 | − "node": "m3u96a", | |
| 178 | − "dir": "~/apps/vrai-prix", | |
| 179 | − "pm2": [ | |
| 180 | − "vrai-prix" | |
| 181 | − ], | |
| 182 | − "web_port": 8090, | |
| 183 | − "site": "https://www.vrai-prix.com", | |
| 184 | − "comment": "Pas de connecteurs sous supervision api-ka (vrai-prix-ingest tourne à la demande): surveillance du SITE seulement. vrai-prix-ngrok exclu de pm2[] pour ne pas relancer le tunnel à chaque restart." | |
| 49 | + "app": "Vrai·Prix", "registry_app": "vrai-prix", "node": "m3u96a", "dir": "~/apps/vrai-prix", | |
| 50 | + "pm2": ["vrai-prix"], "pm2_exclude": ["vrai-prix-ingest"], "web_port": 8090, "site": "https://www.vrai-prix.com", | |
| 51 | + "comment": "Pas de connecteurs sous supervision api-ka (vrai-prix-ingest tourne à la demande — exclu des restarts via pm2_exclude) : surveillance du SITE seulement. Les tunnels *-ngrok sont toujours exclus automatiquement." | |
| 185 | 52 | }, |
| 186 | 53 | "houseka": { |
| 187 | − "app": "House·Ka", | |
| 188 | − "node": "m4m64b", | |
| 189 | − "dir": "~/apps/house-ka", | |
| 190 | − "pm2": [ | |
| 191 | − "house-ka-web", | |
| 192 | − "house-ka-sync" | |
| 193 | − ], | |
| 194 | − "web_port": 8098, | |
| 195 | − "site": "https://www.house-ka.com", | |
| 196 | − "comment": "Fork achat de Immo-Ka (Canada hors Quebec). Connecteurs rapportes a api-ka depuis 2026-08-28 (28 sources, cadence 4 h). house-ka-ngrok exclu de pm2[]." | |
| 54 | + "app": "House·Ka", "registry_app": "house-ka", "node": "m3u96a", "dir": "~/apps/house-ka", | |
| 55 | + "pm2": ["house-ka-web", "house-ka-sync"], "web_port": 8098, "site": "https://www.house-ka.com", | |
| 56 | + "comment": "Fork achat de Immo-Ka (Canada hors Quebec). Connecteurs rapportes a api-ka depuis 2026-08-28 (28 sources, cadence 4 h)." | |
| 197 | 57 | }, |
| 198 | 58 | "rentka": { |
| 199 | − "app": "Rent·Ka", | |
| 200 | − "node": "m4m36", | |
| 201 | − "dir": "~/apps/rent-ka", | |
| 202 | − "pm2": [ | |
| 203 | − "rent-ka-web", | |
| 204 | − "rent-ka-sync" | |
| 205 | − ], | |
| 206 | − "web_port": 8125, | |
| 207 | − "site": "https://www.rent-ka.com", | |
| 208 | − "comment": "Fork anglophone de Lou-Ka (Canada hors Quebec), sur le noeud orchestrateur M4M36 (runner deploye 2026-08-28). Connecteurs rapportes a api-ka depuis 2026-08-28 (~680 sources). rent-ka-ngrok exclu de pm2[]." | |
| 59 | + "app": "Rent·Ka", "registry_app": "rent-ka", "node": "m4m64b", "dir": "~/apps/rent-ka", | |
| 60 | + "pm2": ["rent-ka-web", "rent-ka-sync"], "web_port": 8125, "site": "https://www.rent-ka.com", | |
| 61 | + "comment": "Fork anglophone de Lou-Ka (Canada hors Quebec). Connecteurs rapportes a api-ka depuis 2026-08-28 (~680 sources)." | |
| 209 | 62 | } |
| 210 | 63 | }, |
| 211 | 64 | "policy": { |
| 212 | 65 | "poll_interval_seconds": 300, |
| 213 | − "trigger_statuses": [ | |
| 214 | − "broken", | |
| 215 | − "stale" | |
| 216 | − ], | |
| 66 | + "trigger_statuses": ["broken", "stale"], | |
| 217 | 67 | "max_concurrent_missions": 1, |
| 218 | 68 | "max_attempts_per_incident": 3, |
| 219 | 69 | "attempt_cooldown_hours": 6, |
| 220 | 70 | |