@matuto-ai/opencode-psst-connector 0.0.0-stage → 0.7.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/CHANGELOG.md +43 -0
- package/LICENSE +21 -0
- package/README.md +182 -2
- package/docs/active-conversation.md +68 -0
- package/docs/message-lifecycle.md +31 -0
- package/docs/migration-0.6.0.md +21 -0
- package/docs/native-automation.md +37 -0
- package/docs/presence-notices.md +21 -0
- package/docs/publishing.md +19 -0
- package/docs/reference-snapshot.sha256 +7 -0
- package/docs/setup-acceptance.md +96 -0
- package/docs/setup-context-recovery.md +55 -0
- package/docs/verification.md +39 -0
- package/package.json +42 -4
- package/plugin/index.mjs +18 -0
- package/plugin/tui.mjs +188 -0
- package/src/active-presence.mjs +97 -0
- package/src/approval-helper.mjs +11 -0
- package/src/config.mjs +50 -0
- package/src/enrollment.mjs +59 -0
- package/src/identity-recovery.mjs +94 -0
- package/src/installation.mjs +127 -0
- package/src/message-format.mjs +26 -0
- package/src/message-preview.mjs +29 -0
- package/src/native-delivery.mjs +169 -0
- package/src/native-inbox.mjs +37 -0
- package/src/native-incarnation.mjs +67 -0
- package/src/native-lock.mjs +17 -0
- package/src/native-metadata.mjs +15 -0
- package/src/native-plugin.mjs +336 -0
- package/src/native-setup.mjs +249 -0
- package/src/opencode-adapter.mjs +223 -0
- package/src/presence-notices.mjs +90 -0
- package/src/project-metadata.mjs +21 -0
- package/src/reference-pin.mjs +21 -0
- package/src/reference.mjs +20 -0
- package/src/setup-rpc.mjs +16 -0
- package/src/tool-schema.mjs +5 -0
- package/src/tool-scope.mjs +26 -0
- package/vendor/broker/LICENSE +21 -0
- package/vendor/broker/ORIGIN.md +12 -0
- package/vendor/broker/reference/client.mjs +439 -0
- package/vendor/broker/reference/errors.mjs +43 -0
- package/vendor/broker/reference/inbox.mjs +334 -0
- package/vendor/broker/reference/mcp-stdio.mjs +160 -0
- package/vendor/broker/reference/runtime.mjs +329 -0
- package/vendor/broker/reference/tools.mjs +88 -0
- package/vendor/broker/src/protocol.mjs +325 -0
- package/vendor/enrollment/LICENSE +21 -0
- package/vendor/enrollment/ORIGIN.md +20 -0
- package/vendor/enrollment/approval.mjs +181 -0
- package/vendor/enrollment/enrollment.mjs +137 -0
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
import { request as httpRequest } from 'node:http';
|
|
2
|
+
import { request as httpsRequest } from 'node:https';
|
|
3
|
+
import { mkdirSync, 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
|
+
import { Inbox, newSessionIdentity } from './inbox.mjs';
|
|
10
|
+
|
|
11
|
+
const ALLOWED_ERROR_CODES = new Set(['invalid_request', 'unauthenticated', 'forbidden', 'recipient_not_found',
|
|
12
|
+
'message_not_found', 'history_changed', 'content_unavailable', 'retry_window_elapsed', 'identity_conflict', 'stale_lease_epoch', 'idempotency_conflict', 'metadata_conflict',
|
|
13
|
+
'claim_conflict', 'registration_upgrade_required', 'session_closed', 'message_expired', 'payload_too_large',
|
|
14
|
+
'incompatible_receiver_transport', 'receiving_disabled', 'rate_limited', 'mailbox_full',
|
|
15
|
+
'storage_unavailable', 'not_ready']);
|
|
16
|
+
|
|
17
|
+
export class BrokerError extends LocalError {
|
|
18
|
+
constructor(status, value, retryAfter = 0) {
|
|
19
|
+
super('invalid_response', { effect: 'unknown' });
|
|
20
|
+
this.name = 'BrokerError';
|
|
21
|
+
this.status = status;
|
|
22
|
+
this.retryAfter = retryAfter;
|
|
23
|
+
const raw = value?.error;
|
|
24
|
+
const code = ALLOWED_ERROR_CODES.has(raw?.code) ? raw.code : 'invalid_request';
|
|
25
|
+
// Never reproduce a remote free-text message or details in local tools/logs.
|
|
26
|
+
this.api = new ApiError(status, code, null, {
|
|
27
|
+
effect: ['not_applied', 'unknown'].includes(raw?.effect) ? raw.effect : 'unknown',
|
|
28
|
+
retryable: raw?.retryable === true && [429, 503].includes(status),
|
|
29
|
+
});
|
|
30
|
+
this.code = this.api.code;
|
|
31
|
+
this.message = this.api.message;
|
|
32
|
+
this.effect = this.api.effect;
|
|
33
|
+
this.retryable = this.api.retryable;
|
|
34
|
+
}
|
|
35
|
+
toJSON() { return this.api.toJSON(null); }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function readSecret(path) {
|
|
39
|
+
let fd;
|
|
40
|
+
try {
|
|
41
|
+
fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
42
|
+
const stat = fstatSync(fd);
|
|
43
|
+
if (!stat.isFile() || (stat.mode & 0o077) !== 0 || stat.size > 8192
|
|
44
|
+
|| (process.getuid && stat.uid !== process.getuid())) throw new Error('invalid');
|
|
45
|
+
const token = readFileSync(fd, 'utf8').trim();
|
|
46
|
+
if (!/^[A-Za-z0-9_-]{43,256}$/.test(token)) throw new Error('invalid');
|
|
47
|
+
return token;
|
|
48
|
+
} catch { throw new LocalError('invalid_configuration', { field: 'tokenFile' }); }
|
|
49
|
+
finally { if (fd !== undefined) closeSync(fd); }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function positiveSeconds(value, fallback) {
|
|
53
|
+
return Number.isSafeInteger(value) && value > 0 && value <= 365 * 86400 ? value : fallback;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** HTTPS-only by default. No redirects; no credential-bearing URLs or command-line arguments. */
|
|
57
|
+
export class BrokerClient {
|
|
58
|
+
constructor({ url, tokenFile, tokenEnvironment, caFile, insecureDevelopment = false, inbox,
|
|
59
|
+
now = Date.now, random = Math.random, sleep = delay, maxAttempts = 4,
|
|
60
|
+
maxResponseBytes = 256 * 1024, requestTimeoutMs = 40000, registrationVersion = 1 }) {
|
|
61
|
+
try { this.base = new URL(url); } catch { throw new LocalError('invalid_configuration', { field: 'url' }); }
|
|
62
|
+
const host = this.base.hostname.replace(/^\[|\]$/g, '');
|
|
63
|
+
if (this.base.username || this.base.password || this.base.search || this.base.hash || this.base.pathname !== '/'
|
|
64
|
+
|| !['https:', 'http:'].includes(this.base.protocol)
|
|
65
|
+
|| (this.base.protocol === 'http:' && (!insecureDevelopment || !['127.0.0.1', '::1'].includes(host)))) {
|
|
66
|
+
throw new LocalError('invalid_configuration', { field: 'url' });
|
|
67
|
+
}
|
|
68
|
+
if (!inbox || (!tokenFile && !tokenEnvironment) || (tokenFile && tokenEnvironment)
|
|
69
|
+
|| !Number.isSafeInteger(maxAttempts) || maxAttempts < 1 || maxAttempts > 10
|
|
70
|
+
|| !Number.isSafeInteger(maxResponseBytes) || maxResponseBytes < 1024 || maxResponseBytes > 1048576
|
|
71
|
+
|| !Number.isSafeInteger(requestTimeoutMs) || requestTimeoutMs < 100 || requestTimeoutMs > 120000) throw new LocalError('invalid_configuration');
|
|
72
|
+
if (![1,2].includes(registrationVersion)) throw new LocalError('invalid_configuration');
|
|
73
|
+
this.registrationVersion = registrationVersion;
|
|
74
|
+
this.expiredIdentityConfirmed = false;
|
|
75
|
+
this.inbox = inbox;
|
|
76
|
+
this.now = now;
|
|
77
|
+
this.random = random;
|
|
78
|
+
this.sleep = sleep;
|
|
79
|
+
this.maxAttempts = maxAttempts;
|
|
80
|
+
this.maxResponseBytes = maxResponseBytes;
|
|
81
|
+
this.requestTimeoutMs = requestTimeoutMs;
|
|
82
|
+
this.tokenFile = tokenFile;
|
|
83
|
+
this.tokenEnvironment = tokenEnvironment;
|
|
84
|
+
try { this.ca = caFile ? readFileSync(caFile) : undefined; }
|
|
85
|
+
catch { throw new LocalError('invalid_configuration', { field: 'caFile' }); }
|
|
86
|
+
this.clockOffset = 0;
|
|
87
|
+
this.connectPromise = null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
get identity() { return this.inbox.identity; }
|
|
91
|
+
get attachment() { return this.inbox.getState('attachment') ?? {}; }
|
|
92
|
+
get policy() { return this.attachment.policy ?? {}; }
|
|
93
|
+
serverNow() { return this.now() + this.clockOffset; }
|
|
94
|
+
|
|
95
|
+
credential() {
|
|
96
|
+
if (this.tokenFile) return readSecret(this.tokenFile);
|
|
97
|
+
const token = process.env[this.tokenEnvironment];
|
|
98
|
+
if (typeof token !== 'string' || !/^[A-Za-z0-9_-]{43,256}$/.test(token)) throw new LocalError('invalid_configuration', { field: 'tokenEnvironment' });
|
|
99
|
+
return token;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async request(method, path, { body, key, session = true, signal, idempotent = false, deadline = Infinity, onError = () => {} } = {}) {
|
|
103
|
+
if (!/^\/v1\/[A-Za-z0-9_/?=&.%+~-]+$/.test(path)) throw new LocalError('invalid_configuration');
|
|
104
|
+
for (let attempt = 0; ; attempt++) {
|
|
105
|
+
if (this.now() >= deadline) throw new LocalError('retry_window_elapsed', { effect: 'unknown' });
|
|
106
|
+
try { return await this.requestOnce(method, path, { body, key, session, signal, timeoutMs: Math.min(this.requestTimeoutMs, deadline - this.now()) }); }
|
|
107
|
+
catch (error) {
|
|
108
|
+
onError(error);
|
|
109
|
+
if (signal?.aborted) throw new LocalError('request_aborted', { effect: 'unknown' });
|
|
110
|
+
const retryable = error instanceof BrokerError ? error.retryable
|
|
111
|
+
: error instanceof LocalError && error.code === 'request_failed';
|
|
112
|
+
if (!idempotent || !retryable || attempt + 1 >= this.maxAttempts) throw error;
|
|
113
|
+
const milliseconds = Math.max(error.retryAfter * 1000 || 0, Math.min(10000, 250 * 2 ** attempt) * (0.8 + this.random() * 0.4));
|
|
114
|
+
if (this.now() + milliseconds >= deadline) throw new LocalError('retry_window_elapsed', { effect: 'unknown' });
|
|
115
|
+
try { await this.sleep(milliseconds, undefined, { signal }); }
|
|
116
|
+
catch { throw new LocalError('request_aborted', { effect: 'unknown' }); }
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
requestOnce(method, path, { body, key, session, signal, timeoutMs }) {
|
|
122
|
+
const serialized = body === undefined ? null : canonical(body);
|
|
123
|
+
if (serialized && Buffer.byteLength(serialized) > 98304) throw new LocalError('invalid_configuration', { field: 'body' });
|
|
124
|
+
const headers = { Accept: 'application/json', Authorization: `Bearer ${this.credential()}` };
|
|
125
|
+
if (session) {
|
|
126
|
+
if (!Number.isSafeInteger(this.attachment.lease_epoch) || this.attachment.lease_epoch < 1) throw new LocalError('session_mismatch');
|
|
127
|
+
headers['X-Client-ID'] = this.identity.client_id;
|
|
128
|
+
headers['X-Lease-Epoch'] = String(this.attachment.lease_epoch);
|
|
129
|
+
}
|
|
130
|
+
if (key) headers['Idempotency-Key'] = key;
|
|
131
|
+
if (serialized !== null) { headers['Content-Type'] = 'application/json'; headers['Content-Length'] = Buffer.byteLength(serialized); }
|
|
132
|
+
const target = new URL(path, this.base);
|
|
133
|
+
return new Promise((resolve, reject) => {
|
|
134
|
+
let settled = false;
|
|
135
|
+
let absoluteTimer;
|
|
136
|
+
const finish = (error, value) => {
|
|
137
|
+
if (settled) return;
|
|
138
|
+
settled = true;
|
|
139
|
+
clearTimeout(absoluteTimer);
|
|
140
|
+
if (error) reject(error); else resolve(value);
|
|
141
|
+
};
|
|
142
|
+
const transport = target.protocol === 'https:' ? httpsRequest : httpRequest;
|
|
143
|
+
const request = transport(target, { method, headers, signal, ca: this.ca,
|
|
144
|
+
rejectUnauthorized: true, timeout: timeoutMs, maxHeaderSize: 16384 }, response => {
|
|
145
|
+
const chunks = [];
|
|
146
|
+
let bytes = 0;
|
|
147
|
+
response.on('data', chunk => {
|
|
148
|
+
bytes += chunk.length;
|
|
149
|
+
if (bytes > this.maxResponseBytes) { finish(new LocalError('invalid_response', { effect: 'unknown' })); response.destroy(); }
|
|
150
|
+
else chunks.push(chunk);
|
|
151
|
+
});
|
|
152
|
+
response.on('error', () => finish(new LocalError('request_failed', { effect: 'unknown', retryable: true })));
|
|
153
|
+
response.on('end', () => {
|
|
154
|
+
try {
|
|
155
|
+
const status = response.statusCode;
|
|
156
|
+
if (status === 204) { finish(null, null); return; }
|
|
157
|
+
if (!String(response.headers['content-type'] ?? '').toLowerCase().startsWith('application/json')) throw new LocalError('invalid_response', { effect: 'unknown' });
|
|
158
|
+
const value = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks)));
|
|
159
|
+
if (status < 200 || status >= 300) {
|
|
160
|
+
const retryAfter = /^\d+$/.test(String(response.headers['retry-after'] ?? '')) ? Math.min(3600, Number(response.headers['retry-after'])) : 0;
|
|
161
|
+
finish(new BrokerError(status, value, retryAfter)); return;
|
|
162
|
+
}
|
|
163
|
+
if(['true','false'].includes(response.headers['idempotency-replayed']))value.acceptance_replayed=response.headers['idempotency-replayed']==='true';
|
|
164
|
+
const timestamp = value?.server_time;
|
|
165
|
+
// Registration/send/completion retries may return their original
|
|
166
|
+
// stored result. Historical server_time is not a current clock sample.
|
|
167
|
+
if ((method === 'GET' || path.endsWith('/heartbeat') || method === 'PATCH')
|
|
168
|
+
&& typeof timestamp === 'string' && Number.isFinite(Date.parse(timestamp))) this.clockOffset = Date.parse(timestamp) - this.now();
|
|
169
|
+
finish(null, value);
|
|
170
|
+
} catch (error) { finish(error instanceof LocalError ? error : new LocalError('invalid_response', { effect: 'unknown' })); }
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
request.on('timeout', () => request.destroy());
|
|
174
|
+
request.on('error', () => finish(new LocalError(signal?.aborted ? 'request_aborted' : 'request_failed', { effect: 'unknown', retryable: !signal?.aborted })));
|
|
175
|
+
absoluteTimer = setTimeout(() => request.destroy(), timeoutMs);
|
|
176
|
+
request.end(serialized);
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
capabilities(receiveEnabled = true) {
|
|
181
|
+
return { protocol_version: '1', adapter_version: '0.1.0', client_type: this.identity.client_type,
|
|
182
|
+
receive_enabled: receiveEnabled, durable_inbox: true };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
applyRegistration(result, pending) {
|
|
186
|
+
if (!result || result.client_id !== this.identity.client_id || !Number.isSafeInteger(result.lease_epoch)
|
|
187
|
+
|| ((pending.body.registration_version ?? 1) === 2 ? result.lease_epoch <= pending.body.previous_epoch : result.lease_epoch !== pending.body.previous_epoch + 1) || !Number.isSafeInteger(result.metadata_revision)
|
|
188
|
+
|| result.metadata_revision < 1 || !result.policy) throw new LocalError('invalid_response', { effect: 'unknown' });
|
|
189
|
+
this.inbox.setState('attachment', { ...this.attachment, ...result, connection_id: pending.body.connection_id });
|
|
190
|
+
this.inbox.setState('metadata', pending.body.metadata);
|
|
191
|
+
this.inbox.setState('registration_pending', null);
|
|
192
|
+
this.inbox.setState('claim_pending', null);
|
|
193
|
+
return result;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async register(metadata, { receiveEnabled = true, signal } = {}) {
|
|
197
|
+
let pending = this.inbox.getState('registration_pending');
|
|
198
|
+
if (!pending) {
|
|
199
|
+
const body = validateRegistration({ protocol_version: '1', ...this.identity, connection_id: randomUUID(),
|
|
200
|
+
...(this.registrationVersion === 2 ? { registration_version: 2, requested_at: this.serverNow() } : {}),
|
|
201
|
+
previous_epoch: this.attachment.lease_epoch ?? 0, metadata: validateMetadata(metadata), capabilities: this.capabilities(receiveEnabled) });
|
|
202
|
+
pending = { body, retry_until: this.now() + positiveSeconds(this.policy.registration_retry_seconds, 86400) * 1000 };
|
|
203
|
+
this.inbox.setState('registration_pending', pending);
|
|
204
|
+
}
|
|
205
|
+
const result = await this.request('POST', '/v1/sessions', { body: pending.body, session: false,
|
|
206
|
+
idempotent: true, deadline: pending.retry_until, signal });
|
|
207
|
+
const state = await this.request('GET', `/v1/sessions/${this.identity.client_id}`, { session: false, idempotent: true, signal });
|
|
208
|
+
if (state?.client_id !== this.identity.client_id || state.session_instance_id !== this.identity.session_instance_id
|
|
209
|
+
|| state.client_type !== this.identity.client_type || state.connection_id !== pending.body.connection_id
|
|
210
|
+
|| state.lease_epoch !== result?.lease_epoch) throw new LocalError('session_mismatch', { effect: 'unknown' });
|
|
211
|
+
const confirmed = this.applyRegistration({ ...result, server_time: state.server_time, lease_expires_at: state.lease_expires_at }, pending);
|
|
212
|
+
if (state.closed === true) throw new BrokerError(410, { error: { code: 'session_closed', effect: 'unknown' } });
|
|
213
|
+
if (state.connected !== true || Date.parse(state.lease_expires_at) <= this.serverNow()) {
|
|
214
|
+
throw new BrokerError(409, { error: { code: 'stale_lease_epoch', effect: 'unknown' } });
|
|
215
|
+
}
|
|
216
|
+
return confirmed;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async reconnect({ verifySession, signal, metadata, receiveEnabled } = {}) {
|
|
220
|
+
if (this.connectPromise) return this.connectPromise;
|
|
221
|
+
this.connectPromise = this.reconnectOnce({ verifySession, signal, metadata, receiveEnabled });
|
|
222
|
+
try { return await this.connectPromise; } finally { this.connectPromise = null; }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async reconnectOnce({ verifySession, signal, metadata, receiveEnabled }) {
|
|
226
|
+
if (typeof verifySession !== 'function' || await verifySession(this.identity) !== true) throw new LocalError('session_mismatch');
|
|
227
|
+
const local = this.attachment;
|
|
228
|
+
const pending = this.inbox.getState('registration_pending');
|
|
229
|
+
let state;
|
|
230
|
+
try { state = await this.request('GET', `/v1/sessions/${this.identity.client_id}`, { session: false, idempotent: true, signal }); }
|
|
231
|
+
catch(error) {
|
|
232
|
+
if(this.registrationVersion === 2 && error instanceof BrokerError && error.status === 404 && error.code === 'recipient_not_found') {
|
|
233
|
+
this.expiredIdentityConfirmed = true;
|
|
234
|
+
throw new LocalError('session_expired');
|
|
235
|
+
}
|
|
236
|
+
throw error;
|
|
237
|
+
}
|
|
238
|
+
if (state.client_id !== this.identity.client_id || state.session_instance_id !== this.identity.session_instance_id
|
|
239
|
+
|| state.client_type !== this.identity.client_type || !Number.isSafeInteger(state.lease_epoch)
|
|
240
|
+
|| ![local.connection_id, pending?.body.connection_id].filter(Boolean).includes(state.connection_id)) throw new LocalError('session_mismatch');
|
|
241
|
+
if (state.closed === true || state.closed_at != null) throw new BrokerError(410, { error: { code: 'session_closed', effect: 'not_applied' } });
|
|
242
|
+
// The owned-state read is a reconciliation, not permission to steal a different attachment.
|
|
243
|
+
this.inbox.setState('attachment', { ...local, lease_epoch: state.lease_epoch, connection_id: state.connection_id,
|
|
244
|
+
metadata_revision: state.metadata_revision, policy: state.policy ?? local.policy });
|
|
245
|
+
this.inbox.setState('registration_pending', null);
|
|
246
|
+
const refreshedMetadata = metadata ?? pending?.body.metadata ?? state.metadata ?? this.inbox.getState('metadata');
|
|
247
|
+
return this.register(refreshedMetadata, { receiveEnabled: receiveEnabled ?? pending?.body.capabilities.receive_enabled ?? state.capabilities?.receive_enabled ?? false, signal });
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Explicit replacement only; never edits or transfers the old journal. */
|
|
251
|
+
async createReplacementInbox({ directory, verifySession, signal } = {}) {
|
|
252
|
+
if(this.registrationVersion !== 2 || !this.expiredIdentityConfirmed || typeof verifySession !== 'function' || await verifySession(this.identity) !== true) throw new LocalError('session_mismatch');
|
|
253
|
+
try {
|
|
254
|
+
await this.request('GET', `/v1/sessions/${this.identity.client_id}`, { session:false, idempotent:true, signal });
|
|
255
|
+
throw new LocalError('session_mismatch');
|
|
256
|
+
} catch(error) {
|
|
257
|
+
if(!(error instanceof BrokerError && error.status === 404 && error.code === 'recipient_not_found')) throw error;
|
|
258
|
+
}
|
|
259
|
+
// Nonrecursive mkdir refuses every preexisting directory, including another
|
|
260
|
+
// journal. Parent safety/ownership is still checked by Inbox itself.
|
|
261
|
+
try { mkdirSync(directory, { mode:0o700 }); }
|
|
262
|
+
catch { throw new LocalError('invalid_configuration'); }
|
|
263
|
+
const inbox = new Inbox({directory,identity:newSessionIdentity(this.identity.client_type),now:this.now});
|
|
264
|
+
this.expiredIdentityConfirmed = false;
|
|
265
|
+
return inbox;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async heartbeat({ signal } = {}) {
|
|
269
|
+
return this.request('POST', `/v1/sessions/${this.identity.client_id}/heartbeat`, { body: {}, idempotent: true, signal });
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async closeSession({ signal } = {}) {
|
|
273
|
+
return this.request('POST', `/v1/sessions/${this.identity.client_id}/close`, { body: {}, idempotent: true, signal });
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async disconnectSession({ signal } = {}) {
|
|
277
|
+
return this.request('POST', `/v1/sessions/${this.identity.client_id}/disconnect`, { body: {}, idempotent: true, signal });
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async presenceEvents(filters = {}, { signal } = {}) {
|
|
281
|
+
if (Object.keys(filters).some(key => !['limit', 'cursor'].includes(key))) throw new LocalError('invalid_configuration');
|
|
282
|
+
const query = new URLSearchParams();
|
|
283
|
+
for (const [key, value] of Object.entries(filters)) query.set(key, String(value));
|
|
284
|
+
return this.request('GET', `/v1/presence-events${query.size ? `?${query}` : ''}`, { idempotent: true, signal });
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async acknowledgePresenceEvent(eventId, { signal } = {}) {
|
|
288
|
+
return this.request('POST', `/v1/presence-events/${validateUuid(eventId)}/ack`, { body: {}, idempotent: true, signal });
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async listClients(filters = {}, { signal } = {}) {
|
|
292
|
+
const allowed = ['connected', 'client_type', 'name', 'project', 'limit', 'cursor'];
|
|
293
|
+
if (Object.keys(filters).some(key => !allowed.includes(key))) throw new LocalError('invalid_configuration');
|
|
294
|
+
const query = new URLSearchParams();
|
|
295
|
+
for (const [key, value] of Object.entries(filters)) query.set(key, String(value));
|
|
296
|
+
return this.request('GET', `/v1/clients${query.size ? `?${query}` : ''}`, { idempotent: true, signal });
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async updateMetadata(patch, { expectedRevision = this.attachment.metadata_revision, signal } = {}) {
|
|
300
|
+
const body = validateMetadataPatch({ metadata_patch: patch, expected_metadata_revision: expectedRevision });
|
|
301
|
+
// CAS is not blindly retried after response loss: read owned state to reconcile first.
|
|
302
|
+
const result = await this.request('PATCH', `/v1/sessions/${this.identity.client_id}/metadata`, { body, signal });
|
|
303
|
+
if (!Number.isSafeInteger(result?.metadata_revision)) throw new LocalError('invalid_response', { effect: 'unknown' });
|
|
304
|
+
this.inbox.setState('attachment', { ...this.attachment, metadata_revision: result.metadata_revision });
|
|
305
|
+
this.inbox.setState('metadata', { ...this.inbox.getState('metadata'), ...patch });
|
|
306
|
+
return result;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async refreshOwnedMetadata({ signal } = {}) {
|
|
310
|
+
const state = await this.request('GET', `/v1/sessions/${this.identity.client_id}`, { session: false, idempotent: true, signal });
|
|
311
|
+
if (state.client_id !== this.identity.client_id || state.session_instance_id !== this.identity.session_instance_id
|
|
312
|
+
|| state.connection_id !== this.attachment.connection_id || state.lease_epoch !== this.attachment.lease_epoch
|
|
313
|
+
|| !Number.isSafeInteger(state.metadata_revision)) throw new LocalError('session_mismatch');
|
|
314
|
+
const metadata = validateMetadata(state.metadata);
|
|
315
|
+
this.inbox.setState('attachment', { ...this.attachment, metadata_revision: state.metadata_revision });
|
|
316
|
+
this.inbox.setState('metadata', metadata);
|
|
317
|
+
return metadata;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
operationOptions(options = {}) {
|
|
321
|
+
const {operationStartedAt,...rest}=options,seconds=positiveSeconds(this.policy.send_retry_seconds,86400);
|
|
322
|
+
if(operationStartedAt!==undefined&&(!Number.isSafeInteger(operationStartedAt)||operationStartedAt>this.now()+300000||this.now()-operationStartedAt>=seconds*1000))throw new LocalError('retry_window_elapsed',{effect:'unknown'});
|
|
323
|
+
return {retryWindowSeconds:seconds,issuedAt:Math.trunc((operationStartedAt??this.now())+this.clockOffset),...(operationStartedAt===undefined?{}:{retryUntil:operationStartedAt+seconds*1000}),...rest};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
prepareSend(request, options = {}) {
|
|
327
|
+
return this.inbox.prepareSend(request,this.operationOptions(options));
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async sendPrepared(key, options = {}) { return this.outgoingRequest(key,'send',options); }
|
|
331
|
+
|
|
332
|
+
prepareResend(messageId, input = {}, options = {}) {
|
|
333
|
+
return this.inbox.prepareResend(messageId,input,this.operationOptions(options));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async resendPrepared(key, options = {}) { return this.outgoingRequest(key,'resend',options); }
|
|
337
|
+
|
|
338
|
+
async outgoingRequest(key, kind, options) {
|
|
339
|
+
// Shared by clients using this exact journal: concurrent invocations cannot
|
|
340
|
+
// race a definite rejection against another still-running network attempt.
|
|
341
|
+
const inflight=this.inbox.outgoingRequests??=new Map();
|
|
342
|
+
if(inflight.has(key))return inflight.get(key);
|
|
343
|
+
const work=this.transmitOutgoing(key,kind,options);inflight.set(key,work);
|
|
344
|
+
try{return await work;}finally{inflight.delete(key);}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async transmitOutgoing(key, kind, { signal } = {}) {
|
|
348
|
+
const operation=this.inbox.outgoing(key);
|
|
349
|
+
if(!operation||operation.kind!==kind)throw new LocalError('invalid_configuration');
|
|
350
|
+
if(operation.result)return {...operation.result,acceptance_replayed:true,send_key:key};
|
|
351
|
+
if(operation.failure)throw new BrokerError(operation.failure.status,operation.failure.value);
|
|
352
|
+
let priorUnknown=!!operation.uncertain;
|
|
353
|
+
try{this.inbox.markOutgoingAttempt(key);}catch(error){
|
|
354
|
+
if(priorUnknown)throw new LocalError('storage_unavailable',{effect:'unknown'});
|
|
355
|
+
throw error; // a wholly new operation has not reached the network
|
|
356
|
+
} // crash before/after HTTP stays uncertain
|
|
357
|
+
const body=kind==='send'?operation.request:Object.fromEntries(Object.entries(operation.request).filter(([k])=>k!=='message_id'));
|
|
358
|
+
const path=kind==='send'?'/v1/messages':`/v1/messages/${validateUuid(operation.request.message_id)}/resend`;
|
|
359
|
+
let result;
|
|
360
|
+
try{
|
|
361
|
+
result=await this.request('POST',path,{body,key,idempotent:true,deadline:operation.retry_until,signal,onError:error=>{if(error.effect!=='not_applied')priorUnknown=true;}});
|
|
362
|
+
}catch(error){
|
|
363
|
+
// A rejection of a later retry is not evidence that an earlier unknown
|
|
364
|
+
// attempt was rejected. Only a first, wholly known-not-applied operation
|
|
365
|
+
// may become a durable terminal failure; other intents remain independent.
|
|
366
|
+
if(!priorUnknown&&error instanceof BrokerError&&error.effect==='not_applied'&&!error.retryable)this.inbox.recordSendFailure(key,{status:error.status,value:error.toJSON()});
|
|
367
|
+
throw error;
|
|
368
|
+
}
|
|
369
|
+
try{validateUuid(result?.message_id);}catch{throw new LocalError('invalid_response',{effect:'unknown'});}
|
|
370
|
+
try{
|
|
371
|
+
this.inbox.recordSendResult(key,result);
|
|
372
|
+
if(kind==='send'&&operation.request.metadata_patch)this.inbox.setState('metadata',{...this.inbox.getState('metadata'),...operation.request.metadata_patch});
|
|
373
|
+
}catch{
|
|
374
|
+
// The broker already accepted this operation. A failed local result or
|
|
375
|
+
// metadata commit describes only the journal, never remote non-delivery.
|
|
376
|
+
throw new LocalError('storage_unavailable',{effect:'unknown'});
|
|
377
|
+
}
|
|
378
|
+
return {...result,send_key:key};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async cancelMessage(messageId, { signal } = {}) {
|
|
382
|
+
return this.request('POST',`/v1/messages/${validateUuid(messageId)}/cancel`,{body:{},idempotent:true,signal});
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async recoverOutgoing({ limit = 10, signal } = {}) {
|
|
386
|
+
if(!Number.isSafeInteger(limit)||limit<1||limit>100)throw new LocalError('invalid_configuration');
|
|
387
|
+
const records=[];
|
|
388
|
+
const {operations,expiredCount}=this.inbox.outgoingRecovery(limit,this.now());
|
|
389
|
+
for(const operation of operations){
|
|
390
|
+
try{
|
|
391
|
+
const pending=this.inbox.outgoing(operation.key);
|
|
392
|
+
const value=await (pending.kind==='resend'?this.resendPrepared(operation.key,{signal}):this.sendPrepared(operation.key,{signal}));
|
|
393
|
+
const {send_key,...result}=value;records.push({operation:pending.kind,result});
|
|
394
|
+
}catch(error){records.push({error:error instanceof LocalError||error instanceof BrokerError?error.toJSON().error:new LocalError('request_failed',{effect:'unknown'}).toJSON().error});}
|
|
395
|
+
}
|
|
396
|
+
if(expiredCount)records.push({...new LocalError('retry_window_elapsed',{effect:'unknown'}).toJSON(),count:expiredCount});
|
|
397
|
+
return records;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async sentHistory(filters = {}, { signal } = {}) {
|
|
401
|
+
const allowed=['recipient_id','status','accepted_after','accepted_before','limit','cursor'];
|
|
402
|
+
if(!filters||typeof filters!=='object'||Array.isArray(filters)||Object.keys(filters).some(key=>!allowed.includes(key))) throw new LocalError('invalid_configuration');
|
|
403
|
+
const query=new URLSearchParams();
|
|
404
|
+
for(const [key,value] of Object.entries(filters)) query.set(key,String(value));
|
|
405
|
+
return this.request('GET',`/v1/messages${query.size?`?${query}`:''}`,{idempotent:true,signal});
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async messageContent(messageId, { signal } = {}) {
|
|
409
|
+
return this.request('GET',`/v1/messages/${validateUuid(messageId)}/content`,{idempotent:true,signal});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async messageStatus(messageId, { signal } = {}) {
|
|
413
|
+
return this.request('GET', `/v1/messages/${validateUuid(messageId)}`, { idempotent: true, signal });
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async claim(waitSeconds = 30, { signal } = {}) {
|
|
417
|
+
let pending = this.inbox.getState('claim_pending');
|
|
418
|
+
if (!pending) {
|
|
419
|
+
pending = { key: randomUUID(), body: validateClaim({ wait_seconds: waitSeconds }), lease_epoch: this.attachment.lease_epoch };
|
|
420
|
+
this.inbox.setState('claim_pending', pending);
|
|
421
|
+
}
|
|
422
|
+
if (pending.lease_epoch !== this.attachment.lease_epoch) throw new LocalError('session_mismatch');
|
|
423
|
+
const result = await this.request('POST', '/v1/mailbox/claim', { body: pending.body, key: pending.key, idempotent: true, signal });
|
|
424
|
+
if (result === null) this.inbox.setState('claim_pending', null);
|
|
425
|
+
return result;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
async complete(messageId, completion, { signal } = {}) {
|
|
429
|
+
const result = await this.request('POST', `/v1/mailbox/${validateUuid(messageId)}/complete`, {
|
|
430
|
+
body: validateCompletion(completion), idempotent: true, signal });
|
|
431
|
+
this.inbox.setState('claim_pending', null);
|
|
432
|
+
return result;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async renew(messageId, claimId, { signal } = {}) {
|
|
436
|
+
return this.request('POST', `/v1/mailbox/${validateUuid(messageId)}/renew`, {
|
|
437
|
+
body: validateRenew({ claim_id: claimId }), idempotent: true, signal });
|
|
438
|
+
}
|
|
439
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
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_expired: 'This broker identity is no longer available. Verify the native session and explicitly create a fresh journal; never replay old pending work.',
|
|
5
|
+
session_mismatch: 'Verify the exact surviving harness session; do not adopt its mailbox.',
|
|
6
|
+
owner_conflict: 'Another worker owns this session inbox. Stop it before starting another.',
|
|
7
|
+
storage_unavailable: 'Restore local durable storage before retrying.',
|
|
8
|
+
inbox_full: 'The local inbox or deduplication budget is full; preserve pending input.',
|
|
9
|
+
envelope_mismatch: 'A message ID changed its immutable envelope. Pause and inspect the protocol.',
|
|
10
|
+
envelope_expired: 'The transport deadline passed; do not accept a new inbox item.',
|
|
11
|
+
invalid_envelope: 'The broker envelope is invalid; no inbox item was accepted.',
|
|
12
|
+
invalid_response: 'The broker response is invalid. Reconcile the original operation.',
|
|
13
|
+
request_failed: 'The network result is unknown. Retry only the original idempotent operation.',
|
|
14
|
+
request_aborted: 'The request was canceled. Reconcile any potentially applied operation.',
|
|
15
|
+
retry_window_elapsed: 'The automatic retry window ended. Reconcile; do not allocate a replacement key.',
|
|
16
|
+
send_conflict: 'The local send key already names a different request.',
|
|
17
|
+
injection_uncertain: 'Native acceptance is uncertain. The local FIFO head is paused for operator review.',
|
|
18
|
+
unsafe_boundary: 'Keep the input pending until the harness provides a verified safe boundary.',
|
|
19
|
+
receipt_required: 'A supported native acceptance receipt is required before consumption is recorded.',
|
|
20
|
+
unknown_tool: 'Use a documented broker tool name.',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export class LocalError extends Error {
|
|
24
|
+
constructor(code, { effect = 'not_applied', retryable = false, field = null } = {}) {
|
|
25
|
+
super(MESSAGES[code] ?? MESSAGES.invalid_configuration);
|
|
26
|
+
this.name = 'LocalError';
|
|
27
|
+
this.code = code;
|
|
28
|
+
this.effect = effect;
|
|
29
|
+
this.retryable = retryable;
|
|
30
|
+
this.field = field;
|
|
31
|
+
}
|
|
32
|
+
toJSON() {
|
|
33
|
+
return { error: { code: this.code, message: this.message, action: this.message,
|
|
34
|
+
retryable: this.retryable, effect: this.effect, field: this.field,
|
|
35
|
+
contract_ref: 'docs/reference.md#local-errors' } };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function safeError(error) {
|
|
40
|
+
if (error instanceof LocalError) return error.toJSON();
|
|
41
|
+
if (error?.toJSON && error?.name === 'BrokerError') return error.toJSON();
|
|
42
|
+
return new LocalError('storage_unavailable', { effect: 'unknown' }).toJSON();
|
|
43
|
+
}
|