@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,366 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for Recovery 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 { FailureNode, SuccessNode } from "../test-nodes.js";
|
|
9
|
+
import { type TemporalContext, NodeStatus } from "../types.js";
|
|
10
|
+
import { Recovery } from "./recovery.js";
|
|
11
|
+
|
|
12
|
+
describe("Recovery", () => {
|
|
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("Try-Catch Logic", () => {
|
|
26
|
+
it("should return try result on success", async () => {
|
|
27
|
+
const recovery = new Recovery({ id: "recovery1" });
|
|
28
|
+
recovery.addChild(new SuccessNode({ id: "try" }));
|
|
29
|
+
recovery.addChild(new FailureNode({ id: "catch" }));
|
|
30
|
+
|
|
31
|
+
const result = await recovery.tick(context);
|
|
32
|
+
expect(result).toBe(NodeStatus.SUCCESS);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("should execute catch on try failure", async () => {
|
|
36
|
+
const recovery = new Recovery({ id: "recovery1" });
|
|
37
|
+
|
|
38
|
+
let catchExecuted = false;
|
|
39
|
+
class CatchTracker extends SuccessNode {
|
|
40
|
+
async tick(context: TemporalContext) {
|
|
41
|
+
catchExecuted = true;
|
|
42
|
+
return await super.tick(context);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
recovery.addChild(new FailureNode({ id: "try" }));
|
|
47
|
+
recovery.addChild(new CatchTracker({ id: "catch" }));
|
|
48
|
+
|
|
49
|
+
const result = await recovery.tick(context);
|
|
50
|
+
expect(result).toBe(NodeStatus.SUCCESS);
|
|
51
|
+
expect(catchExecuted).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("should propagate catch result on try failure", async () => {
|
|
55
|
+
const recovery = new Recovery({ id: "recovery1" });
|
|
56
|
+
recovery.addChild(new FailureNode({ id: "try" }));
|
|
57
|
+
recovery.addChild(new FailureNode({ id: "catch" }));
|
|
58
|
+
|
|
59
|
+
const result = await recovery.tick(context);
|
|
60
|
+
expect(result).toBe(NodeStatus.FAILURE);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("should handle thrown errors with catch", async () => {
|
|
64
|
+
const recovery = new Recovery({ id: "recovery1" });
|
|
65
|
+
|
|
66
|
+
class ThrowingNode extends SuccessNode {
|
|
67
|
+
async executeTick(_context: TemporalContext) {
|
|
68
|
+
throw new Error("Test error");
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
recovery.addChild(new ThrowingNode({ id: "try" }));
|
|
73
|
+
recovery.addChild(new SuccessNode({ id: "catch" }));
|
|
74
|
+
|
|
75
|
+
// With error handling, errors are converted to FAILURE status
|
|
76
|
+
// Recovery executes catch branch when try fails, and catch succeeds
|
|
77
|
+
const status = await recovery.tick(context);
|
|
78
|
+
expect(status).toBe(NodeStatus.SUCCESS); // Catch branch succeeds
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("should return FAILURE on thrown error without catch", async () => {
|
|
82
|
+
const recovery = new Recovery({ id: "recovery1" });
|
|
83
|
+
|
|
84
|
+
class ThrowingNode extends SuccessNode {
|
|
85
|
+
async executeTick(_context: TemporalContext) {
|
|
86
|
+
throw new Error("Test error");
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
recovery.addChild(new ThrowingNode({ id: "try" }));
|
|
91
|
+
|
|
92
|
+
// With our changes, errors are caught and converted to FAILURE status
|
|
93
|
+
// So recovery will complete with FAILURE status (no catch branch)
|
|
94
|
+
const status = await recovery.tick(context);
|
|
95
|
+
expect(status).toBe(NodeStatus.FAILURE);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("Try-Finally Logic", () => {
|
|
100
|
+
it("should execute finally after successful try", async () => {
|
|
101
|
+
const recovery = new Recovery({ id: "recovery1" });
|
|
102
|
+
|
|
103
|
+
let finallyExecuted = false;
|
|
104
|
+
class FinallyTracker extends SuccessNode {
|
|
105
|
+
async tick(context: TemporalContext) {
|
|
106
|
+
finallyExecuted = true;
|
|
107
|
+
return await super.tick(context);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Structure: try, catch (pass-through), finally
|
|
112
|
+
recovery.addChild(new SuccessNode({ id: "try" }));
|
|
113
|
+
recovery.addChild(new SuccessNode({ id: "catch" })); // No-op catch
|
|
114
|
+
recovery.addChild(new FinallyTracker({ id: "finally" }));
|
|
115
|
+
|
|
116
|
+
await recovery.tick(context);
|
|
117
|
+
expect(finallyExecuted).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("should execute finally after failed try", async () => {
|
|
121
|
+
const recovery = new Recovery({ id: "recovery1" });
|
|
122
|
+
|
|
123
|
+
let finallyExecuted = false;
|
|
124
|
+
class FinallyTracker extends SuccessNode {
|
|
125
|
+
async tick(context: TemporalContext) {
|
|
126
|
+
finallyExecuted = true;
|
|
127
|
+
return await super.tick(context);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Structure: try, catch, finally
|
|
132
|
+
recovery.addChild(new FailureNode({ id: "try" }));
|
|
133
|
+
recovery.addChild(new SuccessNode({ id: "catch" })); // Catch recovers
|
|
134
|
+
recovery.addChild(new FinallyTracker({ id: "finally" }));
|
|
135
|
+
|
|
136
|
+
await recovery.tick(context);
|
|
137
|
+
expect(finallyExecuted).toBe(true);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("should not change result if finally fails", async () => {
|
|
141
|
+
const recovery = new Recovery({ id: "recovery1" });
|
|
142
|
+
|
|
143
|
+
recovery.addChild(new SuccessNode({ id: "try" }));
|
|
144
|
+
recovery.addChild(new SuccessNode({ id: "catch" })); // No-op catch
|
|
145
|
+
recovery.addChild(new FailureNode({ id: "finally" }));
|
|
146
|
+
|
|
147
|
+
const result = await recovery.tick(context);
|
|
148
|
+
expect(result).toBe(NodeStatus.SUCCESS); // Try result, not finally
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe("Try-Catch-Finally Logic", () => {
|
|
153
|
+
it("should execute all branches in order", async () => {
|
|
154
|
+
const recovery = new Recovery({ id: "recovery1" });
|
|
155
|
+
|
|
156
|
+
const executionOrder: string[] = [];
|
|
157
|
+
|
|
158
|
+
class TryTracker extends FailureNode {
|
|
159
|
+
async tick(context: TemporalContext) {
|
|
160
|
+
executionOrder.push("try");
|
|
161
|
+
return await super.tick(context);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
class CatchTracker extends SuccessNode {
|
|
166
|
+
async tick(context: TemporalContext) {
|
|
167
|
+
executionOrder.push("catch");
|
|
168
|
+
return await super.tick(context);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
class FinallyTracker extends SuccessNode {
|
|
173
|
+
async tick(context: TemporalContext) {
|
|
174
|
+
executionOrder.push("finally");
|
|
175
|
+
return await super.tick(context);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
recovery.addChild(new TryTracker({ id: "try" }));
|
|
180
|
+
recovery.addChild(new CatchTracker({ id: "catch" }));
|
|
181
|
+
recovery.addChild(new FinallyTracker({ id: "finally" }));
|
|
182
|
+
|
|
183
|
+
await recovery.tick(context);
|
|
184
|
+
expect(executionOrder).toEqual(["try", "catch", "finally"]);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("should execute finally even if catch fails", async () => {
|
|
188
|
+
const recovery = new Recovery({ id: "recovery1" });
|
|
189
|
+
|
|
190
|
+
let finallyExecuted = false;
|
|
191
|
+
class FinallyTracker extends SuccessNode {
|
|
192
|
+
async tick(context: TemporalContext) {
|
|
193
|
+
finallyExecuted = true;
|
|
194
|
+
return await super.tick(context);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
recovery.addChild(new FailureNode({ id: "try" }));
|
|
199
|
+
recovery.addChild(new FailureNode({ id: "catch" }));
|
|
200
|
+
recovery.addChild(new FinallyTracker({ id: "finally" }));
|
|
201
|
+
|
|
202
|
+
await recovery.tick(context);
|
|
203
|
+
expect(finallyExecuted).toBe(true);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("should execute finally even on thrown errors", async () => {
|
|
207
|
+
const recovery = new Recovery({ id: "recovery1" });
|
|
208
|
+
|
|
209
|
+
let _finallyExecuted = false;
|
|
210
|
+
|
|
211
|
+
class ThrowingNode extends SuccessNode {
|
|
212
|
+
async executeTick(_context: TemporalContext) {
|
|
213
|
+
throw new Error("Test error");
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
class FinallyTracker extends SuccessNode {
|
|
218
|
+
async tick(context: TemporalContext) {
|
|
219
|
+
_finallyExecuted = true;
|
|
220
|
+
return await super.tick(context);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
recovery.addChild(new ThrowingNode({ id: "try" }));
|
|
225
|
+
recovery.addChild(new SuccessNode({ id: "catch" }));
|
|
226
|
+
recovery.addChild(new FinallyTracker({ id: "finally" }));
|
|
227
|
+
|
|
228
|
+
// With our changes, errors are caught and converted to FAILURE status
|
|
229
|
+
// Recovery executes catch branch when try fails, catch succeeds, so recovery returns SUCCESS
|
|
230
|
+
const status = await recovery.tick(context);
|
|
231
|
+
expect(status).toBe(NodeStatus.SUCCESS); // Catch branch succeeds
|
|
232
|
+
// Finally should execute even when try branch fails
|
|
233
|
+
expect(_finallyExecuted).toBe(true);
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
describe("Edge Cases", () => {
|
|
238
|
+
it("should enforce maximum 3 children", () => {
|
|
239
|
+
const recovery = new Recovery({ id: "recovery1" });
|
|
240
|
+
recovery.addChild(new SuccessNode({ id: "try" }));
|
|
241
|
+
recovery.addChild(new SuccessNode({ id: "catch" }));
|
|
242
|
+
recovery.addChild(new SuccessNode({ id: "finally" }));
|
|
243
|
+
|
|
244
|
+
expect(() => {
|
|
245
|
+
recovery.addChild(new SuccessNode({ id: "extra" }));
|
|
246
|
+
}).toThrow("Recovery can have maximum 3 children");
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("should propagate ConfigurationError without try branch", async () => {
|
|
250
|
+
const recovery = new Recovery({ id: "recovery1" });
|
|
251
|
+
|
|
252
|
+
try {
|
|
253
|
+
await recovery.tick(context);
|
|
254
|
+
expect.fail("Should have thrown an error");
|
|
255
|
+
} catch (error) {
|
|
256
|
+
expect(error).toBeInstanceOf(ConfigurationError);
|
|
257
|
+
expect((error as ConfigurationError).message).toContain(
|
|
258
|
+
"Recovery requires at least a try branch",
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("should work with only try branch", async () => {
|
|
264
|
+
const recovery = new Recovery({ id: "recovery1" });
|
|
265
|
+
recovery.addChild(new SuccessNode({ id: "try" }));
|
|
266
|
+
|
|
267
|
+
const result = await recovery.tick(context);
|
|
268
|
+
expect(result).toBe(NodeStatus.SUCCESS);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("should propagate ConfigurationError from try branch immediately", async () => {
|
|
272
|
+
const recovery = new Recovery({ id: "recovery1" });
|
|
273
|
+
|
|
274
|
+
class ConfigErrorNode extends SuccessNode {
|
|
275
|
+
async executeTick(_context: TemporalContext) {
|
|
276
|
+
throw new ConfigurationError("Test config error");
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
recovery.addChild(new ConfigErrorNode({ id: "try" }));
|
|
281
|
+
recovery.addChild(new SuccessNode({ id: "catch" }));
|
|
282
|
+
|
|
283
|
+
try {
|
|
284
|
+
await recovery.tick(context);
|
|
285
|
+
expect.fail("Should have thrown an error");
|
|
286
|
+
} catch (error) {
|
|
287
|
+
expect(error).toBeInstanceOf(ConfigurationError);
|
|
288
|
+
expect((error as ConfigurationError).message).toContain("Test config error");
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("should propagate ConfigurationError from finally branch", async () => {
|
|
293
|
+
const recovery = new Recovery({ id: "recovery1" });
|
|
294
|
+
|
|
295
|
+
class ConfigErrorNode extends SuccessNode {
|
|
296
|
+
async executeTick(_context: TemporalContext) {
|
|
297
|
+
throw new ConfigurationError("Finally config error");
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
recovery.addChild(new SuccessNode({ id: "try" }));
|
|
302
|
+
recovery.addChild(new SuccessNode({ id: "catch" }));
|
|
303
|
+
recovery.addChild(new ConfigErrorNode({ id: "finally" }));
|
|
304
|
+
|
|
305
|
+
try {
|
|
306
|
+
await recovery.tick(context);
|
|
307
|
+
expect.fail("Should have thrown an error");
|
|
308
|
+
} catch (error) {
|
|
309
|
+
expect(error).toBeInstanceOf(ConfigurationError);
|
|
310
|
+
expect((error as ConfigurationError).message).toContain("Finally config error");
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("should NOT execute finally when try has ConfigurationError (immediate propagation)", async () => {
|
|
315
|
+
const recovery = new Recovery({ id: "recovery1" });
|
|
316
|
+
|
|
317
|
+
let finallyExecuted = false;
|
|
318
|
+
|
|
319
|
+
class ConfigErrorNode extends SuccessNode {
|
|
320
|
+
async executeTick(_context: TemporalContext) {
|
|
321
|
+
throw new ConfigurationError("Try config error");
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
class FinallyTracker extends SuccessNode {
|
|
326
|
+
async tick(context: TemporalContext) {
|
|
327
|
+
finallyExecuted = true;
|
|
328
|
+
return await super.tick(context);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
recovery.addChild(new ConfigErrorNode({ id: "try" }));
|
|
333
|
+
recovery.addChild(new SuccessNode({ id: "catch" }));
|
|
334
|
+
recovery.addChild(new FinallyTracker({ id: "finally" }));
|
|
335
|
+
|
|
336
|
+
// ConfigurationError should propagate immediately without executing finally
|
|
337
|
+
// This differs from traditional finally semantics but is intentional:
|
|
338
|
+
// ConfigurationError means the test is broken, so we stop immediately
|
|
339
|
+
try {
|
|
340
|
+
await recovery.tick(context);
|
|
341
|
+
expect.fail("Should have thrown an error");
|
|
342
|
+
} catch (error) {
|
|
343
|
+
expect(error).toBeInstanceOf(ConfigurationError);
|
|
344
|
+
}
|
|
345
|
+
expect(finallyExecuted).toBe(false);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("should return try result when finally returns RUNNING", async () => {
|
|
349
|
+
const recovery = new Recovery({ id: "recovery1" });
|
|
350
|
+
|
|
351
|
+
class RunningNode extends SuccessNode {
|
|
352
|
+
async tick(_context: TemporalContext) {
|
|
353
|
+
return NodeStatus.RUNNING;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
recovery.addChild(new SuccessNode({ id: "try" }));
|
|
358
|
+
recovery.addChild(new SuccessNode({ id: "catch" }));
|
|
359
|
+
recovery.addChild(new RunningNode({ id: "finally" }));
|
|
360
|
+
|
|
361
|
+
const result = await recovery.tick(context);
|
|
362
|
+
// Should return try result (SUCCESS), not finally result (RUNNING)
|
|
363
|
+
expect(result).toBe(NodeStatus.SUCCESS);
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recovery node - Try-catch-finally error handling
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { CompositeNode } from "../base-node.js";
|
|
6
|
+
import { ConfigurationError } from "../errors.js";
|
|
7
|
+
import { type TemporalContext, NodeStatus, type TreeNode } from "../types.js";
|
|
8
|
+
import { checkSignal } from "../utils/signal-check.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Recovery implements try-catch-finally error handling for behavior trees.
|
|
12
|
+
* Structure:
|
|
13
|
+
* - First child = try branch
|
|
14
|
+
* - Second child (optional) = catch branch
|
|
15
|
+
* - Third child (optional) = finally branch
|
|
16
|
+
*
|
|
17
|
+
* Behavior:
|
|
18
|
+
* - If try succeeds or returns RUNNING, use its result
|
|
19
|
+
* - If try returns FAILURE and catch exists, execute catch branch
|
|
20
|
+
* - Finally branch always executes (if present) after try/catch completes
|
|
21
|
+
* - Finally branch result does not affect the overall result
|
|
22
|
+
*
|
|
23
|
+
* Special error handling:
|
|
24
|
+
* - ConfigurationError and OperationCancelledError propagate immediately
|
|
25
|
+
* - When these special errors occur, finally branch does NOT execute
|
|
26
|
+
* - This differs from traditional finally semantics but is intentional:
|
|
27
|
+
* ConfigurationError means the test is broken, so execution stops immediately
|
|
28
|
+
*/
|
|
29
|
+
export class Recovery extends CompositeNode {
|
|
30
|
+
private tryBranch?: TreeNode;
|
|
31
|
+
private catchBranch?: TreeNode;
|
|
32
|
+
private finallyBranch?: TreeNode;
|
|
33
|
+
|
|
34
|
+
addChild(child: TreeNode): void {
|
|
35
|
+
if (!this.tryBranch) {
|
|
36
|
+
this.tryBranch = child;
|
|
37
|
+
this._children.push(child);
|
|
38
|
+
child.parent = this;
|
|
39
|
+
} else if (!this.catchBranch) {
|
|
40
|
+
this.catchBranch = child;
|
|
41
|
+
this._children.push(child);
|
|
42
|
+
child.parent = this;
|
|
43
|
+
} else if (!this.finallyBranch) {
|
|
44
|
+
this.finallyBranch = child;
|
|
45
|
+
this._children.push(child);
|
|
46
|
+
child.parent = this;
|
|
47
|
+
} else {
|
|
48
|
+
throw new ConfigurationError(
|
|
49
|
+
"Recovery can have maximum 3 children (try, catch, finally)",
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async executeTick(context: TemporalContext): Promise<NodeStatus> {
|
|
55
|
+
// Check for cancellation before starting try-catch-finally
|
|
56
|
+
checkSignal(context.signal);
|
|
57
|
+
|
|
58
|
+
if (!this.tryBranch) {
|
|
59
|
+
throw new ConfigurationError("Recovery requires at least a try branch");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Execute try branch and determine result
|
|
63
|
+
this.log("Executing try branch");
|
|
64
|
+
const tryResult = await this.tryBranch.tick(context);
|
|
65
|
+
|
|
66
|
+
// Determine the main result (from try or catch)
|
|
67
|
+
let mainResult: NodeStatus;
|
|
68
|
+
|
|
69
|
+
if (tryResult === NodeStatus.FAILURE && this.catchBranch) {
|
|
70
|
+
// Try failed and we have a catch branch - execute it
|
|
71
|
+
this.log("Try branch failed - executing catch branch");
|
|
72
|
+
mainResult = await this.catchBranch.tick(context);
|
|
73
|
+
} else {
|
|
74
|
+
// Try succeeded, running, or no catch branch
|
|
75
|
+
mainResult = tryResult;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Always execute finally branch if it exists
|
|
79
|
+
// Finally branch should not affect the main result (unless it throws ConfigurationError/OperationCancelledError)
|
|
80
|
+
if (this.finallyBranch) {
|
|
81
|
+
this.log("Executing finally branch");
|
|
82
|
+
// Execute finally and ignore its status (but let special errors propagate)
|
|
83
|
+
await this.finallyBranch.tick(context);
|
|
84
|
+
this.log("Finally branch completed");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
this._status = mainResult;
|
|
88
|
+
return mainResult;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Selector composite configuration schema
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { nodeConfigurationSchema } from "../schemas/base.schema.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Schema for Selector composite configuration
|
|
10
|
+
* Uses base schema only (no additional properties)
|
|
11
|
+
*/
|
|
12
|
+
export const selectorConfigurationSchema = nodeConfigurationSchema;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Validated Selector configuration type
|
|
16
|
+
*/
|
|
17
|
+
export type ValidatedSelectorConfiguration = z.infer<
|
|
18
|
+
typeof selectorConfigurationSchema
|
|
19
|
+
>;
|