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