@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/do/contracts.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
392
|
-
|
|
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
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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
|
-
/**
|
|
455
|
-
|
|
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
|
-
|
|
535
|
+
uploadUserSnapshotStream(
|
|
469
536
|
userDid: string,
|
|
470
537
|
storageKey: string,
|
|
471
|
-
|
|
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
|
-
/**
|
|
475
|
-
|
|
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<
|
|
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
|
+
}
|