@ixo/oracle-runtime-workers 0.4.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +5 -1
  2. package/package.json +2 -2
  3. package/src/attachments/download.ts +43 -22
  4. package/src/attachments/index.ts +1 -0
  5. package/src/core/env.ts +28 -0
  6. package/src/core/main-agent.ts +56 -4
  7. package/src/core/middlewares/dangling-tool-calls.ts +99 -0
  8. package/src/core/middlewares/index.ts +5 -0
  9. package/src/core/runtime-context.ts +14 -5
  10. package/src/core/subagent-as-tool.ts +12 -1
  11. package/src/do/ambient.ts +13 -4
  12. package/src/do/boot-dirty.ts +38 -0
  13. package/src/do/contracts.ts +113 -18
  14. package/src/do/matrix-turn-ledger.ts +126 -0
  15. package/src/do/reauth-prompt.ts +76 -0
  16. package/src/do/room-mirror.ts +111 -0
  17. package/src/do/sse-stream.ts +104 -2
  18. package/src/do/transcript.ts +15 -2
  19. package/src/do/turn-config.ts +24 -0
  20. package/src/do/user-oracle-do.ts +484 -91
  21. package/src/matrix/gateway-do.ts +233 -42
  22. package/src/matrix/inbox-store.ts +188 -0
  23. package/src/matrix/txn-id.ts +35 -0
  24. package/src/matrix/work-status.ts +15 -3
  25. package/src/owner-store/ixo-vfs-store.ts +12 -5
  26. package/src/owner-store/matrix-media-store.ts +65 -40
  27. package/src/owner-store/measure.ts +39 -0
  28. package/src/owner-store/migrating-store.ts +29 -34
  29. package/src/owner-store/types.ts +0 -0
  30. package/src/plugin-api/types.ts +25 -1
  31. package/src/plugins/portal/action-log.ts +36 -9
  32. package/src/plugins/tasks/tasks-tools.ts +7 -7
  33. package/src/shell/app.ts +92 -7
  34. package/src/shell/turn-body-cap.ts +19 -0
  35. package/src/sqlite/cache-config.ts +27 -6
  36. package/src/sqlite/chunk-layout.ts +14 -0
  37. package/src/sqlite/database.ts +188 -19
  38. package/src/sqlite/do-vfs.ts +963 -99
  39. package/src/sqlite/index.ts +13 -0
  40. package/src/sqlite/page-tier.ts +668 -0
  41. package/src/tasks/schedule-input.ts +102 -0
  42. package/src/tasks/scheduler.ts +351 -54
  43. package/src/tasks/store.ts +198 -3
@@ -1,6 +1,7 @@
1
- import type { BotStatus } from '@ixo/matrix-bot-workers-sdk';
1
+ import type { BotStatus, EncryptedFileInfo } from '@ixo/matrix-bot-workers-sdk';
2
2
  import type { AttachmentInput } from '../attachments/types';
3
3
  import type { RealtimeStatus } from '../realtime/realtime-endpoint';
4
+ import type { TierFlushResult, TierStatus } from '../sqlite/do-vfs';
4
5
  /**
5
6
  * Cross-object contracts for the Workers runtime.
6
7
  *
@@ -82,6 +83,20 @@ export interface OracleWorkerEnv {
82
83
  * for the hit/miss counters that show what a budget buys.
83
84
  */
84
85
  CHUNK_CACHE_BYTES?: string;
86
+ /**
87
+ * R2 page tier for the users' working copies (see `sqlite/page-tier.ts`):
88
+ * chunks no turn touched for `TIER_EVICT_AFTER_PERIODS` periods move
89
+ * from DO SQLite ($0.20/GB-month, 10 GB cap per object) into 1 MiB R2
90
+ * segment objects ($0.015/GB-month, no cap) under a per-object prefix.
91
+ * Absent = every chunk stays in DO storage (the pre-tier behaviour).
92
+ */
93
+ TIER_BUCKET?: R2Bucket;
94
+ /** Soft target for hot bytes per user object (bytes or `16m`; default 16 MiB). */
95
+ TIER_HOT_BUDGET_BYTES?: string;
96
+ /** Periods (of `TIER_PERIOD_MS`) a chunk must go untouched before eviction (default 2). */
97
+ TIER_EVICT_AFTER_PERIODS?: string;
98
+ /** Length of one access-tracking period in ms (default one day; tests shorten it). */
99
+ TIER_PERIOD_MS?: string;
85
100
  /** IXO VFS worker base URL when `OWNER_STORE=vfs` (defaults per NETWORK). */
86
101
  VFS_BASE_URL?: string;
87
102
  /** UCAN store worker base URL when `OWNER_STORE=vfs` (defaults per NETWORK). */
@@ -107,6 +122,8 @@ export interface OracleWorkerEnv {
107
122
  /** Display markup for `GET /models` prices (default 1.6). */
108
123
  MODEL_PRICE_MARKUP?: string;
109
124
  MAIN_REASONING_EFFORT?: string;
125
+ /** LangGraph steps one turn may take before `GraphRecursionError` (default 600; Node hard-codes 200). */
126
+ TURN_RECURSION_LIMIT?: string;
110
127
  /** Platform model provider. Default `openrouter`; `nebius` for self-hosted. */
111
128
  LLM_PROVIDER?: 'openrouter' | 'nebius';
112
129
  /** Nebius Token Factory API key — required when `LLM_PROVIDER=nebius`. */
@@ -205,6 +222,21 @@ export interface TurnResult {
205
222
  messageId?: string;
206
223
  /** Tool calls made during the turn (name + summarized status), for logging. */
207
224
  toolCalls: Array<{ name: string; status: 'done' | 'error' }>;
225
+ /** The stored reply of a turn that had already finished (a gateway asked again after a reset). */
226
+ replayed?: boolean;
227
+ }
228
+
229
+ /**
230
+ * Prefix of the error a user object raises when a gateway asks again about a
231
+ * room message whose turn the object itself lost mid-run (see
232
+ * `src/do/matrix-turn-ledger.ts`): not re-run, the user is told to retry.
233
+ */
234
+ export const TURN_INTERRUPTED_MARKER =
235
+ 'turn interrupted by a runtime reset before it finished; not re-run';
236
+
237
+ export function isInterruptedTurnError(err: unknown): boolean {
238
+ const message = err instanceof Error ? err.message : String(err);
239
+ return message.includes(TURN_INTERRUPTED_MARKER);
208
240
  }
209
241
 
210
242
  /** Row shape served by `GET /sessions`. */
@@ -267,6 +299,8 @@ export interface StorageStatus {
267
299
  freelistCount?: number;
268
300
  /** SQLite's own page cache (`PRAGMA cache_size`; negative = KiB). */
269
301
  sqliteCacheSize?: number;
302
+ /** The R2 page tier of this object's working copy (see sqlite/page-tier.ts). */
303
+ tier?: TierStatus & { lastPassAt?: number };
270
304
  /** The DO VFS clean-chunk cache and its storage counters (isolate lifetime). */
271
305
  chunkCache?: {
272
306
  budgetBytes: number;
@@ -339,8 +373,17 @@ export interface UserOracleObject extends Rpc.DurableObjectBranded {
339
373
  * the "user deleted their file upstream" path.
340
374
  */
341
375
  resetWorkingCopy(): Promise<{ reloadedFromOwnerStore: boolean }>;
376
+ /** Run one R2 page-tier eviction pass now (debug routes; `force` ignores recency). */
377
+ tierFlush(opts?: {
378
+ force?: boolean;
379
+ maxSegments?: number;
380
+ }): Promise<TierFlushResult>;
342
381
  /** Raw upstream schema of the memory search tool as fetched right now. */
343
382
  debugMemorySchema(userDid: string): Promise<MemorySchemaDebug>;
383
+ /** Forcibly reset the object like a platform host drain (debug routes only). The call itself rejects. */
384
+ debugAbortObject(): Promise<void>;
385
+ /** Forget when the last re-authorise prompt was posted (debug routes only). */
386
+ debugResetReauthThrottle(): Promise<void>;
344
387
  /** Raw session row for `GET /debug/sessions/:id` (null when unknown). */
345
388
  debugSession(
346
389
  userDid: string,
@@ -354,7 +397,8 @@ export interface UserOracleObject extends Rpc.DurableObjectBranded {
354
397
  * Diagnostics: the object's current alarm and the user's task records — the
355
398
  * pair that answers "is this task going to fire, and when?".
356
399
  */
357
- tasksStatus(): Promise<{
400
+ /** Task records, open runs and the alarm; boots the object when cold (`userDid` = the caller). */
401
+ tasksStatus(userDid?: string): Promise<{
358
402
  now: number;
359
403
  alarm: number | null;
360
404
  schedulerActive: boolean;
@@ -367,6 +411,15 @@ export interface UserOracleObject extends Rpc.DurableObjectBranded {
367
411
  consecutiveFailures: number;
368
412
  deliveryRoomId: string | null;
369
413
  }>;
414
+ /** Runs no incarnation has finished: what an alarm will re-deliver or close. */
415
+ openRuns: Array<{
416
+ runId: string;
417
+ taskId: string;
418
+ startedAt: string;
419
+ state: 'running' | 'delivering';
420
+ attempts: number;
421
+ retryAt: number | null;
422
+ }>;
370
423
  }>;
371
424
  }
372
425
 
@@ -388,17 +441,21 @@ export interface CreateRoomOptions {
388
441
  * rest is the oracle's.
389
442
  */
390
443
  export interface MatrixGatewayObject extends Rpc.DurableObjectBranded {
391
- /** Bytes of a media event (decrypted when it carries an encrypted `file`); null when unknown. */
392
- downloadEventMedia(
444
+ /**
445
+ * A media event's content as a stream, AS STORED: in an E2EE room the
446
+ * ciphertext plus the `EncryptedFile` fields to pipe through the SDK's
447
+ * `createAttachmentDecryptor` on the caller's side (a stream that errors
448
+ * on the far side of an RPC only surfaces here as a disconnect, so the
449
+ * hash check belongs to the consumer). Null when unknown or redacted.
450
+ */
451
+ downloadEventMediaStream(
393
452
  roomId: string,
394
453
  eventId: string,
395
- ): Promise<{
396
- bytes: Uint8Array;
397
- mimetype?: string;
398
- filename?: string;
399
- } | null>;
400
- /** Authenticated media download of an `mxc://` URI. */
401
- downloadMxcMedia(mxc: string): Promise<Uint8Array>;
454
+ ): Promise<MediaStream | null>;
455
+ /** Authenticated media download of an `mxc://` URI, as a stream. */
456
+ downloadMxcMediaStream(mxc: string): Promise<ReadableStream<Uint8Array>>;
457
+ /** Forcibly reset the gateway object like a platform host drain (debug routes only). The call itself rejects. */
458
+ debugAbortObject(): Promise<void>;
402
459
  /** Stop the bot (operator/debug; `ensureStarted`/cron brings it back). */
403
460
  stop(): Promise<void>;
404
461
  /** Idempotent — starts the bot + keep-alive alarm if not running. */
@@ -451,8 +508,18 @@ export interface MatrixGatewayObject extends Rpc.DurableObjectBranded {
451
508
  txnId?: string;
452
509
  },
453
510
  ): Promise<string>;
454
- /** Send an arbitrary timeline event (`content` JSON-encoded). Returns the event id. */
455
- sendEvent(roomId: string, type: string, content: JsonString): Promise<string>;
511
+ /**
512
+ * Send an arbitrary timeline event (`content` JSON-encoded). Returns the
513
+ * event id. `txnId` pins the transaction id, as for `sendText`: the
514
+ * homeserver deduplicates by it per device, so a send retried after a lost
515
+ * response returns the original event instead of posting a second one.
516
+ */
517
+ sendEvent(
518
+ roomId: string,
519
+ type: string,
520
+ content: JsonString,
521
+ opts?: { txnId?: string },
522
+ ): Promise<string>;
456
523
  /** Resolve the canonical user↔oracle room for a user DID (alias lookup, cached). */
457
524
  resolveUserRoom(
458
525
  userDid: string,
@@ -465,17 +532,23 @@ export interface MatrixGatewayObject extends Rpc.DurableObjectBranded {
465
532
  * Returns the new event id. Wire-compatible with the Node runtime so
466
533
  * existing users migrate transparently.
467
534
  */
468
- uploadUserSnapshot(
535
+ uploadUserSnapshotStream(
469
536
  userDid: string,
470
537
  storageKey: string,
471
- bytes: Uint8Array,
538
+ /** The gzipped file; encrypted chunk by chunk in an E2EE room, never buffered. */
539
+ body: ReadableStream<Uint8Array>,
472
540
  filename: string,
541
+ /** Exact byte length of `body` (the homeserver needs a Content-Length). */
542
+ size: number,
473
543
  ): Promise<{ eventId: string }>;
474
- /** Download the latest snapshot for `storageKey`, or null when none exists. */
475
- downloadUserSnapshot(
544
+ /**
545
+ * The latest snapshot for `storageKey` as a stream, as stored (see
546
+ * `downloadEventMediaStream`), or null when none exists.
547
+ */
548
+ downloadUserSnapshotStream(
476
549
  userDid: string,
477
550
  storageKey: string,
478
- ): Promise<{ bytes: Uint8Array; eventId: string } | null>;
551
+ ): Promise<SnapshotStream | null>;
479
552
  /** Read a room state event's content, JSON-encoded (state is never E2EE). Null when absent. */
480
553
  getRoomStateEvent(
481
554
  roomId: string,
@@ -533,10 +606,32 @@ export interface OutboxRow {
533
606
  }
534
607
 
535
608
  /** `MatrixBotDO.status()` plus the gateway's own turn bookkeeping. */
609
+ /**
610
+ * Media handed across the Durable Object boundary as a stream, as stored on
611
+ * the homeserver: `file` is present when the event carried an encrypted
612
+ * attachment — pipe `stream` through the SDK's `createAttachmentDecryptor`
613
+ * with it; without `file` the stream is the plain upload. `size` is the
614
+ * sender's `info.size` when it set one.
615
+ */
616
+ export interface MediaStream {
617
+ stream: ReadableStream<Uint8Array>;
618
+ file?: EncryptedFileInfo;
619
+ size?: number;
620
+ mimetype?: string;
621
+ filename?: string;
622
+ }
623
+
624
+ /** `MediaStream` of a user's snapshot, plus the media event that carries it. */
625
+ export interface SnapshotStream extends MediaStream {
626
+ eventId: string;
627
+ }
628
+
536
629
  export interface GatewayStatus extends BotStatus {
537
630
  turns: { inFlight: number; waiting: number };
538
631
  /** Debounce buffers waiting to become turns. */
539
632
  ingestPending: number;
633
+ /** Room messages whose turn has not ended yet (durable; replayed after a reset). */
634
+ inbox: number;
540
635
  }
541
636
 
542
637
  /** Derive the durable-object name for a user's object. */
@@ -0,0 +1,126 @@
1
+ /**
2
+ * What the user object remembers about each room message it was asked to
3
+ * answer, so the gateway can ask again after a reset without the turn ever
4
+ * running twice (`src/matrix/inbox-store.ts` is the asking side).
5
+ *
6
+ * One row per Matrix event id, in the user's own SQLite next to the
7
+ * checkpoints: `started_at` is written before the turn runs, `reply_text` +
8
+ * `answered_at` when it has finished. A second request for the same event:
9
+ *
10
+ * - answered → the stored text is returned; no model, no tools;
11
+ * - running in this instance → the caller attaches to the running turn
12
+ * (kept in memory by the object, not here);
13
+ * - started, not answered, not running → the object itself reset mid-turn;
14
+ * tools may have run, so the turn is NOT re-run: the request is refused
15
+ * with `TURN_INTERRUPTED_MARKER` and the gateway tells the user to try
16
+ * again — the same thing that happens today when the object dies while
17
+ * the gateway is alive;
18
+ * - unknown → run once.
19
+ *
20
+ * Rows older than `RETENTION_MS` are pruned at setup; a replay that late
21
+ * cannot happen (the gateway inbox gives up after `MAX_TURN_REPLAYS`).
22
+ */
23
+ import type { DoSqliteDatabase } from '../sqlite/database';
24
+
25
+ export interface MatrixTurnRecord {
26
+ eventId: string;
27
+ sessionId: string;
28
+ requestId: string;
29
+ startedAt: string;
30
+ answeredAt: string | null;
31
+ replyText: string | null;
32
+ }
33
+
34
+ export type MatrixTurnDecision = 'run' | 'answered' | 'interrupted';
35
+
36
+ /** Rows are kept for a week; the gateway never replays anything that old. */
37
+ export const RETENTION_MS = 7 * 24 * 3600 * 1000;
38
+
39
+ export function decideMatrixTurn(
40
+ existing: MatrixTurnRecord | undefined,
41
+ ): MatrixTurnDecision {
42
+ if (!existing) return 'run';
43
+ return existing.replyText !== null ? 'answered' : 'interrupted';
44
+ }
45
+
46
+ type LedgerRow = {
47
+ event_id: string;
48
+ session_id: string;
49
+ request_id: string;
50
+ started_at: string;
51
+ answered_at: string | null;
52
+ reply_text: string | null;
53
+ } & Record<string, string | null>;
54
+
55
+ export class MatrixTurnLedger {
56
+ private setupPromise: Promise<void> | undefined;
57
+
58
+ constructor(
59
+ readonly db: DoSqliteDatabase,
60
+ private readonly now: () => number = () => Date.now(),
61
+ ) {}
62
+
63
+ setup(): Promise<void> {
64
+ this.setupPromise ??= this.doSetup().catch((error: unknown) => {
65
+ this.setupPromise = undefined;
66
+ throw error;
67
+ });
68
+ return this.setupPromise;
69
+ }
70
+
71
+ private async doSetup(): Promise<void> {
72
+ await this.db.run(`
73
+ CREATE TABLE IF NOT EXISTS matrix_turns (
74
+ event_id TEXT PRIMARY KEY,
75
+ session_id TEXT NOT NULL,
76
+ request_id TEXT NOT NULL,
77
+ started_at TEXT NOT NULL,
78
+ answered_at TEXT,
79
+ reply_text TEXT
80
+ )`);
81
+ await this.db.run(`DELETE FROM matrix_turns WHERE started_at < ?`, [
82
+ new Date(this.now() - RETENTION_MS).toISOString(),
83
+ ]);
84
+ }
85
+
86
+ async get(eventId: string): Promise<MatrixTurnRecord | undefined> {
87
+ await this.setup();
88
+ const row = await this.db.get<LedgerRow>(
89
+ `SELECT event_id, session_id, request_id, started_at, answered_at, reply_text
90
+ FROM matrix_turns WHERE event_id = ?`,
91
+ [eventId],
92
+ );
93
+ if (!row) return undefined;
94
+ return {
95
+ eventId: row.event_id,
96
+ sessionId: row.session_id,
97
+ requestId: row.request_id,
98
+ startedAt: row.started_at,
99
+ answeredAt: row.answered_at,
100
+ replyText: row.reply_text,
101
+ };
102
+ }
103
+
104
+ /** Mark the turn as started. Durable before any model or tool runs. */
105
+ async start(
106
+ eventId: string,
107
+ sessionId: string,
108
+ requestId: string,
109
+ ): Promise<void> {
110
+ await this.setup();
111
+ await this.db.run(
112
+ `INSERT OR IGNORE INTO matrix_turns (event_id, session_id, request_id, started_at)
113
+ VALUES (?, ?, ?, ?)`,
114
+ [eventId, sessionId, requestId, new Date(this.now()).toISOString()],
115
+ );
116
+ }
117
+
118
+ /** Record the reply; from now on the event is answered, never re-run. */
119
+ async answer(eventId: string, replyText: string): Promise<void> {
120
+ await this.setup();
121
+ await this.db.run(
122
+ `UPDATE matrix_turns SET reply_text = ?, answered_at = ? WHERE event_id = ?`,
123
+ [replyText, new Date(this.now()).toISOString(), eventId],
124
+ );
125
+ }
126
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * The `ixo.oracle.delegation_required` prompt: when a room message arrives
3
+ * and the user has no usable delegation, the object posts an event the web
4
+ * app listens for to open its "authorize for Matrix" modal in place. It is
5
+ * throttled per user (`UCAN_REAUTH_PROMPT_THROTTLE_SECONDS`) so the room is
6
+ * not spammed while the user is away.
7
+ *
8
+ * The throttle is stamped only AFTER the homeserver accepted the event. The
9
+ * earlier order — stamp, then send — turned a send lost to a gateway restart
10
+ * into six hours of silence: the user kept messaging an oracle that could
11
+ * not act, and nothing told them why. The send is retried across a restart
12
+ * under one transaction id, so a response lost after the homeserver accepted
13
+ * the event is deduplicated rather than posted twice; a second turn arriving
14
+ * while a prompt is in flight joins it instead of posting another.
15
+ */
16
+ import { retryTxnId } from '../matrix/txn-id';
17
+ import { retryGateway, type RetryGatewayOptions } from './gateway-retry';
18
+
19
+ export interface ReauthPromptDeps {
20
+ throttleMs: number;
21
+ /** When the last prompt was accepted (undefined = never). */
22
+ getStamp(): Promise<number | undefined>;
23
+ setStamp(at: number): Promise<void>;
24
+ /** Post the event under the transaction id; resolves to its event id. */
25
+ send(roomId: string, txnId: string): Promise<string>;
26
+ /** Keeps the (un-awaited) prompt alive past the request that started it: `ctx.waitUntil`. */
27
+ keepAlive(work: Promise<unknown>): void;
28
+ log(message: string): void;
29
+ warn(message: string): void;
30
+ now?: () => number;
31
+ retry?: Pick<RetryGatewayOptions, 'delaysMs' | 'sleep' | 'isTransient'>;
32
+ }
33
+
34
+ const errorText = (err: unknown): string =>
35
+ err instanceof Error ? err.message : String(err);
36
+
37
+ export class ReauthPrompter {
38
+ private inFlight: Promise<void> | null = null;
39
+
40
+ constructor(private readonly deps: ReauthPromptDeps) {}
41
+
42
+ /** Prompt unless throttled or already in flight. Never rejects. */
43
+ prompt(userDid: string, roomId: string): Promise<void> {
44
+ if (this.inFlight) return this.inFlight;
45
+ const run = this.run(userDid, roomId)
46
+ .catch((err: unknown) => {
47
+ this.deps.warn(
48
+ `[user-do] delegation-required event failed for ${userDid}: ${errorText(err)}`,
49
+ );
50
+ })
51
+ .finally(() => {
52
+ this.inFlight = null;
53
+ });
54
+ this.inFlight = run;
55
+ this.deps.keepAlive(run);
56
+ return run;
57
+ }
58
+
59
+ private async run(userDid: string, roomId: string): Promise<void> {
60
+ const now = this.deps.now?.() ?? Date.now();
61
+ const last = await this.deps.getStamp();
62
+ if (last !== undefined && now - last < this.deps.throttleMs) return;
63
+ const txnId = retryTxnId('reauth');
64
+ const eventId = await retryGateway(() => this.deps.send(roomId, txnId), {
65
+ ...this.deps.retry,
66
+ onRetry: (err, attempt, delayMs) =>
67
+ this.deps.warn(
68
+ `[user-do] delegation-required event send failed (attempt ${attempt}) — retrying in ${delayMs} ms: ${errorText(err)}`,
69
+ ),
70
+ });
71
+ await this.deps.setStamp(this.deps.now?.() ?? Date.now());
72
+ this.deps.log(
73
+ `[user-do] sent ixo.oracle.delegation_required (${eventId}) to ${roomId} for ${userDid}`,
74
+ );
75
+ }
76
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * The room mirror of HTTP chats: every Portal turn is replayed into the
3
+ * user's oracle room as a thread under the session's marker event, the user
4
+ * message when the turn starts and the reply when it ends (Node's
5
+ * `MessagesService`). Node fires these and forgets them; on Workers the
6
+ * gateway object is replaced on every deploy and can be drained at any time,
7
+ * so a mirror sent at that instant fails with a transport error and, unless
8
+ * retried, is gone for good — the room transcript then disagrees with the
9
+ * Portal's for ever.
10
+ *
11
+ * Two rules make the retry safe:
12
+ * - each send carries a transaction id derived from the session, the request
13
+ * and the author, so a send whose response was lost is deduplicated by the
14
+ * homeserver rather than posted twice;
15
+ * - sends are serialised per session (parallel across sessions), so a reply
16
+ * being retried can never overtake the message it answers, and a later
17
+ * turn's mirrors queue behind an earlier turn's.
18
+ */
19
+ import { matrixTxnId } from '../matrix/txn-id';
20
+ import { retryGateway, type RetryGatewayOptions } from './gateway-retry';
21
+
22
+ export type MirrorAuthor = 'user' | 'oracle';
23
+
24
+ export function mirrorTxnId(
25
+ sessionId: string,
26
+ requestId: string,
27
+ who: MirrorAuthor,
28
+ ): string {
29
+ return matrixTxnId(
30
+ 'replay',
31
+ sessionId,
32
+ requestId,
33
+ who === 'user' ? 'u' : 'o',
34
+ );
35
+ }
36
+
37
+ export interface MirrorSend {
38
+ roomId: string;
39
+ body: string;
40
+ formattedBody?: string;
41
+ /** The session's marker event — the thread the mirror is posted under. */
42
+ threadId: string;
43
+ txnId: string;
44
+ }
45
+
46
+ export interface RoomMirrorDeps {
47
+ sendText(send: MirrorSend): Promise<string>;
48
+ /** Keeps the (un-awaited) send alive past the request that started it: `ctx.waitUntil`. */
49
+ keepAlive(work: Promise<unknown>): void;
50
+ log(message: string): void;
51
+ warn(message: string): void;
52
+ /** Retry policy override (tests shrink the delays). */
53
+ retry?: Pick<RetryGatewayOptions, 'delaysMs' | 'sleep' | 'isTransient'>;
54
+ }
55
+
56
+ const errorText = (err: unknown): string =>
57
+ err instanceof Error ? err.message : String(err);
58
+
59
+ export class RoomMirror {
60
+ /** Per session: the tail of its send chain. Removed once the chain drains. */
61
+ private readonly chains = new Map<string, Promise<void>>();
62
+
63
+ constructor(private readonly deps: RoomMirrorDeps) {}
64
+
65
+ /** Sessions with a mirror still in flight. */
66
+ get pending(): number {
67
+ return this.chains.size;
68
+ }
69
+
70
+ /**
71
+ * Queue one mirror behind the session's previous ones. `prepare` runs when
72
+ * the session's earlier mirrors have settled and resolves what to send
73
+ * (`null` = nothing to mirror, e.g. no room). Never rejects: a mirror that
74
+ * cannot be posted is logged and the chain moves on.
75
+ */
76
+ enqueue(
77
+ sessionId: string,
78
+ prepare: () => Promise<MirrorSend | null>,
79
+ label: string,
80
+ ): Promise<void> {
81
+ const previous = this.chains.get(sessionId) ?? Promise.resolve();
82
+ const tracked: Promise<void> = previous
83
+ .then(() => prepare())
84
+ .then((send) => (send ? this.send(send, label) : undefined))
85
+ .catch((err: unknown) => {
86
+ this.deps.warn(
87
+ `[user-do] Matrix replay (${label}) failed — session=${sessionId}: ${errorText(err)}`,
88
+ );
89
+ })
90
+ .finally(() => {
91
+ if (this.chains.get(sessionId) === tracked)
92
+ this.chains.delete(sessionId);
93
+ });
94
+ this.chains.set(sessionId, tracked);
95
+ this.deps.keepAlive(tracked);
96
+ return tracked;
97
+ }
98
+
99
+ private async send(send: MirrorSend, label: string): Promise<void> {
100
+ const eventId = await retryGateway(() => this.deps.sendText(send), {
101
+ ...this.deps.retry,
102
+ onRetry: (err, attempt, delayMs) =>
103
+ this.deps.warn(
104
+ `[user-do] Matrix replay (${label}) send failed (attempt ${attempt}) — retrying in ${delayMs} ms with the same transaction id: ${errorText(err)}`,
105
+ ),
106
+ });
107
+ this.deps.log(
108
+ `[user-do] Matrix replay (${label}) → ${eventId} in thread ${send.threadId}`,
109
+ );
110
+ }
111
+ }