@q1k-oss/btree-workflows 0.0.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/.claude/settings.local.json +31 -0
- package/CLAUDE.md +181 -0
- package/LICENSE +21 -0
- package/README.md +920 -0
- package/behaviour-tree-workflows-landing/index.html +16 -0
- package/behaviour-tree-workflows-landing/package-lock.json +2074 -0
- package/behaviour-tree-workflows-landing/package.json +31 -0
- package/behaviour-tree-workflows-landing/public/favicon.svg +17 -0
- package/behaviour-tree-workflows-landing/src/App.css +103 -0
- package/behaviour-tree-workflows-landing/src/App.tsx +176 -0
- package/behaviour-tree-workflows-landing/src/components/BlackboardInspector.css +89 -0
- package/behaviour-tree-workflows-landing/src/components/BlackboardInspector.tsx +64 -0
- package/behaviour-tree-workflows-landing/src/components/ExampleSelector.css +64 -0
- package/behaviour-tree-workflows-landing/src/components/ExampleSelector.tsx +34 -0
- package/behaviour-tree-workflows-landing/src/components/ExecutionLog.css +107 -0
- package/behaviour-tree-workflows-landing/src/components/ExecutionLog.tsx +85 -0
- package/behaviour-tree-workflows-landing/src/components/Header.css +50 -0
- package/behaviour-tree-workflows-landing/src/components/Header.tsx +26 -0
- package/behaviour-tree-workflows-landing/src/components/StatusBadge.css +45 -0
- package/behaviour-tree-workflows-landing/src/components/StatusBadge.tsx +15 -0
- package/behaviour-tree-workflows-landing/src/components/Toolbar.css +74 -0
- package/behaviour-tree-workflows-landing/src/components/Toolbar.tsx +53 -0
- package/behaviour-tree-workflows-landing/src/components/TreeVisualizer.css +67 -0
- package/behaviour-tree-workflows-landing/src/components/TreeVisualizer.tsx +192 -0
- package/behaviour-tree-workflows-landing/src/components/YamlEditor.css +18 -0
- package/behaviour-tree-workflows-landing/src/components/YamlEditor.tsx +96 -0
- package/behaviour-tree-workflows-landing/src/lib/count-nodes.ts +11 -0
- package/behaviour-tree-workflows-landing/src/lib/execution-engine.ts +96 -0
- package/behaviour-tree-workflows-landing/src/lib/tree-layout.ts +136 -0
- package/behaviour-tree-workflows-landing/src/lib/yaml-examples.ts +549 -0
- package/behaviour-tree-workflows-landing/src/main.tsx +9 -0
- package/behaviour-tree-workflows-landing/src/stubs/activepieces.ts +18 -0
- package/behaviour-tree-workflows-landing/src/stubs/fs.ts +24 -0
- package/behaviour-tree-workflows-landing/src/stubs/path.ts +16 -0
- package/behaviour-tree-workflows-landing/src/stubs/temporal-activity.ts +6 -0
- package/behaviour-tree-workflows-landing/src/stubs/temporal-workflow.ts +22 -0
- package/behaviour-tree-workflows-landing/tsconfig.json +25 -0
- package/behaviour-tree-workflows-landing/vite.config.ts +40 -0
- package/demo-google-sheets.ts +181 -0
- package/demo-runtime-variables.ts +174 -0
- package/demo-template.ts +208 -0
- package/docs/ARCHITECTURE_SUMMARY.md +613 -0
- package/docs/NODE_REFERENCE.md +504 -0
- package/docs/README.md +53 -0
- package/docs/custom-nodes-architecture.md +826 -0
- package/docs/observability.md +175 -0
- package/docs/yaml-specification.md +990 -0
- package/examples/temporal/README.md +117 -0
- package/examples/temporal/activities.ts +373 -0
- package/examples/temporal/client.ts +115 -0
- package/examples/temporal/python-worker/activities.py +339 -0
- package/examples/temporal/python-worker/requirements.txt +12 -0
- package/examples/temporal/python-worker/worker.py +106 -0
- package/examples/temporal/worker.ts +66 -0
- package/examples/temporal/workflows.ts +6 -0
- package/examples/temporal/yaml-workflow-loader.ts +105 -0
- package/examples/yaml-test.ts +97 -0
- package/examples/yaml-workflows/01-simple-sequence.yaml +25 -0
- package/examples/yaml-workflows/02-parallel-timeout.yaml +45 -0
- package/examples/yaml-workflows/03-ecommerce-checkout.yaml +94 -0
- package/examples/yaml-workflows/04-ai-agent-workflow.yaml +346 -0
- package/examples/yaml-workflows/05-order-processing.yaml +146 -0
- package/examples/yaml-workflows/06-activity-test.yaml +71 -0
- package/examples/yaml-workflows/07-activity-simple-test.yaml +43 -0
- package/examples/yaml-workflows/08-file-processing.yaml +141 -0
- package/examples/yaml-workflows/09-http-request.yaml +137 -0
- package/examples/yaml-workflows/README.md +211 -0
- package/package.json +38 -0
- package/src/actions/code-execution.schema.ts +27 -0
- package/src/actions/code-execution.ts +218 -0
- package/src/actions/generate-file.test.ts +516 -0
- package/src/actions/generate-file.ts +166 -0
- package/src/actions/http-request.test.ts +784 -0
- package/src/actions/http-request.ts +228 -0
- package/src/actions/index.ts +20 -0
- package/src/actions/parse-file.test.ts +448 -0
- package/src/actions/parse-file.ts +139 -0
- package/src/actions/python-script.test.ts +439 -0
- package/src/actions/python-script.ts +154 -0
- package/src/base-node.test.ts +511 -0
- package/src/base-node.ts +605 -0
- package/src/behavior-tree.test.ts +431 -0
- package/src/behavior-tree.ts +283 -0
- package/src/blackboard.test.ts +222 -0
- package/src/blackboard.ts +192 -0
- package/src/composites/conditional.schema.ts +19 -0
- package/src/composites/conditional.test.ts +309 -0
- package/src/composites/conditional.ts +129 -0
- package/src/composites/for-each.schema.ts +23 -0
- package/src/composites/for-each.test.ts +254 -0
- package/src/composites/for-each.ts +132 -0
- package/src/composites/index.ts +15 -0
- package/src/composites/memory-sequence.schema.ts +19 -0
- package/src/composites/memory-sequence.test.ts +223 -0
- package/src/composites/memory-sequence.ts +98 -0
- package/src/composites/parallel.schema.ts +28 -0
- package/src/composites/parallel.test.ts +502 -0
- package/src/composites/parallel.ts +157 -0
- package/src/composites/reactive-sequence.schema.ts +19 -0
- package/src/composites/reactive-sequence.test.ts +170 -0
- package/src/composites/reactive-sequence.ts +85 -0
- package/src/composites/recovery.schema.ts +19 -0
- package/src/composites/recovery.test.ts +366 -0
- package/src/composites/recovery.ts +90 -0
- package/src/composites/selector.schema.ts +19 -0
- package/src/composites/selector.test.ts +387 -0
- package/src/composites/selector.ts +85 -0
- package/src/composites/sequence.schema.ts +19 -0
- package/src/composites/sequence.test.ts +337 -0
- package/src/composites/sequence.ts +72 -0
- package/src/composites/sub-tree.schema.ts +21 -0
- package/src/composites/sub-tree.test.ts +893 -0
- package/src/composites/sub-tree.ts +177 -0
- package/src/composites/while.schema.ts +24 -0
- package/src/composites/while.test.ts +381 -0
- package/src/composites/while.ts +149 -0
- package/src/data-store/index.ts +10 -0
- package/src/data-store/memory-store.ts +161 -0
- package/src/data-store/types.ts +94 -0
- package/src/debug/breakpoint.test.ts +47 -0
- package/src/debug/breakpoint.ts +30 -0
- package/src/debug/index.ts +17 -0
- package/src/debug/resume-point.test.ts +49 -0
- package/src/debug/resume-point.ts +29 -0
- package/src/decorators/delay.schema.ts +21 -0
- package/src/decorators/delay.test.ts +261 -0
- package/src/decorators/delay.ts +140 -0
- package/src/decorators/force-result.schema.ts +32 -0
- package/src/decorators/force-result.test.ts +133 -0
- package/src/decorators/force-result.ts +63 -0
- package/src/decorators/index.ts +13 -0
- package/src/decorators/invert.schema.ts +19 -0
- package/src/decorators/invert.test.ts +135 -0
- package/src/decorators/invert.ts +42 -0
- package/src/decorators/keep-running.schema.ts +20 -0
- package/src/decorators/keep-running.test.ts +105 -0
- package/src/decorators/keep-running.ts +49 -0
- package/src/decorators/precondition.schema.ts +19 -0
- package/src/decorators/precondition.test.ts +351 -0
- package/src/decorators/precondition.ts +139 -0
- package/src/decorators/repeat.schema.ts +21 -0
- package/src/decorators/repeat.test.ts +187 -0
- package/src/decorators/repeat.ts +94 -0
- package/src/decorators/run-once.schema.ts +19 -0
- package/src/decorators/run-once.test.ts +140 -0
- package/src/decorators/run-once.ts +61 -0
- package/src/decorators/soft-assert.schema.ts +19 -0
- package/src/decorators/soft-assert.test.ts +107 -0
- package/src/decorators/soft-assert.ts +68 -0
- package/src/decorators/timeout.schema.ts +21 -0
- package/src/decorators/timeout.test.ts +274 -0
- package/src/decorators/timeout.ts +159 -0
- package/src/errors.test.ts +63 -0
- package/src/errors.ts +34 -0
- package/src/events.test.ts +347 -0
- package/src/events.ts +183 -0
- package/src/index.ts +80 -0
- package/src/integrations/index.ts +30 -0
- package/src/integrations/integration-action.test.ts +571 -0
- package/src/integrations/integration-action.ts +233 -0
- package/src/integrations/piece-executor.ts +320 -0
- package/src/observability/execution-tracker.ts +320 -0
- package/src/observability/index.ts +23 -0
- package/src/observability/sinks.ts +138 -0
- package/src/observability/types.ts +130 -0
- package/src/registry-utils.ts +147 -0
- package/src/registry.test.ts +466 -0
- package/src/registry.ts +334 -0
- package/src/schemas/base.schema.ts +104 -0
- package/src/schemas/index.ts +223 -0
- package/src/schemas/integration.test.ts +238 -0
- package/src/schemas/tree-definition.schema.ts +170 -0
- package/src/schemas/validation.test.ts +146 -0
- package/src/schemas/validation.ts +122 -0
- package/src/scripting/index.ts +22 -0
- package/src/templates/template-loader.test.ts +281 -0
- package/src/templates/template-loader.ts +152 -0
- package/src/temporal-integration.test.ts +213 -0
- package/src/test-nodes.ts +259 -0
- package/src/types.ts +503 -0
- package/src/utilities/index.ts +17 -0
- package/src/utilities/log-message.test.ts +275 -0
- package/src/utilities/log-message.ts +134 -0
- package/src/utilities/regex-extract.test.ts +138 -0
- package/src/utilities/regex-extract.ts +108 -0
- package/src/utilities/variable-resolver.test.ts +416 -0
- package/src/utilities/variable-resolver.ts +318 -0
- package/src/utils/error-handler.test.ts +117 -0
- package/src/utils/error-handler.ts +48 -0
- package/src/utils/signal-check.test.ts +234 -0
- package/src/utils/signal-check.ts +140 -0
- package/src/yaml/errors.ts +143 -0
- package/src/yaml/index.ts +30 -0
- package/src/yaml/loader.ts +39 -0
- package/src/yaml/parser.ts +286 -0
- package/src/yaml/validation/semantic-validator.ts +196 -0
- package/templates/google-sheets/insert-row.yaml +76 -0
- package/templates/notification-sender.yaml +33 -0
- package/templates/order-validation.yaml +44 -0
- package/tsconfig.json +24 -0
- package/vitest.config.ts +25 -0
- package/workflows/order-processor.yaml +59 -0
- package/workflows/process-order-workflow.yaml +142 -0
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BehaviorTree - Wrapper for TreeNode with path-based indexing
|
|
3
|
+
* Enables partial tree updates without full reload
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
TreeNode,
|
|
8
|
+
TemporalContext,
|
|
9
|
+
WorkflowArgs,
|
|
10
|
+
WorkflowResult,
|
|
11
|
+
} from "./types.js";
|
|
12
|
+
import { ScopedBlackboard } from "./blackboard.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* BehaviorTree class that wraps a TreeNode root with path-based indexing
|
|
16
|
+
* Paths use child indices: /0/1/2 represents root → child[0] → child[1] → child[2]
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* Result of parsing a path with tree ID prefix
|
|
20
|
+
*/
|
|
21
|
+
export interface ParsedPath {
|
|
22
|
+
treeId: string;
|
|
23
|
+
nodePath: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class BehaviorTree {
|
|
27
|
+
private root: TreeNode;
|
|
28
|
+
private pathIndex: Map<string, TreeNode> = new Map();
|
|
29
|
+
private idIndex: Map<string, TreeNode> = new Map();
|
|
30
|
+
|
|
31
|
+
constructor(root: TreeNode) {
|
|
32
|
+
this.root = root;
|
|
33
|
+
this.buildNodeIndex();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Parse a path with tree ID prefix.
|
|
38
|
+
* Format: #TreeID/node/path
|
|
39
|
+
*
|
|
40
|
+
* @param fullPath Path string starting with # followed by tree ID
|
|
41
|
+
* @returns Object with treeId and nodePath
|
|
42
|
+
* @throws Error if path format is invalid
|
|
43
|
+
*
|
|
44
|
+
* Valid examples:
|
|
45
|
+
* - "#SimpleTest/0/1" -> { treeId: "SimpleTest", nodePath: "/0/1" }
|
|
46
|
+
* - "#MyTree/" -> { treeId: "MyTree", nodePath: "/" }
|
|
47
|
+
* - "#OnlyTree" -> { treeId: "OnlyTree", nodePath: "/" }
|
|
48
|
+
*
|
|
49
|
+
* Invalid examples:
|
|
50
|
+
* - "/0/1" - missing #TreeID prefix
|
|
51
|
+
* - "#/0/1" - empty tree ID
|
|
52
|
+
* - "#" - empty tree ID
|
|
53
|
+
*/
|
|
54
|
+
static parsePathWithTreeId(fullPath: string): ParsedPath {
|
|
55
|
+
if (!fullPath.startsWith("#")) {
|
|
56
|
+
throw new Error(
|
|
57
|
+
`Invalid path format: '${fullPath}'. Path must start with #TreeID (e.g., #SimpleTest/0/1)`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const slashIndex = fullPath.indexOf("/");
|
|
62
|
+
let treeId: string;
|
|
63
|
+
let nodePath: string;
|
|
64
|
+
|
|
65
|
+
if (slashIndex === -1) {
|
|
66
|
+
// No slash after tree ID, e.g., "#TreeId" means root of that tree
|
|
67
|
+
treeId = fullPath.slice(1);
|
|
68
|
+
nodePath = "/";
|
|
69
|
+
} else {
|
|
70
|
+
treeId = fullPath.slice(1, slashIndex);
|
|
71
|
+
nodePath = fullPath.slice(slashIndex);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Validate tree ID is not empty
|
|
75
|
+
if (!treeId || treeId.trim() === "") {
|
|
76
|
+
throw new Error(
|
|
77
|
+
`Invalid path: tree ID cannot be empty in '${fullPath}'. Expected format: #TreeID/path`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Validate node path starts with /
|
|
82
|
+
if (!nodePath.startsWith("/")) {
|
|
83
|
+
throw new Error(
|
|
84
|
+
`Invalid path: node path must start with '/' in '${fullPath}'`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return { treeId, nodePath };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Get the root node of the tree
|
|
93
|
+
*/
|
|
94
|
+
getRoot(): TreeNode {
|
|
95
|
+
return this.root;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Find a node by its path
|
|
100
|
+
* Path format: / for root, /0 for first child, /0/1 for second child of first child
|
|
101
|
+
*/
|
|
102
|
+
findNodeByPath(path: string): TreeNode | null {
|
|
103
|
+
return this.pathIndex.get(path) || null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Find a node by its ID (if it has one)
|
|
108
|
+
* Convenience method for nodes with explicit IDs
|
|
109
|
+
*/
|
|
110
|
+
findNodeById(nodeId: string): TreeNode | null {
|
|
111
|
+
return this.idIndex.get(nodeId) || null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Get the path for a given node
|
|
116
|
+
* Returns null if the node is not in the tree
|
|
117
|
+
*/
|
|
118
|
+
getNodePath(targetNode: TreeNode): string | null {
|
|
119
|
+
for (const [path, node] of this.pathIndex.entries()) {
|
|
120
|
+
if (node === targetNode) {
|
|
121
|
+
return path;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Get the path for a node by its ID
|
|
129
|
+
* More reliable than instance-based lookup
|
|
130
|
+
* Returns null if the node is not in the tree
|
|
131
|
+
*/
|
|
132
|
+
getNodePathById(nodeId: string): string | null {
|
|
133
|
+
const node = this.findNodeById(nodeId);
|
|
134
|
+
if (!node) return null;
|
|
135
|
+
|
|
136
|
+
// Search pathIndex for this node
|
|
137
|
+
for (const [path, indexedNode] of this.pathIndex.entries()) {
|
|
138
|
+
if (indexedNode.id === nodeId) {
|
|
139
|
+
return path;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Clone this BehaviorTree (deep clones the underlying TreeNode)
|
|
147
|
+
*/
|
|
148
|
+
clone(): BehaviorTree {
|
|
149
|
+
const clonedRoot = this.root.clone();
|
|
150
|
+
return new BehaviorTree(clonedRoot);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Convert this BehaviorTree to a Temporal workflow function
|
|
155
|
+
* Returns a workflow function that can be registered with Temporal
|
|
156
|
+
*
|
|
157
|
+
* @returns A Temporal workflow function that executes this behavior tree
|
|
158
|
+
*
|
|
159
|
+
* @example
|
|
160
|
+
* ```typescript
|
|
161
|
+
* import { BehaviorTree } from '@wayfarer-ai/btree';
|
|
162
|
+
* import { Sequence } from '@wayfarer-ai/btree';
|
|
163
|
+
* import { PrintAction } from '@wayfarer-ai/btree';
|
|
164
|
+
*
|
|
165
|
+
* const root = new Sequence({ id: 'root' });
|
|
166
|
+
* root.addChild(new PrintAction({ id: 'step1', message: 'Hello' }));
|
|
167
|
+
* root.addChild(new PrintAction({ id: 'step2', message: 'World' }));
|
|
168
|
+
*
|
|
169
|
+
* const tree = new BehaviorTree(root);
|
|
170
|
+
* const workflow = tree.toWorkflow();
|
|
171
|
+
*
|
|
172
|
+
* // Register with Temporal worker
|
|
173
|
+
* // Worker.create({ workflows: { myWorkflow: workflow }, ... })
|
|
174
|
+
* ```
|
|
175
|
+
*/
|
|
176
|
+
toWorkflow(): (args: WorkflowArgs) => Promise<WorkflowResult> {
|
|
177
|
+
const root = this.root;
|
|
178
|
+
|
|
179
|
+
return async function behaviorTreeWorkflow(
|
|
180
|
+
args: WorkflowArgs,
|
|
181
|
+
): Promise<WorkflowResult> {
|
|
182
|
+
// Create TemporalContext
|
|
183
|
+
const context: TemporalContext = {
|
|
184
|
+
blackboard: new ScopedBlackboard(),
|
|
185
|
+
treeRegistry: args.treeRegistry,
|
|
186
|
+
timestamp: Date.now(),
|
|
187
|
+
sessionId: args.sessionId || `session-${Date.now()}`,
|
|
188
|
+
// Store input immutably for ${input.key} resolution
|
|
189
|
+
input: args.input ? Object.freeze({ ...args.input }) : undefined,
|
|
190
|
+
// Pass activities for I/O operations (deterministic Temporal execution)
|
|
191
|
+
activities: args.activities,
|
|
192
|
+
// Pass tokenProvider for IntegrationAction authentication
|
|
193
|
+
tokenProvider: args.tokenProvider,
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
// Also copy input to blackboard for backward compatibility
|
|
197
|
+
// This allows ${key} or ${bb.key} to access input values
|
|
198
|
+
if (args.input) {
|
|
199
|
+
for (const [key, value] of Object.entries(args.input)) {
|
|
200
|
+
context.blackboard.set(key, value);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Execute tree
|
|
205
|
+
const status = await root.tick(context);
|
|
206
|
+
|
|
207
|
+
// Return result with output
|
|
208
|
+
return {
|
|
209
|
+
status,
|
|
210
|
+
output: context.blackboard.toJSON(),
|
|
211
|
+
};
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Replace a node at the given path with a new node
|
|
217
|
+
* Updates parent-child relationships and rebuilds the index
|
|
218
|
+
*/
|
|
219
|
+
replaceNodeAtPath(path: string, newNode: TreeNode): void {
|
|
220
|
+
const oldNode = this.findNodeByPath(path);
|
|
221
|
+
if (!oldNode) {
|
|
222
|
+
throw new Error(`Node not found at path: ${path}`);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (path === "/") {
|
|
226
|
+
// Special case: replacing root
|
|
227
|
+
this.root = newNode;
|
|
228
|
+
newNode.parent = undefined;
|
|
229
|
+
} else {
|
|
230
|
+
// Find parent and child index from path
|
|
231
|
+
const pathParts = path.split("/").filter((p) => p);
|
|
232
|
+
const lastPart = pathParts[pathParts.length - 1];
|
|
233
|
+
|
|
234
|
+
if (!lastPart) {
|
|
235
|
+
throw new Error(`Invalid path format: ${path}`);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const childIndex = parseInt(lastPart, 10);
|
|
239
|
+
|
|
240
|
+
const parent = oldNode.parent;
|
|
241
|
+
if (!parent || !parent.children) {
|
|
242
|
+
throw new Error(`Cannot replace node: invalid parent at path ${path}`);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Update parent's children array
|
|
246
|
+
parent.children[childIndex] = newNode;
|
|
247
|
+
newNode.parent = parent;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Rebuild index (paths may have changed if newNode has different children)
|
|
251
|
+
this.buildNodeIndex();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Build the node index with path-based and ID-based lookups
|
|
256
|
+
*/
|
|
257
|
+
private buildNodeIndex(): void {
|
|
258
|
+
this.pathIndex.clear();
|
|
259
|
+
this.idIndex.clear();
|
|
260
|
+
|
|
261
|
+
const indexNode = (node: TreeNode, path: string) => {
|
|
262
|
+
// Index by path (always works)
|
|
263
|
+
this.pathIndex.set(path, node);
|
|
264
|
+
|
|
265
|
+
// Also index by ID if present
|
|
266
|
+
if (node.id) {
|
|
267
|
+
this.idIndex.set(node.id, node);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Recursively index children
|
|
271
|
+
if (node.children) {
|
|
272
|
+
node.children.forEach((child, index) => {
|
|
273
|
+
// Build child path: / becomes /0, /0 becomes /0/1, etc.
|
|
274
|
+
const childPath = path === "/" ? `/${index}` : `${path}/${index}`;
|
|
275
|
+
indexNode(child, childPath);
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
// Start indexing from root with path '/'
|
|
281
|
+
indexNode(this.root, "/");
|
|
282
|
+
}
|
|
283
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { ScopedBlackboard } from "./blackboard.js";
|
|
3
|
+
|
|
4
|
+
describe("ScopedBlackboard", () => {
|
|
5
|
+
let blackboard: ScopedBlackboard;
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
blackboard = new ScopedBlackboard();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
describe("Basic operations", () => {
|
|
12
|
+
it("should store and retrieve values", () => {
|
|
13
|
+
blackboard.set("key1", "value1");
|
|
14
|
+
blackboard.set("key2", 42);
|
|
15
|
+
|
|
16
|
+
expect(blackboard.get("key1")).toBe("value1");
|
|
17
|
+
expect(blackboard.get("key2")).toBe(42);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("should return undefined for non-existent keys", () => {
|
|
21
|
+
expect(blackboard.get("nonexistent")).toBeUndefined();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("should check if key exists", () => {
|
|
25
|
+
blackboard.set("exists", true);
|
|
26
|
+
|
|
27
|
+
expect(blackboard.has("exists")).toBe(true);
|
|
28
|
+
expect(blackboard.has("notexists")).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("should delete keys", () => {
|
|
32
|
+
blackboard.set("toDelete", "value");
|
|
33
|
+
expect(blackboard.has("toDelete")).toBe(true);
|
|
34
|
+
|
|
35
|
+
blackboard.delete("toDelete");
|
|
36
|
+
expect(blackboard.has("toDelete")).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("should clear all entries", () => {
|
|
40
|
+
blackboard.set("key1", "value1");
|
|
41
|
+
blackboard.set("key2", "value2");
|
|
42
|
+
|
|
43
|
+
blackboard.clear();
|
|
44
|
+
|
|
45
|
+
expect(blackboard.has("key1")).toBe(false);
|
|
46
|
+
expect(blackboard.has("key2")).toBe(false);
|
|
47
|
+
expect(blackboard.keys()).toHaveLength(0);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("Scoped inheritance", () => {
|
|
52
|
+
it("should create child scopes", () => {
|
|
53
|
+
const child = blackboard.createScope("child");
|
|
54
|
+
|
|
55
|
+
expect(child.getScopeName()).toBe("child");
|
|
56
|
+
expect(child.getParentScope()).toBe(blackboard);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("should inherit values from parent scope", () => {
|
|
60
|
+
blackboard.set("parentValue", "inherited");
|
|
61
|
+
|
|
62
|
+
const child = blackboard.createScope("child");
|
|
63
|
+
expect(child.get("parentValue")).toBe("inherited");
|
|
64
|
+
expect(child.has("parentValue")).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("should override parent values in child scope", () => {
|
|
68
|
+
blackboard.set("value", "parent");
|
|
69
|
+
|
|
70
|
+
const child = blackboard.createScope("child");
|
|
71
|
+
child.set("value", "child");
|
|
72
|
+
|
|
73
|
+
expect(blackboard.get("value")).toBe("parent");
|
|
74
|
+
expect(child.get("value")).toBe("child");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("should not affect parent when deleting in child", () => {
|
|
78
|
+
blackboard.set("value", "parent");
|
|
79
|
+
|
|
80
|
+
const child = blackboard.createScope("child");
|
|
81
|
+
child.delete("value");
|
|
82
|
+
|
|
83
|
+
expect(blackboard.get("value")).toBe("parent");
|
|
84
|
+
expect(child.get("value")).toBe("parent"); // Still inherited
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("should support multi-level inheritance", () => {
|
|
88
|
+
blackboard.set("level0", "root");
|
|
89
|
+
|
|
90
|
+
const child1 = blackboard.createScope("child1");
|
|
91
|
+
child1.set("level1", "child1");
|
|
92
|
+
|
|
93
|
+
const child2 = child1.createScope("child2");
|
|
94
|
+
child2.set("level2", "child2");
|
|
95
|
+
|
|
96
|
+
expect(child2.get("level0")).toBe("root");
|
|
97
|
+
expect(child2.get("level1")).toBe("child1");
|
|
98
|
+
expect(child2.get("level2")).toBe("child2");
|
|
99
|
+
|
|
100
|
+
expect(child2.getFullScopePath()).toBe("root.child1.child2");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("should reuse existing child scopes", () => {
|
|
104
|
+
const child1 = blackboard.createScope("child");
|
|
105
|
+
child1.set("value", "test");
|
|
106
|
+
|
|
107
|
+
const child2 = blackboard.createScope("child");
|
|
108
|
+
expect(child1).toBe(child2);
|
|
109
|
+
expect(child2.get("value")).toBe("test");
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("Port operations", () => {
|
|
114
|
+
it("should get port with default value", () => {
|
|
115
|
+
expect(blackboard.getPort("missing", "default")).toBe("default");
|
|
116
|
+
|
|
117
|
+
blackboard.set("exists", "value");
|
|
118
|
+
expect(blackboard.getPort("exists", "default")).toBe("value");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("should set port value", () => {
|
|
122
|
+
blackboard.setPort("port", 123);
|
|
123
|
+
expect(blackboard.get("port")).toBe(123);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("should handle typed port operations", () => {
|
|
127
|
+
interface Config {
|
|
128
|
+
timeout: number;
|
|
129
|
+
retries: number;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const config: Config = { timeout: 1000, retries: 3 };
|
|
133
|
+
blackboard.setPort<Config>("config", config);
|
|
134
|
+
|
|
135
|
+
const retrieved = blackboard.getPort<Config>("config");
|
|
136
|
+
expect(retrieved).toEqual(config);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe("Utility methods", () => {
|
|
141
|
+
it("should return all keys including inherited ones", () => {
|
|
142
|
+
blackboard.set("parent1", "value1");
|
|
143
|
+
blackboard.set("parent2", "value2");
|
|
144
|
+
|
|
145
|
+
const child = blackboard.createScope("child");
|
|
146
|
+
child.set("child1", "value3");
|
|
147
|
+
child.set("parent1", "overridden"); // Override parent key
|
|
148
|
+
|
|
149
|
+
const keys = child.keys();
|
|
150
|
+
expect(keys).toContain("parent1");
|
|
151
|
+
expect(keys).toContain("parent2");
|
|
152
|
+
expect(keys).toContain("child1");
|
|
153
|
+
expect(keys).toHaveLength(3);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("should return entries with local values overriding parent", () => {
|
|
157
|
+
blackboard.set("shared", "parent");
|
|
158
|
+
blackboard.set("parentOnly", "value");
|
|
159
|
+
|
|
160
|
+
const child = blackboard.createScope("child");
|
|
161
|
+
child.set("shared", "child");
|
|
162
|
+
child.set("childOnly", "value");
|
|
163
|
+
|
|
164
|
+
const entries = child.entries();
|
|
165
|
+
const entriesMap = new Map(entries);
|
|
166
|
+
|
|
167
|
+
expect(entriesMap.get("shared")).toBe("child");
|
|
168
|
+
expect(entriesMap.get("parentOnly")).toBe("value");
|
|
169
|
+
expect(entriesMap.get("childOnly")).toBe("value");
|
|
170
|
+
expect(entries).toHaveLength(3);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("should convert to JSON with only local entries", () => {
|
|
174
|
+
blackboard.set("parent", "value");
|
|
175
|
+
|
|
176
|
+
const child = blackboard.createScope("child");
|
|
177
|
+
child.set("child1", "value1");
|
|
178
|
+
child.set("child2", "value2");
|
|
179
|
+
|
|
180
|
+
const json = child.toJSON();
|
|
181
|
+
expect(json).toEqual({
|
|
182
|
+
child1: "value1",
|
|
183
|
+
child2: "value2",
|
|
184
|
+
});
|
|
185
|
+
expect(json).not.toHaveProperty("parent");
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe("Debug utilities", () => {
|
|
190
|
+
it("should not throw when calling debug", () => {
|
|
191
|
+
blackboard.set("key", "value");
|
|
192
|
+
const child = blackboard.createScope("child");
|
|
193
|
+
child.set("childKey", "childValue");
|
|
194
|
+
|
|
195
|
+
// Should not throw
|
|
196
|
+
expect(() => child.debug()).not.toThrow();
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe("Snapshots", () => {
|
|
201
|
+
it("should create independent snapshots with clone", () => {
|
|
202
|
+
blackboard.set("x", 1);
|
|
203
|
+
const snapshot = blackboard.clone();
|
|
204
|
+
blackboard.set("x", 2);
|
|
205
|
+
|
|
206
|
+
expect(snapshot.get("x")).toBe(1);
|
|
207
|
+
expect(blackboard.get("x")).toBe(2);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("should deep clone child scopes", () => {
|
|
211
|
+
blackboard.set("parent", "value");
|
|
212
|
+
const child = blackboard.createScope("child");
|
|
213
|
+
child.set("child", "value");
|
|
214
|
+
|
|
215
|
+
const snapshot = blackboard.clone();
|
|
216
|
+
blackboard.set("parent", "changed");
|
|
217
|
+
|
|
218
|
+
expect(snapshot.get("parent")).toBe("value");
|
|
219
|
+
expect(blackboard.get("parent")).toBe("changed");
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
});
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scoped Blackboard implementation with inheritance
|
|
3
|
+
* Inspired by BehaviorTree.CPP's blackboard
|
|
4
|
+
*
|
|
5
|
+
* Simple mutable API with native deep cloning for snapshots
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { IScopedBlackboard } from "./types.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Implementation of a hierarchical blackboard with scoped inheritance
|
|
12
|
+
* Uses simple mutable operations with snapshot support via structuredClone
|
|
13
|
+
*/
|
|
14
|
+
export class ScopedBlackboard implements IScopedBlackboard {
|
|
15
|
+
private data: Record<string, unknown> = {};
|
|
16
|
+
private parent: ScopedBlackboard | null = null;
|
|
17
|
+
private scopeName: string;
|
|
18
|
+
private childScopes: Map<string, ScopedBlackboard> = new Map();
|
|
19
|
+
|
|
20
|
+
constructor(
|
|
21
|
+
scopeName: string = "root",
|
|
22
|
+
parent: ScopedBlackboard | null = null,
|
|
23
|
+
) {
|
|
24
|
+
this.scopeName = scopeName;
|
|
25
|
+
this.parent = parent;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
get(key: string): unknown {
|
|
29
|
+
// First check local scope
|
|
30
|
+
if (key in this.data) {
|
|
31
|
+
return this.data[key];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Then check parent scopes
|
|
35
|
+
if (this.parent) {
|
|
36
|
+
return this.parent.get(key);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
set(key: string, value: unknown): void {
|
|
43
|
+
this.data[key] = value;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
has(key: string): boolean {
|
|
47
|
+
// Check local scope
|
|
48
|
+
if (key in this.data) {
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Check parent scopes
|
|
53
|
+
if (this.parent) {
|
|
54
|
+
return this.parent.has(key);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
delete(key: string): void {
|
|
61
|
+
// Only delete from local scope
|
|
62
|
+
delete this.data[key];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
clear(): void {
|
|
66
|
+
this.data = {};
|
|
67
|
+
this.childScopes.clear();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
createScope(name: string): IScopedBlackboard {
|
|
71
|
+
// Check if scope already exists
|
|
72
|
+
if (this.childScopes.has(name)) {
|
|
73
|
+
const scope = this.childScopes.get(name);
|
|
74
|
+
if (!scope) {
|
|
75
|
+
throw new Error(`Scope ${name} not found`);
|
|
76
|
+
}
|
|
77
|
+
return scope;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Create new child scope
|
|
81
|
+
const childScope = new ScopedBlackboard(name, this);
|
|
82
|
+
this.childScopes.set(name, childScope);
|
|
83
|
+
return childScope;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
getParentScope(): IScopedBlackboard | null {
|
|
87
|
+
return this.parent;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
getScopeName(): string {
|
|
91
|
+
return this.scopeName;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
getPort<T>(key: string, defaultValue?: T): T {
|
|
95
|
+
const value = this.get(key);
|
|
96
|
+
if (value === undefined && defaultValue !== undefined) {
|
|
97
|
+
return defaultValue;
|
|
98
|
+
}
|
|
99
|
+
return value as T;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
setPort<T>(key: string, value: T): void {
|
|
103
|
+
this.set(key, value);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
keys(): string[] {
|
|
107
|
+
const localKeys = Object.keys(this.data);
|
|
108
|
+
const parentKeys = this.parent ? this.parent.keys() : [];
|
|
109
|
+
|
|
110
|
+
// Combine keys, removing duplicates (local keys override parent keys)
|
|
111
|
+
const allKeys = new Set([...localKeys, ...parentKeys]);
|
|
112
|
+
return Array.from(allKeys);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
entries(): [string, unknown][] {
|
|
116
|
+
const result: [string, unknown][] = [];
|
|
117
|
+
const processedKeys = new Set<string>();
|
|
118
|
+
|
|
119
|
+
// Add local entries first
|
|
120
|
+
for (const [key, value] of Object.entries(this.data)) {
|
|
121
|
+
result.push([key, value]);
|
|
122
|
+
processedKeys.add(key);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Add parent entries that aren't overridden
|
|
126
|
+
if (this.parent) {
|
|
127
|
+
for (const [key, value] of this.parent.entries()) {
|
|
128
|
+
if (!processedKeys.has(key)) {
|
|
129
|
+
result.push([key, value]);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return result;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
toJSON(): Record<string, unknown> {
|
|
138
|
+
// Only return local entries, not inherited ones
|
|
139
|
+
return { ...this.data };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Create a deep clone of this blackboard for snapshots
|
|
144
|
+
* Uses structured cloning for deep copy
|
|
145
|
+
*/
|
|
146
|
+
clone(): IScopedBlackboard {
|
|
147
|
+
// Create a new blackboard instance with the same scope configuration
|
|
148
|
+
const cloned = new ScopedBlackboard(this.scopeName, this.parent);
|
|
149
|
+
|
|
150
|
+
// Deep clone the data object using structuredClone (Node 17+)
|
|
151
|
+
// This creates a true deep copy without freezing the original
|
|
152
|
+
cloned.data = structuredClone(this.data);
|
|
153
|
+
|
|
154
|
+
// Recursively clone child scopes
|
|
155
|
+
this.childScopes.forEach((childScope, name) => {
|
|
156
|
+
cloned.childScopes.set(name, childScope.clone() as ScopedBlackboard);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
return cloned;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Get the full scope path (e.g., "root.child.grandchild")
|
|
164
|
+
*/
|
|
165
|
+
getFullScopePath(): string {
|
|
166
|
+
const path: string[] = [this.scopeName];
|
|
167
|
+
let current = this.parent;
|
|
168
|
+
|
|
169
|
+
while (current) {
|
|
170
|
+
path.unshift(current.scopeName);
|
|
171
|
+
current = current.parent;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return path.join(".");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Debug utility to print the blackboard hierarchy
|
|
179
|
+
*/
|
|
180
|
+
debug(indent: number = 0): void {
|
|
181
|
+
const prefix = " ".repeat(indent);
|
|
182
|
+
console.log(`${prefix}Scope: ${this.scopeName}`);
|
|
183
|
+
|
|
184
|
+
for (const [key, value] of Object.entries(this.data)) {
|
|
185
|
+
console.log(`${prefix} ${key}: ${JSON.stringify(value)}`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
for (const [_name, childScope] of this.childScopes) {
|
|
189
|
+
childScope.debug(indent + 1);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conditional composite configuration schema
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { nodeConfigurationSchema } from "../schemas/base.schema.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Schema for Conditional composite configuration
|
|
10
|
+
* Uses base schema only - condition logic is in child nodes
|
|
11
|
+
*/
|
|
12
|
+
export const conditionalConfigurationSchema = nodeConfigurationSchema;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Validated Conditional configuration type
|
|
16
|
+
*/
|
|
17
|
+
export type ValidatedConditionalConfiguration = z.infer<
|
|
18
|
+
typeof conditionalConfigurationSchema
|
|
19
|
+
>;
|