SPB Git

spb/ka-guardian

Public

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>
Simon-Pierre Boucher committed (Sep 5, 2026) parent fd310fc Browse files

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 Chaque agent est une instance de `orchestrator/main.py` (env `AGENT=ka2|ka4|ka6`), 4 Chaque agent est une instance de `orchestrator/main.py` (env `AGENT=ka2|ka4|ka6`),
5 identité (accent, tagline, services surveillés) dans `topology.json`. 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 ## Veilleur de sites (2026-08-25) 28 ## Veilleur de sites (2026-08-25)
8 29
9 En plus des connecteurs (scan api-ka), chaque agent surveille le **site public** 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 2. Vérifier : `curl -sf localhost:8799/health` (8899, 8999) puis le site public. 51 2. Vérifier : `curl -sf localhost:8799/health` (8899, 8999) puis le site public.
31 3. `git add … && git commit && git push origin main` (origin = gitsrv/spbgit, PAS GitHub). 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 ⚠️ 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. 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 ## Pages & image de marque 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 | **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` | 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 L'identité de chaque agent (numéro, domaine, tagline, accents, services surveillés, 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 25+modèle Claude) vit dans **`topology.json`**. L'**emplacement** des apps (nœud, IP LAN,
26−orchestrateurs et runners. Les pages web (`orchestrator/web/index.html`, 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 `commander.html`) sont des gabarits communs : les jetons `__NUM__`, `__DOMAIN__`, 30 `commander.html`) sont des gabarits communs : les jetons `__NUM__`, `__DOMAIN__`,
28 `__TAGLINE__` sont rendus par agent au démarrage (`_render_page` dans `main.py`). 31 `__TAGLINE__` sont rendus par agent au démarrage (`_render_page` dans `main.py`).
29 Chaque agent a sa bannière de partage OG (`orchestrator/web/og-ka{2,4,6}.png`, 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 │ com.ka6.guardian :8999 (FastAPI + SQLite data/kaX.db) │ 157 │ com.ka6.guardian :8999 (FastAPI + SQLite data/kaX.db) │
155 │ ngrok com.kaX.ngrok → www.ka2.bot / ka4.bot / ka6.bot │ 158 │ ngrok com.kaX.ngrok → www.ka2.bot / ka4.bot / ka6.bot │
156 │ courrier zsh com.ka.guardian-courier (spool → ssh+curl) │ 159 │ courrier zsh com.ka.guardian-courier (spool → ssh+curl) │
160+ │ registry-sync zsh com.ka.registry-sync (registre + sondes) │
157 └───────────────┬────────────────────────────────────────────┘ 161 └───────────────┬────────────────────────────────────────────┘
158 │ LAN 192.168.2.x uniquement (via le courrier) 162 │ LAN 192.168.2.x uniquement (via le courrier)
159− ┌────────────┼──────────────┬──────────────┬──────────────┐ 163+ ┌────────────┼──────────────┬──────────────┐
160− ▼ ▼ ▼ ▼ ▼ 164+ ▼ ▼ ▼ ▼ … tout nœud que le registre
161− M3U96a M3U96b M4M64a M4M64b M4M36 165+ M3U96a M2U64 M4M64a M4M64b mld désigne (liste vivante)
162− runner :7791 (com.ka.guardian-runner) sur chaque nœud d'app 166+ runner :7791 (com.ka.guardian-runner) sur chaque nœud hébergeur
163 + pousseur git com.ka.pousseur (nœuds à repos Ka) 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 - **`orchestrator/`** — un process FastAPI par agent (M4M36, launchd 190 - **`orchestrator/`** — un process FastAPI par agent (M4M36, launchd
167 `com.kaX.guardian`, env `AGENT=ka2|ka4|ka6`), SQLite `data/kaX.db`, dashboard 191 `com.kaX.guardian`, env `AGENT=ka2|ka4|ka6`), SQLite `data/kaX.db`, dashboard
168 servi sur le même port (flux SSE, board incidents, couverture, registre des 192 servi sur le même port (flux SSE, board incidents, couverture, registre des
169 missions + transcripts, `/commander`), tunnels ngrok existants conservés. 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 launchd `com.ka.guardian-runner`), **1 mission à la fois par nœud**, transcripts 195 launchd `com.ka.guardian-runner`), **1 mission à la fois par nœud**, transcripts
172 dans `~/ka-guardian-runner/transcripts/`. Exécute `claude -p --output-format 196 dans `~/ka-guardian-runner/transcripts/`. Exécute `claude -p --output-format
173 stream-json` dans le repo de l'app et relaie chaque événement à l'orchestrateur 197 stream-json` dans le repo de l'app et relaie chaque événement à l'orchestrateur
174 (`/api/ingest`). Expose aussi `/restart` (pm2) pour le veilleur de sites et 198 (`/api/ingest`). Expose aussi `/restart` (pm2) pour le veilleur de sites et
175 `/health` (`busy`). ⚠️ Ne jamais redémarrer un runner occupé : ça tue la 199 `/health` (`busy`). ⚠️ Ne jamais redémarrer un runner occupé : ça tue la
176 mission Claude en vol. 200 mission Claude en vol.
177−- **`topology.json`** — source de vérité : agents (port/domaine/accent/tagline/ 201+- **`topology.json`** — agents (port/domaine/accent/tagline/modèle/services),
178− modèle/services), services (app, nœud, dir, process pm2, port web, site), 202+ correspondance service → `registry_app` (+ `pm2_exclude`), politique (fenêtres,
179− IP LAN des nœuds, politique (fenêtres, cooldowns, plafonds). 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 - **`deploy/courier.sh`** — courrier inter-nœuds 100 % zsh (launchd 206 - **`deploy/courier.sh`** — courrier inter-nœuds 100 % zsh (launchd
181 `com.ka.guardian-courier`). macOS 26 « Local Network Privacy » refuse le trafic 207 `com.ka.guardian-courier`). macOS 26 « Local Network Privacy » refuse le trafic
182 LAN dès que python (homebrew) est dans la chaîne de processus ; le courrier 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 #!/bin/zsh 1 #!/bin/zsh
2 # ============================================ 2 # ============================================
3 # KA Guardian — déploiement complet depuis le laptop 3 # KA Guardian — déploiement complet depuis le laptop
4−# ./deploy.sh runners → runners sur M3U96a M3U96b M4M64a M4M64b M4M36 4+# ./deploy.sh runners → runners sur CHAQUE nœud qui héberge un service surveillé (d'après le registre mld)
5−# ./deploy.sh orchestrators → ka2/ka4/ka6 sur M4M36 (remplace les anciens bots) 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 # ./deploy.sh all 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 # Idempotent. Le token partagé vit dans ~/.ka-guardian.env (laptop) et est 12 # 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. 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 set -e 26 set -e
11 ROOT="$(cd "$(dirname "$0")/.." && pwd)" 27 ROOT="$(cd "$(dirname "$0")/.." && pwd)"
12−RUNNER_NODES=(M3U96a M3U96b M4M64a M4M64b M4M36)
13 ORCH_NODE=M4M36 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 TOKEN_FILE="$HOME/.ka-guardian.env" 32 TOKEN_FILE="$HOME/.ka-guardian.env"
18 if [[ ! -f $TOKEN_FILE ]]; then 33 if [[ ! -f $TOKEN_FILE ]]; then
19 echo "KA_GUARDIAN_TOKEN=$(openssl rand -hex 24)" > "$TOKEN_FILE" 34 echo "KA_GUARDIAN_TOKEN=$(openssl rand -hex 24)" > "$TOKEN_FILE"
20 echo "token généré → $TOKEN_FILE" 35 echo "token généré → $TOKEN_FILE"
21 fi 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 deploy_courier() { 127 deploy_courier() {
25 # Courrier zsh sur TOUS les nœuds (orchestrateur + runners): expédie le 128 # Courrier zsh sur TOUS les nœuds (orchestrateur + runners): expédie le
@@ -27,7 +130,7 @@ deploy_courier() {
27 for n in $RUNNER_NODES $ORCH_NODE; do 130 for n in $RUNNER_NODES $ORCH_NODE; do
28 echo "=== courrier → $n" 131 echo "=== courrier → $n"
29 scp -q "$ROOT/deploy/courier.sh" $n:ka-guardian-courier.sh 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 cat > ~/Library/LaunchAgents/com.ka.guardian-courier.plist <<PLIST 134 cat > ~/Library/LaunchAgents/com.ka.guardian-courier.plist <<PLIST
32 <?xml version="1.0" encoding="UTF-8"?> 135 <?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"> 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 </dict></plist> 146 </dict></plist>
44 PLIST 147 PLIST
45 mkdir -p ~/ka-guardian-spool 148 mkdir -p ~/ka-guardian-spool
46− launchctl unload ~/Library/LaunchAgents/com.ka.guardian-courier.plist 2>/dev/null || true 149+ lc_reload com.ka.guardian-courier ~/Library/LaunchAgents/com.ka.guardian-courier.plist' \
47− launchctl load ~/Library/LaunchAgents/com.ka.guardian-courier.plist' \
48 && echo " ✓ $n courrier ok" || echo " ✗ $n courrier KO" 150 && echo " ✓ $n courrier ok" || echo " ✗ $n courrier KO"
49 done 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 deploy_runners() { 180 deploy_runners() {
53 for n in $RUNNER_NODES; do 181 for n in $RUNNER_NODES; do
54 echo "=== runner → $n" 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 # GOTCHA: redémarrer un runner TUE la mission claude en cours (cause de la 186 # 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é. 187 # 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) 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 echo " ⏸ $n occupé (mission en cours) — runner NON redéployé, relance plus tard" 190 echo " ⏸ $n occupé (mission en cours) — runner NON redéployé, relance plus tard"
60 continue 191 continue
61 fi 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 ssh $n "mkdir -p ~/ka-guardian-runner/transcripts" 196 ssh $n "mkdir -p ~/ka-guardian-runner/transcripts"
63 scp -q "$ROOT/runner/runner.py" $n:ka-guardian-runner/runner.py 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 cd ~/ka-guardian-runner 199 cd ~/ka-guardian-runner
66− [[ -d .venv ]] || $PY -m venv .venv 200+ PY=\$(command -v /opt/homebrew/bin/python3 || command -v /opt/homebrew/bin/python3.13 || command -v /opt/homebrew/bin/python3.14)
67− ./.venv/bin/pip -q install 'fastapi>=0.110' 'uvicorn>=0.29' 'httpx>=0.27' 'pydantic>=2' >/dev/null 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 cat > ~/Library/LaunchAgents/com.ka.guardian-runner.plist <<'PLIST' 203 cat > ~/Library/LaunchAgents/com.ka.guardian-runner.plist <<'PLIST'
69 <?xml version=\"1.0\" encoding=\"UTF-8\"?> 204 <?xml version=\"1.0\" encoding=\"UTF-8\"?>
70 <!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\"> 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 <key>StandardErrorPath</key><string>/Users/simon-pierreboucher/ka-guardian-runner/runner.log</string> 217 <key>StandardErrorPath</key><string>/Users/simon-pierreboucher/ka-guardian-runner/runner.log</string>
83 </dict></plist> 218 </dict></plist>
84 PLIST 219 PLIST
85− launchctl unload ~/Library/LaunchAgents/com.ka.guardian-runner.plist 2>/dev/null || true 220+ lc_reload com.ka.guardian-runner ~/Library/LaunchAgents/com.ka.guardian-runner.plist"
86− launchctl load ~/Library/LaunchAgents/com.ka.guardian-runner.plist" 221+ sleep 4
87− sleep 2
88 ssh $n "curl -sf localhost:7791/health" && echo " ✓ $n runner ok" || echo " ✗ $n runner KO" 222 ssh $n "curl -sf localhost:7791/health" && echo " ✓ $n runner ok" || echo " ✗ $n runner KO"
89 done 223 done
90 } 224 }
@@ -99,20 +233,22 @@ deploy_orchestrators() {
99 | ssh $ORCH_NODE 'tar xzf - -C ~/cluster-projects/ka-guardian/' 233 | ssh $ORCH_NODE 'tar xzf - -C ~/cluster-projects/ka-guardian/'
100 loc=$(md5 -q orchestrator/main.py); rem=$(ssh $ORCH_NODE 'md5 -q ~/cluster-projects/ka-guardian/orchestrator/main.py') 234 loc=$(md5 -q orchestrator/main.py); rem=$(ssh $ORCH_NODE 'md5 -q ~/cluster-projects/ka-guardian/orchestrator/main.py')
101 [[ "$loc" == "$rem" ]] || { echo "✗ transfert raté: main.py ($loc ≠ $rem)"; exit 1; } 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 PY=\$(command -v /opt/homebrew/bin/python3 || command -v /opt/homebrew/bin/python3.13) 237 PY=\$(command -v /opt/homebrew/bin/python3 || command -v /opt/homebrew/bin/python3.13)
104 [[ -d .venv ]] || \$PY -m venv .venv 238 [[ -d .venv ]] || \$PY -m venv .venv
105 ./.venv/bin/pip -q install 'fastapi>=0.110' 'uvicorn>=0.29' 'httpx>=0.27' >/dev/null 239 ./.venv/bin/pip -q install 'fastapi>=0.110' 'uvicorn>=0.29' 'httpx>=0.27' >/dev/null
106 mkdir -p data logs 240 mkdir -p data logs
107 # retirer les anciens bots (web+bot), garder les tunnels ngrok 241 # retirer les anciens bots (web+bot), garder les tunnels ngrok
108 for a in ka2 ka4 ka6; do 242 for a in ka2 ka4 ka6; do
109− launchctl unload ~/Library/LaunchAgents/com.\$a.web.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
110− launchctl unload ~/Library/LaunchAgents/com.\$a.bot.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 rm -f ~/Library/LaunchAgents/com.\$a.web.plist ~/Library/LaunchAgents/com.\$a.bot.plist 245 rm -f ~/Library/LaunchAgents/com.\$a.web.plist ~/Library/LaunchAgents/com.\$a.bot.plist
112 done" 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 for a in ka2:8799 ka4:8899 ka6:8999; do 249 for a in ka2:8799 ka4:8899 ka6:8999; do
114 agent=${a%%:*}; port=${a##*:} 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 <?xml version=\"1.0\" encoding=\"UTF-8\"?> 252 <?xml version=\"1.0\" encoding=\"UTF-8\"?>
117 <!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\"> 253 <!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">
118 <plist version=\"1.0\"><dict> 254 <plist version=\"1.0\"><dict>
@@ -123,7 +259,7 @@ deploy_orchestrators() {
123 </array> 259 </array>
124 <key>EnvironmentVariables</key><dict> 260 <key>EnvironmentVariables</key><dict>
125 <key>AGENT</key><string>$agent</string> 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 <key>PATH</key><string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string> 263 <key>PATH</key><string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
128 </dict> 264 </dict>
129 <key>RunAtLoad</key><true/><key>KeepAlive</key><true/> 265 <key>RunAtLoad</key><true/><key>KeepAlive</key><true/>
@@ -131,8 +267,7 @@ deploy_orchestrators() {
131 <key>StandardErrorPath</key><string>/Users/simon-pierreboucher/cluster-projects/ka-guardian/logs/$agent.log</string> 267 <key>StandardErrorPath</key><string>/Users/simon-pierreboucher/cluster-projects/ka-guardian/logs/$agent.log</string>
132 </dict></plist> 268 </dict></plist>
133 PLIST 269 PLIST
134− launchctl unload ~/Library/LaunchAgents/com.$agent.guardian.plist 2>/dev/null || true 270+ lc_reload com.$agent.guardian ~/Library/LaunchAgents/com.$agent.guardian.plist"
135− launchctl load ~/Library/LaunchAgents/com.$agent.guardian.plist"
136 sleep 2 271 sleep 2
137 ssh $ORCH_NODE "curl -sf localhost:$port/health" && echo " ✓ $agent ok (:$port)" || echo " ✗ $agent KO" 272 ssh $ORCH_NODE "curl -sf localhost:$port/health" && echo " ✓ $agent ok (:$port)" || echo " ✗ $agent KO"
138 done 273 done
@@ -142,25 +277,17 @@ deploy_pousseur() {
142 # Pousseur launchd (binaires Apple) : pousse les commits en attente des repos 277 # Pousseur launchd (binaires Apple) : pousse les commits en attente des repos
143 # Ka du nœud — les missions Claude ne peuvent pas pousser elles-mêmes 278 # Ka du nœud — les missions Claude ne peuvent pas pousser elles-mêmes
144 # (macOS 26 Local Network Privacy bloque les descendants du runner python). 279 # (macOS 26 Local Network Privacy bloque les descendants du runner python).
145− typeset -A REPOS 280+ # Les repos viennent du registre mld ; ~/.ka-pousseur-repos est FUSIONNÉ (jamais
146− REPOS[M3U96a]='~/apps/vrai-prix 281+ # écrasé : mld y inscrit aussi les repos à l'arrivée d'une app sur le nœud).
147−~/apps/sorti-ka 282+ local repos_tsv; repos_tsv="$(registry_lines repos)"
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'
158 for n in $RUNNER_NODES; do 283 for n in $RUNNER_NODES; do
159 echo "=== pousseur → $n" 284 echo "=== pousseur → $n"
285+ local mine; mine=$(print -r -- "$repos_tsv" | awk -F'\t' -v n="$n" '$1==n{print $2}')
160 scp -q "$ROOT/deploy/pousseur.sh" $n:ka-pousseur.sh 286 scp -q "$ROOT/deploy/pousseur.sh" $n:ka-pousseur.sh
161− ssh $n "chmod +x ~/ka-pousseur.sh 287+ remote $n "chmod +x ~/ka-pousseur.sh
162− cat > ~/.ka-pousseur-repos <<'REPOS' 288+ touch ~/.ka-pousseur-repos
163−${REPOS[$n]} 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 REPOS 291 REPOS
165 cat > ~/Library/LaunchAgents/com.ka.pousseur.plist <<'PLIST' 292 cat > ~/Library/LaunchAgents/com.ka.pousseur.plist <<'PLIST'
166 <?xml version=\"1.0\" encoding=\"UTF-8\"?> 293 <?xml version=\"1.0\" encoding=\"UTF-8\"?>
@@ -177,17 +304,20 @@ REPOS
177 </dict></plist> 304 </dict></plist>
178 PLIST 305 PLIST
179 mkdir -p ~/ka-guardian-spool 306 mkdir -p ~/ka-guardian-spool
180− launchctl unload ~/Library/LaunchAgents/com.ka.pousseur.plist 2>/dev/null || true 307+ lc_reload com.ka.pousseur ~/Library/LaunchAgents/com.ka.pousseur.plist
181− launchctl load ~/Library/LaunchAgents/com.ka.pousseur.plist" \ 308+ echo \" repos: \$(tr '\n' ' ' < ~/.ka-pousseur-repos)\"" \
182 && echo " ✓ $n pousseur ok" || echo " ✗ $n pousseur KO" 309 && echo " ✓ $n pousseur ok" || echo " ✗ $n pousseur KO"
183 done 310 done
184 } 311 }
185 312
186 case "${1:-all}" in 313 case "${1:-all}" in
314+ nodes) registry_lines repos ;;
187 courier) deploy_courier ;; 315 courier) deploy_courier ;;
316+ registry-sync) deploy_registry_sync ;;
188 runners) deploy_runners ;; 317 runners) deploy_runners ;;
189 orchestrators) deploy_orchestrators ;; 318 orchestrators) deploy_orchestrators ;;
190 pousseur) deploy_pousseur ;; 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 esac 321 esac
322+rm -f "$REG_JSON"
193 echo "terminé." 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 from fastapi.responses import FileResponse, HTMLResponse, StreamingResponse 25 from fastapi.responses import FileResponse, HTMLResponse, StreamingResponse
26 from fastapi.staticfiles import StaticFiles 26 from fastapi.staticfiles import StaticFiles
27 27
28+import registry # topologie vivante (registre mld) — même dossier que main.py
29+
28 BASE = pathlib.Path(__file__).resolve().parent 30 BASE = pathlib.Path(__file__).resolve().parent
29 ROOT = BASE.parent 31 ROOT = BASE.parent
30 HOME = pathlib.Path.home() 32 HOME = pathlib.Path.home()
@@ -33,10 +35,59 @@ AGENT = os.environ.get("AGENT", "ka2")
33 TOPO = json.loads((ROOT / "topology.json").read_text()) 35 TOPO = json.loads((ROOT / "topology.json").read_text())
34 ME = TOPO["agents"][AGENT] 36 ME = TOPO["agents"][AGENT]
35 POLICY = TOPO["policy"] 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 SERVICES: dict[str, Any] = TOPO["services"] 41 SERVICES: dict[str, Any] = TOPO["services"]
37 NODES: dict[str, Any] = TOPO["nodes"] 42 NODES: dict[str, Any] = TOPO["nodes"]
38 MY_SERVICES: set[str] = set(ME["services"]) 43 MY_SERVICES: set[str] = set(ME["services"])
39 RUNNER_PORT = TOPO["runner_port"] 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 # ka6 (ou autre) ramasse les services non assignés qui apparaîtraient dans api-ka. 92 # 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) 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 def build_prompt(service: str, source: str, health: dict[str, Any]) -> str: 258 def build_prompt(service: str, source: str, health: dict[str, Any]) -> str:
208 svc = SERVICES[service] 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]) 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 "?"))
210− node_alias = NODES[svc["node"]]["alias"] 261+ node_alias_ = node_alias(svc["node"])
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', '')}). 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 == CONTEXTE SANTÉ DU CONNECTEUR (supervision api-ka, scan aux 2 h) == 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 {mission_history(service, source)} 274 {mission_history(service, source)}
224 275
225 == TON ENVIRONNEMENT == 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 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']}. 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 Le venv Python et/ou node_modules du repo sont déjà installés — utilise-les, jamais d'installation globale. 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 def build_effort_prompt(kind: str, service: str, source: str, note: str) -> str: 319 def build_effort_prompt(kind: str, service: str, source: str, note: str) -> str:
269 svc = SERVICES[service] 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]) 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 "?"))
271− node_alias = NODES[svc["node"]]["alias"] 322+ node_alias_ = node_alias(svc["node"])
272 rules = COMMON_RULES.replace("%AGENT%", AGENT) 323 rules = COMMON_RULES.replace("%AGENT%", AGENT)
273 env = f"""== TON ENVIRONNEMENT == 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 L'app tourne via pm2 ({', '.join(svc['pm2'])}); process de sync: {sync_proc}; site local port {svc['web_port']}. 326 L'app tourne via pm2 ({', '.join(svc['pm2'])}); process de sync: {sync_proc}; site local port {svc['web_port']}.
276 Commence par lire le CLAUDE.md / README du repo et docs/connecteurs/ s'ils existent.""" 327 Commence par lire le CLAUDE.md / README du repo et docs/connecteurs/ s'ils existent."""
277 if kind == "effort_degrade": 328 if kind == "effort_degrade":
@@ -350,8 +401,8 @@ Termine ta TOUTE DERNIÈRE réponse par un bloc JSON:
350 401
351 def build_site_down_prompt(service: str, detail: dict[str, Any]) -> str: 402 def build_site_down_prompt(service: str, detail: dict[str, Any]) -> str:
352 svc = SERVICES[service] 403 svc = SERVICES[service]
353− web_proc = svc["pm2"][0] 404+ web_proc = svc["pm2"][0] if svc["pm2"] else "?"
354− node_alias = NODES[svc["node"]]["alias"] 405+ node_alias_ = node_alias(svc["node"])
355 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. 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 Session 100 % AUTONOME: tu ne poses aucune question, tu travailles jusqu'au bout. 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 - 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'} 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 == TON ENVIRONNEMENT == 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 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). 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 == DÉMARCHE IMPOSÉE (dans l'ordre) == 418 == DÉMARCHE IMPOSÉE (dans l'ordre) ==
@@ -388,9 +439,16 @@ async def dispatch(incident: sqlite3.Row, health: dict[str, Any]) -> None:
388 service, source, iid = incident["service"], incident["source"], incident["id"] 439 service, source, iid = incident["service"], incident["source"], incident["id"]
389 svc = SERVICES[service] 440 svc = SERVICES[service]
390 node = svc["node"] 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 mid = uuid.uuid4().hex[:12] 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 kind = incident["status_detected"] 452 kind = incident["status_detected"]
395 max_cost = None 453 max_cost = None
396 if kind in ("effort_new", "effort_enrich", "effort_degrade"): 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 c.execute("INSERT INTO missions(id,incident_id,service,source,node,state,started) VALUES(?,?,?,?,?,?,?)", 490 c.execute("INSERT INTO missions(id,incident_id,service,source,node,state,started) VALUES(?,?,?,?,?,?,?)",
433 (mid, iid, service, source, node, "running", now())) 491 (mid, iid, service, source, node, "running", now()))
434 set_incident(c, iid, state="fixing", attempts=incident["attempts"] + 1) 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 hub.publish_sync({"kind": "mission", "mission_id": mid, "service": service, 494 hub.publish_sync({"kind": "mission", "mission_id": mid, "service": service,
437 "source": source, "state": "running", "ts": now()}) 495 "source": source, "state": "running", "ts": now()})
438 496
439 497
440 async def rollback_mission(mission: sqlite3.Row, reason: str) -> dict[str, Any]: 498 async def rollback_mission(mission: sqlite3.Row, reason: str) -> dict[str, Any]:
441 svc = SERVICES[mission["service"]] 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 try: 512 try:
444 code, body = await lan_post(ip, RUNNER_PORT, "/rollback", 513 code, body = await lan_post(ip, RUNNER_PORT, "/rollback",
445 {"dir": svc["dir"], "base_commit": mission["base_commit"], 514 {"dir": svc["dir"], "base_commit": mission["base_commit"],
@@ -540,6 +609,7 @@ async def poll_once() -> None:
540 609
541 610
542 async def tick() -> None: 611 async def tick() -> None:
612+ apply_registry() # même en pause : la topologie doit rester juste
543 if LATEST["paused"]: 613 if LATEST["paused"]:
544 return 614 return
545 try: 615 try:
@@ -647,7 +717,9 @@ async def reconcile_mission(mid: str) -> None:
647 m = c.execute("SELECT * FROM missions WHERE id=? AND state='running'", (mid,)).fetchone() 717 m = c.execute("SELECT * FROM missions WHERE id=? AND state='running'", (mid,)).fetchone()
648 if not m: 718 if not m:
649 return 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 try: 723 try:
652 code, body = await lan_post(ip, RUNNER_PORT, "/mission-result", {"mission_id": mid}, timeout=20) 724 code, body = await lan_post(ip, RUNNER_PORT, "/mission-result", {"mission_id": mid}, timeout=20)
653 resp = json.loads(body) if code == 200 else {"status": "erreur"} 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 async def runner_restart(service: str) -> dict[str, Any]: 785 async def runner_restart(service: str) -> dict[str, Any]:
714 """pm2 restart de l'app via le runner de son nœud (endpoint /restart).""" 786 """pm2 restart de l'app via le runner de son nœud (endpoint /restart)."""
715 svc = SERVICES[service] 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 try: 791 try:
718 code, body = await lan_post(ip, RUNNER_PORT, "/restart", 792 code, body = await lan_post(ip, RUNNER_PORT, "/restart",
719 {"pm2": svc["pm2"], "web_port": svc["web_port"]}, timeout=240) 793 {"pm2": svc["pm2"], "web_port": svc["web_port"]}, timeout=240)
@@ -937,7 +1011,11 @@ def state() -> dict[str, Any]:
937 "SELECT ts, mine FROM snapshots WHERE ts > ? ORDER BY ts", (now() - 7 * 86400,)).fetchall()] 1011 "SELECT ts, mine FROM snapshots WHERE ts > ? ORDER BY ts", (now() - 7 * 86400,)).fetchall()]
938 return { 1012 return {
939 "agent": AGENT, "identity": {k: ME.get(k) for k in ("domain", "accent", "accent2", "accent_soft", "tagline", "model")}, 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 "siblings": {a: {"domain": v["domain"], "accent": v["accent"], "services": v["services"]} 1019 "siblings": {a: {"domain": v["domain"], "accent": v["accent"], "services": v["services"]}
942 for a, v in TOPO["agents"].items() if a != AGENT}, 1020 for a, v in TOPO["agents"].items() if a != AGENT},
943 "latest": LATEST, "sites": SITE, "incidents": incidents, "missions": missions, 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 const strip = STATUSES.filter((st) => sum[st] > 0) 76 const strip = STATUSES.filter((st) => sum[st] > 0)
77 .map((st) => `<span class="s-${st}" style="flex:${sum[st]}" title="${STATUS_FR[st]}: ${sum[st]}"></span>`).join(""); 77 .map((st) => `<span class="s-${st}" style="flex:${sum[st]}" title="${STATUS_FR[st]}: ${sum[st]}"></span>`).join("");
78 const counts = STATUSES.map((st) => `<span><i class="s-${st}"></i>${sum[st] || 0} ${STATUS_FR[st]}</span>`).join(""); 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 return `<div class="cov"><div class="head"><span class="app">${esc(meta.app)}</span> 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 <div class="strip">${strip || "<span style='flex:1;background:var(--line)'></span>"}</div> 88 <div class="strip">${strip || "<span style='flex:1;background:var(--line)'></span>"}</div>
82 <div class="counts">${counts}</div></div>`; 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 function renderMissions() { 106 function renderMissions() {
modified orchestrator/web/style.css +6 −0
@@ -312,3 +312,9 @@ tbody tr:hover { background: var(--lime-soft); }
312 /* bouton primaire : fond lime + texte encre (plus de fond encre peu lisible) */ 312 /* bouton primaire : fond lime + texte encre (plus de fond encre peu lisible) */
313 .btn-primary { background: var(--lime); color: var(--ink); } 313 .btn-primary { background: var(--lime); color: var(--ink); }
314 .btn-primary:hover { background: var(--accent-deep); color: var(--lime); } 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 "apika_monitoring_url": "https://www.api-ka.com/api/v1/monitoring/connectors", 3 "apika_monitoring_url": "https://www.api-ka.com/api/v1/monitoring/connectors",
4 "runner_port": 7791, 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 "agents": { 11 "agents": {
6 "ka2": { 12 "ka2": {
7− "port": 8799, 13+ "port": 8799, "domain": "www.ka2.bot", "accent": "#a9d1f7", "accent2": "#1e4fa3", "accent_soft": "#e3effc",
8− "domain": "www.ka2.bot", 14+ "tagline": "Gardien immobilier & local", "model": "claude-fable-5",
9− "accent": "#a9d1f7", 15+ "services": ["louka", "immoka", "restoka", "houseka", "rentka"]
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"
21 }, 16 },
22 "ka4": { 17 "ka4": {
23− "port": 8899, 18+ "port": 8899, "domain": "www.ka4.bot", "accent": "#ffc36b", "accent2": "#a35c00", "accent_soft": "#ffefd9",
24− "domain": "www.ka4.bot", 19+ "tagline": "Gardien mobilité & quotidien", "model": "claude-fable-5",
25− "accent": "#ffc36b", 20+ "services": ["autoka", "foodka", "sortika"]
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"
35 }, 21 },
36 "ka6": { 22 "ka6": {
37− "port": 8999, 23+ "port": 8999, "domain": "www.ka6.bot", "accent": "#cdb4f9", "accent2": "#5b21b6", "accent_soft": "#f0e8fc",
38− "domain": "www.ka6.bot", 24+ "tagline": "Gardien flagship — gros volumes", "model": "claude-fable-5",
39− "accent": "#cdb4f9", 25+ "services": ["fabrika", "jobka", "creaka", "vraiprix"],
40− "accent2": "#5b21b6", 26+ "default_for_unknown_services": true
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"
51 } 27 }
52 }, 28 },
53 "nodes": { 29 "nodes": {
54− "m3u96a": { 30+ "m3u96a": { "lan_ip": "192.168.2.87", "alias": "M3U96a" },
55− "lan_ip": "192.168.2.87", 31+ "m3u96b": { "lan_ip": "192.168.2.82", "alias": "M3U96b" },
56− "alias": "M3U96a" 32+ "m2u64": { "lan_ip": "192.168.2.107", "alias": "M2U64" },
57− }, 33+ "m4m64a": { "lan_ip": "192.168.2.83", "alias": "M4M64a" },
58− "m3u96b": { 34+ "m4m64b": { "lan_ip": "192.168.2.78", "alias": "M4M64b" },
59− "lan_ip": "192.168.2.82", 35+ "m4m36": { "lan_ip": "192.168.2.69", "alias": "M4M36" },
60− "alias": "M3U96b" 36+ "m2m32": { "lan_ip": "192.168.2.90", "alias": "M2M32" }
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− }
74 }, 37 },
75 "services": { 38 "services": {
76− "louka": { 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" },
77− "app": "Lou·Ka", 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" },
78− "node": "m3u96b", 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" },
79− "dir": "~/apps/lou-ka", 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" },
80− "pm2": [ 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" },
81− "lou-ka-web", 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" },
82− "lou-ka-sync" 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" },
83− ], 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" },
84− "web_port": 8095, 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" },
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− },
175 "vraiprix": { 48 "vraiprix": {
176− "app": "Vrai·Prix", 49+ "app": "Vrai·Prix", "registry_app": "vrai-prix", "node": "m3u96a", "dir": "~/apps/vrai-prix",
177− "node": "m3u96a", 50+ "pm2": ["vrai-prix"], "pm2_exclude": ["vrai-prix-ingest"], "web_port": 8090, "site": "https://www.vrai-prix.com",
178− "dir": "~/apps/vrai-prix", 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."
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."
185 }, 52 },
186 "houseka": { 53 "houseka": {
187− "app": "House·Ka", 54+ "app": "House·Ka", "registry_app": "house-ka", "node": "m3u96a", "dir": "~/apps/house-ka",
188− "node": "m4m64b", 55+ "pm2": ["house-ka-web", "house-ka-sync"], "web_port": 8098, "site": "https://www.house-ka.com",
189− "dir": "~/apps/house-ka", 56+ "comment": "Fork achat de Immo-Ka (Canada hors Quebec). Connecteurs rapportes a api-ka depuis 2026-08-28 (28 sources, cadence 4 h)."
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[]."
197 }, 57 },
198 "rentka": { 58 "rentka": {
199− "app": "Rent·Ka", 59+ "app": "Rent·Ka", "registry_app": "rent-ka", "node": "m4m64b", "dir": "~/apps/rent-ka",
200− "node": "m4m36", 60+ "pm2": ["rent-ka-web", "rent-ka-sync"], "web_port": 8125, "site": "https://www.rent-ka.com",
201− "dir": "~/apps/rent-ka", 61+ "comment": "Fork anglophone de Lou-Ka (Canada hors Quebec). Connecteurs rapportes a api-ka depuis 2026-08-28 (~680 sources)."
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[]."
209 } 62 }
210 }, 63 },
211 "policy": { 64 "policy": {
212 "poll_interval_seconds": 300, 65 "poll_interval_seconds": 300,
213− "trigger_statuses": [ 66+ "trigger_statuses": ["broken", "stale"],
214− "broken",
215− "stale"
216− ],
217 "max_concurrent_missions": 1, 67 "max_concurrent_missions": 1,
218 "max_attempts_per_incident": 3, 68 "max_attempts_per_incident": 3,
219 "attempt_cooldown_hours": 6, 69 "attempt_cooldown_hours": 6,
220 70
Menu

Theme