@mmmbuto/nexuscrew 0.9.34 → 0.9.35

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.
@@ -11,8 +11,8 @@
11
11
  <meta name="apple-mobile-web-app-title" content="NexusCrew" />
12
12
  <link rel="manifest" href="/manifest.json" />
13
13
  <title>NexusCrew</title>
14
- <script type="module" crossorigin src="/assets/index-5eCvm3HF.js"></script>
15
- <link rel="stylesheet" crossorigin href="/assets/index-DqCfZFGi.css">
14
+ <script type="module" crossorigin src="/assets/index-cShz4eZG.js"></script>
15
+ <link rel="stylesheet" crossorigin href="/assets/index-XusWm2UG.css">
16
16
  </head>
17
17
  <body>
18
18
  <div id="root"></div>
@@ -1 +1 @@
1
- {"version":"0.9.34"}
1
+ {"version":"0.9.35"}
package/lib/mcp/tools.js CHANGED
@@ -252,7 +252,7 @@ async function readVlDirectory(ctx) {
252
252
  const localId = String(config && config.instanceId || '');
253
253
  if (!NODE_ID_RE.test(localId)) throw new Error('instanceId locale non disponibile');
254
254
  const owners = [{ instanceId: localId, route: [], label: 'Local', stale: false },
255
- ...topologyOwners(topology).filter((owner) => !owner.stale && owner.instanceId !== localId)];
255
+ ...topologyOwners(topology).filter((owner) => owner.instanceId !== localId)];
256
256
  const nodes = [];
257
257
  const availableOwners = [];
258
258
  const unavailable = [];
@@ -455,7 +455,9 @@ const TOOLS = [
455
455
  if (!NODE_ID_RE.test(localNodeId)) throw new Error('instanceId locale non disponibile');
456
456
  if (!localDecks || !Array.isArray(localDecks.decks)) throw new Error('risposta deck non valida');
457
457
 
458
- const remotes = topologyOwners(topology).filter((owner) => !owner.stale && owner.instanceId !== localNodeId);
458
+ // Gli owner stale restano interrogabili con la loro route (best-effort):
459
+ // un ramo lento degrada a stale, non sparisce dalla directory dei deck.
460
+ const remotes = topologyOwners(topology).filter((owner) => owner.instanceId !== localNodeId);
459
461
  const viewerById = new Map([[localNodeId, []], ...remotes.map((owner) => [owner.instanceId, owner.route])]);
460
462
  const sources = [{
461
463
  owner: { instanceId: localNodeId, route: [], label: 'Local' },
@@ -19,6 +19,24 @@ const { COOKIE_NAME: PANEL_COOKIE_NAME } = require('./panel-auth.js');
19
19
  const MAX_HOPS = 4;
20
20
  const ROUTE_DELIMITER = '_';
21
21
  const TOPOLOGY_PEER_TIMEOUT_MS = 1500;
22
+ // Budget per hop della topologia federata: quando la richiesta arriva da un
23
+ // peer, il fan-out verso i propri figli si tiene questo margine sotto il
24
+ // budget del chiamante, cosi' la risposta rientra sempre in chi chiama.
25
+ const HOP_MARGIN_MS = 500;
26
+ const CHILD_BUDGET_FLOOR_MS = 300;
27
+ // Ultimo snapshot riuscito per figlio diretto (cache in memoria, chiave
28
+ // store+figlio): serve i rami lenti come stale invece di farli sparire dal
29
+ // ramo del chiamante.
30
+ const CHILD_SNAPSHOT_MAX = 64;
31
+ const childSnapshotsByNodeId = new Map();
32
+
33
+ function recordChildSnapshot(nodesPath, nodeId, nodes) {
34
+ childSnapshotsByNodeId.set(`${nodesPath}::${nodeId}`, { ts: Date.now(), nodes });
35
+ if (childSnapshotsByNodeId.size <= CHILD_SNAPSHOT_MAX) return;
36
+ let oldest = null;
37
+ for (const [id, snap] of childSnapshotsByNodeId) if (!oldest || snap.ts < oldest[1].ts) oldest = [id, snap];
38
+ if (oldest) childSnapshotsByNodeId.delete(oldest[0]);
39
+ }
22
40
  // Finestra di attesa del reverse channel su Share ON: limitata per non
23
41
  // diventare un retry storm, ma sufficiente al bind subito dopo un pairing.
24
42
  const SHARE_HEALTH_ATTEMPTS = 10;
@@ -959,7 +977,7 @@ async function fetchPeerTopology({ node, ttl, seen, fetchImpl, timeoutMs }) {
959
977
  }, budget);
960
978
  });
961
979
  const request = (async () => {
962
- const u = `http://127.0.0.1:${node.localPort}/federation/topology?ttl=${ttl - 1}&visited=${encodeURIComponent([...seen].join(','))}`;
980
+ const u = `http://127.0.0.1:${node.localPort}/federation/topology?ttl=${ttl - 1}&visited=${encodeURIComponent([...seen].join(','))}&budget=${budget}`;
963
981
  const response = await fetchImpl(u, {
964
982
  headers: { authorization: `Bearer ${node.token}` }, signal: ctrl.signal,
965
983
  });
@@ -969,14 +987,49 @@ async function fetchPeerTopology({ node, ttl, seen, fetchImpl, timeoutMs }) {
969
987
  finally { clearTimeout(timer); }
970
988
  }
971
989
 
990
+ // Validazione dei nodi figli riportati da un peer (stesso gate del loop
991
+ // storico): identita' strict, route valida e aciclica verso il padre, nome
992
+ // coerente con l'ultimo hop. Le entry valide sono relative al figlio.
993
+ // Lo stato stale/lastSeen che il salto precedente aveva gia' calcolato
994
+ // VIAGGIA con il figlio (ben formato: stale===true, lastSeen intero): senza
995
+ // questa propagazione l'ultimo hop ricompatterebbe tutto come fresco e il
996
+ // badge veritiero non arriverebbe mai a chi guarda.
997
+ function validatedChildNodes(n, seen, ttl, body) {
998
+ if (!body || body.instanceId !== n.nodeId || !Array.isArray(body.nodes)) return null;
999
+ const out = [];
1000
+ for (const child of body.nodes) {
1001
+ if (!child || !store.NODE_ID_RE.test(child.instanceId) || child.instanceId === n.nodeId || seen.has(child.instanceId)
1002
+ || !store.NODE_NAME_RE.test(child.name)
1003
+ || !Array.isArray(child.route) || child.route.length < 1 || child.route.length >= ttl
1004
+ || child.route.some((x) => !store.NODE_NAME_RE.test(x))
1005
+ || new Set(child.route).size !== child.route.length
1006
+ || child.name !== child.route[child.route.length - 1]
1007
+ || child.route.includes(n.name)) continue;
1008
+ out.push({
1009
+ instanceId: child.instanceId, name: child.name, route: [...child.route],
1010
+ label: store.validLabel(child.label) ? child.label.trim() : '',
1011
+ ...(child.stale === true
1012
+ ? { stale: true, ...(Number.isSafeInteger(child.lastSeen) ? { lastSeen: child.lastSeen } : {}) }
1013
+ : {}),
1014
+ });
1015
+ }
1016
+ return out;
1017
+ }
1018
+
972
1019
  async function collectTopologyDetailed({
973
1020
  nodesPath, ingress = null, ttl = MAX_HOPS, visited = [], fetchImpl = fetch,
974
- timeoutMs = TOPOLOGY_PEER_TIMEOUT_MS,
1021
+ timeoutMs = TOPOLOGY_PEER_TIMEOUT_MS, childSnapshots = true,
975
1022
  }) {
976
1023
  const st = store.loadStore(nodesPath);
977
1024
  if (!st) return { instanceId: null, nodes: [], authoritative: [] };
978
1025
  const seen = new Set(visited.filter((x) => store.NODE_ID_RE.test(x)));
979
1026
  seen.add(st.nodeId);
1027
+ const budget = Number.isFinite(timeoutMs)
1028
+ ? Math.max(1, Math.min(30000, Math.floor(timeoutMs))) : TOPOLOGY_PEER_TIMEOUT_MS;
1029
+ // Richiesta da un peer: il fan-out verso i figli usa un budget inferiore a
1030
+ // quello del chiamante, cosi' questa risposta rientra sempre nel suo budget.
1031
+ const childBudget = ingress
1032
+ ? Math.max(CHILD_BUDGET_FLOOR_MS, budget - HOP_MARGIN_MS) : budget;
980
1033
  const out = [];
981
1034
  const authoritative = [];
982
1035
  const probes = [];
@@ -995,22 +1048,17 @@ async function collectTopologyDetailed({
995
1048
  label: store.validLabel(n.label) ? n.label.trim() : '',
996
1049
  });
997
1050
  if (ttl <= 1 || !n.token) continue;
998
- probes.push({ n, pending: fetchPeerTopology({ node: n, ttl, seen, fetchImpl, timeoutMs }) });
1051
+ probes.push({ n, pending: fetchPeerTopology({ node: n, ttl, seen, fetchImpl, timeoutMs: childBudget }) });
999
1052
  }
1000
1053
  const results = await Promise.all(probes.map(async ({ n, pending }) => {
1001
1054
  try { return { n, ...(await pending) }; } catch (_) { return { n, response: null, body: null }; }
1002
1055
  }));
1003
1056
  for (const { n, response, body } of results) {
1004
- if (!response || !response.ok || body.instanceId !== n.nodeId || !Array.isArray(body.nodes)) continue;
1005
- authoritative.push(n.name);
1006
- for (const child of body.nodes) {
1007
- if (!child || !store.NODE_ID_RE.test(child.instanceId) || child.instanceId === n.nodeId || seen.has(child.instanceId)
1008
- || !store.NODE_NAME_RE.test(child.name)
1009
- || !Array.isArray(child.route) || child.route.length < 1 || child.route.length >= ttl
1010
- || child.route.some((x) => !store.NODE_NAME_RE.test(x))
1011
- || new Set(child.route).size !== child.route.length
1012
- || child.name !== child.route[child.route.length - 1]
1013
- || child.route.includes(n.name)) continue;
1057
+ const children = response && response.ok ? validatedChildNodes(n, seen, ttl, body) : null;
1058
+ if (children) {
1059
+ authoritative.push(n.name);
1060
+ recordChildSnapshot(nodesPath, n.nodeId, children);
1061
+ for (const child of children) {
1014
1062
  // La label di un nodo in transito e' testo AUTO-DICHIARATO da un altro
1015
1063
  // nodo: si accetta solo se rispetta la stessa forma di una label locale,
1016
1064
  // altrimenti si resta senza nome invece di propagare qualcosa di
@@ -1018,8 +1066,37 @@ async function collectTopologyDetailed({
1018
1066
  out.push({
1019
1067
  instanceId: child.instanceId, name: child.name,
1020
1068
  route: [n.name, ...child.route], direct: false,
1021
- label: store.validLabel(child.label) ? child.label.trim() : '',
1069
+ label: child.label,
1070
+ ...(child.stale === true
1071
+ ? { stale: true, ...(Number.isSafeInteger(child.lastSeen) ? { lastSeen: child.lastSeen } : {}) }
1072
+ : {}),
1022
1073
  });
1074
+ }
1075
+ continue;
1076
+ }
1077
+ // Figlio che non ha risposto in tempo (failure di trasporto: response null):
1078
+ // si serve l'ultimo snapshot riuscito marcando le entry stale con lastSeen,
1079
+ // invece di far sparire l'intero ramo dalla topologia di chi chiama. Una
1080
+ // risposta ARRIVATA ma non valida (binding instanceId sbagliato, errore
1081
+ // HTTP) non resuscita nulla: il ramo resta assente, come prima. (Raccolta
1082
+ // locale esclusa: la sua cache su disco ha gia' questa semantica con il
1083
+ // proprio clock.)
1084
+ const snapshot = childSnapshots && !response
1085
+ ? childSnapshotsByNodeId.get(`${nodesPath}::${n.nodeId}`) : null;
1086
+ if (!snapshot) continue;
1087
+ const lastSeen = Math.floor(snapshot.ts / 1000);
1088
+ for (const child of snapshot.nodes) {
1089
+ if (child.instanceId === n.nodeId || seen.has(child.instanceId)) continue;
1090
+ if (!Array.isArray(child.route) || child.route.length < 1 || child.route.length >= ttl
1091
+ || child.route.includes(n.name)) continue;
1092
+ out.push({
1093
+ instanceId: child.instanceId, name: child.name,
1094
+ route: [n.name, ...child.route], direct: false,
1095
+ label: child.label, stale: true,
1096
+ // il lastSeen piu' veritiero e' quello che il figlio stesso aveva
1097
+ // riportato (se era gia' stale); altrimenti il momento dello snapshot
1098
+ lastSeen: Number.isSafeInteger(child.lastSeen) ? child.lastSeen : lastSeen,
1099
+ });
1023
1100
  }
1024
1101
  }
1025
1102
  const ids = new Set(); const routes = new Set(); const unique = [];
@@ -1037,12 +1114,14 @@ async function collectTopology(opts) {
1037
1114
  }
1038
1115
 
1039
1116
  // Local roster: live topology plus a credential-free cache of previously seen
1040
- // transitive nodes. Stale entries are never returned by the peer endpoint.
1117
+ // transitive nodes. Slow children fall back to the last good in-memory
1118
+ // snapshot (stale:true); the peer endpoint returns them too, so a branch that
1119
+ // misses one poll degrades to stale instead of disappearing.
1041
1120
  async function collectLocalTopology({
1042
1121
  nodesPath, cachePath, fetchImpl = fetch, now = Math.floor(Date.now() / 1000),
1043
1122
  timeoutMs = TOPOLOGY_PEER_TIMEOUT_MS,
1044
1123
  }) {
1045
- const live = await collectTopologyDetailed({ nodesPath, fetchImpl, timeoutMs });
1124
+ const live = await collectTopologyDetailed({ nodesPath, fetchImpl, timeoutMs, childSnapshots: false });
1046
1125
  const st = store.loadStore(nodesPath);
1047
1126
  const directNames = new Set(((st && st.nodes) || [])
1048
1127
  .filter((n) => n.direction !== 'inbound' || n.shared === true)
@@ -1063,11 +1142,14 @@ async function collectLocalTopology({
1063
1142
  for (const n of live.nodes) {
1064
1143
  // La cache conserva anche la label: senza, un nodo che torna stale perde il
1065
1144
  // nome e ricompare come slug, cioe' esattamente il difetto che si voleva
1066
- // togliere. Resta un dato riferito, non una prova.
1145
+ // togliere. Resta un dato riferito, non una prova. Per i nodi arrivati
1146
+ // gia' stale dal salto precedente, il lastSeen ORIGINALE e' il dato vero:
1147
+ // "ora" mentirebbe su quando sono stati visti l'ultima volta.
1067
1148
  if (n.route.length > 1) {
1068
1149
  next.set(n.instanceId, {
1069
1150
  instanceId: n.instanceId, name: n.name, route: [...n.route],
1070
- ...(n.label ? { label: n.label } : {}), lastSeen: now,
1151
+ ...(n.label ? { label: n.label } : {}),
1152
+ lastSeen: n.stale === true && Number.isSafeInteger(n.lastSeen) ? n.lastSeen : now,
1071
1153
  });
1072
1154
  }
1073
1155
  }
@@ -1077,7 +1159,13 @@ async function collectLocalTopology({
1077
1159
  try { topologyCache.atomicWriteCache(cacheFile, serialized); } catch (_) {}
1078
1160
  }
1079
1161
 
1080
- const nodes = live.nodes.map((n) => ({ ...n, stale: false, lastSeen: now }));
1162
+ // Solo i nodi visti DIRETTAMENTE vivi prendono stale:false + lastSeen=now:
1163
+ // un nodo che il peer aveva gia' marcato stale resta stale col suo lastSeen.
1164
+ const nodes = live.nodes.map((n) => ({
1165
+ ...n,
1166
+ stale: n.stale === true,
1167
+ lastSeen: n.stale === true && Number.isSafeInteger(n.lastSeen) ? n.lastSeen : now,
1168
+ }));
1081
1169
  for (const n of cached) {
1082
1170
  if (!liveIds.has(n.instanceId) && !liveRoutes.has(n.route.join('/'))) nodes.push({ ...n, direct: false, stale: true });
1083
1171
  }
@@ -1302,7 +1390,12 @@ function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly
1302
1390
  r.get('/topology', async (req, res) => {
1303
1391
  const ttl = Math.max(0, Math.min(MAX_HOPS, Number(req.query.ttl) || MAX_HOPS));
1304
1392
  const visited = String(req.query.visited || '').split(',');
1305
- res.json(await collectTopology({ nodesPath, ingress: req.peer, ttl, visited, fetchImpl }));
1393
+ // Il budget residuo del chiamante viaggia nella richiesta: il fan-out
1394
+ // interno se ne tiene un margine (budget per hop) per rispondere in tempo.
1395
+ const rawBudget = Number(req.query.budget);
1396
+ const timeoutMs = Number.isFinite(rawBudget)
1397
+ ? Math.max(CHILD_BUDGET_FLOOR_MS, Math.min(30000, Math.floor(rawBudget))) : TOPOLOGY_PEER_TIMEOUT_MS;
1398
+ res.json(await collectTopology({ nodesPath, ingress: req.peer, ttl, visited, fetchImpl, timeoutMs }));
1306
1399
  });
1307
1400
  // A connected client publishes itself through the SAME SSH connection by
1308
1401
  // toggling its optional -R channel. The hub records that intent only after a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmmbuto/nexuscrew",
3
- "version": "0.9.34",
3
+ "version": "0.9.35",
4
4
  "description": "Faithful browser tmux client — attach to live sessions over a real PTY, localhost-only, mobile-easy",
5
5
  "main": "lib/server.js",
6
6
  "bin": {