@foresthubai/workflow-core 0.3.0

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 (339) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +14 -0
  3. package/README.md +63 -0
  4. package/dist/api/index.d.ts +7 -0
  5. package/dist/api/index.d.ts.map +1 -0
  6. package/dist/api/index.js +2 -0
  7. package/dist/api/index.js.map +1 -0
  8. package/dist/api/workflow.d.ts +607 -0
  9. package/dist/api/workflow.d.ts.map +1 -0
  10. package/dist/api/workflow.js +6 -0
  11. package/dist/api/workflow.js.map +1 -0
  12. package/dist/channel/Channel.d.ts +10 -0
  13. package/dist/channel/Channel.d.ts.map +1 -0
  14. package/dist/channel/Channel.js +2 -0
  15. package/dist/channel/Channel.js.map +1 -0
  16. package/dist/channel/ChannelDefinition.d.ts +27 -0
  17. package/dist/channel/ChannelDefinition.d.ts.map +1 -0
  18. package/dist/channel/ChannelDefinition.js +50 -0
  19. package/dist/channel/ChannelDefinition.js.map +1 -0
  20. package/dist/channel/index.d.ts +7 -0
  21. package/dist/channel/index.d.ts.map +1 -0
  22. package/dist/channel/index.js +4 -0
  23. package/dist/channel/index.js.map +1 -0
  24. package/dist/channel/serialization.d.ts +17 -0
  25. package/dist/channel/serialization.d.ts.map +1 -0
  26. package/dist/channel/serialization.js +63 -0
  27. package/dist/channel/serialization.js.map +1 -0
  28. package/dist/deploy/index.d.ts +2 -0
  29. package/dist/deploy/index.d.ts.map +1 -0
  30. package/dist/deploy/index.js +2 -0
  31. package/dist/deploy/index.js.map +1 -0
  32. package/dist/deploy/requirements.d.ts +17 -0
  33. package/dist/deploy/requirements.d.ts.map +1 -0
  34. package/dist/deploy/requirements.js +41 -0
  35. package/dist/deploy/requirements.js.map +1 -0
  36. package/dist/diagnostics/__fixtures__/diagnosticFixtures.d.ts +28 -0
  37. package/dist/diagnostics/__fixtures__/diagnosticFixtures.d.ts.map +1 -0
  38. package/dist/diagnostics/__fixtures__/diagnosticFixtures.js +125 -0
  39. package/dist/diagnostics/__fixtures__/diagnosticFixtures.js.map +1 -0
  40. package/dist/diagnostics/diagnostics.d.ts +128 -0
  41. package/dist/diagnostics/diagnostics.d.ts.map +1 -0
  42. package/dist/diagnostics/diagnostics.js +783 -0
  43. package/dist/diagnostics/diagnostics.js.map +1 -0
  44. package/dist/diagnostics/index.d.ts +3 -0
  45. package/dist/diagnostics/index.d.ts.map +1 -0
  46. package/dist/diagnostics/index.js +2 -0
  47. package/dist/diagnostics/index.js.map +1 -0
  48. package/dist/edge/Edge.d.ts +22 -0
  49. package/dist/edge/Edge.d.ts.map +1 -0
  50. package/dist/edge/Edge.js +2 -0
  51. package/dist/edge/Edge.js.map +1 -0
  52. package/dist/edge/EdgeDefinition.d.ts +10 -0
  53. package/dist/edge/EdgeDefinition.d.ts.map +1 -0
  54. package/dist/edge/EdgeDefinition.js +36 -0
  55. package/dist/edge/EdgeDefinition.js.map +1 -0
  56. package/dist/edge/EdgeType.d.ts +6 -0
  57. package/dist/edge/EdgeType.d.ts.map +1 -0
  58. package/dist/edge/EdgeType.js +7 -0
  59. package/dist/edge/EdgeType.js.map +1 -0
  60. package/dist/edge/index.d.ts +6 -0
  61. package/dist/edge/index.d.ts.map +1 -0
  62. package/dist/edge/index.js +6 -0
  63. package/dist/edge/index.js.map +1 -0
  64. package/dist/edge/serialization.d.ts +18 -0
  65. package/dist/edge/serialization.d.ts.map +1 -0
  66. package/dist/edge/serialization.js +76 -0
  67. package/dist/edge/serialization.js.map +1 -0
  68. package/dist/expression/index.d.ts +5 -0
  69. package/dist/expression/index.d.ts.map +1 -0
  70. package/dist/expression/index.js +3 -0
  71. package/dist/expression/index.js.map +1 -0
  72. package/dist/expression/parser.d.ts +14 -0
  73. package/dist/expression/parser.d.ts.map +1 -0
  74. package/dist/expression/parser.js +309 -0
  75. package/dist/expression/parser.js.map +1 -0
  76. package/dist/expression/types.d.ts +11 -0
  77. package/dist/expression/types.d.ts.map +1 -0
  78. package/dist/expression/types.js +20 -0
  79. package/dist/expression/types.js.map +1 -0
  80. package/dist/function/FunctionDeclaration.d.ts +43 -0
  81. package/dist/function/FunctionDeclaration.d.ts.map +1 -0
  82. package/dist/function/FunctionDeclaration.js +17 -0
  83. package/dist/function/FunctionDeclaration.js.map +1 -0
  84. package/dist/function/index.d.ts +4 -0
  85. package/dist/function/index.d.ts.map +1 -0
  86. package/dist/function/index.js +3 -0
  87. package/dist/function/index.js.map +1 -0
  88. package/dist/function/serialization.d.ts +20 -0
  89. package/dist/function/serialization.d.ts.map +1 -0
  90. package/dist/function/serialization.js +26 -0
  91. package/dist/function/serialization.js.map +1 -0
  92. package/dist/id/index.d.ts +7 -0
  93. package/dist/id/index.d.ts.map +1 -0
  94. package/dist/id/index.js +9 -0
  95. package/dist/id/index.js.map +1 -0
  96. package/dist/index.d.ts +11 -0
  97. package/dist/index.d.ts.map +1 -0
  98. package/dist/index.js +14 -0
  99. package/dist/index.js.map +1 -0
  100. package/dist/memory/Memory.d.ts +12 -0
  101. package/dist/memory/Memory.d.ts.map +1 -0
  102. package/dist/memory/Memory.js +2 -0
  103. package/dist/memory/Memory.js.map +1 -0
  104. package/dist/memory/MemoryDefinition.d.ts +16 -0
  105. package/dist/memory/MemoryDefinition.d.ts.map +1 -0
  106. package/dist/memory/MemoryDefinition.js +2 -0
  107. package/dist/memory/MemoryDefinition.js.map +1 -0
  108. package/dist/memory/MemoryFileDefinition.d.ts +8 -0
  109. package/dist/memory/MemoryFileDefinition.d.ts.map +1 -0
  110. package/dist/memory/MemoryFileDefinition.js +36 -0
  111. package/dist/memory/MemoryFileDefinition.js.map +1 -0
  112. package/dist/memory/MemoryRegistry.d.ts +17 -0
  113. package/dist/memory/MemoryRegistry.d.ts.map +1 -0
  114. package/dist/memory/MemoryRegistry.js +29 -0
  115. package/dist/memory/MemoryRegistry.js.map +1 -0
  116. package/dist/memory/VectorDatabaseDefinition.d.ts +7 -0
  117. package/dist/memory/VectorDatabaseDefinition.d.ts.map +1 -0
  118. package/dist/memory/VectorDatabaseDefinition.js +20 -0
  119. package/dist/memory/VectorDatabaseDefinition.js.map +1 -0
  120. package/dist/memory/index.d.ts +9 -0
  121. package/dist/memory/index.d.ts.map +1 -0
  122. package/dist/memory/index.js +6 -0
  123. package/dist/memory/index.js.map +1 -0
  124. package/dist/memory/serialization.d.ts +11 -0
  125. package/dist/memory/serialization.d.ts.map +1 -0
  126. package/dist/memory/serialization.js +44 -0
  127. package/dist/memory/serialization.js.map +1 -0
  128. package/dist/migration/index.d.ts +5 -0
  129. package/dist/migration/index.d.ts.map +1 -0
  130. package/dist/migration/index.js +4 -0
  131. package/dist/migration/index.js.map +1 -0
  132. package/dist/migration/migrate.d.ts +11 -0
  133. package/dist/migration/migrate.d.ts.map +1 -0
  134. package/dist/migration/migrate.js +52 -0
  135. package/dist/migration/migrate.js.map +1 -0
  136. package/dist/migration/migrations.d.ts +22 -0
  137. package/dist/migration/migrations.d.ts.map +1 -0
  138. package/dist/migration/migrations.js +10 -0
  139. package/dist/migration/migrations.js.map +1 -0
  140. package/dist/migration/version.d.ts +9 -0
  141. package/dist/migration/version.d.ts.map +1 -0
  142. package/dist/migration/version.js +9 -0
  143. package/dist/migration/version.js.map +1 -0
  144. package/dist/model/LLMModelDefinition.d.ts +7 -0
  145. package/dist/model/LLMModelDefinition.d.ts.map +1 -0
  146. package/dist/model/LLMModelDefinition.js +11 -0
  147. package/dist/model/LLMModelDefinition.js.map +1 -0
  148. package/dist/model/Model.d.ts +21 -0
  149. package/dist/model/Model.d.ts.map +1 -0
  150. package/dist/model/Model.js +15 -0
  151. package/dist/model/Model.js.map +1 -0
  152. package/dist/model/ModelDefinition.d.ts +15 -0
  153. package/dist/model/ModelDefinition.d.ts.map +1 -0
  154. package/dist/model/ModelDefinition.js +2 -0
  155. package/dist/model/ModelDefinition.js.map +1 -0
  156. package/dist/model/ModelRegistry.d.ts +17 -0
  157. package/dist/model/ModelRegistry.d.ts.map +1 -0
  158. package/dist/model/ModelRegistry.js +27 -0
  159. package/dist/model/ModelRegistry.js.map +1 -0
  160. package/dist/model/index.d.ts +8 -0
  161. package/dist/model/index.d.ts.map +1 -0
  162. package/dist/model/index.js +5 -0
  163. package/dist/model/index.js.map +1 -0
  164. package/dist/model/serialization.d.ts +8 -0
  165. package/dist/model/serialization.d.ts.map +1 -0
  166. package/dist/model/serialization.js +25 -0
  167. package/dist/model/serialization.js.map +1 -0
  168. package/dist/node/AgentNode.d.ts +21 -0
  169. package/dist/node/AgentNode.d.ts.map +1 -0
  170. package/dist/node/AgentNode.js +60 -0
  171. package/dist/node/AgentNode.js.map +1 -0
  172. package/dist/node/DataNode.d.ts +14 -0
  173. package/dist/node/DataNode.d.ts.map +1 -0
  174. package/dist/node/DataNode.js +26 -0
  175. package/dist/node/DataNode.js.map +1 -0
  176. package/dist/node/FunctionNode.d.ts +24 -0
  177. package/dist/node/FunctionNode.d.ts.map +1 -0
  178. package/dist/node/FunctionNode.js +44 -0
  179. package/dist/node/FunctionNode.js.map +1 -0
  180. package/dist/node/InputNode.d.ts +46 -0
  181. package/dist/node/InputNode.d.ts.map +1 -0
  182. package/dist/node/InputNode.js +133 -0
  183. package/dist/node/InputNode.js.map +1 -0
  184. package/dist/node/LogicNode.d.ts +13 -0
  185. package/dist/node/LogicNode.d.ts.map +1 -0
  186. package/dist/node/LogicNode.js +19 -0
  187. package/dist/node/LogicNode.js.map +1 -0
  188. package/dist/node/MqttNode.d.ts +27 -0
  189. package/dist/node/MqttNode.d.ts.map +1 -0
  190. package/dist/node/MqttNode.js +96 -0
  191. package/dist/node/MqttNode.js.map +1 -0
  192. package/dist/node/Node.d.ts +49 -0
  193. package/dist/node/Node.d.ts.map +1 -0
  194. package/dist/node/Node.js +9 -0
  195. package/dist/node/Node.js.map +1 -0
  196. package/dist/node/NodeDefinition.d.ts +30 -0
  197. package/dist/node/NodeDefinition.d.ts.map +1 -0
  198. package/dist/node/NodeDefinition.js +2 -0
  199. package/dist/node/NodeDefinition.js.map +1 -0
  200. package/dist/node/NodeRegistry.d.ts +19 -0
  201. package/dist/node/NodeRegistry.d.ts.map +1 -0
  202. package/dist/node/NodeRegistry.js +65 -0
  203. package/dist/node/NodeRegistry.js.map +1 -0
  204. package/dist/node/OutputNode.d.ts +23 -0
  205. package/dist/node/OutputNode.d.ts.map +1 -0
  206. package/dist/node/OutputNode.js +62 -0
  207. package/dist/node/OutputNode.js.map +1 -0
  208. package/dist/node/ToolNode.d.ts +12 -0
  209. package/dist/node/ToolNode.d.ts.map +1 -0
  210. package/dist/node/ToolNode.js +18 -0
  211. package/dist/node/ToolNode.js.map +1 -0
  212. package/dist/node/TriggerNode.d.ts +67 -0
  213. package/dist/node/TriggerNode.d.ts.map +1 -0
  214. package/dist/node/TriggerNode.js +172 -0
  215. package/dist/node/TriggerNode.js.map +1 -0
  216. package/dist/node/constants.d.ts +16 -0
  217. package/dist/node/constants.d.ts.map +1 -0
  218. package/dist/node/constants.js +18 -0
  219. package/dist/node/constants.js.map +1 -0
  220. package/dist/node/index.d.ts +12 -0
  221. package/dist/node/index.d.ts.map +1 -0
  222. package/dist/node/index.js +9 -0
  223. package/dist/node/index.js.map +1 -0
  224. package/dist/node/methods.d.ts +73 -0
  225. package/dist/node/methods.d.ts.map +1 -0
  226. package/dist/node/methods.js +261 -0
  227. package/dist/node/methods.js.map +1 -0
  228. package/dist/node/serialization.d.ts +25 -0
  229. package/dist/node/serialization.d.ts.map +1 -0
  230. package/dist/node/serialization.js +525 -0
  231. package/dist/node/serialization.js.map +1 -0
  232. package/dist/parameter/OutputParameter.d.ts +69 -0
  233. package/dist/parameter/OutputParameter.d.ts.map +1 -0
  234. package/dist/parameter/OutputParameter.js +6 -0
  235. package/dist/parameter/OutputParameter.js.map +1 -0
  236. package/dist/parameter/Parameter.d.ts +152 -0
  237. package/dist/parameter/Parameter.d.ts.map +1 -0
  238. package/dist/parameter/Parameter.js +86 -0
  239. package/dist/parameter/Parameter.js.map +1 -0
  240. package/dist/parameter/index.d.ts +5 -0
  241. package/dist/parameter/index.d.ts.map +1 -0
  242. package/dist/parameter/index.js +3 -0
  243. package/dist/parameter/index.js.map +1 -0
  244. package/dist/variable/Variable.d.ts +25 -0
  245. package/dist/variable/Variable.d.ts.map +1 -0
  246. package/dist/variable/Variable.js +2 -0
  247. package/dist/variable/Variable.js.map +1 -0
  248. package/dist/variable/index.d.ts +3 -0
  249. package/dist/variable/index.d.ts.map +1 -0
  250. package/dist/variable/index.js +5 -0
  251. package/dist/variable/index.js.map +1 -0
  252. package/dist/variable/operations.d.ts +37 -0
  253. package/dist/variable/operations.d.ts.map +1 -0
  254. package/dist/variable/operations.js +88 -0
  255. package/dist/variable/operations.js.map +1 -0
  256. package/dist/workflow/Workflow.d.ts +38 -0
  257. package/dist/workflow/Workflow.d.ts.map +1 -0
  258. package/dist/workflow/Workflow.js +8 -0
  259. package/dist/workflow/Workflow.js.map +1 -0
  260. package/dist/workflow/index.d.ts +4 -0
  261. package/dist/workflow/index.d.ts.map +1 -0
  262. package/dist/workflow/index.js +3 -0
  263. package/dist/workflow/index.js.map +1 -0
  264. package/dist/workflow/serialization.d.ts +43 -0
  265. package/dist/workflow/serialization.d.ts.map +1 -0
  266. package/dist/workflow/serialization.js +215 -0
  267. package/dist/workflow/serialization.js.map +1 -0
  268. package/package.json +105 -0
  269. package/src/api/index.ts +11 -0
  270. package/src/api/workflow.ts +607 -0
  271. package/src/channel/Channel.ts +11 -0
  272. package/src/channel/ChannelDefinition.ts +76 -0
  273. package/src/channel/index.ts +6 -0
  274. package/src/channel/serialization.ts +68 -0
  275. package/src/deploy/index.ts +1 -0
  276. package/src/deploy/requirements.test.ts +61 -0
  277. package/src/deploy/requirements.ts +41 -0
  278. package/src/diagnostics/__fixtures__/diagnosticFixtures.ts +158 -0
  279. package/src/diagnostics/diagnostics.test.ts +878 -0
  280. package/src/diagnostics/diagnostics.ts +936 -0
  281. package/src/diagnostics/index.ts +11 -0
  282. package/src/edge/Edge.ts +23 -0
  283. package/src/edge/EdgeDefinition.ts +45 -0
  284. package/src/edge/EdgeType.ts +19 -0
  285. package/src/edge/index.ts +8 -0
  286. package/src/edge/serialization.ts +83 -0
  287. package/src/expression/index.ts +4 -0
  288. package/src/expression/parser.ts +362 -0
  289. package/src/expression/types.ts +30 -0
  290. package/src/function/FunctionDeclaration.ts +54 -0
  291. package/src/function/index.ts +3 -0
  292. package/src/function/serialization.ts +40 -0
  293. package/src/globals.d.ts +9 -0
  294. package/src/id/index.ts +8 -0
  295. package/src/index.ts +22 -0
  296. package/src/memory/Memory.ts +15 -0
  297. package/src/memory/MemoryDefinition.ts +16 -0
  298. package/src/memory/MemoryFileDefinition.ts +37 -0
  299. package/src/memory/MemoryRegistry.ts +35 -0
  300. package/src/memory/VectorDatabaseDefinition.ts +21 -0
  301. package/src/memory/index.ts +8 -0
  302. package/src/memory/serialization.ts +47 -0
  303. package/src/migration/index.ts +4 -0
  304. package/src/migration/migrate.test.ts +44 -0
  305. package/src/migration/migrate.ts +58 -0
  306. package/src/migration/migrations.ts +24 -0
  307. package/src/migration/version.ts +9 -0
  308. package/src/model/LLMModelDefinition.ts +12 -0
  309. package/src/model/Model.ts +39 -0
  310. package/src/model/ModelDefinition.ts +15 -0
  311. package/src/model/ModelRegistry.ts +33 -0
  312. package/src/model/index.ts +7 -0
  313. package/src/model/serialization.ts +30 -0
  314. package/src/node/AgentNode.ts +82 -0
  315. package/src/node/DataNode.ts +41 -0
  316. package/src/node/FunctionNode.ts +76 -0
  317. package/src/node/InputNode.ts +185 -0
  318. package/src/node/LogicNode.ts +33 -0
  319. package/src/node/MqttNode.ts +127 -0
  320. package/src/node/Node.ts +61 -0
  321. package/src/node/NodeDefinition.ts +37 -0
  322. package/src/node/NodeRegistry.ts +85 -0
  323. package/src/node/OutputNode.ts +87 -0
  324. package/src/node/ToolNode.ts +32 -0
  325. package/src/node/TriggerNode.ts +272 -0
  326. package/src/node/constants.ts +16 -0
  327. package/src/node/index.ts +26 -0
  328. package/src/node/methods.ts +278 -0
  329. package/src/node/serialization.ts +544 -0
  330. package/src/parameter/OutputParameter.ts +68 -0
  331. package/src/parameter/Parameter.ts +243 -0
  332. package/src/parameter/index.ts +33 -0
  333. package/src/variable/Variable.ts +10 -0
  334. package/src/variable/index.ts +16 -0
  335. package/src/variable/operations.ts +106 -0
  336. package/src/workflow/Workflow.ts +41 -0
  337. package/src/workflow/index.ts +3 -0
  338. package/src/workflow/serialization.test.ts +240 -0
  339. package/src/workflow/serialization.ts +242 -0
@@ -0,0 +1,878 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { computeNodeDiagnostics, validateFunction, type Diagnostic } from "./diagnostics";
3
+ import type { Expression } from "../api";
4
+ import type { FunctionDeclaration } from "../function";
5
+ import { NodeCategory } from "../node";
6
+ import { SetVariableNodeDefinition } from "../node/DataNode";
7
+ import { RetrieverNodeDefinition } from "../node/InputNode";
8
+ import { AgentNodeDefinition } from "../node/AgentNode";
9
+ import { OnThresholdNodeDefinition } from "../node/TriggerNode";
10
+ import { WebSearchToolNodeDefinition } from "../node/ToolNode";
11
+ import {
12
+ diagsOfCategory,
13
+ makeAvailableVars,
14
+ makeDeclaredRef,
15
+ makeDeclaredVar,
16
+ makeEdge,
17
+ makeExpression,
18
+ makeChannels,
19
+ makeNode,
20
+ makeNodeDef,
21
+ makeChannel,
22
+ makeMemory,
23
+ makeMemories,
24
+ } from "./__fixtures__/diagnosticFixtures";
25
+
26
+ // ============================================================================
27
+ // Test helpers — common base options
28
+ // ============================================================================
29
+
30
+ /**
31
+ * Base options for a "neutral" node: real type "OnStartup" (no input ports, no tool ports,
32
+ * no output warnings when paired with a non-Trigger synthetic definition), empty args,
33
+ * empty variable maps. Override any field per test.
34
+ */
35
+ function baseOpts(overrides: Partial<Parameters<typeof computeNodeDiagnostics>[0]> = {}) {
36
+ return {
37
+ canvasId: "main",
38
+ nodeId: "n1",
39
+ nodeData: makeNode("OnStartup", {}),
40
+ nodeDefinition: makeNodeDef({ category: NodeCategory.Input }),
41
+ availableVariables: {},
42
+ channels: {},
43
+ edges: [],
44
+ ...overrides,
45
+ };
46
+ }
47
+
48
+ // ============================================================================
49
+ // Function-call lifecycle (deleted / stale)
50
+ // ============================================================================
51
+
52
+ describe("computeNodeDiagnostics — function-call lifecycle", () => {
53
+ it("emits function-deleted error when isDeleted is true", () => {
54
+ const diags = computeNodeDiagnostics({
55
+ ...baseOpts({ nodeDefinition: undefined }),
56
+ isDeleted: true,
57
+ });
58
+ expect(diags).toHaveLength(1);
59
+ expect(diags[0]).toMatchObject({
60
+ severity: "error",
61
+ category: "function-deleted",
62
+ nodeId: "n1",
63
+ canvasId: "main",
64
+ });
65
+ });
66
+
67
+ it("emits function-stale warning when isStale is true", () => {
68
+ const diags = computeNodeDiagnostics({
69
+ ...baseOpts({ nodeDefinition: undefined }),
70
+ isStale: true,
71
+ });
72
+ expect(diags).toHaveLength(1);
73
+ expect(diags[0]).toMatchObject({ severity: "warning", category: "function-stale" });
74
+ });
75
+
76
+ it("prefers deleted over stale when both flags are set", () => {
77
+ const diags = computeNodeDiagnostics({
78
+ ...baseOpts({ nodeDefinition: undefined }),
79
+ isDeleted: true,
80
+ isStale: true,
81
+ });
82
+ expect(diagsOfCategory(diags, "function-deleted")).toHaveLength(1);
83
+ expect(diagsOfCategory(diags, "function-stale")).toHaveLength(0);
84
+ });
85
+
86
+ it("emits no lifecycle diagnostics when neither flag is set", () => {
87
+ const diags = computeNodeDiagnostics(baseOpts());
88
+ expect(diagsOfCategory(diags, "function-deleted")).toHaveLength(0);
89
+ expect(diagsOfCategory(diags, "function-stale")).toHaveLength(0);
90
+ });
91
+ });
92
+
93
+ // ============================================================================
94
+ // Expression parameter validation
95
+ // ============================================================================
96
+
97
+ describe("computeNodeDiagnostics — expression validation", () => {
98
+ const exprDef = makeNodeDef({
99
+ category: NodeCategory.Input,
100
+ parameters: [
101
+ { id: "val", label: "Value", description: "", type: "expression", expressionType: "int" },
102
+ ],
103
+ });
104
+
105
+ it("flags syntactically invalid expression", () => {
106
+ const diags = computeNodeDiagnostics({
107
+ ...baseOpts({
108
+ nodeDefinition: exprDef,
109
+ nodeData: makeNode("OnStartup", { val: makeExpression("1 +", "int") }),
110
+ }),
111
+ });
112
+ const exprDiags = diagsOfCategory(diags, "invalid-expression");
113
+ expect(exprDiags).toHaveLength(1);
114
+ expect(exprDiags[0].paramId).toBe("val");
115
+ expect(exprDiags[0].message).toContain("Invalid expression");
116
+ });
117
+
118
+ it("accepts a well-formed expression matching declared type", () => {
119
+ const diags = computeNodeDiagnostics({
120
+ ...baseOpts({
121
+ nodeDefinition: exprDef,
122
+ nodeData: makeNode("OnStartup", { val: makeExpression("1 + 2", "int") }),
123
+ }),
124
+ });
125
+ expect(diagsOfCategory(diags, "invalid-expression")).toHaveLength(0);
126
+ });
127
+
128
+ it("flags expression whose result type is incompatible with expected", () => {
129
+ // Expected int, but bool result (1 == 2)
130
+ const diags = computeNodeDiagnostics({
131
+ ...baseOpts({
132
+ nodeDefinition: exprDef,
133
+ nodeData: makeNode("OnStartup", { val: makeExpression("1 == 2", "int") }),
134
+ }),
135
+ });
136
+ const exprDiags = diagsOfCategory(diags, "invalid-expression");
137
+ expect(exprDiags).toHaveLength(1);
138
+ expect(exprDiags[0].message).toMatch(/Type mismatch|expected/i);
139
+ });
140
+
141
+ it("reactivity: changing a referenced variable's type invalidates a previously-valid expression", () => {
142
+ // exprDef.val has a static expressionType: "int". An int var → int + 1 → int (matches).
143
+ // The same expression with a string var → string + 1 → string (NOT int-compatible). The node
144
+ // and definition do not change between calls; only the availableVariables map does. This is the
145
+ // reactivity invariant the test was commissioned to prove.
146
+ const node = makeNode("OnStartup", {
147
+ val: makeExpression("${} + 1", "int", [makeDeclaredRef("v1")]),
148
+ });
149
+ const base = {
150
+ canvasId: "main",
151
+ nodeId: "n1",
152
+ nodeData: node,
153
+ nodeDefinition: exprDef,
154
+ channels: {},
155
+ // Connect control output so the trigger-unconnected warning doesn't muddy the picture
156
+ edges: [makeEdge("e1", "n1", "ctrl", "n2", "ctrl")],
157
+ };
158
+
159
+ const before = computeNodeDiagnostics({
160
+ ...base,
161
+ availableVariables: makeAvailableVars([makeDeclaredVar({ uid: "v1", dataType: "int" })]),
162
+ });
163
+ const after = computeNodeDiagnostics({
164
+ ...base,
165
+ availableVariables: makeAvailableVars([makeDeclaredVar({ uid: "v1", dataType: "string" })]),
166
+ });
167
+
168
+ expect(diagsOfCategory(before, "invalid-expression")).toHaveLength(0);
169
+ const afterExprDiags = diagsOfCategory(after, "invalid-expression");
170
+ expect(afterExprDiags).toHaveLength(1);
171
+ expect(afterExprDiags[0].paramId).toBe("val");
172
+ });
173
+
174
+ it("skips validation on inactive expression parameters", () => {
175
+ const def = makeNodeDef({
176
+ category: NodeCategory.Input,
177
+ parameters: [
178
+ { id: "mode", label: "Mode", description: "", type: "selection", options: [{ value: "on", label: "On" }, { value: "off", label: "Off" }], default: "off" },
179
+ {
180
+ id: "val",
181
+ label: "Value",
182
+ description: "",
183
+ type: "expression",
184
+ expressionType: "int",
185
+ activationRules: [{ type: "parameterIn", parameterId: "mode", values: ["on"] }],
186
+ },
187
+ ],
188
+ });
189
+ const diags = computeNodeDiagnostics({
190
+ ...baseOpts({
191
+ nodeDefinition: def,
192
+ // mode=off → val inactive → garbage not validated
193
+ nodeData: makeNode("OnStartup", { mode: "off", val: makeExpression("1 +", "int") }),
194
+ }),
195
+ });
196
+ expect(diagsOfCategory(diags, "invalid-expression")).toHaveLength(0);
197
+ });
198
+ });
199
+
200
+ // ============================================================================
201
+ // Required parameter enforcement
202
+ // ============================================================================
203
+
204
+ describe("computeNodeDiagnostics — missing required parameters", () => {
205
+ const requiredStringDef = makeNodeDef({
206
+ category: NodeCategory.Input,
207
+ parameters: [{ id: "name", label: "Name", description: "", type: "string" }],
208
+ });
209
+
210
+ it("flags required string param with empty string", () => {
211
+ const diags = computeNodeDiagnostics({
212
+ ...baseOpts({
213
+ nodeDefinition: requiredStringDef,
214
+ nodeData: makeNode("OnStartup", { name: "" }),
215
+ }),
216
+ });
217
+ const missing = diagsOfCategory(diags, "missing-required-param");
218
+ expect(missing).toHaveLength(1);
219
+ expect(missing[0].paramId).toBe("name");
220
+ });
221
+
222
+ it("flags required expression param that is empty", () => {
223
+ const def = makeNodeDef({
224
+ category: NodeCategory.Input,
225
+ parameters: [{ id: "expr", label: "Expr", description: "", type: "expression", expressionType: "int" }],
226
+ });
227
+ const diags = computeNodeDiagnostics({
228
+ ...baseOpts({
229
+ nodeDefinition: def,
230
+ nodeData: makeNode("OnStartup", { expr: makeExpression("", "int") }),
231
+ }),
232
+ });
233
+ expect(diagsOfCategory(diags, "missing-required-param")).toHaveLength(1);
234
+ });
235
+
236
+ it("flags required variableSelect param with empty varId", () => {
237
+ const def = makeNodeDef({
238
+ category: NodeCategory.Input,
239
+ parameters: [{ id: "ref", label: "Ref", description: "", type: "variableSelect" }],
240
+ });
241
+ const diags = computeNodeDiagnostics({
242
+ ...baseOpts({
243
+ nodeDefinition: def,
244
+ nodeData: makeNode("OnStartup", { ref: { srcId: "declared", varId: "" } }),
245
+ }),
246
+ });
247
+ expect(diagsOfCategory(diags, "missing-required-param")).toHaveLength(1);
248
+ });
249
+
250
+ it("does not flag optional params that are missing", () => {
251
+ const def = makeNodeDef({
252
+ category: NodeCategory.Input,
253
+ parameters: [{ id: "name", label: "Name", description: "", type: "string", optional: true }],
254
+ });
255
+ const diags = computeNodeDiagnostics({
256
+ ...baseOpts({
257
+ nodeDefinition: def,
258
+ nodeData: makeNode("OnStartup", {}),
259
+ }),
260
+ });
261
+ expect(diagsOfCategory(diags, "missing-required-param")).toHaveLength(0);
262
+ });
263
+
264
+ it("does not flag required params that are inactive", () => {
265
+ const def = makeNodeDef({
266
+ category: NodeCategory.Input,
267
+ parameters: [
268
+ { id: "mode", label: "Mode", description: "", type: "selection", options: [{ value: "a", label: "A" }, { value: "b", label: "B" }], default: "a" },
269
+ {
270
+ id: "extra",
271
+ label: "Extra",
272
+ description: "",
273
+ type: "string",
274
+ activationRules: [{ type: "parameterIn", parameterId: "mode", values: ["b"] }],
275
+ },
276
+ ],
277
+ });
278
+ const diags = computeNodeDiagnostics({
279
+ ...baseOpts({
280
+ nodeDefinition: def,
281
+ nodeData: makeNode("OnStartup", { mode: "a" }),
282
+ }),
283
+ });
284
+ expect(diagsOfCategory(diags, "missing-required-param")).toHaveLength(0);
285
+ });
286
+
287
+ it("does not flag required param when it has a valid value", () => {
288
+ const diags = computeNodeDiagnostics({
289
+ ...baseOpts({
290
+ nodeDefinition: requiredStringDef,
291
+ nodeData: makeNode("OnStartup", { name: "hello" }),
292
+ }),
293
+ });
294
+ expect(diagsOfCategory(diags, "missing-required-param")).toHaveLength(0);
295
+ });
296
+ });
297
+
298
+ // ============================================================================
299
+ // Variable-reference validation
300
+ // ============================================================================
301
+
302
+ describe("computeNodeDiagnostics — variableSelect validation", () => {
303
+ const def = makeNodeDef({
304
+ category: NodeCategory.Input,
305
+ parameters: [{ id: "ref", label: "Ref", description: "", type: "variableSelect" }],
306
+ });
307
+
308
+ it("flags reference to a deleted variable", () => {
309
+ const diags = computeNodeDiagnostics({
310
+ ...baseOpts({
311
+ nodeDefinition: def,
312
+ nodeData: makeNode("OnStartup", { ref: makeDeclaredRef("ghost") }),
313
+ // ghost isn't in the map
314
+ availableVariables: makeAvailableVars([makeDeclaredVar({ uid: "v1" })]),
315
+ }),
316
+ });
317
+ const refDiags = diagsOfCategory(diags, "invalid-reference");
318
+ expect(refDiags).toHaveLength(1);
319
+ expect(refDiags[0].message).toMatch(/deleted variable/);
320
+ expect(refDiags[0].paramId).toBe("ref");
321
+ });
322
+
323
+ it("accepts a valid variable reference", () => {
324
+ const diags = computeNodeDiagnostics({
325
+ ...baseOpts({
326
+ nodeDefinition: def,
327
+ nodeData: makeNode("OnStartup", { ref: makeDeclaredRef("v1") }),
328
+ availableVariables: makeAvailableVars([makeDeclaredVar({ uid: "v1" })]),
329
+ }),
330
+ });
331
+ expect(diagsOfCategory(diags, "invalid-reference")).toHaveLength(0);
332
+ });
333
+
334
+ it("reactivity: a valid reference becomes invalid when the variable is removed", () => {
335
+ const node = makeNode("OnStartup", { ref: makeDeclaredRef("v1") });
336
+ const base = baseOpts({ nodeDefinition: def, nodeData: node });
337
+
338
+ const before = computeNodeDiagnostics({
339
+ ...base,
340
+ availableVariables: makeAvailableVars([makeDeclaredVar({ uid: "v1" })]),
341
+ });
342
+ const after = computeNodeDiagnostics({ ...base, availableVariables: {} });
343
+
344
+ expect(diagsOfCategory(before, "invalid-reference")).toHaveLength(0);
345
+ expect(diagsOfCategory(after, "invalid-reference")).toHaveLength(1);
346
+ });
347
+ });
348
+
349
+ // ============================================================================
350
+ // Channel select validation (channelSelect param)
351
+ // ============================================================================
352
+
353
+ describe("computeNodeDiagnostics — channelSelect validation", () => {
354
+ const channelDef = makeNodeDef({
355
+ category: NodeCategory.Input,
356
+ parameters: [{ id: "pin", label: "Pin", description: "", type: "channelSelect", channelType: ["GPIOIN"] }],
357
+ });
358
+
359
+ it("flags reference to a deleted channel", () => {
360
+ const diags = computeNodeDiagnostics({
361
+ ...baseOpts({
362
+ nodeDefinition: channelDef,
363
+ nodeData: makeNode("OnStartup", { pin: "ghost-pin" }),
364
+ channels: makeChannels([makeChannel({ id: "pin1" })]),
365
+ }),
366
+ });
367
+ const refDiags = diagsOfCategory(diags, "invalid-reference");
368
+ expect(refDiags).toHaveLength(1);
369
+ expect(refDiags[0].message).toMatch(/deleted channel/);
370
+ });
371
+
372
+ it("flags reference to a channel with incompatible type", () => {
373
+ const diags = computeNodeDiagnostics({
374
+ ...baseOpts({
375
+ nodeDefinition: channelDef,
376
+ nodeData: makeNode("OnStartup", { pin: "pin1" }),
377
+ channels: makeChannels([makeChannel({ id: "pin1", type: "ADC" })]),
378
+ }),
379
+ });
380
+ const refDiags = diagsOfCategory(diags, "invalid-reference");
381
+ expect(refDiags).toHaveLength(1);
382
+ expect(refDiags[0].message).toMatch(/not a compatible channel type/);
383
+ });
384
+
385
+ it("accepts reference to a channel with compatible type", () => {
386
+ const diags = computeNodeDiagnostics({
387
+ ...baseOpts({
388
+ nodeDefinition: channelDef,
389
+ nodeData: makeNode("OnStartup", { pin: "pin1" }),
390
+ channels: makeChannels([makeChannel({ id: "pin1", type: "GPIOIN" })]),
391
+ }),
392
+ });
393
+ expect(diagsOfCategory(diags, "invalid-reference")).toHaveLength(0);
394
+ });
395
+
396
+ it("accepts any of several allowed types", () => {
397
+ const multiTypeDef = makeNodeDef({
398
+ category: NodeCategory.Input,
399
+ parameters: [{ id: "pin", label: "Pin", description: "", type: "channelSelect", channelType: ["GPIOIN", "ADC"] }],
400
+ });
401
+ const diags = computeNodeDiagnostics({
402
+ ...baseOpts({
403
+ nodeDefinition: multiTypeDef,
404
+ nodeData: makeNode("OnStartup", { pin: "pin1" }),
405
+ channels: makeChannels([makeChannel({ id: "pin1", type: "ADC" })]),
406
+ }),
407
+ });
408
+ expect(diagsOfCategory(diags, "invalid-reference")).toHaveLength(0);
409
+ });
410
+ });
411
+
412
+ // ============================================================================
413
+ // memorySelect validation
414
+ // ============================================================================
415
+
416
+ describe("computeNodeDiagnostics — memorySelect validation", () => {
417
+ const memDef = makeNodeDef({
418
+ category: NodeCategory.Input,
419
+ parameters: [{ id: "col", label: "Vector DB", description: "", type: "memorySelect", memoryType: ["VectorDatabase"] }],
420
+ });
421
+
422
+ it("flags a memory id not present in the store", () => {
423
+ const diags = computeNodeDiagnostics({
424
+ ...baseOpts({
425
+ nodeDefinition: memDef,
426
+ nodeData: makeNode("OnStartup", { col: "deleted-mem" }),
427
+ }),
428
+ memory: makeMemories([makeMemory({ id: "vdb1", type: "VectorDatabase" })]),
429
+ });
430
+ const refDiags = diagsOfCategory(diags, "invalid-reference");
431
+ expect(refDiags).toHaveLength(1);
432
+ expect(refDiags[0].message).toMatch(/deleted memory/);
433
+ });
434
+
435
+ it("accepts a memory id present in the store with a compatible type", () => {
436
+ const diags = computeNodeDiagnostics({
437
+ ...baseOpts({
438
+ nodeDefinition: memDef,
439
+ nodeData: makeNode("OnStartup", { col: "vdb1" }),
440
+ }),
441
+ memory: makeMemories([makeMemory({ id: "vdb1", type: "VectorDatabase" })]),
442
+ });
443
+ expect(diagsOfCategory(diags, "invalid-reference")).toHaveLength(0);
444
+ });
445
+
446
+ it("flags a memory of an incompatible type", () => {
447
+ const diags = computeNodeDiagnostics({
448
+ ...baseOpts({
449
+ nodeDefinition: memDef,
450
+ nodeData: makeNode("OnStartup", { col: "file1" }),
451
+ }),
452
+ memory: makeMemories([makeMemory({ id: "file1", type: "MemoryFile" })]),
453
+ });
454
+ const refDiags = diagsOfCategory(diags, "invalid-reference");
455
+ expect(refDiags).toHaveLength(1);
456
+ expect(refDiags[0].message).toMatch(/not a compatible memory type/);
457
+ });
458
+
459
+ it("does not flag when the memory map is undefined (not provided)", () => {
460
+ const diags = computeNodeDiagnostics({
461
+ ...baseOpts({
462
+ nodeDefinition: memDef,
463
+ nodeData: makeNode("OnStartup", { col: "vdb1" }),
464
+ }),
465
+ });
466
+ expect(diagsOfCategory(diags, "invalid-reference")).toHaveLength(0);
467
+ });
468
+ });
469
+
470
+ // ============================================================================
471
+ // Scalar output-binding validation (uses RetrieverNode — real node with static output)
472
+ // ============================================================================
473
+
474
+ describe("computeNodeDiagnostics — scalar output binding validation", () => {
475
+ // Common valid retriever args (satisfies required params so assign-type-mismatch is isolated)
476
+ const retrieverArgs = (outputBinding: unknown) => ({
477
+ memoryReference: "vdb1",
478
+ topK: 5,
479
+ query: makeExpression("hello", "string"),
480
+ output: outputBinding,
481
+ });
482
+ const retrieverBase = (outputBinding: unknown, availableVariables: Record<string, ReturnType<typeof makeDeclaredVar>> = {}) => ({
483
+ canvasId: "main",
484
+ nodeId: "n1",
485
+ nodeData: makeNode("Retriever", retrieverArgs(outputBinding)),
486
+ nodeDefinition: RetrieverNodeDefinition,
487
+ availableVariables,
488
+ channels: {},
489
+ memory: makeMemories([makeMemory({ id: "vdb1", type: "VectorDatabase" })]),
490
+ edges: [
491
+ makeEdge("e-in", "trigger", "ctrl", "n1", "ctrl"), // wire control input to silence unconnected-input
492
+ makeEdge("e-out", "n1", "ctrl", "sink", "ctrl"), // wire output to silence trigger
493
+ ],
494
+ });
495
+
496
+ it("flags assign-mode output binding with empty srcId", () => {
497
+ const diags = computeNodeDiagnostics(retrieverBase({ active: true, mode: "assign", target: { srcId: "", varId: "v1" } }));
498
+ const assignDiags = diagsOfCategory(diags, "assign-type-mismatch");
499
+ expect(assignDiags).toHaveLength(1);
500
+ expect(assignDiags[0].message).toMatch(/no variable selected/);
501
+ });
502
+
503
+ it("flags assign-mode binding targeting a deleted variable", () => {
504
+ const diags = computeNodeDiagnostics(retrieverBase({ active: true, mode: "assign", target: makeDeclaredRef("ghost") }));
505
+ const assignDiags = diagsOfCategory(diags, "assign-type-mismatch");
506
+ expect(assignDiags).toHaveLength(1);
507
+ expect(assignDiags[0].message).toMatch(/deleted variable/);
508
+ });
509
+
510
+ it("flags assign-mode binding with type mismatch", () => {
511
+ const diags = computeNodeDiagnostics(
512
+ retrieverBase(
513
+ { active: true, mode: "assign", target: makeDeclaredRef("v1") },
514
+ makeAvailableVars([makeDeclaredVar({ uid: "v1", dataType: "int" })]), // retriever output is string
515
+ ),
516
+ );
517
+ const assignDiags = diagsOfCategory(diags, "assign-type-mismatch");
518
+ expect(assignDiags).toHaveLength(1);
519
+ expect(assignDiags[0].message).toMatch(/cannot assign/);
520
+ });
521
+
522
+ it("accepts assign-mode binding with matching types", () => {
523
+ const diags = computeNodeDiagnostics(
524
+ retrieverBase(
525
+ { active: true, mode: "assign", target: makeDeclaredRef("v1") },
526
+ makeAvailableVars([makeDeclaredVar({ uid: "v1", dataType: "string" })]),
527
+ ),
528
+ );
529
+ expect(diagsOfCategory(diags, "assign-type-mismatch")).toHaveLength(0);
530
+ });
531
+
532
+ it("skips validation entirely for inactive bindings (discarded)", () => {
533
+ // Inactive binding preserves a broken assign target as draft state — must not fire diagnostics.
534
+ const diags = computeNodeDiagnostics(
535
+ retrieverBase({ active: false, mode: "assign", target: { srcId: "", varId: "" } }),
536
+ );
537
+ expect(diagsOfCategory(diags, "assign-type-mismatch")).toHaveLength(0);
538
+ });
539
+
540
+ it("skips output-binding validation entirely when node is used as tool", () => {
541
+ // Retriever has a tool input port; connecting it marks the node as used-as-tool.
542
+ const diags = computeNodeDiagnostics({
543
+ ...retrieverBase({ active: true, mode: "assign", target: { srcId: "", varId: "" } }), // would normally fire
544
+ edges: [makeEdge("e-tool", "agent", "tools", "n1", "tool")],
545
+ });
546
+ expect(diagsOfCategory(diags, "assign-type-mismatch")).toHaveLength(0);
547
+ });
548
+ });
549
+
550
+ // ============================================================================
551
+ // List-output entry validation (uses AgentNode — real node with list output)
552
+ // ============================================================================
553
+
554
+ describe("computeNodeDiagnostics — list-output entry validation", () => {
555
+ const agentBase = (outputDeclarations: unknown[], availableVariables: Record<string, ReturnType<typeof makeDeclaredVar>> = {}) => ({
556
+ canvasId: "main",
557
+ nodeId: "n1",
558
+ nodeData: makeNode("Agent", {
559
+ name: "a",
560
+ model: "gpt-4",
561
+ instructions: "",
562
+ maxTurns: 10,
563
+ options: undefined,
564
+ outputDeclarations,
565
+ answer: { active: true, mode: "emit", name: "answer" },
566
+ }),
567
+ nodeDefinition: AgentNodeDefinition,
568
+ availableVariables,
569
+ channels: {},
570
+ edges: [makeEdge("e-in", "trigger", "ctrl", "n1", "ctrl")],
571
+ });
572
+
573
+ it("flags list entry with empty srcId", () => {
574
+ const diags = computeNodeDiagnostics(
575
+ agentBase([{ mode: "assign", name: "score", dataType: "string", target: { srcId: "", varId: "" } }]),
576
+ );
577
+ const entry = diagsOfCategory(diags, "assign-type-mismatch");
578
+ expect(entry).toHaveLength(1);
579
+ expect(entry[0].outputId).toBe("outputDeclarations[0]");
580
+ expect(entry[0].message).toMatch(/entry #1 has no variable selected/);
581
+ });
582
+
583
+ it("flags list entry targeting a deleted variable", () => {
584
+ const diags = computeNodeDiagnostics(
585
+ agentBase([{ mode: "assign", name: "score", dataType: "string", target: makeDeclaredRef("ghost") }]),
586
+ );
587
+ const entry = diagsOfCategory(diags, "assign-type-mismatch");
588
+ expect(entry).toHaveLength(1);
589
+ expect(entry[0].message).toMatch(/deleted variable/);
590
+ });
591
+
592
+ it("flags list entry with type mismatch", () => {
593
+ const diags = computeNodeDiagnostics(
594
+ agentBase(
595
+ [{ mode: "assign", name: "score", dataType: "int", target: makeDeclaredRef("v1") }],
596
+ makeAvailableVars([makeDeclaredVar({ uid: "v1", dataType: "string" })]),
597
+ ),
598
+ );
599
+ const entry = diagsOfCategory(diags, "assign-type-mismatch");
600
+ expect(entry).toHaveLength(1);
601
+ expect(entry[0].message).toMatch(/cannot assign/);
602
+ });
603
+
604
+ it("accepts a valid list entry", () => {
605
+ const diags = computeNodeDiagnostics(
606
+ agentBase(
607
+ [{ mode: "assign", name: "score", dataType: "int", target: makeDeclaredRef("v1") }],
608
+ makeAvailableVars([makeDeclaredVar({ uid: "v1", dataType: "int" })]),
609
+ ),
610
+ );
611
+ expect(diagsOfCategory(diags, "assign-type-mismatch")).toHaveLength(0);
612
+ expect(diagsOfCategory(diags, "duplicate-output-name")).toHaveLength(0);
613
+ });
614
+
615
+ it("reports only the broken entry when one of several is invalid", () => {
616
+ const diags = computeNodeDiagnostics(
617
+ agentBase(
618
+ [
619
+ { mode: "assign", name: "a", dataType: "int", target: makeDeclaredRef("v1") }, // ok
620
+ { mode: "assign", name: "b", dataType: "int", target: { srcId: "", varId: "" } }, // broken
621
+ { mode: "emit", uid: "u2", name: "ok", dataType: "int" }, // emit — skipped
622
+ ],
623
+ makeAvailableVars([makeDeclaredVar({ uid: "v1", dataType: "int" })]),
624
+ ),
625
+ );
626
+ const entry = diagsOfCategory(diags, "assign-type-mismatch");
627
+ expect(entry).toHaveLength(1);
628
+ expect(entry[0].outputId).toBe("outputDeclarations[1]");
629
+ });
630
+
631
+ it("flags duplicate names across the list (both modes)", () => {
632
+ // Two entries share the name "score" — the JSON property name in the LLM's response
633
+ // would silently collide. Diagnostic must fire on both colliding entries.
634
+ const diags = computeNodeDiagnostics(
635
+ agentBase(
636
+ [
637
+ { mode: "emit", uid: "u1", name: "score", dataType: "int" },
638
+ { mode: "assign", name: "score", dataType: "int", target: makeDeclaredRef("v1") },
639
+ ],
640
+ makeAvailableVars([makeDeclaredVar({ uid: "v1", dataType: "int" })]),
641
+ ),
642
+ );
643
+ const dupes = diagsOfCategory(diags, "duplicate-output-name");
644
+ expect(dupes).toHaveLength(2);
645
+ expect(dupes.map((d) => d.outputId).sort()).toEqual(["outputDeclarations[0]", "outputDeclarations[1]"]);
646
+ });
647
+
648
+ it("flags entry with empty name", () => {
649
+ const diags = computeNodeDiagnostics(
650
+ agentBase([{ mode: "emit", uid: "u1", name: "", dataType: "int" }]),
651
+ );
652
+ const missing = diagsOfCategory(diags, "missing-required-param").filter((d) => d.outputId);
653
+ expect(missing).toHaveLength(1);
654
+ expect(missing[0].message).toMatch(/has no name/);
655
+ });
656
+ });
657
+
658
+ // ============================================================================
659
+ // Control-port connectivity (uses SetVariable — real node with control input)
660
+ // ============================================================================
661
+
662
+ describe("computeNodeDiagnostics — control-input connectivity", () => {
663
+ // SetVariable has a control input "ctrl" and a control output "ctrl". Use it with a
664
+ // valid variable and value so the only remaining signal is the port connectivity.
665
+ const svArgs = {
666
+ variable: makeDeclaredRef("v1"),
667
+ value: makeExpression("1", "int"),
668
+ };
669
+ const svVars = makeAvailableVars([makeDeclaredVar({ uid: "v1", dataType: "int" })]);
670
+
671
+ it("warns when a node with control inputs has none connected", () => {
672
+ const diags = computeNodeDiagnostics({
673
+ canvasId: "main",
674
+ nodeId: "n1",
675
+ nodeData: makeNode("SetVariable", svArgs),
676
+ nodeDefinition: SetVariableNodeDefinition,
677
+ availableVariables: svVars,
678
+ channels: {},
679
+ edges: [],
680
+ });
681
+ expect(diagsOfCategory(diags, "unconnected-input")).toHaveLength(1);
682
+ });
683
+
684
+ it("does not warn when a control input is connected", () => {
685
+ const diags = computeNodeDiagnostics({
686
+ canvasId: "main",
687
+ nodeId: "n1",
688
+ nodeData: makeNode("SetVariable", svArgs),
689
+ nodeDefinition: SetVariableNodeDefinition,
690
+ availableVariables: svVars,
691
+ channels: {},
692
+ edges: [makeEdge("e1", "trigger", "ctrl", "n1", "ctrl")],
693
+ });
694
+ expect(diagsOfCategory(diags, "unconnected-input")).toHaveLength(0);
695
+ });
696
+
697
+ it("does not warn when a node has no control inputs at all", () => {
698
+ // OnStartup has zero input ports
699
+ const diags = computeNodeDiagnostics(baseOpts());
700
+ expect(diagsOfCategory(diags, "unconnected-input")).toHaveLength(0);
701
+ });
702
+ });
703
+
704
+ // ============================================================================
705
+ // Tool-only connectivity (uses WebSearchTool — tool input only)
706
+ // ============================================================================
707
+
708
+ describe("computeNodeDiagnostics — tool-not-connected", () => {
709
+ it("warns when a tool-only node is not connected to any agent", () => {
710
+ const diags = computeNodeDiagnostics({
711
+ canvasId: "main",
712
+ nodeId: "n1",
713
+ nodeData: makeNode("WebSearchTool", {}),
714
+ nodeDefinition: WebSearchToolNodeDefinition,
715
+ availableVariables: {},
716
+ channels: {},
717
+ edges: [],
718
+ });
719
+ expect(diagsOfCategory(diags, "tool-not-connected")).toHaveLength(1);
720
+ });
721
+
722
+ it("does not warn when a tool-only node is connected to an agent", () => {
723
+ const diags = computeNodeDiagnostics({
724
+ canvasId: "main",
725
+ nodeId: "n1",
726
+ nodeData: makeNode("WebSearchTool", {}),
727
+ nodeDefinition: WebSearchToolNodeDefinition,
728
+ availableVariables: {},
729
+ channels: {},
730
+ edges: [makeEdge("e1", "agent", "tools", "n1", "tool")],
731
+ });
732
+ expect(diagsOfCategory(diags, "tool-not-connected")).toHaveLength(0);
733
+ });
734
+
735
+ it("does not emit tool-not-connected for nodes that also have control inputs", () => {
736
+ // Retriever has both control input and tool input — the control-input branch handles it,
737
+ // so the tool-not-connected branch must not fire.
738
+ const diags = computeNodeDiagnostics({
739
+ canvasId: "main",
740
+ nodeId: "n1",
741
+ nodeData: makeNode("Retriever", {
742
+ memoryReference: "vdb1",
743
+ topK: 5,
744
+ query: makeExpression("hi", "string"),
745
+ output: { active: true, mode: "emit", name: "out" },
746
+ }),
747
+ nodeDefinition: RetrieverNodeDefinition,
748
+ availableVariables: {},
749
+ channels: {},
750
+ memory: makeMemories([makeMemory({ id: "vdb1", type: "VectorDatabase" })]),
751
+ edges: [],
752
+ });
753
+ expect(diagsOfCategory(diags, "tool-not-connected")).toHaveLength(0);
754
+ });
755
+ });
756
+
757
+ // ============================================================================
758
+ // Trigger-output connectivity (uses OnThreshold — Trigger category, control output)
759
+ // ============================================================================
760
+
761
+ describe("computeNodeDiagnostics — trigger output connectivity", () => {
762
+ const onThresholdArgs = {
763
+ value: makeExpression("1", "float"),
764
+ threshold: 100,
765
+ direction: "above",
766
+ };
767
+
768
+ it("warns when a trigger's control output is unconnected", () => {
769
+ const diags = computeNodeDiagnostics({
770
+ canvasId: "main",
771
+ nodeId: "n1",
772
+ nodeData: makeNode("OnThreshold", onThresholdArgs),
773
+ nodeDefinition: OnThresholdNodeDefinition,
774
+ availableVariables: {},
775
+ channels: {},
776
+ edges: [],
777
+ });
778
+ expect(diagsOfCategory(diags, "unconnected-output")).toHaveLength(1);
779
+ });
780
+
781
+ it("does not warn when the trigger's output is connected", () => {
782
+ const diags = computeNodeDiagnostics({
783
+ canvasId: "main",
784
+ nodeId: "n1",
785
+ nodeData: makeNode("OnThreshold", onThresholdArgs),
786
+ nodeDefinition: OnThresholdNodeDefinition,
787
+ availableVariables: {},
788
+ channels: {},
789
+ edges: [makeEdge("e1", "n1", "ctrl", "sink", "ctrl")],
790
+ });
791
+ expect(diagsOfCategory(diags, "unconnected-output")).toHaveLength(0);
792
+ });
793
+
794
+ it("does not warn on non-trigger nodes with unconnected control outputs", () => {
795
+ // SetVariable has a control output but category is Data, not Trigger — should not fire.
796
+ const diags = computeNodeDiagnostics({
797
+ canvasId: "main",
798
+ nodeId: "n1",
799
+ nodeData: makeNode("SetVariable", {
800
+ variable: makeDeclaredRef("v1"),
801
+ value: makeExpression("1", "int"),
802
+ }),
803
+ nodeDefinition: SetVariableNodeDefinition,
804
+ availableVariables: makeAvailableVars([makeDeclaredVar({ uid: "v1", dataType: "int" })]),
805
+ channels: {},
806
+ edges: [makeEdge("e1", "trigger", "ctrl", "n1", "ctrl")], // control input wired
807
+ });
808
+ expect(diagsOfCategory(diags, "unconnected-output")).toHaveLength(0);
809
+ });
810
+ });
811
+
812
+ // ============================================================================
813
+ // Undefined nodeDefinition (defensive path) + happy path
814
+ // ============================================================================
815
+
816
+ describe("computeNodeDiagnostics — edge cases", () => {
817
+ it("returns early when nodeDefinition is undefined and node is not a deleted/stale function", () => {
818
+ const diags = computeNodeDiagnostics({
819
+ canvasId: "main",
820
+ nodeId: "n1",
821
+ nodeData: makeNode("OnStartup", {}),
822
+ nodeDefinition: undefined,
823
+ availableVariables: {},
824
+ channels: {},
825
+ edges: [],
826
+ });
827
+ expect(diags).toHaveLength(0);
828
+ });
829
+
830
+ it("returns no diagnostics for a fully valid node (happy path baseline)", () => {
831
+ const diags: Diagnostic[] = computeNodeDiagnostics({
832
+ canvasId: "main",
833
+ nodeId: "n1",
834
+ nodeData: makeNode("SetVariable", {
835
+ variable: makeDeclaredRef("v1"),
836
+ value: makeExpression("${} + 1", "int", [makeDeclaredRef("v1")]),
837
+ }),
838
+ nodeDefinition: SetVariableNodeDefinition,
839
+ availableVariables: makeAvailableVars([makeDeclaredVar({ uid: "v1", dataType: "int" })]),
840
+ channels: {},
841
+ edges: [
842
+ makeEdge("e-in", "trigger", "ctrl", "n1", "ctrl"),
843
+ makeEdge("e-out", "n1", "ctrl", "sink", "ctrl"),
844
+ ],
845
+ });
846
+ expect(diags).toEqual([]);
847
+ });
848
+ });
849
+
850
+ describe("validateFunction", () => {
851
+ const expr = (s: string): Expression => ({ expression: s, references: [], dataType: "string" });
852
+ const fn = (over: Partial<FunctionDeclaration> = {}): FunctionDeclaration => ({
853
+ id: "fn1",
854
+ version: 1,
855
+ name: "doThing",
856
+ arguments: [],
857
+ outputs: [],
858
+ ...over,
859
+ });
860
+
861
+ it("passes for a named function whose outputs are all assigned", () => {
862
+ const def = fn({ outputs: [{ uid: "r1", name: "out", dataType: "string", expression: expr("${} + 1") }] });
863
+ expect(validateFunction(def)).toEqual([]);
864
+ });
865
+
866
+ it("flags an empty name", () => {
867
+ const diags = validateFunction(fn({ name: " " }));
868
+ expect(diags).toHaveLength(1);
869
+ expect(diags[0]).toMatchObject({ severity: "error", functionId: "fn1" });
870
+ });
871
+
872
+ it("flags an output with no assigned expression (engine invariant)", () => {
873
+ const def = fn({ outputs: [{ uid: "r1", name: "out", dataType: "string", expression: expr("") }] });
874
+ expect(validateFunction(def)).toMatchObject([
875
+ { severity: "error", category: "missing-output-assignment", functionId: "fn1" },
876
+ ]);
877
+ });
878
+ });