@davidorex/pi-workflows 0.2.0 → 0.3.1
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/CHANGELOG.md +7 -4
- package/README.md +12 -2
- package/agents/architecture-designer.agent.yaml +58 -0
- package/agents/architecture-inferrer.agent.yaml +60 -0
- package/agents/gap-identifier.agent.yaml +66 -0
- package/agents/handoff-writer.agent.yaml +64 -0
- package/agents/plan-creator.agent.yaml +63 -0
- package/agents/project-definer.agent.yaml +53 -0
- package/agents/project-inferrer.agent.yaml +63 -0
- package/agents/requirements-gatherer.agent.yaml +58 -0
- package/dist/agent-spec.d.ts +27 -0
- package/dist/agent-spec.d.ts.map +1 -0
- package/dist/agent-spec.js +112 -0
- package/dist/agent-spec.js.map +1 -0
- package/dist/checkpoint.d.ts +50 -0
- package/dist/checkpoint.d.ts.map +1 -0
- package/dist/checkpoint.js +109 -0
- package/dist/checkpoint.js.map +1 -0
- package/dist/completion.d.ts +18 -0
- package/dist/completion.d.ts.map +1 -0
- package/dist/completion.js +58 -0
- package/dist/completion.js.map +1 -0
- package/dist/dag.d.ts +56 -0
- package/dist/dag.d.ts.map +1 -0
- package/dist/dag.js +178 -0
- package/dist/dag.js.map +1 -0
- package/dist/dispatch.d.ts +35 -0
- package/dist/dispatch.d.ts.map +1 -0
- package/dist/dispatch.js +311 -0
- package/dist/dispatch.js.map +1 -0
- package/dist/expression.d.ts +64 -0
- package/dist/expression.d.ts.map +1 -0
- package/dist/expression.js +294 -0
- package/dist/expression.js.map +1 -0
- package/dist/format.d.ts +19 -0
- package/dist/format.d.ts.map +1 -0
- package/dist/format.js +42 -0
- package/dist/format.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +724 -0
- package/dist/index.js.map +1 -0
- package/dist/output.d.ts +12 -0
- package/dist/output.d.ts.map +1 -0
- package/dist/output.js +59 -0
- package/dist/output.js.map +1 -0
- package/dist/state.d.ts +66 -0
- package/dist/state.d.ts.map +1 -0
- package/dist/state.js +227 -0
- package/dist/state.js.map +1 -0
- package/dist/step-agent.d.ts +35 -0
- package/dist/step-agent.d.ts.map +1 -0
- package/dist/step-agent.js +171 -0
- package/dist/step-agent.js.map +1 -0
- package/dist/step-command.d.ts +21 -0
- package/dist/step-command.d.ts.map +1 -0
- package/dist/step-command.js +150 -0
- package/dist/step-command.js.map +1 -0
- package/dist/step-foreach.d.ts +33 -0
- package/dist/step-foreach.d.ts.map +1 -0
- package/dist/step-foreach.js +112 -0
- package/dist/step-foreach.js.map +1 -0
- package/dist/step-gate.d.ts +18 -0
- package/dist/step-gate.d.ts.map +1 -0
- package/dist/step-gate.js +137 -0
- package/dist/step-gate.js.map +1 -0
- package/dist/step-loop.d.ts +32 -0
- package/dist/step-loop.d.ts.map +1 -0
- package/dist/step-loop.js +278 -0
- package/dist/step-loop.js.map +1 -0
- package/dist/step-monitor.d.ts +56 -0
- package/dist/step-monitor.d.ts.map +1 -0
- package/dist/step-monitor.js +317 -0
- package/dist/step-monitor.js.map +1 -0
- package/dist/step-parallel.d.ts +43 -0
- package/dist/step-parallel.d.ts.map +1 -0
- package/dist/step-parallel.js +122 -0
- package/dist/step-parallel.js.map +1 -0
- package/{src/step-pause.ts → dist/step-pause.d.ts} +3 -13
- package/dist/step-pause.d.ts.map +1 -0
- package/dist/step-pause.js +22 -0
- package/dist/step-pause.js.map +1 -0
- package/dist/step-shared.d.ts +63 -0
- package/dist/step-shared.d.ts.map +1 -0
- package/dist/step-shared.js +136 -0
- package/dist/step-shared.js.map +1 -0
- package/dist/step-transform.d.ts +11 -0
- package/dist/step-transform.d.ts.map +1 -0
- package/dist/step-transform.js +41 -0
- package/dist/step-transform.js.map +1 -0
- package/dist/template.d.ts +51 -0
- package/dist/template.d.ts.map +1 -0
- package/{src/template.ts → dist/template.js} +32 -38
- package/dist/template.js.map +1 -0
- package/dist/test-helpers.d.ts +17 -0
- package/dist/test-helpers.d.ts.map +1 -0
- package/dist/test-helpers.js +46 -0
- package/dist/test-helpers.js.map +1 -0
- package/dist/tui.d.ts +47 -0
- package/dist/tui.d.ts.map +1 -0
- package/dist/tui.js +143 -0
- package/dist/tui.js.map +1 -0
- package/dist/types.d.ts +165 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/workflow-discovery.d.ts +23 -0
- package/dist/workflow-discovery.d.ts.map +1 -0
- package/dist/workflow-discovery.js +103 -0
- package/dist/workflow-discovery.js.map +1 -0
- package/dist/workflow-executor.d.ts +50 -0
- package/dist/workflow-executor.d.ts.map +1 -0
- package/dist/workflow-executor.js +738 -0
- package/dist/workflow-executor.js.map +1 -0
- package/dist/workflow-sdk.d.ts +44 -0
- package/dist/workflow-sdk.d.ts.map +1 -0
- package/dist/workflow-sdk.js +457 -0
- package/dist/workflow-sdk.js.map +1 -0
- package/dist/workflow-spec.d.ts +33 -0
- package/dist/workflow-spec.d.ts.map +1 -0
- package/dist/workflow-spec.js +476 -0
- package/dist/workflow-spec.js.map +1 -0
- package/dist/workflows-dir.d.ts +3 -0
- package/dist/workflows-dir.d.ts.map +1 -0
- package/{src/workflows-dir.ts → dist/workflows-dir.js} +1 -0
- package/dist/workflows-dir.js.map +1 -0
- package/package.json +53 -38
- package/schemas/decomposition-specs.schema.json +48 -48
- package/schemas/execution-results.schema.json +48 -48
- package/schemas/investigation-findings.schema.json +43 -43
- package/schemas/pattern-analysis.schema.json +41 -41
- package/schemas/phase.schema.json +48 -0
- package/schemas/plan-breakdown.schema.json +20 -20
- package/schemas/quality-analysis.schema.json +33 -33
- package/schemas/research-findings.schema.json +42 -42
- package/schemas/structure-analysis.schema.json +40 -40
- package/schemas/synthesis.schema.json +31 -31
- package/schemas/verifier-output.schema.json +94 -94
- package/skill-narrative.md +107 -0
- package/skills/pi-workflows/SKILL.md +185 -0
- package/skills/pi-workflows/references/bundled-resources.md +87 -0
- package/templates/architecture-designer/task.md +117 -0
- package/templates/architecture-inferrer/task.md +61 -0
- package/templates/gap-identifier/task.md +103 -0
- package/templates/handoff-writer/task.md +91 -0
- package/templates/plan-creator/task.md +143 -0
- package/templates/project-definer/task.md +67 -0
- package/templates/project-inferrer/task.md +56 -0
- package/templates/requirements-gatherer/task.md +90 -0
- package/workflows/analyze-existing-project.workflow.yaml +94 -0
- package/workflows/create-handoff.workflow.yaml +66 -0
- package/workflows/create-phase.workflow.yaml +1 -1
- package/workflows/do-gap.workflow.yaml +4 -4
- package/workflows/fix-audit.workflow.yaml +1 -1
- package/workflows/gap-to-phase.workflow.yaml +1 -1
- package/workflows/init-new-project.workflow.yaml +81 -0
- package/workflows/plan-from-requirements.workflow.yaml +59 -0
- package/workflows/self-implement.workflow.yaml +3 -3
- package/workflows/typed-analysis.workflow.yaml +4 -4
- package/src/agent-spec.test.ts +0 -289
- package/src/agent-spec.ts +0 -122
- package/src/block-validation.test.ts +0 -350
- package/src/checkpoint.test.ts +0 -237
- package/src/checkpoint.ts +0 -140
- package/src/completion.test.ts +0 -143
- package/src/completion.ts +0 -68
- package/src/dag.test.ts +0 -350
- package/src/dag.ts +0 -219
- package/src/dispatch.test.ts +0 -475
- package/src/dispatch.ts +0 -353
- package/src/expression.test.ts +0 -353
- package/src/expression.ts +0 -332
- package/src/format.test.ts +0 -80
- package/src/format.ts +0 -41
- package/src/graduated-failure.test.ts +0 -556
- package/src/index.test.ts +0 -114
- package/src/index.ts +0 -516
- package/src/loop.test.ts +0 -551
- package/src/output.test.ts +0 -213
- package/src/output.ts +0 -70
- package/src/parallel-integration.test.ts +0 -175
- package/src/resume.test.ts +0 -192
- package/src/state.test.ts +0 -192
- package/src/state.ts +0 -254
- package/src/step-agent.test.ts +0 -327
- package/src/step-agent.ts +0 -178
- package/src/step-command.test.ts +0 -330
- package/src/step-command.ts +0 -132
- package/src/step-foreach.test.ts +0 -647
- package/src/step-foreach.ts +0 -148
- package/src/step-gate.test.ts +0 -185
- package/src/step-gate.ts +0 -116
- package/src/step-loop.test.ts +0 -626
- package/src/step-loop.ts +0 -323
- package/src/step-parallel.test.ts +0 -475
- package/src/step-parallel.ts +0 -168
- package/src/step-pause.test.ts +0 -30
- package/src/step-shared.test.ts +0 -355
- package/src/step-shared.ts +0 -157
- package/src/step-transform.test.ts +0 -155
- package/src/step-transform.ts +0 -47
- package/src/template-integration.test.ts +0 -72
- package/src/template.test.ts +0 -162
- package/src/test-helpers.ts +0 -51
- package/src/tui.test.ts +0 -355
- package/src/tui.ts +0 -182
- package/src/types.ts +0 -195
- package/src/verifier-schema.test.ts +0 -226
- package/src/workflow-discovery.test.ts +0 -105
- package/src/workflow-discovery.ts +0 -114
- package/src/workflow-executor.test.ts +0 -1532
- package/src/workflow-executor.ts +0 -810
- package/src/workflow-sdk.test.ts +0 -238
- package/src/workflow-sdk.ts +0 -262
- package/src/workflow-spec.test.ts +0 -576
- package/src/workflow-spec.ts +0 -471
|
@@ -1,1532 +0,0 @@
|
|
|
1
|
-
import { describe, it } from "node:test";
|
|
2
|
-
import assert from "node:assert";
|
|
3
|
-
import { executeWorkflow } from "./workflow-executor.ts";
|
|
4
|
-
import type { WorkflowSpec } from "./types.ts";
|
|
5
|
-
import { mockCtx, mockPi, makeSpec } from "./test-helpers.ts";
|
|
6
|
-
import fs from "node:fs";
|
|
7
|
-
import path from "node:path";
|
|
8
|
-
import os from "node:os";
|
|
9
|
-
|
|
10
|
-
// Skip integration tests unless RUN_INTEGRATION=1 and pi is available
|
|
11
|
-
let hasPi = false;
|
|
12
|
-
if (process.env.RUN_INTEGRATION === "1") {
|
|
13
|
-
try {
|
|
14
|
-
const { execSync } = await import("node:child_process");
|
|
15
|
-
execSync("pi --version", { stdio: "ignore" });
|
|
16
|
-
hasPi = true;
|
|
17
|
-
} catch {}
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
describe("executeWorkflow", { skip: !hasPi ? "pi not available" : undefined }, () => {
|
|
21
|
-
it("runs a single-step workflow", async () => {
|
|
22
|
-
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-exec-"));
|
|
23
|
-
const spec: WorkflowSpec = {
|
|
24
|
-
name: "test",
|
|
25
|
-
description: "test workflow",
|
|
26
|
-
steps: {
|
|
27
|
-
greet: { agent: "default" },
|
|
28
|
-
},
|
|
29
|
-
source: "project",
|
|
30
|
-
filePath: path.join(tmpDir, "test.project.yaml"),
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
const pi = mockPi();
|
|
34
|
-
const result = await executeWorkflow(spec, {}, {
|
|
35
|
-
ctx: mockCtx(tmpDir),
|
|
36
|
-
pi,
|
|
37
|
-
loadAgent: () => ({ name: "default" }),
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
assert.strictEqual(result.status, "completed");
|
|
41
|
-
assert.ok(result.steps.greet);
|
|
42
|
-
assert.strictEqual(result.steps.greet.status, "completed");
|
|
43
|
-
assert.ok(result.totalDurationMs > 0);
|
|
44
|
-
assert.ok(pi._messages.length >= 1); // sendMessage called
|
|
45
|
-
|
|
46
|
-
fs.rmSync(tmpDir, { recursive: true });
|
|
47
|
-
}, { timeout: 60000 });
|
|
48
|
-
|
|
49
|
-
it("fails fast on step failure", async () => {
|
|
50
|
-
// Use a pre-aborted signal to reliably trigger failure on the first step.
|
|
51
|
-
// pi does not necessarily fail for unknown agent names, so the original
|
|
52
|
-
// approach of using a nonexistent agent is not reliable. An already-aborted
|
|
53
|
-
// signal exercises the executor's cancellation/fail-fast path: the first
|
|
54
|
-
// step is marked failed with "Workflow cancelled" and the second step
|
|
55
|
-
// is never reached.
|
|
56
|
-
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-exec-"));
|
|
57
|
-
const spec: WorkflowSpec = {
|
|
58
|
-
name: "test",
|
|
59
|
-
description: "test",
|
|
60
|
-
steps: {
|
|
61
|
-
willFail: { agent: "default" },
|
|
62
|
-
shouldNotRun: { agent: "default" },
|
|
63
|
-
},
|
|
64
|
-
source: "project",
|
|
65
|
-
filePath: path.join(tmpDir, "test.project.yaml"),
|
|
66
|
-
};
|
|
67
|
-
|
|
68
|
-
const controller = new AbortController();
|
|
69
|
-
controller.abort(); // pre-abort
|
|
70
|
-
|
|
71
|
-
const result = await executeWorkflow(spec, {}, {
|
|
72
|
-
ctx: mockCtx(tmpDir),
|
|
73
|
-
pi: mockPi(),
|
|
74
|
-
signal: controller.signal,
|
|
75
|
-
loadAgent: () => ({ name: "default" }),
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
assert.strictEqual(result.status, "failed");
|
|
79
|
-
assert.ok(result.steps.willFail);
|
|
80
|
-
assert.strictEqual(result.steps.willFail.error, "Workflow cancelled");
|
|
81
|
-
assert.ok(!result.steps.shouldNotRun); // never executed
|
|
82
|
-
|
|
83
|
-
fs.rmSync(tmpDir, { recursive: true });
|
|
84
|
-
}, { timeout: 30000 });
|
|
85
|
-
|
|
86
|
-
it("validates workflow input", async () => {
|
|
87
|
-
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-exec-"));
|
|
88
|
-
const spec: WorkflowSpec = {
|
|
89
|
-
name: "test",
|
|
90
|
-
description: "test",
|
|
91
|
-
input: {
|
|
92
|
-
type: "object",
|
|
93
|
-
required: ["name"],
|
|
94
|
-
properties: { name: { type: "string" } },
|
|
95
|
-
},
|
|
96
|
-
steps: { s: { agent: "default" } },
|
|
97
|
-
source: "project",
|
|
98
|
-
filePath: path.join(tmpDir, "test.project.yaml"),
|
|
99
|
-
};
|
|
100
|
-
|
|
101
|
-
await assert.rejects(
|
|
102
|
-
() => executeWorkflow(spec, { name: 123 }, {
|
|
103
|
-
ctx: mockCtx(tmpDir),
|
|
104
|
-
pi: mockPi(),
|
|
105
|
-
loadAgent: () => ({ name: "default" }),
|
|
106
|
-
}),
|
|
107
|
-
(err: unknown) => err instanceof Error && err.message.includes("Validation failed"),
|
|
108
|
-
);
|
|
109
|
-
|
|
110
|
-
fs.rmSync(tmpDir, { recursive: true });
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
it("resolves expressions between steps", async () => {
|
|
114
|
-
// This test verifies that step 2 can reference step 1's output.
|
|
115
|
-
// Since we can't easily control what pi outputs, we verify the
|
|
116
|
-
// expression resolution doesn't throw and both steps complete.
|
|
117
|
-
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-exec-"));
|
|
118
|
-
const spec: WorkflowSpec = {
|
|
119
|
-
name: "test",
|
|
120
|
-
description: "test",
|
|
121
|
-
steps: {
|
|
122
|
-
first: { agent: "default" },
|
|
123
|
-
second: {
|
|
124
|
-
agent: "default",
|
|
125
|
-
input: { prior: "${{ steps.first.textOutput }}" },
|
|
126
|
-
},
|
|
127
|
-
},
|
|
128
|
-
source: "project",
|
|
129
|
-
filePath: path.join(tmpDir, "test.project.yaml"),
|
|
130
|
-
};
|
|
131
|
-
|
|
132
|
-
const result = await executeWorkflow(spec, {}, {
|
|
133
|
-
ctx: mockCtx(tmpDir),
|
|
134
|
-
pi: mockPi(),
|
|
135
|
-
loadAgent: () => ({ name: "default" }),
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
assert.strictEqual(result.status, "completed");
|
|
139
|
-
assert.strictEqual(Object.keys(result.steps).length, 2);
|
|
140
|
-
|
|
141
|
-
fs.rmSync(tmpDir, { recursive: true });
|
|
142
|
-
}, { timeout: 120000 });
|
|
143
|
-
|
|
144
|
-
it("persists state to disk", async () => {
|
|
145
|
-
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-exec-"));
|
|
146
|
-
const spec: WorkflowSpec = {
|
|
147
|
-
name: "test",
|
|
148
|
-
description: "test",
|
|
149
|
-
steps: { s: { agent: "default" } },
|
|
150
|
-
source: "project",
|
|
151
|
-
filePath: path.join(tmpDir, "test.project.yaml"),
|
|
152
|
-
};
|
|
153
|
-
|
|
154
|
-
const result = await executeWorkflow(spec, {}, {
|
|
155
|
-
ctx: mockCtx(tmpDir),
|
|
156
|
-
pi: mockPi(),
|
|
157
|
-
loadAgent: () => ({ name: "default" }),
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
// Verify state.json exists in run directory
|
|
161
|
-
const stateFile = path.join(result.runDir, "state.json");
|
|
162
|
-
assert.ok(fs.existsSync(stateFile));
|
|
163
|
-
const savedState = JSON.parse(fs.readFileSync(stateFile, "utf-8"));
|
|
164
|
-
assert.strictEqual(savedState.status, "completed");
|
|
165
|
-
|
|
166
|
-
fs.rmSync(tmpDir, { recursive: true });
|
|
167
|
-
}, { timeout: 60000 });
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
// ── When conditionals ──
|
|
171
|
-
// These tests don't require pi on PATH since gate/transform/when steps
|
|
172
|
-
// don't use subprocess dispatch.
|
|
173
|
-
|
|
174
|
-
describe("when conditionals", () => {
|
|
175
|
-
it("skips step when condition is falsy", async () => {
|
|
176
|
-
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-when-"));
|
|
177
|
-
const spec: WorkflowSpec = {
|
|
178
|
-
name: "test-when",
|
|
179
|
-
description: "test when conditionals",
|
|
180
|
-
steps: {
|
|
181
|
-
setup: {
|
|
182
|
-
agent: "transform",
|
|
183
|
-
transform: {
|
|
184
|
-
mapping: { ready: false, value: 42 },
|
|
185
|
-
},
|
|
186
|
-
},
|
|
187
|
-
conditional: {
|
|
188
|
-
agent: "transform",
|
|
189
|
-
when: "${{ steps.setup.output.ready }}",
|
|
190
|
-
transform: {
|
|
191
|
-
mapping: { result: "should not appear" },
|
|
192
|
-
},
|
|
193
|
-
},
|
|
194
|
-
after: {
|
|
195
|
-
agent: "transform",
|
|
196
|
-
transform: {
|
|
197
|
-
mapping: { final: "done" },
|
|
198
|
-
},
|
|
199
|
-
},
|
|
200
|
-
},
|
|
201
|
-
source: "project",
|
|
202
|
-
filePath: path.join(tmpDir, "test.project.yaml"),
|
|
203
|
-
};
|
|
204
|
-
|
|
205
|
-
const result = await executeWorkflow(spec, {}, {
|
|
206
|
-
ctx: mockCtx(tmpDir),
|
|
207
|
-
pi: mockPi(),
|
|
208
|
-
loadAgent: () => ({ name: "default" }),
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
assert.strictEqual(result.status, "completed");
|
|
212
|
-
assert.strictEqual(result.steps.conditional.status, "skipped");
|
|
213
|
-
// Subsequent step still runs
|
|
214
|
-
assert.strictEqual(result.steps.after.status, "completed");
|
|
215
|
-
assert.deepStrictEqual(result.steps.after.output, { final: "done" });
|
|
216
|
-
|
|
217
|
-
fs.rmSync(tmpDir, { recursive: true });
|
|
218
|
-
});
|
|
219
|
-
|
|
220
|
-
it("runs step when condition is truthy", async () => {
|
|
221
|
-
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-when-"));
|
|
222
|
-
const spec: WorkflowSpec = {
|
|
223
|
-
name: "test-when-truthy",
|
|
224
|
-
description: "test when conditionals truthy",
|
|
225
|
-
steps: {
|
|
226
|
-
setup: {
|
|
227
|
-
agent: "transform",
|
|
228
|
-
transform: {
|
|
229
|
-
mapping: { ready: true },
|
|
230
|
-
},
|
|
231
|
-
},
|
|
232
|
-
conditional: {
|
|
233
|
-
agent: "transform",
|
|
234
|
-
when: "${{ steps.setup.output.ready }}",
|
|
235
|
-
transform: {
|
|
236
|
-
mapping: { result: "executed" },
|
|
237
|
-
},
|
|
238
|
-
},
|
|
239
|
-
},
|
|
240
|
-
source: "project",
|
|
241
|
-
filePath: path.join(tmpDir, "test.project.yaml"),
|
|
242
|
-
};
|
|
243
|
-
|
|
244
|
-
const result = await executeWorkflow(spec, {}, {
|
|
245
|
-
ctx: mockCtx(tmpDir),
|
|
246
|
-
pi: mockPi(),
|
|
247
|
-
loadAgent: () => ({ name: "default" }),
|
|
248
|
-
});
|
|
249
|
-
|
|
250
|
-
assert.strictEqual(result.status, "completed");
|
|
251
|
-
assert.strictEqual(result.steps.conditional.status, "completed");
|
|
252
|
-
assert.deepStrictEqual(result.steps.conditional.output, { result: "executed" });
|
|
253
|
-
|
|
254
|
-
fs.rmSync(tmpDir, { recursive: true });
|
|
255
|
-
});
|
|
256
|
-
});
|
|
257
|
-
|
|
258
|
-
// ── Gate steps ──
|
|
259
|
-
|
|
260
|
-
describe("gate steps", () => {
|
|
261
|
-
it("passes on exit code 0", async () => {
|
|
262
|
-
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-gate-"));
|
|
263
|
-
const spec: WorkflowSpec = {
|
|
264
|
-
name: "test-gate",
|
|
265
|
-
description: "test gate step",
|
|
266
|
-
steps: {
|
|
267
|
-
verify: {
|
|
268
|
-
agent: "gate",
|
|
269
|
-
gate: {
|
|
270
|
-
check: "echo ok",
|
|
271
|
-
},
|
|
272
|
-
},
|
|
273
|
-
},
|
|
274
|
-
source: "project",
|
|
275
|
-
filePath: path.join(tmpDir, "test.project.yaml"),
|
|
276
|
-
};
|
|
277
|
-
|
|
278
|
-
const result = await executeWorkflow(spec, {}, {
|
|
279
|
-
ctx: mockCtx(tmpDir),
|
|
280
|
-
pi: mockPi(),
|
|
281
|
-
loadAgent: () => ({ name: "default" }),
|
|
282
|
-
});
|
|
283
|
-
|
|
284
|
-
assert.strictEqual(result.status, "completed");
|
|
285
|
-
assert.strictEqual(result.steps.verify.status, "completed");
|
|
286
|
-
const gateOutput = result.steps.verify.output as { passed: boolean; exitCode: number; output: string };
|
|
287
|
-
assert.strictEqual(gateOutput.passed, true);
|
|
288
|
-
assert.strictEqual(gateOutput.exitCode, 0);
|
|
289
|
-
assert.strictEqual(gateOutput.output, "ok");
|
|
290
|
-
|
|
291
|
-
fs.rmSync(tmpDir, { recursive: true });
|
|
292
|
-
});
|
|
293
|
-
|
|
294
|
-
it("fails workflow on gate failure with onFail: fail (default)", async () => {
|
|
295
|
-
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-gate-"));
|
|
296
|
-
const spec: WorkflowSpec = {
|
|
297
|
-
name: "test-gate-fail",
|
|
298
|
-
description: "test gate failure",
|
|
299
|
-
steps: {
|
|
300
|
-
verify: {
|
|
301
|
-
agent: "gate",
|
|
302
|
-
gate: {
|
|
303
|
-
check: "exit 1",
|
|
304
|
-
},
|
|
305
|
-
},
|
|
306
|
-
after: {
|
|
307
|
-
agent: "transform",
|
|
308
|
-
transform: {
|
|
309
|
-
mapping: { shouldNotRun: true },
|
|
310
|
-
},
|
|
311
|
-
},
|
|
312
|
-
},
|
|
313
|
-
source: "project",
|
|
314
|
-
filePath: path.join(tmpDir, "test.project.yaml"),
|
|
315
|
-
};
|
|
316
|
-
|
|
317
|
-
const result = await executeWorkflow(spec, {}, {
|
|
318
|
-
ctx: mockCtx(tmpDir),
|
|
319
|
-
pi: mockPi(),
|
|
320
|
-
loadAgent: () => ({ name: "default" }),
|
|
321
|
-
});
|
|
322
|
-
|
|
323
|
-
assert.strictEqual(result.status, "failed");
|
|
324
|
-
assert.strictEqual(result.steps.verify.status, "failed");
|
|
325
|
-
assert.ok(result.steps.verify.error?.includes("Gate check failed"));
|
|
326
|
-
// Second step should not have run
|
|
327
|
-
assert.ok(!result.steps.after);
|
|
328
|
-
|
|
329
|
-
fs.rmSync(tmpDir, { recursive: true });
|
|
330
|
-
});
|
|
331
|
-
|
|
332
|
-
it("continues on gate failure with onFail: continue", async () => {
|
|
333
|
-
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-gate-"));
|
|
334
|
-
const spec: WorkflowSpec = {
|
|
335
|
-
name: "test-gate-continue",
|
|
336
|
-
description: "test gate failure with continue",
|
|
337
|
-
steps: {
|
|
338
|
-
verify: {
|
|
339
|
-
agent: "gate",
|
|
340
|
-
gate: {
|
|
341
|
-
check: "exit 1",
|
|
342
|
-
onFail: "continue",
|
|
343
|
-
},
|
|
344
|
-
},
|
|
345
|
-
after: {
|
|
346
|
-
agent: "transform",
|
|
347
|
-
transform: {
|
|
348
|
-
mapping: { ran: true },
|
|
349
|
-
},
|
|
350
|
-
},
|
|
351
|
-
},
|
|
352
|
-
source: "project",
|
|
353
|
-
filePath: path.join(tmpDir, "test.project.yaml"),
|
|
354
|
-
};
|
|
355
|
-
|
|
356
|
-
const result = await executeWorkflow(spec, {}, {
|
|
357
|
-
ctx: mockCtx(tmpDir),
|
|
358
|
-
pi: mockPi(),
|
|
359
|
-
loadAgent: () => ({ name: "default" }),
|
|
360
|
-
});
|
|
361
|
-
|
|
362
|
-
assert.strictEqual(result.status, "completed");
|
|
363
|
-
// Gate step is completed (not failed) because onFail: continue
|
|
364
|
-
assert.strictEqual(result.steps.verify.status, "completed");
|
|
365
|
-
const gateOutput = result.steps.verify.output as { passed: boolean };
|
|
366
|
-
assert.strictEqual(gateOutput.passed, false);
|
|
367
|
-
// Next step ran
|
|
368
|
-
assert.strictEqual(result.steps.after.status, "completed");
|
|
369
|
-
assert.deepStrictEqual(result.steps.after.output, { ran: true });
|
|
370
|
-
|
|
371
|
-
fs.rmSync(tmpDir, { recursive: true });
|
|
372
|
-
});
|
|
373
|
-
|
|
374
|
-
it("resolves expressions in gate check", async () => {
|
|
375
|
-
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-gate-"));
|
|
376
|
-
const spec: WorkflowSpec = {
|
|
377
|
-
name: "test-gate-expr",
|
|
378
|
-
description: "test gate expression resolution",
|
|
379
|
-
steps: {
|
|
380
|
-
setup: {
|
|
381
|
-
agent: "transform",
|
|
382
|
-
transform: {
|
|
383
|
-
mapping: { cmd: "echo resolved" },
|
|
384
|
-
},
|
|
385
|
-
},
|
|
386
|
-
verify: {
|
|
387
|
-
agent: "gate",
|
|
388
|
-
gate: {
|
|
389
|
-
check: "${{ steps.setup.output.cmd }}",
|
|
390
|
-
},
|
|
391
|
-
},
|
|
392
|
-
},
|
|
393
|
-
source: "project",
|
|
394
|
-
filePath: path.join(tmpDir, "test.project.yaml"),
|
|
395
|
-
};
|
|
396
|
-
|
|
397
|
-
const result = await executeWorkflow(spec, {}, {
|
|
398
|
-
ctx: mockCtx(tmpDir),
|
|
399
|
-
pi: mockPi(),
|
|
400
|
-
loadAgent: () => ({ name: "default" }),
|
|
401
|
-
});
|
|
402
|
-
|
|
403
|
-
assert.strictEqual(result.status, "completed");
|
|
404
|
-
assert.strictEqual(result.steps.verify.status, "completed");
|
|
405
|
-
const gateOutput = result.steps.verify.output as { passed: boolean; output: string };
|
|
406
|
-
assert.strictEqual(gateOutput.passed, true);
|
|
407
|
-
assert.strictEqual(gateOutput.output, "resolved");
|
|
408
|
-
|
|
409
|
-
fs.rmSync(tmpDir, { recursive: true });
|
|
410
|
-
});
|
|
411
|
-
});
|
|
412
|
-
|
|
413
|
-
// ── Artifacts ──
|
|
414
|
-
|
|
415
|
-
describe("artifacts", () => {
|
|
416
|
-
it("writes artifact files after workflow completion", async () => {
|
|
417
|
-
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-artifact-"));
|
|
418
|
-
const spec: WorkflowSpec = {
|
|
419
|
-
name: "test-artifact",
|
|
420
|
-
description: "test artifact writing",
|
|
421
|
-
steps: {
|
|
422
|
-
produce: {
|
|
423
|
-
agent: "transform",
|
|
424
|
-
transform: {
|
|
425
|
-
mapping: { report: "test report content", count: 42 },
|
|
426
|
-
},
|
|
427
|
-
},
|
|
428
|
-
},
|
|
429
|
-
artifacts: {
|
|
430
|
-
textReport: {
|
|
431
|
-
path: path.join(tmpDir, "reports", "latest.txt"),
|
|
432
|
-
from: "steps.produce.output.report",
|
|
433
|
-
},
|
|
434
|
-
jsonReport: {
|
|
435
|
-
path: path.join(tmpDir, "reports", "data.json"),
|
|
436
|
-
from: "steps.produce.output",
|
|
437
|
-
},
|
|
438
|
-
},
|
|
439
|
-
source: "project",
|
|
440
|
-
filePath: path.join(tmpDir, "test.project.yaml"),
|
|
441
|
-
};
|
|
442
|
-
|
|
443
|
-
const result = await executeWorkflow(spec, {}, {
|
|
444
|
-
ctx: mockCtx(tmpDir),
|
|
445
|
-
pi: mockPi(),
|
|
446
|
-
loadAgent: () => ({ name: "default" }),
|
|
447
|
-
});
|
|
448
|
-
|
|
449
|
-
assert.strictEqual(result.status, "completed");
|
|
450
|
-
assert.ok(result.artifacts);
|
|
451
|
-
assert.ok(result.artifacts!.textReport);
|
|
452
|
-
assert.ok(result.artifacts!.jsonReport);
|
|
453
|
-
|
|
454
|
-
// Verify text artifact written as string (not JSON-wrapped)
|
|
455
|
-
const textContent = fs.readFileSync(result.artifacts!.textReport, "utf-8");
|
|
456
|
-
assert.strictEqual(textContent, "test report content");
|
|
457
|
-
|
|
458
|
-
// Verify JSON artifact written as formatted JSON
|
|
459
|
-
const jsonContent = JSON.parse(fs.readFileSync(result.artifacts!.jsonReport, "utf-8"));
|
|
460
|
-
assert.deepStrictEqual(jsonContent, { report: "test report content", count: 42 });
|
|
461
|
-
|
|
462
|
-
fs.rmSync(tmpDir, { recursive: true });
|
|
463
|
-
});
|
|
464
|
-
|
|
465
|
-
it("resolves expressions in artifact path", async () => {
|
|
466
|
-
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-artifact-"));
|
|
467
|
-
const spec: WorkflowSpec = {
|
|
468
|
-
name: "test-artifact-path",
|
|
469
|
-
description: "test artifact path expression",
|
|
470
|
-
steps: {
|
|
471
|
-
produce: {
|
|
472
|
-
agent: "transform",
|
|
473
|
-
transform: {
|
|
474
|
-
mapping: { value: "data" },
|
|
475
|
-
},
|
|
476
|
-
},
|
|
477
|
-
},
|
|
478
|
-
artifacts: {
|
|
479
|
-
report: {
|
|
480
|
-
path: path.join(tmpDir, "reports", "run-${{ runId }}.json"),
|
|
481
|
-
from: "steps.produce.output",
|
|
482
|
-
},
|
|
483
|
-
},
|
|
484
|
-
source: "project",
|
|
485
|
-
filePath: path.join(tmpDir, "test.project.yaml"),
|
|
486
|
-
};
|
|
487
|
-
|
|
488
|
-
const result = await executeWorkflow(spec, {}, {
|
|
489
|
-
ctx: mockCtx(tmpDir),
|
|
490
|
-
pi: mockPi(),
|
|
491
|
-
loadAgent: () => ({ name: "default" }),
|
|
492
|
-
});
|
|
493
|
-
|
|
494
|
-
assert.strictEqual(result.status, "completed");
|
|
495
|
-
assert.ok(result.artifacts);
|
|
496
|
-
// The artifact path should contain the runId
|
|
497
|
-
assert.ok(result.artifacts!.report.includes(result.runId));
|
|
498
|
-
assert.ok(fs.existsSync(result.artifacts!.report));
|
|
499
|
-
|
|
500
|
-
fs.rmSync(tmpDir, { recursive: true });
|
|
501
|
-
});
|
|
502
|
-
|
|
503
|
-
it("handles relative artifact paths resolved against workflow dir", async () => {
|
|
504
|
-
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-artifact-"));
|
|
505
|
-
const spec: WorkflowSpec = {
|
|
506
|
-
name: "test-artifact-rel",
|
|
507
|
-
description: "test relative artifact path",
|
|
508
|
-
steps: {
|
|
509
|
-
produce: {
|
|
510
|
-
agent: "transform",
|
|
511
|
-
transform: {
|
|
512
|
-
mapping: { result: "output" },
|
|
513
|
-
},
|
|
514
|
-
},
|
|
515
|
-
},
|
|
516
|
-
artifacts: {
|
|
517
|
-
report: {
|
|
518
|
-
path: "latest.json",
|
|
519
|
-
from: "steps.produce.output",
|
|
520
|
-
},
|
|
521
|
-
},
|
|
522
|
-
source: "project",
|
|
523
|
-
filePath: path.join(tmpDir, "test.project.yaml"),
|
|
524
|
-
};
|
|
525
|
-
|
|
526
|
-
const result = await executeWorkflow(spec, {}, {
|
|
527
|
-
ctx: mockCtx(tmpDir),
|
|
528
|
-
pi: mockPi(),
|
|
529
|
-
loadAgent: () => ({ name: "default" }),
|
|
530
|
-
});
|
|
531
|
-
|
|
532
|
-
assert.strictEqual(result.status, "completed");
|
|
533
|
-
assert.ok(result.artifacts);
|
|
534
|
-
// Artifact path should be under .workflows/runs/<workflow-name>/
|
|
535
|
-
const workflowDir = path.join(tmpDir, ".workflows", "runs", "test-artifact-rel");
|
|
536
|
-
const expectedPath = path.resolve(workflowDir, "latest.json");
|
|
537
|
-
assert.strictEqual(result.artifacts!.report, expectedPath);
|
|
538
|
-
assert.ok(fs.existsSync(expectedPath));
|
|
539
|
-
|
|
540
|
-
fs.rmSync(tmpDir, { recursive: true });
|
|
541
|
-
});
|
|
542
|
-
|
|
543
|
-
it("artifact failure is non-fatal", async () => {
|
|
544
|
-
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-artifact-"));
|
|
545
|
-
const notifications: Array<{ msg: string; level: string }> = [];
|
|
546
|
-
const spec: WorkflowSpec = {
|
|
547
|
-
name: "test-artifact-fail",
|
|
548
|
-
description: "test artifact failure handling",
|
|
549
|
-
steps: {
|
|
550
|
-
produce: {
|
|
551
|
-
agent: "transform",
|
|
552
|
-
transform: {
|
|
553
|
-
mapping: { value: "data" },
|
|
554
|
-
},
|
|
555
|
-
},
|
|
556
|
-
},
|
|
557
|
-
artifacts: {
|
|
558
|
-
bad: {
|
|
559
|
-
path: path.join(tmpDir, "reports", "output.json"),
|
|
560
|
-
from: "steps.nonexistent.output", // expression will fail
|
|
561
|
-
},
|
|
562
|
-
},
|
|
563
|
-
source: "project",
|
|
564
|
-
filePath: path.join(tmpDir, "test.project.yaml"),
|
|
565
|
-
};
|
|
566
|
-
|
|
567
|
-
const ctx = {
|
|
568
|
-
cwd: tmpDir,
|
|
569
|
-
hasUI: true,
|
|
570
|
-
ui: {
|
|
571
|
-
setWidget: () => {},
|
|
572
|
-
notify: (msg: string, level: string) => notifications.push({ msg, level }),
|
|
573
|
-
setStatus: () => {},
|
|
574
|
-
setWorkingMessage: () => {},
|
|
575
|
-
},
|
|
576
|
-
} as any;
|
|
577
|
-
|
|
578
|
-
const result = await executeWorkflow(spec, {}, {
|
|
579
|
-
ctx,
|
|
580
|
-
pi: mockPi(),
|
|
581
|
-
loadAgent: () => ({ name: "default" }),
|
|
582
|
-
});
|
|
583
|
-
|
|
584
|
-
// Workflow still completes despite artifact failure
|
|
585
|
-
assert.strictEqual(result.status, "completed");
|
|
586
|
-
// No artifacts written
|
|
587
|
-
assert.ok(!result.artifacts || Object.keys(result.artifacts).length === 0);
|
|
588
|
-
// Warning notification was sent
|
|
589
|
-
assert.ok(notifications.some((n) => n.msg.includes("bad") && n.level === "warning"));
|
|
590
|
-
|
|
591
|
-
fs.rmSync(tmpDir, { recursive: true });
|
|
592
|
-
});
|
|
593
|
-
|
|
594
|
-
it("validates artifact targeting .project/ against block schema", async () => {
|
|
595
|
-
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-artifact-"));
|
|
596
|
-
|
|
597
|
-
// Create .project/schemas/ with a schema that requires { items: array }
|
|
598
|
-
const schemasDir = path.join(tmpDir, ".project", "schemas");
|
|
599
|
-
fs.mkdirSync(schemasDir, { recursive: true });
|
|
600
|
-
fs.writeFileSync(path.join(schemasDir, "test-block.schema.json"), JSON.stringify({
|
|
601
|
-
type: "object",
|
|
602
|
-
required: ["items"],
|
|
603
|
-
properties: { items: { type: "array" } },
|
|
604
|
-
}));
|
|
605
|
-
|
|
606
|
-
const spec: WorkflowSpec = {
|
|
607
|
-
name: "test-artifact-block",
|
|
608
|
-
description: "test block-api routing for .project/ artifacts",
|
|
609
|
-
steps: {
|
|
610
|
-
produce: {
|
|
611
|
-
agent: "transform",
|
|
612
|
-
transform: {
|
|
613
|
-
mapping: { bad: "data" }, // does NOT match schema (missing items)
|
|
614
|
-
},
|
|
615
|
-
},
|
|
616
|
-
},
|
|
617
|
-
artifacts: {
|
|
618
|
-
block: {
|
|
619
|
-
path: path.join(tmpDir, ".project", "test-block.json"),
|
|
620
|
-
from: "steps.produce.output",
|
|
621
|
-
},
|
|
622
|
-
},
|
|
623
|
-
source: "test",
|
|
624
|
-
version: "1.0",
|
|
625
|
-
filePath: path.join(tmpDir, "test.project.yaml"),
|
|
626
|
-
};
|
|
627
|
-
|
|
628
|
-
const notifications: { msg: string; level: string }[] = [];
|
|
629
|
-
const result = await executeWorkflow(spec, {}, {
|
|
630
|
-
ctx: {
|
|
631
|
-
cwd: tmpDir,
|
|
632
|
-
hasUI: true,
|
|
633
|
-
ui: {
|
|
634
|
-
setWidget: () => {},
|
|
635
|
-
notify: (msg: string, level: string) => { notifications.push({ msg, level }); },
|
|
636
|
-
setStatus: () => {},
|
|
637
|
-
setWorkingMessage: () => {},
|
|
638
|
-
},
|
|
639
|
-
} as unknown as ExtensionContext,
|
|
640
|
-
pi: mockPi(),
|
|
641
|
-
loadAgent: () => ({ name: "default" }),
|
|
642
|
-
});
|
|
643
|
-
|
|
644
|
-
// Workflow completes (artifact failure is non-fatal)
|
|
645
|
-
assert.strictEqual(result.status, "completed");
|
|
646
|
-
// Block file should NOT have been written (validation failed)
|
|
647
|
-
assert.ok(!fs.existsSync(path.join(tmpDir, ".project", "test-block.json")));
|
|
648
|
-
// Warning about validation failure
|
|
649
|
-
assert.ok(notifications.some(n => n.msg.includes("test-block") && n.level === "warning"));
|
|
650
|
-
|
|
651
|
-
fs.rmSync(tmpDir, { recursive: true });
|
|
652
|
-
});
|
|
653
|
-
|
|
654
|
-
it("includes artifacts in formatResult output", async () => {
|
|
655
|
-
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-artifact-"));
|
|
656
|
-
const spec: WorkflowSpec = {
|
|
657
|
-
name: "test-artifact-format",
|
|
658
|
-
description: "test artifact in formatResult",
|
|
659
|
-
steps: {
|
|
660
|
-
produce: {
|
|
661
|
-
agent: "transform",
|
|
662
|
-
transform: {
|
|
663
|
-
mapping: { value: "data" },
|
|
664
|
-
},
|
|
665
|
-
},
|
|
666
|
-
},
|
|
667
|
-
artifacts: {
|
|
668
|
-
report: {
|
|
669
|
-
path: path.join(tmpDir, "reports", "latest.json"),
|
|
670
|
-
from: "steps.produce.output",
|
|
671
|
-
},
|
|
672
|
-
},
|
|
673
|
-
source: "project",
|
|
674
|
-
filePath: path.join(tmpDir, "test.project.yaml"),
|
|
675
|
-
};
|
|
676
|
-
|
|
677
|
-
const pi = mockPi();
|
|
678
|
-
const result = await executeWorkflow(spec, {}, {
|
|
679
|
-
ctx: mockCtx(tmpDir),
|
|
680
|
-
pi,
|
|
681
|
-
loadAgent: () => ({ name: "default" }),
|
|
682
|
-
});
|
|
683
|
-
|
|
684
|
-
// The sendMessage content should include artifact info
|
|
685
|
-
const lastMsg = pi._messages[pi._messages.length - 1];
|
|
686
|
-
const content = lastMsg.msg.content;
|
|
687
|
-
assert.ok(content.includes("Artifacts:"));
|
|
688
|
-
assert.ok(content.includes("report"));
|
|
689
|
-
|
|
690
|
-
fs.rmSync(tmpDir, { recursive: true });
|
|
691
|
-
});
|
|
692
|
-
});
|
|
693
|
-
|
|
694
|
-
// ── Transform steps ──
|
|
695
|
-
|
|
696
|
-
describe("transform steps", () => {
|
|
697
|
-
it("produces output from expression mapping", async () => {
|
|
698
|
-
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-transform-"));
|
|
699
|
-
const spec: WorkflowSpec = {
|
|
700
|
-
name: "test-transform",
|
|
701
|
-
description: "test transform step",
|
|
702
|
-
steps: {
|
|
703
|
-
merge: {
|
|
704
|
-
agent: "transform",
|
|
705
|
-
transform: {
|
|
706
|
-
mapping: {
|
|
707
|
-
greeting: "hello",
|
|
708
|
-
count: 42,
|
|
709
|
-
nested: { deep: true },
|
|
710
|
-
},
|
|
711
|
-
},
|
|
712
|
-
},
|
|
713
|
-
},
|
|
714
|
-
source: "project",
|
|
715
|
-
filePath: path.join(tmpDir, "test.project.yaml"),
|
|
716
|
-
};
|
|
717
|
-
|
|
718
|
-
const result = await executeWorkflow(spec, {}, {
|
|
719
|
-
ctx: mockCtx(tmpDir),
|
|
720
|
-
pi: mockPi(),
|
|
721
|
-
loadAgent: () => ({ name: "default" }),
|
|
722
|
-
});
|
|
723
|
-
|
|
724
|
-
assert.strictEqual(result.status, "completed");
|
|
725
|
-
assert.strictEqual(result.steps.merge.status, "completed");
|
|
726
|
-
assert.strictEqual(result.steps.merge.agent, "transform");
|
|
727
|
-
const output = result.steps.merge.output as Record<string, unknown>;
|
|
728
|
-
assert.strictEqual(output.greeting, "hello");
|
|
729
|
-
assert.strictEqual(output.count, 42);
|
|
730
|
-
assert.deepStrictEqual(output.nested, { deep: true });
|
|
731
|
-
|
|
732
|
-
fs.rmSync(tmpDir, { recursive: true });
|
|
733
|
-
});
|
|
734
|
-
|
|
735
|
-
it("costs nothing (usage.cost === 0, usage.turns === 0)", async () => {
|
|
736
|
-
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-transform-"));
|
|
737
|
-
const spec: WorkflowSpec = {
|
|
738
|
-
name: "test-transform-cost",
|
|
739
|
-
description: "test transform zero cost",
|
|
740
|
-
steps: {
|
|
741
|
-
merge: {
|
|
742
|
-
agent: "transform",
|
|
743
|
-
transform: {
|
|
744
|
-
mapping: { result: "free" },
|
|
745
|
-
},
|
|
746
|
-
},
|
|
747
|
-
},
|
|
748
|
-
source: "project",
|
|
749
|
-
filePath: path.join(tmpDir, "test.project.yaml"),
|
|
750
|
-
};
|
|
751
|
-
|
|
752
|
-
const result = await executeWorkflow(spec, {}, {
|
|
753
|
-
ctx: mockCtx(tmpDir),
|
|
754
|
-
pi: mockPi(),
|
|
755
|
-
loadAgent: () => ({ name: "default" }),
|
|
756
|
-
});
|
|
757
|
-
|
|
758
|
-
assert.strictEqual(result.status, "completed");
|
|
759
|
-
assert.strictEqual(result.steps.merge.usage.cost, 0);
|
|
760
|
-
assert.strictEqual(result.steps.merge.usage.turns, 0);
|
|
761
|
-
assert.strictEqual(result.steps.merge.usage.input, 0);
|
|
762
|
-
assert.strictEqual(result.steps.merge.usage.output, 0);
|
|
763
|
-
assert.strictEqual(result.totalUsage.cost, 0);
|
|
764
|
-
assert.strictEqual(result.totalUsage.turns, 0);
|
|
765
|
-
|
|
766
|
-
fs.rmSync(tmpDir, { recursive: true });
|
|
767
|
-
});
|
|
768
|
-
});
|
|
769
|
-
|
|
770
|
-
// ── Phase 2 integration tests ──
|
|
771
|
-
// These exercise combined phase 2 features: when, gate, transform, loop
|
|
772
|
-
|
|
773
|
-
function defaultOptions(tmpDir?: string) {
|
|
774
|
-
const cwd = tmpDir ?? fs.mkdtempSync(path.join(os.tmpdir(), "wf-p2-"));
|
|
775
|
-
return {
|
|
776
|
-
ctx: mockCtx(cwd),
|
|
777
|
-
pi: mockPi(),
|
|
778
|
-
loadAgent: () => ({ name: "default" }),
|
|
779
|
-
};
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
describe("phase 2 integration", () => {
|
|
783
|
-
it("runs a workflow with when, gate, transform", async () => {
|
|
784
|
-
const spec = makeSpec({
|
|
785
|
-
steps: {
|
|
786
|
-
source: {
|
|
787
|
-
transform: {
|
|
788
|
-
mapping: { data: "initial" },
|
|
789
|
-
},
|
|
790
|
-
},
|
|
791
|
-
check: {
|
|
792
|
-
gate: { check: "echo pass", onPass: "continue" },
|
|
793
|
-
},
|
|
794
|
-
conditional: {
|
|
795
|
-
when: "${{ steps.check.output.passed }}",
|
|
796
|
-
transform: {
|
|
797
|
-
mapping: {
|
|
798
|
-
wasChecked: "${{ steps.check.output.passed }}",
|
|
799
|
-
sourceStatus: "${{ steps.source.status }}",
|
|
800
|
-
},
|
|
801
|
-
},
|
|
802
|
-
},
|
|
803
|
-
},
|
|
804
|
-
});
|
|
805
|
-
|
|
806
|
-
const result = await executeWorkflow(spec, {}, defaultOptions());
|
|
807
|
-
assert.strictEqual(result.status, "completed");
|
|
808
|
-
assert.strictEqual(result.steps.check.output.passed, true);
|
|
809
|
-
assert.strictEqual(result.steps.conditional.output.wasChecked, true);
|
|
810
|
-
assert.strictEqual(result.steps.conditional.usage.cost, 0);
|
|
811
|
-
});
|
|
812
|
-
|
|
813
|
-
it("skips conditional step when gate fails", async () => {
|
|
814
|
-
const spec = makeSpec({
|
|
815
|
-
steps: {
|
|
816
|
-
check: {
|
|
817
|
-
gate: { check: "exit 1", onFail: "continue" },
|
|
818
|
-
},
|
|
819
|
-
conditional: {
|
|
820
|
-
when: "${{ steps.check.output.passed }}",
|
|
821
|
-
transform: {
|
|
822
|
-
mapping: { result: "should not appear" },
|
|
823
|
-
},
|
|
824
|
-
},
|
|
825
|
-
final: {
|
|
826
|
-
transform: {
|
|
827
|
-
mapping: { done: true },
|
|
828
|
-
},
|
|
829
|
-
},
|
|
830
|
-
},
|
|
831
|
-
});
|
|
832
|
-
|
|
833
|
-
const result = await executeWorkflow(spec, {}, defaultOptions());
|
|
834
|
-
assert.strictEqual(result.status, "completed");
|
|
835
|
-
assert.strictEqual(result.steps.check.output.passed, false);
|
|
836
|
-
assert.strictEqual(result.steps.conditional.status, "skipped");
|
|
837
|
-
assert.strictEqual(result.steps.final.status, "completed");
|
|
838
|
-
});
|
|
839
|
-
|
|
840
|
-
it("runs a loop with gate break", async () => {
|
|
841
|
-
const spec = makeSpec({
|
|
842
|
-
steps: {
|
|
843
|
-
retry: {
|
|
844
|
-
loop: {
|
|
845
|
-
maxAttempts: 5,
|
|
846
|
-
steps: {
|
|
847
|
-
check: {
|
|
848
|
-
gate: {
|
|
849
|
-
check: "echo pass",
|
|
850
|
-
onPass: "break",
|
|
851
|
-
onFail: "continue",
|
|
852
|
-
},
|
|
853
|
-
},
|
|
854
|
-
},
|
|
855
|
-
},
|
|
856
|
-
},
|
|
857
|
-
},
|
|
858
|
-
});
|
|
859
|
-
|
|
860
|
-
const result = await executeWorkflow(spec, {}, defaultOptions());
|
|
861
|
-
assert.strictEqual(result.status, "completed");
|
|
862
|
-
assert.strictEqual(result.steps.retry.output.iterations, 1);
|
|
863
|
-
});
|
|
864
|
-
|
|
865
|
-
it("combines transform, loop, and artifacts", async () => {
|
|
866
|
-
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-p2-"));
|
|
867
|
-
const spec: WorkflowSpec = {
|
|
868
|
-
name: "test-combined",
|
|
869
|
-
description: "combined phase 2 test",
|
|
870
|
-
steps: {
|
|
871
|
-
setup: {
|
|
872
|
-
transform: {
|
|
873
|
-
mapping: { prefix: "test" },
|
|
874
|
-
},
|
|
875
|
-
},
|
|
876
|
-
retry: {
|
|
877
|
-
loop: {
|
|
878
|
-
maxAttempts: 2,
|
|
879
|
-
steps: {
|
|
880
|
-
check: {
|
|
881
|
-
gate: {
|
|
882
|
-
check: "echo pass",
|
|
883
|
-
onPass: "break",
|
|
884
|
-
onFail: "continue",
|
|
885
|
-
},
|
|
886
|
-
},
|
|
887
|
-
},
|
|
888
|
-
},
|
|
889
|
-
},
|
|
890
|
-
summary: {
|
|
891
|
-
transform: {
|
|
892
|
-
mapping: {
|
|
893
|
-
setupResult: "${{ steps.setup.output.prefix }}",
|
|
894
|
-
loopIterations: "${{ steps.retry.output.iterations }}",
|
|
895
|
-
loopStatus: "${{ steps.retry.status }}",
|
|
896
|
-
},
|
|
897
|
-
},
|
|
898
|
-
},
|
|
899
|
-
},
|
|
900
|
-
artifacts: {
|
|
901
|
-
report: {
|
|
902
|
-
path: path.join(tmpDir, "artifacts", "summary.json"),
|
|
903
|
-
from: "steps.summary.output",
|
|
904
|
-
},
|
|
905
|
-
},
|
|
906
|
-
source: "project",
|
|
907
|
-
filePath: path.join(tmpDir, "test.project.yaml"),
|
|
908
|
-
};
|
|
909
|
-
|
|
910
|
-
const result = await executeWorkflow(spec, {}, {
|
|
911
|
-
ctx: mockCtx(tmpDir),
|
|
912
|
-
pi: mockPi(),
|
|
913
|
-
loadAgent: () => ({ name: "default" }),
|
|
914
|
-
});
|
|
915
|
-
|
|
916
|
-
assert.strictEqual(result.status, "completed");
|
|
917
|
-
|
|
918
|
-
// Verify transform output
|
|
919
|
-
const summaryOutput = result.steps.summary.output as Record<string, unknown>;
|
|
920
|
-
assert.strictEqual(summaryOutput.setupResult, "test");
|
|
921
|
-
assert.strictEqual(summaryOutput.loopIterations, 1);
|
|
922
|
-
assert.strictEqual(summaryOutput.loopStatus, "completed");
|
|
923
|
-
|
|
924
|
-
// Verify artifact was written
|
|
925
|
-
assert.ok(result.artifacts);
|
|
926
|
-
assert.ok(result.artifacts!.report);
|
|
927
|
-
const artifactContent = JSON.parse(fs.readFileSync(result.artifacts!.report, "utf-8"));
|
|
928
|
-
assert.strictEqual(artifactContent.setupResult, "test");
|
|
929
|
-
|
|
930
|
-
fs.rmSync(tmpDir, { recursive: true });
|
|
931
|
-
});
|
|
932
|
-
});
|
|
933
|
-
|
|
934
|
-
describe("parallel execution (DAG-inferred)", () => {
|
|
935
|
-
it("runs independent steps in parallel when they have explicit deps", async () => {
|
|
936
|
-
// Two steps both depend on a source step → they form a parallel layer
|
|
937
|
-
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-parallel-"));
|
|
938
|
-
const spec: WorkflowSpec = {
|
|
939
|
-
name: "test-parallel",
|
|
940
|
-
description: "test parallel",
|
|
941
|
-
steps: {
|
|
942
|
-
source: {
|
|
943
|
-
transform: { mapping: { data: "hello" } },
|
|
944
|
-
},
|
|
945
|
-
analyzerA: {
|
|
946
|
-
transform: {
|
|
947
|
-
mapping: { result: "${{ steps.source.output.data }}-A" },
|
|
948
|
-
},
|
|
949
|
-
},
|
|
950
|
-
analyzerB: {
|
|
951
|
-
transform: {
|
|
952
|
-
mapping: { result: "${{ steps.source.output.data }}-B" },
|
|
953
|
-
},
|
|
954
|
-
},
|
|
955
|
-
merge: {
|
|
956
|
-
transform: {
|
|
957
|
-
mapping: {
|
|
958
|
-
a: "${{ steps.analyzerA.output.result }}",
|
|
959
|
-
b: "${{ steps.analyzerB.output.result }}",
|
|
960
|
-
},
|
|
961
|
-
},
|
|
962
|
-
},
|
|
963
|
-
},
|
|
964
|
-
source: "project",
|
|
965
|
-
filePath: path.join(tmpDir, "test.project.yaml"),
|
|
966
|
-
};
|
|
967
|
-
|
|
968
|
-
const result = await executeWorkflow(spec, {}, {
|
|
969
|
-
ctx: mockCtx(tmpDir),
|
|
970
|
-
pi: mockPi(),
|
|
971
|
-
loadAgent: () => ({ name: "default" }),
|
|
972
|
-
});
|
|
973
|
-
|
|
974
|
-
assert.strictEqual(result.status, "completed");
|
|
975
|
-
assert.strictEqual(result.steps.source.status, "completed");
|
|
976
|
-
assert.strictEqual(result.steps.analyzerA.status, "completed");
|
|
977
|
-
assert.strictEqual(result.steps.analyzerB.status, "completed");
|
|
978
|
-
assert.strictEqual(result.steps.merge.status, "completed");
|
|
979
|
-
|
|
980
|
-
const mergeOutput = result.steps.merge.output as Record<string, unknown>;
|
|
981
|
-
assert.strictEqual(mergeOutput.a, "hello-A");
|
|
982
|
-
assert.strictEqual(mergeOutput.b, "hello-B");
|
|
983
|
-
|
|
984
|
-
fs.rmSync(tmpDir, { recursive: true });
|
|
985
|
-
});
|
|
986
|
-
|
|
987
|
-
it("fails fast when parallel step fails", async () => {
|
|
988
|
-
// source → (analyzerA, failGate) → merge
|
|
989
|
-
// failGate fails, so merge should not run
|
|
990
|
-
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-parallel-"));
|
|
991
|
-
const spec: WorkflowSpec = {
|
|
992
|
-
name: "test-parallel-fail",
|
|
993
|
-
description: "test parallel fail",
|
|
994
|
-
steps: {
|
|
995
|
-
source: {
|
|
996
|
-
transform: { mapping: { data: "hello" } },
|
|
997
|
-
},
|
|
998
|
-
analyzerA: {
|
|
999
|
-
transform: {
|
|
1000
|
-
mapping: { result: "${{ steps.source.output.data }}" },
|
|
1001
|
-
},
|
|
1002
|
-
},
|
|
1003
|
-
failGate: {
|
|
1004
|
-
gate: { check: "exit 1" },
|
|
1005
|
-
when: "${{ steps.source.output.data }}",
|
|
1006
|
-
},
|
|
1007
|
-
merge: {
|
|
1008
|
-
transform: {
|
|
1009
|
-
mapping: {
|
|
1010
|
-
a: "${{ steps.analyzerA.output.result }}",
|
|
1011
|
-
b: "${{ steps.failGate.output }}",
|
|
1012
|
-
},
|
|
1013
|
-
},
|
|
1014
|
-
},
|
|
1015
|
-
},
|
|
1016
|
-
source: "project",
|
|
1017
|
-
filePath: path.join(tmpDir, "test.project.yaml"),
|
|
1018
|
-
};
|
|
1019
|
-
|
|
1020
|
-
const result = await executeWorkflow(spec, {}, {
|
|
1021
|
-
ctx: mockCtx(tmpDir),
|
|
1022
|
-
pi: mockPi(),
|
|
1023
|
-
loadAgent: () => ({ name: "default" }),
|
|
1024
|
-
});
|
|
1025
|
-
|
|
1026
|
-
assert.strictEqual(result.status, "failed");
|
|
1027
|
-
assert.strictEqual(result.steps.failGate.status, "failed");
|
|
1028
|
-
// merge should not have run
|
|
1029
|
-
assert.ok(!result.steps.merge);
|
|
1030
|
-
|
|
1031
|
-
fs.rmSync(tmpDir, { recursive: true });
|
|
1032
|
-
});
|
|
1033
|
-
|
|
1034
|
-
it("sequential steps without deps remain sequential", async () => {
|
|
1035
|
-
// Steps without ${{ steps.X }} deps are sequential by declaration order
|
|
1036
|
-
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-seq-"));
|
|
1037
|
-
const spec: WorkflowSpec = {
|
|
1038
|
-
name: "test-seq",
|
|
1039
|
-
description: "test sequential",
|
|
1040
|
-
steps: {
|
|
1041
|
-
a: { transform: { mapping: { x: 1 } } },
|
|
1042
|
-
b: { transform: { mapping: { y: 2 } } },
|
|
1043
|
-
c: { transform: { mapping: { z: 3 } } },
|
|
1044
|
-
},
|
|
1045
|
-
source: "project",
|
|
1046
|
-
filePath: path.join(tmpDir, "test.project.yaml"),
|
|
1047
|
-
};
|
|
1048
|
-
|
|
1049
|
-
const result = await executeWorkflow(spec, {}, {
|
|
1050
|
-
ctx: mockCtx(tmpDir),
|
|
1051
|
-
pi: mockPi(),
|
|
1052
|
-
loadAgent: () => ({ name: "default" }),
|
|
1053
|
-
});
|
|
1054
|
-
|
|
1055
|
-
assert.strictEqual(result.status, "completed");
|
|
1056
|
-
assert.strictEqual(result.steps.a.status, "completed");
|
|
1057
|
-
assert.strictEqual(result.steps.b.status, "completed");
|
|
1058
|
-
assert.strictEqual(result.steps.c.status, "completed");
|
|
1059
|
-
|
|
1060
|
-
fs.rmSync(tmpDir, { recursive: true });
|
|
1061
|
-
});
|
|
1062
|
-
});
|
|
1063
|
-
|
|
1064
|
-
describe("explicit parallel step", () => {
|
|
1065
|
-
it("runs sub-steps concurrently", async () => {
|
|
1066
|
-
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-explpar-"));
|
|
1067
|
-
const spec: WorkflowSpec = {
|
|
1068
|
-
name: "test-explicit-parallel",
|
|
1069
|
-
description: "test explicit parallel",
|
|
1070
|
-
steps: {
|
|
1071
|
-
source: {
|
|
1072
|
-
transform: { mapping: { data: "hello" } },
|
|
1073
|
-
},
|
|
1074
|
-
analyzers: {
|
|
1075
|
-
parallel: {
|
|
1076
|
-
security: {
|
|
1077
|
-
transform: {
|
|
1078
|
-
mapping: { result: "${{ steps.source.output.data }}-sec" },
|
|
1079
|
-
},
|
|
1080
|
-
},
|
|
1081
|
-
performance: {
|
|
1082
|
-
transform: {
|
|
1083
|
-
mapping: { result: "${{ steps.source.output.data }}-perf" },
|
|
1084
|
-
},
|
|
1085
|
-
},
|
|
1086
|
-
},
|
|
1087
|
-
},
|
|
1088
|
-
merge: {
|
|
1089
|
-
transform: {
|
|
1090
|
-
mapping: {
|
|
1091
|
-
results: "${{ steps.analyzers.output }}",
|
|
1092
|
-
},
|
|
1093
|
-
},
|
|
1094
|
-
},
|
|
1095
|
-
},
|
|
1096
|
-
source: "project",
|
|
1097
|
-
filePath: path.join(tmpDir, "test.project.yaml"),
|
|
1098
|
-
};
|
|
1099
|
-
|
|
1100
|
-
const result = await executeWorkflow(spec, {}, {
|
|
1101
|
-
ctx: mockCtx(tmpDir),
|
|
1102
|
-
pi: mockPi(),
|
|
1103
|
-
loadAgent: () => ({ name: "default" }),
|
|
1104
|
-
});
|
|
1105
|
-
|
|
1106
|
-
assert.strictEqual(result.status, "completed");
|
|
1107
|
-
assert.strictEqual(result.steps.analyzers.agent, "parallel");
|
|
1108
|
-
assert.strictEqual(result.steps.analyzers.status, "completed");
|
|
1109
|
-
const output = result.steps.analyzers.output as Record<string, unknown>;
|
|
1110
|
-
assert.ok(output.security);
|
|
1111
|
-
assert.ok(output.performance);
|
|
1112
|
-
|
|
1113
|
-
fs.rmSync(tmpDir, { recursive: true });
|
|
1114
|
-
});
|
|
1115
|
-
|
|
1116
|
-
it("fails if any sub-step fails", async () => {
|
|
1117
|
-
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-explpar-fail-"));
|
|
1118
|
-
const spec: WorkflowSpec = {
|
|
1119
|
-
name: "test-parallel-subfail",
|
|
1120
|
-
description: "test parallel sub-step failure",
|
|
1121
|
-
steps: {
|
|
1122
|
-
both: {
|
|
1123
|
-
parallel: {
|
|
1124
|
-
good: {
|
|
1125
|
-
transform: { mapping: { ok: true } },
|
|
1126
|
-
},
|
|
1127
|
-
bad: {
|
|
1128
|
-
gate: { check: "exit 1", onFail: "fail" },
|
|
1129
|
-
},
|
|
1130
|
-
},
|
|
1131
|
-
},
|
|
1132
|
-
},
|
|
1133
|
-
source: "project",
|
|
1134
|
-
filePath: path.join(tmpDir, "test.project.yaml"),
|
|
1135
|
-
};
|
|
1136
|
-
|
|
1137
|
-
const result = await executeWorkflow(spec, {}, {
|
|
1138
|
-
ctx: mockCtx(tmpDir),
|
|
1139
|
-
pi: mockPi(),
|
|
1140
|
-
loadAgent: () => ({ name: "default" }),
|
|
1141
|
-
});
|
|
1142
|
-
|
|
1143
|
-
assert.strictEqual(result.steps.both.status, "failed");
|
|
1144
|
-
assert.strictEqual(result.status, "failed");
|
|
1145
|
-
|
|
1146
|
-
fs.rmSync(tmpDir, { recursive: true });
|
|
1147
|
-
});
|
|
1148
|
-
});
|
|
1149
|
-
|
|
1150
|
-
describe("onExhausted error handling", () => {
|
|
1151
|
-
it("records expression error in onExhausted result", async () => {
|
|
1152
|
-
const spec = makeSpec({
|
|
1153
|
-
steps: {
|
|
1154
|
-
retry: {
|
|
1155
|
-
loop: {
|
|
1156
|
-
maxAttempts: 1,
|
|
1157
|
-
steps: {
|
|
1158
|
-
check: {
|
|
1159
|
-
gate: { check: "exit 1", onFail: "continue" },
|
|
1160
|
-
},
|
|
1161
|
-
},
|
|
1162
|
-
onExhausted: {
|
|
1163
|
-
agent: "default",
|
|
1164
|
-
input: {
|
|
1165
|
-
// Reference a non-existent step to trigger expression error
|
|
1166
|
-
bad: "${{ steps.nonexistent.required_field }}",
|
|
1167
|
-
},
|
|
1168
|
-
},
|
|
1169
|
-
},
|
|
1170
|
-
},
|
|
1171
|
-
},
|
|
1172
|
-
});
|
|
1173
|
-
|
|
1174
|
-
const result = await executeWorkflow(spec, {}, defaultOptions());
|
|
1175
|
-
const loopOutput = result.steps.retry.output;
|
|
1176
|
-
// The exhausted step should have run (agent) but with error noted
|
|
1177
|
-
assert.ok(loopOutput.lastIteration._exhausted);
|
|
1178
|
-
});
|
|
1179
|
-
});
|
|
1180
|
-
|
|
1181
|
-
describe("verify step as typed agent output", () => {
|
|
1182
|
-
it("mock dispatch returning verifier-output JSON produces typed step output", async () => {
|
|
1183
|
-
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-verify-"));
|
|
1184
|
-
const verifierOutput = {
|
|
1185
|
-
status: "passed",
|
|
1186
|
-
score: "2/2",
|
|
1187
|
-
truths: [
|
|
1188
|
-
{ truth: "Tests pass", status: "verified", evidence: "exit code 0" },
|
|
1189
|
-
],
|
|
1190
|
-
criteria_results: [
|
|
1191
|
-
{ criterion: "Tests pass", verify_method: "command", status: "passed", evidence: "3 passing" },
|
|
1192
|
-
{ criterion: "File exists", verify_method: "inspect", status: "passed", evidence: "found" },
|
|
1193
|
-
],
|
|
1194
|
-
gaps: [],
|
|
1195
|
-
};
|
|
1196
|
-
|
|
1197
|
-
const spec: WorkflowSpec = {
|
|
1198
|
-
name: "test-verify",
|
|
1199
|
-
description: "test verify step",
|
|
1200
|
-
steps: {
|
|
1201
|
-
implement: {
|
|
1202
|
-
transform: {
|
|
1203
|
-
mapping: { code: "done", files: ["src/index.ts"] },
|
|
1204
|
-
},
|
|
1205
|
-
},
|
|
1206
|
-
verify: {
|
|
1207
|
-
transform: {
|
|
1208
|
-
mapping: verifierOutput,
|
|
1209
|
-
},
|
|
1210
|
-
},
|
|
1211
|
-
},
|
|
1212
|
-
source: "project",
|
|
1213
|
-
filePath: path.join(tmpDir, "test.project.yaml"),
|
|
1214
|
-
};
|
|
1215
|
-
|
|
1216
|
-
const result = await executeWorkflow(spec, {}, {
|
|
1217
|
-
ctx: mockCtx(tmpDir),
|
|
1218
|
-
pi: mockPi(),
|
|
1219
|
-
loadAgent: () => ({ name: "verifier" }),
|
|
1220
|
-
});
|
|
1221
|
-
|
|
1222
|
-
assert.strictEqual(result.status, "completed");
|
|
1223
|
-
assert.strictEqual(result.steps.verify.status, "completed");
|
|
1224
|
-
const output = result.steps.verify.output as any;
|
|
1225
|
-
assert.strictEqual(output.status, "passed");
|
|
1226
|
-
assert.strictEqual(output.score, "2/2");
|
|
1227
|
-
assert.strictEqual(output.truths.length, 1);
|
|
1228
|
-
assert.strictEqual(output.criteria_results.length, 2);
|
|
1229
|
-
|
|
1230
|
-
fs.rmSync(tmpDir, { recursive: true });
|
|
1231
|
-
});
|
|
1232
|
-
|
|
1233
|
-
it("downstream step reads verify output via expressions", async () => {
|
|
1234
|
-
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-verify-expr-"));
|
|
1235
|
-
const spec: WorkflowSpec = {
|
|
1236
|
-
name: "test-verify-expr",
|
|
1237
|
-
description: "test verify expression access",
|
|
1238
|
-
steps: {
|
|
1239
|
-
verify: {
|
|
1240
|
-
transform: {
|
|
1241
|
-
mapping: {
|
|
1242
|
-
status: "gaps_found",
|
|
1243
|
-
score: "1/3",
|
|
1244
|
-
truths: [{ truth: "Tests pass", status: "verified", evidence: "ok" }],
|
|
1245
|
-
criteria_results: [{ criterion: "C1", verify_method: "command", status: "passed", evidence: "ok" }],
|
|
1246
|
-
gaps: [{ truth: "Feature X", status: "failed", reason: "Not implemented" }],
|
|
1247
|
-
},
|
|
1248
|
-
},
|
|
1249
|
-
},
|
|
1250
|
-
react: {
|
|
1251
|
-
transform: {
|
|
1252
|
-
mapping: {
|
|
1253
|
-
verifyStatus: "${{ steps.verify.output.status }}",
|
|
1254
|
-
verifyGaps: "${{ steps.verify.output.gaps }}",
|
|
1255
|
-
gapCount: "${{ steps.verify.output.gaps.length }}",
|
|
1256
|
-
},
|
|
1257
|
-
},
|
|
1258
|
-
},
|
|
1259
|
-
},
|
|
1260
|
-
source: "project",
|
|
1261
|
-
filePath: path.join(tmpDir, "test.project.yaml"),
|
|
1262
|
-
};
|
|
1263
|
-
|
|
1264
|
-
const result = await executeWorkflow(spec, {}, {
|
|
1265
|
-
ctx: mockCtx(tmpDir),
|
|
1266
|
-
pi: mockPi(),
|
|
1267
|
-
loadAgent: () => ({ name: "default" }),
|
|
1268
|
-
});
|
|
1269
|
-
|
|
1270
|
-
assert.strictEqual(result.status, "completed");
|
|
1271
|
-
const reactOutput = result.steps.react.output as any;
|
|
1272
|
-
assert.strictEqual(reactOutput.verifyStatus, "gaps_found");
|
|
1273
|
-
assert.ok(Array.isArray(reactOutput.verifyGaps));
|
|
1274
|
-
assert.strictEqual(reactOutput.verifyGaps.length, 1);
|
|
1275
|
-
assert.strictEqual(reactOutput.verifyGaps[0].reason, "Not implemented");
|
|
1276
|
-
assert.strictEqual(reactOutput.gapCount, 1);
|
|
1277
|
-
|
|
1278
|
-
fs.rmSync(tmpDir, { recursive: true });
|
|
1279
|
-
});
|
|
1280
|
-
});
|
|
1281
|
-
|
|
1282
|
-
// ── Self-implement workflow integration tests ──
|
|
1283
|
-
// These use transforms to simulate agent outputs, avoiding real subprocess dispatch.
|
|
1284
|
-
|
|
1285
|
-
describe("self-implement workflow", () => {
|
|
1286
|
-
it("parses and executes with mock data — all steps complete, gate passes", async () => {
|
|
1287
|
-
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-self-impl-"));
|
|
1288
|
-
const planOutput = {
|
|
1289
|
-
plans: [
|
|
1290
|
-
{
|
|
1291
|
-
name: "step-foreach",
|
|
1292
|
-
intent: "Implement forEach step type",
|
|
1293
|
-
tasks: ["Create step-foreach.ts", "Add tests"],
|
|
1294
|
-
files_to_change: ["src/step-foreach.ts", "src/step-foreach.test.ts"],
|
|
1295
|
-
acceptance_criteria: ["forEach iterates array", "Tests pass"],
|
|
1296
|
-
context_needed: ["src/types.ts"],
|
|
1297
|
-
parallel_group: "a",
|
|
1298
|
-
},
|
|
1299
|
-
{
|
|
1300
|
-
name: "step-command",
|
|
1301
|
-
intent: "Implement command step type",
|
|
1302
|
-
tasks: ["Create step-command.ts", "Add tests"],
|
|
1303
|
-
files_to_change: ["src/step-command.ts", "src/step-command.test.ts"],
|
|
1304
|
-
acceptance_criteria: ["Command executes", "Tests pass"],
|
|
1305
|
-
context_needed: ["src/types.ts"],
|
|
1306
|
-
parallel_group: "a",
|
|
1307
|
-
},
|
|
1308
|
-
],
|
|
1309
|
-
};
|
|
1310
|
-
|
|
1311
|
-
const execResult = {
|
|
1312
|
-
status: "complete",
|
|
1313
|
-
tasks: [{ name: "implement", status: "done", files_modified: ["src/step-foreach.ts"] }],
|
|
1314
|
-
decisions: [],
|
|
1315
|
-
issues: [],
|
|
1316
|
-
test_count: 5,
|
|
1317
|
-
commit_hash: "",
|
|
1318
|
-
};
|
|
1319
|
-
|
|
1320
|
-
const verifyOutput = {
|
|
1321
|
-
status: "passed",
|
|
1322
|
-
score: "3/3",
|
|
1323
|
-
truths: [{ truth: "All tests pass", status: "verified", evidence: "exit 0" }],
|
|
1324
|
-
criteria_results: [
|
|
1325
|
-
{ criterion: "forEach works", verify_method: "command", status: "passed", evidence: "ok" },
|
|
1326
|
-
{ criterion: "command works", verify_method: "command", status: "passed", evidence: "ok" },
|
|
1327
|
-
{ criterion: "Tests pass", verify_method: "command", status: "passed", evidence: "3 passing" },
|
|
1328
|
-
],
|
|
1329
|
-
gaps: [],
|
|
1330
|
-
};
|
|
1331
|
-
|
|
1332
|
-
// Build a spec that uses transforms to simulate agent outputs
|
|
1333
|
-
const spec: WorkflowSpec = {
|
|
1334
|
-
name: "self-implement",
|
|
1335
|
-
description: "mock self-implement",
|
|
1336
|
-
input: {
|
|
1337
|
-
type: "object",
|
|
1338
|
-
required: ["phaseSpec", "architecture", "conventions"],
|
|
1339
|
-
properties: {
|
|
1340
|
-
phaseSpec: { type: "object" },
|
|
1341
|
-
architecture: { type: "object" },
|
|
1342
|
-
conventions: { type: "object" },
|
|
1343
|
-
},
|
|
1344
|
-
},
|
|
1345
|
-
steps: {
|
|
1346
|
-
plan: {
|
|
1347
|
-
transform: { mapping: planOutput },
|
|
1348
|
-
},
|
|
1349
|
-
implement: {
|
|
1350
|
-
forEach: "${{ steps.plan.output.plans }}",
|
|
1351
|
-
as: "plan",
|
|
1352
|
-
transform: {
|
|
1353
|
-
mapping: execResult,
|
|
1354
|
-
},
|
|
1355
|
-
},
|
|
1356
|
-
verify: {
|
|
1357
|
-
transform: { mapping: verifyOutput },
|
|
1358
|
-
},
|
|
1359
|
-
check: {
|
|
1360
|
-
gate: {
|
|
1361
|
-
check: "echo '${{ steps.verify.output.status }}' | grep -q passed",
|
|
1362
|
-
onFail: "fail",
|
|
1363
|
-
},
|
|
1364
|
-
},
|
|
1365
|
-
},
|
|
1366
|
-
source: "project",
|
|
1367
|
-
filePath: path.join(tmpDir, "test.project.yaml"),
|
|
1368
|
-
};
|
|
1369
|
-
|
|
1370
|
-
const result = await executeWorkflow(spec, {
|
|
1371
|
-
phaseSpec: { name: "test-phase", success_criteria: [] },
|
|
1372
|
-
architecture: { modules: [] },
|
|
1373
|
-
conventions: { rules: [] },
|
|
1374
|
-
}, {
|
|
1375
|
-
ctx: mockCtx(tmpDir),
|
|
1376
|
-
pi: mockPi(),
|
|
1377
|
-
loadAgent: () => ({ name: "default" }),
|
|
1378
|
-
});
|
|
1379
|
-
|
|
1380
|
-
assert.strictEqual(result.status, "completed");
|
|
1381
|
-
assert.strictEqual(result.steps.plan.status, "completed");
|
|
1382
|
-
assert.strictEqual(result.steps.implement.status, "completed");
|
|
1383
|
-
assert.strictEqual(result.steps.verify.status, "completed");
|
|
1384
|
-
assert.strictEqual(result.steps.check.status, "completed");
|
|
1385
|
-
|
|
1386
|
-
// Gate passed
|
|
1387
|
-
const gateOutput = result.steps.check.output as { passed: boolean };
|
|
1388
|
-
assert.strictEqual(gateOutput.passed, true);
|
|
1389
|
-
|
|
1390
|
-
fs.rmSync(tmpDir, { recursive: true });
|
|
1391
|
-
});
|
|
1392
|
-
|
|
1393
|
-
it("fails gate when verification finds gaps", async () => {
|
|
1394
|
-
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-self-impl-fail-"));
|
|
1395
|
-
const planOutput = {
|
|
1396
|
-
plans: [
|
|
1397
|
-
{
|
|
1398
|
-
name: "plan-a",
|
|
1399
|
-
intent: "Do something",
|
|
1400
|
-
tasks: ["Task 1"],
|
|
1401
|
-
acceptance_criteria: ["Works"],
|
|
1402
|
-
},
|
|
1403
|
-
],
|
|
1404
|
-
};
|
|
1405
|
-
|
|
1406
|
-
const execResult = {
|
|
1407
|
-
status: "complete",
|
|
1408
|
-
tasks: [{ name: "task-1", status: "done" }],
|
|
1409
|
-
decisions: [],
|
|
1410
|
-
issues: [],
|
|
1411
|
-
test_count: 1,
|
|
1412
|
-
commit_hash: "",
|
|
1413
|
-
};
|
|
1414
|
-
|
|
1415
|
-
const verifyOutput = {
|
|
1416
|
-
status: "gaps_found",
|
|
1417
|
-
score: "1/3",
|
|
1418
|
-
truths: [{ truth: "Feature missing", status: "failed", evidence: "not found" }],
|
|
1419
|
-
criteria_results: [
|
|
1420
|
-
{ criterion: "Feature works", verify_method: "inspect", status: "failed", evidence: "missing" },
|
|
1421
|
-
],
|
|
1422
|
-
gaps: [{ truth: "Feature missing", status: "failed", reason: "Not implemented" }],
|
|
1423
|
-
};
|
|
1424
|
-
|
|
1425
|
-
const spec: WorkflowSpec = {
|
|
1426
|
-
name: "self-implement-fail",
|
|
1427
|
-
description: "mock self-implement with gaps",
|
|
1428
|
-
steps: {
|
|
1429
|
-
plan: {
|
|
1430
|
-
transform: { mapping: planOutput },
|
|
1431
|
-
},
|
|
1432
|
-
implement: {
|
|
1433
|
-
forEach: "${{ steps.plan.output.plans }}",
|
|
1434
|
-
as: "plan",
|
|
1435
|
-
transform: { mapping: execResult },
|
|
1436
|
-
},
|
|
1437
|
-
verify: {
|
|
1438
|
-
transform: { mapping: verifyOutput },
|
|
1439
|
-
},
|
|
1440
|
-
check: {
|
|
1441
|
-
gate: {
|
|
1442
|
-
check: "echo '${{ steps.verify.output.status }}' | grep -q passed",
|
|
1443
|
-
onFail: "fail",
|
|
1444
|
-
},
|
|
1445
|
-
},
|
|
1446
|
-
},
|
|
1447
|
-
source: "project",
|
|
1448
|
-
filePath: path.join(tmpDir, "test.project.yaml"),
|
|
1449
|
-
};
|
|
1450
|
-
|
|
1451
|
-
const result = await executeWorkflow(spec, {}, {
|
|
1452
|
-
ctx: mockCtx(tmpDir),
|
|
1453
|
-
pi: mockPi(),
|
|
1454
|
-
loadAgent: () => ({ name: "default" }),
|
|
1455
|
-
});
|
|
1456
|
-
|
|
1457
|
-
assert.strictEqual(result.status, "failed");
|
|
1458
|
-
assert.strictEqual(result.steps.check.status, "failed");
|
|
1459
|
-
const gateOutput = result.steps.check.output as { passed: boolean };
|
|
1460
|
-
assert.strictEqual(gateOutput.passed, false);
|
|
1461
|
-
|
|
1462
|
-
fs.rmSync(tmpDir, { recursive: true });
|
|
1463
|
-
});
|
|
1464
|
-
|
|
1465
|
-
it("forEach iterates over plans from decomposition", async () => {
|
|
1466
|
-
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-self-impl-foreach-"));
|
|
1467
|
-
const planOutput = {
|
|
1468
|
-
plans: [
|
|
1469
|
-
{ name: "plan-alpha", intent: "Alpha work", tasks: ["A1", "A2"], acceptance_criteria: ["Alpha done"] },
|
|
1470
|
-
{ name: "plan-beta", intent: "Beta work", tasks: ["B1"], acceptance_criteria: ["Beta done"] },
|
|
1471
|
-
{ name: "plan-gamma", intent: "Gamma work", tasks: ["G1"], acceptance_criteria: ["Gamma done"] },
|
|
1472
|
-
],
|
|
1473
|
-
};
|
|
1474
|
-
|
|
1475
|
-
const spec: WorkflowSpec = {
|
|
1476
|
-
name: "self-implement-foreach",
|
|
1477
|
-
description: "test forEach plan iteration",
|
|
1478
|
-
steps: {
|
|
1479
|
-
plan: {
|
|
1480
|
-
transform: { mapping: planOutput },
|
|
1481
|
-
},
|
|
1482
|
-
implement: {
|
|
1483
|
-
forEach: "${{ steps.plan.output.plans }}",
|
|
1484
|
-
as: "plan",
|
|
1485
|
-
transform: {
|
|
1486
|
-
mapping: {
|
|
1487
|
-
planName: "${{ plan.name }}",
|
|
1488
|
-
planIntent: "${{ plan.intent }}",
|
|
1489
|
-
},
|
|
1490
|
-
},
|
|
1491
|
-
},
|
|
1492
|
-
},
|
|
1493
|
-
source: "project",
|
|
1494
|
-
filePath: path.join(tmpDir, "test.project.yaml"),
|
|
1495
|
-
};
|
|
1496
|
-
|
|
1497
|
-
const result = await executeWorkflow(spec, {}, {
|
|
1498
|
-
ctx: mockCtx(tmpDir),
|
|
1499
|
-
pi: mockPi(),
|
|
1500
|
-
loadAgent: () => ({ name: "default" }),
|
|
1501
|
-
});
|
|
1502
|
-
|
|
1503
|
-
assert.strictEqual(result.status, "completed");
|
|
1504
|
-
assert.strictEqual(result.steps.implement.status, "completed");
|
|
1505
|
-
const output = result.steps.implement.output as any[];
|
|
1506
|
-
assert.strictEqual(output.length, 3);
|
|
1507
|
-
assert.strictEqual(output[0].planName, "plan-alpha");
|
|
1508
|
-
assert.strictEqual(output[0].planIntent, "Alpha work");
|
|
1509
|
-
assert.strictEqual(output[1].planName, "plan-beta");
|
|
1510
|
-
assert.strictEqual(output[2].planName, "plan-gamma");
|
|
1511
|
-
|
|
1512
|
-
fs.rmSync(tmpDir, { recursive: true });
|
|
1513
|
-
});
|
|
1514
|
-
});
|
|
1515
|
-
|
|
1516
|
-
describe("kill grace period constant", () => {
|
|
1517
|
-
it("uses SIGKILL_GRACE_MS (not hardcoded magic numbers)", async () => {
|
|
1518
|
-
// This is a static check — grep the source for hardcoded kill timeouts
|
|
1519
|
-
const { readFileSync } = await import("node:fs");
|
|
1520
|
-
const executorSrc = readFileSync(new URL("./workflow-executor.ts", import.meta.url), "utf-8");
|
|
1521
|
-
const dispatchSrc = readFileSync(new URL("./dispatch.ts", import.meta.url), "utf-8");
|
|
1522
|
-
|
|
1523
|
-
// Verify SIGKILL_GRACE_MS is defined
|
|
1524
|
-
assert.ok(executorSrc.includes("SIGKILL_GRACE_MS"));
|
|
1525
|
-
assert.ok(dispatchSrc.includes("SIGKILL_GRACE_MS"));
|
|
1526
|
-
|
|
1527
|
-
// Verify no remaining hardcoded kill timeouts (2000, 3000, 5000 near SIGKILL)
|
|
1528
|
-
const hardcodedPattern = /setTimeout.*(?:2000|5000).*SIGKILL|SIGKILL.*(?:2000|5000)/;
|
|
1529
|
-
assert.ok(!hardcodedPattern.test(executorSrc), "workflow-executor.ts still has hardcoded kill timeout");
|
|
1530
|
-
assert.ok(!hardcodedPattern.test(dispatchSrc), "dispatch.ts still has hardcoded kill timeout");
|
|
1531
|
-
});
|
|
1532
|
-
});
|