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.
Files changed (115) hide show
  1. package/dist/resources/extensions/gsd/auto/loop.js +80 -0
  2. package/dist/resources/extensions/gsd/auto/phases.js +2 -2
  3. package/dist/resources/extensions/gsd/auto/session.js +6 -0
  4. package/dist/resources/extensions/gsd/auto-dashboard.js +2 -0
  5. package/dist/resources/extensions/gsd/auto.js +28 -1
  6. package/dist/resources/extensions/gsd/bootstrap/tool-call-loop-guard.js +7 -2
  7. package/dist/resources/extensions/gsd/commands/catalog.js +32 -0
  8. package/dist/resources/extensions/gsd/commands/handlers/workflow.js +146 -0
  9. package/dist/resources/extensions/gsd/context-injector.js +74 -0
  10. package/dist/resources/extensions/gsd/custom-execution-policy.js +47 -0
  11. package/dist/resources/extensions/gsd/custom-verification.js +145 -0
  12. package/dist/resources/extensions/gsd/custom-workflow-engine.js +164 -0
  13. package/dist/resources/extensions/gsd/dashboard-overlay.js +1 -0
  14. package/dist/resources/extensions/gsd/definition-loader.js +352 -0
  15. package/dist/resources/extensions/gsd/dev-execution-policy.js +24 -0
  16. package/dist/resources/extensions/gsd/dev-workflow-engine.js +82 -0
  17. package/dist/resources/extensions/gsd/engine-resolver.js +40 -0
  18. package/dist/resources/extensions/gsd/engine-types.js +8 -0
  19. package/dist/resources/extensions/gsd/execution-policy.js +8 -0
  20. package/dist/resources/extensions/gsd/graph.js +225 -0
  21. package/dist/resources/extensions/gsd/run-manager.js +134 -0
  22. package/dist/resources/extensions/gsd/workflow-engine.js +7 -0
  23. package/dist/resources/skills/create-workflow/SKILL.md +103 -0
  24. package/dist/resources/skills/create-workflow/references/feature-patterns.md +128 -0
  25. package/dist/resources/skills/create-workflow/references/verification-policies.md +76 -0
  26. package/dist/resources/skills/create-workflow/references/yaml-schema-v1.md +46 -0
  27. package/dist/resources/skills/create-workflow/templates/blog-post-pipeline.yaml +60 -0
  28. package/dist/resources/skills/create-workflow/templates/code-audit.yaml +60 -0
  29. package/dist/resources/skills/create-workflow/templates/release-checklist.yaml +66 -0
  30. package/dist/resources/skills/create-workflow/templates/workflow-definition.yaml +32 -0
  31. package/dist/resources/skills/create-workflow/workflows/create-from-scratch.md +104 -0
  32. package/dist/resources/skills/create-workflow/workflows/create-from-template.md +72 -0
  33. package/dist/web/standalone/.next/BUILD_ID +1 -1
  34. package/dist/web/standalone/.next/app-path-routes-manifest.json +15 -15
  35. package/dist/web/standalone/.next/build-manifest.json +2 -2
  36. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  37. package/dist/web/standalone/.next/server/app/_global-error.html +2 -2
  38. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  39. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  40. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  41. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  42. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  43. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  44. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  45. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  46. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  47. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  48. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  49. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  50. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  51. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  52. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  53. package/dist/web/standalone/.next/server/app/index.html +1 -1
  54. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  55. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  56. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  57. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  58. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  59. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  60. package/dist/web/standalone/.next/server/app-paths-manifest.json +15 -15
  61. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  62. package/dist/web/standalone/.next/server/pages/500.html +2 -2
  63. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  64. package/package.json +1 -1
  65. package/packages/pi-coding-agent/package.json +1 -1
  66. package/pkg/package.json +1 -1
  67. package/src/resources/extensions/gsd/auto/loop.ts +91 -0
  68. package/src/resources/extensions/gsd/auto/phases.ts +2 -2
  69. package/src/resources/extensions/gsd/auto/session.ts +6 -0
  70. package/src/resources/extensions/gsd/auto-dashboard.ts +2 -0
  71. package/src/resources/extensions/gsd/auto.ts +31 -1
  72. package/src/resources/extensions/gsd/bootstrap/tool-call-loop-guard.ts +9 -2
  73. package/src/resources/extensions/gsd/commands/catalog.ts +32 -0
  74. package/src/resources/extensions/gsd/commands/handlers/workflow.ts +164 -0
  75. package/src/resources/extensions/gsd/context-injector.ts +100 -0
  76. package/src/resources/extensions/gsd/custom-execution-policy.ts +73 -0
  77. package/src/resources/extensions/gsd/custom-verification.ts +180 -0
  78. package/src/resources/extensions/gsd/custom-workflow-engine.ts +216 -0
  79. package/src/resources/extensions/gsd/dashboard-overlay.ts +1 -0
  80. package/src/resources/extensions/gsd/definition-loader.ts +462 -0
  81. package/src/resources/extensions/gsd/dev-execution-policy.ts +51 -0
  82. package/src/resources/extensions/gsd/dev-workflow-engine.ts +110 -0
  83. package/src/resources/extensions/gsd/engine-resolver.ts +57 -0
  84. package/src/resources/extensions/gsd/engine-types.ts +71 -0
  85. package/src/resources/extensions/gsd/execution-policy.ts +43 -0
  86. package/src/resources/extensions/gsd/graph.ts +312 -0
  87. package/src/resources/extensions/gsd/run-manager.ts +180 -0
  88. package/src/resources/extensions/gsd/tests/bundled-workflow-defs.test.ts +180 -0
  89. package/src/resources/extensions/gsd/tests/commands-workflow-custom.test.ts +283 -0
  90. package/src/resources/extensions/gsd/tests/context-injector.test.ts +313 -0
  91. package/src/resources/extensions/gsd/tests/custom-engine-loop-integration.test.ts +540 -0
  92. package/src/resources/extensions/gsd/tests/custom-verification.test.ts +382 -0
  93. package/src/resources/extensions/gsd/tests/custom-workflow-engine.test.ts +339 -0
  94. package/src/resources/extensions/gsd/tests/dashboard-custom-engine.test.ts +87 -0
  95. package/src/resources/extensions/gsd/tests/definition-loader.test.ts +778 -0
  96. package/src/resources/extensions/gsd/tests/dev-engine-wrapper.test.ts +318 -0
  97. package/src/resources/extensions/gsd/tests/e2e-workflow-pipeline-integration.test.ts +476 -0
  98. package/src/resources/extensions/gsd/tests/engine-interfaces-contract.test.ts +271 -0
  99. package/src/resources/extensions/gsd/tests/graph-operations.test.ts +599 -0
  100. package/src/resources/extensions/gsd/tests/iterate-engine-integration.test.ts +429 -0
  101. package/src/resources/extensions/gsd/tests/run-manager.test.ts +229 -0
  102. package/src/resources/extensions/gsd/tests/tool-call-loop-guard.test.ts +45 -0
  103. package/src/resources/extensions/gsd/workflow-engine.ts +38 -0
  104. package/src/resources/skills/create-workflow/SKILL.md +103 -0
  105. package/src/resources/skills/create-workflow/references/feature-patterns.md +128 -0
  106. package/src/resources/skills/create-workflow/references/verification-policies.md +76 -0
  107. package/src/resources/skills/create-workflow/references/yaml-schema-v1.md +46 -0
  108. package/src/resources/skills/create-workflow/templates/blog-post-pipeline.yaml +60 -0
  109. package/src/resources/skills/create-workflow/templates/code-audit.yaml +60 -0
  110. package/src/resources/skills/create-workflow/templates/release-checklist.yaml +66 -0
  111. package/src/resources/skills/create-workflow/templates/workflow-definition.yaml +32 -0
  112. package/src/resources/skills/create-workflow/workflows/create-from-scratch.md +104 -0
  113. package/src/resources/skills/create-workflow/workflows/create-from-template.md +72 -0
  114. /package/dist/web/standalone/.next/static/{EnGUNqHeGbE0tuuUkTJVA → PXrI5DoWsm7rwAVnEU2rD}/_buildManifest.js +0 -0
  115. /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
+ });