@foresthubai/workflow-core 0.4.3 → 0.4.4

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