@ixo/oracle-runtime-workers 0.12.0 → 0.15.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 (205) hide show
  1. package/README.md +32 -10
  2. package/package.json +14 -9
  3. package/src/artifacts/config.ts +158 -0
  4. package/src/artifacts/crypto.ts +94 -0
  5. package/src/artifacts/routes.ts +73 -0
  6. package/src/artifacts/store.ts +238 -0
  7. package/src/artifacts/sweep.ts +200 -0
  8. package/src/artifacts/tool.ts +103 -0
  9. package/src/artifacts/viewer-page.ts +614 -0
  10. package/src/attachments/download.ts +168 -45
  11. package/src/attachments/index.ts +9 -0
  12. package/src/attachments/magic.ts +33 -0
  13. package/src/attachments/pipeline.ts +105 -49
  14. package/src/attachments/sandbox-archive.ts +9 -8
  15. package/src/attachments/view-cache.ts +142 -0
  16. package/src/attachments/view.ts +53 -12
  17. package/src/channels/auth.ts +267 -0
  18. package/src/channels/contract.ts +137 -0
  19. package/src/channels/turns.ts +270 -0
  20. package/src/core/budgeted-llm.ts +223 -0
  21. package/src/core/capability-router.ts +273 -0
  22. package/src/core/context-budget.ts +97 -10
  23. package/src/core/env.ts +111 -0
  24. package/src/core/execution-profile.ts +17 -0
  25. package/src/core/index.ts +267 -9
  26. package/src/core/llm.ts +30 -9
  27. package/src/core/main-agent-types.ts +41 -1
  28. package/src/core/main-agent.ts +367 -65
  29. package/src/core/manifest.ts +34 -0
  30. package/src/core/meta-tools.ts +128 -18
  31. package/src/core/middlewares/capability-gate.ts +130 -12
  32. package/src/core/middlewares/context-guard.ts +55 -24
  33. package/src/core/middlewares/index.ts +20 -2
  34. package/src/core/middlewares/page-context.ts +46 -2
  35. package/src/core/middlewares/return-direct-first.ts +47 -0
  36. package/src/core/middlewares/summarization.ts +152 -10
  37. package/src/core/middlewares/tool-execution.ts +323 -0
  38. package/src/core/middlewares/tool-repetition-guard.ts +202 -35
  39. package/src/core/middlewares/turn-boundary.ts +162 -0
  40. package/src/core/openrouter-pricing.ts +155 -22
  41. package/src/core/prompt-composer.ts +112 -7
  42. package/src/core/registries.ts +253 -48
  43. package/src/core/request-admission.ts +159 -0
  44. package/src/core/runtime-context.ts +107 -8
  45. package/src/core/state.ts +1 -1
  46. package/src/core/sub-agent-fallback.ts +21 -2
  47. package/src/core/subagent-as-tool.ts +78 -43
  48. package/src/core/tool-access.ts +189 -0
  49. package/src/core/tool-scheduler.ts +125 -0
  50. package/src/core/turn-budget.ts +205 -0
  51. package/src/core/turn-time-note.ts +43 -0
  52. package/src/core/user-kv.ts +446 -0
  53. package/src/core/wrap-plugin-tool.ts +20 -0
  54. package/src/delivery/plan.ts +278 -0
  55. package/src/delivery/profile.ts +161 -0
  56. package/src/delivery/prompt.ts +27 -0
  57. package/src/delivery/schema.ts +61 -0
  58. package/src/delivery/shaper.ts +389 -0
  59. package/src/delivery/types.ts +84 -0
  60. package/src/do/ambient.ts +51 -15
  61. package/src/do/contracts.ts +198 -6
  62. package/src/do/idle-eviction.ts +38 -0
  63. package/src/do/matrix-turn-ledger.ts +24 -5
  64. package/src/do/result-store-test-do.ts +137 -3
  65. package/src/do/result-store.ts +239 -58
  66. package/src/do/room-mirror.ts +29 -0
  67. package/src/do/run-buffer.ts +64 -9
  68. package/src/do/run-coordinator.ts +451 -76
  69. package/src/do/run-request.ts +2 -0
  70. package/src/do/run-store-test-do.ts +301 -1
  71. package/src/do/run-store.ts +385 -43
  72. package/src/do/secrets-adapter.ts +22 -3
  73. package/src/do/sse-stream.ts +60 -9
  74. package/src/do/transcript.ts +48 -5
  75. package/src/do/turn-body.ts +105 -5
  76. package/src/do/ucan-service.ts +204 -34
  77. package/src/do/user-oracle-do.ts +1883 -264
  78. package/src/feedback/config.ts +127 -0
  79. package/src/feedback/contract.ts +148 -0
  80. package/src/feedback/linear-sink.ts +285 -0
  81. package/src/feedback/privacy.ts +150 -0
  82. package/src/feedback/reservation.ts +298 -0
  83. package/src/feedback/submit.ts +247 -0
  84. package/src/index.ts +138 -6
  85. package/src/llm/byo-adapter.ts +27 -6
  86. package/src/llm/byo-catalog.ts +13 -0
  87. package/src/llm/byo-model-fallback.ts +248 -0
  88. package/src/llm/byo-service.ts +45 -8
  89. package/src/llm/provider-error.ts +139 -2
  90. package/src/matrix/gateway-do.ts +523 -111
  91. package/src/matrix/group-chat.ts +66 -23
  92. package/src/matrix/inbox-store.ts +16 -1
  93. package/src/matrix/ingest.ts +79 -14
  94. package/src/matrix/media-deadline.ts +130 -0
  95. package/src/matrix/replay-format.ts +5 -3
  96. package/src/matrix/reply-parts.ts +92 -0
  97. package/src/matrix/room-alias.ts +96 -0
  98. package/src/matrix/user-homeserver.ts +166 -7
  99. package/src/owner-store/ixo-vfs-store.ts +170 -43
  100. package/src/owner-store/migrating-store.ts +31 -15
  101. package/src/owner-store/types.ts +18 -3
  102. package/src/plugin-api/index.ts +43 -2
  103. package/src/plugin-api/oracle-plugin.ts +35 -0
  104. package/src/plugin-api/request-admission.ts +41 -0
  105. package/src/plugin-api/tool-helper.ts +25 -1
  106. package/src/plugin-api/tool-plane.ts +92 -0
  107. package/src/plugin-api/types.ts +194 -6
  108. package/src/plugins/agui/agui.plugin.ts +31 -16
  109. package/src/plugins/composio/composio-tools.ts +57 -6
  110. package/src/plugins/composio/composio-ucan.ts +3 -2
  111. package/src/plugins/composio/composio.plugin.ts +2 -0
  112. package/src/plugins/delegated-capabilities.ts +25 -0
  113. package/src/plugins/editor/content-session.ts +173 -83
  114. package/src/plugins/editor/content-tools.ts +76 -17
  115. package/src/plugins/editor/editor-mx.ts +28 -9
  116. package/src/plugins/editor/editor.plugin.ts +3 -1
  117. package/src/plugins/editor/failures.ts +28 -7
  118. package/src/plugins/editor/prompts.ts +2 -1
  119. package/src/plugins/editor/provider.ts +553 -40
  120. package/src/plugins/editor/standalone-editor-tool.ts +61 -18
  121. package/src/plugins/firecrawl/firecrawl-tools.ts +13 -5
  122. package/src/plugins/flows/action-metadata.ts +111 -11
  123. package/src/plugins/flows/actions.ts +18 -0
  124. package/src/plugins/flows/decision-context.ts +31 -0
  125. package/src/plugins/flows/edit.ts +223 -52
  126. package/src/plugins/flows/errors.ts +22 -1
  127. package/src/plugins/flows/flow-doc.ts +92 -9
  128. package/src/plugins/flows/flows.plugin.ts +11 -5
  129. package/src/plugins/flows/forms.ts +34 -0
  130. package/src/plugins/flows/index.ts +2 -0
  131. package/src/plugins/flows/input-policy.ts +140 -0
  132. package/src/plugins/flows/prompts.ts +14 -11
  133. package/src/plugins/flows/read.ts +49 -9
  134. package/src/plugins/flows/ref-syntax.ts +129 -0
  135. package/src/plugins/flows/test-homeserver.ts +245 -0
  136. package/src/plugins/flows/tools/authoring.ts +216 -112
  137. package/src/plugins/flows/tools/discovery.ts +4 -1
  138. package/src/plugins/flows/tools/forms.ts +21 -2
  139. package/src/plugins/flows/tools/settings.ts +34 -13
  140. package/src/plugins/flows/translator.ts +13 -10
  141. package/src/plugins/flows/types.ts +167 -42
  142. package/src/plugins/index.ts +11 -1
  143. package/src/plugins/ixo-transaction/chain-ids.ts +43 -0
  144. package/src/plugins/ixo-transaction/index.ts +2 -0
  145. package/src/plugins/ixo-transaction/ixo-transaction.plugin.ts +119 -0
  146. package/src/plugins/ixo-transaction/receipts.ts +99 -0
  147. package/src/plugins/ixo-transaction/tools.ts +329 -0
  148. package/src/plugins/ixo-transaction/wallet-signing.ts +202 -0
  149. package/src/plugins/mcp-tool-adapter.ts +23 -2
  150. package/src/plugins/memory/memory-tools.ts +7 -2
  151. package/src/plugins/memory/memory-ucan.ts +3 -2
  152. package/src/plugins/memory/memory.plugin.ts +2 -0
  153. package/src/plugins/pod-creator/blueprint-store.ts +163 -0
  154. package/src/plugins/pod-creator/blueprint-types.ts +61 -0
  155. package/src/plugins/pod-creator/bounded-map.ts +87 -0
  156. package/src/plugins/pod-creator/capsule-content-client.ts +285 -0
  157. package/src/plugins/pod-creator/chain-gateway.ts +110 -0
  158. package/src/plugins/pod-creator/config.ts +85 -0
  159. package/src/plugins/pod-creator/create-session-store.ts +180 -0
  160. package/src/plugins/pod-creator/create-tools.ts +538 -0
  161. package/src/plugins/pod-creator/design-pod-roles.ts +111 -0
  162. package/src/plugins/pod-creator/index.ts +65 -0
  163. package/src/plugins/pod-creator/orchestration-tools.ts +172 -0
  164. package/src/plugins/pod-creator/pod-creator.plugin.ts +190 -0
  165. package/src/plugins/pod-creator/stage.ts +201 -0
  166. package/src/plugins/pod-creator/sub-agents.ts +189 -0
  167. package/src/plugins/portal/action-log.ts +21 -1
  168. package/src/plugins/portal/declared-tools.ts +90 -0
  169. package/src/plugins/portal/portal.plugin.ts +42 -16
  170. package/src/plugins/sandbox/sandbox-bridge.ts +79 -10
  171. package/src/plugins/sandbox/sandbox-mcp.ts +3 -2
  172. package/src/plugins/sandbox/sandbox-write-blob.ts +11 -1
  173. package/src/plugins/sandbox/sandbox.plugin.ts +8 -16
  174. package/src/plugins/tasks/manifest.ts +1 -1
  175. package/src/plugins/tasks/middleware.ts +20 -7
  176. package/src/plugins/tasks/tasks-tools.ts +30 -3
  177. package/src/plugins/tasks/tasks.plugin.ts +64 -11
  178. package/src/plugins/user-preferences/schema.ts +12 -1
  179. package/src/plugins/user-preferences/user-preferences-store.ts +38 -13
  180. package/src/plugins/user-preferences/user-preferences-tool.ts +3 -2
  181. package/src/plugins/vfs/vfs-client.ts +250 -71
  182. package/src/plugins/vfs/vfs-content.ts +30 -11
  183. package/src/plugins/vfs/vfs-errors.ts +22 -0
  184. package/src/plugins/vfs/vfs-sandbox-tools.ts +12 -5
  185. package/src/plugins/vfs/vfs-tools.ts +60 -20
  186. package/src/realtime/frontend-call-registry.ts +228 -81
  187. package/src/realtime/realtime-endpoint.ts +215 -44
  188. package/src/realtime/session-socket-hub.ts +47 -2
  189. package/src/secrets/secrets-service.ts +9 -3
  190. package/src/shell/app.ts +529 -82
  191. package/src/shell/auth.ts +131 -37
  192. package/src/shell/turn-body-cap.ts +11 -13
  193. package/src/sqlite/blob-compactor.ts +61 -28
  194. package/src/sqlite/database.ts +156 -90
  195. package/src/sqlite/do-vfs.ts +416 -129
  196. package/src/sqlite/page-tier.ts +133 -40
  197. package/src/sqlite/serialization.ts +40 -0
  198. package/src/sqlite/sessions-store.ts +10 -2
  199. package/src/sqlite/sqlite-saver.ts +139 -19
  200. package/src/sqlite/user-kv-store.ts +317 -0
  201. package/src/tasks/schedule.ts +201 -12
  202. package/src/tasks/scheduler.ts +586 -98
  203. package/src/tasks/spec.ts +67 -3
  204. package/src/tasks/store.ts +292 -27
  205. package/src/tasks/topic-deliverables.ts +70 -0
@@ -57,6 +57,7 @@ import {
57
57
  SIGNING_MNEMONIC_STATE_TYPE,
58
58
  } from '../secrets/signing-mnemonic';
59
59
  import {
60
+ attributeUser,
60
61
  IngestPipeline,
61
62
  userDidFromRoomAlias,
62
63
  type InboundAttachment,
@@ -75,6 +76,7 @@ import {
75
76
  MAX_TURN_REPLAYS,
76
77
  planInboxReplay,
77
78
  replyTxnId,
79
+ settleOfferedRow,
78
80
  updateInboxThread,
79
81
  } from './inbox-store';
80
82
  import {
@@ -90,14 +92,48 @@ import {
90
92
  resolveReplyChainRoot,
91
93
  ThreadRootCache,
92
94
  } from './reply-chain';
93
- import { fetchUserMatrixServerName } from './user-homeserver';
95
+ import {
96
+ type CachedUserServerName,
97
+ lookupUserServerName,
98
+ type UserServerNameLookup,
99
+ } from './user-homeserver';
100
+ import { replyPartTxnId, roomReplyMessages } from './reply-parts';
101
+ import { VerifiedRoomAliases } from './room-alias';
102
+ import {
103
+ boundedSignal,
104
+ raceSignal,
105
+ streamWithDeadline,
106
+ } from './media-deadline';
94
107
 
95
108
  const TYPING_REFRESH_MS = 20_000;
96
109
  const TYPING_TIMEOUT_MS = 30_000;
97
- /** User DID → room id memo (aliases never move; a miss re-resolves). */
110
+ /**
111
+ * User DID → room id memo (aliases never move; a miss re-resolves), and the
112
+ * lifetime of a room's verified canonical alias (`room-alias.ts`).
113
+ */
98
114
  const ALIAS_CACHE_TTL_MS = 30 * 60_000;
99
115
  /** User DID → Matrix server name (from the DID document via Blocksync). */
100
116
  const HOMESERVER_CACHE_TTL_MS = 6 * 60 * 60_000;
117
+ /** Entries each in-memory memo in front of the `hs:` / `alias:` storage keys holds. */
118
+ const MEMO_MAX_ENTRIES = 5_000;
119
+ /**
120
+ * The full `status()` is served from memory for this long: it scans several
121
+ * tables, and the route that asks for it is public.
122
+ */
123
+ const STATUS_CACHE_MS = 5_000;
124
+ /**
125
+ * Upper bound on one media download (response and body), unless the caller
126
+ * asks for less. A stalled homeserver stream must not hold the reader — and
127
+ * with it the user's turn — open indefinitely.
128
+ */
129
+ export const MEDIA_DOWNLOAD_TIMEOUT_MS = 60_000;
130
+ /**
131
+ * A live message whose sender's homeserver could not be looked up stays in
132
+ * the inbox and is replayed on its own after this delay (each replay is
133
+ * charged to the row, so a long Blocksync outage ends in the "try again"
134
+ * notice, not a silent drop).
135
+ */
136
+ const SENDER_CHECK_RETRY_MS = 60_000;
101
137
  /**
102
138
  * A room created by a user seconds ago may not have delivered its invite
103
139
  * through sync yet when the user's object asks for its state; within this
@@ -161,9 +197,37 @@ interface AliasCacheEntry {
161
197
  at: number;
162
198
  }
163
199
 
164
- interface HomeserverCacheEntry {
165
- serverName: string;
166
- at: number;
200
+ /** A media download's bound. `signal` only works for a caller in this isolate: an `AbortSignal` cannot cross an RPC boundary. */
201
+ export interface MediaDownloadOptions {
202
+ /** Upper bound on the whole transfer (default `MEDIA_DOWNLOAD_TIMEOUT_MS`). */
203
+ timeoutMs?: number;
204
+ signal?: AbortSignal;
205
+ }
206
+
207
+ /** What `GET /health` on the gateway answers: from memory only, no storage read. */
208
+ export interface GatewayHealth {
209
+ running: boolean;
210
+ }
211
+
212
+ /**
213
+ * A small bounded memo with insertion-order eviction, in front of the
214
+ * gateway's storage keys (the values carry their own timestamps; freshness
215
+ * is the caller's rule, the same one the stored copy is judged by).
216
+ */
217
+ class BoundedMemo<V> {
218
+ private readonly map = new Map<string, V>();
219
+ get(key: string): V | undefined {
220
+ return this.map.get(key);
221
+ }
222
+ set(key: string, value: V): void {
223
+ this.map.delete(key);
224
+ while (this.map.size >= MEMO_MAX_ENTRIES) {
225
+ const oldest = this.map.keys().next().value;
226
+ if (oldest === undefined) break;
227
+ this.map.delete(oldest);
228
+ }
229
+ this.map.set(key, value);
230
+ }
167
231
  }
168
232
 
169
233
  function gateSizeFromEnv(raw: string | undefined, fallback: number): number {
@@ -240,8 +304,33 @@ export class MatrixGatewayDO
240
304
  {
241
305
  private config: OracleGatewayConfig | null = null;
242
306
  private ingest: IngestPipeline | null = null;
243
- /** `m.room.canonical_alias` per room (null = none), read once per instance. */
244
- private readonly roomAliases = new Map<string, string | null>();
307
+ /** How long a row without a verdict waits before it is offered again. */
308
+ protected readonly senderCheckRetryMs: number = SENDER_CHECK_RETRY_MS;
309
+ /**
310
+ * The verified user↔oracle alias per room (`room-alias.ts`): a canonical
311
+ * alias counts only when its homeserver resolves it to that same room.
312
+ */
313
+ private readonly roomAliases = new VerifiedRoomAliases({
314
+ readCanonicalAlias: (roomId) => this.readOracleRoomAlias(roomId),
315
+ resolveAlias: (alias) => this.resolveAlias(alias),
316
+ ttlMs: ALIAS_CACHE_TTL_MS,
317
+ });
318
+ /** In front of `hs:<did>` in storage (same entries, same TTL). */
319
+ private readonly homeserverMemo = new BoundedMemo<CachedUserServerName>();
320
+ /** In front of `alias:<did>` in storage (same entries, same TTL). */
321
+ private readonly roomMemo = new BoundedMemo<AliasCacheEntry>();
322
+ /** The storage-scanning half of `status()`, served for `STATUS_CACHE_MS`. */
323
+ private statusCache: {
324
+ at: number;
325
+ value: Promise<Omit<GatewayStatus, 'turns' | 'ingestPending'>>;
326
+ } | null = null;
327
+ /** Whether the bot was last seen started (set by a start, cleared by `stop`); read by `GET /health`. */
328
+ private botRunning = false;
329
+ /**
330
+ * User DID → registered Matrix server name (lowercased), refreshed before
331
+ * every offer from `userServerName` so the pipeline can check it synchronously.
332
+ */
333
+ private readonly userServers = new Map<string, string>();
245
334
  /** Quote-reply chain → thread root memo (see reply-chain.ts). */
246
335
  private readonly threadRoots = new ThreadRootCache();
247
336
  private readonly turnGate = new Semaphore(
@@ -254,7 +343,16 @@ export class MatrixGatewayDO
254
343
  private inboxTableReady = false;
255
344
  /** Event ids of turns running in this instance (a graceful restart must not replay them). */
256
345
  private readonly inFlightEvents = new Set<string>();
346
+ /**
347
+ * Event ids waiting in the ingest debounce buffer: their turn exists in
348
+ * this instance already, so a start in place (a device rotation) must not
349
+ * replay them. Cleared with the buffers on `stop`, which drops them.
350
+ */
351
+ private readonly debouncing = new Set<string>();
257
352
  private inboxReplayRunning = false;
353
+ /** Inbox rows whose sender check had no verdict, waiting for `unverifiedRetry`. */
354
+ private readonly unverifiedEvents = new Set<string>();
355
+ private unverifiedRetry: ReturnType<typeof setTimeout> | null = null;
258
356
  /** Group rooms: the gate and the per-room channel memory (see group-chat.ts). */
259
357
  private groupChatService: GroupChatService | null = null;
260
358
 
@@ -310,6 +408,23 @@ export class MatrixGatewayDO
310
408
  env.MATRIX_RECYCLE_AFTER_SENDS ?? String(DEFAULT_RECYCLE_AFTER_SENDS),
311
409
  MATRIX_HOT_ROOMS: env.MATRIX_HOT_ROOMS,
312
410
  MATRIX_BACKFILL_MAX_EVENTS: env.MATRIX_BACKFILL_MAX_EVENTS,
411
+ // Memory guards (SDK 0.5+): byte-capped /sync and catch-up pages and
412
+ // an optional ciphertext cap on decryption. Unset = the SDK defaults.
413
+ MATRIX_SYNC_TIMELINE_LIMIT: env.MATRIX_SYNC_TIMELINE_LIMIT,
414
+ MATRIX_SYNC_BYTES_CAP: env.MATRIX_SYNC_BYTES_CAP,
415
+ MATRIX_CATCHUP_PAGE_SIZE: env.MATRIX_CATCHUP_PAGE_SIZE,
416
+ MATRIX_PAGE_BYTES_CAP: env.MATRIX_PAGE_BYTES_CAP,
417
+ MATRIX_MAX_DECRYPT_BYTES: env.MATRIX_MAX_DECRYPT_BYTES,
418
+ // Opt-in bulk restore of the key backup on a device's first start
419
+ // (SDK 0.6+); off by default — the gateway fetches room keys from the
420
+ // backup on demand, one session at a time.
421
+ MATRIX_BACKUP_BULK_RESTORE: env.MATRIX_BACKUP_BULK_RESTORE,
422
+ MATRIX_BACKUP_BULK_RESTORE_MAX_KEYS:
423
+ env.MATRIX_BACKUP_BULK_RESTORE_MAX_KEYS,
424
+ // Keep-alive fuse (SDK 0.7+): the alarm is re-armed a few seconds
425
+ // ahead while the object works, so a host drain brings it back within
426
+ // the fuse instead of at the next cycle.
427
+ MATRIX_KEEPALIVE_FUSE_MS: env.MATRIX_KEEPALIVE_FUSE_MS,
313
428
  LOG_LEVEL: env.LOG_LEVEL,
314
429
  });
315
430
  return {
@@ -330,7 +445,8 @@ export class MatrixGatewayDO
330
445
  this.ingest = new IngestPipeline({
331
446
  oracleDid: cfg.oracleRoomDid,
332
447
  botUserId: cfg.userId,
333
- canonicalAlias: (roomId) => this.roomAliases.get(roomId) ?? null,
448
+ canonicalAlias: (roomId) => this.roomAliases.known(roomId),
449
+ userServerName: (userDid) => this.userServers.get(userDid) ?? null,
334
450
  dispatch: (turn) => this.dispatchTurn(turn),
335
451
  onError: (err, context) =>
336
452
  this.log('error', `ingest failed (${context})`, err),
@@ -370,38 +486,131 @@ export class MatrixGatewayDO
370
486
  // message back if the instance dies before its reply is in the outbox.
371
487
  const sql = this.inboxSql();
372
488
  insertInboxRow(sql, inbound, Date.now());
373
- // Resolved before the offer so the pipeline's synchronous alias lookup
374
- // (room alias → user DID) can answer from the memo.
375
- await this.canonicalAliasOf(message.roomId);
376
- const threadRootId = await this.threadRootFor(
377
- eventId,
378
- message.roomId,
379
- readRelatesTo(message.content),
380
- message.threadRootId,
381
- );
382
- if (threadRootId !== inbound.threadRootId) {
383
- inbound.threadRootId = threadRootId;
384
- updateInboxThread(sql, eventId, threadRootId);
489
+ try {
490
+ // The thread (a quote-reply chain walk) and the sender's identity (the
491
+ // verified alias, then the user's registered homeserver) are
492
+ // independent lookups: they run side by side.
493
+ const [alias, threadRootId] = await Promise.all([
494
+ this.attributionFor(inbound),
495
+ this.threadRootFor(
496
+ eventId,
497
+ message.roomId,
498
+ readRelatesTo(message.content),
499
+ message.threadRootId,
500
+ ),
501
+ ]);
502
+ if (threadRootId !== inbound.threadRootId) {
503
+ inbound.threadRootId = threadRootId;
504
+ updateInboxThread(sql, eventId, threadRootId);
505
+ }
506
+ this.offerInbound(inbound, alias);
507
+ } catch (err) {
508
+ // A homeserver error while preparing the offer (the alias lookup, a
509
+ // rate limit) is not a verdict on the message. The SDK has already
510
+ // marked the event processed, so the row comes back on its own timer.
511
+ this.log(
512
+ 'warn',
513
+ `ingest: could not prepare ${eventId} in ${message.roomId}; keeping it in the inbox for a replay`,
514
+ err,
515
+ );
516
+ this.retryUnverifiedLater(eventId);
385
517
  }
386
- this.offerInbound(inbound);
387
518
  }
388
519
 
389
- /** Feed a message into the ingest pipeline; a message it will not turn into a turn leaves the inbox at once. */
390
- private offerInbound(inbound: InboundMessage): void {
391
- const outcome = this.ingestPipeline().offer(inbound);
392
- if (outcome !== 'queued') {
393
- deleteInboxRows(this.inboxSql(), [inbound.eventId]);
520
+ /**
521
+ * Feed a message into the ingest pipeline. A message it will not turn into
522
+ * a turn leaves the inbox at once; one whose sender could not be checked
523
+ * stays there and is replayed after `SENDER_CHECK_RETRY_MS`.
524
+ */
525
+ private offerInbound(inbound: InboundMessage, alias: string | null): void {
526
+ const outcome = this.ingestPipeline().offer(inbound, alias);
527
+ const row = settleOfferedRow(this.inboxSql(), inbound.eventId, outcome);
528
+ if (outcome === 'queued') this.debouncing.add(inbound.eventId);
529
+ else if (outcome === 'unverified') {
394
530
  this.log(
395
- 'debug',
396
- `ingest dropped ${inbound.eventId} in ${inbound.roomId}: ${outcome}`,
531
+ 'warn',
532
+ `ingest: no homeserver verdict for ${inbound.sender}; keeping ${inbound.eventId} in ${inbound.roomId} for a replay`,
533
+ );
534
+ this.retryUnverifiedLater(inbound.eventId);
535
+ } else if (row === 'deleted') {
536
+ this.log(
537
+ // A DID-shaped identity from a server that is not the DID's own is
538
+ // someone presenting a DID they do not control.
539
+ outcome === 'foreign' ? 'warn' : 'debug',
540
+ `ingest dropped ${inbound.eventId} in ${inbound.roomId} from ${inbound.sender}: ${outcome}`,
541
+ );
542
+ }
543
+ }
544
+
545
+ /** Replay the unverified rows once `SENDER_CHECK_RETRY_MS` has passed (one timer for all of them). */
546
+ private retryUnverifiedLater(eventId: string): void {
547
+ this.unverifiedEvents.add(eventId);
548
+ if (this.unverifiedRetry) return;
549
+ this.unverifiedRetry = setTimeout(() => {
550
+ this.unverifiedRetry = null;
551
+ const only = new Set(this.unverifiedEvents);
552
+ this.unverifiedEvents.clear();
553
+ this.scheduleInboxReplay(only);
554
+ }, this.senderCheckRetryMs);
555
+ }
556
+
557
+ /**
558
+ * The room's verified alias, with the homeserver of the user the message
559
+ * is then attributed to looked up for the sender check. The alias is
560
+ * handed on as checked here: re-reading the memo later could find it
561
+ * expired or invalidated meanwhile, and a legacy sender would read as
562
+ * unmapped. Throws when the alias could not be checked.
563
+ */
564
+ private async attributionFor(
565
+ inbound: InboundMessage,
566
+ ): Promise<string | null> {
567
+ const alias = await this.canonicalAliasOf(inbound.roomId);
568
+ await this.resolveSenderServer(inbound, alias);
569
+ return alias;
570
+ }
571
+
572
+ /**
573
+ * Look up the registered homeserver of the user a message is attributed
574
+ * to, for the pipeline's sender check. A DID document that names no
575
+ * homeserver answers the oracle's own server. A Blocksync failure answers
576
+ * the last cached server even when it has expired; with nothing cached the
577
+ * memo is cleared, so the pipeline gives no verdict (`'unverified'`) and
578
+ * the message stays in the inbox instead of being dropped as foreign.
579
+ */
580
+ private async resolveSenderServer(
581
+ inbound: InboundMessage,
582
+ alias: string | null,
583
+ ): Promise<void> {
584
+ const attribution = attributeUser({
585
+ alias,
586
+ sender: inbound.sender,
587
+ oracleDid: this.cfg().oracleRoomDid,
588
+ });
589
+ if (!attribution) return;
590
+ try {
591
+ const { serverName } = await this.lookupUserServer(attribution.userDid);
592
+ this.userServers.set(attribution.userDid, serverName.toLowerCase());
593
+ } catch (err) {
594
+ this.userServers.delete(attribution.userDid);
595
+ this.log(
596
+ 'warn',
597
+ `could not resolve ${attribution.userDid}'s homeserver from Blocksync and nothing is cached; ${inbound.eventId} waits for a replay`,
598
+ err,
397
599
  );
398
600
  }
399
601
  }
400
602
 
401
- /** A membership change invalidates what the group-chat gate knows about the room. */
603
+ /**
604
+ * A membership change invalidates what the group-chat gate knows about the
605
+ * room; an alias change also invalidates the room's verified alias (the
606
+ * gate's "direct" verdict rests on it too).
607
+ */
402
608
  protected override async onEvent(event: BotTimelineEvent): Promise<void> {
403
609
  await super.onEvent(event);
404
- if (event.type === 'm.room.member')
610
+ if (event.type === 'm.room.canonical_alias') {
611
+ this.roomAliases.invalidate(event.roomId);
612
+ this.groupChat().invalidateRoom(event.roomId);
613
+ } else if (event.type === 'm.room.member')
405
614
  this.groupChat().invalidateRoom(event.roomId);
406
615
  }
407
616
 
@@ -498,10 +707,18 @@ export class MatrixGatewayDO
498
707
  return this.threadRootFor(row.eventId, row.roomId, relatesTo);
499
708
  }
500
709
 
501
- /** `m.room.canonical_alias` of a room, memoised per instance (null = none). */
710
+ /**
711
+ * The room's verified user↔oracle alias (null = none): its canonical alias,
712
+ * when that is an alias of this oracle AND its homeserver resolves it to
713
+ * this room. Memoised (`room-alias.ts`). Throws when the homeserver could
714
+ * not be asked — no verdict, never "accept".
715
+ */
502
716
  private async canonicalAliasOf(roomId: string): Promise<string | null> {
503
- const memo = this.roomAliases.get(roomId);
504
- if (memo !== undefined) return memo;
717
+ return this.roomAliases.verify(roomId);
718
+ }
719
+
720
+ /** `m.room.canonical_alias` of a room when it is a user↔oracle alias of this oracle. */
721
+ private async readOracleRoomAlias(roomId: string): Promise<string | null> {
505
722
  const json = await this.getRoomStateEvent(roomId, 'm.room.canonical_alias');
506
723
  const parsed: unknown = json ? JSON.parse(json) : null;
507
724
  const alias =
@@ -512,12 +729,25 @@ export class MatrixGatewayDO
512
729
  parsed.alias
513
730
  ? parsed.alias
514
731
  : null;
515
- this.roomAliases.set(roomId, alias);
516
- return alias;
732
+ return alias !== null &&
733
+ userDidFromRoomAlias(alias, this.cfg().oracleRoomDid) !== null
734
+ ? alias
735
+ : null;
517
736
  }
518
737
 
519
738
  private async dispatchTurn(turn: IngestTurn): Promise<void> {
520
- return this.turnGate.run(() => this.dispatchTurnNow(turn));
739
+ // In flight from the moment the turn exists, not only once it holds a
740
+ // gate slot: an in-place restart replays the inbox, and a row still
741
+ // queued behind the gate must not be offered (and charged) again.
742
+ for (const id of turn.eventIds) {
743
+ this.debouncing.delete(id);
744
+ this.inFlightEvents.add(id);
745
+ }
746
+ try {
747
+ await this.turnGate.run(() => this.dispatchTurnNow(turn));
748
+ } finally {
749
+ for (const id of turn.eventIds) this.inFlightEvents.delete(id);
750
+ }
521
751
  }
522
752
 
523
753
  private async dispatchTurnNow(turn: IngestTurn): Promise<void> {
@@ -563,10 +793,14 @@ export class MatrixGatewayDO
563
793
  'info',
564
794
  `turn ${requestId}: ${turn.userDid} in ${turn.roomId} (thread ${turn.threadId}${decision.roomKind === 'group' ? `, group room: ${decision.reason}` : ''})`,
565
795
  );
566
- for (const id of turn.eventIds) this.inFlightEvents.add(id);
567
796
  let typing: ReturnType<typeof setInterval> | null = null;
568
797
  try {
569
- await this.setTyping(turn.roomId, true, TYPING_TIMEOUT_MS);
798
+ // Cosmetic: a homeserver hiccup on the typing notice must not cost the
799
+ // user their turn.
800
+ await this.setTyping(turn.roomId, true, TYPING_TIMEOUT_MS).catch(
801
+ (err: unknown) =>
802
+ this.log('debug', `turn ${requestId}: typing notice failed`, err),
803
+ );
570
804
  typing = setInterval(() => {
571
805
  this.setTyping(turn.roomId, true, TYPING_TIMEOUT_MS).catch(
572
806
  () => undefined,
@@ -578,19 +812,30 @@ export class MatrixGatewayDO
578
812
  'info',
579
813
  `turn ${requestId}: reply served from the user object's ledger (turn had already finished)`,
580
814
  );
581
- if (result.text.trim()) {
582
- // The transaction id is derived from the event id, so this reply is
583
- // deduplicated by the homeserver if a previous incarnation already
584
- // sent it before it died (see inbox-store.ts).
585
- // Always inside the thread: the reply to a bare message opens the
586
- // thread on it (Node's listener bridge does the same).
587
- await this.sendText(turn.roomId, result.text, {
815
+ const messages = roomReplyMessages(result);
816
+ if (messages.length === 0)
817
+ this.log('info', `turn ${requestId}: empty reply, nothing sent`);
818
+ // Transaction ids are derived from the event id (one per plan part), so
819
+ // a reply is deduplicated by the homeserver if a previous incarnation
820
+ // already sent it before it died (see inbox-store.ts). Always inside the
821
+ // thread: the reply to a bare message opens the thread on it (Node's
822
+ // listener bridge does the same).
823
+ const eventId = turn.eventIds[0];
824
+ for (const message of messages)
825
+ await this.sendText(turn.roomId, message.body, {
588
826
  threadId: turn.threadId,
589
- ...(turn.eventIds[0] ? { txnId: replyTxnId(turn.eventIds[0]) } : {}),
827
+ ...(message.formattedBody
828
+ ? { formattedBody: message.formattedBody }
829
+ : {}),
830
+ ...(eventId
831
+ ? {
832
+ txnId:
833
+ message.partId !== undefined
834
+ ? replyPartTxnId(eventId, message.partId)
835
+ : replyTxnId(eventId),
836
+ }
837
+ : {}),
590
838
  });
591
- } else {
592
- this.log('info', `turn ${requestId}: empty reply, nothing sent`);
593
- }
594
839
  // The reply is in the durable outbox (or there is none): the turn
595
840
  // has ended, the inbox row has done its job.
596
841
  deleteInboxRows(this.inboxSql(), turn.eventIds);
@@ -625,23 +870,29 @@ export class MatrixGatewayDO
625
870
  // Told the user (or could not): either way this turn is over.
626
871
  deleteInboxRows(this.inboxSql(), turn.eventIds);
627
872
  } finally {
628
- for (const id of turn.eventIds) this.inFlightEvents.delete(id);
629
873
  if (typing) clearInterval(typing);
630
874
  await this.setTyping(turn.roomId, false).catch(() => undefined);
631
875
  // Close the card on every exit — reply posted, empty reply, thrown
632
876
  // error — so no turn can leave a spinner running in the room.
633
- await this.userStub(turn.userDid)
634
- .finishWorkStatus(requestId, 'done')
635
- .catch((err: unknown) =>
877
+ await this.finishWorkStatus(turn.userDid, requestId).catch(
878
+ (err: unknown) =>
636
879
  this.log(
637
880
  'warn',
638
881
  `turn ${requestId}: could not close the status card`,
639
882
  err,
640
883
  ),
641
- );
884
+ );
642
885
  }
643
886
  }
644
887
 
888
+ /** Close the turn's work-status card in the user's object. */
889
+ protected async finishWorkStatus(
890
+ userDid: string,
891
+ requestId: string,
892
+ ): Promise<void> {
893
+ await this.userStub(userDid).finishWorkStatus(requestId, 'done');
894
+ }
895
+
645
896
  private userStub(userDid: string): DurableObjectStub<UserOracleObject> {
646
897
  return this.env.USER_ORACLE.get(
647
898
  this.env.USER_ORACLE.idFromName(
@@ -685,20 +936,38 @@ export class MatrixGatewayDO
685
936
  }
686
937
 
687
938
  /**
688
- * Every start of this instance — first request, keep-alive alarm, RPC —
689
- * goes through here. A start that actually brought the bot up is the
939
+ * After every successful start of this instance — a request, the
940
+ * keep-alive alarm bringing it back after a reset, a device rotation
941
+ * (the SDK's `onStarted` hook, detached from the caller). That is the
690
942
  * moment to re-dispatch the turns a previous incarnation left unfinished.
691
943
  */
944
+ protected override async onStarted(_result: StartResult): Promise<void> {
945
+ this.noteRunning(true);
946
+ this.scheduleInboxReplay();
947
+ }
948
+
692
949
  override async ensureStarted(): Promise<StartResult> {
693
950
  const result = await super.ensureStarted();
694
- if (result.started) this.scheduleInboxReplay();
951
+ this.noteRunning(true);
695
952
  return result;
696
953
  }
697
954
 
698
- private scheduleInboxReplay(): void {
699
- if (this.inboxReplayRunning) return;
955
+ /** A lifecycle change: `GET /health` follows it, the cached status is stale. */
956
+ private noteRunning(running: boolean): void {
957
+ if (this.botRunning !== running) this.statusCache = null;
958
+ this.botRunning = running;
959
+ }
960
+
961
+ /** `only`: replay just these rows (the unverified-sender retry); omitted, every row. */
962
+ private scheduleInboxReplay(only?: ReadonlySet<string>): void {
963
+ if (this.inboxReplayRunning) {
964
+ // A full replay already listed every row; a targeted one may not have,
965
+ // so the rows asked for here wait for the next retry.
966
+ if (only) for (const id of only) this.retryUnverifiedLater(id);
967
+ return;
968
+ }
700
969
  this.inboxReplayRunning = true;
701
- void this.replayInbox()
970
+ void this.replayInbox(only)
702
971
  .catch((err: unknown) => this.log('error', 'inbox replay failed', err))
703
972
  .finally(() => {
704
973
  this.inboxReplayRunning = false;
@@ -712,12 +981,14 @@ export class MatrixGatewayDO
712
981
  * object decides what a replay means for it (`matrix-turn-ledger.ts`):
713
982
  * stored reply, attach to the running turn, or refuse — never a second run.
714
983
  */
715
- private async replayInbox(): Promise<void> {
984
+ private async replayInbox(only?: ReadonlySet<string>): Promise<void> {
716
985
  const sql = this.inboxSql();
717
- const rows = listInboxRows(sql);
986
+ const rows = listInboxRows(sql).filter(
987
+ (row) => !only || only.has(row.eventId),
988
+ );
718
989
  if (rows.length === 0) return;
719
990
  const plan = planInboxReplay(rows, {
720
- inFlight: this.inFlightEvents,
991
+ inFlight: new Set([...this.inFlightEvents, ...this.debouncing]),
721
992
  maxReplays: MAX_TURN_REPLAYS,
722
993
  });
723
994
  for (const row of plan.exhausted) {
@@ -746,17 +1017,30 @@ export class MatrixGatewayDO
746
1017
  );
747
1018
  for (const row of plan.replay) {
748
1019
  try {
749
- await this.canonicalAliasOf(row.roomId);
1020
+ // Without an alias verdict a legacy sender would read as unmapped
1021
+ // and the row would be dropped: the row waits for the next retry
1022
+ // instead (its attempts were bumped above, so an outage still ends
1023
+ // in the "try again" notice).
1024
+ // A failed homeserver lookup leaves no verdict: the offer answers
1025
+ // 'unverified' and the row stays for the next replay.
1026
+ const inbound = inboundOfRow(row);
1027
+ const [alias, threadRootId] = await Promise.all([
1028
+ this.attributionFor(inbound),
1029
+ this.threadRootForRow(row),
1030
+ ]);
1031
+ if (threadRootId !== inbound.threadRootId) {
1032
+ inbound.threadRootId = threadRootId;
1033
+ updateInboxThread(sql, row.eventId, threadRootId);
1034
+ }
1035
+ this.offerInbound(inbound, alias);
750
1036
  } catch (err) {
751
- this.log('warn', `inbox: alias lookup failed for ${row.roomId}`, err);
752
- }
753
- const inbound = inboundOfRow(row);
754
- const threadRootId = await this.threadRootForRow(row);
755
- if (threadRootId !== inbound.threadRootId) {
756
- inbound.threadRootId = threadRootId;
757
- updateInboxThread(sql, row.eventId, threadRootId);
1037
+ this.log(
1038
+ 'warn',
1039
+ `inbox: could not prepare ${row.eventId} in ${row.roomId}; retrying later`,
1040
+ err,
1041
+ );
1042
+ this.retryUnverifiedLater(row.eventId);
758
1043
  }
759
- this.offerInbound(inbound);
760
1044
  }
761
1045
  this.log(
762
1046
  'info',
@@ -780,6 +1064,11 @@ export class MatrixGatewayDO
780
1064
  // A debounce buffer firing after the stop would send through
781
1065
  // `startedClient()`, which restarts the bot.
782
1066
  this.ingest?.clear();
1067
+ this.debouncing.clear();
1068
+ if (this.unverifiedRetry) clearTimeout(this.unverifiedRetry);
1069
+ this.unverifiedRetry = null;
1070
+ this.unverifiedEvents.clear();
1071
+ this.noteRunning(false);
783
1072
  await super.stop();
784
1073
  }
785
1074
 
@@ -787,36 +1076,66 @@ export class MatrixGatewayDO
787
1076
  // Rooms
788
1077
  // -------------------------------------------------------------------------
789
1078
 
790
- private async userServerName(userDid: string): Promise<string> {
1079
+ /**
1080
+ * The user's registered homeserver (`lookupUserServerName`: a 6 h cache
1081
+ * over Blocksync, an expired entry when Blocksync fails). Throws when
1082
+ * Blocksync fails and nothing was ever cached.
1083
+ */
1084
+ private async lookupUserServer(
1085
+ userDid: string,
1086
+ ): Promise<UserServerNameLookup> {
791
1087
  const cfg = this.cfg();
792
1088
  const cacheKey = `hs:${userDid}`;
793
- const cached = await this.ctx.storage.get<HomeserverCacheEntry>(cacheKey);
794
- if (cached && Date.now() - cached.at < HOMESERVER_CACHE_TTL_MS) {
795
- return cached.serverName;
796
- }
797
- const blocksync = this.env.BLOCKSYNC_GRAPHQL_URL;
798
- if (!blocksync) return cfg.serverName;
1089
+ const lookup = await lookupUserServerName(userDid, {
1090
+ blocksyncGraphqlUrl: this.env.BLOCKSYNC_GRAPHQL_URL,
1091
+ defaultServerName: cfg.serverName,
1092
+ ttlMs: HOMESERVER_CACHE_TTL_MS,
1093
+ readCache: async () => {
1094
+ const memo = this.homeserverMemo.get(userDid);
1095
+ if (memo) return memo;
1096
+ const stored =
1097
+ await this.ctx.storage.get<CachedUserServerName>(cacheKey);
1098
+ if (stored) this.homeserverMemo.set(userDid, stored);
1099
+ return stored;
1100
+ },
1101
+ writeCache: async (_did, entry) => {
1102
+ this.homeserverMemo.set(userDid, entry);
1103
+ await this.ctx.storage.put(cacheKey, entry);
1104
+ },
1105
+ });
1106
+ if (lookup.source === 'stale')
1107
+ this.log(
1108
+ 'warn',
1109
+ `could not refresh ${userDid}'s homeserver from Blocksync; using the expired cached ${lookup.serverName}`,
1110
+ lookup.error,
1111
+ );
1112
+ else if (
1113
+ lookup.source === 'blocksync' &&
1114
+ lookup.serverName !== cfg.serverName
1115
+ )
1116
+ this.log(
1117
+ 'info',
1118
+ `user ${userDid} lives on ${lookup.serverName} (oracle is on ${cfg.serverName}); room alias uses the user's server`,
1119
+ );
1120
+ return lookup;
1121
+ }
1122
+
1123
+ /**
1124
+ * The server half of the user's room alias. When the lookup has no answer
1125
+ * the alias is tried on the oracle's own server — a wrong guess only
1126
+ * misses the room — unlike the sender check, which gives no verdict then.
1127
+ */
1128
+ private async userServerName(userDid: string): Promise<string> {
799
1129
  try {
800
- const resolved = await fetchUserMatrixServerName(blocksync, userDid);
801
- if (!resolved) return cfg.serverName;
802
- await this.ctx.storage.put(cacheKey, {
803
- serverName: resolved,
804
- at: Date.now(),
805
- } satisfies HomeserverCacheEntry);
806
- if (resolved !== cfg.serverName) {
807
- this.log(
808
- 'info',
809
- `user ${userDid} lives on ${resolved} (oracle is on ${cfg.serverName}); room alias uses the user's server`,
810
- );
811
- }
812
- return resolved;
1130
+ return (await this.lookupUserServer(userDid)).serverName;
813
1131
  } catch (err) {
1132
+ const fallback = this.cfg().serverName;
814
1133
  this.log(
815
1134
  'warn',
816
- `could not resolve ${userDid}'s homeserver from Blocksync; using ${cfg.serverName}`,
1135
+ `could not resolve ${userDid}'s homeserver from Blocksync; using ${fallback} for the room alias`,
817
1136
  err,
818
1137
  );
819
- return cfg.serverName;
1138
+ return fallback;
820
1139
  }
821
1140
  }
822
1141
 
@@ -829,21 +1148,24 @@ export class MatrixGatewayDO
829
1148
  await this.userServerName(userDid),
830
1149
  );
831
1150
  const cacheKey = `alias:${userDid}`;
1151
+ const fresh = (
1152
+ entry: AliasCacheEntry | undefined,
1153
+ ): entry is AliasCacheEntry =>
1154
+ entry !== undefined &&
1155
+ entry.alias === alias &&
1156
+ Date.now() - entry.at < ALIAS_CACHE_TTL_MS;
1157
+ const memo = this.roomMemo.get(userDid);
1158
+ if (fresh(memo)) return { roomId: memo.roomId, alias };
832
1159
  const cached = await this.ctx.storage.get<AliasCacheEntry>(cacheKey);
833
- if (
834
- cached &&
835
- cached.alias === alias &&
836
- Date.now() - cached.at < ALIAS_CACHE_TTL_MS
837
- ) {
1160
+ if (fresh(cached)) {
1161
+ this.roomMemo.set(userDid, cached);
838
1162
  return { roomId: cached.roomId, alias };
839
1163
  }
840
1164
  const roomId = await this.resolveAlias(alias);
841
1165
  if (!roomId) return null;
842
- await this.ctx.storage.put(cacheKey, {
843
- roomId,
844
- alias,
845
- at: Date.now(),
846
- } satisfies AliasCacheEntry);
1166
+ const entry: AliasCacheEntry = { roomId, alias, at: Date.now() };
1167
+ this.roomMemo.set(userDid, entry);
1168
+ await this.ctx.storage.put(cacheKey, entry);
847
1169
  return { roomId, alias };
848
1170
  }
849
1171
 
@@ -1156,13 +1478,58 @@ export class MatrixGatewayDO
1156
1478
  async downloadEventMediaStream(
1157
1479
  roomId: string,
1158
1480
  eventId: string,
1481
+ opts: MediaDownloadOptions = {},
1159
1482
  ): Promise<MediaStream | null> {
1160
- return this.mediaStream(roomId, eventId);
1483
+ const bound = boundedSignal(
1484
+ opts.timeoutMs ?? MEDIA_DOWNLOAD_TIMEOUT_MS,
1485
+ opts.signal,
1486
+ );
1487
+ let media: MediaStream | null;
1488
+ try {
1489
+ // The SDK opens the download without a signal: race it, and release a
1490
+ // stream that arrives after the bound.
1491
+ media = await raceSignal(
1492
+ this.mediaStream(roomId, eventId),
1493
+ bound.signal,
1494
+ (late) => void late?.stream.cancel().catch(() => undefined),
1495
+ );
1496
+ } catch (err) {
1497
+ bound.clear();
1498
+ throw err;
1499
+ }
1500
+ if (!media) {
1501
+ bound.clear();
1502
+ return null;
1503
+ }
1504
+ return { ...media, stream: streamWithDeadline(media.stream, bound) };
1161
1505
  }
1162
1506
 
1163
- /** Authenticated download of an `mxc://` URI as a stream (`/_matrix/client/v1/media/download`, legacy fallback on 404). */
1507
+ /**
1508
+ * Authenticated download of an `mxc://` URI as a stream
1509
+ * (`/_matrix/client/v1/media/download`, legacy fallback on 404), bounded
1510
+ * by `opts.timeoutMs` (default `MEDIA_DOWNLOAD_TIMEOUT_MS`) from the
1511
+ * request to the last byte.
1512
+ */
1164
1513
  async downloadMxcMediaStream(
1165
1514
  mxc: string,
1515
+ opts: MediaDownloadOptions = {},
1516
+ ): Promise<ReadableStream<Uint8Array>> {
1517
+ const bound = boundedSignal(
1518
+ opts.timeoutMs ?? MEDIA_DOWNLOAD_TIMEOUT_MS,
1519
+ opts.signal,
1520
+ );
1521
+ try {
1522
+ const body = await this.fetchMxc(mxc, bound.signal);
1523
+ return streamWithDeadline(body, bound);
1524
+ } catch (err) {
1525
+ bound.clear();
1526
+ throw err;
1527
+ }
1528
+ }
1529
+
1530
+ private async fetchMxc(
1531
+ mxc: string,
1532
+ signal: AbortSignal,
1166
1533
  ): Promise<ReadableStream<Uint8Array>> {
1167
1534
  const client = await this.startedClient();
1168
1535
  const token = client.getAccessToken();
@@ -1179,7 +1546,7 @@ export class MatrixGatewayDO
1179
1546
  true,
1180
1547
  );
1181
1548
  if (!authed) throw new Error(`MatrixGatewayDO: invalid mxc url ${mxc}`);
1182
- let res = await fetch(authed, { headers });
1549
+ let res = await fetch(authed, { headers, signal });
1183
1550
  if (res.status === 404) {
1184
1551
  const legacy = client.mxcUrlToHttp(
1185
1552
  mxc,
@@ -1190,12 +1557,17 @@ export class MatrixGatewayDO
1190
1557
  true,
1191
1558
  false,
1192
1559
  );
1193
- if (legacy) res = await fetch(legacy, { headers });
1560
+ if (legacy) {
1561
+ await res.body?.cancel().catch(() => undefined);
1562
+ res = await fetch(legacy, { headers, signal });
1563
+ }
1194
1564
  }
1195
- if (!res.ok)
1565
+ if (!res.ok) {
1566
+ await res.body?.cancel().catch(() => undefined);
1196
1567
  throw new Error(
1197
1568
  `MatrixGatewayDO: media download failed: ${res.status} ${res.statusText}`,
1198
1569
  );
1570
+ }
1199
1571
  if (!res.body)
1200
1572
  throw new Error('MatrixGatewayDO: media download has no body');
1201
1573
  return res.body;
@@ -1358,15 +1730,55 @@ export class MatrixGatewayDO
1358
1730
  return this.groupChat().unpinFact(roomId, factId);
1359
1731
  }
1360
1732
 
1733
+ /**
1734
+ * The bot's status. The half that scans storage (the SDK's room, outbox
1735
+ * and catch-up counts, crypto key counts, channel memory, the inbox) is
1736
+ * computed at most once per `STATUS_CACHE_MS` and whenever the bot starts
1737
+ * or stops; the in-memory turn counters are always live.
1738
+ */
1361
1739
  override async status(): Promise<GatewayStatus> {
1740
+ const now = Date.now();
1741
+ if (!this.statusCache || now - this.statusCache.at >= STATUS_CACHE_MS) {
1742
+ const value = this.scanStatus();
1743
+ this.statusCache = { at: now, value };
1744
+ // A failed scan is not served to the next caller.
1745
+ value.catch(() => {
1746
+ if (this.statusCache?.value === value) this.statusCache = null;
1747
+ });
1748
+ }
1362
1749
  return {
1363
- ...(await super.status()),
1750
+ ...(await this.statusCache.value),
1364
1751
  turns: { inFlight: this.turnGate.inUse, waiting: this.turnGate.queued },
1365
- groupChat: this.groupChat().stats(),
1366
1752
  ingestPending: this.ingest?.pendingCount ?? 0,
1753
+ };
1754
+ }
1755
+
1756
+ private async scanStatus(): Promise<
1757
+ Omit<GatewayStatus, 'turns' | 'ingestPending'>
1758
+ > {
1759
+ return {
1760
+ ...(await super.status()),
1761
+ groupChat: this.groupChat().stats(),
1367
1762
  inbox: countInboxRows(this.inboxSql()),
1368
1763
  };
1369
1764
  }
1765
+
1766
+ /** Whether the bot is running, from memory only (no storage read, no start). */
1767
+ health(): GatewayHealth {
1768
+ return { running: this.botRunning };
1769
+ }
1770
+
1771
+ /**
1772
+ * The gateway's HTTP surface: `GET /health` answers `health()` — what the
1773
+ * shell's public `GET /health/matrix` asks, at the price of a request to
1774
+ * this object and nothing more.
1775
+ */
1776
+ async fetch(request: Request): Promise<Response> {
1777
+ const { pathname } = new URL(request.url);
1778
+ if (request.method === 'GET' && pathname === '/health')
1779
+ return Response.json(this.health());
1780
+ return new Response('Not found', { status: 404 });
1781
+ }
1370
1782
  }
1371
1783
 
1372
1784
  /** Attachment descriptor of a media message for the ingest (Node bridge shape). */