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
package/src/workflow.ts
ADDED
|
@@ -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
|
+
}
|