@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.
@@ -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: { id: f.askId, question: f.question, options: f.options, session: f.session, ownerId: envelope.ownerId } });
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
- const closedView = dropAskFromView(envelope.ownerId, f.askId);
273
- if (f.outcome === 'dismissed') noteAskClosed(envelope.ownerId, f.askId, closedView.viewEpoch);
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) throw new Error(`snapshot HTTP ${r.status}`);
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
- return { status: 'error', reason: 'transport' };
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) return { status: 'pending', reason: 'door-timeout', retryAt };
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: deps.cellForSession(a.session) }))
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 };
@@ -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
- function createNotifier({ hub, push }) {
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 ui = hub.broadcast({
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
- let pushed = 0;
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
- } catch (_) { /* push best-effort: la notify UI resta valida */ }
35
- return { ui, push: pushed };
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 };
@@ -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
  }