@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,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for ForEach node
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
6
|
+
import { ScopedBlackboard } from "../blackboard.js";
|
|
7
|
+
import { ConfigurationError } from "../errors.js";
|
|
8
|
+
import { SuccessNode } from "../test-nodes.js";
|
|
9
|
+
import { type TemporalContext, NodeStatus } from "../types.js";
|
|
10
|
+
import { ForEach } from "./for-each.js";
|
|
11
|
+
|
|
12
|
+
describe("ForEach", () => {
|
|
13
|
+
let blackboard: ScopedBlackboard;
|
|
14
|
+
let context: TemporalContext;
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
blackboard = new ScopedBlackboard("root");
|
|
18
|
+
context = {
|
|
19
|
+
blackboard,
|
|
20
|
+
timestamp: Date.now(),
|
|
21
|
+
deltaTime: 0,
|
|
22
|
+
};
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("Basic Functionality", () => {
|
|
26
|
+
it("should iterate over collection", async () => {
|
|
27
|
+
const forEach = new ForEach({
|
|
28
|
+
id: "forEach1",
|
|
29
|
+
collectionKey: "items",
|
|
30
|
+
itemKey: "currentItem",
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const items = ["a", "b", "c"];
|
|
34
|
+
blackboard.set("items", items);
|
|
35
|
+
|
|
36
|
+
const processedItems: string[] = [];
|
|
37
|
+
class RecordingNode extends SuccessNode {
|
|
38
|
+
async tick(context: TemporalContext): Promise<NodeStatus> {
|
|
39
|
+
const superTick = super.tick.bind(this);
|
|
40
|
+
const item = context.blackboard.get("currentItem");
|
|
41
|
+
processedItems.push(item);
|
|
42
|
+
return await superTick(context);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
forEach.addChild(new RecordingNode({ id: "body" }));
|
|
47
|
+
|
|
48
|
+
const result = await forEach.tick(context);
|
|
49
|
+
expect(result).toBe(NodeStatus.SUCCESS);
|
|
50
|
+
expect(processedItems).toEqual(["a", "b", "c"]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("should set item and index in blackboard", async () => {
|
|
54
|
+
const forEach = new ForEach({
|
|
55
|
+
id: "forEach1",
|
|
56
|
+
collectionKey: "numbers",
|
|
57
|
+
itemKey: "num",
|
|
58
|
+
indexKey: "i",
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
blackboard.set("numbers", [10, 20, 30]);
|
|
62
|
+
|
|
63
|
+
const recorded: Array<{ item: number; index: number }> = [];
|
|
64
|
+
class RecordingNode extends SuccessNode {
|
|
65
|
+
async tick(context: TemporalContext): Promise<NodeStatus> {
|
|
66
|
+
const superTick = super.tick.bind(this);
|
|
67
|
+
recorded.push({
|
|
68
|
+
item: context.blackboard.get("num"),
|
|
69
|
+
index: context.blackboard.get("i"),
|
|
70
|
+
});
|
|
71
|
+
return await superTick(context);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
forEach.addChild(new RecordingNode({ id: "body" }));
|
|
76
|
+
|
|
77
|
+
await forEach.tick(context);
|
|
78
|
+
|
|
79
|
+
expect(recorded).toEqual([
|
|
80
|
+
{ item: 10, index: 0 },
|
|
81
|
+
{ item: 20, index: 1 },
|
|
82
|
+
{ item: 30, index: 2 },
|
|
83
|
+
]);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("Failure Handling", () => {
|
|
88
|
+
it("should fail on first failure", async () => {
|
|
89
|
+
const forEach = new ForEach({
|
|
90
|
+
id: "forEach1",
|
|
91
|
+
collectionKey: "items",
|
|
92
|
+
itemKey: "item",
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
blackboard.set("items", ["a", "b", "c"]);
|
|
96
|
+
|
|
97
|
+
let tickCount = 0;
|
|
98
|
+
class FailOnSecond extends SuccessNode {
|
|
99
|
+
async tick(context: TemporalContext): Promise<NodeStatus> {
|
|
100
|
+
const superTick = super.tick.bind(this);
|
|
101
|
+
tickCount++;
|
|
102
|
+
if (tickCount === 2) {
|
|
103
|
+
this._status = NodeStatus.FAILURE;
|
|
104
|
+
return NodeStatus.FAILURE;
|
|
105
|
+
}
|
|
106
|
+
return await superTick(context);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
forEach.addChild(new FailOnSecond({ id: "body" }));
|
|
111
|
+
|
|
112
|
+
const result = await forEach.tick(context);
|
|
113
|
+
expect(result).toBe(NodeStatus.FAILURE);
|
|
114
|
+
expect(tickCount).toBe(2); // Only processed 2 items
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("should fail if collection not found", async () => {
|
|
118
|
+
const forEach = new ForEach({
|
|
119
|
+
id: "forEach1",
|
|
120
|
+
collectionKey: "missing",
|
|
121
|
+
itemKey: "item",
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
forEach.addChild(new SuccessNode({ id: "body" }));
|
|
125
|
+
|
|
126
|
+
const result = await forEach.tick(context);
|
|
127
|
+
expect(result).toBe(NodeStatus.FAILURE);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("should return FAILURE if collection is not an array", async () => {
|
|
131
|
+
const forEach = new ForEach({
|
|
132
|
+
id: "forEach1",
|
|
133
|
+
collectionKey: "notArray",
|
|
134
|
+
itemKey: "item",
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
blackboard.set("notArray", "not an array");
|
|
138
|
+
forEach.addChild(new SuccessNode({ id: "body" }));
|
|
139
|
+
|
|
140
|
+
const status = await forEach.tick(context);
|
|
141
|
+
expect(status).toBe(NodeStatus.FAILURE);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("RUNNING State", () => {
|
|
146
|
+
it("should resume from saved index on RUNNING", async () => {
|
|
147
|
+
const forEach = new ForEach({
|
|
148
|
+
id: "forEach1",
|
|
149
|
+
collectionKey: "items",
|
|
150
|
+
itemKey: "item",
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
blackboard.set("items", ["a", "b", "c"]);
|
|
154
|
+
|
|
155
|
+
let tickCount = 0;
|
|
156
|
+
class RunningOnSecond extends SuccessNode {
|
|
157
|
+
async tick(context: TemporalContext): Promise<NodeStatus> {
|
|
158
|
+
const superTick = super.tick.bind(this);
|
|
159
|
+
tickCount++;
|
|
160
|
+
if (tickCount === 2) {
|
|
161
|
+
this._status = NodeStatus.RUNNING;
|
|
162
|
+
return NodeStatus.RUNNING;
|
|
163
|
+
}
|
|
164
|
+
return await superTick(context);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const body = new RunningOnSecond({ id: "body" });
|
|
169
|
+
forEach.addChild(body);
|
|
170
|
+
|
|
171
|
+
// First tick: processes item 0, returns RUNNING on item 1
|
|
172
|
+
let result = await forEach.tick(context);
|
|
173
|
+
expect(result).toBe(NodeStatus.RUNNING);
|
|
174
|
+
expect(tickCount).toBe(2);
|
|
175
|
+
|
|
176
|
+
// Second tick: should resume from item 1
|
|
177
|
+
result = await forEach.tick(context);
|
|
178
|
+
expect(result).toBe(NodeStatus.SUCCESS);
|
|
179
|
+
expect(tickCount).toBe(4); // item 1 (again), item 2
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("Edge Cases", () => {
|
|
184
|
+
it("should return SUCCESS for empty collection", async () => {
|
|
185
|
+
const forEach = new ForEach({
|
|
186
|
+
id: "forEach1",
|
|
187
|
+
collectionKey: "empty",
|
|
188
|
+
itemKey: "item",
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
blackboard.set("empty", []);
|
|
192
|
+
forEach.addChild(new SuccessNode({ id: "body" }));
|
|
193
|
+
|
|
194
|
+
const result = await forEach.tick(context);
|
|
195
|
+
expect(result).toBe(NodeStatus.SUCCESS);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("should reset index on success", async () => {
|
|
199
|
+
const forEach = new ForEach({
|
|
200
|
+
id: "forEach1",
|
|
201
|
+
collectionKey: "items",
|
|
202
|
+
itemKey: "item",
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
blackboard.set("items", ["a", "b"]);
|
|
206
|
+
|
|
207
|
+
let firstRunCount = 0;
|
|
208
|
+
let secondRunCount = 0;
|
|
209
|
+
|
|
210
|
+
class CountingNode extends SuccessNode {
|
|
211
|
+
async tick(context: TemporalContext): Promise<NodeStatus> {
|
|
212
|
+
const superTick = super.tick.bind(this);
|
|
213
|
+
if (secondRunCount > 0) {
|
|
214
|
+
secondRunCount++;
|
|
215
|
+
} else {
|
|
216
|
+
firstRunCount++;
|
|
217
|
+
}
|
|
218
|
+
return await superTick(context);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
forEach.addChild(new CountingNode({ id: "body" }));
|
|
223
|
+
|
|
224
|
+
// First execution
|
|
225
|
+
await forEach.tick(context);
|
|
226
|
+
expect(firstRunCount).toBe(2);
|
|
227
|
+
|
|
228
|
+
// Second execution should start from beginning
|
|
229
|
+
secondRunCount = 1;
|
|
230
|
+
await forEach.tick(context);
|
|
231
|
+
expect(secondRunCount).toBe(3); // Initial 1 + 2 items
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("should propagate ConfigurationError with no child", async () => {
|
|
235
|
+
const forEach = new ForEach({
|
|
236
|
+
id: "forEach1",
|
|
237
|
+
collectionKey: "items",
|
|
238
|
+
itemKey: "item",
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
blackboard.set("items", ["a"]);
|
|
242
|
+
|
|
243
|
+
try {
|
|
244
|
+
await forEach.tick(context);
|
|
245
|
+
expect.fail("Should have thrown ConfigurationError");
|
|
246
|
+
} catch (error) {
|
|
247
|
+
expect(error).toBeInstanceOf(ConfigurationError);
|
|
248
|
+
expect((error as ConfigurationError).message).toContain(
|
|
249
|
+
"ForEach requires at least one child",
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ForEach node - Iterate over collection
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { CompositeNode } from "../base-node.js";
|
|
6
|
+
import { ConfigurationError } from "../errors.js";
|
|
7
|
+
import {
|
|
8
|
+
type TemporalContext,
|
|
9
|
+
type NodeConfiguration,
|
|
10
|
+
NodeStatus,
|
|
11
|
+
} from "../types.js";
|
|
12
|
+
import { checkSignal } from "../utils/signal-check.js";
|
|
13
|
+
|
|
14
|
+
export interface ForEachConfiguration extends NodeConfiguration {
|
|
15
|
+
collectionKey: string; // Blackboard key for array
|
|
16
|
+
itemKey: string; // Blackboard key for current item
|
|
17
|
+
indexKey?: string; // Blackboard key for current index
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* ForEach iterates over a collection from the blackboard.
|
|
22
|
+
* For each item, it sets the item (and optionally index) in the blackboard
|
|
23
|
+
* and executes the body (first child).
|
|
24
|
+
*/
|
|
25
|
+
export class ForEach extends CompositeNode {
|
|
26
|
+
private collectionKey: string;
|
|
27
|
+
private itemKey: string;
|
|
28
|
+
private indexKey?: string;
|
|
29
|
+
private currentIndex: number = 0;
|
|
30
|
+
|
|
31
|
+
constructor(config: ForEachConfiguration) {
|
|
32
|
+
super(config);
|
|
33
|
+
this.collectionKey = config.collectionKey;
|
|
34
|
+
this.itemKey = config.itemKey;
|
|
35
|
+
this.indexKey = config.indexKey;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async executeTick(context: TemporalContext): Promise<NodeStatus> {
|
|
39
|
+
if (this._children.length === 0) {
|
|
40
|
+
throw new ConfigurationError(
|
|
41
|
+
"ForEach requires at least one child (body)",
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const body = this._children[0];
|
|
46
|
+
if (!body) {
|
|
47
|
+
throw new ConfigurationError(
|
|
48
|
+
"ForEach requires at least one child (body)",
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const collection = context.blackboard.get(this.collectionKey);
|
|
53
|
+
|
|
54
|
+
if (!collection) {
|
|
55
|
+
this.log(`Collection '${this.collectionKey}' not found in blackboard`);
|
|
56
|
+
this._status = NodeStatus.FAILURE;
|
|
57
|
+
return NodeStatus.FAILURE;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (!Array.isArray(collection)) {
|
|
61
|
+
throw new Error(`Collection '${this.collectionKey}' is not an array`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Empty collection is success
|
|
65
|
+
if (collection.length === 0) {
|
|
66
|
+
this.log("Collection is empty - returning SUCCESS");
|
|
67
|
+
this._status = NodeStatus.SUCCESS;
|
|
68
|
+
return NodeStatus.SUCCESS;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
this.log(
|
|
72
|
+
`Iterating over collection (${collection.length} items), starting at index ${this.currentIndex}`,
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
// Continue from where we left off
|
|
76
|
+
while (this.currentIndex < collection.length) {
|
|
77
|
+
// Check for cancellation before processing each item
|
|
78
|
+
checkSignal(context.signal);
|
|
79
|
+
|
|
80
|
+
const item = collection[this.currentIndex];
|
|
81
|
+
|
|
82
|
+
// Set current item and index in blackboard
|
|
83
|
+
context.blackboard.set(this.itemKey, item);
|
|
84
|
+
if (this.indexKey) {
|
|
85
|
+
context.blackboard.set(this.indexKey, this.currentIndex);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
this.log(
|
|
89
|
+
`Processing item ${this.currentIndex}: ${JSON.stringify(item)}`,
|
|
90
|
+
);
|
|
91
|
+
const bodyStatus = await body.tick(context);
|
|
92
|
+
|
|
93
|
+
switch (bodyStatus) {
|
|
94
|
+
case NodeStatus.SUCCESS:
|
|
95
|
+
this.log(`Item ${this.currentIndex} succeeded`);
|
|
96
|
+
this.currentIndex++;
|
|
97
|
+
body.reset(); // Reset for next iteration
|
|
98
|
+
break;
|
|
99
|
+
|
|
100
|
+
case NodeStatus.FAILURE:
|
|
101
|
+
this.log(`Item ${this.currentIndex} failed - ForEach fails`);
|
|
102
|
+
this._status = NodeStatus.FAILURE;
|
|
103
|
+
this.currentIndex = 0; // Reset for next tick
|
|
104
|
+
return NodeStatus.FAILURE;
|
|
105
|
+
|
|
106
|
+
case NodeStatus.RUNNING:
|
|
107
|
+
this.log(`Item ${this.currentIndex} is running`);
|
|
108
|
+
this._status = NodeStatus.RUNNING;
|
|
109
|
+
return NodeStatus.RUNNING; // Will resume from this index next tick
|
|
110
|
+
|
|
111
|
+
default:
|
|
112
|
+
throw new Error(`Unexpected status from body: ${bodyStatus}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// All items processed successfully
|
|
117
|
+
this.log("All items processed successfully");
|
|
118
|
+
this._status = NodeStatus.SUCCESS;
|
|
119
|
+
this.currentIndex = 0; // Reset for next tick
|
|
120
|
+
return NodeStatus.SUCCESS;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
protected onReset(): void {
|
|
124
|
+
super.onReset();
|
|
125
|
+
this.currentIndex = 0;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
protected onHalt(): void {
|
|
129
|
+
super.onHalt();
|
|
130
|
+
this.currentIndex = 0;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Export all composite nodes
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export { Conditional } from "./conditional.js";
|
|
6
|
+
export { ForEach } from "./for-each.js";
|
|
7
|
+
export { MemorySequence, SequenceWithMemory } from "./memory-sequence.js";
|
|
8
|
+
export { Parallel } from "./parallel.js";
|
|
9
|
+
export type { ParallelStrategy } from "./parallel.js";
|
|
10
|
+
export { ReactiveSequence } from "./reactive-sequence.js";
|
|
11
|
+
export { Recovery } from "./recovery.js";
|
|
12
|
+
export { Selector, Fallback } from "./selector.js";
|
|
13
|
+
export { Sequence } from "./sequence.js";
|
|
14
|
+
export { SubTree } from "./sub-tree.js";
|
|
15
|
+
export { While } from "./while.js";
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MemorySequence composite configuration schema
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { nodeConfigurationSchema } from "../schemas/base.schema.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Schema for MemorySequence composite configuration
|
|
10
|
+
* Uses base schema only (no additional properties)
|
|
11
|
+
*/
|
|
12
|
+
export const memorySequenceConfigurationSchema = nodeConfigurationSchema;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Validated MemorySequence configuration type
|
|
16
|
+
*/
|
|
17
|
+
export type ValidatedMemorySequenceConfiguration = z.infer<
|
|
18
|
+
typeof memorySequenceConfigurationSchema
|
|
19
|
+
>;
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for MemorySequence node
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
6
|
+
import { ScopedBlackboard } from "../blackboard.js";
|
|
7
|
+
import { FailureNode, RunningNode, SuccessNode } from "../test-nodes.js";
|
|
8
|
+
import { type TemporalContext, NodeStatus } from "../types.js";
|
|
9
|
+
import { MemorySequence, SequenceWithMemory } from "./memory-sequence.js";
|
|
10
|
+
|
|
11
|
+
describe("MemorySequence", () => {
|
|
12
|
+
let blackboard: ScopedBlackboard;
|
|
13
|
+
let context: TemporalContext;
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
blackboard = new ScopedBlackboard("root");
|
|
17
|
+
context = {
|
|
18
|
+
blackboard,
|
|
19
|
+
timestamp: Date.now(),
|
|
20
|
+
deltaTime: 0,
|
|
21
|
+
};
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("Basic Functionality", () => {
|
|
25
|
+
it("should execute children in order", async () => {
|
|
26
|
+
const seq = new MemorySequence({ id: "seq1" });
|
|
27
|
+
const child1 = new SuccessNode({ id: "child1" });
|
|
28
|
+
const child2 = new SuccessNode({ id: "child2" });
|
|
29
|
+
const child3 = new SuccessNode({ id: "child3" });
|
|
30
|
+
|
|
31
|
+
seq.addChildren([child1, child2, child3]);
|
|
32
|
+
|
|
33
|
+
const result = await seq.tick(context);
|
|
34
|
+
expect(result).toBe(NodeStatus.SUCCESS);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("should fail fast on first failure", async () => {
|
|
38
|
+
const seq = new MemorySequence({ id: "seq1" });
|
|
39
|
+
const child1 = new SuccessNode({ id: "child1" });
|
|
40
|
+
const child2 = new FailureNode({ id: "child2" });
|
|
41
|
+
const child3 = new SuccessNode({ id: "child3" });
|
|
42
|
+
|
|
43
|
+
seq.addChildren([child1, child2, child3]);
|
|
44
|
+
|
|
45
|
+
const result = await seq.tick(context);
|
|
46
|
+
expect(result).toBe(NodeStatus.FAILURE);
|
|
47
|
+
expect(child3.status()).toBe(NodeStatus.IDLE); // Never executed
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("Memory Behavior", () => {
|
|
52
|
+
it("should skip completed children on retry after failure", async () => {
|
|
53
|
+
const seq = new MemorySequence({ id: "seq1" });
|
|
54
|
+
|
|
55
|
+
// Track execution count for each child
|
|
56
|
+
let child1Ticks = 0;
|
|
57
|
+
let child2Ticks = 0;
|
|
58
|
+
let child3Ticks = 0;
|
|
59
|
+
|
|
60
|
+
class CountingSuccess extends SuccessNode {
|
|
61
|
+
async tick(context: TemporalContext): Promise<NodeStatus> {
|
|
62
|
+
const superTick = super.tick.bind(this);
|
|
63
|
+
if (this.id === "child1") child1Ticks++;
|
|
64
|
+
if (this.id === "child2") child2Ticks++;
|
|
65
|
+
if (this.id === "child3") child3Ticks++;
|
|
66
|
+
return await superTick(context);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const child1 = new CountingSuccess({ id: "child1" });
|
|
71
|
+
const child2 = new CountingSuccess({ id: "child2" });
|
|
72
|
+
|
|
73
|
+
// Child3 fails first time, succeeds second
|
|
74
|
+
let child3TickCount = 0;
|
|
75
|
+
class FlipFlopNode extends SuccessNode {
|
|
76
|
+
async tick(context: TemporalContext): Promise<NodeStatus> {
|
|
77
|
+
const superTick = super.tick.bind(this);
|
|
78
|
+
child3TickCount++;
|
|
79
|
+
child3Ticks++;
|
|
80
|
+
// Fail first time, succeed second
|
|
81
|
+
if (child3TickCount === 1) {
|
|
82
|
+
this._status = NodeStatus.FAILURE;
|
|
83
|
+
return NodeStatus.FAILURE;
|
|
84
|
+
}
|
|
85
|
+
return await superTick(context);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const child3 = new FlipFlopNode({ id: "child3" });
|
|
90
|
+
|
|
91
|
+
seq.addChildren([child1, child2, child3]);
|
|
92
|
+
|
|
93
|
+
// First tick: child1 and child2 succeed, child3 fails
|
|
94
|
+
let result = await seq.tick(context);
|
|
95
|
+
expect(result).toBe(NodeStatus.FAILURE);
|
|
96
|
+
expect(child1Ticks).toBe(1);
|
|
97
|
+
expect(child2Ticks).toBe(1);
|
|
98
|
+
expect(child3Ticks).toBe(1);
|
|
99
|
+
|
|
100
|
+
// Second tick: should skip child1 and child2, go straight to child3
|
|
101
|
+
result = await seq.tick(context);
|
|
102
|
+
expect(result).toBe(NodeStatus.SUCCESS);
|
|
103
|
+
expect(child1Ticks).toBe(1); // Not re-executed
|
|
104
|
+
expect(child2Ticks).toBe(1); // Not re-executed
|
|
105
|
+
expect(child3Ticks).toBe(2); // Re-executed (not completed before)
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("should remember successful children across ticks", async () => {
|
|
109
|
+
const seq = new MemorySequence({ id: "seq1" });
|
|
110
|
+
|
|
111
|
+
const child1 = new SuccessNode({ id: "child1" });
|
|
112
|
+
|
|
113
|
+
// Child2 returns RUNNING first time, SUCCESS second time
|
|
114
|
+
let child2TickCount = 0;
|
|
115
|
+
class TwoTickNode extends SuccessNode {
|
|
116
|
+
async tick(context: TemporalContext): Promise<NodeStatus> {
|
|
117
|
+
const superTick = super.tick.bind(this);
|
|
118
|
+
child2TickCount++;
|
|
119
|
+
if (child2TickCount === 1) {
|
|
120
|
+
this._status = NodeStatus.RUNNING;
|
|
121
|
+
return NodeStatus.RUNNING;
|
|
122
|
+
}
|
|
123
|
+
return await superTick(context);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const child2 = new TwoTickNode({ id: "child2" });
|
|
128
|
+
const child3 = new SuccessNode({ id: "child3" });
|
|
129
|
+
|
|
130
|
+
seq.addChildren([child1, child2, child3]);
|
|
131
|
+
|
|
132
|
+
// First tick: child1 succeeds, child2 returns RUNNING
|
|
133
|
+
let result = await seq.tick(context);
|
|
134
|
+
expect(result).toBe(NodeStatus.RUNNING);
|
|
135
|
+
expect(child1.status()).toBe(NodeStatus.SUCCESS);
|
|
136
|
+
expect(child2.status()).toBe(NodeStatus.RUNNING);
|
|
137
|
+
expect(child3.status()).toBe(NodeStatus.IDLE);
|
|
138
|
+
|
|
139
|
+
// Second tick: should skip child1, continue from child2 which now succeeds
|
|
140
|
+
result = await seq.tick(context);
|
|
141
|
+
expect(result).toBe(NodeStatus.SUCCESS);
|
|
142
|
+
expect(child2TickCount).toBe(2);
|
|
143
|
+
expect(child3.status()).toBe(NodeStatus.SUCCESS);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("should not skip RUNNING children", async () => {
|
|
147
|
+
const seq = new MemorySequence({ id: "seq1" });
|
|
148
|
+
|
|
149
|
+
const child1 = new SuccessNode({ id: "child1" });
|
|
150
|
+
const child2 = new RunningNode({ id: "child2" });
|
|
151
|
+
|
|
152
|
+
seq.addChildren([child1, child2]);
|
|
153
|
+
|
|
154
|
+
// First tick: child1 succeeds, child2 running
|
|
155
|
+
let result = await seq.tick(context);
|
|
156
|
+
expect(result).toBe(NodeStatus.RUNNING);
|
|
157
|
+
|
|
158
|
+
// Second tick: child2 still running, should be ticked again
|
|
159
|
+
result = await seq.tick(context);
|
|
160
|
+
expect(result).toBe(NodeStatus.RUNNING);
|
|
161
|
+
expect(child2.status()).toBe(NodeStatus.RUNNING);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("should clear memory on reset", async () => {
|
|
165
|
+
const seq = new MemorySequence({ id: "seq1" });
|
|
166
|
+
|
|
167
|
+
let child1Ticks = 0;
|
|
168
|
+
class CountingSuccess extends SuccessNode {
|
|
169
|
+
async tick(context: TemporalContext): Promise<NodeStatus> {
|
|
170
|
+
const superTick = super.tick.bind(this);
|
|
171
|
+
child1Ticks++;
|
|
172
|
+
return await superTick(context);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const child1 = new CountingSuccess({ id: "child1" });
|
|
177
|
+
const child2 = new FailureNode({ id: "child2" });
|
|
178
|
+
|
|
179
|
+
seq.addChildren([child1, child2]);
|
|
180
|
+
|
|
181
|
+
// First execution: child1 succeeds, child2 fails
|
|
182
|
+
await seq.tick(context);
|
|
183
|
+
expect(child1Ticks).toBe(1);
|
|
184
|
+
|
|
185
|
+
// Reset should clear memory
|
|
186
|
+
seq.reset();
|
|
187
|
+
|
|
188
|
+
// Second execution: child1 should be re-executed
|
|
189
|
+
await seq.tick(context);
|
|
190
|
+
expect(child1Ticks).toBe(2);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe("Edge Cases", () => {
|
|
195
|
+
it("should handle empty children array", async () => {
|
|
196
|
+
const seq = new MemorySequence({ id: "seq1" });
|
|
197
|
+
const result = await seq.tick(context);
|
|
198
|
+
expect(result).toBe(NodeStatus.SUCCESS);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("should handle single child", async () => {
|
|
202
|
+
const seq = new MemorySequence({ id: "seq1" });
|
|
203
|
+
const child = new SuccessNode({ id: "child1" });
|
|
204
|
+
seq.addChild(child);
|
|
205
|
+
|
|
206
|
+
const result = await seq.tick(context);
|
|
207
|
+
expect(result).toBe(NodeStatus.SUCCESS);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe("Alias", () => {
|
|
212
|
+
it("should work with SequenceWithMemory alias", async () => {
|
|
213
|
+
const seq = new SequenceWithMemory({ id: "seq1" });
|
|
214
|
+
const child1 = new SuccessNode({ id: "child1" });
|
|
215
|
+
const child2 = new SuccessNode({ id: "child2" });
|
|
216
|
+
|
|
217
|
+
seq.addChildren([child1, child2]);
|
|
218
|
+
|
|
219
|
+
const result = await seq.tick(context);
|
|
220
|
+
expect(result).toBe(NodeStatus.SUCCESS);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
});
|