openworkflow 0.6.0 → 0.6.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.
Files changed (97) hide show
  1. package/README.md +2 -0
  2. package/dist/bin/openworkflow.js +0 -0
  3. package/dist/internal.d.ts +0 -1
  4. package/dist/internal.d.ts.map +1 -1
  5. package/dist/internal.js +0 -1
  6. package/package.json +6 -2
  7. package/dist/backend-test/backend.testsuite.d.ts +0 -20
  8. package/dist/backend-test/backend.testsuite.d.ts.map +0 -1
  9. package/dist/backend-test/backend.testsuite.js +0 -1090
  10. package/dist/backend-test/index.d.ts +0 -2
  11. package/dist/backend-test/index.d.ts.map +0 -1
  12. package/dist/backend-test/index.js +0 -1
  13. package/dist/backend.testsuite.d.ts +0 -20
  14. package/dist/backend.testsuite.d.ts.map +0 -1
  15. package/dist/backend.testsuite.js +0 -1090
  16. package/dist/chaos.test.d.ts +0 -2
  17. package/dist/chaos.test.d.ts.map +0 -1
  18. package/dist/chaos.test.js +0 -88
  19. package/dist/client.test.d.ts +0 -2
  20. package/dist/client.test.d.ts.map +0 -1
  21. package/dist/client.test.js +0 -311
  22. package/dist/core/duration.test.d.ts +0 -2
  23. package/dist/core/duration.test.d.ts.map +0 -1
  24. package/dist/core/duration.test.js +0 -263
  25. package/dist/core/error.test.d.ts +0 -2
  26. package/dist/core/error.test.d.ts.map +0 -1
  27. package/dist/core/error.test.js +0 -60
  28. package/dist/core/result.test.d.ts +0 -2
  29. package/dist/core/result.test.d.ts.map +0 -1
  30. package/dist/core/result.test.js +0 -11
  31. package/dist/core/step.test.d.ts +0 -2
  32. package/dist/core/step.test.d.ts.map +0 -1
  33. package/dist/core/step.test.js +0 -266
  34. package/dist/core/workflow.test.d.ts +0 -2
  35. package/dist/core/workflow.test.d.ts.map +0 -1
  36. package/dist/core/workflow.test.js +0 -113
  37. package/dist/driver.d.ts +0 -116
  38. package/dist/driver.d.ts.map +0 -1
  39. package/dist/driver.js +0 -1
  40. package/dist/execution.test.d.ts +0 -2
  41. package/dist/execution.test.d.ts.map +0 -1
  42. package/dist/execution.test.js +0 -381
  43. package/dist/factory.d.ts +0 -74
  44. package/dist/factory.d.ts.map +0 -1
  45. package/dist/factory.js +0 -72
  46. package/dist/node-sqlite/backend.d.ts +0 -52
  47. package/dist/node-sqlite/backend.d.ts.map +0 -1
  48. package/dist/node-sqlite/backend.js +0 -673
  49. package/dist/node-sqlite/index.d.ts +0 -11
  50. package/dist/node-sqlite/index.d.ts.map +0 -1
  51. package/dist/node-sqlite/index.js +0 -7
  52. package/dist/node-sqlite/sqlite.d.ts +0 -60
  53. package/dist/node-sqlite/sqlite.d.ts.map +0 -1
  54. package/dist/node-sqlite/sqlite.js +0 -246
  55. package/dist/postgres/backend.test.d.ts +0 -2
  56. package/dist/postgres/backend.test.d.ts.map +0 -1
  57. package/dist/postgres/backend.test.js +0 -19
  58. package/dist/postgres/driver.d.ts +0 -81
  59. package/dist/postgres/driver.d.ts.map +0 -1
  60. package/dist/postgres/driver.js +0 -63
  61. package/dist/postgres/index.d.ts +0 -11
  62. package/dist/postgres/index.d.ts.map +0 -1
  63. package/dist/postgres/index.js +0 -7
  64. package/dist/postgres/internal.d.ts +0 -2
  65. package/dist/postgres/internal.d.ts.map +0 -1
  66. package/dist/postgres/internal.js +0 -1
  67. package/dist/postgres/postgres.test.d.ts +0 -2
  68. package/dist/postgres/postgres.test.d.ts.map +0 -1
  69. package/dist/postgres/postgres.test.js +0 -45
  70. package/dist/postgres/vitest.global-setup.d.ts +0 -3
  71. package/dist/postgres/vitest.global-setup.d.ts.map +0 -1
  72. package/dist/postgres/vitest.global-setup.js +0 -7
  73. package/dist/registry.test.d.ts +0 -2
  74. package/dist/registry.test.d.ts.map +0 -1
  75. package/dist/registry.test.js +0 -109
  76. package/dist/sqlite/backend.test.d.ts +0 -2
  77. package/dist/sqlite/backend.test.d.ts.map +0 -1
  78. package/dist/sqlite/backend.test.js +0 -50
  79. package/dist/sqlite/driver.d.ts +0 -79
  80. package/dist/sqlite/driver.d.ts.map +0 -1
  81. package/dist/sqlite/driver.js +0 -62
  82. package/dist/sqlite/index.d.ts +0 -13
  83. package/dist/sqlite/index.d.ts.map +0 -1
  84. package/dist/sqlite/index.js +0 -11
  85. package/dist/sqlite/internal.d.ts +0 -2
  86. package/dist/sqlite/internal.d.ts.map +0 -1
  87. package/dist/sqlite/internal.js +0 -1
  88. package/dist/sqlite/sqlite.test.d.ts +0 -2
  89. package/dist/sqlite/sqlite.test.d.ts.map +0 -1
  90. package/dist/sqlite/sqlite.test.js +0 -171
  91. package/dist/tsconfig.tsbuildinfo +0 -1
  92. package/dist/worker.test.d.ts +0 -2
  93. package/dist/worker.test.d.ts.map +0 -1
  94. package/dist/worker.test.js +0 -900
  95. package/dist/workflow.test.d.ts +0 -2
  96. package/dist/workflow.test.d.ts.map +0 -1
  97. package/dist/workflow.test.js +0 -84
@@ -1,900 +0,0 @@
1
- import { OpenWorkflow } from "./client.js";
2
- import { BackendPostgres } from "./postgres.js";
3
- import { DEFAULT_POSTGRES_URL } from "./postgres/postgres.js";
4
- import { defineWorkflowSpec } from "./workflow.js";
5
- import { randomUUID } from "node:crypto";
6
- import { describe, expect, test } from "vitest";
7
- describe("Worker", () => {
8
- test("passes workflow input to handlers", async () => {
9
- const backend = await createBackend();
10
- const client = new OpenWorkflow({ backend });
11
- const workflow = client.defineWorkflow({ name: "context" }, ({ input }) => input);
12
- const worker = client.newWorker();
13
- const payload = { value: 10 };
14
- const handle = await workflow.run(payload);
15
- await worker.tick();
16
- const result = await handle.result();
17
- expect(result).toEqual(payload);
18
- });
19
- test("processes workflow runs to completion", async () => {
20
- const backend = await createBackend();
21
- const client = new OpenWorkflow({ backend });
22
- const workflow = client.defineWorkflow({ name: "process" }, ({ input }) => input.value * 2);
23
- const worker = client.newWorker();
24
- const handle = await workflow.run({ value: 21 });
25
- await worker.tick();
26
- const result = await handle.result();
27
- expect(result).toBe(42);
28
- });
29
- test("step.run reuses cached results", async () => {
30
- const backend = await createBackend();
31
- const client = new OpenWorkflow({ backend });
32
- let executionCount = 0;
33
- const workflow = client.defineWorkflow({ name: "cached-step" }, async ({ step }) => {
34
- const first = await step.run({ name: "once" }, () => {
35
- executionCount++;
36
- return "value";
37
- });
38
- const second = await step.run({ name: "once" }, () => {
39
- executionCount++;
40
- return "should-not-run";
41
- });
42
- return { first, second };
43
- });
44
- const worker = client.newWorker();
45
- const handle = await workflow.run();
46
- await worker.tick();
47
- const result = await handle.result();
48
- expect(result).toEqual({ first: "value", second: "value" });
49
- expect(executionCount).toBe(1);
50
- });
51
- test("marks workflow for retry when definition is missing", async () => {
52
- const backend = await createBackend();
53
- const client = new OpenWorkflow({ backend });
54
- const workflowRun = await backend.createWorkflowRun({
55
- workflowName: "missing",
56
- version: null,
57
- idempotencyKey: null,
58
- config: {},
59
- context: null,
60
- input: null,
61
- availableAt: null,
62
- deadlineAt: null,
63
- });
64
- const worker = client.newWorker();
65
- await worker.tick();
66
- const updated = await backend.getWorkflowRun({
67
- workflowRunId: workflowRun.id,
68
- });
69
- expect(updated?.status).toBe("pending");
70
- expect(updated?.error).toBeDefined();
71
- expect(updated?.availableAt).not.toBeNull();
72
- });
73
- test("retries failed workflows automatically", async () => {
74
- const backend = await createBackend();
75
- const client = new OpenWorkflow({ backend });
76
- let attemptCount = 0;
77
- const workflow = client.defineWorkflow({ name: "retry-test" }, () => {
78
- attemptCount++;
79
- if (attemptCount < 2) {
80
- throw new Error(`Attempt ${String(attemptCount)} failed`);
81
- }
82
- return { success: true, attempts: attemptCount };
83
- });
84
- const worker = client.newWorker();
85
- // run the workflow
86
- const handle = await workflow.run();
87
- // first attempt - will fail and reschedule
88
- await worker.tick();
89
- await sleep(100); // wait for worker to finish
90
- expect(attemptCount).toBe(1);
91
- await sleep(1100); // wait for backoff delay
92
- // second attempt - will succeed
93
- await worker.tick();
94
- await sleep(100); // wait for worker to finish
95
- expect(attemptCount).toBe(2);
96
- const result = await handle.result();
97
- expect(result).toEqual({ success: true, attempts: 2 });
98
- });
99
- test("tick is a no-op when no work is available", async () => {
100
- const backend = await createBackend();
101
- const client = new OpenWorkflow({ backend });
102
- client.defineWorkflow({ name: "noop" }, () => null);
103
- const worker = client.newWorker();
104
- await worker.tick(); // no runs queued
105
- });
106
- test("handles step functions that return undefined", async () => {
107
- const backend = await createBackend();
108
- const client = new OpenWorkflow({ backend });
109
- const workflow = client.defineWorkflow({ name: "undefined-steps" }, async ({ step }) => {
110
- await step.run({ name: "step-1" }, () => {
111
- return; // explicit undefined
112
- });
113
- await step.run({ name: "step-2" }, () => {
114
- // implicit undefined
115
- });
116
- return { success: true };
117
- });
118
- const worker = client.newWorker();
119
- const handle = await workflow.run();
120
- await worker.tick();
121
- const result = await handle.result();
122
- expect(result).toEqual({ success: true });
123
- });
124
- test("executes steps synchronously within workflow", async () => {
125
- const backend = await createBackend();
126
- const client = new OpenWorkflow({ backend });
127
- const executionOrder = [];
128
- const workflow = client.defineWorkflow({ name: "sync-steps" }, async ({ step }) => {
129
- executionOrder.push("start");
130
- await step.run({ name: "step1" }, () => {
131
- executionOrder.push("step1");
132
- return 1;
133
- });
134
- executionOrder.push("between");
135
- await step.run({ name: "step2" }, () => {
136
- executionOrder.push("step2");
137
- return 2;
138
- });
139
- executionOrder.push("end");
140
- return executionOrder;
141
- });
142
- const worker = client.newWorker();
143
- const handle = await workflow.run();
144
- await worker.tick();
145
- const result = await handle.result();
146
- expect(result).toEqual(["start", "step1", "between", "step2", "end"]);
147
- });
148
- test("executes parallel steps with Promise.all", async () => {
149
- const backend = await createBackend();
150
- const client = new OpenWorkflow({ backend });
151
- const executionTimes = {};
152
- const workflow = client.defineWorkflow({ name: "parallel" }, async ({ step }) => {
153
- const start = Date.now();
154
- const [a, b, c] = await Promise.all([
155
- step.run({ name: "step-a" }, () => {
156
- executionTimes["step-a"] = Date.now() - start;
157
- return "a";
158
- }),
159
- step.run({ name: "step-b" }, () => {
160
- executionTimes["step-b"] = Date.now() - start;
161
- return "b";
162
- }),
163
- step.run({ name: "step-c" }, () => {
164
- executionTimes["step-c"] = Date.now() - start;
165
- return "c";
166
- }),
167
- ]);
168
- return { a, b, c };
169
- });
170
- const worker = client.newWorker();
171
- const handle = await workflow.run();
172
- await worker.tick();
173
- const result = await handle.result();
174
- expect(result).toEqual({ a: "a", b: "b", c: "c" });
175
- // steps should execute at roughly the same time (within 100ms)
176
- const times = Object.values(executionTimes);
177
- const maxTime = Math.max(...times);
178
- const minTime = Math.min(...times);
179
- expect(maxTime - minTime).toBeLessThan(100);
180
- });
181
- test("respects worker concurrency limit", async () => {
182
- const backend = await createBackend();
183
- const client = new OpenWorkflow({ backend });
184
- const workflow = client.defineWorkflow({ name: "concurrency-test" }, () => {
185
- return "done";
186
- });
187
- const worker = client.newWorker({ concurrency: 2 });
188
- // create 5 workflow runs, though only 2 (concurrency limit) should be
189
- // completed per tick
190
- const handles = await Promise.all([
191
- workflow.run(),
192
- workflow.run(),
193
- workflow.run(),
194
- workflow.run(),
195
- workflow.run(),
196
- ]);
197
- await worker.tick();
198
- await sleep(100);
199
- let completed = 0;
200
- for (const handle of handles) {
201
- const run = await backend.getWorkflowRun({
202
- workflowRunId: handle.workflowRun.id,
203
- });
204
- if (run?.status === "completed")
205
- completed++;
206
- }
207
- expect(completed).toBe(2);
208
- });
209
- test("worker starts, processes work, and stops gracefully", async () => {
210
- const backend = await createBackend();
211
- const client = new OpenWorkflow({ backend });
212
- const workflow = client.defineWorkflow({ name: "lifecycle" }, () => {
213
- return "complete";
214
- });
215
- const worker = client.newWorker();
216
- await worker.start();
217
- const handle = await workflow.run();
218
- await sleep(200);
219
- await worker.stop();
220
- const result = await handle.result();
221
- expect(result).toBe("complete");
222
- });
223
- test("recovers from crashes during parallel step execution", async () => {
224
- const backend = await createBackend();
225
- const client = new OpenWorkflow({ backend });
226
- let attemptCount = 0;
227
- const workflow = client.defineWorkflow({ name: "crash-recovery" }, async ({ step }) => {
228
- attemptCount++;
229
- const [a, b] = await Promise.all([
230
- step.run({ name: "step-a" }, () => {
231
- if (attemptCount > 1)
232
- return "x"; // should not happen since "a" will be cached
233
- return "a";
234
- }),
235
- step.run({ name: "step-b" }, () => {
236
- if (attemptCount === 1)
237
- throw new Error("Simulated crash");
238
- return "b";
239
- }),
240
- ]);
241
- return { a, b, attempts: attemptCount };
242
- });
243
- const worker = client.newWorker();
244
- const handle = await workflow.run();
245
- // first attempt will fail
246
- await worker.tick();
247
- await sleep(100);
248
- expect(attemptCount).toBe(1);
249
- // wait for backoff
250
- await sleep(1100);
251
- // second attempt should succeed
252
- await worker.tick();
253
- await sleep(100);
254
- const result = await handle.result();
255
- expect(result).toEqual({ a: "a", b: "b", attempts: 2 });
256
- expect(attemptCount).toBe(2);
257
- });
258
- test("reclaims workflow run when heartbeat stops", async () => {
259
- const backend = await createBackend();
260
- const client = new OpenWorkflow({ backend });
261
- const workflow = client.defineWorkflow({ name: "heartbeat-test" }, () => "done");
262
- const handle = await workflow.run();
263
- const workerId = randomUUID();
264
- const claimed = await backend.claimWorkflowRun({
265
- workerId,
266
- leaseDurationMs: 50,
267
- });
268
- expect(claimed).not.toBeNull();
269
- // let lease expire before starting worker
270
- await sleep(100);
271
- // worker should be able to reclaim
272
- const worker = client.newWorker();
273
- await worker.tick();
274
- const result = await handle.result();
275
- expect(result).toBe("done");
276
- });
277
- test("tick() returns count of claimed workflows", async () => {
278
- const backend = await createBackend();
279
- const client = new OpenWorkflow({ backend });
280
- const workflow = client.defineWorkflow({ name: "count-test" }, () => "result");
281
- // enqueue 3 workflows
282
- await workflow.run();
283
- await workflow.run();
284
- await workflow.run();
285
- const worker = client.newWorker({ concurrency: 5 });
286
- // first tick should claim 3 workflows (all available)
287
- const claimed = await worker.tick();
288
- expect(claimed).toBe(3);
289
- // second tick should claim 0 (all already claimed)
290
- const claimedAgain = await worker.tick();
291
- expect(claimedAgain).toBe(0);
292
- await worker.stop();
293
- });
294
- test("tick() respects concurrency limit", async () => {
295
- const backend = await createBackend();
296
- const client = new OpenWorkflow({ backend });
297
- const workflow = client.defineWorkflow({ name: "concurrency-test" }, async () => {
298
- await sleep(100);
299
- return "done";
300
- });
301
- // enqueue 10 workflows
302
- for (let i = 0; i < 10; i++) {
303
- await workflow.run();
304
- }
305
- const worker = client.newWorker({ concurrency: 3 });
306
- // first tick should claim exactly 3 (concurrency limit)
307
- const claimed = await worker.tick();
308
- expect(claimed).toBe(3);
309
- // second tick should claim 0 (all slots occupied)
310
- const claimedAgain = await worker.tick();
311
- expect(claimedAgain).toBe(0);
312
- await worker.stop();
313
- });
314
- test("worker only sleeps between claims when no work is available", async () => {
315
- const backend = await createBackend();
316
- const client = new OpenWorkflow({ backend });
317
- const workflow = client.defineWorkflow({ name: "adaptive-test" }, async ({ step }) => {
318
- await step.run({ name: "step-1" }, () => "done");
319
- return "complete";
320
- });
321
- // enqueue many workflows
322
- const handles = [];
323
- for (let i = 0; i < 20; i++) {
324
- handles.push(await workflow.run());
325
- }
326
- const worker = client.newWorker({ concurrency: 5 });
327
- const startTime = Date.now();
328
- await worker.start();
329
- // wait for all workflows to complete
330
- await Promise.all(handles.map((h) => h.result()));
331
- await worker.stop();
332
- const duration = Date.now() - startTime;
333
- // with this conditional sleep, all workflows should complete quickly
334
- // without it (with 100ms sleep between ticks), it would take much longer
335
- expect(duration).toBeLessThan(3000); // should complete in under 3 seconds
336
- });
337
- test("only failed steps re-execute on retry", async () => {
338
- const backend = await createBackend();
339
- const client = new OpenWorkflow({ backend });
340
- const executionCounts = {
341
- stepA: 0,
342
- stepB: 0,
343
- stepC: 0,
344
- };
345
- const workflow = client.defineWorkflow({ name: "mixed-retry" }, async ({ step }) => {
346
- const a = await step.run({ name: "step-a" }, () => {
347
- executionCounts.stepA++;
348
- return "a-result";
349
- });
350
- const b = await step.run({ name: "step-b" }, () => {
351
- executionCounts.stepB++;
352
- if (executionCounts.stepB === 1) {
353
- throw new Error("Step B fails on first attempt");
354
- }
355
- return "b-result";
356
- });
357
- const c = await step.run({ name: "step-c" }, () => {
358
- executionCounts.stepC++;
359
- return "c-result";
360
- });
361
- return { a, b, c };
362
- });
363
- const worker = client.newWorker();
364
- const handle = await workflow.run();
365
- // first workflow attempt
366
- // - step-a succeeds
367
- // - step-b fails
368
- // - step-c never runs (workflow fails at step-b)
369
- await worker.tick();
370
- await sleep(100);
371
- expect(executionCounts.stepA).toBe(1);
372
- expect(executionCounts.stepB).toBe(1);
373
- expect(executionCounts.stepC).toBe(0);
374
- // wait for backoff
375
- await sleep(1100);
376
- // second workflow attempt
377
- // - step-a should be cached (not re-executed)
378
- // - step-b should be re-executed (failed previously)
379
- // - step-c should execute for first time
380
- await worker.tick();
381
- await sleep(100);
382
- expect(executionCounts.stepA).toBe(1); // still 1, was cached
383
- expect(executionCounts.stepB).toBe(2); // incremented, was retried
384
- expect(executionCounts.stepC).toBe(1); // incremented, first execution
385
- const result = await handle.result();
386
- expect(result).toEqual({
387
- a: "a-result",
388
- b: "b-result",
389
- c: "c-result",
390
- });
391
- });
392
- test("step.sleep postpones workflow execution", async () => {
393
- const backend = await createBackend();
394
- const client = new OpenWorkflow({ backend });
395
- let stepCount = 0;
396
- const workflow = client.defineWorkflow({ name: "sleep-test" }, async ({ step }) => {
397
- const before = await step.run({ name: "before-sleep" }, () => {
398
- stepCount++;
399
- return "before";
400
- });
401
- await step.sleep("pause", "100ms");
402
- const after = await step.run({ name: "after-sleep" }, () => {
403
- stepCount++;
404
- return "after";
405
- });
406
- return { before, after };
407
- });
408
- const worker = client.newWorker();
409
- const handle = await workflow.run();
410
- // first execution - runs before-sleep, then sleeps
411
- await worker.tick();
412
- await sleep(50); // wait for processing
413
- expect(stepCount).toBe(1);
414
- // verify workflow was postponed with sleeping status
415
- const slept = await backend.getWorkflowRun({
416
- workflowRunId: handle.workflowRun.id,
417
- });
418
- expect(slept?.status).toBe("sleeping");
419
- expect(slept?.workerId).toBeNull(); // released during sleep
420
- expect(slept?.availableAt).not.toBeNull();
421
- if (!slept?.availableAt)
422
- throw new Error("availableAt should be set");
423
- const delayMs = slept.availableAt.getTime() - Date.now();
424
- expect(delayMs).toBeGreaterThan(0);
425
- expect(delayMs).toBeLessThan(150); // should be ~100ms
426
- // verify sleep step is in "running" state during sleep
427
- const attempts = await backend.listStepAttempts({
428
- workflowRunId: handle.workflowRun.id,
429
- });
430
- const sleepStep = attempts.data.find((a) => a.stepName === "pause");
431
- expect(sleepStep?.status).toBe("running");
432
- // wait for sleep duration
433
- await sleep(150);
434
- // second execution (after sleep)
435
- await worker.tick();
436
- await sleep(50); // wait for processing
437
- expect(stepCount).toBe(2);
438
- // verify sleep step is now "completed"
439
- const refreshedAttempts = await backend.listStepAttempts({
440
- workflowRunId: handle.workflowRun.id,
441
- });
442
- const completedSleepStep = refreshedAttempts.data.find((a) => a.stepName === "pause");
443
- expect(completedSleepStep?.status).toBe("completed");
444
- const result = await handle.result();
445
- expect(result).toEqual({ before: "before", after: "after" });
446
- });
447
- test("step.sleep is cached on replay", async () => {
448
- const backend = await createBackend();
449
- const client = new OpenWorkflow({ backend });
450
- let step1Count = 0;
451
- let step2Count = 0;
452
- const workflow = client.defineWorkflow({ name: "sleep-cache-test" }, async ({ step }) => {
453
- await step.run({ name: "step-1" }, () => {
454
- step1Count++;
455
- return "one";
456
- });
457
- // this should only postpone once
458
- await step.sleep("wait", "50ms");
459
- await step.run({ name: "step-2" }, () => {
460
- step2Count++;
461
- return "two";
462
- });
463
- return "done";
464
- });
465
- const worker = client.newWorker();
466
- const handle = await workflow.run();
467
- // first attempt: execute step-1, then sleep (step-2 not executed)
468
- await worker.tick();
469
- await sleep(50);
470
- expect(step1Count).toBe(1);
471
- expect(step2Count).toBe(0);
472
- await sleep(100); // wait for sleep to complete
473
- // second attempt: step-1 is cached (not re-executed), sleep is cached, step-2 executes
474
- await worker.tick();
475
- await sleep(50);
476
- expect(step1Count).toBe(1); // still 1, was cached
477
- expect(step2Count).toBe(1); // now 1, executed after sleep
478
- const result = await handle.result();
479
- expect(result).toBe("done");
480
- });
481
- test("step.sleep throws error for invalid duration format", async () => {
482
- const backend = await createBackend();
483
- const client = new OpenWorkflow({ backend });
484
- const workflow = client.defineWorkflow({ name: "invalid-duration" }, async ({ step }) => {
485
- // @ts-expect-error - testing invalid duration
486
- await step.sleep("bad", "invalid");
487
- return "should-not-reach";
488
- });
489
- const worker = client.newWorker();
490
- const handle = await workflow.run();
491
- await worker.tick();
492
- await sleep(100);
493
- const failed = await backend.getWorkflowRun({
494
- workflowRunId: handle.workflowRun.id,
495
- });
496
- expect(failed?.status).toBe("pending"); // should be retrying
497
- expect(failed?.error).toBeDefined();
498
- expect(failed?.error?.message).toContain("Invalid duration format");
499
- });
500
- test("step.sleep handles multiple sequential sleeps", async () => {
501
- const backend = await createBackend();
502
- const client = new OpenWorkflow({ backend });
503
- let executionCount = 0;
504
- const workflow = client.defineWorkflow({ name: "sequential-sleeps" }, async ({ step }) => {
505
- executionCount++;
506
- await step.run({ name: "step-1" }, () => "one");
507
- await step.sleep("sleep-1", "50ms");
508
- await step.run({ name: "step-2" }, () => "two");
509
- await step.sleep("sleep-2", "50ms");
510
- await step.run({ name: "step-3" }, () => "three");
511
- return "done";
512
- });
513
- const worker = client.newWorker();
514
- const handle = await workflow.run();
515
- // first execution: step-1, then sleep-1
516
- await worker.tick();
517
- await sleep(50);
518
- expect(executionCount).toBe(1);
519
- // verify first sleep is running
520
- const attempts1 = await backend.listStepAttempts({
521
- workflowRunId: handle.workflowRun.id,
522
- });
523
- expect(attempts1.data.find((a) => a.stepName === "sleep-1")?.status).toBe("running");
524
- // wait for first sleep
525
- await sleep(100);
526
- // second execution: sleep-1 completed, step-2, then sleep-2
527
- await worker.tick();
528
- await sleep(50);
529
- expect(executionCount).toBe(2);
530
- // verify second sleep is running
531
- const attempts2 = await backend.listStepAttempts({
532
- workflowRunId: handle.workflowRun.id,
533
- });
534
- expect(attempts2.data.find((a) => a.stepName === "sleep-1")?.status).toBe("completed");
535
- expect(attempts2.data.find((a) => a.stepName === "sleep-2")?.status).toBe("running");
536
- // wait for second sleep
537
- await sleep(100);
538
- // third execution: sleep-2 completed, step-3, complete
539
- await worker.tick();
540
- await sleep(50);
541
- expect(executionCount).toBe(3);
542
- const result = await handle.result();
543
- expect(result).toBe("done");
544
- // verify all steps completed
545
- const finalAttempts = await backend.listStepAttempts({
546
- workflowRunId: handle.workflowRun.id,
547
- });
548
- expect(finalAttempts.data.length).toBe(5); // 3 regular steps + 2 sleeps
549
- expect(finalAttempts.data.every((a) => a.status === "completed")).toBe(true);
550
- });
551
- test("sleeping workflows can be claimed after availableAt", async () => {
552
- const backend = await createBackend();
553
- const client = new OpenWorkflow({ backend });
554
- const workflow = client.defineWorkflow({ name: "sleeping-claim-test" }, async ({ step }) => {
555
- await step.run({ name: "before" }, () => "before");
556
- await step.sleep("wait", "100ms");
557
- await step.run({ name: "after" }, () => "after");
558
- return "done";
559
- });
560
- const worker = client.newWorker();
561
- const handle = await workflow.run();
562
- // first execution - sleep
563
- await worker.tick();
564
- await sleep(50);
565
- // verify workflow is in sleeping state
566
- const sleeping = await backend.getWorkflowRun({
567
- workflowRunId: handle.workflowRun.id,
568
- });
569
- expect(sleeping?.status).toBe("sleeping");
570
- expect(sleeping?.workerId).toBeNull();
571
- // wait for sleep duration
572
- await sleep(100);
573
- // verify workflow can be claimed again
574
- const claimed = await backend.claimWorkflowRun({
575
- workerId: "test-worker",
576
- leaseDurationMs: 30_000,
577
- });
578
- expect(claimed?.id).toBe(handle.workflowRun.id);
579
- expect(claimed?.status).toBe("running");
580
- expect(claimed?.workerId).toBe("test-worker");
581
- });
582
- test("sleep is not skipped when worker crashes after creating sleep step but before marking workflow as sleeping", async () => {
583
- const backend = await createBackend();
584
- const client = new OpenWorkflow({ backend });
585
- let executionCount = 0;
586
- let beforeSleepCount = 0;
587
- let afterSleepCount = 0;
588
- const workflow = client.defineWorkflow({ name: "crash-during-sleep" }, async ({ step }) => {
589
- executionCount++;
590
- await step.run({ name: "before-sleep" }, () => {
591
- beforeSleepCount++;
592
- return "before";
593
- });
594
- // this sleep should NOT be skipped even if crash happens
595
- await step.sleep("critical-pause", "200ms");
596
- await step.run({ name: "after-sleep" }, () => {
597
- afterSleepCount++;
598
- return "after";
599
- });
600
- return { executionCount, beforeSleepCount, afterSleepCount };
601
- });
602
- const handle = await workflow.run();
603
- // first worker processes the workflow until sleep
604
- const worker1 = client.newWorker();
605
- await worker1.tick();
606
- await sleep(100);
607
- const workflowAfterFirst = await backend.getWorkflowRun({
608
- workflowRunId: handle.workflowRun.id,
609
- });
610
- expect(workflowAfterFirst?.status).toBe("sleeping");
611
- const attemptsAfterFirst = await backend.listStepAttempts({
612
- workflowRunId: handle.workflowRun.id,
613
- });
614
- const sleepStep = attemptsAfterFirst.data.find((a) => a.stepName === "critical-pause");
615
- expect(sleepStep).toBeDefined();
616
- expect(sleepStep?.kind).toBe("sleep");
617
- expect(sleepStep?.status).toBe("running");
618
- await sleep(50); // only 50ms of the 200ms sleep
619
- // if there's a running sleep step, the workflow should be properly
620
- // transitioned to sleeping
621
- const worker2 = client.newWorker();
622
- await worker2.tick();
623
- // after-sleep step should NOT have executed yet
624
- expect(afterSleepCount).toBe(0);
625
- // wait for the full sleep duration to elapse then check to make sure
626
- // workflow is claimable and resume
627
- await sleep(200);
628
- await worker2.tick();
629
- await sleep(100);
630
- expect(afterSleepCount).toBe(1);
631
- const result = await handle.result();
632
- expect(result.afterSleepCount).toBe(1);
633
- });
634
- test("version enables conditional code paths", async () => {
635
- const backend = await createBackend();
636
- const client = new OpenWorkflow({ backend });
637
- const workflow = client.defineWorkflow({ name: "conditional-workflow", version: "v2" }, async ({ version, step }) => {
638
- return version === "v1"
639
- ? await step.run({ name: "old-step" }, () => "old-logic")
640
- : await step.run({ name: "new-step" }, () => "new-logic");
641
- });
642
- const worker = client.newWorker();
643
- const handle = await workflow.run();
644
- await worker.tick();
645
- const result = await handle.result();
646
- expect(result).toBe("new-logic");
647
- });
648
- test("workflow version is null when not specified", async () => {
649
- const backend = await createBackend();
650
- const client = new OpenWorkflow({ backend });
651
- const workflow = client.defineWorkflow({ name: "unversioned-workflow" }, async ({ version, step }) => {
652
- const result = await step.run({ name: "check-version" }, () => {
653
- return { version };
654
- });
655
- return result;
656
- });
657
- const worker = client.newWorker();
658
- const handle = await workflow.run();
659
- await worker.tick();
660
- const result = await handle.result();
661
- expect(result.version).toBeNull();
662
- });
663
- test("cancels a pending workflow", async () => {
664
- const backend = await createBackend();
665
- const client = new OpenWorkflow({ backend });
666
- const workflow = client.defineWorkflow({ name: "cancel-pending" }, async ({ step }) => {
667
- await step.run({ name: "step-1" }, () => "result");
668
- return { completed: true };
669
- });
670
- const handle = await workflow.run();
671
- // cancel before worker processes it
672
- await handle.cancel();
673
- const workflowRun = await backend.getWorkflowRun({
674
- workflowRunId: handle.workflowRun.id,
675
- });
676
- expect(workflowRun?.status).toBe("canceled");
677
- expect(workflowRun?.finishedAt).not.toBeNull();
678
- expect(workflowRun?.availableAt).toBeNull();
679
- expect(workflowRun?.workerId).toBeNull();
680
- });
681
- test("cancels a sleeping workflow", async () => {
682
- const backend = await createBackend();
683
- const client = new OpenWorkflow({ backend });
684
- const workflow = client.defineWorkflow({ name: "cancel-sleeping" }, async ({ step }) => {
685
- await step.sleep("sleep-1", "1h");
686
- return { completed: true };
687
- });
688
- const worker = client.newWorker();
689
- const handle = await workflow.run();
690
- await worker.tick();
691
- // cancel while sleeping
692
- await handle.cancel();
693
- const canceled = await backend.getWorkflowRun({
694
- workflowRunId: handle.workflowRun.id,
695
- });
696
- expect(canceled?.status).toBe("canceled");
697
- expect(canceled?.finishedAt).not.toBeNull();
698
- expect(canceled?.availableAt).toBeNull();
699
- expect(canceled?.workerId).toBeNull();
700
- });
701
- test("cannot cancel a completed workflow", async () => {
702
- const backend = await createBackend();
703
- const client = new OpenWorkflow({ backend });
704
- const workflow = client.defineWorkflow({ name: "cancel-completed" }, () => ({ completed: true }));
705
- const worker = client.newWorker();
706
- const handle = await workflow.run();
707
- await worker.tick();
708
- const result = await handle.result();
709
- expect(result.completed).toBe(true);
710
- // try to cancel after success
711
- await expect(handle.cancel()).rejects.toThrow(/Cannot cancel workflow run .* with status completed/);
712
- });
713
- test("cannot cancel a failed workflow", async () => {
714
- const backend = await createBackend();
715
- const client = new OpenWorkflow({ backend });
716
- const workflow = client.defineWorkflow({ name: "cancel-failed" }, () => {
717
- throw new Error("intentional failure");
718
- });
719
- const worker = client.newWorker();
720
- const handle = await workflow.run({ value: 1 }, { deadlineAt: new Date() });
721
- await worker.tick();
722
- // wait for it to fail due to deadline
723
- await sleep(100);
724
- const failed = await backend.getWorkflowRun({
725
- workflowRunId: handle.workflowRun.id,
726
- });
727
- expect(failed?.status).toBe("failed");
728
- // try to cancel after failure
729
- await expect(handle.cancel()).rejects.toThrow(/Cannot cancel workflow run .* with status failed/);
730
- });
731
- test("cannot cancel non-existent workflow", async () => {
732
- const backend = await createBackend();
733
- await expect(backend.cancelWorkflowRun({
734
- workflowRunId: "non-existent-id",
735
- })).rejects.toThrow(/Workflow run non-existent-id does not exist/);
736
- });
737
- test("worker handles when canceled workflow during execution", async () => {
738
- const backend = await createBackend();
739
- const client = new OpenWorkflow({ backend });
740
- let stepExecuted = false;
741
- const workflow = client.defineWorkflow({ name: "cancel-during-execution" }, async ({ step }) => {
742
- await step.run({ name: "step-1" }, async () => {
743
- stepExecuted = true;
744
- // simulate some work
745
- await sleep(50);
746
- return "result";
747
- });
748
- return { completed: true };
749
- });
750
- const worker = client.newWorker();
751
- const handle = await workflow.run();
752
- // start processing in the background
753
- const tickPromise = worker.tick();
754
- await sleep(25);
755
- // cancel while step is executing
756
- await handle.cancel();
757
- // wait for tick to complete
758
- await tickPromise;
759
- // step should have been executed but workflow should be canceled
760
- expect(stepExecuted).toBe(true);
761
- const canceled = await backend.getWorkflowRun({
762
- workflowRunId: handle.workflowRun.id,
763
- });
764
- expect(canceled?.status).toBe("canceled");
765
- });
766
- test("result() rejects for canceled workflows", async () => {
767
- const backend = await createBackend();
768
- const client = new OpenWorkflow({ backend });
769
- const workflow = client.defineWorkflow({ name: "cancel-result" }, async ({ step }) => {
770
- await step.sleep("sleep-1", "1h");
771
- return { completed: true };
772
- });
773
- const handle = await workflow.run();
774
- await handle.cancel();
775
- await expect(handle.result()).rejects.toThrow(/Workflow cancel-result was canceled/);
776
- });
777
- describe("version matching", () => {
778
- test("worker matches workflow runs by version", async () => {
779
- const backend = await createBackend();
780
- const client = new OpenWorkflow({ backend });
781
- client.defineWorkflow({ name: "versioned-workflow", version: "v1" }, async ({ step }) => {
782
- return await step.run({ name: "compute" }, () => "v1-result");
783
- });
784
- client.defineWorkflow({ name: "versioned-workflow", version: "v2" }, async ({ step }) => {
785
- return await step.run({ name: "compute" }, () => "v2-result");
786
- });
787
- const worker = client.newWorker({ concurrency: 2 });
788
- const v1Spec = defineWorkflowSpec({
789
- name: "versioned-workflow",
790
- version: "v1",
791
- });
792
- const v2Spec = defineWorkflowSpec({
793
- name: "versioned-workflow",
794
- version: "v2",
795
- });
796
- const handleV1 = await client.runWorkflow(v1Spec);
797
- const handleV2 = await client.runWorkflow(v2Spec);
798
- await worker.tick();
799
- await sleep(100); // wait for background execution
800
- const resultV1 = await handleV1.result();
801
- const resultV2 = await handleV2.result();
802
- expect(resultV1).toBe("v1-result");
803
- expect(resultV2).toBe("v2-result");
804
- });
805
- test("worker fails workflow run when version is not registered", async () => {
806
- const backend = await createBackend();
807
- const client = new OpenWorkflow({ backend });
808
- client.defineWorkflow({ name: "version-check", version: "v1" }, () => "v1-result");
809
- const worker = client.newWorker();
810
- const workflowRun = await backend.createWorkflowRun({
811
- workflowName: "version-check",
812
- version: "v2",
813
- idempotencyKey: null,
814
- config: {},
815
- context: null,
816
- input: null,
817
- availableAt: null,
818
- deadlineAt: null,
819
- });
820
- await worker.tick();
821
- const updated = await backend.getWorkflowRun({
822
- workflowRunId: workflowRun.id,
823
- });
824
- expect(updated?.status).toBe("pending");
825
- expect(updated?.error).toEqual({
826
- message: 'Workflow "version-check" (version: v2) is not registered',
827
- });
828
- });
829
- test("unversioned workflow does not match versioned run", async () => {
830
- const backend = await createBackend();
831
- const client = new OpenWorkflow({ backend });
832
- client.defineWorkflow({ name: "version-mismatch" }, () => "unversioned-result");
833
- const worker = client.newWorker();
834
- const workflowRun = await backend.createWorkflowRun({
835
- workflowName: "version-mismatch",
836
- version: "v1",
837
- idempotencyKey: null,
838
- config: {},
839
- context: null,
840
- input: null,
841
- availableAt: null,
842
- deadlineAt: null,
843
- });
844
- await worker.tick();
845
- const updated = await backend.getWorkflowRun({
846
- workflowRunId: workflowRun.id,
847
- });
848
- expect(updated?.status).toBe("pending");
849
- expect(updated?.error).toEqual({
850
- message: 'Workflow "version-mismatch" (version: v1) is not registered',
851
- });
852
- });
853
- test("versioned workflow does not match unversioned run", async () => {
854
- const backend = await createBackend();
855
- const client = new OpenWorkflow({ backend });
856
- client.defineWorkflow({ name: "version-required", version: "v1" }, () => "v1-result");
857
- const worker = client.newWorker();
858
- const workflowRun = await backend.createWorkflowRun({
859
- workflowName: "version-required",
860
- version: null,
861
- idempotencyKey: null,
862
- config: {},
863
- context: null,
864
- input: null,
865
- availableAt: null,
866
- deadlineAt: null,
867
- });
868
- await worker.tick();
869
- const updated = await backend.getWorkflowRun({
870
- workflowRunId: workflowRun.id,
871
- });
872
- expect(updated?.status).toBe("pending");
873
- expect(updated?.error).toEqual({
874
- message: 'Workflow "version-required" is not registered',
875
- });
876
- });
877
- test("workflow receives run's version, not registered version", async () => {
878
- // this test verifies that the version passed to the workflow function
879
- // is the one from the workflow run, not the registered workflow
880
- const backend = await createBackend();
881
- const client = new OpenWorkflow({ backend });
882
- const workflow = client.defineWorkflow({ name: "version-in-handler", version: "v1" }, async ({ version, step }) => {
883
- return await step.run({ name: "get-version" }, () => version);
884
- });
885
- const worker = client.newWorker();
886
- const handle = await workflow.run();
887
- await worker.tick();
888
- const result = await handle.result();
889
- expect(result).toBe("v1");
890
- });
891
- });
892
- });
893
- async function createBackend() {
894
- return await BackendPostgres.connect(DEFAULT_POSTGRES_URL, {
895
- namespaceId: randomUUID(), // unique namespace per test
896
- });
897
- }
898
- function sleep(ms) {
899
- return new Promise((resolve) => setTimeout(resolve, ms));
900
- }