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,144 @@
|
|
|
1
|
+
import type { NodeTypeMap, TypedWorkflowGraph } from '../builder/graph.types'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { analyzeGraph, checkForCycles, createNodeRule } from './graph'
|
|
4
|
+
|
|
5
|
+
describe('testGraphAnalysis', () => {
|
|
6
|
+
describe('analyzeGraph', () => {
|
|
7
|
+
it('should correctly analyze a simple linear graph', () => {
|
|
8
|
+
const graph: TypedWorkflowGraph<{ start: Record<string, never>, end: Record<string, never> }> = {
|
|
9
|
+
nodes: [{ id: 'a', type: 'start', data: {} }, { id: 'b', type: 'end', data: {} }],
|
|
10
|
+
edges: [{ source: 'a', target: 'b' }],
|
|
11
|
+
}
|
|
12
|
+
const analysis = analyzeGraph(graph)
|
|
13
|
+
expect(analysis.allNodeIds).toEqual(['a', 'b'])
|
|
14
|
+
expect(analysis.startNodeIds).toEqual(['a'])
|
|
15
|
+
expect(analysis.nodes.get('a')?.outDegree).toBe(1)
|
|
16
|
+
expect(analysis.nodes.get('a')?.inDegree).toBe(0)
|
|
17
|
+
expect(analysis.nodes.get('b')?.outDegree).toBe(0)
|
|
18
|
+
expect(analysis.nodes.get('b')?.inDegree).toBe(1)
|
|
19
|
+
expect(analysis.cycles).toEqual([])
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('should identify multiple start nodes', () => {
|
|
23
|
+
const graph: TypedWorkflowGraph<any> = {
|
|
24
|
+
nodes: [{ id: 'a', type: 'input', data: {} }, { id: 'b', type: 'input', data: {} }, { id: 'c', type: 'process', data: {} }],
|
|
25
|
+
edges: [{ source: 'a', target: 'c' }, { source: 'b', target: 'c' }],
|
|
26
|
+
}
|
|
27
|
+
const analysis = analyzeGraph(graph)
|
|
28
|
+
expect(analysis.startNodeIds).toEqual(expect.arrayContaining(['a', 'b']))
|
|
29
|
+
expect(analysis.nodes.get('c')?.inDegree).toBe(2)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('should correctly identify a simple cycle', () => {
|
|
33
|
+
const graph: TypedWorkflowGraph<any> = {
|
|
34
|
+
nodes: [{ id: 'a', type: 'process', data: {} }, { id: 'b', type: 'process', data: {} }],
|
|
35
|
+
edges: [{ source: 'a', target: 'b' }, { source: 'b', target: 'a' }],
|
|
36
|
+
}
|
|
37
|
+
const analysis = analyzeGraph(graph)
|
|
38
|
+
expect(analysis.cycles.length).toBeGreaterThan(0)
|
|
39
|
+
expect(analysis.cycles).toContainEqual(['a', 'b', 'a'])
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('should handle an empty graph', () => {
|
|
43
|
+
const graph: TypedWorkflowGraph<any> = { nodes: [], edges: [] }
|
|
44
|
+
const analysis = analyzeGraph(graph)
|
|
45
|
+
expect(analysis.allNodeIds).toEqual([])
|
|
46
|
+
expect(analysis.startNodeIds).toEqual([])
|
|
47
|
+
expect(analysis.nodes.size).toBe(0)
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
describe('createNodeRule and Validator checks', () => {
|
|
52
|
+
const graph: TypedWorkflowGraph<any> = {
|
|
53
|
+
nodes: [
|
|
54
|
+
{ id: 'start', type: 'start', data: {} },
|
|
55
|
+
{ id: 'process', type: 'process', data: {} },
|
|
56
|
+
{ id: 'output', type: 'output', data: {} },
|
|
57
|
+
{ id: 'orphan', type: 'orphan', data: {} },
|
|
58
|
+
],
|
|
59
|
+
edges: [
|
|
60
|
+
{ source: 'start', target: 'process' },
|
|
61
|
+
{ source: 'process', target: 'output' },
|
|
62
|
+
{ source: 'output', target: 'orphan' }, // Invalid edge
|
|
63
|
+
],
|
|
64
|
+
}
|
|
65
|
+
const analysis = analyzeGraph(graph)
|
|
66
|
+
|
|
67
|
+
it('should create a rule that finds nodes with invalid out-degrees', () => {
|
|
68
|
+
const rule = createNodeRule(
|
|
69
|
+
'Output must be terminal',
|
|
70
|
+
node => node.type === 'output',
|
|
71
|
+
node => ({
|
|
72
|
+
valid: node.outDegree === 0,
|
|
73
|
+
message: `Output node '${node.id}' cannot have outgoing connections.`,
|
|
74
|
+
}),
|
|
75
|
+
)
|
|
76
|
+
const errors = rule(analysis, graph)
|
|
77
|
+
expect(errors).toHaveLength(1)
|
|
78
|
+
expect(errors[0].nodeId).toBe('output')
|
|
79
|
+
expect(errors[0].message).toContain('cannot have outgoing connections')
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
describe('checkForCycles validator', () => {
|
|
84
|
+
it('should return a validation error for each detected cycle', () => {
|
|
85
|
+
const graph: TypedWorkflowGraph<any> = {
|
|
86
|
+
nodes: [{ id: 'a', type: 'step', data: {} }, { id: 'b', type: 'step', data: {} }, { id: 'c', type: 'step', data: {} }],
|
|
87
|
+
edges: [
|
|
88
|
+
{ source: 'a', target: 'b' },
|
|
89
|
+
{ source: 'b', target: 'c' },
|
|
90
|
+
{ source: 'c', target: 'a' },
|
|
91
|
+
],
|
|
92
|
+
}
|
|
93
|
+
const analysis = analyzeGraph(graph)
|
|
94
|
+
const errors = checkForCycles(analysis, graph)
|
|
95
|
+
expect(errors).toHaveLength(1)
|
|
96
|
+
expect(errors[0].type).toBe('CycleDetected')
|
|
97
|
+
expect(errors[0].message).toContain('a -> b -> c -> a')
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe('type-Safe Validation with NodeTypeMap', () => {
|
|
102
|
+
// 1. Define a custom, type-safe map for our test nodes.
|
|
103
|
+
interface TestNodeTypeMap extends NodeTypeMap {
|
|
104
|
+
'api-call': { url: string, retries: number }
|
|
105
|
+
'data-transform': { mode: 'uppercase' | 'lowercase' }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 2. Create a graph that uses this specific type map.
|
|
109
|
+
const typedGraph: TypedWorkflowGraph<TestNodeTypeMap> = {
|
|
110
|
+
nodes: [
|
|
111
|
+
{ id: 'fetch-user', type: 'api-call', data: { url: '/users/1', retries: 3 } }, // Valid
|
|
112
|
+
{ id: 'fetch-products', type: 'api-call', data: { url: '/products', retries: 0 } }, // Invalid retries
|
|
113
|
+
{ id: 'format-name', type: 'data-transform', data: { mode: 'uppercase' } },
|
|
114
|
+
],
|
|
115
|
+
edges: [],
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
it('should allow type-safe access to the data property in a rule', () => {
|
|
119
|
+
// 3. Create a type-safe rule that inspects the `data` property.
|
|
120
|
+
const rule = createNodeRule<TestNodeTypeMap>(
|
|
121
|
+
'API calls must have retries',
|
|
122
|
+
// The `node` is correctly typed here as a union of our specific node types
|
|
123
|
+
node => node.type === 'api-call',
|
|
124
|
+
// The `node` here is narrowed to just the 'api-call' type!
|
|
125
|
+
(node) => {
|
|
126
|
+
// `node.data.retries` is fully typed as `number` and autocompletes.
|
|
127
|
+
const valid = node.data.retries > 0
|
|
128
|
+
return {
|
|
129
|
+
valid,
|
|
130
|
+
message: `API call node '${node.id}' must have at least 1 retry.`,
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
const analysis = analyzeGraph(typedGraph)
|
|
136
|
+
const errors = rule(analysis, typedGraph)
|
|
137
|
+
|
|
138
|
+
// 4. Assert that only the invalid node was caught.
|
|
139
|
+
expect(errors).toHaveLength(1)
|
|
140
|
+
expect(errors[0].nodeId).toBe('fetch-products')
|
|
141
|
+
expect(errors[0].message).toContain('must have at least 1 retry')
|
|
142
|
+
})
|
|
143
|
+
})
|
|
144
|
+
})
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import type { GraphNode, NodeTypeMap, TypedGraphNode, TypedWorkflowGraph, WorkflowGraph } from '../builder/graph.types'
|
|
2
|
+
|
|
3
|
+
/** The rich metadata object returned by the analyzeGraph function. */
|
|
4
|
+
export interface GraphAnalysis<T extends NodeTypeMap = any> {
|
|
5
|
+
/** A map of all nodes, keyed by ID, augmented with their connection degrees. */
|
|
6
|
+
nodes: Map<string, TypedGraphNode<T> & { inDegree: number, outDegree: number }>
|
|
7
|
+
/** An array of all node IDs in the graph. */
|
|
8
|
+
allNodeIds: string[]
|
|
9
|
+
/** An array of node IDs that have no incoming edges. */
|
|
10
|
+
startNodeIds: string[]
|
|
11
|
+
/** A list of cycles found in the graph. Each cycle is an array of node IDs. */
|
|
12
|
+
cycles: string[][]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** A standard structure for reporting a single validation error. */
|
|
16
|
+
export interface ValidationError {
|
|
17
|
+
/** The ID of the node where the error occurred, if applicable. */
|
|
18
|
+
nodeId?: string
|
|
19
|
+
/** A category for the error, e.g., 'CycleDetected', 'ConnectionRuleViolation'. */
|
|
20
|
+
type: string
|
|
21
|
+
/** A human-readable message explaining the validation failure. */
|
|
22
|
+
message: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A function that takes a graph analysis and the original graph,
|
|
27
|
+
* and returns an array of validation errors.
|
|
28
|
+
*/
|
|
29
|
+
export type Validator<T extends NodeTypeMap = any> = (
|
|
30
|
+
analysis: GraphAnalysis<T>,
|
|
31
|
+
graph: TypedWorkflowGraph<T>
|
|
32
|
+
) => ValidationError[]
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A helper function that creates a type guard for filtering nodes by their type.
|
|
36
|
+
* This simplifies writing type-safe validation rules by removing the need for
|
|
37
|
+
* verbose, explicit type guard syntax.
|
|
38
|
+
*
|
|
39
|
+
* @param type The literal string of the node type to check for.
|
|
40
|
+
* @returns A type guard function that narrows the node to its specific type.
|
|
41
|
+
*/
|
|
42
|
+
export function isNodeType<T extends NodeTypeMap, K extends keyof T>(type: K) {
|
|
43
|
+
return (node: TypedGraphNode<T>): node is TypedGraphNode<T> & { type: K } => {
|
|
44
|
+
return node.type === type
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Analyzes a declarative workflow graph definition to extract structural metadata.
|
|
50
|
+
* This is a lightweight, static utility that does not instantiate any nodes.
|
|
51
|
+
*
|
|
52
|
+
* @param graph The WorkflowGraph object containing nodes and edges.
|
|
53
|
+
* @returns A GraphAnalysis object containing nodes with degree counts, start nodes, and any cycles.
|
|
54
|
+
*/
|
|
55
|
+
// (Typesafe Overload) Analyzes a declarative workflow graph, preserving strong types.
|
|
56
|
+
export function analyzeGraph<T extends NodeTypeMap>(graph: TypedWorkflowGraph<T>): GraphAnalysis<T>
|
|
57
|
+
// (Untyped Overload) Analyzes a declarative workflow graph with basic types.
|
|
58
|
+
export function analyzeGraph(graph: WorkflowGraph): GraphAnalysis
|
|
59
|
+
// (Implementation) Analyzes a declarative workflow graph to extract structural metadata.
|
|
60
|
+
export function analyzeGraph<T extends NodeTypeMap>(graph: TypedWorkflowGraph<T> | WorkflowGraph): GraphAnalysis<T> {
|
|
61
|
+
const typedGraph = graph as TypedWorkflowGraph<T> // Cast for internal consistency
|
|
62
|
+
const analysis: GraphAnalysis<T> = {
|
|
63
|
+
nodes: new Map(),
|
|
64
|
+
allNodeIds: [],
|
|
65
|
+
startNodeIds: [],
|
|
66
|
+
cycles: [],
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (!typedGraph || !typedGraph.nodes || !typedGraph.nodes.length)
|
|
70
|
+
return analysis
|
|
71
|
+
|
|
72
|
+
const allNodeIds = typedGraph.nodes.map(node => node.id)
|
|
73
|
+
analysis.allNodeIds = allNodeIds
|
|
74
|
+
|
|
75
|
+
const adj: Map<string, string[]> = new Map()
|
|
76
|
+
typedGraph.nodes.forEach((node) => {
|
|
77
|
+
analysis.nodes.set(node.id, { ...node, inDegree: 0, outDegree: 0 })
|
|
78
|
+
adj.set(node.id, [])
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
typedGraph.edges.forEach((edge) => {
|
|
82
|
+
const source = analysis.nodes.get(edge.source)
|
|
83
|
+
const target = analysis.nodes.get(edge.target)
|
|
84
|
+
if (source)
|
|
85
|
+
source.outDegree++
|
|
86
|
+
if (target)
|
|
87
|
+
target.inDegree++
|
|
88
|
+
if (adj.has(edge.source))
|
|
89
|
+
adj.get(edge.source)!.push(edge.target)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
analysis.startNodeIds = allNodeIds.filter(id => analysis.nodes.get(id)!.inDegree === 0)
|
|
93
|
+
|
|
94
|
+
const visited = new Set<string>()
|
|
95
|
+
const recursionStack = new Set<string>()
|
|
96
|
+
function detectCycleUtil(nodeId: string, path: string[]) {
|
|
97
|
+
visited.add(nodeId)
|
|
98
|
+
recursionStack.add(nodeId)
|
|
99
|
+
path.push(nodeId)
|
|
100
|
+
|
|
101
|
+
const neighbors = adj.get(nodeId) || []
|
|
102
|
+
for (const neighbor of neighbors) {
|
|
103
|
+
if (recursionStack.has(neighbor)) {
|
|
104
|
+
const cycleStartIndex = path.indexOf(neighbor)
|
|
105
|
+
const cycle = path.slice(cycleStartIndex)
|
|
106
|
+
analysis.cycles.push([...cycle, neighbor])
|
|
107
|
+
}
|
|
108
|
+
else if (!visited.has(neighbor)) {
|
|
109
|
+
detectCycleUtil(neighbor, path)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
recursionStack.delete(nodeId)
|
|
114
|
+
path.pop()
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
for (const nodeId of allNodeIds) {
|
|
118
|
+
if (!visited.has(nodeId))
|
|
119
|
+
detectCycleUtil(nodeId, [])
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return analysis
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Factory for creating a generic, reusable validator that checks node properties.
|
|
127
|
+
*
|
|
128
|
+
* @param description A human-readable description of the rule for error messages.
|
|
129
|
+
* @param filter A predicate to select which nodes this rule applies to.
|
|
130
|
+
* @param check A function that validates the properties of a selected node.
|
|
131
|
+
* @returns A Validator function.
|
|
132
|
+
*/
|
|
133
|
+
// (Type-Safe Overload) Creates a validator with strong types based on a NodeTypeMap.
|
|
134
|
+
export function createNodeRule<T extends NodeTypeMap>(
|
|
135
|
+
description: string,
|
|
136
|
+
filter: (node: TypedGraphNode<T>) => boolean,
|
|
137
|
+
check: (node: TypedGraphNode<T> & { inDegree: number, outDegree: number }) => { valid: boolean, message?: string },
|
|
138
|
+
): Validator<T>
|
|
139
|
+
// (Untyped Overload) Creates a validator with basic types.
|
|
140
|
+
export function createNodeRule(
|
|
141
|
+
description: string,
|
|
142
|
+
filter: (node: GraphNode) => boolean,
|
|
143
|
+
check: (node: GraphNode & { inDegree: number, outDegree: number }) => { valid: boolean, message?: string },
|
|
144
|
+
): Validator
|
|
145
|
+
// (Implementation) Factory for creating a generic, reusable validator.
|
|
146
|
+
export function createNodeRule(
|
|
147
|
+
description: string,
|
|
148
|
+
filter: (node: any) => boolean,
|
|
149
|
+
check: (node: any) => { valid: boolean, message?: string },
|
|
150
|
+
): Validator {
|
|
151
|
+
return (analysis: GraphAnalysis, _graph: WorkflowGraph): ValidationError[] => {
|
|
152
|
+
const errors: ValidationError[] = []
|
|
153
|
+
for (const node of analysis.nodes.values()) {
|
|
154
|
+
if (filter(node)) {
|
|
155
|
+
const result = check(node)
|
|
156
|
+
if (!result.valid) {
|
|
157
|
+
errors.push({
|
|
158
|
+
nodeId: node.id,
|
|
159
|
+
type: 'ConnectionRuleViolation',
|
|
160
|
+
message: result.message || `Node ${node.id} failed rule: ${description}`,
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return errors
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* A built-in validator that reports any cycles found in the graph.
|
|
171
|
+
*/
|
|
172
|
+
export const checkForCycles: Validator = (analysis) => {
|
|
173
|
+
const uniqueCycles = new Set(analysis.cycles.map(c => c.slice(0, -1).sort().join(',')))
|
|
174
|
+
return Array.from(uniqueCycles).map((cycleKey) => {
|
|
175
|
+
const representativeCycle = analysis.cycles.find(c => c.slice(0, -1).sort().join(',') === cycleKey)!
|
|
176
|
+
return {
|
|
177
|
+
nodeId: representativeCycle[0],
|
|
178
|
+
type: 'CycleDetected',
|
|
179
|
+
message: `Cycle detected involving nodes: ${representativeCycle.join(' -> ')}`,
|
|
180
|
+
}
|
|
181
|
+
})
|
|
182
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import type { AbstractNode } from '../workflow'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { ParallelFlow } from '../builder'
|
|
4
|
+
import { DEFAULT_ACTION, FILTER_FAILED, Flow, Node } from '../workflow'
|
|
5
|
+
import { generateMermaidGraph } from './mermaid'
|
|
6
|
+
|
|
7
|
+
// Define simple, named node classes to make test assertions clearer.
|
|
8
|
+
class StartNode extends Node { }
|
|
9
|
+
class ProcessNode extends Node { }
|
|
10
|
+
class EndNode extends Node { }
|
|
11
|
+
class DecisionNode extends Node { }
|
|
12
|
+
class PathANode extends Node { }
|
|
13
|
+
class PathBNode extends Node { }
|
|
14
|
+
class PathCNode extends Node { } // For 3-way branch test
|
|
15
|
+
class FailureNode extends Node { }
|
|
16
|
+
class TopNode extends Node { } // For diamond test
|
|
17
|
+
class LeftNode extends Node { } // For diamond test
|
|
18
|
+
class RightNode extends Node { } // For diamond test
|
|
19
|
+
class BottomNode extends Node { } // For diamond test
|
|
20
|
+
|
|
21
|
+
describe('testGenerateMermaidGraph', () => {
|
|
22
|
+
it('should generate a correct graph for a simple linear flow', () => {
|
|
23
|
+
const startNode = new StartNode()
|
|
24
|
+
const processNode = new ProcessNode()
|
|
25
|
+
const endNode = new EndNode()
|
|
26
|
+
startNode.next(processNode).next(endNode)
|
|
27
|
+
const flow = new Flow(startNode)
|
|
28
|
+
const result = generateMermaidGraph(flow)
|
|
29
|
+
expect(result).toContain('graph TD')
|
|
30
|
+
expect(result).toContain('StartNode_0[StartNode]')
|
|
31
|
+
expect(result).toContain('ProcessNode_0[ProcessNode]')
|
|
32
|
+
expect(result).toContain('EndNode_0[EndNode]')
|
|
33
|
+
expect(result).toContain('StartNode_0 --> ProcessNode_0')
|
|
34
|
+
expect(result).toContain('ProcessNode_0 --> EndNode_0')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('should handle conditional branching with custom string actions', () => {
|
|
38
|
+
const decisionNode = new DecisionNode()
|
|
39
|
+
const pathANode = new PathANode()
|
|
40
|
+
const pathBNode = new PathBNode()
|
|
41
|
+
decisionNode.next(pathANode, 'action_a')
|
|
42
|
+
decisionNode.next(pathBNode, 'action_b')
|
|
43
|
+
const flow = new Flow(decisionNode)
|
|
44
|
+
const result = generateMermaidGraph(flow)
|
|
45
|
+
expect(result).toContain('DecisionNode_0[DecisionNode]')
|
|
46
|
+
expect(result).toContain('PathANode_0[PathANode]')
|
|
47
|
+
expect(result).toContain('PathBNode_0[PathBNode]')
|
|
48
|
+
expect(result).toContain('DecisionNode_0 -- "action_a" --> PathANode_0')
|
|
49
|
+
expect(result).toContain('DecisionNode_0 -- "action_b" --> PathBNode_0')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('should use special labels for FILTER_FAILED and DEFAULT_ACTION', () => {
|
|
53
|
+
const decisionNode = new DecisionNode()
|
|
54
|
+
const pathANode = new PathANode()
|
|
55
|
+
const failureNode = new FailureNode()
|
|
56
|
+
decisionNode.next(pathANode, DEFAULT_ACTION)
|
|
57
|
+
decisionNode.next(failureNode, FILTER_FAILED)
|
|
58
|
+
const flow = new Flow(decisionNode)
|
|
59
|
+
const result = generateMermaidGraph(flow)
|
|
60
|
+
expect(result).toContain('DecisionNode_0 --> PathANode_0')
|
|
61
|
+
expect(result).toContain('DecisionNode_0 -- "filter failed" --> FailureNode_0')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('should correctly represent a flow with a cycle', () => {
|
|
65
|
+
const startNode = new StartNode()
|
|
66
|
+
const processNode = new ProcessNode()
|
|
67
|
+
startNode.next(processNode)
|
|
68
|
+
processNode.next(startNode) // Loop back
|
|
69
|
+
const flow = new Flow(startNode)
|
|
70
|
+
const result = generateMermaidGraph(flow)
|
|
71
|
+
expect(result).toContain('StartNode_0[StartNode]')
|
|
72
|
+
expect(result).toContain('ProcessNode_0[ProcessNode]')
|
|
73
|
+
expect(result).toContain('StartNode_0 --> ProcessNode_0')
|
|
74
|
+
expect(result).toContain('ProcessNode_0 --> StartNode_0')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('should handle multiple nodes fanning into a single node', () => {
|
|
78
|
+
const decisionNode = new DecisionNode()
|
|
79
|
+
const pathANode = new PathANode()
|
|
80
|
+
const pathBNode = new PathBNode()
|
|
81
|
+
const endNode = new EndNode()
|
|
82
|
+
decisionNode.next(pathANode, 'a')
|
|
83
|
+
decisionNode.next(pathBNode, 'b')
|
|
84
|
+
pathANode.next(endNode)
|
|
85
|
+
pathBNode.next(endNode)
|
|
86
|
+
const flow = new Flow(decisionNode)
|
|
87
|
+
const result = generateMermaidGraph(flow)
|
|
88
|
+
expect(result).toContain('PathANode_0 --> EndNode_0')
|
|
89
|
+
expect(result).toContain('PathBNode_0 --> EndNode_0')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('should return a minimal graph for a flow with only a single node', () => {
|
|
93
|
+
const flow = new Flow(new StartNode())
|
|
94
|
+
const result = generateMermaidGraph(flow)
|
|
95
|
+
expect(result).toBe('graph TD\n StartNode_0[StartNode]')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('should return an empty graph definition for an empty flow', () => {
|
|
99
|
+
const flow = new Flow() // No start node
|
|
100
|
+
const result = generateMermaidGraph(flow)
|
|
101
|
+
expect(result).toBe('graph TD\n %% Empty Flow')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('should generate unique names for multiple instances of the same node class', () => {
|
|
105
|
+
const startNode = new ProcessNode()
|
|
106
|
+
const middleNode = new ProcessNode()
|
|
107
|
+
const endNode = new ProcessNode()
|
|
108
|
+
startNode.next(middleNode).next(endNode)
|
|
109
|
+
const flow = new Flow(startNode)
|
|
110
|
+
const result = generateMermaidGraph(flow)
|
|
111
|
+
expect(result).toContain('ProcessNode_0[ProcessNode]')
|
|
112
|
+
expect(result).toContain('ProcessNode_1[ProcessNode]')
|
|
113
|
+
expect(result).toContain('ProcessNode_2[ProcessNode]')
|
|
114
|
+
expect(result).toContain('ProcessNode_0 --> ProcessNode_1')
|
|
115
|
+
expect(result).toContain('ProcessNode_1 --> ProcessNode_2')
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('should correctly render a diamond-shaped graph', () => {
|
|
119
|
+
const top = new TopNode()
|
|
120
|
+
const left = new LeftNode()
|
|
121
|
+
const right = new RightNode()
|
|
122
|
+
const bottom = new BottomNode()
|
|
123
|
+
top.next(left, 'go_left')
|
|
124
|
+
top.next(right, 'go_right')
|
|
125
|
+
left.next(bottom)
|
|
126
|
+
right.next(bottom)
|
|
127
|
+
const flow = new Flow(top)
|
|
128
|
+
const result = generateMermaidGraph(flow)
|
|
129
|
+
expect(result).toContain('TopNode_0 -- "go_left" --> LeftNode_0')
|
|
130
|
+
expect(result).toContain('TopNode_0 -- "go_right" --> RightNode_0')
|
|
131
|
+
expect(result).toContain('LeftNode_0 --> BottomNode_0')
|
|
132
|
+
expect(result).toContain('RightNode_0 --> BottomNode_0')
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('should handle a node with three or more branches', () => {
|
|
136
|
+
const decision = new DecisionNode()
|
|
137
|
+
const pathA = new PathANode()
|
|
138
|
+
const pathB = new PathBNode()
|
|
139
|
+
const pathC = new PathCNode()
|
|
140
|
+
decision.next(pathA, 'a')
|
|
141
|
+
decision.next(pathB, 'b')
|
|
142
|
+
decision.next(pathC, 'c')
|
|
143
|
+
const flow = new Flow(decision)
|
|
144
|
+
const result = generateMermaidGraph(flow)
|
|
145
|
+
expect(result).toContain('DecisionNode_0 -- "a" --> PathANode_0')
|
|
146
|
+
expect(result).toContain('DecisionNode_0 -- "b" --> PathBNode_0')
|
|
147
|
+
expect(result).toContain('DecisionNode_0 -- "c" --> PathCNode_0')
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('should handle a branch that leads to another branch (multi-level)', () => {
|
|
151
|
+
const root = new DecisionNode()
|
|
152
|
+
const branchA = new PathANode() // Terminal branch
|
|
153
|
+
const branchB = new DecisionNode() // This branch also branches
|
|
154
|
+
const leafA = new EndNode()
|
|
155
|
+
const leafB1 = new EndNode()
|
|
156
|
+
const leafB2 = new EndNode()
|
|
157
|
+
root.next(branchA, 'path_a')
|
|
158
|
+
root.next(branchB, 'path_b')
|
|
159
|
+
branchA.next(leafA)
|
|
160
|
+
branchB.next(leafB1, 'sub_b1')
|
|
161
|
+
branchB.next(leafB2, 'sub_b2')
|
|
162
|
+
const flow = new Flow(root)
|
|
163
|
+
const result = generateMermaidGraph(flow)
|
|
164
|
+
expect(result).toContain('DecisionNode_0 -- "path_a" --> PathANode_0')
|
|
165
|
+
expect(result).toContain('DecisionNode_0 -- "path_b" --> DecisionNode_1')
|
|
166
|
+
expect(result).toContain('PathANode_0 --> EndNode_0')
|
|
167
|
+
expect(result).toContain('DecisionNode_1 -- "sub_b1" --> EndNode_1')
|
|
168
|
+
expect(result).toContain('DecisionNode_1 -- "sub_b2" --> EndNode_2')
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('should handle a branch that loops while another terminates', () => {
|
|
172
|
+
const decision = new DecisionNode()
|
|
173
|
+
const loopNode = new ProcessNode()
|
|
174
|
+
const endNode = new EndNode()
|
|
175
|
+
decision.next(loopNode, 'continue')
|
|
176
|
+
decision.next(endNode, 'finish')
|
|
177
|
+
loopNode.next(decision) // Loop back to the decision
|
|
178
|
+
const flow = new Flow(decision)
|
|
179
|
+
const result = generateMermaidGraph(flow)
|
|
180
|
+
expect(result).toContain('DecisionNode_0 -- "continue" --> ProcessNode_0')
|
|
181
|
+
expect(result).toContain('DecisionNode_0 -- "finish" --> EndNode_0')
|
|
182
|
+
expect(result).toContain('ProcessNode_0 --> DecisionNode_0')
|
|
183
|
+
})
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
describe('testGraphBuilderGraphs', () => {
|
|
187
|
+
class ParallelBranchContainer extends ParallelFlow {
|
|
188
|
+
public readonly isParallelContainer = true
|
|
189
|
+
constructor(public readonly nodesToRun: AbstractNode[]) { super(nodesToRun) }
|
|
190
|
+
}
|
|
191
|
+
it('should generate descriptive labels using node.graphData', () => {
|
|
192
|
+
const nodeA = new Node().withGraphData({ id: 'start-node', type: 'llm-process' })
|
|
193
|
+
const nodeB = new Node().withGraphData({ id: 'end-node', type: 'output' })
|
|
194
|
+
nodeA.next(nodeB)
|
|
195
|
+
const flow = new Flow(nodeA)
|
|
196
|
+
const result = generateMermaidGraph(flow)
|
|
197
|
+
|
|
198
|
+
expect(result).toContain('startnode_0["start-node (llm-process)"]')
|
|
199
|
+
expect(result).toContain('endnode_0["end-node (output)"]')
|
|
200
|
+
expect(result).toContain('startnode_0 --> endnode_0')
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('should generate special labels for parallel containers and mappers', () => {
|
|
204
|
+
const start = new Node().withGraphData({ id: 'start', type: 'start-type' })
|
|
205
|
+
const branchA = new Node().withGraphData({ id: 'branch-a', type: 'type-a' })
|
|
206
|
+
const branchB = new Node().withGraphData({ id: 'branch-b', type: 'type-b' })
|
|
207
|
+
const parallel = new ParallelBranchContainer([branchA, branchB])
|
|
208
|
+
const finalNode = new Node().withGraphData({ id: 'final', type: 'end-type' })
|
|
209
|
+
|
|
210
|
+
// This is how the GraphBuilder wires the flow
|
|
211
|
+
start.next(parallel)
|
|
212
|
+
branchA.next(finalNode) // Internal node fans out
|
|
213
|
+
branchB.next(finalNode) // Internal node fans out
|
|
214
|
+
|
|
215
|
+
const flow = new Flow(start)
|
|
216
|
+
const result = generateMermaidGraph(flow)
|
|
217
|
+
|
|
218
|
+
// Check for container label
|
|
219
|
+
expect(result).toContain('ParallelBlock_0{Parallel Block}')
|
|
220
|
+
// Check for fan-out from container
|
|
221
|
+
expect(result).toContain('start_0 --> ParallelBlock_0')
|
|
222
|
+
expect(result).toContain('ParallelBlock_0 --> brancha_0')
|
|
223
|
+
expect(result).toContain('ParallelBlock_0 --> branchb_0')
|
|
224
|
+
// Check for fan-in to final node
|
|
225
|
+
expect(result).toContain('brancha_0 --> final_0')
|
|
226
|
+
expect(result).toContain('branchb_0 --> final_0')
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it('should get original ID from graphData, ignoring prefixes', () => {
|
|
230
|
+
// Simulates a node from an inlined sub-workflow
|
|
231
|
+
const inlinedNode = new Node().withGraphData({
|
|
232
|
+
id: 'parent:child-node',
|
|
233
|
+
type: 'child-type',
|
|
234
|
+
})
|
|
235
|
+
const flow = new Flow(inlinedNode)
|
|
236
|
+
const result = generateMermaidGraph(flow)
|
|
237
|
+
expect(result).toContain('childnode_0["child-node (child-type)"]')
|
|
238
|
+
})
|
|
239
|
+
})
|