@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
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
// The spec resolver: (workflow + bindings) -> DeploymentSpec. The shared
|
|
2
|
+
// "packaging library" the migration doc calls for — component-set derivation
|
|
3
|
+
// and device-grant resolution computed once here, frozen into the contract spec,
|
|
4
|
+
// so neither renderer (OSS one-shot CLI, paid ranger) re-derives them.
|
|
5
|
+
//
|
|
6
|
+
// Every produced field is typed against the generated deployment contract, so a
|
|
7
|
+
// contract change stops this compiling — the drift guard for the spec.
|
|
8
|
+
|
|
9
|
+
import type { Workflow } from "../workflow";
|
|
10
|
+
import { serialize } from "../workflow";
|
|
11
|
+
import type { DeploymentSchemas, EngineSchemas } from "../api";
|
|
12
|
+
import { deriveRequirements } from "./requirements";
|
|
13
|
+
import type { DeployRequirements, HardwareChannel, HardwareFamily } from "./requirements";
|
|
14
|
+
import type { DeploymentInputs, HardwareBinding } from "./inputs";
|
|
15
|
+
|
|
16
|
+
type DeploymentSpec = DeploymentSchemas["DeploymentSpec"];
|
|
17
|
+
type DeployComponent = DeploymentSchemas["DeployComponent"];
|
|
18
|
+
// Typed against the engine's own contract (engine.yaml), not the deployment one:
|
|
19
|
+
// the spec carries it as an opaque blob in DeployComponent.config.
|
|
20
|
+
type EngineConfig = EngineSchemas["EngineConfig"];
|
|
21
|
+
|
|
22
|
+
// Mount path for the engine's memory volume — matches the image's ENGINE_MEMORY_DIR
|
|
23
|
+
// default, so the renderer mounts here without wiring an env var.
|
|
24
|
+
const ENGINE_MEMORY_PATH = "/var/lib/foresthub/memory";
|
|
25
|
+
|
|
26
|
+
// Deploy-time metadata the resolver cannot derive from the workflow: identity,
|
|
27
|
+
// lifecycle, and the full image reference each component runs (frozen here so the
|
|
28
|
+
// renderer emits a coordinate rather than assembling one). llamaServerImage is
|
|
29
|
+
// used only when the workflow has an on-device model.
|
|
30
|
+
export interface DeploymentSpecMeta {
|
|
31
|
+
id: string;
|
|
32
|
+
status: DeploymentSpec["status"];
|
|
33
|
+
createdAt?: string;
|
|
34
|
+
engineImage: string;
|
|
35
|
+
llamaServerImage: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// One external resource's credentials. Secrets are NEVER part of the spec (not
|
|
39
|
+
// rotation-safe, breach-exposed if stored): the resolver returns them separately,
|
|
40
|
+
// keyed by the same resource ref the spec's externalResources use, for the
|
|
41
|
+
// renderer to deliver out-of-band (engine env FH_RESOURCE_SECRETS).
|
|
42
|
+
export interface ResourceSecret {
|
|
43
|
+
password?: string; // MQTT broker password
|
|
44
|
+
apiKey?: string; // self-hosted LLM endpoint bearer
|
|
45
|
+
}
|
|
46
|
+
export type ResourceSecrets = Record<string, ResourceSecret>;
|
|
47
|
+
|
|
48
|
+
// buildDeploymentSpec's output: the secret-free spec plus the pulled-out secrets.
|
|
49
|
+
export interface DeploymentSpecResult {
|
|
50
|
+
spec: DeploymentSpec;
|
|
51
|
+
resourceSecrets: ResourceSecrets;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Hands out stable, collision-free ref names. Same dedup key -> same ref (the
|
|
55
|
+
// engine builds that resource once); distinct keys preferring the same name get
|
|
56
|
+
// suffixed (-2, -3, ...) so the flat ref namespace stays unambiguous.
|
|
57
|
+
class RefAllocator {
|
|
58
|
+
private readonly byKey = new Map<string, string>();
|
|
59
|
+
private readonly used = new Set<string>();
|
|
60
|
+
|
|
61
|
+
alloc(key: string, hint: string): string {
|
|
62
|
+
const existing = this.byKey.get(key);
|
|
63
|
+
if (existing) return existing;
|
|
64
|
+
let ref = hint;
|
|
65
|
+
for (let n = 2; this.used.has(ref); n++) ref = `${hint}-${n}`;
|
|
66
|
+
this.used.add(ref);
|
|
67
|
+
this.byKey.set(key, ref);
|
|
68
|
+
return ref;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Last path/URL segment, reduced to a safe ref token.
|
|
73
|
+
// "/dev/gpiochip0" -> "gpiochip0", ".../iio:device0" -> "iio:device0".
|
|
74
|
+
function basename(p: string): string {
|
|
75
|
+
const tail = p.replace(/\/+$/, "").split("/").pop() ?? p;
|
|
76
|
+
return tail.replace(/[^A-Za-z0-9._:-]/g, "-") || "res";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Host of a broker URL, for a readable ref. "mqtt" when unparseable.
|
|
80
|
+
function brokerHost(url: string): string {
|
|
81
|
+
try {
|
|
82
|
+
return new URL(url).hostname || "mqtt";
|
|
83
|
+
} catch {
|
|
84
|
+
return "mqtt";
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Compose/container service name for an on-device model's llama-server sidecar.
|
|
89
|
+
// Single source of truth: the resolver derives the provider URL from it and the
|
|
90
|
+
// renderer emits a service with the same name — they must agree or the URL
|
|
91
|
+
// points at a service that doesn't exist.
|
|
92
|
+
export function sidecarServiceName(modelId: string): string {
|
|
93
|
+
const slug = modelId
|
|
94
|
+
.toLowerCase()
|
|
95
|
+
.replace(/[^a-z0-9._-]+/g, "-")
|
|
96
|
+
.replace(/^-+|-+$/g, "");
|
|
97
|
+
return `llama-${slug || "model"}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Why an on-device model filename is unacceptable, or null when fine. A name
|
|
101
|
+
// check only — the file doesn't exist yet at spec time. Shared with the prompts
|
|
102
|
+
// so input collection and the resolver reject the same input.
|
|
103
|
+
export function ggufNameError(name: string | undefined): string | null {
|
|
104
|
+
const t = (name ?? "").trim();
|
|
105
|
+
if (!t) return "a model filename is required";
|
|
106
|
+
if (!t.toLowerCase().endsWith(".gguf")) return "must be a .gguf file (llama-server only loads GGUF)";
|
|
107
|
+
if (t.includes("/")) return "just the filename, not a path — the file goes in ./models/";
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// A physical address belongs to exactly one channel — the engine doesn't police
|
|
112
|
+
// this and would silently let the last claimer win. Same key = collision;
|
|
113
|
+
// sharing just a chip path is fine (one chip, many lines), except serial where
|
|
114
|
+
// the path IS the device. Exported so input collection (prompts) can reject a
|
|
115
|
+
// duplicate at entry with the same identity the resolver validates against.
|
|
116
|
+
export function hardwareAddressKey(family: HardwareFamily, chipOrDevice: string, index?: number): string {
|
|
117
|
+
const dev = chipOrDevice.trim();
|
|
118
|
+
return family === "serial" ? `serial:${dev}` : `${family}:${dev}:${index}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// The same address phrased for an error message: "/dev/gpiochip0 line 17".
|
|
122
|
+
export function hardwareAddressLabel(family: HardwareFamily, chipOrDevice: string, index?: number): string {
|
|
123
|
+
const dev = chipOrDevice.trim();
|
|
124
|
+
if (family === "serial") return dev;
|
|
125
|
+
return `${dev} ${family === "gpio" ? "line" : "channel"} ${index}`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// One message per channel whose address an earlier channel already claimed.
|
|
129
|
+
// Incomplete bindings are skipped (completeness is checked separately).
|
|
130
|
+
export function hardwareConflicts(channels: HardwareChannel[], bindings: Record<string, HardwareBinding>): string[] {
|
|
131
|
+
const conflicts: string[] = [];
|
|
132
|
+
const claimed = new Map<string, string>(); // address key -> channel id holding it
|
|
133
|
+
for (const ch of channels) {
|
|
134
|
+
const b = bindings[ch.id];
|
|
135
|
+
if (!b?.chipOrDevice || (ch.addressable && b.index === undefined)) continue;
|
|
136
|
+
const key = hardwareAddressKey(ch.family, b.chipOrDevice, b.index);
|
|
137
|
+
const holder = claimed.get(key);
|
|
138
|
+
if (holder) {
|
|
139
|
+
conflicts.push(`hardware "${ch.id}": ${hardwareAddressLabel(ch.family, b.chipOrDevice, b.index)} is already used by "${holder}"`);
|
|
140
|
+
} else {
|
|
141
|
+
claimed.set(key, ch.id);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return conflicts;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// One message per binding carrying a field its family doesn't have (`baud` is
|
|
148
|
+
// serial-only, `index` is everything-but-serial). Usually a mixed-up channel id
|
|
149
|
+
// in a machine-written binding set — reject loudly instead of ignoring.
|
|
150
|
+
export function familyMismatches(channels: HardwareChannel[], bindings: Record<string, HardwareBinding>): string[] {
|
|
151
|
+
const mismatches: string[] = [];
|
|
152
|
+
for (const ch of channels) {
|
|
153
|
+
const b = bindings[ch.id];
|
|
154
|
+
if (!b) continue;
|
|
155
|
+
if (ch.family !== "serial" && b.baud !== undefined) {
|
|
156
|
+
mismatches.push(`hardware "${ch.id}": "baud" only applies to serial channels (this is a ${ch.family} channel)`);
|
|
157
|
+
}
|
|
158
|
+
if (ch.family === "serial" && b.index !== undefined) {
|
|
159
|
+
mismatches.push(`hardware "${ch.id}": "index" does not apply to serial channels (the device path is the full address)`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return mismatches;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Exhaustiveness guard over HardwareFamily — a new family breaks compilation
|
|
166
|
+
// here until handled.
|
|
167
|
+
function assertNeverFamily(f: never): never {
|
|
168
|
+
throw new Error(`unhandled hardware family: ${String(f)}`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Mirror of the engine's deploy-layer validation: every declared resource must
|
|
172
|
+
// carry a binding or the engine fatals at boot. Collects ALL gaps so a caller
|
|
173
|
+
// sees them at once. Throws on any gap — the last guard before a dead spec.
|
|
174
|
+
export function assertDeployable(req: DeployRequirements, inputs: DeploymentInputs): void {
|
|
175
|
+
const missing: string[] = [];
|
|
176
|
+
for (const ch of req.hardwareChannels) {
|
|
177
|
+
const b = inputs.hardware[ch.id];
|
|
178
|
+
if (!b?.chipOrDevice) missing.push(`hardware "${ch.id}": device path`);
|
|
179
|
+
else if (ch.addressable && b.index === undefined) missing.push(`hardware "${ch.id}": index`);
|
|
180
|
+
}
|
|
181
|
+
for (const ch of req.mqttChannels) {
|
|
182
|
+
if (!inputs.mqtt[ch.id]?.brokerUrl) missing.push(`mqtt "${ch.id}": broker URL`);
|
|
183
|
+
}
|
|
184
|
+
for (const m of req.customModels) {
|
|
185
|
+
const b = inputs.models[m.id];
|
|
186
|
+
if (b?.location === "device") {
|
|
187
|
+
const err = ggufNameError(b.modelFile);
|
|
188
|
+
if (err) missing.push(`model "${m.id}": ${err}`);
|
|
189
|
+
} else if (!b?.url) {
|
|
190
|
+
missing.push(`model "${m.id}": endpoint URL`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
missing.push(...hardwareConflicts(req.hardwareChannels, inputs.hardware));
|
|
194
|
+
missing.push(...familyMismatches(req.hardwareChannels, inputs.hardware));
|
|
195
|
+
if (missing.length > 0) {
|
|
196
|
+
throw new Error(`invalid deploy config:\n - ${missing.join("\n - ")}`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// One compose service per name: a duplicate name would collapse two components
|
|
201
|
+
// onto one service and silently drop the loser. Names only — same image under
|
|
202
|
+
// different names is legitimate (e.g. two grafanas).
|
|
203
|
+
function assertNoNameCollisions(components: DeployComponent[]): void {
|
|
204
|
+
const seen = new Set<string>();
|
|
205
|
+
for (const c of components) {
|
|
206
|
+
if (seen.has(c.name)) throw new Error(`duplicate component name "${c.name}"`);
|
|
207
|
+
seen.add(c.name);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// buildDeploymentSpec resolves a workflow plus its bindings into a complete,
|
|
212
|
+
// contract-defined DeploymentSpec. Throws (via assertDeployable) if any declared
|
|
213
|
+
// resource is unbound. The embedded engine config carries the serialized (and
|
|
214
|
+
// thereby pruned) workflow; device grants and privileged are resolved here.
|
|
215
|
+
// customComponents are operator-authored containers, merged in verbatim.
|
|
216
|
+
export function buildDeploymentSpec(
|
|
217
|
+
workflow: Workflow,
|
|
218
|
+
inputs: DeploymentInputs,
|
|
219
|
+
meta: DeploymentSpecMeta,
|
|
220
|
+
customComponents: DeployComponent[] = [],
|
|
221
|
+
): DeploymentSpecResult {
|
|
222
|
+
const req = deriveRequirements(workflow);
|
|
223
|
+
assertDeployable(req, inputs);
|
|
224
|
+
|
|
225
|
+
const refs = new RefAllocator();
|
|
226
|
+
const resourceSecrets: ResourceSecrets = {};
|
|
227
|
+
|
|
228
|
+
// DeviceManifest is split per family; accumulate each separately, attach only
|
|
229
|
+
// the non-empty ones (all slots optional).
|
|
230
|
+
const gpios: Record<string, EngineSchemas["GPIOConfig"]> = {};
|
|
231
|
+
const adcs: Record<string, EngineSchemas["ADCConfig"]> = {};
|
|
232
|
+
const dacs: Record<string, EngineSchemas["DACConfig"]> = {};
|
|
233
|
+
const pwms: Record<string, EngineSchemas["PWMConfig"]> = {};
|
|
234
|
+
const serials: Record<string, EngineSchemas["SerialConfig"]> = {};
|
|
235
|
+
|
|
236
|
+
const externalResources: EngineSchemas["ExternalResources"] = {};
|
|
237
|
+
const mapping: EngineSchemas["DeploymentMapping"] = {};
|
|
238
|
+
|
|
239
|
+
// Container-level hardware access, resolved once: cdev nodes (GPIO, UART) map
|
|
240
|
+
// one-to-one into the engine component's devices; sysfs families (ADC/DAC/PWM)
|
|
241
|
+
// have no single node, so the engine container runs privileged.
|
|
242
|
+
const cdev = new Set<string>();
|
|
243
|
+
let privileged = false;
|
|
244
|
+
|
|
245
|
+
// Hardware: one driver instance per distinct device path (dedup by
|
|
246
|
+
// family+path); one mapping per channel carrying its per-channel index.
|
|
247
|
+
for (const ch of req.hardwareChannels) {
|
|
248
|
+
const b = inputs.hardware[ch.id];
|
|
249
|
+
if (!b) throw new Error(`unbound hardware channel ${ch.id}`); // unreachable after assertDeployable
|
|
250
|
+
const dev = b.chipOrDevice;
|
|
251
|
+
const ref = refs.alloc(`hw:${ch.family}:${dev}`, basename(dev));
|
|
252
|
+
|
|
253
|
+
switch (ch.family) {
|
|
254
|
+
case "gpio":
|
|
255
|
+
gpios[ref] = { chip: dev };
|
|
256
|
+
cdev.add(dev);
|
|
257
|
+
break;
|
|
258
|
+
case "serial":
|
|
259
|
+
serials[ref] = b.baud ? { device: dev, baud: b.baud } : { device: dev };
|
|
260
|
+
cdev.add(dev);
|
|
261
|
+
break;
|
|
262
|
+
case "pwm":
|
|
263
|
+
pwms[ref] = { chip: dev };
|
|
264
|
+
privileged = true;
|
|
265
|
+
break;
|
|
266
|
+
case "adc":
|
|
267
|
+
adcs[ref] = { device: dev };
|
|
268
|
+
privileged = true;
|
|
269
|
+
break;
|
|
270
|
+
case "dac":
|
|
271
|
+
dacs[ref] = { device: dev };
|
|
272
|
+
privileged = true;
|
|
273
|
+
break;
|
|
274
|
+
default:
|
|
275
|
+
return assertNeverFamily(ch.family);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (ch.addressable && b.index !== undefined) mapping[ch.id] = { ref, index: b.index };
|
|
279
|
+
else mapping[ch.id] = { ref };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// MQTT: one connection per distinct config (dedup by full content — same
|
|
283
|
+
// broker, different creds is a different resource). No index.
|
|
284
|
+
for (const ch of req.mqttChannels) {
|
|
285
|
+
const b = inputs.mqtt[ch.id];
|
|
286
|
+
if (!b) throw new Error(`unbound mqtt channel ${ch.id}`); // unreachable
|
|
287
|
+
const conn: EngineSchemas["MQTTConnection"] = { type: "mqtt", brokerUrl: b.brokerUrl };
|
|
288
|
+
if (b.username) conn.username = b.username;
|
|
289
|
+
// The password is a secret — kept out of conn (and thus the spec). It still
|
|
290
|
+
// participates in the dedup key, so two channels differing only by password
|
|
291
|
+
// don't collapse onto one ref (and one shared secret).
|
|
292
|
+
const ref = refs.alloc(`mqtt:${JSON.stringify(conn)}:${b.password ?? ""}`, `mqtt-${brokerHost(b.brokerUrl)}`);
|
|
293
|
+
externalResources[ref] = conn;
|
|
294
|
+
mapping[ch.id] = { ref };
|
|
295
|
+
if (b.password) resourceSecrets[ref] = { ...resourceSecrets[ref], password: b.password };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Custom models: one self-hosted provider per model id. A device model points
|
|
299
|
+
// at the sidecar we run (over the container network, no key) and gets its own
|
|
300
|
+
// llama-server component; a network model points at the operator's endpoint and
|
|
301
|
+
// runs no component here. `model` is left off — the endpoint serves under the
|
|
302
|
+
// workflow id (no upstream-name aliasing yet).
|
|
303
|
+
const llamaComponents: DeployComponent[] = [];
|
|
304
|
+
for (const m of req.customModels) {
|
|
305
|
+
const b = inputs.models[m.id];
|
|
306
|
+
if (!b) throw new Error(`unbound model ${m.id}`); // unreachable after assertDeployable
|
|
307
|
+
const ref = refs.alloc(`model:${m.id}`, basename(m.id));
|
|
308
|
+
mapping[m.id] = { ref };
|
|
309
|
+
|
|
310
|
+
if (b.location === "device") {
|
|
311
|
+
const port = b.port ?? 8080;
|
|
312
|
+
const service = sidecarServiceName(m.id);
|
|
313
|
+
externalResources[ref] = { type: "selfhosted", url: `http://${service}:${port}` };
|
|
314
|
+
// The whole config is CLI flags, so it rides in `command` (no config blob).
|
|
315
|
+
// ctx-size is frozen here — retuning it is a re-deploy, not an env edit.
|
|
316
|
+
llamaComponents.push({
|
|
317
|
+
name: service,
|
|
318
|
+
image: meta.llamaServerImage,
|
|
319
|
+
command: [
|
|
320
|
+
"--model",
|
|
321
|
+
`/models/${b.modelFile}`,
|
|
322
|
+
"--host",
|
|
323
|
+
"0.0.0.0",
|
|
324
|
+
"--port",
|
|
325
|
+
String(port),
|
|
326
|
+
"--ctx-size",
|
|
327
|
+
String(b.ctxSize ?? 4096),
|
|
328
|
+
],
|
|
329
|
+
volumes: ["./models:/models:ro"],
|
|
330
|
+
});
|
|
331
|
+
} else {
|
|
332
|
+
externalResources[ref] = { type: "selfhosted", url: b.url };
|
|
333
|
+
// The endpoint bearer is a secret — out of the spec, returned separately.
|
|
334
|
+
if (b.apiKey) resourceSecrets[ref] = { apiKey: b.apiKey };
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const manifest: EngineSchemas["DeviceManifest"] = {};
|
|
339
|
+
if (Object.keys(gpios).length) manifest.gpios = gpios;
|
|
340
|
+
if (Object.keys(adcs).length) manifest.adcs = adcs;
|
|
341
|
+
if (Object.keys(dacs).length) manifest.dacs = dacs;
|
|
342
|
+
if (Object.keys(pwms).length) manifest.pwms = pwms;
|
|
343
|
+
if (Object.keys(serials).length) manifest.serials = serials;
|
|
344
|
+
|
|
345
|
+
// The engine's boot input. workflow is serialized (and thereby pruned) from
|
|
346
|
+
// the domain. The optional sections attach only when non-empty — an absent
|
|
347
|
+
// section is correct, not a gap, and matches what the engine skips.
|
|
348
|
+
const config: EngineConfig = { workflow: serialize(workflow) };
|
|
349
|
+
if (Object.keys(mapping).length) config.mapping = mapping;
|
|
350
|
+
if (Object.keys(externalResources).length) config.externalResources = externalResources;
|
|
351
|
+
if (Object.keys(manifest).length) config.manifest = manifest;
|
|
352
|
+
|
|
353
|
+
// The engine component. configPath is omitted (the image reads the convention
|
|
354
|
+
// path it defaults to); the config blob is mounted there by the renderer. cdev
|
|
355
|
+
// nodes pass through as devices; sysfs families force privileged; either case
|
|
356
|
+
// needs root to open the root-owned nodes the nonroot image otherwise can't.
|
|
357
|
+
const engine: DeployComponent = {
|
|
358
|
+
name: "engine",
|
|
359
|
+
image: meta.engineImage,
|
|
360
|
+
pull: "never", // built locally before deploy, in no registry
|
|
361
|
+
config,
|
|
362
|
+
volumes: [`engine-memory:${ENGINE_MEMORY_PATH}`],
|
|
363
|
+
};
|
|
364
|
+
if (cdev.size > 0) engine.devices = [...cdev];
|
|
365
|
+
if (privileged) engine.privileged = true;
|
|
366
|
+
if (cdev.size > 0 || privileged) engine.user = "0:0";
|
|
367
|
+
|
|
368
|
+
const components = [engine, ...llamaComponents, ...customComponents];
|
|
369
|
+
assertNoNameCollisions(components);
|
|
370
|
+
|
|
371
|
+
const spec: DeploymentSpec = {
|
|
372
|
+
schemaVersion: 1,
|
|
373
|
+
id: meta.id,
|
|
374
|
+
status: meta.status,
|
|
375
|
+
components,
|
|
376
|
+
};
|
|
377
|
+
if (meta.createdAt) spec.createdAt = meta.createdAt;
|
|
378
|
+
return { spec, resourceSecrets };
|
|
379
|
+
}
|