@mmmbuto/nexuscrew 0.9.15 → 0.9.16
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/frontend/dist/assets/{index-ToJ5arRI.js → index-CwsGpQwK.js} +29 -29
- package/frontend/dist/index.html +1 -1
- package/frontend/dist/version.json +1 -1
- package/lib/audio/receipt.js +1 -1
- package/lib/cells/routes.js +26 -9
- package/lib/diagnostics/store.js +4 -1
- package/lib/fleet/builtin.js +53 -17
- package/lib/fleet/catalogs/opencode-go.json +3 -3
- package/lib/fleet/definitions.js +29 -10
- package/lib/fleet/managed.js +86 -15
- package/lib/nodes/health.js +7 -11
- package/lib/server.js +13 -1
- package/lib/ws/bridge.js +55 -2
- package/lib/ws/drop-counter.js +37 -0
- package/package.json +1 -1
- package/skills/nexuscrew/SKILL.md +1 -1
package/lib/server.js
CHANGED
|
@@ -71,6 +71,7 @@ const { createNotifier } = require('./notify/notifier.js');
|
|
|
71
71
|
const { notifyRoutes } = require('./notify/routes.js');
|
|
72
72
|
const { createNpmUpdater } = require('./update/manager.js');
|
|
73
73
|
const { createDiagnostics } = require('./diagnostics/store.js');
|
|
74
|
+
const { createDropCounter } = require('./ws/drop-counter.js');
|
|
74
75
|
const { diagnosticsRoutes } = require('./diagnostics/routes.js');
|
|
75
76
|
const vlNodeStore = require('./vl-nodes/store.js');
|
|
76
77
|
const { createBroker: createVlNodeBroker } = require('./vl-nodes/broker.js');
|
|
@@ -146,6 +147,8 @@ function createServer(opts = {}) {
|
|
|
146
147
|
const asksStore = createAsksStore({ dir: notifyDir });
|
|
147
148
|
const notifier = createNotifier({ hub: eventsHub, push: pushSvc });
|
|
148
149
|
const diagnostics = opts.diagnostics || createDiagnostics();
|
|
150
|
+
// Contatore cadute WS (rolling 10 min): alimenta i meta dei log di bridge.
|
|
151
|
+
const wsDropCounter = createDropCounter();
|
|
149
152
|
const updater = opts.updateManager || createNpmUpdater({
|
|
150
153
|
currentVersion: VERSION,
|
|
151
154
|
home: cfg.home || os.homedir(),
|
|
@@ -942,6 +945,7 @@ function createServer(opts = {}) {
|
|
|
942
945
|
}));
|
|
943
946
|
api.use('/cells', cellsRoutes({
|
|
944
947
|
fleetP,
|
|
948
|
+
diagnostics,
|
|
945
949
|
instanceId: () => (nodesStore.loadStore(nodesPath) || {}).nodeId || null,
|
|
946
950
|
submit: opts.cellSubmit || ((session, text, meta) => submitToSession(cfg.tmuxBin, session, text, {
|
|
947
951
|
engine: meta && meta.engine,
|
|
@@ -1139,7 +1143,13 @@ function createServer(opts = {}) {
|
|
|
1139
1143
|
// lato browser, che il client riconnette senza richiedere refresh pagina.
|
|
1140
1144
|
const heartbeat = setInterval(() => {
|
|
1141
1145
|
for (const client of wss.clients) {
|
|
1142
|
-
if (client.isAlive === false) {
|
|
1146
|
+
if (client.isAlive === false) {
|
|
1147
|
+
// Marca il motivo PRIMA del terminate: il close event (1006) nel bridge
|
|
1148
|
+
// lo classifichera' come heartbeat-timeout invece di «drop TCP generico».
|
|
1149
|
+
try { client.__ncCloseReason = 'heartbeat-timeout'; } catch (_) {}
|
|
1150
|
+
try { client.terminate(); } catch (_) {}
|
|
1151
|
+
continue;
|
|
1152
|
+
}
|
|
1143
1153
|
client.isAlive = false;
|
|
1144
1154
|
try { client.ping(); } catch (_) { try { client.terminate(); } catch (_e) {} }
|
|
1145
1155
|
}
|
|
@@ -1234,6 +1244,8 @@ function createServer(opts = {}) {
|
|
|
1234
1244
|
defaults: { readonlyDefault: cfg.readonlyDefault, tmuxBin: cfg.tmuxBin },
|
|
1235
1245
|
ptyGrace,
|
|
1236
1246
|
onAttach: (sess) => attachedWs.set(ws, sess),
|
|
1247
|
+
diagnostics,
|
|
1248
|
+
dropCounter: wsDropCounter,
|
|
1237
1249
|
});
|
|
1238
1250
|
ws.on('close', () => attachedWs.delete(ws));
|
|
1239
1251
|
});
|
package/lib/ws/bridge.js
CHANGED
|
@@ -118,10 +118,24 @@ function sendJson(ws, value) {
|
|
|
118
118
|
}
|
|
119
119
|
|
|
120
120
|
function bindWs(ws, deps) {
|
|
121
|
-
const { openAttach, verifyToken, isValidSession = () => true, runAction = () => false, countClients = () => 0, defaults = {}, onAttach = () => {}, ptyGrace = null } = deps;
|
|
121
|
+
const { openAttach, verifyToken, isValidSession = () => true, runAction = () => false, countClients = () => 0, defaults = {}, onAttach = () => {}, ptyGrace = null, diagnostics = null, dropCounter = null } = deps;
|
|
122
122
|
let record = null;
|
|
123
123
|
let attached = false;
|
|
124
124
|
let session = null;
|
|
125
|
+
let closeLogged = false;
|
|
126
|
+
// UNA sola riga diagnostica per socket: la prima chiusura vince. Le
|
|
127
|
+
// successive (close + error sullo stesso socket) non generano rumore.
|
|
128
|
+
const logClose = (level, event, message, meta = {}) => {
|
|
129
|
+
if (closeLogged) return;
|
|
130
|
+
closeLogged = true;
|
|
131
|
+
if (!diagnostics) return;
|
|
132
|
+
try { diagnostics.record(level, 'ws', event, message, { cell: session || undefined, ...meta }); } catch (_) {}
|
|
133
|
+
};
|
|
134
|
+
const countDrop = () => {
|
|
135
|
+
if (!dropCounter) return {};
|
|
136
|
+
const snap = dropCounter.recordDrop(session);
|
|
137
|
+
return { drops: snap.drops };
|
|
138
|
+
};
|
|
125
139
|
|
|
126
140
|
const detach = () => {
|
|
127
141
|
if (!record || record.ws !== ws) return;
|
|
@@ -144,6 +158,11 @@ function bindWs(ws, deps) {
|
|
|
144
158
|
// fino al close event tiene in piedi un handle senza scopo.
|
|
145
159
|
clearAttachTimer();
|
|
146
160
|
sendJson(ws, { type: 'error', reason });
|
|
161
|
+
// Chiusura INITIATA DAL SERVER: il motivo e' qui, non nel close event.
|
|
162
|
+
// Level warn = sempre visibile (non richiede verbose).
|
|
163
|
+
logClose('warn', 'WS_SERVER_CLOSE', `Server closed socket: ${reason}`, {
|
|
164
|
+
reason: String(reason).slice(0, 48), closeCode: Number(code) || undefined, ...countDrop(),
|
|
165
|
+
});
|
|
147
166
|
try { ws.close(code, reason); } catch (_) {}
|
|
148
167
|
}
|
|
149
168
|
|
|
@@ -197,12 +216,14 @@ function bindWs(ws, deps) {
|
|
|
197
216
|
attached = true;
|
|
198
217
|
session = record.session;
|
|
199
218
|
onAttach(session, ws);
|
|
219
|
+
logReattach();
|
|
200
220
|
sendJson(ws, { type: 'attached', reconnectToken: record.resumeToken });
|
|
201
221
|
return;
|
|
202
222
|
}
|
|
203
223
|
attached = true;
|
|
204
224
|
clearAttachTimer();
|
|
205
225
|
session = msg.session;
|
|
226
|
+
logReattach();
|
|
206
227
|
// Resize default: when nobody else is attached, drive the session size so a
|
|
207
228
|
// small phone gets a usable (non-clipped) view and clean line editing. When a
|
|
208
229
|
// real terminal is already attached, default to ignore-size so we don't shrink
|
|
@@ -250,6 +271,9 @@ function bindWs(ws, deps) {
|
|
|
250
271
|
record.pty.onExit((info) => {
|
|
251
272
|
record.ended = true;
|
|
252
273
|
record.exitCode = info && info.exitCode;
|
|
274
|
+
logClose('notice', 'PTY_EXIT', 'Sessione terminata dal PTY', {
|
|
275
|
+
exitCode: typeof record.exitCode === 'number' ? record.exitCode : undefined,
|
|
276
|
+
});
|
|
253
277
|
if (!record.ws) return;
|
|
254
278
|
sendJson(record.ws, { type: 'exit', code: record.exitCode });
|
|
255
279
|
try { record.ws.close(1000, 'exit'); } catch (_) {}
|
|
@@ -272,8 +296,37 @@ function bindWs(ws, deps) {
|
|
|
272
296
|
else if (msg.type === 'action') runAction(session, msg.name); // nav window/pane server-side
|
|
273
297
|
}
|
|
274
298
|
|
|
299
|
+
// Alla riconnessione la DURATA della caduta e' il gap close->reopen della
|
|
300
|
+
// stessa sessione. Ritorni con gap = notice (sempre visibili); un primo
|
|
301
|
+
// attach senza storia e' debug (solo verbose).
|
|
302
|
+
function logReattach() {
|
|
303
|
+
if (!diagnostics) return;
|
|
304
|
+
if (!dropCounter) return;
|
|
305
|
+
try {
|
|
306
|
+
const reopen = dropCounter.recordReopen(session);
|
|
307
|
+
if (reopen.gapMs != null) {
|
|
308
|
+
diagnostics.record('notice', 'ws', 'WS_REATTACHED', 'Riconnesso dopo una caduta', {
|
|
309
|
+
cell: session, gapMs: reopen.gapMs, drops: reopen.drops,
|
|
310
|
+
});
|
|
311
|
+
} else {
|
|
312
|
+
diagnostics.record('debug', 'ws', 'WS_ATTACHED', 'Attach completato', { cell: session });
|
|
313
|
+
}
|
|
314
|
+
} catch (_) {}
|
|
315
|
+
}
|
|
316
|
+
|
|
275
317
|
ws.on('message', onMessage);
|
|
276
|
-
ws.on('close', () => {
|
|
318
|
+
ws.on('close', (code) => {
|
|
319
|
+
clearAttachTimer(); detach();
|
|
320
|
+
if (closeLogged) return;
|
|
321
|
+
const closeCode = Number(code) || undefined;
|
|
322
|
+
if (ws.__ncCloseReason === 'heartbeat-timeout') {
|
|
323
|
+
logClose('warn', 'WS_HEARTBEAT_DROPPED', 'Heartbeat scaduto: connessione mezzo-aperta terminata', { closeCode, ...countDrop() });
|
|
324
|
+
} else if (closeCode === 1006) {
|
|
325
|
+
logClose('warn', 'WS_ABNORMAL_CLOSE', 'Chiusura senza handshake (drop TCP o terminate)', { closeCode, ...countDrop() });
|
|
326
|
+
} else {
|
|
327
|
+
logClose('notice', 'WS_CLIENT_CLOSE', 'Il client ha chiuso la connessione', { closeCode, ...countDrop() });
|
|
328
|
+
}
|
|
329
|
+
});
|
|
277
330
|
ws.on('error', () => { clearAttachTimer(); detach(); });
|
|
278
331
|
}
|
|
279
332
|
module.exports = { bindWs, clamp, createPtyGraceStore, PTY_GRACE_MS };
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Contatore di cadute WebSocket: trasforma «celle che vanno e vengono»
|
|
3
|
+
// in numeri — quante cadute nella finestra (default 10 minuti) e, alla
|
|
4
|
+
// riconnessione, quanto è durata la caduta (gap tra close e reopen).
|
|
5
|
+
// In memoria, zero I/O: i valori viaggiano nei meta dei log di bridge.js.
|
|
6
|
+
function createDropCounter({ windowMs = 10 * 60 * 1000, now = Date.now } = {}) {
|
|
7
|
+
const drops = []; // { ts, sessionId }
|
|
8
|
+
const lastClose = new Map(); // sessionId -> ts
|
|
9
|
+
const key = (s) => String(s == null ? '' : s);
|
|
10
|
+
function prune(ts) {
|
|
11
|
+
const cutoff = ts - windowMs;
|
|
12
|
+
while (drops.length && drops[0].ts < cutoff) drops.shift();
|
|
13
|
+
}
|
|
14
|
+
return {
|
|
15
|
+
recordDrop(session, ts = now()) {
|
|
16
|
+
const k = key(session);
|
|
17
|
+
drops.push({ ts, sessionId: k });
|
|
18
|
+
if (k) lastClose.set(k, ts);
|
|
19
|
+
prune(ts);
|
|
20
|
+
return { drops: drops.length, windowSeconds: Math.round(windowMs / 1000) };
|
|
21
|
+
},
|
|
22
|
+
// Alla riconnessione: la durata della caduta e' il gap close->reopen.
|
|
23
|
+
// Consuma il last close: una connessione lunga e sana non e' «ritorno».
|
|
24
|
+
recordReopen(session, ts = now()) {
|
|
25
|
+
const k = key(session);
|
|
26
|
+
const last = lastClose.get(k);
|
|
27
|
+
lastClose.delete(k);
|
|
28
|
+
prune(ts);
|
|
29
|
+
return { gapMs: last == null ? null : Math.max(0, ts - last), drops: drops.length };
|
|
30
|
+
},
|
|
31
|
+
snapshot(ts = now()) {
|
|
32
|
+
prune(ts);
|
|
33
|
+
return { drops: drops.length, windowSeconds: Math.round(windowMs / 1000) };
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
module.exports = { createDropCounter };
|
package/package.json
CHANGED
|
@@ -23,7 +23,7 @@ Five nouns carry almost everything:
|
|
|
23
23
|
- **Node** — one installation on one machine. Its identity is an opaque
|
|
24
24
|
`instanceId`; the human-readable name is a **label**, and two nodes may
|
|
25
25
|
legitimately carry the same one. **Address things by id, never by name.**
|
|
26
|
-
- **Cell** — one stable working identity (`
|
|
26
|
+
- **Cell** — one stable working identity (`Alpha`, `Beta`, …) bound to one
|
|
27
27
|
tmux session and one engine. A cell is not a process: it survives restarts of
|
|
28
28
|
the service, and stopping it does not end the work it was doing.
|
|
29
29
|
- **Engine** — what a cell runs: an AI CLI, a plain shell, a command in a
|