@matuto-ai/opencode-broker-connector 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +89 -2
- package/bin/launch.mjs +11 -0
- package/docs/integration.md +101 -0
- package/docs/owned-run-checkpoint.md +23 -0
- package/docs/owned-run.md +68 -0
- package/docs/publishing.md +24 -0
- package/docs/reference-snapshot.sha256 +11 -0
- package/docs/verification.md +112 -0
- package/package.json +37 -4
- package/plugin/index.mjs +7 -0
- package/plugin/owned-run.ts +7 -0
- package/src/config.mjs +42 -0
- package/src/cooperative-inbox.mjs +50 -0
- package/src/native-plugin.mjs +105 -0
- package/src/opencode-adapter.mjs +189 -0
- package/src/owned-run.mjs +193 -0
- package/src/reference-pin.mjs +21 -0
- package/src/reference.mjs +20 -0
- package/src/tool-catalog.mjs +6 -0
- package/vendor/broker/LICENSE +21 -0
- package/vendor/broker/ORIGIN.md +5 -0
- package/vendor/broker/reference/client.mjs +318 -0
- package/vendor/broker/reference/errors.mjs +42 -0
- package/vendor/broker/reference/inbox.mjs +289 -0
- package/vendor/broker/reference/mcp-stdio.mjs +153 -0
- package/vendor/broker/reference/runtime.mjs +306 -0
- package/vendor/broker/reference/tools.mjs +59 -0
- package/vendor/broker/src/protocol.mjs +299 -0
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { request as httpRequest } from 'node:http';
|
|
2
|
+
import { request as httpsRequest } from 'node:https';
|
|
3
|
+
import { readFileSync, openSync, fstatSync, closeSync, constants } from 'node:fs';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
6
|
+
import { ApiError, canonical, validateMetadata, validateRegistration, validateMetadataPatch,
|
|
7
|
+
validateClaim, validateCompletion, validateRenew, validateUuid } from '../src/protocol.mjs';
|
|
8
|
+
import { LocalError } from './errors.mjs';
|
|
9
|
+
|
|
10
|
+
const ALLOWED_ERROR_CODES = new Set(['invalid_request', 'unauthenticated', 'forbidden', 'recipient_not_found',
|
|
11
|
+
'message_not_found', 'identity_conflict', 'stale_lease_epoch', 'idempotency_conflict', 'metadata_conflict',
|
|
12
|
+
'claim_conflict', 'session_closed', 'message_expired', 'payload_too_large',
|
|
13
|
+
'incompatible_receiver_transport', 'receiving_disabled', 'rate_limited', 'mailbox_full',
|
|
14
|
+
'storage_unavailable', 'not_ready']);
|
|
15
|
+
|
|
16
|
+
export class BrokerError extends LocalError {
|
|
17
|
+
constructor(status, value, retryAfter = 0) {
|
|
18
|
+
super('invalid_response', { effect: 'unknown' });
|
|
19
|
+
this.name = 'BrokerError';
|
|
20
|
+
this.status = status;
|
|
21
|
+
this.retryAfter = retryAfter;
|
|
22
|
+
const raw = value?.error;
|
|
23
|
+
const code = ALLOWED_ERROR_CODES.has(raw?.code) ? raw.code : 'invalid_request';
|
|
24
|
+
// Never reproduce a remote free-text message or details in local tools/logs.
|
|
25
|
+
this.api = new ApiError(status, code, null, {
|
|
26
|
+
effect: ['not_applied', 'unknown'].includes(raw?.effect) ? raw.effect : 'unknown',
|
|
27
|
+
retryable: raw?.retryable === true && [429, 503].includes(status),
|
|
28
|
+
});
|
|
29
|
+
this.code = this.api.code;
|
|
30
|
+
this.message = this.api.message;
|
|
31
|
+
this.effect = this.api.effect;
|
|
32
|
+
this.retryable = this.api.retryable;
|
|
33
|
+
}
|
|
34
|
+
toJSON() { return this.api.toJSON(null); }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function readSecret(path) {
|
|
38
|
+
let fd;
|
|
39
|
+
try {
|
|
40
|
+
fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
41
|
+
const stat = fstatSync(fd);
|
|
42
|
+
if (!stat.isFile() || (stat.mode & 0o077) !== 0 || stat.size > 8192
|
|
43
|
+
|| (process.getuid && stat.uid !== process.getuid())) throw new Error('invalid');
|
|
44
|
+
const token = readFileSync(fd, 'utf8').trim();
|
|
45
|
+
if (!/^[A-Za-z0-9_-]{43,256}$/.test(token)) throw new Error('invalid');
|
|
46
|
+
return token;
|
|
47
|
+
} catch { throw new LocalError('invalid_configuration', { field: 'tokenFile' }); }
|
|
48
|
+
finally { if (fd !== undefined) closeSync(fd); }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function positiveSeconds(value, fallback) {
|
|
52
|
+
return Number.isSafeInteger(value) && value > 0 && value <= 365 * 86400 ? value : fallback;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** HTTPS-only by default. No redirects; no credential-bearing URLs or command-line arguments. */
|
|
56
|
+
export class BrokerClient {
|
|
57
|
+
constructor({ url, tokenFile, tokenEnvironment, caFile, insecureDevelopment = false, inbox,
|
|
58
|
+
now = Date.now, random = Math.random, sleep = delay, maxAttempts = 4,
|
|
59
|
+
maxResponseBytes = 256 * 1024, requestTimeoutMs = 40000 }) {
|
|
60
|
+
try { this.base = new URL(url); } catch { throw new LocalError('invalid_configuration', { field: 'url' }); }
|
|
61
|
+
const host = this.base.hostname.replace(/^\[|\]$/g, '');
|
|
62
|
+
if (this.base.username || this.base.password || this.base.search || this.base.hash || this.base.pathname !== '/'
|
|
63
|
+
|| !['https:', 'http:'].includes(this.base.protocol)
|
|
64
|
+
|| (this.base.protocol === 'http:' && (!insecureDevelopment || !['127.0.0.1', '::1'].includes(host)))) {
|
|
65
|
+
throw new LocalError('invalid_configuration', { field: 'url' });
|
|
66
|
+
}
|
|
67
|
+
if (!inbox || (!tokenFile && !tokenEnvironment) || (tokenFile && tokenEnvironment)
|
|
68
|
+
|| !Number.isSafeInteger(maxAttempts) || maxAttempts < 1 || maxAttempts > 10
|
|
69
|
+
|| !Number.isSafeInteger(maxResponseBytes) || maxResponseBytes < 1024 || maxResponseBytes > 1048576
|
|
70
|
+
|| !Number.isSafeInteger(requestTimeoutMs) || requestTimeoutMs < 100 || requestTimeoutMs > 120000) throw new LocalError('invalid_configuration');
|
|
71
|
+
this.inbox = inbox;
|
|
72
|
+
this.now = now;
|
|
73
|
+
this.random = random;
|
|
74
|
+
this.sleep = sleep;
|
|
75
|
+
this.maxAttempts = maxAttempts;
|
|
76
|
+
this.maxResponseBytes = maxResponseBytes;
|
|
77
|
+
this.requestTimeoutMs = requestTimeoutMs;
|
|
78
|
+
this.tokenFile = tokenFile;
|
|
79
|
+
this.tokenEnvironment = tokenEnvironment;
|
|
80
|
+
try { this.ca = caFile ? readFileSync(caFile) : undefined; }
|
|
81
|
+
catch { throw new LocalError('invalid_configuration', { field: 'caFile' }); }
|
|
82
|
+
this.clockOffset = 0;
|
|
83
|
+
this.connectPromise = null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
get identity() { return this.inbox.identity; }
|
|
87
|
+
get attachment() { return this.inbox.getState('attachment') ?? {}; }
|
|
88
|
+
get policy() { return this.attachment.policy ?? {}; }
|
|
89
|
+
serverNow() { return this.now() + this.clockOffset; }
|
|
90
|
+
|
|
91
|
+
credential() {
|
|
92
|
+
if (this.tokenFile) return readSecret(this.tokenFile);
|
|
93
|
+
const token = process.env[this.tokenEnvironment];
|
|
94
|
+
if (typeof token !== 'string' || !/^[A-Za-z0-9_-]{43,256}$/.test(token)) throw new LocalError('invalid_configuration', { field: 'tokenEnvironment' });
|
|
95
|
+
return token;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async request(method, path, { body, key, session = true, signal, idempotent = false, deadline = Infinity } = {}) {
|
|
99
|
+
if (!/^\/v1\/[A-Za-z0-9_/?=&.%+~-]+$/.test(path)) throw new LocalError('invalid_configuration');
|
|
100
|
+
for (let attempt = 0; ; attempt++) {
|
|
101
|
+
if (this.now() >= deadline) throw new LocalError('retry_window_elapsed', { effect: 'unknown' });
|
|
102
|
+
try { return await this.requestOnce(method, path, { body, key, session, signal, timeoutMs: Math.min(this.requestTimeoutMs, deadline - this.now()) }); }
|
|
103
|
+
catch (error) {
|
|
104
|
+
if (signal?.aborted) throw new LocalError('request_aborted', { effect: 'unknown' });
|
|
105
|
+
const retryable = error instanceof BrokerError ? error.retryable
|
|
106
|
+
: error instanceof LocalError && error.code === 'request_failed';
|
|
107
|
+
if (!idempotent || !retryable || attempt + 1 >= this.maxAttempts) throw error;
|
|
108
|
+
const milliseconds = Math.max(error.retryAfter * 1000 || 0, Math.min(10000, 250 * 2 ** attempt) * (0.8 + this.random() * 0.4));
|
|
109
|
+
if (this.now() + milliseconds >= deadline) throw new LocalError('retry_window_elapsed', { effect: 'unknown' });
|
|
110
|
+
try { await this.sleep(milliseconds, undefined, { signal }); }
|
|
111
|
+
catch { throw new LocalError('request_aborted', { effect: 'unknown' }); }
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
requestOnce(method, path, { body, key, session, signal, timeoutMs }) {
|
|
117
|
+
const serialized = body === undefined ? null : canonical(body);
|
|
118
|
+
if (serialized && Buffer.byteLength(serialized) > 98304) throw new LocalError('invalid_configuration', { field: 'body' });
|
|
119
|
+
const headers = { Accept: 'application/json', Authorization: `Bearer ${this.credential()}` };
|
|
120
|
+
if (session) {
|
|
121
|
+
if (!Number.isSafeInteger(this.attachment.lease_epoch) || this.attachment.lease_epoch < 1) throw new LocalError('session_mismatch');
|
|
122
|
+
headers['X-Client-ID'] = this.identity.client_id;
|
|
123
|
+
headers['X-Lease-Epoch'] = String(this.attachment.lease_epoch);
|
|
124
|
+
}
|
|
125
|
+
if (key) headers['Idempotency-Key'] = key;
|
|
126
|
+
if (serialized !== null) { headers['Content-Type'] = 'application/json'; headers['Content-Length'] = Buffer.byteLength(serialized); }
|
|
127
|
+
const target = new URL(path, this.base);
|
|
128
|
+
return new Promise((resolve, reject) => {
|
|
129
|
+
let settled = false;
|
|
130
|
+
let absoluteTimer;
|
|
131
|
+
const finish = (error, value) => {
|
|
132
|
+
if (settled) return;
|
|
133
|
+
settled = true;
|
|
134
|
+
clearTimeout(absoluteTimer);
|
|
135
|
+
if (error) reject(error); else resolve(value);
|
|
136
|
+
};
|
|
137
|
+
const transport = target.protocol === 'https:' ? httpsRequest : httpRequest;
|
|
138
|
+
const request = transport(target, { method, headers, signal, ca: this.ca,
|
|
139
|
+
rejectUnauthorized: true, timeout: timeoutMs, maxHeaderSize: 16384 }, response => {
|
|
140
|
+
const chunks = [];
|
|
141
|
+
let bytes = 0;
|
|
142
|
+
response.on('data', chunk => {
|
|
143
|
+
bytes += chunk.length;
|
|
144
|
+
if (bytes > this.maxResponseBytes) { finish(new LocalError('invalid_response', { effect: 'unknown' })); response.destroy(); }
|
|
145
|
+
else chunks.push(chunk);
|
|
146
|
+
});
|
|
147
|
+
response.on('error', () => finish(new LocalError('request_failed', { effect: 'unknown', retryable: true })));
|
|
148
|
+
response.on('end', () => {
|
|
149
|
+
try {
|
|
150
|
+
const status = response.statusCode;
|
|
151
|
+
if (status === 204) { finish(null, null); return; }
|
|
152
|
+
if (!String(response.headers['content-type'] ?? '').toLowerCase().startsWith('application/json')) throw new LocalError('invalid_response', { effect: 'unknown' });
|
|
153
|
+
const value = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks)));
|
|
154
|
+
if (status < 200 || status >= 300) {
|
|
155
|
+
const retryAfter = /^\d+$/.test(String(response.headers['retry-after'] ?? '')) ? Math.min(3600, Number(response.headers['retry-after'])) : 0;
|
|
156
|
+
finish(new BrokerError(status, value, retryAfter)); return;
|
|
157
|
+
}
|
|
158
|
+
const timestamp = value?.server_time;
|
|
159
|
+
// Registration/send/completion retries may return their original
|
|
160
|
+
// stored result. Historical server_time is not a current clock sample.
|
|
161
|
+
if ((method === 'GET' || path.endsWith('/heartbeat') || method === 'PATCH')
|
|
162
|
+
&& typeof timestamp === 'string' && Number.isFinite(Date.parse(timestamp))) this.clockOffset = Date.parse(timestamp) - this.now();
|
|
163
|
+
finish(null, value);
|
|
164
|
+
} catch (error) { finish(error instanceof LocalError ? error : new LocalError('invalid_response', { effect: 'unknown' })); }
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
request.on('timeout', () => request.destroy());
|
|
168
|
+
request.on('error', () => finish(new LocalError(signal?.aborted ? 'request_aborted' : 'request_failed', { effect: 'unknown', retryable: !signal?.aborted })));
|
|
169
|
+
absoluteTimer = setTimeout(() => request.destroy(), timeoutMs);
|
|
170
|
+
request.end(serialized);
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
capabilities(receiveEnabled = true) {
|
|
175
|
+
return { protocol_version: '1', adapter_version: '0.1.0', client_type: this.identity.client_type,
|
|
176
|
+
receive_enabled: receiveEnabled, durable_inbox: true };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
applyRegistration(result, pending) {
|
|
180
|
+
if (!result || result.client_id !== this.identity.client_id || !Number.isSafeInteger(result.lease_epoch)
|
|
181
|
+
|| result.lease_epoch !== pending.body.previous_epoch + 1 || !Number.isSafeInteger(result.metadata_revision)
|
|
182
|
+
|| result.metadata_revision < 1 || !result.policy) throw new LocalError('invalid_response', { effect: 'unknown' });
|
|
183
|
+
this.inbox.setState('attachment', { ...this.attachment, ...result, connection_id: pending.body.connection_id });
|
|
184
|
+
this.inbox.setState('metadata', pending.body.metadata);
|
|
185
|
+
this.inbox.setState('registration_pending', null);
|
|
186
|
+
this.inbox.setState('claim_pending', null);
|
|
187
|
+
return result;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async register(metadata, { receiveEnabled = true, signal } = {}) {
|
|
191
|
+
let pending = this.inbox.getState('registration_pending');
|
|
192
|
+
if (!pending) {
|
|
193
|
+
const body = validateRegistration({ protocol_version: '1', ...this.identity, connection_id: randomUUID(),
|
|
194
|
+
previous_epoch: this.attachment.lease_epoch ?? 0, metadata: validateMetadata(metadata), capabilities: this.capabilities(receiveEnabled) });
|
|
195
|
+
pending = { body, retry_until: this.now() + positiveSeconds(this.policy.registration_retry_seconds, 86400) * 1000 };
|
|
196
|
+
this.inbox.setState('registration_pending', pending);
|
|
197
|
+
}
|
|
198
|
+
const result = await this.request('POST', '/v1/sessions', { body: pending.body, session: false,
|
|
199
|
+
idempotent: true, deadline: pending.retry_until, signal });
|
|
200
|
+
const state = await this.request('GET', `/v1/sessions/${this.identity.client_id}`, { session: false, idempotent: true, signal });
|
|
201
|
+
if (state?.client_id !== this.identity.client_id || state.session_instance_id !== this.identity.session_instance_id
|
|
202
|
+
|| state.client_type !== this.identity.client_type || state.connection_id !== pending.body.connection_id
|
|
203
|
+
|| state.lease_epoch !== result?.lease_epoch) throw new LocalError('session_mismatch', { effect: 'unknown' });
|
|
204
|
+
const confirmed = this.applyRegistration({ ...result, server_time: state.server_time, lease_expires_at: state.lease_expires_at }, pending);
|
|
205
|
+
if (state.closed === true) throw new BrokerError(410, { error: { code: 'session_closed', effect: 'unknown' } });
|
|
206
|
+
if (state.connected !== true || Date.parse(state.lease_expires_at) <= this.serverNow()) {
|
|
207
|
+
throw new BrokerError(409, { error: { code: 'stale_lease_epoch', effect: 'unknown' } });
|
|
208
|
+
}
|
|
209
|
+
return confirmed;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async reconnect({ verifySession, signal, metadata, receiveEnabled } = {}) {
|
|
213
|
+
if (this.connectPromise) return this.connectPromise;
|
|
214
|
+
this.connectPromise = this.reconnectOnce({ verifySession, signal, metadata, receiveEnabled });
|
|
215
|
+
try { return await this.connectPromise; } finally { this.connectPromise = null; }
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async reconnectOnce({ verifySession, signal, metadata, receiveEnabled }) {
|
|
219
|
+
if (typeof verifySession !== 'function' || await verifySession(this.identity) !== true) throw new LocalError('session_mismatch');
|
|
220
|
+
const local = this.attachment;
|
|
221
|
+
const pending = this.inbox.getState('registration_pending');
|
|
222
|
+
const state = await this.request('GET', `/v1/sessions/${this.identity.client_id}`, { session: false, idempotent: true, signal });
|
|
223
|
+
if (state.client_id !== this.identity.client_id || state.session_instance_id !== this.identity.session_instance_id
|
|
224
|
+
|| state.client_type !== this.identity.client_type || !Number.isSafeInteger(state.lease_epoch)
|
|
225
|
+
|| ![local.connection_id, pending?.body.connection_id].filter(Boolean).includes(state.connection_id)) throw new LocalError('session_mismatch');
|
|
226
|
+
if (state.closed === true || state.closed_at != null) throw new BrokerError(410, { error: { code: 'session_closed', effect: 'not_applied' } });
|
|
227
|
+
// The owned-state read is a reconciliation, not permission to steal a different attachment.
|
|
228
|
+
this.inbox.setState('attachment', { ...local, lease_epoch: state.lease_epoch, connection_id: state.connection_id,
|
|
229
|
+
metadata_revision: state.metadata_revision, policy: state.policy ?? local.policy });
|
|
230
|
+
this.inbox.setState('registration_pending', null);
|
|
231
|
+
const refreshedMetadata = metadata ?? pending?.body.metadata ?? state.metadata ?? this.inbox.getState('metadata');
|
|
232
|
+
return this.register(refreshedMetadata, { receiveEnabled: receiveEnabled ?? pending?.body.capabilities.receive_enabled ?? state.capabilities?.receive_enabled ?? false, signal });
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async heartbeat({ signal } = {}) {
|
|
236
|
+
return this.request('POST', `/v1/sessions/${this.identity.client_id}/heartbeat`, { body: {}, idempotent: true, signal });
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async closeSession({ signal } = {}) {
|
|
240
|
+
return this.request('POST', `/v1/sessions/${this.identity.client_id}/close`, { body: {}, idempotent: true, signal });
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async listClients(filters = {}, { signal } = {}) {
|
|
244
|
+
const allowed = ['connected', 'client_type', 'name', 'project', 'limit', 'cursor'];
|
|
245
|
+
if (Object.keys(filters).some(key => !allowed.includes(key))) throw new LocalError('invalid_configuration');
|
|
246
|
+
const query = new URLSearchParams();
|
|
247
|
+
for (const [key, value] of Object.entries(filters)) query.set(key, String(value));
|
|
248
|
+
return this.request('GET', `/v1/clients${query.size ? `?${query}` : ''}`, { idempotent: true, signal });
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async updateMetadata(patch, { expectedRevision = this.attachment.metadata_revision, signal } = {}) {
|
|
252
|
+
const body = validateMetadataPatch({ metadata_patch: patch, expected_metadata_revision: expectedRevision });
|
|
253
|
+
// CAS is not blindly retried after response loss: read owned state to reconcile first.
|
|
254
|
+
const result = await this.request('PATCH', `/v1/sessions/${this.identity.client_id}/metadata`, { body, signal });
|
|
255
|
+
if (!Number.isSafeInteger(result?.metadata_revision)) throw new LocalError('invalid_response', { effect: 'unknown' });
|
|
256
|
+
this.inbox.setState('attachment', { ...this.attachment, metadata_revision: result.metadata_revision });
|
|
257
|
+
this.inbox.setState('metadata', { ...this.inbox.getState('metadata'), ...patch });
|
|
258
|
+
return result;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async refreshOwnedMetadata({ signal } = {}) {
|
|
262
|
+
const state = await this.request('GET', `/v1/sessions/${this.identity.client_id}`, { session: false, idempotent: true, signal });
|
|
263
|
+
if (state.client_id !== this.identity.client_id || state.session_instance_id !== this.identity.session_instance_id
|
|
264
|
+
|| state.connection_id !== this.attachment.connection_id || state.lease_epoch !== this.attachment.lease_epoch
|
|
265
|
+
|| !Number.isSafeInteger(state.metadata_revision)) throw new LocalError('session_mismatch');
|
|
266
|
+
const metadata = validateMetadata(state.metadata);
|
|
267
|
+
this.inbox.setState('attachment', { ...this.attachment, metadata_revision: state.metadata_revision });
|
|
268
|
+
this.inbox.setState('metadata', metadata);
|
|
269
|
+
return metadata;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
prepareSend(request, options = {}) {
|
|
273
|
+
return this.inbox.prepareSend(request, { retryWindowSeconds: positiveSeconds(this.policy.send_retry_seconds, 86400), ...options });
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async sendPrepared(key, { signal } = {}) {
|
|
277
|
+
const operation = this.inbox.outgoing(key);
|
|
278
|
+
if (!operation) throw new LocalError('invalid_configuration');
|
|
279
|
+
if (operation.result) return { ...operation.result, send_key: key };
|
|
280
|
+
const result = await this.request('POST', '/v1/messages', { body: operation.request, key, idempotent: true,
|
|
281
|
+
deadline: operation.retry_until, signal });
|
|
282
|
+
try { validateUuid(result?.message_id); } catch { throw new LocalError('invalid_response', { effect: 'unknown' }); }
|
|
283
|
+
this.inbox.recordSendResult(key, result);
|
|
284
|
+
if (operation.request.metadata_patch) {
|
|
285
|
+
this.inbox.setState('metadata', { ...this.inbox.getState('metadata'), ...operation.request.metadata_patch });
|
|
286
|
+
// Sender revision is not guessed; reconcile from owned state before a future CAS mutation.
|
|
287
|
+
}
|
|
288
|
+
return { ...result, send_key: key };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async messageStatus(messageId, { signal } = {}) {
|
|
292
|
+
return this.request('GET', `/v1/messages/${validateUuid(messageId)}`, { idempotent: true, signal });
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async claim(waitSeconds = 30, { signal } = {}) {
|
|
296
|
+
let pending = this.inbox.getState('claim_pending');
|
|
297
|
+
if (!pending) {
|
|
298
|
+
pending = { key: randomUUID(), body: validateClaim({ wait_seconds: waitSeconds }), lease_epoch: this.attachment.lease_epoch };
|
|
299
|
+
this.inbox.setState('claim_pending', pending);
|
|
300
|
+
}
|
|
301
|
+
if (pending.lease_epoch !== this.attachment.lease_epoch) throw new LocalError('session_mismatch');
|
|
302
|
+
const result = await this.request('POST', '/v1/mailbox/claim', { body: pending.body, key: pending.key, idempotent: true, signal });
|
|
303
|
+
if (result === null) this.inbox.setState('claim_pending', null);
|
|
304
|
+
return result;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async complete(messageId, completion, { signal } = {}) {
|
|
308
|
+
const result = await this.request('POST', `/v1/mailbox/${validateUuid(messageId)}/complete`, {
|
|
309
|
+
body: validateCompletion(completion), idempotent: true, signal });
|
|
310
|
+
this.inbox.setState('claim_pending', null);
|
|
311
|
+
return result;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async renew(messageId, claimId, { signal } = {}) {
|
|
315
|
+
return this.request('POST', `/v1/mailbox/${validateUuid(messageId)}/renew`, {
|
|
316
|
+
body: validateRenew({ claim_id: claimId }), idempotent: true, signal });
|
|
317
|
+
}
|
|
318
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** Local errors deliberately never include content, paths, credentials or native responses. */
|
|
2
|
+
const MESSAGES = {
|
|
3
|
+
invalid_configuration: 'Correct the local adapter configuration before retrying.',
|
|
4
|
+
session_mismatch: 'Verify the exact surviving harness session; do not adopt its mailbox.',
|
|
5
|
+
owner_conflict: 'Another worker owns this session inbox. Stop it before starting another.',
|
|
6
|
+
storage_unavailable: 'Restore local durable storage before retrying.',
|
|
7
|
+
inbox_full: 'The local inbox or deduplication budget is full; preserve pending input.',
|
|
8
|
+
envelope_mismatch: 'A message ID changed its immutable envelope. Pause and inspect the protocol.',
|
|
9
|
+
envelope_expired: 'The transport deadline passed; do not accept a new inbox item.',
|
|
10
|
+
invalid_envelope: 'The broker envelope is invalid; no inbox item was accepted.',
|
|
11
|
+
invalid_response: 'The broker response is invalid. Reconcile the original operation.',
|
|
12
|
+
request_failed: 'The network result is unknown. Retry only the original idempotent operation.',
|
|
13
|
+
request_aborted: 'The request was canceled. Reconcile any potentially applied operation.',
|
|
14
|
+
retry_window_elapsed: 'The automatic retry window ended. Reconcile; do not allocate a replacement key.',
|
|
15
|
+
send_conflict: 'The local send key already names a different request.',
|
|
16
|
+
injection_uncertain: 'Native acceptance is uncertain. The local FIFO head is paused for operator review.',
|
|
17
|
+
unsafe_boundary: 'Keep the input pending until the harness provides a verified safe boundary.',
|
|
18
|
+
receipt_required: 'A supported native acceptance receipt is required before consumption is recorded.',
|
|
19
|
+
unknown_tool: 'Use a documented broker tool name.',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export class LocalError extends Error {
|
|
23
|
+
constructor(code, { effect = 'not_applied', retryable = false, field = null } = {}) {
|
|
24
|
+
super(MESSAGES[code] ?? MESSAGES.invalid_configuration);
|
|
25
|
+
this.name = 'LocalError';
|
|
26
|
+
this.code = code;
|
|
27
|
+
this.effect = effect;
|
|
28
|
+
this.retryable = retryable;
|
|
29
|
+
this.field = field;
|
|
30
|
+
}
|
|
31
|
+
toJSON() {
|
|
32
|
+
return { error: { code: this.code, message: this.message, action: this.message,
|
|
33
|
+
retryable: this.retryable, effect: this.effect, field: this.field,
|
|
34
|
+
contract_ref: 'docs/reference.md#local-errors' } };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function safeError(error) {
|
|
39
|
+
if (error instanceof LocalError) return error.toJSON();
|
|
40
|
+
if (error?.toJSON && error?.name === 'BrokerError') return error.toJSON();
|
|
41
|
+
return new LocalError('storage_unavailable', { effect: 'unknown' }).toJSON();
|
|
42
|
+
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
import { mkdirSync, lstatSync, statfsSync, readdirSync, chmodSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { resolve, join } from 'node:path';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { canonical, digest, validateEnvelope, validateUuid, validateSend, validateIdempotencyKey, TYPES } from '../src/protocol.mjs';
|
|
6
|
+
import { LocalError } from './errors.mjs';
|
|
7
|
+
|
|
8
|
+
export function newSessionIdentity(clientType) {
|
|
9
|
+
if (!TYPES.includes(clientType)) throw new LocalError('invalid_configuration');
|
|
10
|
+
return { client_id: randomUUID(), session_instance_id: randomUUID(), client_type: clientType };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function identityValue(identity) {
|
|
14
|
+
if (!identity || Object.keys(identity).sort().join(',') !== 'client_id,client_type,session_instance_id'
|
|
15
|
+
|| !TYPES.includes(identity.client_type)) throw new LocalError('invalid_configuration');
|
|
16
|
+
return { client_id: validateUuid(identity.client_id), session_instance_id: validateUuid(identity.session_instance_id), client_type: identity.client_type };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** One session-bound durable journal. A separate SQLite OS lock survives no process crash. */
|
|
20
|
+
export class Inbox {
|
|
21
|
+
constructor({ directory, identity, now = Date.now, maxPendingCount = 1000,
|
|
22
|
+
maxPendingBytes = 64 * 1024 * 1024, maxLedgerCount = 50000,
|
|
23
|
+
maxOutgoingCount = 10000, maxDatabaseBytes = 128 * 1024 * 1024,
|
|
24
|
+
minFreeBytes = 16 * 1024 * 1024, retentionSeconds = 8 * 86400,
|
|
25
|
+
diskCheck, failurePoint = () => {} }) {
|
|
26
|
+
this.identity = identityValue(identity);
|
|
27
|
+
this.now = now;
|
|
28
|
+
this.failurePoint = failurePoint;
|
|
29
|
+
this.retentionSeconds = retentionSeconds;
|
|
30
|
+
this.limits = { maxPendingCount, maxPendingBytes, maxLedgerCount, maxOutgoingCount, maxDatabaseBytes, minFreeBytes };
|
|
31
|
+
for (const [key, value] of Object.entries({ ...this.limits, retentionSeconds })) {
|
|
32
|
+
if (!Number.isSafeInteger(value) || value < (key === 'minFreeBytes' ? 0 : 1)) throw new LocalError('invalid_configuration');
|
|
33
|
+
}
|
|
34
|
+
if (retentionSeconds < 7 * 86400) throw new LocalError('invalid_configuration');
|
|
35
|
+
this.directory = resolve(directory);
|
|
36
|
+
this.diskCheck = diskCheck;
|
|
37
|
+
this.closed = false;
|
|
38
|
+
this.storageUncertain = false;
|
|
39
|
+
try {
|
|
40
|
+
mkdirSync(this.directory, { recursive: true, mode: 0o700 });
|
|
41
|
+
const dir = lstatSync(this.directory);
|
|
42
|
+
if (!dir.isDirectory() || dir.isSymbolicLink() || realpathSync(this.directory) !== this.directory || (dir.mode & 0o077) !== 0
|
|
43
|
+
|| (process.getuid && dir.uid !== process.getuid())) throw new LocalError('invalid_configuration');
|
|
44
|
+
for (const name of ['inbox.sqlite', 'owner.sqlite']) {
|
|
45
|
+
const path = join(this.directory, name);
|
|
46
|
+
try {
|
|
47
|
+
const file = lstatSync(path);
|
|
48
|
+
if (!file.isFile() || file.isSymbolicLink() || (file.mode & 0o077) !== 0) throw new LocalError('invalid_configuration');
|
|
49
|
+
} catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
50
|
+
}
|
|
51
|
+
this.checkDisk();
|
|
52
|
+
this.owner = new DatabaseSync(join(this.directory, 'owner.sqlite'));
|
|
53
|
+
chmodSync(join(this.directory, 'owner.sqlite'), 0o600);
|
|
54
|
+
try { this.owner.exec('PRAGMA busy_timeout=0; PRAGMA journal_mode=DELETE; BEGIN EXCLUSIVE; CREATE TABLE IF NOT EXISTS owner(singleton INTEGER PRIMARY KEY CHECK(singleton=1));'); }
|
|
55
|
+
catch { throw new LocalError('owner_conflict'); }
|
|
56
|
+
this.db = new DatabaseSync(join(this.directory, 'inbox.sqlite'));
|
|
57
|
+
chmodSync(join(this.directory, 'inbox.sqlite'), 0o600);
|
|
58
|
+
if (this.db.prepare('PRAGMA user_version').get().user_version > 1) throw new LocalError('invalid_configuration');
|
|
59
|
+
this.db.exec(`PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; PRAGMA foreign_keys=ON;
|
|
60
|
+
PRAGMA busy_timeout=0; PRAGMA wal_autocheckpoint=100;
|
|
61
|
+
CREATE TABLE IF NOT EXISTS state(key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
|
62
|
+
CREATE TABLE IF NOT EXISTS inbox(
|
|
63
|
+
message_id TEXT PRIMARY KEY, sequence INTEGER NOT NULL UNIQUE, digest TEXT NOT NULL,
|
|
64
|
+
envelope TEXT NOT NULL, bytes INTEGER NOT NULL, state TEXT NOT NULL,
|
|
65
|
+
received_at INTEGER NOT NULL, retain_until INTEGER NOT NULL,
|
|
66
|
+
injection_id TEXT, native_receipt TEXT, consumed_at INTEGER);
|
|
67
|
+
CREATE TABLE IF NOT EXISTS outgoing(
|
|
68
|
+
key TEXT PRIMARY KEY, digest TEXT NOT NULL, request TEXT NOT NULL,
|
|
69
|
+
created_at INTEGER NOT NULL, retry_until INTEGER NOT NULL, result TEXT);
|
|
70
|
+
PRAGMA user_version=1;`);
|
|
71
|
+
const stored = this.getState('identity');
|
|
72
|
+
if (stored && canonical(stored) !== canonical(this.identity)) throw new LocalError('session_mismatch');
|
|
73
|
+
if (!stored) this.setState('identity', this.identity);
|
|
74
|
+
this.db.prepare("UPDATE inbox SET state='uncertain' WHERE state='injecting'").run();
|
|
75
|
+
} catch (error) {
|
|
76
|
+
this.db?.close();
|
|
77
|
+
this.owner?.close();
|
|
78
|
+
if (error instanceof LocalError) throw error;
|
|
79
|
+
throw new LocalError('storage_unavailable');
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
checkDisk(extra = 0) {
|
|
84
|
+
this.assertUsable();
|
|
85
|
+
if (this.closed) throw new LocalError('storage_unavailable');
|
|
86
|
+
try {
|
|
87
|
+
if (this.diskCheck && !this.diskCheck(extra)) throw new Error('disk');
|
|
88
|
+
const disk = statfsSync(this.directory);
|
|
89
|
+
if ([0x6969, 0x517b, 0xff534d42].includes(Number(disk.type))) throw new Error('unsupported filesystem');
|
|
90
|
+
const bytes = readdirSync(this.directory).reduce((total, name) => {
|
|
91
|
+
const stat = lstatSync(join(this.directory, name));
|
|
92
|
+
return total + (stat.isFile() ? stat.size : 0);
|
|
93
|
+
}, 0);
|
|
94
|
+
if (Number(disk.bavail) * Number(disk.bsize) < this.limits.minFreeBytes + extra
|
|
95
|
+
|| bytes + extra > this.limits.maxDatabaseBytes) throw new Error('disk');
|
|
96
|
+
} catch { throw new LocalError('storage_unavailable', { retryable: true }); }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
transaction(callback, { growth = 0 } = {}) {
|
|
100
|
+
this.checkDisk(growth);
|
|
101
|
+
let commitAttempted = false;
|
|
102
|
+
let committed = false;
|
|
103
|
+
try {
|
|
104
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
105
|
+
const result = callback();
|
|
106
|
+
this.failurePoint('before_commit');
|
|
107
|
+
commitAttempted = true;
|
|
108
|
+
this.db.exec('COMMIT');
|
|
109
|
+
committed = true;
|
|
110
|
+
this.failurePoint('after_commit');
|
|
111
|
+
return result;
|
|
112
|
+
} catch (error) {
|
|
113
|
+
let rollbackConfirmed = false;
|
|
114
|
+
try { this.failurePoint('before_rollback'); this.db.exec('ROLLBACK'); rollbackConfirmed = true; } catch { /* The effect may be unknown. */ }
|
|
115
|
+
const effect = committed || (commitAttempted && !rollbackConfirmed) || !rollbackConfirmed ? 'unknown' : 'not_applied';
|
|
116
|
+
// A failed rollback can leave uncommitted rows visible on this connection.
|
|
117
|
+
// Do not allow a later duplicate lookup to ACK those rows. Reopen first.
|
|
118
|
+
if (!committed && !rollbackConfirmed) this.storageUncertain = true;
|
|
119
|
+
if (error instanceof LocalError && effect === 'not_applied') throw error;
|
|
120
|
+
throw new LocalError('storage_unavailable', { retryable: true, effect });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
assertUsable() {
|
|
125
|
+
if (this.closed || this.storageUncertain) throw new LocalError('storage_unavailable', { effect: this.storageUncertain ? 'unknown' : 'not_applied', retryable: true });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
getState(key) {
|
|
129
|
+
this.assertUsable();
|
|
130
|
+
const row = this.db.prepare('SELECT value FROM state WHERE key=?').get(key);
|
|
131
|
+
return row ? JSON.parse(row.value) : null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
setState(key, value) {
|
|
135
|
+
if (!['identity', 'attachment', 'registration_pending', 'claim_pending', 'metadata'].includes(key)) throw new LocalError('invalid_configuration');
|
|
136
|
+
const encoded = canonical(value);
|
|
137
|
+
if (Buffer.byteLength(encoded) > 256 * 1024) throw new LocalError('invalid_configuration');
|
|
138
|
+
return this.transaction(() => this.db.prepare('INSERT INTO state(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value').run(key, encoded), { growth: Buffer.byteLength(encoded) + 4096 });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
accept(rawEnvelope, { serverNow = this.now(), retentionSeconds = this.retentionSeconds, retryWindowSeconds = 86400 } = {}) {
|
|
142
|
+
this.assertUsable();
|
|
143
|
+
let envelope;
|
|
144
|
+
try { envelope = validateEnvelope(rawEnvelope); }
|
|
145
|
+
catch { throw new LocalError('invalid_envelope'); }
|
|
146
|
+
if (envelope.recipient_id !== this.identity.client_id) throw new LocalError('session_mismatch');
|
|
147
|
+
const encoded = canonical(envelope);
|
|
148
|
+
const hash = digest(envelope);
|
|
149
|
+
const bytes = Buffer.byteLength(encoded);
|
|
150
|
+
if (!Number.isSafeInteger(retentionSeconds) || retentionSeconds < 7 * 86400
|
|
151
|
+
|| !Number.isSafeInteger(retryWindowSeconds) || retryWindowSeconds < 1) throw new LocalError('invalid_configuration');
|
|
152
|
+
const existing = this.db.prepare('SELECT digest FROM inbox WHERE message_id=?').get(envelope.message_id);
|
|
153
|
+
if (existing) {
|
|
154
|
+
if (existing.digest !== hash) throw new LocalError('envelope_mismatch');
|
|
155
|
+
// A late redelivery restarts the local retention guarantee. If extending it
|
|
156
|
+
// fails, withhold ACK: saying not_stored would contradict this known row.
|
|
157
|
+
try {
|
|
158
|
+
this.transaction(() => this.db.prepare('UPDATE inbox SET retain_until=MAX(retain_until,?) WHERE message_id=?')
|
|
159
|
+
.run(this.now() + Math.max(retentionSeconds, this.retentionSeconds) * 1000, envelope.message_id));
|
|
160
|
+
} catch { throw new LocalError('storage_unavailable', { effect: 'unknown', retryable: true }); }
|
|
161
|
+
return { stored: true, duplicate: true, message_id: envelope.message_id };
|
|
162
|
+
}
|
|
163
|
+
if (Date.parse(envelope.expires_at) <= serverNow) throw new LocalError('envelope_expired');
|
|
164
|
+
return this.transaction(() => {
|
|
165
|
+
const usage = this.stats();
|
|
166
|
+
if (usage.pending_count >= this.limits.maxPendingCount || usage.pending_bytes + bytes > this.limits.maxPendingBytes
|
|
167
|
+
|| usage.ledger_count >= this.limits.maxLedgerCount) throw new LocalError('inbox_full', { retryable: true });
|
|
168
|
+
const highest = this.db.prepare('SELECT MAX(sequence) AS sequence FROM inbox').get().sequence ?? 0;
|
|
169
|
+
const lastSequence = this.getState('attachment')?.highest_received_sequence ?? 0;
|
|
170
|
+
if (envelope.recipient_sequence <= Math.max(highest, lastSequence)) throw new LocalError('envelope_mismatch');
|
|
171
|
+
const retainUntil = Math.max(this.now() + Math.max(retentionSeconds, this.retentionSeconds) * 1000,
|
|
172
|
+
this.now() + Math.max(0, Date.parse(envelope.expires_at) - serverNow) + retryWindowSeconds * 1000);
|
|
173
|
+
this.db.prepare(`INSERT INTO inbox(message_id,sequence,digest,envelope,bytes,state,received_at,retain_until)
|
|
174
|
+
VALUES(?,?,?,?,?,'pending',?,?)`).run(envelope.message_id, envelope.recipient_sequence, hash, encoded, bytes, this.now(), retainUntil);
|
|
175
|
+
const attachment = { ...(this.getState('attachment') ?? {}), highest_received_sequence: envelope.recipient_sequence };
|
|
176
|
+
this.db.prepare('INSERT INTO state(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value').run('attachment', canonical(attachment));
|
|
177
|
+
this.failurePoint('after_inbox_insert');
|
|
178
|
+
return { stored: true, duplicate: false, message_id: envelope.message_id };
|
|
179
|
+
}, { growth: bytes * 3 + 16384 });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
stats() {
|
|
183
|
+
this.assertUsable();
|
|
184
|
+
const row = this.db.prepare("SELECT COUNT(*) AS ledger_count, COALESCE(SUM(CASE WHEN state!='consumed' THEN 1 ELSE 0 END),0) AS pending_count, COALESCE(SUM(CASE WHEN state!='consumed' THEN bytes ELSE 0 END),0) AS pending_bytes FROM inbox").get();
|
|
185
|
+
return { ...row };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
head() {
|
|
189
|
+
this.assertUsable();
|
|
190
|
+
const row = this.db.prepare("SELECT * FROM inbox WHERE state!='consumed' ORDER BY sequence LIMIT 1").get();
|
|
191
|
+
return row ? { message_id: row.message_id, sequence: row.sequence, state: row.state,
|
|
192
|
+
envelope: JSON.parse(row.envelope), injection_id: row.injection_id } : null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
beginInjection(messageId) {
|
|
196
|
+
return this.transaction(() => {
|
|
197
|
+
const head = this.head();
|
|
198
|
+
if (!head || head.message_id !== messageId) throw new LocalError('unsafe_boundary');
|
|
199
|
+
if (head.state !== 'pending') throw new LocalError('injection_uncertain');
|
|
200
|
+
const injectionId = randomUUID();
|
|
201
|
+
this.db.prepare("UPDATE inbox SET state='injecting', injection_id=? WHERE message_id=?").run(injectionId, messageId);
|
|
202
|
+
return injectionId;
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
recordNativeAcceptance(messageId, injectionId, receipt) {
|
|
207
|
+
if (!receipt || receipt.accepted !== true || typeof receipt.receipt_id !== 'string'
|
|
208
|
+
|| receipt.receipt_id.length < 1 || receipt.receipt_id.length > 256
|
|
209
|
+
|| /[\x00-\x1f\x7f]/.test(receipt.receipt_id)) throw new LocalError('receipt_required', { effect: 'unknown' });
|
|
210
|
+
return this.transaction(() => {
|
|
211
|
+
const row = this.db.prepare('SELECT state,injection_id FROM inbox WHERE message_id=?').get(messageId);
|
|
212
|
+
if (!row || !['injecting', 'uncertain'].includes(row.state) || row.injection_id !== injectionId) throw new LocalError('injection_uncertain');
|
|
213
|
+
this.db.prepare("UPDATE inbox SET state='consumed', native_receipt=?, consumed_at=? WHERE message_id=?").run(receipt.receipt_id, this.now(), messageId);
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
recordNativeRejection(messageId, injectionId, { definitelyNotAccepted } = {}) {
|
|
218
|
+
if (definitelyNotAccepted !== true) throw new LocalError('injection_uncertain', { effect: 'unknown' });
|
|
219
|
+
return this.transaction(() => {
|
|
220
|
+
const row = this.db.prepare('SELECT state,injection_id FROM inbox WHERE message_id=?').get(messageId);
|
|
221
|
+
if (!row || row.state !== 'injecting' || row.injection_id !== injectionId) throw new LocalError('injection_uncertain');
|
|
222
|
+
this.db.prepare("UPDATE inbox SET state='pending', injection_id=NULL WHERE message_id=?").run(messageId);
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Operator-only local API. Never expose this through an agent tool. */
|
|
227
|
+
resolveUncertain(messageId, { resolution, duplicateRiskAcknowledged = false } = {}) {
|
|
228
|
+
if (!['accepted', 'retry'].includes(resolution) || (resolution === 'retry' && duplicateRiskAcknowledged !== true)) throw new LocalError('injection_uncertain');
|
|
229
|
+
return this.transaction(() => {
|
|
230
|
+
const row = this.db.prepare('SELECT state FROM inbox WHERE message_id=?').get(messageId);
|
|
231
|
+
if (!row || !['injecting', 'uncertain'].includes(row.state)) throw new LocalError('injection_uncertain');
|
|
232
|
+
if (resolution === 'accepted') this.db.prepare("UPDATE inbox SET state='consumed',native_receipt='operator-confirmed',consumed_at=? WHERE message_id=?").run(this.now(), messageId);
|
|
233
|
+
else this.db.prepare("UPDATE inbox SET state='pending',injection_id=NULL WHERE message_id=?").run(messageId);
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
prepareSend(rawRequest, { key = randomUUID(), retryWindowSeconds = 86400 } = {}) {
|
|
238
|
+
key = validateIdempotencyKey(key);
|
|
239
|
+
const request = validateSend(rawRequest);
|
|
240
|
+
const encoded = canonical(request);
|
|
241
|
+
const hash = digest(request);
|
|
242
|
+
const existing = this.outgoing(key);
|
|
243
|
+
if (existing) {
|
|
244
|
+
if (existing.digest !== hash) throw new LocalError('send_conflict');
|
|
245
|
+
return key;
|
|
246
|
+
}
|
|
247
|
+
if (!Number.isSafeInteger(retryWindowSeconds) || retryWindowSeconds < 1) throw new LocalError('invalid_configuration');
|
|
248
|
+
this.transaction(() => {
|
|
249
|
+
if (this.db.prepare('SELECT COUNT(*) AS n FROM outgoing').get().n >= this.limits.maxOutgoingCount) throw new LocalError('inbox_full');
|
|
250
|
+
this.db.prepare('INSERT INTO outgoing(key,digest,request,created_at,retry_until) VALUES(?,?,?,?,?)').run(key, hash, encoded, this.now(), this.now() + retryWindowSeconds * 1000);
|
|
251
|
+
}, { growth: Buffer.byteLength(encoded) * 3 + 8192 });
|
|
252
|
+
return key;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
outgoing(key) {
|
|
256
|
+
this.assertUsable();
|
|
257
|
+
const row = this.db.prepare('SELECT * FROM outgoing WHERE key=?').get(key);
|
|
258
|
+
return row ? { ...row, request: JSON.parse(row.request), result: row.result ? JSON.parse(row.result) : null } : null;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
unresolvedSends() {
|
|
262
|
+
this.assertUsable();
|
|
263
|
+
return this.db.prepare('SELECT key,created_at,retry_until FROM outgoing WHERE result IS NULL ORDER BY created_at LIMIT 100').all().map(row => ({ ...row }));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
recordSendResult(key, result) {
|
|
267
|
+
const encoded = canonical(result);
|
|
268
|
+
if (Buffer.byteLength(encoded) > 16384) throw new LocalError('invalid_response', { effect: 'unknown' });
|
|
269
|
+
this.transaction(() => {
|
|
270
|
+
if (!this.outgoing(key)) throw new LocalError('invalid_configuration');
|
|
271
|
+
this.db.prepare('UPDATE outgoing SET result=? WHERE key=?').run(encoded, key);
|
|
272
|
+
}, { growth: Buffer.byteLength(encoded) * 2 + 8192 });
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
collect(limit = 100) {
|
|
276
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000) throw new LocalError('invalid_configuration');
|
|
277
|
+
return this.transaction(() => {
|
|
278
|
+
const removedInbox = this.db.prepare("DELETE FROM inbox WHERE message_id IN (SELECT message_id FROM inbox WHERE state='consumed' AND retain_until<? ORDER BY retain_until LIMIT ?)").run(this.now(), limit).changes;
|
|
279
|
+
const removedSends = this.db.prepare('DELETE FROM outgoing WHERE key IN (SELECT key FROM outgoing WHERE result IS NOT NULL AND retry_until<? ORDER BY retry_until LIMIT ?)').run(this.now(), limit).changes;
|
|
280
|
+
return { removed_inbox: removedInbox, removed_sends: removedSends };
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
close() {
|
|
285
|
+
if (this.closed) return;
|
|
286
|
+
this.closed = true;
|
|
287
|
+
try { this.db.close(); } finally { this.owner.close(); }
|
|
288
|
+
}
|
|
289
|
+
}
|