@foresthubai/workflow-core 0.4.5 → 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
@@ -1,159 +1,104 @@
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
- }
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (c) 2026 ForestHub.
3
+
4
+ // Stage 0 of the deployment pipeline (docs/deployment-pipeline.md): extract the
5
+ // binding SURFACE a workflow demands — which resource ids must be bound, and of
6
+ // what kind from the workflow content alone. This is the language-neutral
7
+ // requirement analysis every deploy path (OSS CLI, backend) shares.
8
+
9
+ import type { Workflow } from "../workflow";
10
+
11
+ // The binding kind a workflow resource must be bound with the discriminator of
12
+ // the deployment api's ResourceBindingRequest.
13
+ //
14
+ // CROSS-LANGUAGE SEAM: these string values MUST match the backend's
15
+ // deploy.BindingRequirement constants (fh-backend deploy.WorkflowBindingRequirements),
16
+ // which are themselves the ResourceBindingRequest discriminators
17
+ // ("hardware"/"mqtt"/"declaredModel"/"catalogModel"/"rag"). A value that drifts
18
+ // here silently disagrees with the backend about what a workflow needs bound.
19
+ //
20
+ // Two deliberate asymmetries with the backend today, each a known catch-up:
21
+ // - "camera" is OSS-ahead: the backend's extractor predates the CAMERA channel
22
+ // and has no camera binding kind yet. OSS emits it; the backend will add it.
23
+ // - "rag" is absent: OSS is behind on retrieval — it does not yet extract a
24
+ // VectorDatabase memory resource as a requirement (the backend emits "rag").
25
+ // Add it when the OSS engine gains a retrieval backing.
26
+ export type BindingKind = "hardware" | "mqtt" | "camera" | "declaredModel" | "catalogModel";
27
+
28
+ // Drift sentinel: a new ChannelType breaks compilation here until it is
29
+ // classified in workflowBindingRequirements' switch.
30
+ function assertNeverChannel(t: never): never {
31
+ throw new Error(`unhandled channel type: ${String(t)}`);
32
+ }
33
+
34
+ // workflowBindingRequirements returns the resources a deploy must bind, keyed by
35
+ // workflow logical id the binding surface. The TS twin of the backend's
36
+ // deploy.WorkflowBindingRequirements: both must derive the SAME id->kind map from
37
+ // the same workflow, or an OSS deploy and a backend deploy disagree about what
38
+ // needs binding.
39
+ //
40
+ // The surface is neither the declared set nor a subset of it:
41
+ // - LOG channels and MemoryFile bind nothing — declared, but out.
42
+ // - referenced catalog models bind a provider yet are node references, not
43
+ // declarations — undeclared, but in.
44
+ //
45
+ // Pure — reads workflow content alone: no operator input, no model catalog.
46
+ export function workflowBindingRequirements(workflow: Workflow): Record<string, BindingKind> {
47
+ const reqs: Record<string, BindingKind> = {};
48
+
49
+ for (const channel of Object.values(workflow.channels)) {
50
+ switch (channel.type) {
51
+ case "GPIOIN":
52
+ case "GPIOOUT":
53
+ case "ADC":
54
+ case "DAC":
55
+ case "PWM":
56
+ case "UART":
57
+ reqs[channel.id] = "hardware";
58
+ break;
59
+ case "MQTT":
60
+ reqs[channel.id] = "mqtt";
61
+ break;
62
+ case "CAMERA":
63
+ reqs[channel.id] = "camera";
64
+ break;
65
+ case "LOG":
66
+ // Resolves to the ambient engine logger no platform resource to bind.
67
+ break;
68
+ default:
69
+ return assertNeverChannel(channel.type);
70
+ }
71
+ }
72
+
73
+ // Every declared model needs a source binding, LLM and ML alike. The surface
74
+ // does not distinguish the family that split is a Stage-1 resolver concern.
75
+ // Mirrors the backend keying every declared model as "declaredModel".
76
+ for (const model of Object.values(workflow.models)) reqs[model.id] = "declaredModel";
77
+
78
+ // Catalog models: referenced by an Agent node but not declared. Keyed by model
79
+ // id (canonical, matching the backend surface) not collapsed to providers
80
+ // here. getReferencedCatalogModelIds already excludes declared ids, so this
81
+ // never overwrites a declaredModel entry.
82
+ for (const id of getReferencedCatalogModelIds(workflow)) reqs[id] = "catalogModel";
83
+
84
+ return reqs;
85
+ }
86
+
87
+ /**
88
+ * Model ids that Agent nodes reference but the workflow does not declare in
89
+ * `models` - the catalog models a deploy must bind a provider for.
90
+ */
91
+ export function getReferencedCatalogModelIds(workflow: Workflow): string[] {
92
+ const declared = new Set(Object.keys(workflow.models));
93
+ const catalogIds = new Set<string>();
94
+
95
+ for (const canvas of Object.values(workflow.canvases)) {
96
+ for (const node of canvas.nodes) {
97
+ if (node.type !== "Agent") continue;
98
+ const id = node.arguments.model;
99
+ if (id !== "" && !declared.has(id)) catalogIds.add(id);
100
+ }
101
+ }
102
+
103
+ return [...catalogIds];
104
+ }
@@ -101,7 +101,7 @@ describe("computeNodeDiagnostics — expression validation", () => {
101
101
  const exprDef = makeNodeDef({
102
102
  category: NodeCategory.Input,
103
103
  parameters: [
104
- { id: "val", label: "Value", description: "", type: "expression", expressionType: "int" },
104
+ { id: "val", label: "Value", description: "", type: "expression", expressionType: "int", default: makeExpression("0", "int") },
105
105
  ],
106
106
  });
107
107
 
@@ -114,8 +114,8 @@ describe("computeNodeDiagnostics — expression validation", () => {
114
114
  });
115
115
  const exprDiags = diagsOfCategory(diags, "invalid-expression");
116
116
  expect(exprDiags).toHaveLength(1);
117
- expect(exprDiags[0].paramId).toBe("val");
118
- expect(exprDiags[0].message).toContain("Invalid expression");
117
+ expect(exprDiags[0]?.paramId).toBe("val");
118
+ expect(exprDiags[0]?.message).toContain("Invalid expression");
119
119
  });
120
120
 
121
121
  it("accepts a well-formed expression matching declared type", () => {
@@ -138,7 +138,7 @@ describe("computeNodeDiagnostics — expression validation", () => {
138
138
  });
139
139
  const exprDiags = diagsOfCategory(diags, "invalid-expression");
140
140
  expect(exprDiags).toHaveLength(1);
141
- expect(exprDiags[0].message).toMatch(/Type mismatch|expected/i);
141
+ expect(exprDiags[0]?.message).toMatch(/Type mismatch|expected/i);
142
142
  });
143
143
 
144
144
  it("reactivity: changing a referenced variable's type invalidates a previously-valid expression", () => {
@@ -171,7 +171,24 @@ describe("computeNodeDiagnostics — expression validation", () => {
171
171
  expect(diagsOfCategory(before, "invalid-expression")).toHaveLength(0);
172
172
  const afterExprDiags = diagsOfCategory(after, "invalid-expression");
173
173
  expect(afterExprDiags).toHaveLength(1);
174
- expect(afterExprDiags[0].paramId).toBe("val");
174
+ expect(afterExprDiags[0]?.paramId).toBe("val");
175
+ });
176
+
177
+ it("flags an image variable used in a string expression", () => {
178
+ // A CameraCapture frame (image) interpolated into a text expression has no
179
+ // string form; it must be rejected, not silently emptied at runtime.
180
+ const stringDef = makeNodeDef({
181
+ category: NodeCategory.Input,
182
+ parameters: [{ id: "val", label: "Value", description: "", type: "expression", expressionType: "string", default: makeExpression("0", "string") }],
183
+ });
184
+ const diags = computeNodeDiagnostics({
185
+ ...baseOpts({
186
+ nodeDefinition: stringDef,
187
+ nodeData: makeNode("OnStartup", { val: makeExpression("frame is ${}", "string", [makeDeclaredRef("v1")]) }),
188
+ }),
189
+ availableVariables: makeAvailableVars([makeDeclaredVar({ uid: "v1", dataType: "image" })]),
190
+ });
191
+ expect(diagsOfCategory(diags, "invalid-expression")).toHaveLength(1);
175
192
  });
176
193
 
177
194
  it("skips validation on inactive expression parameters", () => {
@@ -185,6 +202,7 @@ describe("computeNodeDiagnostics — expression validation", () => {
185
202
  description: "",
186
203
  type: "expression",
187
204
  expressionType: "int",
205
+ default: makeExpression("0", "int"),
188
206
  activationRules: [{ type: "parameterIn", parameterId: "mode", values: ["on"] }],
189
207
  },
190
208
  ],
@@ -219,13 +237,13 @@ describe("computeNodeDiagnostics — missing required parameters", () => {
219
237
  });
220
238
  const missing = diagsOfCategory(diags, "missing-required-param");
221
239
  expect(missing).toHaveLength(1);
222
- expect(missing[0].paramId).toBe("name");
240
+ expect(missing[0]?.paramId).toBe("name");
223
241
  });
224
242
 
225
243
  it("flags required expression param that is empty", () => {
226
244
  const def = makeNodeDef({
227
245
  category: NodeCategory.Input,
228
- parameters: [{ id: "expr", label: "Expr", description: "", type: "expression", expressionType: "int" }],
246
+ parameters: [{ id: "expr", label: "Expr", description: "", type: "expression", expressionType: "int", default: makeExpression("0", "int") }],
229
247
  });
230
248
  const diags = computeNodeDiagnostics({
231
249
  ...baseOpts({
@@ -319,8 +337,8 @@ describe("computeNodeDiagnostics — variableSelect validation", () => {
319
337
  });
320
338
  const refDiags = diagsOfCategory(diags, "invalid-reference");
321
339
  expect(refDiags).toHaveLength(1);
322
- expect(refDiags[0].message).toMatch(/deleted variable/);
323
- expect(refDiags[0].paramId).toBe("ref");
340
+ expect(refDiags[0]?.message).toMatch(/deleted variable/);
341
+ expect(refDiags[0]?.paramId).toBe("ref");
324
342
  });
325
343
 
326
344
  it("accepts a valid variable reference", () => {
@@ -369,7 +387,7 @@ describe("computeNodeDiagnostics — channelSelect validation", () => {
369
387
  });
370
388
  const refDiags = diagsOfCategory(diags, "invalid-reference");
371
389
  expect(refDiags).toHaveLength(1);
372
- expect(refDiags[0].message).toMatch(/deleted channel/);
390
+ expect(refDiags[0]?.message).toMatch(/deleted channel/);
373
391
  });
374
392
 
375
393
  it("flags reference to a channel with incompatible type", () => {
@@ -382,7 +400,7 @@ describe("computeNodeDiagnostics — channelSelect validation", () => {
382
400
  });
383
401
  const refDiags = diagsOfCategory(diags, "invalid-reference");
384
402
  expect(refDiags).toHaveLength(1);
385
- expect(refDiags[0].message).toMatch(/not a compatible channel type/);
403
+ expect(refDiags[0]?.message).toMatch(/not a compatible channel type/);
386
404
  });
387
405
 
388
406
  it("accepts reference to a channel with compatible type", () => {
@@ -432,7 +450,7 @@ describe("computeNodeDiagnostics — memorySelect validation", () => {
432
450
  });
433
451
  const refDiags = diagsOfCategory(diags, "invalid-reference");
434
452
  expect(refDiags).toHaveLength(1);
435
- expect(refDiags[0].message).toMatch(/deleted memory/);
453
+ expect(refDiags[0]?.message).toMatch(/deleted memory/);
436
454
  });
437
455
 
438
456
  it("accepts a memory id present in the store with a compatible type", () => {
@@ -456,7 +474,7 @@ describe("computeNodeDiagnostics — memorySelect validation", () => {
456
474
  });
457
475
  const refDiags = diagsOfCategory(diags, "invalid-reference");
458
476
  expect(refDiags).toHaveLength(1);
459
- expect(refDiags[0].message).toMatch(/not a compatible memory type/);
477
+ expect(refDiags[0]?.message).toMatch(/not a compatible memory type/);
460
478
  });
461
479
 
462
480
  it("does not flag when the memory map is undefined (not provided)", () => {
@@ -500,14 +518,14 @@ describe("computeNodeDiagnostics — scalar output binding validation", () => {
500
518
  const diags = computeNodeDiagnostics(retrieverBase({ active: true, mode: "assign", target: { srcId: "", varId: "v1" } }));
501
519
  const assignDiags = diagsOfCategory(diags, "assign-type-mismatch");
502
520
  expect(assignDiags).toHaveLength(1);
503
- expect(assignDiags[0].message).toMatch(/no variable selected/);
521
+ expect(assignDiags[0]?.message).toMatch(/no variable selected/);
504
522
  });
505
523
 
506
524
  it("flags assign-mode binding targeting a deleted variable", () => {
507
525
  const diags = computeNodeDiagnostics(retrieverBase({ active: true, mode: "assign", target: makeDeclaredRef("ghost") }));
508
526
  const assignDiags = diagsOfCategory(diags, "assign-type-mismatch");
509
527
  expect(assignDiags).toHaveLength(1);
510
- expect(assignDiags[0].message).toMatch(/deleted variable/);
528
+ expect(assignDiags[0]?.message).toMatch(/deleted variable/);
511
529
  });
512
530
 
513
531
  it("flags assign-mode binding with type mismatch", () => {
@@ -519,7 +537,7 @@ describe("computeNodeDiagnostics — scalar output binding validation", () => {
519
537
  );
520
538
  const assignDiags = diagsOfCategory(diags, "assign-type-mismatch");
521
539
  expect(assignDiags).toHaveLength(1);
522
- expect(assignDiags[0].message).toMatch(/cannot assign/);
540
+ expect(assignDiags[0]?.message).toMatch(/cannot assign/);
523
541
  });
524
542
 
525
543
  it("accepts assign-mode binding with matching types", () => {
@@ -579,8 +597,8 @@ describe("computeNodeDiagnostics — list-output entry validation", () => {
579
597
  );
580
598
  const entry = diagsOfCategory(diags, "assign-type-mismatch");
581
599
  expect(entry).toHaveLength(1);
582
- expect(entry[0].outputId).toBe("outputDeclarations[0]");
583
- expect(entry[0].message).toMatch(/entry #1 has no variable selected/);
600
+ expect(entry[0]?.outputId).toBe("outputDeclarations[0]");
601
+ expect(entry[0]?.message).toMatch(/entry #1 has no variable selected/);
584
602
  });
585
603
 
586
604
  it("flags list entry targeting a deleted variable", () => {
@@ -589,7 +607,7 @@ describe("computeNodeDiagnostics — list-output entry validation", () => {
589
607
  );
590
608
  const entry = diagsOfCategory(diags, "assign-type-mismatch");
591
609
  expect(entry).toHaveLength(1);
592
- expect(entry[0].message).toMatch(/deleted variable/);
610
+ expect(entry[0]?.message).toMatch(/deleted variable/);
593
611
  });
594
612
 
595
613
  it("flags list entry with type mismatch", () => {
@@ -601,7 +619,7 @@ describe("computeNodeDiagnostics — list-output entry validation", () => {
601
619
  );
602
620
  const entry = diagsOfCategory(diags, "assign-type-mismatch");
603
621
  expect(entry).toHaveLength(1);
604
- expect(entry[0].message).toMatch(/cannot assign/);
622
+ expect(entry[0]?.message).toMatch(/cannot assign/);
605
623
  });
606
624
 
607
625
  it("accepts a valid list entry", () => {
@@ -628,7 +646,7 @@ describe("computeNodeDiagnostics — list-output entry validation", () => {
628
646
  );
629
647
  const entry = diagsOfCategory(diags, "assign-type-mismatch");
630
648
  expect(entry).toHaveLength(1);
631
- expect(entry[0].outputId).toBe("outputDeclarations[1]");
649
+ expect(entry[0]?.outputId).toBe("outputDeclarations[1]");
632
650
  });
633
651
 
634
652
  it("flags duplicate names across the list (both modes)", () => {
@@ -654,7 +672,7 @@ describe("computeNodeDiagnostics — list-output entry validation", () => {
654
672
  );
655
673
  const missing = diagsOfCategory(diags, "missing-required-param").filter((d) => d.outputId);
656
674
  expect(missing).toHaveLength(1);
657
- expect(missing[0].message).toMatch(/has no name/);
675
+ expect(missing[0]?.message).toMatch(/has no name/);
658
676
  });
659
677
  });
660
678
 
@@ -187,8 +187,13 @@ function inferBinaryType(node: jsep.BinaryExpression, ctx: TypeContext, errors:
187
187
  // If either operand failed to type, propagate null
188
188
  if (!leftType || !rightType) return null;
189
189
 
190
- // String concatenation: string + any → string
190
+ // String concatenation: string + any → string. An image has no text form,
191
+ // so it cannot be concatenated even though the other operand is a string.
191
192
  if (op === "+" && (leftType === "string" || rightType === "string")) {
193
+ if (leftType === "image" || rightType === "image") {
194
+ errors.push("An image value cannot be used in a string expression");
195
+ return null;
196
+ }
192
197
  return "string";
193
198
  }
194
199
 
@@ -338,6 +343,9 @@ function isNumeric(type: DataType | null): boolean {
338
343
  function isCompatible(from: DataType, to: DataType): boolean {
339
344
  if (from === to) return true;
340
345
  if (isNumeric(from) && isNumeric(to)) return true;
346
+ // An image is opaque binary with no text form; it is only compatible with an
347
+ // image sink (handled by from === to above), never the string catch-all.
348
+ if (from === "image") return false;
341
349
  if (to === "string") return true;
342
350
  return false;
343
351
  }
@@ -0,0 +1,16 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (c) 2026 ForestHub.
3
+
4
+ import type { ModelDefinition } from "./ModelDefinition";
5
+
6
+ /**
7
+ * A declared machine-learning model, served by an inference component, that nodes
8
+ * can reference. The component selects the model by id and derives its behaviour
9
+ * from the mounted model bundle, so nothing needs to be configured here.
10
+ */
11
+ export const MLModelDefinition: ModelDefinition = {
12
+ type: "MLModel",
13
+ label: "ML Model",
14
+ description: "A machine-learning model served by an inference component (e.g. object detection)",
15
+ parameters: [],
16
+ };
@@ -20,9 +20,9 @@ import type { Schemas } from "../api";
20
20
  /** A capability a model supports (chat, embedding, vision, ...). From the api. */
21
21
  export type ModelCapability = Schemas["ModelCapability"];
22
22
 
23
- export type ModelType = "LLMModel";
23
+ export type ModelType = "LLMModel" | "MLModel";
24
24
 
25
- export const ALL_MODEL_TYPES: ModelType[] = ["LLMModel"];
25
+ export const ALL_MODEL_TYPES: ModelType[] = ["LLMModel", "MLModel"];
26
26
 
27
27
  export interface Model {
28
28
  id: string;
@@ -39,4 +39,8 @@ export interface ModelInfo {
39
39
  id: string;
40
40
  label: string;
41
41
  capabilities: ModelCapability[];
42
+ // Catalog provider that serves this model (e.g. "anthropic"). The deploy
43
+ // resolver reads it to emit one ExternalResources provider entry per distinct
44
+ // provider a workflow's Agent nodes reference. Mirrors llmproxy ModelInfo.provider.
45
+ provider: string;
42
46
  }
@@ -4,6 +4,7 @@
4
4
  import type { ModelType } from "./Model";
5
5
  import type { ModelDefinition } from "./ModelDefinition";
6
6
  import { LLMModelDefinition } from "./LLMModelDefinition";
7
+ import { MLModelDefinition } from "./MLModelDefinition";
7
8
 
8
9
  /**
9
10
  * Central registry for declared (custom) model variant definitions (one per
@@ -16,6 +17,7 @@ class ModelDefinitionRegistry {
16
17
  initialize() {
17
18
  if (this.initialized) return;
18
19
  this.register(LLMModelDefinition);
20
+ this.register(MLModelDefinition);
19
21
  this.initialized = true;
20
22
  }
21
23
 
@@ -5,6 +5,7 @@ export type { ModelType, Model, ModelCapability, ModelInfo } from "./Model";
5
5
  export { ALL_MODEL_TYPES } from "./Model";
6
6
  export type { ModelDefinition } from "./ModelDefinition";
7
7
  export { LLMModelDefinition } from "./LLMModelDefinition";
8
+ export { MLModelDefinition } from "./MLModelDefinition";
8
9
  export { ModelRegistry } from "./ModelRegistry";
9
10
  export { serialize, deserialize } from "./serialization";
10
11
  export type { ApiModel } from "./serialization";
@@ -17,6 +17,8 @@ export function serialize(model: Model): ApiModel {
17
17
  label,
18
18
  capabilities: (args.capabilities as ModelCapability[]) ?? ["chat"],
19
19
  };
20
+ case "MLModel":
21
+ return { type, id, label };
20
22
  }
21
23
  }
22
24
 
@@ -28,6 +30,9 @@ export function deserialize(api: ApiModel): Model {
28
30
  case "LLMModel":
29
31
  args.capabilities = api.capabilities;
30
32
  break;
33
+ case "MLModel":
34
+ // No type-specific arguments; the component selects the model by id.
35
+ break;
31
36
  }
32
37
  return { id, label, type, arguments: args };
33
38
  }
@@ -48,8 +48,17 @@ export interface WebFetchNode extends NodeBase {
48
48
  };
49
49
  }
50
50
 
51
- export type InputNodeType = "ReadPin" | "SerialRead" | "Retriever" | "WebFetch";
52
- export type InputNode = ReadPinNode | SerialReadNode | RetrieverNode | WebFetchNode;
51
+ // CameraCapture - grabs a single still image from a camera channel
52
+ export interface CameraCaptureNode extends NodeBase {
53
+ type: "CameraCapture";
54
+ arguments: {
55
+ cameraReference: string | undefined;
56
+ output: OutputBinding;
57
+ };
58
+ }
59
+
60
+ export type InputNodeType = "ReadPin" | "SerialRead" | "Retriever" | "WebFetch" | "CameraCapture";
61
+ export type InputNode = ReadPinNode | SerialReadNode | RetrieverNode | WebFetchNode | CameraCaptureNode;
53
62
 
54
63
  // Node Definitions
55
64
 
@@ -186,3 +195,20 @@ export const SerialReadNodeDefinition: NodeDefinition = {
186
195
  },
187
196
  ],
188
197
  };
198
+
199
+ export const CameraCaptureNodeDefinition: NodeDefinition = {
200
+ type: "CameraCapture",
201
+ label: "Camera Capture",
202
+ category: NodeCategory.Input,
203
+ description: "Capture a still image from a camera",
204
+ outputs: [{ id: "output", label: "Image", type: "static", dataType: "image" }],
205
+ parameters: [
206
+ {
207
+ id: "cameraReference",
208
+ label: "Camera",
209
+ description: "Camera to capture from",
210
+ type: "channelSelect",
211
+ channelType: ["CAMERA"],
212
+ },
213
+ ],
214
+ };