@multiplier-labs/stepflow 0.3.1 → 0.3.4
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/.codekin/reports/code-review/2026-04-11_code-review-daily.md +146 -0
- package/.codekin/reports/code-review/2026-04-13_code-review-daily.md +74 -0
- package/.codekin/reports/comments/2026-04-04_comment-assessment.md +179 -0
- package/.codekin/reports/comments/2026-04-11_comment-assessment.md +161 -0
- package/.codekin/reports/comments/2026-04-14_comment-assessment.md +34 -0
- package/.codekin/reports/complexity/2026-04-03_complexity-report.md +152 -0
- package/.codekin/reports/complexity/2026-04-11_complexity-report.md +152 -0
- package/.codekin/reports/complexity/2026-04-14_complexity-report.md +34 -0
- package/.codekin/reports/dependencies/2026-04-04_dependency-health.md +94 -0
- package/.codekin/reports/dependencies/2026-04-11_dependency-health.md +93 -0
- package/.codekin/reports/docs-audit/2026-04-04_docs-audit.md +10 -0
- package/.codekin/reports/docs-audit/2026-04-11_docs-audit.md +138 -0
- package/.codekin/reports/docs-audit/2026-04-13_docs-audit.md +121 -0
- package/.codekin/reports/security/2026-04-03_security-audit.md +208 -0
- package/.codekin/reports/security/2026-04-11_security-audit.md +297 -0
- package/.github/CODEOWNERS +15 -0
- package/.github/dependabot.yml +23 -0
- package/.github/workflows/ci.yml +38 -0
- package/.github/workflows/publish.yml +17 -5
- package/.github/workflows/security.yml +29 -0
- package/CHANGELOG.md +34 -0
- package/README.md +24 -0
- package/SECURITY.md +40 -0
- package/dist/{chunk-FQCT3I2A.js → chunk-5XJBAPXF.js} +527 -551
- package/dist/chunk-5XJBAPXF.js.map +1 -0
- package/dist/{chunk-U5QGN3RT.js → chunk-FMPJBK2N.js} +67 -55
- package/dist/chunk-FMPJBK2N.js.map +1 -0
- package/dist/{chunk-GXY66V7Q.js → chunk-JPXPB7U5.js} +52 -2
- package/dist/chunk-JPXPB7U5.js.map +1 -0
- package/dist/{chunk-XCF4PLN7.js → chunk-U3PCVK6O.js} +276 -61
- package/dist/chunk-U3PCVK6O.js.map +1 -0
- package/dist/events/index.d.ts +60 -7
- package/dist/events/index.js +1 -1
- package/dist/{index-CPfGsmrp.d.ts → index-Cd0IZ40L.d.ts} +56 -17
- package/dist/index.d.ts +15 -23
- package/dist/index.js +784 -547
- package/dist/index.js.map +1 -1
- package/dist/scheduler/index.d.ts +4 -4
- package/dist/scheduler/index.js +2 -2
- package/dist/storage/index.d.ts +322 -56
- package/dist/storage/index.js +4 -2
- package/dist/{types-BlwFEGzj.d.ts → types-DSEjiLMj.d.ts} +16 -15
- package/dist/{types-DzkEqnQJ.d.ts → types-DZUdWmCc.d.ts} +2 -2
- package/dist/{types-BVShq7zT.d.ts → types-K5Gjk3H_.d.ts} +4 -4
- package/docs/stepflow-api-reference.md +5 -4
- package/package.json +12 -8
- package/src/core/engine/concurrency.ts +229 -0
- package/src/core/engine/lifecycle.ts +176 -0
- package/src/core/engine.test.ts +536 -522
- package/src/core/engine.ts +116 -235
- package/src/core/index.ts +3 -3
- package/src/core/orchestrator/persistence.ts +165 -0
- package/src/core/orchestrator/plan-resolution.ts +140 -0
- package/src/core/orchestrator/state-transitions.ts +109 -0
- package/src/core/orchestrator/step-runner.ts +427 -0
- package/src/core/orchestrator.ts +57 -395
- package/src/core/types.ts +7 -29
- package/src/events/index.ts +4 -4
- package/src/events/memory.test.ts +39 -41
- package/src/events/memory.ts +7 -10
- package/src/events/socketio.test.ts +112 -162
- package/src/events/socketio.ts +20 -27
- package/src/events/types.ts +16 -19
- package/src/events/webhook.test.ts +489 -235
- package/src/events/webhook.ts +355 -139
- package/src/index.ts +19 -19
- package/src/planning/evaluate-condition.test.ts +153 -0
- package/src/planning/evaluate-condition.ts +105 -0
- package/src/planning/index.ts +6 -3
- package/src/planning/planner.ts +45 -152
- package/src/planning/planning.test.ts +303 -366
- package/src/planning/registry.test.ts +204 -251
- package/src/planning/registry.ts +20 -106
- package/src/planning/types.ts +23 -27
- package/src/scheduler/cron.test.ts +149 -150
- package/src/scheduler/cron.ts +58 -69
- package/src/scheduler/index.ts +4 -14
- package/src/scheduler/postgres-persistence.ts +89 -105
- package/src/scheduler/sqlite-persistence.test.ts +96 -96
- package/src/scheduler/sqlite-persistence.ts +40 -22
- package/src/scheduler/types.ts +4 -9
- package/src/storage/index.ts +5 -5
- package/src/storage/memory.test.ts +133 -183
- package/src/storage/memory.ts +21 -42
- package/src/storage/postgres-core.ts +550 -0
- package/src/storage/postgres-migration.test.ts +69 -0
- package/src/storage/postgres-transaction-pagination.test.ts +107 -0
- package/src/storage/postgres.ts +222 -937
- package/src/storage/sqlite-core.ts +151 -0
- package/src/storage/sqlite.test.ts +330 -281
- package/src/storage/sqlite.ts +225 -170
- package/src/storage/types.ts +33 -58
- package/src/utils/errors.test.ts +78 -80
- package/src/utils/errors.ts +27 -57
- package/src/utils/id.ts +3 -3
- package/src/utils/index.ts +6 -4
- package/src/utils/logger.test.ts +52 -58
- package/src/utils/logger.ts +4 -4
- package/src/utils/pinned-fetch.test.ts +84 -0
- package/src/utils/pinned-fetch.ts +118 -0
- package/src/utils/postgres-deps.ts +20 -4
- package/src/utils/retry.test.ts +47 -67
- package/src/utils/retry.ts +9 -9
- package/src/utils/safe-json.test.ts +166 -0
- package/src/utils/safe-json.ts +107 -0
- package/src/utils/webhook-signature.test.ts +111 -0
- package/src/utils/webhook-signature.ts +74 -0
- package/dist/chunk-FQCT3I2A.js.map +0 -1
- package/dist/chunk-GXY66V7Q.js.map +0 -1
- package/dist/chunk-U5QGN3RT.js.map +0 -1
- package/dist/chunk-XCF4PLN7.js.map +0 -1
- package/src/scheduler/postgres-persistence.test.ts +0 -906
package/src/core/engine.test.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, vi } from
|
|
2
|
-
import { WorkflowEngine } from
|
|
3
|
-
import { MemoryStorageAdapter } from
|
|
4
|
-
import { MemoryEventTransport } from
|
|
5
|
-
import { SilentLogger } from
|
|
6
|
-
import type { WorkflowDefinition, WorkflowEvent } from
|
|
7
|
-
|
|
8
|
-
describe(
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import { WorkflowEngine } from './engine';
|
|
3
|
+
import { MemoryStorageAdapter } from '../storage/memory';
|
|
4
|
+
import { MemoryEventTransport } from '../events/memory';
|
|
5
|
+
import { SilentLogger } from '../utils/logger';
|
|
6
|
+
import type { WorkflowDefinition, WorkflowEvent } from '../index';
|
|
7
|
+
|
|
8
|
+
describe('WorkflowEngine', () => {
|
|
9
9
|
let engine: WorkflowEngine;
|
|
10
10
|
let storage: MemoryStorageAdapter;
|
|
11
11
|
let events: MemoryEventTransport;
|
|
@@ -20,149 +20,147 @@ describe("WorkflowEngine", () => {
|
|
|
20
20
|
});
|
|
21
21
|
});
|
|
22
22
|
|
|
23
|
-
describe(
|
|
24
|
-
it(
|
|
23
|
+
describe('registerWorkflow', () => {
|
|
24
|
+
it('should register a workflow definition', () => {
|
|
25
25
|
const workflow: WorkflowDefinition = {
|
|
26
|
-
kind:
|
|
27
|
-
name:
|
|
26
|
+
kind: 'test.workflow',
|
|
27
|
+
name: 'Test Workflow',
|
|
28
28
|
steps: [],
|
|
29
29
|
};
|
|
30
30
|
|
|
31
31
|
engine.registerWorkflow(workflow);
|
|
32
32
|
|
|
33
|
-
expect(engine.getWorkflow(
|
|
34
|
-
expect(engine.getRegisteredWorkflows()).toContain(
|
|
33
|
+
expect(engine.getWorkflow('test.workflow')).toBeDefined();
|
|
34
|
+
expect(engine.getRegisteredWorkflows()).toContain('test.workflow');
|
|
35
35
|
});
|
|
36
36
|
|
|
37
|
-
it(
|
|
37
|
+
it('should throw if workflow is already registered', () => {
|
|
38
38
|
const workflow: WorkflowDefinition = {
|
|
39
|
-
kind:
|
|
40
|
-
name:
|
|
39
|
+
kind: 'test.workflow',
|
|
40
|
+
name: 'Test Workflow',
|
|
41
41
|
steps: [],
|
|
42
42
|
};
|
|
43
43
|
|
|
44
44
|
engine.registerWorkflow(workflow);
|
|
45
45
|
|
|
46
46
|
expect(() => engine.registerWorkflow(workflow)).toThrow(
|
|
47
|
-
'Workflow "test.workflow" is already registered'
|
|
47
|
+
'Workflow "test.workflow" is already registered'
|
|
48
48
|
);
|
|
49
49
|
});
|
|
50
50
|
});
|
|
51
51
|
|
|
52
|
-
describe(
|
|
53
|
-
it(
|
|
52
|
+
describe('unregisterWorkflow', () => {
|
|
53
|
+
it('should unregister a workflow', () => {
|
|
54
54
|
const workflow: WorkflowDefinition = {
|
|
55
|
-
kind:
|
|
56
|
-
name:
|
|
55
|
+
kind: 'test.workflow',
|
|
56
|
+
name: 'Test Workflow',
|
|
57
57
|
steps: [],
|
|
58
58
|
};
|
|
59
59
|
|
|
60
60
|
engine.registerWorkflow(workflow);
|
|
61
|
-
expect(engine.unregisterWorkflow(
|
|
62
|
-
expect(engine.getWorkflow(
|
|
61
|
+
expect(engine.unregisterWorkflow('test.workflow')).toBe(true);
|
|
62
|
+
expect(engine.getWorkflow('test.workflow')).toBeUndefined();
|
|
63
63
|
});
|
|
64
64
|
|
|
65
|
-
it(
|
|
66
|
-
expect(engine.unregisterWorkflow(
|
|
65
|
+
it('should return false if workflow not found', () => {
|
|
66
|
+
expect(engine.unregisterWorkflow('nonexistent')).toBe(false);
|
|
67
67
|
});
|
|
68
68
|
});
|
|
69
69
|
|
|
70
|
-
describe(
|
|
71
|
-
it(
|
|
72
|
-
await expect(engine.startRun({ kind:
|
|
73
|
-
'Workflow "nonexistent" is not registered'
|
|
70
|
+
describe('startRun', () => {
|
|
71
|
+
it('should throw if workflow is not registered', async () => {
|
|
72
|
+
await expect(engine.startRun({ kind: 'nonexistent' })).rejects.toThrow(
|
|
73
|
+
'Workflow "nonexistent" is not registered'
|
|
74
74
|
);
|
|
75
75
|
});
|
|
76
76
|
|
|
77
|
-
it(
|
|
77
|
+
it('should start a workflow run and return run ID', async () => {
|
|
78
78
|
engine.registerWorkflow({
|
|
79
|
-
kind:
|
|
80
|
-
name:
|
|
79
|
+
kind: 'test.workflow',
|
|
80
|
+
name: 'Test Workflow',
|
|
81
81
|
steps: [
|
|
82
82
|
{
|
|
83
|
-
key:
|
|
84
|
-
name:
|
|
85
|
-
handler: async () =>
|
|
83
|
+
key: 'step1',
|
|
84
|
+
name: 'Step 1',
|
|
85
|
+
handler: async () => 'result1',
|
|
86
86
|
},
|
|
87
87
|
],
|
|
88
88
|
});
|
|
89
89
|
|
|
90
|
-
const runId = await engine.startRun({ kind:
|
|
90
|
+
const runId = await engine.startRun({ kind: 'test.workflow' });
|
|
91
91
|
|
|
92
92
|
expect(runId).toBeDefined();
|
|
93
|
-
expect(typeof runId).toBe(
|
|
93
|
+
expect(typeof runId).toBe('string');
|
|
94
94
|
});
|
|
95
95
|
|
|
96
|
-
it(
|
|
96
|
+
it('should execute steps in order', async () => {
|
|
97
97
|
const executionOrder: string[] = [];
|
|
98
98
|
|
|
99
99
|
engine.registerWorkflow({
|
|
100
|
-
kind:
|
|
101
|
-
name:
|
|
100
|
+
kind: 'test.order',
|
|
101
|
+
name: 'Test Order',
|
|
102
102
|
steps: [
|
|
103
103
|
{
|
|
104
|
-
key:
|
|
105
|
-
name:
|
|
104
|
+
key: 'first',
|
|
105
|
+
name: 'First',
|
|
106
106
|
handler: async () => {
|
|
107
|
-
executionOrder.push(
|
|
108
|
-
return
|
|
107
|
+
executionOrder.push('first');
|
|
108
|
+
return 'first';
|
|
109
109
|
},
|
|
110
110
|
},
|
|
111
111
|
{
|
|
112
|
-
key:
|
|
113
|
-
name:
|
|
112
|
+
key: 'second',
|
|
113
|
+
name: 'Second',
|
|
114
114
|
handler: async () => {
|
|
115
|
-
executionOrder.push(
|
|
116
|
-
return
|
|
115
|
+
executionOrder.push('second');
|
|
116
|
+
return 'second';
|
|
117
117
|
},
|
|
118
118
|
},
|
|
119
119
|
{
|
|
120
|
-
key:
|
|
121
|
-
name:
|
|
120
|
+
key: 'third',
|
|
121
|
+
name: 'Third',
|
|
122
122
|
handler: async () => {
|
|
123
|
-
executionOrder.push(
|
|
124
|
-
return
|
|
123
|
+
executionOrder.push('third');
|
|
124
|
+
return 'third';
|
|
125
125
|
},
|
|
126
126
|
},
|
|
127
127
|
],
|
|
128
128
|
});
|
|
129
129
|
|
|
130
|
-
const runId = await engine.startRun({ kind:
|
|
130
|
+
const runId = await engine.startRun({ kind: 'test.order' });
|
|
131
131
|
const run = await engine.waitForRun(runId);
|
|
132
132
|
|
|
133
|
-
expect(run.status).toBe(
|
|
134
|
-
expect(executionOrder).toEqual([
|
|
133
|
+
expect(run.status).toBe('succeeded');
|
|
134
|
+
expect(executionOrder).toEqual(['first', 'second', 'third']);
|
|
135
135
|
});
|
|
136
136
|
|
|
137
|
-
it(
|
|
137
|
+
it('should pass accumulated results to steps via context', async () => {
|
|
138
138
|
let step2Input: unknown;
|
|
139
139
|
|
|
140
140
|
engine.registerWorkflow({
|
|
141
|
-
kind:
|
|
142
|
-
name:
|
|
141
|
+
kind: 'test.results',
|
|
142
|
+
name: 'Test Results',
|
|
143
143
|
steps: [
|
|
144
144
|
{
|
|
145
|
-
key:
|
|
146
|
-
name:
|
|
145
|
+
key: 'step1',
|
|
146
|
+
name: 'Step 1',
|
|
147
147
|
handler: async () => ({ value: 42 }),
|
|
148
148
|
},
|
|
149
149
|
{
|
|
150
|
-
key:
|
|
151
|
-
name:
|
|
150
|
+
key: 'step2',
|
|
151
|
+
name: 'Step 2',
|
|
152
152
|
handler: async (ctx) => {
|
|
153
153
|
step2Input = ctx.results.step1;
|
|
154
|
-
return {
|
|
155
|
-
doubled: (ctx.results.step1 as { value: number }).value * 2,
|
|
156
|
-
};
|
|
154
|
+
return { doubled: (ctx.results.step1 as { value: number }).value * 2 };
|
|
157
155
|
},
|
|
158
156
|
},
|
|
159
157
|
],
|
|
160
158
|
});
|
|
161
159
|
|
|
162
|
-
const runId = await engine.startRun({ kind:
|
|
160
|
+
const runId = await engine.startRun({ kind: 'test.results' });
|
|
163
161
|
const run = await engine.waitForRun(runId);
|
|
164
162
|
|
|
165
|
-
expect(run.status).toBe(
|
|
163
|
+
expect(run.status).toBe('succeeded');
|
|
166
164
|
expect(step2Input).toEqual({ value: 42 });
|
|
167
165
|
expect(run.context).toEqual({
|
|
168
166
|
step1: { value: 42 },
|
|
@@ -170,238 +168,234 @@ describe("WorkflowEngine", () => {
|
|
|
170
168
|
});
|
|
171
169
|
});
|
|
172
170
|
|
|
173
|
-
it(
|
|
171
|
+
it('should pass input to context', async () => {
|
|
174
172
|
let receivedInput: unknown;
|
|
175
173
|
|
|
176
174
|
engine.registerWorkflow({
|
|
177
|
-
kind:
|
|
178
|
-
name:
|
|
175
|
+
kind: 'test.input',
|
|
176
|
+
name: 'Test Input',
|
|
179
177
|
steps: [
|
|
180
178
|
{
|
|
181
|
-
key:
|
|
182
|
-
name:
|
|
179
|
+
key: 'step1',
|
|
180
|
+
name: 'Step 1',
|
|
183
181
|
handler: async (ctx) => {
|
|
184
182
|
receivedInput = ctx.input;
|
|
185
|
-
return
|
|
183
|
+
return 'done';
|
|
186
184
|
},
|
|
187
185
|
},
|
|
188
186
|
],
|
|
189
187
|
});
|
|
190
188
|
|
|
191
189
|
const runId = await engine.startRun({
|
|
192
|
-
kind:
|
|
193
|
-
input: { foo:
|
|
190
|
+
kind: 'test.input',
|
|
191
|
+
input: { foo: 'bar', num: 123 },
|
|
194
192
|
});
|
|
195
193
|
await engine.waitForRun(runId);
|
|
196
194
|
|
|
197
|
-
expect(receivedInput).toEqual({ foo:
|
|
195
|
+
expect(receivedInput).toEqual({ foo: 'bar', num: 123 });
|
|
198
196
|
});
|
|
199
197
|
|
|
200
|
-
it(
|
|
198
|
+
it('should pass metadata to context', async () => {
|
|
201
199
|
let receivedMetadata: unknown;
|
|
202
200
|
|
|
203
201
|
engine.registerWorkflow({
|
|
204
|
-
kind:
|
|
205
|
-
name:
|
|
202
|
+
kind: 'test.metadata',
|
|
203
|
+
name: 'Test Metadata',
|
|
206
204
|
steps: [
|
|
207
205
|
{
|
|
208
|
-
key:
|
|
209
|
-
name:
|
|
206
|
+
key: 'step1',
|
|
207
|
+
name: 'Step 1',
|
|
210
208
|
handler: async (ctx) => {
|
|
211
209
|
receivedMetadata = ctx.metadata;
|
|
212
|
-
return
|
|
210
|
+
return 'done';
|
|
213
211
|
},
|
|
214
212
|
},
|
|
215
213
|
],
|
|
216
214
|
});
|
|
217
215
|
|
|
218
216
|
const runId = await engine.startRun({
|
|
219
|
-
kind:
|
|
220
|
-
metadata: { userId:
|
|
217
|
+
kind: 'test.metadata',
|
|
218
|
+
metadata: { userId: 'user123', topicId: 'topic456' },
|
|
221
219
|
});
|
|
222
220
|
await engine.waitForRun(runId);
|
|
223
221
|
|
|
224
|
-
expect(receivedMetadata).toEqual({
|
|
225
|
-
userId: "user123",
|
|
226
|
-
topicId: "topic456",
|
|
227
|
-
});
|
|
222
|
+
expect(receivedMetadata).toEqual({ userId: 'user123', topicId: 'topic456' });
|
|
228
223
|
});
|
|
229
224
|
});
|
|
230
225
|
|
|
231
|
-
describe(
|
|
232
|
-
it(
|
|
226
|
+
describe('error handling', () => {
|
|
227
|
+
it('should fail the run when a step throws (default strategy)', async () => {
|
|
233
228
|
engine.registerWorkflow({
|
|
234
|
-
kind:
|
|
235
|
-
name:
|
|
229
|
+
kind: 'test.fail',
|
|
230
|
+
name: 'Test Fail',
|
|
236
231
|
steps: [
|
|
237
232
|
{
|
|
238
|
-
key:
|
|
239
|
-
name:
|
|
233
|
+
key: 'step1',
|
|
234
|
+
name: 'Step 1',
|
|
240
235
|
handler: async () => {
|
|
241
|
-
throw new Error(
|
|
236
|
+
throw new Error('Step failed!');
|
|
242
237
|
},
|
|
243
238
|
},
|
|
244
239
|
],
|
|
245
240
|
});
|
|
246
241
|
|
|
247
|
-
const runId = await engine.startRun({ kind:
|
|
242
|
+
const runId = await engine.startRun({ kind: 'test.fail' });
|
|
248
243
|
const run = await engine.waitForRun(runId);
|
|
249
244
|
|
|
250
|
-
expect(run.status).toBe(
|
|
251
|
-
expect(run.error?.message).toContain(
|
|
245
|
+
expect(run.status).toBe('failed');
|
|
246
|
+
expect(run.error?.message).toContain('Step failed!');
|
|
252
247
|
});
|
|
253
248
|
|
|
254
249
|
it('should skip step when onError is "skip"', async () => {
|
|
255
250
|
const executionOrder: string[] = [];
|
|
256
251
|
|
|
257
252
|
engine.registerWorkflow({
|
|
258
|
-
kind:
|
|
259
|
-
name:
|
|
253
|
+
kind: 'test.skip',
|
|
254
|
+
name: 'Test Skip',
|
|
260
255
|
steps: [
|
|
261
256
|
{
|
|
262
|
-
key:
|
|
263
|
-
name:
|
|
257
|
+
key: 'step1',
|
|
258
|
+
name: 'Step 1',
|
|
264
259
|
handler: async () => {
|
|
265
|
-
executionOrder.push(
|
|
266
|
-
throw new Error(
|
|
260
|
+
executionOrder.push('step1');
|
|
261
|
+
throw new Error('This will be skipped');
|
|
267
262
|
},
|
|
268
|
-
onError:
|
|
263
|
+
onError: 'skip',
|
|
269
264
|
},
|
|
270
265
|
{
|
|
271
|
-
key:
|
|
272
|
-
name:
|
|
266
|
+
key: 'step2',
|
|
267
|
+
name: 'Step 2',
|
|
273
268
|
handler: async () => {
|
|
274
|
-
executionOrder.push(
|
|
275
|
-
return
|
|
269
|
+
executionOrder.push('step2');
|
|
270
|
+
return 'step2';
|
|
276
271
|
},
|
|
277
272
|
},
|
|
278
273
|
],
|
|
279
274
|
});
|
|
280
275
|
|
|
281
|
-
const runId = await engine.startRun({ kind:
|
|
276
|
+
const runId = await engine.startRun({ kind: 'test.skip' });
|
|
282
277
|
const run = await engine.waitForRun(runId);
|
|
283
278
|
|
|
284
|
-
expect(run.status).toBe(
|
|
285
|
-
expect(executionOrder).toEqual([
|
|
279
|
+
expect(run.status).toBe('succeeded');
|
|
280
|
+
expect(executionOrder).toEqual(['step1', 'step2']);
|
|
286
281
|
});
|
|
287
282
|
|
|
288
283
|
it('should retry step when onError is "retry"', async () => {
|
|
289
284
|
let attempts = 0;
|
|
290
285
|
|
|
291
286
|
engine.registerWorkflow({
|
|
292
|
-
kind:
|
|
293
|
-
name:
|
|
287
|
+
kind: 'test.retry',
|
|
288
|
+
name: 'Test Retry',
|
|
294
289
|
steps: [
|
|
295
290
|
{
|
|
296
|
-
key:
|
|
297
|
-
name:
|
|
291
|
+
key: 'step1',
|
|
292
|
+
name: 'Step 1',
|
|
298
293
|
handler: async () => {
|
|
299
294
|
attempts++;
|
|
300
295
|
if (attempts < 3) {
|
|
301
296
|
throw new Error(`Attempt ${attempts} failed`);
|
|
302
297
|
}
|
|
303
|
-
return
|
|
298
|
+
return 'success';
|
|
304
299
|
},
|
|
305
|
-
onError:
|
|
300
|
+
onError: 'retry',
|
|
306
301
|
maxRetries: 5,
|
|
307
302
|
retryDelay: 10,
|
|
308
303
|
},
|
|
309
304
|
],
|
|
310
305
|
});
|
|
311
306
|
|
|
312
|
-
const runId = await engine.startRun({ kind:
|
|
307
|
+
const runId = await engine.startRun({ kind: 'test.retry' });
|
|
313
308
|
const run = await engine.waitForRun(runId);
|
|
314
309
|
|
|
315
|
-
expect(run.status).toBe(
|
|
310
|
+
expect(run.status).toBe('succeeded');
|
|
316
311
|
expect(attempts).toBe(3);
|
|
317
312
|
});
|
|
318
313
|
|
|
319
|
-
it(
|
|
314
|
+
it('should fail after exhausting retries', async () => {
|
|
320
315
|
let attempts = 0;
|
|
321
316
|
|
|
322
317
|
engine.registerWorkflow({
|
|
323
|
-
kind:
|
|
324
|
-
name:
|
|
318
|
+
kind: 'test.retry.exhaust',
|
|
319
|
+
name: 'Test Retry Exhaust',
|
|
325
320
|
steps: [
|
|
326
321
|
{
|
|
327
|
-
key:
|
|
328
|
-
name:
|
|
322
|
+
key: 'step1',
|
|
323
|
+
name: 'Step 1',
|
|
329
324
|
handler: async () => {
|
|
330
325
|
attempts++;
|
|
331
326
|
throw new Error(`Attempt ${attempts} failed`);
|
|
332
327
|
},
|
|
333
|
-
onError:
|
|
328
|
+
onError: 'retry',
|
|
334
329
|
maxRetries: 2,
|
|
335
330
|
retryDelay: 10,
|
|
336
331
|
},
|
|
337
332
|
],
|
|
338
333
|
});
|
|
339
334
|
|
|
340
|
-
const runId = await engine.startRun({ kind:
|
|
335
|
+
const runId = await engine.startRun({ kind: 'test.retry.exhaust' });
|
|
341
336
|
const run = await engine.waitForRun(runId);
|
|
342
337
|
|
|
343
|
-
expect(run.status).toBe(
|
|
338
|
+
expect(run.status).toBe('failed');
|
|
344
339
|
expect(attempts).toBe(3); // 1 initial + 2 retries
|
|
345
340
|
});
|
|
346
341
|
});
|
|
347
342
|
|
|
348
|
-
describe(
|
|
349
|
-
it(
|
|
343
|
+
describe('skipIf condition', () => {
|
|
344
|
+
it('should skip step when skipIf returns true', async () => {
|
|
350
345
|
const executionOrder: string[] = [];
|
|
351
346
|
|
|
352
347
|
engine.registerWorkflow({
|
|
353
|
-
kind:
|
|
354
|
-
name:
|
|
348
|
+
kind: 'test.skipif',
|
|
349
|
+
name: 'Test SkipIf',
|
|
355
350
|
steps: [
|
|
356
351
|
{
|
|
357
|
-
key:
|
|
358
|
-
name:
|
|
352
|
+
key: 'step1',
|
|
353
|
+
name: 'Step 1',
|
|
359
354
|
handler: async () => {
|
|
360
|
-
executionOrder.push(
|
|
355
|
+
executionOrder.push('step1');
|
|
361
356
|
return { skip: true };
|
|
362
357
|
},
|
|
363
358
|
},
|
|
364
359
|
{
|
|
365
|
-
key:
|
|
366
|
-
name:
|
|
360
|
+
key: 'step2',
|
|
361
|
+
name: 'Step 2',
|
|
367
362
|
handler: async () => {
|
|
368
|
-
executionOrder.push(
|
|
369
|
-
return
|
|
363
|
+
executionOrder.push('step2');
|
|
364
|
+
return 'step2';
|
|
370
365
|
},
|
|
371
|
-
skipIf: async (ctx) =>
|
|
372
|
-
(ctx.results.step1 as { skip: boolean }).skip,
|
|
366
|
+
skipIf: async (ctx) => (ctx.results.step1 as { skip: boolean }).skip,
|
|
373
367
|
},
|
|
374
368
|
{
|
|
375
|
-
key:
|
|
376
|
-
name:
|
|
369
|
+
key: 'step3',
|
|
370
|
+
name: 'Step 3',
|
|
377
371
|
handler: async () => {
|
|
378
|
-
executionOrder.push(
|
|
379
|
-
return
|
|
372
|
+
executionOrder.push('step3');
|
|
373
|
+
return 'step3';
|
|
380
374
|
},
|
|
381
375
|
},
|
|
382
376
|
],
|
|
383
377
|
});
|
|
384
378
|
|
|
385
|
-
const runId = await engine.startRun({ kind:
|
|
379
|
+
const runId = await engine.startRun({ kind: 'test.skipif' });
|
|
386
380
|
const run = await engine.waitForRun(runId);
|
|
387
381
|
|
|
388
|
-
expect(run.status).toBe(
|
|
389
|
-
expect(executionOrder).toEqual([
|
|
382
|
+
expect(run.status).toBe('succeeded');
|
|
383
|
+
expect(executionOrder).toEqual(['step1', 'step3']);
|
|
390
384
|
});
|
|
391
385
|
});
|
|
392
386
|
|
|
393
|
-
describe(
|
|
394
|
-
it(
|
|
387
|
+
describe('events', () => {
|
|
388
|
+
it('should emit events during workflow execution', async () => {
|
|
395
389
|
const receivedEvents: WorkflowEvent[] = [];
|
|
396
390
|
|
|
397
391
|
engine.registerWorkflow({
|
|
398
|
-
kind:
|
|
399
|
-
name:
|
|
392
|
+
kind: 'test.events',
|
|
393
|
+
name: 'Test Events',
|
|
400
394
|
steps: [
|
|
401
395
|
{
|
|
402
|
-
key:
|
|
403
|
-
name:
|
|
404
|
-
handler: async () =>
|
|
396
|
+
key: 'step1',
|
|
397
|
+
name: 'Step 1',
|
|
398
|
+
handler: async () => 'result',
|
|
405
399
|
},
|
|
406
400
|
],
|
|
407
401
|
});
|
|
@@ -410,32 +404,32 @@ describe("WorkflowEngine", () => {
|
|
|
410
404
|
receivedEvents.push(event);
|
|
411
405
|
});
|
|
412
406
|
|
|
413
|
-
const runId = await engine.startRun({ kind:
|
|
407
|
+
const runId = await engine.startRun({ kind: 'test.events' });
|
|
414
408
|
await engine.waitForRun(runId);
|
|
415
409
|
unsubscribe();
|
|
416
410
|
|
|
417
|
-
const eventTypes = receivedEvents.map(
|
|
418
|
-
expect(eventTypes).toContain(
|
|
419
|
-
expect(eventTypes).toContain(
|
|
420
|
-
expect(eventTypes).toContain(
|
|
421
|
-
expect(eventTypes).toContain(
|
|
422
|
-
expect(eventTypes).toContain(
|
|
411
|
+
const eventTypes = receivedEvents.map(e => e.eventType);
|
|
412
|
+
expect(eventTypes).toContain('run.created');
|
|
413
|
+
expect(eventTypes).toContain('run.started');
|
|
414
|
+
expect(eventTypes).toContain('step.started');
|
|
415
|
+
expect(eventTypes).toContain('step.completed');
|
|
416
|
+
expect(eventTypes).toContain('run.completed');
|
|
423
417
|
});
|
|
424
418
|
|
|
425
|
-
it(
|
|
419
|
+
it('should emit custom events via context.emit', async () => {
|
|
426
420
|
const receivedEvents: WorkflowEvent[] = [];
|
|
427
421
|
|
|
428
422
|
engine.registerWorkflow({
|
|
429
|
-
kind:
|
|
430
|
-
name:
|
|
423
|
+
kind: 'test.custom.events',
|
|
424
|
+
name: 'Test Custom Events',
|
|
431
425
|
steps: [
|
|
432
426
|
{
|
|
433
|
-
key:
|
|
434
|
-
name:
|
|
427
|
+
key: 'step1',
|
|
428
|
+
name: 'Step 1',
|
|
435
429
|
handler: async (ctx) => {
|
|
436
|
-
ctx.emit(
|
|
437
|
-
ctx.emit(
|
|
438
|
-
return
|
|
430
|
+
ctx.emit('custom.progress', { percent: 50 });
|
|
431
|
+
ctx.emit('custom.progress', { percent: 100 });
|
|
432
|
+
return 'done';
|
|
439
433
|
},
|
|
440
434
|
},
|
|
441
435
|
],
|
|
@@ -445,68 +439,66 @@ describe("WorkflowEngine", () => {
|
|
|
445
439
|
receivedEvents.push(event);
|
|
446
440
|
});
|
|
447
441
|
|
|
448
|
-
const runId = await engine.startRun({ kind:
|
|
442
|
+
const runId = await engine.startRun({ kind: 'test.custom.events' });
|
|
449
443
|
await engine.waitForRun(runId);
|
|
450
444
|
unsubscribe();
|
|
451
445
|
|
|
452
|
-
const customEvents = receivedEvents.filter(
|
|
453
|
-
(e) => e.eventType === "custom.progress",
|
|
454
|
-
);
|
|
446
|
+
const customEvents = receivedEvents.filter(e => e.eventType === 'custom.progress');
|
|
455
447
|
expect(customEvents).toHaveLength(2);
|
|
456
448
|
expect(customEvents[0].payload).toEqual({ percent: 50 });
|
|
457
449
|
expect(customEvents[1].payload).toEqual({ percent: 100 });
|
|
458
450
|
});
|
|
459
451
|
});
|
|
460
452
|
|
|
461
|
-
describe(
|
|
462
|
-
it(
|
|
453
|
+
describe('cancelRun', () => {
|
|
454
|
+
it('should cancel a running workflow', async () => {
|
|
463
455
|
engine.registerWorkflow({
|
|
464
|
-
kind:
|
|
465
|
-
name:
|
|
456
|
+
kind: 'test.cancel',
|
|
457
|
+
name: 'Test Cancel',
|
|
466
458
|
steps: [
|
|
467
459
|
{
|
|
468
|
-
key:
|
|
469
|
-
name:
|
|
460
|
+
key: 'step1',
|
|
461
|
+
name: 'Step 1',
|
|
470
462
|
handler: async () => {
|
|
471
463
|
// Simulate long-running step
|
|
472
|
-
await new Promise(
|
|
473
|
-
return
|
|
464
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
465
|
+
return 'done';
|
|
474
466
|
},
|
|
475
467
|
},
|
|
476
468
|
],
|
|
477
469
|
});
|
|
478
470
|
|
|
479
|
-
const runId = await engine.startRun({ kind:
|
|
471
|
+
const runId = await engine.startRun({ kind: 'test.cancel' });
|
|
480
472
|
|
|
481
473
|
// Cancel after a short delay
|
|
482
|
-
await new Promise(
|
|
474
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
483
475
|
await engine.cancelRun(runId);
|
|
484
476
|
|
|
485
477
|
const run = await engine.waitForRun(runId, { timeout: 2000 });
|
|
486
|
-
expect(run.status).toBe(
|
|
478
|
+
expect(run.status).toBe('canceled');
|
|
487
479
|
});
|
|
488
480
|
});
|
|
489
481
|
|
|
490
|
-
describe(
|
|
491
|
-
it(
|
|
482
|
+
describe('hooks', () => {
|
|
483
|
+
it('should call beforeRun and afterRun hooks', async () => {
|
|
492
484
|
const hookCalls: string[] = [];
|
|
493
485
|
|
|
494
486
|
engine.registerWorkflow({
|
|
495
|
-
kind:
|
|
496
|
-
name:
|
|
487
|
+
kind: 'test.hooks',
|
|
488
|
+
name: 'Test Hooks',
|
|
497
489
|
steps: [
|
|
498
490
|
{
|
|
499
|
-
key:
|
|
500
|
-
name:
|
|
491
|
+
key: 'step1',
|
|
492
|
+
name: 'Step 1',
|
|
501
493
|
handler: async () => {
|
|
502
|
-
hookCalls.push(
|
|
503
|
-
return
|
|
494
|
+
hookCalls.push('step1');
|
|
495
|
+
return 'done';
|
|
504
496
|
},
|
|
505
497
|
},
|
|
506
498
|
],
|
|
507
499
|
hooks: {
|
|
508
500
|
beforeRun: async () => {
|
|
509
|
-
hookCalls.push(
|
|
501
|
+
hookCalls.push('beforeRun');
|
|
510
502
|
},
|
|
511
503
|
afterRun: async (_, result) => {
|
|
512
504
|
hookCalls.push(`afterRun:${result.status}`);
|
|
@@ -514,28 +506,28 @@ describe("WorkflowEngine", () => {
|
|
|
514
506
|
},
|
|
515
507
|
});
|
|
516
508
|
|
|
517
|
-
const runId = await engine.startRun({ kind:
|
|
509
|
+
const runId = await engine.startRun({ kind: 'test.hooks' });
|
|
518
510
|
await engine.waitForRun(runId);
|
|
519
511
|
|
|
520
|
-
expect(hookCalls).toEqual([
|
|
512
|
+
expect(hookCalls).toEqual(['beforeRun', 'step1', 'afterRun:succeeded']);
|
|
521
513
|
});
|
|
522
514
|
|
|
523
|
-
it(
|
|
515
|
+
it('should call beforeStep and afterStep hooks', async () => {
|
|
524
516
|
const hookCalls: string[] = [];
|
|
525
517
|
|
|
526
518
|
engine.registerWorkflow({
|
|
527
|
-
kind:
|
|
528
|
-
name:
|
|
519
|
+
kind: 'test.step.hooks',
|
|
520
|
+
name: 'Test Step Hooks',
|
|
529
521
|
steps: [
|
|
530
522
|
{
|
|
531
|
-
key:
|
|
532
|
-
name:
|
|
533
|
-
handler: async () =>
|
|
523
|
+
key: 'step1',
|
|
524
|
+
name: 'Step 1',
|
|
525
|
+
handler: async () => 'result1',
|
|
534
526
|
},
|
|
535
527
|
{
|
|
536
|
-
key:
|
|
537
|
-
name:
|
|
538
|
-
handler: async () =>
|
|
528
|
+
key: 'step2',
|
|
529
|
+
name: 'Step 2',
|
|
530
|
+
handler: async () => 'result2',
|
|
539
531
|
},
|
|
540
532
|
],
|
|
541
533
|
hooks: {
|
|
@@ -548,159 +540,157 @@ describe("WorkflowEngine", () => {
|
|
|
548
540
|
},
|
|
549
541
|
});
|
|
550
542
|
|
|
551
|
-
const runId = await engine.startRun({ kind:
|
|
543
|
+
const runId = await engine.startRun({ kind: 'test.step.hooks' });
|
|
552
544
|
await engine.waitForRun(runId);
|
|
553
545
|
|
|
554
546
|
expect(hookCalls).toEqual([
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
547
|
+
'before:step1',
|
|
548
|
+
'after:step1',
|
|
549
|
+
'before:step2',
|
|
550
|
+
'after:step2',
|
|
559
551
|
]);
|
|
560
552
|
});
|
|
561
553
|
});
|
|
562
554
|
|
|
563
|
-
describe(
|
|
564
|
-
it(
|
|
555
|
+
describe('storage', () => {
|
|
556
|
+
it('should persist run records', async () => {
|
|
565
557
|
engine.registerWorkflow({
|
|
566
|
-
kind:
|
|
567
|
-
name:
|
|
558
|
+
kind: 'test.storage',
|
|
559
|
+
name: 'Test Storage',
|
|
568
560
|
steps: [
|
|
569
561
|
{
|
|
570
|
-
key:
|
|
571
|
-
name:
|
|
572
|
-
handler: async () =>
|
|
562
|
+
key: 'step1',
|
|
563
|
+
name: 'Step 1',
|
|
564
|
+
handler: async () => 'result',
|
|
573
565
|
},
|
|
574
566
|
],
|
|
575
567
|
});
|
|
576
568
|
|
|
577
569
|
const runId = await engine.startRun({
|
|
578
|
-
kind:
|
|
579
|
-
input: { foo:
|
|
580
|
-
metadata: { userId:
|
|
570
|
+
kind: 'test.storage',
|
|
571
|
+
input: { foo: 'bar' },
|
|
572
|
+
metadata: { userId: 'user1' },
|
|
581
573
|
});
|
|
582
574
|
await engine.waitForRun(runId);
|
|
583
575
|
|
|
584
576
|
const run = await storage.getRun(runId);
|
|
585
577
|
expect(run).toBeDefined();
|
|
586
|
-
expect(run?.kind).toBe(
|
|
587
|
-
expect(run?.status).toBe(
|
|
588
|
-
expect(run?.input).toEqual({ foo:
|
|
589
|
-
expect(run?.metadata).toEqual({ userId:
|
|
578
|
+
expect(run?.kind).toBe('test.storage');
|
|
579
|
+
expect(run?.status).toBe('succeeded');
|
|
580
|
+
expect(run?.input).toEqual({ foo: 'bar' });
|
|
581
|
+
expect(run?.metadata).toEqual({ userId: 'user1' });
|
|
590
582
|
});
|
|
591
583
|
|
|
592
|
-
it(
|
|
584
|
+
it('should persist step records', async () => {
|
|
593
585
|
engine.registerWorkflow({
|
|
594
|
-
kind:
|
|
595
|
-
name:
|
|
586
|
+
kind: 'test.step.storage',
|
|
587
|
+
name: 'Test Step Storage',
|
|
596
588
|
steps: [
|
|
597
589
|
{
|
|
598
|
-
key:
|
|
599
|
-
name:
|
|
600
|
-
handler: async () =>
|
|
590
|
+
key: 'step1',
|
|
591
|
+
name: 'Step 1',
|
|
592
|
+
handler: async () => 'result1',
|
|
601
593
|
},
|
|
602
594
|
{
|
|
603
|
-
key:
|
|
604
|
-
name:
|
|
605
|
-
handler: async () =>
|
|
595
|
+
key: 'step2',
|
|
596
|
+
name: 'Step 2',
|
|
597
|
+
handler: async () => 'result2',
|
|
606
598
|
},
|
|
607
599
|
],
|
|
608
600
|
});
|
|
609
601
|
|
|
610
|
-
const runId = await engine.startRun({ kind:
|
|
602
|
+
const runId = await engine.startRun({ kind: 'test.step.storage' });
|
|
611
603
|
await engine.waitForRun(runId);
|
|
612
604
|
|
|
613
605
|
const steps = await storage.getStepsForRun(runId);
|
|
614
606
|
expect(steps).toHaveLength(2);
|
|
615
|
-
expect(steps[0].stepKey).toBe(
|
|
616
|
-
expect(steps[0].status).toBe(
|
|
617
|
-
expect(steps[1].stepKey).toBe(
|
|
618
|
-
expect(steps[1].status).toBe(
|
|
607
|
+
expect(steps[0].stepKey).toBe('step1');
|
|
608
|
+
expect(steps[0].status).toBe('succeeded');
|
|
609
|
+
expect(steps[1].stepKey).toBe('step2');
|
|
610
|
+
expect(steps[1].status).toBe('succeeded');
|
|
619
611
|
});
|
|
620
612
|
});
|
|
621
613
|
|
|
622
|
-
describe(
|
|
623
|
-
it(
|
|
614
|
+
describe('workflow timeout', () => {
|
|
615
|
+
it('should timeout a long-running workflow', async () => {
|
|
624
616
|
engine.registerWorkflow({
|
|
625
|
-
kind:
|
|
626
|
-
name:
|
|
617
|
+
kind: 'test.timeout',
|
|
618
|
+
name: 'Test Timeout',
|
|
627
619
|
timeout: 100, // 100ms timeout
|
|
628
620
|
steps: [
|
|
629
621
|
{
|
|
630
|
-
key:
|
|
631
|
-
name:
|
|
622
|
+
key: 'slow_step',
|
|
623
|
+
name: 'Slow Step',
|
|
632
624
|
handler: async () => {
|
|
633
|
-
await new Promise(
|
|
634
|
-
return
|
|
625
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
626
|
+
return 'done';
|
|
635
627
|
},
|
|
636
628
|
},
|
|
637
629
|
],
|
|
638
630
|
});
|
|
639
631
|
|
|
640
|
-
const runId = await engine.startRun({ kind:
|
|
632
|
+
const runId = await engine.startRun({ kind: 'test.timeout' });
|
|
641
633
|
const run = await engine.waitForRun(runId, { timeout: 2000 });
|
|
642
634
|
|
|
643
|
-
expect(run.status).toBe(
|
|
644
|
-
expect(run.error?.code).toBe(
|
|
635
|
+
expect(run.status).toBe('failed');
|
|
636
|
+
expect(run.error?.code).toBe('WORKFLOW_TIMEOUT');
|
|
645
637
|
});
|
|
646
638
|
|
|
647
|
-
it(
|
|
639
|
+
it('should complete before timeout if fast enough', async () => {
|
|
648
640
|
engine.registerWorkflow({
|
|
649
|
-
kind:
|
|
650
|
-
name:
|
|
641
|
+
kind: 'test.fast',
|
|
642
|
+
name: 'Test Fast',
|
|
651
643
|
timeout: 1000, // 1s timeout
|
|
652
644
|
steps: [
|
|
653
645
|
{
|
|
654
|
-
key:
|
|
655
|
-
name:
|
|
646
|
+
key: 'fast_step',
|
|
647
|
+
name: 'Fast Step',
|
|
656
648
|
handler: async () => {
|
|
657
|
-
await new Promise(
|
|
658
|
-
return
|
|
649
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
650
|
+
return 'done';
|
|
659
651
|
},
|
|
660
652
|
},
|
|
661
653
|
],
|
|
662
654
|
});
|
|
663
655
|
|
|
664
|
-
const runId = await engine.startRun({ kind:
|
|
656
|
+
const runId = await engine.startRun({ kind: 'test.fast' });
|
|
665
657
|
const run = await engine.waitForRun(runId, { timeout: 2000 });
|
|
666
658
|
|
|
667
|
-
expect(run.status).toBe(
|
|
659
|
+
expect(run.status).toBe('succeeded');
|
|
668
660
|
});
|
|
669
661
|
|
|
670
|
-
it(
|
|
662
|
+
it('should emit run.timeout event', async () => {
|
|
671
663
|
const receivedEvents: WorkflowEvent[] = [];
|
|
672
664
|
|
|
673
665
|
engine.registerWorkflow({
|
|
674
|
-
kind:
|
|
675
|
-
name:
|
|
666
|
+
kind: 'test.timeout.event',
|
|
667
|
+
name: 'Test Timeout Event',
|
|
676
668
|
timeout: 50,
|
|
677
669
|
steps: [
|
|
678
670
|
{
|
|
679
|
-
key:
|
|
680
|
-
name:
|
|
671
|
+
key: 'slow_step',
|
|
672
|
+
name: 'Slow Step',
|
|
681
673
|
handler: async () => {
|
|
682
|
-
await new Promise(
|
|
683
|
-
return
|
|
674
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
675
|
+
return 'done';
|
|
684
676
|
},
|
|
685
677
|
},
|
|
686
678
|
],
|
|
687
679
|
});
|
|
688
680
|
|
|
689
|
-
const runId = await engine.startRun({ kind:
|
|
681
|
+
const runId = await engine.startRun({ kind: 'test.timeout.event' });
|
|
690
682
|
engine.subscribeToRun(runId, (event) => {
|
|
691
683
|
receivedEvents.push(event);
|
|
692
684
|
});
|
|
693
685
|
|
|
694
686
|
await engine.waitForRun(runId, { timeout: 2000 });
|
|
695
687
|
|
|
696
|
-
expect(receivedEvents.some(
|
|
697
|
-
true,
|
|
698
|
-
);
|
|
688
|
+
expect(receivedEvents.some(e => e.eventType === 'run.timeout')).toBe(true);
|
|
699
689
|
});
|
|
700
690
|
});
|
|
701
691
|
|
|
702
|
-
describe(
|
|
703
|
-
it(
|
|
692
|
+
describe('concurrency control', () => {
|
|
693
|
+
it('should limit concurrent runs with maxConcurrency', async () => {
|
|
704
694
|
const concurrentEngine = new WorkflowEngine({
|
|
705
695
|
storage: new MemoryStorageAdapter(),
|
|
706
696
|
events: new MemoryEventTransport(),
|
|
@@ -711,15 +701,15 @@ describe("WorkflowEngine", () => {
|
|
|
711
701
|
});
|
|
712
702
|
|
|
713
703
|
concurrentEngine.registerWorkflow({
|
|
714
|
-
kind:
|
|
715
|
-
name:
|
|
704
|
+
kind: 'test.concurrent',
|
|
705
|
+
name: 'Test Concurrent',
|
|
716
706
|
steps: [
|
|
717
707
|
{
|
|
718
|
-
key:
|
|
719
|
-
name:
|
|
708
|
+
key: 'step1',
|
|
709
|
+
name: 'Step 1',
|
|
720
710
|
handler: async () => {
|
|
721
|
-
await new Promise(
|
|
722
|
-
return
|
|
711
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
712
|
+
return 'done';
|
|
723
713
|
},
|
|
724
714
|
},
|
|
725
715
|
],
|
|
@@ -727,10 +717,10 @@ describe("WorkflowEngine", () => {
|
|
|
727
717
|
|
|
728
718
|
// Start 4 runs
|
|
729
719
|
const runIds = await Promise.all([
|
|
730
|
-
concurrentEngine.startRun({ kind:
|
|
731
|
-
concurrentEngine.startRun({ kind:
|
|
732
|
-
concurrentEngine.startRun({ kind:
|
|
733
|
-
concurrentEngine.startRun({ kind:
|
|
720
|
+
concurrentEngine.startRun({ kind: 'test.concurrent' }),
|
|
721
|
+
concurrentEngine.startRun({ kind: 'test.concurrent' }),
|
|
722
|
+
concurrentEngine.startRun({ kind: 'test.concurrent' }),
|
|
723
|
+
concurrentEngine.startRun({ kind: 'test.concurrent' }),
|
|
734
724
|
]);
|
|
735
725
|
|
|
736
726
|
// Check that only 2 are active and 2 are queued
|
|
@@ -738,9 +728,7 @@ describe("WorkflowEngine", () => {
|
|
|
738
728
|
expect(concurrentEngine.getQueuedRunCount()).toBe(2);
|
|
739
729
|
|
|
740
730
|
// Wait for all to complete
|
|
741
|
-
await Promise.all(
|
|
742
|
-
runIds.map((id) => concurrentEngine.waitForRun(id, { timeout: 2000 })),
|
|
743
|
-
);
|
|
731
|
+
await Promise.all(runIds.map(id => concurrentEngine.waitForRun(id, { timeout: 2000 })));
|
|
744
732
|
|
|
745
733
|
expect(concurrentEngine.getActiveRunCount()).toBe(0);
|
|
746
734
|
expect(concurrentEngine.getQueuedRunCount()).toBe(0);
|
|
@@ -748,7 +736,7 @@ describe("WorkflowEngine", () => {
|
|
|
748
736
|
await concurrentEngine.shutdown();
|
|
749
737
|
});
|
|
750
738
|
|
|
751
|
-
it(
|
|
739
|
+
it('should process queue in order when runs complete', async () => {
|
|
752
740
|
const completionOrder: string[] = [];
|
|
753
741
|
|
|
754
742
|
const concurrentEngine = new WorkflowEngine({
|
|
@@ -761,15 +749,15 @@ describe("WorkflowEngine", () => {
|
|
|
761
749
|
});
|
|
762
750
|
|
|
763
751
|
concurrentEngine.registerWorkflow({
|
|
764
|
-
kind:
|
|
765
|
-
name:
|
|
752
|
+
kind: 'test.queue.order',
|
|
753
|
+
name: 'Test Queue Order',
|
|
766
754
|
steps: [
|
|
767
755
|
{
|
|
768
|
-
key:
|
|
769
|
-
name:
|
|
756
|
+
key: 'step1',
|
|
757
|
+
name: 'Step 1',
|
|
770
758
|
handler: async (ctx) => {
|
|
771
759
|
completionOrder.push(ctx.input.id as string);
|
|
772
|
-
return
|
|
760
|
+
return 'done';
|
|
773
761
|
},
|
|
774
762
|
},
|
|
775
763
|
],
|
|
@@ -777,34 +765,23 @@ describe("WorkflowEngine", () => {
|
|
|
777
765
|
|
|
778
766
|
// Start 3 runs in order
|
|
779
767
|
const runIds = await Promise.all([
|
|
780
|
-
concurrentEngine.startRun({
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
}),
|
|
784
|
-
concurrentEngine.startRun({
|
|
785
|
-
kind: "test.queue.order",
|
|
786
|
-
input: { id: "second" },
|
|
787
|
-
}),
|
|
788
|
-
concurrentEngine.startRun({
|
|
789
|
-
kind: "test.queue.order",
|
|
790
|
-
input: { id: "third" },
|
|
791
|
-
}),
|
|
768
|
+
concurrentEngine.startRun({ kind: 'test.queue.order', input: { id: 'first' } }),
|
|
769
|
+
concurrentEngine.startRun({ kind: 'test.queue.order', input: { id: 'second' } }),
|
|
770
|
+
concurrentEngine.startRun({ kind: 'test.queue.order', input: { id: 'third' } }),
|
|
792
771
|
]);
|
|
793
772
|
|
|
794
773
|
// Wait for all to complete
|
|
795
|
-
await Promise.all(
|
|
796
|
-
runIds.map((id) => concurrentEngine.waitForRun(id, { timeout: 2000 })),
|
|
797
|
-
);
|
|
774
|
+
await Promise.all(runIds.map(id => concurrentEngine.waitForRun(id, { timeout: 2000 })));
|
|
798
775
|
|
|
799
776
|
// Should complete in order (FIFO)
|
|
800
|
-
expect(completionOrder).toEqual([
|
|
777
|
+
expect(completionOrder).toEqual(['first', 'second', 'third']);
|
|
801
778
|
|
|
802
779
|
await concurrentEngine.shutdown();
|
|
803
780
|
});
|
|
804
781
|
});
|
|
805
782
|
|
|
806
|
-
describe(
|
|
807
|
-
it(
|
|
783
|
+
describe('initialize', () => {
|
|
784
|
+
it('should call storage.initialize if available', async () => {
|
|
808
785
|
const initFn = vi.fn();
|
|
809
786
|
const customStorage = new MemoryStorageAdapter();
|
|
810
787
|
(customStorage as any).initialize = initFn;
|
|
@@ -819,36 +796,36 @@ describe("WorkflowEngine", () => {
|
|
|
819
796
|
expect(initFn).toHaveBeenCalledTimes(1);
|
|
820
797
|
});
|
|
821
798
|
|
|
822
|
-
it(
|
|
799
|
+
it('should not throw if storage has no initialize', async () => {
|
|
823
800
|
await expect(engine.initialize()).resolves.not.toThrow();
|
|
824
801
|
});
|
|
825
802
|
});
|
|
826
803
|
|
|
827
|
-
describe(
|
|
828
|
-
it(
|
|
804
|
+
describe('shutdown', () => {
|
|
805
|
+
it('should cancel active runs and close resources', async () => {
|
|
829
806
|
engine.registerWorkflow({
|
|
830
|
-
kind:
|
|
831
|
-
name:
|
|
807
|
+
kind: 'test.shutdown',
|
|
808
|
+
name: 'Test Shutdown',
|
|
832
809
|
steps: [
|
|
833
810
|
{
|
|
834
|
-
key:
|
|
835
|
-
name:
|
|
811
|
+
key: 'step1',
|
|
812
|
+
name: 'Step 1',
|
|
836
813
|
handler: async () => {
|
|
837
|
-
await new Promise(
|
|
838
|
-
return
|
|
814
|
+
await new Promise(resolve => setTimeout(resolve, 5000));
|
|
815
|
+
return 'done';
|
|
839
816
|
},
|
|
840
817
|
},
|
|
841
818
|
],
|
|
842
819
|
});
|
|
843
820
|
|
|
844
|
-
await engine.startRun({ kind:
|
|
821
|
+
await engine.startRun({ kind: 'test.shutdown' });
|
|
845
822
|
expect(engine.getActiveRunCount()).toBe(1);
|
|
846
823
|
|
|
847
824
|
await engine.shutdown();
|
|
848
825
|
expect(engine.getActiveRunCount()).toBe(0);
|
|
849
826
|
});
|
|
850
827
|
|
|
851
|
-
it(
|
|
828
|
+
it('should call events.close and storage.close if available', async () => {
|
|
852
829
|
const closeFn = vi.fn();
|
|
853
830
|
const storageCloseFn = vi.fn();
|
|
854
831
|
const customEvents = new MemoryEventTransport();
|
|
@@ -868,125 +845,117 @@ describe("WorkflowEngine", () => {
|
|
|
868
845
|
});
|
|
869
846
|
});
|
|
870
847
|
|
|
871
|
-
describe(
|
|
872
|
-
it(
|
|
873
|
-
await expect(engine.waitForRun(
|
|
874
|
-
"not found",
|
|
875
|
-
);
|
|
848
|
+
describe('waitForRun', () => {
|
|
849
|
+
it('should throw RunNotFoundError if run does not exist', async () => {
|
|
850
|
+
await expect(engine.waitForRun('nonexistent')).rejects.toThrow('not found');
|
|
876
851
|
});
|
|
877
852
|
|
|
878
|
-
it(
|
|
853
|
+
it('should timeout if run never completes', async () => {
|
|
879
854
|
engine.registerWorkflow({
|
|
880
|
-
kind:
|
|
881
|
-
name:
|
|
855
|
+
kind: 'test.wait.timeout',
|
|
856
|
+
name: 'Test Wait Timeout',
|
|
882
857
|
steps: [
|
|
883
858
|
{
|
|
884
|
-
key:
|
|
885
|
-
name:
|
|
859
|
+
key: 'step1',
|
|
860
|
+
name: 'Step 1',
|
|
886
861
|
handler: async () => {
|
|
887
|
-
await new Promise(
|
|
888
|
-
return
|
|
862
|
+
await new Promise(resolve => setTimeout(resolve, 10000));
|
|
863
|
+
return 'done';
|
|
889
864
|
},
|
|
890
865
|
},
|
|
891
866
|
],
|
|
892
867
|
});
|
|
893
868
|
|
|
894
|
-
const runId = await engine.startRun({ kind:
|
|
869
|
+
const runId = await engine.startRun({ kind: 'test.wait.timeout' });
|
|
895
870
|
|
|
896
871
|
await expect(
|
|
897
|
-
engine.waitForRun(runId, { timeout: 100, pollInterval: 20 })
|
|
898
|
-
).rejects.toThrow(
|
|
872
|
+
engine.waitForRun(runId, { timeout: 100, pollInterval: 20 })
|
|
873
|
+
).rejects.toThrow('Timeout waiting for run');
|
|
899
874
|
|
|
900
875
|
await engine.cancelRun(runId);
|
|
901
876
|
});
|
|
902
877
|
});
|
|
903
878
|
|
|
904
|
-
describe(
|
|
905
|
-
it(
|
|
906
|
-
await expect(engine.cancelRun(
|
|
907
|
-
"not found",
|
|
908
|
-
);
|
|
879
|
+
describe('cancelRun', () => {
|
|
880
|
+
it('should throw RunNotFoundError for non-existent run', async () => {
|
|
881
|
+
await expect(engine.cancelRun('nonexistent')).rejects.toThrow('not found');
|
|
909
882
|
});
|
|
910
883
|
|
|
911
|
-
it(
|
|
884
|
+
it('should update status even if run is not actively running', async () => {
|
|
912
885
|
engine.registerWorkflow({
|
|
913
|
-
kind:
|
|
914
|
-
name:
|
|
886
|
+
kind: 'test.cancel.inactive',
|
|
887
|
+
name: 'Test Cancel Inactive',
|
|
915
888
|
steps: [
|
|
916
889
|
{
|
|
917
|
-
key:
|
|
918
|
-
name:
|
|
919
|
-
handler: async () =>
|
|
890
|
+
key: 'step1',
|
|
891
|
+
name: 'Step 1',
|
|
892
|
+
handler: async () => 'done',
|
|
920
893
|
},
|
|
921
894
|
],
|
|
922
895
|
});
|
|
923
896
|
|
|
924
|
-
const runId = await engine.startRun({ kind:
|
|
897
|
+
const runId = await engine.startRun({ kind: 'test.cancel.inactive' });
|
|
925
898
|
await engine.waitForRun(runId);
|
|
926
899
|
|
|
927
900
|
// Run is already complete — cancelRun is a no-op for terminal statuses
|
|
928
901
|
await engine.cancelRun(runId);
|
|
929
902
|
const run = await engine.getRunStatus(runId);
|
|
930
|
-
expect(run?.status).toBe(
|
|
903
|
+
expect(run?.status).toBe('succeeded');
|
|
931
904
|
});
|
|
932
905
|
});
|
|
933
906
|
|
|
934
|
-
describe(
|
|
935
|
-
it(
|
|
936
|
-
await expect(engine.resumeRun(
|
|
937
|
-
"not found",
|
|
938
|
-
);
|
|
907
|
+
describe('resumeRun', () => {
|
|
908
|
+
it('should throw RunNotFoundError for non-existent run', async () => {
|
|
909
|
+
await expect(engine.resumeRun('nonexistent')).rejects.toThrow('not found');
|
|
939
910
|
});
|
|
940
911
|
|
|
941
|
-
it(
|
|
912
|
+
it('should throw if run status is not resumable', async () => {
|
|
942
913
|
engine.registerWorkflow({
|
|
943
|
-
kind:
|
|
944
|
-
name:
|
|
945
|
-
steps: [
|
|
914
|
+
kind: 'test.resume.completed',
|
|
915
|
+
name: 'Test',
|
|
916
|
+
steps: [
|
|
917
|
+
{ key: 's1', name: 'S1', handler: async () => 'done' },
|
|
918
|
+
],
|
|
946
919
|
});
|
|
947
920
|
|
|
948
|
-
const runId = await engine.startRun({ kind:
|
|
921
|
+
const runId = await engine.startRun({ kind: 'test.resume.completed' });
|
|
949
922
|
await engine.waitForRun(runId);
|
|
950
923
|
|
|
951
|
-
await expect(engine.resumeRun(runId)).rejects.toThrow(
|
|
952
|
-
"Cannot resume run",
|
|
953
|
-
);
|
|
924
|
+
await expect(engine.resumeRun(runId)).rejects.toThrow('Cannot resume run');
|
|
954
925
|
});
|
|
955
926
|
|
|
956
|
-
it(
|
|
927
|
+
it('should throw if workflow is not registered', async () => {
|
|
957
928
|
// Create a run directly in storage with a kind that is not registered
|
|
958
929
|
const run = await storage.createRun({
|
|
959
|
-
kind:
|
|
960
|
-
status:
|
|
930
|
+
kind: 'unregistered.workflow',
|
|
931
|
+
status: 'running',
|
|
961
932
|
input: {},
|
|
962
933
|
metadata: {},
|
|
963
934
|
context: {},
|
|
964
935
|
});
|
|
965
936
|
|
|
966
|
-
await expect(engine.resumeRun(run.id)).rejects.toThrow(
|
|
967
|
-
"is not registered",
|
|
968
|
-
);
|
|
937
|
+
await expect(engine.resumeRun(run.id)).rejects.toThrow('is not registered');
|
|
969
938
|
});
|
|
970
939
|
|
|
971
|
-
it(
|
|
940
|
+
it('should skip resume if run is already active', async () => {
|
|
972
941
|
engine.registerWorkflow({
|
|
973
|
-
kind:
|
|
974
|
-
name:
|
|
942
|
+
kind: 'test.resume.active',
|
|
943
|
+
name: 'Test',
|
|
975
944
|
steps: [
|
|
976
945
|
{
|
|
977
|
-
key:
|
|
978
|
-
name:
|
|
946
|
+
key: 's1',
|
|
947
|
+
name: 'S1',
|
|
979
948
|
handler: async () => {
|
|
980
|
-
await new Promise(
|
|
981
|
-
return
|
|
949
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
950
|
+
return 'done';
|
|
982
951
|
},
|
|
983
952
|
},
|
|
984
953
|
],
|
|
985
954
|
});
|
|
986
955
|
|
|
987
|
-
const runId = await engine.startRun({ kind:
|
|
956
|
+
const runId = await engine.startRun({ kind: 'test.resume.active' });
|
|
988
957
|
// Manually set status to running for resume check
|
|
989
|
-
await storage.updateRun(runId, { status:
|
|
958
|
+
await storage.updateRun(runId, { status: 'running' });
|
|
990
959
|
|
|
991
960
|
// Attempt resume while the run is still active
|
|
992
961
|
const result = await engine.resumeRun(runId);
|
|
@@ -995,27 +964,27 @@ describe("WorkflowEngine", () => {
|
|
|
995
964
|
await engine.waitForRun(runId, { timeout: 2000 });
|
|
996
965
|
});
|
|
997
966
|
|
|
998
|
-
it(
|
|
967
|
+
it('should resume from checkpoint', async () => {
|
|
999
968
|
const executionOrder: string[] = [];
|
|
1000
969
|
|
|
1001
970
|
engine.registerWorkflow({
|
|
1002
|
-
kind:
|
|
1003
|
-
name:
|
|
971
|
+
kind: 'test.resume.checkpoint',
|
|
972
|
+
name: 'Test Resume',
|
|
1004
973
|
steps: [
|
|
1005
974
|
{
|
|
1006
|
-
key:
|
|
1007
|
-
name:
|
|
975
|
+
key: 'step1',
|
|
976
|
+
name: 'Step 1',
|
|
1008
977
|
handler: async () => {
|
|
1009
|
-
executionOrder.push(
|
|
1010
|
-
return
|
|
978
|
+
executionOrder.push('step1');
|
|
979
|
+
return 'r1';
|
|
1011
980
|
},
|
|
1012
981
|
},
|
|
1013
982
|
{
|
|
1014
|
-
key:
|
|
1015
|
-
name:
|
|
983
|
+
key: 'step2',
|
|
984
|
+
name: 'Step 2',
|
|
1016
985
|
handler: async () => {
|
|
1017
|
-
executionOrder.push(
|
|
1018
|
-
return
|
|
986
|
+
executionOrder.push('step2');
|
|
987
|
+
return 'r2';
|
|
1019
988
|
},
|
|
1020
989
|
},
|
|
1021
990
|
],
|
|
@@ -1023,51 +992,54 @@ describe("WorkflowEngine", () => {
|
|
|
1023
992
|
|
|
1024
993
|
// Create a run that looks interrupted with step1 already done
|
|
1025
994
|
const run = await storage.createRun({
|
|
1026
|
-
kind:
|
|
1027
|
-
status:
|
|
995
|
+
kind: 'test.resume.checkpoint',
|
|
996
|
+
status: 'running',
|
|
1028
997
|
input: {},
|
|
1029
998
|
metadata: {},
|
|
1030
|
-
context: { step1:
|
|
999
|
+
context: { step1: 'r1' },
|
|
1000
|
+
completedSteps: ['step1'],
|
|
1031
1001
|
});
|
|
1032
1002
|
|
|
1033
1003
|
const resumedId = await engine.resumeRun(run.id);
|
|
1034
1004
|
expect(resumedId).toBe(run.id);
|
|
1035
1005
|
|
|
1036
1006
|
const result = await engine.waitForRun(run.id, { timeout: 2000 });
|
|
1037
|
-
expect(result.status).toBe(
|
|
1007
|
+
expect(result.status).toBe('succeeded');
|
|
1038
1008
|
|
|
1039
1009
|
// Only step2 should have executed
|
|
1040
|
-
expect(executionOrder).toEqual([
|
|
1010
|
+
expect(executionOrder).toEqual(['step2']);
|
|
1041
1011
|
});
|
|
1042
1012
|
});
|
|
1043
1013
|
|
|
1044
|
-
describe(
|
|
1045
|
-
it(
|
|
1014
|
+
describe('resumeAllInterrupted', () => {
|
|
1015
|
+
it('should resume all resumable runs', async () => {
|
|
1046
1016
|
engine.registerWorkflow({
|
|
1047
|
-
kind:
|
|
1048
|
-
name:
|
|
1049
|
-
steps: [
|
|
1017
|
+
kind: 'test.resumeAll',
|
|
1018
|
+
name: 'Test',
|
|
1019
|
+
steps: [
|
|
1020
|
+
{ key: 's1', name: 'S1', handler: async () => 'done' },
|
|
1021
|
+
],
|
|
1050
1022
|
});
|
|
1051
1023
|
|
|
1052
1024
|
// Create two interrupted runs
|
|
1053
1025
|
await storage.createRun({
|
|
1054
|
-
kind:
|
|
1055
|
-
status:
|
|
1026
|
+
kind: 'test.resumeAll',
|
|
1027
|
+
status: 'running',
|
|
1056
1028
|
input: {},
|
|
1057
1029
|
metadata: {},
|
|
1058
1030
|
context: {},
|
|
1059
1031
|
});
|
|
1060
1032
|
await storage.createRun({
|
|
1061
|
-
kind:
|
|
1062
|
-
status:
|
|
1033
|
+
kind: 'test.resumeAll',
|
|
1034
|
+
status: 'queued',
|
|
1063
1035
|
input: {},
|
|
1064
1036
|
metadata: {},
|
|
1065
1037
|
context: {},
|
|
1066
1038
|
});
|
|
1067
1039
|
// One completed run (should be skipped)
|
|
1068
1040
|
await storage.createRun({
|
|
1069
|
-
kind:
|
|
1070
|
-
status:
|
|
1041
|
+
kind: 'test.resumeAll',
|
|
1042
|
+
status: 'succeeded',
|
|
1071
1043
|
input: {},
|
|
1072
1044
|
metadata: {},
|
|
1073
1045
|
context: {},
|
|
@@ -1082,10 +1054,10 @@ describe("WorkflowEngine", () => {
|
|
|
1082
1054
|
}
|
|
1083
1055
|
});
|
|
1084
1056
|
|
|
1085
|
-
it(
|
|
1057
|
+
it('should skip runs for unregistered workflows', async () => {
|
|
1086
1058
|
await storage.createRun({
|
|
1087
|
-
kind:
|
|
1088
|
-
status:
|
|
1059
|
+
kind: 'unregistered.workflow',
|
|
1060
|
+
status: 'running',
|
|
1089
1061
|
input: {},
|
|
1090
1062
|
metadata: {},
|
|
1091
1063
|
context: {},
|
|
@@ -1096,86 +1068,62 @@ describe("WorkflowEngine", () => {
|
|
|
1096
1068
|
});
|
|
1097
1069
|
});
|
|
1098
1070
|
|
|
1099
|
-
describe(
|
|
1100
|
-
it(
|
|
1101
|
-
await storage.createRun({
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
metadata: {},
|
|
1106
|
-
context: {},
|
|
1107
|
-
});
|
|
1108
|
-
await storage.createRun({
|
|
1109
|
-
kind: "a",
|
|
1110
|
-
status: "running",
|
|
1111
|
-
input: {},
|
|
1112
|
-
metadata: {},
|
|
1113
|
-
context: {},
|
|
1114
|
-
});
|
|
1115
|
-
await storage.createRun({
|
|
1116
|
-
kind: "a",
|
|
1117
|
-
status: "succeeded",
|
|
1118
|
-
input: {},
|
|
1119
|
-
metadata: {},
|
|
1120
|
-
context: {},
|
|
1121
|
-
});
|
|
1122
|
-
await storage.createRun({
|
|
1123
|
-
kind: "a",
|
|
1124
|
-
status: "failed",
|
|
1125
|
-
input: {},
|
|
1126
|
-
metadata: {},
|
|
1127
|
-
context: {},
|
|
1128
|
-
});
|
|
1071
|
+
describe('getResumableRuns', () => {
|
|
1072
|
+
it('should return only queued and running runs', async () => {
|
|
1073
|
+
await storage.createRun({ kind: 'a', status: 'queued', input: {}, metadata: {}, context: {} });
|
|
1074
|
+
await storage.createRun({ kind: 'a', status: 'running', input: {}, metadata: {}, context: {} });
|
|
1075
|
+
await storage.createRun({ kind: 'a', status: 'succeeded', input: {}, metadata: {}, context: {} });
|
|
1076
|
+
await storage.createRun({ kind: 'a', status: 'failed', input: {}, metadata: {}, context: {} });
|
|
1129
1077
|
|
|
1130
1078
|
const resumable = await engine.getResumableRuns();
|
|
1131
1079
|
expect(resumable).toHaveLength(2);
|
|
1132
|
-
expect(
|
|
1133
|
-
resumable.every((r) => ["queued", "running"].includes(r.status)),
|
|
1134
|
-
).toBe(true);
|
|
1080
|
+
expect(resumable.every(r => ['queued', 'running'].includes(r.status))).toBe(true);
|
|
1135
1081
|
});
|
|
1136
1082
|
});
|
|
1137
1083
|
|
|
1138
|
-
describe(
|
|
1139
|
-
it(
|
|
1084
|
+
describe('delayed execution', () => {
|
|
1085
|
+
it('should delay workflow start', async () => {
|
|
1140
1086
|
engine.registerWorkflow({
|
|
1141
|
-
kind:
|
|
1142
|
-
name:
|
|
1087
|
+
kind: 'test.delay',
|
|
1088
|
+
name: 'Test Delay',
|
|
1143
1089
|
steps: [
|
|
1144
1090
|
{
|
|
1145
|
-
key:
|
|
1146
|
-
name:
|
|
1147
|
-
handler: async () =>
|
|
1091
|
+
key: 'step1',
|
|
1092
|
+
name: 'Step 1',
|
|
1093
|
+
handler: async () => 'done',
|
|
1148
1094
|
},
|
|
1149
1095
|
],
|
|
1150
1096
|
});
|
|
1151
1097
|
|
|
1152
1098
|
const start = Date.now();
|
|
1153
|
-
const runId = await engine.startRun({ kind:
|
|
1099
|
+
const runId = await engine.startRun({ kind: 'test.delay', delay: 100 });
|
|
1154
1100
|
const run = await engine.waitForRun(runId, { timeout: 2000 });
|
|
1155
1101
|
|
|
1156
|
-
expect(run.status).toBe(
|
|
1102
|
+
expect(run.status).toBe('succeeded');
|
|
1157
1103
|
expect(Date.now() - start).toBeGreaterThanOrEqual(90);
|
|
1158
1104
|
});
|
|
1159
1105
|
});
|
|
1160
1106
|
|
|
1161
|
-
describe(
|
|
1162
|
-
it(
|
|
1107
|
+
describe('getStorage / getEvents', () => {
|
|
1108
|
+
it('should return storage and events adapters', () => {
|
|
1163
1109
|
expect(engine.getStorage()).toBe(storage);
|
|
1164
1110
|
expect(engine.getEvents()).toBe(events);
|
|
1165
1111
|
});
|
|
1166
1112
|
});
|
|
1167
1113
|
|
|
1168
|
-
describe(
|
|
1169
|
-
it(
|
|
1114
|
+
describe('subscribeToRun', () => {
|
|
1115
|
+
it('should receive events for specific run', async () => {
|
|
1170
1116
|
const receivedEvents: WorkflowEvent[] = [];
|
|
1171
1117
|
|
|
1172
1118
|
engine.registerWorkflow({
|
|
1173
|
-
kind:
|
|
1174
|
-
name:
|
|
1175
|
-
steps: [
|
|
1119
|
+
kind: 'test.sub',
|
|
1120
|
+
name: 'Test',
|
|
1121
|
+
steps: [
|
|
1122
|
+
{ key: 's1', name: 'S1', handler: async () => 'done' },
|
|
1123
|
+
],
|
|
1176
1124
|
});
|
|
1177
1125
|
|
|
1178
|
-
const runId = await engine.startRun({ kind:
|
|
1126
|
+
const runId = await engine.startRun({ kind: 'test.sub' });
|
|
1179
1127
|
const unsub = engine.subscribeToRun(runId, (event) => {
|
|
1180
1128
|
receivedEvents.push(event);
|
|
1181
1129
|
});
|
|
@@ -1184,19 +1132,19 @@ describe("WorkflowEngine", () => {
|
|
|
1184
1132
|
unsub();
|
|
1185
1133
|
|
|
1186
1134
|
expect(receivedEvents.length).toBeGreaterThan(0);
|
|
1187
|
-
expect(receivedEvents.every(
|
|
1135
|
+
expect(receivedEvents.every(e => e.runId === runId)).toBe(true);
|
|
1188
1136
|
});
|
|
1189
1137
|
});
|
|
1190
1138
|
|
|
1191
|
-
describe(
|
|
1192
|
-
it(
|
|
1139
|
+
describe('default constructor', () => {
|
|
1140
|
+
it('should use defaults when no config is provided', () => {
|
|
1193
1141
|
const defaultEngine = new WorkflowEngine();
|
|
1194
1142
|
expect(defaultEngine.getStorage()).toBeInstanceOf(MemoryStorageAdapter);
|
|
1195
1143
|
});
|
|
1196
1144
|
});
|
|
1197
1145
|
|
|
1198
|
-
describe(
|
|
1199
|
-
it(
|
|
1146
|
+
describe('priority queues', () => {
|
|
1147
|
+
it('should process high priority runs first', async () => {
|
|
1200
1148
|
const completionOrder: string[] = [];
|
|
1201
1149
|
|
|
1202
1150
|
const priorityEngine = new WorkflowEngine({
|
|
@@ -1209,15 +1157,15 @@ describe("WorkflowEngine", () => {
|
|
|
1209
1157
|
});
|
|
1210
1158
|
|
|
1211
1159
|
priorityEngine.registerWorkflow({
|
|
1212
|
-
kind:
|
|
1213
|
-
name:
|
|
1160
|
+
kind: 'test.priority',
|
|
1161
|
+
name: 'Test Priority',
|
|
1214
1162
|
steps: [
|
|
1215
1163
|
{
|
|
1216
|
-
key:
|
|
1217
|
-
name:
|
|
1164
|
+
key: 'step1',
|
|
1165
|
+
name: 'Step 1',
|
|
1218
1166
|
handler: async (ctx) => {
|
|
1219
1167
|
completionOrder.push(ctx.input.id as string);
|
|
1220
|
-
return
|
|
1168
|
+
return 'done';
|
|
1221
1169
|
},
|
|
1222
1170
|
},
|
|
1223
1171
|
],
|
|
@@ -1225,28 +1173,28 @@ describe("WorkflowEngine", () => {
|
|
|
1225
1173
|
|
|
1226
1174
|
// Start first run (occupies the single slot)
|
|
1227
1175
|
const firstId = await priorityEngine.startRun({
|
|
1228
|
-
kind:
|
|
1229
|
-
input: { id:
|
|
1176
|
+
kind: 'test.priority',
|
|
1177
|
+
input: { id: 'first' },
|
|
1230
1178
|
priority: 0,
|
|
1231
1179
|
});
|
|
1232
1180
|
|
|
1233
1181
|
// Queue 3 runs with different priorities
|
|
1234
1182
|
// Low priority first, then high priority
|
|
1235
1183
|
const lowId = await priorityEngine.startRun({
|
|
1236
|
-
kind:
|
|
1237
|
-
input: { id:
|
|
1184
|
+
kind: 'test.priority',
|
|
1185
|
+
input: { id: 'low' },
|
|
1238
1186
|
priority: 0,
|
|
1239
1187
|
});
|
|
1240
1188
|
|
|
1241
1189
|
const highId = await priorityEngine.startRun({
|
|
1242
|
-
kind:
|
|
1243
|
-
input: { id:
|
|
1190
|
+
kind: 'test.priority',
|
|
1191
|
+
input: { id: 'high' },
|
|
1244
1192
|
priority: 10,
|
|
1245
1193
|
});
|
|
1246
1194
|
|
|
1247
1195
|
const mediumId = await priorityEngine.startRun({
|
|
1248
|
-
kind:
|
|
1249
|
-
input: { id:
|
|
1196
|
+
kind: 'test.priority',
|
|
1197
|
+
input: { id: 'medium' },
|
|
1250
1198
|
priority: 5,
|
|
1251
1199
|
});
|
|
1252
1200
|
|
|
@@ -1260,12 +1208,12 @@ describe("WorkflowEngine", () => {
|
|
|
1260
1208
|
|
|
1261
1209
|
// First should complete first (already running)
|
|
1262
1210
|
// Then high priority, medium, low
|
|
1263
|
-
expect(completionOrder).toEqual([
|
|
1211
|
+
expect(completionOrder).toEqual(['first', 'high', 'medium', 'low']);
|
|
1264
1212
|
|
|
1265
1213
|
await priorityEngine.shutdown();
|
|
1266
1214
|
});
|
|
1267
1215
|
|
|
1268
|
-
it(
|
|
1216
|
+
it('should emit run.queued and run.dequeued events', async () => {
|
|
1269
1217
|
const queuedEvents: WorkflowEvent[] = [];
|
|
1270
1218
|
const dequeuedEvents: WorkflowEvent[] = [];
|
|
1271
1219
|
|
|
@@ -1279,15 +1227,15 @@ describe("WorkflowEngine", () => {
|
|
|
1279
1227
|
});
|
|
1280
1228
|
|
|
1281
1229
|
priorityEngine.registerWorkflow({
|
|
1282
|
-
kind:
|
|
1283
|
-
name:
|
|
1230
|
+
kind: 'test.queue.events',
|
|
1231
|
+
name: 'Test Queue Events',
|
|
1284
1232
|
steps: [
|
|
1285
1233
|
{
|
|
1286
|
-
key:
|
|
1287
|
-
name:
|
|
1234
|
+
key: 'step1',
|
|
1235
|
+
name: 'Step 1',
|
|
1288
1236
|
handler: async () => {
|
|
1289
|
-
await new Promise(
|
|
1290
|
-
return
|
|
1237
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
1238
|
+
return 'done';
|
|
1291
1239
|
},
|
|
1292
1240
|
},
|
|
1293
1241
|
],
|
|
@@ -1295,21 +1243,17 @@ describe("WorkflowEngine", () => {
|
|
|
1295
1243
|
|
|
1296
1244
|
// Subscribe to all events
|
|
1297
1245
|
priorityEngine.subscribeToAll((event) => {
|
|
1298
|
-
if (event.eventType ===
|
|
1246
|
+
if (event.eventType === 'run.queued') {
|
|
1299
1247
|
queuedEvents.push(event);
|
|
1300
1248
|
}
|
|
1301
|
-
if (event.eventType ===
|
|
1249
|
+
if (event.eventType === 'run.dequeued') {
|
|
1302
1250
|
dequeuedEvents.push(event);
|
|
1303
1251
|
}
|
|
1304
1252
|
});
|
|
1305
1253
|
|
|
1306
1254
|
// Start 2 runs (first will run, second will queue)
|
|
1307
|
-
const firstId = await priorityEngine.startRun({
|
|
1308
|
-
|
|
1309
|
-
});
|
|
1310
|
-
const secondId = await priorityEngine.startRun({
|
|
1311
|
-
kind: "test.queue.events",
|
|
1312
|
-
});
|
|
1255
|
+
const firstId = await priorityEngine.startRun({ kind: 'test.queue.events' });
|
|
1256
|
+
const secondId = await priorityEngine.startRun({ kind: 'test.queue.events' });
|
|
1313
1257
|
|
|
1314
1258
|
// Wait for both to complete
|
|
1315
1259
|
await Promise.all([
|
|
@@ -1327,95 +1271,165 @@ describe("WorkflowEngine", () => {
|
|
|
1327
1271
|
});
|
|
1328
1272
|
});
|
|
1329
1273
|
|
|
1330
|
-
describe(
|
|
1331
|
-
it(
|
|
1274
|
+
describe('processQueue resilience (audit 2026-04-27 C3)', () => {
|
|
1275
|
+
it('continues processing remaining queued items when one item fails to dispatch', async () => {
|
|
1276
|
+
// Drive a dispatch failure by injecting an event transport whose
|
|
1277
|
+
// run.dequeued emit throws once. Pre-fix, the throw escaped processQueue
|
|
1278
|
+
// and the remaining queued items were stranded; post-fix, the loop logs,
|
|
1279
|
+
// records telemetry, and advances to the next item.
|
|
1280
|
+
const baseEvents = new MemoryEventTransport();
|
|
1281
|
+
let dequeueCount = 0;
|
|
1282
|
+
const flakyEvents: typeof baseEvents = Object.create(baseEvents);
|
|
1283
|
+
flakyEvents.emit = (event) => {
|
|
1284
|
+
if (event.eventType === 'run.dequeued') {
|
|
1285
|
+
dequeueCount++;
|
|
1286
|
+
if (dequeueCount === 1) {
|
|
1287
|
+
throw new Error('boom: dequeue handler exploded');
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
return baseEvents.emit.call(baseEvents, event);
|
|
1291
|
+
};
|
|
1292
|
+
flakyEvents.subscribe = baseEvents.subscribe.bind(baseEvents);
|
|
1293
|
+
flakyEvents.subscribeAll = baseEvents.subscribeAll.bind(baseEvents);
|
|
1294
|
+
|
|
1295
|
+
const customStorage = new MemoryStorageAdapter();
|
|
1296
|
+
const eng = new WorkflowEngine({
|
|
1297
|
+
storage: customStorage,
|
|
1298
|
+
events: flakyEvents,
|
|
1299
|
+
logger: new SilentLogger(),
|
|
1300
|
+
settings: { maxConcurrency: 1 },
|
|
1301
|
+
});
|
|
1302
|
+
|
|
1303
|
+
const completionOrder: string[] = [];
|
|
1304
|
+
eng.registerWorkflow({
|
|
1305
|
+
kind: 'test.queue.resilient',
|
|
1306
|
+
name: 'Test Queue Resilient',
|
|
1307
|
+
steps: [
|
|
1308
|
+
{
|
|
1309
|
+
key: 's1',
|
|
1310
|
+
name: 'S1',
|
|
1311
|
+
handler: async (ctx) => {
|
|
1312
|
+
completionOrder.push(ctx.input.id as string);
|
|
1313
|
+
return 'done';
|
|
1314
|
+
},
|
|
1315
|
+
},
|
|
1316
|
+
],
|
|
1317
|
+
});
|
|
1318
|
+
|
|
1319
|
+
// First runs immediately (no dequeue event emitted for it).
|
|
1320
|
+
const firstId = await eng.startRun({ kind: 'test.queue.resilient', input: { id: 'first' } });
|
|
1321
|
+
// These two get queued behind it; their run.dequeued events fire when
|
|
1322
|
+
// the queue advances.
|
|
1323
|
+
const secondId = await eng.startRun({ kind: 'test.queue.resilient', input: { id: 'second' } });
|
|
1324
|
+
const thirdId = await eng.startRun({ kind: 'test.queue.resilient', input: { id: 'third' } });
|
|
1325
|
+
|
|
1326
|
+
// First and third must complete; second's dispatch fails and is recorded
|
|
1327
|
+
// as failed instead of stranding the queue.
|
|
1328
|
+
await eng.waitForRun(firstId, { timeout: 2000 });
|
|
1329
|
+
await eng.waitForRun(thirdId, { timeout: 2000 });
|
|
1330
|
+
|
|
1331
|
+
expect(completionOrder).toContain('first');
|
|
1332
|
+
expect(completionOrder).toContain('third');
|
|
1333
|
+
expect(completionOrder).not.toContain('second');
|
|
1334
|
+
|
|
1335
|
+
// Telemetry: the dropped run should be marked failed in storage.
|
|
1336
|
+
const secondRun = await customStorage.getRun(secondId);
|
|
1337
|
+
expect(secondRun?.status).toBe('failed');
|
|
1338
|
+
expect(secondRun?.error?.code).toBe('QUEUE_DISPATCH_FAILED');
|
|
1339
|
+
|
|
1340
|
+
await eng.shutdown();
|
|
1341
|
+
});
|
|
1342
|
+
});
|
|
1343
|
+
|
|
1344
|
+
describe('orchestrator: raceWithAbort pre-aborted signal', () => {
|
|
1345
|
+
it('should throw WorkflowCanceledError immediately when signal is already aborted', async () => {
|
|
1332
1346
|
engine.registerWorkflow({
|
|
1333
|
-
kind:
|
|
1334
|
-
name:
|
|
1347
|
+
kind: 'test.preabort',
|
|
1348
|
+
name: 'Test Pre-Abort',
|
|
1335
1349
|
steps: [
|
|
1336
1350
|
{
|
|
1337
|
-
key:
|
|
1338
|
-
name:
|
|
1351
|
+
key: 'step1',
|
|
1352
|
+
name: 'Step 1',
|
|
1339
1353
|
handler: async () => {
|
|
1340
|
-
await new Promise(
|
|
1341
|
-
return
|
|
1354
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
1355
|
+
return 'done';
|
|
1342
1356
|
},
|
|
1343
1357
|
},
|
|
1344
1358
|
],
|
|
1345
1359
|
});
|
|
1346
1360
|
|
|
1347
|
-
const runId = await engine.startRun({ kind:
|
|
1361
|
+
const runId = await engine.startRun({ kind: 'test.preabort' });
|
|
1348
1362
|
|
|
1349
1363
|
// Cancel immediately (before step starts executing in the microtask)
|
|
1350
1364
|
engine.cancelRun(runId);
|
|
1351
1365
|
|
|
1352
1366
|
const run = await engine.waitForRun(runId, { timeout: 2000 });
|
|
1353
|
-
expect(run.status).toBe(
|
|
1367
|
+
expect(run.status).toBe('canceled');
|
|
1354
1368
|
});
|
|
1355
1369
|
});
|
|
1356
1370
|
|
|
1357
|
-
describe(
|
|
1358
|
-
it(
|
|
1371
|
+
describe('orchestrator: afterRun hook error swallowing', () => {
|
|
1372
|
+
it('should swallow afterRun hook error on failure path and still return failed result', async () => {
|
|
1359
1373
|
engine.registerWorkflow({
|
|
1360
|
-
kind:
|
|
1361
|
-
name:
|
|
1374
|
+
kind: 'test.hook.afterrun.error',
|
|
1375
|
+
name: 'Test afterRun Hook Error',
|
|
1362
1376
|
steps: [
|
|
1363
1377
|
{
|
|
1364
|
-
key:
|
|
1365
|
-
name:
|
|
1378
|
+
key: 'fail-step',
|
|
1379
|
+
name: 'Failing Step',
|
|
1366
1380
|
handler: async () => {
|
|
1367
|
-
throw new Error(
|
|
1381
|
+
throw new Error('Step failure');
|
|
1368
1382
|
},
|
|
1369
1383
|
},
|
|
1370
1384
|
],
|
|
1371
1385
|
hooks: {
|
|
1372
1386
|
afterRun: async () => {
|
|
1373
|
-
throw new Error(
|
|
1387
|
+
throw new Error('afterRun hook exploded');
|
|
1374
1388
|
},
|
|
1375
1389
|
},
|
|
1376
1390
|
});
|
|
1377
1391
|
|
|
1378
|
-
const runId = await engine.startRun({ kind:
|
|
1392
|
+
const runId = await engine.startRun({ kind: 'test.hook.afterrun.error' });
|
|
1379
1393
|
const run = await engine.waitForRun(runId, { timeout: 2000 });
|
|
1380
1394
|
|
|
1381
1395
|
// The workflow should fail with the original step error, not the hook error
|
|
1382
|
-
expect(run.status).toBe(
|
|
1383
|
-
expect(run.error?.message).toContain(
|
|
1396
|
+
expect(run.status).toBe('failed');
|
|
1397
|
+
expect(run.error?.message).toContain('Step failure');
|
|
1384
1398
|
});
|
|
1385
1399
|
});
|
|
1386
1400
|
|
|
1387
|
-
describe(
|
|
1388
|
-
it(
|
|
1401
|
+
describe('orchestrator: executeWithTimeout abort-during-step', () => {
|
|
1402
|
+
it('should cancel a step with per-step timeout when workflow-level abort fires', async () => {
|
|
1389
1403
|
let stepStarted = false;
|
|
1390
1404
|
|
|
1391
1405
|
engine.registerWorkflow({
|
|
1392
|
-
kind:
|
|
1393
|
-
name:
|
|
1406
|
+
kind: 'test.timeout.abort',
|
|
1407
|
+
name: 'Test Timeout Abort',
|
|
1394
1408
|
steps: [
|
|
1395
1409
|
{
|
|
1396
|
-
key:
|
|
1397
|
-
name:
|
|
1410
|
+
key: 'long-step',
|
|
1411
|
+
name: 'Long Step',
|
|
1398
1412
|
timeout: 60000, // 60s per-step timeout
|
|
1399
1413
|
handler: async () => {
|
|
1400
1414
|
stepStarted = true;
|
|
1401
1415
|
// This step takes a long time
|
|
1402
|
-
await new Promise(
|
|
1403
|
-
return
|
|
1416
|
+
await new Promise(resolve => setTimeout(resolve, 10000));
|
|
1417
|
+
return 'done';
|
|
1404
1418
|
},
|
|
1405
1419
|
},
|
|
1406
1420
|
],
|
|
1407
1421
|
});
|
|
1408
1422
|
|
|
1409
|
-
const runId = await engine.startRun({ kind:
|
|
1423
|
+
const runId = await engine.startRun({ kind: 'test.timeout.abort' });
|
|
1410
1424
|
|
|
1411
1425
|
// Wait until the step has started, then cancel
|
|
1412
|
-
await new Promise(
|
|
1426
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
1413
1427
|
expect(stepStarted).toBe(true);
|
|
1414
1428
|
|
|
1415
1429
|
engine.cancelRun(runId);
|
|
1416
1430
|
|
|
1417
1431
|
const run = await engine.waitForRun(runId, { timeout: 2000 });
|
|
1418
|
-
expect(run.status).toBe(
|
|
1432
|
+
expect(run.status).toBe('canceled');
|
|
1419
1433
|
});
|
|
1420
1434
|
});
|
|
1421
1435
|
});
|