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