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,93 @@
|
|
|
1
|
+
import type { MiddlewareNext, NodeArgs } from '../workflow'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { contextKey, Flow, Node, TypedContext } from '../workflow'
|
|
4
|
+
import { InMemoryExecutor } from './in-memory'
|
|
5
|
+
|
|
6
|
+
const VALUE = contextKey<number>('value')
|
|
7
|
+
const PATH = contextKey<string[]>('path')
|
|
8
|
+
const MIDDLEWARE_PATH = contextKey<string[]>('middleware_path')
|
|
9
|
+
|
|
10
|
+
// Helper Nodes for testing
|
|
11
|
+
class AddNode extends Node {
|
|
12
|
+
constructor(private num: number, public id: string) { super() }
|
|
13
|
+
async exec({ ctx }: any) {
|
|
14
|
+
const current = ctx.get(VALUE) ?? 0
|
|
15
|
+
ctx.set(VALUE, current + this.num)
|
|
16
|
+
const path = ctx.get(PATH) ?? []
|
|
17
|
+
ctx.set(PATH, [...path, this.id])
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
class BranchNode extends Node {
|
|
22
|
+
async post({ ctx }: any) {
|
|
23
|
+
const path = ctx.get(PATH) ?? []
|
|
24
|
+
ctx.set(PATH, [...path, 'branch'])
|
|
25
|
+
return ctx.get(VALUE) > 10 ? 'over' : 'under'
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('testInMemoryExecutor', () => {
|
|
30
|
+
it('should execute a simple linear flow', async () => {
|
|
31
|
+
const ctx = new TypedContext([[VALUE, 1]])
|
|
32
|
+
const startNode = new AddNode(5, 'start')
|
|
33
|
+
startNode.next(new AddNode(3, 'next'))
|
|
34
|
+
const flow = new Flow(startNode)
|
|
35
|
+
const executor = new InMemoryExecutor()
|
|
36
|
+
|
|
37
|
+
await executor.run(flow, ctx)
|
|
38
|
+
|
|
39
|
+
expect(ctx.get(VALUE)).toBe(9)
|
|
40
|
+
expect(ctx.get(PATH)).toEqual(['start', 'next'])
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('should handle conditional branching', async () => {
|
|
44
|
+
const ctx = new TypedContext([[VALUE, 5]])
|
|
45
|
+
const start = new AddNode(6, 'start') // value becomes 11
|
|
46
|
+
const branch = new BranchNode()
|
|
47
|
+
const overNode = new AddNode(100, 'over_node')
|
|
48
|
+
const underNode = new AddNode(-1, 'under_node')
|
|
49
|
+
start.next(branch)
|
|
50
|
+
branch.next(overNode, 'over')
|
|
51
|
+
branch.next(underNode, 'under')
|
|
52
|
+
|
|
53
|
+
const flow = new Flow(start)
|
|
54
|
+
const executor = new InMemoryExecutor()
|
|
55
|
+
|
|
56
|
+
await executor.run(flow, ctx)
|
|
57
|
+
|
|
58
|
+
expect(ctx.get(VALUE)).toBe(111) // 5 + 6 + 100
|
|
59
|
+
expect(ctx.get(PATH)).toEqual(['start', 'branch', 'over_node'])
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('should correctly execute a composed flow (sub-flow)', async () => {
|
|
63
|
+
const ctx = new TypedContext([[VALUE, 1]])
|
|
64
|
+
const innerStart = new AddNode(5, 'inner_start') // 1 + 5 = 6
|
|
65
|
+
innerStart.next(new AddNode(10, 'inner_end')) // 6 + 10 = 16
|
|
66
|
+
const innerFlow = new Flow(innerStart)
|
|
67
|
+
const outerStart = new AddNode(100, 'outer_start') // 16 + 100 = 116
|
|
68
|
+
const outerFlow = new Flow(innerFlow)
|
|
69
|
+
innerFlow.next(outerStart)
|
|
70
|
+
const executor = new InMemoryExecutor()
|
|
71
|
+
await executor.run(outerFlow, ctx)
|
|
72
|
+
expect(ctx.get(VALUE)).toBe(116)
|
|
73
|
+
expect(ctx.get(PATH)).toEqual(['inner_start', 'inner_end', 'outer_start'])
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('should apply middleware from the orchestrating flow to its nodes', async () => {
|
|
77
|
+
const ctx = new TypedContext()
|
|
78
|
+
const flow = new Flow(new AddNode(10, 'node1'))
|
|
79
|
+
const testMiddleware = async (args: NodeArgs, next: MiddlewareNext) => {
|
|
80
|
+
const path = args.ctx.get(MIDDLEWARE_PATH) ?? []
|
|
81
|
+
args.ctx.set(MIDDLEWARE_PATH, [...path, `enter_${args.name}`])
|
|
82
|
+
const result = await next(args)
|
|
83
|
+
const finalPath = args.ctx.get(MIDDLEWARE_PATH) ?? []
|
|
84
|
+
args.ctx.set(MIDDLEWARE_PATH, [...finalPath, `exit_${args.name}`])
|
|
85
|
+
return result
|
|
86
|
+
}
|
|
87
|
+
flow.use(testMiddleware)
|
|
88
|
+
const executor = new InMemoryExecutor()
|
|
89
|
+
await executor.run(flow, ctx)
|
|
90
|
+
expect(ctx.get(VALUE)).toBe(10)
|
|
91
|
+
expect(ctx.get(MIDDLEWARE_PATH)).toEqual(['enter_AddNode', 'exit_AddNode'])
|
|
92
|
+
})
|
|
93
|
+
})
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import type { IExecutor, InternalRunOptions } from '../executor'
|
|
2
|
+
import type { AbstractNode, Context, Flow, Logger, Middleware, MiddlewareNext, NodeArgs, RunOptions } from '../workflow'
|
|
3
|
+
import { AbortError, NullLogger } from '../workflow'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The default executor that runs a workflow within a single, in-memory process.
|
|
7
|
+
* This class contains the core logic for traversing a workflow graph, applying middleware,
|
|
8
|
+
* and handling node execution.
|
|
9
|
+
*/
|
|
10
|
+
export class InMemoryExecutor implements IExecutor {
|
|
11
|
+
/**
|
|
12
|
+
* A stateless, reusable method that orchestrates the traversal of a graph.
|
|
13
|
+
* It is called by `run()` for top-level flows and by `Flow.exec()` for sub-flows.
|
|
14
|
+
* @param startNode The node where the graph traversal begins.
|
|
15
|
+
* @param flowMiddleware The middleware array from the containing flow.
|
|
16
|
+
* @param context The shared workflow context.
|
|
17
|
+
* @param options The internal, normalized run options.
|
|
18
|
+
* @returns The final action from the last executed node in the graph.
|
|
19
|
+
* @internal
|
|
20
|
+
*/
|
|
21
|
+
public async _orch(
|
|
22
|
+
startNode: AbstractNode,
|
|
23
|
+
flowMiddleware: Middleware[],
|
|
24
|
+
context: Context,
|
|
25
|
+
options: InternalRunOptions,
|
|
26
|
+
): Promise<any> {
|
|
27
|
+
let currentNode: AbstractNode | undefined = startNode
|
|
28
|
+
let lastAction: any
|
|
29
|
+
|
|
30
|
+
const { logger, signal, params, executor } = options
|
|
31
|
+
|
|
32
|
+
while (currentNode) {
|
|
33
|
+
if (signal?.aborted)
|
|
34
|
+
throw new AbortError()
|
|
35
|
+
|
|
36
|
+
const chain = this.applyMiddleware(flowMiddleware, currentNode)
|
|
37
|
+
|
|
38
|
+
const nodeArgs: NodeArgs = {
|
|
39
|
+
ctx: context,
|
|
40
|
+
params,
|
|
41
|
+
signal,
|
|
42
|
+
logger,
|
|
43
|
+
prepRes: undefined,
|
|
44
|
+
execRes: undefined,
|
|
45
|
+
name: currentNode.constructor.name,
|
|
46
|
+
executor,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
lastAction = await chain(nodeArgs)
|
|
50
|
+
|
|
51
|
+
const previousNode = currentNode
|
|
52
|
+
currentNode = this.getNextNode(previousNode, lastAction, logger)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return lastAction
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Executes a given flow with a specific context and options.
|
|
60
|
+
* This is the main entry point for the in-memory execution engine.
|
|
61
|
+
* @param flow The Flow instance to execute.
|
|
62
|
+
* @param context The shared context for the workflow.
|
|
63
|
+
* @param options Runtime options, including a logger, abort controller, or initial params.
|
|
64
|
+
* @returns A promise that resolves with the final action of the workflow.
|
|
65
|
+
*/
|
|
66
|
+
public async run(flow: Flow, context: Context, options?: RunOptions): Promise<any> {
|
|
67
|
+
const logger = options?.logger ?? new NullLogger()
|
|
68
|
+
const controller = options?.controller
|
|
69
|
+
const combinedParams = { ...flow.params, ...options?.params }
|
|
70
|
+
|
|
71
|
+
const internalOptions: InternalRunOptions = {
|
|
72
|
+
logger,
|
|
73
|
+
signal: controller?.signal,
|
|
74
|
+
params: combinedParams,
|
|
75
|
+
executor: this,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Handle "logic-bearing" flows (e.g., BatchFlow) that don't have a graph.
|
|
79
|
+
// Their logic is self-contained in their `exec` method.
|
|
80
|
+
if (!flow.startNode) {
|
|
81
|
+
logger.info(`Executor is running a logic-bearing flow: ${flow.constructor.name}`)
|
|
82
|
+
const chain = this.applyMiddleware(flow.middleware, flow)
|
|
83
|
+
return await chain({
|
|
84
|
+
...internalOptions,
|
|
85
|
+
ctx: context,
|
|
86
|
+
prepRes: undefined,
|
|
87
|
+
execRes: undefined,
|
|
88
|
+
name: flow.constructor.name,
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
logger.info(`Executor is running flow graph: ${flow.constructor.name}`)
|
|
93
|
+
// Delegate the graph traversal to our new stateless helper.
|
|
94
|
+
// Pass the flow's own middleware to be applied to its nodes.
|
|
95
|
+
return this._orch(flow.startNode, flow.middleware, context, internalOptions)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Determines the next node to execute based on the action returned by the current node.
|
|
100
|
+
* @internal
|
|
101
|
+
*/
|
|
102
|
+
public getNextNode(curr: AbstractNode, action: any, logger: Logger): AbstractNode | undefined {
|
|
103
|
+
const nextNode = curr.successors.get(action)
|
|
104
|
+
const actionDisplay = typeof action === 'symbol' ? action.toString() : action
|
|
105
|
+
|
|
106
|
+
if (nextNode) {
|
|
107
|
+
logger.debug(`Action '${actionDisplay}' from ${curr.constructor.name} leads to ${nextNode.constructor.name}`, { action })
|
|
108
|
+
}
|
|
109
|
+
else if (curr.successors.size > 0 && action !== undefined && action !== null) {
|
|
110
|
+
logger.info(`Flow ends: Action '${actionDisplay}' from ${curr.constructor.name} has no configured successor.`)
|
|
111
|
+
}
|
|
112
|
+
return nextNode
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Composes a chain of middleware functions around a node's execution.
|
|
117
|
+
* @internal
|
|
118
|
+
*/
|
|
119
|
+
public applyMiddleware(middleware: Middleware[], nodeToRun: AbstractNode): MiddlewareNext {
|
|
120
|
+
// The final function in the chain is the actual execution of the node.
|
|
121
|
+
const runNode: MiddlewareNext = (args: NodeArgs) => {
|
|
122
|
+
return nodeToRun._run({
|
|
123
|
+
ctx: args.ctx,
|
|
124
|
+
params: { ...args.params, ...nodeToRun.params },
|
|
125
|
+
signal: args.signal,
|
|
126
|
+
logger: args.logger,
|
|
127
|
+
executor: args.executor,
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (!middleware || middleware.length === 0)
|
|
132
|
+
return runNode
|
|
133
|
+
|
|
134
|
+
// Build the chain backwards, so the first middleware in the array is the outermost.
|
|
135
|
+
return middleware.reduceRight<MiddlewareNext>(
|
|
136
|
+
(next, mw) => (args: NodeArgs) => mw(args, next),
|
|
137
|
+
runNode,
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import type { Logger, RunOptions } from './workflow'
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import {
|
|
4
|
+
compose,
|
|
5
|
+
contextNode,
|
|
6
|
+
mapNode,
|
|
7
|
+
pipeline,
|
|
8
|
+
transformNode,
|
|
9
|
+
} from './functions'
|
|
10
|
+
import { contextKey, DEFAULT_ACTION, lens, TypedContext } from './workflow'
|
|
11
|
+
|
|
12
|
+
// Mock logger for testing
|
|
13
|
+
function createMockLogger(): Logger {
|
|
14
|
+
return {
|
|
15
|
+
debug: vi.fn(),
|
|
16
|
+
info: vi.fn(),
|
|
17
|
+
warn: vi.fn(),
|
|
18
|
+
error: vi.fn(),
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let mockLogger = createMockLogger()
|
|
23
|
+
let runOptions: RunOptions = { logger: mockLogger }
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
mockLogger = createMockLogger()
|
|
27
|
+
runOptions = { logger: mockLogger }
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
// Context Keys for testing
|
|
31
|
+
const NAME = contextKey<string>('name')
|
|
32
|
+
const COUNTER = contextKey<number>('counter')
|
|
33
|
+
const RESULT = contextKey<any>('result')
|
|
34
|
+
const PREFIX = contextKey<string>('prefix')
|
|
35
|
+
|
|
36
|
+
describe('mapNode', () => {
|
|
37
|
+
it('should create a node from a synchronous function', async () => {
|
|
38
|
+
const ctx = new TypedContext()
|
|
39
|
+
const doubleNode = mapNode<{ value: number }, number>(params => params.value * 2)
|
|
40
|
+
.toContext(RESULT)
|
|
41
|
+
await doubleNode.withParams({ value: 10 }).run(ctx, runOptions)
|
|
42
|
+
expect(ctx.get(RESULT)).toBe(20)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('should create a node from an asynchronous function', async () => {
|
|
46
|
+
const ctx = new TypedContext()
|
|
47
|
+
const upperNode = mapNode<{ value: string }, string>(async params => params.value.toUpperCase())
|
|
48
|
+
.toContext(RESULT)
|
|
49
|
+
await upperNode.withParams({ value: 'hello' }).run(ctx, runOptions)
|
|
50
|
+
expect(ctx.get(RESULT)).toBe('HELLO')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('should handle nodes that produce no output', async () => {
|
|
54
|
+
const ctx = new TypedContext()
|
|
55
|
+
const sideEffectFn = vi.fn()
|
|
56
|
+
const tapNode = mapNode<any, void>(params => sideEffectFn(params.value))
|
|
57
|
+
await tapNode.withParams({ value: 123 }).run(ctx, runOptions)
|
|
58
|
+
expect(sideEffectFn).toHaveBeenCalledWith(123)
|
|
59
|
+
const action = await tapNode.withParams({ value: 456 }).run(ctx, runOptions)
|
|
60
|
+
expect(Array.from(ctx.entries())).toHaveLength(0)
|
|
61
|
+
expect(action).toBe(DEFAULT_ACTION)
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
describe('contextNode', () => {
|
|
66
|
+
it('should access context and params in a synchronous function', async () => {
|
|
67
|
+
const ctx = new TypedContext([[PREFIX, 'Hello']])
|
|
68
|
+
const greeterNode = contextNode<{ name: string }, string>((ctx, params) => {
|
|
69
|
+
const prefix = ctx.get(PREFIX) ?? 'Default'
|
|
70
|
+
return `${prefix}, ${params.name}!`
|
|
71
|
+
}).toContext(RESULT)
|
|
72
|
+
await greeterNode.withParams({ name: 'World' }).run(ctx, runOptions)
|
|
73
|
+
expect(ctx.get(RESULT)).toBe('Hello, World!')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('should access context and params in an asynchronous function', async () => {
|
|
77
|
+
const ctx = new TypedContext([[PREFIX, 'Hola']])
|
|
78
|
+
const greeterNode = contextNode<{ name: string }, string>(async (ctx, params) => {
|
|
79
|
+
const prefix = ctx.get(PREFIX)
|
|
80
|
+
await new Promise(resolve => setTimeout(resolve, 1))
|
|
81
|
+
return `${prefix}, ${params.name}!`
|
|
82
|
+
}).toContext(RESULT)
|
|
83
|
+
await greeterNode.withParams({ name: 'Mundo' }).run(ctx, runOptions)
|
|
84
|
+
expect(ctx.get(RESULT)).toBe('Hola, Mundo!')
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
describe('transformNode', () => {
|
|
89
|
+
it('should apply a single context transform', async () => {
|
|
90
|
+
const ctx = new TypedContext()
|
|
91
|
+
const nameLens = lens(NAME)
|
|
92
|
+
const setNode = transformNode(nameLens.set('Alice'))
|
|
93
|
+
await setNode.run(ctx, runOptions)
|
|
94
|
+
expect(ctx.get(NAME)).toBe('Alice')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('should apply multiple context transforms in order', async () => {
|
|
98
|
+
const ctx = new TypedContext([[COUNTER, 10]])
|
|
99
|
+
const nameLens = lens(NAME)
|
|
100
|
+
const counterLens = lens(COUNTER)
|
|
101
|
+
const setupNode = transformNode(
|
|
102
|
+
nameLens.set('Bob'),
|
|
103
|
+
counterLens.update(c => (c ?? 0) + 5),
|
|
104
|
+
)
|
|
105
|
+
await setupNode.run(ctx, runOptions)
|
|
106
|
+
expect(ctx.get(NAME)).toBe('Bob')
|
|
107
|
+
expect(ctx.get(COUNTER)).toBe(15)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('should be chainable within a pipeline', async () => {
|
|
111
|
+
const ctx = new TypedContext([[COUNTER, 0]])
|
|
112
|
+
const nameLens = lens(NAME)
|
|
113
|
+
const counterLens = lens(COUNTER)
|
|
114
|
+
// A node that reads from context
|
|
115
|
+
const readNode = contextNode((ctx) => {
|
|
116
|
+
return `${ctx.get(NAME)} has ${ctx.get(COUNTER)} points`
|
|
117
|
+
}).toContext(RESULT)
|
|
118
|
+
const flow = pipeline(
|
|
119
|
+
transformNode(
|
|
120
|
+
nameLens.set('Carol'),
|
|
121
|
+
counterLens.set(100),
|
|
122
|
+
),
|
|
123
|
+
readNode,
|
|
124
|
+
)
|
|
125
|
+
await flow.run(ctx, runOptions)
|
|
126
|
+
expect(ctx.get(RESULT)).toBe('Carol has 100 points')
|
|
127
|
+
})
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
describe('pipeline', () => {
|
|
131
|
+
it('should create and run a linear sequence of nodes', async () => {
|
|
132
|
+
const ctx = new TypedContext([[COUNTER, 5]])
|
|
133
|
+
const add10 = mapNode(() => 10).toContext(COUNTER)
|
|
134
|
+
const multiplyBy3 = contextNode(ctx => (ctx.get(COUNTER) ?? 0) * 3).toContext(COUNTER)
|
|
135
|
+
const flow = pipeline(add10, multiplyBy3)
|
|
136
|
+
await flow.run(ctx, runOptions)
|
|
137
|
+
expect(ctx.get(COUNTER)).toBe(30)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('should run successfully with an empty sequence', async () => {
|
|
141
|
+
const ctx = new TypedContext()
|
|
142
|
+
const emptyFlow = pipeline()
|
|
143
|
+
const action = await emptyFlow.run(ctx, runOptions)
|
|
144
|
+
expect(Array.from(ctx.entries())).toHaveLength(0)
|
|
145
|
+
expect(action).toBe(DEFAULT_ACTION)
|
|
146
|
+
})
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
describe('compose', () => {
|
|
150
|
+
it('should compose two synchronous functions', async () => {
|
|
151
|
+
const add5 = (x: number) => x + 5
|
|
152
|
+
const multiply2 = (x: number) => x * 2
|
|
153
|
+
// multiply2(add5(x)) => (x + 5) * 2
|
|
154
|
+
const addThenMultiply = compose(multiply2, add5)
|
|
155
|
+
expect(await addThenMultiply(10)).toBe(30)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('should compose two asynchronous functions', async () => {
|
|
159
|
+
const add5Async = async (x: number) => {
|
|
160
|
+
await new Promise(resolve => setTimeout(resolve, 1))
|
|
161
|
+
return x + 5
|
|
162
|
+
}
|
|
163
|
+
const multiply2Async = async (x: number) => {
|
|
164
|
+
await new Promise(resolve => setTimeout(resolve, 1))
|
|
165
|
+
return x * 2
|
|
166
|
+
}
|
|
167
|
+
const composed = compose(multiply2Async, add5Async)
|
|
168
|
+
expect(await composed(10)).toBe(30)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('should compose mixed sync and async functions', async () => {
|
|
172
|
+
const add5 = (x: number) => x + 5
|
|
173
|
+
const multiply2Async = async (x: number) => x * 2
|
|
174
|
+
const composed1 = compose(multiply2Async, add5) // async(sync(x))
|
|
175
|
+
const composed2 = compose(add5, multiply2Async) // sync(async(x))
|
|
176
|
+
expect(await composed1(10)).toBe(30)
|
|
177
|
+
expect(await composed2(10)).toBe(25)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('should be usable inside a mapNode', async () => {
|
|
181
|
+
const ctx = new TypedContext()
|
|
182
|
+
const add5 = (p: { val: number }) => ({ ...p, val: p.val + 5 })
|
|
183
|
+
const multiply2 = (p: { val: number }) => ({ ...p, val: p.val * 2 })
|
|
184
|
+
const composedFn = compose(multiply2, add5)
|
|
185
|
+
const processNode = mapNode(composedFn)
|
|
186
|
+
.map(res => res.val)
|
|
187
|
+
.toContext(RESULT)
|
|
188
|
+
await processNode.withParams({ val: 10 }).run(ctx, runOptions)
|
|
189
|
+
expect(ctx.get(RESULT)).toBe(30) // (10 + 5) * 2
|
|
190
|
+
})
|
|
191
|
+
})
|
package/src/functions.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { ContextTransform } from './context'
|
|
2
|
+
import type { Context, Flow, Node, NodeArgs } from './workflow'
|
|
3
|
+
import { SequenceFlow } from './builder/collection'
|
|
4
|
+
import { composeContext } from './context'
|
|
5
|
+
import { Node as BaseNode } from './workflow'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A type for a pure function that can be executed within a `Node`,
|
|
9
|
+
* typically taking the node's `params` as input.
|
|
10
|
+
* @template TIn The input type, corresponding to `params`.
|
|
11
|
+
* @template TOut The output type, which becomes the node's `execRes`.
|
|
12
|
+
*/
|
|
13
|
+
export type NodeFunction<TIn = any, TOut = any> = (input: TIn) => TOut | Promise<TOut>
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A type for a function that operates on the shared `Context` in addition
|
|
17
|
+
* to the node's `params`.
|
|
18
|
+
* @template TIn The input type, corresponding to `params`.
|
|
19
|
+
* @template TOut The output type, which becomes the node's `execRes`.
|
|
20
|
+
*/
|
|
21
|
+
export type ContextFunction<TIn = any, TOut = any> = (ctx: Context, input: TIn) => TOut | Promise<TOut>
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Creates a `Node` from a simple, pure function that transforms an input to an output.
|
|
25
|
+
* The node's `params` object is passed as the input to the function.
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* const add = (n: number) => mapNode<{ value: number }, number>(params => params.value + n)
|
|
29
|
+
* const add5Node = add(5) // A reusable node that adds 5 to its input parameter.
|
|
30
|
+
*
|
|
31
|
+
* @param fn A function that takes an input object and returns a result.
|
|
32
|
+
* @returns A new `Node` instance that wraps the function.
|
|
33
|
+
*/
|
|
34
|
+
export function mapNode<TIn, TOut>(fn: NodeFunction<TIn, TOut>): Node<TIn, TOut> {
|
|
35
|
+
return new class extends BaseNode<TIn, TOut> {
|
|
36
|
+
async exec({ params }: NodeArgs<TIn>): Promise<TOut> {
|
|
37
|
+
return fn(params as TIn)
|
|
38
|
+
}
|
|
39
|
+
}()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Creates a `Node` from a function that requires access to the shared `Context`.
|
|
44
|
+
* Both the `Context` and the node's `params` are passed as arguments to the function.
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* const greeter = contextNode((ctx, params: { name: string }) => {
|
|
48
|
+
* const language = ctx.get(LANGUAGE_KEY) || 'en'
|
|
49
|
+
* return language === 'en' ? `Hello, ${params.name}` : `Hola, ${params.name}`
|
|
50
|
+
* })
|
|
51
|
+
*
|
|
52
|
+
* @param fn A function that takes the context and an input object, and returns a result.
|
|
53
|
+
* @returns A new `Node` instance that wraps the function.
|
|
54
|
+
*/
|
|
55
|
+
export function contextNode<TIn, TOut>(fn: ContextFunction<TIn, TOut>): Node<TIn, TOut> {
|
|
56
|
+
return new class extends BaseNode<TIn, TOut> {
|
|
57
|
+
async exec({ ctx, params }: NodeArgs<TIn>): Promise<TOut> {
|
|
58
|
+
return fn(ctx, params as TIn)
|
|
59
|
+
}
|
|
60
|
+
}()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Creates a `Node` that declaratively applies a series of transformations to the `Context`.
|
|
65
|
+
* This is a "side-effect" node used purely for state management; its logic runs in the `prep` phase,
|
|
66
|
+
* and it does not produce an `exec` output.
|
|
67
|
+
*
|
|
68
|
+
* @example
|
|
69
|
+
* const USER_ID = contextKey<string>('user_id')
|
|
70
|
+
* const userLens = lens(USER_ID)
|
|
71
|
+
* const setupUserContext = (userId: string) => transformNode(userLens.set(userId))
|
|
72
|
+
*
|
|
73
|
+
* @param transforms A sequence of `ContextTransform` functions (e.g., from a lens) to apply.
|
|
74
|
+
* @returns A new `Node` instance that will mutate the context when executed.
|
|
75
|
+
*/
|
|
76
|
+
export function transformNode(...transforms: ContextTransform[]): Node {
|
|
77
|
+
return new class extends BaseNode {
|
|
78
|
+
async prep({ ctx }: NodeArgs) {
|
|
79
|
+
// Apply the composed transformations directly to the mutable context.
|
|
80
|
+
composeContext(...transforms)(ctx)
|
|
81
|
+
}
|
|
82
|
+
}()
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* A functional-style alias for `SequenceFlow`. It constructs a linear workflow
|
|
87
|
+
* where each node executes in the order it is provided.
|
|
88
|
+
*
|
|
89
|
+
* @example
|
|
90
|
+
* const mathPipeline = pipeline(addNode(5), multiplyNode(2))
|
|
91
|
+
*
|
|
92
|
+
* @param nodes A sequence of `Node` instances to chain together.
|
|
93
|
+
* @returns A `Flow` instance representing the linear sequence.
|
|
94
|
+
*/
|
|
95
|
+
export function pipeline(...nodes: Node[]): Flow {
|
|
96
|
+
return new SequenceFlow(...nodes)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* A classic functional composition utility. It takes two functions, `f` and `g`,
|
|
101
|
+
* and returns a new function that computes `f(g(x))`.
|
|
102
|
+
*
|
|
103
|
+
* This is a general-purpose helper, not a `Node` builder itself, but it can be
|
|
104
|
+
* used to create more complex `NodeFunction`s to pass to `mapNode`.
|
|
105
|
+
*
|
|
106
|
+
* @example
|
|
107
|
+
* const add5 = (x: number) => x + 5
|
|
108
|
+
* const multiply2 = (x: number) => x * 2
|
|
109
|
+
* const add5ThenMultiply2 = compose(multiply2, add5) // equivalent to: x => (x + 5) * 2
|
|
110
|
+
*
|
|
111
|
+
* @param f The outer function, which receives the result of `g`.
|
|
112
|
+
* @param g The inner function, which receives the initial input.
|
|
113
|
+
* @returns A new `NodeFunction` that combines both operations.
|
|
114
|
+
*/
|
|
115
|
+
export function compose<A, B, C>(f: NodeFunction<B, C>, g: NodeFunction<A, B>): NodeFunction<A, C> {
|
|
116
|
+
return async (input: A) => f(await g(input))
|
|
117
|
+
}
|
package/src/index.ts
ADDED
package/src/logger.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Defines the interface for a logger that can be used by the workflow engine.
|
|
3
|
+
* This allows for plugging in any logging library (e.g., Pino, Winston).
|
|
4
|
+
*/
|
|
5
|
+
export interface Logger {
|
|
6
|
+
debug: (message: string, context?: object) => void
|
|
7
|
+
info: (message: string, context?: object) => void
|
|
8
|
+
warn: (message: string, context?: object) => void
|
|
9
|
+
error: (message: string, context?: object) => void
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A logger implementation that performs no action (a "no-op" logger).
|
|
14
|
+
* This is the default logger used by the framework if none is provided,
|
|
15
|
+
* making Flowcraft silent out-of-the-box.
|
|
16
|
+
*/
|
|
17
|
+
export class NullLogger implements Logger {
|
|
18
|
+
debug() { /* no-op */ }
|
|
19
|
+
info() { /* no-op */ }
|
|
20
|
+
warn() { /* no-op */ }
|
|
21
|
+
error() { /* no-op */ }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A default logger implementation that writes messages to the `console`.
|
|
26
|
+
* Useful for development and debugging.
|
|
27
|
+
*/
|
|
28
|
+
export class ConsoleLogger implements Logger {
|
|
29
|
+
private log(level: 'debug' | 'info' | 'warn' | 'error', message: string, context?: object) {
|
|
30
|
+
const fullMessage = `[${level.toUpperCase()}] ${message}`
|
|
31
|
+
if (context && Object.keys(context).length > 0)
|
|
32
|
+
console[level](fullMessage, context)
|
|
33
|
+
else
|
|
34
|
+
console[level](fullMessage)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
debug(message: string, context?: object) { this.log('debug', message, context) }
|
|
38
|
+
info(message: string, context?: object) { this.log('info', message, context) }
|
|
39
|
+
warn(message: string, context?: object) { this.log('warn', message, context) }
|
|
40
|
+
error(message: string, context?: object) { this.log('error', message, context) }
|
|
41
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { Context } from './context'
|
|
2
|
+
import type { IExecutor } from './executor'
|
|
3
|
+
import type { Logger } from './logger'
|
|
4
|
+
|
|
5
|
+
/** A generic type for key-value parameters. */
|
|
6
|
+
export type Params = Record<string, any>
|
|
7
|
+
|
|
8
|
+
/** The default action returned by a node for linear progression. */
|
|
9
|
+
export const DEFAULT_ACTION = Symbol('default')
|
|
10
|
+
|
|
11
|
+
/** The action returned by a `.filter()` node when the predicate fails. */
|
|
12
|
+
export const FILTER_FAILED = Symbol('filter_failed')
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The standard arguments object passed to a node's lifecycle methods.
|
|
16
|
+
* @template PrepRes The type of the `prepRes` property.
|
|
17
|
+
* @template ExecRes The type of the `execRes` property.
|
|
18
|
+
*/
|
|
19
|
+
export interface NodeArgs<PrepRes = any, ExecRes = any> {
|
|
20
|
+
/** The shared, mutable context for the workflow run. */
|
|
21
|
+
ctx: Context
|
|
22
|
+
/** The static parameters for the node, merged from the node and flow's `withParams`. */
|
|
23
|
+
params: Params
|
|
24
|
+
/** An `AbortSignal` for handling cancellation. */
|
|
25
|
+
signal?: AbortSignal
|
|
26
|
+
/** The logger instance for the workflow run. */
|
|
27
|
+
logger: Logger
|
|
28
|
+
/** The result of the `prep` phase. */
|
|
29
|
+
prepRes: PrepRes
|
|
30
|
+
/** The result of the `exec` phase. */
|
|
31
|
+
execRes: ExecRes
|
|
32
|
+
/** The final error object, available only in `execFallback`. */
|
|
33
|
+
error?: Error
|
|
34
|
+
/** The name of the Node's constructor, for logging. */
|
|
35
|
+
name?: string
|
|
36
|
+
/** A reference to the current `IExecutor` running the flow. */
|
|
37
|
+
executor?: IExecutor
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The context object passed to a node's internal `_run` method.
|
|
42
|
+
* @internal
|
|
43
|
+
*/
|
|
44
|
+
export interface NodeRunContext {
|
|
45
|
+
ctx: Context
|
|
46
|
+
params: Params
|
|
47
|
+
signal?: AbortSignal
|
|
48
|
+
logger: Logger
|
|
49
|
+
executor?: IExecutor
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Options for configuring a `Node` instance. */
|
|
53
|
+
export interface NodeOptions {
|
|
54
|
+
/** The total number of times the `exec` phase will be attempted. Defaults to `1`. */
|
|
55
|
+
maxRetries?: number
|
|
56
|
+
/** The time in milliseconds to wait between failed `exec` attempts. Defaults to `0`. */
|
|
57
|
+
wait?: number
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Options for running a top-level `Flow`. */
|
|
61
|
+
export interface RunOptions {
|
|
62
|
+
/** An `AbortController` to gracefully cancel the workflow. */
|
|
63
|
+
controller?: AbortController
|
|
64
|
+
/** A `Logger` instance to receive logs from the execution engine. */
|
|
65
|
+
logger?: Logger
|
|
66
|
+
/** Top-level parameters to be merged into the context for the entire run. */
|
|
67
|
+
params?: Params
|
|
68
|
+
/** A custom `IExecutor` instance to run the workflow. Defaults to `InMemoryExecutor`. */
|
|
69
|
+
executor?: IExecutor
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The function signature for the `next` function passed to middleware. */
|
|
73
|
+
export type MiddlewareNext = (args: NodeArgs) => Promise<any>
|
|
74
|
+
/** The function signature for a middleware function. */
|
|
75
|
+
export type Middleware = (args: NodeArgs, next: MiddlewareNext) => Promise<any>
|