@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
@@ -0,0 +1,738 @@
1
+ /**
2
+ * Workflow executor — orchestrates step execution with DAG-based layering,
3
+ * parallel dispatch, timeout enforcement, state persistence, and TUI updates.
4
+ */
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import { readBlock, writeBlock } from "@davidorex/pi-project/src/block-api.js";
8
+ import { rollbackBlockFiles, snapshotBlockFiles, validateChangedBlocks, } from "@davidorex/pi-project/src/block-validation.js";
9
+ import { PROJECT_DIR } from "@davidorex/pi-project/src/project-dir.js";
10
+ import { validate, validateFromFile } from "@davidorex/pi-project/src/schema-validator.js";
11
+ import { truncateTail } from "@mariozechner/pi-coding-agent";
12
+ import { resolveCompletion } from "./completion.js";
13
+ import { buildPlanFromDeps, extractDependencies } from "./dag.js";
14
+ import { dispatch } from "./dispatch.js";
15
+ import { evaluateCondition, resolveExpressions } from "./expression.js";
16
+ import { buildResult, formatResult, generateRunId, getWorkflowDir, initRunDir, writeMetrics, writeState, } from "./state.js";
17
+ import { executeAgentStep } from "./step-agent.js";
18
+ import { executeCommand } from "./step-command.js";
19
+ import { executeForEach } from "./step-foreach.js";
20
+ import { executeGate } from "./step-gate.js";
21
+ import { executeLoop } from "./step-loop.js";
22
+ import { executeMonitor } from "./step-monitor.js";
23
+ import { executeParallelLayer, executeParallelStep } from "./step-parallel.js";
24
+ import { executePause } from "./step-pause.js";
25
+ import { persistStep, resolveSchemaPath, SIGKILL_GRACE_MS, WIDGET_ID, zeroUsage } from "./step-shared.js";
26
+ import { executeTransform } from "./step-transform.js";
27
+ import { createTemplateEnv } from "./template.js";
28
+ import { createProgressWidget } from "./tui.js";
29
+ // Re-export SIGKILL_GRACE_MS so tests that grep this file still find it
30
+ export { SIGKILL_GRACE_MS };
31
+ /** Module-level flag set by the Ctrl+H keybinding handler. */
32
+ let pauseRequested = false;
33
+ /** Set by the extension keybinding to request a pause after the current step. */
34
+ export function requestPause() {
35
+ pauseRequested = true;
36
+ }
37
+ /** Clear the pause flag (called at the start of each workflow execution). */
38
+ function clearPauseRequest() {
39
+ pauseRequested = false;
40
+ }
41
+ /**
42
+ * Build a conservative execution plan that preserves declaration-order
43
+ * sequencing for steps without explicit `${{ steps.X }}` dependencies.
44
+ *
45
+ * Steps with no explicit dependencies implicitly depend on the previous
46
+ * step in declaration order. This ensures backward compatibility with
47
+ * workflows written for sequential execution while still allowing
48
+ * DAG-inferred parallelism for steps that DO have explicit dependencies
49
+ * (e.g., diamond patterns where two steps both depend on an earlier step).
50
+ */
51
+ function buildConservativePlan(spec) {
52
+ const deps = extractDependencies(spec);
53
+ const allSteps = Object.keys(spec.steps);
54
+ // Add implicit declaration-order dependency for steps with no explicit deps.
55
+ // If a step has no ${{ steps.X }} references at all, it depends on the
56
+ // immediately preceding step in YAML order.
57
+ for (let i = 1; i < allSteps.length; i++) {
58
+ const stepDeps = deps.get(allSteps[i]);
59
+ if (stepDeps.size === 0) {
60
+ stepDeps.add(allSteps[i - 1]);
61
+ }
62
+ }
63
+ return buildPlanFromDeps(allSteps, deps);
64
+ }
65
+ /**
66
+ * Determine if a step type supports retry.
67
+ * Agent, forEach, and loop steps are retryable.
68
+ * Command, gate, and transform are deterministic and not retryable.
69
+ */
70
+ function isRetryableStepType(stepSpec) {
71
+ if (stepSpec.command || stepSpec.gate || stepSpec.transform || stepSpec.monitor)
72
+ return false;
73
+ return true;
74
+ }
75
+ /**
76
+ * Execute a single step (agent, gate, transform, loop, or parallel).
77
+ *
78
+ * This is the central step type dispatcher. It delegates to the appropriate
79
+ * step executor module based on the step spec type.
80
+ * Returns true if the workflow should continue, false if it should stop
81
+ * (due to failure, break, or cancellation).
82
+ *
83
+ * Supports per-step retry: if step.retry is configured and the step type
84
+ * is retryable, failures (including block validation) trigger rollback
85
+ * and re-execution with error context injected into the prompt.
86
+ */
87
+ async function executeSingleStep(stepName, stepSpec, state, options) {
88
+ const { ctx, signal, loadAgent, runDir, spec, widgetState } = options;
89
+ // Check cancellation
90
+ if (signal?.aborted) {
91
+ state.steps[stepName] = {
92
+ step: stepName,
93
+ agent: stepSpec.agent ?? "",
94
+ status: "failed",
95
+ usage: zeroUsage(),
96
+ durationMs: 0,
97
+ error: "Workflow cancelled",
98
+ };
99
+ state.status = "failed";
100
+ return false;
101
+ }
102
+ // Build expression scope
103
+ const scope = { input: state.input, steps: state.steps };
104
+ // Expose forEach bindings (as name + forEach metadata) if present on the state
105
+ const stateAny = state;
106
+ if (stateAny.forEach !== undefined) {
107
+ scope.forEach = stateAny.forEach;
108
+ }
109
+ // Expose any custom bindings (e.g. from forEach as)
110
+ for (const key of Object.keys(stateAny)) {
111
+ if (key !== "input" &&
112
+ key !== "steps" &&
113
+ key !== "status" &&
114
+ key !== "loop" &&
115
+ key !== "workflowName" &&
116
+ key !== "specVersion" &&
117
+ key !== "startedAt" &&
118
+ key !== "updatedAt" &&
119
+ key !== "forEach") {
120
+ scope[key] = stateAny[key];
121
+ }
122
+ }
123
+ // Evaluate `when` conditional
124
+ if (stepSpec.when) {
125
+ const conditionExpr = stepSpec.when.replace(/^\$\{\{\s*/, "").replace(/\s*\}\}$/, "");
126
+ const shouldRun = evaluateCondition(conditionExpr, scope);
127
+ if (!shouldRun) {
128
+ persistStep(state, stepName, {
129
+ step: stepName,
130
+ agent: stepSpec.agent ?? "skipped",
131
+ status: "skipped",
132
+ usage: zeroUsage(),
133
+ durationMs: 0,
134
+ }, runDir, widgetState, ctx);
135
+ return true;
136
+ }
137
+ }
138
+ // Determine retry config
139
+ const retryConfig = stepSpec.retry;
140
+ const maxAttempts = retryConfig?.maxAttempts ?? 1;
141
+ const canRetry = isRetryableStepType(stepSpec) && maxAttempts > 1;
142
+ const totalAttempts = canRetry ? maxAttempts : 1;
143
+ const priorErrors = [];
144
+ for (let attempt = 1; attempt <= totalAttempts; attempt++) {
145
+ // Check cancellation between retry attempts
146
+ if (attempt > 1 && signal?.aborted) {
147
+ state.steps[stepName] = {
148
+ step: stepName,
149
+ agent: stepSpec.agent ?? "",
150
+ status: "failed",
151
+ usage: zeroUsage(),
152
+ durationMs: 0,
153
+ error: "Workflow cancelled",
154
+ };
155
+ state.status = "failed";
156
+ return false;
157
+ }
158
+ // Snapshot project block files before step execution for post-step validation
159
+ const blockSnapshot = snapshotBlockFiles(ctx.cwd);
160
+ // Update widget: mark this step as current
161
+ widgetState.currentStep = stepName;
162
+ if (ctx.hasUI) {
163
+ ctx.ui.setWidget(WIDGET_ID, createProgressWidget(widgetState));
164
+ }
165
+ // Build retry context for this attempt (if retrying)
166
+ const retryContext = attempt > 1
167
+ ? {
168
+ attempt,
169
+ priorErrors: [...priorErrors],
170
+ steeringMessage: retryConfig?.steeringMessage,
171
+ }
172
+ : undefined;
173
+ // Execute the step, then validate any changed block files
174
+ // Reset failed status before re-attempt
175
+ if (attempt > 1) {
176
+ delete state.steps[stepName];
177
+ state.status = "running";
178
+ }
179
+ const continueWorkflow = await executeStepByType(stepName, stepSpec, state, scope, options, retryContext);
180
+ // Post-step block validation: if the step succeeded, validate changed project block files
181
+ let blockValidationFailed = false;
182
+ if (continueWorkflow && state.steps[stepName]?.status === "completed") {
183
+ try {
184
+ validateChangedBlocks(ctx.cwd, blockSnapshot);
185
+ }
186
+ catch (err) {
187
+ const msg = err instanceof Error ? err.message : String(err);
188
+ blockValidationFailed = true;
189
+ priorErrors.push(msg);
190
+ // Always rollback — invalid data must not persist
191
+ rollbackBlockFiles(ctx.cwd, blockSnapshot);
192
+ if (attempt < totalAttempts) {
193
+ // Retry
194
+ continue;
195
+ }
196
+ // Last attempt — mark failed
197
+ state.steps[stepName].status = "failed";
198
+ state.steps[stepName].error = msg;
199
+ state.steps[stepName].attempt = attempt;
200
+ state.steps[stepName].totalAttempts = attempt;
201
+ state.steps[stepName].priorErrors = [...priorErrors];
202
+ state.status = "failed";
203
+ persistStep(state, stepName, state.steps[stepName], runDir, widgetState, ctx);
204
+ // Check onExhausted
205
+ if (retryConfig?.onExhausted === "skip") {
206
+ state.steps[stepName].status = "skipped";
207
+ state.steps[stepName].warnings = [
208
+ ...(state.steps[stepName].warnings ?? []),
209
+ `Step skipped after ${attempt} failed attempts (onExhausted: skip)`,
210
+ ];
211
+ state.status = "running";
212
+ persistStep(state, stepName, state.steps[stepName], runDir, widgetState, ctx);
213
+ return true;
214
+ }
215
+ return false;
216
+ }
217
+ }
218
+ if (!continueWorkflow || state.steps[stepName]?.status === "failed") {
219
+ const errorMsg = state.steps[stepName]?.error ?? "Step failed";
220
+ priorErrors.push(errorMsg);
221
+ if (attempt < totalAttempts && canRetry) {
222
+ // Rollback block files and retry
223
+ rollbackBlockFiles(ctx.cwd, blockSnapshot);
224
+ continue;
225
+ }
226
+ // Last attempt or not retryable — annotate and return
227
+ if (state.steps[stepName]) {
228
+ state.steps[stepName].attempt = attempt;
229
+ state.steps[stepName].totalAttempts = attempt;
230
+ state.steps[stepName].priorErrors = attempt > 1 ? [...priorErrors] : undefined;
231
+ persistStep(state, stepName, state.steps[stepName], runDir, widgetState, ctx);
232
+ }
233
+ // Check onExhausted
234
+ if (canRetry && retryConfig?.onExhausted === "skip") {
235
+ if (state.steps[stepName]) {
236
+ state.steps[stepName].status = "skipped";
237
+ state.steps[stepName].warnings = [
238
+ ...(state.steps[stepName].warnings ?? []),
239
+ `Step skipped after ${attempt} failed attempts (onExhausted: skip)`,
240
+ ];
241
+ state.status = "running";
242
+ persistStep(state, stepName, state.steps[stepName], runDir, widgetState, ctx);
243
+ }
244
+ return true;
245
+ }
246
+ return false;
247
+ }
248
+ // Success — annotate with attempt tracking
249
+ if (state.steps[stepName]) {
250
+ state.steps[stepName].attempt = attempt;
251
+ state.steps[stepName].totalAttempts = attempt;
252
+ state.steps[stepName].priorErrors = attempt > 1 ? [...priorErrors] : undefined;
253
+ }
254
+ // Parse step output into a summary for TUI display
255
+ const stepOutput = state.steps[stepName]?.output;
256
+ if (stepOutput && typeof stepOutput === "object") {
257
+ const out = stepOutput;
258
+ const summary = {};
259
+ if (Array.isArray(out.tasks)) {
260
+ summary.tasks = out.tasks
261
+ .filter((t) => t && typeof t === "object")
262
+ .map((t) => ({
263
+ name: String(t.name ?? "?"),
264
+ status: String(t.status ?? "?"),
265
+ files: Array.isArray(t.files_modified) ? t.files_modified.map(String) : undefined,
266
+ }));
267
+ }
268
+ if (typeof out.test_count === "number") {
269
+ summary.testCount = out.test_count;
270
+ }
271
+ if (typeof out.notes === "string") {
272
+ summary.note = out.notes;
273
+ }
274
+ if (summary.tasks || summary.testCount || summary.note) {
275
+ widgetState.outputSummaries.set(stepName, summary);
276
+ if (ctx.hasUI) {
277
+ ctx.ui.setWidget(WIDGET_ID, createProgressWidget(widgetState));
278
+ }
279
+ }
280
+ }
281
+ return continueWorkflow;
282
+ }
283
+ // Should not reach here, but safety net
284
+ return false;
285
+ }
286
+ /**
287
+ * Execute a step based on its type (agent, gate, transform, etc.).
288
+ * Factored out of executeSingleStep to allow post-step validation.
289
+ */
290
+ async function executeStepByType(stepName, stepSpec, state, scope, options, retryContext) {
291
+ const { ctx, signal, loadAgent, runDir, spec, widgetState } = options;
292
+ // ── ForEach step (wraps any step type) ──
293
+ if (stepSpec.forEach) {
294
+ const asName = stepSpec.as ?? "item";
295
+ const forEachResult = await executeForEach(stepName, stepSpec, state, stepSpec.forEach, asName, executeSingleStep, options);
296
+ persistStep(state, stepName, forEachResult, runDir, widgetState, ctx);
297
+ if (forEachResult.status === "failed") {
298
+ state.status = "failed";
299
+ return false;
300
+ }
301
+ return true;
302
+ }
303
+ // ── Gate step ──
304
+ if (stepSpec.gate) {
305
+ const resolvedCheck = String(resolveExpressions(stepSpec.gate.check, scope));
306
+ const resolvedGate = { ...stepSpec.gate, check: resolvedCheck };
307
+ const resolvedGateOutputPath = stepSpec.output?.path
308
+ ? String(resolveExpressions(stepSpec.output.path, scope))
309
+ : undefined;
310
+ const gateResult = await executeGate(resolvedGate, stepName, {
311
+ cwd: ctx.cwd,
312
+ signal,
313
+ timeoutMs: stepSpec.timeout ? stepSpec.timeout.seconds * 1000 : undefined,
314
+ runDir,
315
+ outputPath: resolvedGateOutputPath,
316
+ });
317
+ const gateOutput = gateResult.output;
318
+ if (gateOutput.passed) {
319
+ const onPass = stepSpec.gate.onPass ?? "continue";
320
+ persistStep(state, stepName, gateResult, runDir, widgetState, ctx);
321
+ return onPass !== "break";
322
+ }
323
+ else {
324
+ const onFail = stepSpec.gate.onFail ?? "fail";
325
+ if (onFail === "fail") {
326
+ gateResult.status = "failed";
327
+ gateResult.error = `Gate check failed (exit ${gateOutput.exitCode}): ${gateOutput.output}`;
328
+ persistStep(state, stepName, gateResult, runDir, widgetState, ctx);
329
+ state.status = "failed";
330
+ return false;
331
+ }
332
+ else if (onFail === "continue") {
333
+ persistStep(state, stepName, gateResult, runDir, widgetState, ctx);
334
+ return true;
335
+ }
336
+ else if (onFail === "break") {
337
+ persistStep(state, stepName, gateResult, runDir, widgetState, ctx);
338
+ return false;
339
+ }
340
+ }
341
+ }
342
+ // ── Command step ──
343
+ if (stepSpec.command) {
344
+ const resolvedCommand = String(resolveExpressions(stepSpec.command, scope));
345
+ const resolvedCommandOutputPath = stepSpec.output?.path
346
+ ? String(resolveExpressions(stepSpec.output.path, scope))
347
+ : undefined;
348
+ const commandResult = await executeCommand(resolvedCommand, stepName, {
349
+ cwd: ctx.cwd,
350
+ signal,
351
+ timeoutMs: stepSpec.timeout ? stepSpec.timeout.seconds * 1000 : undefined,
352
+ runDir,
353
+ outputPath: resolvedCommandOutputPath,
354
+ }, stepSpec.output?.format);
355
+ persistStep(state, stepName, commandResult, runDir, widgetState, ctx);
356
+ if (commandResult.status === "failed") {
357
+ state.status = "failed";
358
+ return false;
359
+ }
360
+ return true;
361
+ }
362
+ // ── Monitor step ──
363
+ if (stepSpec.monitor) {
364
+ const resolvedInput = stepSpec.input ? resolveExpressions(stepSpec.input, scope) : {};
365
+ const resolvedMonitorOutputPath = stepSpec.output?.path
366
+ ? String(resolveExpressions(stepSpec.output.path, scope))
367
+ : undefined;
368
+ const monitorResult = await executeMonitor(stepSpec.monitor, stepName, resolvedInput, {
369
+ cwd: ctx.cwd,
370
+ ctx,
371
+ signal,
372
+ runDir,
373
+ outputPath: resolvedMonitorOutputPath,
374
+ });
375
+ persistStep(state, stepName, monitorResult, runDir, widgetState, ctx);
376
+ if (monitorResult.status === "failed") {
377
+ state.status = "failed";
378
+ return false;
379
+ }
380
+ return true;
381
+ }
382
+ // ── Transform step ──
383
+ if (stepSpec.transform) {
384
+ const resolvedTransformOutputPath = stepSpec.output?.path
385
+ ? String(resolveExpressions(stepSpec.output.path, scope))
386
+ : undefined;
387
+ const transformResult = executeTransform(stepSpec.transform, stepName, scope, runDir, resolvedTransformOutputPath);
388
+ persistStep(state, stepName, transformResult, runDir, widgetState, ctx);
389
+ if (transformResult.status === "failed") {
390
+ state.status = "failed";
391
+ return false;
392
+ }
393
+ return true;
394
+ }
395
+ // ── Loop step ──
396
+ if (stepSpec.loop) {
397
+ const resolvedLoopOutputPath = stepSpec.output?.path
398
+ ? String(resolveExpressions(stepSpec.output.path, scope))
399
+ : undefined;
400
+ const loopResult = await executeLoop(stepSpec.loop, stepName, state, {
401
+ ctx,
402
+ pi: options.pi,
403
+ signal,
404
+ loadAgent,
405
+ runDir,
406
+ spec,
407
+ dispatchAgent: (s, a, p, o) => (options.dispatchFn ?? dispatch)(s, a, p, { ...o, modelConfig: options.modelConfig }),
408
+ templateEnv: options.templateEnv,
409
+ outputPath: resolvedLoopOutputPath,
410
+ });
411
+ persistStep(state, stepName, loopResult, runDir, widgetState, ctx);
412
+ if (loopResult.status === "failed") {
413
+ state.status = "failed";
414
+ return false;
415
+ }
416
+ return true;
417
+ }
418
+ // ── Parallel step ──
419
+ if (stepSpec.parallel) {
420
+ const parallelResult = await executeParallelStep(stepSpec.parallel, stepName, state, executeSingleStep, options);
421
+ persistStep(state, stepName, parallelResult, runDir, widgetState, ctx);
422
+ if (parallelResult.status === "failed") {
423
+ state.status = "failed";
424
+ return false;
425
+ }
426
+ return true;
427
+ }
428
+ // ── Pause step ──
429
+ if (stepSpec.pause !== undefined) {
430
+ const message = typeof stepSpec.pause === "string" ? stepSpec.pause : undefined;
431
+ const pauseResult = executePause(stepName, message);
432
+ persistStep(state, stepName, pauseResult, runDir, widgetState, ctx);
433
+ state.status = "paused";
434
+ if (ctx.hasUI) {
435
+ ctx.ui.notify(message || "Workflow paused. Use /workflow resume or Ctrl+J to continue.", "info");
436
+ }
437
+ return false;
438
+ }
439
+ // ── Agent step (default) ──
440
+ const agentResult = await executeAgentStep(stepName, stepSpec, state, {
441
+ ctx,
442
+ signal,
443
+ loadAgent,
444
+ runDir,
445
+ specFilePath: spec.filePath,
446
+ widgetState,
447
+ templateEnv: options.templateEnv,
448
+ dispatchFn: options.dispatchFn,
449
+ modelConfig: options.modelConfig,
450
+ retryContext,
451
+ onStepActivity: (activity) => {
452
+ if (!widgetState.activities)
453
+ widgetState.activities = new Map();
454
+ const existing = widgetState.activities.get(stepName) || [];
455
+ if (existing.length >= 5)
456
+ existing.shift();
457
+ existing.push(activity);
458
+ widgetState.activities.set(stepName, existing);
459
+ if (ctx.hasUI) {
460
+ ctx.ui.setWidget(WIDGET_ID, createProgressWidget(widgetState));
461
+ }
462
+ },
463
+ });
464
+ persistStep(state, stepName, agentResult, runDir, widgetState, ctx);
465
+ if (agentResult.status === "failed") {
466
+ state.status = "failed";
467
+ return false;
468
+ }
469
+ return true;
470
+ }
471
+ /**
472
+ * Execute a workflow from a parsed spec and validated input.
473
+ *
474
+ * Runs steps sequentially (phase 1), resolving ${{ }} expressions,
475
+ * dispatching subprocesses, validating outputs, persisting state,
476
+ * updating TUI, and injecting the result into the conversation.
477
+ *
478
+ * Supports step types: agent (default), gate, transform, loop.
479
+ * Supports `when` conditionals for skipping steps.
480
+ *
481
+ * Returns the WorkflowResult (also injected into conversation via sendMessage).
482
+ */
483
+ export async function executeWorkflow(spec, input, options) {
484
+ const { ctx, pi, signal, loadAgent } = options;
485
+ clearPauseRequest();
486
+ // 1. Validate input against workflow input schema (if defined)
487
+ // Skip on resume — input was validated on first run.
488
+ if (!options.resume && spec.input) {
489
+ validate(spec.input, input, `workflow input for '${spec.name}'`);
490
+ }
491
+ // 2. Initialize or resume run
492
+ let runId;
493
+ let runDir;
494
+ let state;
495
+ if (options.resume) {
496
+ runId = options.resume.runId;
497
+ runDir = options.resume.runDir;
498
+ state = options.resume.state;
499
+ // Reset failed steps so they re-run
500
+ for (const [name, result] of Object.entries(state.steps)) {
501
+ if (result.status === "failed") {
502
+ delete state.steps[name];
503
+ }
504
+ }
505
+ state.status = "running";
506
+ input = state.input; // use original input
507
+ }
508
+ else {
509
+ runId = generateRunId(spec.name);
510
+ runDir = initRunDir(ctx.cwd, spec.name, runId);
511
+ state = {
512
+ input,
513
+ steps: {},
514
+ status: "running",
515
+ workflowName: spec.name,
516
+ specVersion: spec.version,
517
+ startedAt: new Date().toISOString(),
518
+ };
519
+ }
520
+ // 3. Show TUI progress widget
521
+ const widgetState = {
522
+ spec,
523
+ state,
524
+ startTime: Date.now(),
525
+ activities: new Map(),
526
+ outputSummaries: new Map(),
527
+ };
528
+ if (options.resume) {
529
+ widgetState.resumedSteps = Object.keys(state.steps).filter((s) => state.steps[s].status === "completed" || state.steps[s].status === "skipped").length;
530
+ }
531
+ if (ctx.hasUI) {
532
+ ctx.ui.setWidget(WIDGET_ID, createProgressWidget(widgetState));
533
+ }
534
+ // 4. Set working message
535
+ if (ctx.hasUI) {
536
+ ctx.ui.setWorkingMessage(`Running ${spec.name} workflow...`);
537
+ }
538
+ // 5. Load model config (project-level model assignments by role)
539
+ let modelConfig = options.modelConfig;
540
+ if (!modelConfig) {
541
+ try {
542
+ modelConfig = readBlock(ctx.cwd, "model-config");
543
+ }
544
+ catch {
545
+ /* no config file — models come from agent specs or step specs */
546
+ }
547
+ }
548
+ // 6. Build execution plan and execute layers
549
+ const plan = buildConservativePlan(spec);
550
+ const templateEnv = createTemplateEnv(ctx.cwd);
551
+ const stepOpts = {
552
+ ctx,
553
+ pi,
554
+ signal,
555
+ loadAgent,
556
+ runDir,
557
+ spec,
558
+ widgetState,
559
+ templateEnv,
560
+ dispatchFn: options.dispatchFn,
561
+ modelConfig,
562
+ };
563
+ for (const layer of plan) {
564
+ if (signal?.aborted) {
565
+ // Mark first unprocessed step as cancelled
566
+ for (const sn of layer.steps) {
567
+ if (!state.steps[sn]) {
568
+ state.steps[sn] = {
569
+ step: sn,
570
+ agent: spec.steps[sn].agent ?? "",
571
+ status: "failed",
572
+ usage: zeroUsage(),
573
+ durationMs: 0,
574
+ error: "Workflow cancelled",
575
+ };
576
+ }
577
+ }
578
+ state.status = "failed";
579
+ break;
580
+ }
581
+ // Filter to only pending steps in this layer (resume support)
582
+ const pendingSteps = layer.steps.filter((s) => !state.steps[s] || state.steps[s].status === "failed");
583
+ if (pendingSteps.length === 0)
584
+ continue; // entire layer already done
585
+ if (pendingSteps.length === 1) {
586
+ const stepName = pendingSteps[0];
587
+ const stepSpec = spec.steps[stepName];
588
+ const cont = await executeSingleStep(stepName, stepSpec, state, stepOpts);
589
+ if (!cont)
590
+ break;
591
+ }
592
+ else {
593
+ // Run only the pending steps concurrently
594
+ const pendingLayer = { steps: pendingSteps };
595
+ await executeParallelLayer(pendingLayer, spec, state, executeSingleStep, stepOpts);
596
+ if (state.status === "failed" || state.status === "paused")
597
+ break;
598
+ }
599
+ // Check keybinding-initiated pause (between layers/steps)
600
+ if (pauseRequested) {
601
+ pauseRequested = false;
602
+ state.status = "paused";
603
+ try {
604
+ writeState(runDir, state);
605
+ }
606
+ catch (err) {
607
+ const msg = err instanceof Error ? err.message : String(err);
608
+ if (ctx.hasUI) {
609
+ ctx.ui.notify(`Warning: state write failed after pause — resume may not work: ${msg}`, "error");
610
+ }
611
+ }
612
+ if (ctx.hasUI) {
613
+ ctx.ui.notify("Workflow paused. Use /workflow resume or Ctrl+J to continue.", "info");
614
+ }
615
+ break;
616
+ }
617
+ }
618
+ // 5. Process artifacts (before finalization — block write failures are fatal)
619
+ const writtenArtifacts = {};
620
+ if (spec.artifacts && state.status === "running") {
621
+ const workflowDir = getWorkflowDir(ctx.cwd, spec.name);
622
+ const artifactScope = {
623
+ input: state.input,
624
+ steps: state.steps,
625
+ runId,
626
+ runDir,
627
+ };
628
+ for (const [name, artifactSpec] of Object.entries(spec.artifacts)) {
629
+ try {
630
+ // Resolve the output path (may contain expressions)
631
+ // Relative paths resolve against the workflow's output directory
632
+ const resolvedPath = String(resolveExpressions(artifactSpec.path, artifactScope));
633
+ const absolutePath = path.isAbsolute(resolvedPath) ? resolvedPath : path.resolve(workflowDir, resolvedPath);
634
+ // Resolve the data source — wrap `from` as ${{ from }} for expression resolution
635
+ const fromExpr = artifactSpec.from.startsWith("${{") ? artifactSpec.from : `\${{ ${artifactSpec.from} }}`;
636
+ const data = resolveExpressions(fromExpr, artifactScope);
637
+ // Validate against schema if specified
638
+ if (artifactSpec.schema) {
639
+ const schemaPath = resolveSchemaPath(artifactSpec.schema, spec.filePath, ctx.cwd);
640
+ validateFromFile(schemaPath, data, `artifact '${name}'`);
641
+ }
642
+ // Route project block JSON targets through block-api for block-schema validation
643
+ const workflowPrefix = path.join(ctx.cwd, PROJECT_DIR) + path.sep;
644
+ if (absolutePath.startsWith(workflowPrefix) && absolutePath.endsWith(".json")) {
645
+ const blockName = path.basename(absolutePath, ".json");
646
+ writeBlock(ctx.cwd, blockName, data);
647
+ writtenArtifacts[name] = absolutePath;
648
+ }
649
+ else {
650
+ // Write non-block artifacts directly
651
+ fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
652
+ if (typeof data === "string") {
653
+ fs.writeFileSync(absolutePath, data);
654
+ }
655
+ else {
656
+ fs.writeFileSync(absolutePath, JSON.stringify(data, null, 2));
657
+ }
658
+ writtenArtifacts[name] = absolutePath;
659
+ }
660
+ }
661
+ catch (err) {
662
+ const msg = err instanceof Error ? err.message : String(err);
663
+ // Block artifact failures are fatal — the typed-state contract requires it.
664
+ // Non-block artifact failures remain non-fatal.
665
+ const resolvedPath = String(resolveExpressions(artifactSpec.path, artifactScope));
666
+ const absolutePath = path.isAbsolute(resolvedPath) ? resolvedPath : path.resolve(workflowDir, resolvedPath);
667
+ const isBlockTarget = absolutePath.startsWith(path.join(ctx.cwd, PROJECT_DIR) + path.sep) && absolutePath.endsWith(".json");
668
+ if (isBlockTarget) {
669
+ state.status = "failed";
670
+ if (ctx.hasUI) {
671
+ ctx.ui.notify(`Block artifact '${name}' failed: ${msg}`, "error");
672
+ }
673
+ break;
674
+ }
675
+ // Non-block: warn and continue
676
+ if (ctx.hasUI) {
677
+ ctx.ui.notify(`Artifact '${name}' failed: ${msg}`, "warning");
678
+ }
679
+ }
680
+ }
681
+ }
682
+ // 6. Finalize
683
+ if (state.status === "running") {
684
+ state.status = "completed";
685
+ }
686
+ try {
687
+ writeState(runDir, state);
688
+ }
689
+ catch (err) {
690
+ const msg = err instanceof Error ? err.message : String(err);
691
+ if (ctx.hasUI) {
692
+ ctx.ui.notify(`Warning: final state write failed — run history may be incomplete: ${msg}`, "error");
693
+ }
694
+ }
695
+ writeMetrics(runDir, state.steps);
696
+ // 7. Clean up TUI
697
+ if (ctx.hasUI) {
698
+ ctx.ui.setWidget(WIDGET_ID, undefined);
699
+ ctx.ui.setWorkingMessage(undefined);
700
+ }
701
+ // 8. Build and inject result
702
+ const result = buildResult(spec, runId, runDir, state, state.status);
703
+ // Attach written artifact paths to the result
704
+ if (Object.keys(writtenArtifacts).length > 0) {
705
+ result.artifacts = writtenArtifacts;
706
+ }
707
+ const triggerTurn = spec.triggerTurn !== false;
708
+ if (state.status === "paused") {
709
+ const completedCount = Object.values(state.steps).filter((s) => s.status === "completed").length;
710
+ const totalCount = Object.keys(spec.steps).length;
711
+ const pauseContent = `Workflow '${spec.name}' paused (${completedCount}/${totalCount} steps completed). Use /workflow resume ${spec.name} or Ctrl+J to continue.`;
712
+ pi.sendMessage({ customType: "workflow-result", content: pauseContent, display: "verbose" }, { triggerTurn: false });
713
+ }
714
+ else {
715
+ let content;
716
+ if (spec.completion) {
717
+ try {
718
+ content = resolveCompletion(spec.completion, result, input);
719
+ }
720
+ catch (err) {
721
+ const msg = err instanceof Error ? err.message : String(err);
722
+ content = formatResult(result) + `\n\nCompletion template error: ${msg}`;
723
+ }
724
+ }
725
+ else {
726
+ content = formatResult(result);
727
+ }
728
+ // P7: Truncate completion message to avoid context overflow (50KB / 2000 lines)
729
+ const truncated = truncateTail(content, { maxLines: 2000, maxBytes: 50 * 1024 });
730
+ content = truncated.content;
731
+ if (truncated.truncated) {
732
+ content += `\n\n[Truncated: output exceeded ${truncated.truncatedBy === "bytes" ? "50KB" : "2000 lines"}. Full output in run dir: ${runDir}]`;
733
+ }
734
+ pi.sendMessage({ customType: "workflow-result", content, display: "verbose" }, { triggerTurn });
735
+ }
736
+ return result;
737
+ }
738
+ //# sourceMappingURL=workflow-executor.js.map