@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,826 @@
1
+ # Custom Nodes Plugin System Architecture
2
+
3
+ **Goal**: Allow tenants to create custom workflow nodes without writing code initially, then progressively enable full TypeScript node development.
4
+
5
+ ---
6
+
7
+ ## Phased Rollout Strategy
8
+
9
+ ### Phase 1 (MVP - Week 1-4): Built-in Nodes Only
10
+ - ✅ 32 standard nodes from `registerStandardNodes()`
11
+ - ✅ Python/JavaScript interpreter nodes (coming in Phase 2A)
12
+ - ❌ No custom uploads yet
13
+
14
+ ### Phase 2A (Week 5-6): Interpreter Nodes
15
+ - ✅ Python interpreter node (user provides Python code inline)
16
+ - ✅ JavaScript interpreter node (user provides JS code inline)
17
+ - ✅ Sandboxed execution with timeouts and memory limits
18
+
19
+ ### Phase 2B (Week 7-8): Custom Node Uploads
20
+ - ✅ Upload TypeScript files
21
+ - ✅ Server compiles and bundles
22
+ - ✅ Per-tenant node registry
23
+ - ✅ Versioning and rollback
24
+
25
+ ### Phase 3 (Week 9-12): Full SDK
26
+ - ✅ Type-safe custom node SDK
27
+ - ✅ Local development with CLI
28
+ - ✅ Automated testing
29
+ - ✅ Marketplace for sharing nodes
30
+
31
+ ---
32
+
33
+ ## Phase 2A: Interpreter Nodes (Week 5-6)
34
+
35
+ ### Architecture
36
+
37
+ ```
38
+ YAML Workflow
39
+
40
+ Contains PythonInterpreter node with inline script
41
+
42
+ Worker executes node
43
+
44
+ Node calls executePython activity
45
+
46
+ Activity spawns Python subprocess
47
+
48
+ Script executes with blackboard variables as context
49
+
50
+ Result written back to blackboard
51
+ ```
52
+
53
+ ### YAML Definition
54
+
55
+ ```yaml
56
+ type: Sequence
57
+ id: data-processing
58
+ children:
59
+ # Fetch data via HTTP
60
+ - type: HttpRequest
61
+ id: fetch-data
62
+ props:
63
+ url: "https://api.example.com/data"
64
+ method: "GET"
65
+
66
+ # Process data with Python
67
+ - type: PythonInterpreter
68
+ id: process-data
69
+ props:
70
+ outputKey: "processedData"
71
+ timeout: "30s"
72
+ script: |
73
+ import json
74
+ import numpy as np
75
+
76
+ # httpResponse comes from blackboard
77
+ data = httpResponse['data']
78
+
79
+ # Process data
80
+ values = [item['value'] for item in data]
81
+ mean = np.mean(values)
82
+ std = np.std(values)
83
+
84
+ # Result stored in blackboard under 'processedData'
85
+ result = {
86
+ 'mean': mean,
87
+ 'std': std,
88
+ 'count': len(values)
89
+ }
90
+
91
+ # Send processed data
92
+ - type: HttpRequest
93
+ id: send-results
94
+ props:
95
+ url: "https://api.example.com/results"
96
+ method: "POST"
97
+ body:
98
+ stats: "{{processedData}}"
99
+ ```
100
+
101
+ ### Implementation
102
+
103
+ ```typescript
104
+ // worker/src/actions/python-interpreter.ts
105
+ import { ActionNode } from '@wayfarer-ai/btree';
106
+ import { proxyActivities } from '@temporalio/workflow';
107
+ import type { NodeStatus, TemporalContext } from '@wayfarer-ai/btree';
108
+
109
+ interface InterpreterActivities {
110
+ executePython(
111
+ script: string,
112
+ context: Record<string, any>,
113
+ options: { timeout: string }
114
+ ): Promise<any>;
115
+ }
116
+
117
+ const activities = proxyActivities<InterpreterActivities>({
118
+ startToCloseTimeout: '5m',
119
+ heartbeatTimeout: '30s',
120
+ });
121
+
122
+ export class PythonInterpreter extends ActionNode {
123
+ private script: string;
124
+ private outputKey: string;
125
+ private timeout: string;
126
+
127
+ constructor(config: any) {
128
+ super(config);
129
+ this.script = config.script;
130
+ this.outputKey = config.outputKey || 'pythonResult';
131
+ this.timeout = config.timeout || '30s';
132
+ }
133
+
134
+ protected async executeTick(context: TemporalContext): Promise<NodeStatus> {
135
+ try {
136
+ // Gather blackboard data as script context
137
+ const scriptContext = context.blackboard.toJSON();
138
+
139
+ // Execute Python in activity
140
+ const result = await activities.executePython(
141
+ this.script,
142
+ scriptContext,
143
+ { timeout: this.timeout }
144
+ );
145
+
146
+ // Store result
147
+ context.blackboard.set(this.outputKey, result);
148
+
149
+ return NodeStatus.SUCCESS;
150
+ } catch (error) {
151
+ this._lastError = `Python execution failed: ${error.message}`;
152
+ return NodeStatus.FAILURE;
153
+ }
154
+ }
155
+ }
156
+ ```
157
+
158
+ ```typescript
159
+ // worker/src/activities/python-interpreter.ts
160
+ import { spawn } from 'child_process';
161
+ import { writeFileSync, unlinkSync, mkdirSync, existsSync } from 'fs';
162
+ import { join } from 'path';
163
+ import { v4 as uuid } from 'uuid';
164
+
165
+ const PYTHON_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes max
166
+
167
+ export async function executePython(
168
+ script: string,
169
+ context: Record<string, any>,
170
+ options: { timeout: string }
171
+ ): Promise<any> {
172
+ const scriptId = uuid();
173
+ const tempDir = join('/tmp', 'python-scripts');
174
+
175
+ // Create temp directory
176
+ if (!existsSync(tempDir)) {
177
+ mkdirSync(tempDir, { recursive: true });
178
+ }
179
+
180
+ const scriptPath = join(tempDir, `${scriptId}.py`);
181
+
182
+ // Build full Python script with context injection
183
+ const fullScript = `
184
+ import json
185
+ import sys
186
+
187
+ # Inject context variables from blackboard
188
+ ${Object.entries(context)
189
+ .map(([key, val]) => `${key} = ${JSON.stringify(val)}`)
190
+ .join('\n')}
191
+
192
+ # User script
193
+ ${script}
194
+
195
+ # Capture result variable
196
+ if 'result' in locals():
197
+ print(json.dumps(result))
198
+ else:
199
+ print(json.dumps({}))
200
+ `;
201
+
202
+ writeFileSync(scriptPath, fullScript);
203
+
204
+ try {
205
+ // Parse timeout
206
+ const timeoutMs = parseTimeout(options.timeout);
207
+
208
+ // Execute Python subprocess
209
+ const output = await executeWithTimeout(scriptPath, timeoutMs);
210
+
211
+ // Parse JSON output
212
+ try {
213
+ return JSON.parse(output.trim());
214
+ } catch (e) {
215
+ throw new Error(`Python script did not return valid JSON: ${output}`);
216
+ }
217
+ } finally {
218
+ // Cleanup
219
+ try {
220
+ unlinkSync(scriptPath);
221
+ } catch (e) {
222
+ // Ignore cleanup errors
223
+ }
224
+ }
225
+ }
226
+
227
+ function executeWithTimeout(
228
+ scriptPath: string,
229
+ timeoutMs: number
230
+ ): Promise<string> {
231
+ return new Promise((resolve, reject) => {
232
+ const python = spawn('python3', [scriptPath]);
233
+ let stdout = '';
234
+ let stderr = '';
235
+ let timedOut = false;
236
+
237
+ // Timeout timer
238
+ const timer = setTimeout(() => {
239
+ timedOut = true;
240
+ python.kill('SIGTERM');
241
+ reject(new Error(`Python script timed out after ${timeoutMs}ms`));
242
+ }, timeoutMs);
243
+
244
+ python.stdout.on('data', (data) => {
245
+ stdout += data.toString();
246
+ });
247
+
248
+ python.stderr.on('data', (data) => {
249
+ stderr += data.toString();
250
+ });
251
+
252
+ python.on('close', (code) => {
253
+ clearTimeout(timer);
254
+
255
+ if (timedOut) return; // Already rejected
256
+
257
+ if (code !== 0) {
258
+ reject(new Error(`Python exited with code ${code}: ${stderr}`));
259
+ } else {
260
+ resolve(stdout);
261
+ }
262
+ });
263
+
264
+ python.on('error', (err) => {
265
+ clearTimeout(timer);
266
+ reject(new Error(`Failed to start Python: ${err.message}`));
267
+ });
268
+ });
269
+ }
270
+
271
+ function parseTimeout(timeout: string): number {
272
+ const match = timeout.match(/^(\d+)(s|m|h)?$/);
273
+ if (!match) return 30000; // default 30s
274
+
275
+ const value = parseInt(match[1]);
276
+ const unit = match[2] || 's';
277
+
278
+ switch (unit) {
279
+ case 's': return value * 1000;
280
+ case 'm': return value * 60 * 1000;
281
+ case 'h': return value * 60 * 60 * 1000;
282
+ default: return value * 1000;
283
+ }
284
+ }
285
+ ```
286
+
287
+ ### Security & Sandboxing
288
+
289
+ **Baseline Security**:
290
+ ```python
291
+ # 1. No network access (use Docker with --network=none)
292
+ # 2. Limited filesystem access (read-only mount)
293
+ # 3. Memory limits (Docker --memory flag)
294
+ # 4. CPU limits (Docker --cpus flag)
295
+ # 5. Timeout enforcement (killed after timeout)
296
+ ```
297
+
298
+ **Docker-based Execution** (Production):
299
+
300
+ ```typescript
301
+ // worker/src/activities/python-interpreter.ts (production version)
302
+ import Docker from 'dockerode';
303
+
304
+ const docker = new Docker();
305
+
306
+ export async function executePython(
307
+ script: string,
308
+ context: Record<string, any>,
309
+ options: { timeout: string }
310
+ ): Promise<any> {
311
+ const scriptId = uuid();
312
+
313
+ // Create container
314
+ const container = await docker.createContainer({
315
+ Image: 'python:3.11-slim',
316
+ Cmd: ['python', '-c', buildScript(script, context)],
317
+ HostConfig: {
318
+ Memory: 512 * 1024 * 1024, // 512MB
319
+ MemorySwap: 512 * 1024 * 1024,
320
+ NanoCpus: 1 * 1e9, // 1 CPU
321
+ NetworkMode: 'none', // No network access
322
+ ReadonlyRootfs: true, // Read-only filesystem
323
+ AutoRemove: true,
324
+ },
325
+ WorkingDir: '/workspace',
326
+ });
327
+
328
+ try {
329
+ // Start container
330
+ await container.start();
331
+
332
+ // Wait for completion with timeout
333
+ const timeoutMs = parseTimeout(options.timeout);
334
+ const result = await Promise.race([
335
+ container.wait(),
336
+ new Promise((_, reject) => {
337
+ setTimeout(() => {
338
+ container.kill();
339
+ reject(new Error('Timeout'));
340
+ }, timeoutMs);
341
+ }),
342
+ ]);
343
+
344
+ // Get logs
345
+ const logs = await container.logs({
346
+ stdout: true,
347
+ stderr: true,
348
+ });
349
+
350
+ // Parse output
351
+ return JSON.parse(logs.toString());
352
+ } finally {
353
+ // Container auto-removed due to AutoRemove: true
354
+ }
355
+ }
356
+ ```
357
+
358
+ **Allowed Python Packages** (pre-installed in Docker image):
359
+ - numpy, pandas, scipy (data processing)
360
+ - requests (HTTP - but network disabled, just for lib compatibility)
361
+ - json, datetime, math (stdlib)
362
+ - No database drivers, no subprocess, no file I/O
363
+
364
+ ---
365
+
366
+ ## Phase 2B: Custom Node Uploads (Week 7-8)
367
+
368
+ ### Architecture
369
+
370
+ ```
371
+ User writes TypeScript node
372
+
373
+ Uploads to platform (POST /api/custom-nodes)
374
+
375
+ Server validates and compiles with esbuild
376
+
377
+ Bundle stored in GCS
378
+
379
+ Metadata stored in PostgreSQL
380
+
381
+ Workers dynamically import bundle
382
+
383
+ Node available in workflows
384
+ ```
385
+
386
+ ### Database Schema
387
+
388
+ ```sql
389
+ CREATE TABLE custom_nodes (
390
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
391
+ tenant_id UUID REFERENCES tenants(id) NOT NULL,
392
+ name VARCHAR(255) NOT NULL, -- e.g., "SendSlackMessage"
393
+ version VARCHAR(20) NOT NULL, -- e.g., "1.0.0"
394
+ description TEXT,
395
+ source_code TEXT NOT NULL, -- TypeScript source
396
+ bundle_url VARCHAR(500), -- GCS URL
397
+ schema JSONB, -- Zod schema for props validation
398
+ status VARCHAR(50) DEFAULT 'draft', -- draft, active, deprecated
399
+ created_by UUID REFERENCES users(id),
400
+ created_at TIMESTAMP DEFAULT NOW(),
401
+
402
+ UNIQUE(tenant_id, name, version)
403
+ );
404
+
405
+ CREATE TABLE workflow_node_dependencies (
406
+ workflow_id UUID REFERENCES workflows(id),
407
+ custom_node_id UUID REFERENCES custom_nodes(id),
408
+
409
+ PRIMARY KEY(workflow_id, custom_node_id)
410
+ );
411
+ ```
412
+
413
+ ### Upload API
414
+
415
+ ```typescript
416
+ // api-server/src/routes/custom-nodes.ts
417
+ import { FastifyInstance } from 'fastify';
418
+ import * as esbuild from 'esbuild';
419
+ import { Storage } from '@google-cloud/storage';
420
+ import { z } from 'zod';
421
+
422
+ const storage = new Storage();
423
+ const bucket = storage.bucket(process.env.GCS_BUCKET!);
424
+
425
+ const uploadSchema = z.object({
426
+ name: z.string().min(1).max(255),
427
+ version: z.string().regex(/^\d+\.\d+\.\d+$/), // semver
428
+ description: z.string().optional(),
429
+ sourceCode: z.string().min(1),
430
+ schema: z.record(z.any()).optional(), // Zod schema as JSON
431
+ });
432
+
433
+ export default async (fastify: FastifyInstance) => {
434
+ // Upload custom node
435
+ fastify.post('/custom-nodes', {
436
+ onRequest: [fastify.authenticate],
437
+ schema: { body: uploadSchema },
438
+ }, async (request) => {
439
+ const { name, version, description, sourceCode, schema } = request.body;
440
+ const { tenantId } = request;
441
+
442
+ // 1. Validate TypeScript syntax
443
+ try {
444
+ await esbuild.transform(sourceCode, {
445
+ loader: 'ts',
446
+ target: 'es2020',
447
+ });
448
+ } catch (error) {
449
+ throw fastify.httpErrors.badRequest(
450
+ `TypeScript compilation failed: ${error.message}`
451
+ );
452
+ }
453
+
454
+ // 2. Bundle the code
455
+ const bundleResult = await esbuild.build({
456
+ stdin: {
457
+ contents: sourceCode,
458
+ loader: 'ts',
459
+ resolveDir: process.cwd(),
460
+ },
461
+ bundle: true,
462
+ platform: 'node',
463
+ target: 'node18',
464
+ format: 'esm',
465
+ external: ['@wayfarer-ai/btree', '@temporalio/workflow'],
466
+ write: false,
467
+ });
468
+
469
+ const bundleCode = bundleResult.outputFiles[0].text;
470
+
471
+ // 3. Upload bundle to GCS
472
+ const bundlePath = `${tenantId}/custom-nodes/${name}/${version}/bundle.js`;
473
+ const file = bucket.file(bundlePath);
474
+
475
+ await file.save(bundleCode, {
476
+ contentType: 'application/javascript',
477
+ metadata: {
478
+ tenantId,
479
+ nodeName: name,
480
+ version,
481
+ },
482
+ });
483
+
484
+ const bundleUrl = `gs://${process.env.GCS_BUCKET}/${bundlePath}`;
485
+
486
+ // 4. Save metadata to database
487
+ const [customNode] = await fastify.db
488
+ .insert(schema.customNodes)
489
+ .values({
490
+ tenantId,
491
+ name,
492
+ version,
493
+ description,
494
+ sourceCode,
495
+ bundleUrl,
496
+ schema,
497
+ status: 'draft',
498
+ createdBy: request.user.id,
499
+ })
500
+ .returning();
501
+
502
+ return { customNode };
503
+ });
504
+
505
+ // List custom nodes
506
+ fastify.get('/custom-nodes', {
507
+ onRequest: [fastify.authenticate],
508
+ }, async (request) => {
509
+ const { tenantId } = request;
510
+
511
+ const nodes = await fastify.db
512
+ .select()
513
+ .from(schema.customNodes)
514
+ .where(eq(schema.customNodes.tenantId, tenantId))
515
+ .orderBy(desc(schema.customNodes.createdAt));
516
+
517
+ return { nodes };
518
+ });
519
+
520
+ // Activate custom node (make available in workflows)
521
+ fastify.post('/custom-nodes/:id/activate', {
522
+ onRequest: [fastify.authenticate],
523
+ }, async (request) => {
524
+ const { id } = request.params;
525
+ const { tenantId } = request;
526
+
527
+ const [node] = await fastify.db
528
+ .update(schema.customNodes)
529
+ .set({ status: 'active' })
530
+ .where(and(
531
+ eq(schema.customNodes.id, id),
532
+ eq(schema.customNodes.tenantId, tenantId)
533
+ ))
534
+ .returning();
535
+
536
+ return { node, message: 'Custom node activated' };
537
+ });
538
+ };
539
+ ```
540
+
541
+ ### Worker Integration
542
+
543
+ ```typescript
544
+ // worker/src/custom-node-loader.ts
545
+ import { Storage } from '@google-cloud/storage';
546
+ import { writeFileSync, readFileSync } from 'fs';
547
+ import { join } from 'path';
548
+
549
+ const storage = new Storage();
550
+ const bucket = storage.bucket(process.env.GCS_BUCKET!);
551
+
552
+ export class CustomNodeLoader {
553
+ private cache = new Map<string, any>();
554
+
555
+ async loadCustomNode(
556
+ tenantId: string,
557
+ nodeName: string,
558
+ version: string
559
+ ): Promise<any> {
560
+ const cacheKey = `${tenantId}:${nodeName}:${version}`;
561
+
562
+ // Check cache
563
+ if (this.cache.has(cacheKey)) {
564
+ return this.cache.get(cacheKey);
565
+ }
566
+
567
+ // Download bundle from GCS
568
+ const bundlePath = `${tenantId}/custom-nodes/${nodeName}/${version}/bundle.js`;
569
+ const localPath = join('/tmp', 'custom-nodes', bundlePath);
570
+
571
+ const file = bucket.file(bundlePath);
572
+ await file.download({ destination: localPath });
573
+
574
+ // Dynamic import
575
+ const module = await import(localPath);
576
+ const NodeClass = module.default || module[nodeName];
577
+
578
+ if (!NodeClass) {
579
+ throw new Error(`Node class not found in bundle: ${nodeName}`);
580
+ }
581
+
582
+ // Cache for future use
583
+ this.cache.set(cacheKey, NodeClass);
584
+
585
+ return NodeClass;
586
+ }
587
+
588
+ clearCache() {
589
+ this.cache.clear();
590
+ }
591
+ }
592
+ ```
593
+
594
+ ```typescript
595
+ // worker/src/workflows/yaml-workflow.ts
596
+ import {
597
+ BehaviorTree,
598
+ Registry,
599
+ registerStandardNodes,
600
+ loadTreeFromYaml,
601
+ } from '@wayfarer-ai/btree';
602
+ import { CustomNodeLoader } from '../custom-node-loader';
603
+
604
+ const customNodeLoader = new CustomNodeLoader();
605
+
606
+ export async function yamlWorkflow(args: YamlWorkflowArgs) {
607
+ const { tenantId } = args.metadata;
608
+
609
+ // 1. Setup registry with standard nodes
610
+ const registry = new Registry();
611
+ registerStandardNodes(registry);
612
+
613
+ // 2. Load custom nodes for this tenant
614
+ const customNodes = await fetchCustomNodes(tenantId);
615
+
616
+ for (const node of customNodes) {
617
+ const NodeClass = await customNodeLoader.loadCustomNode(
618
+ tenantId,
619
+ node.name,
620
+ node.version
621
+ );
622
+
623
+ registry.register(node.name, NodeClass, {
624
+ category: 'action', // or from node metadata
625
+ });
626
+ }
627
+
628
+ // 3. Parse YAML and execute
629
+ const root = loadTreeFromYaml(args.yamlContent, registry);
630
+ const tree = new BehaviorTree(root);
631
+ return tree.toWorkflow()(args);
632
+ }
633
+
634
+ async function fetchCustomNodes(tenantId: string) {
635
+ // Fetch from database (cached via worker startup)
636
+ // In production, cache this with TTL
637
+ return []; // TODO: implement
638
+ }
639
+ ```
640
+
641
+ ### YAML Usage
642
+
643
+ Once uploaded and activated, use like built-in nodes:
644
+
645
+ ```yaml
646
+ type: Sequence
647
+ id: notification-workflow
648
+ children:
649
+ # Custom node uploaded by tenant
650
+ - type: SendSlackMessage
651
+ id: notify-slack
652
+ props:
653
+ channel: "#alerts"
654
+ message: "Order {{orderId}} received!"
655
+
656
+ # Another custom node
657
+ - type: CreateJiraTicket
658
+ id: create-ticket
659
+ props:
660
+ project: "OPS"
661
+ summary: "New order: {{orderId}}"
662
+ description: "Customer: {{customerEmail}}"
663
+ ```
664
+
665
+ ### Example Custom Node Source
666
+
667
+ ```typescript
668
+ // User uploads this TypeScript code
669
+ import { ActionNode } from '@wayfarer-ai/btree';
670
+ import { proxyActivities } from '@temporalio/workflow';
671
+ import type { NodeStatus, TemporalContext } from '@wayfarer-ai/btree';
672
+
673
+ interface SlackActivities {
674
+ sendSlackMessage(channel: string, message: string): Promise<void>;
675
+ }
676
+
677
+ const activities = proxyActivities<SlackActivities>({
678
+ startToCloseTimeout: '30s',
679
+ });
680
+
681
+ export default class SendSlackMessage extends ActionNode {
682
+ private channel: string;
683
+ private message: string;
684
+
685
+ constructor(config: any) {
686
+ super(config);
687
+ this.channel = config.channel;
688
+ this.message = config.message;
689
+ }
690
+
691
+ protected async executeTick(context: TemporalContext): Promise<NodeStatus> {
692
+ try {
693
+ // Resolve template variables
694
+ const resolvedMessage = this.resolveVariables(
695
+ this.message,
696
+ context.blackboard.toJSON()
697
+ );
698
+
699
+ // Call activity
700
+ await activities.sendSlackMessage(this.channel, resolvedMessage);
701
+
702
+ return NodeStatus.SUCCESS;
703
+ } catch (error) {
704
+ this._lastError = `Slack message failed: ${error.message}`;
705
+ return NodeStatus.FAILURE;
706
+ }
707
+ }
708
+
709
+ private resolveVariables(template: string, context: Record<string, any>): string {
710
+ return template.replace(/\{\{(\w+)\}\}/g, (_, key) => {
711
+ return context[key] || '';
712
+ });
713
+ }
714
+ }
715
+ ```
716
+
717
+ ### Security Considerations
718
+
719
+ **Code Review Required**:
720
+ - Manual approval before activation (Week 9 feature)
721
+ - Automated scanning for malicious patterns
722
+ - Rate limiting on custom node executions
723
+
724
+ **Isolation**:
725
+ - Per-tenant node registry (tenant A can't use tenant B's nodes)
726
+ - Separate GCS paths per tenant
727
+ - Worker cache isolation
728
+
729
+ **Versioning**:
730
+ - Immutable versions (can't modify v1.0.0 once deployed)
731
+ - Workflows pin to specific versions
732
+ - Deprecation warnings for old versions
733
+
734
+ ---
735
+
736
+ ## Phase 3: Full SDK (Week 9-12)
737
+
738
+ ### Local Development
739
+
740
+ ```bash
741
+ # Install SDK
742
+ npm install -g @wayfarer-ai/workflow-sdk
743
+
744
+ # Create new custom node
745
+ workflow-sdk init SendSlackMessage
746
+
747
+ # Generated template:
748
+ # src/
749
+ # send-slack-message.ts
750
+ # send-slack-message.test.ts
751
+ # send-slack-message.schema.ts
752
+ ```
753
+
754
+ ### CLI Commands
755
+
756
+ ```bash
757
+ # Test locally
758
+ workflow-sdk test
759
+
760
+ # Validate
761
+ workflow-sdk validate
762
+
763
+ # Upload to platform
764
+ workflow-sdk deploy --tenant mycompany
765
+ ```
766
+
767
+ ### Marketplace
768
+
769
+ - Browse community nodes
770
+ - One-click install
771
+ - Reviews and ratings
772
+ - Verified publishers
773
+
774
+ ---
775
+
776
+ ## Comparison: Interpreter vs Custom Nodes
777
+
778
+ | Feature | Interpreter Nodes | Custom Node Uploads |
779
+ |---------|------------------|---------------------|
780
+ | **Ease of Use** | ✅ Very easy (paste code) | ⚠️ Requires TypeScript |
781
+ | **Performance** | ⚠️ Subprocess overhead | ✅ Native performance |
782
+ | **Type Safety** | ❌ No validation | ✅ Full TypeScript |
783
+ | **Reusability** | ❌ Copy-paste | ✅ Import in any workflow |
784
+ | **Versioning** | ❌ Manual | ✅ Semver |
785
+ | **Best For** | Quick scripts, data transforms | Reusable integrations, complex logic |
786
+
787
+ ---
788
+
789
+ ## Recommended Approach for MVP
790
+
791
+ **Week 1-4 (MVP)**:
792
+ - Ship with 32 built-in nodes
793
+ - Document Python/JavaScript interpreter pattern
794
+ - No custom uploads yet
795
+
796
+ **Week 5-6 (Phase 2A)**:
797
+ - Add PythonInterpreter and JavaScriptInterpreter nodes
798
+ - Sandboxed execution
799
+ - Allow inline scripts in YAML
800
+
801
+ **Week 7-8 (Phase 2B)**:
802
+ - Enable custom node uploads
803
+ - Basic UI for upload
804
+ - No marketplace yet
805
+
806
+ **Week 9-12 (Phase 3)**:
807
+ - Full SDK
808
+ - CLI tools
809
+ - Marketplace
810
+
811
+ This gives you a **production platform in 4 weeks** and custom nodes in **8 weeks**.
812
+
813
+ ---
814
+
815
+ ## Next Steps
816
+
817
+ 1. **Decide on Phase 2A timeline**: Do you need interpreters for MVP, or can you wait until Week 5?
818
+
819
+ 2. **Activity implementations**: Which integrations do you want built-in?
820
+ - Slack, Discord, email?
821
+ - Database (Postgres, MySQL, MongoDB)?
822
+ - AI (OpenAI, Anthropic)?
823
+
824
+ 3. **Sandboxing approach**: Docker containers or simple subprocess?
825
+
826
+ Let me know and I can provide detailed implementation for any of these phases!