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>
7 changed files +591 −89
modified
.gitignore
+1 −0
@@ -9,3 +9,4 @@ data/chats.json | ||
| 9 | 9 | data/sessions.json |
| 10 | 10 | data/mcp-*.json |
| 11 | 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 | 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 | 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/perm-mcp.js` — serveur MCP stdio pour `--permission-prompt-tool mcp__adminka__approve` ; déployé aussi sur M3U96b/M4M64a/M4M64b/M2M32 dans `~/.adminka/`. | |
| 27 | +- `server/monitor.js` — collecteur Écosystème (node:sqlite intégré, aucune dépendance native) ; sites et nœuds = `registry.js`. | |
| 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 | 29 | - `public/` — frontend une page, 4 onglets, aucun framework. |
| 29 | 30 | |
| 30 | 31 | ## Pièges connus (ne pas re-découvrir) |
@@ -46,7 +47,7 @@ tail -f ~/apps/admin-ka/data/backend.err.log | ||
| 46 | 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 | 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 | 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 | 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 | 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 | 1038 | else banner('good', `🟢 ${esc(msg.site)} est de retour en ligne`, () => switchView('eco')); |
| 1028 | 1039 | navBadge('eco'); |
@@ -1865,6 +1876,40 @@ | ||
| 1865 | 1876 | }; |
| 1866 | 1877 | |
| 1867 | 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 | 1913 | async function init() { |
| 1869 | 1914 | const me = await fetch('/api/me'); |
| 1870 | 1915 | if (!me.ok) { |
@@ -1873,21 +1918,34 @@ | ||
| 1873 | 1918 | return; |
| 1874 | 1919 | } |
| 1875 | 1920 | const info = await me.json(); |
| 1876 | − $('setHost').textContent = (info.node || 'M3U96a') + ':3300'; | |
| 1921 | + $('setHost').textContent = (info.node || '?') + ':3300'; | |
| 1877 | 1922 | $('login').classList.add('hidden'); |
| 1878 | 1923 | $('app').classList.remove('hidden'); |
| 1879 | 1924 | const pr = await fetch('/api/projects'); |
| 1880 | 1925 | const { projects } = await pr.json(); |
| 1881 | − projectSel.innerHTML = ''; | |
| 1882 | − const allOpt = document.createElement('option'); | |
| 1883 | − allOpt.value = 'ALL'; allOpt.textContent = '🌐 Tous les sites KA (' + projects.length + ')'; | |
| 1884 | − projectSel.appendChild(allOpt); | |
| 1885 | − for (const p of projects) { | |
| 1886 | − const o = document.createElement('option'); | |
| 1887 | − o.value = p.id; o.textContent = p.name + ' · ' + p.node; | |
| 1888 | − projectSel.appendChild(o); | |
| 1889 | − } | |
| 1890 | − if (settings.projectId) projectSel.value = settings.projectId; | |
| 1926 | + fillProjects(projects); | |
| 1927 | + loadRegistryInfo(); | |
| 1928 | + const rb = $('btnRegRefresh'); | |
| 1929 | + if (rb) rb.onclick = async () => { | |
| 1930 | + rb.disabled = true; rb.textContent = '↻ lecture…'; | |
| 1931 | + try { | |
| 1932 | + const r = await fetch('/api/registry/refresh', { method: 'POST' }); | |
| 1933 | + const d = await r.json(); | |
| 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 || '?')); | |
| 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 | 1949 | projectSel.onchange = () => { |
| 1892 | 1950 | settings.projectId = projectSel.value; saveStore(); refreshChips(); |
| 1893 | 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 | 256 | </div> |
| 257 | 257 | </div> |
| 258 | 258 | <div class="set-card"> |
| 259 | − <div class="set-row"><span>Backend</span><span id="setHost" class="mono">M3U96a:3300</span></div> | |
| 260 | − <div class="set-row"><span>Version</span><span class="mono">3.0.0</span></div> | |
| 259 | + <div class="set-label">Registre des emplacements (mld · passerelle M1M32)</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 | 270 | </div> |
| 262 | 271 | <button id="logoutBtn" class="btn-danger">Se déconnecter</button> |
| 263 | 272 | </section> |
modified
server/monitor.js
+22 −18
@@ -6,9 +6,13 @@ import https from 'node:https'; | ||
| 6 | 6 | import path from 'node:path'; |
| 7 | 7 | import { execFile } from 'node:child_process'; |
| 8 | 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 | −export const SITES = [ | |
| 11 | +// Les sites surveillés et leurs nœuds viennent du REGISTRE mld (registry.js) — | |
| 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 | 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 | 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 | 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 | 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']; | |
| 35 | −const SELF_NODE = 'M3U96a'; | |
| 38 | +void _SITES_LEGACY_2026_08; // conservé comme trace, jamais utilisé | |
| 36 | 39 | const CHECK_MS = 45_000; |
| 37 | 40 | const SWEEP_MS = 5 * 60_000; |
| 38 | 41 | const SLOW_MS = 4000; // seuil "lent" (▲) |
@@ -80,7 +83,7 @@ async function fetchIconFor(site) { | ||
| 80 | 83 | } |
| 81 | 84 | } |
| 82 | 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 | 87 | emit({ type: 'eco', kind: 'icons', ts: Date.now() }); |
| 85 | 88 | } |
| 86 | 89 | export function getIcon(app) { return ICONS[app] || null; } |
@@ -130,7 +133,7 @@ function checkOne(site) { | ||
| 130 | 133 | } |
| 131 | 134 | |
| 132 | 135 | async function checkAll() { |
| 133 | − const results = await Promise.all(SITES.map(checkOne)); | |
| 136 | + const results = await Promise.all(getSites().map(checkOne)); | |
| 134 | 137 | const ins = db.prepare('INSERT INTO checks(site,ts,ms,code,ok,err,bytes) VALUES(?,?,?,?,?,?,?)'); |
| 135 | 138 | for (const r of results) { |
| 136 | 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 | 163 | // ---------- sweep des nœuds (charge, RAM, disque, PM2, dernier commit) ---------- |
| 161 | 164 | function sweepNode(node) { |
| 162 | 165 | return new Promise((resolve) => { |
| 163 | − const gitCmds = SITES.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('; '); | |
| 166 | + const gitCmds = getSites().filter(s => s.node === node) | |
| 167 | + .map(s => `echo "GIT ${s.app} $(git -C ${s.repoDir || s.dir} log -1 --format=%ct 2>/dev/null || echo 0)"`).join('; '); | |
| 165 | 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 | 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 | 171 | const script = `export PATH=/opt/homebrew/bin:/usr/local/bin:$PATH |
| 169 | 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 | 177 | echo "PM2JSON $(pm2 jlist 2>/dev/null | tail -1)" |
| 175 | 178 | ${lchCmds} |
| 176 | 179 | ${gitCmds}`; |
| 177 | − const args = node === SELF_NODE | |
| 180 | + const local = node === selfNode(); | |
| 181 | + const args = local | |
| 178 | 182 | ? ['-c', script] |
| 179 | − : ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=12', node, script]; | |
| 180 | − const bin = node === SELF_NODE ? '/bin/bash' : 'ssh'; | |
| 183 | + : ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=12', sshTarget(node), script]; | |
| 184 | + const bin = local ? '/bin/bash' : 'ssh'; | |
| 181 | 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 | 186 | if (err && !stdout) return resolve(null); |
| 183 | 187 | try { parseSweep(node, stdout); } catch {} |
@@ -210,7 +214,7 @@ function parseSweep(node, out) { | ||
| 210 | 214 | lch[m[1]] = { pid: m[2], cpu: Number(m[3] || 0), rss: Number(m[4] || 0) }; |
| 211 | 215 | } |
| 212 | 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 | 218 | if (site.unit) { |
| 215 | 219 | const l = lch[site.app]; |
| 216 | 220 | const up = l && l.pid !== '-' && /^\d+$/.test(l.pid); |
@@ -231,7 +235,7 @@ function parseSweep(node, out) { | ||
| 231 | 235 | } |
| 232 | 236 | |
| 233 | 237 | async function sweepNodes() { |
| 234 | − await Promise.all(NODES.map(sweepNode)); | |
| 238 | + await Promise.all(getNodes().map(sweepNode)); | |
| 235 | 239 | emit({ type: 'eco', kind: 'sweep', ts: Date.now() }); |
| 236 | 240 | } |
| 237 | 241 | |
@@ -298,7 +302,7 @@ function incidentStats(site) { | ||
| 298 | 302 | |
| 299 | 303 | export function ecoSummary() { |
| 300 | 304 | const out = []; |
| 301 | − for (const s of SITES) { | |
| 305 | + for (const s of getSites()) { | |
| 302 | 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 | 307 | const spark = db.prepare('SELECT ts,ms,ok FROM checks WHERE site=? ORDER BY ts DESC LIMIT 40').all(s.app).reverse(); |
| 304 | 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 | 352 | export function ecoSite(app) { |
| 349 | − const s = SITES.find(x => x.app === app); | |
| 353 | + const s = getSites().find(x => x.app === app); | |
| 350 | 354 | if (!s) return null; |
| 351 | 355 | const since = Date.now() - 24 * 3600_000; |
| 352 | 356 | // ~200 seaux sur 24 h pour le graphique |
@@ -384,10 +388,10 @@ export function ecoIncidents() { | ||
| 384 | 388 | |
| 385 | 389 | export function ecoNodes() { |
| 386 | 390 | const out = []; |
| 387 | − for (const node of NODES) { | |
| 391 | + for (const node of getNodes()) { | |
| 388 | 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 | 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 | 395 | const p = db.prepare('SELECT cpu,mem,status,restarts FROM appstats WHERE app=? ORDER BY ts DESC LIMIT 1').get(s.app); |
| 392 | 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 | 10 | import { spawn } from 'node:child_process'; |
| 11 | 11 | import { fileURLToPath } from 'node:url'; |
| 12 | 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 | 16 | import { initAnalytics, recordHit, analyticsSummary, analyticsSeries, analyticsSite, analyticsRealtime, analyticsAnomalies, analyticsInspect, analyticsClasses, analyticsConfig, setAnalyticsConfig, PIXEL } from './analytics.js'; |
| 15 | 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 | 18 | import { execFile } from 'node:child_process'; |
@@ -60,7 +62,8 @@ const UPGRADER_MODEL = config.upgraderModel || 'claude-opus-4-8'; | ||
| 60 | 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 | 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 | 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 | 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 | 89 | async function upgradePrompt(rawPrompt, project) { |
| 87 | 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 | 92 | const resp = await fetch('https://api.anthropic.com/v1/messages', { |
| 90 | 93 | method: 'POST', |
| 91 | 94 | headers: { |
@@ -96,7 +99,7 @@ async function upgradePrompt(rawPrompt, project) { | ||
| 96 | 99 | body: JSON.stringify({ |
| 97 | 100 | model: UPGRADER_MODEL, |
| 98 | 101 | max_tokens: 1500, |
| 99 | − system: KA_KNOWLEDGE, | |
| 102 | + system: KA_KNOWLEDGE + '\n\n# ' + topologyText(), | |
| 100 | 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 | 207 | try { prompts = JSON.parse(fs.readFileSync(PROMPTS_PATH, 'utf8')); } catch {} |
| 205 | 208 | function savePrompts() { fs.writeFileSync(PROMPTS_PATH, JSON.stringify(prompts, null, 1)); } |
| 206 | 209 | |
| 207 | −// ---------- projets (12 apps Groupe KA) ---------- | |
| 208 | −const SELF_NODE = 'M3U96a'; | |
| 209 | −const PROJECTS = [ | |
| 210 | − { id: 'groupe-ka@M3U96b', name: 'Groupe·Ka', node: 'M3U96b', dir: '~/apps/groupe-ka' }, | |
| 211 | − { id: 'lou-ka@M3U96b', name: 'Lou·Ka', node: 'M3U96b', dir: '~/apps/lou-ka' }, | |
| 212 | − { id: 'rent-ka@M4M36', name: 'Rent·Ka', node: 'M4M36', dir: '~/apps/rent-ka' }, | |
| 213 | − { id: 'immo-ka@M4M64a', name: 'Immo·Ka', node: 'M4M64a', dir: '~/apps/immo-ka' }, | |
| 214 | − { id: 'house-ka@M4M64b', name: 'House·Ka', node: 'M4M64b', dir: '~/apps/house-ka' }, | |
| 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') }; | |
| 210 | +// ---------- projets (apps Groupe KA) ---------- | |
| 211 | +// La liste des projets Claude Code (app → nœud → repo de prod) est DÉRIVÉE du registre | |
| 212 | +// mld via registry.js (getProjects/findProject) : quand `mld move` déplace une app, la | |
| 213 | +// prochaine session s'ouvre sur le bon nœud. Plus aucune table figée ici. | |
| 214 | +// Orchestrateur : une seule session sur le nœud de la console qui pilote toutes les apps | |
| 215 | +// via SSH, en gardant le contexte complet de la tâche (CLAUDE.md régénéré par registry.js). | |
| 216 | +const ORCH_DIR = path.join(HOME, 'ka-orchestrator'); | |
| 217 | +const ORCH_PROJECT = { id: 'ORCH', name: '🎛️ Orchestrateur multi-sites', node: null, dir: ORCH_DIR }; | |
| 234 | 218 | function listProjects() { |
| 235 | 219 | return [ |
| 236 | − { id: ORCH_PROJECT.id, name: ORCH_PROJECT.name, node: SELF_NODE, dir: ORCH_PROJECT.dir }, | |
| 237 | − ...PROJECTS.map(p => ({ id: p.id, name: p.name, node: p.node || SELF_NODE, dir: p.dir })), | |
| 220 | + { id: ORCH_PROJECT.id, name: ORCH_PROJECT.name, node: selfNode(), dir: ORCH_PROJECT.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 | 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 | 245 | // ---------- exécution Claude Code ---------- |
@@ -252,7 +252,7 @@ const ALLOWED_READONLY = 'Read,Glob,Grep,LS,WebFetch,WebSearch,TodoWrite,Noteboo | ||
| 252 | 252 | |
| 253 | 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 | 257 | const shq = (s) => `'` + String(s).replace(/'/g, `'\\''`) + `'`; |
| 258 | 258 | |
@@ -301,7 +301,8 @@ function startClaude(chat, prompt) { | ||
| 301 | 301 | const permMode = PERM_MODES.includes(chat.permMode) ? chat.permMode : 'default'; |
| 302 | 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 | 306 | const baseArgs = ['-p', '--output-format', 'stream-json', '--verbose', '--include-partial-messages', |
| 306 | 307 | '--model', CLI_MODEL[model], '--append-system-prompt', sysAppend]; |
| 307 | 308 | if (chat.claudeSessionId) baseArgs.push('--resume', chat.claudeSessionId); |
@@ -372,7 +373,7 @@ function startClaude(chat, prompt) { | ||
| 372 | 373 | `echo $$ > /tmp/adminka-pid-${permToken}`, |
| 373 | 374 | cmd, |
| 374 | 375 | ].join(' && '); |
| 375 | − proc = spawn('ssh', [...sshArgs, chat.node, remoteCmd], { | |
| 376 | + proc = spawn('ssh', [...sshArgs, sshTarget(chat.node), remoteCmd], { | |
| 376 | 377 | env: { ...process.env, PATH: '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin', HOME }, |
| 377 | 378 | stdio: ['pipe', 'pipe', 'pipe'], |
| 378 | 379 | }); |
@@ -382,7 +383,7 @@ function startClaude(chat, prompt) { | ||
| 382 | 383 | |
| 383 | 384 | const state = { proc, permToken, stderr: '', remote: chat.node || null }; |
| 384 | 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 | 387 | emitAndLog(chat.id, { type: 'status', state: 'running' }); |
| 387 | 388 | setChatState(chat, 'running'); |
| 388 | 389 | |
@@ -455,7 +456,7 @@ function interrupt(chatId) { | ||
| 455 | 456 | if (!st) return; |
| 456 | 457 | audit({ kind: 'interrupt', chatId }); |
| 457 | 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 | 460 | env: { ...process.env, HOME }, stdio: 'ignore', |
| 460 | 461 | }); |
| 461 | 462 | } |
@@ -467,8 +468,9 @@ function interrupt(chatId) { | ||
| 467 | 468 | function runOnNode(node, script) { |
| 468 | 469 | return new Promise((resolve) => { |
| 469 | 470 | const full = `export PATH=/opt/homebrew/bin:/usr/local/bin:$PATH; ${script}`; |
| 470 | − const bin = node === SELF_NODE ? '/bin/bash' : 'ssh'; | |
| 471 | − const args = node === SELF_NODE ? ['-c', full] : ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=12', node, full]; | |
| 471 | + const local = node === selfNode(); | |
| 472 | + const bin = local ? '/bin/bash' : 'ssh'; | |
| 473 | + const args = local ? ['-c', full] : ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=12', sshTarget(node), full]; | |
| 472 | 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 | 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 | 585 | // --- API authentifiée --- |
| 584 | 586 | if (p.startsWith('/api/')) { |
| 585 | 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 | 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 | 605 | if (p === '/api/chats' && req.method === 'GET') { |
| 589 | 606 | const list = [...chats].sort((a, b) => (b.updated || 0) - (a.updated || 0)).slice(0, 100).map(publicChat); |
| 590 | 607 | return json(res, 200, { chats: list }); |
@@ -612,7 +629,7 @@ const server = http.createServer(async (req, res) => { | ||
| 612 | 629 | id: crypto.randomBytes(6).toString('hex'), |
| 613 | 630 | title: (String(body.title || '').trim() || text.slice(0, 48)), |
| 614 | 631 | text, |
| 615 | − project: findProject(body.project) ? body.project : null, | |
| 632 | + project: resolveProject(body.project) ? body.project : null, | |
| 616 | 633 | fav: !!body.fav, uses: 0, |
| 617 | 634 | created: Date.now(), updated: Date.now(), |
| 618 | 635 | }; |
@@ -627,7 +644,7 @@ const server = http.createServer(async (req, res) => { | ||
| 627 | 644 | let body; try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: 'bad json' }); } |
| 628 | 645 | if (typeof body.text === 'string' && body.text.trim()) pr.text = body.text.trim(); |
| 629 | 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 | 648 | if (typeof body.fav === 'boolean') pr.fav = body.fav; |
| 632 | 649 | if (body.bumpUse) pr.uses = (pr.uses || 0) + 1; |
| 633 | 650 | pr.updated = Date.now(); savePrompts(); |
@@ -644,7 +661,7 @@ const server = http.createServer(async (req, res) => { | ||
| 644 | 661 | const raw = String(body.prompt || '').trim(); |
| 645 | 662 | if (!raw) return json(res, 400, { error: 'prompt vide' }); |
| 646 | 663 | try { |
| 647 | − const project = findProject(body.project); | |
| 664 | + const project = resolveProject(body.project); | |
| 648 | 665 | const upgraded = await upgradePrompt(raw, project); |
| 649 | 666 | audit({ kind: 'upgrade_prompt', project: body.project, len_in: raw.length, len_out: upgraded.length }); |
| 650 | 667 | return json(res, 200, { upgraded }); |
@@ -652,10 +669,10 @@ const server = http.createServer(async (req, res) => { | ||
| 652 | 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 | 673 | if (p.startsWith('/api/analytics/series/')) return json(res, 200, analyticsSeries(p.split('/')[4], 30)); |
| 657 | 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 | 676 | if (p === '/api/analytics/anomalies') return json(res, 200, analyticsAnomalies()); |
| 660 | 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 | 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 | 694 | if (p === '/api/admin/action' && req.method === 'POST') { |
| 678 | 695 | let body; |
| 679 | 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 | 698 | const action = ['restart', 'stop', 'start'].includes(body.action) ? body.action : null; |
| 682 | 699 | if (!site || !action) return json(res, 400, { error: 'app ou action invalide' }); |
| 683 | 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 | 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 | 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 | 717 | if (!site) return json(res, 404, { error: 'site inconnu' }); |
| 701 | 718 | const out = site.log |
| 702 | 719 | ? await runOnNode(site.node, `tail -70 ${site.log} 2>&1`) |
@@ -704,9 +721,9 @@ const server = http.createServer(async (req, res) => { | ||
| 704 | 721 | return json(res, 200, { logs: out.stdout || out.error || '(vide)' }); |
| 705 | 722 | } |
| 706 | 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 | 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 | 727 | return json(res, 200, { text: out.stdout || out.error || '(vide)' }); |
| 711 | 728 | } |
| 712 | 729 | // --- Social : publication automatique + a la demande --- |
@@ -882,7 +899,8 @@ function handleWsMessage(ws, msg) { | ||
| 882 | 899 | if (!prompt) return; |
| 883 | 900 | let chat = msg.chatId ? getChat(msg.chatId) : null; |
| 884 | 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 | 904 | chat = { |
| 887 | 905 | id: crypto.randomBytes(8).toString('hex'), |
| 888 | 906 | title: prompt.slice(0, 60), |
@@ -914,7 +932,8 @@ function handleWsMessage(ws, msg) { | ||
| 914 | 932 | const permMode = PERM_MODES.includes(msg.permMode) ? msg.permMode : 'default'; |
| 915 | 933 | const created = []; |
| 916 | 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 | 937 | const chat = { |
| 919 | 938 | id: crypto.randomBytes(8).toString('hex'), |
| 920 | 939 | title: '🌐 ' + prompt.slice(0, 46), |
@@ -933,7 +952,7 @@ function handleWsMessage(ws, msg) { | ||
| 933 | 952 | } |
| 934 | 953 | saveChats(); |
| 935 | 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 | 956 | return; |
| 938 | 957 | } |
| 939 | 958 | if (msg.type === 'set_opts') { |
@@ -991,6 +1010,21 @@ function handleWsMessage(ws, msg) { | ||
| 991 | 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 | 1028 | // ---------- monitoring Écosystème ---------- |
| 995 | 1029 | startMonitor({ |
| 996 | 1030 | dataDir: DATA_DIR, |
| 997 | 1031 | |