@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.
- package/LICENSE +202 -202
- package/NOTICE +14 -14
- package/README.md +63 -63
- package/dist/api/deployment.d.ts +67 -0
- package/dist/api/deployment.d.ts.map +1 -0
- package/dist/api/deployment.js +6 -0
- package/dist/api/deployment.js.map +1 -0
- package/dist/api/engine.d.ts +744 -0
- package/dist/api/engine.d.ts.map +1 -0
- package/dist/api/engine.js +6 -0
- package/dist/api/engine.js.map +1 -0
- package/dist/api/index.d.ts +4 -0
- package/dist/api/index.d.ts.map +1 -1
- package/dist/api/workflow.d.ts +17 -1
- 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 +22 -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/index.d.ts +5 -1
- package/dist/deploy/index.d.ts.map +1 -1
- package/dist/deploy/index.js +2 -1
- package/dist/deploy/index.js.map +1 -1
- package/dist/deploy/inputs.d.ts +26 -0
- package/dist/deploy/inputs.d.ts.map +1 -0
- package/dist/deploy/inputs.js +12 -0
- package/dist/deploy/inputs.js.map +1 -0
- package/dist/deploy/requirements.d.ts +24 -0
- package/dist/deploy/requirements.d.ts.map +1 -1
- package/dist/deploy/requirements.js +61 -0
- package/dist/deploy/requirements.js.map +1 -1
- package/dist/deploy/spec.d.ts +32 -0
- package/dist/deploy/spec.d.ts.map +1 -0
- package/dist/deploy/spec.js +343 -0
- package/dist/deploy/spec.js.map +1 -0
- package/dist/node/OutputNode.js +2 -2
- package/dist/node/OutputNode.js.map +1 -1
- package/package.json +2 -2
- package/src/api/deployment.ts +67 -0
- package/src/api/engine.ts +744 -0
- package/src/api/index.ts +26 -11
- package/src/api/workflow.ts +17 -1
- package/src/channel/Channel.ts +11 -11
- package/src/channel/ChannelDefinition.ts +98 -76
- package/src/channel/index.ts +6 -6
- package/src/channel/serialization.ts +74 -68
- package/src/deploy/index.ts +14 -1
- package/src/deploy/inputs.ts +41 -0
- package/src/deploy/requirements.test.ts +61 -61
- package/src/deploy/requirements.ts +156 -41
- package/src/deploy/spec.test.ts +226 -0
- package/src/deploy/spec.ts +379 -0
- package/src/diagnostics/__fixtures__/diagnosticFixtures.ts +158 -158
- package/src/diagnostics/diagnostics.test.ts +878 -878
- package/src/diagnostics/diagnostics.ts +936 -936
- package/src/diagnostics/index.ts +11 -11
- package/src/edge/Edge.ts +23 -23
- package/src/edge/EdgeDefinition.ts +45 -45
- package/src/edge/EdgeType.ts +19 -19
- package/src/edge/index.ts +8 -8
- package/src/edge/serialization.ts +83 -83
- package/src/expression/index.ts +4 -4
- package/src/expression/parser.ts +362 -362
- package/src/expression/types.ts +30 -30
- package/src/function/FunctionDeclaration.ts +54 -54
- package/src/function/index.ts +3 -3
- package/src/function/serialization.ts +40 -40
- package/src/globals.d.ts +9 -9
- package/src/id/index.ts +8 -8
- package/src/index.ts +22 -22
- package/src/memory/Memory.ts +15 -15
- package/src/memory/MemoryDefinition.ts +16 -16
- package/src/memory/MemoryFileDefinition.ts +37 -37
- package/src/memory/MemoryRegistry.ts +35 -35
- package/src/memory/VectorDatabaseDefinition.ts +21 -21
- package/src/memory/index.ts +8 -8
- package/src/memory/serialization.ts +47 -47
- package/src/migration/index.ts +4 -4
- package/src/migration/migrate.test.ts +44 -44
- package/src/migration/migrate.ts +58 -58
- package/src/migration/migrations.ts +24 -24
- package/src/migration/version.ts +9 -9
- package/src/model/LLMModelDefinition.ts +12 -12
- package/src/model/Model.ts +39 -39
- package/src/model/ModelDefinition.ts +15 -15
- package/src/model/ModelRegistry.ts +33 -33
- package/src/model/index.ts +7 -7
- package/src/model/serialization.ts +30 -30
- package/src/node/AgentNode.ts +82 -82
- package/src/node/DataNode.ts +41 -41
- package/src/node/FunctionNode.ts +76 -76
- package/src/node/InputNode.ts +185 -185
- package/src/node/LogicNode.ts +33 -33
- package/src/node/MqttNode.ts +127 -127
- package/src/node/Node.ts +61 -61
- package/src/node/NodeDefinition.ts +37 -37
- package/src/node/NodeRegistry.ts +85 -85
- package/src/node/OutputNode.ts +87 -87
- package/src/node/ToolNode.ts +32 -32
- package/src/node/TriggerNode.ts +272 -272
- package/src/node/constants.ts +16 -16
- package/src/node/index.ts +26 -26
- package/src/node/methods.ts +278 -278
- package/src/node/serialization.ts +544 -544
- package/src/parameter/OutputParameter.ts +68 -68
- package/src/parameter/Parameter.ts +243 -243
- package/src/parameter/index.ts +33 -33
- package/src/variable/Variable.ts +10 -10
- package/src/variable/index.ts +16 -16
- package/src/variable/operations.ts +106 -106
- package/src/workflow/Workflow.ts +41 -41
- package/src/workflow/index.ts +3 -3
- package/src/workflow/serialization.test.ts +240 -240
- 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
|
+
});
|