@mmmbuto/nexuscrew 0.9.59 → 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 +9 -0
- 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/notify/ask-alert-identity.js +24 -0
- package/lib/notify/ask-alert-registry.js +96 -0
- package/lib/notify/asks.js +8 -2
- package/lib/notify/closure-retry.js +5 -4
- package/lib/notify/event-feed-client.js +140 -15
- package/lib/notify/event-feed-producers.js +4 -0
- package/lib/notify/event-feed-routes.js +2 -1
- 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 +26 -16
- package/lib/server.js +70 -20
- package/lib/tmux/list.js +45 -0
- package/lib/tmux-reads.js +44 -0
- package/package.json +1 -1
- package/frontend/dist/assets/index-CZFJnks_.js +0 -169
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');
|
|
@@ -196,18 +200,21 @@ function createServer(opts = {}) {
|
|
|
196
200
|
// alert budget and a durable (owner, eventId) registry — an event already
|
|
197
201
|
// alerted never rings twice, across a replay or a restart. It is fed only by
|
|
198
202
|
// the import hub below, so a local emission never takes this path.
|
|
203
|
+
const askAlertRegistry = createAskAlertRegistry({ filePath: path.join(notifyDir, 'ask-alerts.json') });
|
|
199
204
|
const pushRelay = createPushRelay({
|
|
205
|
+
alertRegistry: askAlertRegistry,
|
|
200
206
|
pushDedup: createPushDedup({ filePath: path.join(notifyDir, 'push-relay-dedup.json') }),
|
|
201
207
|
send: (payload) => pushSvc.sendToAll(payload),
|
|
202
208
|
log: opts.log || console.log,
|
|
203
209
|
});
|
|
204
210
|
const asksStore = createAsksStore({ dir: notifyDir });
|
|
211
|
+
const ownerAskScope = createOwnerAskScope(cfg);
|
|
205
212
|
// --- Event feed (owner side) -------------------------------------------------
|
|
206
213
|
// The notifier stays the single emission point; the feed is a SIDE EFFECT of
|
|
207
214
|
// a local emission, never a separate path. Federated ingress and feed import
|
|
208
215
|
// call deliverOnly(): local delivery that does NOT re-enter the publisher —
|
|
209
216
|
// an imported event can never be re-exported, so A→B→A dies by construction.
|
|
210
|
-
const notifierBase = createNotifier({ hub: eventsHub, push: pushSvc });
|
|
217
|
+
const notifierBase = createNotifier({ hub: eventsHub, push: pushSvc, alertRegistry: askAlertRegistry });
|
|
211
218
|
const feedHistory = createEventFeedHistory({
|
|
212
219
|
filePath: path.join(notifyDir, 'event-feed-history.json'),
|
|
213
220
|
});
|
|
@@ -265,12 +272,17 @@ function createServer(opts = {}) {
|
|
|
265
272
|
// arriva da un altro nodo. Il fan-out e' lo stesso dell'andata e NON parte
|
|
266
273
|
// per un ask importato: la chiusura di quello la decide il suo owner.
|
|
267
274
|
onClosure: (kind, info) => {
|
|
268
|
-
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 (_) {}
|
|
269
281
|
try {
|
|
270
282
|
const ask = asksStore.get(info.askId);
|
|
271
283
|
if (ask && !ask.originNode && closureFanout) {
|
|
272
284
|
return closureFanout.dispatch({
|
|
273
|
-
askId: info.askId,
|
|
285
|
+
askId: info.askId, ownerAskTs: ask.ts,
|
|
274
286
|
outcome: kind === 'ask-dismissed' ? 'dismissed' : 'answered',
|
|
275
287
|
session: (ask && ask.session) || info.cellSession,
|
|
276
288
|
});
|
|
@@ -369,7 +381,7 @@ function createServer(opts = {}) {
|
|
|
369
381
|
try {
|
|
370
382
|
if (frame && frame.type === 'ask' && frame.ask) {
|
|
371
383
|
const ask = frame.ask;
|
|
372
|
-
const cellId =
|
|
384
|
+
const cellId = ownerAskScope.cellForSession(ask.session);
|
|
373
385
|
if (cellId) {
|
|
374
386
|
recordAndPublish({
|
|
375
387
|
producer: 'ask', cellId,
|
|
@@ -379,11 +391,11 @@ function createServer(opts = {}) {
|
|
|
379
391
|
} else if (frame && (frame.type === 'ask-answered' || frame.type === 'ask-dismissed')) {
|
|
380
392
|
const all = asksStore.list({ open: false }).concat(asksStore.list({ open: true }));
|
|
381
393
|
const ask = all.find((a) => a && a.id === frame.id);
|
|
382
|
-
const cellId = ask ?
|
|
394
|
+
const cellId = ask ? ownerAskScope.cellForSession(ask.session) : null;
|
|
383
395
|
if (cellId) {
|
|
384
396
|
recordAndPublish({
|
|
385
397
|
producer: 'ask-closed', cellId,
|
|
386
|
-
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() },
|
|
387
399
|
});
|
|
388
400
|
}
|
|
389
401
|
}
|
|
@@ -393,7 +405,23 @@ function createServer(opts = {}) {
|
|
|
393
405
|
// Delivery-only: federated ingress and feed import land here. NO history,
|
|
394
406
|
// NO publisher — an imported event is never re-exported.
|
|
395
407
|
async deliverOnly(frame) { return notifierBase.emit(frame); },
|
|
396
|
-
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
|
+
},
|
|
397
425
|
publishState: (producer, payload) => recordAndPublish({ producer, cellId: null, payload }),
|
|
398
426
|
};
|
|
399
427
|
const diagnostics = opts.diagnostics || createDiagnostics();
|
|
@@ -1000,13 +1028,24 @@ function createServer(opts = {}) {
|
|
|
1000
1028
|
cellForSession: (session) => cellIdFromTmuxSession(session),
|
|
1001
1029
|
resolveOrigin: (req) => cellScopeOrigin.resolve(req, { requireCell: false }),
|
|
1002
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);
|
|
1003
1046
|
api.get('/sessions', async (_req, res) => {
|
|
1004
1047
|
try {
|
|
1005
|
-
const sessions = await
|
|
1006
|
-
// UNA chiamata per giro per sapere chi ha ancora il suo supervisore.
|
|
1007
|
-
const statiSupervisore = sessions.length
|
|
1008
|
-
? statiSupervisoreDi(await listPanes(cfg.tmuxBin))
|
|
1009
|
-
: new Map();
|
|
1048
|
+
const { sessions, statiSupervisore } = await tmuxReads.read();
|
|
1010
1049
|
const sum = watcher.getSummary();
|
|
1011
1050
|
const enriched = await Promise.all(sessions.map(async (s) => {
|
|
1012
1051
|
const sample = await previews.getState(s.name);
|
|
@@ -1225,7 +1264,8 @@ function createServer(opts = {}) {
|
|
|
1225
1264
|
try {
|
|
1226
1265
|
const closed = asksStore.closeImported({ ownerId, ownerAskId: alias.ownerAskId, outcome: 'dismissed' });
|
|
1227
1266
|
if (closed.changed && closed.ask) {
|
|
1228
|
-
try { notifier.deliverOnlyRaw({ type: 'ask-dismissed', id: closed.ask.id, ownerId, ownerAskId: closed.ask.ownerAskId
|
|
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 (_) {}
|
|
1229
1269
|
}
|
|
1230
1270
|
} catch (_) { /* un alias non chiudibile non ferma gli altri */ }
|
|
1231
1271
|
}
|
|
@@ -1239,8 +1279,8 @@ function createServer(opts = {}) {
|
|
|
1239
1279
|
// verso quelli. Senza, ogni ritentativo rimanderebbe la chiusura anche a chi
|
|
1240
1280
|
// l'ha gia' ricevuta — rumore, non danno, ma nemmeno una coda che sa cosa
|
|
1241
1281
|
// le manca.
|
|
1242
|
-
run: ({ askId, outcome, session, targets }) => (closureFanout
|
|
1243
|
-
? 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 })
|
|
1244
1284
|
: Promise.resolve([])),
|
|
1245
1285
|
});
|
|
1246
1286
|
closureFanout = createClosureFanout({
|
|
@@ -1294,7 +1334,7 @@ function createServer(opts = {}) {
|
|
|
1294
1334
|
asks: asksStore,
|
|
1295
1335
|
answerService: askAnswerService,
|
|
1296
1336
|
receipts: askReceipts,
|
|
1297
|
-
cellForSession:
|
|
1337
|
+
cellForSession: ownerAskScope.cellForSession,
|
|
1298
1338
|
readonly: proxyReadonly,
|
|
1299
1339
|
}));
|
|
1300
1340
|
api.use('/event-feed', createEventFeedRoutes({
|
|
@@ -1308,7 +1348,8 @@ function createServer(opts = {}) {
|
|
|
1308
1348
|
history: feedHistory,
|
|
1309
1349
|
asksStore,
|
|
1310
1350
|
fleetP,
|
|
1311
|
-
cellForSession:
|
|
1351
|
+
cellForSession: ownerAskScope.cellForSession,
|
|
1352
|
+
askScopeResolver: ownerAskScope.snapshotResolver,
|
|
1312
1353
|
eventsEnabled,
|
|
1313
1354
|
// Il produttore dello snapshot logga la lettura fallita degli asks: senza
|
|
1314
1355
|
// questo wiring la riga finiva nel noop di default delle routes.
|
|
@@ -1477,7 +1518,12 @@ function createServer(opts = {}) {
|
|
|
1477
1518
|
// durable write.
|
|
1478
1519
|
const importHub = {
|
|
1479
1520
|
broadcast: (event) => {
|
|
1480
|
-
|
|
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
|
+
}
|
|
1481
1527
|
if (!bridgeReadonly()) pushRelay.considerImported(event);
|
|
1482
1528
|
},
|
|
1483
1529
|
};
|
|
@@ -1488,8 +1534,12 @@ function createServer(opts = {}) {
|
|
|
1488
1534
|
}
|
|
1489
1535
|
}
|
|
1490
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
|
+
},
|
|
1491
1541
|
isAskDismissed: (ownerId, ask) => asksStore.isImportedDismissed(ownerId, ask),
|
|
1492
|
-
onAskClosed: (ownerId, ownerAskId, outcome) => asksStore.closeImported({ ownerId, ownerAskId, outcome }),
|
|
1542
|
+
onAskClosed: (ownerId, ownerAskId, outcome, ownerAskTs) => asksStore.closeImported({ ownerId, ownerAskId, outcome, ownerAskTs }),
|
|
1493
1543
|
loadStore: () => nodesStore.loadStore(nodesPath),
|
|
1494
1544
|
token: () => tokenHolder.value,
|
|
1495
1545
|
eventsHub: importHub,
|
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 };
|