@mmmbuto/nexuscrew 0.9.59 → 0.9.61
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 +14 -0
- package/frontend/dist/assets/index-VtTd56fc.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 +12 -6
- package/lib/fleet/definitions.js +15 -0
- 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 +34 -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
|
@@ -150,23 +150,29 @@ function createEventFeedClient(opts = {}) {
|
|
|
150
150
|
|
|
151
151
|
function askTombstoneKey(ownerId, askId) { return `${ownerId}|${askId}`; }
|
|
152
152
|
|
|
153
|
-
function noteAskClosed(ownerId, askId, epoch) {
|
|
153
|
+
function noteAskClosed(ownerId, askId, epoch, ownerAskTs, ask) {
|
|
154
154
|
if (!ownerId || !askId) return;
|
|
155
|
-
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);
|
|
156
158
|
}
|
|
157
159
|
|
|
158
|
-
function askIsClosed(ownerId, askId, epoch) {
|
|
160
|
+
function askIsClosed(ownerId, askId, epoch, ask) {
|
|
159
161
|
const key = askTombstoneKey(ownerId, askId);
|
|
160
162
|
const hit = askTombstones.get(key);
|
|
161
163
|
if (!hit) return false;
|
|
162
164
|
if (now() - hit.at > ASK_TOMBSTONE_TTL_MS) { askTombstones.delete(key); return false; }
|
|
163
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;
|
|
164
169
|
return true;
|
|
165
170
|
}
|
|
166
171
|
|
|
167
172
|
function dropAskFromView(ownerId, askId) {
|
|
168
173
|
const view = viewFor(ownerId);
|
|
169
174
|
view.asks = view.asks.filter((a) => (a.ownerAskId || a.id) !== askId);
|
|
175
|
+
if (view.alertAsks) view.alertAsks.delete(askId);
|
|
170
176
|
return view;
|
|
171
177
|
}
|
|
172
178
|
|
|
@@ -227,14 +233,16 @@ function createEventFeedClient(opts = {}) {
|
|
|
227
233
|
// ask: the tombstone is what keeps it from coming back.
|
|
228
234
|
view.asks = Array.isArray(snap.asks)
|
|
229
235
|
? snap.asks
|
|
230
|
-
.filter((a) => a && !askIsClosed(ownerId, a.ownerAskId || a.id, snap.viewEpoch) && !isAskDismissed(ownerId, a))
|
|
236
|
+
.filter((a) => a && !askIsClosed(ownerId, a.ownerAskId || a.id, snap.viewEpoch, a) && !isAskDismissed(ownerId, a))
|
|
231
237
|
.map((a) => ({ ...a, ownerId }))
|
|
232
238
|
: [];
|
|
239
|
+
view.alertAsks = new Map(); // fresh snapshot replaces all live-only alert sources
|
|
233
240
|
view.notifications = Array.isArray(snap.notifications) ? snap.notifications.map((e) => ({ ...e, ownerId })) : [];
|
|
234
241
|
view.fleetState = snap.fleetState || null;
|
|
235
242
|
view.askReplyAccess = snap.askReplyAccess === true;
|
|
236
243
|
view.viewEpoch = snap.viewEpoch;
|
|
237
244
|
view.stale = false;
|
|
245
|
+
if (typeof opts.onAuthoritativeAsks === 'function') opts.onAuthoritativeAsks(ownerId, view.asks);
|
|
238
246
|
// Lo snapshot fresco è la guarigione visibile: stale torna falso e
|
|
239
247
|
// l'ultimo errore cessa di essere mostrato. Lo streak NON si tocca qui:
|
|
240
248
|
// il conto dei reset misura i round SENZA frame sano, e ogni round di
|
|
@@ -242,9 +250,70 @@ function createEventFeedClient(opts = {}) {
|
|
|
242
250
|
// tappo anti-reset. Lo sblocco dello streak sta alla scadenza del cooldown.
|
|
243
251
|
view.lastError = null;
|
|
244
252
|
view.generation = gen;
|
|
253
|
+
if (typeof opts.onViewChanged === 'function') {
|
|
254
|
+
try { opts.onViewChanged(ownerId); } catch (_) { /* UI refresh cannot invalidate the applied view. */ }
|
|
255
|
+
}
|
|
245
256
|
return true;
|
|
246
257
|
}
|
|
247
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
|
+
|
|
248
317
|
// Local re-emission of ONE imported envelope: attributed to the owner, into
|
|
249
318
|
// the local SSE hub only. The hub is the browser-facing surface — this call
|
|
250
319
|
// deliberately bypasses the notifier so the imported event can never be
|
|
@@ -256,29 +325,59 @@ function createEventFeedClient(opts = {}) {
|
|
|
256
325
|
if (!envelope.frame || envelope.hop !== 1) return false;
|
|
257
326
|
const f = envelope.frame;
|
|
258
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
|
+
}
|
|
259
344
|
opts.eventsHub.broadcast({
|
|
260
345
|
type: 'notify', title: f.title, ...(f.body ? { body: f.body } : {}),
|
|
261
346
|
urgency: f.urgency === 'high' ? 'high' : 'normal',
|
|
262
347
|
...(f.lang ? { lang: f.lang } : {}),
|
|
263
348
|
...(f.askId ? { askId: String(f.askId) } : {}),
|
|
349
|
+
...alertFields(alertFrame),
|
|
264
350
|
originNode: envelope.ownerId, ownerId: envelope.ownerId,
|
|
265
351
|
...(envelope.scope === 'cell' && envelope.cellId ? { originCell: envelope.cellId } : {}),
|
|
266
352
|
eventId: envelope.eventId, ts: Date.now(),
|
|
267
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]);
|
|
268
357
|
} else if (f.type === 'ask') {
|
|
269
358
|
const ask = { id: f.askId, question: f.question, options: f.options, session: f.session, ts: f.ts,
|
|
270
359
|
ownerAskTs: Number.isSafeInteger(f.askTs) && f.askTs > 0 ? f.askTs : null, ownerId: envelope.ownerId };
|
|
271
|
-
if (
|
|
360
|
+
if (!rememberLiveAsk(envelope.ownerId, ask)) return false;
|
|
361
|
+
if (typeof opts.onAuthoritativeAsks === 'function') opts.onAuthoritativeAsks(envelope.ownerId, [ask]);
|
|
272
362
|
opts.eventsHub.broadcast({ type: 'ask', ownerId: envelope.ownerId, eventId: envelope.eventId,
|
|
273
363
|
ask });
|
|
274
364
|
} else if (f.type === 'ask-closed') {
|
|
275
|
-
|
|
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
|
+
}
|
|
276
374
|
// The owner says the ask is closed: the VIEW drops it too, not only the
|
|
277
|
-
// UI, or the next state read hands the card back.
|
|
278
|
-
|
|
279
|
-
|
|
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 });
|
|
280
378
|
opts.eventsHub.broadcast({ type: f.outcome === 'dismissed' ? 'ask-dismissed' : 'ask-answered',
|
|
281
|
-
id: f.askId, ownerAskId: f.askId, ownerId: envelope.ownerId, eventId: envelope.eventId
|
|
379
|
+
id: f.askId, ownerAskId: f.askId, ownerId: envelope.ownerId, eventId: envelope.eventId,
|
|
380
|
+
...(ownerAskTs ? { ownerAskTs } : {}) });
|
|
282
381
|
} else if (f.type === 'file-notice') {
|
|
283
382
|
// Stessa forma della consegna locale (lib/files/routes.js): il nome del
|
|
284
383
|
// file e' SEMPRE nel corpo, con la caption quando c'e'. Prima l'importato
|
|
@@ -588,11 +687,17 @@ function createEventFeedClient(opts = {}) {
|
|
|
588
687
|
view.lastSnapshotAt = now();
|
|
589
688
|
view.nextSnapshotAllowedAt = null;
|
|
590
689
|
const gen = generation;
|
|
690
|
+
const acquisitionCursor = view.cursor;
|
|
691
|
+
const acquisitionAskRevision = view.liveAskRevision || 0;
|
|
591
692
|
const { r, text } = await withDeadline(slot, 8000, async (signal) => {
|
|
592
693
|
const r = await fetchImpl(routeUrl(peer, '/event-feed/snapshot'), {
|
|
593
694
|
headers: identityHeaders(peer, store), signal,
|
|
594
695
|
});
|
|
595
|
-
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
|
+
}
|
|
596
701
|
return { r, text: await r.text() };
|
|
597
702
|
});
|
|
598
703
|
if ((slot && !active(ownerId, slot)) || gen !== generation) throw new Error('obsolete acquisition');
|
|
@@ -615,6 +720,13 @@ function createEventFeedClient(opts = {}) {
|
|
|
615
720
|
view.stale = true; view.lastError = decision.reason;
|
|
616
721
|
return { state: 'failed' };
|
|
617
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
|
+
}
|
|
618
730
|
if (!forView.ok) {
|
|
619
731
|
if (profile !== 'view') {
|
|
620
732
|
// Autorevole per la chiusura, non applicabile alla vista: la vista
|
|
@@ -833,14 +945,27 @@ function createEventFeedClient(opts = {}) {
|
|
|
833
945
|
// l'elenco che la decisione usa — e solo quello. La vista, nello
|
|
834
946
|
// stesso snapshotOnce, applica il suo schema completo o resta ferma.
|
|
835
947
|
state = await Promise.race([snapshotOnce(peer, id, store, { profile: 'decision' }), doorTimeoutPromise()]);
|
|
836
|
-
} catch (
|
|
837
|
-
|
|
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 };
|
|
838
956
|
} finally {
|
|
839
957
|
doorInFlight.delete(id);
|
|
840
958
|
}
|
|
841
959
|
const view = views.get(id);
|
|
842
960
|
const retryAt = (view && typeof view.lastSnapshotAt === 'number' ? view.lastSnapshotAt : now()) + minSnapshotIntervalMs;
|
|
843
|
-
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
|
+
}
|
|
844
969
|
if (state.state === 'skipped') return { status: 'skipped', reason: 'window', retryAt };
|
|
845
970
|
if (state.state === 'applied') {
|
|
846
971
|
// L'elenco autorevole è quello dello snapshot: anche quando la vista
|
|
@@ -884,7 +1009,7 @@ function createEventFeedClient(opts = {}) {
|
|
|
884
1009
|
}
|
|
885
1010
|
}
|
|
886
1011
|
|
|
887
|
-
return { start, stop, poll, state, reemit, viewFor, dismissConfirmed, ownerSnapshotAsks };
|
|
1012
|
+
return { start, stop, poll, state, reemit, viewFor, dismissConfirmed, ownerSnapshotAsks, rememberLiveAsk, applyOwnerClosure };
|
|
888
1013
|
}
|
|
889
1014
|
|
|
890
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',
|
|
@@ -87,6 +90,7 @@ const PRODUCERS = {
|
|
|
87
90
|
askId: cleanStr(p.askId, 32) || '',
|
|
88
91
|
revision: typeof p.revision === 'number' ? p.revision : 0,
|
|
89
92
|
outcome: p.outcome === 'answered' || p.outcome === 'dismissed' ? p.outcome : 'answered',
|
|
93
|
+
...(Number.isSafeInteger(p.askTs) && p.askTs > 0 ? { askTs: p.askTs } : {}),
|
|
90
94
|
ts: typeof p.ts === 'number' ? p.ts : undefined,
|
|
91
95
|
}),
|
|
92
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) {
|
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,34 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { loadDefinitions, cellIdFromTmuxSession, defaultDefinitionsPath } = require('../fleet/definitions.js');
|
|
3
|
+
|
|
4
|
+
// The owner resolves local definitions, never a cell id supplied by a peer.
|
|
5
|
+
// Activity and tmux polling do not participate in ASK authorization.
|
|
6
|
+
function resolveOwnerSession(session, cells) {
|
|
7
|
+
if (typeof session !== 'string' || !session) return null;
|
|
8
|
+
const matches = cells.filter(cell => cell.tmuxSession === session);
|
|
9
|
+
if (matches.length > 1) return null;
|
|
10
|
+
if (matches.length === 1) return matches[0].id;
|
|
11
|
+
return cellIdFromTmuxSession(session);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function createOwnerAskScope(cfg) {
|
|
15
|
+
const enabled = cfg.fleetEnabled !== false && cfg.builtinEnabled !== false;
|
|
16
|
+
const definitionsPath = enabled ? defaultDefinitionsPath(cfg) : null;
|
|
17
|
+
function read() {
|
|
18
|
+
if (!enabled) return { available: true, cells: [] };
|
|
19
|
+
const definitions = loadDefinitions(definitionsPath);
|
|
20
|
+
return definitions ? { available: true, cells: definitions.cells } : { available: false, cells: [] };
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
snapshotResolver: () => {
|
|
24
|
+
const definitions = read();
|
|
25
|
+
if (!definitions.available) throw new Error('owner ASK scope unavailable');
|
|
26
|
+
return session => resolveOwnerSession(session, definitions.cells);
|
|
27
|
+
},
|
|
28
|
+
cellForSession: session => {
|
|
29
|
+
const definitions = read();
|
|
30
|
+
return definitions.available ? resolveOwnerSession(session, definitions.cells) : null;
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
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
|
}
|
package/lib/notify/routes.js
CHANGED
|
@@ -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,14 +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,
|
|
490
|
-
ownerAskId: closed.ask.ownerAskId,
|
|
493
|
+
ownerAskId: (closed.ask && closed.ask.ownerAskId) || b.askId,
|
|
494
|
+
...(Number.isSafeInteger(b.ownerAskTs) && b.ownerAskTs > 0 ? { ownerAskTs: b.ownerAskTs } : {}),
|
|
491
495
|
});
|
|
492
496
|
}
|
|
493
497
|
return res.json({ status: 'delivered', closed: closed.changed });
|
|
@@ -514,23 +518,29 @@ function notifyRoutes({
|
|
|
514
518
|
if (!out.ok) {
|
|
515
519
|
return res.status(out.reason === 'cap' ? 429 : 400).json({ status: 'refused', reason: out.reason, error: out.error });
|
|
516
520
|
}
|
|
517
|
-
if (out.suppressed
|
|
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
|
+
}
|
|
518
526
|
const ask = out.ask;
|
|
519
527
|
// deliverOnly: consegna locale alla UI, NIENTE pubblicazione sul feed e
|
|
520
528
|
// niente ri-esportazione. E' l'invariante che uccide il loop A->B->A per
|
|
521
529
|
// costruzione — un ask importato non torna mai indietro, esattamente
|
|
522
530
|
// come una notify federata.
|
|
523
|
-
notifier.deliverOnlyRaw({ type: 'ask', ask });
|
|
524
|
-
await notifier.deliverOnly({
|
|
531
|
+
const cardUi = notifier.deliverOnlyRaw({ type: 'ask', ask });
|
|
532
|
+
const alert = await notifier.deliverOnly({
|
|
525
533
|
title: `domanda da ${ask.session}`,
|
|
526
534
|
body: ask.question,
|
|
527
535
|
urgency: 'high',
|
|
528
536
|
session: ask.session,
|
|
529
537
|
lang: 'it',
|
|
530
|
-
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) }),
|
|
531
540
|
url: `/#ask=${ask.id}`,
|
|
532
541
|
});
|
|
533
|
-
|
|
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 });
|
|
534
544
|
}
|
|
535
545
|
|
|
536
546
|
// --- domanda locale ----------------------------------------------------
|
|
@@ -572,7 +582,7 @@ function notifyRoutes({
|
|
|
572
582
|
ownerNode: self,
|
|
573
583
|
},
|
|
574
584
|
});
|
|
575
|
-
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 } : {}) });
|
|
576
586
|
} catch (e) {
|
|
577
587
|
fanout.push({ target, status: 'unknown', reason: 'dispatch-threw' });
|
|
578
588
|
try { log(`ask fan-out verso ${target} fallito: ${String(e && e.message || e)}`); } catch (_) {}
|
|
@@ -592,7 +602,7 @@ function notifyRoutes({
|
|
|
592
602
|
urgency: 'high',
|
|
593
603
|
session: ask.session,
|
|
594
604
|
lang: 'it',
|
|
595
|
-
askId: ask.id,
|
|
605
|
+
ownerId: self, askId: ask.id, ownerAskTs: ask.ts,
|
|
596
606
|
url: `/#ask=${ask.id}`,
|
|
597
607
|
});
|
|
598
608
|
res.status(201).json({ id: ask.id, ...(fanout.length ? { fanout } : {}) });
|