@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.
Files changed (110) hide show
  1. package/dist/api/camera.d.ts +254 -0
  2. package/dist/api/camera.d.ts.map +1 -0
  3. package/dist/api/camera.js +6 -0
  4. package/dist/api/camera.js.map +1 -0
  5. package/dist/api/engine.d.ts +89 -10
  6. package/dist/api/engine.d.ts.map +1 -1
  7. package/dist/api/index.d.ts +0 -4
  8. package/dist/api/index.d.ts.map +1 -1
  9. package/dist/api/workflow.d.ts +61 -4
  10. package/dist/api/workflow.d.ts.map +1 -1
  11. package/dist/channel/Channel.d.ts +1 -1
  12. package/dist/channel/Channel.d.ts.map +1 -1
  13. package/dist/channel/Channel.js +1 -1
  14. package/dist/channel/Channel.js.map +1 -1
  15. package/dist/channel/ChannelDefinition.d.ts.map +1 -1
  16. package/dist/channel/ChannelDefinition.js +16 -0
  17. package/dist/channel/ChannelDefinition.js.map +1 -1
  18. package/dist/channel/serialization.d.ts.map +1 -1
  19. package/dist/channel/serialization.js +6 -0
  20. package/dist/channel/serialization.js.map +1 -1
  21. package/dist/deploy/bindingRequirements.d.ts +4 -0
  22. package/dist/deploy/bindingRequirements.d.ts.map +1 -0
  23. package/dist/deploy/bindingRequirements.js +59 -0
  24. package/dist/deploy/bindingRequirements.js.map +1 -0
  25. package/dist/deploy/constants.d.ts +8 -0
  26. package/dist/deploy/constants.d.ts.map +1 -0
  27. package/dist/deploy/constants.js +26 -0
  28. package/dist/deploy/constants.js.map +1 -0
  29. package/dist/deploy/index.d.ts +3 -5
  30. package/dist/deploy/index.d.ts.map +1 -1
  31. package/dist/deploy/index.js +10 -2
  32. package/dist/deploy/index.js.map +1 -1
  33. package/dist/deploy/inputs.d.ts +30 -3
  34. package/dist/deploy/inputs.d.ts.map +1 -1
  35. package/dist/deploy/requirements.d.ts +4 -36
  36. package/dist/deploy/requirements.d.ts.map +1 -1
  37. package/dist/deploy/requirements.js +48 -76
  38. package/dist/deploy/requirements.js.map +1 -1
  39. package/dist/deploy/spec.d.ts +10 -4
  40. package/dist/deploy/spec.d.ts.map +1 -1
  41. package/dist/deploy/spec.js +233 -63
  42. package/dist/deploy/spec.js.map +1 -1
  43. package/dist/expression/parser.js +10 -1
  44. package/dist/expression/parser.js.map +1 -1
  45. package/dist/model/MLModelDefinition.d.ts +8 -0
  46. package/dist/model/MLModelDefinition.d.ts.map +1 -0
  47. package/dist/model/MLModelDefinition.js +14 -0
  48. package/dist/model/MLModelDefinition.js.map +1 -0
  49. package/dist/model/Model.d.ts +2 -1
  50. package/dist/model/Model.d.ts.map +1 -1
  51. package/dist/model/Model.js +1 -1
  52. package/dist/model/Model.js.map +1 -1
  53. package/dist/model/ModelRegistry.d.ts.map +1 -1
  54. package/dist/model/ModelRegistry.js +2 -0
  55. package/dist/model/ModelRegistry.js.map +1 -1
  56. package/dist/model/index.d.ts +1 -0
  57. package/dist/model/index.d.ts.map +1 -1
  58. package/dist/model/index.js +1 -0
  59. package/dist/model/index.js.map +1 -1
  60. package/dist/model/serialization.d.ts.map +1 -1
  61. package/dist/model/serialization.js +5 -0
  62. package/dist/model/serialization.js.map +1 -1
  63. package/dist/node/InputNode.d.ts +10 -2
  64. package/dist/node/InputNode.d.ts.map +1 -1
  65. package/dist/node/InputNode.js +16 -0
  66. package/dist/node/InputNode.js.map +1 -1
  67. package/dist/node/MLInferenceNode.d.ts +15 -0
  68. package/dist/node/MLInferenceNode.d.ts.map +1 -0
  69. package/dist/node/MLInferenceNode.js +26 -0
  70. package/dist/node/MLInferenceNode.js.map +1 -0
  71. package/dist/node/Node.d.ts +3 -2
  72. package/dist/node/Node.d.ts.map +1 -1
  73. package/dist/node/NodeRegistry.d.ts.map +1 -1
  74. package/dist/node/NodeRegistry.js +4 -1
  75. package/dist/node/NodeRegistry.js.map +1 -1
  76. package/dist/node/methods.d.ts.map +1 -1
  77. package/dist/node/methods.js +6 -0
  78. package/dist/node/methods.js.map +1 -1
  79. package/dist/node/serialization.d.ts.map +1 -1
  80. package/dist/node/serialization.js +42 -0
  81. package/dist/node/serialization.js.map +1 -1
  82. package/package.json +2 -2
  83. package/src/api/index.ts +0 -15
  84. package/src/api/workflow.ts +61 -4
  85. package/src/channel/Channel.ts +2 -2
  86. package/src/channel/ChannelDefinition.ts +16 -0
  87. package/src/channel/serialization.ts +6 -0
  88. package/src/deploy/constants.ts +30 -0
  89. package/src/deploy/index.ts +14 -17
  90. package/src/deploy/requirements.test.ts +164 -64
  91. package/src/deploy/requirements.ts +104 -159
  92. package/src/diagnostics/diagnostics.test.ts +40 -22
  93. package/src/expression/parser.ts +9 -1
  94. package/src/model/MLModelDefinition.ts +16 -0
  95. package/src/model/Model.ts +6 -2
  96. package/src/model/ModelRegistry.ts +2 -0
  97. package/src/model/index.ts +1 -0
  98. package/src/model/serialization.ts +5 -0
  99. package/src/node/InputNode.ts +28 -2
  100. package/src/node/MLInferenceNode.ts +46 -0
  101. package/src/node/Node.ts +3 -1
  102. package/src/node/NodeRegistry.ts +4 -1
  103. package/src/node/methods.ts +6 -0
  104. package/src/node/serialization.ts +44 -2
  105. package/src/workflow/serialization.test.ts +53 -0
  106. package/src/api/deployment.ts +0 -74
  107. package/src/api/engine.ts +0 -744
  108. package/src/deploy/inputs.ts +0 -44
  109. package/src/deploy/spec.test.ts +0 -227
  110. package/src/deploy/spec.ts +0 -397
@@ -1,44 +0,0 @@
1
- // SPDX-License-Identifier: Apache-2.0
2
- // Copyright (c) 2026 ForestHub.
3
-
4
- // Operator-supplied bindings: the per-deploy values that bind a workflow's
5
- // logical resource ids to a concrete environment. Shared by every spec
6
- // producer (the CLI prompts/--values, the FE deploy forms) — how the values are
7
- // collected differs per renderer, but the shape they resolve to is one thing.
8
- //
9
- // Secrets policy: provider API keys and the web-search key are NOT here — they
10
- // are device/operator-scoped engine env, injected at render time, never frozen
11
- // into a versioned spec. Only deployment-scoped resource creds (broker, custom
12
- // endpoint) live in these bindings, because EngineConfig.externalResources needs
13
- // them resolved inline.
14
-
15
- // One hardware channel's physical address. `index` = sub-address (addressable
16
- // families: GPIO line, ADC/DAC/PWM channel); `baud` = serial only.
17
- export interface HardwareBinding {
18
- chipOrDevice: string;
19
- index?: number;
20
- baud?: number;
21
- }
22
-
23
- // One MQTT channel's broker connection.
24
- export interface MqttBinding {
25
- brokerUrl: string;
26
- username?: string;
27
- password?: string;
28
- }
29
-
30
- // One custom model's runtime location. `device` = a llama-server sidecar on this
31
- // controller (the engine reaches it over the container network by service name);
32
- // `network` = an inference endpoint the operator runs elsewhere. The endpoint
33
- // serves the model under its workflow id — no upstream-name aliasing yet.
34
- export type ModelBinding =
35
- | { location: "device"; modelFile: string; port?: number; ctxSize?: number }
36
- | { location: "network"; url: string; apiKey?: string };
37
-
38
- // The complete set of bindings a deploy supplies, keyed by workflow logical id
39
- // (channel id / model id). Empty for any resource kind the workflow doesn't use.
40
- export interface DeploymentInputs {
41
- hardware: Record<string, HardwareBinding>;
42
- mqtt: Record<string, MqttBinding>;
43
- models: Record<string, ModelBinding>;
44
- }
@@ -1,227 +0,0 @@
1
- // SPDX-License-Identifier: Apache-2.0
2
- // Copyright (c) 2026 ForestHub.
3
-
4
- import { describe, it, expect } from "vitest";
5
- import { buildDeploymentSpec, assertDeployable, sidecarServiceName } from "./spec";
6
- import { deriveRequirements } from "./requirements";
7
- import type { DeploymentInputs } from "./inputs";
8
- import { MAIN_CANVAS_ID, type Workflow } from "../workflow";
9
- import type { Channel } from "../channel";
10
- import type { Model } from "../model";
11
- import type { DeploymentSchemas, EngineSchemas } from "../api";
12
-
13
- type Spec = DeploymentSchemas["DeploymentSpec"];
14
-
15
- // The engine is the component named "engine"; its config is transported as an
16
- // opaque blob, so cast it back to EngineConfig to inspect manifest/mapping/etc.
17
- function engineOf(spec: Spec): DeploymentSchemas["DeployComponent"] {
18
- const e = spec.components.find((c) => c.name === "engine");
19
- if (!e) throw new Error("expected engine component");
20
- return e;
21
- }
22
- function engineConfigOf(spec: Spec): EngineSchemas["EngineConfig"] {
23
- return engineOf(spec).config as EngineSchemas["EngineConfig"];
24
- }
25
- const llamaOf = (spec: Spec, modelId: string) => spec.components.find((c) => c.name === sidecarServiceName(modelId));
26
-
27
- function channel(id: string, type: Channel["type"], args: Record<string, unknown> = {}): Channel {
28
- return { id, label: id, type, arguments: args };
29
- }
30
-
31
- const customModel: Model = { id: "local-llm", label: "Local", type: "LLMModel", arguments: {} };
32
-
33
- // One workflow exercising every resource kind: a cdev output (GPIO), a serial
34
- // device (cdev), a sysfs ADC (privileged), an MQTT channel, and an on-device
35
- // custom model. Empty main canvas — the resolver reads channels/models, not nodes.
36
- function fullWorkflow(): Workflow {
37
- return {
38
- canvases: { [MAIN_CANVAS_ID]: { nodes: [], edges: [], variables: {} } },
39
- functions: {},
40
- channels: {
41
- led: channel("led", "GPIOOUT"),
42
- serial0: channel("serial0", "UART"),
43
- sensor: channel("sensor", "ADC"),
44
- telemetry: channel("telemetry", "MQTT", { topic: "t/x" }),
45
- },
46
- memory: {},
47
- models: { "local-llm": customModel },
48
- };
49
- }
50
-
51
- const fullInputs: DeploymentInputs = {
52
- hardware: {
53
- led: { chipOrDevice: "/dev/gpiochip0", index: 17 },
54
- serial0: { chipOrDevice: "/dev/ttyUSB0", baud: 9600 },
55
- sensor: { chipOrDevice: "/sys/bus/iio/devices/iio:device0", index: 0 },
56
- },
57
- mqtt: { telemetry: { brokerUrl: "mqtt://broker.local:1883", username: "u", password: "p" } },
58
- models: { "local-llm": { location: "device", modelFile: "model.gguf" } },
59
- };
60
-
61
- const meta = {
62
- id: "dep-1",
63
- engineImage: "fh-engine:0.4.2",
64
- llamaServerImage: "ghcr.io/ggml-org/llama.cpp:server-b8589",
65
- };
66
-
67
- describe("buildDeploymentSpec", () => {
68
- it("resolves a full workflow into a contract-shaped spec", () => {
69
- const { spec } = buildDeploymentSpec(fullWorkflow(), fullInputs, meta);
70
-
71
- expect(spec.schemaVersion).toBe(1);
72
- expect(spec.id).toBe("dep-1");
73
-
74
- const engine = engineOf(spec);
75
- expect(engine.image).toBe("fh-engine:0.4.2");
76
- expect(engine.pull).toBe("never"); // built locally, in no registry
77
- expect(engineConfigOf(spec).workflow.schemaVersion).toBeGreaterThanOrEqual(1);
78
- });
79
-
80
- it("splits the device manifest by family and maps every resource", () => {
81
- const config = engineConfigOf(buildDeploymentSpec(fullWorkflow(), fullInputs, meta).spec);
82
- const m = config.manifest!;
83
- expect(Object.keys(m.gpios ?? {})).toHaveLength(1);
84
- expect(Object.keys(m.serials ?? {})).toHaveLength(1);
85
- expect(Object.keys(m.adcs ?? {})).toHaveLength(1);
86
-
87
- // Mapping keyed by every workflow logical id; each ref resolves to a built resource.
88
- expect(Object.keys(config.mapping ?? {}).sort()).toEqual(["led", "local-llm", "sensor", "serial0", "telemetry"]);
89
- expect(config.mapping!.sensor).toEqual({ ref: expect.any(String), index: 0 });
90
- });
91
-
92
- it("resolves cdev nodes into devices and forces privileged for sysfs families", () => {
93
- const engine = engineOf(buildDeploymentSpec(fullWorkflow(), fullInputs, meta).spec);
94
- // GPIO + serial are cdev → granted; ADC (sysfs) is not a node → privileged instead.
95
- expect(engine.devices?.sort()).toEqual(["/dev/gpiochip0", "/dev/ttyUSB0"]);
96
- expect(engine.privileged).toBe(true);
97
- // A nonroot image reaching root-owned nodes/sysfs runs as root.
98
- expect(engine.user).toBe("0:0");
99
- });
100
-
101
- it("emits a llama-server component and points the engine's provider at the sidecar", () => {
102
- const { spec } = buildDeploymentSpec(fullWorkflow(), fullInputs, meta);
103
- expect(llamaOf(spec, "local-llm")).toMatchObject({
104
- name: sidecarServiceName("local-llm"),
105
- image: "ghcr.io/ggml-org/llama.cpp:server-b8589",
106
- command: ["--model", "/var/lib/foresthub/workspace/model.gguf", "--host", "0.0.0.0", "--port", "8080", "--ctx-size", "4096"],
107
- volumes: ["./workspaces/llama-local-llm:/var/lib/foresthub/workspace:ro"],
108
- });
109
- // The external-resource provider URL must point at the sidecar service name.
110
- const ext = engineConfigOf(spec).externalResources!;
111
- const provider = Object.values(ext).find((r) => r.type === "selfhosted");
112
- expect(provider).toMatchObject({ url: `http://${sidecarServiceName("local-llm")}:8080` });
113
- });
114
-
115
- it("omits privileged and the llama sidecar when neither applies", () => {
116
- const wf: Workflow = {
117
- canvases: { [MAIN_CANVAS_ID]: { nodes: [], edges: [], variables: {} } },
118
- functions: {},
119
- channels: { led: channel("led", "GPIOOUT") },
120
- memory: {},
121
- models: {},
122
- };
123
- const { spec } = buildDeploymentSpec(wf, { hardware: { led: { chipOrDevice: "/dev/gpiochip0", index: 1 } }, mqtt: {}, models: {} }, meta);
124
- const engine = engineOf(spec);
125
- expect(engine.privileged).toBeUndefined();
126
- expect(spec.components).toHaveLength(1); // engine only, no sidecar
127
- expect(engine.devices).toEqual(["/dev/gpiochip0"]);
128
- });
129
-
130
- it("uses a network model's own endpoint and runs no sidecar", () => {
131
- const wf: Workflow = {
132
- canvases: { [MAIN_CANVAS_ID]: { nodes: [], edges: [], variables: {} } },
133
- functions: {},
134
- channels: {},
135
- memory: {},
136
- models: { "local-llm": customModel },
137
- };
138
- const inputs: DeploymentInputs = {
139
- hardware: {},
140
- mqtt: {},
141
- models: { "local-llm": { location: "network", url: "https://infer.example/v1", apiKey: "k" } },
142
- };
143
- const { spec, resourceSecrets } = buildDeploymentSpec(wf, inputs, meta);
144
- expect(spec.components).toHaveLength(1); // engine only, no sidecar
145
- // The provider config in the spec is secret-free; the apiKey is pulled out.
146
- const ext = engineConfigOf(spec).externalResources!;
147
- const [ref, provider] = Object.entries(ext).find(([, r]) => r.type === "selfhosted")!;
148
- expect(provider).toEqual({ type: "selfhosted", url: "https://infer.example/v1" });
149
- expect(resourceSecrets[ref]).toEqual("k");
150
- });
151
-
152
- it("pulls MQTT/endpoint secrets out of the spec, keyed by resource ref", () => {
153
- const { spec, resourceSecrets } = buildDeploymentSpec(fullWorkflow(), fullInputs, meta);
154
- const ext = engineConfigOf(spec).externalResources!;
155
- // The stored connection carries metadata but never the password.
156
- const [mqttRef, mqttConn] = Object.entries(ext).find(([, r]) => r.type === "mqtt")!;
157
- expect(mqttConn).not.toHaveProperty("password");
158
- expect(mqttConn).toMatchObject({ username: "u" });
159
- expect(resourceSecrets[mqttRef]).toEqual("p");
160
- // Whole spec serialized carries no secret value.
161
- expect(JSON.stringify(spec)).not.toContain('"p"');
162
- });
163
- });
164
-
165
- describe("buildDeploymentSpec custom components", () => {
166
- const bareWf: Workflow = {
167
- canvases: { [MAIN_CANVAS_ID]: { nodes: [], edges: [], variables: {} } },
168
- functions: {},
169
- channels: {},
170
- memory: {},
171
- models: {},
172
- };
173
- const bareInputs: DeploymentInputs = { hardware: {}, mqtt: {}, models: {} };
174
-
175
- it("merges custom components after engine (and llama)", () => {
176
- const grafana = { name: "grafana", image: "grafana/grafana:11.3.0", ports: ["3000:3000"] };
177
- const { spec } = buildDeploymentSpec(bareWf, bareInputs, meta, [grafana]);
178
- expect(spec.components.map((c) => c.name)).toEqual(["engine", "grafana"]);
179
- });
180
-
181
- it("rejects a custom name colliding with the engine", () => {
182
- expect(() => buildDeploymentSpec(bareWf, bareInputs, meta, [{ name: "engine", image: "x" }])).toThrow(
183
- /duplicate component name "engine"/,
184
- );
185
- });
186
-
187
- it("rejects a custom name colliding with a generated llama sidecar", () => {
188
- const dup = { name: sidecarServiceName("local-llm"), image: "x" };
189
- expect(() => buildDeploymentSpec(fullWorkflow(), fullInputs, meta, [dup])).toThrow(/duplicate component name/);
190
- });
191
-
192
- it("allows the same image under different names", () => {
193
- const a = { name: "graf-a", image: "grafana/grafana:11.3.0" };
194
- const b = { name: "graf-b", image: "grafana/grafana:11.3.0" };
195
- const { spec } = buildDeploymentSpec(bareWf, bareInputs, meta, [a, b]);
196
- expect(spec.components.map((c) => c.name)).toEqual(["engine", "graf-a", "graf-b"]);
197
- });
198
-
199
- it("rejects two customs sharing a name", () => {
200
- const a = { name: "dash", image: "x" };
201
- const b = { name: "dash", image: "y" };
202
- expect(() => buildDeploymentSpec(bareWf, bareInputs, meta, [a, b])).toThrow(/duplicate component name "dash"/);
203
- });
204
- });
205
-
206
- describe("assertDeployable", () => {
207
- it("throws listing every unbound resource", () => {
208
- const req = deriveRequirements(fullWorkflow());
209
- expect(() => assertDeployable(req, { hardware: {}, mqtt: {}, models: {} })).toThrow(/device path[\s\S]*broker URL[\s\S]*model/);
210
- });
211
-
212
- it("rejects a non-GGUF on-device model file", () => {
213
- const req = deriveRequirements(fullWorkflow());
214
- const inputs = { ...fullInputs, models: { "local-llm": { location: "device" as const, modelFile: "model.bin" } } };
215
- expect(() => assertDeployable(req, inputs)).toThrow(/\.gguf/);
216
- });
217
- });
218
-
219
- describe("deriveRequirements", () => {
220
- it("classifies channels into hardware families and mqtt", () => {
221
- const req = deriveRequirements(fullWorkflow());
222
- expect(req.hardwareChannels.map((c) => c.family).sort()).toEqual(["adc", "gpio", "serial"]);
223
- expect(req.mqttChannels.map((c) => c.id)).toEqual(["telemetry"]);
224
- expect(req.customModels.map((c) => c.id)).toEqual(["local-llm"]);
225
- expect(req.hardwareChannels.find((c) => c.family === "serial")?.addressable).toBe(false);
226
- });
227
- });
@@ -1,397 +0,0 @@
1
- // SPDX-License-Identifier: Apache-2.0
2
- // Copyright (c) 2026 ForestHub.
3
-
4
- // The spec resolver: (workflow + bindings) -> DeploymentSpec. The shared
5
- // "packaging library" the migration doc calls for — component-set derivation
6
- // and device-grant resolution computed once here, frozen into the contract spec,
7
- // so neither renderer (OSS one-shot CLI, paid ranger) re-derives them.
8
- //
9
- // Every produced field is typed against the generated deployment contract, so a
10
- // contract change stops this compiling — the drift guard for the spec.
11
-
12
- import type { Workflow } from "../workflow";
13
- import { serialize } from "../workflow";
14
- import type { DeploymentSchemas, EngineSchemas } from "../api";
15
- import { deriveRequirements } from "./requirements";
16
- import type { DeployRequirements, HardwareChannel, HardwareFamily } from "./requirements";
17
- import type { DeploymentInputs, HardwareBinding } from "./inputs";
18
-
19
- type DeploymentSpec = DeploymentSchemas["DeploymentSpec"];
20
- type DeployComponent = DeploymentSchemas["DeployComponent"];
21
- // Typed against the engine's own contract (engine.yaml), not the deployment one:
22
- // the spec carries it as an opaque blob in DeployComponent.config.
23
- type EngineConfig = EngineSchemas["EngineConfig"];
24
-
25
- // In-container mount path for a component's durable workspace. MUST equal the Go
26
- // component.Workspace contract constant (go/component/paths.go) — the component
27
- // reads/writes there, so we mount the host workspace dir there and wire no env var.
28
- // A cross-repo contract: changing it requires changing the Go constant in lockstep.
29
- const COMPONENT_WORKSPACE_PATH = "/var/lib/foresthub/workspace";
30
-
31
- // The OSS renderer's state root (docs/device-filesystem.md §2). Bind-mount sources
32
- // hang off it as `<root>/workspaces/<container>/`. "." makes Docker resolve them
33
- // relative to the compose file, so the bundle stays drop-anywhere; Ranger is a
34
- // separate renderer that sets root to the absolute `/var/lib/foresthub`.
35
- const STATE_ROOT = ".";
36
-
37
- // Host bind-mount source for a container's workspace under the state root.
38
- function workspaceDir(container: string): string {
39
- return `${STATE_ROOT}/workspaces/${container}`;
40
- }
41
-
42
- // Deploy-time metadata the resolver cannot derive from the workflow: identity,
43
- // lifecycle, and the full image reference each component runs (frozen here so the
44
- // renderer emits a coordinate rather than assembling one). llamaServerImage is
45
- // used only when the workflow has an on-device model.
46
- export interface DeploymentSpecMeta {
47
- id: string;
48
- createdAt?: string;
49
- engineImage: string;
50
- llamaServerImage: string;
51
- }
52
-
53
- // The engine's secret store: a flat map of secret id -> opaque secret value,
54
- // keyed by the same resource ref the spec's externalResources use. Each value is
55
- // the single credential that resource needs (MQTT password, self-hosted-LLM
56
- // bearer token). Secrets are NEVER part of the spec (not rotation-safe, breach-
57
- // exposed if stored): the resolver returns them separately, for the renderer to
58
- // deliver out-of-band as a mounted secret document (secrets.json). Mirrors the
59
- // wire EngineSecrets.
60
- export type EngineSecrets = Record<string, string>;
61
-
62
- // buildDeploymentSpec's output: the secret-free spec plus the pulled-out secrets.
63
- export interface DeploymentSpecResult {
64
- spec: DeploymentSpec;
65
- resourceSecrets: EngineSecrets;
66
- }
67
-
68
- // Hands out stable, collision-free ref names. Same dedup key -> same ref (the
69
- // engine builds that resource once); distinct keys preferring the same name get
70
- // suffixed (-2, -3, ...) so the flat ref namespace stays unambiguous.
71
- class RefAllocator {
72
- private readonly byKey = new Map<string, string>();
73
- private readonly used = new Set<string>();
74
-
75
- alloc(key: string, hint: string): string {
76
- const existing = this.byKey.get(key);
77
- if (existing) return existing;
78
- let ref = hint;
79
- for (let n = 2; this.used.has(ref); n++) ref = `${hint}-${n}`;
80
- this.used.add(ref);
81
- this.byKey.set(key, ref);
82
- return ref;
83
- }
84
- }
85
-
86
- // Last path/URL segment, reduced to a safe ref token.
87
- // "/dev/gpiochip0" -> "gpiochip0", ".../iio:device0" -> "iio:device0".
88
- function basename(p: string): string {
89
- const tail = p.replace(/\/+$/, "").split("/").pop() ?? p;
90
- return tail.replace(/[^A-Za-z0-9._:-]/g, "-") || "res";
91
- }
92
-
93
- // Host of a broker URL, for a readable ref. "mqtt" when unparseable.
94
- function brokerHost(url: string): string {
95
- try {
96
- return new URL(url).hostname || "mqtt";
97
- } catch {
98
- return "mqtt";
99
- }
100
- }
101
-
102
- // Compose/container service name for an on-device model's llama-server sidecar.
103
- // Single source of truth: the resolver derives the provider URL from it and the
104
- // renderer emits a service with the same name — they must agree or the URL
105
- // points at a service that doesn't exist.
106
- export function sidecarServiceName(modelId: string): string {
107
- const slug = modelId
108
- .toLowerCase()
109
- .replace(/[^a-z0-9._-]+/g, "-")
110
- .replace(/^-+|-+$/g, "");
111
- return `llama-${slug || "model"}`;
112
- }
113
-
114
- // Why an on-device model filename is unacceptable, or null when fine. A name
115
- // check only — the file doesn't exist yet at spec time. Shared with the prompts
116
- // so input collection and the resolver reject the same input.
117
- export function ggufNameError(name: string | undefined): string | null {
118
- const t = (name ?? "").trim();
119
- if (!t) return "a model filename is required";
120
- if (!t.toLowerCase().endsWith(".gguf")) return "must be a .gguf file (llama-server only loads GGUF)";
121
- if (t.includes("/")) return "just the filename, not a path — the file goes in the model's workspace dir (./workspaces/llama-…/)";
122
- return null;
123
- }
124
-
125
- // A physical address belongs to exactly one channel — the engine doesn't police
126
- // this and would silently let the last claimer win. Same key = collision;
127
- // sharing just a chip path is fine (one chip, many lines), except serial where
128
- // the path IS the device. Exported so input collection (prompts) can reject a
129
- // duplicate at entry with the same identity the resolver validates against.
130
- export function hardwareAddressKey(family: HardwareFamily, chipOrDevice: string, index?: number): string {
131
- const dev = chipOrDevice.trim();
132
- return family === "serial" ? `serial:${dev}` : `${family}:${dev}:${index}`;
133
- }
134
-
135
- // The same address phrased for an error message: "/dev/gpiochip0 line 17".
136
- export function hardwareAddressLabel(family: HardwareFamily, chipOrDevice: string, index?: number): string {
137
- const dev = chipOrDevice.trim();
138
- if (family === "serial") return dev;
139
- return `${dev} ${family === "gpio" ? "line" : "channel"} ${index}`;
140
- }
141
-
142
- // One message per channel whose address an earlier channel already claimed.
143
- // Incomplete bindings are skipped (completeness is checked separately).
144
- export function hardwareConflicts(channels: HardwareChannel[], bindings: Record<string, HardwareBinding>): string[] {
145
- const conflicts: string[] = [];
146
- const claimed = new Map<string, string>(); // address key -> channel id holding it
147
- for (const ch of channels) {
148
- const b = bindings[ch.id];
149
- if (!b?.chipOrDevice || (ch.addressable && b.index === undefined)) continue;
150
- const key = hardwareAddressKey(ch.family, b.chipOrDevice, b.index);
151
- const holder = claimed.get(key);
152
- if (holder) {
153
- conflicts.push(`hardware "${ch.id}": ${hardwareAddressLabel(ch.family, b.chipOrDevice, b.index)} is already used by "${holder}"`);
154
- } else {
155
- claimed.set(key, ch.id);
156
- }
157
- }
158
- return conflicts;
159
- }
160
-
161
- // One message per binding carrying a field its family doesn't have (`baud` is
162
- // serial-only, `index` is everything-but-serial). Usually a mixed-up channel id
163
- // in a machine-written binding set — reject loudly instead of ignoring.
164
- export function familyMismatches(channels: HardwareChannel[], bindings: Record<string, HardwareBinding>): string[] {
165
- const mismatches: string[] = [];
166
- for (const ch of channels) {
167
- const b = bindings[ch.id];
168
- if (!b) continue;
169
- if (ch.family !== "serial" && b.baud !== undefined) {
170
- mismatches.push(`hardware "${ch.id}": "baud" only applies to serial channels (this is a ${ch.family} channel)`);
171
- }
172
- if (ch.family === "serial" && b.index !== undefined) {
173
- mismatches.push(`hardware "${ch.id}": "index" does not apply to serial channels (the device path is the full address)`);
174
- }
175
- }
176
- return mismatches;
177
- }
178
-
179
- // Exhaustiveness guard over HardwareFamily — a new family breaks compilation
180
- // here until handled.
181
- function assertNeverFamily(f: never): never {
182
- throw new Error(`unhandled hardware family: ${String(f)}`);
183
- }
184
-
185
- // Mirror of the engine's deploy-layer validation: every declared resource must
186
- // carry a binding or the engine fatals at boot. Collects ALL gaps so a caller
187
- // sees them at once. Throws on any gap — the last guard before a dead spec.
188
- export function assertDeployable(req: DeployRequirements, inputs: DeploymentInputs): void {
189
- const missing: string[] = [];
190
- for (const ch of req.hardwareChannels) {
191
- const b = inputs.hardware[ch.id];
192
- if (!b?.chipOrDevice) missing.push(`hardware "${ch.id}": device path`);
193
- else if (ch.addressable && b.index === undefined) missing.push(`hardware "${ch.id}": index`);
194
- }
195
- for (const ch of req.mqttChannels) {
196
- if (!inputs.mqtt[ch.id]?.brokerUrl) missing.push(`mqtt "${ch.id}": broker URL`);
197
- }
198
- for (const m of req.customModels) {
199
- const b = inputs.models[m.id];
200
- if (b?.location === "device") {
201
- const err = ggufNameError(b.modelFile);
202
- if (err) missing.push(`model "${m.id}": ${err}`);
203
- } else if (!b?.url) {
204
- missing.push(`model "${m.id}": endpoint URL`);
205
- }
206
- }
207
- missing.push(...hardwareConflicts(req.hardwareChannels, inputs.hardware));
208
- missing.push(...familyMismatches(req.hardwareChannels, inputs.hardware));
209
- if (missing.length > 0) {
210
- throw new Error(`invalid deploy config:\n - ${missing.join("\n - ")}`);
211
- }
212
- }
213
-
214
- // One compose service per name: a duplicate name would collapse two components
215
- // onto one service and silently drop the loser. Names only — same image under
216
- // different names is legitimate (e.g. two grafanas).
217
- function assertNoNameCollisions(components: DeployComponent[]): void {
218
- const seen = new Set<string>();
219
- for (const c of components) {
220
- if (seen.has(c.name)) throw new Error(`duplicate component name "${c.name}"`);
221
- seen.add(c.name);
222
- }
223
- }
224
-
225
- // buildDeploymentSpec resolves a workflow plus its bindings into a complete,
226
- // contract-defined DeploymentSpec. Throws (via assertDeployable) if any declared
227
- // resource is unbound. The embedded engine config carries the serialized (and
228
- // thereby pruned) workflow; device grants and privileged are resolved here.
229
- // customComponents are operator-authored containers, merged in verbatim.
230
- export function buildDeploymentSpec(
231
- workflow: Workflow,
232
- inputs: DeploymentInputs,
233
- meta: DeploymentSpecMeta,
234
- customComponents: DeployComponent[] = [],
235
- ): DeploymentSpecResult {
236
- const req = deriveRequirements(workflow);
237
- assertDeployable(req, inputs);
238
-
239
- const refs = new RefAllocator();
240
- const resourceSecrets: EngineSecrets = {};
241
-
242
- // DeviceManifest is split per family; accumulate each separately, attach only
243
- // the non-empty ones (all slots optional).
244
- const gpios: Record<string, EngineSchemas["GPIOConfig"]> = {};
245
- const adcs: Record<string, EngineSchemas["ADCConfig"]> = {};
246
- const dacs: Record<string, EngineSchemas["DACConfig"]> = {};
247
- const pwms: Record<string, EngineSchemas["PWMConfig"]> = {};
248
- const serials: Record<string, EngineSchemas["SerialConfig"]> = {};
249
-
250
- const externalResources: EngineSchemas["ExternalResources"] = {};
251
- const mapping: EngineSchemas["ResourceMapping"] = {};
252
-
253
- // Container-level hardware access, resolved once: cdev nodes (GPIO, UART) map
254
- // one-to-one into the engine component's devices; sysfs families (ADC/DAC/PWM)
255
- // have no single node, so the engine container runs privileged.
256
- const cdev = new Set<string>();
257
- let privileged = false;
258
-
259
- // Hardware: one driver instance per distinct device path (dedup by
260
- // family+path); one mapping per channel carrying its per-channel index.
261
- for (const ch of req.hardwareChannels) {
262
- const b = inputs.hardware[ch.id];
263
- if (!b) throw new Error(`unbound hardware channel ${ch.id}`); // unreachable after assertDeployable
264
- const dev = b.chipOrDevice;
265
- const ref = refs.alloc(`hw:${ch.family}:${dev}`, basename(dev));
266
-
267
- switch (ch.family) {
268
- case "gpio":
269
- gpios[ref] = { chip: dev };
270
- cdev.add(dev);
271
- break;
272
- case "serial":
273
- serials[ref] = b.baud ? { device: dev, baud: b.baud } : { device: dev };
274
- cdev.add(dev);
275
- break;
276
- case "pwm":
277
- pwms[ref] = { chip: dev };
278
- privileged = true;
279
- break;
280
- case "adc":
281
- adcs[ref] = { device: dev };
282
- privileged = true;
283
- break;
284
- case "dac":
285
- dacs[ref] = { device: dev };
286
- privileged = true;
287
- break;
288
- default:
289
- return assertNeverFamily(ch.family);
290
- }
291
-
292
- if (ch.addressable && b.index !== undefined) mapping[ch.id] = { ref, index: b.index };
293
- else mapping[ch.id] = { ref };
294
- }
295
-
296
- // MQTT: one connection per distinct config (dedup by full content — same
297
- // broker, different creds is a different resource). No index.
298
- for (const ch of req.mqttChannels) {
299
- const b = inputs.mqtt[ch.id];
300
- if (!b) throw new Error(`unbound mqtt channel ${ch.id}`); // unreachable
301
- const conn: EngineSchemas["MQTTConnection"] = { type: "mqtt", brokerUrl: b.brokerUrl };
302
- if (b.username) conn.username = b.username;
303
- // The password is a secret — kept out of conn (and thus the spec). It still
304
- // participates in the dedup key, so two channels differing only by password
305
- // don't collapse onto one ref (and one shared secret).
306
- const ref = refs.alloc(`mqtt:${JSON.stringify(conn)}:${b.password ?? ""}`, `mqtt-${brokerHost(b.brokerUrl)}`);
307
- externalResources[ref] = conn;
308
- mapping[ch.id] = { ref };
309
- if (b.password) resourceSecrets[ref] = b.password;
310
- }
311
-
312
- // Custom models: one self-hosted provider per model id. A device model points
313
- // at the sidecar we run (over the container network, no key) and gets its own
314
- // llama-server component; a network model points at the operator's endpoint and
315
- // runs no component here. `model` is left off — the endpoint serves under the
316
- // workflow id (no upstream-name aliasing yet).
317
- const llamaComponents: DeployComponent[] = [];
318
- for (const m of req.customModels) {
319
- const b = inputs.models[m.id];
320
- if (!b) throw new Error(`unbound model ${m.id}`); // unreachable after assertDeployable
321
- const ref = refs.alloc(`model:${m.id}`, basename(m.id));
322
- mapping[m.id] = { ref };
323
-
324
- if (b.location === "device") {
325
- const port = b.port ?? 8080;
326
- const service = sidecarServiceName(m.id);
327
- externalResources[ref] = { type: "selfhosted", url: `http://${service}:${port}` };
328
- // The whole config is CLI flags, so it rides in `command` (no config blob).
329
- // ctx-size is frozen here — retuning it is a re-deploy, not an env edit.
330
- llamaComponents.push({
331
- name: service,
332
- image: meta.llamaServerImage,
333
- command: [
334
- "--model",
335
- `${COMPONENT_WORKSPACE_PATH}/${b.modelFile}`,
336
- "--host",
337
- "0.0.0.0",
338
- "--port",
339
- String(port),
340
- "--ctx-size",
341
- String(b.ctxSize ?? 4096),
342
- ],
343
- // The model lives in this sidecar's own workspace; read-only, so no chown
344
- // is needed (docs §5). The operator drops the GGUF in workspaceDir(service).
345
- volumes: [`${workspaceDir(service)}:${COMPONENT_WORKSPACE_PATH}:ro`],
346
- });
347
- } else {
348
- externalResources[ref] = { type: "selfhosted", url: b.url };
349
- // The endpoint bearer is a secret — out of the spec, returned separately.
350
- if (b.apiKey) resourceSecrets[ref] = b.apiKey;
351
- }
352
- }
353
-
354
- const manifest: EngineSchemas["DeviceManifest"] = {};
355
- if (Object.keys(gpios).length) manifest.gpios = gpios;
356
- if (Object.keys(adcs).length) manifest.adcs = adcs;
357
- if (Object.keys(dacs).length) manifest.dacs = dacs;
358
- if (Object.keys(pwms).length) manifest.pwms = pwms;
359
- if (Object.keys(serials).length) manifest.serials = serials;
360
-
361
- // The engine's boot input. workflow is serialized (and thereby pruned) from
362
- // the domain. The optional sections attach only when non-empty — an absent
363
- // section is correct, not a gap, and matches what the engine skips.
364
- const config: EngineConfig = { workflow: serialize(workflow) };
365
- if (Object.keys(mapping).length) config.mapping = mapping;
366
- if (Object.keys(externalResources).length) config.externalResources = externalResources;
367
- if (Object.keys(manifest).length) config.manifest = manifest;
368
-
369
- // The engine component. The config blob is mounted at the fixed convention path
370
- // (component.ConfigFile) the engine image reads.
371
- const engine: DeployComponent = {
372
- name: "engine",
373
- image: meta.engineImage,
374
- pull: "never", // built locally before deploy, in no registry
375
- config,
376
- // Durable memory: a host bind mount under the state root, read-write, at the
377
- // in-container workspace path (docs/device-filesystem.md §10).
378
- volumes: [`${workspaceDir("engine")}:${COMPONENT_WORKSPACE_PATH}`],
379
- // Run as root: writes the rw workspace bind mount without a host chown step
380
- // (the OSS default, §5), and also opens root-owned device nodes when hardware
381
- // (cdev passthrough / sysfs-privileged) is mapped in below.
382
- user: "0:0",
383
- };
384
- if (cdev.size > 0) engine.devices = [...cdev];
385
- if (privileged) engine.privileged = true;
386
-
387
- const components = [engine, ...llamaComponents, ...customComponents];
388
- assertNoNameCollisions(components);
389
-
390
- const spec: DeploymentSpec = {
391
- schemaVersion: 1,
392
- id: meta.id,
393
- components,
394
- };
395
- if (meta.createdAt) spec.createdAt = meta.createdAt;
396
- return { spec, resourceSecrets };
397
- }