@mmmbuto/nexuscrew 0.9.30 → 0.9.32
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/CHANGELOG.md +21 -0
- package/docs/CONFIGURATION.md +39 -0
- package/frontend/dist/assets/index-CH2iAu6y.js +167 -0
- package/frontend/dist/index.html +1 -1
- package/frontend/dist/version.json +1 -1
- package/lib/cli/doctor.js +105 -4
- package/lib/decks/routes.js +11 -1
- package/lib/fleet/builtin.js +96 -11
- package/lib/fleet/definitions.js +11 -3
- package/lib/fleet/endpoint-probe.js +146 -0
- package/lib/fleet/managed.js +175 -11
- package/lib/fleet/model-probe.js +110 -6
- package/lib/fleet/runtime.js +30 -3
- package/lib/notify/ask-answer-service.js +10 -4
- package/lib/notify/asks.js +67 -7
- package/lib/notify/closure-retry.js +201 -0
- package/lib/notify/event-feed-asks-routes.js +4 -0
- package/lib/notify/event-feed-routes.js +8 -0
- package/lib/notify/routes.js +274 -12
- package/lib/proxy/federation.js +16 -3
- package/lib/proxy/resource-acl.js +6 -0
- package/lib/server.js +132 -14
- package/package.json +1 -1
- package/frontend/dist/assets/index-BbcjoZS0.js +0 -167
package/lib/server.js
CHANGED
|
@@ -87,7 +87,8 @@ function sanitizeReplyLabel(raw) {
|
|
|
87
87
|
return String(raw === undefined || raw === null ? 'human' : raw)
|
|
88
88
|
.replace(/[\x00-\x1f\x7f\]]/g, '').slice(0, 32) || 'human';
|
|
89
89
|
}
|
|
90
|
-
const { notifyRoutes } = require('./notify/routes.js');
|
|
90
|
+
const { notifyRoutes, createClosureFanout } = require('./notify/routes.js');
|
|
91
|
+
const { createClosureRetryQueue } = require('./notify/closure-retry.js');
|
|
91
92
|
const { createNpmUpdater } = require('./update/manager.js');
|
|
92
93
|
const { createDiagnostics } = require('./diagnostics/store.js');
|
|
93
94
|
const { createDropCounter } = require('./ws/drop-counter.js');
|
|
@@ -200,11 +201,36 @@ function createServer(opts = {}) {
|
|
|
200
201
|
// federated ask surface. The closure event flows through the notifier
|
|
201
202
|
// wrapper so the feed gets a qualified ask-closed envelope.
|
|
202
203
|
const askReceipts = createAskReceipts({ filePath: path.join(notifyDir, 'ask-receipts.json') });
|
|
204
|
+
// Recapito della chiusura di un ask verso i peer che ne hanno un alias.
|
|
205
|
+
// Assegnato piu' sotto, quando esistono dispatcher e topologia; l'hook lo
|
|
206
|
+
// legge a runtime, quindi la dichiarazione in avanti basta.
|
|
207
|
+
let closureFanout = null;
|
|
208
|
+
// Recapito recuperabile della chiusura (lato owner): la voce si crea dove
|
|
209
|
+
// esiste il fan-out, ma la coda serve prima, sulla lettura degli ask.
|
|
210
|
+
let closureRetry = null;
|
|
203
211
|
const askAnswerService = createAskAnswerService({
|
|
204
212
|
asks: asksStore,
|
|
205
213
|
paste: cfg.pasteSeam || ((session, text) => pasteToSession(cfg.tmuxBin, session, text)),
|
|
206
214
|
receipts: askReceipts,
|
|
207
|
-
|
|
215
|
+
// La chiusura esce da QUI: e' il punto in cui OGNI transizione autorevole
|
|
216
|
+
// passa — risposta e scarto, rotta locale e rotta federata. Emetterla dalle
|
|
217
|
+
// due route locali lasciava fuori proprio il caso normale, la risposta che
|
|
218
|
+
// arriva da un altro nodo. Il fan-out e' lo stesso dell'andata e NON parte
|
|
219
|
+
// per un ask importato: la chiusura di quello la decide il suo owner.
|
|
220
|
+
onClosure: (kind, info) => {
|
|
221
|
+
try { notifier.emitRaw({ type: kind, id: info.askId }); } catch (_) {}
|
|
222
|
+
try {
|
|
223
|
+
const ask = asksStore.get(info.askId);
|
|
224
|
+
if (ask && !ask.originNode && closureFanout) {
|
|
225
|
+
return closureFanout.dispatch({
|
|
226
|
+
askId: info.askId,
|
|
227
|
+
outcome: kind === 'ask-dismissed' ? 'dismissed' : 'answered',
|
|
228
|
+
session: (ask && ask.session) || info.cellSession,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
} catch (_) {}
|
|
232
|
+
return null;
|
|
233
|
+
},
|
|
208
234
|
labelPrefix: sanitizeReplyLabel(cfg && cfg.replyLabel),
|
|
209
235
|
});
|
|
210
236
|
const feedSignatures = new Map(); // peerNodeId -> grant signature last seen
|
|
@@ -1037,6 +1063,86 @@ function createServer(opts = {}) {
|
|
|
1037
1063
|
topology: topology && Array.isArray(topology.nodes) ? topology.nodes : [],
|
|
1038
1064
|
});
|
|
1039
1065
|
};
|
|
1066
|
+
// UN SOLO dispatcher per le domande e per le chiusure: sono la stessa strada
|
|
1067
|
+
// percorsa in due momenti, e il recapito della chiusura deve usare lo stesso
|
|
1068
|
+
// instradamento dell'andata, non una copia con la sua topologia.
|
|
1069
|
+
const askDispatcher = createDispatcher({
|
|
1070
|
+
localNodeId: federatedNodeId,
|
|
1071
|
+
peers: federatedPeers,
|
|
1072
|
+
localPort: () => (server && server.address() ? server.address().port : cfg.port),
|
|
1073
|
+
localToken: () => tokenHolder.value,
|
|
1074
|
+
// 'no-delivery' (-): esito legittimo del target — richiesta accettata
|
|
1075
|
+
// ma nessun canale raggiunto (0 UI, 0 push). Senza questa voce forward()
|
|
1076
|
+
// lo degraderebbe a 'unknown/unreadable-endpoint-result', nascondendo
|
|
1077
|
+
// proprio il silenzio che l'esito esiste per rivelare.
|
|
1078
|
+
statuses: new Set(['delivered', 'no-delivery', 'refused', 'unreachable', 'unknown']),
|
|
1079
|
+
});
|
|
1080
|
+
const askPeerTargets = async () => {
|
|
1081
|
+
const list = await federatedPeers();
|
|
1082
|
+
return (Array.isArray(list) ? list : []).map((p) => p && (p.nodeId || p.instanceId)).filter(Boolean);
|
|
1083
|
+
};
|
|
1084
|
+
// Riconciliazione del RICEVENTE: per ogni alias ancora aperto si chiede
|
|
1085
|
+
// all'owner quali domande sono ancora aperte. Se l'alias non c'e' piu' la'
|
|
1086
|
+
// (l'owner ha risposto o scartato mentre questo nodo era spento), l'alias si
|
|
1087
|
+
// chiude qui. E' la via minima al recapito recuperabile: niente coda
|
|
1088
|
+
// persistente con backoff, un confronto con la fonte autorevole. Sotto il
|
|
1089
|
+
// tetto dello snapshot non si conclude nulla, perche' un'assenza potrebbe
|
|
1090
|
+
// essere un taglio della lista e non una chiusura.
|
|
1091
|
+
const SNAPSHOT_FULL = 100;
|
|
1092
|
+
async function reconcileImportedAsks() {
|
|
1093
|
+
const store = nodesStore.loadStore(nodesPath);
|
|
1094
|
+
if (!store) return;
|
|
1095
|
+
const open = asksStore.list({ open: true }).filter((a) => a.originNode && a.ownerId && a.ownerAskId);
|
|
1096
|
+
if (!open.length) return;
|
|
1097
|
+
const byOwner = new Map();
|
|
1098
|
+
for (const a of open) {
|
|
1099
|
+
if (!byOwner.has(a.ownerId)) byOwner.set(a.ownerId, []);
|
|
1100
|
+
byOwner.get(a.ownerId).push(a);
|
|
1101
|
+
}
|
|
1102
|
+
for (const [ownerId, aliases] of byOwner) {
|
|
1103
|
+
const peer = (store.nodes || []).find((n) => n && n.nodeId === ownerId && n.token && n.localPort);
|
|
1104
|
+
if (!peer) continue;
|
|
1105
|
+
let snap = null;
|
|
1106
|
+
try {
|
|
1107
|
+
const res = await fetch(`http://127.0.0.1:${peer.localPort}/federation/route/_/event-feed/snapshot`, {
|
|
1108
|
+
headers: { authorization: `Bearer ${peer.token}` }, signal: AbortSignal.timeout(3000),
|
|
1109
|
+
});
|
|
1110
|
+
if (!res.ok) continue;
|
|
1111
|
+
snap = await res.json();
|
|
1112
|
+
} catch (_) { continue; }
|
|
1113
|
+
const live = new Set((snap && Array.isArray(snap.asks) ? snap.asks : []).map((x) => String(x.id)));
|
|
1114
|
+
if (live.size >= SNAPSHOT_FULL) continue;
|
|
1115
|
+
for (const alias of aliases) {
|
|
1116
|
+
if (live.has(String(alias.ownerAskId))) continue;
|
|
1117
|
+
try {
|
|
1118
|
+
const closed = asksStore.closeImported({ ownerId, ownerAskId: alias.ownerAskId, outcome: 'dismissed' });
|
|
1119
|
+
if (closed.changed && closed.ask) {
|
|
1120
|
+
try { notifier.deliverOnlyRaw({ type: 'ask-dismissed', id: closed.ask.id, ownerId }); } catch (_) {}
|
|
1121
|
+
}
|
|
1122
|
+
} catch (_) { /* un alias non chiudibile non ferma gli altri */ }
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
// Assegnato ORA che dispatcher e topologia esistono (l'hook lo legge a runtime).
|
|
1127
|
+
closureRetry = createClosureRetryQueue({
|
|
1128
|
+
log: opts.log || console.log,
|
|
1129
|
+
// Il tentativo nasce DALLA coda: non si riaccoda (sarebbe autoalimentata).
|
|
1130
|
+
// `targets` e' l'insieme dei pendenti della voce: il ritentativo va SOLO
|
|
1131
|
+
// verso quelli. Senza, ogni ritentativo rimanderebbe la chiusura anche a chi
|
|
1132
|
+
// l'ha gia' ricevuta — rumore, non danno, ma nemmeno una coda che sa cosa
|
|
1133
|
+
// le manca.
|
|
1134
|
+
run: ({ askId, outcome, session, targets }) => (closureFanout
|
|
1135
|
+
? closureFanout.dispatch({ askId, outcome, session, retryOnFailure: false, targets })
|
|
1136
|
+
: Promise.resolve([])),
|
|
1137
|
+
});
|
|
1138
|
+
closureFanout = createClosureFanout({
|
|
1139
|
+
dispatcher: askDispatcher,
|
|
1140
|
+
peerTargets: askPeerTargets,
|
|
1141
|
+
localNodeId: federatedNodeId,
|
|
1142
|
+
log: opts.log || console.log,
|
|
1143
|
+
retry: closureRetry,
|
|
1144
|
+
});
|
|
1145
|
+
|
|
1040
1146
|
api.use(notifyRoutes({
|
|
1041
1147
|
cfg,
|
|
1042
1148
|
notifier,
|
|
@@ -1056,20 +1162,24 @@ function createServer(opts = {}) {
|
|
|
1056
1162
|
hopSecret: () => hopSecret,
|
|
1057
1163
|
}),
|
|
1058
1164
|
acl: createAudioAcl({ nodesPath }),
|
|
1059
|
-
dispatcher:
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
// 'no-delivery' (-): esito legittimo del target — richiesta accettata
|
|
1065
|
-
// ma nessun canale raggiunto (0 UI, 0 push). Senza questa voce forward()
|
|
1066
|
-
// lo degraderebbe a 'unknown/unreadable-endpoint-result', nascondendo
|
|
1067
|
-
// proprio il silenzio che l'esito esiste per rivelare.
|
|
1068
|
-
statuses: new Set(['delivered', 'no-delivery', 'refused', 'unreachable', 'unknown']),
|
|
1069
|
-
}),
|
|
1165
|
+
dispatcher: askDispatcher,
|
|
1166
|
+
closureFanout,
|
|
1167
|
+
peerTargets: askPeerTargets,
|
|
1168
|
+
reconcileImported: reconcileImportedAsks,
|
|
1169
|
+
closureRetry,
|
|
1070
1170
|
federatedRate: createSpeakRateLimiter(),
|
|
1071
1171
|
answerService: askAnswerService,
|
|
1072
1172
|
receipts: askReceipts,
|
|
1173
|
+
// Elenco dei peer autorizzati per il fan-out di default della domanda
|
|
1174
|
+
// (nessun `target` → tutti i nodi dell'owner). Il
|
|
1175
|
+
// dispatcher resta a target esatto: l'enumerazione la fa la route.
|
|
1176
|
+
peerTargets: async () => {
|
|
1177
|
+
const list = await federatedPeers();
|
|
1178
|
+
return (Array.isArray(list) ? list : [])
|
|
1179
|
+
.map((p) => p && (p.nodeId || p.instanceId))
|
|
1180
|
+
.filter(Boolean);
|
|
1181
|
+
},
|
|
1182
|
+
log: opts.log || console.log,
|
|
1073
1183
|
}));
|
|
1074
1184
|
// Event feed: the closed SSE/JSON surface, mounted inside the
|
|
1075
1185
|
// authenticated API. Its own gate demands a proven federated origin and an
|
|
@@ -1113,7 +1223,13 @@ function createServer(opts = {}) {
|
|
|
1113
1223
|
loadStore: () => nodesStore.loadStore(nodesPath),
|
|
1114
1224
|
log: opts.log || console.log,
|
|
1115
1225
|
});
|
|
1116
|
-
api
|
|
1226
|
+
// Il body va parsato QUI: il router `api` non monta un parser globale, ogni
|
|
1227
|
+
// rotta porta il suo (`express.json`) — vedi /sessions, /notify, /asks.
|
|
1228
|
+
// Senza, `req.body` e' undefined, il relay legge un body vuoto e risponde
|
|
1229
|
+
// 400 «ownerId, askId e text richiesti» a OGNI richiesta: la risposta
|
|
1230
|
+
// federata non ha mai potuto attraversare, e nessun test copriva questa
|
|
1231
|
+
// rotta. Difetto preesistente, trovato scrivendo il test del ritorno federato.
|
|
1232
|
+
api.post('/asks-relay', express.json({ limit: '8kb' }), async (req, res) => {
|
|
1117
1233
|
try {
|
|
1118
1234
|
const b = req.body || {};
|
|
1119
1235
|
if (b.action === 'dismiss') {
|
|
@@ -1500,6 +1616,8 @@ function createServer(opts = {}) {
|
|
|
1500
1616
|
server.on('close', () => {
|
|
1501
1617
|
diagnostics.record('info', 'server', 'SERVER_STOPPED', 'NexusCrew server stopped', { reason: 'close' });
|
|
1502
1618
|
watcher.close(); previews.close(); eventsHub.closeAll(); updater.close(); ptyGrace.close();
|
|
1619
|
+
// Nessun timer di ritentativo sopravvive alla chiusura del server.
|
|
1620
|
+
if (closureRetry) { try { closureRetry.stop(); } catch (_) {} }
|
|
1503
1621
|
for (const timer of reverseWatchers.values()) clearInterval(timer);
|
|
1504
1622
|
reverseWatchers.clear(); rotatableReverse.clear(); void reverseSlotListeners?.closeAll();
|
|
1505
1623
|
// Il pannello non sopravvive al control plane: senza requireToken sopra,
|