@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.
@@ -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<{ socket: S; attachment: SocketAttachment }> {
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
- /** A client event arrived on the socket: persist the time (see `executorForSession`). */
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 runs on: the one
179
- * with the most recent client event, else the one opened last. The tab the
180
- * user acted in last is the likeliest to be in front; the tab that
181
- * connected last is often just a background tab that reconnected after a
182
- * laptop woke. Ties go to the socket registered later.
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
- executorForSession(
235
+ executorFor(
185
236
  sessionId: string,
186
- ): { socket: S; attachment: SocketAttachment } | undefined {
187
- const recency = (attachment: SocketAttachment): number =>
188
- attachment.lastActiveAt ?? attachment.openedAt;
189
- let chosen: { socket: S; attachment: SocketAttachment } | undefined;
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 (!chosen || recency(attachment) >= recency(chosen.attachment))
193
- chosen = { socket, attachment };
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 chosen;
256
+ return foreground ?? background;
196
257
  }
197
258
 
198
- /** Fan an event out to every authenticated socket of the session. */
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
- const frame = encodeEvent(eventName, payload);
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
- for (const socket of this.forSession(sessionId)) {
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: 'user' | 'channel' | 'artifact' | 'socket' | 'status' | 'start',
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, 'user', auth.userDid),
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>(