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,85 @@
|
|
|
1
|
+
import type { OpenRouterExecutionResult } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
export interface OpenRouterMessage {
|
|
4
|
+
role: "system" | "user" | "assistant";
|
|
5
|
+
content: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function buildOpenRouterHeaders(apiKey: string): Record<string, string> {
|
|
9
|
+
return {
|
|
10
|
+
"Content-Type": "application/json",
|
|
11
|
+
Authorization: `Bearer ${apiKey}`,
|
|
12
|
+
"HTTP-Referer": "https://github.com/deeflect/smart-spawn",
|
|
13
|
+
"X-Title": "smart-spawn-mcp",
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class OpenRouterClient {
|
|
18
|
+
constructor(
|
|
19
|
+
private readonly apiKey: string,
|
|
20
|
+
private readonly baseUrl = "https://openrouter.ai/api/v1"
|
|
21
|
+
) {}
|
|
22
|
+
|
|
23
|
+
async chatCompletion(input: {
|
|
24
|
+
model: string;
|
|
25
|
+
messages: OpenRouterMessage[];
|
|
26
|
+
maxTokens?: number;
|
|
27
|
+
temperature?: number;
|
|
28
|
+
signal?: AbortSignal;
|
|
29
|
+
}): Promise<OpenRouterExecutionResult> {
|
|
30
|
+
if (!this.apiKey) {
|
|
31
|
+
throw new Error("OPENROUTER_API_KEY is required to execute runs");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const model = input.model.replace(/^openrouter\//, "");
|
|
35
|
+
const res = await fetch(`${this.baseUrl}/chat/completions`, {
|
|
36
|
+
method: "POST",
|
|
37
|
+
headers: buildOpenRouterHeaders(this.apiKey),
|
|
38
|
+
signal: input.signal,
|
|
39
|
+
body: JSON.stringify({
|
|
40
|
+
model,
|
|
41
|
+
messages: input.messages,
|
|
42
|
+
max_tokens: input.maxTokens ?? 2000,
|
|
43
|
+
temperature: input.temperature ?? 0.2,
|
|
44
|
+
}),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const raw = await res.text();
|
|
48
|
+
const data = raw ? JSON.parse(raw) : {};
|
|
49
|
+
|
|
50
|
+
if (!res.ok) {
|
|
51
|
+
const msg = data?.error?.message ?? `OpenRouter error ${res.status}`;
|
|
52
|
+
throw new Error(String(msg));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const choice = data?.choices?.[0];
|
|
56
|
+
const content = this.flattenContent(choice?.message?.content);
|
|
57
|
+
const promptTokens = Number(data?.usage?.prompt_tokens ?? 0);
|
|
58
|
+
const completionTokens = Number(data?.usage?.completion_tokens ?? 0);
|
|
59
|
+
const totalTokens = Number(data?.usage?.total_tokens ?? promptTokens + completionTokens);
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
text: content,
|
|
63
|
+
promptTokens,
|
|
64
|
+
completionTokens,
|
|
65
|
+
totalTokens,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private flattenContent(content: unknown): string {
|
|
70
|
+
if (typeof content === "string") return content;
|
|
71
|
+
if (Array.isArray(content)) {
|
|
72
|
+
return content
|
|
73
|
+
.map((part) => {
|
|
74
|
+
if (typeof part === "string") return part;
|
|
75
|
+
if (part && typeof part === "object" && "text" in part) {
|
|
76
|
+
return String((part as any).text ?? "");
|
|
77
|
+
}
|
|
78
|
+
return "";
|
|
79
|
+
})
|
|
80
|
+
.join("\n")
|
|
81
|
+
.trim();
|
|
82
|
+
}
|
|
83
|
+
return "";
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
import type { McpConfig } from "../config.ts";
|
|
2
|
+
import { McpStore } from "../db.ts";
|
|
3
|
+
import { OpenRouterClient } from "../openrouter-client.ts";
|
|
4
|
+
import { ArtifactStorage } from "../storage.ts";
|
|
5
|
+
import type { NodeRecord, RunRecord } from "../types.ts";
|
|
6
|
+
|
|
7
|
+
function parseDependsOn(raw: string): string[] {
|
|
8
|
+
try {
|
|
9
|
+
const parsed = JSON.parse(raw);
|
|
10
|
+
return Array.isArray(parsed) ? parsed.map((x) => String(x)) : [];
|
|
11
|
+
} catch {
|
|
12
|
+
return [];
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function parseMeta(raw: string): Record<string, unknown> {
|
|
17
|
+
try {
|
|
18
|
+
const parsed = JSON.parse(raw);
|
|
19
|
+
return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {};
|
|
20
|
+
} catch {
|
|
21
|
+
return {};
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function truncate(text: string, max = 6000): string {
|
|
26
|
+
if (text.length <= max) return text;
|
|
27
|
+
return `${text.slice(0, max)}\n\n[truncated ${text.length - max} chars]`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function shouldRetry(errorMessage: string): boolean {
|
|
31
|
+
const lower = errorMessage.toLowerCase();
|
|
32
|
+
return lower.includes("429") || lower.includes("timeout") || lower.includes("temporarily") || lower.includes("5");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function calcCostUsd(model: string, promptTokens: number, completionTokens: number): number {
|
|
36
|
+
void model;
|
|
37
|
+
// Conservative default estimate when exact model pricing is unknown.
|
|
38
|
+
const promptPer1m = 1;
|
|
39
|
+
const completionPer1m = 3;
|
|
40
|
+
return (promptTokens / 1_000_000) * promptPer1m + (completionTokens / 1_000_000) * completionPer1m;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function elapsedSeconds(startedAt: string): number {
|
|
44
|
+
const startMs = new Date(startedAt).getTime();
|
|
45
|
+
return (Date.now() - startMs) / 1000;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function findNode(nodes: NodeRecord[], id: string): NodeRecord | undefined {
|
|
49
|
+
return nodes.find((n) => n.id === id);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function shouldStopForBudget(input: { spentUsd: number; maxUsd: number }): boolean {
|
|
53
|
+
return input.spentUsd > input.maxUsd;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export class RunExecutor {
|
|
57
|
+
constructor(
|
|
58
|
+
private readonly config: McpConfig,
|
|
59
|
+
private readonly store: McpStore,
|
|
60
|
+
private readonly storage: ArtifactStorage,
|
|
61
|
+
private readonly openRouter: OpenRouterClient
|
|
62
|
+
) {}
|
|
63
|
+
|
|
64
|
+
async processRun(run: RunRecord): Promise<void> {
|
|
65
|
+
const current = this.store.getRun(run.id);
|
|
66
|
+
if (!current || current.status === "canceled" || current.status === "completed" || current.status === "failed") {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
this.store.updateRunStatus(run.id, "running");
|
|
71
|
+
if (!run.startedAt) {
|
|
72
|
+
this.store.addEvent(run.id, "info", "Run started");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (this.isRunTimedOut(this.store.getRun(run.id)!)) {
|
|
76
|
+
this.failRun(run.id, "Run timed out before execution");
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const nodes = this.store.listNodes(run.id);
|
|
81
|
+
if (nodes.length === 0) {
|
|
82
|
+
this.failRun(run.id, "Run has no planned nodes");
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
while (true) {
|
|
87
|
+
const snapshot = this.store.getRun(run.id);
|
|
88
|
+
if (!snapshot) return;
|
|
89
|
+
if (snapshot.status === "canceled") {
|
|
90
|
+
this.store.addEvent(run.id, "warn", "Run canceled");
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (this.isRunTimedOut(snapshot)) {
|
|
95
|
+
this.failRun(run.id, "Run timed out");
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const nodeRows = this.store.listNodes(run.id);
|
|
100
|
+
const runningNodes = nodeRows.filter((n) => n.status === "running");
|
|
101
|
+
const terminalNodes = nodeRows.filter((n) => ["completed", "failed", "canceled", "skipped"].includes(n.status));
|
|
102
|
+
const failedNodes = nodeRows.filter((n) => n.status === "failed");
|
|
103
|
+
|
|
104
|
+
if (terminalNodes.length === nodeRows.length) {
|
|
105
|
+
if (failedNodes.length > 0) {
|
|
106
|
+
this.failRun(run.id, `${failedNodes.length} node(s) failed`);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
await this.ensureMergedArtifact(run.id);
|
|
110
|
+
this.store.updateRunStatus(run.id, "completed");
|
|
111
|
+
this.store.addEvent(run.id, "info", "Run completed");
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (runningNodes.length >= this.config.maxParallelNodesPerRun) {
|
|
116
|
+
await Bun.sleep(200);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const ready = nodeRows.filter((node) => {
|
|
121
|
+
if (node.status !== "queued") return false;
|
|
122
|
+
const deps = parseDependsOn(node.dependsOnJson);
|
|
123
|
+
return deps.every((depId) => {
|
|
124
|
+
const dep = findNode(nodeRows, depId);
|
|
125
|
+
return dep && (dep.status === "completed" || dep.status === "skipped");
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
if (ready.length === 0) {
|
|
130
|
+
await Bun.sleep(200);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const available = Math.max(1, this.config.maxParallelNodesPerRun - runningNodes.length);
|
|
135
|
+
const toRun = ready.slice(0, available);
|
|
136
|
+
await Promise.all(toRun.map((node) => this.executeNode(run.id, node)));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private async executeNode(runId: string, node: NodeRecord): Promise<void> {
|
|
141
|
+
if (node.kind === "merge") {
|
|
142
|
+
await this.executeMergeNode(runId, node);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (await this.shouldSkipCascadePremium(runId, node)) {
|
|
147
|
+
this.store.markNodeSkipped(node.id, "Cascade cheap output passed quality gate");
|
|
148
|
+
this.store.addEvent(runId, "info", `Skipped premium cascade node ${node.id}`, node.id);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
this.store.startNode(node.id);
|
|
153
|
+
this.store.addEvent(runId, "info", `Executing node ${node.id} on ${node.model}`, node.id);
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
const dependencyContext = await this.buildDependencyContext(runId, node);
|
|
157
|
+
const prompt = dependencyContext
|
|
158
|
+
? `${node.prompt}\n\n## Dependency context\n${dependencyContext}`
|
|
159
|
+
: node.prompt;
|
|
160
|
+
|
|
161
|
+
const result = await this.runWithNodeTimeout(node.id, (signal) =>
|
|
162
|
+
this.openRouter.chatCompletion({
|
|
163
|
+
model: node.model,
|
|
164
|
+
messages: [{ role: "user", content: prompt }],
|
|
165
|
+
signal,
|
|
166
|
+
})
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
const costUsd = calcCostUsd(node.model, result.promptTokens, result.completionTokens);
|
|
170
|
+
const artifactPayload = JSON.stringify(
|
|
171
|
+
{
|
|
172
|
+
runId,
|
|
173
|
+
nodeId: node.id,
|
|
174
|
+
model: node.model,
|
|
175
|
+
task: node.task,
|
|
176
|
+
output: result.text,
|
|
177
|
+
tokens: {
|
|
178
|
+
prompt: result.promptTokens,
|
|
179
|
+
completion: result.completionTokens,
|
|
180
|
+
total: result.totalTokens,
|
|
181
|
+
},
|
|
182
|
+
costUsd,
|
|
183
|
+
finishedAt: new Date().toISOString(),
|
|
184
|
+
},
|
|
185
|
+
null,
|
|
186
|
+
2
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
const file = await this.storage.writeArtifact(runId, node.id, "raw", artifactPayload, "json");
|
|
190
|
+
this.store.createArtifact({
|
|
191
|
+
runId,
|
|
192
|
+
nodeId: node.id,
|
|
193
|
+
type: "raw",
|
|
194
|
+
path: file.relativePath,
|
|
195
|
+
bytes: file.bytes,
|
|
196
|
+
sha256: file.sha256,
|
|
197
|
+
createdAt: new Date().toISOString(),
|
|
198
|
+
});
|
|
199
|
+
this.store.markNodeCompleted(node.id, result.promptTokens, result.completionTokens, costUsd);
|
|
200
|
+
|
|
201
|
+
const runCost = this.store.getRunCost(runId);
|
|
202
|
+
if (shouldStopForBudget({ spentUsd: runCost.usdEstimate, maxUsd: this.config.maxUsdPerRun })) {
|
|
203
|
+
this.store.updateRunStatus(runId, "canceled", "Budget limit reached");
|
|
204
|
+
this.store.addEvent(runId, "warn", "Run canceled: budget limit reached", node.id);
|
|
205
|
+
}
|
|
206
|
+
} catch (error) {
|
|
207
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
208
|
+
if (node.retryCount < node.maxRetries && shouldRetry(message)) {
|
|
209
|
+
this.store.incrementNodeRetry(node.id, message);
|
|
210
|
+
this.store.addEvent(runId, "warn", `Retrying node ${node.id}: ${message}`, node.id);
|
|
211
|
+
await Bun.sleep(300 * (node.retryCount + 1));
|
|
212
|
+
} else {
|
|
213
|
+
this.store.markNodeFailed(node.id, message);
|
|
214
|
+
this.store.addEvent(runId, "error", `Node failed: ${message}`, node.id);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private async executeMergeNode(runId: string, node: NodeRecord): Promise<void> {
|
|
220
|
+
this.store.startNode(node.id);
|
|
221
|
+
|
|
222
|
+
try {
|
|
223
|
+
const inputs = [];
|
|
224
|
+
for (const parentId of parseDependsOn(node.dependsOnJson)) {
|
|
225
|
+
const artifact = this.store.getArtifact(runId, parentId);
|
|
226
|
+
if (!artifact) continue;
|
|
227
|
+
const raw = await this.storage.readArtifact(artifact.path);
|
|
228
|
+
inputs.push({ nodeId: parentId, payload: raw });
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const meta = parseMeta(node.metaJson);
|
|
232
|
+
const style = String(meta.mergeStyle ?? "detailed");
|
|
233
|
+
const mergePrompt = [
|
|
234
|
+
`You are merging outputs from multiple sub-agents for task: ${node.task}`,
|
|
235
|
+
`Output style: ${style}.`,
|
|
236
|
+
"Produce one final answer, remove conflicts, and include the strongest concrete recommendations.",
|
|
237
|
+
"Inputs:",
|
|
238
|
+
...inputs.map((item, idx) => `### Input ${idx + 1} (${item.nodeId})\n${truncate(item.payload, 10000)}`),
|
|
239
|
+
].join("\n\n");
|
|
240
|
+
|
|
241
|
+
const result = await this.runWithNodeTimeout(node.id, (signal) =>
|
|
242
|
+
this.openRouter.chatCompletion({
|
|
243
|
+
model: node.model,
|
|
244
|
+
messages: [{ role: "user", content: mergePrompt }],
|
|
245
|
+
signal,
|
|
246
|
+
})
|
|
247
|
+
);
|
|
248
|
+
const costUsd = calcCostUsd(node.model, result.promptTokens, result.completionTokens);
|
|
249
|
+
|
|
250
|
+
const mergedContent = [
|
|
251
|
+
`# Merged Output`,
|
|
252
|
+
"",
|
|
253
|
+
result.text.trim(),
|
|
254
|
+
].join("\n");
|
|
255
|
+
|
|
256
|
+
const file = await this.storage.writeArtifact(runId, "merged", "merged", mergedContent, "md");
|
|
257
|
+
this.store.createArtifact({
|
|
258
|
+
runId,
|
|
259
|
+
nodeId: "merged",
|
|
260
|
+
type: "merged",
|
|
261
|
+
path: file.relativePath,
|
|
262
|
+
bytes: file.bytes,
|
|
263
|
+
sha256: file.sha256,
|
|
264
|
+
createdAt: new Date().toISOString(),
|
|
265
|
+
});
|
|
266
|
+
this.store.markNodeCompleted(node.id, result.promptTokens, result.completionTokens, costUsd);
|
|
267
|
+
} catch (error) {
|
|
268
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
269
|
+
this.store.markNodeFailed(node.id, message);
|
|
270
|
+
this.store.addEvent(runId, "error", `Merge node failed: ${message}`, node.id);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private async buildDependencyContext(runId: string, node: NodeRecord): Promise<string> {
|
|
275
|
+
const dependencyIds = parseDependsOn(node.dependsOnJson);
|
|
276
|
+
if (dependencyIds.length === 0) return "";
|
|
277
|
+
|
|
278
|
+
const chunks: string[] = [];
|
|
279
|
+
for (const depId of dependencyIds) {
|
|
280
|
+
const artifact = this.store.getArtifact(runId, depId);
|
|
281
|
+
if (!artifact) continue;
|
|
282
|
+
const raw = await this.storage.readArtifact(artifact.path);
|
|
283
|
+
chunks.push(`## ${depId}\n${truncate(raw, 6000)}`);
|
|
284
|
+
}
|
|
285
|
+
return chunks.join("\n\n");
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
private async shouldSkipCascadePremium(runId: string, node: NodeRecord): Promise<boolean> {
|
|
289
|
+
const meta = parseMeta(node.metaJson);
|
|
290
|
+
if (meta.mode !== "cascade" || meta.tier !== "premium" || meta.conditional !== true) return false;
|
|
291
|
+
|
|
292
|
+
const nodes = this.store.listNodes(runId);
|
|
293
|
+
const cheap = nodes.find((n) => {
|
|
294
|
+
const m = parseMeta(n.metaJson);
|
|
295
|
+
return m.mode === "cascade" && m.tier === "cheap";
|
|
296
|
+
});
|
|
297
|
+
if (!cheap || cheap.status !== "completed") return false;
|
|
298
|
+
|
|
299
|
+
const artifact = this.store.getArtifact(runId, cheap.id);
|
|
300
|
+
if (!artifact) return false;
|
|
301
|
+
|
|
302
|
+
try {
|
|
303
|
+
const raw = await this.storage.readArtifact(artifact.path);
|
|
304
|
+
const parsed = JSON.parse(raw);
|
|
305
|
+
const output = String(parsed?.output ?? "");
|
|
306
|
+
if (output.trim().length < 500) return false;
|
|
307
|
+
} catch {
|
|
308
|
+
return false;
|
|
309
|
+
}
|
|
310
|
+
return true;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
private isRunTimedOut(run: RunRecord): boolean {
|
|
314
|
+
if (!run.startedAt) return false;
|
|
315
|
+
return elapsedSeconds(run.startedAt) > this.config.runTimeoutSeconds;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
private async runWithNodeTimeout<T>(
|
|
319
|
+
nodeId: string,
|
|
320
|
+
execute: (signal: AbortSignal) => Promise<T>
|
|
321
|
+
): Promise<T> {
|
|
322
|
+
const timeoutMs = Math.max(1, Math.floor(this.config.nodeTimeoutSeconds * 1000));
|
|
323
|
+
const controller = new AbortController();
|
|
324
|
+
const timeoutError = new Error(`Node ${nodeId} timed out after ${this.config.nodeTimeoutSeconds}s`);
|
|
325
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
326
|
+
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
327
|
+
timer = setTimeout(() => {
|
|
328
|
+
controller.abort();
|
|
329
|
+
reject(timeoutError);
|
|
330
|
+
}, timeoutMs);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
try {
|
|
334
|
+
return await Promise.race([execute(controller.signal), timeoutPromise]);
|
|
335
|
+
} catch (error) {
|
|
336
|
+
if (error instanceof DOMException && error.name === "AbortError") {
|
|
337
|
+
throw timeoutError;
|
|
338
|
+
}
|
|
339
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
340
|
+
throw timeoutError;
|
|
341
|
+
}
|
|
342
|
+
throw error;
|
|
343
|
+
} finally {
|
|
344
|
+
if (timer) clearTimeout(timer);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
private failRun(runId: string, error: string): void {
|
|
349
|
+
this.store.updateRunStatus(runId, "failed", error);
|
|
350
|
+
this.store.addEvent(runId, "error", error);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
private async ensureMergedArtifact(runId: string): Promise<void> {
|
|
354
|
+
const existing = this.store.getArtifact(runId, "merged");
|
|
355
|
+
if (existing) return;
|
|
356
|
+
|
|
357
|
+
const artifacts = this.store.listArtifacts(runId).filter((a) => a.type === "raw");
|
|
358
|
+
if (artifacts.length === 0) return;
|
|
359
|
+
|
|
360
|
+
const latest = artifacts[artifacts.length - 1];
|
|
361
|
+
if (!latest) return;
|
|
362
|
+
const raw = await this.storage.readArtifact(latest.path);
|
|
363
|
+
|
|
364
|
+
let output = raw;
|
|
365
|
+
try {
|
|
366
|
+
const parsed = JSON.parse(raw);
|
|
367
|
+
output = String(parsed?.output ?? raw);
|
|
368
|
+
} catch {
|
|
369
|
+
output = raw;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const mergedContent = `# Merged Output\n\n${output.trim()}\n`;
|
|
373
|
+
const file = await this.storage.writeArtifact(runId, "merged", "merged", mergedContent, "md");
|
|
374
|
+
this.store.createArtifact({
|
|
375
|
+
runId,
|
|
376
|
+
nodeId: "merged",
|
|
377
|
+
type: "merged",
|
|
378
|
+
path: file.relativePath,
|
|
379
|
+
bytes: file.bytes,
|
|
380
|
+
sha256: file.sha256,
|
|
381
|
+
createdAt: new Date().toISOString(),
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
}
|