@mmmbuto/nexuscrew 0.9.29 → 0.9.31
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 +20 -0
- package/docs/LIVE_PROMPT.md +1 -1
- package/docs/NOTIFICATIONS.md +37 -0
- package/frontend/dist/assets/index-DaAL7P-m.js +167 -0
- package/frontend/dist/assets/{index-CywSos2e.css → index-DqCfZFGi.css} +1 -1
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/sw.js +91 -10
- package/frontend/dist/version.json +1 -1
- package/lib/audio/groups.js +1 -1
- package/lib/auth/token.js +4 -4
- package/lib/cli/commands.js +59 -12
- package/lib/cli/doctor.js +36 -7
- package/lib/cli/fleet-service.js +7 -7
- package/lib/cli/init.js +27 -27
- package/lib/cli/pidfile.js +1 -1
- package/lib/cli/service.js +6 -6
- package/lib/cli/stable-alias.js +4 -4
- package/lib/cli/url.js +1 -1
- package/lib/config.js +36 -6
- package/lib/decks/routes.js +11 -1
- package/lib/files/routes.js +1 -1
- package/lib/fleet/boot.js +2 -2
- package/lib/fleet/builtin.js +28 -19
- package/lib/fleet/causes.js +1 -1
- package/lib/fleet/cell-exec.js +23 -23
- package/lib/fleet/cell-hold.js +6 -6
- package/lib/fleet/cell-lease-server.js +106 -106
- package/lib/fleet/cell-lease.js +21 -21
- package/lib/fleet/definitions.js +21 -13
- package/lib/fleet/identity-authority.js +1 -1
- package/lib/fleet/launch-broker.js +13 -13
- package/lib/fleet/launch.js +15 -15
- package/lib/fleet/lease-client.js +23 -23
- package/lib/fleet/lease-routes.js +3 -3
- package/lib/fleet/lease-verifier.js +22 -22
- package/lib/fleet/managed.js +46 -36
- package/lib/fleet/prompt-delivery.js +13 -13
- package/lib/fleet/routes.js +15 -10
- package/lib/fleet/runtime.js +26 -26
- package/lib/live-host/bridge.js +36 -36
- package/lib/live-host/routes.js +2 -2
- package/lib/live-host/store.js +4 -4
- package/lib/mcp/server.js +14 -14
- package/lib/mcp/tools.js +5 -5
- package/lib/nodes/access-presets.js +258 -0
- package/lib/nodes/commands.js +198 -4
- package/lib/nodes/store.js +152 -9
- package/lib/nodes/tunnel.js +7 -7
- package/lib/notify/ask-answer-service.js +184 -0
- package/lib/notify/ask-receipts.js +197 -0
- package/lib/notify/ask-relay.js +152 -0
- package/lib/notify/asks.js +28 -3
- package/lib/notify/event-feed-acl.js +95 -0
- package/lib/notify/event-feed-asks-routes.js +144 -0
- package/lib/notify/event-feed-client.js +434 -0
- package/lib/notify/event-feed-history.js +115 -0
- package/lib/notify/event-feed-producers.js +175 -0
- package/lib/notify/event-feed-routes.js +254 -0
- package/lib/notify/event-feed.js +328 -0
- package/lib/notify/events.js +1 -1
- package/lib/notify/notifier.js +3 -3
- package/lib/notify/push-relay.js +240 -0
- package/lib/notify/push.js +8 -8
- package/lib/notify/routes.js +62 -23
- package/lib/proxy/federation.js +74 -3
- package/lib/proxy/node-proxy.js +13 -13
- package/lib/proxy/panel-auth.js +4 -4
- package/lib/proxy/panel-proxy.js +2 -2
- package/lib/proxy/resource-acl.js +145 -0
- package/lib/server.js +372 -39
- package/lib/settings/pairing-coordinator.js +1 -1
- package/lib/settings/routes.js +45 -32
- package/lib/update/manager.js +1 -1
- package/lib/update/runner.js +1 -1
- package/lib/voice/transcribe.js +1 -1
- package/lib/ws/bridge.js +3 -3
- package/package.json +1 -1
- package/frontend/dist/assets/index-B_THfiL5.js +0 -167
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// lib/notify/ask-answer-service.js — ONE answer cycle for BOTH the local route
|
|
3
|
+
// and the federated one. There is no second path: claim (synchronous) ->
|
|
4
|
+
// paste to the session FROM THE STORE (never from a body) -> commit or a
|
|
5
|
+
// declared failure -> a qualified closure event (askId, revision, cell).
|
|
6
|
+
//
|
|
7
|
+
// Federated attempts additionally carry a durable receipt (written BEFORE the
|
|
8
|
+
// paste) and an idempotency contract: the same requestId with the same text
|
|
9
|
+
// after a timeout returns the stored state WITHOUT pasting again; the same id
|
|
10
|
+
// with a different text is a 409. A locked ask (delivery-unknown from a
|
|
11
|
+
// previous crash) refuses new pastes on EVERY path until the operator
|
|
12
|
+
// reconciles explicitly: no silent retries, no TTL.
|
|
13
|
+
|
|
14
|
+
const crypto = require('node:crypto');
|
|
15
|
+
|
|
16
|
+
const REQUEST_ID_RE = /^[0-9a-f-]{16,64}$/i;
|
|
17
|
+
|
|
18
|
+
function createAskAnswerService({ asks, paste, receipts, onClosure, labelPrefix = 'reply' } = {}) {
|
|
19
|
+
if (!asks || !paste) throw new Error('createAskAnswerService: asks e paste richiesti');
|
|
20
|
+
const emitClosure = typeof onClosure === 'function' ? onClosure : () => {};
|
|
21
|
+
|
|
22
|
+
function pasteText(askId, text, label) {
|
|
23
|
+
return `[${label} reply · ask#${askId}] ${text}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// The shared core. Returns {ok:true} or {ok:false, code, error, reason}.
|
|
27
|
+
async function runAnswer({ askId, text, request = null }) {
|
|
28
|
+
// A locked ask refuses new pastes on every path until reconciliation.
|
|
29
|
+
if (receipts && receipts.isBlocked(askId)) {
|
|
30
|
+
return { ok: false, code: 409, error: 'ask con esito incerto: richiede riconciliazione dell\'operatore', reason: 'delivery-unknown-block' };
|
|
31
|
+
}
|
|
32
|
+
// The receipt is authoritative AT THE POINT OF PASTE: an ask whose durable
|
|
33
|
+
// answer state is already terminal cannot produce a second paste, whichever
|
|
34
|
+
// path got here — the ask store alone is not enough, because a reconciliation
|
|
35
|
+
// can land on the receipt without closing the ask.
|
|
36
|
+
const answerState = receipts && typeof receipts.answerStateFor === 'function'
|
|
37
|
+
? receipts.answerStateFor(askId) : null;
|
|
38
|
+
if (answerState === 'committed' || answerState === 'delivered') {
|
|
39
|
+
return { ok: false, code: 409, error: 'ask gia\' consegnato: nessun nuovo paste', reason: 'already-delivered' };
|
|
40
|
+
}
|
|
41
|
+
const claim = asks.claim(askId);
|
|
42
|
+
if (!claim.ok) {
|
|
43
|
+
// Refused BEFORE any paste: the attempt is closed here. Left `pending`, it
|
|
44
|
+
// would come back as delivery-unknown after a restart and block the ask
|
|
45
|
+
// for a paste that never happened.
|
|
46
|
+
if (request && receipts) receipts.finalize(request.peerId, askId, request.requestId, 'failed', `refused:${claim.reason}`);
|
|
47
|
+
if (claim.reason === 'unknown') return { ok: false, code: 404, error: 'ask inesistente', reason: 'unknown' };
|
|
48
|
+
if (claim.reason === 'dismissed') return { ok: false, code: 409, error: 'ask gia\' scartato (dismissed)', reason: 'dismissed' };
|
|
49
|
+
if (claim.reason === 'answering') return { ok: false, code: 409, error: 'risposta gia\' in corso da un\'altra richiesta', reason: 'answering' };
|
|
50
|
+
return { ok: false, code: 409, error: 'ask gia\' risposto', reason: 'answered' };
|
|
51
|
+
}
|
|
52
|
+
let pasted = false;
|
|
53
|
+
try {
|
|
54
|
+
pasted = await paste(claim.ask.session, pasteText(askId, text, labelPrefix));
|
|
55
|
+
} catch (_) { pasted = false; }
|
|
56
|
+
if (!pasted) {
|
|
57
|
+
asks.release(askId); // rollback: the ask stays open and contestable
|
|
58
|
+
if (request && receipts) receipts.finalize(request.peerId, askId, request.requestId, 'failed', 'paste-failed');
|
|
59
|
+
return { ok: false, code: 502, error: `paste fallito: sessione "${claim.ask.session}" non raggiungibile`, reason: 'paste-failed' };
|
|
60
|
+
}
|
|
61
|
+
const committed = asks.commit(askId, text);
|
|
62
|
+
if (request && receipts) receipts.finalize(request.peerId, askId, request.requestId, 'committed', null);
|
|
63
|
+
const finalAsk = asks.get(askId);
|
|
64
|
+
emitClosure('ask-answered', { askId, revision: (finalAsk && finalAsk.revision) || 1, cellSession: finalAsk && finalAsk.session });
|
|
65
|
+
return { ok: true, committed };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// LOCAL answer: validation and binding already happened in the route.
|
|
69
|
+
async function answerLocal({ askId, text }) {
|
|
70
|
+
return runAnswer({ askId, text });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// FEDERATED answer: the receipt lifecycle wraps the same core.
|
|
74
|
+
async function answerFederated({ askId, text, peerId, requestId }) {
|
|
75
|
+
if (!requestId || !REQUEST_ID_RE.test(String(requestId))) {
|
|
76
|
+
return { ok: false, code: 400, error: 'requestId non valido', reason: 'bad-request-id' };
|
|
77
|
+
}
|
|
78
|
+
if (!receipts) return { ok: false, code: 503, error: 'receipt store non disponibile', reason: 'no-receipts' };
|
|
79
|
+
const existing = receipts.get(peerId, askId, requestId);
|
|
80
|
+
const digest = receipts.digestOf(text);
|
|
81
|
+
if (existing) {
|
|
82
|
+
// Same id, same text: the stored state, never a second paste.
|
|
83
|
+
if (existing.digest === digest) {
|
|
84
|
+
return { ok: true, replay: true, state: existing.state };
|
|
85
|
+
}
|
|
86
|
+
// Same id, different text: never answer twice with diverging payloads.
|
|
87
|
+
return { ok: false, code: 409, error: 'stesso requestId con testo diverso', reason: 'request-conflict' };
|
|
88
|
+
}
|
|
89
|
+
// Receipt BEFORE the paste: a failed receipt write means no paste at all.
|
|
90
|
+
const receipt = receipts.attempt({ peerId, askId, requestId, text });
|
|
91
|
+
if (!receipt.ok) {
|
|
92
|
+
if (receipt.reason === 'blocked') {
|
|
93
|
+
return { ok: false, code: 409, error: 'ask con esito incerto: richiede riconciliazione dell\'operatore', reason: 'delivery-unknown-block' };
|
|
94
|
+
}
|
|
95
|
+
if (receipt.reason === 'digest-conflict') {
|
|
96
|
+
return { ok: false, code: 409, error: 'stesso requestId con testo diverso', reason: 'request-conflict' };
|
|
97
|
+
}
|
|
98
|
+
return { ok: false, code: 429, error: 'receipt store al cap', reason: 'receipt-cap' };
|
|
99
|
+
}
|
|
100
|
+
const out = await runAnswer({ askId, text, request: { peerId, requestId } });
|
|
101
|
+
if (out.ok) return { ok: true, state: 'committed' };
|
|
102
|
+
// Every refusal is answered as it is: a rejection BEFORE the paste (claim
|
|
103
|
+
// lost, ask closed, locked) finalizes the attempt as failed, and a transport
|
|
104
|
+
// failure does the same — the peer re-checks the status instead of blindly
|
|
105
|
+
// retrying.
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Dismiss: idempotent, refused while an answer is in flight, and refused on an
|
|
110
|
+
// ask whose delivery is still unknown — dismissing the card would hide an
|
|
111
|
+
// outcome the operator still has to reconcile.
|
|
112
|
+
function dismiss(askId) {
|
|
113
|
+
if (receipts && receipts.isBlocked(askId)) {
|
|
114
|
+
return { ok: false, code: 409, error: 'ask con esito incerto: richiede riconciliazione dell\'operatore', reason: 'delivery-unknown-block' };
|
|
115
|
+
}
|
|
116
|
+
const out = asks.dismiss(askId);
|
|
117
|
+
if (!out.ok) return out;
|
|
118
|
+
emitClosure('ask-dismissed', { askId, revision: (out.ask && out.ask.revision) || 1, cellSession: out.ask && out.ask.session });
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Operator reconciliation of a delivery-unknown attempt. The revision is a
|
|
123
|
+
// MANDATORY compare-and-set: a reconciliation decided on a generation that is
|
|
124
|
+
// already gone must not unlock anything, and the transition itself advances
|
|
125
|
+
// the revision so the same token cannot be replayed.
|
|
126
|
+
function reconcile({ askId, decision, expectedRevision }) {
|
|
127
|
+
if (decision !== 'mark-delivered' && decision !== 'allow-new-attempt') {
|
|
128
|
+
return { ok: false, code: 400, error: 'decision deve essere mark-delivered|allow-new-attempt', reason: 'bad-decision' };
|
|
129
|
+
}
|
|
130
|
+
if (!receipts) return { ok: false, code: 503, error: 'receipt store non disponibile', reason: 'no-receipts' };
|
|
131
|
+
const ask = asks.get(askId);
|
|
132
|
+
if (!ask) return { ok: false, code: 404, error: 'ask inesistente', reason: 'unknown' };
|
|
133
|
+
if (expectedRevision === undefined || expectedRevision === null || expectedRevision === '') {
|
|
134
|
+
return { ok: false, code: 400, error: 'expectedRevision obbligatoria', reason: 'revision-required' };
|
|
135
|
+
}
|
|
136
|
+
if (Number(expectedRevision) !== (ask.revision || 0)) {
|
|
137
|
+
return { ok: false, code: 409, error: 'revision stantia', reason: 'revision-conflict' };
|
|
138
|
+
}
|
|
139
|
+
// A paste that is still in flight is not an outcome to reconcile: its claim
|
|
140
|
+
// is HERS, and deciding here would release a claim mid-paste and let a
|
|
141
|
+
// second paste through. Refused without touching the claim.
|
|
142
|
+
// The window between this check and markReconciled is SYNCHRONOUS (there is
|
|
143
|
+
// no await in between), so no new claim can appear once the refusal is not
|
|
144
|
+
// taken: that is what makes the guard sufficient, not the receipt alone.
|
|
145
|
+
const live = receipts.reconcilableFor(askId);
|
|
146
|
+
if ((typeof asks.isAnswering === 'function' && asks.isAnswering(askId)) || live.live > 0) {
|
|
147
|
+
return {
|
|
148
|
+
ok: false, code: 409, reason: 'answering',
|
|
149
|
+
error: 'paste in corso su questa ask: riconciliazione rifiutata, il claim non si tocca',
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
// Nothing unresolved: `ok:true, changed:0` would be a false success.
|
|
153
|
+
if (live.count === 0) {
|
|
154
|
+
return { ok: false, code: 409, reason: 'nothing-to-reconcile', error: 'nessun esito incerto da riconciliare' };
|
|
155
|
+
}
|
|
156
|
+
const expect = { count: live.count, generation: live.generation };
|
|
157
|
+
|
|
158
|
+
// Restrictive write FIRST, permissive one last: the ask-store transition
|
|
159
|
+
// closes the ask (mark-delivered) or just advances the revision
|
|
160
|
+
// (allow-new-attempt), while the receipt keeps blocking the paste until its
|
|
161
|
+
// own durable write lands. A partial failure therefore never leaves a state
|
|
162
|
+
// where a paste is possible: the residue is "ask closed" or "receipt still
|
|
163
|
+
// blocked", never more permissive than the decision.
|
|
164
|
+
const moved = asks.markReconciled(askId, decision);
|
|
165
|
+
if (!moved.ok) {
|
|
166
|
+
return { ok: false, code: 500, error: 'transizione non scritta: stato invariato', reason: 'ask-transition-failed' };
|
|
167
|
+
}
|
|
168
|
+
const out = receipts.reconcile(askId, decision, expect);
|
|
169
|
+
if (!out.ok) {
|
|
170
|
+
return {
|
|
171
|
+
ok: false, code: out.reason === 'nothing-to-reconcile' || out.reason === 'generation-changed' ? 409 : 500,
|
|
172
|
+
reason: out.reason,
|
|
173
|
+
error: decision === 'mark-delivered'
|
|
174
|
+
? 'ricevuta non riconciliata: ask comunque chiuso (nessun paste possibile)'
|
|
175
|
+
: 'ricevuta non riconciliata: blocco ancora attivo (nessun paste possibile)',
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
return { ok: true, changed: out.changed, revision: (moved.ask && moved.ask.revision) || 0 };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return { answerLocal, answerFederated, dismiss, reconcile, pasteText };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
module.exports = { createAskAnswerService, REQUEST_ID_RE };
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// lib/notify/ask-receipts.js — durable receipts for FEDERATED answer attempts.
|
|
3
|
+
//
|
|
4
|
+
// The receipt is the answer's paper trail: a federated attempt gets a UUID
|
|
5
|
+
// (requestId), a digest of the text and a state that is WRITTEN BEFORE the
|
|
6
|
+
// paste happens (a failed receipt write means no paste — the owner never fires
|
|
7
|
+
// blind). Terminal states are final: replaying the same requestId with the
|
|
8
|
+
// same digest returns the stored state without pasting again; the same id with
|
|
9
|
+
// a different digest is a conflict. A process crash leaves `pending` behind:
|
|
10
|
+
// at recovery it becomes `delivery-unknown` and BLOCKS every new paste for
|
|
11
|
+
// that ask — local ones too — until the operator reconciles explicitly (
|
|
12
|
+
// no silent retries, no TTL).
|
|
13
|
+
//
|
|
14
|
+
// Cap 1000 attempts; terminal receipts age out after 24 h; at cap, NEW
|
|
15
|
+
// attempts are refused rather than evicting pending/unknown ones.
|
|
16
|
+
|
|
17
|
+
const fs = require('node:fs');
|
|
18
|
+
const path = require('node:path');
|
|
19
|
+
const crypto = require('node:crypto');
|
|
20
|
+
const { atomicWriteJson } = require('./persist.js');
|
|
21
|
+
|
|
22
|
+
const SCHEMA = 'nexuscrew-ask-receipts-v1';
|
|
23
|
+
const CAP_ATTEMPTS = 1000;
|
|
24
|
+
const TERMINAL_RETENTION_MS = 24 * 60 * 60 * 1000;
|
|
25
|
+
|
|
26
|
+
function keyOf(peerId, askId, requestId) { return `${peerId}|${askId}|${requestId}`; }
|
|
27
|
+
function digestOf(text) { return crypto.createHash('sha256').update(String(text), 'utf8').digest('hex'); }
|
|
28
|
+
|
|
29
|
+
function createAskReceipts({ filePath, now = Date.now } = {}) {
|
|
30
|
+
if (!filePath) throw new Error('createAskReceipts: filePath richiesta');
|
|
31
|
+
|
|
32
|
+
function loadRaw() {
|
|
33
|
+
try {
|
|
34
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
35
|
+
if (parsed && parsed.schema === SCHEMA && parsed.attempts && typeof parsed.attempts === 'object') return parsed;
|
|
36
|
+
} catch (_) { /* fresh */ }
|
|
37
|
+
return { schema: SCHEMA, attempts: {}, blockedAsks: {} };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function persist(state) {
|
|
41
|
+
const dir = path.dirname(filePath);
|
|
42
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
43
|
+
const tmp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
44
|
+
fs.writeFileSync(tmp, JSON.stringify(state), { mode: 0o600 });
|
|
45
|
+
fs.renameSync(tmp, filePath);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Recovery at construction: a `pending` attempt did not observe its own
|
|
49
|
+
// paste, so its outcome is unknown and the ask is locked.
|
|
50
|
+
const state = loadRaw();
|
|
51
|
+
for (const entry of Object.values(state.attempts)) {
|
|
52
|
+
if (entry.state === 'pending') {
|
|
53
|
+
entry.state = 'delivery-unknown';
|
|
54
|
+
entry.updatedAt = now();
|
|
55
|
+
state.blockedAsks[entry.askId] = true;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// Terminal retention: old terminal receipts age out (pending/unknown NEVER).
|
|
59
|
+
const cutoff = now() - TERMINAL_RETENTION_MS;
|
|
60
|
+
for (const [key, entry] of Object.entries(state.attempts)) {
|
|
61
|
+
const terminal = entry.state === 'committed' || entry.state === 'failed';
|
|
62
|
+
if (terminal && (entry.updatedAt || 0) < cutoff) delete state.attempts[key];
|
|
63
|
+
}
|
|
64
|
+
if (Object.keys(state.attempts).length > 0) persist(state);
|
|
65
|
+
|
|
66
|
+
function isBlocked(askId) { return state.blockedAsks[askId] === true; }
|
|
67
|
+
|
|
68
|
+
function get(peerId, askId, requestId) {
|
|
69
|
+
return state.attempts[keyOf(peerId, askId, requestId)] || null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Create the pending receipt. Refuses when the ask is blocked, or at cap —
|
|
73
|
+
// NEVER evicts a pending/unknown attempt to make room.
|
|
74
|
+
function attempt({ peerId, askId, requestId, text }) {
|
|
75
|
+
if (isBlocked(askId)) return { ok: false, reason: 'blocked' };
|
|
76
|
+
const key = keyOf(peerId, askId, requestId);
|
|
77
|
+
const existing = state.attempts[key];
|
|
78
|
+
if (existing) {
|
|
79
|
+
if (existing.digest !== digestOf(text)) return { ok: false, reason: 'digest-conflict', entry: existing };
|
|
80
|
+
return { ok: true, replay: true, entry: existing };
|
|
81
|
+
}
|
|
82
|
+
const terminalCount = Object.values(state.attempts)
|
|
83
|
+
.filter((e) => e.state !== 'pending' && e.state !== 'delivery-unknown').length;
|
|
84
|
+
if (Object.keys(state.attempts).length >= CAP_ATTEMPTS && terminalCount === 0) {
|
|
85
|
+
return { ok: false, reason: 'cap' };
|
|
86
|
+
}
|
|
87
|
+
const entry = {
|
|
88
|
+
peerId, askId, requestId,
|
|
89
|
+
digest: digestOf(text),
|
|
90
|
+
state: 'pending',
|
|
91
|
+
createdAt: now(), updatedAt: now(),
|
|
92
|
+
};
|
|
93
|
+
state.attempts[key] = entry;
|
|
94
|
+
persist(state);
|
|
95
|
+
return { ok: true, replay: false, entry };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function finalize(peerId, askId, requestId, finalState, receipt) {
|
|
99
|
+
const entry = state.attempts[keyOf(peerId, askId, requestId)];
|
|
100
|
+
if (!entry) return false;
|
|
101
|
+
if (entry.state !== 'pending') { entry.replayedAt = now(); return true; }
|
|
102
|
+
entry.state = finalState;
|
|
103
|
+
entry.receipt = receipt === undefined ? null : receipt;
|
|
104
|
+
entry.updatedAt = now();
|
|
105
|
+
persist(state);
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Explicit operator reconciliation (local, authenticated, never federated):
|
|
110
|
+
// every delivery-unknown attempt of the ask gets the decided outcome and the
|
|
111
|
+
// ask is unlocked for new pastes.
|
|
112
|
+
function reconcile(askId, decision, expect = null) {
|
|
113
|
+
const finalState = decision === 'mark-delivered' ? 'committed'
|
|
114
|
+
: decision === 'allow-new-attempt' ? 'failed' : null;
|
|
115
|
+
if (!finalState) return { ok: false, reason: 'bad-decision' };
|
|
116
|
+
// The decision is bound to the GENERATION it was taken on: the exact set of
|
|
117
|
+
// unresolved outcomes that was visible when the operator decided. If that
|
|
118
|
+
// set moved (or is empty), this reconciliation is refused instead of
|
|
119
|
+
// unlocking a generation nobody decided about.
|
|
120
|
+
const current = reconcilableFor(askId);
|
|
121
|
+
if (current.count === 0) return { ok: false, reason: 'nothing-to-reconcile' };
|
|
122
|
+
if (expect && (expect.count !== current.count || expect.generation !== current.generation)) {
|
|
123
|
+
return { ok: false, reason: 'generation-changed' };
|
|
124
|
+
}
|
|
125
|
+
// The unlock is exposed ONLY after the durable write succeeded: the fields
|
|
126
|
+
// this decision touches are snapshotted first, so a failed write leaves the
|
|
127
|
+
// ask exactly as blocked as it was (no half-reconciled state in memory).
|
|
128
|
+
const touched = [];
|
|
129
|
+
let changed = 0;
|
|
130
|
+
for (const [key, entry] of Object.entries(state.attempts)) {
|
|
131
|
+
if (entry.askId === askId && entry.state === 'delivery-unknown') {
|
|
132
|
+
touched.push([key, { ...entry }]);
|
|
133
|
+
entry.state = finalState;
|
|
134
|
+
entry.receipt = 'reconciled';
|
|
135
|
+
entry.updatedAt = now();
|
|
136
|
+
changed += 1;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const wasBlocked = state.blockedAsks[askId] === true;
|
|
140
|
+
delete state.blockedAsks[askId];
|
|
141
|
+
try {
|
|
142
|
+
persist(state);
|
|
143
|
+
} catch (_) {
|
|
144
|
+
for (const [key, prev] of touched) state.attempts[key] = prev;
|
|
145
|
+
if (wasBlocked) state.blockedAsks[askId] = true;
|
|
146
|
+
return { ok: false, reason: 'persist-failed' };
|
|
147
|
+
}
|
|
148
|
+
return { ok: true, changed };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// The authoritative answer state of an ask, as the durable receipts know it:
|
|
152
|
+
// a terminal write (`committed` after a paste, `delivered` after an operator
|
|
153
|
+
// reconciliation) means the ask already produced its answer — whatever store
|
|
154
|
+
// the caller went through. `unknown` marks an outcome that is still unresolved.
|
|
155
|
+
function answerStateFor(askId) {
|
|
156
|
+
let unfinished = false;
|
|
157
|
+
for (const entry of Object.values(state.attempts)) {
|
|
158
|
+
if (entry.askId !== askId) continue;
|
|
159
|
+
if (entry.state === 'committed' || entry.state === 'delivered') return entry.state;
|
|
160
|
+
if (entry.state === 'pending' || entry.state === 'delivery-unknown') unfinished = true;
|
|
161
|
+
}
|
|
162
|
+
return unfinished ? 'unknown' : null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// The outcomes an operator can reconcile right now: the delivery-unknown
|
|
166
|
+
// attempts of this ask, with a short digest of their identity set. `pending`
|
|
167
|
+
// attempts are a paste still in flight — they are NOT reconcilable and are
|
|
168
|
+
// reported separately, so nobody can decide on an outcome that is still open.
|
|
169
|
+
function reconcilableFor(askId) {
|
|
170
|
+
const ids = [];
|
|
171
|
+
let live = 0;
|
|
172
|
+
for (const entry of Object.values(state.attempts)) {
|
|
173
|
+
if (entry.askId !== askId) continue;
|
|
174
|
+
if (entry.state === 'delivery-unknown') ids.push(String(entry.requestId));
|
|
175
|
+
else if (entry.state === 'pending') live += 1;
|
|
176
|
+
}
|
|
177
|
+
ids.sort();
|
|
178
|
+
return {
|
|
179
|
+
count: ids.length,
|
|
180
|
+
live,
|
|
181
|
+
generation: crypto.createHash('sha256').update(ids.join('|'), 'utf8').digest('hex').slice(0, 16),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function statusFor(askId) {
|
|
186
|
+
const attempts = Object.values(state.attempts).filter((e) => e.askId === askId);
|
|
187
|
+
return {
|
|
188
|
+
blocked: isBlocked(askId),
|
|
189
|
+
attempts: attempts.length,
|
|
190
|
+
unknown: attempts.filter((e) => e.state === 'delivery-unknown').length,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return { attempt, get, finalize, isBlocked, reconcile, reconcilableFor, statusFor, answerStateFor, digestOf };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
module.exports = { createAskReceipts, digestOf, keyOf, CAP_ATTEMPTS };
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// lib/notify/ask-relay.js — the client-side relay for federated ask answers.
|
|
3
|
+
//
|
|
4
|
+
// The local endpoint receives {ownerId, askId, text, requestId} from the PWA.
|
|
5
|
+
// The route to the owner is resolved from the AUTHORIZED node store (the
|
|
6
|
+
// outbound peer whose nodeId is the owner) — never from anything an event
|
|
7
|
+
// said. The forward uses the peer's own pair token and the visited header;
|
|
8
|
+
// the hop proof is minted by the owner on the last hop.
|
|
9
|
+
//
|
|
10
|
+
// Outcome contract: a committed/failed answer is final. An uncertain
|
|
11
|
+
// outcome (timeout, network error after the request left) is NOT retried: the
|
|
12
|
+
// attempt is parked as "uncertain" and the only allowed action is "verify
|
|
13
|
+
// status" (GET .../requests/:requestId). A new paste for the same ask is
|
|
14
|
+
// refused locally until verification resolves the attempt.
|
|
15
|
+
|
|
16
|
+
const crypto = require('node:crypto');
|
|
17
|
+
const IDLE_MS = 15000;
|
|
18
|
+
|
|
19
|
+
function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = () => {} } = {}) {
|
|
20
|
+
// (ownerId|askId|requestId) -> {state: 'sent'|'committed'|'failed'|'uncertain', requestId}
|
|
21
|
+
const attempts = new Map();
|
|
22
|
+
|
|
23
|
+
function requestId() { return crypto.randomUUID(); }
|
|
24
|
+
|
|
25
|
+
function resolveOwner(ownerId) {
|
|
26
|
+
const store = loadStore();
|
|
27
|
+
if (!store) return null;
|
|
28
|
+
return (store.nodes || []).find((n) => n && n.direction === 'outbound'
|
|
29
|
+
&& n.nodeId === ownerId && n.token && n.localPort) || null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function ownerUrl(peer, path) {
|
|
33
|
+
return `http://127.0.0.1:${peer.localPort}/federation/route/_/${path.replace(/^\//, '')}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function attemptKey(ownerId, askId, rid) { return `${ownerId}|${askId}|${rid}`; }
|
|
37
|
+
|
|
38
|
+
function statusFor(ownerId, askId) {
|
|
39
|
+
const out = [];
|
|
40
|
+
for (const [key, a] of attempts) {
|
|
41
|
+
if (key.startsWith(`${ownerId}|${askId}|`)) out.push(a);
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isUncertain(ownerId, askId) {
|
|
47
|
+
return statusFor(ownerId, askId).some((a) => a.state === 'uncertain');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function relayAnswer({ ownerId, askId, text, rid: providedRid } = {}) {
|
|
51
|
+
if (!ownerId || !askId || typeof text !== 'string' || !text.trim()) {
|
|
52
|
+
return { ok: false, code: 400, error: 'ownerId, askId e text richiesti' };
|
|
53
|
+
}
|
|
54
|
+
const peer = resolveOwner(ownerId);
|
|
55
|
+
if (!peer) return { ok: false, code: 404, error: 'owner non tra i peer autorizzati', reason: 'owner-unknown' };
|
|
56
|
+
// Local reconciliation FIRST: an uncertain attempt on this ask must be
|
|
57
|
+
// verified before any new paste leaves this node.
|
|
58
|
+
if (isUncertain(ownerId, askId)) {
|
|
59
|
+
return { ok: false, code: 409, error: 'esito incerto: verifica lo stato prima di rispondere', reason: 'uncertain', uncertain: true };
|
|
60
|
+
}
|
|
61
|
+
const rid = providedRid || requestId();
|
|
62
|
+
const key = attemptKey(ownerId, askId, rid);
|
|
63
|
+
const store = loadStore();
|
|
64
|
+
attempts.set(key, { requestId: rid, state: 'sent', askId, ownerId });
|
|
65
|
+
const ctrl = new AbortController();
|
|
66
|
+
const timer = setTimeout(() => ctrl.abort(), IDLE_MS);
|
|
67
|
+
try {
|
|
68
|
+
const r = await fetchImpl(ownerUrl(peer, `/event-feed/asks/${askId}/answer`), {
|
|
69
|
+
method: 'POST',
|
|
70
|
+
headers: {
|
|
71
|
+
authorization: `Bearer ${peer.token}`,
|
|
72
|
+
'content-type': 'application/json',
|
|
73
|
+
'x-nexuscrew-visited': store.nodeId,
|
|
74
|
+
},
|
|
75
|
+
body: JSON.stringify({ text, requestId: rid }),
|
|
76
|
+
signal: ctrl.signal,
|
|
77
|
+
});
|
|
78
|
+
const body = await r.json().catch(() => ({}));
|
|
79
|
+
if (r.status === 200) {
|
|
80
|
+
const state = body.status === 'answered' ? 'committed' : body.status || 'committed';
|
|
81
|
+
attempts.get(key).state = body.replay ? (body.state || state) : state;
|
|
82
|
+
return { ok: true, status: attempts.get(key).state, requestId: rid };
|
|
83
|
+
}
|
|
84
|
+
const entry = attempts.get(key);
|
|
85
|
+
// 409 request-conflict / answering are definitive; anything else that
|
|
86
|
+
// could have pasted (timeout-ish) parks as uncertain, never retried.
|
|
87
|
+
if (r.status === 409) {
|
|
88
|
+
if (entry) entry.state = body.reason === 'delivery-unknown-block' ? 'uncertain' : 'failed';
|
|
89
|
+
return { ok: false, code: 409, error: body.error || 'conflict', reason: body.reason };
|
|
90
|
+
}
|
|
91
|
+
if (r.status === 502) {
|
|
92
|
+
if (entry) entry.state = 'uncertain';
|
|
93
|
+
return { ok: false, code: 200, uncertain: true, reason: 'delivery-unknown', requestId: rid,
|
|
94
|
+
error: 'esito incerto: verifica lo stato prima di rispondere' };
|
|
95
|
+
}
|
|
96
|
+
if (entry) entry.state = 'failed';
|
|
97
|
+
return { ok: false, code: r.status, error: body.error || `HTTP ${r.status}`, reason: body.reason };
|
|
98
|
+
} catch (_) {
|
|
99
|
+
// The request may or may not have pasted: uncertain, never retried.
|
|
100
|
+
const entry = attempts.get(key);
|
|
101
|
+
if (entry) entry.state = 'uncertain';
|
|
102
|
+
return { ok: false, code: 200, uncertain: true, reason: 'delivery-unknown', requestId: rid,
|
|
103
|
+
error: 'esito incerto: verifica lo stato prima di rispondere' };
|
|
104
|
+
} finally {
|
|
105
|
+
clearTimeout(timer);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Remote dismiss: same resolution rules as the answer (DELETE on the owner).
|
|
110
|
+
async function relayDismiss({ ownerId, askId } = {}) {
|
|
111
|
+
const peer = resolveOwner(ownerId);
|
|
112
|
+
if (!peer) return { ok: false, code: 404, error: 'owner non tra i peer autorizzati' };
|
|
113
|
+
const store = loadStore();
|
|
114
|
+
try {
|
|
115
|
+
const r = await fetchImpl(ownerUrl(peer, `/event-feed/asks/${askId}`), {
|
|
116
|
+
method: 'DELETE',
|
|
117
|
+
headers: { authorization: `Bearer ${peer.token}`, 'x-nexuscrew-visited': store ? store.nodeId : '' },
|
|
118
|
+
});
|
|
119
|
+
const body = await r.json().catch(() => ({}));
|
|
120
|
+
if (!r.ok) return { ok: false, code: r.status, error: body.error || `HTTP ${r.status}` };
|
|
121
|
+
return { ok: true };
|
|
122
|
+
} catch (e) { return { ok: false, error: String((e && e.message) || e) }; }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function verifyStatus({ ownerId, askId, requestId: rid }) {
|
|
126
|
+
const peer = resolveOwner(ownerId);
|
|
127
|
+
if (!peer) return { ok: false, code: 404, error: 'owner non tra i peer autorizzati' };
|
|
128
|
+
const store = loadStore();
|
|
129
|
+
try {
|
|
130
|
+
const r = await fetchImpl(ownerUrl(peer, `/event-feed/asks/${askId}/requests/${rid}`), {
|
|
131
|
+
headers: { authorization: `Bearer ${peer.token}`, 'x-nexuscrew-visited': store ? store.nodeId : '' },
|
|
132
|
+
});
|
|
133
|
+
if (!r.ok) return { ok: false, code: r.status };
|
|
134
|
+
const body = await r.json();
|
|
135
|
+
const key = attemptKey(ownerId, askId, rid);
|
|
136
|
+
if (attempts.has(key) && (body.state === 'committed' || body.state === 'failed')) {
|
|
137
|
+
attempts.get(key).state = body.state;
|
|
138
|
+
}
|
|
139
|
+
return { ok: true, state: body.state || 'unknown' };
|
|
140
|
+
} catch (e) {
|
|
141
|
+
return { ok: false, error: String((e && e.message) || e) };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function state() {
|
|
146
|
+
return [...attempts.entries()].map(([key, a]) => ({ key, ...a }));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return { relayAnswer, relayDismiss, verifyStatus, state, isUncertain, requestId };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
module.exports = { createAskRelay };
|
package/lib/notify/asks.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
-
// Store
|
|
2
|
+
// Store of "asks" (cell->operator questions) — MCP bridge.
|
|
3
3
|
// Stato in-memory + persistenza <dir>/asks.json 0600 (sopravvive al restart:
|
|
4
4
|
// un ask aperto resta risponibile). Lo store e' PURO stato: il paste tmux e la
|
|
5
5
|
// notify li orchestra la route (lib/notify/routes.js), che qui fa il ciclo
|
|
@@ -86,6 +86,7 @@ function createAsksStore(opts = {}) {
|
|
|
86
86
|
...(v.value.options ? { options: v.value.options } : {}),
|
|
87
87
|
session: String(session),
|
|
88
88
|
ts: now(),
|
|
89
|
+
revision: 0,
|
|
89
90
|
answered: false,
|
|
90
91
|
dismissed: false,
|
|
91
92
|
};
|
|
@@ -117,7 +118,7 @@ function createAsksStore(opts = {}) {
|
|
|
117
118
|
if (!ask) return { ok: false, reason: 'unknown' };
|
|
118
119
|
// Un ask dismissato non e' piu' risponibile: senza questa guardia la
|
|
119
120
|
// sequenza dismiss -> claim -> commit lascia lo stato ibrido
|
|
120
|
-
// `dismissed && answered`
|
|
121
|
+
// `dismissed && answered` state.
|
|
121
122
|
if (ask.dismissed) return { ok: false, reason: 'dismissed' };
|
|
122
123
|
if (ask.answered) return { ok: false, reason: 'answered' };
|
|
123
124
|
if (answering.has(id)) return { ok: false, reason: 'answering' };
|
|
@@ -125,6 +126,10 @@ function createAsksStore(opts = {}) {
|
|
|
125
126
|
return { ok: true, ask: { ...ask } };
|
|
126
127
|
}
|
|
127
128
|
|
|
129
|
+
// Read-only: a claim is being held right now. Reconciliation uses this to
|
|
130
|
+
// refuse WITHOUT touching the claim of a paste that is still in flight.
|
|
131
|
+
function isAnswering(id) { return answering.has(id); }
|
|
132
|
+
|
|
128
133
|
// Rollback: il paste e' fallito, l'ask torna contendibile.
|
|
129
134
|
function release(id) {
|
|
130
135
|
answering.delete(id);
|
|
@@ -138,6 +143,7 @@ function createAsksStore(opts = {}) {
|
|
|
138
143
|
ask.answered = true;
|
|
139
144
|
ask.answer = String(text);
|
|
140
145
|
ask.answeredTs = now();
|
|
146
|
+
ask.revision = (ask.revision || 0) + 1;
|
|
141
147
|
save();
|
|
142
148
|
return true;
|
|
143
149
|
}
|
|
@@ -154,6 +160,25 @@ function createAsksStore(opts = {}) {
|
|
|
154
160
|
if (ask.dismissed) return { ok: true, ask: { ...ask }, idempotent: true };
|
|
155
161
|
ask.dismissed = true;
|
|
156
162
|
ask.dismissedTs = now();
|
|
163
|
+
ask.revision = (ask.revision || 0) + 1;
|
|
164
|
+
save();
|
|
165
|
+
return { ok: true, ask: { ...ask } };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Operator reconciliation of a federated attempt: `mark-delivered` closes the
|
|
169
|
+
// ask (the paste happened on the peer's side, there is no local answer text),
|
|
170
|
+
// `allow-new-attempt` leaves it open. Both advance the revision, so the token
|
|
171
|
+
// the operator reconciled with can never be reused on the same generation.
|
|
172
|
+
function markReconciled(id, decision) {
|
|
173
|
+
const ask = get(id);
|
|
174
|
+
if (!ask) return { ok: false, reason: 'unknown' };
|
|
175
|
+
answering.delete(id);
|
|
176
|
+
if (decision === 'mark-delivered' && !ask.answered) {
|
|
177
|
+
ask.answered = true;
|
|
178
|
+
ask.answeredTs = now();
|
|
179
|
+
ask.answeredReconciled = true;
|
|
180
|
+
}
|
|
181
|
+
ask.revision = (ask.revision || 0) + 1;
|
|
157
182
|
save();
|
|
158
183
|
return { ok: true, ask: { ...ask } };
|
|
159
184
|
}
|
|
@@ -165,7 +190,7 @@ function createAsksStore(opts = {}) {
|
|
|
165
190
|
return commit(id, text);
|
|
166
191
|
}
|
|
167
192
|
|
|
168
|
-
return { create, get, list, openCount, claim, release, commit, markAnswered, dismiss, validate, filePath, MAX_OPEN };
|
|
193
|
+
return { create, get, list, openCount, claim, release, commit, markAnswered, markReconciled, isAnswering, dismiss, validate, filePath, MAX_OPEN };
|
|
169
194
|
}
|
|
170
195
|
|
|
171
196
|
module.exports = { createAsksStore };
|