@ixo/oracle-runtime-workers 0.21.2 → 0.22.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 +1 -0
- package/package.json +3 -3
- package/src/core/env.ts +18 -0
- package/src/core/index.ts +8 -2
- package/src/core/main-agent-types.ts +8 -0
- package/src/core/main-agent.ts +24 -0
- package/src/core/middlewares/tool-execution.ts +87 -46
- package/src/core/middlewares/tool-marks.ts +54 -19
- package/src/core/runtime-context.ts +15 -1
- package/src/core/state.ts +2 -0
- package/src/core/tool-scheduler.ts +129 -24
- package/src/do/active-runs.ts +171 -0
- package/src/do/contracts.ts +47 -3
- package/src/do/run-coordinator.ts +30 -0
- package/src/do/run-request.ts +15 -2
- package/src/do/run-store-test-do.ts +10 -0
- package/src/do/run-store.ts +66 -13
- package/src/do/turn-body.ts +13 -1
- package/src/do/user-oracle-do.ts +167 -10
- package/src/plugin-api/tool-helper.ts +6 -0
- package/src/plugin-api/types.ts +63 -7
- package/src/plugins/agui/agui.plugin.ts +7 -5
- package/src/plugins/ixo-transaction/wallet-signing.ts +5 -2
- package/src/plugins/portal/declared-tools.ts +12 -0
- package/src/plugins/portal/portal.plugin.ts +22 -8
- package/src/realtime/frontend-call-registry.ts +147 -27
- package/src/realtime/realtime-endpoint.ts +335 -41
- package/src/realtime/session-socket-hub.ts +90 -19
- package/src/shell/app.ts +24 -2
- package/src/sqlite/sessions-store.ts +22 -0
|
@@ -10,7 +10,14 @@
|
|
|
10
10
|
* (`serializeAttachment`), so the set is rebuilt losslessly from
|
|
11
11
|
* `ctx.getWebSockets()` whenever the object wakes from hibernation or
|
|
12
12
|
* restarts. Nothing here is kept only in memory.
|
|
13
|
+
*
|
|
14
|
+
* A socket is a foreground executor (a tab showing the chat: it renders the
|
|
15
|
+
* session's events and runs any frontend call) unless its CONNECT declared
|
|
16
|
+
* itself a background executor: then it runs only the browser tools it
|
|
17
|
+
* named — never an AG-UI action, which renders on the chat's own surface or
|
|
18
|
+
* asks the user (a wallet signature) — and receives no fan-out.
|
|
13
19
|
*/
|
|
20
|
+
import type { FrontendCallKind } from './frontend-call-registry';
|
|
14
21
|
import { ENGINE_PING, encodeEvent } from './socket-io-codec';
|
|
15
22
|
|
|
16
23
|
export interface SocketAttachment {
|
|
@@ -32,8 +39,40 @@ export interface SocketAttachment {
|
|
|
32
39
|
* engine.io pongs do not count — a background tab answers those too.
|
|
33
40
|
*/
|
|
34
41
|
lastActiveAt?: number;
|
|
42
|
+
/** Set when the CONNECT declared a background executor; absent = foreground. */
|
|
43
|
+
executor?: 'background';
|
|
44
|
+
/** The tools a background executor can run (validated, bounded; see `realtime-endpoint.ts`). */
|
|
45
|
+
tools?: string[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** An executor socket with its attachment. */
|
|
49
|
+
export interface HubEntry<S> {
|
|
50
|
+
socket: S;
|
|
51
|
+
attachment: SocketAttachment;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const isForeground = (attachment: SocketAttachment): boolean =>
|
|
55
|
+
attachment.executor !== 'background';
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Whether the socket may run a call of `kind` to `toolName`: any call in
|
|
59
|
+
* the foreground; in the background only a browser tool it declared.
|
|
60
|
+
*/
|
|
61
|
+
export function canRunTool(
|
|
62
|
+
attachment: SocketAttachment,
|
|
63
|
+
kind: FrontendCallKind,
|
|
64
|
+
toolName: string,
|
|
65
|
+
): boolean {
|
|
66
|
+
return (
|
|
67
|
+
isForeground(attachment) ||
|
|
68
|
+
(kind === 'browser' && (attachment.tools ?? []).includes(toolName))
|
|
69
|
+
);
|
|
35
70
|
}
|
|
36
71
|
|
|
72
|
+
/** Most recent client event, else open time (see `executorFor`). */
|
|
73
|
+
const recency = (attachment: SocketAttachment): number =>
|
|
74
|
+
attachment.lastActiveAt ?? attachment.openedAt;
|
|
75
|
+
|
|
37
76
|
/** The subset of the Workers `WebSocket` the hub needs (tests use fakes). */
|
|
38
77
|
export interface HubSocket {
|
|
39
78
|
send(message: string): void;
|
|
@@ -148,14 +187,14 @@ export class SessionSocketHub<S extends HubSocket = HubSocket> {
|
|
|
148
187
|
}
|
|
149
188
|
|
|
150
189
|
/** Every socket with its heartbeat bookkeeping (diagnostics). */
|
|
151
|
-
entries(): Array<
|
|
190
|
+
entries(): Array<HubEntry<S>> {
|
|
152
191
|
return [...this.sockets].map(([socket, attachment]) => ({
|
|
153
192
|
socket,
|
|
154
193
|
attachment,
|
|
155
194
|
}));
|
|
156
195
|
}
|
|
157
196
|
|
|
158
|
-
/** Authenticated sockets subscribed to `sessionId
|
|
197
|
+
/** Authenticated sockets subscribed to `sessionId`, background executors included. */
|
|
159
198
|
forSession(sessionId: string): S[] {
|
|
160
199
|
const out: S[] = [];
|
|
161
200
|
for (const [socket, meta] of this.sockets) {
|
|
@@ -168,42 +207,74 @@ export class SessionSocketHub<S extends HubSocket = HubSocket> {
|
|
|
168
207
|
return this.forSession(sessionId).length > 0;
|
|
169
208
|
}
|
|
170
209
|
|
|
171
|
-
/**
|
|
210
|
+
/** Whether a foreground (chat-rendering) socket of `sessionId` is authenticated. */
|
|
211
|
+
hasForegroundSession(sessionId: string): boolean {
|
|
212
|
+
for (const meta of this.sockets.values())
|
|
213
|
+
if (meta.userDid && meta.sessionId === sessionId && isForeground(meta))
|
|
214
|
+
return true;
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** A client event arrived on the socket: persist the time (see `executorFor`). */
|
|
172
219
|
noteActivity(socket: S): void {
|
|
173
220
|
if (this.sockets.has(socket))
|
|
174
221
|
this.update(socket, { lastActiveAt: this.now() });
|
|
175
222
|
}
|
|
176
223
|
|
|
177
224
|
/**
|
|
178
|
-
* The authenticated socket of `sessionId` a frontend call
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
225
|
+
* The authenticated socket of `sessionId` a frontend call of `kind` to
|
|
226
|
+
* `toolName` runs on. A foreground socket first: the one with the most recent client
|
|
227
|
+
* event, else the one opened last — the tab the user acted in last is the
|
|
228
|
+
* likeliest to be in front; the tab that connected last is often just a
|
|
229
|
+
* background tab that reconnected after a laptop woke. Without one, a
|
|
230
|
+
* background executor that declared the browser tool `toolName`, by the
|
|
231
|
+
* same recency.
|
|
232
|
+
* Ties go to the socket registered later. Undefined when no socket of the
|
|
233
|
+
* session can run the tool.
|
|
183
234
|
*/
|
|
184
|
-
|
|
235
|
+
executorFor(
|
|
185
236
|
sessionId: string,
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
let
|
|
237
|
+
kind: FrontendCallKind,
|
|
238
|
+
toolName: string,
|
|
239
|
+
): HubEntry<S> | undefined {
|
|
240
|
+
let foreground: HubEntry<S> | undefined;
|
|
241
|
+
let background: HubEntry<S> | undefined;
|
|
190
242
|
for (const [socket, attachment] of this.sockets) {
|
|
191
243
|
if (!attachment.userDid || attachment.sessionId !== sessionId) continue;
|
|
192
|
-
if (
|
|
193
|
-
|
|
244
|
+
if (isForeground(attachment)) {
|
|
245
|
+
if (
|
|
246
|
+
!foreground ||
|
|
247
|
+
recency(attachment) >= recency(foreground.attachment)
|
|
248
|
+
)
|
|
249
|
+
foreground = { socket, attachment };
|
|
250
|
+
} else if (
|
|
251
|
+
canRunTool(attachment, kind, toolName) &&
|
|
252
|
+
(!background || recency(attachment) >= recency(background.attachment))
|
|
253
|
+
)
|
|
254
|
+
background = { socket, attachment };
|
|
194
255
|
}
|
|
195
|
-
return
|
|
256
|
+
return foreground ?? background;
|
|
196
257
|
}
|
|
197
258
|
|
|
198
|
-
/**
|
|
259
|
+
/**
|
|
260
|
+
* Fan an event out to the session's authenticated FOREGROUND sockets: a
|
|
261
|
+
* background executor runs calls, it does not render the chat.
|
|
262
|
+
*/
|
|
199
263
|
emitToSession(
|
|
200
264
|
sessionId: string,
|
|
201
265
|
eventName: string,
|
|
202
266
|
payload: unknown,
|
|
203
267
|
): number {
|
|
204
|
-
|
|
268
|
+
// Encoded once, and only when a socket will take it: most events of a
|
|
269
|
+
// session nobody has open (or only background executors) cost nothing.
|
|
270
|
+
let frame: string | undefined;
|
|
205
271
|
let sent = 0;
|
|
206
|
-
|
|
272
|
+
// A failed send removes that socket; deleting the entry being visited
|
|
273
|
+
// is safe while iterating a Map.
|
|
274
|
+
for (const [socket, meta] of this.sockets) {
|
|
275
|
+
if (!meta.userDid || meta.sessionId !== sessionId || !isForeground(meta))
|
|
276
|
+
continue;
|
|
277
|
+
frame ??= encodeEvent(eventName, payload);
|
|
207
278
|
if (this.trySend(socket, frame)) sent += 1;
|
|
208
279
|
}
|
|
209
280
|
return sent;
|
package/src/shell/app.ts
CHANGED
|
@@ -213,7 +213,14 @@ export function isDidShaped(value: string): boolean {
|
|
|
213
213
|
*/
|
|
214
214
|
export function rateLimitKey(
|
|
215
215
|
env: Pick<OracleWorkerEnv, 'ORACLE_DID'>,
|
|
216
|
-
scope:
|
|
216
|
+
scope:
|
|
217
|
+
| 'user'
|
|
218
|
+
| 'poll'
|
|
219
|
+
| 'channel'
|
|
220
|
+
| 'artifact'
|
|
221
|
+
| 'socket'
|
|
222
|
+
| 'status'
|
|
223
|
+
| 'start',
|
|
217
224
|
subject: string,
|
|
218
225
|
): string {
|
|
219
226
|
return `${env.ORACLE_DID}|${scope}|${subject}`;
|
|
@@ -523,13 +530,18 @@ export function createShell(
|
|
|
523
530
|
// unbounded LLM spend; the key carries the oracle's DID (`rateLimitKey`).
|
|
524
531
|
// Auth-excluded routes (health/models/matrix status) ran `next()` above
|
|
525
532
|
// and never reach here. Absent binding = no limiting (local harness / tests).
|
|
533
|
+
// The chat lists' status poll (`GET /runs/active`) spends its own budget
|
|
534
|
+
// (`poll` scope, same binding): a tab polling every 4 s never pushes the
|
|
535
|
+
// user's messages into 429, nor the other way round.
|
|
526
536
|
app.use('*', async (c, next) => {
|
|
527
537
|
const limiter = c.env.RATE_LIMIT;
|
|
528
538
|
if (!limiter) return next();
|
|
529
539
|
const auth = c.get('auth') as AuthResult | undefined;
|
|
530
540
|
if (!auth) return next();
|
|
541
|
+
const scope =
|
|
542
|
+
c.req.method === 'GET' && c.req.path === '/runs/active' ? 'poll' : 'user';
|
|
531
543
|
const { success } = await limiter.limit({
|
|
532
|
-
key: rateLimitKey(c.env,
|
|
544
|
+
key: rateLimitKey(c.env, scope, auth.userDid),
|
|
533
545
|
});
|
|
534
546
|
if (!success)
|
|
535
547
|
return c.json(
|
|
@@ -668,6 +680,7 @@ export function createShell(
|
|
|
668
680
|
return c.json({ message: 'sessionId is required' }, 400);
|
|
669
681
|
const success = await userStub(c.env, identity.userDid).abortTurn(
|
|
670
682
|
body.sessionId,
|
|
683
|
+
identity,
|
|
671
684
|
);
|
|
672
685
|
return c.json({ success });
|
|
673
686
|
});
|
|
@@ -745,6 +758,15 @@ export function createShell(
|
|
|
745
758
|
);
|
|
746
759
|
return c.json({ run });
|
|
747
760
|
});
|
|
761
|
+
// Every active run of the caller's chats (task runs excluded, no
|
|
762
|
+
// `partialText`), oldest first: a client's chat lists poll it to show
|
|
763
|
+
// which chats are busy. Registered before `/runs/:runId`, which would
|
|
764
|
+
// otherwise take `active` for a run id.
|
|
765
|
+
app.get('/runs/active', async (c) => {
|
|
766
|
+
const identity = identityOf(c.get('auth'), c.req.raw.headers);
|
|
767
|
+
const runs = await userStub(c.env, identity.userDid).activeRuns(identity);
|
|
768
|
+
return c.json({ runs });
|
|
769
|
+
});
|
|
748
770
|
// Re-join a run's SSE stream after a cursor (`?after=<seq>`, the `id:` of
|
|
749
771
|
// the last frame the client saw). Frames after it are replayed, then the
|
|
750
772
|
// live tail until `done`. A run that ended replays and closes at once.
|
|
@@ -220,6 +220,28 @@ export class SessionsStore {
|
|
|
220
220
|
return { sessions: [], total: Number(count?.n ?? 0) };
|
|
221
221
|
}
|
|
222
222
|
|
|
223
|
+
/**
|
|
224
|
+
* The room of each of these sessions that has a row (`null`: a row
|
|
225
|
+
* without a room). One statement for the lot; sessions without a row are
|
|
226
|
+
* absent from the map.
|
|
227
|
+
*/
|
|
228
|
+
async roomsOf(
|
|
229
|
+
sessionIds: readonly string[],
|
|
230
|
+
): Promise<Map<string, string | null>> {
|
|
231
|
+
const rooms = new Map<string, string | null>();
|
|
232
|
+
if (sessionIds.length === 0) return rooms;
|
|
233
|
+
await this.setup();
|
|
234
|
+
const rows = await this.db.exec<{
|
|
235
|
+
session_id: string;
|
|
236
|
+
room_id: string | null;
|
|
237
|
+
}>(
|
|
238
|
+
`SELECT session_id, room_id FROM sessions WHERE session_id IN (${sessionIds.map(() => '?').join(', ')})`,
|
|
239
|
+
[...sessionIds],
|
|
240
|
+
);
|
|
241
|
+
for (const row of rows) rooms.set(row.session_id, row.room_id);
|
|
242
|
+
return rooms;
|
|
243
|
+
}
|
|
244
|
+
|
|
223
245
|
async getSession(sessionId: string): Promise<SessionRecord | undefined> {
|
|
224
246
|
await this.setup();
|
|
225
247
|
const row = await this.db.get<SessionRow>(
|