@mmmbuto/nexuscrew 0.9.58 → 0.9.60
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 +16 -0
- package/README.md +32 -8
- package/frontend/dist/assets/index-D42BJVwr.js +169 -0
- package/frontend/dist/index.html +1 -1
- package/frontend/dist/sw.js +18 -4
- package/frontend/dist/version.json +1 -1
- package/lib/audio/dispatch.js +5 -0
- package/lib/fleet/builtin.js +10 -4
- package/lib/fleet/managed.js +76 -47
- package/lib/fleet/runtime.js +15 -1
- package/lib/mcp/tools.js +1 -0
- package/lib/nodes/store.js +19 -1
- package/lib/notify/ask-alert-identity.js +24 -0
- package/lib/notify/ask-alert-registry.js +96 -0
- package/lib/notify/ask-relay.js +64 -14
- package/lib/notify/asks.js +124 -10
- package/lib/notify/closure-retry.js +5 -4
- package/lib/notify/event-feed-asks-routes.js +18 -5
- package/lib/notify/event-feed-client.js +146 -15
- package/lib/notify/event-feed-producers.js +5 -0
- package/lib/notify/event-feed-routes.js +2 -1
- package/lib/notify/imported-dismissals.js +106 -0
- package/lib/notify/notifier.js +19 -7
- package/lib/notify/owner-ask-scope.js +35 -0
- package/lib/notify/push-relay.js +10 -1
- package/lib/notify/routes.js +29 -15
- package/lib/server.js +177 -22
- package/lib/tmux/list.js +45 -0
- package/lib/tmux-reads.js +44 -0
- package/package.json +1 -1
- package/frontend/dist/assets/index-D8cf4AEo.js +0 -169
package/lib/notify/routes.js
CHANGED
|
@@ -44,7 +44,7 @@ const ASK_KEYS = new Set(['question', 'options', 'session', 'target']);
|
|
|
44
44
|
// del nodo di origine, non un chiamante locale (stessa regola di FEDERATED_KEYS).
|
|
45
45
|
// `ownerNode`/`originNode` qualificano l'identita', `askId` e' l'id con cui
|
|
46
46
|
// l'owner conosce la domanda (serve alla risposta per tornare sul bersaglio).
|
|
47
|
-
const FEDERATED_ASK_KEYS = new Set(['askId', 'ownerNode', 'originNode', 'originCell', 'closeOutcome']);
|
|
47
|
+
const FEDERATED_ASK_KEYS = new Set(['askId', 'ownerNode', 'originNode', 'originCell', 'closeOutcome', 'ownerAskTs']);
|
|
48
48
|
const RATE_MAX = 6;
|
|
49
49
|
const RATE_WINDOW_MS = 60 * 1000;
|
|
50
50
|
const RATE_MAX_BUCKETS = 64;
|
|
@@ -160,7 +160,7 @@ function createClosureFanout({
|
|
|
160
160
|
// l'unico lato che puo' riprovare: il ricevente non ha rotta verso l'owner).
|
|
161
161
|
retry = null,
|
|
162
162
|
} = {}) {
|
|
163
|
-
async function dispatch({ askId, outcome, session, retryOnFailure = true, targets = null }) {
|
|
163
|
+
async function dispatch({ askId, ownerAskTs, outcome, session, retryOnFailure = true, targets = null }) {
|
|
164
164
|
if (!dispatcher) return [];
|
|
165
165
|
const self = localNodeId();
|
|
166
166
|
// `targets` esplicito = ritentativo: si va SOLO verso i pendenti, non di
|
|
@@ -178,7 +178,8 @@ function createClosureFanout({
|
|
|
178
178
|
resource: '/asks',
|
|
179
179
|
target,
|
|
180
180
|
origin: { node: self, cell: session || 'unknown' },
|
|
181
|
-
payload: { askId, closeOutcome: outcome, ownerNode: self
|
|
181
|
+
payload: { askId, closeOutcome: outcome, ownerNode: self,
|
|
182
|
+
...(Number.isSafeInteger(ownerAskTs) && ownerAskTs > 0 ? { ownerAskTs } : {}) },
|
|
182
183
|
});
|
|
183
184
|
out.push({ target, status: r && r.status, ...(r && r.reason ? { reason: r.reason } : {}) });
|
|
184
185
|
} catch (e) {
|
|
@@ -197,7 +198,7 @@ function createClosureFanout({
|
|
|
197
198
|
const pendenti = out
|
|
198
199
|
.filter((r) => !CLOSURE_DONE_STATUSES.has(r.status) && !CLOSURE_FINAL_STATUSES.has(r.status))
|
|
199
200
|
.map((r) => r.target);
|
|
200
|
-
try { retry.enqueue({ askId, outcome, session, targets: pendenti }); } catch (_) {}
|
|
201
|
+
try { retry.enqueue({ askId, ownerAskTs, outcome, session, targets: pendenti }); } catch (_) {}
|
|
201
202
|
}
|
|
202
203
|
return out;
|
|
203
204
|
}
|
|
@@ -464,7 +465,7 @@ function notifyRoutes({
|
|
|
464
465
|
if (isClosure) {
|
|
465
466
|
const owner = resolved.origin.node;
|
|
466
467
|
const closed = asks.closeImported({
|
|
467
|
-
ownerId: owner, ownerAskId: b.askId, outcome: b.closeOutcome,
|
|
468
|
+
ownerId: owner, ownerAskId: b.askId, ownerAskTs: b.ownerAskTs, outcome: b.closeOutcome,
|
|
468
469
|
});
|
|
469
470
|
// closeImported rifiuta ({ok:false, reason:'store-unreadable'}) se lo
|
|
470
471
|
// store locale e' illeggibile: l'alias NON e' stato chiuso. Rispondere
|
|
@@ -480,13 +481,17 @@ function notifyRoutes({
|
|
|
480
481
|
const reason = closed.reason || 'store-unreadable';
|
|
481
482
|
return res.status(503).json({ status: 'unavailable', reason });
|
|
482
483
|
}
|
|
483
|
-
if (closed.
|
|
484
|
-
//
|
|
485
|
-
//
|
|
484
|
+
if (!closed.generationMismatch) {
|
|
485
|
+
// The frame must reach the exported view even when the store had
|
|
486
|
+
// nothing to change (alias absent, or already closed by a
|
|
487
|
+
// duplicate): a fresh page rebuilds its cards from that view, and
|
|
488
|
+
// only a generation mismatch may refuse the closure.
|
|
486
489
|
notifier.deliverOnlyRaw({
|
|
487
490
|
type: b.closeOutcome === 'dismissed' ? 'ask-dismissed' : 'ask-answered',
|
|
488
|
-
id: closed.ask.id,
|
|
491
|
+
id: (closed.ask && closed.ask.id) || b.askId,
|
|
489
492
|
ownerId: owner,
|
|
493
|
+
ownerAskId: (closed.ask && closed.ask.ownerAskId) || b.askId,
|
|
494
|
+
...(Number.isSafeInteger(b.ownerAskTs) && b.ownerAskTs > 0 ? { ownerAskTs: b.ownerAskTs } : {}),
|
|
490
495
|
});
|
|
491
496
|
}
|
|
492
497
|
return res.json({ status: 'delivered', closed: closed.changed });
|
|
@@ -506,28 +511,36 @@ function notifyRoutes({
|
|
|
506
511
|
// L'id con cui l'OWNER conosce la domanda: e' quello che la risposta
|
|
507
512
|
// deve citare. Il nostro `id` locale resta nostro.
|
|
508
513
|
ownerAskId: b.askId,
|
|
514
|
+
ownerAskTs: b.ownerAskTs,
|
|
509
515
|
originNode: owner,
|
|
510
516
|
originCell: resolved.origin.cell,
|
|
511
517
|
});
|
|
512
518
|
if (!out.ok) {
|
|
513
519
|
return res.status(out.reason === 'cap' ? 429 : 400).json({ status: 'refused', reason: out.reason, error: out.error });
|
|
514
520
|
}
|
|
521
|
+
if (out.suppressed || out.elided) {
|
|
522
|
+
const reason = out.suppressed ? 'suppressed' : 'elided';
|
|
523
|
+
try { log(`ask ingress ${owner}/${b.askId}: ${reason}, ui=0 push=0`); } catch (_) {}
|
|
524
|
+
return res.json({ status: 'delivered', ...(out.suppressed ? { suppressed: true } : { elided: true }), ownerId: owner, alertStatus: reason, alert: { ui: 0, push: 0, uiAttempted: false, pushAttempted: false } });
|
|
525
|
+
}
|
|
515
526
|
const ask = out.ask;
|
|
516
527
|
// deliverOnly: consegna locale alla UI, NIENTE pubblicazione sul feed e
|
|
517
528
|
// niente ri-esportazione. E' l'invariante che uccide il loop A->B->A per
|
|
518
529
|
// costruzione — un ask importato non torna mai indietro, esattamente
|
|
519
530
|
// come una notify federata.
|
|
520
|
-
notifier.deliverOnlyRaw({ type: 'ask', ask });
|
|
521
|
-
await notifier.deliverOnly({
|
|
531
|
+
const cardUi = notifier.deliverOnlyRaw({ type: 'ask', ask });
|
|
532
|
+
const alert = await notifier.deliverOnly({
|
|
522
533
|
title: `domanda da ${ask.session}`,
|
|
523
534
|
body: ask.question,
|
|
524
535
|
urgency: 'high',
|
|
525
536
|
session: ask.session,
|
|
526
537
|
lang: 'it',
|
|
527
|
-
askId: ask.id,
|
|
538
|
+
ownerId: owner, askId: ask.ownerAskId || ask.id,
|
|
539
|
+
...(Number.isSafeInteger(ask.ownerAskTs) && ask.ownerAskTs > 0 ? { ownerAskTs: ask.ownerAskTs } : { ownerAskFingerprint: asks.askFingerprint(ask) }),
|
|
528
540
|
url: `/#ask=${ask.id}`,
|
|
529
541
|
});
|
|
530
|
-
|
|
542
|
+
try { log(`ask ingress ${owner}/${b.askId}: accepted, cardUi=${cardUi || 0} ui=${alert.ui} push=${alert.push} alert=${alert.alertStatus}`); } catch (_) {}
|
|
543
|
+
return res.json({ status: 'delivered', id: ask.id, ownerId: ask.ownerId, deduped: out.deduped === true, cardUi: cardUi || 0, alertStatus: alert.alertStatus, alert });
|
|
531
544
|
}
|
|
532
545
|
|
|
533
546
|
// --- domanda locale ----------------------------------------------------
|
|
@@ -565,10 +578,11 @@ function notifyRoutes({
|
|
|
565
578
|
...(ask.options ? { options: ask.options } : {}),
|
|
566
579
|
session: ask.session,
|
|
567
580
|
askId: ask.id,
|
|
581
|
+
ownerAskTs: ask.ts,
|
|
568
582
|
ownerNode: self,
|
|
569
583
|
},
|
|
570
584
|
});
|
|
571
|
-
fanout.push({ target, status: r && r.status, ...(r && r.reason ? { reason: r.reason } : {}) });
|
|
585
|
+
fanout.push({ target, status: r && r.status, ...(r && r.reason ? { reason: r.reason } : {}), ...(r && r.alertStatus ? { alertStatus: r.alertStatus, alert: r.alert } : {}), ...(r && r.suppressed === true ? { suppressed: true } : {}), ...(r && r.elided === true ? { elided: true } : {}), ...(r && typeof r.deduped === 'boolean' ? { deduped: r.deduped } : {}) });
|
|
572
586
|
} catch (e) {
|
|
573
587
|
fanout.push({ target, status: 'unknown', reason: 'dispatch-threw' });
|
|
574
588
|
try { log(`ask fan-out verso ${target} fallito: ${String(e && e.message || e)}`); } catch (_) {}
|
|
@@ -588,7 +602,7 @@ function notifyRoutes({
|
|
|
588
602
|
urgency: 'high',
|
|
589
603
|
session: ask.session,
|
|
590
604
|
lang: 'it',
|
|
591
|
-
askId: ask.id,
|
|
605
|
+
ownerId: self, askId: ask.id, ownerAskTs: ask.ts,
|
|
592
606
|
url: `/#ask=${ask.id}`,
|
|
593
607
|
});
|
|
594
608
|
res.status(201).json({ id: ask.id, ...(fanout.length ? { fanout } : {}) });
|
package/lib/server.js
CHANGED
|
@@ -7,7 +7,8 @@ const { execFileSync } = require('node:child_process');
|
|
|
7
7
|
const express = require('express');
|
|
8
8
|
const { WebSocketServer } = require('ws');
|
|
9
9
|
const { defaults, loadConfig, assertLoopback, configJsonPath } = require('./config.js');
|
|
10
|
-
const {
|
|
10
|
+
const { attachedClients, setSessionVisibility, listFleetPanes, sessionsFromPanes } = require('./tmux/list.js');
|
|
11
|
+
const { createTmuxReadWindow } = require('./tmux-reads.js');
|
|
11
12
|
const { statiSupervisore: statiSupervisoreDi, senzaSupervisore } = require('./tmux/supervisor-pane.js');
|
|
12
13
|
const { runAction, pasteToSession, submitToSession } = require('./tmux/actions.js');
|
|
13
14
|
const { createSession, killSession, isProtectedSession } = require('./tmux/lifecycle.js');
|
|
@@ -53,6 +54,7 @@ const { createDispatcher } = require('./audio/dispatch.js');
|
|
|
53
54
|
const { createCellScopeGuard } = require('./cells/scope-guard.js');
|
|
54
55
|
const { createCellScope } = require('./cells/scope.js');
|
|
55
56
|
const { cellIdFromTmuxSession } = require('./fleet/definitions.js');
|
|
57
|
+
const { createOwnerAskScope } = require('./notify/owner-ask-scope.js');
|
|
56
58
|
// Budget a tre finestre, riusato dalle notifiche federate: e' gia' scritto per
|
|
57
59
|
// limitare il danno di un nodo che mente sulla propria cella (il tetto per
|
|
58
60
|
// target si calcola sul NODO, che la catena visited verifica).
|
|
@@ -75,6 +77,8 @@ const { createPushService } = require('./notify/push.js');
|
|
|
75
77
|
const { createPushRelay, createPushDedup } = require('./notify/push-relay.js');
|
|
76
78
|
const { createAsksStore } = require('./notify/asks.js');
|
|
77
79
|
const { createNotifier } = require('./notify/notifier.js');
|
|
80
|
+
const { createAskAlertRegistry } = require('./notify/ask-alert-registry.js');
|
|
81
|
+
const { canonicalAskAlert } = require('./notify/ask-alert-identity.js');
|
|
78
82
|
const { createEventFeed } = require('./notify/event-feed.js');
|
|
79
83
|
const { createEventFeedHistory } = require('./notify/event-feed-history.js');
|
|
80
84
|
const { buildEnvelope } = require('./notify/event-feed-producers.js');
|
|
@@ -85,6 +89,7 @@ const { createAskReceipts } = require('./notify/ask-receipts.js');
|
|
|
85
89
|
const { createAskAnswerService } = require('./notify/ask-answer-service.js');
|
|
86
90
|
const { createEventFeedAsksRoutes } = require('./notify/event-feed-asks-routes.js');
|
|
87
91
|
const { createAskRelay } = require('./notify/ask-relay.js');
|
|
92
|
+
const { createImportedDismissalDrainer } = require('./notify/imported-dismissals.js');
|
|
88
93
|
const { createAskSlotVerifier } = require('./nodes/ask-slot-verifier.js');
|
|
89
94
|
const { readFederationEventsEnabled } = require('./config.js');
|
|
90
95
|
|
|
@@ -195,18 +200,21 @@ function createServer(opts = {}) {
|
|
|
195
200
|
// alert budget and a durable (owner, eventId) registry — an event already
|
|
196
201
|
// alerted never rings twice, across a replay or a restart. It is fed only by
|
|
197
202
|
// the import hub below, so a local emission never takes this path.
|
|
203
|
+
const askAlertRegistry = createAskAlertRegistry({ filePath: path.join(notifyDir, 'ask-alerts.json') });
|
|
198
204
|
const pushRelay = createPushRelay({
|
|
205
|
+
alertRegistry: askAlertRegistry,
|
|
199
206
|
pushDedup: createPushDedup({ filePath: path.join(notifyDir, 'push-relay-dedup.json') }),
|
|
200
207
|
send: (payload) => pushSvc.sendToAll(payload),
|
|
201
208
|
log: opts.log || console.log,
|
|
202
209
|
});
|
|
203
210
|
const asksStore = createAsksStore({ dir: notifyDir });
|
|
211
|
+
const ownerAskScope = createOwnerAskScope(cfg);
|
|
204
212
|
// --- Event feed (owner side) -------------------------------------------------
|
|
205
213
|
// The notifier stays the single emission point; the feed is a SIDE EFFECT of
|
|
206
214
|
// a local emission, never a separate path. Federated ingress and feed import
|
|
207
215
|
// call deliverOnly(): local delivery that does NOT re-enter the publisher —
|
|
208
216
|
// an imported event can never be re-exported, so A→B→A dies by construction.
|
|
209
|
-
const notifierBase = createNotifier({ hub: eventsHub, push: pushSvc });
|
|
217
|
+
const notifierBase = createNotifier({ hub: eventsHub, push: pushSvc, alertRegistry: askAlertRegistry });
|
|
210
218
|
const feedHistory = createEventFeedHistory({
|
|
211
219
|
filePath: path.join(notifyDir, 'event-feed-history.json'),
|
|
212
220
|
});
|
|
@@ -264,12 +272,17 @@ function createServer(opts = {}) {
|
|
|
264
272
|
// arriva da un altro nodo. Il fan-out e' lo stesso dell'andata e NON parte
|
|
265
273
|
// per un ask importato: la chiusura di quello la decide il suo owner.
|
|
266
274
|
onClosure: (kind, info) => {
|
|
267
|
-
try {
|
|
275
|
+
try {
|
|
276
|
+
const ask = asksStore.get(info.askId);
|
|
277
|
+
const ownerAskTs = ask && (ask.originNode ? ask.ownerAskTs : ask.ts);
|
|
278
|
+
notifier.emitRaw({ type: kind, id: info.askId,
|
|
279
|
+
...(Number.isSafeInteger(ownerAskTs) && ownerAskTs > 0 ? { ownerAskTs } : {}) });
|
|
280
|
+
} catch (_) {}
|
|
268
281
|
try {
|
|
269
282
|
const ask = asksStore.get(info.askId);
|
|
270
283
|
if (ask && !ask.originNode && closureFanout) {
|
|
271
284
|
return closureFanout.dispatch({
|
|
272
|
-
askId: info.askId,
|
|
285
|
+
askId: info.askId, ownerAskTs: ask.ts,
|
|
273
286
|
outcome: kind === 'ask-dismissed' ? 'dismissed' : 'answered',
|
|
274
287
|
session: (ask && ask.session) || info.cellSession,
|
|
275
288
|
});
|
|
@@ -368,21 +381,21 @@ function createServer(opts = {}) {
|
|
|
368
381
|
try {
|
|
369
382
|
if (frame && frame.type === 'ask' && frame.ask) {
|
|
370
383
|
const ask = frame.ask;
|
|
371
|
-
const cellId =
|
|
384
|
+
const cellId = ownerAskScope.cellForSession(ask.session);
|
|
372
385
|
if (cellId) {
|
|
373
386
|
recordAndPublish({
|
|
374
387
|
producer: 'ask', cellId,
|
|
375
|
-
payload: { askId: ask.id, revision: ask.revision || 0, question: ask.question, options: ask.options, session: ask.session, ts: Date.now() },
|
|
388
|
+
payload: { askId: ask.id, revision: ask.revision || 0, question: ask.question, options: ask.options, session: ask.session, askTs: ask.ts, ts: Date.now() },
|
|
376
389
|
});
|
|
377
390
|
}
|
|
378
391
|
} else if (frame && (frame.type === 'ask-answered' || frame.type === 'ask-dismissed')) {
|
|
379
392
|
const all = asksStore.list({ open: false }).concat(asksStore.list({ open: true }));
|
|
380
393
|
const ask = all.find((a) => a && a.id === frame.id);
|
|
381
|
-
const cellId = ask ?
|
|
394
|
+
const cellId = ask ? ownerAskScope.cellForSession(ask.session) : null;
|
|
382
395
|
if (cellId) {
|
|
383
396
|
recordAndPublish({
|
|
384
397
|
producer: 'ask-closed', cellId,
|
|
385
|
-
payload: { askId: frame.id, revision: (ask && ask.revision) || 0, outcome: frame.type === 'ask-dismissed' ? 'dismissed' : 'answered', ts: Date.now() },
|
|
398
|
+
payload: { askId: frame.id, askTs: ask.ts, revision: (ask && ask.revision) || 0, outcome: frame.type === 'ask-dismissed' ? 'dismissed' : 'answered', ts: Date.now() },
|
|
386
399
|
});
|
|
387
400
|
}
|
|
388
401
|
}
|
|
@@ -392,7 +405,23 @@ function createServer(opts = {}) {
|
|
|
392
405
|
// Delivery-only: federated ingress and feed import land here. NO history,
|
|
393
406
|
// NO publisher — an imported event is never re-exported.
|
|
394
407
|
async deliverOnly(frame) { return notifierBase.emit(frame); },
|
|
395
|
-
deliverOnlyRaw(frame) {
|
|
408
|
+
deliverOnlyRaw(frame) {
|
|
409
|
+
if (frame.type === 'ask' && frame.ask && frame.ask.ownerId) {
|
|
410
|
+
eventFeedClient.rememberLiveAsk(frame.ask.ownerId, frame.ask);
|
|
411
|
+
}
|
|
412
|
+
// An authenticated owner closure must reach the SAME exported view the
|
|
413
|
+
// live ask entered, even when the store alias was already closed or
|
|
414
|
+
// absent: a fresh page rebuilds its cards from that view. scope:'local'
|
|
415
|
+
// is a local dismissal, not the owner speaking — it keeps its own path.
|
|
416
|
+
if ((frame.type === 'ask-answered' || frame.type === 'ask-dismissed')
|
|
417
|
+
&& frame.ownerId && frame.ownerAskId && frame.scope !== 'local') {
|
|
418
|
+
eventFeedClient.applyOwnerClosure(frame.ownerId, frame.ownerAskId, {
|
|
419
|
+
ownerAskTs: Number.isSafeInteger(frame.ownerAskTs) && frame.ownerAskTs > 0 ? frame.ownerAskTs : undefined,
|
|
420
|
+
outcome: frame.type === 'ask-answered' ? 'answered' : 'dismissed',
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
return notifierBase.emitRaw(frame);
|
|
424
|
+
},
|
|
396
425
|
publishState: (producer, payload) => recordAndPublish({ producer, cellId: null, payload }),
|
|
397
426
|
};
|
|
398
427
|
const diagnostics = opts.diagnostics || createDiagnostics();
|
|
@@ -999,13 +1028,24 @@ function createServer(opts = {}) {
|
|
|
999
1028
|
cellForSession: (session) => cellIdFromTmuxSession(session),
|
|
1000
1029
|
resolveOrigin: (req) => cellScopeOrigin.resolve(req, { requireCell: false }),
|
|
1001
1030
|
}));
|
|
1031
|
+
// UNO spawn per giro. La list-panes -a del fleet snapshot porta
|
|
1032
|
+
// sessioni E supervisori insieme (lib/tmux/list.js), e lo snapshot TMUX
|
|
1033
|
+
// viene coalescato entro la finestra (2 s): letture vicine di finestre o
|
|
1034
|
+
// client diversi condividono lo stesso giro, chi arriva mentre il giro e'
|
|
1035
|
+
// in volo aspetta quello. Oltre la finestra si rilegge. L'ENRICHMENT
|
|
1036
|
+
// (outbox, preview, telemetria, attivita) resta fuori dalla cache: sono
|
|
1037
|
+
// file che cambiano senza toccare tmux, e restano freschi a ogni lettura.
|
|
1038
|
+
const tmuxReads = createTmuxReadWindow(async () => {
|
|
1039
|
+
const panesRaw = await listFleetPanes(cfg.tmuxBin);
|
|
1040
|
+
const sessions = sessionsFromPanes(panesRaw);
|
|
1041
|
+
// La stessa lettura dice chi ha ancora il suo supervisore: campi 0-3
|
|
1042
|
+
// delle stesse righe (parsePanes), nessuna seconda chiamata.
|
|
1043
|
+
const statiSupervisore = sessions.length ? statiSupervisoreDi(panesRaw) : new Map();
|
|
1044
|
+
return { sessions, statiSupervisore };
|
|
1045
|
+
}, cfg.sessionsCacheMs);
|
|
1002
1046
|
api.get('/sessions', async (_req, res) => {
|
|
1003
1047
|
try {
|
|
1004
|
-
const sessions = await
|
|
1005
|
-
// UNA chiamata per giro per sapere chi ha ancora il suo supervisore.
|
|
1006
|
-
const statiSupervisore = sessions.length
|
|
1007
|
-
? statiSupervisoreDi(await listPanes(cfg.tmuxBin))
|
|
1008
|
-
: new Map();
|
|
1048
|
+
const { sessions, statiSupervisore } = await tmuxReads.read();
|
|
1009
1049
|
const sum = watcher.getSummary();
|
|
1010
1050
|
const enriched = await Promise.all(sessions.map(async (s) => {
|
|
1011
1051
|
const sample = await previews.getState(s.name);
|
|
@@ -1224,7 +1264,8 @@ function createServer(opts = {}) {
|
|
|
1224
1264
|
try {
|
|
1225
1265
|
const closed = asksStore.closeImported({ ownerId, ownerAskId: alias.ownerAskId, outcome: 'dismissed' });
|
|
1226
1266
|
if (closed.changed && closed.ask) {
|
|
1227
|
-
try { notifier.deliverOnlyRaw({ type: 'ask-dismissed', id: closed.ask.id, ownerId
|
|
1267
|
+
try { notifier.deliverOnlyRaw({ type: 'ask-dismissed', id: closed.ask.id, ownerId, ownerAskId: closed.ask.ownerAskId,
|
|
1268
|
+
...(Number.isSafeInteger(closed.ask.ownerAskTs) && closed.ask.ownerAskTs > 0 ? { ownerAskTs: closed.ask.ownerAskTs } : {}) }); } catch (_) {}
|
|
1228
1269
|
}
|
|
1229
1270
|
} catch (_) { /* un alias non chiudibile non ferma gli altri */ }
|
|
1230
1271
|
}
|
|
@@ -1238,8 +1279,8 @@ function createServer(opts = {}) {
|
|
|
1238
1279
|
// verso quelli. Senza, ogni ritentativo rimanderebbe la chiusura anche a chi
|
|
1239
1280
|
// l'ha gia' ricevuta — rumore, non danno, ma nemmeno una coda che sa cosa
|
|
1240
1281
|
// le manca.
|
|
1241
|
-
run: ({ askId, outcome, session, targets }) => (closureFanout
|
|
1242
|
-
? closureFanout.dispatch({ askId, outcome, session, retryOnFailure: false, targets })
|
|
1282
|
+
run: ({ askId, ownerAskTs, outcome, session, targets }) => (closureFanout
|
|
1283
|
+
? closureFanout.dispatch({ askId, ownerAskTs, outcome, session, retryOnFailure: false, targets })
|
|
1243
1284
|
: Promise.resolve([])),
|
|
1244
1285
|
});
|
|
1245
1286
|
closureFanout = createClosureFanout({
|
|
@@ -1293,7 +1334,7 @@ function createServer(opts = {}) {
|
|
|
1293
1334
|
asks: asksStore,
|
|
1294
1335
|
answerService: askAnswerService,
|
|
1295
1336
|
receipts: askReceipts,
|
|
1296
|
-
cellForSession:
|
|
1337
|
+
cellForSession: ownerAskScope.cellForSession,
|
|
1297
1338
|
readonly: proxyReadonly,
|
|
1298
1339
|
}));
|
|
1299
1340
|
api.use('/event-feed', createEventFeedRoutes({
|
|
@@ -1307,7 +1348,8 @@ function createServer(opts = {}) {
|
|
|
1307
1348
|
history: feedHistory,
|
|
1308
1349
|
asksStore,
|
|
1309
1350
|
fleetP,
|
|
1310
|
-
cellForSession:
|
|
1351
|
+
cellForSession: ownerAskScope.cellForSession,
|
|
1352
|
+
askScopeResolver: ownerAskScope.snapshotResolver,
|
|
1311
1353
|
eventsEnabled,
|
|
1312
1354
|
// Il produttore dello snapshot logga la lettura fallita degli asks: senza
|
|
1313
1355
|
// questo wiring la riga finiva nel noop di default delle routes.
|
|
@@ -1321,6 +1363,7 @@ function createServer(opts = {}) {
|
|
|
1321
1363
|
// resolves the owner from the authorized store and forwards with the peer's
|
|
1322
1364
|
// own pair token. Uncertain outcomes are parked, never retried silently.
|
|
1323
1365
|
const askRelay = createAskRelay({
|
|
1366
|
+
availabilityBinding: dismissalBinding,
|
|
1324
1367
|
nodesPath, slotVerifier: askReverseVerifier,
|
|
1325
1368
|
loadStore: () => nodesStore.loadStore(nodesPath),
|
|
1326
1369
|
peers: federatedPeers,
|
|
@@ -1334,9 +1377,94 @@ function createServer(opts = {}) {
|
|
|
1334
1377
|
// 400 «ownerId, askId e text richiesti» a OGNI richiesta: la risposta
|
|
1335
1378
|
// federata non ha mai potuto attraversare, e nessun test copriva questa
|
|
1336
1379
|
// rotta. Difetto preesistente, trovato scrivendo il test del ritorno federato.
|
|
1380
|
+
function dismissalBinding(ownerId) {
|
|
1381
|
+
const st = nodesStore.loadStore(nodesPath);
|
|
1382
|
+
if (!st) return null;
|
|
1383
|
+
const direct = st.nodes.filter(node => node.nodeId === ownerId);
|
|
1384
|
+
const cached = topologyCache.loadCache(topologyCachePath);
|
|
1385
|
+
const route = cached && cached.nodes.find(node => node.instanceId === ownerId)?.route;
|
|
1386
|
+
const relevant = direct.length ? direct : st.nodes.filter(node => route && route.includes(node.name));
|
|
1387
|
+
return require('node:crypto').createHash('sha256').update(JSON.stringify(relevant.map(node => ({ nodeId: node.nodeId, direction: node.direction,
|
|
1388
|
+
shared: node.shared, pairingRevision: node.pairingRevision || 0, localPort: node.localPort, reversePool: node.reversePool,
|
|
1389
|
+
configured: accessPresets.grantsOf(node).configured, grants: accessPresets.grantsOf(node).grants }))) + JSON.stringify(route || [])).digest('hex');
|
|
1390
|
+
}
|
|
1391
|
+
function dismissalPeer(ownerId) {
|
|
1392
|
+
const st = nodesStore.loadStore(nodesPath);
|
|
1393
|
+
if (!st) return null;
|
|
1394
|
+
const direct = st.nodes.filter(node => node.nodeId === ownerId);
|
|
1395
|
+
if (direct.length) return direct.length === 1 ? direct[0] : null;
|
|
1396
|
+
// A cached route identifies the first hop, never authorizes it or proves
|
|
1397
|
+
// an outage. The current local grants and channel are checked separately.
|
|
1398
|
+
const cached = topologyCache.loadCache(topologyCachePath);
|
|
1399
|
+
const routes = cached && cached.nodes.filter(node => node.instanceId === ownerId);
|
|
1400
|
+
if (!routes || routes.length !== 1) return null;
|
|
1401
|
+
const first = st.nodes.filter(node => node.name === routes[0].route[0]);
|
|
1402
|
+
return first.length === 1 ? first[0] : null;
|
|
1403
|
+
}
|
|
1404
|
+
function localDismissalEligibility(ownerId, askId) {
|
|
1405
|
+
const peer = dismissalPeer(ownerId);
|
|
1406
|
+
const storedAlias = asksStore.findImported(ownerId, askId);
|
|
1407
|
+
const previous = asksStore.getImportedDismissal(ownerId, askId);
|
|
1408
|
+
const view = eventFeedClient.state().views.find(item => item.ownerId === ownerId);
|
|
1409
|
+
const feedAsk = view && view.asks.find(item => (item.ownerAskId || item.id) === askId);
|
|
1410
|
+
const tsOf = item => { const ts = item && (Object.hasOwn(item, 'ownerAskTs') ? item.ownerAskTs : item.originNode ? null : item.ts); return Number.isSafeInteger(ts) && ts > 0 ? ts : null; };
|
|
1411
|
+
let ask = storedAlias || feedAsk, ambiguous = false;
|
|
1412
|
+
if (storedAlias && feedAsk) {
|
|
1413
|
+
const aliasTs = tsOf(storedAlias), feedTs = tsOf(feedAsk);
|
|
1414
|
+
const sameContent = asksStore.askFingerprint(storedAlias) === asksStore.askFingerprint(feedAsk);
|
|
1415
|
+
if (aliasTs !== null && feedTs !== null && aliasTs !== feedTs) {
|
|
1416
|
+
ask = feedTs > aliasTs ? feedAsk : storedAlias;
|
|
1417
|
+
// An older visible feed cannot be acknowledged through a newer closed
|
|
1418
|
+
// alias. Its current generation needs an authoritative refresh.
|
|
1419
|
+
if (ask === storedAlias && (storedAlias.dismissed || storedAlias.answered)) ambiguous = true;
|
|
1420
|
+
}
|
|
1421
|
+
else if (!sameContent) ambiguous = true;
|
|
1422
|
+
else ask = feedTs !== null && aliasTs === null ? feedAsk : storedAlias;
|
|
1423
|
+
}
|
|
1424
|
+
// Guards and alias mutation belong only to the selected generation.
|
|
1425
|
+
const alias = storedAlias && ask && asksStore.sameImportedGeneration(storedAlias, ask) ? storedAlias : null;
|
|
1426
|
+
const grants = peer && accessPresets.grantsOf(peer);
|
|
1427
|
+
const cellId = ask ? cellIdFromTmuxSession(ask.session) : previous && previous.cellId;
|
|
1428
|
+
const visible = !!((ask || previous) && cellId && grants && grants.configured && grants.grants.eventsAccess === true
|
|
1429
|
+
&& eventFeedAcl.visibilityAllows(grants.grants, cellId));
|
|
1430
|
+
const blocked = !!((ask && ask.answered) || (alias && (asksStore.isAnswering(alias.id) || askReceipts.isBlocked(alias.id)))
|
|
1431
|
+
|| askRelay.state().some(attempt => attempt.ownerId === ownerId && attempt.askId === askId && ['sent', 'uncertain'].includes(attempt.state)));
|
|
1432
|
+
const slot = peer && peer.reversePool && peer.reversePool.slots && peer.reversePool.slots[peer.reversePool.activeSlot];
|
|
1433
|
+
const unavailable = !!(peer && peer.direction === 'inbound' && (!peer.shared || !slot || !Number.isInteger(slot.port)))
|
|
1434
|
+
|| askRelay.channelState(ownerId).unavailable;
|
|
1435
|
+
return { peer, alias, previous, ask, grants, cellId, visible, blocked: blocked || ambiguous, unavailable, ambiguous };
|
|
1436
|
+
}
|
|
1337
1437
|
api.post('/asks-relay', express.json({ limit: '8kb' }), async (req, res) => {
|
|
1338
1438
|
try {
|
|
1339
1439
|
const b = req.body || {};
|
|
1440
|
+
if (b.action === 'dismiss-local') {
|
|
1441
|
+
if (proxyReadonly()) return res.status(403).json({ error: 'READONLY: mutation blocked' });
|
|
1442
|
+
if (req.headers[HOP_HEADER]) return res.status(403).json({ error: 'local operator required' });
|
|
1443
|
+
if (!b || Array.isArray(b) || Object.keys(b).some(key => !['action', 'ownerId', 'askId'].includes(key))
|
|
1444
|
+
|| !/^[a-f0-9]{32}$/.test(b.ownerId) || !/^[a-f0-9]{8}$/.test(b.askId) || b.ownerId === federatedNodeId()) {
|
|
1445
|
+
return res.status(400).json({ error: 'invalid local dismissal body' });
|
|
1446
|
+
}
|
|
1447
|
+
try { if (!asksStore.health().readable) return res.status(503).json({ error: 'dismissal store unavailable' }); }
|
|
1448
|
+
catch (_) { return res.status(503).json({ error: 'dismissal store unavailable' }); }
|
|
1449
|
+
const { peer, alias, previous, ask, grants, cellId, visible, unavailable, ambiguous } = localDismissalEligibility(b.ownerId, b.askId);
|
|
1450
|
+
if (!visible) return res.status(404).json({ error: 'unknown or invisible ask' });
|
|
1451
|
+
if (ambiguous) return res.status(409).json({ error: 'ask generation is ambiguous; refresh the owner view', reason: 'generation-ambiguous' });
|
|
1452
|
+
if (ask && ask.answered) return res.status(409).json({ error: 'ask already answered', reason: 'answered' });
|
|
1453
|
+
if ((alias && (asksStore.isAnswering(alias.id) || askReceipts.isBlocked(alias.id)))
|
|
1454
|
+
|| askRelay.state().some(attempt => attempt.ownerId === b.ownerId && attempt.askId === b.askId && ['sent', 'uncertain'].includes(attempt.state))) {
|
|
1455
|
+
return res.status(409).json({ error: 'answer requires reconciliation', reason: 'delivery-unknown-block' });
|
|
1456
|
+
}
|
|
1457
|
+
if (!ask && previous) return res.json({ dismissed: true, scope: 'local', ownerSync: previous.syncState === 'blocked' ? 'blocked' : 'pending' });
|
|
1458
|
+
if (!unavailable) return res.status(409).json({ error: 'owner channel is not known unavailable', reason: 'owner-availability-unknown' });
|
|
1459
|
+
const out = asksStore.dismissImported({ ownerId: b.ownerId, ownerAskId: b.askId, ask, cellId, retryBinding: dismissalBinding(b.ownerId),
|
|
1460
|
+
syncState: grants.grants.askReplyAccess === true ? 'pending' : 'blocked' });
|
|
1461
|
+
if (!out.ok) return res.status(out.reason === 'dismissal-cap' ? 429 : 503).json({ error: 'local dismissal refused', reason: out.reason });
|
|
1462
|
+
notifier.deliverOnlyRaw({ type: 'ask-dismissed', id: alias ? alias.id : b.askId, ownerId: b.ownerId, ownerAskId: b.askId, scope: 'local',
|
|
1463
|
+
// UI-only context also distinguishes changed content before the first snapshot.
|
|
1464
|
+
askGeneration: { question: ask.question, options: ask.options || [], session: ask.session },
|
|
1465
|
+
ownerAskTs: out.record.ownerAskTs, ownerSync: out.record.syncState === 'blocked' ? 'blocked' : 'pending' });
|
|
1466
|
+
return res.json({ dismissed: true, scope: 'local', ownerSync: out.record.syncState === 'blocked' ? 'blocked' : 'pending' });
|
|
1467
|
+
}
|
|
1340
1468
|
if (b.action === 'dismiss') {
|
|
1341
1469
|
const out = await askRelay.relayDismiss({ ownerId: b.ownerId, askId: b.askId });
|
|
1342
1470
|
// Dismiss CONFERMATO dall'owner (2xx): la view del feed lo dimentica
|
|
@@ -1367,7 +1495,16 @@ function createServer(opts = {}) {
|
|
|
1367
1495
|
const ownerId = typeof req.query.ownerId === 'string' ? req.query.ownerId : '';
|
|
1368
1496
|
const askId = typeof req.query.askId === 'string' ? req.query.askId : '';
|
|
1369
1497
|
if (!/^[a-f0-9]{32}$/.test(ownerId) || !/^[a-f0-9]{8}$/.test(askId)) return res.status(400).json({ error: 'ownerId e askId richiesti' });
|
|
1370
|
-
try {
|
|
1498
|
+
try {
|
|
1499
|
+
const remote = await askRelay.replyCapability({ ownerId, askId });
|
|
1500
|
+
if (req.query.dismissals !== '1') return res.json(remote);
|
|
1501
|
+
const local = localDismissalEligibility(ownerId, askId);
|
|
1502
|
+
const mutable = !proxyReadonly() && !req.headers[HOP_HEADER];
|
|
1503
|
+
let readable = false;
|
|
1504
|
+
try { readable = asksStore.health().readable; } catch (_) {}
|
|
1505
|
+
res.json({ ...remote, canDismissRemote: mutable && remote.canReply === true,
|
|
1506
|
+
canDismissLocal: mutable && readable && local.visible && !local.blocked && (local.unavailable || !!local.previous) });
|
|
1507
|
+
}
|
|
1371
1508
|
catch (_) { res.json({ ownerId, askId, canReply: false, status: 'unreachable' }); }
|
|
1372
1509
|
});
|
|
1373
1510
|
api.get('/asks-relay/state', (_req, res) => {
|
|
@@ -1381,7 +1518,12 @@ function createServer(opts = {}) {
|
|
|
1381
1518
|
// durable write.
|
|
1382
1519
|
const importHub = {
|
|
1383
1520
|
broadcast: (event) => {
|
|
1384
|
-
|
|
1521
|
+
const alert = event && event.type === 'notify' && canonicalAskAlert(event);
|
|
1522
|
+
const claim = alert && !bridgeReadonly() ? askAlertRegistry.claim(event, 'ui') : { allowed: true };
|
|
1523
|
+
if (claim.allowed) {
|
|
1524
|
+
const count = eventsHub.broadcast(event);
|
|
1525
|
+
if (alert && !bridgeReadonly()) askAlertRegistry.complete(event, 'ui', count);
|
|
1526
|
+
}
|
|
1385
1527
|
if (!bridgeReadonly()) pushRelay.considerImported(event);
|
|
1386
1528
|
},
|
|
1387
1529
|
};
|
|
@@ -1392,6 +1534,12 @@ function createServer(opts = {}) {
|
|
|
1392
1534
|
}
|
|
1393
1535
|
}
|
|
1394
1536
|
const eventFeedClient = createEventFeedClient({
|
|
1537
|
+
onViewChanged: ownerId => eventsHub.broadcast({ type: 'feed-state-changed', ownerId }),
|
|
1538
|
+
onAuthoritativeAsks: (ownerId, asks) => {
|
|
1539
|
+
if (!bridgeReadonly()) for (const ask of asks) askAlertRegistry.adopt(ownerId, ask, ask.ownerAskTs === undefined ? ask.ts : ask.ownerAskTs);
|
|
1540
|
+
},
|
|
1541
|
+
isAskDismissed: (ownerId, ask) => asksStore.isImportedDismissed(ownerId, ask),
|
|
1542
|
+
onAskClosed: (ownerId, ownerAskId, outcome, ownerAskTs) => asksStore.closeImported({ ownerId, ownerAskId, outcome, ownerAskTs }),
|
|
1395
1543
|
loadStore: () => nodesStore.loadStore(nodesPath),
|
|
1396
1544
|
token: () => tokenHolder.value,
|
|
1397
1545
|
eventsHub: importHub,
|
|
@@ -1404,6 +1552,11 @@ function createServer(opts = {}) {
|
|
|
1404
1552
|
pollMs: cfg.eventFeedClientPollMs,
|
|
1405
1553
|
});
|
|
1406
1554
|
eventFeedClient.start();
|
|
1555
|
+
const importedDismissalDrainer = createImportedDismissalDrainer({
|
|
1556
|
+
store: asksStore, relay: askRelay, readonly: proxyReadonly, log: opts.log || console.log,
|
|
1557
|
+
bindingForOwner: dismissalBinding,
|
|
1558
|
+
});
|
|
1559
|
+
|
|
1407
1560
|
// Local aggregated read for the UI: connected views only, with the cursor
|
|
1408
1561
|
// watermark so the first fetch cannot race the live stream. Local
|
|
1409
1562
|
// authenticated route, NOT in the federation allowlist.
|
|
@@ -1617,6 +1770,7 @@ function createServer(opts = {}) {
|
|
|
1617
1770
|
});
|
|
1618
1771
|
// Store-root revision of the peer access policy: the CAS value the UI
|
|
1619
1772
|
// must send back with every preset write (stale revision -> 409).
|
|
1773
|
+
importedDismissalDrainer.nudge();
|
|
1620
1774
|
res.json({ nodeId: view.nodeId, nodes, accessRevision: nodesStore.accessRevisionOf(st) });
|
|
1621
1775
|
} catch (e) { res.status(500).json({ error: String(e.message || e) }); }
|
|
1622
1776
|
});
|
|
@@ -1748,13 +1902,14 @@ function createServer(opts = {}) {
|
|
|
1748
1902
|
diagnostics.record('info', 'server', 'SERVER_STARTED', 'NexusCrew server started', {
|
|
1749
1903
|
port: runtimePort(), platform: cfg.platform || process.platform,
|
|
1750
1904
|
});
|
|
1751
|
-
updater.start(); startManagedTunnels();
|
|
1905
|
+
updater.start(); startManagedTunnels(); importedDismissalDrainer.start();
|
|
1752
1906
|
});
|
|
1753
1907
|
server.on('close', () => {
|
|
1754
1908
|
diagnostics.record('info', 'server', 'SERVER_STOPPED', 'NexusCrew server stopped', { reason: 'close' });
|
|
1755
1909
|
watcher.close(); previews.close(); eventsHub.closeAll(); updater.close(); ptyGrace.close();
|
|
1756
1910
|
// Nessun timer di ritentativo sopravvive alla chiusura del server.
|
|
1757
1911
|
if (closureRetry) { try { closureRetry.stop(); } catch (_) {} }
|
|
1912
|
+
importedDismissalDrainer.stop(); eventFeedClient.stop();
|
|
1758
1913
|
for (const timer of reverseWatchers.values()) clearInterval(timer);
|
|
1759
1914
|
reverseWatchers.clear(); rotatableReverse.clear(); void reverseSlotListeners?.closeAll();
|
|
1760
1915
|
// Il pannello non sopravvive al control plane: senza requireToken sopra,
|
package/lib/tmux/list.js
CHANGED
|
@@ -108,6 +108,50 @@ function listSessions(tmuxBin = 'tmux') {
|
|
|
108
108
|
});
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
// Il formato "fleet snapshot": i campi del supervisore di PANES_FMT (primi
|
|
112
|
+
// quattro, gli stessi che legge parsePanes) SEGUITI dai campi della sessione.
|
|
113
|
+
// UNA sola chiamata list-panes -a alimenta sia lo stato dei supervisori sia
|
|
114
|
+
// l'inventario sessioni di /api/sessions: due spawn per giro diventano uno.
|
|
115
|
+
// L'ordine dei campi sessione rispecchia FMT (name, attached, windows,
|
|
116
|
+
// created, activity, cmd, visibility, titolo), con i due marker del pane
|
|
117
|
+
// attivo prima del titolo.
|
|
118
|
+
const FLEET_PANES_FMT = `${PANES_FMT}\t#{session_attached}\t#{session_windows}\t#{session_created}\t#{session_activity}\t#{pane_current_command}\t#{@nexuscrew_visibility}\t#{window_active}\t#{pane_active}\t#{pane_title}`;
|
|
119
|
+
|
|
120
|
+
// Dalle righe per-pane alle righe per-SESSIONE: per ogni nome tiene il pane
|
|
121
|
+
// ATTIVO (window_active=1 e pane_active=1) — lo stesso pane a cui si
|
|
122
|
+
// riferivano i formati pane_* di list-sessions. I campi sessione sono uguali
|
|
123
|
+
// su tutte le righe dello stesso nome; cambia solo quale pane parla, e deve
|
|
124
|
+
// parlare quello attivo.
|
|
125
|
+
function sessionsFromPanes(raw) {
|
|
126
|
+
const rows = new Map();
|
|
127
|
+
for (const line of String(raw).split('\n')) {
|
|
128
|
+
if (!line.trim()) continue;
|
|
129
|
+
const f = line.split('\t');
|
|
130
|
+
const name = f[0];
|
|
131
|
+
if (!name) continue;
|
|
132
|
+
const attiva = f[10] === '1' && f[11] === '1';
|
|
133
|
+
const previous = rows.get(name);
|
|
134
|
+
if (previous && (!attiva || previous.attiva)) continue;
|
|
135
|
+
rows.set(name, {
|
|
136
|
+
attiva,
|
|
137
|
+
line: [f[0], f[4], f[5], f[6], f[7], f[8], f[9], f.slice(12).join('\t')].join('\t'),
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
return parseSessions([...rows.values()].map((r) => r.line).join('\n'));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function listFleetPanes(tmuxBin = 'tmux') {
|
|
144
|
+
return new Promise((resolve, reject) => {
|
|
145
|
+
execFile(tmuxBin, ['list-panes', '-a', '-F', FLEET_PANES_FMT], (err, stdout, stderr) => {
|
|
146
|
+
if (err) {
|
|
147
|
+
if (isNoTmuxServerError(stderr || err.message)) return resolve('');
|
|
148
|
+
return reject(new Error(`tmux list-panes failed: ${stderr || err.message}`));
|
|
149
|
+
}
|
|
150
|
+
resolve(stdout);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
111
155
|
// How many clients are already attached to this session (before our attach).
|
|
112
156
|
// Used to pick a sane resize default: drive the size only when no one else is watching.
|
|
113
157
|
function attachedClients(tmuxBin = 'tmux', session) {
|
|
@@ -123,4 +167,5 @@ function attachedClients(tmuxBin = 'tmux', session) {
|
|
|
123
167
|
|
|
124
168
|
module.exports = {
|
|
125
169
|
parsePaneTitle, parseSessions, isNoTmuxServerError, listSessions, listPanes, attachedClients, setSessionVisibility, FMT,
|
|
170
|
+
FLEET_PANES_FMT, sessionsFromPanes, listFleetPanes,
|
|
126
171
|
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Coalescing della risposta di /api/sessions (finestra di 2 s, decisione
|
|
4
|
+
// operativa: latenza percepita al piu' pari alla finestra in cambio di spawn
|
|
5
|
+
// dimezzati — anzi, di UNO spawn per finestra condiviso fra client).
|
|
6
|
+
//
|
|
7
|
+
// Tre comportamenti, tutti voluti:
|
|
8
|
+
// - letture entro la finestra servono la risposta gia' pronta: zero spawn;
|
|
9
|
+
// - letture che arrivano mentre il giro e' in volo aspettano QUELLO giro
|
|
10
|
+
// (mai due serie di spawn contemporanee per lo stesso endpoint);
|
|
11
|
+
// - un errore non si cache-a: il giro fallito non lascia nulla e la
|
|
12
|
+
// prossima lettura riparte da zero.
|
|
13
|
+
//
|
|
14
|
+
// La finestra e' breve PERCHE' la cache non deve nascondere i cambi di tmux:
|
|
15
|
+
// kill, avvio e cambio finestra diventano visibili al piu' tardi al primo
|
|
16
|
+
// giro dopo la finestra. Il test di freschezza custodisce esattamente questo.
|
|
17
|
+
|
|
18
|
+
// Finestra di coalescing in millisecondi. Il valore e' un contratto
|
|
19
|
+
// operativo (decisione approvata): al piu' 2 s di latenza aggiuntiva sulla
|
|
20
|
+
// vista sessioni, in cambio di una serie sola di spawn per finestra.
|
|
21
|
+
const TMUX_READS_WINDOW_MS = 2000;
|
|
22
|
+
|
|
23
|
+
function createTmuxReadWindow(loader, windowMs = TMUX_READS_WINDOW_MS) {
|
|
24
|
+
let cached = null;
|
|
25
|
+
let inFlight = null;
|
|
26
|
+
return {
|
|
27
|
+
async read() {
|
|
28
|
+
if (cached && Date.now() - cached.at < windowMs) return cached.value;
|
|
29
|
+
if (inFlight) return inFlight;
|
|
30
|
+
inFlight = (async () => {
|
|
31
|
+
try {
|
|
32
|
+
const value = await loader();
|
|
33
|
+
cached = { at: Date.now(), value };
|
|
34
|
+
return value;
|
|
35
|
+
} finally {
|
|
36
|
+
inFlight = null;
|
|
37
|
+
}
|
|
38
|
+
})();
|
|
39
|
+
return inFlight;
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = { TMUX_READS_WINDOW_MS, createTmuxReadWindow };
|