@ixo/oracle-runtime-workers 0.4.0 → 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.
- package/README.md +5 -1
- package/package.json +3 -3
- package/src/attachments/download.ts +43 -22
- package/src/attachments/index.ts +1 -0
- package/src/core/env.ts +19 -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 +91 -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 +339 -67
- 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/matrix-media-store.ts +59 -39
- package/src/owner-store/migrating-store.ts +22 -33
- 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 +80 -7
- package/src/shell/turn-body-cap.ts +19 -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
|
|
|
@@ -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
|
-
*
|
|
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,
|
|
22
25
|
} from './types';
|
|
23
26
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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:
|
|
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:
|
|
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
|
-
*
|
|
57
|
-
*
|
|
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.
|
|
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
|
|
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 };
|
|
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
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const
|
|
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
|
-
|
|
108
|
+
gzipStream(snapshot.open()),
|
|
90
109
|
this.filename,
|
|
110
|
+
gzLength,
|
|
91
111
|
);
|
|
92
|
-
return { etag: eventId, bytes:
|
|
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,
|
|
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,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
|
|
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
|
-
}
|
|
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
|
-
|
|
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> {
|
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()
|
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
|
-
//
|
|
271
|
-
//
|
|
272
|
-
if (
|
|
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(
|
|
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
|
+
}
|