@q1k-oss/btree-workflows 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +31 -0
- package/CLAUDE.md +181 -0
- package/LICENSE +21 -0
- package/README.md +920 -0
- package/behaviour-tree-workflows-landing/index.html +16 -0
- package/behaviour-tree-workflows-landing/package-lock.json +2074 -0
- package/behaviour-tree-workflows-landing/package.json +31 -0
- package/behaviour-tree-workflows-landing/public/favicon.svg +17 -0
- package/behaviour-tree-workflows-landing/src/App.css +103 -0
- package/behaviour-tree-workflows-landing/src/App.tsx +176 -0
- package/behaviour-tree-workflows-landing/src/components/BlackboardInspector.css +89 -0
- package/behaviour-tree-workflows-landing/src/components/BlackboardInspector.tsx +64 -0
- package/behaviour-tree-workflows-landing/src/components/ExampleSelector.css +64 -0
- package/behaviour-tree-workflows-landing/src/components/ExampleSelector.tsx +34 -0
- package/behaviour-tree-workflows-landing/src/components/ExecutionLog.css +107 -0
- package/behaviour-tree-workflows-landing/src/components/ExecutionLog.tsx +85 -0
- package/behaviour-tree-workflows-landing/src/components/Header.css +50 -0
- package/behaviour-tree-workflows-landing/src/components/Header.tsx +26 -0
- package/behaviour-tree-workflows-landing/src/components/StatusBadge.css +45 -0
- package/behaviour-tree-workflows-landing/src/components/StatusBadge.tsx +15 -0
- package/behaviour-tree-workflows-landing/src/components/Toolbar.css +74 -0
- package/behaviour-tree-workflows-landing/src/components/Toolbar.tsx +53 -0
- package/behaviour-tree-workflows-landing/src/components/TreeVisualizer.css +67 -0
- package/behaviour-tree-workflows-landing/src/components/TreeVisualizer.tsx +192 -0
- package/behaviour-tree-workflows-landing/src/components/YamlEditor.css +18 -0
- package/behaviour-tree-workflows-landing/src/components/YamlEditor.tsx +96 -0
- package/behaviour-tree-workflows-landing/src/lib/count-nodes.ts +11 -0
- package/behaviour-tree-workflows-landing/src/lib/execution-engine.ts +96 -0
- package/behaviour-tree-workflows-landing/src/lib/tree-layout.ts +136 -0
- package/behaviour-tree-workflows-landing/src/lib/yaml-examples.ts +549 -0
- package/behaviour-tree-workflows-landing/src/main.tsx +9 -0
- package/behaviour-tree-workflows-landing/src/stubs/activepieces.ts +18 -0
- package/behaviour-tree-workflows-landing/src/stubs/fs.ts +24 -0
- package/behaviour-tree-workflows-landing/src/stubs/path.ts +16 -0
- package/behaviour-tree-workflows-landing/src/stubs/temporal-activity.ts +6 -0
- package/behaviour-tree-workflows-landing/src/stubs/temporal-workflow.ts +22 -0
- package/behaviour-tree-workflows-landing/tsconfig.json +25 -0
- package/behaviour-tree-workflows-landing/vite.config.ts +40 -0
- package/demo-google-sheets.ts +181 -0
- package/demo-runtime-variables.ts +174 -0
- package/demo-template.ts +208 -0
- package/docs/ARCHITECTURE_SUMMARY.md +613 -0
- package/docs/NODE_REFERENCE.md +504 -0
- package/docs/README.md +53 -0
- package/docs/custom-nodes-architecture.md +826 -0
- package/docs/observability.md +175 -0
- package/docs/yaml-specification.md +990 -0
- package/examples/temporal/README.md +117 -0
- package/examples/temporal/activities.ts +373 -0
- package/examples/temporal/client.ts +115 -0
- package/examples/temporal/python-worker/activities.py +339 -0
- package/examples/temporal/python-worker/requirements.txt +12 -0
- package/examples/temporal/python-worker/worker.py +106 -0
- package/examples/temporal/worker.ts +66 -0
- package/examples/temporal/workflows.ts +6 -0
- package/examples/temporal/yaml-workflow-loader.ts +105 -0
- package/examples/yaml-test.ts +97 -0
- package/examples/yaml-workflows/01-simple-sequence.yaml +25 -0
- package/examples/yaml-workflows/02-parallel-timeout.yaml +45 -0
- package/examples/yaml-workflows/03-ecommerce-checkout.yaml +94 -0
- package/examples/yaml-workflows/04-ai-agent-workflow.yaml +346 -0
- package/examples/yaml-workflows/05-order-processing.yaml +146 -0
- package/examples/yaml-workflows/06-activity-test.yaml +71 -0
- package/examples/yaml-workflows/07-activity-simple-test.yaml +43 -0
- package/examples/yaml-workflows/08-file-processing.yaml +141 -0
- package/examples/yaml-workflows/09-http-request.yaml +137 -0
- package/examples/yaml-workflows/README.md +211 -0
- package/package.json +38 -0
- package/src/actions/code-execution.schema.ts +27 -0
- package/src/actions/code-execution.ts +218 -0
- package/src/actions/generate-file.test.ts +516 -0
- package/src/actions/generate-file.ts +166 -0
- package/src/actions/http-request.test.ts +784 -0
- package/src/actions/http-request.ts +228 -0
- package/src/actions/index.ts +20 -0
- package/src/actions/parse-file.test.ts +448 -0
- package/src/actions/parse-file.ts +139 -0
- package/src/actions/python-script.test.ts +439 -0
- package/src/actions/python-script.ts +154 -0
- package/src/base-node.test.ts +511 -0
- package/src/base-node.ts +605 -0
- package/src/behavior-tree.test.ts +431 -0
- package/src/behavior-tree.ts +283 -0
- package/src/blackboard.test.ts +222 -0
- package/src/blackboard.ts +192 -0
- package/src/composites/conditional.schema.ts +19 -0
- package/src/composites/conditional.test.ts +309 -0
- package/src/composites/conditional.ts +129 -0
- package/src/composites/for-each.schema.ts +23 -0
- package/src/composites/for-each.test.ts +254 -0
- package/src/composites/for-each.ts +132 -0
- package/src/composites/index.ts +15 -0
- package/src/composites/memory-sequence.schema.ts +19 -0
- package/src/composites/memory-sequence.test.ts +223 -0
- package/src/composites/memory-sequence.ts +98 -0
- package/src/composites/parallel.schema.ts +28 -0
- package/src/composites/parallel.test.ts +502 -0
- package/src/composites/parallel.ts +157 -0
- package/src/composites/reactive-sequence.schema.ts +19 -0
- package/src/composites/reactive-sequence.test.ts +170 -0
- package/src/composites/reactive-sequence.ts +85 -0
- package/src/composites/recovery.schema.ts +19 -0
- package/src/composites/recovery.test.ts +366 -0
- package/src/composites/recovery.ts +90 -0
- package/src/composites/selector.schema.ts +19 -0
- package/src/composites/selector.test.ts +387 -0
- package/src/composites/selector.ts +85 -0
- package/src/composites/sequence.schema.ts +19 -0
- package/src/composites/sequence.test.ts +337 -0
- package/src/composites/sequence.ts +72 -0
- package/src/composites/sub-tree.schema.ts +21 -0
- package/src/composites/sub-tree.test.ts +893 -0
- package/src/composites/sub-tree.ts +177 -0
- package/src/composites/while.schema.ts +24 -0
- package/src/composites/while.test.ts +381 -0
- package/src/composites/while.ts +149 -0
- package/src/data-store/index.ts +10 -0
- package/src/data-store/memory-store.ts +161 -0
- package/src/data-store/types.ts +94 -0
- package/src/debug/breakpoint.test.ts +47 -0
- package/src/debug/breakpoint.ts +30 -0
- package/src/debug/index.ts +17 -0
- package/src/debug/resume-point.test.ts +49 -0
- package/src/debug/resume-point.ts +29 -0
- package/src/decorators/delay.schema.ts +21 -0
- package/src/decorators/delay.test.ts +261 -0
- package/src/decorators/delay.ts +140 -0
- package/src/decorators/force-result.schema.ts +32 -0
- package/src/decorators/force-result.test.ts +133 -0
- package/src/decorators/force-result.ts +63 -0
- package/src/decorators/index.ts +13 -0
- package/src/decorators/invert.schema.ts +19 -0
- package/src/decorators/invert.test.ts +135 -0
- package/src/decorators/invert.ts +42 -0
- package/src/decorators/keep-running.schema.ts +20 -0
- package/src/decorators/keep-running.test.ts +105 -0
- package/src/decorators/keep-running.ts +49 -0
- package/src/decorators/precondition.schema.ts +19 -0
- package/src/decorators/precondition.test.ts +351 -0
- package/src/decorators/precondition.ts +139 -0
- package/src/decorators/repeat.schema.ts +21 -0
- package/src/decorators/repeat.test.ts +187 -0
- package/src/decorators/repeat.ts +94 -0
- package/src/decorators/run-once.schema.ts +19 -0
- package/src/decorators/run-once.test.ts +140 -0
- package/src/decorators/run-once.ts +61 -0
- package/src/decorators/soft-assert.schema.ts +19 -0
- package/src/decorators/soft-assert.test.ts +107 -0
- package/src/decorators/soft-assert.ts +68 -0
- package/src/decorators/timeout.schema.ts +21 -0
- package/src/decorators/timeout.test.ts +274 -0
- package/src/decorators/timeout.ts +159 -0
- package/src/errors.test.ts +63 -0
- package/src/errors.ts +34 -0
- package/src/events.test.ts +347 -0
- package/src/events.ts +183 -0
- package/src/index.ts +80 -0
- package/src/integrations/index.ts +30 -0
- package/src/integrations/integration-action.test.ts +571 -0
- package/src/integrations/integration-action.ts +233 -0
- package/src/integrations/piece-executor.ts +320 -0
- package/src/observability/execution-tracker.ts +320 -0
- package/src/observability/index.ts +23 -0
- package/src/observability/sinks.ts +138 -0
- package/src/observability/types.ts +130 -0
- package/src/registry-utils.ts +147 -0
- package/src/registry.test.ts +466 -0
- package/src/registry.ts +334 -0
- package/src/schemas/base.schema.ts +104 -0
- package/src/schemas/index.ts +223 -0
- package/src/schemas/integration.test.ts +238 -0
- package/src/schemas/tree-definition.schema.ts +170 -0
- package/src/schemas/validation.test.ts +146 -0
- package/src/schemas/validation.ts +122 -0
- package/src/scripting/index.ts +22 -0
- package/src/templates/template-loader.test.ts +281 -0
- package/src/templates/template-loader.ts +152 -0
- package/src/temporal-integration.test.ts +213 -0
- package/src/test-nodes.ts +259 -0
- package/src/types.ts +503 -0
- package/src/utilities/index.ts +17 -0
- package/src/utilities/log-message.test.ts +275 -0
- package/src/utilities/log-message.ts +134 -0
- package/src/utilities/regex-extract.test.ts +138 -0
- package/src/utilities/regex-extract.ts +108 -0
- package/src/utilities/variable-resolver.test.ts +416 -0
- package/src/utilities/variable-resolver.ts +318 -0
- package/src/utils/error-handler.test.ts +117 -0
- package/src/utils/error-handler.ts +48 -0
- package/src/utils/signal-check.test.ts +234 -0
- package/src/utils/signal-check.ts +140 -0
- package/src/yaml/errors.ts +143 -0
- package/src/yaml/index.ts +30 -0
- package/src/yaml/loader.ts +39 -0
- package/src/yaml/parser.ts +286 -0
- package/src/yaml/validation/semantic-validator.ts +196 -0
- package/templates/google-sheets/insert-row.yaml +76 -0
- package/templates/notification-sender.yaml +33 -0
- package/templates/order-validation.yaml +44 -0
- package/tsconfig.json +24 -0
- package/vitest.config.ts +25 -0
- package/workflows/order-processor.yaml +59 -0
- package/workflows/process-order-workflow.yaml +142 -0
|
@@ -0,0 +1,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
|
+
});
|