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