@workflow-manager/runner 0.4.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/acpExecutor.d.ts +16 -0
- package/dist/acpExecutor.js +311 -0
- package/dist/adapters.js +1 -0
- package/dist/engine.js +12 -2
- package/dist/index.js +26 -0
- package/dist/runtimePreflight.d.ts +6 -6
- package/dist/runtimePreflight.js +67 -18
- package/dist/types.d.ts +1 -1
- package/package.json +4 -3
- package/skills/workflow-manager-cli/SKILL.md +4 -3
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
The core ideas:
|
|
6
6
|
|
|
7
7
|
- **Workflow definitions.** A workflow is a list of steps with stable keys, objectives, and `dependsOn` edges (the engine topologically orders them). Each step is one of three kinds: `task` (delegated to an agent), `approval` (a human checkpoint), or `system`. Steps can carry retry policies, timeouts, and validation rules (`human`, `external`, or `none`).
|
|
8
|
-
- **Agent adapters.** Each task step picks an adapter that does the actual work: `pi-agent` (the default — drives the `pi` coding agent CLI with a built prompt plus `input.json`/`output.json` envelopes), `
|
|
8
|
+
- **Agent adapters.** Each task step picks an adapter that does the actual work: `pi-agent` (the default — drives the `pi` coding agent CLI with a built prompt plus `input.json`/`output.json` envelopes), `acp` (connects to any [Agent Client Protocol](https://agentclientprotocol.com) agent over JSON-RPC/stdio), and `mock` (deterministic simulator for tests/authoring). The `claude-code`, `opencode`, and `codex` keys are ACP presets — their original bespoke executors are deprecated behind `taskSpec.payload.legacyExecutor`. Steps can declare skills, MCP endpoints, system prompts, and a model under `taskSpec.init`, which get injected into the agent's prompt, input file, or ACP session.
|
|
9
9
|
- **The envelope protocol.** Every step execution returns a structured result: an execution status (`SUCCESS`, `FAILED`, `QA_REJECTED`, `YIELD_EXTERNAL`) plus a QA routing action (`PROCEED`, `RETRY_CURRENT`, `ROLLBACK_PREVIOUS`, `RESTART_ALL`). That's what lets the engine retry a step, roll back to the previous one, or restart the whole run based on what the agent reported.
|
|
10
10
|
- **Human-in-the-loop control.** While a run is active, wfm starts a local HTTP attach API (token-protected, with SSE event streaming), so a waiting step can be resolved either in the terminal prompt or from another shell with `wfm approve` / `wfm resume` / `wfm cancel`.
|
|
11
11
|
- **A registry.** `wfm publish` / `pull` / `search` / `auth` talk to a Supabase-backed remote registry (the `apps/remote-registry` web app in this repo) for sharing workflows, with skills bundled and SHA-256 verified. Runs also emit opt-in telemetry there.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { InputEnvelope, OutputEnvelope, StepDefinition, StepExecutionHooks, WorkflowDefinition } from "./types.js";
|
|
2
|
+
interface ResolvedAcpCommand {
|
|
3
|
+
command: string;
|
|
4
|
+
args: string[];
|
|
5
|
+
}
|
|
6
|
+
export declare function normalizeTimeout(value: unknown, fallbackMs?: number): number;
|
|
7
|
+
/**
|
|
8
|
+
* Resolves which ACP agent command to launch for a step. Precedence:
|
|
9
|
+
* payload.acpCommand → WFM_ACP_COMMAND env → preset for payload.acpAgent → preset
|
|
10
|
+
* for the adapter key (claude-code / opencode). Returns null when nothing resolves
|
|
11
|
+
* (e.g. bare `acp` or `codex` without configuration), which keeps the step on mock.
|
|
12
|
+
*/
|
|
13
|
+
export declare function resolveAcpCommand(step: StepDefinition, env: NodeJS.ProcessEnv): ResolvedAcpCommand | null;
|
|
14
|
+
export declare function shouldUseRealAcp(step: StepDefinition): boolean;
|
|
15
|
+
export declare function executeAcpStep(step: StepDefinition, input: InputEnvelope, attempt: number, workflow?: WorkflowDefinition, workflowFilePath?: string, hooks?: StepExecutionHooks): Promise<OutputEnvelope>;
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { ClientSideConnection, PROTOCOL_VERSION, ndJsonStream, } from "@zed-industries/agent-client-protocol";
|
|
5
|
+
import { resolveTaskAdapter } from "./adapters.js";
|
|
6
|
+
import { resolveSkill } from "./skillResolver.js";
|
|
7
|
+
// Best-effort presets so claude-code / opencode / a named agent route through ACP
|
|
8
|
+
// without explicit commands. All are overridable via payload.acpCommand or WFM_ACP_COMMAND.
|
|
9
|
+
const ACP_COMMAND_PRESETS = {
|
|
10
|
+
"claude-code": { command: "claude-code-acp", args: [] },
|
|
11
|
+
opencode: { command: "opencode", args: ["acp"] },
|
|
12
|
+
gemini: { command: "gemini", args: ["--experimental-acp"] },
|
|
13
|
+
};
|
|
14
|
+
const READ_ONLY_TOOL_KINDS = new Set(["read", "search", "fetch", "think"]);
|
|
15
|
+
function asRecord(value) {
|
|
16
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
17
|
+
}
|
|
18
|
+
// node:stream's Writable.toWeb / Readable.toWeb are not implemented in Bun, so we
|
|
19
|
+
// bridge the child process stdio to Web streams ourselves (portable across runtimes).
|
|
20
|
+
function writableToWeb(writable) {
|
|
21
|
+
return new WritableStream({
|
|
22
|
+
write(chunk) {
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
writable.write(chunk, (err) => (err ? reject(err) : resolve()));
|
|
25
|
+
});
|
|
26
|
+
},
|
|
27
|
+
close() {
|
|
28
|
+
return new Promise((resolve) => writable.end(() => resolve()));
|
|
29
|
+
},
|
|
30
|
+
abort() {
|
|
31
|
+
writable.destroy();
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
function readableToWeb(readable) {
|
|
36
|
+
return new ReadableStream({
|
|
37
|
+
start(controller) {
|
|
38
|
+
readable.on("data", (chunk) => {
|
|
39
|
+
controller.enqueue(typeof chunk === "string" ? new TextEncoder().encode(chunk) : Uint8Array.from(chunk));
|
|
40
|
+
});
|
|
41
|
+
readable.on("end", () => {
|
|
42
|
+
try {
|
|
43
|
+
controller.close();
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// already closed
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
readable.on("error", (err) => controller.error(err));
|
|
50
|
+
},
|
|
51
|
+
cancel() {
|
|
52
|
+
readable.destroy();
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
export function normalizeTimeout(value, fallbackMs = 600000) {
|
|
57
|
+
const timeout = Number(value ?? fallbackMs);
|
|
58
|
+
if (!Number.isFinite(timeout) || timeout <= 0)
|
|
59
|
+
return fallbackMs;
|
|
60
|
+
return Math.floor(timeout);
|
|
61
|
+
}
|
|
62
|
+
function stringArg(value) {
|
|
63
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Resolves which ACP agent command to launch for a step. Precedence:
|
|
67
|
+
* payload.acpCommand → WFM_ACP_COMMAND env → preset for payload.acpAgent → preset
|
|
68
|
+
* for the adapter key (claude-code / opencode). Returns null when nothing resolves
|
|
69
|
+
* (e.g. bare `acp` or `codex` without configuration), which keeps the step on mock.
|
|
70
|
+
*/
|
|
71
|
+
export function resolveAcpCommand(step, env) {
|
|
72
|
+
const payload = asRecord(step.taskSpec?.payload);
|
|
73
|
+
const payloadArgs = Array.isArray(payload.acpArgs) ? payload.acpArgs.map((arg) => String(arg)) : [];
|
|
74
|
+
const explicit = stringArg(payload.acpCommand) ?? stringArg(env.WFM_ACP_COMMAND);
|
|
75
|
+
if (explicit) {
|
|
76
|
+
return { command: explicit, args: payloadArgs };
|
|
77
|
+
}
|
|
78
|
+
const presetKey = stringArg(payload.acpAgent) ?? resolveTaskAdapter(step.taskSpec?.adapterKey);
|
|
79
|
+
const preset = ACP_COMMAND_PRESETS[presetKey];
|
|
80
|
+
if (preset) {
|
|
81
|
+
return { command: preset.command, args: payloadArgs.length > 0 ? payloadArgs : [...preset.args] };
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
export function shouldUseRealAcp(step) {
|
|
86
|
+
const payload = asRecord(step.taskSpec?.payload);
|
|
87
|
+
return payload.useRealAdapter === true && resolveAcpCommand(step, process.env) !== null;
|
|
88
|
+
}
|
|
89
|
+
function permissionPolicy(step) {
|
|
90
|
+
const value = asRecord(step.taskSpec?.payload).acpPermissions;
|
|
91
|
+
return value === "deny" || value === "reads-only" ? value : "allow";
|
|
92
|
+
}
|
|
93
|
+
function decidePermission(req, policy) {
|
|
94
|
+
const allow = req.options.find((option) => option.kind === "allow_once") ??
|
|
95
|
+
req.options.find((option) => option.kind === "allow_always");
|
|
96
|
+
const reject = req.options.find((option) => option.kind === "reject_once") ??
|
|
97
|
+
req.options.find((option) => option.kind === "reject_always");
|
|
98
|
+
let chooseAllow;
|
|
99
|
+
if (policy === "allow") {
|
|
100
|
+
chooseAllow = true;
|
|
101
|
+
}
|
|
102
|
+
else if (policy === "deny") {
|
|
103
|
+
chooseAllow = false;
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
const kind = req.toolCall.kind ?? undefined;
|
|
107
|
+
chooseAllow = kind ? READ_ONLY_TOOL_KINDS.has(kind) : false;
|
|
108
|
+
}
|
|
109
|
+
const chosen = chooseAllow ? (allow ?? req.options[0]) : reject;
|
|
110
|
+
if (!chosen) {
|
|
111
|
+
return { outcome: { outcome: "cancelled" } };
|
|
112
|
+
}
|
|
113
|
+
return { outcome: { outcome: "selected", optionId: chosen.optionId } };
|
|
114
|
+
}
|
|
115
|
+
function buildMcpServers(endpoints) {
|
|
116
|
+
const servers = [];
|
|
117
|
+
const skipped = [];
|
|
118
|
+
for (const endpoint of endpoints) {
|
|
119
|
+
if (/^https?:\/\//i.test(endpoint)) {
|
|
120
|
+
servers.push({ type: "http", name: endpoint, url: endpoint, headers: [] });
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
skipped.push(endpoint);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return { servers, skipped };
|
|
127
|
+
}
|
|
128
|
+
function composePrompt(step, input, workflow, workflowFilePath) {
|
|
129
|
+
const payload = asRecord(step.taskSpec?.payload);
|
|
130
|
+
if (typeof payload.prompt === "string" && payload.prompt.trim()) {
|
|
131
|
+
return payload.prompt;
|
|
132
|
+
}
|
|
133
|
+
const parts = [];
|
|
134
|
+
const systemPrompts = input.priming_configuration.system_prompts;
|
|
135
|
+
if (systemPrompts.length > 0) {
|
|
136
|
+
parts.push(systemPrompts.join("\n"));
|
|
137
|
+
}
|
|
138
|
+
const skills = input.priming_configuration.required_skills;
|
|
139
|
+
if (skills.length > 0) {
|
|
140
|
+
const unresolved = [];
|
|
141
|
+
for (const name of skills) {
|
|
142
|
+
const resolved = workflow && workflowFilePath ? resolveSkill(name, workflow, workflowFilePath) : null;
|
|
143
|
+
if (resolved) {
|
|
144
|
+
parts.push(resolved.content);
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
unresolved.push(name);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (unresolved.length > 0) {
|
|
151
|
+
parts.push(`Apply the following skills: ${unresolved.join(", ")}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const globalState = input.global_context.global_state;
|
|
155
|
+
const inputLines = Object.entries(globalState)
|
|
156
|
+
.filter(([, value]) => typeof value === "string" || typeof value === "number")
|
|
157
|
+
.map(([key, value]) => `${key}: ${String(value).replace(/[\n\r]/g, " ")}`);
|
|
158
|
+
if (inputLines.length > 0) {
|
|
159
|
+
parts.push(`Input:\n${inputLines.join("\n")}`);
|
|
160
|
+
}
|
|
161
|
+
parts.push(input.step_context.step_objective);
|
|
162
|
+
const previous = input.step_context.previous_output;
|
|
163
|
+
if (Object.keys(previous).length > 0) {
|
|
164
|
+
parts.push(`Previous step output:\n${JSON.stringify(previous, null, 2)}`);
|
|
165
|
+
}
|
|
166
|
+
const context = input.priming_configuration.context;
|
|
167
|
+
if (typeof context === "string" && context.trim()) {
|
|
168
|
+
parts.push(`Context:\n${context}`);
|
|
169
|
+
}
|
|
170
|
+
else if (context && typeof context === "object") {
|
|
171
|
+
const serialized = JSON.stringify(context, null, 2);
|
|
172
|
+
if (serialized !== "{}") {
|
|
173
|
+
parts.push(`Context:\n${serialized}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return parts.join("\n\n");
|
|
177
|
+
}
|
|
178
|
+
function mapStopReason(stopReason) {
|
|
179
|
+
switch (stopReason) {
|
|
180
|
+
case "end_turn":
|
|
181
|
+
return { status: "SUCCESS", action: "PROCEED", reason: "" };
|
|
182
|
+
case "refusal":
|
|
183
|
+
return { status: "QA_REJECTED", action: "RETRY_CURRENT", reason: "agent refused the turn" };
|
|
184
|
+
case "max_tokens":
|
|
185
|
+
return { status: "FAILED", action: "RETRY_CURRENT", reason: "agent stopped at max tokens" };
|
|
186
|
+
case "max_turn_requests":
|
|
187
|
+
return { status: "FAILED", action: "RETRY_CURRENT", reason: "agent stopped at max turn requests" };
|
|
188
|
+
case "cancelled":
|
|
189
|
+
return { status: "FAILED", action: "PROCEED", reason: "turn cancelled" };
|
|
190
|
+
default:
|
|
191
|
+
return { status: "FAILED", action: "PROCEED", reason: `unknown stop reason: ${stopReason}` };
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
export function executeAcpStep(step, input, attempt, workflow, workflowFilePath, hooks) {
|
|
195
|
+
const startedAt = Date.now();
|
|
196
|
+
const adapter = resolveTaskAdapter(step.taskSpec?.adapterKey);
|
|
197
|
+
const payload = asRecord(step.taskSpec?.payload);
|
|
198
|
+
const timeoutMs = normalizeTimeout(payload.timeoutMs);
|
|
199
|
+
const resolved = resolveAcpCommand(step, process.env);
|
|
200
|
+
const makeResult = (status, reason, extra = {}, action = "PROCEED") => ({
|
|
201
|
+
step_id: step.key,
|
|
202
|
+
execution_status: status,
|
|
203
|
+
qa_routing: { action, feedback_reason: reason },
|
|
204
|
+
mutated_payload: {
|
|
205
|
+
stepKey: step.key,
|
|
206
|
+
attempt,
|
|
207
|
+
adapter,
|
|
208
|
+
command: resolved?.command,
|
|
209
|
+
args: resolved?.args,
|
|
210
|
+
...extra,
|
|
211
|
+
},
|
|
212
|
+
metadata: {
|
|
213
|
+
execution_time_ms: Date.now() - startedAt,
|
|
214
|
+
external_intervention_required: false,
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
if (!resolved) {
|
|
218
|
+
return Promise.resolve(makeResult("FAILED", "no ACP agent command could be resolved for this step"));
|
|
219
|
+
}
|
|
220
|
+
const cwd = stringArg(payload.cwd) ?? (workflowFilePath ? path.dirname(path.resolve(workflowFilePath)) : process.cwd());
|
|
221
|
+
const policy = permissionPolicy(step);
|
|
222
|
+
const { servers: mcpServers, skipped } = buildMcpServers(input.priming_configuration.mcp_endpoints);
|
|
223
|
+
if (skipped.length > 0) {
|
|
224
|
+
hooks?.onStderr?.(`[acp] ignoring non-http MCP endpoints (need structured config): ${skipped.join(", ")}\n`);
|
|
225
|
+
}
|
|
226
|
+
let child;
|
|
227
|
+
try {
|
|
228
|
+
child = spawn(resolved.command, resolved.args, { stdio: ["pipe", "pipe", "pipe"], cwd, env: process.env });
|
|
229
|
+
}
|
|
230
|
+
catch (err) {
|
|
231
|
+
return Promise.resolve(makeResult("FAILED", `failed to spawn ${resolved.command}: ${err.message}`));
|
|
232
|
+
}
|
|
233
|
+
hooks?.onStarted?.({ command: resolved.command, args: resolved.args, cwd, timeoutMs });
|
|
234
|
+
child.stderr?.setEncoding("utf-8");
|
|
235
|
+
child.stderr?.on("data", (chunk) => hooks?.onStderr?.(chunk));
|
|
236
|
+
// A ChildProcess "error" (e.g. ENOENT for a missing agent binary) is emitted
|
|
237
|
+
// asynchronously and would crash the process if unhandled.
|
|
238
|
+
const childError = new Promise((_resolve, reject) => {
|
|
239
|
+
child.on("error", (err) => reject(new Error(`agent process error: ${err.message}`)));
|
|
240
|
+
});
|
|
241
|
+
let agentText = "";
|
|
242
|
+
let sessionId;
|
|
243
|
+
let timer;
|
|
244
|
+
const client = {
|
|
245
|
+
async sessionUpdate(params) {
|
|
246
|
+
const update = params.update;
|
|
247
|
+
if (update.sessionUpdate === "agent_message_chunk" && update.content.type === "text") {
|
|
248
|
+
agentText += update.content.text;
|
|
249
|
+
hooks?.onStdout?.(update.content.text);
|
|
250
|
+
}
|
|
251
|
+
else if (update.sessionUpdate === "tool_call") {
|
|
252
|
+
hooks?.onStdout?.(`\n[acp tool] ${update.title}\n`);
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
async requestPermission(params) {
|
|
256
|
+
return decidePermission(params, policy);
|
|
257
|
+
},
|
|
258
|
+
async readTextFile(params) {
|
|
259
|
+
return { content: fs.readFileSync(params.path, "utf-8") };
|
|
260
|
+
},
|
|
261
|
+
async writeTextFile(params) {
|
|
262
|
+
fs.mkdirSync(path.dirname(params.path), { recursive: true });
|
|
263
|
+
fs.writeFileSync(params.path, params.content, "utf-8");
|
|
264
|
+
return {};
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
let conn;
|
|
268
|
+
try {
|
|
269
|
+
const stream = ndJsonStream(writableToWeb(child.stdin), readableToWeb(child.stdout));
|
|
270
|
+
conn = new ClientSideConnection(() => client, stream);
|
|
271
|
+
}
|
|
272
|
+
catch (err) {
|
|
273
|
+
child.kill("SIGKILL");
|
|
274
|
+
return Promise.resolve(makeResult("FAILED", `failed to open ACP connection: ${err.message}`));
|
|
275
|
+
}
|
|
276
|
+
const runTurn = async () => {
|
|
277
|
+
await conn.initialize({
|
|
278
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
279
|
+
clientCapabilities: { fs: { readTextFile: true, writeTextFile: true }, terminal: false },
|
|
280
|
+
});
|
|
281
|
+
const authMethod = stringArg(payload.acpAuthMethod) ?? stringArg(process.env.WFM_ACP_AUTH_METHOD);
|
|
282
|
+
if (authMethod) {
|
|
283
|
+
await conn.authenticate({ methodId: authMethod });
|
|
284
|
+
}
|
|
285
|
+
const session = await conn.newSession({ cwd, mcpServers });
|
|
286
|
+
sessionId = session.sessionId;
|
|
287
|
+
const prompt = [{ type: "text", text: composePrompt(step, input, workflow, workflowFilePath) }];
|
|
288
|
+
const response = await conn.prompt({ sessionId: session.sessionId, prompt });
|
|
289
|
+
const mapped = mapStopReason(response.stopReason);
|
|
290
|
+
return makeResult(mapped.status, mapped.reason, { stopReason: response.stopReason, output: agentText.trim() }, mapped.action);
|
|
291
|
+
};
|
|
292
|
+
const timeout = new Promise((resolve) => {
|
|
293
|
+
timer = setTimeout(() => {
|
|
294
|
+
if (sessionId) {
|
|
295
|
+
conn.cancel({ sessionId }).catch(() => undefined);
|
|
296
|
+
}
|
|
297
|
+
resolve(makeResult("FAILED", `timed out after ${timeoutMs}ms`, { stopReason: "timeout", output: agentText.trim() }));
|
|
298
|
+
}, timeoutMs);
|
|
299
|
+
});
|
|
300
|
+
return Promise.race([runTurn(), timeout, childError])
|
|
301
|
+
.catch((err) => makeResult("FAILED", `ACP turn failed: ${err.message}`, { output: agentText.trim() }))
|
|
302
|
+
.then((result) => {
|
|
303
|
+
hooks?.onFinished?.({ executionStatus: result.execution_status });
|
|
304
|
+
return result;
|
|
305
|
+
})
|
|
306
|
+
.finally(() => {
|
|
307
|
+
if (timer)
|
|
308
|
+
clearTimeout(timer);
|
|
309
|
+
child.kill("SIGTERM");
|
|
310
|
+
});
|
|
311
|
+
}
|
package/dist/adapters.js
CHANGED
package/dist/engine.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { createInterface } from "node:readline";
|
|
3
|
+
import { executeAcpStep, shouldUseRealAcp } from "./acpExecutor.js";
|
|
3
4
|
import { resolveTaskAdapter } from "./adapters.js";
|
|
4
5
|
import { EventLog } from "./events.js";
|
|
5
6
|
import { executeClaudeCodeStep, shouldUseRealClaudeCode } from "./claudeCodeExecutor.js";
|
|
@@ -296,12 +297,21 @@ async function executeStep(step, input, attempt, workflow, workflowFilePath, hoo
|
|
|
296
297
|
if (adapterKey === "pi-agent") {
|
|
297
298
|
return executePiAgentStep(step, input, attempt, workflow, workflowFilePath, hooks);
|
|
298
299
|
}
|
|
299
|
-
|
|
300
|
+
// Legacy escape hatch: the bespoke claude-code / opencode subprocess executors are
|
|
301
|
+
// deprecated in favor of ACP, but stay reachable via payload.legacyExecutor so existing
|
|
302
|
+
// real-integration users are not stranded.
|
|
303
|
+
const legacyExecutor = step.taskSpec?.payload?.legacyExecutor === true;
|
|
304
|
+
if (legacyExecutor && adapterKey === "opencode" && shouldUseRealOpencode(step)) {
|
|
300
305
|
return executeOpencodeStep(step, input, attempt, hooks);
|
|
301
306
|
}
|
|
302
|
-
if (adapterKey === "claude-code" && shouldUseRealClaudeCode(step)) {
|
|
307
|
+
if (legacyExecutor && adapterKey === "claude-code" && shouldUseRealClaudeCode(step)) {
|
|
303
308
|
return executeClaudeCodeStep(step, input, attempt, workflow, workflowFilePath, hooks);
|
|
304
309
|
}
|
|
310
|
+
// All non-pi agents run through ACP.
|
|
311
|
+
const acpRoutable = adapterKey === "acp" || adapterKey === "claude-code" || adapterKey === "opencode" || adapterKey === "codex";
|
|
312
|
+
if (acpRoutable && shouldUseRealAcp(step)) {
|
|
313
|
+
return executeAcpStep(step, input, attempt, workflow, workflowFilePath, hooks);
|
|
314
|
+
}
|
|
305
315
|
const fallbackReason = adapterMockFallbackReason(step);
|
|
306
316
|
if (fallbackReason) {
|
|
307
317
|
hooks?.onStderr?.(`[wfm] ${step.key}: ${fallbackReason}\n`);
|
package/dist/index.js
CHANGED
|
@@ -159,6 +159,18 @@ const WORKFLOW_SCAFFOLD_JSON = {
|
|
|
159
159
|
payload: { mockResult: "success" },
|
|
160
160
|
},
|
|
161
161
|
},
|
|
162
|
+
{
|
|
163
|
+
key: "acp_review",
|
|
164
|
+
kind: "task",
|
|
165
|
+
objective: "Example ACP step: set useRealAdapter + acpAgent to run a real agent; mocks otherwise",
|
|
166
|
+
dependsOn: ["hardening"],
|
|
167
|
+
validation: { mode: "none", required: false, autoConfirm: true },
|
|
168
|
+
taskSpec: {
|
|
169
|
+
adapterKey: "acp",
|
|
170
|
+
init: { systemPrompts: ["Review the implementation"] },
|
|
171
|
+
payload: { mockResult: "success" },
|
|
172
|
+
},
|
|
173
|
+
},
|
|
162
174
|
],
|
|
163
175
|
};
|
|
164
176
|
const WORKFLOW_SCAFFOLD_MARKDOWN = `---
|
|
@@ -244,6 +256,20 @@ steps:
|
|
|
244
256
|
systemPrompts: [Prioritize correctness and readability]
|
|
245
257
|
payload:
|
|
246
258
|
mockResult: success
|
|
259
|
+
- key: acp_review
|
|
260
|
+
kind: task
|
|
261
|
+
objective: "Example ACP step: set useRealAdapter + acpAgent to run a real agent; mocks otherwise"
|
|
262
|
+
dependsOn: [hardening]
|
|
263
|
+
validation:
|
|
264
|
+
mode: none
|
|
265
|
+
required: false
|
|
266
|
+
autoConfirm: true
|
|
267
|
+
taskSpec:
|
|
268
|
+
adapterKey: acp
|
|
269
|
+
init:
|
|
270
|
+
systemPrompts: [Review the implementation]
|
|
271
|
+
payload:
|
|
272
|
+
mockResult: success
|
|
247
273
|
---
|
|
248
274
|
|
|
249
275
|
# Workflow Notes
|
|
@@ -19,12 +19,12 @@ export interface AdapterMockFallbackWarning {
|
|
|
19
19
|
message: string;
|
|
20
20
|
}
|
|
21
21
|
/**
|
|
22
|
-
* Detects a step that explicitly selects a
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
22
|
+
* Detects a step that explicitly selects a non-pi adapter whose real execution
|
|
23
|
+
* path is not enabled, so the engine will route it to the mock executor. Non-pi
|
|
24
|
+
* agents run through ACP, which needs `useRealAdapter: true` plus a resolvable
|
|
25
|
+
* agent command. Returns a message explaining the gap, or null when the step
|
|
26
|
+
* will run as the user expects (default pi-agent, an enabled ACP/legacy path, or
|
|
27
|
+
* an intentional mock selection).
|
|
28
28
|
*/
|
|
29
29
|
export declare function adapterMockFallbackReason(step: StepDefinition): string | null;
|
|
30
30
|
export declare function adapterMockFallbackWarnings(definition: WorkflowDefinition): AdapterMockFallbackWarning[];
|
package/dist/runtimePreflight.js
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { resolveAcpCommand, shouldUseRealAcp } from "./acpExecutor.js";
|
|
3
4
|
import { resolveTaskAdapter } from "./adapters.js";
|
|
4
5
|
import { shouldUseRealClaudeCode } from "./claudeCodeExecutor.js";
|
|
5
6
|
import { shouldUseRealOpencode } from "./opencodeExecutor.js";
|
|
6
7
|
import { DEFAULT_PI_COMMAND } from "./piAgentExecutor.js";
|
|
8
|
+
const ACP_ROUTABLE_ADAPTERS = new Set(["acp", "claude-code", "opencode", "codex"]);
|
|
9
|
+
function legacyExecutorEnabled(step) {
|
|
10
|
+
return asRecord(step.taskSpec?.payload).legacyExecutor === true;
|
|
11
|
+
}
|
|
7
12
|
function asRecord(value) {
|
|
8
13
|
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
9
14
|
}
|
|
@@ -98,12 +103,23 @@ function runtimeRequirement(step, env) {
|
|
|
98
103
|
// explicitly declared env vars are enforced for pi steps.
|
|
99
104
|
return { stepKey: step.key, adapter, command: piAgentCommand(step, env), envVars: explicitRequiredEnv(step) };
|
|
100
105
|
}
|
|
101
|
-
|
|
106
|
+
const legacy = legacyExecutorEnabled(step);
|
|
107
|
+
if (legacy && adapter === "opencode" && shouldUseRealOpencode(step)) {
|
|
102
108
|
return { stepKey: step.key, adapter, command: "opencode", envVars };
|
|
103
109
|
}
|
|
104
|
-
if (adapter === "claude-code" && shouldUseRealClaudeCode(step)) {
|
|
110
|
+
if (legacy && adapter === "claude-code" && shouldUseRealClaudeCode(step)) {
|
|
105
111
|
return { stepKey: step.key, adapter, command: "claude", envVars };
|
|
106
112
|
}
|
|
113
|
+
if (shouldUseRealAcp(step)) {
|
|
114
|
+
// ACP agents manage their own credentials (like pi), so only explicitly declared
|
|
115
|
+
// env vars are enforced; the resolved agent command must exist on the host.
|
|
116
|
+
return {
|
|
117
|
+
stepKey: step.key,
|
|
118
|
+
adapter,
|
|
119
|
+
command: resolveAcpCommand(step, env)?.command,
|
|
120
|
+
envVars: explicitRequiredEnv(step),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
107
123
|
if (envVars.length > 0 && adapter !== "mock") {
|
|
108
124
|
return { stepKey: step.key, adapter, envVars };
|
|
109
125
|
}
|
|
@@ -149,34 +165,62 @@ function envCheck(key, label, envVar, env) {
|
|
|
149
165
|
}
|
|
150
166
|
export function runtimeDoctorChecks(env = process.env) {
|
|
151
167
|
const piAgentStep = { key: "pi-agent", kind: "task", taskSpec: {} };
|
|
168
|
+
const acpCommand = env.WFM_ACP_COMMAND?.trim();
|
|
169
|
+
const acpCheck = acpCommand
|
|
170
|
+
? commandCheck("acp", "ACP agent command", acpCommand, false, env)
|
|
171
|
+
: {
|
|
172
|
+
key: "acp",
|
|
173
|
+
label: "ACP agent command",
|
|
174
|
+
status: "info",
|
|
175
|
+
required: false,
|
|
176
|
+
detail: "configured per step (payload.acpCommand / acpAgent) or via WFM_ACP_COMMAND",
|
|
177
|
+
};
|
|
152
178
|
return [
|
|
153
179
|
commandCheck("pi-agent", "Pi command", piAgentCommand(piAgentStep, env), true, env),
|
|
154
|
-
|
|
155
|
-
commandCheck("
|
|
180
|
+
acpCheck,
|
|
181
|
+
commandCheck("opencode", "OpenCode command (legacy)", "opencode", false, env),
|
|
182
|
+
commandCheck("claude", "Claude Code command (legacy)", "claude", false, env),
|
|
156
183
|
envCheck("openrouter-key", "OpenRouter API key", "OPENROUTER_API_KEY", env),
|
|
157
184
|
envCheck("openai-key", "OpenAI API key", "OPENAI_API_KEY", env),
|
|
158
185
|
envCheck("anthropic-key", "Anthropic API key", "ANTHROPIC_API_KEY", env),
|
|
159
186
|
];
|
|
160
187
|
}
|
|
161
188
|
/**
|
|
162
|
-
* Detects a step that explicitly selects a
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
189
|
+
* Detects a step that explicitly selects a non-pi adapter whose real execution
|
|
190
|
+
* path is not enabled, so the engine will route it to the mock executor. Non-pi
|
|
191
|
+
* agents run through ACP, which needs `useRealAdapter: true` plus a resolvable
|
|
192
|
+
* agent command. Returns a message explaining the gap, or null when the step
|
|
193
|
+
* will run as the user expects (default pi-agent, an enabled ACP/legacy path, or
|
|
194
|
+
* an intentional mock selection).
|
|
168
195
|
*/
|
|
169
196
|
export function adapterMockFallbackReason(step) {
|
|
170
197
|
if (step.kind !== "task" || !step.taskSpec?.adapterKey) {
|
|
171
198
|
return null;
|
|
172
199
|
}
|
|
173
200
|
const adapter = resolveTaskAdapter(step.taskSpec.adapterKey);
|
|
174
|
-
if (
|
|
175
|
-
return
|
|
201
|
+
if (!ACP_ROUTABLE_ADAPTERS.has(adapter)) {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
const legacy = legacyExecutorEnabled(step);
|
|
205
|
+
if (legacy && adapter === "opencode" && shouldUseRealOpencode(step)) {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
if (legacy && adapter === "claude-code" && shouldUseRealClaudeCode(step)) {
|
|
209
|
+
return null;
|
|
176
210
|
}
|
|
177
|
-
if (
|
|
178
|
-
return
|
|
211
|
+
if (shouldUseRealAcp(step)) {
|
|
212
|
+
return null;
|
|
179
213
|
}
|
|
214
|
+
if (asRecord(step.taskSpec?.payload).useRealAdapter === true) {
|
|
215
|
+
// The user opted into a real run but no ACP agent command could be resolved.
|
|
216
|
+
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.`;
|
|
217
|
+
}
|
|
218
|
+
if (resolveAcpCommand(step, process.env) !== null) {
|
|
219
|
+
// A concrete agent is named (a preset like claude-code/opencode, or an explicit
|
|
220
|
+
// command) but useRealAdapter is off, so the step still mocks.
|
|
221
|
+
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.`;
|
|
222
|
+
}
|
|
223
|
+
// Bare acp/codex with no agent configured is treated as an intentional mock.
|
|
180
224
|
return null;
|
|
181
225
|
}
|
|
182
226
|
export function adapterMockFallbackWarnings(definition) {
|
|
@@ -196,6 +240,11 @@ export function adapterImplementationStatuses() {
|
|
|
196
240
|
status: "real",
|
|
197
241
|
detail: "default host-backed adapter driving the pi coding agent CLI",
|
|
198
242
|
},
|
|
243
|
+
{
|
|
244
|
+
adapter: "acp",
|
|
245
|
+
status: "real",
|
|
246
|
+
detail: "Agent Client Protocol adapter; runs any ACP agent (via acpCommand/acpAgent) when useRealAdapter is true",
|
|
247
|
+
},
|
|
199
248
|
{
|
|
200
249
|
adapter: "mock",
|
|
201
250
|
status: "mock",
|
|
@@ -204,17 +253,17 @@ export function adapterImplementationStatuses() {
|
|
|
204
253
|
{
|
|
205
254
|
adapter: "opencode",
|
|
206
255
|
status: "partial",
|
|
207
|
-
detail: "
|
|
256
|
+
detail: "routed through ACP when useRealAdapter is true; bespoke executor deprecated (payload.legacyExecutor)",
|
|
208
257
|
},
|
|
209
258
|
{
|
|
210
259
|
adapter: "codex",
|
|
211
|
-
status: "
|
|
212
|
-
detail: "
|
|
260
|
+
status: "partial",
|
|
261
|
+
detail: "routed through ACP when useRealAdapter and an acpCommand/acpAgent are set; otherwise mock",
|
|
213
262
|
},
|
|
214
263
|
{
|
|
215
264
|
adapter: "claude-code",
|
|
216
265
|
status: "partial",
|
|
217
|
-
detail: "
|
|
266
|
+
detail: "routed through ACP when useRealAdapter is true; bespoke executor deprecated (payload.legacyExecutor)",
|
|
218
267
|
},
|
|
219
268
|
];
|
|
220
269
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ export type NodeType = "AGENT" | "HUMAN" | "SYSTEM";
|
|
|
5
5
|
export type ExecutionStatus = "SUCCESS" | "QA_REJECTED" | "YIELD_EXTERNAL" | "FAILED";
|
|
6
6
|
export type QaAction = "PROCEED" | "RETRY_CURRENT" | "ROLLBACK_PREVIOUS" | "RESTART_ALL";
|
|
7
7
|
export type ValidationMode = "none" | "human" | "external";
|
|
8
|
-
export type AdapterKey = "pi-agent" | "mock" | "opencode" | "codex" | "claude-code";
|
|
8
|
+
export type AdapterKey = "pi-agent" | "mock" | "opencode" | "codex" | "claude-code" | "acp";
|
|
9
9
|
export interface RetryPolicy {
|
|
10
10
|
maxAttempts?: number;
|
|
11
11
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@workflow-manager/runner",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "CLI runner for in-memory and markdown workflow orchestration using ATEP-like envelopes",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -33,8 +33,8 @@
|
|
|
33
33
|
"lint:fix": "biome lint --write --error-on-warnings",
|
|
34
34
|
"lint:staged": "lint-staged --relative --concurrent false",
|
|
35
35
|
"man": "man ./man/wfm.1",
|
|
36
|
-
"test:unit": "bun test tests/parser.test.ts tests/engine.test.ts tests/runtimePreflight.test.ts tests/mockExecutor.test.ts tests/opencodeExecutor.test.ts tests/claudeCodeExecutor.test.ts tests/piAgentExecutor.test.ts tests/skillResolver.test.ts tests/publish-bundle.test.ts tests/remote.test.ts tests/installer.test.ts tests/run-telemetry.test.ts tests/runnerApi.test.ts tests/runnerCli.test.ts tests/supabase-functions.test.ts tests/supabase-ops.test.ts tests/remote-registry-app.test.ts",
|
|
37
|
-
"test:release": "bun test tests/parser.test.ts tests/engine.test.ts tests/runtimePreflight.test.ts tests/mockExecutor.test.ts tests/opencodeExecutor.test.ts tests/claudeCodeExecutor.test.ts tests/piAgentExecutor.test.ts tests/skillResolver.test.ts tests/publish-bundle.test.ts tests/remote.test.ts tests/run-telemetry.test.ts tests/runnerApi.test.ts tests/runnerCli.test.ts tests/supabase-functions.test.ts tests/supabase-ops.test.ts tests/remote-registry-app.test.ts tests/story-workflow.e2e.test.ts tests/opencode-real.e2e.test.ts",
|
|
36
|
+
"test:unit": "bun test tests/parser.test.ts tests/engine.test.ts tests/runtimePreflight.test.ts tests/mockExecutor.test.ts tests/opencodeExecutor.test.ts tests/claudeCodeExecutor.test.ts tests/acpExecutor.test.ts tests/piAgentExecutor.test.ts tests/skillResolver.test.ts tests/publish-bundle.test.ts tests/remote.test.ts tests/installer.test.ts tests/run-telemetry.test.ts tests/runnerApi.test.ts tests/runnerCli.test.ts tests/supabase-functions.test.ts tests/supabase-ops.test.ts tests/remote-registry-app.test.ts",
|
|
37
|
+
"test:release": "bun test tests/parser.test.ts tests/engine.test.ts tests/runtimePreflight.test.ts tests/mockExecutor.test.ts tests/opencodeExecutor.test.ts tests/claudeCodeExecutor.test.ts tests/acpExecutor.test.ts tests/piAgentExecutor.test.ts tests/skillResolver.test.ts tests/publish-bundle.test.ts tests/remote.test.ts tests/run-telemetry.test.ts tests/runnerApi.test.ts tests/runnerCli.test.ts tests/supabase-functions.test.ts tests/supabase-ops.test.ts tests/remote-registry-app.test.ts tests/story-workflow.e2e.test.ts tests/opencode-real.e2e.test.ts",
|
|
38
38
|
"test:e2e": "bun test tests/story-workflow.e2e.test.ts tests/opencode-real.e2e.test.ts",
|
|
39
39
|
"test:e2e:real": "WORKFLOW_MANAGER_REAL_OPENCODE=1 bun test tests/opencode-real.e2e.test.ts",
|
|
40
40
|
"test": "bun test",
|
|
@@ -72,6 +72,7 @@
|
|
|
72
72
|
],
|
|
73
73
|
"license": "MIT",
|
|
74
74
|
"dependencies": {
|
|
75
|
+
"@zed-industries/agent-client-protocol": "^0.4.5",
|
|
75
76
|
"gray-matter": "^4.0.3"
|
|
76
77
|
},
|
|
77
78
|
"devDependencies": {
|
|
@@ -62,9 +62,10 @@ Adapters (set per step via `taskSpec.adapterKey`; omit for the default):
|
|
|
62
62
|
| Adapter | Kind | Notes |
|
|
63
63
|
| --- | --- | --- |
|
|
64
64
|
| `pi-agent` | real (default) | Drives the host `pi` coding agent CLI in print mode. `pi` must be on `PATH`; its auth lives in `~/.pi`, not env vars. |
|
|
65
|
-
| `
|
|
66
|
-
| `claude-code` | real (opt-in) |
|
|
67
|
-
| `
|
|
65
|
+
| `acp` | real (opt-in) | Connects to any Agent Client Protocol agent over JSON-RPC/stdio. Needs `payload.useRealAdapter: true` and an agent via `payload.acpCommand`/`acpArgs`, `payload.acpAgent` preset, or `WFM_ACP_COMMAND`. Permissions via `payload.acpPermissions` (`allow`/`deny`/`reads-only`). |
|
|
66
|
+
| `claude-code` | real (opt-in) | ACP preset (`claude-code-acp`). Routes through `acp` when `useRealAdapter` is true. Legacy `claude` CLI path via `payload.legacyExecutor: true`. |
|
|
67
|
+
| `opencode` | real (opt-in) | ACP preset (`opencode acp`). Routes through `acp` when `useRealAdapter` is true. Legacy `opencode` CLI path via `payload.legacyExecutor: true`. |
|
|
68
|
+
| `codex` | mock / opt-in | Mock by default; routes through `acp` when `useRealAdapter` and an `acpCommand`/`acpAgent` are set. |
|
|
68
69
|
| `mock` | mock | Deterministic simulation for tests and examples. |
|
|
69
70
|
|
|
70
71
|
Provider API keys are inferred from `taskSpec.init.model` and checked by `wfm doctor` / run preflight:
|