@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
@@ -18,7 +18,11 @@
18
18
  * R2 objects are removed by `sweep()` at boot; a session's results go with
19
19
  * the session. Operators may add an R2 lifecycle rule on the `results/`
20
20
  * prefix as a belt on top of the sweep. Identical results de-duplicate by
21
- * hash (the row's expiry is refreshed instead of a second copy).
21
+ * hash (the row's expiry is refreshed instead of a second copy); every
22
+ * session that stored one is recorded in `tool_result_sessions`, and
23
+ * deleting a session removes only the results no other session holds.
24
+ * `tool_results.session_id` keeps the first session (the row format is
25
+ * unchanged).
22
26
  */
23
27
  import type { DoSqliteDatabase } from '../sqlite/database';
24
28
  import type { Logger } from '../plugin-api/types';
@@ -64,6 +68,38 @@ export const DEFAULT_RESULT_R2_MIN_BYTES = 1_000_000;
64
68
  export const SQLITE_RESULT_MAX_BYTES = 1_500_000;
65
69
  /** Longest chunk `read` returns (bytes). */
66
70
  export const MAX_READ_BYTES = 64 * 1024;
71
+ /** Ids per `IN (…)` statement. */
72
+ const ID_BATCH = 100;
73
+ /** Longest UTF-8 sequence minus its lead byte. */
74
+ const UTF8_MAX_CONTINUATION = 3;
75
+
76
+ /**
77
+ * Move a byte range of UTF-8 text onto character boundaries: the start back
78
+ * to the lead byte of its character, the end back to the start of the
79
+ * character it splits (or, when that would leave nothing, forward past it).
80
+ * `bytes` holds the text from byte `base` on and covers the range plus up
81
+ * to three bytes on either side.
82
+ */
83
+ export function alignUtf8Range(
84
+ bytes: Uint8Array,
85
+ base: number,
86
+ size: number,
87
+ start: number,
88
+ end: number,
89
+ ): { start: number; end: number } {
90
+ const limit = Math.min(size, base + bytes.length);
91
+ const continues = (at: number) =>
92
+ at > base && at < limit && (bytes[at - base]! & 0xc0) === 0x80;
93
+ let from = start;
94
+ while (continues(from)) from -= 1;
95
+ let to = Math.max(from, Math.min(end, limit));
96
+ while (to > from && continues(to)) to -= 1;
97
+ if (to === from && from < limit) {
98
+ to = from + 1;
99
+ while (continues(to)) to += 1;
100
+ }
101
+ return { start: from, end: to };
102
+ }
67
103
 
68
104
  type ResultRow = {
69
105
  id: string;
@@ -86,6 +122,13 @@ export async function sha256Hex(bytes: Uint8Array): Promise<string> {
86
122
  export class ResultStore {
87
123
  private setupPromise: Promise<void> | undefined;
88
124
 
125
+ /**
126
+ * Stores and removals, one at a time: a removal deletes R2 objects before
127
+ * their rows, so a put of the same content must not land in between (it
128
+ * would keep a row whose object is then deleted).
129
+ */
130
+ private writes: Promise<unknown> = Promise.resolve();
131
+
89
132
  private readonly now: () => number;
90
133
 
91
134
  private readonly ttlMs: number;
@@ -130,6 +173,28 @@ export class ResultStore {
130
173
  await this.db.run(
131
174
  `CREATE INDEX IF NOT EXISTS idx_tool_results_expires ON tool_results(expires_at)`,
132
175
  );
176
+ const hasRefs = await this.db.get(
177
+ `SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'tool_result_sessions'`,
178
+ );
179
+ if (!hasRefs)
180
+ // Rows stored before results were shared across sessions name their
181
+ // one session; they become its references in the same transaction
182
+ // that creates the table, so a crash leaves either both or neither.
183
+ await this.db.transaction(async () => {
184
+ await this.db.run(`
185
+ CREATE TABLE IF NOT EXISTS tool_result_sessions (
186
+ id TEXT NOT NULL,
187
+ session_id TEXT NOT NULL,
188
+ PRIMARY KEY (id, session_id)
189
+ )`);
190
+ await this.db.run(
191
+ `CREATE INDEX IF NOT EXISTS idx_tool_result_sessions_session ON tool_result_sessions(session_id)`,
192
+ );
193
+ await this.db.run(
194
+ `INSERT OR IGNORE INTO tool_result_sessions (id, session_id)
195
+ SELECT id, session_id FROM tool_results`,
196
+ );
197
+ });
133
198
  await this.sweep();
134
199
  }
135
200
 
@@ -150,16 +215,32 @@ export class ResultStore {
150
215
  await this.setup();
151
216
  const bytes = new TextEncoder().encode(input.content);
152
217
  const id = await sha256Hex(bytes);
218
+ return this.serialized(() => this.store(input, bytes, id));
219
+ }
220
+
221
+ private async store(
222
+ input: { sessionId: string; toolName: string; content: string },
223
+ bytes: Uint8Array,
224
+ id: string,
225
+ ): Promise<StoredResultRef | undefined> {
153
226
  const expiresAt = this.now() + this.ttlMs;
227
+ // An expired row is not reused: it may be one kept only because its R2
228
+ // delete failed, with its object already gone. It is stored afresh.
154
229
  const existing = await this.db.get<ResultRow>(
155
- `SELECT id, tier, size FROM tool_results WHERE id = ?`,
156
- [id],
230
+ `SELECT id, tier, size FROM tool_results WHERE id = ? AND expires_at > ?`,
231
+ [id, this.now()],
157
232
  );
158
233
  if (existing) {
159
- await this.db.run(`UPDATE tool_results SET expires_at = ? WHERE id = ?`, [
160
- expiresAt,
161
- id,
162
- ]);
234
+ await this.db.transaction(async () => {
235
+ await this.db.run(
236
+ `UPDATE tool_results SET expires_at = ? WHERE id = ?`,
237
+ [expiresAt, id],
238
+ );
239
+ await this.db.run(
240
+ `INSERT OR IGNORE INTO tool_result_sessions (id, session_id) VALUES (?, ?)`,
241
+ [id, input.sessionId],
242
+ );
243
+ });
163
244
  return {
164
245
  id,
165
246
  size: Number(existing.size),
@@ -183,24 +264,39 @@ export class ResultStore {
183
264
  },
184
265
  });
185
266
  }
186
- await this.db.run(
187
- `INSERT OR IGNORE INTO tool_results (id, session_id, tool_name, size, tier, content, created_at, expires_at)
188
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
189
- [
190
- id,
191
- input.sessionId,
192
- input.toolName,
193
- bytes.byteLength,
194
- useR2 ? 'r2' : 'sqlite',
195
- useR2 ? null : input.content,
196
- new Date(this.now()).toISOString(),
197
- expiresAt,
198
- ],
199
- );
267
+ await this.db.transaction(async () => {
268
+ await this.db.run(
269
+ `INSERT INTO tool_results (id, session_id, tool_name, size, tier, content, created_at, expires_at)
270
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
271
+ ON CONFLICT (id) DO UPDATE SET
272
+ tool_name = excluded.tool_name, size = excluded.size,
273
+ tier = excluded.tier, content = excluded.content,
274
+ created_at = excluded.created_at, expires_at = excluded.expires_at`,
275
+ [
276
+ id,
277
+ input.sessionId,
278
+ input.toolName,
279
+ bytes.byteLength,
280
+ useR2 ? 'r2' : 'sqlite',
281
+ useR2 ? null : input.content,
282
+ new Date(this.now()).toISOString(),
283
+ expiresAt,
284
+ ],
285
+ );
286
+ await this.db.run(
287
+ `INSERT OR IGNORE INTO tool_result_sessions (id, session_id) VALUES (?, ?)`,
288
+ [id, input.sessionId],
289
+ );
290
+ });
200
291
  return { id, size: bytes.byteLength, tier: useR2 ? 'r2' : 'sqlite' };
201
292
  }
202
293
 
203
- /** A chunk of a stored result, by byte range. */
294
+ /**
295
+ * A chunk of a stored result, by byte range, on UTF-8 character
296
+ * boundaries: an offset inside a character starts at that character, and
297
+ * a chunk never ends inside one (`length` and `next` say where it really
298
+ * ended), so concatenating the chunks gives the text back exactly.
299
+ */
204
300
  async read(
205
301
  id: string,
206
302
  offset = 0,
@@ -213,80 +309,165 @@ export class ResultStore {
213
309
  );
214
310
  if (!row) return { status: 'not-found' };
215
311
  if (Number(row.expires_at) <= this.now()) {
216
- await this.remove([row]);
312
+ await this.serialized(async () => {
313
+ // Still expired once it is this removal's turn (a put may have
314
+ // stored the same content again meanwhile).
315
+ const expired = await this.db.exec<ResultRow>(
316
+ `SELECT id, tier FROM tool_results WHERE id = ? AND expires_at <= ?`,
317
+ [id, this.now()],
318
+ );
319
+ await this.remove(expired);
320
+ });
217
321
  return { status: 'expired' };
218
322
  }
219
323
  const size = Number(row.size);
220
- const start = Math.max(0, Math.min(Math.floor(offset), size));
324
+ const requested = Math.max(0, Math.min(Math.floor(offset), size));
221
325
  const span = Math.max(
222
326
  1,
223
- Math.min(Math.floor(length), MAX_READ_BYTES, size - start),
327
+ Math.min(Math.floor(length), MAX_READ_BYTES, size - requested),
224
328
  );
225
329
  const tier: ResultTier = row.tier === 'r2' ? 'r2' : 'sqlite';
226
- let slice: Uint8Array;
330
+ // The bytes around the range too, to find the character boundaries.
331
+ let base: number;
332
+ let bytes: Uint8Array;
227
333
  if (tier === 'r2') {
334
+ base = Math.max(0, requested - UTF8_MAX_CONTINUATION);
335
+ const until = Math.min(size, requested + span + UTF8_MAX_CONTINUATION);
228
336
  const object = await this.options.bucket?.get(this.key(id), {
229
- range: { offset: start, length: span },
337
+ range: { offset: base, length: Math.max(1, until - base) },
230
338
  });
231
339
  if (!object) return { status: 'not-found' };
232
- slice = new Uint8Array(await object.arrayBuffer());
340
+ bytes = new Uint8Array(await object.arrayBuffer());
233
341
  } else {
234
- slice = new TextEncoder()
235
- .encode(row.content ?? '')
236
- .subarray(start, start + span);
342
+ base = 0;
343
+ bytes = new TextEncoder().encode(row.content ?? '');
237
344
  }
238
- const text = new TextDecoder().decode(slice);
239
- const next = start + span < size ? start + span : null;
345
+ const { start, end } = alignUtf8Range(
346
+ bytes,
347
+ base,
348
+ size,
349
+ requested,
350
+ Math.min(size, requested + span),
351
+ );
352
+ const text = new TextDecoder().decode(
353
+ bytes.subarray(start - base, end - base),
354
+ );
240
355
  return {
241
356
  status: 'ok',
242
357
  id,
243
358
  tier,
244
359
  size,
245
360
  offset: start,
246
- length: span,
361
+ length: end - start,
247
362
  text,
248
- next,
363
+ next: end < size ? end : null,
249
364
  };
250
365
  }
251
366
 
367
+ /**
368
+ * Drop the session's references; remove the results no other session
369
+ * holds (and their R2 objects). Returns how many results were removed.
370
+ */
252
371
  async deleteForSession(sessionId: string): Promise<number> {
253
372
  await this.setup();
254
- const rows = await this.db.exec<ResultRow>(
255
- `SELECT id, tier FROM tool_results WHERE session_id = ?`,
256
- [sessionId],
257
- );
373
+ return this.serialized(() => this.removeSession(sessionId));
374
+ }
375
+
376
+ private async removeSession(sessionId: string): Promise<number> {
377
+ const rows = await this.db.transaction(async () => {
378
+ const orphaned = await this.db.exec<ResultRow>(
379
+ `SELECT r.id, r.tier FROM tool_results r
380
+ WHERE (r.session_id = ?
381
+ OR EXISTS (SELECT 1 FROM tool_result_sessions s
382
+ WHERE s.id = r.id AND s.session_id = ?))
383
+ AND NOT EXISTS (SELECT 1 FROM tool_result_sessions s
384
+ WHERE s.id = r.id AND s.session_id <> ?)`,
385
+ [sessionId, sessionId, sessionId],
386
+ );
387
+ await this.db.run(
388
+ `DELETE FROM tool_result_sessions WHERE session_id = ?`,
389
+ [sessionId],
390
+ );
391
+ return orphaned;
392
+ });
258
393
  await this.remove(rows);
259
394
  return rows.length;
260
395
  }
261
396
 
262
397
  /** Drop expired rows (and their R2 objects). Returns how many. */
263
- async sweep(): Promise<number> {
264
- const rows = await this.db.exec<ResultRow>(
265
- `SELECT id, tier FROM tool_results WHERE expires_at <= ?`,
266
- [this.now()],
267
- );
268
- await this.remove(rows);
269
- return rows.length;
398
+ sweep(): Promise<number> {
399
+ return this.serialized(async () => {
400
+ const rows = await this.db.exec<ResultRow>(
401
+ `SELECT id, tier FROM tool_results WHERE expires_at <= ?`,
402
+ [this.now()],
403
+ );
404
+ await this.remove(rows);
405
+ return rows.length;
406
+ });
407
+ }
408
+
409
+ /** Run `work` after every store or removal already in progress. */
410
+ private serialized<T>(work: () => Promise<T>): Promise<T> {
411
+ const next = this.writes.then(work);
412
+ this.writes = next.catch(() => undefined);
413
+ return next;
270
414
  }
271
415
 
416
+ /**
417
+ * Remove rows and their R2 objects. An R2-tier row whose object could not
418
+ * be deleted stays, marked expired: it is the only record of the object,
419
+ * so the next sweep (or read) tries again instead of leaking it.
420
+ */
272
421
  private async remove(
273
422
  rows: Array<Pick<ResultRow, 'id' | 'tier'>>,
274
423
  ): Promise<void> {
275
424
  if (rows.length === 0) return;
276
- const r2Keys = rows
277
- .filter((r) => r.tier === 'r2')
278
- .map((r) => this.key(r.id));
279
- if (r2Keys.length > 0 && this.options.bucket) {
280
- try {
281
- await this.options.bucket.delete(r2Keys);
282
- } catch (error) {
283
- this.logger.warn(
284
- `[results] could not delete ${r2Keys.length} R2 object(s): ${error instanceof Error ? error.message : String(error)}`,
285
- );
425
+ const r2Rows = rows.filter((r) => r.tier === 'r2');
426
+ const kept = new Set<string>();
427
+ if (r2Rows.length > 0 && this.options.bucket) {
428
+ // R2 deletes at most 1,000 keys per call; a failed call keeps only
429
+ // its own rows.
430
+ for (let i = 0; i < r2Rows.length; i += 1_000) {
431
+ const batch = r2Rows.slice(i, i + 1_000);
432
+ try {
433
+ await this.options.bucket.delete(batch.map((r) => this.key(r.id)));
434
+ } catch (error) {
435
+ this.logger.warn(
436
+ `[results] could not delete ${batch.length} R2 object(s); their rows stay for the next sweep: ${error instanceof Error ? error.message : String(error)}`,
437
+ );
438
+ for (const row of batch) kept.add(row.id);
439
+ }
286
440
  }
287
441
  }
288
- for (const row of rows)
289
- await this.db.run(`DELETE FROM tool_results WHERE id = ?`, [row.id]);
442
+ const gone = rows.filter((r) => !kept.has(r.id)).map((r) => r.id);
443
+ await this.db.transaction(async () => {
444
+ for (let i = 0; i < gone.length; i += ID_BATCH) {
445
+ const ids = gone.slice(i, i + ID_BATCH);
446
+ const marks = ids.map(() => '?').join(', ');
447
+ await this.db.run(
448
+ `DELETE FROM tool_results WHERE id IN (${marks})`,
449
+ ids,
450
+ );
451
+ await this.db.run(
452
+ `DELETE FROM tool_result_sessions WHERE id IN (${marks})`,
453
+ ids,
454
+ );
455
+ }
456
+ const retry = [...kept];
457
+ for (let i = 0; i < retry.length; i += ID_BATCH) {
458
+ const ids = retry.slice(i, i + ID_BATCH);
459
+ const marks = ids.map(() => '?').join(', ');
460
+ await this.db.run(
461
+ `UPDATE tool_results SET expires_at = 0 WHERE id IN (${marks})`,
462
+ ids,
463
+ );
464
+ // No session holds it any more; the row only tracks the object.
465
+ await this.db.run(
466
+ `DELETE FROM tool_result_sessions WHERE id IN (${marks})`,
467
+ ids,
468
+ );
469
+ }
470
+ });
290
471
  }
291
472
 
292
473
  async stats(): Promise<{
@@ -1,3 +1,4 @@
1
+ import { channelOrigin } from '../channels/contract';
1
2
  /**
2
3
  * The room mirror of HTTP chats: every Portal turn is replayed into the
3
4
  * user's oracle room as a thread under the session's marker event, the user
@@ -41,6 +42,11 @@ export interface MirrorSend {
41
42
  /** The session's marker event — the thread the mirror is posted under. */
42
43
  threadId: string;
43
44
  txnId: string;
45
+ origin?: {
46
+ provider: 'whatsapp';
47
+ bindingId: string;
48
+ remoteMessageRef: string;
49
+ };
44
50
  }
45
51
 
46
52
  export interface RoomMirrorDeps {
@@ -53,6 +59,27 @@ export interface RoomMirrorDeps {
53
59
  retry?: Pick<RetryGatewayOptions, 'delaysMs' | 'sleep' | 'isTransient'>;
54
60
  }
55
61
 
62
+ export function mirrorEventContent(send: MirrorSend) {
63
+ return {
64
+ msgtype: 'm.text',
65
+ body: send.body,
66
+ ...(send.formattedBody
67
+ ? { format: 'org.matrix.custom.html', formatted_body: send.formattedBody }
68
+ : {}),
69
+ 'm.relates_to': {
70
+ rel_type: 'm.thread',
71
+ event_id: send.threadId,
72
+ is_falling_back: true,
73
+ 'm.in_reply_to': { event_id: send.threadId },
74
+ },
75
+ ...(send.origin
76
+ ? {
77
+ 'org.ixo.qi.origin': channelOrigin(send.origin),
78
+ }
79
+ : {}),
80
+ };
81
+ }
82
+
56
83
  const errorText = (err: unknown): string =>
57
84
  err instanceof Error ? err.message : String(err);
58
85
 
@@ -77,12 +104,14 @@ export class RoomMirror {
77
104
  sessionId: string,
78
105
  prepare: () => Promise<MirrorSend | null>,
79
106
  label: string,
107
+ required = false,
80
108
  ): Promise<void> {
81
109
  const previous = this.chains.get(sessionId) ?? Promise.resolve();
82
110
  const tracked: Promise<void> = previous
83
111
  .then(() => prepare())
84
112
  .then((send) => (send ? this.send(send, label) : undefined))
85
113
  .catch((err: unknown) => {
114
+ if (required) throw err;
86
115
  this.deps.warn(
87
116
  `[user-do] Matrix replay (${label}) failed — session=${sessionId}: ${errorText(err)}`,
88
117
  );
@@ -89,9 +89,20 @@ export class RunBuffer {
89
89
  /** Frames not yet packed into a segment. */
90
90
  private readonly tail: RunFrame[] = [];
91
91
 
92
+ /**
93
+ * Frames taken out of the tail whose segment write has not settled yet,
94
+ * one batch per pending pack, in order. A re-join reads the store and then
95
+ * the tail; without these, a frame packed between the two would be in
96
+ * neither.
97
+ */
98
+ private readonly inFlight: RunFrame[][] = [];
99
+
92
100
  private tailBytes = 0;
93
101
 
94
- private readonly subscribers = new Set<RunSubscriber>();
102
+ private readonly subscribers = new Map<RunSubscriber, (() => void) | null>();
103
+
104
+ /** The `done` frame, once pushed (a closed buffer keeps it for late readers). */
105
+ private doneFrame: RunFrame | null = null;
95
106
 
96
107
  private timer: unknown = null;
97
108
 
@@ -115,13 +126,19 @@ export class RunBuffer {
115
126
  return this.closed;
116
127
  }
117
128
 
129
+ /** A `done` frame has been pushed: the run told its subscribers it ended. */
130
+ get hasDone(): boolean {
131
+ return this.doneFrame !== null;
132
+ }
133
+
118
134
  /** Append a frame, fan it out, and schedule or force a pack. */
119
135
  push(event: string, data: unknown): RunFrame {
120
136
  if (this.closed) throw new Error('run buffer is closed');
121
137
  const frame: RunFrame = { seq: ++this.seq, event, data };
138
+ if (event === 'done' && this.doneFrame === null) this.doneFrame = frame;
122
139
  this.tail.push(frame);
123
140
  this.tailBytes += JSON.stringify(frame).length;
124
- for (const subscriber of this.subscribers) {
141
+ for (const subscriber of this.subscribers.keys()) {
125
142
  try {
126
143
  subscriber(frame);
127
144
  } catch {
@@ -144,19 +161,40 @@ export class RunBuffer {
144
161
  }
145
162
 
146
163
  /**
147
- * The unflushed frames after `after` — what a re-join needs on top of the
148
- * segments it read from the store.
164
+ * The frames after `after` that a store read may not hold yet — the ones
165
+ * whose segment write is still pending, then the unflushed tail — what a
166
+ * re-join needs on top of the segments it read from the store. A closed
167
+ * buffer also keeps its `done` frame, so a reader that arrives after the
168
+ * run ended still learns how it ended.
149
169
  */
150
170
  tailAfter(after: number): RunFrame[] {
151
- return this.tail.filter((frame) => frame.seq > after);
171
+ const out: RunFrame[] = [];
172
+ for (const batch of this.inFlight)
173
+ for (const frame of batch) if (frame.seq > after) out.push(frame);
174
+ for (const frame of this.tail) if (frame.seq > after) out.push(frame);
175
+ const done = this.doneFrame;
176
+ if (
177
+ this.closed &&
178
+ done !== null &&
179
+ done.seq > after &&
180
+ !out.some((frame) => frame.seq === done.seq)
181
+ )
182
+ out.push(done);
183
+ return out;
152
184
  }
153
185
 
154
186
  /**
155
187
  * Attach a live subscriber. The caller replays the store's segments and
156
188
  * `tailAfter(cursor)` first; from then on every new frame is delivered.
189
+ * `onEnd` runs once when the buffer closes (at once when it already is),
190
+ * after the subscriber received every frame.
157
191
  */
158
- subscribe(subscriber: RunSubscriber): () => void {
159
- this.subscribers.add(subscriber);
192
+ subscribe(subscriber: RunSubscriber, onEnd?: () => void): () => void {
193
+ if (this.closed) {
194
+ onEnd?.();
195
+ return () => undefined;
196
+ }
197
+ this.subscribers.set(subscriber, onEnd ?? null);
160
198
  return () => {
161
199
  this.subscribers.delete(subscriber);
162
200
  };
@@ -175,6 +213,7 @@ export class RunBuffer {
175
213
  if (this.tail.length === 0) return this.packing;
176
214
  const frames = this.tail.splice(0, this.tail.length);
177
215
  this.tailBytes = 0;
216
+ this.inFlight.push(frames);
178
217
  const segment: PackedSegment = {
179
218
  seqFrom: frames[0]!.seq,
180
219
  seqTo: frames[frames.length - 1]!.seq,
@@ -190,15 +229,31 @@ export class RunBuffer {
190
229
  // copy is missing. Report and carry on — the next pack retries
191
230
  // nothing (the frames are gone) but the run itself is unaffected.
192
231
  this.options.onPackError?.(error);
232
+ })
233
+ .finally(() => {
234
+ // Packs settle in order: this batch is the oldest one pending.
235
+ this.inFlight.shift();
193
236
  });
194
237
  return this.packing;
195
238
  }
196
239
 
197
- /** Final pack; no more frames accepted afterwards. */
240
+ /**
241
+ * Final pack; no more frames accepted afterwards. Every subscriber is
242
+ * told the buffer ended (after the frames it was already given) and
243
+ * detached.
244
+ */
198
245
  async close(): Promise<void> {
199
246
  if (this.closed) return this.packing;
200
247
  this.closed = true;
201
- await this.flush();
248
+ const ended = [...this.subscribers.values()];
202
249
  this.subscribers.clear();
250
+ for (const onEnd of ended) {
251
+ try {
252
+ onEnd?.();
253
+ } catch {
254
+ /* a broken subscriber never breaks the run */
255
+ }
256
+ }
257
+ await this.flush();
203
258
  }
204
259
  }