@mmmbuto/nexuscrew 0.9.58 → 0.9.59
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 +7 -0
- package/README.md +32 -8
- package/frontend/dist/assets/index-CZFJnks_.js +169 -0
- package/frontend/dist/index.html +1 -1
- package/frontend/dist/version.json +1 -1
- package/lib/nodes/store.js +19 -1
- package/lib/notify/ask-relay.js +64 -14
- package/lib/notify/asks.js +117 -9
- package/lib/notify/event-feed-asks-routes.js +18 -5
- package/lib/notify/event-feed-client.js +10 -4
- package/lib/notify/event-feed-producers.js +1 -0
- package/lib/notify/imported-dismissals.js +106 -0
- package/lib/notify/routes.js +7 -3
- package/lib/server.js +109 -4
- package/package.json +1 -1
- package/frontend/dist/assets/index-D8cf4AEo.js +0 -169
package/frontend/dist/index.html
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
<meta name="apple-mobile-web-app-title" content="NexusCrew" />
|
|
12
12
|
<link rel="manifest" href="/manifest.json" />
|
|
13
13
|
<title>NexusCrew</title>
|
|
14
|
-
<script type="module" crossorigin src="/assets/index-
|
|
14
|
+
<script type="module" crossorigin src="/assets/index-CZFJnks_.js"></script>
|
|
15
15
|
<link rel="stylesheet" crossorigin href="/assets/index-fCIoVxeq.css">
|
|
16
16
|
</head>
|
|
17
17
|
<body>
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"0.9.
|
|
1
|
+
{"version":"0.9.59"}
|
package/lib/nodes/store.js
CHANGED
|
@@ -109,7 +109,7 @@ const LABEL_MAX = 64;
|
|
|
109
109
|
const NODE_KEYS = new Set([
|
|
110
110
|
'name', 'ssh', 'sshPort', 'remotePort', 'localPort', 'keyPath', 'identityFile',
|
|
111
111
|
'roles', 'rolesKnown', 'token', 'acceptToken', 'nodeId', 'transport', 'autostart', 'visibility', 'selected',
|
|
112
|
-
'direction', 'reversePort', 'shared', 'label', 'reversePool',
|
|
112
|
+
'direction', 'reversePort', 'shared', 'label', 'reversePool', 'pairingRevision',
|
|
113
113
|
// Quali CELLE di questo nodo il peer puo' vedere. Distinto da `visibility`,
|
|
114
114
|
// che governa il TRANSITO (attraverso chi passa il traffico) e non l'accesso.
|
|
115
115
|
'cellVisibility', 'cells',
|
|
@@ -392,6 +392,11 @@ function parseNode(n, schemaVersion = SCHEMA_VERSION) {
|
|
|
392
392
|
if (!validToken(n.acceptToken)) return null;
|
|
393
393
|
out.acceptToken = n.acceptToken;
|
|
394
394
|
}
|
|
395
|
+
// Public rotation counter, independent of the credential bytes.
|
|
396
|
+
if (n.pairingRevision !== undefined) {
|
|
397
|
+
if (!Number.isSafeInteger(n.pairingRevision) || n.pairingRevision < 0) return null;
|
|
398
|
+
out.pairingRevision = n.pairingRevision;
|
|
399
|
+
}
|
|
395
400
|
if (n.nodeId !== undefined) {
|
|
396
401
|
if (typeof n.nodeId !== 'string' || !NODE_ID_RE.test(n.nodeId)) return null;
|
|
397
402
|
out.nodeId = n.nodeId;
|
|
@@ -570,6 +575,19 @@ function atomicWriteStore(p, data) {
|
|
|
570
575
|
const parsed = parseStore(data);
|
|
571
576
|
if (!parsed) throw new Error('nodes.json non valido: validazione fallita (schema strict)');
|
|
572
577
|
|
|
578
|
+
// Count rotations at the durable write boundary, including pairing paths
|
|
579
|
+
// that replace tokens directly. Never export a hash of the credentials.
|
|
580
|
+
const previous = loadStore(p);
|
|
581
|
+
for (const peer of parsed.nodes) {
|
|
582
|
+
const old = previous && previous.nodes.find(node => node.name === peer.name && node.nodeId === peer.nodeId);
|
|
583
|
+
if (!old) continue;
|
|
584
|
+
const revision = old.pairingRevision || 0;
|
|
585
|
+
const rotated = old.token !== peer.token || old.acceptToken !== peer.acceptToken;
|
|
586
|
+
if (rotated && revision === Number.MAX_SAFE_INTEGER) throw new Error('pairing rotation revision exhausted');
|
|
587
|
+
if (rotated || old.pairingRevision !== undefined) peer.pairingRevision = revision + (rotated ? 1 : 0);
|
|
588
|
+
else delete peer.pairingRevision;
|
|
589
|
+
}
|
|
590
|
+
|
|
573
591
|
const dir = path.dirname(p);
|
|
574
592
|
fs.mkdirSync(dir, { recursive: true });
|
|
575
593
|
const tmp = path.join(dir, `.${path.basename(p)}.${crypto.randomBytes(6).toString('hex')}.tmp`);
|
package/lib/notify/ask-relay.js
CHANGED
|
@@ -27,12 +27,13 @@ const OWNER_REASONS = new Set([
|
|
|
27
27
|
'unknown', 'dismissed', 'answering', 'answered', 'store-unreadable',
|
|
28
28
|
'delivery-unknown-block', 'already-delivered', 'paste-failed', 'bad-request-id',
|
|
29
29
|
'no-receipts', 'request-conflict', 'receipt-cap', 'federation-peer-unreachable',
|
|
30
|
-
'federation-peer-timeout', 'federation-body-consumed',
|
|
30
|
+
'federation-peer-timeout', 'federation-body-consumed', 'generation-mismatch', 'invalid-generation', 'unsupported',
|
|
31
31
|
]);
|
|
32
32
|
function safeReason(reason) { return OWNER_REASONS.has(reason) ? reason : undefined; }
|
|
33
33
|
|
|
34
|
-
function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = () => {}, probeReverseSlotImpl = probeReverseSlot, peers = null, localPort = () => 0, localToken = () => '', nodesPath = null, slotVerifier = askSlotVerifier } = {}) {
|
|
34
|
+
function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = () => {}, probeReverseSlotImpl = probeReverseSlot, peers = null, localPort = () => 0, localToken = () => '', nodesPath = null, availabilityBinding = () => null, slotVerifier = askSlotVerifier } = {}) {
|
|
35
35
|
// (ownerId|askId|requestId) -> {state: 'sent'|'committed'|'failed'|'uncertain', requestId}
|
|
36
|
+
const channelObservations = new Map();
|
|
36
37
|
const attempts = new Map();
|
|
37
38
|
const answerFlights = new Map();
|
|
38
39
|
|
|
@@ -123,7 +124,7 @@ function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = ()
|
|
|
123
124
|
return `http://127.0.0.1:${port}/federation/route/_/${path.replace(/^\//, '')}`;
|
|
124
125
|
}
|
|
125
126
|
|
|
126
|
-
async function forward(candidate, path, { method = 'GET', body, op } = {}) {
|
|
127
|
+
async function forward(candidate, path, { method = 'GET', body, expectedTs, op } = {}) {
|
|
127
128
|
op.check();
|
|
128
129
|
const st = loadStore();
|
|
129
130
|
slotVerifier.sync(st, proofScope);
|
|
@@ -143,7 +144,8 @@ function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = ()
|
|
|
143
144
|
: { authorization: `Bearer ${candidate.forwardToken}`, 'x-nexuscrew-visited': st && st.nodeId || '' };
|
|
144
145
|
try {
|
|
145
146
|
op.check(); op.delivered = true;
|
|
146
|
-
const r = await op.wait(fetchImpl(url, { method, headers: { ...headers, 'content-type': 'application/json'
|
|
147
|
+
const r = await op.wait(fetchImpl(url, { method, headers: { ...headers, 'content-type': 'application/json',
|
|
148
|
+
...(expectedTs === undefined ? {} : { 'x-nexuscrew-ask-ts': String(expectedTs) }) },
|
|
147
149
|
...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: op.controller.signal }));
|
|
148
150
|
op.check();
|
|
149
151
|
const payload = await op.wait(r.json().catch(error => {
|
|
@@ -290,7 +292,15 @@ function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = ()
|
|
|
290
292
|
}
|
|
291
293
|
|
|
292
294
|
// Remote dismiss: same resolution rules as the answer (DELETE on the owner).
|
|
293
|
-
|
|
295
|
+
function retryAfterMs(response) {
|
|
296
|
+
const value = response.headers?.get?.('retry-after');
|
|
297
|
+
if (typeof value !== 'string') return 0;
|
|
298
|
+
const seconds = /^\d+(?:\.\d+)?$/.test(value.trim()) ? Number(value) : null;
|
|
299
|
+
const delay = seconds === null ? Date.parse(value) - now() : seconds * 1000;
|
|
300
|
+
return Number.isFinite(delay) && delay > 0 ? delay : 0;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function relayDismissOnce({ ownerId, askId, expectedTs } = {}, op) {
|
|
294
304
|
const candidate = await candidateOwner(ownerId, op);
|
|
295
305
|
if (!candidate) return { ok: false, code: 404, error: 'owner non tra i peer autorizzati', reason: 'owner-unknown' };
|
|
296
306
|
if (candidate.kind === 'inbound-unverified') {
|
|
@@ -303,9 +313,9 @@ function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = ()
|
|
|
303
313
|
const peer = candidate.node;
|
|
304
314
|
const store = loadStore();
|
|
305
315
|
try {
|
|
306
|
-
const { r, body } = await forward(candidate, `/event-feed/asks/${encodeURIComponent(askId)}`, { method: 'DELETE', op });
|
|
307
|
-
if (!r.ok) return { ok: false, code: r.status, error: 'owner refused', reason: body.reason };
|
|
308
|
-
return { ok: true };
|
|
316
|
+
const { r, body } = await forward(candidate, `/event-feed/asks/${encodeURIComponent(askId)}`, { method: 'DELETE', expectedTs, op });
|
|
317
|
+
if (!r.ok) return { ok: false, code: r.status, error: 'owner refused', reason: body.reason, retryAfterMs: retryAfterMs(r) };
|
|
318
|
+
return { ok: true, ...(body.outcome ? { outcome: body.outcome } : {}) };
|
|
309
319
|
} catch (e) { if (!op.active()) return deadlineResult(op, { ownerId, askId }, false); return { ok: false, code: 502, reason: safeReason(e.reason), error: 'owner unreachable' }; }
|
|
310
320
|
}
|
|
311
321
|
|
|
@@ -343,32 +353,72 @@ function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = ()
|
|
|
343
353
|
const denied = (status, reason) => ({ ownerId, askId, canReply: false, status, ...(reason ? { reason } : {}) });
|
|
344
354
|
const candidate = await candidateOwner(ownerId, op);
|
|
345
355
|
if (!candidate) return denied('unreachable', 'owner-unknown');
|
|
346
|
-
if (candidate.kind === 'inbound-unverified') return denied('unreachable', 'reverse-slot-unverified');
|
|
356
|
+
if (candidate.kind === 'inbound-unverified') { op.channelUnavailable = true; return denied('unreachable', 'reverse-slot-unverified'); }
|
|
347
357
|
if (candidate.kind === 'inbound') {
|
|
348
358
|
const gate = await verifyInboundChannel(candidate, op);
|
|
349
|
-
if (!gate.ok) return denied('unreachable', gate.reason);
|
|
359
|
+
if (!gate.ok) { op.channelUnavailable = true; return denied('unreachable', gate.reason); }
|
|
350
360
|
}
|
|
351
361
|
try {
|
|
352
362
|
const { r, body } = await forward(candidate, `/event-feed/asks/${encodeURIComponent(askId)}/capability`, { op });
|
|
353
|
-
if (
|
|
363
|
+
if (['reverse-slot-unverified', 'federation-peer-unreachable', 'federation-peer-timeout'].includes(body.reason)) { op.channelUnavailable = true; return denied('unreachable', body.reason); }
|
|
354
364
|
if (r.status === 404 || r.status === 501) return denied('unsupported', body.reason);
|
|
355
365
|
if (r.status === 403) return denied('denied', body.reason);
|
|
356
366
|
if (!r.ok || body.ownerId !== ownerId || body.askId !== askId || typeof body.canReply !== 'boolean'
|
|
357
367
|
|| !['open', 'denied', 'answered', 'dismissed'].includes(body.status)
|
|
358
368
|
|| (body.canReply && body.status !== 'open')) return denied('unreachable');
|
|
359
369
|
return { ownerId, askId, canReply: body.canReply, status: body.status };
|
|
360
|
-
} catch (_) { return denied('unreachable', op.active() ? undefined : 'relay-deadline'); }
|
|
370
|
+
} catch (_) { op.channelUnavailable = true; return denied('unreachable', op.active() ? undefined : 'relay-deadline'); }
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async function inspectDismissalOnce({ ownerId, askId } = {}, op) {
|
|
374
|
+
const candidate = await candidateOwner(ownerId, op);
|
|
375
|
+
if (!candidate) return { ok: false, code: 404, reason: 'owner-unknown' };
|
|
376
|
+
if (candidate.kind === 'inbound-unverified') return { ok: false, code: 404, reason: 'reverse-slot-unverified' };
|
|
377
|
+
if (candidate.kind === 'inbound') {
|
|
378
|
+
const proof = await verifyInboundChannel(candidate, op);
|
|
379
|
+
if (!proof.ok) return proof;
|
|
380
|
+
}
|
|
381
|
+
const { r, body } = await forward(candidate, `/event-feed/asks/${encodeURIComponent(askId)}/capability`, { op });
|
|
382
|
+
if (!r.ok) return { ok: false, code: r.status, reason: body.reason || 'unknown', retryAfterMs: retryAfterMs(r) };
|
|
383
|
+
if (body.ownerId !== ownerId || body.askId !== askId || !['open', 'answered', 'dismissed'].includes(body.status)) {
|
|
384
|
+
return { ok: false, code: 403, reason: 'unknown' };
|
|
385
|
+
}
|
|
386
|
+
if (body.generationPrecondition !== true) return { ok: false, code: 501, reason: 'unsupported' };
|
|
387
|
+
const ask = body.ask;
|
|
388
|
+
if (!ask || ask.id !== askId || !Number.isSafeInteger(ask.ts) || ask.ts <= 0
|
|
389
|
+
|| typeof ask.question !== 'string' || typeof ask.session !== 'string'
|
|
390
|
+
|| (ask.options !== undefined && (!Array.isArray(ask.options) || ask.options.some(option => typeof option !== 'string')))) {
|
|
391
|
+
return { ok: false, code: 409, reason: 'generation-mismatch' };
|
|
392
|
+
}
|
|
393
|
+
return { ok: true, status: body.status, ask, generationPrecondition: true };
|
|
361
394
|
}
|
|
395
|
+
function inspectDismissal(args = {}) { return operation(args, op => inspectDismissalOnce(args, op)); }
|
|
362
396
|
|
|
363
397
|
function relayDismiss(args = {}) { return operation(args, op => relayDismissOnce(args, op)); }
|
|
364
398
|
function verifyStatus(args = {}) { return operation(args, op => verifyStatusOnce(args, op)); }
|
|
365
|
-
function
|
|
399
|
+
function channelBinding(ownerId) {
|
|
400
|
+
const store = loadStore();
|
|
401
|
+
// Kept only in process memory; credentials never enter the dismissal store.
|
|
402
|
+
return crypto.createHash('sha256').update(JSON.stringify([store, availabilityBinding(ownerId)])).digest('hex');
|
|
403
|
+
}
|
|
404
|
+
function channelState(ownerId) {
|
|
405
|
+
const observation = channelObservations.get(ownerId);
|
|
406
|
+
return { unavailable: !!(observation && now() - observation.at < 30000 && observation.binding === channelBinding(ownerId) && observation.unavailable) };
|
|
407
|
+
}
|
|
408
|
+
function replyCapability(args = {}) {
|
|
409
|
+
const initial = channelBinding(args.ownerId);
|
|
410
|
+
return operation(args, async op => {
|
|
411
|
+
const result = await replyCapabilityOnce(args, op);
|
|
412
|
+
channelObservations.set(args.ownerId, { at: now(), binding: initial, unavailable: op.channelUnavailable === true });
|
|
413
|
+
return result;
|
|
414
|
+
}, true);
|
|
415
|
+
}
|
|
366
416
|
|
|
367
417
|
function state() {
|
|
368
418
|
return [...attempts.entries()].map(([key, a]) => ({ key, ...a }));
|
|
369
419
|
}
|
|
370
420
|
|
|
371
|
-
return { relayAnswer, relayDismiss, verifyStatus, replyCapability, state, isUncertain, requestId };
|
|
421
|
+
return { channelState, inspectDismissal, relayAnswer, relayDismiss, verifyStatus, replyCapability, state, isUncertain, requestId };
|
|
372
422
|
}
|
|
373
423
|
|
|
374
424
|
module.exports = { createAskRelay };
|
package/lib/notify/asks.js
CHANGED
|
@@ -17,6 +17,22 @@ const MAX_OPEN = 100;
|
|
|
17
17
|
// che arrivano da un peer e quelle poste da questo nodo sono due classi
|
|
18
18
|
// distinte, e le prime non devono poter esaurire il budget delle seconde.
|
|
19
19
|
const MAX_OPEN_IMPORTED = 100;
|
|
20
|
+
const MAX_IMPORTED_DISMISSALS = 1000;
|
|
21
|
+
const SYNC_STATES = new Set(['pending', 'confirmed-dismissed', 'confirmed-answered', 'blocked']);
|
|
22
|
+
function askFingerprint(ask) {
|
|
23
|
+
return crypto.createHash('sha256').update(JSON.stringify([String(ask.question || ''), Array.isArray(ask.options) ? ask.options : [], String(ask.session || '')])).digest('hex');
|
|
24
|
+
}
|
|
25
|
+
function validDismissals(value) {
|
|
26
|
+
return value && typeof value === 'object' && !Array.isArray(value) && Object.entries(value).every(([key, r]) =>
|
|
27
|
+
r && key === `${r.ownerId}|${r.ownerAskId}` && /^[a-f0-9]{32}$/.test(r.ownerId) && /^[a-f0-9]{8}$/.test(r.ownerAskId)
|
|
28
|
+
&& /^[a-f0-9]{64}$/.test(r.ownerAskFingerprint) && SYNC_STATES.has(r.syncState)
|
|
29
|
+
&& ['known', 'unknown'].includes(r.generation)
|
|
30
|
+
&& (r.generation === 'unknown' ? r.ownerAskTs === null : Number.isSafeInteger(r.ownerAskTs) && r.ownerAskTs > 0)
|
|
31
|
+
&& (r.cellId === undefined || (typeof r.cellId === 'string' && /^[A-Za-z0-9._-]{1,32}$/.test(r.cellId)))
|
|
32
|
+
&& r.dismissed === true && Number.isSafeInteger(r.attempts) && r.attempts >= 0
|
|
33
|
+
&& Number.isFinite(r.nextRetryAt) && r.nextRetryAt >= 0
|
|
34
|
+
&& r.dismissedReason === 'owner-unreachable' && Number.isFinite(r.dismissedTs));
|
|
35
|
+
}
|
|
20
36
|
const MAX_KEEP = 100; // ask totali persistiti (i piu' vecchi answered si potano)
|
|
21
37
|
const MAX_QUESTION = 2000;
|
|
22
38
|
const MAX_OPTIONS = 8;
|
|
@@ -39,11 +55,14 @@ function createAsksStore(opts = {}) {
|
|
|
39
55
|
// per non sovrascrivere il file malformato con un defaults vuoto, che
|
|
40
56
|
// cancellerebbe le domande aperte.
|
|
41
57
|
let asks = null;
|
|
58
|
+
let importedDismissals = null;
|
|
59
|
+
const dismissalCap = opts.maxImportedDismissals || MAX_IMPORTED_DISMISSALS;
|
|
42
60
|
function load() {
|
|
43
61
|
if (asks) return asks;
|
|
44
62
|
const r = readJsonStrict(filePath);
|
|
45
|
-
if (r.state === 'absent') { asks = []; return asks; }
|
|
46
|
-
if (r.state === 'ok' && Array.isArray(r.value.asks)) {
|
|
63
|
+
if (r.state === 'absent') { asks = []; importedDismissals = {}; return asks; }
|
|
64
|
+
if (r.state === 'ok' && Array.isArray(r.value.asks) && (r.value.importedDismissals === undefined || validDismissals(r.value.importedDismissals))) {
|
|
65
|
+
importedDismissals = r.value.importedDismissals || {};
|
|
47
66
|
asks = r.value.asks.filter((a) => a && typeof a === 'object' && typeof a.id === 'string');
|
|
48
67
|
return asks;
|
|
49
68
|
}
|
|
@@ -58,7 +77,8 @@ function createAsksStore(opts = {}) {
|
|
|
58
77
|
if (asks) return { readable: true };
|
|
59
78
|
const r = readJsonStrict(filePath);
|
|
60
79
|
if (r.state === 'absent') return { readable: true };
|
|
61
|
-
if (r.state === 'ok' && Array.isArray(r.value.asks)) return { readable: true };
|
|
80
|
+
if (r.state === 'ok' && Array.isArray(r.value.asks) && (r.value.importedDismissals === undefined || validDismissals(r.value.importedDismissals))) return { readable: true };
|
|
81
|
+
if (r.state === 'ok' && r.value.importedDismissals !== undefined && !validDismissals(r.value.importedDismissals)) return { readable: false, reason: 'malformed imported dismissals' };
|
|
62
82
|
const reason = r.state === 'malformed'
|
|
63
83
|
? r.reason
|
|
64
84
|
: `forma invalida: 'asks' non e' un array (${r.value && r.value.asks !== undefined ? typeof r.value.asks : 'assente'})`;
|
|
@@ -86,7 +106,7 @@ function createAsksStore(opts = {}) {
|
|
|
86
106
|
const drop = new Set(answered.slice(0, excess).map((a) => a.id));
|
|
87
107
|
asks = list.filter((a) => !drop.has(a.id));
|
|
88
108
|
}
|
|
89
|
-
atomicWriteJson(filePath, { asks: load() });
|
|
109
|
+
atomicWriteJson(filePath, { asks: load(), importedDismissals: importedDismissals || {} });
|
|
90
110
|
}
|
|
91
111
|
|
|
92
112
|
// Validazione input fail-closed. Ritorna {ok:false,error} o {ok:true,value}.
|
|
@@ -121,7 +141,7 @@ function createAsksStore(opts = {}) {
|
|
|
121
141
|
// L'`id` locale e' nostro e all'owner non dice nulla; e' la coppia che nomina
|
|
122
142
|
// lo STESSO oggetto sui due nodi, quindi e' la chiave con cui si riconcilia.
|
|
123
143
|
function findImported(ownerId, ownerAskId) {
|
|
124
|
-
return load().find((a) => isImported(a) && a.ownerId === String(ownerId)
|
|
144
|
+
return load().slice().reverse().find((a) => isImported(a) && a.ownerId === String(ownerId)
|
|
125
145
|
&& a.ownerAskId === String(ownerAskId)) || null;
|
|
126
146
|
}
|
|
127
147
|
|
|
@@ -132,6 +152,11 @@ function createAsksStore(opts = {}) {
|
|
|
132
152
|
const ur = unreadableGuard();
|
|
133
153
|
if (ur) return { ok: false, reason: 'store-unreadable', changed: false, ask: null };
|
|
134
154
|
const ask = findImported(ownerId, ownerAskId);
|
|
155
|
+
const record = getImportedDismissal(ownerId, ownerAskId);
|
|
156
|
+
if (record) {
|
|
157
|
+
const synced = updateImportedDismissal(ownerId, ownerAskId, { syncState: outcome === 'answered' ? 'confirmed-answered' : 'confirmed-dismissed', ownerOutcome: outcome });
|
|
158
|
+
if (!synced.ok) return { ...synced, changed: false, ask: null };
|
|
159
|
+
}
|
|
135
160
|
if (!ask) return { ok: true, changed: false, ask: null };
|
|
136
161
|
if (ask.answered || ask.dismissed) return { ok: true, changed: false, ask };
|
|
137
162
|
if (outcome === 'answered') { ask.answered = true; ask.answeredReconciled = true; }
|
|
@@ -141,7 +166,7 @@ function createAsksStore(opts = {}) {
|
|
|
141
166
|
return { ok: true, changed: true, ask };
|
|
142
167
|
}
|
|
143
168
|
|
|
144
|
-
function create({ question, options, session, ownerId, ownerAskId, originNode, originCell }) {
|
|
169
|
+
function create({ question, options, session, ownerId, ownerAskId, originNode, originCell, ownerAskTs }) {
|
|
145
170
|
const ur = unreadableGuard();
|
|
146
171
|
if (ur) return { ok: false, reason: 'store-unreadable', error: `store illeggibile (${ur}): creazione rifiutata` };
|
|
147
172
|
const v = validate({ question, options });
|
|
@@ -152,7 +177,12 @@ function createAsksStore(opts = {}) {
|
|
|
152
177
|
// se ne crea un secondo.
|
|
153
178
|
if (originNode && ownerId && ownerAskId) {
|
|
154
179
|
const existing = findImported(ownerId, ownerAskId);
|
|
155
|
-
|
|
180
|
+
const incoming = { id: ownerAskId, question: v.value.question, options: v.value.options, session: String(session || ''), originNode, ownerAskTs };
|
|
181
|
+
if (isImportedDismissed(ownerId, incoming)) return { ok: true, ask: existing, suppressed: true, deduped: !!existing };
|
|
182
|
+
if (existing && askFingerprint(existing) === askFingerprint(incoming)
|
|
183
|
+
&& (generationTs(existing) === null || generationTs(incoming) === null || generationTs(existing) === generationTs(incoming))) {
|
|
184
|
+
return { ok: true, ask: existing, deduped: true };
|
|
185
|
+
}
|
|
156
186
|
}
|
|
157
187
|
// Cap duro sugli aperti, SEPARATO PER CLASSE: gli importati non consumano il
|
|
158
188
|
// budget dei locali. Con un cap unico, cento domande ricevute da un peer
|
|
@@ -188,6 +218,7 @@ function createAsksStore(opts = {}) {
|
|
|
188
218
|
// ri-esportato (nessun loop A->B->A). Un ask locale non ha questo campo.
|
|
189
219
|
...(originNode ? { originNode: String(originNode) } : {}),
|
|
190
220
|
...(originCell ? { originCell: String(originCell) } : {}),
|
|
221
|
+
...(Number.isSafeInteger(ownerAskTs) && ownerAskTs > 0 ? { ownerAskTs } : {}),
|
|
191
222
|
ts: now(),
|
|
192
223
|
revision: 0,
|
|
193
224
|
answered: false,
|
|
@@ -208,6 +239,83 @@ function createAsksStore(opts = {}) {
|
|
|
208
239
|
.map((a) => ({ ...a, ...(a.options ? { options: a.options.slice() } : {}) }));
|
|
209
240
|
}
|
|
210
241
|
|
|
242
|
+
function getImportedDismissal(ownerId, askId) {
|
|
243
|
+
load();
|
|
244
|
+
const record = importedDismissals && importedDismissals[`${ownerId}|${askId}`];
|
|
245
|
+
return record ? { ...record } : null;
|
|
246
|
+
}
|
|
247
|
+
function listImportedDismissals() {
|
|
248
|
+
load();
|
|
249
|
+
return Object.values(importedDismissals || {}).map(r => ({ ...r }));
|
|
250
|
+
}
|
|
251
|
+
function generationTs(ask) {
|
|
252
|
+
const ts = Object.hasOwn(ask, 'ownerAskTs') ? ask.ownerAskTs : ask.originNode ? null : ask.ts;
|
|
253
|
+
return Number.isSafeInteger(ts) && ts > 0 ? ts : null;
|
|
254
|
+
}
|
|
255
|
+
function sameImportedGeneration(a, b) {
|
|
256
|
+
const at = generationTs(a), bt = generationTs(b);
|
|
257
|
+
return askFingerprint(a) === askFingerprint(b) && (at === null || bt === null || at === bt);
|
|
258
|
+
}
|
|
259
|
+
function isImportedDismissed(ownerId, ask) {
|
|
260
|
+
const record = getImportedDismissal(ownerId, ask.ownerAskId || ask.id);
|
|
261
|
+
if (!record || record.ownerAskFingerprint !== askFingerprint(ask)) return false;
|
|
262
|
+
// Historical imports have no original owner timestamp. Reusing an id with
|
|
263
|
+
// identical content remains suppressed; this is not proof of historical identity.
|
|
264
|
+
const ts = generationTs(ask);
|
|
265
|
+
return record.generation === 'unknown' || ts === null || record.ownerAskTs === ts;
|
|
266
|
+
}
|
|
267
|
+
function dismissalTransaction(change) {
|
|
268
|
+
const ur = unreadableGuard();
|
|
269
|
+
if (ur) return { ok: false, reason: 'store-unreadable' };
|
|
270
|
+
load();
|
|
271
|
+
const beforeAsks = asks.map(a => ({ ...a }));
|
|
272
|
+
const beforeRecords = Object.fromEntries(Object.entries(importedDismissals).map(([k, r]) => [k, { ...r }]));
|
|
273
|
+
try { const result = change(); if (!result.ok) return result; save(); return result; }
|
|
274
|
+
catch (_) { asks = beforeAsks; importedDismissals = beforeRecords; return { ok: false, reason: 'persist-failed' }; }
|
|
275
|
+
}
|
|
276
|
+
function dismissImported({ ownerId, ownerAskId, ask, cellId, retryBinding, syncState = 'pending' } = {}) {
|
|
277
|
+
return dismissalTransaction(() => {
|
|
278
|
+
if (!/^[a-f0-9]{32}$/.test(ownerId) || !/^[a-f0-9]{8}$/.test(ownerAskId) || !ask || (ask.ownerAskId || ask.id) !== ownerAskId) return { ok: false, reason: 'unknown' };
|
|
279
|
+
const candidate = findImported(ownerId, ownerAskId);
|
|
280
|
+
const alias = candidate && sameImportedGeneration(candidate, ask) ? candidate : null;
|
|
281
|
+
if (alias && answering.has(alias.id)) return { ok: false, reason: 'answering' };
|
|
282
|
+
if (ask.answered || (alias && alias.answered)) return { ok: false, reason: 'answered' };
|
|
283
|
+
const key = `${ownerId}|${ownerAskId}`;
|
|
284
|
+
const existing = importedDismissals[key];
|
|
285
|
+
if (existing && isImportedDismissed(ownerId, ask)) return { ok: true, record: { ...existing }, idempotent: true };
|
|
286
|
+
if (!existing && Object.keys(importedDismissals).length >= dismissalCap) return { ok: false, reason: 'dismissal-cap' };
|
|
287
|
+
const ts = generationTs(ask);
|
|
288
|
+
const record = { ownerId, ownerAskId, ...(cellId ? { cellId } : {}), ...(alias ? { localAliasId: alias.id } : {}),
|
|
289
|
+
dismissed: true, dismissedReason: 'owner-unreachable', dismissedTs: now(),
|
|
290
|
+
generation: ts === null ? 'unknown' : 'known', ownerAskTs: ts,
|
|
291
|
+
ownerAskFingerprint: askFingerprint(ask), syncState, attempts: 0, nextRetryAt: 0,
|
|
292
|
+
...(syncState === 'blocked' ? { lastReason: 'permission-denied', ...(retryBinding ? { retryBinding } : {}) } : {}) };
|
|
293
|
+
importedDismissals[key] = record;
|
|
294
|
+
if (alias) {
|
|
295
|
+
if (!alias.dismissed) { alias.dismissed = true; alias.dismissedTs = now(); alias.revision = (alias.revision || 0) + 1; }
|
|
296
|
+
alias.dismissedReason = 'owner-unreachable';
|
|
297
|
+
}
|
|
298
|
+
return { ok: true, record: { ...record } };
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
function updateImportedDismissal(ownerId, askId, patch) {
|
|
302
|
+
return dismissalTransaction(() => {
|
|
303
|
+
const record = importedDismissals[`${ownerId}|${askId}`];
|
|
304
|
+
if (!record) return { ok: false, reason: 'unknown' };
|
|
305
|
+
const next = { ...record, ...patch };
|
|
306
|
+
if (!validDismissals({ [`${ownerId}|${askId}`]: next })) return { ok: false, reason: 'invalid' };
|
|
307
|
+
importedDismissals[`${ownerId}|${askId}`] = next;
|
|
308
|
+
return { ok: true, record: { ...next } };
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
function adoptImportedDismissal(ownerId, askId, ask) {
|
|
312
|
+
const record = getImportedDismissal(ownerId, askId);
|
|
313
|
+
const ts = generationTs(ask);
|
|
314
|
+
if (!record || (ask.ownerAskId || ask.id) !== askId || ts === null || record.ownerAskFingerprint !== askFingerprint(ask)
|
|
315
|
+
|| (record.generation === 'known' && record.ownerAskTs !== ts)) return { ok: false, reason: 'generation-mismatch' };
|
|
316
|
+
return updateImportedDismissal(ownerId, askId, { generation: 'known', ownerAskTs: ts });
|
|
317
|
+
}
|
|
318
|
+
|
|
211
319
|
// --- ciclo answer (da revisione): claim atomico open -> answering ---------------
|
|
212
320
|
// Node e' single-threaded ma il paste e' un await: due answer concorrenti
|
|
213
321
|
// superavano entrambe il check `answered` prima che una marcasse. Il claim
|
|
@@ -301,7 +409,7 @@ function createAsksStore(opts = {}) {
|
|
|
301
409
|
return commit(id, text);
|
|
302
410
|
}
|
|
303
411
|
|
|
304
|
-
return { create, get, list, openCount, isImported, findImported, closeImported, claim, release, commit, markAnswered, markReconciled, isAnswering, dismiss, validate, health, filePath, MAX_OPEN, MAX_OPEN_IMPORTED };
|
|
412
|
+
return { sameImportedGeneration, dismissImported, getImportedDismissal, listImportedDismissals, isImportedDismissed, updateImportedDismissal, adoptImportedDismissal, askFingerprint, create, get, list, openCount, isImported, findImported, closeImported, claim, release, commit, markAnswered, markReconciled, isAnswering, dismiss, validate, health, filePath, MAX_OPEN, MAX_OPEN_IMPORTED };
|
|
305
413
|
}
|
|
306
414
|
|
|
307
|
-
module.exports = { createAsksStore };
|
|
415
|
+
module.exports = { createAsksStore, askFingerprint };
|
|
@@ -102,7 +102,9 @@ function createEventFeedAsksRoutes(deps) {
|
|
|
102
102
|
const readonly = deps.readonly && deps.readonly();
|
|
103
103
|
const status = !resolved || readonly ? 'denied'
|
|
104
104
|
: resolved.ask.dismissed ? 'dismissed' : resolved.ask.answered ? 'answered' : 'open';
|
|
105
|
-
return res.json({ ownerId: g.self, askId, canReply: status === 'open', status
|
|
105
|
+
return res.json({ ownerId: g.self, askId, canReply: status === 'open', status,
|
|
106
|
+
...(resolved && !readonly ? { generationPrecondition: true, ask: { id: resolved.ask.id, ts: resolved.ask.ts, question: resolved.ask.question,
|
|
107
|
+
...(resolved.ask.options ? { options: resolved.ask.options } : {}), session: resolved.ask.session } } : {}) });
|
|
106
108
|
});
|
|
107
109
|
|
|
108
110
|
r.post('/:id/answer', json, async (req, res) => {
|
|
@@ -157,15 +159,26 @@ function createEventFeedAsksRoutes(deps) {
|
|
|
157
159
|
const askId = String(req.params.id || '');
|
|
158
160
|
const resolved = resolveAskForPeer(g.peer, askId);
|
|
159
161
|
if (!resolved) return res.status(404).json({ error: 'ask inesistente' });
|
|
160
|
-
|
|
162
|
+
const expected = req.headers['x-nexuscrew-ask-ts'];
|
|
163
|
+
if (expected !== undefined && (typeof expected !== 'string' || !/^[1-9][0-9]{0,15}$/.test(expected)
|
|
164
|
+
|| !Number.isSafeInteger(Number(expected)))) {
|
|
165
|
+
return res.status(400).json({ error: 'invalid generation precondition', reason: 'invalid-generation' });
|
|
166
|
+
}
|
|
167
|
+
// Keep the generation and terminal guards next to the synchronous service:
|
|
168
|
+
// authorization may await, but no yield is allowed between this check and dismiss.
|
|
169
|
+
if (expected !== undefined && resolved.ask.ts !== Number(expected)) {
|
|
170
|
+
return res.status(409).json({ error: 'ask generation changed', reason: 'generation-mismatch' });
|
|
171
|
+
}
|
|
172
|
+
if (resolved.ask.answered) return res.json({ dismissed: false, id: askId, outcome: 'answered', idempotent: true });
|
|
173
|
+
if (resolved.ask.dismissed) return res.json({ dismissed: true, id: askId, outcome: 'dismissed', idempotent: true });
|
|
161
174
|
const out = deps.answerService.dismiss(askId);
|
|
162
175
|
if (!out.ok) {
|
|
163
176
|
if (out.reason === 'unknown') return res.status(404).json({ error: 'ask inesistente' });
|
|
164
|
-
if (out.reason
|
|
165
|
-
return res.status(500).json({ error: 'dismiss
|
|
177
|
+
if (['answering', 'delivery-unknown-block'].includes(out.reason)) return res.status(409).json({ error: out.error || 'ask cannot be dismissed', reason: out.reason });
|
|
178
|
+
return res.status(out.code || 500).json({ error: out.error || 'dismiss failed', reason: out.reason });
|
|
166
179
|
}
|
|
167
180
|
if (out.closure) { try { await out.closure; } catch (_) {} }
|
|
168
|
-
return res.json({ dismissed: true, id: askId, idempotent: out.idempotent === true });
|
|
181
|
+
return res.json({ dismissed: true, id: askId, outcome: 'dismissed', idempotent: out.idempotent === true });
|
|
169
182
|
});
|
|
170
183
|
|
|
171
184
|
r.get('/:id/requests/:requestId', async (req, res) => {
|
|
@@ -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;
|
|
@@ -225,7 +227,7 @@ function createEventFeedClient(opts = {}) {
|
|
|
225
227
|
// ask: the tombstone is what keeps it from coming back.
|
|
226
228
|
view.asks = Array.isArray(snap.asks)
|
|
227
229
|
? snap.asks
|
|
228
|
-
.filter((a) => a && !askIsClosed(ownerId, a.ownerAskId || a.id, snap.viewEpoch))
|
|
230
|
+
.filter((a) => a && !askIsClosed(ownerId, a.ownerAskId || a.id, snap.viewEpoch) && !isAskDismissed(ownerId, a))
|
|
229
231
|
.map((a) => ({ ...a, ownerId }))
|
|
230
232
|
: [];
|
|
231
233
|
view.notifications = Array.isArray(snap.notifications) ? snap.notifications.map((e) => ({ ...e, ownerId })) : [];
|
|
@@ -264,15 +266,19 @@ function createEventFeedClient(opts = {}) {
|
|
|
264
266
|
eventId: envelope.eventId, ts: Date.now(),
|
|
265
267
|
});
|
|
266
268
|
} else if (f.type === 'ask') {
|
|
269
|
+
const ask = { id: f.askId, question: f.question, options: f.options, session: f.session, ts: f.ts,
|
|
270
|
+
ownerAskTs: Number.isSafeInteger(f.askTs) && f.askTs > 0 ? f.askTs : null, ownerId: envelope.ownerId };
|
|
271
|
+
if (isAskDismissed(envelope.ownerId, ask)) return false;
|
|
267
272
|
opts.eventsHub.broadcast({ type: 'ask', ownerId: envelope.ownerId, eventId: envelope.eventId,
|
|
268
|
-
ask
|
|
273
|
+
ask });
|
|
269
274
|
} else if (f.type === 'ask-closed') {
|
|
275
|
+
if (typeof opts.onAskClosed === 'function') opts.onAskClosed(envelope.ownerId, f.askId, f.outcome);
|
|
270
276
|
// The owner says the ask is closed: the VIEW drops it too, not only the
|
|
271
277
|
// UI, or the next state read hands the card back.
|
|
272
278
|
const closedView = dropAskFromView(envelope.ownerId, f.askId);
|
|
273
279
|
if (f.outcome === 'dismissed') noteAskClosed(envelope.ownerId, f.askId, closedView.viewEpoch);
|
|
274
280
|
opts.eventsHub.broadcast({ type: f.outcome === 'dismissed' ? 'ask-dismissed' : 'ask-answered',
|
|
275
|
-
id: f.askId, ownerId: envelope.ownerId, eventId: envelope.eventId });
|
|
281
|
+
id: f.askId, ownerAskId: f.askId, ownerId: envelope.ownerId, eventId: envelope.eventId });
|
|
276
282
|
} else if (f.type === 'file-notice') {
|
|
277
283
|
// Stessa forma della consegna locale (lib/files/routes.js): il nome del
|
|
278
284
|
// file e' SEMPRE nel corpo, con la caption quando c'e'. Prima l'importato
|
|
@@ -780,7 +786,7 @@ function createEventFeedClient(opts = {}) {
|
|
|
780
786
|
lastError: v.lastError || null,
|
|
781
787
|
ingressBlockedUntil: v.ingressBlockedUntil || null,
|
|
782
788
|
ingressBlockReason: v.ingressBlockReason || null,
|
|
783
|
-
asks: v.asks, notifications: v.notifications, fleetState: v.fleetState,
|
|
789
|
+
asks: visibleAsks(ownerId, v.asks), notifications: v.notifications, fleetState: v.fleetState,
|
|
784
790
|
})),
|
|
785
791
|
};
|
|
786
792
|
}
|
|
@@ -74,6 +74,7 @@ const PRODUCERS = {
|
|
|
74
74
|
question: cleanStr(p.question, 3900) || '',
|
|
75
75
|
...(Array.isArray(p.options) ? { options: p.options.slice(0, 8).map((o) => cleanStr(o, 200) || '') } : {}),
|
|
76
76
|
session: cleanStr(p.session, 128) || undefined,
|
|
77
|
+
...(Number.isSafeInteger(p.askTs) && p.askTs > 0 ? { askTs: p.askTs } : {}),
|
|
77
78
|
ts: typeof p.ts === 'number' ? p.ts : undefined,
|
|
78
79
|
}),
|
|
79
80
|
},
|
|
@@ -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 };
|