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,561 @@
1
+ import type { GraphNode } from './builder/graph.types'
2
+ import type { Context, ContextKey, ContextLens } from './context'
3
+ import type { InternalRunOptions } from './executor'
4
+ import type { Middleware, NodeArgs, NodeOptions, NodeRunContext, Params, RunOptions } from './types'
5
+ import { AbortError, WorkflowError } from './errors'
6
+ import { InMemoryExecutor } from './executors/in-memory'
7
+ import { NullLogger } from './logger'
8
+ import { DEFAULT_ACTION, FILTER_FAILED } from './types'
9
+ import { sleep } from './utils/index'
10
+
11
+ export * from './context'
12
+ export * from './errors'
13
+ export * from './executor'
14
+ export * from './logger'
15
+ export * from './types'
16
+
17
+ /**
18
+ * The abstract base class for all executable units in a workflow.
19
+ * It provides the core structure for connecting nodes into a graph.
20
+ */
21
+ export abstract class AbstractNode {
22
+ /** A unique identifier for this node instance, often set by the GraphBuilder. */
23
+ public id?: number | string
24
+ /** A key-value store for static parameters that configure the node's behavior. */
25
+ public params: Params = {}
26
+ /** A map of successor nodes, keyed by the action that triggers the transition. */
27
+ public successors = new Map<string | typeof DEFAULT_ACTION | typeof FILTER_FAILED, AbstractNode>()
28
+ /** The original graph definition for this node, if created by a GraphBuilder. */
29
+ public graphData?: GraphNode
30
+
31
+ /**
32
+ * Sets a unique identifier for this node instance.
33
+ * Primarily used by the GraphBuilder for wiring and debugging.
34
+ * @param id The unique ID for the node.
35
+ * @returns The node instance for chaining.
36
+ */
37
+ withId(id: number | string): this {
38
+ this.id = id
39
+ return this
40
+ }
41
+
42
+ /**
43
+ * Attaches the original graph definition data to the node instance.
44
+ * @internal
45
+ * @param data The graph node definition.
46
+ * @returns The node instance for chaining.
47
+ */
48
+ withGraphData(data: GraphNode): this {
49
+ this.graphData = data
50
+ return this
51
+ }
52
+
53
+ /**
54
+ * Sets or merges static parameters for the node. These parameters are available
55
+ * via `args.params` in the node's lifecycle methods.
56
+ * @param params The parameters to merge into the node's existing parameters.
57
+ * @returns The node instance for chaining.
58
+ */
59
+ withParams(params: Params): this {
60
+ this.params = { ...this.params, ...params }
61
+ return this
62
+ }
63
+
64
+ /**
65
+ * Defines the next node in the sequence for a given action.
66
+ * This is the primary method for constructing a workflow graph.
67
+ *
68
+ * @param node The successor node to execute next.
69
+ * @param action The action string from this node's `post` method that triggers
70
+ * the transition. Defaults to `DEFAULT_ACTION` for linear flows.
71
+ * @returns The successor node instance, allowing for further chaining.
72
+ */
73
+ next(node: AbstractNode, action: string | typeof DEFAULT_ACTION | typeof FILTER_FAILED = DEFAULT_ACTION): AbstractNode {
74
+ this.successors.set(action, node)
75
+ return node
76
+ }
77
+
78
+ /**
79
+ * The internal method that executes the node's full lifecycle.
80
+ * It is called by an `IExecutor`.
81
+ * @internal
82
+ */
83
+ abstract _run(ctx: NodeRunContext): Promise<any>
84
+ }
85
+
86
+ /**
87
+ * The fundamental building block of a workflow, representing a single unit of work.
88
+ * It features a three-phase lifecycle, retry logic, and a fluent API for creating
89
+ * data processing pipelines.
90
+ *
91
+ * @template PrepRes The type of data returned by the `prep` phase.
92
+ * @template ExecRes The type of data returned by the `exec` phase.
93
+ * @template PostRes The type of the action returned by the `post` phase.
94
+ */
95
+ export class Node<PrepRes = any, ExecRes = any, PostRes = any> extends AbstractNode {
96
+ /** The total number of times the `exec` phase will be attempted. */
97
+ public maxRetries: number
98
+ /** The time in milliseconds to wait between failed `exec` attempts. */
99
+ public wait: number
100
+
101
+ /**
102
+ * @param options Configuration options for the node's behavior.
103
+ * @param options.maxRetries Total number of `exec` attempts. Defaults to `1`.
104
+ * @param options.wait Milliseconds to wait between failed `exec` attempts. Defaults to `0`.
105
+ */
106
+ constructor(options: NodeOptions = {}) {
107
+ super()
108
+ this.maxRetries = options.maxRetries ?? 1
109
+ this.wait = options.wait ?? 0
110
+ }
111
+
112
+ protected _wrapError(e: any, phase: 'prep' | 'exec' | 'post'): Error {
113
+ if (e instanceof AbortError || e instanceof WorkflowError) {
114
+ return e
115
+ }
116
+ return new WorkflowError(`Failed in ${phase} phase for node ${this.constructor.name}`, this.constructor.name, phase, e as Error)
117
+ }
118
+
119
+ /**
120
+ * (Lifecycle) Prepares data for execution. Runs once before `exec`.
121
+ * This is the ideal place to read data from the `Context`.
122
+ * @param _args The arguments for the node, including `ctx` and `params`.
123
+ * @returns The data required by the `exec` phase.
124
+ */
125
+ async prep(_args: NodeArgs<void, void>): Promise<PrepRes> { return undefined as unknown as PrepRes }
126
+
127
+ /**
128
+ * (Lifecycle) Performs the core, isolated logic of the node.
129
+ * This is the only phase that is retried on failure. It should not access the `Context` directly.
130
+ * @param _args The arguments for the node, including `prepRes`.
131
+ * @returns The result of the execution.
132
+ */
133
+ async exec(_args: NodeArgs<PrepRes, void>): Promise<ExecRes> { return undefined as unknown as ExecRes }
134
+
135
+ /**
136
+ * (Lifecycle) Processes results and determines the next step. Runs once after `exec` succeeds.
137
+ * This is the ideal place to write data to the `Context`.
138
+ * @param _args The arguments for the node, including `execRes`.
139
+ * @returns An "action" string to determine which successor to execute next. Defaults to `DEFAULT_ACTION`.
140
+ */
141
+ async post(_args: NodeArgs<PrepRes, ExecRes>): Promise<PostRes> { return DEFAULT_ACTION as any }
142
+
143
+ /**
144
+ * (Lifecycle) A fallback that runs if all `exec` retries fail.
145
+ * If not implemented, the final error will be re-thrown, halting the workflow.
146
+ * @param args The arguments for the node, including the final `error` that caused the failure.
147
+ * @returns A fallback result of type `ExecRes`, allowing the workflow to recover and continue.
148
+ */
149
+ async execFallback(args: NodeArgs<PrepRes, void>): Promise<ExecRes> {
150
+ if (args.error) {
151
+ throw args.error
152
+ }
153
+ throw new Error(`Node ${this.constructor.name} failed and has no fallback implementation.`)
154
+ }
155
+
156
+ /**
157
+ * The internal retry-aware execution logic for the `exec` phase.
158
+ * @internal
159
+ */
160
+ async _exec(args: NodeArgs<PrepRes, void>): Promise<ExecRes> {
161
+ let lastError: Error | undefined
162
+ for (let curRetry = 0; curRetry < this.maxRetries; curRetry++) {
163
+ if (args.signal?.aborted)
164
+ throw new AbortError()
165
+ try {
166
+ return await this.exec(args)
167
+ }
168
+ catch (e) {
169
+ const error = e as Error
170
+ lastError = error
171
+ if (error instanceof AbortError || error.name === 'AbortError')
172
+ throw error
173
+
174
+ if (curRetry < this.maxRetries - 1) {
175
+ args.logger.warn(`Attempt ${curRetry + 1}/${this.maxRetries} failed for ${this.constructor.name}. Retrying...`, { error })
176
+ if (this.wait > 0)
177
+ await sleep(this.wait, args.signal)
178
+ }
179
+ }
180
+ }
181
+ args.logger.error(`All retries failed for ${this.constructor.name}. Executing fallback.`, { error: lastError })
182
+ if (args.signal?.aborted)
183
+ throw new AbortError()
184
+ return await this.execFallback({ ...args, error: lastError })
185
+ }
186
+
187
+ /**
188
+ * The internal method that executes the node's full lifecycle.
189
+ * @internal
190
+ */
191
+ async _run({ ctx, params, signal, logger, executor }: NodeRunContext): Promise<PostRes> {
192
+ if (this instanceof Flow) {
193
+ logger.info(`Running flow: ${this.constructor.name}`, { params })
194
+ }
195
+ else {
196
+ logger.info(`Running node: ${this.constructor.name}`, { params })
197
+ }
198
+
199
+ if (signal?.aborted)
200
+ throw new AbortError()
201
+ let prepRes: PrepRes
202
+ try {
203
+ prepRes = await this.prep({ ctx, params, signal, logger, prepRes: undefined, execRes: undefined, executor })
204
+ }
205
+ catch (e) {
206
+ throw this._wrapError(e, 'prep')
207
+ }
208
+ if (signal?.aborted)
209
+ throw new AbortError()
210
+ let execRes: ExecRes
211
+ try {
212
+ execRes = await this._exec({ ctx, params, signal, logger, prepRes, execRes: undefined, executor })
213
+ }
214
+ catch (e) {
215
+ throw this._wrapError(e, 'exec')
216
+ }
217
+ if (signal?.aborted)
218
+ throw new AbortError()
219
+ try {
220
+ const action = await this.post({ ctx, params, signal, logger, prepRes, execRes, executor })
221
+ return action === undefined ? DEFAULT_ACTION as any : action
222
+ }
223
+ catch (e) {
224
+ throw this._wrapError(e, 'post')
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Runs the node as a standalone unit, independent of a larger flow.
230
+ * This is useful for testing individual nodes in isolation.
231
+ *
232
+ * @param ctx The shared workflow context.
233
+ * @param options Runtime options like a logger or abort controller.
234
+ * @returns The result of the node's `post` method (its action).
235
+ */
236
+ async run(ctx: Context, options?: RunOptions): Promise<PostRes> {
237
+ const logger = options?.logger ?? new NullLogger()
238
+ if (this.successors.size > 0 && !(this instanceof Flow))
239
+ logger.warn('Node.run() called directly on a node with successors. The flow will not continue. Use a Flow to execute a sequence.')
240
+ const executor = options?.executor ?? new InMemoryExecutor()
241
+ // Wrap the node in a Flow and pass its params via the options.
242
+ return executor.run(new Flow(this), ctx, { ...options, params: this.params })
243
+ }
244
+
245
+ /**
246
+ * Creates a new node that transforms the result of this node's `exec` phase.
247
+ *
248
+ * @remarks
249
+ * This method returns a **new** `Node` instance and does not modify the original.
250
+ * The new node inherits the original's `prep` method. The original `post` method
251
+ * is discarded as it is incompatible with the new result type.
252
+ *
253
+ * @example
254
+ * const fetchUserNode = new FetchUserNode() // returns { id: 1, name: 'Alice' }
255
+ * const getUserNameNode = fetchUserNode.map(user => user.name) // returns 'Alice'
256
+ *
257
+ * @param fn A sync or async function to transform the execution result from `ExecRes` to `NewRes`.
258
+ * @returns A new `Node` instance with the transformed output type.
259
+ */
260
+ map<NewRes>(fn: (result: ExecRes) => NewRes | Promise<NewRes>): Node<PrepRes, NewRes, any> {
261
+ const originalNode = this
262
+ const maxRetries = this.maxRetries
263
+ const wait = this.wait
264
+
265
+ return new class extends Node<PrepRes, NewRes, any> {
266
+ constructor() { super({ maxRetries, wait }) }
267
+ async prep(args: NodeArgs): Promise<PrepRes> { return originalNode.prep(args) }
268
+ async exec(args: NodeArgs<PrepRes>): Promise<NewRes> {
269
+ const originalResult = await originalNode.exec(args)
270
+ return fn(originalResult)
271
+ }
272
+
273
+ async post(_args: NodeArgs<PrepRes, NewRes>): Promise<any> {
274
+ return DEFAULT_ACTION
275
+ }
276
+ }()
277
+ }
278
+
279
+ /**
280
+ * Creates a new node that stores the result of this node's `exec` phase in the `Context`.
281
+ * This is a common terminal operation for a data processing chain.
282
+ *
283
+ * @remarks
284
+ * This method returns a **new** `Node` instance and does not modify the original.
285
+ *
286
+ * @example
287
+ * const USER_NAME = contextKey<string>('user_name')
288
+ * const workflow = new FetchUserNode()
289
+ * .map(user => user.name)
290
+ * .toContext(USER_NAME)
291
+ *
292
+ * @param key The type-safe `ContextKey` to use for storing the result.
293
+ * @returns A new `Node` instance that performs the context update in its `post` phase.
294
+ */
295
+ toContext(key: ContextKey<ExecRes>): Node<PrepRes, ExecRes, any> {
296
+ const originalNode = this
297
+ const maxRetries = this.maxRetries
298
+ const wait = this.wait
299
+
300
+ return new class extends Node<PrepRes, ExecRes, any> {
301
+ constructor() { super({ maxRetries, wait }) }
302
+ async prep(args: NodeArgs): Promise<PrepRes> { return originalNode.prep(args) }
303
+ async exec(args: NodeArgs<PrepRes>): Promise<ExecRes> { return originalNode.exec(args as any) }
304
+ async post(args: NodeArgs<PrepRes, ExecRes>): Promise<any> {
305
+ args.ctx.set(key, args.execRes)
306
+ return DEFAULT_ACTION
307
+ }
308
+ }()
309
+ }
310
+
311
+ /**
312
+ * Creates a new node that acts as a conditional gate based on the `exec` result.
313
+ * If the predicate returns `true`, the node returns `DEFAULT_ACTION`.
314
+ * If it returns `false`, the node returns `FILTER_FAILED`, enabling branching.
315
+ *
316
+ * @remarks
317
+ * This method returns a **new** `Node` instance and does not modify the original.
318
+ *
319
+ * @example
320
+ * const checkAdminNode = new FetchUserNode().filter(user => user.isAdmin)
321
+ *
322
+ * checkAdminNode.next(adminOnlyNode, DEFAULT_ACTION)
323
+ * checkAdminNode.next(accessDeniedNode, FILTER_FAILED)
324
+ *
325
+ * @param predicate A sync or async function that returns `true` or `false`.
326
+ * @returns A new `Node` instance that implements the filter logic.
327
+ */
328
+ filter(predicate: (result: ExecRes) => boolean | Promise<boolean>): Node<PrepRes, ExecRes, any> {
329
+ const originalNode = this
330
+
331
+ return new class extends Node<PrepRes, ExecRes, any> {
332
+ private didPass = false
333
+
334
+ async prep(args: NodeArgs) { return originalNode.prep(args) }
335
+ async exec(args: NodeArgs<PrepRes>): Promise<ExecRes> {
336
+ const result = await originalNode.exec(args)
337
+ this.didPass = await predicate(result)
338
+ if (!this.didPass)
339
+ args.logger.info(`[Filter] Predicate failed for node ${this.constructor.name}.`)
340
+
341
+ return result
342
+ }
343
+
344
+ async post(_args: NodeArgs<PrepRes, ExecRes>): Promise<any> {
345
+ return this.didPass ? DEFAULT_ACTION : FILTER_FAILED
346
+ }
347
+ }()
348
+ }
349
+
350
+ /**
351
+ * Creates a new node that performs a side effect with the `exec` result,
352
+ * but passes the original result through unmodified. Ideal for logging or debugging.
353
+ *
354
+ * @remarks
355
+ * This method returns a **new** `Node` instance and does not modify the original.
356
+ *
357
+ * @example
358
+ * const workflow = new FetchUserNode()
359
+ * .tap(user => console.log('Fetched User:', user))
360
+ * .map(user => user.id)
361
+ *
362
+ * @param fn A function to call with the execution result for its side effect.
363
+ * @returns A new `Node` instance that wraps the original.
364
+ */
365
+ tap(fn: (result: ExecRes) => void | Promise<void>): Node<PrepRes, ExecRes, PostRes> {
366
+ return this.map(async (result) => {
367
+ await fn(result)
368
+ return result
369
+ })
370
+ }
371
+
372
+ /**
373
+ * Creates a new node that applies a context mutation using a lens before executing.
374
+ * This allows for declaratively setting or updating context as part of a fluent chain.
375
+ *
376
+ * @remarks
377
+ * This method returns a **new** `Node` instance and does not modify the original.
378
+ *
379
+ * @example
380
+ * const VALUE = contextKey<number>('value')
381
+ * const valueLens = lens(VALUE)
382
+ *
383
+ * const nodeWithLens = new SomeNode().withLens(valueLens, 42) // Sets VALUE to 42 before SomeNode runs
384
+ *
385
+ * @param lens The `ContextLens` to use for the operation.
386
+ * @param value The value to set in the context via the lens.
387
+ * @returns A new `Node` instance that applies the context change.
388
+ */
389
+ withLens<T>(lens: ContextLens<T>, value: T): Node<PrepRes, ExecRes, PostRes> {
390
+ const originalNode = this
391
+ const maxRetries = this.maxRetries
392
+ const wait = this.wait
393
+
394
+ return new class extends Node<PrepRes, ExecRes, PostRes> {
395
+ constructor() { super({ maxRetries, wait }) }
396
+ async prep(args: NodeArgs): Promise<PrepRes> {
397
+ // Apply the lens transformation before executing the original node's logic.
398
+ lens.set(value)(args.ctx)
399
+ return originalNode.prep(args)
400
+ }
401
+
402
+ async exec(args: NodeArgs<PrepRes>): Promise<ExecRes> {
403
+ return originalNode.exec(args as any)
404
+ }
405
+
406
+ async post(args: NodeArgs<PrepRes, ExecRes>): Promise<PostRes> {
407
+ return originalNode.post(args)
408
+ }
409
+ }()
410
+ }
411
+ }
412
+
413
+ /**
414
+ * A special type of `Node` that orchestrates a graph of other nodes.
415
+ * It can contain its own middleware and can be composed within other flows.
416
+ */
417
+ export class Flow extends Node<any, any, any> {
418
+ /** The first node to be executed in this flow's graph. */
419
+ public startNode?: AbstractNode
420
+ /** An array of middleware functions to be applied to every node within this flow. */
421
+ public middleware: Middleware[] = []
422
+
423
+ /**
424
+ * @param start An optional node to start the flow with.
425
+ */
426
+ constructor(start?: AbstractNode) {
427
+ super()
428
+ this.startNode = start
429
+ }
430
+
431
+ protected _wrapError(e: any, phase: 'prep' | 'exec' | 'post'): Error {
432
+ if (phase === 'exec') {
433
+ // Errors from a sub-flow's orchestration are already wrapped, so we pass them through.
434
+ return e
435
+ }
436
+ return super._wrapError(e, phase)
437
+ }
438
+
439
+ /**
440
+ * Adds a middleware function to this flow. Middleware will be executed in the
441
+ * order it is added, wrapping the execution of every node within this flow.
442
+ * @param fn The middleware function to add.
443
+ * @returns The `Flow` instance for chaining.
444
+ */
445
+ public use(fn: Middleware): this {
446
+ this.middleware.push(fn)
447
+ return this
448
+ }
449
+
450
+ /**
451
+ * Sets the starting node of the flow's graph.
452
+ * @param start The node to start with.
453
+ * @returns The start node instance, allowing for further chaining (`.next()`).
454
+ */
455
+ start(start: AbstractNode): AbstractNode {
456
+ this.startNode = start
457
+ return start
458
+ }
459
+
460
+ /**
461
+ * (Lifecycle) Executes this flow's internal graph when it is used as a sub-flow
462
+ * (a node within a larger flow).
463
+ * @internal
464
+ * @param args The arguments for the node, passed down from the parent executor.
465
+ * @returns The final action returned by the last node in this flow's graph.
466
+ */
467
+ async exec(args: NodeArgs<any, any>): Promise<any> {
468
+ // For programmatic composition, a Flow node orchestrates its own graph.
469
+ // This is a feature of the InMemoryExecutor. Distributed systems should
470
+ // rely on pre-flattened graphs produced by the GraphBuilder.
471
+ if (!(args.executor instanceof InMemoryExecutor)) {
472
+ throw new TypeError('Programmatic sub-flow execution is only supported by the InMemoryExecutor. For other environments, use GraphBuilder to create a single, flattened workflow.')
473
+ }
474
+
475
+ if (!this.startNode) {
476
+ // This handles logic-bearing flows like BatchFlow that override exec directly.
477
+ return super.exec(args)
478
+ }
479
+
480
+ args.logger.info(`-- Entering sub-flow: ${this.constructor.name} --`)
481
+
482
+ const combinedParams = { ...args.params, ...this.params }
483
+ const internalOptions: InternalRunOptions = {
484
+ logger: args.logger,
485
+ signal: args.signal,
486
+ params: combinedParams,
487
+ executor: args.executor,
488
+ }
489
+
490
+ const finalAction = await args.executor._orch(
491
+ this.startNode,
492
+ this.middleware,
493
+ args.ctx,
494
+ internalOptions,
495
+ )
496
+
497
+ args.logger.info(`-- Exiting sub-flow: ${this.constructor.name} --`)
498
+ return finalAction
499
+ }
500
+
501
+ /**
502
+ * (Lifecycle) The post-execution step for a `Flow` node. It simply passes through
503
+ * the final action from its internal graph execution (`execRes`).
504
+ * @internal
505
+ */
506
+ async post({ execRes }: NodeArgs<any, any>): Promise<any> {
507
+ return execRes
508
+ }
509
+
510
+ /**
511
+ * Runs the entire flow as a top-level entry point.
512
+ * @param ctx The shared workflow context.
513
+ * @param options Runtime options like a logger, abort controller, or a custom executor.
514
+ * @returns The final action returned by the last node in the flow.
515
+ */
516
+ async run(ctx: Context, options?: RunOptions): Promise<any> {
517
+ const executor = options?.executor ?? new InMemoryExecutor()
518
+ return executor.run(this, ctx, options)
519
+ }
520
+
521
+ /**
522
+ * Finds a node within the flow's graph by its unique ID.
523
+ *
524
+ * This method performs a breadth-first search starting from the `startNode`.
525
+ * It is a convenient way to get a reference to a specific node instance
526
+ * for debugging or dynamic modifications.
527
+ *
528
+ * @remarks
529
+ * This performs a graph traversal on each call, which has a time complexity
530
+ * proportional to the number of nodes and edges in the graph (O(V+E)). For
531
+ * performance-critical applications or flows built with `GraphBuilder`,
532
+ * it is more efficient to use the `nodeMap` returned by `GraphBuilder.build()`.
533
+ *
534
+ * @param id The unique ID of the node to find (set via `.withId()` or by the `GraphBuilder`).
535
+ * @returns The `AbstractNode` instance if found, otherwise `undefined`.
536
+ */
537
+ public getNodeById(id: string | number): AbstractNode | undefined {
538
+ if (!this.startNode) {
539
+ return undefined
540
+ }
541
+
542
+ const queue: AbstractNode[] = [this.startNode]
543
+ const visited = new Set<AbstractNode>([this.startNode])
544
+ while (queue.length > 0) {
545
+ const currentNode = queue.shift()!
546
+
547
+ if (currentNode.id === id) {
548
+ return currentNode
549
+ }
550
+
551
+ for (const successor of currentNode.successors.values()) {
552
+ if (!visited.has(successor)) {
553
+ visited.add(successor)
554
+ queue.push(successor)
555
+ }
556
+ }
557
+ }
558
+
559
+ return undefined
560
+ }
561
+ }