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,182 @@
|
|
|
1
|
+
# Best Practices: Debugging Workflows
|
|
2
|
+
|
|
3
|
+
Debugging multi-step, asynchronous workflows can be challenging. State can change in unexpected ways, and control flow can be complex. Flowcraft is designed with debuggability in mind and provides several tools and patterns to help you pinpoint issues quickly.
|
|
4
|
+
|
|
5
|
+
This guide covers the most effective techniques for debugging your workflows.
|
|
6
|
+
|
|
7
|
+
## 1. Inspect Data Flow with `.tap()`
|
|
8
|
+
|
|
9
|
+
> [!TIP]
|
|
10
|
+
> The `.tap()` method is your best friend for non-disruptive debugging. It's the cleanest way to inspect data mid-pipeline without breaking a fluent chain.
|
|
11
|
+
|
|
12
|
+
Instead of breaking your chain to insert a `console.log`, use the `.tap()` method. It receives the result of the previous step, allows you to perform a side-effect (like logging), and then passes the original result through to the next step, completely unmodified.
|
|
13
|
+
|
|
14
|
+
**Scenario**: You have a chain of `.map()` calls and want to see the intermediate result.
|
|
15
|
+
|
|
16
|
+
```typescript
|
|
17
|
+
import { contextKey, Node } from 'flowcraft'
|
|
18
|
+
|
|
19
|
+
const FINAL_RESULT = contextKey<string>('final_result')
|
|
20
|
+
|
|
21
|
+
// A node that fetches a user object
|
|
22
|
+
const fetchUserNode = new Node().exec(() => ({ id: 123, name: 'Alice', email: 'alice@test.com' }))
|
|
23
|
+
|
|
24
|
+
const processUser = fetchUserNode
|
|
25
|
+
.map(user => ({ ...user, name: user.name.toUpperCase() }))
|
|
26
|
+
// Let's inspect the data right here!
|
|
27
|
+
.tap((intermediateResult) => {
|
|
28
|
+
console.log('[DEBUG] After capitalization:', intermediateResult)
|
|
29
|
+
})
|
|
30
|
+
.map(user => `User ID: ${user.id}, Name: ${user.name}`)
|
|
31
|
+
.toContext(FINAL_RESULT)
|
|
32
|
+
|
|
33
|
+
// When this runs, the debug log will print the intermediate object.
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## 2. Trace Execution with the Logger
|
|
37
|
+
|
|
38
|
+
When your problem is about *control flow* ("Why did my workflow take the wrong branch?"), the logger is your best friend. By passing a `ConsoleLogger` to your `flow.run()` call, you get a detailed, step-by-step trace of the entire execution.
|
|
39
|
+
|
|
40
|
+
The logger will show:
|
|
41
|
+
|
|
42
|
+
- Which node is currently running.
|
|
43
|
+
- The **action** string returned by each node.
|
|
44
|
+
- The successor node chosen for that action.
|
|
45
|
+
- Warnings for retry attempts and errors for fallback execution.
|
|
46
|
+
|
|
47
|
+
```typescript
|
|
48
|
+
import { ConsoleLogger, Flow, TypedContext } from 'flowcraft'
|
|
49
|
+
|
|
50
|
+
// Assume you have a conditional flow set up
|
|
51
|
+
const myFlow = createMyConditionalFlow()
|
|
52
|
+
const context = new TypedContext()
|
|
53
|
+
|
|
54
|
+
// Run the flow with a logger enabled
|
|
55
|
+
await myFlow.run(context, { logger: new ConsoleLogger() })
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
**Example Log Output**:
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
[INFO] Running flow: MyFlow
|
|
62
|
+
[INFO] Running node: CheckConditionNode
|
|
63
|
+
[DEBUG] Action 'action_approve' from CheckConditionNode leads to ApproveNode
|
|
64
|
+
[INFO] Running node: ApproveNode
|
|
65
|
+
[INFO] Flow ends: Action 'Symbol(default)' from ApproveNode has no configured successor.
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
This output makes it immediately clear that `CheckConditionNode` returned `'action_approve'`, which led to `ApproveNode`.
|
|
69
|
+
|
|
70
|
+
## 3. Visualize Your Graph with `generateMermaidGraph`
|
|
71
|
+
|
|
72
|
+
Sometimes the problem isn't the logic inside your nodes, but the way you've wired them together. It's easy to make a mistake with `.next()`, creating a dead end or an incorrect branch.
|
|
73
|
+
|
|
74
|
+
Flowcraft includes a `generateMermaidGraph` utility that creates a visual representation of your `Flow`'s structure. You can paste the output into any Mermaid.js renderer (like the one in the GitHub or VS Code markdown preview) to see your workflow.
|
|
75
|
+
|
|
76
|
+
```typescript
|
|
77
|
+
import { generateMermaidGraph } from 'flowcraft'
|
|
78
|
+
import { createMyComplexFlow } from './my-flows'
|
|
79
|
+
|
|
80
|
+
const complexFlow = createMyComplexFlow()
|
|
81
|
+
|
|
82
|
+
// Generate the Mermaid syntax
|
|
83
|
+
const mermaidSyntax = generateMermaidGraph(complexFlow)
|
|
84
|
+
|
|
85
|
+
console.log(mermaidSyntax)
|
|
86
|
+
/*
|
|
87
|
+
Outputs something like:
|
|
88
|
+
graph TD
|
|
89
|
+
StartNode_0[StartNode]
|
|
90
|
+
DecisionNode_0[DecisionNode]
|
|
91
|
+
PathANode_0[PathANode]
|
|
92
|
+
PathBNode_0[PathBNode]
|
|
93
|
+
EndNode_0[EndNode]
|
|
94
|
+
StartNode_0 --> DecisionNode_0
|
|
95
|
+
DecisionNode_0 -- "go_a" --> PathANode_0
|
|
96
|
+
DecisionNode_0 -- "go_b" --> PathBNode_0
|
|
97
|
+
PathANode_0 --> EndNode_0
|
|
98
|
+
PathBNode_0 --> EndNode_0
|
|
99
|
+
*/
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
This is the fastest way to verify that your graph is connected as you intend.
|
|
103
|
+
|
|
104
|
+
### Visualizing `GraphBuilder` Flows
|
|
105
|
+
|
|
106
|
+
You can pass a `Logger` instance to the `GraphBuilder`'s constructor. When you do, it will automatically generate and log a detailed Mermaid.js diagram of the final, "flattened" graph every time you call `.build()`. This diagram shows you the exact structure the `Executor` will run, including inlined sub-workflows and automatically generated parallel blocks.
|
|
107
|
+
|
|
108
|
+
**Scenario**: You are building a complex workflow and want to see the final executable graph.
|
|
109
|
+
|
|
110
|
+
```typescript
|
|
111
|
+
import { ConsoleLogger, GraphBuilder } from 'flowcraft'
|
|
112
|
+
|
|
113
|
+
// Assume `nodeRegistry` and `myComplexGraph` are defined
|
|
114
|
+
|
|
115
|
+
// Instantiate the builder WITH a logger
|
|
116
|
+
const builder = new GraphBuilder(
|
|
117
|
+
nodeRegistry,
|
|
118
|
+
{ /* dependencies */ },
|
|
119
|
+
{ /* options */ },
|
|
120
|
+
new ConsoleLogger() // <-- This enables automatic logging
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
// When you call .build(), the Mermaid graph will be logged to the console.
|
|
124
|
+
const { flow } = builder.build(myComplexGraph)
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
**Example Log Output**:
|
|
128
|
+
|
|
129
|
+
The builder will log a complete Mermaid diagram, which you can paste into any compatible renderer (like GitHub's markdown preview) to see the visual graph. This is invaluable for verifying complex wiring, fan-outs, and sub-workflow logic.
|
|
130
|
+
|
|
131
|
+
```
|
|
132
|
+
[INFO] [GraphBuilder] Flattened Graph
|
|
133
|
+
[INFO] graph TD
|
|
134
|
+
[INFO] ParallelBlock_0{Parallel Block}
|
|
135
|
+
[INFO] check_sentiment_0["check_sentiment (llm-condition)"]
|
|
136
|
+
[INFO] ... and so on ...
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
> [!TIP]
|
|
140
|
+
> For programmatically built flows (using `.next()`), you can still use the standalone `generateMermaidGraph` utility. However, for declarative workflows, the `GraphBuilder`'s built-in logging is the recommended approach.
|
|
141
|
+
|
|
142
|
+
## 4. Isolate and Inspect Nodes
|
|
143
|
+
|
|
144
|
+
If a single node is behaving incorrectly, you can debug it in isolation.
|
|
145
|
+
|
|
146
|
+
### Isolate with `.run()`
|
|
147
|
+
|
|
148
|
+
You can test a node's full lifecycle (`prep`, `exec`, `post`) by calling its own `.run()` method without the complexity of the entire workflow.
|
|
149
|
+
|
|
150
|
+
1. Create a `TypedContext` and manually set any values the node needs.
|
|
151
|
+
2. Call `node.run(context)`.
|
|
152
|
+
3. Assert that the `Context` contains the expected values after the run.
|
|
153
|
+
|
|
154
|
+
### Inspect with `getNodeById()`
|
|
155
|
+
|
|
156
|
+
For flows built programmatically (with `.next()`), you can get a direct reference to any node instance using `flow.getNodeById()`. This is useful for inspecting its configuration before the flow runs.
|
|
157
|
+
|
|
158
|
+
> [!TIP]
|
|
159
|
+
> For flows built with `GraphBuilder`, always use the `nodeMap` returned by the `.build()` method for the most efficient lookup.
|
|
160
|
+
|
|
161
|
+
```typescript
|
|
162
|
+
const startNode = new Node().withId('start')
|
|
163
|
+
const decisionNode = new Node().withId('decision')
|
|
164
|
+
startNode.next(decisionNode)
|
|
165
|
+
|
|
166
|
+
const myFlow = new Flow(startNode)
|
|
167
|
+
|
|
168
|
+
// Get a reference to the node
|
|
169
|
+
const nodeToInspect = myFlow.getNodeById('decision')
|
|
170
|
+
|
|
171
|
+
// You can now inspect its properties, e.g., its successors
|
|
172
|
+
console.log(nodeToInspect?.successors)
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
## Common Pitfalls
|
|
176
|
+
|
|
177
|
+
If your workflow isn't behaving as expected, check for these common issues:
|
|
178
|
+
|
|
179
|
+
- **Forgetting `await flow.run()`**: Since workflows are asynchronous, forgetting to `await` the top-level `run()` call will cause your script to exit before the workflow can complete.
|
|
180
|
+
- **Context Key Collisions**: In large, composed flows, different sub-flows might accidentally write to the same context key, overwriting each other's data. Using descriptive, unique `ContextKey`s helps prevent this.
|
|
181
|
+
- **Infinite Loops**: If you create a cycle in your graph, make sure your "decider" node has a reliable exit condition. Use the logger to trace the loop and see if the context state is changing as expected on each iteration.
|
|
182
|
+
- **Mutating Objects in Context**: If you place a mutable object (like `{}`, `[]`) in the context, any node can modify it. This can lead to unexpected behavior if one node changes an object that a later node relies on. It's often safer for nodes to create new objects/arrays rather than modifying existing ones in place.
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# Best Practices: State Management
|
|
2
|
+
|
|
3
|
+
The `Context` is the heart of a running workflow, acting as its shared memory. Managing the state within the `Context` effectively is one of the most important skills for building clean, maintainable, and scalable workflows in Flowcraft.
|
|
4
|
+
|
|
5
|
+
## 1. Always Use `ContextKey` for Type Safety
|
|
6
|
+
|
|
7
|
+
> [!IMPORTANT]
|
|
8
|
+
> This is the most important rule for state management. Always prefer `contextKey()` over raw strings to access the `Context`.
|
|
9
|
+
|
|
10
|
+
**Why?**
|
|
11
|
+
|
|
12
|
+
- **Compile-Time Safety**: The TypeScript compiler will catch typos and type mismatches. If you try to `set` a `number` to a `ContextKey<string>`, your code won't compile.
|
|
13
|
+
- **No Typos**: `ctx.get('user_id')` vs. `ctx.get('userId')` is a common runtime bug. `ctx.get(USER_ID)` is checked by the compiler.
|
|
14
|
+
- **Easy Refactoring**: Renaming a `ContextKey` is a simple, safe refactoring operation in any modern IDE. Renaming a string key across a large codebase is risky.
|
|
15
|
+
|
|
16
|
+
```typescript
|
|
17
|
+
import { contextKey, TypedContext } from 'flowcraft'
|
|
18
|
+
|
|
19
|
+
// --- BAD: Using strings ---
|
|
20
|
+
const ctxStrings = new TypedContext()
|
|
21
|
+
ctxStrings.set('user_id', 123) // 'user_id' is just a string, no type info
|
|
22
|
+
const id_bad = ctxStrings.get('user_id') // Type is 'any'
|
|
23
|
+
|
|
24
|
+
// --- GOOD: Using ContextKey ---
|
|
25
|
+
const USER_ID = contextKey<number>('A key for the user ID')
|
|
26
|
+
|
|
27
|
+
const ctxTyped = new TypedContext()
|
|
28
|
+
ctxTyped.set(USER_ID, 123)
|
|
29
|
+
const id_good = ctxTyped.get(USER_ID) // Type is 'number | undefined'
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## 2. Keep the Context Minimal
|
|
33
|
+
|
|
34
|
+
The `Context` should not be a dumping ground for all data ever generated in the workflow. A lean context makes your workflow easier to debug and reason about.
|
|
35
|
+
|
|
36
|
+
**Principle**: A piece of data should only be in the `Context` if it is **required by a subsequent node**.
|
|
37
|
+
|
|
38
|
+
If a node generates intermediate data that is only used within that same node, it should be stored in a local variable, not written to the `Context`.
|
|
39
|
+
|
|
40
|
+
```typescript
|
|
41
|
+
import { Node } from 'flowcraft'
|
|
42
|
+
|
|
43
|
+
class DataProcessorNode extends Node {
|
|
44
|
+
async exec({ prepRes: data }) {
|
|
45
|
+
// BAD: Don't put temporary data in the context
|
|
46
|
+
// ctx.set(TEMP_VALUE, data.a + data.b)
|
|
47
|
+
// const intermediate = ctx.get(TEMP_VALUE)
|
|
48
|
+
// return intermediate * 2
|
|
49
|
+
|
|
50
|
+
// GOOD: Use local variables for intermediate steps
|
|
51
|
+
const intermediate = data.a + data.b
|
|
52
|
+
return intermediate * 2
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## 3. Use `params` for Static Input
|
|
58
|
+
|
|
59
|
+
Distinguish between *dynamic state* and *static configuration*.
|
|
60
|
+
|
|
61
|
+
- **Dynamic State**: Data generated by a previous node that changes during the workflow's execution. This belongs in the **`Context`**.
|
|
62
|
+
- **Static Configuration**: Data provided to a node or flow when it starts, which does not change. This belongs in **`params`**.
|
|
63
|
+
|
|
64
|
+
Using `params` makes your nodes more reusable and their dependencies more explicit.
|
|
65
|
+
|
|
66
|
+
```typescript
|
|
67
|
+
import { contextKey, Node } from 'flowcraft'
|
|
68
|
+
|
|
69
|
+
const NUMBER_TO_ADD = contextKey<number>('number_to_add')
|
|
70
|
+
const CURRENT_VALUE = contextKey<number>('current_value')
|
|
71
|
+
|
|
72
|
+
// --- BAD: Configuration is mixed with state ---
|
|
73
|
+
// This node's behavior depends on a value that must be in the context.
|
|
74
|
+
class AddNumberFromContext extends Node {
|
|
75
|
+
async exec({ ctx }) {
|
|
76
|
+
const valueToAdd = ctx.get(NUMBER_TO_ADD) // less reusable
|
|
77
|
+
return ctx.get(CURRENT_VALUE) + valueToAdd
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// --- GOOD: Configuration is passed as a parameter ---
|
|
82
|
+
// This node is self-contained. Its configuration comes from its params.
|
|
83
|
+
class AddNumberFromParams extends Node {
|
|
84
|
+
async exec({ ctx, params }) {
|
|
85
|
+
const valueToAdd = params.amount // much more reusable
|
|
86
|
+
return ctx.get(CURRENT_VALUE) + valueToAdd
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Usage:
|
|
91
|
+
const node = new AddNumberFromParams().withParams({ amount: 10 })
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## 4. Isolate State Within Sub-Flows
|
|
95
|
+
|
|
96
|
+
When you use composition (a `Flow` within a `Flow`), the sub-flow shares the same `Context` as its parent. To maintain modularity, a sub-flow should not pollute the parent's `Context` with its own internal, temporary state.
|
|
97
|
+
|
|
98
|
+
**Best Practice**: A sub-flow should only write its final, essential outputs to the `Context`. If possible, use a dedicated output node within the sub-flow to aggregate results and clean up any temporary keys.
|
|
99
|
+
|
|
100
|
+
The `SubWorkflowNode` in the `sandbox/4.dag` example demonstrates an advanced pattern for this. By defining explicit `input` and `output` maps, it creates a clean data boundary, preventing any leakage of temporary state. For a detailed explanation of this powerful pattern, see the guide on **[Data Flow in Sub-Workflows](./sub-workflow-data.md)**.
|
|
101
|
+
|
|
102
|
+
## 5. Plan for Serialization
|
|
103
|
+
|
|
104
|
+
> [!WARNING]
|
|
105
|
+
> **Standard `JSON.stringify` is Lossy!**
|
|
106
|
+
> While the `InMemoryExecutor` can handle any JavaScript object, your workflow's state may need to be saved or sent over a network (as in the `BullMQExecutor` example). Standard `JSON.stringify` will not correctly preserve complex data types and can lead to silent data loss or bugs.
|
|
107
|
+
|
|
108
|
+
**Common data types that break `JSON.stringify`:**
|
|
109
|
+
|
|
110
|
+
- `Date` objects (are converted to strings)
|
|
111
|
+
- `Map` and `Set` objects (are converted to empty objects `{}`)
|
|
112
|
+
- Custom class instances (lose their methods and class identity)
|
|
113
|
+
- `Symbol` keys (are dropped entirely)
|
|
114
|
+
|
|
115
|
+
To build robust, stateful workflows, it is a best practice to use a library that handles this automatically.
|
|
116
|
+
|
|
117
|
+
> [!TIP]
|
|
118
|
+
> Use a library like [`superjson`](https://github.com/blitz-js/superjson) to serialize and deserialize your context. It transparently handles all common JavaScript types, ensuring that the state you save is the same as the state you load.
|
|
119
|
+
|
|
120
|
+
The **[Advanced RAG Agent](https://github.com/gorango/tree/master/sandbox/6.rag/) example** was specifically designed to demonstrate this principle. It uses `superjson` to correctly manage a `Context` containing `Map`, `Date`, and custom class instances, making its state reliable and easy to inspect.
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# Best Practices: Data Flow in Sub-Workflows
|
|
2
|
+
|
|
3
|
+
When you compose workflows, managing the flow of data between the parent and child is critical for creating modular and predictable systems. By default, Flowcraft uses a **shared context**, which is simple but can lead to tight coupling.
|
|
4
|
+
|
|
5
|
+
This guide describes a powerful pattern for creating an explicit data boundary between flows, as demonstrated by the `SubWorkflowNode` in the Dynamic Graph Engine example.
|
|
6
|
+
|
|
7
|
+
## The Problem: A Leaky Context
|
|
8
|
+
|
|
9
|
+
Because the default behavior is a shared context, a sub-workflow has access to the parent's entire state. This can cause two main problems in large systems:
|
|
10
|
+
|
|
11
|
+
- **Input Pollution**: The sub-workflow has access to *everything* in the parent's context, making its dependencies implicit and hard to track. It's not clear from the sub-workflow's definition what data it actually needs to operate.
|
|
12
|
+
- **Output Pollution**: Temporary, internal state from the sub-workflow can leak out and accidentally overwrite values in the parent's context, causing unexpected side effects in later stages of the parent flow.
|
|
13
|
+
|
|
14
|
+
## The Solution: Explicit Input and Output Mapping
|
|
15
|
+
|
|
16
|
+
To solve this, you can create a specialized `Node` (like the `SubWorkflowNode` in our examples) that acts as a gatekeeper. Instead of sharing the context, this node creates a *new, temporary context* for the sub-workflow and explicitly maps data in and out. Its configuration defines this data contract.
|
|
17
|
+
|
|
18
|
+
```json
|
|
19
|
+
{
|
|
20
|
+
"id": "my_sub_workflow_node",
|
|
21
|
+
"type": "sub-workflow",
|
|
22
|
+
"data": {
|
|
23
|
+
"workflowId": 201,
|
|
24
|
+
"inputs": {
|
|
25
|
+
"sub_flow_key": "parent_flow_key"
|
|
26
|
+
},
|
|
27
|
+
"outputs": {
|
|
28
|
+
"parent_flow_key_for_result": "sub_flow_output_key"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### 1. The `inputs` Map
|
|
35
|
+
|
|
36
|
+
The `inputs` map defines **what data flows from the parent context into the sub-workflow's context**.
|
|
37
|
+
|
|
38
|
+
- `"sub_flow_key"`: The `ContextKey` that will be used *inside* the sub-workflow.
|
|
39
|
+
- `"parent_flow_key"`: The `ContextKey` in the *parent* flow whose value will be copied.
|
|
40
|
+
|
|
41
|
+
**How it works:**
|
|
42
|
+
Before the sub-workflow runs, the `SubWorkflowNode` creates a new, empty context. It then iterates through the `inputs` map. For each entry, it reads the value from `parent_flow_key` in the parent context and writes it to `sub_flow_key` in the sub-workflow's fresh context.
|
|
43
|
+
|
|
44
|
+
This ensures the sub-workflow only receives the exact data it needs, making it a pure, reusable component with clear dependencies.
|
|
45
|
+
|
|
46
|
+
### 2. The `outputs` Map
|
|
47
|
+
|
|
48
|
+
The `outputs` map defines **what data flows from the sub-workflow's context back out to the parent context**.
|
|
49
|
+
|
|
50
|
+
- `"parent_flow_key_for_result"`: The `ContextKey` in the *parent* flow where the result will be stored.
|
|
51
|
+
- `"sub_flow_output_key"`: The `ContextKey` in the *sub-workflow* whose value will be copied.
|
|
52
|
+
|
|
53
|
+
**How it works:**
|
|
54
|
+
After the sub-workflow completes, the `SubWorkflowNode` iterates through the `outputs` map. For each entry, it reads the value from `sub_flow_output_key` in the sub-workflow's context and writes it to `parent_flow_key_for_result` in the parent's context.
|
|
55
|
+
|
|
56
|
+
This prevents any temporary or internal variables from the sub-workflow from leaking out and polluting the parent's state. Only the explicitly defined outputs are passed back.
|
|
57
|
+
|
|
58
|
+
### Example: A User Greeter Sub-Workflow
|
|
59
|
+
|
|
60
|
+
Imagine a parent flow that has a `USER_OBJECT`. We want to call a sub-workflow that generates a greeting.
|
|
61
|
+
|
|
62
|
+
**Parent Flow Graph Node:**
|
|
63
|
+
|
|
64
|
+
```json
|
|
65
|
+
{
|
|
66
|
+
"id": "generate_greeting",
|
|
67
|
+
"type": "sub-workflow",
|
|
68
|
+
"data": {
|
|
69
|
+
"workflowId": 101,
|
|
70
|
+
"inputs": {
|
|
71
|
+
"user_to_greet": "USER_OBJECT"
|
|
72
|
+
},
|
|
73
|
+
"outputs": {
|
|
74
|
+
"FINAL_GREETING_MESSAGE": "greeting_result"
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
**Sub-Workflow (ID: 101):**
|
|
81
|
+
|
|
82
|
+
1. **Starts**: Receives a new context containing only `user_to_greet` (copied from the parent's `USER_OBJECT`).
|
|
83
|
+
2. **Internal Node `A`**: Reads `user_to_greet.name` and creates a string: `Hello, Alice!`. It stores this in an internal key, `temp_message`.
|
|
84
|
+
3. **Internal Node `B`**: Reads `temp_message`, adds an emoji, and stores the final string in `greeting_result`.
|
|
85
|
+
4. **Ends**.
|
|
86
|
+
|
|
87
|
+
**Data Flow:**
|
|
88
|
+
|
|
89
|
+
1. The `SubWorkflowNode` copies `parentContext.get('USER_OBJECT')` into `subContext.set('user_to_greet', ...)`.
|
|
90
|
+
2. Sub-workflow `101` runs with its own isolated context. The `temp_message` key exists only within this context.
|
|
91
|
+
3. When it finishes, its context contains `greeting_result` with the value `"Hello, Alice! 👋"`.
|
|
92
|
+
4. The `SubWorkflowNode` copies `subContext.get('greeting_result')` into `parentContext.set('FINAL_GREETING_MESSAGE', ...)`.
|
|
93
|
+
5. The parent flow now has a `FINAL_GREETING_MESSAGE` key, but is completely unaware of the `temp_message` key.
|
|
94
|
+
|
|
95
|
+
By enforcing this explicit data contract, you can build complex, nested workflows that are as easy to reason about as pure functions.
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# Best Practices: Testing Workflows
|
|
2
|
+
|
|
3
|
+
Testing is crucial for building robust and reliable workflows. Flowcraft's design, which separates data preparation (`prep`), core logic (`exec`), and state updates (`post`), makes testing straightforward. This guide covers strategies for testing individual nodes and entire flows.
|
|
4
|
+
|
|
5
|
+
## Testing Individual Nodes
|
|
6
|
+
|
|
7
|
+
The most important part of a `Node` to test is its `exec` method, as it contains the core business logic. Because `exec` is designed to be pure—receiving all its input from `prep` and not accessing the `Context` directly—you can test it in complete isolation.
|
|
8
|
+
|
|
9
|
+
### Strategy: Test `exec` Directly
|
|
10
|
+
|
|
11
|
+
You can instantiate your node and call its `_exec` method (the internal method that includes retry logic) or `exec` method directly, providing mock `NodeArgs`.
|
|
12
|
+
|
|
13
|
+
**Example: Testing a data transformation node**
|
|
14
|
+
|
|
15
|
+
Let's assume you have this node:
|
|
16
|
+
|
|
17
|
+
```typescript
|
|
18
|
+
import { Node } from 'flowcraft'
|
|
19
|
+
|
|
20
|
+
// src/nodes.ts
|
|
21
|
+
class UserProcessorNode extends Node<{ name: string, email: string }, { fullName: string, domain: string }> {
|
|
22
|
+
async exec({ prepRes: user }) {
|
|
23
|
+
if (!user.name || !user.email) {
|
|
24
|
+
throw new Error('Invalid user data')
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
fullName: user.name.toUpperCase(),
|
|
28
|
+
domain: user.email.split('@')[1],
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Here's how you could test it using a testing framework like Vitest or Jest:
|
|
35
|
+
|
|
36
|
+
```typescript
|
|
37
|
+
import { NullLogger } from 'flowcraft'
|
|
38
|
+
// src/nodes.test.ts
|
|
39
|
+
import { UserProcessorNode } from './nodes'
|
|
40
|
+
|
|
41
|
+
describe('UserProcessorNode', () => {
|
|
42
|
+
it('should correctly process valid user data', async () => {
|
|
43
|
+
const node = new UserProcessorNode()
|
|
44
|
+
const mockArgs = {
|
|
45
|
+
prepRes: { name: 'Alice', email: 'alice@example.com' },
|
|
46
|
+
logger: new NullLogger(),
|
|
47
|
+
// other args can be mocked as needed
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const result = await node.exec(mockArgs)
|
|
51
|
+
|
|
52
|
+
expect(result).toEqual({
|
|
53
|
+
fullName: 'ALICE',
|
|
54
|
+
domain: 'example.com',
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('should throw an error for invalid user data', async () => {
|
|
59
|
+
const node = new UserProcessorNode()
|
|
60
|
+
const mockArgs = {
|
|
61
|
+
prepRes: { name: '', email: 'no-name@test.com' },
|
|
62
|
+
logger: new NullLogger(),
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
await expect(node.exec(mockArgs)).rejects.toThrow('Invalid user data')
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Testing `prep` and `post`
|
|
71
|
+
|
|
72
|
+
To test the full lifecycle of a node, including its interaction with the `Context`, you can use the node's `.run()` method.
|
|
73
|
+
|
|
74
|
+
### Strategy: Use `.run()` and inspect the Context
|
|
75
|
+
|
|
76
|
+
1. Create a `TypedContext` and pre-populate it with any data your node's `prep` phase needs.
|
|
77
|
+
2. Call `node.run(context)`.
|
|
78
|
+
3. Assert that the `Context` contains the expected values after the run, which tests your `post` logic.
|
|
79
|
+
|
|
80
|
+
**Example: Testing a node that interacts with context**
|
|
81
|
+
|
|
82
|
+
```typescript
|
|
83
|
+
import { contextKey, Node } from 'flowcraft'
|
|
84
|
+
|
|
85
|
+
// src/nodes.ts
|
|
86
|
+
const INPUT = contextKey<number>('input')
|
|
87
|
+
const OUTPUT = contextKey<number>('output')
|
|
88
|
+
|
|
89
|
+
class AddTenNode extends Node {
|
|
90
|
+
async prep({ ctx }) {
|
|
91
|
+
return ctx.get(INPUT) || 0
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async exec({ prepRes: value }) {
|
|
95
|
+
return value + 10
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async post({ ctx, execRes: result }) {
|
|
99
|
+
ctx.set(OUTPUT, result)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
And the test:
|
|
105
|
+
|
|
106
|
+
```typescript
|
|
107
|
+
// src/nodes.test.ts
|
|
108
|
+
import { TypedContext } from 'flowcraft'
|
|
109
|
+
|
|
110
|
+
describe('AddTenNode', () => {
|
|
111
|
+
it('should read from, and write to, the context correctly', async () => {
|
|
112
|
+
const node = new AddTenNode()
|
|
113
|
+
const context = new TypedContext([
|
|
114
|
+
[INPUT, 5]
|
|
115
|
+
])
|
|
116
|
+
|
|
117
|
+
await node.run(context)
|
|
118
|
+
|
|
119
|
+
expect(context.get(OUTPUT)).toBe(15)
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Testing Flows
|
|
125
|
+
|
|
126
|
+
When testing a `Flow`, you are performing an integration test to ensure that all the nodes work together correctly and that the `Context` state evolves as expected.
|
|
127
|
+
|
|
128
|
+
### Strategy: Run the Flow and Assert Final Context State
|
|
129
|
+
|
|
130
|
+
The approach is similar to testing a single node with `.run()`, but you do it for the entire `Flow`.
|
|
131
|
+
|
|
132
|
+
```typescript
|
|
133
|
+
import { contextKey, SequenceFlow, TypedContext } from 'flowcraft'
|
|
134
|
+
|
|
135
|
+
const INITIAL_DATA = contextKey<string>('initial_data')
|
|
136
|
+
const FINAL_RESULT = contextKey<string>('final_result')
|
|
137
|
+
const SOME_INTERMEDIATE_VALUE = contextKey<any>('intermediate')
|
|
138
|
+
|
|
139
|
+
describe('DataProcessingFlow', () => {
|
|
140
|
+
it('should run the full sequence and produce the correct final output', async () => {
|
|
141
|
+
// Assume NodeA, NodeB, NodeC are defined elsewhere
|
|
142
|
+
const flow = new SequenceFlow(new NodeA(), new NodeB(), new NodeC())
|
|
143
|
+
const context = new TypedContext([
|
|
144
|
+
[INITIAL_DATA, 'start']
|
|
145
|
+
])
|
|
146
|
+
|
|
147
|
+
await flow.run(context)
|
|
148
|
+
|
|
149
|
+
// Assert the final state of the context after the whole flow has run
|
|
150
|
+
expect(context.get(FINAL_RESULT)).toBe('expected-final-value')
|
|
151
|
+
expect(context.get(SOME_INTERMEDIATE_VALUE)).toBeUndefined() // Verify cleanup if applicable
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### Testing Branching Logic
|
|
157
|
+
|
|
158
|
+
To test conditional branching, create separate tests for each path. In each test, set up the `Context` in a way that forces the flow to take the specific branch you want to verify.
|
|
159
|
+
|
|
160
|
+
```typescript
|
|
161
|
+
import { contextKey } from 'flowcraft'
|
|
162
|
+
|
|
163
|
+
const IS_VALID = contextKey<boolean>('is_valid')
|
|
164
|
+
const PATH_TAKEN = contextKey<string>('path_taken')
|
|
165
|
+
|
|
166
|
+
it('should take the "success" path when data is valid', async () => {
|
|
167
|
+
const flow = createMyConditionalFlow()
|
|
168
|
+
const context = new TypedContext([
|
|
169
|
+
[IS_VALID, true] // Force the success path
|
|
170
|
+
])
|
|
171
|
+
|
|
172
|
+
await flow.run(context)
|
|
173
|
+
|
|
174
|
+
expect(context.get(PATH_TAKEN)).toBe('success-path')
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('should take the "error" path when data is invalid', async () => {
|
|
178
|
+
const flow = createMyConditionalFlow()
|
|
179
|
+
const context = new TypedContext([
|
|
180
|
+
[IS_VALID, false] // Force the error path
|
|
181
|
+
])
|
|
182
|
+
|
|
183
|
+
await flow.run(context)
|
|
184
|
+
|
|
185
|
+
expect(context.get(PATH_TAKEN)).toBe('error-path')
|
|
186
|
+
})
|
|
187
|
+
```
|