@alexeiled/pi-fusion 0.5.0 → 0.5.1
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 +28 -0
- package/package.json +1 -1
- package/src/fusion-rpc.ts +619 -0
- package/src/index.ts +14 -1
- package/src/orchestrator.ts +1 -0
- package/src/run-store.ts +49 -1
- package/src/types.ts +2 -0
package/README.md
CHANGED
|
@@ -142,6 +142,34 @@ Do not use it for trivial edits, formatting, or obvious one-step fixes.
|
|
|
142
142
|
/fusion init
|
|
143
143
|
```
|
|
144
144
|
|
|
145
|
+
## Plan execution RPC
|
|
146
|
+
|
|
147
|
+
Other Pi extensions can control Fusion through the versioned event-bus contract
|
|
148
|
+
`fusion:rpc:v1`:
|
|
149
|
+
|
|
150
|
+
- emit requests on `fusion:rpc:v1:request`
|
|
151
|
+
- listen for the response on `fusion:rpc:v1:reply:<requestId>` before emitting
|
|
152
|
+
- send `{ "version": 1, "requestId": "...", "method": "...", "params": {} }`
|
|
153
|
+
- receive `{ "version": 1, "requestId": "...", "method": "...", "success": true, "data": {} }` or a failure with a typed `error`
|
|
154
|
+
|
|
155
|
+
Methods:
|
|
156
|
+
|
|
157
|
+
- `ping` — return the RPC version and supported methods
|
|
158
|
+
- `start` — requires `prompt` and a non-empty `operationId`; accepts optional `profile`. Reusing an operation ID returns the original run instead of starting another, including after Fusion restores the Pi session history.
|
|
159
|
+
- `status` — return structured run state by `operationId`, `runId`, or the current/last run
|
|
160
|
+
- `result` — return a terminal run and report; active runs return `not_ready`
|
|
161
|
+
- `cancel` — cancel the selected active run, or report that the selected terminal run was not cancelled
|
|
162
|
+
- `adopt` — confirm and return a run from restored session history by `runId`
|
|
163
|
+
|
|
164
|
+
`start` returns `{ operationId, replayed, run }`. `status` and `result`
|
|
165
|
+
return `{ run }`. `cancel` returns `{ cancelled, run? }`. `adopt` returns
|
|
166
|
+
`{ adopted: true, run }`. Run state contains `runId`, optional `operationId`,
|
|
167
|
+
`phase`, `terminal`, and optional `report` or `error`.
|
|
168
|
+
|
|
169
|
+
Failure codes are `invalid_request`, `unsupported_method`, `busy`, `not_found`,
|
|
170
|
+
`not_ready`, `unavailable`, `start_failed`, `cancel_failed`, and `internal`.
|
|
171
|
+
`busy`, `not_ready`, and lookup failures include structured details when available.
|
|
172
|
+
|
|
145
173
|
## Quick start
|
|
146
174
|
|
|
147
175
|
Requirements:
|
package/package.json
CHANGED
|
@@ -0,0 +1,619 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
FusionCommandContext,
|
|
3
|
+
FusionCommandResult,
|
|
4
|
+
} from "./orchestrator.js";
|
|
5
|
+
import type { FusionRunStore } from "./run-store.js";
|
|
6
|
+
import type { FusionPhase, FusionRun, ParsedFusionArgs } from "./types.js";
|
|
7
|
+
import { isNonEmptyString, isRecord } from "./utils.js";
|
|
8
|
+
|
|
9
|
+
export const FUSION_RPC_VERSION = 1;
|
|
10
|
+
export const FUSION_RPC_REQUEST_EVENT = "fusion:rpc:v1:request";
|
|
11
|
+
export const FUSION_RPC_REPLY_EVENT_PREFIX = "fusion:rpc:v1:reply:";
|
|
12
|
+
|
|
13
|
+
export const FUSION_RPC_METHODS = [
|
|
14
|
+
"ping",
|
|
15
|
+
"start",
|
|
16
|
+
"status",
|
|
17
|
+
"result",
|
|
18
|
+
"cancel",
|
|
19
|
+
"adopt",
|
|
20
|
+
] as const;
|
|
21
|
+
|
|
22
|
+
export type FusionRpcMethod = (typeof FUSION_RPC_METHODS)[number];
|
|
23
|
+
|
|
24
|
+
export type FusionRpcErrorCode =
|
|
25
|
+
| "invalid_request"
|
|
26
|
+
| "unsupported_method"
|
|
27
|
+
| "busy"
|
|
28
|
+
| "not_found"
|
|
29
|
+
| "not_ready"
|
|
30
|
+
| "unavailable"
|
|
31
|
+
| "start_failed"
|
|
32
|
+
| "cancel_failed"
|
|
33
|
+
| "internal";
|
|
34
|
+
|
|
35
|
+
export interface FusionRpcRequestEnvelope {
|
|
36
|
+
version: typeof FUSION_RPC_VERSION;
|
|
37
|
+
requestId: string;
|
|
38
|
+
method: FusionRpcMethod;
|
|
39
|
+
params?: unknown;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface FusionRpcError {
|
|
43
|
+
code: FusionRpcErrorCode;
|
|
44
|
+
message: string;
|
|
45
|
+
details?: unknown;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface FusionRunState {
|
|
49
|
+
runId: string;
|
|
50
|
+
operationId?: string;
|
|
51
|
+
phase: FusionPhase;
|
|
52
|
+
terminal: boolean;
|
|
53
|
+
report?: string;
|
|
54
|
+
error?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface FusionRpcPingData {
|
|
58
|
+
pong: true;
|
|
59
|
+
version: typeof FUSION_RPC_VERSION;
|
|
60
|
+
methods: readonly FusionRpcMethod[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface FusionRpcStartData {
|
|
64
|
+
operationId: string;
|
|
65
|
+
replayed: boolean;
|
|
66
|
+
run: FusionRunState;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface FusionRpcStatusData {
|
|
70
|
+
run: FusionRunState;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface FusionRpcResultData {
|
|
74
|
+
run: FusionRunState;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface FusionRpcCancelData {
|
|
78
|
+
cancelled: boolean;
|
|
79
|
+
run?: FusionRunState;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface FusionRpcAdoptData {
|
|
83
|
+
adopted: true;
|
|
84
|
+
run: FusionRunState;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export type FusionRpcReplyEnvelope =
|
|
88
|
+
| {
|
|
89
|
+
version: typeof FUSION_RPC_VERSION;
|
|
90
|
+
requestId: string;
|
|
91
|
+
method?: FusionRpcMethod;
|
|
92
|
+
success: true;
|
|
93
|
+
data: unknown;
|
|
94
|
+
}
|
|
95
|
+
| {
|
|
96
|
+
version: typeof FUSION_RPC_VERSION;
|
|
97
|
+
requestId: string;
|
|
98
|
+
method?: FusionRpcMethod;
|
|
99
|
+
success: false;
|
|
100
|
+
error: FusionRpcError;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export interface FusionRpcEventBus {
|
|
104
|
+
on(event: string, handler: (payload: unknown) => void): (() => void) | void;
|
|
105
|
+
emit(event: string, payload: unknown): void;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface FusionRpcOrchestrator {
|
|
109
|
+
startRun(
|
|
110
|
+
input: ParsedFusionArgs,
|
|
111
|
+
ctx: FusionCommandContext,
|
|
112
|
+
): Promise<FusionCommandResult>;
|
|
113
|
+
cancelActiveRun(ctx: FusionCommandContext): Promise<FusionCommandResult>;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
type FusionRpcRunStore = Pick<
|
|
117
|
+
FusionRunStore,
|
|
118
|
+
"getActiveRun" | "getLastRunSummary" | "getRunById" | "getRunByOperationId"
|
|
119
|
+
>;
|
|
120
|
+
|
|
121
|
+
export interface FusionRpcDependencies {
|
|
122
|
+
events: FusionRpcEventBus;
|
|
123
|
+
orchestrator: FusionRpcOrchestrator;
|
|
124
|
+
store: FusionRpcRunStore;
|
|
125
|
+
getContext: () => FusionCommandContext | undefined;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
interface OperationRecord {
|
|
129
|
+
pending?: Promise<FusionRpcStartData>;
|
|
130
|
+
runId?: string;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
interface StartParams {
|
|
134
|
+
prompt: string;
|
|
135
|
+
profile?: string;
|
|
136
|
+
operationId: string;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
interface RunParams {
|
|
140
|
+
runId?: string;
|
|
141
|
+
operationId?: string;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
type ObservableRun = Pick<
|
|
145
|
+
FusionRun,
|
|
146
|
+
"id" | "operationId" | "phase" | "report" | "error"
|
|
147
|
+
>;
|
|
148
|
+
|
|
149
|
+
const TERMINAL_PHASES = new Set<FusionPhase>(["done", "failed", "cancelled"]);
|
|
150
|
+
|
|
151
|
+
export function fusionRpcReplyEvent(requestId: string): string {
|
|
152
|
+
return `${FUSION_RPC_REPLY_EVENT_PREFIX}${requestId}`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function registerFusionRpc({
|
|
156
|
+
events,
|
|
157
|
+
orchestrator,
|
|
158
|
+
store,
|
|
159
|
+
getContext,
|
|
160
|
+
}: FusionRpcDependencies): () => void {
|
|
161
|
+
const operations = new Map<string, OperationRecord>();
|
|
162
|
+
const unsubscribe = events.on(FUSION_RPC_REQUEST_EVENT, (event) => {
|
|
163
|
+
void handleRequest(event);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
return typeof unsubscribe === "function" ? unsubscribe : () => undefined;
|
|
167
|
+
|
|
168
|
+
async function handleRequest(event: unknown): Promise<void> {
|
|
169
|
+
const request = parseRequest(event);
|
|
170
|
+
if (request instanceof RpcRequestFailure) {
|
|
171
|
+
if (request.requestId) {
|
|
172
|
+
replyFailure(request.requestId, request.method, request.error);
|
|
173
|
+
}
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
const data = await dispatch(request);
|
|
179
|
+
replySuccess(request.requestId, request.method, data);
|
|
180
|
+
} catch (error: unknown) {
|
|
181
|
+
replyFailure(request.requestId, request.method, normalizeError(error));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function dispatch(request: FusionRpcRequestEnvelope): Promise<unknown> {
|
|
186
|
+
switch (request.method) {
|
|
187
|
+
case "ping":
|
|
188
|
+
return {
|
|
189
|
+
pong: true,
|
|
190
|
+
version: FUSION_RPC_VERSION,
|
|
191
|
+
methods: FUSION_RPC_METHODS,
|
|
192
|
+
} satisfies FusionRpcPingData;
|
|
193
|
+
case "start":
|
|
194
|
+
return start(request.params);
|
|
195
|
+
case "status":
|
|
196
|
+
return {
|
|
197
|
+
run: stateFor(findRun(request.params, operations, store, "status")),
|
|
198
|
+
} satisfies FusionRpcStatusData;
|
|
199
|
+
case "result":
|
|
200
|
+
return result(request.params);
|
|
201
|
+
case "cancel":
|
|
202
|
+
return cancel(request.params);
|
|
203
|
+
case "adopt":
|
|
204
|
+
return adopt(request.params);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function start(params: unknown): Promise<FusionRpcStartData> {
|
|
209
|
+
const input = parseStartParams(params);
|
|
210
|
+
const persisted = store.getRunByOperationId(input.operationId);
|
|
211
|
+
if (persisted) return startData(input.operationId, persisted, true);
|
|
212
|
+
|
|
213
|
+
const known = operations.get(input.operationId);
|
|
214
|
+
if (known?.runId) {
|
|
215
|
+
const run = store.getRunById(known.runId);
|
|
216
|
+
if (run) return startData(input.operationId, run, true);
|
|
217
|
+
operations.delete(input.operationId);
|
|
218
|
+
} else if (known?.pending) {
|
|
219
|
+
const response = await known.pending;
|
|
220
|
+
return { ...response, replayed: true };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const context = requireContext(getContext());
|
|
224
|
+
const pending = orchestrator
|
|
225
|
+
.startRun(toParsedFusionArgs(input), context)
|
|
226
|
+
.then((result) =>
|
|
227
|
+
startData(
|
|
228
|
+
input.operationId,
|
|
229
|
+
runFromStartResult(result, input.operationId, store),
|
|
230
|
+
false,
|
|
231
|
+
),
|
|
232
|
+
);
|
|
233
|
+
operations.set(input.operationId, { pending });
|
|
234
|
+
|
|
235
|
+
try {
|
|
236
|
+
const response = await pending;
|
|
237
|
+
operations.set(input.operationId, { runId: response.run.runId });
|
|
238
|
+
return response;
|
|
239
|
+
} catch (error: unknown) {
|
|
240
|
+
operations.delete(input.operationId);
|
|
241
|
+
throw error;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function result(params: unknown): FusionRpcResultData {
|
|
246
|
+
const run = findRun(params, operations, store, "result");
|
|
247
|
+
const state = stateFor(run);
|
|
248
|
+
if (!state.terminal) {
|
|
249
|
+
throw new RpcFailure({
|
|
250
|
+
code: "not_ready",
|
|
251
|
+
message: `Fusion run ${state.runId} is not terminal.`,
|
|
252
|
+
details: { run: state },
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
return { run: state };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function cancel(params: unknown): Promise<FusionRpcCancelData> {
|
|
259
|
+
const selector = parseRunParams(params, "cancel");
|
|
260
|
+
const selected = hasRunSelector(selector)
|
|
261
|
+
? findRun(params, operations, store, "cancel")
|
|
262
|
+
: undefined;
|
|
263
|
+
const active = store.getActiveRun();
|
|
264
|
+
|
|
265
|
+
if (selected && TERMINAL_PHASES.has(selected.phase)) {
|
|
266
|
+
return { cancelled: false, run: stateFor(selected) };
|
|
267
|
+
}
|
|
268
|
+
if (!active) {
|
|
269
|
+
const last = selected ?? store.getLastRunSummary();
|
|
270
|
+
return last
|
|
271
|
+
? { cancelled: false, run: stateFor(last) }
|
|
272
|
+
: { cancelled: false };
|
|
273
|
+
}
|
|
274
|
+
if (selected && selected.id !== active.id) {
|
|
275
|
+
return { cancelled: false, run: stateFor(selected) };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const context = requireContext(getContext());
|
|
279
|
+
const cancellation = await orchestrator.cancelActiveRun(context);
|
|
280
|
+
if (cancellation.status === "cancelled") {
|
|
281
|
+
return { cancelled: true, run: stateFor(cancellation.run) };
|
|
282
|
+
}
|
|
283
|
+
if (cancellation.status === "failed") {
|
|
284
|
+
throw new RpcFailure({
|
|
285
|
+
code: "cancel_failed",
|
|
286
|
+
message: cancellation.error,
|
|
287
|
+
details: { run: stateFor(active) },
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const current = store.getRunById(active.id);
|
|
292
|
+
if (!current) return { cancelled: false };
|
|
293
|
+
return {
|
|
294
|
+
cancelled: current.phase === "cancelled",
|
|
295
|
+
run: stateFor(current),
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function adopt(params: unknown): FusionRpcAdoptData {
|
|
300
|
+
const runId = parseAdoptParams(params);
|
|
301
|
+
const run = store.getRunById(runId);
|
|
302
|
+
if (!run) {
|
|
303
|
+
throw new RpcFailure({
|
|
304
|
+
code: "not_found",
|
|
305
|
+
message: "Fusion run was not found in this session history.",
|
|
306
|
+
details: { runId },
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
return { adopted: true, run: stateFor(run) };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function replySuccess(
|
|
313
|
+
requestId: string,
|
|
314
|
+
method: FusionRpcMethod,
|
|
315
|
+
data: unknown,
|
|
316
|
+
): void {
|
|
317
|
+
events.emit(fusionRpcReplyEvent(requestId), {
|
|
318
|
+
version: FUSION_RPC_VERSION,
|
|
319
|
+
requestId,
|
|
320
|
+
method,
|
|
321
|
+
success: true,
|
|
322
|
+
data,
|
|
323
|
+
} satisfies FusionRpcReplyEnvelope);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function replyFailure(
|
|
327
|
+
requestId: string,
|
|
328
|
+
method: FusionRpcMethod | undefined,
|
|
329
|
+
error: FusionRpcError,
|
|
330
|
+
): void {
|
|
331
|
+
events.emit(fusionRpcReplyEvent(requestId), {
|
|
332
|
+
version: FUSION_RPC_VERSION,
|
|
333
|
+
requestId,
|
|
334
|
+
...(method === undefined ? {} : { method }),
|
|
335
|
+
success: false,
|
|
336
|
+
error,
|
|
337
|
+
} satisfies FusionRpcReplyEnvelope);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function parseRequest(
|
|
342
|
+
input: unknown,
|
|
343
|
+
): FusionRpcRequestEnvelope | RpcRequestFailure {
|
|
344
|
+
if (!isRecord(input)) {
|
|
345
|
+
return new RpcRequestFailure(undefined, undefined, {
|
|
346
|
+
code: "invalid_request",
|
|
347
|
+
message: "RPC request must be an object.",
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const requestId = input.requestId;
|
|
352
|
+
const method = input.method;
|
|
353
|
+
if (!isNonEmptyString(requestId)) {
|
|
354
|
+
return new RpcRequestFailure(undefined, undefined, {
|
|
355
|
+
code: "invalid_request",
|
|
356
|
+
message: "RPC requestId must be a non-empty string.",
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
if (!isMethod(method)) {
|
|
360
|
+
return new RpcRequestFailure(requestId, undefined, {
|
|
361
|
+
code: "unsupported_method",
|
|
362
|
+
message: "RPC method is unsupported.",
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
if (input.version !== FUSION_RPC_VERSION) {
|
|
366
|
+
return new RpcRequestFailure(requestId, method, {
|
|
367
|
+
code: "invalid_request",
|
|
368
|
+
message: `RPC version must be ${FUSION_RPC_VERSION}.`,
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return input.params === undefined
|
|
373
|
+
? { version: FUSION_RPC_VERSION, requestId, method }
|
|
374
|
+
: {
|
|
375
|
+
version: FUSION_RPC_VERSION,
|
|
376
|
+
requestId,
|
|
377
|
+
method,
|
|
378
|
+
params: input.params,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function parseStartParams(input: unknown): StartParams {
|
|
383
|
+
if (!isRecord(input)) {
|
|
384
|
+
throw invalidParams("start parameters must be an object.");
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const prompt = input.prompt;
|
|
388
|
+
if (!isNonEmptyString(prompt)) {
|
|
389
|
+
throw invalidParams("start prompt must be a non-empty string.");
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const operationId = input.operationId;
|
|
393
|
+
if (!isNonEmptyString(operationId)) {
|
|
394
|
+
throw invalidParams("start operationId must be a non-empty string.");
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const profile = input.profile;
|
|
398
|
+
if (profile !== undefined && !isNonEmptyString(profile)) {
|
|
399
|
+
throw invalidParams(
|
|
400
|
+
"start profile must be a non-empty string when provided.",
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return profile === undefined
|
|
405
|
+
? { prompt, operationId }
|
|
406
|
+
: { prompt, operationId, profile };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function toParsedFusionArgs(input: StartParams): ParsedFusionArgs {
|
|
410
|
+
return input.profile === undefined
|
|
411
|
+
? { prompt: input.prompt, operationId: input.operationId }
|
|
412
|
+
: {
|
|
413
|
+
prompt: input.prompt,
|
|
414
|
+
profile: input.profile,
|
|
415
|
+
operationId: input.operationId,
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function findRun(
|
|
420
|
+
input: unknown,
|
|
421
|
+
operations: ReadonlyMap<string, OperationRecord>,
|
|
422
|
+
store: FusionRpcRunStore,
|
|
423
|
+
method: "status" | "result" | "cancel",
|
|
424
|
+
): ObservableRun {
|
|
425
|
+
const params = parseRunParams(input, method);
|
|
426
|
+
if (params.operationId) {
|
|
427
|
+
const persisted = store.getRunByOperationId(params.operationId);
|
|
428
|
+
if (persisted) return persisted;
|
|
429
|
+
|
|
430
|
+
const operation = operations.get(params.operationId);
|
|
431
|
+
if (operation?.runId) {
|
|
432
|
+
const run = store.getRunById(operation.runId);
|
|
433
|
+
if (run) return run;
|
|
434
|
+
}
|
|
435
|
+
if (operation?.pending) {
|
|
436
|
+
const active = store.getActiveRun();
|
|
437
|
+
if (active?.operationId === params.operationId) return active;
|
|
438
|
+
throw new RpcFailure({
|
|
439
|
+
code: "not_ready",
|
|
440
|
+
message: `Fusion operation ${params.operationId} has not produced a run yet.`,
|
|
441
|
+
details: { operationId: params.operationId },
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
throw notFound({ operationId: params.operationId });
|
|
445
|
+
}
|
|
446
|
+
if (params.runId) {
|
|
447
|
+
const run = store.getRunById(params.runId);
|
|
448
|
+
if (!run) throw notFound({ runId: params.runId });
|
|
449
|
+
return run;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const run = store.getActiveRun() ?? store.getLastRunSummary();
|
|
453
|
+
if (!run) throw notFound();
|
|
454
|
+
return run;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function parseRunParams(
|
|
458
|
+
input: unknown,
|
|
459
|
+
method: "status" | "result" | "cancel",
|
|
460
|
+
): RunParams {
|
|
461
|
+
if (input === undefined) return {};
|
|
462
|
+
if (!isRecord(input)) {
|
|
463
|
+
throw invalidParams(`${method} parameters must be an object.`);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const { operationId, runId } = input;
|
|
467
|
+
if (operationId !== undefined && !isNonEmptyString(operationId)) {
|
|
468
|
+
throw invalidParams(
|
|
469
|
+
"operationId must be a non-empty string when provided.",
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
if (runId !== undefined && !isNonEmptyString(runId)) {
|
|
473
|
+
throw invalidParams("runId must be a non-empty string when provided.");
|
|
474
|
+
}
|
|
475
|
+
if (operationId !== undefined && runId !== undefined) {
|
|
476
|
+
throw invalidParams("Specify either operationId or runId, not both.");
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
if (operationId !== undefined) return { operationId };
|
|
480
|
+
return runId === undefined ? {} : { runId };
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function hasRunSelector(params: RunParams): boolean {
|
|
484
|
+
return params.operationId !== undefined || params.runId !== undefined;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function parseAdoptParams(input: unknown): string {
|
|
488
|
+
if (!isRecord(input) || !isNonEmptyString(input.runId)) {
|
|
489
|
+
throw invalidParams("adopt runId must be a non-empty string.");
|
|
490
|
+
}
|
|
491
|
+
return input.runId;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function runFromStartResult(
|
|
495
|
+
result: FusionCommandResult,
|
|
496
|
+
operationId: string,
|
|
497
|
+
store: FusionRpcRunStore,
|
|
498
|
+
): ObservableRun {
|
|
499
|
+
switch (result.status) {
|
|
500
|
+
case "started":
|
|
501
|
+
case "done":
|
|
502
|
+
case "cancelled":
|
|
503
|
+
return result.run;
|
|
504
|
+
case "conflict": {
|
|
505
|
+
const active = store.getRunById(result.activeRunId);
|
|
506
|
+
throw new RpcFailure({
|
|
507
|
+
code: "busy",
|
|
508
|
+
message: `Fusion run ${result.activeRunId} is already active.`,
|
|
509
|
+
details: {
|
|
510
|
+
activeRunId: result.activeRunId,
|
|
511
|
+
...(active ? { run: stateFor(active) } : {}),
|
|
512
|
+
},
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
case "failed":
|
|
516
|
+
throw startFailure(result.error, store.getRunByOperationId(operationId));
|
|
517
|
+
case "ignored": {
|
|
518
|
+
const persisted = store.getRunByOperationId(operationId);
|
|
519
|
+
if (persisted) return persisted;
|
|
520
|
+
throw new RpcFailure({
|
|
521
|
+
code: "internal",
|
|
522
|
+
message: "Fusion run did not start.",
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function startData(
|
|
529
|
+
operationId: string,
|
|
530
|
+
run: ObservableRun,
|
|
531
|
+
replayed: boolean,
|
|
532
|
+
): FusionRpcStartData {
|
|
533
|
+
if (run.phase === "failed") {
|
|
534
|
+
throw startFailure(run.error ?? "Fusion run failed to start.", run);
|
|
535
|
+
}
|
|
536
|
+
return {
|
|
537
|
+
operationId,
|
|
538
|
+
replayed,
|
|
539
|
+
run: stateFor(run),
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function startFailure(message: string, run?: ObservableRun): RpcFailure {
|
|
544
|
+
return new RpcFailure({
|
|
545
|
+
code: "start_failed",
|
|
546
|
+
message,
|
|
547
|
+
...(run ? { details: { run: stateFor(run) } } : {}),
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function stateFor(run: ObservableRun): FusionRunState {
|
|
552
|
+
const state: FusionRunState = {
|
|
553
|
+
runId: run.id,
|
|
554
|
+
...(run.operationId !== undefined ? { operationId: run.operationId } : {}),
|
|
555
|
+
phase: run.phase,
|
|
556
|
+
terminal: TERMINAL_PHASES.has(run.phase),
|
|
557
|
+
};
|
|
558
|
+
if (run.report !== undefined) state.report = run.report;
|
|
559
|
+
if (run.error !== undefined) state.error = run.error;
|
|
560
|
+
return state;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function requireContext(
|
|
564
|
+
context: FusionCommandContext | undefined,
|
|
565
|
+
): FusionCommandContext {
|
|
566
|
+
if (context) return context;
|
|
567
|
+
throw new RpcFailure({
|
|
568
|
+
code: "unavailable",
|
|
569
|
+
message: "Fusion session context is unavailable.",
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function isMethod(value: unknown): value is FusionRpcMethod {
|
|
574
|
+
return (
|
|
575
|
+
value === "ping" ||
|
|
576
|
+
value === "start" ||
|
|
577
|
+
value === "status" ||
|
|
578
|
+
value === "result" ||
|
|
579
|
+
value === "cancel" ||
|
|
580
|
+
value === "adopt"
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function invalidParams(message: string): RpcFailure {
|
|
585
|
+
return new RpcFailure({ code: "invalid_request", message });
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function notFound(details?: unknown): RpcFailure {
|
|
589
|
+
return new RpcFailure({
|
|
590
|
+
code: "not_found",
|
|
591
|
+
message: "Fusion run was not found.",
|
|
592
|
+
...(details === undefined ? {} : { details }),
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function normalizeError(error: unknown): FusionRpcError {
|
|
597
|
+
if (error instanceof RpcFailure) return error.error;
|
|
598
|
+
return {
|
|
599
|
+
code: "internal",
|
|
600
|
+
message:
|
|
601
|
+
error instanceof Error ? error.message : "Unexpected Fusion RPC error.",
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
class RpcFailure extends Error {
|
|
606
|
+
constructor(readonly error: FusionRpcError) {
|
|
607
|
+
super(error.message);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
class RpcRequestFailure extends Error {
|
|
612
|
+
constructor(
|
|
613
|
+
readonly requestId: string | undefined,
|
|
614
|
+
readonly method: FusionRpcMethod | undefined,
|
|
615
|
+
readonly error: FusionRpcError,
|
|
616
|
+
) {
|
|
617
|
+
super(error.message);
|
|
618
|
+
}
|
|
619
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -4,7 +4,9 @@ import { registerFusionCommands } from "./commands.js";
|
|
|
4
4
|
import {
|
|
5
5
|
FusionOrchestrator,
|
|
6
6
|
SUBAGENT_ASYNC_COMPLETE_EVENT,
|
|
7
|
+
type FusionCommandContext,
|
|
7
8
|
} from "./orchestrator.js";
|
|
9
|
+
import { registerFusionRpc } from "./fusion-rpc.js";
|
|
8
10
|
import { FusionRunStore } from "./run-store.js";
|
|
9
11
|
import { SubagentsRpcClient } from "./subagents-rpc.js";
|
|
10
12
|
|
|
@@ -54,9 +56,11 @@ function registerFusionTool(
|
|
|
54
56
|
}
|
|
55
57
|
|
|
56
58
|
export default function fusionExtension(pi: ExtensionAPI): void {
|
|
59
|
+
const store = new FusionRunStore({ persistence: pi });
|
|
60
|
+
let sessionContext: FusionCommandContext | undefined;
|
|
57
61
|
const orchestrator = new FusionOrchestrator({
|
|
58
62
|
rpc: new SubagentsRpcClient({ events: pi.events }),
|
|
59
|
-
runStore:
|
|
63
|
+
runStore: store,
|
|
60
64
|
sendMessage: (message) => pi.sendMessage(message),
|
|
61
65
|
});
|
|
62
66
|
|
|
@@ -69,14 +73,23 @@ export default function fusionExtension(pi: ExtensionAPI): void {
|
|
|
69
73
|
void orchestrator.handleSubagentComplete(payload);
|
|
70
74
|
},
|
|
71
75
|
);
|
|
76
|
+
const unsubscribeRpc = registerFusionRpc({
|
|
77
|
+
events: pi.events,
|
|
78
|
+
orchestrator,
|
|
79
|
+
store,
|
|
80
|
+
getContext: () => sessionContext,
|
|
81
|
+
});
|
|
72
82
|
|
|
73
83
|
pi.on("session_start", async (_event, ctx) => {
|
|
84
|
+
sessionContext = ctx;
|
|
74
85
|
await orchestrator.restore(ctx);
|
|
75
86
|
});
|
|
76
87
|
|
|
77
88
|
pi.on("session_shutdown", () => {
|
|
89
|
+
sessionContext = undefined;
|
|
78
90
|
orchestrator.clearUi();
|
|
79
91
|
orchestrator.dispose();
|
|
80
92
|
if (typeof unsubscribeComplete === "function") unsubscribeComplete();
|
|
93
|
+
unsubscribeRpc();
|
|
81
94
|
});
|
|
82
95
|
}
|
package/src/orchestrator.ts
CHANGED
|
@@ -165,6 +165,7 @@ export class FusionOrchestrator {
|
|
|
165
165
|
run = this.runStore.startRun({
|
|
166
166
|
prompt: args.prompt,
|
|
167
167
|
profileName: resolved.name,
|
|
168
|
+
...(args.operationId !== undefined ? { operationId: args.operationId } : {}),
|
|
168
169
|
phase: "panel",
|
|
169
170
|
});
|
|
170
171
|
} catch (error: unknown) {
|
package/src/run-store.ts
CHANGED
|
@@ -23,6 +23,7 @@ export type FusionRunSummary = Omit<
|
|
|
23
23
|
| "id"
|
|
24
24
|
| "prompt"
|
|
25
25
|
| "profileName"
|
|
26
|
+
| "operationId"
|
|
26
27
|
| "phase"
|
|
27
28
|
| "createdAt"
|
|
28
29
|
| "updatedAt"
|
|
@@ -39,6 +40,7 @@ export interface FusionRunStartInput {
|
|
|
39
40
|
id?: string;
|
|
40
41
|
prompt: string;
|
|
41
42
|
profileName: string;
|
|
43
|
+
operationId?: string;
|
|
42
44
|
phase?: Exclude<FusionPhase, FusionTerminalPhase>;
|
|
43
45
|
createdAt?: number;
|
|
44
46
|
}
|
|
@@ -96,6 +98,8 @@ export class FusionRunStoreError extends Error {
|
|
|
96
98
|
export class FusionRunStore {
|
|
97
99
|
private activeRun: FusionRun | undefined;
|
|
98
100
|
private lastRunSummary: FusionRunSummary | undefined;
|
|
101
|
+
private readonly runsById = new Map<string, FusionRun>();
|
|
102
|
+
private readonly runIdsByOperationId = new Map<string, string>();
|
|
99
103
|
private readonly now: () => number;
|
|
100
104
|
private readonly idFactory: () => string;
|
|
101
105
|
private readonly persistence: FusionRunStorePersistence | undefined;
|
|
@@ -116,22 +120,44 @@ export class FusionRunStore {
|
|
|
116
120
|
: undefined;
|
|
117
121
|
}
|
|
118
122
|
|
|
123
|
+
getRunById(id: string): FusionRun | undefined {
|
|
124
|
+
const run = this.runsById.get(id);
|
|
125
|
+
return run ? cloneRun(run) : undefined;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
getRunByOperationId(operationId: string): FusionRun | undefined {
|
|
129
|
+
const runId = this.runIdsByOperationId.get(operationId);
|
|
130
|
+
return runId ? this.getRunById(runId) : undefined;
|
|
131
|
+
}
|
|
132
|
+
|
|
119
133
|
startRun(input: FusionRunStartInput): FusionRun {
|
|
120
134
|
if (this.activeRun) {
|
|
121
135
|
throw new FusionRunStoreError(
|
|
122
136
|
`Fusion run ${this.activeRun.id} is already active.`,
|
|
123
137
|
);
|
|
124
138
|
}
|
|
139
|
+
if (
|
|
140
|
+
input.operationId !== undefined &&
|
|
141
|
+
this.runIdsByOperationId.has(input.operationId)
|
|
142
|
+
) {
|
|
143
|
+
throw new FusionRunStoreError(
|
|
144
|
+
`Fusion operation ${input.operationId} already has a run.`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
125
147
|
const createdAt = input.createdAt ?? this.now();
|
|
126
148
|
const run: FusionRun = {
|
|
127
149
|
id: input.id ?? this.idFactory(),
|
|
128
150
|
prompt: input.prompt,
|
|
129
151
|
profileName: input.profileName,
|
|
152
|
+
...(input.operationId !== undefined
|
|
153
|
+
? { operationId: input.operationId }
|
|
154
|
+
: {}),
|
|
130
155
|
phase: input.phase ?? "chain",
|
|
131
156
|
createdAt,
|
|
132
157
|
updatedAt: createdAt,
|
|
133
158
|
};
|
|
134
159
|
this.activeRun = run;
|
|
160
|
+
this.rememberRun(run);
|
|
135
161
|
this.persistRun(run);
|
|
136
162
|
return cloneRun(run);
|
|
137
163
|
}
|
|
@@ -140,6 +166,7 @@ export class FusionRunStore {
|
|
|
140
166
|
const active = this.requireActiveRun(id);
|
|
141
167
|
const updated = applyPatch(active, patch, patch.updatedAt ?? this.now());
|
|
142
168
|
this.activeRun = updated;
|
|
169
|
+
this.rememberRun(updated);
|
|
143
170
|
this.persistRun(updated);
|
|
144
171
|
return cloneRun(updated);
|
|
145
172
|
}
|
|
@@ -171,6 +198,7 @@ export class FusionRunStore {
|
|
|
171
198
|
const summary = toRunSummary(finished);
|
|
172
199
|
this.activeRun = undefined;
|
|
173
200
|
this.lastRunSummary = summary;
|
|
201
|
+
this.rememberRun(finished);
|
|
174
202
|
this.persistRun(summary);
|
|
175
203
|
return cloneRun(finished);
|
|
176
204
|
}
|
|
@@ -178,7 +206,12 @@ export class FusionRunStore {
|
|
|
178
206
|
restoreFromEntries(
|
|
179
207
|
entries: readonly unknown[],
|
|
180
208
|
): FusionRunSummary | undefined {
|
|
181
|
-
const
|
|
209
|
+
const states = readFusionRunStates(entries);
|
|
210
|
+
this.runsById.clear();
|
|
211
|
+
this.runIdsByOperationId.clear();
|
|
212
|
+
for (const state of states) this.rememberRun(state);
|
|
213
|
+
|
|
214
|
+
const latestState = states.at(-1);
|
|
182
215
|
const summary = readLastFusionRunSummary(entries);
|
|
183
216
|
this.activeRun =
|
|
184
217
|
latestState && !isTerminalPhase(latestState.phase)
|
|
@@ -208,6 +241,16 @@ export class FusionRunStore {
|
|
|
208
241
|
this.persistence?.appendEntry(FUSION_RUN_ENTRY_TYPE, cloneRun(run));
|
|
209
242
|
}
|
|
210
243
|
|
|
244
|
+
private rememberRun(run: FusionRun): void {
|
|
245
|
+
this.runsById.set(run.id, cloneRun(run));
|
|
246
|
+
if (
|
|
247
|
+
run.operationId !== undefined &&
|
|
248
|
+
!this.runIdsByOperationId.has(run.operationId)
|
|
249
|
+
) {
|
|
250
|
+
this.runIdsByOperationId.set(run.operationId, run.id);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
211
254
|
private requireActiveRun(id: string): FusionRun {
|
|
212
255
|
if (!this.activeRun) {
|
|
213
256
|
throw new FusionRunStoreError("No active fusion run.");
|
|
@@ -325,6 +368,7 @@ function toRunSummary(
|
|
|
325
368
|
id: run.id,
|
|
326
369
|
prompt: run.prompt,
|
|
327
370
|
profileName: run.profileName,
|
|
371
|
+
...(run.operationId !== undefined ? { operationId: run.operationId } : {}),
|
|
328
372
|
phase: run.phase,
|
|
329
373
|
createdAt: run.createdAt,
|
|
330
374
|
updatedAt: run.updatedAt,
|
|
@@ -341,6 +385,7 @@ function cloneRun(run: FusionRun): FusionRun {
|
|
|
341
385
|
id: run.id,
|
|
342
386
|
prompt: run.prompt,
|
|
343
387
|
profileName: run.profileName,
|
|
388
|
+
...(run.operationId !== undefined ? { operationId: run.operationId } : {}),
|
|
344
389
|
phase: run.phase,
|
|
345
390
|
createdAt: run.createdAt,
|
|
346
391
|
updatedAt: run.updatedAt,
|
|
@@ -396,6 +441,9 @@ function isFusionRunState(value: unknown): value is FusionRun {
|
|
|
396
441
|
if (!isNonEmptyString(value.id)) return false;
|
|
397
442
|
if (typeof value.prompt !== "string") return false;
|
|
398
443
|
if (!isNonEmptyString(value.profileName)) return false;
|
|
444
|
+
if (value.operationId !== undefined && !isNonEmptyString(value.operationId)) {
|
|
445
|
+
return false;
|
|
446
|
+
}
|
|
399
447
|
if (!isFusionPhase(value.phase)) return false;
|
|
400
448
|
if (!isFiniteNumber(value.createdAt)) return false;
|
|
401
449
|
if (!isFiniteNumber(value.updatedAt)) return false;
|
package/src/types.ts
CHANGED
|
@@ -78,6 +78,7 @@ export interface FusionConfig {
|
|
|
78
78
|
export interface ParsedFusionArgs {
|
|
79
79
|
prompt: string;
|
|
80
80
|
profile?: string;
|
|
81
|
+
operationId?: string;
|
|
81
82
|
}
|
|
82
83
|
|
|
83
84
|
export interface PanelOutput {
|
|
@@ -120,6 +121,7 @@ export interface FusionRun {
|
|
|
120
121
|
id: string;
|
|
121
122
|
prompt: string;
|
|
122
123
|
profileName: string;
|
|
124
|
+
operationId?: string;
|
|
123
125
|
phase: FusionPhase;
|
|
124
126
|
createdAt: number;
|
|
125
127
|
updatedAt: number;
|