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.
- package/.editorconfig +9 -0
- package/LICENSE +21 -0
- package/README.md +249 -0
- package/config/tsconfig.json +21 -0
- package/config/tsup.config.ts +11 -0
- package/config/vitest.config.ts +11 -0
- package/docs/.vitepress/config.ts +105 -0
- package/docs/api-reference/builder.md +158 -0
- package/docs/api-reference/fn.md +142 -0
- package/docs/api-reference/index.md +38 -0
- package/docs/api-reference/workflow.md +126 -0
- package/docs/guide/advanced-guides/cancellation.md +117 -0
- package/docs/guide/advanced-guides/composition.md +68 -0
- package/docs/guide/advanced-guides/custom-executor.md +180 -0
- package/docs/guide/advanced-guides/error-handling.md +135 -0
- package/docs/guide/advanced-guides/logging.md +106 -0
- package/docs/guide/advanced-guides/middleware.md +106 -0
- package/docs/guide/advanced-guides/observability.md +175 -0
- package/docs/guide/best-practices/debugging.md +182 -0
- package/docs/guide/best-practices/state-management.md +120 -0
- package/docs/guide/best-practices/sub-workflow-data.md +95 -0
- package/docs/guide/best-practices/testing.md +187 -0
- package/docs/guide/builders.md +157 -0
- package/docs/guide/functional-api.md +133 -0
- package/docs/guide/index.md +178 -0
- package/docs/guide/recipes/creating-a-loop.md +113 -0
- package/docs/guide/recipes/data-processing-pipeline.md +123 -0
- package/docs/guide/recipes/fan-out-fan-in.md +112 -0
- package/docs/guide/recipes/index.md +15 -0
- package/docs/guide/recipes/resilient-api-call.md +110 -0
- package/docs/guide/tooling/graph-validation.md +160 -0
- package/docs/guide/tooling/mermaid.md +156 -0
- package/docs/index.md +56 -0
- package/eslint.config.js +16 -0
- package/package.json +40 -0
- package/pnpm-workspace.yaml +2 -0
- package/sandbox/1.basic/README.md +45 -0
- package/sandbox/1.basic/package.json +16 -0
- package/sandbox/1.basic/src/flow.ts +17 -0
- package/sandbox/1.basic/src/main.ts +22 -0
- package/sandbox/1.basic/src/nodes.ts +112 -0
- package/sandbox/1.basic/src/utils.ts +35 -0
- package/sandbox/1.basic/tsconfig.json +3 -0
- package/sandbox/2.research/README.md +46 -0
- package/sandbox/2.research/package.json +16 -0
- package/sandbox/2.research/src/flow.ts +14 -0
- package/sandbox/2.research/src/main.ts +31 -0
- package/sandbox/2.research/src/nodes.ts +108 -0
- package/sandbox/2.research/src/utils.ts +45 -0
- package/sandbox/2.research/src/visualize.ts +29 -0
- package/sandbox/2.research/tsconfig.json +3 -0
- package/sandbox/3.parallel/README.md +65 -0
- package/sandbox/3.parallel/package.json +16 -0
- package/sandbox/3.parallel/src/main.ts +45 -0
- package/sandbox/3.parallel/src/nodes.ts +43 -0
- package/sandbox/3.parallel/src/utils.ts +25 -0
- package/sandbox/3.parallel/tsconfig.json +3 -0
- package/sandbox/4.dag/README.md +179 -0
- package/sandbox/4.dag/data/1.blog-post/100.json +60 -0
- package/sandbox/4.dag/data/1.blog-post/README.md +25 -0
- package/sandbox/4.dag/data/2.job-application/200.json +103 -0
- package/sandbox/4.dag/data/2.job-application/201.json +31 -0
- package/sandbox/4.dag/data/2.job-application/202.json +31 -0
- package/sandbox/4.dag/data/2.job-application/README.md +58 -0
- package/sandbox/4.dag/data/3.customer-review/300.json +141 -0
- package/sandbox/4.dag/data/3.customer-review/301.json +31 -0
- package/sandbox/4.dag/data/3.customer-review/302.json +28 -0
- package/sandbox/4.dag/data/3.customer-review/README.md +71 -0
- package/sandbox/4.dag/data/4.content-moderation/400.json +161 -0
- package/sandbox/4.dag/data/4.content-moderation/401.json +47 -0
- package/sandbox/4.dag/data/4.content-moderation/402.json +46 -0
- package/sandbox/4.dag/data/4.content-moderation/403.json +31 -0
- package/sandbox/4.dag/data/4.content-moderation/README.md +83 -0
- package/sandbox/4.dag/package.json +19 -0
- package/sandbox/4.dag/src/main.ts +73 -0
- package/sandbox/4.dag/src/nodes.ts +134 -0
- package/sandbox/4.dag/src/registry.ts +87 -0
- package/sandbox/4.dag/src/types.ts +25 -0
- package/sandbox/4.dag/src/utils.ts +42 -0
- package/sandbox/4.dag/tsconfig.json +3 -0
- package/sandbox/5.distributed/.env.example +1 -0
- package/sandbox/5.distributed/README.md +88 -0
- package/sandbox/5.distributed/data/1.blog-post/100.json +59 -0
- package/sandbox/5.distributed/data/1.blog-post/README.md +25 -0
- package/sandbox/5.distributed/data/2.job-application/200.json +103 -0
- package/sandbox/5.distributed/data/2.job-application/201.json +30 -0
- package/sandbox/5.distributed/data/2.job-application/202.json +30 -0
- package/sandbox/5.distributed/data/2.job-application/README.md +58 -0
- package/sandbox/5.distributed/data/3.customer-review/300.json +141 -0
- package/sandbox/5.distributed/data/3.customer-review/301.json +31 -0
- package/sandbox/5.distributed/data/3.customer-review/302.json +57 -0
- package/sandbox/5.distributed/data/3.customer-review/README.md +71 -0
- package/sandbox/5.distributed/data/4.content-moderation/400.json +173 -0
- package/sandbox/5.distributed/data/4.content-moderation/401.json +47 -0
- package/sandbox/5.distributed/data/4.content-moderation/402.json +46 -0
- package/sandbox/5.distributed/data/4.content-moderation/403.json +31 -0
- package/sandbox/5.distributed/data/4.content-moderation/README.md +83 -0
- package/sandbox/5.distributed/package.json +20 -0
- package/sandbox/5.distributed/src/client.ts +124 -0
- package/sandbox/5.distributed/src/executor.ts +69 -0
- package/sandbox/5.distributed/src/nodes.ts +136 -0
- package/sandbox/5.distributed/src/registry.ts +101 -0
- package/sandbox/5.distributed/src/types.ts +45 -0
- package/sandbox/5.distributed/src/utils.ts +69 -0
- package/sandbox/5.distributed/src/worker.ts +217 -0
- package/sandbox/5.distributed/tsconfig.json +3 -0
- package/sandbox/6.rag/.env.example +1 -0
- package/sandbox/6.rag/README.md +60 -0
- package/sandbox/6.rag/data/README.md +31 -0
- package/sandbox/6.rag/data/rag.json +58 -0
- package/sandbox/6.rag/documents/sample-cascade.txt +11 -0
- package/sandbox/6.rag/package.json +18 -0
- package/sandbox/6.rag/src/main.ts +52 -0
- package/sandbox/6.rag/src/nodes/GenerateEmbeddingsNode.ts +54 -0
- package/sandbox/6.rag/src/nodes/LLMProcessNode.ts +48 -0
- package/sandbox/6.rag/src/nodes/LoadAndChunkNode.ts +40 -0
- package/sandbox/6.rag/src/nodes/StoreInVectorDBNode.ts +36 -0
- package/sandbox/6.rag/src/nodes/VectorSearchNode.ts +53 -0
- package/sandbox/6.rag/src/nodes/index.ts +28 -0
- package/sandbox/6.rag/src/registry.ts +23 -0
- package/sandbox/6.rag/src/types.ts +44 -0
- package/sandbox/6.rag/src/utils.ts +77 -0
- package/sandbox/6.rag/tsconfig.json +3 -0
- package/sandbox/tsconfig.json +13 -0
- package/src/builder/collection.test.ts +287 -0
- package/src/builder/collection.ts +269 -0
- package/src/builder/graph.test.ts +406 -0
- package/src/builder/graph.ts +336 -0
- package/src/builder/graph.types.ts +104 -0
- package/src/builder/index.ts +3 -0
- package/src/context.ts +111 -0
- package/src/errors.ts +34 -0
- package/src/executor.ts +29 -0
- package/src/executors/in-memory.test.ts +93 -0
- package/src/executors/in-memory.ts +140 -0
- package/src/functions.test.ts +191 -0
- package/src/functions.ts +117 -0
- package/src/index.ts +5 -0
- package/src/logger.ts +41 -0
- package/src/types.ts +75 -0
- package/src/utils/graph.test.ts +144 -0
- package/src/utils/graph.ts +182 -0
- package/src/utils/index.ts +3 -0
- package/src/utils/mermaid.test.ts +239 -0
- package/src/utils/mermaid.ts +133 -0
- package/src/utils/sleep.ts +20 -0
- package/src/workflow.test.ts +622 -0
- 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
|
+
})
|