@foresthubai/workflow-core 0.4.6 → 0.4.8

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 (114) 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 +64 -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/AINode.d.ts +31 -0
  64. package/dist/node/AINode.d.ts.map +1 -0
  65. package/dist/node/AINode.js +85 -0
  66. package/dist/node/AINode.js.map +1 -0
  67. package/dist/node/InputNode.d.ts +10 -2
  68. package/dist/node/InputNode.d.ts.map +1 -1
  69. package/dist/node/InputNode.js +16 -0
  70. package/dist/node/InputNode.js.map +1 -1
  71. package/dist/node/MLInferenceNode.d.ts +15 -0
  72. package/dist/node/MLInferenceNode.d.ts.map +1 -0
  73. package/dist/node/MLInferenceNode.js +26 -0
  74. package/dist/node/MLInferenceNode.js.map +1 -0
  75. package/dist/node/Node.d.ts +3 -3
  76. package/dist/node/Node.d.ts.map +1 -1
  77. package/dist/node/NodeRegistry.d.ts.map +1 -1
  78. package/dist/node/NodeRegistry.js +4 -2
  79. package/dist/node/NodeRegistry.js.map +1 -1
  80. package/dist/node/methods.d.ts.map +1 -1
  81. package/dist/node/methods.js +6 -0
  82. package/dist/node/methods.js.map +1 -1
  83. package/dist/node/serialization.d.ts.map +1 -1
  84. package/dist/node/serialization.js +42 -0
  85. package/dist/node/serialization.js.map +1 -1
  86. package/package.json +2 -2
  87. package/src/api/index.ts +0 -15
  88. package/src/api/workflow.ts +61 -4
  89. package/src/channel/Channel.ts +2 -2
  90. package/src/channel/ChannelDefinition.ts +16 -0
  91. package/src/channel/serialization.ts +6 -0
  92. package/src/deploy/constants.ts +30 -0
  93. package/src/deploy/index.ts +14 -17
  94. package/src/deploy/requirements.test.ts +165 -64
  95. package/src/deploy/requirements.ts +122 -159
  96. package/src/diagnostics/diagnostics.test.ts +41 -23
  97. package/src/expression/parser.ts +9 -1
  98. package/src/model/MLModelDefinition.ts +16 -0
  99. package/src/model/Model.ts +6 -2
  100. package/src/model/ModelRegistry.ts +2 -0
  101. package/src/model/index.ts +1 -0
  102. package/src/model/serialization.ts +5 -0
  103. package/src/node/{AgentNode.ts → AINode.ts} +124 -85
  104. package/src/node/InputNode.ts +28 -2
  105. package/src/node/Node.ts +3 -3
  106. package/src/node/NodeRegistry.ts +4 -2
  107. package/src/node/methods.ts +6 -0
  108. package/src/node/serialization.ts +44 -2
  109. package/src/workflow/serialization.test.ts +53 -0
  110. package/src/api/deployment.ts +0 -74
  111. package/src/api/engine.ts +0 -744
  112. package/src/deploy/inputs.ts +0 -44
  113. package/src/deploy/spec.test.ts +0 -227
  114. package/src/deploy/spec.ts +0 -397
@@ -1,64 +1,165 @@
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("splits declared models: LLMModel -> 'declaredModel', MLModel -> 'mlInference'", () => {
98
+ const models = modelsById([llm("local-llm"), ml("yolo")]);
99
+ expect(workflowBindingRequirements(workflow({ models }))).toEqual({ "local-llm": "declaredModel", yolo: "mlInference" });
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"), ml("yolo")]),
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
+ yolo: "mlInference",
131
+ "claude-sonnet": "catalogModel",
132
+ };
133
+ expect(workflowBindingRequirements(wf)).toEqual(expected);
134
+ });
135
+ });
136
+
137
+ describe("getReferencedCatalogModelIds", () => {
138
+ it("returns catalog ids (referenced but not declared), excluding declared customs", () => {
139
+ const wf = workflow({
140
+ canvases: { [MAIN_CANVAS_ID]: canvas([agent("n1", "claude-opus-4-7"), agent("n2", "custom-llm")]) },
141
+ models: modelsById([llm("custom-llm")]),
142
+ });
143
+ expect(getReferencedCatalogModelIds(wf)).toEqual(["claude-opus-4-7"]);
144
+ });
145
+
146
+ it("ignores unset model references", () => {
147
+ const wf = workflow({ nodes: [agent("n1", "")] });
148
+ expect(getReferencedCatalogModelIds(wf)).toEqual([]);
149
+ });
150
+
151
+ it("dedupes across nodes and walks every canvas (main + function bodies)", () => {
152
+ const wf = workflow({
153
+ canvases: {
154
+ [MAIN_CANVAS_ID]: canvas([agent("n1", "claude-opus-4-7"), agent("n2", "claude-opus-4-7")]),
155
+ fnBody: canvas([agent("n3", "gemini-2")]),
156
+ },
157
+ });
158
+ expect(getReferencedCatalogModelIds(wf).sort()).toEqual(["claude-opus-4-7", "gemini-2"]);
159
+ });
160
+
161
+ it("returns nothing when every referenced model is declared", () => {
162
+ const wf = workflow({ nodes: [agent("n1", "custom-llm")], models: modelsById([llm("custom-llm")]) });
163
+ expect(getReferencedCatalogModelIds(wf)).toEqual([]);
164
+ });
165
+ });
@@ -1,159 +1,122 @@
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"/"camera"/"declaredModel"/"mlInference"/"catalogModel"/"rag").
18
+ // A value that drifts here silently disagrees with the backend about what a
19
+ // workflow needs bound.
20
+ //
21
+ // One deliberate asymmetry with the backend today: "rag" is absent. OSS is behind
22
+ // on retrieval it does not yet extract a VectorDatabase memory resource as a
23
+ // requirement (the backend emits "rag"). A standalone engine has no retriever, so
24
+ // such a workflow is refused at deploy; add "rag" here when the OSS engine gains a
25
+ // retrieval backing.
26
+ export type BindingKind = "hardware" | "mqtt" | "camera" | "declaredModel" | "mlInference" | "catalogModel";
27
+
28
+ // Drift sentinel: a new ChannelType breaks compilation here until it is
29
+ // classified in workflowBindingRequirements' channel switch.
30
+ function assertNeverChannel(t: never): never {
31
+ throw new Error(`unhandled channel type: ${String(t)}`);
32
+ }
33
+
34
+ // Drift sentinel: a new ModelType breaks compilation here until it is classified
35
+ // in workflowBindingRequirements' model switch.
36
+ function assertNeverModel(t: never): never {
37
+ throw new Error(`unhandled model type: ${String(t)}`);
38
+ }
39
+
40
+ // workflowBindingRequirements returns the resources a deploy must bind, keyed by
41
+ // workflow logical id — the binding surface. The TS twin of the backend's
42
+ // deploy.WorkflowBindingRequirements: both must derive the SAME id->kind map from
43
+ // the same workflow, or an OSS deploy and a backend deploy disagree about what
44
+ // needs binding.
45
+ //
46
+ // The surface is neither the declared set nor a subset of it:
47
+ // - LOG channels and MemoryFile bind nothing declared, but out.
48
+ // - referenced catalog models bind a provider yet are node references, not
49
+ // declarations — undeclared, but in.
50
+ //
51
+ // Pure reads workflow content alone: no operator input, no model catalog.
52
+ export function workflowBindingRequirements(workflow: Workflow): Record<string, BindingKind> {
53
+ const reqs: Record<string, BindingKind> = {};
54
+
55
+ for (const channel of Object.values(workflow.channels)) {
56
+ switch (channel.type) {
57
+ case "GPIOIN":
58
+ case "GPIOOUT":
59
+ case "ADC":
60
+ case "DAC":
61
+ case "PWM":
62
+ case "UART":
63
+ reqs[channel.id] = "hardware";
64
+ break;
65
+ case "MQTT":
66
+ reqs[channel.id] = "mqtt";
67
+ break;
68
+ case "CAMERA":
69
+ reqs[channel.id] = "camera";
70
+ break;
71
+ case "LOG":
72
+ // Resolves to the ambient engine logger no platform resource to bind.
73
+ break;
74
+ default:
75
+ return assertNeverChannel(channel.type);
76
+ }
77
+ }
78
+
79
+ // Every declared model needs a source binding, but of a different kind by family:
80
+ // an LLMModel binds a model source ("declaredModel"); an MLModel is served by an
81
+ // ml-inference component ("mlInference"). Mirrors the backend's workflowModelIDs
82
+ // split both sides must agree on which kind each declared model gets.
83
+ for (const model of Object.values(workflow.models)) {
84
+ switch (model.type) {
85
+ case "LLMModel":
86
+ reqs[model.id] = "declaredModel";
87
+ break;
88
+ case "MLModel":
89
+ reqs[model.id] = "mlInference";
90
+ break;
91
+ default:
92
+ return assertNeverModel(model.type);
93
+ }
94
+ }
95
+
96
+ // Catalog models: referenced by an Agent node but not declared. Keyed by model
97
+ // id (canonical, matching the backend surface) not collapsed to providers
98
+ // here. getReferencedCatalogModelIds already excludes declared ids, so this
99
+ // never overwrites a declaredModel entry.
100
+ for (const id of getReferencedCatalogModelIds(workflow)) reqs[id] = "catalogModel";
101
+
102
+ return reqs;
103
+ }
104
+
105
+ /**
106
+ * Model ids that Agent nodes reference but the workflow does not declare in
107
+ * `models` - the catalog models a deploy must bind a provider for.
108
+ */
109
+ export function getReferencedCatalogModelIds(workflow: Workflow): string[] {
110
+ const declared = new Set(Object.keys(workflow.models));
111
+ const catalogIds = new Set<string>();
112
+
113
+ for (const canvas of Object.values(workflow.canvases)) {
114
+ for (const node of canvas.nodes) {
115
+ if (node.type !== "Agent") continue;
116
+ const id = node.arguments.model;
117
+ if (id !== "" && !declared.has(id)) catalogIds.add(id);
118
+ }
119
+ }
120
+
121
+ return [...catalogIds];
122
+ }