@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.
- package/README.md +16 -13
- package/package.json +10 -4
- package/src/action-proposals/policy.ts +54 -0
- package/src/action-proposals/reconcile.ts +87 -0
- package/src/action-proposals/store.ts +863 -0
- package/src/action-proposals/topic-budget.ts +84 -0
- package/src/action-proposals/topic-chain.ts +272 -0
- package/src/action-proposals/topic-effects.ts +44 -0
- package/src/action-proposals/topic-errors.ts +79 -0
- package/src/action-proposals/topic-history.ts +267 -0
- package/src/action-proposals/topic-matrix.ts +292 -0
- package/src/action-proposals/topic-prepare.ts +299 -0
- package/src/action-proposals/topic-store.ts +999 -0
- package/src/action-proposals/topic-verify.ts +279 -0
- package/src/action-proposals/verify.ts +223 -0
- package/src/channels/auth.ts +6 -1
- package/src/channels/contract.ts +4 -0
- package/src/channels/turns.ts +1 -0
- package/src/core/env.ts +107 -5
- package/src/core/index.ts +8 -2
- package/src/core/main-agent-types.ts +16 -0
- package/src/core/main-agent.ts +26 -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 +27 -1
- package/src/core/state.ts +2 -0
- package/src/core/tool-scheduler.ts +129 -24
- package/src/delivery/plan.ts +43 -2
- package/src/delivery/schema.ts +25 -1
- package/src/delivery/types.ts +13 -2
- package/src/do/active-runs.ts +171 -0
- package/src/do/ambient.ts +6 -0
- package/src/do/contracts.ts +86 -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 +1420 -17
- package/src/hub-actions/client.ts +163 -0
- package/src/hub-actions/request.ts +110 -0
- package/src/hub-actions/store.ts +485 -0
- package/src/matrix/gateway-do.ts +176 -0
- package/src/matrix/reply-parts.ts +8 -2
- package/src/plugin-api/tool-helper.ts +6 -0
- package/src/plugin-api/types.ts +117 -7
- package/src/plugins/action-proposals/action-proposals.plugin.ts +486 -0
- package/src/plugins/action-proposals/evidence.ts +67 -0
- package/src/plugins/agui/agui.plugin.ts +7 -5
- package/src/plugins/hub-actions/hub-actions.plugin.ts +244 -0
- package/src/plugins/index.ts +6 -0
- 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 +218 -2
- 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
|
-
* -
|
|
6
|
-
* the same user's data, even from two simultaneous
|
|
7
|
-
*
|
|
8
|
-
* -
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
|
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.
|
|
93
|
-
|
|
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
|
|
105
|
-
|
|
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():
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
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:
|
|
121
|
-
write:
|
|
122
|
-
|
|
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
|
}
|
package/src/delivery/plan.ts
CHANGED
|
@@ -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
|
-
:
|
|
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
|
|
package/src/delivery/schema.ts
CHANGED
|
@@ -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
|
-
:
|
|
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
|
}
|
package/src/delivery/types.ts
CHANGED
|
@@ -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 } : {}),
|