@mmmbuto/nexuscrew 0.9.59 → 0.9.60

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 (isAskDismissed(envelope.ownerId, ask)) return false;
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
- if (typeof opts.onAskClosed === 'function') opts.onAskClosed(envelope.ownerId, f.askId, f.outcome);
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
- const closedView = dropAskFromView(envelope.ownerId, f.askId);
279
- 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 });
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) 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
+ }
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
- 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 };
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) 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
+ }
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: 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) {
@@ -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
  }
@@ -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.changed && closed.ask) {
484
- // Preserve the local alias id for older clients and the canonical
485
- // owner ask id for clients that merge aliases with feed cards.
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) return res.json({ status: 'delivered', suppressed: true, ownerId: owner });
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
- return res.json({ status: 'delivered', id: ask.id, ownerId: ask.ownerId });
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 } : {}) });