@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.
Files changed (42) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +438 -0
  3. package/docs/architecture.md +503 -0
  4. package/package.json +43 -0
  5. package/src/aginti-adapter.js +602 -0
  6. package/src/chat-context.js +1020 -0
  7. package/src/chat-migrations.js +947 -0
  8. package/src/chat-store.js +3308 -0
  9. package/src/cli.js +134 -0
  10. package/src/cloud-server.js +2043 -0
  11. package/src/contracts.js +103 -0
  12. package/src/deterministic-context-summarizer.js +254 -0
  13. package/src/direct-chat-capability-limits.js +66 -0
  14. package/src/direct-chat-contract.js +3 -0
  15. package/src/errors.js +50 -0
  16. package/src/http-contract.js +592 -0
  17. package/src/index.js +88 -0
  18. package/src/localllm-connector.js +667 -0
  19. package/src/migrations.js +231 -0
  20. package/src/operator-health.js +184 -0
  21. package/src/password-verifier.js +131 -0
  22. package/src/service-config.js +547 -0
  23. package/src/service.js +408 -0
  24. package/src/sqlite-health.js +83 -0
  25. package/src/storage-path.js +130 -0
  26. package/src/store.js +914 -0
  27. package/src/validation.js +181 -0
  28. package/src/vision-attachment.js +404 -0
  29. package/src/web/aginti-client.js +552 -0
  30. package/src/web/aginti-protocol.js +1146 -0
  31. package/src/web/asset-map.js +462 -0
  32. package/src/web/browser-app.js +6491 -0
  33. package/src/web/cloud-session-client.js +427 -0
  34. package/src/web/direct-chat-client.js +1482 -0
  35. package/src/web/index.js +10 -0
  36. package/src/web/presentation-state.js +107 -0
  37. package/src/web/pwa-assets.js +854 -0
  38. package/src/web/pwa-update-handoff-store.js +179 -0
  39. package/src/web/safe-rendering.js +836 -0
  40. package/src/web/vision-image-client.js +546 -0
  41. package/src/web/vision-image-sanitizer.js +168 -0
  42. package/src/web/web-release.js +28 -0
package/src/store.js ADDED
@@ -0,0 +1,914 @@
1
+ import { timingSafeEqual } from 'node:crypto';
2
+ import { DatabaseSync } from 'node:sqlite';
3
+
4
+ import {
5
+ ConflictError,
6
+ IdempotencyConflictError,
7
+ NotFoundError,
8
+ StorageCorruptionError,
9
+ ValidationError
10
+ } from './errors.js';
11
+ import {
12
+ LATEST_SCHEMA_VERSION,
13
+ SQLITE_APPLICATION_ID,
14
+ applyMigrations
15
+ } from './migrations.js';
16
+ import { checkOpenSqliteHealth } from './sqlite-health.js';
17
+ import { assertSecureDatabaseFile, prepareSecureDatabasePath } from './storage-path.js';
18
+ import {
19
+ assertBoolean,
20
+ assertBoundedString,
21
+ assertCanonicalIsoTimestamp,
22
+ assertEventHash,
23
+ assertExactKeys,
24
+ assertIdentifier,
25
+ assertIdempotencyKey,
26
+ assertInteger,
27
+ canonicalJson,
28
+ digestSecret,
29
+ nowIso,
30
+ sha256
31
+ } from './validation.js';
32
+
33
+ const DEFAULT_CLOCK = () => new Date();
34
+
35
+ export const IDEMPOTENCY_RECEIPT_TTL_MS = 24 * 60 * 60 * 1000;
36
+ export const MAX_IDEMPOTENCY_RECEIPTS_PER_ACCOUNT = 256;
37
+ export const MAX_BROWSER_SESSIONS_PER_ACCOUNT = 32;
38
+
39
+ function addMilliseconds(timestamp, milliseconds) {
40
+ return new Date(new Date(timestamp).getTime() + milliseconds).toISOString();
41
+ }
42
+
43
+ function pruneExpiredRows(database, timestamp) {
44
+ const sessions = database.prepare(`
45
+ DELETE FROM browser_sessions
46
+ WHERE expires_at <= ? OR revoked_at IS NOT NULL
47
+ `).run(timestamp);
48
+ const receipts = database.prepare(`
49
+ DELETE FROM idempotency_records WHERE expires_at <= ?
50
+ `).run(timestamp);
51
+ return {
52
+ browserSessionsRemoved: Number(sessions.changes),
53
+ idempotencyReceiptsRemoved: Number(receipts.changes)
54
+ };
55
+ }
56
+
57
+ function accountView(row) {
58
+ if (!row) return null;
59
+ return {
60
+ id: row.id,
61
+ issuer: row.issuer,
62
+ subject: row.subject,
63
+ displayName: row.display_name,
64
+ createdAt: row.created_at,
65
+ updatedAt: row.updated_at
66
+ };
67
+ }
68
+
69
+ function sessionView(row) {
70
+ if (!row) return null;
71
+ return {
72
+ accountId: row.account_id,
73
+ createdAt: row.created_at,
74
+ expiresAt: row.expires_at,
75
+ lastSeenAt: row.last_seen_at,
76
+ revokedAt: row.revoked_at
77
+ };
78
+ }
79
+
80
+ function threadView(row) {
81
+ if (!row) return null;
82
+ return {
83
+ accountId: row.account_id,
84
+ threadId: row.thread_id,
85
+ authority: row.authority,
86
+ title: row.title,
87
+ pinned: row.pinned === 1,
88
+ routingNodeId: row.routing_node_id,
89
+ authorityRevision: row.authority_revision,
90
+ lastRunId: row.last_run_id,
91
+ createdAt: row.created_at,
92
+ updatedAt: row.updated_at,
93
+ lastSeenAt: row.last_seen_at
94
+ };
95
+ }
96
+
97
+ function cursorView(row) {
98
+ if (!row) return null;
99
+ return {
100
+ accountId: row.account_id,
101
+ threadId: row.thread_id,
102
+ runId: row.run_id,
103
+ lastSeq: Number(row.last_seq),
104
+ lastEventHash: row.last_event_hash,
105
+ createdAt: row.created_at,
106
+ updatedAt: row.updated_at
107
+ };
108
+ }
109
+
110
+ function isConstraintError(error) {
111
+ return typeof error?.code === 'string' && error.code.startsWith('ERR_SQLITE_CONSTRAINT');
112
+ }
113
+
114
+ function secretDigestsEqual(first, second) {
115
+ if (
116
+ typeof first !== 'string' ||
117
+ typeof second !== 'string' ||
118
+ !/^[a-f0-9]{64}$/u.test(first) ||
119
+ !/^[a-f0-9]{64}$/u.test(second)
120
+ ) {
121
+ return false;
122
+ }
123
+ return timingSafeEqual(Buffer.from(first, 'hex'), Buffer.from(second, 'hex'));
124
+ }
125
+
126
+ function validateDisplayName(value) {
127
+ if (value === undefined || value === null) return null;
128
+ return assertBoundedString(value, 'displayName', { min: 1, max: 256 });
129
+ }
130
+
131
+ function validateThreadPatch(patch) {
132
+ assertExactKeys(
133
+ patch,
134
+ { optional: ['title', 'pinned'] },
135
+ 'patch'
136
+ );
137
+ if (Object.keys(patch).length === 0) {
138
+ throw new ValidationError('patch must change at least one presentation field.');
139
+ }
140
+ const result = {};
141
+ if (Object.hasOwn(patch, 'title')) {
142
+ result.title = assertBoundedString(patch.title, 'patch.title', { min: 0, max: 120 });
143
+ }
144
+ if (Object.hasOwn(patch, 'pinned')) {
145
+ result.pinned = assertBoolean(patch.pinned, 'patch.pinned');
146
+ }
147
+ return result;
148
+ }
149
+
150
+ function validateAuthorityMetadata(input) {
151
+ const routingNodeId = input.routingNodeId === null
152
+ ? null
153
+ : assertIdentifier(input.routingNodeId, 'routingNodeId');
154
+ const authorityRevision = input.authorityRevision === null
155
+ ? null
156
+ : assertInteger(input.authorityRevision, 'authorityRevision', { min: 0 });
157
+ return { routingNodeId, authorityRevision };
158
+ }
159
+
160
+ export class CloudIndexStore {
161
+ #clock;
162
+ #closed = false;
163
+ #database;
164
+ #databasePath;
165
+
166
+ constructor({ databasePath, clock = DEFAULT_CLOCK } = {}) {
167
+ if (typeof clock !== 'function') throw new ValidationError('clock must be a function.');
168
+ this.#clock = clock;
169
+ this.#databasePath = prepareSecureDatabasePath(databasePath);
170
+
171
+ let database;
172
+ try {
173
+ database = new DatabaseSync(this.#databasePath);
174
+ } catch (error) {
175
+ throw new StorageCorruptionError('SQLite could not open the control-plane database.', { cause: error });
176
+ }
177
+
178
+ try {
179
+ database.enableLoadExtension(false);
180
+ database.exec(`
181
+ PRAGMA busy_timeout = 5000;
182
+ PRAGMA foreign_keys = ON;
183
+ PRAGMA journal_mode = DELETE;
184
+ PRAGMA synchronous = FULL;
185
+ PRAGMA temp_store = MEMORY;
186
+ PRAGMA trusted_schema = OFF;
187
+ PRAGMA secure_delete = ON;
188
+ `);
189
+ applyMigrations(database, nowIso(this.#clock));
190
+ database.exec('BEGIN IMMEDIATE');
191
+ try {
192
+ pruneExpiredRows(database, nowIso(this.#clock));
193
+ database.exec('COMMIT');
194
+ } catch (error) {
195
+ try {
196
+ database.exec('ROLLBACK');
197
+ } catch {
198
+ // Preserve the retention failure.
199
+ }
200
+ throw error;
201
+ }
202
+ assertSecureDatabaseFile(this.#databasePath);
203
+ } catch (error) {
204
+ try {
205
+ database.close();
206
+ } catch {
207
+ // Preserve the validation failure.
208
+ }
209
+ if (
210
+ error instanceof StorageCorruptionError ||
211
+ error?.code === 'unsupported_schema' ||
212
+ error?.code === 'storage_security_error' ||
213
+ error?.code === 'invalid_input'
214
+ ) {
215
+ throw error;
216
+ }
217
+ throw new StorageCorruptionError('The control-plane database could not be initialized safely.', { cause: error });
218
+ }
219
+
220
+ this.#database = database;
221
+ }
222
+
223
+ #assertOpen() {
224
+ if (this.#closed) throw new StorageCorruptionError('The control-plane database is closed.');
225
+ }
226
+
227
+ #transaction(callback) {
228
+ this.#assertOpen();
229
+ this.#database.exec('BEGIN IMMEDIATE');
230
+ try {
231
+ const result = callback();
232
+ this.#database.exec('COMMIT');
233
+ return result;
234
+ } catch (error) {
235
+ try {
236
+ this.#database.exec('ROLLBACK');
237
+ } catch {
238
+ // Preserve the mutation error.
239
+ }
240
+ throw error;
241
+ }
242
+ }
243
+
244
+ #idempotentMutation(
245
+ { accountId, operation, idempotencyKey, request, resourceKind, resourceId },
246
+ mutation,
247
+ replay
248
+ ) {
249
+ assertIdentifier(accountId, 'accountId');
250
+ assertBoundedString(operation, 'operation', { min: 1, max: 128 });
251
+ assertIdempotencyKey(idempotencyKey);
252
+ if (!['account', 'browser_session', 'thread_index'].includes(resourceKind)) {
253
+ throw new ValidationError('resourceKind is not a supported idempotency receipt type.');
254
+ }
255
+ assertIdentifier(resourceId, 'resourceId');
256
+ if (typeof replay !== 'function') throw new ValidationError('An idempotency replay function is required.');
257
+ const keyHash = sha256(idempotencyKey);
258
+ const requestHash = sha256(canonicalJson(request));
259
+ const receiptTimestamp = nowIso(this.#clock);
260
+ const receiptExpiresAt = addMilliseconds(receiptTimestamp, IDEMPOTENCY_RECEIPT_TTL_MS);
261
+
262
+ return this.#transaction(() => {
263
+ pruneExpiredRows(this.#database, receiptTimestamp);
264
+ const existing = this.#database.prepare(`
265
+ SELECT request_hash, outcome_code, resource_kind, resource_id, result_digest
266
+ FROM idempotency_records
267
+ WHERE account_id = ? AND operation = ? AND key_hash = ?
268
+ `).get(accountId, operation, keyHash);
269
+ if (existing) {
270
+ if (existing.request_hash !== requestHash) throw new IdempotencyConflictError();
271
+ if (
272
+ existing.outcome_code !== 'succeeded' ||
273
+ existing.resource_kind !== resourceKind ||
274
+ existing.resource_id !== resourceId ||
275
+ typeof existing.result_digest !== 'string' ||
276
+ !/^[a-f0-9]{64}$/u.test(existing.result_digest)
277
+ ) {
278
+ throw new StorageCorruptionError('An idempotency receipt failed closed-schema validation.');
279
+ }
280
+ const replayResult = replay(existing);
281
+ const replayDigest = sha256(canonicalJson(replayResult));
282
+ if (replayDigest !== existing.result_digest) {
283
+ throw new ConflictError('The original idempotent response can no longer be replayed exactly.');
284
+ }
285
+ return replayResult;
286
+ }
287
+
288
+ const result = mutation();
289
+ const resultDigest = sha256(canonicalJson(result));
290
+ this.#database.prepare(`
291
+ INSERT INTO idempotency_records(
292
+ account_id, operation, key_hash, request_hash, outcome_code,
293
+ resource_kind, resource_id, result_digest, created_at, expires_at
294
+ ) VALUES (?, ?, ?, ?, 'succeeded', ?, ?, ?, ?, ?)
295
+ `).run(
296
+ accountId,
297
+ operation,
298
+ keyHash,
299
+ requestHash,
300
+ resourceKind,
301
+ resourceId,
302
+ resultDigest,
303
+ receiptTimestamp,
304
+ receiptExpiresAt
305
+ );
306
+ this.#database.prepare(`
307
+ DELETE FROM idempotency_records
308
+ WHERE rowid IN (
309
+ SELECT rowid
310
+ FROM idempotency_records
311
+ WHERE account_id = ?
312
+ ORDER BY rowid DESC
313
+ LIMIT -1 OFFSET ?
314
+ )
315
+ `).run(accountId, MAX_IDEMPOTENCY_RECEIPTS_PER_ACCOUNT);
316
+ return result;
317
+ });
318
+ }
319
+
320
+ #assertAccountExists(accountId) {
321
+ const row = this.#database.prepare('SELECT 1 AS present FROM accounts WHERE id = ?').get(accountId);
322
+ if (!row) throw new NotFoundError();
323
+ }
324
+
325
+ provisionAccount(input) {
326
+ assertExactKeys(
327
+ input,
328
+ { required: ['accountId', 'issuer', 'subject', 'idempotencyKey'], optional: ['displayName'] },
329
+ 'account'
330
+ );
331
+ const accountId = assertIdentifier(input.accountId, 'accountId');
332
+ const issuer = assertBoundedString(input.issuer, 'issuer', { min: 1, max: 256 });
333
+ const subject = assertBoundedString(input.subject, 'subject', { min: 1, max: 512 });
334
+ const displayName = validateDisplayName(input.displayName);
335
+ const request = { accountId, issuer, subject, displayName };
336
+
337
+ return this.#idempotentMutation(
338
+ {
339
+ accountId,
340
+ operation: 'account.provision',
341
+ idempotencyKey: input.idempotencyKey,
342
+ request,
343
+ resourceKind: 'account',
344
+ resourceId: accountId
345
+ },
346
+ () => {
347
+ const byId = this.#database.prepare('SELECT * FROM accounts WHERE id = ?').get(accountId);
348
+ if (byId) {
349
+ if (byId.issuer !== issuer || byId.subject !== subject) throw new ConflictError();
350
+ return accountView(byId);
351
+ }
352
+ const byIdentity = this.#database.prepare(`
353
+ SELECT id FROM accounts WHERE issuer = ? AND subject = ?
354
+ `).get(issuer, subject);
355
+ if (byIdentity) throw new ConflictError();
356
+ const timestamp = nowIso(this.#clock);
357
+ try {
358
+ this.#database.prepare(`
359
+ INSERT INTO accounts(id, issuer, subject, display_name, created_at, updated_at)
360
+ VALUES (?, ?, ?, ?, ?, ?)
361
+ `).run(accountId, issuer, subject, displayName, timestamp, timestamp);
362
+ } catch (error) {
363
+ if (isConstraintError(error)) throw new ConflictError();
364
+ throw error;
365
+ }
366
+ return accountView(this.#database.prepare('SELECT * FROM accounts WHERE id = ?').get(accountId));
367
+ },
368
+ () => {
369
+ const row = accountView(this.#database.prepare('SELECT * FROM accounts WHERE id = ?').get(accountId));
370
+ if (!row) throw new ConflictError('The idempotent account resource is no longer present.');
371
+ return row;
372
+ }
373
+ );
374
+ }
375
+
376
+ getAccount(accountId) {
377
+ this.#assertOpen();
378
+ assertIdentifier(accountId, 'accountId');
379
+ return accountView(this.#database.prepare('SELECT * FROM accounts WHERE id = ?').get(accountId));
380
+ }
381
+
382
+ resolveAccountIdentity(input) {
383
+ this.#assertOpen();
384
+ assertExactKeys(input, { required: ['issuer', 'subject'] }, 'identity');
385
+ const issuer = assertBoundedString(input.issuer, 'issuer', { min: 1, max: 256 });
386
+ const subject = assertBoundedString(input.subject, 'subject', { min: 1, max: 512 });
387
+ return accountView(this.#database.prepare(`
388
+ SELECT * FROM accounts WHERE issuer = ? AND subject = ?
389
+ `).get(issuer, subject));
390
+ }
391
+
392
+ createBrowserSession(input) {
393
+ assertExactKeys(
394
+ input,
395
+ {
396
+ required: ['accountId', 'sessionToken', 'csrfToken', 'expiresAt', 'idempotencyKey']
397
+ },
398
+ 'browser session'
399
+ );
400
+ const accountId = assertIdentifier(input.accountId, 'accountId');
401
+ const sessionDigest = digestSecret(input.sessionToken, 'sessionToken');
402
+ const csrfDigest = digestSecret(input.csrfToken, 'csrfToken');
403
+ const expiresAt = assertCanonicalIsoTimestamp(input.expiresAt, 'expiresAt');
404
+ const request = { accountId, sessionDigest, csrfDigest, expiresAt };
405
+
406
+ return this.#idempotentMutation(
407
+ {
408
+ accountId,
409
+ operation: 'browser_session.create',
410
+ idempotencyKey: input.idempotencyKey,
411
+ request,
412
+ resourceKind: 'browser_session',
413
+ resourceId: sessionDigest
414
+ },
415
+ () => {
416
+ this.#assertAccountExists(accountId);
417
+ const timestamp = nowIso(this.#clock);
418
+ if (expiresAt <= timestamp) throw new ValidationError('expiresAt must be in the future.');
419
+ const existingSession = this.#database.prepare(`
420
+ SELECT 1 AS present FROM browser_sessions WHERE session_digest = ?
421
+ `).get(sessionDigest);
422
+ if (existingSession) {
423
+ // Check before capacity eviction so a caller cannot delete and
424
+ // silently rebind an existing oldest token with new CSRF authority.
425
+ throw new ConflictError('The browser-session token already exists.');
426
+ }
427
+ const sessionCount = Number(this.#database.prepare(`
428
+ SELECT count(*) AS count
429
+ FROM browser_sessions
430
+ WHERE account_id = ?
431
+ `).get(accountId)?.count);
432
+ if (sessionCount >= MAX_BROWSER_SESSIONS_PER_ACCOUNT) {
433
+ const sessionsToEvict = sessionCount - MAX_BROWSER_SESSIONS_PER_ACCOUNT + 1;
434
+ const eviction = this.#database.prepare(`
435
+ DELETE FROM browser_sessions
436
+ WHERE account_id = ? AND session_digest IN (
437
+ SELECT session_digest
438
+ FROM browser_sessions
439
+ WHERE account_id = ?
440
+ -- last_seen_at is issuance-only until authentication records durable access.
441
+ ORDER BY created_at ASC, session_digest ASC
442
+ LIMIT ?
443
+ )
444
+ `).run(accountId, accountId, sessionsToEvict);
445
+ if (Number(eviction.changes) !== sessionsToEvict) {
446
+ throw new StorageCorruptionError('Browser-session admission could not make exactly one slot.');
447
+ }
448
+ }
449
+ try {
450
+ this.#database.prepare(`
451
+ INSERT INTO browser_sessions(
452
+ session_digest, account_id, csrf_digest, created_at, expires_at, last_seen_at, revoked_at
453
+ ) VALUES (?, ?, ?, ?, ?, ?, NULL)
454
+ `).run(sessionDigest, accountId, csrfDigest, timestamp, expiresAt, timestamp);
455
+ } catch (error) {
456
+ if (isConstraintError(error)) throw new ConflictError();
457
+ throw error;
458
+ }
459
+ return sessionView(this.#database.prepare(`
460
+ SELECT * FROM browser_sessions
461
+ WHERE session_digest = ? AND account_id = ?
462
+ `).get(sessionDigest, accountId));
463
+ },
464
+ () => {
465
+ const row = sessionView(this.#database.prepare(`
466
+ SELECT * FROM browser_sessions WHERE account_id = ? AND session_digest = ?
467
+ `).get(accountId, sessionDigest));
468
+ if (!row) throw new ConflictError('The idempotent browser session is no longer present.');
469
+ return row;
470
+ }
471
+ );
472
+ }
473
+
474
+ #authenticateBrowserSession(input, requireCsrf) {
475
+ this.#assertOpen();
476
+ assertExactKeys(
477
+ input,
478
+ requireCsrf ? { required: ['sessionToken', 'csrfToken'] } : { required: ['sessionToken'] },
479
+ 'browser authentication'
480
+ );
481
+ const sessionDigest = digestSecret(input.sessionToken, 'sessionToken');
482
+ const row = this.#database.prepare(`
483
+ SELECT * FROM browser_sessions
484
+ WHERE session_digest = ? AND revoked_at IS NULL AND expires_at > ?
485
+ `).get(sessionDigest, nowIso(this.#clock));
486
+ if (!row) return null;
487
+ if (requireCsrf) {
488
+ const csrfDigest = digestSecret(input.csrfToken, 'csrfToken');
489
+ if (!secretDigestsEqual(row.csrf_digest, csrfDigest)) return null;
490
+ }
491
+ return sessionView(row);
492
+ }
493
+
494
+ authenticateBrowserSession(input) {
495
+ return this.#authenticateBrowserSession(input, false);
496
+ }
497
+
498
+ authenticateBrowserMutation(input) {
499
+ return this.#authenticateBrowserSession(input, true);
500
+ }
501
+
502
+ revokeBrowserSession(input) {
503
+ assertExactKeys(
504
+ input,
505
+ { required: ['accountId', 'sessionToken', 'idempotencyKey'] },
506
+ 'browser session revocation'
507
+ );
508
+ const accountId = assertIdentifier(input.accountId, 'accountId');
509
+ const sessionDigest = digestSecret(input.sessionToken, 'sessionToken');
510
+ const request = { accountId, sessionDigest };
511
+ return this.#idempotentMutation(
512
+ {
513
+ accountId,
514
+ operation: 'browser_session.revoke',
515
+ idempotencyKey: input.idempotencyKey,
516
+ request,
517
+ resourceKind: 'browser_session',
518
+ resourceId: sessionDigest
519
+ },
520
+ () => {
521
+ const row = this.#database.prepare(`
522
+ SELECT * FROM browser_sessions WHERE account_id = ? AND session_digest = ?
523
+ `).get(accountId, sessionDigest);
524
+ if (!row) throw new NotFoundError();
525
+ this.#database.prepare(`
526
+ DELETE FROM browser_sessions
527
+ WHERE account_id = ? AND session_digest = ?
528
+ `).run(accountId, sessionDigest);
529
+ return { accountId, revoked: true };
530
+ },
531
+ () => ({ accountId, revoked: true })
532
+ );
533
+ }
534
+
535
+ registerThread(input) {
536
+ assertExactKeys(
537
+ input,
538
+ {
539
+ required: ['accountId', 'threadId', 'idempotencyKey'],
540
+ optional: ['title', 'pinned', 'routingNodeId', 'authorityRevision']
541
+ },
542
+ 'thread index'
543
+ );
544
+ const accountId = assertIdentifier(input.accountId, 'accountId');
545
+ const threadId = assertIdentifier(input.threadId, 'threadId');
546
+ const title = input.title === undefined
547
+ ? ''
548
+ : assertBoundedString(input.title, 'title', { min: 0, max: 120 });
549
+ const pinned = input.pinned === undefined ? false : assertBoolean(input.pinned, 'pinned');
550
+ const routingNodeId = input.routingNodeId === undefined || input.routingNodeId === null
551
+ ? null
552
+ : assertIdentifier(input.routingNodeId, 'routingNodeId');
553
+ const authorityRevision = input.authorityRevision === undefined || input.authorityRevision === null
554
+ ? null
555
+ : assertInteger(input.authorityRevision, 'authorityRevision', { min: 0 });
556
+ const request = {
557
+ accountId,
558
+ threadId,
559
+ title,
560
+ pinned,
561
+ routingNodeId,
562
+ authorityRevision
563
+ };
564
+
565
+ return this.#idempotentMutation(
566
+ {
567
+ accountId,
568
+ operation: 'thread.register',
569
+ idempotencyKey: input.idempotencyKey,
570
+ request,
571
+ resourceKind: 'thread_index',
572
+ resourceId: threadId
573
+ },
574
+ () => {
575
+ this.#assertAccountExists(accountId);
576
+ const existing = this.#database.prepare('SELECT 1 AS present FROM thread_index WHERE thread_id = ?').get(threadId);
577
+ if (existing) throw new ConflictError();
578
+ const timestamp = nowIso(this.#clock);
579
+ try {
580
+ this.#database.prepare(`
581
+ INSERT INTO thread_index(
582
+ thread_id, account_id, authority, title, pinned, routing_node_id,
583
+ authority_revision, last_run_id, created_at, updated_at, last_seen_at
584
+ ) VALUES (?, ?, 'aginti', ?, ?, ?, ?, NULL, ?, ?, ?)
585
+ `).run(
586
+ threadId,
587
+ accountId,
588
+ title,
589
+ pinned ? 1 : 0,
590
+ routingNodeId,
591
+ authorityRevision,
592
+ timestamp,
593
+ timestamp,
594
+ timestamp
595
+ );
596
+ } catch (error) {
597
+ if (isConstraintError(error)) throw new ConflictError();
598
+ throw error;
599
+ }
600
+ return threadView(this.#database.prepare(`
601
+ SELECT * FROM thread_index WHERE account_id = ? AND thread_id = ?
602
+ `).get(accountId, threadId));
603
+ },
604
+ () => {
605
+ const row = threadView(this.#database.prepare(`
606
+ SELECT * FROM thread_index WHERE account_id = ? AND thread_id = ?
607
+ `).get(accountId, threadId));
608
+ if (!row) throw new ConflictError('The idempotent thread index is no longer present.');
609
+ return row;
610
+ }
611
+ );
612
+ }
613
+
614
+ getThread(accountId, threadId) {
615
+ this.#assertOpen();
616
+ assertIdentifier(accountId, 'accountId');
617
+ assertIdentifier(threadId, 'threadId');
618
+ return threadView(this.#database.prepare(`
619
+ SELECT * FROM thread_index WHERE account_id = ? AND thread_id = ?
620
+ `).get(accountId, threadId));
621
+ }
622
+
623
+ listThreads(input) {
624
+ this.#assertOpen();
625
+ assertExactKeys(input, { required: ['accountId'], optional: ['limit', 'before'] }, 'thread list');
626
+ const accountId = assertIdentifier(input.accountId, 'accountId');
627
+ const limit = input.limit === undefined ? 50 : assertInteger(input.limit, 'limit', { min: 1, max: 100 });
628
+ if (input.before === undefined || input.before === null) {
629
+ return this.#database.prepare(`
630
+ SELECT * FROM thread_index
631
+ WHERE account_id = ?
632
+ ORDER BY updated_at DESC, thread_id DESC
633
+ LIMIT ?
634
+ `).all(accountId, limit).map(threadView);
635
+ }
636
+ assertExactKeys(input.before, { required: ['updatedAt', 'threadId'] }, 'before');
637
+ const updatedAt = assertCanonicalIsoTimestamp(input.before.updatedAt, 'before.updatedAt');
638
+ const threadId = assertIdentifier(input.before.threadId, 'before.threadId');
639
+ return this.#database.prepare(`
640
+ SELECT * FROM thread_index
641
+ WHERE account_id = ?
642
+ AND (updated_at < ? OR (updated_at = ? AND thread_id < ?))
643
+ ORDER BY updated_at DESC, thread_id DESC
644
+ LIMIT ?
645
+ `).all(accountId, updatedAt, updatedAt, threadId, limit).map(threadView);
646
+ }
647
+
648
+ updateThreadPresentation(input) {
649
+ assertExactKeys(
650
+ input,
651
+ { required: ['accountId', 'threadId', 'patch', 'idempotencyKey'] },
652
+ 'thread update'
653
+ );
654
+ const accountId = assertIdentifier(input.accountId, 'accountId');
655
+ const threadId = assertIdentifier(input.threadId, 'threadId');
656
+ const patch = validateThreadPatch(input.patch);
657
+ const request = { accountId, threadId, patch };
658
+
659
+ return this.#idempotentMutation(
660
+ {
661
+ accountId,
662
+ operation: 'thread.update',
663
+ idempotencyKey: input.idempotencyKey,
664
+ request,
665
+ resourceKind: 'thread_index',
666
+ resourceId: threadId
667
+ },
668
+ () => {
669
+ const existing = this.#database.prepare(`
670
+ SELECT 1 AS present FROM thread_index WHERE account_id = ? AND thread_id = ?
671
+ `).get(accountId, threadId);
672
+ if (!existing) throw new NotFoundError();
673
+
674
+ const assignments = [];
675
+ const values = [];
676
+ if (Object.hasOwn(patch, 'title')) {
677
+ assignments.push('title = ?');
678
+ values.push(patch.title);
679
+ }
680
+ if (Object.hasOwn(patch, 'pinned')) {
681
+ assignments.push('pinned = ?');
682
+ values.push(patch.pinned ? 1 : 0);
683
+ }
684
+ const timestamp = nowIso(this.#clock);
685
+ assignments.push('updated_at = ?', 'last_seen_at = ?');
686
+ values.push(timestamp, timestamp, accountId, threadId);
687
+ this.#database.prepare(`
688
+ UPDATE thread_index SET ${assignments.join(', ')}
689
+ WHERE account_id = ? AND thread_id = ?
690
+ `).run(...values);
691
+ return threadView(this.#database.prepare(`
692
+ SELECT * FROM thread_index WHERE account_id = ? AND thread_id = ?
693
+ `).get(accountId, threadId));
694
+ },
695
+ () => {
696
+ const row = threadView(this.#database.prepare(`
697
+ SELECT * FROM thread_index WHERE account_id = ? AND thread_id = ?
698
+ `).get(accountId, threadId));
699
+ if (!row) throw new ConflictError('The idempotent thread index is no longer present.');
700
+ return row;
701
+ }
702
+ );
703
+ }
704
+
705
+ syncThreadAuthorityMetadataFromAginti(input) {
706
+ assertExactKeys(
707
+ input,
708
+ {
709
+ required: [
710
+ 'accountId',
711
+ 'threadId',
712
+ 'routingNodeId',
713
+ 'authorityRevision',
714
+ 'idempotencyKey'
715
+ ]
716
+ },
717
+ 'AgInTi thread metadata sync'
718
+ );
719
+ const accountId = assertIdentifier(input.accountId, 'accountId');
720
+ const threadId = assertIdentifier(input.threadId, 'threadId');
721
+ const { routingNodeId, authorityRevision } = validateAuthorityMetadata(input);
722
+ const request = { accountId, threadId, routingNodeId, authorityRevision };
723
+
724
+ return this.#idempotentMutation(
725
+ {
726
+ accountId,
727
+ operation: 'thread.sync_aginti_metadata',
728
+ idempotencyKey: input.idempotencyKey,
729
+ request,
730
+ resourceKind: 'thread_index',
731
+ resourceId: threadId
732
+ },
733
+ () => {
734
+ const timestamp = nowIso(this.#clock);
735
+ const result = this.#database.prepare(`
736
+ UPDATE thread_index
737
+ SET routing_node_id = ?, authority_revision = ?,
738
+ updated_at = ?, last_seen_at = ?
739
+ WHERE account_id = ? AND thread_id = ?
740
+ `).run(
741
+ routingNodeId,
742
+ authorityRevision,
743
+ timestamp,
744
+ timestamp,
745
+ accountId,
746
+ threadId
747
+ );
748
+ if (Number(result.changes) !== 1) throw new NotFoundError();
749
+ return threadView(this.#database.prepare(`
750
+ SELECT * FROM thread_index WHERE account_id = ? AND thread_id = ?
751
+ `).get(accountId, threadId));
752
+ },
753
+ () => {
754
+ const row = threadView(this.#database.prepare(`
755
+ SELECT * FROM thread_index WHERE account_id = ? AND thread_id = ?
756
+ `).get(accountId, threadId));
757
+ if (!row) throw new ConflictError('The idempotent thread index is no longer present.');
758
+ return row;
759
+ }
760
+ );
761
+ }
762
+
763
+ removeThreadIndex(input) {
764
+ assertExactKeys(
765
+ input,
766
+ { required: ['accountId', 'threadId', 'idempotencyKey'] },
767
+ 'thread index removal'
768
+ );
769
+ const accountId = assertIdentifier(input.accountId, 'accountId');
770
+ const threadId = assertIdentifier(input.threadId, 'threadId');
771
+ const request = { accountId, threadId };
772
+ return this.#idempotentMutation(
773
+ {
774
+ accountId,
775
+ operation: 'thread.remove_index',
776
+ idempotencyKey: input.idempotencyKey,
777
+ request,
778
+ resourceKind: 'thread_index',
779
+ resourceId: threadId
780
+ },
781
+ () => {
782
+ const result = this.#database.prepare(`
783
+ DELETE FROM thread_index WHERE account_id = ? AND thread_id = ?
784
+ `).run(accountId, threadId);
785
+ if (Number(result.changes) !== 1) throw new NotFoundError();
786
+ return { threadId, removedFromCloudIndex: true };
787
+ },
788
+ () => ({ threadId, removedFromCloudIndex: true })
789
+ );
790
+ }
791
+
792
+ recordRunCursor(input) {
793
+ assertExactKeys(
794
+ input,
795
+ {
796
+ required: ['accountId', 'threadId', 'runId', 'lastSeq', 'lastEventHash']
797
+ },
798
+ 'run cursor'
799
+ );
800
+ const accountId = assertIdentifier(input.accountId, 'accountId');
801
+ const threadId = assertIdentifier(input.threadId, 'threadId');
802
+ const runId = assertIdentifier(input.runId, 'runId');
803
+ const lastSeq = assertInteger(input.lastSeq, 'lastSeq', { min: 0 });
804
+ const lastEventHash = assertEventHash(input.lastEventHash, lastSeq);
805
+ const retentionTimestamp = nowIso(this.#clock);
806
+
807
+ return this.#transaction(() => {
808
+ pruneExpiredRows(this.#database, retentionTimestamp);
809
+ const thread = this.#database.prepare(`
810
+ SELECT 1 AS present FROM thread_index WHERE account_id = ? AND thread_id = ?
811
+ `).get(accountId, threadId);
812
+ if (!thread) throw new NotFoundError();
813
+
814
+ const existing = this.#database.prepare(`
815
+ SELECT * FROM run_cursors WHERE account_id = ? AND run_id = ?
816
+ `).get(accountId, runId);
817
+ if (existing) {
818
+ if (existing.thread_id !== threadId) throw new ConflictError();
819
+ if (lastSeq < Number(existing.last_seq)) {
820
+ throw new ConflictError('A delivery cursor must not move backwards.');
821
+ }
822
+ if (lastSeq === Number(existing.last_seq)) {
823
+ if (existing.last_event_hash !== lastEventHash) {
824
+ throw new ConflictError('A delivery sequence cannot be rebound to a different event hash.');
825
+ }
826
+ return cursorView(existing);
827
+ }
828
+ }
829
+
830
+ const timestamp = nowIso(this.#clock);
831
+ if (!existing) {
832
+ const globalCollision = this.#database.prepare(`
833
+ SELECT 1 AS present FROM run_cursors WHERE run_id = ?
834
+ `).get(runId);
835
+ if (globalCollision) throw new ConflictError();
836
+ try {
837
+ this.#database.prepare(`
838
+ INSERT INTO run_cursors(
839
+ run_id, account_id, thread_id, last_seq, last_event_hash,
840
+ created_at, updated_at
841
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
842
+ `).run(
843
+ runId,
844
+ accountId,
845
+ threadId,
846
+ lastSeq,
847
+ lastEventHash,
848
+ timestamp,
849
+ timestamp
850
+ );
851
+ } catch (error) {
852
+ if (isConstraintError(error)) throw new ConflictError();
853
+ throw error;
854
+ }
855
+ } else {
856
+ this.#database.prepare(`
857
+ UPDATE run_cursors
858
+ SET last_seq = ?, last_event_hash = ?, updated_at = ?
859
+ WHERE account_id = ? AND run_id = ?
860
+ `).run(lastSeq, lastEventHash, timestamp, accountId, runId);
861
+ }
862
+ this.#database.prepare(`
863
+ UPDATE thread_index
864
+ SET last_run_id = ?, updated_at = ?, last_seen_at = ?
865
+ WHERE account_id = ? AND thread_id = ?
866
+ `).run(runId, timestamp, timestamp, accountId, threadId);
867
+ return cursorView(this.#database.prepare(`
868
+ SELECT * FROM run_cursors WHERE account_id = ? AND run_id = ?
869
+ `).get(accountId, runId));
870
+ });
871
+ }
872
+
873
+ getRunCursor(accountId, runId) {
874
+ this.#assertOpen();
875
+ assertIdentifier(accountId, 'accountId');
876
+ assertIdentifier(runId, 'runId');
877
+ return cursorView(this.#database.prepare(`
878
+ SELECT * FROM run_cursors WHERE account_id = ? AND run_id = ?
879
+ `).get(accountId, runId));
880
+ }
881
+
882
+ listRunCursors(input) {
883
+ this.#assertOpen();
884
+ assertExactKeys(input, { required: ['accountId', 'threadId'], optional: ['limit'] }, 'run cursor list');
885
+ const accountId = assertIdentifier(input.accountId, 'accountId');
886
+ const threadId = assertIdentifier(input.threadId, 'threadId');
887
+ const limit = input.limit === undefined ? 50 : assertInteger(input.limit, 'limit', { min: 1, max: 100 });
888
+ return this.#database.prepare(`
889
+ SELECT * FROM run_cursors
890
+ WHERE account_id = ? AND thread_id = ?
891
+ ORDER BY updated_at DESC, run_id DESC
892
+ LIMIT ?
893
+ `).all(accountId, threadId, limit).map(cursorView);
894
+ }
895
+
896
+ pruneExpiredState() {
897
+ const timestamp = nowIso(this.#clock);
898
+ return this.#transaction(() => pruneExpiredRows(this.#database, timestamp));
899
+ }
900
+
901
+ healthCheck() {
902
+ this.#assertOpen();
903
+ return checkOpenSqliteHealth(this.#database, {
904
+ expectedApplicationId: SQLITE_APPLICATION_ID,
905
+ allowedSchemaVersions: [LATEST_SCHEMA_VERSION]
906
+ });
907
+ }
908
+
909
+ close() {
910
+ if (this.#closed) return;
911
+ this.#database.close();
912
+ this.#closed = true;
913
+ }
914
+ }