@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.
@@ -2,18 +2,61 @@
2
2
  * Bounded tool concurrency for one user object, shared by every session and
3
3
  * every turn that object runs:
4
4
  *
5
- * - writes run one at a time (a mutation never races another mutation of
6
- * the same user's data, even from two simultaneous sessions);
7
- * - reads run up to `maxReads` at a time;
8
- * - sub-agent dispatches run up to `maxSubagents` at a time, in their own
9
- * lane so a child waiting for one of the parent's slots can never
10
- * deadlock the parent.
5
+ * - `write`: one at a time across every session (a mutation never races
6
+ * another mutation of the same user's data, even from two simultaneous
7
+ * sessions);
8
+ * - `read`: up to `maxReads` at a time across every session;
9
+ * - `frontend:<sessionId>`: a tool that acts in the user's browser
10
+ * (`PluginTool.surface: 'frontend'`), one at a time per session and
11
+ * never in the user-wide lanes above, so a call parked while its chat
12
+ * has no tab holds no slot another chat needs;
13
+ * - `subagent:<sessionId>`: sub-agent dispatches, up to `maxSubagents` at
14
+ * a time per session, in their own lane so a child waiting for one of
15
+ * the parent's slots can never deadlock the parent, and a dispatch
16
+ * waiting in one chat (an inner frontend call parked) never holds up
17
+ * another chat's.
18
+ *
19
+ * The per-session lanes are created on demand and removed once idle (no
20
+ * call in flight or queued), so only busy ones exist.
11
21
  *
12
22
  * A wait is cancelled by the turn's abort signal: the waiter leaves the
13
23
  * queue and the signal's reason is thrown, so an aborted turn never holds a
14
24
  * slot it will not use.
15
25
  */
16
- export type ToolLane = 'read' | 'write' | 'subagent';
26
+ export type ToolLane = 'read' | 'write' | 'frontend' | 'subagent';
27
+
28
+ /** The lanes kept per session (`frontend:<sessionId>`, `subagent:<sessionId>`). */
29
+ export type SessionToolLane = Extract<ToolLane, 'frontend' | 'subagent'>;
30
+
31
+ const isSessionLane = (lane: ToolLane): lane is SessionToolLane =>
32
+ lane === 'frontend' || lane === 'subagent';
33
+
34
+ /** The key of a per-session lane: `frontend:<sessionId>` or `subagent:<sessionId>`. */
35
+ export function sessionLaneKey(
36
+ lane: SessionToolLane,
37
+ sessionId: string,
38
+ ): string {
39
+ return `${lane}:${sessionId}`;
40
+ }
41
+
42
+ export interface LaneOccupancy {
43
+ inFlight: number;
44
+ queued: number;
45
+ }
46
+
47
+ export interface ToolSchedulerSnapshot {
48
+ read: LaneOccupancy;
49
+ write: LaneOccupancy;
50
+ /** Summed over every session's frontend lane. */
51
+ frontend: LaneOccupancy;
52
+ /** Summed over every session's sub-agent lane. */
53
+ subagent: LaneOccupancy;
54
+ /**
55
+ * Per-session lanes by key (`sessionLaneKey`); only lanes with a call in
56
+ * flight or queued exist.
57
+ */
58
+ sessionLanes: Record<string, LaneOccupancy>;
59
+ }
17
60
 
18
61
  class Lane {
19
62
  private active = 0;
@@ -65,6 +108,14 @@ class Lane {
65
108
  get queued(): number {
66
109
  return this.waiting.length;
67
110
  }
111
+
112
+ get idle(): boolean {
113
+ return this.active === 0 && this.waiting.length === 0;
114
+ }
115
+
116
+ get occupancy(): LaneOccupancy {
117
+ return { inFlight: this.active, queued: this.waiting.length };
118
+ }
68
119
  }
69
120
 
70
121
  /** The signal's reason as an Error (an aborter may pass any value). */
@@ -86,40 +137,94 @@ export interface ToolSchedulerOptions {
86
137
  }
87
138
 
88
139
  export class ToolScheduler {
89
- private readonly lanes: Record<ToolLane, Lane>;
140
+ private readonly read: Lane;
141
+ private readonly write = new Lane(1);
142
+ private readonly maxSubagents: number;
143
+ /** Per-session lanes by `sessionLaneKey`; an idle lane is removed. */
144
+ private readonly sessionLanes = new Map<
145
+ string,
146
+ { kind: SessionToolLane; lane: Lane }
147
+ >();
90
148
 
91
149
  constructor(options: ToolSchedulerOptions = {}) {
92
- this.lanes = {
93
- write: new Lane(1),
94
- read: new Lane(options.maxReads ?? 4),
95
- subagent: new Lane(options.maxSubagents ?? 4),
96
- };
150
+ this.read = new Lane(options.maxReads ?? 4);
151
+ this.maxSubagents = options.maxSubagents ?? 4;
97
152
  }
98
153
 
154
+ /**
155
+ * Run `action` once a slot of `lane` is free. `sessionId` keys the
156
+ * per-session lanes (`frontend`, `subagent`) and is required for them; the
157
+ * user-wide `read` and `write` lanes ignore it.
158
+ */
99
159
  async run<T>(
100
160
  lane: ToolLane,
101
161
  signal: AbortSignal | undefined,
102
162
  action: () => Promise<T>,
163
+ sessionId?: string,
103
164
  ): Promise<T> {
104
- const slot = this.lanes[lane];
105
- await slot.acquire(signal);
165
+ const key = isSessionLane(lane)
166
+ ? sessionLaneKey(lane, requireSessionId(lane, sessionId))
167
+ : undefined;
168
+ const slot = this.laneFor(lane, key);
169
+ try {
170
+ await slot.acquire(signal);
171
+ } catch (error) {
172
+ this.dropIfIdle(key, slot);
173
+ throw error;
174
+ }
106
175
  try {
107
176
  return await action();
108
177
  } finally {
109
178
  slot.release();
179
+ this.dropIfIdle(key, slot);
110
180
  }
111
181
  }
112
182
 
113
- /** Occupancy per lane (diagnostics). */
114
- snapshot(): Record<ToolLane, { inFlight: number; queued: number }> {
115
- const entry = (lane: Lane) => ({
116
- inFlight: lane.inFlight,
117
- queued: lane.queued,
118
- });
183
+ /** Occupancy per lane (diagnostics: `GET /debug/runs`). */
184
+ snapshot(): ToolSchedulerSnapshot {
185
+ const sessionLanes: Record<string, LaneOccupancy> = {};
186
+ const summed: Record<SessionToolLane, LaneOccupancy> = {
187
+ frontend: { inFlight: 0, queued: 0 },
188
+ subagent: { inFlight: 0, queued: 0 },
189
+ };
190
+ for (const [key, { kind, lane }] of this.sessionLanes) {
191
+ sessionLanes[key] = lane.occupancy;
192
+ summed[kind].inFlight += lane.inFlight;
193
+ summed[kind].queued += lane.queued;
194
+ }
119
195
  return {
120
- read: entry(this.lanes.read),
121
- write: entry(this.lanes.write),
122
- subagent: entry(this.lanes.subagent),
196
+ read: this.read.occupancy,
197
+ write: this.write.occupancy,
198
+ frontend: summed.frontend,
199
+ subagent: summed.subagent,
200
+ sessionLanes,
123
201
  };
124
202
  }
203
+
204
+ private laneFor(lane: ToolLane, key: string | undefined): Lane {
205
+ if (!isSessionLane(lane) || key === undefined)
206
+ return lane === 'read' ? this.read : this.write;
207
+ const existing = this.sessionLanes.get(key);
208
+ if (existing) return existing.lane;
209
+ const created = new Lane(lane === 'frontend' ? 1 : this.maxSubagents);
210
+ this.sessionLanes.set(key, { kind: lane, lane: created });
211
+ return created;
212
+ }
213
+
214
+ private dropIfIdle(key: string | undefined, slot: Lane): void {
215
+ if (key === undefined || !slot.idle) return;
216
+ if (this.sessionLanes.get(key)?.lane === slot)
217
+ this.sessionLanes.delete(key);
218
+ }
219
+ }
220
+
221
+ function requireSessionId(
222
+ lane: SessionToolLane,
223
+ sessionId: string | undefined,
224
+ ): string {
225
+ if (sessionId === undefined)
226
+ throw new TypeError(
227
+ `ToolScheduler.run: the ${lane} lane is kept per session; pass the call's sessionId.`,
228
+ );
229
+ return sessionId;
125
230
  }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * The user-wide view of runs: which of the user's chats are busy right now
3
+ * (`GET /runs/active`, and `activeRun` on the rows of `GET /sessions`).
4
+ * Task runs (`task:<id>` sessions) are not chats and stay out, as they stay
5
+ * out of the session list; Matrix and channel runs are the user's chats
6
+ * whichever client sent the message, and are in.
7
+ */
8
+ import type { FrontendWaitingFor } from '../plugin-api/types';
9
+ import { TASK_SESSION_PREFIX } from '../tasks/scheduler';
10
+ import type { RunSummary, SessionSummary } from './contracts';
11
+ import { runSummaryOf } from './run-request';
12
+ import { ACTIVE_RUN_STATUSES, type RunRecord } from './run-store';
13
+
14
+ /**
15
+ * Every active (queued / running / recovering) chat run, oldest first (run
16
+ * id breaks a tie). `live` are the coordinator's records, `stored` the
17
+ * store's active rows (`RunStore.listActive()`): a run in both is listed
18
+ * once with its live record, which is ahead of the row between writes (a
19
+ * dequeued run is `running` in memory before its row says so). After a boot
20
+ * whose recovery scan succeeded every active row is live and the host
21
+ * passes no `stored` rows: a run leaves the live map before its row is
22
+ * closed, so a row read then would still say it runs.
23
+ */
24
+ export function activeChatRuns(
25
+ live: Iterable<RunRecord>,
26
+ stored: Iterable<RunRecord>,
27
+ ): RunRecord[] {
28
+ const byRunId = new Map<string, RunRecord>();
29
+ for (const record of stored) byRunId.set(record.runId, record);
30
+ for (const record of live) byRunId.set(record.runId, record);
31
+ return [...byRunId.values()]
32
+ .filter(
33
+ (record) =>
34
+ ACTIVE_RUN_STATUSES.has(record.status) &&
35
+ !record.sessionId.startsWith(TASK_SESSION_PREFIX),
36
+ )
37
+ .sort(
38
+ (a, b) =>
39
+ (a.startedAt < b.startedAt ? -1 : a.startedAt > b.startedAt ? 1 : 0) ||
40
+ a.runId.localeCompare(b.runId),
41
+ );
42
+ }
43
+
44
+ /** The room a run's stored request names (`turn.roomId`), if any. */
45
+ export function storedTurnRoomId(request: string): string | undefined {
46
+ let parsed: unknown;
47
+ try {
48
+ parsed = JSON.parse(request);
49
+ } catch {
50
+ return undefined;
51
+ }
52
+ const turn: unknown =
53
+ typeof parsed === 'object' && parsed !== null
54
+ ? Reflect.get(parsed, 'turn')
55
+ : undefined;
56
+ const roomId: unknown =
57
+ typeof turn === 'object' && turn !== null
58
+ ? Reflect.get(turn, 'roomId')
59
+ : undefined;
60
+ return typeof roomId === 'string' ? roomId : undefined;
61
+ }
62
+
63
+ /**
64
+ * The runs of the chats `GET /sessions` lists: with a main oracle room, only
65
+ * sessions in that room (`sessionRooms`: the room of each run's session
66
+ * that has a row). A run whose session has no row yet — a Matrix thread is
67
+ * recorded inside its first attempt — is placed by the room its request
68
+ * names; a Portal run without one is listed (the Portal opens its sessions
69
+ * in the main room). Without a main room every chat is listed, as
70
+ * `listSessions` lists every session then. Task runs are already out
71
+ * (`activeChatRuns`).
72
+ */
73
+ export function listedChatRuns(
74
+ runs: readonly RunRecord[],
75
+ o: {
76
+ mainRoomId: string | null;
77
+ sessionRooms: ReadonlyMap<string, string | null>;
78
+ },
79
+ ): RunRecord[] {
80
+ const { mainRoomId, sessionRooms } = o;
81
+ if (mainRoomId === null) return [...runs];
82
+ return runs.filter((run) => {
83
+ if (sessionRooms.has(run.sessionId))
84
+ return sessionRooms.get(run.sessionId) === mainRoomId;
85
+ const roomId = storedTurnRoomId(run.request);
86
+ if (roomId !== undefined) return roomId === mainRoomId;
87
+ return run.client === 'portal';
88
+ });
89
+ }
90
+
91
+ /** `running` before `recovering` before `queued`, as `RunStore.activeForSession` ranks them. */
92
+ const STATUS_RANK: Partial<Record<RunRecord['status'], number>> = {
93
+ running: 0,
94
+ recovering: 1,
95
+ queued: 2,
96
+ };
97
+
98
+ /**
99
+ * The run each session is busy with, from `activeChatRuns` (oldest first):
100
+ * the running or recovering one, else the oldest queued one — the run
101
+ * `GET /sessions/:id/run` reports for that session.
102
+ */
103
+ export function activeRunBySession(
104
+ runs: readonly RunRecord[],
105
+ ): Map<string, RunRecord> {
106
+ const bySession = new Map<string, RunRecord>();
107
+ for (const run of runs) {
108
+ const current = bySession.get(run.sessionId);
109
+ if (
110
+ !current ||
111
+ (STATUS_RANK[run.status] ?? 3) < (STATUS_RANK[current.status] ?? 3)
112
+ )
113
+ bySession.set(run.sessionId, run);
114
+ }
115
+ return bySession;
116
+ }
117
+
118
+ /**
119
+ * The session's parked frontend call, if any (`ctx.frontend.parkedFor`):
120
+ * the object's live realtime state, never stored.
121
+ */
122
+ export type ParkedCallLookup = (sessionId: string) => FrontendWaitingFor | null;
123
+
124
+ /**
125
+ * `summary` with `waitingFor` set when the run executes (`running` or
126
+ * `recovering`) and its session has a parked call: only the executing run
127
+ * of a session can have made one. No field at all otherwise (a queued run,
128
+ * a run that is over).
129
+ */
130
+ export function withWaitingFor(
131
+ summary: RunSummary,
132
+ parkedFor?: ParkedCallLookup,
133
+ ): RunSummary {
134
+ if (
135
+ !parkedFor ||
136
+ (summary.status !== 'running' && summary.status !== 'recovering')
137
+ )
138
+ return summary;
139
+ const waitingFor = parkedFor(summary.sessionId);
140
+ return waitingFor ? { ...summary, waitingFor } : summary;
141
+ }
142
+
143
+ /** A run as the user-wide listings report it: without the kept reply text. */
144
+ export function activeRunSummaryOf(
145
+ record: RunRecord,
146
+ parkedFor?: ParkedCallLookup,
147
+ ): RunSummary {
148
+ return withWaitingFor(
149
+ runSummaryOf(record, { withPartialText: false }),
150
+ parkedFor,
151
+ );
152
+ }
153
+
154
+ /**
155
+ * Session rows with `activeRun` set on the ones that have an active run.
156
+ * A row without one gets no field at all (never `null`), so clients that
157
+ * predate it see the rows they always did.
158
+ */
159
+ export function withActiveRuns(
160
+ sessions: readonly SessionSummary[],
161
+ runs: readonly RunRecord[],
162
+ parkedFor?: ParkedCallLookup,
163
+ ): SessionSummary[] {
164
+ const bySession = activeRunBySession(runs);
165
+ return sessions.map((session) => {
166
+ const run = bySession.get(session.sessionId);
167
+ return run
168
+ ? { ...session, activeRun: activeRunSummaryOf(run, parkedFor) }
169
+ : session;
170
+ });
171
+ }
@@ -14,9 +14,11 @@ import type {
14
14
  } from '@ixo/common/ai/decisions';
15
15
  import type { TranscriptPageOptions } from './transcript';
16
16
  import type { TurnUsage } from '../core/turn-budget';
17
+ import type { ToolSchedulerSnapshot } from '../core/tool-scheduler';
17
18
  import type { BotStatus, EncryptedFileInfo } from '@ixo/matrix-bot-workers-sdk';
18
19
  import type { AttachmentInput } from '../attachments/types';
19
20
  import type { RealtimeStatus } from '../realtime/realtime-endpoint';
21
+ import type { FrontendWaitingFor } from '../plugin-api/types';
20
22
  import type { TierFlushResult, TierStatus } from '../sqlite/do-vfs';
21
23
  import type { OwnedArtifact } from '../artifacts/store';
22
24
  import type {
@@ -251,6 +253,8 @@ export interface OracleWorkerEnv {
251
253
  TURN_MAX_IDENTICAL_READS?: string;
252
254
  /** Time limit per request-admission handler in ms (default 2000). */
253
255
  REQUEST_ADMISSION_TIMEOUT_MS?: string;
256
+ /** How long a frontend call waits unsent for a browser, in ms (default 300000). */
257
+ FRONTEND_PARK_TIMEOUT_MS?: string;
254
258
  /** Durable-run knobs (docs/plans/durable-runs.md); see `runDurabilityConfig`. */
255
259
  RUN_KEEPALIVE_MS?: string;
256
260
  RUN_SEGMENT_FLUSH_MS?: string;
@@ -405,7 +409,6 @@ export interface TurnRequest {
405
409
  taskRunId?: string;
406
410
  }
407
411
 
408
- /** A run as the shell reports it (`GET /sessions/:id/run`, `GET /debug/runs`). */
409
412
  /** `GET /debug/context` — the context budget a model gets on this object. */
410
413
  /**
411
414
  * Per-session context counters (`GET /debug/context?session=`): what the
@@ -500,6 +503,10 @@ export interface DomainContextRouterUpdate {
500
503
  domainContext: DomainContextProvenance[];
501
504
  }
502
505
 
506
+ /**
507
+ * A run as the shell reports it (`GET /sessions/:id/run`, `GET /runs/active`,
508
+ * `activeRun` on `GET /sessions` rows, `GET /debug/runs`).
509
+ */
503
510
  export interface RunSummary {
504
511
  runId: string;
505
512
  sessionId: string;
@@ -513,13 +520,27 @@ export interface RunSummary {
513
520
  | 'aborted'
514
521
  | 'interrupted'
515
522
  | 'failed';
523
+ /**
524
+ * When the run's row was created (`RunStore.create`), unchanged when a
525
+ * queued run starts: a dequeued run's elapsed time includes its wait.
526
+ */
516
527
  startedAt: string;
517
528
  updatedAt: string;
518
- /** Highest frame sequence a re-join can ask `after`. */
529
+ /**
530
+ * Highest frame sequence packed when the row was last written; written
531
+ * when the run ends, so not a live cursor for an active run.
532
+ */
519
533
  lastSeq: number;
520
534
  messageId?: string;
521
535
  /** The reply so far, for a run that ended without a committed reply. */
522
536
  partialText?: string;
537
+ /**
538
+ * The browser tool / AG-UI action the run is waiting on, parked because no
539
+ * browser of the session that can run it is connected: opening the chat
540
+ * sends it. Live state of this object only, never persisted; absent when
541
+ * the run waits on nothing.
542
+ */
543
+ waitingFor?: FrontendWaitingFor;
523
544
  }
524
545
 
525
546
  /** `GET /debug/runs`: the durable-run configuration, live runs and recent rows. */
@@ -573,6 +594,12 @@ export interface RunsStatus {
573
594
  startedAt: string;
574
595
  state: 'pending' | 'warned';
575
596
  }>;
597
+ /**
598
+ * Tool lanes of the object right now (tool-scheduler.ts): the user-wide
599
+ * `read` / `write` lanes, the per-session `frontend` and `subagent` lanes
600
+ * summed, and each busy per-session lane by key (`sessionLanes`).
601
+ */
602
+ tools: ToolSchedulerSnapshot;
576
603
  }
577
604
 
578
605
  /** Non-streaming turn result. */
@@ -611,6 +638,12 @@ export interface SessionSummary {
611
638
  createdAt: string;
612
639
  lastUpdatedAt: string;
613
640
  roomId?: string;
641
+ /**
642
+ * The run the session is busy with (queued / running / recovering), as
643
+ * `GET /runs/active` reports it: without `partialText`. Absent, never
644
+ * null, when the session has none.
645
+ */
646
+ activeRun?: RunSummary;
614
647
  }
615
648
 
616
649
  /** `search_memory_engine`'s raw upstream schema + whether it converts to Zod. */
@@ -761,12 +794,23 @@ export interface UserOracleObject extends Rpc.DurableObjectBranded {
761
794
  target: FeedbackTarget,
762
795
  outcome: FeedbackSettlement,
763
796
  ): Promise<void>;
764
- abortTurn(sessionId: string): Promise<boolean>;
797
+ /**
798
+ * Stop the session's current message. With `identity` (the HTTP route)
799
+ * the object boots first and the answer waits until the stopped runs
800
+ * have ended (at most the supersede grace).
801
+ */
802
+ abortTurn(sessionId: string, identity?: TurnIdentity): Promise<boolean>;
765
803
  /** The session's active (queued/running/recovering) run, if any. */
766
804
  sessionRun(
767
805
  identity: TurnIdentity,
768
806
  sessionId: string,
769
807
  ): Promise<RunSummary | null>;
808
+ /**
809
+ * `GET /runs/active` — every active run of the chats `GET /sessions`
810
+ * lists (same main-room rule, task runs excluded), oldest first, without
811
+ * `partialText`.
812
+ */
813
+ activeRuns(identity: TurnIdentity): Promise<RunSummary[]>;
770
814
  /** Diagnostics: recent runs with their tool marks and live state. */
771
815
  runsStatus(): Promise<RunsStatus>;
772
816
  /** `GET /debug/context` — the context budget a model gets on this object. */
@@ -108,6 +108,8 @@ export interface RunCoordinatorHost {
108
108
  checkpointIdOf: (sessionId: string) => Promise<string | null>;
109
109
  /** After a run reached a terminal state and its row was closed. */
110
110
  onRunEnded?: (record: RunRecord, outcome: RunOutcome) => Promise<void>;
111
+ /** Before a run's row is created (the host marks that runs may be active). */
112
+ beforeCreate?: () => Promise<void>;
111
113
  /** How long a superseded or aborted attempt gets to wind down (default 5 s). */
112
114
  supersedeGraceMs?: number;
113
115
  }
@@ -215,6 +217,11 @@ export class RunCoordinator {
215
217
  return candidate;
216
218
  }
217
219
 
220
+ /** The record of every run this coordinator holds (not ended yet). */
221
+ records(): RunRecord[] {
222
+ return [...this.live.values()].map((run) => run.record);
223
+ }
224
+
218
225
  byRequestId(requestId: string): LiveRun | undefined {
219
226
  for (const run of this.live.values())
220
227
  if (run.requestId === requestId) return run;
@@ -367,6 +374,7 @@ export class RunCoordinator {
367
374
  }
368
375
  }
369
376
  const checkpointId = await this.host.checkpointIdOf(sessionId);
377
+ await this.host.beforeCreate?.();
370
378
  const record = await this.host.store.create({
371
379
  runId: input.runId,
372
380
  sessionId,
@@ -423,6 +431,28 @@ export class RunCoordinator {
423
431
  return true;
424
432
  }
425
433
 
434
+ /**
435
+ * `abortSession`, resolving once every run it stopped has ended — out of
436
+ * the live map, its row closed — or after the supersede grace, whichever
437
+ * comes first. Runs enqueued behind the active one are not stopped and
438
+ * not waited for.
439
+ */
440
+ async abortSessionSettled(sessionId: string): Promise<boolean> {
441
+ const before = [...this.live.values()].filter(
442
+ (run) => run.sessionId === sessionId,
443
+ );
444
+ const stopped = this.abortSession(sessionId);
445
+ const ending = before.filter(
446
+ (run) => this.live.get(run.runId) !== run || run.abort.signal.aborted,
447
+ );
448
+ if (ending.length > 0)
449
+ await withinGrace(
450
+ this.graceMs,
451
+ Promise.all(ending.map((run) => run.done)),
452
+ );
453
+ return stopped;
454
+ }
455
+
426
456
  /**
427
457
  * End every run of the session: the executing attempt is aborted, queued
428
458
  * runs and runs waiting for a recovery attempt are closed, and messages
@@ -78,7 +78,20 @@ export function clientSurfaceFor(
78
78
  };
79
79
  }
80
80
 
81
- export function runSummaryOf(record: RunRecord): RunSummary {
81
+ export interface RunSummaryOptions {
82
+ /**
83
+ * Carry the run's kept reply text (default true). The user-wide listings
84
+ * (`GET /runs/active`, `activeRun` on session rows) pass false: the text
85
+ * can be large and a client polling every chat does not need it; the
86
+ * session's own `GET /sessions/:id/run` still has it.
87
+ */
88
+ withPartialText?: boolean;
89
+ }
90
+
91
+ export function runSummaryOf(
92
+ record: RunRecord,
93
+ { withPartialText = true }: RunSummaryOptions = {},
94
+ ): RunSummary {
82
95
  return {
83
96
  runId: record.runId,
84
97
  sessionId: record.sessionId,
@@ -89,7 +102,7 @@ export function runSummaryOf(record: RunRecord): RunSummary {
89
102
  updatedAt: record.updatedAt,
90
103
  lastSeq: record.lastSeq,
91
104
  ...(record.messageId ? { messageId: record.messageId } : {}),
92
- ...(record.partialText && record.status !== 'finished'
105
+ ...(withPartialText && record.partialText && record.status !== 'finished'
93
106
  ? { partialText: record.partialText }
94
107
  : {}),
95
108
  };
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import { DurableObject } from 'cloudflare:workers';
7
7
  import { DoSqliteDatabase } from '../sqlite/database';
8
+ import { activeChatRuns } from './active-runs';
8
9
  import type { RunSummary } from './contracts';
9
10
  import type { PackedSegment } from './run-buffer';
10
11
  import { RunCoordinator } from './run-coordinator';
@@ -172,6 +173,11 @@ export class RunStoreTestDO extends DurableObject {
172
173
  return (await this.runStore()).listActive();
173
174
  }
174
175
 
176
+ /** `activeChatRuns` over the given live records and this store's `listActive()`. */
177
+ async activeChatRuns(live: RunRecord[]): Promise<RunRecord[]> {
178
+ return activeChatRuns(live, await (await this.runStore()).listActive());
179
+ }
180
+
175
181
  async queuedForSession(sessionId: string): Promise<RunRecord[]> {
176
182
  return (await this.runStore()).queuedForSession(sessionId);
177
183
  }
@@ -202,6 +208,10 @@ export class RunStoreTestDO extends DurableObject {
202
208
  await (await this.runStore()).bumpMark(runId, toolCallId);
203
209
  }
204
210
 
211
+ async markSent(runId: string, toolCallId: string): Promise<void> {
212
+ await (await this.runStore()).markSent(runId, toolCallId);
213
+ }
214
+
205
215
  async finishMark(
206
216
  runId: string,
207
217
  toolCallId: string,