@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.
- package/dist/api/camera.d.ts +254 -0
- package/dist/api/camera.d.ts.map +1 -0
- package/dist/api/camera.js +6 -0
- package/dist/api/camera.js.map +1 -0
- package/dist/api/engine.d.ts +89 -10
- package/dist/api/engine.d.ts.map +1 -1
- package/dist/api/index.d.ts +0 -4
- package/dist/api/index.d.ts.map +1 -1
- package/dist/api/workflow.d.ts +61 -4
- package/dist/api/workflow.d.ts.map +1 -1
- package/dist/channel/Channel.d.ts +1 -1
- package/dist/channel/Channel.d.ts.map +1 -1
- package/dist/channel/Channel.js +1 -1
- package/dist/channel/Channel.js.map +1 -1
- package/dist/channel/ChannelDefinition.d.ts.map +1 -1
- package/dist/channel/ChannelDefinition.js +16 -0
- package/dist/channel/ChannelDefinition.js.map +1 -1
- package/dist/channel/serialization.d.ts.map +1 -1
- package/dist/channel/serialization.js +6 -0
- package/dist/channel/serialization.js.map +1 -1
- package/dist/deploy/bindingRequirements.d.ts +4 -0
- package/dist/deploy/bindingRequirements.d.ts.map +1 -0
- package/dist/deploy/bindingRequirements.js +59 -0
- package/dist/deploy/bindingRequirements.js.map +1 -0
- package/dist/deploy/constants.d.ts +8 -0
- package/dist/deploy/constants.d.ts.map +1 -0
- package/dist/deploy/constants.js +26 -0
- package/dist/deploy/constants.js.map +1 -0
- package/dist/deploy/index.d.ts +3 -5
- package/dist/deploy/index.d.ts.map +1 -1
- package/dist/deploy/index.js +10 -2
- package/dist/deploy/index.js.map +1 -1
- package/dist/deploy/inputs.d.ts +30 -3
- package/dist/deploy/inputs.d.ts.map +1 -1
- package/dist/deploy/requirements.d.ts +4 -36
- package/dist/deploy/requirements.d.ts.map +1 -1
- package/dist/deploy/requirements.js +48 -76
- package/dist/deploy/requirements.js.map +1 -1
- package/dist/deploy/spec.d.ts +10 -4
- package/dist/deploy/spec.d.ts.map +1 -1
- package/dist/deploy/spec.js +233 -63
- package/dist/deploy/spec.js.map +1 -1
- package/dist/expression/parser.js +10 -1
- package/dist/expression/parser.js.map +1 -1
- package/dist/model/MLModelDefinition.d.ts +8 -0
- package/dist/model/MLModelDefinition.d.ts.map +1 -0
- package/dist/model/MLModelDefinition.js +14 -0
- package/dist/model/MLModelDefinition.js.map +1 -0
- package/dist/model/Model.d.ts +2 -1
- package/dist/model/Model.d.ts.map +1 -1
- package/dist/model/Model.js +1 -1
- package/dist/model/Model.js.map +1 -1
- package/dist/model/ModelRegistry.d.ts.map +1 -1
- package/dist/model/ModelRegistry.js +2 -0
- package/dist/model/ModelRegistry.js.map +1 -1
- package/dist/model/index.d.ts +1 -0
- package/dist/model/index.d.ts.map +1 -1
- package/dist/model/index.js +1 -0
- package/dist/model/index.js.map +1 -1
- package/dist/model/serialization.d.ts.map +1 -1
- package/dist/model/serialization.js +5 -0
- package/dist/model/serialization.js.map +1 -1
- package/dist/node/InputNode.d.ts +10 -2
- package/dist/node/InputNode.d.ts.map +1 -1
- package/dist/node/InputNode.js +16 -0
- package/dist/node/InputNode.js.map +1 -1
- package/dist/node/MLInferenceNode.d.ts +15 -0
- package/dist/node/MLInferenceNode.d.ts.map +1 -0
- package/dist/node/MLInferenceNode.js +26 -0
- package/dist/node/MLInferenceNode.js.map +1 -0
- package/dist/node/Node.d.ts +3 -2
- package/dist/node/Node.d.ts.map +1 -1
- package/dist/node/NodeRegistry.d.ts.map +1 -1
- package/dist/node/NodeRegistry.js +4 -1
- package/dist/node/NodeRegistry.js.map +1 -1
- package/dist/node/methods.d.ts.map +1 -1
- package/dist/node/methods.js +6 -0
- package/dist/node/methods.js.map +1 -1
- package/dist/node/serialization.d.ts.map +1 -1
- package/dist/node/serialization.js +42 -0
- package/dist/node/serialization.js.map +1 -1
- package/package.json +2 -2
- package/src/api/index.ts +0 -15
- package/src/api/workflow.ts +61 -4
- package/src/channel/Channel.ts +2 -2
- package/src/channel/ChannelDefinition.ts +16 -0
- package/src/channel/serialization.ts +6 -0
- package/src/deploy/constants.ts +30 -0
- package/src/deploy/index.ts +14 -17
- package/src/deploy/requirements.test.ts +164 -64
- package/src/deploy/requirements.ts +104 -159
- package/src/diagnostics/diagnostics.test.ts +40 -22
- package/src/expression/parser.ts +9 -1
- package/src/model/MLModelDefinition.ts +16 -0
- package/src/model/Model.ts +6 -2
- package/src/model/ModelRegistry.ts +2 -0
- package/src/model/index.ts +1 -0
- package/src/model/serialization.ts +5 -0
- package/src/node/InputNode.ts +28 -2
- package/src/node/MLInferenceNode.ts +46 -0
- package/src/node/Node.ts +3 -1
- package/src/node/NodeRegistry.ts +4 -1
- package/src/node/methods.ts +6 -0
- package/src/node/serialization.ts +44 -2
- package/src/workflow/serialization.test.ts +53 -0
- package/src/api/deployment.ts +0 -74
- package/src/api/engine.ts +0 -744
- package/src/deploy/inputs.ts +0 -44
- package/src/deploy/spec.test.ts +0 -227
- 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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
//
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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]
|
|
118
|
-
expect(exprDiags[0]
|
|
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]
|
|
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]
|
|
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]
|
|
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]
|
|
323
|
-
expect(refDiags[0]
|
|
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]
|
|
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]
|
|
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]
|
|
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]
|
|
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]
|
|
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]
|
|
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]
|
|
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]
|
|
583
|
-
expect(entry[0]
|
|
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]
|
|
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]
|
|
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]
|
|
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]
|
|
675
|
+
expect(missing[0]?.message).toMatch(/has no name/);
|
|
658
676
|
});
|
|
659
677
|
});
|
|
660
678
|
|
package/src/expression/parser.ts
CHANGED
|
@@ -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
|
+
};
|
package/src/model/Model.ts
CHANGED
|
@@ -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
|
|
package/src/model/index.ts
CHANGED
|
@@ -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
|
}
|
package/src/node/InputNode.ts
CHANGED
|
@@ -48,8 +48,17 @@ export interface WebFetchNode extends NodeBase {
|
|
|
48
48
|
};
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
|
|
52
|
-
export
|
|
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
|
+
};
|