@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
|
@@ -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
|
-
/**
|
|
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(
|
|
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.
|
|
237
|
-
*
|
|
238
|
-
*
|
|
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(
|
|
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 =
|
|
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
|
-
*
|
|
8
|
-
*
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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:
|
|
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:
|
|
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
|
-
*
|
|
57
|
-
*
|
|
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.
|
|
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
|
|
68
|
-
|
|
69
|
-
: found.
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
113
|
+
gzipStream(snapshot.open()),
|
|
90
114
|
this.filename,
|
|
115
|
+
gzLength,
|
|
91
116
|
);
|
|
92
|
-
return { etag: eventId, bytes:
|
|
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,
|
|
7
|
-
* the legacy
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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> {
|
package/src/owner-store/types.ts
CHANGED
|
Binary file
|
package/src/plugin-api/types.ts
CHANGED
|
@@ -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: (
|
|
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
|
-
|
|
25
|
-
.
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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: ${
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
262
|
+
schedule: ScheduleInputToScheduleSchema.optional(),
|
|
263
263
|
approval: z
|
|
264
264
|
.enum(['never', 'before-action'])
|
|
265
265
|
.optional()
|