openworkflow 0.4.0 → 0.4.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 (133) hide show
  1. package/README.md +17 -11
  2. package/dist/backend-sqlite/backend.d.ts +38 -0
  3. package/dist/backend-sqlite/backend.d.ts.map +1 -0
  4. package/dist/backend-sqlite/backend.js +628 -0
  5. package/dist/backend-sqlite/backend.js.map +1 -0
  6. package/dist/backend-sqlite/index.d.ts +2 -0
  7. package/dist/backend-sqlite/index.d.ts.map +1 -0
  8. package/dist/backend-sqlite/index.js +2 -0
  9. package/dist/backend-sqlite/index.js.map +1 -0
  10. package/dist/backend-sqlite/sqlite.d.ts +45 -0
  11. package/dist/backend-sqlite/sqlite.d.ts.map +1 -0
  12. package/dist/backend-sqlite/sqlite.js +229 -0
  13. package/dist/backend-sqlite/sqlite.js.map +1 -0
  14. package/dist/config/config.d.ts +102 -0
  15. package/dist/config/config.d.ts.map +1 -0
  16. package/dist/config/config.js +29 -0
  17. package/dist/config/config.js.map +1 -0
  18. package/dist/config/index.d.ts +3 -0
  19. package/dist/config/index.d.ts.map +1 -0
  20. package/dist/config/index.js +2 -0
  21. package/dist/config/index.js.map +1 -0
  22. package/dist/config.d.ts +28 -0
  23. package/dist/config.d.ts.map +1 -0
  24. package/dist/config.js +41 -0
  25. package/dist/config.js.map +1 -0
  26. package/dist/core/backend-test-suite.d.ts +22 -0
  27. package/dist/core/backend-test-suite.d.ts.map +1 -0
  28. package/dist/core/backend-test-suite.js +960 -0
  29. package/dist/core/backend-test-suite.js.map +1 -0
  30. package/dist/core/backend.d.ts +103 -0
  31. package/dist/core/backend.d.ts.map +1 -0
  32. package/dist/core/backend.js +2 -0
  33. package/dist/core/backend.js.map +1 -0
  34. package/dist/core/backend.testsuite.d.ts +21 -0
  35. package/dist/core/backend.testsuite.d.ts.map +1 -0
  36. package/dist/core/backend.testsuite.js +958 -0
  37. package/dist/core/backend.testsuite.js.map +1 -0
  38. package/dist/{duration.d.ts → core/duration.d.ts} +2 -1
  39. package/dist/core/duration.d.ts.map +1 -0
  40. package/dist/{duration.js → core/duration.js} +6 -5
  41. package/dist/core/duration.js.map +1 -0
  42. package/dist/core/duration.test.d.ts +2 -0
  43. package/dist/core/duration.test.d.ts.map +1 -0
  44. package/dist/core/duration.test.js +264 -0
  45. package/dist/core/duration.test.js.map +1 -0
  46. package/dist/core/json.d.ts +5 -0
  47. package/dist/core/json.d.ts.map +1 -0
  48. package/dist/core/json.js +2 -0
  49. package/dist/core/json.js.map +1 -0
  50. package/dist/core/result.d.ts +12 -0
  51. package/dist/core/result.d.ts.map +1 -0
  52. package/dist/core/result.js +7 -0
  53. package/dist/core/result.js.map +1 -0
  54. package/dist/core/result.test.d.ts +2 -0
  55. package/dist/core/result.test.d.ts.map +1 -0
  56. package/dist/core/result.test.js +11 -0
  57. package/dist/core/result.test.js.map +1 -0
  58. package/dist/core/retry.d.ts +16 -0
  59. package/dist/core/retry.d.ts.map +1 -0
  60. package/dist/{backend.js → core/retry.js} +1 -3
  61. package/dist/core/retry.js.map +1 -0
  62. package/dist/core/retry.test.d.ts +2 -0
  63. package/dist/core/retry.test.d.ts.map +1 -0
  64. package/dist/core/retry.test.js +36 -0
  65. package/dist/core/retry.test.js.map +1 -0
  66. package/dist/core/schema.d.ts.map +1 -0
  67. package/dist/core/schema.js.map +1 -0
  68. package/dist/core/step.d.ts +120 -0
  69. package/dist/core/step.d.ts.map +1 -0
  70. package/dist/core/step.js +101 -0
  71. package/dist/core/step.js.map +1 -0
  72. package/dist/core/step.test.d.ts +2 -0
  73. package/dist/core/step.test.d.ts.map +1 -0
  74. package/dist/core/step.test.js +340 -0
  75. package/dist/core/step.test.js.map +1 -0
  76. package/dist/core/workflow.d.ts +108 -0
  77. package/dist/core/workflow.d.ts.map +1 -0
  78. package/dist/core/workflow.js +79 -0
  79. package/dist/core/workflow.js.map +1 -0
  80. package/dist/core/workflow.test.d.ts +2 -0
  81. package/dist/core/workflow.test.d.ts.map +1 -0
  82. package/dist/core/workflow.test.js +216 -0
  83. package/dist/core/workflow.test.js.map +1 -0
  84. package/dist/execution/execution.d.ts +91 -0
  85. package/dist/execution/execution.d.ts.map +1 -0
  86. package/dist/execution/execution.js +188 -0
  87. package/dist/execution/execution.js.map +1 -0
  88. package/dist/execution/execution.test.d.ts +2 -0
  89. package/dist/execution/execution.test.d.ts.map +1 -0
  90. package/dist/execution/execution.test.js +382 -0
  91. package/dist/execution/execution.test.js.map +1 -0
  92. package/dist/global.d.ts +62 -0
  93. package/dist/global.d.ts.map +1 -0
  94. package/dist/global.js +78 -0
  95. package/dist/global.js.map +1 -0
  96. package/dist/index.d.ts +9 -5
  97. package/dist/index.d.ts.map +1 -1
  98. package/dist/index.js +4 -3
  99. package/dist/index.js.map +1 -1
  100. package/dist/sdk/sdk.d.ts +182 -0
  101. package/dist/sdk/sdk.d.ts.map +1 -0
  102. package/dist/sdk/sdk.js +208 -0
  103. package/dist/sdk/sdk.js.map +1 -0
  104. package/dist/sdk/sdk.test.d.ts +2 -0
  105. package/dist/sdk/sdk.test.d.ts.map +1 -0
  106. package/dist/sdk/sdk.test.js +195 -0
  107. package/dist/sdk/sdk.test.js.map +1 -0
  108. package/dist/tsconfig.tsbuildinfo +1 -1
  109. package/dist/{worker.d.ts → worker/worker.d.ts} +3 -3
  110. package/dist/worker/worker.d.ts.map +1 -0
  111. package/dist/worker/worker.js +208 -0
  112. package/dist/worker/worker.js.map +1 -0
  113. package/dist/worker/worker.test.d.ts +2 -0
  114. package/dist/worker/worker.test.d.ts.map +1 -0
  115. package/dist/worker/worker.test.js +786 -0
  116. package/dist/worker/worker.test.js.map +1 -0
  117. package/package.json +6 -6
  118. package/dist/backend.d.ts +0 -173
  119. package/dist/backend.d.ts.map +0 -1
  120. package/dist/backend.js.map +0 -1
  121. package/dist/client.d.ts +0 -153
  122. package/dist/client.d.ts.map +0 -1
  123. package/dist/client.js +0 -151
  124. package/dist/client.js.map +0 -1
  125. package/dist/duration.d.ts.map +0 -1
  126. package/dist/duration.js.map +0 -1
  127. package/dist/schema.d.ts.map +0 -1
  128. package/dist/schema.js.map +0 -1
  129. package/dist/worker.d.ts.map +0 -1
  130. package/dist/worker.js +0 -384
  131. package/dist/worker.js.map +0 -1
  132. /package/dist/{schema.d.ts → core/schema.d.ts} +0 -0
  133. /package/dist/{schema.js → core/schema.js} +0 -0
@@ -0,0 +1,786 @@
1
+ import { BackendPostgres } from "../../backend-postgres/backend.js";
2
+ import { DEFAULT_DATABASE_URL } from "../../backend-postgres/postgres.js";
3
+ import { OpenWorkflow } from "../sdk/sdk.js";
4
+ import { randomUUID } from "node:crypto";
5
+ import { describe, expect, test } from "vitest";
6
+ describe("Worker", () => {
7
+ test("passes workflow input to handlers (known slow test)", async () => {
8
+ const backend = await createBackend();
9
+ const client = new OpenWorkflow({ backend });
10
+ const workflow = client.defineWorkflow({ name: "context" }, ({ input }) => input);
11
+ const worker = client.newWorker();
12
+ const payload = { value: 10 };
13
+ const handle = await workflow.run(payload);
14
+ await worker.tick();
15
+ const result = await handle.result();
16
+ expect(result).toEqual(payload);
17
+ });
18
+ test("processes workflow runs to completion (known slow test)", async () => {
19
+ const backend = await createBackend();
20
+ const client = new OpenWorkflow({ backend });
21
+ const workflow = client.defineWorkflow({ name: "process" }, ({ input }) => input.value * 2);
22
+ const worker = client.newWorker();
23
+ const handle = await workflow.run({ value: 21 });
24
+ await worker.tick();
25
+ const result = await handle.result();
26
+ expect(result).toBe(42);
27
+ });
28
+ test("step.run reuses cached results (known slow test)", async () => {
29
+ const backend = await createBackend();
30
+ const client = new OpenWorkflow({ backend });
31
+ let executionCount = 0;
32
+ const workflow = client.defineWorkflow({ name: "cached-step" }, async ({ step }) => {
33
+ const first = await step.run({ name: "once" }, () => {
34
+ executionCount++;
35
+ return "value";
36
+ });
37
+ const second = await step.run({ name: "once" }, () => {
38
+ executionCount++;
39
+ return "should-not-run";
40
+ });
41
+ return { first, second };
42
+ });
43
+ const worker = client.newWorker();
44
+ const handle = await workflow.run();
45
+ await worker.tick();
46
+ const result = await handle.result();
47
+ expect(result).toEqual({ first: "value", second: "value" });
48
+ expect(executionCount).toBe(1);
49
+ });
50
+ test("marks workflow for retry when definition is missing", async () => {
51
+ const backend = await createBackend();
52
+ const client = new OpenWorkflow({ backend });
53
+ const workflowRun = await backend.createWorkflowRun({
54
+ workflowName: "missing",
55
+ version: null,
56
+ idempotencyKey: null,
57
+ config: {},
58
+ context: null,
59
+ input: null,
60
+ availableAt: null,
61
+ deadlineAt: null,
62
+ });
63
+ const worker = client.newWorker();
64
+ await worker.tick();
65
+ const updated = await backend.getWorkflowRun({
66
+ workflowRunId: workflowRun.id,
67
+ });
68
+ expect(updated?.status).toBe("pending");
69
+ expect(updated?.error).toBeDefined();
70
+ expect(updated?.availableAt).not.toBeNull();
71
+ });
72
+ test("retries failed workflows automatically (known slow test)", async () => {
73
+ const backend = await createBackend();
74
+ const client = new OpenWorkflow({ backend });
75
+ let attemptCount = 0;
76
+ const workflow = client.defineWorkflow({ name: "retry-test" }, () => {
77
+ attemptCount++;
78
+ if (attemptCount < 2) {
79
+ throw new Error(`Attempt ${String(attemptCount)} failed`);
80
+ }
81
+ return { success: true, attempts: attemptCount };
82
+ });
83
+ const worker = client.newWorker();
84
+ // run the workflow
85
+ const handle = await workflow.run();
86
+ // first attempt - will fail and reschedule
87
+ await worker.tick();
88
+ await sleep(100); // wait for worker to finish
89
+ expect(attemptCount).toBe(1);
90
+ await sleep(1100); // wait for backoff delay
91
+ // second attempt - will succeed
92
+ await worker.tick();
93
+ await sleep(100); // wait for worker to finish
94
+ expect(attemptCount).toBe(2);
95
+ const result = await handle.result();
96
+ expect(result).toEqual({ success: true, attempts: 2 });
97
+ });
98
+ test("tick is a no-op when no work is available", async () => {
99
+ const backend = await createBackend();
100
+ const client = new OpenWorkflow({ backend });
101
+ client.defineWorkflow({ name: "noop" }, () => null);
102
+ const worker = client.newWorker();
103
+ await worker.tick(); // no runs queued
104
+ });
105
+ test("handles step functions that return undefined (known slow test)", async () => {
106
+ const backend = await createBackend();
107
+ const client = new OpenWorkflow({ backend });
108
+ const workflow = client.defineWorkflow({ name: "undefined-steps" }, async ({ step }) => {
109
+ await step.run({ name: "step-1" }, () => {
110
+ return; // explicit undefined
111
+ });
112
+ await step.run({ name: "step-2" }, () => {
113
+ // implicit undefined
114
+ });
115
+ return { success: true };
116
+ });
117
+ const worker = client.newWorker();
118
+ const handle = await workflow.run();
119
+ await worker.tick();
120
+ const result = await handle.result();
121
+ expect(result).toEqual({ success: true });
122
+ });
123
+ test("executes steps synchronously within workflow (known slow test)", async () => {
124
+ const backend = await createBackend();
125
+ const client = new OpenWorkflow({ backend });
126
+ const executionOrder = [];
127
+ const workflow = client.defineWorkflow({ name: "sync-steps" }, async ({ step }) => {
128
+ executionOrder.push("start");
129
+ await step.run({ name: "step1" }, () => {
130
+ executionOrder.push("step1");
131
+ return 1;
132
+ });
133
+ executionOrder.push("between");
134
+ await step.run({ name: "step2" }, () => {
135
+ executionOrder.push("step2");
136
+ return 2;
137
+ });
138
+ executionOrder.push("end");
139
+ return executionOrder;
140
+ });
141
+ const worker = client.newWorker();
142
+ const handle = await workflow.run();
143
+ await worker.tick();
144
+ const result = await handle.result();
145
+ expect(result).toEqual(["start", "step1", "between", "step2", "end"]);
146
+ });
147
+ test("executes parallel steps with Promise.all (known slow test)", async () => {
148
+ const backend = await createBackend();
149
+ const client = new OpenWorkflow({ backend });
150
+ const executionTimes = {};
151
+ const workflow = client.defineWorkflow({ name: "parallel" }, async ({ step }) => {
152
+ const start = Date.now();
153
+ const [a, b, c] = await Promise.all([
154
+ step.run({ name: "step-a" }, () => {
155
+ executionTimes["step-a"] = Date.now() - start;
156
+ return "a";
157
+ }),
158
+ step.run({ name: "step-b" }, () => {
159
+ executionTimes["step-b"] = Date.now() - start;
160
+ return "b";
161
+ }),
162
+ step.run({ name: "step-c" }, () => {
163
+ executionTimes["step-c"] = Date.now() - start;
164
+ return "c";
165
+ }),
166
+ ]);
167
+ return { a, b, c };
168
+ });
169
+ const worker = client.newWorker();
170
+ const handle = await workflow.run();
171
+ await worker.tick();
172
+ const result = await handle.result();
173
+ expect(result).toEqual({ a: "a", b: "b", c: "c" });
174
+ // steps should execute at roughly the same time (within 100ms)
175
+ const times = Object.values(executionTimes);
176
+ const maxTime = Math.max(...times);
177
+ const minTime = Math.min(...times);
178
+ expect(maxTime - minTime).toBeLessThan(100);
179
+ });
180
+ test("respects worker concurrency limit", async () => {
181
+ const backend = await createBackend();
182
+ const client = new OpenWorkflow({ backend });
183
+ const workflow = client.defineWorkflow({ name: "concurrency-test" }, () => {
184
+ return "done";
185
+ });
186
+ const worker = client.newWorker({ concurrency: 2 });
187
+ // create 5 workflow runs, though only 2 (concurrency limit) should be
188
+ // completed per tick
189
+ const handles = await Promise.all([
190
+ workflow.run(),
191
+ workflow.run(),
192
+ workflow.run(),
193
+ workflow.run(),
194
+ workflow.run(),
195
+ ]);
196
+ await worker.tick();
197
+ await sleep(100);
198
+ let completed = 0;
199
+ for (const handle of handles) {
200
+ const run = await backend.getWorkflowRun({
201
+ workflowRunId: handle.workflowRun.id,
202
+ });
203
+ if (run?.status === "completed")
204
+ completed++;
205
+ }
206
+ expect(completed).toBe(2);
207
+ });
208
+ test("worker starts, processes work, and stops gracefully (known slow test)", async () => {
209
+ const backend = await createBackend();
210
+ const client = new OpenWorkflow({ backend });
211
+ const workflow = client.defineWorkflow({ name: "lifecycle" }, () => {
212
+ return "complete";
213
+ });
214
+ const worker = client.newWorker();
215
+ await worker.start();
216
+ const handle = await workflow.run();
217
+ await sleep(200);
218
+ await worker.stop();
219
+ const result = await handle.result();
220
+ expect(result).toBe("complete");
221
+ });
222
+ test("recovers from crashes during parallel step execution (known slow test)", async () => {
223
+ const backend = await createBackend();
224
+ const client = new OpenWorkflow({ backend });
225
+ let attemptCount = 0;
226
+ const workflow = client.defineWorkflow({ name: "crash-recovery" }, async ({ step }) => {
227
+ attemptCount++;
228
+ const [a, b] = await Promise.all([
229
+ step.run({ name: "step-a" }, () => {
230
+ if (attemptCount > 1)
231
+ return "x"; // should not happen since "a" will be cached
232
+ return "a";
233
+ }),
234
+ step.run({ name: "step-b" }, () => {
235
+ if (attemptCount === 1)
236
+ throw new Error("Simulated crash");
237
+ return "b";
238
+ }),
239
+ ]);
240
+ return { a, b, attempts: attemptCount };
241
+ });
242
+ const worker = client.newWorker();
243
+ const handle = await workflow.run();
244
+ // first attempt will fail
245
+ await worker.tick();
246
+ await sleep(100);
247
+ expect(attemptCount).toBe(1);
248
+ // wait for backoff
249
+ await sleep(1100);
250
+ // second attempt should succeed
251
+ await worker.tick();
252
+ await sleep(100);
253
+ const result = await handle.result();
254
+ expect(result).toEqual({ a: "a", b: "b", attempts: 2 });
255
+ expect(attemptCount).toBe(2);
256
+ });
257
+ test("reclaims workflow run when heartbeat stops (known slow test)", async () => {
258
+ const backend = await createBackend();
259
+ const client = new OpenWorkflow({ backend });
260
+ const workflow = client.defineWorkflow({ name: "heartbeat-test" }, () => "done");
261
+ const handle = await workflow.run();
262
+ const workerId = randomUUID();
263
+ const claimed = await backend.claimWorkflowRun({
264
+ workerId,
265
+ leaseDurationMs: 50,
266
+ });
267
+ expect(claimed).not.toBeNull();
268
+ // let lease expire before starting worker
269
+ await sleep(100);
270
+ // worker should be able to reclaim
271
+ const worker = client.newWorker();
272
+ await worker.tick();
273
+ const result = await handle.result();
274
+ expect(result).toBe("done");
275
+ });
276
+ test("tick() returns count of claimed workflows", async () => {
277
+ const backend = await createBackend();
278
+ const client = new OpenWorkflow({ backend });
279
+ const workflow = client.defineWorkflow({ name: "count-test" }, () => "result");
280
+ // enqueue 3 workflows
281
+ await workflow.run();
282
+ await workflow.run();
283
+ await workflow.run();
284
+ const worker = client.newWorker({ concurrency: 5 });
285
+ // first tick should claim 3 workflows (all available)
286
+ const claimed = await worker.tick();
287
+ expect(claimed).toBe(3);
288
+ // second tick should claim 0 (all already claimed)
289
+ const claimedAgain = await worker.tick();
290
+ expect(claimedAgain).toBe(0);
291
+ await worker.stop();
292
+ });
293
+ test("tick() respects concurrency limit", async () => {
294
+ const backend = await createBackend();
295
+ const client = new OpenWorkflow({ backend });
296
+ const workflow = client.defineWorkflow({ name: "concurrency-test" }, async () => {
297
+ await sleep(100);
298
+ return "done";
299
+ });
300
+ // enqueue 10 workflows
301
+ for (let i = 0; i < 10; i++) {
302
+ await workflow.run();
303
+ }
304
+ const worker = client.newWorker({ concurrency: 3 });
305
+ // first tick should claim exactly 3 (concurrency limit)
306
+ const claimed = await worker.tick();
307
+ expect(claimed).toBe(3);
308
+ // second tick should claim 0 (all slots occupied)
309
+ const claimedAgain = await worker.tick();
310
+ expect(claimedAgain).toBe(0);
311
+ await worker.stop();
312
+ });
313
+ test("worker only sleeps between claims when no work is available (known slow test)", async () => {
314
+ const backend = await createBackend();
315
+ const client = new OpenWorkflow({ backend });
316
+ const workflow = client.defineWorkflow({ name: "adaptive-test" }, async ({ step }) => {
317
+ await step.run({ name: "step-1" }, () => "done");
318
+ return "complete";
319
+ });
320
+ // enqueue many workflows
321
+ const handles = [];
322
+ for (let i = 0; i < 20; i++) {
323
+ handles.push(await workflow.run());
324
+ }
325
+ const worker = client.newWorker({ concurrency: 5 });
326
+ const startTime = Date.now();
327
+ await worker.start();
328
+ // wait for all workflows to complete
329
+ await Promise.all(handles.map((h) => h.result()));
330
+ await worker.stop();
331
+ const duration = Date.now() - startTime;
332
+ // with this conditional sleep, all workflows should complete quickly
333
+ // without it (with 100ms sleep between ticks), it would take much longer
334
+ expect(duration).toBeLessThan(3000); // should complete in under 3 seconds
335
+ });
336
+ test("only failed steps re-execute on retry (known slow test)", async () => {
337
+ const backend = await createBackend();
338
+ const client = new OpenWorkflow({ backend });
339
+ const executionCounts = {
340
+ stepA: 0,
341
+ stepB: 0,
342
+ stepC: 0,
343
+ };
344
+ const workflow = client.defineWorkflow({ name: "mixed-retry" }, async ({ step }) => {
345
+ const a = await step.run({ name: "step-a" }, () => {
346
+ executionCounts.stepA++;
347
+ return "a-result";
348
+ });
349
+ const b = await step.run({ name: "step-b" }, () => {
350
+ executionCounts.stepB++;
351
+ if (executionCounts.stepB === 1) {
352
+ throw new Error("Step B fails on first attempt");
353
+ }
354
+ return "b-result";
355
+ });
356
+ const c = await step.run({ name: "step-c" }, () => {
357
+ executionCounts.stepC++;
358
+ return "c-result";
359
+ });
360
+ return { a, b, c };
361
+ });
362
+ const worker = client.newWorker();
363
+ const handle = await workflow.run();
364
+ // first workflow attempt
365
+ // - step-a succeeds
366
+ // - step-b fails
367
+ // - step-c never runs (workflow fails at step-b)
368
+ await worker.tick();
369
+ await sleep(100);
370
+ expect(executionCounts.stepA).toBe(1);
371
+ expect(executionCounts.stepB).toBe(1);
372
+ expect(executionCounts.stepC).toBe(0);
373
+ // wait for backoff
374
+ await sleep(1100);
375
+ // second workflow attempt
376
+ // - step-a should be cached (not re-executed)
377
+ // - step-b should be re-executed (failed previously)
378
+ // - step-c should execute for first time
379
+ await worker.tick();
380
+ await sleep(100);
381
+ expect(executionCounts.stepA).toBe(1); // still 1, was cached
382
+ expect(executionCounts.stepB).toBe(2); // incremented, was retried
383
+ expect(executionCounts.stepC).toBe(1); // incremented, first execution
384
+ const result = await handle.result();
385
+ expect(result).toEqual({
386
+ a: "a-result",
387
+ b: "b-result",
388
+ c: "c-result",
389
+ });
390
+ });
391
+ test("step.sleep postpones workflow execution (known slow test)", async () => {
392
+ const backend = await createBackend();
393
+ const client = new OpenWorkflow({ backend });
394
+ let stepCount = 0;
395
+ const workflow = client.defineWorkflow({ name: "sleep-test" }, async ({ step }) => {
396
+ const before = await step.run({ name: "before-sleep" }, () => {
397
+ stepCount++;
398
+ return "before";
399
+ });
400
+ await step.sleep("pause", "100ms");
401
+ const after = await step.run({ name: "after-sleep" }, () => {
402
+ stepCount++;
403
+ return "after";
404
+ });
405
+ return { before, after };
406
+ });
407
+ const worker = client.newWorker();
408
+ const handle = await workflow.run();
409
+ // first execution - runs before-sleep, then sleeps
410
+ await worker.tick();
411
+ await sleep(50); // wait for processing
412
+ expect(stepCount).toBe(1);
413
+ // verify workflow was postponed with sleeping status
414
+ const slept = await backend.getWorkflowRun({
415
+ workflowRunId: handle.workflowRun.id,
416
+ });
417
+ expect(slept?.status).toBe("sleeping");
418
+ expect(slept?.workerId).toBeNull(); // released during sleep
419
+ expect(slept?.availableAt).not.toBeNull();
420
+ if (!slept?.availableAt)
421
+ throw new Error("availableAt should be set");
422
+ const delayMs = slept.availableAt.getTime() - Date.now();
423
+ expect(delayMs).toBeGreaterThan(0);
424
+ expect(delayMs).toBeLessThan(150); // should be ~100ms
425
+ // verify sleep step is in "running" state during sleep
426
+ const attempts = await backend.listStepAttempts({
427
+ workflowRunId: handle.workflowRun.id,
428
+ });
429
+ const sleepStep = attempts.data.find((a) => a.stepName === "pause");
430
+ expect(sleepStep?.status).toBe("running");
431
+ // wait for sleep duration
432
+ await sleep(150);
433
+ // second execution (after sleep)
434
+ await worker.tick();
435
+ await sleep(50); // wait for processing
436
+ expect(stepCount).toBe(2);
437
+ // verify sleep step is now "completed"
438
+ const refreshedAttempts = await backend.listStepAttempts({
439
+ workflowRunId: handle.workflowRun.id,
440
+ });
441
+ const completedSleepStep = refreshedAttempts.data.find((a) => a.stepName === "pause");
442
+ expect(completedSleepStep?.status).toBe("completed");
443
+ const result = await handle.result();
444
+ expect(result).toEqual({ before: "before", after: "after" });
445
+ });
446
+ test("step.sleep is cached on replay", async () => {
447
+ const backend = await createBackend();
448
+ const client = new OpenWorkflow({ backend });
449
+ let step1Count = 0;
450
+ let step2Count = 0;
451
+ const workflow = client.defineWorkflow({ name: "sleep-cache-test" }, async ({ step }) => {
452
+ await step.run({ name: "step-1" }, () => {
453
+ step1Count++;
454
+ return "one";
455
+ });
456
+ // this should only postpone once
457
+ await step.sleep("wait", "50ms");
458
+ await step.run({ name: "step-2" }, () => {
459
+ step2Count++;
460
+ return "two";
461
+ });
462
+ return "done";
463
+ });
464
+ const worker = client.newWorker();
465
+ const handle = await workflow.run();
466
+ // first attempt: execute step-1, then sleep (step-2 not executed)
467
+ await worker.tick();
468
+ await sleep(50);
469
+ expect(step1Count).toBe(1);
470
+ expect(step2Count).toBe(0);
471
+ await sleep(100); // wait for sleep to complete
472
+ // second attempt: step-1 is cached (not re-executed), sleep is cached, step-2 executes
473
+ await worker.tick();
474
+ await sleep(50);
475
+ expect(step1Count).toBe(1); // still 1, was cached
476
+ expect(step2Count).toBe(1); // now 1, executed after sleep
477
+ const result = await handle.result();
478
+ expect(result).toBe("done");
479
+ });
480
+ test("step.sleep throws error for invalid duration format", async () => {
481
+ const backend = await createBackend();
482
+ const client = new OpenWorkflow({ backend });
483
+ const workflow = client.defineWorkflow({ name: "invalid-duration" }, async ({ step }) => {
484
+ // @ts-expect-error - testing invalid duration
485
+ await step.sleep("bad", "invalid");
486
+ return "should-not-reach";
487
+ });
488
+ const worker = client.newWorker();
489
+ const handle = await workflow.run();
490
+ await worker.tick();
491
+ await sleep(100);
492
+ const failed = await backend.getWorkflowRun({
493
+ workflowRunId: handle.workflowRun.id,
494
+ });
495
+ expect(failed?.status).toBe("pending"); // should be retrying
496
+ expect(failed?.error).toBeDefined();
497
+ // @ts-expect-error - test suite
498
+ expect(failed?.error?.message).toContain("Invalid duration format");
499
+ });
500
+ test("step.sleep handles multiple sequential sleeps (known slow test)", 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 (known slow test)", 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 (known slow test)", 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
+ });
778
+ async function createBackend() {
779
+ return await BackendPostgres.connect(DEFAULT_DATABASE_URL, {
780
+ namespaceId: randomUUID(), // unique namespace per test
781
+ });
782
+ }
783
+ function sleep(ms) {
784
+ return new Promise((resolve) => setTimeout(resolve, ms));
785
+ }
786
+ //# sourceMappingURL=worker.test.js.map