agentic-forge 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/.gitattributes +24 -0
  2. package/.github/workflows/ci.yml +70 -0
  3. package/.markdownlint-cli2.jsonc +16 -0
  4. package/.prettierignore +3 -0
  5. package/.prettierrc +6 -0
  6. package/.vscode/agentic-forge.code-workspace +26 -0
  7. package/CHANGELOG.md +100 -0
  8. package/CLAUDE.md +158 -0
  9. package/CONTRIBUTING.md +152 -0
  10. package/LICENSE +21 -0
  11. package/README.md +145 -0
  12. package/agentic-forge-banner.png +0 -0
  13. package/biome.json +21 -0
  14. package/package.json +5 -0
  15. package/scripts/copy-assets.js +21 -0
  16. package/src/agents/explorer.md +97 -0
  17. package/src/agents/reviewer.md +137 -0
  18. package/src/checkpoints/manager.ts +119 -0
  19. package/src/claude/.claude/skills/analyze/SKILL.md +241 -0
  20. package/src/claude/.claude/skills/analyze/references/bug.md +62 -0
  21. package/src/claude/.claude/skills/analyze/references/debt.md +76 -0
  22. package/src/claude/.claude/skills/analyze/references/doc.md +67 -0
  23. package/src/claude/.claude/skills/analyze/references/security.md +76 -0
  24. package/src/claude/.claude/skills/analyze/references/style.md +72 -0
  25. package/src/claude/.claude/skills/create-checkpoint/SKILL.md +88 -0
  26. package/src/claude/.claude/skills/create-log/SKILL.md +75 -0
  27. package/src/claude/.claude/skills/fix-analyze/SKILL.md +102 -0
  28. package/src/claude/.claude/skills/git-branch/SKILL.md +71 -0
  29. package/src/claude/.claude/skills/git-commit/SKILL.md +107 -0
  30. package/src/claude/.claude/skills/git-pr/SKILL.md +96 -0
  31. package/src/claude/.claude/skills/orchestrate/SKILL.md +120 -0
  32. package/src/claude/.claude/skills/sdlc-plan/SKILL.md +163 -0
  33. package/src/claude/.claude/skills/sdlc-plan/references/bug.md +115 -0
  34. package/src/claude/.claude/skills/sdlc-plan/references/chore.md +105 -0
  35. package/src/claude/.claude/skills/sdlc-plan/references/feature.md +130 -0
  36. package/src/claude/.claude/skills/sdlc-review/SKILL.md +215 -0
  37. package/src/claude/.claude/skills/workflow-builder/SKILL.md +185 -0
  38. package/src/claude/.claude/skills/workflow-builder/references/REFERENCE.md +487 -0
  39. package/src/claude/.claude/skills/workflow-builder/references/workflow-example.yaml +427 -0
  40. package/src/cli.ts +182 -0
  41. package/src/commands/config-cmd.ts +28 -0
  42. package/src/commands/index.ts +21 -0
  43. package/src/commands/init.ts +96 -0
  44. package/src/commands/release-notes.ts +85 -0
  45. package/src/commands/resume.ts +103 -0
  46. package/src/commands/run.ts +234 -0
  47. package/src/commands/shortcuts.ts +11 -0
  48. package/src/commands/skills-dir.ts +11 -0
  49. package/src/commands/status.ts +112 -0
  50. package/src/commands/update.ts +64 -0
  51. package/src/commands/version.ts +27 -0
  52. package/src/commands/workflows.ts +129 -0
  53. package/src/config.ts +129 -0
  54. package/src/console.ts +790 -0
  55. package/src/executor.ts +354 -0
  56. package/src/git/worktree.ts +236 -0
  57. package/src/logging/logger.ts +95 -0
  58. package/src/orchestrator.ts +815 -0
  59. package/src/parser.ts +225 -0
  60. package/src/progress.ts +306 -0
  61. package/src/prompts/agentic-system.md +31 -0
  62. package/src/ralph-loop.ts +260 -0
  63. package/src/renderer.ts +164 -0
  64. package/src/runner.ts +634 -0
  65. package/src/signal-manager.ts +55 -0
  66. package/src/steps/base.ts +71 -0
  67. package/src/steps/conditional-step.ts +144 -0
  68. package/src/steps/index.ts +15 -0
  69. package/src/steps/parallel-step.ts +213 -0
  70. package/src/steps/prompt-step.ts +121 -0
  71. package/src/steps/ralph-loop-step.ts +186 -0
  72. package/src/steps/serial-step.ts +84 -0
  73. package/src/templates/analysis/bug.md.j2 +35 -0
  74. package/src/templates/analysis/debt.md.j2 +38 -0
  75. package/src/templates/analysis/doc.md.j2 +45 -0
  76. package/src/templates/analysis/security.md.j2 +35 -0
  77. package/src/templates/analysis/style.md.j2 +44 -0
  78. package/src/templates/analysis-summary.md.j2 +58 -0
  79. package/src/templates/checkpoint.md.j2 +27 -0
  80. package/src/templates/implementation-report.md.j2 +81 -0
  81. package/src/templates/memory.md.j2 +16 -0
  82. package/src/templates/plan-bug.md.j2 +42 -0
  83. package/src/templates/plan-chore.md.j2 +27 -0
  84. package/src/templates/plan-feature.md.j2 +41 -0
  85. package/src/templates/progress.json.j2 +16 -0
  86. package/src/templates/ralph-report.md.j2 +45 -0
  87. package/src/types.ts +141 -0
  88. package/src/workflows/analyze-codebase-merge.yaml +328 -0
  89. package/src/workflows/analyze-codebase.yaml +196 -0
  90. package/src/workflows/analyze-single.yaml +56 -0
  91. package/src/workflows/demo.yaml +180 -0
  92. package/src/workflows/one-shot.yaml +54 -0
  93. package/src/workflows/plan-build-review.yaml +160 -0
  94. package/src/workflows/ralph-loop.yaml +73 -0
  95. package/tests/config.test.ts +219 -0
  96. package/tests/console.test.ts +506 -0
  97. package/tests/executor.test.ts +339 -0
  98. package/tests/init.test.ts +86 -0
  99. package/tests/logger.test.ts +110 -0
  100. package/tests/parser.test.ts +290 -0
  101. package/tests/progress.test.ts +345 -0
  102. package/tests/ralph-loop.test.ts +418 -0
  103. package/tests/renderer.test.ts +350 -0
  104. package/tests/runner.test.ts +497 -0
  105. package/tests/setup.test.ts +7 -0
  106. package/tests/signal-manager.test.ts +26 -0
  107. package/tests/steps.test.ts +412 -0
  108. package/tests/worktree.test.ts +411 -0
  109. package/tsconfig.json +18 -0
  110. package/vitest.config.ts +8 -0
@@ -0,0 +1,290 @@
1
+ import { mkdirSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import path from "node:path";
4
+ import { beforeEach, describe, expect, it } from "vitest";
5
+ import { WorkflowParseError, WorkflowParser } from "../src/parser.js";
6
+
7
+ // --- Fixtures ---
8
+
9
+ const sampleWorkflowYaml = `
10
+ name: test-workflow
11
+ version: "1.0"
12
+ description: A test workflow
13
+ steps:
14
+ - name: test-step
15
+ type: prompt
16
+ prompt: "Test prompt"
17
+ `;
18
+
19
+ const sampleParallelWorkflowYaml = `
20
+ name: parallel-workflow
21
+ version: "1.0"
22
+ description: A workflow with parallel steps
23
+ steps:
24
+ - name: parallel-step
25
+ type: parallel
26
+ steps:
27
+ - name: branch-a
28
+ type: serial
29
+ steps:
30
+ - name: task-a1
31
+ type: prompt
32
+ prompt: "Task A1"
33
+ - name: branch-b
34
+ type: serial
35
+ steps:
36
+ - name: task-b1
37
+ type: prompt
38
+ prompt: "Task B1"
39
+ `;
40
+
41
+ const sampleConditionalWorkflowYaml = `
42
+ name: conditional-workflow
43
+ version: "1.0"
44
+ description: A workflow with conditional steps
45
+ variables:
46
+ - name: run_tests
47
+ type: boolean
48
+ default: true
49
+ steps:
50
+ - name: check-tests
51
+ type: conditional
52
+ condition: "variables.run_tests"
53
+ then:
54
+ - name: run-tests
55
+ type: prompt
56
+ prompt: "Run tests"
57
+ else:
58
+ - name: skip-tests
59
+ type: prompt
60
+ prompt: "Tests skipped"
61
+ `;
62
+
63
+ function makeTempDir(): string {
64
+ const dir = path.join(
65
+ tmpdir(),
66
+ `agentic-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
67
+ );
68
+ mkdirSync(dir, { recursive: true });
69
+ return dir;
70
+ }
71
+
72
+ describe("WorkflowParser", () => {
73
+ let tempDir: string;
74
+
75
+ beforeEach(() => {
76
+ tempDir = makeTempDir();
77
+ });
78
+
79
+ it("should parse a minimal workflow from file", () => {
80
+ const filePath = path.join(tempDir, "workflow.yaml");
81
+ writeFileSync(filePath, sampleWorkflowYaml);
82
+
83
+ const parser = new WorkflowParser();
84
+ const workflow = parser.parseFile(filePath);
85
+
86
+ expect(workflow.name).toBe("test-workflow");
87
+ expect(workflow.version).toBe("1.0");
88
+ expect(workflow.description).toBe("A test workflow");
89
+ expect(workflow.steps).toHaveLength(1);
90
+ expect(workflow.steps[0].name).toBe("test-step");
91
+ expect(workflow.steps[0].type).toBe("prompt");
92
+ expect(workflow.steps[0].prompt).toBe("Test prompt");
93
+ });
94
+
95
+ it("should parse workflow from string", () => {
96
+ const parser = new WorkflowParser();
97
+ const workflow = parser.parseString(sampleWorkflowYaml);
98
+
99
+ expect(workflow.name).toBe("test-workflow");
100
+ expect(workflow.steps).toHaveLength(1);
101
+ });
102
+
103
+ it("should throw on missing name field", () => {
104
+ const filePath = path.join(tempDir, "workflow.yaml");
105
+ writeFileSync(filePath, "steps: []");
106
+
107
+ const parser = new WorkflowParser();
108
+ expect(() => parser.parseFile(filePath)).toThrow(WorkflowParseError);
109
+ expect(() => parser.parseFile(filePath)).toThrow("Missing required field: name");
110
+ });
111
+
112
+ it("should throw on invalid YAML syntax", () => {
113
+ const filePath = path.join(tempDir, "workflow.yaml");
114
+ writeFileSync(filePath, "name: [invalid yaml");
115
+
116
+ const parser = new WorkflowParser();
117
+ expect(() => parser.parseFile(filePath)).toThrow(WorkflowParseError);
118
+ expect(() => parser.parseFile(filePath)).toThrow("Invalid YAML");
119
+ });
120
+
121
+ it("should throw on nonexistent file", () => {
122
+ const filePath = path.join(tempDir, "nonexistent.yaml");
123
+
124
+ const parser = new WorkflowParser();
125
+ expect(() => parser.parseFile(filePath)).toThrow(WorkflowParseError);
126
+ expect(() => parser.parseFile(filePath)).toThrow("Workflow file not found");
127
+ });
128
+
129
+ it("should throw on invalid step type", () => {
130
+ const filePath = path.join(tempDir, "workflow.yaml");
131
+ writeFileSync(
132
+ filePath,
133
+ `
134
+ name: test
135
+ steps:
136
+ - name: bad-step
137
+ type: invalid-type
138
+ `,
139
+ );
140
+
141
+ const parser = new WorkflowParser();
142
+ expect(() => parser.parseFile(filePath)).toThrow(WorkflowParseError);
143
+ expect(() => parser.parseFile(filePath)).toThrow("Invalid step type");
144
+ });
145
+
146
+ it("should parse workflow with parallel steps", () => {
147
+ const parser = new WorkflowParser();
148
+ const workflow = parser.parseString(sampleParallelWorkflowYaml);
149
+
150
+ expect(workflow.name).toBe("parallel-workflow");
151
+ expect(workflow.steps).toHaveLength(1);
152
+
153
+ const parallelStep = workflow.steps[0];
154
+ expect(parallelStep.type).toBe("parallel");
155
+ expect(parallelStep.steps).toHaveLength(2);
156
+ expect(parallelStep.steps[0].name).toBe("branch-a");
157
+ expect(parallelStep.steps[0].type).toBe("serial");
158
+ });
159
+
160
+ it("should parse workflow with conditional steps", () => {
161
+ const parser = new WorkflowParser();
162
+ const workflow = parser.parseString(sampleConditionalWorkflowYaml);
163
+
164
+ expect(workflow.name).toBe("conditional-workflow");
165
+ expect(workflow.variables).toHaveLength(1);
166
+ expect(workflow.variables[0].name).toBe("run_tests");
167
+ expect(workflow.variables[0].type).toBe("boolean");
168
+ expect(workflow.variables[0].default).toBe(true);
169
+
170
+ const conditionalStep = workflow.steps[0];
171
+ expect(conditionalStep.type).toBe("conditional");
172
+ expect(conditionalStep.condition).toBe("variables.run_tests");
173
+ expect(conditionalStep.thenSteps).toHaveLength(1);
174
+ expect(conditionalStep.elseSteps).toHaveLength(1);
175
+ });
176
+
177
+ it("should throw on non-dict workflow", () => {
178
+ const parser = new WorkflowParser();
179
+ expect(() => parser.parseString("- item1\n- item2")).toThrow("must be a dictionary");
180
+ });
181
+
182
+ it("should throw on nested parallel steps", () => {
183
+ const filePath = path.join(tempDir, "workflow.yaml");
184
+ writeFileSync(
185
+ filePath,
186
+ `
187
+ name: nested-parallel
188
+ steps:
189
+ - name: outer-parallel
190
+ type: parallel
191
+ steps:
192
+ - name: inner-parallel
193
+ type: parallel
194
+ steps: []
195
+ `,
196
+ );
197
+
198
+ const parser = new WorkflowParser();
199
+ expect(() => parser.parseFile(filePath)).toThrow("Nested parallel steps");
200
+ });
201
+ });
202
+
203
+ describe("WorkflowParser settings", () => {
204
+ it("should apply default settings", () => {
205
+ const parser = new WorkflowParser();
206
+ const workflow = parser.parseString("name: test\nsteps: []");
207
+
208
+ expect(workflow.settings.maxRetry).toBe(3);
209
+ expect(workflow.settings.timeoutMinutes).toBe(60);
210
+ expect(workflow.settings.trackProgress).toBe(true);
211
+ expect(workflow.settings.git.enabled).toBe(false);
212
+ });
213
+
214
+ it("should parse custom settings", () => {
215
+ const parser = new WorkflowParser();
216
+ const workflow = parser.parseString(`
217
+ name: test
218
+ settings:
219
+ max-retry: 5
220
+ timeout-minutes: 120
221
+ track-progress: false
222
+ git:
223
+ enabled: true
224
+ worktree: true
225
+ branch-prefix: custom
226
+ steps: []
227
+ `);
228
+
229
+ expect(workflow.settings.maxRetry).toBe(5);
230
+ expect(workflow.settings.timeoutMinutes).toBe(120);
231
+ expect(workflow.settings.trackProgress).toBe(false);
232
+ expect(workflow.settings.git.enabled).toBe(true);
233
+ expect(workflow.settings.git.worktree).toBe(true);
234
+ expect(workflow.settings.git.branchPrefix).toBe("custom");
235
+ });
236
+
237
+ it("should parse workflow-level model setting", () => {
238
+ const parser = new WorkflowParser();
239
+ const workflow = parser.parseString(`
240
+ name: test
241
+ settings:
242
+ model: opus
243
+ steps: []
244
+ `);
245
+
246
+ expect(workflow.settings.model).toBe("opus");
247
+ });
248
+
249
+ it("should default model to null when not specified", () => {
250
+ const parser = new WorkflowParser();
251
+ const workflow = parser.parseString("name: test\nsteps: []");
252
+
253
+ expect(workflow.settings.model).toBeNull();
254
+ });
255
+ });
256
+
257
+ describe("WorkflowParser outputs", () => {
258
+ it("should parse workflow outputs", () => {
259
+ const parser = new WorkflowParser();
260
+ const workflow = parser.parseString(`
261
+ name: test
262
+ steps: []
263
+ outputs:
264
+ - name: summary
265
+ template: summary.md.j2
266
+ path: output/summary.md
267
+ when: completed
268
+ `);
269
+
270
+ expect(workflow.outputs).toHaveLength(1);
271
+ const output = workflow.outputs[0];
272
+ expect(output.name).toBe("summary");
273
+ expect(output.template).toBe("summary.md.j2");
274
+ expect(output.path).toBe("output/summary.md");
275
+ expect(output.when).toBe("completed");
276
+ });
277
+
278
+ it("should throw on missing required output fields", () => {
279
+ const parser = new WorkflowParser();
280
+ expect(() =>
281
+ parser.parseString(`
282
+ name: test
283
+ steps: []
284
+ outputs:
285
+ - name: summary
286
+ template: summary.md.j2
287
+ `),
288
+ ).toThrow("Missing required field");
289
+ });
290
+ });
@@ -0,0 +1,345 @@
1
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import path from "node:path";
4
+ import { describe, expect, it } from "vitest";
5
+ import {
6
+ STEP_STATUS,
7
+ WORKFLOW_STATUS,
8
+ createProgress,
9
+ dictToProgress,
10
+ generateWorkflowId,
11
+ getProgressPath,
12
+ loadProgress,
13
+ prepareForResume,
14
+ progressToDict,
15
+ saveProgress,
16
+ updateStepCompleted,
17
+ updateStepFailed,
18
+ updateStepSkipped,
19
+ updateStepStarted,
20
+ } from "../src/progress.js";
21
+ import type { StepProgress, WorkflowProgress } from "../src/types.js";
22
+
23
+ function makeTempDir(): string {
24
+ const dir = path.join(
25
+ tmpdir(),
26
+ `agentic-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
27
+ );
28
+ mkdirSync(dir, { recursive: true });
29
+ return dir;
30
+ }
31
+
32
+ describe("WorkflowStatus constants", () => {
33
+ it("should have all expected values", () => {
34
+ expect(WORKFLOW_STATUS.PENDING).toBe("pending");
35
+ expect(WORKFLOW_STATUS.RUNNING).toBe("running");
36
+ expect(WORKFLOW_STATUS.COMPLETED).toBe("completed");
37
+ expect(WORKFLOW_STATUS.FAILED).toBe("failed");
38
+ expect(WORKFLOW_STATUS.PAUSED).toBe("paused");
39
+ expect(WORKFLOW_STATUS.CANCELED).toBe("canceled");
40
+ });
41
+ });
42
+
43
+ describe("StepStatus constants", () => {
44
+ it("should have all expected values", () => {
45
+ expect(STEP_STATUS.PENDING).toBe("pending");
46
+ expect(STEP_STATUS.RUNNING).toBe("running");
47
+ expect(STEP_STATUS.COMPLETED).toBe("completed");
48
+ expect(STEP_STATUS.FAILED).toBe("failed");
49
+ expect(STEP_STATUS.SKIPPED).toBe("skipped");
50
+ });
51
+ });
52
+
53
+ describe("generateWorkflowId", () => {
54
+ it("should follow expected format", () => {
55
+ const id = generateWorkflowId("test-workflow");
56
+ const parts = id.split("-");
57
+ expect(parts.length).toBeGreaterThanOrEqual(4);
58
+ expect(parts[0]).toHaveLength(8); // Date
59
+ expect(parts[1]).toHaveLength(6); // Time
60
+ expect(id).toContain("test");
61
+ expect(id).toContain("workflow");
62
+ });
63
+
64
+ it("should sanitize spaces", () => {
65
+ const id = generateWorkflowId("my test workflow");
66
+ expect(id).not.toContain(" ");
67
+ expect(id).toContain("my-test-workflow");
68
+ });
69
+
70
+ it("should sanitize underscores", () => {
71
+ const id = generateWorkflowId("my_test_workflow");
72
+ expect(id).not.toContain("_");
73
+ expect(id).toContain("my-test-workflow");
74
+ });
75
+
76
+ it("should remove special characters", () => {
77
+ const id = generateWorkflowId("test@workflow#name!");
78
+ expect(id).not.toContain("@");
79
+ expect(id).not.toContain("#");
80
+ expect(id).not.toContain("!");
81
+ });
82
+
83
+ it("should produce valid IDs ending with workflow name", () => {
84
+ const id1 = generateWorkflowId("test");
85
+ const id2 = generateWorkflowId("test");
86
+ expect(id1).toMatch(/-test$/);
87
+ expect(id2).toMatch(/-test$/);
88
+ });
89
+ });
90
+
91
+ describe("getProgressPath", () => {
92
+ it("should return correct path", () => {
93
+ const tempDir = makeTempDir();
94
+ const p = getProgressPath("20260111-143052-test-workflow", tempDir);
95
+ const expected = path.join(
96
+ tempDir,
97
+ "agentic",
98
+ "outputs",
99
+ "20260111-143052-test-workflow",
100
+ "progress.json",
101
+ );
102
+ expect(p).toBe(expected);
103
+ });
104
+
105
+ it("should use cwd when no root specified", () => {
106
+ const p = getProgressPath("test-workflow");
107
+ expect(p).toContain("test-workflow");
108
+ expect(p).toMatch(/progress\.json$/);
109
+ });
110
+ });
111
+
112
+ describe("createProgress", () => {
113
+ it("should create basic progress document", () => {
114
+ const progress = createProgress("test-id", "test-workflow", ["step1", "step2", "step3"], {
115
+ var1: "value1",
116
+ });
117
+
118
+ expect(progress.workflowId).toBe("test-id");
119
+ expect(progress.workflowName).toBe("test-workflow");
120
+ expect(progress.status).toBe(WORKFLOW_STATUS.RUNNING);
121
+ expect(progress.pendingSteps).toEqual(["step1", "step2", "step3"]);
122
+ expect(progress.variables).toEqual({ var1: "value1" });
123
+ expect(progress.startedAt).toBeTruthy();
124
+ expect(progress.completedSteps).toEqual([]);
125
+ expect(progress.runningSteps).toEqual([]);
126
+ });
127
+
128
+ it("should make copies of lists and objects", () => {
129
+ const stepNames = ["step1", "step2"];
130
+ const variables = { key: "value" };
131
+
132
+ const progress = createProgress("id", "name", stepNames, variables);
133
+
134
+ stepNames.push("step3");
135
+ (variables as Record<string, string>).newKey = "new_value";
136
+
137
+ expect(progress.pendingSteps).toEqual(["step1", "step2"]);
138
+ expect(progress.variables).toEqual({ key: "value" });
139
+ });
140
+ });
141
+
142
+ describe("saveProgress and loadProgress", () => {
143
+ it("should round-trip save and load", () => {
144
+ const tempDir = makeTempDir();
145
+ const progress = createProgress("test-save-load", "test-workflow", ["step1"], { var: "value" });
146
+
147
+ saveProgress(progress, tempDir);
148
+ const loaded = loadProgress("test-save-load", tempDir);
149
+
150
+ expect(loaded).not.toBeNull();
151
+ expect(loaded?.workflowId).toBe(progress.workflowId);
152
+ expect(loaded?.workflowName).toBe(progress.workflowName);
153
+ expect(loaded?.status).toBe(progress.status);
154
+ expect(loaded?.variables).toEqual(progress.variables);
155
+ });
156
+
157
+ it("should return null for nonexistent progress", () => {
158
+ const tempDir = makeTempDir();
159
+ const loaded = loadProgress("nonexistent-workflow", tempDir);
160
+ expect(loaded).toBeNull();
161
+ });
162
+
163
+ it("should create directories when saving", () => {
164
+ const tempDir = makeTempDir();
165
+ const progress = createProgress("new-workflow", "test", [], {});
166
+ saveProgress(progress, tempDir);
167
+
168
+ const expectedPath = path.join(tempDir, "agentic", "outputs", "new-workflow", "progress.json");
169
+ expect(existsSync(expectedPath)).toBe(true);
170
+ });
171
+
172
+ it("should write valid JSON", () => {
173
+ const tempDir = makeTempDir();
174
+ const progress = createProgress("json-test", "test", ["step1"], {
175
+ var: "value",
176
+ });
177
+ saveProgress(progress, tempDir);
178
+
179
+ const progressPath = getProgressPath("json-test", tempDir);
180
+ const data = JSON.parse(readFileSync(progressPath, "utf-8"));
181
+
182
+ expect(data.workflow_id).toBe("json-test");
183
+ expect(Array.isArray(data.pending_steps)).toBe(true);
184
+ });
185
+ });
186
+
187
+ describe("Step updates", () => {
188
+ it("should mark a step as started", () => {
189
+ const progress = createProgress("test", "workflow", ["step1", "step2"], {});
190
+ updateStepStarted(progress, "step1");
191
+
192
+ expect(progress.pendingSteps).not.toContain("step1");
193
+ expect(progress.runningSteps).toContain("step1");
194
+ expect(progress.currentStep).not.toBeNull();
195
+ expect(progress.currentStep?.name).toBe("step1");
196
+ });
197
+
198
+ it("should mark a step as completed", () => {
199
+ const progress = createProgress("test", "workflow", ["step1"], {});
200
+ updateStepStarted(progress, "step1");
201
+ updateStepCompleted(progress, "step1", "Done", { result: "success" });
202
+
203
+ expect(progress.runningSteps).not.toContain("step1");
204
+ expect(progress.completedSteps).toHaveLength(1);
205
+ expect(progress.completedSteps[0].name).toBe("step1");
206
+ expect(progress.completedSteps[0].status).toBe(STEP_STATUS.COMPLETED);
207
+ expect(progress.completedSteps[0].outputSummary).toBe("Done");
208
+ expect(progress.stepOutputs.step1).toEqual({ result: "success" });
209
+ expect(progress.currentStep).toBeNull();
210
+ });
211
+
212
+ it("should mark a step as failed", () => {
213
+ const progress = createProgress("test", "workflow", ["step1"], {});
214
+ updateStepStarted(progress, "step1");
215
+ updateStepFailed(progress, "step1", "Something went wrong");
216
+
217
+ expect(progress.runningSteps).not.toContain("step1");
218
+ expect(progress.completedSteps).toHaveLength(1);
219
+ expect(progress.completedSteps[0].status).toBe(STEP_STATUS.FAILED);
220
+ expect(progress.completedSteps[0].error).toBe("Something went wrong");
221
+ expect(progress.errors).toHaveLength(1);
222
+ expect(progress.errors[0].step).toBe("step1");
223
+ });
224
+
225
+ it("should mark a step as skipped", () => {
226
+ const progress = createProgress("test", "workflow", ["step1", "step2"], {});
227
+ updateStepSkipped(progress, "step2");
228
+
229
+ expect(progress.pendingSteps).not.toContain("step2");
230
+ expect(progress.completedSteps).toHaveLength(1);
231
+ expect(progress.completedSteps[0].status).toBe(STEP_STATUS.SKIPPED);
232
+ });
233
+
234
+ it("should preserve retry count", () => {
235
+ const progress = createProgress("test", "workflow", ["step1"], {});
236
+ updateStepStarted(progress, "step1");
237
+ (progress.currentStep as Record<string, unknown>).retry_count = 3;
238
+
239
+ updateStepCompleted(progress, "step1");
240
+
241
+ expect(progress.completedSteps[0].retryCount).toBe(3);
242
+ });
243
+ });
244
+
245
+ describe("Progress conversion", () => {
246
+ it("should round-trip through dict", () => {
247
+ const progress: WorkflowProgress = {
248
+ schemaVersion: "1.0",
249
+ workflowId: "test-id",
250
+ workflowName: "test-workflow",
251
+ status: "running",
252
+ startedAt: "2026-01-11T14:30:00Z",
253
+ completedAt: null,
254
+ currentStep: null,
255
+ completedSteps: [
256
+ {
257
+ name: "step1",
258
+ status: "completed",
259
+ startedAt: "2026-01-11T14:30:00Z",
260
+ completedAt: "2026-01-11T14:31:00Z",
261
+ retryCount: 0,
262
+ outputSummary: "",
263
+ error: null,
264
+ humanInput: null,
265
+ },
266
+ ],
267
+ pendingSteps: ["step2"],
268
+ runningSteps: [],
269
+ parallelBranches: [],
270
+ errors: [],
271
+ variables: { var: "value" },
272
+ stepOutputs: { step1: { result: "ok" } },
273
+ workflowFile: "",
274
+ };
275
+
276
+ const data = progressToDict(progress);
277
+ const restored = dictToProgress(data);
278
+
279
+ expect(restored.workflowId).toBe(progress.workflowId);
280
+ expect(restored.workflowName).toBe(progress.workflowName);
281
+ expect(restored.status).toBe(progress.status);
282
+ expect(restored.completedSteps).toHaveLength(1);
283
+ expect(restored.completedSteps[0].name).toBe("step1");
284
+ expect(restored.stepOutputs).toEqual(progress.stepOutputs);
285
+ });
286
+
287
+ it("should handle minimal dict", () => {
288
+ const data = {
289
+ workflow_id: "minimal",
290
+ workflow_name: "test",
291
+ };
292
+ const progress = dictToProgress(data);
293
+
294
+ expect(progress.workflowId).toBe("minimal");
295
+ expect(progress.workflowName).toBe("test");
296
+ expect(progress.status).toBe("pending");
297
+ expect(progress.completedSteps).toEqual([]);
298
+ expect(progress.pendingSteps).toEqual([]);
299
+ });
300
+ });
301
+
302
+ describe("StepProgress defaults", () => {
303
+ it("should use correct defaults when created via createProgress flow", () => {
304
+ const progress = createProgress("test", "workflow", ["test"], {});
305
+ updateStepStarted(progress, "test");
306
+ updateStepCompleted(progress, "test");
307
+
308
+ const step = progress.completedSteps[0];
309
+ expect(step.name).toBe("test");
310
+ expect(step.status).toBe("completed");
311
+ expect(step.startedAt).toBeTruthy();
312
+ expect(step.completedAt).toBeTruthy();
313
+ expect(step.retryCount).toBe(0);
314
+ expect(step.outputSummary).toBe("");
315
+ expect(step.error).toBeNull();
316
+ expect(step.humanInput).toBeNull();
317
+ });
318
+ });
319
+
320
+ describe("prepareForResume", () => {
321
+ it("should move running steps to pending", () => {
322
+ const progress = createProgress("test", "workflow", ["step1", "step2"], {});
323
+ updateStepStarted(progress, "step1");
324
+
325
+ prepareForResume(progress);
326
+
327
+ expect(progress.runningSteps).toEqual([]);
328
+ expect(progress.pendingSteps).toContain("step1");
329
+ expect(progress.currentStep).toBeNull();
330
+ expect(progress.status).toBe(WORKFLOW_STATUS.RUNNING);
331
+ expect(progress.completedAt).toBeNull();
332
+ });
333
+
334
+ it("should move current step to front of pending", () => {
335
+ const progress = createProgress("test", "workflow", ["step1", "step2"], {});
336
+ updateStepStarted(progress, "step1");
337
+ // Manually clear running (simulate partial state)
338
+ progress.runningSteps = [];
339
+
340
+ prepareForResume(progress);
341
+
342
+ expect(progress.pendingSteps[0]).toBe("step1");
343
+ expect(progress.currentStep).toBeNull();
344
+ });
345
+ });