@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.
Files changed (203) hide show
  1. package/.claude/settings.local.json +31 -0
  2. package/CLAUDE.md +181 -0
  3. package/LICENSE +21 -0
  4. package/README.md +920 -0
  5. package/behaviour-tree-workflows-landing/index.html +16 -0
  6. package/behaviour-tree-workflows-landing/package-lock.json +2074 -0
  7. package/behaviour-tree-workflows-landing/package.json +31 -0
  8. package/behaviour-tree-workflows-landing/public/favicon.svg +17 -0
  9. package/behaviour-tree-workflows-landing/src/App.css +103 -0
  10. package/behaviour-tree-workflows-landing/src/App.tsx +176 -0
  11. package/behaviour-tree-workflows-landing/src/components/BlackboardInspector.css +89 -0
  12. package/behaviour-tree-workflows-landing/src/components/BlackboardInspector.tsx +64 -0
  13. package/behaviour-tree-workflows-landing/src/components/ExampleSelector.css +64 -0
  14. package/behaviour-tree-workflows-landing/src/components/ExampleSelector.tsx +34 -0
  15. package/behaviour-tree-workflows-landing/src/components/ExecutionLog.css +107 -0
  16. package/behaviour-tree-workflows-landing/src/components/ExecutionLog.tsx +85 -0
  17. package/behaviour-tree-workflows-landing/src/components/Header.css +50 -0
  18. package/behaviour-tree-workflows-landing/src/components/Header.tsx +26 -0
  19. package/behaviour-tree-workflows-landing/src/components/StatusBadge.css +45 -0
  20. package/behaviour-tree-workflows-landing/src/components/StatusBadge.tsx +15 -0
  21. package/behaviour-tree-workflows-landing/src/components/Toolbar.css +74 -0
  22. package/behaviour-tree-workflows-landing/src/components/Toolbar.tsx +53 -0
  23. package/behaviour-tree-workflows-landing/src/components/TreeVisualizer.css +67 -0
  24. package/behaviour-tree-workflows-landing/src/components/TreeVisualizer.tsx +192 -0
  25. package/behaviour-tree-workflows-landing/src/components/YamlEditor.css +18 -0
  26. package/behaviour-tree-workflows-landing/src/components/YamlEditor.tsx +96 -0
  27. package/behaviour-tree-workflows-landing/src/lib/count-nodes.ts +11 -0
  28. package/behaviour-tree-workflows-landing/src/lib/execution-engine.ts +96 -0
  29. package/behaviour-tree-workflows-landing/src/lib/tree-layout.ts +136 -0
  30. package/behaviour-tree-workflows-landing/src/lib/yaml-examples.ts +549 -0
  31. package/behaviour-tree-workflows-landing/src/main.tsx +9 -0
  32. package/behaviour-tree-workflows-landing/src/stubs/activepieces.ts +18 -0
  33. package/behaviour-tree-workflows-landing/src/stubs/fs.ts +24 -0
  34. package/behaviour-tree-workflows-landing/src/stubs/path.ts +16 -0
  35. package/behaviour-tree-workflows-landing/src/stubs/temporal-activity.ts +6 -0
  36. package/behaviour-tree-workflows-landing/src/stubs/temporal-workflow.ts +22 -0
  37. package/behaviour-tree-workflows-landing/tsconfig.json +25 -0
  38. package/behaviour-tree-workflows-landing/vite.config.ts +40 -0
  39. package/demo-google-sheets.ts +181 -0
  40. package/demo-runtime-variables.ts +174 -0
  41. package/demo-template.ts +208 -0
  42. package/docs/ARCHITECTURE_SUMMARY.md +613 -0
  43. package/docs/NODE_REFERENCE.md +504 -0
  44. package/docs/README.md +53 -0
  45. package/docs/custom-nodes-architecture.md +826 -0
  46. package/docs/observability.md +175 -0
  47. package/docs/yaml-specification.md +990 -0
  48. package/examples/temporal/README.md +117 -0
  49. package/examples/temporal/activities.ts +373 -0
  50. package/examples/temporal/client.ts +115 -0
  51. package/examples/temporal/python-worker/activities.py +339 -0
  52. package/examples/temporal/python-worker/requirements.txt +12 -0
  53. package/examples/temporal/python-worker/worker.py +106 -0
  54. package/examples/temporal/worker.ts +66 -0
  55. package/examples/temporal/workflows.ts +6 -0
  56. package/examples/temporal/yaml-workflow-loader.ts +105 -0
  57. package/examples/yaml-test.ts +97 -0
  58. package/examples/yaml-workflows/01-simple-sequence.yaml +25 -0
  59. package/examples/yaml-workflows/02-parallel-timeout.yaml +45 -0
  60. package/examples/yaml-workflows/03-ecommerce-checkout.yaml +94 -0
  61. package/examples/yaml-workflows/04-ai-agent-workflow.yaml +346 -0
  62. package/examples/yaml-workflows/05-order-processing.yaml +146 -0
  63. package/examples/yaml-workflows/06-activity-test.yaml +71 -0
  64. package/examples/yaml-workflows/07-activity-simple-test.yaml +43 -0
  65. package/examples/yaml-workflows/08-file-processing.yaml +141 -0
  66. package/examples/yaml-workflows/09-http-request.yaml +137 -0
  67. package/examples/yaml-workflows/README.md +211 -0
  68. package/package.json +38 -0
  69. package/src/actions/code-execution.schema.ts +27 -0
  70. package/src/actions/code-execution.ts +218 -0
  71. package/src/actions/generate-file.test.ts +516 -0
  72. package/src/actions/generate-file.ts +166 -0
  73. package/src/actions/http-request.test.ts +784 -0
  74. package/src/actions/http-request.ts +228 -0
  75. package/src/actions/index.ts +20 -0
  76. package/src/actions/parse-file.test.ts +448 -0
  77. package/src/actions/parse-file.ts +139 -0
  78. package/src/actions/python-script.test.ts +439 -0
  79. package/src/actions/python-script.ts +154 -0
  80. package/src/base-node.test.ts +511 -0
  81. package/src/base-node.ts +605 -0
  82. package/src/behavior-tree.test.ts +431 -0
  83. package/src/behavior-tree.ts +283 -0
  84. package/src/blackboard.test.ts +222 -0
  85. package/src/blackboard.ts +192 -0
  86. package/src/composites/conditional.schema.ts +19 -0
  87. package/src/composites/conditional.test.ts +309 -0
  88. package/src/composites/conditional.ts +129 -0
  89. package/src/composites/for-each.schema.ts +23 -0
  90. package/src/composites/for-each.test.ts +254 -0
  91. package/src/composites/for-each.ts +132 -0
  92. package/src/composites/index.ts +15 -0
  93. package/src/composites/memory-sequence.schema.ts +19 -0
  94. package/src/composites/memory-sequence.test.ts +223 -0
  95. package/src/composites/memory-sequence.ts +98 -0
  96. package/src/composites/parallel.schema.ts +28 -0
  97. package/src/composites/parallel.test.ts +502 -0
  98. package/src/composites/parallel.ts +157 -0
  99. package/src/composites/reactive-sequence.schema.ts +19 -0
  100. package/src/composites/reactive-sequence.test.ts +170 -0
  101. package/src/composites/reactive-sequence.ts +85 -0
  102. package/src/composites/recovery.schema.ts +19 -0
  103. package/src/composites/recovery.test.ts +366 -0
  104. package/src/composites/recovery.ts +90 -0
  105. package/src/composites/selector.schema.ts +19 -0
  106. package/src/composites/selector.test.ts +387 -0
  107. package/src/composites/selector.ts +85 -0
  108. package/src/composites/sequence.schema.ts +19 -0
  109. package/src/composites/sequence.test.ts +337 -0
  110. package/src/composites/sequence.ts +72 -0
  111. package/src/composites/sub-tree.schema.ts +21 -0
  112. package/src/composites/sub-tree.test.ts +893 -0
  113. package/src/composites/sub-tree.ts +177 -0
  114. package/src/composites/while.schema.ts +24 -0
  115. package/src/composites/while.test.ts +381 -0
  116. package/src/composites/while.ts +149 -0
  117. package/src/data-store/index.ts +10 -0
  118. package/src/data-store/memory-store.ts +161 -0
  119. package/src/data-store/types.ts +94 -0
  120. package/src/debug/breakpoint.test.ts +47 -0
  121. package/src/debug/breakpoint.ts +30 -0
  122. package/src/debug/index.ts +17 -0
  123. package/src/debug/resume-point.test.ts +49 -0
  124. package/src/debug/resume-point.ts +29 -0
  125. package/src/decorators/delay.schema.ts +21 -0
  126. package/src/decorators/delay.test.ts +261 -0
  127. package/src/decorators/delay.ts +140 -0
  128. package/src/decorators/force-result.schema.ts +32 -0
  129. package/src/decorators/force-result.test.ts +133 -0
  130. package/src/decorators/force-result.ts +63 -0
  131. package/src/decorators/index.ts +13 -0
  132. package/src/decorators/invert.schema.ts +19 -0
  133. package/src/decorators/invert.test.ts +135 -0
  134. package/src/decorators/invert.ts +42 -0
  135. package/src/decorators/keep-running.schema.ts +20 -0
  136. package/src/decorators/keep-running.test.ts +105 -0
  137. package/src/decorators/keep-running.ts +49 -0
  138. package/src/decorators/precondition.schema.ts +19 -0
  139. package/src/decorators/precondition.test.ts +351 -0
  140. package/src/decorators/precondition.ts +139 -0
  141. package/src/decorators/repeat.schema.ts +21 -0
  142. package/src/decorators/repeat.test.ts +187 -0
  143. package/src/decorators/repeat.ts +94 -0
  144. package/src/decorators/run-once.schema.ts +19 -0
  145. package/src/decorators/run-once.test.ts +140 -0
  146. package/src/decorators/run-once.ts +61 -0
  147. package/src/decorators/soft-assert.schema.ts +19 -0
  148. package/src/decorators/soft-assert.test.ts +107 -0
  149. package/src/decorators/soft-assert.ts +68 -0
  150. package/src/decorators/timeout.schema.ts +21 -0
  151. package/src/decorators/timeout.test.ts +274 -0
  152. package/src/decorators/timeout.ts +159 -0
  153. package/src/errors.test.ts +63 -0
  154. package/src/errors.ts +34 -0
  155. package/src/events.test.ts +347 -0
  156. package/src/events.ts +183 -0
  157. package/src/index.ts +80 -0
  158. package/src/integrations/index.ts +30 -0
  159. package/src/integrations/integration-action.test.ts +571 -0
  160. package/src/integrations/integration-action.ts +233 -0
  161. package/src/integrations/piece-executor.ts +320 -0
  162. package/src/observability/execution-tracker.ts +320 -0
  163. package/src/observability/index.ts +23 -0
  164. package/src/observability/sinks.ts +138 -0
  165. package/src/observability/types.ts +130 -0
  166. package/src/registry-utils.ts +147 -0
  167. package/src/registry.test.ts +466 -0
  168. package/src/registry.ts +334 -0
  169. package/src/schemas/base.schema.ts +104 -0
  170. package/src/schemas/index.ts +223 -0
  171. package/src/schemas/integration.test.ts +238 -0
  172. package/src/schemas/tree-definition.schema.ts +170 -0
  173. package/src/schemas/validation.test.ts +146 -0
  174. package/src/schemas/validation.ts +122 -0
  175. package/src/scripting/index.ts +22 -0
  176. package/src/templates/template-loader.test.ts +281 -0
  177. package/src/templates/template-loader.ts +152 -0
  178. package/src/temporal-integration.test.ts +213 -0
  179. package/src/test-nodes.ts +259 -0
  180. package/src/types.ts +503 -0
  181. package/src/utilities/index.ts +17 -0
  182. package/src/utilities/log-message.test.ts +275 -0
  183. package/src/utilities/log-message.ts +134 -0
  184. package/src/utilities/regex-extract.test.ts +138 -0
  185. package/src/utilities/regex-extract.ts +108 -0
  186. package/src/utilities/variable-resolver.test.ts +416 -0
  187. package/src/utilities/variable-resolver.ts +318 -0
  188. package/src/utils/error-handler.test.ts +117 -0
  189. package/src/utils/error-handler.ts +48 -0
  190. package/src/utils/signal-check.test.ts +234 -0
  191. package/src/utils/signal-check.ts +140 -0
  192. package/src/yaml/errors.ts +143 -0
  193. package/src/yaml/index.ts +30 -0
  194. package/src/yaml/loader.ts +39 -0
  195. package/src/yaml/parser.ts +286 -0
  196. package/src/yaml/validation/semantic-validator.ts +196 -0
  197. package/templates/google-sheets/insert-row.yaml +76 -0
  198. package/templates/notification-sender.yaml +33 -0
  199. package/templates/order-validation.yaml +44 -0
  200. package/tsconfig.json +24 -0
  201. package/vitest.config.ts +25 -0
  202. package/workflows/order-processor.yaml +59 -0
  203. 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
+ >;