@mmmbuto/nexuscrew 0.8.7 → 0.8.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +77 -20
- package/frontend/dist/assets/index-CbUkgtAz.js +91 -0
- package/frontend/dist/assets/index-ChGJawuv.css +32 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/auth/token.js +1 -1
- package/lib/cli/commands.js +236 -141
- package/lib/cli/doctor.js +17 -14
- package/lib/cli/init.js +2 -2
- package/lib/cli/pidfile.js +3 -2
- package/lib/config.js +6 -0
- package/lib/decks/store.js +5 -2
- package/lib/fleet/builtin.js +69 -5
- package/lib/fleet/routes.js +13 -2
- package/lib/mcp/server.js +161 -0
- package/lib/nodes/commands.js +95 -123
- package/lib/nodes/health.js +33 -14
- package/lib/nodes/peering.js +75 -12
- package/lib/nodes/store.js +30 -20
- package/lib/nodes/tunnel-supervisor.js +29 -9
- package/lib/nodes/tunnel.js +217 -72
- package/lib/proxy/federation.js +81 -9
- package/lib/server.js +47 -32
- package/lib/settings/routes.js +247 -92
- package/lib/update/core.js +157 -0
- package/lib/update/manager.js +245 -0
- package/lib/update/runner.js +144 -0
- package/package.json +2 -2
- package/skills/nexuscrew-agent/SKILL.md +6 -2
- package/frontend/dist/assets/index-D2TrRtWQ.js +0 -90
- package/frontend/dist/assets/index-DrEuy6A6.css +0 -32
package/lib/decks/store.js
CHANGED
|
@@ -9,6 +9,7 @@ const MAX_DECKS = 24;
|
|
|
9
9
|
const MAX_TILES = 9;
|
|
10
10
|
const NAME_RE = /^[a-z0-9-]{1,32}$/;
|
|
11
11
|
const NODE_RE = /^[a-z0-9-]{1,32}(?:\/[a-z0-9-]{1,32}){0,3}$/;
|
|
12
|
+
const OWNER_ID_RE = /^[a-f0-9]{16,64}$/;
|
|
12
13
|
|
|
13
14
|
function validNodeRoute(node) {
|
|
14
15
|
if (!NODE_RE.test(node)) return false;
|
|
@@ -48,15 +49,17 @@ function parseLayout(raw) {
|
|
|
48
49
|
for (const t of c.tiles) {
|
|
49
50
|
if (!t || typeof t !== 'object' || Array.isArray(t) || !validText(t.session, 128)) return null;
|
|
50
51
|
if (t.node !== undefined && (typeof t.node !== 'string' || !validNodeRoute(t.node))) return null;
|
|
52
|
+
if (t.ownerId !== undefined && (typeof t.ownerId !== 'string' || !OWNER_ID_RE.test(t.ownerId))) return null;
|
|
51
53
|
const height = Number(t.height);
|
|
52
54
|
const fontSize = Number(t.fontSize);
|
|
53
55
|
if (!Number.isFinite(height) || height < 0.2 || height > 100) return null;
|
|
54
56
|
if (!Number.isFinite(fontSize) || fontSize < 9 || fontSize > 24) return null;
|
|
55
|
-
const key = t.node ? `${t.node}:${t.session}` : t.session;
|
|
57
|
+
const key = t.ownerId ? `${t.ownerId}:${t.session}` : t.node ? `${t.node}:${t.session}` : t.session;
|
|
56
58
|
if (seen.has(key) || ++count > MAX_TILES) return null;
|
|
57
59
|
seen.add(key);
|
|
58
60
|
const tile = { session: t.session, height, fontSize };
|
|
59
61
|
if (t.node) tile.node = t.node;
|
|
62
|
+
if (t.ownerId) tile.ownerId = t.ownerId;
|
|
60
63
|
tiles.push(tile);
|
|
61
64
|
}
|
|
62
65
|
columns.push({ width, tiles });
|
|
@@ -118,6 +121,6 @@ function loadOrCreate(p) {
|
|
|
118
121
|
}
|
|
119
122
|
|
|
120
123
|
module.exports = {
|
|
121
|
-
SCHEMA_VERSION, MAX_DECKS, MAX_TILES, NAME_RE,
|
|
124
|
+
SCHEMA_VERSION, MAX_DECKS, MAX_TILES, NAME_RE, OWNER_ID_RE,
|
|
122
125
|
defaultDecksPath, emptyStore, parseLayout, parseStore, loadStore, loadOrCreate, atomicWrite,
|
|
123
126
|
};
|
package/lib/fleet/builtin.js
CHANGED
|
@@ -26,7 +26,7 @@ const path = require('node:path');
|
|
|
26
26
|
const { execFile } = require('node:child_process');
|
|
27
27
|
const {
|
|
28
28
|
parseDefinitions, validateCommandTrust, resolveCwd,
|
|
29
|
-
loadDefinitions, atomicWrite, CAPS, validTmuxName,
|
|
29
|
+
loadDefinitions, atomicWrite, CAPS, MAX_CELLS, validTmuxName,
|
|
30
30
|
} = require('./definitions.js');
|
|
31
31
|
const {
|
|
32
32
|
publicCatalog, describeManaged, resolveManagedEngine, discoverOllamaModels, discoverPiModels,
|
|
@@ -56,7 +56,7 @@ function minimalEnv() {
|
|
|
56
56
|
return env;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
function httpError(status, msg) { const e = new Error(msg); e.status = status; return e; }
|
|
59
|
+
function httpError(status, msg, data = null) { const e = new Error(msg); e.status = status; if (data) e.data = data; return e; }
|
|
60
60
|
|
|
61
61
|
// Marcatore di redazione (design §9h): stderr/stdout dei comandi tmux falliti
|
|
62
62
|
// NON devono mai ecoare i segreti delle definizioni.
|
|
@@ -371,6 +371,17 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
371
371
|
throw httpError(/duplicate/i.test(launch.stderr) ? 409 : 500, why);
|
|
372
372
|
}
|
|
373
373
|
|
|
374
|
+
// `tmux new-session -d` can return 0 even when the launched CLI exits a
|
|
375
|
+
// moment later (missing login, bad model, incompatible provider). Without
|
|
376
|
+
// this readiness gate the PWA reported success and then showed nothing.
|
|
377
|
+
// Always verify liveness, including cells without a system prompt.
|
|
378
|
+
const readyMs = cfg.launchReadyMs != null ? cfg.launchReadyMs : 500;
|
|
379
|
+
const alive = await waitAlive(tmuxBin, cell.tmuxSession, { env: minimalEnv(), readyMs });
|
|
380
|
+
if (!alive) {
|
|
381
|
+
cache = { ...cache, at: 0 };
|
|
382
|
+
throw httpError(500, `client ${launchEngine.command} terminato subito: verifica login, provider, modello e argomenti dell'engine`);
|
|
383
|
+
}
|
|
384
|
+
|
|
374
385
|
// (6) prompt send-keys: bracketed-paste best-effort, target = pane id esatto
|
|
375
386
|
// (fallback al nome sessione con match esatto '=' se tmux non ha stampato l'id).
|
|
376
387
|
let prompt = null;
|
|
@@ -379,7 +390,7 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
379
390
|
const target = paneId.startsWith('%') ? paneId : `=${cell.tmuxSession}`;
|
|
380
391
|
prompt = await injectPrompt(tmuxBin, cell.tmuxSession, cell.prompt, {
|
|
381
392
|
env: minimalEnv(),
|
|
382
|
-
readyMs: cfg.sendKeysReadyMs != null ? cfg.sendKeysReadyMs :
|
|
393
|
+
readyMs: cfg.sendKeysReadyMs != null ? cfg.sendKeysReadyMs : readyMs,
|
|
383
394
|
target,
|
|
384
395
|
engine: launchEngine, cell, // per la redazione del reason se paste-buffer fallisce (§9h)
|
|
385
396
|
});
|
|
@@ -541,6 +552,59 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
541
552
|
const sessions = await refreshSessions();
|
|
542
553
|
return { ok: true, active: sessions.has(findCell(defs, id).tmuxSession) };
|
|
543
554
|
}
|
|
555
|
+
|
|
556
|
+
// Ripristino selettivo atomico da backup PWA. Il body contiene SOLO campi
|
|
557
|
+
// cella allowlisted (mai engine.env/provider secrets). Tutte le celle vengono
|
|
558
|
+
// validate insieme da atomicWrite: un errore lascia il file precedente intatto.
|
|
559
|
+
async function restoreCells(cells) {
|
|
560
|
+
if (readonly()) throw httpError(403, 'READONLY: restore-cells bloccato');
|
|
561
|
+
if (!Array.isArray(cells) || cells.length < 1 || cells.length > MAX_CELLS) {
|
|
562
|
+
throw httpError(400, `cells deve contenere 1..${MAX_CELLS} definizioni`);
|
|
563
|
+
}
|
|
564
|
+
const allowed = new Set(['id', 'cwd', 'engine', 'boot', 'model', 'models', 'permissionPolicies', 'prompt']);
|
|
565
|
+
const seen = new Set();
|
|
566
|
+
for (const cell of cells) {
|
|
567
|
+
if (!cell || typeof cell !== 'object' || Array.isArray(cell)) throw httpError(400, 'definizione cell non valida');
|
|
568
|
+
for (const key of Object.keys(cell)) if (!allowed.has(key)) throw httpError(400, `campo backup non ammesso: ${key}`);
|
|
569
|
+
if (typeof cell.id !== 'string' || seen.has(cell.id)) throw httpError(400, `id cell duplicato o mancante: ${cell.id || '?'}`);
|
|
570
|
+
seen.add(cell.id);
|
|
571
|
+
}
|
|
572
|
+
const defs = reloadDefs();
|
|
573
|
+
const availableEngines = new Set(defs.engines.map((engine) => engine.id));
|
|
574
|
+
const referencedEngines = new Set();
|
|
575
|
+
for (const cell of cells) {
|
|
576
|
+
referencedEngines.add(cell.engine);
|
|
577
|
+
for (const id of Object.keys(cell.models || {})) referencedEngines.add(id);
|
|
578
|
+
for (const id of Object.keys(cell.permissionPolicies || {})) referencedEngines.add(id);
|
|
579
|
+
}
|
|
580
|
+
const missingEngines = [...referencedEngines].filter((id) => !availableEngines.has(id)).sort();
|
|
581
|
+
if (missingEngines.length) {
|
|
582
|
+
throw httpError(400, `engine mancanti sul target: ${missingEngines.join(', ')}`, {
|
|
583
|
+
code: 'missing-engines', missingEngines,
|
|
584
|
+
hint: 'definisci prima gli engine mancanti oppure mappa le celle su engine disponibili',
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
const replaced = cells.filter((cell) => !!findCell(defs, cell.id)).map((cell) => cell.id);
|
|
588
|
+
const created = cells.filter((cell) => !findCell(defs, cell.id)).map((cell) => cell.id);
|
|
589
|
+
await mutate(defs, (draft) => {
|
|
590
|
+
for (const cell of cells) {
|
|
591
|
+
const index = draft.cells.findIndex((current) => current.id === cell.id);
|
|
592
|
+
const next = { ...cell };
|
|
593
|
+
if (index >= 0) {
|
|
594
|
+
// tmuxSession e' identita' runtime immutabile: il backup non la porta.
|
|
595
|
+
next.tmuxSession = draft.cells[index].tmuxSession;
|
|
596
|
+
draft.cells[index] = next;
|
|
597
|
+
} else draft.cells.push(next);
|
|
598
|
+
}
|
|
599
|
+
});
|
|
600
|
+
const sessions = await refreshSessions();
|
|
601
|
+
const saved = reloadDefs();
|
|
602
|
+
return {
|
|
603
|
+
ok: true, count: cells.length, created, replaced,
|
|
604
|
+
needsRestart: cells.map((cell) => findCell(saved, cell.id))
|
|
605
|
+
.filter((cell) => cell && sessions.has(cell.tmuxSession)).map((cell) => cell.id),
|
|
606
|
+
};
|
|
607
|
+
}
|
|
544
608
|
async function removeCell(id, opts = {}) {
|
|
545
609
|
if (readonly()) throw httpError(403, 'READONLY: remove-cell bloccato');
|
|
546
610
|
let defs = reloadDefs();
|
|
@@ -704,7 +768,7 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
704
768
|
}
|
|
705
769
|
|
|
706
770
|
function capabilities() {
|
|
707
|
-
return ['status', 'up', 'down', 'restart', 'engine', 'boot', 'define', 'edit', 'remove', 'import', 'schema', 'definitions'];
|
|
771
|
+
return ['status', 'up', 'down', 'restart', 'engine', 'boot', 'define', 'edit', 'remove', 'import', 'restore', 'schema', 'definitions'];
|
|
708
772
|
}
|
|
709
773
|
|
|
710
774
|
return {
|
|
@@ -712,7 +776,7 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
712
776
|
provider: 'builtin',
|
|
713
777
|
status, up, down, restart, engine: setEngine, boot: setBoot, isCellSession,
|
|
714
778
|
defineEngine, editEngine, removeEngine,
|
|
715
|
-
defineCell, editCell, removeCell, importCell,
|
|
779
|
+
defineCell, editCell, removeCell, importCell, restoreCells,
|
|
716
780
|
schema, definitions, capabilities,
|
|
717
781
|
};
|
|
718
782
|
}
|
package/lib/fleet/routes.js
CHANGED
|
@@ -25,7 +25,9 @@ function requireCap(fleet, cap) {
|
|
|
25
25
|
// async): ogni handler attende la resolve; unavailable → 404 sui comandi.
|
|
26
26
|
function fleetRoutes(fleetP, cfg = {}) {
|
|
27
27
|
const r = express.Router();
|
|
28
|
-
|
|
28
|
+
const smallJson = express.json({ limit: '4kb' });
|
|
29
|
+
const restoreJson = express.json({ limit: '256kb' });
|
|
30
|
+
r.use((req, res, next) => (req.path === '/restore-cells' ? restoreJson : smallJson)(req, res, next));
|
|
29
31
|
|
|
30
32
|
const readonly = () => (cfg.readonlyDefault === true || process.env.NEXUSCREW_READONLY === '1');
|
|
31
33
|
|
|
@@ -38,7 +40,7 @@ function fleetRoutes(fleetP, cfg = {}) {
|
|
|
38
40
|
if (!fleet.available) return res.status(404).json({ error: 'fleet non disponibile' });
|
|
39
41
|
res.json(await fn(fleet, req.body || {}));
|
|
40
42
|
} catch (e) {
|
|
41
|
-
res.status(e.status || 500).json({ error: String(e.message || e) });
|
|
43
|
+
res.status(e.status || 500).json({ error: String(e.message || e), ...(e.data || {}) });
|
|
42
44
|
}
|
|
43
45
|
};
|
|
44
46
|
|
|
@@ -110,10 +112,19 @@ function fleetRoutes(fleetP, cfg = {}) {
|
|
|
110
112
|
r.post('/define-cell', guard((f, b) => { requireCap(f, 'define'); return f.defineCell(b.def); }, { mutate: true }));
|
|
111
113
|
r.post('/edit-cell', guard((f, b) => { requireCap(f, 'edit'); return f.editCell(b.id, b.patch); }, { mutate: true }));
|
|
112
114
|
r.post('/remove-cell', guard((f, b) => { requireCap(f, 'remove'); return f.removeCell(b.id, { stop: b.stop === true }); }, { mutate: true }));
|
|
115
|
+
r.post('/restore-cells', guard((f, b) => { requireCap(f, 'restore'); return f.restoreCells(b.cells); }, { mutate: true }));
|
|
113
116
|
// Riconciliazione sessione tmux esistente (cella Fleet legacy orfana) in cella
|
|
114
117
|
// gestita fleet.json. Capability 'define' (solo builtin): external legacy -> 501.
|
|
115
118
|
r.post('/import-cell', guard(async (f, b) => { requireCap(f, 'import'); return f.importCell(b || {}); }, { mutate: true }));
|
|
116
119
|
|
|
120
|
+
r.use((err, _req, res, _next) => {
|
|
121
|
+
if (err && (err.type === 'entity.too.large' || err.status === 413)) {
|
|
122
|
+
return res.status(413).json({ error: 'body troppo grande', code: 'body-too-large' });
|
|
123
|
+
}
|
|
124
|
+
if (err instanceof SyntaxError) return res.status(400).json({ error: 'JSON non valido', code: 'invalid-json' });
|
|
125
|
+
return res.status(err.status || 400).json({ error: String(err.message || err) });
|
|
126
|
+
});
|
|
127
|
+
|
|
117
128
|
return r;
|
|
118
129
|
}
|
|
119
130
|
|
package/lib/mcp/server.js
CHANGED
|
@@ -71,6 +71,79 @@ function requireSession(session, tool) {
|
|
|
71
71
|
);
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
// Deck layout is stored column-major, while the UI is read row-major. Preserve
|
|
75
|
+
// the visual order so an agent sees the same neighbourhood as the operator.
|
|
76
|
+
function orderedDeckMembers(deck) {
|
|
77
|
+
const columns = deck && deck.layout && Array.isArray(deck.layout.columns)
|
|
78
|
+
? deck.layout.columns : [];
|
|
79
|
+
const rows = Math.max(0, ...columns.map((column) => (
|
|
80
|
+
column && Array.isArray(column.tiles) ? column.tiles.length : 0
|
|
81
|
+
)));
|
|
82
|
+
const out = [];
|
|
83
|
+
for (let row = 0; row < rows; row += 1) {
|
|
84
|
+
for (const column of columns) {
|
|
85
|
+
const tile = column && Array.isArray(column.tiles) ? column.tiles[row] : null;
|
|
86
|
+
if (!tile || typeof tile.session !== 'string' || !tile.session) continue;
|
|
87
|
+
const member = { tmuxSession: tile.session };
|
|
88
|
+
if (typeof tile.node === 'string' && tile.node) member.node = tile.node;
|
|
89
|
+
if (typeof tile.ownerId === 'string' && NODE_ID_RE.test(tile.ownerId)) member.ownerId = tile.ownerId;
|
|
90
|
+
out.push(member);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const NODE_PART_RE = /^[a-z0-9-]{1,32}$/;
|
|
97
|
+
const NODE_ID_RE = /^[a-f0-9]{16,64}$/;
|
|
98
|
+
function fleetStatusPath(node) {
|
|
99
|
+
if (!node) return '/api/fleet/status';
|
|
100
|
+
const parts = String(node).split('/');
|
|
101
|
+
if (!parts.length || parts.some((part) => !NODE_PART_RE.test(part))
|
|
102
|
+
|| new Set(parts).size !== parts.length) return null;
|
|
103
|
+
return `/api/route/${parts.map(encodeURIComponent).join('/')}/_/fleet/status`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function fleetCellsBySession(payload) {
|
|
107
|
+
const out = new Map();
|
|
108
|
+
if (!payload || payload.available !== true || !Array.isArray(payload.cells)) return out;
|
|
109
|
+
for (const cell of payload.cells) {
|
|
110
|
+
if (!cell || typeof cell.tmuxSession !== 'string' || !cell.tmuxSession
|
|
111
|
+
|| typeof cell.cell !== 'string' || !cell.cell) continue;
|
|
112
|
+
out.set(cell.tmuxSession, cell.cell);
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function routePath(route, resource) {
|
|
118
|
+
if (!Array.isArray(route) || !route.length || route.length > 4
|
|
119
|
+
|| route.some((part) => !NODE_PART_RE.test(part)) || new Set(route).size !== route.length) return null;
|
|
120
|
+
return `/api/route/${route.map(encodeURIComponent).join('/')}/_/${resource}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function topologyOwners(payload) {
|
|
124
|
+
const out = [];
|
|
125
|
+
const seen = new Set();
|
|
126
|
+
for (const node of (payload && Array.isArray(payload.nodes) ? payload.nodes : [])) {
|
|
127
|
+
if (!node || !NODE_ID_RE.test(String(node.instanceId || '')) || seen.has(node.instanceId)
|
|
128
|
+
|| !Array.isArray(node.route) || !routePath(node.route, 'decks')) continue;
|
|
129
|
+
seen.add(node.instanceId);
|
|
130
|
+
out.push({
|
|
131
|
+
instanceId: node.instanceId,
|
|
132
|
+
route: [...node.route],
|
|
133
|
+
label: typeof node.label === 'string' && node.label ? node.label : (node.name || node.route.join(' › ')),
|
|
134
|
+
stale: node.stale === true,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function memberOwnerId(member, deckOwner, ownerTopology) {
|
|
141
|
+
if (member.ownerId && NODE_ID_RE.test(member.ownerId)) return member.ownerId;
|
|
142
|
+
if (!member.node) return deckOwner.instanceId;
|
|
143
|
+
const found = ownerTopology.find((node) => Array.isArray(node.route) && node.route.join('/') === member.node);
|
|
144
|
+
return found ? found.instanceId : null;
|
|
145
|
+
}
|
|
146
|
+
|
|
74
147
|
// --- definizione tool (prefisso nc_ anti-collisione) ---------------------------
|
|
75
148
|
// annotations.readOnlyHint:true sui tool che non mutano nulla (§1).
|
|
76
149
|
const TOOLS = [
|
|
@@ -176,6 +249,94 @@ const TOOLS = [
|
|
|
176
249
|
return { sessions, fleet };
|
|
177
250
|
},
|
|
178
251
|
},
|
|
252
|
+
{
|
|
253
|
+
name: 'nc_deck',
|
|
254
|
+
description: 'Contesto read-only del deck della sessione chiamante: restituisce i deck che la contengono e i relativi membri con nome cella Fleet, sessione tmux e route.',
|
|
255
|
+
inputSchema: { type: 'object', properties: {} },
|
|
256
|
+
annotations: { readOnlyHint: true },
|
|
257
|
+
async handler(_args, ctx) {
|
|
258
|
+
const tmuxSession = requireSession(await ctx.session(), 'nc_deck');
|
|
259
|
+
const [config, topology, localDecks] = await Promise.all([
|
|
260
|
+
ctx.api('GET', '/api/config'), ctx.api('GET', '/api/topology'), ctx.api('GET', '/api/decks'),
|
|
261
|
+
]);
|
|
262
|
+
const localNodeId = String(config && config.instanceId || '');
|
|
263
|
+
if (!NODE_ID_RE.test(localNodeId)) throw new Error('instanceId locale non disponibile');
|
|
264
|
+
if (!localDecks || !Array.isArray(localDecks.decks)) throw new Error('risposta deck non valida');
|
|
265
|
+
|
|
266
|
+
const remotes = topologyOwners(topology).filter((owner) => !owner.stale && owner.instanceId !== localNodeId);
|
|
267
|
+
const viewerById = new Map([[localNodeId, []], ...remotes.map((owner) => [owner.instanceId, owner.route])]);
|
|
268
|
+
const sources = [{
|
|
269
|
+
owner: { instanceId: localNodeId, route: [], label: 'Local' },
|
|
270
|
+
ownerTopology: topologyOwners(topology), decks: localDecks.decks,
|
|
271
|
+
}];
|
|
272
|
+
await Promise.all(remotes.map(async (owner) => {
|
|
273
|
+
const decksPath = routePath(owner.route, 'decks');
|
|
274
|
+
const topologyPath = routePath(owner.route, 'topology');
|
|
275
|
+
if (!decksPath || !topologyPath) return;
|
|
276
|
+
try {
|
|
277
|
+
const [deckPayload, ownerTopology] = await Promise.all([
|
|
278
|
+
ctx.api('GET', decksPath), ctx.api('GET', topologyPath).catch(() => ({ nodes: [] })),
|
|
279
|
+
]);
|
|
280
|
+
if (deckPayload && Array.isArray(deckPayload.decks)) {
|
|
281
|
+
sources.push({ owner, ownerTopology: topologyOwners(ownerTopology), decks: deckPayload.decks });
|
|
282
|
+
}
|
|
283
|
+
} catch (_) { /* owner offline/withdrawn: no stale deck disclosure */ }
|
|
284
|
+
}));
|
|
285
|
+
|
|
286
|
+
const decks = [];
|
|
287
|
+
for (const source of sources) {
|
|
288
|
+
for (const deck of source.decks) {
|
|
289
|
+
if (!deck || typeof deck.name !== 'string') continue;
|
|
290
|
+
const members = orderedDeckMembers(deck).map((member) => ({
|
|
291
|
+
...member,
|
|
292
|
+
ownerId: memberOwnerId(member, source.owner, source.ownerTopology),
|
|
293
|
+
}));
|
|
294
|
+
if (!members.some((member) => member.ownerId === localNodeId && member.tmuxSession === tmuxSession)) continue;
|
|
295
|
+
decks.push({
|
|
296
|
+
id: `${source.owner.instanceId}:${deck.name}`,
|
|
297
|
+
name: deck.name,
|
|
298
|
+
owner: { instanceId: source.owner.instanceId, route: source.owner.route.length ? source.owner.route.join('/') : 'local', label: source.owner.label },
|
|
299
|
+
members,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
decks.sort((a, b) => a.owner.route.localeCompare(b.owner.route) || a.name.localeCompare(b.name));
|
|
304
|
+
|
|
305
|
+
if (!decks.length) return { tmuxSession, nodeId: localNodeId, decks: [] };
|
|
306
|
+
|
|
307
|
+
const routes = new Set(['']);
|
|
308
|
+
for (const deck of decks) for (const member of deck.members) {
|
|
309
|
+
const route = member.ownerId && viewerById.has(member.ownerId)
|
|
310
|
+
? viewerById.get(member.ownerId).join('/') : null;
|
|
311
|
+
member.viewerRoute = route;
|
|
312
|
+
if (route !== null) routes.add(route);
|
|
313
|
+
}
|
|
314
|
+
const cellsByRoute = new Map();
|
|
315
|
+
await Promise.all([...routes].map(async (route) => {
|
|
316
|
+
const apiPath = fleetStatusPath(route);
|
|
317
|
+
if (!apiPath) return;
|
|
318
|
+
try { cellsByRoute.set(route, fleetCellsBySession(await ctx.api('GET', apiPath))); }
|
|
319
|
+
catch (_) { cellsByRoute.set(route, new Map()); }
|
|
320
|
+
}));
|
|
321
|
+
|
|
322
|
+
return {
|
|
323
|
+
tmuxSession, nodeId: localNodeId,
|
|
324
|
+
decks: decks.map((deck) => ({
|
|
325
|
+
id: deck.id, name: deck.name, owner: deck.owner,
|
|
326
|
+
members: deck.members.map((member) => {
|
|
327
|
+
const route = member.viewerRoute;
|
|
328
|
+
return {
|
|
329
|
+
cell: route === null ? null : (cellsByRoute.get(route)?.get(member.tmuxSession) || null),
|
|
330
|
+
tmuxSession: member.tmuxSession,
|
|
331
|
+
ownerId: member.ownerId,
|
|
332
|
+
route: route === null ? 'unavailable' : (route || 'local'),
|
|
333
|
+
self: member.ownerId === localNodeId && member.tmuxSession === tmuxSession,
|
|
334
|
+
};
|
|
335
|
+
}),
|
|
336
|
+
})),
|
|
337
|
+
};
|
|
338
|
+
},
|
|
339
|
+
},
|
|
179
340
|
{
|
|
180
341
|
name: 'nc_inbox',
|
|
181
342
|
description: 'Elenca i file ricevuti nell\'inbox NexusCrew di questa sessione (read-only).',
|