@ixo/oracle-runtime-workers 0.4.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +5 -1
  2. package/package.json +2 -2
  3. package/src/attachments/download.ts +43 -22
  4. package/src/attachments/index.ts +1 -0
  5. package/src/core/env.ts +28 -0
  6. package/src/core/main-agent.ts +56 -4
  7. package/src/core/middlewares/dangling-tool-calls.ts +99 -0
  8. package/src/core/middlewares/index.ts +5 -0
  9. package/src/core/runtime-context.ts +14 -5
  10. package/src/core/subagent-as-tool.ts +12 -1
  11. package/src/do/ambient.ts +13 -4
  12. package/src/do/boot-dirty.ts +38 -0
  13. package/src/do/contracts.ts +113 -18
  14. package/src/do/matrix-turn-ledger.ts +126 -0
  15. package/src/do/reauth-prompt.ts +76 -0
  16. package/src/do/room-mirror.ts +111 -0
  17. package/src/do/sse-stream.ts +104 -2
  18. package/src/do/transcript.ts +15 -2
  19. package/src/do/turn-config.ts +24 -0
  20. package/src/do/user-oracle-do.ts +484 -91
  21. package/src/matrix/gateway-do.ts +233 -42
  22. package/src/matrix/inbox-store.ts +188 -0
  23. package/src/matrix/txn-id.ts +35 -0
  24. package/src/matrix/work-status.ts +15 -3
  25. package/src/owner-store/ixo-vfs-store.ts +12 -5
  26. package/src/owner-store/matrix-media-store.ts +65 -40
  27. package/src/owner-store/measure.ts +39 -0
  28. package/src/owner-store/migrating-store.ts +29 -34
  29. package/src/owner-store/types.ts +0 -0
  30. package/src/plugin-api/types.ts +25 -1
  31. package/src/plugins/portal/action-log.ts +36 -9
  32. package/src/plugins/tasks/tasks-tools.ts +7 -7
  33. package/src/shell/app.ts +92 -7
  34. package/src/shell/turn-body-cap.ts +19 -0
  35. package/src/sqlite/cache-config.ts +27 -6
  36. package/src/sqlite/chunk-layout.ts +14 -0
  37. package/src/sqlite/database.ts +188 -19
  38. package/src/sqlite/do-vfs.ts +963 -99
  39. package/src/sqlite/index.ts +13 -0
  40. package/src/sqlite/page-tier.ts +668 -0
  41. package/src/tasks/schedule-input.ts +102 -0
  42. package/src/tasks/scheduler.ts +351 -54
  43. package/src/tasks/store.ts +198 -3
@@ -28,6 +28,7 @@ import {
28
28
  withRateLimitRetry,
29
29
  type BotMessage,
30
30
  type MatrixBotOptions,
31
+ type StartResult,
31
32
  } from '@ixo/matrix-bot-workers-sdk';
32
33
  import { createClient, MatrixError, Preset, Visibility } from 'matrix-js-sdk';
33
34
  import {
@@ -42,6 +43,9 @@ import {
42
43
  type OutboxRow,
43
44
  type TurnResult,
44
45
  type UserOracleObject,
46
+ type MediaStream,
47
+ type SnapshotStream,
48
+ isInterruptedTurnError,
45
49
  } from '../do/contracts';
46
50
  import { decryptWithPin } from '../secrets/pin-cipher';
47
51
  import {
@@ -54,7 +58,21 @@ import {
54
58
  IngestPipeline,
55
59
  type InboundAttachment,
56
60
  type IngestTurn,
61
+ type InboundMessage,
57
62
  } from './ingest';
63
+ import {
64
+ bumpInboxAttempts,
65
+ countInboxRows,
66
+ deleteInboxRows,
67
+ ensureInboxTable,
68
+ inboundOfRow,
69
+ insertInboxRow,
70
+ listInboxRows,
71
+ MAX_TURN_REPLAYS,
72
+ planInboxReplay,
73
+ replyTxnId,
74
+ updateInboxThread,
75
+ } from './inbox-store';
58
76
  import {
59
77
  readRelatesTo,
60
78
  resolveReplyChainRoot,
@@ -192,6 +210,10 @@ function stateEventId(json: JsonString | null): string | undefined {
192
210
  // Durable Object
193
211
  // ---------------------------------------------------------------------------
194
212
 
213
+ /** Posted when a room turn fails or is given up; the user resends. */
214
+ const TURN_FAILED_NOTICE =
215
+ 'Sorry — something went wrong while handling your message. Please try again.';
216
+
195
217
  export class MatrixGatewayDO
196
218
  extends MatrixBotDO<OracleWorkerEnv>
197
219
  implements MatrixGatewayObject
@@ -209,6 +231,10 @@ export class MatrixGatewayDO
209
231
  /** Memoised `getOracleSecretsKey` result (private JWK JSON). */
210
232
  private oracleSecretsKeyJson: string | null = null;
211
233
  private oracleSigningMnemonic: string | null = null;
234
+ private inboxTableReady = false;
235
+ /** Event ids of turns running in this instance (a graceful restart must not replay them). */
236
+ private readonly inFlightEvents = new Set<string>();
237
+ private inboxReplayRunning = false;
212
238
 
213
239
  // -------------------------------------------------------------------------
214
240
  // Configuration
@@ -298,26 +324,43 @@ export class MatrixGatewayDO
298
324
  const isText = message.msgtype === 'm.text';
299
325
  const isFile = FILE_MSGTYPES.has(message.msgtype);
300
326
  if (!isText && !isFile) return;
301
- // Resolved before the offer so the pipeline's synchronous alias lookup
302
- // (room alias → user DID) can answer from the memo.
303
- await this.canonicalAliasOf(message.roomId);
304
- const threadRootId = await this.threadRootFor(message, eventId);
305
- const outcome = this.ingestPipeline().offer({
327
+ const inbound: InboundMessage = {
306
328
  eventId,
307
329
  roomId: message.roomId,
308
330
  sender: message.sender,
309
331
  ts: message.ts,
310
332
  body: isText ? message.body : '',
311
- ...(threadRootId ? { threadRootId } : {}),
333
+ ...(message.threadRootId ? { threadRootId: message.threadRootId } : {}),
312
334
  ...(isFile
313
335
  ? { attachment: attachmentOf(content, eventId, message.body) }
314
336
  : {}),
315
- });
316
- if (outcome !== 'queued')
337
+ };
338
+ // Durable before anything waits on the network: the SDK has already
339
+ // marked the event processed, so from here on only this row brings the
340
+ // message back if the instance dies before its reply is in the outbox.
341
+ const sql = this.inboxSql();
342
+ insertInboxRow(sql, inbound, Date.now());
343
+ // Resolved before the offer so the pipeline's synchronous alias lookup
344
+ // (room alias → user DID) can answer from the memo.
345
+ await this.canonicalAliasOf(message.roomId);
346
+ const threadRootId = await this.threadRootFor(message, eventId);
347
+ if (threadRootId && threadRootId !== inbound.threadRootId) {
348
+ inbound.threadRootId = threadRootId;
349
+ updateInboxThread(sql, eventId, threadRootId);
350
+ }
351
+ this.offerInbound(inbound);
352
+ }
353
+
354
+ /** Feed a message into the ingest pipeline; a message it will not turn into a turn leaves the inbox at once. */
355
+ private offerInbound(inbound: InboundMessage): void {
356
+ const outcome = this.ingestPipeline().offer(inbound);
357
+ if (outcome !== 'queued') {
358
+ deleteInboxRows(this.inboxSql(), [inbound.eventId]);
317
359
  this.log(
318
360
  'debug',
319
- `ingest dropped ${eventId} in ${message.roomId}: ${outcome}`,
361
+ `ingest dropped ${inbound.eventId} in ${inbound.roomId}: ${outcome}`,
320
362
  );
363
+ }
321
364
  }
322
365
 
323
366
  /**
@@ -378,6 +421,7 @@ export class MatrixGatewayDO
378
421
  'info',
379
422
  `turn ${requestId}: ${turn.userDid} in ${turn.roomId}${turn.threadId ? ` (thread ${turn.threadId})` : ''}`,
380
423
  );
424
+ for (const id of turn.eventIds) this.inFlightEvents.add(id);
381
425
  let typing: ReturnType<typeof setInterval> | null = null;
382
426
  try {
383
427
  await this.setTyping(turn.roomId, true, TYPING_TIMEOUT_MS);
@@ -387,29 +431,46 @@ export class MatrixGatewayDO
387
431
  );
388
432
  }, TYPING_REFRESH_MS);
389
433
  const result = await this.runTurn(turn, requestId);
390
- if (result.text.trim()) {
391
- await this.sendText(
392
- turn.roomId,
393
- result.text,
394
- turn.threadId ? { threadId: turn.threadId } : undefined,
434
+ if (result.replayed)
435
+ this.log(
436
+ 'info',
437
+ `turn ${requestId}: reply served from the user object's ledger (turn had already finished)`,
395
438
  );
439
+ if (result.text.trim()) {
440
+ // The transaction id is derived from the event id, so this reply is
441
+ // deduplicated by the homeserver if a previous incarnation already
442
+ // sent it before it died (see inbox-store.ts).
443
+ await this.sendText(turn.roomId, result.text, {
444
+ ...(turn.threadId ? { threadId: turn.threadId } : {}),
445
+ ...(turn.eventIds[0] ? { txnId: replyTxnId(turn.eventIds[0]) } : {}),
446
+ });
396
447
  } else {
397
448
  this.log('info', `turn ${requestId}: empty reply, nothing sent`);
398
449
  }
450
+ // The reply is in the durable outbox (or there is none): the turn
451
+ // has ended, the inbox row has done its job.
452
+ deleteInboxRows(this.inboxSql(), turn.eventIds);
399
453
  } catch (err) {
400
454
  // A turn aborted because a newer message on the same thread superseded
401
455
  // it is not a failure: the new turn answers, the old card says so.
402
456
  if (isAbortError(err)) {
403
457
  this.log('info', `turn ${requestId}: superseded by a newer message`);
458
+ deleteInboxRows(this.inboxSql(), turn.eventIds);
404
459
  return;
405
460
  }
406
- this.log('error', `turn ${requestId} failed`, err);
407
- try {
408
- await this.sendNotice(
409
- turn.roomId,
410
- 'Sorry — something went wrong while handling your message. Please try again.',
411
- turn.threadId ? { threadId: turn.threadId } : undefined,
461
+ if (isInterruptedTurnError(err))
462
+ this.log(
463
+ 'warn',
464
+ `turn ${requestId}: the user object lost this turn in a reset and will not re-run it; asking the user to retry`,
412
465
  );
466
+ else this.log('error', `turn ${requestId} failed`, err);
467
+ try {
468
+ await this.sendNotice(turn.roomId, TURN_FAILED_NOTICE, {
469
+ ...(turn.threadId ? { threadId: turn.threadId } : {}),
470
+ ...(turn.eventIds[0]
471
+ ? { txnId: `${replyTxnId(turn.eventIds[0])}-notice` }
472
+ : {}),
473
+ });
413
474
  } catch (noticeErr) {
414
475
  this.log(
415
476
  'error',
@@ -417,7 +478,10 @@ export class MatrixGatewayDO
417
478
  noticeErr,
418
479
  );
419
480
  }
481
+ // Told the user (or could not): either way this turn is over.
482
+ deleteInboxRows(this.inboxSql(), turn.eventIds);
420
483
  } finally {
484
+ for (const id of turn.eventIds) this.inFlightEvents.delete(id);
421
485
  if (typing) clearInterval(typing);
422
486
  await this.setTyping(turn.roomId, false).catch(() => undefined);
423
487
  // Close the card on every exit — reply posted, empty reply, thrown
@@ -460,6 +524,105 @@ export class MatrixGatewayDO
460
524
  });
461
525
  }
462
526
 
527
+ // -------------------------------------------------------------------------
528
+ // Turn inbox: room messages whose reply is not in the outbox yet
529
+ // -------------------------------------------------------------------------
530
+
531
+ private inboxSql(): SqlStorage {
532
+ const sql = this.ctx.storage.sql;
533
+ if (!this.inboxTableReady) {
534
+ ensureInboxTable(sql);
535
+ this.inboxTableReady = true;
536
+ }
537
+ return sql;
538
+ }
539
+
540
+ /**
541
+ * Every start of this instance — first request, keep-alive alarm, RPC —
542
+ * goes through here. A start that actually brought the bot up is the
543
+ * moment to re-dispatch the turns a previous incarnation left unfinished.
544
+ */
545
+ override async ensureStarted(): Promise<StartResult> {
546
+ const result = await super.ensureStarted();
547
+ if (result.started) this.scheduleInboxReplay();
548
+ return result;
549
+ }
550
+
551
+ private scheduleInboxReplay(): void {
552
+ if (this.inboxReplayRunning) return;
553
+ this.inboxReplayRunning = true;
554
+ void this.replayInbox()
555
+ .catch((err: unknown) => this.log('error', 'inbox replay failed', err))
556
+ .finally(() => {
557
+ this.inboxReplayRunning = false;
558
+ });
559
+ }
560
+
561
+ /**
562
+ * Re-dispatch the messages whose turn never ended. Each replay is charged
563
+ * to the row; a row that has used its replays gets the "try again" notice
564
+ * instead, so a message that kills the instance cannot loop. The user
565
+ * object decides what a replay means for it (`matrix-turn-ledger.ts`):
566
+ * stored reply, attach to the running turn, or refuse — never a second run.
567
+ */
568
+ private async replayInbox(): Promise<void> {
569
+ const sql = this.inboxSql();
570
+ const rows = listInboxRows(sql);
571
+ if (rows.length === 0) return;
572
+ const plan = planInboxReplay(rows, {
573
+ inFlight: this.inFlightEvents,
574
+ maxReplays: MAX_TURN_REPLAYS,
575
+ });
576
+ for (const row of plan.exhausted) {
577
+ this.log(
578
+ 'warn',
579
+ `inbox: giving up on ${row.eventId} in ${row.roomId} after ${row.attempts} replays; notifying the user`,
580
+ );
581
+ try {
582
+ await this.sendNotice(row.roomId, TURN_FAILED_NOTICE, {
583
+ ...(row.threadRootId ? { threadId: row.threadRootId } : {}),
584
+ txnId: `${replyTxnId(row.eventId)}-notice`,
585
+ });
586
+ } catch (err) {
587
+ this.log(
588
+ 'error',
589
+ `inbox: could not post the notice for ${row.eventId}`,
590
+ err,
591
+ );
592
+ }
593
+ deleteInboxRows(sql, [row.eventId]);
594
+ }
595
+ if (plan.replay.length > 0)
596
+ bumpInboxAttempts(
597
+ sql,
598
+ plan.replay.map((row) => row.eventId),
599
+ );
600
+ for (const row of plan.replay) {
601
+ try {
602
+ await this.canonicalAliasOf(row.roomId);
603
+ } catch (err) {
604
+ this.log('warn', `inbox: alias lookup failed for ${row.roomId}`, err);
605
+ }
606
+ this.offerInbound(inboundOfRow(row));
607
+ }
608
+ this.log(
609
+ 'info',
610
+ `inbox replay: ${plan.replay.length} turn(s) re-dispatched, ${plan.exhausted.length} given up, ${plan.skipped.length} already running`,
611
+ );
612
+ }
613
+
614
+ /**
615
+ * Operator / testing aid behind `ORACLE_DEBUG_ROUTES`: reset this object
616
+ * the way an unplanned platform reset does — in-flight turns and the sync
617
+ * loop die, committed storage (outbox, inbox, crypto snapshot) survives,
618
+ * the next request or alarm boots a fresh instance. The RPC rejects.
619
+ */
620
+ async debugAbortObject(): Promise<void> {
621
+ // Worded like the platform's own reset so RPC callers classify it as
622
+ // transient (see `isTransientGatewayError`), exactly as in production.
623
+ this.ctx.abort('gateway reset requested by the debug route');
624
+ }
625
+
463
626
  override async stop(): Promise<void> {
464
627
  // A debounce buffer firing after the stop would send through
465
628
  // `startedClient()`, which restarts the bot.
@@ -724,11 +887,12 @@ export class MatrixGatewayDO
724
887
  // User snapshots (SQLite files as room media) and media downloads
725
888
  // -------------------------------------------------------------------------
726
889
 
727
- async uploadUserSnapshot(
890
+ async uploadUserSnapshotStream(
728
891
  userDid: string,
729
892
  storageKey: string,
730
- bytes: Uint8Array,
893
+ body: ReadableStream<Uint8Array>,
731
894
  filename: string,
895
+ size: number,
732
896
  ): Promise<{ eventId: string }> {
733
897
  const target = await this.resolveUserRoom(userDid);
734
898
  if (!target)
@@ -740,10 +904,13 @@ export class MatrixGatewayDO
740
904
  await this.getRoomStateEvent(roomId, MEDIA_STATE_TYPE, storageKey),
741
905
  );
742
906
  // `{"file":{…}}` (encrypted) or `{"url":"mxc://…"}`, per the room.
907
+ // Streamed: encrypted chunk by chunk in an E2EE room and sent with the
908
+ // exact length; the whole-buffer upload grew the crypto WASM heap for good.
743
909
  const source: unknown = JSON.parse(
744
- await this.uploadFile(roomId, bytes, {
910
+ await this.uploadFileStream(roomId, body, {
745
911
  filename,
746
912
  mimetype: SNAPSHOT_MIMETYPE,
913
+ size,
747
914
  }),
748
915
  );
749
916
  const content = {
@@ -752,7 +919,7 @@ export class MatrixGatewayDO
752
919
  filename: storageKey,
753
920
  cid: storageKey,
754
921
  sender: this.cfg().userId,
755
- info: { mimetype: SNAPSHOT_MIMETYPE, size: bytes.byteLength },
922
+ info: { mimetype: SNAPSHOT_MIMETYPE, size },
756
923
  ...(typeof source === 'object' && source !== null ? source : {}),
757
924
  };
758
925
  const eventId = await this.sendEvent(
@@ -786,43 +953,64 @@ export class MatrixGatewayDO
786
953
  }
787
954
  this.log(
788
955
  'info',
789
- `uploaded snapshot ${storageKey} (${bytes.byteLength} bytes) for ${userDid} → ${eventId}`,
956
+ `uploaded snapshot ${storageKey} (${size} bytes) for ${userDid} → ${eventId}`,
790
957
  );
791
958
  return { eventId };
792
959
  }
793
960
 
794
- async downloadUserSnapshot(
961
+ async downloadUserSnapshotStream(
795
962
  userDid: string,
796
963
  storageKey: string,
797
- ): Promise<{ bytes: Uint8Array; eventId: string } | null> {
964
+ ): Promise<SnapshotStream | null> {
798
965
  const target = await this.resolveUserRoom(userDid);
799
966
  if (!target) return null;
800
967
  const eventId = stateEventId(
801
968
  await this.getRoomStateEvent(target.roomId, MEDIA_STATE_TYPE, storageKey),
802
969
  );
803
970
  if (!eventId) return null;
804
- const file = await this.downloadFile(target.roomId, eventId);
805
- return file ? { bytes: file.bytes, eventId } : null;
971
+ const media = await this.mediaStream(target.roomId, eventId);
972
+ return media ? { ...media, eventId } : null;
973
+ }
974
+
975
+ /**
976
+ * A media event as a stream, AS STORED — raw ciphertext plus the
977
+ * `EncryptedFile` fields in an E2EE room. Decryption happens in the calling
978
+ * object: the SDK's whole-buffer `downloadFile` decrypts in the crypto WASM
979
+ * and grows its heap for good, and a stream that fails its hash check on
980
+ * this side of the RPC would reach the caller as a bare disconnect.
981
+ */
982
+ private async mediaStream(
983
+ roomId: string,
984
+ eventId: string,
985
+ ): Promise<MediaStream | null> {
986
+ const media = await this.downloadFileStream(roomId, eventId, {
987
+ raw: true,
988
+ });
989
+ if (!media) return null;
990
+ return {
991
+ stream: media.stream,
992
+ ...(media.file ? { file: media.file } : {}),
993
+ ...(media.size !== undefined ? { size: media.size } : {}),
994
+ ...(media.mimetype ? { mimetype: media.mimetype } : {}),
995
+ ...(media.filename ? { filename: media.filename } : {}),
996
+ };
806
997
  }
807
998
 
808
999
  /**
809
- * Bytes of a media message (`m.image` / `m.file` / …) for the attachments
810
- * pipeline — decrypted when the event carries an encrypted `file`. Null
811
- * when the event is unknown or redacted.
1000
+ * A media message (`m.image` / `m.file` / …) for the attachments pipeline,
1001
+ * as stored (see `mediaStream`). Null when the event is unknown or redacted.
812
1002
  */
813
- async downloadEventMedia(
1003
+ async downloadEventMediaStream(
814
1004
  roomId: string,
815
1005
  eventId: string,
816
- ): Promise<{
817
- bytes: Uint8Array;
818
- mimetype?: string;
819
- filename?: string;
820
- } | null> {
821
- return this.downloadFile(roomId, eventId);
1006
+ ): Promise<MediaStream | null> {
1007
+ return this.mediaStream(roomId, eventId);
822
1008
  }
823
1009
 
824
- /** Authenticated download of an `mxc://` URI (`/_matrix/client/v1/media/download`, legacy fallback on 404). */
825
- async downloadMxcMedia(mxc: string): Promise<Uint8Array> {
1010
+ /** Authenticated download of an `mxc://` URI as a stream (`/_matrix/client/v1/media/download`, legacy fallback on 404). */
1011
+ async downloadMxcMediaStream(
1012
+ mxc: string,
1013
+ ): Promise<ReadableStream<Uint8Array>> {
826
1014
  const client = await this.startedClient();
827
1015
  const token = client.getAccessToken();
828
1016
  const headers: Record<string, string> = token
@@ -855,7 +1043,9 @@ export class MatrixGatewayDO
855
1043
  throw new Error(
856
1044
  `MatrixGatewayDO: media download failed: ${res.status} ${res.statusText}`,
857
1045
  );
858
- return new Uint8Array(await res.arrayBuffer());
1046
+ if (!res.body)
1047
+ throw new Error('MatrixGatewayDO: media download has no body');
1048
+ return res.body;
859
1049
  }
860
1050
 
861
1051
  // -------------------------------------------------------------------------
@@ -973,6 +1163,7 @@ export class MatrixGatewayDO
973
1163
  ...(await super.status()),
974
1164
  turns: { inFlight: this.turnGate.inUse, waiting: this.turnGate.queued },
975
1165
  ingestPending: this.ingest?.pendingCount ?? 0,
1166
+ inbox: countInboxRows(this.inboxSql()),
976
1167
  };
977
1168
  }
978
1169
  }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Durable inbox for room messages whose turn is still in flight.
3
+ *
4
+ * The bot SDK marks an inbound event processed before it is delivered, and
5
+ * the reply only reaches the durable outbox once the user object has finished
6
+ * the whole LLM turn. A gateway reset in between — a platform host drain, a
7
+ * deploy — used to lose the reply silently: the fresh instance saw the event
8
+ * as processed and never asked again. Every accepted message is therefore
9
+ * written here before anything waits on the network, deleted once its turn
10
+ * has ended (reply in the outbox, empty reply, superseded, error notice
11
+ * posted), and whatever is left is re-dispatched on the next start. The user
12
+ * object makes re-dispatch safe (`src/do/matrix-turn-ledger.ts`): an event
13
+ * it already answered returns the stored text, one still running attaches,
14
+ * and one interrupted mid-turn is refused — never run twice.
15
+ *
16
+ * Cost shape (SQLite-backed Durable Object storage bills rows written and
17
+ * read): one row written on receipt, one delete when the turn ends.
18
+ */
19
+ import type { InboundAttachment, InboundMessage } from './ingest';
20
+ import { matrixTxnId } from './txn-id';
21
+
22
+ export interface InboxRow extends InboundMessage {
23
+ receivedAt: number;
24
+ /** Replays charged to this row (0 = never replayed). */
25
+ attempts: number;
26
+ }
27
+
28
+ /**
29
+ * Replays a message gets after the original dispatch. A row that has been
30
+ * replayed this many times when the next start finds it is given up: the
31
+ * user gets the "try again" notice instead of a fourth attempt.
32
+ */
33
+ export const MAX_TURN_REPLAYS = 2;
34
+
35
+ export function ensureInboxTable(sql: SqlStorage): void {
36
+ sql.exec(
37
+ `CREATE TABLE IF NOT EXISTS turn_inbox (
38
+ event_id TEXT PRIMARY KEY,
39
+ room_id TEXT NOT NULL,
40
+ sender TEXT NOT NULL,
41
+ ts INTEGER NOT NULL,
42
+ body TEXT NOT NULL,
43
+ thread_root_id TEXT,
44
+ attachment TEXT,
45
+ received_at INTEGER NOT NULL,
46
+ attempts INTEGER NOT NULL DEFAULT 0
47
+ ) WITHOUT ROWID`,
48
+ );
49
+ }
50
+
51
+ /** Record a message before its turn starts. A repeat of the same event id is a no-op. */
52
+ export function insertInboxRow(
53
+ sql: SqlStorage,
54
+ msg: InboundMessage,
55
+ receivedAt: number,
56
+ ): void {
57
+ sql.exec(
58
+ `INSERT OR IGNORE INTO turn_inbox
59
+ (event_id, room_id, sender, ts, body, thread_root_id, attachment, received_at, attempts)
60
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)`,
61
+ msg.eventId,
62
+ msg.roomId,
63
+ msg.sender,
64
+ msg.ts,
65
+ msg.body,
66
+ msg.threadRootId ?? null,
67
+ msg.attachment ? JSON.stringify(msg.attachment) : null,
68
+ receivedAt,
69
+ );
70
+ }
71
+
72
+ /** A quote-reply's thread root, resolved after the row was written. */
73
+ export function updateInboxThread(
74
+ sql: SqlStorage,
75
+ eventId: string,
76
+ threadRootId: string,
77
+ ): void {
78
+ sql.exec(
79
+ `UPDATE turn_inbox SET thread_root_id = ? WHERE event_id = ?`,
80
+ threadRootId,
81
+ eventId,
82
+ );
83
+ }
84
+
85
+ export function deleteInboxRows(sql: SqlStorage, eventIds: string[]): void {
86
+ for (const id of eventIds)
87
+ sql.exec(`DELETE FROM turn_inbox WHERE event_id = ?`, id);
88
+ }
89
+
90
+ export function bumpInboxAttempts(sql: SqlStorage, eventIds: string[]): void {
91
+ for (const id of eventIds)
92
+ sql.exec(
93
+ `UPDATE turn_inbox SET attempts = attempts + 1 WHERE event_id = ?`,
94
+ id,
95
+ );
96
+ }
97
+
98
+ export function countInboxRows(sql: SqlStorage): number {
99
+ const rows = sql
100
+ .exec<{ n: number }>(`SELECT COUNT(*) AS n FROM turn_inbox`)
101
+ .toArray();
102
+ return Number(rows[0]?.n ?? 0);
103
+ }
104
+
105
+ type InboxSqlRow = {
106
+ event_id: string;
107
+ room_id: string;
108
+ sender: string;
109
+ ts: number;
110
+ body: string;
111
+ thread_root_id: string | null;
112
+ attachment: string | null;
113
+ received_at: number;
114
+ attempts: number;
115
+ } & Record<string, SqlStorageValue>;
116
+
117
+ /** Every pending row, oldest first (the order the messages arrived in). */
118
+ export function listInboxRows(sql: SqlStorage): InboxRow[] {
119
+ return sql
120
+ .exec<InboxSqlRow>(
121
+ `SELECT event_id, room_id, sender, ts, body, thread_root_id, attachment, received_at, attempts
122
+ FROM turn_inbox ORDER BY ts ASC, event_id ASC`,
123
+ )
124
+ .toArray()
125
+ .map((row) => ({
126
+ eventId: row.event_id,
127
+ roomId: row.room_id,
128
+ sender: row.sender,
129
+ ts: Number(row.ts),
130
+ body: row.body,
131
+ ...(row.thread_root_id ? { threadRootId: row.thread_root_id } : {}),
132
+ ...(row.attachment
133
+ ? { attachment: parseAttachment(row.attachment) }
134
+ : {}),
135
+ receivedAt: Number(row.received_at),
136
+ attempts: Number(row.attempts),
137
+ }));
138
+ }
139
+
140
+ function parseAttachment(json: string): InboundAttachment {
141
+ const parsed: unknown = JSON.parse(json);
142
+ if (
143
+ typeof parsed !== 'object' ||
144
+ parsed === null ||
145
+ typeof (parsed as { eventId?: unknown }).eventId !== 'string'
146
+ )
147
+ throw new Error('turn_inbox: malformed attachment column');
148
+ return parsed as InboundAttachment;
149
+ }
150
+
151
+ /** The message a row was made from — what goes back into the ingest pipeline. */
152
+ export function inboundOfRow(row: InboxRow): InboundMessage {
153
+ const { receivedAt: _receivedAt, attempts: _attempts, ...msg } = row;
154
+ return msg;
155
+ }
156
+
157
+ export interface InboxReplayPlan {
158
+ /** Rows to feed back into the ingest pipeline, oldest first. */
159
+ replay: InboxRow[];
160
+ /** Rows that used up their replays: notify the user and drop. */
161
+ exhausted: InboxRow[];
162
+ /** Rows whose turn is running in this instance right now (a restart, not a reset): leave alone. */
163
+ skipped: InboxRow[];
164
+ }
165
+
166
+ export function planInboxReplay(
167
+ rows: InboxRow[],
168
+ opts: { inFlight: ReadonlySet<string>; maxReplays: number },
169
+ ): InboxReplayPlan {
170
+ const plan: InboxReplayPlan = { replay: [], exhausted: [], skipped: [] };
171
+ for (const row of rows) {
172
+ if (opts.inFlight.has(row.eventId)) plan.skipped.push(row);
173
+ else if (row.attempts >= opts.maxReplays) plan.exhausted.push(row);
174
+ else plan.replay.push(row);
175
+ }
176
+ return plan;
177
+ }
178
+
179
+ /**
180
+ * The transaction id of the reply to a room message. Derived from the event
181
+ * id, so a reply re-sent after a reset — by the replayed turn, or by the
182
+ * durable outbox — is deduplicated by the homeserver instead of appearing
183
+ * twice. Event ids are `$` + base64 (standard or url-safe); the id must not
184
+ * contain `/`, whitespace or control characters, and stays under 255 bytes.
185
+ */
186
+ export function replyTxnId(eventId: string): string {
187
+ return matrixTxnId('reply', eventId);
188
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Transaction ids for Matrix sends that may be retried across a gateway
3
+ * restart. The homeserver deduplicates sends by transaction id per device,
4
+ * so a caller that re-sends with the same id after a lost response gets the
5
+ * original event back instead of a duplicate. Synapse accepts ids without
6
+ * whitespace, control characters or `/`, up to 255 bytes. The parts joined
7
+ * here (event ids, session ids, request ids, run ids) are ASCII; `/` and `+`
8
+ * of standard-base64 event ids are mapped so distinct ids stay distinct, and
9
+ * anything outside printable ASCII is dropped.
10
+ */
11
+ export const MAX_TXN_ID_LENGTH = 255;
12
+
13
+ export function matrixTxnId(prefix: string, ...parts: string[]): string {
14
+ let out = prefix;
15
+ for (const part of parts) {
16
+ out += '-';
17
+ for (const ch of part) {
18
+ const code = ch.charCodeAt(0);
19
+ if (ch === '/') out += '_';
20
+ else if (ch === '+') out += '-';
21
+ else if (code > 0x20 && code < 0x7f) out += ch;
22
+ }
23
+ }
24
+ return out.slice(0, MAX_TXN_ID_LENGTH);
25
+ }
26
+
27
+ /**
28
+ * A transaction id for a best-effort post whose retries all live in one
29
+ * in-memory loop (an audit entry, a prompt): fixed for the life of that loop,
30
+ * so a response lost after the homeserver accepted the event is deduplicated
31
+ * instead of posted twice, and fresh for every new post.
32
+ */
33
+ export function retryTxnId(prefix: string): string {
34
+ return matrixTxnId(prefix, crypto.randomUUID());
35
+ }