SPB Git

spb/admin-ka

Public

topologie vivante : sites, projets Claude Code et nœuds lus dans le registre mld (plus rien en dur)

- server/registry.js : registre M1M32:~/dispatch/registry.json (copie data/registry.json
  poussée par mld + surveillée, tirage ssh 2 min), SITES/getSites, getProjects/findProject
  (ids app@nœud résolus par app), selfNode (entrée admin-ka du registre), sshTarget
  (alias ssh sinon user@IP LAN), topologyText pour les prompts, CLAUDE.md de
  l orchestrateur régénéré, outillage automatique des nœuds (perm-mcp, CLAUDE.md contextuel)
- monitor.js / server.js : plus de SITES/PROJECTS/NODES/SELF_NODE figés ; realignChat
  suit une app déménagée ; API /api/registry (+refresh, +tooling) ; alertes registre
- UI : carte Registre dans Réglages, sélecteur d apps re-rempli à chaud, alerte déménagement
- KA_CATALOG = connaissance Ka seulement (libellés, launchd gardiens, pm2 non déductibles)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Sep 5, 2026) parent 0809871 Browse files

7 changed files +591 −89

modified .gitignore +1 −0
@@ -9,3 +9,4 @@ data/chats.json
9 data/sessions.json 9 data/sessions.json
10 data/mcp-*.json 10 data/mcp-*.json
11 data/transcripts/ 11 data/transcripts/
12+data/registry.json
modified CLAUDE.md +4 −3
@@ -22,9 +22,10 @@ iPhone ⇄ wss://www.administration-ka.com (ngrok) ⇄ backend :3300 (M3U96a)
22 backend ⇄ monitor.js : health checks + sweep nœuds + SQLite + push WS 22 backend ⇄ monitor.js : health checks + sweep nœuds + SQLite + push WS
23 ``` 23 ```
24 24
25+- `server/registry.js` — **topologie vivante** (2026-09-04) : où tourne chaque app Ka = registre mld de la passerelle (`M1M32:~/dispatch/registry.json`). Copie `data/registry.json` poussée par `mld` (abonné `admin-ka`) et surveillée (fs.watchFile), + tirage ssh toutes les 2 min (`M1M32` puis `gitsrv`). Fournit `SITES`/`getSites()` (monitoring), `getProjects()`/`findProject()` (projets Claude Code, ids `app@nœud` résolus par app), `selfNode()` (nœud de la console = entrée `admin-ka` du registre), `sshTarget()` (alias ssh sinon `user@IP LAN`), `topologyText()` (bloc injecté dans les prompts système), régénère `~/ka-orchestrator/CLAUDE.md` à chaque changement et outille les nœuds hébergeurs (`~/.adminka/perm-mcp.js`, `~/.claude/CLAUDE.md` contextuel). `KA_CATALOG` ne contient que la connaissance Ka (libellés, launchd des gardiens, pm2 non déductibles) — **plus jamais d'emplacement en dur**. API : `GET /api/registry`, `POST /api/registry/refresh`, `POST /api/registry/tooling` ; UI : carte « Registre » dans Réglages, alerte `eco_alert kind=registry` + message WS `projects` quand une app bouge ; une conversation dont l'app a déménagé est réalignée à son prochain tour (`realignChat`, nouvelle session Claude).
25 - `server/server.js` — HTTP + WS + orchestration claude + états de session. 26 - `server/server.js` — HTTP + WS + orchestration claude + états de session.
26−- `server/monitor.js` — collecteur Écosystème (node:sqlite intégré, aucune dépendance native). 27+- `server/monitor.js` — collecteur Écosystème (node:sqlite intégré, aucune dépendance native) ; sites et nœuds = `registry.js`.
27−- `server/perm-mcp.js` — serveur MCP stdio pour `--permission-prompt-tool mcp__adminka__approve` ; déployé aussi sur M3U96b/M4M64a/M4M64b/M2M32 dans `~/.adminka/`. 28+- `server/perm-mcp.js` — serveur MCP stdio pour `--permission-prompt-tool mcp__adminka__approve` ; copié automatiquement dans `~/.adminka/` de chaque nœud hébergeur connu du registre (`ensureNodeTooling`, contrôle toutes les 6 h ou bouton « Vérifier l'outillage » dans Réglages).
28 - `public/` — frontend une page, 4 onglets, aucun framework. 29 - `public/` — frontend une page, 4 onglets, aucun framework.
29 30
30 ## Pièges connus (ne pas re-découvrir) 31 ## Pièges connus (ne pas re-découvrir)
@@ -46,7 +47,7 @@ tail -f ~/apps/admin-ka/data/backend.err.log
46 node server/set-password.js '<nouveau>' # changer le mot de passe 47 node server/set-password.js '<nouveau>' # changer le mot de passe
47 ``` 48 ```
48 49
49−Registre des déploiements : `~/Desktop/cluster-skill/cluster-deployments.json` (laptop, source de vérité). Repo : spbgit `~/srv/git/admin-ka.git`. 50+Registre des déploiements : **`M1M32:~/dispatch/registry.json`** (orchestrateur `mld`, source de vérité — `cluster-deployments.json` du laptop est l'ancien registre, ne plus s'en servir). Repo : spbgit `gitsrv:srv/git/admin-ka.git` (bare sur M1M32).
50 51
51 ## v3 (2026-08-26) — Control Center : refonte UI + analytics humains 52 ## v3 (2026-08-26) — Control Center : refonte UI + analytics humains
52 53
modified public/app.js +69 −11
@@ -1022,7 +1022,18 @@
1022 } 1022 }
1023 return; 1023 return;
1024 } 1024 }
1025+ if (msg.type === 'projects') { // le registre mld a bougé : la liste des apps/nœuds suit
1026+ fillProjects(msg.projects || []);
1027+ loadRegistryInfo();
1028+ return;
1029+ }
1025 if (msg.type === 'eco_alert') { 1030 if (msg.type === 'eco_alert') {
1031+ if (msg.kind === 'registry') {
1032+ banner('warn', `📦 ${esc(msg.reason || '')} — registre mld`, () => switchView('settings'), 12000);
1033+ loadRegistryInfo();
1034+ if (curView === 'eco') loadEco();
1035+ return;
1036+ }
1026 if (msg.kind === 'down') { banner('bad', `🔴 ${esc(msg.site)} est HORS LIGNE — ${esc(msg.reason || '')}`, () => switchView('eco'), 12000); const tb = $('topIncBadge'); if (tb) tb.classList.remove('hidden'); } 1037 if (msg.kind === 'down') { banner('bad', `🔴 ${esc(msg.site)} est HORS LIGNE — ${esc(msg.reason || '')}`, () => switchView('eco'), 12000); const tb = $('topIncBadge'); if (tb) tb.classList.remove('hidden'); }
1027 else banner('good', `🟢 ${esc(msg.site)} est de retour en ligne`, () => switchView('eco')); 1038 else banner('good', `🟢 ${esc(msg.site)} est de retour en ligne`, () => switchView('eco'));
1028 navBadge('eco'); 1039 navBadge('eco');
@@ -1865,6 +1876,40 @@
1865 }; 1876 };
1866 1877
1867 // ---------- login / init ---------- 1878 // ---------- login / init ----------
1879+ // Sélecteur d'app : rempli depuis /api/projects (dérivé du registre mld) et RE-rempli
1880+ // quand le serveur annonce un changement de topologie (message WS `projects`).
1881+ function fillProjects(projects) {
1882+ const keep = projectSel.value || settings.projectId;
1883+ projectSel.innerHTML = '';
1884+ const allOpt = document.createElement('option');
1885+ allOpt.value = 'ALL'; allOpt.textContent = '🌐 Tous les sites KA (' + Math.max(0, projects.length - 1) + ')';
1886+ projectSel.appendChild(allOpt);
1887+ for (const p of projects) {
1888+ const o = document.createElement('option');
1889+ o.value = p.id; o.textContent = p.name + ' · ' + p.node;
1890+ projectSel.appendChild(o);
1891+ }
1892+ // un ancien id « app@nœud » reste valide côté serveur (résolu par app) ; côté UI on
1893+ // retrouve l'option de la même app si elle a changé de nœud
1894+ if (keep) {
1895+ const app = String(keep).split('@')[0];
1896+ const match = [...projectSel.options].find(o => o.value === keep) || [...projectSel.options].find(o => o.value.split('@')[0] === app);
1897+ if (match) { projectSel.value = match.value; if (settings.projectId !== match.value) { settings.projectId = match.value; saveStore(); } }
1898+ }
1899+ if (typeof refreshChips === 'function') refreshChips();
1900+ }
1901+ async function loadRegistryInfo() {
1902+ const el = $('setRegistry'), en = $('setRegistryNodes');
1903+ if (!el) return;
1904+ try {
1905+ const r = await (await fetch('/api/registry')).json();
1906+ const when = r.updated ? r.updated.replace('T', ' ') : 'jamais reçu';
1907+ const age = r.ageMs != null ? Math.round(r.ageMs / 60000) : null;
1908+ el.textContent = r.error ? `⚠ ${r.error}` : `${r.sites.length} apps Ka / ${r.nApps} au registre · mld ${when}${age != null ? ` · lu il y a ${age} min` : ''}${r.stale ? ' · ⚠ PÉRIMÉ' : ''}`;
1909+ el.title = `source : ${r.source || '?'} · cache ${r.cache || ''}${r.missing?.length ? ` · absentes du registre : ${r.missing.join(', ')}` : ''}`;
1910+ if (en) en.textContent = (r.nodes || []).map(n => n === r.self ? n + ' (console)' : n).join(' · ');
1911+ } catch (e) { el.textContent = 'indisponible'; }
1912+ }
1868 async function init() { 1913 async function init() {
1869 const me = await fetch('/api/me'); 1914 const me = await fetch('/api/me');
1870 if (!me.ok) { 1915 if (!me.ok) {
@@ -1873,21 +1918,34 @@
1873 return; 1918 return;
1874 } 1919 }
1875 const info = await me.json(); 1920 const info = await me.json();
1876− $('setHost').textContent = (info.node || 'M3U96a') + ':3300'; 1921+ $('setHost').textContent = (info.node || '?') + ':3300';
1877 $('login').classList.add('hidden'); 1922 $('login').classList.add('hidden');
1878 $('app').classList.remove('hidden'); 1923 $('app').classList.remove('hidden');
1879 const pr = await fetch('/api/projects'); 1924 const pr = await fetch('/api/projects');
1880 const { projects } = await pr.json(); 1925 const { projects } = await pr.json();
1881− projectSel.innerHTML = ''; 1926+ fillProjects(projects);
1882− const allOpt = document.createElement('option'); 1927+ loadRegistryInfo();
1883− allOpt.value = 'ALL'; allOpt.textContent = '🌐 Tous les sites KA (' + projects.length + ')'; 1928+ const rb = $('btnRegRefresh');
1884− projectSel.appendChild(allOpt); 1929+ if (rb) rb.onclick = async () => {
1885− for (const p of projects) { 1930+ rb.disabled = true; rb.textContent = '↻ lecture…';
1886− const o = document.createElement('option'); 1931+ try {
1887− o.value = p.id; o.textContent = p.name + ' · ' + p.node; 1932+ const r = await fetch('/api/registry/refresh', { method: 'POST' });
1888− projectSel.appendChild(o); 1933+ const d = await r.json();
1889− } 1934+ banner(d.ok ? 'good' : 'bad', d.ok ? `Registre relu (${esc(d.source || '')})${d.events?.length ? ' — ' + d.events.length + ' changement(s)' : ''}` : 'Registre injoignable : ' + esc(d.error || '?'));
1890− if (settings.projectId) projectSel.value = settings.projectId; 1935+ const pr2 = await fetch('/api/projects'); fillProjects((await pr2.json()).projects || []);
1936+ } catch (e) { banner('bad', 'Échec : ' + e.message); }
1937+ rb.disabled = false; rb.textContent = '↻ Relire le registre (M1M32)'; loadRegistryInfo();
1938+ };
1939+ const tb = $('btnRegTooling');
1940+ if (tb) tb.onclick = async () => {
1941+ tb.disabled = true; tb.textContent = '🧰 vérification…';
1942+ try {
1943+ const d = await (await fetch('/api/registry/tooling', { method: 'POST' })).json();
1944+ const lines = (d.nodes || []).map(n => `${n.node} : ${n.local ? 'console (local)' : n.error ? '✗ ' + n.error : (n.actions && n.actions.length ? n.actions.join(' ; ') : 'ok')}`);
1945+ banner('good', lines.map(esc).join('<br>'), null, 15000);
1946+ } catch (e) { banner('bad', 'Échec : ' + e.message); }
1947+ tb.disabled = false; tb.textContent = '🧰 Vérifier l\'outillage des nœuds';
1948+ };
1891 projectSel.onchange = () => { 1949 projectSel.onchange = () => {
1892 settings.projectId = projectSel.value; saveStore(); refreshChips(); 1950 settings.projectId = projectSel.value; saveStore(); refreshChips();
1893 inputEl.placeholder = projectSel.value === 'ALL' ? 'Prompt diffusé à TOUTES les apps KA…' 1951 inputEl.placeholder = projectSel.value === 'ALL' ? 'Prompt diffusé à TOUTES les apps KA…'
modified public/index.html +11 −2
@@ -256,8 +256,17 @@ document.documentElement.dataset.theme=dark?'dark':'light';}catch(e){}})();</scr
256 </div> 256 </div>
257 </div> 257 </div>
258 <div class="set-card"> 258 <div class="set-card">
259− <div class="set-row"><span>Backend</span><span id="setHost" class="mono">M3U96a:3300</span></div> 259+ <div class="set-label">Registre des emplacements (mld · passerelle M1M32)</div>
260− <div class="set-row"><span>Version</span><span class="mono">3.0.0</span></div> 260+ <div class="set-row"><span>Registre</span><span id="setRegistry" class="mono">—</span></div>
261+ <div class="set-row"><span>Nœuds Ka</span><span id="setRegistryNodes" class="mono">—</span></div>
262+ <div class="set-actions">
263+ <button id="btnRegRefresh" class="btn-ghost">↻ Relire le registre (M1M32)</button>
264+ <button id="btnRegTooling" class="btn-ghost">🧰 Vérifier l'outillage des nœuds</button>
265+ </div>
266+ </div>
267+ <div class="set-card">
268+ <div class="set-row"><span>Backend</span><span id="setHost" class="mono">—</span></div>
269+ <div class="set-row"><span>Version</span><span class="mono">3.2.0</span></div>
261 </div> 270 </div>
262 <button id="logoutBtn" class="btn-danger">Se déconnecter</button> 271 <button id="logoutBtn" class="btn-danger">Se déconnecter</button>
263 </section> 272 </section>
modified server/monitor.js +22 −18
@@ -6,9 +6,13 @@ import https from 'node:https';
6 import path from 'node:path'; 6 import path from 'node:path';
7 import { execFile } from 'node:child_process'; 7 import { execFile } from 'node:child_process';
8 import { DatabaseSync } from 'node:sqlite'; 8 import { DatabaseSync } from 'node:sqlite';
9+import { getSites, getNodes, selfNode, sshTarget } from './registry.js';
9 10
10−// Sites du Groupe KA (source : cluster-deployments.json 2026-08-19) 11+// Les sites surveillés et leurs nœuds viennent du REGISTRE mld (registry.js) —
11−export const SITES = [ 12+// plus aucune table figée ici : une app déplacée par `mld move` est suivie
13+// automatiquement (health checks, sweep du bon nœud, actions pm2/launchd).
14+// Historique (pour mémoire, table figée du 2026-08-19, devenue fausse le 2026-09-04) :
15+const _SITES_LEGACY_2026_08 = [
12 { app: 'lou-ka', label: 'Lou·Ka', domain: 'www.lou-ka.com', node: 'M3U96b', pm2: 'lou-ka-web', dir: '~/apps/lou-ka', deployed: '2026-08' }, 16 { app: 'lou-ka', label: 'Lou·Ka', domain: 'www.lou-ka.com', node: 'M3U96b', pm2: 'lou-ka-web', dir: '~/apps/lou-ka', deployed: '2026-08' },
13 { app: 'immo-ka', label: 'Immo·Ka', domain: 'www.immo-ka.com', node: 'M4M64a', pm2: 'immo-ka-web', dir: '~/apps/immo-ka', deployed: '2026-08' }, 17 { app: 'immo-ka', label: 'Immo·Ka', domain: 'www.immo-ka.com', node: 'M4M64a', pm2: 'immo-ka-web', dir: '~/apps/immo-ka', deployed: '2026-08' },
14 { app: 'house-ka', label: 'House·Ka', domain: 'www.house-ka.com', node: 'M4M64b', pm2: 'house-ka-web', dir: '~/apps/house-ka', deployed: '2026-08' }, 18 { app: 'house-ka', label: 'House·Ka', domain: 'www.house-ka.com', node: 'M4M64b', pm2: 'house-ka-web', dir: '~/apps/house-ka', deployed: '2026-08' },
@@ -31,8 +35,7 @@ export const SITES = [
31 { app: 'ka6', label: 'KA·6 Guardian', domain: 'www.ka6.bot', node: 'M4M36', unit: 'com.ka6.guardian', log: '~/cluster-projects/ka-guardian/logs/ka6.log', dir: '~/cluster-projects/ka-guardian', deployed: '2026-08' }, 35 { app: 'ka6', label: 'KA·6 Guardian', domain: 'www.ka6.bot', node: 'M4M36', unit: 'com.ka6.guardian', log: '~/cluster-projects/ka-guardian/logs/ka6.log', dir: '~/cluster-projects/ka-guardian', deployed: '2026-08' },
32 ]; 36 ];
33 37
34−const NODES = ['M3U96a', 'M3U96b', 'M4M64a', 'M4M64b', 'M2M32', 'M4M36', 'M1M32']; 38+void _SITES_LEGACY_2026_08; // conservé comme trace, jamais utilisé
35−const SELF_NODE = 'M3U96a';
36 const CHECK_MS = 45_000; 39 const CHECK_MS = 45_000;
37 const SWEEP_MS = 5 * 60_000; 40 const SWEEP_MS = 5 * 60_000;
38 const SLOW_MS = 4000; // seuil "lent" (▲) 41 const SLOW_MS = 4000; // seuil "lent" (▲)
@@ -80,7 +83,7 @@ async function fetchIconFor(site) {
80 } 83 }
81 } 84 }
82 async function fetchAllIcons() { 85 async function fetchAllIcons() {
83− await Promise.all(SITES.map(s => fetchIconFor(s).catch(() => {}))); 86+ await Promise.all(getSites().map(s => fetchIconFor(s).catch(() => {})));
84 emit({ type: 'eco', kind: 'icons', ts: Date.now() }); 87 emit({ type: 'eco', kind: 'icons', ts: Date.now() });
85 } 88 }
86 export function getIcon(app) { return ICONS[app] || null; } 89 export function getIcon(app) { return ICONS[app] || null; }
@@ -130,7 +133,7 @@ function checkOne(site) {
130 } 133 }
131 134
132 async function checkAll() { 135 async function checkAll() {
133− const results = await Promise.all(SITES.map(checkOne)); 136+ const results = await Promise.all(getSites().map(checkOne));
134 const ins = db.prepare('INSERT INTO checks(site,ts,ms,code,ok,err,bytes) VALUES(?,?,?,?,?,?,?)'); 137 const ins = db.prepare('INSERT INTO checks(site,ts,ms,code,ok,err,bytes) VALUES(?,?,?,?,?,?,?)');
135 for (const r of results) { 138 for (const r of results) {
136 ins.run(r.site, r.ts, r.ms, r.code, r.ok, r.err, r.bytes || 0); 139 ins.run(r.site, r.ts, r.ms, r.code, r.ok, r.err, r.bytes || 0);
@@ -160,10 +163,10 @@ function handleIncident(r) {
160 // ---------- sweep des nœuds (charge, RAM, disque, PM2, dernier commit) ---------- 163 // ---------- sweep des nœuds (charge, RAM, disque, PM2, dernier commit) ----------
161 function sweepNode(node) { 164 function sweepNode(node) {
162 return new Promise((resolve) => { 165 return new Promise((resolve) => {
163− const gitCmds = SITES.filter(s => s.node === node) 166+ const gitCmds = getSites().filter(s => s.node === node)
164− .map(s => `echo "GIT ${s.app} $(git -C ${s.dir} log -1 --format=%ct 2>/dev/null || echo 0)"`).join('; '); 167+ .map(s => `echo "GIT ${s.app} $(git -C ${s.repoDir || s.dir} log -1 --format=%ct 2>/dev/null || echo 0)"`).join('; ');
165 // sites sous launchd (KA Guardian) : PID + cpu/rss via ps 168 // sites sous launchd (KA Guardian) : PID + cpu/rss via ps
166− const lchCmds = SITES.filter(s => s.node === node && s.unit) 169+ const lchCmds = getSites().filter(s => s.node === node && s.unit)
167 .map(s => `pid=$(launchctl list | awk '$3=="${s.unit}"{print $1}'); [ -z "$pid" ] && pid=-; echo "LCH ${s.app} $pid $(ps -o %cpu=,rss= -p $pid 2>/dev/null | tr -s ' ')"`).join('; '); 170 .map(s => `pid=$(launchctl list | awk '$3=="${s.unit}"{print $1}'); [ -z "$pid" ] && pid=-; echo "LCH ${s.app} $pid $(ps -o %cpu=,rss= -p $pid 2>/dev/null | tr -s ' ')"`).join('; ');
168 const script = `export PATH=/opt/homebrew/bin:/usr/local/bin:$PATH 171 const script = `export PATH=/opt/homebrew/bin:/usr/local/bin:$PATH
169 echo "LOAD $(sysctl -n vm.loadavg 2>/dev/null)" 172 echo "LOAD $(sysctl -n vm.loadavg 2>/dev/null)"
@@ -174,10 +177,11 @@ echo "DISK $(df -k / | tail -1 | awk '{print $5}' | tr -d '%')"
174 echo "PM2JSON $(pm2 jlist 2>/dev/null | tail -1)" 177 echo "PM2JSON $(pm2 jlist 2>/dev/null | tail -1)"
175 ${lchCmds} 178 ${lchCmds}
176 ${gitCmds}`; 179 ${gitCmds}`;
177− const args = node === SELF_NODE 180+ const local = node === selfNode();
181+ const args = local
178 ? ['-c', script] 182 ? ['-c', script]
179− : ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=12', node, script]; 183+ : ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=12', sshTarget(node), script];
180− const bin = node === SELF_NODE ? '/bin/bash' : 'ssh'; 184+ const bin = local ? '/bin/bash' : 'ssh';
181 execFile(bin, args, { timeout: 45_000, maxBuffer: 8 * 1024 * 1024, env: { ...process.env, PATH: '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin' } }, (err, stdout) => { 185 execFile(bin, args, { timeout: 45_000, maxBuffer: 8 * 1024 * 1024, env: { ...process.env, PATH: '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin' } }, (err, stdout) => {
182 if (err && !stdout) return resolve(null); 186 if (err && !stdout) return resolve(null);
183 try { parseSweep(node, stdout); } catch {} 187 try { parseSweep(node, stdout); } catch {}
@@ -210,7 +214,7 @@ function parseSweep(node, out) {
210 lch[m[1]] = { pid: m[2], cpu: Number(m[3] || 0), rss: Number(m[4] || 0) }; 214 lch[m[1]] = { pid: m[2], cpu: Number(m[3] || 0), rss: Number(m[4] || 0) };
211 } 215 }
212 const ins = db.prepare('INSERT INTO appstats(app,ts,cpu,mem,status,restarts,last_commit) VALUES(?,?,?,?,?,?,?)'); 216 const ins = db.prepare('INSERT INTO appstats(app,ts,cpu,mem,status,restarts,last_commit) VALUES(?,?,?,?,?,?,?)');
213− for (const site of SITES.filter(s => s.node === node)) { 217+ for (const site of getSites().filter(s => s.node === node)) {
214 if (site.unit) { 218 if (site.unit) {
215 const l = lch[site.app]; 219 const l = lch[site.app];
216 const up = l && l.pid !== '-' && /^\d+$/.test(l.pid); 220 const up = l && l.pid !== '-' && /^\d+$/.test(l.pid);
@@ -231,7 +235,7 @@ function parseSweep(node, out) {
231 } 235 }
232 236
233 async function sweepNodes() { 237 async function sweepNodes() {
234− await Promise.all(NODES.map(sweepNode)); 238+ await Promise.all(getNodes().map(sweepNode));
235 emit({ type: 'eco', kind: 'sweep', ts: Date.now() }); 239 emit({ type: 'eco', kind: 'sweep', ts: Date.now() });
236 } 240 }
237 241
@@ -298,7 +302,7 @@ function incidentStats(site) {
298 302
299 export function ecoSummary() { 303 export function ecoSummary() {
300 const out = []; 304 const out = [];
301− for (const s of SITES) { 305+ for (const s of getSites()) {
302 const last = db.prepare('SELECT ts,ms,code,ok,err FROM checks WHERE site=? ORDER BY ts DESC LIMIT 1').get(s.app); 306 const last = db.prepare('SELECT ts,ms,code,ok,err FROM checks WHERE site=? ORDER BY ts DESC LIMIT 1').get(s.app);
303 const spark = db.prepare('SELECT ts,ms,ok FROM checks WHERE site=? ORDER BY ts DESC LIMIT 40').all(s.app).reverse(); 307 const spark = db.prepare('SELECT ts,ms,ok FROM checks WHERE site=? ORDER BY ts DESC LIMIT 40').all(s.app).reverse();
304 const avg = db.prepare('SELECT AVG(ms) a FROM checks WHERE site=? AND ok=1 AND ts>?').get(s.app, Date.now() - 24 * 3600_000); 308 const avg = db.prepare('SELECT AVG(ms) a FROM checks WHERE site=? AND ok=1 AND ts>?').get(s.app, Date.now() - 24 * 3600_000);
@@ -346,7 +350,7 @@ export function ecoSummary() {
346 } 350 }
347 351
348 export function ecoSite(app) { 352 export function ecoSite(app) {
349− const s = SITES.find(x => x.app === app); 353+ const s = getSites().find(x => x.app === app);
350 if (!s) return null; 354 if (!s) return null;
351 const since = Date.now() - 24 * 3600_000; 355 const since = Date.now() - 24 * 3600_000;
352 // ~200 seaux sur 24 h pour le graphique 356 // ~200 seaux sur 24 h pour le graphique
@@ -384,10 +388,10 @@ export function ecoIncidents() {
384 388
385 export function ecoNodes() { 389 export function ecoNodes() {
386 const out = []; 390 const out = [];
387− for (const node of NODES) { 391+ for (const node of getNodes()) {
388 const last = db.prepare('SELECT ts,load1,mem_used,mem_total,disk_pct FROM nodestats WHERE node=? ORDER BY ts DESC LIMIT 1').get(node); 392 const last = db.prepare('SELECT ts,load1,mem_used,mem_total,disk_pct FROM nodestats WHERE node=? ORDER BY ts DESC LIMIT 1').get(node);
389 const hist = db.prepare('SELECT ts,load1,mem_used,mem_total FROM nodestats WHERE node=? AND ts>? ORDER BY ts').all(node, Date.now() - 24 * 3600_000); 393 const hist = db.prepare('SELECT ts,load1,mem_used,mem_total FROM nodestats WHERE node=? AND ts>? ORDER BY ts').all(node, Date.now() - 24 * 3600_000);
390− const apps = SITES.filter(s => s.node === node).map(s => { 394+ const apps = getSites().filter(s => s.node === node).map(s => {
391 const p = db.prepare('SELECT cpu,mem,status,restarts FROM appstats WHERE app=? ORDER BY ts DESC LIMIT 1').get(s.app); 395 const p = db.prepare('SELECT cpu,mem,status,restarts FROM appstats WHERE app=? ORDER BY ts DESC LIMIT 1').get(s.app);
392 return { app: s.app, label: s.label, ...p }; 396 return { app: s.app, label: s.label, ...p };
393 }); 397 });
added server/registry.js +395 −0
@@ -0,0 +1,395 @@
1+// registry.js — topologie VIVANTE de la console admin-ka.
2+// « Quelle app tourne sur quel nœud ? » n'est plus codé ici : la réponse vient
3+// du registre de la passerelle mld (M1M32:~/dispatch/registry.json), que
4+// l'orchestrateur maclustr-dispatch met à jour à chaque deploy/move/retire.
5+// • poussé par mld dans data/registry.json (abonné « admin-ka », suit les migrations) ;
6+// • tiré toutes les 2 min par ssh (M1M32 / gitsrv) en ceinture-et-bretelles ;
7+// • le fichier local est surveillé : un push mld est appliqué en quelques secondes.
8+// Ce module ne garde en dur que la CONNAISSANCE Ka (libellés, exceptions launchd,
9+// process PM2 non déductibles) — jamais un emplacement.
10+import fs from 'node:fs';
11+import os from 'node:os';
12+import path from 'node:path';
13+import { execFile } from 'node:child_process';
14+import { fileURLToPath } from 'node:url';
15+
16+const __dirname = path.dirname(fileURLToPath(import.meta.url));
17+const HOME = os.homedir();
18+const USER = 'simon-pierreboucher';
19+const GATEWAY_HOSTS = ['M1M32', 'gitsrv']; // alias ssh essayés pour tirer le registre
20+const PULL_MS = 2 * 60_000;
21+const WATCH_MS = 5_000;
22+const STALE_MS = 3 * 3600_000; // registre plus vieux → « périmé » (mld en panne ?)
23+const ENV_PATH = '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin';
24+
25+// ---------- connaissance Ka (PAS d'emplacement ici) ----------
26+// label : nom affiché ; pm2 : process web quand la règle « *-web sinon 1er » ne suffit pas ;
27+// unit/log : apps sous launchd (pas de pm2) ; repoDir : repo de travail ≠ dir du manifeste ;
28+// monitor:false / project:false : exclure du monitoring / des projets Claude Code.
29+export const KA_CATALOG = {
30+ 'lou-ka': { label: 'Lou·Ka' },
31+ 'rent-ka': { label: 'Rent·Ka' },
32+ 'immo-ka': { label: 'Immo·Ka' },
33+ 'house-ka': { label: 'House·Ka' },
34+ 'vrai-prix': { label: 'Vrai-Prix' },
35+ 'auto-ka': { label: 'Auto·Ka' },
36+ 'fabri-ka': { label: 'Fabri·Ka' },
37+ 'food-ka': { label: 'Food·Ka' },
38+ 'resto-ka': { label: 'Resto·Ka' },
39+ 'sorti-ka': { label: 'Sorti·Ka' },
40+ 'crea-ka': { label: 'Créa·Ka' },
41+ 'job-ka': { label: 'Job·Ka' },
42+ 'trouve-ka': { label: 'Trouve·Ka', pm2: 'tk-web' },
43+ 'groupe-ka': { label: 'Groupe·Ka' },
44+ 'api-ka': { label: 'API·Ka', pm2: 'apika-api' },
45+ 'ka-stats': { label: 'Ka·Stats' },
46+ // Agents gardiens KA Guardian — un seul repo, launchd (pas de pm2)
47+ 'ka2': { label: 'KA·2 Guardian', unit: 'com.ka2.guardian', log: '~/cluster-projects/ka-guardian/logs/ka2.log', repoDir: '~/cluster-projects/ka-guardian' },
48+ 'ka4': { label: 'KA·4 Guardian', unit: 'com.ka4.guardian', log: '~/cluster-projects/ka-guardian/logs/ka4.log', repoDir: '~/cluster-projects/ka-guardian' },
49+ 'ka6': { label: 'KA·6 Guardian', unit: 'com.ka6.guardian', log: '~/cluster-projects/ka-guardian/logs/ka6.log', repoDir: '~/cluster-projects/ka-guardian' },
50+ // la console elle-même : sert à détecter SON nœud, ni surveillée ni projet
51+ 'admin-ka': { label: 'Administration·Ka', monitor: false, project: false },
52+};
53+// toute app du registre au nom « Ka » (ex. un futur toit-ka) est incluse automatiquement
54+const KA_NAME_RE = /(^|-)ka(\d|-|$)/i;
55+
56+// ---------- état ----------
57+export const SITES = []; // rempli EN PLACE (les importateurs gardent leur référence)
58+let REG = { apps: {}, updated: null, gateway: null, fetchedAt: 0, source: null, error: null, mtime: 0 };
59+let SELF = 'M3U96a'; // repli tant que le registre n'a pas dit où tourne admin-ka
60+let cachePath = null;
61+let onChange = () => {};
62+let sshHosts = new Set();
63+const toolingChecked = {}; // node -> ts du dernier contrôle outillage
64+
65+function expandHome(p) { return p && p.startsWith('~') ? path.join(HOME, p.slice(1)) : p; }
66+function loadSshHosts() {
67+ try {
68+ const txt = fs.readFileSync(path.join(HOME, '.ssh', 'config'), 'utf8');
69+ sshHosts = new Set([...txt.matchAll(/^Host\s+(\S+)\s*$/gm)].map(m => m[1]).filter(h => h !== '*'));
70+ } catch { sshHosts = new Set(); }
71+}
72+
73+// ---------- construction des sites depuis le registre ----------
74+function webProcess(app, processes, cat) {
75+ if (cat.pm2) return cat.pm2;
76+ const biz = processes.filter(p => !/(^|-)ngrok$/.test(p));
77+ return biz.find(p => /-web$/.test(p)) || biz.find(p => p === app) || biz[0] || null;
78+}
79+
80+function buildSites() {
81+ const apps = REG.apps || {};
82+ const names = [
83+ ...Object.keys(KA_CATALOG).filter(a => apps[a]),
84+ ...Object.keys(apps).filter(a => !KA_CATALOG[a] && KA_NAME_RE.test(a)).sort(),
85+ ];
86+ const next = [];
87+ for (const app of names) {
88+ const cat = KA_CATALOG[app] || {};
89+ const e = apps[app];
90+ if (cat.monitor === false && cat.project === false) continue;
91+ const processes = Array.isArray(e.processes) ? e.processes : [];
92+ const launchd = Array.isArray(e.launchd) ? e.launchd : [];
93+ const unit = cat.unit || (processes.length === 0 && launchd.length ? launchd[0] : null);
94+ next.push({
95+ app, label: cat.label || e.label || app,
96+ domain: e.domain || null, node: e.node, ip: e.ip || null, port: e.port || null,
97+ dir: e.dir, repoDir: cat.repoDir || e.dir,
98+ pm2: unit ? null : webProcess(app, processes, cat),
99+ unit, log: cat.log || null,
100+ processes, launchd,
101+ deployed: e.deployed || null, status: e.status || null, updated: e.updated || null,
102+ monitor: cat.monitor !== false, project: cat.project !== false,
103+ });
104+ }
105+ return next;
106+}
107+
108+function diffSites(before, after) {
109+ const ev = [];
110+ const b = new Map(before.map(s => [s.app, s]));
111+ const a = new Map(after.map(s => [s.app, s]));
112+ for (const [app, s] of a) {
113+ const o = b.get(app);
114+ if (!o) ev.push({ kind: 'added', app, label: s.label, to: s.node });
115+ else if (o.node !== s.node) ev.push({ kind: 'moved', app, label: s.label, from: o.node, to: s.node });
116+ else if (o.dir !== s.dir || o.port !== s.port) ev.push({ kind: 'changed', app, label: s.label, node: s.node, dir: s.dir, port: s.port });
117+ }
118+ for (const [app, o] of b) if (!a.has(app)) ev.push({ kind: 'removed', app, label: o.label, from: o.node });
119+ return ev;
120+}
121+
122+function applyRegistry(data, source, mtime) {
123+ const apps = data && data.apps;
124+ if (!apps || typeof apps !== 'object' || !Object.keys(apps).length) throw new Error('registre sans apps');
125+ const before = SITES.slice();
126+ const prevSelf = SELF;
127+ REG = { apps, updated: data.updated || null, gateway: data.gateway || null, fetchedAt: Date.now(), source, error: null, mtime: mtime || Date.now() };
128+ if (apps['admin-ka'] && apps['admin-ka'].node) SELF = apps['admin-ka'].node;
129+ const next = buildSites();
130+ SITES.length = 0; SITES.push(...next);
131+ const events = diffSites(before, next);
132+ if (prevSelf !== SELF) events.push({ kind: 'self', from: prevSelf, to: SELF });
133+ return events;
134+}
135+
136+// ---------- cache local (poussé par mld) ----------
137+function readCache() {
138+ if (!cachePath || !fs.existsSync(cachePath)) return false;
139+ const st = fs.statSync(cachePath);
140+ if (st.mtimeMs === REG.mtime && SITES.length) return false;
141+ const data = JSON.parse(fs.readFileSync(cachePath, 'utf8'));
142+ const events = applyRegistry(data, 'cache:' + cachePath, st.mtimeMs);
143+ return events;
144+}
145+function writeCache(text) {
146+ const tmp = cachePath + '.tmp';
147+ fs.writeFileSync(tmp, text);
148+ fs.renameSync(tmp, cachePath);
149+}
150+
151+// ---------- tirage ssh ----------
152+function sshCat(host) {
153+ return new Promise((resolve) => {
154+ execFile('ssh', ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', host, 'cat ~/dispatch/registry.json'],
155+ { timeout: 25_000, maxBuffer: 8 * 1024 * 1024, env: { ...process.env, PATH: ENV_PATH, HOME } },
156+ (err, stdout, stderr) => resolve(err ? { error: (stderr || err.message).trim().slice(0, 200) } : { text: String(stdout) }));
157+ });
158+}
159+
160+export async function refreshRegistry(reason = 'periodic') {
161+ let lastErr = null;
162+ for (const host of GATEWAY_HOSTS) {
163+ if (!sshHosts.has(host) && host !== 'gitsrv') continue;
164+ const r = await sshCat(host);
165+ if (r.error) { lastErr = `${host}: ${r.error}`; continue; }
166+ let data;
167+ try { data = JSON.parse(r.text); } catch (e) { lastErr = `${host}: JSON invalide (${e.message})`; continue; }
168+ try {
169+ const events = applyRegistry(data, `ssh:${host}`, Date.now());
170+ if (cachePath) { try { writeCache(JSON.stringify(data, null, 2)); REG.mtime = fs.statSync(cachePath).mtimeMs; } catch {} }
171+ finish(events, reason);
172+ return { ok: true, source: `ssh:${host}`, events };
173+ } catch (e) { lastErr = `${host}: ${e.message}`; }
174+ }
175+ REG.error = lastErr || 'aucun hôte passerelle';
176+ return { ok: false, error: REG.error };
177+}
178+
179+function finish(events, reason) {
180+ if (events && events.length) {
181+ for (const ev of events) console.log(`[registre] ${describe(ev)} (${reason})`);
182+ try { onChange(events); } catch {}
183+ }
184+}
185+
186+export function describe(ev) {
187+ switch (ev.kind) {
188+ case 'moved': return `${ev.label} a déménagé ${ev.from} → ${ev.to}`;
189+ case 'added': return `${ev.label} apparaît dans le registre (${ev.to})`;
190+ case 'removed': return `${ev.label} a quitté le registre (était sur ${ev.from})`;
191+ case 'changed': return `${ev.label} : répertoire/port changés sur ${ev.node} (${ev.dir}:${ev.port})`;
192+ case 'self': return `la console admin-ka tourne maintenant sur ${ev.to} (avant ${ev.from})`;
193+ default: return JSON.stringify(ev);
194+ }
195+}
196+
197+// ---------- API pour le reste du backend ----------
198+export function selfNode() { return SELF; }
199+export function getNodes() {
200+ const set = new Set(SITES.filter(s => s.monitor).map(s => s.node));
201+ set.add(SELF);
202+ return [...set].sort();
203+}
204+export function ipOf(node) {
205+ for (const s of SITES) if (s.node === node && s.ip) return s.ip;
206+ for (const e of Object.values(REG.apps || {})) if (e.node === node && e.ip) return e.ip;
207+ return null;
208+}
209+// cible ssh d'un nœud : l'alias de ~/.ssh/config s'il existe (noms Bonjour, résistants au
210+// DHCP), sinon user@IP LAN du registre (un nœud jamais vu par cette config reste joignable).
211+export function sshTarget(node) {
212+ if (sshHosts.has(node)) return node;
213+ const ip = ipOf(node);
214+ return ip ? `${USER}@${ip}` : node;
215+}
216+export function getSites() { return SITES.filter(s => s.monitor); }
217+export function getProjects() {
218+ return SITES.filter(s => s.project).map(s => ({
219+ id: `${s.app}@${s.node}`, app: s.app, name: s.label,
220+ node: s.node === SELF ? null : s.node,
221+ dir: s.node === SELF ? expandHome(s.repoDir) : s.repoDir,
222+ nodeLabel: s.node,
223+ }));
224+}
225+// accepte les anciens ids « app@nœud » même si l'app a déménagé depuis
226+export function findProject(id) {
227+ if (!id) return null;
228+ const app = String(id).split('@')[0];
229+ return getProjects().find(p => p.app === app) || null;
230+}
231+export function getRegistry() {
232+ const age = REG.fetchedAt ? Date.now() - REG.fetchedAt : null;
233+ const missing = Object.keys(KA_CATALOG).filter(a => KA_CATALOG[a].monitor !== false && !(REG.apps || {})[a]);
234+ return {
235+ updated: REG.updated, gateway: REG.gateway, fetchedAt: REG.fetchedAt, source: REG.source, error: REG.error,
236+ ageMs: age, stale: age == null || age > STALE_MS, nApps: Object.keys(REG.apps || {}).length,
237+ self: SELF, nodes: getNodes(), missing, cache: cachePath,
238+ sites: SITES.map(s => ({ app: s.app, label: s.label, node: s.node, ip: s.ip, port: s.port, dir: s.dir, repoDir: s.repoDir, pm2: s.pm2, unit: s.unit, processes: s.processes, status: s.status, deployed: s.deployed, updated: s.updated, domain: s.domain })),
239+ };
240+}
241+
242+// Bloc texte injecté dans les prompts système (upgradeur, orchestrateur) : jamais de table figée.
243+export function topologyText() {
244+ const when = REG.updated ? REG.updated.replace('T', ' ') : 'inconnu';
245+ const lines = SITES.map(s => `- ${s.app} → nœud ${s.node}${s.node === SELF ? ' (local)' : ''}, repo ${s.repoDir}${s.pm2 ? `, pm2 ${s.processes.filter(p => !/ngrok$/.test(p)).join('/')}` : s.unit ? `, launchd ${s.unit}` : ''}${s.port ? `, port ${s.port}` : ''}`);
246+ return `Emplacements ACTUELS des apps (registre mld de la passerelle M1M32, mis à jour ${when} — les apps peuvent être déplacées par \`mld move\`, ne jamais supposer un nœud de mémoire ; pour vérifier : \`ssh gitsrv cat ~/dispatch/registry.json\`) :\n${lines.join('\n')}`;
247+}
248+
249+// ---------- CLAUDE.md de l'orchestrateur multi-sites (régénéré à chaque changement) ----------
250+export function orchestratorDoc() {
251+ const when = REG.updated ? REG.updated.replace('T', ' ') : 'inconnu';
252+ const rows = SITES.map(s => `| ${s.app.padEnd(10)} | ${s.node.padEnd(7)} | ${s.repoDir.padEnd(34)} | ${s.unit ? `launchd ${s.unit}` : s.processes.filter(p => !/ngrok$/.test(p)).join(', ')} |`).join('\n');
253+ const locals = SITES.filter(s => s.node === SELF).map(s => s.app).join(', ') || '(aucune)';
254+ const nodes = getNodes().filter(n => n !== SELF);
255+ return `# 🎛️ Orchestrateur multi-sites Groupe KA
256+
257+Tu es une session Claude Code **orchestratrice** lancée sur le nœud **${SELF}** (là où tourne la console administration-ka.com). Ton rôle : réaliser une tâche qui touche **plusieurs apps du Groupe KA à la fois**, en gardant le **contexte complet** d'un bout à l'autre (contrairement à la diffusion qui lance une session isolée par app). Tu travailles sur les repos de PROD **à distance via SSH** sur le nœud de chaque app.
258+
259+## Table de déploiement (GÉNÉRÉE depuis le registre mld — ne pas éditer à la main)
260+
261+Source de vérité : \`M1M32:~/dispatch/registry.json\` (mis à jour ${when}). Ce fichier est régénéré par admin-ka à chaque changement du registre ; en cas de doute : \`ssh gitsrv cat ~/dispatch/registry.json\` ou \`ssh M1M32 ~/maclustr-dispatch/bin/mld status\`.
262+
263+| App | Nœud | Répertoire de prod | Process |
264+|------------|---------|------------------------------------|---------|
265+${rows}
266+
267+- Les apps sur **${SELF}** (${locals}) : tu es déjà dessus → travaille en local (\`cd <dir>\`), pas de SSH.
268+- Les autres : \`ssh <nœud> 'cd <dir> && …'\`. Alias SSH configurés sur ${SELF} pour ${nodes.join(', ')} (noms Bonjour .local en interne). ⚠️ *.maclustr.io est bloqué entre nœuds ; utilise les alias, ou \`simon-pierreboucher@<IP LAN>\` du registre pour un nœud sans alias.
269+- Pour copier un fichier vers un nœud : \`scp fichier <nœud>:<dir>/\` ou édite via un heredoc en SSH.
270+- Une app peut être **déplacée par \`mld move\`** entre deux de tes tours : relis cette table (elle est réécrite) plutôt que de te fier à ta mémoire.
271+
272+## Méthode d'orchestration (garde le contexte de TOUTE la tâche)
273+
274+1. **Planifie d'abord** : liste les apps concernées et ce qui doit changer sur chacune. Utilise TodoWrite pour suivre l'avancement app par app.
275+2. **Explore avant d'agir** : sur 1-2 apps représentatives, lis le code pour comprendre le pattern commun (les apps KA partagent beaucoup : ka-ui, ecosystem.json, structure back/front). Décide d'une approche cohérente réutilisable partout.
276+3. **Applique app par app** (ou par nœud, en parallèle avec \`&\`+\`wait\` quand c'est sûr). Pour chaque app : édite le repo de prod, build si besoin, \`pm2 restart <process>\`, vérifie le healthcheck/site, puis \`git add/commit/push origin main\` (origin = spbgit).
277+4. **Rapporte un bilan final** : tableau des apps modifiées, commits, et ce qui reste.
278+
279+## Conventions (rappel)
280+- Remote-first, origin = **spbgit** (git perso sur la passerelle M1M32, alias \`gitsrv\` — PAS GitHub). Standards UI KA (header/nav opaques, --z-*, mobile-first, ordre DOM=visuel des fiches). i18n fr-CA.
281+- Ne jamais laisser une app avec des modifs non commitées / non redéployées.
282+- Outils dispo sur chaque nœud (dans ~/.claude/.env) : Serper, Tavily, Scrapfly, Bright Data, Oxylabs, Apify — pour les tâches de connecteurs.
283+- Sudo si besoin : \`echo '492592' | sudo -S <cmd>\`.
284+
285+## Process (colonne « Process » ci-dessus : pm2 sauf mention launchd)
286+Les tunnels \`<app>-ngrok\` ne sont jamais redémarrés (ils portent le domaine). En cas de doute : \`pm2 jlist\` sur le nœud. EXCEPTION KA Guardian (ka2/ka4/ka6) : launchd, pas pm2 — restart via \`launchctl kickstart -k gui/$(id -u)/com.ka2.guardian\` (idem ka4/ka6), health \`curl localhost:8799|8899|8999/health\` ; voir le CLAUDE.md du repo ka-guardian.
287+
288+Travaille avec rigueur : une tâche multi-sites bien orchestrée > 13 modifs incohérentes.
289+`;
290+}
291+export function writeOrchestratorDoc(file) {
292+ try {
293+ const next = orchestratorDoc();
294+ let cur = null;
295+ try { cur = fs.readFileSync(file, 'utf8'); } catch {}
296+ if (cur === next) return false;
297+ fs.mkdirSync(path.dirname(file), { recursive: true });
298+ fs.writeFileSync(file, next);
299+ console.log(`[registre] ${file} régénéré`);
300+ return true;
301+ } catch (e) { console.error('[registre] CLAUDE.md orchestrateur :', e.message); return false; }
302+}
303+
304+// ---------- outillage des nœuds distants (perm-mcp + CLAUDE.md contextuel) ----------
305+function nodeClaudeMd(node) {
306+ const hosted = SITES.filter(s => s.node === node).map(s => `${s.app} (${s.repoDir}${s.unit ? `, launchd ${s.unit}` : s.processes.length ? `, pm2 ${s.processes.filter(p => !/ngrok$/.test(p)).join('/')}` : ''})`);
307+ return `# ⚠️ TU ES SUR LE NŒUD ${node} DU CLUSTER MACLUSTR (pas le laptop)
308+
309+Tu es Claude Code exécuté **sur ${node}**, lancé à distance depuis administration-ka.com (console sur ${SELF}). Le répertoire courant est un **repo de production déployé sur CE nœud** : c'est la source de vérité (doctrine remote-first Groupe Ka).
310+
311+- Après toute modification : build si nécessaire, \`pm2 restart <app>\`, vérifier le healthcheck, puis \`git add/commit/push origin main\` (origin = gitsrv/spbgit, le git perso sur la passerelle M1M32 — PAS GitHub).
312+- **Où sont les autres apps ?** Le registre mld fait foi, jamais une table en dur : \`ssh gitsrv cat ~/dispatch/registry.json\` (nœud, IP LAN, dir, port, process PM2 de chaque app).
313+- ⚠️ Depuis un nœud, SSH vers *.maclustr.io est BLOQUÉ (ACL Tailscale). Seul l'alias \`gitsrv\` (M1M32) est garanti ; les autres nœuds par leur IP LAN 192.168.2.x du registre, si la clé locale y est autorisée.
314+- Clés API (Anthropic, Serper, Tavily, Scrapfly, Bright Data, Oxylabs, Apify) : \`~/.claude/.env\`, exportées dans tes sessions. Ne jamais installer de paquets globaux : venv / node_modules du repo.
315+- Sudo local : \`echo '492592' | sudo -S <cmd>\`.
316+- Apps hébergées ici d'après le registre (${REG.updated || 'date inconnue'}) : ${hosted.join(' ; ') || '(aucune connue)'}.
317+`;
318+}
319+function sshExec(target, script, timeoutMs = 30_000) {
320+ return new Promise((resolve) => {
321+ execFile('ssh', ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=12', target, script],
322+ { timeout: timeoutMs, maxBuffer: 2 * 1024 * 1024, env: { ...process.env, PATH: ENV_PATH, HOME } },
323+ (err, stdout, stderr) => resolve({ rc: err ? (err.code ?? 1) : 0, out: String(stdout || ''), err: String(stderr || '') }));
324+ });
325+}
326+const toolingInflight = {}; // node -> Promise (évite deux contrôles concurrents au démarrage)
327+export function ensureNodeTooling(node, opts = {}) {
328+ if (toolingInflight[node]) return toolingInflight[node];
329+ const p = _ensureNodeTooling(node, opts).finally(() => { delete toolingInflight[node]; });
330+ toolingInflight[node] = p;
331+ return p;
332+}
333+async function _ensureNodeTooling(node, { force = false } = {}) {
334+ if (node === SELF) return { node, local: true };
335+ if (!force && Date.now() - (toolingChecked[node] || 0) < 6 * 3600_000) return { node, cached: true };
336+ const target = sshTarget(node);
337+ const local = path.join(__dirname, 'perm-mcp.js');
338+ const probe = await sshExec(target, `md5 -q ~/.adminka/perm-mcp.js 2>/dev/null || echo none; test -s ~/.claude/CLAUDE.md && echo claude-md:ok || echo claude-md:none; test -x /opt/homebrew/bin/claude && echo claude:ok || echo claude:none; test -s ~/.claude/.env && echo env:ok || echo env:none`);
339+ if (probe.rc !== 0) return { node, error: `ssh ${target} : ${probe.err.slice(0, 160)}` };
340+ toolingChecked[node] = Date.now();
341+ const lines = probe.out.trim().split('\n');
342+ const result = { node, target, actions: [] };
343+ const localMd5 = await new Promise(r => execFile('md5', ['-q', local], (e, o) => r(e ? null : String(o).trim())));
344+ if (localMd5 && lines[0] !== localMd5) {
345+ const src = fs.readFileSync(local, 'utf8');
346+ const w = await sshExec(target, `mkdir -p ~/.adminka && cat > ~/.adminka/perm-mcp.js <<'__ADMINKA_EOF__'\n${src}\n__ADMINKA_EOF__\necho ok`);
347+ result.actions.push(w.out.includes('ok') ? 'perm-mcp.js installé/mis à jour' : `perm-mcp.js ÉCHEC ${w.err.slice(0, 100)}`);
348+ }
349+ if (lines.includes('claude-md:none')) {
350+ const md = nodeClaudeMd(node);
351+ const w = await sshExec(target, `mkdir -p ~/.claude && cat > ~/.claude/CLAUDE.md <<'__ADMINKA_EOF__'\n${md}\n__ADMINKA_EOF__\necho ok`);
352+ result.actions.push(w.out.includes('ok') ? 'CLAUDE.md contextuel créé' : `CLAUDE.md ÉCHEC ${w.err.slice(0, 100)}`);
353+ }
354+ result.claude = lines.includes('claude:ok');
355+ result.env = lines.includes('env:ok');
356+ if (!result.claude) result.actions.push('⚠ /opt/homebrew/bin/claude ABSENT — les sessions Claude Code y échoueront (npm i -g @anthropic-ai/claude-code)');
357+ if (!result.env) result.actions.push('⚠ ~/.claude/.env ABSENT — copier les clés API');
358+ if (result.actions.length) console.log(`[registre] outillage ${node} : ${result.actions.join(' ; ')}`);
359+ return result;
360+}
361+export async function ensureAllNodesTooling(opts) {
362+ const out = [];
363+ for (const n of getNodes()) out.push(await ensureNodeTooling(n, opts));
364+ return out;
365+}
366+
367+// ---------- démarrage ----------
368+export async function initRegistry({ dataDir, onChange: cb, orchestratorDocPath } = {}) {
369+ cachePath = path.join(dataDir, 'registry.json');
370+ onChange = (events) => {
371+ if (orchestratorDocPath) writeOrchestratorDoc(orchestratorDocPath);
372+ ensureAllNodesTooling().catch(() => {});
373+ cb && cb(events);
374+ };
375+ loadSshHosts();
376+ let events = [];
377+ try { events = readCache() || []; } catch (e) { console.error('[registre] cache illisible :', e.message); }
378+ if (!SITES.length) {
379+ const r = await refreshRegistry('startup');
380+ if (!r.ok) console.error('[registre] INDISPONIBLE au démarrage :', r.error, '— aucun site connu tant que le registre ne répond pas');
381+ } else {
382+ console.log(`[registre] ${SITES.length} apps Ka depuis le cache (registre ${REG.updated}), nœud local ${SELF}`);
383+ refreshRegistry('startup-refresh').catch(() => {});
384+ }
385+ if (orchestratorDocPath) writeOrchestratorDoc(orchestratorDocPath);
386+ ensureAllNodesTooling().catch(() => {});
387+ if (events.length) finish(events, 'startup');
388+ // 1) le push mld modifie data/registry.json → application en quelques secondes
389+ fs.watchFile(cachePath, { interval: WATCH_MS }, () => {
390+ try { const ev = readCache(); if (ev) finish(ev, 'push mld'); } catch (e) { console.error('[registre] cache :', e.message); }
391+ });
392+ // 2) tirage périodique (si un push a raté ou si mld tourne sans abonné)
393+ setInterval(() => { refreshRegistry('periodic').catch(() => {}); }, PULL_MS);
394+ setInterval(loadSshHosts, 30 * 60_000);
395+}
modified server/server.js +89 −55
@@ -10,7 +10,9 @@ import os from 'node:os';
10 import { spawn } from 'node:child_process'; 10 import { spawn } from 'node:child_process';
11 import { fileURLToPath } from 'node:url'; 11 import { fileURLToPath } from 'node:url';
12 import { WebSocketServer } from 'ws'; 12 import { WebSocketServer } from 'ws';
13−import { startMonitor, ecoSummary, ecoSite, ecoIncidents, ecoNodes, getIcon, SITES } from './monitor.js'; 13+import { startMonitor, ecoSummary, ecoSite, ecoIncidents, ecoNodes, getIcon } from './monitor.js';
14+// Topologie vivante : où tourne chaque app Ka = registre mld (M1M32), jamais codé ici.
15+import { initRegistry, refreshRegistry, getRegistry, getSites, getProjects, findProject, selfNode, sshTarget, topologyText, describe as describeRegistryEvent, ensureAllNodesTooling } from './registry.js';
14 import { initAnalytics, recordHit, analyticsSummary, analyticsSeries, analyticsSite, analyticsRealtime, analyticsAnomalies, analyticsInspect, analyticsClasses, analyticsConfig, setAnalyticsConfig, PIXEL } from './analytics.js'; 16 import { initAnalytics, recordHit, analyticsSummary, analyticsSeries, analyticsSite, analyticsRealtime, analyticsAnomalies, analyticsInspect, analyticsClasses, analyticsConfig, setAnalyticsConfig, PIXEL } from './analytics.js';
15 import { initSocial, socialState, socialLog, setAuto, generateDraft, publishDraft, runAutoCycle, cardPath, fbLoginState, generateReelDraft, publishReelDraft, runReelCycle, reelPath, startReelBatch, batchStatus, REEL_BATCH_DEFAULT, getGallery, startGenBatch, genBatchStatus } from './social.js'; 17 import { initSocial, socialState, socialLog, setAuto, generateDraft, publishDraft, runAutoCycle, cardPath, fbLoginState, generateReelDraft, publishReelDraft, runReelCycle, reelPath, startReelBatch, batchStatus, REEL_BATCH_DEFAULT, getGallery, startGenBatch, genBatchStatus } from './social.js';
16 import { execFile } from 'node:child_process'; 18 import { execFile } from 'node:child_process';
@@ -60,7 +62,8 @@ const UPGRADER_MODEL = config.upgraderModel || 'claude-opus-4-8';
60 const KA_KNOWLEDGE = `Tu es l'"upgradeur de prompt" de la console administration-ka.com. Ta seule tâche : réécrire la demande brute de l'utilisateur en un prompt EXCELLENT destiné à Claude Code, qui s'exécute sur le nœud d'une app du Groupe KA. Tu ne réponds jamais à la demande toi-même, tu ne codes pas : tu produis UNIQUEMENT le prompt amélioré, prêt à être envoyé. 62 const KA_KNOWLEDGE = `Tu es l'"upgradeur de prompt" de la console administration-ka.com. Ta seule tâche : réécrire la demande brute de l'utilisateur en un prompt EXCELLENT destiné à Claude Code, qui s'exécute sur le nœud d'une app du Groupe KA. Tu ne réponds jamais à la demande toi-même, tu ne codes pas : tu produis UNIQUEMENT le prompt amélioré, prêt à être envoyé.
61 63
62 # Écosystème Groupe KA (12 apps web, stack Node/React, cluster MacLustr) 64 # Écosystème Groupe KA (12 apps web, stack Node/React, cluster MacLustr)
63−- lou-ka (lou-ka.com) : location immobilière/logements. rent-ka (rent-ka.com) : locations résidentielles Canada hors Québec, site EN. immo-ka (immo-ka.com) : achat/vente immobilier. house-ka (house-ka.com) : maisons à vendre Canada hors Québec, site EN (M4M64b, pm2 house-ka-web). vrai-prix (vrai-prix.com) : comparateur de prix. valoplex : immobilier/valorisation. auto-ka (auto-ka.com) : véhicules. fabri-ka (fabri-ka.com) : fabricants/artisans/friperies. food-ka (food-ka.com) : alimentation/épicerie. resto-ka (resto-ka.com) : restaurants. sorti-ka (sorti-ka.com) : sorties/événements. crea-ka (crea-ka.com) : créateurs de contenu QC (multi-plateforme). job-ka (job-ka.com) : emplois. trouve-ka (trouve-ka.com) : recherche/annuaire. ka-stats (ka-stats.com) : Ka·Stats, l'observatoire de statistiques de l'écosystème — agrège les dashboards /api/stats/dashboard des 12 sites (Node zéro-dépendance, nœud M1M32, pm2 ka-stats + ka-stats-ngrok, dir ~/apps/ka-stats). groupe-ka.com : hub + KA ID (SSO). ka2/ka4/ka6 (ka2.bot/ka4.bot/ka6.bot) : agents gardiens autonomes des connecteurs (KA Guardian — un seul repo Python/FastAPI sur M4M36, process launchd com.kaX.guardian, PAS pm2). Marché cible : Québec (fr-CA). 65+- lou-ka (lou-ka.com) : location immobilière/logements. rent-ka (rent-ka.com) : locations résidentielles Canada hors Québec, site EN. immo-ka (immo-ka.com) : achat/vente immobilier. house-ka (house-ka.com) : maisons à vendre Canada hors Québec, site EN. vrai-prix (vrai-prix.com) : comparateur de prix. auto-ka (auto-ka.com) : véhicules. fabri-ka (fabri-ka.com) : fabricants/artisans/friperies. food-ka (food-ka.com) : alimentation/épicerie. resto-ka (resto-ka.com) : restaurants. sorti-ka (sorti-ka.com) : sorties/événements. crea-ka (crea-ka.com) : créateurs de contenu QC (multi-plateforme). job-ka (job-ka.com) : emplois. trouve-ka (trouve-ka.com) : recherche/annuaire. api-ka (api-ka.com) : API centrale + supervision des connecteurs. ka-stats (ka-stats.com) : Ka·Stats, l'observatoire de statistiques de l'écosystème — agrège les dashboards /api/stats/dashboard des sites (Node zéro-dépendance). groupe-ka.com : hub + KA ID (SSO). ka2/ka4/ka6 (ka2.bot/ka4.bot/ka6.bot) : agents gardiens autonomes des connecteurs (KA Guardian — un seul repo Python/FastAPI, process launchd com.kaX.guardian, PAS pm2). Marché cible : Québec (fr-CA).
66+- EMPLACEMENTS : les nœuds, répertoires et process PM2 réels de chaque app sont fournis en fin de ce message (bloc « Emplacements ACTUELS », issu du registre mld) — ils changent au fil des migrations, ne jamais en citer de mémoire.
64 - Architecture commune : back Node (souvent Express/Fastify), front React/Next ou Vite, données via connecteurs (scrapers) + SQLite/Postgres, PM2 pour les process, ngrok pour les domaines. Beaucoup d'apps ont un backend "sync"/crawler et une couche qualité (published/quarantaine). 67 - Architecture commune : back Node (souvent Express/Fastify), front React/Next ou Vite, données via connecteurs (scrapers) + SQLite/Postgres, PM2 pour les process, ngrok pour les domaines. Beaucoup d'apps ont un backend "sync"/crawler et une couche qualité (published/quarantaine).
65 68
66 # Conventions NON négociables (rappelle-les dans le prompt quand c'est pertinent) 69 # Conventions NON négociables (rappelle-les dans le prompt quand c'est pertinent)
@@ -85,7 +88,7 @@ const KA_KNOWLEDGE = `Tu es l'"upgradeur de prompt" de la console administration
85 88
86 async function upgradePrompt(rawPrompt, project) { 89 async function upgradePrompt(rawPrompt, project) {
87 if (!UPGRADER_KEY) throw new Error('Clé Anthropic non configurée (config.anthropicKey).'); 90 if (!UPGRADER_KEY) throw new Error('Clé Anthropic non configurée (config.anthropicKey).');
88− const ctx = project ? `\n\nContexte : la demande vise le projet "${project.name}" (déployé sur le nœud ${project.node || SELF_NODE}, répertoire ${project.dir}).` : ''; 91+ const ctx = project ? `\n\nContexte : la demande vise le projet "${project.name}" (déployé sur le nœud ${project.node || selfNode()}, répertoire ${project.dir}).` : '';
89 const resp = await fetch('https://api.anthropic.com/v1/messages', { 92 const resp = await fetch('https://api.anthropic.com/v1/messages', {
90 method: 'POST', 93 method: 'POST',
91 headers: { 94 headers: {
@@ -96,7 +99,7 @@ async function upgradePrompt(rawPrompt, project) {
96 body: JSON.stringify({ 99 body: JSON.stringify({
97 model: UPGRADER_MODEL, 100 model: UPGRADER_MODEL,
98 max_tokens: 1500, 101 max_tokens: 1500,
99− system: KA_KNOWLEDGE, 102+ system: KA_KNOWLEDGE + '\n\n# ' + topologyText(),
100 messages: [{ role: 'user', content: `Réécris cette demande en un prompt Claude Code excellent.${ctx}\n\nDemande brute :\n${rawPrompt}` }], 103 messages: [{ role: 'user', content: `Réécris cette demande en un prompt Claude Code excellent.${ctx}\n\nDemande brute :\n${rawPrompt}` }],
101 }), 104 }),
102 }); 105 });
@@ -204,42 +207,39 @@ let prompts = [];
204 try { prompts = JSON.parse(fs.readFileSync(PROMPTS_PATH, 'utf8')); } catch {} 207 try { prompts = JSON.parse(fs.readFileSync(PROMPTS_PATH, 'utf8')); } catch {}
205 function savePrompts() { fs.writeFileSync(PROMPTS_PATH, JSON.stringify(prompts, null, 1)); } 208 function savePrompts() { fs.writeFileSync(PROMPTS_PATH, JSON.stringify(prompts, null, 1)); }
206 209
207−// ---------- projets (12 apps Groupe KA) ---------- 210+// ---------- projets (apps Groupe KA) ----------
208−const SELF_NODE = 'M3U96a'; 211+// La liste des projets Claude Code (app → nœud → repo de prod) est DÉRIVÉE du registre
209−const PROJECTS = [ 212+// mld via registry.js (getProjects/findProject) : quand `mld move` déplace une app, la
210− { id: 'groupe-ka@M3U96b', name: 'Groupe·Ka', node: 'M3U96b', dir: '~/apps/groupe-ka' }, 213+// prochaine session s'ouvre sur le bon nœud. Plus aucune table figée ici.
211− { id: 'lou-ka@M3U96b', name: 'Lou·Ka', node: 'M3U96b', dir: '~/apps/lou-ka' }, 214+// Orchestrateur : une seule session sur le nœud de la console qui pilote toutes les apps
212− { id: 'rent-ka@M4M36', name: 'Rent·Ka', node: 'M4M36', dir: '~/apps/rent-ka' }, 215+// via SSH, en gardant le contexte complet de la tâche (CLAUDE.md régénéré par registry.js).
213− { id: 'immo-ka@M4M64a', name: 'Immo·Ka', node: 'M4M64a', dir: '~/apps/immo-ka' }, 216+const ORCH_DIR = path.join(HOME, 'ka-orchestrator');
214− { id: 'house-ka@M4M64b', name: 'House·Ka', node: 'M4M64b', dir: '~/apps/house-ka' }, 217+const ORCH_PROJECT = { id: 'ORCH', name: '🎛️ Orchestrateur multi-sites', node: null, dir: ORCH_DIR };
215− { id: 'vrai-prix@M3U96a', name: 'Vrai-Prix', node: null, dir: path.join(HOME, 'apps', 'vrai-prix') },
216− { id: 'valoplex@M3U96a', name: 'ValoPlex', node: null, dir: path.join(HOME, 'apps', 'valoplex') },
217− { id: 'auto-ka@M4M64b', name: 'Auto·Ka', node: 'M4M64b', dir: '~/auto-ka' },
218− { id: 'fabri-ka@M4M64a', name: 'Fabri·Ka', node: 'M4M64a', dir: '~/fabri-ka' },
219− { id: 'food-ka@M4M64b', name: 'Food·Ka', node: 'M4M64b', dir: '~/apps/food-ka' },
220− { id: 'resto-ka@M3U96b', name: 'Resto·Ka', node: 'M3U96b', dir: '~/apps/resto-ka' },
221− { id: 'sorti-ka@M3U96a', name: 'Sorti·Ka', node: null, dir: path.join(HOME, 'apps', 'sorti-ka') },
222− { id: 'crea-ka@M3U96b', name: 'Créa·Ka', node: 'M3U96b', dir: '~/apps/crea-ka' },
223− { id: 'job-ka@M3U96a', name: 'Job·Ka', node: null, dir: path.join(HOME, 'apps', 'job-ka') },
224− { id: 'trouve-ka@M2M32', name: 'Trouve·Ka', node: 'M2M32', dir: '~/trouve-ka' },
225− { id: 'ka-stats@M1M32', name: 'Ka·Stats', node: 'M1M32', dir: '~/apps/ka-stats' },
226− // Agents gardiens KA Guardian — 3 sites, 1 seul repo sur M4M36 (launchd, pas pm2)
227− { id: 'ka2@M4M36', name: 'KA·2 Guardian', node: 'M4M36', dir: '~/cluster-projects/ka-guardian' },
228− { id: 'ka4@M4M36', name: 'KA·4 Guardian', node: 'M4M36', dir: '~/cluster-projects/ka-guardian' },
229− { id: 'ka6@M4M36', name: 'KA·6 Guardian', node: 'M4M36', dir: '~/cluster-projects/ka-guardian' },
230−];
231−// Orchestrateur : une seule session sur M3U96a qui pilote les 13 apps via SSH,
232−// en gardant le contexte complet de la tâche.
233−const ORCH_PROJECT = { id: 'ORCH', name: '🎛️ Orchestrateur multi-sites', node: null, dir: path.join(HOME, 'ka-orchestrator') };
234 function listProjects() { 218 function listProjects() {
235 return [ 219 return [
236− { id: ORCH_PROJECT.id, name: ORCH_PROJECT.name, node: SELF_NODE, dir: ORCH_PROJECT.dir }, 220+ { id: ORCH_PROJECT.id, name: ORCH_PROJECT.name, node: selfNode(), dir: ORCH_PROJECT.dir },
237− ...PROJECTS.map(p => ({ id: p.id, name: p.name, node: p.node || SELF_NODE, dir: p.dir })), 221+ ...getProjects().map(p => ({ id: p.id, name: p.name, node: p.node || selfNode(), dir: p.dir })),
238 ]; 222 ];
239 } 223 }
240−function findProject(id) { 224+function resolveProject(id) {
241 if (id === 'ORCH') return ORCH_PROJECT; 225 if (id === 'ORCH') return ORCH_PROJECT;
242− return PROJECTS.find(p => p.id === id) || null; 226+ return findProject(id);
227+}
228+// Une conversation garde node/dir de sa création ; si l'app a déménagé depuis (registre),
229+// on la rattache au nœud actuel et on repart d'une session Claude vierge (la session
230+// `--resume` vivait sur l'ancien nœud).
231+function realignChat(chat) {
232+ if (!chat || chat.projectId === 'ORCH') return false;
233+ const proj = resolveProject(chat.projectId);
234+ if (!proj) return false;
235+ if (proj.node === chat.node && proj.dir === chat.dir) return false;
236+ const from = chat.node || selfNode(), to = proj.node || selfNode();
237+ chat.projectId = proj.id; chat.node = proj.node; chat.dir = proj.dir;
238+ chat.claudeSessionId = null; chat.contextTokens = 0;
239+ saveChats();
240+ emitAndLog(chat.id, { type: 'notice', text: `📦 ${chat.projectName} a déménagé ${from} → ${to} (registre mld) : cette conversation suit l'app sur son nouveau nœud — nouvelle session Claude Code (l'ancien contexte vivait sur ${from}).` });
241+ audit({ kind: 'chat_realigned', chatId: chat.id, project: chat.projectId, from, to });
242+ return true;
243 } 243 }
244 244
245 // ---------- exécution Claude Code ---------- 245 // ---------- exécution Claude Code ----------
@@ -252,7 +252,7 @@ const ALLOWED_READONLY = 'Read,Glob,Grep,LS,WebFetch,WebSearch,TodoWrite,Noteboo
252 252
253 const SYSTEM_APPEND = `RÈGLE OBLIGATOIRE (console administration-ka) : si tu as modifié des fichiers du projet pendant ce tour, tu DOIS avant de conclure : (1) rebuild si le projet a une étape de build ; (2) redéployer — pm2 restart <app> (ou le mécanisme en place) — et VÉRIFIER que l'app répond (healthcheck/port/site) ; (3) committer et pousser : git add + git commit + git push origin main — le remote origin est spbgit (git perso git.spboucher.ai), PAS GitHub. Ne termine jamais en laissant des modifications non commitées ou non redéployées. Si aucun fichier n'a été modifié, ignore cette règle.`; 253 const SYSTEM_APPEND = `RÈGLE OBLIGATOIRE (console administration-ka) : si tu as modifié des fichiers du projet pendant ce tour, tu DOIS avant de conclure : (1) rebuild si le projet a une étape de build ; (2) redéployer — pm2 restart <app> (ou le mécanisme en place) — et VÉRIFIER que l'app répond (healthcheck/port/site) ; (3) committer et pousser : git add + git commit + git push origin main — le remote origin est spbgit (git perso git.spboucher.ai), PAS GitHub. Ne termine jamais en laissant des modifications non commitées ou non redéployées. Si aucun fichier n'a été modifié, ignore cette règle.`;
254 254
255−const ORCH_SYSTEM = `Tu es la session ORCHESTRATRICE multi-sites du Groupe KA, lancée sur M3U96a. La tâche demandée peut concerner PLUSIEURS apps KA à la fois — tu gardes le contexte complet du début à la fin. Le fichier CLAUDE.md de ton répertoire (~/ka-orchestrator) contient la table app→nœud→répertoire de prod et la méthode : lis-le et suis-le. Tu modifies les repos de PROD à distance via SSH (ssh <nœud> 'cd <dir> && …') ; les apps sur M3U96a se font en local. Planifie avec TodoWrite, explore le pattern commun avant d'agir, applique app par app, et pour CHAQUE app modifiée : build si besoin + pm2 restart + healthcheck + git add/commit/push origin main (spbgit). Termine par un bilan (apps modifiées, commits, restes). Ne laisse aucune app en état incohérent.`; 255+const orchSystem = () => `Tu es la session ORCHESTRATRICE multi-sites du Groupe KA, lancée sur ${selfNode()}. La tâche demandée peut concerner PLUSIEURS apps KA à la fois — tu gardes le contexte complet du début à la fin. Le fichier CLAUDE.md de ton répertoire (~/ka-orchestrator) contient la table app→nœud→répertoire de prod (GÉNÉRÉE depuis le registre mld, donc à jour) et la méthode : lis-le et suis-le. Tu modifies les repos de PROD à distance via SSH (ssh <nœud> 'cd <dir> && …') ; les apps sur ${selfNode()} se font en local. Planifie avec TodoWrite, explore le pattern commun avant d'agir, applique app par app, et pour CHAQUE app modifiée : build si besoin + pm2 restart + healthcheck + git add/commit/push origin main (spbgit). Termine par un bilan (apps modifiées, commits, restes). Ne laisse aucune app en état incohérent.\n\n${topologyText()}`;
256 256
257 const shq = (s) => `'` + String(s).replace(/'/g, `'\\''`) + `'`; 257 const shq = (s) => `'` + String(s).replace(/'/g, `'\\''`) + `'`;
258 258
@@ -301,7 +301,8 @@ function startClaude(chat, prompt) {
301 const permMode = PERM_MODES.includes(chat.permMode) ? chat.permMode : 'default'; 301 const permMode = PERM_MODES.includes(chat.permMode) ? chat.permMode : 'default';
302 let proc; 302 let proc;
303 303
304− const sysAppend = chat.projectId === 'ORCH' ? ORCH_SYSTEM : SYSTEM_APPEND; 304+ realignChat(chat); // l'app a-t-elle déménagé depuis la dernière fois ? (registre mld)
305+ const sysAppend = chat.projectId === 'ORCH' ? orchSystem() : SYSTEM_APPEND + '\n\n' + topologyText();
305 const baseArgs = ['-p', '--output-format', 'stream-json', '--verbose', '--include-partial-messages', 306 const baseArgs = ['-p', '--output-format', 'stream-json', '--verbose', '--include-partial-messages',
306 '--model', CLI_MODEL[model], '--append-system-prompt', sysAppend]; 307 '--model', CLI_MODEL[model], '--append-system-prompt', sysAppend];
307 if (chat.claudeSessionId) baseArgs.push('--resume', chat.claudeSessionId); 308 if (chat.claudeSessionId) baseArgs.push('--resume', chat.claudeSessionId);
@@ -372,7 +373,7 @@ function startClaude(chat, prompt) {
372 `echo $$ > /tmp/adminka-pid-${permToken}`, 373 `echo $$ > /tmp/adminka-pid-${permToken}`,
373 cmd, 374 cmd,
374 ].join(' && '); 375 ].join(' && ');
375− proc = spawn('ssh', [...sshArgs, chat.node, remoteCmd], { 376+ proc = spawn('ssh', [...sshArgs, sshTarget(chat.node), remoteCmd], {
376 env: { ...process.env, PATH: '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin', HOME }, 377 env: { ...process.env, PATH: '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin', HOME },
377 stdio: ['pipe', 'pipe', 'pipe'], 378 stdio: ['pipe', 'pipe', 'pipe'],
378 }); 379 });
@@ -382,7 +383,7 @@ function startClaude(chat, prompt) {
382 383
383 const state = { proc, permToken, stderr: '', remote: chat.node || null }; 384 const state = { proc, permToken, stderr: '', remote: chat.node || null };
384 running.set(chat.id, state); 385 running.set(chat.id, state);
385− audit({ kind: 'prompt', chatId: chat.id, project: chat.projectId, node: chat.node || SELF_NODE, dir: chat.dir, model, permMode, prompt: prompt.slice(0, 2000) }); 386+ audit({ kind: 'prompt', chatId: chat.id, project: chat.projectId, node: chat.node || selfNode(), dir: chat.dir, model, permMode, prompt: prompt.slice(0, 2000) });
386 emitAndLog(chat.id, { type: 'status', state: 'running' }); 387 emitAndLog(chat.id, { type: 'status', state: 'running' });
387 setChatState(chat, 'running'); 388 setChatState(chat, 'running');
388 389
@@ -455,7 +456,7 @@ function interrupt(chatId) {
455 if (!st) return; 456 if (!st) return;
456 audit({ kind: 'interrupt', chatId }); 457 audit({ kind: 'interrupt', chatId });
457 if (st.remote) { 458 if (st.remote) {
458− spawn('ssh', ['-o', 'BatchMode=yes', st.remote, `kill -TERM $(cat /tmp/adminka-pid-${st.permToken} 2>/dev/null) 2>/dev/null; rm -f /tmp/adminka-pid-${st.permToken}`], { 459+ spawn('ssh', ['-o', 'BatchMode=yes', sshTarget(st.remote), `kill -TERM $(cat /tmp/adminka-pid-${st.permToken} 2>/dev/null) 2>/dev/null; rm -f /tmp/adminka-pid-${st.permToken}`], {
459 env: { ...process.env, HOME }, stdio: 'ignore', 460 env: { ...process.env, HOME }, stdio: 'ignore',
460 }); 461 });
461 } 462 }
@@ -467,8 +468,9 @@ function interrupt(chatId) {
467 function runOnNode(node, script) { 468 function runOnNode(node, script) {
468 return new Promise((resolve) => { 469 return new Promise((resolve) => {
469 const full = `export PATH=/opt/homebrew/bin:/usr/local/bin:$PATH; ${script}`; 470 const full = `export PATH=/opt/homebrew/bin:/usr/local/bin:$PATH; ${script}`;
470− const bin = node === SELF_NODE ? '/bin/bash' : 'ssh'; 471+ const local = node === selfNode();
471− const args = node === SELF_NODE ? ['-c', full] : ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=12', node, full]; 472+ const bin = local ? '/bin/bash' : 'ssh';
473+ const args = local ? ['-c', full] : ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=12', sshTarget(node), full];
472 execFile(bin, args, { timeout: 60_000, maxBuffer: 4 * 1024 * 1024, env: { ...process.env, PATH: '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin', HOME } }, 474 execFile(bin, args, { timeout: 60_000, maxBuffer: 4 * 1024 * 1024, env: { ...process.env, PATH: '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin', HOME } },
473 (err, stdout, stderr) => resolve({ stdout: String(stdout || ''), stderr: String(stderr || ''), error: err ? (stderr || err.message) : null })); 475 (err, stdout, stderr) => resolve({ stdout: String(stdout || ''), stderr: String(stderr || ''), error: err ? (stderr || err.message) : null }));
474 }); 476 });
@@ -583,8 +585,23 @@ const server = http.createServer(async (req, res) => {
583 // --- API authentifiée --- 585 // --- API authentifiée ---
584 if (p.startsWith('/api/')) { 586 if (p.startsWith('/api/')) {
585 if (!getSession(req)) return json(res, 401, { error: 'auth' }); 587 if (!getSession(req)) return json(res, 401, { error: 'auth' });
586− if (p === '/api/me') return json(res, 200, { ok: true, host: os.hostname(), node: SELF_NODE, version: '3.0.0' }); 588+ if (p === '/api/me') {
589+ const r = getRegistry();
590+ return json(res, 200, { ok: true, host: os.hostname(), node: selfNode(), version: '3.2.0',
591+ registry: { updated: r.updated, fetchedAt: r.fetchedAt, source: r.source, stale: r.stale, error: r.error, nApps: r.nApps, sites: r.sites.length, nodes: r.nodes } });
592+ }
587 if (p === '/api/projects') return json(res, 200, { projects: listProjects() }); 593 if (p === '/api/projects') return json(res, 200, { projects: listProjects() });
594+ // --- registre mld (topologie vivante) ---
595+ if (p === '/api/registry' && req.method === 'GET') return json(res, 200, getRegistry());
596+ if (p === '/api/registry/refresh' && req.method === 'POST') {
597+ audit({ kind: 'registry_refresh', ip: clientIp(req) });
598+ const r = await refreshRegistry('manual');
599+ return json(res, r.ok ? 200 : 502, { ...r, registry: getRegistry() });
600+ }
601+ if (p === '/api/registry/tooling' && req.method === 'POST') {
602+ audit({ kind: 'registry_tooling', ip: clientIp(req) });
603+ return json(res, 200, { nodes: await ensureAllNodesTooling({ force: true }) });
604+ }
588 if (p === '/api/chats' && req.method === 'GET') { 605 if (p === '/api/chats' && req.method === 'GET') {
589 const list = [...chats].sort((a, b) => (b.updated || 0) - (a.updated || 0)).slice(0, 100).map(publicChat); 606 const list = [...chats].sort((a, b) => (b.updated || 0) - (a.updated || 0)).slice(0, 100).map(publicChat);
590 return json(res, 200, { chats: list }); 607 return json(res, 200, { chats: list });
@@ -612,7 +629,7 @@ const server = http.createServer(async (req, res) => {
612 id: crypto.randomBytes(6).toString('hex'), 629 id: crypto.randomBytes(6).toString('hex'),
613 title: (String(body.title || '').trim() || text.slice(0, 48)), 630 title: (String(body.title || '').trim() || text.slice(0, 48)),
614 text, 631 text,
615− project: findProject(body.project) ? body.project : null, 632+ project: resolveProject(body.project) ? body.project : null,
616 fav: !!body.fav, uses: 0, 633 fav: !!body.fav, uses: 0,
617 created: Date.now(), updated: Date.now(), 634 created: Date.now(), updated: Date.now(),
618 }; 635 };
@@ -627,7 +644,7 @@ const server = http.createServer(async (req, res) => {
627 let body; try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: 'bad json' }); } 644 let body; try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: 'bad json' }); }
628 if (typeof body.text === 'string' && body.text.trim()) pr.text = body.text.trim(); 645 if (typeof body.text === 'string' && body.text.trim()) pr.text = body.text.trim();
629 if (typeof body.title === 'string') pr.title = body.title.trim() || pr.text.slice(0, 48); 646 if (typeof body.title === 'string') pr.title = body.title.trim() || pr.text.slice(0, 48);
630− if ('project' in body) pr.project = findProject(body.project) ? body.project : null; 647+ if ('project' in body) pr.project = resolveProject(body.project) ? body.project : null;
631 if (typeof body.fav === 'boolean') pr.fav = body.fav; 648 if (typeof body.fav === 'boolean') pr.fav = body.fav;
632 if (body.bumpUse) pr.uses = (pr.uses || 0) + 1; 649 if (body.bumpUse) pr.uses = (pr.uses || 0) + 1;
633 pr.updated = Date.now(); savePrompts(); 650 pr.updated = Date.now(); savePrompts();
@@ -644,7 +661,7 @@ const server = http.createServer(async (req, res) => {
644 const raw = String(body.prompt || '').trim(); 661 const raw = String(body.prompt || '').trim();
645 if (!raw) return json(res, 400, { error: 'prompt vide' }); 662 if (!raw) return json(res, 400, { error: 'prompt vide' });
646 try { 663 try {
647− const project = findProject(body.project); 664+ const project = resolveProject(body.project);
648 const upgraded = await upgradePrompt(raw, project); 665 const upgraded = await upgradePrompt(raw, project);
649 audit({ kind: 'upgrade_prompt', project: body.project, len_in: raw.length, len_out: upgraded.length }); 666 audit({ kind: 'upgrade_prompt', project: body.project, len_in: raw.length, len_out: upgraded.length });
650 return json(res, 200, { upgraded }); 667 return json(res, 200, { upgraded });
@@ -652,10 +669,10 @@ const server = http.createServer(async (req, res) => {
652 return json(res, 502, { error: e.message }); 669 return json(res, 502, { error: e.message });
653 } 670 }
654 } 671 }
655− if (p === '/api/analytics/summary') return json(res, 200, analyticsSummary(SITES)); 672+ if (p === '/api/analytics/summary') return json(res, 200, analyticsSummary(getSites()));
656 if (p.startsWith('/api/analytics/series/')) return json(res, 200, analyticsSeries(p.split('/')[4], 30)); 673 if (p.startsWith('/api/analytics/series/')) return json(res, 200, analyticsSeries(p.split('/')[4], 30));
657 if (p.startsWith('/api/analytics/site/')) return json(res, 200, analyticsSite(p.split('/')[4], Number(url.searchParams.get('days')) || 7)); 674 if (p.startsWith('/api/analytics/site/')) return json(res, 200, analyticsSite(p.split('/')[4], Number(url.searchParams.get('days')) || 7));
658− if (p === '/api/analytics/realtime') return json(res, 200, analyticsRealtime(SITES)); 675+ if (p === '/api/analytics/realtime') return json(res, 200, analyticsRealtime(getSites()));
659 if (p === '/api/analytics/anomalies') return json(res, 200, analyticsAnomalies()); 676 if (p === '/api/analytics/anomalies') return json(res, 200, analyticsAnomalies());
660 if (p === '/api/analytics/inspect') return json(res, 200, analyticsInspect({ ip: url.searchParams.get('ip'), vid: url.searchParams.get('vid'), ua: url.searchParams.get('ua') })); 677 if (p === '/api/analytics/inspect') return json(res, 200, analyticsInspect({ ip: url.searchParams.get('ip'), vid: url.searchParams.get('vid'), ua: url.searchParams.get('ua') }));
661 if (p === '/api/analytics/classes') return json(res, 200, analyticsClasses(Number(url.searchParams.get('ms')) || 864e5)); 678 if (p === '/api/analytics/classes') return json(res, 200, analyticsClasses(Number(url.searchParams.get('ms')) || 864e5));
@@ -677,7 +694,7 @@ const server = http.createServer(async (req, res) => {
677 if (p === '/api/admin/action' && req.method === 'POST') { 694 if (p === '/api/admin/action' && req.method === 'POST') {
678 let body; 695 let body;
679 try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: 'bad json' }); } 696 try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: 'bad json' }); }
680− const site = SITES.find(s => s.app === body.app); 697+ const site = getSites().find(s => s.app === body.app);
681 const action = ['restart', 'stop', 'start'].includes(body.action) ? body.action : null; 698 const action = ['restart', 'stop', 'start'].includes(body.action) ? body.action : null;
682 if (!site || !action) return json(res, 400, { error: 'app ou action invalide' }); 699 if (!site || !action) return json(res, 400, { error: 'app ou action invalide' });
683 audit({ kind: 'admin_action', app: site.app, node: site.node, action, ip: clientIp(req) }); 700 audit({ kind: 'admin_action', app: site.app, node: site.node, action, ip: clientIp(req) });
@@ -696,7 +713,7 @@ const server = http.createServer(async (req, res) => {
696 return json(res, 200, { ok: !out.error, output: (out.stdout || out.error || '').trim().split('\n').pop(), full: (out.stdout || '').slice(-1500) }); 713 return json(res, 200, { ok: !out.error, output: (out.stdout || out.error || '').trim().split('\n').pop(), full: (out.stdout || '').slice(-1500) });
697 } 714 }
698 if (p.startsWith('/api/admin/logs/')) { 715 if (p.startsWith('/api/admin/logs/')) {
699− const site = SITES.find(s => s.app === p.split('/')[4]); 716+ const site = getSites().find(s => s.app === p.split('/')[4]);
700 if (!site) return json(res, 404, { error: 'site inconnu' }); 717 if (!site) return json(res, 404, { error: 'site inconnu' });
701 const out = site.log 718 const out = site.log
702 ? await runOnNode(site.node, `tail -70 ${site.log} 2>&1`) 719 ? await runOnNode(site.node, `tail -70 ${site.log} 2>&1`)
@@ -704,9 +721,9 @@ const server = http.createServer(async (req, res) => {
704 return json(res, 200, { logs: out.stdout || out.error || '(vide)' }); 721 return json(res, 200, { logs: out.stdout || out.error || '(vide)' });
705 } 722 }
706 if (p.startsWith('/api/admin/commits/')) { 723 if (p.startsWith('/api/admin/commits/')) {
707− const site = SITES.find(s => s.app === p.split('/')[4]); 724+ const site = getSites().find(s => s.app === p.split('/')[4]);
708 if (!site) return json(res, 404, { error: 'site inconnu' }); 725 if (!site) return json(res, 404, { error: 'site inconnu' });
709− const out = await runOnNode(site.node, `cd ${site.dir} && echo "=== derniers commits ===" && git log --oneline -8 && echo && echo "=== statut ===" && (git status --short | head -10; [ -z "$(git status --short)" ] && echo "propre ✓")`); 726+ const out = await runOnNode(site.node, `cd ${site.repoDir || site.dir} && echo "=== derniers commits ===" && git log --oneline -8 && echo && echo "=== statut ===" && (git status --short | head -10; [ -z "$(git status --short)" ] && echo "propre ✓")`);
710 return json(res, 200, { text: out.stdout || out.error || '(vide)' }); 727 return json(res, 200, { text: out.stdout || out.error || '(vide)' });
711 } 728 }
712 // --- Social : publication automatique + a la demande --- 729 // --- Social : publication automatique + a la demande ---
@@ -882,7 +899,8 @@ function handleWsMessage(ws, msg) {
882 if (!prompt) return; 899 if (!prompt) return;
883 let chat = msg.chatId ? getChat(msg.chatId) : null; 900 let chat = msg.chatId ? getChat(msg.chatId) : null;
884 if (!chat) { 901 if (!chat) {
885− const proj = findProject(msg.project) || PROJECTS[0]; 902+ const proj = resolveProject(msg.project) || getProjects()[0];
903+ if (!proj) { ws.send(JSON.stringify({ type: 'error', message: 'Aucun projet connu : le registre mld est indisponible (voir Réglages → Registre).' })); return; }
886 chat = { 904 chat = {
887 id: crypto.randomBytes(8).toString('hex'), 905 id: crypto.randomBytes(8).toString('hex'),
888 title: prompt.slice(0, 60), 906 title: prompt.slice(0, 60),
@@ -914,7 +932,8 @@ function handleWsMessage(ws, msg) {
914 const permMode = PERM_MODES.includes(msg.permMode) ? msg.permMode : 'default'; 932 const permMode = PERM_MODES.includes(msg.permMode) ? msg.permMode : 'default';
915 const created = []; 933 const created = [];
916 const stamp = new Date().toLocaleString('fr-CA', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); 934 const stamp = new Date().toLocaleString('fr-CA', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
917− for (const proj of PROJECTS) { 935+ const projectsNow = getProjects();
936+ for (const proj of projectsNow) {
918 const chat = { 937 const chat = {
919 id: crypto.randomBytes(8).toString('hex'), 938 id: crypto.randomBytes(8).toString('hex'),
920 title: '🌐 ' + prompt.slice(0, 46), 939 title: '🌐 ' + prompt.slice(0, 46),
@@ -933,7 +952,7 @@ function handleWsMessage(ws, msg) {
933 } 952 }
934 saveChats(); 953 saveChats();
935 audit({ kind: 'start_all', count: created.length, model, permMode, prompt: prompt.slice(0, 500) }); 954 audit({ kind: 'start_all', count: created.length, model, permMode, prompt: prompt.slice(0, 500) });
936− ws.send(JSON.stringify({ type: 'batch_created', count: created.length, total: PROJECTS.length })); 955+ ws.send(JSON.stringify({ type: 'batch_created', count: created.length, total: projectsNow.length }));
937 return; 956 return;
938 } 957 }
939 if (msg.type === 'set_opts') { 958 if (msg.type === 'set_opts') {
@@ -991,6 +1010,21 @@ function handleWsMessage(ws, msg) {
991 if (msg.type === 'eco_unsub') { ws.eco = false; return; } 1010 if (msg.type === 'eco_unsub') { ws.eco = false; return; }
992 } 1011 }
993 1012
1013+// ---------- registre mld (topologie vivante) — AVANT le monitoring, qui en dépend ----------
1014+await initRegistry({
1015+ dataDir: DATA_DIR,
1016+ orchestratorDocPath: path.join(ORCH_DIR, 'CLAUDE.md'),
1017+ onChange: (events) => {
1018+ for (const ev of events) {
1019+ audit({ kind: 'registry_' + ev.kind, app: ev.app || null, from: ev.from || null, to: ev.to || null });
1020+ // alerte visible partout (bandeau) : une app a bougé, la console suit
1021+ broadcastAll({ type: 'eco_alert', kind: 'registry', site: ev.app || 'admin-ka', reason: describeRegistryEvent(ev), ts: Date.now() });
1022+ }
1023+ // les conversations rattachées à une app déplacée seront réalignées à leur prochain tour (realignChat)
1024+ broadcastAll({ type: 'projects', projects: listProjects() });
1025+ },
1026+});
1027+
994 // ---------- monitoring Écosystème ---------- 1028 // ---------- monitoring Écosystème ----------
995 startMonitor({ 1029 startMonitor({
996 dataDir: DATA_DIR, 1030 dataDir: DATA_DIR,
997 1031
Menu

Theme