@mmmbuto/nexuscrew 0.9.31 → 0.9.32
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 +10 -0
- package/docs/CONFIGURATION.md +39 -0
- package/frontend/dist/assets/{index-DaAL7P-m.js → index-CH2iAu6y.js} +13 -13
- package/frontend/dist/index.html +1 -1
- package/frontend/dist/version.json +1 -1
- package/lib/cli/doctor.js +74 -2
- package/lib/fleet/builtin.js +84 -8
- package/lib/fleet/endpoint-probe.js +146 -0
- package/lib/fleet/managed.js +164 -10
- package/lib/fleet/model-probe.js +110 -6
- package/lib/fleet/runtime.js +29 -2
- package/lib/notify/ask-answer-service.js +10 -4
- package/lib/notify/asks.js +67 -7
- package/lib/notify/closure-retry.js +201 -0
- package/lib/notify/event-feed-asks-routes.js +4 -0
- package/lib/notify/event-feed-routes.js +8 -0
- package/lib/notify/routes.js +274 -12
- package/lib/proxy/federation.js +16 -3
- package/lib/proxy/resource-acl.js +6 -0
- package/lib/server.js +132 -14
- package/package.json +1 -1
package/lib/fleet/model-probe.js
CHANGED
|
@@ -6,11 +6,20 @@
|
|
|
6
6
|
// dice se il problema e' il nome del modello, la chiave o la rete. Qui la
|
|
7
7
|
// domanda si fa prima, e la risposta e' un enum chiuso.
|
|
8
8
|
//
|
|
9
|
-
// COSTO: si interroga l'elenco dei modelli
|
|
10
|
-
// Non si genera testo, quindi non si
|
|
11
|
-
// esiste l'esito e' `unverified`: NON si
|
|
12
|
-
// completamento, per quanto minima. Una prova che
|
|
13
|
-
// la guarda impara a non fare — e allora tanto vale
|
|
9
|
+
// COSTO, per gli engine DI CATALOGO: si interroga l'elenco dei modelli
|
|
10
|
+
// (`GET .../models`) e nient'altro. Non si genera testo, quindi non si
|
|
11
|
+
// consumano token. Dove il catalogo non esiste l'esito e' `unverified`: NON si
|
|
12
|
+
// ricade su una richiesta di completamento, per quanto minima. Una prova che
|
|
13
|
+
// costa e' una prova che chi la guarda impara a non fare — e allora tanto vale
|
|
14
|
+
// non averla.
|
|
15
|
+
//
|
|
16
|
+
// COSTO, per un endpoint DICHIARATO A MANO (`probeCustomEndpoint`): la regola
|
|
17
|
+
// sopra vale finche' il catalogo c'e'. Un router locale spesso non espone
|
|
18
|
+
// `GET /models`, e su quel ramo l'alternativa a una richiesta minima non e' una
|
|
19
|
+
// prova gratuita — e' nessuna prova, cioe' un `unverified` su un modello che
|
|
20
|
+
// quasi certamente esiste. Li' si ricade su UN completamento da un token
|
|
21
|
+
// (`max_tokens: 1`) e il testo generato non viene letto ne' registrato: la
|
|
22
|
+
// risposta serve solo a distinguere «c'e'» da «non c'e'».
|
|
14
23
|
//
|
|
15
24
|
// COSA NON ESCE MAI DA QUI:
|
|
16
25
|
// - il testo che il modello eventualmente genera: non viene letto, non viene
|
|
@@ -44,6 +53,98 @@ function modelsUrl(profile) {
|
|
|
44
53
|
return joinUrl(endpoint, 'models');
|
|
45
54
|
}
|
|
46
55
|
|
|
56
|
+
// Un endpoint dichiarato A MANO non sta nel catalogo pubblico: la prova si fa
|
|
57
|
+
// sul suo indirizzo. Stessa forma di URL della sonda di prontezza (`/v1` non si
|
|
58
|
+
// ripete), stesso verdetto enum della prova di catalogo — chi legge l'esito non
|
|
59
|
+
// deve sapere da quale dei due rami e' arrivato.
|
|
60
|
+
function chatCompletionsUrl(baseUrl) {
|
|
61
|
+
const b = String(baseUrl || '').trim().replace(/\/+$/, '');
|
|
62
|
+
if (!/^https?:\/\//i.test(b)) return null;
|
|
63
|
+
return /\/v1$/.test(b) ? `${b}/chat/completions` : `${b}/v1/chat/completions`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Un endpoint locale spesso non espone `GET /models` (o lo espone vuoto). La
|
|
67
|
+
// seconda via e' la richiesta minima: `max_tokens: 1`, nessun testo letto, e il
|
|
68
|
+
// corpo della risposta NON entra nell'esito. Serve a distinguere «il modello
|
|
69
|
+
// c'e'» da «il modello non c'e'», non a misurare la qualita' della risposta.
|
|
70
|
+
const CUSTOM_FALLBACK_TIMEOUT_MS = 10000;
|
|
71
|
+
|
|
72
|
+
async function probeCustomEndpoint({
|
|
73
|
+
endpoint, credential = '', model, fetchImpl = fetch,
|
|
74
|
+
timeoutMs = DEFAULT_TIMEOUT_MS, fallbackTimeoutMs = CUSTOM_FALLBACK_TIMEOUT_MS,
|
|
75
|
+
} = {}) {
|
|
76
|
+
const listUrl = modelsUrl({ endpoint });
|
|
77
|
+
if (!listUrl) return { outcome: 'unverified', latencyMs: 0, detail: 'endpoint non interrogabile' };
|
|
78
|
+
|
|
79
|
+
const started = Date.now();
|
|
80
|
+
const headers = credential ? { authorization: `Bearer ${credential}` } : {};
|
|
81
|
+
const list = await timedFetch({
|
|
82
|
+
fetchImpl, url: listUrl, method: 'GET', headers, timeoutMs,
|
|
83
|
+
});
|
|
84
|
+
if (list.transport === 'timeout') return { outcome: 'unreachable', latencyMs: list.latencyMs, detail: `timeout (${timeoutMs}ms)` };
|
|
85
|
+
if (list.transport === 'error') return { outcome: 'unreachable', latencyMs: list.latencyMs, detail: 'endpoint non raggiungibile' };
|
|
86
|
+
if (list.status === 401 || list.status === 403) return { outcome: 'auth', latencyMs: list.latencyMs };
|
|
87
|
+
if (list.status >= 200 && list.status < 300) {
|
|
88
|
+
// Un elenco VUOTO non e' «il modello non c'e'»: e' un endpoint che non
|
|
89
|
+
// espone un catalogo. Trattarlo come assenza darebbe un falso negativo su
|
|
90
|
+
// un modello che risponde benissimo — il caso tipico dei router locali.
|
|
91
|
+
const rows = Array.isArray(list.payload && list.payload.data) ? list.payload.data
|
|
92
|
+
: Array.isArray(list.payload && list.payload.models) ? list.payload.models : null;
|
|
93
|
+
if (rows && rows.length) {
|
|
94
|
+
const found = findInCatalog(list.payload, model);
|
|
95
|
+
if (found === true) return { outcome: 'ok', latencyMs: list.latencyMs };
|
|
96
|
+
if (found === false) return { outcome: 'unknown-model', latencyMs: list.latencyMs };
|
|
97
|
+
}
|
|
98
|
+
// Elenco assente, vuoto o illeggibile: si prova la via minima invece di
|
|
99
|
+
// dichiarare `unverified` un modello che probabilmente c'e'.
|
|
100
|
+
} else if (list.status >= 500) {
|
|
101
|
+
return { outcome: 'unreachable', latencyMs: list.latencyMs, detail: `http ${list.status}` };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const chatUrl = chatCompletionsUrl(endpoint);
|
|
105
|
+
if (!chatUrl) return { outcome: 'unverified', latencyMs: Date.now() - started, detail: 'endpoint non interrogabile' };
|
|
106
|
+
const chat = await timedFetch({
|
|
107
|
+
fetchImpl,
|
|
108
|
+
url: chatUrl,
|
|
109
|
+
method: 'POST',
|
|
110
|
+
headers: { 'content-type': 'application/json', ...headers },
|
|
111
|
+
body: JSON.stringify({ model, max_tokens: 1, messages: [{ role: 'user', content: 'ping' }] }),
|
|
112
|
+
timeoutMs: fallbackTimeoutMs,
|
|
113
|
+
});
|
|
114
|
+
const latencyMs = Date.now() - started;
|
|
115
|
+
if (chat.transport === 'timeout') return { outcome: 'unreachable', latencyMs, detail: `timeout (${fallbackTimeoutMs}ms)` };
|
|
116
|
+
if (chat.transport === 'error') return { outcome: 'unreachable', latencyMs, detail: 'endpoint non raggiungibile' };
|
|
117
|
+
if (chat.status === 401 || chat.status === 403) return { outcome: 'auth', latencyMs };
|
|
118
|
+
if (chat.status >= 200 && chat.status < 300) return { outcome: 'ok', latencyMs };
|
|
119
|
+
// 400/404 su un modello chiesto per nome: l'endpoint risponde e non lo
|
|
120
|
+
// conosce. E' l'esito piu' utile che si possa dare senza leggere il corpo.
|
|
121
|
+
if (chat.status === 400 || chat.status === 404 || chat.status === 422) return { outcome: 'unknown-model', latencyMs };
|
|
122
|
+
return { outcome: 'unverified', latencyMs, detail: `http ${chat.status}` };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Una fetch sola, con budget proprio, che non solleva mai: l'esito e' un valore.
|
|
126
|
+
async function timedFetch({ fetchImpl, url, method, headers, body, timeoutMs }) {
|
|
127
|
+
const started = Date.now();
|
|
128
|
+
const controller = new AbortController();
|
|
129
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
130
|
+
try {
|
|
131
|
+
const res = await fetchImpl(url, {
|
|
132
|
+
method, signal: controller.signal, headers, ...(body ? { body } : {}),
|
|
133
|
+
});
|
|
134
|
+
const status = res && typeof res.status === 'number' ? res.status : 0;
|
|
135
|
+
let payload = null;
|
|
136
|
+
if (status >= 200 && status < 300) {
|
|
137
|
+
try { payload = typeof res.json === 'function' ? await res.json() : null; } catch (_) { payload = null; }
|
|
138
|
+
}
|
|
139
|
+
return { status, payload, latencyMs: Date.now() - started };
|
|
140
|
+
} catch (error) {
|
|
141
|
+
const aborted = error && (error.name === 'AbortError' || error.code === 'ABORT_ERR');
|
|
142
|
+
return { status: 0, payload: null, latencyMs: Date.now() - started, transport: aborted ? 'timeout' : 'error' };
|
|
143
|
+
} finally {
|
|
144
|
+
clearTimeout(timer);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
47
148
|
function authHeaders(profile, credential) {
|
|
48
149
|
const protocol = profile && profile.protocol;
|
|
49
150
|
if (protocol === 'anthropic_messages') {
|
|
@@ -126,4 +227,7 @@ async function probeModel({
|
|
|
126
227
|
}
|
|
127
228
|
}
|
|
128
229
|
|
|
129
|
-
module.exports = {
|
|
230
|
+
module.exports = {
|
|
231
|
+
probeModel, OUTCOMES, findInCatalog, modelsUrl,
|
|
232
|
+
probeCustomEndpoint, chatCompletionsUrl, CUSTOM_FALLBACK_TIMEOUT_MS,
|
|
233
|
+
};
|
package/lib/fleet/runtime.js
CHANGED
|
@@ -20,6 +20,7 @@ const {
|
|
|
20
20
|
const {
|
|
21
21
|
describeManaged, resolveManagedEngine, discoverOllamaModels, discoverPiModels, extraModelsFrom,
|
|
22
22
|
} = require('./managed.js');
|
|
23
|
+
const { sharedProbe: defaultEndpointProbe } = require('./endpoint-probe.js');
|
|
23
24
|
const {
|
|
24
25
|
httpError, minimalEnv, tmuxExec,
|
|
25
26
|
composeClientInvocation, alternateScreenArgs,
|
|
@@ -170,12 +171,29 @@ function createBuiltinRuntime(ctx) {
|
|
|
170
171
|
// Le discovery esterne hanno budget propri. Avviarle in parallelo mantiene
|
|
171
172
|
// il budget dello status sotto quello del bridge invece di sommare i timeout
|
|
172
173
|
// di Ollama e Pi in sequenza.
|
|
173
|
-
|
|
174
|
+
// Stessa logica per gli endpoint dichiarati a mano: la sonda ha un budget
|
|
175
|
+
// suo (≤ 1,5 s) e va in parallelo alle discovery, non in coda. Si attende
|
|
176
|
+
// SOLO cio' che non e' in cache: entro il TTL il verdetto e' gia' noto, ed
|
|
177
|
+
// e' questo che tiene `nc_status` e la UI lontani dal tempestare i router.
|
|
178
|
+
const endpointProbe = cfg.endpointProbe || defaultEndpointProbe;
|
|
179
|
+
const customUrls = [...new Set(cache.defs.engines
|
|
180
|
+
.map((e) => (e.managed && e.managed.baseUrl ? e.managed.baseUrl : null))
|
|
181
|
+
.filter(Boolean))];
|
|
182
|
+
const endpointVerdicts = new Map();
|
|
183
|
+
const probeEndpoints = customUrls.length
|
|
184
|
+
? Promise.all(customUrls.map((u) => endpointProbe.status(u).then((v) => [u, v]).catch(() => [u, null])))
|
|
185
|
+
: Promise.resolve([]);
|
|
186
|
+
const [ollamaModels, piModels, probedEndpoints] = await Promise.all([
|
|
174
187
|
needsOllama ? discoverOllamaModels({ ...cfg, home }) : [],
|
|
175
188
|
needsPi ? discoverPiModels({ ...cfg, home }) : {},
|
|
189
|
+
probeEndpoints,
|
|
176
190
|
]);
|
|
191
|
+
for (const [u, v] of probedEndpoints) endpointVerdicts.set(u, v);
|
|
177
192
|
const engines = cache.defs.engines.map((e) => {
|
|
178
|
-
const managed = e.managed ? describeManaged(e.managed, {
|
|
193
|
+
const managed = e.managed ? describeManaged(e.managed, {
|
|
194
|
+
...cfg, home, extraModels: extraModelsFrom(cache.defs), engineId: e.id,
|
|
195
|
+
endpointVerdict: e.managed && e.managed.baseUrl ? endpointVerdicts.get(e.managed.baseUrl) || null : null,
|
|
196
|
+
}) : null;
|
|
179
197
|
return {
|
|
180
198
|
id: e.id, label: e.label, rc: !!e.rc,
|
|
181
199
|
...(managed ? {
|
|
@@ -190,6 +208,15 @@ function createBuiltinRuntime(ctx) {
|
|
|
190
208
|
: (piModels[e.managed.provider] || [])))
|
|
191
209
|
: managed.models),
|
|
192
210
|
configured: managed.configured, reason: managed.reason,
|
|
211
|
+
// Provenienza della credenziale risolta: prima non arrivava affatto
|
|
212
|
+
// qui, quindi `nc_status` e la vista non potevano distinguere le
|
|
213
|
+
// origini nemmeno in teoria. Path, mtime e impronta del valore — il
|
|
214
|
+
// valore non esce mai da `managed.js`.
|
|
215
|
+
credentialSource: managed.credentialSource || 'missing',
|
|
216
|
+
credentialPath: managed.credentialPath || '',
|
|
217
|
+
credentialMtime: managed.credentialMtime || 0,
|
|
218
|
+
credentialHash8: managed.credentialHash8 || '',
|
|
219
|
+
credentialConflict: managed.credentialConflict || null,
|
|
193
220
|
} : { kind: 'custom', configured: true, model: e.model?.value || '', models: [] }),
|
|
194
221
|
};
|
|
195
222
|
});
|
|
@@ -61,8 +61,12 @@ function createAskAnswerService({ asks, paste, receipts, onClosure, labelPrefix
|
|
|
61
61
|
const committed = asks.commit(askId, text);
|
|
62
62
|
if (request && receipts) receipts.finalize(request.peerId, askId, request.requestId, 'committed', null);
|
|
63
63
|
const finalAsk = asks.get(askId);
|
|
64
|
-
emitClosure('ask-answered', { askId, revision: (finalAsk && finalAsk.revision) || 1, cellSession: finalAsk && finalAsk.session });
|
|
65
|
-
|
|
64
|
+
const closure = emitClosure('ask-answered', { askId, revision: (finalAsk && finalAsk.revision) || 1, cellSession: finalAsk && finalAsk.session });
|
|
65
|
+
// `closure` e' il lavoro di recapito della chiusura verso i peer. Nasce QUI,
|
|
66
|
+
// nel punto in cui la transizione e' autorevole, e non su una rotta: legarlo
|
|
67
|
+
// alle route locali lasciava fuori la via federata, che e' il caso normale
|
|
68
|
+
// quando a rispondere e' un altro nodo. Chi puo' attendere lo attende.
|
|
69
|
+
return { ok: true, committed, closure };
|
|
66
70
|
}
|
|
67
71
|
|
|
68
72
|
// LOCAL answer: validation and binding already happened in the route.
|
|
@@ -115,8 +119,10 @@ function createAskAnswerService({ asks, paste, receipts, onClosure, labelPrefix
|
|
|
115
119
|
}
|
|
116
120
|
const out = asks.dismiss(askId);
|
|
117
121
|
if (!out.ok) return out;
|
|
118
|
-
emitClosure('ask-dismissed', { askId, revision: (out.ask && out.ask.revision) || 1, cellSession: out.ask && out.ask.session });
|
|
119
|
-
|
|
122
|
+
const closure = emitClosure('ask-dismissed', { askId, revision: (out.ask && out.ask.revision) || 1, cellSession: out.ask && out.ask.session });
|
|
123
|
+
// Stesso punto comune dell'answer. `dismiss` resta SINCRONO — i chiamanti
|
|
124
|
+
// esistenti leggono `ok` subito — e il recapito viaggia come promise a parte.
|
|
125
|
+
return { ...out, closure };
|
|
120
126
|
}
|
|
121
127
|
|
|
122
128
|
// Operator reconciliation of a delivery-unknown attempt. The revision is a
|
package/lib/notify/asks.js
CHANGED
|
@@ -13,6 +13,10 @@ const ASKS_FILE = 'asks.json';
|
|
|
13
13
|
// RIFIUTATO (reason 'cap'), mai droppato uno aperto. MAX_KEEP pota solo gli
|
|
14
14
|
// answered piu' vecchi dal file.
|
|
15
15
|
const MAX_OPEN = 100;
|
|
16
|
+
// Cap degli ask IMPORTATI, separato da quello locale (stessa cifra): le domande
|
|
17
|
+
// che arrivano da un peer e quelle poste da questo nodo sono due classi
|
|
18
|
+
// distinte, e le prime non devono poter esaurire il budget delle seconde.
|
|
19
|
+
const MAX_OPEN_IMPORTED = 100;
|
|
16
20
|
const MAX_KEEP = 100; // ask totali persistiti (i piu' vecchi answered si potano)
|
|
17
21
|
const MAX_QUESTION = 2000;
|
|
18
22
|
const MAX_OPTIONS = 8;
|
|
@@ -65,19 +69,58 @@ function createAsksStore(opts = {}) {
|
|
|
65
69
|
return { ok: true, value: { question: question.trim(), options: opts2 } };
|
|
66
70
|
}
|
|
67
71
|
|
|
68
|
-
|
|
69
|
-
|
|
72
|
+
// Un ask IMPORTATO appartiene a un altro nodo: lo marca `originNode`, che e'
|
|
73
|
+
// anche il marcatore che ne impedisce la ri-esportazione.
|
|
74
|
+
function isImported(a) { return !!(a && a.originNode); }
|
|
75
|
+
|
|
76
|
+
function openCount(kind = 'local') {
|
|
77
|
+
return load().filter((a) => !a.answered && !a.dismissed
|
|
78
|
+
&& (kind === 'imported' ? isImported(a) : !isImported(a))).length;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Identita' CANONICA di un ask importato: la coppia (ownerId, ownerAskId).
|
|
82
|
+
// L'`id` locale e' nostro e all'owner non dice nulla; e' la coppia che nomina
|
|
83
|
+
// lo STESSO oggetto sui due nodi, quindi e' la chiave con cui si riconcilia.
|
|
84
|
+
function findImported(ownerId, ownerAskId) {
|
|
85
|
+
return load().find((a) => isImported(a) && a.ownerId === String(ownerId)
|
|
86
|
+
&& a.ownerAskId === String(ownerAskId)) || null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Chiusura dell'alias locale quando l'OWNER chiude la domanda. Durevole: la
|
|
90
|
+
// riga resta nello storico marcata, quindi non ricompare a un reload e non
|
|
91
|
+
// torna risponibile.
|
|
92
|
+
function closeImported({ ownerId, ownerAskId, outcome }) {
|
|
93
|
+
const ask = findImported(ownerId, ownerAskId);
|
|
94
|
+
if (!ask) return { ok: true, changed: false, ask: null };
|
|
95
|
+
if (ask.answered || ask.dismissed) return { ok: true, changed: false, ask };
|
|
96
|
+
if (outcome === 'answered') { ask.answered = true; ask.answeredReconciled = true; }
|
|
97
|
+
else ask.dismissed = true;
|
|
98
|
+
ask.revision = (ask.revision || 0) + 1;
|
|
99
|
+
save();
|
|
100
|
+
return { ok: true, changed: true, ask };
|
|
70
101
|
}
|
|
71
102
|
|
|
72
|
-
function create({ question, options, session }) {
|
|
103
|
+
function create({ question, options, session, ownerId, ownerAskId, originNode, originCell }) {
|
|
73
104
|
const v = validate({ question, options });
|
|
74
105
|
if (!v.ok) return { ok: false, reason: 'invalid', error: v.error };
|
|
75
|
-
//
|
|
76
|
-
|
|
106
|
+
// DEDUP: la stessa domanda puo' arrivare due volte allo stesso nodo (import
|
|
107
|
+
// diretto del fan-out e feed dell'owner). L'identita' canonica e' la coppia
|
|
108
|
+
// (ownerId, ownerAskId): se l'alias esiste gia', si restituisce quello, non
|
|
109
|
+
// se ne crea un secondo.
|
|
110
|
+
if (originNode && ownerId && ownerAskId) {
|
|
111
|
+
const existing = findImported(ownerId, ownerAskId);
|
|
112
|
+
if (existing) return { ok: true, ask: existing, deduped: true };
|
|
113
|
+
}
|
|
114
|
+
// Cap duro sugli aperti, SEPARATO PER CLASSE: gli importati non consumano il
|
|
115
|
+
// budget dei locali. Con un cap unico, cento domande ricevute da un peer
|
|
116
|
+
// lasciavano questo nodo senza poter porre la prima domanda propria.
|
|
117
|
+
const imported = !!originNode;
|
|
118
|
+
const cap = imported ? MAX_OPEN_IMPORTED : MAX_OPEN;
|
|
119
|
+
if (openCount(imported ? 'imported' : 'local') >= cap) {
|
|
77
120
|
return {
|
|
78
121
|
ok: false,
|
|
79
122
|
reason: 'cap',
|
|
80
|
-
error: `cap ask aperti raggiunto (${
|
|
123
|
+
error: `cap ask aperti raggiunto (${cap}): rispondi o attendi prima di crearne altri`,
|
|
81
124
|
};
|
|
82
125
|
}
|
|
83
126
|
const ask = {
|
|
@@ -85,6 +128,23 @@ function createAsksStore(opts = {}) {
|
|
|
85
128
|
question: v.value.question,
|
|
86
129
|
...(v.value.options ? { options: v.value.options } : {}),
|
|
87
130
|
session: String(session),
|
|
131
|
+
// Identita' QUALIFICATA dell'ask. `ownerId` e' il nodo che possiede la
|
|
132
|
+
// domanda: la UI identifica una card con la coppia (ownerId, id) — due
|
|
133
|
+
// proprietari possono usare lo stesso id locale — e instrada la risposta
|
|
134
|
+
// al proprietario via ask-relay invece di incollarla qui. Assente = ask
|
|
135
|
+
// di questo nodo (il caso locale storico).
|
|
136
|
+
...(ownerId ? { ownerId: String(ownerId) } : {}),
|
|
137
|
+
// L'id con cui l'OWNER conosce questa domanda. Su un ask importato l'`id`
|
|
138
|
+
// locale e' nostro e serve solo a noi; la risposta deve invece citare
|
|
139
|
+
// l'id dell'owner, perche' e' lui che risolve l'ask nel proprio store.
|
|
140
|
+
// Senza questo campo una risposta a un ask importato colpirebbe un id
|
|
141
|
+
// inesistente (o, peggio, un ask locale nostro con lo stesso id).
|
|
142
|
+
...(ownerAskId ? { ownerAskId: String(ownerAskId) } : {}),
|
|
143
|
+
// Provenienza di un ask ARRIVATO dalla federazione. E' il marcatore che
|
|
144
|
+
// rende esplicito l'invariante: un ask con `originNode` non viene mai
|
|
145
|
+
// ri-esportato (nessun loop A->B->A). Un ask locale non ha questo campo.
|
|
146
|
+
...(originNode ? { originNode: String(originNode) } : {}),
|
|
147
|
+
...(originCell ? { originCell: String(originCell) } : {}),
|
|
88
148
|
ts: now(),
|
|
89
149
|
revision: 0,
|
|
90
150
|
answered: false,
|
|
@@ -190,7 +250,7 @@ function createAsksStore(opts = {}) {
|
|
|
190
250
|
return commit(id, text);
|
|
191
251
|
}
|
|
192
252
|
|
|
193
|
-
return { create, get, list, openCount, claim, release, commit, markAnswered, markReconciled, isAnswering, dismiss, validate, filePath, MAX_OPEN };
|
|
253
|
+
return { create, get, list, openCount, isImported, findImported, closeImported, claim, release, commit, markAnswered, markReconciled, isAnswering, dismiss, validate, filePath, MAX_OPEN, MAX_OPEN_IMPORTED };
|
|
194
254
|
}
|
|
195
255
|
|
|
196
256
|
module.exports = { createAsksStore };
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Coda di RITENTATIVI della chiusura di un ask, lato OWNER.
|
|
3
|
+
//
|
|
4
|
+
// Il problema che risolve: la chiusura di una domanda segue la stessa strada
|
|
5
|
+
// dell'andata, ma il destinatario puo' essere spento in quel momento. Il suo
|
|
6
|
+
// alias locale resta allora aperto — e ricompare a ogni reload — perche' la
|
|
7
|
+
// transizione e' avvenuta altrove. Il ricevente NON puo' rimediare da solo:
|
|
8
|
+
// nella topologia in cui l'owner si e' collegato a lui (peer `inbound`) non ha
|
|
9
|
+
// ne' rotta ne' credenziale per raggiungerlo. L'unico lato che puo' riprovare
|
|
10
|
+
// e' chi ha emesso la transizione.
|
|
11
|
+
//
|
|
12
|
+
// La coda e' DELIBERATAMENTE piccola e limitata su tre assi indipendenti:
|
|
13
|
+
// - numero di voci (tetto duro, si scarta la piu' vecchia);
|
|
14
|
+
// - tentativi per voce (backoff esponenziale, tetto esplicito);
|
|
15
|
+
// - eta' della voce (TTL: oltre, si rinuncia).
|
|
16
|
+
// Ogni timer e' `unref()`: una coda in attesa non tiene vivo il processo.
|
|
17
|
+
//
|
|
18
|
+
// Due modi per far ripartire un tentativo:
|
|
19
|
+
// 1) il timer di backoff (il caso normale, nessuno guarda);
|
|
20
|
+
// 2) una LETTURA locale dell'elenco degli ask: e' il momento in cui qualcuno
|
|
21
|
+
// sta guardando lo stato, quindi e' il momento naturale per riconciliare
|
|
22
|
+
// il recapito. Senza questa seconda via una coda con base di 1 s non
|
|
23
|
+
// coprirebbe mai una finestra di riavvio del peer di pochi millisecondi,
|
|
24
|
+
// e il backoff non deve essere accorciato fino a diventare un busy loop.
|
|
25
|
+
|
|
26
|
+
const BASE_MS = 1000; // primo ritardo fra i tentativi
|
|
27
|
+
const FACTOR = 2; // crescita esponenziale
|
|
28
|
+
const MAX_ATTEMPTS = 6; // tentativi oltre al primo dispatch
|
|
29
|
+
const TTL_MS = 5 * 60 * 1000; // oltre questa eta' si rinuncia
|
|
30
|
+
const MAX_ENTRIES = 256; // tetto duro sulle voci in coda
|
|
31
|
+
const NUDGE_FLOOR_MS = 500; // distanza minima fra due risvegli da lettura
|
|
32
|
+
|
|
33
|
+
// Esiti che NON meritano un ritentativo: il peer ha risposto, la chiusura e'
|
|
34
|
+
// arrivata (una seconda consegna della stessa chiusura e' un no-op sul peer,
|
|
35
|
+
// che risponde `delivered` con `closed:false`), oppure ha rifiutato — e un
|
|
36
|
+
// rifiuto non cambia da solo col tempo.
|
|
37
|
+
const DONE_STATUSES = new Set(['delivered', 'no-delivery']);
|
|
38
|
+
const FINAL_STATUSES = new Set(['refused']);
|
|
39
|
+
|
|
40
|
+
function createClosureRetryQueue({
|
|
41
|
+
run, // async ({askId, outcome, session}) -> [{target, status, reason}]
|
|
42
|
+
now = () => Date.now(),
|
|
43
|
+
setTimer = setTimeout,
|
|
44
|
+
clearTimer = clearTimeout,
|
|
45
|
+
baseMs = BASE_MS,
|
|
46
|
+
factor = FACTOR,
|
|
47
|
+
maxAttempts = MAX_ATTEMPTS,
|
|
48
|
+
ttlMs = TTL_MS,
|
|
49
|
+
maxEntries = MAX_ENTRIES,
|
|
50
|
+
nudgeFloorMs = NUDGE_FLOOR_MS,
|
|
51
|
+
log = () => {},
|
|
52
|
+
} = {}) {
|
|
53
|
+
if (typeof run !== 'function') throw new Error('createClosureRetryQueue: run richiesta');
|
|
54
|
+
const entries = [];
|
|
55
|
+
let stopped = false;
|
|
56
|
+
let lastNudge = null;
|
|
57
|
+
|
|
58
|
+
function clearEntry(entry) {
|
|
59
|
+
if (entry.timer) { try { clearTimer(entry.timer); } catch (_) {} entry.timer = null; }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function remove(entry) {
|
|
63
|
+
clearEntry(entry);
|
|
64
|
+
const i = entries.indexOf(entry);
|
|
65
|
+
if (i >= 0) entries.splice(i, 1);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Ritardo del prossimo tentativo: esponenziale, con tetto sul TTL.
|
|
69
|
+
function schedule(entry) {
|
|
70
|
+
if (stopped) return;
|
|
71
|
+
const delay = baseMs * Math.pow(factor, entry.attempts);
|
|
72
|
+
entry.nextAt = now() + delay;
|
|
73
|
+
entry.timer = setTimer(() => { attempt(entry, 'backoff'); }, delay);
|
|
74
|
+
// Non blocca la chiusura del processo: una coda in attesa e' solo una coda.
|
|
75
|
+
if (entry.timer && typeof entry.timer.unref === 'function') entry.timer.unref();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function expired(entry) {
|
|
79
|
+
return entry.attempts >= maxAttempts || (now() - entry.createdAt) >= ttlMs;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function attempt(entry, why = 'backoff') {
|
|
83
|
+
if (stopped || entry.inFlight) return;
|
|
84
|
+
clearEntry(entry);
|
|
85
|
+
if (expired(entry)) {
|
|
86
|
+
remove(entry);
|
|
87
|
+
try { log(`chiusura ask ${entry.askId}: rinuncio dopo ${entry.attempts} tentativi (${why})`); } catch (_) {}
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
entry.inFlight = true;
|
|
91
|
+
let results = [];
|
|
92
|
+
// Si ritenta SOLO verso i target ancora pendenti: chi ha gia' risposto non
|
|
93
|
+
// riceve una seconda consegna inutile.
|
|
94
|
+
const targets = [...entry.targets];
|
|
95
|
+
try {
|
|
96
|
+
results = await run({ askId: entry.askId, outcome: entry.outcome, session: entry.session, targets });
|
|
97
|
+
} catch (e) {
|
|
98
|
+
results = targets.map((target) => ({ target, status: 'unknown', reason: 'dispatch-threw' }));
|
|
99
|
+
try { log(`chiusura ask ${entry.askId}: tentativo fallito (${String(e && e.message || e)})`); } catch (_) {}
|
|
100
|
+
} finally {
|
|
101
|
+
entry.inFlight = false;
|
|
102
|
+
}
|
|
103
|
+
const list = Array.isArray(results) ? results : [];
|
|
104
|
+
// IL SET SI AGGIORNA QUI, dal lato della coda: ogni target che ha risposto
|
|
105
|
+
// esce. `delivered` e `no-delivery` sono consegne, `refused` e' un rifiuto —
|
|
106
|
+
// in tutti e tre i casi riprovare non aggiunge nulla. Restano i pendenti, e
|
|
107
|
+
// la voce si chiude quando non ne resta nessuno.
|
|
108
|
+
for (const r of list) {
|
|
109
|
+
if (!r || !r.target) continue;
|
|
110
|
+
if (DONE_STATUSES.has(r.status) || FINAL_STATUSES.has(r.status)) entry.targets.delete(r.target);
|
|
111
|
+
}
|
|
112
|
+
const refused = list.filter((r) => r && FINAL_STATUSES.has(r.status));
|
|
113
|
+
if (refused.length) {
|
|
114
|
+
try { log(`chiusura ask ${entry.askId}: rifiutata (${refused.map((r) => r.reason || r.status).join(',')})`); } catch (_) {}
|
|
115
|
+
}
|
|
116
|
+
if (!entry.targets.size) { remove(entry); return; }
|
|
117
|
+
entry.attempts += 1;
|
|
118
|
+
if (expired(entry)) {
|
|
119
|
+
remove(entry);
|
|
120
|
+
try { log(`chiusura ask ${entry.askId}: tetto raggiunto, alias lasciato al peer`); } catch (_) {}
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
schedule(entry);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Accoda i target NON raggiunti di una chiusura. La voce e' la COPPIA
|
|
127
|
+
// (chiusura, insieme dei pendenti): un peer che risponde esce dall'insieme, e
|
|
128
|
+
// la voce si chiude quando l'insieme e' vuoto. Una seconda `enqueue` per la
|
|
129
|
+
// stessa chiusura UNISCE i target invece di essere buttata via: e' cio' che
|
|
130
|
+
// impedisce a un peer spento di sparire dalla coda quando un ALTRO peer
|
|
131
|
+
// risponde, o quando la stessa chiusura viene riprovata piu' tardi.
|
|
132
|
+
function enqueue({ askId, outcome, session, targets } = {}) {
|
|
133
|
+
if (stopped || !askId || !outcome) return { ok: false, reason: 'invalid' };
|
|
134
|
+
const nuovi = [...new Set((Array.isArray(targets) ? targets : []).map((t) => String(t)).filter(Boolean))];
|
|
135
|
+
if (!nuovi.length) return { ok: false, reason: 'no-targets' };
|
|
136
|
+
const existing = entries.find((e) => e.askId === askId && e.outcome === outcome);
|
|
137
|
+
if (existing) {
|
|
138
|
+
const prima = existing.targets.size;
|
|
139
|
+
for (const t of nuovi) existing.targets.add(t);
|
|
140
|
+
// Se il tentativo precedente era in volo, il nuovo target non era nella
|
|
141
|
+
// sua lista: la voce va risvegliata, o aspetterebbe il backoff per nulla.
|
|
142
|
+
if (existing.targets.size !== prima && !existing.inFlight && !existing.timer) schedule(existing);
|
|
143
|
+
return { ok: true, merged: true, added: existing.targets.size - prima, size: entries.length };
|
|
144
|
+
}
|
|
145
|
+
if (entries.length >= maxEntries) {
|
|
146
|
+
const oldest = entries.shift();
|
|
147
|
+
clearEntry(oldest);
|
|
148
|
+
try { log(`coda chiusure piena (${maxEntries}): scartata la piu' vecchia (${oldest.askId})`); } catch (_) {}
|
|
149
|
+
}
|
|
150
|
+
const entry = {
|
|
151
|
+
askId, outcome, session, targets: new Set(nuovi),
|
|
152
|
+
attempts: 0, createdAt: now(), nextAt: 0, timer: null, inFlight: false,
|
|
153
|
+
};
|
|
154
|
+
entries.push(entry);
|
|
155
|
+
schedule(entry);
|
|
156
|
+
return { ok: true, size: entries.length };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Risveglio su domanda: chi legge lo stato vuole lo stato VERO. Si ritentano
|
|
160
|
+
// subito le voci in attesa, senza aspettare il backoff — ma non a ogni
|
|
161
|
+
// lettura: c'e' una distanza minima fra due risvegli, altrimenti un refresh
|
|
162
|
+
// ripetuto diventerebbe un martellamento del peer.
|
|
163
|
+
async function drain(why = 'read') {
|
|
164
|
+
if (stopped || !entries.length) return { attempted: 0 };
|
|
165
|
+
const t = now();
|
|
166
|
+
if (lastNudge !== null && (t - lastNudge) < nudgeFloorMs) return { attempted: 0, throttled: true };
|
|
167
|
+
lastNudge = t;
|
|
168
|
+
const snapshot = entries.slice();
|
|
169
|
+
for (const entry of snapshot) await attempt(entry, why);
|
|
170
|
+
return { attempted: snapshot.length, size: entries.length };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Svuotamento alla chiusura del server: nessun timer sopravvive.
|
|
174
|
+
function stop() {
|
|
175
|
+
stopped = true;
|
|
176
|
+
for (const entry of entries.slice()) remove(entry);
|
|
177
|
+
return { cleared: true };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
enqueue, drain, stop,
|
|
182
|
+
pending: () => entries.map((e) => ({
|
|
183
|
+
askId: e.askId, outcome: e.outcome, attempts: e.attempts, nextAt: e.nextAt,
|
|
184
|
+
targets: [...e.targets],
|
|
185
|
+
})),
|
|
186
|
+
size: () => entries.length,
|
|
187
|
+
limits: { baseMs, factor, maxAttempts, ttlMs, maxEntries, nudgeFloorMs },
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
module.exports = {
|
|
192
|
+
createClosureRetryQueue,
|
|
193
|
+
CLOSURE_DONE_STATUSES: DONE_STATUSES,
|
|
194
|
+
CLOSURE_FINAL_STATUSES: FINAL_STATUSES,
|
|
195
|
+
CLOSURE_RETRY_BASE_MS: BASE_MS,
|
|
196
|
+
CLOSURE_RETRY_FACTOR: FACTOR,
|
|
197
|
+
CLOSURE_RETRY_MAX_ATTEMPTS: MAX_ATTEMPTS,
|
|
198
|
+
CLOSURE_RETRY_TTL_MS: TTL_MS,
|
|
199
|
+
CLOSURE_RETRY_MAX_ENTRIES: MAX_ENTRIES,
|
|
200
|
+
CLOSURE_RETRY_NUDGE_FLOOR_MS: NUDGE_FLOOR_MS,
|
|
201
|
+
};
|
|
@@ -88,6 +88,9 @@ function createEventFeedAsksRoutes(deps) {
|
|
|
88
88
|
askId, text: body.text, peerId: g.peer.nodeId, requestId: body.requestId,
|
|
89
89
|
});
|
|
90
90
|
if (out.ok) {
|
|
91
|
+
// La chiusura nasce dal servizio; qui si aspetta il recapito, cosi' chi ha
|
|
92
|
+
// risposto non vede una risposta che precede la chiusura dei peer.
|
|
93
|
+
if (out.closure) { try { await out.closure; } catch (_) {} }
|
|
91
94
|
return res.json({ status: out.replay ? out.state : 'committed', requestId: body.requestId });
|
|
92
95
|
}
|
|
93
96
|
return res.status(out.code || 500).json({ error: out.error, reason: out.reason });
|
|
@@ -114,6 +117,7 @@ function createEventFeedAsksRoutes(deps) {
|
|
|
114
117
|
if (out.reason === 'answering') return res.status(409).json({ error: 'risposta in corso: non si scarta un ask in answering' });
|
|
115
118
|
return res.status(500).json({ error: 'dismiss non riuscito' });
|
|
116
119
|
}
|
|
120
|
+
if (out.closure) { try { await out.closure; } catch (_) {} }
|
|
117
121
|
return res.json({ dismissed: true, id: askId, idempotent: out.idempotent === true });
|
|
118
122
|
});
|
|
119
123
|
|
|
@@ -190,8 +190,16 @@ function createEventFeedRoutes(deps) {
|
|
|
190
190
|
// Asks: open asks of VISIBLE cells only, capped.
|
|
191
191
|
let asks = [];
|
|
192
192
|
try {
|
|
193
|
+
const self = typeof deps.localNodeId === 'function' ? deps.localNodeId() : null;
|
|
193
194
|
const open = deps.asksStore.list({ open: true }) || [];
|
|
194
195
|
const visible = open
|
|
196
|
+
// SOLO gli ask di QUESTO nodo. Un ask importato appartiene al nodo che
|
|
197
|
+
// l'ha posto: pubblicarlo qui lo consegnerebbe a un terzo peer
|
|
198
|
+
// attribuito a noi, cioe' a un destinatario che chi ha chiesto non
|
|
199
|
+
// aveva scelto — la domanda uscirebbe dal perimetro del fan-out. La
|
|
200
|
+
// guardia e' esplicita sui campi di provenienza, non dedotta dalla
|
|
201
|
+
// visibilita' della cella.
|
|
202
|
+
.filter((a) => !a.originNode && !(a.ownerId && self && a.ownerId !== self))
|
|
195
203
|
.map((a) => ({ id: a.id, question: a.question, options: a.options, session: a.session, ts: a.ts }))
|
|
196
204
|
.filter((a) => peer.allows({ scope: 'cell', cellId: deps.cellForSession(a.session) }))
|
|
197
205
|
.slice(0, SNAPSHOT_MAX_ASKS);
|