SPB Git forge

spb/ka-guardian

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

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 19 days ago (Sep 5, 2026) parent fd310fc

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