@dsh-enhanced/assistant-delivery 0.1.3
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 +225 -0
- package/cordis.patch.yml +29 -0
- package/lib/agent-runtime.d.ts +112 -0
- package/lib/agent-runtime.d.ts.map +1 -0
- package/lib/agent-runtime.js +1866 -0
- package/lib/agent-runtime.js.map +1 -0
- package/lib/canonical.d.ts +18 -0
- package/lib/canonical.d.ts.map +1 -0
- package/lib/canonical.js +83 -0
- package/lib/canonical.js.map +1 -0
- package/lib/coordinator.d.ts +124 -0
- package/lib/coordinator.d.ts.map +1 -0
- package/lib/coordinator.js +676 -0
- package/lib/coordinator.js.map +1 -0
- package/lib/inbound-images.d.ts +38 -0
- package/lib/inbound-images.d.ts.map +1 -0
- package/lib/inbound-images.js +267 -0
- package/lib/inbound-images.js.map +1 -0
- package/lib/index.d.ts +12 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +12 -0
- package/lib/index.js.map +1 -0
- package/lib/operator.d.ts +23 -0
- package/lib/operator.d.ts.map +1 -0
- package/lib/operator.js +38 -0
- package/lib/operator.js.map +1 -0
- package/lib/service.d.ts +269 -0
- package/lib/service.d.ts.map +1 -0
- package/lib/service.js +1698 -0
- package/lib/service.js.map +1 -0
- package/lib/session-commands.d.ts +17 -0
- package/lib/session-commands.d.ts.map +1 -0
- package/lib/session-commands.js +39 -0
- package/lib/session-commands.js.map +1 -0
- package/lib/sqlite.d.ts +8 -0
- package/lib/sqlite.d.ts.map +1 -0
- package/lib/sqlite.js +411 -0
- package/lib/sqlite.js.map +1 -0
- package/lib/store.d.ts +353 -0
- package/lib/store.d.ts.map +1 -0
- package/lib/store.js +2614 -0
- package/lib/store.js.map +1 -0
- package/lib/tools.d.ts +4 -0
- package/lib/tools.d.ts.map +1 -0
- package/lib/tools.js +54 -0
- package/lib/tools.js.map +1 -0
- package/lib/types.d.ts +329 -0
- package/lib/types.d.ts.map +1 -0
- package/lib/types.js +2 -0
- package/lib/types.js.map +1 -0
- package/lib/version.d.ts +2 -0
- package/lib/version.d.ts.map +1 -0
- package/lib/version.js +2 -0
- package/lib/version.js.map +1 -0
- package/package.json +122 -0
package/lib/store.js
ADDED
|
@@ -0,0 +1,2614 @@
|
|
|
1
|
+
import { createHash, randomBytes, randomUUID, scryptSync, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { isAbsolute } from 'node:path';
|
|
3
|
+
import { canonicalConversation, canonicalPrincipal, canonicalTarget } from './canonical.js';
|
|
4
|
+
import { isExactDeliveryCommand, isPermissionDeliveryCommand, parseDeliveryCommand, permissionDispatchRecoveryCode, permissionDispatchRecoveryFromFailureCode, } from './session-commands.js';
|
|
5
|
+
import { openDeliveryDatabase } from './sqlite.js';
|
|
6
|
+
export class DeliveryStoreError extends Error {
|
|
7
|
+
code;
|
|
8
|
+
constructor(code, message) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.code = code;
|
|
11
|
+
this.name = 'DeliveryStoreError';
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
const imageMediaTypes = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif']);
|
|
15
|
+
function invalidImageRef() {
|
|
16
|
+
throw new DeliveryStoreError('conflict', 'inbound image reference is invalid');
|
|
17
|
+
}
|
|
18
|
+
function canonicalImageRef(input) {
|
|
19
|
+
if (input === null || typeof input !== 'object' || Array.isArray(input))
|
|
20
|
+
invalidImageRef();
|
|
21
|
+
const value = input;
|
|
22
|
+
const required = ['attachmentId', 'mediaType', 'bytes', 'width', 'height'];
|
|
23
|
+
if (required.some(key => !Object.hasOwn(value, key))
|
|
24
|
+
|| Object.keys(value).some(key => ![...required, 'name'].includes(key))
|
|
25
|
+
|| typeof value.attachmentId !== 'string'
|
|
26
|
+
|| value.attachmentId.length < 1 || value.attachmentId.length > 512
|
|
27
|
+
|| /\p{Cc}/u.test(value.attachmentId)
|
|
28
|
+
|| typeof value.mediaType !== 'string'
|
|
29
|
+
|| !imageMediaTypes.has(value.mediaType)
|
|
30
|
+
|| !Number.isSafeInteger(value.bytes) || value.bytes < 1
|
|
31
|
+
|| !Number.isSafeInteger(value.width) || value.width < 1
|
|
32
|
+
|| !Number.isSafeInteger(value.height) || value.height < 1
|
|
33
|
+
|| (value.name !== undefined && (typeof value.name !== 'string' || value.name.length < 1
|
|
34
|
+
|| value.name.length > 255 || value.name === '.' || value.name === '..' || /[\\/\p{Cc}]/u.test(value.name)))) {
|
|
35
|
+
invalidImageRef();
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
attachmentId: value.attachmentId,
|
|
39
|
+
mediaType: value.mediaType,
|
|
40
|
+
bytes: value.bytes,
|
|
41
|
+
width: value.width,
|
|
42
|
+
height: value.height,
|
|
43
|
+
...(value.name === undefined ? {} : { name: value.name }),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
function persistedImageRef(row) {
|
|
47
|
+
if (row.spool_ref === null || !/^[a-f0-9]{64}$/u.test(row.sha256))
|
|
48
|
+
invalidImageRef();
|
|
49
|
+
let parsed;
|
|
50
|
+
try {
|
|
51
|
+
parsed = JSON.parse(row.spool_ref);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
invalidImageRef();
|
|
55
|
+
}
|
|
56
|
+
const ref = canonicalImageRef(parsed);
|
|
57
|
+
if (row.media_type !== ref.mediaType || row.size_bytes !== ref.bytes)
|
|
58
|
+
invalidImageRef();
|
|
59
|
+
return ref;
|
|
60
|
+
}
|
|
61
|
+
function sameImageRef(left, right) {
|
|
62
|
+
return left.attachmentId === right.attachmentId
|
|
63
|
+
&& left.mediaType === right.mediaType
|
|
64
|
+
&& left.bytes === right.bytes
|
|
65
|
+
&& left.width === right.width
|
|
66
|
+
&& left.height === right.height
|
|
67
|
+
&& left.name === right.name;
|
|
68
|
+
}
|
|
69
|
+
function attachmentFromRow(row) {
|
|
70
|
+
const imageRef = row.status === 'ready' && row.resource_kind === 'image'
|
|
71
|
+
? persistedImageRef(row)
|
|
72
|
+
: undefined;
|
|
73
|
+
return {
|
|
74
|
+
id: row.id,
|
|
75
|
+
ownerKind: row.owner_kind,
|
|
76
|
+
ownerId: row.owner_id,
|
|
77
|
+
resourceType: row.resource_kind ?? 'file',
|
|
78
|
+
providerRef: row.provider_ref ?? '',
|
|
79
|
+
...(row.file_name === null ? {} : { fileName: row.file_name }),
|
|
80
|
+
...(row.media_type === '' ? {} : { mediaType: row.media_type }),
|
|
81
|
+
...(row.status === 'metadata' && row.size_bytes === 0 ? {} : { sizeBytes: row.size_bytes }),
|
|
82
|
+
...(row.status === 'metadata' ? {} : { contentSha256: row.sha256 }),
|
|
83
|
+
...(imageRef === undefined ? {} : { imageRef }),
|
|
84
|
+
status: row.status,
|
|
85
|
+
...(row.expires_at === null ? {} : { expiresAt: row.expires_at }),
|
|
86
|
+
createdAt: row.created_at,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
function digest(value) {
|
|
90
|
+
return createHash('sha256').update(value).digest('hex');
|
|
91
|
+
}
|
|
92
|
+
function principalJson(principal) {
|
|
93
|
+
return JSON.stringify(canonicalPrincipal(principal));
|
|
94
|
+
}
|
|
95
|
+
function principalHash(principal) {
|
|
96
|
+
return digest(principalJson(principal));
|
|
97
|
+
}
|
|
98
|
+
function conversationJson(conversation) {
|
|
99
|
+
return JSON.stringify(canonicalConversation(conversation));
|
|
100
|
+
}
|
|
101
|
+
function conversationHash(conversation) {
|
|
102
|
+
return digest(conversationJson(conversation));
|
|
103
|
+
}
|
|
104
|
+
function modelRoutePart(value, field) {
|
|
105
|
+
const normalized = value.trim();
|
|
106
|
+
const valid = field === 'provider'
|
|
107
|
+
? /^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$/u.test(normalized)
|
|
108
|
+
: normalized.length <= (field === 'effort' ? 128 : 512) && !/[\s\p{Cc}]/u.test(normalized);
|
|
109
|
+
if (!valid)
|
|
110
|
+
throw new DeliveryStoreError('invalid-binding', `${field} is invalid`);
|
|
111
|
+
return normalized;
|
|
112
|
+
}
|
|
113
|
+
function canonicalModelPickerState(input) {
|
|
114
|
+
if (!Number.isSafeInteger(input.revision) || input.revision < 0) {
|
|
115
|
+
throw new DeliveryStoreError('invalid-binding', 'model picker revision is invalid');
|
|
116
|
+
}
|
|
117
|
+
const provider = modelRoutePart(input.provider, 'provider');
|
|
118
|
+
const model = modelRoutePart(input.model, 'model');
|
|
119
|
+
const reasoningEffort = input.reasoningEffort === undefined
|
|
120
|
+
? undefined
|
|
121
|
+
: modelRoutePart(input.reasoningEffort, 'effort');
|
|
122
|
+
return { revision: input.revision, provider, model,
|
|
123
|
+
...(reasoningEffort === undefined ? {} : { reasoningEffort }) };
|
|
124
|
+
}
|
|
125
|
+
function canonicalModelRoute(input) {
|
|
126
|
+
const state = canonicalModelPickerState({ ...input, revision: 0 });
|
|
127
|
+
return { provider: state.provider, model: state.model,
|
|
128
|
+
...(state.reasoningEffort === undefined ? {} : { reasoningEffort: state.reasoningEffort }) };
|
|
129
|
+
}
|
|
130
|
+
function modelPickerStateFromRow(row) {
|
|
131
|
+
return { revision: row.revision, provider: row.provider, model: row.model,
|
|
132
|
+
...(row.reasoning_effort === null ? {} : { reasoningEffort: row.reasoning_effort }) };
|
|
133
|
+
}
|
|
134
|
+
function sameModelPickerState(left, right) {
|
|
135
|
+
return left.revision === right.revision && left.provider === right.provider && left.model === right.model
|
|
136
|
+
&& left.reasoningEffort === right.reasoningEffort;
|
|
137
|
+
}
|
|
138
|
+
function pairingFromRow(row) {
|
|
139
|
+
return {
|
|
140
|
+
id: row.id,
|
|
141
|
+
principal: JSON.parse(row.principal_json),
|
|
142
|
+
expiresAt: row.expires_at,
|
|
143
|
+
status: row.status,
|
|
144
|
+
attempts: row.attempts,
|
|
145
|
+
createdAt: row.created_at,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
function principalFromRow(row) {
|
|
149
|
+
return {
|
|
150
|
+
id: row.id,
|
|
151
|
+
principal: JSON.parse(row.principal_json),
|
|
152
|
+
role: row.role,
|
|
153
|
+
status: row.status,
|
|
154
|
+
...(row.linked_to_id === null ? {} : { linkedToId: row.linked_to_id }),
|
|
155
|
+
createdAt: row.created_at,
|
|
156
|
+
updatedAt: row.updated_at,
|
|
157
|
+
version: row.version,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
function bindingFromRow(row) {
|
|
161
|
+
return {
|
|
162
|
+
id: row.id,
|
|
163
|
+
conversation: JSON.parse(row.conversation_json),
|
|
164
|
+
principal: JSON.parse(row.principal_json),
|
|
165
|
+
workspace: row.workspace,
|
|
166
|
+
agentPreset: row.agent_preset,
|
|
167
|
+
sessionId: row.session_id,
|
|
168
|
+
generation: row.generation,
|
|
169
|
+
policyRef: row.policy_ref,
|
|
170
|
+
status: row.status,
|
|
171
|
+
createdAt: row.created_at,
|
|
172
|
+
updatedAt: row.updated_at,
|
|
173
|
+
version: row.version,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
const bindingSelect = `
|
|
177
|
+
SELECT id, conversation_json, principal_json, workspace, agent_preset, session_id,
|
|
178
|
+
generation, policy_ref, status, created_at, updated_at, version
|
|
179
|
+
FROM conversation_bindings
|
|
180
|
+
`;
|
|
181
|
+
const inboxSelect = `
|
|
182
|
+
SELECT id, channel, account, event_id, envelope_hash, envelope_json, status, binding_id,
|
|
183
|
+
attempt_count, next_attempt_at, claimed_by, fencing_token, lease_until, failure_code,
|
|
184
|
+
received_at, updated_at
|
|
185
|
+
FROM inbox_messages
|
|
186
|
+
`;
|
|
187
|
+
const outboxSelect = `
|
|
188
|
+
SELECT id, intent_hash, intent_json, status, provider_message_id, attempt_count,
|
|
189
|
+
next_attempt_at, claimed_by, fencing_token, lease_until, failure_code, created_at, updated_at
|
|
190
|
+
FROM outbox_messages
|
|
191
|
+
`;
|
|
192
|
+
function validateBindingText(value, field, max) {
|
|
193
|
+
const normalized = value.trim();
|
|
194
|
+
const hasControl = [...normalized].some(character => {
|
|
195
|
+
const code = character.codePointAt(0);
|
|
196
|
+
return code <= 0x1f || code === 0x7f;
|
|
197
|
+
});
|
|
198
|
+
if (normalized === '' || normalized.length > max || hasControl) {
|
|
199
|
+
throw new DeliveryStoreError('invalid-binding', `${field} is invalid`);
|
|
200
|
+
}
|
|
201
|
+
return normalized;
|
|
202
|
+
}
|
|
203
|
+
function boundedMaintenanceLimit(value, fallback, label) {
|
|
204
|
+
const limit = value ?? fallback;
|
|
205
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) {
|
|
206
|
+
throw new DeliveryStoreError('conflict', `invalid ${label} maintenance limit`);
|
|
207
|
+
}
|
|
208
|
+
return limit;
|
|
209
|
+
}
|
|
210
|
+
function inboxFromRow(row) {
|
|
211
|
+
return {
|
|
212
|
+
id: row.id,
|
|
213
|
+
channel: row.channel,
|
|
214
|
+
account: row.account,
|
|
215
|
+
eventId: row.event_id,
|
|
216
|
+
envelope: JSON.parse(row.envelope_json),
|
|
217
|
+
envelopeHash: row.envelope_hash,
|
|
218
|
+
status: row.status,
|
|
219
|
+
...(row.binding_id === null ? {} : { bindingId: row.binding_id }),
|
|
220
|
+
attemptCount: row.attempt_count,
|
|
221
|
+
...(row.next_attempt_at === null ? {} : { nextAttemptAt: row.next_attempt_at }),
|
|
222
|
+
...(row.claimed_by === null ? {} : { claimedBy: row.claimed_by }),
|
|
223
|
+
...(row.fencing_token === null ? {} : { fencingToken: row.fencing_token }),
|
|
224
|
+
...(row.lease_until === null ? {} : { leaseUntil: row.lease_until }),
|
|
225
|
+
...(row.failure_code === null ? {} : { failureCode: row.failure_code }),
|
|
226
|
+
receivedAt: row.received_at,
|
|
227
|
+
updatedAt: row.updated_at,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
function outboxFromRow(row) {
|
|
231
|
+
return {
|
|
232
|
+
id: row.id,
|
|
233
|
+
intent: JSON.parse(row.intent_json),
|
|
234
|
+
intentHash: row.intent_hash,
|
|
235
|
+
status: row.status,
|
|
236
|
+
...(row.provider_message_id === null ? {} : { providerMessageId: row.provider_message_id }),
|
|
237
|
+
attemptCount: row.attempt_count,
|
|
238
|
+
...(row.next_attempt_at === null ? {} : { nextAttemptAt: row.next_attempt_at }),
|
|
239
|
+
...(row.claimed_by === null ? {} : { claimedBy: row.claimed_by }),
|
|
240
|
+
...(row.fencing_token === null ? {} : { fencingToken: row.fencing_token }),
|
|
241
|
+
...(row.lease_until === null ? {} : { leaseUntil: row.lease_until }),
|
|
242
|
+
...(row.failure_code === null ? {} : { failureCode: row.failure_code }),
|
|
243
|
+
createdAt: row.created_at,
|
|
244
|
+
updatedAt: row.updated_at,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
function invalidEnvelope(message) {
|
|
248
|
+
throw new DeliveryStoreError('invalid-envelope', message);
|
|
249
|
+
}
|
|
250
|
+
function canonicalEnvelope(input, maxTextBytes) {
|
|
251
|
+
if (input === null || typeof input !== 'object' || Array.isArray(input))
|
|
252
|
+
invalidEnvelope('inbound envelope must be an object');
|
|
253
|
+
const allowed = ['channel', 'account', 'eventId', 'occurredAt', 'principal', 'conversation', 'kind', 'text', 'metadata', 'attachments'];
|
|
254
|
+
if (Object.keys(input).some(key => !allowed.includes(key)))
|
|
255
|
+
invalidEnvelope('inbound envelope contains an unknown field');
|
|
256
|
+
const principal = canonicalPrincipal(input.principal);
|
|
257
|
+
const conversation = canonicalConversation(input.conversation);
|
|
258
|
+
let channel;
|
|
259
|
+
let account;
|
|
260
|
+
let eventId;
|
|
261
|
+
try {
|
|
262
|
+
channel = validateBindingText(input.channel, 'channel', 256);
|
|
263
|
+
account = validateBindingText(input.account, 'account', 256);
|
|
264
|
+
eventId = validateBindingText(input.eventId, 'eventId', 512);
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
invalidEnvelope('inbound routing identifier is invalid');
|
|
268
|
+
}
|
|
269
|
+
if (channel !== principal.channel || channel !== conversation.channel
|
|
270
|
+
|| account !== principal.account || account !== conversation.account
|
|
271
|
+
|| principal.tenant !== conversation.tenant)
|
|
272
|
+
invalidEnvelope('inbound envelope routing namespaces do not match');
|
|
273
|
+
if (!Number.isSafeInteger(input.occurredAt) || input.occurredAt < 0)
|
|
274
|
+
invalidEnvelope('occurredAt is invalid');
|
|
275
|
+
if (input.kind !== 'command' && input.kind !== 'text')
|
|
276
|
+
invalidEnvelope('inbound kind is invalid');
|
|
277
|
+
if (typeof input.text !== 'string' || Buffer.byteLength(input.text, 'utf8') > maxTextBytes) {
|
|
278
|
+
invalidEnvelope('inbound text exceeds its byte budget');
|
|
279
|
+
}
|
|
280
|
+
let metadata;
|
|
281
|
+
if (input.metadata !== undefined) {
|
|
282
|
+
const entries = Object.entries(input.metadata);
|
|
283
|
+
if (entries.length > 16)
|
|
284
|
+
invalidEnvelope('inbound metadata has too many entries');
|
|
285
|
+
metadata = {};
|
|
286
|
+
for (const [metadataKey, value] of entries) {
|
|
287
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u.test(metadataKey) || typeof value !== 'string' || value.length > 256) {
|
|
288
|
+
invalidEnvelope('inbound metadata is invalid');
|
|
289
|
+
}
|
|
290
|
+
metadata[metadataKey] = value;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
let attachments;
|
|
294
|
+
if (input.attachments !== undefined) {
|
|
295
|
+
if (!Array.isArray(input.attachments) || input.attachments.length > 10) {
|
|
296
|
+
invalidEnvelope('inbound attachments exceed the descriptor budget');
|
|
297
|
+
}
|
|
298
|
+
attachments = input.attachments.map(item => {
|
|
299
|
+
if (item === null || typeof item !== 'object' || Array.isArray(item)
|
|
300
|
+
|| Object.keys(item).some(field => !['resourceType', 'providerRef', 'fileName', 'mediaType', 'sizeBytes'].includes(field))) {
|
|
301
|
+
invalidEnvelope('inbound attachment descriptor shape is invalid');
|
|
302
|
+
}
|
|
303
|
+
if (!['audio', 'file', 'image', 'sticker', 'video'].includes(item.resourceType)) {
|
|
304
|
+
invalidEnvelope('inbound attachment resource type is invalid');
|
|
305
|
+
}
|
|
306
|
+
if (typeof item.providerRef !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,511}$/u.test(item.providerRef)) {
|
|
307
|
+
invalidEnvelope('inbound attachment provider reference is invalid');
|
|
308
|
+
}
|
|
309
|
+
if (item.fileName !== undefined && (typeof item.fileName !== 'string' || item.fileName.length > 255
|
|
310
|
+
|| item.fileName === '.' || item.fileName === '..' || /[\\/\p{Cc}]/u.test(item.fileName))) {
|
|
311
|
+
invalidEnvelope('inbound attachment file name is invalid');
|
|
312
|
+
}
|
|
313
|
+
if (item.mediaType !== undefined && (typeof item.mediaType !== 'string'
|
|
314
|
+
|| !/^[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]{0,126}\/[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]{0,126}$/u.test(item.mediaType))) {
|
|
315
|
+
invalidEnvelope('inbound attachment media type is invalid');
|
|
316
|
+
}
|
|
317
|
+
if (item.sizeBytes !== undefined && (!Number.isSafeInteger(item.sizeBytes)
|
|
318
|
+
|| item.sizeBytes < 0 || item.sizeBytes > 100 * 1024 * 1024)) {
|
|
319
|
+
invalidEnvelope('inbound attachment size is invalid');
|
|
320
|
+
}
|
|
321
|
+
return {
|
|
322
|
+
resourceType: item.resourceType,
|
|
323
|
+
providerRef: item.providerRef,
|
|
324
|
+
...(item.fileName === undefined ? {} : { fileName: item.fileName }),
|
|
325
|
+
...(item.mediaType === undefined ? {} : { mediaType: item.mediaType }),
|
|
326
|
+
...(item.sizeBytes === undefined ? {} : { sizeBytes: item.sizeBytes }),
|
|
327
|
+
};
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
return {
|
|
331
|
+
channel,
|
|
332
|
+
account,
|
|
333
|
+
eventId,
|
|
334
|
+
occurredAt: input.occurredAt,
|
|
335
|
+
principal,
|
|
336
|
+
conversation,
|
|
337
|
+
kind: input.kind,
|
|
338
|
+
text: input.text,
|
|
339
|
+
...(metadata === undefined ? {} : { metadata }),
|
|
340
|
+
...(attachments === undefined ? {} : { attachments }),
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
function canonicalMetadata(input, kind) {
|
|
344
|
+
if (input === undefined)
|
|
345
|
+
return undefined;
|
|
346
|
+
const entries = Object.entries(input);
|
|
347
|
+
if (entries.length > 16) {
|
|
348
|
+
throw new DeliveryStoreError(kind === 'intent' ? 'invalid-intent' : 'receipt-mismatch', `${kind} metadata has too many entries`);
|
|
349
|
+
}
|
|
350
|
+
const output = {};
|
|
351
|
+
for (const [metadataKey, value] of entries) {
|
|
352
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u.test(metadataKey) || typeof value !== 'string' || value.length > 256) {
|
|
353
|
+
throw new DeliveryStoreError(kind === 'intent' ? 'invalid-intent' : 'receipt-mismatch', `${kind} metadata is invalid`);
|
|
354
|
+
}
|
|
355
|
+
output[metadataKey] = value;
|
|
356
|
+
}
|
|
357
|
+
return output;
|
|
358
|
+
}
|
|
359
|
+
function canonicalIntent(input, binding, maxTextBytes) {
|
|
360
|
+
const allowed = [
|
|
361
|
+
'idempotencyKey', 'bindingId', 'target', 'text', 'format', 'approval', 'modelPicker', 'permissionPicker',
|
|
362
|
+
'replyToEventId', 'metadata',
|
|
363
|
+
];
|
|
364
|
+
if (input === null || typeof input !== 'object' || Array.isArray(input) || Object.keys(input).some(key => !allowed.includes(key))) {
|
|
365
|
+
throw new DeliveryStoreError('invalid-intent', 'outbound intent shape is invalid');
|
|
366
|
+
}
|
|
367
|
+
let idempotencyKey;
|
|
368
|
+
let bindingId;
|
|
369
|
+
try {
|
|
370
|
+
idempotencyKey = validateBindingText(input.idempotencyKey, 'idempotencyKey', 512);
|
|
371
|
+
bindingId = validateBindingText(input.bindingId, 'bindingId', 256);
|
|
372
|
+
}
|
|
373
|
+
catch {
|
|
374
|
+
throw new DeliveryStoreError('invalid-intent', 'outbound intent identifier is invalid');
|
|
375
|
+
}
|
|
376
|
+
if (bindingId !== binding.id)
|
|
377
|
+
throw new DeliveryStoreError('invalid-intent', 'outbound intent binding does not exist');
|
|
378
|
+
const target = canonicalTarget(input.target);
|
|
379
|
+
if (conversationHash(target.conversation) !== conversationHash(binding.conversation)
|
|
380
|
+
|| principalHash(target.principal) !== principalHash(binding.principal))
|
|
381
|
+
throw new DeliveryStoreError('invalid-intent', 'outbound target does not match its binding');
|
|
382
|
+
if (typeof input.text !== 'string' || Buffer.byteLength(input.text, 'utf8') > maxTextBytes) {
|
|
383
|
+
throw new DeliveryStoreError('invalid-intent', 'outbound text exceeds its byte budget');
|
|
384
|
+
}
|
|
385
|
+
const format = input.format ?? 'plain';
|
|
386
|
+
if (format !== 'plain' && format !== 'markdown' && format !== 'approval' && format !== 'model-picker'
|
|
387
|
+
&& format !== 'permission-picker') {
|
|
388
|
+
throw new DeliveryStoreError('invalid-intent', 'outbound format is invalid');
|
|
389
|
+
}
|
|
390
|
+
let approval;
|
|
391
|
+
if (format === 'approval') {
|
|
392
|
+
const value = input.approval;
|
|
393
|
+
if (value === undefined || value === null || typeof value !== 'object' || Array.isArray(value)
|
|
394
|
+
|| Object.keys(value).some(field => ![
|
|
395
|
+
'operationId', 'proposalId', 'expectedVersion', 'expiresAt', 'title', 'diffHash',
|
|
396
|
+
].includes(field)) || Object.keys(value).length !== 6) {
|
|
397
|
+
throw new DeliveryStoreError('invalid-intent', 'approval intent shape is invalid');
|
|
398
|
+
}
|
|
399
|
+
let operationId;
|
|
400
|
+
let proposalId;
|
|
401
|
+
let title;
|
|
402
|
+
try {
|
|
403
|
+
operationId = validateBindingText(value.operationId, 'operationId', 256);
|
|
404
|
+
proposalId = validateBindingText(value.proposalId, 'proposalId', 256);
|
|
405
|
+
title = validateBindingText(value.title, 'title', 120);
|
|
406
|
+
}
|
|
407
|
+
catch {
|
|
408
|
+
throw new DeliveryStoreError('invalid-intent', 'approval intent text is invalid');
|
|
409
|
+
}
|
|
410
|
+
if (!Number.isSafeInteger(value.expectedVersion) || value.expectedVersion < 1
|
|
411
|
+
|| !Number.isSafeInteger(value.expiresAt) || value.expiresAt < 1
|
|
412
|
+
|| typeof value.diffHash !== 'string' || !/^[a-f0-9]{64}$/u.test(value.diffHash)) {
|
|
413
|
+
throw new DeliveryStoreError('invalid-intent', 'approval intent version or expiry is invalid');
|
|
414
|
+
}
|
|
415
|
+
approval = { operationId, proposalId, expectedVersion: value.expectedVersion,
|
|
416
|
+
expiresAt: value.expiresAt, title, diffHash: value.diffHash };
|
|
417
|
+
}
|
|
418
|
+
else if (input.approval !== undefined) {
|
|
419
|
+
throw new DeliveryStoreError('invalid-intent', 'approval payload requires approval format');
|
|
420
|
+
}
|
|
421
|
+
let modelPicker;
|
|
422
|
+
if (format === 'model-picker') {
|
|
423
|
+
modelPicker = canonicalModelPicker(input.modelPicker);
|
|
424
|
+
}
|
|
425
|
+
else if (input.modelPicker !== undefined) {
|
|
426
|
+
throw new DeliveryStoreError('invalid-intent', 'model picker payload requires model-picker format');
|
|
427
|
+
}
|
|
428
|
+
let permissionPicker;
|
|
429
|
+
if (format === 'permission-picker') {
|
|
430
|
+
permissionPicker = canonicalPermissionPicker(input.permissionPicker, binding);
|
|
431
|
+
}
|
|
432
|
+
else if (input.permissionPicker !== undefined) {
|
|
433
|
+
throw new DeliveryStoreError('invalid-intent', 'permission picker payload requires permission-picker format');
|
|
434
|
+
}
|
|
435
|
+
let replyToEventId;
|
|
436
|
+
if (input.replyToEventId !== undefined) {
|
|
437
|
+
try {
|
|
438
|
+
replyToEventId = validateBindingText(input.replyToEventId, 'replyToEventId', 512);
|
|
439
|
+
}
|
|
440
|
+
catch {
|
|
441
|
+
throw new DeliveryStoreError('invalid-intent', 'reply event id is invalid');
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
const metadata = canonicalMetadata(input.metadata, 'intent');
|
|
445
|
+
return { idempotencyKey, bindingId, target, text: input.text, format,
|
|
446
|
+
...(approval === undefined ? {} : { approval }),
|
|
447
|
+
...(modelPicker === undefined ? {} : { modelPicker }),
|
|
448
|
+
...(permissionPicker === undefined ? {} : { permissionPicker }),
|
|
449
|
+
...(replyToEventId === undefined ? {} : { replyToEventId }),
|
|
450
|
+
...(metadata === undefined ? {} : { metadata }) };
|
|
451
|
+
}
|
|
452
|
+
function canonicalPermissionPicker(input, binding) {
|
|
453
|
+
if (input === undefined || input === null || typeof input !== 'object' || Array.isArray(input)
|
|
454
|
+
|| Object.keys(input).some(field => ![
|
|
455
|
+
'operationId', 'issuedAt', 'expiresAt', 'current', 'expectedStateHash', 'emergencyStopVersion',
|
|
456
|
+
'bindingVersion', 'sessionId',
|
|
457
|
+
].includes(field)) || Object.keys(input).length !== 8) {
|
|
458
|
+
throw new DeliveryStoreError('invalid-intent', 'permission picker intent shape is invalid');
|
|
459
|
+
}
|
|
460
|
+
let operationId;
|
|
461
|
+
let sessionId;
|
|
462
|
+
try {
|
|
463
|
+
operationId = validateBindingText(input.operationId, 'operationId', 256);
|
|
464
|
+
sessionId = validateBindingText(input.sessionId, 'sessionId', 512);
|
|
465
|
+
}
|
|
466
|
+
catch {
|
|
467
|
+
throw new DeliveryStoreError('invalid-intent', 'permission picker identifier is invalid');
|
|
468
|
+
}
|
|
469
|
+
if (!Number.isSafeInteger(input.issuedAt) || input.issuedAt < 1
|
|
470
|
+
|| !Number.isSafeInteger(input.expiresAt) || input.expiresAt < 1
|
|
471
|
+
|| input.issuedAt >= input.expiresAt) {
|
|
472
|
+
throw new DeliveryStoreError('invalid-intent', 'permission picker lifetime is invalid');
|
|
473
|
+
}
|
|
474
|
+
if (input.current !== 'ask' && input.current !== 'auto' && input.current !== 'full' && input.current !== 'custom') {
|
|
475
|
+
throw new DeliveryStoreError('invalid-intent', 'permission picker current level is invalid');
|
|
476
|
+
}
|
|
477
|
+
if (typeof input.expectedStateHash !== 'string' || !/^[a-f0-9]{64}$/u.test(input.expectedStateHash)) {
|
|
478
|
+
throw new DeliveryStoreError('invalid-intent', 'permission picker state hash is invalid');
|
|
479
|
+
}
|
|
480
|
+
if (!Number.isSafeInteger(input.emergencyStopVersion) || input.emergencyStopVersion < 0) {
|
|
481
|
+
throw new DeliveryStoreError('invalid-intent', 'permission picker emergency-stop version is invalid');
|
|
482
|
+
}
|
|
483
|
+
if (!Number.isSafeInteger(input.bindingVersion) || input.bindingVersion < 1
|
|
484
|
+
|| input.bindingVersion !== binding.version || sessionId !== binding.sessionId) {
|
|
485
|
+
throw new DeliveryStoreError('invalid-intent', 'permission picker binding snapshot is invalid');
|
|
486
|
+
}
|
|
487
|
+
return {
|
|
488
|
+
operationId,
|
|
489
|
+
issuedAt: input.issuedAt,
|
|
490
|
+
expiresAt: input.expiresAt,
|
|
491
|
+
current: input.current,
|
|
492
|
+
expectedStateHash: input.expectedStateHash,
|
|
493
|
+
emergencyStopVersion: input.emergencyStopVersion,
|
|
494
|
+
bindingVersion: input.bindingVersion,
|
|
495
|
+
sessionId,
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
function canonicalModelPicker(input) {
|
|
499
|
+
if (input === undefined || input === null || typeof input !== 'object' || Array.isArray(input)
|
|
500
|
+
|| Object.keys(input).some(field => ![
|
|
501
|
+
'operationId', 'expiresAt', 'current', 'providers', 'models', 'efforts',
|
|
502
|
+
].includes(field)) || Object.keys(input).length !== 6) {
|
|
503
|
+
throw new DeliveryStoreError('invalid-intent', 'model picker intent shape is invalid');
|
|
504
|
+
}
|
|
505
|
+
const operationId = validateBindingText(input.operationId, 'operationId', 256);
|
|
506
|
+
if (!Number.isSafeInteger(input.expiresAt) || input.expiresAt < 1) {
|
|
507
|
+
throw new DeliveryStoreError('invalid-intent', 'model picker expiry is invalid');
|
|
508
|
+
}
|
|
509
|
+
const current = canonicalPickerRoute(input.current);
|
|
510
|
+
if (!Array.isArray(input.providers) || input.providers.length < 1 || input.providers.length > 20
|
|
511
|
+
|| !Array.isArray(input.models) || input.models.length < 1 || input.models.length > 50
|
|
512
|
+
|| !Array.isArray(input.efforts) || input.efforts.length > 20) {
|
|
513
|
+
throw new DeliveryStoreError('invalid-intent', 'model picker option budget is invalid');
|
|
514
|
+
}
|
|
515
|
+
const providers = input.providers.map(entry => {
|
|
516
|
+
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)
|
|
517
|
+
|| Object.keys(entry).length !== 2 || Object.keys(entry).some(field => !['id', 'name'].includes(field))) {
|
|
518
|
+
throw new DeliveryStoreError('invalid-intent', 'model picker provider is invalid');
|
|
519
|
+
}
|
|
520
|
+
return { id: modelRoutePart(entry.id, 'provider'), name: validateBindingText(entry.name, 'provider.name', 120) };
|
|
521
|
+
});
|
|
522
|
+
if (new Set(providers.map(entry => entry.id)).size !== providers.length) {
|
|
523
|
+
throw new DeliveryStoreError('invalid-intent', 'model picker providers contain duplicates');
|
|
524
|
+
}
|
|
525
|
+
const efforts = input.efforts.map(entry => {
|
|
526
|
+
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)
|
|
527
|
+
|| Object.keys(entry).length !== 2 || Object.keys(entry).some(field => !['id', 'name'].includes(field))) {
|
|
528
|
+
throw new DeliveryStoreError('invalid-intent', 'model picker effort is invalid');
|
|
529
|
+
}
|
|
530
|
+
return { id: modelRoutePart(entry.id, 'effort'), name: validateBindingText(entry.name, 'effort.name', 120) };
|
|
531
|
+
});
|
|
532
|
+
if (new Set(efforts.map(entry => entry.id)).size !== efforts.length) {
|
|
533
|
+
throw new DeliveryStoreError('invalid-intent', 'model picker efforts contain duplicates');
|
|
534
|
+
}
|
|
535
|
+
const effortIds = new Set(efforts.map(entry => entry.id));
|
|
536
|
+
const providerIds = new Set(providers.map(entry => entry.id));
|
|
537
|
+
const models = input.models.map(entry => {
|
|
538
|
+
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)
|
|
539
|
+
|| Object.keys(entry).length !== 4
|
|
540
|
+
|| Object.keys(entry).some(field => !['provider', 'id', 'name', 'effortIds'].includes(field))
|
|
541
|
+
|| !Array.isArray(entry.effortIds) || entry.effortIds.length > 20) {
|
|
542
|
+
throw new DeliveryStoreError('invalid-intent', 'model picker model is invalid');
|
|
543
|
+
}
|
|
544
|
+
const provider = modelRoutePart(entry.provider, 'provider');
|
|
545
|
+
if (!providerIds.has(provider))
|
|
546
|
+
throw new DeliveryStoreError('invalid-intent', 'model picker model provider is missing');
|
|
547
|
+
const linkedEfforts = entry.effortIds.map((id) => {
|
|
548
|
+
if (typeof id !== 'string')
|
|
549
|
+
throw new DeliveryStoreError('invalid-intent', 'model picker model effort is invalid');
|
|
550
|
+
return modelRoutePart(id, 'effort');
|
|
551
|
+
});
|
|
552
|
+
if (new Set(linkedEfforts).size !== linkedEfforts.length
|
|
553
|
+
|| linkedEfforts.some((id) => !effortIds.has(id))) {
|
|
554
|
+
throw new DeliveryStoreError('invalid-intent', 'model picker model efforts are invalid');
|
|
555
|
+
}
|
|
556
|
+
return { provider, id: modelRoutePart(entry.id, 'model'),
|
|
557
|
+
name: validateBindingText(entry.name, 'model.name', 120), effortIds: linkedEfforts };
|
|
558
|
+
});
|
|
559
|
+
if (new Set(models.map(entry => `${entry.provider}\0${entry.id}`)).size !== models.length) {
|
|
560
|
+
throw new DeliveryStoreError('invalid-intent', 'model picker models contain duplicates');
|
|
561
|
+
}
|
|
562
|
+
return { operationId, expiresAt: input.expiresAt, current, providers, models, efforts };
|
|
563
|
+
}
|
|
564
|
+
function canonicalPickerRoute(input) {
|
|
565
|
+
if (input === null || typeof input !== 'object' || Array.isArray(input)
|
|
566
|
+
|| Object.keys(input).some(field => !['provider', 'model', 'reasoningEffort'].includes(field))) {
|
|
567
|
+
throw new DeliveryStoreError('invalid-intent', 'model picker current route is invalid');
|
|
568
|
+
}
|
|
569
|
+
return {
|
|
570
|
+
provider: modelRoutePart(input.provider, 'provider'),
|
|
571
|
+
model: modelRoutePart(input.model, 'model'),
|
|
572
|
+
...(input.reasoningEffort === undefined
|
|
573
|
+
? {}
|
|
574
|
+
: { reasoningEffort: modelRoutePart(input.reasoningEffort, 'effort') }),
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
function getPairingStatement(database) {
|
|
578
|
+
return database.prepare(`
|
|
579
|
+
SELECT id, principal_json, expires_at, status, attempts, created_at
|
|
580
|
+
FROM pairing_challenges WHERE id = ?
|
|
581
|
+
`);
|
|
582
|
+
}
|
|
583
|
+
export class DeliveryStore {
|
|
584
|
+
database;
|
|
585
|
+
databaseInstanceId;
|
|
586
|
+
now;
|
|
587
|
+
codeGenerator;
|
|
588
|
+
maxTextBytes;
|
|
589
|
+
closed = false;
|
|
590
|
+
constructor(options) {
|
|
591
|
+
this.database = openDeliveryDatabase(options.path);
|
|
592
|
+
this.now = options.now ?? Date.now;
|
|
593
|
+
this.codeGenerator = options.codeGenerator ?? (() => randomBytes(5).toString('hex').toUpperCase());
|
|
594
|
+
this.maxTextBytes = options.maxTextBytes ?? 65_536;
|
|
595
|
+
const instance = this.database.prepare(`
|
|
596
|
+
SELECT instance_id FROM delivery_instance WHERE singleton = 1
|
|
597
|
+
`).get();
|
|
598
|
+
if (instance === undefined || !/^[0-9a-f]{32}$/u.test(instance.instance_id)) {
|
|
599
|
+
this.database.close();
|
|
600
|
+
this.closed = true;
|
|
601
|
+
throw new DeliveryStoreError('conflict', 'delivery database instance namespace is missing or invalid');
|
|
602
|
+
}
|
|
603
|
+
this.databaseInstanceId = instance.instance_id;
|
|
604
|
+
}
|
|
605
|
+
instanceId() {
|
|
606
|
+
this.assertOpen();
|
|
607
|
+
return this.databaseInstanceId;
|
|
608
|
+
}
|
|
609
|
+
issuePairing(input, options) {
|
|
610
|
+
this.assertOpen();
|
|
611
|
+
const principal = canonicalPrincipal(input);
|
|
612
|
+
if (!Number.isSafeInteger(options.ttlMs) || options.ttlMs < 1 || options.ttlMs > 86_400_000) {
|
|
613
|
+
throw new DeliveryStoreError('conflict', 'pairing ttl must be between 1 ms and 24 hours');
|
|
614
|
+
}
|
|
615
|
+
if (!Number.isSafeInteger(options.maxAttempts) || options.maxAttempts < 1 || options.maxAttempts > 10) {
|
|
616
|
+
throw new DeliveryStoreError('conflict', 'pairing maxAttempts must be between 1 and 10');
|
|
617
|
+
}
|
|
618
|
+
const code = this.codeGenerator();
|
|
619
|
+
if (!/^[A-Z0-9]{8,32}$/u.test(code))
|
|
620
|
+
throw new DeliveryStoreError('conflict', 'pairing generator returned an invalid code');
|
|
621
|
+
const now = this.now();
|
|
622
|
+
const json = principalJson(principal);
|
|
623
|
+
const hash = digest(json);
|
|
624
|
+
const salt = randomBytes(16).toString('hex');
|
|
625
|
+
const codeHash = scryptSync(code, salt, 32).toString('hex');
|
|
626
|
+
const id = `pair_${randomUUID()}`;
|
|
627
|
+
this.transaction(() => {
|
|
628
|
+
this.database.prepare(`
|
|
629
|
+
UPDATE pairing_challenges SET status = 'expired', updated_at = ?
|
|
630
|
+
WHERE principal_hash = ? AND status = 'active'
|
|
631
|
+
`).run(now, hash);
|
|
632
|
+
this.database.prepare(`
|
|
633
|
+
INSERT INTO pairing_challenges (
|
|
634
|
+
id, principal_hash, principal_json, code_salt, code_hash, status, attempts, max_attempts,
|
|
635
|
+
expires_at, created_at, updated_at
|
|
636
|
+
) VALUES (?, ?, ?, ?, ?, 'active', 0, ?, ?, ?, ?)
|
|
637
|
+
`).run(id, hash, json, salt, codeHash, options.maxAttempts, now + options.ttlMs, now, now);
|
|
638
|
+
});
|
|
639
|
+
return { challenge: pairingFromRow(getPairingStatement(this.database).get(id)), code };
|
|
640
|
+
}
|
|
641
|
+
confirmPairing(input) {
|
|
642
|
+
this.assertOpen();
|
|
643
|
+
const principal = canonicalPrincipal(input.principal);
|
|
644
|
+
const json = principalJson(principal);
|
|
645
|
+
const hash = digest(json);
|
|
646
|
+
const now = this.now();
|
|
647
|
+
let result;
|
|
648
|
+
let failure;
|
|
649
|
+
this.transaction(() => {
|
|
650
|
+
const row = this.database.prepare(`
|
|
651
|
+
SELECT id, principal_hash, principal_json, code_salt, code_hash, status, attempts,
|
|
652
|
+
max_attempts, expires_at, created_at, updated_at
|
|
653
|
+
FROM pairing_challenges WHERE id = ?
|
|
654
|
+
`).get(input.challengeId);
|
|
655
|
+
if (row === undefined) {
|
|
656
|
+
failure = new DeliveryStoreError('not-found', 'pairing challenge was not found');
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
if (row.status === 'consumed') {
|
|
660
|
+
failure = new DeliveryStoreError('pairing-replayed', 'pairing challenge was already consumed');
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
if (row.status === 'locked') {
|
|
664
|
+
failure = new DeliveryStoreError('pairing-locked', 'pairing challenge is locked');
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
if (row.status === 'expired' || now >= row.expires_at) {
|
|
668
|
+
this.database.prepare("UPDATE pairing_challenges SET status = 'expired', updated_at = ? WHERE id = ?")
|
|
669
|
+
.run(now, row.id);
|
|
670
|
+
failure = new DeliveryStoreError('pairing-expired', 'pairing challenge expired');
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
const suppliedPrincipalHash = Buffer.from(hash, 'hex');
|
|
674
|
+
const expectedPrincipalHash = Buffer.from(row.principal_hash, 'hex');
|
|
675
|
+
if (suppliedPrincipalHash.length !== expectedPrincipalHash.length
|
|
676
|
+
|| !timingSafeEqual(suppliedPrincipalHash, expectedPrincipalHash)) {
|
|
677
|
+
failure = new DeliveryStoreError('pairing-principal-mismatch', 'pairing challenge belongs to another principal');
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
const suppliedCodeHash = scryptSync(input.code, row.code_salt, 32);
|
|
681
|
+
const expectedCodeHash = Buffer.from(row.code_hash, 'hex');
|
|
682
|
+
if (suppliedCodeHash.length !== expectedCodeHash.length || !timingSafeEqual(suppliedCodeHash, expectedCodeHash)) {
|
|
683
|
+
const attempts = row.attempts + 1;
|
|
684
|
+
const locked = attempts >= row.max_attempts;
|
|
685
|
+
this.database.prepare('UPDATE pairing_challenges SET attempts = ?, status = ?, updated_at = ? WHERE id = ?')
|
|
686
|
+
.run(attempts, locked ? 'locked' : 'active', now, row.id);
|
|
687
|
+
failure = new DeliveryStoreError(locked ? 'pairing-locked' : 'pairing-invalid', locked
|
|
688
|
+
? 'pairing challenge locked after too many attempts'
|
|
689
|
+
: 'pairing code is invalid');
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
const existing = this.database.prepare(`
|
|
693
|
+
SELECT id, principal_json, role, status, linked_to_id, created_at, updated_at, version
|
|
694
|
+
FROM delivery_principals WHERE key_hash = ?
|
|
695
|
+
`).get(hash);
|
|
696
|
+
if (existing === undefined) {
|
|
697
|
+
const count = this.database.prepare("SELECT COUNT(*) AS count FROM delivery_principals WHERE role = 'owner' AND status = 'active'")
|
|
698
|
+
.get().count;
|
|
699
|
+
const id = `principal_${randomUUID()}`;
|
|
700
|
+
this.database.prepare(`
|
|
701
|
+
INSERT INTO delivery_principals (
|
|
702
|
+
id, key_hash, principal_json, role, status, linked_to_id, created_at, updated_at, version
|
|
703
|
+
) VALUES (?, ?, ?, ?, 'active', NULL, ?, ?, 1)
|
|
704
|
+
`).run(id, hash, json, count === 0 ? 'owner' : 'linked', now, now);
|
|
705
|
+
result = principalFromRow(this.database.prepare(`
|
|
706
|
+
SELECT id, principal_json, role, status, linked_to_id, created_at, updated_at, version
|
|
707
|
+
FROM delivery_principals WHERE id = ?
|
|
708
|
+
`).get(id));
|
|
709
|
+
}
|
|
710
|
+
else {
|
|
711
|
+
const anotherOwner = existing.role === 'owner' && existing.status === 'revoked'
|
|
712
|
+
? this.database.prepare(`
|
|
713
|
+
SELECT id FROM delivery_principals
|
|
714
|
+
WHERE role = 'owner' AND status = 'active' AND id <> ? LIMIT 1
|
|
715
|
+
`).get(existing.id)
|
|
716
|
+
: undefined;
|
|
717
|
+
const linkedOwner = existing.role === 'linked'
|
|
718
|
+
&& existing.status === 'revoked'
|
|
719
|
+
&& existing.linked_to_id !== null
|
|
720
|
+
? this.database.prepare(`
|
|
721
|
+
SELECT id FROM delivery_principals
|
|
722
|
+
WHERE id = ? AND role = 'owner' AND status = 'active'
|
|
723
|
+
`).get(existing.linked_to_id)
|
|
724
|
+
: undefined;
|
|
725
|
+
const retiredLink = existing.role === 'linked'
|
|
726
|
+
&& existing.status === 'revoked'
|
|
727
|
+
&& linkedOwner === undefined;
|
|
728
|
+
if (anotherOwner !== undefined || retiredLink) {
|
|
729
|
+
// A trusted local handoff is the only operation allowed to rotate
|
|
730
|
+
// owner authority. A linked identity may only be reactivated while
|
|
731
|
+
// its explicit root is still the active owner. Consuming this
|
|
732
|
+
// otherwise-valid challenge prevents replay without restoring stale
|
|
733
|
+
// authority from a retired owner or legacy orphan.
|
|
734
|
+
failure = new DeliveryStoreError('unauthorized-principal', retiredLink
|
|
735
|
+
? 'ordinary pairing cannot reactivate a link without its active owner'
|
|
736
|
+
: 'ordinary pairing cannot reactivate a former owner while another owner is active');
|
|
737
|
+
}
|
|
738
|
+
else {
|
|
739
|
+
this.database.prepare(`
|
|
740
|
+
UPDATE delivery_principals SET status = 'active', updated_at = ?, version = version + 1 WHERE id = ?
|
|
741
|
+
`).run(now, existing.id);
|
|
742
|
+
result = principalFromRow(this.database.prepare(`
|
|
743
|
+
SELECT id, principal_json, role, status, linked_to_id, created_at, updated_at, version
|
|
744
|
+
FROM delivery_principals WHERE id = ?
|
|
745
|
+
`).get(existing.id));
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
this.database.prepare("UPDATE pairing_challenges SET status = 'consumed', updated_at = ? WHERE id = ?")
|
|
749
|
+
.run(now, row.id);
|
|
750
|
+
});
|
|
751
|
+
if (failure !== undefined)
|
|
752
|
+
throw failure;
|
|
753
|
+
if (result === undefined)
|
|
754
|
+
throw new DeliveryStoreError('conflict', 'pairing transaction produced no principal');
|
|
755
|
+
return result;
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
758
|
+
* Trusted local owner rotation. Unlike an ordinary pairing, this promotes
|
|
759
|
+
* the exact replacement principal and retires every previous active owner in
|
|
760
|
+
* one SQLite transaction, so setup can never leave two active owners or turn
|
|
761
|
+
* the newly discovered owner into a merely linked identity.
|
|
762
|
+
*/
|
|
763
|
+
handoffOwner(input) {
|
|
764
|
+
this.assertOpen();
|
|
765
|
+
const principal = canonicalPrincipal(input);
|
|
766
|
+
const json = principalJson(principal);
|
|
767
|
+
const hash = digest(json);
|
|
768
|
+
const now = this.now();
|
|
769
|
+
return this.transaction(() => {
|
|
770
|
+
let replacement = this.database.prepare(`
|
|
771
|
+
SELECT id, principal_json, role, status, linked_to_id, created_at, updated_at, version
|
|
772
|
+
FROM delivery_principals WHERE key_hash = ? AND principal_json = ?
|
|
773
|
+
`).get(hash, json);
|
|
774
|
+
const activeOwners = this.database.prepare(`
|
|
775
|
+
SELECT id, principal_json, role, status, linked_to_id, created_at, updated_at, version
|
|
776
|
+
FROM delivery_principals WHERE role = 'owner' AND status = 'active'
|
|
777
|
+
`).all();
|
|
778
|
+
if (replacement === undefined) {
|
|
779
|
+
const id = `principal_${randomUUID()}`;
|
|
780
|
+
this.database.prepare(`
|
|
781
|
+
INSERT INTO delivery_principals (
|
|
782
|
+
id, key_hash, principal_json, role, status, linked_to_id, created_at, updated_at, version
|
|
783
|
+
) VALUES (?, ?, ?, 'owner', 'active', NULL, ?, ?, 1)
|
|
784
|
+
`).run(id, hash, json, now, now);
|
|
785
|
+
replacement = this.database.prepare(`
|
|
786
|
+
SELECT id, principal_json, role, status, linked_to_id, created_at, updated_at, version
|
|
787
|
+
FROM delivery_principals WHERE id = ?
|
|
788
|
+
`).get(id);
|
|
789
|
+
}
|
|
790
|
+
else if (replacement.role !== 'owner'
|
|
791
|
+
|| replacement.status !== 'active'
|
|
792
|
+
|| replacement.linked_to_id !== null) {
|
|
793
|
+
this.database.prepare(`
|
|
794
|
+
UPDATE delivery_principals
|
|
795
|
+
SET role = 'owner', status = 'active', linked_to_id = NULL, updated_at = ?, version = version + 1
|
|
796
|
+
WHERE id = ?
|
|
797
|
+
`).run(now, replacement.id);
|
|
798
|
+
}
|
|
799
|
+
// Revoke every linked identity not explicitly rooted in the replacement.
|
|
800
|
+
// This covers both legacy NULL orphans and identities whose former owner
|
|
801
|
+
// was revoked before the handoff. Exclude the replacement (which may
|
|
802
|
+
// itself have arrived as an orphan) before promoting it above.
|
|
803
|
+
this.database.prepare(`
|
|
804
|
+
UPDATE conversation_bindings
|
|
805
|
+
SET status = 'revoked', updated_at = ?, version = version + 1
|
|
806
|
+
WHERE status = 'active' AND principal_id IN (
|
|
807
|
+
SELECT id FROM delivery_principals
|
|
808
|
+
WHERE role = 'linked' AND status = 'active' AND id <> ?
|
|
809
|
+
AND (linked_to_id IS NULL OR linked_to_id <> ?)
|
|
810
|
+
)
|
|
811
|
+
`).run(now, replacement.id, replacement.id);
|
|
812
|
+
this.database.prepare(`
|
|
813
|
+
UPDATE delivery_principals
|
|
814
|
+
SET status = 'revoked', updated_at = ?, version = version + 1
|
|
815
|
+
WHERE role = 'linked' AND status = 'active' AND id <> ?
|
|
816
|
+
AND (linked_to_id IS NULL OR linked_to_id <> ?)
|
|
817
|
+
`).run(now, replacement.id, replacement.id);
|
|
818
|
+
for (const owner of activeOwners) {
|
|
819
|
+
if (owner.id === replacement.id)
|
|
820
|
+
continue;
|
|
821
|
+
this.database.prepare(`
|
|
822
|
+
UPDATE delivery_principals
|
|
823
|
+
SET status = 'revoked', updated_at = ?, version = version + 1
|
|
824
|
+
WHERE id = ? AND role = 'owner' AND status = 'active'
|
|
825
|
+
`).run(now, owner.id);
|
|
826
|
+
this.database.prepare(`
|
|
827
|
+
UPDATE conversation_bindings
|
|
828
|
+
SET status = 'revoked', updated_at = ?, version = version + 1
|
|
829
|
+
WHERE principal_id = ? AND status = 'active'
|
|
830
|
+
`).run(now, owner.id);
|
|
831
|
+
}
|
|
832
|
+
// A challenge issued before the handoff must not be able to reactivate a
|
|
833
|
+
// retired owner after setup has returned successfully.
|
|
834
|
+
this.database.prepare(`
|
|
835
|
+
UPDATE pairing_challenges SET status = 'expired', updated_at = ? WHERE status = 'active'
|
|
836
|
+
`).run(now);
|
|
837
|
+
const result = this.database.prepare(`
|
|
838
|
+
SELECT id, principal_json, role, status, linked_to_id, created_at, updated_at, version
|
|
839
|
+
FROM delivery_principals WHERE id = ?
|
|
840
|
+
`).get(replacement.id);
|
|
841
|
+
return principalFromRow(result);
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
isAuthorizedPrincipal(input) {
|
|
845
|
+
this.assertOpen();
|
|
846
|
+
const row = this.database.prepare('SELECT status FROM delivery_principals WHERE key_hash = ?')
|
|
847
|
+
.get(principalHash(input));
|
|
848
|
+
return row?.status === 'active';
|
|
849
|
+
}
|
|
850
|
+
getPrincipal(input) {
|
|
851
|
+
this.assertOpen();
|
|
852
|
+
const row = this.database.prepare(`
|
|
853
|
+
SELECT id, principal_json, role, status, linked_to_id, created_at, updated_at, version
|
|
854
|
+
FROM delivery_principals WHERE key_hash = ?
|
|
855
|
+
`).get(principalHash(input));
|
|
856
|
+
return row === undefined ? undefined : principalFromRow(row);
|
|
857
|
+
}
|
|
858
|
+
createBinding(input) {
|
|
859
|
+
this.assertOpen();
|
|
860
|
+
const target = canonicalTarget({ conversation: input.conversation, principal: input.principal });
|
|
861
|
+
if (!isAbsolute(input.workspace))
|
|
862
|
+
throw new DeliveryStoreError('invalid-binding', 'binding workspace must be absolute');
|
|
863
|
+
const workspace = validateBindingText(input.workspace, 'workspace', 4_096);
|
|
864
|
+
const agentPreset = validateBindingText(input.agentPreset, 'agentPreset', 128);
|
|
865
|
+
const sessionId = validateBindingText(input.sessionId, 'sessionId', 512);
|
|
866
|
+
const policyRef = validateBindingText(input.policyRef, 'policyRef', 256);
|
|
867
|
+
if (input.expectedGeneration !== undefined
|
|
868
|
+
&& (!Number.isSafeInteger(input.expectedGeneration) || input.expectedGeneration < 1)) {
|
|
869
|
+
throw new DeliveryStoreError('invalid-binding', 'binding expectedGeneration is invalid');
|
|
870
|
+
}
|
|
871
|
+
const hash = conversationHash(target.conversation);
|
|
872
|
+
const canonicalConversationJson = conversationJson(target.conversation);
|
|
873
|
+
const canonicalPrincipalJson = principalJson(target.principal);
|
|
874
|
+
const canonicalPrincipalHash = principalHash(target.principal);
|
|
875
|
+
const now = this.now();
|
|
876
|
+
const id = `binding_${randomUUID()}`;
|
|
877
|
+
return this.transaction(() => {
|
|
878
|
+
const principalRow = this.database.prepare(`
|
|
879
|
+
SELECT id, principal_json, role, status, linked_to_id, created_at, updated_at, version
|
|
880
|
+
FROM delivery_principals
|
|
881
|
+
WHERE key_hash = ? AND principal_json = ? AND status = 'active'
|
|
882
|
+
`).get(canonicalPrincipalHash, canonicalPrincipalJson);
|
|
883
|
+
if (principalRow === undefined) {
|
|
884
|
+
throw new DeliveryStoreError('unauthorized-principal', 'binding principal is not active');
|
|
885
|
+
}
|
|
886
|
+
const principal = principalFromRow(principalRow);
|
|
887
|
+
const existingRow = this.database.prepare(`
|
|
888
|
+
${bindingSelect}
|
|
889
|
+
WHERE conversation_hash = ? AND conversation_json = ? AND status = 'active'
|
|
890
|
+
`).get(hash, canonicalConversationJson);
|
|
891
|
+
if (existingRow !== undefined) {
|
|
892
|
+
const existing = bindingFromRow(existingRow);
|
|
893
|
+
if (principalHash(existing.principal) !== canonicalPrincipalHash) {
|
|
894
|
+
throw new DeliveryStoreError('conflict', 'conversation is already bound to another principal');
|
|
895
|
+
}
|
|
896
|
+
if (input.expectedGeneration !== undefined && existing.generation !== input.expectedGeneration) {
|
|
897
|
+
throw new DeliveryStoreError('version-conflict', 'binding generation changed before creation');
|
|
898
|
+
}
|
|
899
|
+
return existing;
|
|
900
|
+
}
|
|
901
|
+
const generation = this.nextBindingGenerationByHash(hash);
|
|
902
|
+
if (input.expectedGeneration !== undefined && generation !== input.expectedGeneration) {
|
|
903
|
+
throw new DeliveryStoreError('version-conflict', 'binding generation changed before creation');
|
|
904
|
+
}
|
|
905
|
+
this.database.prepare(`
|
|
906
|
+
INSERT INTO conversation_bindings (
|
|
907
|
+
id, conversation_hash, conversation_json, principal_id, principal_json, workspace, agent_preset,
|
|
908
|
+
session_id, generation, policy_ref, status, created_at, updated_at, version
|
|
909
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, 1)
|
|
910
|
+
`).run(id, hash, canonicalConversationJson, principal.id, canonicalPrincipalJson, workspace, agentPreset, sessionId, generation, policyRef, now, now);
|
|
911
|
+
return this.getBinding(id);
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
/**
|
|
915
|
+
* Snapshot the generation a newly-created session must use. `createBinding`
|
|
916
|
+
* revalidates this value under `BEGIN IMMEDIATE` when passed as
|
|
917
|
+
* `expectedGeneration`, so a concurrent generation change fails closed.
|
|
918
|
+
*/
|
|
919
|
+
nextBindingGeneration(input) {
|
|
920
|
+
this.assertOpen();
|
|
921
|
+
return this.nextBindingGenerationByHash(conversationHash(canonicalConversation(input)));
|
|
922
|
+
}
|
|
923
|
+
getActiveBinding(input) {
|
|
924
|
+
this.assertOpen();
|
|
925
|
+
const row = this.database.prepare(`${bindingSelect} WHERE conversation_hash = ? AND status = 'active'`)
|
|
926
|
+
.get(conversationHash(input));
|
|
927
|
+
return row === undefined ? undefined : bindingFromRow(row);
|
|
928
|
+
}
|
|
929
|
+
getBinding(id) {
|
|
930
|
+
this.assertOpen();
|
|
931
|
+
const row = this.database.prepare(`${bindingSelect} WHERE id = ?`).get(id);
|
|
932
|
+
return row === undefined ? undefined : bindingFromRow(row);
|
|
933
|
+
}
|
|
934
|
+
listBindings(input) {
|
|
935
|
+
this.assertOpen();
|
|
936
|
+
return this.database.prepare(`${bindingSelect} WHERE conversation_hash = ? ORDER BY generation DESC`)
|
|
937
|
+
.all(conversationHash(input)).map(bindingFromRow);
|
|
938
|
+
}
|
|
939
|
+
/** Read-only operator query. Callers must apply their own exact route checks. */
|
|
940
|
+
listActiveBindings() {
|
|
941
|
+
this.assertOpen();
|
|
942
|
+
return this.database.prepare(`${bindingSelect} WHERE status = 'active' ORDER BY created_at, id`)
|
|
943
|
+
.all().map(bindingFromRow);
|
|
944
|
+
}
|
|
945
|
+
getBindingBySession(sessionId) {
|
|
946
|
+
this.assertOpen();
|
|
947
|
+
const row = this.database.prepare(`${bindingSelect} WHERE session_id = ?`).get(sessionId);
|
|
948
|
+
return row === undefined ? undefined : bindingFromRow(row);
|
|
949
|
+
}
|
|
950
|
+
getModelSelection(input) {
|
|
951
|
+
this.assertOpen();
|
|
952
|
+
const conversation = canonicalConversation(input);
|
|
953
|
+
const row = this.database.prepare(`
|
|
954
|
+
SELECT provider, model, reasoning_effort, updated_at, version
|
|
955
|
+
FROM conversation_model_selections
|
|
956
|
+
WHERE conversation_hash = ? AND conversation_json = ?
|
|
957
|
+
`).get(conversationHash(conversation), conversationJson(conversation));
|
|
958
|
+
return row === undefined ? undefined : {
|
|
959
|
+
provider: row.provider,
|
|
960
|
+
model: row.model,
|
|
961
|
+
...(row.reasoning_effort === null ? {} : { reasoningEffort: row.reasoning_effort }),
|
|
962
|
+
updatedAt: row.updated_at,
|
|
963
|
+
version: row.version,
|
|
964
|
+
};
|
|
965
|
+
}
|
|
966
|
+
setModelSelection(input, route) {
|
|
967
|
+
this.assertOpen();
|
|
968
|
+
const conversation = canonicalConversation(input);
|
|
969
|
+
const provider = modelRoutePart(route.provider, 'provider');
|
|
970
|
+
const model = modelRoutePart(route.model, 'model');
|
|
971
|
+
const reasoningEffort = route.reasoningEffort === undefined
|
|
972
|
+
? undefined
|
|
973
|
+
: modelRoutePart(route.reasoningEffort, 'effort');
|
|
974
|
+
const current = this.getModelSelection(conversation);
|
|
975
|
+
if (current?.provider === provider && current.model === model
|
|
976
|
+
&& current.reasoningEffort === reasoningEffort)
|
|
977
|
+
return current;
|
|
978
|
+
const now = this.now();
|
|
979
|
+
this.database.prepare(`
|
|
980
|
+
INSERT INTO conversation_model_selections (
|
|
981
|
+
conversation_hash, conversation_json, provider, model, reasoning_effort, updated_at, version
|
|
982
|
+
) VALUES (?, ?, ?, ?, ?, ?, 1)
|
|
983
|
+
ON CONFLICT(conversation_hash) DO UPDATE SET
|
|
984
|
+
conversation_json = excluded.conversation_json,
|
|
985
|
+
provider = excluded.provider,
|
|
986
|
+
model = excluded.model,
|
|
987
|
+
reasoning_effort = excluded.reasoning_effort,
|
|
988
|
+
updated_at = excluded.updated_at,
|
|
989
|
+
version = conversation_model_selections.version + 1
|
|
990
|
+
`).run(conversationHash(conversation), conversationJson(conversation), provider, model, reasoningEffort ?? null, now);
|
|
991
|
+
return this.getModelSelection(conversation);
|
|
992
|
+
}
|
|
993
|
+
clearModelSelection(input) {
|
|
994
|
+
this.assertOpen();
|
|
995
|
+
const conversation = canonicalConversation(input);
|
|
996
|
+
return this.database.prepare(`
|
|
997
|
+
DELETE FROM conversation_model_selections
|
|
998
|
+
WHERE conversation_hash = ? AND conversation_json = ?
|
|
999
|
+
`).run(conversationHash(conversation), conversationJson(conversation)).changes === 1;
|
|
1000
|
+
}
|
|
1001
|
+
beginModelCommand(input) {
|
|
1002
|
+
this.assertOpen();
|
|
1003
|
+
const conversation = canonicalConversation(input);
|
|
1004
|
+
return this.transaction(() => this.advanceModelCommandEpoch(conversation));
|
|
1005
|
+
}
|
|
1006
|
+
commitModelCommand(input) {
|
|
1007
|
+
this.assertOpen();
|
|
1008
|
+
if (!Number.isSafeInteger(input.expectedEpoch) || input.expectedEpoch < 1) {
|
|
1009
|
+
throw new DeliveryStoreError('version-conflict', 'model command epoch is invalid');
|
|
1010
|
+
}
|
|
1011
|
+
const conversation = canonicalConversation(input.conversation);
|
|
1012
|
+
const route = input.route === undefined ? undefined : canonicalModelRoute(input.route);
|
|
1013
|
+
return this.transaction(() => {
|
|
1014
|
+
const current = this.database.prepare(`
|
|
1015
|
+
SELECT epoch FROM conversation_model_epochs
|
|
1016
|
+
WHERE conversation_hash = ? AND conversation_json = ?
|
|
1017
|
+
`).get(conversationHash(conversation), conversationJson(conversation));
|
|
1018
|
+
if (current?.epoch !== input.expectedEpoch)
|
|
1019
|
+
return { applied: false };
|
|
1020
|
+
if (route === undefined) {
|
|
1021
|
+
this.clearModelSelection(conversation);
|
|
1022
|
+
return { applied: true };
|
|
1023
|
+
}
|
|
1024
|
+
return { applied: true, selection: this.setModelSelection(conversation, route) };
|
|
1025
|
+
});
|
|
1026
|
+
}
|
|
1027
|
+
rotateBinding(input) {
|
|
1028
|
+
this.assertOpen();
|
|
1029
|
+
const sessionId = validateBindingText(input.sessionId, 'sessionId', 512);
|
|
1030
|
+
const now = this.now();
|
|
1031
|
+
const id = `binding_${randomUUID()}`;
|
|
1032
|
+
return this.transaction(() => {
|
|
1033
|
+
const current = this.getBinding(input.bindingId);
|
|
1034
|
+
if (current === undefined || current.status !== 'active' || current.version !== input.expectedVersion) {
|
|
1035
|
+
throw new DeliveryStoreError('version-conflict', 'active binding version changed or does not exist');
|
|
1036
|
+
}
|
|
1037
|
+
const generation = this.nextBindingGenerationByHash(conversationHash(current.conversation));
|
|
1038
|
+
if (generation !== current.generation + 1) {
|
|
1039
|
+
throw new DeliveryStoreError('version-conflict', 'binding generation changed before rotation');
|
|
1040
|
+
}
|
|
1041
|
+
const updated = this.database.prepare(`
|
|
1042
|
+
UPDATE conversation_bindings SET status = 'revoked', updated_at = ?, version = version + 1
|
|
1043
|
+
WHERE id = ? AND status = 'active' AND version = ?
|
|
1044
|
+
`).run(now, current.id, input.expectedVersion);
|
|
1045
|
+
if (updated.changes !== 1)
|
|
1046
|
+
throw new DeliveryStoreError('version-conflict', 'active binding version changed');
|
|
1047
|
+
const principal = this.getPrincipal(current.principal);
|
|
1048
|
+
if (principal?.status !== 'active')
|
|
1049
|
+
throw new DeliveryStoreError('unauthorized-principal', 'binding principal is not active');
|
|
1050
|
+
this.database.prepare(`
|
|
1051
|
+
INSERT INTO conversation_bindings (
|
|
1052
|
+
id, conversation_hash, conversation_json, principal_id, principal_json, workspace, agent_preset,
|
|
1053
|
+
session_id, generation, policy_ref, status, created_at, updated_at, version
|
|
1054
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, 1)
|
|
1055
|
+
`).run(id, conversationHash(current.conversation), conversationJson(current.conversation), principal.id, principalJson(current.principal), current.workspace, current.agentPreset, sessionId, generation, current.policyRef, now, now);
|
|
1056
|
+
return this.getBinding(id);
|
|
1057
|
+
});
|
|
1058
|
+
}
|
|
1059
|
+
/**
|
|
1060
|
+
* Consume one exact `/new` Inbox while rotating its binding.
|
|
1061
|
+
*
|
|
1062
|
+
* The Inbox transition is part of the same SQLite commit as revoking the
|
|
1063
|
+
* previous binding and inserting its successor. Consequently a provider
|
|
1064
|
+
* replay can observe either the entirely old state or the entirely new
|
|
1065
|
+
* state, never a rotated binding paired with an unconsumed command.
|
|
1066
|
+
*/
|
|
1067
|
+
rotateBindingAndQueueCommand(input) {
|
|
1068
|
+
this.assertOpen();
|
|
1069
|
+
const bindingId = validateBindingText(input.bindingId, 'bindingId', 256);
|
|
1070
|
+
const inboxId = validateBindingText(input.inboxId, 'inboxId', 256);
|
|
1071
|
+
const sessionId = validateBindingText(input.sessionId, 'sessionId', 512);
|
|
1072
|
+
if (!Number.isSafeInteger(input.expectedVersion) || input.expectedVersion < 1) {
|
|
1073
|
+
throw new DeliveryStoreError('version-conflict', 'active binding version is invalid');
|
|
1074
|
+
}
|
|
1075
|
+
const now = this.now();
|
|
1076
|
+
const nextBindingId = `binding_${randomUUID()}`;
|
|
1077
|
+
return this.transaction(() => {
|
|
1078
|
+
const current = this.getBinding(bindingId);
|
|
1079
|
+
const command = this.getInbox(inboxId);
|
|
1080
|
+
if (current === undefined || current.status !== 'active' || current.version !== input.expectedVersion) {
|
|
1081
|
+
throw new DeliveryStoreError('version-conflict', 'active binding version changed or does not exist');
|
|
1082
|
+
}
|
|
1083
|
+
if (command === undefined) {
|
|
1084
|
+
throw new DeliveryStoreError('not-found', 'new-session command Inbox was not found');
|
|
1085
|
+
}
|
|
1086
|
+
if (!['received', 'authorized'].includes(command.status)
|
|
1087
|
+
|| !isExactDeliveryCommand(parseDeliveryCommand(command.envelope), 'new', 'clear')) {
|
|
1088
|
+
throw new DeliveryStoreError('conflict', 'Inbox is not an unconsumed exact new-session command');
|
|
1089
|
+
}
|
|
1090
|
+
if (conversationHash(command.envelope.conversation) !== conversationHash(current.conversation)
|
|
1091
|
+
|| principalHash(command.envelope.principal) !== principalHash(current.principal)) {
|
|
1092
|
+
throw new DeliveryStoreError('conflict', 'new-session command does not belong to the binding');
|
|
1093
|
+
}
|
|
1094
|
+
const generation = this.nextBindingGenerationByHash(conversationHash(current.conversation));
|
|
1095
|
+
if (generation !== current.generation + 1) {
|
|
1096
|
+
throw new DeliveryStoreError('version-conflict', 'binding generation changed before rotation');
|
|
1097
|
+
}
|
|
1098
|
+
const principal = this.getPrincipal(current.principal);
|
|
1099
|
+
if (principal?.status !== 'active') {
|
|
1100
|
+
throw new DeliveryStoreError('unauthorized-principal', 'binding principal is not active');
|
|
1101
|
+
}
|
|
1102
|
+
const revoked = this.database.prepare(`
|
|
1103
|
+
UPDATE conversation_bindings SET status = 'revoked', updated_at = ?, version = version + 1
|
|
1104
|
+
WHERE id = ? AND status = 'active' AND version = ?
|
|
1105
|
+
`).run(now, current.id, input.expectedVersion);
|
|
1106
|
+
if (revoked.changes !== 1) {
|
|
1107
|
+
throw new DeliveryStoreError('version-conflict', 'active binding version changed');
|
|
1108
|
+
}
|
|
1109
|
+
this.database.prepare(`
|
|
1110
|
+
INSERT INTO conversation_bindings (
|
|
1111
|
+
id, conversation_hash, conversation_json, principal_id, principal_json, workspace, agent_preset,
|
|
1112
|
+
session_id, generation, policy_ref, status, created_at, updated_at, version
|
|
1113
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, 1)
|
|
1114
|
+
`).run(nextBindingId, conversationHash(current.conversation), conversationJson(current.conversation), principal.id, principalJson(current.principal), current.workspace, current.agentPreset, sessionId, generation, current.policyRef, now, now);
|
|
1115
|
+
const queued = this.database.prepare(`
|
|
1116
|
+
UPDATE inbox_messages
|
|
1117
|
+
SET status = 'queued', binding_id = ?, next_attempt_at = NULL, failure_code = NULL,
|
|
1118
|
+
claimed_by = NULL, fencing_token = NULL, lease_until = NULL, updated_at = ?
|
|
1119
|
+
WHERE id = ? AND status IN ('received', 'authorized')
|
|
1120
|
+
`).run(nextBindingId, now, inboxId);
|
|
1121
|
+
if (queued.changes !== 1) {
|
|
1122
|
+
throw new DeliveryStoreError('conflict', 'new-session command queue transition lost a race');
|
|
1123
|
+
}
|
|
1124
|
+
return {
|
|
1125
|
+
binding: this.getBinding(nextBindingId),
|
|
1126
|
+
inbox: this.getInbox(inboxId),
|
|
1127
|
+
};
|
|
1128
|
+
});
|
|
1129
|
+
}
|
|
1130
|
+
acceptInbound(input) {
|
|
1131
|
+
this.assertOpen();
|
|
1132
|
+
const envelope = canonicalEnvelope(input, this.maxTextBytes);
|
|
1133
|
+
const json = JSON.stringify(envelope);
|
|
1134
|
+
const hash = digest(json);
|
|
1135
|
+
const existing = this.database.prepare(`${inboxSelect} WHERE channel = ? AND account = ? AND event_id = ?`)
|
|
1136
|
+
.get(envelope.channel, envelope.account, envelope.eventId);
|
|
1137
|
+
if (existing !== undefined) {
|
|
1138
|
+
if (existing.envelope_hash !== hash) {
|
|
1139
|
+
throw new DeliveryStoreError('idempotency-conflict', 'provider event id was reused with a different envelope');
|
|
1140
|
+
}
|
|
1141
|
+
return { duplicate: true, record: inboxFromRow(existing) };
|
|
1142
|
+
}
|
|
1143
|
+
const now = this.now();
|
|
1144
|
+
const id = `inbox_${randomUUID()}`;
|
|
1145
|
+
try {
|
|
1146
|
+
this.transaction(() => {
|
|
1147
|
+
this.database.prepare(`
|
|
1148
|
+
INSERT INTO inbox_messages (
|
|
1149
|
+
id, channel, account, event_id, envelope_hash, envelope_json, status,
|
|
1150
|
+
attempt_count, received_at, updated_at
|
|
1151
|
+
) VALUES (?, ?, ?, ?, ?, ?, 'received', 0, ?, ?)
|
|
1152
|
+
`).run(id, envelope.channel, envelope.account, envelope.eventId, hash, json, now, now);
|
|
1153
|
+
for (const [ordinal, attachment] of (envelope.attachments ?? []).entries()) {
|
|
1154
|
+
const descriptorHash = digest(JSON.stringify(attachment));
|
|
1155
|
+
this.database.prepare(`
|
|
1156
|
+
INSERT INTO delivery_attachments (
|
|
1157
|
+
id, owner_kind, owner_id, ordinal, media_type, size_bytes, sha256, spool_ref,
|
|
1158
|
+
resource_kind, provider_ref, file_name, status, expires_at, created_at
|
|
1159
|
+
) VALUES (?, 'inbox', ?, ?, ?, ?, ?, NULL, ?, ?, ?, 'metadata', NULL, ?)
|
|
1160
|
+
`).run(`attachment_${digest(`${id}:${ordinal}:${attachment.providerRef}`).slice(0, 40)}`, id, ordinal, attachment.mediaType ?? '', attachment.sizeBytes ?? 0, descriptorHash, attachment.resourceType, attachment.providerRef, attachment.fileName ?? null, now);
|
|
1161
|
+
}
|
|
1162
|
+
});
|
|
1163
|
+
}
|
|
1164
|
+
catch (error) {
|
|
1165
|
+
const winner = this.database.prepare(`${inboxSelect} WHERE channel = ? AND account = ? AND event_id = ?`)
|
|
1166
|
+
.get(envelope.channel, envelope.account, envelope.eventId);
|
|
1167
|
+
if (winner !== undefined && winner.envelope_hash === hash)
|
|
1168
|
+
return { duplicate: true, record: inboxFromRow(winner) };
|
|
1169
|
+
throw error;
|
|
1170
|
+
}
|
|
1171
|
+
return { duplicate: false, record: this.getInbox(id) };
|
|
1172
|
+
}
|
|
1173
|
+
listAttachments(input) {
|
|
1174
|
+
this.assertOpen();
|
|
1175
|
+
const rows = this.database.prepare(`
|
|
1176
|
+
SELECT id, owner_kind, owner_id, ordinal, media_type, size_bytes, sha256, spool_ref,
|
|
1177
|
+
resource_kind, provider_ref, file_name, status, expires_at, created_at
|
|
1178
|
+
FROM delivery_attachments WHERE owner_kind = ? AND owner_id = ? ORDER BY ordinal
|
|
1179
|
+
`).all(input.ownerKind, input.ownerId);
|
|
1180
|
+
return rows.map(attachmentFromRow);
|
|
1181
|
+
}
|
|
1182
|
+
/**
|
|
1183
|
+
* Return complete durable image references in the provider descriptor order.
|
|
1184
|
+
* `undefined` means the image descriptors have not been materialized yet.
|
|
1185
|
+
*/
|
|
1186
|
+
listReadyInboundImageRefs(inboxId) {
|
|
1187
|
+
this.assertOpen();
|
|
1188
|
+
const rows = this.database.prepare(`
|
|
1189
|
+
SELECT id, owner_kind, owner_id, ordinal, media_type, size_bytes, sha256, spool_ref,
|
|
1190
|
+
resource_kind, provider_ref, file_name, status, expires_at, created_at
|
|
1191
|
+
FROM delivery_attachments
|
|
1192
|
+
WHERE owner_kind = 'inbox' AND owner_id = ? AND resource_kind = 'image'
|
|
1193
|
+
ORDER BY ordinal
|
|
1194
|
+
`).all(inboxId);
|
|
1195
|
+
if (rows.length === 0)
|
|
1196
|
+
return [];
|
|
1197
|
+
if (rows.every(row => row.status === 'metadata'))
|
|
1198
|
+
return undefined;
|
|
1199
|
+
if (rows.some(row => row.status !== 'ready')) {
|
|
1200
|
+
throw new DeliveryStoreError('conflict', 'inbound images have inconsistent materialization state');
|
|
1201
|
+
}
|
|
1202
|
+
return rows.map(persistedImageRef);
|
|
1203
|
+
}
|
|
1204
|
+
/** Atomically persist one ordered image batch while the caller still owns the live inbox lease. */
|
|
1205
|
+
commitInboundImageRefs(input) {
|
|
1206
|
+
this.assertOpen();
|
|
1207
|
+
if (typeof input.inboxId !== 'string' || input.inboxId.length < 1 || input.inboxId.length > 256
|
|
1208
|
+
|| typeof input.ownerId !== 'string' || input.ownerId.length < 1 || input.ownerId.length > 256
|
|
1209
|
+
|| /\p{Cc}/u.test(input.inboxId) || /\p{Cc}/u.test(input.ownerId)
|
|
1210
|
+
|| !Number.isSafeInteger(input.fencingToken) || input.fencingToken < 1) {
|
|
1211
|
+
throw new DeliveryStoreError('stale-fence', 'inbound image commit has an invalid fence');
|
|
1212
|
+
}
|
|
1213
|
+
if (!Array.isArray(input.images)) {
|
|
1214
|
+
throw new DeliveryStoreError('conflict', 'inbound image reference batch is invalid');
|
|
1215
|
+
}
|
|
1216
|
+
const now = this.now();
|
|
1217
|
+
return this.transaction(() => {
|
|
1218
|
+
const claim = this.database.prepare(`
|
|
1219
|
+
SELECT binding.status AS binding_status, principal.status AS principal_status
|
|
1220
|
+
FROM inbox_messages AS inbox
|
|
1221
|
+
LEFT JOIN conversation_bindings AS binding ON binding.id = inbox.binding_id
|
|
1222
|
+
LEFT JOIN delivery_principals AS principal ON principal.id = binding.principal_id
|
|
1223
|
+
WHERE inbox.id = ? AND inbox.status = 'claimed' AND inbox.claimed_by = ?
|
|
1224
|
+
AND inbox.fencing_token = ? AND inbox.lease_until > ?
|
|
1225
|
+
`).get(input.inboxId, input.ownerId, input.fencingToken, now);
|
|
1226
|
+
if (claim === undefined) {
|
|
1227
|
+
throw new DeliveryStoreError('stale-fence', 'inbound image commit has a stale fence');
|
|
1228
|
+
}
|
|
1229
|
+
if (claim.binding_status !== 'active' || claim.principal_status !== 'active') {
|
|
1230
|
+
throw new DeliveryStoreError('unauthorized-principal', 'inbound image authority was revoked before commit');
|
|
1231
|
+
}
|
|
1232
|
+
const rows = this.database.prepare(`
|
|
1233
|
+
SELECT id, owner_kind, owner_id, ordinal, media_type, size_bytes, sha256, spool_ref,
|
|
1234
|
+
resource_kind, provider_ref, file_name, status, expires_at, created_at
|
|
1235
|
+
FROM delivery_attachments
|
|
1236
|
+
WHERE owner_kind = 'inbox' AND owner_id = ? AND resource_kind = 'image'
|
|
1237
|
+
ORDER BY ordinal
|
|
1238
|
+
`).all(input.inboxId);
|
|
1239
|
+
if (rows.length !== input.images.length) {
|
|
1240
|
+
throw new DeliveryStoreError('conflict', 'inbound image reference count does not match its descriptors');
|
|
1241
|
+
}
|
|
1242
|
+
const images = input.images.map(image => {
|
|
1243
|
+
if (image === null || typeof image !== 'object' || Array.isArray(image)
|
|
1244
|
+
|| !Object.hasOwn(image, 'ref') || !Object.hasOwn(image, 'contentSha256')
|
|
1245
|
+
|| Object.keys(image).some(key => !['ref', 'contentSha256'].includes(key))
|
|
1246
|
+
|| typeof image.contentSha256 !== 'string' || !/^[a-f0-9]{64}$/u.test(image.contentSha256)) {
|
|
1247
|
+
invalidImageRef();
|
|
1248
|
+
}
|
|
1249
|
+
return { ref: canonicalImageRef(image.ref), contentSha256: image.contentSha256 };
|
|
1250
|
+
});
|
|
1251
|
+
if (rows.every(row => row.status === 'ready')) {
|
|
1252
|
+
const persisted = rows.map(persistedImageRef);
|
|
1253
|
+
if (persisted.some((ref, index) => !sameImageRef(ref, images[index].ref)
|
|
1254
|
+
|| rows[index].sha256 !== images[index].contentSha256)) {
|
|
1255
|
+
throw new DeliveryStoreError('conflict', 'inbound image references are immutable');
|
|
1256
|
+
}
|
|
1257
|
+
return persisted;
|
|
1258
|
+
}
|
|
1259
|
+
if (rows.some(row => row.status !== 'metadata')) {
|
|
1260
|
+
throw new DeliveryStoreError('conflict', 'inbound images have inconsistent materialization state');
|
|
1261
|
+
}
|
|
1262
|
+
for (const [index, row] of rows.entries()) {
|
|
1263
|
+
const image = images[index];
|
|
1264
|
+
const changed = this.database.prepare(`
|
|
1265
|
+
UPDATE delivery_attachments
|
|
1266
|
+
SET media_type = ?, size_bytes = ?, sha256 = ?, spool_ref = ?, status = 'ready'
|
|
1267
|
+
WHERE id = ? AND owner_kind = 'inbox' AND owner_id = ? AND resource_kind = 'image'
|
|
1268
|
+
AND status = 'metadata'
|
|
1269
|
+
`).run(image.ref.mediaType, image.ref.bytes, image.contentSha256, JSON.stringify(image.ref), row.id, input.inboxId);
|
|
1270
|
+
if (changed.changes !== 1) {
|
|
1271
|
+
throw new DeliveryStoreError('conflict', 'inbound image batch changed during commit');
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
return images.map(image => image.ref);
|
|
1275
|
+
});
|
|
1276
|
+
}
|
|
1277
|
+
queueInbox(inboxId, bindingId) {
|
|
1278
|
+
this.assertOpen();
|
|
1279
|
+
const record = this.getInbox(inboxId);
|
|
1280
|
+
const binding = this.getBinding(bindingId);
|
|
1281
|
+
if (record === undefined || binding === undefined || binding.status !== 'active') {
|
|
1282
|
+
throw new DeliveryStoreError('not-found', 'inbox or active binding was not found');
|
|
1283
|
+
}
|
|
1284
|
+
if (conversationHash(record.envelope.conversation) !== conversationHash(binding.conversation)
|
|
1285
|
+
|| principalHash(record.envelope.principal) !== principalHash(binding.principal))
|
|
1286
|
+
throw new DeliveryStoreError('conflict', 'inbox envelope does not belong to the binding');
|
|
1287
|
+
if (record.status === 'queued' && record.bindingId === bindingId)
|
|
1288
|
+
return record;
|
|
1289
|
+
if (record.status !== 'received' && record.status !== 'authorized') {
|
|
1290
|
+
throw new DeliveryStoreError('conflict', `cannot queue inbox in ${record.status}`);
|
|
1291
|
+
}
|
|
1292
|
+
const now = this.now();
|
|
1293
|
+
this.transaction(() => {
|
|
1294
|
+
if (record.status === 'received') {
|
|
1295
|
+
this.database.prepare(`
|
|
1296
|
+
UPDATE inbox_messages SET status = 'authorized', binding_id = ?, updated_at = ?
|
|
1297
|
+
WHERE id = ? AND status = 'received'
|
|
1298
|
+
`).run(bindingId, now, inboxId);
|
|
1299
|
+
}
|
|
1300
|
+
const queued = this.database.prepare(`
|
|
1301
|
+
UPDATE inbox_messages SET status = 'queued', binding_id = ?, next_attempt_at = NULL, updated_at = ?
|
|
1302
|
+
WHERE id = ? AND status = 'authorized'
|
|
1303
|
+
`).run(bindingId, now, inboxId);
|
|
1304
|
+
if (queued.changes !== 1)
|
|
1305
|
+
throw new DeliveryStoreError('conflict', 'inbox queue transition lost a race');
|
|
1306
|
+
});
|
|
1307
|
+
return this.getInbox(inboxId);
|
|
1308
|
+
}
|
|
1309
|
+
/**
|
|
1310
|
+
* Establish a durable command boundary before `/stop` or `/new` is queued.
|
|
1311
|
+
*
|
|
1312
|
+
* Every earlier record in the same binding that has not crossed the exact
|
|
1313
|
+
* dispatch marker becomes terminal in one transaction. A normal claim
|
|
1314
|
+
* carrying `dispatch-started` is deliberately left live because the external
|
|
1315
|
+
* Agent may have observed it already. Exact permission commands instead get
|
|
1316
|
+
* a durable cancelled-recovery marker before the runtime is asked to abort.
|
|
1317
|
+
*/
|
|
1318
|
+
cancelUndispatchedInboxBefore(input) {
|
|
1319
|
+
this.assertOpen();
|
|
1320
|
+
const bindingId = validateBindingText(input.bindingId, 'bindingId', 256);
|
|
1321
|
+
const beforeInboxId = validateBindingText(input.beforeInboxId, 'beforeInboxId', 256);
|
|
1322
|
+
const failureCode = validateBindingText(input.failureCode, 'failureCode', 256);
|
|
1323
|
+
const boundary = this.getInbox(beforeInboxId);
|
|
1324
|
+
const binding = this.getBinding(bindingId);
|
|
1325
|
+
if (boundary === undefined || binding === undefined || binding.status !== 'active') {
|
|
1326
|
+
throw new DeliveryStoreError('not-found', 'inbox cancellation boundary or active binding was not found');
|
|
1327
|
+
}
|
|
1328
|
+
if (conversationHash(boundary.envelope.conversation) !== conversationHash(binding.conversation)
|
|
1329
|
+
|| principalHash(boundary.envelope.principal) !== principalHash(binding.principal)) {
|
|
1330
|
+
throw new DeliveryStoreError('conflict', 'inbox cancellation boundary does not belong to the binding');
|
|
1331
|
+
}
|
|
1332
|
+
const bindingConversationJson = conversationJson(binding.conversation);
|
|
1333
|
+
const bindingPrincipalJson = principalJson(binding.principal);
|
|
1334
|
+
const now = this.now();
|
|
1335
|
+
return this.transaction(() => {
|
|
1336
|
+
const boundaryRow = this.database.prepare('SELECT rowid FROM inbox_messages WHERE id = ?')
|
|
1337
|
+
.get(beforeInboxId);
|
|
1338
|
+
if (boundaryRow === undefined) {
|
|
1339
|
+
throw new DeliveryStoreError('not-found', 'inbox cancellation boundary was not found');
|
|
1340
|
+
}
|
|
1341
|
+
const dispatchingRows = this.database.prepare(`
|
|
1342
|
+
SELECT id, envelope_json FROM inbox_messages
|
|
1343
|
+
WHERE rowid < ? AND status = 'claimed' AND failure_code = 'dispatch-started'
|
|
1344
|
+
AND (binding_id = ? OR (
|
|
1345
|
+
json_extract(envelope_json, '$.conversation') = json(?)
|
|
1346
|
+
AND json_extract(envelope_json, '$.principal') = json(?)
|
|
1347
|
+
))
|
|
1348
|
+
ORDER BY rowid
|
|
1349
|
+
`).all(boundaryRow.rowid, bindingId, bindingConversationJson, bindingPrincipalJson);
|
|
1350
|
+
for (const row of dispatchingRows) {
|
|
1351
|
+
let permission = false;
|
|
1352
|
+
try {
|
|
1353
|
+
permission = isPermissionDeliveryCommand(JSON.parse(row.envelope_json));
|
|
1354
|
+
}
|
|
1355
|
+
catch { }
|
|
1356
|
+
if (!permission)
|
|
1357
|
+
continue;
|
|
1358
|
+
const marked = this.database.prepare(`
|
|
1359
|
+
UPDATE inbox_messages SET failure_code = 'permission-cancelled-recovery', updated_at = ?
|
|
1360
|
+
WHERE id = ? AND status = 'claimed' AND failure_code = 'dispatch-started'
|
|
1361
|
+
`).run(now, row.id);
|
|
1362
|
+
if (marked.changes !== 1) {
|
|
1363
|
+
throw new DeliveryStoreError('conflict', 'permission cancellation marker lost its dispatch fence');
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
const claimedRows = this.database.prepare(`
|
|
1367
|
+
SELECT id FROM inbox_messages
|
|
1368
|
+
WHERE rowid < ? AND status = 'claimed' AND failure_code IS NULL
|
|
1369
|
+
AND (binding_id = ? OR (
|
|
1370
|
+
json_extract(envelope_json, '$.conversation') = json(?)
|
|
1371
|
+
AND json_extract(envelope_json, '$.principal') = json(?)
|
|
1372
|
+
))
|
|
1373
|
+
ORDER BY rowid
|
|
1374
|
+
`).all(boundaryRow.rowid, bindingId, bindingConversationJson, bindingPrincipalJson);
|
|
1375
|
+
this.database.prepare(`
|
|
1376
|
+
UPDATE inbox_attempts SET status = 'dead_letter', failure_code = ?, finished_at = ?
|
|
1377
|
+
WHERE status = 'claimed' AND inbox_id IN (
|
|
1378
|
+
SELECT id FROM inbox_messages
|
|
1379
|
+
WHERE rowid < ? AND status = 'claimed' AND failure_code IS NULL
|
|
1380
|
+
AND (binding_id = ? OR (
|
|
1381
|
+
json_extract(envelope_json, '$.conversation') = json(?)
|
|
1382
|
+
AND json_extract(envelope_json, '$.principal') = json(?)
|
|
1383
|
+
))
|
|
1384
|
+
)
|
|
1385
|
+
`).run(failureCode, now, boundaryRow.rowid, bindingId, bindingConversationJson, bindingPrincipalJson);
|
|
1386
|
+
const cancelled = this.database.prepare(`
|
|
1387
|
+
UPDATE inbox_messages SET status = 'dead_letter', failure_code = ?, next_attempt_at = NULL,
|
|
1388
|
+
claimed_by = NULL, fencing_token = NULL, lease_until = NULL, updated_at = ?
|
|
1389
|
+
WHERE rowid < ?
|
|
1390
|
+
AND (binding_id = ? OR (
|
|
1391
|
+
json_extract(envelope_json, '$.conversation') = json(?)
|
|
1392
|
+
AND json_extract(envelope_json, '$.principal') = json(?)
|
|
1393
|
+
))
|
|
1394
|
+
AND (status IN ('received', 'authorized', 'queued', 'retry_wait')
|
|
1395
|
+
OR (status = 'claimed' AND failure_code IS NULL))
|
|
1396
|
+
`).run(failureCode, now, boundaryRow.rowid, bindingId, bindingConversationJson, bindingPrincipalJson);
|
|
1397
|
+
const cancelledCount = Number(cancelled.changes);
|
|
1398
|
+
if (!Number.isSafeInteger(cancelledCount)) {
|
|
1399
|
+
throw new DeliveryStoreError('conflict', 'inbox cancellation count is outside the safe integer range');
|
|
1400
|
+
}
|
|
1401
|
+
return {
|
|
1402
|
+
cancelled: cancelledCount,
|
|
1403
|
+
dispatching: dispatchingRows.length,
|
|
1404
|
+
claimedInboxIds: claimedRows.map(row => row.id),
|
|
1405
|
+
dispatchingInboxIds: dispatchingRows.map(row => row.id),
|
|
1406
|
+
};
|
|
1407
|
+
});
|
|
1408
|
+
}
|
|
1409
|
+
getInbox(id) {
|
|
1410
|
+
this.assertOpen();
|
|
1411
|
+
const row = this.database.prepare(`${inboxSelect} WHERE id = ?`).get(id);
|
|
1412
|
+
return row === undefined ? undefined : inboxFromRow(row);
|
|
1413
|
+
}
|
|
1414
|
+
getInboxByProviderEvent(channel, account, eventId) {
|
|
1415
|
+
this.assertOpen();
|
|
1416
|
+
const row = this.database.prepare(`${inboxSelect} WHERE channel = ? AND account = ? AND event_id = ?`)
|
|
1417
|
+
.get(channel, account, eventId);
|
|
1418
|
+
return row === undefined ? undefined : inboxFromRow(row);
|
|
1419
|
+
}
|
|
1420
|
+
/** Return the oldest durable admission that did not finish before another Inbox. */
|
|
1421
|
+
findPendingInboundBefore(input) {
|
|
1422
|
+
this.assertOpen();
|
|
1423
|
+
const target = canonicalTarget({ conversation: input.conversation, principal: input.principal });
|
|
1424
|
+
const beforeInboxId = validateBindingText(input.beforeInboxId, 'beforeInboxId', 256);
|
|
1425
|
+
const boundary = this.database.prepare('SELECT rowid FROM inbox_messages WHERE id = ?')
|
|
1426
|
+
.get(beforeInboxId);
|
|
1427
|
+
if (boundary === undefined)
|
|
1428
|
+
throw new DeliveryStoreError('not-found', 'inbound recovery boundary Inbox was not found');
|
|
1429
|
+
const row = this.database.prepare(`${inboxSelect}
|
|
1430
|
+
WHERE rowid < ? AND status IN ('received', 'authorized')
|
|
1431
|
+
AND json_extract(envelope_json, '$.conversation') = json(?)
|
|
1432
|
+
AND json_extract(envelope_json, '$.principal') = json(?)
|
|
1433
|
+
ORDER BY rowid LIMIT 1
|
|
1434
|
+
`).get(boundary.rowid, conversationJson(target.conversation), principalJson(target.principal));
|
|
1435
|
+
return row === undefined ? undefined : inboxFromRow(row);
|
|
1436
|
+
}
|
|
1437
|
+
listInbox(input = {}) {
|
|
1438
|
+
this.assertOpen();
|
|
1439
|
+
const limit = Math.max(1, Math.min(100, input.limit ?? 20));
|
|
1440
|
+
const rows = input.bindingId === undefined
|
|
1441
|
+
? this.database.prepare(`${inboxSelect} ORDER BY received_at DESC, rowid DESC LIMIT ?`).all(limit)
|
|
1442
|
+
: this.database.prepare(`${inboxSelect} WHERE binding_id = ? ORDER BY received_at DESC, rowid DESC LIMIT ?`)
|
|
1443
|
+
.all(input.bindingId, limit);
|
|
1444
|
+
return rows.map(inboxFromRow);
|
|
1445
|
+
}
|
|
1446
|
+
health() {
|
|
1447
|
+
this.assertOpen();
|
|
1448
|
+
const scalar = (sql) => this.database.prepare(sql).get().count;
|
|
1449
|
+
return {
|
|
1450
|
+
pendingInbox: scalar("SELECT COUNT(*) AS count FROM inbox_messages WHERE status IN ('received', 'authorized', 'queued', 'claimed', 'retry_wait')"),
|
|
1451
|
+
deadLetterInbox: scalar("SELECT COUNT(*) AS count FROM inbox_messages WHERE status = 'dead_letter'"),
|
|
1452
|
+
pendingOutbox: scalar("SELECT COUNT(*) AS count FROM outbox_messages WHERE status IN ('pending', 'attempting', 'retry_wait')"),
|
|
1453
|
+
deadLetterOutbox: scalar("SELECT COUNT(*) AS count FROM outbox_messages WHERE status = 'dead'"),
|
|
1454
|
+
unknownOutbox: scalar("SELECT COUNT(*) AS count FROM outbox_messages WHERE status = 'unknown_after_send'"),
|
|
1455
|
+
};
|
|
1456
|
+
}
|
|
1457
|
+
getApprovalDispatchCursor() {
|
|
1458
|
+
this.assertOpen();
|
|
1459
|
+
const row = this.database.prepare(`
|
|
1460
|
+
SELECT after_created_at, after_proposal_id, version
|
|
1461
|
+
FROM approval_dispatch_cursor WHERE singleton = 1
|
|
1462
|
+
`).get();
|
|
1463
|
+
if (row === undefined)
|
|
1464
|
+
return { version: 0 };
|
|
1465
|
+
return {
|
|
1466
|
+
version: row.version,
|
|
1467
|
+
...(row.after_created_at === null || row.after_proposal_id === null
|
|
1468
|
+
? {}
|
|
1469
|
+
: { after: { createdAt: row.after_created_at, proposalId: row.after_proposal_id } }),
|
|
1470
|
+
};
|
|
1471
|
+
}
|
|
1472
|
+
advanceApprovalDispatchCursor(input) {
|
|
1473
|
+
this.assertOpen();
|
|
1474
|
+
if (!Number.isSafeInteger(input.expectedVersion) || input.expectedVersion < 0) {
|
|
1475
|
+
throw new DeliveryStoreError('stale-fence', 'approval dispatch cursor fence is invalid');
|
|
1476
|
+
}
|
|
1477
|
+
let after;
|
|
1478
|
+
if (input.after !== undefined) {
|
|
1479
|
+
if (!Number.isSafeInteger(input.after.createdAt) || input.after.createdAt < 0) {
|
|
1480
|
+
throw new DeliveryStoreError('stale-fence', 'approval dispatch cursor time is invalid');
|
|
1481
|
+
}
|
|
1482
|
+
after = { createdAt: input.after.createdAt,
|
|
1483
|
+
proposalId: validateBindingText(input.after.proposalId, 'proposalId', 256) };
|
|
1484
|
+
}
|
|
1485
|
+
return this.transaction(() => {
|
|
1486
|
+
const current = this.getApprovalDispatchCursor();
|
|
1487
|
+
if (current.version !== input.expectedVersion) {
|
|
1488
|
+
throw new DeliveryStoreError('stale-fence', 'approval dispatch cursor changed');
|
|
1489
|
+
}
|
|
1490
|
+
const now = this.now();
|
|
1491
|
+
const values = [after?.createdAt ?? null, after?.proposalId ?? null];
|
|
1492
|
+
const changed = current.version === 0
|
|
1493
|
+
? this.database.prepare(`
|
|
1494
|
+
INSERT INTO approval_dispatch_cursor (
|
|
1495
|
+
singleton, after_created_at, after_proposal_id, version, updated_at
|
|
1496
|
+
) VALUES (1, ?, ?, 1, ?)
|
|
1497
|
+
ON CONFLICT(singleton) DO NOTHING
|
|
1498
|
+
`).run(...values, now)
|
|
1499
|
+
: this.database.prepare(`
|
|
1500
|
+
UPDATE approval_dispatch_cursor
|
|
1501
|
+
SET after_created_at = ?, after_proposal_id = ?, version = version + 1, updated_at = ?
|
|
1502
|
+
WHERE singleton = 1 AND version = ?
|
|
1503
|
+
`).run(...values, now, input.expectedVersion);
|
|
1504
|
+
if (changed.changes !== 1) {
|
|
1505
|
+
throw new DeliveryStoreError('stale-fence', 'approval dispatch cursor changed');
|
|
1506
|
+
}
|
|
1507
|
+
return this.getApprovalDispatchCursor();
|
|
1508
|
+
});
|
|
1509
|
+
}
|
|
1510
|
+
deadLetterInbox(inboxId, failureCode) {
|
|
1511
|
+
this.assertOpen();
|
|
1512
|
+
const failure = validateBindingText(failureCode, 'failureCode', 256);
|
|
1513
|
+
const now = this.now();
|
|
1514
|
+
const changed = this.database.prepare(`
|
|
1515
|
+
UPDATE inbox_messages SET status = 'dead_letter', failure_code = ?, next_attempt_at = NULL,
|
|
1516
|
+
claimed_by = NULL, fencing_token = NULL, lease_until = NULL, updated_at = ?
|
|
1517
|
+
WHERE id = ? AND status NOT IN ('processed', 'dead_letter')
|
|
1518
|
+
`).run(failure, now, inboxId);
|
|
1519
|
+
if (changed.changes !== 1) {
|
|
1520
|
+
const current = this.getInbox(inboxId);
|
|
1521
|
+
if (current?.status === 'dead_letter')
|
|
1522
|
+
return current;
|
|
1523
|
+
throw new DeliveryStoreError('conflict', 'inbox cannot be dead-lettered in its current state');
|
|
1524
|
+
}
|
|
1525
|
+
return this.getInbox(inboxId);
|
|
1526
|
+
}
|
|
1527
|
+
claimInbox(input) {
|
|
1528
|
+
this.assertOpen();
|
|
1529
|
+
const ownerId = validateBindingText(input.ownerId, 'ownerId', 256);
|
|
1530
|
+
if (!Number.isSafeInteger(input.leaseMs) || input.leaseMs < 1)
|
|
1531
|
+
throw new DeliveryStoreError('conflict', 'invalid inbox lease');
|
|
1532
|
+
if (!Number.isSafeInteger(input.limit) || input.limit < 1 || input.limit > 100) {
|
|
1533
|
+
throw new DeliveryStoreError('conflict', 'invalid inbox claim limit');
|
|
1534
|
+
}
|
|
1535
|
+
if (!Number.isSafeInteger(input.maxAttempts) || input.maxAttempts < 1 || input.maxAttempts > 100) {
|
|
1536
|
+
throw new DeliveryStoreError('conflict', 'invalid inbox max attempts');
|
|
1537
|
+
}
|
|
1538
|
+
const maintenanceLimit = boundedMaintenanceLimit(input.maintenanceLimit, input.limit, 'inbox');
|
|
1539
|
+
const now = this.now();
|
|
1540
|
+
const claims = [];
|
|
1541
|
+
this.transaction(() => {
|
|
1542
|
+
this.database.prepare(`
|
|
1543
|
+
UPDATE inbox_messages SET status = 'dead_letter', failure_code = 'attempts-exhausted',
|
|
1544
|
+
next_attempt_at = NULL, updated_at = ?
|
|
1545
|
+
WHERE rowid IN (
|
|
1546
|
+
SELECT rowid FROM inbox_messages
|
|
1547
|
+
WHERE status = 'retry_wait' AND next_attempt_at <= ? AND attempt_count >= ?
|
|
1548
|
+
AND (failure_code IS NULL OR failure_code NOT IN (
|
|
1549
|
+
'permission-dispatch-recovery',
|
|
1550
|
+
'permission-cancelled-recovery',
|
|
1551
|
+
'permission-failure-notice-recovery'
|
|
1552
|
+
))
|
|
1553
|
+
ORDER BY rowid LIMIT ?
|
|
1554
|
+
)
|
|
1555
|
+
`).run(now, now, input.maxAttempts, maintenanceLimit);
|
|
1556
|
+
const candidates = this.database.prepare(`
|
|
1557
|
+
SELECT candidate.id FROM inbox_messages AS candidate
|
|
1558
|
+
WHERE candidate.status IN ('queued', 'retry_wait')
|
|
1559
|
+
AND (candidate.next_attempt_at IS NULL OR candidate.next_attempt_at <= ?)
|
|
1560
|
+
AND (candidate.status = 'queued' OR candidate.attempt_count < ? OR candidate.failure_code IN (
|
|
1561
|
+
'permission-dispatch-recovery',
|
|
1562
|
+
'permission-cancelled-recovery',
|
|
1563
|
+
'permission-failure-notice-recovery'
|
|
1564
|
+
))
|
|
1565
|
+
AND candidate.binding_id IS NOT NULL
|
|
1566
|
+
AND NOT EXISTS (
|
|
1567
|
+
SELECT 1 FROM inbox_messages AS earlier
|
|
1568
|
+
WHERE earlier.binding_id = candidate.binding_id
|
|
1569
|
+
AND earlier.rowid < candidate.rowid
|
|
1570
|
+
AND earlier.status NOT IN ('processed', 'dead_letter')
|
|
1571
|
+
)
|
|
1572
|
+
AND NOT EXISTS (
|
|
1573
|
+
SELECT 1 FROM inbox_messages AS active
|
|
1574
|
+
WHERE active.binding_id = candidate.binding_id AND active.status = 'claimed'
|
|
1575
|
+
)
|
|
1576
|
+
ORDER BY candidate.rowid LIMIT ?
|
|
1577
|
+
`).all(now, input.maxAttempts, input.limit);
|
|
1578
|
+
for (const candidate of candidates) {
|
|
1579
|
+
const current = this.getInbox(candidate.id);
|
|
1580
|
+
const fencingToken = current.attemptCount + 1;
|
|
1581
|
+
const changed = this.database.prepare(`
|
|
1582
|
+
UPDATE inbox_messages SET status = 'claimed', claimed_by = ?, fencing_token = ?, lease_until = ?,
|
|
1583
|
+
attempt_count = attempt_count + 1, next_attempt_at = NULL,
|
|
1584
|
+
failure_code = CASE WHEN failure_code IN (
|
|
1585
|
+
'permission-dispatch-recovery',
|
|
1586
|
+
'permission-cancelled-recovery',
|
|
1587
|
+
'permission-failure-notice-recovery'
|
|
1588
|
+
) THEN failure_code ELSE NULL END,
|
|
1589
|
+
updated_at = ?
|
|
1590
|
+
WHERE id = ? AND status IN ('queued', 'retry_wait')
|
|
1591
|
+
`).run(ownerId, fencingToken, now + input.leaseMs, now, current.id);
|
|
1592
|
+
if (changed.changes !== 1)
|
|
1593
|
+
continue;
|
|
1594
|
+
this.database.prepare(`
|
|
1595
|
+
INSERT INTO inbox_attempts (id, inbox_id, attempt_number, owner_id, fencing_token, status, created_at)
|
|
1596
|
+
VALUES (?, ?, ?, ?, ?, 'claimed', ?)
|
|
1597
|
+
`).run(`inbox_attempt_${randomUUID()}`, current.id, fencingToken, ownerId, fencingToken, now);
|
|
1598
|
+
claims.push({ record: this.getInbox(current.id), fencingToken });
|
|
1599
|
+
}
|
|
1600
|
+
});
|
|
1601
|
+
return claims;
|
|
1602
|
+
}
|
|
1603
|
+
finishInbox(input) {
|
|
1604
|
+
this.assertOpen();
|
|
1605
|
+
const now = this.now();
|
|
1606
|
+
if (input.outcome === 'retry_wait' && (!Number.isSafeInteger(input.retryAt) || input.retryAt < now)) {
|
|
1607
|
+
throw new DeliveryStoreError('conflict', 'retry_wait requires a current or future retryAt');
|
|
1608
|
+
}
|
|
1609
|
+
this.transaction(() => {
|
|
1610
|
+
const changed = this.database.prepare(`
|
|
1611
|
+
UPDATE inbox_messages SET status = ?, next_attempt_at = ?, claimed_by = NULL, fencing_token = NULL,
|
|
1612
|
+
lease_until = NULL, failure_code = ?, updated_at = ?
|
|
1613
|
+
WHERE id = ? AND status = 'claimed' AND claimed_by = ? AND fencing_token = ? AND lease_until > ?
|
|
1614
|
+
`).run(input.outcome, input.outcome === 'retry_wait' ? input.retryAt : null, input.failureCode ?? null, now, input.inboxId, input.ownerId, input.fencingToken, now);
|
|
1615
|
+
if (changed.changes !== 1)
|
|
1616
|
+
throw new DeliveryStoreError('stale-fence', 'inbox completion has a stale fence');
|
|
1617
|
+
this.database.prepare(`
|
|
1618
|
+
UPDATE inbox_attempts SET status = ?, failure_code = ?, finished_at = ?
|
|
1619
|
+
WHERE inbox_id = ? AND owner_id = ? AND fencing_token = ? AND status = 'claimed'
|
|
1620
|
+
`).run(input.outcome, input.failureCode ?? null, now, input.inboxId, input.ownerId, input.fencingToken);
|
|
1621
|
+
});
|
|
1622
|
+
return this.getInbox(input.inboxId);
|
|
1623
|
+
}
|
|
1624
|
+
markInboxDispatching(input) {
|
|
1625
|
+
this.assertOpen();
|
|
1626
|
+
let bindingId;
|
|
1627
|
+
let sessionId;
|
|
1628
|
+
let bindingConversationJson;
|
|
1629
|
+
let bindingPrincipalJson;
|
|
1630
|
+
try {
|
|
1631
|
+
bindingId = validateBindingText(input.binding.id, 'binding.id', 256);
|
|
1632
|
+
sessionId = validateBindingText(input.binding.sessionId, 'binding.sessionId', 512);
|
|
1633
|
+
bindingConversationJson = conversationJson(input.binding.conversation);
|
|
1634
|
+
bindingPrincipalJson = principalJson(input.binding.principal);
|
|
1635
|
+
}
|
|
1636
|
+
catch {
|
|
1637
|
+
throw new DeliveryStoreError('invalid-binding', 'inbox dispatch binding snapshot is invalid');
|
|
1638
|
+
}
|
|
1639
|
+
if (!Number.isSafeInteger(input.binding.version) || input.binding.version < 1
|
|
1640
|
+
|| !Number.isSafeInteger(input.binding.generation) || input.binding.generation < 1) {
|
|
1641
|
+
throw new DeliveryStoreError('invalid-binding', 'inbox dispatch binding snapshot is invalid');
|
|
1642
|
+
}
|
|
1643
|
+
const now = this.now();
|
|
1644
|
+
return this.transaction(() => {
|
|
1645
|
+
const changed = this.database.prepare(`
|
|
1646
|
+
UPDATE inbox_messages AS inbox SET failure_code = CASE WHEN failure_code IN (
|
|
1647
|
+
'permission-dispatch-recovery',
|
|
1648
|
+
'permission-cancelled-recovery',
|
|
1649
|
+
'permission-failure-notice-recovery'
|
|
1650
|
+
) THEN failure_code ELSE 'dispatch-started' END, updated_at = ?
|
|
1651
|
+
WHERE inbox.id = ? AND inbox.status = 'claimed' AND inbox.claimed_by = ?
|
|
1652
|
+
AND inbox.fencing_token = ? AND inbox.lease_until > ? AND inbox.binding_id = ?
|
|
1653
|
+
AND EXISTS (
|
|
1654
|
+
SELECT 1 FROM conversation_bindings AS binding
|
|
1655
|
+
JOIN delivery_principals AS principal ON principal.id = binding.principal_id
|
|
1656
|
+
WHERE binding.id = inbox.binding_id AND binding.id = ? AND binding.status = 'active'
|
|
1657
|
+
AND binding.version = ? AND binding.session_id = ? AND binding.generation = ?
|
|
1658
|
+
AND binding.conversation_json = ? AND binding.principal_json = ?
|
|
1659
|
+
AND principal.status = 'active' AND principal.key_hash = ? AND principal.principal_json = ?
|
|
1660
|
+
)
|
|
1661
|
+
`).run(now, input.inboxId, input.ownerId, input.fencingToken, now, bindingId, bindingId, input.binding.version, sessionId, input.binding.generation, bindingConversationJson, bindingPrincipalJson, principalHash(input.binding.principal), bindingPrincipalJson);
|
|
1662
|
+
if (changed.changes === 1)
|
|
1663
|
+
return this.getInbox(input.inboxId);
|
|
1664
|
+
const claim = this.database.prepare(`
|
|
1665
|
+
SELECT binding_id FROM inbox_messages
|
|
1666
|
+
WHERE id = ? AND status = 'claimed' AND claimed_by = ? AND fencing_token = ? AND lease_until > ?
|
|
1667
|
+
`).get(input.inboxId, input.ownerId, input.fencingToken, now);
|
|
1668
|
+
if (claim === undefined) {
|
|
1669
|
+
throw new DeliveryStoreError('stale-fence', 'inbox dispatch marker has a stale fence');
|
|
1670
|
+
}
|
|
1671
|
+
const principal = this.database.prepare(`
|
|
1672
|
+
SELECT status FROM delivery_principals WHERE key_hash = ? AND principal_json = ?
|
|
1673
|
+
`).get(principalHash(input.binding.principal), bindingPrincipalJson);
|
|
1674
|
+
if (principal?.status !== 'active') {
|
|
1675
|
+
throw new DeliveryStoreError('unauthorized-principal', 'inbox dispatch principal is not active');
|
|
1676
|
+
}
|
|
1677
|
+
throw new DeliveryStoreError('invalid-binding', 'inbox dispatch binding snapshot is no longer active');
|
|
1678
|
+
});
|
|
1679
|
+
}
|
|
1680
|
+
renewInboxClaim(input) {
|
|
1681
|
+
this.assertOpen();
|
|
1682
|
+
const ownerId = validateBindingText(input.ownerId, 'ownerId', 256);
|
|
1683
|
+
if (!Number.isSafeInteger(input.fencingToken) || input.fencingToken < 1
|
|
1684
|
+
|| !Number.isSafeInteger(input.leaseMs) || input.leaseMs < 1) {
|
|
1685
|
+
throw new DeliveryStoreError('stale-fence', 'inbox lease renewal is invalid');
|
|
1686
|
+
}
|
|
1687
|
+
const now = this.now();
|
|
1688
|
+
return this.database.prepare(`
|
|
1689
|
+
UPDATE inbox_messages SET lease_until = ?, updated_at = ?
|
|
1690
|
+
WHERE id = ? AND status = 'claimed' AND claimed_by = ? AND fencing_token = ? AND lease_until > ?
|
|
1691
|
+
`).run(now + input.leaseMs, now, input.inboxId, ownerId, input.fencingToken, now).changes === 1;
|
|
1692
|
+
}
|
|
1693
|
+
recoverInbox(input) {
|
|
1694
|
+
this.assertOpen();
|
|
1695
|
+
const limit = boundedMaintenanceLimit(input.limit, 100, 'inbox recovery');
|
|
1696
|
+
const now = this.now();
|
|
1697
|
+
const recovered = [];
|
|
1698
|
+
this.transaction(() => {
|
|
1699
|
+
const rows = this.database.prepare(`
|
|
1700
|
+
SELECT id, event_id, binding_id, attempt_count, failure_code, envelope_json FROM inbox_messages
|
|
1701
|
+
WHERE status = 'claimed' AND lease_until <= ? ORDER BY rowid LIMIT ?
|
|
1702
|
+
`).all(now, limit);
|
|
1703
|
+
for (const row of rows) {
|
|
1704
|
+
let interruptedPermission = false;
|
|
1705
|
+
if (row.failure_code === 'dispatch-started') {
|
|
1706
|
+
try {
|
|
1707
|
+
interruptedPermission = isPermissionDeliveryCommand(JSON.parse(row.envelope_json));
|
|
1708
|
+
}
|
|
1709
|
+
catch { }
|
|
1710
|
+
}
|
|
1711
|
+
const existingPermissionRecovery = permissionDispatchRecoveryFromFailureCode(row.failure_code ?? undefined);
|
|
1712
|
+
const permissionCommand = interruptedPermission || existingPermissionRecovery !== undefined;
|
|
1713
|
+
const terminalOutbox = !permissionCommand || row.binding_id === null
|
|
1714
|
+
? undefined
|
|
1715
|
+
: this.database.prepare(`
|
|
1716
|
+
SELECT id FROM outbox_messages
|
|
1717
|
+
WHERE idempotency_key IN (?, ?) AND binding_id = ?
|
|
1718
|
+
AND json_valid(intent_json)
|
|
1719
|
+
AND json_extract(intent_json, '$.replyToEventId') = ?
|
|
1720
|
+
`).get(`inbound:${row.id}:reply`, `inbound:${row.event_id}:reply`, row.binding_id, row.event_id);
|
|
1721
|
+
if (terminalOutbox !== undefined) {
|
|
1722
|
+
const changed = this.database.prepare(`
|
|
1723
|
+
UPDATE inbox_messages SET status = 'processed', next_attempt_at = NULL, claimed_by = NULL,
|
|
1724
|
+
fencing_token = NULL, lease_until = NULL, failure_code = NULL, updated_at = ?
|
|
1725
|
+
WHERE id = ? AND status = 'claimed' AND lease_until <= ?
|
|
1726
|
+
`).run(now, row.id, now);
|
|
1727
|
+
if (changed.changes !== 1)
|
|
1728
|
+
continue;
|
|
1729
|
+
this.database.prepare(`
|
|
1730
|
+
UPDATE inbox_attempts SET status = 'processed', failure_code = NULL, finished_at = ?
|
|
1731
|
+
WHERE inbox_id = ? AND status = 'claimed'
|
|
1732
|
+
`).run(now, row.id);
|
|
1733
|
+
recovered.push(row.id);
|
|
1734
|
+
continue;
|
|
1735
|
+
}
|
|
1736
|
+
const permissionRecovery = interruptedPermission
|
|
1737
|
+
? permissionDispatchRecoveryCode('commit')
|
|
1738
|
+
: existingPermissionRecovery === undefined
|
|
1739
|
+
? undefined
|
|
1740
|
+
: permissionDispatchRecoveryCode(existingPermissionRecovery);
|
|
1741
|
+
const ambiguous = row.failure_code === 'dispatch-started' && permissionRecovery === undefined;
|
|
1742
|
+
const exhausted = row.attempt_count >= input.maxAttempts && permissionRecovery === undefined;
|
|
1743
|
+
const changed = this.database.prepare(`
|
|
1744
|
+
UPDATE inbox_messages SET status = ?, next_attempt_at = ?, claimed_by = NULL,
|
|
1745
|
+
fencing_token = NULL, lease_until = NULL, failure_code = ?, updated_at = ?
|
|
1746
|
+
WHERE id = ? AND status = 'claimed' AND lease_until <= ?
|
|
1747
|
+
`).run(ambiguous || exhausted ? 'dead_letter' : 'retry_wait', ambiguous || exhausted ? null : now, ambiguous ? 'dispatch-ambiguous' : permissionRecovery ?? 'lease-expired', now, row.id, now);
|
|
1748
|
+
if (changed.changes !== 1)
|
|
1749
|
+
continue;
|
|
1750
|
+
this.database.prepare(`
|
|
1751
|
+
UPDATE inbox_attempts SET status = 'lost', failure_code = 'lease-expired', finished_at = ?
|
|
1752
|
+
WHERE inbox_id = ? AND status = 'claimed'
|
|
1753
|
+
`).run(now, row.id);
|
|
1754
|
+
recovered.push(row.id);
|
|
1755
|
+
}
|
|
1756
|
+
});
|
|
1757
|
+
return recovered.map(id => this.getInbox(id));
|
|
1758
|
+
}
|
|
1759
|
+
enqueue(input) {
|
|
1760
|
+
this.assertOpen();
|
|
1761
|
+
const binding = this.getBinding(input.bindingId);
|
|
1762
|
+
if (binding === undefined || binding.status !== 'active') {
|
|
1763
|
+
throw new DeliveryStoreError('invalid-intent', 'outbound intent requires an active binding');
|
|
1764
|
+
}
|
|
1765
|
+
const intent = canonicalIntent(input, binding, this.maxTextBytes);
|
|
1766
|
+
if (intent.idempotencyKey.startsWith('inbound:') && intent.idempotencyKey.endsWith(':reply')) {
|
|
1767
|
+
const replyEventId = intent.replyToEventId;
|
|
1768
|
+
const inbox = replyEventId === undefined
|
|
1769
|
+
? undefined
|
|
1770
|
+
: this.getInboxByProviderEvent(binding.conversation.channel, binding.conversation.account, replyEventId);
|
|
1771
|
+
const currentKey = inbox === undefined ? undefined : `inbound:${inbox.id}:reply`;
|
|
1772
|
+
const legacyKey = replyEventId === undefined ? undefined : `inbound:${replyEventId}:reply`;
|
|
1773
|
+
if (inbox === undefined
|
|
1774
|
+
|| inbox.bindingId !== binding.id
|
|
1775
|
+
|| conversationHash(inbox.envelope.conversation) !== conversationHash(binding.conversation)
|
|
1776
|
+
|| principalHash(inbox.envelope.principal) !== principalHash(binding.principal)
|
|
1777
|
+
|| (intent.idempotencyKey !== currentKey && intent.idempotencyKey !== legacyKey)) {
|
|
1778
|
+
throw new DeliveryStoreError('invalid-intent', 'inbound reply idempotency namespace requires the exact bound Inbox event');
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
const json = JSON.stringify(intent);
|
|
1782
|
+
const hash = digest(json);
|
|
1783
|
+
const existing = this.database.prepare(`${outboxSelect} WHERE idempotency_key = ?`)
|
|
1784
|
+
.get(intent.idempotencyKey);
|
|
1785
|
+
if (existing !== undefined) {
|
|
1786
|
+
if (existing.intent_hash !== hash) {
|
|
1787
|
+
throw new DeliveryStoreError('idempotency-conflict', 'outbox idempotency key was reused with a different intent');
|
|
1788
|
+
}
|
|
1789
|
+
return outboxFromRow(existing);
|
|
1790
|
+
}
|
|
1791
|
+
const now = this.now();
|
|
1792
|
+
const id = `outbox_${randomUUID()}`;
|
|
1793
|
+
try {
|
|
1794
|
+
this.database.prepare(`
|
|
1795
|
+
INSERT INTO outbox_messages (
|
|
1796
|
+
id, idempotency_key, binding_id, intent_hash, intent_json, channel, account, lane_hash,
|
|
1797
|
+
status, attempt_count, created_at, updated_at
|
|
1798
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', 0, ?, ?)
|
|
1799
|
+
`).run(id, intent.idempotencyKey, intent.bindingId, hash, json, intent.target.conversation.channel, intent.target.conversation.account, conversationHash(intent.target.conversation), now, now);
|
|
1800
|
+
}
|
|
1801
|
+
catch (error) {
|
|
1802
|
+
const winner = this.database.prepare(`${outboxSelect} WHERE idempotency_key = ?`)
|
|
1803
|
+
.get(intent.idempotencyKey);
|
|
1804
|
+
if (winner !== undefined && winner.intent_hash === hash)
|
|
1805
|
+
return outboxFromRow(winner);
|
|
1806
|
+
throw error;
|
|
1807
|
+
}
|
|
1808
|
+
return this.getOutbox(id);
|
|
1809
|
+
}
|
|
1810
|
+
getOutbox(id) {
|
|
1811
|
+
this.assertOpen();
|
|
1812
|
+
const row = this.database.prepare(`${outboxSelect} WHERE id = ?`).get(id);
|
|
1813
|
+
return row === undefined ? undefined : outboxFromRow(row);
|
|
1814
|
+
}
|
|
1815
|
+
getApprovalIntent(operationId, bindingId) {
|
|
1816
|
+
this.assertOpen();
|
|
1817
|
+
const operation = validateBindingText(operationId, 'operationId', 256);
|
|
1818
|
+
const binding = validateBindingText(bindingId, 'bindingId', 256);
|
|
1819
|
+
const row = this.database.prepare(`${outboxSelect}
|
|
1820
|
+
WHERE json_extract(intent_json, '$.bindingId') = ?
|
|
1821
|
+
AND json_extract(intent_json, '$.approval.operationId') = ?
|
|
1822
|
+
ORDER BY created_at DESC, id DESC LIMIT 1`).get(binding, operation);
|
|
1823
|
+
return row === undefined ? undefined : outboxFromRow(row).intent.approval;
|
|
1824
|
+
}
|
|
1825
|
+
getModelPicker(operationId, bindingId) {
|
|
1826
|
+
this.assertOpen();
|
|
1827
|
+
const operation = validateBindingText(operationId, 'operationId', 256);
|
|
1828
|
+
const binding = validateBindingText(bindingId, 'bindingId', 256);
|
|
1829
|
+
const row = this.database.prepare(`${outboxSelect}
|
|
1830
|
+
WHERE json_extract(intent_json, '$.bindingId') = ?
|
|
1831
|
+
AND json_extract(intent_json, '$.modelPicker.operationId') = ?
|
|
1832
|
+
ORDER BY created_at DESC, id DESC LIMIT 1`).get(binding, operation);
|
|
1833
|
+
return row === undefined ? undefined : outboxFromRow(row).intent.modelPicker;
|
|
1834
|
+
}
|
|
1835
|
+
getPermissionPicker(operationId, bindingId) {
|
|
1836
|
+
return this.getPermissionPickerRecord(operationId, bindingId)?.intent.permissionPicker;
|
|
1837
|
+
}
|
|
1838
|
+
getPermissionPickerRecord(operationId, bindingId) {
|
|
1839
|
+
this.assertOpen();
|
|
1840
|
+
const operation = validateBindingText(operationId, 'operationId', 256);
|
|
1841
|
+
const bindingKey = validateBindingText(bindingId, 'bindingId', 256);
|
|
1842
|
+
const row = this.database.prepare(`${outboxSelect}
|
|
1843
|
+
WHERE binding_id = ? AND json_valid(intent_json)
|
|
1844
|
+
AND json_extract(intent_json, '$.permissionPicker.operationId') = ?
|
|
1845
|
+
ORDER BY created_at DESC, id DESC LIMIT 1`).get(bindingKey, operation);
|
|
1846
|
+
if (row === undefined)
|
|
1847
|
+
return undefined;
|
|
1848
|
+
const binding = this.getBinding(bindingKey);
|
|
1849
|
+
if (binding === undefined)
|
|
1850
|
+
throw new DeliveryStoreError('invalid-intent', 'permission picker binding does not exist');
|
|
1851
|
+
const record = outboxFromRow(row);
|
|
1852
|
+
const intent = canonicalIntent(record.intent, binding, this.maxTextBytes);
|
|
1853
|
+
if (intent.format !== 'permission-picker' || intent.permissionPicker?.operationId !== operation
|
|
1854
|
+
|| digest(JSON.stringify(intent)) !== record.intentHash) {
|
|
1855
|
+
throw new DeliveryStoreError('invalid-intent', 'persisted permission picker intent is invalid');
|
|
1856
|
+
}
|
|
1857
|
+
return { ...record, intent };
|
|
1858
|
+
}
|
|
1859
|
+
getModelPickerState(operationId, bindingId) {
|
|
1860
|
+
this.assertOpen();
|
|
1861
|
+
const operation = validateBindingText(operationId, 'operationId', 512);
|
|
1862
|
+
const binding = validateBindingText(bindingId, 'bindingId', 256);
|
|
1863
|
+
const row = this.database.prepare(`
|
|
1864
|
+
SELECT binding_id, revision, provider, model, reasoning_effort
|
|
1865
|
+
FROM model_picker_states WHERE operation_id = ? AND binding_id = ?
|
|
1866
|
+
`).get(operation, binding);
|
|
1867
|
+
return row === undefined ? undefined : modelPickerStateFromRow(row);
|
|
1868
|
+
}
|
|
1869
|
+
advanceModelPicker(input) {
|
|
1870
|
+
this.assertOpen();
|
|
1871
|
+
const operationId = validateBindingText(input.operationId, 'operationId', 512);
|
|
1872
|
+
const bindingId = validateBindingText(input.bindingId, 'bindingId', 256);
|
|
1873
|
+
const expected = canonicalModelPickerState(input.expected);
|
|
1874
|
+
const next = canonicalModelRoute(input.next);
|
|
1875
|
+
const binding = this.getBinding(bindingId);
|
|
1876
|
+
if (binding?.status !== 'active') {
|
|
1877
|
+
throw new DeliveryStoreError('invalid-binding', 'model picker requires an active binding');
|
|
1878
|
+
}
|
|
1879
|
+
return this.transaction(() => {
|
|
1880
|
+
const settlement = this.database.prepare(`
|
|
1881
|
+
SELECT operation_id FROM model_selection_settlements WHERE operation_id = ?
|
|
1882
|
+
`).get(operationId);
|
|
1883
|
+
if (settlement !== undefined) {
|
|
1884
|
+
throw new DeliveryStoreError('idempotency-conflict', 'model picker is already being settled');
|
|
1885
|
+
}
|
|
1886
|
+
const row = this.database.prepare(`
|
|
1887
|
+
SELECT binding_id, revision, provider, model, reasoning_effort
|
|
1888
|
+
FROM model_picker_states WHERE operation_id = ?
|
|
1889
|
+
`).get(operationId);
|
|
1890
|
+
if (row === undefined) {
|
|
1891
|
+
if (expected.revision !== 0) {
|
|
1892
|
+
throw new DeliveryStoreError('version-conflict', 'model picker state does not exist at the expected revision');
|
|
1893
|
+
}
|
|
1894
|
+
const now = this.now();
|
|
1895
|
+
this.database.prepare(`
|
|
1896
|
+
INSERT INTO model_picker_states (
|
|
1897
|
+
operation_id, binding_id, revision, provider, model, reasoning_effort, created_at, updated_at
|
|
1898
|
+
) VALUES (?, ?, 1, ?, ?, ?, ?, ?)
|
|
1899
|
+
`).run(operationId, bindingId, next.provider, next.model, next.reasoningEffort ?? null, now, now);
|
|
1900
|
+
return { applied: true, state: { ...next, revision: 1 } };
|
|
1901
|
+
}
|
|
1902
|
+
if (row.binding_id !== bindingId) {
|
|
1903
|
+
throw new DeliveryStoreError('idempotency-conflict', 'model picker operation belongs to another binding');
|
|
1904
|
+
}
|
|
1905
|
+
const current = modelPickerStateFromRow(row);
|
|
1906
|
+
if (!sameModelPickerState(current, expected))
|
|
1907
|
+
return { applied: false, state: current };
|
|
1908
|
+
if (current.revision >= Number.MAX_SAFE_INTEGER) {
|
|
1909
|
+
throw new DeliveryStoreError('version-conflict', 'model picker revision is exhausted');
|
|
1910
|
+
}
|
|
1911
|
+
const now = this.now();
|
|
1912
|
+
const revision = current.revision + 1;
|
|
1913
|
+
const changed = this.database.prepare(`
|
|
1914
|
+
UPDATE model_picker_states
|
|
1915
|
+
SET revision = ?, provider = ?, model = ?, reasoning_effort = ?, updated_at = ?
|
|
1916
|
+
WHERE operation_id = ? AND binding_id = ? AND revision = ?
|
|
1917
|
+
`).run(revision, next.provider, next.model, next.reasoningEffort ?? null, now, operationId, bindingId, current.revision);
|
|
1918
|
+
if (changed.changes !== 1) {
|
|
1919
|
+
throw new DeliveryStoreError('version-conflict', 'model picker state changed during navigation');
|
|
1920
|
+
}
|
|
1921
|
+
return { applied: true, state: { ...next, revision } };
|
|
1922
|
+
});
|
|
1923
|
+
}
|
|
1924
|
+
listOutbox(input = {}) {
|
|
1925
|
+
this.assertOpen();
|
|
1926
|
+
const limit = Math.max(1, Math.min(100, input.limit ?? 20));
|
|
1927
|
+
const rows = input.bindingId === undefined
|
|
1928
|
+
? this.database.prepare(`${outboxSelect} ORDER BY created_at DESC, id DESC LIMIT ?`).all(limit)
|
|
1929
|
+
: this.database.prepare(`${outboxSelect} WHERE json_extract(intent_json, '$.bindingId') = ? ORDER BY created_at DESC, id DESC LIMIT ?`)
|
|
1930
|
+
.all(input.bindingId, limit);
|
|
1931
|
+
return rows.map(outboxFromRow);
|
|
1932
|
+
}
|
|
1933
|
+
claimOutbox(input) {
|
|
1934
|
+
this.assertOpen();
|
|
1935
|
+
const ownerId = validateBindingText(input.ownerId, 'ownerId', 256);
|
|
1936
|
+
if (!Number.isSafeInteger(input.leaseMs) || input.leaseMs < 1)
|
|
1937
|
+
throw new DeliveryStoreError('conflict', 'invalid outbox lease');
|
|
1938
|
+
if (!Number.isSafeInteger(input.limit) || input.limit < 1 || input.limit > 100) {
|
|
1939
|
+
throw new DeliveryStoreError('conflict', 'invalid outbox claim limit');
|
|
1940
|
+
}
|
|
1941
|
+
if (!Number.isSafeInteger(input.maxAttempts) || input.maxAttempts < 1 || input.maxAttempts > 100) {
|
|
1942
|
+
throw new DeliveryStoreError('conflict', 'invalid outbox max attempts');
|
|
1943
|
+
}
|
|
1944
|
+
if ((input.unknownReconcileRoutes?.length ?? 0) > 100 || (input.excludeIds?.length ?? 0) > 100) {
|
|
1945
|
+
throw new DeliveryStoreError('conflict', 'outbox claim routes and exclusions must be bounded');
|
|
1946
|
+
}
|
|
1947
|
+
const maintenanceLimit = boundedMaintenanceLimit(input.maintenanceLimit, input.limit, 'outbox');
|
|
1948
|
+
const routes = input.unknownReconcileRoutes?.map(route => ({
|
|
1949
|
+
channel: validateBindingText(route.channel, 'channel', 128),
|
|
1950
|
+
account: validateBindingText(route.account, 'account', 128),
|
|
1951
|
+
}));
|
|
1952
|
+
const excludeIds = input.excludeIds?.map(id => validateBindingText(id, 'outboxId', 256)) ?? [];
|
|
1953
|
+
const routeClause = routes === undefined
|
|
1954
|
+
? ''
|
|
1955
|
+
: routes.length === 0
|
|
1956
|
+
? "AND candidate.status <> 'unknown_after_send'"
|
|
1957
|
+
: `AND (candidate.status <> 'unknown_after_send' OR (${routes
|
|
1958
|
+
.map(() => '(candidate.channel = ? AND candidate.account = ?)').join(' OR ')}))`;
|
|
1959
|
+
const excludeClause = excludeIds.length === 0
|
|
1960
|
+
? ''
|
|
1961
|
+
: `AND candidate.id NOT IN (${excludeIds.map(() => '?').join(', ')})`;
|
|
1962
|
+
const routeParameters = routes?.flatMap(route => [route.channel, route.account]) ?? [];
|
|
1963
|
+
const now = this.now();
|
|
1964
|
+
const claims = [];
|
|
1965
|
+
this.transaction(() => {
|
|
1966
|
+
this.database.prepare(`
|
|
1967
|
+
UPDATE outbox_messages SET status = 'dead', failure_code = 'attempts-exhausted',
|
|
1968
|
+
next_attempt_at = NULL, updated_at = ?
|
|
1969
|
+
WHERE rowid IN (
|
|
1970
|
+
SELECT candidate.rowid FROM outbox_messages AS candidate
|
|
1971
|
+
WHERE candidate.status = 'retry_wait' AND candidate.next_attempt_at <= ?
|
|
1972
|
+
AND (
|
|
1973
|
+
SELECT COUNT(*) FROM outbox_attempts AS history
|
|
1974
|
+
WHERE history.outbox_id = candidate.id AND history.operation = 'send'
|
|
1975
|
+
) >= ?
|
|
1976
|
+
ORDER BY candidate.rowid LIMIT ?
|
|
1977
|
+
)
|
|
1978
|
+
`).run(now, now, input.maxAttempts, maintenanceLimit);
|
|
1979
|
+
this.database.prepare(`
|
|
1980
|
+
UPDATE outbox_messages SET status = 'dead', failure_code = 'reconcile-attempts-exhausted',
|
|
1981
|
+
next_attempt_at = NULL, updated_at = ?
|
|
1982
|
+
WHERE rowid IN (
|
|
1983
|
+
SELECT candidate.rowid FROM outbox_messages AS candidate
|
|
1984
|
+
WHERE candidate.status = 'unknown_after_send'
|
|
1985
|
+
AND (
|
|
1986
|
+
SELECT COUNT(*) FROM outbox_attempts AS history
|
|
1987
|
+
WHERE history.outbox_id = candidate.id AND history.operation = 'reconcile'
|
|
1988
|
+
) >= ?
|
|
1989
|
+
ORDER BY candidate.rowid LIMIT ?
|
|
1990
|
+
)
|
|
1991
|
+
`).run(now, input.maxAttempts, maintenanceLimit);
|
|
1992
|
+
this.database.prepare(`
|
|
1993
|
+
UPDATE outbox_messages SET status = 'dead', failure_code = CASE
|
|
1994
|
+
WHEN status = 'unknown_after_send' THEN 'binding-revoked-unknown'
|
|
1995
|
+
ELSE 'binding-revoked'
|
|
1996
|
+
END,
|
|
1997
|
+
next_attempt_at = NULL, updated_at = ?
|
|
1998
|
+
WHERE rowid IN (
|
|
1999
|
+
SELECT candidate.rowid FROM outbox_messages AS candidate
|
|
2000
|
+
WHERE candidate.status IN ('pending', 'retry_wait', 'unknown_after_send')
|
|
2001
|
+
AND NOT EXISTS (
|
|
2002
|
+
SELECT 1 FROM conversation_bindings AS binding
|
|
2003
|
+
WHERE binding.id = candidate.binding_id AND binding.status = 'active'
|
|
2004
|
+
)
|
|
2005
|
+
ORDER BY candidate.rowid LIMIT ?
|
|
2006
|
+
)
|
|
2007
|
+
`).run(now, maintenanceLimit);
|
|
2008
|
+
const candidates = this.database.prepare(`
|
|
2009
|
+
SELECT candidate.id, candidate.status FROM outbox_messages AS candidate
|
|
2010
|
+
WHERE candidate.status IN ('pending', 'retry_wait', 'unknown_after_send')
|
|
2011
|
+
AND candidate.claimed_by IS NULL
|
|
2012
|
+
AND (candidate.next_attempt_at IS NULL OR candidate.next_attempt_at <= ?)
|
|
2013
|
+
AND (
|
|
2014
|
+
candidate.status = 'pending'
|
|
2015
|
+
OR (candidate.status = 'retry_wait' AND (
|
|
2016
|
+
SELECT COUNT(*) FROM outbox_attempts AS history
|
|
2017
|
+
WHERE history.outbox_id = candidate.id AND history.operation = 'send'
|
|
2018
|
+
) < ?)
|
|
2019
|
+
OR (candidate.status = 'unknown_after_send' AND (
|
|
2020
|
+
SELECT COUNT(*) FROM outbox_attempts AS history
|
|
2021
|
+
WHERE history.outbox_id = candidate.id AND history.operation = 'reconcile'
|
|
2022
|
+
) < ?)
|
|
2023
|
+
)
|
|
2024
|
+
AND EXISTS (
|
|
2025
|
+
SELECT 1 FROM conversation_bindings AS binding
|
|
2026
|
+
WHERE binding.id = candidate.binding_id AND binding.status = 'active'
|
|
2027
|
+
)
|
|
2028
|
+
${routeClause}
|
|
2029
|
+
${excludeClause}
|
|
2030
|
+
AND NOT EXISTS (
|
|
2031
|
+
SELECT 1 FROM outbox_messages AS earlier
|
|
2032
|
+
WHERE earlier.lane_hash = candidate.lane_hash
|
|
2033
|
+
AND earlier.rowid < candidate.rowid
|
|
2034
|
+
AND earlier.status NOT IN ('accepted', 'delivered', 'read', 'dead')
|
|
2035
|
+
)
|
|
2036
|
+
AND NOT EXISTS (
|
|
2037
|
+
SELECT 1 FROM outbox_messages AS active
|
|
2038
|
+
WHERE active.lane_hash = candidate.lane_hash AND active.status = 'attempting'
|
|
2039
|
+
)
|
|
2040
|
+
ORDER BY candidate.rowid
|
|
2041
|
+
LIMIT ?
|
|
2042
|
+
`).all(now, input.maxAttempts, input.maxAttempts, ...routeParameters, ...excludeIds, input.limit);
|
|
2043
|
+
for (const candidate of candidates) {
|
|
2044
|
+
const current = this.getOutbox(candidate.id);
|
|
2045
|
+
const mode = candidate.status === 'unknown_after_send' ? 'reconcile' : 'send';
|
|
2046
|
+
const fencingToken = current.attemptCount + 1;
|
|
2047
|
+
const changed = this.database.prepare(`
|
|
2048
|
+
UPDATE outbox_messages SET status = 'attempting', claimed_by = ?, fencing_token = ?, lease_until = ?,
|
|
2049
|
+
attempt_count = attempt_count + 1, next_attempt_at = NULL, updated_at = ?
|
|
2050
|
+
WHERE id = ? AND status = ? AND claimed_by IS NULL
|
|
2051
|
+
`).run(ownerId, fencingToken, now + input.leaseMs, now, current.id, candidate.status);
|
|
2052
|
+
if (changed.changes !== 1)
|
|
2053
|
+
continue;
|
|
2054
|
+
this.database.prepare(`
|
|
2055
|
+
INSERT INTO outbox_attempts (
|
|
2056
|
+
id, outbox_id, attempt_number, owner_id, fencing_token, operation, status, created_at
|
|
2057
|
+
) VALUES (?, ?, ?, ?, ?, ?, 'attempting', ?)
|
|
2058
|
+
`).run(`outbox_attempt_${randomUUID()}`, current.id, fencingToken, ownerId, fencingToken, mode, now);
|
|
2059
|
+
claims.push({ record: this.getOutbox(current.id), fencingToken, mode });
|
|
2060
|
+
}
|
|
2061
|
+
});
|
|
2062
|
+
return claims;
|
|
2063
|
+
}
|
|
2064
|
+
renewOutboxClaim(input) {
|
|
2065
|
+
this.assertOpen();
|
|
2066
|
+
const ownerId = validateBindingText(input.ownerId, 'ownerId', 256);
|
|
2067
|
+
if (!Number.isSafeInteger(input.fencingToken) || input.fencingToken < 1
|
|
2068
|
+
|| !Number.isSafeInteger(input.leaseMs) || input.leaseMs < 1) {
|
|
2069
|
+
throw new DeliveryStoreError('stale-fence', 'outbox lease renewal is invalid');
|
|
2070
|
+
}
|
|
2071
|
+
const now = this.now();
|
|
2072
|
+
return this.database.prepare(`
|
|
2073
|
+
UPDATE outbox_messages SET lease_until = ?, updated_at = ?
|
|
2074
|
+
WHERE id = ? AND status = 'attempting' AND claimed_by = ? AND fencing_token = ? AND lease_until > ?
|
|
2075
|
+
`).run(now + input.leaseMs, now, input.outboxId, ownerId, input.fencingToken, now).changes === 1;
|
|
2076
|
+
}
|
|
2077
|
+
finishOutbox(input) {
|
|
2078
|
+
this.assertOpen();
|
|
2079
|
+
const now = this.now();
|
|
2080
|
+
if (input.outcome === 'accepted' && input.providerMessageId === undefined) {
|
|
2081
|
+
throw new DeliveryStoreError('conflict', 'accepted delivery requires a provider message id');
|
|
2082
|
+
}
|
|
2083
|
+
if (input.outcome === 'retry_wait' && (!Number.isSafeInteger(input.retryAt) || input.retryAt < now)) {
|
|
2084
|
+
throw new DeliveryStoreError('conflict', 'retry_wait requires a current or future retryAt');
|
|
2085
|
+
}
|
|
2086
|
+
let providerMessageId = null;
|
|
2087
|
+
if (input.providerMessageId !== undefined) {
|
|
2088
|
+
try {
|
|
2089
|
+
providerMessageId = validateBindingText(input.providerMessageId, 'providerMessageId', 512);
|
|
2090
|
+
}
|
|
2091
|
+
catch {
|
|
2092
|
+
throw new DeliveryStoreError('conflict', 'provider message id is invalid');
|
|
2093
|
+
}
|
|
2094
|
+
}
|
|
2095
|
+
this.transaction(() => {
|
|
2096
|
+
const changed = this.database.prepare(`
|
|
2097
|
+
UPDATE outbox_messages SET status = ?, provider_message_id = COALESCE(?, provider_message_id),
|
|
2098
|
+
next_attempt_at = ?, claimed_by = NULL, fencing_token = NULL, lease_until = NULL,
|
|
2099
|
+
failure_code = ?, updated_at = ?
|
|
2100
|
+
WHERE id = ? AND status = 'attempting' AND claimed_by = ? AND fencing_token = ? AND lease_until > ?
|
|
2101
|
+
`).run(input.outcome, providerMessageId, input.outcome === 'retry_wait' ? input.retryAt : null, input.failureCode ?? null, now, input.outboxId, input.ownerId, input.fencingToken, now);
|
|
2102
|
+
if (changed.changes !== 1)
|
|
2103
|
+
throw new DeliveryStoreError('stale-fence', 'outbox completion has a stale fence');
|
|
2104
|
+
this.database.prepare(`
|
|
2105
|
+
UPDATE outbox_attempts SET status = ?, provider_message_id = ?, failure_code = ?, finished_at = ?
|
|
2106
|
+
WHERE outbox_id = ? AND owner_id = ? AND fencing_token = ? AND status = 'attempting'
|
|
2107
|
+
`).run(input.outcome, providerMessageId, input.failureCode ?? null, now, input.outboxId, input.ownerId, input.fencingToken);
|
|
2108
|
+
});
|
|
2109
|
+
return this.getOutbox(input.outboxId);
|
|
2110
|
+
}
|
|
2111
|
+
recoverOutbox(input) {
|
|
2112
|
+
this.assertOpen();
|
|
2113
|
+
const limit = boundedMaintenanceLimit(input.limit, 100, 'outbox recovery');
|
|
2114
|
+
const now = this.now();
|
|
2115
|
+
const recovered = [];
|
|
2116
|
+
this.transaction(() => {
|
|
2117
|
+
const rows = this.database.prepare(`
|
|
2118
|
+
SELECT id FROM outbox_messages WHERE status = 'attempting' AND lease_until <= ? ORDER BY rowid LIMIT ?
|
|
2119
|
+
`).all(now, limit);
|
|
2120
|
+
for (const row of rows) {
|
|
2121
|
+
const changed = this.database.prepare(`
|
|
2122
|
+
UPDATE outbox_messages SET status = 'unknown_after_send', claimed_by = NULL, fencing_token = NULL,
|
|
2123
|
+
lease_until = NULL, next_attempt_at = NULL, failure_code = 'attempt-lease-expired', updated_at = ?
|
|
2124
|
+
WHERE id = ? AND status = 'attempting' AND lease_until <= ?
|
|
2125
|
+
`).run(now, row.id, now);
|
|
2126
|
+
if (changed.changes !== 1)
|
|
2127
|
+
continue;
|
|
2128
|
+
this.database.prepare(`
|
|
2129
|
+
UPDATE outbox_attempts SET status = 'unknown_after_send', failure_code = 'attempt-lease-expired', finished_at = ?
|
|
2130
|
+
WHERE outbox_id = ? AND status = 'attempting'
|
|
2131
|
+
`).run(now, row.id);
|
|
2132
|
+
recovered.push(row.id);
|
|
2133
|
+
}
|
|
2134
|
+
this.database.prepare(`
|
|
2135
|
+
UPDATE outbox_messages SET status = 'dead', failure_code = 'attempts-exhausted', next_attempt_at = NULL,
|
|
2136
|
+
updated_at = ? WHERE rowid IN (
|
|
2137
|
+
SELECT candidate.rowid FROM outbox_messages AS candidate
|
|
2138
|
+
WHERE candidate.status = 'retry_wait' AND candidate.next_attempt_at <= ?
|
|
2139
|
+
AND (
|
|
2140
|
+
SELECT COUNT(*) FROM outbox_attempts AS history
|
|
2141
|
+
WHERE history.outbox_id = candidate.id AND history.operation = 'send'
|
|
2142
|
+
) >= ?
|
|
2143
|
+
ORDER BY candidate.rowid LIMIT ?
|
|
2144
|
+
)
|
|
2145
|
+
`).run(now, now, input.maxAttempts, limit);
|
|
2146
|
+
});
|
|
2147
|
+
return recovered.map(id => this.getOutbox(id));
|
|
2148
|
+
}
|
|
2149
|
+
recordReceipt(input) {
|
|
2150
|
+
this.assertOpen();
|
|
2151
|
+
let channel;
|
|
2152
|
+
let account;
|
|
2153
|
+
let providerMessageId;
|
|
2154
|
+
try {
|
|
2155
|
+
channel = validateBindingText(input.channel, 'channel', 256);
|
|
2156
|
+
account = validateBindingText(input.account, 'account', 256);
|
|
2157
|
+
providerMessageId = validateBindingText(input.providerMessageId, 'providerMessageId', 512);
|
|
2158
|
+
}
|
|
2159
|
+
catch {
|
|
2160
|
+
throw new DeliveryStoreError('receipt-mismatch', 'receipt identifiers are invalid');
|
|
2161
|
+
}
|
|
2162
|
+
if (!['accepted', 'delivered', 'read'].includes(input.status) || !Number.isSafeInteger(input.occurredAt) || input.occurredAt < 0) {
|
|
2163
|
+
throw new DeliveryStoreError('receipt-mismatch', 'receipt status or time is invalid');
|
|
2164
|
+
}
|
|
2165
|
+
const metadata = canonicalMetadata(input.metadata, 'receipt');
|
|
2166
|
+
const receipt = { channel, account, providerMessageId, status: input.status,
|
|
2167
|
+
occurredAt: input.occurredAt, ...(metadata === undefined ? {} : { metadata }) };
|
|
2168
|
+
const json = JSON.stringify(receipt);
|
|
2169
|
+
const hash = digest(json);
|
|
2170
|
+
const row = this.database.prepare(`
|
|
2171
|
+
SELECT id FROM outbox_messages WHERE channel = ? AND account = ? AND provider_message_id = ?
|
|
2172
|
+
`).get(channel, account, providerMessageId);
|
|
2173
|
+
if (row === undefined)
|
|
2174
|
+
throw new DeliveryStoreError('receipt-mismatch', 'receipt does not match an outbox attempt');
|
|
2175
|
+
const existing = this.database.prepare(`
|
|
2176
|
+
SELECT receipt_hash FROM delivery_receipts
|
|
2177
|
+
WHERE channel = ? AND account = ? AND provider_message_id = ? AND status = ?
|
|
2178
|
+
`).get(channel, account, providerMessageId, input.status);
|
|
2179
|
+
if (existing !== undefined) {
|
|
2180
|
+
if (existing.receipt_hash !== hash) {
|
|
2181
|
+
throw new DeliveryStoreError('idempotency-conflict', 'receipt status was reused with changed content');
|
|
2182
|
+
}
|
|
2183
|
+
return this.getOutbox(row.id);
|
|
2184
|
+
}
|
|
2185
|
+
const now = this.now();
|
|
2186
|
+
this.transaction(() => {
|
|
2187
|
+
this.database.prepare(`
|
|
2188
|
+
INSERT INTO delivery_receipts (
|
|
2189
|
+
id, channel, account, provider_message_id, status, receipt_hash, receipt_json, occurred_at, created_at
|
|
2190
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
2191
|
+
`).run(`receipt_${randomUUID()}`, channel, account, providerMessageId, input.status, hash, json, input.occurredAt, now);
|
|
2192
|
+
const current = this.getOutbox(row.id);
|
|
2193
|
+
const rank = { accepted: 1, delivered: 2, read: 3 };
|
|
2194
|
+
if ((rank[input.status] ?? 0) > (rank[current.status] ?? 0)) {
|
|
2195
|
+
this.database.prepare('UPDATE outbox_messages SET status = ?, updated_at = ? WHERE id = ?')
|
|
2196
|
+
.run(input.status, now, row.id);
|
|
2197
|
+
}
|
|
2198
|
+
});
|
|
2199
|
+
return this.getOutbox(row.id);
|
|
2200
|
+
}
|
|
2201
|
+
revokePrincipal(id, expectedVersion) {
|
|
2202
|
+
this.assertOpen();
|
|
2203
|
+
const now = this.now();
|
|
2204
|
+
return this.transaction(() => {
|
|
2205
|
+
const result = this.database.prepare(`
|
|
2206
|
+
UPDATE delivery_principals SET status = 'revoked', updated_at = ?, version = version + 1
|
|
2207
|
+
WHERE id = ? AND version = ?
|
|
2208
|
+
`).run(now, id, expectedVersion);
|
|
2209
|
+
if (result.changes !== 1) {
|
|
2210
|
+
throw new DeliveryStoreError('version-conflict', 'principal version changed or does not exist');
|
|
2211
|
+
}
|
|
2212
|
+
this.database.prepare(`
|
|
2213
|
+
UPDATE conversation_bindings SET status = 'revoked', updated_at = ?, version = version + 1
|
|
2214
|
+
WHERE principal_id = ? AND status = 'active'
|
|
2215
|
+
`).run(now, id);
|
|
2216
|
+
const row = this.database.prepare(`
|
|
2217
|
+
SELECT id, principal_json, role, status, linked_to_id, created_at, updated_at, version
|
|
2218
|
+
FROM delivery_principals WHERE id = ?
|
|
2219
|
+
`).get(id);
|
|
2220
|
+
return principalFromRow(row);
|
|
2221
|
+
});
|
|
2222
|
+
}
|
|
2223
|
+
linkPrincipal(input) {
|
|
2224
|
+
this.assertOpen();
|
|
2225
|
+
const owner = this.getPrincipal(input.owner);
|
|
2226
|
+
const linked = this.getPrincipal(input.linked);
|
|
2227
|
+
if (owner?.status !== 'active' || owner.role !== 'owner') {
|
|
2228
|
+
throw new DeliveryStoreError('unauthorized-principal', 'principal link requires an active owner');
|
|
2229
|
+
}
|
|
2230
|
+
if (linked?.status !== 'active' || linked.id === owner.id) {
|
|
2231
|
+
throw new DeliveryStoreError('unauthorized-principal', 'linked principal must be a distinct active principal');
|
|
2232
|
+
}
|
|
2233
|
+
if (linked.version !== input.expectedLinkedVersion) {
|
|
2234
|
+
throw new DeliveryStoreError('version-conflict', 'linked principal version changed');
|
|
2235
|
+
}
|
|
2236
|
+
if (linked.linkedToId === owner.id)
|
|
2237
|
+
return linked;
|
|
2238
|
+
const now = this.now();
|
|
2239
|
+
const changed = this.database.prepare(`
|
|
2240
|
+
UPDATE delivery_principals SET role = 'linked', linked_to_id = ?, updated_at = ?, version = version + 1
|
|
2241
|
+
WHERE id = ? AND version = ? AND status = 'active'
|
|
2242
|
+
`).run(owner.id, now, linked.id, input.expectedLinkedVersion);
|
|
2243
|
+
if (changed.changes !== 1)
|
|
2244
|
+
throw new DeliveryStoreError('version-conflict', 'linked principal version changed');
|
|
2245
|
+
return this.getPrincipal(input.linked);
|
|
2246
|
+
}
|
|
2247
|
+
resolveInbox(input) {
|
|
2248
|
+
this.assertOpen();
|
|
2249
|
+
const current = this.getInbox(input.inboxId);
|
|
2250
|
+
if (current?.status !== 'dead_letter' || current.attemptCount !== input.expectedAttemptCount) {
|
|
2251
|
+
throw new DeliveryStoreError('version-conflict', 'dead-letter inbox attempt count or state changed');
|
|
2252
|
+
}
|
|
2253
|
+
if (input.resolution === 'retry') {
|
|
2254
|
+
const binding = current.bindingId === undefined ? undefined : this.getBinding(current.bindingId);
|
|
2255
|
+
if (binding?.status !== 'active')
|
|
2256
|
+
throw new DeliveryStoreError('conflict', 'dead-letter inbox has no active binding');
|
|
2257
|
+
}
|
|
2258
|
+
const now = this.now();
|
|
2259
|
+
const changed = this.database.prepare(`
|
|
2260
|
+
UPDATE inbox_messages SET status = ?, failure_code = ?, next_attempt_at = NULL, updated_at = ?
|
|
2261
|
+
WHERE id = ? AND status = 'dead_letter' AND attempt_count = ?
|
|
2262
|
+
`).run(input.resolution === 'retry' ? 'queued' : 'dead_letter', input.resolution === 'retry' ? null : 'operator-cancelled', now, input.inboxId, input.expectedAttemptCount);
|
|
2263
|
+
if (changed.changes !== 1)
|
|
2264
|
+
throw new DeliveryStoreError('version-conflict', 'dead-letter inbox changed');
|
|
2265
|
+
return this.getInbox(input.inboxId);
|
|
2266
|
+
}
|
|
2267
|
+
resolveOutbox(input) {
|
|
2268
|
+
this.assertOpen();
|
|
2269
|
+
return this.transaction(() => {
|
|
2270
|
+
const current = this.getOutbox(input.outboxId);
|
|
2271
|
+
if (current === undefined || !['dead', 'unknown_after_send'].includes(current.status)
|
|
2272
|
+
|| current.attemptCount !== input.expectedAttemptCount) {
|
|
2273
|
+
throw new DeliveryStoreError('version-conflict', 'outbox attempt count or resolvable state changed');
|
|
2274
|
+
}
|
|
2275
|
+
if (input.resolution === 'retry') {
|
|
2276
|
+
const binding = this.getBinding(current.intent.bindingId);
|
|
2277
|
+
if (binding?.status !== 'active') {
|
|
2278
|
+
throw new DeliveryStoreError('conflict', 'resolvable outbox has no active binding');
|
|
2279
|
+
}
|
|
2280
|
+
}
|
|
2281
|
+
const now = this.now();
|
|
2282
|
+
const changed = this.database.prepare(`
|
|
2283
|
+
UPDATE outbox_messages SET status = ?, failure_code = ?, next_attempt_at = NULL,
|
|
2284
|
+
claimed_by = NULL, fencing_token = NULL, lease_until = NULL, updated_at = ?
|
|
2285
|
+
WHERE id = ? AND status = ? AND attempt_count = ?
|
|
2286
|
+
`).run(input.resolution === 'retry' ? 'pending' : 'dead', input.resolution === 'retry' ? null : 'operator-cancelled', now, input.outboxId, current.status, input.expectedAttemptCount);
|
|
2287
|
+
if (changed.changes !== 1)
|
|
2288
|
+
throw new DeliveryStoreError('version-conflict', 'resolvable outbox changed');
|
|
2289
|
+
return this.getOutbox(input.outboxId);
|
|
2290
|
+
});
|
|
2291
|
+
}
|
|
2292
|
+
beginApprovalSettlement(input) {
|
|
2293
|
+
this.assertOpen();
|
|
2294
|
+
const operationId = validateBindingText(input.operationId, 'operationId', 512);
|
|
2295
|
+
const payloadJson = JSON.stringify(input.payload);
|
|
2296
|
+
if (payloadJson === undefined || payloadJson.length > 16_384) {
|
|
2297
|
+
throw new DeliveryStoreError('conflict', 'approval settlement payload is invalid or too large');
|
|
2298
|
+
}
|
|
2299
|
+
const payloadHash = digest(payloadJson);
|
|
2300
|
+
const selectSettlement = this.database.prepare(`
|
|
2301
|
+
SELECT payload_hash, payload_json, status, result_json FROM approval_settlements WHERE operation_id = ?
|
|
2302
|
+
`);
|
|
2303
|
+
const readSettlement = () => selectSettlement.get(operationId);
|
|
2304
|
+
const replay = (existing) => {
|
|
2305
|
+
if (existing === undefined) {
|
|
2306
|
+
throw new DeliveryStoreError('version-conflict', 'approval settlement winner disappeared');
|
|
2307
|
+
}
|
|
2308
|
+
if (existing.payload_hash !== payloadHash || existing.payload_json !== payloadJson) {
|
|
2309
|
+
throw new DeliveryStoreError('idempotency-conflict', 'approval operation id was reused with a different payload');
|
|
2310
|
+
}
|
|
2311
|
+
return { payloadHash, replayed: true,
|
|
2312
|
+
...(existing.result_json === null ? {} : { result: JSON.parse(existing.result_json) }) };
|
|
2313
|
+
};
|
|
2314
|
+
const existing = readSettlement();
|
|
2315
|
+
if (existing !== undefined) {
|
|
2316
|
+
return replay(existing);
|
|
2317
|
+
}
|
|
2318
|
+
if (input.createIfMissing === false) {
|
|
2319
|
+
throw new DeliveryStoreError('not-found', 'approval settlement has no durable operation to recover');
|
|
2320
|
+
}
|
|
2321
|
+
const now = this.now();
|
|
2322
|
+
const inserted = this.database.prepare(`
|
|
2323
|
+
INSERT INTO approval_settlements (
|
|
2324
|
+
operation_id, payload_hash, payload_json, status, result_json, created_at, updated_at
|
|
2325
|
+
) VALUES (?, ?, ?, 'pending', NULL, ?, ?)
|
|
2326
|
+
ON CONFLICT(operation_id) DO NOTHING
|
|
2327
|
+
`).run(operationId, payloadHash, payloadJson, now, now);
|
|
2328
|
+
if (inserted.changes !== 1)
|
|
2329
|
+
return replay(readSettlement());
|
|
2330
|
+
return { payloadHash, replayed: false };
|
|
2331
|
+
}
|
|
2332
|
+
completeApprovalSettlement(input) {
|
|
2333
|
+
this.assertOpen();
|
|
2334
|
+
const resultJson = JSON.stringify(input.result);
|
|
2335
|
+
if (resultJson === undefined || resultJson.length > 16_384) {
|
|
2336
|
+
throw new DeliveryStoreError('conflict', 'approval settlement result is invalid or too large');
|
|
2337
|
+
}
|
|
2338
|
+
const now = this.now();
|
|
2339
|
+
const changed = this.database.prepare(`
|
|
2340
|
+
UPDATE approval_settlements SET status = 'completed', result_json = ?, updated_at = ?
|
|
2341
|
+
WHERE operation_id = ? AND payload_hash = ? AND status = 'pending'
|
|
2342
|
+
`).run(resultJson, now, input.operationId, input.payloadHash);
|
|
2343
|
+
if (changed.changes !== 1) {
|
|
2344
|
+
const replay = this.database.prepare(`
|
|
2345
|
+
SELECT result_json FROM approval_settlements
|
|
2346
|
+
WHERE operation_id = ? AND payload_hash = ? AND status = 'completed'
|
|
2347
|
+
`).get(input.operationId, input.payloadHash);
|
|
2348
|
+
if (replay !== undefined)
|
|
2349
|
+
return JSON.parse(replay.result_json);
|
|
2350
|
+
throw new DeliveryStoreError('version-conflict', 'approval settlement changed before completion');
|
|
2351
|
+
}
|
|
2352
|
+
return JSON.parse(resultJson);
|
|
2353
|
+
}
|
|
2354
|
+
beginModelSelectionSettlement(input) {
|
|
2355
|
+
this.assertOpen();
|
|
2356
|
+
const operationId = validateBindingText(input.operationId, 'operationId', 512);
|
|
2357
|
+
const bindingId = validateBindingText(input.bindingId, 'bindingId', 256);
|
|
2358
|
+
const expected = canonicalModelPickerState(input.expected);
|
|
2359
|
+
const payloadJson = JSON.stringify({ bindingId, expected, payload: input.payload });
|
|
2360
|
+
if (payloadJson === undefined || payloadJson.length > 16_384) {
|
|
2361
|
+
throw new DeliveryStoreError('conflict', 'model selection settlement payload is invalid or too large');
|
|
2362
|
+
}
|
|
2363
|
+
const payloadHash = digest(payloadJson);
|
|
2364
|
+
const binding = this.getBinding(bindingId);
|
|
2365
|
+
if (binding?.status !== 'active') {
|
|
2366
|
+
throw new DeliveryStoreError('invalid-binding', 'model selection requires an active binding');
|
|
2367
|
+
}
|
|
2368
|
+
return this.transaction(() => {
|
|
2369
|
+
const existing = this.database.prepare(`
|
|
2370
|
+
SELECT binding_id, conversation_hash, command_epoch, payload_hash, status, result_json,
|
|
2371
|
+
attempt_count, claimed_by, lease_until
|
|
2372
|
+
FROM model_selection_settlements WHERE operation_id = ?
|
|
2373
|
+
`).get(operationId);
|
|
2374
|
+
if (existing !== undefined) {
|
|
2375
|
+
if (existing.binding_id !== bindingId || existing.payload_hash !== payloadHash) {
|
|
2376
|
+
throw new DeliveryStoreError('idempotency-conflict', 'model selection operation was reused with a different payload');
|
|
2377
|
+
}
|
|
2378
|
+
return { payloadHash, replayed: true,
|
|
2379
|
+
status: existing.status === 'completed' ? 'completed' : 'pending',
|
|
2380
|
+
...(existing.result_json === null ? {} : { result: JSON.parse(existing.result_json) }) };
|
|
2381
|
+
}
|
|
2382
|
+
if (input.createIfMissing === false) {
|
|
2383
|
+
throw new DeliveryStoreError('not-found', 'expired model selection has no pending settlement to resume');
|
|
2384
|
+
}
|
|
2385
|
+
const row = this.database.prepare(`
|
|
2386
|
+
SELECT binding_id, revision, provider, model, reasoning_effort
|
|
2387
|
+
FROM model_picker_states WHERE operation_id = ?
|
|
2388
|
+
`).get(operationId);
|
|
2389
|
+
if (row === undefined) {
|
|
2390
|
+
if (expected.revision !== 0) {
|
|
2391
|
+
throw new DeliveryStoreError('version-conflict', 'model picker state does not exist at the expected revision');
|
|
2392
|
+
}
|
|
2393
|
+
}
|
|
2394
|
+
else {
|
|
2395
|
+
if (row.binding_id !== bindingId) {
|
|
2396
|
+
throw new DeliveryStoreError('idempotency-conflict', 'model picker operation belongs to another binding');
|
|
2397
|
+
}
|
|
2398
|
+
if (!sameModelPickerState(modelPickerStateFromRow(row), expected)) {
|
|
2399
|
+
throw new DeliveryStoreError('version-conflict', 'model picker confirmation used a stale revision');
|
|
2400
|
+
}
|
|
2401
|
+
}
|
|
2402
|
+
const now = this.now();
|
|
2403
|
+
const commandEpoch = this.advanceModelCommandEpoch(binding.conversation);
|
|
2404
|
+
this.database.prepare(`
|
|
2405
|
+
INSERT INTO model_selection_settlements (
|
|
2406
|
+
operation_id, binding_id, conversation_hash, command_epoch, payload_hash,
|
|
2407
|
+
payload_json, status, result_json, outbox_id, created_at, updated_at
|
|
2408
|
+
) VALUES (?, ?, ?, ?, ?, ?, 'pending', NULL, NULL, ?, ?)
|
|
2409
|
+
`).run(operationId, bindingId, conversationHash(binding.conversation), commandEpoch, payloadHash, payloadJson, now, now);
|
|
2410
|
+
return { payloadHash, replayed: false, status: 'pending' };
|
|
2411
|
+
});
|
|
2412
|
+
}
|
|
2413
|
+
claimModelSelectionSettlements(input) {
|
|
2414
|
+
this.assertOpen();
|
|
2415
|
+
const ownerId = validateBindingText(input.ownerId, 'ownerId', 256);
|
|
2416
|
+
if (!Number.isSafeInteger(input.leaseMs) || input.leaseMs < 1) {
|
|
2417
|
+
throw new DeliveryStoreError('conflict', 'model selection lease is invalid');
|
|
2418
|
+
}
|
|
2419
|
+
const limit = input.limit ?? 10;
|
|
2420
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) {
|
|
2421
|
+
throw new DeliveryStoreError('conflict', 'model selection claim limit is invalid');
|
|
2422
|
+
}
|
|
2423
|
+
const now = this.now();
|
|
2424
|
+
return this.transaction(() => {
|
|
2425
|
+
this.database.prepare(`
|
|
2426
|
+
UPDATE model_selection_settlements
|
|
2427
|
+
SET status = 'pending', claimed_by = NULL, lease_until = NULL, updated_at = ?
|
|
2428
|
+
WHERE status = 'processing' AND lease_until <= ?
|
|
2429
|
+
`).run(now, now);
|
|
2430
|
+
const rows = this.database.prepare(`
|
|
2431
|
+
SELECT operation_id, binding_id, payload_hash, payload_json, attempt_count
|
|
2432
|
+
FROM model_selection_settlements
|
|
2433
|
+
WHERE status = 'pending'
|
|
2434
|
+
ORDER BY created_at, operation_id LIMIT ?
|
|
2435
|
+
`).all(limit);
|
|
2436
|
+
const claims = [];
|
|
2437
|
+
for (const row of rows) {
|
|
2438
|
+
const fencingToken = row.attempt_count + 1;
|
|
2439
|
+
const changed = this.database.prepare(`
|
|
2440
|
+
UPDATE model_selection_settlements
|
|
2441
|
+
SET status = 'processing', attempt_count = ?, claimed_by = ?, lease_until = ?, updated_at = ?
|
|
2442
|
+
WHERE operation_id = ? AND status = 'pending'
|
|
2443
|
+
`).run(fencingToken, ownerId, now + input.leaseMs, now, row.operation_id);
|
|
2444
|
+
if (changed.changes !== 1)
|
|
2445
|
+
continue;
|
|
2446
|
+
const stored = JSON.parse(row.payload_json);
|
|
2447
|
+
claims.push({ operationId: row.operation_id, bindingId: row.binding_id,
|
|
2448
|
+
payloadHash: row.payload_hash, payload: stored.payload, fencingToken });
|
|
2449
|
+
}
|
|
2450
|
+
return claims;
|
|
2451
|
+
});
|
|
2452
|
+
}
|
|
2453
|
+
nextModelSelectionClaimAt() {
|
|
2454
|
+
this.assertOpen();
|
|
2455
|
+
const row = this.database.prepare(`
|
|
2456
|
+
SELECT status, lease_until FROM model_selection_settlements
|
|
2457
|
+
WHERE status IN ('pending', 'processing')
|
|
2458
|
+
ORDER BY CASE WHEN status = 'pending' THEN 0 ELSE lease_until END, created_at
|
|
2459
|
+
LIMIT 1
|
|
2460
|
+
`).get();
|
|
2461
|
+
if (row === undefined)
|
|
2462
|
+
return undefined;
|
|
2463
|
+
return row.status === 'pending' ? this.now() : row.lease_until ?? this.now();
|
|
2464
|
+
}
|
|
2465
|
+
renewModelSelectionSettlement(input) {
|
|
2466
|
+
this.assertOpen();
|
|
2467
|
+
const operationId = validateBindingText(input.operationId, 'operationId', 512);
|
|
2468
|
+
const ownerId = validateBindingText(input.ownerId, 'ownerId', 256);
|
|
2469
|
+
if (!Number.isSafeInteger(input.fencingToken) || input.fencingToken < 1
|
|
2470
|
+
|| !Number.isSafeInteger(input.leaseMs) || input.leaseMs < 1) {
|
|
2471
|
+
throw new DeliveryStoreError('stale-fence', 'model selection lease renewal is invalid');
|
|
2472
|
+
}
|
|
2473
|
+
const now = this.now();
|
|
2474
|
+
return this.database.prepare(`
|
|
2475
|
+
UPDATE model_selection_settlements SET lease_until = ?, updated_at = ?
|
|
2476
|
+
WHERE operation_id = ? AND status = 'processing' AND claimed_by = ?
|
|
2477
|
+
AND attempt_count = ? AND lease_until > ?
|
|
2478
|
+
`).run(now + input.leaseMs, now, operationId, ownerId, input.fencingToken, now).changes === 1;
|
|
2479
|
+
}
|
|
2480
|
+
completeModelSelectionSettlement(input) {
|
|
2481
|
+
this.assertOpen();
|
|
2482
|
+
const operationId = validateBindingText(input.operationId, 'operationId', 512);
|
|
2483
|
+
if (!/^[a-f0-9]{64}$/u.test(input.payloadHash)) {
|
|
2484
|
+
throw new DeliveryStoreError('conflict', 'model selection payload hash is invalid');
|
|
2485
|
+
}
|
|
2486
|
+
const resultJson = JSON.stringify(input.result);
|
|
2487
|
+
if (resultJson === undefined || resultJson.length > 16_384) {
|
|
2488
|
+
throw new DeliveryStoreError('conflict', 'model selection settlement result is invalid or too large');
|
|
2489
|
+
}
|
|
2490
|
+
const supersededResultJson = input.superseded === undefined ? resultJson : JSON.stringify(input.superseded.result);
|
|
2491
|
+
if (supersededResultJson === undefined || supersededResultJson.length > 16_384) {
|
|
2492
|
+
throw new DeliveryStoreError('conflict', 'superseded model selection result is invalid or too large');
|
|
2493
|
+
}
|
|
2494
|
+
const ownerId = input.ownerId === undefined ? undefined : validateBindingText(input.ownerId, 'ownerId', 256);
|
|
2495
|
+
if ((ownerId === undefined) !== (input.fencingToken === undefined)
|
|
2496
|
+
|| (input.fencingToken !== undefined && (!Number.isSafeInteger(input.fencingToken) || input.fencingToken < 1))) {
|
|
2497
|
+
throw new DeliveryStoreError('stale-fence', 'model selection completion fence is invalid');
|
|
2498
|
+
}
|
|
2499
|
+
return this.transaction(() => {
|
|
2500
|
+
const current = this.database.prepare(`
|
|
2501
|
+
SELECT settlement.binding_id, settlement.conversation_hash, settlement.command_epoch,
|
|
2502
|
+
settlement.payload_hash, settlement.status, settlement.result_json,
|
|
2503
|
+
settlement.attempt_count, settlement.claimed_by, settlement.lease_until,
|
|
2504
|
+
binding.status AS binding_status, principal.status AS principal_status
|
|
2505
|
+
FROM model_selection_settlements AS settlement
|
|
2506
|
+
JOIN conversation_bindings AS binding ON binding.id = settlement.binding_id
|
|
2507
|
+
JOIN delivery_principals AS principal ON principal.id = binding.principal_id
|
|
2508
|
+
WHERE settlement.operation_id = ?
|
|
2509
|
+
`).get(operationId);
|
|
2510
|
+
if (current?.payload_hash !== input.payloadHash) {
|
|
2511
|
+
throw new DeliveryStoreError('version-conflict', 'model selection settlement does not match the pending operation');
|
|
2512
|
+
}
|
|
2513
|
+
if (current.status === 'completed') {
|
|
2514
|
+
if (current.result_json === null) {
|
|
2515
|
+
throw new DeliveryStoreError('version-conflict', 'completed model selection settlement has no result');
|
|
2516
|
+
}
|
|
2517
|
+
return JSON.parse(current.result_json);
|
|
2518
|
+
}
|
|
2519
|
+
if (ownerId !== undefined && (current.status !== 'processing' || current.claimed_by !== ownerId
|
|
2520
|
+
|| current.attempt_count !== input.fencingToken || current.lease_until === null
|
|
2521
|
+
|| current.lease_until <= this.now())) {
|
|
2522
|
+
throw new DeliveryStoreError('stale-fence', 'model selection completion lost its lease');
|
|
2523
|
+
}
|
|
2524
|
+
if (ownerId === undefined && current.status !== 'pending') {
|
|
2525
|
+
throw new DeliveryStoreError('version-conflict', 'model selection settlement is already being processed');
|
|
2526
|
+
}
|
|
2527
|
+
const epoch = this.database.prepare(`
|
|
2528
|
+
SELECT epoch FROM conversation_model_epochs WHERE conversation_hash = ?
|
|
2529
|
+
`).get(current.conversation_hash);
|
|
2530
|
+
const superseded = epoch?.epoch !== current.command_epoch;
|
|
2531
|
+
const reply = superseded && input.superseded !== undefined ? input.superseded.reply : input.reply;
|
|
2532
|
+
const completedResultJson = superseded ? supersededResultJson : resultJson;
|
|
2533
|
+
if ((reply !== undefined || (!superseded && input.selection !== undefined))
|
|
2534
|
+
&& (current.binding_status !== 'active' || current.principal_status !== 'active')) {
|
|
2535
|
+
throw new DeliveryStoreError('unauthorized-principal', 'model selection authority was revoked before completion');
|
|
2536
|
+
}
|
|
2537
|
+
if (reply !== undefined && reply.bindingId !== current.binding_id) {
|
|
2538
|
+
throw new DeliveryStoreError('conflict', 'model selection reply does not belong to the settlement binding');
|
|
2539
|
+
}
|
|
2540
|
+
if (!superseded && input.selection !== undefined) {
|
|
2541
|
+
if (reply === undefined) {
|
|
2542
|
+
throw new DeliveryStoreError('conflict', 'model selection cannot commit without a durable reply');
|
|
2543
|
+
}
|
|
2544
|
+
if (conversationJson(input.selection.conversation) !== conversationJson(reply.target.conversation)) {
|
|
2545
|
+
throw new DeliveryStoreError('conflict', 'model selection route and reply target different conversations');
|
|
2546
|
+
}
|
|
2547
|
+
this.setModelSelection(input.selection.conversation, input.selection.route);
|
|
2548
|
+
}
|
|
2549
|
+
const outbox = reply === undefined ? undefined : this.enqueue(reply);
|
|
2550
|
+
const now = this.now();
|
|
2551
|
+
const changed = this.database.prepare(`
|
|
2552
|
+
UPDATE model_selection_settlements
|
|
2553
|
+
SET status = 'completed', result_json = ?, outbox_id = ?, claimed_by = NULL,
|
|
2554
|
+
lease_until = NULL, updated_at = ?
|
|
2555
|
+
WHERE operation_id = ? AND payload_hash = ? AND status = ?
|
|
2556
|
+
`).run(completedResultJson, outbox?.id ?? null, now, operationId, input.payloadHash, current.status);
|
|
2557
|
+
if (changed.changes !== 1) {
|
|
2558
|
+
throw new DeliveryStoreError('version-conflict', 'model selection settlement changed before completion');
|
|
2559
|
+
}
|
|
2560
|
+
return JSON.parse(completedResultJson);
|
|
2561
|
+
});
|
|
2562
|
+
}
|
|
2563
|
+
close() {
|
|
2564
|
+
if (this.closed)
|
|
2565
|
+
return;
|
|
2566
|
+
this.closed = true;
|
|
2567
|
+
this.database.close();
|
|
2568
|
+
}
|
|
2569
|
+
transaction(operation) {
|
|
2570
|
+
this.database.exec('BEGIN IMMEDIATE');
|
|
2571
|
+
try {
|
|
2572
|
+
const result = operation();
|
|
2573
|
+
this.database.exec('COMMIT');
|
|
2574
|
+
return result;
|
|
2575
|
+
}
|
|
2576
|
+
catch (error) {
|
|
2577
|
+
this.database.exec('ROLLBACK');
|
|
2578
|
+
throw error;
|
|
2579
|
+
}
|
|
2580
|
+
}
|
|
2581
|
+
advanceModelCommandEpoch(conversation) {
|
|
2582
|
+
const hash = conversationHash(conversation);
|
|
2583
|
+
const json = conversationJson(conversation);
|
|
2584
|
+
const now = this.now();
|
|
2585
|
+
this.database.prepare(`
|
|
2586
|
+
INSERT INTO conversation_model_epochs (conversation_hash, conversation_json, epoch, updated_at)
|
|
2587
|
+
VALUES (?, ?, 1, ?)
|
|
2588
|
+
ON CONFLICT(conversation_hash) DO UPDATE SET
|
|
2589
|
+
conversation_json = excluded.conversation_json,
|
|
2590
|
+
epoch = conversation_model_epochs.epoch + 1,
|
|
2591
|
+
updated_at = excluded.updated_at
|
|
2592
|
+
`).run(hash, json, now);
|
|
2593
|
+
return this.database.prepare(`
|
|
2594
|
+
SELECT epoch FROM conversation_model_epochs
|
|
2595
|
+
WHERE conversation_hash = ? AND conversation_json = ?
|
|
2596
|
+
`).get(hash, json).epoch;
|
|
2597
|
+
}
|
|
2598
|
+
nextBindingGenerationByHash(hash) {
|
|
2599
|
+
const row = this.database.prepare(`
|
|
2600
|
+
SELECT COALESCE(MAX(generation), 0) AS maximum FROM conversation_bindings
|
|
2601
|
+
WHERE conversation_hash = ?
|
|
2602
|
+
`).get(hash);
|
|
2603
|
+
const generation = row.maximum + 1;
|
|
2604
|
+
if (!Number.isSafeInteger(generation) || generation < 1) {
|
|
2605
|
+
throw new DeliveryStoreError('conflict', 'conversation binding generation is exhausted');
|
|
2606
|
+
}
|
|
2607
|
+
return generation;
|
|
2608
|
+
}
|
|
2609
|
+
assertOpen() {
|
|
2610
|
+
if (this.closed)
|
|
2611
|
+
throw new DeliveryStoreError('conflict', 'delivery store is closed');
|
|
2612
|
+
}
|
|
2613
|
+
}
|
|
2614
|
+
//# sourceMappingURL=store.js.map
|