UNPKG

@workflow-manager/runner

Version:

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

325 lines (324 loc) 13.6 kB
import fs from "node:fs"; import path from "node:path"; import { isRealByDefaultAcpAdapter, resolveAcpCommand, shouldUseRealAcp } from "./acpExecutor.js"; import { resolveTaskAdapter, resolveValidatorAgentSpec } from "./adapters.js"; import { shouldUseRealClaudeCode } from "./claudeCodeExecutor.js"; import { DEFAULT_PI_COMMAND } from "./piAgentExecutor.js"; const ACP_ROUTABLE_ADAPTERS = new Set(["acp", "claude-code", "opencode", "codex", "kimi", "gemini", "qwen"]); function legacyExecutorEnabled(step) { return asRecord(step.taskSpec?.payload).legacyExecutor === true; } function asRecord(value) { return value && typeof value === "object" && !Array.isArray(value) ? value : {}; } function isExecutable(filePath) { try { const stat = fs.statSync(filePath); if (!stat.isFile()) { return false; } fs.accessSync(filePath, fs.constants.X_OK); return true; } catch { return false; } } function commandExists(command, env) { if (command.includes("/") || command.includes("\\")) { return isExecutable(path.resolve(command)); } const pathValue = env.PATH ?? ""; const extensions = process.platform === "win32" ? (env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";").filter(Boolean) : [""]; for (const directory of pathValue.split(path.delimiter)) { if (!directory) { continue; } for (const extension of extensions) { if (isExecutable(path.join(directory, `${command}${extension}`))) { return true; } } } return false; } function piAgentCommand(step, env) { const payload = asRecord(step.taskSpec?.payload); if (typeof payload.command === "string" && payload.command.trim()) { return payload.command; } if (env.WFM_PI_AGENT_COMMAND?.trim()) { return env.WFM_PI_AGENT_COMMAND; } return DEFAULT_PI_COMMAND; } function requiredEnvFromModel(model) { if (typeof model !== "string" || !model.trim()) { return null; } const normalized = model.trim().toLowerCase(); if (normalized.startsWith("openrouter/")) { return "OPENROUTER_API_KEY"; } if (normalized.startsWith("openai/") || normalized.startsWith("gpt-") || normalized.includes("/gpt-")) { return "OPENAI_API_KEY"; } if (normalized.startsWith("anthropic/") || normalized.startsWith("claude-") || normalized.includes("/claude")) { return "ANTHROPIC_API_KEY"; } return null; } function explicitRequiredEnv(step) { const payload = asRecord(step.taskSpec?.payload); const required = new Set(); if (Array.isArray(payload.requiredEnv)) { for (const value of payload.requiredEnv) { if (typeof value === "string" && value.trim()) { required.add(value.trim()); } } } return [...required].sort(); } function requiredEnvVars(step) { const payload = asRecord(step.taskSpec?.payload); const required = new Set(explicitRequiredEnv(step)); const fromInitModel = requiredEnvFromModel(step.taskSpec?.init?.model); const fromPayloadModel = requiredEnvFromModel(payload.model); if (fromInitModel) required.add(fromInitModel); if (fromPayloadModel) required.add(fromPayloadModel); return [...required].sort(); } function runtimeRequirement(step, env) { if (step.kind !== "task") { return null; } const adapter = resolveTaskAdapter(step.taskSpec?.adapterKey); const envVars = requiredEnvVars(step); if (adapter === "pi-agent") { // pi manages provider credentials in its own auth store, so only // explicitly declared env vars are enforced for pi steps. return { stepKey: step.key, adapter, command: piAgentCommand(step, env), envVars: explicitRequiredEnv(step) }; } const legacy = legacyExecutorEnabled(step); if (legacy && adapter === "claude-code" && shouldUseRealClaudeCode(step)) { return { stepKey: step.key, adapter, command: "claude", envVars }; } if (shouldUseRealAcp(step)) { // ACP agents manage their own credentials (like pi), so only explicitly declared // env vars are enforced; the resolved agent command must exist on the host. return { stepKey: step.key, adapter, command: resolveAcpCommand(step, env)?.command, envVars: explicitRequiredEnv(step), }; } if (envVars.length > 0 && adapter !== "mock") { return { stepKey: step.key, adapter, envVars }; } return null; } // Synthesizes a pseudo-step for a step's agent validator (validation.mode "agent"), mirroring // the adapter/payload the engine will actually use to run it (see resolveValidatorAgentSpec). // Returns null when the step has no agent validator. The `(validator)` suffix on the key is // intentional labeling so preflight errors/warnings distinguish the validator from the step. function validatorPseudoStep(step) { const validatorSpec = resolveValidatorAgentSpec(step); if (!validatorSpec) { return null; } return { key: `${step.key} (validator)`, kind: "task", taskSpec: { adapterKey: validatorSpec.adapterKey, payload: validatorSpec.payload }, }; } function collectRuntimeRequirementErrors(step, env, errors) { const requirement = runtimeRequirement(step, env); if (!requirement) { return; } if (requirement.command && !commandExists(requirement.command, env)) { errors.push(`Step ${requirement.stepKey} requires ${requirement.adapter} command "${requirement.command}", but it is not installed or not executable on this host`); } for (const envVar of requirement.envVars) { if (!env[envVar]?.trim()) { errors.push(`Step ${requirement.stepKey} requires ${envVar} for ${requirement.adapter} LLM access`); } } } export function validateRuntimeRequirements(definition, env = process.env) { const errors = []; for (const step of definition.steps) { collectRuntimeRequirementErrors(step, env, errors); const validatorStep = validatorPseudoStep(step); if (validatorStep) { collectRuntimeRequirementErrors(validatorStep, env, errors); } } return errors; } function commandCheck(key, label, command, required, env) { const ok = commandExists(command, env); return { key, label, status: ok ? "ok" : "missing", required, detail: ok ? `${command} is executable` : `${command} is not installed or not executable on this host`, }; } function envCheck(key, label, envVar, env) { const ok = !!env[envVar]?.trim(); return { key, label, status: ok ? "ok" : "missing", required: false, detail: ok ? `${envVar} is set` : `${envVar} is not set`, }; } export function runtimeDoctorChecks(env = process.env) { const piAgentStep = { key: "pi-agent", kind: "task", taskSpec: {} }; const acpCommand = env.WFM_ACP_COMMAND?.trim(); const acpCheck = acpCommand ? commandCheck("acp", "ACP agent command", acpCommand, false, env) : { key: "acp", label: "ACP agent command", status: "info", required: false, detail: "configured per step (payload.acpCommand / acpAgent) or via WFM_ACP_COMMAND", }; return [ commandCheck("pi-agent", "Pi command", piAgentCommand(piAgentStep, env), true, env), acpCheck, commandCheck("codex-acp", "Codex ACP bridge", "codex-acp", false, env), commandCheck("opencode", "OpenCode command", "opencode", false, env), commandCheck("claude", "Claude Code command (legacy)", "claude", false, env), commandCheck("kimi", "Kimi CLI", "kimi", false, env), commandCheck("gemini", "Gemini CLI", "gemini", false, env), commandCheck("qwen", "Qwen Code CLI", "qwen", false, env), envCheck("openrouter-key", "OpenRouter API key", "OPENROUTER_API_KEY", env), envCheck("openai-key", "OpenAI API key", "OPENAI_API_KEY", env), envCheck("anthropic-key", "Anthropic API key", "ANTHROPIC_API_KEY", env), ]; } /** * Detects a step that explicitly selects a non-pi adapter whose real execution * path is not enabled, so the engine will route it to the mock executor. Non-pi * agents run through ACP: opencode runs real by default (needs a resolvable agent * command; opt out with `useRealAdapter: false`), while acp/claude-code/codex need * `useRealAdapter: true` plus a resolvable agent command. Returns a message * explaining the gap, or null when the step will run as the user expects (default * pi-agent, an enabled ACP/legacy path, or an intentional mock selection). */ export function adapterMockFallbackReason(step) { if (step.kind !== "task" || !step.taskSpec?.adapterKey) { return null; } const adapter = resolveTaskAdapter(step.taskSpec.adapterKey); if (!ACP_ROUTABLE_ADAPTERS.has(adapter)) { return null; } const legacy = legacyExecutorEnabled(step); if (legacy && adapter === "claude-code" && shouldUseRealClaudeCode(step)) { return null; } if (shouldUseRealAcp(step)) { return null; } const payload = asRecord(step.taskSpec?.payload); if (isRealByDefaultAcpAdapter(adapter)) { if (payload.useRealAdapter === false) { return null; // explicit opt-out to mock is intentional } // Real by default, but no ACP agent command resolved (e.g. payload.acpAgent // names an agent with no preset), so the step would silently mock. return `adapterKey '${adapter}' runs real by default, but no ACP agent command could be resolved, so the step runs as a mock. Set taskSpec.payload.acpCommand or acpAgent (or WFM_ACP_COMMAND), or set useRealAdapter: false to mock intentionally.`; } if (payload.useRealAdapter === true) { // The user opted into a real run but no ACP agent command could be resolved. return `adapterKey '${adapter}' has useRealAdapter set, but no ACP agent command could be resolved, so the step runs as a mock. Set taskSpec.payload.acpCommand or acpAgent (or WFM_ACP_COMMAND) to run it through ACP.`; } if (resolveAcpCommand(step, process.env) !== null) { // A concrete agent is named (a preset like claude-code/opencode, or an explicit // command) but useRealAdapter is off, so the step still mocks. return `adapterKey '${adapter}' is set, but useRealAdapter is not enabled, so the step runs as a mock. Set taskSpec.payload.useRealAdapter: true to run it through ACP.`; } // Bare acp with no agent configured is treated as an intentional mock. return null; } export function adapterMockFallbackWarnings(definition) { const warnings = []; for (const step of definition.steps) { const message = adapterMockFallbackReason(step); if (message) { warnings.push({ stepKey: step.key, adapter: resolveTaskAdapter(step.taskSpec?.adapterKey), message }); } const validatorStep = validatorPseudoStep(step); if (validatorStep) { const validatorMessage = adapterMockFallbackReason(validatorStep); if (validatorMessage) { warnings.push({ stepKey: validatorStep.key, adapter: resolveTaskAdapter(validatorStep.taskSpec?.adapterKey), message: validatorMessage, }); } } } return warnings; } export function adapterImplementationStatuses() { return [ { adapter: "pi-agent", status: "real", detail: "default host-backed adapter driving the pi coding agent CLI", }, { adapter: "acp", status: "real", detail: "Agent Client Protocol adapter; runs any ACP agent (via acpCommand/acpAgent) when useRealAdapter is true", }, { adapter: "mock", status: "mock", detail: "deterministic in-process simulator for tests and local authoring", }, { adapter: "opencode", status: "real", detail: "runs real by default through ACP (opencode acp); requires the opencode CLI; set taskSpec.payload.useRealAdapter: false to mock", }, { adapter: "codex", status: "partial", detail: "routed through ACP via the codex-acp bridge when useRealAdapter is true; otherwise mock", }, { adapter: "claude-code", status: "partial", detail: "routed through ACP when useRealAdapter is true; bespoke executor deprecated (payload.legacyExecutor)", }, { adapter: "kimi", status: "real", detail: "routed through ACP via the kimi CLI's native 'kimi acp' mode when useRealAdapter is true; kimi manages its own auth (kimi CLI's own /login flow)", }, { adapter: "gemini", status: "real", detail: "routed through ACP via the Gemini CLI's native 'gemini --acp' mode when useRealAdapter is true; Gemini CLI manages its own auth", }, { adapter: "qwen", status: "real", detail: "routed through ACP via the Qwen Code CLI's native 'qwen --acp --experimental-skills' mode when useRealAdapter is true; Qwen Code manages its own auth", }, ]; }