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,336 @@
|
|
|
1
|
+
import type { AbstractNode, FILTER_FAILED, Logger, NodeArgs } from '../workflow'
|
|
2
|
+
import type { BuildResult, GraphBuilderOptions, GraphEdge, GraphNode, NodeRegistry, TypedNodeRegistry, TypedWorkflowGraph, WorkflowGraph } from './graph.types'
|
|
3
|
+
import { generateMermaidGraph } from '../utils/mermaid'
|
|
4
|
+
import { DEFAULT_ACTION, Flow, Node, NullLogger } from '../workflow'
|
|
5
|
+
import { ParallelFlow } from './collection'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A type-safe helper function for creating a `TypedNodeRegistry`.
|
|
9
|
+
* This function preserves the strong typing of the registry object, enabling
|
|
10
|
+
* compile-time validation of `TypedWorkflowGraph` definitions.
|
|
11
|
+
*
|
|
12
|
+
* @param registry The registry object, where keys are node types and values are `Node` constructors.
|
|
13
|
+
* @returns The same registry object, correctly typed for use with `GraphBuilder`.
|
|
14
|
+
*/
|
|
15
|
+
export function createNodeRegistry<T extends { [K in keyof T]: Record<string, any> }>(registry: TypedNodeRegistry<T>): TypedNodeRegistry<T> {
|
|
16
|
+
return registry
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* An internal node used by the GraphBuilder to handle the `inputs` mapping
|
|
21
|
+
* of an inlined sub-workflow. It copies data from the parent context scope
|
|
22
|
+
* to the sub-workflow's context scope.
|
|
23
|
+
* @internal
|
|
24
|
+
*/
|
|
25
|
+
class InputMappingNode extends Node {
|
|
26
|
+
private mappings: Record<string, string>
|
|
27
|
+
constructor(options: { data: Record<string, string> }) {
|
|
28
|
+
super()
|
|
29
|
+
const { nodeId, ...mappings } = options.data
|
|
30
|
+
this.mappings = mappings
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async prep({ ctx, logger }: NodeArgs) {
|
|
34
|
+
for (const [subKey, parentKey] of Object.entries(this.mappings)) {
|
|
35
|
+
if (ctx.has(parentKey)) {
|
|
36
|
+
ctx.set(subKey, ctx.get(parentKey))
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
logger.warn(`[InputMapper] Input mapping failed. Key '${parentKey}' not found in context.`)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* An internal node used by the GraphBuilder to handle the `outputs` mapping
|
|
47
|
+
* of an inlined sub-workflow. It copies data from the sub-workflow's
|
|
48
|
+
* context scope back to the parent's context scope.
|
|
49
|
+
* @internal
|
|
50
|
+
*/
|
|
51
|
+
class OutputMappingNode extends Node {
|
|
52
|
+
private mappings: Record<string, string>
|
|
53
|
+
constructor(options: { data: Record<string, string> }) {
|
|
54
|
+
super()
|
|
55
|
+
const { nodeId, ...mappings } = options.data
|
|
56
|
+
this.mappings = mappings
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async prep({ ctx, logger }: NodeArgs) {
|
|
60
|
+
for (const [parentKey, subKey] of Object.entries(this.mappings)) {
|
|
61
|
+
if (ctx.has(subKey)) {
|
|
62
|
+
ctx.set(parentKey, ctx.get(subKey))
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
logger.warn(`[OutputMapper] Output mapping failed. Key '${subKey}' not found in context.`)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A private class used by the builder to represent parallel execution blocks. */
|
|
72
|
+
class ParallelBranchContainer extends ParallelFlow {
|
|
73
|
+
/** A tag to reliably identify this node type in the visualizer. */
|
|
74
|
+
public readonly isParallelContainer = true
|
|
75
|
+
constructor(public readonly nodesToRun: AbstractNode[]) { super(nodesToRun) }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Constructs an executable `Flow` from a declarative `WorkflowGraph` definition.
|
|
80
|
+
* It supports a fully type-safe API for compile-time validation of graph definitions
|
|
81
|
+
* and intelligently handles complex patterns like parallel fan-out and fan-in.
|
|
82
|
+
* @template T A `NodeTypeMap` for validating type-safe graph definitions.
|
|
83
|
+
*/
|
|
84
|
+
export class GraphBuilder<T extends { [K in keyof T]: Record<string, any> }> {
|
|
85
|
+
private registry: Map<string, new (...args: any[]) => AbstractNode>
|
|
86
|
+
private subWorkflowNodeTypes: string[]
|
|
87
|
+
private logger: Logger
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* @param registry A type-safe object or a `Map` where keys are node `type` strings and
|
|
91
|
+
* values are the corresponding `Node` class constructors. For type-safety, use `createNodeRegistry`.
|
|
92
|
+
* @param nodeOptionsContext An optional object that is passed to every node's
|
|
93
|
+
* constructor, useful for dependency injection (e.g., passing a database client or the builder itself).
|
|
94
|
+
*/
|
|
95
|
+
// type-safe overload
|
|
96
|
+
constructor(registry: TypedNodeRegistry<T>, nodeOptionsContext?: Record<string, any>, options?: GraphBuilderOptions, logger?: Logger)
|
|
97
|
+
// untyped overload
|
|
98
|
+
constructor(registry: NodeRegistry, nodeOptionsContext?: Record<string, any>, options?: GraphBuilderOptions, logger?: Logger)
|
|
99
|
+
// handle both cases
|
|
100
|
+
constructor(
|
|
101
|
+
registry: TypedNodeRegistry<T> | NodeRegistry,
|
|
102
|
+
private nodeOptionsContext: Record<string, any> = {},
|
|
103
|
+
options: GraphBuilderOptions = {},
|
|
104
|
+
logger: Logger = new NullLogger(),
|
|
105
|
+
) {
|
|
106
|
+
this.logger = logger
|
|
107
|
+
if (registry instanceof Map) {
|
|
108
|
+
this.registry = registry
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
this.registry = new Map(Object.entries(registry))
|
|
112
|
+
}
|
|
113
|
+
this.registry.set('__internal_input_mapper__', InputMappingNode as any)
|
|
114
|
+
this.registry.set('__internal_output_mapper__', OutputMappingNode as any)
|
|
115
|
+
this.subWorkflowNodeTypes = options.subWorkflowNodeTypes ?? []
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private _logMermaid(flow: Flow) {
|
|
119
|
+
if (!(this.logger instanceof NullLogger)) {
|
|
120
|
+
this.logger.info('[GraphBuilder] Flattened Graph')
|
|
121
|
+
const mermaid = generateMermaidGraph(flow)
|
|
122
|
+
mermaid.split('\n').forEach(line => this.logger.info(line))
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private _flattenGraph(graph: WorkflowGraph, idPrefix = ''): WorkflowGraph {
|
|
127
|
+
const finalNodes: GraphNode[] = []
|
|
128
|
+
const finalEdges: GraphEdge[] = []
|
|
129
|
+
|
|
130
|
+
const localNodeIds = new Set(graph.nodes.map(n => n.id))
|
|
131
|
+
|
|
132
|
+
// Pass 1: Recursively add all nodes, inlining sub-workflows and rewriting input paths.
|
|
133
|
+
for (const node of graph.nodes) {
|
|
134
|
+
const prefixedNodeId = `${idPrefix}${node.id}`
|
|
135
|
+
const isRegisteredSubWorkflow = this.subWorkflowNodeTypes.includes(node.type)
|
|
136
|
+
const hasWorkflowId = node.data && 'workflowId' in node.data
|
|
137
|
+
|
|
138
|
+
// Create a mutable copy of node data to safely rewrite input paths.
|
|
139
|
+
const newNodeData = JSON.parse(JSON.stringify(node.data || {}))
|
|
140
|
+
|
|
141
|
+
if (newNodeData.inputs) {
|
|
142
|
+
const inputs = newNodeData.inputs as Record<string, string | string[]>
|
|
143
|
+
for (const [templateKey, sourcePathOrPaths] of Object.entries(inputs)) {
|
|
144
|
+
const sourcePaths = Array.isArray(sourcePathOrPaths) ? sourcePathOrPaths : [sourcePathOrPaths]
|
|
145
|
+
const newSourcePaths = sourcePaths.map((sourcePath) => {
|
|
146
|
+
// If the input source is another node within this same graph file, prefix its ID.
|
|
147
|
+
// Otherwise, leave it as is (it's from a parent context or an initial value).
|
|
148
|
+
if (localNodeIds.has(sourcePath))
|
|
149
|
+
return `${idPrefix}${sourcePath}`
|
|
150
|
+
|
|
151
|
+
return sourcePath
|
|
152
|
+
})
|
|
153
|
+
inputs[templateKey] = Array.isArray(sourcePathOrPaths) ? newSourcePaths : newSourcePaths[0]
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (isRegisteredSubWorkflow) {
|
|
158
|
+
const subWorkflowData = node.data as any
|
|
159
|
+
const subWorkflowId = subWorkflowData.workflowId
|
|
160
|
+
const registry = this.nodeOptionsContext.registry as any
|
|
161
|
+
if (!registry || typeof registry.getGraph !== 'function')
|
|
162
|
+
throw new Error('GraphBuilder needs a registry with a `getGraph` method in its context to resolve sub-workflows.')
|
|
163
|
+
|
|
164
|
+
const subGraph: WorkflowGraph | undefined = registry.getGraph(subWorkflowId)
|
|
165
|
+
if (!subGraph)
|
|
166
|
+
throw new Error(`Sub-workflow with ID ${subWorkflowId} not found in registry.`)
|
|
167
|
+
|
|
168
|
+
const inputMapperId = `${prefixedNodeId}_input_mapper`
|
|
169
|
+
const outputMapperId = `${prefixedNodeId}_output_mapper`
|
|
170
|
+
finalNodes.push({ id: inputMapperId, type: '__internal_input_mapper__', data: subWorkflowData.inputs || {} })
|
|
171
|
+
finalNodes.push({ id: outputMapperId, type: '__internal_output_mapper__', data: subWorkflowData.outputs || {} })
|
|
172
|
+
|
|
173
|
+
const inlinedSubGraph = this._flattenGraph(subGraph, `${prefixedNodeId}:`)
|
|
174
|
+
finalNodes.push(...inlinedSubGraph.nodes)
|
|
175
|
+
finalEdges.push(...inlinedSubGraph.edges)
|
|
176
|
+
|
|
177
|
+
const subGraphStartIds = inlinedSubGraph.nodes.map(n => n.id).filter(id => !inlinedSubGraph.edges.some(e => e.target === id))
|
|
178
|
+
for (const startId of subGraphStartIds)
|
|
179
|
+
finalEdges.push({ source: inputMapperId, target: startId, action: DEFAULT_ACTION as any })
|
|
180
|
+
|
|
181
|
+
const subGraphTerminalIds = inlinedSubGraph.nodes.map(n => n.id).filter(id => !inlinedSubGraph.edges.some(e => e.source === id))
|
|
182
|
+
for (const terminalId of subGraphTerminalIds)
|
|
183
|
+
finalEdges.push({ source: terminalId, target: outputMapperId, action: DEFAULT_ACTION as any })
|
|
184
|
+
}
|
|
185
|
+
else if (hasWorkflowId) {
|
|
186
|
+
throw new Error(
|
|
187
|
+
`GraphBuilder Error: Node with ID '${node.id}' and type '${node.type}' contains a 'workflowId' property, `
|
|
188
|
+
+ `but its type is not registered in the 'subWorkflowNodeTypes' option. `
|
|
189
|
+
+ `Please add '${node.type}' to the subWorkflowNodeTypes array in the GraphBuilder constructor.`,
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
// Add the normal node with its newly resolved input paths.
|
|
194
|
+
finalNodes.push({ ...node, id: prefixedNodeId, data: newNodeData })
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Pass 2: Re-wire all original edges to connect to the correct nodes in the flattened graph.
|
|
199
|
+
for (const edge of graph.edges) {
|
|
200
|
+
const sourceNode = graph.nodes.find(n => n.id === edge.source)!
|
|
201
|
+
const targetNode = graph.nodes.find(n => n.id === edge.target)!
|
|
202
|
+
const prefixedSourceId = `${idPrefix}${edge.source}`
|
|
203
|
+
const prefixedTargetId = `${idPrefix}${edge.target}`
|
|
204
|
+
|
|
205
|
+
const isSourceSub = this.subWorkflowNodeTypes.includes(sourceNode.type)
|
|
206
|
+
const isTargetSub = this.subWorkflowNodeTypes.includes(targetNode.type)
|
|
207
|
+
|
|
208
|
+
if (isSourceSub && isTargetSub)
|
|
209
|
+
finalEdges.push({ ...edge, source: `${prefixedSourceId}_output_mapper`, target: `${prefixedTargetId}_input_mapper` })
|
|
210
|
+
else if (isSourceSub)
|
|
211
|
+
finalEdges.push({ ...edge, source: `${prefixedSourceId}_output_mapper`, target: prefixedTargetId })
|
|
212
|
+
else if (isTargetSub)
|
|
213
|
+
finalEdges.push({ ...edge, source: prefixedSourceId, target: `${prefixedTargetId}_input_mapper` })
|
|
214
|
+
else
|
|
215
|
+
finalEdges.push({ ...edge, source: prefixedSourceId, target: prefixedTargetId })
|
|
216
|
+
}
|
|
217
|
+
return { nodes: finalNodes, edges: finalEdges }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Builds a runnable `Flow` from a graph definition.
|
|
222
|
+
* @param graph The `WorkflowGraph` object describing the flow.
|
|
223
|
+
* @returns A `BuildResult` object containing the executable `flow` and a `nodeMap`.
|
|
224
|
+
*/
|
|
225
|
+
// type-safe overload
|
|
226
|
+
build(graph: TypedWorkflowGraph<T>): BuildResult
|
|
227
|
+
// untyped overload
|
|
228
|
+
build(graph: WorkflowGraph): BuildResult
|
|
229
|
+
// single implementation that handles both cases
|
|
230
|
+
build(graph: TypedWorkflowGraph<T> | WorkflowGraph): BuildResult {
|
|
231
|
+
const flatGraph = this._flattenGraph(graph as WorkflowGraph)
|
|
232
|
+
|
|
233
|
+
const nodeMap = new Map<string, AbstractNode>()
|
|
234
|
+
const predecessorMap = new Map<string, Set<string>>()
|
|
235
|
+
for (const edge of flatGraph.edges) {
|
|
236
|
+
if (!predecessorMap.has(edge.target))
|
|
237
|
+
predecessorMap.set(edge.target, new Set())
|
|
238
|
+
predecessorMap.get(edge.target)!.add(edge.source)
|
|
239
|
+
}
|
|
240
|
+
const predecessorCountMap = new Map<string, number>()
|
|
241
|
+
for (const node of flatGraph.nodes) {
|
|
242
|
+
const uniquePredecessors = predecessorMap.get(node.id)
|
|
243
|
+
predecessorCountMap.set(node.id, uniquePredecessors ? uniquePredecessors.size : 0)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Pass 1: Instantiate all nodes.
|
|
247
|
+
for (const graphNode of flatGraph.nodes) {
|
|
248
|
+
const NodeClass = this.registry.get(graphNode.type.toString())
|
|
249
|
+
if (!NodeClass)
|
|
250
|
+
throw new Error(`GraphBuilder: Node type '${graphNode.type.toString()}' not found in the registry.`)
|
|
251
|
+
|
|
252
|
+
const nodeOptions = {
|
|
253
|
+
...this.nodeOptionsContext,
|
|
254
|
+
data: { ...graphNode.data, nodeId: graphNode.id },
|
|
255
|
+
}
|
|
256
|
+
const executableNode = new NodeClass(nodeOptions)
|
|
257
|
+
.withId(graphNode.id)
|
|
258
|
+
.withGraphData(graphNode)
|
|
259
|
+
nodeMap.set(graphNode.id, executableNode)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Pass 2: Group all edges by their source and action. This map is the source of truth for wiring.
|
|
263
|
+
const edgeGroups = new Map<string, Map<string | typeof DEFAULT_ACTION | typeof FILTER_FAILED, AbstractNode[]>>()
|
|
264
|
+
for (const edge of flatGraph.edges) {
|
|
265
|
+
const sourceId = edge.source
|
|
266
|
+
const action = edge.action || DEFAULT_ACTION
|
|
267
|
+
const targetNode = nodeMap.get(edge.target)!
|
|
268
|
+
|
|
269
|
+
if (!edgeGroups.has(sourceId))
|
|
270
|
+
edgeGroups.set(sourceId, new Map())
|
|
271
|
+
const sourceActions = edgeGroups.get(sourceId)!
|
|
272
|
+
if (!sourceActions.has(action))
|
|
273
|
+
sourceActions.set(action, [])
|
|
274
|
+
sourceActions.get(action)!.push(targetNode)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Pass 3: Wire the graph using the grouped edges, creating ParallelFlows where necessary.
|
|
278
|
+
for (const [sourceId, actions] of edgeGroups.entries()) {
|
|
279
|
+
const sourceNode = nodeMap.get(sourceId)!
|
|
280
|
+
for (const [action, successors] of actions.entries()) {
|
|
281
|
+
if (successors.length === 1) {
|
|
282
|
+
// Simple 1-to-1 connection.
|
|
283
|
+
sourceNode.next(successors[0], action)
|
|
284
|
+
}
|
|
285
|
+
else if (successors.length > 1) {
|
|
286
|
+
// Fan-out detected. Use our named container.
|
|
287
|
+
const parallelNode = new ParallelBranchContainer(successors)
|
|
288
|
+
sourceNode.next(parallelNode, action)
|
|
289
|
+
|
|
290
|
+
// Determine the single convergence point for this parallel block.
|
|
291
|
+
const firstBranchSuccessor = edgeGroups.get(successors[0].id!.toString())?.get(DEFAULT_ACTION)?.[0]
|
|
292
|
+
if (firstBranchSuccessor) {
|
|
293
|
+
const allConverge = successors.slice(1).every(
|
|
294
|
+
node => edgeGroups.get(node.id!.toString())?.get(DEFAULT_ACTION)?.[0] === firstBranchSuccessor,
|
|
295
|
+
)
|
|
296
|
+
// If all branches lead to the same next node, wire the container to it.
|
|
297
|
+
if (allConverge)
|
|
298
|
+
parallelNode.next(firstBranchSuccessor)
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Final Step: Determine the start node(s) for the entire flow.
|
|
305
|
+
const allNodeIds = Array.from(nodeMap.keys())
|
|
306
|
+
const allTargetIds = new Set(flatGraph.edges.map(e => e.target))
|
|
307
|
+
const startNodeIds = allNodeIds.filter(id => !allTargetIds.has(id))
|
|
308
|
+
|
|
309
|
+
if (startNodeIds.length === 0 && allNodeIds.length > 0)
|
|
310
|
+
throw new Error('GraphBuilder: This graph has a cycle and no clear start node.')
|
|
311
|
+
|
|
312
|
+
if (startNodeIds.length === 1) {
|
|
313
|
+
const startNode = nodeMap.get(startNodeIds[0])!
|
|
314
|
+
const flow = new Flow(startNode)
|
|
315
|
+
this._logMermaid(flow)
|
|
316
|
+
return { flow, nodeMap, predecessorCountMap }
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Handle parallel start nodes.
|
|
320
|
+
const startNodes = startNodeIds.map(id => nodeMap.get(id)!)
|
|
321
|
+
const parallelStartNode = new ParallelBranchContainer(startNodes)
|
|
322
|
+
|
|
323
|
+
if (startNodes.length > 0) {
|
|
324
|
+
const firstSuccessor = edgeGroups.get(startNodes[0].id!.toString())?.get(DEFAULT_ACTION)?.[0]
|
|
325
|
+
if (firstSuccessor) {
|
|
326
|
+
const allConverge = startNodes.slice(1).every(node => edgeGroups.get(node.id!.toString())?.get(DEFAULT_ACTION)?.[0] === firstSuccessor)
|
|
327
|
+
if (allConverge)
|
|
328
|
+
parallelStartNode.next(firstSuccessor)
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const flow = new Flow(parallelStartNode)
|
|
333
|
+
this._logMermaid(flow)
|
|
334
|
+
return { flow, nodeMap, predecessorCountMap }
|
|
335
|
+
}
|
|
336
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { AbstractNode, Flow, NodeOptions } from '../workflow'
|
|
2
|
+
|
|
3
|
+
export interface NodeTypeMap { [key: string]: Record<string, any> }
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The standard options object passed to a Node's constructor by the `GraphBuilder`.
|
|
7
|
+
* @template T The type of the `data` payload for this specific node.
|
|
8
|
+
*/
|
|
9
|
+
export interface NodeConstructorOptions<T> extends NodeOptions {
|
|
10
|
+
/** The `data` payload from the graph definition, with `nodeId` injected for logging/debugging. */
|
|
11
|
+
data: T & { nodeId: string }
|
|
12
|
+
/** A context object containing any dependencies injected into the `GraphBuilder` constructor. */
|
|
13
|
+
[key: string]: any
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Represents a single, type-safe node within a declarative workflow graph.
|
|
18
|
+
* This is a discriminated union based on the `type` property, ensuring that
|
|
19
|
+
* the `data` payload matches the node's type as defined in the `TypedNodeRegistry`.
|
|
20
|
+
* @template T The `NodeTypeMap` that defines all possible node types and their data schemas.
|
|
21
|
+
*/
|
|
22
|
+
export type TypedGraphNode<T extends { [K in keyof T]: Record<string, any> }> = {
|
|
23
|
+
[K in keyof T]: {
|
|
24
|
+
/** A unique identifier for the node within the graph. */
|
|
25
|
+
id: string
|
|
26
|
+
/** The type of the node, used to look up the corresponding Node class in the registry. */
|
|
27
|
+
type: K
|
|
28
|
+
/** A flexible data object that must match the schema defined in the `NodeTypeMap` for this type. */
|
|
29
|
+
data: T[K]
|
|
30
|
+
}
|
|
31
|
+
}[keyof T]
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Represents a directed edge connecting two nodes in a workflow graph.
|
|
35
|
+
*/
|
|
36
|
+
export interface GraphEdge {
|
|
37
|
+
/** The `id` of the source node. */
|
|
38
|
+
source: string
|
|
39
|
+
/** The `id` of the target node. */
|
|
40
|
+
target: string
|
|
41
|
+
/** The action from the source node that triggers this edge. Defaults to `DEFAULT_ACTION`. */
|
|
42
|
+
action?: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Defines the structure of a type-safe, declarative workflow graph.
|
|
47
|
+
* @template T The `NodeTypeMap` that validates the graph's node definitions.
|
|
48
|
+
*/
|
|
49
|
+
export interface TypedWorkflowGraph<T extends { [K in keyof T]: Record<string, any> }> {
|
|
50
|
+
/** An array of node definitions. */
|
|
51
|
+
nodes: TypedGraphNode<T>[]
|
|
52
|
+
/** An array of edge definitions that connect the nodes. */
|
|
53
|
+
edges: GraphEdge[]
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A type-safe registry that maps a node type string to its corresponding `Node` constructor.
|
|
58
|
+
* TypeScript ensures that the constructor's options match the schema defined in the `NodeTypeMap`.
|
|
59
|
+
* @template T The `NodeTypeMap` that defines all possible node types and their data schemas.
|
|
60
|
+
*/
|
|
61
|
+
export type TypedNodeRegistry<T extends { [K in keyof T]: Record<string, any> }> = {
|
|
62
|
+
[K in keyof T]: new (options: NodeConstructorOptions<T[K]>) => AbstractNode
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The result of a successful `GraphBuilder.build()` call.
|
|
67
|
+
*/
|
|
68
|
+
export interface BuildResult {
|
|
69
|
+
/** The fully wired, executable `Flow` instance. */
|
|
70
|
+
flow: Flow
|
|
71
|
+
/** A map of all created node instances, keyed by their `id` from the graph definition. */
|
|
72
|
+
nodeMap: Map<string, AbstractNode>
|
|
73
|
+
/** A map of all node `id`s to their predecessor count. */
|
|
74
|
+
predecessorCountMap: Map<string, number>
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Represents a node within the workflow graph.
|
|
79
|
+
* This is a simpler (UNTYPED) version of the `TypedGraphNode` type
|
|
80
|
+
*/
|
|
81
|
+
export interface GraphNode {
|
|
82
|
+
id: string
|
|
83
|
+
type: string
|
|
84
|
+
data?: Record<string, any>
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Defines the structure of a workflow graph.
|
|
89
|
+
* This is a simpler (UNTYPED) version of the `TypedWorkflowGraph` type
|
|
90
|
+
*/
|
|
91
|
+
export interface WorkflowGraph {
|
|
92
|
+
nodes: GraphNode[]
|
|
93
|
+
edges: GraphEdge[]
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* A permissive (UNTYPED) registry that maps a node type string to a constructor.
|
|
98
|
+
* This is a simpler (UNTYPED) version of the `TypedNodeRegistry` type
|
|
99
|
+
*/
|
|
100
|
+
export type NodeRegistry = Map<string, new (...args: any[]) => AbstractNode>
|
|
101
|
+
|
|
102
|
+
export interface GraphBuilderOptions {
|
|
103
|
+
subWorkflowNodeTypes?: string[]
|
|
104
|
+
}
|
package/src/context.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A type-safe, opaque key for storing and retrieving values from the Context.
|
|
3
|
+
* Using a `ContextKey` provides compile-time safety for your workflow's state.
|
|
4
|
+
* @template T The type of the value this key refers to.
|
|
5
|
+
*/
|
|
6
|
+
export type ContextKey<T> = symbol & { __type: T }
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Creates a new, unique `ContextKey` for type-safe access to the `Context`.
|
|
10
|
+
* @template T The type of the value this key will hold.
|
|
11
|
+
* @param description An optional description for debugging purposes (e.g., in logs or test snapshots).
|
|
12
|
+
* @returns A unique `ContextKey<T>`.
|
|
13
|
+
*/
|
|
14
|
+
export const contextKey = <T>(description?: string): ContextKey<T> => Symbol(description) as ContextKey<T>
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Defines the interface for the shared context object passed through the workflow.
|
|
18
|
+
* It acts as the shared memory for all nodes in a flow. It supports both
|
|
19
|
+
* type-safe `ContextKey`s and flexible `string` keys.
|
|
20
|
+
*/
|
|
21
|
+
export interface Context {
|
|
22
|
+
/** Retrieves a value from the context. */
|
|
23
|
+
get: (<T>(key: ContextKey<T>) => T | undefined) & (<T = any>(key: string) => T | undefined)
|
|
24
|
+
/** Stores a value in the context. */
|
|
25
|
+
set: (<T>(key: ContextKey<T>, value: T) => this) & ((key: string, value: any) => this)
|
|
26
|
+
/** Checks if a key exists in the context. */
|
|
27
|
+
has: ((key: ContextKey<any>) => boolean) & ((key: string) => boolean)
|
|
28
|
+
/** Returns an iterator of all [key, value] pairs in the context. */
|
|
29
|
+
entries: () => IterableIterator<[any, any]>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The default, `Map`-based implementation of the `Context` interface.
|
|
34
|
+
*/
|
|
35
|
+
export class TypedContext implements Context {
|
|
36
|
+
private data: Map<any, any>
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param initialData An optional iterable (like an array of `[key, value]` pairs)
|
|
40
|
+
* to initialize the context with.
|
|
41
|
+
*/
|
|
42
|
+
constructor(initialData?: Iterable<readonly [ContextKey<any> | string, any]> | null) {
|
|
43
|
+
this.data = new Map<any, any>(initialData)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
get(key: ContextKey<any> | string): any {
|
|
47
|
+
return this.data.get(key)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
set(key: ContextKey<any> | string, value: any): this {
|
|
51
|
+
this.data.set(key, value)
|
|
52
|
+
return this
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
has(key: ContextKey<any> | string): boolean {
|
|
56
|
+
return this.data.has(key)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
entries(): IterableIterator<[any, any]> {
|
|
60
|
+
return this.data.entries()
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A function that takes a `Context` and returns a (potentially new) `Context`. */
|
|
65
|
+
export type ContextTransform = (ctx: Context) => Context
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* A "lens" provides a way to "focus" on a single key in the `Context`,
|
|
69
|
+
* creating reusable, type-safe functions to get, set, or update its value.
|
|
70
|
+
* @template T The type of the value the lens focuses on.
|
|
71
|
+
*/
|
|
72
|
+
export interface ContextLens<T> {
|
|
73
|
+
/** Retrieves the value for the key from the context. */
|
|
74
|
+
get: (ctx: Context) => T | undefined
|
|
75
|
+
/** Returns a `ContextTransform` function that will set the key to the provided value. */
|
|
76
|
+
set: (value: T) => ContextTransform
|
|
77
|
+
/** Returns a `ContextTransform` function that updates the key's value based on its current value. */
|
|
78
|
+
update: (fn: (current: T | undefined) => T) => ContextTransform
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Creates a `ContextLens` object for a specific `ContextKey`.
|
|
83
|
+
* This is the entry point for functional context manipulation.
|
|
84
|
+
*
|
|
85
|
+
* @example
|
|
86
|
+
* const NAME = contextKey<string>('name')
|
|
87
|
+
* const nameLens = lens(NAME)
|
|
88
|
+
* const setNameTransform = nameLens.set('Alice') // This is a function: (ctx) => ctx.set(NAME, 'Alice')
|
|
89
|
+
*
|
|
90
|
+
* @param key The `ContextKey` to focus on.
|
|
91
|
+
* @returns A `ContextLens<T>` object with `.get()`, `.set()`, and `.update()` methods.
|
|
92
|
+
*/
|
|
93
|
+
export function lens<T>(key: ContextKey<T>): ContextLens<T> {
|
|
94
|
+
return {
|
|
95
|
+
get: (ctx: Context) => ctx.get(key),
|
|
96
|
+
set: (value: T) => (ctx: Context) => ctx.set(key, value),
|
|
97
|
+
update: (fn: (current: T | undefined) => T) => (ctx: Context) =>
|
|
98
|
+
ctx.set(key, fn(ctx.get(key))),
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Composes multiple `ContextTransform` functions into a single `ContextTransform` function.
|
|
104
|
+
* The transformations are applied in the order they are provided.
|
|
105
|
+
*
|
|
106
|
+
* @param transforms A sequence of `ContextTransform` functions.
|
|
107
|
+
* @returns A single function that applies all transformations.
|
|
108
|
+
*/
|
|
109
|
+
export function composeContext(...transforms: ContextTransform[]): ContextTransform {
|
|
110
|
+
return (ctx: Context) => transforms.reduce((acc, transform) => transform(acc), ctx)
|
|
111
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error thrown when a workflow is gracefully aborted via an `AbortSignal`.
|
|
3
|
+
* This error is caught by the execution engine to halt the flow.
|
|
4
|
+
*/
|
|
5
|
+
export class AbortError extends Error {
|
|
6
|
+
constructor(message = 'Workflow aborted') {
|
|
7
|
+
super(message)
|
|
8
|
+
this.name = 'AbortError'
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A custom error class for failures within a workflow, providing additional
|
|
14
|
+
* context about where and when the error occurred.
|
|
15
|
+
*/
|
|
16
|
+
export class WorkflowError extends Error {
|
|
17
|
+
/**
|
|
18
|
+
* @param message The error message.
|
|
19
|
+
* @param nodeName The name of the `Node` class where the error occurred.
|
|
20
|
+
* @param phase The lifecycle phase (`'prep'`, `'exec'`, or `'post'`) where the error was thrown.
|
|
21
|
+
* @param originalError The underlying error that was caught and wrapped.
|
|
22
|
+
*/
|
|
23
|
+
constructor(
|
|
24
|
+
message: string,
|
|
25
|
+
public readonly nodeName: string,
|
|
26
|
+
public readonly phase: 'prep' | 'exec' | 'post',
|
|
27
|
+
public readonly originalError?: Error,
|
|
28
|
+
) {
|
|
29
|
+
super(message)
|
|
30
|
+
this.name = 'WorkflowError'
|
|
31
|
+
if (originalError?.stack)
|
|
32
|
+
this.stack = `${this.stack}\nCaused by: ${originalError.stack}`
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/executor.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Context, Flow, Logger, Params, RunOptions } from './workflow'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Defines the contract for a workflow execution engine.
|
|
5
|
+
* An executor is responsible for taking a `Flow` definition and running it,
|
|
6
|
+
* orchestrating the traversal of the node graph.
|
|
7
|
+
*/
|
|
8
|
+
export interface IExecutor {
|
|
9
|
+
/**
|
|
10
|
+
* Executes a given flow with a specific context and options.
|
|
11
|
+
* @param flow The `Flow` instance to execute.
|
|
12
|
+
* @param context The shared `Context` for the workflow run.
|
|
13
|
+
* @param options Runtime options, which can include a logger, abort controller, or initial params.
|
|
14
|
+
* @returns A promise that resolves with the final action of the workflow, or another result
|
|
15
|
+
* depending on the executor's implementation (e.g., a job ID for a distributed executor).
|
|
16
|
+
*/
|
|
17
|
+
run: (flow: Flow, context: Context, options?: RunOptions) => Promise<any>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Internal, normalized run options used by executors.
|
|
22
|
+
* @internal
|
|
23
|
+
*/
|
|
24
|
+
export interface InternalRunOptions {
|
|
25
|
+
logger: Logger
|
|
26
|
+
signal?: AbortSignal
|
|
27
|
+
params: Params
|
|
28
|
+
executor: IExecutor
|
|
29
|
+
}
|