@ixo/oracle-runtime-workers 0.13.0 → 0.16.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 +13 -8
  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 +310 -0
  18. package/src/channels/contract.ts +246 -0
  19. package/src/channels/turns.ts +280 -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 +170 -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 +2003 -274
  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 +504 -108
  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 +562 -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
 
@@ -347,7 +445,8 @@ export class MatrixGatewayDO
347
445
  this.ingest = new IngestPipeline({
348
446
  oracleDid: cfg.oracleRoomDid,
349
447
  botUserId: cfg.userId,
350
- canonicalAlias: (roomId) => this.roomAliases.get(roomId) ?? null,
448
+ canonicalAlias: (roomId) => this.roomAliases.known(roomId),
449
+ userServerName: (userDid) => this.userServers.get(userDid) ?? null,
351
450
  dispatch: (turn) => this.dispatchTurn(turn),
352
451
  onError: (err, context) =>
353
452
  this.log('error', `ingest failed (${context})`, err),
@@ -387,38 +486,131 @@ export class MatrixGatewayDO
387
486
  // message back if the instance dies before its reply is in the outbox.
388
487
  const sql = this.inboxSql();
389
488
  insertInboxRow(sql, inbound, Date.now());
390
- // Resolved before the offer so the pipeline's synchronous alias lookup
391
- // (room alias → user DID) can answer from the memo.
392
- await this.canonicalAliasOf(message.roomId);
393
- const threadRootId = await this.threadRootFor(
394
- eventId,
395
- message.roomId,
396
- readRelatesTo(message.content),
397
- message.threadRootId,
398
- );
399
- if (threadRootId !== inbound.threadRootId) {
400
- inbound.threadRootId = threadRootId;
401
- 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);
402
517
  }
403
- this.offerInbound(inbound);
404
518
  }
405
519
 
406
- /** Feed a message into the ingest pipeline; a message it will not turn into a turn leaves the inbox at once. */
407
- private offerInbound(inbound: InboundMessage): void {
408
- const outcome = this.ingestPipeline().offer(inbound);
409
- if (outcome !== 'queued') {
410
- 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') {
411
530
  this.log(
412
- 'debug',
413
- `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,
414
599
  );
415
600
  }
416
601
  }
417
602
 
418
- /** 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
+ */
419
608
  protected override async onEvent(event: BotTimelineEvent): Promise<void> {
420
609
  await super.onEvent(event);
421
- 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')
422
614
  this.groupChat().invalidateRoom(event.roomId);
423
615
  }
424
616
 
@@ -515,10 +707,18 @@ export class MatrixGatewayDO
515
707
  return this.threadRootFor(row.eventId, row.roomId, relatesTo);
516
708
  }
517
709
 
518
- /** `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
+ */
519
716
  private async canonicalAliasOf(roomId: string): Promise<string | null> {
520
- const memo = this.roomAliases.get(roomId);
521
- 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> {
522
722
  const json = await this.getRoomStateEvent(roomId, 'm.room.canonical_alias');
523
723
  const parsed: unknown = json ? JSON.parse(json) : null;
524
724
  const alias =
@@ -529,12 +729,25 @@ export class MatrixGatewayDO
529
729
  parsed.alias
530
730
  ? parsed.alias
531
731
  : null;
532
- this.roomAliases.set(roomId, alias);
533
- return alias;
732
+ return alias !== null &&
733
+ userDidFromRoomAlias(alias, this.cfg().oracleRoomDid) !== null
734
+ ? alias
735
+ : null;
534
736
  }
535
737
 
536
738
  private async dispatchTurn(turn: IngestTurn): Promise<void> {
537
- 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
+ }
538
751
  }
539
752
 
540
753
  private async dispatchTurnNow(turn: IngestTurn): Promise<void> {
@@ -580,10 +793,14 @@ export class MatrixGatewayDO
580
793
  'info',
581
794
  `turn ${requestId}: ${turn.userDid} in ${turn.roomId} (thread ${turn.threadId}${decision.roomKind === 'group' ? `, group room: ${decision.reason}` : ''})`,
582
795
  );
583
- for (const id of turn.eventIds) this.inFlightEvents.add(id);
584
796
  let typing: ReturnType<typeof setInterval> | null = null;
585
797
  try {
586
- 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
+ );
587
804
  typing = setInterval(() => {
588
805
  this.setTyping(turn.roomId, true, TYPING_TIMEOUT_MS).catch(
589
806
  () => undefined,
@@ -595,19 +812,30 @@ export class MatrixGatewayDO
595
812
  'info',
596
813
  `turn ${requestId}: reply served from the user object's ledger (turn had already finished)`,
597
814
  );
598
- if (result.text.trim()) {
599
- // The transaction id is derived from the event id, so this reply is
600
- // deduplicated by the homeserver if a previous incarnation already
601
- // sent it before it died (see inbox-store.ts).
602
- // Always inside the thread: the reply to a bare message opens the
603
- // thread on it (Node's listener bridge does the same).
604
- 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, {
605
826
  threadId: turn.threadId,
606
- ...(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
+ : {}),
607
838
  });
608
- } else {
609
- this.log('info', `turn ${requestId}: empty reply, nothing sent`);
610
- }
611
839
  // The reply is in the durable outbox (or there is none): the turn
612
840
  // has ended, the inbox row has done its job.
613
841
  deleteInboxRows(this.inboxSql(), turn.eventIds);
@@ -642,23 +870,29 @@ export class MatrixGatewayDO
642
870
  // Told the user (or could not): either way this turn is over.
643
871
  deleteInboxRows(this.inboxSql(), turn.eventIds);
644
872
  } finally {
645
- for (const id of turn.eventIds) this.inFlightEvents.delete(id);
646
873
  if (typing) clearInterval(typing);
647
874
  await this.setTyping(turn.roomId, false).catch(() => undefined);
648
875
  // Close the card on every exit — reply posted, empty reply, thrown
649
876
  // error — so no turn can leave a spinner running in the room.
650
- await this.userStub(turn.userDid)
651
- .finishWorkStatus(requestId, 'done')
652
- .catch((err: unknown) =>
877
+ await this.finishWorkStatus(turn.userDid, requestId).catch(
878
+ (err: unknown) =>
653
879
  this.log(
654
880
  'warn',
655
881
  `turn ${requestId}: could not close the status card`,
656
882
  err,
657
883
  ),
658
- );
884
+ );
659
885
  }
660
886
  }
661
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
+
662
896
  private userStub(userDid: string): DurableObjectStub<UserOracleObject> {
663
897
  return this.env.USER_ORACLE.get(
664
898
  this.env.USER_ORACLE.idFromName(
@@ -708,13 +942,32 @@ export class MatrixGatewayDO
708
942
  * moment to re-dispatch the turns a previous incarnation left unfinished.
709
943
  */
710
944
  protected override async onStarted(_result: StartResult): Promise<void> {
945
+ this.noteRunning(true);
711
946
  this.scheduleInboxReplay();
712
947
  }
713
948
 
714
- private scheduleInboxReplay(): void {
715
- if (this.inboxReplayRunning) return;
949
+ override async ensureStarted(): Promise<StartResult> {
950
+ const result = await super.ensureStarted();
951
+ this.noteRunning(true);
952
+ return result;
953
+ }
954
+
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
+ }
716
969
  this.inboxReplayRunning = true;
717
- void this.replayInbox()
970
+ void this.replayInbox(only)
718
971
  .catch((err: unknown) => this.log('error', 'inbox replay failed', err))
719
972
  .finally(() => {
720
973
  this.inboxReplayRunning = false;
@@ -728,12 +981,14 @@ export class MatrixGatewayDO
728
981
  * object decides what a replay means for it (`matrix-turn-ledger.ts`):
729
982
  * stored reply, attach to the running turn, or refuse — never a second run.
730
983
  */
731
- private async replayInbox(): Promise<void> {
984
+ private async replayInbox(only?: ReadonlySet<string>): Promise<void> {
732
985
  const sql = this.inboxSql();
733
- const rows = listInboxRows(sql);
986
+ const rows = listInboxRows(sql).filter(
987
+ (row) => !only || only.has(row.eventId),
988
+ );
734
989
  if (rows.length === 0) return;
735
990
  const plan = planInboxReplay(rows, {
736
- inFlight: this.inFlightEvents,
991
+ inFlight: new Set([...this.inFlightEvents, ...this.debouncing]),
737
992
  maxReplays: MAX_TURN_REPLAYS,
738
993
  });
739
994
  for (const row of plan.exhausted) {
@@ -762,17 +1017,30 @@ export class MatrixGatewayDO
762
1017
  );
763
1018
  for (const row of plan.replay) {
764
1019
  try {
765
- 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);
766
1036
  } catch (err) {
767
- this.log('warn', `inbox: alias lookup failed for ${row.roomId}`, err);
768
- }
769
- const inbound = inboundOfRow(row);
770
- const threadRootId = await this.threadRootForRow(row);
771
- if (threadRootId !== inbound.threadRootId) {
772
- inbound.threadRootId = threadRootId;
773
- 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);
774
1043
  }
775
- this.offerInbound(inbound);
776
1044
  }
777
1045
  this.log(
778
1046
  'info',
@@ -796,6 +1064,11 @@ export class MatrixGatewayDO
796
1064
  // A debounce buffer firing after the stop would send through
797
1065
  // `startedClient()`, which restarts the bot.
798
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);
799
1072
  await super.stop();
800
1073
  }
801
1074
 
@@ -803,36 +1076,66 @@ export class MatrixGatewayDO
803
1076
  // Rooms
804
1077
  // -------------------------------------------------------------------------
805
1078
 
806
- 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> {
807
1087
  const cfg = this.cfg();
808
1088
  const cacheKey = `hs:${userDid}`;
809
- const cached = await this.ctx.storage.get<HomeserverCacheEntry>(cacheKey);
810
- if (cached && Date.now() - cached.at < HOMESERVER_CACHE_TTL_MS) {
811
- return cached.serverName;
812
- }
813
- const blocksync = this.env.BLOCKSYNC_GRAPHQL_URL;
814
- 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> {
815
1129
  try {
816
- const resolved = await fetchUserMatrixServerName(blocksync, userDid);
817
- if (!resolved) return cfg.serverName;
818
- await this.ctx.storage.put(cacheKey, {
819
- serverName: resolved,
820
- at: Date.now(),
821
- } satisfies HomeserverCacheEntry);
822
- if (resolved !== cfg.serverName) {
823
- this.log(
824
- 'info',
825
- `user ${userDid} lives on ${resolved} (oracle is on ${cfg.serverName}); room alias uses the user's server`,
826
- );
827
- }
828
- return resolved;
1130
+ return (await this.lookupUserServer(userDid)).serverName;
829
1131
  } catch (err) {
1132
+ const fallback = this.cfg().serverName;
830
1133
  this.log(
831
1134
  'warn',
832
- `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`,
833
1136
  err,
834
1137
  );
835
- return cfg.serverName;
1138
+ return fallback;
836
1139
  }
837
1140
  }
838
1141
 
@@ -845,21 +1148,24 @@ export class MatrixGatewayDO
845
1148
  await this.userServerName(userDid),
846
1149
  );
847
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 };
848
1159
  const cached = await this.ctx.storage.get<AliasCacheEntry>(cacheKey);
849
- if (
850
- cached &&
851
- cached.alias === alias &&
852
- Date.now() - cached.at < ALIAS_CACHE_TTL_MS
853
- ) {
1160
+ if (fresh(cached)) {
1161
+ this.roomMemo.set(userDid, cached);
854
1162
  return { roomId: cached.roomId, alias };
855
1163
  }
856
1164
  const roomId = await this.resolveAlias(alias);
857
1165
  if (!roomId) return null;
858
- await this.ctx.storage.put(cacheKey, {
859
- roomId,
860
- alias,
861
- at: Date.now(),
862
- } 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);
863
1169
  return { roomId, alias };
864
1170
  }
865
1171
 
@@ -1172,13 +1478,58 @@ export class MatrixGatewayDO
1172
1478
  async downloadEventMediaStream(
1173
1479
  roomId: string,
1174
1480
  eventId: string,
1481
+ opts: MediaDownloadOptions = {},
1175
1482
  ): Promise<MediaStream | null> {
1176
- 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) };
1177
1505
  }
1178
1506
 
1179
- /** 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
+ */
1180
1513
  async downloadMxcMediaStream(
1181
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,
1182
1533
  ): Promise<ReadableStream<Uint8Array>> {
1183
1534
  const client = await this.startedClient();
1184
1535
  const token = client.getAccessToken();
@@ -1195,7 +1546,7 @@ export class MatrixGatewayDO
1195
1546
  true,
1196
1547
  );
1197
1548
  if (!authed) throw new Error(`MatrixGatewayDO: invalid mxc url ${mxc}`);
1198
- let res = await fetch(authed, { headers });
1549
+ let res = await fetch(authed, { headers, signal });
1199
1550
  if (res.status === 404) {
1200
1551
  const legacy = client.mxcUrlToHttp(
1201
1552
  mxc,
@@ -1206,12 +1557,17 @@ export class MatrixGatewayDO
1206
1557
  true,
1207
1558
  false,
1208
1559
  );
1209
- 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
+ }
1210
1564
  }
1211
- if (!res.ok)
1565
+ if (!res.ok) {
1566
+ await res.body?.cancel().catch(() => undefined);
1212
1567
  throw new Error(
1213
1568
  `MatrixGatewayDO: media download failed: ${res.status} ${res.statusText}`,
1214
1569
  );
1570
+ }
1215
1571
  if (!res.body)
1216
1572
  throw new Error('MatrixGatewayDO: media download has no body');
1217
1573
  return res.body;
@@ -1374,15 +1730,55 @@ export class MatrixGatewayDO
1374
1730
  return this.groupChat().unpinFact(roomId, factId);
1375
1731
  }
1376
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
+ */
1377
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
+ }
1378
1749
  return {
1379
- ...(await super.status()),
1750
+ ...(await this.statusCache.value),
1380
1751
  turns: { inFlight: this.turnGate.inUse, waiting: this.turnGate.queued },
1381
- groupChat: this.groupChat().stats(),
1382
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(),
1383
1762
  inbox: countInboxRows(this.inboxSql()),
1384
1763
  };
1385
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
+ }
1386
1782
  }
1387
1783
 
1388
1784
  /** Attachment descriptor of a media message for the ingest (Node bridge shape). */