@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,154 @@
1
+ /**
2
+ * PythonScript Node
3
+ *
4
+ * Executes Python code via a cross-language Temporal activity.
5
+ * This node requires the `executePythonScript` activity to be configured
6
+ * in the context - it does not support standalone/inline execution because
7
+ * Python execution requires a separate Python worker process.
8
+ *
9
+ * Features:
10
+ * - Access to blackboard state via `bb` dict in Python
11
+ * - Access to workflow input via `input` dict in Python
12
+ * - Modifications to `bb` dict are merged back to blackboard
13
+ * - Configurable timeout and environment variables
14
+ */
15
+
16
+ import { ActionNode } from "../base-node.js";
17
+ import { ConfigurationError } from "../errors.js";
18
+ import {
19
+ type TemporalContext,
20
+ type NodeConfiguration,
21
+ type PythonScriptRequest,
22
+ NodeStatus,
23
+ } from "../types.js";
24
+ import { resolveString, type VariableContext } from "../utilities/variable-resolver.js";
25
+
26
+ /**
27
+ * Configuration for PythonScript node
28
+ */
29
+ export interface PythonScriptConfig extends NodeConfiguration {
30
+ /** Python code to execute */
31
+ code: string;
32
+ /** Required packages (for documentation/validation) */
33
+ packages?: string[];
34
+ /** Execution timeout in ms (default: 60000) */
35
+ timeout?: number;
36
+ /** Allowed environment variables to pass to Python */
37
+ allowedEnvVars?: string[];
38
+ }
39
+
40
+ /**
41
+ * PythonScript Node
42
+ *
43
+ * Executes Python code via a cross-language Temporal activity.
44
+ * The Python code has access to:
45
+ * - `bb`: dict - Blackboard state (read/write)
46
+ * - `input`: dict - Workflow input (read-only)
47
+ *
48
+ * @example YAML
49
+ * ```yaml
50
+ * type: PythonScript
51
+ * id: transform-data
52
+ * props:
53
+ * code: |
54
+ * import pandas as pd
55
+ * df = pd.DataFrame(bb['orders'])
56
+ * bb['total'] = df['amount'].sum()
57
+ * bb['count'] = len(df)
58
+ * packages:
59
+ * - pandas
60
+ * timeout: 30000
61
+ * ```
62
+ */
63
+ export class PythonScript extends ActionNode {
64
+ private code: string;
65
+ private packages: string[];
66
+ private timeout: number;
67
+ private allowedEnvVars: string[];
68
+
69
+ constructor(config: PythonScriptConfig) {
70
+ super(config);
71
+
72
+ if (!config.code) {
73
+ throw new ConfigurationError("PythonScript requires code");
74
+ }
75
+
76
+ this.code = config.code;
77
+ this.packages = config.packages || [];
78
+ this.timeout = config.timeout || 60000;
79
+ this.allowedEnvVars = config.allowedEnvVars || [];
80
+ }
81
+
82
+ protected async executeTick(context: TemporalContext): Promise<NodeStatus> {
83
+ // Validate activity is available (Python requires cross-language activity)
84
+ if (!context.activities?.executePythonScript) {
85
+ this._lastError =
86
+ "PythonScript requires activities.executePythonScript to be configured. " +
87
+ "This activity executes Python code in a separate worker process.";
88
+ this.log(`Error: ${this._lastError}`);
89
+ return NodeStatus.FAILURE;
90
+ }
91
+
92
+ try {
93
+ const request: PythonScriptRequest = {
94
+ code: this.resolveCode(context),
95
+ blackboard: context.blackboard.toJSON(),
96
+ input: context.input ? { ...context.input } : undefined,
97
+ env: this.getAllowedEnv(),
98
+ timeout: this.timeout,
99
+ };
100
+
101
+ this.log(`Executing Python script (${this.code.length} chars, timeout: ${this.timeout}ms)`);
102
+ const result = await context.activities.executePythonScript(request);
103
+
104
+ // Merge Python changes back to blackboard
105
+ for (const [key, value] of Object.entries(result.blackboard)) {
106
+ context.blackboard.set(key, value);
107
+ }
108
+
109
+ this.log(`Python script completed, ${Object.keys(result.blackboard).length} blackboard keys updated`);
110
+
111
+ // Log stdout/stderr if present
112
+ if (result.stdout) {
113
+ this.log(`Python stdout: ${result.stdout}`);
114
+ }
115
+ if (result.stderr) {
116
+ this.log(`Python stderr: ${result.stderr}`);
117
+ }
118
+
119
+ return NodeStatus.SUCCESS;
120
+ } catch (error) {
121
+ this._lastError = error instanceof Error ? error.message : String(error);
122
+ this.log(`Python script failed: ${this._lastError}`);
123
+ return NodeStatus.FAILURE;
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Resolve variable references in the Python code
129
+ * Allows dynamic code templates like:
130
+ * code: "bb['output_key'] = '${input.prefix}_result'"
131
+ */
132
+ private resolveCode(context: TemporalContext): string {
133
+ const varCtx: VariableContext = {
134
+ blackboard: context.blackboard,
135
+ input: context.input,
136
+ testData: context.testData,
137
+ };
138
+ return resolveString(this.code, varCtx) as string;
139
+ }
140
+
141
+ /**
142
+ * Get allowed environment variables to pass to Python
143
+ */
144
+ private getAllowedEnv(): Record<string, string> {
145
+ const env: Record<string, string> = {};
146
+ for (const varName of this.allowedEnvVars) {
147
+ const value = process.env[varName];
148
+ if (value !== undefined) {
149
+ env[varName] = value;
150
+ }
151
+ }
152
+ return env;
153
+ }
154
+ }
@@ -0,0 +1,511 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { ActionNode, CompositeNode, DecoratorNode } from "./base-node.js";
3
+ import { ScopedBlackboard } from "./blackboard.js";
4
+ import { ConfigurationError } from "./errors.js";
5
+ import { NodeEventEmitter, NodeEventType } from "./events.js";
6
+ import {
7
+ type TemporalContext,
8
+ type NodeConfiguration,
9
+ NodeStatus,
10
+ } from "./types.js";
11
+ import { OperationCancelledError } from "./utils/signal-check.js";
12
+
13
+ // Mock implementations for testing
14
+ class MockActionNode extends ActionNode {
15
+ async executeTick(_context: TemporalContext) {
16
+ this._status = NodeStatus.SUCCESS;
17
+ return NodeStatus.SUCCESS;
18
+ }
19
+ }
20
+
21
+ class MockDecoratorNode extends DecoratorNode {
22
+ async executeTick(context: TemporalContext) {
23
+ if (!this.child) {
24
+ throw new Error("No child");
25
+ }
26
+ return await this.child.tick(context);
27
+ }
28
+ }
29
+
30
+ class MockCompositeNode extends CompositeNode {
31
+ async executeTick(context: TemporalContext) {
32
+ for (const child of this._children) {
33
+ const status = await child.tick(context);
34
+ if (status !== NodeStatus.SUCCESS) {
35
+ return status;
36
+ }
37
+ }
38
+ return NodeStatus.SUCCESS;
39
+ }
40
+ }
41
+
42
+ describe("BaseNode", () => {
43
+ let context: TemporalContext;
44
+
45
+ beforeEach(() => {
46
+ context = {
47
+ blackboard: new ScopedBlackboard(),
48
+ timestamp: Date.now(),
49
+ deltaTime: 0,
50
+ };
51
+ });
52
+
53
+ describe("Node initialization", () => {
54
+ it("should initialize with correct properties", () => {
55
+ const config: NodeConfiguration = {
56
+ id: "test-node",
57
+ name: "Test Node",
58
+ };
59
+
60
+ const node = new MockActionNode(config);
61
+
62
+ expect(node.id).toBe("test-node");
63
+ expect(node.name).toBe("Test Node");
64
+ expect(node.type).toBe("MockActionNode");
65
+ expect(node.status()).toBe(NodeStatus.IDLE);
66
+ });
67
+
68
+ it("should use id as name if name not provided", () => {
69
+ const config: NodeConfiguration = {
70
+ id: "test-node",
71
+ };
72
+
73
+ const node = new MockActionNode(config);
74
+ expect(node.name).toBe("test-node");
75
+ });
76
+ });
77
+
78
+ describe("Status management", () => {
79
+ it("should track node status", async () => {
80
+ const node = new MockActionNode({ id: "test" });
81
+
82
+ expect(node.status()).toBe(NodeStatus.IDLE);
83
+
84
+ await node.tick(context);
85
+ expect(node.status()).toBe(NodeStatus.SUCCESS);
86
+ });
87
+ });
88
+
89
+ describe("Halt and Reset", () => {
90
+ it("should halt running nodes", () => {
91
+ const node = new MockActionNode({ id: "test" });
92
+ (node as any)._status = NodeStatus.RUNNING;
93
+
94
+ const consoleSpy = vi.spyOn(console, "log");
95
+ node.halt();
96
+
97
+ expect(node.status()).toBe(NodeStatus.IDLE);
98
+ expect(consoleSpy).toHaveBeenCalledWith(
99
+ expect.stringContaining("Halting..."),
100
+ );
101
+ });
102
+
103
+ it("should not halt non-running nodes", () => {
104
+ const node = new MockActionNode({ id: "test" });
105
+ (node as any)._status = NodeStatus.SUCCESS;
106
+
107
+ node.halt();
108
+ expect(node.status()).toBe(NodeStatus.SUCCESS);
109
+ });
110
+
111
+ it("should reset node state", () => {
112
+ const node = new MockActionNode({ id: "test" });
113
+ (node as any)._status = NodeStatus.SUCCESS;
114
+
115
+ const consoleSpy = vi.spyOn(console, "log");
116
+ node.reset();
117
+
118
+ expect(node.status()).toBe(NodeStatus.IDLE);
119
+ expect(consoleSpy).toHaveBeenCalledWith(
120
+ expect.stringContaining("Resetting..."),
121
+ );
122
+ });
123
+ });
124
+
125
+ describe("Input/Output helpers", () => {
126
+ it("should get input from blackboard", () => {
127
+ context.blackboard.set("testKey", "testValue");
128
+
129
+ const node = new MockActionNode({ id: "test" });
130
+ const value = (node as any).getInput(context, "testKey");
131
+
132
+ expect(value).toBe("testValue");
133
+ });
134
+
135
+ it("should use config mapping for input keys", () => {
136
+ context.blackboard.set("actualKey", "value");
137
+
138
+ const node = new MockActionNode({
139
+ id: "test",
140
+ testKey: "actualKey", // Map testKey to actualKey
141
+ });
142
+
143
+ const value = (node as any).getInput(context, "testKey");
144
+ expect(value).toBe("value");
145
+ });
146
+
147
+ it("should return default value for missing input", () => {
148
+ const node = new MockActionNode({ id: "test" });
149
+ const value = (node as any).getInput(context, "missing", "default");
150
+
151
+ expect(value).toBe("default");
152
+ });
153
+
154
+ it("should set output to blackboard", () => {
155
+ const node = new MockActionNode({ id: "test" });
156
+ (node as any).setOutput(context, "outputKey", "outputValue");
157
+
158
+ expect(context.blackboard.get("outputKey")).toBe("outputValue");
159
+ });
160
+
161
+ it("should use config mapping for output keys", () => {
162
+ const node = new MockActionNode({
163
+ id: "test",
164
+ outputKey: "actualOutputKey",
165
+ });
166
+
167
+ (node as any).setOutput(context, "outputKey", "value");
168
+ expect(context.blackboard.get("actualOutputKey")).toBe("value");
169
+ });
170
+ });
171
+ });
172
+
173
+ describe("DecoratorNode", () => {
174
+ let _context: TemporalContext;
175
+
176
+ beforeEach(() => {
177
+ _context = {
178
+ blackboard: new ScopedBlackboard(),
179
+ timestamp: Date.now(),
180
+ deltaTime: 0,
181
+ };
182
+ });
183
+
184
+ it("should manage single child", () => {
185
+ const decorator = new MockDecoratorNode({ id: "decorator" });
186
+ const child = new MockActionNode({ id: "child" });
187
+
188
+ decorator.setChild(child);
189
+
190
+ expect((decorator as any).child).toBe(child);
191
+ expect(decorator.children).toEqual([child]);
192
+ expect(child.parent).toBe(decorator);
193
+ });
194
+
195
+ it("should halt child when halted", () => {
196
+ const decorator = new MockDecoratorNode({ id: "decorator" });
197
+ const child = new MockActionNode({ id: "child" });
198
+ (child as any)._status = NodeStatus.RUNNING;
199
+
200
+ decorator.setChild(child);
201
+
202
+ const childHaltSpy = vi.spyOn(child, "halt");
203
+ decorator.halt();
204
+
205
+ expect(childHaltSpy).toHaveBeenCalled();
206
+ });
207
+
208
+ it("should reset child when reset", () => {
209
+ const decorator = new MockDecoratorNode({ id: "decorator" });
210
+ const child = new MockActionNode({ id: "child" });
211
+
212
+ decorator.setChild(child);
213
+
214
+ const childResetSpy = vi.spyOn(child, "reset");
215
+ decorator.reset();
216
+
217
+ expect(childResetSpy).toHaveBeenCalled();
218
+ });
219
+ });
220
+
221
+ describe("CompositeNode", () => {
222
+ let _context: TemporalContext;
223
+
224
+ beforeEach(() => {
225
+ _context = {
226
+ blackboard: new ScopedBlackboard(),
227
+ timestamp: Date.now(),
228
+ deltaTime: 0,
229
+ };
230
+ });
231
+
232
+ it("should manage multiple children", () => {
233
+ const composite = new MockCompositeNode({ id: "composite" });
234
+ const child1 = new MockActionNode({ id: "child1" });
235
+ const child2 = new MockActionNode({ id: "child2" });
236
+
237
+ composite.addChild(child1);
238
+ composite.addChild(child2);
239
+
240
+ expect(composite.children).toEqual([child1, child2]);
241
+ expect(child1.parent).toBe(composite);
242
+ expect(child2.parent).toBe(composite);
243
+ });
244
+
245
+ it("should add multiple children at once", () => {
246
+ const composite = new MockCompositeNode({ id: "composite" });
247
+ const children = [
248
+ new MockActionNode({ id: "child1" }),
249
+ new MockActionNode({ id: "child2" }),
250
+ new MockActionNode({ id: "child3" }),
251
+ ];
252
+
253
+ composite.addChildren(children);
254
+
255
+ expect(composite.children).toEqual(children);
256
+ children.forEach((child) => {
257
+ expect(child.parent).toBe(composite);
258
+ });
259
+ });
260
+
261
+ it("should halt all running children", () => {
262
+ const composite = new MockCompositeNode({ id: "composite" });
263
+ const runningChild = new MockActionNode({ id: "running" });
264
+ const idleChild = new MockActionNode({ id: "idle" });
265
+
266
+ (runningChild as any)._status = NodeStatus.RUNNING;
267
+ (idleChild as any)._status = NodeStatus.IDLE;
268
+
269
+ composite.addChildren([runningChild, idleChild]);
270
+
271
+ const runningHaltSpy = vi.spyOn(runningChild, "halt");
272
+ const idleHaltSpy = vi.spyOn(idleChild, "halt");
273
+
274
+ composite.halt();
275
+
276
+ expect(runningHaltSpy).toHaveBeenCalled();
277
+ expect(idleHaltSpy).not.toHaveBeenCalled();
278
+ });
279
+
280
+ it("should reset all children", () => {
281
+ const composite = new MockCompositeNode({ id: "composite" });
282
+ const child1 = new MockActionNode({ id: "child1" });
283
+ const child2 = new MockActionNode({ id: "child2" });
284
+
285
+ composite.addChildren([child1, child2]);
286
+
287
+ const reset1Spy = vi.spyOn(child1, "reset");
288
+ const reset2Spy = vi.spyOn(child2, "reset");
289
+
290
+ composite.reset();
291
+
292
+ expect(reset1Spy).toHaveBeenCalled();
293
+ expect(reset2Spy).toHaveBeenCalled();
294
+ });
295
+
296
+ it("should halt children from specific index", () => {
297
+ const composite = new MockCompositeNode({ id: "composite" });
298
+ const children = [
299
+ new MockActionNode({ id: "child0" }),
300
+ new MockActionNode({ id: "child1" }),
301
+ new MockActionNode({ id: "child2" }),
302
+ ];
303
+
304
+ children.forEach((child) => {
305
+ (child as any)._status = NodeStatus.RUNNING;
306
+ });
307
+
308
+ composite.addChildren(children);
309
+
310
+ const haltSpies = children.map((child) => vi.spyOn(child, "halt"));
311
+
312
+ (composite as any).haltChildren(1); // Start from index 1
313
+
314
+ expect(haltSpies[0]).not.toHaveBeenCalled();
315
+ expect(haltSpies[1]).toHaveBeenCalled();
316
+ expect(haltSpies[2]).toHaveBeenCalled();
317
+ });
318
+ });
319
+
320
+ describe("lastError property", () => {
321
+ it("should initially be undefined", () => {
322
+ const node = new MockActionNode({ id: "test" });
323
+ expect(node.lastError).toBeUndefined();
324
+ });
325
+
326
+ it("should store error when set", () => {
327
+ const node = new MockActionNode({ id: "test" });
328
+ (node as any)._lastError = "Test error message";
329
+ expect(node.lastError).toBe("Test error message");
330
+ });
331
+
332
+ it("should clear error on reset", () => {
333
+ const node = new MockActionNode({ id: "test" });
334
+ (node as any)._lastError = "Test error message";
335
+ node.reset();
336
+ expect(node.lastError).toBeUndefined();
337
+ });
338
+ });
339
+
340
+ describe("Error handling in tick()", () => {
341
+ let context: TemporalContext;
342
+ let eventEmitter: NodeEventEmitter;
343
+ let errorEvents: any[];
344
+ let tickEndEvents: any[];
345
+
346
+ beforeEach(() => {
347
+ errorEvents = [];
348
+ tickEndEvents = [];
349
+ eventEmitter = new NodeEventEmitter();
350
+ eventEmitter.on(NodeEventType.ERROR, (event) => errorEvents.push(event));
351
+ eventEmitter.on(NodeEventType.TICK_END, (event) =>
352
+ tickEndEvents.push(event),
353
+ );
354
+
355
+ context = {
356
+ blackboard: new ScopedBlackboard(),
357
+ timestamp: Date.now(),
358
+ deltaTime: 0,
359
+ eventEmitter,
360
+ };
361
+ });
362
+
363
+ it("should catch thrown errors and convert to FAILURE status", async () => {
364
+ class FailingNode extends ActionNode {
365
+ async executeTick(_context: TemporalContext) {
366
+ throw new Error("Test error from Effect.fail");
367
+ }
368
+ }
369
+
370
+ const node = new FailingNode({ id: "test" });
371
+ const status = await node.tick(context);
372
+
373
+ expect(status).toBe(NodeStatus.FAILURE);
374
+ expect(node.status()).toBe(NodeStatus.FAILURE);
375
+ expect(node.lastError).toBe("Test error from Effect.fail");
376
+ });
377
+
378
+ it("should catch JavaScript throw and convert to FAILURE status", async () => {
379
+ class ThrowingNode extends ActionNode {
380
+ async executeTick(_context: TemporalContext): Promise<NodeStatus> {
381
+ throw new Error("Test error from throw");
382
+ }
383
+ }
384
+
385
+ const node = new ThrowingNode({ id: "test" });
386
+ const status = await node.tick(context);
387
+
388
+ expect(status).toBe(NodeStatus.FAILURE);
389
+ expect(node.status()).toBe(NodeStatus.FAILURE);
390
+ expect(node.lastError).toBe("Test error from throw");
391
+ });
392
+
393
+ it("should emit ERROR event when error occurs", async () => {
394
+ class FailingNode extends ActionNode {
395
+ async executeTick(_context: TemporalContext) {
396
+ throw new Error("Test error");
397
+ }
398
+ }
399
+
400
+ const node = new FailingNode({ id: "test-id", name: "test-name" });
401
+ await node.tick(context);
402
+
403
+ expect(errorEvents.length).toBe(1);
404
+ expect(errorEvents[0].nodeId).toBe("test-id");
405
+ expect(errorEvents[0].nodeName).toBe("test-name");
406
+ expect(errorEvents[0].data.error).toBe("Test error");
407
+ });
408
+
409
+ it("should emit TICK_END with FAILURE status on error", async () => {
410
+ class FailingNode extends ActionNode {
411
+ async executeTick(_context: TemporalContext) {
412
+ throw new Error("Test error");
413
+ }
414
+ }
415
+
416
+ const node = new FailingNode({ id: "test" });
417
+ await node.tick(context);
418
+
419
+ const failureEvents = tickEndEvents.filter(
420
+ (e) => e.data.status === NodeStatus.FAILURE,
421
+ );
422
+ expect(failureEvents.length).toBe(1);
423
+ expect(failureEvents[0].nodeId).toBe("test");
424
+ });
425
+
426
+ it("should re-propagate OperationCancelledError", async () => {
427
+ class CancellingNode extends ActionNode {
428
+ async executeTick(_context: TemporalContext) {
429
+ throw new OperationCancelledError("Test cancellation");
430
+ }
431
+ }
432
+
433
+ const node = new CancellingNode({ id: "test" });
434
+
435
+ // OperationCancelledError should propagate as rejection
436
+ try {
437
+ await node.tick(context);
438
+ expect.fail("Should have thrown");
439
+ } catch (error) {
440
+ expect(error).toBeInstanceOf(OperationCancelledError);
441
+ }
442
+
443
+ // But node status should still be FAILURE
444
+ expect(node.status()).toBe(NodeStatus.FAILURE);
445
+ expect(node.lastError).toBe("Test cancellation");
446
+ });
447
+
448
+ it("should emit ERROR event even for OperationCancelledError", async () => {
449
+ class CancellingNode extends ActionNode {
450
+ async executeTick(_context: TemporalContext): Promise<NodeStatus> {
451
+ throw new OperationCancelledError("Test cancellation");
452
+ }
453
+ }
454
+
455
+ const node = new CancellingNode({ id: "test" });
456
+
457
+ // Catch the re-propagated error
458
+ try {
459
+ await node.tick(context);
460
+ } catch (error) {
461
+ // Expected to throw
462
+ }
463
+
464
+ expect(errorEvents.length).toBe(1);
465
+ expect(errorEvents[0].data.error).toBe("Test cancellation");
466
+ });
467
+
468
+ it("should re-propagate ConfigurationError", async () => {
469
+ class MisconfiguredNode extends ActionNode {
470
+ async executeTick(_context: TemporalContext) {
471
+ throw new ConfigurationError("Test is broken - missing element");
472
+ }
473
+ }
474
+
475
+ const node = new MisconfiguredNode({ id: "test" });
476
+
477
+ // ConfigurationError should propagate as rejection
478
+ try {
479
+ await node.tick(context);
480
+ expect.fail("Should have thrown");
481
+ } catch (error) {
482
+ expect(error).toBeInstanceOf(ConfigurationError);
483
+ }
484
+
485
+ // But node status should still be FAILURE
486
+ expect(node.status()).toBe(NodeStatus.FAILURE);
487
+ expect(node.lastError).toBe("Test is broken - missing element");
488
+ });
489
+
490
+ it("should emit ERROR event even for ConfigurationError", async () => {
491
+ class MisconfiguredNode extends ActionNode {
492
+ async executeTick(_context: TemporalContext): Promise<NodeStatus> {
493
+ throw new ConfigurationError("Test is broken - missing element");
494
+ }
495
+ }
496
+
497
+ const node = new MisconfiguredNode({ id: "test" });
498
+
499
+ // Catch the re-propagated error
500
+ try {
501
+ await node.tick(context);
502
+ } catch (error) {
503
+ // Expected to throw
504
+ }
505
+
506
+ expect(errorEvents.length).toBe(1);
507
+ expect(errorEvents[0].data.error).toBe(
508
+ "Test is broken - missing element",
509
+ );
510
+ });
511
+ });