@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
|
@@ -88,6 +88,8 @@ function createEventFeedClient(opts = {}) {
|
|
|
88
88
|
: MIN_SNAPSHOT_INTERVAL_MS;
|
|
89
89
|
// ownerId -> view state {cursor, generation, asks, notifications, fleetState,
|
|
90
90
|
// stale, lastError}; generation guards against obsolete fetches.
|
|
91
|
+
const isAskDismissed = typeof opts.isAskDismissed === 'function' ? opts.isAskDismissed : () => false;
|
|
92
|
+
const visibleAsks = (ownerId, asks) => asks.filter(ask => !isAskDismissed(ownerId, ask));
|
|
91
93
|
const views = new Map();
|
|
92
94
|
const running = new Map(); // nodeId -> {abort}
|
|
93
95
|
let stopped = false;
|
|
@@ -148,23 +150,29 @@ function createEventFeedClient(opts = {}) {
|
|
|
148
150
|
|
|
149
151
|
function askTombstoneKey(ownerId, askId) { return `${ownerId}|${askId}`; }
|
|
150
152
|
|
|
151
|
-
function noteAskClosed(ownerId, askId, epoch) {
|
|
153
|
+
function noteAskClosed(ownerId, askId, epoch, ownerAskTs, ask) {
|
|
152
154
|
if (!ownerId || !askId) return;
|
|
153
|
-
askTombstones.set(askTombstoneKey(ownerId, askId), { at: now(), epoch
|
|
155
|
+
askTombstones.set(askTombstoneKey(ownerId, askId), { at: now(), epoch, ownerAskTs,
|
|
156
|
+
fingerprint: ask && ask.question !== undefined ? JSON.stringify([ask.question, ask.options || [], ask.session]) : null });
|
|
157
|
+
if (askTombstones.size > 4096) askTombstones.delete(askTombstones.keys().next().value);
|
|
154
158
|
}
|
|
155
159
|
|
|
156
|
-
function askIsClosed(ownerId, askId, epoch) {
|
|
160
|
+
function askIsClosed(ownerId, askId, epoch, ask) {
|
|
157
161
|
const key = askTombstoneKey(ownerId, askId);
|
|
158
162
|
const hit = askTombstones.get(key);
|
|
159
163
|
if (!hit) return false;
|
|
160
164
|
if (now() - hit.at > ASK_TOMBSTONE_TTL_MS) { askTombstones.delete(key); return false; }
|
|
161
165
|
if (typeof hit.epoch === 'number' && typeof epoch === 'number' && hit.epoch !== epoch) return false;
|
|
166
|
+
const ts = ask && (Object.hasOwn(ask, 'ownerAskTs') ? ask.ownerAskTs : ask.ts);
|
|
167
|
+
if (Number.isSafeInteger(hit.ownerAskTs) && hit.ownerAskTs > 0 && Number.isSafeInteger(ts) && ts > 0 && hit.ownerAskTs !== ts) return false;
|
|
168
|
+
if (!hit.ownerAskTs && hit.fingerprint !== null && ask && hit.fingerprint !== JSON.stringify([ask.question, ask.options || [], ask.session])) return false;
|
|
162
169
|
return true;
|
|
163
170
|
}
|
|
164
171
|
|
|
165
172
|
function dropAskFromView(ownerId, askId) {
|
|
166
173
|
const view = viewFor(ownerId);
|
|
167
174
|
view.asks = view.asks.filter((a) => (a.ownerAskId || a.id) !== askId);
|
|
175
|
+
if (view.alertAsks) view.alertAsks.delete(askId);
|
|
168
176
|
return view;
|
|
169
177
|
}
|
|
170
178
|
|
|
@@ -225,14 +233,16 @@ function createEventFeedClient(opts = {}) {
|
|
|
225
233
|
// ask: the tombstone is what keeps it from coming back.
|
|
226
234
|
view.asks = Array.isArray(snap.asks)
|
|
227
235
|
? snap.asks
|
|
228
|
-
.filter((a) => a && !askIsClosed(ownerId, a.ownerAskId || a.id, snap.viewEpoch))
|
|
236
|
+
.filter((a) => a && !askIsClosed(ownerId, a.ownerAskId || a.id, snap.viewEpoch, a) && !isAskDismissed(ownerId, a))
|
|
229
237
|
.map((a) => ({ ...a, ownerId }))
|
|
230
238
|
: [];
|
|
239
|
+
view.alertAsks = new Map(); // fresh snapshot replaces all live-only alert sources
|
|
231
240
|
view.notifications = Array.isArray(snap.notifications) ? snap.notifications.map((e) => ({ ...e, ownerId })) : [];
|
|
232
241
|
view.fleetState = snap.fleetState || null;
|
|
233
242
|
view.askReplyAccess = snap.askReplyAccess === true;
|
|
234
243
|
view.viewEpoch = snap.viewEpoch;
|
|
235
244
|
view.stale = false;
|
|
245
|
+
if (typeof opts.onAuthoritativeAsks === 'function') opts.onAuthoritativeAsks(ownerId, view.asks);
|
|
236
246
|
// Lo snapshot fresco è la guarigione visibile: stale torna falso e
|
|
237
247
|
// l'ultimo errore cessa di essere mostrato. Lo streak NON si tocca qui:
|
|
238
248
|
// il conto dei reset misura i round SENZA frame sano, e ogni round di
|
|
@@ -240,9 +250,70 @@ function createEventFeedClient(opts = {}) {
|
|
|
240
250
|
// tappo anti-reset. Lo sblocco dello streak sta alla scadenza del cooldown.
|
|
241
251
|
view.lastError = null;
|
|
242
252
|
view.generation = gen;
|
|
253
|
+
if (typeof opts.onViewChanged === 'function') {
|
|
254
|
+
try { opts.onViewChanged(ownerId); } catch (_) { /* UI refresh cannot invalidate the applied view. */ }
|
|
255
|
+
}
|
|
243
256
|
return true;
|
|
244
257
|
}
|
|
245
258
|
|
|
259
|
+
// Both authenticated ingress paths must update the same exported owner
|
|
260
|
+
// collection. Recording a fan-out ASK neither publishes nor fabricates a
|
|
261
|
+
// feed cursor; the next genuinely new owner snapshot can replace it.
|
|
262
|
+
function rememberLiveAsk(ownerId, source) {
|
|
263
|
+
const askId = source.ownerAskId || source.id;
|
|
264
|
+
const ask = { ...source, id: askId, ownerId,
|
|
265
|
+
ownerAskTs: Object.hasOwn(source, 'ownerAskTs') ? source.ownerAskTs : source.ts };
|
|
266
|
+
const view = viewFor(ownerId);
|
|
267
|
+
if (isAskDismissed(ownerId, ask)
|
|
268
|
+
|| askIsClosed(ownerId, askId, view.viewEpoch, ask)) return null;
|
|
269
|
+
// The exported collection must follow both ingress paths, even when the
|
|
270
|
+
// feed cursor has not yet advanced, or a cache falsely certifies absence.
|
|
271
|
+
const currentIndex = view.asks.findIndex(a => (a.ownerAskId || a.id) === askId);
|
|
272
|
+
const current = currentIndex >= 0 ? view.asks[currentIndex] : null;
|
|
273
|
+
const currentTs = current && (Object.hasOwn(current, 'ownerAskTs') ? current.ownerAskTs : current.ts);
|
|
274
|
+
if (current && Number.isSafeInteger(currentTs) && currentTs > 0
|
|
275
|
+
&& (!ask.ownerAskTs || ask.ownerAskTs < currentTs)) return null;
|
|
276
|
+
if (currentIndex >= 0) view.asks[currentIndex] = ask;
|
|
277
|
+
else if (view.asks.length < SNAPSHOT_MAX_ASKS) view.asks.push(ask);
|
|
278
|
+
else {
|
|
279
|
+
// Never certify an incomplete collection or silently evict a card.
|
|
280
|
+
view.stale = true; view.lastError = 'snapshot-asks-cap';
|
|
281
|
+
}
|
|
282
|
+
if (!view.stale) {
|
|
283
|
+
if (!view.alertAsks) view.alertAsks = new Map();
|
|
284
|
+
view.alertAsks.delete(askId); view.alertAsks.set(askId, ask);
|
|
285
|
+
while (view.alertAsks.size > SNAPSHOT_MAX_ASKS) view.alertAsks.delete(view.alertAsks.keys().next().value);
|
|
286
|
+
}
|
|
287
|
+
view.liveAskRevision = (view.liveAskRevision || 0) + 1;
|
|
288
|
+
return ask;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// An authenticated owner closure (fan-out frame or feed envelope) lands on
|
|
292
|
+
// the SAME exported view the live ask entered — whether or not the store
|
|
293
|
+
// alias changed. The key is canonical (ownerId, ownerAskId); a closure of a
|
|
294
|
+
// different generation closes nothing. The tombstone blocks the CLOSED
|
|
295
|
+
// generation for both outcomes, and the revision bump rejects a snapshot
|
|
296
|
+
// acquisition that started before the closure: an anterior read must never
|
|
297
|
+
// certify the card back.
|
|
298
|
+
function applyOwnerClosure(ownerId, ownerAskId, { ownerAskTs, outcome, ask } = {}) {
|
|
299
|
+
if (!ownerId || !ownerAskId) return { applied: false };
|
|
300
|
+
const view = viewFor(ownerId);
|
|
301
|
+
const current = view.asks.find((a) => (a.ownerAskId || a.id) === ownerAskId)
|
|
302
|
+
|| (view.alertAsks && view.alertAsks.get(ownerAskId)) || null;
|
|
303
|
+
const currentTs = current && (Object.hasOwn(current, 'ownerAskTs') ? current.ownerAskTs : current.ts);
|
|
304
|
+
const closureTs = Number.isSafeInteger(ownerAskTs) && ownerAskTs > 0 ? ownerAskTs : null;
|
|
305
|
+
if (closureTs && Number.isSafeInteger(currentTs) && currentTs > 0 && currentTs !== closureTs) {
|
|
306
|
+
return { applied: false, mismatch: true };
|
|
307
|
+
}
|
|
308
|
+
dropAskFromView(ownerId, ownerAskId);
|
|
309
|
+
noteAskClosed(ownerId, ownerAskId, view.viewEpoch, closureTs || undefined, current || ask);
|
|
310
|
+
view.liveAskRevision = (view.liveAskRevision || 0) + 1;
|
|
311
|
+
if (typeof opts.onViewChanged === 'function') {
|
|
312
|
+
try { opts.onViewChanged(ownerId); } catch (_) { /* UI refresh cannot invalidate the closure. */ }
|
|
313
|
+
}
|
|
314
|
+
return { applied: true };
|
|
315
|
+
}
|
|
316
|
+
|
|
246
317
|
// Local re-emission of ONE imported envelope: attributed to the owner, into
|
|
247
318
|
// the local SSE hub only. The hub is the browser-facing surface — this call
|
|
248
319
|
// deliberately bypasses the notifier so the imported event can never be
|
|
@@ -254,25 +325,59 @@ function createEventFeedClient(opts = {}) {
|
|
|
254
325
|
if (!envelope.frame || envelope.hop !== 1) return false;
|
|
255
326
|
const f = envelope.frame;
|
|
256
327
|
if (f.type === 'notify') {
|
|
328
|
+
const { canonicalAskAlert, alertFields } = require('./ask-alert-identity.js');
|
|
329
|
+
let alertFrame = { ...f, ownerId: envelope.ownerId };
|
|
330
|
+
let historicalAsk = null;
|
|
331
|
+
if (f.askId && !canonicalAskAlert(alertFrame)) {
|
|
332
|
+
const view = views.get(envelope.ownerId);
|
|
333
|
+
// A legacy notification has no generation. Only a fresh full owner
|
|
334
|
+
// ASK can supply its fingerprint; emission time never supplies a ts.
|
|
335
|
+
const source = view && !view.stale && ((view.alertAsks && view.alertAsks.get(f.askId)) || view.asks.find(a => a.id === f.askId));
|
|
336
|
+
historicalAsk = source && source.question === f.body ? source : null;
|
|
337
|
+
if (historicalAsk) {
|
|
338
|
+
const ownerAskTs = historicalAsk.ownerAskTs === undefined ? historicalAsk.ts : historicalAsk.ownerAskTs;
|
|
339
|
+
alertFrame = { ...alertFrame, ownerAskFingerprint: require('./asks.js').askFingerprint(historicalAsk),
|
|
340
|
+
...(Number.isSafeInteger(ownerAskTs) && ownerAskTs > 0 ? { ownerAskTs } : {}) };
|
|
341
|
+
if (typeof opts.onAuthoritativeAsks === 'function') opts.onAuthoritativeAsks(envelope.ownerId, [historicalAsk]);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
257
344
|
opts.eventsHub.broadcast({
|
|
258
345
|
type: 'notify', title: f.title, ...(f.body ? { body: f.body } : {}),
|
|
259
346
|
urgency: f.urgency === 'high' ? 'high' : 'normal',
|
|
260
347
|
...(f.lang ? { lang: f.lang } : {}),
|
|
261
348
|
...(f.askId ? { askId: String(f.askId) } : {}),
|
|
349
|
+
...alertFields(alertFrame),
|
|
262
350
|
originNode: envelope.ownerId, ownerId: envelope.ownerId,
|
|
263
351
|
...(envelope.scope === 'cell' && envelope.cellId ? { originCell: envelope.cellId } : {}),
|
|
264
352
|
eventId: envelope.eventId, ts: Date.now(),
|
|
265
353
|
});
|
|
354
|
+
// The first legacy alert may have created an unknown admission during
|
|
355
|
+
// broadcast. Bind that admission before any following known replay.
|
|
356
|
+
if (historicalAsk && typeof opts.onAuthoritativeAsks === 'function') opts.onAuthoritativeAsks(envelope.ownerId, [historicalAsk]);
|
|
266
357
|
} else if (f.type === 'ask') {
|
|
358
|
+
const ask = { id: f.askId, question: f.question, options: f.options, session: f.session, ts: f.ts,
|
|
359
|
+
ownerAskTs: Number.isSafeInteger(f.askTs) && f.askTs > 0 ? f.askTs : null, ownerId: envelope.ownerId };
|
|
360
|
+
if (!rememberLiveAsk(envelope.ownerId, ask)) return false;
|
|
361
|
+
if (typeof opts.onAuthoritativeAsks === 'function') opts.onAuthoritativeAsks(envelope.ownerId, [ask]);
|
|
267
362
|
opts.eventsHub.broadcast({ type: 'ask', ownerId: envelope.ownerId, eventId: envelope.eventId,
|
|
268
|
-
ask
|
|
363
|
+
ask });
|
|
269
364
|
} else if (f.type === 'ask-closed') {
|
|
365
|
+
const ownerAskTs = Number.isSafeInteger(f.askTs) && f.askTs > 0 ? f.askTs : null;
|
|
366
|
+
const view = viewFor(envelope.ownerId);
|
|
367
|
+
const current = view.asks.find(a => (a.ownerAskId || a.id) === f.askId) || (view.alertAsks && view.alertAsks.get(f.askId));
|
|
368
|
+
const currentTs = current && (Object.hasOwn(current, 'ownerAskTs') ? current.ownerAskTs : current.ts);
|
|
369
|
+
if (ownerAskTs && Number.isSafeInteger(currentTs) && currentTs > 0 && currentTs !== ownerAskTs) return false;
|
|
370
|
+
if (typeof opts.onAskClosed === 'function') {
|
|
371
|
+
const closed = opts.onAskClosed(envelope.ownerId, f.askId, f.outcome, ownerAskTs);
|
|
372
|
+
if (closed && closed.generationMismatch === true) return false;
|
|
373
|
+
}
|
|
270
374
|
// The owner says the ask is closed: the VIEW drops it too, not only the
|
|
271
|
-
// UI, or the next state read hands the card back.
|
|
272
|
-
|
|
273
|
-
|
|
375
|
+
// UI, or the next state read hands the card back. Same function as the
|
|
376
|
+
// fan-out path: one closure semantics, one exported collection.
|
|
377
|
+
applyOwnerClosure(envelope.ownerId, f.askId, { ownerAskTs, outcome: f.outcome });
|
|
274
378
|
opts.eventsHub.broadcast({ type: f.outcome === 'dismissed' ? 'ask-dismissed' : 'ask-answered',
|
|
275
|
-
id: f.askId, ownerId: envelope.ownerId, eventId: envelope.eventId
|
|
379
|
+
id: f.askId, ownerAskId: f.askId, ownerId: envelope.ownerId, eventId: envelope.eventId,
|
|
380
|
+
...(ownerAskTs ? { ownerAskTs } : {}) });
|
|
276
381
|
} else if (f.type === 'file-notice') {
|
|
277
382
|
// Stessa forma della consegna locale (lib/files/routes.js): il nome del
|
|
278
383
|
// file e' SEMPRE nel corpo, con la caption quando c'e'. Prima l'importato
|
|
@@ -582,11 +687,17 @@ function createEventFeedClient(opts = {}) {
|
|
|
582
687
|
view.lastSnapshotAt = now();
|
|
583
688
|
view.nextSnapshotAllowedAt = null;
|
|
584
689
|
const gen = generation;
|
|
690
|
+
const acquisitionCursor = view.cursor;
|
|
691
|
+
const acquisitionAskRevision = view.liveAskRevision || 0;
|
|
585
692
|
const { r, text } = await withDeadline(slot, 8000, async (signal) => {
|
|
586
693
|
const r = await fetchImpl(routeUrl(peer, '/event-feed/snapshot'), {
|
|
587
694
|
headers: identityHeaders(peer, store), signal,
|
|
588
695
|
});
|
|
589
|
-
if (!r.ok)
|
|
696
|
+
if (!r.ok) {
|
|
697
|
+
const error = new Error(`snapshot HTTP ${r.status}`);
|
|
698
|
+
error.snapshotReason = r.status === 503 ? 'asks-unreadable' : `http-${r.status}`;
|
|
699
|
+
throw error;
|
|
700
|
+
}
|
|
590
701
|
return { r, text: await r.text() };
|
|
591
702
|
});
|
|
592
703
|
if ((slot && !active(ownerId, slot)) || gen !== generation) throw new Error('obsolete acquisition');
|
|
@@ -609,6 +720,13 @@ function createEventFeedClient(opts = {}) {
|
|
|
609
720
|
view.stale = true; view.lastError = decision.reason;
|
|
610
721
|
return { state: 'failed' };
|
|
611
722
|
}
|
|
723
|
+
// An event consumed during this acquisition is later than the start of
|
|
724
|
+
// the read. Neither the view nor imported alias decisions may use that
|
|
725
|
+
// older response to certify an ASK's absence.
|
|
726
|
+
if (view.cursor !== acquisitionCursor || (view.liveAskRevision || 0) !== acquisitionAskRevision) {
|
|
727
|
+
view.stale = true; view.lastError = 'snapshot-superseded';
|
|
728
|
+
return { state: 'failed' };
|
|
729
|
+
}
|
|
612
730
|
if (!forView.ok) {
|
|
613
731
|
if (profile !== 'view') {
|
|
614
732
|
// Autorevole per la chiusura, non applicabile alla vista: la vista
|
|
@@ -780,7 +898,7 @@ function createEventFeedClient(opts = {}) {
|
|
|
780
898
|
lastError: v.lastError || null,
|
|
781
899
|
ingressBlockedUntil: v.ingressBlockedUntil || null,
|
|
782
900
|
ingressBlockReason: v.ingressBlockReason || null,
|
|
783
|
-
asks: v.asks, notifications: v.notifications, fleetState: v.fleetState,
|
|
901
|
+
asks: visibleAsks(ownerId, v.asks), notifications: v.notifications, fleetState: v.fleetState,
|
|
784
902
|
})),
|
|
785
903
|
};
|
|
786
904
|
}
|
|
@@ -827,14 +945,27 @@ function createEventFeedClient(opts = {}) {
|
|
|
827
945
|
// l'elenco che la decisione usa — e solo quello. La vista, nello
|
|
828
946
|
// stesso snapshotOnce, applica il suo schema completo o resta ferma.
|
|
829
947
|
state = await Promise.race([snapshotOnce(peer, id, store, { profile: 'decision' }), doorTimeoutPromise()]);
|
|
830
|
-
} catch (
|
|
831
|
-
|
|
948
|
+
} catch (error) {
|
|
949
|
+
const reason = error && error.snapshotReason || 'transport';
|
|
950
|
+
const view = viewFor(id);
|
|
951
|
+
view.stale = true; view.lastError = reason;
|
|
952
|
+
if (typeof opts.onViewChanged === 'function') {
|
|
953
|
+
try { opts.onViewChanged(id); } catch (_) { /* Diagnostics cannot replace the acquisition failure. */ }
|
|
954
|
+
}
|
|
955
|
+
return { status: 'error', reason };
|
|
832
956
|
} finally {
|
|
833
957
|
doorInFlight.delete(id);
|
|
834
958
|
}
|
|
835
959
|
const view = views.get(id);
|
|
836
960
|
const retryAt = (view && typeof view.lastSnapshotAt === 'number' ? view.lastSnapshotAt : now()) + minSnapshotIntervalMs;
|
|
837
|
-
if (state === null)
|
|
961
|
+
if (state === null) {
|
|
962
|
+
const pendingView = viewFor(id);
|
|
963
|
+
pendingView.stale = true; pendingView.lastError = 'snapshot-timeout';
|
|
964
|
+
if (typeof opts.onViewChanged === 'function') {
|
|
965
|
+
try { opts.onViewChanged(id); } catch (_) { /* A pending owner acquisition remains non-authoritative. */ }
|
|
966
|
+
}
|
|
967
|
+
return { status: 'pending', reason: 'door-timeout', retryAt };
|
|
968
|
+
}
|
|
838
969
|
if (state.state === 'skipped') return { status: 'skipped', reason: 'window', retryAt };
|
|
839
970
|
if (state.state === 'applied') {
|
|
840
971
|
// L'elenco autorevole è quello dello snapshot: anche quando la vista
|
|
@@ -878,7 +1009,7 @@ function createEventFeedClient(opts = {}) {
|
|
|
878
1009
|
}
|
|
879
1010
|
}
|
|
880
1011
|
|
|
881
|
-
return { start, stop, poll, state, reemit, viewFor, dismissConfirmed, ownerSnapshotAsks };
|
|
1012
|
+
return { start, stop, poll, state, reemit, viewFor, dismissConfirmed, ownerSnapshotAsks, rememberLiveAsk, applyOwnerClosure };
|
|
882
1013
|
}
|
|
883
1014
|
|
|
884
1015
|
module.exports = { createEventFeedClient };
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// coverage is proved by tests, never by silent omission.
|
|
10
10
|
|
|
11
11
|
const crypto = require('node:crypto');
|
|
12
|
+
const { alertFields } = require('./ask-alert-identity.js');
|
|
12
13
|
|
|
13
14
|
const CELL_ID_RE = /^[A-Za-z0-9._-]{1,32}$/;
|
|
14
15
|
const FRAME_MAX_BYTES = 16 * 1024;
|
|
@@ -38,6 +39,7 @@ const PRODUCERS = {
|
|
|
38
39
|
requires: ['cellId'],
|
|
39
40
|
build: (p) => ({
|
|
40
41
|
type: 'notify',
|
|
42
|
+
...alertFields(p),
|
|
41
43
|
title: cleanStr(p.title, 200) || '',
|
|
42
44
|
...(p.body !== undefined ? { body: cleanStr(p.body, 2000) || '' } : {}),
|
|
43
45
|
urgency: p.urgency === 'high' ? 'high' : 'normal',
|
|
@@ -55,6 +57,7 @@ const PRODUCERS = {
|
|
|
55
57
|
requires: [],
|
|
56
58
|
build: (p) => ({
|
|
57
59
|
type: 'notify',
|
|
60
|
+
...alertFields(p),
|
|
58
61
|
title: cleanStr(p.title, 200) || '',
|
|
59
62
|
...(p.body !== undefined ? { body: cleanStr(p.body, 2000) || '' } : {}),
|
|
60
63
|
urgency: p.urgency === 'high' ? 'high' : 'normal',
|
|
@@ -74,6 +77,7 @@ const PRODUCERS = {
|
|
|
74
77
|
question: cleanStr(p.question, 3900) || '',
|
|
75
78
|
...(Array.isArray(p.options) ? { options: p.options.slice(0, 8).map((o) => cleanStr(o, 200) || '') } : {}),
|
|
76
79
|
session: cleanStr(p.session, 128) || undefined,
|
|
80
|
+
...(Number.isSafeInteger(p.askTs) && p.askTs > 0 ? { askTs: p.askTs } : {}),
|
|
77
81
|
ts: typeof p.ts === 'number' ? p.ts : undefined,
|
|
78
82
|
}),
|
|
79
83
|
},
|
|
@@ -86,6 +90,7 @@ const PRODUCERS = {
|
|
|
86
90
|
askId: cleanStr(p.askId, 32) || '',
|
|
87
91
|
revision: typeof p.revision === 'number' ? p.revision : 0,
|
|
88
92
|
outcome: p.outcome === 'answered' || p.outcome === 'dismissed' ? p.outcome : 'answered',
|
|
93
|
+
...(Number.isSafeInteger(p.askTs) && p.askTs > 0 ? { askTs: p.askTs } : {}),
|
|
89
94
|
ts: typeof p.ts === 'number' ? p.ts : undefined,
|
|
90
95
|
}),
|
|
91
96
|
},
|
|
@@ -223,6 +223,7 @@ function createEventFeedRoutes(deps) {
|
|
|
223
223
|
err.reason = 'asks-unreadable';
|
|
224
224
|
throw err;
|
|
225
225
|
}
|
|
226
|
+
const cellForSession = deps.askScopeResolver ? deps.askScopeResolver() : deps.cellForSession;
|
|
226
227
|
const self = typeof deps.localNodeId === 'function' ? deps.localNodeId() : null;
|
|
227
228
|
const open = deps.asksStore.list({ open: true }) || [];
|
|
228
229
|
const visible = open
|
|
@@ -234,7 +235,7 @@ function createEventFeedRoutes(deps) {
|
|
|
234
235
|
// visibilita' della cella.
|
|
235
236
|
.filter((a) => !a.originNode && !(a.ownerId && self && a.ownerId !== self))
|
|
236
237
|
.map((a) => ({ id: a.id, question: a.question, options: a.options, session: a.session, ts: a.ts }))
|
|
237
|
-
.filter((a) => peer.allows({ scope: 'cell', cellId:
|
|
238
|
+
.filter((a) => peer.allows({ scope: 'cell', cellId: cellForSession(a.session) }))
|
|
238
239
|
.slice(0, SNAPSHOT_MAX_ASKS);
|
|
239
240
|
asks = visible;
|
|
240
241
|
} catch (e) {
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Durable receiver-to-owner synchronization. No attempt may submit an answer.
|
|
3
|
+
function createImportedDismissalDrainer({ store, relay, now = Date.now, random = Math.random,
|
|
4
|
+
setTimer = setTimeout, clearTimer = clearTimeout, intervalMs = 15000,
|
|
5
|
+
globalBudget = 4, ownerBudget = 1, budgetWindowMs = 60000, bindingForOwner = () => null, readonly = () => false, log = () => {} } = {}) {
|
|
6
|
+
const flights = new Set();
|
|
7
|
+
const globalHits = [];
|
|
8
|
+
const ownerHits = new Map();
|
|
9
|
+
let stopped = false;
|
|
10
|
+
let timer = null;
|
|
11
|
+
let running = null;
|
|
12
|
+
function defer(record, reason, blocked = false, retryAfterMs = 0) {
|
|
13
|
+
const attempts = (record.attempts || 0) + 1;
|
|
14
|
+
const delay = Math.max(retryAfterMs, Math.min(300000, 1000 * 2 ** Math.min(attempts, 9)) * (0.5 + random() * 0.5));
|
|
15
|
+
return store.updateImportedDismissal(record.ownerId, record.ownerAskId, {
|
|
16
|
+
syncState: blocked ? 'blocked' : 'pending', retryBinding: bindingForOwner(record.ownerId), attempts, lastReason: reason || 'owner-unreachable', nextRetryAt: now() + delay,
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
function rejected(record, result = {}) {
|
|
20
|
+
const reason = result.reason || 'owner-unreachable';
|
|
21
|
+
const transient = ['answering', 'delivery-unknown-block', 'reverse-slot-unverified', 'relay-deadline', 'owner-unreachable', 'federation-peer-unreachable'];
|
|
22
|
+
const pending = transient.includes(reason) || result.code === 429 || result.code >= 500;
|
|
23
|
+
return defer(record, reason, !pending, result.retryAfterMs || 0);
|
|
24
|
+
}
|
|
25
|
+
function confirmed(record, outcome) {
|
|
26
|
+
return store.updateImportedDismissal(record.ownerId, record.ownerAskId, {
|
|
27
|
+
syncState: outcome === 'answered' ? 'confirmed-answered' : 'confirmed-dismissed', ownerOutcome: outcome, lastReason: null,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
async function attempt(record) {
|
|
31
|
+
const key = `${record.ownerId}|${record.ownerAskId}`;
|
|
32
|
+
if (flights.has(key) || stopped || readonly()) return;
|
|
33
|
+
flights.add(key);
|
|
34
|
+
try {
|
|
35
|
+
let current = store.getImportedDismissal(record.ownerId, record.ownerAskId);
|
|
36
|
+
if (!current || current.syncState !== 'pending') return;
|
|
37
|
+
const result = await relay.inspectDismissal({ ownerId: record.ownerId, askId: record.ownerAskId });
|
|
38
|
+
if (stopped || readonly()) return;
|
|
39
|
+
current = store.getImportedDismissal(record.ownerId, record.ownerAskId);
|
|
40
|
+
if (!current || current.syncState !== 'pending' || current.dismissedTs !== record.dismissedTs) return;
|
|
41
|
+
if (!result.ok) { rejected(current, result); return; }
|
|
42
|
+
if (result.generationPrecondition !== true) { defer(current, 'unsupported', true); return; }
|
|
43
|
+
if (!result.ask || (result.ask.ownerAskId || result.ask.id) !== current.ownerAskId
|
|
44
|
+
|| store.askFingerprint(result.ask) !== current.ownerAskFingerprint
|
|
45
|
+
|| !Number.isSafeInteger(result.ask.ts) || result.ask.ts <= 0
|
|
46
|
+
|| (current.generation === 'known' && current.ownerAskTs !== result.ask.ts)) {
|
|
47
|
+
defer(current, 'generation-mismatch', true); return;
|
|
48
|
+
}
|
|
49
|
+
const adopted = store.adoptImportedDismissal(current.ownerId, current.ownerAskId, result.ask);
|
|
50
|
+
if (!adopted.ok) { log(`dismissal adoption refused: ${adopted.reason}`); return; }
|
|
51
|
+
current = adopted.record;
|
|
52
|
+
if (['answered', 'dismissed'].includes(result.status)) { confirmed(current, result.status); return; }
|
|
53
|
+
if (result.status !== 'open') { rejected(current, { code: 409, reason: result.status || 'unknown' }); return; }
|
|
54
|
+
// The timestamp is durable before the request and checked atomically by
|
|
55
|
+
// the owner. A lost response leaves the record pending for reconciliation.
|
|
56
|
+
const out = await relay.relayDismiss({ ownerId: current.ownerId, askId: current.ownerAskId, expectedTs: current.ownerAskTs });
|
|
57
|
+
if (stopped || readonly()) return;
|
|
58
|
+
const latest = store.getImportedDismissal(current.ownerId, current.ownerAskId);
|
|
59
|
+
if (!latest || latest.syncState !== 'pending' || latest.dismissedTs !== current.dismissedTs) return;
|
|
60
|
+
if (out.ok && ['answered', 'dismissed'].includes(out.outcome)) confirmed(latest, out.outcome);
|
|
61
|
+
else rejected(latest, out.ok ? { code: 409, reason: 'unsupported' } : out);
|
|
62
|
+
} catch (error) {
|
|
63
|
+
const current = store.getImportedDismissal(record.ownerId, record.ownerAskId);
|
|
64
|
+
if (!stopped && current && current.syncState === 'pending') defer(current, 'owner-unreachable');
|
|
65
|
+
log(`dismissal synchronization failed: ${String(error.message || error)}`);
|
|
66
|
+
} finally { flights.delete(key); }
|
|
67
|
+
}
|
|
68
|
+
async function run() {
|
|
69
|
+
if (stopped || readonly()) return;
|
|
70
|
+
const t = now();
|
|
71
|
+
const prune = hits => { while (hits.length && hits[0] <= t - budgetWindowMs) hits.shift(); };
|
|
72
|
+
prune(globalHits);
|
|
73
|
+
for (const [ownerId, hits] of ownerHits) { prune(hits); if (!hits.length) ownerHits.delete(ownerId); }
|
|
74
|
+
for (let record of store.listImportedDismissals()) {
|
|
75
|
+
if (globalHits.length >= globalBudget) break;
|
|
76
|
+
if (record.syncState === 'blocked' && record.lastReason !== 'generation-mismatch') {
|
|
77
|
+
const binding = bindingForOwner(record.ownerId);
|
|
78
|
+
if (binding !== null && binding !== record.retryBinding) {
|
|
79
|
+
const resumed = store.updateImportedDismissal(record.ownerId, record.ownerAskId, { syncState: 'pending', nextRetryAt: 0, retryBinding: binding });
|
|
80
|
+
if (resumed.ok) record = resumed.record;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (record.syncState !== 'pending' || (record.nextRetryAt || 0) > now()) continue;
|
|
84
|
+
const hits = ownerHits.get(record.ownerId) || [];
|
|
85
|
+
if (hits.length >= ownerBudget) continue;
|
|
86
|
+
hits.push(t); ownerHits.set(record.ownerId, hits); globalHits.push(t);
|
|
87
|
+
await attempt(record);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function drain() {
|
|
91
|
+
if (running) return running;
|
|
92
|
+
const flight = run().finally(() => { if (running === flight) running = null; });
|
|
93
|
+
running = flight;
|
|
94
|
+
return flight;
|
|
95
|
+
}
|
|
96
|
+
function schedule() {
|
|
97
|
+
if (stopped || timer) return;
|
|
98
|
+
timer = setTimer(() => { timer = null; void drain().catch(error => log(String(error.message || error))).finally(schedule); }, intervalMs);
|
|
99
|
+
if (timer && typeof timer.unref === 'function') timer.unref();
|
|
100
|
+
}
|
|
101
|
+
function start() { schedule(); void drain().catch(error => log(String(error.message || error))); }
|
|
102
|
+
function stop() { stopped = true; if (timer) clearTimer(timer); timer = null; }
|
|
103
|
+
function nudge() { if (!stopped) void drain().catch(error => log(String(error.message || error))); }
|
|
104
|
+
return { start, stop, drain, nudge };
|
|
105
|
+
}
|
|
106
|
+
module.exports = { createImportedDismissalDrainer };
|
package/lib/notify/notifier.js
CHANGED
|
@@ -3,11 +3,15 @@
|
|
|
3
3
|
// web-push. Reused by the /api/notify route, by the asks (high urgency) and
|
|
4
4
|
// by the outbox file delivery — one point that knows both channels.
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
const { alertFields } = require('./ask-alert-identity.js');
|
|
7
|
+
|
|
8
|
+
function createNotifier({ hub, push, alertRegistry }) {
|
|
7
9
|
// frame: {title, body?, urgency?, session?, lang?, url?}. Ritorna {ui, push} (conteggi).
|
|
8
10
|
async function emit(frame) {
|
|
9
|
-
const
|
|
11
|
+
const uiClaim = alertRegistry ? alertRegistry.claim(frame, 'ui') : { allowed: true };
|
|
12
|
+
const ui = uiClaim.allowed ? hub.broadcast({
|
|
10
13
|
type: 'notify',
|
|
14
|
+
...alertFields(frame),
|
|
11
15
|
title: String(frame.title || ''),
|
|
12
16
|
...(frame.body ? { body: String(frame.body) } : {}),
|
|
13
17
|
urgency: frame.urgency === 'high' ? 'high' : 'normal',
|
|
@@ -21,18 +25,26 @@ function createNotifier({ hub, push }) {
|
|
|
21
25
|
...(frame.originNode ? { originNode: String(frame.originNode) } : {}),
|
|
22
26
|
...(frame.originCell ? { originCell: String(frame.originCell) } : {}),
|
|
23
27
|
ts: Date.now(),
|
|
24
|
-
});
|
|
25
|
-
|
|
28
|
+
}) : 0;
|
|
29
|
+
if (uiClaim.allowed && alertRegistry) alertRegistry.complete(frame, 'ui', ui);
|
|
30
|
+
let pushed = 0; let pushReason = null;
|
|
31
|
+
const pushClaim = alertRegistry ? alertRegistry.claim(frame, 'push') : { allowed: true };
|
|
26
32
|
try {
|
|
33
|
+
if (pushClaim.allowed) {
|
|
27
34
|
const r = await push.sendToAll({
|
|
35
|
+
...alertFields(frame),
|
|
28
36
|
title: String(frame.title || ''),
|
|
29
37
|
...(frame.body ? { body: String(frame.body) } : {}),
|
|
30
38
|
...(frame.lang ? { lang: String(frame.lang) } : {}),
|
|
31
39
|
url: typeof frame.url === 'string' ? frame.url : '/',
|
|
32
40
|
});
|
|
33
|
-
pushed = r.sent;
|
|
34
|
-
|
|
35
|
-
|
|
41
|
+
pushed = r && Number.isFinite(r.sent) ? r.sent : 0;
|
|
42
|
+
if (!pushed) pushReason = 'no-delivery';
|
|
43
|
+
} else pushReason = pushClaim.reason;
|
|
44
|
+
} catch (_) { pushReason = 'send-failed'; }
|
|
45
|
+
if (pushClaim.allowed && alertRegistry) alertRegistry.complete(frame, 'push', pushed);
|
|
46
|
+
if (!frame.askId) return { ui, push: pushed };
|
|
47
|
+
return { ui, push: pushed, uiAttempted: uiClaim.allowed === true, pushAttempted: pushClaim.allowed === true, alertStatus: ui > 0 || pushed > 0 ? 'delivered' : 'no-delivery', ...(pushReason ? { pushReason } : {}) };
|
|
36
48
|
}
|
|
37
49
|
|
|
38
50
|
// Frame di servizio solo-UI (es. {type:'ask-answered', id}): nessun push.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
const { loadDefinitions, cellIdFromTmuxSession } = require('../fleet/definitions.js');
|
|
4
|
+
|
|
5
|
+
// The owner resolves local definitions, never a cell id supplied by a peer.
|
|
6
|
+
// Activity and tmux polling do not participate in ASK authorization.
|
|
7
|
+
function resolveOwnerSession(session, cells) {
|
|
8
|
+
if (typeof session !== 'string' || !session) return null;
|
|
9
|
+
const matches = cells.filter(cell => cell.tmuxSession === session);
|
|
10
|
+
if (matches.length > 1) return null;
|
|
11
|
+
if (matches.length === 1) return matches[0].id;
|
|
12
|
+
return cellIdFromTmuxSession(session);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function createOwnerAskScope(cfg) {
|
|
16
|
+
const enabled = cfg.fleetEnabled !== false && cfg.builtinEnabled !== false;
|
|
17
|
+
const definitionsPath = enabled ? (cfg.fleetDefsPath || path.join(cfg.home, '.nexuscrew', 'fleet.json')) : null;
|
|
18
|
+
function read() {
|
|
19
|
+
if (!enabled) return { available: true, cells: [] };
|
|
20
|
+
const definitions = loadDefinitions(definitionsPath);
|
|
21
|
+
return definitions ? { available: true, cells: definitions.cells } : { available: false, cells: [] };
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
snapshotResolver: () => {
|
|
25
|
+
const definitions = read();
|
|
26
|
+
if (!definitions.available) throw new Error('owner ASK scope unavailable');
|
|
27
|
+
return session => resolveOwnerSession(session, definitions.cells);
|
|
28
|
+
},
|
|
29
|
+
cellForSession: session => {
|
|
30
|
+
const definitions = read();
|
|
31
|
+
return definitions.available ? resolveOwnerSession(session, definitions.cells) : null;
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
module.exports = { createOwnerAskScope, resolveOwnerSession };
|
package/lib/notify/push-relay.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
const fs = require('node:fs');
|
|
19
19
|
const path = require('node:path');
|
|
20
|
+
const { alertFields } = require('./ask-alert-identity.js');
|
|
20
21
|
|
|
21
22
|
const QUEUE_MAX_ALERTS = 32;
|
|
22
23
|
const QUEUE_MAX_BYTES = 128 * 1024;
|
|
@@ -60,6 +61,7 @@ function buildPayload(envelope) {
|
|
|
60
61
|
...(preview ? { body: truncated ? `${preview}…` : preview } : {}),
|
|
61
62
|
...(f.lang ? { lang: f.lang } : {}),
|
|
62
63
|
tag: `nc:${envelope.ownerId}:${envelope.eventId}`,
|
|
64
|
+
...alertFields({ ...f, ownerId: envelope.ownerId }),
|
|
63
65
|
url,
|
|
64
66
|
ownerId: envelope.ownerId,
|
|
65
67
|
eventId: envelope.eventId,
|
|
@@ -104,7 +106,7 @@ function createPushDedup({ filePath, now = Date.now } = {}) {
|
|
|
104
106
|
|
|
105
107
|
// `send` is the push sender call (pushService.sendToAll wrapped with the
|
|
106
108
|
// timeout). Everything else is queue + budget + counters.
|
|
107
|
-
function createPushRelay({ pushDedup, send, now = Date.now, sendTimeoutMs = SEND_TIMEOUT_MS, log = () => {} } = {}) {
|
|
109
|
+
function createPushRelay({ pushDedup, send, alertRegistry, now = Date.now, sendTimeoutMs = SEND_TIMEOUT_MS, log = () => {} } = {}) {
|
|
108
110
|
const queue = [];
|
|
109
111
|
let queueBytes = 0;
|
|
110
112
|
let active = 0;
|
|
@@ -147,6 +149,7 @@ function createPushRelay({ pushDedup, send, now = Date.now, sendTimeoutMs = SEND
|
|
|
147
149
|
send(item.payload),
|
|
148
150
|
new Promise((resolve) => setTimeout(() => resolve({ timeout: true }), sendTimeoutMs)),
|
|
149
151
|
]);
|
|
152
|
+
if (alertRegistry) alertRegistry.complete(item.payload, 'push', out && out.timeout ? 'uncertain' : out && out.sent || 0);
|
|
150
153
|
const own = forOwner(item.payload && item.payload.ownerId);
|
|
151
154
|
if (out && out.timeout) {
|
|
152
155
|
counters.sendFailed += 1; own.sendFailed += 1; own.lastReason = 'send-timeout';
|
|
@@ -154,6 +157,7 @@ function createPushRelay({ pushDedup, send, now = Date.now, sendTimeoutMs = SEND
|
|
|
154
157
|
} else if (out && out.sent > 0) { counters.pushed += out.sent; own.pushed += out.sent; }
|
|
155
158
|
else { counters.sendFailed += 1; own.sendFailed += 1; own.lastReason = 'send-failed'; }
|
|
156
159
|
} catch (_) {
|
|
160
|
+
if (alertRegistry) alertRegistry.complete(item.payload, 'push', 0);
|
|
157
161
|
counters.sendFailed += 1;
|
|
158
162
|
const own = forOwner(item.payload && item.payload.ownerId);
|
|
159
163
|
own.sendFailed += 1; own.lastReason = 'send-failed';
|
|
@@ -194,6 +198,10 @@ function createPushRelay({ pushDedup, send, now = Date.now, sendTimeoutMs = SEND
|
|
|
194
198
|
own.dropped += 1; own.lastReason = 'dedup-persist-failed';
|
|
195
199
|
return false;
|
|
196
200
|
}
|
|
201
|
+
if (alertRegistry) {
|
|
202
|
+
const claim = alertRegistry.claim(payload, 'push');
|
|
203
|
+
if (!claim.allowed) { counters.dropped += 1; const own = forOwner(envelope.ownerId); own.dropped += 1; own.lastReason = claim.reason; return false; }
|
|
204
|
+
}
|
|
197
205
|
queue.push({ payload, bytes });
|
|
198
206
|
queueBytes += bytes;
|
|
199
207
|
void flush();
|
|
@@ -216,6 +224,7 @@ function createPushRelay({ pushDedup, send, now = Date.now, sendTimeoutMs = SEND
|
|
|
216
224
|
frame: {
|
|
217
225
|
type: 'notify', title: event.title, body: event.body, lang: event.lang,
|
|
218
226
|
...(event.askId ? { askId: String(event.askId) } : {}),
|
|
227
|
+
...alertFields(event),
|
|
219
228
|
},
|
|
220
229
|
});
|
|
221
230
|
}
|