@davidorex/pi-workflows 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/README.md +169 -0
- package/agents/audit-fixer.agent.yaml +18 -0
- package/agents/code-explorer.agent.yaml +11 -0
- package/agents/decomposer.agent.yaml +51 -0
- package/agents/investigator.agent.yaml +49 -0
- package/agents/pattern-analyzer.agent.yaml +21 -0
- package/agents/phase-author.agent.yaml +16 -0
- package/agents/plan-decomposer.agent.yaml +12 -0
- package/agents/quality-analyzer.agent.yaml +21 -0
- package/agents/researcher.agent.yaml +38 -0
- package/agents/spec-implementer.agent.yaml +12 -0
- package/agents/structure-analyzer.agent.yaml +21 -0
- package/agents/synthesizer.agent.yaml +23 -0
- package/agents/verifier.agent.yaml +12 -0
- package/package.json +40 -0
- package/schemas/decomposition-specs.schema.json +50 -0
- package/schemas/execution-results.schema.json +50 -0
- package/schemas/investigation-findings.schema.json +45 -0
- package/schemas/pattern-analysis.schema.json +43 -0
- package/schemas/plan-breakdown.schema.json +22 -0
- package/schemas/quality-analysis.schema.json +35 -0
- package/schemas/research-findings.schema.json +44 -0
- package/schemas/structure-analysis.schema.json +42 -0
- package/schemas/synthesis.schema.json +33 -0
- package/schemas/verifier-output.schema.json +96 -0
- package/src/agent-spec.test.ts +289 -0
- package/src/agent-spec.ts +122 -0
- package/src/block-validation.test.ts +350 -0
- package/src/checkpoint.test.ts +237 -0
- package/src/checkpoint.ts +139 -0
- package/src/completion.test.ts +143 -0
- package/src/completion.ts +68 -0
- package/src/dag.test.ts +350 -0
- package/src/dag.ts +219 -0
- package/src/dispatch.test.ts +473 -0
- package/src/dispatch.ts +353 -0
- package/src/expression.test.ts +353 -0
- package/src/expression.ts +332 -0
- package/src/format.test.ts +80 -0
- package/src/format.ts +41 -0
- package/src/graduated-failure.test.ts +556 -0
- package/src/index.test.ts +114 -0
- package/src/index.ts +488 -0
- package/src/loop.test.ts +549 -0
- package/src/output.test.ts +213 -0
- package/src/output.ts +70 -0
- package/src/parallel-integration.test.ts +175 -0
- package/src/resume.test.ts +192 -0
- package/src/state.test.ts +192 -0
- package/src/state.ts +253 -0
- package/src/step-agent.test.ts +327 -0
- package/src/step-agent.ts +178 -0
- package/src/step-command.test.ts +330 -0
- package/src/step-command.ts +132 -0
- package/src/step-foreach.test.ts +647 -0
- package/src/step-foreach.ts +148 -0
- package/src/step-gate.test.ts +185 -0
- package/src/step-gate.ts +116 -0
- package/src/step-loop.test.ts +626 -0
- package/src/step-loop.ts +323 -0
- package/src/step-parallel.test.ts +475 -0
- package/src/step-parallel.ts +168 -0
- package/src/step-pause.test.ts +30 -0
- package/src/step-pause.ts +27 -0
- package/src/step-shared.test.ts +355 -0
- package/src/step-shared.ts +157 -0
- package/src/step-transform.test.ts +155 -0
- package/src/step-transform.ts +47 -0
- package/src/template-integration.test.ts +72 -0
- package/src/template.test.ts +162 -0
- package/src/template.ts +92 -0
- package/src/test-helpers.ts +51 -0
- package/src/tui.test.ts +355 -0
- package/src/tui.ts +182 -0
- package/src/types.ts +195 -0
- package/src/verifier-schema.test.ts +226 -0
- package/src/workflow-discovery.test.ts +105 -0
- package/src/workflow-discovery.ts +113 -0
- package/src/workflow-executor.test.ts +1530 -0
- package/src/workflow-executor.ts +810 -0
- package/src/workflow-sdk.test.ts +238 -0
- package/src/workflow-sdk.ts +262 -0
- package/src/workflow-spec.test.ts +576 -0
- package/src/workflow-spec.ts +471 -0
- package/templates/analyzers/base-analyzer.md +9 -0
- package/templates/analyzers/macros.md +1 -0
- package/templates/analyzers/patterns-task.md +11 -0
- package/templates/analyzers/patterns.md +15 -0
- package/templates/analyzers/quality-task.md +11 -0
- package/templates/analyzers/quality.md +15 -0
- package/templates/analyzers/structure-task.md +17 -0
- package/templates/analyzers/structure.md +15 -0
- package/templates/audit-fixer/task.md +67 -0
- package/templates/decomposer/task.md +56 -0
- package/templates/explorer/system.md +3 -0
- package/templates/explorer/task.md +9 -0
- package/templates/investigator/task.md +30 -0
- package/templates/phase-author/task.md +74 -0
- package/templates/plan-decomposer/task.md +46 -0
- package/templates/researcher/task.md +26 -0
- package/templates/spec-implementer/task.md +53 -0
- package/templates/synthesizer/system.md +3 -0
- package/templates/synthesizer/task.md +38 -0
- package/templates/verifier/task.md +57 -0
- package/workflows/create-phase.workflow.yaml +67 -0
- package/workflows/do-gap.workflow.yaml +153 -0
- package/workflows/fix-audit.workflow.yaml +217 -0
- package/workflows/gap-to-phase.workflow.yaml +98 -0
- package/workflows/parallel-analysis.workflow.yaml +62 -0
- package/workflows/parallel-explicit.workflow.yaml +42 -0
- package/workflows/pausable-analysis.workflow.yaml +44 -0
- package/workflows/resumable-analysis.workflow.yaml +42 -0
- package/workflows/self-implement.workflow.yaml +63 -0
- package/workflows/typed-analysis.workflow.yaml +63 -0
package/src/dag.test.ts
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert";
|
|
3
|
+
import { extractDependencies, buildExecutionPlan, isSequential } from "./dag.ts";
|
|
4
|
+
import type { StepSpec } from "./types.ts";
|
|
5
|
+
import { makeSpec as makeSpecFull } from "./test-helpers.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Helper: build a minimal WorkflowSpec from a steps object.
|
|
9
|
+
*/
|
|
10
|
+
function makeSpec(steps: Record<string, StepSpec>) {
|
|
11
|
+
return makeSpecFull({ steps });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("extractDependencies", () => {
|
|
15
|
+
it("returns empty deps for steps with no expressions", () => {
|
|
16
|
+
const spec = makeSpec({
|
|
17
|
+
a: { agent: "foo" },
|
|
18
|
+
b: { agent: "bar" },
|
|
19
|
+
});
|
|
20
|
+
const deps = extractDependencies(spec);
|
|
21
|
+
assert.strictEqual(deps.get("a")!.size, 0);
|
|
22
|
+
assert.strictEqual(deps.get("b")!.size, 0);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("extracts deps from input expressions", () => {
|
|
26
|
+
const spec = makeSpec({
|
|
27
|
+
a: { agent: "foo" },
|
|
28
|
+
b: {
|
|
29
|
+
agent: "bar",
|
|
30
|
+
input: {
|
|
31
|
+
context: "${{ steps.a.textOutput }}",
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
const deps = extractDependencies(spec);
|
|
36
|
+
assert.strictEqual(deps.get("a")!.size, 0);
|
|
37
|
+
assert.ok(deps.get("b")!.has("a"));
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("extracts deps from when condition", () => {
|
|
41
|
+
const spec = makeSpec({
|
|
42
|
+
a: { agent: "foo" },
|
|
43
|
+
b: {
|
|
44
|
+
agent: "bar",
|
|
45
|
+
when: "${{ steps.a.output.shouldRun }}",
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
const deps = extractDependencies(spec);
|
|
49
|
+
assert.ok(deps.get("b")!.has("a"));
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("extracts deps from bare when condition (no ${{ }})", () => {
|
|
53
|
+
const spec = makeSpec({
|
|
54
|
+
a: { agent: "foo" },
|
|
55
|
+
b: {
|
|
56
|
+
agent: "bar",
|
|
57
|
+
when: "steps.a.output.shouldRun",
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
const deps = extractDependencies(spec);
|
|
61
|
+
assert.ok(deps.get("b")!.has("a"));
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("extracts deps from gate check", () => {
|
|
65
|
+
const spec = makeSpec({
|
|
66
|
+
a: { agent: "foo" },
|
|
67
|
+
b: {
|
|
68
|
+
gate: { check: "${{ steps.a.output.reproCommand }}" },
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
const deps = extractDependencies(spec);
|
|
72
|
+
assert.ok(deps.get("b")!.has("a"));
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("extracts deps from transform mapping", () => {
|
|
76
|
+
const spec = makeSpec({
|
|
77
|
+
a: { agent: "foo" },
|
|
78
|
+
b: { agent: "bar" },
|
|
79
|
+
c: {
|
|
80
|
+
transform: {
|
|
81
|
+
mapping: {
|
|
82
|
+
fromA: "${{ steps.a.textOutput }}",
|
|
83
|
+
fromB: "${{ steps.b.output.data }}",
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
const deps = extractDependencies(spec);
|
|
89
|
+
assert.ok(deps.get("c")!.has("a"));
|
|
90
|
+
assert.ok(deps.get("c")!.has("b"));
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("extracts multiple deps from a single expression string", () => {
|
|
94
|
+
const spec = makeSpec({
|
|
95
|
+
a: { agent: "foo" },
|
|
96
|
+
b: { agent: "bar" },
|
|
97
|
+
c: {
|
|
98
|
+
agent: "baz",
|
|
99
|
+
input: {
|
|
100
|
+
combined: "${{ steps.a.textOutput }}\n\n${{ steps.b.textOutput }}",
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
const deps = extractDependencies(spec);
|
|
105
|
+
assert.ok(deps.get("c")!.has("a"));
|
|
106
|
+
assert.ok(deps.get("c")!.has("b"));
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("extracts deps from nested input objects", () => {
|
|
110
|
+
const spec = makeSpec({
|
|
111
|
+
a: { agent: "foo" },
|
|
112
|
+
b: {
|
|
113
|
+
agent: "bar",
|
|
114
|
+
input: {
|
|
115
|
+
nested: {
|
|
116
|
+
deep: {
|
|
117
|
+
value: "${{ steps.a.output.x }}",
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
const deps = extractDependencies(spec);
|
|
124
|
+
assert.ok(deps.get("b")!.has("a"));
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("extracts deps from input arrays", () => {
|
|
128
|
+
const spec = makeSpec({
|
|
129
|
+
a: { agent: "foo" },
|
|
130
|
+
b: {
|
|
131
|
+
agent: "bar",
|
|
132
|
+
input: {
|
|
133
|
+
items: ["${{ steps.a.output.x }}", "static"],
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
const deps = extractDependencies(spec);
|
|
138
|
+
assert.ok(deps.get("b")!.has("a"));
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("ignores input-only references (no step deps)", () => {
|
|
142
|
+
const spec = makeSpec({
|
|
143
|
+
a: {
|
|
144
|
+
agent: "foo",
|
|
145
|
+
input: {
|
|
146
|
+
path: "${{ input.path }}",
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
const deps = extractDependencies(spec);
|
|
151
|
+
assert.strictEqual(deps.get("a")!.size, 0);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("ignores self-references", () => {
|
|
155
|
+
const spec = makeSpec({
|
|
156
|
+
a: {
|
|
157
|
+
agent: "foo",
|
|
158
|
+
input: {
|
|
159
|
+
self: "${{ steps.a.output }}",
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
const deps = extractDependencies(spec);
|
|
164
|
+
assert.strictEqual(deps.get("a")!.size, 0);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("ignores references to non-existent steps", () => {
|
|
168
|
+
const spec = makeSpec({
|
|
169
|
+
a: {
|
|
170
|
+
agent: "foo",
|
|
171
|
+
input: {
|
|
172
|
+
bad: "${{ steps.nonexistent.output }}",
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
const deps = extractDependencies(spec);
|
|
177
|
+
assert.strictEqual(deps.get("a")!.size, 0);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("does not descend into loop sub-steps", () => {
|
|
181
|
+
const spec = makeSpec({
|
|
182
|
+
a: { agent: "foo" },
|
|
183
|
+
b: {
|
|
184
|
+
loop: {
|
|
185
|
+
maxAttempts: 3,
|
|
186
|
+
steps: {
|
|
187
|
+
inner: {
|
|
188
|
+
agent: "bar",
|
|
189
|
+
input: { ctx: "${{ steps.a.textOutput }}" },
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
const deps = extractDependencies(spec);
|
|
196
|
+
// The loop step itself should depend on 'a' via its sub-step's expression,
|
|
197
|
+
// BUT per the design: we do NOT descend into loop sub-steps for top-level DAG.
|
|
198
|
+
// The loop step's own `input` or `when` would declare its real deps.
|
|
199
|
+
assert.strictEqual(deps.get("b")!.size, 0);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe("buildExecutionPlan", () => {
|
|
204
|
+
it("fully sequential: linear chain", () => {
|
|
205
|
+
const spec = makeSpec({
|
|
206
|
+
a: { agent: "foo" },
|
|
207
|
+
b: {
|
|
208
|
+
agent: "bar",
|
|
209
|
+
input: { ctx: "${{ steps.a.textOutput }}" },
|
|
210
|
+
},
|
|
211
|
+
c: {
|
|
212
|
+
agent: "baz",
|
|
213
|
+
input: { ctx: "${{ steps.b.output }}" },
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
const plan = buildExecutionPlan(spec);
|
|
217
|
+
assert.strictEqual(plan.length, 3);
|
|
218
|
+
assert.deepStrictEqual(plan[0].steps, ["a"]);
|
|
219
|
+
assert.deepStrictEqual(plan[1].steps, ["b"]);
|
|
220
|
+
assert.deepStrictEqual(plan[2].steps, ["c"]);
|
|
221
|
+
assert.ok(isSequential(plan));
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("fully parallel: no dependencies", () => {
|
|
225
|
+
const spec = makeSpec({
|
|
226
|
+
a: { agent: "foo", input: { x: "${{ input.path }}" } },
|
|
227
|
+
b: { agent: "bar", input: { x: "${{ input.path }}" } },
|
|
228
|
+
c: { agent: "baz", input: { x: "${{ input.path }}" } },
|
|
229
|
+
});
|
|
230
|
+
const plan = buildExecutionPlan(spec);
|
|
231
|
+
assert.strictEqual(plan.length, 1);
|
|
232
|
+
assert.strictEqual(plan[0].steps.length, 3);
|
|
233
|
+
assert.ok(plan[0].steps.includes("a"));
|
|
234
|
+
assert.ok(plan[0].steps.includes("b"));
|
|
235
|
+
assert.ok(plan[0].steps.includes("c"));
|
|
236
|
+
assert.ok(!isSequential(plan));
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("fan-out then merge: diamond pattern", () => {
|
|
240
|
+
const spec = makeSpec({
|
|
241
|
+
source: { agent: "foo" },
|
|
242
|
+
analyzerA: {
|
|
243
|
+
agent: "bar",
|
|
244
|
+
input: { ctx: "${{ steps.source.textOutput }}" },
|
|
245
|
+
},
|
|
246
|
+
analyzerB: {
|
|
247
|
+
agent: "baz",
|
|
248
|
+
input: { ctx: "${{ steps.source.textOutput }}" },
|
|
249
|
+
},
|
|
250
|
+
merge: {
|
|
251
|
+
transform: {
|
|
252
|
+
mapping: {
|
|
253
|
+
a: "${{ steps.analyzerA.output }}",
|
|
254
|
+
b: "${{ steps.analyzerB.output }}",
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
},
|
|
258
|
+
});
|
|
259
|
+
const plan = buildExecutionPlan(spec);
|
|
260
|
+
assert.strictEqual(plan.length, 3);
|
|
261
|
+
assert.deepStrictEqual(plan[0].steps, ["source"]);
|
|
262
|
+
assert.strictEqual(plan[1].steps.length, 2);
|
|
263
|
+
assert.ok(plan[1].steps.includes("analyzerA"));
|
|
264
|
+
assert.ok(plan[1].steps.includes("analyzerB"));
|
|
265
|
+
assert.deepStrictEqual(plan[2].steps, ["merge"]);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("mixed: some parallel, some sequential", () => {
|
|
269
|
+
const spec = makeSpec({
|
|
270
|
+
a: { agent: "foo" },
|
|
271
|
+
b: { agent: "bar" },
|
|
272
|
+
c: {
|
|
273
|
+
agent: "baz",
|
|
274
|
+
input: { ctx: "${{ steps.a.textOutput }}" },
|
|
275
|
+
},
|
|
276
|
+
d: {
|
|
277
|
+
transform: {
|
|
278
|
+
mapping: {
|
|
279
|
+
b: "${{ steps.b.output }}",
|
|
280
|
+
c: "${{ steps.c.output }}",
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
},
|
|
284
|
+
});
|
|
285
|
+
const plan = buildExecutionPlan(spec);
|
|
286
|
+
assert.strictEqual(plan.length, 3);
|
|
287
|
+
// Layer 0: a, b (both independent)
|
|
288
|
+
assert.strictEqual(plan[0].steps.length, 2);
|
|
289
|
+
assert.ok(plan[0].steps.includes("a"));
|
|
290
|
+
assert.ok(plan[0].steps.includes("b"));
|
|
291
|
+
// Layer 1: c (depends on a)
|
|
292
|
+
assert.deepStrictEqual(plan[1].steps, ["c"]);
|
|
293
|
+
// Layer 2: d (depends on b and c)
|
|
294
|
+
assert.deepStrictEqual(plan[2].steps, ["d"]);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("detects cycle", () => {
|
|
298
|
+
const spec = makeSpec({
|
|
299
|
+
a: {
|
|
300
|
+
agent: "foo",
|
|
301
|
+
input: { ctx: "${{ steps.b.output }}" },
|
|
302
|
+
},
|
|
303
|
+
b: {
|
|
304
|
+
agent: "bar",
|
|
305
|
+
input: { ctx: "${{ steps.a.output }}" },
|
|
306
|
+
},
|
|
307
|
+
});
|
|
308
|
+
assert.throws(
|
|
309
|
+
() => buildExecutionPlan(spec),
|
|
310
|
+
(err: Error) => err.message.includes("cycle"),
|
|
311
|
+
);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("single step", () => {
|
|
315
|
+
const spec = makeSpec({
|
|
316
|
+
only: { agent: "foo" },
|
|
317
|
+
});
|
|
318
|
+
const plan = buildExecutionPlan(spec);
|
|
319
|
+
assert.strictEqual(plan.length, 1);
|
|
320
|
+
assert.deepStrictEqual(plan[0].steps, ["only"]);
|
|
321
|
+
assert.ok(isSequential(plan));
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("preserves YAML order within layers", () => {
|
|
325
|
+
// Steps with no deps should appear in their YAML declaration order
|
|
326
|
+
const spec = makeSpec({
|
|
327
|
+
z: { agent: "foo" },
|
|
328
|
+
a: { agent: "bar" },
|
|
329
|
+
m: { agent: "baz" },
|
|
330
|
+
});
|
|
331
|
+
const plan = buildExecutionPlan(spec);
|
|
332
|
+
assert.strictEqual(plan.length, 1);
|
|
333
|
+
// Should preserve insertion order: z, a, m
|
|
334
|
+
assert.deepStrictEqual(plan[0].steps, ["z", "a", "m"]);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("step with when referencing another step", () => {
|
|
338
|
+
const spec = makeSpec({
|
|
339
|
+
check: { gate: { check: "echo ok" } },
|
|
340
|
+
conditional: {
|
|
341
|
+
agent: "foo",
|
|
342
|
+
when: "${{ steps.check.output.passed }}",
|
|
343
|
+
},
|
|
344
|
+
});
|
|
345
|
+
const plan = buildExecutionPlan(spec);
|
|
346
|
+
assert.strictEqual(plan.length, 2);
|
|
347
|
+
assert.deepStrictEqual(plan[0].steps, ["check"]);
|
|
348
|
+
assert.deepStrictEqual(plan[1].steps, ["conditional"]);
|
|
349
|
+
});
|
|
350
|
+
});
|
package/src/dag.ts
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DAG dependency analysis and execution planning for workflow steps.
|
|
3
|
+
* Extracts `${{ steps.X }}` references, builds a dependency graph,
|
|
4
|
+
* and produces layered execution plans for parallel dispatch.
|
|
5
|
+
*/
|
|
6
|
+
import type { WorkflowSpec, StepSpec } from "./types.ts";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A single layer in the execution plan.
|
|
10
|
+
* All steps in a layer are independent and can run concurrently.
|
|
11
|
+
*/
|
|
12
|
+
export interface ExecutionLayer {
|
|
13
|
+
steps: string[]; // step names in this layer
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The full execution plan: an ordered list of layers.
|
|
18
|
+
* Steps in the same layer have no mutual dependencies.
|
|
19
|
+
* Steps in layer N depend only on steps in layers 0..N-1.
|
|
20
|
+
*/
|
|
21
|
+
export type ExecutionPlan = ExecutionLayer[];
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Extract all step dependencies from a workflow spec.
|
|
25
|
+
*
|
|
26
|
+
* Scans all expression-bearing fields in each step:
|
|
27
|
+
* - `input` values (recursive — expressions can be nested in objects/arrays)
|
|
28
|
+
* - `when` condition
|
|
29
|
+
* - `gate.check` (may contain ${{ }})
|
|
30
|
+
* - `transform.mapping` values (recursive)
|
|
31
|
+
* - `loop.attempts` (may be an expression)
|
|
32
|
+
*
|
|
33
|
+
* Does NOT descend into loop sub-steps — a loop step's internal steps
|
|
34
|
+
* are the loop's own concern, not part of the top-level DAG.
|
|
35
|
+
*
|
|
36
|
+
* Returns a map: stepName → Set of step names it depends on.
|
|
37
|
+
*/
|
|
38
|
+
export function extractDependencies(spec: WorkflowSpec): Map<string, Set<string>> {
|
|
39
|
+
const stepNames = new Set(Object.keys(spec.steps));
|
|
40
|
+
const deps = new Map<string, Set<string>>();
|
|
41
|
+
|
|
42
|
+
for (const [name, step] of Object.entries(spec.steps)) {
|
|
43
|
+
const stepDeps = new Set<string>();
|
|
44
|
+
|
|
45
|
+
// Collect all expression strings from this step
|
|
46
|
+
const expressions = collectExpressions(step);
|
|
47
|
+
|
|
48
|
+
// Parse each expression for `steps.<name>` references
|
|
49
|
+
for (const expr of expressions) {
|
|
50
|
+
const referenced = extractStepReferences(expr, stepNames);
|
|
51
|
+
for (const ref of referenced) {
|
|
52
|
+
if (ref !== name) { // no self-dependencies
|
|
53
|
+
stepDeps.add(ref);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
deps.set(name, stepDeps);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return deps;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Collect all raw expression strings from a step spec.
|
|
66
|
+
* Walks `input`, `when`, `gate.check`, `transform.mapping`, `loop.attempts`.
|
|
67
|
+
*/
|
|
68
|
+
function collectExpressions(step: StepSpec): string[] {
|
|
69
|
+
const exprs: string[] = [];
|
|
70
|
+
|
|
71
|
+
// input values (recursive)
|
|
72
|
+
if (step.input) {
|
|
73
|
+
collectExpressionsFromValue(step.input, exprs);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// when condition
|
|
77
|
+
if (step.when) {
|
|
78
|
+
exprs.push(step.when);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// gate check
|
|
82
|
+
if (step.gate) {
|
|
83
|
+
collectExpressionsFromValue(step.gate.check, exprs);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// transform mapping (recursive)
|
|
87
|
+
if (step.transform) {
|
|
88
|
+
collectExpressionsFromValue(step.transform.mapping, exprs);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// loop attempts expression
|
|
92
|
+
if (step.loop?.attempts) {
|
|
93
|
+
exprs.push(step.loop.attempts);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// forEach expression
|
|
97
|
+
if (step.forEach) {
|
|
98
|
+
collectExpressionsFromValue(step.forEach, exprs);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// command expression (may contain ${{ }} references)
|
|
102
|
+
if (step.command) {
|
|
103
|
+
collectExpressionsFromValue(step.command, exprs);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return exprs;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Recursively collect expression strings from a value.
|
|
111
|
+
* Walks objects, arrays, and strings looking for ${{ }} patterns.
|
|
112
|
+
*/
|
|
113
|
+
function collectExpressionsFromValue(value: unknown, exprs: string[]): void {
|
|
114
|
+
if (typeof value === "string") {
|
|
115
|
+
// Extract all ${{ ... }} patterns from the string
|
|
116
|
+
const regex = /\$\{\{([^}]+)\}\}/g;
|
|
117
|
+
let match: RegExpExecArray | null;
|
|
118
|
+
while ((match = regex.exec(value)) !== null) {
|
|
119
|
+
exprs.push(match[1].trim());
|
|
120
|
+
}
|
|
121
|
+
// Also handle bare expressions (no ${{ }} wrapper, e.g. in `when`)
|
|
122
|
+
if (!value.includes("${{") && value.includes("steps.")) {
|
|
123
|
+
exprs.push(value);
|
|
124
|
+
}
|
|
125
|
+
} else if (Array.isArray(value)) {
|
|
126
|
+
for (const item of value) {
|
|
127
|
+
collectExpressionsFromValue(item, exprs);
|
|
128
|
+
}
|
|
129
|
+
} else if (value !== null && typeof value === "object") {
|
|
130
|
+
for (const v of Object.values(value)) {
|
|
131
|
+
collectExpressionsFromValue(v, exprs);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Extract step name references from an expression string.
|
|
138
|
+
*
|
|
139
|
+
* Looks for patterns like:
|
|
140
|
+
* - `steps.diagnose.output`
|
|
141
|
+
* - `steps.diagnose.textOutput`
|
|
142
|
+
* - `steps.diagnose.status`
|
|
143
|
+
*
|
|
144
|
+
* Returns the set of step names referenced.
|
|
145
|
+
*/
|
|
146
|
+
function extractStepReferences(expr: string, validStepNames: Set<string>): Set<string> {
|
|
147
|
+
const refs = new Set<string>();
|
|
148
|
+
// Match `steps.<name>` — name is a word (alphanumeric + underscore + hyphen)
|
|
149
|
+
const regex = /steps\.([a-zA-Z_][\w-]*)/g;
|
|
150
|
+
let match: RegExpExecArray | null;
|
|
151
|
+
while ((match = regex.exec(expr)) !== null) {
|
|
152
|
+
const name = match[1];
|
|
153
|
+
if (validStepNames.has(name)) {
|
|
154
|
+
refs.add(name);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return refs;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Build an execution plan from a pre-computed dependency map.
|
|
162
|
+
* Performs topological sort, grouping independent steps into layers.
|
|
163
|
+
* Throws if the dependency graph contains a cycle.
|
|
164
|
+
*/
|
|
165
|
+
export function buildPlanFromDeps(
|
|
166
|
+
allSteps: string[],
|
|
167
|
+
deps: Map<string, Set<string>>,
|
|
168
|
+
): ExecutionPlan {
|
|
169
|
+
const plan: ExecutionPlan = [];
|
|
170
|
+
const placed = new Set<string>();
|
|
171
|
+
|
|
172
|
+
while (placed.size < allSteps.length) {
|
|
173
|
+
const layer: string[] = [];
|
|
174
|
+
|
|
175
|
+
for (const step of allSteps) {
|
|
176
|
+
if (placed.has(step)) continue;
|
|
177
|
+
|
|
178
|
+
const stepDeps = deps.get(step) ?? new Set();
|
|
179
|
+
if ([...stepDeps].every((d) => placed.has(d))) {
|
|
180
|
+
layer.push(step);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (layer.length === 0) {
|
|
185
|
+
const remaining = allSteps.filter((s) => !placed.has(s));
|
|
186
|
+
throw new Error(
|
|
187
|
+
`Dependency cycle detected among steps: ${remaining.join(", ")}`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
plan.push({ steps: layer });
|
|
192
|
+
for (const s of layer) {
|
|
193
|
+
placed.add(s);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return plan;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Build an execution plan from a workflow spec.
|
|
202
|
+
*
|
|
203
|
+
* Performs topological sort, grouping independent steps into layers.
|
|
204
|
+
* Steps within a layer can execute concurrently.
|
|
205
|
+
*
|
|
206
|
+
* Throws if the dependency graph contains a cycle.
|
|
207
|
+
*/
|
|
208
|
+
export function buildExecutionPlan(spec: WorkflowSpec): ExecutionPlan {
|
|
209
|
+
const deps = extractDependencies(spec);
|
|
210
|
+
return buildPlanFromDeps(Object.keys(spec.steps), deps);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Check if an execution plan is fully sequential
|
|
215
|
+
* (every layer has exactly one step).
|
|
216
|
+
*/
|
|
217
|
+
export function isSequential(plan: ExecutionPlan): boolean {
|
|
218
|
+
return plan.every((layer) => layer.steps.length === 1);
|
|
219
|
+
}
|