@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/user-oracle-do.ts
CHANGED
|
@@ -37,6 +37,8 @@ import {
|
|
|
37
37
|
type AttachmentViewSurface,
|
|
38
38
|
type MatrixMediaSource,
|
|
39
39
|
type SandboxUploadConfig,
|
|
40
|
+
MAX_FILE_SIZE,
|
|
41
|
+
readBytesCapped,
|
|
40
42
|
} from '../attachments';
|
|
41
43
|
import type { RuntimeCore } from '../core';
|
|
42
44
|
import {
|
|
@@ -81,12 +83,17 @@ import {
|
|
|
81
83
|
finishCompaction,
|
|
82
84
|
type CompactCursors,
|
|
83
85
|
} from '../sqlite/blob-compactor';
|
|
84
|
-
import { createHash } from 'node:crypto';
|
|
85
86
|
import {
|
|
86
87
|
cachePagesForBytes,
|
|
88
|
+
parseByteSize,
|
|
87
89
|
parseChunkCacheBytes,
|
|
88
90
|
} from '../sqlite/cache-config';
|
|
89
|
-
import { DoSqliteDatabase } from '../sqlite/database';
|
|
91
|
+
import { DoSqliteDatabase, type DoSqliteOpenOptions } from '../sqlite/database';
|
|
92
|
+
import type { TierFlushResult } from '../sqlite/do-vfs';
|
|
93
|
+
import {
|
|
94
|
+
DEFAULT_TIER_HOT_BUDGET_BYTES,
|
|
95
|
+
type PageTierOptions,
|
|
96
|
+
} from '../sqlite/page-tier';
|
|
90
97
|
import {
|
|
91
98
|
shouldVacuum,
|
|
92
99
|
vacuumWanted,
|
|
@@ -103,7 +110,9 @@ import {
|
|
|
103
110
|
UNTITLED_SESSION,
|
|
104
111
|
type SessionRecord,
|
|
105
112
|
} from '../sqlite/sessions-store';
|
|
113
|
+
import { createAttachmentDecryptor } from '@ixo/matrix-bot-workers-sdk';
|
|
106
114
|
import { MatrixMediaOwnerStore } from '../owner-store/matrix-media-store';
|
|
115
|
+
import { measureForSave } from '../owner-store/measure';
|
|
107
116
|
import { MigratingOwnerStore } from '../owner-store/migrating-store';
|
|
108
117
|
import type { OwnerCopy } from '../owner-store/types';
|
|
109
118
|
import {
|
|
@@ -135,7 +144,13 @@ import {
|
|
|
135
144
|
type TurnRequest,
|
|
136
145
|
type TurnResult,
|
|
137
146
|
type MemorySchemaDebug,
|
|
147
|
+
TURN_INTERRUPTED_MARKER,
|
|
138
148
|
} from './contracts';
|
|
149
|
+
import { turnRecursionLimit } from './turn-config';
|
|
150
|
+
import { decideMatrixTurn, MatrixTurnLedger } from './matrix-turn-ledger';
|
|
151
|
+
import { decideBootDirty } from './boot-dirty';
|
|
152
|
+
import { mirrorTxnId, RoomMirror } from './room-mirror';
|
|
153
|
+
import { ReauthPrompter } from './reauth-prompt';
|
|
139
154
|
import {
|
|
140
155
|
createTaskScheduler,
|
|
141
156
|
TASK_SESSION_PREFIX,
|
|
@@ -215,6 +230,14 @@ interface MatrixUserIdCache {
|
|
|
215
230
|
/** Consecutive flush failures (cleared on success). */
|
|
216
231
|
const META_FLUSH_FAILURES = 'meta:flushFailures';
|
|
217
232
|
const META_LAST_VACUUM = 'meta:lastVacuumAt';
|
|
233
|
+
/** When the last R2 page-tier eviction pass ran (see `tierTick`). */
|
|
234
|
+
const META_LAST_TIER_PASS = 'meta:lastTierPassAt';
|
|
235
|
+
/**
|
|
236
|
+
* Eviction passes at most this often per object. The horizon is two daily
|
|
237
|
+
* periods, so four checks a day catch chunks the moment they age out
|
|
238
|
+
* without re-reading the hot set more than that.
|
|
239
|
+
*/
|
|
240
|
+
const TIER_PASS_INTERVAL_MS = 6 * 60 * 60_000;
|
|
218
241
|
/**
|
|
219
242
|
* Persisted twin of the in-memory `dirty` flag. The flag alone dies with an
|
|
220
243
|
* eviction, and evictions are routine — without the marker an alarm waking a
|
|
@@ -239,6 +262,16 @@ const DELEGATION_MISS_TTL_MS = 60_000;
|
|
|
239
262
|
const META_REAUTH_PROMPT_AT = 'meta:reauthPromptAt';
|
|
240
263
|
/** Node's `AgentBuilder.DEFAULT_REAUTH_THROTTLE_SECONDS`. */
|
|
241
264
|
const DEFAULT_REAUTH_THROTTLE_SECONDS = 6 * 60 * 60;
|
|
265
|
+
|
|
266
|
+
/** `UCAN_REAUTH_PROMPT_THROTTLE_SECONDS` as a positive number, else the default. */
|
|
267
|
+
function reauthThrottleSeconds(env: OracleWorkerEnv): number {
|
|
268
|
+
const raw = env.UCAN_REAUTH_PROMPT_THROTTLE_SECONDS;
|
|
269
|
+
return typeof raw === 'string' &&
|
|
270
|
+
Number.isFinite(Number(raw)) &&
|
|
271
|
+
Number(raw) > 0
|
|
272
|
+
? Number(raw)
|
|
273
|
+
: DEFAULT_REAUTH_THROTTLE_SECONDS;
|
|
274
|
+
}
|
|
242
275
|
/** Highest whole-GB size watermark already alerted to Slack (0 = none). */
|
|
243
276
|
const META_SIZE_ALERT_GB = 'meta:sizeAlertGb';
|
|
244
277
|
/** Blob re-compression bookkeeping (see sqlite/blob-compactor.ts). */
|
|
@@ -295,22 +328,6 @@ interface FlushResult {
|
|
|
295
328
|
}
|
|
296
329
|
|
|
297
330
|
/** Hex SHA-256 of a byte stream, hashed as it flows. */
|
|
298
|
-
async function sha256OfStream(
|
|
299
|
-
stream: ReadableStream<Uint8Array>,
|
|
300
|
-
): Promise<string> {
|
|
301
|
-
const hash = createHash('sha256');
|
|
302
|
-
const reader = stream.getReader();
|
|
303
|
-
try {
|
|
304
|
-
for (;;) {
|
|
305
|
-
const { done, value } = await reader.read();
|
|
306
|
-
if (done) break;
|
|
307
|
-
hash.update(value);
|
|
308
|
-
}
|
|
309
|
-
} finally {
|
|
310
|
-
reader.releaseLock();
|
|
311
|
-
}
|
|
312
|
-
return hash.digest('hex');
|
|
313
|
-
}
|
|
314
331
|
|
|
315
332
|
/** Does this adapter expose provider config + role resolution (platform adapters do)? */
|
|
316
333
|
function isProviderAdapter(llm: LlmAdapter): llm is OpenRouterLlmAdapter {
|
|
@@ -433,6 +450,16 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
433
450
|
|
|
434
451
|
/** In-flight Matrix turn per session (sessionId → requestId), for supersede. */
|
|
435
452
|
private readonly matrixTurns = new Map<string, string>();
|
|
453
|
+
/** What this object remembers about each room message it answered (see matrix-turn-ledger.ts). */
|
|
454
|
+
private matrixLedger: MatrixTurnLedger | null = null;
|
|
455
|
+
/** Room mirrors of HTTP turns, serialised per session and retried across gateway restarts (see room-mirror.ts). */
|
|
456
|
+
private mirror: RoomMirror | null = null;
|
|
457
|
+
/** The oracle room of each session this instance resolved: spares the mirrors a row read per send. */
|
|
458
|
+
private readonly sessionRooms = new Map<string, string>();
|
|
459
|
+
/** The throttled `delegation_required` prompt (see reauth-prompt.ts). */
|
|
460
|
+
private reauthPrompter: ReauthPrompter | null = null;
|
|
461
|
+
/** Room turns running right now, by Matrix event id: a gateway that asks again attaches instead of re-running. */
|
|
462
|
+
private readonly matrixTurnRuns = new Map<string, Promise<TurnResult>>();
|
|
436
463
|
|
|
437
464
|
/** Session-history → memory-engine indexing (built lazily, see `scheduleHistoryIndexing`). */
|
|
438
465
|
private historyIndexer: SessionHistoryIndexer | null = null;
|
|
@@ -447,6 +474,8 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
447
474
|
>();
|
|
448
475
|
private initPromise: Promise<void> | null = null;
|
|
449
476
|
private reloadedFromOwnerStore = false;
|
|
477
|
+
/** Set by `adoptOwnerCopy` for a legacy copy: `ready()` flushes it to the system of record right after. */
|
|
478
|
+
private pendingLegacyMigration: { bytes: number } | null = null;
|
|
450
479
|
|
|
451
480
|
// ── lifecycle ───────────────────────────────────────────────────────────
|
|
452
481
|
|
|
@@ -792,7 +821,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
792
821
|
const imported = await this.adoptOwnerCopy(db, loaded);
|
|
793
822
|
this.reloadedFromOwnerStore = true;
|
|
794
823
|
console.log(
|
|
795
|
-
`[user-do] imported ${imported} bytes from ${this.ownerStore.kind} for ${userDid}`,
|
|
824
|
+
`[user-do] imported ${imported} bytes from ${loaded.fromLegacy ? 'legacy Matrix media' : this.ownerStore.kind} for ${userDid}`,
|
|
796
825
|
);
|
|
797
826
|
}
|
|
798
827
|
} else {
|
|
@@ -926,6 +955,31 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
926
955
|
|
|
927
956
|
this.db ??= db;
|
|
928
957
|
const liveDb = this.db;
|
|
958
|
+
if (this.pendingLegacyMigration) {
|
|
959
|
+
const { bytes } = this.pendingLegacyMigration;
|
|
960
|
+
this.pendingLegacyMigration = null;
|
|
961
|
+
// Second half of the one-time migration: the legacy copy just
|
|
962
|
+
// imported goes to the system of record from the working copy —
|
|
963
|
+
// streamed from a snapshot like every flush, so a Node-era history of
|
|
964
|
+
// any size costs a few chunks of memory. A failure (no
|
|
965
|
+
// `ixo:filesystem` delegation yet, a VFS outage) keeps the copy dirty
|
|
966
|
+
// and retried every 10 min; the object serves the legacy history
|
|
967
|
+
// meanwhile, and the Matrix copy is only redacted once VFS holds it.
|
|
968
|
+
try {
|
|
969
|
+
const flushed = await this.flushToOwnerStore();
|
|
970
|
+
console.log(
|
|
971
|
+
`[user-do] migrated ${bytes} bytes from legacy Matrix media to ${this.ownerStore.kind} for ${userDid} (${flushed.etag ?? '-'})`,
|
|
972
|
+
);
|
|
973
|
+
} catch (err) {
|
|
974
|
+
console.warn(
|
|
975
|
+
`[user-do] VFS migration write failed for ${userDid} — serving the legacy Matrix copy for now: ${err instanceof Error ? err.message : String(err)}`,
|
|
976
|
+
);
|
|
977
|
+
// Sooner than the 24 h debounce `markDirty` armed: the system of
|
|
978
|
+
// record is still empty for this user.
|
|
979
|
+
this.requestAlarm(Date.now() + FLUSH_RETRY_DELAY_MS);
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
await this.reconcileDirtyOnBoot(liveDb, userDid);
|
|
929
983
|
{
|
|
930
984
|
// One line per boot so an operator can tell what a user's object
|
|
931
985
|
// actually holds without a debug route: which store, how big, how
|
|
@@ -959,6 +1013,8 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
959
1013
|
await this.saver.setup();
|
|
960
1014
|
this.sessions = new SessionsStore(liveDb);
|
|
961
1015
|
await this.sessions.setup();
|
|
1016
|
+
this.matrixLedger = new MatrixTurnLedger(liveDb);
|
|
1017
|
+
await this.matrixLedger.setup();
|
|
962
1018
|
|
|
963
1019
|
const core = this.core;
|
|
964
1020
|
// Boot-time plugin hooks + collision checks, once per object (memoised).
|
|
@@ -1003,8 +1059,13 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1003
1059
|
logger: console,
|
|
1004
1060
|
});
|
|
1005
1061
|
this.workStatus = new WorkStatusProducer({
|
|
1006
|
-
postEvent: (roomId, type, content) =>
|
|
1007
|
-
this.gateway.sendEvent(
|
|
1062
|
+
postEvent: (roomId, type, content, opts) =>
|
|
1063
|
+
this.gateway.sendEvent(
|
|
1064
|
+
roomId,
|
|
1065
|
+
type,
|
|
1066
|
+
JSON.stringify(content),
|
|
1067
|
+
opts ?? {},
|
|
1068
|
+
),
|
|
1008
1069
|
logger: console,
|
|
1009
1070
|
});
|
|
1010
1071
|
this.ambient = createAmbientServices({
|
|
@@ -1022,6 +1083,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1022
1083
|
delegationFor: (did) => this.delegations.get(did),
|
|
1023
1084
|
events: this.events,
|
|
1024
1085
|
secrets: createSecretsAdapter(secretsService),
|
|
1086
|
+
background: (work) => this.ctx.waitUntil(work),
|
|
1025
1087
|
});
|
|
1026
1088
|
}
|
|
1027
1089
|
|
|
@@ -1067,7 +1129,8 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1067
1129
|
if (db) {
|
|
1068
1130
|
await db.close().catch(() => undefined);
|
|
1069
1131
|
}
|
|
1070
|
-
|
|
1132
|
+
const tier = this.tierOptions();
|
|
1133
|
+
await DoSqliteDatabase.wipe(this.ctx, DB_FILE, tier ? { tier } : {});
|
|
1071
1134
|
await this.ctx.storage.delete([
|
|
1072
1135
|
META_OWNER_ETAG,
|
|
1073
1136
|
META_LAST_CHECKSUM,
|
|
@@ -1078,12 +1141,40 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1078
1141
|
]);
|
|
1079
1142
|
}
|
|
1080
1143
|
|
|
1081
|
-
/**
|
|
1082
|
-
|
|
1144
|
+
/**
|
|
1145
|
+
* `CHUNK_CACHE_BYTES` → the VFS clean-chunk budget for this object, and
|
|
1146
|
+
* the R2 page tier when the script binds `TIER_BUCKET` (the object's id
|
|
1147
|
+
* is its key prefix; the knobs are documented in `contracts.ts`).
|
|
1148
|
+
*/
|
|
1149
|
+
private sqliteOpenOptions(): DoSqliteOpenOptions {
|
|
1083
1150
|
const bytes = parseChunkCacheBytes(this.env.CHUNK_CACHE_BYTES, (m) =>
|
|
1084
1151
|
console.warn(`[user-do] ${m}`),
|
|
1085
1152
|
);
|
|
1086
|
-
|
|
1153
|
+
const tier = this.tierOptions();
|
|
1154
|
+
return { cachePages: cachePagesForBytes(bytes), ...(tier && { tier }) };
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
private tierOptions(): PageTierOptions | undefined {
|
|
1158
|
+
const bucket = this.env.TIER_BUCKET;
|
|
1159
|
+
if (!bucket) return undefined;
|
|
1160
|
+
const warn = (m: string): void => console.warn(`[user-do] ${m}`);
|
|
1161
|
+
const hotBudgetBytes = parseByteSize(this.env.TIER_HOT_BUDGET_BYTES, {
|
|
1162
|
+
name: 'TIER_HOT_BUDGET_BYTES',
|
|
1163
|
+
fallback: DEFAULT_TIER_HOT_BUDGET_BYTES,
|
|
1164
|
+
min: 1024 * 1024,
|
|
1165
|
+
max: 1024 * 1024 * 1024,
|
|
1166
|
+
warn,
|
|
1167
|
+
});
|
|
1168
|
+
const periods = Number(this.env.TIER_EVICT_AFTER_PERIODS);
|
|
1169
|
+
const periodMs = Number(this.env.TIER_PERIOD_MS);
|
|
1170
|
+
return {
|
|
1171
|
+
bucket,
|
|
1172
|
+
prefix: this.ctx.id.toString(),
|
|
1173
|
+
hotBudgetBytes,
|
|
1174
|
+
...(Number.isInteger(periods) &&
|
|
1175
|
+
periods >= 1 && { evictAfterPeriods: periods }),
|
|
1176
|
+
...(Number.isFinite(periodMs) && periodMs >= 1000 && { periodMs }),
|
|
1177
|
+
};
|
|
1087
1178
|
}
|
|
1088
1179
|
|
|
1089
1180
|
/**
|
|
@@ -1094,10 +1185,25 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1094
1185
|
*/
|
|
1095
1186
|
private async adoptOwnerCopy(
|
|
1096
1187
|
db: DoSqliteDatabase,
|
|
1097
|
-
copy:
|
|
1188
|
+
copy: OwnerCopy,
|
|
1098
1189
|
): Promise<number> {
|
|
1099
1190
|
const bytes = await db.importFromStream(copy.stream);
|
|
1100
1191
|
await this.ctx.storage.put(META_OWNER_ETAG, copy.etag);
|
|
1192
|
+
if (copy.fromLegacy) {
|
|
1193
|
+
// Streamed in from the legacy Matrix media, not in the system of
|
|
1194
|
+
// record yet: forget any earlier upload markers so the flush that
|
|
1195
|
+
// `ready()` runs next (`pendingLegacyMigration`) actually sends it,
|
|
1196
|
+
// and forget "no legacy copy, stop looking" — a copy evidently
|
|
1197
|
+
// exists now, and the flush must be able to redact it.
|
|
1198
|
+
await this.ctx.storage.delete([
|
|
1199
|
+
META_LAST_CHECKSUM,
|
|
1200
|
+
META_UPLOADED_GEN,
|
|
1201
|
+
META_LEGACY_CLEARED,
|
|
1202
|
+
]);
|
|
1203
|
+
this.markDirty();
|
|
1204
|
+
this.pendingLegacyMigration = { bytes };
|
|
1205
|
+
return bytes;
|
|
1206
|
+
}
|
|
1101
1207
|
await this.ctx.storage.put(META_LAST_CHECKSUM, await db.checksum());
|
|
1102
1208
|
await this.ctx.storage.put(META_UPLOADED_GEN, db.writeGeneration);
|
|
1103
1209
|
return bytes;
|
|
@@ -1226,8 +1332,13 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1226
1332
|
}
|
|
1227
1333
|
|
|
1228
1334
|
// Legacy-blob compaction: one bounded batch per tick until the whole
|
|
1229
|
-
// file is codec-compressed (see sqlite/blob-compactor.ts).
|
|
1230
|
-
|
|
1335
|
+
// file is codec-compressed (see sqlite/blob-compactor.ts). A flush in
|
|
1336
|
+
// flight holds a snapshot of the file, which the compactor's swap-in
|
|
1337
|
+
// cannot replace — a large migration flush (streamed, seconds long)
|
|
1338
|
+
// routinely overlaps the first tick, so wait for it instead of erroring.
|
|
1339
|
+
if (this.db && this.flushInFlight) {
|
|
1340
|
+
deadlines.push(now + 5000);
|
|
1341
|
+
} else if (this.db) {
|
|
1231
1342
|
try {
|
|
1232
1343
|
if (await this.compactionTick(this.db)) deadlines.push(now + 2000);
|
|
1233
1344
|
} catch (err) {
|
|
@@ -1249,6 +1360,15 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1249
1360
|
if (next !== null) deadlines.push(next);
|
|
1250
1361
|
}
|
|
1251
1362
|
|
|
1363
|
+
// R2 page tier: move chunks no turn touched for a couple of days out
|
|
1364
|
+
// of DO storage (see sqlite/page-tier.ts). After the flush above (the
|
|
1365
|
+
// upload reads hot rows, not R2) and never over an in-flight one (it
|
|
1366
|
+
// holds a snapshot). A pass is capped; more work re-arms soon.
|
|
1367
|
+
if (this.db?.tierEnabled && !this.flushInFlight) {
|
|
1368
|
+
const next = await this.tierTick(this.db, now);
|
|
1369
|
+
if (next !== null) deadlines.push(next);
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1252
1372
|
// Idle housekeeping: a user silent for a day loses the cached pages —
|
|
1253
1373
|
// their file is safe upstream, and we stop paying to store a copy. Never
|
|
1254
1374
|
// evict while tasks are scheduled: their runs ARE activity. The flush
|
|
@@ -1277,6 +1397,71 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1277
1397
|
);
|
|
1278
1398
|
}
|
|
1279
1399
|
|
|
1400
|
+
/**
|
|
1401
|
+
* One page-tier eviction pass, at most once per `TIER_PASS_INTERVAL_MS`
|
|
1402
|
+
* (a pass that hit its segment cap re-arms in a minute). Returns the next
|
|
1403
|
+
* time a pass is due, or null when nothing is pending.
|
|
1404
|
+
*/
|
|
1405
|
+
private async tierTick(
|
|
1406
|
+
db: DoSqliteDatabase,
|
|
1407
|
+
now: number,
|
|
1408
|
+
): Promise<number | null> {
|
|
1409
|
+
const last =
|
|
1410
|
+
(await this.ctx.storage.get<number>(META_LAST_TIER_PASS)) ?? 0;
|
|
1411
|
+
if (now - last < TIER_PASS_INTERVAL_MS)
|
|
1412
|
+
return last + TIER_PASS_INTERVAL_MS;
|
|
1413
|
+
try {
|
|
1414
|
+
const pass = await db.tierFlush();
|
|
1415
|
+
await this.ctx.storage.put(META_LAST_TIER_PASS, now);
|
|
1416
|
+
if (pass.skipped) {
|
|
1417
|
+
if (pass.skipped !== 'no-tier')
|
|
1418
|
+
console.log(
|
|
1419
|
+
`[user-do] tier pass skipped for ${this.userDid}: ${pass.skipped}`,
|
|
1420
|
+
);
|
|
1421
|
+
return now + 60_000;
|
|
1422
|
+
}
|
|
1423
|
+
if (pass.evictedChunks > 0 || pass.remaining > 0) {
|
|
1424
|
+
console.log(
|
|
1425
|
+
`[user-do] tier pass for ${this.userDid}: ${pass.evictedChunks} chunks → R2 in ${pass.segmentsRewritten} segment(s), ${pass.hotRows} hot rows (${Math.round(pass.hotBytes / 1024 / 1024)} MB) kept, ${pass.remaining} segment(s) pending`,
|
|
1426
|
+
);
|
|
1427
|
+
}
|
|
1428
|
+
const budget = this.tierOptions()?.hotBudgetBytes;
|
|
1429
|
+
if (
|
|
1430
|
+
budget !== undefined &&
|
|
1431
|
+
pass.remaining === 0 &&
|
|
1432
|
+
pass.hotBytes > budget
|
|
1433
|
+
) {
|
|
1434
|
+
// Everything left is recent: the user's real working set exceeds
|
|
1435
|
+
// the target — a sizing signal, not a failure.
|
|
1436
|
+
console.warn(
|
|
1437
|
+
`[user-do] tier hot set of ${this.userDid} is ${Math.round(pass.hotBytes / 1024 / 1024)} MB, over the ${Math.round(budget / 1024 / 1024)} MB target (all of it touched within the eviction horizon)`,
|
|
1438
|
+
);
|
|
1439
|
+
}
|
|
1440
|
+
return pass.remaining > 0 ? now + 60_000 : now + TIER_PASS_INTERVAL_MS;
|
|
1441
|
+
} catch (err) {
|
|
1442
|
+
console.error(
|
|
1443
|
+
`[user-do] tier pass failed for ${this.userDid}: ${err instanceof Error ? err.message : String(err)}`,
|
|
1444
|
+
);
|
|
1445
|
+
return now + FLUSH_RETRY_DELAY_MS;
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
/** Operator/test entry: run an eviction pass now (debug routes). */
|
|
1450
|
+
async tierFlush(
|
|
1451
|
+
opts: { force?: boolean; maxSegments?: number } = {},
|
|
1452
|
+
): Promise<TierFlushResult> {
|
|
1453
|
+
if (!this.db) {
|
|
1454
|
+
const userDid =
|
|
1455
|
+
this.userDid ?? (await this.ctx.storage.get<string>(META_USER_DID));
|
|
1456
|
+
if (userDid) await this.ready({ userDid });
|
|
1457
|
+
}
|
|
1458
|
+
if (!this.db) throw new Error('no working copy');
|
|
1459
|
+
if (this.flushInFlight) await this.flushInFlight.catch(() => undefined);
|
|
1460
|
+
const pass = await this.db.tierFlush(opts);
|
|
1461
|
+
await this.ctx.storage.put(META_LAST_TIER_PASS, Date.now());
|
|
1462
|
+
return pass;
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1280
1465
|
/**
|
|
1281
1466
|
* One VACUUM policy check. Returns the time of a follow-up check when a
|
|
1282
1467
|
* rebuild is wanted but the object is not quiet yet, else null.
|
|
@@ -1579,42 +1764,70 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1579
1764
|
}
|
|
1580
1765
|
|
|
1581
1766
|
/**
|
|
1582
|
-
*
|
|
1583
|
-
*
|
|
1584
|
-
*
|
|
1585
|
-
*
|
|
1767
|
+
* Ask the web app to open its "authorize for Matrix" modal in place when a
|
|
1768
|
+
* Matrix turn finds no valid delegation — the Node AgentBuilder's
|
|
1769
|
+
* `maybePromptReauth`, throttled per object
|
|
1770
|
+
* (`UCAN_REAUTH_PROMPT_THROTTLE_SECONDS`, default 6 h). Never throws; the
|
|
1771
|
+
* prompt outlives the turn under `waitUntil` while it is retried across a
|
|
1772
|
+
* gateway restart (see reauth-prompt.ts for the ordering that matters).
|
|
1586
1773
|
*/
|
|
1587
|
-
private
|
|
1774
|
+
private promptReauth(userDid: string, roomId: string): void {
|
|
1775
|
+
this.reauthPrompter ??= new ReauthPrompter({
|
|
1776
|
+
throttleMs: reauthThrottleSeconds(this.env) * 1000,
|
|
1777
|
+
getStamp: () => this.ctx.storage.get<number>(META_REAUTH_PROMPT_AT),
|
|
1778
|
+
setStamp: (at) => this.ctx.storage.put(META_REAUTH_PROMPT_AT, at),
|
|
1779
|
+
send: (room, txnId) =>
|
|
1780
|
+
this.gateway.sendEvent(
|
|
1781
|
+
room,
|
|
1782
|
+
'ixo.oracle.delegation_required',
|
|
1783
|
+
JSON.stringify({
|
|
1784
|
+
oracleEntityDid: this.core.identity.entityDid,
|
|
1785
|
+
oracleDid: this.env.ORACLE_DID,
|
|
1786
|
+
}),
|
|
1787
|
+
{ txnId },
|
|
1788
|
+
),
|
|
1789
|
+
keepAlive: (work) => this.ctx.waitUntil(work),
|
|
1790
|
+
log: (message) => console.log(message),
|
|
1791
|
+
warn: (message) => console.warn(message),
|
|
1792
|
+
});
|
|
1793
|
+
void this.reauthPrompter.prompt(userDid, roomId);
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
/** Debug route: forget when the last re-authorise prompt was posted, so a drill can trigger the next one. */
|
|
1797
|
+
async debugResetReauthThrottle(): Promise<void> {
|
|
1798
|
+
await this.ctx.storage.delete(META_REAUTH_PROMPT_AT);
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
/**
|
|
1802
|
+
* A turn that died mid-way leaves committed checkpoint steps with no dirty
|
|
1803
|
+
* mark (the mark is set at the end of a turn). Compare the file's write
|
|
1804
|
+
* generation with the last upload's on boot and mark the copy dirty when
|
|
1805
|
+
* it moved on; one batched storage read per boot (see boot-dirty.ts).
|
|
1806
|
+
*/
|
|
1807
|
+
private async reconcileDirtyOnBoot(
|
|
1808
|
+
db: DoSqliteDatabase,
|
|
1588
1809
|
userDid: string,
|
|
1589
|
-
roomId: string,
|
|
1590
1810
|
): Promise<void> {
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
oracleEntityDid: this.core.identity.entityDid,
|
|
1608
|
-
oracleDid: this.env.ORACLE_DID,
|
|
1609
|
-
}),
|
|
1610
|
-
);
|
|
1811
|
+
if (this.dirty) return;
|
|
1812
|
+
const got = await this.ctx.storage.get<unknown>([
|
|
1813
|
+
META_DIRTY,
|
|
1814
|
+
META_UPLOADED_GEN,
|
|
1815
|
+
]);
|
|
1816
|
+
const decision = decideBootDirty({
|
|
1817
|
+
dirtyInMemory: this.dirty,
|
|
1818
|
+
dirtyFlag: got.get(META_DIRTY),
|
|
1819
|
+
uploadedGen: got.get(META_UPLOADED_GEN),
|
|
1820
|
+
writeGeneration: db.writeGeneration,
|
|
1821
|
+
});
|
|
1822
|
+
if (decision === 'flagged') {
|
|
1823
|
+
// The mark is on disk already (its flush alarm too); just adopt it.
|
|
1824
|
+
this.dirty = true;
|
|
1825
|
+
this.requestAlarm(Date.now() + FLUSH_DEBOUNCE_MS);
|
|
1826
|
+
} else if (decision === 'behind-upload') {
|
|
1611
1827
|
console.log(
|
|
1612
|
-
`[user-do]
|
|
1613
|
-
);
|
|
1614
|
-
} catch (err) {
|
|
1615
|
-
console.warn(
|
|
1616
|
-
`[user-do] delegation-required event failed for ${userDid}: ${err instanceof Error ? err.message : String(err)}`,
|
|
1828
|
+
`[user-do] working copy of ${userDid} is at generation ${db.writeGeneration}, last upload at ${String(got.get(META_UPLOADED_GEN))} — a turn ended without marking it; flushing`,
|
|
1617
1829
|
);
|
|
1830
|
+
this.markDirty();
|
|
1618
1831
|
}
|
|
1619
1832
|
}
|
|
1620
1833
|
|
|
@@ -1656,8 +1869,68 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1656
1869
|
return true;
|
|
1657
1870
|
}
|
|
1658
1871
|
|
|
1872
|
+
/**
|
|
1873
|
+
* A room turn runs at most once per Matrix event, however many times the
|
|
1874
|
+
* gateway asks (it asks again after a reset, see the inbox in
|
|
1875
|
+
* `src/matrix/gateway-do.ts`). The ledger and the in-flight map decide:
|
|
1876
|
+
* answered → stored reply; running → attach; started but lost in a reset
|
|
1877
|
+
* of this object → refused (tools may have run), the user is told to
|
|
1878
|
+
* retry; unknown → run once. HTTP and task turns carry no event id and
|
|
1879
|
+
* always run.
|
|
1880
|
+
*/
|
|
1659
1881
|
async runTurn(req: TurnRequest): Promise<TurnResult> {
|
|
1660
1882
|
await this.ready(req.identity);
|
|
1883
|
+
const eventId = req.client === 'matrix' ? req.eventId : undefined;
|
|
1884
|
+
const ledger = this.matrixLedger;
|
|
1885
|
+
if (!eventId || !ledger) return this.runTurnOnce(req);
|
|
1886
|
+
|
|
1887
|
+
const running = this.matrixTurnRuns.get(eventId);
|
|
1888
|
+
if (running) {
|
|
1889
|
+
console.log(
|
|
1890
|
+
`[user-do] turn for ${eventId} is still running; the gateway asked again and attaches to it`,
|
|
1891
|
+
);
|
|
1892
|
+
return running;
|
|
1893
|
+
}
|
|
1894
|
+
const existing = await ledger.get(eventId);
|
|
1895
|
+
switch (decideMatrixTurn(existing)) {
|
|
1896
|
+
case 'answered':
|
|
1897
|
+
console.log(
|
|
1898
|
+
`[user-do] turn for ${eventId} already answered; returning the stored reply`,
|
|
1899
|
+
);
|
|
1900
|
+
return {
|
|
1901
|
+
sessionId: req.sessionId,
|
|
1902
|
+
requestId: req.requestId,
|
|
1903
|
+
text: existing?.replyText ?? '',
|
|
1904
|
+
toolCalls: [],
|
|
1905
|
+
replayed: true,
|
|
1906
|
+
};
|
|
1907
|
+
case 'interrupted':
|
|
1908
|
+
throw new Error(`${TURN_INTERRUPTED_MARKER} (${eventId})`);
|
|
1909
|
+
case 'run':
|
|
1910
|
+
break;
|
|
1911
|
+
}
|
|
1912
|
+
await ledger.start(eventId, req.sessionId, req.requestId);
|
|
1913
|
+
const run = this.runTurnOnce(req)
|
|
1914
|
+
.then(async (result) => {
|
|
1915
|
+
await ledger.answer(eventId, result.text);
|
|
1916
|
+
return result;
|
|
1917
|
+
})
|
|
1918
|
+
.finally(() => {
|
|
1919
|
+
this.matrixTurnRuns.delete(eventId);
|
|
1920
|
+
});
|
|
1921
|
+
this.matrixTurnRuns.set(eventId, run);
|
|
1922
|
+
// The turn's I/O belongs to this RPC's request context. If the gateway
|
|
1923
|
+
// that made the call is reset, the context would be cancelled with it:
|
|
1924
|
+
// fetches started afterwards (the next model call, the next tool) never
|
|
1925
|
+
// settle, and the turn hangs forever — with a replay attached to it.
|
|
1926
|
+
// `waitUntil` keeps the context alive until the turn has ended, so the
|
|
1927
|
+
// turn finishes, the ledger records the reply, and the replayed request
|
|
1928
|
+
// returns it.
|
|
1929
|
+
this.ctx.waitUntil(run.catch(() => undefined));
|
|
1930
|
+
return run;
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
private async runTurnOnce(req: TurnRequest): Promise<TurnResult> {
|
|
1661
1934
|
// Matrix turns drive a `work_status` card in the user's thread; a newer
|
|
1662
1935
|
// message on the same session supersedes the previous card (the
|
|
1663
1936
|
// previous turn itself is aborted by prepareTurn).
|
|
@@ -1809,6 +2082,13 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1809
2082
|
* while one runs joins it.
|
|
1810
2083
|
*/
|
|
1811
2084
|
async flushToOwnerStore(): Promise<FlushResult> {
|
|
2085
|
+
if (!this.db) {
|
|
2086
|
+
// A cold (evicted) object: boot from the persisted identity first, so
|
|
2087
|
+
// an operator's `POST /debug/storage/flush` is never a silent no-op.
|
|
2088
|
+
const userDid =
|
|
2089
|
+
this.userDid ?? (await this.ctx.storage.get<string>(META_USER_DID));
|
|
2090
|
+
if (userDid) await this.ready({ userDid });
|
|
2091
|
+
}
|
|
1812
2092
|
if (this.flushInFlight) return this.flushInFlight;
|
|
1813
2093
|
const run = this.flushOnce().finally(() => {
|
|
1814
2094
|
this.flushInFlight = null;
|
|
@@ -1836,7 +2116,11 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1836
2116
|
|
|
1837
2117
|
const snapshot = await db.snapshot();
|
|
1838
2118
|
try {
|
|
1839
|
-
|
|
2119
|
+
// One pass yields the change gate (hash) AND the length the store
|
|
2120
|
+
// needs to upload; the upload is the only other read of the file.
|
|
2121
|
+
const { sha256Hex: checksum, gzippedLength } = await measureForSave(
|
|
2122
|
+
snapshot.open(),
|
|
2123
|
+
);
|
|
1840
2124
|
const last = await this.ctx.storage.get<string>(META_LAST_CHECKSUM);
|
|
1841
2125
|
if (last === checksum) {
|
|
1842
2126
|
await this.ctx.storage.put(META_UPLOADED_GEN, snapshot.generation);
|
|
@@ -1851,7 +2135,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1851
2135
|
|
|
1852
2136
|
let saved: { etag: string; bytes: number };
|
|
1853
2137
|
try {
|
|
1854
|
-
saved = await store.save(snapshot);
|
|
2138
|
+
saved = await store.save(snapshot, { gzippedLength });
|
|
1855
2139
|
} catch (err) {
|
|
1856
2140
|
// Never diverted anywhere else: the working copy in Durable Object
|
|
1857
2141
|
// storage is durable, stays dirty, and is retried. Loud from the
|
|
@@ -1976,6 +2260,8 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1976
2260
|
this.db = null;
|
|
1977
2261
|
this.saver = null;
|
|
1978
2262
|
this.sessions = null;
|
|
2263
|
+
this.matrixLedger = null;
|
|
2264
|
+
this.sessionRooms.clear();
|
|
1979
2265
|
this.ambient = null;
|
|
1980
2266
|
this.secretsService = null;
|
|
1981
2267
|
this.byo = null;
|
|
@@ -1994,9 +2280,21 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1994
2280
|
async debugSession(
|
|
1995
2281
|
userDid: string,
|
|
1996
2282
|
sessionId: string,
|
|
1997
|
-
): Promise<
|
|
2283
|
+
): Promise<Record<string, unknown> | null> {
|
|
1998
2284
|
await this.ready({ userDid });
|
|
1999
|
-
|
|
2285
|
+
const session = await this.sessions!.getSession(sessionId);
|
|
2286
|
+
if (!session) return null;
|
|
2287
|
+
// Whether the thread's agent context was condensed: the summarization
|
|
2288
|
+
// middleware's bookkeeping message is stored with the history but
|
|
2289
|
+
// never listed, so `GET /messages/:id` alone cannot tell.
|
|
2290
|
+
const all = await this.saver!.listThreadMessages(sessionId);
|
|
2291
|
+
return {
|
|
2292
|
+
...session,
|
|
2293
|
+
threadMessages: all.length,
|
|
2294
|
+
summaryMessages: all.filter(isSummarizationMessage).length,
|
|
2295
|
+
// Folded into their assistant messages by the listing, not shown alone.
|
|
2296
|
+
toolMessages: all.filter((m) => m.type === 'tool').length,
|
|
2297
|
+
};
|
|
2000
2298
|
}
|
|
2001
2299
|
|
|
2002
2300
|
/**
|
|
@@ -2004,6 +2302,16 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
2004
2302
|
* this user's credentials and return `search_memory_engine`'s raw schema
|
|
2005
2303
|
* plus whether it converts to Zod. Same headers the memory plugin sends.
|
|
2006
2304
|
*/
|
|
2305
|
+
/**
|
|
2306
|
+
* Operator / testing aid behind `ORACLE_DEBUG_ROUTES`: reset this object
|
|
2307
|
+
* the way an unplanned platform reset does — in-memory state and every
|
|
2308
|
+
* in-flight turn are gone, committed storage survives, the next request
|
|
2309
|
+
* boots a fresh instance. The RPC rejects by design.
|
|
2310
|
+
*/
|
|
2311
|
+
async debugAbortObject(): Promise<void> {
|
|
2312
|
+
this.ctx.abort('debug reset requested');
|
|
2313
|
+
}
|
|
2314
|
+
|
|
2007
2315
|
async debugMemorySchema(userDid: string): Promise<MemorySchemaDebug> {
|
|
2008
2316
|
await this.ready({ userDid });
|
|
2009
2317
|
const ambient = this.ambient;
|
|
@@ -2092,10 +2400,17 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
2092
2400
|
rowsWritten: vfs.rowsWritten,
|
|
2093
2401
|
}
|
|
2094
2402
|
: undefined,
|
|
2403
|
+
tier: db
|
|
2404
|
+
? {
|
|
2405
|
+
...db.tierStatus(),
|
|
2406
|
+
lastPassAt:
|
|
2407
|
+
await this.ctx.storage.get<number>(META_LAST_TIER_PASS),
|
|
2408
|
+
}
|
|
2409
|
+
: undefined,
|
|
2095
2410
|
};
|
|
2096
2411
|
}
|
|
2097
2412
|
|
|
2098
|
-
async tasksStatus(): Promise<{
|
|
2413
|
+
async tasksStatus(userDid?: string): Promise<{
|
|
2099
2414
|
now: number;
|
|
2100
2415
|
alarm: number | null;
|
|
2101
2416
|
schedulerActive: boolean;
|
|
@@ -2107,14 +2422,41 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
2107
2422
|
lastResult: { ok: boolean; summary: string; at: string } | null;
|
|
2108
2423
|
consecutiveFailures: number;
|
|
2109
2424
|
}>;
|
|
2425
|
+
openRuns: Array<{
|
|
2426
|
+
runId: string;
|
|
2427
|
+
taskId: string;
|
|
2428
|
+
startedAt: string;
|
|
2429
|
+
state: 'running' | 'delivering';
|
|
2430
|
+
attempts: number;
|
|
2431
|
+
retryAt: number | null;
|
|
2432
|
+
}>;
|
|
2110
2433
|
}> {
|
|
2434
|
+
// A cold (evicted or reset) object has no scheduler until it boots;
|
|
2435
|
+
// reporting "no tasks" for it would be wrong, so boot like the alarm
|
|
2436
|
+
// does when the caller — or the stored identity — says whose object.
|
|
2437
|
+
if (!this.taskScheduler) {
|
|
2438
|
+
const did =
|
|
2439
|
+
userDid ?? (await this.ctx.storage.get<string>(META_USER_DID));
|
|
2440
|
+
if (did) await this.ready({ userDid: did });
|
|
2441
|
+
}
|
|
2111
2442
|
const records = this.taskScheduler
|
|
2112
2443
|
? await this.taskScheduler.surface.list()
|
|
2113
2444
|
: [];
|
|
2445
|
+
const openRuns = this.taskScheduler
|
|
2446
|
+
? await this.taskScheduler.openRuns()
|
|
2447
|
+
: [];
|
|
2114
2448
|
return {
|
|
2115
2449
|
now: Date.now(),
|
|
2116
2450
|
alarm: await this.ctx.storage.getAlarm(),
|
|
2117
2451
|
schedulerActive: this.taskScheduler !== null,
|
|
2452
|
+
openRuns: openRuns.map((r) => ({
|
|
2453
|
+
runId: r.runId,
|
|
2454
|
+
taskId: r.taskId,
|
|
2455
|
+
startedAt: r.startedAt,
|
|
2456
|
+
state: r.state,
|
|
2457
|
+
attempts: r.attempts,
|
|
2458
|
+
retryAt: r.retryAt ?? null,
|
|
2459
|
+
})),
|
|
2118
2460
|
tasks: records.map((t) => ({
|
|
2119
2461
|
id: t.id,
|
|
2120
2462
|
title: t.title,
|
|
@@ -2392,12 +2734,36 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
2392
2734
|
|
|
2393
2735
|
// ── turn plumbing ──────────────────────────────────────────────────────
|
|
2394
2736
|
|
|
2395
|
-
/**
|
|
2737
|
+
/**
|
|
2738
|
+
* Matrix media access for the attachments pipeline, through the gateway.
|
|
2739
|
+
* Both transfers stream across the object boundary and are cut off at
|
|
2740
|
+
* the attachment cap; an encrypted attachment is decrypted HERE, not in
|
|
2741
|
+
* the gateway (its whole-buffer download grew the crypto WASM heap for
|
|
2742
|
+
* good, and a hash mismatch on the far side of the RPC would only reach
|
|
2743
|
+
* this object as a disconnect).
|
|
2744
|
+
*/
|
|
2396
2745
|
private matrixMediaSource(): MatrixMediaSource {
|
|
2397
2746
|
return {
|
|
2398
|
-
downloadMxc: (mxc) =>
|
|
2399
|
-
|
|
2400
|
-
|
|
2747
|
+
downloadMxc: async (mxc, maxBytes = MAX_FILE_SIZE) =>
|
|
2748
|
+
readBytesCapped(
|
|
2749
|
+
await this.gateway.downloadMxcMediaStream(mxc),
|
|
2750
|
+
maxBytes,
|
|
2751
|
+
),
|
|
2752
|
+
downloadEvent: async (roomId, eventId, maxBytes = MAX_FILE_SIZE) => {
|
|
2753
|
+
const media = await this.gateway.downloadEventMediaStream(
|
|
2754
|
+
roomId,
|
|
2755
|
+
eventId,
|
|
2756
|
+
);
|
|
2757
|
+
if (!media) return null;
|
|
2758
|
+
const plain = media.file
|
|
2759
|
+
? media.stream.pipeThrough(createAttachmentDecryptor(media.file))
|
|
2760
|
+
: media.stream;
|
|
2761
|
+
return {
|
|
2762
|
+
bytes: await readBytesCapped(plain, maxBytes),
|
|
2763
|
+
...(media.mimetype ? { mimetype: media.mimetype } : {}),
|
|
2764
|
+
...(media.filename ? { filename: media.filename } : {}),
|
|
2765
|
+
};
|
|
2766
|
+
},
|
|
2401
2767
|
};
|
|
2402
2768
|
}
|
|
2403
2769
|
|
|
@@ -2672,6 +3038,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
2672
3038
|
`[user-do] no oracle room resolved for ${req.identity.userDid}; room-scoped plugins (memory) will be unavailable this turn`,
|
|
2673
3039
|
);
|
|
2674
3040
|
}
|
|
3041
|
+
if (sessionRoomId) this.sessionRooms.set(req.sessionId, sessionRoomId);
|
|
2675
3042
|
|
|
2676
3043
|
// A Matrix turn with no usable delegation: ask the web app to open its
|
|
2677
3044
|
// "authorize for Matrix" modal (throttled per user, best-effort) — the
|
|
@@ -2681,7 +3048,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
2681
3048
|
sessionRoomId &&
|
|
2682
3049
|
!this.delegations.get(req.identity.userDid)?.raw
|
|
2683
3050
|
) {
|
|
2684
|
-
|
|
3051
|
+
this.promptReauth(req.identity.userDid, sessionRoomId);
|
|
2685
3052
|
}
|
|
2686
3053
|
|
|
2687
3054
|
// Per-room user preferences (tone, language, what to call whom) —
|
|
@@ -2873,7 +3240,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
2873
3240
|
configurable: { thread_id: req.sessionId },
|
|
2874
3241
|
context,
|
|
2875
3242
|
signal: abortController.signal,
|
|
2876
|
-
recursionLimit:
|
|
3243
|
+
recursionLimit: turnRecursionLimit(this.env),
|
|
2877
3244
|
metadata: tracing.metadata,
|
|
2878
3245
|
...(tracing.callbacks ? { callbacks: tracing.callbacks } : {}),
|
|
2879
3246
|
};
|
|
@@ -2909,40 +3276,57 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
2909
3276
|
if (!req.sessionId.startsWith('$')) return;
|
|
2910
3277
|
if (!text.trim()) return;
|
|
2911
3278
|
const label = who === 'user' ? 'user message' : 'AI response';
|
|
2912
|
-
//
|
|
2913
|
-
//
|
|
2914
|
-
|
|
2915
|
-
|
|
2916
|
-
|
|
2917
|
-
(async () => {
|
|
3279
|
+
// Serialised per session, retried across a gateway restart with a fixed
|
|
3280
|
+
// transaction id, kept alive past the request (room-mirror.ts).
|
|
3281
|
+
void this.roomMirror().enqueue(
|
|
3282
|
+
req.sessionId,
|
|
3283
|
+
async () => {
|
|
2918
3284
|
const roomId =
|
|
3285
|
+
this.sessionRooms.get(req.sessionId) ??
|
|
2919
3286
|
req.roomId ??
|
|
2920
3287
|
(await this.sessions?.getSession(req.sessionId))?.roomId ??
|
|
2921
|
-
(
|
|
3288
|
+
(
|
|
3289
|
+
await retryGateway(() =>
|
|
3290
|
+
this.gateway.resolveUserRoom(req.identity.userDid),
|
|
3291
|
+
)
|
|
3292
|
+
)?.roomId;
|
|
2922
3293
|
if (!roomId) throw new Error('no oracle room for this user');
|
|
3294
|
+
this.sessionRooms.set(req.sessionId, roomId);
|
|
2923
3295
|
const { body, formattedBody } = formatReplay({
|
|
2924
3296
|
message: text,
|
|
2925
3297
|
isOracle: who === 'oracle',
|
|
2926
3298
|
oracleName: this.core.identity.name,
|
|
2927
3299
|
});
|
|
2928
|
-
|
|
3300
|
+
return {
|
|
3301
|
+
roomId,
|
|
3302
|
+
body,
|
|
3303
|
+
...(formattedBody ? { formattedBody } : {}),
|
|
2929
3304
|
threadId: req.sessionId,
|
|
2930
|
-
|
|
2931
|
-
|
|
2932
|
-
|
|
2933
|
-
|
|
2934
|
-
`[user-do] Matrix replay (${label}) → ${eventId} in thread ${req.sessionId}`,
|
|
2935
|
-
);
|
|
2936
|
-
})().catch((err: unknown) => {
|
|
2937
|
-
console.error(
|
|
2938
|
-
`[user-do] Matrix replay (${label}) failed — session=${req.sessionId}: ${
|
|
2939
|
-
err instanceof Error ? err.message : String(err)
|
|
2940
|
-
}`,
|
|
2941
|
-
);
|
|
2942
|
-
}),
|
|
3305
|
+
txnId: mirrorTxnId(req.sessionId, req.requestId, who),
|
|
3306
|
+
};
|
|
3307
|
+
},
|
|
3308
|
+
label,
|
|
2943
3309
|
);
|
|
2944
3310
|
}
|
|
2945
3311
|
|
|
3312
|
+
private roomMirror(): RoomMirror {
|
|
3313
|
+
this.mirror ??= new RoomMirror({
|
|
3314
|
+
sendText: (send) =>
|
|
3315
|
+
this.gateway.sendText(send.roomId, send.body, {
|
|
3316
|
+
threadId: send.threadId,
|
|
3317
|
+
...(send.formattedBody
|
|
3318
|
+
? { formattedBody: send.formattedBody }
|
|
3319
|
+
: {}),
|
|
3320
|
+
priority: 'background',
|
|
3321
|
+
txnId: send.txnId,
|
|
3322
|
+
}),
|
|
3323
|
+
keepAlive: (work) => this.ctx.waitUntil(work),
|
|
3324
|
+
log: (message) => console.log(message),
|
|
3325
|
+
warn: (message) => console.warn(message),
|
|
3326
|
+
});
|
|
3327
|
+
return this.mirror;
|
|
3328
|
+
}
|
|
3329
|
+
|
|
2946
3330
|
private async afterTurn(
|
|
2947
3331
|
sessionId: string,
|
|
2948
3332
|
messages: BaseMessage[],
|
|
@@ -2963,6 +3347,15 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
2963
3347
|
await sessions.setTitle(sessionId, title, { onlyIfUntitled: true });
|
|
2964
3348
|
}
|
|
2965
3349
|
this.markDirty();
|
|
3350
|
+
// Which chunks this turn touched, for the page tier's eviction policy
|
|
3351
|
+
// (one row write; a no-op without a tier).
|
|
3352
|
+
try {
|
|
3353
|
+
this.db?.recordAccess();
|
|
3354
|
+
} catch (err) {
|
|
3355
|
+
console.warn(
|
|
3356
|
+
`[user-do] tier access record failed for ${this.userDid}: ${err instanceof Error ? err.message : String(err)}`,
|
|
3357
|
+
);
|
|
3358
|
+
}
|
|
2966
3359
|
}
|
|
2967
3360
|
|
|
2968
3361
|
private async generateTitle(
|