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,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
+ }
@@ -0,0 +1,3 @@
1
+ export * from './collection'
2
+ export * from './graph'
3
+ export * from './graph.types'
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
+ }
@@ -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
+ }