@mmmbuto/nexuscrew 0.9.40 → 0.9.42
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 -1
- package/bin/nc-activity-hook.js +55 -0
- package/frontend/dist/assets/index-CPO7bv6-.css +32 -0
- package/frontend/dist/assets/index-DpFbq_tt.js +169 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/files/activity.js +407 -0
- package/lib/fleet/ai-desktop.js +123 -0
- package/lib/fleet/cell-exec.js +152 -4
- package/lib/fleet/codex-hooks.js +253 -0
- package/lib/fleet/managed.js +274 -30
- package/lib/fleet/mcp-cache-readiness.js +154 -0
- package/lib/fleet/prompt-delivery.js +65 -12
- package/lib/fleet/runtime.js +158 -10
- package/lib/notify/ask-relay.js +104 -19
- package/lib/proxy/panel-proxy.js +16 -2
- package/lib/server.js +41 -1
- package/lib/settings/routes.js +55 -3
- package/lib/tmux/list.js +19 -1
- package/lib/tmux/supervisor-pane.js +81 -0
- package/package.json +4 -3
- package/frontend/dist/assets/index-BJBurY16.js +0 -169
- package/frontend/dist/assets/index-Csz8VaFz.css +0 -32
|
@@ -190,8 +190,9 @@ async function deliverBootstrapPrompt(opts = {}) {
|
|
|
190
190
|
const readyWaitMs = clampInt(opts.readyWaitMs, 15000, 0, 120000);
|
|
191
191
|
const pollMs = clampInt(opts.pollMs, 400, 50, 5000);
|
|
192
192
|
const settleMs = clampInt(opts.settleMs, 150, 0, 5000);
|
|
193
|
-
const done = (delivered, state, notReady, attempts) => ({
|
|
193
|
+
const done = (delivered, state, notReady, attempts, mcp) => ({
|
|
194
194
|
delivered, state, notReady, attempts, reason: state,
|
|
195
|
+
...(mcp ? { mcp } : {}),
|
|
195
196
|
});
|
|
196
197
|
if (!promptCharsOk(prompt)) return done(false, 'prompt-rejected', '', 0);
|
|
197
198
|
|
|
@@ -217,6 +218,25 @@ async function deliverBootstrapPrompt(opts = {}) {
|
|
|
217
218
|
await sleepImpl(pollMs);
|
|
218
219
|
}
|
|
219
220
|
|
|
221
|
+
// (1b) Gate readiness MCP opzionale (solo claude.*): la mcpWait e'
|
|
222
|
+
// fornita dal supervisore, con deadline ASSOLUTA composta sullo stesso
|
|
223
|
+
// orologio del launch (tetto MCP dal budget approvato, mai un budget nuovo a
|
|
224
|
+
// valle). Il composer pronto resta SEMPRE obbligatorio: qui si arriva solo
|
|
225
|
+
// col pane ready. A budget esaurito senza MCP pronti la consegna prosegue
|
|
226
|
+
// come AVVIO DEGRADATO VISIBILE: esito submitted/degraded con l'elenco
|
|
227
|
+
// bounded dei server failed/pending, mai un lancio silenzioso.
|
|
228
|
+
let mcp = null;
|
|
229
|
+
if (typeof opts.mcpWait === 'function') {
|
|
230
|
+
const r = await opts.mcpWait({ isCancelled: cancelled });
|
|
231
|
+
if (cancelled() || !r || r.cancelled) return done(false, 'cancelled', '', 0);
|
|
232
|
+
mcp = {
|
|
233
|
+
state: r.state === 'ready' ? 'ready' : 'degraded',
|
|
234
|
+
ready: Array.isArray(r.ready) ? r.ready : [],
|
|
235
|
+
failed: Array.isArray(r.failed) ? r.failed : [],
|
|
236
|
+
pending: Array.isArray(r.pending) ? r.pending : [],
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
220
240
|
// (2) Consegna AT-MOST-ONCE: un solo paste per generazione. Il retry copre
|
|
221
241
|
// SOLO fallimenti certi pre-paste (resolve/load); dopo qualsiasi tentativo
|
|
222
242
|
// Di paste-buffer non esiste retry automatico.
|
|
@@ -239,32 +259,54 @@ async function deliverBootstrapPrompt(opts = {}) {
|
|
|
239
259
|
if (paneAgain !== stage.paneId) return done(false, 'delivery-unknown', '', attempts);
|
|
240
260
|
if (cancelled()) return done(false, 'delivery-unknown', '', attempts);
|
|
241
261
|
const enter = await exec(tmuxBin, ['send-keys', '-t', stage.paneId, 'Enter'], { env });
|
|
242
|
-
if (enter.err) return done(false, 'staged-not-submitted', '', attempts);
|
|
262
|
+
if (enter.err) return done(false, 'staged-not-submitted', '', attempts, mcp);
|
|
243
263
|
// Cancel con Enter appena partito -> la generazione e' morta con un
|
|
244
264
|
// submit in volo: esito incerto (residuo PTY possibile), mai 'submitted'.
|
|
245
265
|
if (cancelled()) return done(false, 'delivery-unknown', '', attempts);
|
|
246
|
-
return done(true, 'submitted', '', attempts);
|
|
266
|
+
return done(true, 'submitted', '', attempts, mcp);
|
|
247
267
|
}
|
|
248
268
|
if (stage.stage === 'paste' || stage.stage === 'validate') {
|
|
249
269
|
// Paste tentato (esito incerto) o input rifiutato: zero secondo paste.
|
|
250
|
-
return done(false, stage.stage === 'validate' ? 'prompt-rejected' : 'delivery-unknown', '', attempts);
|
|
270
|
+
return done(false, stage.stage === 'validate' ? 'prompt-rejected' : 'delivery-unknown', '', attempts, mcp);
|
|
251
271
|
}
|
|
252
|
-
if (attempts >= 2) return done(false, 'failed-pre-paste', '', attempts);
|
|
272
|
+
if (attempts >= 2) return done(false, 'failed-pre-paste', '', attempts, mcp);
|
|
253
273
|
// Fallimento certo PRE-paste: un solo retry, readiness riverificata prima.
|
|
254
274
|
await sleepImpl(pollMs);
|
|
255
275
|
if (cancelled()) return done(false, 'cancelled', '', attempts);
|
|
256
276
|
const text = await capture();
|
|
257
277
|
const again = text === null ? 'unknown' : classifyPane(text, client);
|
|
258
|
-
if (again !== 'ready') return done(false, 'failed-pre-paste', '', attempts);
|
|
278
|
+
if (again !== 'ready') return done(false, 'failed-pre-paste', '', attempts, mcp);
|
|
259
279
|
}
|
|
260
280
|
}
|
|
261
281
|
|
|
262
282
|
// WaitDeliveryReport(tmuxBin, target, opts) -> delivery-like null.
|
|
263
283
|
// Il launcher supervisionato (cell-exec) e' l'UNICO owner della consegna per
|
|
264
|
-
// TUTTE le generazioni degli engine Kimi e pubblica l'esito bounded
|
|
265
|
-
// opzione tmux di pane @nc_delivery ('<state>'
|
|
266
|
-
// enum chiusi; muore col
|
|
267
|
-
//
|
|
284
|
+
// TUTTE le generazioni degli engine Kimi e claude.* e pubblica l'esito bounded
|
|
285
|
+
// sull'opzione tmux di pane @nc_delivery ('<state>' / '<state>:<notReady>' /
|
|
286
|
+
// '<state>:<notReady>:mcp<state>' dal 0.9.41, solo enum chiusi; muore col
|
|
287
|
+
// pane, nessuno state file). Con degrado MCP l'elenco bounded dei server
|
|
288
|
+
// failed/pending viaggia su @nc_mcp_list ('failed=a,b|pending=c', nomi
|
|
289
|
+
// validati). up() la legge con attesa bounded: niente paste dal runtime,
|
|
290
|
+
// niente doppio writer cross-generation.
|
|
291
|
+
const MCP_REPORT_STATES = Object.freeze(['ready', 'degraded']);
|
|
292
|
+
const MCP_NAME_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
293
|
+
|
|
294
|
+
function parseMcpListOption(raw) {
|
|
295
|
+
const out = { failed: [], pending: [] };
|
|
296
|
+
const text = typeof raw === 'string' ? raw.trim() : '';
|
|
297
|
+
if (!text) return out;
|
|
298
|
+
for (const part of text.split('|')) {
|
|
299
|
+
const eq = part.indexOf('=');
|
|
300
|
+
if (eq <= 0) continue;
|
|
301
|
+
const kind = part.slice(0, eq);
|
|
302
|
+
if (kind !== 'failed' && kind !== 'pending') continue;
|
|
303
|
+
for (const nome of part.slice(eq + 1).split(',')) {
|
|
304
|
+
if (nome && MCP_NAME_RE.test(nome) && !out[kind].includes(nome)) out[kind].push(nome);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return out;
|
|
308
|
+
}
|
|
309
|
+
|
|
268
310
|
async function waitDeliveryReport(tmuxBin, target, { env, exec, sleepImpl, nowImpl, timeoutMs, pollMs } = {}) {
|
|
269
311
|
const run = exec || tmuxExec;
|
|
270
312
|
const sleepFn = sleepImpl || sleep;
|
|
@@ -277,13 +319,24 @@ async function waitDeliveryReport(tmuxBin, target, { env, exec, sleepImpl, nowIm
|
|
|
277
319
|
{ env, timeoutMs: 2000 });
|
|
278
320
|
const raw = r.err ? '' : r.stdout.trim();
|
|
279
321
|
if (raw) {
|
|
280
|
-
const [state, notReady = ''] = raw.split(':');
|
|
322
|
+
const [state, notReady = '', mcpTag = ''] = raw.split(':');
|
|
281
323
|
if (!DELIVERY_STATES.includes(state)) return null; // valore non bounded: mai fidarsi
|
|
282
324
|
const kind = PANE_STATES.includes(notReady) ? notReady : '';
|
|
325
|
+
const mcpState = mcpTag.startsWith('mcp') && MCP_REPORT_STATES.includes(mcpTag.slice(3))
|
|
326
|
+
? mcpTag.slice(3) : '';
|
|
327
|
+
let mcp = null;
|
|
328
|
+
if (mcpState) {
|
|
329
|
+
const r2 = await run(tmuxBin,
|
|
330
|
+
['display-message', '-p', '-t', target, '#{@nc_mcp_list}'],
|
|
331
|
+
{ env, timeoutMs: 2000 });
|
|
332
|
+
const { failed, pending } = parseMcpListOption(r2.err ? '' : r2.stdout);
|
|
333
|
+
mcp = { state: mcpState, ready: [], failed, pending };
|
|
334
|
+
}
|
|
283
335
|
return {
|
|
284
336
|
delivered: state === 'submitted',
|
|
285
337
|
state, notReady: state === 'skipped-not-ready' ? kind : '',
|
|
286
338
|
attempts: 0, reason: state,
|
|
339
|
+
...(mcp ? { mcp } : {}),
|
|
287
340
|
};
|
|
288
341
|
}
|
|
289
342
|
if (now() >= deadline) return null;
|
|
@@ -318,6 +371,6 @@ function actionRequiredFor(client, provider, delivery) {
|
|
|
318
371
|
}
|
|
319
372
|
|
|
320
373
|
module.exports = {
|
|
321
|
-
PANE_STATES, DELIVERY_STATES, ACTION_CODES, RECOVERY_SLUGS,
|
|
374
|
+
PANE_STATES, DELIVERY_STATES, ACTION_CODES, RECOVERY_SLUGS, MCP_REPORT_STATES, parseMcpListOption,
|
|
322
375
|
classifyPane, deliverBootstrapPrompt, waitDeliveryReport, actionRequiredFor, vlPaneReadiness,
|
|
323
376
|
};
|
package/lib/fleet/runtime.js
CHANGED
|
@@ -17,10 +17,13 @@ const crypto = require('node:crypto');
|
|
|
17
17
|
const {
|
|
18
18
|
loadDefinitions, validateCommandTrust, resolveCwd,
|
|
19
19
|
} = require('./definitions.js');
|
|
20
|
+
const aiDesktop = require('./ai-desktop.js');
|
|
20
21
|
const {
|
|
21
22
|
describeManaged, resolveManagedEngine, discoverOllamaModels, discoverPiModels, extraModelsFrom,
|
|
23
|
+
knownMcpServerNames,
|
|
22
24
|
} = require('./managed.js');
|
|
23
25
|
const { sharedProbe: defaultEndpointProbe } = require('./endpoint-probe.js');
|
|
26
|
+
const { NC_SUPERVISOR_OPT } = require('../tmux/supervisor-pane.js');
|
|
24
27
|
const {
|
|
25
28
|
httpError, minimalEnv, tmuxExec,
|
|
26
29
|
composeClientInvocation, alternateScreenArgs,
|
|
@@ -29,6 +32,55 @@ const {
|
|
|
29
32
|
} = require('./launch.js');
|
|
30
33
|
const { waitDeliveryReport, actionRequiredFor, vlPaneReadiness } = require('./prompt-delivery.js');
|
|
31
34
|
|
|
35
|
+
// Insieme MCP MATERIALIZZATO della cella — le chiavi del file che
|
|
36
|
+
// writeCellMcpConfig (managed.js) scrive PRIMA dello spawn. Un server non
|
|
37
|
+
// concesso non esiste per il launch, quindi non e' atteso dal gate.
|
|
38
|
+
const MCP_BUDGET_DEFAULT_MS = 20000; // tetto approvato per il gate MCP
|
|
39
|
+
|
|
40
|
+
function cellMcpExpectedServers(home, cellId) {
|
|
41
|
+
const fs = require('node:fs');
|
|
42
|
+
try {
|
|
43
|
+
const p = path.join(home, '.nexuscrew', 'cell-mcp', `${cellId}.json`);
|
|
44
|
+
const st = fs.lstatSync(p);
|
|
45
|
+
if (st.isSymbolicLink() || !st.isFile()) return [];
|
|
46
|
+
const parsed = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
47
|
+
const servers = parsed && typeof parsed === 'object' && parsed.mcpServers
|
|
48
|
+
&& typeof parsed.mcpServers === 'object' && !Array.isArray(parsed.mcpServers)
|
|
49
|
+
? parsed.mcpServers : null;
|
|
50
|
+
return servers ? Object.keys(servers).sort() : [];
|
|
51
|
+
} catch (_) { return []; }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Celle claude LEGACY (nessun materializzato cell-mcp): l'atteso e' la config
|
|
55
|
+
// nota al client (le tre sorgenti di `knownMcpServerNames`) meno cio' che la
|
|
56
|
+
// cella nega con `cell.mcp`. RESIDUO DICHIARATO: plugin, connettori e
|
|
57
|
+
// configurazione gestita di sistema non sono enumerabili e restano fuori
|
|
58
|
+
// dall'atteso — il gate copre cio' che e' enumerabile, mai promette il totale.
|
|
59
|
+
function legacyMcpExpectedServers(home, cwd, cell) {
|
|
60
|
+
const noti = [...(knownMcpServerNames(home, cwd) || [])];
|
|
61
|
+
if (!noti.length) return [];
|
|
62
|
+
const voluti = cell && Array.isArray(cell.mcp) ? cell.mcp : null;
|
|
63
|
+
if (voluti === null) return [...noti].sort();
|
|
64
|
+
const concessi = new Set(voluti.filter((n) => typeof n === 'string'));
|
|
65
|
+
return [...noti].filter((n) => concessi.has(n)).sort();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Payload readiness per il supervisore: cwd reale della cella + attese + tetto
|
|
69
|
+
// MCP. Priorita' all'insieme MATERIALIZZATO (cell-mcp); per le claude legacy
|
|
70
|
+
// ricade sulla config nota meno i negati. undefined quando la cella non ha
|
|
71
|
+
// server attesi (niente gate: consegna come kimi, che da 0.8.47 non ha gate).
|
|
72
|
+
function buildMcpReadinessPayload(cell, realCwd, home, cfg) {
|
|
73
|
+
const expectedServers = cellMcpExpectedServers(home, cell.id).length
|
|
74
|
+
? cellMcpExpectedServers(home, cell.id)
|
|
75
|
+
: legacyMcpExpectedServers(home, realCwd, cell);
|
|
76
|
+
if (!expectedServers.length) return undefined;
|
|
77
|
+
return {
|
|
78
|
+
cwd: realCwd,
|
|
79
|
+
expectedServers,
|
|
80
|
+
budgetMs: Math.max(0, Math.min(120000, Number(cfg && cfg.mcpReadyBudgetMs) || MCP_BUDGET_DEFAULT_MS)),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
32
84
|
// TTL della cache status (ms): scaduto, status rilegge tmux + defs da disco.
|
|
33
85
|
const STATUS_TTL_MS = 2000;
|
|
34
86
|
|
|
@@ -127,7 +179,12 @@ function createBuiltinRuntime(ctx) {
|
|
|
127
179
|
// engineDef gia' usata sopra per la permission policy: un valore
|
|
128
180
|
// presente qui e' gia' passato da validPanelUrl a monte (parseEngine/
|
|
129
181
|
// parseCell), quindi si copia, non si ri-valida.
|
|
130
|
-
|
|
182
|
+
// La spunta del desktop grafico taglia qui il pannello del container
|
|
183
|
+
// quando è esplicitamente OFF: il campo non esce dallo stato, quindi il
|
|
184
|
+
// tasto e la sorgente Pannello spariscono da OGNI superficie senza un
|
|
185
|
+
// if nel frontend. Solo la chiave esplicita comanda: qui non si
|
|
186
|
+
// interroga mai docker (percorso caldo a polling).
|
|
187
|
+
const panelUrl = aiDesktop.pubblicaPanelUrlCaldo(c.panelUrl || engineDef?.panelUrl || '');
|
|
131
188
|
return {
|
|
132
189
|
// `cell` resta l'id: e' la chiave di indirizzamento. `label` e' il nome
|
|
133
190
|
// leggibile e viaggia accanto, senza mai sostituirlo.
|
|
@@ -239,9 +296,48 @@ function createBuiltinRuntime(ctx) {
|
|
|
239
296
|
if (!cell) throw httpError(400, `cella sconosciuta: ${cellId}`);
|
|
240
297
|
const engine = findEngine(defs, cell.engine);
|
|
241
298
|
if (!engine) throw httpError(400, `engine dangling per cella ${cellId}: ${cell.engine}`);
|
|
299
|
+
// (0) Una cella GIA' VIVA non si rilancia — e soprattutto non si risolve.
|
|
300
|
+
// Senza questo controllo l'ordine era: si risolve lo spec (che PUBBLICA la
|
|
301
|
+
// generazione di attivita', managed.js:1696), si chiede il ticket, e solo
|
|
302
|
+
// alla new-session si scopre che la sessione esiste gia' -> 409. Il file
|
|
303
|
+
// `activity.gen` restava pero' riscritto con una generazione nuova, mentre
|
|
304
|
+
// gli hook del lancio vero portano quella vecchia: lo stato di quella cella
|
|
305
|
+
// veniva scartato (files/activity.js:336-338) e la UI diceva «non
|
|
306
|
+
// verificata» su una cella che stava lavorando. Lo pagava qualunque `up` su
|
|
307
|
+
// una cella viva: l'operatore dalla UI, e `fleet-boot` all'avvio del
|
|
308
|
+
// servizio, che cicla TUTTE le celle boot:true e su quelle gia' in
|
|
309
|
+
// esecuzione si limitava a saltare (boot.js:49) — dopo aver riscritto il
|
|
310
|
+
// file.
|
|
311
|
+
// Il 409 in fondo (new-session) resta come rete per la corsa fra questo
|
|
312
|
+
// controllo e la creazione della sessione.
|
|
313
|
+
const vive = await refreshSessions();
|
|
314
|
+
if (vive.has(String(cell.tmuxSession))) {
|
|
315
|
+
throw httpError(409, 'sessione già in esecuzione', null,
|
|
316
|
+
{ phase: 'preflight', code: 'SESSION_DUPLICATE' });
|
|
317
|
+
}
|
|
242
318
|
let launchEngine = engine;
|
|
319
|
+
// Canale di attivita' della cella: `resolved` vive solo dentro il ramo
|
|
320
|
+
// managed, e il payload del broker si costruisce FUORI. Portarlo fuori con
|
|
321
|
+
// una variabile dichiarata qui evita di usare, in un ramo, un nome
|
|
322
|
+
// dichiarato in un altro: un errore che in produzione fa fallire il lancio
|
|
323
|
+
// di ogni cella, non una sola asserzione.
|
|
324
|
+
let activityCanale = null;
|
|
243
325
|
if (engine.managed) {
|
|
244
|
-
|
|
326
|
+
// Generazione del lancio: identifica QUESTA partenza della cella. Uno
|
|
327
|
+
// stato di attivita' che ne porta un'altra appartiene a un lancio
|
|
328
|
+
// precedente e viene scartato — una cella riavviata non eredita il
|
|
329
|
+
// «lavora» di prima.
|
|
330
|
+
const generazione = crypto.randomBytes(8).toString('hex');
|
|
331
|
+
const resolved = resolveManagedEngine(engine, cell, {
|
|
332
|
+
...cfg, home, extraModels: extraModelsFrom(defs),
|
|
333
|
+
capabilityProfiles: defs.capabilityProfiles || null,
|
|
334
|
+
activityGeneration: generazione,
|
|
335
|
+
// La generazione NON si scrive qui: viaggia negli argv degli hook e nel
|
|
336
|
+
// payload del broker, e a scriverla su disco e' `cell-exec`, dentro la
|
|
337
|
+
// sessione che tmux ha appena creato — quindi solo per chi vince la
|
|
338
|
+
// new-session. Chi perde la corsa scriverebbe una generazione che non e'
|
|
339
|
+
// quella di nessun lancio vivo.
|
|
340
|
+
});
|
|
245
341
|
// Il materializzatore MCP per cella e' FAIL-CLOSED: se il file per cella
|
|
246
342
|
// non si scrive, la cella non parte — mai con la superficie intera dove
|
|
247
343
|
// l'operatore l'ha ristretta.
|
|
@@ -253,6 +349,10 @@ function createBuiltinRuntime(ctx) {
|
|
|
253
349
|
throw httpError(400, `engine managed non configurato (${engine.id}): ${resolved.reason}`, null, { phase: 'preflight', code });
|
|
254
350
|
}
|
|
255
351
|
launchEngine = resolved.engine;
|
|
352
|
+
// Qui si porta FUORI dal ramo il solo dato che serve al payload: dove e
|
|
353
|
+
// con quale generazione il supervisore dovra' PUBBLICARLA su disco e
|
|
354
|
+
// dichiarare l'uscita del client.
|
|
355
|
+
if (resolved.activityDir) activityCanale = { dir: resolved.activityDir, generation: generazione };
|
|
256
356
|
// A vl cell whose runtime cannot hold the per-cell prompt
|
|
257
357
|
// (VL_SYSTEM_APPEND_FILE, 0.3.1+) DEGRADES by declaring it — same
|
|
258
358
|
// shape as readinessDegraded: the cell starts without its own identity
|
|
@@ -327,6 +427,13 @@ function createBuiltinRuntime(ctx) {
|
|
|
327
427
|
...(typeof launchEngine.identityChannel === 'boolean'
|
|
328
428
|
? { identityChannel: launchEngine.identityChannel } : {}),
|
|
329
429
|
...(leaseInfo ? { lease: { cellId: cell.id, launchEpoch: leaseInfo.launchEpoch, stablePath: leaseInfo.stablePath } } : {}),
|
|
430
|
+
// Canale di attivita' verso il supervisore: il riavvio del client
|
|
431
|
+
// avviene DENTRO lo stesso lancio, quindi la generazione non cambia e
|
|
432
|
+
// l'unico modo di invalidare lo stato del client morto e' che sia
|
|
433
|
+
// cell-exec a dichiararlo. Presente solo dove gli hook sono davvero
|
|
434
|
+
// iniettati (`activityDir` lo dice): senza canale non c'e' niente da
|
|
435
|
+
// invalidare.
|
|
436
|
+
...(activityCanale ? { activity: activityCanale } : {}),
|
|
330
437
|
supervise: {
|
|
331
438
|
enabled: !launchEngine.shellOneShot,
|
|
332
439
|
initialReadyMs: Math.max(50, Math.min(30000, Number(readyMs) || 500)),
|
|
@@ -342,12 +449,17 @@ function createBuiltinRuntime(ctx) {
|
|
|
342
449
|
tmuxSession: cell.tmuxSession,
|
|
343
450
|
prompt: cell.prompt,
|
|
344
451
|
readyMs: Math.max(0, Math.min(30000, Number(cfg.sendKeysReadyMs) || readyMs)),
|
|
345
|
-
//
|
|
346
|
-
//
|
|
347
|
-
//
|
|
348
|
-
client: engine.managed && engine.managed.client === 'kimi'
|
|
349
|
-
|
|
452
|
+
// kimi.* e TUTTE le claude.* hanno la delivery classificata
|
|
453
|
+
// at-most-once ai restart; gli engine custom send-keys conservano
|
|
454
|
+
// il reinject legacy senza Enter (0.8.47).
|
|
455
|
+
client: engine.managed && (engine.managed.client === 'kimi' || engine.managed.client === 'claude')
|
|
456
|
+
? engine.managed.client : '',
|
|
350
457
|
readyWaitMs: Math.max(0, Math.min(120000, Number(cfg.bootstrapReadyWaitMs) || 15000)),
|
|
458
|
+
// Gate readiness MCP solo per le claude.* (budget composto col
|
|
459
|
+
// readyWaitMs: tetto MCP dal launch, avvio degradato visibile).
|
|
460
|
+
...(engine.managed && engine.managed.client === 'claude' ? {
|
|
461
|
+
mcpReadiness: buildMcpReadinessPayload(cell, realCwd, home, cfg),
|
|
462
|
+
} : {}),
|
|
351
463
|
},
|
|
352
464
|
} : {}),
|
|
353
465
|
});
|
|
@@ -398,6 +510,26 @@ function createBuiltinRuntime(ctx) {
|
|
|
398
510
|
throw httpError(dup ? 409 : 500, why, null,
|
|
399
511
|
{ phase: 'new-session', code: dup ? 'SESSION_DUPLICATE' : 'NEW_SESSION_FAILED' });
|
|
400
512
|
}
|
|
513
|
+
// Il pane del SUPERVISORE si marca ORA, sul pane che esegue cell-exec: e'
|
|
514
|
+
// l'identita' stabile della cella viva. Il server cerca questo marcatore
|
|
515
|
+
// (`list-panes`) perche' `#{pane_dead}` di list-sessions riguarda il pane
|
|
516
|
+
// ATTIVO — con una seconda finestra viva selezionata direbbe «vivo» anche
|
|
517
|
+
// con il supervisore morto. L'id ce l'abbiamo gia' da `-P -F` (sotto), non
|
|
518
|
+
// serve una ricerca. Best effort come le opzioni che seguono: un tmux che
|
|
519
|
+
// non accetta l'opzione non deve impedire l'avvio — la cella resta senza
|
|
520
|
+
// marcatore, e per lei il server dira' «non verificato» invece di leggere
|
|
521
|
+
// uno stato di cui non sa identificare il supervisore.
|
|
522
|
+
const idPaneLancio = /^%[0-9]+$/.test((create.stdout.trim().split('\n')[0] || '').split('\t')[2] || '')
|
|
523
|
+
? (create.stdout.trim().split('\n')[0] || '').split('\t')[2] : '';
|
|
524
|
+
if (idPaneLancio) {
|
|
525
|
+
const marcato = await tmuxExec(tmuxBin,
|
|
526
|
+
['set-option', '-p', '-t', idPaneLancio, NC_SUPERVISOR_OPT, '1'],
|
|
527
|
+
{ env: minimalEnv(), timeoutMs: 2000 });
|
|
528
|
+
if (marcato.err) {
|
|
529
|
+
const log = typeof cfg.log === 'function' ? cfg.log : console.warn;
|
|
530
|
+
log(`fleet marcatore del pane supervisore non impostato per ${cell.id}; la cella resta non identificabile`);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
401
533
|
// Best effort per la sola sessione appena creata: non tocchiamo mai
|
|
402
534
|
// ~/.tmux.conf, opzioni globali o sessioni preesistenti. Un tmux che non
|
|
403
535
|
// accetta una delle opzioni non deve impedire l'avvio della cella.
|
|
@@ -557,14 +689,20 @@ function createBuiltinRuntime(ctx) {
|
|
|
557
689
|
// Paste SENZA Enter, contratto invariato —), target %N esatto.
|
|
558
690
|
let prompt = null;
|
|
559
691
|
let actionRequired = null;
|
|
692
|
+
let mcpDegraded = null;
|
|
560
693
|
if (launchEngine.promptMode === 'send-keys' && cell.prompt) {
|
|
561
694
|
const managedClient = engine.managed && typeof engine.managed.client === 'string' ? engine.managed.client : '';
|
|
562
695
|
const managedProvider = engine.managed && typeof engine.managed.provider === 'string' ? engine.managed.provider : '';
|
|
563
|
-
const classified = managedClient === 'kimi' ||
|
|
696
|
+
const classified = managedClient === 'kimi' || managedClient === 'claude';
|
|
564
697
|
if (classified) {
|
|
565
698
|
const readyWaitMs = Math.max(0, Math.min(120000, Number(cfg.bootstrapReadyWaitMs) || 15000));
|
|
699
|
+
// Budget unico composto — il report copre anche il tetto MCP del
|
|
700
|
+
// gate (l'attesa MCP consuma lo stesso orologio del launch).
|
|
701
|
+
const mcpBudgetMs = managedClient === 'claude'
|
|
702
|
+
? Math.max(0, Math.min(120000, Number(cfg.mcpReadyBudgetMs) || MCP_BUDGET_DEFAULT_MS))
|
|
703
|
+
: 0;
|
|
566
704
|
const reportWaitMs = Math.max(100, Math.min(150000,
|
|
567
|
-
(Number(cfg.sendKeysReadyMs) || readyMs) + readyWaitMs + 2000));
|
|
705
|
+
(Number(cfg.sendKeysReadyMs) || readyMs) + readyWaitMs + mcpBudgetMs + 2000));
|
|
568
706
|
const report = await waitDeliveryReport(tmuxBin,
|
|
569
707
|
paneId.startsWith('%') ? paneId : `=${cell.tmuxSession}`,
|
|
570
708
|
{ env: minimalEnv(), timeoutMs: reportWaitMs });
|
|
@@ -576,8 +714,17 @@ function createBuiltinRuntime(ctx) {
|
|
|
576
714
|
delivered: delivery.delivered,
|
|
577
715
|
state: delivery.state,
|
|
578
716
|
reason: delivery.reason,
|
|
717
|
+
...(delivery.mcp ? { mcp: delivery.mcp } : {}),
|
|
579
718
|
};
|
|
580
719
|
actionRequired = actionRequiredFor(managedClient, managedProvider, delivery);
|
|
720
|
+
// Avvio degradato visibile: l'elenco bounded dei server MCP non pronti
|
|
721
|
+
// sale alla UI con il flag esistente readinessDegraded.
|
|
722
|
+
if (delivery.mcp && delivery.mcp.state === 'degraded') {
|
|
723
|
+
mcpDegraded = {
|
|
724
|
+
failed: Array.isArray(delivery.mcp.failed) ? delivery.mcp.failed : [],
|
|
725
|
+
pending: Array.isArray(delivery.mcp.pending) ? delivery.mcp.pending : [],
|
|
726
|
+
};
|
|
727
|
+
}
|
|
581
728
|
} else {
|
|
582
729
|
const target = paneId.startsWith('%') ? paneId : `=${cell.tmuxSession}`;
|
|
583
730
|
prompt = await injectPrompt(tmuxBin, cell.tmuxSession, cell.prompt, {
|
|
@@ -593,6 +740,7 @@ function createBuiltinRuntime(ctx) {
|
|
|
593
740
|
ok: true, cell: cellId, session: cell.tmuxSession, prompt,
|
|
594
741
|
...(actionRequired ? { actionRequired } : {}),
|
|
595
742
|
...(readinessDegraded ? { readinessDegraded: true } : {}),
|
|
743
|
+
...(mcpDegraded ? { readinessDegraded: true, mcpDegraded } : {}),
|
|
596
744
|
// Per-cell prompt not delivered to a vl cell (old runtime or
|
|
597
745
|
// unwritable file). Strict boolean like readinessDegraded: the
|
|
598
746
|
// payload may come from a federated node and the text is local i18n.
|
|
@@ -630,4 +778,4 @@ function createBuiltinRuntime(ctx) {
|
|
|
630
778
|
};
|
|
631
779
|
}
|
|
632
780
|
|
|
633
|
-
module.exports = { createBuiltinRuntime, findCell, findEngine, identityLaunchRefusal, STATUS_TTL_MS };
|
|
781
|
+
module.exports = { createBuiltinRuntime, findCell, findEngine, identityLaunchRefusal, STATUS_TTL_MS, buildMcpReadinessPayload, cellMcpExpectedServers };
|
package/lib/notify/ask-relay.js
CHANGED
|
@@ -14,23 +14,66 @@
|
|
|
14
14
|
// refused locally until verification resolves the attempt.
|
|
15
15
|
|
|
16
16
|
const crypto = require('node:crypto');
|
|
17
|
+
const { probeReverseSlot } = require('../nodes/reverse-slot-proof.js');
|
|
17
18
|
const IDLE_MS = 15000;
|
|
18
19
|
|
|
19
|
-
function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = () => {} } = {}) {
|
|
20
|
+
function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = () => {}, probeReverseSlotImpl = probeReverseSlot } = {}) {
|
|
20
21
|
// (ownerId|askId|requestId) -> {state: 'sent'|'committed'|'failed'|'uncertain', requestId}
|
|
21
22
|
const attempts = new Map();
|
|
22
23
|
|
|
23
24
|
function requestId() { return crypto.randomUUID(); }
|
|
24
25
|
|
|
25
|
-
|
|
26
|
+
// Risoluzione del produttore: peer OUTBOUND (canale proprio, come sempre)
|
|
27
|
+
// oppure peer INBOUND condiviso con consenso eventi+risposta e slot reverse
|
|
28
|
+
// attivo. Per gli inbound la porta dello slot DEVE essere verificata con la
|
|
29
|
+
// stessa sonda dei terminali (secret del peer + instanceId atteso): nessun
|
|
30
|
+
// fallback sulla localPort senza proof.
|
|
31
|
+
function candidateOwner(ownerId) {
|
|
26
32
|
const store = loadStore();
|
|
27
33
|
if (!store) return null;
|
|
28
|
-
|
|
29
|
-
|
|
34
|
+
const nodes = store.nodes || [];
|
|
35
|
+
const out = nodes.find((n) => n && n.direction === 'outbound'
|
|
36
|
+
&& n.nodeId === ownerId && n.token && n.localPort);
|
|
37
|
+
if (out) return { node: out, forwardPort: out.localPort, forwardToken: out.token, kind: 'outbound' };
|
|
38
|
+
const inbound = nodes.find((n) => n && n.direction === 'inbound'
|
|
39
|
+
&& n.shared === true && n.nodeId === ownerId
|
|
40
|
+
&& (n.token || n.acceptToken)
|
|
41
|
+
&& n.askReplyAccess === true && n.eventsAccess === true
|
|
42
|
+
&& n.cellVisibility !== 'none');
|
|
43
|
+
if (!inbound) return null;
|
|
44
|
+
const forwardToken = inbound.token || inbound.acceptToken;
|
|
45
|
+
const pool = inbound.reversePool;
|
|
46
|
+
const slot = pool && Array.isArray(pool.slots) ? pool.slots[pool.activeSlot] : null;
|
|
47
|
+
if (!slot || !Number.isInteger(slot.port)) {
|
|
48
|
+
return { node: inbound, forwardPort: null, forwardToken, kind: 'inbound-unverified' };
|
|
49
|
+
}
|
|
50
|
+
return { node: inbound, forwardPort: slot.port, forwardToken, kind: 'inbound' };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function ownerUrl(port, path) {
|
|
54
|
+
return `http://127.0.0.1:${port}/federation/route/_/${path.replace(/^\//, '')}`;
|
|
30
55
|
}
|
|
31
56
|
|
|
32
|
-
|
|
33
|
-
|
|
57
|
+
// Preflight del canale reverse per un peer inbound: rifiuto NETTO se lo slot
|
|
58
|
+
// non e' verificato (non e' un esito incerto: il forward non e' mai partito).
|
|
59
|
+
async function verifyInboundChannel(candidate) {
|
|
60
|
+
const { node, forwardPort } = candidate;
|
|
61
|
+
const pool = node.reversePool;
|
|
62
|
+
const slot = pool && Array.isArray(pool.slots) ? pool.slots[pool.activeSlot] : null;
|
|
63
|
+
const proof = await probeReverseSlotImpl({
|
|
64
|
+
port: forwardPort, secret: node.token,
|
|
65
|
+
expected: {
|
|
66
|
+
remotePort: forwardPort,
|
|
67
|
+
generation: pool && Number.isInteger(pool.activeGeneration) ? pool.activeGeneration : undefined,
|
|
68
|
+
instanceId: node.nodeId,
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
if (proof && proof.owned === true) return { ok: true };
|
|
72
|
+
return {
|
|
73
|
+
ok: false, code: 404,
|
|
74
|
+
error: 'canale reverse non verificato: risposta non inoltrata',
|
|
75
|
+
reason: 'reverse-slot-unverified',
|
|
76
|
+
};
|
|
34
77
|
}
|
|
35
78
|
|
|
36
79
|
function attemptKey(ownerId, askId, rid) { return `${ownerId}|${askId}|${rid}`; }
|
|
@@ -47,16 +90,35 @@ function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = ()
|
|
|
47
90
|
return statusFor(ownerId, askId).some((a) => a.state === 'uncertain');
|
|
48
91
|
}
|
|
49
92
|
|
|
93
|
+
// Il blocco precedente rifiuta il nuovo requestId PRIMA di creare la sua
|
|
94
|
+
// ricevuta: la sola verifica possibile e' sull'ID originale ancora 'sent'
|
|
95
|
+
// (se esiste), altrimenti resta solo la riconciliazione con l'owner.
|
|
96
|
+
function originalSentRequestId(ownerId, askId, excludeRid) {
|
|
97
|
+
const sent = statusFor(ownerId, askId)
|
|
98
|
+
.filter((a) => (a.state === 'sent' || a.state === 'uncertain') && a.requestId !== excludeRid);
|
|
99
|
+
return sent.length ? sent[sent.length - 1].requestId : null;
|
|
100
|
+
}
|
|
101
|
+
|
|
50
102
|
async function relayAnswer({ ownerId, askId, text, rid: providedRid } = {}) {
|
|
51
103
|
if (!ownerId || !askId || typeof text !== 'string' || !text.trim()) {
|
|
52
104
|
return { ok: false, code: 400, error: 'ownerId, askId e text richiesti' };
|
|
53
105
|
}
|
|
54
|
-
const
|
|
55
|
-
if (!
|
|
106
|
+
const candidate = candidateOwner(ownerId);
|
|
107
|
+
if (!candidate) return { ok: false, code: 404, error: 'owner non tra i peer autorizzati', reason: 'owner-unknown' };
|
|
108
|
+
if (candidate.kind === 'inbound-unverified') {
|
|
109
|
+
return { ok: false, code: 404, error: 'canale reverse non verificato: risposta non inoltrata', reason: 'reverse-slot-unverified' };
|
|
110
|
+
}
|
|
111
|
+
if (candidate.kind === 'inbound') {
|
|
112
|
+
const gate = await verifyInboundChannel(candidate);
|
|
113
|
+
if (!gate.ok) return { ok: false, code: gate.code, error: gate.error, reason: gate.reason };
|
|
114
|
+
}
|
|
115
|
+
const peer = candidate.node;
|
|
56
116
|
// Local reconciliation FIRST: an uncertain attempt on this ask must be
|
|
57
117
|
// verified before any new paste leaves this node.
|
|
58
118
|
if (isUncertain(ownerId, askId)) {
|
|
59
|
-
|
|
119
|
+
// L'ID originale e' quello verificabile: il nuovo invio non e' mai partito.
|
|
120
|
+
return { ok: false, code: 409, error: 'esito incerto: verifica lo stato prima di rispondere', reason: 'uncertain', uncertain: true,
|
|
121
|
+
originalRequestId: originalSentRequestId(ownerId, askId) };
|
|
60
122
|
}
|
|
61
123
|
const rid = providedRid || requestId();
|
|
62
124
|
const key = attemptKey(ownerId, askId, rid);
|
|
@@ -65,10 +127,10 @@ function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = ()
|
|
|
65
127
|
const ctrl = new AbortController();
|
|
66
128
|
const timer = setTimeout(() => ctrl.abort(), IDLE_MS);
|
|
67
129
|
try {
|
|
68
|
-
const r = await fetchImpl(ownerUrl(
|
|
130
|
+
const r = await fetchImpl(ownerUrl(candidate.forwardPort, `/event-feed/asks/${askId}/answer`), {
|
|
69
131
|
method: 'POST',
|
|
70
132
|
headers: {
|
|
71
|
-
authorization: `Bearer ${
|
|
133
|
+
authorization: `Bearer ${candidate.forwardToken}`,
|
|
72
134
|
'content-type': 'application/json',
|
|
73
135
|
'x-nexuscrew-visited': store.nodeId,
|
|
74
136
|
},
|
|
@@ -86,6 +148,13 @@ function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = ()
|
|
|
86
148
|
// could have pasted (timeout-ish) parks as uncertain, never retried.
|
|
87
149
|
if (r.status === 409) {
|
|
88
150
|
if (entry) entry.state = body.reason === 'delivery-unknown-block' ? 'uncertain' : 'failed';
|
|
151
|
+
if (body.reason === 'delivery-unknown-block') {
|
|
152
|
+
// Stesso contratto del ramo 502: la UI riceve 200+uncertain e non
|
|
153
|
+
// ripete mai il paste. La verifica possibile e' sull'ID originale.
|
|
154
|
+
return { ok: false, code: 200, uncertain: true, reason: body.reason,
|
|
155
|
+
originalRequestId: originalSentRequestId(ownerId, askId, rid), requestId: rid,
|
|
156
|
+
error: 'esito incerto: bloccato da un invio precedente' };
|
|
157
|
+
}
|
|
89
158
|
return { ok: false, code: 409, error: body.error || 'conflict', reason: body.reason };
|
|
90
159
|
}
|
|
91
160
|
if (r.status === 502) {
|
|
@@ -108,13 +177,21 @@ function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = ()
|
|
|
108
177
|
|
|
109
178
|
// Remote dismiss: same resolution rules as the answer (DELETE on the owner).
|
|
110
179
|
async function relayDismiss({ ownerId, askId } = {}) {
|
|
111
|
-
const
|
|
112
|
-
if (!
|
|
180
|
+
const candidate = candidateOwner(ownerId);
|
|
181
|
+
if (!candidate) return { ok: false, code: 404, error: 'owner non tra i peer autorizzati', reason: 'owner-unknown' };
|
|
182
|
+
if (candidate.kind === 'inbound-unverified') {
|
|
183
|
+
return { ok: false, code: 404, error: 'canale reverse non verificato: risposta non inoltrata', reason: 'reverse-slot-unverified' };
|
|
184
|
+
}
|
|
185
|
+
if (candidate.kind === 'inbound') {
|
|
186
|
+
const gate = await verifyInboundChannel(candidate);
|
|
187
|
+
if (!gate.ok) return { ok: false, code: gate.code, error: gate.error, reason: gate.reason };
|
|
188
|
+
}
|
|
189
|
+
const peer = candidate.node;
|
|
113
190
|
const store = loadStore();
|
|
114
191
|
try {
|
|
115
|
-
const r = await fetchImpl(ownerUrl(
|
|
192
|
+
const r = await fetchImpl(ownerUrl(candidate.forwardPort, `/event-feed/asks/${askId}`), {
|
|
116
193
|
method: 'DELETE',
|
|
117
|
-
headers: { authorization: `Bearer ${
|
|
194
|
+
headers: { authorization: `Bearer ${candidate.forwardToken}`, 'x-nexuscrew-visited': store ? store.nodeId : '' },
|
|
118
195
|
});
|
|
119
196
|
const body = await r.json().catch(() => ({}));
|
|
120
197
|
if (!r.ok) return { ok: false, code: r.status, error: body.error || `HTTP ${r.status}` };
|
|
@@ -123,12 +200,20 @@ function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = ()
|
|
|
123
200
|
}
|
|
124
201
|
|
|
125
202
|
async function verifyStatus({ ownerId, askId, requestId: rid }) {
|
|
126
|
-
const
|
|
127
|
-
if (!
|
|
203
|
+
const candidate = candidateOwner(ownerId);
|
|
204
|
+
if (!candidate) return { ok: false, code: 404, error: 'owner non tra i peer autorizzati', reason: 'owner-unknown' };
|
|
205
|
+
if (candidate.kind === 'inbound-unverified') {
|
|
206
|
+
return { ok: false, code: 404, error: 'canale reverse non verificato: risposta non inoltrata', reason: 'reverse-slot-unverified' };
|
|
207
|
+
}
|
|
208
|
+
if (candidate.kind === 'inbound') {
|
|
209
|
+
const gate = await verifyInboundChannel(candidate);
|
|
210
|
+
if (!gate.ok) return { ok: false, code: gate.code, error: gate.error, reason: gate.reason };
|
|
211
|
+
}
|
|
212
|
+
const peer = candidate.node;
|
|
128
213
|
const store = loadStore();
|
|
129
214
|
try {
|
|
130
|
-
const r = await fetchImpl(ownerUrl(
|
|
131
|
-
headers: { authorization: `Bearer ${
|
|
215
|
+
const r = await fetchImpl(ownerUrl(candidate.forwardPort, `/event-feed/asks/${askId}/requests/${rid}`), {
|
|
216
|
+
headers: { authorization: `Bearer ${candidate.forwardToken}`, 'x-nexuscrew-visited': store ? store.nodeId : '' },
|
|
132
217
|
});
|
|
133
218
|
if (!r.ok) return { ok: false, code: r.status };
|
|
134
219
|
const body = await r.json();
|
package/lib/proxy/panel-proxy.js
CHANGED
|
@@ -219,6 +219,20 @@ function respond(res, code, body) {
|
|
|
219
219
|
res.end(payload);
|
|
220
220
|
}
|
|
221
221
|
|
|
222
|
+
// Il frame NON deve finire carico-bianco su un pannello morto: il 502 qui è
|
|
223
|
+
// UNA PAGINA (l'iframe spara `load` anche sulle risposte d'errore), quindi la
|
|
224
|
+
// pagina dice sé stessa a chi la monta — un postMessage con la causa. Il
|
|
225
|
+
// contenuto è fisso e NON sensibile (un tipo, nessun dato), quindi il
|
|
226
|
+
// targetOrigin è '*': può nascere un padre di origine diversa (il frame viaggia
|
|
227
|
+
// sulla porta pannello separata proprio per isolare le origini). La fiducia la
|
|
228
|
+
// mette chi RICEVE: source del frame attivo + origine del frame + tipo atteso.
|
|
229
|
+
function respondPaginaNonRaggiungibile(res) {
|
|
230
|
+
res.writeHead(502, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
|
|
231
|
+
res.end('<!doctype html><html><body><p>pannello non raggiungibile</p><script>('
|
|
232
|
+
+ 'function(){try{window.parent.postMessage({type:"nc-panel-unreachable"},"*");}catch(_){}}'
|
|
233
|
+
+ ')()</script></body></html>');
|
|
234
|
+
}
|
|
235
|
+
|
|
222
236
|
function createPanelProxy({ resolveCellPanel, log = () => {}, requestImpl }) {
|
|
223
237
|
return async function panelProxy(req, res) {
|
|
224
238
|
const parsed = splitPanelPath(req.url);
|
|
@@ -248,13 +262,13 @@ function createPanelProxy({ resolveCellPanel, log = () => {}, requestImpl }) {
|
|
|
248
262
|
});
|
|
249
263
|
} catch (_) {
|
|
250
264
|
log({ event: 'panel-proxy', outcome: 'error', reason: 'request-failed', cell: parsed.cellId });
|
|
251
|
-
if (!res.headersSent)
|
|
265
|
+
if (!res.headersSent) respondPaginaNonRaggiungibile(res);
|
|
252
266
|
return;
|
|
253
267
|
}
|
|
254
268
|
upstream.setTimeout(PANEL_TIMEOUT_MS, () => upstream.destroy(new Error('panel timeout')));
|
|
255
269
|
upstream.on('error', () => {
|
|
256
270
|
log({ event: 'panel-proxy', outcome: 'error', reason: 'upstream-error', cell: parsed.cellId });
|
|
257
|
-
if (!res.headersSent)
|
|
271
|
+
if (!res.headersSent) respondPaginaNonRaggiungibile(res);
|
|
258
272
|
else res.destroy();
|
|
259
273
|
});
|
|
260
274
|
req.on('aborted', () => upstream.destroy());
|