ai-workflows 2.1.1 → 2.3.0

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 (211) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +17 -1
  3. package/README.md +305 -184
  4. package/dist/barrier.d.ts +159 -0
  5. package/dist/barrier.d.ts.map +1 -0
  6. package/dist/barrier.js +377 -0
  7. package/dist/barrier.js.map +1 -0
  8. package/dist/cascade-context.d.ts +149 -0
  9. package/dist/cascade-context.d.ts.map +1 -0
  10. package/dist/cascade-context.js +324 -0
  11. package/dist/cascade-context.js.map +1 -0
  12. package/dist/cascade-executor.d.ts +196 -0
  13. package/dist/cascade-executor.d.ts.map +1 -0
  14. package/dist/cascade-executor.js +384 -0
  15. package/dist/cascade-executor.js.map +1 -0
  16. package/dist/context.d.ts.map +1 -1
  17. package/dist/context.js +27 -8
  18. package/dist/context.js.map +1 -1
  19. package/dist/cron-parser.d.ts +65 -0
  20. package/dist/cron-parser.d.ts.map +1 -0
  21. package/dist/cron-parser.js +294 -0
  22. package/dist/cron-parser.js.map +1 -0
  23. package/dist/cron-scheduler.d.ts +117 -0
  24. package/dist/cron-scheduler.d.ts.map +1 -0
  25. package/dist/cron-scheduler.js +176 -0
  26. package/dist/cron-scheduler.js.map +1 -0
  27. package/dist/database-context.d.ts +184 -0
  28. package/dist/database-context.d.ts.map +1 -0
  29. package/dist/database-context.js +428 -0
  30. package/dist/database-context.js.map +1 -0
  31. package/dist/dependency-graph.d.ts +157 -0
  32. package/dist/dependency-graph.d.ts.map +1 -0
  33. package/dist/dependency-graph.js +382 -0
  34. package/dist/dependency-graph.js.map +1 -0
  35. package/dist/digital-objects-adapter.d.ts +159 -0
  36. package/dist/digital-objects-adapter.d.ts.map +1 -0
  37. package/dist/digital-objects-adapter.js +229 -0
  38. package/dist/digital-objects-adapter.js.map +1 -0
  39. package/dist/durable-execution-cloudflare.d.ts +427 -0
  40. package/dist/durable-execution-cloudflare.d.ts.map +1 -0
  41. package/dist/durable-execution-cloudflare.js +510 -0
  42. package/dist/durable-execution-cloudflare.js.map +1 -0
  43. package/dist/durable-execution.d.ts +482 -0
  44. package/dist/durable-execution.d.ts.map +1 -0
  45. package/dist/durable-execution.js +594 -0
  46. package/dist/durable-execution.js.map +1 -0
  47. package/dist/durable-workflow.d.ts +176 -0
  48. package/dist/durable-workflow.d.ts.map +1 -0
  49. package/dist/durable-workflow.js +552 -0
  50. package/dist/durable-workflow.js.map +1 -0
  51. package/dist/every.d.ts +31 -2
  52. package/dist/every.d.ts.map +1 -1
  53. package/dist/every.js +63 -32
  54. package/dist/every.js.map +1 -1
  55. package/dist/graph/index.d.ts +8 -0
  56. package/dist/graph/index.d.ts.map +1 -0
  57. package/dist/graph/index.js +8 -0
  58. package/dist/graph/index.js.map +1 -0
  59. package/dist/graph/topological-sort.d.ts +121 -0
  60. package/dist/graph/topological-sort.d.ts.map +1 -0
  61. package/dist/graph/topological-sort.js +292 -0
  62. package/dist/graph/topological-sort.js.map +1 -0
  63. package/dist/index.d.ts +10 -1
  64. package/dist/index.d.ts.map +1 -1
  65. package/dist/index.js +25 -0
  66. package/dist/index.js.map +1 -1
  67. package/dist/logger.d.ts +101 -0
  68. package/dist/logger.d.ts.map +1 -0
  69. package/dist/logger.js +115 -0
  70. package/dist/logger.js.map +1 -0
  71. package/dist/on.d.ts +35 -10
  72. package/dist/on.d.ts.map +1 -1
  73. package/dist/on.js +53 -19
  74. package/dist/on.js.map +1 -1
  75. package/dist/runtime.d.ts +169 -0
  76. package/dist/runtime.d.ts.map +1 -0
  77. package/dist/runtime.js +275 -0
  78. package/dist/runtime.js.map +1 -0
  79. package/dist/send.d.ts.map +1 -1
  80. package/dist/send.js +4 -3
  81. package/dist/send.js.map +1 -1
  82. package/dist/telemetry.d.ts +150 -0
  83. package/dist/telemetry.d.ts.map +1 -0
  84. package/dist/telemetry.js +388 -0
  85. package/dist/telemetry.js.map +1 -0
  86. package/dist/timer-registry.d.ts +77 -0
  87. package/dist/timer-registry.d.ts.map +1 -0
  88. package/dist/timer-registry.js +154 -0
  89. package/dist/timer-registry.js.map +1 -0
  90. package/dist/types.d.ts +105 -6
  91. package/dist/types.d.ts.map +1 -1
  92. package/dist/types.js +17 -1
  93. package/dist/types.js.map +1 -1
  94. package/dist/worker/durable-step.d.ts +481 -0
  95. package/dist/worker/durable-step.d.ts.map +1 -0
  96. package/dist/worker/durable-step.js +606 -0
  97. package/dist/worker/durable-step.js.map +1 -0
  98. package/dist/worker/index.d.ts +106 -0
  99. package/dist/worker/index.d.ts.map +1 -0
  100. package/dist/worker/index.js +124 -0
  101. package/dist/worker/index.js.map +1 -0
  102. package/dist/worker/state-adapter.d.ts +230 -0
  103. package/dist/worker/state-adapter.d.ts.map +1 -0
  104. package/dist/worker/state-adapter.js +409 -0
  105. package/dist/worker/state-adapter.js.map +1 -0
  106. package/dist/worker/topological-executor.d.ts +282 -0
  107. package/dist/worker/topological-executor.d.ts.map +1 -0
  108. package/dist/worker/topological-executor.js +396 -0
  109. package/dist/worker/topological-executor.js.map +1 -0
  110. package/dist/worker/workflow-builder.d.ts +286 -0
  111. package/dist/worker/workflow-builder.d.ts.map +1 -0
  112. package/dist/worker/workflow-builder.js +565 -0
  113. package/dist/worker/workflow-builder.js.map +1 -0
  114. package/dist/worker.d.ts +800 -0
  115. package/dist/worker.d.ts.map +1 -0
  116. package/dist/worker.js +2428 -0
  117. package/dist/worker.js.map +1 -0
  118. package/dist/workflow-builder.d.ts +287 -0
  119. package/dist/workflow-builder.d.ts.map +1 -0
  120. package/dist/workflow-builder.js +762 -0
  121. package/dist/workflow-builder.js.map +1 -0
  122. package/dist/workflow.d.ts +14 -30
  123. package/dist/workflow.d.ts.map +1 -1
  124. package/dist/workflow.js +136 -292
  125. package/dist/workflow.js.map +1 -1
  126. package/examples/01-ecommerce-order-pipeline.ts +358 -0
  127. package/examples/02-content-moderation-cascade.ts +454 -0
  128. package/examples/03-scheduled-reporting-dependencies.ts +479 -0
  129. package/examples/04-database-persistence.ts +518 -0
  130. package/examples/README.md +173 -0
  131. package/package.json +21 -4
  132. package/src/__tests__/digital-objects-adapter.test.ts +274 -0
  133. package/src/__tests__/durable-workflow.test.ts +297 -0
  134. package/src/barrier.ts +507 -0
  135. package/src/cascade-context.ts +495 -0
  136. package/src/cascade-executor.ts +588 -0
  137. package/src/context.ts +51 -17
  138. package/src/cron-parser.ts +347 -0
  139. package/src/cron-scheduler.ts +239 -0
  140. package/src/database-context.ts +658 -0
  141. package/src/dependency-graph.ts +518 -0
  142. package/src/digital-objects-adapter.ts +351 -0
  143. package/src/durable-execution-cloudflare.ts +855 -0
  144. package/src/durable-execution.ts +1042 -0
  145. package/src/durable-workflow.ts +717 -0
  146. package/src/every.ts +104 -35
  147. package/src/graph/index.ts +19 -0
  148. package/src/graph/topological-sort.ts +412 -0
  149. package/src/index.ts +147 -0
  150. package/src/logger.ts +148 -0
  151. package/src/on.ts +81 -26
  152. package/src/runtime.ts +436 -0
  153. package/src/send.ts +4 -5
  154. package/src/telemetry.ts +577 -0
  155. package/src/timer-registry.ts +179 -0
  156. package/src/types.ts +146 -10
  157. package/src/worker/durable-step.ts +976 -0
  158. package/src/worker/index.ts +216 -0
  159. package/src/worker/state-adapter.ts +589 -0
  160. package/src/worker/topological-executor.ts +625 -0
  161. package/src/worker/workflow-builder.ts +871 -0
  162. package/src/worker.ts +2906 -0
  163. package/src/workflow-builder.ts +1068 -0
  164. package/src/workflow.ts +199 -355
  165. package/test/barrier-join.test.ts +442 -0
  166. package/test/barrier-unhandled-rejections.test.ts +359 -0
  167. package/test/cascade-context.test.ts +390 -0
  168. package/test/cascade-executor.test.ts +852 -0
  169. package/test/cron-parser.test.ts +314 -0
  170. package/test/cron-scheduler.test.ts +291 -0
  171. package/test/database-context.test.ts +770 -0
  172. package/test/db-provider-adapter.test.ts +862 -0
  173. package/test/dependency-graph.test.ts +512 -0
  174. package/test/durable-execution-cloudflare.test.ts +606 -0
  175. package/test/durable-execution-in-process.test.ts +286 -0
  176. package/test/durable-execution.test.ts +247 -0
  177. package/test/e2e/workflow-scenarios.e2e.test.ts +1039 -0
  178. package/test/graph/topological-sort.test.ts +586 -0
  179. package/test/integration.test.ts +442 -0
  180. package/test/rpc-surface.test.ts +946 -0
  181. package/test/runtime.test.ts +262 -0
  182. package/test/schedule-timer-cleanup.test.ts +353 -0
  183. package/test/send-race-conditions.test.ts +400 -0
  184. package/test/type-safety-every.test.ts +303 -0
  185. package/test/worker/durable-cascade.test.ts +1117 -0
  186. package/test/worker/durable-step.test.ts +723 -0
  187. package/test/worker/topological-executor.test.ts +1240 -0
  188. package/test/worker/workflow-builder.test.ts +1067 -0
  189. package/test/worker.test.ts +608 -0
  190. package/test/workflow-builder.test.ts +1670 -0
  191. package/test/workflow-cron.test.ts +256 -0
  192. package/test/workflow-state-adapter.test.ts +923 -0
  193. package/test/workflow.test.ts +25 -22
  194. package/tsconfig.json +3 -1
  195. package/vitest.config.ts +38 -1
  196. package/vitest.workers.config.ts +44 -0
  197. package/wrangler.jsonc +22 -0
  198. package/.turbo/turbo-test.log +0 -7
  199. package/src/context.js +0 -83
  200. package/src/every.js +0 -267
  201. package/src/index.js +0 -71
  202. package/src/on.js +0 -79
  203. package/src/send.js +0 -111
  204. package/src/types.js +0 -4
  205. package/src/workflow.js +0 -455
  206. package/test/context.test.js +0 -116
  207. package/test/every.test.js +0 -282
  208. package/test/on.test.js +0 -80
  209. package/test/send.test.js +0 -89
  210. package/test/workflow.test.js +0 -224
  211. package/vitest.config.js +0 -7
@@ -0,0 +1,1670 @@
1
+ /**
2
+ * WorkflowBuilder DSL Tests (RED Phase)
3
+ *
4
+ * Tests for WorkflowBuilder - a fluent DSL for building durable workflows
5
+ * with support for sequential steps, parallel execution, conditional branching,
6
+ * loops, error handlers, timeouts, and retries.
7
+ *
8
+ * These tests define the expected behavior for WorkflowBuilder DSL before implementation.
9
+ * All tests SHOULD FAIL because WorkflowBuilder does not exist yet.
10
+ *
11
+ * Uses regular vitest (not vitest-pool-workers).
12
+ *
13
+ * Bead: aip-llm1
14
+ *
15
+ * @packageDocumentation
16
+ */
17
+
18
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
19
+
20
+ // ============================================================================
21
+ // These imports will FAIL because WorkflowBuilder does not exist yet.
22
+ // This is the RED phase of TDD.
23
+ // ============================================================================
24
+ import {
25
+ workflow,
26
+ WorkflowBuilder,
27
+ type WorkflowDefinition,
28
+ type StepDefinition,
29
+ type StepChain,
30
+ type ConditionalChain,
31
+ type LoopChain,
32
+ type BuiltWorkflow,
33
+ type StepContext,
34
+ type RetryConfig,
35
+ } from '../src/workflow-builder.js'
36
+
37
+ // ============================================================================
38
+ // Test Data - Sample Step Functions
39
+ // ============================================================================
40
+
41
+ const validateOrder = async (input: { orderId: string }) => {
42
+ return { valid: true, orderId: input.orderId }
43
+ }
44
+
45
+ const chargePayment = async (input: { orderId: string; amount: number }) => {
46
+ return { charged: true, transactionId: `txn_${input.orderId}` }
47
+ }
48
+
49
+ const fulfillOrder = async (input: { orderId: string; transactionId: string }) => {
50
+ return { fulfilled: true, trackingNumber: `track_${input.orderId}` }
51
+ }
52
+
53
+ const sendNotification = async (input: { to: string; message: string }) => {
54
+ return { sent: true }
55
+ }
56
+
57
+ const processItem = async (input: { item: string; index: number }) => {
58
+ return { processed: true, item: input.item }
59
+ }
60
+
61
+ // ============================================================================
62
+ // 1. Fluent DSL: workflow('name').step().step().build()
63
+ // ============================================================================
64
+
65
+ describe('WorkflowBuilder Fluent DSL', () => {
66
+ describe('workflow() factory function', () => {
67
+ it('creates a workflow builder with a name', () => {
68
+ const builder = workflow('order-process')
69
+
70
+ expect(builder).toBeDefined()
71
+ expect(builder).toBeInstanceOf(WorkflowBuilder)
72
+ })
73
+
74
+ it('supports the fluent pattern: workflow().step().step().build()', () => {
75
+ const built = workflow('order-process')
76
+ .step('step1', validateOrder)
77
+ .step('step2', chargePayment)
78
+ .build()
79
+
80
+ expect(built).toBeDefined()
81
+ expect(built.name).toBe('order-process')
82
+ expect(built.steps).toHaveLength(2)
83
+ })
84
+
85
+ it('validates workflow name is provided', () => {
86
+ expect(() => workflow('')).toThrow(/workflow name/i)
87
+ })
88
+
89
+ it('workflow name is accessible on the builder', () => {
90
+ const builder = workflow('my-workflow')
91
+
92
+ expect(builder.name).toBe('my-workflow')
93
+ })
94
+ })
95
+
96
+ describe('step() method', () => {
97
+ it('adds a step with name and function', () => {
98
+ const built = workflow('test').step('validate', validateOrder).build()
99
+
100
+ expect(built.steps).toHaveLength(1)
101
+ expect(built.steps[0].name).toBe('validate')
102
+ })
103
+
104
+ it('chains multiple steps', () => {
105
+ const built = workflow('test')
106
+ .step('step1', async () => ({ a: 1 }))
107
+ .step('step2', async () => ({ b: 2 }))
108
+ .step('step3', async () => ({ c: 3 }))
109
+ .build()
110
+
111
+ expect(built.steps).toHaveLength(3)
112
+ expect(built.steps.map((s) => s.name)).toEqual(['step1', 'step2', 'step3'])
113
+ })
114
+
115
+ it('preserves step order', () => {
116
+ const built = workflow('ordered')
117
+ .step('first', async () => 1)
118
+ .step('second', async () => 2)
119
+ .step('third', async () => 3)
120
+ .build()
121
+
122
+ expect(built.steps[0].name).toBe('first')
123
+ expect(built.steps[1].name).toBe('second')
124
+ expect(built.steps[2].name).toBe('third')
125
+ })
126
+
127
+ it('rejects duplicate step names', () => {
128
+ expect(() =>
129
+ workflow('test')
130
+ .step('duplicate', async () => 1)
131
+ .step('duplicate', async () => 2)
132
+ .build()
133
+ ).toThrow(/duplicate.*step.*name/i)
134
+ })
135
+ })
136
+
137
+ describe('build() method', () => {
138
+ it('returns a BuiltWorkflow object', () => {
139
+ const built = workflow('test').step('step1', validateOrder).build()
140
+
141
+ expect(built).toHaveProperty('name')
142
+ expect(built).toHaveProperty('steps')
143
+ expect(built).toHaveProperty('execute')
144
+ })
145
+
146
+ it('built workflow has execute method', () => {
147
+ const built = workflow('test').step('step1', validateOrder).build()
148
+
149
+ expect(typeof built.execute).toBe('function')
150
+ })
151
+
152
+ it('returns immutable workflow definition', () => {
153
+ const builder = workflow('test').step('step1', validateOrder)
154
+
155
+ const built1 = builder.build()
156
+
157
+ builder.step('step2', chargePayment)
158
+
159
+ const built2 = builder.build()
160
+
161
+ expect(built1.steps).toHaveLength(1)
162
+ expect(built2.steps).toHaveLength(2)
163
+ })
164
+ })
165
+ })
166
+
167
+ // ============================================================================
168
+ // 2. Sequential and Parallel Steps
169
+ // ============================================================================
170
+
171
+ describe('Sequential and Parallel Steps', () => {
172
+ describe('sequential execution (default)', () => {
173
+ it('steps execute in order by default', async () => {
174
+ const executionOrder: string[] = []
175
+
176
+ const built = workflow('sequential')
177
+ .step('first', async () => {
178
+ executionOrder.push('first')
179
+ return { order: 1 }
180
+ })
181
+ .step('second', async () => {
182
+ executionOrder.push('second')
183
+ return { order: 2 }
184
+ })
185
+ .step('third', async () => {
186
+ executionOrder.push('third')
187
+ return { order: 3 }
188
+ })
189
+ .build()
190
+
191
+ await built.execute({})
192
+
193
+ expect(executionOrder).toEqual(['first', 'second', 'third'])
194
+ })
195
+
196
+ it('each step receives result from previous step', async () => {
197
+ const built = workflow('chained')
198
+ .step('add5', async (input: { value: number }) => ({
199
+ value: input.value + 5,
200
+ }))
201
+ .step('multiply2', async (input: { value: number }) => ({
202
+ value: input.value * 2,
203
+ }))
204
+ .step('subtract3', async (input: { value: number }) => ({
205
+ value: input.value - 3,
206
+ }))
207
+ .build()
208
+
209
+ const result = await built.execute({ value: 10 })
210
+
211
+ // (10 + 5) * 2 - 3 = 27
212
+ expect(result.value).toBe(27)
213
+ })
214
+ })
215
+
216
+ describe('parallel execution', () => {
217
+ it('supports parallel step groups with .parallel()', () => {
218
+ const built = workflow('parallel-test')
219
+ .parallel([
220
+ { name: 'taskA', fn: async () => ({ a: true }) },
221
+ { name: 'taskB', fn: async () => ({ b: true }) },
222
+ { name: 'taskC', fn: async () => ({ c: true }) },
223
+ ])
224
+ .build()
225
+
226
+ expect(built).toBeDefined()
227
+ })
228
+
229
+ it('parallel steps execute concurrently', async () => {
230
+ const startTimes: Record<string, number> = {}
231
+
232
+ const built = workflow('concurrent')
233
+ .parallel([
234
+ {
235
+ name: 'slow1',
236
+ fn: async () => {
237
+ startTimes.slow1 = Date.now()
238
+ await new Promise((r) => setTimeout(r, 50))
239
+ return { done: 1 }
240
+ },
241
+ },
242
+ {
243
+ name: 'slow2',
244
+ fn: async () => {
245
+ startTimes.slow2 = Date.now()
246
+ await new Promise((r) => setTimeout(r, 50))
247
+ return { done: 2 }
248
+ },
249
+ },
250
+ ])
251
+ .build()
252
+
253
+ await built.execute({})
254
+
255
+ // Both should start at approximately the same time
256
+ const timeDiff = Math.abs(startTimes.slow1 - startTimes.slow2)
257
+ expect(timeDiff).toBeLessThan(20)
258
+ })
259
+
260
+ it('parallel results are merged', async () => {
261
+ const built = workflow('merge-results')
262
+ .parallel([
263
+ { name: 'a', fn: async () => ({ resultA: 'A' }) },
264
+ { name: 'b', fn: async () => ({ resultB: 'B' }) },
265
+ ])
266
+ .build()
267
+
268
+ const result = await built.execute({})
269
+
270
+ expect(result.a).toEqual({ resultA: 'A' })
271
+ expect(result.b).toEqual({ resultB: 'B' })
272
+ })
273
+
274
+ it('sequential and parallel can be mixed', async () => {
275
+ const executionOrder: string[] = []
276
+
277
+ const built = workflow('mixed')
278
+ .step('first', async () => {
279
+ executionOrder.push('first')
280
+ return {}
281
+ })
282
+ .parallel([
283
+ {
284
+ name: 'parallelA',
285
+ fn: async () => {
286
+ executionOrder.push('parallelA')
287
+ return {}
288
+ },
289
+ },
290
+ {
291
+ name: 'parallelB',
292
+ fn: async () => {
293
+ executionOrder.push('parallelB')
294
+ return {}
295
+ },
296
+ },
297
+ ])
298
+ .step('last', async () => {
299
+ executionOrder.push('last')
300
+ return {}
301
+ })
302
+ .build()
303
+
304
+ await built.execute({})
305
+
306
+ expect(executionOrder[0]).toBe('first')
307
+ expect(executionOrder[executionOrder.length - 1]).toBe('last')
308
+ expect(executionOrder).toContain('parallelA')
309
+ expect(executionOrder).toContain('parallelB')
310
+ })
311
+
312
+ it('parallel failure handling with .parallel().onError()', async () => {
313
+ const built = workflow('parallel-error')
314
+ .parallel([
315
+ { name: 'success', fn: async () => ({ ok: true }) },
316
+ {
317
+ name: 'failure',
318
+ fn: async () => {
319
+ throw new Error('Parallel step failed')
320
+ },
321
+ },
322
+ ])
323
+ .onError(async (error) => ({ recovered: true, error: error.message }))
324
+ .build()
325
+
326
+ const result = await built.execute({})
327
+
328
+ expect(result.recovered).toBe(true)
329
+ })
330
+ })
331
+ })
332
+
333
+ // ============================================================================
334
+ // 3. Conditional Branching: .when(condition).then(steps).else(steps)
335
+ // ============================================================================
336
+
337
+ describe('Conditional Branching', () => {
338
+ describe('.when(condition).then(steps)', () => {
339
+ it('executes then branch when condition is true', async () => {
340
+ const built = workflow('conditional')
341
+ .step('check', async () => ({ amount: 150 }))
342
+ .when((ctx) => ctx.result.amount > 100)
343
+ .then(workflow('then-branch').step('highValue', async () => ({ tier: 'premium' })))
344
+ .build()
345
+
346
+ const result = await built.execute({})
347
+
348
+ expect(result.tier).toBe('premium')
349
+ })
350
+
351
+ it('skips then branch when condition is false', async () => {
352
+ const executed: string[] = []
353
+
354
+ const built = workflow('conditional')
355
+ .step('check', async () => {
356
+ executed.push('check')
357
+ return { amount: 50 }
358
+ })
359
+ .when((ctx) => ctx.result.amount > 100)
360
+ .then(
361
+ workflow('then-branch').step('highValue', async () => {
362
+ executed.push('highValue')
363
+ return { tier: 'premium' }
364
+ })
365
+ )
366
+ .step('continue', async () => {
367
+ executed.push('continue')
368
+ return { continued: true }
369
+ })
370
+ .build()
371
+
372
+ await built.execute({})
373
+
374
+ expect(executed).toContain('check')
375
+ expect(executed).toContain('continue')
376
+ expect(executed).not.toContain('highValue')
377
+ })
378
+ })
379
+
380
+ describe('.when(condition).then(steps).else(steps)', () => {
381
+ it('executes else branch when condition is false', async () => {
382
+ const built = workflow('if-else')
383
+ .step('check', async () => ({ amount: 50 }))
384
+ .when((ctx) => ctx.result.amount > 100)
385
+ .then(workflow('then-branch').step('premium', async () => ({ tier: 'premium' })))
386
+ .else(workflow('else-branch').step('standard', async () => ({ tier: 'standard' })))
387
+ .build()
388
+
389
+ const result = await built.execute({})
390
+
391
+ expect(result.tier).toBe('standard')
392
+ })
393
+
394
+ it('executes then branch and skips else when condition is true', async () => {
395
+ const executed: string[] = []
396
+
397
+ const built = workflow('if-else')
398
+ .step('check', async () => ({ amount: 150 }))
399
+ .when((ctx) => ctx.result.amount > 100)
400
+ .then(
401
+ workflow('then-branch').step('premium', async () => {
402
+ executed.push('premium')
403
+ return { tier: 'premium' }
404
+ })
405
+ )
406
+ .else(
407
+ workflow('else-branch').step('standard', async () => {
408
+ executed.push('standard')
409
+ return { tier: 'standard' }
410
+ })
411
+ )
412
+ .build()
413
+
414
+ await built.execute({})
415
+
416
+ expect(executed).toContain('premium')
417
+ expect(executed).not.toContain('standard')
418
+ })
419
+
420
+ it('supports nested conditionals', async () => {
421
+ const built = workflow('nested-conditional')
422
+ .step('getData', async () => ({ value: 75 }))
423
+ .when((ctx) => ctx.result.value > 50)
424
+ .then(
425
+ workflow('outer-then')
426
+ .when((ctx) => ctx.result.value > 90)
427
+ .then(workflow('inner-then').step('high', async () => ({ level: 'high' })))
428
+ .else(workflow('inner-else').step('medium', async () => ({ level: 'medium' })))
429
+ )
430
+ .else(workflow('outer-else').step('low', async () => ({ level: 'low' })))
431
+ .build()
432
+
433
+ const result = await built.execute({})
434
+
435
+ expect(result.level).toBe('medium')
436
+ })
437
+
438
+ it('condition receives step context with input and results', async () => {
439
+ let capturedContext: StepContext | null = null
440
+
441
+ const built = workflow('context-check')
442
+ .step('setup', async (input: { userId: string }) => ({
443
+ user: input.userId,
444
+ premium: true,
445
+ }))
446
+ .when((ctx) => {
447
+ capturedContext = ctx
448
+ return ctx.result.premium
449
+ })
450
+ .then(workflow('premium-flow').step('premium', async () => ({ applied: true })))
451
+ .build()
452
+
453
+ await built.execute({ userId: 'user-123' })
454
+
455
+ expect(capturedContext).not.toBeNull()
456
+ expect(capturedContext!.input).toEqual({ userId: 'user-123' })
457
+ expect(capturedContext!.result).toEqual({ user: 'user-123', premium: true })
458
+ })
459
+
460
+ it('supports async conditions', async () => {
461
+ const built = workflow('async-condition')
462
+ .step('getData', async () => ({ id: 'test-123' }))
463
+ .when(async (ctx) => {
464
+ // Simulate async check (e.g., database lookup)
465
+ await new Promise((r) => setTimeout(r, 10))
466
+ return ctx.result.id.startsWith('test')
467
+ })
468
+ .then(workflow('test-branch').step('testMode', async () => ({ testMode: true })))
469
+ .build()
470
+
471
+ const result = await built.execute({})
472
+
473
+ expect(result.testMode).toBe(true)
474
+ })
475
+ })
476
+
477
+ describe('.when() with inline steps', () => {
478
+ it('supports inline step functions in then()', async () => {
479
+ const built = workflow('inline-then')
480
+ .step('check', async () => ({ proceed: true }))
481
+ .when((ctx) => ctx.result.proceed)
482
+ .then(async () => ({ inlined: true }))
483
+ .build()
484
+
485
+ const result = await built.execute({})
486
+
487
+ expect(result.inlined).toBe(true)
488
+ })
489
+
490
+ it('supports inline step functions in else()', async () => {
491
+ const built = workflow('inline-else')
492
+ .step('check', async () => ({ proceed: false }))
493
+ .when((ctx) => ctx.result.proceed)
494
+ .then(async () => ({ branch: 'then' }))
495
+ .else(async () => ({ branch: 'else' }))
496
+ .build()
497
+
498
+ const result = await built.execute({})
499
+
500
+ expect(result.branch).toBe('else')
501
+ })
502
+ })
503
+ })
504
+
505
+ // ============================================================================
506
+ // 4. Loops: .loop(condition, steps)
507
+ // ============================================================================
508
+
509
+ describe('Loops', () => {
510
+ describe('.loop(condition, steps)', () => {
511
+ it('repeats steps while condition is true', async () => {
512
+ let counter = 0
513
+
514
+ const built = workflow('while-loop')
515
+ .step('init', async () => ({ count: 0 }))
516
+ .loop(
517
+ (ctx) => ctx.result.count < 3,
518
+ workflow('loop-body').step('increment', async (input: { count: number }) => {
519
+ counter++
520
+ return { count: input.count + 1 }
521
+ })
522
+ )
523
+ .build()
524
+
525
+ const result = await built.execute({})
526
+
527
+ expect(counter).toBe(3)
528
+ expect(result.count).toBe(3)
529
+ })
530
+
531
+ it('does not execute loop body if condition is initially false', async () => {
532
+ let executed = false
533
+
534
+ const built = workflow('skip-loop')
535
+ .step('init', async () => ({ count: 10 }))
536
+ .loop(
537
+ (ctx) => ctx.result.count < 3,
538
+ workflow('loop-body').step('never', async () => {
539
+ executed = true
540
+ return {}
541
+ })
542
+ )
543
+ .build()
544
+
545
+ await built.execute({})
546
+
547
+ expect(executed).toBe(false)
548
+ })
549
+
550
+ it('loop has access to accumulated results', async () => {
551
+ const values: number[] = []
552
+
553
+ const built = workflow('accumulator')
554
+ .step('init', async () => ({ items: [1, 2, 3], index: 0 }))
555
+ .loop(
556
+ (ctx) => ctx.result.index < ctx.result.items.length,
557
+ workflow('process-item').step(
558
+ 'process',
559
+ async (input: { items: number[]; index: number }) => {
560
+ values.push(input.items[input.index])
561
+ return { ...input, index: input.index + 1 }
562
+ }
563
+ )
564
+ )
565
+ .build()
566
+
567
+ await built.execute({})
568
+
569
+ expect(values).toEqual([1, 2, 3])
570
+ })
571
+
572
+ it('supports async loop conditions', async () => {
573
+ let iterations = 0
574
+
575
+ const built = workflow('async-loop')
576
+ .step('init', async () => ({ remaining: 2 }))
577
+ .loop(
578
+ async (ctx) => {
579
+ await new Promise((r) => setTimeout(r, 5))
580
+ return ctx.result.remaining > 0
581
+ },
582
+ workflow('loop-body').step('decrement', async (input: { remaining: number }) => {
583
+ iterations++
584
+ return { remaining: input.remaining - 1 }
585
+ })
586
+ )
587
+ .build()
588
+
589
+ await built.execute({})
590
+
591
+ expect(iterations).toBe(2)
592
+ })
593
+
594
+ it('prevents infinite loops with maxIterations option', async () => {
595
+ let iterations = 0
596
+
597
+ const built = workflow('infinite-guard')
598
+ .step('init', async () => ({ value: true }))
599
+ .loop(
600
+ () => true, // Always true - would loop forever
601
+ workflow('body').step('tick', async () => {
602
+ iterations++
603
+ return { value: true }
604
+ }),
605
+ { maxIterations: 5 }
606
+ )
607
+ .build()
608
+
609
+ await built.execute({})
610
+
611
+ expect(iterations).toBe(5)
612
+ })
613
+
614
+ it('throws error when maxIterations exceeded without breakOnMax option', async () => {
615
+ const built = workflow('overflow')
616
+ .step('init', async () => ({}))
617
+ .loop(
618
+ () => true,
619
+ workflow('body').step('tick', async () => ({})),
620
+ { maxIterations: 3, throwOnMaxIterations: true }
621
+ )
622
+ .build()
623
+
624
+ await expect(built.execute({})).rejects.toThrow(/max.*iterations.*exceeded/i)
625
+ })
626
+ })
627
+
628
+ describe('.forEach(items, steps)', () => {
629
+ it('iterates over array items', async () => {
630
+ const processed: string[] = []
631
+
632
+ const built = workflow('for-each')
633
+ .step('init', async () => ({ items: ['a', 'b', 'c'] }))
634
+ .forEach(
635
+ (ctx) => ctx.result.items,
636
+ workflow('process-item').step(
637
+ 'process',
638
+ async (input: { item: string; index: number }) => {
639
+ processed.push(input.item)
640
+ return { processed: input.item }
641
+ }
642
+ )
643
+ )
644
+ .build()
645
+
646
+ await built.execute({})
647
+
648
+ expect(processed).toEqual(['a', 'b', 'c'])
649
+ })
650
+
651
+ it('provides item and index to loop body', async () => {
652
+ const captured: Array<{ item: string; index: number }> = []
653
+
654
+ const built = workflow('indexed-foreach')
655
+ .step('init', async () => ({ list: ['x', 'y', 'z'] }))
656
+ .forEach(
657
+ (ctx) => ctx.result.list,
658
+ workflow('capture').step('capture', async (input: { item: string; index: number }) => {
659
+ captured.push({ item: input.item, index: input.index })
660
+ return {}
661
+ })
662
+ )
663
+ .build()
664
+
665
+ await built.execute({})
666
+
667
+ expect(captured).toEqual([
668
+ { item: 'x', index: 0 },
669
+ { item: 'y', index: 1 },
670
+ { item: 'z', index: 2 },
671
+ ])
672
+ })
673
+
674
+ it('collects results from each iteration', async () => {
675
+ const built = workflow('collect-results')
676
+ .step('init', async () => ({ numbers: [1, 2, 3] }))
677
+ .forEach(
678
+ (ctx) => ctx.result.numbers,
679
+ workflow('double').step('double', async (input: { item: number }) => ({
680
+ doubled: input.item * 2,
681
+ }))
682
+ )
683
+ .build()
684
+
685
+ const result = await built.execute({})
686
+
687
+ expect(result.forEachResults).toEqual([{ doubled: 2 }, { doubled: 4 }, { doubled: 6 }])
688
+ })
689
+
690
+ it('supports parallel iteration with concurrency option', async () => {
691
+ const startTimes: number[] = []
692
+
693
+ const built = workflow('parallel-foreach')
694
+ .step('init', async () => ({ items: [1, 2, 3, 4] }))
695
+ .forEach(
696
+ (ctx) => ctx.result.items,
697
+ workflow('slow-process').step('slow', async () => {
698
+ startTimes.push(Date.now())
699
+ await new Promise((r) => setTimeout(r, 50))
700
+ return {}
701
+ }),
702
+ { concurrency: 2 }
703
+ )
704
+ .build()
705
+
706
+ await built.execute({})
707
+
708
+ // With concurrency 2, items 1&2 should start together, then 3&4
709
+ // So first two should have similar start times, last two should have similar start times
710
+ const diff12 = Math.abs(startTimes[0] - startTimes[1])
711
+ const diff34 = Math.abs(startTimes[2] - startTimes[3])
712
+
713
+ expect(diff12).toBeLessThan(20)
714
+ expect(diff34).toBeLessThan(20)
715
+ })
716
+
717
+ it('handles empty arrays gracefully', async () => {
718
+ let bodyExecuted = false
719
+
720
+ const built = workflow('empty-array')
721
+ .step('init', async () => ({ items: [] as string[] }))
722
+ .forEach(
723
+ (ctx) => ctx.result.items,
724
+ workflow('never').step('never', async () => {
725
+ bodyExecuted = true
726
+ return {}
727
+ })
728
+ )
729
+ .step('after', async () => ({ completed: true }))
730
+ .build()
731
+
732
+ const result = await built.execute({})
733
+
734
+ expect(bodyExecuted).toBe(false)
735
+ expect(result.completed).toBe(true)
736
+ })
737
+ })
738
+ })
739
+
740
+ // ============================================================================
741
+ // 5. Error Handlers: .onError(handler)
742
+ // ============================================================================
743
+
744
+ describe('Error Handlers', () => {
745
+ describe('.onError(handler) on steps', () => {
746
+ it('catches errors from a single step', async () => {
747
+ const built = workflow('step-error')
748
+ .step('failing', async () => {
749
+ throw new Error('Step failed!')
750
+ })
751
+ .onError(async (error) => ({
752
+ recovered: true,
753
+ errorMessage: error.message,
754
+ }))
755
+ .build()
756
+
757
+ const result = await built.execute({})
758
+
759
+ expect(result.recovered).toBe(true)
760
+ expect(result.errorMessage).toBe('Step failed!')
761
+ })
762
+
763
+ it('error handler receives error and context', async () => {
764
+ let capturedError: Error | null = null
765
+ let capturedContext: StepContext | null = null
766
+
767
+ const built = workflow('error-context')
768
+ .step('setup', async () => ({ setupValue: 42 }))
769
+ .step('failing', async () => {
770
+ throw new Error('Oops!')
771
+ })
772
+ .onError(async (error, ctx) => {
773
+ capturedError = error
774
+ capturedContext = ctx
775
+ return { handled: true }
776
+ })
777
+ .build()
778
+
779
+ await built.execute({ inputValue: 'test' })
780
+
781
+ expect(capturedError).not.toBeNull()
782
+ expect(capturedError!.message).toBe('Oops!')
783
+ expect(capturedContext).not.toBeNull()
784
+ expect(capturedContext!.input).toEqual({ inputValue: 'test' })
785
+ })
786
+
787
+ it('error handler can retry the step', async () => {
788
+ let attempts = 0
789
+
790
+ const built = workflow('retry-in-handler')
791
+ .step('flaky', async () => {
792
+ attempts++
793
+ if (attempts < 3) {
794
+ throw new Error(`Attempt ${attempts} failed`)
795
+ }
796
+ return { success: true }
797
+ })
798
+ .onError(async (error, ctx) => {
799
+ if (attempts < 3) {
800
+ return ctx.retry()
801
+ }
802
+ return { gaveUp: true }
803
+ })
804
+ .build()
805
+
806
+ const result = await built.execute({})
807
+
808
+ expect(attempts).toBe(3)
809
+ expect(result.success).toBe(true)
810
+ })
811
+
812
+ it('error handler can skip to next step', async () => {
813
+ const executed: string[] = []
814
+
815
+ const built = workflow('skip-on-error')
816
+ .step('first', async () => {
817
+ executed.push('first')
818
+ throw new Error('First failed')
819
+ })
820
+ .onError(async (_, ctx) => {
821
+ executed.push('error-handler')
822
+ return ctx.skip({ skipped: true })
823
+ })
824
+ .step('second', async () => {
825
+ executed.push('second')
826
+ return { continued: true }
827
+ })
828
+ .build()
829
+
830
+ const result = await built.execute({})
831
+
832
+ expect(executed).toEqual(['first', 'error-handler', 'second'])
833
+ expect(result.continued).toBe(true)
834
+ })
835
+
836
+ it('unhandled errors propagate', async () => {
837
+ const built = workflow('unhandled')
838
+ .step('failing', async () => {
839
+ throw new Error('Unhandled error')
840
+ })
841
+ .build()
842
+
843
+ await expect(built.execute({})).rejects.toThrow('Unhandled error')
844
+ })
845
+ })
846
+
847
+ describe('.onError(handler) on workflow', () => {
848
+ it('workflow-level error handler catches any step error', async () => {
849
+ const built = workflow('workflow-error')
850
+ .step('step1', async () => ({ done: true }))
851
+ .step('step2', async () => {
852
+ throw new Error('Step 2 failed')
853
+ })
854
+ .step('step3', async () => ({ never: true }))
855
+ .onError(async (error, ctx) => ({
856
+ workflowFailed: true,
857
+ failedAt: ctx.currentStep,
858
+ error: error.message,
859
+ }))
860
+ .build()
861
+
862
+ const result = await built.execute({})
863
+
864
+ expect(result.workflowFailed).toBe(true)
865
+ expect(result.failedAt).toBe('step2')
866
+ expect(result.error).toBe('Step 2 failed')
867
+ })
868
+
869
+ it('step-level error handler takes precedence over workflow-level', async () => {
870
+ let workflowHandlerCalled = false
871
+
872
+ const built = workflow('precedence')
873
+ .step('failing', async () => {
874
+ throw new Error('Handled at step level')
875
+ })
876
+ .onError(async () => ({ stepHandled: true }))
877
+ .step('another', async () => ({ done: true }))
878
+ .onError(async () => {
879
+ workflowHandlerCalled = true
880
+ return { workflowHandled: true }
881
+ })
882
+ .build()
883
+
884
+ const result = await built.execute({})
885
+
886
+ expect(result.stepHandled).toBe(true)
887
+ expect(workflowHandlerCalled).toBe(false)
888
+ })
889
+
890
+ it('multiple error handlers can be chained (fallback pattern)', async () => {
891
+ const handlersCalled: string[] = []
892
+
893
+ const built = workflow('fallback')
894
+ .step('failing', async () => {
895
+ throw new Error('Original error')
896
+ })
897
+ .onError(async (error) => {
898
+ handlersCalled.push('handler1')
899
+ throw error // Re-throw to next handler
900
+ })
901
+ .onError(async (error) => {
902
+ handlersCalled.push('handler2')
903
+ return { recovered: true }
904
+ })
905
+ .build()
906
+
907
+ const result = await built.execute({})
908
+
909
+ expect(handlersCalled).toEqual(['handler1', 'handler2'])
910
+ expect(result.recovered).toBe(true)
911
+ })
912
+ })
913
+
914
+ describe('error handler with typed errors', () => {
915
+ it('supports custom error types', async () => {
916
+ class ValidationError extends Error {
917
+ constructor(public field: string, message: string) {
918
+ super(message)
919
+ this.name = 'ValidationError'
920
+ }
921
+ }
922
+
923
+ const built = workflow('typed-error')
924
+ .step('validate', async () => {
925
+ throw new ValidationError('email', 'Invalid email format')
926
+ })
927
+ .onError(async (error) => {
928
+ if (error instanceof ValidationError) {
929
+ return {
930
+ validationFailed: true,
931
+ field: error.field,
932
+ message: error.message,
933
+ }
934
+ }
935
+ throw error
936
+ })
937
+ .build()
938
+
939
+ const result = await built.execute({})
940
+
941
+ expect(result.validationFailed).toBe(true)
942
+ expect(result.field).toBe('email')
943
+ })
944
+ })
945
+ })
946
+
947
+ // ============================================================================
948
+ // 6. Timeout Configuration: .timeout(ms)
949
+ // ============================================================================
950
+
951
+ describe('Timeout Configuration', () => {
952
+ describe('.timeout(ms) on steps', () => {
953
+ it('times out a slow step', async () => {
954
+ const built = workflow('timeout-step')
955
+ .step('slow', async () => {
956
+ await new Promise((r) => setTimeout(r, 1000))
957
+ return { completed: true }
958
+ })
959
+ .timeout(50)
960
+ .onError(async (error) => ({
961
+ timedOut: true,
962
+ error: error.message,
963
+ }))
964
+ .build()
965
+
966
+ const result = await built.execute({})
967
+
968
+ expect(result.timedOut).toBe(true)
969
+ expect(result.error).toMatch(/timeout/i)
970
+ })
971
+
972
+ it('accepts timeout as string duration', async () => {
973
+ const built = workflow('string-timeout')
974
+ .step('slow', async () => {
975
+ await new Promise((r) => setTimeout(r, 1000))
976
+ return {}
977
+ })
978
+ .timeout('50ms')
979
+ .onError(async () => ({ timedOut: true }))
980
+ .build()
981
+
982
+ const result = await built.execute({})
983
+
984
+ expect(result.timedOut).toBe(true)
985
+ })
986
+
987
+ it('completes if step finishes before timeout', async () => {
988
+ const built = workflow('fast-enough')
989
+ .step('fast', async () => {
990
+ await new Promise((r) => setTimeout(r, 10))
991
+ return { completed: true }
992
+ })
993
+ .timeout(1000)
994
+ .build()
995
+
996
+ const result = await built.execute({})
997
+
998
+ expect(result.completed).toBe(true)
999
+ })
1000
+
1001
+ it('timeout applies per step', async () => {
1002
+ const results: string[] = []
1003
+
1004
+ const built = workflow('per-step-timeout')
1005
+ .step('fast', async () => {
1006
+ results.push('fast')
1007
+ return {}
1008
+ })
1009
+ .timeout(1000)
1010
+ .step('slow', async () => {
1011
+ await new Promise((r) => setTimeout(r, 100))
1012
+ results.push('slow')
1013
+ return {}
1014
+ })
1015
+ .timeout(50)
1016
+ .onError(async () => ({ slowTimedOut: true }))
1017
+ .build()
1018
+
1019
+ await built.execute({})
1020
+
1021
+ expect(results).toContain('fast')
1022
+ expect(results).not.toContain('slow')
1023
+ })
1024
+ })
1025
+
1026
+ describe('.timeout(ms) on workflow', () => {
1027
+ it('applies timeout to entire workflow', async () => {
1028
+ const built = workflow('workflow-timeout')
1029
+ .step('step1', async () => {
1030
+ await new Promise((r) => setTimeout(r, 30))
1031
+ return {}
1032
+ })
1033
+ .step('step2', async () => {
1034
+ await new Promise((r) => setTimeout(r, 30))
1035
+ return {}
1036
+ })
1037
+ .step('step3', async () => {
1038
+ await new Promise((r) => setTimeout(r, 30))
1039
+ return {}
1040
+ })
1041
+ .timeout(50) // Total workflow timeout
1042
+ .onError(async () => ({ workflowTimedOut: true }))
1043
+ .build()
1044
+
1045
+ const result = await built.execute({})
1046
+
1047
+ expect(result.workflowTimedOut).toBe(true)
1048
+ })
1049
+ })
1050
+ })
1051
+
1052
+ // ============================================================================
1053
+ // 7. Retry Configuration: .retry({ attempts, backoff })
1054
+ // ============================================================================
1055
+
1056
+ describe('Retry Configuration', () => {
1057
+ describe('.retry({ attempts }) on steps', () => {
1058
+ it('retries failed step specified number of times', async () => {
1059
+ let attempts = 0
1060
+
1061
+ const built = workflow('retry-attempts')
1062
+ .step('flaky', async () => {
1063
+ attempts++
1064
+ if (attempts < 3) {
1065
+ throw new Error(`Attempt ${attempts} failed`)
1066
+ }
1067
+ return { success: true }
1068
+ })
1069
+ .retry({ attempts: 5 })
1070
+ .build()
1071
+
1072
+ const result = await built.execute({})
1073
+
1074
+ expect(attempts).toBe(3)
1075
+ expect(result.success).toBe(true)
1076
+ })
1077
+
1078
+ it('fails after exhausting all retries', async () => {
1079
+ let attempts = 0
1080
+
1081
+ const built = workflow('exhaust-retries')
1082
+ .step('alwaysFails', async () => {
1083
+ attempts++
1084
+ throw new Error('Always fails')
1085
+ })
1086
+ .retry({ attempts: 3 })
1087
+ .build()
1088
+
1089
+ await expect(built.execute({})).rejects.toThrow('Always fails')
1090
+ expect(attempts).toBe(3)
1091
+ })
1092
+
1093
+ it('does not retry on success', async () => {
1094
+ let attempts = 0
1095
+
1096
+ const built = workflow('no-retry-needed')
1097
+ .step('succeeds', async () => {
1098
+ attempts++
1099
+ return { done: true }
1100
+ })
1101
+ .retry({ attempts: 5 })
1102
+ .build()
1103
+
1104
+ await built.execute({})
1105
+
1106
+ expect(attempts).toBe(1)
1107
+ })
1108
+ })
1109
+
1110
+ describe('.retry({ backoff }) strategies', () => {
1111
+ it('supports constant backoff', async () => {
1112
+ const attemptTimes: number[] = []
1113
+
1114
+ const built = workflow('constant-backoff')
1115
+ .step('failing', async () => {
1116
+ attemptTimes.push(Date.now())
1117
+ if (attemptTimes.length < 3) {
1118
+ throw new Error('Retry me')
1119
+ }
1120
+ return { done: true }
1121
+ })
1122
+ .retry({
1123
+ attempts: 5,
1124
+ backoff: 'constant',
1125
+ delay: 50,
1126
+ })
1127
+ .build()
1128
+
1129
+ await built.execute({})
1130
+
1131
+ // Check delays are approximately constant
1132
+ const delay1 = attemptTimes[1] - attemptTimes[0]
1133
+ const delay2 = attemptTimes[2] - attemptTimes[1]
1134
+
1135
+ expect(delay1).toBeGreaterThanOrEqual(45)
1136
+ expect(delay1).toBeLessThan(100)
1137
+ expect(delay2).toBeGreaterThanOrEqual(45)
1138
+ expect(delay2).toBeLessThan(100)
1139
+ })
1140
+
1141
+ it('supports linear backoff', async () => {
1142
+ const attemptTimes: number[] = []
1143
+
1144
+ const built = workflow('linear-backoff')
1145
+ .step('failing', async () => {
1146
+ attemptTimes.push(Date.now())
1147
+ if (attemptTimes.length < 4) {
1148
+ throw new Error('Retry me')
1149
+ }
1150
+ return { done: true }
1151
+ })
1152
+ .retry({
1153
+ attempts: 5,
1154
+ backoff: 'linear',
1155
+ delay: 20, // Base delay
1156
+ })
1157
+ .build()
1158
+
1159
+ await built.execute({})
1160
+
1161
+ // Linear: delay, delay*2, delay*3, ...
1162
+ const delay1 = attemptTimes[1] - attemptTimes[0]
1163
+ const delay2 = attemptTimes[2] - attemptTimes[1]
1164
+ const delay3 = attemptTimes[3] - attemptTimes[2]
1165
+
1166
+ expect(delay2).toBeGreaterThan(delay1)
1167
+ expect(delay3).toBeGreaterThan(delay2)
1168
+ })
1169
+
1170
+ it('supports exponential backoff', async () => {
1171
+ const attemptTimes: number[] = []
1172
+
1173
+ const built = workflow('exponential-backoff')
1174
+ .step('failing', async () => {
1175
+ attemptTimes.push(Date.now())
1176
+ if (attemptTimes.length < 4) {
1177
+ throw new Error('Retry me')
1178
+ }
1179
+ return { done: true }
1180
+ })
1181
+ .retry({
1182
+ attempts: 5,
1183
+ backoff: 'exponential',
1184
+ delay: 10, // Base delay
1185
+ })
1186
+ .build()
1187
+
1188
+ await built.execute({})
1189
+
1190
+ // Exponential: delay, delay*2, delay*4, delay*8, ...
1191
+ const delay1 = attemptTimes[1] - attemptTimes[0]
1192
+ const delay2 = attemptTimes[2] - attemptTimes[1]
1193
+ const delay3 = attemptTimes[3] - attemptTimes[2]
1194
+
1195
+ // Each delay should be roughly double the previous
1196
+ expect(delay2).toBeGreaterThan(delay1 * 1.5)
1197
+ expect(delay3).toBeGreaterThan(delay2 * 1.5)
1198
+ })
1199
+
1200
+ it('supports jitter option for backoff', async () => {
1201
+ const attemptTimes1: number[] = []
1202
+ const attemptTimes2: number[] = []
1203
+
1204
+ const createWorkflow = () =>
1205
+ workflow('jitter-backoff')
1206
+ .step('failing', async () => {
1207
+ throw new Error('Always fails')
1208
+ })
1209
+ .retry({
1210
+ attempts: 3,
1211
+ backoff: 'exponential',
1212
+ delay: 50,
1213
+ jitter: true,
1214
+ })
1215
+ .onError(async () => ({ failed: true }))
1216
+ .build()
1217
+
1218
+ // Run twice and collect timing - with jitter, times should vary
1219
+ const built1 = createWorkflow()
1220
+ const built2 = createWorkflow()
1221
+
1222
+ // Execute and track (simplified - in practice would need to capture)
1223
+ await built1.execute({})
1224
+ await built2.execute({})
1225
+
1226
+ // Jitter should introduce randomness (hard to test deterministically)
1227
+ // Just verify the option is accepted
1228
+ expect(true).toBe(true)
1229
+ })
1230
+
1231
+ it('supports maxDelay cap', async () => {
1232
+ const attemptTimes: number[] = []
1233
+
1234
+ const built = workflow('capped-backoff')
1235
+ .step('failing', async () => {
1236
+ attemptTimes.push(Date.now())
1237
+ if (attemptTimes.length < 5) {
1238
+ throw new Error('Retry me')
1239
+ }
1240
+ return { done: true }
1241
+ })
1242
+ .retry({
1243
+ attempts: 6,
1244
+ backoff: 'exponential',
1245
+ delay: 20,
1246
+ maxDelay: 50, // Cap at 50ms
1247
+ })
1248
+ .build()
1249
+
1250
+ await built.execute({})
1251
+
1252
+ // Later delays should not exceed maxDelay
1253
+ for (let i = 2; i < attemptTimes.length; i++) {
1254
+ const delay = attemptTimes[i] - attemptTimes[i - 1]
1255
+ expect(delay).toBeLessThanOrEqual(100) // Allow some tolerance
1256
+ }
1257
+ })
1258
+ })
1259
+
1260
+ describe('.retry() with conditions', () => {
1261
+ it('only retries on specific error types', async () => {
1262
+ class RetryableError extends Error {}
1263
+ class FatalError extends Error {}
1264
+
1265
+ let attempts = 0
1266
+
1267
+ const built = workflow('conditional-retry')
1268
+ .step('conditional', async () => {
1269
+ attempts++
1270
+ if (attempts === 1) {
1271
+ throw new RetryableError('Retry this')
1272
+ }
1273
+ if (attempts === 2) {
1274
+ throw new FatalError('Do not retry')
1275
+ }
1276
+ return { success: true }
1277
+ })
1278
+ .retry({
1279
+ attempts: 5,
1280
+ retryIf: (error) => error instanceof RetryableError,
1281
+ })
1282
+ .build()
1283
+
1284
+ await expect(built.execute({})).rejects.toThrow('Do not retry')
1285
+ expect(attempts).toBe(2)
1286
+ })
1287
+
1288
+ it('provides attempt number to retry condition', async () => {
1289
+ const attemptNumbers: number[] = []
1290
+
1291
+ const built = workflow('attempt-number')
1292
+ .step('failing', async () => {
1293
+ throw new Error('Fail')
1294
+ })
1295
+ .retry({
1296
+ attempts: 5,
1297
+ retryIf: (error, attempt) => {
1298
+ attemptNumbers.push(attempt)
1299
+ return attempt < 3
1300
+ },
1301
+ })
1302
+ .build()
1303
+
1304
+ await expect(built.execute({})).rejects.toThrow('Fail')
1305
+
1306
+ expect(attemptNumbers).toEqual([1, 2, 3])
1307
+ })
1308
+ })
1309
+
1310
+ describe('.retry() on workflow', () => {
1311
+ it('applies default retry config to all steps', () => {
1312
+ const built = workflow('workflow-retry')
1313
+ .retry({ attempts: 3, backoff: 'exponential', delay: 100 })
1314
+ .step('step1', async () => ({ a: 1 }))
1315
+ .step('step2', async () => ({ b: 2 }))
1316
+ .build()
1317
+
1318
+ // All steps should have the default retry config
1319
+ expect(built.defaultRetryConfig).toEqual({
1320
+ attempts: 3,
1321
+ backoff: 'exponential',
1322
+ delay: 100,
1323
+ })
1324
+ })
1325
+
1326
+ it('step-level retry overrides workflow-level', async () => {
1327
+ let step1Attempts = 0
1328
+ let step2Attempts = 0
1329
+
1330
+ const built = workflow('override-retry')
1331
+ .retry({ attempts: 2 })
1332
+ .step('step1', async () => {
1333
+ step1Attempts++
1334
+ throw new Error('Step 1 fails')
1335
+ })
1336
+ .retry({ attempts: 5 }) // Override for step1
1337
+ .onError(async () => ({ step1Failed: true }))
1338
+ .step('step2', async () => {
1339
+ step2Attempts++
1340
+ throw new Error('Step 2 fails')
1341
+ })
1342
+ // Uses workflow default (2 attempts)
1343
+ .onError(async () => ({ step2Failed: true }))
1344
+ .build()
1345
+
1346
+ await built.execute({})
1347
+
1348
+ expect(step1Attempts).toBe(5)
1349
+ expect(step2Attempts).toBe(2)
1350
+ })
1351
+ })
1352
+ })
1353
+
1354
+ // ============================================================================
1355
+ // 8. Input/Output Typing
1356
+ // ============================================================================
1357
+
1358
+ describe('Input/Output Typing', () => {
1359
+ describe('workflow input typing', () => {
1360
+ it('enforces input type on execute', async () => {
1361
+ interface OrderInput {
1362
+ orderId: string
1363
+ amount: number
1364
+ }
1365
+
1366
+ const built = workflow<OrderInput>('typed-input')
1367
+ .step('validate', async (input) => {
1368
+ // TypeScript should infer input as OrderInput
1369
+ return { valid: true, orderId: input.orderId }
1370
+ })
1371
+ .build()
1372
+
1373
+ // This should type-check correctly
1374
+ const result = await built.execute({ orderId: 'order-123', amount: 99.99 })
1375
+
1376
+ expect(result.valid).toBe(true)
1377
+ })
1378
+
1379
+ it('workflow input is passed to first step', async () => {
1380
+ interface UserInput {
1381
+ userId: string
1382
+ email: string
1383
+ }
1384
+
1385
+ let receivedInput: UserInput | null = null
1386
+
1387
+ const built = workflow<UserInput>('input-passthrough')
1388
+ .step('receive', async (input) => {
1389
+ receivedInput = input
1390
+ return { received: true }
1391
+ })
1392
+ .build()
1393
+
1394
+ await built.execute({ userId: 'user-1', email: 'test@example.com' })
1395
+
1396
+ expect(receivedInput).toEqual({ userId: 'user-1', email: 'test@example.com' })
1397
+ })
1398
+ })
1399
+
1400
+ describe('step output typing', () => {
1401
+ it('step output type flows to next step input', async () => {
1402
+ interface Step1Output {
1403
+ value: number
1404
+ label: string
1405
+ }
1406
+
1407
+ const built = workflow('typed-chain')
1408
+ .step(
1409
+ 'first',
1410
+ async (): Promise<Step1Output> => ({
1411
+ value: 42,
1412
+ label: 'answer',
1413
+ })
1414
+ )
1415
+ .step('second', async (input: Step1Output) => {
1416
+ // TypeScript should know input has value and label
1417
+ return { doubled: input.value * 2 }
1418
+ })
1419
+ .build()
1420
+
1421
+ const result = await built.execute({})
1422
+
1423
+ expect(result.doubled).toBe(84)
1424
+ })
1425
+
1426
+ it('generic step function preserves types', async () => {
1427
+ const built = workflow('generic-step')
1428
+ .step<{ name: string }, { greeting: string }>('greet', async (input) => ({
1429
+ greeting: `Hello, ${input.name}!`,
1430
+ }))
1431
+ .build()
1432
+
1433
+ const result = await built.execute({ name: 'World' })
1434
+
1435
+ expect(result.greeting).toBe('Hello, World!')
1436
+ })
1437
+ })
1438
+
1439
+ describe('workflow output typing', () => {
1440
+ it('build() returns typed workflow with output type', async () => {
1441
+ interface WorkflowOutput {
1442
+ processed: boolean
1443
+ id: string
1444
+ }
1445
+
1446
+ const built = workflow<{ id: string }, WorkflowOutput>('typed-output')
1447
+ .step(
1448
+ 'process',
1449
+ async (input): Promise<WorkflowOutput> => ({
1450
+ processed: true,
1451
+ id: input.id,
1452
+ })
1453
+ )
1454
+ .build()
1455
+
1456
+ const result = await built.execute({ id: 'test-123' })
1457
+
1458
+ // result should be typed as WorkflowOutput
1459
+ expect(result.processed).toBe(true)
1460
+ expect(result.id).toBe('test-123')
1461
+ })
1462
+
1463
+ it('final step output is workflow output', async () => {
1464
+ const built = workflow<void, { final: string }>('final-output')
1465
+ .step('step1', async () => ({ intermediate: 'value' }))
1466
+ .step('step2', async () => ({ final: 'result' }))
1467
+ .build()
1468
+
1469
+ const result = await built.execute()
1470
+
1471
+ expect(result.final).toBe('result')
1472
+ })
1473
+ })
1474
+
1475
+ describe('type inference through complex chains', () => {
1476
+ it('types flow through conditionals', async () => {
1477
+ interface Input {
1478
+ value: number
1479
+ }
1480
+
1481
+ const built = workflow<Input>('conditional-types')
1482
+ .step('check', async (input) => ({ high: input.value > 50 }))
1483
+ .when((ctx) => ctx.result.high)
1484
+ .then(
1485
+ workflow('high-branch').step('handleHigh', async () => ({ tier: 'premium' as const }))
1486
+ )
1487
+ .else(workflow('low-branch').step('handleLow', async () => ({ tier: 'standard' as const })))
1488
+ .build()
1489
+
1490
+ const highResult = await built.execute({ value: 75 })
1491
+ expect(highResult.tier).toBe('premium')
1492
+
1493
+ const lowResult = await built.execute({ value: 25 })
1494
+ expect(lowResult.tier).toBe('standard')
1495
+ })
1496
+
1497
+ it('types flow through loops', async () => {
1498
+ interface LoopInput {
1499
+ items: string[]
1500
+ }
1501
+
1502
+ const built = workflow<LoopInput>('loop-types')
1503
+ .step('init', async (input) => ({
1504
+ remaining: input.items,
1505
+ processed: [] as string[],
1506
+ }))
1507
+ .loop(
1508
+ (ctx) => ctx.result.remaining.length > 0,
1509
+ workflow('loop-body').step('process', async (input) => ({
1510
+ remaining: input.remaining.slice(1),
1511
+ processed: [...input.processed, input.remaining[0].toUpperCase()],
1512
+ }))
1513
+ )
1514
+ .build()
1515
+
1516
+ const result = await built.execute({ items: ['a', 'b', 'c'] })
1517
+
1518
+ expect(result.processed).toEqual(['A', 'B', 'C'])
1519
+ })
1520
+ })
1521
+ })
1522
+
1523
+ // ============================================================================
1524
+ // 9. Complete Integration Example
1525
+ // ============================================================================
1526
+
1527
+ describe('Complete Integration Example', () => {
1528
+ it('builds and executes a complex order processing workflow', async () => {
1529
+ interface OrderInput {
1530
+ orderId: string
1531
+ customerId: string
1532
+ items: Array<{ sku: string; quantity: number; price: number }>
1533
+ }
1534
+
1535
+ const built = workflow<OrderInput>('order-processing')
1536
+ // Step 1: Validate order
1537
+ .step('validate', async (input) => {
1538
+ const isValid = input.items.length > 0
1539
+ const total = input.items.reduce((sum, item) => sum + item.price * item.quantity, 0)
1540
+ return { valid: isValid, total, ...input }
1541
+ })
1542
+ .retry({ attempts: 3 })
1543
+
1544
+ // Step 2: Check inventory (conditional)
1545
+ .when((ctx) => ctx.result.valid)
1546
+ .then(
1547
+ workflow('inventory-check')
1548
+ .step('checkStock', async (input) => {
1549
+ // Simulate inventory check
1550
+ return { inStock: true, ...input }
1551
+ })
1552
+ .timeout(5000)
1553
+ )
1554
+ .else(
1555
+ workflow('invalid-order').step('reject', async () => ({
1556
+ rejected: true,
1557
+ reason: 'Invalid order',
1558
+ }))
1559
+ )
1560
+
1561
+ // Step 3: Process payment (with error handling)
1562
+ .step('payment', async (input) => {
1563
+ if (!input.inStock) {
1564
+ throw new Error('Cannot process payment: out of stock')
1565
+ }
1566
+ return {
1567
+ paid: true,
1568
+ transactionId: `txn_${input.orderId}`,
1569
+ amount: input.total,
1570
+ }
1571
+ })
1572
+ .retry({ attempts: 3, backoff: 'exponential', delay: 100 })
1573
+ .timeout(10000)
1574
+ .onError(async (error, ctx) => ({
1575
+ paymentFailed: true,
1576
+ error: error.message,
1577
+ refundRequired: false,
1578
+ }))
1579
+
1580
+ // Step 4: Fulfill order items in parallel
1581
+ .step('fulfill', async (input) => {
1582
+ if (!input.paid) {
1583
+ return { fulfilled: false }
1584
+ }
1585
+ return {
1586
+ fulfilled: true,
1587
+ trackingNumber: `track_${input.orderId}`,
1588
+ }
1589
+ })
1590
+
1591
+ // Step 5: Send notification
1592
+ .step('notify', async (input) => ({
1593
+ notified: true,
1594
+ message: input.fulfilled
1595
+ ? `Order ${input.orderId} shipped!`
1596
+ : `Order ${input.orderId} could not be processed`,
1597
+ }))
1598
+
1599
+ .build()
1600
+
1601
+ // Execute the workflow
1602
+ const result = await built.execute({
1603
+ orderId: 'order-456',
1604
+ customerId: 'cust-789',
1605
+ items: [
1606
+ { sku: 'WIDGET-001', quantity: 2, price: 29.99 },
1607
+ { sku: 'GADGET-002', quantity: 1, price: 49.99 },
1608
+ ],
1609
+ })
1610
+
1611
+ expect(result.valid).toBe(true)
1612
+ expect(result.inStock).toBe(true)
1613
+ expect(result.paid).toBe(true)
1614
+ expect(result.fulfilled).toBe(true)
1615
+ expect(result.notified).toBe(true)
1616
+ expect(result.trackingNumber).toBe('track_order-456')
1617
+ })
1618
+ })
1619
+
1620
+ // ============================================================================
1621
+ // 10. Type Definitions (for reference)
1622
+ // ============================================================================
1623
+
1624
+ describe('Type Definitions', () => {
1625
+ it('RetryConfig type matches expected shape', () => {
1626
+ const config: RetryConfig = {
1627
+ attempts: 3,
1628
+ backoff: 'exponential',
1629
+ delay: 100,
1630
+ maxDelay: 5000,
1631
+ jitter: true,
1632
+ retryIf: (error, attempt) => attempt < 3,
1633
+ }
1634
+
1635
+ expect(config.attempts).toBe(3)
1636
+ expect(config.backoff).toBe('exponential')
1637
+ })
1638
+
1639
+ it('StepContext type provides expected methods', async () => {
1640
+ let capturedCtx: StepContext | null = null
1641
+
1642
+ const built = workflow('context-shape')
1643
+ .step('capture', async (_, ctx) => {
1644
+ capturedCtx = ctx
1645
+ return {}
1646
+ })
1647
+ .build()
1648
+
1649
+ await built.execute({})
1650
+
1651
+ expect(capturedCtx).not.toBeNull()
1652
+ expect(typeof capturedCtx!.retry).toBe('function')
1653
+ expect(typeof capturedCtx!.skip).toBe('function')
1654
+ expect(typeof capturedCtx!.abort).toBe('function')
1655
+ expect(capturedCtx!.input).toBeDefined()
1656
+ expect(capturedCtx!.result).toBeDefined()
1657
+ })
1658
+
1659
+ it('BuiltWorkflow type has expected properties', () => {
1660
+ const built = workflow('type-check')
1661
+ .step('test', async () => ({}))
1662
+ .build()
1663
+
1664
+ // BuiltWorkflow should have these properties
1665
+ expect(built).toHaveProperty('name')
1666
+ expect(built).toHaveProperty('steps')
1667
+ expect(built).toHaveProperty('execute')
1668
+ expect(typeof built.execute).toBe('function')
1669
+ })
1670
+ })