cueclaw 0.0.1 → 0.0.3
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/dist/app-UJ4M3TPW.js +448 -0
- package/dist/chunk-D77G7ABJ.js +1051 -0
- package/dist/chunk-E7BP6DMO.js +10 -0
- package/dist/chunk-GMHDL4CG.js +250 -0
- package/dist/chunk-JRHM3Z4C.js +158 -0
- package/dist/chunk-K4PGB2DU.js +140 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +225 -0
- package/dist/config-HMHM7UAZ.js +12 -0
- package/dist/daemon-TWVEMRCU.js +308 -0
- package/dist/router-36O66FDW.js +10 -0
- package/dist/service-BHFOM6E2.js +153 -0
- package/dist/setup-QZUEJUIN.js +154 -0
- package/dist/telegram-BTTWEETO.js +120 -0
- package/dist/whatsapp-36XWDSJ5.js +92 -0
- package/package.json +49 -11
- package/dist/index.d.ts +0 -0
- package/dist/index.js +0 -0
|
@@ -0,0 +1,1051 @@
|
|
|
1
|
+
import {
|
|
2
|
+
insertStepRun,
|
|
3
|
+
insertWorkflowRun,
|
|
4
|
+
updateStepRunStatus,
|
|
5
|
+
updateWorkflowPhase,
|
|
6
|
+
updateWorkflowRunStatus
|
|
7
|
+
} from "./chunk-K4PGB2DU.js";
|
|
8
|
+
import {
|
|
9
|
+
ConfigError,
|
|
10
|
+
ExecutorError,
|
|
11
|
+
PlannerError,
|
|
12
|
+
cueclawHome,
|
|
13
|
+
loadConfig
|
|
14
|
+
} from "./chunk-JRHM3Z4C.js";
|
|
15
|
+
import {
|
|
16
|
+
logger
|
|
17
|
+
} from "./chunk-E7BP6DMO.js";
|
|
18
|
+
|
|
19
|
+
// src/planner.ts
|
|
20
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
21
|
+
import { z } from "zod/v4";
|
|
22
|
+
import { nanoid } from "nanoid";
|
|
23
|
+
|
|
24
|
+
// src/workflow.ts
|
|
25
|
+
function validateDAG(steps) {
|
|
26
|
+
const errors = [];
|
|
27
|
+
const stepIds = new Set(steps.map((s) => s.id));
|
|
28
|
+
for (const step of steps) {
|
|
29
|
+
for (const dep of step.depends_on) {
|
|
30
|
+
if (!stepIds.has(dep)) {
|
|
31
|
+
errors.push(`Step "${step.id}" depends on unknown step "${dep}"`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
for (const step of steps) {
|
|
36
|
+
const refs = extractStepRefs(step.inputs);
|
|
37
|
+
for (const ref of refs) {
|
|
38
|
+
if (!stepIds.has(ref)) {
|
|
39
|
+
errors.push(`Step "${step.id}" references unknown step "${ref}" in inputs`);
|
|
40
|
+
}
|
|
41
|
+
if (stepIds.has(ref) && !step.depends_on.includes(ref)) {
|
|
42
|
+
errors.push(`Step "${step.id}" references "$steps.${ref}.output" but does not list "${ref}" in depends_on`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (hasCycle(steps)) {
|
|
47
|
+
errors.push("DAG contains a cycle");
|
|
48
|
+
}
|
|
49
|
+
return errors;
|
|
50
|
+
}
|
|
51
|
+
function extractStepRefs(obj) {
|
|
52
|
+
const refs = [];
|
|
53
|
+
const pattern = /\$steps\.([a-z0-9-]+)\.output/g;
|
|
54
|
+
function walk(value) {
|
|
55
|
+
if (typeof value === "string") {
|
|
56
|
+
let match;
|
|
57
|
+
while ((match = pattern.exec(value)) !== null) {
|
|
58
|
+
refs.push(match[1]);
|
|
59
|
+
}
|
|
60
|
+
pattern.lastIndex = 0;
|
|
61
|
+
} else if (Array.isArray(value)) {
|
|
62
|
+
for (const item of value) walk(item);
|
|
63
|
+
} else if (value !== null && typeof value === "object") {
|
|
64
|
+
for (const v of Object.values(value)) walk(v);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
walk(obj);
|
|
68
|
+
return refs;
|
|
69
|
+
}
|
|
70
|
+
function hasCycle(steps) {
|
|
71
|
+
const inDegree = /* @__PURE__ */ new Map();
|
|
72
|
+
const adjacency = /* @__PURE__ */ new Map();
|
|
73
|
+
for (const step of steps) {
|
|
74
|
+
inDegree.set(step.id, 0);
|
|
75
|
+
adjacency.set(step.id, []);
|
|
76
|
+
}
|
|
77
|
+
for (const step of steps) {
|
|
78
|
+
for (const dep of step.depends_on) {
|
|
79
|
+
if (adjacency.has(dep)) {
|
|
80
|
+
adjacency.get(dep).push(step.id);
|
|
81
|
+
inDegree.set(step.id, (inDegree.get(step.id) ?? 0) + 1);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const queue = [];
|
|
86
|
+
for (const [id, deg] of inDegree) {
|
|
87
|
+
if (deg === 0) queue.push(id);
|
|
88
|
+
}
|
|
89
|
+
let processed = 0;
|
|
90
|
+
while (queue.length > 0) {
|
|
91
|
+
const current = queue.shift();
|
|
92
|
+
processed++;
|
|
93
|
+
for (const neighbor of adjacency.get(current) ?? []) {
|
|
94
|
+
const newDeg = (inDegree.get(neighbor) ?? 1) - 1;
|
|
95
|
+
inDegree.set(neighbor, newDeg);
|
|
96
|
+
if (newDeg === 0) queue.push(neighbor);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return processed !== steps.length;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// src/planner.ts
|
|
103
|
+
var PlanStepSchema = z.object({
|
|
104
|
+
id: z.string(),
|
|
105
|
+
description: z.string(),
|
|
106
|
+
expected_output: z.string().optional(),
|
|
107
|
+
agent: z.literal("claude"),
|
|
108
|
+
inputs: z.record(z.string(), z.any()),
|
|
109
|
+
depends_on: z.array(z.string())
|
|
110
|
+
});
|
|
111
|
+
var TriggerConfigSchema = z.union([
|
|
112
|
+
z.object({
|
|
113
|
+
type: z.literal("poll"),
|
|
114
|
+
interval_seconds: z.number(),
|
|
115
|
+
check_script: z.string(),
|
|
116
|
+
diff_mode: z.enum(["new_items", "any_change"])
|
|
117
|
+
}),
|
|
118
|
+
z.object({
|
|
119
|
+
type: z.literal("cron"),
|
|
120
|
+
expression: z.string(),
|
|
121
|
+
timezone: z.string().optional()
|
|
122
|
+
}),
|
|
123
|
+
z.object({ type: z.literal("manual") })
|
|
124
|
+
]);
|
|
125
|
+
var FailurePolicySchema = z.object({
|
|
126
|
+
on_step_failure: z.enum(["stop", "skip_dependents", "ask_user"]),
|
|
127
|
+
max_retries: z.number(),
|
|
128
|
+
retry_delay_ms: z.number()
|
|
129
|
+
});
|
|
130
|
+
var PlannerOutputSchema = z.object({
|
|
131
|
+
name: z.string(),
|
|
132
|
+
description: z.string(),
|
|
133
|
+
trigger: TriggerConfigSchema,
|
|
134
|
+
steps: z.array(PlanStepSchema).min(1),
|
|
135
|
+
failure_policy: FailurePolicySchema
|
|
136
|
+
});
|
|
137
|
+
var plannerOutputJsonSchema = {
|
|
138
|
+
type: "object",
|
|
139
|
+
required: ["name", "description", "trigger", "steps", "failure_policy"],
|
|
140
|
+
properties: {
|
|
141
|
+
name: { type: "string", description: "Short workflow name" },
|
|
142
|
+
description: { type: "string", description: "User's original natural language description" },
|
|
143
|
+
trigger: {
|
|
144
|
+
type: "object",
|
|
145
|
+
description: "Trigger configuration",
|
|
146
|
+
properties: {
|
|
147
|
+
type: { type: "string", enum: ["poll", "cron", "manual"] },
|
|
148
|
+
interval_seconds: { type: "number" },
|
|
149
|
+
check_script: { type: "string" },
|
|
150
|
+
diff_mode: { type: "string", enum: ["new_items", "any_change"] },
|
|
151
|
+
expression: { type: "string" },
|
|
152
|
+
timezone: { type: "string" }
|
|
153
|
+
},
|
|
154
|
+
required: ["type"]
|
|
155
|
+
},
|
|
156
|
+
steps: {
|
|
157
|
+
type: "array",
|
|
158
|
+
items: {
|
|
159
|
+
type: "object",
|
|
160
|
+
required: ["id", "description", "agent", "inputs", "depends_on"],
|
|
161
|
+
properties: {
|
|
162
|
+
id: { type: "string", description: "kebab-case step ID" },
|
|
163
|
+
description: { type: "string", description: "Detailed step description for the agent" },
|
|
164
|
+
expected_output: { type: "string" },
|
|
165
|
+
agent: { type: "string", enum: ["claude"] },
|
|
166
|
+
inputs: { type: "object", description: "Step inputs, may use $steps.{id}.output or $trigger_data" },
|
|
167
|
+
depends_on: { type: "array", items: { type: "string" }, description: "Step IDs this step depends on" }
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
minItems: 1
|
|
171
|
+
},
|
|
172
|
+
failure_policy: {
|
|
173
|
+
type: "object",
|
|
174
|
+
required: ["on_step_failure", "max_retries", "retry_delay_ms"],
|
|
175
|
+
properties: {
|
|
176
|
+
on_step_failure: { type: "string", enum: ["stop", "skip_dependents", "ask_user"] },
|
|
177
|
+
max_retries: { type: "number" },
|
|
178
|
+
retry_delay_ms: { type: "number" }
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
var plannerTool = {
|
|
184
|
+
name: "create_workflow",
|
|
185
|
+
description: "Create a workflow definition from the user description. Generate steps as a DAG with proper dependencies.",
|
|
186
|
+
input_schema: plannerOutputJsonSchema
|
|
187
|
+
};
|
|
188
|
+
function buildPlannerSystemPrompt(config) {
|
|
189
|
+
const identity = config.identity?.name ? `
|
|
190
|
+
User identity: ${config.identity.name}` : "";
|
|
191
|
+
return `You are CueClaw Planner. Convert user's natural language into a structured Workflow.
|
|
192
|
+
|
|
193
|
+
## Execution Environment
|
|
194
|
+
|
|
195
|
+
All steps are executed by the Claude Agent SDK. The agent has Bash and can use
|
|
196
|
+
any CLI tool installed locally. It auto-loads .claude/skills/.
|
|
197
|
+
You don't need to verify tool availability \u2014 the agent detects at runtime.
|
|
198
|
+
|
|
199
|
+
## Rules
|
|
200
|
+
|
|
201
|
+
1. Every step's agent field is "claude" (Claude Agent SDK execution)
|
|
202
|
+
2. Step description uses natural language \u2014 the agent decides which tools to use
|
|
203
|
+
3. depends_on must reference defined step IDs, forming a valid DAG (no cycles)
|
|
204
|
+
4. Step IDs use kebab-case: "fetch-issues", "create-branch"
|
|
205
|
+
5. Use $steps.{id}.output in inputs to reference prior step results
|
|
206
|
+
6. Use $trigger_data in inputs to reference the trigger's output data
|
|
207
|
+
7. Do NOT generate position, phase, schema_version, status, or timestamp fields (framework auto-fills). You MUST generate step id fields.
|
|
208
|
+
8. Trigger check_script can use any shell commands
|
|
209
|
+
9. Step description should be detailed enough for the agent to execute independently \u2014 not just a one-line summary
|
|
210
|
+
10. Input keys use snake_case (e.g., issue_number, repo_path)
|
|
211
|
+
11. Steps must NOT include a status field \u2014 the framework sets all steps to pending automatically
|
|
212
|
+
12. Step output is a plain text string \u2014 downstream agents parse structure themselves
|
|
213
|
+
|
|
214
|
+
## User Identity
|
|
215
|
+
${identity}`;
|
|
216
|
+
}
|
|
217
|
+
async function generatePlan(userDescription, config) {
|
|
218
|
+
const anthropic = new Anthropic({ apiKey: config.claude.api_key });
|
|
219
|
+
const MAX_RETRIES = 2;
|
|
220
|
+
let retryContext = "";
|
|
221
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
222
|
+
const prompt = retryContext ? `${userDescription}
|
|
223
|
+
|
|
224
|
+
${retryContext}` : userDescription;
|
|
225
|
+
logger.debug({ attempt, hasRetryContext: !!retryContext }, "Planner attempt");
|
|
226
|
+
const response = await anthropic.messages.create({
|
|
227
|
+
model: config.claude.planner.model,
|
|
228
|
+
max_tokens: 4096,
|
|
229
|
+
system: buildPlannerSystemPrompt(config),
|
|
230
|
+
messages: [{ role: "user", content: prompt }],
|
|
231
|
+
tools: [plannerTool],
|
|
232
|
+
tool_choice: { type: "tool", name: "create_workflow" }
|
|
233
|
+
});
|
|
234
|
+
const toolBlock = response.content.find((b) => b.type === "tool_use");
|
|
235
|
+
if (!toolBlock || toolBlock.type !== "tool_use") {
|
|
236
|
+
throw new PlannerError("Unexpected planner response format: no tool_use block");
|
|
237
|
+
}
|
|
238
|
+
const parseResult = PlannerOutputSchema.safeParse(toolBlock.input);
|
|
239
|
+
if (!parseResult.success) {
|
|
240
|
+
const errMsg = parseResult.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ");
|
|
241
|
+
if (attempt < MAX_RETRIES) {
|
|
242
|
+
retryContext = `[System] Previous plan had validation issues:
|
|
243
|
+
${errMsg}
|
|
244
|
+
Please fix and try again.`;
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
throw new PlannerError(`Invalid plan after ${MAX_RETRIES + 1} attempts: ${errMsg}`);
|
|
248
|
+
}
|
|
249
|
+
const dagErrors = validateDAG(parseResult.data.steps);
|
|
250
|
+
if (dagErrors.length > 0) {
|
|
251
|
+
if (attempt < MAX_RETRIES) {
|
|
252
|
+
retryContext = `[System] DAG dependency issues:
|
|
253
|
+
${dagErrors.join("\n")}
|
|
254
|
+
Please fix the step dependencies.`;
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
throw new PlannerError(`DAG validation failed after ${MAX_RETRIES + 1} attempts: ${dagErrors.join(", ")}`);
|
|
258
|
+
}
|
|
259
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
260
|
+
return {
|
|
261
|
+
...parseResult.data,
|
|
262
|
+
schema_version: "1.0",
|
|
263
|
+
id: `wf_${nanoid()}`,
|
|
264
|
+
phase: "awaiting_confirmation",
|
|
265
|
+
created_at: now,
|
|
266
|
+
updated_at: now
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
throw new PlannerError("Failed to generate valid plan after retries");
|
|
270
|
+
}
|
|
271
|
+
async function modifyPlan(originalWorkflow, modificationDescription, config) {
|
|
272
|
+
const plannerOutput = {
|
|
273
|
+
name: originalWorkflow.name,
|
|
274
|
+
description: originalWorkflow.description,
|
|
275
|
+
trigger: originalWorkflow.trigger,
|
|
276
|
+
steps: originalWorkflow.steps,
|
|
277
|
+
failure_policy: originalWorkflow.failure_policy
|
|
278
|
+
};
|
|
279
|
+
const combinedPrompt = `Here is the current workflow plan:
|
|
280
|
+
\`\`\`json
|
|
281
|
+
${JSON.stringify(plannerOutput, null, 2)}
|
|
282
|
+
\`\`\`
|
|
283
|
+
|
|
284
|
+
The user wants to modify it as follows:
|
|
285
|
+
${modificationDescription}
|
|
286
|
+
|
|
287
|
+
Preserve unmodified steps' IDs, descriptions, and dependencies \u2014 only change what the user specified.
|
|
288
|
+
Return the complete modified workflow using the create_workflow tool.`;
|
|
289
|
+
const result = await generatePlan(combinedPrompt, config);
|
|
290
|
+
return {
|
|
291
|
+
...result,
|
|
292
|
+
id: originalWorkflow.id,
|
|
293
|
+
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
function confirmPlan(workflow) {
|
|
297
|
+
if (workflow.phase !== "awaiting_confirmation") {
|
|
298
|
+
throw new PlannerError(`Cannot confirm workflow in phase "${workflow.phase}"`);
|
|
299
|
+
}
|
|
300
|
+
const nextPhase = workflow.trigger.type === "manual" ? "executing" : "active";
|
|
301
|
+
return {
|
|
302
|
+
...workflow,
|
|
303
|
+
phase: nextPhase,
|
|
304
|
+
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
function rejectPlan(workflow) {
|
|
308
|
+
return {
|
|
309
|
+
...workflow,
|
|
310
|
+
phase: "planning",
|
|
311
|
+
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// src/executor.ts
|
|
316
|
+
import { nanoid as nanoid4 } from "nanoid";
|
|
317
|
+
|
|
318
|
+
// src/hooks.ts
|
|
319
|
+
function checkBashSafety(command) {
|
|
320
|
+
const dangerousPatterns = [
|
|
321
|
+
[/rm\s+-rf\s+\//, "recursive delete from root"],
|
|
322
|
+
[/\bsudo\b/, "privilege escalation"],
|
|
323
|
+
[/chmod\s+777/, "world-writable permissions"],
|
|
324
|
+
[/>\s*\/etc\//, "write to system config"],
|
|
325
|
+
[/\bmkfs\b/, "format filesystem"],
|
|
326
|
+
[/curl\s.*\|\s*(ba)?sh/, "pipe-to-shell execution"],
|
|
327
|
+
[/wget\s.*\|\s*(ba)?sh/, "pipe-to-shell execution"],
|
|
328
|
+
[/\bdd\b.*\bif=\/dev\//, "raw disk operations"],
|
|
329
|
+
[/>\s*\/dev\//, "write to device files"],
|
|
330
|
+
[/\bnc\b.*-[lp]/, "netcat listener"],
|
|
331
|
+
[/\bncat\b/, "ncat listener"]
|
|
332
|
+
];
|
|
333
|
+
for (const [pattern, reason] of dangerousPatterns) {
|
|
334
|
+
if (pattern.test(command)) {
|
|
335
|
+
return { allowed: false, reason: `Dangerous command blocked: ${reason}` };
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return { allowed: true };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// src/container-runner.ts
|
|
342
|
+
import { spawn } from "child_process";
|
|
343
|
+
import { mkdirSync as mkdirSync2 } from "fs";
|
|
344
|
+
import { join as join3 } from "path";
|
|
345
|
+
|
|
346
|
+
// src/mount-security.ts
|
|
347
|
+
import { readFileSync, writeFileSync, existsSync } from "fs";
|
|
348
|
+
import { join } from "path";
|
|
349
|
+
import { homedir } from "os";
|
|
350
|
+
function expandHome(path) {
|
|
351
|
+
if (path.startsWith("~/")) return join(homedir(), path.slice(2));
|
|
352
|
+
if (path === "~") return homedir();
|
|
353
|
+
return path;
|
|
354
|
+
}
|
|
355
|
+
function loadMountAllowlist() {
|
|
356
|
+
const path = join(cueclawHome(), "mount-allowlist.json");
|
|
357
|
+
if (!existsSync(path)) {
|
|
358
|
+
const defaults = generateDefaultAllowlist();
|
|
359
|
+
writeFileSync(path, JSON.stringify(defaults, null, 2));
|
|
360
|
+
return defaults;
|
|
361
|
+
}
|
|
362
|
+
return JSON.parse(readFileSync(path, "utf-8"));
|
|
363
|
+
}
|
|
364
|
+
function validateAdditionalMounts(mounts, allowlist) {
|
|
365
|
+
for (const mount of mounts) {
|
|
366
|
+
const expanded = expandHome(mount.hostPath);
|
|
367
|
+
for (const pattern of allowlist.blockedPatterns) {
|
|
368
|
+
if (expanded.includes(pattern)) {
|
|
369
|
+
throw new ConfigError(`Mount blocked: "${mount.hostPath}" matches blocked pattern "${pattern}"`);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
const allowed = allowlist.allowedRoots.find(
|
|
373
|
+
(root) => expanded.startsWith(expandHome(root.path))
|
|
374
|
+
);
|
|
375
|
+
if (!allowed) {
|
|
376
|
+
throw new ConfigError(`Mount not in allowlist: "${mount.hostPath}". Add it to ~/.cueclaw/mount-allowlist.json`);
|
|
377
|
+
}
|
|
378
|
+
if (mount.readonly === false && !allowed.allowReadWrite) {
|
|
379
|
+
throw new ConfigError(`Mount "${mount.hostPath}" is read-only in allowlist but requested read-write`);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
function generateDefaultAllowlist() {
|
|
384
|
+
return {
|
|
385
|
+
allowedRoots: [
|
|
386
|
+
{ path: "~/projects", allowReadWrite: true, description: "User project directories" },
|
|
387
|
+
{ path: "/tmp", allowReadWrite: true, description: "Temporary files" }
|
|
388
|
+
],
|
|
389
|
+
blockedPatterns: [".ssh", ".gnupg", ".aws", ".env", "credentials", "private_key", ".docker"],
|
|
390
|
+
nonMainReadOnly: true
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// src/ipc.ts
|
|
395
|
+
import {
|
|
396
|
+
existsSync as existsSync2,
|
|
397
|
+
readdirSync,
|
|
398
|
+
readFileSync as readFileSync2,
|
|
399
|
+
writeFileSync as writeFileSync2,
|
|
400
|
+
unlinkSync,
|
|
401
|
+
renameSync,
|
|
402
|
+
mkdirSync
|
|
403
|
+
} from "fs";
|
|
404
|
+
import { join as join2 } from "path";
|
|
405
|
+
import { nanoid as nanoid2 } from "nanoid";
|
|
406
|
+
var IpcWatcher = class {
|
|
407
|
+
constructor(workflowId, _stepId, ipcDir, onMessage, pollInterval = 500) {
|
|
408
|
+
this.workflowId = workflowId;
|
|
409
|
+
this.ipcDir = ipcDir;
|
|
410
|
+
this.onMessage = onMessage;
|
|
411
|
+
this.pollInterval = pollInterval;
|
|
412
|
+
}
|
|
413
|
+
timeout = null;
|
|
414
|
+
start() {
|
|
415
|
+
this.scheduleNext();
|
|
416
|
+
}
|
|
417
|
+
scheduleNext() {
|
|
418
|
+
this.timeout = setTimeout(async () => {
|
|
419
|
+
await this.poll();
|
|
420
|
+
if (this.timeout !== null) this.scheduleNext();
|
|
421
|
+
}, this.pollInterval);
|
|
422
|
+
}
|
|
423
|
+
stop() {
|
|
424
|
+
if (this.timeout) {
|
|
425
|
+
clearTimeout(this.timeout);
|
|
426
|
+
this.timeout = null;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
async poll() {
|
|
430
|
+
const outputDir = join2(this.ipcDir, "output");
|
|
431
|
+
if (!existsSync2(outputDir)) return;
|
|
432
|
+
const files = readdirSync(outputDir).filter((f) => f.endsWith(".json")).sort();
|
|
433
|
+
for (const file of files) {
|
|
434
|
+
const filePath = join2(outputDir, file);
|
|
435
|
+
try {
|
|
436
|
+
const content = readFileSync2(filePath, "utf-8");
|
|
437
|
+
const msg = JSON.parse(content);
|
|
438
|
+
if (msg.workflowId !== this.workflowId) {
|
|
439
|
+
logger.warn({ file, expected: this.workflowId, got: msg.workflowId }, "IPC message workflow mismatch");
|
|
440
|
+
this.moveToErrors(filePath, outputDir);
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
this.onMessage(msg);
|
|
444
|
+
unlinkSync(filePath);
|
|
445
|
+
} catch (err) {
|
|
446
|
+
logger.error({ file, err }, "Failed to process IPC message");
|
|
447
|
+
this.moveToErrors(filePath, outputDir);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
sendToContainer(msg) {
|
|
452
|
+
const inputDir = join2(this.ipcDir, "input");
|
|
453
|
+
mkdirSync(inputDir, { recursive: true });
|
|
454
|
+
const filename = `${Date.now()}-${nanoid2(6)}.json`;
|
|
455
|
+
const tempPath = join2(inputDir, `.${filename}.tmp`);
|
|
456
|
+
const finalPath = join2(inputDir, filename);
|
|
457
|
+
writeFileSync2(tempPath, JSON.stringify(msg));
|
|
458
|
+
renameSync(tempPath, finalPath);
|
|
459
|
+
}
|
|
460
|
+
signalClose() {
|
|
461
|
+
writeFileSync2(join2(this.ipcDir, "_close"), "");
|
|
462
|
+
}
|
|
463
|
+
moveToErrors(filePath, outputDir) {
|
|
464
|
+
const errorsDir = join2(outputDir, "..", "errors");
|
|
465
|
+
mkdirSync(errorsDir, { recursive: true });
|
|
466
|
+
const filename = filePath.split("/").pop();
|
|
467
|
+
try {
|
|
468
|
+
renameSync(filePath, join2(errorsDir, filename));
|
|
469
|
+
} catch {
|
|
470
|
+
try {
|
|
471
|
+
unlinkSync(filePath);
|
|
472
|
+
} catch {
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
// src/mcp-server.ts
|
|
479
|
+
var McpMessageHandler = class {
|
|
480
|
+
constructor(db, router, ipcWatcher) {
|
|
481
|
+
this.db = db;
|
|
482
|
+
this.router = router;
|
|
483
|
+
this.ipcWatcher = ipcWatcher;
|
|
484
|
+
}
|
|
485
|
+
log = logger.child({ module: "mcp-handler" });
|
|
486
|
+
handle(msg) {
|
|
487
|
+
switch (msg.type) {
|
|
488
|
+
case "progress":
|
|
489
|
+
this.handleProgress(msg);
|
|
490
|
+
break;
|
|
491
|
+
case "notification":
|
|
492
|
+
this.handleNotification(msg);
|
|
493
|
+
break;
|
|
494
|
+
case "context_request":
|
|
495
|
+
this.handleContextRequest(msg);
|
|
496
|
+
break;
|
|
497
|
+
default:
|
|
498
|
+
this.log.warn({ type: msg.type }, "Unknown IPC message type");
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
handleProgress(msg) {
|
|
502
|
+
this.db.prepare(
|
|
503
|
+
"UPDATE step_runs SET status = ?, output_json = ? WHERE step_id = ? AND run_id = ?"
|
|
504
|
+
).run(msg.data.status, msg.data.output ?? null, msg.stepId, msg.data.runId);
|
|
505
|
+
this.log.info({ stepId: msg.stepId, status: msg.data.status }, "Step progress update");
|
|
506
|
+
}
|
|
507
|
+
handleNotification(msg) {
|
|
508
|
+
this.router.broadcastNotification(msg.data.message);
|
|
509
|
+
}
|
|
510
|
+
handleContextRequest(msg) {
|
|
511
|
+
const stepRun = this.db.prepare(
|
|
512
|
+
"SELECT output_json FROM step_runs WHERE step_id = ? AND run_id = ?"
|
|
513
|
+
).get(msg.data.requestedStepId, msg.data.runId);
|
|
514
|
+
this.ipcWatcher.sendToContainer({
|
|
515
|
+
workflowId: msg.workflowId,
|
|
516
|
+
stepId: msg.stepId,
|
|
517
|
+
type: "user_message",
|
|
518
|
+
correlationId: msg.correlationId,
|
|
519
|
+
data: { context: stepRun?.output_json ?? null },
|
|
520
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
// src/container-runner.ts
|
|
526
|
+
var OUTPUT_START_MARKER = "---CUECLAW_OUTPUT_START---";
|
|
527
|
+
var OUTPUT_END_MARKER = "---CUECLAW_OUTPUT_END---";
|
|
528
|
+
async function runContainerAgent(opts) {
|
|
529
|
+
const config = loadConfig();
|
|
530
|
+
const allowlist = loadMountAllowlist();
|
|
531
|
+
if (opts.additionalMounts) {
|
|
532
|
+
validateAdditionalMounts(opts.additionalMounts, allowlist);
|
|
533
|
+
}
|
|
534
|
+
mkdirSync2(opts.workDir, { recursive: true });
|
|
535
|
+
mkdirSync2(join3(opts.ipcDir, "input"), { recursive: true });
|
|
536
|
+
mkdirSync2(join3(opts.ipcDir, "output"), { recursive: true });
|
|
537
|
+
const image = config.container?.image ?? "cueclaw-agent:latest";
|
|
538
|
+
const network = config.container?.network ?? "none";
|
|
539
|
+
const volumeMounts = buildVolumeMounts(opts);
|
|
540
|
+
const dockerArgs = [
|
|
541
|
+
"run",
|
|
542
|
+
"--rm",
|
|
543
|
+
"-i",
|
|
544
|
+
"--name",
|
|
545
|
+
opts.containerName,
|
|
546
|
+
"--network",
|
|
547
|
+
network,
|
|
548
|
+
"--user",
|
|
549
|
+
"1000:1000",
|
|
550
|
+
"--memory",
|
|
551
|
+
"4g",
|
|
552
|
+
"--cpus",
|
|
553
|
+
"2",
|
|
554
|
+
...volumeMounts,
|
|
555
|
+
image
|
|
556
|
+
];
|
|
557
|
+
let ipcWatcher;
|
|
558
|
+
if (opts.db && opts.router) {
|
|
559
|
+
ipcWatcher = new IpcWatcher(opts.workflowId, opts.stepId, opts.ipcDir, (msg) => {
|
|
560
|
+
const handler = new McpMessageHandler(opts.db, opts.router, ipcWatcher);
|
|
561
|
+
handler.handle(msg);
|
|
562
|
+
});
|
|
563
|
+
ipcWatcher.start();
|
|
564
|
+
}
|
|
565
|
+
try {
|
|
566
|
+
const result = await spawnContainer(dockerArgs, opts, config);
|
|
567
|
+
return result;
|
|
568
|
+
} finally {
|
|
569
|
+
ipcWatcher?.stop();
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
function buildVolumeMounts(opts) {
|
|
573
|
+
const mounts = [
|
|
574
|
+
"-v",
|
|
575
|
+
`${opts.cwd}:/workspace/project:ro`,
|
|
576
|
+
"-v",
|
|
577
|
+
`${opts.workDir}:/workspace/work`,
|
|
578
|
+
"-v",
|
|
579
|
+
`${opts.ipcDir}:/workspace/ipc`
|
|
580
|
+
];
|
|
581
|
+
for (const mount of opts.additionalMounts ?? []) {
|
|
582
|
+
const expanded = expandHome(mount.hostPath);
|
|
583
|
+
const containerPath = mount.containerPath ?? `/workspace/mounts${expanded}`;
|
|
584
|
+
const mode = mount.readonly !== false ? "ro" : "rw";
|
|
585
|
+
mounts.push("-v", `${expanded}:${containerPath}:${mode}`);
|
|
586
|
+
}
|
|
587
|
+
return mounts;
|
|
588
|
+
}
|
|
589
|
+
async function spawnContainer(dockerArgs, opts, config) {
|
|
590
|
+
return new Promise((resolve) => {
|
|
591
|
+
const proc = spawn("docker", dockerArgs, {
|
|
592
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
593
|
+
});
|
|
594
|
+
const input = {
|
|
595
|
+
prompt: opts.prompt,
|
|
596
|
+
workflowId: opts.workflowId,
|
|
597
|
+
stepId: opts.stepId,
|
|
598
|
+
runId: opts.runId,
|
|
599
|
+
apiKey: config.claude.api_key,
|
|
600
|
+
allowedTools: opts.allowedTools
|
|
601
|
+
};
|
|
602
|
+
proc.stdin.write(JSON.stringify(input));
|
|
603
|
+
proc.stdin.end();
|
|
604
|
+
const hardTimeout = config.container?.timeout ?? 18e5;
|
|
605
|
+
const idleTimeout = config.container?.idle_timeout ?? 18e5;
|
|
606
|
+
const maxOutputSize = config.container?.max_output_size ?? 10485760;
|
|
607
|
+
let lastActivity = Date.now();
|
|
608
|
+
let totalOutput = 0;
|
|
609
|
+
let truncated = false;
|
|
610
|
+
const hardTimer = setTimeout(() => {
|
|
611
|
+
logger.warn({ containerName: opts.containerName }, "Container hard timeout reached");
|
|
612
|
+
gracefulStop(opts.containerName);
|
|
613
|
+
}, hardTimeout);
|
|
614
|
+
const idleCheck = setInterval(() => {
|
|
615
|
+
if (Date.now() - lastActivity > idleTimeout) {
|
|
616
|
+
logger.warn({ containerName: opts.containerName }, "Container idle timeout reached");
|
|
617
|
+
gracefulStop(opts.containerName);
|
|
618
|
+
}
|
|
619
|
+
}, 1e4);
|
|
620
|
+
opts.signal?.addEventListener("abort", () => gracefulStop(opts.containerName), { once: true });
|
|
621
|
+
let stdout = "";
|
|
622
|
+
let resultBuffer = "";
|
|
623
|
+
proc.stdout?.on("data", (chunk) => {
|
|
624
|
+
lastActivity = Date.now();
|
|
625
|
+
const text = chunk.toString();
|
|
626
|
+
totalOutput += text.length;
|
|
627
|
+
if (totalOutput > maxOutputSize) {
|
|
628
|
+
if (!truncated) {
|
|
629
|
+
truncated = true;
|
|
630
|
+
logger.warn({ containerName: opts.containerName, size: totalOutput }, "Container output size cap reached, stopping container");
|
|
631
|
+
gracefulStop(opts.containerName);
|
|
632
|
+
}
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
stdout += text;
|
|
636
|
+
opts.onProgress?.({ type: "output", data: text });
|
|
637
|
+
});
|
|
638
|
+
let stderr = "";
|
|
639
|
+
proc.stderr?.on("data", (chunk) => {
|
|
640
|
+
lastActivity = Date.now();
|
|
641
|
+
stderr += chunk.toString();
|
|
642
|
+
});
|
|
643
|
+
proc.on("close", (code) => {
|
|
644
|
+
clearTimeout(hardTimer);
|
|
645
|
+
clearInterval(idleCheck);
|
|
646
|
+
if (truncated) {
|
|
647
|
+
resolve({ status: "failed", error: "Container output size cap exceeded" });
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
const startIdx = stdout.indexOf(OUTPUT_START_MARKER);
|
|
651
|
+
const endIdx = stdout.indexOf(OUTPUT_END_MARKER);
|
|
652
|
+
if (startIdx !== -1 && endIdx !== -1) {
|
|
653
|
+
resultBuffer = stdout.slice(startIdx + OUTPUT_START_MARKER.length + 1, endIdx).trim();
|
|
654
|
+
}
|
|
655
|
+
if (code !== 0 && !resultBuffer) {
|
|
656
|
+
resolve({
|
|
657
|
+
status: "failed",
|
|
658
|
+
error: stderr || `Container exited with code ${code}`
|
|
659
|
+
});
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
try {
|
|
663
|
+
const parsed = JSON.parse(resultBuffer);
|
|
664
|
+
resolve({
|
|
665
|
+
status: "succeeded",
|
|
666
|
+
output: parsed.result ?? null,
|
|
667
|
+
sessionId: parsed.sessionId
|
|
668
|
+
});
|
|
669
|
+
} catch {
|
|
670
|
+
if (resultBuffer) {
|
|
671
|
+
resolve({ status: "succeeded", output: resultBuffer });
|
|
672
|
+
} else {
|
|
673
|
+
resolve({
|
|
674
|
+
status: "failed",
|
|
675
|
+
error: stderr || "No output captured from container"
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
});
|
|
680
|
+
proc.on("error", (err) => {
|
|
681
|
+
clearTimeout(hardTimer);
|
|
682
|
+
clearInterval(idleCheck);
|
|
683
|
+
resolve({
|
|
684
|
+
status: "failed",
|
|
685
|
+
error: `Docker spawn error: ${err.message}`
|
|
686
|
+
});
|
|
687
|
+
});
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
function gracefulStop(containerName) {
|
|
691
|
+
try {
|
|
692
|
+
spawn("docker", ["stop", containerName]);
|
|
693
|
+
} catch {
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
function prepareContainerOpts(workflowId, stepId, runId, prompt, cwd, allowedTools) {
|
|
697
|
+
const ipcDir = join3(cueclawHome(), "ipc", workflowId, stepId);
|
|
698
|
+
const workDir = join3(cueclawHome(), "work", workflowId, stepId);
|
|
699
|
+
const containerName = `cueclaw-${workflowId}-${stepId}-${Date.now()}`;
|
|
700
|
+
mkdirSync2(workDir, { recursive: true });
|
|
701
|
+
mkdirSync2(join3(ipcDir, "input"), { recursive: true });
|
|
702
|
+
mkdirSync2(join3(ipcDir, "output"), { recursive: true });
|
|
703
|
+
return {
|
|
704
|
+
workflowId,
|
|
705
|
+
stepId,
|
|
706
|
+
runId,
|
|
707
|
+
prompt,
|
|
708
|
+
cwd,
|
|
709
|
+
workDir,
|
|
710
|
+
ipcDir,
|
|
711
|
+
containerName,
|
|
712
|
+
allowedTools
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// src/agent-runner.ts
|
|
717
|
+
function runAgent(opts) {
|
|
718
|
+
const config = loadConfig();
|
|
719
|
+
if (config.container?.enabled && opts.workflowId && opts.stepId && opts.runId) {
|
|
720
|
+
const containerOpts = prepareContainerOpts(
|
|
721
|
+
opts.workflowId,
|
|
722
|
+
opts.stepId,
|
|
723
|
+
opts.runId,
|
|
724
|
+
opts.prompt,
|
|
725
|
+
opts.cwd,
|
|
726
|
+
opts.allowedTools
|
|
727
|
+
);
|
|
728
|
+
const resultPromise2 = runContainerAgent({
|
|
729
|
+
...containerOpts,
|
|
730
|
+
signal: opts.signal,
|
|
731
|
+
onProgress: opts.onProgress
|
|
732
|
+
});
|
|
733
|
+
return {
|
|
734
|
+
resultPromise: resultPromise2,
|
|
735
|
+
abort: () => {
|
|
736
|
+
try {
|
|
737
|
+
import("child_process").then(({ spawn: spawn2 }) => {
|
|
738
|
+
spawn2("docker", ["stop", containerOpts.containerName]);
|
|
739
|
+
});
|
|
740
|
+
} catch {
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
let aborted = false;
|
|
746
|
+
const resultPromise = (async () => {
|
|
747
|
+
try {
|
|
748
|
+
const { query } = await import("@anthropic-ai/claude-agent-sdk");
|
|
749
|
+
const q = query({
|
|
750
|
+
prompt: opts.prompt,
|
|
751
|
+
options: {
|
|
752
|
+
cwd: opts.cwd,
|
|
753
|
+
model: config.claude.executor.model,
|
|
754
|
+
resume: opts.sessionId,
|
|
755
|
+
allowedTools: opts.allowedTools ?? [
|
|
756
|
+
"Bash",
|
|
757
|
+
"Read",
|
|
758
|
+
"Write",
|
|
759
|
+
"Edit",
|
|
760
|
+
"Glob",
|
|
761
|
+
"Grep",
|
|
762
|
+
"WebSearch",
|
|
763
|
+
"WebFetch"
|
|
764
|
+
],
|
|
765
|
+
settingSources: ["project"],
|
|
766
|
+
permissionMode: "default"
|
|
767
|
+
}
|
|
768
|
+
});
|
|
769
|
+
let sessionId;
|
|
770
|
+
let result = null;
|
|
771
|
+
for await (const message of q) {
|
|
772
|
+
if (aborted) {
|
|
773
|
+
if ("interrupt" in q && typeof q.interrupt === "function") {
|
|
774
|
+
q.interrupt();
|
|
775
|
+
}
|
|
776
|
+
return { status: "failed", error: "Aborted by user" };
|
|
777
|
+
}
|
|
778
|
+
if (message.type === "system" && message.subtype === "init") {
|
|
779
|
+
sessionId = message.session_id;
|
|
780
|
+
}
|
|
781
|
+
if (message.type === "assistant") {
|
|
782
|
+
for (const block of message.content ?? []) {
|
|
783
|
+
if (block.type === "tool_use" && block.name === "Bash") {
|
|
784
|
+
const cmd = block.input?.command;
|
|
785
|
+
if (cmd) {
|
|
786
|
+
const check = checkBashSafety(cmd);
|
|
787
|
+
if (!check.allowed) {
|
|
788
|
+
logger.warn({ command: cmd, reason: check.reason }, "Blocked dangerous command");
|
|
789
|
+
return { status: "failed", error: `Blocked dangerous command: ${check.reason}` };
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
if ("result" in message) {
|
|
796
|
+
result = message.result ?? null;
|
|
797
|
+
}
|
|
798
|
+
opts.onProgress?.(message);
|
|
799
|
+
}
|
|
800
|
+
return { status: "succeeded", output: result, sessionId };
|
|
801
|
+
} catch (err) {
|
|
802
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
803
|
+
logger.error({ err, stepId: opts.stepId }, "Agent execution failed");
|
|
804
|
+
return { status: "failed", error: errorMsg };
|
|
805
|
+
}
|
|
806
|
+
})();
|
|
807
|
+
return {
|
|
808
|
+
resultPromise,
|
|
809
|
+
abort: () => {
|
|
810
|
+
aborted = true;
|
|
811
|
+
}
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// src/session.ts
|
|
816
|
+
import { nanoid as nanoid3 } from "nanoid";
|
|
817
|
+
function createSession(db, stepRunId, sdkSessionId) {
|
|
818
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
819
|
+
const session = {
|
|
820
|
+
id: `sess_${nanoid3()}`,
|
|
821
|
+
step_run_id: stepRunId,
|
|
822
|
+
sdk_session_id: sdkSessionId,
|
|
823
|
+
created_at: now,
|
|
824
|
+
last_used_at: now,
|
|
825
|
+
is_active: true
|
|
826
|
+
};
|
|
827
|
+
db.prepare(`
|
|
828
|
+
INSERT INTO sessions (id, step_run_id, sdk_session_id, created_at, last_used_at, is_active)
|
|
829
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
830
|
+
`).run(session.id, session.step_run_id, session.sdk_session_id ?? null, session.created_at, session.last_used_at, 1);
|
|
831
|
+
return session;
|
|
832
|
+
}
|
|
833
|
+
function deactivateSession(db, sessionId) {
|
|
834
|
+
db.prepare("UPDATE sessions SET is_active = 0, last_used_at = ? WHERE id = ?").run((/* @__PURE__ */ new Date()).toISOString(), sessionId);
|
|
835
|
+
}
|
|
836
|
+
function updateSessionSdkId(db, sessionId, sdkSessionId) {
|
|
837
|
+
db.prepare("UPDATE sessions SET sdk_session_id = ?, last_used_at = ? WHERE id = ?").run(sdkSessionId, (/* @__PURE__ */ new Date()).toISOString(), sessionId);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// src/executor.ts
|
|
841
|
+
var STEP_REF_PATTERN = /\$steps\.([a-z0-9-]+)\.output/g;
|
|
842
|
+
var TRIGGER_DATA_PATTERN = /\$trigger_data/g;
|
|
843
|
+
var MAX_OUTPUT_SIZE = 10240;
|
|
844
|
+
function resolveValue(value, completedSteps, triggerData) {
|
|
845
|
+
if (typeof value === "string") {
|
|
846
|
+
let shouldSkip = false;
|
|
847
|
+
let resolved = value.replace(STEP_REF_PATTERN, (_match, stepId) => {
|
|
848
|
+
const result = completedSteps.get(stepId);
|
|
849
|
+
if (!result) return "null";
|
|
850
|
+
if (result.status === "failed" || result.status === "skipped") {
|
|
851
|
+
shouldSkip = true;
|
|
852
|
+
return "null";
|
|
853
|
+
}
|
|
854
|
+
const output = result.output ?? "null";
|
|
855
|
+
return output.length > MAX_OUTPUT_SIZE ? output.slice(0, MAX_OUTPUT_SIZE) + "\n[truncated]" : output;
|
|
856
|
+
});
|
|
857
|
+
if (shouldSkip) return { __skip: true };
|
|
858
|
+
resolved = resolved.replace(TRIGGER_DATA_PATTERN, triggerData ?? "null");
|
|
859
|
+
return resolved;
|
|
860
|
+
}
|
|
861
|
+
if (Array.isArray(value)) {
|
|
862
|
+
return value.map((item) => resolveValue(item, completedSteps, triggerData));
|
|
863
|
+
}
|
|
864
|
+
if (value !== null && typeof value === "object") {
|
|
865
|
+
return resolveInputs(value, completedSteps, triggerData);
|
|
866
|
+
}
|
|
867
|
+
return value;
|
|
868
|
+
}
|
|
869
|
+
function resolveInputs(inputs, completedSteps, triggerData) {
|
|
870
|
+
const resolved = {};
|
|
871
|
+
for (const [key, value] of Object.entries(inputs)) {
|
|
872
|
+
resolved[key] = resolveValue(value, completedSteps, triggerData);
|
|
873
|
+
}
|
|
874
|
+
return resolved;
|
|
875
|
+
}
|
|
876
|
+
function hasSkipMarker(inputs) {
|
|
877
|
+
for (const value of Object.values(inputs)) {
|
|
878
|
+
if (value && typeof value === "object" && "__skip" in value) return true;
|
|
879
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value) && hasSkipMarker(value)) return true;
|
|
880
|
+
}
|
|
881
|
+
return false;
|
|
882
|
+
}
|
|
883
|
+
async function executeStepOnce(step, resolvedInputs, runId, db, cwd, onProgress) {
|
|
884
|
+
const stepRunId = `sr_${nanoid4()}`;
|
|
885
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
886
|
+
const stepRun = {
|
|
887
|
+
id: stepRunId,
|
|
888
|
+
run_id: runId,
|
|
889
|
+
step_id: step.id,
|
|
890
|
+
status: "running",
|
|
891
|
+
started_at: now
|
|
892
|
+
};
|
|
893
|
+
insertStepRun(db, stepRun);
|
|
894
|
+
const inputContext = Object.keys(resolvedInputs).length > 0 ? `
|
|
895
|
+
|
|
896
|
+
Inputs:
|
|
897
|
+
${JSON.stringify(resolvedInputs, null, 2)}` : "";
|
|
898
|
+
const prompt = `${step.description}${inputContext}`;
|
|
899
|
+
const handle = runAgent({
|
|
900
|
+
prompt,
|
|
901
|
+
cwd,
|
|
902
|
+
workflowId: step.id,
|
|
903
|
+
stepId: step.id,
|
|
904
|
+
runId,
|
|
905
|
+
onProgress: onProgress ? (msg) => onProgress(step.id, msg) : void 0
|
|
906
|
+
});
|
|
907
|
+
const result = await handle.resultPromise;
|
|
908
|
+
if (result.sessionId) {
|
|
909
|
+
const session = createSession(db, stepRunId, result.sessionId);
|
|
910
|
+
updateSessionSdkId(db, session.id, result.sessionId);
|
|
911
|
+
deactivateSession(db, session.id);
|
|
912
|
+
}
|
|
913
|
+
updateStepRunStatus(db, stepRunId, result.status, result.output ?? void 0, result.error);
|
|
914
|
+
return result;
|
|
915
|
+
}
|
|
916
|
+
async function executeStepWithRetry(step, resolvedInputs, runId, db, cwd, policy, onProgress) {
|
|
917
|
+
const maxRetries = policy.max_retries ?? 0;
|
|
918
|
+
let delay = policy.retry_delay_ms ?? 5e3;
|
|
919
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
920
|
+
const result = await executeStepOnce(step, resolvedInputs, runId, db, cwd, onProgress);
|
|
921
|
+
if (result.status !== "failed" || attempt === maxRetries) return result;
|
|
922
|
+
logger.info({ stepId: step.id, attempt, delay }, "Retrying step");
|
|
923
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
924
|
+
delay *= 2;
|
|
925
|
+
}
|
|
926
|
+
throw new ExecutorError("Unreachable: step retry loop exited without returning");
|
|
927
|
+
}
|
|
928
|
+
async function executeWorkflow(opts) {
|
|
929
|
+
const { workflow, triggerData, db, cwd, onStepFailure, onProgress, signal } = opts;
|
|
930
|
+
const runId = `run_${nanoid4()}`;
|
|
931
|
+
const run = {
|
|
932
|
+
id: runId,
|
|
933
|
+
workflow_id: workflow.id,
|
|
934
|
+
trigger_data: triggerData,
|
|
935
|
+
status: "running",
|
|
936
|
+
started_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
937
|
+
};
|
|
938
|
+
insertWorkflowRun(db, run);
|
|
939
|
+
updateWorkflowPhase(db, workflow.id, "executing");
|
|
940
|
+
const completed = /* @__PURE__ */ new Map();
|
|
941
|
+
const remaining = new Set(workflow.steps.map((s) => s.id));
|
|
942
|
+
const stepMap = new Map(workflow.steps.map((s) => [s.id, s]));
|
|
943
|
+
let runFailed = false;
|
|
944
|
+
try {
|
|
945
|
+
while (remaining.size > 0) {
|
|
946
|
+
if (signal?.aborted) {
|
|
947
|
+
for (const id of remaining) {
|
|
948
|
+
completed.set(id, { status: "skipped", error: "Aborted" });
|
|
949
|
+
}
|
|
950
|
+
remaining.clear();
|
|
951
|
+
runFailed = true;
|
|
952
|
+
break;
|
|
953
|
+
}
|
|
954
|
+
const ready = [...remaining].filter((id) => {
|
|
955
|
+
const step = stepMap.get(id);
|
|
956
|
+
return step.depends_on.every((dep) => completed.has(dep));
|
|
957
|
+
});
|
|
958
|
+
if (ready.length === 0) {
|
|
959
|
+
throw new ExecutorError("Deadlock: no ready steps but remaining steps exist");
|
|
960
|
+
}
|
|
961
|
+
const executable = [];
|
|
962
|
+
for (const id of ready) {
|
|
963
|
+
const step = stepMap.get(id);
|
|
964
|
+
const depsFailed = step.depends_on.some(
|
|
965
|
+
(dep) => completed.get(dep)?.status === "failed" || completed.get(dep)?.status === "skipped"
|
|
966
|
+
);
|
|
967
|
+
if (depsFailed && workflow.failure_policy.on_step_failure !== "ask_user") {
|
|
968
|
+
remaining.delete(id);
|
|
969
|
+
completed.set(id, { status: "skipped" });
|
|
970
|
+
const skipRunId = `sr_${nanoid4()}`;
|
|
971
|
+
insertStepRun(db, { id: skipRunId, run_id: runId, step_id: id, status: "skipped" });
|
|
972
|
+
continue;
|
|
973
|
+
}
|
|
974
|
+
const resolvedInputs = resolveInputs(step.inputs, completed, triggerData);
|
|
975
|
+
if (hasSkipMarker(resolvedInputs)) {
|
|
976
|
+
remaining.delete(id);
|
|
977
|
+
completed.set(id, { status: "skipped" });
|
|
978
|
+
const skipRunId = `sr_${nanoid4()}`;
|
|
979
|
+
insertStepRun(db, { id: skipRunId, run_id: runId, step_id: id, status: "skipped" });
|
|
980
|
+
continue;
|
|
981
|
+
}
|
|
982
|
+
executable.push(step);
|
|
983
|
+
}
|
|
984
|
+
if (executable.length === 0) continue;
|
|
985
|
+
const results = await Promise.all(
|
|
986
|
+
executable.map(async (step) => {
|
|
987
|
+
remaining.delete(step.id);
|
|
988
|
+
const resolvedInputs = resolveInputs(step.inputs, completed, triggerData);
|
|
989
|
+
const result = await executeStepWithRetry(
|
|
990
|
+
step,
|
|
991
|
+
resolvedInputs,
|
|
992
|
+
runId,
|
|
993
|
+
db,
|
|
994
|
+
cwd,
|
|
995
|
+
workflow.failure_policy,
|
|
996
|
+
onProgress
|
|
997
|
+
);
|
|
998
|
+
return { stepId: step.id, result };
|
|
999
|
+
})
|
|
1000
|
+
);
|
|
1001
|
+
for (const { stepId, result } of results) {
|
|
1002
|
+
completed.set(stepId, result);
|
|
1003
|
+
if (result.status === "failed") {
|
|
1004
|
+
const policy = workflow.failure_policy.on_step_failure;
|
|
1005
|
+
if (policy === "stop") {
|
|
1006
|
+
for (const remainingId of remaining) {
|
|
1007
|
+
completed.set(remainingId, { status: "skipped" });
|
|
1008
|
+
const skipRunId = `sr_${nanoid4()}`;
|
|
1009
|
+
insertStepRun(db, { id: skipRunId, run_id: runId, step_id: remainingId, status: "skipped" });
|
|
1010
|
+
}
|
|
1011
|
+
remaining.clear();
|
|
1012
|
+
runFailed = true;
|
|
1013
|
+
break;
|
|
1014
|
+
}
|
|
1015
|
+
if (policy === "ask_user" && onStepFailure) {
|
|
1016
|
+
const decision = await onStepFailure(stepMap.get(stepId), result.error ?? "Unknown error");
|
|
1017
|
+
if (decision === "stop") {
|
|
1018
|
+
for (const remainingId of remaining) {
|
|
1019
|
+
completed.set(remainingId, { status: "skipped" });
|
|
1020
|
+
const skipRunId = `sr_${nanoid4()}`;
|
|
1021
|
+
insertStepRun(db, { id: skipRunId, run_id: runId, step_id: remainingId, status: "skipped" });
|
|
1022
|
+
}
|
|
1023
|
+
remaining.clear();
|
|
1024
|
+
runFailed = true;
|
|
1025
|
+
break;
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
} catch (err) {
|
|
1032
|
+
runFailed = true;
|
|
1033
|
+
logger.error({ err, runId }, "Workflow execution error");
|
|
1034
|
+
}
|
|
1035
|
+
const finalStatus = runFailed ? "failed" : "completed";
|
|
1036
|
+
updateWorkflowRunStatus(db, runId, finalStatus);
|
|
1037
|
+
if (workflow.trigger.type === "manual") {
|
|
1038
|
+
updateWorkflowPhase(db, workflow.id, runFailed ? "failed" : "completed");
|
|
1039
|
+
} else {
|
|
1040
|
+
updateWorkflowPhase(db, workflow.id, "active");
|
|
1041
|
+
}
|
|
1042
|
+
return { runId, status: finalStatus, results: completed };
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
export {
|
|
1046
|
+
generatePlan,
|
|
1047
|
+
modifyPlan,
|
|
1048
|
+
confirmPlan,
|
|
1049
|
+
rejectPlan,
|
|
1050
|
+
executeWorkflow
|
|
1051
|
+
};
|