@mmmbuto/nexuscrew 0.8.39 → 0.8.41
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 +56 -1
- package/README.md +2 -1
- package/frontend/dist/assets/index-CrZBnpKn.css +32 -0
- package/frontend/dist/assets/index-CwZySkm2.js +93 -0
- package/frontend/dist/assets/index-hq-2ORFQ.js +93 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/audio/acl.js +65 -0
- package/lib/audio/adapters.js +233 -0
- package/lib/audio/bridge-auth.js +176 -0
- package/lib/audio/capability.js +40 -0
- package/lib/audio/consent.js +64 -0
- package/lib/audio/dispatch.js +154 -0
- package/lib/audio/group-receipt.js +119 -0
- package/lib/audio/group-speak.js +146 -0
- package/lib/audio/groups.js +121 -0
- package/lib/audio/origin.js +128 -0
- package/lib/audio/queue.js +191 -0
- package/lib/audio/rate-limit.js +71 -0
- package/lib/audio/receipt.js +146 -0
- package/lib/audio/routes.js +374 -0
- package/lib/audio/speak.js +125 -0
- package/lib/cli/doctor.js +37 -0
- package/lib/config.js +7 -0
- package/lib/fleet/builtin.js +2 -1
- package/lib/fleet/launch.js +16 -0
- package/lib/fleet/managed.js +53 -2
- package/lib/fleet/provider.js +6 -0
- package/lib/fleet/runtime.js +14 -1
- package/lib/mcp/server.js +27 -3
- package/lib/mcp/tools.js +142 -0
- package/lib/nodes/tunnel-supervisor.js +33 -7
- package/lib/nodes/tunnel.js +21 -0
- package/lib/proxy/federation.js +41 -11
- package/lib/proxy/hop-proof.js +50 -0
- package/lib/server.js +97 -3
- package/lib/settings/routes.js +150 -4
- package/lib/tmux/lifecycle.js +24 -3
- package/package.json +1 -1
- package/skills/nexuscrew-agent/SKILL.md +30 -1
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// lib/audio/queue.js — coda degli enunciati di UN nodo.
|
|
3
|
+
//
|
|
4
|
+
// Un nodo ha una voce sola: gli enunciati si serializzano, non si sovrappongono.
|
|
5
|
+
// La coda e' bounded, deduplica per utteranceId, applica la prelazione di
|
|
6
|
+
// urgency alta e possiede il watchdog dell'ack di avvio.
|
|
7
|
+
//
|
|
8
|
+
// Transizioni di stato, tutte osservabili via `onStatus` e tutte oneste:
|
|
9
|
+
// accepted -> spoken l'adapter ha AVVIATO la sintesi (non "qualcuno ha sentito")
|
|
10
|
+
// accepted -> refused reason `adapter-error` l'avvio non e' riuscito
|
|
11
|
+
// accepted -> refused reason `preempted` prelazionato da un urgency alto
|
|
12
|
+
// accepted -> refused reason `stopped` stop locale o remoto
|
|
13
|
+
// accepted -> unknown l'ack non e' arrivato entro il timeout: non si mente
|
|
14
|
+
//
|
|
15
|
+
// `unknown` non e' un fallimento nascosto: e' l'ammissione che il nodo non puo'
|
|
16
|
+
// dire cosa sia successo. Un fan-out o un failover devono poterlo distinguere da
|
|
17
|
+
// un rifiuto esplicito, altrimenti riprovano dove non serve o si fermano dove
|
|
18
|
+
// invece dovrebbero riprovare.
|
|
19
|
+
const ACK_TIMEOUT_MS = 5000;
|
|
20
|
+
const MAX_PENDING = 2;
|
|
21
|
+
|
|
22
|
+
function createSpeakQueue(opts = {}) {
|
|
23
|
+
const adapter = opts.adapter || null;
|
|
24
|
+
const onStatus = typeof opts.onStatus === 'function' ? opts.onStatus : () => {};
|
|
25
|
+
const ackTimeoutMs = Number.isFinite(opts.ackTimeoutMs) ? opts.ackTimeoutMs : ACK_TIMEOUT_MS;
|
|
26
|
+
const maxPending = Number.isInteger(opts.maxPending) && opts.maxPending > 0 ? opts.maxPending : MAX_PENDING;
|
|
27
|
+
const setTimeoutImpl = opts.setTimeoutImpl || setTimeout;
|
|
28
|
+
const clearTimeoutImpl = opts.clearTimeoutImpl || clearTimeout;
|
|
29
|
+
|
|
30
|
+
const pending = []; // enunciati in attesa, in ordine
|
|
31
|
+
let current = null; // { utteranceId, handle, ackTimer }
|
|
32
|
+
const known = new Set(); // utteranceId gia' visti: dedup finche' sono vivi
|
|
33
|
+
|
|
34
|
+
// `spoken` e' un ACK, non una conclusione: l'enunciato e' partito e puo'
|
|
35
|
+
// ancora essere fermato o prelazionato. Confondere le due cose renderebbe
|
|
36
|
+
// impossibile registrare uno stop dopo l'avvio, che e' proprio il caso d'uso.
|
|
37
|
+
function ack(entry) {
|
|
38
|
+
if (entry.finished) return;
|
|
39
|
+
entry.acked = true;
|
|
40
|
+
if (entry.ackTimer) { clearTimeoutImpl(entry.ackTimer); entry.ackTimer = null; }
|
|
41
|
+
onStatus(entry.utteranceId, 'spoken');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Stato terminale: emette la transizione finale e libera il dedup.
|
|
45
|
+
function settle(entry, status, reason) {
|
|
46
|
+
if (entry.finished) return;
|
|
47
|
+
entry.finished = true;
|
|
48
|
+
if (entry.ackTimer) { clearTimeoutImpl(entry.ackTimer); entry.ackTimer = null; }
|
|
49
|
+
known.delete(entry.utteranceId);
|
|
50
|
+
onStatus(entry.utteranceId, status, reason);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Fine naturale dopo un ack: nessuna nuova transizione da annunciare, `spoken`
|
|
54
|
+
// resta lo stato finale. Si libera solo il dedup.
|
|
55
|
+
function complete(entry) {
|
|
56
|
+
if (entry.finished) return;
|
|
57
|
+
entry.finished = true;
|
|
58
|
+
if (entry.ackTimer) { clearTimeoutImpl(entry.ackTimer); entry.ackTimer = null; }
|
|
59
|
+
known.delete(entry.utteranceId);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function startNext() {
|
|
63
|
+
if (current || !pending.length) return;
|
|
64
|
+
const entry = pending.shift();
|
|
65
|
+
if (entry.finished) return startNext();
|
|
66
|
+
current = entry;
|
|
67
|
+
if (!adapter || typeof adapter.speak !== 'function') {
|
|
68
|
+
current = null;
|
|
69
|
+
settle(entry, 'refused', 'no-adapter');
|
|
70
|
+
return startNext();
|
|
71
|
+
}
|
|
72
|
+
let handle;
|
|
73
|
+
try {
|
|
74
|
+
handle = adapter.speak({ text: entry.text, lang: entry.lang, voice: entry.voice });
|
|
75
|
+
} catch (_) {
|
|
76
|
+
current = null;
|
|
77
|
+
settle(entry, 'refused', 'adapter-error');
|
|
78
|
+
return startNext();
|
|
79
|
+
}
|
|
80
|
+
if (!handle || handle.started !== true) {
|
|
81
|
+
current = null;
|
|
82
|
+
settle(entry, 'refused', handle && handle.reason ? handle.reason : 'adapter-error');
|
|
83
|
+
return startNext();
|
|
84
|
+
}
|
|
85
|
+
entry.handle = handle;
|
|
86
|
+
// Gli adapter nativi possono verificare lo spawn solo in modo asincrono:
|
|
87
|
+
// finche' la loro `start` Promise non attesta l'avvio, il receipt resta
|
|
88
|
+
// `accepted`. I seam legacy senza `start` mantengono il comportamento
|
|
89
|
+
// sincrono usato dai test e dagli adapter gia' affidabili.
|
|
90
|
+
const confirmStart = (result) => {
|
|
91
|
+
if (entry.finished) return;
|
|
92
|
+
if (result && result.started === true) {
|
|
93
|
+
ack(entry);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (current === entry) current = null;
|
|
97
|
+
settle(entry, 'refused', (result && result.reason) || 'adapter-start-failed');
|
|
98
|
+
startNext();
|
|
99
|
+
};
|
|
100
|
+
if (handle.start && typeof handle.start.then === 'function') {
|
|
101
|
+
handle.start.then(confirmStart, () => confirmStart({ started: false, reason: 'adapter-start-failed' }));
|
|
102
|
+
} else {
|
|
103
|
+
confirmStart({ started: true });
|
|
104
|
+
}
|
|
105
|
+
const finish = () => {
|
|
106
|
+
complete(entry);
|
|
107
|
+
if (current === entry) { current = null; startNext(); }
|
|
108
|
+
};
|
|
109
|
+
if (handle.done && typeof handle.done.then === 'function') handle.done.then(finish, finish);
|
|
110
|
+
else finish();
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// enqueue(): l'ammissione e' sincrona e bounded. Ritorna sempre uno stato per
|
|
115
|
+
// endpoint, mai un booleano aggregato.
|
|
116
|
+
function enqueue({ utteranceId, text, lang = '', voice = '', urgency = 'normal' } = {}) {
|
|
117
|
+
if (typeof utteranceId !== 'string' || !utteranceId) return { status: 'refused', reason: 'invalid-utterance' };
|
|
118
|
+
if (typeof text !== 'string' || !text) return { status: 'refused', reason: 'invalid-text' };
|
|
119
|
+
// Dedup: lo stesso utteranceId non viene mai pronunciato due volte mentre e'
|
|
120
|
+
// ancora vivo. Un retry idempotente non produce una seconda voce.
|
|
121
|
+
if (known.has(utteranceId)) return { status: 'accepted', reason: 'duplicate' };
|
|
122
|
+
if (!adapter || typeof adapter.speak !== 'function') return { status: 'refused', reason: 'no-adapter' };
|
|
123
|
+
|
|
124
|
+
const entry = { utteranceId, text, lang, voice, urgency, finished: false, acked: false, handle: null, ackTimer: null };
|
|
125
|
+
|
|
126
|
+
if (urgency === 'high' && current) {
|
|
127
|
+
// Prelazione: l'enunciato corrente non diventa mai `spoken` a posteriori,
|
|
128
|
+
// transita a `refused/preempted`. La coda in attesa NON viene svuotata:
|
|
129
|
+
// urgency cambia l'ordine, non cancella il consenso altrui a essere letto.
|
|
130
|
+
const victim = current;
|
|
131
|
+
current = null;
|
|
132
|
+
try { if (victim.handle && typeof victim.handle.kill === 'function') victim.handle.kill(); } catch (_) {}
|
|
133
|
+
settle(victim, 'refused', 'preempted');
|
|
134
|
+
pending.unshift(entry);
|
|
135
|
+
} else if (pending.length >= maxPending) {
|
|
136
|
+
return { status: 'refused', reason: 'queue-full' };
|
|
137
|
+
} else if (urgency === 'high') {
|
|
138
|
+
pending.unshift(entry);
|
|
139
|
+
} else {
|
|
140
|
+
pending.push(entry);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
known.add(utteranceId);
|
|
144
|
+
entry.ackTimer = setTimeoutImpl(() => {
|
|
145
|
+
// Timeout dell'ack: non si e' potuto confermare l'avvio. `unknown` e'
|
|
146
|
+
// l'unica risposta corretta; promuoverlo a spoken sarebbe una bugia.
|
|
147
|
+
const idx = pending.indexOf(entry);
|
|
148
|
+
if (idx >= 0) pending.splice(idx, 1);
|
|
149
|
+
if (current === entry) current = null;
|
|
150
|
+
settle(entry, 'unknown', 'ack-timeout');
|
|
151
|
+
startNext();
|
|
152
|
+
}, ackTimeoutMs);
|
|
153
|
+
if (entry.ackTimer && typeof entry.ackTimer.unref === 'function') entry.ackTimer.unref();
|
|
154
|
+
|
|
155
|
+
startNext();
|
|
156
|
+
return { status: 'accepted' };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// stop(): stop LOCALE sovrano. Non richiede rete, non richiede l'hub, non
|
|
160
|
+
// richiede che l'origine sia raggiungibile. Un endpoint deve poter tacere.
|
|
161
|
+
function stop(utteranceId) {
|
|
162
|
+
let acted = false;
|
|
163
|
+
if (current && (!utteranceId || current.utteranceId === utteranceId)) {
|
|
164
|
+
const victim = current;
|
|
165
|
+
current = null;
|
|
166
|
+
try { if (victim.handle && typeof victim.handle.kill === 'function') victim.handle.kill(); } catch (_) {}
|
|
167
|
+
settle(victim, 'refused', 'stopped');
|
|
168
|
+
acted = true;
|
|
169
|
+
}
|
|
170
|
+
for (let i = pending.length - 1; i >= 0; i -= 1) {
|
|
171
|
+
if (!utteranceId || pending[i].utteranceId === utteranceId) {
|
|
172
|
+
const [entry] = pending.splice(i, 1);
|
|
173
|
+
settle(entry, 'refused', 'stopped');
|
|
174
|
+
acted = true;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
startNext();
|
|
178
|
+
return acted;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
enqueue,
|
|
183
|
+
stop,
|
|
184
|
+
stopAll: () => stop(null),
|
|
185
|
+
pendingSize: () => pending.length,
|
|
186
|
+
isBusy: () => current !== null,
|
|
187
|
+
currentId: () => (current ? current.utteranceId : null),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
module.exports = { createSpeakQueue, ACK_TIMEOUT_MS, MAX_PENDING };
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// lib/audio/rate-limit.js — budget dedicato agli enunciati, separato da quello
|
|
3
|
+
// delle notifiche visuali: saturare le notifiche non deve poter zittire la voce,
|
|
4
|
+
// e viceversa.
|
|
5
|
+
//
|
|
6
|
+
// Tre finestre scorrevoli da 60s, tutte e tre da rispettare:
|
|
7
|
+
// origin-cell : per (nodo, cella) di origine — 6/60s verso qualunque target
|
|
8
|
+
// target-origin : per (target, nodo, cella) — 6/60s
|
|
9
|
+
// target-global : per nodo target — 12/60s sommando TUTTE le origini
|
|
10
|
+
//
|
|
11
|
+
// Il terzo bucket e' quello che limita il danno di un nodo che mente sulla
|
|
12
|
+
// propria cella: puo' inventare nomi quanto vuole, ma il tetto per target e'
|
|
13
|
+
// calcolato sul nodo, che invece e' verificato dalla catena `visited`.
|
|
14
|
+
//
|
|
15
|
+
// L'urgency non scavalca nulla. Un chiamante rumoroso — o compromesso — non deve
|
|
16
|
+
// poter uscire dal limite semplicemente dichiarandosi urgente.
|
|
17
|
+
const WINDOW_MS = 60 * 1000;
|
|
18
|
+
const LIMITS = Object.freeze({ 'origin-cell': 6, 'target-origin': 6, 'target-global': 12 });
|
|
19
|
+
|
|
20
|
+
// Stessa chiave dello store dei receipt: nodo + cella. Contare per sola cella
|
|
21
|
+
// permetterebbe a due nodi con celle omonime di consumarsi il budget a vicenda.
|
|
22
|
+
function originKey(origin) {
|
|
23
|
+
if (!origin || typeof origin !== 'object') return null;
|
|
24
|
+
const node = typeof origin.node === 'string' ? origin.node : '';
|
|
25
|
+
const cell = typeof origin.cell === 'string' ? origin.cell : '';
|
|
26
|
+
if (!node || !cell) return null;
|
|
27
|
+
return `${node} ${cell}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function createSpeakRateLimiter({ now = Date.now } = {}) {
|
|
31
|
+
const buckets = { 'origin-cell': new Map(), 'target-origin': new Map(), 'target-global': new Map() };
|
|
32
|
+
|
|
33
|
+
function prune(map, t) {
|
|
34
|
+
const cutoff = t - WINDOW_MS;
|
|
35
|
+
for (const [k, ts] of map) {
|
|
36
|
+
const kept = ts.filter((x) => x > cutoff);
|
|
37
|
+
if (kept.length === 0) map.delete(k);
|
|
38
|
+
else map.set(k, kept);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function check({ origin, target, urgency } = {}) {
|
|
43
|
+
const key = originKey(origin);
|
|
44
|
+
if (!key) throw new Error('rate-limit: origin {node,cell} mancante');
|
|
45
|
+
if (typeof target !== 'string' || !target) throw new Error('rate-limit: target mancante');
|
|
46
|
+
void urgency; // ignorata di proposito: non esiste una corsia preferenziale
|
|
47
|
+
const t = now();
|
|
48
|
+
for (const name of Object.keys(buckets)) prune(buckets[name], t);
|
|
49
|
+
const keys = {
|
|
50
|
+
'origin-cell': key,
|
|
51
|
+
'target-origin': `${target}\x00${key}`,
|
|
52
|
+
'target-global': target,
|
|
53
|
+
};
|
|
54
|
+
for (const bucket of ['origin-cell', 'target-origin', 'target-global']) {
|
|
55
|
+
const arr = buckets[bucket].get(keys[bucket]) || [];
|
|
56
|
+
if (arr.length >= LIMITS[bucket]) {
|
|
57
|
+
return { allowed: false, bucket, limit: LIMITS[bucket], retryInMs: WINDOW_MS - (t - arr[0]) };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
for (const bucket of ['origin-cell', 'target-origin', 'target-global']) {
|
|
61
|
+
const arr = buckets[bucket].get(keys[bucket]) || [];
|
|
62
|
+
arr.push(t);
|
|
63
|
+
buckets[bucket].set(keys[bucket], arr);
|
|
64
|
+
}
|
|
65
|
+
return { allowed: true };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return { check, LIMITS, WINDOW_MS };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
module.exports = { createSpeakRateLimiter, originKey, LIMITS, WINDOW_MS };
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// lib/audio/receipt.js — receipt degli enunciati, bounded e ridotto all'osso.
|
|
3
|
+
//
|
|
4
|
+
// Scope del chiamante = NODO + CELLA. Non la sola cella: due nodi possono avere
|
|
5
|
+
// celle omonime (`Dev` esiste su piu' installazioni), e una chiave basata solo
|
|
6
|
+
// sul nome permetterebbe a un nodo di leggere o sovrascrivere i receipt di una
|
|
7
|
+
// cella altrui con lo stesso nome. La chiave e' costruita qui, dal server, mai
|
|
8
|
+
// accettata dal client.
|
|
9
|
+
//
|
|
10
|
+
// Cosa NON entra mai in un receipt: testo, lingua, voce, path, credenziali,
|
|
11
|
+
// header. L'attribuzione e' {utteranceId, origin{node,cell}, target, stato,
|
|
12
|
+
// reason, timestamp} e nient'altro. `attested` distingue una cella verificata
|
|
13
|
+
// localmente dal bridge da una dichiarata da un altro nodo: la differenza e' di
|
|
14
|
+
// sicurezza e non va persa per strada.
|
|
15
|
+
//
|
|
16
|
+
// Nessun booleano aggregato di successo: chi legge deve guardare lo stato per
|
|
17
|
+
// endpoint, altrimenti "vero" finirebbe per significare "ho provato".
|
|
18
|
+
const crypto = require('node:crypto');
|
|
19
|
+
|
|
20
|
+
const STATUS = Object.freeze(['refused', 'unreachable', 'accepted', 'spoken', 'unknown']);
|
|
21
|
+
const CAP = 512;
|
|
22
|
+
const TTL_MS = 24 * 60 * 60 * 1000;
|
|
23
|
+
|
|
24
|
+
// Chiave di scope: il separatore e' uno spazio, non rappresentabile in un nodeId
|
|
25
|
+
// (hex) ne' in un nome cella ([A-Za-z0-9._-]), quindi non e' ambigua.
|
|
26
|
+
function callerKey(origin) {
|
|
27
|
+
if (!origin || typeof origin !== 'object') return null;
|
|
28
|
+
const node = typeof origin.node === 'string' ? origin.node : '';
|
|
29
|
+
const cell = typeof origin.cell === 'string' ? origin.cell : '';
|
|
30
|
+
if (!node || !cell) return null;
|
|
31
|
+
return `${node} ${cell}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function createReceiptStore({ now = Date.now } = {}) {
|
|
35
|
+
const byId = new Map();
|
|
36
|
+
|
|
37
|
+
const expired = (e, t) => t - e.timestamp > TTL_MS;
|
|
38
|
+
|
|
39
|
+
function cleanup(t) {
|
|
40
|
+
for (const [id, e] of byId) if (expired(e, t)) byId.delete(id);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function redact(e) {
|
|
44
|
+
return {
|
|
45
|
+
utteranceId: e.utteranceId,
|
|
46
|
+
origin: { node: e.origin.node, cell: e.origin.cell, attested: e.attested === true },
|
|
47
|
+
target: e.target,
|
|
48
|
+
status: e.status,
|
|
49
|
+
timestamp: e.timestamp,
|
|
50
|
+
...(e.reason ? { reason: e.reason } : {}),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// find(): sonda di idempotenza. Non muta. `collision` segnala che lo stesso
|
|
55
|
+
// utteranceId appartiene a un altro chiamante o a un altro target: fail-closed,
|
|
56
|
+
// perche' riusare l'id di un altro sarebbe un modo per leggerne il receipt.
|
|
57
|
+
function find(utteranceId, origin, target) {
|
|
58
|
+
if (typeof utteranceId !== 'string' || !utteranceId) return null;
|
|
59
|
+
const key = callerKey(origin);
|
|
60
|
+
const e = byId.get(utteranceId);
|
|
61
|
+
if (!e) return null;
|
|
62
|
+
if (expired(e, now())) { byId.delete(utteranceId); return null; }
|
|
63
|
+
if (!key || e.caller !== key || e.target !== target) return 'collision';
|
|
64
|
+
return redact(e);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function record(opts) {
|
|
68
|
+
if (!opts || typeof opts !== 'object') throw new Error('record: opts mancante');
|
|
69
|
+
const key = callerKey(opts.origin);
|
|
70
|
+
if (!key) throw new Error('record: origin {node,cell} mancante');
|
|
71
|
+
if (typeof opts.target !== 'string' || !opts.target) throw new Error('record: target mancante');
|
|
72
|
+
if (!STATUS.includes(opts.status)) throw new Error(`status non valida (enum: ${STATUS.join('|')})`);
|
|
73
|
+
const t = now();
|
|
74
|
+
cleanup(t);
|
|
75
|
+
const provided = typeof opts.utteranceId === 'string' && opts.utteranceId ? opts.utteranceId : null;
|
|
76
|
+
if (provided) {
|
|
77
|
+
const existing = byId.get(provided);
|
|
78
|
+
if (existing && !expired(existing, t)) {
|
|
79
|
+
if (existing.caller === key && existing.target === opts.target) return redact(existing);
|
|
80
|
+
throw new Error('utteranceId collision (diverso caller/target)');
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const utteranceId = provided || crypto.randomUUID();
|
|
84
|
+
const entry = {
|
|
85
|
+
utteranceId,
|
|
86
|
+
caller: key,
|
|
87
|
+
origin: { node: opts.origin.node, cell: opts.origin.cell },
|
|
88
|
+
attested: opts.attested === true,
|
|
89
|
+
target: opts.target,
|
|
90
|
+
status: opts.status,
|
|
91
|
+
...(opts.reason ? { reason: String(opts.reason).slice(0, 64) } : {}),
|
|
92
|
+
timestamp: t,
|
|
93
|
+
};
|
|
94
|
+
byId.set(utteranceId, entry);
|
|
95
|
+
if (byId.size > CAP) {
|
|
96
|
+
const sorted = [...byId.entries()].sort((a, b) => a[1].timestamp - b[1].timestamp);
|
|
97
|
+
for (let i = 0; i < byId.size - CAP; i += 1) byId.delete(sorted[i][0]);
|
|
98
|
+
}
|
|
99
|
+
return redact(entry);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// update(): transizione di stato di un enunciato gia' registrato (accepted ->
|
|
103
|
+
// spoken/refused/unknown quando la coda locale conclude). Non crea receipt
|
|
104
|
+
// nuovi e non cambia mai origine o target: solo lo stato puo' evolvere.
|
|
105
|
+
function update(utteranceId, status, reason) {
|
|
106
|
+
if (!STATUS.includes(status)) throw new Error('status non valida');
|
|
107
|
+
const e = byId.get(utteranceId);
|
|
108
|
+
if (!e) return null;
|
|
109
|
+
if (expired(e, now())) { byId.delete(utteranceId); return null; }
|
|
110
|
+
e.status = status;
|
|
111
|
+
if (reason) e.reason = String(reason).slice(0, 64);
|
|
112
|
+
else delete e.reason;
|
|
113
|
+
return redact(e);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// get(): lettura scoped. Solo lo stesso nodo+cella vede il proprio receipt;
|
|
117
|
+
// per chiunque altro l'enunciato semplicemente non esiste.
|
|
118
|
+
function get(origin, utteranceId) {
|
|
119
|
+
const key = callerKey(origin);
|
|
120
|
+
const e = byId.get(utteranceId);
|
|
121
|
+
if (!key || !e) return null;
|
|
122
|
+
if (expired(e, now())) { byId.delete(utteranceId); return null; }
|
|
123
|
+
if (e.caller !== key) return null;
|
|
124
|
+
return redact(e);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function list(origin) {
|
|
128
|
+
const key = callerKey(origin);
|
|
129
|
+
if (!key) return [];
|
|
130
|
+
const t = now();
|
|
131
|
+
const out = [];
|
|
132
|
+
for (const [, e] of byId) if (!expired(e, t) && e.caller === key) out.push(redact(e));
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function count() {
|
|
137
|
+
const t = now();
|
|
138
|
+
let c = 0;
|
|
139
|
+
for (const [, e] of byId) if (!expired(e, t)) c += 1;
|
|
140
|
+
return c;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return { record, update, find, get, list, count, STATUS };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
module.exports = { createReceiptStore, callerKey, STATUS, CAP, TTL_MS };
|