iflow-mcp-deeflect-smart-spawn 0.1.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/.smart-spawn-mcp/db.sqlite +0 -0
- package/.smart-spawn-mcp/db.sqlite-shm +0 -0
- package/.smart-spawn-mcp/db.sqlite-wal +0 -0
- package/package.json +1 -0
- package/scripts/live-smoke.ts +121 -0
- package/src/config.ts +55 -0
- package/src/db.ts +448 -0
- package/src/index.ts +46 -0
- package/src/openrouter-client.ts +85 -0
- package/src/runtime/executor.ts +384 -0
- package/src/runtime/planner.ts +418 -0
- package/src/runtime/queue.ts +223 -0
- package/src/smart-spawn-client.ts +183 -0
- package/src/storage.ts +43 -0
- package/src/tools.ts +273 -0
- package/src/types.ts +107 -0
- package/tests/clients.test.ts +7 -0
- package/tests/db.test.ts +15 -0
- package/tests/guardrails.test.ts +7 -0
- package/tests/mcp.integration.test.ts +299 -0
- package/tests/node-timeout.test.ts +106 -0
- package/tests/runtime.test.ts +11 -0
- package/tests/tools.test.ts +13 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { RunCreateInput, PlannedRun, PlannedNode } from "../types.ts";
|
|
3
|
+
import { SmartSpawnClient } from "../smart-spawn-client.ts";
|
|
4
|
+
|
|
5
|
+
function makeNodeId(prefix: string): string {
|
|
6
|
+
return `${prefix}-${randomUUID().slice(0, 8)}`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function fallbackModel(): string {
|
|
10
|
+
return "openai/gpt-4o-mini";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function fallbackPremiumModel(): string {
|
|
14
|
+
return "anthropic/claude-sonnet-4";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function fallbackCollectiveModels(count: number): string[] {
|
|
18
|
+
const base = [
|
|
19
|
+
"openai/gpt-4o-mini",
|
|
20
|
+
"anthropic/claude-sonnet-4",
|
|
21
|
+
"google/gemini-2.5-pro",
|
|
22
|
+
"openai/gpt-4o",
|
|
23
|
+
"meta-llama/llama-3.3-70b-instruct",
|
|
24
|
+
];
|
|
25
|
+
return base.slice(0, Math.max(1, Math.min(count, base.length)));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function splitTaskFallback(task: string): string[] {
|
|
29
|
+
const numbered = task
|
|
30
|
+
.split(/\r?\n/)
|
|
31
|
+
.map((line) => line.trim())
|
|
32
|
+
.filter((line) => /^\d+[.)]\s+/.test(line))
|
|
33
|
+
.map((line) => line.replace(/^\d+[.)]\s+/, "").trim())
|
|
34
|
+
.filter(Boolean);
|
|
35
|
+
if (numbered.length > 0) return numbered;
|
|
36
|
+
|
|
37
|
+
const bullet = task
|
|
38
|
+
.split(/\r?\n/)
|
|
39
|
+
.map((line) => line.trim())
|
|
40
|
+
.filter((line) => /^[-*]\s+/.test(line))
|
|
41
|
+
.map((line) => line.replace(/^[-*]\s+/, "").trim())
|
|
42
|
+
.filter(Boolean);
|
|
43
|
+
if (bullet.length > 0) return bullet;
|
|
44
|
+
|
|
45
|
+
const byAnd = task
|
|
46
|
+
.split(/\s+(?:and then|then|and)\s+/i)
|
|
47
|
+
.map((part) => part.trim())
|
|
48
|
+
.filter((part) => part.length > 8);
|
|
49
|
+
if (byAnd.length > 1) return byAnd.slice(0, 6);
|
|
50
|
+
|
|
51
|
+
return [task.trim()];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function buildRunPlan(
|
|
55
|
+
input: RunCreateInput,
|
|
56
|
+
smartSpawn: SmartSpawnClient
|
|
57
|
+
): Promise<PlannedRun> {
|
|
58
|
+
switch (input.mode) {
|
|
59
|
+
case "single":
|
|
60
|
+
return buildSinglePlan(input, smartSpawn);
|
|
61
|
+
case "collective":
|
|
62
|
+
return buildCollectivePlan(input, smartSpawn);
|
|
63
|
+
case "cascade":
|
|
64
|
+
return buildCascadePlan(input, smartSpawn);
|
|
65
|
+
case "plan":
|
|
66
|
+
return buildSequentialPlan(input, smartSpawn);
|
|
67
|
+
case "swarm":
|
|
68
|
+
return buildSwarmPlan(input, smartSpawn);
|
|
69
|
+
default:
|
|
70
|
+
return buildSinglePlan(input, smartSpawn);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function buildSinglePlan(
|
|
75
|
+
input: RunCreateInput,
|
|
76
|
+
smartSpawn?: SmartSpawnClient
|
|
77
|
+
): Promise<PlannedRun> {
|
|
78
|
+
let planningSource: "api" | "fallback" = smartSpawn ? "api" : "fallback";
|
|
79
|
+
let picked = { modelId: fallbackModel(), reason: "Fallback single model" };
|
|
80
|
+
if (smartSpawn) {
|
|
81
|
+
try {
|
|
82
|
+
picked = await smartSpawn.pick({
|
|
83
|
+
task: input.task,
|
|
84
|
+
budget: input.budget,
|
|
85
|
+
context: input.context,
|
|
86
|
+
});
|
|
87
|
+
} catch {
|
|
88
|
+
planningSource = "fallback";
|
|
89
|
+
picked = {
|
|
90
|
+
modelId: fallbackModel(),
|
|
91
|
+
reason: "Fallback single model (Smart Spawn API unavailable)",
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let prompt = input.task;
|
|
97
|
+
if (smartSpawn) {
|
|
98
|
+
try {
|
|
99
|
+
prompt = await smartSpawn.composeRole(input.task, input.role);
|
|
100
|
+
} catch {
|
|
101
|
+
prompt = input.task;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const node: PlannedNode = {
|
|
106
|
+
id: makeNodeId("node"),
|
|
107
|
+
kind: "task",
|
|
108
|
+
wave: 0,
|
|
109
|
+
dependsOn: [],
|
|
110
|
+
task: input.task,
|
|
111
|
+
model: picked.modelId,
|
|
112
|
+
prompt,
|
|
113
|
+
meta: { reason: picked.reason, mode: "single", planningSource },
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
plannerSummary: `single plan with model ${picked.modelId}`,
|
|
118
|
+
nodes: [node],
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function buildCollectivePlan(
|
|
123
|
+
input: RunCreateInput,
|
|
124
|
+
smartSpawn: SmartSpawnClient
|
|
125
|
+
): Promise<PlannedRun> {
|
|
126
|
+
const count = Math.max(2, Math.min(input.collectiveCount ?? 3, 5));
|
|
127
|
+
let picks: Array<{ modelId: string; reason: string }> = [];
|
|
128
|
+
let planningSource: "api" | "fallback" = "api";
|
|
129
|
+
try {
|
|
130
|
+
picks = await smartSpawn.recommend({
|
|
131
|
+
task: input.task,
|
|
132
|
+
budget: input.budget,
|
|
133
|
+
count,
|
|
134
|
+
context: input.context,
|
|
135
|
+
});
|
|
136
|
+
} catch {
|
|
137
|
+
planningSource = "fallback";
|
|
138
|
+
picks = fallbackCollectiveModels(count).map((modelId) => ({
|
|
139
|
+
modelId,
|
|
140
|
+
reason: "Fallback recommendation (Smart Spawn API unavailable)",
|
|
141
|
+
}));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
let prompt = input.task;
|
|
145
|
+
try {
|
|
146
|
+
prompt = await smartSpawn.composeRole(input.task, input.role);
|
|
147
|
+
} catch {
|
|
148
|
+
prompt = input.task;
|
|
149
|
+
}
|
|
150
|
+
const taskNodes: PlannedNode[] = picks.map((p, idx) => ({
|
|
151
|
+
id: makeNodeId(`collective-${idx + 1}`),
|
|
152
|
+
kind: "task",
|
|
153
|
+
wave: 0,
|
|
154
|
+
dependsOn: [],
|
|
155
|
+
task: input.task,
|
|
156
|
+
model: p.modelId,
|
|
157
|
+
prompt,
|
|
158
|
+
meta: { reason: p.reason, mode: "collective", planningSource },
|
|
159
|
+
}));
|
|
160
|
+
|
|
161
|
+
if (taskNodes.length === 0) {
|
|
162
|
+
return buildSinglePlan(input, smartSpawn);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const mergeNode: PlannedNode = {
|
|
166
|
+
id: "merged",
|
|
167
|
+
kind: "merge",
|
|
168
|
+
wave: 1,
|
|
169
|
+
dependsOn: taskNodes.map((n) => n.id),
|
|
170
|
+
task: input.task,
|
|
171
|
+
model: input.merge?.model ?? taskNodes[0]?.model ?? fallbackModel(),
|
|
172
|
+
prompt: "",
|
|
173
|
+
meta: {
|
|
174
|
+
mode: "collective",
|
|
175
|
+
mergeStyle: input.merge?.style ?? "detailed",
|
|
176
|
+
planningSource,
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
plannerSummary: `collective plan with ${taskNodes.length} worker nodes`,
|
|
182
|
+
nodes: [...taskNodes, mergeNode],
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function buildCascadePlan(
|
|
187
|
+
input: RunCreateInput,
|
|
188
|
+
smartSpawn: SmartSpawnClient
|
|
189
|
+
): Promise<PlannedRun> {
|
|
190
|
+
let planningSource: "api" | "fallback" = "api";
|
|
191
|
+
let cheap: { modelId: string; reason: string };
|
|
192
|
+
let premium: { modelId: string; reason: string };
|
|
193
|
+
try {
|
|
194
|
+
cheap = await smartSpawn.pick({
|
|
195
|
+
task: input.task,
|
|
196
|
+
budget: "low",
|
|
197
|
+
context: input.context,
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
premium = await smartSpawn.pick({
|
|
201
|
+
task: input.task,
|
|
202
|
+
budget: input.budget === "high" ? "high" : "medium",
|
|
203
|
+
context: input.context,
|
|
204
|
+
exclude: [cheap.modelId],
|
|
205
|
+
});
|
|
206
|
+
} catch {
|
|
207
|
+
planningSource = "fallback";
|
|
208
|
+
cheap = {
|
|
209
|
+
modelId: fallbackModel(),
|
|
210
|
+
reason: "Fallback cheap model (Smart Spawn API unavailable)",
|
|
211
|
+
};
|
|
212
|
+
premium = {
|
|
213
|
+
modelId: fallbackPremiumModel(),
|
|
214
|
+
reason: "Fallback premium model (Smart Spawn API unavailable)",
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
let prompt = input.task;
|
|
219
|
+
try {
|
|
220
|
+
prompt = await smartSpawn.composeRole(input.task, input.role);
|
|
221
|
+
} catch {
|
|
222
|
+
prompt = input.task;
|
|
223
|
+
}
|
|
224
|
+
const cheapNode: PlannedNode = {
|
|
225
|
+
id: makeNodeId("cascade-cheap"),
|
|
226
|
+
kind: "task",
|
|
227
|
+
wave: 0,
|
|
228
|
+
dependsOn: [],
|
|
229
|
+
task: input.task,
|
|
230
|
+
model: cheap.modelId,
|
|
231
|
+
prompt,
|
|
232
|
+
meta: { mode: "cascade", tier: "cheap", reason: cheap.reason, planningSource },
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const premiumNode: PlannedNode = {
|
|
236
|
+
id: makeNodeId("cascade-premium"),
|
|
237
|
+
kind: "task",
|
|
238
|
+
wave: 1,
|
|
239
|
+
dependsOn: [cheapNode.id],
|
|
240
|
+
task: input.task,
|
|
241
|
+
model: premium.modelId,
|
|
242
|
+
prompt,
|
|
243
|
+
meta: { mode: "cascade", tier: "premium", reason: premium.reason, conditional: true, planningSource },
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const mergeNode: PlannedNode = {
|
|
247
|
+
id: "merged",
|
|
248
|
+
kind: "merge",
|
|
249
|
+
wave: 2,
|
|
250
|
+
dependsOn: [cheapNode.id, premiumNode.id],
|
|
251
|
+
task: input.task,
|
|
252
|
+
model: input.merge?.model ?? premium.modelId,
|
|
253
|
+
prompt: "",
|
|
254
|
+
meta: { mode: "cascade", mergeStyle: input.merge?.style ?? "decision", planningSource },
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
plannerSummary: "cascade plan with cheap and premium fallback",
|
|
259
|
+
nodes: [cheapNode, premiumNode, mergeNode],
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function buildSequentialPlan(
|
|
264
|
+
input: RunCreateInput,
|
|
265
|
+
smartSpawn: SmartSpawnClient
|
|
266
|
+
): Promise<PlannedRun> {
|
|
267
|
+
let planningSource: "api" | "fallback" = "api";
|
|
268
|
+
let steps: Array<{ id: string; task: string; modelId: string; wave: number; dependsOn: string[]; reason: string }> = [];
|
|
269
|
+
try {
|
|
270
|
+
const result = await smartSpawn.decompose({
|
|
271
|
+
task: input.task,
|
|
272
|
+
budget: input.budget,
|
|
273
|
+
context: input.context,
|
|
274
|
+
});
|
|
275
|
+
if (result.decomposed && result.steps.length > 0) {
|
|
276
|
+
steps = result.steps;
|
|
277
|
+
}
|
|
278
|
+
} catch {
|
|
279
|
+
// fall through to fallback steps
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (steps.length === 0) {
|
|
283
|
+
planningSource = "fallback";
|
|
284
|
+
const split = splitTaskFallback(input.task);
|
|
285
|
+
if (split.length <= 1) {
|
|
286
|
+
return buildSinglePlan(input, smartSpawn);
|
|
287
|
+
}
|
|
288
|
+
steps = split.map((task, idx) => ({
|
|
289
|
+
id: `step-${idx + 1}`,
|
|
290
|
+
task,
|
|
291
|
+
modelId: idx === split.length - 1 ? fallbackPremiumModel() : fallbackModel(),
|
|
292
|
+
wave: idx,
|
|
293
|
+
dependsOn: idx === 0 ? [] : [`step-${idx}`],
|
|
294
|
+
reason: "Fallback decomposition (Smart Spawn API unavailable)",
|
|
295
|
+
}));
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const nodes: PlannedNode[] = [];
|
|
299
|
+
for (const step of steps) {
|
|
300
|
+
let prompt = step.task;
|
|
301
|
+
try {
|
|
302
|
+
prompt = await smartSpawn.composeRole(step.task, input.role);
|
|
303
|
+
} catch {
|
|
304
|
+
prompt = step.task;
|
|
305
|
+
}
|
|
306
|
+
nodes.push({
|
|
307
|
+
id: step.id,
|
|
308
|
+
kind: "task",
|
|
309
|
+
wave: step.wave,
|
|
310
|
+
dependsOn: step.dependsOn,
|
|
311
|
+
task: step.task,
|
|
312
|
+
model: step.modelId,
|
|
313
|
+
prompt,
|
|
314
|
+
meta: { mode: "plan", reason: step.reason, planningSource },
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const mergeNode: PlannedNode = {
|
|
319
|
+
id: "merged",
|
|
320
|
+
kind: "merge",
|
|
321
|
+
wave: Math.max(...nodes.map((n) => n.wave)) + 1,
|
|
322
|
+
dependsOn: nodes.map((n) => n.id),
|
|
323
|
+
task: input.task,
|
|
324
|
+
model: input.merge?.model ?? nodes[nodes.length - 1]?.model ?? fallbackModel(),
|
|
325
|
+
prompt: "",
|
|
326
|
+
meta: { mode: "plan", mergeStyle: input.merge?.style ?? "detailed", planningSource },
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
return {
|
|
330
|
+
plannerSummary: `plan mode with ${nodes.length} sequential nodes`,
|
|
331
|
+
nodes: [...nodes, mergeNode],
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async function buildSwarmPlan(
|
|
336
|
+
input: RunCreateInput,
|
|
337
|
+
smartSpawn: SmartSpawnClient
|
|
338
|
+
): Promise<PlannedRun> {
|
|
339
|
+
let planningSource: "api" | "fallback" = "api";
|
|
340
|
+
let tasks: Array<{ id: string; task: string; modelId: string; wave: number; dependsOn: string[]; reason: string }> = [];
|
|
341
|
+
try {
|
|
342
|
+
const result = await smartSpawn.swarm({
|
|
343
|
+
task: input.task,
|
|
344
|
+
budget: input.budget,
|
|
345
|
+
context: input.context,
|
|
346
|
+
maxParallel: 5,
|
|
347
|
+
});
|
|
348
|
+
if (result.decomposed && result.tasks.length > 0) {
|
|
349
|
+
tasks = result.tasks;
|
|
350
|
+
}
|
|
351
|
+
} catch {
|
|
352
|
+
// fall through to fallback tasks
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (tasks.length === 0) {
|
|
356
|
+
planningSource = "fallback";
|
|
357
|
+
const split = splitTaskFallback(input.task);
|
|
358
|
+
if (split.length <= 1) {
|
|
359
|
+
return buildSinglePlan(input, smartSpawn);
|
|
360
|
+
}
|
|
361
|
+
if (split.length === 2) {
|
|
362
|
+
tasks = split.map((task, idx) => ({
|
|
363
|
+
id: `swarm-${idx + 1}`,
|
|
364
|
+
task,
|
|
365
|
+
modelId: fallbackModel(),
|
|
366
|
+
wave: idx,
|
|
367
|
+
dependsOn: idx === 0 ? [] : ["swarm-1"],
|
|
368
|
+
reason: "Fallback swarm decomposition (Smart Spawn API unavailable)",
|
|
369
|
+
}));
|
|
370
|
+
} else {
|
|
371
|
+
const lastIdx = split.length - 1;
|
|
372
|
+
tasks = split.map((task, idx) => ({
|
|
373
|
+
id: `swarm-${idx + 1}`,
|
|
374
|
+
task,
|
|
375
|
+
modelId: idx === lastIdx ? fallbackPremiumModel() : fallbackModel(),
|
|
376
|
+
wave: idx === lastIdx ? 1 : 0,
|
|
377
|
+
dependsOn: idx === lastIdx ? split.slice(0, lastIdx).map((_, i) => `swarm-${i + 1}`) : [],
|
|
378
|
+
reason: "Fallback swarm decomposition (Smart Spawn API unavailable)",
|
|
379
|
+
}));
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const nodes: PlannedNode[] = [];
|
|
384
|
+
for (const t of tasks) {
|
|
385
|
+
let prompt = t.task;
|
|
386
|
+
try {
|
|
387
|
+
prompt = await smartSpawn.composeRole(t.task, input.role);
|
|
388
|
+
} catch {
|
|
389
|
+
prompt = t.task;
|
|
390
|
+
}
|
|
391
|
+
nodes.push({
|
|
392
|
+
id: t.id,
|
|
393
|
+
kind: "task",
|
|
394
|
+
wave: t.wave,
|
|
395
|
+
dependsOn: t.dependsOn,
|
|
396
|
+
task: t.task,
|
|
397
|
+
model: t.modelId,
|
|
398
|
+
prompt,
|
|
399
|
+
meta: { mode: "swarm", reason: t.reason, planningSource },
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const mergeNode: PlannedNode = {
|
|
404
|
+
id: "merged",
|
|
405
|
+
kind: "merge",
|
|
406
|
+
wave: Math.max(...nodes.map((n) => n.wave)) + 1,
|
|
407
|
+
dependsOn: nodes.map((n) => n.id),
|
|
408
|
+
task: input.task,
|
|
409
|
+
model: input.merge?.model ?? nodes[0]?.model ?? fallbackModel(),
|
|
410
|
+
prompt: "",
|
|
411
|
+
meta: { mode: "swarm", mergeStyle: input.merge?.style ?? "detailed", planningSource },
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
return {
|
|
415
|
+
plannerSummary: `swarm mode with ${nodes.length} nodes`,
|
|
416
|
+
nodes: [...nodes, mergeNode],
|
|
417
|
+
};
|
|
418
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import type { McpConfig } from "../config.ts";
|
|
2
|
+
import { McpStore } from "../db.ts";
|
|
3
|
+
import { OpenRouterClient } from "../openrouter-client.ts";
|
|
4
|
+
import { SmartSpawnClient } from "../smart-spawn-client.ts";
|
|
5
|
+
import { ArtifactStorage } from "../storage.ts";
|
|
6
|
+
import type { RunCreateInput, RunProgress, RunRecord, RunStatus } from "../types.ts";
|
|
7
|
+
import { buildRunPlan } from "./planner.ts";
|
|
8
|
+
import { RunExecutor } from "./executor.ts";
|
|
9
|
+
|
|
10
|
+
function parseJson<T>(raw: string): T {
|
|
11
|
+
return JSON.parse(raw) as T;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function formatPercent(value: number): number {
|
|
15
|
+
return Math.max(0, Math.min(100, Number(value.toFixed(2))));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class RuntimeQueue {
|
|
19
|
+
private interval: Timer | null = null;
|
|
20
|
+
private processing = new Set<string>();
|
|
21
|
+
private readonly executor: RunExecutor;
|
|
22
|
+
|
|
23
|
+
constructor(
|
|
24
|
+
private readonly config: McpConfig,
|
|
25
|
+
private readonly store: McpStore,
|
|
26
|
+
private readonly storage: ArtifactStorage,
|
|
27
|
+
private readonly smartSpawn: SmartSpawnClient,
|
|
28
|
+
private readonly openRouter: OpenRouterClient
|
|
29
|
+
) {
|
|
30
|
+
this.executor = new RunExecutor(config, store, storage, openRouter);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async start(): Promise<void> {
|
|
34
|
+
await this.storage.ensure();
|
|
35
|
+
this.interval = setInterval(() => {
|
|
36
|
+
void this.tick();
|
|
37
|
+
}, this.config.pollIntervalMs);
|
|
38
|
+
await this.tick();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
stop(): void {
|
|
42
|
+
if (this.interval) clearInterval(this.interval);
|
|
43
|
+
this.interval = null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async createRun(input: RunCreateInput): Promise<RunRecord> {
|
|
47
|
+
const run = this.store.createRun(input);
|
|
48
|
+
this.store.addEvent(run.id, "info", "Run created");
|
|
49
|
+
await this.tick();
|
|
50
|
+
return run;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
getRun(runId: string): RunRecord | null {
|
|
54
|
+
return this.store.getRun(runId);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
listRuns(status?: RunStatus, limit?: number): RunRecord[] {
|
|
58
|
+
return this.store.listRuns(status, limit ?? 20);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
cancelRun(runId: string): RunRecord | null {
|
|
62
|
+
const run = this.store.getRun(runId);
|
|
63
|
+
if (!run) return null;
|
|
64
|
+
if (run.status === "completed" || run.status === "failed") return run;
|
|
65
|
+
this.store.updateRunStatus(runId, "canceled");
|
|
66
|
+
this.store.addEvent(runId, "warn", "Run canceled by user");
|
|
67
|
+
return this.store.getRun(runId);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
getProgress(runId: string): RunProgress {
|
|
71
|
+
const nodes = this.store.listNodes(runId);
|
|
72
|
+
const totalNodes = nodes.length;
|
|
73
|
+
const doneNodes = nodes.filter((n) => n.status === "completed" || n.status === "skipped").length;
|
|
74
|
+
const runningNodes = nodes.filter((n) => n.status === "running").length;
|
|
75
|
+
const failedNodes = nodes.filter((n) => n.status === "failed").length;
|
|
76
|
+
const percent = totalNodes > 0 ? formatPercent((doneNodes / totalNodes) * 100) : 0;
|
|
77
|
+
return { totalNodes, doneNodes, runningNodes, failedNodes, percent };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
getLastEvent(runId: string): string | null {
|
|
81
|
+
const events = this.store.listRecentEvents(runId, 1);
|
|
82
|
+
return events.length > 0 ? (events[0]?.message ?? null) : null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async getResult(runId: string, includeRaw = false): Promise<{
|
|
86
|
+
status: string;
|
|
87
|
+
mergedOutput: string | null;
|
|
88
|
+
summary: string;
|
|
89
|
+
artifacts: Array<{ nodeId: string; path: string; type: string; model: string; status: string }>;
|
|
90
|
+
cost: { promptTokens: number; completionTokens: number; usdEstimate: number };
|
|
91
|
+
rawOutputs?: Array<{ nodeId: string; output: string }>;
|
|
92
|
+
} | null> {
|
|
93
|
+
const run = this.store.getRun(runId);
|
|
94
|
+
if (!run) return null;
|
|
95
|
+
|
|
96
|
+
const nodes = this.store.listNodes(runId);
|
|
97
|
+
const artifacts = this.store.listArtifacts(runId);
|
|
98
|
+
const mergedArtifact = artifacts.find((a) => a.nodeId === "merged");
|
|
99
|
+
const mergedOutput = mergedArtifact ? await this.storage.readArtifact(mergedArtifact.path) : null;
|
|
100
|
+
const cost = this.store.getRunCost(runId);
|
|
101
|
+
|
|
102
|
+
const artifactRows = artifacts.map((a) => {
|
|
103
|
+
const node = nodes.find((n) => n.id === a.nodeId || (a.nodeId === "merged" && n.kind === "merge"));
|
|
104
|
+
return {
|
|
105
|
+
nodeId: a.nodeId,
|
|
106
|
+
path: a.path,
|
|
107
|
+
type: a.type,
|
|
108
|
+
model: node?.model ?? "",
|
|
109
|
+
status: node?.status ?? run.status,
|
|
110
|
+
};
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const rawOutputs = [];
|
|
114
|
+
if (includeRaw) {
|
|
115
|
+
for (const artifact of artifacts.filter((a) => a.type === "raw")) {
|
|
116
|
+
const content = await this.storage.readArtifact(artifact.path);
|
|
117
|
+
rawOutputs.push({ nodeId: artifact.nodeId, output: content.slice(0, 12000) });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
status: run.status,
|
|
123
|
+
mergedOutput,
|
|
124
|
+
summary: `${run.mode} run with ${nodes.length} nodes`,
|
|
125
|
+
artifacts: artifactRows,
|
|
126
|
+
cost,
|
|
127
|
+
...(includeRaw ? { rawOutputs } : {}),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async getArtifact(runId: string, nodeId: string): Promise<{
|
|
132
|
+
type: string;
|
|
133
|
+
content: string;
|
|
134
|
+
metadata: { bytes: number; sha256: string; createdAt: string; path: string };
|
|
135
|
+
} | null> {
|
|
136
|
+
const artifact = this.store.getArtifact(runId, nodeId);
|
|
137
|
+
if (!artifact) return null;
|
|
138
|
+
const content = await this.storage.readArtifact(artifact.path);
|
|
139
|
+
return {
|
|
140
|
+
type: artifact.type,
|
|
141
|
+
content,
|
|
142
|
+
metadata: {
|
|
143
|
+
bytes: artifact.bytes,
|
|
144
|
+
sha256: artifact.sha256,
|
|
145
|
+
createdAt: artifact.createdAt,
|
|
146
|
+
path: artifact.path,
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async tick(): Promise<void> {
|
|
152
|
+
if (this.processing.size >= this.config.maxParallelRuns) return;
|
|
153
|
+
const openSlots = this.config.maxParallelRuns - this.processing.size;
|
|
154
|
+
const active = this.store.listActiveRuns(openSlots * 2);
|
|
155
|
+
for (const run of active) {
|
|
156
|
+
if (this.processing.size >= this.config.maxParallelRuns) break;
|
|
157
|
+
if (this.processing.has(run.id)) continue;
|
|
158
|
+
this.processing.add(run.id);
|
|
159
|
+
void this.processRun(run).finally(() => {
|
|
160
|
+
this.processing.delete(run.id);
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private async processRun(run: RunRecord): Promise<void> {
|
|
166
|
+
const latest = this.store.getRun(run.id);
|
|
167
|
+
if (!latest || latest.status === "canceled" || latest.status === "completed" || latest.status === "failed") {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
let nodes = this.store.listNodes(run.id);
|
|
172
|
+
if (nodes.length === 0) {
|
|
173
|
+
const input = parseJson<RunCreateInput>(latest.paramsJson);
|
|
174
|
+
const plan = await buildRunPlan(input, this.smartSpawn);
|
|
175
|
+
this.store.createNodes(run.id, plan.nodes);
|
|
176
|
+
const planFile = await this.storage.writeArtifact(run.id, "plan", "plan", JSON.stringify(plan, null, 2), "json");
|
|
177
|
+
this.store.createArtifact({
|
|
178
|
+
runId: run.id,
|
|
179
|
+
nodeId: "plan",
|
|
180
|
+
type: "plan",
|
|
181
|
+
path: planFile.relativePath,
|
|
182
|
+
bytes: planFile.bytes,
|
|
183
|
+
sha256: planFile.sha256,
|
|
184
|
+
createdAt: new Date().toISOString(),
|
|
185
|
+
});
|
|
186
|
+
this.store.addEvent(run.id, "info", plan.plannerSummary);
|
|
187
|
+
nodes = this.store.listNodes(run.id);
|
|
188
|
+
if (nodes.length === 0) {
|
|
189
|
+
this.store.updateRunStatus(run.id, "failed", "Planner returned no nodes");
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
await this.executor.processRun(run);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async health(): Promise<{
|
|
198
|
+
openrouterConfigured: boolean;
|
|
199
|
+
smartSpawnApiReachable: boolean;
|
|
200
|
+
dbWritable: boolean;
|
|
201
|
+
artifactStorageWritable: boolean;
|
|
202
|
+
workerAlive: boolean;
|
|
203
|
+
}> {
|
|
204
|
+
const smart = await this.smartSpawn.health();
|
|
205
|
+
const dbWritable = this.store.pingWritable();
|
|
206
|
+
|
|
207
|
+
let artifactStorageWritable = false;
|
|
208
|
+
try {
|
|
209
|
+
await this.storage.ensure();
|
|
210
|
+
artifactStorageWritable = true;
|
|
211
|
+
} catch {
|
|
212
|
+
artifactStorageWritable = false;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
openrouterConfigured: Boolean(this.config.openRouterApiKey),
|
|
217
|
+
smartSpawnApiReachable: smart.reachable,
|
|
218
|
+
dbWritable,
|
|
219
|
+
artifactStorageWritable,
|
|
220
|
+
workerAlive: this.interval !== null,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
}
|