@mmmbuto/nexuscrew 0.9.58 → 0.9.60
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/README.md +32 -8
- package/frontend/dist/assets/index-D42BJVwr.js +169 -0
- package/frontend/dist/index.html +1 -1
- package/frontend/dist/sw.js +18 -4
- package/frontend/dist/version.json +1 -1
- package/lib/audio/dispatch.js +5 -0
- package/lib/fleet/builtin.js +10 -4
- package/lib/fleet/managed.js +76 -47
- package/lib/fleet/runtime.js +15 -1
- package/lib/mcp/tools.js +1 -0
- package/lib/nodes/store.js +19 -1
- package/lib/notify/ask-alert-identity.js +24 -0
- package/lib/notify/ask-alert-registry.js +96 -0
- package/lib/notify/ask-relay.js +64 -14
- package/lib/notify/asks.js +124 -10
- package/lib/notify/closure-retry.js +5 -4
- package/lib/notify/event-feed-asks-routes.js +18 -5
- package/lib/notify/event-feed-client.js +146 -15
- package/lib/notify/event-feed-producers.js +5 -0
- package/lib/notify/event-feed-routes.js +2 -1
- package/lib/notify/imported-dismissals.js +106 -0
- package/lib/notify/notifier.js +19 -7
- package/lib/notify/owner-ask-scope.js +35 -0
- package/lib/notify/push-relay.js +10 -1
- package/lib/notify/routes.js +29 -15
- package/lib/server.js +177 -22
- package/lib/tmux/list.js +45 -0
- package/lib/tmux-reads.js +44 -0
- package/package.json +1 -1
- package/frontend/dist/assets/index-D8cf4AEo.js +0 -169
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,17 +141,28 @@ 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
|
|
|
128
148
|
// Chiusura dell'alias locale quando l'OWNER chiude la domanda. Durevole: la
|
|
129
149
|
// riga resta nello storico marcata, quindi non ricompare a un reload e non
|
|
130
150
|
// torna risponibile.
|
|
131
|
-
function closeImported({ ownerId, ownerAskId, outcome }) {
|
|
151
|
+
function closeImported({ ownerId, ownerAskId, ownerAskTs, outcome }) {
|
|
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
|
+
// Known closure generations cannot mutate a newer alias or dismissal receipt.
|
|
157
|
+
const closureTs = Number.isSafeInteger(ownerAskTs) && ownerAskTs > 0 ? ownerAskTs : null;
|
|
158
|
+
const recordMismatch = closureTs && record && record.ownerAskTs && record.ownerAskTs !== closureTs;
|
|
159
|
+
if ((closureTs && ask && generationTs(ask) && generationTs(ask) !== closureTs) || (!ask && recordMismatch)) {
|
|
160
|
+
return { ok: true, changed: false, ask: null, generationMismatch: true };
|
|
161
|
+
}
|
|
162
|
+
if (record && !recordMismatch) {
|
|
163
|
+
const synced = updateImportedDismissal(ownerId, ownerAskId, { syncState: outcome === 'answered' ? 'confirmed-answered' : 'confirmed-dismissed', ownerOutcome: outcome });
|
|
164
|
+
if (!synced.ok) return { ...synced, changed: false, ask: null };
|
|
165
|
+
}
|
|
135
166
|
if (!ask) return { ok: true, changed: false, ask: null };
|
|
136
167
|
if (ask.answered || ask.dismissed) return { ok: true, changed: false, ask };
|
|
137
168
|
if (outcome === 'answered') { ask.answered = true; ask.answeredReconciled = true; }
|
|
@@ -141,7 +172,7 @@ function createAsksStore(opts = {}) {
|
|
|
141
172
|
return { ok: true, changed: true, ask };
|
|
142
173
|
}
|
|
143
174
|
|
|
144
|
-
function create({ question, options, session, ownerId, ownerAskId, originNode, originCell }) {
|
|
175
|
+
function create({ question, options, session, ownerId, ownerAskId, originNode, originCell, ownerAskTs }) {
|
|
145
176
|
const ur = unreadableGuard();
|
|
146
177
|
if (ur) return { ok: false, reason: 'store-unreadable', error: `store illeggibile (${ur}): creazione rifiutata` };
|
|
147
178
|
const v = validate({ question, options });
|
|
@@ -152,7 +183,12 @@ function createAsksStore(opts = {}) {
|
|
|
152
183
|
// se ne crea un secondo.
|
|
153
184
|
if (originNode && ownerId && ownerAskId) {
|
|
154
185
|
const existing = findImported(ownerId, ownerAskId);
|
|
155
|
-
|
|
186
|
+
const incoming = { id: ownerAskId, question: v.value.question, options: v.value.options, session: String(session || ''), originNode, ownerAskTs };
|
|
187
|
+
if (isImportedDismissed(ownerId, incoming)) return { ok: true, ask: existing, suppressed: true, deduped: !!existing };
|
|
188
|
+
if (existing && askFingerprint(existing) === askFingerprint(incoming)
|
|
189
|
+
&& (generationTs(existing) === null || generationTs(incoming) === null || generationTs(existing) === generationTs(incoming))) {
|
|
190
|
+
return { ok: true, ask: existing, deduped: true };
|
|
191
|
+
}
|
|
156
192
|
}
|
|
157
193
|
// Cap duro sugli aperti, SEPARATO PER CLASSE: gli importati non consumano il
|
|
158
194
|
// budget dei locali. Con un cap unico, cento domande ricevute da un peer
|
|
@@ -188,6 +224,7 @@ function createAsksStore(opts = {}) {
|
|
|
188
224
|
// ri-esportato (nessun loop A->B->A). Un ask locale non ha questo campo.
|
|
189
225
|
...(originNode ? { originNode: String(originNode) } : {}),
|
|
190
226
|
...(originCell ? { originCell: String(originCell) } : {}),
|
|
227
|
+
...(Number.isSafeInteger(ownerAskTs) && ownerAskTs > 0 ? { ownerAskTs } : {}),
|
|
191
228
|
ts: now(),
|
|
192
229
|
revision: 0,
|
|
193
230
|
answered: false,
|
|
@@ -208,6 +245,83 @@ function createAsksStore(opts = {}) {
|
|
|
208
245
|
.map((a) => ({ ...a, ...(a.options ? { options: a.options.slice() } : {}) }));
|
|
209
246
|
}
|
|
210
247
|
|
|
248
|
+
function getImportedDismissal(ownerId, askId) {
|
|
249
|
+
load();
|
|
250
|
+
const record = importedDismissals && importedDismissals[`${ownerId}|${askId}`];
|
|
251
|
+
return record ? { ...record } : null;
|
|
252
|
+
}
|
|
253
|
+
function listImportedDismissals() {
|
|
254
|
+
load();
|
|
255
|
+
return Object.values(importedDismissals || {}).map(r => ({ ...r }));
|
|
256
|
+
}
|
|
257
|
+
function generationTs(ask) {
|
|
258
|
+
const ts = Object.hasOwn(ask, 'ownerAskTs') ? ask.ownerAskTs : ask.originNode ? null : ask.ts;
|
|
259
|
+
return Number.isSafeInteger(ts) && ts > 0 ? ts : null;
|
|
260
|
+
}
|
|
261
|
+
function sameImportedGeneration(a, b) {
|
|
262
|
+
const at = generationTs(a), bt = generationTs(b);
|
|
263
|
+
return askFingerprint(a) === askFingerprint(b) && (at === null || bt === null || at === bt);
|
|
264
|
+
}
|
|
265
|
+
function isImportedDismissed(ownerId, ask) {
|
|
266
|
+
const record = getImportedDismissal(ownerId, ask.ownerAskId || ask.id);
|
|
267
|
+
if (!record || record.ownerAskFingerprint !== askFingerprint(ask)) return false;
|
|
268
|
+
// Historical imports have no original owner timestamp. Reusing an id with
|
|
269
|
+
// identical content remains suppressed; this is not proof of historical identity.
|
|
270
|
+
const ts = generationTs(ask);
|
|
271
|
+
return record.generation === 'unknown' || ts === null || record.ownerAskTs === ts;
|
|
272
|
+
}
|
|
273
|
+
function dismissalTransaction(change) {
|
|
274
|
+
const ur = unreadableGuard();
|
|
275
|
+
if (ur) return { ok: false, reason: 'store-unreadable' };
|
|
276
|
+
load();
|
|
277
|
+
const beforeAsks = asks.map(a => ({ ...a }));
|
|
278
|
+
const beforeRecords = Object.fromEntries(Object.entries(importedDismissals).map(([k, r]) => [k, { ...r }]));
|
|
279
|
+
try { const result = change(); if (!result.ok) return result; save(); return result; }
|
|
280
|
+
catch (_) { asks = beforeAsks; importedDismissals = beforeRecords; return { ok: false, reason: 'persist-failed' }; }
|
|
281
|
+
}
|
|
282
|
+
function dismissImported({ ownerId, ownerAskId, ask, cellId, retryBinding, syncState = 'pending' } = {}) {
|
|
283
|
+
return dismissalTransaction(() => {
|
|
284
|
+
if (!/^[a-f0-9]{32}$/.test(ownerId) || !/^[a-f0-9]{8}$/.test(ownerAskId) || !ask || (ask.ownerAskId || ask.id) !== ownerAskId) return { ok: false, reason: 'unknown' };
|
|
285
|
+
const candidate = findImported(ownerId, ownerAskId);
|
|
286
|
+
const alias = candidate && sameImportedGeneration(candidate, ask) ? candidate : null;
|
|
287
|
+
if (alias && answering.has(alias.id)) return { ok: false, reason: 'answering' };
|
|
288
|
+
if (ask.answered || (alias && alias.answered)) return { ok: false, reason: 'answered' };
|
|
289
|
+
const key = `${ownerId}|${ownerAskId}`;
|
|
290
|
+
const existing = importedDismissals[key];
|
|
291
|
+
if (existing && isImportedDismissed(ownerId, ask)) return { ok: true, record: { ...existing }, idempotent: true };
|
|
292
|
+
if (!existing && Object.keys(importedDismissals).length >= dismissalCap) return { ok: false, reason: 'dismissal-cap' };
|
|
293
|
+
const ts = generationTs(ask);
|
|
294
|
+
const record = { ownerId, ownerAskId, ...(cellId ? { cellId } : {}), ...(alias ? { localAliasId: alias.id } : {}),
|
|
295
|
+
dismissed: true, dismissedReason: 'owner-unreachable', dismissedTs: now(),
|
|
296
|
+
generation: ts === null ? 'unknown' : 'known', ownerAskTs: ts,
|
|
297
|
+
ownerAskFingerprint: askFingerprint(ask), syncState, attempts: 0, nextRetryAt: 0,
|
|
298
|
+
...(syncState === 'blocked' ? { lastReason: 'permission-denied', ...(retryBinding ? { retryBinding } : {}) } : {}) };
|
|
299
|
+
importedDismissals[key] = record;
|
|
300
|
+
if (alias) {
|
|
301
|
+
if (!alias.dismissed) { alias.dismissed = true; alias.dismissedTs = now(); alias.revision = (alias.revision || 0) + 1; }
|
|
302
|
+
alias.dismissedReason = 'owner-unreachable';
|
|
303
|
+
}
|
|
304
|
+
return { ok: true, record: { ...record } };
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
function updateImportedDismissal(ownerId, askId, patch) {
|
|
308
|
+
return dismissalTransaction(() => {
|
|
309
|
+
const record = importedDismissals[`${ownerId}|${askId}`];
|
|
310
|
+
if (!record) return { ok: false, reason: 'unknown' };
|
|
311
|
+
const next = { ...record, ...patch };
|
|
312
|
+
if (!validDismissals({ [`${ownerId}|${askId}`]: next })) return { ok: false, reason: 'invalid' };
|
|
313
|
+
importedDismissals[`${ownerId}|${askId}`] = next;
|
|
314
|
+
return { ok: true, record: { ...next } };
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
function adoptImportedDismissal(ownerId, askId, ask) {
|
|
318
|
+
const record = getImportedDismissal(ownerId, askId);
|
|
319
|
+
const ts = generationTs(ask);
|
|
320
|
+
if (!record || (ask.ownerAskId || ask.id) !== askId || ts === null || record.ownerAskFingerprint !== askFingerprint(ask)
|
|
321
|
+
|| (record.generation === 'known' && record.ownerAskTs !== ts)) return { ok: false, reason: 'generation-mismatch' };
|
|
322
|
+
return updateImportedDismissal(ownerId, askId, { generation: 'known', ownerAskTs: ts });
|
|
323
|
+
}
|
|
324
|
+
|
|
211
325
|
// --- ciclo answer (da revisione): claim atomico open -> answering ---------------
|
|
212
326
|
// Node e' single-threaded ma il paste e' un await: due answer concorrenti
|
|
213
327
|
// superavano entrambe il check `answered` prima che una marcasse. Il claim
|
|
@@ -301,7 +415,7 @@ function createAsksStore(opts = {}) {
|
|
|
301
415
|
return commit(id, text);
|
|
302
416
|
}
|
|
303
417
|
|
|
304
|
-
return { create, get, list, openCount, isImported, findImported, closeImported, claim, release, commit, markAnswered, markReconciled, isAnswering, dismiss, validate, health, filePath, MAX_OPEN, MAX_OPEN_IMPORTED };
|
|
418
|
+
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
419
|
}
|
|
306
420
|
|
|
307
|
-
module.exports = { createAsksStore };
|
|
421
|
+
module.exports = { createAsksStore, askFingerprint };
|
|
@@ -93,7 +93,7 @@ function createClosureRetryQueue({
|
|
|
93
93
|
// riceve una seconda consegna inutile.
|
|
94
94
|
const targets = [...entry.targets];
|
|
95
95
|
try {
|
|
96
|
-
results = await run({ askId: entry.askId, outcome: entry.outcome, session: entry.session, targets });
|
|
96
|
+
results = await run({ askId: entry.askId, ...(entry.ownerAskTs ? { ownerAskTs: entry.ownerAskTs } : {}), outcome: entry.outcome, session: entry.session, targets });
|
|
97
97
|
} catch (e) {
|
|
98
98
|
results = targets.map((target) => ({ target, status: 'unknown', reason: 'dispatch-threw' }));
|
|
99
99
|
try { log(`chiusura ask ${entry.askId}: tentativo fallito (${String(e && e.message || e)})`); } catch (_) {}
|
|
@@ -129,11 +129,12 @@ function createClosureRetryQueue({
|
|
|
129
129
|
// stessa chiusura UNISCE i target invece di essere buttata via: e' cio' che
|
|
130
130
|
// impedisce a un peer spento di sparire dalla coda quando un ALTRO peer
|
|
131
131
|
// risponde, o quando la stessa chiusura viene riprovata piu' tardi.
|
|
132
|
-
function enqueue({ askId, outcome, session, targets } = {}) {
|
|
132
|
+
function enqueue({ askId, ownerAskTs, outcome, session, targets } = {}) {
|
|
133
133
|
if (stopped || !askId || !outcome) return { ok: false, reason: 'invalid' };
|
|
134
134
|
const nuovi = [...new Set((Array.isArray(targets) ? targets : []).map((t) => String(t)).filter(Boolean))];
|
|
135
135
|
if (!nuovi.length) return { ok: false, reason: 'no-targets' };
|
|
136
|
-
const
|
|
136
|
+
const generation = Number.isSafeInteger(ownerAskTs) && ownerAskTs > 0 ? ownerAskTs : null;
|
|
137
|
+
const existing = entries.find((e) => e.askId === askId && e.outcome === outcome && (e.ownerAskTs || null) === generation);
|
|
137
138
|
if (existing) {
|
|
138
139
|
const prima = existing.targets.size;
|
|
139
140
|
for (const t of nuovi) existing.targets.add(t);
|
|
@@ -148,7 +149,7 @@ function createClosureRetryQueue({
|
|
|
148
149
|
try { log(`coda chiusure piena (${maxEntries}): scartata la piu' vecchia (${oldest.askId})`); } catch (_) {}
|
|
149
150
|
}
|
|
150
151
|
const entry = {
|
|
151
|
-
askId, outcome, session, targets: new Set(nuovi),
|
|
152
|
+
askId, ...(generation ? { ownerAskTs: generation } : {}), outcome, session, targets: new Set(nuovi),
|
|
152
153
|
attempts: 0, createdAt: now(), nextAt: 0, timer: null, inFlight: false,
|
|
153
154
|
};
|
|
154
155
|
entries.push(entry);
|
|
@@ -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) => {
|