@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.
Files changed (216) hide show
  1. package/CHANGELOG.md +7 -4
  2. package/README.md +12 -2
  3. package/agents/architecture-designer.agent.yaml +58 -0
  4. package/agents/architecture-inferrer.agent.yaml +60 -0
  5. package/agents/gap-identifier.agent.yaml +66 -0
  6. package/agents/handoff-writer.agent.yaml +64 -0
  7. package/agents/plan-creator.agent.yaml +63 -0
  8. package/agents/project-definer.agent.yaml +53 -0
  9. package/agents/project-inferrer.agent.yaml +63 -0
  10. package/agents/requirements-gatherer.agent.yaml +58 -0
  11. package/dist/agent-spec.d.ts +27 -0
  12. package/dist/agent-spec.d.ts.map +1 -0
  13. package/dist/agent-spec.js +112 -0
  14. package/dist/agent-spec.js.map +1 -0
  15. package/dist/checkpoint.d.ts +50 -0
  16. package/dist/checkpoint.d.ts.map +1 -0
  17. package/dist/checkpoint.js +109 -0
  18. package/dist/checkpoint.js.map +1 -0
  19. package/dist/completion.d.ts +18 -0
  20. package/dist/completion.d.ts.map +1 -0
  21. package/dist/completion.js +58 -0
  22. package/dist/completion.js.map +1 -0
  23. package/dist/dag.d.ts +56 -0
  24. package/dist/dag.d.ts.map +1 -0
  25. package/dist/dag.js +178 -0
  26. package/dist/dag.js.map +1 -0
  27. package/dist/dispatch.d.ts +35 -0
  28. package/dist/dispatch.d.ts.map +1 -0
  29. package/dist/dispatch.js +311 -0
  30. package/dist/dispatch.js.map +1 -0
  31. package/dist/expression.d.ts +64 -0
  32. package/dist/expression.d.ts.map +1 -0
  33. package/dist/expression.js +294 -0
  34. package/dist/expression.js.map +1 -0
  35. package/dist/format.d.ts +19 -0
  36. package/dist/format.d.ts.map +1 -0
  37. package/dist/format.js +42 -0
  38. package/dist/format.js.map +1 -0
  39. package/dist/index.d.ts +8 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +724 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/output.d.ts +12 -0
  44. package/dist/output.d.ts.map +1 -0
  45. package/dist/output.js +59 -0
  46. package/dist/output.js.map +1 -0
  47. package/dist/state.d.ts +66 -0
  48. package/dist/state.d.ts.map +1 -0
  49. package/dist/state.js +227 -0
  50. package/dist/state.js.map +1 -0
  51. package/dist/step-agent.d.ts +35 -0
  52. package/dist/step-agent.d.ts.map +1 -0
  53. package/dist/step-agent.js +171 -0
  54. package/dist/step-agent.js.map +1 -0
  55. package/dist/step-command.d.ts +21 -0
  56. package/dist/step-command.d.ts.map +1 -0
  57. package/dist/step-command.js +150 -0
  58. package/dist/step-command.js.map +1 -0
  59. package/dist/step-foreach.d.ts +33 -0
  60. package/dist/step-foreach.d.ts.map +1 -0
  61. package/dist/step-foreach.js +112 -0
  62. package/dist/step-foreach.js.map +1 -0
  63. package/dist/step-gate.d.ts +18 -0
  64. package/dist/step-gate.d.ts.map +1 -0
  65. package/dist/step-gate.js +137 -0
  66. package/dist/step-gate.js.map +1 -0
  67. package/dist/step-loop.d.ts +32 -0
  68. package/dist/step-loop.d.ts.map +1 -0
  69. package/dist/step-loop.js +278 -0
  70. package/dist/step-loop.js.map +1 -0
  71. package/dist/step-monitor.d.ts +56 -0
  72. package/dist/step-monitor.d.ts.map +1 -0
  73. package/dist/step-monitor.js +317 -0
  74. package/dist/step-monitor.js.map +1 -0
  75. package/dist/step-parallel.d.ts +43 -0
  76. package/dist/step-parallel.d.ts.map +1 -0
  77. package/dist/step-parallel.js +122 -0
  78. package/dist/step-parallel.js.map +1 -0
  79. package/{src/step-pause.ts → dist/step-pause.d.ts} +3 -13
  80. package/dist/step-pause.d.ts.map +1 -0
  81. package/dist/step-pause.js +22 -0
  82. package/dist/step-pause.js.map +1 -0
  83. package/dist/step-shared.d.ts +63 -0
  84. package/dist/step-shared.d.ts.map +1 -0
  85. package/dist/step-shared.js +136 -0
  86. package/dist/step-shared.js.map +1 -0
  87. package/dist/step-transform.d.ts +11 -0
  88. package/dist/step-transform.d.ts.map +1 -0
  89. package/dist/step-transform.js +41 -0
  90. package/dist/step-transform.js.map +1 -0
  91. package/dist/template.d.ts +51 -0
  92. package/dist/template.d.ts.map +1 -0
  93. package/{src/template.ts → dist/template.js} +32 -38
  94. package/dist/template.js.map +1 -0
  95. package/dist/test-helpers.d.ts +17 -0
  96. package/dist/test-helpers.d.ts.map +1 -0
  97. package/dist/test-helpers.js +46 -0
  98. package/dist/test-helpers.js.map +1 -0
  99. package/dist/tui.d.ts +47 -0
  100. package/dist/tui.d.ts.map +1 -0
  101. package/dist/tui.js +143 -0
  102. package/dist/tui.js.map +1 -0
  103. package/dist/types.d.ts +165 -0
  104. package/dist/types.d.ts.map +1 -0
  105. package/dist/types.js +3 -0
  106. package/dist/types.js.map +1 -0
  107. package/dist/workflow-discovery.d.ts +23 -0
  108. package/dist/workflow-discovery.d.ts.map +1 -0
  109. package/dist/workflow-discovery.js +103 -0
  110. package/dist/workflow-discovery.js.map +1 -0
  111. package/dist/workflow-executor.d.ts +50 -0
  112. package/dist/workflow-executor.d.ts.map +1 -0
  113. package/dist/workflow-executor.js +738 -0
  114. package/dist/workflow-executor.js.map +1 -0
  115. package/dist/workflow-sdk.d.ts +44 -0
  116. package/dist/workflow-sdk.d.ts.map +1 -0
  117. package/dist/workflow-sdk.js +457 -0
  118. package/dist/workflow-sdk.js.map +1 -0
  119. package/dist/workflow-spec.d.ts +33 -0
  120. package/dist/workflow-spec.d.ts.map +1 -0
  121. package/dist/workflow-spec.js +476 -0
  122. package/dist/workflow-spec.js.map +1 -0
  123. package/dist/workflows-dir.d.ts +3 -0
  124. package/dist/workflows-dir.d.ts.map +1 -0
  125. package/{src/workflows-dir.ts → dist/workflows-dir.js} +1 -0
  126. package/dist/workflows-dir.js.map +1 -0
  127. package/package.json +53 -38
  128. package/schemas/decomposition-specs.schema.json +48 -48
  129. package/schemas/execution-results.schema.json +48 -48
  130. package/schemas/investigation-findings.schema.json +43 -43
  131. package/schemas/pattern-analysis.schema.json +41 -41
  132. package/schemas/phase.schema.json +48 -0
  133. package/schemas/plan-breakdown.schema.json +20 -20
  134. package/schemas/quality-analysis.schema.json +33 -33
  135. package/schemas/research-findings.schema.json +42 -42
  136. package/schemas/structure-analysis.schema.json +40 -40
  137. package/schemas/synthesis.schema.json +31 -31
  138. package/schemas/verifier-output.schema.json +94 -94
  139. package/skill-narrative.md +107 -0
  140. package/skills/pi-workflows/SKILL.md +185 -0
  141. package/skills/pi-workflows/references/bundled-resources.md +87 -0
  142. package/templates/architecture-designer/task.md +117 -0
  143. package/templates/architecture-inferrer/task.md +61 -0
  144. package/templates/gap-identifier/task.md +103 -0
  145. package/templates/handoff-writer/task.md +91 -0
  146. package/templates/plan-creator/task.md +143 -0
  147. package/templates/project-definer/task.md +67 -0
  148. package/templates/project-inferrer/task.md +56 -0
  149. package/templates/requirements-gatherer/task.md +90 -0
  150. package/workflows/analyze-existing-project.workflow.yaml +94 -0
  151. package/workflows/create-handoff.workflow.yaml +66 -0
  152. package/workflows/create-phase.workflow.yaml +1 -1
  153. package/workflows/do-gap.workflow.yaml +4 -4
  154. package/workflows/fix-audit.workflow.yaml +1 -1
  155. package/workflows/gap-to-phase.workflow.yaml +1 -1
  156. package/workflows/init-new-project.workflow.yaml +81 -0
  157. package/workflows/plan-from-requirements.workflow.yaml +59 -0
  158. package/workflows/self-implement.workflow.yaml +3 -3
  159. package/workflows/typed-analysis.workflow.yaml +4 -4
  160. package/src/agent-spec.test.ts +0 -289
  161. package/src/agent-spec.ts +0 -122
  162. package/src/block-validation.test.ts +0 -350
  163. package/src/checkpoint.test.ts +0 -237
  164. package/src/checkpoint.ts +0 -140
  165. package/src/completion.test.ts +0 -143
  166. package/src/completion.ts +0 -68
  167. package/src/dag.test.ts +0 -350
  168. package/src/dag.ts +0 -219
  169. package/src/dispatch.test.ts +0 -475
  170. package/src/dispatch.ts +0 -353
  171. package/src/expression.test.ts +0 -353
  172. package/src/expression.ts +0 -332
  173. package/src/format.test.ts +0 -80
  174. package/src/format.ts +0 -41
  175. package/src/graduated-failure.test.ts +0 -556
  176. package/src/index.test.ts +0 -114
  177. package/src/index.ts +0 -516
  178. package/src/loop.test.ts +0 -551
  179. package/src/output.test.ts +0 -213
  180. package/src/output.ts +0 -70
  181. package/src/parallel-integration.test.ts +0 -175
  182. package/src/resume.test.ts +0 -192
  183. package/src/state.test.ts +0 -192
  184. package/src/state.ts +0 -254
  185. package/src/step-agent.test.ts +0 -327
  186. package/src/step-agent.ts +0 -178
  187. package/src/step-command.test.ts +0 -330
  188. package/src/step-command.ts +0 -132
  189. package/src/step-foreach.test.ts +0 -647
  190. package/src/step-foreach.ts +0 -148
  191. package/src/step-gate.test.ts +0 -185
  192. package/src/step-gate.ts +0 -116
  193. package/src/step-loop.test.ts +0 -626
  194. package/src/step-loop.ts +0 -323
  195. package/src/step-parallel.test.ts +0 -475
  196. package/src/step-parallel.ts +0 -168
  197. package/src/step-pause.test.ts +0 -30
  198. package/src/step-shared.test.ts +0 -355
  199. package/src/step-shared.ts +0 -157
  200. package/src/step-transform.test.ts +0 -155
  201. package/src/step-transform.ts +0 -47
  202. package/src/template-integration.test.ts +0 -72
  203. package/src/template.test.ts +0 -162
  204. package/src/test-helpers.ts +0 -51
  205. package/src/tui.test.ts +0 -355
  206. package/src/tui.ts +0 -182
  207. package/src/types.ts +0 -195
  208. package/src/verifier-schema.test.ts +0 -226
  209. package/src/workflow-discovery.test.ts +0 -105
  210. package/src/workflow-discovery.ts +0 -114
  211. package/src/workflow-executor.test.ts +0 -1532
  212. package/src/workflow-executor.ts +0 -810
  213. package/src/workflow-sdk.test.ts +0 -238
  214. package/src/workflow-sdk.ts +0 -262
  215. package/src/workflow-spec.test.ts +0 -576
  216. 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
- });