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.
- package/.gitattributes +24 -0
- package/.github/workflows/ci.yml +70 -0
- package/.markdownlint-cli2.jsonc +16 -0
- package/.prettierignore +3 -0
- package/.prettierrc +6 -0
- package/.vscode/agentic-forge.code-workspace +26 -0
- package/CHANGELOG.md +100 -0
- package/CLAUDE.md +158 -0
- package/CONTRIBUTING.md +152 -0
- package/LICENSE +21 -0
- package/README.md +145 -0
- package/agentic-forge-banner.png +0 -0
- package/biome.json +21 -0
- package/package.json +5 -0
- package/scripts/copy-assets.js +21 -0
- package/src/agents/explorer.md +97 -0
- package/src/agents/reviewer.md +137 -0
- package/src/checkpoints/manager.ts +119 -0
- package/src/claude/.claude/skills/analyze/SKILL.md +241 -0
- package/src/claude/.claude/skills/analyze/references/bug.md +62 -0
- package/src/claude/.claude/skills/analyze/references/debt.md +76 -0
- package/src/claude/.claude/skills/analyze/references/doc.md +67 -0
- package/src/claude/.claude/skills/analyze/references/security.md +76 -0
- package/src/claude/.claude/skills/analyze/references/style.md +72 -0
- package/src/claude/.claude/skills/create-checkpoint/SKILL.md +88 -0
- package/src/claude/.claude/skills/create-log/SKILL.md +75 -0
- package/src/claude/.claude/skills/fix-analyze/SKILL.md +102 -0
- package/src/claude/.claude/skills/git-branch/SKILL.md +71 -0
- package/src/claude/.claude/skills/git-commit/SKILL.md +107 -0
- package/src/claude/.claude/skills/git-pr/SKILL.md +96 -0
- package/src/claude/.claude/skills/orchestrate/SKILL.md +120 -0
- package/src/claude/.claude/skills/sdlc-plan/SKILL.md +163 -0
- package/src/claude/.claude/skills/sdlc-plan/references/bug.md +115 -0
- package/src/claude/.claude/skills/sdlc-plan/references/chore.md +105 -0
- package/src/claude/.claude/skills/sdlc-plan/references/feature.md +130 -0
- package/src/claude/.claude/skills/sdlc-review/SKILL.md +215 -0
- package/src/claude/.claude/skills/workflow-builder/SKILL.md +185 -0
- package/src/claude/.claude/skills/workflow-builder/references/REFERENCE.md +487 -0
- package/src/claude/.claude/skills/workflow-builder/references/workflow-example.yaml +427 -0
- package/src/cli.ts +182 -0
- package/src/commands/config-cmd.ts +28 -0
- package/src/commands/index.ts +21 -0
- package/src/commands/init.ts +96 -0
- package/src/commands/release-notes.ts +85 -0
- package/src/commands/resume.ts +103 -0
- package/src/commands/run.ts +234 -0
- package/src/commands/shortcuts.ts +11 -0
- package/src/commands/skills-dir.ts +11 -0
- package/src/commands/status.ts +112 -0
- package/src/commands/update.ts +64 -0
- package/src/commands/version.ts +27 -0
- package/src/commands/workflows.ts +129 -0
- package/src/config.ts +129 -0
- package/src/console.ts +790 -0
- package/src/executor.ts +354 -0
- package/src/git/worktree.ts +236 -0
- package/src/logging/logger.ts +95 -0
- package/src/orchestrator.ts +815 -0
- package/src/parser.ts +225 -0
- package/src/progress.ts +306 -0
- package/src/prompts/agentic-system.md +31 -0
- package/src/ralph-loop.ts +260 -0
- package/src/renderer.ts +164 -0
- package/src/runner.ts +634 -0
- package/src/signal-manager.ts +55 -0
- package/src/steps/base.ts +71 -0
- package/src/steps/conditional-step.ts +144 -0
- package/src/steps/index.ts +15 -0
- package/src/steps/parallel-step.ts +213 -0
- package/src/steps/prompt-step.ts +121 -0
- package/src/steps/ralph-loop-step.ts +186 -0
- package/src/steps/serial-step.ts +84 -0
- package/src/templates/analysis/bug.md.j2 +35 -0
- package/src/templates/analysis/debt.md.j2 +38 -0
- package/src/templates/analysis/doc.md.j2 +45 -0
- package/src/templates/analysis/security.md.j2 +35 -0
- package/src/templates/analysis/style.md.j2 +44 -0
- package/src/templates/analysis-summary.md.j2 +58 -0
- package/src/templates/checkpoint.md.j2 +27 -0
- package/src/templates/implementation-report.md.j2 +81 -0
- package/src/templates/memory.md.j2 +16 -0
- package/src/templates/plan-bug.md.j2 +42 -0
- package/src/templates/plan-chore.md.j2 +27 -0
- package/src/templates/plan-feature.md.j2 +41 -0
- package/src/templates/progress.json.j2 +16 -0
- package/src/templates/ralph-report.md.j2 +45 -0
- package/src/types.ts +141 -0
- package/src/workflows/analyze-codebase-merge.yaml +328 -0
- package/src/workflows/analyze-codebase.yaml +196 -0
- package/src/workflows/analyze-single.yaml +56 -0
- package/src/workflows/demo.yaml +180 -0
- package/src/workflows/one-shot.yaml +54 -0
- package/src/workflows/plan-build-review.yaml +160 -0
- package/src/workflows/ralph-loop.yaml +73 -0
- package/tests/config.test.ts +219 -0
- package/tests/console.test.ts +506 -0
- package/tests/executor.test.ts +339 -0
- package/tests/init.test.ts +86 -0
- package/tests/logger.test.ts +110 -0
- package/tests/parser.test.ts +290 -0
- package/tests/progress.test.ts +345 -0
- package/tests/ralph-loop.test.ts +418 -0
- package/tests/renderer.test.ts +350 -0
- package/tests/runner.test.ts +497 -0
- package/tests/setup.test.ts +7 -0
- package/tests/signal-manager.test.ts +26 -0
- package/tests/steps.test.ts +412 -0
- package/tests/worktree.test.ts +411 -0
- package/tsconfig.json +18 -0
- 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
|
+
});
|