@foresthubai/workflow-core 0.4.3 → 0.4.5

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 (298) hide show
  1. package/LICENSE +202 -202
  2. package/NOTICE +14 -14
  3. package/README.md +63 -63
  4. package/dist/api/deployment.d.ts +74 -0
  5. package/dist/api/deployment.d.ts.map +1 -0
  6. package/dist/api/deployment.js +6 -0
  7. package/dist/api/deployment.js.map +1 -0
  8. package/dist/api/engine.d.ts +744 -0
  9. package/dist/api/engine.d.ts.map +1 -0
  10. package/dist/api/engine.js +6 -0
  11. package/dist/api/engine.js.map +1 -0
  12. package/dist/api/index.d.ts +4 -0
  13. package/dist/api/index.d.ts.map +1 -1
  14. package/dist/api/index.js +2 -0
  15. package/dist/api/index.js.map +1 -1
  16. package/dist/api/workflow.d.ts +25 -9
  17. package/dist/api/workflow.d.ts.map +1 -1
  18. package/dist/channel/Channel.d.ts +1 -1
  19. package/dist/channel/Channel.d.ts.map +1 -1
  20. package/dist/channel/Channel.js +3 -1
  21. package/dist/channel/Channel.js.map +1 -1
  22. package/dist/channel/ChannelDefinition.d.ts.map +1 -1
  23. package/dist/channel/ChannelDefinition.js +24 -0
  24. package/dist/channel/ChannelDefinition.js.map +1 -1
  25. package/dist/channel/index.d.ts.map +1 -1
  26. package/dist/channel/index.js +2 -0
  27. package/dist/channel/index.js.map +1 -1
  28. package/dist/channel/serialization.d.ts.map +1 -1
  29. package/dist/channel/serialization.js +8 -0
  30. package/dist/channel/serialization.js.map +1 -1
  31. package/dist/deploy/index.d.ts +5 -1
  32. package/dist/deploy/index.d.ts.map +1 -1
  33. package/dist/deploy/index.js +4 -1
  34. package/dist/deploy/index.js.map +1 -1
  35. package/dist/deploy/inputs.d.ts +26 -0
  36. package/dist/deploy/inputs.d.ts.map +1 -0
  37. package/dist/deploy/inputs.js +4 -0
  38. package/dist/deploy/inputs.js.map +1 -0
  39. package/dist/deploy/requirements.d.ts +24 -0
  40. package/dist/deploy/requirements.d.ts.map +1 -1
  41. package/dist/deploy/requirements.js +63 -0
  42. package/dist/deploy/requirements.js.map +1 -1
  43. package/dist/deploy/spec.d.ts +27 -0
  44. package/dist/deploy/spec.d.ts.map +1 -0
  45. package/dist/deploy/spec.js +352 -0
  46. package/dist/deploy/spec.js.map +1 -0
  47. package/dist/diagnostics/__fixtures__/diagnosticFixtures.d.ts.map +1 -1
  48. package/dist/diagnostics/__fixtures__/diagnosticFixtures.js +2 -0
  49. package/dist/diagnostics/__fixtures__/diagnosticFixtures.js.map +1 -1
  50. package/dist/diagnostics/diagnostics.d.ts.map +1 -1
  51. package/dist/diagnostics/diagnostics.js +2 -0
  52. package/dist/diagnostics/diagnostics.js.map +1 -1
  53. package/dist/diagnostics/index.d.ts.map +1 -1
  54. package/dist/diagnostics/index.js +2 -0
  55. package/dist/diagnostics/index.js.map +1 -1
  56. package/dist/edge/Edge.d.ts.map +1 -1
  57. package/dist/edge/Edge.js +2 -0
  58. package/dist/edge/Edge.js.map +1 -1
  59. package/dist/edge/EdgeDefinition.d.ts.map +1 -1
  60. package/dist/edge/EdgeDefinition.js +2 -0
  61. package/dist/edge/EdgeDefinition.js.map +1 -1
  62. package/dist/edge/EdgeType.d.ts.map +1 -1
  63. package/dist/edge/EdgeType.js +2 -0
  64. package/dist/edge/EdgeType.js.map +1 -1
  65. package/dist/edge/index.d.ts.map +1 -1
  66. package/dist/edge/index.js +2 -2
  67. package/dist/edge/index.js.map +1 -1
  68. package/dist/edge/serialization.d.ts.map +1 -1
  69. package/dist/edge/serialization.js +2 -0
  70. package/dist/edge/serialization.js.map +1 -1
  71. package/dist/expression/index.d.ts.map +1 -1
  72. package/dist/expression/index.js +2 -0
  73. package/dist/expression/index.js.map +1 -1
  74. package/dist/expression/parser.d.ts.map +1 -1
  75. package/dist/expression/parser.js +2 -0
  76. package/dist/expression/parser.js.map +1 -1
  77. package/dist/expression/types.d.ts.map +1 -1
  78. package/dist/expression/types.js +2 -0
  79. package/dist/expression/types.js.map +1 -1
  80. package/dist/function/FunctionDeclaration.d.ts.map +1 -1
  81. package/dist/function/FunctionDeclaration.js +2 -0
  82. package/dist/function/FunctionDeclaration.js.map +1 -1
  83. package/dist/function/index.d.ts.map +1 -1
  84. package/dist/function/index.js +2 -0
  85. package/dist/function/index.js.map +1 -1
  86. package/dist/function/serialization.d.ts.map +1 -1
  87. package/dist/function/serialization.js +2 -0
  88. package/dist/function/serialization.js.map +1 -1
  89. package/dist/id/index.d.ts.map +1 -1
  90. package/dist/id/index.js +2 -0
  91. package/dist/id/index.js.map +1 -1
  92. package/dist/index.d.ts.map +1 -1
  93. package/dist/index.js +2 -0
  94. package/dist/index.js.map +1 -1
  95. package/dist/memory/Memory.d.ts.map +1 -1
  96. package/dist/memory/Memory.js +2 -0
  97. package/dist/memory/Memory.js.map +1 -1
  98. package/dist/memory/MemoryDefinition.d.ts.map +1 -1
  99. package/dist/memory/MemoryDefinition.js +2 -0
  100. package/dist/memory/MemoryDefinition.js.map +1 -1
  101. package/dist/memory/MemoryFileDefinition.d.ts.map +1 -1
  102. package/dist/memory/MemoryFileDefinition.js +2 -0
  103. package/dist/memory/MemoryFileDefinition.js.map +1 -1
  104. package/dist/memory/MemoryRegistry.d.ts.map +1 -1
  105. package/dist/memory/MemoryRegistry.js +2 -0
  106. package/dist/memory/MemoryRegistry.js.map +1 -1
  107. package/dist/memory/VectorDatabaseDefinition.d.ts.map +1 -1
  108. package/dist/memory/VectorDatabaseDefinition.js +2 -0
  109. package/dist/memory/VectorDatabaseDefinition.js.map +1 -1
  110. package/dist/memory/index.d.ts.map +1 -1
  111. package/dist/memory/index.js +2 -0
  112. package/dist/memory/index.js.map +1 -1
  113. package/dist/memory/serialization.d.ts.map +1 -1
  114. package/dist/memory/serialization.js +2 -0
  115. package/dist/memory/serialization.js.map +1 -1
  116. package/dist/migration/index.d.ts.map +1 -1
  117. package/dist/migration/index.js +2 -0
  118. package/dist/migration/index.js.map +1 -1
  119. package/dist/migration/migrate.d.ts.map +1 -1
  120. package/dist/migration/migrate.js +2 -0
  121. package/dist/migration/migrate.js.map +1 -1
  122. package/dist/migration/migrations.d.ts.map +1 -1
  123. package/dist/migration/migrations.js +2 -0
  124. package/dist/migration/migrations.js.map +1 -1
  125. package/dist/migration/version.d.ts.map +1 -1
  126. package/dist/migration/version.js +2 -0
  127. package/dist/migration/version.js.map +1 -1
  128. package/dist/model/LLMModelDefinition.d.ts.map +1 -1
  129. package/dist/model/LLMModelDefinition.js +2 -0
  130. package/dist/model/LLMModelDefinition.js.map +1 -1
  131. package/dist/model/Model.d.ts.map +1 -1
  132. package/dist/model/Model.js +2 -13
  133. package/dist/model/Model.js.map +1 -1
  134. package/dist/model/ModelDefinition.d.ts.map +1 -1
  135. package/dist/model/ModelDefinition.js +2 -0
  136. package/dist/model/ModelDefinition.js.map +1 -1
  137. package/dist/model/ModelRegistry.d.ts.map +1 -1
  138. package/dist/model/ModelRegistry.js +2 -0
  139. package/dist/model/ModelRegistry.js.map +1 -1
  140. package/dist/model/index.d.ts.map +1 -1
  141. package/dist/model/index.js +2 -0
  142. package/dist/model/index.js.map +1 -1
  143. package/dist/model/serialization.d.ts.map +1 -1
  144. package/dist/model/serialization.js +2 -0
  145. package/dist/model/serialization.js.map +1 -1
  146. package/dist/node/AgentNode.d.ts.map +1 -1
  147. package/dist/node/AgentNode.js +2 -0
  148. package/dist/node/AgentNode.js.map +1 -1
  149. package/dist/node/DataNode.d.ts.map +1 -1
  150. package/dist/node/DataNode.js +2 -0
  151. package/dist/node/DataNode.js.map +1 -1
  152. package/dist/node/FunctionNode.d.ts.map +1 -1
  153. package/dist/node/FunctionNode.js +2 -0
  154. package/dist/node/FunctionNode.js.map +1 -1
  155. package/dist/node/InputNode.d.ts.map +1 -1
  156. package/dist/node/InputNode.js +2 -0
  157. package/dist/node/InputNode.js.map +1 -1
  158. package/dist/node/LogicNode.d.ts.map +1 -1
  159. package/dist/node/LogicNode.js +2 -0
  160. package/dist/node/LogicNode.js.map +1 -1
  161. package/dist/node/MqttNode.d.ts.map +1 -1
  162. package/dist/node/MqttNode.js +2 -0
  163. package/dist/node/MqttNode.js.map +1 -1
  164. package/dist/node/Node.d.ts.map +1 -1
  165. package/dist/node/Node.js +2 -7
  166. package/dist/node/Node.js.map +1 -1
  167. package/dist/node/NodeDefinition.d.ts.map +1 -1
  168. package/dist/node/NodeDefinition.js +2 -0
  169. package/dist/node/NodeDefinition.js.map +1 -1
  170. package/dist/node/NodeRegistry.d.ts.map +1 -1
  171. package/dist/node/NodeRegistry.js +2 -0
  172. package/dist/node/NodeRegistry.js.map +1 -1
  173. package/dist/node/OutputNode.d.ts.map +1 -1
  174. package/dist/node/OutputNode.js +4 -2
  175. package/dist/node/OutputNode.js.map +1 -1
  176. package/dist/node/ToolNode.d.ts.map +1 -1
  177. package/dist/node/ToolNode.js +2 -0
  178. package/dist/node/ToolNode.js.map +1 -1
  179. package/dist/node/TriggerNode.d.ts.map +1 -1
  180. package/dist/node/TriggerNode.js +2 -0
  181. package/dist/node/TriggerNode.js.map +1 -1
  182. package/dist/node/constants.d.ts.map +1 -1
  183. package/dist/node/constants.js +2 -0
  184. package/dist/node/constants.js.map +1 -1
  185. package/dist/node/index.d.ts.map +1 -1
  186. package/dist/node/index.js +2 -0
  187. package/dist/node/index.js.map +1 -1
  188. package/dist/node/methods.d.ts.map +1 -1
  189. package/dist/node/methods.js +2 -0
  190. package/dist/node/methods.js.map +1 -1
  191. package/dist/node/serialization.d.ts.map +1 -1
  192. package/dist/node/serialization.js +33 -13
  193. package/dist/node/serialization.js.map +1 -1
  194. package/dist/parameter/OutputParameter.d.ts.map +1 -1
  195. package/dist/parameter/OutputParameter.js +2 -0
  196. package/dist/parameter/OutputParameter.js.map +1 -1
  197. package/dist/parameter/Parameter.d.ts.map +1 -1
  198. package/dist/parameter/Parameter.js +2 -0
  199. package/dist/parameter/Parameter.js.map +1 -1
  200. package/dist/parameter/index.d.ts.map +1 -1
  201. package/dist/parameter/index.js +2 -0
  202. package/dist/parameter/index.js.map +1 -1
  203. package/dist/variable/Variable.d.ts.map +1 -1
  204. package/dist/variable/Variable.js +2 -0
  205. package/dist/variable/Variable.js.map +1 -1
  206. package/dist/variable/index.d.ts.map +1 -1
  207. package/dist/variable/index.js +2 -3
  208. package/dist/variable/index.js.map +1 -1
  209. package/dist/variable/operations.d.ts.map +1 -1
  210. package/dist/variable/operations.js +2 -0
  211. package/dist/variable/operations.js.map +1 -1
  212. package/dist/workflow/Workflow.d.ts.map +1 -1
  213. package/dist/workflow/Workflow.js +2 -0
  214. package/dist/workflow/Workflow.js.map +1 -1
  215. package/dist/workflow/index.d.ts.map +1 -1
  216. package/dist/workflow/index.js +2 -0
  217. package/dist/workflow/index.js.map +1 -1
  218. package/dist/workflow/serialization.d.ts.map +1 -1
  219. package/dist/workflow/serialization.js +2 -4
  220. package/dist/workflow/serialization.js.map +1 -1
  221. package/package.json +2 -2
  222. package/src/api/deployment.ts +74 -0
  223. package/src/api/engine.ts +744 -0
  224. package/src/api/index.ts +29 -11
  225. package/src/api/workflow.ts +25 -9
  226. package/src/channel/Channel.ts +14 -11
  227. package/src/channel/ChannelDefinition.ts +101 -76
  228. package/src/channel/index.ts +9 -6
  229. package/src/channel/serialization.ts +77 -68
  230. package/src/deploy/index.ts +17 -1
  231. package/src/deploy/inputs.ts +44 -0
  232. package/src/deploy/requirements.test.ts +64 -61
  233. package/src/deploy/requirements.ts +159 -41
  234. package/src/deploy/spec.test.ts +227 -0
  235. package/src/deploy/spec.ts +397 -0
  236. package/src/diagnostics/__fixtures__/diagnosticFixtures.ts +161 -158
  237. package/src/diagnostics/diagnostics.test.ts +881 -878
  238. package/src/diagnostics/diagnostics.ts +939 -936
  239. package/src/diagnostics/index.ts +14 -11
  240. package/src/edge/Edge.ts +26 -23
  241. package/src/edge/EdgeDefinition.ts +48 -45
  242. package/src/edge/EdgeType.ts +22 -19
  243. package/src/edge/index.ts +11 -8
  244. package/src/edge/serialization.ts +86 -83
  245. package/src/expression/index.ts +7 -4
  246. package/src/expression/parser.ts +365 -362
  247. package/src/expression/types.ts +33 -30
  248. package/src/function/FunctionDeclaration.ts +57 -54
  249. package/src/function/index.ts +6 -3
  250. package/src/function/serialization.ts +43 -40
  251. package/src/globals.d.ts +12 -9
  252. package/src/id/index.ts +11 -8
  253. package/src/index.ts +25 -22
  254. package/src/memory/Memory.ts +18 -15
  255. package/src/memory/MemoryDefinition.ts +19 -16
  256. package/src/memory/MemoryFileDefinition.ts +40 -37
  257. package/src/memory/MemoryRegistry.ts +38 -35
  258. package/src/memory/VectorDatabaseDefinition.ts +24 -21
  259. package/src/memory/index.ts +11 -8
  260. package/src/memory/serialization.ts +50 -47
  261. package/src/migration/index.ts +7 -4
  262. package/src/migration/migrate.test.ts +47 -44
  263. package/src/migration/migrate.ts +61 -58
  264. package/src/migration/migrations.ts +27 -24
  265. package/src/migration/version.ts +12 -9
  266. package/src/model/LLMModelDefinition.ts +15 -12
  267. package/src/model/Model.ts +42 -39
  268. package/src/model/ModelDefinition.ts +18 -15
  269. package/src/model/ModelRegistry.ts +36 -33
  270. package/src/model/index.ts +10 -7
  271. package/src/model/serialization.ts +33 -30
  272. package/src/node/AgentNode.ts +85 -82
  273. package/src/node/DataNode.ts +44 -41
  274. package/src/node/FunctionNode.ts +79 -76
  275. package/src/node/InputNode.ts +188 -185
  276. package/src/node/LogicNode.ts +36 -33
  277. package/src/node/MqttNode.ts +130 -127
  278. package/src/node/Node.ts +64 -61
  279. package/src/node/NodeDefinition.ts +40 -37
  280. package/src/node/NodeRegistry.ts +88 -85
  281. package/src/node/OutputNode.ts +90 -87
  282. package/src/node/ToolNode.ts +35 -32
  283. package/src/node/TriggerNode.ts +275 -272
  284. package/src/node/constants.ts +19 -16
  285. package/src/node/index.ts +29 -26
  286. package/src/node/methods.ts +281 -278
  287. package/src/node/serialization.test.ts +51 -0
  288. package/src/node/serialization.ts +565 -544
  289. package/src/parameter/OutputParameter.ts +71 -68
  290. package/src/parameter/Parameter.ts +246 -243
  291. package/src/parameter/index.ts +36 -33
  292. package/src/variable/Variable.ts +13 -10
  293. package/src/variable/index.ts +19 -16
  294. package/src/variable/operations.ts +109 -106
  295. package/src/workflow/Workflow.ts +44 -41
  296. package/src/workflow/index.ts +6 -3
  297. package/src/workflow/serialization.test.ts +243 -240
  298. package/src/workflow/serialization.ts +245 -242
@@ -1,61 +1,64 @@
1
- import { describe, it, expect } from "vitest";
2
- import { getReferencedCatalogModelIds } from "./requirements";
3
- import { MAIN_CANVAS_ID, type Workflow, type Canvas } from "../workflow";
4
- import type { Node } from "../node";
5
- import type { Model } from "../model";
6
-
7
- // Minimal Agent node referencing `model`. Cast through the union — only id/type/
8
- // arguments.model matter to the walk.
9
- function agent(id: string, model: string): Node {
10
- return {
11
- id,
12
- type: "Agent",
13
- position: { x: 0, y: 0 },
14
- arguments: {
15
- name: id,
16
- model,
17
- instructions: "",
18
- outputDeclarations: [],
19
- memoryRefs: [],
20
- answer: { active: true, mode: "emit", name: "answer" },
21
- },
22
- } as Node;
23
- }
24
-
25
- function canvas(nodes: Node[]): Canvas {
26
- return { nodes, edges: [], variables: {} };
27
- }
28
-
29
- const customModel: Model = { id: "custom-llm", label: "Custom", type: "LLMModel", arguments: {} };
30
-
31
- function workflow(canvases: Workflow["canvases"], models: Record<string, Model> = {}): Workflow {
32
- return { canvases, functions: {}, channels: {}, memory: {}, models };
33
- }
34
-
35
- describe("getReferencedCatalogModelIds", () => {
36
- it("returns catalog ids (referenced but not declared), excluding declared customs", () => {
37
- const wf = workflow(
38
- { [MAIN_CANVAS_ID]: canvas([agent("n1", "claude-opus-4-7"), agent("n2", "custom-llm")]) },
39
- { "custom-llm": customModel },
40
- );
41
- expect(getReferencedCatalogModelIds(wf)).toEqual(["claude-opus-4-7"]);
42
- });
43
-
44
- it("ignores unset model references", () => {
45
- const wf = workflow({ [MAIN_CANVAS_ID]: canvas([agent("n1", "")]) });
46
- expect(getReferencedCatalogModelIds(wf)).toEqual([]);
47
- });
48
-
49
- it("dedupes across nodes and walks every canvas (main + function bodies)", () => {
50
- const wf = workflow({
51
- [MAIN_CANVAS_ID]: canvas([agent("n1", "claude-opus-4-7"), agent("n2", "claude-opus-4-7")]),
52
- fnBody: canvas([agent("n3", "gemini-2")]),
53
- });
54
- expect(getReferencedCatalogModelIds(wf).sort()).toEqual(["claude-opus-4-7", "gemini-2"]);
55
- });
56
-
57
- it("returns nothing when every referenced model is declared", () => {
58
- const wf = workflow({ [MAIN_CANVAS_ID]: canvas([agent("n1", "custom-llm")]) }, { "custom-llm": customModel });
59
- expect(getReferencedCatalogModelIds(wf)).toEqual([]);
60
- });
61
- });
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (c) 2026 ForestHub.
3
+
4
+ import { describe, it, expect } from "vitest";
5
+ import { getReferencedCatalogModelIds } from "./requirements";
6
+ import { MAIN_CANVAS_ID, type Workflow, type Canvas } from "../workflow";
7
+ import type { Node } from "../node";
8
+ import type { Model } from "../model";
9
+
10
+ // Minimal Agent node referencing `model`. Cast through the union — only id/type/
11
+ // arguments.model matter to the walk.
12
+ function agent(id: string, model: string): Node {
13
+ return {
14
+ id,
15
+ type: "Agent",
16
+ position: { x: 0, y: 0 },
17
+ arguments: {
18
+ name: id,
19
+ model,
20
+ instructions: "",
21
+ outputDeclarations: [],
22
+ memoryRefs: [],
23
+ answer: { active: true, mode: "emit", name: "answer" },
24
+ },
25
+ } as Node;
26
+ }
27
+
28
+ function canvas(nodes: Node[]): Canvas {
29
+ return { nodes, edges: [], variables: {} };
30
+ }
31
+
32
+ const customModel: Model = { id: "custom-llm", label: "Custom", type: "LLMModel", arguments: {} };
33
+
34
+ function workflow(canvases: Workflow["canvases"], models: Record<string, Model> = {}): Workflow {
35
+ return { canvases, functions: {}, channels: {}, memory: {}, models };
36
+ }
37
+
38
+ describe("getReferencedCatalogModelIds", () => {
39
+ it("returns catalog ids (referenced but not declared), excluding declared customs", () => {
40
+ const wf = workflow(
41
+ { [MAIN_CANVAS_ID]: canvas([agent("n1", "claude-opus-4-7"), agent("n2", "custom-llm")]) },
42
+ { "custom-llm": customModel },
43
+ );
44
+ expect(getReferencedCatalogModelIds(wf)).toEqual(["claude-opus-4-7"]);
45
+ });
46
+
47
+ it("ignores unset model references", () => {
48
+ const wf = workflow({ [MAIN_CANVAS_ID]: canvas([agent("n1", "")]) });
49
+ expect(getReferencedCatalogModelIds(wf)).toEqual([]);
50
+ });
51
+
52
+ it("dedupes across nodes and walks every canvas (main + function bodies)", () => {
53
+ const wf = workflow({
54
+ [MAIN_CANVAS_ID]: canvas([agent("n1", "claude-opus-4-7"), agent("n2", "claude-opus-4-7")]),
55
+ fnBody: canvas([agent("n3", "gemini-2")]),
56
+ });
57
+ expect(getReferencedCatalogModelIds(wf).sort()).toEqual(["claude-opus-4-7", "gemini-2"]);
58
+ });
59
+
60
+ it("returns nothing when every referenced model is declared", () => {
61
+ const wf = workflow({ [MAIN_CANVAS_ID]: canvas([agent("n1", "custom-llm")]) }, { "custom-llm": customModel });
62
+ expect(getReferencedCatalogModelIds(wf)).toEqual([]);
63
+ });
64
+ });
@@ -1,41 +1,159 @@
1
- import type { Workflow } from "../workflow";
2
- import { NodeRegistry, isNodeUsedAsTool } from "../node";
3
- import { isParameterActive } from "../parameter";
4
-
5
- /**
6
- * Model ids that nodes reference but the workflow does not declare in `models`.
7
- * A `modelSelect` accepts exactly two sources — declared custom models and the
8
- * static catalog — so any referenced id that isn't a declared model is a catalog
9
- * model: it carries no declared config, yet still needs a provider/credential
10
- * supplied at deploy.
11
- *
12
- * This is the one deploy demand the workflow's resource arrays can't express:
13
- * channels/memory/declared-models are enumerable directly from
14
- * `workflow.{channels,memory,models}`, but catalog model ids live only on the
15
- * nodes that pick them hence the walk. Spans every canvas (main + function
16
- * bodies) and honors parameter activation, so a model behind an inactive
17
- * `modelSelect` (pruned on serialize, never deployed) is not counted.
18
- */
19
- export function getReferencedCatalogModelIds(workflow: Workflow): string[] {
20
- const declaredModel = new Set(Object.keys(workflow.models));
21
- const catalogIds = new Set<string>();
22
-
23
- for (const canvas of Object.values(workflow.canvases)) {
24
- for (const node of canvas.nodes) {
25
- const def = NodeRegistry.getByType(node.type);
26
- if (!def) continue;
27
- const args = node.arguments as Record<string, unknown>;
28
- const isToolInput = isNodeUsedAsTool(node.id, node, canvas.edges);
29
- for (const param of def.parameters) {
30
- if (param.type !== "modelSelect") continue;
31
- if (!isParameterActive(param, args, isToolInput)) continue;
32
- const id = args[param.id];
33
- if (typeof id === "string" && id !== "" && !declaredModel.has(id)) {
34
- catalogIds.add(id);
35
- }
36
- }
37
- }
38
- }
39
-
40
- return [...catalogIds];
41
- }
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (c) 2026 ForestHub.
3
+
4
+ import type { Workflow } from "../workflow";
5
+ import { NodeRegistry, isNodeUsedAsTool } from "../node";
6
+ import { isParameterActive } from "../parameter";
7
+
8
+ /**
9
+ * Model ids that nodes reference but the workflow does not declare in `models`.
10
+ * A `modelSelect` accepts exactly two sources — declared custom models and the
11
+ * static catalog — so any referenced id that isn't a declared model is a catalog
12
+ * model: it carries no declared config, yet still needs a provider/credential
13
+ * supplied at deploy.
14
+ *
15
+ * This is the one deploy demand the workflow's resource arrays can't express:
16
+ * channels/memory/declared-models are enumerable directly from
17
+ * `workflow.{channels,memory,models}`, but catalog model ids live only on the
18
+ * nodes that pick them — hence the walk. Spans every canvas (main + function
19
+ * bodies) and honors parameter activation, so a model behind an inactive
20
+ * `modelSelect` (pruned on serialize, never deployed) is not counted.
21
+ */
22
+ export function getReferencedCatalogModelIds(workflow: Workflow): string[] {
23
+ const declaredModel = new Set(Object.keys(workflow.models));
24
+ const catalogIds = new Set<string>();
25
+
26
+ for (const canvas of Object.values(workflow.canvases)) {
27
+ for (const node of canvas.nodes) {
28
+ const def = NodeRegistry.getByType(node.type);
29
+ if (!def) continue;
30
+ const args = node.arguments as Record<string, unknown>;
31
+ const isToolInput = isNodeUsedAsTool(node.id, node, canvas.edges);
32
+ for (const param of def.parameters) {
33
+ if (param.type !== "modelSelect") continue;
34
+ if (!isParameterActive(param, args, isToolInput)) continue;
35
+ const id = args[param.id];
36
+ if (typeof id === "string" && id !== "" && !declaredModel.has(id)) {
37
+ catalogIds.add(id);
38
+ }
39
+ }
40
+ }
41
+ }
42
+
43
+ return [...catalogIds];
44
+ }
45
+
46
+ // The five hardware-channel families the engine has a driver for. UART is the
47
+ // odd one out: it carries no per-channel sub-address (see `addressable`).
48
+ export type HardwareFamily = "gpio" | "adc" | "dac" | "pwm" | "serial";
49
+
50
+ // One hardware channel the workflow declares. `family` is derived from the
51
+ // channel type; `addressable` is false only for serial/UART (every
52
+ // gpio/adc/dac/pwm channel needs an `index` sub-address, UART does not).
53
+ export interface HardwareChannel {
54
+ id: string;
55
+ label: string;
56
+ family: HardwareFamily;
57
+ addressable: boolean;
58
+ }
59
+
60
+ export interface MqttChannel {
61
+ id: string;
62
+ label: string;
63
+ }
64
+
65
+ // One custom/self-hosted model declared in workflow.models — needs an
66
+ // ExternalResources provider entry (a device sidecar or a network endpoint).
67
+ export interface CustomModel {
68
+ id: string;
69
+ label: string;
70
+ }
71
+
72
+ // What a workflow needs from its environment, derived from its content alone —
73
+ // no operator input. Drives both input collection (which bindings to ask for)
74
+ // and the spec resolver's completeness check. The component-set derivation the
75
+ // migration doc names as must-not-be-duplicated lives here, shared by every
76
+ // producer.
77
+ export interface DeployRequirements {
78
+ // At least one Agent references a catalog model (not declared in
79
+ // workflow.models) — that model needs a provider key as engine env.
80
+ hasProviderModel: boolean;
81
+ // The workflow has a Retriever node. A standalone engine has no retriever, so
82
+ // the node cannot resolve — a producer may refuse to deploy.
83
+ hasRetriever: boolean;
84
+ // The workflow has a WebSearchTool node — needs a web-search key as engine env.
85
+ hasWebSearch: boolean;
86
+ // Every hardware channel, in declaration order; drives the device manifest +
87
+ // mapping + container device passthrough.
88
+ hardwareChannels: HardwareChannel[];
89
+ // Every MQTT channel; each becomes an ExternalResources entry + a mapping.
90
+ mqttChannels: MqttChannel[];
91
+ // Every declared custom model; each becomes an ExternalResources provider + a
92
+ // mapping (and a llama-server sidecar when bound on-device).
93
+ customModels: CustomModel[];
94
+ }
95
+
96
+ // Drift sentinel: a new ChannelType widens the switch input and breaks
97
+ // compilation here until the new type is classified above.
98
+ function assertNeverChannel(t: never): never {
99
+ throw new Error(`unhandled channel type: ${String(t)}`);
100
+ }
101
+
102
+ // deriveRequirements reads what a workflow demands of its environment. Pure —
103
+ // no I/O, no operator input. Sorts every declared channel into the hardware /
104
+ // MQTT pools the deploy artifacts need and walks nodes (main + function bodies)
105
+ // for the catalog-model / retriever / web-search signals.
106
+ export function deriveRequirements(workflow: Workflow): DeployRequirements {
107
+ const hardwareChannels: HardwareChannel[] = [];
108
+ const mqttChannels: MqttChannel[] = [];
109
+
110
+ for (const channel of Object.values(workflow.channels)) {
111
+ switch (channel.type) {
112
+ case "GPIOIN":
113
+ case "GPIOOUT":
114
+ hardwareChannels.push({ id: channel.id, label: channel.label, family: "gpio", addressable: true });
115
+ break;
116
+ case "ADC":
117
+ hardwareChannels.push({ id: channel.id, label: channel.label, family: "adc", addressable: true });
118
+ break;
119
+ case "DAC":
120
+ hardwareChannels.push({ id: channel.id, label: channel.label, family: "dac", addressable: true });
121
+ break;
122
+ case "PWM":
123
+ hardwareChannels.push({ id: channel.id, label: channel.label, family: "pwm", addressable: true });
124
+ break;
125
+ case "UART":
126
+ hardwareChannels.push({ id: channel.id, label: channel.label, family: "serial", addressable: false });
127
+ break;
128
+ case "MQTT":
129
+ mqttChannels.push({ id: channel.id, label: channel.label });
130
+ break;
131
+ case "LOG":
132
+ // Resolves to the ambient engine logger — no platform resource to bind, so
133
+ // it demands nothing of the deploy environment.
134
+ break;
135
+ default:
136
+ return assertNeverChannel(channel.type);
137
+ }
138
+ }
139
+
140
+ let hasRetriever = false;
141
+ let hasWebSearch = false;
142
+ for (const canvas of Object.values(workflow.canvases)) {
143
+ for (const node of canvas.nodes) {
144
+ if (node.type === "Retriever") hasRetriever = true;
145
+ else if (node.type === "WebSearchTool") hasWebSearch = true;
146
+ }
147
+ }
148
+
149
+ const customModels: CustomModel[] = Object.values(workflow.models).map((m) => ({ id: m.id, label: m.label }));
150
+
151
+ return {
152
+ hasProviderModel: getReferencedCatalogModelIds(workflow).length > 0,
153
+ hasRetriever,
154
+ hasWebSearch,
155
+ hardwareChannels,
156
+ mqttChannels,
157
+ customModels,
158
+ };
159
+ }
@@ -0,0 +1,227 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (c) 2026 ForestHub.
3
+
4
+ import { describe, it, expect } from "vitest";
5
+ import { buildDeploymentSpec, assertDeployable, sidecarServiceName } from "./spec";
6
+ import { deriveRequirements } from "./requirements";
7
+ import type { DeploymentInputs } from "./inputs";
8
+ import { MAIN_CANVAS_ID, type Workflow } from "../workflow";
9
+ import type { Channel } from "../channel";
10
+ import type { Model } from "../model";
11
+ import type { DeploymentSchemas, EngineSchemas } from "../api";
12
+
13
+ type Spec = DeploymentSchemas["DeploymentSpec"];
14
+
15
+ // The engine is the component named "engine"; its config is transported as an
16
+ // opaque blob, so cast it back to EngineConfig to inspect manifest/mapping/etc.
17
+ function engineOf(spec: Spec): DeploymentSchemas["DeployComponent"] {
18
+ const e = spec.components.find((c) => c.name === "engine");
19
+ if (!e) throw new Error("expected engine component");
20
+ return e;
21
+ }
22
+ function engineConfigOf(spec: Spec): EngineSchemas["EngineConfig"] {
23
+ return engineOf(spec).config as EngineSchemas["EngineConfig"];
24
+ }
25
+ const llamaOf = (spec: Spec, modelId: string) => spec.components.find((c) => c.name === sidecarServiceName(modelId));
26
+
27
+ function channel(id: string, type: Channel["type"], args: Record<string, unknown> = {}): Channel {
28
+ return { id, label: id, type, arguments: args };
29
+ }
30
+
31
+ const customModel: Model = { id: "local-llm", label: "Local", type: "LLMModel", arguments: {} };
32
+
33
+ // One workflow exercising every resource kind: a cdev output (GPIO), a serial
34
+ // device (cdev), a sysfs ADC (privileged), an MQTT channel, and an on-device
35
+ // custom model. Empty main canvas — the resolver reads channels/models, not nodes.
36
+ function fullWorkflow(): Workflow {
37
+ return {
38
+ canvases: { [MAIN_CANVAS_ID]: { nodes: [], edges: [], variables: {} } },
39
+ functions: {},
40
+ channels: {
41
+ led: channel("led", "GPIOOUT"),
42
+ serial0: channel("serial0", "UART"),
43
+ sensor: channel("sensor", "ADC"),
44
+ telemetry: channel("telemetry", "MQTT", { topic: "t/x" }),
45
+ },
46
+ memory: {},
47
+ models: { "local-llm": customModel },
48
+ };
49
+ }
50
+
51
+ const fullInputs: DeploymentInputs = {
52
+ hardware: {
53
+ led: { chipOrDevice: "/dev/gpiochip0", index: 17 },
54
+ serial0: { chipOrDevice: "/dev/ttyUSB0", baud: 9600 },
55
+ sensor: { chipOrDevice: "/sys/bus/iio/devices/iio:device0", index: 0 },
56
+ },
57
+ mqtt: { telemetry: { brokerUrl: "mqtt://broker.local:1883", username: "u", password: "p" } },
58
+ models: { "local-llm": { location: "device", modelFile: "model.gguf" } },
59
+ };
60
+
61
+ const meta = {
62
+ id: "dep-1",
63
+ engineImage: "fh-engine:0.4.2",
64
+ llamaServerImage: "ghcr.io/ggml-org/llama.cpp:server-b8589",
65
+ };
66
+
67
+ describe("buildDeploymentSpec", () => {
68
+ it("resolves a full workflow into a contract-shaped spec", () => {
69
+ const { spec } = buildDeploymentSpec(fullWorkflow(), fullInputs, meta);
70
+
71
+ expect(spec.schemaVersion).toBe(1);
72
+ expect(spec.id).toBe("dep-1");
73
+
74
+ const engine = engineOf(spec);
75
+ expect(engine.image).toBe("fh-engine:0.4.2");
76
+ expect(engine.pull).toBe("never"); // built locally, in no registry
77
+ expect(engineConfigOf(spec).workflow.schemaVersion).toBeGreaterThanOrEqual(1);
78
+ });
79
+
80
+ it("splits the device manifest by family and maps every resource", () => {
81
+ const config = engineConfigOf(buildDeploymentSpec(fullWorkflow(), fullInputs, meta).spec);
82
+ const m = config.manifest!;
83
+ expect(Object.keys(m.gpios ?? {})).toHaveLength(1);
84
+ expect(Object.keys(m.serials ?? {})).toHaveLength(1);
85
+ expect(Object.keys(m.adcs ?? {})).toHaveLength(1);
86
+
87
+ // Mapping keyed by every workflow logical id; each ref resolves to a built resource.
88
+ expect(Object.keys(config.mapping ?? {}).sort()).toEqual(["led", "local-llm", "sensor", "serial0", "telemetry"]);
89
+ expect(config.mapping!.sensor).toEqual({ ref: expect.any(String), index: 0 });
90
+ });
91
+
92
+ it("resolves cdev nodes into devices and forces privileged for sysfs families", () => {
93
+ const engine = engineOf(buildDeploymentSpec(fullWorkflow(), fullInputs, meta).spec);
94
+ // GPIO + serial are cdev → granted; ADC (sysfs) is not a node → privileged instead.
95
+ expect(engine.devices?.sort()).toEqual(["/dev/gpiochip0", "/dev/ttyUSB0"]);
96
+ expect(engine.privileged).toBe(true);
97
+ // A nonroot image reaching root-owned nodes/sysfs runs as root.
98
+ expect(engine.user).toBe("0:0");
99
+ });
100
+
101
+ it("emits a llama-server component and points the engine's provider at the sidecar", () => {
102
+ const { spec } = buildDeploymentSpec(fullWorkflow(), fullInputs, meta);
103
+ expect(llamaOf(spec, "local-llm")).toMatchObject({
104
+ name: sidecarServiceName("local-llm"),
105
+ image: "ghcr.io/ggml-org/llama.cpp:server-b8589",
106
+ command: ["--model", "/var/lib/foresthub/workspace/model.gguf", "--host", "0.0.0.0", "--port", "8080", "--ctx-size", "4096"],
107
+ volumes: ["./workspaces/llama-local-llm:/var/lib/foresthub/workspace:ro"],
108
+ });
109
+ // The external-resource provider URL must point at the sidecar service name.
110
+ const ext = engineConfigOf(spec).externalResources!;
111
+ const provider = Object.values(ext).find((r) => r.type === "selfhosted");
112
+ expect(provider).toMatchObject({ url: `http://${sidecarServiceName("local-llm")}:8080` });
113
+ });
114
+
115
+ it("omits privileged and the llama sidecar when neither applies", () => {
116
+ const wf: Workflow = {
117
+ canvases: { [MAIN_CANVAS_ID]: { nodes: [], edges: [], variables: {} } },
118
+ functions: {},
119
+ channels: { led: channel("led", "GPIOOUT") },
120
+ memory: {},
121
+ models: {},
122
+ };
123
+ const { spec } = buildDeploymentSpec(wf, { hardware: { led: { chipOrDevice: "/dev/gpiochip0", index: 1 } }, mqtt: {}, models: {} }, meta);
124
+ const engine = engineOf(spec);
125
+ expect(engine.privileged).toBeUndefined();
126
+ expect(spec.components).toHaveLength(1); // engine only, no sidecar
127
+ expect(engine.devices).toEqual(["/dev/gpiochip0"]);
128
+ });
129
+
130
+ it("uses a network model's own endpoint and runs no sidecar", () => {
131
+ const wf: Workflow = {
132
+ canvases: { [MAIN_CANVAS_ID]: { nodes: [], edges: [], variables: {} } },
133
+ functions: {},
134
+ channels: {},
135
+ memory: {},
136
+ models: { "local-llm": customModel },
137
+ };
138
+ const inputs: DeploymentInputs = {
139
+ hardware: {},
140
+ mqtt: {},
141
+ models: { "local-llm": { location: "network", url: "https://infer.example/v1", apiKey: "k" } },
142
+ };
143
+ const { spec, resourceSecrets } = buildDeploymentSpec(wf, inputs, meta);
144
+ expect(spec.components).toHaveLength(1); // engine only, no sidecar
145
+ // The provider config in the spec is secret-free; the apiKey is pulled out.
146
+ const ext = engineConfigOf(spec).externalResources!;
147
+ const [ref, provider] = Object.entries(ext).find(([, r]) => r.type === "selfhosted")!;
148
+ expect(provider).toEqual({ type: "selfhosted", url: "https://infer.example/v1" });
149
+ expect(resourceSecrets[ref]).toEqual("k");
150
+ });
151
+
152
+ it("pulls MQTT/endpoint secrets out of the spec, keyed by resource ref", () => {
153
+ const { spec, resourceSecrets } = buildDeploymentSpec(fullWorkflow(), fullInputs, meta);
154
+ const ext = engineConfigOf(spec).externalResources!;
155
+ // The stored connection carries metadata but never the password.
156
+ const [mqttRef, mqttConn] = Object.entries(ext).find(([, r]) => r.type === "mqtt")!;
157
+ expect(mqttConn).not.toHaveProperty("password");
158
+ expect(mqttConn).toMatchObject({ username: "u" });
159
+ expect(resourceSecrets[mqttRef]).toEqual("p");
160
+ // Whole spec serialized carries no secret value.
161
+ expect(JSON.stringify(spec)).not.toContain('"p"');
162
+ });
163
+ });
164
+
165
+ describe("buildDeploymentSpec custom components", () => {
166
+ const bareWf: Workflow = {
167
+ canvases: { [MAIN_CANVAS_ID]: { nodes: [], edges: [], variables: {} } },
168
+ functions: {},
169
+ channels: {},
170
+ memory: {},
171
+ models: {},
172
+ };
173
+ const bareInputs: DeploymentInputs = { hardware: {}, mqtt: {}, models: {} };
174
+
175
+ it("merges custom components after engine (and llama)", () => {
176
+ const grafana = { name: "grafana", image: "grafana/grafana:11.3.0", ports: ["3000:3000"] };
177
+ const { spec } = buildDeploymentSpec(bareWf, bareInputs, meta, [grafana]);
178
+ expect(spec.components.map((c) => c.name)).toEqual(["engine", "grafana"]);
179
+ });
180
+
181
+ it("rejects a custom name colliding with the engine", () => {
182
+ expect(() => buildDeploymentSpec(bareWf, bareInputs, meta, [{ name: "engine", image: "x" }])).toThrow(
183
+ /duplicate component name "engine"/,
184
+ );
185
+ });
186
+
187
+ it("rejects a custom name colliding with a generated llama sidecar", () => {
188
+ const dup = { name: sidecarServiceName("local-llm"), image: "x" };
189
+ expect(() => buildDeploymentSpec(fullWorkflow(), fullInputs, meta, [dup])).toThrow(/duplicate component name/);
190
+ });
191
+
192
+ it("allows the same image under different names", () => {
193
+ const a = { name: "graf-a", image: "grafana/grafana:11.3.0" };
194
+ const b = { name: "graf-b", image: "grafana/grafana:11.3.0" };
195
+ const { spec } = buildDeploymentSpec(bareWf, bareInputs, meta, [a, b]);
196
+ expect(spec.components.map((c) => c.name)).toEqual(["engine", "graf-a", "graf-b"]);
197
+ });
198
+
199
+ it("rejects two customs sharing a name", () => {
200
+ const a = { name: "dash", image: "x" };
201
+ const b = { name: "dash", image: "y" };
202
+ expect(() => buildDeploymentSpec(bareWf, bareInputs, meta, [a, b])).toThrow(/duplicate component name "dash"/);
203
+ });
204
+ });
205
+
206
+ describe("assertDeployable", () => {
207
+ it("throws listing every unbound resource", () => {
208
+ const req = deriveRequirements(fullWorkflow());
209
+ expect(() => assertDeployable(req, { hardware: {}, mqtt: {}, models: {} })).toThrow(/device path[\s\S]*broker URL[\s\S]*model/);
210
+ });
211
+
212
+ it("rejects a non-GGUF on-device model file", () => {
213
+ const req = deriveRequirements(fullWorkflow());
214
+ const inputs = { ...fullInputs, models: { "local-llm": { location: "device" as const, modelFile: "model.bin" } } };
215
+ expect(() => assertDeployable(req, inputs)).toThrow(/\.gguf/);
216
+ });
217
+ });
218
+
219
+ describe("deriveRequirements", () => {
220
+ it("classifies channels into hardware families and mqtt", () => {
221
+ const req = deriveRequirements(fullWorkflow());
222
+ expect(req.hardwareChannels.map((c) => c.family).sort()).toEqual(["adc", "gpio", "serial"]);
223
+ expect(req.mqttChannels.map((c) => c.id)).toEqual(["telemetry"]);
224
+ expect(req.customModels.map((c) => c.id)).toEqual(["local-llm"]);
225
+ expect(req.hardwareChannels.find((c) => c.family === "serial")?.addressable).toBe(false);
226
+ });
227
+ });