topologie vivante : emplacements des services lus dans le registre mld (plus rien en dur)
- orchestrator/registry.py : applique ~/ka-guardian-spool/registry.json (nœud, IP, dir, port, PM2 sans ngrok) à SERVICES/NODES en place, journalise les déménagements, lit la sonde des runners ; topology.json ne garde que service→registry_app (+ pm2_exclude), ses node/dir/port ne sont qu un repli - main.py : refresh à chaque tick, refus de dépêcher vers un nœud sans runner (log 1×/h), rollback sur le nœud ACTUEL du service, bloc registry dans /api/state - deploy/registry-sync.sh + launchd com.ka.registry-sync (zsh pur, LNP) : tire le registre via gitsrv toutes les 2 min + sonde :7791 → runners.json - deploy.sh : nœuds/repos dérivés du registre, cible registry-sync, pousseur fusionné, helper lc_reload (agent gui/uid ou DAEMON système sur nœud sans session graphique, ex. M2U64), filtre <nœud> - dashboard : runner prêt/occupé/absent + fraîcheur du registre par service Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
9 changed files +634 −263
modified
CLAUDE.md
+22 −1
@@ -4,6 +4,27 @@ Un seul repo pour les **3 sites gardiens** : www.ka2.bot, www.ka4.bot, www.ka6.b | |||||
| 4 | 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 | ||||