@alexeiled/pi-fusion 0.5.0 → 0.5.2
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 +83 -29
- package/src/run-store.ts +49 -1
- package/src/status.ts +31 -0
- 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
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
clearFusionUi,
|
|
22
22
|
extractFusionProgressCounts,
|
|
23
23
|
formatProgressCounts,
|
|
24
|
+
isTerminalFusionProgress,
|
|
24
25
|
publishFusionStatus,
|
|
25
26
|
type FusionProgressCounts,
|
|
26
27
|
type FusionUi,
|
|
@@ -165,6 +166,9 @@ export class FusionOrchestrator {
|
|
|
165
166
|
run = this.runStore.startRun({
|
|
166
167
|
prompt: args.prompt,
|
|
167
168
|
profileName: resolved.name,
|
|
169
|
+
...(args.operationId !== undefined
|
|
170
|
+
? { operationId: args.operationId }
|
|
171
|
+
: {}),
|
|
168
172
|
phase: "panel",
|
|
169
173
|
});
|
|
170
174
|
} catch (error: unknown) {
|
|
@@ -187,6 +191,8 @@ export class FusionOrchestrator {
|
|
|
187
191
|
const spawnResult = await this.rpc.spawn(
|
|
188
192
|
buildPanelSpawnParams(resolved.profile, args.prompt),
|
|
189
193
|
);
|
|
194
|
+
const spawnError = extractSubagentFailure(spawnResult);
|
|
195
|
+
if (spawnError) throw new FusionArgsError(spawnError);
|
|
190
196
|
const panelRunId = extractSubagentRunId(spawnResult);
|
|
191
197
|
if (!panelRunId) {
|
|
192
198
|
throw new FusionArgsError(
|
|
@@ -516,13 +522,17 @@ export class FusionOrchestrator {
|
|
|
516
522
|
return { status: "ignored" };
|
|
517
523
|
}
|
|
518
524
|
|
|
519
|
-
const
|
|
520
|
-
snapshot.resultPayload ?? snapshot.statusPayload ?? payload
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
525
|
+
const lifecyclePayload =
|
|
526
|
+
snapshot.resultPayload ?? snapshot.statusPayload ?? payload;
|
|
527
|
+
const lifecycleError = extractSubagentFailure(lifecyclePayload);
|
|
528
|
+
if (!hasLifecycleResults(lifecyclePayload) && lifecycleError) {
|
|
529
|
+
return this.failActiveRun(lifecycleError);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const extracted = extractPanelResults(lifecyclePayload, {
|
|
533
|
+
panel: profile.panel,
|
|
534
|
+
limit: profile.panel.length,
|
|
535
|
+
});
|
|
526
536
|
if (!extracted.ok) {
|
|
527
537
|
return this.failActiveRun(
|
|
528
538
|
`${extracted.error.message} (${extracted.error.path})`,
|
|
@@ -627,16 +637,20 @@ export class FusionOrchestrator {
|
|
|
627
637
|
return { status: "ignored" };
|
|
628
638
|
}
|
|
629
639
|
|
|
630
|
-
const
|
|
631
|
-
snapshot.resultPayload ?? snapshot.statusPayload ?? payload
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
+
const lifecyclePayload =
|
|
641
|
+
snapshot.resultPayload ?? snapshot.statusPayload ?? payload;
|
|
642
|
+
const lifecycleError = extractSubagentFailure(lifecyclePayload);
|
|
643
|
+
if (!hasLifecycleResults(lifecyclePayload) && lifecycleError) {
|
|
644
|
+
return this.failActiveRun(lifecycleError);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
const extracted = extractPanelResults(lifecyclePayload, {
|
|
648
|
+
panel: profile.panel,
|
|
649
|
+
limit: profile.panel.length,
|
|
650
|
+
...(active.panelStoppedIndices
|
|
651
|
+
? { stoppedPanelIndices: active.panelStoppedIndices }
|
|
652
|
+
: {}),
|
|
653
|
+
});
|
|
640
654
|
if (!extracted.ok) {
|
|
641
655
|
return this.failActiveRun(
|
|
642
656
|
`${extracted.error.message} (${extracted.error.path})`,
|
|
@@ -741,6 +755,8 @@ export class FusionOrchestrator {
|
|
|
741
755
|
|
|
742
756
|
try {
|
|
743
757
|
const spawnResult = await this.rpc.spawn(decision.params);
|
|
758
|
+
const spawnError = extractSubagentFailure(spawnResult);
|
|
759
|
+
if (spawnError) throw new FusionArgsError(spawnError);
|
|
744
760
|
const judgeRunId = extractSubagentRunId(spawnResult);
|
|
745
761
|
if (!judgeRunId) {
|
|
746
762
|
throw new FusionArgsError(decision.missingRunIdError);
|
|
@@ -788,9 +804,14 @@ export class FusionOrchestrator {
|
|
|
788
804
|
return { status: "ignored" };
|
|
789
805
|
}
|
|
790
806
|
|
|
791
|
-
const
|
|
792
|
-
snapshot.resultPayload ?? snapshot.statusPayload ?? payload
|
|
793
|
-
);
|
|
807
|
+
const lifecyclePayload =
|
|
808
|
+
snapshot.resultPayload ?? snapshot.statusPayload ?? payload;
|
|
809
|
+
const lifecycleError = extractSubagentFailure(lifecyclePayload);
|
|
810
|
+
if (!hasLifecycleResults(lifecyclePayload) && lifecycleError) {
|
|
811
|
+
return this.failActiveRun(lifecycleError);
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
const output = extractJudgeOutput(lifecyclePayload);
|
|
794
815
|
if (!output.ok) return this.failActiveRun(output.error);
|
|
795
816
|
|
|
796
817
|
const judgeModel = this.activeProfile
|
|
@@ -849,12 +870,18 @@ export class FusionOrchestrator {
|
|
|
849
870
|
);
|
|
850
871
|
}
|
|
851
872
|
|
|
873
|
+
const statusIsTerminal =
|
|
874
|
+
isTerminalSubagentState(extractSubagentState(statusPayload)) ||
|
|
875
|
+
isTerminalFusionProgress(statusPayload);
|
|
876
|
+
const eventIsTerminal =
|
|
877
|
+
isTerminalSubagentState(extractSubagentState(input.eventPayload)) ||
|
|
878
|
+
isTerminalFusionProgress(input.eventPayload);
|
|
852
879
|
const eventHasResults = hasResultsArray(input.eventPayload);
|
|
853
880
|
if (eventPayloadMatches && eventHasResults) {
|
|
854
881
|
return {
|
|
855
882
|
statusPayload,
|
|
856
883
|
resultPayload: input.eventPayload,
|
|
857
|
-
resultIsTerminal:
|
|
884
|
+
resultIsTerminal: eventIsTerminal || statusIsTerminal,
|
|
858
885
|
};
|
|
859
886
|
}
|
|
860
887
|
|
|
@@ -871,17 +898,14 @@ export class FusionOrchestrator {
|
|
|
871
898
|
}
|
|
872
899
|
|
|
873
900
|
if (hasResultsArray(statusPayload)) {
|
|
874
|
-
const resultIsTerminal =
|
|
875
|
-
eventPayloadMatches ||
|
|
876
|
-
isTerminalSubagentState(extractSubagentState(statusPayload));
|
|
877
901
|
return {
|
|
878
902
|
statusPayload,
|
|
879
903
|
resultPayload: statusPayload,
|
|
880
|
-
resultIsTerminal,
|
|
904
|
+
resultIsTerminal: statusIsTerminal,
|
|
881
905
|
};
|
|
882
906
|
}
|
|
883
907
|
|
|
884
|
-
if (
|
|
908
|
+
if (statusIsTerminal) {
|
|
885
909
|
return {
|
|
886
910
|
statusPayload,
|
|
887
911
|
resultPayload: statusPayload,
|
|
@@ -893,9 +917,7 @@ export class FusionOrchestrator {
|
|
|
893
917
|
return {
|
|
894
918
|
statusPayload,
|
|
895
919
|
resultPayload: input.eventPayload,
|
|
896
|
-
resultIsTerminal:
|
|
897
|
-
extractSubagentState(input.eventPayload),
|
|
898
|
-
),
|
|
920
|
+
resultIsTerminal: eventIsTerminal,
|
|
899
921
|
};
|
|
900
922
|
}
|
|
901
923
|
|
|
@@ -1525,6 +1547,38 @@ function isTerminalSubagentState(state: string | undefined): boolean {
|
|
|
1525
1547
|
);
|
|
1526
1548
|
}
|
|
1527
1549
|
|
|
1550
|
+
function hasLifecycleResults(payload: unknown): boolean {
|
|
1551
|
+
return hasResultsArray(payload) || findStepsArray(payload).length > 0;
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
function extractSubagentFailure(payload: unknown): string | undefined {
|
|
1555
|
+
if (!isRecord(payload)) return undefined;
|
|
1556
|
+
const direct = firstNonBlankString(payload.error, payload.errorMessage);
|
|
1557
|
+
if (direct) return direct;
|
|
1558
|
+
if (isRecord(payload.details)) {
|
|
1559
|
+
const detailsError = firstNonBlankString(
|
|
1560
|
+
payload.details.error,
|
|
1561
|
+
payload.details.errorMessage,
|
|
1562
|
+
);
|
|
1563
|
+
if (detailsError) return detailsError;
|
|
1564
|
+
}
|
|
1565
|
+
if (payload.isError === true && Array.isArray(payload.content)) {
|
|
1566
|
+
for (const item of payload.content) {
|
|
1567
|
+
const contentText = isRecord(item)
|
|
1568
|
+
? firstNonBlankString(item.text)
|
|
1569
|
+
: undefined;
|
|
1570
|
+
if (contentText) return contentText;
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
const text = firstNonBlankString(
|
|
1574
|
+
payload.text,
|
|
1575
|
+
isRecord(payload.details) ? payload.details.text : undefined,
|
|
1576
|
+
);
|
|
1577
|
+
if (text && /^error(?:\s|:)/i.test(text)) return text;
|
|
1578
|
+
if (isRecord(payload.data)) return extractSubagentFailure(payload.data);
|
|
1579
|
+
return undefined;
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1528
1582
|
function firstNonBlankStringFromPayload(payload: unknown): string | undefined {
|
|
1529
1583
|
if (!isRecord(payload)) return undefined;
|
|
1530
1584
|
const direct = firstNonBlankString(
|
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/status.ts
CHANGED
|
@@ -92,6 +92,20 @@ export function formatProgressCounts(progress: FusionProgressCounts): string {
|
|
|
92
92
|
return `${progress.completed}/${total} done, ${progress.running} running, ${progress.failed} failed`;
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
export function isTerminalFusionProgress(payload: unknown): boolean {
|
|
96
|
+
const progressPayload = findTerminalProgressPayload(payload);
|
|
97
|
+
const progress = progressPayload
|
|
98
|
+
? extractFusionProgressCounts(progressPayload)
|
|
99
|
+
: undefined;
|
|
100
|
+
return Boolean(
|
|
101
|
+
progress &&
|
|
102
|
+
progress.total !== undefined &&
|
|
103
|
+
progress.total > 0 &&
|
|
104
|
+
progress.pending === 0 &&
|
|
105
|
+
progress.running === 0,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
95
109
|
type ProgressStatus = "pending" | "running" | "completed" | "failed";
|
|
96
110
|
|
|
97
111
|
function findProgressContainer(
|
|
@@ -116,6 +130,23 @@ function findProgressContainer(
|
|
|
116
130
|
return undefined;
|
|
117
131
|
}
|
|
118
132
|
|
|
133
|
+
function findTerminalProgressPayload(payload: unknown): unknown {
|
|
134
|
+
if (!isRecord(payload)) return undefined;
|
|
135
|
+
if (Array.isArray(payload.progress)) return { progress: payload.progress };
|
|
136
|
+
if (Array.isArray(payload.steps)) return { steps: payload.steps };
|
|
137
|
+
if (isRecord(payload.details)) {
|
|
138
|
+
if (Array.isArray(payload.details.progress)) {
|
|
139
|
+
return { progress: payload.details.progress };
|
|
140
|
+
}
|
|
141
|
+
if (Array.isArray(payload.details.steps)) {
|
|
142
|
+
return { steps: payload.details.steps };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return isRecord(payload.data)
|
|
146
|
+
? findTerminalProgressPayload(payload.data)
|
|
147
|
+
: undefined;
|
|
148
|
+
}
|
|
149
|
+
|
|
119
150
|
function classifyProgressItem(value: unknown): ProgressStatus {
|
|
120
151
|
if (!isRecord(value)) return "failed";
|
|
121
152
|
if (value.success === true) return "completed";
|
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;
|