@workflow-manager/runner
Version:
CLI runner for in-memory and markdown workflow orchestration using ATEP-like envelopes
177 lines (176 loc) • 7.25 kB
JavaScript
import { spawn } from "node:child_process";
import { createContextMetricsBuilder } from "./contextMetrics.js";
import { previousOutputTextSections } from "./promptSections.js";
import { resolveSkill } from "./skillResolver.js";
function asRecord(value) {
return value && typeof value === "object" ? value : {};
}
export function normalizeTimeout(value, fallbackMs = 120000) {
const timeout = Number(value ?? fallbackMs);
if (!Number.isFinite(timeout) || timeout <= 0)
return fallbackMs;
return Math.floor(timeout);
}
function buildPrompt(step, input, workflow, workflowFilePath) {
const payload = asRecord(step.taskSpec?.payload);
const metrics = createContextMetricsBuilder();
if (typeof payload.prompt === "string" && payload.prompt.trim()) {
metrics.addContext(payload.prompt);
return { prompt: payload.prompt, metrics: metrics.build() };
}
const parts = [];
const systemPrompts = input.priming_configuration.system_prompts;
if (systemPrompts.length > 0) {
const joined = systemPrompts.join("\n");
parts.push(joined);
metrics.addSystemPrompts(joined);
}
const skills = input.priming_configuration.required_skills;
if (skills.length > 0) {
const resolvedNames = [];
for (const name of skills) {
const resolved = workflow && workflowFilePath ? resolveSkill(name, workflow, workflowFilePath) : null;
if (resolved) {
parts.push(resolved.content);
metrics.addSkill(name, resolved.content);
}
else {
resolvedNames.push(name);
}
}
if (resolvedNames.length > 0) {
parts.push(`Apply the following skills: ${resolvedNames.join(", ")}`);
}
}
// Inject primitive user inputs (feature, ticket, etc.) — skip step output objects.
// Newlines stripped from values to reduce prompt injection surface.
const globalState = input.global_context.global_state;
const inputLines = Object.entries(globalState)
.filter(([, v]) => typeof v === "string" || typeof v === "number")
.map(([k, v]) => `${k}: ${String(v).replace(/[\n\r]/g, " ")}`);
if (inputLines.length > 0) {
const block = `Input:\n${inputLines.join("\n")}`;
parts.push(block);
metrics.addGlobalState(block);
}
parts.push(input.step_context.step_objective);
metrics.addObjective(input.step_context.step_objective);
// Inject output from previous steps so context flows forward
const prev = input.step_context.previous_output;
for (const [key, val] of Object.entries(prev)) {
const sections = previousOutputTextSections(val);
if (sections.length > 0) {
const block = `Output from ${key}:\n${sections.join("\n\n")}`;
parts.push(block);
metrics.addPreviousOutput(block);
}
}
const context = input.priming_configuration.context;
if (typeof context === "string" && context.trim()) {
const block = `Context:\n${context}`;
parts.push(block);
metrics.addContext(block);
}
else if (context && typeof context === "object") {
const str = JSON.stringify(context, null, 2);
if (str !== "{}") {
const block = `Context:\n${str}`;
parts.push(block);
metrics.addContext(block);
}
}
return { prompt: parts.join("\n\n"), metrics: metrics.build() };
}
export function shouldUseRealClaudeCode(step) {
const payload = asRecord(step.taskSpec?.payload);
return payload.useRealAdapter === true;
}
export function executeClaudeCodeStep(step, input, attempt, workflow, workflowFilePath, hooks) {
const startedAt = Date.now();
const payload = asRecord(step.taskSpec?.payload);
const timeoutMs = normalizeTimeout(payload.timeoutMs);
const { prompt, metrics: contextMetrics } = buildPrompt(step, input, workflow, workflowFilePath);
const configuredModel = typeof input.priming_configuration.model === "string" && input.priming_configuration.model.trim()
? input.priming_configuration.model
: typeof payload.model === "string" && payload.model.trim()
? payload.model
: undefined;
const args = ["-p"];
if (configuredModel) {
args.push("--model", configuredModel);
}
const makeResult = (status, reason, extra = {}) => ({
step_id: step.key,
execution_status: status,
qa_routing: { action: "PROCEED", feedback_reason: reason },
mutated_payload: {
stepKey: step.key,
attempt,
adapter: "claude-code",
prompt,
model: configuredModel,
contextMetrics,
...extra,
},
metadata: { execution_time_ms: Date.now() - startedAt, external_intervention_required: false },
});
return new Promise((resolve) => {
let child;
try {
child = spawn("claude", args);
}
catch (err) {
resolve(makeResult("FAILED", err.message));
return;
}
hooks?.onStarted?.({ command: "claude", args, model: configuredModel, contextMetrics });
child.stdin?.on("error", () => undefined);
child.stdin?.end(prompt);
const outChunks = [];
const errChunks = [];
child.stdout?.on("data", (chunk) => {
const text = chunk.toString();
outChunks.push(text);
hooks?.onStdout?.(text);
});
child.stderr?.on("data", (chunk) => {
const text = chunk.toString();
errChunks.push(text);
hooks?.onStderr?.(text);
});
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
child.kill("SIGTERM");
const result = makeResult("FAILED", `timed out after ${timeoutMs}ms`);
hooks?.onFinished?.({ executionStatus: result.execution_status, timedOut: true });
resolve(result);
}, timeoutMs);
child.on("error", (err) => {
clearTimeout(timer);
if (timedOut)
return;
const result = makeResult("FAILED", err.message);
hooks?.onFinished?.({ executionStatus: result.execution_status });
resolve(result);
});
child.on("close", (code) => {
clearTimeout(timer);
if (timedOut)
return;
const stdout = outChunks.join("");
const stderr = errChunks.join("");
const exitStatus = code ?? 1;
if (exitStatus !== 0) {
const result = makeResult("FAILED", `claude exited ${exitStatus}: ${stderr.trim()}`, { exitStatus, stdout, stderr });
hooks?.onFinished?.({ executionStatus: result.execution_status, exitStatus });
resolve(result);
}
else {
const result = makeResult("SUCCESS", "", { exitStatus, output: stdout.trim() });
hooks?.onFinished?.({ executionStatus: result.execution_status, exitStatus });
resolve(result);
}
});
});
}