@ixo/oracle-runtime-workers 0.4.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +5 -1
  2. package/package.json +2 -2
  3. package/src/attachments/download.ts +43 -22
  4. package/src/attachments/index.ts +1 -0
  5. package/src/core/env.ts +28 -0
  6. package/src/core/main-agent.ts +56 -4
  7. package/src/core/middlewares/dangling-tool-calls.ts +99 -0
  8. package/src/core/middlewares/index.ts +5 -0
  9. package/src/core/runtime-context.ts +14 -5
  10. package/src/core/subagent-as-tool.ts +12 -1
  11. package/src/do/ambient.ts +13 -4
  12. package/src/do/boot-dirty.ts +38 -0
  13. package/src/do/contracts.ts +113 -18
  14. package/src/do/matrix-turn-ledger.ts +126 -0
  15. package/src/do/reauth-prompt.ts +76 -0
  16. package/src/do/room-mirror.ts +111 -0
  17. package/src/do/sse-stream.ts +104 -2
  18. package/src/do/transcript.ts +15 -2
  19. package/src/do/turn-config.ts +24 -0
  20. package/src/do/user-oracle-do.ts +484 -91
  21. package/src/matrix/gateway-do.ts +233 -42
  22. package/src/matrix/inbox-store.ts +188 -0
  23. package/src/matrix/txn-id.ts +35 -0
  24. package/src/matrix/work-status.ts +15 -3
  25. package/src/owner-store/ixo-vfs-store.ts +12 -5
  26. package/src/owner-store/matrix-media-store.ts +65 -40
  27. package/src/owner-store/measure.ts +39 -0
  28. package/src/owner-store/migrating-store.ts +29 -34
  29. package/src/owner-store/types.ts +0 -0
  30. package/src/plugin-api/types.ts +25 -1
  31. package/src/plugins/portal/action-log.ts +36 -9
  32. package/src/plugins/tasks/tasks-tools.ts +7 -7
  33. package/src/shell/app.ts +92 -7
  34. package/src/shell/turn-body-cap.ts +19 -0
  35. package/src/sqlite/cache-config.ts +27 -6
  36. package/src/sqlite/chunk-layout.ts +14 -0
  37. package/src/sqlite/database.ts +188 -19
  38. package/src/sqlite/do-vfs.ts +963 -99
  39. package/src/sqlite/index.ts +13 -0
  40. package/src/sqlite/page-tier.ts +668 -0
  41. package/src/tasks/schedule-input.ts +102 -0
  42. package/src/tasks/scheduler.ts +351 -54
  43. package/src/tasks/store.ts +198 -3
@@ -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
 
@@ -50,6 +50,7 @@ import {
50
50
  type OwnerCopy,
51
51
  type OwnerStore,
52
52
  type SaveResult,
53
+ type SaveHints,
53
54
  } from './types';
54
55
 
55
56
  const VFS_RESOURCE = 'ixo:filesystem';
@@ -233,9 +234,11 @@ export class IxoVfsOwnerStore implements OwnerStore {
233
234
  * Atomic replace, always delete + re-create, never a new version:
234
235
  *
235
236
  * 1. drop stale `.uploading-*` temp files a crashed flush left behind;
236
- * 2. measure the gzipped length in a counting pass (the VFS needs
237
- * `Upload-Length` at session creation — `Upload-Defer-Length` is not
238
- * supported — and gzip output is deterministic for identical input);
237
+ * 2. take the gzipped length from `hints` (the object measured it in the
238
+ * same pass as its checksum) or, without a hint, count it in a pass
239
+ * of its own (the VFS needs `Upload-Length` at session creation —
240
+ * `Upload-Defer-Length` is not supported — and gzip output is
241
+ * deterministic for identical input);
239
242
  * 3. upload gzip → a TEMP path next to the real one: one `POST /files`
240
243
  * when it fits in a single part, else a tus session in 5 MiB parts
241
244
  * (`tusUpload`, resumable per part);
@@ -247,7 +250,10 @@ export class IxoVfsOwnerStore implements OwnerStore {
247
250
  * file is cleaned up then). Peak memory is one part (5 MiB) plus fixed
248
251
  * overhead, whatever the file size.
249
252
  */
250
- async save(snapshot: FileSnapshot): Promise<SaveResult> {
253
+ async save(
254
+ snapshot: FileSnapshot,
255
+ hints: SaveHints = {},
256
+ ): Promise<SaveResult> {
251
257
  const listing = await this.listOracleFiles();
252
258
  const existing = listing.filter((f) => f.path === this.path);
253
259
  const stale = listing.filter((f) =>
@@ -261,7 +267,8 @@ export class IxoVfsOwnerStore implements OwnerStore {
261
267
  });
262
268
  }
263
269
 
264
- const gzLength = await countStream(gzipStream(snapshot.open()));
270
+ const gzLength =
271
+ hints.gzippedLength ?? (await countStream(gzipStream(snapshot.open())));
265
272
  const tempPath = `${this.path}${TEMP_PATH_INFIX}${Date.now().toString(36)}`;
266
273
  const uploaded =
267
274
  gzLength <= SINGLE_SHOT_MAX_BYTES
@@ -4,41 +4,50 @@
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,
25
+ type SaveHints,
22
26
  } from './types';
23
27
 
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
- }
28
+ /**
29
+ * The slice of the gateway RPC surface this store needs — structurally
30
+ * satisfied by `DurableObjectStub<MatrixGatewayObject>` and faked in tests.
31
+ */
32
+ export type SnapshotGateway = Pick<
33
+ MatrixGatewayObject,
34
+ | 'downloadUserSnapshotStream'
35
+ | 'uploadUserSnapshotStream'
36
+ | 'resolveUserRoom'
37
+ | 'getRoomStateEvent'
38
+ | 'sendEvent'
39
+ | 'sendStateEvent'
40
+ >;
32
41
 
33
42
  export interface MatrixMediaOwnerStoreOptions {
34
- gateway: DurableObjectStub<MatrixGatewayObject>;
43
+ gateway: SnapshotGateway;
35
44
  userDid: string;
36
45
  storageKey: string;
37
46
  }
38
47
 
39
48
  export class MatrixMediaOwnerStore implements OwnerStore {
40
49
  readonly kind = 'matrix' as const;
41
- private readonly gateway: DurableObjectStub<MatrixGatewayObject>;
50
+ private readonly gateway: SnapshotGateway;
42
51
  private readonly userDid: string;
43
52
  private readonly storageKey: string;
44
53
 
@@ -53,43 +62,59 @@ export class MatrixMediaOwnerStore implements OwnerStore {
53
62
  }
54
63
 
55
64
  /**
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.
65
+ * Streamed end to end: the gateway hands the media over as stored (the
66
+ * ciphertext plus its `EncryptedFile` fields in an E2EE room, the plain
67
+ * upload otherwise), this side decrypts and gunzips as the bytes arrive and
68
+ * rejects anything that does not start like a SQLite file before the object
69
+ * writes a single chunk. A hash mismatch surfaces at the END of the stream
70
+ * (the SDK verifies the ciphertext SHA-256 once the last byte is in), which
71
+ * fails the import and leaves the working copy untouched. Peak memory is a
72
+ * few chunks, whatever the file size.
58
73
  */
59
74
  async load(): Promise<OwnerCopy | null> {
60
- const found = await this.gateway.downloadUserSnapshot(
75
+ const found = await this.gateway.downloadUserSnapshotStream(
61
76
  this.userDid,
62
77
  this.storageKey,
63
78
  );
64
79
  if (!found) return null;
80
+ const plain = found.file
81
+ ? found.stream.pipeThrough(createAttachmentDecryptor(found.file))
82
+ : found.stream;
65
83
  // Old Node-runtime uploads were always gzipped, but tolerate a raw file
66
84
  // 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 };
85
+ const stream = await assertSqliteStream(
86
+ await gunzipStreamIfNeeded(plain),
87
+ `MatrixMediaOwnerStore: snapshot ${found.eventId} for ${this.storageKey}`,
88
+ );
89
+ return { stream, etag: found.eventId };
76
90
  }
77
91
 
78
- 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(
92
+ /**
93
+ * Streamed as well: the snapshot is opened for a header peek, for a gzip
94
+ * pass that only counts unless the caller measured the length already
95
+ * (`hints`; the homeserver needs the exact ciphertext length up front and
96
+ * gzip output is deterministic for identical input) and for the gzip pass
97
+ * that is uploaded — and never held whole.
98
+ */
99
+ async save(
100
+ snapshot: FileSnapshot,
101
+ hints: SaveHints = {},
102
+ ): Promise<SaveResult> {
103
+ const checked = await assertSqliteStream(
104
+ snapshot.open(),
105
+ `MatrixMediaOwnerStore: refusing to save — the working copy (${snapshot.size} bytes)`,
106
+ );
107
+ await checked.cancel().catch(() => undefined);
108
+ const gzLength =
109
+ hints.gzippedLength ?? (await countStream(gzipStream(snapshot.open())));
110
+ const { eventId } = await this.gateway.uploadUserSnapshotStream(
87
111
  this.userDid,
88
112
  this.storageKey,
89
- compressed,
113
+ gzipStream(snapshot.open()),
90
114
  this.filename,
115
+ gzLength,
91
116
  );
92
- return { etag: eventId, bytes: compressed.byteLength };
117
+ return { etag: eventId, bytes: gzLength };
93
118
  }
94
119
 
95
120
  async head(): Promise<{ etag: string } | null> {
@@ -0,0 +1,39 @@
1
+ /**
2
+ * One streamed pass over a snapshot that yields BOTH what a flush needs
3
+ * before it uploads: the SHA-256 of the raw SQLite bytes (the "did anything
4
+ * change" gate against the last upload) and the exact gzipped length (the
5
+ * VFS needs `Upload-Length` when the tus session is created). Before this
6
+ * helper the object hashed in one pass and the store counted the gzip in a
7
+ * second one; with the R2 page tier every pass over a cold file is one R2
8
+ * GET per segment, so the two are folded together and the upload itself is
9
+ * the only other read.
10
+ *
11
+ * The length is exact for the store's own gzip: `gzipStream` is the same
12
+ * `CompressionStream('gzip')` the stores pipe the upload through, and its
13
+ * output is deterministic for identical input.
14
+ */
15
+ import { createHash } from 'node:crypto';
16
+ import { countStream, gzipStream } from './types';
17
+
18
+ export interface SnapshotMeasure {
19
+ /** Hex SHA-256 of the raw (uncompressed) bytes. */
20
+ sha256Hex: string;
21
+ /** Byte length of `gzipStream(<the same bytes>)`. */
22
+ gzippedLength: number;
23
+ }
24
+
25
+ export async function measureForSave(
26
+ stream: ReadableStream<Uint8Array>,
27
+ ): Promise<SnapshotMeasure> {
28
+ const hash = createHash('sha256');
29
+ const tapped = stream.pipeThrough(
30
+ new TransformStream<Uint8Array, Uint8Array>({
31
+ transform(chunk, controller) {
32
+ hash.update(chunk);
33
+ controller.enqueue(chunk);
34
+ },
35
+ }),
36
+ );
37
+ const gzippedLength = await countStream(gzipStream(tapped));
38
+ return { sha256Hex: hash.digest('hex'), gzippedLength };
39
+ }
@@ -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,14 +20,12 @@
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,
23
+ import type {
24
+ FileSnapshot,
25
+ OwnerCopy,
26
+ OwnerStore,
27
+ SaveHints,
28
+ SaveResult,
29
29
  } from './types';
30
30
 
31
31
  export interface MigratingOwnerStoreOptions {
@@ -93,24 +93,16 @@ export class MigratingOwnerStore implements OwnerStore {
93
93
  return null;
94
94
  }
95
95
 
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
- }
96
+ // One-time migration, streamed: the object imports this copy chunk by
97
+ // chunk and then flushes its working copy to VFS (`fromLegacy`). Writing
98
+ // VFS from here would need the whole file in memory twice over (the VFS
99
+ // store measures the gzip before it uploads) — what used to reset the
100
+ // isolate on large Node-era histories.
101
+ this.log(
102
+ 'log',
103
+ `[owner-store] legacy Matrix copy ${fromLegacy.etag} found and no VFS copy yet — importing it for migration`,
104
+ );
105
+ return { ...fromLegacy, fromLegacy: true };
114
106
  }
115
107
 
116
108
  /**
@@ -118,8 +110,8 @@ export class MigratingOwnerStore implements OwnerStore {
118
110
  * "not on VFS yet" (no delegation) — so the object keeps the copy dirty
119
111
  * and retries; Matrix media is never written.
120
112
  */
121
- async save(snapshot: FileSnapshot): Promise<SaveResult> {
122
- return this.primary.save(snapshot);
113
+ async save(snapshot: FileSnapshot, hints?: SaveHints): Promise<SaveResult> {
114
+ return this.primary.save(snapshot, hints);
123
115
  }
124
116
 
125
117
  /**
@@ -159,13 +151,16 @@ export class MigratingOwnerStore implements OwnerStore {
159
151
 
160
152
  /** The legacy Matrix copy regardless of what VFS holds (see `OwnerStore.loadLegacy`). */
161
153
  async loadLegacy(): Promise<OwnerCopy | null> {
162
- return this.legacy.load().catch((err) => {
154
+ try {
155
+ const copy = await this.legacy.load();
156
+ return copy ? { ...copy, fromLegacy: true } : null;
157
+ } catch (err) {
163
158
  this.log(
164
159
  'warn',
165
160
  `[owner-store] legacy Matrix load failed: ${err instanceof Error ? err.message : String(err)}`,
166
161
  );
167
162
  return null;
168
- });
163
+ }
169
164
  }
170
165
 
171
166
  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()