flowcraft 1.0.0-beta.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 (148) hide show
  1. package/.editorconfig +9 -0
  2. package/LICENSE +21 -0
  3. package/README.md +249 -0
  4. package/config/tsconfig.json +21 -0
  5. package/config/tsup.config.ts +11 -0
  6. package/config/vitest.config.ts +11 -0
  7. package/docs/.vitepress/config.ts +105 -0
  8. package/docs/api-reference/builder.md +158 -0
  9. package/docs/api-reference/fn.md +142 -0
  10. package/docs/api-reference/index.md +38 -0
  11. package/docs/api-reference/workflow.md +126 -0
  12. package/docs/guide/advanced-guides/cancellation.md +117 -0
  13. package/docs/guide/advanced-guides/composition.md +68 -0
  14. package/docs/guide/advanced-guides/custom-executor.md +180 -0
  15. package/docs/guide/advanced-guides/error-handling.md +135 -0
  16. package/docs/guide/advanced-guides/logging.md +106 -0
  17. package/docs/guide/advanced-guides/middleware.md +106 -0
  18. package/docs/guide/advanced-guides/observability.md +175 -0
  19. package/docs/guide/best-practices/debugging.md +182 -0
  20. package/docs/guide/best-practices/state-management.md +120 -0
  21. package/docs/guide/best-practices/sub-workflow-data.md +95 -0
  22. package/docs/guide/best-practices/testing.md +187 -0
  23. package/docs/guide/builders.md +157 -0
  24. package/docs/guide/functional-api.md +133 -0
  25. package/docs/guide/index.md +178 -0
  26. package/docs/guide/recipes/creating-a-loop.md +113 -0
  27. package/docs/guide/recipes/data-processing-pipeline.md +123 -0
  28. package/docs/guide/recipes/fan-out-fan-in.md +112 -0
  29. package/docs/guide/recipes/index.md +15 -0
  30. package/docs/guide/recipes/resilient-api-call.md +110 -0
  31. package/docs/guide/tooling/graph-validation.md +160 -0
  32. package/docs/guide/tooling/mermaid.md +156 -0
  33. package/docs/index.md +56 -0
  34. package/eslint.config.js +16 -0
  35. package/package.json +40 -0
  36. package/pnpm-workspace.yaml +2 -0
  37. package/sandbox/1.basic/README.md +45 -0
  38. package/sandbox/1.basic/package.json +16 -0
  39. package/sandbox/1.basic/src/flow.ts +17 -0
  40. package/sandbox/1.basic/src/main.ts +22 -0
  41. package/sandbox/1.basic/src/nodes.ts +112 -0
  42. package/sandbox/1.basic/src/utils.ts +35 -0
  43. package/sandbox/1.basic/tsconfig.json +3 -0
  44. package/sandbox/2.research/README.md +46 -0
  45. package/sandbox/2.research/package.json +16 -0
  46. package/sandbox/2.research/src/flow.ts +14 -0
  47. package/sandbox/2.research/src/main.ts +31 -0
  48. package/sandbox/2.research/src/nodes.ts +108 -0
  49. package/sandbox/2.research/src/utils.ts +45 -0
  50. package/sandbox/2.research/src/visualize.ts +29 -0
  51. package/sandbox/2.research/tsconfig.json +3 -0
  52. package/sandbox/3.parallel/README.md +65 -0
  53. package/sandbox/3.parallel/package.json +16 -0
  54. package/sandbox/3.parallel/src/main.ts +45 -0
  55. package/sandbox/3.parallel/src/nodes.ts +43 -0
  56. package/sandbox/3.parallel/src/utils.ts +25 -0
  57. package/sandbox/3.parallel/tsconfig.json +3 -0
  58. package/sandbox/4.dag/README.md +179 -0
  59. package/sandbox/4.dag/data/1.blog-post/100.json +60 -0
  60. package/sandbox/4.dag/data/1.blog-post/README.md +25 -0
  61. package/sandbox/4.dag/data/2.job-application/200.json +103 -0
  62. package/sandbox/4.dag/data/2.job-application/201.json +31 -0
  63. package/sandbox/4.dag/data/2.job-application/202.json +31 -0
  64. package/sandbox/4.dag/data/2.job-application/README.md +58 -0
  65. package/sandbox/4.dag/data/3.customer-review/300.json +141 -0
  66. package/sandbox/4.dag/data/3.customer-review/301.json +31 -0
  67. package/sandbox/4.dag/data/3.customer-review/302.json +28 -0
  68. package/sandbox/4.dag/data/3.customer-review/README.md +71 -0
  69. package/sandbox/4.dag/data/4.content-moderation/400.json +161 -0
  70. package/sandbox/4.dag/data/4.content-moderation/401.json +47 -0
  71. package/sandbox/4.dag/data/4.content-moderation/402.json +46 -0
  72. package/sandbox/4.dag/data/4.content-moderation/403.json +31 -0
  73. package/sandbox/4.dag/data/4.content-moderation/README.md +83 -0
  74. package/sandbox/4.dag/package.json +19 -0
  75. package/sandbox/4.dag/src/main.ts +73 -0
  76. package/sandbox/4.dag/src/nodes.ts +134 -0
  77. package/sandbox/4.dag/src/registry.ts +87 -0
  78. package/sandbox/4.dag/src/types.ts +25 -0
  79. package/sandbox/4.dag/src/utils.ts +42 -0
  80. package/sandbox/4.dag/tsconfig.json +3 -0
  81. package/sandbox/5.distributed/.env.example +1 -0
  82. package/sandbox/5.distributed/README.md +88 -0
  83. package/sandbox/5.distributed/data/1.blog-post/100.json +59 -0
  84. package/sandbox/5.distributed/data/1.blog-post/README.md +25 -0
  85. package/sandbox/5.distributed/data/2.job-application/200.json +103 -0
  86. package/sandbox/5.distributed/data/2.job-application/201.json +30 -0
  87. package/sandbox/5.distributed/data/2.job-application/202.json +30 -0
  88. package/sandbox/5.distributed/data/2.job-application/README.md +58 -0
  89. package/sandbox/5.distributed/data/3.customer-review/300.json +141 -0
  90. package/sandbox/5.distributed/data/3.customer-review/301.json +31 -0
  91. package/sandbox/5.distributed/data/3.customer-review/302.json +57 -0
  92. package/sandbox/5.distributed/data/3.customer-review/README.md +71 -0
  93. package/sandbox/5.distributed/data/4.content-moderation/400.json +173 -0
  94. package/sandbox/5.distributed/data/4.content-moderation/401.json +47 -0
  95. package/sandbox/5.distributed/data/4.content-moderation/402.json +46 -0
  96. package/sandbox/5.distributed/data/4.content-moderation/403.json +31 -0
  97. package/sandbox/5.distributed/data/4.content-moderation/README.md +83 -0
  98. package/sandbox/5.distributed/package.json +20 -0
  99. package/sandbox/5.distributed/src/client.ts +124 -0
  100. package/sandbox/5.distributed/src/executor.ts +69 -0
  101. package/sandbox/5.distributed/src/nodes.ts +136 -0
  102. package/sandbox/5.distributed/src/registry.ts +101 -0
  103. package/sandbox/5.distributed/src/types.ts +45 -0
  104. package/sandbox/5.distributed/src/utils.ts +69 -0
  105. package/sandbox/5.distributed/src/worker.ts +217 -0
  106. package/sandbox/5.distributed/tsconfig.json +3 -0
  107. package/sandbox/6.rag/.env.example +1 -0
  108. package/sandbox/6.rag/README.md +60 -0
  109. package/sandbox/6.rag/data/README.md +31 -0
  110. package/sandbox/6.rag/data/rag.json +58 -0
  111. package/sandbox/6.rag/documents/sample-cascade.txt +11 -0
  112. package/sandbox/6.rag/package.json +18 -0
  113. package/sandbox/6.rag/src/main.ts +52 -0
  114. package/sandbox/6.rag/src/nodes/GenerateEmbeddingsNode.ts +54 -0
  115. package/sandbox/6.rag/src/nodes/LLMProcessNode.ts +48 -0
  116. package/sandbox/6.rag/src/nodes/LoadAndChunkNode.ts +40 -0
  117. package/sandbox/6.rag/src/nodes/StoreInVectorDBNode.ts +36 -0
  118. package/sandbox/6.rag/src/nodes/VectorSearchNode.ts +53 -0
  119. package/sandbox/6.rag/src/nodes/index.ts +28 -0
  120. package/sandbox/6.rag/src/registry.ts +23 -0
  121. package/sandbox/6.rag/src/types.ts +44 -0
  122. package/sandbox/6.rag/src/utils.ts +77 -0
  123. package/sandbox/6.rag/tsconfig.json +3 -0
  124. package/sandbox/tsconfig.json +13 -0
  125. package/src/builder/collection.test.ts +287 -0
  126. package/src/builder/collection.ts +269 -0
  127. package/src/builder/graph.test.ts +406 -0
  128. package/src/builder/graph.ts +336 -0
  129. package/src/builder/graph.types.ts +104 -0
  130. package/src/builder/index.ts +3 -0
  131. package/src/context.ts +111 -0
  132. package/src/errors.ts +34 -0
  133. package/src/executor.ts +29 -0
  134. package/src/executors/in-memory.test.ts +93 -0
  135. package/src/executors/in-memory.ts +140 -0
  136. package/src/functions.test.ts +191 -0
  137. package/src/functions.ts +117 -0
  138. package/src/index.ts +5 -0
  139. package/src/logger.ts +41 -0
  140. package/src/types.ts +75 -0
  141. package/src/utils/graph.test.ts +144 -0
  142. package/src/utils/graph.ts +182 -0
  143. package/src/utils/index.ts +3 -0
  144. package/src/utils/mermaid.test.ts +239 -0
  145. package/src/utils/mermaid.ts +133 -0
  146. package/src/utils/sleep.ts +20 -0
  147. package/src/workflow.test.ts +622 -0
  148. package/src/workflow.ts +561 -0
@@ -0,0 +1,622 @@
1
+ import type { AbstractNode, ContextKey, Logger, NodeArgs, NodeOptions, RunOptions } from './workflow'
2
+ import { afterEach, describe, expect, it, vi } from 'vitest'
3
+ import { BatchFlow, ParallelBatchFlow } from './builder/collection'
4
+ import { sleep } from './utils/index'
5
+ import {
6
+ AbortError,
7
+ composeContext,
8
+ contextKey,
9
+ DEFAULT_ACTION,
10
+ FILTER_FAILED,
11
+ Flow,
12
+ lens,
13
+ Node,
14
+ TypedContext,
15
+ WorkflowError,
16
+ } from './workflow'
17
+
18
+ const CURRENT = contextKey<number>('current')
19
+ const PATH_TAKEN = contextKey<string>('path_taken')
20
+ const STARTED = contextKey<number[]>('started')
21
+ const FINISHED = contextKey<number[]>('finished')
22
+ const RESULT = contextKey<string>('result')
23
+ const ATTEMPTS = contextKey<number>('attempts')
24
+ const NAME = contextKey<string>('name')
25
+ const COUNTER = contextKey<number>('counter')
26
+ const FINAL_RESULT = contextKey<string>('final_result')
27
+ const LENS_VALUE = contextKey<number>('lens_value')
28
+ const MIDDLEWARE_PATH = contextKey<string[]>('middleware_path')
29
+
30
+ // Mock logger for testing
31
+ function createMockLogger(): Logger {
32
+ return {
33
+ debug: vi.fn(),
34
+ info: vi.fn(),
35
+ warn: vi.fn(),
36
+ error: vi.fn(),
37
+ }
38
+ }
39
+
40
+ let mockLogger = createMockLogger()
41
+ let runOptions: RunOptions = { logger: mockLogger }
42
+
43
+ afterEach(() => {
44
+ mockLogger = createMockLogger()
45
+ runOptions = { logger: mockLogger }
46
+ })
47
+
48
+ class NumberNode extends Node {
49
+ constructor(private number: number) { super() }
50
+ async prep({ ctx }: NodeArgs) {
51
+ ctx.set(CURRENT, this.number)
52
+ }
53
+ }
54
+ class AddNode extends Node {
55
+ constructor(private number: number) { super() }
56
+ async prep({ ctx }: NodeArgs) {
57
+ const current = ctx.get(CURRENT) ?? 0
58
+ ctx.set(CURRENT, current + this.number)
59
+ }
60
+ }
61
+ class MultiplyNode extends Node {
62
+ constructor(private number: number) { super() }
63
+ async prep({ ctx }: NodeArgs) {
64
+ const current = ctx.get(CURRENT) ?? 1
65
+ ctx.set(CURRENT, current * this.number)
66
+ }
67
+ }
68
+ class CheckPositiveNode extends Node<void, void, string> {
69
+ async post({ ctx }: NodeArgs): Promise<string> {
70
+ const current = ctx.get(CURRENT)!
71
+ return current >= 0 ? 'positive' : 'negative'
72
+ }
73
+ }
74
+ class SignalNode extends Node<void, void, string | typeof DEFAULT_ACTION> {
75
+ constructor(private signal: string | typeof DEFAULT_ACTION = DEFAULT_ACTION) { super() }
76
+ async post(): Promise<string | typeof DEFAULT_ACTION> {
77
+ return this.signal
78
+ }
79
+ }
80
+ class PathNode extends Node {
81
+ constructor(private pathId: string) { super() }
82
+ async prep({ ctx }: NodeArgs) {
83
+ ctx.set(PATH_TAKEN, this.pathId)
84
+ }
85
+ }
86
+ class ValueNode<T> extends Node<void, T> {
87
+ constructor(private value: T, options?: NodeOptions) { super(options) }
88
+ async exec(): Promise<T> {
89
+ return this.value
90
+ }
91
+ }
92
+ class ReadContextNode<T> extends Node<void, T | undefined> {
93
+ constructor(private key: ContextKey<T>) { super() }
94
+ async exec({ ctx }: NodeArgs): Promise<T | undefined> {
95
+ return ctx.get(this.key)
96
+ }
97
+ }
98
+
99
+ describe('testFlowBasic', () => {
100
+ it('should handle a simple linear pipeline', async () => {
101
+ const ctx = new TypedContext()
102
+ const n1 = new NumberNode(5)
103
+ const n2 = new AddNode(3)
104
+ const n3 = new MultiplyNode(2)
105
+ const flow = new Flow()
106
+ flow.start(n1).next(n2).next(n3)
107
+ const lastAction = await flow.run(ctx, runOptions)
108
+ expect(ctx.get(CURRENT)).toBe(16)
109
+ expect(lastAction).toBe(DEFAULT_ACTION)
110
+ })
111
+
112
+ it('should handle positive branching', async () => {
113
+ const ctx = new TypedContext()
114
+ const startNode = new NumberNode(5)
115
+ const checkNode = new CheckPositiveNode()
116
+ const addIfPositive = new AddNode(10)
117
+ const addIfNegative = new AddNode(-20)
118
+ const flow = new Flow(startNode)
119
+ startNode.next(checkNode)
120
+ checkNode.next(addIfPositive, 'positive')
121
+ checkNode.next(addIfNegative, 'negative')
122
+ await flow.run(ctx, runOptions)
123
+ expect(ctx.get(CURRENT)).toBe(15)
124
+ })
125
+
126
+ it('should handle negative branching', async () => {
127
+ const ctx = new TypedContext()
128
+ const startNode = new NumberNode(-5)
129
+ const checkNode = new CheckPositiveNode()
130
+ const addIfPositive = new AddNode(10)
131
+ const addIfNegative = new AddNode(-20)
132
+ const flow = new Flow(startNode)
133
+ startNode.next(checkNode)
134
+ checkNode.next(addIfPositive, 'positive')
135
+ checkNode.next(addIfNegative, 'negative')
136
+ await flow.run(ctx, runOptions)
137
+ expect(ctx.get(CURRENT)).toBe(-25)
138
+ })
139
+
140
+ it('should return the final action from the last node in a cycle', async () => {
141
+ const ctx = new TypedContext()
142
+ const startNode = new NumberNode(10)
143
+ const checkNode = new CheckPositiveNode()
144
+ const subtractNode = new AddNode(-3)
145
+ const endNode = new SignalNode('cycle_done')
146
+ const flow = new Flow(startNode)
147
+ startNode.next(checkNode)
148
+ checkNode.next(subtractNode, 'positive')
149
+ checkNode.next(endNode, 'negative')
150
+ subtractNode.next(checkNode)
151
+ const lastAction = await flow.run(ctx, runOptions)
152
+ expect(ctx.get(CURRENT)).toBe(-2)
153
+ expect(lastAction).toBe('cycle_done')
154
+ })
155
+ })
156
+
157
+ describe('testFlowComposition', () => {
158
+ it('should treat a flow as a node in another flow', async () => {
159
+ const ctx = new TypedContext()
160
+ const innerFlow = new Flow(new NumberNode(5))
161
+ innerFlow.startNode!.next(new AddNode(10)).next(new MultiplyNode(2))
162
+ const outerFlow = new Flow(innerFlow)
163
+ await outerFlow.run(ctx, runOptions)
164
+ expect(ctx.get(CURRENT)).toBe(30)
165
+ })
166
+
167
+ it('should propagate actions from inner flows for branching', async () => {
168
+ const ctx = new TypedContext()
169
+ const innerStart = new NumberNode(100)
170
+ const innerEnd = new SignalNode('inner_done')
171
+ innerStart.next(innerEnd)
172
+ const innerFlow = new Flow(innerStart)
173
+ const pathA = new PathNode('A')
174
+ const pathB = new PathNode('B')
175
+ const outerFlow = new Flow(innerFlow)
176
+ innerFlow.next(pathA, 'other_action')
177
+ innerFlow.next(pathB, 'inner_done')
178
+ await outerFlow.run(ctx, runOptions)
179
+ expect(ctx.get(CURRENT)).toBe(100)
180
+ expect(ctx.get(PATH_TAKEN)).toBe('B')
181
+ })
182
+ })
183
+
184
+ describe('testFlowGetNodeById', () => {
185
+ it('should find a node by its ID in a linear flow', () => {
186
+ const nodeA = new Node().withId('A')
187
+ const nodeB = new Node().withId('B')
188
+ const nodeC = new Node().withId('C')
189
+ nodeA.next(nodeB).next(nodeC)
190
+ const flow = new Flow(nodeA)
191
+
192
+ const foundNode = flow.getNodeById('B')
193
+ expect(foundNode).toBe(nodeB)
194
+ expect(foundNode?.id).toBe('B')
195
+ })
196
+
197
+ it('should return undefined if a node ID does not exist', () => {
198
+ const nodeA = new Node().withId('A')
199
+ const flow = new Flow(nodeA)
200
+ expect(flow.getNodeById('non-existent')).toBeUndefined()
201
+ })
202
+
203
+ it('should return undefined for an empty flow', () => {
204
+ const flow = new Flow()
205
+ expect(flow.getNodeById('any-id')).toBeUndefined()
206
+ })
207
+
208
+ it('should find a node in a complex graph with branches and cycles', () => {
209
+ const start = new Node().withId('start')
210
+ const decision = new Node().withId('decision')
211
+ const pathA = new Node().withId('pathA')
212
+ const pathB = new Node().withId('pathB')
213
+ const converge = new Node().withId('converge')
214
+ const final = new Node().withId('final')
215
+
216
+ start.next(decision)
217
+ decision.next(pathA, 'a')
218
+ decision.next(pathB, 'b')
219
+ pathA.next(converge)
220
+ pathB.next(converge)
221
+ converge.next(decision) // Cycle back
222
+ converge.next(final, 'exit')
223
+
224
+ const flow = new Flow(start)
225
+
226
+ expect(flow.getNodeById('start')).toBe(start)
227
+ expect(flow.getNodeById('pathB')).toBe(pathB)
228
+ expect(flow.getNodeById('final')).toBe(final)
229
+ })
230
+ })
231
+
232
+ describe('testExecFallback', () => {
233
+ class FallbackNode extends Node<void, string> {
234
+ public attemptCount = 0
235
+ private shouldFail: boolean
236
+
237
+ constructor(shouldFail: boolean, options?: NodeOptions) {
238
+ super(options) // Pass options to the parent
239
+ this.shouldFail = shouldFail
240
+ }
241
+
242
+ async exec(): Promise<string> {
243
+ this.attemptCount++
244
+ if (this.shouldFail)
245
+ throw new Error('Intentional failure')
246
+ return 'success'
247
+ }
248
+
249
+ async execFallback(): Promise<string> { return 'fallback' }
250
+ async post({ ctx, execRes }: NodeArgs<void, string>) {
251
+ ctx.set(RESULT, execRes)
252
+ ctx.set(ATTEMPTS, this.attemptCount)
253
+ }
254
+ }
255
+
256
+ it('should call execFallback after all retries are exhausted', async () => {
257
+ const ctx = new TypedContext()
258
+ const node = new FallbackNode(true, { maxRetries: 3 })
259
+ await node.run(ctx, runOptions)
260
+ // 3 attempts total: 1 initial + 2 retries. The attemptCount logic is inside exec, so it will be 3.
261
+ expect(ctx.get(ATTEMPTS)).toBe(3)
262
+ expect(ctx.get(RESULT)).toBe('fallback')
263
+ })
264
+ })
265
+
266
+ describe('testAbortController', () => {
267
+ class LongRunningNode extends Node<void, string> {
268
+ constructor(public id: number, private delayMs: number) { super() }
269
+ async exec({ ctx, signal }: NodeArgs): Promise<string> {
270
+ const started = ctx.get(STARTED) ?? []
271
+ ctx.set(STARTED, started.concat(this.id))
272
+ await sleep(this.delayMs, signal)
273
+ const finished = ctx.get(FINISHED) ?? []
274
+ ctx.set(FINISHED, finished.concat(this.id))
275
+ return `ok_${this.id}`
276
+ }
277
+ }
278
+
279
+ class LongRunningNodeWithParams extends Node<void, string> {
280
+ constructor(private delayMs: number) { super() }
281
+ async exec({ ctx, params, signal }: NodeArgs): Promise<string> {
282
+ const id = params.id
283
+ const started = ctx.get(STARTED) ?? []
284
+ ctx.set(STARTED, started.concat(id))
285
+ await sleep(this.delayMs, signal)
286
+ const finished = ctx.get(FINISHED) ?? []
287
+ ctx.set(FINISHED, finished.concat(id))
288
+ return `ok_${id}`
289
+ }
290
+ }
291
+
292
+ it('should abort a linear flow and throw an AbortError', async () => {
293
+ const ctx = new TypedContext()
294
+ const n1 = new LongRunningNode(1, 15)
295
+ const n2 = new LongRunningNode(2, 50)
296
+ const n3 = new LongRunningNode(3, 15)
297
+ const flow = new Flow()
298
+ flow.start(n1).next(n2).next(n3)
299
+ const controller = new AbortController()
300
+ const runPromise = flow.run(ctx, { controller, logger: mockLogger })
301
+ setTimeout(() => controller.abort(), 30) // Abort during n2's execution
302
+ await expect(runPromise).rejects.toThrow(AbortError)
303
+ expect(ctx.get(STARTED)).toEqual([1, 2])
304
+ expect(ctx.get(FINISHED)).toEqual([1])
305
+ })
306
+
307
+ it('should abort a sequential BatchFlow', async () => {
308
+ const ctx = new TypedContext()
309
+ class TestBatchFlow extends BatchFlow {
310
+ protected nodeToRun: AbstractNode = new LongRunningNodeWithParams(20)
311
+ async prep() { return [{ id: 1 }, { id: 2 }, { id: 3 }] }
312
+ }
313
+ const batchFlow = new TestBatchFlow()
314
+ const controller = new AbortController()
315
+ const runPromise = batchFlow.run(ctx, { controller, logger: mockLogger })
316
+ setTimeout(() => controller.abort(), 30) // Abort during the 2nd item's execution
317
+ await expect(runPromise).rejects.toThrow(AbortError)
318
+ expect(ctx.get(STARTED)).toEqual([1, 2])
319
+ expect(ctx.get(FINISHED)).toEqual([1])
320
+ })
321
+
322
+ it('should abort a ParallelBatchFlow', async () => {
323
+ const ctx = new TypedContext()
324
+ class TestParallelFlow extends ParallelBatchFlow {
325
+ protected nodeToRun: AbstractNode = new LongRunningNodeWithParams(50)
326
+ async prep() { return [{ id: 1 }, { id: 2 }, { id: 3 }] }
327
+ }
328
+ const parallelFlow = new TestParallelFlow()
329
+ const controller = new AbortController()
330
+ const runPromise = parallelFlow.run(ctx, { controller, logger: mockLogger })
331
+ setTimeout(() => controller.abort(), 20) // Abort while all are running
332
+ await expect(runPromise).rejects.toThrow(AbortError)
333
+ // All should have started in parallel
334
+ expect(ctx.get(STARTED)).toBeDefined()
335
+ expect(ctx.get(STARTED)!.length).toBe(3)
336
+ // None should have finished
337
+ expect(ctx.get(FINISHED)).toBeUndefined()
338
+ })
339
+ })
340
+
341
+ describe('testLoggingAndErrors', () => {
342
+ it('should be silent by default if no logger is provided', async () => {
343
+ const consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation(() => { })
344
+ const flow = new Flow(new NumberNode(1))
345
+ await flow.run(new TypedContext())
346
+ expect(consoleInfoSpy).not.toHaveBeenCalled()
347
+ consoleInfoSpy.mockRestore()
348
+ })
349
+
350
+ it('should use the provided custom logger', async () => {
351
+ const flow = new Flow(new NumberNode(1))
352
+ await flow.run(new TypedContext(), runOptions)
353
+ expect(mockLogger.info).toHaveBeenCalledWith(
354
+ 'Executor is running flow graph: Flow',
355
+ )
356
+ expect(mockLogger.info).toHaveBeenCalledWith(
357
+ 'Running node: NumberNode',
358
+ expect.any(Object),
359
+ )
360
+ })
361
+
362
+ it('should log branching decisions', async () => {
363
+ const checkNode = new CheckPositiveNode()
364
+ const pathNode = new PathNode('A')
365
+ const flow = new Flow(checkNode)
366
+ checkNode.next(pathNode, 'positive')
367
+ await flow.run(new TypedContext([[CURRENT, 10]]), runOptions)
368
+ expect(mockLogger.debug).toHaveBeenCalledWith(
369
+ `Action 'positive' from CheckPositiveNode leads to PathNode`,
370
+ expect.any(Object),
371
+ )
372
+ })
373
+
374
+ it('should log retry attempts and fallback execution', async () => {
375
+ class FailingNode extends Node {
376
+ constructor() { super({ maxRetries: 2, wait: 0 }) }
377
+ async exec() { throw new Error('fail') }
378
+ async execFallback() { return 'fallback' }
379
+ }
380
+ const flow = new Flow(new FailingNode())
381
+ await flow.run(new TypedContext(), runOptions)
382
+ expect(mockLogger.warn).toHaveBeenCalledWith(
383
+ 'Attempt 1/2 failed for FailingNode. Retrying...',
384
+ expect.any(Object),
385
+ )
386
+ expect(mockLogger.error).toHaveBeenCalledWith(
387
+ 'All retries failed for FailingNode. Executing fallback.',
388
+ expect.any(Object),
389
+ )
390
+ })
391
+
392
+ it('should wrap errors in WorkflowError with correct phase context', async () => {
393
+ class FailingPrepNode extends Node {
394
+ async prep() { throw new Error('Prep failed') }
395
+ }
396
+ const flow = new Flow(new FailingPrepNode())
397
+ const runPromise = flow.run(new TypedContext(), runOptions)
398
+ await expect(runPromise).rejects.toThrow(WorkflowError)
399
+ await expect(runPromise).rejects.toMatchObject({
400
+ name: 'WorkflowError',
401
+ nodeName: 'FailingPrepNode',
402
+ phase: 'prep',
403
+ originalError: expect.any(Error),
404
+ })
405
+ })
406
+ })
407
+
408
+ describe('testContextUtilities', () => {
409
+ it('lens should get and set values correctly', () => {
410
+ const nameLens = lens(NAME)
411
+ const ctx = new TypedContext()
412
+ const setNameTransform = nameLens.set('Alice')
413
+ setNameTransform(ctx)
414
+ expect(nameLens.get(ctx)).toBe('Alice')
415
+ expect(ctx.get(NAME)).toBe('Alice')
416
+ })
417
+
418
+ it('lens should update values based on the current value', () => {
419
+ const counterLens = lens(COUNTER)
420
+ const ctx = new TypedContext([[COUNTER, 5]])
421
+ const incrementTransform = counterLens.update(current => (current ?? 0) + 1)
422
+ incrementTransform(ctx)
423
+ expect(counterLens.get(ctx)).toBe(6)
424
+ // Test update on an undefined value
425
+ const newCtx = new TypedContext()
426
+ incrementTransform(newCtx)
427
+ expect(counterLens.get(newCtx)).toBe(1)
428
+ })
429
+
430
+ it('composeContext should apply multiple transformations in order', () => {
431
+ const nameLens = lens(NAME)
432
+ const counterLens = lens(COUNTER)
433
+ const ctx = new TypedContext([[COUNTER, 10]])
434
+ const composedTransform = composeContext(
435
+ nameLens.set('Bob'),
436
+ counterLens.update(c => (c ?? 0) * 2),
437
+ )
438
+ composedTransform(ctx)
439
+ expect(nameLens.get(ctx)).toBe('Bob')
440
+ expect(counterLens.get(ctx)).toBe(20)
441
+ })
442
+ })
443
+
444
+ describe('testFunctionalMethods', () => {
445
+ it('map() should transform the execution result', async () => {
446
+ const node = new ValueNode({ value: 10 }).map(res => `Value is ${res.value}`)
447
+ const resultNode = node.toContext(FINAL_RESULT) // Use toContext to easily inspect the result
448
+ const ctx = new TypedContext()
449
+ await resultNode.run(ctx, runOptions)
450
+ expect(ctx.get(FINAL_RESULT)).toBe('Value is 10')
451
+ })
452
+
453
+ it('map() should handle async transformations', async () => {
454
+ const node = new ValueNode('hello').map(async (res) => {
455
+ await sleep(1)
456
+ return res.toUpperCase()
457
+ })
458
+ const resultNode = node.toContext(FINAL_RESULT)
459
+ const ctx = new TypedContext()
460
+ await resultNode.run(ctx, runOptions)
461
+ expect(ctx.get(FINAL_RESULT)).toBe('HELLO')
462
+ })
463
+
464
+ it('toContext() should set the execution result in the context', async () => {
465
+ const node = new ValueNode('success').toContext(FINAL_RESULT)
466
+ const ctx = new TypedContext()
467
+ await node.run(ctx, runOptions)
468
+ expect(ctx.get(FINAL_RESULT)).toBe('success')
469
+ })
470
+
471
+ it('tap() should perform a side effect without altering the result', async () => {
472
+ const sideEffect = vi.fn()
473
+ const node = new ValueNode(42)
474
+ .tap(sideEffect)
475
+ .map(res => res + 1)
476
+ .toContext(COUNTER)
477
+ const ctx = new TypedContext()
478
+ await node.run(ctx, runOptions)
479
+ expect(sideEffect).toHaveBeenCalledWith(42)
480
+ expect(ctx.get(COUNTER)).toBe(43)
481
+ })
482
+
483
+ it('filter() should route to DEFAULT_ACTION when predicate is true', async () => {
484
+ const node = new ValueNode(100).filter(res => res > 50)
485
+ const action = await node.run(new TypedContext(), runOptions)
486
+ expect(action).toBe(DEFAULT_ACTION)
487
+ })
488
+
489
+ it('filter() should route to FILTER_FAILED when predicate is false', async () => {
490
+ const node = new ValueNode(10).filter(res => res > 50)
491
+ const action = await node.run(new TypedContext(), runOptions)
492
+ expect(action).toBe(FILTER_FAILED)
493
+ })
494
+
495
+ it('withLens() should modify context before execution', async () => {
496
+ const valueLens = lens(LENS_VALUE)
497
+ // This node reads the value that withLens should have set
498
+ const node = new ReadContextNode(LENS_VALUE)
499
+ .withLens(valueLens, 99)
500
+ .map(res => (res ?? -1) + 1)
501
+ .toContext(COUNTER)
502
+ const ctx = new TypedContext()
503
+ await node.run(ctx, runOptions)
504
+ expect(ctx.get(COUNTER)).toBe(100)
505
+ })
506
+
507
+ it('should allow chaining of multiple functional methods', async () => {
508
+ const sideEffect = vi.fn()
509
+ const valueLens = lens(LENS_VALUE)
510
+ // Create a node that reads from the context after a lens sets it,
511
+ // taps the value, maps it, filters it, and stores the final result.
512
+ const node = new ReadContextNode(LENS_VALUE)
513
+ .withLens(valueLens, 50) // 1. Set LENS_VALUE to 50
514
+ .tap(sideEffect) // 2. Side effect with the value (50)
515
+ .map(val => `The number is ${val}`) // 3. Transform to "The number is 50"
516
+ .filter(str => str.includes('50')) // 4. Predicate passes
517
+ .toContext(FINAL_RESULT) // 5. Store "The number is 50" in FINAL_RESULT
518
+ const ctx = new TypedContext()
519
+ const action = await node.run(ctx, runOptions)
520
+ expect(sideEffect).toHaveBeenCalledWith(50)
521
+ expect(ctx.get(FINAL_RESULT)).toBe('The number is 50')
522
+ expect(action).toBe(DEFAULT_ACTION)
523
+ // Test the filter failing
524
+ const failingNode = new ReadContextNode(LENS_VALUE)
525
+ .withLens(valueLens, 99)
526
+ .filter(val => val! < 90)
527
+ const failingAction = await failingNode.run(new TypedContext(), runOptions)
528
+ expect(failingAction).toBe(FILTER_FAILED)
529
+ })
530
+ })
531
+
532
+ describe('testFlowMiddleware', () => {
533
+ it('should run a single middleware and complete the flow', async () => {
534
+ const ctx = new TypedContext()
535
+ const flow = new Flow(new AddNode(10)).withParams({ start: 5 })
536
+ flow.use(async (args, next) => {
537
+ const path = args.ctx.get(MIDDLEWARE_PATH) ?? []
538
+ args.ctx.set(MIDDLEWARE_PATH, [...path, 'mw-enter'])
539
+ const result = await next(args)
540
+ args.ctx.set(MIDDLEWARE_PATH, [...args.ctx.get(MIDDLEWARE_PATH)!, 'mw-exit'])
541
+ return result
542
+ })
543
+ await flow.run(ctx, runOptions)
544
+ expect(ctx.get(CURRENT)).toBe(10) // Node logic ran
545
+ expect(ctx.get(MIDDLEWARE_PATH)).toEqual(['mw-enter', 'mw-exit'])
546
+ })
547
+
548
+ it('should run multiple middlewares in the correct LIFO order', async () => {
549
+ const ctx = new TypedContext()
550
+ const flow = new Flow(new NumberNode(100))
551
+ const createTracer = (id: string) => async (args: NodeArgs, next: any) => {
552
+ const path = args.ctx.get(MIDDLEWARE_PATH) ?? []
553
+ args.ctx.set(MIDDLEWARE_PATH, [...path, `enter-${id}`])
554
+ const result = await next(args)
555
+ const final_path = args.ctx.get(MIDDLEWARE_PATH) ?? []
556
+ args.ctx.set(MIDDLEWARE_PATH, [...final_path, `exit-${id}`])
557
+ return result
558
+ }
559
+ flow.use(createTracer('mw1'))
560
+ flow.use(createTracer('mw2'))
561
+ await flow.run(ctx, runOptions)
562
+ expect(ctx.get(MIDDLEWARE_PATH)).toEqual([
563
+ 'enter-mw1',
564
+ 'enter-mw2',
565
+ 'exit-mw2',
566
+ 'exit-mw1',
567
+ ])
568
+ })
569
+
570
+ it('should allow middleware to modify context before the node runs', async () => {
571
+ const ctx = new TypedContext([[CURRENT, 0]])
572
+ const node = new class extends Node<void, number> {
573
+ async exec({ ctx }: NodeArgs): Promise<number> {
574
+ return ctx.get(CURRENT) ?? -1
575
+ }
576
+ }().toContext(CURRENT)
577
+ const flow = new Flow(node)
578
+ flow.use(async (args, next) => {
579
+ args.ctx.set(CURRENT, 50)
580
+ return next(args)
581
+ })
582
+ await flow.run(ctx, runOptions)
583
+ expect(ctx.get(CURRENT)).toBe(50)
584
+ })
585
+
586
+ it('should propagate errors from middleware and halt execution', async () => {
587
+ const ctx = new TypedContext()
588
+ const flow = new Flow(new NumberNode(1))
589
+ const goodMiddleware = async (args: NodeArgs, next: any) => {
590
+ const path = args.ctx.get(MIDDLEWARE_PATH) ?? []
591
+ args.ctx.set(MIDDLEWARE_PATH, [...path, 'enter-good'])
592
+ const res = await next(args)
593
+ // This line should never be reached
594
+ args.ctx.set(MIDDLEWARE_PATH, [...(args.ctx.get(MIDDLEWARE_PATH) ?? []), 'exit-good'])
595
+ return res
596
+ }
597
+ const badMiddleware = async () => {
598
+ throw new Error('Middleware failure')
599
+ }
600
+ flow.use(goodMiddleware)
601
+ flow.use(badMiddleware)
602
+ await expect(flow.run(ctx, runOptions)).rejects.toThrow('Middleware failure')
603
+ expect(ctx.get(MIDDLEWARE_PATH)).toEqual(['enter-good'])
604
+ expect(ctx.get(CURRENT)).toBeUndefined()
605
+ })
606
+
607
+ it('should allow middleware to short-circuit the flow', async () => {
608
+ const ctx = new TypedContext([[CURRENT, 0]])
609
+ const flow = new Flow(new AddNode(100))
610
+ // This middleware decides not to proceed
611
+ flow.use(async (args, next) => {
612
+ const shouldProceed = false
613
+ if (shouldProceed) {
614
+ return next(args)
615
+ }
616
+ return 'short-circuited'
617
+ })
618
+ const lastAction = await flow.run(ctx, runOptions)
619
+ expect(ctx.get(CURRENT)).toBe(0)
620
+ expect(lastAction).toBe('short-circuited')
621
+ })
622
+ })