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,269 @@
1
+ import type { NodeFunction } from '../functions'
2
+ import type { AbstractNode, NodeArgs } from '../workflow'
3
+ import { AbortError, Flow } from '../workflow'
4
+
5
+ /**
6
+ * A `Flow` that creates a linear workflow from a sequence of nodes,
7
+ * automatically chaining them in order.
8
+ */
9
+ export class SequenceFlow extends Flow {
10
+ /**
11
+ * @param nodes A sequence of `Node` or `Flow` instances to be executed in order.
12
+ */
13
+ constructor(...nodes: AbstractNode[]) {
14
+ if (nodes.length === 0) {
15
+ super()
16
+ return
17
+ }
18
+ super(nodes[0])
19
+ let current = nodes[0]
20
+ for (let i = 1; i < nodes.length; i++)
21
+ current = current.next(nodes[i])
22
+ }
23
+ }
24
+
25
+ /**
26
+ * A `Flow` that executes a collection of different nodes concurrently.
27
+ * This is the core of the "fan-out, fan-in" pattern for structural parallelism.
28
+ * After all parallel branches complete, the flow can proceed to a single successor.
29
+ */
30
+ export class ParallelFlow extends Flow {
31
+ /**
32
+ * @param nodesToRun The array of nodes to execute concurrently.
33
+ */
34
+ constructor(protected nodesToRun: AbstractNode[]) {
35
+ super()
36
+ }
37
+
38
+ /**
39
+ * Orchestrates the parallel execution of all nodes.
40
+ * @internal
41
+ */
42
+ async exec({ ctx, params, signal, logger, executor }: NodeArgs): Promise<void> {
43
+ if (this.nodesToRun.length === 0) {
44
+ logger.info('[ParallelFlow] No branches to execute in parallel.')
45
+ return
46
+ }
47
+
48
+ logger.info(`[ParallelFlow] Executing ${this.nodesToRun.length} branches in parallel...`)
49
+ const promises = this.nodesToRun.map(node =>
50
+ node._run({
51
+ ctx,
52
+ params: { ...params, ...node.params },
53
+ signal,
54
+ logger,
55
+ executor,
56
+ }),
57
+ )
58
+ const results = await Promise.allSettled(promises)
59
+ logger.info(`[ParallelFlow] ✓ All parallel branches finished.`)
60
+ // Check for and log any failures. A more robust implementation might
61
+ // collect these errors and decide on a specific failure action.
62
+ results.forEach((result) => {
63
+ if (result.status === 'rejected')
64
+ logger.error('[ParallelFlow] A parallel branch failed.', { error: result.reason })
65
+ })
66
+ }
67
+ }
68
+
69
+ /**
70
+ * An abstract `Flow` that processes a collection of items sequentially, one by one.
71
+ * Subclasses must implement the `prep` method to provide the items and the
72
+ * `nodeToRun` property to define the processing logic for each item.
73
+ */
74
+ export abstract class BatchFlow extends Flow {
75
+ /**
76
+ * The `Node` instance that will be executed for each item in the batch.
77
+ * This must be implemented by any subclass.
78
+ */
79
+ protected abstract nodeToRun: AbstractNode
80
+
81
+ constructor() {
82
+ super()
83
+ }
84
+
85
+ /**
86
+ * (Abstract) Prepares the list of items to be processed.
87
+ * This method is called once before the batch processing begins.
88
+ * @param _args The arguments for the node, including `ctx` and `params`.
89
+ * @returns An array or iterable of parameter objects, one for each item.
90
+ * The `nodeToRun` will be executed once for each of these objects.
91
+ */
92
+ async prep(_args: NodeArgs): Promise<Iterable<any>> {
93
+ return []
94
+ }
95
+
96
+ /**
97
+ * Orchestrates the sequential execution of `nodeToRun` for each item.
98
+ * @internal
99
+ */
100
+ async exec(args: NodeArgs): Promise<null> {
101
+ if (!this.nodeToRun)
102
+ return null
103
+
104
+ const combinedParams = { ...this.params, ...args.params }
105
+ const batchParamsIterable = (await this.prep(args)) || []
106
+ const batchParamsList = Array.from(batchParamsIterable)
107
+ args.logger.info(`[BatchFlow] Starting sequential processing of ${batchParamsList.length} items.`)
108
+
109
+ for (const batchParams of batchParamsList) {
110
+ if (args.signal?.aborted)
111
+ throw new AbortError()
112
+
113
+ await this.nodeToRun._run({
114
+ ctx: args.ctx,
115
+ params: { ...combinedParams, ...batchParams },
116
+ signal: args.signal,
117
+ logger: args.logger,
118
+ executor: args.executor,
119
+ })
120
+ }
121
+ return null
122
+ }
123
+ }
124
+
125
+ /**
126
+ * An abstract `Flow` that processes a collection of items concurrently.
127
+ * Subclasses must implement the `prep` method to provide the items and the
128
+ * `nodeToRun` property to define the processing logic for each item.
129
+ * This provides a significant performance boost for I/O-bound tasks.
130
+ */
131
+ export abstract class ParallelBatchFlow extends Flow {
132
+ /**
133
+ * The `Node` instance that will be executed concurrently for each item in the batch.
134
+ * This must be implemented by any subclass.
135
+ */
136
+ protected abstract nodeToRun: AbstractNode
137
+
138
+ constructor() {
139
+ super()
140
+ }
141
+
142
+ /**
143
+ * (Abstract) Prepares the list of items to be processed.
144
+ * This method is called once before the batch processing begins.
145
+ * @param _args The arguments for the node, including `ctx` and `params`.
146
+ * @returns An array or iterable of parameter objects, one for each item.
147
+ * The `nodeToRun` will be executed concurrently for each of these objects.
148
+ */
149
+ async prep(_args: NodeArgs): Promise<Iterable<any>> {
150
+ return []
151
+ }
152
+
153
+ /**
154
+ * Orchestrates the parallel execution of `nodeToRun` for each item.
155
+ * @internal
156
+ */
157
+ async exec(args: NodeArgs<any, void>): Promise<any> {
158
+ if (!this.nodeToRun)
159
+ return null
160
+
161
+ const combinedParams = { ...this.params, ...args.params }
162
+ const batchParamsIterable = (await this.prep(args)) || []
163
+ const batchParamsList = Array.from(batchParamsIterable)
164
+ args.logger.info(`[ParallelBatchFlow] Starting parallel processing of ${batchParamsList.length} items.`)
165
+
166
+ const promises = batchParamsList.map(batchParams =>
167
+ this.nodeToRun._run({
168
+ ctx: args.ctx,
169
+ params: { ...combinedParams, ...batchParams },
170
+ signal: args.signal,
171
+ logger: args.logger,
172
+ executor: args.executor,
173
+ }),
174
+ )
175
+
176
+ const results = await Promise.allSettled(promises)
177
+
178
+ // Optionally handle rejected promises, but don't rethrow to avoid halting the whole batch.
179
+ for (const result of results) {
180
+ if (result.status === 'rejected') {
181
+ args.logger.error('A parallel batch item failed.', { error: result.reason })
182
+ }
183
+ }
184
+
185
+ return results
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Creates a flow that applies a mapping function to each item in a collection in parallel
191
+ * and returns a new array containing the results.
192
+ *
193
+ * @example
194
+ * const numbers = [1, 2, 3];
195
+ * const double = (n: number) => n * 2;
196
+ * const processingFlow = mapCollection(numbers, double);
197
+ * // When run, processingFlow's result will be [2, 4, 6]
198
+ *
199
+ * @param items The initial array of items of type `T`.
200
+ * @param fn An async or sync function that transforms an item from type `T` to type `U`.
201
+ * @returns A `Flow` instance that, when run, will output an array of type `U[]`.
202
+ */
203
+ export function mapCollection<T, U>(items: T[], fn: NodeFunction<T, U>): Flow {
204
+ return new class extends Flow {
205
+ async exec(): Promise<U[]> {
206
+ // Using Promise.all to run the mapping function on all items concurrently.
207
+ const promises = items.map(item => fn(item))
208
+ return Promise.all(promises)
209
+ }
210
+ }()
211
+ }
212
+
213
+ /**
214
+ * Creates a flow that filters a collection based on a predicate function,
215
+ * returning a new array containing only the items that pass the predicate.
216
+ * The predicate is applied to all items concurrently.
217
+ *
218
+ * @example
219
+ * const users = [{ id: 1, admin: true }, { id: 2, admin: false }];
220
+ * const isAdmin = async (user: { admin: boolean }) => user.admin;
221
+ * const adminFilterFlow = filterCollection(users, isAdmin);
222
+ * // When run, the result will be [{ id: 1, admin: true }]
223
+ *
224
+ * @param items The initial array of items of type `T`.
225
+ * @param predicate An async or sync function that returns `true` or `false` for an item.
226
+ * @returns A `Flow` instance that, when run, will output a filtered array of type `T[]`.
227
+ */
228
+ export function filterCollection<T>(items: T[], predicate: (item: T) => boolean | Promise<boolean>): Flow {
229
+ return new class extends Flow {
230
+ async exec(): Promise<T[]> {
231
+ const results = await Promise.all(items.map(item => predicate(item)))
232
+ return items.filter((_, index) => results[index])
233
+ }
234
+ }()
235
+ }
236
+
237
+ /**
238
+ * Creates a flow that reduces a collection to a single value by executing a
239
+ * reducer function sequentially for each item, similar to `Array.prototype.reduce()`.
240
+ *
241
+ * @example
242
+ * const numbers = [1, 2, 3, 4];
243
+ * const sumReducer = (acc: number, val: number) => acc + val;
244
+ * const sumFlow = reduceCollection(numbers, sumReducer, 0);
245
+ * // When run, the result will be 10.
246
+ *
247
+ * @param items The array of items to be reduced.
248
+ * @param reducer An async or sync function that processes the accumulator and the current item.
249
+ * @param initialValue The initial value for the accumulator.
250
+ * @returns A `Flow` instance that, when run, will output the final accumulated value of type `U`.
251
+ */
252
+ export function reduceCollection<T, U>(
253
+ items: T[],
254
+ reducer: (accumulator: U, item: T) => U | Promise<U>,
255
+ initialValue: U,
256
+ ): Flow {
257
+ return new class extends Flow {
258
+ async exec(_args: NodeArgs): Promise<U> {
259
+ let accumulator = initialValue
260
+ for (const item of items) {
261
+ if (_args.signal?.aborted) {
262
+ throw new AbortError()
263
+ }
264
+ accumulator = await reducer(accumulator, item)
265
+ }
266
+ return accumulator
267
+ }
268
+ }()
269
+ }
@@ -0,0 +1,406 @@
1
+ import type { AbstractNode, Logger, NodeArgs, RunOptions } from '../workflow'
2
+ import type { NodeConstructorOptions, NodeRegistry, TypedWorkflowGraph, WorkflowGraph } from './graph.types'
3
+ import { describe, expect, it, vi } from 'vitest'
4
+ import { ConsoleLogger, contextKey, Node, TypedContext } from '../workflow'
5
+ import { createNodeRegistry, GraphBuilder } from './graph'
6
+
7
+ function createMockLogger(): Logger {
8
+ return {
9
+ debug: vi.fn(),
10
+ info: vi.fn(),
11
+ warn: vi.fn(),
12
+ error: vi.fn(),
13
+ }
14
+ }
15
+
16
+ const mockLogger = createMockLogger()
17
+ const runOptions: RunOptions = { logger: mockLogger }
18
+
19
+ describe('graphBuilder', () => {
20
+ const VALUE = contextKey<number>('value')
21
+ const PATH = contextKey<string[]>('path')
22
+
23
+ interface TestNodeTypeMap {
24
+ set: { value: number }
25
+ add: { value: number }
26
+ branch: { threshold: number }
27
+ logPath: { id: string }
28
+ }
29
+
30
+ class SetValueNode extends Node {
31
+ private value: number
32
+ constructor(options: NodeConstructorOptions<TestNodeTypeMap['set']>) {
33
+ super()
34
+ this.value = options.data.value
35
+ }
36
+
37
+ async prep({ ctx }: NodeArgs) {
38
+ ctx.set(VALUE, this.value)
39
+ }
40
+ }
41
+
42
+ class AddValueNode extends Node {
43
+ private valueToAdd: number
44
+ constructor(options: NodeConstructorOptions<TestNodeTypeMap['add']>) {
45
+ super()
46
+ this.valueToAdd = options.data.value
47
+ }
48
+
49
+ async prep({ ctx }: NodeArgs) {
50
+ const current = ctx.get(VALUE) ?? 0
51
+ ctx.set(VALUE, current + this.valueToAdd)
52
+ }
53
+ }
54
+
55
+ class ConditionalBranchNode extends Node<void, void, string> {
56
+ private threshold: number
57
+ constructor(options: NodeConstructorOptions<TestNodeTypeMap['branch']>) {
58
+ super()
59
+ this.threshold = options.data.threshold
60
+ }
61
+
62
+ async post({ ctx }: NodeArgs) {
63
+ const current = ctx.get(VALUE) ?? 0
64
+ return current > this.threshold ? 'over' : 'under'
65
+ }
66
+ }
67
+
68
+ class LogPathNode extends Node {
69
+ private pathId: string
70
+ constructor(options: NodeConstructorOptions<TestNodeTypeMap['logPath']>) {
71
+ super()
72
+ this.pathId = options.data.id
73
+ }
74
+
75
+ async prep({ ctx }: NodeArgs) {
76
+ const currentPath = ctx.get(PATH) ?? []
77
+ ctx.set(PATH, [...currentPath, this.pathId])
78
+ }
79
+ }
80
+
81
+ const testRegistry = createNodeRegistry({
82
+ set: SetValueNode,
83
+ add: AddValueNode,
84
+ branch: ConditionalBranchNode,
85
+ logPath: LogPathNode,
86
+ })
87
+
88
+ it('should build and run a complex graph with parallel fan-out', async () => {
89
+ const graph: TypedWorkflowGraph<TestNodeTypeMap> = {
90
+ nodes: [
91
+ { id: 'start', type: 'set', data: { value: 10 } },
92
+ { id: 'brancher', type: 'branch', data: { threshold: 15 } },
93
+ { id: 'add-10', type: 'add', data: { value: 10 } },
94
+ { id: 'log-A', type: 'logPath', data: { id: 'path_A' } }, // Parallel branch 1
95
+ { id: 'log-B', type: 'logPath', data: { id: 'path_B' } }, // Parallel branch 2
96
+ { id: 'final', type: 'add', data: { value: 1000 } },
97
+ // TRY THIS: Uncomment the lines below. TypeScript will throw an error!
98
+ // { id: 'error-test', type: 'add', data: { threshold: 99 } },
99
+ // { id: 'error-test', type: 'add', data: { WRONG_PROP: 99 } },
100
+ ],
101
+ edges: [
102
+ { source: 'start', target: 'brancher' },
103
+ { source: 'brancher', target: 'add-10', action: 'under' },
104
+ // Fan-out from 'add-10'
105
+ { source: 'add-10', target: 'log-A' },
106
+ { source: 'add-10', target: 'log-B' },
107
+ // Fan-in to 'final'
108
+ { source: 'log-A', target: 'final' },
109
+ { source: 'log-B', target: 'final' },
110
+ ],
111
+ }
112
+
113
+ const builder = new GraphBuilder(testRegistry)
114
+ const { flow } = builder.build(graph)
115
+ const ctx = new TypedContext()
116
+ await flow.run(ctx, runOptions)
117
+ expect(ctx.get(VALUE)).toBe(1020)
118
+ const path = ctx.get(PATH)
119
+ expect(path).toBeDefined()
120
+ expect(path).toHaveLength(2)
121
+ expect(path).toEqual(expect.arrayContaining(['path_A', 'path_B']))
122
+ })
123
+ })
124
+
125
+ describe('graphBuilder (no type safety)', () => {
126
+ const VALUE = contextKey<number>('value')
127
+ const PATH = contextKey<string[]>('path')
128
+
129
+ class SetValueNode extends Node {
130
+ private value: number
131
+ constructor(options: { data: { value: number } }) {
132
+ super()
133
+ this.value = options.data.value
134
+ }
135
+
136
+ async prep({ ctx }: NodeArgs) { ctx.set(VALUE, this.value) }
137
+ }
138
+
139
+ class AddValueNode extends Node {
140
+ private valueToAdd: number
141
+ constructor(options: { data: { value: number } }) {
142
+ super()
143
+ this.valueToAdd = options.data.value
144
+ }
145
+
146
+ async prep({ ctx }: NodeArgs) {
147
+ const current = ctx.get(VALUE) ?? 0
148
+ ctx.set(VALUE, current + this.valueToAdd)
149
+ }
150
+ }
151
+
152
+ class ConditionalBranchNode extends Node<void, void, string> {
153
+ private threshold: number
154
+ constructor(options: { data: { threshold: number } }) {
155
+ super()
156
+ this.threshold = options.data.threshold
157
+ }
158
+
159
+ async post({ ctx }: NodeArgs) {
160
+ const current = ctx.get(VALUE) ?? 0
161
+ return current > this.threshold ? 'over' : 'under'
162
+ }
163
+ }
164
+
165
+ class LogPathNode extends Node {
166
+ private pathId: string
167
+ constructor(options: { data: { id: string } }) {
168
+ super()
169
+ this.pathId = options.data.id
170
+ }
171
+
172
+ async prep({ ctx }: NodeArgs) {
173
+ const currentPath = ctx.get(PATH) ?? []
174
+ ctx.set(PATH, [...currentPath, this.pathId])
175
+ }
176
+ }
177
+
178
+ const testRegistry: NodeRegistry = new Map<string, new (...args: any[]) => AbstractNode>([
179
+ ['set', SetValueNode],
180
+ ['add', AddValueNode],
181
+ ['branch', ConditionalBranchNode],
182
+ ['logPath', LogPathNode],
183
+ ])
184
+
185
+ it('should build and run a simple graph using the untyped API', async () => {
186
+ const graph: WorkflowGraph = {
187
+ nodes: [
188
+ { id: 'start', type: 'set', data: { value: 10 } },
189
+ { id: 'brancher', type: 'branch', data: { threshold: 15 } },
190
+ { id: 'add-10', type: 'add', data: { value: 10 } },
191
+ { id: 'log-A', type: 'logPath', data: { id: 'path_A' } },
192
+ { id: 'log-B', type: 'logPath', data: { id: 'path_B' } },
193
+ { id: 'final', type: 'add', data: { value: 1000 } },
194
+ ],
195
+ edges: [
196
+ { source: 'start', target: 'brancher' },
197
+ { source: 'brancher', target: 'add-10', action: 'under' },
198
+ { source: 'add-10', target: 'log-A' },
199
+ { source: 'add-10', target: 'log-B' },
200
+ { source: 'log-A', target: 'final' },
201
+ { source: 'log-B', target: 'final' },
202
+ ],
203
+ }
204
+ const builder = new GraphBuilder(testRegistry)
205
+ const { flow } = builder.build(graph)
206
+ const ctx = new TypedContext()
207
+ await flow.run(ctx)
208
+ expect(ctx.get(VALUE)).toBe(1020)
209
+ const path = ctx.get(PATH)
210
+ expect(path).toBeDefined()
211
+ expect(path).toHaveLength(2)
212
+ expect(path).toEqual(expect.arrayContaining(['path_A', 'path_B']))
213
+ })
214
+ })
215
+
216
+ describe('graphBuilder with sub-workflows', () => {
217
+ interface TestSubWorkflowNodeTypeMap {
218
+ append: { value: string }
219
+ final: Record<string, never>
220
+ custom_sub_workflow: {
221
+ workflowId: number
222
+ inputs: Record<string, string>
223
+ outputs: Record<string, string>
224
+ }
225
+ }
226
+
227
+ const PARENT_VALUE = contextKey<string>('parent_value')
228
+ const SUB_VALUE = contextKey<string>('sub_value')
229
+ const FINAL_VALUE = contextKey<string>('final_value')
230
+
231
+ class AppendStringNode extends Node {
232
+ private str: string
233
+ constructor(options: NodeConstructorOptions<TestSubWorkflowNodeTypeMap['append']>) {
234
+ super()
235
+ this.str = options.data.value
236
+ }
237
+
238
+ async exec({ prepRes }: NodeArgs<string>): Promise<string> {
239
+ return `${prepRes} -> ${this.str}`
240
+ }
241
+
242
+ async prep({ ctx }: NodeArgs): Promise<string> {
243
+ return ctx.get(SUB_VALUE) ?? ctx.get(PARENT_VALUE) ?? 'start'
244
+ }
245
+
246
+ async post({ ctx, execRes }: NodeArgs<string, string>) {
247
+ ctx.set(SUB_VALUE, execRes)
248
+ }
249
+ }
250
+
251
+ class FinalOutputNode extends Node {
252
+ constructor(_options: NodeConstructorOptions<TestSubWorkflowNodeTypeMap['final']>) {
253
+ super()
254
+ }
255
+
256
+ async prep({ ctx }: NodeArgs) {
257
+ const subResult = ctx.get(SUB_VALUE) ?? ''
258
+ ctx.set(FINAL_VALUE, `final: ${subResult}`)
259
+ }
260
+ }
261
+
262
+ const mockRegistry = {
263
+ registry: createNodeRegistry({
264
+ append: AppendStringNode,
265
+ final: FinalOutputNode,
266
+ }),
267
+ graphs: new Map<number, WorkflowGraph>([
268
+ [200, {
269
+ nodes: [
270
+ { id: 'step_d', type: 'append', data: { value: 'D' } },
271
+ { id: 'step_e', type: 'append', data: { value: 'E' } },
272
+ ],
273
+ edges: [{ source: 'step_d', target: 'step_e' }],
274
+ }],
275
+ ]),
276
+ getGraph(id: number) {
277
+ return this.graphs.get(id)
278
+ },
279
+ }
280
+
281
+ it('should correctly inline a sub-workflow and run the flattened graph', async () => {
282
+ const parentGraph: TypedWorkflowGraph<TestSubWorkflowNodeTypeMap> = {
283
+ nodes: [
284
+ { id: 'step_a', type: 'append', data: { value: 'A' } },
285
+ {
286
+ id: 'the_sub',
287
+ type: 'custom_sub_workflow',
288
+ data: {
289
+ workflowId: 200,
290
+ inputs: { sub_value: 'parent_value' }, // PARENT_VALUE
291
+ outputs: { parent_value: 'sub_value' }, // SUB_VALUE
292
+ },
293
+ },
294
+ { id: 'step_c', type: 'final', data: {} },
295
+ ],
296
+ edges: [
297
+ { source: 'step_a', target: 'the_sub' },
298
+ { source: 'the_sub', target: 'step_c' },
299
+ ],
300
+ }
301
+
302
+ const builder = new GraphBuilder(mockRegistry.registry, { registry: mockRegistry }, {
303
+ subWorkflowNodeTypes: ['custom_sub_workflow'],
304
+ })
305
+
306
+ const { flow } = builder.build(parentGraph)
307
+ const ctx = new TypedContext([
308
+ [PARENT_VALUE, 'start'],
309
+ ])
310
+
311
+ await flow.run(ctx, runOptions)
312
+ expect(ctx.get(FINAL_VALUE)).toBe('final: start -> A -> D -> E')
313
+ })
314
+
315
+ it('should throw an error if a node with workflowId is not a registered sub-workflow type', () => {
316
+ const graphWithUndeclaredSub: TypedWorkflowGraph<TestSubWorkflowNodeTypeMap> = {
317
+ nodes: [
318
+ { id: 'step_a', type: 'append', data: { value: 'A' } },
319
+ // This node has a `workflowId` but its type ('some_other_type') isn't in our list.
320
+ // Note: We cast to `any` because TS would correctly catch this error at compile time!
321
+ { id: 'the_sub', type: 'some_other_type' as any, data: { workflowId: 200 } as any },
322
+ ],
323
+ edges: [
324
+ { source: 'step_a', target: 'the_sub' },
325
+ ],
326
+ }
327
+
328
+ const builder = new GraphBuilder(mockRegistry.registry, { registry: mockRegistry }, {
329
+ subWorkflowNodeTypes: ['custom_sub_workflow'],
330
+ })
331
+
332
+ expect(() => builder.build(graphWithUndeclaredSub)).toThrow(
333
+ /Node with ID 'the_sub' and type 'some_other_type' contains a 'workflowId' property, but its type is not registered/,
334
+ )
335
+ })
336
+ })
337
+
338
+ describe('graphBuilder with parallel start nodes', () => {
339
+ const A = contextKey<string>('a')
340
+ const B = contextKey<string>('b')
341
+ const RESULT = contextKey<string>('result')
342
+
343
+ interface ParallelTestMap {
344
+ 'set-value': { key: 'a' | 'b', value: string }
345
+ 'combine': Record<string, never>
346
+ }
347
+
348
+ class SetValueNode extends Node {
349
+ private key: 'a' | 'b'
350
+ private value: string
351
+ constructor(options: NodeConstructorOptions<ParallelTestMap['set-value']>) {
352
+ super(options) // Pass options up for potential retries etc.
353
+ this.key = options.data.key
354
+ this.value = options.data.value
355
+ }
356
+
357
+ async exec({ ctx }: NodeArgs) {
358
+ const key = this.key === 'a' ? A : B
359
+ ctx.set(key, this.value)
360
+ }
361
+ }
362
+
363
+ class CombineNode extends Node {
364
+ constructor(options: NodeConstructorOptions<ParallelTestMap['combine']>) {
365
+ super(options)
366
+ }
367
+
368
+ async exec({ ctx }: NodeArgs) {
369
+ const valA = ctx.get(A)
370
+ const valB = ctx.get(B)
371
+ ctx.set(RESULT, `${valA}-${valB}`)
372
+ }
373
+ }
374
+
375
+ const parallelRegistry = createNodeRegistry<ParallelTestMap>({
376
+ 'set-value': SetValueNode,
377
+ 'combine': CombineNode,
378
+ })
379
+
380
+ it('should build and run a graph with multiple start nodes in parallel', async () => {
381
+ const graph: TypedWorkflowGraph<ParallelTestMap> = {
382
+ nodes: [
383
+ { id: 'set-a', type: 'set-value', data: { key: 'a', value: 'Hello' } },
384
+ { id: 'set-b', type: 'set-value', data: { key: 'b', value: 'World' } },
385
+ { id: 'combiner', type: 'combine', data: {} },
386
+ ],
387
+ edges: [
388
+ { source: 'set-a', target: 'combiner' },
389
+ { source: 'set-b', target: 'combiner' },
390
+ ],
391
+ }
392
+
393
+ const builder = new GraphBuilder(
394
+ parallelRegistry,
395
+ { registry: parallelRegistry },
396
+ { subWorkflowNodeTypes: [] },
397
+ new ConsoleLogger(),
398
+ )
399
+ const { flow } = builder.build(graph)
400
+ const ctx = new TypedContext()
401
+
402
+ await flow.run(ctx, runOptions)
403
+
404
+ expect(ctx.get(RESULT)).toBe('Hello-World')
405
+ })
406
+ })