UNPKG

@workflow-manager/runner

Version:

CLI runner for in-memory and markdown workflow orchestration using ATEP-like envelopes

972 lines (971 loc) 41.3 kB
import { randomUUID } from "node:crypto"; import { createInterface } from "node:readline"; import { executeAcpStep, shouldUseRealAcp } from "./acpExecutor.js"; import { resolveTaskAdapter, resolveValidatorAgentSpec } from "./adapters.js"; import { EventLog } from "./events.js"; import { executeClaudeCodeStep, shouldUseRealClaudeCode } from "./claudeCodeExecutor.js"; import { executeMockStep } from "./mockExecutor.js"; import { executePiAgentStep } from "./piAgentExecutor.js"; import { adapterMockFallbackReason, validateRuntimeRequirements } from "./runtimePreflight.js"; function nodeType(step) { if (step.kind === "approval") return "HUMAN"; if (step.kind === "system") return "SYSTEM"; return "AGENT"; } function stepAdapter(step) { return step.kind === "task" ? resolveTaskAdapter(step.taskSpec?.adapterKey) : "approval"; } function stepObjective(step, workflowObjective) { return step.objective ?? `${workflowObjective} :: ${step.key}`; } function stepLabel(step) { return step.title ?? step.objective ?? step.key; } const EXECUTION_STATUSES = ["SUCCESS", "QA_REJECTED", "YIELD_EXTERNAL", "FAILED"]; const QA_ACTIONS = ["PROCEED", "RETRY_CURRENT", "ROLLBACK_PREVIOUS", "RESTART_ALL"]; function isExecutionStatus(value) { return typeof value === "string" && EXECUTION_STATUSES.includes(value); } function isQaAction(value) { return typeof value === "string" && QA_ACTIONS.includes(value); } function extractContextMetrics(mutatedPayload) { const value = mutatedPayload.contextMetrics; return value && typeof value === "object" ? value : null; } function validatedExecutorOutput(step, input, attempt, output) { if (isExecutionStatus(output.execution_status) && isQaAction(output.qa_routing.action)) { return output; } const invalidExecutionStatus = isExecutionStatus(output.execution_status) ? null : String(output.execution_status); const invalidQaAction = isQaAction(output.qa_routing.action) ? null : String(output.qa_routing.action); const reason = [ invalidExecutionStatus ? `execution_status=${invalidExecutionStatus}` : null, invalidQaAction ? `qa_routing.action=${invalidQaAction}` : null, ] .filter(Boolean) .join(", "); return { step_id: step.key, execution_status: "FAILED", qa_routing: { action: "PROCEED", feedback_reason: `Invalid executor output for ${step.key}: ${reason}`, }, mutated_payload: { stepKey: step.key, attempt, adapter: input.priming_configuration.adapter ?? stepAdapter(step), invalidExecutionStatus, invalidQaAction, }, metadata: { execution_time_ms: output.metadata.execution_time_ms, external_intervention_required: false, }, }; } function orderStepsByDependencies(steps) { const byKey = new Map(steps.map((step) => [step.key, step])); const ordered = []; const visiting = new Set(); const visited = new Set(); const visit = (step) => { if (visited.has(step.key)) { return; } if (visiting.has(step.key)) { throw new Error(`Circular dependency detected at step ${step.key}`); } visiting.add(step.key); for (const dependency of step.dependsOn ?? []) { const dependencyStep = byKey.get(dependency); if (dependencyStep) { visit(dependencyStep); } } visiting.delete(step.key); visited.add(step.key); ordered.push(step); }; for (const step of steps) { visit(step); } return ordered; } function projectGlobalState(globalState, stateFrom) { if (stateFrom === undefined || stateFrom === "all") return globalState; if (stateFrom === "none") return {}; const projected = {}; for (const key of stateFrom) { if (key in globalState) projected[key] = globalState[key]; } return projected; } function summarizeContext(value) { if (typeof value === "string") { return { type: "string", length: value.length }; } if (value && typeof value === "object") { return { type: "object", keys: Object.keys(value).sort() }; } return { type: "none" }; } function requiresValidation(step) { if (step.approvalSpec?.validation?.required) return step.approvalSpec.validation.mode ?? "human"; if (step.validation?.required) return step.validation.mode ?? "human"; if (step.kind === "approval") return step.approvalSpec?.validation?.mode ?? "human"; return step.validation?.mode ?? "none"; } export function canUseInteractiveConfirmation(step) { return requiresValidation(step) === "human"; } function canConfirm(step, options, output) { const mode = requiresValidation(step); // Agent validation is a QA gate handled by executeStep's validation pass below, not a // human confirmer — it must never be short-circuited by autoConfirmAll/confirmations. if ((mode === "none" || mode === "agent") && output.execution_status !== "YIELD_EXTERNAL") return { ok: true }; if (options.autoConfirmAll) return { ok: true }; const list = new Set(options.confirmations ?? []); const modeToken = `${step.key}:${mode}`; if (list.has(step.key) || list.has(modeToken)) return { ok: true }; const autoConfirm = step.validation?.autoConfirm ?? step.approvalSpec?.validation?.autoConfirm ?? false; if (autoConfirm) return { ok: true }; return { ok: false, reason: `Missing confirmation for ${step.key} (${mode})` }; } function summarizeText(value, maxLength = 180) { const normalized = value.replace(/\s+/g, " ").trim(); if (normalized.length <= maxLength) { return normalized; } return `${normalized.slice(0, maxLength - 3)}...`; } function summarizeArtifact(value) { if (typeof value === "string") { return summarizeText(value); } if (typeof value === "number" || typeof value === "boolean") { return String(value); } if (Array.isArray(value)) { const items = value.slice(0, 3).map((entry) => summarizeArtifact(entry)).filter(Boolean); return items.length > 0 ? items.join(", ") : "Array value"; } if (!value || typeof value !== "object") { return "No review artifact available."; } const record = value; const preferredKeys = [ "summary", "output", "storyMarkdown", "chapterMarkdown", "stdout", "stderr", "paragraph", "objective", "feedback_reason", "feedbackReason", ]; for (const key of preferredKeys) { const candidate = record[key]; if (typeof candidate === "string" && candidate.trim()) { return summarizeText(candidate); } } const primitives = Object.entries(record) .filter(([, candidate]) => typeof candidate === "string" || typeof candidate === "number" || typeof candidate === "boolean") .slice(0, 4) .map(([key, candidate]) => `${key}=${summarizeText(String(candidate), 60)}`); if (primitives.length > 0) { return primitives.join(", "); } const keys = Object.keys(record).slice(0, 5); return keys.length > 0 ? `Object with keys: ${keys.join(", ")}` : "No review artifact available."; } function buildApprovalPreview(step, stepRuns, previousOutput, currentOutput) { const items = []; if (currentOutput && Object.keys(currentOutput).length > 0 && step.kind !== "approval") { items.push({ stepKey: step.key, title: `Output from ${step.key}`, summary: summarizeArtifact(currentOutput), source: "current_step", status: stepRuns.get(step.key)?.status, }); } for (const dependency of step.dependsOn ?? []) { items.push({ stepKey: dependency, title: `Dependency ${dependency}`, summary: summarizeArtifact(previousOutput[dependency]), source: "dependency", status: stepRuns.get(dependency)?.status, }); } if (items.length === 0) { items.push({ stepKey: null, title: step.kind === "approval" ? "Manual approval gate" : "No review artifact", summary: step.kind === "approval" ? "This step is a manual checkpoint. Approving it continues the workflow." : "This step did not emit a review artifact. Approving it continues the workflow.", source: "approval_gate", }); } const summary = step.kind === "approval" ? `Approve this gate to continue after ${items .filter((item) => item.stepKey) .map((item) => item.stepKey) .join(", ") || "the previous step"}.` : `Approve the results of ${step.key} before the workflow continues.`; return { stepLabel: stepLabel(step), objective: step.objective ?? step.title ?? null, summary, items, }; } export async function promptForApprovalDecision(stepKey, reason, validation, preview, actor, signal) { if (!process.stdin.isTTY) return null; const rl = createInterface({ input: process.stdin, output: process.stderr }); const decisionVerb = validation === "external" ? "Resume" : "Approve"; const positiveAnswers = new Set(["a", "approve", "y", "yes", "r", "resume"]); const negativeAnswers = new Set(["c", "cancel", "n", "no"]); const render = () => { process.stderr.write(`\n${decisionVerb} required for ${stepKey}\n`); process.stderr.write(`- Reason: ${reason}\n`); process.stderr.write(`- Validation: ${validation}\n`); if (preview) { process.stderr.write(`- Step: ${preview.stepLabel}\n`); process.stderr.write(`- What you are deciding: ${preview.summary}\n`); for (const item of preview.items) { const status = item.status ? ` [${item.status}]` : ""; process.stderr.write(` - ${item.title}${status}: ${item.summary}\n`); } } }; render(); return new Promise((resolve) => { let settled = false; const finish = (value) => { if (settled) { return; } settled = true; signal?.removeEventListener("abort", onAbort); rl.close(); process.stdin.resume(); resolve(value); }; const onAbort = () => { finish(null); }; signal?.addEventListener("abort", onAbort, { once: true }); const ask = () => { rl.question(`${decisionVerb} now? [a]pprove/[r]esume / [c]ancel / [v]iew: `, (answer) => { if (signal?.aborted) { finish(null); return; } const normalized = answer.trim().toLowerCase(); if (positiveAnswers.has(normalized)) { finish({ decision: "approved", actor, source: "terminal" }); return; } if (negativeAnswers.has(normalized)) { finish({ decision: "cancelled", actor, source: "terminal", note: "cancelled in terminal" }); return; } if (normalized === "v" || normalized === "view" || normalized === "") { render(); ask(); return; } process.stderr.write("Enter 'a'/'r' to continue, 'c' to cancel, or 'v' to reprint the decision details.\n"); ask(); }); }; if (signal?.aborted) { finish(null); return; } ask(); }); } export async function executeStep(step, input, attempt, workflow, workflowFilePath, hooks) { if (step.kind !== "task") { return executeMockStep(step, input, attempt, hooks); } const adapterKey = resolveTaskAdapter(step.taskSpec?.adapterKey); if (adapterKey === "pi-agent") { return executePiAgentStep(step, input, attempt, workflow, workflowFilePath, hooks); } // Legacy escape hatch: the bespoke claude-code subprocess executor is deprecated // in favor of ACP, but stays reachable via payload.legacyExecutor so existing // real-integration users are not stranded. const legacyExecutor = step.taskSpec?.payload?.legacyExecutor === true; if (legacyExecutor && adapterKey === "claude-code" && shouldUseRealClaudeCode(step)) { return executeClaudeCodeStep(step, input, attempt, workflow, workflowFilePath, hooks); } // All non-pi agents run through ACP. opencode runs real by default (opt out with // payload.useRealAdapter: false); acp / claude-code / codex opt in with // payload.useRealAdapter: true. const acpRoutable = adapterKey === "acp" || adapterKey === "claude-code" || adapterKey === "opencode" || adapterKey === "codex"; if (acpRoutable && shouldUseRealAcp(step)) { return executeAcpStep(step, input, attempt, workflow, workflowFilePath, hooks); } const fallbackReason = adapterMockFallbackReason(step); if (fallbackReason) { hooks?.onStderr?.(`[wfm] ${step.key}: ${fallbackReason}\n`); } return executeMockStep(step, input, attempt, hooks); } export async function runWorkflow(definition, options) { const runId = options?.runId ?? randomUUID(); const actor = options?.actor ?? "cli"; const primaryObjective = options?.objective ?? definition.title; const workflowObjectives = definition.objectives ?? []; const globalState = { ...(options?.input ?? {}) }; const workflowFilePath = options?.workflowFilePath ?? ""; const eventLog = new EventLog(); const observer = options?.observer; const controller = options?.controller; let runStatus = "queued"; let currentStepKey = null; let runStartedAt = null; let runUpdatedAt = new Date().toISOString(); let runEndedAt = null; let waitingForApproval = null; const stepRuns = new Map(); const stepRuntime = new Map(); const emptyExecution = () => ({ executionStatus: null, qaAction: null, feedbackReason: null, contextMetrics: null, }); const touchRun = (ended = false) => { const now = new Date().toISOString(); runUpdatedAt = now; if (ended) { runEndedAt = now; } }; const touchStep = (stepKey, patch) => { const current = stepRuntime.get(stepKey); if (!current) { return; } const now = new Date().toISOString(); if (patch.startedAt !== undefined) current.startedAt = patch.startedAt; if (patch.finishedAt !== undefined) current.finishedAt = patch.finishedAt; if (patch.lastExecution) current.lastExecution = { ...patch.lastExecution }; current.updatedAt = patch.updatedAt ?? now; touchRun(false); }; const buildStepDetails = () => definition.steps.map((step) => { const run = stepRuns.get(step.key); const runtime = stepRuntime.get(step.key); return { stepKey: step.key, status: run.status, attempt: run.attempt, confirmed: run.confirmed, adapter: stepAdapter(step), startedAt: runtime.startedAt, updatedAt: runtime.updatedAt, finishedAt: runtime.finishedAt, kind: step.kind, objective: step.objective ?? step.title ?? null, dependsOn: step.dependsOn ?? [], config: { model: typeof step.taskSpec?.init?.model === "string" ? step.taskSpec.init.model : null, skills: step.taskSpec?.init?.skills ?? [], mcps: step.taskSpec?.init?.mcps ?? [], systemPrompts: step.taskSpec?.init?.systemPrompts ?? [], contextSummary: summarizeContext(step.taskSpec?.init?.context), }, lastExecution: { ...runtime.lastExecution }, }; }); const buildSnapshot = () => ({ runId, workflowKey: definition.key, workflowTitle: definition.title, status: runStatus, currentStepKey, startedAt: runStartedAt, updatedAt: runUpdatedAt, endedAt: runEndedAt, objective: primaryObjective, objectives: [...workflowObjectives], waitingForApproval: waitingForApproval ? { ...waitingForApproval } : null, steps: definition.steps.map((step) => { const run = stepRuns.get(step.key); const runtime = stepRuntime.get(step.key); return { stepKey: step.key, status: run.status, attempt: run.attempt, confirmed: run.confirmed, adapter: stepAdapter(step), startedAt: runtime.startedAt, updatedAt: runtime.updatedAt, finishedAt: runtime.finishedAt, }; }), }); const emitSnapshot = () => { observer?.onSnapshot(buildSnapshot(), buildStepDetails()); }; const pushEvent = (type, payload = {}, stepKey, eventActor = "system") => { const event = eventLog.push(runId, type, payload, stepKey, eventActor); observer?.onEvent(event); }; const emitLog = (stepKey, stream, text) => { observer?.onLog({ id: randomUUID(), runId, stepKey, stream, text, occurredAt: new Date().toISOString(), }); }; const waitForDecision = async (step, stepRun, reason, validation, requireConfirmationEvent, preview = null) => { stepRun.status = "waiting_for_approval"; runStatus = "waiting_for_approval"; waitingForApproval = { stepKey: step.key, reason, validation, preview, }; touchStep(step.key, { finishedAt: null }); pushEvent("step.waiting_for_approval", { reason, validation, preview }, step.key); pushEvent("run.waiting_for_approval", { reason, preview }, step.key, actor); emitSnapshot(); const request = { stepKey: step.key, reason, validation }; let resolution = null; if (controller) { const promptAbort = new AbortController(); const promptTask = options?.approvalPrompt ? options.approvalPrompt({ ...request, preview, signal: promptAbort.signal }).catch((error) => { if (promptAbort.signal.aborted) { return null; } throw error; }) : null; try { resolution = await controller.waitForDecision(request); } finally { promptAbort.abort(); await promptTask; } } else if (options?.approvalPrompt) { resolution = await options.approvalPrompt({ ...request, preview }); } else if (options?.interactive && process.stdin.isTTY) { resolution = await promptForApprovalDecision(step.key, reason, validation, preview, actor); } if (!resolution) { return null; } const resolutionActor = resolution.actor?.trim() ? resolution.actor : actor; pushEvent("approval.resolved", { decision: resolution.decision, actor: resolutionActor, note: resolution.note ?? null, source: resolution.source ?? "api", }, step.key, resolutionActor); if (resolution.decision === "cancelled") { stepRun.status = "cancelled"; runStatus = "cancelled"; currentStepKey = step.key; waitingForApproval = null; touchStep(step.key, { finishedAt: new Date().toISOString() }); touchRun(true); pushEvent("run.cancelled", { stepKey: step.key, reason: resolution.note ?? "cancelled by API", source: resolution.source ?? "api" }, step.key, resolutionActor); emitSnapshot(); return resolution; } waitingForApproval = null; runStatus = "running"; if (requireConfirmationEvent) { stepRun.confirmed = true; pushEvent("step.confirmed", { by: resolutionActor, validation, note: resolution.note ?? null, source: resolution.source ?? "api" }, step.key, resolutionActor); } emitSnapshot(); return resolution; }; // Applies a QA rejection's routing action (RETRY_CURRENT / ROLLBACK_PREVIOUS / RESTART_ALL / // unknown) to `step`, mutating run/step state exactly the way a step's own self-reported // QA_REJECTED output would. Shared by the direct QA_REJECTED path and by agent validation, // which routes its verdict through the same machinery. const applyQaRejection = (step, stepRun, index, qaAction, feedbackReason) => { const retryMax = step.retryPolicy?.maxAttempts ?? definition.defaultRetryPolicy?.maxAttempts ?? 1; if (qaAction === "RETRY_CURRENT") { if (stepRun.attempt < retryMax) { stepRun.status = "pending"; touchStep(step.key, { finishedAt: new Date().toISOString() }); pushEvent("step.retried", { stepKey: step.key, attempt: stepRun.attempt + 1 }, step.key); emitSnapshot(); return { index }; } stepRun.status = "failed"; runStatus = "failed"; currentStepKey = step.key; touchStep(step.key, { finishedAt: new Date().toISOString() }); touchRun(true); pushEvent("run.failed", { stepKey: step.key, reason: feedbackReason ? `max retry exceeded: ${feedbackReason}` : "max retry exceeded" }, step.key); emitSnapshot(); return { stop: true }; } if (qaAction === "ROLLBACK_PREVIOUS") { if (index === 0) { stepRun.status = "failed"; runStatus = "failed"; currentStepKey = step.key; touchStep(step.key, { finishedAt: new Date().toISOString() }); touchRun(true); pushEvent("run.failed", { stepKey: step.key, reason: feedbackReason ? `cannot rollback before first step: ${feedbackReason}` : "cannot rollback before first step", }, step.key); emitSnapshot(); return { stop: true }; } const prevStep = orderedSteps[index - 1]; const prevRun = stepRuns.get(prevStep.key); prevRun.status = "pending"; prevRun.attempt = 0; prevRun.confirmed = false; delete prevRun.output; delete globalState[prevStep.key]; delete stepRun.output; delete globalState[step.key]; touchStep(prevStep.key, { startedAt: null, finishedAt: null, lastExecution: emptyExecution() }); pushEvent("step.retried", { stepKey: prevStep.key, via: step.key }, prevStep.key); stepRun.status = "pending"; stepRun.confirmed = false; touchStep(step.key, { finishedAt: new Date().toISOString() }); emitSnapshot(); return { index: index - 1 }; } if (qaAction === "RESTART_ALL") { for (const s of definition.steps) { const sr = stepRuns.get(s.key); sr.status = "pending"; sr.attempt = 0; sr.confirmed = false; delete sr.output; delete globalState[s.key]; touchStep(s.key, { startedAt: null, finishedAt: null, lastExecution: emptyExecution() }); } currentStepKey = null; pushEvent("step.retried", { mode: "restart_all", triggeredBy: step.key }, step.key); emitSnapshot(); return { index: 0 }; } stepRun.status = "failed"; runStatus = "failed"; currentStepKey = step.key; touchStep(step.key, { finishedAt: new Date().toISOString() }); touchRun(true); pushEvent("run.failed", { stepKey: step.key, reason: `Unknown QA action: ${qaAction}` }, step.key); emitSnapshot(); return { stop: true }; }; // Runs the step's configured validator agent against its just-produced output and folds the // verdict back into the SAME routing machinery a self-reported QA_REJECTED would use. Runs // unconditionally for validation.mode "agent" — autoConfirmAll/confirmations never skip it, // since it is a QA gate, not a human approval. const runAgentValidation = async (step, stepRun, index, input, executionOutput) => { const agentSpec = step.validation?.agent ?? {}; const resolvedValidator = resolveValidatorAgentSpec(step) ?? { adapterKey: agentSpec.adapterKey ?? resolveTaskAdapter(step.taskSpec?.adapterKey), payload: agentSpec.payload ?? {}, }; const validatorAdapter = resolvedValidator.adapterKey; const criteria = agentSpec.criteria; const objective = criteria ? `Validate the output of step "${step.key}": ${criteria}` : `Validate the output of step "${step.key}"`; const validatorStep = { key: step.key, kind: "task", title: step.title, objective, dependsOn: step.dependsOn, taskSpec: { adapterKey: validatorAdapter, init: agentSpec.init, payload: resolvedValidator.payload, }, }; const validatorInput = { global_context: { ...input.global_context, global_state: agentSpec.init?.stateFrom !== undefined ? projectGlobalState(globalState, agentSpec.init.stateFrom) : input.global_context.global_state, }, step_context: { step_id: step.key, step_objective: objective, previous_output: { [step.key]: executionOutput.mutated_payload }, assigned_node_type: "AGENT", }, priming_configuration: { required_skills: agentSpec.init?.skills ?? [], mcp_endpoints: agentSpec.init?.mcps ?? [], system_prompts: agentSpec.init?.systemPrompts ?? [], context: agentSpec.init?.context, adapter: validatorAdapter, model: agentSpec.init?.model, }, }; pushEvent("step.validation_started", { adapter: validatorAdapter, criteria: criteria ?? null }, step.key); emitSnapshot(); const validationHooks = { onStarted: (payload) => { pushEvent("agent.started", { attempt: stepRun.attempt, validator: true, ...(payload ?? {}) }, step.key); }, onStdout: (chunk) => { emitLog(step.key, "stdout", chunk); pushEvent("agent.stdout", { stream: "stdout", text: chunk }, step.key); }, onStderr: (chunk) => { emitLog(step.key, "stderr", chunk); pushEvent("agent.stderr", { stream: "stderr", text: chunk }, step.key); }, onFinished: (payload) => { pushEvent("agent.finished", { attempt: stepRun.attempt, validator: true, ...(payload ?? {}) }, step.key); }, }; const validatorOutput = validatedExecutorOutput(validatorStep, validatorInput, stepRun.attempt, await executeStep(validatorStep, validatorInput, stepRun.attempt, definition, workflowFilePath, validationHooks)); pushEvent("step.validation_finished", { status: validatorOutput.execution_status, action: validatorOutput.qa_routing.action, feedbackReason: validatorOutput.qa_routing.feedback_reason, }, step.key); emitSnapshot(); if (validatorOutput.execution_status === "SUCCESS" && validatorOutput.qa_routing.action === "PROCEED") { return { type: "proceed" }; } if (validatorOutput.execution_status === "FAILED" || validatorOutput.execution_status === "YIELD_EXTERNAL") { const reason = `agent validation failed: ${validatorOutput.qa_routing.feedback_reason || validatorOutput.execution_status}`; stepRun.status = "failed"; runStatus = "failed"; currentStepKey = step.key; touchStep(step.key, { finishedAt: new Date().toISOString(), lastExecution: { executionStatus: "FAILED", qaAction: "PROCEED", feedbackReason: reason, contextMetrics: stepRuntime.get(step.key)?.lastExecution.contextMetrics ?? null, }, }); touchRun(true); pushEvent("run.failed", { stepKey: step.key, reason }, step.key); emitSnapshot(); return { type: "stop" }; } // QA_REJECTED (any action), or SUCCESS with a non-PROCEED action: route the validator's // verdict onto the validated step exactly like a self-reported QA rejection. const qaAction = String(validatorOutput.qa_routing.action); const feedbackReason = validatorOutput.qa_routing.feedback_reason || `agent validation rejected step ${step.key}`; touchStep(step.key, { lastExecution: { executionStatus: "QA_REJECTED", qaAction: isQaAction(qaAction) ? qaAction : "PROCEED", feedbackReason, contextMetrics: stepRuntime.get(step.key)?.lastExecution.contextMetrics ?? null, }, }); const outcome = applyQaRejection(step, stepRun, index, qaAction, feedbackReason); if ("stop" in outcome && outcome.stop) { return { type: "stop" }; } return { type: "reindex", index: outcome.index }; }; for (const step of definition.steps) { stepRuns.set(step.key, { stepKey: step.key, status: "pending", attempt: 0, confirmed: false, }); stepRuntime.set(step.key, { startedAt: null, updatedAt: runUpdatedAt, finishedAt: null, lastExecution: emptyExecution(), }); } emitSnapshot(); pushEvent("run.created", { workflowKey: definition.key }, undefined, actor); const runtimeErrors = validateRuntimeRequirements(definition); if (runtimeErrors.length > 0) { runStatus = "failed"; touchRun(true); pushEvent("run.failed", { reason: runtimeErrors.join("; "), runtimeErrors }, undefined, actor); emitSnapshot(); return { runId, status: runStatus, outputs: globalState, stepRuns: definition.steps.map((step) => stepRuns.get(step.key)), events: eventLog.all(), }; } let orderedSteps; try { orderedSteps = orderStepsByDependencies(definition.steps); } catch (err) { runStatus = "failed"; touchRun(true); pushEvent("run.failed", { reason: err.message }, undefined, actor); emitSnapshot(); return { runId, status: runStatus, outputs: globalState, stepRuns: definition.steps.map((step) => stepRuns.get(step.key)), events: eventLog.all(), }; } runStatus = "running"; runStartedAt = new Date().toISOString(); touchRun(false); pushEvent("run.started", { objective: primaryObjective, objectives: workflowObjectives }, undefined, actor); emitSnapshot(); let index = 0; let guard = 0; const maxSteps = Math.max(definition.steps.length * 30, 30); while (index < orderedSteps.length) { guard += 1; if (guard > maxSteps) { runStatus = "failed"; touchRun(true); pushEvent("run.failed", { reason: "Execution guard exceeded" }); emitSnapshot(); break; } const step = orderedSteps[index]; const stepRun = stepRuns.get(step.key); if (!stepRun) throw new Error(`Missing step run for ${step.key}`); const dependencies = step.dependsOn ?? []; const depsComplete = dependencies.every((depKey) => stepRuns.get(depKey)?.status === "succeeded"); if (!depsComplete) { runStatus = "failed"; currentStepKey = step.key; touchRun(true); pushEvent("run.failed", { reason: `Dependencies not satisfied for ${step.key}` }, step.key); emitSnapshot(); break; } stepRun.status = "runnable"; currentStepKey = step.key; waitingForApproval = null; touchStep(step.key, { finishedAt: null }); pushEvent("step.runnable", { stepKey: step.key }, step.key); emitSnapshot(); stepRun.status = "running"; stepRun.attempt += 1; delete stepRun.output; delete globalState[step.key]; touchStep(step.key, { startedAt: new Date().toISOString(), finishedAt: null }); pushEvent("step.claimed", { attempt: stepRun.attempt }, step.key); pushEvent("step.execution_started", { attempt: stepRun.attempt }, step.key); emitSnapshot(); const previousOutput = {}; for (const dep of dependencies) { previousOutput[dep] = stepRuns.get(dep)?.output ?? null; } const inputEnvelope = { global_context: { workflow_id: runId, primary_objective: primaryObjective, workflow_objectives: workflowObjectives, global_state: projectGlobalState(globalState, step.taskSpec?.init?.stateFrom), }, step_context: { step_id: step.key, step_objective: stepObjective(step, primaryObjective), previous_output: previousOutput, assigned_node_type: nodeType(step), }, priming_configuration: { required_skills: step.taskSpec?.init?.skills ?? [], mcp_endpoints: step.taskSpec?.init?.mcps ?? [], system_prompts: step.taskSpec?.init?.systemPrompts ?? [], context: step.taskSpec?.init?.context, adapter: resolveTaskAdapter(step.taskSpec?.adapterKey), model: step.taskSpec?.init?.model, }, }; const hooks = { onStarted: (payload) => { pushEvent("agent.started", { attempt: stepRun.attempt, ...(payload ?? {}) }, step.key); }, onStdout: (chunk) => { emitLog(step.key, "stdout", chunk); pushEvent("agent.stdout", { stream: "stdout", text: chunk }, step.key); }, onStderr: (chunk) => { emitLog(step.key, "stderr", chunk); pushEvent("agent.stderr", { stream: "stderr", text: chunk }, step.key); }, onFinished: (payload) => { pushEvent("agent.finished", { attempt: stepRun.attempt, ...(payload ?? {}) }, step.key); }, }; const output = validatedExecutorOutput(step, inputEnvelope, stepRun.attempt, await executeStep(step, inputEnvelope, stepRun.attempt, definition, workflowFilePath, hooks)); const contextMetrics = extractContextMetrics(output.mutated_payload); touchStep(step.key, { lastExecution: { executionStatus: output.execution_status, qaAction: output.qa_routing.action, feedbackReason: output.qa_routing.feedback_reason, contextMetrics, }, }); pushEvent("step.execution_finished", { status: output.execution_status, action: output.qa_routing.action, feedbackReason: output.qa_routing.feedback_reason, adapter: resolveTaskAdapter(step.taskSpec?.adapterKey), contextMetrics, init: { skills: step.taskSpec?.init?.skills ?? [], mcps: step.taskSpec?.init?.mcps ?? [], context: step.taskSpec?.init?.context ?? {}, systemPrompts: step.taskSpec?.init?.systemPrompts ?? [], model: step.taskSpec?.init?.model ?? null, }, }, step.key); emitSnapshot(); const approvalPreview = buildApprovalPreview(step, stepRuns, previousOutput, output.mutated_payload); const confirmed = canConfirm(step, options ?? {}, output).ok; if (!confirmed) { const validation = requiresValidation(step); const decision = await waitForDecision(step, stepRun, `confirmation required for ${step.key}`, validation, true, approvalPreview); if (decision === null || decision.decision === "cancelled") { break; } } if (!stepRun.confirmed) { stepRun.confirmed = true; waitingForApproval = null; pushEvent("step.confirmed", { by: actor, validation: requiresValidation(step) }, step.key, actor); emitSnapshot(); } if (output.execution_status === "YIELD_EXTERNAL") { if (step.kind === "approval") { stepRun.status = "succeeded"; runStatus = "running"; waitingForApproval = null; stepRun.output = output.mutated_payload; globalState[step.key] = output.mutated_payload; touchStep(step.key, { finishedAt: new Date().toISOString() }); currentStepKey = null; emitSnapshot(); index += 1; continue; } const decision = await waitForDecision(step, stepRun, "external intervention", "external", false, approvalPreview); if (decision === null || decision.decision === "cancelled") { break; } stepRun.status = "succeeded"; runStatus = "running"; waitingForApproval = null; stepRun.output = output.mutated_payload; globalState[step.key] = output.mutated_payload; touchStep(step.key, { finishedAt: new Date().toISOString() }); currentStepKey = null; emitSnapshot(); index += 1; continue; } if (output.execution_status === "FAILED") { stepRun.status = "failed"; runStatus = "failed"; currentStepKey = step.key; touchStep(step.key, { finishedAt: new Date().toISOString() }); touchRun(true); pushEvent("run.failed", { stepKey: step.key, reason: "step failed" }, step.key); emitSnapshot(); break; } if (output.execution_status === "QA_REJECTED") { const outcome = applyQaRejection(step, stepRun, index, String(output.qa_routing.action), output.qa_routing.feedback_reason); if ("stop" in outcome && outcome.stop) { break; } index = outcome.index; continue; } if (step.kind === "task" && output.execution_status === "SUCCESS" && output.qa_routing.action === "PROCEED" && requiresValidation(step) === "agent") { const validationOutcome = await runAgentValidation(step, stepRun, index, inputEnvelope, output); if (validationOutcome.type === "stop") { break; } if (validationOutcome.type === "reindex") { index = validationOutcome.index; continue; } // "proceed": validator approved — fall through to the normal success path below. } stepRun.status = "succeeded"; stepRun.output = output.mutated_payload; globalState[step.key] = output.mutated_payload; touchStep(step.key, { finishedAt: new Date().toISOString() }); currentStepKey = null; emitSnapshot(); index += 1; } if (runStatus === "running") { runStatus = "succeeded"; currentStepKey = null; touchRun(true); pushEvent("run.completed", { steps: definition.steps.length }, undefined, actor); emitSnapshot(); } return { runId, status: runStatus, outputs: globalState, stepRuns: definition.steps.map((step) => stepRuns.get(step.key)), events: eventLog.all(), }; }