@foresthubai/workflow-core 0.4.6 → 0.4.7

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 (110) hide show
  1. package/dist/api/camera.d.ts +254 -0
  2. package/dist/api/camera.d.ts.map +1 -0
  3. package/dist/api/camera.js +6 -0
  4. package/dist/api/camera.js.map +1 -0
  5. package/dist/api/engine.d.ts +89 -10
  6. package/dist/api/engine.d.ts.map +1 -1
  7. package/dist/api/index.d.ts +0 -4
  8. package/dist/api/index.d.ts.map +1 -1
  9. package/dist/api/workflow.d.ts +61 -4
  10. package/dist/api/workflow.d.ts.map +1 -1
  11. package/dist/channel/Channel.d.ts +1 -1
  12. package/dist/channel/Channel.d.ts.map +1 -1
  13. package/dist/channel/Channel.js +1 -1
  14. package/dist/channel/Channel.js.map +1 -1
  15. package/dist/channel/ChannelDefinition.d.ts.map +1 -1
  16. package/dist/channel/ChannelDefinition.js +16 -0
  17. package/dist/channel/ChannelDefinition.js.map +1 -1
  18. package/dist/channel/serialization.d.ts.map +1 -1
  19. package/dist/channel/serialization.js +6 -0
  20. package/dist/channel/serialization.js.map +1 -1
  21. package/dist/deploy/bindingRequirements.d.ts +4 -0
  22. package/dist/deploy/bindingRequirements.d.ts.map +1 -0
  23. package/dist/deploy/bindingRequirements.js +59 -0
  24. package/dist/deploy/bindingRequirements.js.map +1 -0
  25. package/dist/deploy/constants.d.ts +8 -0
  26. package/dist/deploy/constants.d.ts.map +1 -0
  27. package/dist/deploy/constants.js +26 -0
  28. package/dist/deploy/constants.js.map +1 -0
  29. package/dist/deploy/index.d.ts +3 -5
  30. package/dist/deploy/index.d.ts.map +1 -1
  31. package/dist/deploy/index.js +10 -2
  32. package/dist/deploy/index.js.map +1 -1
  33. package/dist/deploy/inputs.d.ts +30 -3
  34. package/dist/deploy/inputs.d.ts.map +1 -1
  35. package/dist/deploy/requirements.d.ts +4 -36
  36. package/dist/deploy/requirements.d.ts.map +1 -1
  37. package/dist/deploy/requirements.js +48 -76
  38. package/dist/deploy/requirements.js.map +1 -1
  39. package/dist/deploy/spec.d.ts +10 -4
  40. package/dist/deploy/spec.d.ts.map +1 -1
  41. package/dist/deploy/spec.js +233 -63
  42. package/dist/deploy/spec.js.map +1 -1
  43. package/dist/expression/parser.js +10 -1
  44. package/dist/expression/parser.js.map +1 -1
  45. package/dist/model/MLModelDefinition.d.ts +8 -0
  46. package/dist/model/MLModelDefinition.d.ts.map +1 -0
  47. package/dist/model/MLModelDefinition.js +14 -0
  48. package/dist/model/MLModelDefinition.js.map +1 -0
  49. package/dist/model/Model.d.ts +2 -1
  50. package/dist/model/Model.d.ts.map +1 -1
  51. package/dist/model/Model.js +1 -1
  52. package/dist/model/Model.js.map +1 -1
  53. package/dist/model/ModelRegistry.d.ts.map +1 -1
  54. package/dist/model/ModelRegistry.js +2 -0
  55. package/dist/model/ModelRegistry.js.map +1 -1
  56. package/dist/model/index.d.ts +1 -0
  57. package/dist/model/index.d.ts.map +1 -1
  58. package/dist/model/index.js +1 -0
  59. package/dist/model/index.js.map +1 -1
  60. package/dist/model/serialization.d.ts.map +1 -1
  61. package/dist/model/serialization.js +5 -0
  62. package/dist/model/serialization.js.map +1 -1
  63. package/dist/node/InputNode.d.ts +10 -2
  64. package/dist/node/InputNode.d.ts.map +1 -1
  65. package/dist/node/InputNode.js +16 -0
  66. package/dist/node/InputNode.js.map +1 -1
  67. package/dist/node/MLInferenceNode.d.ts +15 -0
  68. package/dist/node/MLInferenceNode.d.ts.map +1 -0
  69. package/dist/node/MLInferenceNode.js +26 -0
  70. package/dist/node/MLInferenceNode.js.map +1 -0
  71. package/dist/node/Node.d.ts +3 -2
  72. package/dist/node/Node.d.ts.map +1 -1
  73. package/dist/node/NodeRegistry.d.ts.map +1 -1
  74. package/dist/node/NodeRegistry.js +4 -1
  75. package/dist/node/NodeRegistry.js.map +1 -1
  76. package/dist/node/methods.d.ts.map +1 -1
  77. package/dist/node/methods.js +6 -0
  78. package/dist/node/methods.js.map +1 -1
  79. package/dist/node/serialization.d.ts.map +1 -1
  80. package/dist/node/serialization.js +42 -0
  81. package/dist/node/serialization.js.map +1 -1
  82. package/package.json +2 -2
  83. package/src/api/index.ts +0 -15
  84. package/src/api/workflow.ts +61 -4
  85. package/src/channel/Channel.ts +2 -2
  86. package/src/channel/ChannelDefinition.ts +16 -0
  87. package/src/channel/serialization.ts +6 -0
  88. package/src/deploy/constants.ts +30 -0
  89. package/src/deploy/index.ts +14 -17
  90. package/src/deploy/requirements.test.ts +164 -64
  91. package/src/deploy/requirements.ts +104 -159
  92. package/src/diagnostics/diagnostics.test.ts +40 -22
  93. package/src/expression/parser.ts +9 -1
  94. package/src/model/MLModelDefinition.ts +16 -0
  95. package/src/model/Model.ts +6 -2
  96. package/src/model/ModelRegistry.ts +2 -0
  97. package/src/model/index.ts +1 -0
  98. package/src/model/serialization.ts +5 -0
  99. package/src/node/InputNode.ts +28 -2
  100. package/src/node/MLInferenceNode.ts +46 -0
  101. package/src/node/Node.ts +3 -1
  102. package/src/node/NodeRegistry.ts +4 -1
  103. package/src/node/methods.ts +6 -0
  104. package/src/node/serialization.ts +44 -2
  105. package/src/workflow/serialization.test.ts +53 -0
  106. package/src/api/deployment.ts +0 -74
  107. package/src/api/engine.ts +0 -744
  108. package/src/deploy/inputs.ts +0 -44
  109. package/src/deploy/spec.test.ts +0 -227
  110. package/src/deploy/spec.ts +0 -397
@@ -8,7 +8,7 @@ export type webhooks = Record<string, never>;
8
8
  export interface components {
9
9
  schemas: {
10
10
  /** @enum {string} */
11
- DataType: "int" | "float" | "bool" | "string";
11
+ DataType: "int" | "float" | "bool" | "string" | "image";
12
12
  /** @enum {string} */
13
13
  SignalType: "digital" | "analog";
14
14
  /** @enum {string} */
@@ -145,7 +145,7 @@ export interface components {
145
145
  */
146
146
  mode: "r" | "rw";
147
147
  };
148
- Model: components["schemas"]["LLMModel"];
148
+ Model: components["schemas"]["LLMModel"] | components["schemas"]["MLModel"];
149
149
  /** @description A custom or self-hosted language model that agent nodes can reference. */
150
150
  LLMModel: {
151
151
  /**
@@ -160,7 +160,19 @@ export interface components {
160
160
  /** @description Capabilities this model supports. */
161
161
  capabilities: components["schemas"]["ModelCapability"][];
162
162
  };
163
- Node: components["schemas"]["ReadPinNode"] | components["schemas"]["WritePinNode"] | components["schemas"]["AgentNode"] | components["schemas"]["IfNode"] | components["schemas"]["SerialReadNode"] | components["schemas"]["SerialWriteNode"] | components["schemas"]["RetrieverNode"] | components["schemas"]["WebFetchNode"] | components["schemas"]["FunctionCallNode"] | components["schemas"]["OnFunctionCallNode"] | components["schemas"]["DelayNode"] | components["schemas"]["TickerNode"] | components["schemas"]["AlarmNode"] | components["schemas"]["WebSearchToolNode"] | components["schemas"]["OnStartupNode"] | components["schemas"]["OnPinEdgeNode"] | components["schemas"]["OnSerialReceiveNode"] | components["schemas"]["OnThresholdNode"] | components["schemas"]["SetVariableNode"] | components["schemas"]["MqttPublishNode"] | components["schemas"]["OnMqttMessageNode"];
163
+ /** @description A machine-learning model, served by an inference component, that nodes can reference. */
164
+ MLModel: {
165
+ /**
166
+ * @description discriminator enum property added by openapi-typescript
167
+ * @enum {string}
168
+ */
169
+ type: "MLModel";
170
+ /** @description Stable identifier; this is the model name nodes reference and the component selects on. */
171
+ id: string;
172
+ /** @description Display name. */
173
+ label: string;
174
+ };
175
+ Node: components["schemas"]["ReadPinNode"] | components["schemas"]["WritePinNode"] | components["schemas"]["AgentNode"] | components["schemas"]["IfNode"] | components["schemas"]["SerialReadNode"] | components["schemas"]["SerialWriteNode"] | components["schemas"]["RetrieverNode"] | components["schemas"]["WebFetchNode"] | components["schemas"]["MLInferenceNode"] | components["schemas"]["CameraCaptureNode"] | components["schemas"]["FunctionCallNode"] | components["schemas"]["OnFunctionCallNode"] | components["schemas"]["DelayNode"] | components["schemas"]["TickerNode"] | components["schemas"]["AlarmNode"] | components["schemas"]["WebSearchToolNode"] | components["schemas"]["OnStartupNode"] | components["schemas"]["OnPinEdgeNode"] | components["schemas"]["OnSerialReceiveNode"] | components["schemas"]["OnThresholdNode"] | components["schemas"]["SetVariableNode"] | components["schemas"]["MqttPublishNode"] | components["schemas"]["OnMqttMessageNode"];
164
176
  WebSearchToolNode: {
165
177
  id: string;
166
178
  /**
@@ -363,6 +375,38 @@ export interface components {
363
375
  output: components["schemas"]["OutputBinding"];
364
376
  };
365
377
  };
378
+ MLInferenceNode: {
379
+ id: string;
380
+ /**
381
+ * @description discriminator enum property added by openapi-typescript
382
+ * @enum {string}
383
+ */
384
+ type: "MLInference";
385
+ label?: string;
386
+ position: components["schemas"]["NodePosition"];
387
+ arguments: {
388
+ /** @description Reference to an MLModel id. */
389
+ model: string;
390
+ /** @description Variable holding the model input. Its type is resolved at runtime and dispatched to the matching handler. */
391
+ input: components["schemas"]["Reference"];
392
+ output: components["schemas"]["OutputBinding"];
393
+ };
394
+ };
395
+ CameraCaptureNode: {
396
+ id: string;
397
+ /**
398
+ * @description discriminator enum property added by openapi-typescript
399
+ * @enum {string}
400
+ */
401
+ type: "CameraCapture";
402
+ label?: string;
403
+ position: components["schemas"]["NodePosition"];
404
+ arguments: {
405
+ /** @description Reference to a CAMERA channel id. The channel carries optional capture defaults; it resolves to a capture component endpoint at deploy time. */
406
+ cameraReference: string;
407
+ output: components["schemas"]["OutputBinding"];
408
+ };
409
+ };
366
410
  ReadPinNode: {
367
411
  id: string;
368
412
  /**
@@ -519,7 +563,7 @@ export interface components {
519
563
  output: components["schemas"]["OutputBinding"];
520
564
  };
521
565
  };
522
- Channel: components["schemas"]["GPIOINChannel"] | components["schemas"]["GPIOOUTChannel"] | components["schemas"]["ADCChannel"] | components["schemas"]["PWMChannel"] | components["schemas"]["DACChannel"] | components["schemas"]["UARTChannel"] | components["schemas"]["MQTTChannel"] | components["schemas"]["LOGChannel"];
566
+ Channel: components["schemas"]["GPIOINChannel"] | components["schemas"]["GPIOOUTChannel"] | components["schemas"]["ADCChannel"] | components["schemas"]["PWMChannel"] | components["schemas"]["DACChannel"] | components["schemas"]["UARTChannel"] | components["schemas"]["MQTTChannel"] | components["schemas"]["CAMERAChannel"] | components["schemas"]["LOGChannel"];
523
567
  GPIOINChannel: {
524
568
  /**
525
569
  * @description discriminator enum property added by openapi-typescript
@@ -594,6 +638,19 @@ export interface components {
594
638
  /** @description Topic this channel publishes to / subscribes on. */
595
639
  topic: string;
596
640
  };
641
+ CAMERAChannel: {
642
+ /**
643
+ * @description discriminator enum property added by openapi-typescript
644
+ * @enum {string}
645
+ */
646
+ type: "CAMERA";
647
+ id: string;
648
+ label: string;
649
+ /** @description Default capture width in pixels. The source picks its native resolution when omitted. */
650
+ width?: number;
651
+ /** @description Default capture height in pixels. The source picks its native resolution when omitted. */
652
+ height?: number;
653
+ };
597
654
  LOGChannel: {
598
655
  /**
599
656
  * @description discriminator enum property added by openapi-typescript
@@ -1,9 +1,9 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  // Copyright (c) 2026 ForestHub.
3
3
 
4
- export type ChannelType = "GPIOIN" | "GPIOOUT" | "ADC" | "PWM" | "DAC" | "UART" | "MQTT" | "LOG";
4
+ export type ChannelType = "GPIOIN" | "GPIOOUT" | "ADC" | "PWM" | "DAC" | "UART" | "MQTT" | "LOG" | "CAMERA";
5
5
 
6
- export const ALL_CHANNEL_TYPES: ChannelType[] = ["GPIOIN", "GPIOOUT", "ADC", "PWM", "DAC", "UART", "MQTT", "LOG"];
6
+ export const ALL_CHANNEL_TYPES: ChannelType[] = ["GPIOIN", "GPIOOUT", "ADC", "PWM", "DAC", "UART", "MQTT", "LOG", "CAMERA"];
7
7
 
8
8
  /** Interface for a channel instance in the workflow */
9
9
  export interface Channel {
@@ -97,5 +97,21 @@ export const CHANNEL_DEFINITION: ChannelDefinition = {
97
97
  optional: true,
98
98
  activationRules: [{ type: "parameterIn", parameterId: "type", values: ["LOG"] }],
99
99
  },
100
+ {
101
+ id: "width",
102
+ label: "Width (px)",
103
+ description: "Default capture width in pixels (source's native resolution when empty)",
104
+ type: "int",
105
+ optional: true,
106
+ activationRules: [{ type: "parameterIn", parameterId: "type", values: ["CAMERA"] }],
107
+ },
108
+ {
109
+ id: "height",
110
+ label: "Height (px)",
111
+ description: "Default capture height in pixels (source's native resolution when empty)",
112
+ type: "int",
113
+ optional: true,
114
+ activationRules: [{ type: "parameterIn", parameterId: "type", values: ["CAMERA"] }],
115
+ },
100
116
  ],
101
117
  };
@@ -45,6 +45,8 @@ export function serialize(ch: Channel): ApiChannel {
45
45
  return { type, id, label, topic: args.topic as string };
46
46
  case "LOG":
47
47
  return { type, id, label, level: args.level as Schemas["LOGChannel"]["level"], tag: args.tag as string | undefined };
48
+ case "CAMERA":
49
+ return { type, id, label, width: args.width as number | undefined, height: args.height as number | undefined };
48
50
  }
49
51
  }
50
52
 
@@ -67,6 +69,10 @@ export function deserialize(api: ApiChannel): Channel {
67
69
  args.level = api.level;
68
70
  args.tag = api.tag;
69
71
  break;
72
+ case "CAMERA":
73
+ args.width = api.width;
74
+ args.height = api.height;
75
+ break;
70
76
  case "GPIOOUT":
71
77
  case "ADC":
72
78
  case "DAC":
@@ -0,0 +1,30 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (c) 2026 ForestHub.
3
+
4
+ // The component contract: the fixed in-container paths every ForestHub component
5
+ // reads/writes (also the targets the renderers bind-mount host dirs onto) and the
6
+ // canonical component identities. The language-neutral source of truth is
7
+ // contract/component-constants.json; these MUST equal it (enforced by
8
+ // constants.test.ts) and the Go twins in go/component/constants.go. Changing a value
9
+ // means changing all three in lockstep.
10
+
11
+ // Fixed path a component's boot config (config.json) is mounted at, read-only.
12
+ export const COMPONENT_CONFIG_PATH = "/etc/foresthub/config.json";
13
+
14
+ // Fixed path a component's resolved secret document (secrets.json) is mounted at,
15
+ // read-only. Dynamic, id-keyed credentials resolved fresh each deploy, never in the spec.
16
+ export const COMPONENT_SECRETS_PATH = "/etc/foresthub/secrets.json";
17
+
18
+ // Fixed path a component's durable workspace is mounted at (engine memory, model
19
+ // weights, model repository) — persisted across deployments.
20
+ export const COMPONENT_WORKSPACE_PATH = "/var/lib/foresthub/workspace";
21
+
22
+ // Canonical identities of the singleton first-party components. For a singleton the
23
+ // identity is ALSO its compose service/container name — so the renderer names the
24
+ // container this, other components reach it at this hostname, and it stamps its logs
25
+ // with it. llama-server is a singleton too: one container fronts every on-device model
26
+ // with llama-swap, selected by id per request — so its name is fixed, not per-model.
27
+ export const ENGINE_COMPONENT_NAME = "engine";
28
+ export const LLAMA_COMPONENT_NAME = "llama-server";
29
+ export const CAMERA_COMPONENT_NAME = "camera";
30
+ export const ML_COMPONENT_NAME = "ml-inference";
@@ -1,17 +1,14 @@
1
- // SPDX-License-Identifier: Apache-2.0
2
- // Copyright (c) 2026 ForestHub.
3
-
4
- export { getReferencedCatalogModelIds, deriveRequirements } from "./requirements";
5
- export type { DeployRequirements, HardwareChannel, MqttChannel, CustomModel, HardwareFamily } from "./requirements";
6
- export type { DeploymentInputs, HardwareBinding, MqttBinding, ModelBinding } from "./inputs";
7
- export {
8
- buildDeploymentSpec,
9
- assertDeployable,
10
- hardwareConflicts,
11
- familyMismatches,
12
- hardwareAddressKey,
13
- hardwareAddressLabel,
14
- ggufNameError,
15
- sidecarServiceName,
16
- } from "./spec";
17
- export type { DeploymentSpecMeta, DeploymentSpecResult, EngineSecrets } from "./spec";
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (c) 2026 ForestHub.
3
+
4
+ // The deploy module's public surface: the language-neutral, cross-implementation
5
+ // pieces of the deployment pipeline (docs/deployment-pipeline.md). The OSS CLI's
6
+ // spec resolver (buildDeploymentSpec), its rich requirement analysis
7
+ // (deriveRequirements) and its operator-input types live in the workflow-cli
8
+ // package, not here — this module holds only what the backend path must
9
+ // independently agree with: the Stage-0 binding surface (the cross-language seam),
10
+ // the catalog-model walk it builds on, and the component contract.
11
+
12
+ export { workflowBindingRequirements, getReferencedCatalogModelIds, type BindingKind } from "./requirements";
13
+ export { COMPONENT_CONFIG_PATH, COMPONENT_SECRETS_PATH, COMPONENT_WORKSPACE_PATH } from "./constants";
14
+ export { ENGINE_COMPONENT_NAME, CAMERA_COMPONENT_NAME, ML_COMPONENT_NAME, LLAMA_COMPONENT_NAME } from "./constants";
@@ -1,64 +1,164 @@
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
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (c) 2026 ForestHub.
3
+
4
+ import { describe, it, expect } from "vitest";
5
+ import { workflowBindingRequirements, getReferencedCatalogModelIds, type BindingKind } from "./requirements";
6
+ import { MAIN_CANVAS_ID, type Workflow, type Canvas } from "../workflow";
7
+ import type { Channel, ChannelType } from "../channel";
8
+ import type { Model } from "../model";
9
+ import type { Node } from "../node";
10
+
11
+ function channel(id: string, type: ChannelType): Channel {
12
+ return { id, label: id, type, arguments: {} };
13
+ }
14
+
15
+ const llm = (id: string): Model => ({ id, label: id, type: "LLMModel", arguments: {} });
16
+ const ml = (id: string): Model => ({ id, label: id, type: "MLModel", arguments: {} });
17
+
18
+ // Minimal Agent node referencing a catalog model by id — only the model ref
19
+ // matters to the requirement walk. Cast through the union: the other Agent args
20
+ // are irrelevant here.
21
+ function agent(id: string, model: string): Node {
22
+ return {
23
+ id,
24
+ type: "Agent",
25
+ position: { x: 0, y: 0 },
26
+ arguments: {
27
+ name: id,
28
+ model,
29
+ instructions: "",
30
+ outputDeclarations: [],
31
+ memoryRefs: [],
32
+ answer: { active: true, mode: "emit", name: "answer" },
33
+ },
34
+ } as unknown as Node;
35
+ }
36
+
37
+ const canvas = (nodes: Node[]): Canvas => ({ nodes, edges: [], variables: {} });
38
+
39
+ // Build a workflow from parts. `nodes` fills the main canvas; pass `canvases`
40
+ // instead to model multiple bodies (main + function). Any other field
41
+ // (channels, models) overrides the empty default.
42
+ function workflow(parts: Partial<Workflow> & { nodes?: Node[] }): Workflow {
43
+ const { nodes = [], ...rest } = parts;
44
+ return {
45
+ canvases: { [MAIN_CANVAS_ID]: canvas(nodes) },
46
+ functions: {},
47
+ channels: {},
48
+ memory: {},
49
+ models: {},
50
+ ...rest,
51
+ };
52
+ }
53
+
54
+ const byId = (chs: Channel[]): Record<string, Channel> => Object.fromEntries(chs.map((c) => [c.id, c]));
55
+ const modelsById = (ms: Model[]): Record<string, Model> => Object.fromEntries(ms.map((m) => [m.id, m]));
56
+
57
+ // CROSS-LANGUAGE CONFORMANCE. Each case below is a golden fixture: a workflow in,
58
+ // an exact id->kind surface out. The backend's deploy.WorkflowBindingRequirements
59
+ // must produce the identical map for the same workflow — mirror these fixtures on
60
+ // the Go side so the two extractors stay pinned. Divergence here is deploy drift:
61
+ // OSS and the backend disagreeing about what a workflow needs bound.
62
+ describe("workflowBindingRequirements", () => {
63
+ it("returns an empty surface for an empty workflow", () => {
64
+ expect(workflowBindingRequirements(workflow({}))).toEqual({});
65
+ });
66
+
67
+ it("maps every hardware channel family to 'hardware'", () => {
68
+ const chs = byId([
69
+ channel("in", "GPIOIN"),
70
+ channel("out", "GPIOOUT"),
71
+ channel("adc", "ADC"),
72
+ channel("dac", "DAC"),
73
+ channel("pwm", "PWM"),
74
+ channel("uart", "UART"),
75
+ ]);
76
+ const expected: Record<string, BindingKind> = {
77
+ in: "hardware",
78
+ out: "hardware",
79
+ adc: "hardware",
80
+ dac: "hardware",
81
+ pwm: "hardware",
82
+ uart: "hardware",
83
+ };
84
+ expect(workflowBindingRequirements(workflow({ channels: chs }))).toEqual(expected);
85
+ });
86
+
87
+ it("maps MQTT to 'mqtt' and CAMERA to 'camera' (OSS-ahead kind)", () => {
88
+ const chs = byId([channel("telemetry", "MQTT"), channel("cam0", "CAMERA")]);
89
+ expect(workflowBindingRequirements(workflow({ channels: chs }))).toEqual({ telemetry: "mqtt", cam0: "camera" });
90
+ });
91
+
92
+ it("binds nothing for a LOG channel", () => {
93
+ const chs = byId([channel("log", "LOG"), channel("led", "GPIOOUT")]);
94
+ expect(workflowBindingRequirements(workflow({ channels: chs }))).toEqual({ led: "hardware" });
95
+ });
96
+
97
+ it("maps every declared model to 'declaredModel', LLM and ML alike", () => {
98
+ const models = modelsById([llm("local-llm"), ml("yolo")]);
99
+ expect(workflowBindingRequirements(workflow({ models }))).toEqual({ "local-llm": "declaredModel", yolo: "declaredModel" });
100
+ });
101
+
102
+ it("maps a referenced-but-undeclared catalog model to 'catalogModel', keyed by model id", () => {
103
+ const wf = workflow({ nodes: [agent("a1", "gpt-4o")] });
104
+ expect(workflowBindingRequirements(wf)).toEqual({ "gpt-4o": "catalogModel" });
105
+ });
106
+
107
+ it("prefers 'declaredModel' when an id is both declared and referenced (no overwrite)", () => {
108
+ const wf = workflow({ models: modelsById([llm("shared")]), nodes: [agent("a1", "shared")] });
109
+ expect(workflowBindingRequirements(wf)).toEqual({ shared: "declaredModel" });
110
+ });
111
+
112
+ it("does not extract MemoryFile / VectorDatabase memory (RAG not yet in the OSS surface)", () => {
113
+ const wf = workflow({
114
+ memory: { notes: { id: "notes", label: "Notes", type: "MemoryFile", arguments: {} } as Workflow["memory"][string] },
115
+ });
116
+ expect(workflowBindingRequirements(wf)).toEqual({});
117
+ });
118
+
119
+ it("produces the full surface for a mixed workflow", () => {
120
+ const wf = workflow({
121
+ channels: byId([channel("led", "GPIOOUT"), channel("telemetry", "MQTT"), channel("cam0", "CAMERA"), channel("log", "LOG")]),
122
+ models: modelsById([llm("local-llm")]),
123
+ nodes: [agent("a1", "claude-sonnet")],
124
+ });
125
+ const expected: Record<string, BindingKind> = {
126
+ led: "hardware",
127
+ telemetry: "mqtt",
128
+ cam0: "camera",
129
+ "local-llm": "declaredModel",
130
+ "claude-sonnet": "catalogModel",
131
+ };
132
+ expect(workflowBindingRequirements(wf)).toEqual(expected);
133
+ });
134
+ });
135
+
136
+ describe("getReferencedCatalogModelIds", () => {
137
+ it("returns catalog ids (referenced but not declared), excluding declared customs", () => {
138
+ const wf = workflow({
139
+ canvases: { [MAIN_CANVAS_ID]: canvas([agent("n1", "claude-opus-4-7"), agent("n2", "custom-llm")]) },
140
+ models: modelsById([llm("custom-llm")]),
141
+ });
142
+ expect(getReferencedCatalogModelIds(wf)).toEqual(["claude-opus-4-7"]);
143
+ });
144
+
145
+ it("ignores unset model references", () => {
146
+ const wf = workflow({ nodes: [agent("n1", "")] });
147
+ expect(getReferencedCatalogModelIds(wf)).toEqual([]);
148
+ });
149
+
150
+ it("dedupes across nodes and walks every canvas (main + function bodies)", () => {
151
+ const wf = workflow({
152
+ canvases: {
153
+ [MAIN_CANVAS_ID]: canvas([agent("n1", "claude-opus-4-7"), agent("n2", "claude-opus-4-7")]),
154
+ fnBody: canvas([agent("n3", "gemini-2")]),
155
+ },
156
+ });
157
+ expect(getReferencedCatalogModelIds(wf).sort()).toEqual(["claude-opus-4-7", "gemini-2"]);
158
+ });
159
+
160
+ it("returns nothing when every referenced model is declared", () => {
161
+ const wf = workflow({ nodes: [agent("n1", "custom-llm")], models: modelsById([llm("custom-llm")]) });
162
+ expect(getReferencedCatalogModelIds(wf)).toEqual([]);
163
+ });
164
+ });