@ixo/oracle-runtime-workers 0.21.2 → 0.23.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.
Files changed (59) hide show
  1. package/README.md +16 -13
  2. package/package.json +10 -4
  3. package/src/action-proposals/policy.ts +54 -0
  4. package/src/action-proposals/reconcile.ts +87 -0
  5. package/src/action-proposals/store.ts +863 -0
  6. package/src/action-proposals/topic-budget.ts +84 -0
  7. package/src/action-proposals/topic-chain.ts +272 -0
  8. package/src/action-proposals/topic-effects.ts +44 -0
  9. package/src/action-proposals/topic-errors.ts +79 -0
  10. package/src/action-proposals/topic-history.ts +267 -0
  11. package/src/action-proposals/topic-matrix.ts +292 -0
  12. package/src/action-proposals/topic-prepare.ts +299 -0
  13. package/src/action-proposals/topic-store.ts +999 -0
  14. package/src/action-proposals/topic-verify.ts +279 -0
  15. package/src/action-proposals/verify.ts +223 -0
  16. package/src/channels/auth.ts +6 -1
  17. package/src/channels/contract.ts +4 -0
  18. package/src/channels/turns.ts +1 -0
  19. package/src/core/env.ts +107 -5
  20. package/src/core/index.ts +8 -2
  21. package/src/core/main-agent-types.ts +16 -0
  22. package/src/core/main-agent.ts +26 -0
  23. package/src/core/middlewares/tool-execution.ts +87 -46
  24. package/src/core/middlewares/tool-marks.ts +54 -19
  25. package/src/core/runtime-context.ts +27 -1
  26. package/src/core/state.ts +2 -0
  27. package/src/core/tool-scheduler.ts +129 -24
  28. package/src/delivery/plan.ts +43 -2
  29. package/src/delivery/schema.ts +25 -1
  30. package/src/delivery/types.ts +13 -2
  31. package/src/do/active-runs.ts +171 -0
  32. package/src/do/ambient.ts +6 -0
  33. package/src/do/contracts.ts +86 -3
  34. package/src/do/run-coordinator.ts +30 -0
  35. package/src/do/run-request.ts +15 -2
  36. package/src/do/run-store-test-do.ts +10 -0
  37. package/src/do/run-store.ts +66 -13
  38. package/src/do/turn-body.ts +13 -1
  39. package/src/do/user-oracle-do.ts +1420 -17
  40. package/src/hub-actions/client.ts +163 -0
  41. package/src/hub-actions/request.ts +110 -0
  42. package/src/hub-actions/store.ts +485 -0
  43. package/src/matrix/gateway-do.ts +176 -0
  44. package/src/matrix/reply-parts.ts +8 -2
  45. package/src/plugin-api/tool-helper.ts +6 -0
  46. package/src/plugin-api/types.ts +117 -7
  47. package/src/plugins/action-proposals/action-proposals.plugin.ts +486 -0
  48. package/src/plugins/action-proposals/evidence.ts +67 -0
  49. package/src/plugins/agui/agui.plugin.ts +7 -5
  50. package/src/plugins/hub-actions/hub-actions.plugin.ts +244 -0
  51. package/src/plugins/index.ts +6 -0
  52. package/src/plugins/ixo-transaction/wallet-signing.ts +5 -2
  53. package/src/plugins/portal/declared-tools.ts +12 -0
  54. package/src/plugins/portal/portal.plugin.ts +22 -8
  55. package/src/realtime/frontend-call-registry.ts +147 -27
  56. package/src/realtime/realtime-endpoint.ts +335 -41
  57. package/src/realtime/session-socket-hub.ts +90 -19
  58. package/src/shell/app.ts +218 -2
  59. package/src/sqlite/sessions-store.ts +22 -0
@@ -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
  }
@@ -1,3 +1,5 @@
1
+ import { ActionReferenceSchema } from '@ixo/ixo-transaction/action-proposals';
2
+ import { parseReference } from '@ixo/action-protocol';
1
3
  /**
2
4
  * A finished run → its Reply Plan. Built once, when the run finishes, from
3
5
  * the turn's own messages (the model's checkpointed history is never
@@ -160,11 +162,42 @@ export function draftReplyPlan(input: {
160
162
  );
161
163
  else parts.push({ kind: 'spill', key: `step-${i}`, spill: shape.spill });
162
164
  }
163
- for (const call of step.toolCalls)
165
+ for (const call of step.toolCalls) {
166
+ if (call.name === 'request_hub_action') {
167
+ try {
168
+ const result = JSON.parse(input.toolResults.get(call.id) ?? 'null');
169
+ if (result?.status === 'prepared') {
170
+ const actionReference = parseReference(result.actionReference);
171
+ if (
172
+ typeof result.summary === 'string' &&
173
+ result.summary === actionReference.title
174
+ )
175
+ parts.push({
176
+ kind: 'hub-action',
177
+ actionReference,
178
+ summary: result.summary,
179
+ });
180
+ }
181
+ } catch {
182
+ // Only a parsed tool result becomes a structured Hub reference.
183
+ }
184
+ }
185
+ if (call.name === 'prepare_action') {
186
+ try {
187
+ const result = JSON.parse(input.toolResults.get(call.id) ?? 'null');
188
+ if (result?.status === 'prepared') {
189
+ const ref = ActionReferenceSchema.safeParse(result.action);
190
+ if (ref.success) parts.push({ kind: 'action', action: ref.data });
191
+ }
192
+ } catch {
193
+ /* A malformed result is not an action reference. */
194
+ }
195
+ }
164
196
  if (call.name === CREATE_ARTIFACT_TOOL)
165
197
  parts.push(
166
198
  ...createArtifactParts(call.args, input.toolResults.get(call.id)),
167
199
  );
200
+ }
168
201
  });
169
202
  return parts;
170
203
  }
@@ -221,7 +254,15 @@ function capParts(parts: PlanPart[], limits: ChatLimits): ReplyContent[] {
221
254
  (part): ReplyContent =>
222
255
  part.kind === 'text'
223
256
  ? { kind: 'text', text: part.text }
224
- : { kind: 'artifact', artifact: part.artifact },
257
+ : part.kind === 'artifact'
258
+ ? { kind: 'artifact', artifact: part.artifact }
259
+ : part.kind === 'action'
260
+ ? { kind: 'action', action: part.action }
261
+ : {
262
+ kind: 'hub-action',
263
+ actionReference: part.actionReference,
264
+ summary: part.summary,
265
+ },
225
266
  );
226
267
  }
227
268
 
@@ -1,3 +1,5 @@
1
+ import { ActionReferenceSchema } from '@ixo/ixo-transaction/action-proposals';
2
+ import { parseReference, type ActionReferenceV1 } from '@ixo/action-protocol';
1
3
  import { z } from 'zod';
2
4
  import type { ReplyContent, ReplyPlan } from './types';
3
5
 
@@ -19,6 +21,24 @@ export const ReplyPlanSchema = z.object({
19
21
  kind: z.literal('text'),
20
22
  text: z.string(),
21
23
  }),
24
+ z.object({
25
+ partId: z.string(),
26
+ kind: z.literal('action'),
27
+ action: ActionReferenceSchema,
28
+ }),
29
+ z.object({
30
+ partId: z.string(),
31
+ kind: z.literal('hub-action'),
32
+ actionReference: z.custom<ActionReferenceV1>((value) => {
33
+ try {
34
+ parseReference(value);
35
+ return true;
36
+ } catch {
37
+ return false;
38
+ }
39
+ }),
40
+ summary: z.string().min(1).max(80),
41
+ }),
22
42
  z.object({
23
43
  partId: z.string(),
24
44
  kind: z.literal('artifact'),
@@ -55,7 +75,11 @@ export function planText(plan: { parts: readonly ReplyContent[] }): string {
55
75
  .map((part) =>
56
76
  part.kind === 'text'
57
77
  ? part.text
58
- : `[${linkLabel(part.artifact.title)}](${part.artifact.url})`,
78
+ : part.kind === 'action'
79
+ ? `[${linkLabel(part.action.title)}](${part.action.url})`
80
+ : part.kind === 'hub-action'
81
+ ? `[${linkLabel(part.summary)}](${part.actionReference.url})`
82
+ : `[${linkLabel(part.artifact.title)}](${part.artifact.url})`,
59
83
  )
60
84
  .join('\n\n');
61
85
  }
@@ -1,3 +1,5 @@
1
+ import type { ActionReference } from '@ixo/ixo-transaction/action-proposals';
2
+ import type { ActionReferenceV1 } from '@ixo/action-protocol';
1
3
  /**
2
4
  * Chat delivery: how a finished turn reaches a chat surface (an IXO Channels
3
5
  * provider such as WhatsApp, or a Matrix room) as a few short messages and
@@ -70,12 +72,21 @@ export interface ArtifactRef {
70
72
  */
71
73
  export type ReplyPart =
72
74
  | { partId: string; kind: 'text'; text: string }
73
- | { partId: string; kind: 'artifact'; artifact: ArtifactRef };
75
+ | { partId: string; kind: 'artifact'; artifact: ArtifactRef }
76
+ | { partId: string; kind: 'action'; action: ActionReference }
77
+ | {
78
+ partId: string;
79
+ kind: 'hub-action';
80
+ actionReference: ActionReferenceV1;
81
+ summary: string;
82
+ };
74
83
 
75
84
  /** A part before it is numbered: what a plan is assembled from. */
76
85
  export type ReplyContent =
77
86
  | { kind: 'text'; text: string }
78
- | { kind: 'artifact'; artifact: ArtifactRef };
87
+ | { kind: 'artifact'; artifact: ArtifactRef }
88
+ | { kind: 'action'; action: ActionReference }
89
+ | { kind: 'hub-action'; actionReference: ActionReferenceV1; summary: string };
79
90
 
80
91
  /** What a chat surface delivers for one finished run, in delivery order. */
81
92
  export interface ReplyPlan {
@@ -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
+ }
package/src/do/ambient.ts CHANGED
@@ -267,6 +267,8 @@ export function createUcanAdapter(
267
267
 
268
268
  export interface CreateAmbientInput {
269
269
  /** Host task scheduler surface for this user, when tasks are enabled. */
270
+ actionProposals?: AmbientServices['actionProposals'];
271
+ hubActions?: AmbientServices['hubActions'];
270
272
  tasks?: import('../plugin-api/types').OracleTasksSurface;
271
273
  preferences?: import('../plugin-api/types').UserPreferencesSurface;
272
274
  /** `ctx.kv` over the user's working copy (`SqliteUserKv`). */
@@ -302,6 +304,10 @@ export function createAmbientServices(
302
304
  ): AmbientServices {
303
305
  return {
304
306
  ...(input.tasks ? { tasks: input.tasks } : {}),
307
+ ...(input.actionProposals
308
+ ? { actionProposals: input.actionProposals }
309
+ : {}),
310
+ ...(input.hubActions ? { hubActions: input.hubActions } : {}),
305
311
  ...(input.preferences ? { preferences: input.preferences } : {}),
306
312
  ...(input.kv ? { kv: input.kv } : {}),
307
313
  ...(input.frontend ? { frontend: input.frontend } : {}),