gsd-pi 2.41.0-dev.cac69f9 → 2.42.0-dev.97e9e30
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/dist/resources/extensions/gsd/auto/loop.js +80 -0
- package/dist/resources/extensions/gsd/auto/phases.js +2 -2
- package/dist/resources/extensions/gsd/auto/session.js +6 -0
- package/dist/resources/extensions/gsd/auto-dashboard.js +2 -0
- package/dist/resources/extensions/gsd/auto.js +28 -1
- package/dist/resources/extensions/gsd/bootstrap/tool-call-loop-guard.js +7 -2
- package/dist/resources/extensions/gsd/commands/catalog.js +32 -0
- package/dist/resources/extensions/gsd/commands/handlers/workflow.js +146 -0
- package/dist/resources/extensions/gsd/context-injector.js +74 -0
- package/dist/resources/extensions/gsd/custom-execution-policy.js +47 -0
- package/dist/resources/extensions/gsd/custom-verification.js +145 -0
- package/dist/resources/extensions/gsd/custom-workflow-engine.js +164 -0
- package/dist/resources/extensions/gsd/dashboard-overlay.js +1 -0
- package/dist/resources/extensions/gsd/definition-loader.js +352 -0
- package/dist/resources/extensions/gsd/dev-execution-policy.js +24 -0
- package/dist/resources/extensions/gsd/dev-workflow-engine.js +82 -0
- package/dist/resources/extensions/gsd/engine-resolver.js +40 -0
- package/dist/resources/extensions/gsd/engine-types.js +8 -0
- package/dist/resources/extensions/gsd/execution-policy.js +8 -0
- package/dist/resources/extensions/gsd/graph.js +225 -0
- package/dist/resources/extensions/gsd/run-manager.js +134 -0
- package/dist/resources/extensions/gsd/workflow-engine.js +7 -0
- package/dist/resources/skills/create-workflow/SKILL.md +103 -0
- package/dist/resources/skills/create-workflow/references/feature-patterns.md +128 -0
- package/dist/resources/skills/create-workflow/references/verification-policies.md +76 -0
- package/dist/resources/skills/create-workflow/references/yaml-schema-v1.md +46 -0
- package/dist/resources/skills/create-workflow/templates/blog-post-pipeline.yaml +60 -0
- package/dist/resources/skills/create-workflow/templates/code-audit.yaml +60 -0
- package/dist/resources/skills/create-workflow/templates/release-checklist.yaml +66 -0
- package/dist/resources/skills/create-workflow/templates/workflow-definition.yaml +32 -0
- package/dist/resources/skills/create-workflow/workflows/create-from-scratch.md +104 -0
- package/dist/resources/skills/create-workflow/workflows/create-from-template.md +72 -0
- package/dist/web/standalone/.next/BUILD_ID +1 -1
- package/dist/web/standalone/.next/app-path-routes-manifest.json +15 -15
- package/dist/web/standalone/.next/build-manifest.json +2 -2
- package/dist/web/standalone/.next/prerender-manifest.json +3 -3
- package/dist/web/standalone/.next/server/app/_global-error.html +2 -2
- package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.html +1 -1
- package/dist/web/standalone/.next/server/app/index.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app-paths-manifest.json +15 -15
- package/dist/web/standalone/.next/server/pages/404.html +1 -1
- package/dist/web/standalone/.next/server/pages/500.html +2 -2
- package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
- package/package.json +1 -1
- package/packages/pi-coding-agent/package.json +1 -1
- package/pkg/package.json +1 -1
- package/src/resources/extensions/gsd/auto/loop.ts +91 -0
- package/src/resources/extensions/gsd/auto/phases.ts +2 -2
- package/src/resources/extensions/gsd/auto/session.ts +6 -0
- package/src/resources/extensions/gsd/auto-dashboard.ts +2 -0
- package/src/resources/extensions/gsd/auto.ts +31 -1
- package/src/resources/extensions/gsd/bootstrap/tool-call-loop-guard.ts +9 -2
- package/src/resources/extensions/gsd/commands/catalog.ts +32 -0
- package/src/resources/extensions/gsd/commands/handlers/workflow.ts +164 -0
- package/src/resources/extensions/gsd/context-injector.ts +100 -0
- package/src/resources/extensions/gsd/custom-execution-policy.ts +73 -0
- package/src/resources/extensions/gsd/custom-verification.ts +180 -0
- package/src/resources/extensions/gsd/custom-workflow-engine.ts +216 -0
- package/src/resources/extensions/gsd/dashboard-overlay.ts +1 -0
- package/src/resources/extensions/gsd/definition-loader.ts +462 -0
- package/src/resources/extensions/gsd/dev-execution-policy.ts +51 -0
- package/src/resources/extensions/gsd/dev-workflow-engine.ts +110 -0
- package/src/resources/extensions/gsd/engine-resolver.ts +57 -0
- package/src/resources/extensions/gsd/engine-types.ts +71 -0
- package/src/resources/extensions/gsd/execution-policy.ts +43 -0
- package/src/resources/extensions/gsd/graph.ts +312 -0
- package/src/resources/extensions/gsd/run-manager.ts +180 -0
- package/src/resources/extensions/gsd/tests/bundled-workflow-defs.test.ts +180 -0
- package/src/resources/extensions/gsd/tests/commands-workflow-custom.test.ts +283 -0
- package/src/resources/extensions/gsd/tests/context-injector.test.ts +313 -0
- package/src/resources/extensions/gsd/tests/custom-engine-loop-integration.test.ts +540 -0
- package/src/resources/extensions/gsd/tests/custom-verification.test.ts +382 -0
- package/src/resources/extensions/gsd/tests/custom-workflow-engine.test.ts +339 -0
- package/src/resources/extensions/gsd/tests/dashboard-custom-engine.test.ts +87 -0
- package/src/resources/extensions/gsd/tests/definition-loader.test.ts +778 -0
- package/src/resources/extensions/gsd/tests/dev-engine-wrapper.test.ts +318 -0
- package/src/resources/extensions/gsd/tests/e2e-workflow-pipeline-integration.test.ts +476 -0
- package/src/resources/extensions/gsd/tests/engine-interfaces-contract.test.ts +271 -0
- package/src/resources/extensions/gsd/tests/graph-operations.test.ts +599 -0
- package/src/resources/extensions/gsd/tests/iterate-engine-integration.test.ts +429 -0
- package/src/resources/extensions/gsd/tests/run-manager.test.ts +229 -0
- package/src/resources/extensions/gsd/tests/tool-call-loop-guard.test.ts +45 -0
- package/src/resources/extensions/gsd/workflow-engine.ts +38 -0
- package/src/resources/skills/create-workflow/SKILL.md +103 -0
- package/src/resources/skills/create-workflow/references/feature-patterns.md +128 -0
- package/src/resources/skills/create-workflow/references/verification-policies.md +76 -0
- package/src/resources/skills/create-workflow/references/yaml-schema-v1.md +46 -0
- package/src/resources/skills/create-workflow/templates/blog-post-pipeline.yaml +60 -0
- package/src/resources/skills/create-workflow/templates/code-audit.yaml +60 -0
- package/src/resources/skills/create-workflow/templates/release-checklist.yaml +66 -0
- package/src/resources/skills/create-workflow/templates/workflow-definition.yaml +32 -0
- package/src/resources/skills/create-workflow/workflows/create-from-scratch.md +104 -0
- package/src/resources/skills/create-workflow/workflows/create-from-template.md +72 -0
- /package/dist/web/standalone/.next/static/{EnGUNqHeGbE0tuuUkTJVA → PXrI5DoWsm7rwAVnEU2rD}/_buildManifest.js +0 -0
- /package/dist/web/standalone/.next/static/{EnGUNqHeGbE0tuuUkTJVA → PXrI5DoWsm7rwAVnEU2rD}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* iterate-engine-integration.test.ts — Integration tests for iterate/fan-out
|
|
3
|
+
* expansion wired into CustomWorkflowEngine.
|
|
4
|
+
*
|
|
5
|
+
* Proves the full expansion→dispatch→reconcile cycle: the engine reads
|
|
6
|
+
* iterate config from frozen DEFINITION.yaml, reads the source artifact,
|
|
7
|
+
* extracts items via regex, calls expandIteration() to rewrite the graph,
|
|
8
|
+
* persists it, and dispatches instance steps sequentially.
|
|
9
|
+
*
|
|
10
|
+
* Uses real temp directories with actual DEFINITION.yaml, GRAPH.yaml,
|
|
11
|
+
* and source artifact files — no mocks.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, it, afterEach } from "node:test";
|
|
15
|
+
import assert from "node:assert/strict";
|
|
16
|
+
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { tmpdir } from "node:os";
|
|
19
|
+
import { stringify } from "yaml";
|
|
20
|
+
|
|
21
|
+
import { CustomWorkflowEngine } from "../custom-workflow-engine.ts";
|
|
22
|
+
import {
|
|
23
|
+
writeGraph,
|
|
24
|
+
readGraph,
|
|
25
|
+
type WorkflowGraph,
|
|
26
|
+
type GraphStep,
|
|
27
|
+
} from "../graph.ts";
|
|
28
|
+
import type { WorkflowDefinition } from "../definition-loader.ts";
|
|
29
|
+
|
|
30
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
const tmpDirs: string[] = [];
|
|
33
|
+
|
|
34
|
+
function makeTmpDir(): string {
|
|
35
|
+
const dir = mkdtempSync(join(tmpdir(), "iterate-test-"));
|
|
36
|
+
tmpDirs.push(dir);
|
|
37
|
+
return dir;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
for (const d of tmpDirs) {
|
|
42
|
+
try { rmSync(d, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch { /* Windows EPERM */ }
|
|
43
|
+
}
|
|
44
|
+
tmpDirs.length = 0;
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Create a temp run directory with DEFINITION.yaml, GRAPH.yaml, and optional
|
|
49
|
+
* artifact files. Returns the run dir path and engine instance.
|
|
50
|
+
*/
|
|
51
|
+
function makeTempRun(
|
|
52
|
+
def: WorkflowDefinition,
|
|
53
|
+
graphSteps: GraphStep[],
|
|
54
|
+
files?: Record<string, string>,
|
|
55
|
+
): { runDir: string; engine: CustomWorkflowEngine } {
|
|
56
|
+
const runDir = makeTmpDir();
|
|
57
|
+
|
|
58
|
+
// Write frozen DEFINITION.yaml (camelCase — serialized from TS object)
|
|
59
|
+
writeFileSync(join(runDir, "DEFINITION.yaml"), stringify(def), "utf-8");
|
|
60
|
+
|
|
61
|
+
// Write GRAPH.yaml via the standard writer
|
|
62
|
+
const graph: WorkflowGraph = {
|
|
63
|
+
steps: graphSteps,
|
|
64
|
+
metadata: { name: def.name, createdAt: "2026-01-01T00:00:00.000Z" },
|
|
65
|
+
};
|
|
66
|
+
writeGraph(runDir, graph);
|
|
67
|
+
|
|
68
|
+
// Write optional artifact files
|
|
69
|
+
if (files) {
|
|
70
|
+
for (const [relPath, content] of Object.entries(files)) {
|
|
71
|
+
const absPath = join(runDir, relPath);
|
|
72
|
+
mkdirSync(join(absPath, ".."), { recursive: true });
|
|
73
|
+
writeFileSync(absPath, content, "utf-8");
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { runDir, engine: new CustomWorkflowEngine(runDir) };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Shorthand to build a GraphStep. */
|
|
81
|
+
function makeStep(overrides: Partial<GraphStep> & { id: string }): GraphStep {
|
|
82
|
+
return {
|
|
83
|
+
title: overrides.id,
|
|
84
|
+
status: "pending",
|
|
85
|
+
prompt: `Do ${overrides.id}`,
|
|
86
|
+
dependsOn: [],
|
|
87
|
+
...overrides,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Drive a full deriveState→resolveDispatch cycle. */
|
|
92
|
+
async function dispatch(engine: CustomWorkflowEngine) {
|
|
93
|
+
const state = await engine.deriveState("/unused");
|
|
94
|
+
return engine.resolveDispatch(state, { basePath: "/unused" });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Drive a full deriveState→reconcile cycle for a given unitId. */
|
|
98
|
+
async function reconcile(engine: CustomWorkflowEngine, unitId: string) {
|
|
99
|
+
const state = await engine.deriveState("/unused");
|
|
100
|
+
return engine.reconcile(state, {
|
|
101
|
+
unitType: "custom-step",
|
|
102
|
+
unitId,
|
|
103
|
+
startedAt: Date.now() - 1000,
|
|
104
|
+
finishedAt: Date.now(),
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ─── Tests ───────────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
describe("iterate expansion — basic", () => {
|
|
111
|
+
it("expands an iterate step into 3 instances and dispatches the first", async () => {
|
|
112
|
+
const def: WorkflowDefinition = {
|
|
113
|
+
version: 1,
|
|
114
|
+
name: "iter-wf",
|
|
115
|
+
steps: [
|
|
116
|
+
{
|
|
117
|
+
id: "iter-step",
|
|
118
|
+
name: "Iterate Step",
|
|
119
|
+
prompt: "Process {{item}}",
|
|
120
|
+
requires: [],
|
|
121
|
+
produces: [],
|
|
122
|
+
iterate: { source: "topics.md", pattern: "^- (.+)$" },
|
|
123
|
+
},
|
|
124
|
+
],
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const graphSteps = [
|
|
128
|
+
makeStep({ id: "iter-step", prompt: "Process {{item}}" }),
|
|
129
|
+
];
|
|
130
|
+
|
|
131
|
+
const { runDir, engine } = makeTempRun(def, graphSteps, {
|
|
132
|
+
"topics.md": "- Alpha\n- Beta\n- Gamma\n",
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const result = await dispatch(engine);
|
|
136
|
+
|
|
137
|
+
// Should dispatch the first instance step
|
|
138
|
+
assert.equal(result.action, "dispatch");
|
|
139
|
+
if (result.action === "dispatch") {
|
|
140
|
+
assert.equal(result.step.unitId, "iter-wf/iter-step--001");
|
|
141
|
+
assert.equal(result.step.prompt, "Process Alpha");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Verify on-disk graph state
|
|
145
|
+
const graph = readGraph(runDir);
|
|
146
|
+
const parent = graph.steps.find((s) => s.id === "iter-step");
|
|
147
|
+
assert.ok(parent, "Parent step should exist");
|
|
148
|
+
assert.equal(parent.status, "expanded");
|
|
149
|
+
|
|
150
|
+
const instances = graph.steps.filter((s) => s.parentStepId === "iter-step");
|
|
151
|
+
assert.equal(instances.length, 3);
|
|
152
|
+
assert.equal(instances[0].id, "iter-step--001");
|
|
153
|
+
assert.equal(instances[1].id, "iter-step--002");
|
|
154
|
+
assert.equal(instances[2].id, "iter-step--003");
|
|
155
|
+
assert.equal(instances[0].prompt, "Process Alpha");
|
|
156
|
+
assert.equal(instances[1].prompt, "Process Beta");
|
|
157
|
+
assert.equal(instances[2].prompt, "Process Gamma");
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe("iterate expansion — full dispatch→reconcile sequence", () => {
|
|
162
|
+
it("dispatches all 3 instances sequentially then stops", async () => {
|
|
163
|
+
const def: WorkflowDefinition = {
|
|
164
|
+
version: 1,
|
|
165
|
+
name: "seq-wf",
|
|
166
|
+
steps: [
|
|
167
|
+
{
|
|
168
|
+
id: "fan",
|
|
169
|
+
name: "Fan Step",
|
|
170
|
+
prompt: "Handle {{item}}",
|
|
171
|
+
requires: [],
|
|
172
|
+
produces: [],
|
|
173
|
+
iterate: { source: "items.md", pattern: "^- (.+)$" },
|
|
174
|
+
},
|
|
175
|
+
],
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const graphSteps = [makeStep({ id: "fan", prompt: "Handle {{item}}" })];
|
|
179
|
+
|
|
180
|
+
const { engine } = makeTempRun(def, graphSteps, {
|
|
181
|
+
"items.md": "- One\n- Two\n- Three\n",
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// First dispatch triggers expansion, returns instance 1
|
|
185
|
+
let result = await dispatch(engine);
|
|
186
|
+
assert.equal(result.action, "dispatch");
|
|
187
|
+
if (result.action === "dispatch") {
|
|
188
|
+
assert.equal(result.step.unitId, "seq-wf/fan--001");
|
|
189
|
+
assert.equal(result.step.prompt, "Handle One");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Reconcile instance 1, dispatch → instance 2
|
|
193
|
+
await reconcile(engine, "seq-wf/fan--001");
|
|
194
|
+
result = await dispatch(engine);
|
|
195
|
+
assert.equal(result.action, "dispatch");
|
|
196
|
+
if (result.action === "dispatch") {
|
|
197
|
+
assert.equal(result.step.unitId, "seq-wf/fan--002");
|
|
198
|
+
assert.equal(result.step.prompt, "Handle Two");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Reconcile instance 2, dispatch → instance 3
|
|
202
|
+
await reconcile(engine, "seq-wf/fan--002");
|
|
203
|
+
result = await dispatch(engine);
|
|
204
|
+
assert.equal(result.action, "dispatch");
|
|
205
|
+
if (result.action === "dispatch") {
|
|
206
|
+
assert.equal(result.step.unitId, "seq-wf/fan--003");
|
|
207
|
+
assert.equal(result.step.prompt, "Handle Three");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Reconcile instance 3, dispatch → should stop (all done)
|
|
211
|
+
await reconcile(engine, "seq-wf/fan--003");
|
|
212
|
+
result = await dispatch(engine);
|
|
213
|
+
assert.equal(result.action, "stop");
|
|
214
|
+
if (result.action === "stop") {
|
|
215
|
+
assert.equal(result.reason, "All steps complete");
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
describe("iterate expansion — downstream blocking", () => {
|
|
221
|
+
it("blocks downstream step until all instances are complete", async () => {
|
|
222
|
+
const def: WorkflowDefinition = {
|
|
223
|
+
version: 1,
|
|
224
|
+
name: "block-wf",
|
|
225
|
+
steps: [
|
|
226
|
+
{
|
|
227
|
+
id: "fan",
|
|
228
|
+
name: "Fan Step",
|
|
229
|
+
prompt: "Process {{item}}",
|
|
230
|
+
requires: [],
|
|
231
|
+
produces: [],
|
|
232
|
+
iterate: { source: "items.md", pattern: "^- (.+)$" },
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
id: "merge",
|
|
236
|
+
name: "Merge Step",
|
|
237
|
+
prompt: "Merge all results",
|
|
238
|
+
requires: ["fan"],
|
|
239
|
+
produces: [],
|
|
240
|
+
},
|
|
241
|
+
],
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const graphSteps = [
|
|
245
|
+
makeStep({ id: "fan", prompt: "Process {{item}}" }),
|
|
246
|
+
makeStep({ id: "merge", prompt: "Merge all results", dependsOn: ["fan"] }),
|
|
247
|
+
];
|
|
248
|
+
|
|
249
|
+
const { runDir, engine } = makeTempRun(def, graphSteps, {
|
|
250
|
+
"items.md": "- X\n- Y\n",
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// First dispatch: expands and returns instance 1
|
|
254
|
+
let result = await dispatch(engine);
|
|
255
|
+
assert.equal(result.action, "dispatch");
|
|
256
|
+
if (result.action === "dispatch") {
|
|
257
|
+
assert.equal(result.step.unitId, "block-wf/fan--001");
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Verify downstream dep was rewritten: merge now depends on fan--001, fan--002
|
|
261
|
+
let graph = readGraph(runDir);
|
|
262
|
+
const mergeStep = graph.steps.find((s) => s.id === "merge");
|
|
263
|
+
assert.ok(mergeStep);
|
|
264
|
+
assert.deepStrictEqual(mergeStep.dependsOn.sort(), ["fan--001", "fan--002"]);
|
|
265
|
+
|
|
266
|
+
// Complete instance 1 only — merge should NOT be dispatchable yet
|
|
267
|
+
await reconcile(engine, "block-wf/fan--001");
|
|
268
|
+
result = await dispatch(engine);
|
|
269
|
+
assert.equal(result.action, "dispatch");
|
|
270
|
+
if (result.action === "dispatch") {
|
|
271
|
+
// Should get fan--002, not merge
|
|
272
|
+
assert.equal(result.step.unitId, "block-wf/fan--002");
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Complete instance 2 — now merge should be dispatchable
|
|
276
|
+
await reconcile(engine, "block-wf/fan--002");
|
|
277
|
+
result = await dispatch(engine);
|
|
278
|
+
assert.equal(result.action, "dispatch");
|
|
279
|
+
if (result.action === "dispatch") {
|
|
280
|
+
assert.equal(result.step.unitId, "block-wf/merge");
|
|
281
|
+
assert.equal(result.step.prompt, "Merge all results");
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Complete merge — all done
|
|
285
|
+
await reconcile(engine, "block-wf/merge");
|
|
286
|
+
result = await dispatch(engine);
|
|
287
|
+
assert.equal(result.action, "stop");
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
describe("iterate expansion — zero matches", () => {
|
|
292
|
+
it("handles zero-match expansion gracefully", async () => {
|
|
293
|
+
const def: WorkflowDefinition = {
|
|
294
|
+
version: 1,
|
|
295
|
+
name: "zero-wf",
|
|
296
|
+
steps: [
|
|
297
|
+
{
|
|
298
|
+
id: "fan",
|
|
299
|
+
name: "Fan Step",
|
|
300
|
+
prompt: "Process {{item}}",
|
|
301
|
+
requires: [],
|
|
302
|
+
produces: [],
|
|
303
|
+
iterate: { source: "items.md", pattern: "^- (.+)$" },
|
|
304
|
+
},
|
|
305
|
+
{
|
|
306
|
+
id: "after",
|
|
307
|
+
name: "After Step",
|
|
308
|
+
prompt: "Do after",
|
|
309
|
+
requires: ["fan"],
|
|
310
|
+
produces: [],
|
|
311
|
+
},
|
|
312
|
+
],
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
const graphSteps = [
|
|
316
|
+
makeStep({ id: "fan", prompt: "Process {{item}}" }),
|
|
317
|
+
makeStep({ id: "after", prompt: "Do after", dependsOn: ["fan"] }),
|
|
318
|
+
];
|
|
319
|
+
|
|
320
|
+
// Source file exists but has no matching lines
|
|
321
|
+
const { runDir, engine } = makeTempRun(def, graphSteps, {
|
|
322
|
+
"items.md": "No bullet items here\nJust plain text\n",
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
// Dispatch should expand with zero instances
|
|
326
|
+
const result = await dispatch(engine);
|
|
327
|
+
|
|
328
|
+
// Verify parent is expanded
|
|
329
|
+
const graph = readGraph(runDir);
|
|
330
|
+
const parent = graph.steps.find((s) => s.id === "fan");
|
|
331
|
+
assert.ok(parent);
|
|
332
|
+
assert.equal(parent.status, "expanded");
|
|
333
|
+
|
|
334
|
+
// With zero instances, no instance deps exist.
|
|
335
|
+
// expandIteration rewrites "fan" → [] in the downstream dep list,
|
|
336
|
+
// so "after" now has empty dependsOn and becomes dispatchable.
|
|
337
|
+
// But first dispatch after expansion finds no pending instance steps.
|
|
338
|
+
// The engine should either dispatch "after" or return stop.
|
|
339
|
+
// Let's check what actually happened:
|
|
340
|
+
if (result.action === "dispatch") {
|
|
341
|
+
// The re-query found "after" step (since its deps were rewritten to [])
|
|
342
|
+
assert.equal(result.step.unitId, "zero-wf/after");
|
|
343
|
+
} else {
|
|
344
|
+
// The engine returned stop for zero instances
|
|
345
|
+
assert.equal(result.action, "stop");
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
describe("iterate expansion — missing source artifact", () => {
|
|
351
|
+
it("throws an error mentioning the missing file path", async () => {
|
|
352
|
+
const def: WorkflowDefinition = {
|
|
353
|
+
version: 1,
|
|
354
|
+
name: "missing-wf",
|
|
355
|
+
steps: [
|
|
356
|
+
{
|
|
357
|
+
id: "fan",
|
|
358
|
+
name: "Fan Step",
|
|
359
|
+
prompt: "Process {{item}}",
|
|
360
|
+
requires: [],
|
|
361
|
+
produces: [],
|
|
362
|
+
iterate: { source: "nonexistent.md", pattern: "^- (.+)$" },
|
|
363
|
+
},
|
|
364
|
+
],
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
const graphSteps = [
|
|
368
|
+
makeStep({ id: "fan", prompt: "Process {{item}}" }),
|
|
369
|
+
];
|
|
370
|
+
|
|
371
|
+
// No source file written
|
|
372
|
+
const { engine } = makeTempRun(def, graphSteps);
|
|
373
|
+
|
|
374
|
+
await assert.rejects(
|
|
375
|
+
() => dispatch(engine),
|
|
376
|
+
(err: Error) => {
|
|
377
|
+
assert.ok(err.message.includes("nonexistent.md"), `Error should mention the filename: ${err.message}`);
|
|
378
|
+
assert.ok(err.message.includes("Iterate source artifact not found"), `Error should mention it's an iterate source: ${err.message}`);
|
|
379
|
+
return true;
|
|
380
|
+
},
|
|
381
|
+
);
|
|
382
|
+
});
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
describe("iterate expansion — idempotency", () => {
|
|
386
|
+
it("does not re-expand an already expanded step on subsequent dispatch", async () => {
|
|
387
|
+
const def: WorkflowDefinition = {
|
|
388
|
+
version: 1,
|
|
389
|
+
name: "idem-wf",
|
|
390
|
+
steps: [
|
|
391
|
+
{
|
|
392
|
+
id: "fan",
|
|
393
|
+
name: "Fan Step",
|
|
394
|
+
prompt: "Process {{item}}",
|
|
395
|
+
requires: [],
|
|
396
|
+
produces: [],
|
|
397
|
+
iterate: { source: "items.md", pattern: "^- (.+)$" },
|
|
398
|
+
},
|
|
399
|
+
],
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
const graphSteps = [makeStep({ id: "fan", prompt: "Process {{item}}" })];
|
|
403
|
+
|
|
404
|
+
const { runDir, engine } = makeTempRun(def, graphSteps, {
|
|
405
|
+
"items.md": "- Uno\n- Dos\n",
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
// First dispatch: triggers expansion
|
|
409
|
+
let result = await dispatch(engine);
|
|
410
|
+
assert.equal(result.action, "dispatch");
|
|
411
|
+
if (result.action === "dispatch") {
|
|
412
|
+
assert.equal(result.step.unitId, "idem-wf/fan--001");
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Second dispatch without reconciling: should return the same instance
|
|
416
|
+
// (graph already expanded on disk, parent is "expanded" so getNextPendingStep
|
|
417
|
+
// skips it and returns the first pending instance step)
|
|
418
|
+
result = await dispatch(engine);
|
|
419
|
+
assert.equal(result.action, "dispatch");
|
|
420
|
+
if (result.action === "dispatch") {
|
|
421
|
+
assert.equal(result.step.unitId, "idem-wf/fan--001");
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Verify no double-expansion: still only 2 instances
|
|
425
|
+
const graph = readGraph(runDir);
|
|
426
|
+
const instances = graph.steps.filter((s) => s.parentStepId === "fan");
|
|
427
|
+
assert.equal(instances.length, 2);
|
|
428
|
+
});
|
|
429
|
+
});
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* run-manager.test.ts — Tests for run directory creation and listing.
|
|
3
|
+
*
|
|
4
|
+
* Uses real temp directories with actual definition YAML files and
|
|
5
|
+
* GRAPH.yaml persistence — no mocks.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, afterEach } from "node:test";
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
import {
|
|
11
|
+
mkdtempSync,
|
|
12
|
+
rmSync,
|
|
13
|
+
mkdirSync,
|
|
14
|
+
writeFileSync,
|
|
15
|
+
readFileSync,
|
|
16
|
+
existsSync,
|
|
17
|
+
readdirSync,
|
|
18
|
+
} from "node:fs";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { tmpdir } from "node:os";
|
|
21
|
+
import { parse } from "yaml";
|
|
22
|
+
|
|
23
|
+
import { createRun, listRuns } from "../run-manager.ts";
|
|
24
|
+
|
|
25
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
const tmpDirs: string[] = [];
|
|
28
|
+
|
|
29
|
+
function makeTmpBase(): string {
|
|
30
|
+
const dir = mkdtempSync(join(tmpdir(), "run-mgr-test-"));
|
|
31
|
+
tmpDirs.push(dir);
|
|
32
|
+
return dir;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
for (const d of tmpDirs) {
|
|
37
|
+
try { rmSync(d, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch { /* Windows EPERM */ }
|
|
38
|
+
}
|
|
39
|
+
tmpDirs.length = 0;
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
/** Write a minimal valid workflow definition YAML to the expected location. */
|
|
43
|
+
function writeDefinition(
|
|
44
|
+
basePath: string,
|
|
45
|
+
name: string,
|
|
46
|
+
content: string,
|
|
47
|
+
): void {
|
|
48
|
+
const defsDir = join(basePath, ".gsd", "workflow-defs");
|
|
49
|
+
mkdirSync(defsDir, { recursive: true });
|
|
50
|
+
writeFileSync(join(defsDir, `${name}.yaml`), content, "utf-8");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const SIMPLE_DEF = `
|
|
54
|
+
version: 1
|
|
55
|
+
name: test-workflow
|
|
56
|
+
description: A test workflow
|
|
57
|
+
steps:
|
|
58
|
+
- id: step-1
|
|
59
|
+
name: First Step
|
|
60
|
+
prompt: Do step 1
|
|
61
|
+
requires: []
|
|
62
|
+
produces: []
|
|
63
|
+
- id: step-2
|
|
64
|
+
name: Second Step
|
|
65
|
+
prompt: Do step 2
|
|
66
|
+
requires:
|
|
67
|
+
- step-1
|
|
68
|
+
produces: []
|
|
69
|
+
`;
|
|
70
|
+
|
|
71
|
+
const PARAMETERIZED_DEF = `
|
|
72
|
+
version: 1
|
|
73
|
+
name: param-workflow
|
|
74
|
+
description: A parameterized workflow
|
|
75
|
+
params:
|
|
76
|
+
target: default-target
|
|
77
|
+
steps:
|
|
78
|
+
- id: step-1
|
|
79
|
+
name: Build
|
|
80
|
+
prompt: "Build {{target}}"
|
|
81
|
+
requires: []
|
|
82
|
+
produces: []
|
|
83
|
+
`;
|
|
84
|
+
|
|
85
|
+
// ─── createRun ───────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
describe("createRun", () => {
|
|
88
|
+
it("creates directory structure with DEFINITION.yaml and GRAPH.yaml", () => {
|
|
89
|
+
const base = makeTmpBase();
|
|
90
|
+
writeDefinition(base, "test-workflow", SIMPLE_DEF);
|
|
91
|
+
|
|
92
|
+
const runDir = createRun(base, "test-workflow");
|
|
93
|
+
|
|
94
|
+
// Run directory exists
|
|
95
|
+
assert.ok(existsSync(runDir), "run directory should exist");
|
|
96
|
+
|
|
97
|
+
// DEFINITION.yaml exists and contains the definition
|
|
98
|
+
const defPath = join(runDir, "DEFINITION.yaml");
|
|
99
|
+
assert.ok(existsSync(defPath), "DEFINITION.yaml should exist");
|
|
100
|
+
const defContent = parse(readFileSync(defPath, "utf-8"));
|
|
101
|
+
assert.equal(defContent.name, "test-workflow");
|
|
102
|
+
assert.equal(defContent.steps.length, 2);
|
|
103
|
+
|
|
104
|
+
// GRAPH.yaml exists with all steps pending
|
|
105
|
+
const graphPath = join(runDir, "GRAPH.yaml");
|
|
106
|
+
assert.ok(existsSync(graphPath), "GRAPH.yaml should exist");
|
|
107
|
+
const graphContent = parse(readFileSync(graphPath, "utf-8"));
|
|
108
|
+
assert.equal(graphContent.steps.length, 2);
|
|
109
|
+
assert.equal(graphContent.steps[0].status, "pending");
|
|
110
|
+
assert.equal(graphContent.steps[1].status, "pending");
|
|
111
|
+
assert.equal(graphContent.metadata.name, "test-workflow");
|
|
112
|
+
|
|
113
|
+
// No PARAMS.json without overrides
|
|
114
|
+
assert.ok(!existsSync(join(runDir, "PARAMS.json")), "PARAMS.json should not exist without overrides");
|
|
115
|
+
|
|
116
|
+
// Run directory path matches convention
|
|
117
|
+
assert.ok(runDir.includes(join(".gsd", "workflow-runs", "test-workflow")), "path should follow convention");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("writes PARAMS.json and substituted prompts when overrides provided", () => {
|
|
121
|
+
const base = makeTmpBase();
|
|
122
|
+
writeDefinition(base, "param-workflow", PARAMETERIZED_DEF);
|
|
123
|
+
|
|
124
|
+
const runDir = createRun(base, "param-workflow", { target: "my-app" });
|
|
125
|
+
|
|
126
|
+
// PARAMS.json exists with overrides
|
|
127
|
+
const paramsPath = join(runDir, "PARAMS.json");
|
|
128
|
+
assert.ok(existsSync(paramsPath), "PARAMS.json should exist");
|
|
129
|
+
const params = JSON.parse(readFileSync(paramsPath, "utf-8"));
|
|
130
|
+
assert.deepStrictEqual(params, { target: "my-app" });
|
|
131
|
+
|
|
132
|
+
// DEFINITION.yaml has substituted prompts
|
|
133
|
+
const defPath = join(runDir, "DEFINITION.yaml");
|
|
134
|
+
const defContent = parse(readFileSync(defPath, "utf-8"));
|
|
135
|
+
assert.equal(defContent.steps[0].prompt, "Build my-app");
|
|
136
|
+
|
|
137
|
+
// GRAPH.yaml also has substituted prompts
|
|
138
|
+
const graphPath = join(runDir, "GRAPH.yaml");
|
|
139
|
+
const graphContent = parse(readFileSync(graphPath, "utf-8"));
|
|
140
|
+
assert.equal(graphContent.steps[0].prompt, "Build my-app");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("throws for unknown definition", () => {
|
|
144
|
+
const base = makeTmpBase();
|
|
145
|
+
// Don't write any definition file
|
|
146
|
+
|
|
147
|
+
assert.throws(
|
|
148
|
+
() => createRun(base, "nonexistent"),
|
|
149
|
+
(err: Error) => err.message.includes("not found"),
|
|
150
|
+
);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("uses filesystem-safe timestamp directory names", () => {
|
|
154
|
+
const base = makeTmpBase();
|
|
155
|
+
writeDefinition(base, "test-workflow", SIMPLE_DEF);
|
|
156
|
+
|
|
157
|
+
const runDir = createRun(base, "test-workflow");
|
|
158
|
+
|
|
159
|
+
// Extract the timestamp directory name (use path.sep for cross-platform)
|
|
160
|
+
const timestamp = runDir.split(/[/\\]/).pop()!;
|
|
161
|
+
|
|
162
|
+
// Should not contain colons (filesystem-unsafe on Windows)
|
|
163
|
+
assert.ok(!timestamp.includes(":"), `timestamp should not contain colons: ${timestamp}`);
|
|
164
|
+
// Should match YYYY-MM-DDTHH-MM-SS pattern
|
|
165
|
+
assert.match(timestamp, /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}$/);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// ─── listRuns ────────────────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
describe("listRuns", () => {
|
|
172
|
+
it("returns empty array when no runs exist", () => {
|
|
173
|
+
const base = makeTmpBase();
|
|
174
|
+
const runs = listRuns(base);
|
|
175
|
+
assert.deepStrictEqual(runs, []);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("returns correct metadata for existing runs", () => {
|
|
179
|
+
const base = makeTmpBase();
|
|
180
|
+
writeDefinition(base, "test-workflow", SIMPLE_DEF);
|
|
181
|
+
|
|
182
|
+
// Create a run
|
|
183
|
+
const runDir = createRun(base, "test-workflow");
|
|
184
|
+
|
|
185
|
+
const runs = listRuns(base);
|
|
186
|
+
assert.equal(runs.length, 1);
|
|
187
|
+
assert.equal(runs[0].name, "test-workflow");
|
|
188
|
+
assert.equal(runs[0].runDir, runDir);
|
|
189
|
+
assert.equal(runs[0].steps.total, 2);
|
|
190
|
+
assert.equal(runs[0].steps.completed, 0);
|
|
191
|
+
assert.equal(runs[0].steps.pending, 2);
|
|
192
|
+
assert.equal(runs[0].steps.active, 0);
|
|
193
|
+
assert.equal(runs[0].status, "pending");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("filters by definition name", () => {
|
|
197
|
+
const base = makeTmpBase();
|
|
198
|
+
writeDefinition(base, "test-workflow", SIMPLE_DEF);
|
|
199
|
+
writeDefinition(base, "param-workflow", PARAMETERIZED_DEF);
|
|
200
|
+
|
|
201
|
+
createRun(base, "test-workflow");
|
|
202
|
+
createRun(base, "param-workflow", { target: "app" });
|
|
203
|
+
|
|
204
|
+
const allRuns = listRuns(base);
|
|
205
|
+
assert.equal(allRuns.length, 2);
|
|
206
|
+
|
|
207
|
+
const filtered = listRuns(base, "test-workflow");
|
|
208
|
+
assert.equal(filtered.length, 1);
|
|
209
|
+
assert.equal(filtered[0].name, "test-workflow");
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("returns newest-first within same definition", () => {
|
|
213
|
+
const base = makeTmpBase();
|
|
214
|
+
writeDefinition(base, "test-workflow", SIMPLE_DEF);
|
|
215
|
+
|
|
216
|
+
const run1 = createRun(base, "test-workflow");
|
|
217
|
+
// Ensure different timestamp by creating run dir manually with earlier timestamp
|
|
218
|
+
const earlyDir = join(base, ".gsd", "workflow-runs", "test-workflow", "2020-01-01T00-00-00");
|
|
219
|
+
mkdirSync(earlyDir, { recursive: true });
|
|
220
|
+
// Copy GRAPH.yaml to make it a valid run
|
|
221
|
+
const graphContent = readFileSync(join(run1, "GRAPH.yaml"), "utf-8");
|
|
222
|
+
writeFileSync(join(earlyDir, "GRAPH.yaml"), graphContent, "utf-8");
|
|
223
|
+
|
|
224
|
+
const runs = listRuns(base, "test-workflow");
|
|
225
|
+
assert.equal(runs.length, 2);
|
|
226
|
+
// First should be the newer one (the one we just created)
|
|
227
|
+
assert.ok(runs[0].timestamp > runs[1].timestamp, "should be sorted newest-first");
|
|
228
|
+
});
|
|
229
|
+
});
|