@ixo/oracle-runtime-workers 0.4.1 → 0.9.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.
@@ -15,6 +15,7 @@
15
15
  * replaces it — which also guarantees a terminal phase is never dropped.
16
16
  * Fire-and-forget throughout: a failed post is logged, never thrown.
17
17
  */
18
+ import { retryTxnId } from './txn-id';
18
19
 
19
20
  export const ORACLE_COMPONENT_EVENT_TYPE = 'ixo.oracle.component';
20
21
 
@@ -129,11 +130,16 @@ interface TurnEntry extends WorkStatusTurn {
129
130
  }
130
131
 
131
132
  export interface WorkStatusProducerDeps {
132
- /** Posts a room event and resolves its event id (the gateway's `sendEvent`). */
133
+ /**
134
+ * Posts a room event and resolves its event id (the gateway's `sendEvent`).
135
+ * `txnId` pins the transaction id so the anchor's single retry cannot post
136
+ * a second card when only the response was lost.
137
+ */
133
138
  postEvent: (
134
139
  roomId: string,
135
140
  eventType: string,
136
141
  content: object,
142
+ opts?: { txnId?: string },
137
143
  ) => Promise<string>;
138
144
  clock?: () => Date;
139
145
  logger?: { warn: (message: string) => void };
@@ -205,14 +211,20 @@ export class WorkStatusProducer {
205
211
  content: object,
206
212
  retryOnce: boolean,
207
213
  ): Promise<string> {
214
+ const opts = { txnId: retryTxnId('work-status') };
208
215
  try {
209
- return await this.postEvent(roomId, ORACLE_COMPONENT_EVENT_TYPE, content);
216
+ return await this.postEvent(
217
+ roomId,
218
+ ORACLE_COMPONENT_EVENT_TYPE,
219
+ content,
220
+ opts,
221
+ );
210
222
  } catch (error) {
211
223
  if (!retryOnce) throw error;
212
224
  await new Promise((resolve) =>
213
225
  setTimeout(resolve, this.anchorRetryDelayMs),
214
226
  );
215
- return this.postEvent(roomId, ORACLE_COMPONENT_EVENT_TYPE, content);
227
+ return this.postEvent(roomId, ORACLE_COMPONENT_EVENT_TYPE, content, opts);
216
228
  }
217
229
  }
218
230
 
@@ -4,41 +4,49 @@
4
4
  * Wire format is the Node runtime's (`m.ixo.media_upload` timeline event +
5
5
  * `m.ixo.media_state[storageKey]` pointer), so a user migrating from the
6
6
  * Node runtime finds their checkpoint file where they left it. The gateway
7
- * handles encryption and media transfer; this class owns gzip and the
8
- * SQLite sanity check.
7
+ * moves the media across the object boundary as a stream, as stored; this
8
+ * class decrypts, gunzips and header-checks it chunk by chunk (and gzips +
9
+ * hands over a stream on the way up), so the file is never held whole in
10
+ * either isolate — a 50 MB Node-era history used to be materialised three
11
+ * times over in the user object and reset its 128 MB isolate.
9
12
  */
10
13
 
14
+ import { createAttachmentDecryptor } from '@ixo/matrix-bot-workers-sdk';
11
15
  import type { MatrixGatewayObject } from '../do/contracts';
12
16
  import {
13
- bytesOfStream,
14
- gunzip,
15
- gzip,
16
- isSqliteFile,
17
- streamOfBytes,
17
+ assertSqliteStream,
18
+ countStream,
19
+ gunzipStreamIfNeeded,
20
+ gzipStream,
18
21
  type FileSnapshot,
19
22
  type OwnerCopy,
20
23
  type OwnerStore,
21
24
  type SaveResult,
22
25
  } from './types';
23
26
 
24
- const GZIP_MAGIC_0 = 0x1f;
25
- const GZIP_MAGIC_1 = 0x8b;
26
-
27
- function looksGzipped(bytes: Uint8Array): boolean {
28
- return (
29
- bytes.length >= 2 && bytes[0] === GZIP_MAGIC_0 && bytes[1] === GZIP_MAGIC_1
30
- );
31
- }
27
+ /**
28
+ * The slice of the gateway RPC surface this store needs — structurally
29
+ * satisfied by `DurableObjectStub<MatrixGatewayObject>` and faked in tests.
30
+ */
31
+ export type SnapshotGateway = Pick<
32
+ MatrixGatewayObject,
33
+ | 'downloadUserSnapshotStream'
34
+ | 'uploadUserSnapshotStream'
35
+ | 'resolveUserRoom'
36
+ | 'getRoomStateEvent'
37
+ | 'sendEvent'
38
+ | 'sendStateEvent'
39
+ >;
32
40
 
33
41
  export interface MatrixMediaOwnerStoreOptions {
34
- gateway: DurableObjectStub<MatrixGatewayObject>;
42
+ gateway: SnapshotGateway;
35
43
  userDid: string;
36
44
  storageKey: string;
37
45
  }
38
46
 
39
47
  export class MatrixMediaOwnerStore implements OwnerStore {
40
48
  readonly kind = 'matrix' as const;
41
- private readonly gateway: DurableObjectStub<MatrixGatewayObject>;
49
+ private readonly gateway: SnapshotGateway;
42
50
  private readonly userDid: string;
43
51
  private readonly storageKey: string;
44
52
 
@@ -53,43 +61,55 @@ export class MatrixMediaOwnerStore implements OwnerStore {
53
61
  }
54
62
 
55
63
  /**
56
- * The gateway RPC hands the whole (encrypted, then decrypted) media blob
57
- * over; this path is the legacy one and inherently materializes the file.
64
+ * Streamed end to end: the gateway hands the media over as stored (the
65
+ * ciphertext plus its `EncryptedFile` fields in an E2EE room, the plain
66
+ * upload otherwise), this side decrypts and gunzips as the bytes arrive and
67
+ * rejects anything that does not start like a SQLite file before the object
68
+ * writes a single chunk. A hash mismatch surfaces at the END of the stream
69
+ * (the SDK verifies the ciphertext SHA-256 once the last byte is in), which
70
+ * fails the import and leaves the working copy untouched. Peak memory is a
71
+ * few chunks, whatever the file size.
58
72
  */
59
73
  async load(): Promise<OwnerCopy | null> {
60
- const found = await this.gateway.downloadUserSnapshot(
74
+ const found = await this.gateway.downloadUserSnapshotStream(
61
75
  this.userDid,
62
76
  this.storageKey,
63
77
  );
64
78
  if (!found) return null;
79
+ const plain = found.file
80
+ ? found.stream.pipeThrough(createAttachmentDecryptor(found.file))
81
+ : found.stream;
65
82
  // Old Node-runtime uploads were always gzipped, but tolerate a raw file
66
83
  // so a hand-uploaded database still loads.
67
- const bytes = looksGzipped(found.bytes)
68
- ? await gunzip(found.bytes)
69
- : found.bytes;
70
- if (!isSqliteFile(bytes)) {
71
- throw new Error(
72
- `MatrixMediaOwnerStore: snapshot ${found.eventId} for ${this.storageKey} is not a SQLite file (${bytes.length} bytes)`,
73
- );
74
- }
75
- return { stream: streamOfBytes(bytes), etag: found.eventId };
84
+ const stream = await assertSqliteStream(
85
+ await gunzipStreamIfNeeded(plain),
86
+ `MatrixMediaOwnerStore: snapshot ${found.eventId} for ${this.storageKey}`,
87
+ );
88
+ return { stream, etag: found.eventId };
76
89
  }
77
90
 
91
+ /**
92
+ * Streamed as well: the snapshot is opened three times — a header peek, a
93
+ * gzip pass that only counts (the homeserver needs the exact ciphertext
94
+ * length up front; gzip output is deterministic for identical input, the
95
+ * VFS store relies on the same property) and the gzip pass that is
96
+ * uploaded — and never held whole.
97
+ */
78
98
  async save(snapshot: FileSnapshot): Promise<SaveResult> {
79
- const bytes = await bytesOfStream(snapshot.open());
80
- if (!isSqliteFile(bytes)) {
81
- throw new Error(
82
- `MatrixMediaOwnerStore: refusing to save ${bytes.length} bytes that are not a SQLite file`,
83
- );
84
- }
85
- const compressed = await gzip(bytes);
86
- const { eventId } = await this.gateway.uploadUserSnapshot(
99
+ const checked = await assertSqliteStream(
100
+ snapshot.open(),
101
+ `MatrixMediaOwnerStore: refusing to save — the working copy (${snapshot.size} bytes)`,
102
+ );
103
+ await checked.cancel().catch(() => undefined);
104
+ const gzLength = await countStream(gzipStream(snapshot.open()));
105
+ const { eventId } = await this.gateway.uploadUserSnapshotStream(
87
106
  this.userDid,
88
107
  this.storageKey,
89
- compressed,
108
+ gzipStream(snapshot.open()),
90
109
  this.filename,
110
+ gzLength,
91
111
  );
92
- return { etag: eventId, bytes: compressed.byteLength };
112
+ return { etag: eventId, bytes: gzLength };
93
113
  }
94
114
 
95
115
  async head(): Promise<{ etag: string } | null> {
@@ -3,10 +3,12 @@
3
3
  * Matrix room media is only a read-only *legacy* source, checked when VFS has
4
4
  * no file yet so users coming from the Node runtime keep their history.
5
5
  *
6
- * load(): VFS first. If absent there but present in Matrix media, import
7
- * the legacy file AND write it to VFS immediately — from that
8
- * moment the VFS copy is authoritative and Matrix is never
9
- * consulted again (head() sees the VFS file).
6
+ * load(): VFS first. If absent there but present in Matrix media, hand
7
+ * the legacy stream over flagged `fromLegacy`: the object imports
8
+ * it chunk by chunk and writes it to VFS from its working copy at
9
+ * once (`flushToOwnerStore`) — from that moment the VFS copy is
10
+ * authoritative and Matrix is never consulted again (head() sees
11
+ * the VFS file). Nothing on this path holds the file in memory.
10
12
  * save(): VFS, and only VFS. Nothing is ever written to Matrix media any
11
13
  * more: a failed VFS write (no `ixo:filesystem` delegation yet, a
12
14
  * VFS outage) propagates, and the object keeps its working copy
@@ -18,15 +20,7 @@
18
20
  * Matrix copy so no second copy of the user's history lingers.
19
21
  */
20
22
  import { VfsNoDelegationError } from './ixo-vfs-store';
21
- import {
22
- bytesOfStream,
23
- snapshotOfBytes,
24
- streamOfBytes,
25
- type FileSnapshot,
26
- type OwnerCopy,
27
- type OwnerStore,
28
- type SaveResult,
29
- } from './types';
23
+ import type { FileSnapshot, OwnerCopy, OwnerStore, SaveResult } from './types';
30
24
 
31
25
  export interface MigratingOwnerStoreOptions {
32
26
  /** System of record (IXO VFS). */
@@ -93,24 +87,16 @@ export class MigratingOwnerStore implements OwnerStore {
93
87
  return null;
94
88
  }
95
89
 
96
- // One-time migration: the legacy file becomes the first VFS version. The
97
- // legacy copy is already in memory (gateway RPCs hand over bytes), so
98
- // re-opening it for the VFS upload costs nothing extra.
99
- const legacyBytes = await bytesOfStream(fromLegacy.stream);
100
- try {
101
- const { etag } = await this.primary.save(snapshotOfBytes(legacyBytes));
102
- this.log(
103
- 'log',
104
- `[owner-store] migrated ${legacyBytes.byteLength} bytes from Matrix media to VFS (${etag})`,
105
- );
106
- return { stream: streamOfBytes(legacyBytes), etag };
107
- } catch (err) {
108
- this.log(
109
- 'warn',
110
- `[owner-store] VFS migration write failed — serving the legacy Matrix copy for now: ${err instanceof Error ? err.message : String(err)}`,
111
- );
112
- return { stream: streamOfBytes(legacyBytes), etag: fromLegacy.etag };
113
- }
90
+ // One-time migration, streamed: the object imports this copy chunk by
91
+ // chunk and then flushes its working copy to VFS (`fromLegacy`). Writing
92
+ // VFS from here would need the whole file in memory twice over (the VFS
93
+ // store measures the gzip before it uploads) — what used to reset the
94
+ // isolate on large Node-era histories.
95
+ this.log(
96
+ 'log',
97
+ `[owner-store] legacy Matrix copy ${fromLegacy.etag} found and no VFS copy yet — importing it for migration`,
98
+ );
99
+ return { ...fromLegacy, fromLegacy: true };
114
100
  }
115
101
 
116
102
  /**
@@ -159,13 +145,16 @@ export class MigratingOwnerStore implements OwnerStore {
159
145
 
160
146
  /** The legacy Matrix copy regardless of what VFS holds (see `OwnerStore.loadLegacy`). */
161
147
  async loadLegacy(): Promise<OwnerCopy | null> {
162
- return this.legacy.load().catch((err) => {
148
+ try {
149
+ const copy = await this.legacy.load();
150
+ return copy ? { ...copy, fromLegacy: true } : null;
151
+ } catch (err) {
163
152
  this.log(
164
153
  'warn',
165
154
  `[owner-store] legacy Matrix load failed: ${err instanceof Error ? err.message : String(err)}`,
166
155
  );
167
156
  return null;
168
- });
157
+ }
169
158
  }
170
159
 
171
160
  async remove(): Promise<void> {
Binary file
@@ -415,6 +415,17 @@ export interface PluginContext<TConfig = MergedConfig> {
415
415
  * Passed to tool handlers, sub-agent handlers, and to plugin middlewares' hook
416
416
  * functions. Built fresh per graph invocation.
417
417
  */
418
+ /**
419
+ * Options of a room post made through `RuntimeContext.matrix`. A plugin
420
+ * that retries a post after a lost response should pin `txnId` (one id for
421
+ * the whole retry loop, see `retryTxnId`): the homeserver deduplicates sends
422
+ * by transaction id per device, so the retried post lands once. Printable
423
+ * ASCII without `/`, at most 255 characters.
424
+ */
425
+ export interface MatrixPostOpts {
426
+ txnId?: string;
427
+ }
428
+
418
429
  export interface RuntimeContext<TConfig = MergedConfig> {
419
430
  /** Authenticated user identity (validated by core auth middleware). */
420
431
  user: {
@@ -492,7 +503,11 @@ export interface RuntimeContext<TConfig = MergedConfig> {
492
503
 
493
504
  /** Matrix client, scoped operations only. */
494
505
  matrix: {
495
- postToRoom: (roomId: string, content: unknown) => Promise<string>;
506
+ postToRoom: (
507
+ roomId: string,
508
+ content: unknown,
509
+ opts?: MatrixPostOpts,
510
+ ) => Promise<string>;
496
511
  /**
497
512
  * Post a timeline event with a caller-chosen event type (e.g. the
498
513
  * `ixo.oracle.*` protocol events). `postToRoom` is the `m.room.message`
@@ -502,6 +517,7 @@ export interface RuntimeContext<TConfig = MergedConfig> {
502
517
  roomId: string,
503
518
  eventType: string,
504
519
  content: object,
520
+ opts?: MatrixPostOpts,
505
521
  ) => Promise<string>;
506
522
  getRoomState: (roomId: string) => Promise<RoomStateSnapshot>;
507
523
  getEventById: (roomId: string, eventId: string) => Promise<MatrixEvent>;
@@ -637,6 +653,14 @@ export interface RuntimeContext<TConfig = MergedConfig> {
637
653
  */
638
654
  onTurnEnd?: (dispose: () => void | Promise<void>) => void;
639
655
 
656
+ /**
657
+ * Keep un-awaited work alive past the end of the request that started it
658
+ * (a best-effort room post retried across a gateway restart). On Workers
659
+ * this is the Durable Object's `waitUntil`; absent on hosts where pending
660
+ * promises run to completion on their own.
661
+ */
662
+ background?: (work: Promise<unknown>) => void;
663
+
640
664
  /** Propagates from the HTTP request / graph invocation. */
641
665
  abortSignal: AbortSignal;
642
666
 
@@ -2,7 +2,16 @@
2
2
  * The Node runtime's `logActionToMatrix`: a best-effort `ixo.action.log`
3
3
  * room event recording a frontend action (browser tool / AG-UI action) and
4
4
  * its outcome, posted only when the turn has a room. Never throws.
5
+ *
6
+ * Retried across a gateway restart (the gateway object is replaced on every
7
+ * deploy and can be drained mid-turn) and handed to the host to keep alive
8
+ * past the turn, so the audit trail does not lose entries to resets. The
9
+ * post carries one transaction id for the life of its retry loop, so a
10
+ * response lost between the homeserver's ack and the reply to the gateway
11
+ * is deduplicated instead of logged twice.
5
12
  */
13
+ import { retryGateway, type RetryGatewayOptions } from '../../do/gateway-retry';
14
+ import { retryTxnId } from '../../matrix/txn-id';
6
15
  import type { RuntimeContext } from '../../plugin-api/types';
7
16
 
8
17
  export const ACTION_LOG_EVENT_TYPE = 'ixo.action.log';
@@ -16,19 +25,37 @@ export interface LoggedAction {
16
25
  }
17
26
 
18
27
  export function logActionToMatrix(
19
- ctx: Pick<RuntimeContext, 'matrix' | 'session' | 'logger'>,
28
+ ctx: Pick<RuntimeContext, 'matrix' | 'session' | 'logger' | 'background'>,
20
29
  action: LoggedAction,
30
+ retry?: Pick<RetryGatewayOptions, 'delaysMs' | 'sleep' | 'isTransient'>,
21
31
  ): void {
22
32
  const roomId = ctx.session.roomId;
23
33
  if (!roomId) return;
24
- void ctx.matrix
25
- .postEvent(roomId, ACTION_LOG_EVENT_TYPE, {
26
- action,
27
- threadId: ctx.session.id,
28
- })
29
- .catch((err: unknown) => {
34
+ const errorText = (err: unknown): string =>
35
+ err instanceof Error ? err.message : String(err);
36
+ const txnId = retryTxnId('action-log');
37
+ const work = retryGateway(
38
+ () =>
39
+ ctx.matrix.postEvent(
40
+ roomId,
41
+ ACTION_LOG_EVENT_TYPE,
42
+ { action, threadId: ctx.session.id },
43
+ { txnId },
44
+ ),
45
+ {
46
+ ...retry,
47
+ onRetry: (err, attempt, delayMs) =>
48
+ ctx.logger.warn?.(
49
+ `[portal] action log for ${action.name} failed (attempt ${attempt}) — retrying in ${delayMs} ms: ${errorText(err)}`,
50
+ ),
51
+ },
52
+ ).then(
53
+ () => undefined,
54
+ (err: unknown) => {
30
55
  ctx.logger.warn?.(
31
- `[portal] action log for ${action.name} not posted: ${err instanceof Error ? err.message : String(err)}`,
56
+ `[portal] action log for ${action.name} not posted: ${errorText(err)}`,
32
57
  );
33
- });
58
+ },
59
+ );
60
+ ctx.background?.(work);
34
61
  }
@@ -17,11 +17,8 @@ import type {
17
17
  RuntimeContext,
18
18
  } from '../../plugin-api/types';
19
19
  import { summarizeSchedule } from '../../tasks/schedule';
20
- import {
21
- TASK_ID_PATTERN,
22
- TASK_STATUSES,
23
- TaskScheduleSchema,
24
- } from '../../tasks/spec';
20
+ import { TASK_ID_PATTERN, TASK_STATUSES } from '../../tasks/spec';
21
+ import { ScheduleInputToScheduleSchema } from '../../tasks/schedule-input';
25
22
  import { pendingApprovalOf } from '../../tasks/store';
26
23
 
27
24
  const TASKS_UNAVAILABLE = {
@@ -51,7 +48,10 @@ const taskInputShape = {
51
48
  .describe(
52
49
  'Markdown instructions the run executes. Scheduled runs are BACKGROUND sessions with NO memory of this conversation — put every ID, URL, and name the run needs into this text, never assume the run "knows" anything discussed here.',
53
50
  ),
54
- schedule: TaskScheduleSchema,
51
+ // Flat at the tool boundary (a `kind` enum + optional per-kind fields),
52
+ // converted to the store's discriminated union on parse — see
53
+ // tasks/schedule-input.ts for why the union itself cannot be the input.
54
+ schedule: ScheduleInputToScheduleSchema,
55
55
  approval: z
56
56
  .enum(['never', 'before-action'])
57
57
  .default('never')
@@ -259,7 +259,7 @@ const updateInput = z.object({
259
259
  taskId: taskIdSchema.shape.taskId,
260
260
  title: z.string().min(1).max(120).optional(),
261
261
  intent: taskInputShape.intent.optional(),
262
- schedule: TaskScheduleSchema.optional(),
262
+ schedule: ScheduleInputToScheduleSchema.optional(),
263
263
  approval: z
264
264
  .enum(['never', 'before-action'])
265
265
  .optional()
package/src/shell/app.ts CHANGED
@@ -26,6 +26,18 @@ import {
26
26
  type AuthResult,
27
27
  type RouteExclusion,
28
28
  } from './auth';
29
+ import { turnBodyTooLarge } from './turn-body-cap';
30
+ import { z } from 'zod';
31
+
32
+ const DebugEventBody = z.object({
33
+ type: z
34
+ .string()
35
+ .min(1)
36
+ .max(255)
37
+ .refine((t) => t !== 'm.room.redaction', 'redactions are not events'),
38
+ content: z.record(z.string(), z.unknown()).default({}),
39
+ txnId: z.string().min(1).max(255).optional(),
40
+ });
29
41
 
30
42
  export interface PluginRoute {
31
43
  method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'ALL';
@@ -54,6 +66,17 @@ export interface ShellOptions {
54
66
 
55
67
  type Variables = { auth: AuthResult };
56
68
 
69
+ function errorText(err: unknown): string {
70
+ return err instanceof Error ? err.message : String(err);
71
+ }
72
+
73
+ /** The rejection an in-flight RPC gets when `ctx.abort()` resets its object. */
74
+ function isAbortRejection(err: unknown): boolean {
75
+ return /debug reset requested|reset requested by the debug route|durable object reset|no longer active|aborted/i.test(
76
+ errorText(err),
77
+ );
78
+ }
79
+
57
80
  const BUILTIN_EXCLUSIONS: RouteExclusion[] = [
58
81
  { path: '/', method: 'GET' },
59
82
  { path: '/health', method: 'ALL' },
@@ -66,9 +89,6 @@ const BUILTIN_EXCLUSIONS: RouteExclusion[] = [
66
89
  { path: '/debug/matrix/stop', method: 'POST' },
67
90
  ];
68
91
 
69
- /** Express body-parser default in the Node runtime: JSON bodies over 100 KiB are a 413. */
70
- const MAX_TURN_BODY_BYTES = 100 * 1024;
71
-
72
92
  export function createShell(
73
93
  opts: ShellOptions = {},
74
94
  ): Hono<{ Bindings: OracleWorkerEnv; Variables: Variables }> {
@@ -267,9 +287,9 @@ export function createShell(
267
287
  const identity = identityOf(c.get('auth'), c.req.raw.headers);
268
288
  const sessionId = c.req.param('sessionId');
269
289
  const body = await c.req.text();
270
- // Node (express body-parser default) refuses JSON bodies over 100 KiB
271
- // with a 413; attachments are references, so real turns never get near it.
272
- if (new TextEncoder().encode(body).byteLength > MAX_TURN_BODY_BYTES) {
290
+ // Bounds the memory one request can pin (see turn-body-cap.ts); the
291
+ // status and message are the ones Node's body parser uses.
292
+ if (turnBodyTooLarge(body)) {
273
293
  return c.json(
274
294
  { statusCode: 413, message: 'request entity too large' },
275
295
  413,
@@ -454,6 +474,18 @@ export function createShell(
454
474
  app.post('/debug/storage/reset', async (c) =>
455
475
  c.json(await userStub(c.env, c.get('auth').userDid).resetWorkingCopy()),
456
476
  );
477
+ app.post('/debug/object/abort', async (c) => {
478
+ try {
479
+ await userStub(c.env, c.get('auth').userDid).debugAbortObject();
480
+ } catch (err) {
481
+ // The abort tears the object down under the call: that rejection is
482
+ // the expected signal. Anything else (an older build without the
483
+ // method, a transport error) is a real failure.
484
+ if (!isAbortRejection(err))
485
+ return c.json({ aborted: false, error: errorText(err) }, 500);
486
+ }
487
+ return c.json({ aborted: true });
488
+ });
457
489
  app.get('/debug/memory-schema', async (c) => {
458
490
  const userDid = c.get('auth').userDid;
459
491
  return c.json(await userStub(c.env, userDid).debugMemorySchema(userDid));
@@ -469,11 +501,19 @@ export function createShell(
469
501
  : c.json({ statusCode: 404, message: 'Session not found' }, 404);
470
502
  });
471
503
  app.get('/debug/tasks', async (c) =>
472
- c.json(await userStub(c.env, c.get('auth').userDid).tasksStatus()),
504
+ c.json(
505
+ await userStub(c.env, c.get('auth').userDid).tasksStatus(
506
+ c.get('auth').userDid,
507
+ ),
508
+ ),
473
509
  );
474
510
  app.get('/debug/realtime', async (c) =>
475
511
  c.json(await userStub(c.env, c.get('auth').userDid).realtimeStatus()),
476
512
  );
513
+ app.post('/debug/reauth-prompt/reset', async (c) => {
514
+ await userStub(c.env, c.get('auth').userDid).debugResetReauthThrottle();
515
+ return c.json({ reset: true });
516
+ });
477
517
  app.get('/debug/delegation', async (c) =>
478
518
  c.json(
479
519
  await userStub(c.env, c.get('auth').userDid).delegationStatus(
@@ -487,6 +527,39 @@ export function createShell(
487
527
  app.get('/debug/matrix/outbox', async (c) =>
488
528
  c.json({ rows: await gateway(c.env).listOutbox() }),
489
529
  );
530
+ // Drill: post a custom event into the caller's own room under a
531
+ // caller-pinned transaction id. The same id twice returns the same event
532
+ // (the homeserver deduplicates per device), which is what makes a
533
+ // best-effort post retried across a gateway restart land once.
534
+ app.post('/debug/matrix/event', async (c) => {
535
+ const parsed = DebugEventBody.safeParse(
536
+ await c.req.json().catch(() => null),
537
+ );
538
+ if (!parsed.success)
539
+ return c.json(
540
+ { error: 'expected { type: string, content: object, txnId?: string }' },
541
+ 400,
542
+ );
543
+ const room = await gateway(c.env).resolveUserRoom(c.get('auth').userDid);
544
+ if (!room) return c.json({ error: 'no room for this user' }, 404);
545
+ const { type, content, txnId } = parsed.data;
546
+ const eventId = await gateway(c.env).sendEvent(
547
+ room.roomId,
548
+ type,
549
+ JSON.stringify(content),
550
+ txnId === undefined ? {} : { txnId },
551
+ );
552
+ return c.json({ roomId: room.roomId, eventId });
553
+ });
554
+ app.post('/debug/matrix/abort', async (c) => {
555
+ try {
556
+ await gateway(c.env).debugAbortObject();
557
+ } catch (err) {
558
+ if (!isAbortRejection(err))
559
+ return c.json({ aborted: false, error: errorText(err) }, 500);
560
+ }
561
+ return c.json({ aborted: true });
562
+ });
490
563
  app.post('/debug/matrix/restart', async (c) =>
491
564
  c.json(await gateway(c.env).restart()),
492
565
  );
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Upper bound on a chat turn's request body (`POST /messages/:sessionId`).
3
+ *
4
+ * The cap bounds the memory one request can pin: the shell reads the body in
5
+ * full before forwarding it to the user object. It no longer mirrors the Node
6
+ * runtime, which keeps Express body-parser's 100 kb default — a normal Portal
7
+ * turn now exceeds that. Every turn carries the browser-tool catalogue (26–32
8
+ * tools, 65–70 KiB of JSON schema) and the AG-UI action schemas (~27 KiB)
9
+ * before the message text, and a compose-topic turn adds the pinned text in
10
+ * `metadata.contextToolCall`; on devnet turns of 102–115 KiB were refused.
11
+ * 256 KiB leaves room for those. Attachments never count: files go to Matrix
12
+ * media first and the body carries only their references.
13
+ */
14
+ export const MAX_TURN_BODY_BYTES = 256 * 1024;
15
+
16
+ /** Whether a raw body is over the cap, measured as the UTF-8 bytes the wire carried. */
17
+ export function turnBodyTooLarge(body: string): boolean {
18
+ return new TextEncoder().encode(body).byteLength > MAX_TURN_BODY_BYTES;
19
+ }