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,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"nodes": [
|
|
3
|
+
{
|
|
4
|
+
"id": "api_call_ban_user",
|
|
5
|
+
"type": "llm-process",
|
|
6
|
+
"data": {
|
|
7
|
+
"promptTemplate": "Simulate a database update to ban a user. Respond with a simulated SQL command.\n\nUPDATE users SET status='banned' WHERE id={{userId}};",
|
|
8
|
+
"inputs": {
|
|
9
|
+
"userId": "userId"
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"id": "log_ban_event",
|
|
15
|
+
"type": "llm-process",
|
|
16
|
+
"data": {
|
|
17
|
+
"promptTemplate": "Simulate writing to a security log. Respond with a simulated log entry.\n\nEVENT: UserBanned\nUSER_ID: {{userId}}\nREASON: {{reason}}\nSEVERITY: critical",
|
|
18
|
+
"inputs": {
|
|
19
|
+
"userId": "userId",
|
|
20
|
+
"reason": "reason"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"id": "output_ban_summary",
|
|
26
|
+
"type": "output",
|
|
27
|
+
"data": {
|
|
28
|
+
"outputKey": "ban_summary",
|
|
29
|
+
"promptTemplate": "User Banned Successfully.\n--- DB Action ---\n{{db_update}}\n\n--- Log Entry ---\n{{log_entry}}",
|
|
30
|
+
"inputs": {
|
|
31
|
+
"db_update": "api_call_ban_user",
|
|
32
|
+
"log_entry": "log_ban_event"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
],
|
|
37
|
+
"edges": [
|
|
38
|
+
{
|
|
39
|
+
"source": "api_call_ban_user",
|
|
40
|
+
"target": "output_ban_summary"
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"source": "log_ban_event",
|
|
44
|
+
"target": "output_ban_summary"
|
|
45
|
+
}
|
|
46
|
+
]
|
|
47
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"nodes": [
|
|
3
|
+
{
|
|
4
|
+
"id": "redact_pii",
|
|
5
|
+
"type": "llm-process",
|
|
6
|
+
"data": {
|
|
7
|
+
"promptTemplate": "Find any Personally Identifiable Information (PII) in the following text and replace it with '[REDACTED]'.\n\nOriginal Text: \"{{userPost}}\"",
|
|
8
|
+
"inputs": {
|
|
9
|
+
"userPost": "userPost"
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"id": "generate_warning",
|
|
15
|
+
"type": "llm-process",
|
|
16
|
+
"data": {
|
|
17
|
+
"promptTemplate": "Write a polite, templated warning message to user {{userId}} explaining that their post was edited to remove personal information and reminding them not to share PII.",
|
|
18
|
+
"inputs": {
|
|
19
|
+
"userId": "userId"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"id": "output_redaction_summary",
|
|
25
|
+
"type": "output",
|
|
26
|
+
"data": {
|
|
27
|
+
"outputKey": "redaction_summary",
|
|
28
|
+
"promptTemplate": "Post Edited for PII.\n--- Redacted Post ---\n{{redacted_post}}\n\n--- User Warning Message ---\n{{warning_message}}",
|
|
29
|
+
"inputs": {
|
|
30
|
+
"redacted_post": "redact_pii",
|
|
31
|
+
"warning_message": "generate_warning"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
],
|
|
36
|
+
"edges": [
|
|
37
|
+
{
|
|
38
|
+
"source": "redact_pii",
|
|
39
|
+
"target": "output_redaction_summary"
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"source": "generate_warning",
|
|
43
|
+
"target": "output_redaction_summary"
|
|
44
|
+
}
|
|
45
|
+
]
|
|
46
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"nodes": [
|
|
3
|
+
{
|
|
4
|
+
"id": "delete_and_flag_user",
|
|
5
|
+
"type": "llm-process",
|
|
6
|
+
"data": {
|
|
7
|
+
"promptTemplate": "Simulate the actions of deleting a spam post and flagging the user. Respond with a summary of actions taken for user ID {{userId}}.",
|
|
8
|
+
"inputs": {
|
|
9
|
+
"userId": "userId"
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"id": "output_spam_summary",
|
|
15
|
+
"type": "output",
|
|
16
|
+
"data": {
|
|
17
|
+
"outputKey": "spam_deletion_summary",
|
|
18
|
+
"promptTemplate": "{{result}}",
|
|
19
|
+
"inputs": {
|
|
20
|
+
"result": "delete_and_flag_user"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
],
|
|
25
|
+
"edges": [
|
|
26
|
+
{
|
|
27
|
+
"source": "delete_and_flag_user",
|
|
28
|
+
"target": "output_spam_summary"
|
|
29
|
+
}
|
|
30
|
+
]
|
|
31
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# Use Case 4: Content Moderation
|
|
2
|
+
|
|
3
|
+
This workflow demonstrates a sophisticated "router" pattern. It performs multiple parallel analyses on a piece of content and then uses a central LLM-powered router node to decide which of several distinct moderation actions to take.
|
|
4
|
+
|
|
5
|
+
## Main Workflow ID: 400
|
|
6
|
+
|
|
7
|
+
### Description
|
|
8
|
+
|
|
9
|
+
1. **Parallel Analysis**: The workflow begins by performing three independent checks on the `userPost` in parallel:
|
|
10
|
+
- `check_for_pii`: Looks for Personally Identifiable Information.
|
|
11
|
+
- `check_for_hate_speech`: Analyzes the severity of any hate speech.
|
|
12
|
+
- `check_for_spam`: Detects if the post is commercial spam.
|
|
13
|
+
2. **Router Node**: All three analysis results are fed into the `triage_post` node. This special `llm-router` node uses an LLM to evaluate the combined inputs against a set of rules and returns a single action string (e.g., `action_ban`, `action_redact`).
|
|
14
|
+
3. **Multi-Way Branching**: The action string from the router determines which of the four possible paths is executed.
|
|
15
|
+
- `action_ban` -> Sub-Workflow 401 (Ban User)
|
|
16
|
+
- `action_redact` -> Sub-Workflow 402 (Redact Post)
|
|
17
|
+
- `action_delete_spam` -> Sub-Workflow 403 (Delete Spam)
|
|
18
|
+
- `action_approve` -> A simple node to log the approval.
|
|
19
|
+
4. **Convergence**: The result from the chosen moderation path is captured in the `final_log` node.
|
|
20
|
+
|
|
21
|
+
### Visual Graph
|
|
22
|
+
|
|
23
|
+
```mermaid
|
|
24
|
+
graph TD
|
|
25
|
+
subgraph "Content Moderation (ID: 400)"
|
|
26
|
+
A(User Post) --> B[check_for_pii]
|
|
27
|
+
A --> C[check_for_hate_speech]
|
|
28
|
+
A --> D[check_for_spam]
|
|
29
|
+
|
|
30
|
+
B --> E{triage_post}
|
|
31
|
+
C --> E
|
|
32
|
+
D --> E
|
|
33
|
+
|
|
34
|
+
E -- action_ban --> F["Sub-Workflow: Ban User (401)"]
|
|
35
|
+
E -- action_redact --> G["Sub-Workflow: Redact Post (402)"]
|
|
36
|
+
E -- action_delete_spam --> H["Sub-Workflow: Delete Spam (403)"]
|
|
37
|
+
E -- action_approve --> I[approve_post_branch]
|
|
38
|
+
|
|
39
|
+
F --> Z[final_log]
|
|
40
|
+
G --> Z
|
|
41
|
+
H --> Z
|
|
42
|
+
I --> Z
|
|
43
|
+
end
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## Sub-Workflows
|
|
49
|
+
|
|
50
|
+
### Sub-Workflow ID: 401 (Ban User)
|
|
51
|
+
|
|
52
|
+
This workflow simulates banning a user by performing a database update and creating a security log entry in parallel.
|
|
53
|
+
|
|
54
|
+
```mermaid
|
|
55
|
+
graph TD
|
|
56
|
+
subgraph "Ban User Actions (ID: 401)"
|
|
57
|
+
A[api_call_ban_user] --> C[output_ban_summary]
|
|
58
|
+
B[log_ban_event] --> C
|
|
59
|
+
end
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Sub-Workflow ID: 402 (Redact Post)
|
|
63
|
+
|
|
64
|
+
This workflow redacts PII from the user's post and generates a warning message for the user, with both actions running in parallel.
|
|
65
|
+
|
|
66
|
+
```mermaid
|
|
67
|
+
graph TD
|
|
68
|
+
subgraph "Redact Post Actions (ID: 402)"
|
|
69
|
+
A[redact_pii] --> C[output_redaction_summary]
|
|
70
|
+
B[generate_warning] --> C
|
|
71
|
+
end
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Sub-Workflow ID: 403 (Delete Spam)
|
|
75
|
+
|
|
76
|
+
A simple, linear workflow that simulates deleting a spam post and flagging the user.
|
|
77
|
+
|
|
78
|
+
```mermaid
|
|
79
|
+
graph TD
|
|
80
|
+
subgraph "Delete Spam Action (ID: 403)"
|
|
81
|
+
A[delete_and_flag_user] --> B[output_spam_summary]
|
|
82
|
+
end
|
|
83
|
+
```
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "distributed-workflow",
|
|
3
|
+
"type": "module",
|
|
4
|
+
"scripts": {
|
|
5
|
+
"start": "tsx src/client.ts",
|
|
6
|
+
"worker": "tsx src/worker.ts"
|
|
7
|
+
},
|
|
8
|
+
"dependencies": {
|
|
9
|
+
"bullmq": "^5.10.1",
|
|
10
|
+
"dotenv": "^16.4.5",
|
|
11
|
+
"flowcraft": "workspace:*",
|
|
12
|
+
"ioredis": "^5.4.1",
|
|
13
|
+
"openai": "^4.52.7"
|
|
14
|
+
},
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"@types/node": "^24.0.13",
|
|
17
|
+
"tsx": "^4.16.2",
|
|
18
|
+
"typescript": "^5.5.4"
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import process from 'node:process'
|
|
3
|
+
import { ConsoleLogger, TypedContext } from 'flowcraft'
|
|
4
|
+
import IORedis from 'ioredis'
|
|
5
|
+
import { BullMQExecutor } from './executor'
|
|
6
|
+
import { WorkflowRegistry } from './registry'
|
|
7
|
+
import { RUN_ID } from './types'
|
|
8
|
+
import { waitForWorkflow } from './utils'
|
|
9
|
+
|
|
10
|
+
const config = {
|
|
11
|
+
'1.blog-post': {
|
|
12
|
+
mainWorkflowId: 100,
|
|
13
|
+
getInitialContext: () => new TypedContext([
|
|
14
|
+
['topic', 'The rise of AI-powered workflow automation in modern software development.'],
|
|
15
|
+
]),
|
|
16
|
+
},
|
|
17
|
+
'2.job-application': {
|
|
18
|
+
mainWorkflowId: 200,
|
|
19
|
+
getInitialContext: () => new TypedContext([
|
|
20
|
+
['applicantName', 'Jane Doe'],
|
|
21
|
+
['resume', 'Experienced developer with a background in TypeScript, Node.js, and building complex DAG workflow systems. Also proficient in React and SQL.'],
|
|
22
|
+
['coverLetter', 'To Whom It May Concern, I am writing to express my interest in the Senior Developer position. My skills and experience align perfectly with the requirements of the role.'],
|
|
23
|
+
]),
|
|
24
|
+
},
|
|
25
|
+
'3.customer-review': {
|
|
26
|
+
mainWorkflowId: 300,
|
|
27
|
+
getInitialContext: () => new TypedContext([
|
|
28
|
+
['initial_review', 'The new dashboard is a huge improvement, but I noticed that the export-to-PDF feature is really slow and sometimes crashes the app on large datasets. It would be great if you could look into this.'],
|
|
29
|
+
]),
|
|
30
|
+
},
|
|
31
|
+
'4.content-moderation': {
|
|
32
|
+
mainWorkflowId: 400,
|
|
33
|
+
getInitialContext: () => new TypedContext([
|
|
34
|
+
['userId', 'user-456'],
|
|
35
|
+
// Try different posts to test different paths
|
|
36
|
+
// Path 1: PII detection
|
|
37
|
+
['userPost', 'Hi, I need help with my account. My email is test@example.com and my phone is 555-123-4567.'],
|
|
38
|
+
// Path 2: Spam
|
|
39
|
+
// ['userPost', '!!!BUY NOW!!! Visit my-scam-site.com for a FREE PRIZE! Limited time offer!'],
|
|
40
|
+
// Path 3: Severe hate speech
|
|
41
|
+
// ['userPost', `I don't want any dirty immigrants in my country, stealing, raping, and killing my people. They should all be eradicated!`],
|
|
42
|
+
// Path 4: Moderate hate speech (approve)
|
|
43
|
+
// ['userPost', `I don't want any illegal immigrants in my country.`],
|
|
44
|
+
// Path 5: Approved post
|
|
45
|
+
// ['userPost', 'I really enjoy using this platform. The new features are great and very helpful.'],
|
|
46
|
+
]),
|
|
47
|
+
},
|
|
48
|
+
} as const
|
|
49
|
+
|
|
50
|
+
type UseCase = keyof typeof config
|
|
51
|
+
|
|
52
|
+
// --- CONFIGURATION ---
|
|
53
|
+
const QUEUE_NAME = 'distributed-flowcraft-queue'
|
|
54
|
+
const ACTIVE_USE_CASE: UseCase = '2.job-application'
|
|
55
|
+
const WORKFLOW_ID = config[ACTIVE_USE_CASE].mainWorkflowId
|
|
56
|
+
|
|
57
|
+
async function main() {
|
|
58
|
+
const logger = new ConsoleLogger()
|
|
59
|
+
logger.info('--- Distributed Workflow Client ---')
|
|
60
|
+
|
|
61
|
+
const runId = Math.random().toString(36).substring(2, 4)
|
|
62
|
+
const redisConnection = new IORedis({ maxRetriesPerRequest: null })
|
|
63
|
+
const useCaseDirectory = path.join(process.cwd(), 'data', ACTIVE_USE_CASE)
|
|
64
|
+
|
|
65
|
+
const registry = await WorkflowRegistry.create([useCaseDirectory])
|
|
66
|
+
|
|
67
|
+
const flow = await registry.getFlow(WORKFLOW_ID)
|
|
68
|
+
const context = config[ACTIVE_USE_CASE].getInitialContext()
|
|
69
|
+
context.set(RUN_ID, runId) // Add runId to the context
|
|
70
|
+
|
|
71
|
+
const executor = new BullMQExecutor(QUEUE_NAME, redisConnection)
|
|
72
|
+
|
|
73
|
+
console.log('🚀 Starting Workflow and awaiting result...')
|
|
74
|
+
|
|
75
|
+
const initialJobsOrJob = await flow.run(context, {
|
|
76
|
+
logger,
|
|
77
|
+
executor,
|
|
78
|
+
params: { runId, workflowId: WORKFLOW_ID },
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
if (!initialJobsOrJob) {
|
|
82
|
+
logger.error('Workflow did not produce any initial jobs.')
|
|
83
|
+
await redisConnection.quit()
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const firstJob = Array.isArray(initialJobsOrJob)
|
|
88
|
+
? initialJobsOrJob[0]
|
|
89
|
+
: initialJobsOrJob
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
const finalStatus = await waitForWorkflow(redisConnection, runId, 30000)
|
|
93
|
+
|
|
94
|
+
console.log('\n=============================================================')
|
|
95
|
+
|
|
96
|
+
switch (finalStatus.status) {
|
|
97
|
+
case 'completed':
|
|
98
|
+
logger.info(`✅ Workflow Run ID: ${runId} COMPLETED.`)
|
|
99
|
+
console.log('=============================================================\n')
|
|
100
|
+
console.log('Final Result from Worker:\n')
|
|
101
|
+
console.log(finalStatus.payload.result)
|
|
102
|
+
break
|
|
103
|
+
case 'cancelled':
|
|
104
|
+
logger.warn(`🛑 Workflow Run ID: ${runId} was successfully CANCELLED.`)
|
|
105
|
+
console.log(` Reason: ${finalStatus.reason}`)
|
|
106
|
+
console.log('=============================================================')
|
|
107
|
+
break
|
|
108
|
+
case 'failed':
|
|
109
|
+
logger.error(`❌ Workflow Run ID: ${runId} FAILED or timed out.`)
|
|
110
|
+
console.log(` Reason: ${finalStatus.reason}`)
|
|
111
|
+
console.log('=============================================================')
|
|
112
|
+
break
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
catch (error: any) {
|
|
116
|
+
logger.error(`Error waiting for job to complete for Run ID ${runId}`, error)
|
|
117
|
+
const jobState = await firstJob.getState()
|
|
118
|
+
logger.error(`Job state is: ${jobState}`)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
await redisConnection.quit()
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
main().catch(console.error)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { Job } from 'bullmq'
|
|
2
|
+
import type { Context, IExecutor, RunOptions } from 'flowcraft'
|
|
3
|
+
import type IORedis from 'ioredis'
|
|
4
|
+
import type { NodeJobPayload } from './types'
|
|
5
|
+
import { Queue } from 'bullmq'
|
|
6
|
+
import { Flow } from 'flowcraft'
|
|
7
|
+
|
|
8
|
+
export class BullMQExecutor implements IExecutor {
|
|
9
|
+
public readonly queue: Queue<NodeJobPayload>
|
|
10
|
+
|
|
11
|
+
constructor(
|
|
12
|
+
queueName: string,
|
|
13
|
+
private connection: IORedis,
|
|
14
|
+
) {
|
|
15
|
+
this.queue = new Queue(queueName, { connection })
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async run(flow: Flow, context: Context, options?: RunOptions): Promise<any> {
|
|
19
|
+
const logger = options?.logger
|
|
20
|
+
const runId = options?.params?.runId as string
|
|
21
|
+
const workflowId = options?.params?.workflowId as number
|
|
22
|
+
|
|
23
|
+
if (!runId || !workflowId) {
|
|
24
|
+
throw new Error('BullMQExecutor requires a `runId` and `workflowId` to be passed in `options.params`.')
|
|
25
|
+
}
|
|
26
|
+
if (!flow.startNode) {
|
|
27
|
+
logger?.warn(`Executing a flow with no startNode: ${flow.constructor.name}. Nothing to do.`)
|
|
28
|
+
return
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const serializedContext = Object.fromEntries(context.entries())
|
|
32
|
+
const combinedParams = { ...flow.params, ...options?.params }
|
|
33
|
+
|
|
34
|
+
// The GraphBuilder creates a special Flow to handle parallel start nodes.
|
|
35
|
+
// We need to look inside it to enqueue the actual start nodes.
|
|
36
|
+
const isParallelStart = flow.startNode instanceof Flow
|
|
37
|
+
const nodesToEnqueue = isParallelStart
|
|
38
|
+
? (flow.startNode as any).nodesToRun
|
|
39
|
+
: [flow.startNode]
|
|
40
|
+
|
|
41
|
+
if (!nodesToEnqueue || nodesToEnqueue.length === 0) {
|
|
42
|
+
logger?.warn('Flow start node has no executable children.')
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
logger?.info(`[Executor] Enqueuing ${nodesToEnqueue.length} start node(s) for workflow ${workflowId}`)
|
|
47
|
+
logger?.info(`[Executor] Starting Run ID: ${runId}`)
|
|
48
|
+
|
|
49
|
+
const jobs: Job[] = []
|
|
50
|
+
for (const node of nodesToEnqueue) {
|
|
51
|
+
const nodeId = node.id!
|
|
52
|
+
const jobPayload: NodeJobPayload = {
|
|
53
|
+
runId,
|
|
54
|
+
workflowId,
|
|
55
|
+
nodeId,
|
|
56
|
+
context: serializedContext,
|
|
57
|
+
params: combinedParams,
|
|
58
|
+
}
|
|
59
|
+
const job = await this.queue.add(nodeId, jobPayload)
|
|
60
|
+
jobs.push(job)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (jobs.length === 0)
|
|
64
|
+
return undefined
|
|
65
|
+
if (jobs.length === 1)
|
|
66
|
+
return jobs[0]
|
|
67
|
+
return jobs
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import type { DEFAULT_ACTION, NodeArgs, NodeOptions } from 'flowcraft'
|
|
2
|
+
import type { WorkflowRegistry } from './registry'
|
|
3
|
+
import type { AgentNodeTypeMap } from './types'
|
|
4
|
+
import { Node } from 'flowcraft'
|
|
5
|
+
import { FINAL_ACTION } from './types'
|
|
6
|
+
import { callLLM, resolveTemplate } from './utils'
|
|
7
|
+
|
|
8
|
+
interface AiNodeOptions<T extends keyof AgentNodeTypeMap> extends NodeOptions {
|
|
9
|
+
data: AgentNodeTypeMap[T] & { nodeId: string }
|
|
10
|
+
registry?: WorkflowRegistry
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A generic node that executes an LLM prompt.
|
|
15
|
+
* The prompt is a template that gets resolved with inputs from the context.
|
|
16
|
+
*/
|
|
17
|
+
export class LLMProcessNode extends Node<string, string> {
|
|
18
|
+
private data: AiNodeOptions<'llm-process'>['data']
|
|
19
|
+
|
|
20
|
+
constructor(options: AiNodeOptions<'llm-process'>) {
|
|
21
|
+
super(options)
|
|
22
|
+
this.data = options.data
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
prep(args: NodeArgs): Promise<string> {
|
|
26
|
+
const template = this.data.promptTemplate
|
|
27
|
+
const inputMappings = this.data.inputs
|
|
28
|
+
const templateData: Record<string, any> = {}
|
|
29
|
+
|
|
30
|
+
for (const [templateKey, sourcePathOrPaths] of Object.entries(inputMappings)) {
|
|
31
|
+
const sourcePaths = Array.isArray(sourcePathOrPaths) ? sourcePathOrPaths : [sourcePathOrPaths]
|
|
32
|
+
let value: any
|
|
33
|
+
|
|
34
|
+
for (const sourcePath of sourcePaths) {
|
|
35
|
+
value = args.ctx.get(sourcePath)
|
|
36
|
+
if (value !== undefined)
|
|
37
|
+
break
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (value === undefined)
|
|
41
|
+
args.logger.warn(`[Node: ${this.data.nodeId}] Template variable '{{${templateKey}}}' could not be resolved from any source: [${sourcePaths.join(', ')}].`)
|
|
42
|
+
|
|
43
|
+
templateData[templateKey] = value
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const resolvedPrompt = resolveTemplate(template, templateData)
|
|
47
|
+
return Promise.resolve(resolvedPrompt)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
exec(args: NodeArgs<string>): Promise<string> {
|
|
51
|
+
args.logger.info(`[Node: ${this.data.nodeId}] Executing LLM process...`)
|
|
52
|
+
return callLLM(args.prepRes)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async post(args: NodeArgs<string, string>) {
|
|
56
|
+
args.ctx.set(this.data.nodeId, args.execRes)
|
|
57
|
+
args.logger.info(`[Node: ${this.data.nodeId}] ✓ Process complete.`)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* An LLM-powered node that evaluates a condition and returns 'true' or 'false'.
|
|
63
|
+
*/
|
|
64
|
+
export class LLMConditionNode extends Node<string, string, 'true' | 'false'> {
|
|
65
|
+
private data: AiNodeOptions<'llm-condition'>['data']
|
|
66
|
+
|
|
67
|
+
constructor(options: AiNodeOptions<'llm-condition'>) {
|
|
68
|
+
super(options)
|
|
69
|
+
this.data = options.data
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
prep = LLMProcessNode.prototype.prep
|
|
73
|
+
|
|
74
|
+
exec(args: NodeArgs<string>): Promise<string> {
|
|
75
|
+
args.logger.info(`[Node: ${this.data.nodeId}] Evaluating condition...`)
|
|
76
|
+
return callLLM(args.prepRes)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async post(args: NodeArgs<string, string>): Promise<'true' | 'false'> {
|
|
80
|
+
const result = args.execRes.toLowerCase().includes('true') ? 'true' : 'false'
|
|
81
|
+
args.ctx.set(this.data.nodeId, result)
|
|
82
|
+
args.logger.info(`[Node: ${this.data.nodeId}] ✓ Condition evaluated to: ${result}`)
|
|
83
|
+
return result
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* An LLM-powered node that returns its raw output as an action for dynamic routing.
|
|
89
|
+
*/
|
|
90
|
+
export class LLMRouterNode extends Node<string, string, string> {
|
|
91
|
+
private data: AiNodeOptions<'llm-router'>['data']
|
|
92
|
+
|
|
93
|
+
constructor(options: AiNodeOptions<'llm-router'>) {
|
|
94
|
+
super(options)
|
|
95
|
+
this.data = options.data
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
prep = LLMProcessNode.prototype.prep
|
|
99
|
+
exec = LLMProcessNode.prototype.exec
|
|
100
|
+
|
|
101
|
+
async post(args: NodeArgs<string, string>): Promise<string> {
|
|
102
|
+
const result = args.execRes.trim()
|
|
103
|
+
args.ctx.set(this.data.nodeId, result)
|
|
104
|
+
args.logger.info(`[Node: ${this.data.nodeId}] ✓ Routing decision is: '${result}'`)
|
|
105
|
+
return result
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Aggregates inputs and sets a final value in the context.
|
|
111
|
+
*/
|
|
112
|
+
export class OutputNode extends Node<string, void, typeof FINAL_ACTION | string | typeof DEFAULT_ACTION> {
|
|
113
|
+
private data: AiNodeOptions<'output'>['data']
|
|
114
|
+
|
|
115
|
+
constructor(options: AiNodeOptions<'output'>) {
|
|
116
|
+
super(options)
|
|
117
|
+
this.data = options.data
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
prep = LLMProcessNode.prototype.prep
|
|
121
|
+
|
|
122
|
+
async post(args: NodeArgs<string, void>): Promise<any> {
|
|
123
|
+
const finalResult = args.prepRes
|
|
124
|
+
const outputKey = this.data.outputKey || 'final_output'
|
|
125
|
+
args.ctx.set(outputKey, finalResult)
|
|
126
|
+
|
|
127
|
+
// The payload needs to be the *entire context* for sub-workflow output mapping.
|
|
128
|
+
args.ctx.set('__final_payload', {
|
|
129
|
+
result: finalResult,
|
|
130
|
+
context: Object.fromEntries(args.ctx.entries()),
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
args.logger.info(`[Output] Workflow branch finished. Final value set to context key '${outputKey}'.`)
|
|
134
|
+
return FINAL_ACTION
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { AbstractNode, Flow, TypedWorkflowGraph } from 'flowcraft'
|
|
2
|
+
import type { AgentNodeTypeMap } from './types'
|
|
3
|
+
import { promises as fs } from 'node:fs'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { ConsoleLogger, createNodeRegistry, GraphBuilder } from 'flowcraft'
|
|
6
|
+
import {
|
|
7
|
+
LLMConditionNode,
|
|
8
|
+
LLMProcessNode,
|
|
9
|
+
LLMRouterNode,
|
|
10
|
+
OutputNode,
|
|
11
|
+
} from './nodes'
|
|
12
|
+
|
|
13
|
+
export const nodeRegistry = createNodeRegistry({
|
|
14
|
+
'llm-process': LLMProcessNode,
|
|
15
|
+
'llm-condition': LLMConditionNode,
|
|
16
|
+
'llm-router': LLMRouterNode,
|
|
17
|
+
'output': OutputNode,
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
export class WorkflowRegistry {
|
|
21
|
+
private flowCache = new Map<number, Flow>()
|
|
22
|
+
private nodeMapCache = new Map<number, Map<string, AbstractNode>>()
|
|
23
|
+
private predecessorCountCache = new Map<number, Map<string, number>>()
|
|
24
|
+
private graphDatabase = new Map<number, TypedWorkflowGraph<AgentNodeTypeMap>>()
|
|
25
|
+
private builder: GraphBuilder<AgentNodeTypeMap>
|
|
26
|
+
|
|
27
|
+
private constructor() {
|
|
28
|
+
this.builder = new GraphBuilder(
|
|
29
|
+
nodeRegistry,
|
|
30
|
+
{ registry: this },
|
|
31
|
+
{ subWorkflowNodeTypes: ['sub-workflow'] },
|
|
32
|
+
new ConsoleLogger(),
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
public static async create(useCaseDirectories: string[]): Promise<WorkflowRegistry> {
|
|
37
|
+
const registry = new WorkflowRegistry()
|
|
38
|
+
for (const dir of useCaseDirectories) {
|
|
39
|
+
await registry.initialize(dir)
|
|
40
|
+
}
|
|
41
|
+
return registry
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private async initialize(useCaseDirectory: string): Promise<void> {
|
|
45
|
+
try {
|
|
46
|
+
const files = await fs.readdir(useCaseDirectory)
|
|
47
|
+
for (const file of files) {
|
|
48
|
+
if (path.extname(file) === '.json') {
|
|
49
|
+
const workflowId = Number.parseInt(path.basename(file, '.json'), 10)
|
|
50
|
+
if (!Number.isNaN(workflowId)) {
|
|
51
|
+
const filePath = path.join(useCaseDirectory, file)
|
|
52
|
+
const fileContent = await fs.readFile(filePath, 'utf-8')
|
|
53
|
+
const graphData: TypedWorkflowGraph<AgentNodeTypeMap> = JSON.parse(fileContent)
|
|
54
|
+
this.graphDatabase.set(workflowId, graphData)
|
|
55
|
+
console.log(`[Registry] Loaded workflow ${workflowId} from ${file}`)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
console.error(`[Registry] Failed to initialize from directory ${useCaseDirectory}`, error)
|
|
62
|
+
throw error
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
public getGraph(workflowId: number): TypedWorkflowGraph<AgentNodeTypeMap> | undefined {
|
|
67
|
+
return this.graphDatabase.get(workflowId)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private async buildAndCache(workflowId: number): Promise<void> {
|
|
71
|
+
const graphData = this.graphDatabase.get(workflowId)
|
|
72
|
+
if (!graphData)
|
|
73
|
+
throw new Error(`Workflow with id ${workflowId} not found in the database.`)
|
|
74
|
+
|
|
75
|
+
const { flow, nodeMap, predecessorCountMap } = this.builder.build(graphData)
|
|
76
|
+
this.flowCache.set(workflowId, flow)
|
|
77
|
+
this.nodeMapCache.set(workflowId, nodeMap)
|
|
78
|
+
this.predecessorCountCache.set(workflowId, predecessorCountMap)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async getFlow(workflowId: number): Promise<Flow> {
|
|
82
|
+
if (!this.flowCache.has(workflowId))
|
|
83
|
+
await this.buildAndCache(workflowId)
|
|
84
|
+
|
|
85
|
+
return this.flowCache.get(workflowId)!
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async getNode(workflowId: number, nodeId: string): Promise<AbstractNode | undefined> {
|
|
89
|
+
if (!this.nodeMapCache.has(workflowId))
|
|
90
|
+
await this.buildAndCache(workflowId)
|
|
91
|
+
|
|
92
|
+
return this.nodeMapCache.get(workflowId)?.get(nodeId)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async getPredecessorCount(workflowId: number, nodeId: string): Promise<number> {
|
|
96
|
+
if (!this.predecessorCountCache.has(workflowId))
|
|
97
|
+
await this.buildAndCache(workflowId)
|
|
98
|
+
|
|
99
|
+
return this.predecessorCountCache.get(workflowId)?.get(nodeId) ?? 0
|
|
100
|
+
}
|
|
101
|
+
}
|