@lazyingart/agent-web 0.1.40
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 +22 -0
- package/README.md +438 -0
- package/docs/architecture.md +503 -0
- package/package.json +43 -0
- package/src/aginti-adapter.js +602 -0
- package/src/chat-context.js +1020 -0
- package/src/chat-migrations.js +947 -0
- package/src/chat-store.js +3308 -0
- package/src/cli.js +134 -0
- package/src/cloud-server.js +2043 -0
- package/src/contracts.js +103 -0
- package/src/deterministic-context-summarizer.js +254 -0
- package/src/direct-chat-capability-limits.js +66 -0
- package/src/direct-chat-contract.js +3 -0
- package/src/errors.js +50 -0
- package/src/http-contract.js +592 -0
- package/src/index.js +88 -0
- package/src/localllm-connector.js +667 -0
- package/src/migrations.js +231 -0
- package/src/operator-health.js +184 -0
- package/src/password-verifier.js +131 -0
- package/src/service-config.js +547 -0
- package/src/service.js +408 -0
- package/src/sqlite-health.js +83 -0
- package/src/storage-path.js +130 -0
- package/src/store.js +914 -0
- package/src/validation.js +181 -0
- package/src/vision-attachment.js +404 -0
- package/src/web/aginti-client.js +552 -0
- package/src/web/aginti-protocol.js +1146 -0
- package/src/web/asset-map.js +462 -0
- package/src/web/browser-app.js +6491 -0
- package/src/web/cloud-session-client.js +427 -0
- package/src/web/direct-chat-client.js +1482 -0
- package/src/web/index.js +10 -0
- package/src/web/presentation-state.js +107 -0
- package/src/web/pwa-assets.js +854 -0
- package/src/web/pwa-update-handoff-store.js +179 -0
- package/src/web/safe-rendering.js +836 -0
- package/src/web/vision-image-client.js +546 -0
- package/src/web/vision-image-sanitizer.js +168 -0
- package/src/web/web-release.js +28 -0
|
@@ -0,0 +1,3308 @@
|
|
|
1
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
ConflictError,
|
|
5
|
+
IdempotencyConflictError,
|
|
6
|
+
NotFoundError,
|
|
7
|
+
StorageCorruptionError,
|
|
8
|
+
ValidationError
|
|
9
|
+
} from './errors.js';
|
|
10
|
+
import {
|
|
11
|
+
CHAT_SQLITE_APPLICATION_ID,
|
|
12
|
+
applyChatMigrations
|
|
13
|
+
} from './chat-migrations.js';
|
|
14
|
+
import { checkOpenSqliteHealth } from './sqlite-health.js';
|
|
15
|
+
import { assertSecureDatabaseFile, prepareSecureDatabasePath } from './storage-path.js';
|
|
16
|
+
import {
|
|
17
|
+
assertCanonicalIsoTimestamp,
|
|
18
|
+
assertEventHash,
|
|
19
|
+
assertExactKeys,
|
|
20
|
+
assertIdentifier,
|
|
21
|
+
assertIdempotencyKey,
|
|
22
|
+
assertInteger,
|
|
23
|
+
canonicalJson,
|
|
24
|
+
nowIso,
|
|
25
|
+
sha256
|
|
26
|
+
} from './validation.js';
|
|
27
|
+
import {
|
|
28
|
+
VISION_ATTACHMENT_LIMITS,
|
|
29
|
+
VISION_MODEL_ALIAS,
|
|
30
|
+
validateStoredVisionAttachment,
|
|
31
|
+
visionAttachmentDescriptor
|
|
32
|
+
} from './vision-attachment.js';
|
|
33
|
+
|
|
34
|
+
const DEFAULT_CLOCK = () => new Date();
|
|
35
|
+
const HASH_PATTERN = /^[a-f0-9]{64}$/u;
|
|
36
|
+
const MODEL_ALIAS_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/u;
|
|
37
|
+
const DISPATCH_LEASE_OWNER_PATTERN = /^[A-Za-z0-9._~:-]{32,256}$/u;
|
|
38
|
+
const MAX_DISPATCH_LEASE_FENCE = Number.MAX_SAFE_INTEGER;
|
|
39
|
+
const FAILURE_CODES = new Set([
|
|
40
|
+
'provider_unavailable',
|
|
41
|
+
'timeout',
|
|
42
|
+
'internal_error',
|
|
43
|
+
'response_limit',
|
|
44
|
+
'content_rejected'
|
|
45
|
+
]);
|
|
46
|
+
const PRE_DISPATCH_FAILURE_CODES = new Set(['provider_unavailable', 'timeout']);
|
|
47
|
+
const DATABASE_METADATA = new WeakMap();
|
|
48
|
+
const MAX_AUDITED_THREAD_CACHE_ENTRIES = 256;
|
|
49
|
+
|
|
50
|
+
export const DIRECT_CHAT_LIMITS = Object.freeze({
|
|
51
|
+
threadsPerAccount: 100,
|
|
52
|
+
messagesPerThread: 2_000,
|
|
53
|
+
ledgerBytesPerThread: 8 * 1024 * 1024,
|
|
54
|
+
messageBytes: 64 * 1024,
|
|
55
|
+
generationsPerThread: 1_024,
|
|
56
|
+
deltasPerGeneration: 8_192,
|
|
57
|
+
deltasPerThread: 32_768,
|
|
58
|
+
deltaBytes: 16 * 1024,
|
|
59
|
+
generationBytes: 64 * 1024,
|
|
60
|
+
journalBytesPerThread: 16 * 1024 * 1024,
|
|
61
|
+
compactionsPerThread: 32,
|
|
62
|
+
summaryBytes: 256 * 1024,
|
|
63
|
+
listPage: 200,
|
|
64
|
+
idempotencyReceiptsPerAccount: 1_024,
|
|
65
|
+
threadDeletionReceiptsPerAccount: 100_000,
|
|
66
|
+
cleanupRows: 256
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
export const DIRECT_CHAT_IDEMPOTENCY_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
70
|
+
export const DIRECT_CHAT_TERMINAL_DELTA_RETENTION_MS = 24 * 60 * 60 * 1000;
|
|
71
|
+
export const DIRECT_CHAT_DISPATCH_LEASE_LIMITS = Object.freeze({
|
|
72
|
+
minimumTtlMs: 1_000,
|
|
73
|
+
maximumTtlMs: 5 * 60 * 1_000,
|
|
74
|
+
maximumFence: MAX_DISPATCH_LEASE_FENCE
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
function utf8Bytes(value) {
|
|
78
|
+
return Buffer.byteLength(value, 'utf8');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function assertUnicodeScalarString(value, name, { minBytes = 1, maxBytes }) {
|
|
82
|
+
if (typeof value !== 'string') throw new ValidationError(`${name} must be a string.`);
|
|
83
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
84
|
+
const code = value.charCodeAt(index);
|
|
85
|
+
if (code >= 0xd800 && code <= 0xdbff) {
|
|
86
|
+
const next = value.charCodeAt(index + 1);
|
|
87
|
+
if (!(next >= 0xdc00 && next <= 0xdfff)) {
|
|
88
|
+
throw new ValidationError(`${name} must not contain unpaired UTF-16 surrogates.`);
|
|
89
|
+
}
|
|
90
|
+
index += 1;
|
|
91
|
+
} else if (code >= 0xdc00 && code <= 0xdfff) {
|
|
92
|
+
throw new ValidationError(`${name} must not contain unpaired UTF-16 surrogates.`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (/\u0000/u.test(value)) throw new ValidationError(`${name} must not contain NUL bytes.`);
|
|
96
|
+
const bytes = utf8Bytes(value);
|
|
97
|
+
if (bytes < minBytes || bytes > maxBytes) {
|
|
98
|
+
throw new ValidationError(`${name} must contain between ${minBytes} and ${maxBytes} UTF-8 bytes.`);
|
|
99
|
+
}
|
|
100
|
+
return { value, bytes };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function assertModelAlias(value) {
|
|
104
|
+
if (typeof value !== 'string' || !MODEL_ALIAS_PATTERN.test(value)) {
|
|
105
|
+
throw new ValidationError('modelAlias must be a server-selected portable alias, not a provider URL or path.');
|
|
106
|
+
}
|
|
107
|
+
return value;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function assertFailureCode(value) {
|
|
111
|
+
if (typeof value !== 'string' || !FAILURE_CODES.has(value)) {
|
|
112
|
+
throw new ValidationError('failureCode is not an approved server-side failure category.');
|
|
113
|
+
}
|
|
114
|
+
return value;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function assertDispatchLeaseOwnerToken(value) {
|
|
118
|
+
if (typeof value !== 'string' || !DISPATCH_LEASE_OWNER_PATTERN.test(value)) {
|
|
119
|
+
throw new ValidationError(
|
|
120
|
+
'ownerToken must be a 32-256 character opaque restricted-ASCII dispatch identity.'
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
return value;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function assertDispatchLeaseTtl(value) {
|
|
127
|
+
return assertInteger(value, 'ttlMs', {
|
|
128
|
+
min: DIRECT_CHAT_DISPATCH_LEASE_LIMITS.minimumTtlMs,
|
|
129
|
+
max: DIRECT_CHAT_DISPATCH_LEASE_LIMITS.maximumTtlMs
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function assertDispatchLeaseProof(value) {
|
|
134
|
+
if (value === undefined) return null;
|
|
135
|
+
assertExactKeys(
|
|
136
|
+
value,
|
|
137
|
+
{ required: ['ownerToken', 'fence'] },
|
|
138
|
+
'direct chat generation dispatch lease proof'
|
|
139
|
+
);
|
|
140
|
+
const ownerToken = assertDispatchLeaseOwnerToken(value.ownerToken);
|
|
141
|
+
return Object.freeze({
|
|
142
|
+
ownerHash: sha256(ownerToken),
|
|
143
|
+
fence: assertInteger(value.fence, 'dispatchLease.fence', {
|
|
144
|
+
min: 1,
|
|
145
|
+
max: MAX_DISPATCH_LEASE_FENCE
|
|
146
|
+
})
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function addMilliseconds(timestamp, milliseconds) {
|
|
151
|
+
return new Date(new Date(timestamp).getTime() + milliseconds).toISOString();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function isConstraintError(error) {
|
|
155
|
+
return typeof error?.code === 'string' && error.code.startsWith('ERR_SQLITE_CONSTRAINT');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function threadView(row) {
|
|
159
|
+
if (!row) return null;
|
|
160
|
+
return {
|
|
161
|
+
accountId: row.account_id,
|
|
162
|
+
threadId: row.thread_id,
|
|
163
|
+
title: row.title,
|
|
164
|
+
modelAlias: row.model_alias,
|
|
165
|
+
revision: Number(row.ledger_revision),
|
|
166
|
+
ledgerHash: row.ledger_hash,
|
|
167
|
+
messageCount: Number(row.message_count),
|
|
168
|
+
ledgerBytes: Number(row.ledger_bytes),
|
|
169
|
+
currentGenerationId: row.current_generation_id,
|
|
170
|
+
createdAt: row.created_at,
|
|
171
|
+
updatedAt: row.updated_at
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function attachmentViewFields(attachments) {
|
|
176
|
+
if (!Array.isArray(attachments) || attachments.length === 0) return {};
|
|
177
|
+
const descriptors = Object.freeze(attachments.map(visionAttachmentDescriptor));
|
|
178
|
+
// Preserve the v3 one-image ledger/API representation so existing rows,
|
|
179
|
+
// idempotency receipts, and the immediately previous PWA remain valid.
|
|
180
|
+
return descriptors.length === 1
|
|
181
|
+
? { attachment: descriptors[0] }
|
|
182
|
+
: { attachments: descriptors };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function messageView(row, attachments = []) {
|
|
186
|
+
if (!row) return null;
|
|
187
|
+
return {
|
|
188
|
+
accountId: row.account_id,
|
|
189
|
+
threadId: row.thread_id,
|
|
190
|
+
messageId: row.message_id,
|
|
191
|
+
revision: Number(row.revision),
|
|
192
|
+
role: row.role,
|
|
193
|
+
content: row.content,
|
|
194
|
+
contentBytes: Number(row.content_bytes),
|
|
195
|
+
previousHash: row.previous_hash,
|
|
196
|
+
messageHash: row.message_hash,
|
|
197
|
+
generationId: row.generation_id,
|
|
198
|
+
createdAt: row.created_at,
|
|
199
|
+
...attachmentViewFields(attachments)
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function attachmentFromRow(row) {
|
|
204
|
+
if (!row) return null;
|
|
205
|
+
let checked;
|
|
206
|
+
try {
|
|
207
|
+
checked = validateStoredVisionAttachment({
|
|
208
|
+
accountId: row.account_id,
|
|
209
|
+
threadId: row.thread_id,
|
|
210
|
+
attachmentId: row.attachment_id,
|
|
211
|
+
messageId: row.message_id,
|
|
212
|
+
position: Number(row.position),
|
|
213
|
+
mediaType: row.media_type,
|
|
214
|
+
byteLength: Number(row.byte_length),
|
|
215
|
+
width: Number(row.width),
|
|
216
|
+
height: Number(row.height),
|
|
217
|
+
contentSha256: row.content_sha256,
|
|
218
|
+
content: row.content,
|
|
219
|
+
createdAt: row.created_at
|
|
220
|
+
});
|
|
221
|
+
} catch (error) {
|
|
222
|
+
throw new StorageCorruptionError('A stored direct-chat vision attachment is invalid.', { cause: error });
|
|
223
|
+
}
|
|
224
|
+
assertStoredIdentifier(checked.accountId, 'attachment.accountId');
|
|
225
|
+
assertStoredIdentifier(checked.threadId, 'attachment.threadId');
|
|
226
|
+
assertStoredIdentifier(checked.messageId, 'attachment.messageId');
|
|
227
|
+
assertStoredTimestamp(checked.createdAt, 'attachment.createdAt');
|
|
228
|
+
if (!Number.isSafeInteger(checked.position) || checked.position < 0
|
|
229
|
+
|| checked.position >= VISION_ATTACHMENT_LIMITS.attachmentsPerMessage) {
|
|
230
|
+
throw new StorageCorruptionError('A stored direct-chat vision attachment position is invalid.');
|
|
231
|
+
}
|
|
232
|
+
return checked;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function attachmentDescriptorFromRow(row) {
|
|
236
|
+
if (!row) return null;
|
|
237
|
+
try {
|
|
238
|
+
assertStoredIdentifier(row.account_id, 'attachment.accountId');
|
|
239
|
+
assertStoredIdentifier(row.thread_id, 'attachment.threadId');
|
|
240
|
+
assertStoredIdentifier(row.attachment_id, 'attachment.attachmentId');
|
|
241
|
+
assertStoredIdentifier(row.message_id, 'attachment.messageId');
|
|
242
|
+
assertStoredTimestamp(row.created_at, 'attachment.createdAt');
|
|
243
|
+
} catch (error) {
|
|
244
|
+
if (error instanceof StorageCorruptionError) throw error;
|
|
245
|
+
throw new StorageCorruptionError('A stored direct-chat attachment descriptor is invalid.', { cause: error });
|
|
246
|
+
}
|
|
247
|
+
const byteLength = Number(row.byte_length);
|
|
248
|
+
const width = Number(row.width);
|
|
249
|
+
const height = Number(row.height);
|
|
250
|
+
const position = Number(row.position);
|
|
251
|
+
if (!['image/jpeg', 'image/png'].includes(row.media_type)
|
|
252
|
+
|| !Number.isSafeInteger(byteLength) || byteLength < 1 || byteLength > VISION_ATTACHMENT_LIMITS.bytes
|
|
253
|
+
|| !Number.isSafeInteger(width) || width < 1 || width > VISION_ATTACHMENT_LIMITS.maximumEdge
|
|
254
|
+
|| !Number.isSafeInteger(height) || height < 1 || height > VISION_ATTACHMENT_LIMITS.maximumEdge
|
|
255
|
+
|| width * height > VISION_ATTACHMENT_LIMITS.pixels
|
|
256
|
+
|| !Number.isSafeInteger(position) || position < 0
|
|
257
|
+
|| position >= VISION_ATTACHMENT_LIMITS.attachmentsPerMessage
|
|
258
|
+
|| !HASH_PATTERN.test(row.content_sha256)
|
|
259
|
+
|| (row.content_length !== undefined && Number(row.content_length) !== byteLength)) {
|
|
260
|
+
throw new StorageCorruptionError('A stored direct-chat attachment descriptor is inconsistent.');
|
|
261
|
+
}
|
|
262
|
+
return Object.freeze({
|
|
263
|
+
accountId: row.account_id,
|
|
264
|
+
threadId: row.thread_id,
|
|
265
|
+
attachmentId: row.attachment_id,
|
|
266
|
+
messageId: row.message_id,
|
|
267
|
+
position,
|
|
268
|
+
mediaType: row.media_type,
|
|
269
|
+
byteLength,
|
|
270
|
+
width,
|
|
271
|
+
height,
|
|
272
|
+
contentSha256: row.content_sha256,
|
|
273
|
+
createdAt: row.created_at,
|
|
274
|
+
descriptor: visionAttachmentDescriptor({
|
|
275
|
+
attachmentId: row.attachment_id,
|
|
276
|
+
mediaType: row.media_type,
|
|
277
|
+
byteLength,
|
|
278
|
+
width,
|
|
279
|
+
height,
|
|
280
|
+
contentSha256: row.content_sha256
|
|
281
|
+
})
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function attachmentsForMessage(database, accountId, threadId, messageId) {
|
|
286
|
+
if ((DATABASE_METADATA.get(database)?.schemaVersion ?? 0) < 3) return Object.freeze([]);
|
|
287
|
+
return Object.freeze(database.prepare(`
|
|
288
|
+
SELECT * FROM direct_chat_attachments
|
|
289
|
+
WHERE account_id = ? AND thread_id = ? AND message_id = ?
|
|
290
|
+
ORDER BY position
|
|
291
|
+
`).all(accountId, threadId, messageId).map(attachmentFromRow));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function generationView(row) {
|
|
295
|
+
if (!row) return null;
|
|
296
|
+
return {
|
|
297
|
+
accountId: row.account_id,
|
|
298
|
+
threadId: row.thread_id,
|
|
299
|
+
generationId: row.generation_id,
|
|
300
|
+
assistantMessageId: row.assistant_message_id,
|
|
301
|
+
status: row.status,
|
|
302
|
+
terminal: row.status !== 'in_progress',
|
|
303
|
+
modelAlias: row.model_alias,
|
|
304
|
+
sourceRevision: Number(row.source_revision),
|
|
305
|
+
sourceHash: row.source_hash,
|
|
306
|
+
deltaCount: Number(row.delta_count),
|
|
307
|
+
deltaBytes: Number(row.delta_bytes),
|
|
308
|
+
lastDeltaHash: row.last_delta_hash,
|
|
309
|
+
finalRevision: row.final_revision === null ? null : Number(row.final_revision),
|
|
310
|
+
finalHash: row.final_hash,
|
|
311
|
+
failureCode: row.failure_code,
|
|
312
|
+
deltasPruned: row.deltas_pruned === 1,
|
|
313
|
+
startedAt: row.started_at,
|
|
314
|
+
updatedAt: row.updated_at,
|
|
315
|
+
terminalAt: row.terminal_at,
|
|
316
|
+
prunedAt: row.pruned_at
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function deltaView(row) {
|
|
321
|
+
if (!row) return null;
|
|
322
|
+
return {
|
|
323
|
+
accountId: row.account_id,
|
|
324
|
+
threadId: row.thread_id,
|
|
325
|
+
generationId: row.generation_id,
|
|
326
|
+
sequence: Number(row.sequence),
|
|
327
|
+
content: row.content,
|
|
328
|
+
contentBytes: Number(row.content_bytes),
|
|
329
|
+
previousHash: row.previous_hash,
|
|
330
|
+
deltaHash: row.delta_hash,
|
|
331
|
+
createdAt: row.created_at
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function compactionView(row) {
|
|
336
|
+
if (!row) return null;
|
|
337
|
+
return {
|
|
338
|
+
accountId: row.account_id,
|
|
339
|
+
threadId: row.thread_id,
|
|
340
|
+
snapshotId: row.snapshot_id,
|
|
341
|
+
sourceStartRevision: Number(row.source_start_revision),
|
|
342
|
+
sourceStartHash: row.source_start_hash,
|
|
343
|
+
sourceEndRevision: Number(row.source_end_revision),
|
|
344
|
+
sourceEndHash: row.source_end_hash,
|
|
345
|
+
summaryText: row.summary_text,
|
|
346
|
+
summaryBytes: Number(row.summary_bytes),
|
|
347
|
+
summaryHash: row.summary_hash,
|
|
348
|
+
untrustedDirectChatData: true,
|
|
349
|
+
createdAt: row.created_at
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function threadDeletionView(row) {
|
|
354
|
+
if (!row) return null;
|
|
355
|
+
return {
|
|
356
|
+
accountId: row.account_id,
|
|
357
|
+
threadId: row.thread_id,
|
|
358
|
+
deleted: true,
|
|
359
|
+
revision: Number(row.deleted_revision),
|
|
360
|
+
ledgerHash: row.deleted_hash,
|
|
361
|
+
deletedAt: row.deleted_at
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function threadDeletionRequest(accountId, threadId, cursor) {
|
|
366
|
+
return {
|
|
367
|
+
accountId,
|
|
368
|
+
threadId,
|
|
369
|
+
expectedRevision: cursor.revision,
|
|
370
|
+
expectedHash: cursor.hash
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function threadDeletionDigest(result) {
|
|
375
|
+
return {
|
|
376
|
+
accountId: result.accountId,
|
|
377
|
+
threadId: result.threadId,
|
|
378
|
+
deleted: result.deleted,
|
|
379
|
+
revision: result.revision,
|
|
380
|
+
ledgerHash: result.ledgerHash,
|
|
381
|
+
deletedAt: result.deletedAt
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function dispatchLeaseView(row, timestamp) {
|
|
386
|
+
if (!row) return null;
|
|
387
|
+
const active = row.released_at === null && row.expires_at > timestamp;
|
|
388
|
+
return {
|
|
389
|
+
accountId: row.account_id,
|
|
390
|
+
threadId: row.thread_id,
|
|
391
|
+
generationId: row.generation_id,
|
|
392
|
+
fence: Number(row.fence),
|
|
393
|
+
phase: row.phase,
|
|
394
|
+
expiresAt: row.expires_at,
|
|
395
|
+
claimedAt: row.claimed_at,
|
|
396
|
+
dispatchStartedAt: row.dispatch_started_at,
|
|
397
|
+
updatedAt: row.updated_at,
|
|
398
|
+
releasedAt: row.released_at,
|
|
399
|
+
active,
|
|
400
|
+
dispatchMayHaveStarted: row.dispatch_started_at !== null,
|
|
401
|
+
dispatchAmbiguous: row.phase === 'interrupted' || (row.phase === 'dispatch_started' && !active)
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function calculateMessageHash(row, attachments = []) {
|
|
406
|
+
return sha256(canonicalJson({
|
|
407
|
+
accountId: row.account_id,
|
|
408
|
+
threadId: row.thread_id,
|
|
409
|
+
messageId: row.message_id,
|
|
410
|
+
revision: Number(row.revision),
|
|
411
|
+
role: row.role,
|
|
412
|
+
content: row.content,
|
|
413
|
+
contentBytes: Number(row.content_bytes),
|
|
414
|
+
previousHash: row.previous_hash,
|
|
415
|
+
generationId: row.generation_id,
|
|
416
|
+
createdAt: row.created_at,
|
|
417
|
+
...attachmentViewFields(attachments)
|
|
418
|
+
}));
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function calculateDeltaHash(row) {
|
|
422
|
+
return sha256(canonicalJson({
|
|
423
|
+
accountId: row.account_id,
|
|
424
|
+
threadId: row.thread_id,
|
|
425
|
+
generationId: row.generation_id,
|
|
426
|
+
sequence: Number(row.sequence),
|
|
427
|
+
content: row.content,
|
|
428
|
+
contentBytes: Number(row.content_bytes),
|
|
429
|
+
previousHash: row.previous_hash,
|
|
430
|
+
createdAt: row.created_at
|
|
431
|
+
}));
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function assertStoredTimestamp(value, label) {
|
|
435
|
+
try {
|
|
436
|
+
assertCanonicalIsoTimestamp(value, label);
|
|
437
|
+
} catch (error) {
|
|
438
|
+
throw new StorageCorruptionError(`Stored ${label} is invalid.`, { cause: error });
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function assertStoredIdentifier(value, label) {
|
|
443
|
+
try {
|
|
444
|
+
assertIdentifier(value, label);
|
|
445
|
+
} catch (error) {
|
|
446
|
+
throw new StorageCorruptionError(`Stored ${label} is invalid.`, { cause: error });
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function assertStoredText(value, label, limits) {
|
|
451
|
+
try {
|
|
452
|
+
return assertUnicodeScalarString(value, label, limits);
|
|
453
|
+
} catch (error) {
|
|
454
|
+
throw new StorageCorruptionError(`Stored ${label} is invalid.`, { cause: error });
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function auditDispatchLease(row) {
|
|
459
|
+
assertStoredIdentifier(row.account_id, 'dispatch_lease.account_id');
|
|
460
|
+
assertStoredIdentifier(row.thread_id, 'dispatch_lease.thread_id');
|
|
461
|
+
assertStoredIdentifier(row.generation_id, 'dispatch_lease.generation_id');
|
|
462
|
+
if (!HASH_PATTERN.test(row.owner_hash)) {
|
|
463
|
+
throw new StorageCorruptionError('A stored generation dispatch lease owner digest is invalid.');
|
|
464
|
+
}
|
|
465
|
+
if (!Number.isSafeInteger(Number(row.fence)) || Number(row.fence) < 1) {
|
|
466
|
+
throw new StorageCorruptionError('A stored generation dispatch lease fence is invalid.');
|
|
467
|
+
}
|
|
468
|
+
if (!['claimed', 'dispatch_started', 'released', 'interrupted'].includes(row.phase)) {
|
|
469
|
+
throw new StorageCorruptionError('A stored generation dispatch lease phase is invalid.');
|
|
470
|
+
}
|
|
471
|
+
assertStoredTimestamp(row.expires_at, 'dispatch_lease.expires_at');
|
|
472
|
+
assertStoredTimestamp(row.claimed_at, 'dispatch_lease.claimed_at');
|
|
473
|
+
if (row.dispatch_started_at !== null) {
|
|
474
|
+
assertStoredTimestamp(row.dispatch_started_at, 'dispatch_lease.dispatch_started_at');
|
|
475
|
+
}
|
|
476
|
+
assertStoredTimestamp(row.updated_at, 'dispatch_lease.updated_at');
|
|
477
|
+
if (row.released_at !== null) {
|
|
478
|
+
assertStoredTimestamp(row.released_at, 'dispatch_lease.released_at');
|
|
479
|
+
}
|
|
480
|
+
if (
|
|
481
|
+
row.updated_at < row.claimed_at ||
|
|
482
|
+
(row.dispatch_started_at !== null && (
|
|
483
|
+
row.dispatch_started_at < row.claimed_at || row.dispatch_started_at > row.updated_at
|
|
484
|
+
)) ||
|
|
485
|
+
(['claimed', 'dispatch_started'].includes(row.phase) && (
|
|
486
|
+
row.released_at !== null || row.expires_at <= row.updated_at
|
|
487
|
+
)) ||
|
|
488
|
+
(['released', 'interrupted'].includes(row.phase) && (
|
|
489
|
+
row.released_at === null ||
|
|
490
|
+
row.expires_at !== row.released_at ||
|
|
491
|
+
row.updated_at !== row.released_at ||
|
|
492
|
+
row.released_at < row.claimed_at
|
|
493
|
+
)) ||
|
|
494
|
+
(row.phase === 'claimed' && row.dispatch_started_at !== null) ||
|
|
495
|
+
(['dispatch_started', 'interrupted'].includes(row.phase) && row.dispatch_started_at === null)
|
|
496
|
+
) {
|
|
497
|
+
throw new StorageCorruptionError('A stored generation dispatch lease lifetime is inconsistent.');
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function requireThread(database, accountId, threadId) {
|
|
502
|
+
const row = database.prepare(`
|
|
503
|
+
SELECT * FROM direct_chat_threads WHERE account_id = ? AND thread_id = ?
|
|
504
|
+
`).get(accountId, threadId);
|
|
505
|
+
if (!row) throw new NotFoundError();
|
|
506
|
+
return row;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function requireGeneration(database, accountId, threadId, generationId) {
|
|
510
|
+
const row = database.prepare(`
|
|
511
|
+
SELECT * FROM direct_chat_generations
|
|
512
|
+
WHERE account_id = ? AND thread_id = ? AND generation_id = ?
|
|
513
|
+
`).get(accountId, threadId, generationId);
|
|
514
|
+
if (!row) throw new NotFoundError();
|
|
515
|
+
return row;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function auditGeneration(database, generation) {
|
|
519
|
+
assertStoredIdentifier(generation.account_id, 'generation.account_id');
|
|
520
|
+
assertStoredIdentifier(generation.thread_id, 'generation.thread_id');
|
|
521
|
+
assertStoredIdentifier(generation.generation_id, 'generation.generation_id');
|
|
522
|
+
assertStoredIdentifier(generation.assistant_message_id, 'generation.assistant_message_id');
|
|
523
|
+
assertStoredTimestamp(generation.started_at, 'generation.started_at');
|
|
524
|
+
assertStoredTimestamp(generation.updated_at, 'generation.updated_at');
|
|
525
|
+
if (generation.terminal_at !== null) assertStoredTimestamp(generation.terminal_at, 'generation.terminal_at');
|
|
526
|
+
if (generation.pruned_at !== null) assertStoredTimestamp(generation.pruned_at, 'generation.pruned_at');
|
|
527
|
+
|
|
528
|
+
const deltas = database.prepare(`
|
|
529
|
+
SELECT * FROM direct_chat_deltas
|
|
530
|
+
WHERE account_id = ? AND thread_id = ? AND generation_id = ?
|
|
531
|
+
ORDER BY sequence
|
|
532
|
+
`).all(generation.account_id, generation.thread_id, generation.generation_id);
|
|
533
|
+
let previousHash = null;
|
|
534
|
+
let bytes = 0;
|
|
535
|
+
for (let index = 0; index < deltas.length; index += 1) {
|
|
536
|
+
const delta = deltas[index];
|
|
537
|
+
assertStoredIdentifier(delta.account_id, 'delta.account_id');
|
|
538
|
+
assertStoredIdentifier(delta.thread_id, 'delta.thread_id');
|
|
539
|
+
assertStoredIdentifier(delta.generation_id, 'delta.generation_id');
|
|
540
|
+
assertStoredText(delta.content, 'delta.content', { maxBytes: DIRECT_CHAT_LIMITS.deltaBytes });
|
|
541
|
+
const expectedSequence = index + 1;
|
|
542
|
+
if (
|
|
543
|
+
Number(delta.sequence) !== expectedSequence ||
|
|
544
|
+
delta.previous_hash !== previousHash ||
|
|
545
|
+
Number(delta.content_bytes) !== utf8Bytes(delta.content) ||
|
|
546
|
+
calculateDeltaHash(delta) !== delta.delta_hash
|
|
547
|
+
) {
|
|
548
|
+
throw new StorageCorruptionError('A direct-chat generation delta hash chain is inconsistent.');
|
|
549
|
+
}
|
|
550
|
+
assertStoredTimestamp(delta.created_at, 'delta.created_at');
|
|
551
|
+
bytes += Number(delta.content_bytes);
|
|
552
|
+
previousHash = delta.delta_hash;
|
|
553
|
+
}
|
|
554
|
+
if (generation.deltas_pruned === 1) {
|
|
555
|
+
if (
|
|
556
|
+
generation.status !== 'completed' || generation.pruned_at === null || deltas.length !== 0 ||
|
|
557
|
+
Number(generation.delta_count) < 1 || Number(generation.delta_count) > DIRECT_CHAT_LIMITS.deltasPerGeneration ||
|
|
558
|
+
Number(generation.delta_bytes) < 1 || Number(generation.delta_bytes) > DIRECT_CHAT_LIMITS.generationBytes ||
|
|
559
|
+
!HASH_PATTERN.test(generation.last_delta_hash)
|
|
560
|
+
) {
|
|
561
|
+
throw new StorageCorruptionError('A pruned direct-chat generation has invalid retained metadata.');
|
|
562
|
+
}
|
|
563
|
+
return deltas;
|
|
564
|
+
}
|
|
565
|
+
if (
|
|
566
|
+
deltas.length !== Number(generation.delta_count) ||
|
|
567
|
+
bytes !== Number(generation.delta_bytes) ||
|
|
568
|
+
previousHash !== generation.last_delta_hash ||
|
|
569
|
+
deltas.length > DIRECT_CHAT_LIMITS.deltasPerGeneration ||
|
|
570
|
+
bytes > DIRECT_CHAT_LIMITS.generationBytes
|
|
571
|
+
) {
|
|
572
|
+
throw new StorageCorruptionError('A direct-chat generation journal is inconsistent.');
|
|
573
|
+
}
|
|
574
|
+
return deltas;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function auditThread(database, thread) {
|
|
578
|
+
assertStoredIdentifier(thread.account_id, 'thread.account_id');
|
|
579
|
+
assertStoredIdentifier(thread.thread_id, 'thread.thread_id');
|
|
580
|
+
assertStoredText(thread.title, 'thread.title', { minBytes: 0, maxBytes: 512 });
|
|
581
|
+
assertStoredTimestamp(thread.created_at, 'thread.created_at');
|
|
582
|
+
assertStoredTimestamp(thread.updated_at, 'thread.updated_at');
|
|
583
|
+
if (!MODEL_ALIAS_PATTERN.test(thread.model_alias)) {
|
|
584
|
+
throw new StorageCorruptionError('A stored direct-chat model alias is invalid.');
|
|
585
|
+
}
|
|
586
|
+
const messages = database.prepare(`
|
|
587
|
+
SELECT * FROM direct_chat_messages
|
|
588
|
+
WHERE account_id = ? AND thread_id = ?
|
|
589
|
+
ORDER BY revision
|
|
590
|
+
`).all(thread.account_id, thread.thread_id);
|
|
591
|
+
const metadata = DATABASE_METADATA.get(database);
|
|
592
|
+
const attachmentRows = metadata?.schemaVersion >= 3
|
|
593
|
+
? database.prepare(`
|
|
594
|
+
SELECT account_id, thread_id, attachment_id, message_id, position, media_type,
|
|
595
|
+
byte_length, width, height, content_sha256, created_at,
|
|
596
|
+
length(content) AS content_length
|
|
597
|
+
FROM direct_chat_attachments
|
|
598
|
+
WHERE account_id = ? AND thread_id = ?
|
|
599
|
+
ORDER BY created_at, message_id, position
|
|
600
|
+
`).all(thread.account_id, thread.thread_id)
|
|
601
|
+
: [];
|
|
602
|
+
if (attachmentRows.length > VISION_ATTACHMENT_LIMITS.attachmentsPerThread) {
|
|
603
|
+
throw new StorageCorruptionError('A direct-chat thread exceeds its vision attachment count bound.');
|
|
604
|
+
}
|
|
605
|
+
const attachments = attachmentRows.map(attachmentDescriptorFromRow);
|
|
606
|
+
const attachmentByMessage = new Map();
|
|
607
|
+
for (const attachment of attachments) {
|
|
608
|
+
const owned = attachmentByMessage.get(attachment.messageId) ?? [];
|
|
609
|
+
if (attachment.position !== owned.length
|
|
610
|
+
|| owned.length >= VISION_ATTACHMENT_LIMITS.attachmentsPerMessage) {
|
|
611
|
+
throw new StorageCorruptionError('A direct-chat message has inconsistent vision attachment positions.');
|
|
612
|
+
}
|
|
613
|
+
owned.push(attachment);
|
|
614
|
+
attachmentByMessage.set(attachment.messageId, owned);
|
|
615
|
+
}
|
|
616
|
+
const attachmentBytes = attachments.reduce((total, attachment) => total + attachment.byteLength, 0);
|
|
617
|
+
if (attachmentBytes > VISION_ATTACHMENT_LIMITS.bytesPerThread) {
|
|
618
|
+
throw new StorageCorruptionError('A direct-chat thread exceeds its vision attachment storage bound.');
|
|
619
|
+
}
|
|
620
|
+
for (const owned of attachmentByMessage.values()) {
|
|
621
|
+
if (owned.reduce((total, attachment) => total + attachment.byteLength, 0)
|
|
622
|
+
> VISION_ATTACHMENT_LIMITS.bytesPerMessage) {
|
|
623
|
+
throw new StorageCorruptionError('A direct-chat message exceeds its vision attachment storage bound.');
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
const messageRevisionById = new Map(messages.map((message) => [message.message_id, Number(message.revision)]));
|
|
627
|
+
const firstVisionRevision = attachments.reduce((minimum, attachment) => {
|
|
628
|
+
const revision = messageRevisionById.get(attachment.messageId);
|
|
629
|
+
if (revision === undefined) {
|
|
630
|
+
throw new StorageCorruptionError('A direct-chat vision attachment has no ledger message.');
|
|
631
|
+
}
|
|
632
|
+
return Math.min(minimum, revision);
|
|
633
|
+
}, Number.POSITIVE_INFINITY);
|
|
634
|
+
let previousHash = null;
|
|
635
|
+
let bytes = 0;
|
|
636
|
+
for (let index = 0; index < messages.length; index += 1) {
|
|
637
|
+
const message = messages[index];
|
|
638
|
+
assertStoredIdentifier(message.account_id, 'message.account_id');
|
|
639
|
+
assertStoredIdentifier(message.thread_id, 'message.thread_id');
|
|
640
|
+
assertStoredIdentifier(message.message_id, 'message.message_id');
|
|
641
|
+
if (message.generation_id !== null) {
|
|
642
|
+
assertStoredIdentifier(message.generation_id, 'message.generation_id');
|
|
643
|
+
}
|
|
644
|
+
const messageAttachments = attachmentByMessage.get(message.message_id) ?? [];
|
|
645
|
+
if (messageAttachments.length > 0 && message.role !== 'user') {
|
|
646
|
+
throw new StorageCorruptionError('A direct-chat vision attachment is not bound to a user message.');
|
|
647
|
+
}
|
|
648
|
+
assertStoredText(message.content, 'message.content', { maxBytes: DIRECT_CHAT_LIMITS.messageBytes });
|
|
649
|
+
const expectedRevision = index + 1;
|
|
650
|
+
if (
|
|
651
|
+
Number(message.revision) !== expectedRevision ||
|
|
652
|
+
message.previous_hash !== previousHash ||
|
|
653
|
+
Number(message.content_bytes) !== utf8Bytes(message.content) ||
|
|
654
|
+
calculateMessageHash(message, messageAttachments) !== message.message_hash
|
|
655
|
+
) {
|
|
656
|
+
throw new StorageCorruptionError('A direct-chat message ledger hash chain is inconsistent.');
|
|
657
|
+
}
|
|
658
|
+
assertStoredTimestamp(message.created_at, 'message.created_at');
|
|
659
|
+
bytes += Number(message.content_bytes);
|
|
660
|
+
previousHash = message.message_hash;
|
|
661
|
+
}
|
|
662
|
+
if (
|
|
663
|
+
messages.length !== Number(thread.message_count) ||
|
|
664
|
+
messages.length !== Number(thread.ledger_revision) ||
|
|
665
|
+
bytes !== Number(thread.ledger_bytes) ||
|
|
666
|
+
previousHash !== thread.ledger_hash ||
|
|
667
|
+
messages.length > DIRECT_CHAT_LIMITS.messagesPerThread ||
|
|
668
|
+
bytes > DIRECT_CHAT_LIMITS.ledgerBytesPerThread
|
|
669
|
+
) {
|
|
670
|
+
throw new StorageCorruptionError('A direct-chat thread ledger is inconsistent.');
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
const generations = database.prepare(`
|
|
674
|
+
SELECT * FROM direct_chat_generations
|
|
675
|
+
WHERE account_id = ? AND thread_id = ?
|
|
676
|
+
ORDER BY started_at, generation_id
|
|
677
|
+
`).all(thread.account_id, thread.thread_id);
|
|
678
|
+
let activeId = null;
|
|
679
|
+
let totalDeltas = 0;
|
|
680
|
+
let totalJournalBytes = 0;
|
|
681
|
+
const completedGenerationIds = new Set();
|
|
682
|
+
for (const generation of generations) {
|
|
683
|
+
const source = messages[Number(generation.source_revision) - 1];
|
|
684
|
+
const hasVision = firstVisionRevision <= Number(generation.source_revision);
|
|
685
|
+
const expectedModelAlias = hasVision ? metadata.visionModelAlias : thread.model_alias;
|
|
686
|
+
if (
|
|
687
|
+
!source || source.message_hash !== generation.source_hash || source.role !== 'user' ||
|
|
688
|
+
generation.model_alias !== expectedModelAlias
|
|
689
|
+
) {
|
|
690
|
+
throw new StorageCorruptionError('A direct-chat generation does not reference an exact user-ledger state.');
|
|
691
|
+
}
|
|
692
|
+
const deltas = auditGeneration(database, generation);
|
|
693
|
+
if (generation.deltas_pruned === 0) {
|
|
694
|
+
totalDeltas += deltas.length;
|
|
695
|
+
totalJournalBytes += Number(generation.delta_bytes);
|
|
696
|
+
}
|
|
697
|
+
if (generation.status === 'in_progress') {
|
|
698
|
+
if (activeId !== null) throw new StorageCorruptionError('A thread has multiple active generations.');
|
|
699
|
+
activeId = generation.generation_id;
|
|
700
|
+
}
|
|
701
|
+
if (generation.status === 'completed') {
|
|
702
|
+
const finalMessage = messages[Number(generation.final_revision) - 1];
|
|
703
|
+
if (
|
|
704
|
+
Number(generation.final_revision) !== Number(generation.source_revision) + 1 ||
|
|
705
|
+
!finalMessage ||
|
|
706
|
+
finalMessage.role !== 'assistant' ||
|
|
707
|
+
finalMessage.generation_id !== generation.generation_id ||
|
|
708
|
+
finalMessage.message_id !== generation.assistant_message_id ||
|
|
709
|
+
finalMessage.message_hash !== generation.final_hash ||
|
|
710
|
+
(generation.deltas_pruned === 0 && finalMessage.content !== deltas.map((delta) => delta.content).join(''))
|
|
711
|
+
) {
|
|
712
|
+
throw new StorageCorruptionError('A completed direct-chat generation has no exact assistant-ledger turn.');
|
|
713
|
+
}
|
|
714
|
+
completedGenerationIds.add(generation.generation_id);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
for (const message of messages) {
|
|
718
|
+
if (message.role === 'assistant' && !completedGenerationIds.has(message.generation_id)) {
|
|
719
|
+
throw new StorageCorruptionError('An assistant-ledger turn has no completed direct-chat generation.');
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
if (
|
|
723
|
+
generations.length !== Number(thread.generation_count) ||
|
|
724
|
+
generations.length > DIRECT_CHAT_LIMITS.generationsPerThread ||
|
|
725
|
+
totalDeltas !== Number(thread.journal_delta_count) ||
|
|
726
|
+
totalDeltas > DIRECT_CHAT_LIMITS.deltasPerThread ||
|
|
727
|
+
totalJournalBytes !== Number(thread.journal_bytes) ||
|
|
728
|
+
totalJournalBytes > DIRECT_CHAT_LIMITS.journalBytesPerThread ||
|
|
729
|
+
activeId !== thread.current_generation_id
|
|
730
|
+
) {
|
|
731
|
+
throw new StorageCorruptionError('A direct-chat thread generation index is inconsistent.');
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
const generationById = new Map(
|
|
735
|
+
generations.map((generation) => [generation.generation_id, generation])
|
|
736
|
+
);
|
|
737
|
+
const dispatchLeases = database.prepare(`
|
|
738
|
+
SELECT * FROM direct_chat_generation_leases
|
|
739
|
+
WHERE account_id = ? AND thread_id = ?
|
|
740
|
+
ORDER BY generation_id
|
|
741
|
+
`).all(thread.account_id, thread.thread_id);
|
|
742
|
+
if (dispatchLeases.length > generations.length) {
|
|
743
|
+
throw new StorageCorruptionError('A thread has more generation dispatch leases than generations.');
|
|
744
|
+
}
|
|
745
|
+
for (const lease of dispatchLeases) {
|
|
746
|
+
auditDispatchLease(lease);
|
|
747
|
+
const generation = generationById.get(lease.generation_id);
|
|
748
|
+
if (!generation) {
|
|
749
|
+
throw new StorageCorruptionError('A generation dispatch lease has no owned generation.');
|
|
750
|
+
}
|
|
751
|
+
if (
|
|
752
|
+
lease.released_at === null &&
|
|
753
|
+
(generation.status !== 'in_progress' || thread.current_generation_id !== lease.generation_id)
|
|
754
|
+
) {
|
|
755
|
+
throw new StorageCorruptionError('An unreleased dispatch lease is not bound to the active generation.');
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
const compactions = database.prepare(`
|
|
760
|
+
SELECT * FROM direct_chat_compactions
|
|
761
|
+
WHERE account_id = ? AND thread_id = ?
|
|
762
|
+
ORDER BY source_end_revision, created_at, snapshot_id
|
|
763
|
+
`).all(thread.account_id, thread.thread_id);
|
|
764
|
+
if (compactions.length > DIRECT_CHAT_LIMITS.compactionsPerThread) {
|
|
765
|
+
throw new StorageCorruptionError('A direct-chat thread exceeds the compaction snapshot bound.');
|
|
766
|
+
}
|
|
767
|
+
for (const snapshot of compactions) {
|
|
768
|
+
assertStoredIdentifier(snapshot.account_id, 'compaction.account_id');
|
|
769
|
+
assertStoredIdentifier(snapshot.thread_id, 'compaction.thread_id');
|
|
770
|
+
assertStoredIdentifier(snapshot.snapshot_id, 'compaction.snapshot_id');
|
|
771
|
+
assertStoredText(snapshot.summary_text, 'compaction.summary_text', {
|
|
772
|
+
maxBytes: DIRECT_CHAT_LIMITS.summaryBytes
|
|
773
|
+
});
|
|
774
|
+
const first = messages[Number(snapshot.source_start_revision) - 1];
|
|
775
|
+
const last = messages[Number(snapshot.source_end_revision) - 1];
|
|
776
|
+
if (
|
|
777
|
+
!first || !last ||
|
|
778
|
+
first.message_hash !== snapshot.source_start_hash ||
|
|
779
|
+
last.message_hash !== snapshot.source_end_hash ||
|
|
780
|
+
Number(snapshot.summary_bytes) !== utf8Bytes(snapshot.summary_text) ||
|
|
781
|
+
snapshot.summary_hash !== sha256(snapshot.summary_text)
|
|
782
|
+
) {
|
|
783
|
+
throw new StorageCorruptionError('A direct-chat compaction snapshot references an invalid ledger range.');
|
|
784
|
+
}
|
|
785
|
+
assertStoredTimestamp(snapshot.created_at, 'compaction.created_at');
|
|
786
|
+
}
|
|
787
|
+
return { messages, generations, attachments, attachmentBytes };
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
function auditDatabase(database) {
|
|
791
|
+
const threads = database.prepare(`
|
|
792
|
+
SELECT * FROM direct_chat_threads ORDER BY account_id, thread_id
|
|
793
|
+
`).all();
|
|
794
|
+
const counts = new Map();
|
|
795
|
+
const attachmentBytesByAccount = new Map();
|
|
796
|
+
for (const thread of threads) {
|
|
797
|
+
const count = (counts.get(thread.account_id) ?? 0) + 1;
|
|
798
|
+
if (count > DIRECT_CHAT_LIMITS.threadsPerAccount) {
|
|
799
|
+
throw new StorageCorruptionError('An account exceeds the direct-chat thread bound.');
|
|
800
|
+
}
|
|
801
|
+
counts.set(thread.account_id, count);
|
|
802
|
+
const audit = auditThread(database, thread);
|
|
803
|
+
attachmentBytesByAccount.set(
|
|
804
|
+
thread.account_id,
|
|
805
|
+
(attachmentBytesByAccount.get(thread.account_id) ?? 0) + audit.attachmentBytes
|
|
806
|
+
);
|
|
807
|
+
if (attachmentBytesByAccount.get(thread.account_id) > VISION_ATTACHMENT_LIMITS.bytesPerAccount) {
|
|
808
|
+
throw new StorageCorruptionError('An account exceeds its vision attachment storage bound.');
|
|
809
|
+
}
|
|
810
|
+
if ((DATABASE_METADATA.get(database)?.schemaVersion ?? 0) >= 3) {
|
|
811
|
+
for (const descriptor of audit.attachments) {
|
|
812
|
+
attachmentFromRow(database.prepare(`
|
|
813
|
+
SELECT * FROM direct_chat_attachments
|
|
814
|
+
WHERE account_id = ? AND thread_id = ? AND attachment_id = ?
|
|
815
|
+
`).get(thread.account_id, thread.thread_id, descriptor.attachmentId));
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
const receiptOverflows = database.prepare(`
|
|
820
|
+
SELECT account_id, count(*) AS count
|
|
821
|
+
FROM direct_chat_idempotency
|
|
822
|
+
GROUP BY account_id
|
|
823
|
+
HAVING count(*) > ?
|
|
824
|
+
`).all(DIRECT_CHAT_LIMITS.idempotencyReceiptsPerAccount);
|
|
825
|
+
if (receiptOverflows.length !== 0) {
|
|
826
|
+
throw new StorageCorruptionError('An account exceeds the direct-chat idempotency receipt bound.');
|
|
827
|
+
}
|
|
828
|
+
const receipts = database.prepare(`
|
|
829
|
+
SELECT account_id, thread_id, resource_id, created_at, expires_at
|
|
830
|
+
FROM direct_chat_idempotency
|
|
831
|
+
`).all();
|
|
832
|
+
for (const receipt of receipts) {
|
|
833
|
+
assertStoredIdentifier(receipt.account_id, 'idempotency.account_id');
|
|
834
|
+
assertStoredIdentifier(receipt.thread_id, 'idempotency.thread_id');
|
|
835
|
+
assertStoredIdentifier(receipt.resource_id, 'idempotency.resource_id');
|
|
836
|
+
assertStoredTimestamp(receipt.created_at, 'idempotency.created_at');
|
|
837
|
+
assertStoredTimestamp(receipt.expires_at, 'idempotency.expires_at');
|
|
838
|
+
if (receipt.expires_at <= receipt.created_at) {
|
|
839
|
+
throw new StorageCorruptionError('A direct-chat idempotency receipt has an invalid lifetime.');
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
const deletionReceiptOverflows = database.prepare(`
|
|
843
|
+
SELECT account_id, count(*) AS count
|
|
844
|
+
FROM direct_chat_thread_deletions
|
|
845
|
+
GROUP BY account_id
|
|
846
|
+
HAVING count(*) > ?
|
|
847
|
+
`).all(DIRECT_CHAT_LIMITS.threadDeletionReceiptsPerAccount);
|
|
848
|
+
if (deletionReceiptOverflows.length !== 0) {
|
|
849
|
+
throw new StorageCorruptionError('An account exceeds the direct-chat thread deletion receipt bound.');
|
|
850
|
+
}
|
|
851
|
+
const deletionReceipts = database.prepare(`
|
|
852
|
+
SELECT * FROM direct_chat_thread_deletions
|
|
853
|
+
ORDER BY account_id, thread_id
|
|
854
|
+
`).all();
|
|
855
|
+
const liveDeletedThread = database.prepare(`
|
|
856
|
+
SELECT deletion.account_id, deletion.thread_id
|
|
857
|
+
FROM direct_chat_thread_deletions AS deletion
|
|
858
|
+
INNER JOIN direct_chat_threads AS thread
|
|
859
|
+
ON thread.account_id = deletion.account_id AND thread.thread_id = deletion.thread_id
|
|
860
|
+
LIMIT 1
|
|
861
|
+
`).get();
|
|
862
|
+
if (liveDeletedThread) {
|
|
863
|
+
throw new StorageCorruptionError('A deleted direct-chat thread still has live storage.');
|
|
864
|
+
}
|
|
865
|
+
for (const receipt of deletionReceipts) {
|
|
866
|
+
assertStoredIdentifier(receipt.account_id, 'thread_deletion.account_id');
|
|
867
|
+
assertStoredIdentifier(receipt.thread_id, 'thread_deletion.thread_id');
|
|
868
|
+
assertStoredTimestamp(receipt.deleted_at, 'thread_deletion.deleted_at');
|
|
869
|
+
const revision = Number(receipt.deleted_revision);
|
|
870
|
+
if (!Number.isSafeInteger(revision) || revision < 0 || revision > DIRECT_CHAT_LIMITS.messagesPerThread
|
|
871
|
+
|| (revision === 0) !== (receipt.deleted_hash === null)
|
|
872
|
+
|| (revision > 0 && !HASH_PATTERN.test(receipt.deleted_hash))
|
|
873
|
+
|| !HASH_PATTERN.test(receipt.key_hash)
|
|
874
|
+
|| !HASH_PATTERN.test(receipt.request_hash)
|
|
875
|
+
|| !HASH_PATTERN.test(receipt.result_digest)) {
|
|
876
|
+
throw new StorageCorruptionError('A direct-chat thread deletion receipt is invalid.');
|
|
877
|
+
}
|
|
878
|
+
const cursor = { revision, hash: receipt.deleted_hash };
|
|
879
|
+
const request = threadDeletionRequest(receipt.account_id, receipt.thread_id, cursor);
|
|
880
|
+
const result = threadDeletionView(receipt);
|
|
881
|
+
if (sha256(canonicalJson(request)) !== receipt.request_hash
|
|
882
|
+
|| sha256(canonicalJson(threadDeletionDigest(result))) !== receipt.result_digest) {
|
|
883
|
+
throw new StorageCorruptionError('A direct-chat thread deletion receipt digest is inconsistent.');
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
function assertCursor(revision, hash, prefix = 'expected') {
|
|
889
|
+
const normalizedRevision = assertInteger(revision, `${prefix}Revision`, { min: 0 });
|
|
890
|
+
const normalizedHash = assertEventHash(hash, normalizedRevision, `${prefix}Hash`);
|
|
891
|
+
return { revision: normalizedRevision, hash: normalizedHash };
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
function assertCursorMatches(thread, cursor) {
|
|
895
|
+
if (Number(thread.ledger_revision) !== cursor.revision || thread.ledger_hash !== cursor.hash) {
|
|
896
|
+
throw new ConflictError('The direct-chat ledger cursor is stale.');
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
function assertTurnAttachment(value) {
|
|
901
|
+
assertExactKeys(
|
|
902
|
+
value,
|
|
903
|
+
{
|
|
904
|
+
required: [
|
|
905
|
+
'attachmentId', 'mediaType', 'byteLength', 'width', 'height',
|
|
906
|
+
'contentSha256', 'content'
|
|
907
|
+
]
|
|
908
|
+
},
|
|
909
|
+
'direct chat turn attachment'
|
|
910
|
+
);
|
|
911
|
+
return validateStoredVisionAttachment(value);
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
function assertTurnAttachments(value) {
|
|
915
|
+
if (!Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype
|
|
916
|
+
|| value.length < 1 || value.length > VISION_ATTACHMENT_LIMITS.attachmentsPerMessage) {
|
|
917
|
+
throw new ValidationError('Direct Chat attachments must be a bounded non-empty array.');
|
|
918
|
+
}
|
|
919
|
+
const attachments = [];
|
|
920
|
+
const identifiers = new Set();
|
|
921
|
+
let bytes = 0;
|
|
922
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
923
|
+
if (!Object.hasOwn(value, index)) throw new ValidationError('Direct Chat attachments must be dense.');
|
|
924
|
+
const attachment = assertTurnAttachment(value[index]);
|
|
925
|
+
if (identifiers.has(attachment.attachmentId)) {
|
|
926
|
+
throw new ValidationError('Direct Chat attachment identifiers must be unique.');
|
|
927
|
+
}
|
|
928
|
+
identifiers.add(attachment.attachmentId);
|
|
929
|
+
bytes += attachment.byteLength;
|
|
930
|
+
if (bytes > VISION_ATTACHMENT_LIMITS.bytesPerMessage) {
|
|
931
|
+
throw new ValidationError('Direct Chat attachments exceed the per-message byte limit.');
|
|
932
|
+
}
|
|
933
|
+
attachments.push(attachment);
|
|
934
|
+
}
|
|
935
|
+
const keys = Reflect.ownKeys(value);
|
|
936
|
+
if (keys.some((key) => key !== 'length'
|
|
937
|
+
&& (typeof key !== 'string' || !/^(0|[1-9]\d*)$/u.test(key)
|
|
938
|
+
|| Number(key) >= value.length))) {
|
|
939
|
+
throw new ValidationError('Direct Chat attachments contain an unsupported property.');
|
|
940
|
+
}
|
|
941
|
+
return Object.freeze(attachments);
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
function statusVersion(generation) {
|
|
945
|
+
const deltaCount = Number(generation.delta_count ?? generation.deltaCount);
|
|
946
|
+
const finalRevision = Number(generation.final_revision ?? generation.finalRevision);
|
|
947
|
+
if (generation.status === 'completed') return finalRevision;
|
|
948
|
+
return deltaCount;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
function terminalGenerationDigestInput(result) {
|
|
952
|
+
return {
|
|
953
|
+
accountId: result.accountId,
|
|
954
|
+
threadId: result.threadId,
|
|
955
|
+
generationId: result.generationId,
|
|
956
|
+
assistantMessageId: result.assistantMessageId,
|
|
957
|
+
status: result.status,
|
|
958
|
+
modelAlias: result.modelAlias,
|
|
959
|
+
sourceRevision: result.sourceRevision,
|
|
960
|
+
sourceHash: result.sourceHash,
|
|
961
|
+
deltaCount: result.deltaCount,
|
|
962
|
+
deltaBytes: result.deltaBytes,
|
|
963
|
+
lastDeltaHash: result.lastDeltaHash,
|
|
964
|
+
finalRevision: result.finalRevision,
|
|
965
|
+
finalHash: result.finalHash,
|
|
966
|
+
failureCode: result.failureCode,
|
|
967
|
+
terminalAt: result.terminalAt
|
|
968
|
+
};
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
export class DirectChatStore {
|
|
972
|
+
#auditedThreads = new Map();
|
|
973
|
+
#auditDataVersion = null;
|
|
974
|
+
#clock;
|
|
975
|
+
#closed = false;
|
|
976
|
+
#database;
|
|
977
|
+
#databasePath;
|
|
978
|
+
#enableVisionAttachments;
|
|
979
|
+
#modelAlias;
|
|
980
|
+
#readTransactionActive = false;
|
|
981
|
+
#readTransactionDataVersion = null;
|
|
982
|
+
#schemaVersion;
|
|
983
|
+
#visionModelAlias;
|
|
984
|
+
#writeTransactionActive = false;
|
|
985
|
+
|
|
986
|
+
constructor({
|
|
987
|
+
databasePath,
|
|
988
|
+
modelAlias = 'local-default',
|
|
989
|
+
visionModelAlias = VISION_MODEL_ALIAS,
|
|
990
|
+
enableVisionAttachments = false,
|
|
991
|
+
clock = DEFAULT_CLOCK
|
|
992
|
+
} = {}) {
|
|
993
|
+
if (typeof clock !== 'function') throw new ValidationError('clock must be a function.');
|
|
994
|
+
if (typeof enableVisionAttachments !== 'boolean') {
|
|
995
|
+
throw new ValidationError('enableVisionAttachments must be boolean.');
|
|
996
|
+
}
|
|
997
|
+
this.#clock = clock;
|
|
998
|
+
this.#modelAlias = assertModelAlias(modelAlias);
|
|
999
|
+
this.#visionModelAlias = assertModelAlias(visionModelAlias);
|
|
1000
|
+
this.#enableVisionAttachments = enableVisionAttachments;
|
|
1001
|
+
this.#databasePath = prepareSecureDatabasePath(databasePath);
|
|
1002
|
+
|
|
1003
|
+
let database;
|
|
1004
|
+
try {
|
|
1005
|
+
database = new DatabaseSync(this.#databasePath);
|
|
1006
|
+
} catch (error) {
|
|
1007
|
+
throw new StorageCorruptionError('SQLite could not open the direct-chat database.', { cause: error });
|
|
1008
|
+
}
|
|
1009
|
+
try {
|
|
1010
|
+
database.enableLoadExtension(false);
|
|
1011
|
+
database.exec(`
|
|
1012
|
+
PRAGMA busy_timeout = 5000;
|
|
1013
|
+
PRAGMA foreign_keys = ON;
|
|
1014
|
+
PRAGMA journal_mode = DELETE;
|
|
1015
|
+
PRAGMA synchronous = FULL;
|
|
1016
|
+
PRAGMA temp_store = MEMORY;
|
|
1017
|
+
PRAGMA trusted_schema = OFF;
|
|
1018
|
+
PRAGMA secure_delete = ON;
|
|
1019
|
+
`);
|
|
1020
|
+
this.#schemaVersion = applyChatMigrations(database, nowIso(this.#clock), {
|
|
1021
|
+
enableVisionAttachments
|
|
1022
|
+
});
|
|
1023
|
+
DATABASE_METADATA.set(database, Object.freeze({
|
|
1024
|
+
schemaVersion: this.#schemaVersion,
|
|
1025
|
+
visionModelAlias: this.#visionModelAlias
|
|
1026
|
+
}));
|
|
1027
|
+
database.exec('BEGIN');
|
|
1028
|
+
try {
|
|
1029
|
+
auditDatabase(database);
|
|
1030
|
+
database.exec('COMMIT');
|
|
1031
|
+
} catch (error) {
|
|
1032
|
+
try {
|
|
1033
|
+
database.exec('ROLLBACK');
|
|
1034
|
+
} catch {
|
|
1035
|
+
// Preserve the audit failure.
|
|
1036
|
+
}
|
|
1037
|
+
throw error;
|
|
1038
|
+
}
|
|
1039
|
+
assertSecureDatabaseFile(this.#databasePath);
|
|
1040
|
+
} catch (error) {
|
|
1041
|
+
try {
|
|
1042
|
+
database.close();
|
|
1043
|
+
} catch {
|
|
1044
|
+
// Preserve the initialization error.
|
|
1045
|
+
}
|
|
1046
|
+
if (
|
|
1047
|
+
error instanceof StorageCorruptionError ||
|
|
1048
|
+
error?.code === 'unsupported_schema' ||
|
|
1049
|
+
error?.code === 'storage_security_error' ||
|
|
1050
|
+
error?.code === 'invalid_input'
|
|
1051
|
+
) {
|
|
1052
|
+
throw error;
|
|
1053
|
+
}
|
|
1054
|
+
throw new StorageCorruptionError('The direct-chat database could not be initialized safely.', { cause: error });
|
|
1055
|
+
}
|
|
1056
|
+
this.#database = database;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
#assertOpen() {
|
|
1060
|
+
if (this.#closed) throw new StorageCorruptionError('The direct-chat database is closed.');
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
#clearAuditedThreads() {
|
|
1064
|
+
this.#auditedThreads.clear();
|
|
1065
|
+
this.#auditDataVersion = null;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
#dataVersion() {
|
|
1069
|
+
const value = Number(this.#database.prepare('PRAGMA data_version').get()?.data_version);
|
|
1070
|
+
if (!Number.isSafeInteger(value) || value < 1) {
|
|
1071
|
+
this.#clearAuditedThreads();
|
|
1072
|
+
throw new StorageCorruptionError('SQLite returned an invalid direct-chat data version.');
|
|
1073
|
+
}
|
|
1074
|
+
return value;
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
#auditThread(thread) {
|
|
1078
|
+
// Write transactions deliberately bypass the cache. A transaction can
|
|
1079
|
+
// touch several integrity-linked rows without advancing data_version on
|
|
1080
|
+
// its own connection, so both its precondition and postcondition audits
|
|
1081
|
+
// must inspect the actual transactional snapshot.
|
|
1082
|
+
if (this.#writeTransactionActive) return auditThread(this.#database, thread);
|
|
1083
|
+
|
|
1084
|
+
const dataVersion = this.#readTransactionDataVersion ?? this.#dataVersion();
|
|
1085
|
+
if (this.#readTransactionActive && this.#readTransactionDataVersion === null) {
|
|
1086
|
+
// The owning lookup already pinned this read transaction's snapshot.
|
|
1087
|
+
// Reuse one data_version guard for every thread in the same list read.
|
|
1088
|
+
this.#readTransactionDataVersion = dataVersion;
|
|
1089
|
+
}
|
|
1090
|
+
if (this.#auditDataVersion !== dataVersion) {
|
|
1091
|
+
this.#auditedThreads.clear();
|
|
1092
|
+
this.#auditDataVersion = dataVersion;
|
|
1093
|
+
}
|
|
1094
|
+
assertStoredIdentifier(thread.account_id, 'thread.account_id');
|
|
1095
|
+
assertStoredIdentifier(thread.thread_id, 'thread.thread_id');
|
|
1096
|
+
const identity = canonicalJson([thread.account_id, thread.thread_id]);
|
|
1097
|
+
if (this.#auditedThreads.has(identity)) {
|
|
1098
|
+
// Refresh insertion order so the bounded map behaves as a small LRU.
|
|
1099
|
+
this.#auditedThreads.delete(identity);
|
|
1100
|
+
this.#auditedThreads.set(identity, dataVersion);
|
|
1101
|
+
return null;
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
let result;
|
|
1105
|
+
try {
|
|
1106
|
+
result = auditThread(this.#database, thread);
|
|
1107
|
+
} catch (error) {
|
|
1108
|
+
this.#auditedThreads.delete(identity);
|
|
1109
|
+
throw error;
|
|
1110
|
+
}
|
|
1111
|
+
// A read transaction validates its version again after COMMIT. Keep the
|
|
1112
|
+
// local second check for any future caller that audits outside one.
|
|
1113
|
+
if (!this.#readTransactionActive && this.#dataVersion() !== dataVersion) {
|
|
1114
|
+
this.#clearAuditedThreads();
|
|
1115
|
+
return result;
|
|
1116
|
+
}
|
|
1117
|
+
this.#auditedThreads.set(identity, dataVersion);
|
|
1118
|
+
if (this.#auditedThreads.size > MAX_AUDITED_THREAD_CACHE_ENTRIES) {
|
|
1119
|
+
this.#auditedThreads.delete(this.#auditedThreads.keys().next().value);
|
|
1120
|
+
}
|
|
1121
|
+
return result;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
#transaction(callback) {
|
|
1125
|
+
this.#assertOpen();
|
|
1126
|
+
// SQLite data_version changes only for commits made by other connections.
|
|
1127
|
+
// Clear around every local write transaction and never cache its audits.
|
|
1128
|
+
this.#clearAuditedThreads();
|
|
1129
|
+
this.#writeTransactionActive = true;
|
|
1130
|
+
try {
|
|
1131
|
+
try {
|
|
1132
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
1133
|
+
const result = callback();
|
|
1134
|
+
this.#database.exec('COMMIT');
|
|
1135
|
+
return result;
|
|
1136
|
+
} catch (error) {
|
|
1137
|
+
try {
|
|
1138
|
+
this.#database.exec('ROLLBACK');
|
|
1139
|
+
} catch {
|
|
1140
|
+
// Preserve the mutation error.
|
|
1141
|
+
}
|
|
1142
|
+
throw error;
|
|
1143
|
+
}
|
|
1144
|
+
} finally {
|
|
1145
|
+
this.#writeTransactionActive = false;
|
|
1146
|
+
this.#clearAuditedThreads();
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
#readTransaction(callback) {
|
|
1151
|
+
this.#assertOpen();
|
|
1152
|
+
this.#readTransactionActive = true;
|
|
1153
|
+
this.#readTransactionDataVersion = null;
|
|
1154
|
+
try {
|
|
1155
|
+
try {
|
|
1156
|
+
this.#database.exec('BEGIN');
|
|
1157
|
+
const result = callback();
|
|
1158
|
+
this.#database.exec('COMMIT');
|
|
1159
|
+
if (this.#readTransactionDataVersion !== null
|
|
1160
|
+
&& this.#dataVersion() !== this.#readTransactionDataVersion) {
|
|
1161
|
+
// An external WAL-capable connection may commit while a read
|
|
1162
|
+
// snapshot is pinned. The result was coherent, but it must not seed
|
|
1163
|
+
// cache state for the newer database version.
|
|
1164
|
+
this.#clearAuditedThreads();
|
|
1165
|
+
}
|
|
1166
|
+
return result;
|
|
1167
|
+
} catch (error) {
|
|
1168
|
+
try {
|
|
1169
|
+
this.#database.exec('ROLLBACK');
|
|
1170
|
+
} catch {
|
|
1171
|
+
// Preserve the read or validation failure.
|
|
1172
|
+
}
|
|
1173
|
+
this.#clearAuditedThreads();
|
|
1174
|
+
throw error;
|
|
1175
|
+
}
|
|
1176
|
+
} finally {
|
|
1177
|
+
this.#readTransactionDataVersion = null;
|
|
1178
|
+
this.#readTransactionActive = false;
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
#deleteExpiredReceipts(timestamp, limit = DIRECT_CHAT_LIMITS.cleanupRows, accountId = null) {
|
|
1183
|
+
const result = this.#database.prepare(`
|
|
1184
|
+
DELETE FROM direct_chat_idempotency
|
|
1185
|
+
WHERE rowid IN (
|
|
1186
|
+
SELECT rowid FROM direct_chat_idempotency
|
|
1187
|
+
WHERE expires_at <= ? AND (? IS NULL OR account_id = ?)
|
|
1188
|
+
ORDER BY expires_at, rowid
|
|
1189
|
+
LIMIT ?
|
|
1190
|
+
)
|
|
1191
|
+
`).run(timestamp, accountId, accountId, limit);
|
|
1192
|
+
return Number(result.changes);
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
#generationLease(accountId, threadId, generationId) {
|
|
1196
|
+
return this.#database.prepare(`
|
|
1197
|
+
SELECT * FROM direct_chat_generation_leases
|
|
1198
|
+
WHERE account_id = ? AND thread_id = ? AND generation_id = ?
|
|
1199
|
+
`).get(accountId, threadId, generationId) ?? null;
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
#reconcileExpiredGenerationDispatches(timestamp) {
|
|
1203
|
+
// A one-way dispatch marker is never replayable. Releasing an expired
|
|
1204
|
+
// global admission therefore also records the owning generation as
|
|
1205
|
+
// interrupted and advances its fence before another inference may start.
|
|
1206
|
+
this.#database.prepare(`
|
|
1207
|
+
UPDATE direct_chat_generation_leases
|
|
1208
|
+
SET fence = fence + 1, phase = 'interrupted', expires_at = ?,
|
|
1209
|
+
updated_at = ?, released_at = ?
|
|
1210
|
+
WHERE phase = 'dispatch_started' AND released_at IS NULL
|
|
1211
|
+
AND expires_at <= ? AND fence < ?
|
|
1212
|
+
`).run(timestamp, timestamp, timestamp, timestamp, MAX_DISPATCH_LEASE_FENCE);
|
|
1213
|
+
const exhausted = this.#database.prepare(`
|
|
1214
|
+
SELECT 1 AS present FROM direct_chat_generation_leases
|
|
1215
|
+
WHERE phase = 'dispatch_started' AND released_at IS NULL
|
|
1216
|
+
AND expires_at <= ? AND fence >= ?
|
|
1217
|
+
LIMIT 1
|
|
1218
|
+
`).get(timestamp, MAX_DISPATCH_LEASE_FENCE);
|
|
1219
|
+
if (exhausted) throw new ConflictError('An expired generation dispatch fence is exhausted.');
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
#requireGenerationLease(
|
|
1223
|
+
accountId,
|
|
1224
|
+
threadId,
|
|
1225
|
+
generationId,
|
|
1226
|
+
proof,
|
|
1227
|
+
timestamp,
|
|
1228
|
+
allowedPhases = ['dispatch_started']
|
|
1229
|
+
) {
|
|
1230
|
+
const lease = this.#generationLease(accountId, threadId, generationId);
|
|
1231
|
+
if (!lease) {
|
|
1232
|
+
if (proof !== null) {
|
|
1233
|
+
throw new ConflictError('No dispatch lease is registered for this generation.');
|
|
1234
|
+
}
|
|
1235
|
+
return null;
|
|
1236
|
+
}
|
|
1237
|
+
auditDispatchLease(lease);
|
|
1238
|
+
if (
|
|
1239
|
+
proof === null ||
|
|
1240
|
+
lease.owner_hash !== proof.ownerHash ||
|
|
1241
|
+
Number(lease.fence) !== proof.fence ||
|
|
1242
|
+
lease.released_at !== null ||
|
|
1243
|
+
lease.expires_at <= timestamp ||
|
|
1244
|
+
!allowedPhases.includes(lease.phase)
|
|
1245
|
+
) {
|
|
1246
|
+
throw new ConflictError(
|
|
1247
|
+
'The generation dispatch lease is missing, unstarted, expired, released, interrupted, or fenced out.'
|
|
1248
|
+
);
|
|
1249
|
+
}
|
|
1250
|
+
return lease;
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
#invalidateGenerationLease(
|
|
1254
|
+
accountId,
|
|
1255
|
+
threadId,
|
|
1256
|
+
generationId,
|
|
1257
|
+
timestamp,
|
|
1258
|
+
targetPhase = 'released'
|
|
1259
|
+
) {
|
|
1260
|
+
if (!['released', 'interrupted'].includes(targetPhase)) {
|
|
1261
|
+
throw new StorageCorruptionError('The generation dispatch lease terminal phase is invalid.');
|
|
1262
|
+
}
|
|
1263
|
+
const lease = this.#generationLease(accountId, threadId, generationId);
|
|
1264
|
+
if (!lease) return null;
|
|
1265
|
+
auditDispatchLease(lease);
|
|
1266
|
+
if (lease.released_at !== null) return lease;
|
|
1267
|
+
const releasedAt = timestamp < lease.updated_at ? lease.updated_at : timestamp;
|
|
1268
|
+
const updated = this.#database.prepare(`
|
|
1269
|
+
UPDATE direct_chat_generation_leases
|
|
1270
|
+
SET phase = ?, expires_at = ?, updated_at = ?, released_at = ?
|
|
1271
|
+
WHERE account_id = ? AND thread_id = ? AND generation_id = ?
|
|
1272
|
+
AND fence = ? AND owner_hash = ? AND released_at IS NULL
|
|
1273
|
+
`).run(
|
|
1274
|
+
targetPhase,
|
|
1275
|
+
releasedAt,
|
|
1276
|
+
releasedAt,
|
|
1277
|
+
releasedAt,
|
|
1278
|
+
accountId,
|
|
1279
|
+
threadId,
|
|
1280
|
+
generationId,
|
|
1281
|
+
lease.fence,
|
|
1282
|
+
lease.owner_hash
|
|
1283
|
+
);
|
|
1284
|
+
if (Number(updated.changes) !== 1) {
|
|
1285
|
+
throw new ConflictError('The generation dispatch lease changed concurrently.');
|
|
1286
|
+
}
|
|
1287
|
+
return this.#generationLease(accountId, threadId, generationId);
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
#idempotentMutation(meta, mutation, replay) {
|
|
1291
|
+
const accountId = assertIdentifier(meta.accountId, 'accountId');
|
|
1292
|
+
const threadId = assertIdentifier(meta.threadId, 'threadId');
|
|
1293
|
+
assertIdempotencyKey(meta.idempotencyKey);
|
|
1294
|
+
const keyHash = sha256(meta.idempotencyKey);
|
|
1295
|
+
const requestHash = sha256(canonicalJson(meta.request));
|
|
1296
|
+
const timestamp = nowIso(this.#clock);
|
|
1297
|
+
const expiresAt = addMilliseconds(timestamp, DIRECT_CHAT_IDEMPOTENCY_TTL_MS);
|
|
1298
|
+
|
|
1299
|
+
return this.#transaction(() => {
|
|
1300
|
+
this.#deleteExpiredReceipts(timestamp);
|
|
1301
|
+
const existing = this.#database.prepare(`
|
|
1302
|
+
SELECT * FROM direct_chat_idempotency
|
|
1303
|
+
WHERE account_id = ? AND operation = ? AND key_hash = ?
|
|
1304
|
+
`).get(accountId, meta.operation, keyHash);
|
|
1305
|
+
if (existing) {
|
|
1306
|
+
if (existing.request_hash !== requestHash) throw new IdempotencyConflictError();
|
|
1307
|
+
if (
|
|
1308
|
+
existing.resource_kind !== meta.resourceKind ||
|
|
1309
|
+
existing.thread_id !== threadId ||
|
|
1310
|
+
existing.resource_id !== meta.resourceId ||
|
|
1311
|
+
!HASH_PATTERN.test(existing.result_digest)
|
|
1312
|
+
) {
|
|
1313
|
+
throw new StorageCorruptionError('A direct-chat idempotency receipt failed closed-schema validation.');
|
|
1314
|
+
}
|
|
1315
|
+
const result = replay(existing);
|
|
1316
|
+
const digestInput = meta.digestOf === undefined ? result : meta.digestOf(result);
|
|
1317
|
+
if (sha256(canonicalJson(digestInput)) !== existing.result_digest) {
|
|
1318
|
+
throw new ConflictError('The original direct-chat mutation result can no longer be replayed exactly.');
|
|
1319
|
+
}
|
|
1320
|
+
return result;
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
const result = mutation();
|
|
1324
|
+
const version = meta.versionOf(result);
|
|
1325
|
+
assertInteger(version, 'resourceVersion', { min: 0 });
|
|
1326
|
+
this.#database.prepare(`
|
|
1327
|
+
INSERT INTO direct_chat_idempotency(
|
|
1328
|
+
account_id, operation, key_hash, request_hash, resource_kind,
|
|
1329
|
+
thread_id, resource_id, resource_version, result_digest, created_at, expires_at
|
|
1330
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1331
|
+
`).run(
|
|
1332
|
+
accountId,
|
|
1333
|
+
meta.operation,
|
|
1334
|
+
keyHash,
|
|
1335
|
+
requestHash,
|
|
1336
|
+
meta.resourceKind,
|
|
1337
|
+
threadId,
|
|
1338
|
+
meta.resourceId,
|
|
1339
|
+
version,
|
|
1340
|
+
sha256(canonicalJson(meta.digestOf === undefined ? result : meta.digestOf(result))),
|
|
1341
|
+
timestamp,
|
|
1342
|
+
expiresAt
|
|
1343
|
+
);
|
|
1344
|
+
this.#database.prepare(`
|
|
1345
|
+
DELETE FROM direct_chat_idempotency
|
|
1346
|
+
WHERE rowid IN (
|
|
1347
|
+
SELECT rowid FROM direct_chat_idempotency
|
|
1348
|
+
WHERE account_id = ?
|
|
1349
|
+
ORDER BY created_at DESC, rowid DESC
|
|
1350
|
+
LIMIT -1 OFFSET ?
|
|
1351
|
+
)
|
|
1352
|
+
`).run(accountId, DIRECT_CHAT_LIMITS.idempotencyReceiptsPerAccount);
|
|
1353
|
+
return result;
|
|
1354
|
+
});
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
createThread(input) {
|
|
1358
|
+
assertExactKeys(
|
|
1359
|
+
input,
|
|
1360
|
+
{ required: ['accountId', 'threadId', 'idempotencyKey'], optional: ['title'] },
|
|
1361
|
+
'direct chat thread'
|
|
1362
|
+
);
|
|
1363
|
+
const accountId = assertIdentifier(input.accountId, 'accountId');
|
|
1364
|
+
const threadId = assertIdentifier(input.threadId, 'threadId');
|
|
1365
|
+
const title = assertUnicodeScalarString(input.title ?? '', 'title', { minBytes: 0, maxBytes: 512 });
|
|
1366
|
+
const request = { accountId, threadId, title: title.value };
|
|
1367
|
+
|
|
1368
|
+
return this.#idempotentMutation(
|
|
1369
|
+
{
|
|
1370
|
+
accountId,
|
|
1371
|
+
threadId,
|
|
1372
|
+
operation: 'thread.create',
|
|
1373
|
+
idempotencyKey: input.idempotencyKey,
|
|
1374
|
+
request,
|
|
1375
|
+
resourceKind: 'thread',
|
|
1376
|
+
resourceId: threadId,
|
|
1377
|
+
versionOf: (result) => result.revision,
|
|
1378
|
+
digestOf: (result) => ({
|
|
1379
|
+
accountId: result.accountId,
|
|
1380
|
+
threadId: result.threadId,
|
|
1381
|
+
title: result.title,
|
|
1382
|
+
modelAlias: result.modelAlias
|
|
1383
|
+
})
|
|
1384
|
+
},
|
|
1385
|
+
() => {
|
|
1386
|
+
const retired = this.#database.prepare(`
|
|
1387
|
+
SELECT 1 AS present FROM direct_chat_thread_deletions
|
|
1388
|
+
WHERE account_id = ? AND thread_id = ?
|
|
1389
|
+
`).get(accountId, threadId);
|
|
1390
|
+
if (retired) {
|
|
1391
|
+
throw new ConflictError('The direct-chat thread identifier was permanently retired.');
|
|
1392
|
+
}
|
|
1393
|
+
const existing = this.#database.prepare(`
|
|
1394
|
+
SELECT * FROM direct_chat_threads WHERE account_id = ? AND thread_id = ?
|
|
1395
|
+
`).get(accountId, threadId);
|
|
1396
|
+
if (existing) {
|
|
1397
|
+
if (existing.title !== title.value || existing.model_alias !== this.#modelAlias) {
|
|
1398
|
+
throw new ConflictError('The direct-chat thread identifier already has different immutable settings.');
|
|
1399
|
+
}
|
|
1400
|
+
this.#auditThread(existing);
|
|
1401
|
+
return threadView(existing);
|
|
1402
|
+
}
|
|
1403
|
+
const count = Number(this.#database.prepare(`
|
|
1404
|
+
SELECT count(*) AS count FROM direct_chat_threads WHERE account_id = ?
|
|
1405
|
+
`).get(accountId).count);
|
|
1406
|
+
if (count >= DIRECT_CHAT_LIMITS.threadsPerAccount) {
|
|
1407
|
+
throw new ConflictError('The account reached its direct-chat thread limit.');
|
|
1408
|
+
}
|
|
1409
|
+
const timestamp = nowIso(this.#clock);
|
|
1410
|
+
try {
|
|
1411
|
+
this.#database.prepare(`
|
|
1412
|
+
INSERT INTO direct_chat_threads(
|
|
1413
|
+
account_id, thread_id, title, title_bytes, model_alias, created_at, updated_at
|
|
1414
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
1415
|
+
`).run(accountId, threadId, title.value, title.bytes, this.#modelAlias, timestamp, timestamp);
|
|
1416
|
+
} catch (error) {
|
|
1417
|
+
if (isConstraintError(error)) throw new ConflictError();
|
|
1418
|
+
throw error;
|
|
1419
|
+
}
|
|
1420
|
+
return threadView(requireThread(this.#database, accountId, threadId));
|
|
1421
|
+
},
|
|
1422
|
+
() => {
|
|
1423
|
+
const thread = requireThread(this.#database, accountId, threadId);
|
|
1424
|
+
this.#auditThread(thread);
|
|
1425
|
+
return threadView(thread);
|
|
1426
|
+
}
|
|
1427
|
+
);
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
getThread(accountId, threadId) {
|
|
1431
|
+
this.#assertOpen();
|
|
1432
|
+
assertIdentifier(accountId, 'accountId');
|
|
1433
|
+
assertIdentifier(threadId, 'threadId');
|
|
1434
|
+
return this.#readTransaction(() => {
|
|
1435
|
+
const row = this.#database.prepare(`
|
|
1436
|
+
SELECT * FROM direct_chat_threads WHERE account_id = ? AND thread_id = ?
|
|
1437
|
+
`).get(accountId, threadId);
|
|
1438
|
+
if (!row) return null;
|
|
1439
|
+
this.#auditThread(row);
|
|
1440
|
+
return threadView(row);
|
|
1441
|
+
});
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
listThreads(input) {
|
|
1445
|
+
this.#assertOpen();
|
|
1446
|
+
assertExactKeys(input, { required: ['accountId'], optional: ['limit'] }, 'direct chat thread query');
|
|
1447
|
+
const accountId = assertIdentifier(input.accountId, 'accountId');
|
|
1448
|
+
const limit = assertInteger(input.limit ?? 50, 'limit', { min: 1, max: DIRECT_CHAT_LIMITS.listPage });
|
|
1449
|
+
return this.#readTransaction(() => {
|
|
1450
|
+
const rows = this.#database.prepare(`
|
|
1451
|
+
SELECT * FROM direct_chat_threads
|
|
1452
|
+
WHERE account_id = ?
|
|
1453
|
+
ORDER BY updated_at DESC, thread_id DESC
|
|
1454
|
+
LIMIT ?
|
|
1455
|
+
`).all(accountId, limit);
|
|
1456
|
+
for (const row of rows) this.#auditThread(row);
|
|
1457
|
+
return rows.map(threadView);
|
|
1458
|
+
});
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
deleteThread(input) {
|
|
1462
|
+
assertExactKeys(
|
|
1463
|
+
input,
|
|
1464
|
+
{
|
|
1465
|
+
required: [
|
|
1466
|
+
'accountId', 'threadId', 'expectedRevision', 'expectedHash', 'idempotencyKey'
|
|
1467
|
+
]
|
|
1468
|
+
},
|
|
1469
|
+
'direct chat thread deletion'
|
|
1470
|
+
);
|
|
1471
|
+
const accountId = assertIdentifier(input.accountId, 'accountId');
|
|
1472
|
+
const threadId = assertIdentifier(input.threadId, 'threadId');
|
|
1473
|
+
const cursor = assertCursor(input.expectedRevision, input.expectedHash);
|
|
1474
|
+
assertIdempotencyKey(input.idempotencyKey);
|
|
1475
|
+
const keyHash = sha256(input.idempotencyKey);
|
|
1476
|
+
const request = threadDeletionRequest(accountId, threadId, cursor);
|
|
1477
|
+
const requestHash = sha256(canonicalJson(request));
|
|
1478
|
+
|
|
1479
|
+
return this.#transaction(() => {
|
|
1480
|
+
const existing = this.#database.prepare(`
|
|
1481
|
+
SELECT * FROM direct_chat_thread_deletions
|
|
1482
|
+
WHERE account_id = ? AND key_hash = ?
|
|
1483
|
+
`).get(accountId, keyHash);
|
|
1484
|
+
if (existing) {
|
|
1485
|
+
if (existing.request_hash !== requestHash) throw new IdempotencyConflictError();
|
|
1486
|
+
if (existing.thread_id !== threadId) {
|
|
1487
|
+
throw new StorageCorruptionError('A direct-chat deletion receipt has inconsistent ownership.');
|
|
1488
|
+
}
|
|
1489
|
+
const replayed = threadDeletionView(existing);
|
|
1490
|
+
if (sha256(canonicalJson(threadDeletionDigest(replayed))) !== existing.result_digest) {
|
|
1491
|
+
throw new ConflictError('The original direct-chat deletion result cannot be replayed exactly.');
|
|
1492
|
+
}
|
|
1493
|
+
return replayed;
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
if (this.#database.prepare(`
|
|
1497
|
+
SELECT 1 AS present FROM direct_chat_thread_deletions
|
|
1498
|
+
WHERE account_id = ? AND thread_id = ?
|
|
1499
|
+
`).get(accountId, threadId)) {
|
|
1500
|
+
throw new NotFoundError();
|
|
1501
|
+
}
|
|
1502
|
+
const thread = requireThread(this.#database, accountId, threadId);
|
|
1503
|
+
this.#auditThread(thread);
|
|
1504
|
+
assertCursorMatches(thread, cursor);
|
|
1505
|
+
if (thread.current_generation_id !== null) {
|
|
1506
|
+
throw new ConflictError('An active or unresolved Direct Chat generation cannot be deleted.');
|
|
1507
|
+
}
|
|
1508
|
+
const unresolvedUser = this.#database.prepare(`
|
|
1509
|
+
SELECT 1 AS present
|
|
1510
|
+
FROM direct_chat_messages AS message
|
|
1511
|
+
WHERE message.account_id = ? AND message.thread_id = ?
|
|
1512
|
+
AND message.revision = ? AND message.role = 'user'
|
|
1513
|
+
AND NOT EXISTS (
|
|
1514
|
+
SELECT 1 FROM direct_chat_generations AS generation
|
|
1515
|
+
WHERE generation.account_id = message.account_id
|
|
1516
|
+
AND generation.thread_id = message.thread_id
|
|
1517
|
+
AND generation.source_revision = message.revision
|
|
1518
|
+
AND generation.source_hash = message.message_hash
|
|
1519
|
+
)
|
|
1520
|
+
`).get(accountId, threadId, Number(thread.ledger_revision));
|
|
1521
|
+
if (unresolvedUser) {
|
|
1522
|
+
throw new ConflictError('A Direct Chat send with unresolved acceptance cannot be deleted.');
|
|
1523
|
+
}
|
|
1524
|
+
const receiptCount = Number(this.#database.prepare(`
|
|
1525
|
+
SELECT count(*) AS count FROM direct_chat_thread_deletions WHERE account_id = ?
|
|
1526
|
+
`).get(accountId).count);
|
|
1527
|
+
if (receiptCount >= DIRECT_CHAT_LIMITS.threadDeletionReceiptsPerAccount) {
|
|
1528
|
+
throw new ConflictError('The account reached its durable thread deletion receipt limit.');
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
const deletedAt = nowIso(this.#clock);
|
|
1532
|
+
const result = {
|
|
1533
|
+
accountId,
|
|
1534
|
+
threadId,
|
|
1535
|
+
deleted: true,
|
|
1536
|
+
revision: Number(thread.ledger_revision),
|
|
1537
|
+
ledgerHash: thread.ledger_hash,
|
|
1538
|
+
deletedAt
|
|
1539
|
+
};
|
|
1540
|
+
this.#database.prepare(`
|
|
1541
|
+
INSERT INTO direct_chat_thread_deletions(
|
|
1542
|
+
account_id, thread_id, key_hash, request_hash, deleted_revision,
|
|
1543
|
+
deleted_hash, result_digest, deleted_at
|
|
1544
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
1545
|
+
`).run(
|
|
1546
|
+
accountId,
|
|
1547
|
+
threadId,
|
|
1548
|
+
keyHash,
|
|
1549
|
+
requestHash,
|
|
1550
|
+
result.revision,
|
|
1551
|
+
result.ledgerHash,
|
|
1552
|
+
sha256(canonicalJson(threadDeletionDigest(result))),
|
|
1553
|
+
deletedAt
|
|
1554
|
+
);
|
|
1555
|
+
|
|
1556
|
+
// The durable receipt above is the only authority accepted by the
|
|
1557
|
+
// deletion triggers. All private descendants and mutation receipts are
|
|
1558
|
+
// removed in one transaction before the owning thread row disappears.
|
|
1559
|
+
this.#database.prepare(`
|
|
1560
|
+
DELETE FROM direct_chat_attachments WHERE account_id = ? AND thread_id = ?
|
|
1561
|
+
`).run(accountId, threadId);
|
|
1562
|
+
this.#database.prepare(`
|
|
1563
|
+
DELETE FROM direct_chat_deltas WHERE account_id = ? AND thread_id = ?
|
|
1564
|
+
`).run(accountId, threadId);
|
|
1565
|
+
this.#database.prepare(`
|
|
1566
|
+
DELETE FROM direct_chat_generation_leases WHERE account_id = ? AND thread_id = ?
|
|
1567
|
+
`).run(accountId, threadId);
|
|
1568
|
+
this.#database.prepare(`
|
|
1569
|
+
DELETE FROM direct_chat_compactions WHERE account_id = ? AND thread_id = ?
|
|
1570
|
+
`).run(accountId, threadId);
|
|
1571
|
+
this.#database.prepare(`
|
|
1572
|
+
DELETE FROM direct_chat_generations WHERE account_id = ? AND thread_id = ?
|
|
1573
|
+
`).run(accountId, threadId);
|
|
1574
|
+
this.#database.prepare(`
|
|
1575
|
+
DELETE FROM direct_chat_messages WHERE account_id = ? AND thread_id = ?
|
|
1576
|
+
`).run(accountId, threadId);
|
|
1577
|
+
this.#database.prepare(`
|
|
1578
|
+
DELETE FROM direct_chat_idempotency WHERE account_id = ? AND thread_id = ?
|
|
1579
|
+
`).run(accountId, threadId);
|
|
1580
|
+
const removed = this.#database.prepare(`
|
|
1581
|
+
DELETE FROM direct_chat_threads WHERE account_id = ? AND thread_id = ?
|
|
1582
|
+
`).run(accountId, threadId);
|
|
1583
|
+
if (Number(removed.changes) !== 1) {
|
|
1584
|
+
throw new StorageCorruptionError('The authorized direct-chat thread deletion was incomplete.');
|
|
1585
|
+
}
|
|
1586
|
+
return result;
|
|
1587
|
+
});
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
startTurn(input) {
|
|
1591
|
+
assertExactKeys(
|
|
1592
|
+
input,
|
|
1593
|
+
{
|
|
1594
|
+
required: [
|
|
1595
|
+
'accountId', 'threadId', 'messageId', 'content',
|
|
1596
|
+
'generationId', 'assistantMessageId',
|
|
1597
|
+
'expectedRevision', 'expectedHash', 'idempotencyKey'
|
|
1598
|
+
],
|
|
1599
|
+
optional: ['attachment', 'attachments']
|
|
1600
|
+
},
|
|
1601
|
+
'atomic direct chat turn'
|
|
1602
|
+
);
|
|
1603
|
+
const accountId = assertIdentifier(input.accountId, 'accountId');
|
|
1604
|
+
const threadId = assertIdentifier(input.threadId, 'threadId');
|
|
1605
|
+
const messageId = assertIdentifier(input.messageId, 'messageId');
|
|
1606
|
+
const generationId = assertIdentifier(input.generationId, 'generationId');
|
|
1607
|
+
const assistantMessageId = assertIdentifier(input.assistantMessageId, 'assistantMessageId');
|
|
1608
|
+
const content = assertUnicodeScalarString(input.content, 'content', {
|
|
1609
|
+
maxBytes: DIRECT_CHAT_LIMITS.messageBytes
|
|
1610
|
+
});
|
|
1611
|
+
const cursor = assertCursor(input.expectedRevision, input.expectedHash);
|
|
1612
|
+
if (input.attachment !== undefined && input.attachments !== undefined) {
|
|
1613
|
+
throw new ValidationError('Use either attachment or attachments, not both.');
|
|
1614
|
+
}
|
|
1615
|
+
const attachments = input.attachments !== undefined
|
|
1616
|
+
? assertTurnAttachments(input.attachments)
|
|
1617
|
+
: (input.attachment === undefined
|
|
1618
|
+
? Object.freeze([])
|
|
1619
|
+
: Object.freeze([assertTurnAttachment(input.attachment)]));
|
|
1620
|
+
if (messageId === assistantMessageId) {
|
|
1621
|
+
throw new ConflictError('The user and assistant message identifiers must be different.');
|
|
1622
|
+
}
|
|
1623
|
+
const request = {
|
|
1624
|
+
accountId,
|
|
1625
|
+
threadId,
|
|
1626
|
+
messageId,
|
|
1627
|
+
content: content.value,
|
|
1628
|
+
generationId,
|
|
1629
|
+
assistantMessageId,
|
|
1630
|
+
expectedRevision: cursor.revision,
|
|
1631
|
+
expectedHash: cursor.hash,
|
|
1632
|
+
...attachmentViewFields(attachments)
|
|
1633
|
+
};
|
|
1634
|
+
|
|
1635
|
+
const immutableDigest = (result) => ({
|
|
1636
|
+
message: {
|
|
1637
|
+
accountId: result.message.accountId,
|
|
1638
|
+
threadId: result.message.threadId,
|
|
1639
|
+
messageId: result.message.messageId,
|
|
1640
|
+
revision: result.message.revision,
|
|
1641
|
+
role: result.message.role,
|
|
1642
|
+
content: result.message.content,
|
|
1643
|
+
contentBytes: result.message.contentBytes,
|
|
1644
|
+
previousHash: result.message.previousHash,
|
|
1645
|
+
messageHash: result.message.messageHash,
|
|
1646
|
+
createdAt: result.message.createdAt,
|
|
1647
|
+
...(result.message.attachment === undefined ? {} : { attachment: result.message.attachment }),
|
|
1648
|
+
...(result.message.attachments === undefined ? {} : { attachments: result.message.attachments })
|
|
1649
|
+
},
|
|
1650
|
+
generation: {
|
|
1651
|
+
accountId: result.generation.accountId,
|
|
1652
|
+
threadId: result.generation.threadId,
|
|
1653
|
+
generationId: result.generation.generationId,
|
|
1654
|
+
assistantMessageId: result.generation.assistantMessageId,
|
|
1655
|
+
modelAlias: result.generation.modelAlias,
|
|
1656
|
+
sourceRevision: result.generation.sourceRevision,
|
|
1657
|
+
sourceHash: result.generation.sourceHash,
|
|
1658
|
+
startedAt: result.generation.startedAt
|
|
1659
|
+
}
|
|
1660
|
+
});
|
|
1661
|
+
const replayTurn = () => {
|
|
1662
|
+
const message = this.#database.prepare(`
|
|
1663
|
+
SELECT * FROM direct_chat_messages
|
|
1664
|
+
WHERE account_id = ? AND thread_id = ? AND message_id = ?
|
|
1665
|
+
`).get(accountId, threadId, messageId);
|
|
1666
|
+
const generation = this.#database.prepare(`
|
|
1667
|
+
SELECT * FROM direct_chat_generations
|
|
1668
|
+
WHERE account_id = ? AND thread_id = ? AND generation_id = ?
|
|
1669
|
+
`).get(accountId, threadId, generationId);
|
|
1670
|
+
if (!message || !generation) {
|
|
1671
|
+
throw new ConflictError('The atomic direct-chat turn is only partially present.');
|
|
1672
|
+
}
|
|
1673
|
+
const storedAttachments = attachmentsForMessage(this.#database, accountId, threadId, messageId);
|
|
1674
|
+
const expectedAttachmentFields = attachmentViewFields(attachments);
|
|
1675
|
+
const actualAttachmentFields = attachmentViewFields(storedAttachments);
|
|
1676
|
+
const latestAttachment = this.#schemaVersion >= 3
|
|
1677
|
+
? this.#database.prepare(`
|
|
1678
|
+
SELECT 1 AS present
|
|
1679
|
+
FROM direct_chat_attachments AS attachment
|
|
1680
|
+
JOIN direct_chat_messages AS owned
|
|
1681
|
+
ON owned.account_id = attachment.account_id
|
|
1682
|
+
AND owned.thread_id = attachment.thread_id
|
|
1683
|
+
AND owned.message_id = attachment.message_id
|
|
1684
|
+
WHERE attachment.account_id = ? AND attachment.thread_id = ?
|
|
1685
|
+
AND owned.revision <= ?
|
|
1686
|
+
LIMIT 1
|
|
1687
|
+
`).get(accountId, threadId, Number(message.revision))
|
|
1688
|
+
: null;
|
|
1689
|
+
const ownerThread = requireThread(this.#database, accountId, threadId);
|
|
1690
|
+
const expectedModelAlias = latestAttachment ? this.#visionModelAlias : ownerThread.model_alias;
|
|
1691
|
+
if (
|
|
1692
|
+
message.role !== 'user' ||
|
|
1693
|
+
message.content !== content.value ||
|
|
1694
|
+
Number(message.revision) !== cursor.revision + 1 ||
|
|
1695
|
+
message.previous_hash !== cursor.hash ||
|
|
1696
|
+
generation.assistant_message_id !== assistantMessageId ||
|
|
1697
|
+
Number(generation.source_revision) !== Number(message.revision) ||
|
|
1698
|
+
generation.source_hash !== message.message_hash ||
|
|
1699
|
+
generation.model_alias !== expectedModelAlias ||
|
|
1700
|
+
canonicalJson(actualAttachmentFields) !== canonicalJson(expectedAttachmentFields)
|
|
1701
|
+
) {
|
|
1702
|
+
throw new ConflictError('The atomic direct-chat turn identifiers are bound to different data.');
|
|
1703
|
+
}
|
|
1704
|
+
return {
|
|
1705
|
+
message: messageView(message, storedAttachments),
|
|
1706
|
+
generation: generationView(generation)
|
|
1707
|
+
};
|
|
1708
|
+
};
|
|
1709
|
+
|
|
1710
|
+
return this.#idempotentMutation(
|
|
1711
|
+
{
|
|
1712
|
+
accountId,
|
|
1713
|
+
threadId,
|
|
1714
|
+
operation: 'generation.start',
|
|
1715
|
+
idempotencyKey: input.idempotencyKey,
|
|
1716
|
+
request,
|
|
1717
|
+
resourceKind: 'generation',
|
|
1718
|
+
resourceId: generationId,
|
|
1719
|
+
versionOf: (result) => result.generation.sourceRevision,
|
|
1720
|
+
digestOf: immutableDigest
|
|
1721
|
+
},
|
|
1722
|
+
() => {
|
|
1723
|
+
const thread = requireThread(this.#database, accountId, threadId);
|
|
1724
|
+
this.#auditThread(thread);
|
|
1725
|
+
const existingMessage = this.#database.prepare(`
|
|
1726
|
+
SELECT * FROM direct_chat_messages
|
|
1727
|
+
WHERE account_id = ? AND thread_id = ? AND message_id = ?
|
|
1728
|
+
`).get(accountId, threadId, messageId);
|
|
1729
|
+
const existingGeneration = this.#database.prepare(`
|
|
1730
|
+
SELECT * FROM direct_chat_generations
|
|
1731
|
+
WHERE account_id = ? AND thread_id = ? AND generation_id = ?
|
|
1732
|
+
`).get(accountId, threadId, generationId);
|
|
1733
|
+
if (existingMessage || existingGeneration) return replayTurn();
|
|
1734
|
+
|
|
1735
|
+
const existingVision = this.#schemaVersion >= 3
|
|
1736
|
+
? this.#database.prepare(`
|
|
1737
|
+
SELECT 1 AS present FROM direct_chat_attachments
|
|
1738
|
+
WHERE account_id = ? AND thread_id = ?
|
|
1739
|
+
LIMIT 1
|
|
1740
|
+
`).get(accountId, threadId)
|
|
1741
|
+
: null;
|
|
1742
|
+
if ((attachments.length > 0 || existingVision)
|
|
1743
|
+
&& (!this.#enableVisionAttachments || this.#schemaVersion < 3)) {
|
|
1744
|
+
throw new ValidationError('Vision attachments are not enabled for new Direct Chat turns.');
|
|
1745
|
+
}
|
|
1746
|
+
if (thread.current_generation_id !== null) {
|
|
1747
|
+
throw new ConflictError('A user turn cannot start while generation is in progress.');
|
|
1748
|
+
}
|
|
1749
|
+
assertCursorMatches(thread, cursor);
|
|
1750
|
+
if (Number(thread.message_count) >= DIRECT_CHAT_LIMITS.messagesPerThread) {
|
|
1751
|
+
throw new ConflictError('The direct-chat thread reached its message limit.');
|
|
1752
|
+
}
|
|
1753
|
+
if (Number(thread.ledger_bytes) + content.bytes > DIRECT_CHAT_LIMITS.ledgerBytesPerThread) {
|
|
1754
|
+
throw new ConflictError('The direct-chat thread reached its ledger byte limit.');
|
|
1755
|
+
}
|
|
1756
|
+
if (Number(thread.generation_count) >= DIRECT_CHAT_LIMITS.generationsPerThread) {
|
|
1757
|
+
throw new ConflictError('The direct-chat thread reached its generation limit.');
|
|
1758
|
+
}
|
|
1759
|
+
const reservedUserId = this.#database.prepare(`
|
|
1760
|
+
SELECT 1 AS present FROM direct_chat_generations
|
|
1761
|
+
WHERE account_id = ? AND thread_id = ? AND assistant_message_id = ?
|
|
1762
|
+
`).get(accountId, threadId, messageId);
|
|
1763
|
+
if (reservedUserId) {
|
|
1764
|
+
throw new ConflictError('The user message identifier is reserved by an assistant generation.');
|
|
1765
|
+
}
|
|
1766
|
+
const assistantMessageCollision = this.#database.prepare(`
|
|
1767
|
+
SELECT 1 AS present FROM direct_chat_messages
|
|
1768
|
+
WHERE account_id = ? AND thread_id = ? AND message_id = ?
|
|
1769
|
+
`).get(accountId, threadId, assistantMessageId);
|
|
1770
|
+
if (assistantMessageCollision) {
|
|
1771
|
+
throw new ConflictError('The assistant message identifier is already used.');
|
|
1772
|
+
}
|
|
1773
|
+
const assistantReservationCollision = this.#database.prepare(`
|
|
1774
|
+
SELECT 1 AS present FROM direct_chat_generations
|
|
1775
|
+
WHERE account_id = ? AND thread_id = ? AND assistant_message_id = ?
|
|
1776
|
+
`).get(accountId, threadId, assistantMessageId);
|
|
1777
|
+
if (assistantReservationCollision) {
|
|
1778
|
+
throw new ConflictError('The assistant message identifier is already reserved.');
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
let inferenceModelAlias = thread.model_alias;
|
|
1782
|
+
if (existingVision || attachments.length > 0) inferenceModelAlias = this.#visionModelAlias;
|
|
1783
|
+
if (attachments.length > 0) {
|
|
1784
|
+
const threadAttachmentTotals = this.#database.prepare(`
|
|
1785
|
+
SELECT count(*) AS count, coalesce(sum(byte_length), 0) AS bytes
|
|
1786
|
+
FROM direct_chat_attachments
|
|
1787
|
+
WHERE account_id = ? AND thread_id = ?
|
|
1788
|
+
`).get(accountId, threadId);
|
|
1789
|
+
const accountAttachmentBytes = Number(this.#database.prepare(`
|
|
1790
|
+
SELECT coalesce(sum(byte_length), 0) AS bytes
|
|
1791
|
+
FROM direct_chat_attachments WHERE account_id = ?
|
|
1792
|
+
`).get(accountId).bytes);
|
|
1793
|
+
const incomingBytes = attachments.reduce((total, attachment) => total + attachment.byteLength, 0);
|
|
1794
|
+
if (Number(threadAttachmentTotals.count) + attachments.length > VISION_ATTACHMENT_LIMITS.attachmentsPerThread
|
|
1795
|
+
|| Number(threadAttachmentTotals.bytes) + incomingBytes > VISION_ATTACHMENT_LIMITS.bytesPerThread
|
|
1796
|
+
|| accountAttachmentBytes + incomingBytes > VISION_ATTACHMENT_LIMITS.bytesPerAccount) {
|
|
1797
|
+
throw new ConflictError('The Direct Chat vision attachment storage quota is reached.');
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
const timestamp = nowIso(this.#clock);
|
|
1802
|
+
const message = {
|
|
1803
|
+
account_id: accountId,
|
|
1804
|
+
thread_id: threadId,
|
|
1805
|
+
message_id: messageId,
|
|
1806
|
+
revision: cursor.revision + 1,
|
|
1807
|
+
role: 'user',
|
|
1808
|
+
content: content.value,
|
|
1809
|
+
content_bytes: content.bytes,
|
|
1810
|
+
previous_hash: cursor.hash,
|
|
1811
|
+
generation_id: null,
|
|
1812
|
+
created_at: timestamp
|
|
1813
|
+
};
|
|
1814
|
+
message.message_hash = calculateMessageHash(message, attachments);
|
|
1815
|
+
try {
|
|
1816
|
+
this.#database.prepare(`
|
|
1817
|
+
INSERT INTO direct_chat_messages(
|
|
1818
|
+
account_id, thread_id, message_id, revision, role, content, content_bytes,
|
|
1819
|
+
previous_hash, message_hash, generation_id, created_at
|
|
1820
|
+
) VALUES (?, ?, ?, ?, 'user', ?, ?, ?, ?, NULL, ?)
|
|
1821
|
+
`).run(
|
|
1822
|
+
accountId,
|
|
1823
|
+
threadId,
|
|
1824
|
+
messageId,
|
|
1825
|
+
message.revision,
|
|
1826
|
+
message.content,
|
|
1827
|
+
message.content_bytes,
|
|
1828
|
+
message.previous_hash,
|
|
1829
|
+
message.message_hash,
|
|
1830
|
+
timestamp
|
|
1831
|
+
);
|
|
1832
|
+
for (let position = 0; position < attachments.length; position += 1) {
|
|
1833
|
+
const attachment = attachments[position];
|
|
1834
|
+
this.#database.prepare(`
|
|
1835
|
+
INSERT INTO direct_chat_attachments(
|
|
1836
|
+
account_id, thread_id, attachment_id, message_id, position, media_type,
|
|
1837
|
+
byte_length, width, height, content_sha256, content, created_at
|
|
1838
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1839
|
+
`).run(
|
|
1840
|
+
accountId,
|
|
1841
|
+
threadId,
|
|
1842
|
+
attachment.attachmentId,
|
|
1843
|
+
messageId,
|
|
1844
|
+
position,
|
|
1845
|
+
attachment.mediaType,
|
|
1846
|
+
attachment.byteLength,
|
|
1847
|
+
attachment.width,
|
|
1848
|
+
attachment.height,
|
|
1849
|
+
attachment.contentSha256,
|
|
1850
|
+
attachment.content,
|
|
1851
|
+
timestamp
|
|
1852
|
+
);
|
|
1853
|
+
}
|
|
1854
|
+
this.#database.prepare(`
|
|
1855
|
+
INSERT INTO direct_chat_generations(
|
|
1856
|
+
account_id, thread_id, generation_id, assistant_message_id, status,
|
|
1857
|
+
model_alias, source_revision, source_hash, started_at, updated_at
|
|
1858
|
+
) VALUES (?, ?, ?, ?, 'in_progress', ?, ?, ?, ?, ?)
|
|
1859
|
+
`).run(
|
|
1860
|
+
accountId,
|
|
1861
|
+
threadId,
|
|
1862
|
+
generationId,
|
|
1863
|
+
assistantMessageId,
|
|
1864
|
+
inferenceModelAlias,
|
|
1865
|
+
message.revision,
|
|
1866
|
+
message.message_hash,
|
|
1867
|
+
timestamp,
|
|
1868
|
+
timestamp
|
|
1869
|
+
);
|
|
1870
|
+
} catch (error) {
|
|
1871
|
+
if (isConstraintError(error)) throw new ConflictError('The atomic direct-chat turn identifiers conflict.');
|
|
1872
|
+
throw error;
|
|
1873
|
+
}
|
|
1874
|
+
const updated = this.#database.prepare(`
|
|
1875
|
+
UPDATE direct_chat_threads
|
|
1876
|
+
SET ledger_revision = ?, ledger_hash = ?, message_count = message_count + 1,
|
|
1877
|
+
ledger_bytes = ledger_bytes + ?, current_generation_id = ?,
|
|
1878
|
+
generation_count = generation_count + 1, updated_at = ?
|
|
1879
|
+
WHERE account_id = ? AND thread_id = ? AND ledger_revision = ?
|
|
1880
|
+
AND ledger_hash IS ? AND current_generation_id IS NULL
|
|
1881
|
+
`).run(
|
|
1882
|
+
message.revision,
|
|
1883
|
+
message.message_hash,
|
|
1884
|
+
message.content_bytes,
|
|
1885
|
+
generationId,
|
|
1886
|
+
timestamp,
|
|
1887
|
+
accountId,
|
|
1888
|
+
threadId,
|
|
1889
|
+
cursor.revision,
|
|
1890
|
+
cursor.hash
|
|
1891
|
+
);
|
|
1892
|
+
if (Number(updated.changes) !== 1) {
|
|
1893
|
+
throw new ConflictError('The direct-chat thread changed concurrently.');
|
|
1894
|
+
}
|
|
1895
|
+
return {
|
|
1896
|
+
message: messageView(message, attachments),
|
|
1897
|
+
generation: generationView(
|
|
1898
|
+
requireGeneration(this.#database, accountId, threadId, generationId)
|
|
1899
|
+
)
|
|
1900
|
+
};
|
|
1901
|
+
},
|
|
1902
|
+
replayTurn
|
|
1903
|
+
);
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
sendUserMessage(input) {
|
|
1907
|
+
assertExactKeys(
|
|
1908
|
+
input,
|
|
1909
|
+
{
|
|
1910
|
+
required: [
|
|
1911
|
+
'accountId', 'threadId', 'messageId', 'content',
|
|
1912
|
+
'expectedRevision', 'expectedHash', 'idempotencyKey'
|
|
1913
|
+
]
|
|
1914
|
+
},
|
|
1915
|
+
'direct chat user message'
|
|
1916
|
+
);
|
|
1917
|
+
const accountId = assertIdentifier(input.accountId, 'accountId');
|
|
1918
|
+
const threadId = assertIdentifier(input.threadId, 'threadId');
|
|
1919
|
+
const messageId = assertIdentifier(input.messageId, 'messageId');
|
|
1920
|
+
const content = assertUnicodeScalarString(input.content, 'content', {
|
|
1921
|
+
maxBytes: DIRECT_CHAT_LIMITS.messageBytes
|
|
1922
|
+
});
|
|
1923
|
+
const cursor = assertCursor(input.expectedRevision, input.expectedHash);
|
|
1924
|
+
const request = {
|
|
1925
|
+
accountId, threadId, messageId, content: content.value,
|
|
1926
|
+
expectedRevision: cursor.revision, expectedHash: cursor.hash
|
|
1927
|
+
};
|
|
1928
|
+
|
|
1929
|
+
const replayMessage = () => {
|
|
1930
|
+
const row = this.#database.prepare(`
|
|
1931
|
+
SELECT * FROM direct_chat_messages
|
|
1932
|
+
WHERE account_id = ? AND thread_id = ? AND message_id = ?
|
|
1933
|
+
`).get(accountId, threadId, messageId);
|
|
1934
|
+
if (!row) throw new ConflictError('The idempotent user message is no longer present.');
|
|
1935
|
+
return messageView(row);
|
|
1936
|
+
};
|
|
1937
|
+
|
|
1938
|
+
return this.#idempotentMutation(
|
|
1939
|
+
{
|
|
1940
|
+
accountId,
|
|
1941
|
+
threadId,
|
|
1942
|
+
operation: 'message.user.append',
|
|
1943
|
+
idempotencyKey: input.idempotencyKey,
|
|
1944
|
+
request,
|
|
1945
|
+
resourceKind: 'message',
|
|
1946
|
+
resourceId: messageId,
|
|
1947
|
+
versionOf: (result) => result.revision
|
|
1948
|
+
},
|
|
1949
|
+
() => {
|
|
1950
|
+
const thread = requireThread(this.#database, accountId, threadId);
|
|
1951
|
+
this.#auditThread(thread);
|
|
1952
|
+
const existing = this.#database.prepare(`
|
|
1953
|
+
SELECT * FROM direct_chat_messages
|
|
1954
|
+
WHERE account_id = ? AND thread_id = ? AND message_id = ?
|
|
1955
|
+
`).get(accountId, threadId, messageId);
|
|
1956
|
+
if (existing) {
|
|
1957
|
+
if (
|
|
1958
|
+
existing.role !== 'user' || existing.content !== content.value ||
|
|
1959
|
+
Number(existing.revision) !== cursor.revision + 1 || existing.previous_hash !== cursor.hash
|
|
1960
|
+
) {
|
|
1961
|
+
throw new ConflictError('The direct-chat message identifier is already bound to different content.');
|
|
1962
|
+
}
|
|
1963
|
+
return messageView(existing);
|
|
1964
|
+
}
|
|
1965
|
+
const reservedAssistantId = this.#database.prepare(`
|
|
1966
|
+
SELECT 1 AS present FROM direct_chat_generations
|
|
1967
|
+
WHERE account_id = ? AND thread_id = ? AND assistant_message_id = ?
|
|
1968
|
+
`).get(accountId, threadId, messageId);
|
|
1969
|
+
if (reservedAssistantId) {
|
|
1970
|
+
throw new ConflictError('The message identifier is reserved by an assistant generation.');
|
|
1971
|
+
}
|
|
1972
|
+
if (thread.current_generation_id !== null) {
|
|
1973
|
+
throw new ConflictError('A user message cannot be appended while generation is in progress.');
|
|
1974
|
+
}
|
|
1975
|
+
assertCursorMatches(thread, cursor);
|
|
1976
|
+
if (Number(thread.message_count) >= DIRECT_CHAT_LIMITS.messagesPerThread) {
|
|
1977
|
+
throw new ConflictError('The direct-chat thread reached its message limit.');
|
|
1978
|
+
}
|
|
1979
|
+
if (Number(thread.ledger_bytes) + content.bytes > DIRECT_CHAT_LIMITS.ledgerBytesPerThread) {
|
|
1980
|
+
throw new ConflictError('The direct-chat thread reached its ledger byte limit.');
|
|
1981
|
+
}
|
|
1982
|
+
const timestamp = nowIso(this.#clock);
|
|
1983
|
+
const row = {
|
|
1984
|
+
account_id: accountId,
|
|
1985
|
+
thread_id: threadId,
|
|
1986
|
+
message_id: messageId,
|
|
1987
|
+
revision: cursor.revision + 1,
|
|
1988
|
+
role: 'user',
|
|
1989
|
+
content: content.value,
|
|
1990
|
+
content_bytes: content.bytes,
|
|
1991
|
+
previous_hash: cursor.hash,
|
|
1992
|
+
generation_id: null,
|
|
1993
|
+
created_at: timestamp
|
|
1994
|
+
};
|
|
1995
|
+
row.message_hash = calculateMessageHash(row);
|
|
1996
|
+
this.#database.prepare(`
|
|
1997
|
+
INSERT INTO direct_chat_messages(
|
|
1998
|
+
account_id, thread_id, message_id, revision, role, content, content_bytes,
|
|
1999
|
+
previous_hash, message_hash, generation_id, created_at
|
|
2000
|
+
) VALUES (?, ?, ?, ?, 'user', ?, ?, ?, ?, NULL, ?)
|
|
2001
|
+
`).run(
|
|
2002
|
+
accountId, threadId, messageId, row.revision, row.content, row.content_bytes,
|
|
2003
|
+
row.previous_hash, row.message_hash, timestamp
|
|
2004
|
+
);
|
|
2005
|
+
const updated = this.#database.prepare(`
|
|
2006
|
+
UPDATE direct_chat_threads
|
|
2007
|
+
SET ledger_revision = ?, ledger_hash = ?, message_count = message_count + 1,
|
|
2008
|
+
ledger_bytes = ledger_bytes + ?, updated_at = ?
|
|
2009
|
+
WHERE account_id = ? AND thread_id = ? AND ledger_revision = ?
|
|
2010
|
+
AND ledger_hash IS ? AND current_generation_id IS NULL
|
|
2011
|
+
`).run(
|
|
2012
|
+
row.revision, row.message_hash, row.content_bytes, timestamp,
|
|
2013
|
+
accountId, threadId, cursor.revision, cursor.hash
|
|
2014
|
+
);
|
|
2015
|
+
if (Number(updated.changes) !== 1) throw new ConflictError('The direct-chat ledger changed concurrently.');
|
|
2016
|
+
return messageView(row);
|
|
2017
|
+
},
|
|
2018
|
+
replayMessage
|
|
2019
|
+
);
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
startGeneration(input) {
|
|
2023
|
+
assertExactKeys(
|
|
2024
|
+
input,
|
|
2025
|
+
{
|
|
2026
|
+
required: [
|
|
2027
|
+
'accountId', 'threadId', 'generationId', 'assistantMessageId',
|
|
2028
|
+
'expectedRevision', 'expectedHash', 'idempotencyKey'
|
|
2029
|
+
]
|
|
2030
|
+
},
|
|
2031
|
+
'direct chat generation'
|
|
2032
|
+
);
|
|
2033
|
+
const accountId = assertIdentifier(input.accountId, 'accountId');
|
|
2034
|
+
const threadId = assertIdentifier(input.threadId, 'threadId');
|
|
2035
|
+
const generationId = assertIdentifier(input.generationId, 'generationId');
|
|
2036
|
+
const assistantMessageId = assertIdentifier(input.assistantMessageId, 'assistantMessageId');
|
|
2037
|
+
const cursor = assertCursor(input.expectedRevision, input.expectedHash);
|
|
2038
|
+
if (cursor.revision === 0) throw new ValidationError('A generation must follow a user message.');
|
|
2039
|
+
const request = {
|
|
2040
|
+
accountId, threadId, generationId, assistantMessageId,
|
|
2041
|
+
expectedRevision: cursor.revision, expectedHash: cursor.hash
|
|
2042
|
+
};
|
|
2043
|
+
const replayGeneration = () => generationView(
|
|
2044
|
+
requireGeneration(this.#database, accountId, threadId, generationId)
|
|
2045
|
+
);
|
|
2046
|
+
|
|
2047
|
+
return this.#idempotentMutation(
|
|
2048
|
+
{
|
|
2049
|
+
accountId,
|
|
2050
|
+
threadId,
|
|
2051
|
+
operation: 'generation.start',
|
|
2052
|
+
idempotencyKey: input.idempotencyKey,
|
|
2053
|
+
request,
|
|
2054
|
+
resourceKind: 'generation',
|
|
2055
|
+
resourceId: generationId,
|
|
2056
|
+
versionOf: statusVersion,
|
|
2057
|
+
digestOf: (result) => ({
|
|
2058
|
+
accountId: result.accountId,
|
|
2059
|
+
threadId: result.threadId,
|
|
2060
|
+
generationId: result.generationId,
|
|
2061
|
+
assistantMessageId: result.assistantMessageId,
|
|
2062
|
+
modelAlias: result.modelAlias,
|
|
2063
|
+
sourceRevision: result.sourceRevision,
|
|
2064
|
+
sourceHash: result.sourceHash
|
|
2065
|
+
})
|
|
2066
|
+
},
|
|
2067
|
+
() => {
|
|
2068
|
+
const thread = requireThread(this.#database, accountId, threadId);
|
|
2069
|
+
this.#auditThread(thread);
|
|
2070
|
+
const inferenceModelAlias = this.#schemaVersion >= 3 && this.#database.prepare(`
|
|
2071
|
+
SELECT 1 AS present
|
|
2072
|
+
FROM direct_chat_attachments AS attachment
|
|
2073
|
+
JOIN direct_chat_messages AS message
|
|
2074
|
+
ON message.account_id = attachment.account_id
|
|
2075
|
+
AND message.thread_id = attachment.thread_id
|
|
2076
|
+
AND message.message_id = attachment.message_id
|
|
2077
|
+
WHERE attachment.account_id = ? AND attachment.thread_id = ?
|
|
2078
|
+
AND message.revision <= ?
|
|
2079
|
+
LIMIT 1
|
|
2080
|
+
`).get(accountId, threadId, cursor.revision)
|
|
2081
|
+
? this.#visionModelAlias
|
|
2082
|
+
: thread.model_alias;
|
|
2083
|
+
const existing = this.#database.prepare(`
|
|
2084
|
+
SELECT * FROM direct_chat_generations
|
|
2085
|
+
WHERE account_id = ? AND thread_id = ? AND generation_id = ?
|
|
2086
|
+
`).get(accountId, threadId, generationId);
|
|
2087
|
+
if (existing) {
|
|
2088
|
+
if (
|
|
2089
|
+
existing.assistant_message_id !== assistantMessageId ||
|
|
2090
|
+
Number(existing.source_revision) !== cursor.revision ||
|
|
2091
|
+
existing.source_hash !== cursor.hash || existing.model_alias !== inferenceModelAlias
|
|
2092
|
+
) {
|
|
2093
|
+
throw new ConflictError('The generation identifier is already bound to a different request.');
|
|
2094
|
+
}
|
|
2095
|
+
return generationView(existing);
|
|
2096
|
+
}
|
|
2097
|
+
assertCursorMatches(thread, cursor);
|
|
2098
|
+
if (thread.current_generation_id !== null) {
|
|
2099
|
+
throw new ConflictError('The thread already has an active generation.');
|
|
2100
|
+
}
|
|
2101
|
+
if (Number(thread.generation_count) >= DIRECT_CHAT_LIMITS.generationsPerThread) {
|
|
2102
|
+
throw new ConflictError('The direct-chat thread reached its generation limit.');
|
|
2103
|
+
}
|
|
2104
|
+
const last = this.#database.prepare(`
|
|
2105
|
+
SELECT role, message_hash FROM direct_chat_messages
|
|
2106
|
+
WHERE account_id = ? AND thread_id = ? AND revision = ?
|
|
2107
|
+
`).get(accountId, threadId, cursor.revision);
|
|
2108
|
+
if (!last || last.role !== 'user' || last.message_hash !== cursor.hash) {
|
|
2109
|
+
throw new ConflictError('A generation must start from the exact latest user message.');
|
|
2110
|
+
}
|
|
2111
|
+
const messageCollision = this.#database.prepare(`
|
|
2112
|
+
SELECT 1 AS present FROM direct_chat_messages
|
|
2113
|
+
WHERE account_id = ? AND thread_id = ? AND message_id = ?
|
|
2114
|
+
`).get(accountId, threadId, assistantMessageId);
|
|
2115
|
+
if (messageCollision) throw new ConflictError('The assistant message identifier is already used.');
|
|
2116
|
+
const generationMessageCollision = this.#database.prepare(`
|
|
2117
|
+
SELECT 1 AS present FROM direct_chat_generations
|
|
2118
|
+
WHERE account_id = ? AND thread_id = ? AND assistant_message_id = ?
|
|
2119
|
+
`).get(accountId, threadId, assistantMessageId);
|
|
2120
|
+
if (generationMessageCollision) {
|
|
2121
|
+
throw new ConflictError('The assistant message identifier is already reserved.');
|
|
2122
|
+
}
|
|
2123
|
+
const timestamp = nowIso(this.#clock);
|
|
2124
|
+
this.#database.prepare(`
|
|
2125
|
+
INSERT INTO direct_chat_generations(
|
|
2126
|
+
account_id, thread_id, generation_id, assistant_message_id, status,
|
|
2127
|
+
model_alias, source_revision, source_hash, started_at, updated_at
|
|
2128
|
+
) VALUES (?, ?, ?, ?, 'in_progress', ?, ?, ?, ?, ?)
|
|
2129
|
+
`).run(
|
|
2130
|
+
accountId, threadId, generationId, assistantMessageId,
|
|
2131
|
+
inferenceModelAlias, cursor.revision, cursor.hash, timestamp, timestamp
|
|
2132
|
+
);
|
|
2133
|
+
const updated = this.#database.prepare(`
|
|
2134
|
+
UPDATE direct_chat_threads
|
|
2135
|
+
SET current_generation_id = ?, generation_count = generation_count + 1, updated_at = ?
|
|
2136
|
+
WHERE account_id = ? AND thread_id = ? AND current_generation_id IS NULL
|
|
2137
|
+
AND ledger_revision = ? AND ledger_hash = ?
|
|
2138
|
+
`).run(generationId, timestamp, accountId, threadId, cursor.revision, cursor.hash);
|
|
2139
|
+
if (Number(updated.changes) !== 1) throw new ConflictError('The direct-chat thread changed concurrently.');
|
|
2140
|
+
return generationView(requireGeneration(this.#database, accountId, threadId, generationId));
|
|
2141
|
+
},
|
|
2142
|
+
replayGeneration
|
|
2143
|
+
);
|
|
2144
|
+
}
|
|
2145
|
+
|
|
2146
|
+
claimGenerationLease(input) {
|
|
2147
|
+
assertExactKeys(
|
|
2148
|
+
input,
|
|
2149
|
+
{ required: ['accountId', 'threadId', 'generationId', 'ownerToken', 'ttlMs'] },
|
|
2150
|
+
'direct chat generation dispatch lease claim'
|
|
2151
|
+
);
|
|
2152
|
+
const accountId = assertIdentifier(input.accountId, 'accountId');
|
|
2153
|
+
const threadId = assertIdentifier(input.threadId, 'threadId');
|
|
2154
|
+
const generationId = assertIdentifier(input.generationId, 'generationId');
|
|
2155
|
+
const ownerHash = sha256(assertDispatchLeaseOwnerToken(input.ownerToken));
|
|
2156
|
+
const ttlMs = assertDispatchLeaseTtl(input.ttlMs);
|
|
2157
|
+
|
|
2158
|
+
let blockedMessage = null;
|
|
2159
|
+
const claimed = this.#transaction(() => {
|
|
2160
|
+
const thread = requireThread(this.#database, accountId, threadId);
|
|
2161
|
+
this.#auditThread(thread);
|
|
2162
|
+
const generation = requireGeneration(this.#database, accountId, threadId, generationId);
|
|
2163
|
+
if (generation.status !== 'in_progress' || thread.current_generation_id !== generationId) {
|
|
2164
|
+
throw new ConflictError('Only the active in-progress generation can acquire a dispatch lease.');
|
|
2165
|
+
}
|
|
2166
|
+
const timestamp = nowIso(this.#clock);
|
|
2167
|
+
this.#reconcileExpiredGenerationDispatches(timestamp);
|
|
2168
|
+
const existing = this.#generationLease(accountId, threadId, generationId);
|
|
2169
|
+
if (existing) auditDispatchLease(existing);
|
|
2170
|
+
if (existing?.phase === 'interrupted') {
|
|
2171
|
+
blockedMessage = 'This generation has an ambiguous interrupted dispatch and cannot run inference again.';
|
|
2172
|
+
return dispatchLeaseView(existing, timestamp);
|
|
2173
|
+
}
|
|
2174
|
+
if (
|
|
2175
|
+
existing &&
|
|
2176
|
+
existing.released_at === null &&
|
|
2177
|
+
existing.expires_at > timestamp
|
|
2178
|
+
) {
|
|
2179
|
+
if (existing.owner_hash !== ownerHash) {
|
|
2180
|
+
throw new ConflictError('The generation already has an active dispatch lease.');
|
|
2181
|
+
}
|
|
2182
|
+
if (existing.phase !== 'claimed') {
|
|
2183
|
+
blockedMessage = 'Inference dispatch was already marked started for this generation.';
|
|
2184
|
+
}
|
|
2185
|
+
return dispatchLeaseView(existing, timestamp);
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2188
|
+
if (existing && existing.dispatch_started_at !== null) {
|
|
2189
|
+
const fence = Number(existing.fence) + 1;
|
|
2190
|
+
if (!Number.isSafeInteger(fence) || fence > MAX_DISPATCH_LEASE_FENCE) {
|
|
2191
|
+
throw new ConflictError('The generation dispatch lease fence is exhausted.');
|
|
2192
|
+
}
|
|
2193
|
+
const interruptedAt = timestamp < existing.updated_at ? existing.updated_at : timestamp;
|
|
2194
|
+
const updated = this.#database.prepare(`
|
|
2195
|
+
UPDATE direct_chat_generation_leases
|
|
2196
|
+
SET fence = ?, phase = 'interrupted', expires_at = ?,
|
|
2197
|
+
updated_at = ?, released_at = ?
|
|
2198
|
+
WHERE account_id = ? AND thread_id = ? AND generation_id = ?
|
|
2199
|
+
AND fence = ? AND owner_hash = ? AND phase = 'dispatch_started'
|
|
2200
|
+
AND dispatch_started_at IS NOT NULL AND released_at IS NULL
|
|
2201
|
+
`).run(
|
|
2202
|
+
fence,
|
|
2203
|
+
interruptedAt,
|
|
2204
|
+
interruptedAt,
|
|
2205
|
+
interruptedAt,
|
|
2206
|
+
accountId,
|
|
2207
|
+
threadId,
|
|
2208
|
+
generationId,
|
|
2209
|
+
existing.fence,
|
|
2210
|
+
existing.owner_hash
|
|
2211
|
+
);
|
|
2212
|
+
if (Number(updated.changes) !== 1) {
|
|
2213
|
+
throw new ConflictError('The generation dispatch lease changed concurrently.');
|
|
2214
|
+
}
|
|
2215
|
+
blockedMessage = 'The expired generation dispatch may already have run; start a new user generation.';
|
|
2216
|
+
return dispatchLeaseView(
|
|
2217
|
+
this.#generationLease(accountId, threadId, generationId),
|
|
2218
|
+
timestamp
|
|
2219
|
+
);
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2222
|
+
const fence = existing === null ? 1 : Number(existing.fence) + 1;
|
|
2223
|
+
if (!Number.isSafeInteger(fence) || fence > MAX_DISPATCH_LEASE_FENCE) {
|
|
2224
|
+
throw new ConflictError('The generation dispatch lease fence is exhausted.');
|
|
2225
|
+
}
|
|
2226
|
+
const claimedAt = existing && timestamp < existing.updated_at
|
|
2227
|
+
? existing.updated_at
|
|
2228
|
+
: timestamp;
|
|
2229
|
+
const expiresAt = addMilliseconds(claimedAt, ttlMs);
|
|
2230
|
+
if (!existing) {
|
|
2231
|
+
this.#database.prepare(`
|
|
2232
|
+
INSERT INTO direct_chat_generation_leases(
|
|
2233
|
+
account_id, thread_id, generation_id, owner_hash, fence, phase,
|
|
2234
|
+
expires_at, claimed_at, dispatch_started_at, updated_at, released_at
|
|
2235
|
+
) VALUES (?, ?, ?, ?, ?, 'claimed', ?, ?, NULL, ?, NULL)
|
|
2236
|
+
`).run(
|
|
2237
|
+
accountId,
|
|
2238
|
+
threadId,
|
|
2239
|
+
generationId,
|
|
2240
|
+
ownerHash,
|
|
2241
|
+
fence,
|
|
2242
|
+
expiresAt,
|
|
2243
|
+
claimedAt,
|
|
2244
|
+
claimedAt
|
|
2245
|
+
);
|
|
2246
|
+
} else {
|
|
2247
|
+
const updated = this.#database.prepare(`
|
|
2248
|
+
UPDATE direct_chat_generation_leases
|
|
2249
|
+
SET owner_hash = ?, fence = ?, phase = 'claimed', expires_at = ?, claimed_at = ?,
|
|
2250
|
+
dispatch_started_at = NULL, updated_at = ?, released_at = NULL
|
|
2251
|
+
WHERE account_id = ? AND thread_id = ? AND generation_id = ?
|
|
2252
|
+
AND fence = ? AND owner_hash = ? AND expires_at = ?
|
|
2253
|
+
AND phase IN ('claimed', 'released') AND dispatch_started_at IS NULL
|
|
2254
|
+
AND released_at IS ?
|
|
2255
|
+
`).run(
|
|
2256
|
+
ownerHash,
|
|
2257
|
+
fence,
|
|
2258
|
+
expiresAt,
|
|
2259
|
+
claimedAt,
|
|
2260
|
+
claimedAt,
|
|
2261
|
+
accountId,
|
|
2262
|
+
threadId,
|
|
2263
|
+
generationId,
|
|
2264
|
+
existing.fence,
|
|
2265
|
+
existing.owner_hash,
|
|
2266
|
+
existing.expires_at,
|
|
2267
|
+
existing.released_at
|
|
2268
|
+
);
|
|
2269
|
+
if (Number(updated.changes) !== 1) {
|
|
2270
|
+
throw new ConflictError('The generation dispatch lease changed concurrently.');
|
|
2271
|
+
}
|
|
2272
|
+
}
|
|
2273
|
+
return dispatchLeaseView(
|
|
2274
|
+
this.#generationLease(accountId, threadId, generationId),
|
|
2275
|
+
timestamp
|
|
2276
|
+
);
|
|
2277
|
+
});
|
|
2278
|
+
if (blockedMessage !== null) throw new ConflictError(blockedMessage);
|
|
2279
|
+
return claimed;
|
|
2280
|
+
}
|
|
2281
|
+
|
|
2282
|
+
markGenerationDispatchStarted(input) {
|
|
2283
|
+
assertExactKeys(
|
|
2284
|
+
input,
|
|
2285
|
+
{ required: ['accountId', 'threadId', 'generationId', 'ownerToken', 'fence'] },
|
|
2286
|
+
'direct chat generation dispatch start marker'
|
|
2287
|
+
);
|
|
2288
|
+
const accountId = assertIdentifier(input.accountId, 'accountId');
|
|
2289
|
+
const threadId = assertIdentifier(input.threadId, 'threadId');
|
|
2290
|
+
const generationId = assertIdentifier(input.generationId, 'generationId');
|
|
2291
|
+
const proof = assertDispatchLeaseProof({ ownerToken: input.ownerToken, fence: input.fence });
|
|
2292
|
+
|
|
2293
|
+
return this.#transaction(() => {
|
|
2294
|
+
const thread = requireThread(this.#database, accountId, threadId);
|
|
2295
|
+
this.#auditThread(thread);
|
|
2296
|
+
const generation = requireGeneration(this.#database, accountId, threadId, generationId);
|
|
2297
|
+
if (generation.status !== 'in_progress' || thread.current_generation_id !== generationId) {
|
|
2298
|
+
throw new ConflictError('Only the active in-progress generation can start dispatch.');
|
|
2299
|
+
}
|
|
2300
|
+
const timestamp = nowIso(this.#clock);
|
|
2301
|
+
const lease = this.#requireGenerationLease(
|
|
2302
|
+
accountId,
|
|
2303
|
+
threadId,
|
|
2304
|
+
generationId,
|
|
2305
|
+
proof,
|
|
2306
|
+
timestamp,
|
|
2307
|
+
['claimed', 'dispatch_started']
|
|
2308
|
+
);
|
|
2309
|
+
if (lease.phase === 'dispatch_started') {
|
|
2310
|
+
return Object.freeze({
|
|
2311
|
+
...dispatchLeaseView(lease, timestamp),
|
|
2312
|
+
dispatchAuthorized: false,
|
|
2313
|
+
dispatchState: 'already_started'
|
|
2314
|
+
});
|
|
2315
|
+
}
|
|
2316
|
+
this.#reconcileExpiredGenerationDispatches(timestamp);
|
|
2317
|
+
const activeInference = this.#database.prepare(`
|
|
2318
|
+
SELECT 1 AS present FROM direct_chat_generation_leases
|
|
2319
|
+
WHERE phase = 'dispatch_started' AND released_at IS NULL
|
|
2320
|
+
AND NOT (account_id = ? AND thread_id = ? AND generation_id = ?)
|
|
2321
|
+
LIMIT 1
|
|
2322
|
+
`).get(accountId, threadId, generationId);
|
|
2323
|
+
if (activeInference) {
|
|
2324
|
+
return Object.freeze({
|
|
2325
|
+
...dispatchLeaseView(lease, timestamp),
|
|
2326
|
+
dispatchAuthorized: false,
|
|
2327
|
+
dispatchState: 'global_busy'
|
|
2328
|
+
});
|
|
2329
|
+
}
|
|
2330
|
+
const startedAt = timestamp < lease.updated_at ? lease.updated_at : timestamp;
|
|
2331
|
+
const updated = this.#database.prepare(`
|
|
2332
|
+
UPDATE direct_chat_generation_leases
|
|
2333
|
+
SET phase = 'dispatch_started', dispatch_started_at = ?, updated_at = ?
|
|
2334
|
+
WHERE account_id = ? AND thread_id = ? AND generation_id = ?
|
|
2335
|
+
AND owner_hash = ? AND fence = ? AND phase = 'claimed'
|
|
2336
|
+
AND dispatch_started_at IS NULL AND released_at IS NULL AND expires_at > ?
|
|
2337
|
+
`).run(
|
|
2338
|
+
startedAt,
|
|
2339
|
+
startedAt,
|
|
2340
|
+
accountId,
|
|
2341
|
+
threadId,
|
|
2342
|
+
generationId,
|
|
2343
|
+
proof.ownerHash,
|
|
2344
|
+
proof.fence,
|
|
2345
|
+
timestamp
|
|
2346
|
+
);
|
|
2347
|
+
if (Number(updated.changes) !== 1) {
|
|
2348
|
+
throw new ConflictError('The generation dispatch start marker changed concurrently.');
|
|
2349
|
+
}
|
|
2350
|
+
return Object.freeze({
|
|
2351
|
+
...dispatchLeaseView(
|
|
2352
|
+
this.#generationLease(accountId, threadId, generationId),
|
|
2353
|
+
timestamp
|
|
2354
|
+
),
|
|
2355
|
+
dispatchAuthorized: true,
|
|
2356
|
+
dispatchState: 'started'
|
|
2357
|
+
});
|
|
2358
|
+
});
|
|
2359
|
+
}
|
|
2360
|
+
|
|
2361
|
+
getGenerationLease(input) {
|
|
2362
|
+
this.#assertOpen();
|
|
2363
|
+
assertExactKeys(
|
|
2364
|
+
input,
|
|
2365
|
+
{ required: ['accountId', 'threadId', 'generationId'] },
|
|
2366
|
+
'direct chat generation dispatch lease lookup'
|
|
2367
|
+
);
|
|
2368
|
+
const accountId = assertIdentifier(input.accountId, 'accountId');
|
|
2369
|
+
const threadId = assertIdentifier(input.threadId, 'threadId');
|
|
2370
|
+
const generationId = assertIdentifier(input.generationId, 'generationId');
|
|
2371
|
+
return this.#readTransaction(() => {
|
|
2372
|
+
const thread = this.#database.prepare(`
|
|
2373
|
+
SELECT * FROM direct_chat_threads WHERE account_id = ? AND thread_id = ?
|
|
2374
|
+
`).get(accountId, threadId);
|
|
2375
|
+
if (!thread) return null;
|
|
2376
|
+
this.#auditThread(thread);
|
|
2377
|
+
const lease = this.#generationLease(accountId, threadId, generationId);
|
|
2378
|
+
if (!lease) return null;
|
|
2379
|
+
auditDispatchLease(lease);
|
|
2380
|
+
return dispatchLeaseView(lease, nowIso(this.#clock));
|
|
2381
|
+
});
|
|
2382
|
+
}
|
|
2383
|
+
|
|
2384
|
+
renewGenerationLease(input) {
|
|
2385
|
+
assertExactKeys(
|
|
2386
|
+
input,
|
|
2387
|
+
{ required: ['accountId', 'threadId', 'generationId', 'ownerToken', 'fence', 'ttlMs'] },
|
|
2388
|
+
'direct chat generation dispatch lease renewal'
|
|
2389
|
+
);
|
|
2390
|
+
const accountId = assertIdentifier(input.accountId, 'accountId');
|
|
2391
|
+
const threadId = assertIdentifier(input.threadId, 'threadId');
|
|
2392
|
+
const generationId = assertIdentifier(input.generationId, 'generationId');
|
|
2393
|
+
const proof = assertDispatchLeaseProof({ ownerToken: input.ownerToken, fence: input.fence });
|
|
2394
|
+
const ttlMs = assertDispatchLeaseTtl(input.ttlMs);
|
|
2395
|
+
|
|
2396
|
+
return this.#transaction(() => {
|
|
2397
|
+
const thread = requireThread(this.#database, accountId, threadId);
|
|
2398
|
+
this.#auditThread(thread);
|
|
2399
|
+
const generation = requireGeneration(this.#database, accountId, threadId, generationId);
|
|
2400
|
+
if (generation.status !== 'in_progress' || thread.current_generation_id !== generationId) {
|
|
2401
|
+
throw new ConflictError('Only the active in-progress generation can renew a dispatch lease.');
|
|
2402
|
+
}
|
|
2403
|
+
const timestamp = nowIso(this.#clock);
|
|
2404
|
+
const lease = this.#requireGenerationLease(
|
|
2405
|
+
accountId,
|
|
2406
|
+
threadId,
|
|
2407
|
+
generationId,
|
|
2408
|
+
proof,
|
|
2409
|
+
timestamp,
|
|
2410
|
+
['claimed', 'dispatch_started']
|
|
2411
|
+
);
|
|
2412
|
+
const renewedAt = timestamp < lease.updated_at ? lease.updated_at : timestamp;
|
|
2413
|
+
const expiresAt = addMilliseconds(renewedAt, ttlMs);
|
|
2414
|
+
if (expiresAt <= lease.expires_at) return dispatchLeaseView(lease, timestamp);
|
|
2415
|
+
const updated = this.#database.prepare(`
|
|
2416
|
+
UPDATE direct_chat_generation_leases
|
|
2417
|
+
SET expires_at = ?, updated_at = ?
|
|
2418
|
+
WHERE account_id = ? AND thread_id = ? AND generation_id = ?
|
|
2419
|
+
AND owner_hash = ? AND fence = ? AND expires_at = ? AND released_at IS NULL
|
|
2420
|
+
`).run(
|
|
2421
|
+
expiresAt,
|
|
2422
|
+
renewedAt,
|
|
2423
|
+
accountId,
|
|
2424
|
+
threadId,
|
|
2425
|
+
generationId,
|
|
2426
|
+
proof.ownerHash,
|
|
2427
|
+
proof.fence,
|
|
2428
|
+
lease.expires_at
|
|
2429
|
+
);
|
|
2430
|
+
if (Number(updated.changes) !== 1) {
|
|
2431
|
+
throw new ConflictError('The generation dispatch lease changed concurrently.');
|
|
2432
|
+
}
|
|
2433
|
+
return dispatchLeaseView(
|
|
2434
|
+
this.#generationLease(accountId, threadId, generationId),
|
|
2435
|
+
timestamp
|
|
2436
|
+
);
|
|
2437
|
+
});
|
|
2438
|
+
}
|
|
2439
|
+
|
|
2440
|
+
releaseGenerationLease(input) {
|
|
2441
|
+
assertExactKeys(
|
|
2442
|
+
input,
|
|
2443
|
+
{ required: ['accountId', 'threadId', 'generationId', 'ownerToken', 'fence'] },
|
|
2444
|
+
'direct chat generation dispatch lease release'
|
|
2445
|
+
);
|
|
2446
|
+
const accountId = assertIdentifier(input.accountId, 'accountId');
|
|
2447
|
+
const threadId = assertIdentifier(input.threadId, 'threadId');
|
|
2448
|
+
const generationId = assertIdentifier(input.generationId, 'generationId');
|
|
2449
|
+
const proof = assertDispatchLeaseProof({ ownerToken: input.ownerToken, fence: input.fence });
|
|
2450
|
+
|
|
2451
|
+
return this.#transaction(() => {
|
|
2452
|
+
const thread = requireThread(this.#database, accountId, threadId);
|
|
2453
|
+
this.#auditThread(thread);
|
|
2454
|
+
const generation = requireGeneration(this.#database, accountId, threadId, generationId);
|
|
2455
|
+
const timestamp = nowIso(this.#clock);
|
|
2456
|
+
const existing = this.#generationLease(accountId, threadId, generationId);
|
|
2457
|
+
if (
|
|
2458
|
+
existing &&
|
|
2459
|
+
existing.owner_hash === proof.ownerHash &&
|
|
2460
|
+
Number(existing.fence) === proof.fence &&
|
|
2461
|
+
existing.released_at !== null
|
|
2462
|
+
) {
|
|
2463
|
+
auditDispatchLease(existing);
|
|
2464
|
+
return dispatchLeaseView(existing, timestamp);
|
|
2465
|
+
}
|
|
2466
|
+
if (generation.status !== 'in_progress' || thread.current_generation_id !== generationId) {
|
|
2467
|
+
throw new ConflictError('Only the active in-progress generation can release a dispatch lease.');
|
|
2468
|
+
}
|
|
2469
|
+
const lease = this.#requireGenerationLease(
|
|
2470
|
+
accountId,
|
|
2471
|
+
threadId,
|
|
2472
|
+
generationId,
|
|
2473
|
+
proof,
|
|
2474
|
+
timestamp,
|
|
2475
|
+
['claimed', 'dispatch_started']
|
|
2476
|
+
);
|
|
2477
|
+
const targetPhase = lease.phase === 'dispatch_started' ? 'interrupted' : 'released';
|
|
2478
|
+
const released = this.#invalidateGenerationLease(
|
|
2479
|
+
accountId,
|
|
2480
|
+
threadId,
|
|
2481
|
+
generationId,
|
|
2482
|
+
timestamp,
|
|
2483
|
+
targetPhase
|
|
2484
|
+
);
|
|
2485
|
+
return dispatchLeaseView(released, timestamp);
|
|
2486
|
+
});
|
|
2487
|
+
}
|
|
2488
|
+
|
|
2489
|
+
appendGenerationDelta(input) {
|
|
2490
|
+
assertExactKeys(
|
|
2491
|
+
input,
|
|
2492
|
+
{
|
|
2493
|
+
required: [
|
|
2494
|
+
'accountId', 'threadId', 'generationId', 'expectedSequence',
|
|
2495
|
+
'expectedHash', 'content'
|
|
2496
|
+
],
|
|
2497
|
+
optional: ['dispatchLease']
|
|
2498
|
+
},
|
|
2499
|
+
'direct chat generation delta'
|
|
2500
|
+
);
|
|
2501
|
+
const accountId = assertIdentifier(input.accountId, 'accountId');
|
|
2502
|
+
const threadId = assertIdentifier(input.threadId, 'threadId');
|
|
2503
|
+
const generationId = assertIdentifier(input.generationId, 'generationId');
|
|
2504
|
+
const expectedSequence = assertInteger(input.expectedSequence, 'expectedSequence', { min: 0 });
|
|
2505
|
+
const expectedHash = assertEventHash(input.expectedHash, expectedSequence, 'expectedHash');
|
|
2506
|
+
const content = assertUnicodeScalarString(input.content, 'content', {
|
|
2507
|
+
maxBytes: DIRECT_CHAT_LIMITS.deltaBytes
|
|
2508
|
+
});
|
|
2509
|
+
const dispatchLease = assertDispatchLeaseProof(input.dispatchLease);
|
|
2510
|
+
const sequence = expectedSequence + 1;
|
|
2511
|
+
return this.#transaction(() => {
|
|
2512
|
+
const thread = requireThread(this.#database, accountId, threadId);
|
|
2513
|
+
const generation = requireGeneration(this.#database, accountId, threadId, generationId);
|
|
2514
|
+
this.#auditThread(thread);
|
|
2515
|
+
const timestamp = nowIso(this.#clock);
|
|
2516
|
+
this.#requireGenerationLease(
|
|
2517
|
+
accountId,
|
|
2518
|
+
threadId,
|
|
2519
|
+
generationId,
|
|
2520
|
+
dispatchLease,
|
|
2521
|
+
timestamp
|
|
2522
|
+
);
|
|
2523
|
+
const existing = this.#database.prepare(`
|
|
2524
|
+
SELECT * FROM direct_chat_deltas
|
|
2525
|
+
WHERE account_id = ? AND thread_id = ? AND generation_id = ? AND sequence = ?
|
|
2526
|
+
`).get(accountId, threadId, generationId, sequence);
|
|
2527
|
+
if (existing) {
|
|
2528
|
+
if (existing.content !== content.value || existing.previous_hash !== expectedHash) {
|
|
2529
|
+
throw new ConflictError('The generation sequence is already bound to a different hash or content.');
|
|
2530
|
+
}
|
|
2531
|
+
if (calculateDeltaHash(existing) !== existing.delta_hash) {
|
|
2532
|
+
throw new StorageCorruptionError('The naturally idempotent generation delta failed hash validation.');
|
|
2533
|
+
}
|
|
2534
|
+
return deltaView(existing);
|
|
2535
|
+
}
|
|
2536
|
+
if (generation.status !== 'in_progress' || thread.current_generation_id !== generationId) {
|
|
2537
|
+
throw new ConflictError('The generation is no longer in progress.');
|
|
2538
|
+
}
|
|
2539
|
+
if (
|
|
2540
|
+
Number(generation.delta_count) !== expectedSequence ||
|
|
2541
|
+
generation.last_delta_hash !== expectedHash
|
|
2542
|
+
) {
|
|
2543
|
+
throw new ConflictError('The generation delta cursor is stale.');
|
|
2544
|
+
}
|
|
2545
|
+
if (sequence > DIRECT_CHAT_LIMITS.deltasPerGeneration) {
|
|
2546
|
+
throw new ConflictError('The generation reached its delta count limit.');
|
|
2547
|
+
}
|
|
2548
|
+
if (Number(generation.delta_bytes) + content.bytes > DIRECT_CHAT_LIMITS.generationBytes) {
|
|
2549
|
+
throw new ConflictError('The generation reached its output byte limit.');
|
|
2550
|
+
}
|
|
2551
|
+
if (Number(thread.journal_delta_count) >= DIRECT_CHAT_LIMITS.deltasPerThread) {
|
|
2552
|
+
throw new ConflictError('The thread reached its durable delta count limit; run bounded maintenance.');
|
|
2553
|
+
}
|
|
2554
|
+
if (Number(thread.journal_bytes) + content.bytes > DIRECT_CHAT_LIMITS.journalBytesPerThread) {
|
|
2555
|
+
throw new ConflictError('The thread reached its durable delta byte limit; run bounded maintenance.');
|
|
2556
|
+
}
|
|
2557
|
+
const row = {
|
|
2558
|
+
account_id: accountId,
|
|
2559
|
+
thread_id: threadId,
|
|
2560
|
+
generation_id: generationId,
|
|
2561
|
+
sequence,
|
|
2562
|
+
content: content.value,
|
|
2563
|
+
content_bytes: content.bytes,
|
|
2564
|
+
previous_hash: expectedHash,
|
|
2565
|
+
created_at: timestamp
|
|
2566
|
+
};
|
|
2567
|
+
row.delta_hash = calculateDeltaHash(row);
|
|
2568
|
+
this.#database.prepare(`
|
|
2569
|
+
INSERT INTO direct_chat_deltas(
|
|
2570
|
+
account_id, thread_id, generation_id, sequence, content, content_bytes,
|
|
2571
|
+
previous_hash, delta_hash, created_at
|
|
2572
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
2573
|
+
`).run(
|
|
2574
|
+
accountId, threadId, generationId, sequence, row.content, row.content_bytes,
|
|
2575
|
+
row.previous_hash, row.delta_hash, timestamp
|
|
2576
|
+
);
|
|
2577
|
+
const generationUpdated = this.#database.prepare(`
|
|
2578
|
+
UPDATE direct_chat_generations
|
|
2579
|
+
SET delta_count = delta_count + 1, delta_bytes = delta_bytes + ?,
|
|
2580
|
+
last_delta_hash = ?, updated_at = ?
|
|
2581
|
+
WHERE account_id = ? AND thread_id = ? AND generation_id = ?
|
|
2582
|
+
AND status = 'in_progress' AND delta_count = ? AND last_delta_hash IS ?
|
|
2583
|
+
`).run(
|
|
2584
|
+
content.bytes, row.delta_hash, timestamp,
|
|
2585
|
+
accountId, threadId, generationId, expectedSequence, expectedHash
|
|
2586
|
+
);
|
|
2587
|
+
const threadUpdated = this.#database.prepare(`
|
|
2588
|
+
UPDATE direct_chat_threads
|
|
2589
|
+
SET journal_delta_count = journal_delta_count + 1,
|
|
2590
|
+
journal_bytes = journal_bytes + ?, updated_at = ?
|
|
2591
|
+
WHERE account_id = ? AND thread_id = ? AND current_generation_id = ?
|
|
2592
|
+
`).run(content.bytes, timestamp, accountId, threadId, generationId);
|
|
2593
|
+
if (Number(generationUpdated.changes) !== 1 || Number(threadUpdated.changes) !== 1) {
|
|
2594
|
+
throw new ConflictError('The generation changed concurrently.');
|
|
2595
|
+
}
|
|
2596
|
+
return deltaView(row);
|
|
2597
|
+
});
|
|
2598
|
+
}
|
|
2599
|
+
|
|
2600
|
+
finalizeGeneration(input) {
|
|
2601
|
+
assertExactKeys(
|
|
2602
|
+
input,
|
|
2603
|
+
{
|
|
2604
|
+
required: ['accountId', 'threadId', 'generationId', 'idempotencyKey'],
|
|
2605
|
+
optional: ['dispatchLease']
|
|
2606
|
+
},
|
|
2607
|
+
'direct chat generation finalization'
|
|
2608
|
+
);
|
|
2609
|
+
const accountId = assertIdentifier(input.accountId, 'accountId');
|
|
2610
|
+
const threadId = assertIdentifier(input.threadId, 'threadId');
|
|
2611
|
+
const generationId = assertIdentifier(input.generationId, 'generationId');
|
|
2612
|
+
const dispatchLease = assertDispatchLeaseProof(input.dispatchLease);
|
|
2613
|
+
const request = { accountId, threadId, generationId };
|
|
2614
|
+
if (dispatchLease !== null) request.dispatchLease = dispatchLease;
|
|
2615
|
+
const replayGeneration = () => generationView(
|
|
2616
|
+
requireGeneration(this.#database, accountId, threadId, generationId)
|
|
2617
|
+
);
|
|
2618
|
+
|
|
2619
|
+
return this.#idempotentMutation(
|
|
2620
|
+
{
|
|
2621
|
+
accountId,
|
|
2622
|
+
threadId,
|
|
2623
|
+
operation: 'generation.finalize',
|
|
2624
|
+
idempotencyKey: input.idempotencyKey,
|
|
2625
|
+
request,
|
|
2626
|
+
resourceKind: 'generation',
|
|
2627
|
+
resourceId: generationId,
|
|
2628
|
+
versionOf: statusVersion,
|
|
2629
|
+
digestOf: terminalGenerationDigestInput
|
|
2630
|
+
},
|
|
2631
|
+
() => {
|
|
2632
|
+
const thread = requireThread(this.#database, accountId, threadId);
|
|
2633
|
+
this.#auditThread(thread);
|
|
2634
|
+
const generation = requireGeneration(this.#database, accountId, threadId, generationId);
|
|
2635
|
+
const timestamp = nowIso(this.#clock);
|
|
2636
|
+
this.#requireGenerationLease(
|
|
2637
|
+
accountId,
|
|
2638
|
+
threadId,
|
|
2639
|
+
generationId,
|
|
2640
|
+
dispatchLease,
|
|
2641
|
+
timestamp
|
|
2642
|
+
);
|
|
2643
|
+
if (generation.status === 'completed') return generationView(generation);
|
|
2644
|
+
if (generation.status !== 'in_progress' || thread.current_generation_id !== generationId) {
|
|
2645
|
+
throw new ConflictError('Only an in-progress generation can be finalized.');
|
|
2646
|
+
}
|
|
2647
|
+
if (Number(generation.delta_count) === 0) {
|
|
2648
|
+
throw new ConflictError('An empty generation cannot become an assistant ledger turn.');
|
|
2649
|
+
}
|
|
2650
|
+
if (
|
|
2651
|
+
Number(thread.ledger_revision) !== Number(generation.source_revision) ||
|
|
2652
|
+
thread.ledger_hash !== generation.source_hash
|
|
2653
|
+
) {
|
|
2654
|
+
throw new ConflictError('The chat ledger changed after generation began.');
|
|
2655
|
+
}
|
|
2656
|
+
if (Number(thread.message_count) >= DIRECT_CHAT_LIMITS.messagesPerThread) {
|
|
2657
|
+
throw new ConflictError('The direct-chat thread reached its message limit.');
|
|
2658
|
+
}
|
|
2659
|
+
const deltas = auditGeneration(this.#database, generation);
|
|
2660
|
+
const content = deltas.map((delta) => delta.content).join('');
|
|
2661
|
+
const contentBytes = utf8Bytes(content);
|
|
2662
|
+
if (
|
|
2663
|
+
contentBytes !== Number(generation.delta_bytes) ||
|
|
2664
|
+
contentBytes < 1 ||
|
|
2665
|
+
contentBytes > DIRECT_CHAT_LIMITS.messageBytes ||
|
|
2666
|
+
Number(thread.ledger_bytes) + contentBytes > DIRECT_CHAT_LIMITS.ledgerBytesPerThread
|
|
2667
|
+
) {
|
|
2668
|
+
throw new ConflictError('The completed generation cannot fit in the append-only chat ledger.');
|
|
2669
|
+
}
|
|
2670
|
+
const message = {
|
|
2671
|
+
account_id: accountId,
|
|
2672
|
+
thread_id: threadId,
|
|
2673
|
+
message_id: generation.assistant_message_id,
|
|
2674
|
+
revision: Number(generation.source_revision) + 1,
|
|
2675
|
+
role: 'assistant',
|
|
2676
|
+
content,
|
|
2677
|
+
content_bytes: contentBytes,
|
|
2678
|
+
previous_hash: generation.source_hash,
|
|
2679
|
+
generation_id: generationId,
|
|
2680
|
+
created_at: timestamp
|
|
2681
|
+
};
|
|
2682
|
+
message.message_hash = calculateMessageHash(message);
|
|
2683
|
+
this.#database.prepare(`
|
|
2684
|
+
INSERT INTO direct_chat_messages(
|
|
2685
|
+
account_id, thread_id, message_id, revision, role, content, content_bytes,
|
|
2686
|
+
previous_hash, message_hash, generation_id, created_at
|
|
2687
|
+
) VALUES (?, ?, ?, ?, 'assistant', ?, ?, ?, ?, ?, ?)
|
|
2688
|
+
`).run(
|
|
2689
|
+
accountId, threadId, message.message_id, message.revision, content, contentBytes,
|
|
2690
|
+
message.previous_hash, message.message_hash, generationId, timestamp
|
|
2691
|
+
);
|
|
2692
|
+
const generationUpdated = this.#database.prepare(`
|
|
2693
|
+
UPDATE direct_chat_generations
|
|
2694
|
+
SET status = 'completed', final_revision = ?, final_hash = ?,
|
|
2695
|
+
updated_at = ?, terminal_at = ?
|
|
2696
|
+
WHERE account_id = ? AND thread_id = ? AND generation_id = ?
|
|
2697
|
+
AND status = 'in_progress'
|
|
2698
|
+
`).run(
|
|
2699
|
+
message.revision, message.message_hash, timestamp, timestamp,
|
|
2700
|
+
accountId, threadId, generationId
|
|
2701
|
+
);
|
|
2702
|
+
const threadUpdated = this.#database.prepare(`
|
|
2703
|
+
UPDATE direct_chat_threads
|
|
2704
|
+
SET ledger_revision = ?, ledger_hash = ?, message_count = message_count + 1,
|
|
2705
|
+
ledger_bytes = ledger_bytes + ?, current_generation_id = NULL, updated_at = ?
|
|
2706
|
+
WHERE account_id = ? AND thread_id = ? AND current_generation_id = ?
|
|
2707
|
+
AND ledger_revision = ? AND ledger_hash = ?
|
|
2708
|
+
`).run(
|
|
2709
|
+
message.revision, message.message_hash, contentBytes, timestamp,
|
|
2710
|
+
accountId, threadId, generationId,
|
|
2711
|
+
generation.source_revision, generation.source_hash
|
|
2712
|
+
);
|
|
2713
|
+
if (Number(generationUpdated.changes) !== 1 || Number(threadUpdated.changes) !== 1) {
|
|
2714
|
+
throw new ConflictError('The generation changed concurrently.');
|
|
2715
|
+
}
|
|
2716
|
+
this.#invalidateGenerationLease(accountId, threadId, generationId, timestamp);
|
|
2717
|
+
return generationView(requireGeneration(this.#database, accountId, threadId, generationId));
|
|
2718
|
+
},
|
|
2719
|
+
replayGeneration
|
|
2720
|
+
);
|
|
2721
|
+
}
|
|
2722
|
+
|
|
2723
|
+
cancelGeneration(input) {
|
|
2724
|
+
return this.#terminateGeneration(input, 'cancelled');
|
|
2725
|
+
}
|
|
2726
|
+
|
|
2727
|
+
failGeneration(input) {
|
|
2728
|
+
return this.#terminateGeneration(input, 'failed');
|
|
2729
|
+
}
|
|
2730
|
+
|
|
2731
|
+
#terminateGeneration(input, targetStatus) {
|
|
2732
|
+
const required = targetStatus === 'failed'
|
|
2733
|
+
? ['accountId', 'threadId', 'generationId', 'failureCode', 'idempotencyKey']
|
|
2734
|
+
: ['accountId', 'threadId', 'generationId', 'idempotencyKey'];
|
|
2735
|
+
const optional = targetStatus === 'failed' ? ['dispatchLease'] : [];
|
|
2736
|
+
assertExactKeys(input, { required, optional }, `direct chat generation ${targetStatus}`);
|
|
2737
|
+
const accountId = assertIdentifier(input.accountId, 'accountId');
|
|
2738
|
+
const threadId = assertIdentifier(input.threadId, 'threadId');
|
|
2739
|
+
const generationId = assertIdentifier(input.generationId, 'generationId');
|
|
2740
|
+
const failureCode = targetStatus === 'failed' ? assertFailureCode(input.failureCode) : null;
|
|
2741
|
+
const dispatchLease = targetStatus === 'failed'
|
|
2742
|
+
? assertDispatchLeaseProof(input.dispatchLease)
|
|
2743
|
+
: null;
|
|
2744
|
+
const request = { accountId, threadId, generationId, failureCode };
|
|
2745
|
+
if (dispatchLease !== null) request.dispatchLease = dispatchLease;
|
|
2746
|
+
const replayGeneration = () => generationView(
|
|
2747
|
+
requireGeneration(this.#database, accountId, threadId, generationId)
|
|
2748
|
+
);
|
|
2749
|
+
|
|
2750
|
+
return this.#idempotentMutation(
|
|
2751
|
+
{
|
|
2752
|
+
accountId,
|
|
2753
|
+
threadId,
|
|
2754
|
+
operation: `generation.${targetStatus === 'cancelled' ? 'cancel' : 'fail'}`,
|
|
2755
|
+
idempotencyKey: input.idempotencyKey,
|
|
2756
|
+
request,
|
|
2757
|
+
resourceKind: 'generation',
|
|
2758
|
+
resourceId: generationId,
|
|
2759
|
+
versionOf: statusVersion,
|
|
2760
|
+
digestOf: terminalGenerationDigestInput
|
|
2761
|
+
},
|
|
2762
|
+
() => {
|
|
2763
|
+
const thread = requireThread(this.#database, accountId, threadId);
|
|
2764
|
+
this.#auditThread(thread);
|
|
2765
|
+
const generation = requireGeneration(this.#database, accountId, threadId, generationId);
|
|
2766
|
+
const timestamp = nowIso(this.#clock);
|
|
2767
|
+
if (targetStatus === 'failed') {
|
|
2768
|
+
const lease = this.#generationLease(accountId, threadId, generationId);
|
|
2769
|
+
if (
|
|
2770
|
+
dispatchLease === null && lease !== null &&
|
|
2771
|
+
lease.phase === 'interrupted' && lease.released_at !== null &&
|
|
2772
|
+
failureCode === 'provider_unavailable'
|
|
2773
|
+
) {
|
|
2774
|
+
auditDispatchLease(lease);
|
|
2775
|
+
} else {
|
|
2776
|
+
this.#requireGenerationLease(
|
|
2777
|
+
accountId,
|
|
2778
|
+
threadId,
|
|
2779
|
+
generationId,
|
|
2780
|
+
dispatchLease,
|
|
2781
|
+
timestamp,
|
|
2782
|
+
dispatchLease !== null && PRE_DISPATCH_FAILURE_CODES.has(failureCode)
|
|
2783
|
+
? ['claimed', 'dispatch_started']
|
|
2784
|
+
: ['dispatch_started']
|
|
2785
|
+
);
|
|
2786
|
+
}
|
|
2787
|
+
}
|
|
2788
|
+
if (generation.status === targetStatus) {
|
|
2789
|
+
if (generation.failure_code !== failureCode) {
|
|
2790
|
+
throw new ConflictError('The terminal generation has a different failure category.');
|
|
2791
|
+
}
|
|
2792
|
+
return generationView(generation);
|
|
2793
|
+
}
|
|
2794
|
+
if (generation.status !== 'in_progress' || thread.current_generation_id !== generationId) {
|
|
2795
|
+
throw new ConflictError('Only an in-progress generation can be terminated.');
|
|
2796
|
+
}
|
|
2797
|
+
const generationUpdated = this.#database.prepare(`
|
|
2798
|
+
UPDATE direct_chat_generations
|
|
2799
|
+
SET status = ?, failure_code = ?, updated_at = ?, terminal_at = ?
|
|
2800
|
+
WHERE account_id = ? AND thread_id = ? AND generation_id = ?
|
|
2801
|
+
AND status = 'in_progress'
|
|
2802
|
+
`).run(
|
|
2803
|
+
targetStatus, failureCode, timestamp, timestamp,
|
|
2804
|
+
accountId, threadId, generationId
|
|
2805
|
+
);
|
|
2806
|
+
const threadUpdated = this.#database.prepare(`
|
|
2807
|
+
UPDATE direct_chat_threads SET current_generation_id = NULL, updated_at = ?
|
|
2808
|
+
WHERE account_id = ? AND thread_id = ? AND current_generation_id = ?
|
|
2809
|
+
`).run(timestamp, accountId, threadId, generationId);
|
|
2810
|
+
if (Number(generationUpdated.changes) !== 1 || Number(threadUpdated.changes) !== 1) {
|
|
2811
|
+
throw new ConflictError('The generation changed concurrently.');
|
|
2812
|
+
}
|
|
2813
|
+
this.#invalidateGenerationLease(accountId, threadId, generationId, timestamp);
|
|
2814
|
+
return generationView(requireGeneration(this.#database, accountId, threadId, generationId));
|
|
2815
|
+
},
|
|
2816
|
+
replayGeneration
|
|
2817
|
+
);
|
|
2818
|
+
}
|
|
2819
|
+
|
|
2820
|
+
getGeneration(input) {
|
|
2821
|
+
this.#assertOpen();
|
|
2822
|
+
assertExactKeys(
|
|
2823
|
+
input,
|
|
2824
|
+
{ required: ['accountId', 'threadId', 'generationId'] },
|
|
2825
|
+
'direct chat generation lookup'
|
|
2826
|
+
);
|
|
2827
|
+
const accountId = assertIdentifier(input.accountId, 'accountId');
|
|
2828
|
+
const threadId = assertIdentifier(input.threadId, 'threadId');
|
|
2829
|
+
const generationId = assertIdentifier(input.generationId, 'generationId');
|
|
2830
|
+
return this.#readTransaction(() => {
|
|
2831
|
+
const thread = this.#database.prepare(`
|
|
2832
|
+
SELECT * FROM direct_chat_threads WHERE account_id = ? AND thread_id = ?
|
|
2833
|
+
`).get(accountId, threadId);
|
|
2834
|
+
if (!thread) return null;
|
|
2835
|
+
this.#auditThread(thread);
|
|
2836
|
+
const row = this.#database.prepare(`
|
|
2837
|
+
SELECT * FROM direct_chat_generations
|
|
2838
|
+
WHERE account_id = ? AND thread_id = ? AND generation_id = ?
|
|
2839
|
+
`).get(accountId, threadId, generationId);
|
|
2840
|
+
if (!row) return null;
|
|
2841
|
+
return generationView(row);
|
|
2842
|
+
});
|
|
2843
|
+
}
|
|
2844
|
+
|
|
2845
|
+
replayGeneration(input) {
|
|
2846
|
+
this.#assertOpen();
|
|
2847
|
+
assertExactKeys(
|
|
2848
|
+
input,
|
|
2849
|
+
{
|
|
2850
|
+
required: ['accountId', 'threadId', 'generationId'],
|
|
2851
|
+
optional: ['afterSequence', 'limit']
|
|
2852
|
+
},
|
|
2853
|
+
'direct chat generation replay'
|
|
2854
|
+
);
|
|
2855
|
+
const accountId = assertIdentifier(input.accountId, 'accountId');
|
|
2856
|
+
const threadId = assertIdentifier(input.threadId, 'threadId');
|
|
2857
|
+
const generationId = assertIdentifier(input.generationId, 'generationId');
|
|
2858
|
+
const afterSequence = assertInteger(input.afterSequence ?? 0, 'afterSequence', { min: 0 });
|
|
2859
|
+
const limit = assertInteger(input.limit ?? 200, 'limit', { min: 1, max: DIRECT_CHAT_LIMITS.listPage });
|
|
2860
|
+
return this.#readTransaction(() => {
|
|
2861
|
+
const thread = requireThread(this.#database, accountId, threadId);
|
|
2862
|
+
this.#auditThread(thread);
|
|
2863
|
+
const generation = requireGeneration(this.#database, accountId, threadId, generationId);
|
|
2864
|
+
const rows = this.#database.prepare(`
|
|
2865
|
+
SELECT * FROM direct_chat_deltas
|
|
2866
|
+
WHERE account_id = ? AND thread_id = ? AND generation_id = ? AND sequence > ?
|
|
2867
|
+
ORDER BY sequence
|
|
2868
|
+
LIMIT ?
|
|
2869
|
+
`).all(accountId, threadId, generationId, afterSequence, limit);
|
|
2870
|
+
return {
|
|
2871
|
+
generation: generationView(generation),
|
|
2872
|
+
deltas: rows.map(deltaView),
|
|
2873
|
+
hasMore: rows.length === limit && Number(rows[rows.length - 1].sequence) < Number(generation.delta_count)
|
|
2874
|
+
};
|
|
2875
|
+
});
|
|
2876
|
+
}
|
|
2877
|
+
|
|
2878
|
+
listMessages(input) {
|
|
2879
|
+
this.#assertOpen();
|
|
2880
|
+
assertExactKeys(
|
|
2881
|
+
input,
|
|
2882
|
+
{ required: ['accountId', 'threadId'], optional: ['afterRevision', 'limit'] },
|
|
2883
|
+
'direct chat message query'
|
|
2884
|
+
);
|
|
2885
|
+
const accountId = assertIdentifier(input.accountId, 'accountId');
|
|
2886
|
+
const threadId = assertIdentifier(input.threadId, 'threadId');
|
|
2887
|
+
const afterRevision = assertInteger(input.afterRevision ?? 0, 'afterRevision', { min: 0 });
|
|
2888
|
+
const limit = assertInteger(input.limit ?? 100, 'limit', { min: 1, max: DIRECT_CHAT_LIMITS.listPage });
|
|
2889
|
+
return this.#readTransaction(() => {
|
|
2890
|
+
const thread = requireThread(this.#database, accountId, threadId);
|
|
2891
|
+
this.#auditThread(thread);
|
|
2892
|
+
const rows = this.#database.prepare(`
|
|
2893
|
+
SELECT * FROM direct_chat_messages
|
|
2894
|
+
WHERE account_id = ? AND thread_id = ? AND revision > ?
|
|
2895
|
+
ORDER BY revision
|
|
2896
|
+
LIMIT ?
|
|
2897
|
+
`).all(accountId, threadId, afterRevision, limit);
|
|
2898
|
+
if (this.#schemaVersion < 3 || rows.length === 0) return rows.map((row) => messageView(row));
|
|
2899
|
+
const attachmentRows = this.#database.prepare(`
|
|
2900
|
+
SELECT attachment.account_id, attachment.thread_id, attachment.attachment_id,
|
|
2901
|
+
attachment.message_id, attachment.position, attachment.media_type, attachment.byte_length,
|
|
2902
|
+
attachment.width, attachment.height, attachment.content_sha256,
|
|
2903
|
+
attachment.created_at, length(attachment.content) AS content_length
|
|
2904
|
+
FROM direct_chat_attachments AS attachment
|
|
2905
|
+
JOIN direct_chat_messages AS message
|
|
2906
|
+
ON message.account_id = attachment.account_id
|
|
2907
|
+
AND message.thread_id = attachment.thread_id
|
|
2908
|
+
AND message.message_id = attachment.message_id
|
|
2909
|
+
WHERE attachment.account_id = ? AND attachment.thread_id = ?
|
|
2910
|
+
AND message.revision > ? AND message.revision <= ?
|
|
2911
|
+
ORDER BY message.revision, attachment.position
|
|
2912
|
+
`).all(accountId, threadId, afterRevision, Number(rows.at(-1).revision));
|
|
2913
|
+
const byMessage = new Map();
|
|
2914
|
+
for (const row of attachmentRows) {
|
|
2915
|
+
const attachment = attachmentDescriptorFromRow(row);
|
|
2916
|
+
const owned = byMessage.get(attachment.messageId) ?? [];
|
|
2917
|
+
owned.push(attachment);
|
|
2918
|
+
byMessage.set(attachment.messageId, owned);
|
|
2919
|
+
}
|
|
2920
|
+
return rows.map((row) => messageView(row, byMessage.get(row.message_id) ?? []));
|
|
2921
|
+
});
|
|
2922
|
+
}
|
|
2923
|
+
|
|
2924
|
+
getVisionAttachment(input) {
|
|
2925
|
+
this.#assertOpen();
|
|
2926
|
+
assertExactKeys(
|
|
2927
|
+
input,
|
|
2928
|
+
{ required: ['accountId', 'threadId', 'attachmentId'] },
|
|
2929
|
+
'direct chat vision attachment lookup'
|
|
2930
|
+
);
|
|
2931
|
+
const accountId = assertIdentifier(input.accountId, 'accountId');
|
|
2932
|
+
const threadId = assertIdentifier(input.threadId, 'threadId');
|
|
2933
|
+
const attachmentId = assertIdentifier(input.attachmentId, 'attachmentId');
|
|
2934
|
+
if (this.#schemaVersion < 3) return null;
|
|
2935
|
+
return this.#readTransaction(() => {
|
|
2936
|
+
const thread = this.#database.prepare(`
|
|
2937
|
+
SELECT * FROM direct_chat_threads WHERE account_id = ? AND thread_id = ?
|
|
2938
|
+
`).get(accountId, threadId);
|
|
2939
|
+
if (!thread) return null;
|
|
2940
|
+
this.#auditThread(thread);
|
|
2941
|
+
return attachmentFromRow(this.#database.prepare(`
|
|
2942
|
+
SELECT * FROM direct_chat_attachments
|
|
2943
|
+
WHERE account_id = ? AND thread_id = ? AND attachment_id = ?
|
|
2944
|
+
`).get(accountId, threadId, attachmentId));
|
|
2945
|
+
});
|
|
2946
|
+
}
|
|
2947
|
+
|
|
2948
|
+
getLatestVisionAttachments(input) {
|
|
2949
|
+
this.#assertOpen();
|
|
2950
|
+
assertExactKeys(
|
|
2951
|
+
input,
|
|
2952
|
+
{ required: ['accountId', 'threadId', 'sourceRevision'] },
|
|
2953
|
+
'latest direct chat vision attachments lookup'
|
|
2954
|
+
);
|
|
2955
|
+
const accountId = assertIdentifier(input.accountId, 'accountId');
|
|
2956
|
+
const threadId = assertIdentifier(input.threadId, 'threadId');
|
|
2957
|
+
const sourceRevision = assertInteger(input.sourceRevision, 'sourceRevision', { min: 1, max: 2_000 });
|
|
2958
|
+
if (this.#schemaVersion < 3) return Object.freeze([]);
|
|
2959
|
+
return this.#readTransaction(() => {
|
|
2960
|
+
const thread = this.#database.prepare(`
|
|
2961
|
+
SELECT * FROM direct_chat_threads WHERE account_id = ? AND thread_id = ?
|
|
2962
|
+
`).get(accountId, threadId);
|
|
2963
|
+
if (!thread) return Object.freeze([]);
|
|
2964
|
+
this.#auditThread(thread);
|
|
2965
|
+
const owner = this.#database.prepare(`
|
|
2966
|
+
SELECT attachment.message_id
|
|
2967
|
+
FROM direct_chat_attachments AS attachment
|
|
2968
|
+
JOIN direct_chat_messages AS message
|
|
2969
|
+
ON message.account_id = attachment.account_id
|
|
2970
|
+
AND message.thread_id = attachment.thread_id
|
|
2971
|
+
AND message.message_id = attachment.message_id
|
|
2972
|
+
WHERE attachment.account_id = ? AND attachment.thread_id = ?
|
|
2973
|
+
AND message.revision <= ?
|
|
2974
|
+
ORDER BY message.revision DESC
|
|
2975
|
+
LIMIT 1
|
|
2976
|
+
`).get(accountId, threadId, sourceRevision);
|
|
2977
|
+
if (!owner) return Object.freeze([]);
|
|
2978
|
+
return Object.freeze(this.#database.prepare(`
|
|
2979
|
+
SELECT * FROM direct_chat_attachments
|
|
2980
|
+
WHERE account_id = ? AND thread_id = ? AND message_id = ?
|
|
2981
|
+
ORDER BY position
|
|
2982
|
+
`).all(accountId, threadId, owner.message_id).map(attachmentFromRow));
|
|
2983
|
+
});
|
|
2984
|
+
}
|
|
2985
|
+
|
|
2986
|
+
getLatestVisionAttachment(input) {
|
|
2987
|
+
const attachments = this.getLatestVisionAttachments(input);
|
|
2988
|
+
return attachments[0] ?? null;
|
|
2989
|
+
}
|
|
2990
|
+
|
|
2991
|
+
createCompactionSnapshot(input) {
|
|
2992
|
+
assertExactKeys(
|
|
2993
|
+
input,
|
|
2994
|
+
{
|
|
2995
|
+
required: [
|
|
2996
|
+
'accountId', 'threadId', 'snapshotId', 'sourceStartRevision',
|
|
2997
|
+
'sourceStartHash', 'sourceEndRevision', 'sourceEndHash',
|
|
2998
|
+
'summaryText', 'idempotencyKey'
|
|
2999
|
+
]
|
|
3000
|
+
},
|
|
3001
|
+
'direct chat compaction snapshot'
|
|
3002
|
+
);
|
|
3003
|
+
const accountId = assertIdentifier(input.accountId, 'accountId');
|
|
3004
|
+
const threadId = assertIdentifier(input.threadId, 'threadId');
|
|
3005
|
+
const snapshotId = assertIdentifier(input.snapshotId, 'snapshotId');
|
|
3006
|
+
const sourceStartRevision = assertInteger(input.sourceStartRevision, 'sourceStartRevision', { min: 1 });
|
|
3007
|
+
const sourceEndRevision = assertInteger(input.sourceEndRevision, 'sourceEndRevision', {
|
|
3008
|
+
min: sourceStartRevision
|
|
3009
|
+
});
|
|
3010
|
+
const sourceStartHash = assertEventHash(input.sourceStartHash, sourceStartRevision, 'sourceStartHash');
|
|
3011
|
+
const sourceEndHash = assertEventHash(input.sourceEndHash, sourceEndRevision, 'sourceEndHash');
|
|
3012
|
+
const summary = assertUnicodeScalarString(input.summaryText, 'summaryText', {
|
|
3013
|
+
maxBytes: DIRECT_CHAT_LIMITS.summaryBytes
|
|
3014
|
+
});
|
|
3015
|
+
const summaryHash = sha256(summary.value);
|
|
3016
|
+
const request = {
|
|
3017
|
+
accountId, threadId, snapshotId, sourceStartRevision, sourceStartHash,
|
|
3018
|
+
sourceEndRevision, sourceEndHash, summaryText: summary.value
|
|
3019
|
+
};
|
|
3020
|
+
const replaySnapshot = () => {
|
|
3021
|
+
const row = this.#database.prepare(`
|
|
3022
|
+
SELECT * FROM direct_chat_compactions
|
|
3023
|
+
WHERE account_id = ? AND thread_id = ? AND snapshot_id = ?
|
|
3024
|
+
`).get(accountId, threadId, snapshotId);
|
|
3025
|
+
if (!row) throw new ConflictError('The idempotent compaction snapshot is no longer present.');
|
|
3026
|
+
return compactionView(row);
|
|
3027
|
+
};
|
|
3028
|
+
|
|
3029
|
+
return this.#idempotentMutation(
|
|
3030
|
+
{
|
|
3031
|
+
accountId,
|
|
3032
|
+
threadId,
|
|
3033
|
+
operation: 'compaction.create',
|
|
3034
|
+
idempotencyKey: input.idempotencyKey,
|
|
3035
|
+
request,
|
|
3036
|
+
resourceKind: 'compaction',
|
|
3037
|
+
resourceId: snapshotId,
|
|
3038
|
+
versionOf: (result) => result.sourceEndRevision
|
|
3039
|
+
},
|
|
3040
|
+
() => {
|
|
3041
|
+
const thread = requireThread(this.#database, accountId, threadId);
|
|
3042
|
+
this.#auditThread(thread);
|
|
3043
|
+
const existing = this.#database.prepare(`
|
|
3044
|
+
SELECT * FROM direct_chat_compactions
|
|
3045
|
+
WHERE account_id = ? AND thread_id = ? AND snapshot_id = ?
|
|
3046
|
+
`).get(accountId, threadId, snapshotId);
|
|
3047
|
+
if (existing) {
|
|
3048
|
+
if (
|
|
3049
|
+
Number(existing.source_start_revision) !== sourceStartRevision ||
|
|
3050
|
+
existing.source_start_hash !== sourceStartHash ||
|
|
3051
|
+
Number(existing.source_end_revision) !== sourceEndRevision ||
|
|
3052
|
+
existing.source_end_hash !== sourceEndHash || existing.summary_text !== summary.value ||
|
|
3053
|
+
existing.summary_hash !== summaryHash
|
|
3054
|
+
) {
|
|
3055
|
+
throw new ConflictError('The compaction snapshot identifier is already bound to different data.');
|
|
3056
|
+
}
|
|
3057
|
+
return compactionView(existing);
|
|
3058
|
+
}
|
|
3059
|
+
if (thread.current_generation_id !== null) {
|
|
3060
|
+
throw new ConflictError('A compaction snapshot cannot be recorded during an active generation.');
|
|
3061
|
+
}
|
|
3062
|
+
if (sourceEndRevision > Number(thread.ledger_revision)) {
|
|
3063
|
+
throw new ConflictError('The compaction range extends beyond the raw chat ledger.');
|
|
3064
|
+
}
|
|
3065
|
+
const count = Number(this.#database.prepare(`
|
|
3066
|
+
SELECT count(*) AS count FROM direct_chat_compactions
|
|
3067
|
+
WHERE account_id = ? AND thread_id = ?
|
|
3068
|
+
`).get(accountId, threadId).count);
|
|
3069
|
+
if (count >= DIRECT_CHAT_LIMITS.compactionsPerThread) {
|
|
3070
|
+
throw new ConflictError('The thread reached its compaction snapshot limit.');
|
|
3071
|
+
}
|
|
3072
|
+
const endpoints = this.#database.prepare(`
|
|
3073
|
+
SELECT revision, message_hash FROM direct_chat_messages
|
|
3074
|
+
WHERE account_id = ? AND thread_id = ? AND revision IN (?, ?)
|
|
3075
|
+
ORDER BY revision
|
|
3076
|
+
`).all(accountId, threadId, sourceStartRevision, sourceEndRevision);
|
|
3077
|
+
const first = endpoints.find((row) => Number(row.revision) === sourceStartRevision);
|
|
3078
|
+
const last = endpoints.find((row) => Number(row.revision) === sourceEndRevision);
|
|
3079
|
+
if (!first || first.message_hash !== sourceStartHash || !last || last.message_hash !== sourceEndHash) {
|
|
3080
|
+
throw new ConflictError('The compaction snapshot hashes do not match the exact raw ledger range.');
|
|
3081
|
+
}
|
|
3082
|
+
const timestamp = nowIso(this.#clock);
|
|
3083
|
+
this.#database.prepare(`
|
|
3084
|
+
INSERT INTO direct_chat_compactions(
|
|
3085
|
+
account_id, thread_id, snapshot_id, source_start_revision, source_start_hash,
|
|
3086
|
+
source_end_revision, source_end_hash, summary_text, summary_bytes, summary_hash, created_at
|
|
3087
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
3088
|
+
`).run(
|
|
3089
|
+
accountId, threadId, snapshotId, sourceStartRevision, sourceStartHash,
|
|
3090
|
+
sourceEndRevision, sourceEndHash, summary.value, summary.bytes, summaryHash, timestamp
|
|
3091
|
+
);
|
|
3092
|
+
return replaySnapshot();
|
|
3093
|
+
},
|
|
3094
|
+
replaySnapshot
|
|
3095
|
+
);
|
|
3096
|
+
}
|
|
3097
|
+
|
|
3098
|
+
getLatestCompactionSnapshot(accountId, threadId) {
|
|
3099
|
+
this.#assertOpen();
|
|
3100
|
+
assertIdentifier(accountId, 'accountId');
|
|
3101
|
+
assertIdentifier(threadId, 'threadId');
|
|
3102
|
+
return this.#readTransaction(() => {
|
|
3103
|
+
const thread = this.#database.prepare(`
|
|
3104
|
+
SELECT * FROM direct_chat_threads WHERE account_id = ? AND thread_id = ?
|
|
3105
|
+
`).get(accountId, threadId);
|
|
3106
|
+
if (!thread) return null;
|
|
3107
|
+
this.#auditThread(thread);
|
|
3108
|
+
return compactionView(this.#database.prepare(`
|
|
3109
|
+
SELECT * FROM direct_chat_compactions
|
|
3110
|
+
WHERE account_id = ? AND thread_id = ?
|
|
3111
|
+
ORDER BY source_end_revision DESC, created_at DESC, snapshot_id DESC
|
|
3112
|
+
LIMIT 1
|
|
3113
|
+
`).get(accountId, threadId));
|
|
3114
|
+
});
|
|
3115
|
+
}
|
|
3116
|
+
|
|
3117
|
+
runMaintenance(input) {
|
|
3118
|
+
assertExactKeys(
|
|
3119
|
+
input,
|
|
3120
|
+
{ required: ['accountId'], optional: ['terminalBefore', 'snapshotBefore', 'limit'] },
|
|
3121
|
+
'direct chat maintenance'
|
|
3122
|
+
);
|
|
3123
|
+
const accountId = assertIdentifier(input.accountId, 'accountId');
|
|
3124
|
+
const timestamp = nowIso(this.#clock);
|
|
3125
|
+
const retentionCutoff = addMilliseconds(timestamp, -DIRECT_CHAT_TERMINAL_DELTA_RETENTION_MS);
|
|
3126
|
+
const requestedTerminalBefore = input.terminalBefore === undefined
|
|
3127
|
+
? retentionCutoff
|
|
3128
|
+
: assertCanonicalIsoTimestamp(input.terminalBefore, 'terminalBefore');
|
|
3129
|
+
const terminalBefore = requestedTerminalBefore < retentionCutoff
|
|
3130
|
+
? requestedTerminalBefore
|
|
3131
|
+
: retentionCutoff;
|
|
3132
|
+
const requestedSnapshotBefore = input.snapshotBefore === undefined
|
|
3133
|
+
? timestamp
|
|
3134
|
+
: assertCanonicalIsoTimestamp(input.snapshotBefore, 'snapshotBefore');
|
|
3135
|
+
const snapshotBefore = requestedSnapshotBefore < timestamp ? requestedSnapshotBefore : timestamp;
|
|
3136
|
+
const limit = assertInteger(input.limit ?? DIRECT_CHAT_LIMITS.cleanupRows, 'limit', {
|
|
3137
|
+
min: 1,
|
|
3138
|
+
max: DIRECT_CHAT_LIMITS.cleanupRows
|
|
3139
|
+
});
|
|
3140
|
+
|
|
3141
|
+
return this.#transaction(() => {
|
|
3142
|
+
let remaining = limit;
|
|
3143
|
+
let terminalGenerationsPruned = 0;
|
|
3144
|
+
let deltaRowsRemoved = 0;
|
|
3145
|
+
let deltaBytesReleased = 0;
|
|
3146
|
+
let compactionSnapshotsRemoved = 0;
|
|
3147
|
+
const touchedThreads = new Set();
|
|
3148
|
+
|
|
3149
|
+
const candidates = this.#database.prepare(`
|
|
3150
|
+
SELECT g.*
|
|
3151
|
+
FROM direct_chat_generations AS g
|
|
3152
|
+
WHERE g.account_id = ?
|
|
3153
|
+
AND g.status = 'completed'
|
|
3154
|
+
AND g.deltas_pruned = 0
|
|
3155
|
+
AND g.terminal_at <= ?
|
|
3156
|
+
AND EXISTS (
|
|
3157
|
+
SELECT 1 FROM direct_chat_generations AS newer
|
|
3158
|
+
WHERE newer.account_id = g.account_id
|
|
3159
|
+
AND newer.thread_id = g.thread_id
|
|
3160
|
+
AND newer.status = 'completed'
|
|
3161
|
+
AND (
|
|
3162
|
+
newer.terminal_at > g.terminal_at
|
|
3163
|
+
OR (newer.terminal_at = g.terminal_at AND newer.generation_id > g.generation_id)
|
|
3164
|
+
)
|
|
3165
|
+
)
|
|
3166
|
+
ORDER BY g.terminal_at, g.thread_id, g.generation_id
|
|
3167
|
+
LIMIT ?
|
|
3168
|
+
`).all(accountId, terminalBefore, remaining);
|
|
3169
|
+
for (const generation of candidates) {
|
|
3170
|
+
const thread = requireThread(this.#database, accountId, generation.thread_id);
|
|
3171
|
+
this.#auditThread(thread);
|
|
3172
|
+
const marked = this.#database.prepare(`
|
|
3173
|
+
UPDATE direct_chat_generations
|
|
3174
|
+
SET deltas_pruned = 1, pruned_at = ?
|
|
3175
|
+
WHERE account_id = ? AND thread_id = ? AND generation_id = ?
|
|
3176
|
+
AND status = 'completed' AND deltas_pruned = 0
|
|
3177
|
+
`).run(timestamp, accountId, generation.thread_id, generation.generation_id);
|
|
3178
|
+
if (Number(marked.changes) !== 1) {
|
|
3179
|
+
throw new ConflictError('A terminal generation changed during maintenance.');
|
|
3180
|
+
}
|
|
3181
|
+
const deleted = this.#database.prepare(`
|
|
3182
|
+
DELETE FROM direct_chat_deltas
|
|
3183
|
+
WHERE account_id = ? AND thread_id = ? AND generation_id = ?
|
|
3184
|
+
`).run(accountId, generation.thread_id, generation.generation_id);
|
|
3185
|
+
if (Number(deleted.changes) !== Number(generation.delta_count)) {
|
|
3186
|
+
throw new StorageCorruptionError('Terminal delta pruning removed an unexpected row count.');
|
|
3187
|
+
}
|
|
3188
|
+
const counters = this.#database.prepare(`
|
|
3189
|
+
UPDATE direct_chat_threads
|
|
3190
|
+
SET journal_delta_count = journal_delta_count - ?, journal_bytes = journal_bytes - ?
|
|
3191
|
+
WHERE account_id = ? AND thread_id = ?
|
|
3192
|
+
AND current_generation_id IS NOT ?
|
|
3193
|
+
AND journal_delta_count >= ? AND journal_bytes >= ?
|
|
3194
|
+
`).run(
|
|
3195
|
+
generation.delta_count,
|
|
3196
|
+
generation.delta_bytes,
|
|
3197
|
+
accountId,
|
|
3198
|
+
generation.thread_id,
|
|
3199
|
+
generation.generation_id,
|
|
3200
|
+
generation.delta_count,
|
|
3201
|
+
generation.delta_bytes
|
|
3202
|
+
);
|
|
3203
|
+
if (Number(counters.changes) !== 1) {
|
|
3204
|
+
throw new StorageCorruptionError('Terminal delta pruning could not safely release thread counters.');
|
|
3205
|
+
}
|
|
3206
|
+
terminalGenerationsPruned += 1;
|
|
3207
|
+
deltaRowsRemoved += Number(deleted.changes);
|
|
3208
|
+
deltaBytesReleased += Number(generation.delta_bytes);
|
|
3209
|
+
remaining -= 1;
|
|
3210
|
+
touchedThreads.add(generation.thread_id);
|
|
3211
|
+
}
|
|
3212
|
+
|
|
3213
|
+
if (remaining > 0) {
|
|
3214
|
+
const snapshots = this.#database.prepare(`
|
|
3215
|
+
SELECT old.account_id, old.thread_id, old.snapshot_id
|
|
3216
|
+
FROM direct_chat_compactions AS old
|
|
3217
|
+
WHERE old.account_id = ? AND old.created_at <= ?
|
|
3218
|
+
AND EXISTS (
|
|
3219
|
+
SELECT 1 FROM direct_chat_compactions AS newer
|
|
3220
|
+
WHERE newer.account_id = old.account_id
|
|
3221
|
+
AND newer.thread_id = old.thread_id
|
|
3222
|
+
AND (
|
|
3223
|
+
newer.source_end_revision > old.source_end_revision
|
|
3224
|
+
OR (
|
|
3225
|
+
newer.source_end_revision = old.source_end_revision
|
|
3226
|
+
AND newer.created_at > old.created_at
|
|
3227
|
+
)
|
|
3228
|
+
OR (
|
|
3229
|
+
newer.source_end_revision = old.source_end_revision
|
|
3230
|
+
AND newer.created_at = old.created_at
|
|
3231
|
+
AND newer.snapshot_id > old.snapshot_id
|
|
3232
|
+
)
|
|
3233
|
+
)
|
|
3234
|
+
)
|
|
3235
|
+
ORDER BY old.created_at, old.thread_id, old.snapshot_id
|
|
3236
|
+
LIMIT ?
|
|
3237
|
+
`).all(accountId, snapshotBefore, remaining);
|
|
3238
|
+
for (const snapshot of snapshots) {
|
|
3239
|
+
const deleted = this.#database.prepare(`
|
|
3240
|
+
DELETE FROM direct_chat_compactions
|
|
3241
|
+
WHERE account_id = ? AND thread_id = ? AND snapshot_id = ?
|
|
3242
|
+
`).run(accountId, snapshot.thread_id, snapshot.snapshot_id);
|
|
3243
|
+
if (Number(deleted.changes) !== 1) {
|
|
3244
|
+
throw new ConflictError('A compaction snapshot changed during maintenance.');
|
|
3245
|
+
}
|
|
3246
|
+
compactionSnapshotsRemoved += 1;
|
|
3247
|
+
remaining -= 1;
|
|
3248
|
+
touchedThreads.add(snapshot.thread_id);
|
|
3249
|
+
}
|
|
3250
|
+
}
|
|
3251
|
+
|
|
3252
|
+
const idempotencyReceiptsRemoved = remaining > 0
|
|
3253
|
+
? this.#deleteExpiredReceipts(timestamp, remaining, accountId)
|
|
3254
|
+
: 0;
|
|
3255
|
+
for (const threadId of touchedThreads) {
|
|
3256
|
+
this.#auditThread(requireThread(this.#database, accountId, threadId));
|
|
3257
|
+
}
|
|
3258
|
+
return {
|
|
3259
|
+
terminalGenerationsPruned,
|
|
3260
|
+
deltaRowsRemoved,
|
|
3261
|
+
deltaBytesReleased,
|
|
3262
|
+
compactionSnapshotsRemoved,
|
|
3263
|
+
idempotencyReceiptsRemoved,
|
|
3264
|
+
chatMessagesRemoved: 0,
|
|
3265
|
+
activeGenerationsRemoved: 0
|
|
3266
|
+
};
|
|
3267
|
+
});
|
|
3268
|
+
}
|
|
3269
|
+
|
|
3270
|
+
cleanupExpiredIdempotency(input = {}) {
|
|
3271
|
+
assertExactKeys(input, { optional: ['before', 'limit'] }, 'direct chat cleanup');
|
|
3272
|
+
const now = nowIso(this.#clock);
|
|
3273
|
+
const requestedBefore = input.before === undefined
|
|
3274
|
+
? now
|
|
3275
|
+
: assertCanonicalIsoTimestamp(input.before, 'before');
|
|
3276
|
+
const before = requestedBefore < now ? requestedBefore : now;
|
|
3277
|
+
const limit = assertInteger(input.limit ?? DIRECT_CHAT_LIMITS.cleanupRows, 'limit', {
|
|
3278
|
+
min: 1,
|
|
3279
|
+
max: DIRECT_CHAT_LIMITS.cleanupRows
|
|
3280
|
+
});
|
|
3281
|
+
return this.#transaction(() => ({
|
|
3282
|
+
idempotencyReceiptsRemoved: this.#deleteExpiredReceipts(before, limit),
|
|
3283
|
+
chatRowsRemoved: 0
|
|
3284
|
+
}));
|
|
3285
|
+
}
|
|
3286
|
+
|
|
3287
|
+
healthCheck() {
|
|
3288
|
+
this.#assertOpen();
|
|
3289
|
+
return checkOpenSqliteHealth(this.#database, {
|
|
3290
|
+
expectedApplicationId: CHAT_SQLITE_APPLICATION_ID,
|
|
3291
|
+
allowedSchemaVersions: [this.#schemaVersion]
|
|
3292
|
+
});
|
|
3293
|
+
}
|
|
3294
|
+
|
|
3295
|
+
close() {
|
|
3296
|
+
if (this.#closed) return;
|
|
3297
|
+
try {
|
|
3298
|
+
assertSecureDatabaseFile(this.#databasePath);
|
|
3299
|
+
} finally {
|
|
3300
|
+
try {
|
|
3301
|
+
this.#database.close();
|
|
3302
|
+
} finally {
|
|
3303
|
+
this.#clearAuditedThreads();
|
|
3304
|
+
this.#closed = true;
|
|
3305
|
+
}
|
|
3306
|
+
}
|
|
3307
|
+
}
|
|
3308
|
+
}
|