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,599 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* graph-operations.test.ts — Comprehensive tests for graph.ts DAG operations.
|
|
3
|
+
*
|
|
4
|
+
* Covers: YAML I/O round-trips, DAG queries (getNextPendingStep),
|
|
5
|
+
* immutable step completion, iteration expansion with downstream dep
|
|
6
|
+
* rewriting, initializeGraph conversion, and atomic write safety.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it } from "node:test";
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import { mkdtempSync, rmSync, readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
readGraph,
|
|
17
|
+
writeGraph,
|
|
18
|
+
getNextPendingStep,
|
|
19
|
+
markStepComplete,
|
|
20
|
+
expandIteration,
|
|
21
|
+
initializeGraph,
|
|
22
|
+
graphFromDefinition,
|
|
23
|
+
type WorkflowGraph,
|
|
24
|
+
type GraphStep,
|
|
25
|
+
} from "../graph.ts";
|
|
26
|
+
import type { WorkflowDefinition } from "../definition-loader.ts";
|
|
27
|
+
|
|
28
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
function makeTmpDir(): string {
|
|
31
|
+
return mkdtempSync(join(tmpdir(), "graph-test-"));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function cleanupDir(dir: string): void {
|
|
35
|
+
try { rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch { /* Windows EPERM */ }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Minimal valid graph for testing. */
|
|
39
|
+
function makeGraph(steps: GraphStep[], name = "test-workflow"): WorkflowGraph {
|
|
40
|
+
return {
|
|
41
|
+
steps,
|
|
42
|
+
metadata: { name, createdAt: "2026-01-01T00:00:00.000Z" },
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function makeStep(overrides: Partial<GraphStep> & { id: string }): GraphStep {
|
|
47
|
+
return {
|
|
48
|
+
title: overrides.id,
|
|
49
|
+
status: "pending",
|
|
50
|
+
prompt: `Do ${overrides.id}`,
|
|
51
|
+
dependsOn: [],
|
|
52
|
+
...overrides,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ─── writeGraph + readGraph round-trip ───────────────────────────────────
|
|
57
|
+
|
|
58
|
+
describe("writeGraph + readGraph round-trip", () => {
|
|
59
|
+
it("preserves all fields including parentStepId and dependsOn", () => {
|
|
60
|
+
const dir = makeTmpDir();
|
|
61
|
+
try {
|
|
62
|
+
const graph = makeGraph([
|
|
63
|
+
makeStep({ id: "step-1", title: "First Step", dependsOn: [] }),
|
|
64
|
+
makeStep({
|
|
65
|
+
id: "step-2",
|
|
66
|
+
title: "Second Step",
|
|
67
|
+
dependsOn: ["step-1"],
|
|
68
|
+
parentStepId: "parent-iter",
|
|
69
|
+
}),
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
writeGraph(dir, graph);
|
|
73
|
+
const loaded = readGraph(dir);
|
|
74
|
+
|
|
75
|
+
assert.equal(loaded.steps.length, 2);
|
|
76
|
+
assert.equal(loaded.steps[0].id, "step-1");
|
|
77
|
+
assert.equal(loaded.steps[0].title, "First Step");
|
|
78
|
+
assert.equal(loaded.steps[0].status, "pending");
|
|
79
|
+
assert.deepStrictEqual(loaded.steps[0].dependsOn, []);
|
|
80
|
+
|
|
81
|
+
assert.equal(loaded.steps[1].id, "step-2");
|
|
82
|
+
assert.deepStrictEqual(loaded.steps[1].dependsOn, ["step-1"]);
|
|
83
|
+
assert.equal(loaded.steps[1].parentStepId, "parent-iter");
|
|
84
|
+
|
|
85
|
+
assert.equal(loaded.metadata.name, "test-workflow");
|
|
86
|
+
assert.equal(loaded.metadata.createdAt, "2026-01-01T00:00:00.000Z");
|
|
87
|
+
} finally {
|
|
88
|
+
cleanupDir(dir);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("preserves startedAt and finishedAt fields", () => {
|
|
93
|
+
const dir = makeTmpDir();
|
|
94
|
+
try {
|
|
95
|
+
const graph = makeGraph([
|
|
96
|
+
makeStep({
|
|
97
|
+
id: "s1",
|
|
98
|
+
status: "complete",
|
|
99
|
+
startedAt: "2026-01-01T01:00:00.000Z",
|
|
100
|
+
finishedAt: "2026-01-01T01:05:00.000Z",
|
|
101
|
+
}),
|
|
102
|
+
]);
|
|
103
|
+
writeGraph(dir, graph);
|
|
104
|
+
const loaded = readGraph(dir);
|
|
105
|
+
|
|
106
|
+
assert.equal(loaded.steps[0].startedAt, "2026-01-01T01:00:00.000Z");
|
|
107
|
+
assert.equal(loaded.steps[0].finishedAt, "2026-01-01T01:05:00.000Z");
|
|
108
|
+
} finally {
|
|
109
|
+
cleanupDir(dir);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("creates directory if it does not exist", () => {
|
|
114
|
+
const base = makeTmpDir();
|
|
115
|
+
const nested = join(base, "sub", "dir");
|
|
116
|
+
try {
|
|
117
|
+
const graph = makeGraph([makeStep({ id: "s1" })]);
|
|
118
|
+
writeGraph(nested, graph);
|
|
119
|
+
assert.ok(existsSync(join(nested, "GRAPH.yaml")));
|
|
120
|
+
|
|
121
|
+
const loaded = readGraph(nested);
|
|
122
|
+
assert.equal(loaded.steps[0].id, "s1");
|
|
123
|
+
} finally {
|
|
124
|
+
cleanupDir(base);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// ─── readGraph error paths ───────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
describe("readGraph error paths", () => {
|
|
132
|
+
it("throws with descriptive error when file is missing", () => {
|
|
133
|
+
const dir = makeTmpDir();
|
|
134
|
+
try {
|
|
135
|
+
assert.throws(
|
|
136
|
+
() => readGraph(dir),
|
|
137
|
+
(err: Error) => {
|
|
138
|
+
assert.ok(err.message.includes("GRAPH.yaml not found"));
|
|
139
|
+
assert.ok(err.message.includes(dir));
|
|
140
|
+
return true;
|
|
141
|
+
},
|
|
142
|
+
);
|
|
143
|
+
} finally {
|
|
144
|
+
cleanupDir(dir);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("throws with descriptive error when YAML is malformed (missing steps)", () => {
|
|
149
|
+
const dir = makeTmpDir();
|
|
150
|
+
try {
|
|
151
|
+
writeFileSync(join(dir, "GRAPH.yaml"), "metadata:\n name: bad\n", "utf-8");
|
|
152
|
+
assert.throws(
|
|
153
|
+
() => readGraph(dir),
|
|
154
|
+
(err: Error) => {
|
|
155
|
+
assert.ok(err.message.includes("missing or invalid 'steps' array"));
|
|
156
|
+
return true;
|
|
157
|
+
},
|
|
158
|
+
);
|
|
159
|
+
} finally {
|
|
160
|
+
cleanupDir(dir);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("throws when steps is not an array", () => {
|
|
165
|
+
const dir = makeTmpDir();
|
|
166
|
+
try {
|
|
167
|
+
writeFileSync(join(dir, "GRAPH.yaml"), "steps: not-an-array\nmetadata:\n name: bad\n", "utf-8");
|
|
168
|
+
assert.throws(
|
|
169
|
+
() => readGraph(dir),
|
|
170
|
+
(err: Error) => {
|
|
171
|
+
assert.ok(err.message.includes("missing or invalid 'steps' array"));
|
|
172
|
+
return true;
|
|
173
|
+
},
|
|
174
|
+
);
|
|
175
|
+
} finally {
|
|
176
|
+
cleanupDir(dir);
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// ─── getNextPendingStep ──────────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
describe("getNextPendingStep", () => {
|
|
184
|
+
it("returns first step with all deps complete", () => {
|
|
185
|
+
const graph = makeGraph([
|
|
186
|
+
makeStep({ id: "a", status: "complete" }),
|
|
187
|
+
makeStep({ id: "b", dependsOn: ["a"] }),
|
|
188
|
+
makeStep({ id: "c", dependsOn: ["b"] }),
|
|
189
|
+
]);
|
|
190
|
+
|
|
191
|
+
const next = getNextPendingStep(graph);
|
|
192
|
+
assert.equal(next?.id, "b");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("skips steps with incomplete deps", () => {
|
|
196
|
+
const graph = makeGraph([
|
|
197
|
+
makeStep({ id: "a" }),
|
|
198
|
+
makeStep({ id: "b", dependsOn: ["a"] }),
|
|
199
|
+
]);
|
|
200
|
+
|
|
201
|
+
// 'a' is still pending, so 'b' is blocked, but 'a' has no deps → returns 'a'
|
|
202
|
+
const next = getNextPendingStep(graph);
|
|
203
|
+
assert.equal(next?.id, "a");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("returns null when all steps are complete", () => {
|
|
207
|
+
const graph = makeGraph([
|
|
208
|
+
makeStep({ id: "a", status: "complete" }),
|
|
209
|
+
makeStep({ id: "b", status: "complete" }),
|
|
210
|
+
]);
|
|
211
|
+
|
|
212
|
+
assert.equal(getNextPendingStep(graph), null);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("returns null when all pending steps are blocked", () => {
|
|
216
|
+
const graph = makeGraph([
|
|
217
|
+
makeStep({ id: "a", status: "active" }), // not complete
|
|
218
|
+
makeStep({ id: "b", dependsOn: ["a"] }), // blocked
|
|
219
|
+
]);
|
|
220
|
+
|
|
221
|
+
assert.equal(getNextPendingStep(graph), null);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("returns first pending step with no deps when root steps exist", () => {
|
|
225
|
+
const graph = makeGraph([
|
|
226
|
+
makeStep({ id: "a" }),
|
|
227
|
+
makeStep({ id: "b" }),
|
|
228
|
+
]);
|
|
229
|
+
|
|
230
|
+
const next = getNextPendingStep(graph);
|
|
231
|
+
assert.equal(next?.id, "a");
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("skips expanded steps", () => {
|
|
235
|
+
const graph = makeGraph([
|
|
236
|
+
makeStep({ id: "a", status: "expanded" }),
|
|
237
|
+
makeStep({ id: "b" }),
|
|
238
|
+
]);
|
|
239
|
+
|
|
240
|
+
const next = getNextPendingStep(graph);
|
|
241
|
+
assert.equal(next?.id, "b");
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// ─── markStepComplete ────────────────────────────────────────────────────
|
|
246
|
+
|
|
247
|
+
describe("markStepComplete", () => {
|
|
248
|
+
it("returns new graph with step status 'complete' (original unchanged)", () => {
|
|
249
|
+
const original = makeGraph([
|
|
250
|
+
makeStep({ id: "a" }),
|
|
251
|
+
makeStep({ id: "b" }),
|
|
252
|
+
]);
|
|
253
|
+
|
|
254
|
+
const updated = markStepComplete(original, "a");
|
|
255
|
+
|
|
256
|
+
// Original is untouched
|
|
257
|
+
assert.equal(original.steps[0].status, "pending");
|
|
258
|
+
|
|
259
|
+
// New graph has the step complete
|
|
260
|
+
assert.equal(updated.steps[0].status, "complete");
|
|
261
|
+
assert.equal(updated.steps[0].id, "a");
|
|
262
|
+
|
|
263
|
+
// Other steps unchanged
|
|
264
|
+
assert.equal(updated.steps[1].status, "pending");
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("sets finishedAt timestamp", () => {
|
|
268
|
+
const graph = makeGraph([makeStep({ id: "a" })]);
|
|
269
|
+
const updated = markStepComplete(graph, "a");
|
|
270
|
+
assert.ok(updated.steps[0].finishedAt);
|
|
271
|
+
// Should be a valid ISO string
|
|
272
|
+
assert.ok(!isNaN(Date.parse(updated.steps[0].finishedAt!)));
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("throws for unknown step ID", () => {
|
|
276
|
+
const graph = makeGraph([makeStep({ id: "a" })]);
|
|
277
|
+
assert.throws(
|
|
278
|
+
() => markStepComplete(graph, "nonexistent"),
|
|
279
|
+
(err: Error) => {
|
|
280
|
+
assert.ok(err.message.includes("Step not found"));
|
|
281
|
+
assert.ok(err.message.includes("nonexistent"));
|
|
282
|
+
return true;
|
|
283
|
+
},
|
|
284
|
+
);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("preserves metadata in returned graph", () => {
|
|
288
|
+
const graph = makeGraph([makeStep({ id: "a" })], "my-workflow");
|
|
289
|
+
const updated = markStepComplete(graph, "a");
|
|
290
|
+
assert.equal(updated.metadata.name, "my-workflow");
|
|
291
|
+
assert.equal(updated.metadata.createdAt, "2026-01-01T00:00:00.000Z");
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
// ─── expandIteration ─────────────────────────────────────────────────────
|
|
296
|
+
|
|
297
|
+
describe("expandIteration", () => {
|
|
298
|
+
it("creates instance steps with correct IDs (stepId--001, stepId--002)", () => {
|
|
299
|
+
const graph = makeGraph([
|
|
300
|
+
makeStep({ id: "iter-step", title: "Process items" }),
|
|
301
|
+
makeStep({ id: "final", dependsOn: ["iter-step"] }),
|
|
302
|
+
]);
|
|
303
|
+
|
|
304
|
+
const expanded = expandIteration(
|
|
305
|
+
graph,
|
|
306
|
+
"iter-step",
|
|
307
|
+
["apple", "banana", "cherry"],
|
|
308
|
+
"Process {{item}}",
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
// Parent + 3 instances + final = 5 steps
|
|
312
|
+
assert.equal(expanded.steps.length, 5);
|
|
313
|
+
|
|
314
|
+
// Instances are correctly named
|
|
315
|
+
assert.equal(expanded.steps[1].id, "iter-step--001");
|
|
316
|
+
assert.equal(expanded.steps[2].id, "iter-step--002");
|
|
317
|
+
assert.equal(expanded.steps[3].id, "iter-step--003");
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("marks parent step as 'expanded'", () => {
|
|
321
|
+
const graph = makeGraph([
|
|
322
|
+
makeStep({ id: "iter", title: "Iterate" }),
|
|
323
|
+
]);
|
|
324
|
+
|
|
325
|
+
const expanded = expandIteration(graph, "iter", ["a"], "Do {{item}}");
|
|
326
|
+
assert.equal(expanded.steps[0].status, "expanded");
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("instance steps have correct titles, prompts, parentStepId, and deps", () => {
|
|
330
|
+
const graph = makeGraph([
|
|
331
|
+
makeStep({ id: "pre", status: "complete" }),
|
|
332
|
+
makeStep({ id: "iter", title: "Process", dependsOn: ["pre"] }),
|
|
333
|
+
]);
|
|
334
|
+
|
|
335
|
+
const expanded = expandIteration(
|
|
336
|
+
graph,
|
|
337
|
+
"iter",
|
|
338
|
+
["foo", "bar"],
|
|
339
|
+
"Handle {{item}} carefully",
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
const inst1 = expanded.steps[2]; // after pre and expanded parent
|
|
343
|
+
assert.equal(inst1.title, "Process: foo");
|
|
344
|
+
assert.equal(inst1.prompt, "Handle foo carefully");
|
|
345
|
+
assert.equal(inst1.parentStepId, "iter");
|
|
346
|
+
assert.deepStrictEqual(inst1.dependsOn, ["pre"]);
|
|
347
|
+
assert.equal(inst1.status, "pending");
|
|
348
|
+
|
|
349
|
+
const inst2 = expanded.steps[3];
|
|
350
|
+
assert.equal(inst2.title, "Process: bar");
|
|
351
|
+
assert.equal(inst2.prompt, "Handle bar carefully");
|
|
352
|
+
assert.equal(inst2.parentStepId, "iter");
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it("rewrites downstream deps from parent ID to all instance IDs", () => {
|
|
356
|
+
const graph = makeGraph([
|
|
357
|
+
makeStep({ id: "iter", title: "Iterate" }),
|
|
358
|
+
makeStep({ id: "after", dependsOn: ["iter"] }),
|
|
359
|
+
]);
|
|
360
|
+
|
|
361
|
+
const expanded = expandIteration(
|
|
362
|
+
graph,
|
|
363
|
+
"iter",
|
|
364
|
+
["x", "y"],
|
|
365
|
+
"Do {{item}}",
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
// 'after' should now depend on iter--001 and iter--002
|
|
369
|
+
const afterStep = expanded.steps.find((s) => s.id === "after")!;
|
|
370
|
+
assert.deepStrictEqual(afterStep.dependsOn, ["iter--001", "iter--002"]);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it("preserves steps that don't depend on the parent", () => {
|
|
374
|
+
const graph = makeGraph([
|
|
375
|
+
makeStep({ id: "unrelated" }),
|
|
376
|
+
makeStep({ id: "iter", title: "Iterate" }),
|
|
377
|
+
makeStep({ id: "after", dependsOn: ["iter"] }),
|
|
378
|
+
]);
|
|
379
|
+
|
|
380
|
+
const expanded = expandIteration(graph, "iter", ["a"], "{{item}}");
|
|
381
|
+
const unrelated = expanded.steps.find((s) => s.id === "unrelated")!;
|
|
382
|
+
assert.deepStrictEqual(unrelated.dependsOn, []);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it("throws for non-pending parent step", () => {
|
|
386
|
+
const graph = makeGraph([
|
|
387
|
+
makeStep({ id: "iter", status: "complete" }),
|
|
388
|
+
]);
|
|
389
|
+
|
|
390
|
+
assert.throws(
|
|
391
|
+
() => expandIteration(graph, "iter", ["a"], "{{item}}"),
|
|
392
|
+
(err: Error) => {
|
|
393
|
+
assert.ok(err.message.includes("complete"));
|
|
394
|
+
assert.ok(err.message.includes("expected \"pending\""));
|
|
395
|
+
return true;
|
|
396
|
+
},
|
|
397
|
+
);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it("throws for unknown step ID", () => {
|
|
401
|
+
const graph = makeGraph([makeStep({ id: "a" })]);
|
|
402
|
+
assert.throws(
|
|
403
|
+
() => expandIteration(graph, "nonexistent", ["a"], "{{item}}"),
|
|
404
|
+
(err: Error) => {
|
|
405
|
+
assert.ok(err.message.includes("step not found"));
|
|
406
|
+
assert.ok(err.message.includes("nonexistent"));
|
|
407
|
+
return true;
|
|
408
|
+
},
|
|
409
|
+
);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it("does not mutate the input graph", () => {
|
|
413
|
+
const graph = makeGraph([
|
|
414
|
+
makeStep({ id: "iter", title: "Iterate" }),
|
|
415
|
+
makeStep({ id: "after", dependsOn: ["iter"] }),
|
|
416
|
+
]);
|
|
417
|
+
|
|
418
|
+
const originalStepsLength = graph.steps.length;
|
|
419
|
+
const originalAfterDeps = [...graph.steps[1].dependsOn];
|
|
420
|
+
|
|
421
|
+
expandIteration(graph, "iter", ["a", "b"], "{{item}}");
|
|
422
|
+
|
|
423
|
+
// Original unchanged
|
|
424
|
+
assert.equal(graph.steps.length, originalStepsLength);
|
|
425
|
+
assert.equal(graph.steps[0].status, "pending");
|
|
426
|
+
assert.deepStrictEqual(graph.steps[1].dependsOn, originalAfterDeps);
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
// ─── initializeGraph ─────────────────────────────────────────────────────
|
|
431
|
+
|
|
432
|
+
describe("initializeGraph", () => {
|
|
433
|
+
it("converts a valid 3-step definition to graph with all pending steps", () => {
|
|
434
|
+
const def: WorkflowDefinition = {
|
|
435
|
+
version: 1,
|
|
436
|
+
name: "test-workflow",
|
|
437
|
+
steps: [
|
|
438
|
+
{ id: "s1", name: "Step One", prompt: "Do step one", requires: [], produces: ["out.md"] },
|
|
439
|
+
{ id: "s2", name: "Step Two", prompt: "Do step two", requires: ["s1"], produces: [] },
|
|
440
|
+
{ id: "s3", name: "Step Three", prompt: "Do step three", requires: ["s1", "s2"], produces: [] },
|
|
441
|
+
],
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
const graph = initializeGraph(def);
|
|
445
|
+
|
|
446
|
+
assert.equal(graph.steps.length, 3);
|
|
447
|
+
assert.equal(graph.metadata.name, "test-workflow");
|
|
448
|
+
assert.ok(graph.metadata.createdAt); // ISO string
|
|
449
|
+
|
|
450
|
+
// All pending
|
|
451
|
+
for (const step of graph.steps) {
|
|
452
|
+
assert.equal(step.status, "pending");
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Correct mapping
|
|
456
|
+
assert.equal(graph.steps[0].id, "s1");
|
|
457
|
+
assert.equal(graph.steps[0].title, "Step One");
|
|
458
|
+
assert.equal(graph.steps[0].prompt, "Do step one");
|
|
459
|
+
assert.deepStrictEqual(graph.steps[0].dependsOn, []);
|
|
460
|
+
|
|
461
|
+
assert.equal(graph.steps[1].id, "s2");
|
|
462
|
+
assert.deepStrictEqual(graph.steps[1].dependsOn, ["s1"]);
|
|
463
|
+
|
|
464
|
+
assert.equal(graph.steps[2].id, "s3");
|
|
465
|
+
assert.deepStrictEqual(graph.steps[2].dependsOn, ["s1", "s2"]);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it("is also exported as graphFromDefinition (backward compat)", () => {
|
|
469
|
+
assert.equal(graphFromDefinition, initializeGraph);
|
|
470
|
+
});
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
// ─── Atomic write safety ─────────────────────────────────────────────────
|
|
474
|
+
|
|
475
|
+
describe("atomic write safety", () => {
|
|
476
|
+
it("final file exists and .tmp file does not exist after write", () => {
|
|
477
|
+
const dir = makeTmpDir();
|
|
478
|
+
try {
|
|
479
|
+
const graph = makeGraph([makeStep({ id: "s1" })]);
|
|
480
|
+
writeGraph(dir, graph);
|
|
481
|
+
|
|
482
|
+
assert.ok(existsSync(join(dir, "GRAPH.yaml")));
|
|
483
|
+
assert.ok(!existsSync(join(dir, "GRAPH.yaml.tmp")));
|
|
484
|
+
} finally {
|
|
485
|
+
cleanupDir(dir);
|
|
486
|
+
}
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
it("YAML content is valid and parseable", () => {
|
|
490
|
+
const dir = makeTmpDir();
|
|
491
|
+
try {
|
|
492
|
+
const graph = makeGraph([makeStep({ id: "s1" })]);
|
|
493
|
+
writeGraph(dir, graph);
|
|
494
|
+
|
|
495
|
+
const content = readFileSync(join(dir, "GRAPH.yaml"), "utf-8");
|
|
496
|
+
// Should contain snake_case keys
|
|
497
|
+
assert.ok(content.includes("created_at"));
|
|
498
|
+
// Should not contain camelCase keys
|
|
499
|
+
assert.ok(!content.includes("createdAt"));
|
|
500
|
+
assert.ok(!content.includes("dependsOn"));
|
|
501
|
+
} finally {
|
|
502
|
+
cleanupDir(dir);
|
|
503
|
+
}
|
|
504
|
+
});
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
// ─── YAML snake_case / camelCase boundary ────────────────────────────────
|
|
508
|
+
|
|
509
|
+
describe("YAML snake_case / camelCase boundary", () => {
|
|
510
|
+
it("writes snake_case to disk and reads back as camelCase", () => {
|
|
511
|
+
const dir = makeTmpDir();
|
|
512
|
+
try {
|
|
513
|
+
const graph = makeGraph([
|
|
514
|
+
makeStep({
|
|
515
|
+
id: "s1",
|
|
516
|
+
dependsOn: ["s0"],
|
|
517
|
+
parentStepId: "parent",
|
|
518
|
+
startedAt: "2026-01-01T00:00:00Z",
|
|
519
|
+
finishedAt: "2026-01-01T00:01:00Z",
|
|
520
|
+
}),
|
|
521
|
+
]);
|
|
522
|
+
|
|
523
|
+
writeGraph(dir, graph);
|
|
524
|
+
|
|
525
|
+
// Verify raw YAML uses snake_case
|
|
526
|
+
const raw = readFileSync(join(dir, "GRAPH.yaml"), "utf-8");
|
|
527
|
+
assert.ok(raw.includes("depends_on"));
|
|
528
|
+
assert.ok(raw.includes("parent_step_id"));
|
|
529
|
+
assert.ok(raw.includes("started_at"));
|
|
530
|
+
assert.ok(raw.includes("finished_at"));
|
|
531
|
+
assert.ok(raw.includes("created_at"));
|
|
532
|
+
|
|
533
|
+
// Verify read returns camelCase
|
|
534
|
+
const loaded = readGraph(dir);
|
|
535
|
+
assert.deepStrictEqual(loaded.steps[0].dependsOn, ["s0"]);
|
|
536
|
+
assert.equal(loaded.steps[0].parentStepId, "parent");
|
|
537
|
+
assert.equal(loaded.steps[0].startedAt, "2026-01-01T00:00:00Z");
|
|
538
|
+
assert.equal(loaded.steps[0].finishedAt, "2026-01-01T00:01:00Z");
|
|
539
|
+
} finally {
|
|
540
|
+
cleanupDir(dir);
|
|
541
|
+
}
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
it("omits optional fields from YAML when undefined", () => {
|
|
545
|
+
const dir = makeTmpDir();
|
|
546
|
+
try {
|
|
547
|
+
const graph = makeGraph([
|
|
548
|
+
makeStep({ id: "s1" }),
|
|
549
|
+
]);
|
|
550
|
+
|
|
551
|
+
writeGraph(dir, graph);
|
|
552
|
+
const raw = readFileSync(join(dir, "GRAPH.yaml"), "utf-8");
|
|
553
|
+
|
|
554
|
+
// No depends_on, parent_step_id, started_at, finished_at when undefined/empty
|
|
555
|
+
assert.ok(!raw.includes("depends_on"));
|
|
556
|
+
assert.ok(!raw.includes("parent_step_id"));
|
|
557
|
+
assert.ok(!raw.includes("started_at"));
|
|
558
|
+
assert.ok(!raw.includes("finished_at"));
|
|
559
|
+
} finally {
|
|
560
|
+
cleanupDir(dir);
|
|
561
|
+
}
|
|
562
|
+
});
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
// ─── Edge cases ──────────────────────────────────────────────────────────
|
|
566
|
+
|
|
567
|
+
describe("edge cases", () => {
|
|
568
|
+
it("handles empty items array in expandIteration", () => {
|
|
569
|
+
const graph = makeGraph([
|
|
570
|
+
makeStep({ id: "iter" }),
|
|
571
|
+
]);
|
|
572
|
+
|
|
573
|
+
const expanded = expandIteration(graph, "iter", [], "{{item}}");
|
|
574
|
+
// Parent marked expanded, no instances created
|
|
575
|
+
assert.equal(expanded.steps.length, 1);
|
|
576
|
+
assert.equal(expanded.steps[0].status, "expanded");
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
it("handles graph with single step", () => {
|
|
580
|
+
const graph = makeGraph([makeStep({ id: "only" })]);
|
|
581
|
+
const next = getNextPendingStep(graph);
|
|
582
|
+
assert.equal(next?.id, "only");
|
|
583
|
+
|
|
584
|
+
const completed = markStepComplete(graph, "only");
|
|
585
|
+
assert.equal(getNextPendingStep(completed), null);
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
it("initializeGraph handles steps with empty requires", () => {
|
|
589
|
+
const def: WorkflowDefinition = {
|
|
590
|
+
version: 1,
|
|
591
|
+
name: "empty-requires",
|
|
592
|
+
steps: [
|
|
593
|
+
{ id: "s1", name: "Step", prompt: "Go", requires: [], produces: [] },
|
|
594
|
+
],
|
|
595
|
+
};
|
|
596
|
+
const graph = initializeGraph(def);
|
|
597
|
+
assert.deepStrictEqual(graph.steps[0].dependsOn, []);
|
|
598
|
+
});
|
|
599
|
+
});
|