@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,177 @@
1
+ /**
2
+ * SubTree node - References and executes a behavior tree from the session-scoped registry
3
+ * Provides function-like reusability for step groups with scoped blackboard isolation
4
+ *
5
+ * Features:
6
+ * - params: Pass values to subtree's blackboard (supports variable resolution)
7
+ * - outputs: Export subtree values back to parent blackboard after execution
8
+ */
9
+
10
+ import { ActionNode } from "../base-node.js";
11
+ import type { TreeNode } from "../types.js";
12
+ import {
13
+ type TemporalContext,
14
+ type NodeConfiguration,
15
+ NodeStatus,
16
+ } from "../types.js";
17
+ import { checkSignal } from "../utils/signal-check.js";
18
+ import { resolveValue, type VariableContext } from "../utilities/variable-resolver.js";
19
+
20
+ export interface SubTreeConfiguration extends NodeConfiguration {
21
+ /** BehaviorTree ID to look up from registry */
22
+ treeId: string;
23
+ /**
24
+ * Parameters to pass to the subtree's blackboard
25
+ * Supports variable resolution: ${input.key}, ${bb.key}, ${env.KEY}, ${param.key}
26
+ */
27
+ params?: Record<string, unknown>;
28
+ /**
29
+ * Keys to export from subtree's blackboard back to parent after execution
30
+ * These values are copied to the parent scope when subtree completes
31
+ */
32
+ outputs?: string[];
33
+ }
34
+
35
+ /**
36
+ * SubTree - References and executes a behavior tree from the registry
37
+ *
38
+ * Execution flow:
39
+ * 1. Clone behavior tree from registry (lazy, on first tick)
40
+ * 2. Create scoped blackboard for isolation (subtree_${id})
41
+ * 3. Resolve and copy params to subtree's blackboard
42
+ * 4. Execute cloned tree with scoped context
43
+ * 5. Copy output values back to parent blackboard
44
+ * 6. Return the tree's execution status
45
+ *
46
+ * The scoped blackboard provides isolation while maintaining read access to parent scopes.
47
+ *
48
+ * @example
49
+ * ```yaml
50
+ * type: SubTree
51
+ * id: process-order
52
+ * props:
53
+ * treeId: ProcessOrderFlow
54
+ * params:
55
+ * orderId: "${input.orderId}"
56
+ * customer: "${bb.currentCustomer}"
57
+ * outputs:
58
+ * - orderResult
59
+ * - processingTime
60
+ * ```
61
+ */
62
+ export class SubTree extends ActionNode {
63
+ private treeId: string;
64
+ private params: Record<string, unknown>;
65
+ private outputs: string[];
66
+ private clonedTree?: TreeNode; // Cached tree instance
67
+
68
+ constructor(config: SubTreeConfiguration) {
69
+ super(config);
70
+ this.treeId = config.treeId;
71
+ this.params = config.params ?? {};
72
+ this.outputs = config.outputs ?? [];
73
+ }
74
+
75
+ protected async executeTick(context: TemporalContext): Promise<NodeStatus> {
76
+ // Check for cancellation before starting step group
77
+ checkSignal(context.signal);
78
+
79
+ // 1. Clone tree from registry (lazy, only on first tick)
80
+ if (!this.clonedTree) {
81
+ if (!context.treeRegistry.hasTree(this.treeId)) {
82
+ throw new Error(
83
+ `SubTree tree '${this.treeId}' not found in registry. ` +
84
+ `Available trees: ${context.treeRegistry.getAllTreeIds().join(", ") || "none"}`,
85
+ );
86
+ }
87
+ // cloneTree returns BehaviorTree, get the root TreeNode for execution
88
+ const clonedBehaviorTree = context.treeRegistry.cloneTree(this.treeId);
89
+ this.clonedTree = clonedBehaviorTree.getRoot();
90
+ this.log(`Cloned SubTree tree '${this.treeId}' from registry`);
91
+ }
92
+
93
+ // 2. Create scoped blackboard for this SubTree
94
+ const subtreeScope = context.blackboard.createScope(`subtree_${this.id}`);
95
+ this.log(`Created scoped blackboard: ${subtreeScope.getFullScopePath()}`);
96
+
97
+ // 3. Resolve and copy params to subtree's blackboard
98
+ if (Object.keys(this.params).length > 0) {
99
+ const varCtx: VariableContext = {
100
+ blackboard: context.blackboard,
101
+ input: context.input,
102
+ testData: context.testData,
103
+ };
104
+
105
+ const resolvedParams = resolveValue(this.params, varCtx) as Record<string, unknown>;
106
+
107
+ for (const [key, value] of Object.entries(resolvedParams)) {
108
+ subtreeScope.set(key, value);
109
+ this.log(`Set param '${key}' in subtree scope`);
110
+ }
111
+ }
112
+
113
+ // 4. Execute cloned tree with scoped context
114
+ const scopedContext: TemporalContext = {
115
+ ...context,
116
+ blackboard: subtreeScope,
117
+ };
118
+
119
+ try {
120
+ this.log(`Executing SubTree tree '${this.treeId}'`);
121
+ const status = await this.clonedTree.tick(scopedContext);
122
+
123
+ // 5. Copy output values back to parent blackboard
124
+ if (this.outputs.length > 0 && (status === NodeStatus.SUCCESS || status === NodeStatus.RUNNING)) {
125
+ for (const outputKey of this.outputs) {
126
+ if (subtreeScope.has(outputKey)) {
127
+ const value = subtreeScope.get(outputKey);
128
+ context.blackboard.set(outputKey, value);
129
+ this.log(`Exported output '${outputKey}' to parent scope`);
130
+ } else {
131
+ this.log(`Output '${outputKey}' not found in subtree scope, skipping`);
132
+ }
133
+ }
134
+ }
135
+
136
+ this.log(
137
+ `SubTree tree '${this.treeId}' completed with status: ${status}`,
138
+ );
139
+ return status;
140
+ } catch (error) {
141
+ this.log(`SubTree tree '${this.treeId}' failed with error: ${error}`);
142
+ throw error;
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Override clone to include cloned tree
148
+ */
149
+ clone(): TreeNode {
150
+ const ClonedClass = this.constructor as new (
151
+ config: NodeConfiguration,
152
+ ) => this;
153
+ const cloned = new ClonedClass({ ...this.config });
154
+ // Don't clone the cached tree - let the clone lazy-load its own
155
+ return cloned;
156
+ }
157
+
158
+ /**
159
+ * Override halt to halt the referenced tree
160
+ */
161
+ halt(): void {
162
+ super.halt();
163
+ if (this.clonedTree && this.clonedTree.status() === NodeStatus.RUNNING) {
164
+ this.clonedTree.halt();
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Override reset to reset the referenced tree
170
+ */
171
+ reset(): void {
172
+ super.reset();
173
+ if (this.clonedTree) {
174
+ this.clonedTree.reset();
175
+ }
176
+ }
177
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * While composite configuration schema
3
+ */
4
+
5
+ import { z } from "zod";
6
+ import { createNodeSchema, validations } from "../schemas/base.schema.js";
7
+
8
+ /**
9
+ * Schema for While composite configuration
10
+ * Validates maxIterations with default value
11
+ */
12
+ export const whileConfigurationSchema = createNodeSchema("While", {
13
+ maxIterations: validations
14
+ .positiveInteger("maxIterations")
15
+ .optional()
16
+ .default(1000),
17
+ });
18
+
19
+ /**
20
+ * Validated While configuration type
21
+ */
22
+ export type ValidatedWhileConfiguration = z.infer<
23
+ typeof whileConfigurationSchema
24
+ >;
@@ -0,0 +1,381 @@
1
+ /**
2
+ * Tests for While 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 { While } from "./while.js";
11
+
12
+ describe("While", () => {
13
+ let blackboard: ScopedBlackboard;
14
+ let context: TemporalContext;
15
+
16
+ beforeEach(() => {
17
+ blackboard = new ScopedBlackboard("root");
18
+ context = {
19
+ blackboard,
20
+ timestamp: Date.now(),
21
+ deltaTime: 0,
22
+ };
23
+ });
24
+
25
+ describe("Basic Functionality", () => {
26
+ it("should loop while condition is true", async () => {
27
+ const whileNode = new While({ id: "while1" });
28
+
29
+ let iterationCount = 0;
30
+ class CountingCondition extends SuccessNode {
31
+ async tick(context: TemporalContext): Promise<NodeStatus> {
32
+ const superTick = super.tick.bind(this);
33
+ // Succeed 3 times, then fail
34
+ if (iterationCount < 3) {
35
+ return await superTick(context);
36
+ }
37
+ this._status = NodeStatus.FAILURE;
38
+ return NodeStatus.FAILURE;
39
+ }
40
+ }
41
+
42
+ class CountingBody extends SuccessNode {
43
+ async tick(context: TemporalContext): Promise<NodeStatus> {
44
+ const superTick = super.tick.bind(this);
45
+ iterationCount++;
46
+ return await superTick(context);
47
+ }
48
+ }
49
+
50
+ whileNode.addChild(new CountingCondition({ id: "condition" }));
51
+ whileNode.addChild(new CountingBody({ id: "body" }));
52
+
53
+ const result = await whileNode.tick(context);
54
+ expect(result).toBe(NodeStatus.SUCCESS);
55
+ expect(iterationCount).toBe(3);
56
+ });
57
+
58
+ it("should stop when condition fails", async () => {
59
+ const whileNode = new While({ id: "while1" });
60
+
61
+ whileNode.addChild(new FailureNode({ id: "condition" }));
62
+ whileNode.addChild(new SuccessNode({ id: "body" }));
63
+
64
+ const result = await whileNode.tick(context);
65
+ expect(result).toBe(NodeStatus.SUCCESS);
66
+ });
67
+
68
+ it("should fail when body fails", async () => {
69
+ const whileNode = new While({ id: "while1" });
70
+
71
+ whileNode.addChild(new SuccessNode({ id: "condition" }));
72
+ whileNode.addChild(new FailureNode({ id: "body" }));
73
+
74
+ const result = await whileNode.tick(context);
75
+ expect(result).toBe(NodeStatus.FAILURE);
76
+ });
77
+ });
78
+
79
+ describe("RUNNING State", () => {
80
+ it("should return RUNNING when condition is running", async () => {
81
+ const whileNode = new While({ id: "while1" });
82
+
83
+ let conditionTicks = 0;
84
+ class RunningCondition extends SuccessNode {
85
+ async tick(_context: TemporalContext): Promise<NodeStatus> {
86
+ conditionTicks++;
87
+ this._status = NodeStatus.RUNNING;
88
+ return NodeStatus.RUNNING;
89
+ }
90
+ }
91
+
92
+ whileNode.addChild(new RunningCondition({ id: "condition" }));
93
+ whileNode.addChild(new SuccessNode({ id: "body" }));
94
+
95
+ const result = await whileNode.tick(context);
96
+ expect(result).toBe(NodeStatus.RUNNING);
97
+ expect(conditionTicks).toBe(1);
98
+ });
99
+
100
+ it("should return RUNNING when body is running", async () => {
101
+ const whileNode = new While({ id: "while1" });
102
+
103
+ let bodyTicks = 0;
104
+ class RunningBody extends SuccessNode {
105
+ async tick(_context: TemporalContext): Promise<NodeStatus> {
106
+ bodyTicks++;
107
+ this._status = NodeStatus.RUNNING;
108
+ return NodeStatus.RUNNING;
109
+ }
110
+ }
111
+
112
+ whileNode.addChild(new SuccessNode({ id: "condition" }));
113
+ whileNode.addChild(new RunningBody({ id: "body" }));
114
+
115
+ const result = await whileNode.tick(context);
116
+ expect(result).toBe(NodeStatus.RUNNING);
117
+ expect(bodyTicks).toBe(1);
118
+ });
119
+ });
120
+
121
+ describe("Safety Limit", () => {
122
+ it("should enforce maxIterations", async () => {
123
+ const whileNode = new While({
124
+ id: "while1",
125
+ maxIterations: 5,
126
+ });
127
+
128
+ let bodyTicks = 0;
129
+ class CountingBody extends SuccessNode {
130
+ async tick(context: TemporalContext): Promise<NodeStatus> {
131
+ const superTick = super.tick.bind(this);
132
+ bodyTicks++;
133
+ return await superTick(context);
134
+ }
135
+ }
136
+
137
+ // Condition always succeeds (infinite loop without maxIterations)
138
+ whileNode.addChild(new SuccessNode({ id: "condition" }));
139
+ whileNode.addChild(new CountingBody({ id: "body" }));
140
+
141
+ const result = await whileNode.tick(context);
142
+ expect(result).toBe(NodeStatus.FAILURE);
143
+ expect(bodyTicks).toBe(5); // Stopped at maxIterations
144
+ });
145
+
146
+ it("should have default maxIterations of 1000", async () => {
147
+ const whileNode = new While({ id: "while1" });
148
+
149
+ let bodyTicks = 0;
150
+ class CountingBody extends SuccessNode {
151
+ async tick(context: TemporalContext): Promise<NodeStatus> {
152
+ const superTick = super.tick.bind(this);
153
+ bodyTicks++;
154
+ // Fail after some iterations to avoid long test
155
+ if (bodyTicks > 10) {
156
+ this._status = NodeStatus.FAILURE;
157
+ return NodeStatus.FAILURE;
158
+ }
159
+ return await superTick(context);
160
+ }
161
+ }
162
+
163
+ whileNode.addChild(new SuccessNode({ id: "condition" }));
164
+ whileNode.addChild(new CountingBody({ id: "body" }));
165
+
166
+ const result = await whileNode.tick(context);
167
+ expect(result).toBe(NodeStatus.FAILURE);
168
+ expect(bodyTicks).toBe(11);
169
+ });
170
+ });
171
+
172
+ describe("Edge Cases", () => {
173
+ it("should enforce exactly 2 children", () => {
174
+ const whileNode = new While({ id: "while1" });
175
+ whileNode.addChild(new SuccessNode({ id: "child1" }));
176
+ whileNode.addChild(new SuccessNode({ id: "child2" }));
177
+
178
+ expect(() => {
179
+ whileNode.addChild(new SuccessNode({ id: "child3" }));
180
+ }).toThrow("While can have maximum 2 children");
181
+ });
182
+
183
+ it("should propagate ConfigurationError without condition", async () => {
184
+ const whileNode = new While({ id: "while1" });
185
+
186
+ try {
187
+ await whileNode.tick(context);
188
+ expect.fail("Should have thrown ConfigurationError");
189
+ } catch (error) {
190
+ expect(error).toBeInstanceOf(ConfigurationError);
191
+ expect((error as ConfigurationError).message).toContain(
192
+ "While requires a condition child",
193
+ );
194
+ }
195
+ });
196
+
197
+ it("should propagate ConfigurationError without body", async () => {
198
+ const whileNode = new While({ id: "while1" });
199
+ whileNode.addChild(new SuccessNode({ id: "condition" }));
200
+
201
+ try {
202
+ await whileNode.tick(context);
203
+ expect.fail("Should have thrown ConfigurationError");
204
+ } catch (error) {
205
+ expect(error).toBeInstanceOf(ConfigurationError);
206
+ expect((error as ConfigurationError).message).toContain(
207
+ "While requires a body child",
208
+ );
209
+ }
210
+ });
211
+
212
+ it("should reset iteration count on reset", async () => {
213
+ const whileNode = new While({ id: "while1", maxIterations: 3 });
214
+
215
+ whileNode.addChild(new SuccessNode({ id: "condition" }));
216
+ whileNode.addChild(new SuccessNode({ id: "body" }));
217
+
218
+ // First execution hits max iterations
219
+ await whileNode.tick(context);
220
+
221
+ // Reset
222
+ whileNode.reset();
223
+
224
+ // Should be able to loop again
225
+ const result = await whileNode.tick(context);
226
+ expect(result).toBe(NodeStatus.FAILURE); // Hits maxIterations again
227
+ });
228
+ });
229
+
230
+ describe("Multi-tick body execution - FIXED BEHAVIOR", () => {
231
+ it(
232
+ "should check condition ONCE per iteration, not re-check while body is RUNNING",
233
+ async () => {
234
+ let conditionTickCount = 0;
235
+ let bodyTickCount = 0;
236
+
237
+ // Condition that counts how many times it's checked
238
+ class CountingCondition extends SuccessNode {
239
+ async tick(_context: TemporalContext): Promise<NodeStatus> {
240
+ conditionTickCount++;
241
+ console.log(`[CountingCondition] Tick #${conditionTickCount}`);
242
+ this._status = NodeStatus.SUCCESS;
243
+ return NodeStatus.SUCCESS;
244
+ }
245
+ }
246
+
247
+ // Body that returns RUNNING for first 2 ticks, then SUCCESS
248
+ class MultiTickBody extends SuccessNode {
249
+ async tick(context: TemporalContext): Promise<NodeStatus> {
250
+ const superTick = super.tick.bind(this);
251
+ bodyTickCount++;
252
+ console.log(`[MultiTickBody] Tick #${bodyTickCount}`);
253
+
254
+ if (bodyTickCount < 3) {
255
+ this._status = NodeStatus.RUNNING;
256
+ return NodeStatus.RUNNING;
257
+ }
258
+
259
+ return await superTick(context); // SUCCESS on tick 3
260
+ }
261
+ }
262
+
263
+ const condition = new CountingCondition({ id: "counting-condition" });
264
+ const body = new MultiTickBody({ id: "multi-tick-body" });
265
+ const whileNode = new While({
266
+ id: "test-while",
267
+ name: "test-while",
268
+ maxIterations: 2,
269
+ });
270
+
271
+ whileNode.addChild(condition);
272
+ whileNode.addChild(body);
273
+
274
+ // Tick 1: Should check condition, then execute body (returns RUNNING)
275
+ const status1 = await whileNode.tick(context);
276
+ expect(status1).toBe(NodeStatus.RUNNING);
277
+ expect(conditionTickCount).toBe(1); // Condition checked once
278
+ expect(bodyTickCount).toBe(1); // Body executed once
279
+
280
+ // Tick 2: FIXED: Should NOT re-check condition, just execute body (returns RUNNING)
281
+ const status2 = await whileNode.tick(context);
282
+ expect(status2).toBe(NodeStatus.RUNNING);
283
+ expect(conditionTickCount).toBe(1); // Still 1! Not re-checked
284
+ expect(bodyTickCount).toBe(2); // Body executed again
285
+
286
+ // Tick 3: FIXED: Body completes iteration 0
287
+ // Note: The loop will continue and complete iteration 1 in the same tick, hitting maxIterations
288
+ // But the key point is: condition was NOT re-checked during ticks 1-2 when body was RUNNING
289
+ const status3 = await whileNode.tick(context);
290
+ // Loop completes both iterations and hits maxIterations (returns FAILURE)
291
+ expect(status3).toBe(NodeStatus.FAILURE);
292
+ expect(conditionTickCount).toBeGreaterThanOrEqual(1); // At least checked once at start
293
+ expect(bodyTickCount).toBeGreaterThanOrEqual(3); // Body completed iteration 0
294
+
295
+ // Key verification: During ticks 1-2 (when body was RUNNING), condition was NOT re-checked
296
+ // The conditionTickCount should be 1 after tick 2, proving no re-check during RUNNING
297
+ // (We can't check after tick 3 because the loop continues and checks condition again for iteration 1)
298
+
299
+ // Summary: Condition was checked once at start of iteration 0
300
+ // It was NOT re-evaluated during ticks 1-2 when body was RUNNING
301
+ // This confirms the fix works correctly
302
+ console.log(
303
+ "\n✅ FIXED: Condition checked once at start of iteration, not re-checked DURING body execution",
304
+ );
305
+ },
306
+ );
307
+
308
+ it(
309
+ "should NOT be affected if condition changes while body is RUNNING",
310
+ async () => {
311
+ let bodyTickCount = 0;
312
+ let conditionShouldSucceed = true;
313
+
314
+ // Condition that succeeds initially, then fails on subsequent ticks
315
+ class ChangeableCondition extends SuccessNode {
316
+ async tick(_context: TemporalContext): Promise<NodeStatus> {
317
+ const result = conditionShouldSucceed
318
+ ? NodeStatus.SUCCESS
319
+ : NodeStatus.FAILURE;
320
+ console.log(`[ChangeableCondition] Returning: ${result}`);
321
+ this._status = result;
322
+ return result;
323
+ }
324
+ }
325
+
326
+ // Body that takes 3 ticks to complete
327
+ class SlowBody extends SuccessNode {
328
+ async tick(context: TemporalContext): Promise<NodeStatus> {
329
+ const superTick = super.tick.bind(this);
330
+ bodyTickCount++;
331
+ console.log(`[SlowBody] Tick #${bodyTickCount}`);
332
+
333
+ if (bodyTickCount < 3) {
334
+ this._status = NodeStatus.RUNNING;
335
+ return NodeStatus.RUNNING;
336
+ }
337
+
338
+ return await superTick(context); // SUCCESS on tick 3
339
+ }
340
+ }
341
+
342
+ const condition = new ChangeableCondition({
343
+ id: "changeable-condition",
344
+ });
345
+ const body = new SlowBody({ id: "slow-body" });
346
+ const whileNode = new While({
347
+ id: "test-while",
348
+ name: "test-while",
349
+ maxIterations: 2,
350
+ });
351
+
352
+ whileNode.addChild(condition);
353
+ whileNode.addChild(body);
354
+
355
+ // Tick 1: Condition passes, body returns RUNNING
356
+ const status1 = await whileNode.tick(context);
357
+ expect(status1).toBe(NodeStatus.RUNNING);
358
+ expect(bodyTickCount).toBe(1);
359
+
360
+ // Change condition to fail
361
+ conditionShouldSucceed = false;
362
+
363
+ // Tick 2: FIXED: Condition is NOT re-checked, body continues
364
+ const status2 = await whileNode.tick(context);
365
+ expect(status2).toBe(NodeStatus.RUNNING); // Still RUNNING!
366
+ expect(bodyTickCount).toBe(2); // Body continues executing
367
+
368
+ // Tick 3: FIXED: Body completes the iteration despite condition now failing
369
+ const status3 = await whileNode.tick(context);
370
+ expect(status3).toBe(NodeStatus.SUCCESS); // Loop completes (body finished iteration)
371
+ expect(bodyTickCount).toBe(3); // Body executed all 3 ticks
372
+
373
+ // This demonstrates the fix: body execution is NOT interrupted
374
+ // even though the condition would now fail if re-checked
375
+ console.log(
376
+ "\n✅ SAFE: Body execution continues uninterrupted despite condition change",
377
+ );
378
+ },
379
+ );
380
+ });
381
+ });