@foresthubai/workflow-cli 0.4.4 → 0.4.6
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/dist/assets/index-DY0leyGX.js +75 -0
- package/dist/index.html +1 -1
- package/dist-cli/cli.js +140 -67
- package/dist-cli/deployment.yaml +43 -21
- package/dist-cli/llmproxy.yaml +5 -2
- package/dist-cli/workflow.yaml +13 -12
- package/package.json +1 -1
- package/dist/assets/index-D7a-pqL0.js +0 -75
package/dist/index.html
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<link rel="icon" href="/favicon.png" type="image/png" />
|
|
7
7
|
<title>Edge Agents Builder</title>
|
|
8
|
-
<script type="module" crossorigin src="/assets/index-
|
|
8
|
+
<script type="module" crossorigin src="/assets/index-DY0leyGX.js"></script>
|
|
9
9
|
<link rel="stylesheet" crossorigin href="/assets/index-CiLOHdeR.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body class="bg-background text-foreground">
|
package/dist-cli/cli.js
CHANGED
|
@@ -12782,9 +12782,29 @@ function isAllowed(target, roots) {
|
|
|
12782
12782
|
return rel === "" || !rel.startsWith("..") && !path.isAbsolute(rel);
|
|
12783
12783
|
});
|
|
12784
12784
|
}
|
|
12785
|
+
function isLoopbackHost(host) {
|
|
12786
|
+
if (!host) return false;
|
|
12787
|
+
const name = host.startsWith("[") ? host.slice(0, host.indexOf("]") + 1) : host.replace(/:\d+$/, "");
|
|
12788
|
+
return name === "localhost" || name === "127.0.0.1" || name === "[::1]";
|
|
12789
|
+
}
|
|
12790
|
+
function isTrustedRequest(req) {
|
|
12791
|
+
if (!isLoopbackHost(req.headers.host)) return false;
|
|
12792
|
+
const origin = req.headers.origin;
|
|
12793
|
+
if (origin === void 0) return true;
|
|
12794
|
+
try {
|
|
12795
|
+
return isLoopbackHost(new URL(origin).host);
|
|
12796
|
+
} catch {
|
|
12797
|
+
return false;
|
|
12798
|
+
}
|
|
12799
|
+
}
|
|
12785
12800
|
async function handleFileRequest(req, res, allowedRoots) {
|
|
12786
12801
|
const url2 = new URL(req.url ?? "", "http://localhost");
|
|
12787
12802
|
if (url2.pathname !== "/api/file") return false;
|
|
12803
|
+
if (!isTrustedRequest(req)) {
|
|
12804
|
+
res.statusCode = 403;
|
|
12805
|
+
res.end("forbidden: non-local request");
|
|
12806
|
+
return true;
|
|
12807
|
+
}
|
|
12788
12808
|
try {
|
|
12789
12809
|
const filePath = url2.searchParams.get("path");
|
|
12790
12810
|
if (!filePath) {
|
|
@@ -12810,7 +12830,7 @@ async function handleFileRequest(req, res, allowedRoots) {
|
|
|
12810
12830
|
}
|
|
12811
12831
|
return true;
|
|
12812
12832
|
}
|
|
12813
|
-
if (req.method === "PUT"
|
|
12833
|
+
if (req.method === "PUT") {
|
|
12814
12834
|
const chunks = [];
|
|
12815
12835
|
for await (const chunk of req) chunks.push(chunk);
|
|
12816
12836
|
const body = Buffer.concat(chunks).toString("utf-8");
|
|
@@ -14495,6 +14515,9 @@ function buildFunctionNodeDef(fn, t = (key) => key) {
|
|
|
14495
14515
|
}
|
|
14496
14516
|
|
|
14497
14517
|
// ../workflow-core/src/node/serialization.ts
|
|
14518
|
+
function draft(value) {
|
|
14519
|
+
return value;
|
|
14520
|
+
}
|
|
14498
14521
|
function serialize6(node, isToolInput) {
|
|
14499
14522
|
const result = serializeNodeData(node, node.position, isToolInput);
|
|
14500
14523
|
if (node.label) {
|
|
@@ -14505,6 +14528,10 @@ function serialize6(node, isToolInput) {
|
|
|
14505
14528
|
if (def) {
|
|
14506
14529
|
pruneArguments(result.arguments, def.parameters, isToolInput);
|
|
14507
14530
|
}
|
|
14531
|
+
const args2 = result.arguments;
|
|
14532
|
+
for (const key of Object.keys(args2)) {
|
|
14533
|
+
if (args2[key] === void 0) delete args2[key];
|
|
14534
|
+
}
|
|
14508
14535
|
}
|
|
14509
14536
|
return result;
|
|
14510
14537
|
}
|
|
@@ -14516,7 +14543,7 @@ function serializeNodeData(data, position, isToolInput) {
|
|
|
14516
14543
|
type: data.type,
|
|
14517
14544
|
position,
|
|
14518
14545
|
arguments: {
|
|
14519
|
-
pinReference: data.arguments.pinReference,
|
|
14546
|
+
pinReference: draft(data.arguments.pinReference),
|
|
14520
14547
|
signalType: data.arguments.signalType,
|
|
14521
14548
|
output: data.arguments.output,
|
|
14522
14549
|
toolDescription: data.arguments.toolDescription
|
|
@@ -14528,7 +14555,7 @@ function serializeNodeData(data, position, isToolInput) {
|
|
|
14528
14555
|
type: data.type,
|
|
14529
14556
|
position,
|
|
14530
14557
|
arguments: {
|
|
14531
|
-
portReference: data.arguments.portReference,
|
|
14558
|
+
portReference: draft(data.arguments.portReference),
|
|
14532
14559
|
...data.arguments.prompt !== void 0 ? { prompt: data.arguments.prompt } : {},
|
|
14533
14560
|
output: data.arguments.output
|
|
14534
14561
|
}
|
|
@@ -14539,7 +14566,7 @@ function serializeNodeData(data, position, isToolInput) {
|
|
|
14539
14566
|
type: data.type,
|
|
14540
14567
|
position,
|
|
14541
14568
|
arguments: {
|
|
14542
|
-
pinReference: data.arguments.pinReference,
|
|
14569
|
+
pinReference: draft(data.arguments.pinReference),
|
|
14543
14570
|
signalType: data.arguments.signalType,
|
|
14544
14571
|
value: data.arguments.value
|
|
14545
14572
|
}
|
|
@@ -14550,7 +14577,7 @@ function serializeNodeData(data, position, isToolInput) {
|
|
|
14550
14577
|
type: data.type,
|
|
14551
14578
|
position,
|
|
14552
14579
|
arguments: {
|
|
14553
|
-
portReference: data.arguments.portReference,
|
|
14580
|
+
portReference: draft(data.arguments.portReference),
|
|
14554
14581
|
value: data.arguments.value
|
|
14555
14582
|
}
|
|
14556
14583
|
};
|
|
@@ -14598,7 +14625,7 @@ function serializeNodeData(data, position, isToolInput) {
|
|
|
14598
14625
|
type: data.type,
|
|
14599
14626
|
position,
|
|
14600
14627
|
arguments: {
|
|
14601
|
-
pinReference: data.arguments.pinReference,
|
|
14628
|
+
pinReference: draft(data.arguments.pinReference),
|
|
14602
14629
|
edge: data.arguments.edge
|
|
14603
14630
|
}
|
|
14604
14631
|
};
|
|
@@ -14608,7 +14635,7 @@ function serializeNodeData(data, position, isToolInput) {
|
|
|
14608
14635
|
type: data.type,
|
|
14609
14636
|
position,
|
|
14610
14637
|
arguments: {
|
|
14611
|
-
portReference: data.arguments.portReference,
|
|
14638
|
+
portReference: draft(data.arguments.portReference),
|
|
14612
14639
|
output: data.arguments.output
|
|
14613
14640
|
}
|
|
14614
14641
|
};
|
|
@@ -14618,8 +14645,8 @@ function serializeNodeData(data, position, isToolInput) {
|
|
|
14618
14645
|
type: data.type,
|
|
14619
14646
|
position,
|
|
14620
14647
|
arguments: {
|
|
14621
|
-
variable: data.arguments.variable,
|
|
14622
|
-
threshold: data.arguments.threshold,
|
|
14648
|
+
variable: draft(data.arguments.variable),
|
|
14649
|
+
threshold: draft(data.arguments.threshold),
|
|
14623
14650
|
direction: data.arguments.direction,
|
|
14624
14651
|
deadband: data.arguments.deadband,
|
|
14625
14652
|
output: data.arguments.output
|
|
@@ -14631,7 +14658,7 @@ function serializeNodeData(data, position, isToolInput) {
|
|
|
14631
14658
|
type: data.type,
|
|
14632
14659
|
position,
|
|
14633
14660
|
arguments: {
|
|
14634
|
-
delayMs: data.arguments.delayMs
|
|
14661
|
+
delayMs: draft(data.arguments.delayMs)
|
|
14635
14662
|
}
|
|
14636
14663
|
};
|
|
14637
14664
|
case "Ticker":
|
|
@@ -14640,7 +14667,7 @@ function serializeNodeData(data, position, isToolInput) {
|
|
|
14640
14667
|
type: data.type,
|
|
14641
14668
|
position,
|
|
14642
14669
|
arguments: {
|
|
14643
|
-
intervalValue: data.arguments.intervalValue,
|
|
14670
|
+
intervalValue: draft(data.arguments.intervalValue),
|
|
14644
14671
|
intervalUnit: data.arguments.intervalUnit
|
|
14645
14672
|
}
|
|
14646
14673
|
};
|
|
@@ -14670,7 +14697,7 @@ function serializeNodeData(data, position, isToolInput) {
|
|
|
14670
14697
|
position,
|
|
14671
14698
|
arguments: {
|
|
14672
14699
|
memoryReference: data.arguments.memoryReference,
|
|
14673
|
-
topK: data.arguments.topK,
|
|
14700
|
+
topK: draft(data.arguments.topK),
|
|
14674
14701
|
query: data.arguments.query,
|
|
14675
14702
|
output: data.arguments.output,
|
|
14676
14703
|
toolDescription: data.arguments.toolDescription
|
|
@@ -14720,7 +14747,7 @@ function serializeNodeData(data, position, isToolInput) {
|
|
|
14720
14747
|
type: data.type,
|
|
14721
14748
|
position,
|
|
14722
14749
|
arguments: {
|
|
14723
|
-
variable: data.arguments.variable,
|
|
14750
|
+
variable: draft(data.arguments.variable),
|
|
14724
14751
|
value: data.arguments.value
|
|
14725
14752
|
}
|
|
14726
14753
|
};
|
|
@@ -14733,7 +14760,8 @@ function serializeNodeData(data, position, isToolInput) {
|
|
|
14733
14760
|
channelReference: data.arguments.channelReference ?? "",
|
|
14734
14761
|
dataType: data.arguments.dataType,
|
|
14735
14762
|
value: data.arguments.value,
|
|
14736
|
-
|
|
14763
|
+
// Unset stays unset — Number(undefined) is NaN, which stringifies to null.
|
|
14764
|
+
qos: draft(data.arguments.qos === void 0 ? void 0 : Number(data.arguments.qos)),
|
|
14737
14765
|
retain: data.arguments.retain
|
|
14738
14766
|
}
|
|
14739
14767
|
};
|
|
@@ -17027,12 +17055,21 @@ function printReport(file2, result) {
|
|
|
17027
17055
|
`);
|
|
17028
17056
|
for (const d of result.memoryDiagnostics) printDiagnostic(d, " ");
|
|
17029
17057
|
}
|
|
17058
|
+
if (result.modelDiagnostics.length > 0) {
|
|
17059
|
+
out.write(`
|
|
17060
|
+
models:
|
|
17061
|
+
`);
|
|
17062
|
+
for (const d of result.modelDiagnostics) printDiagnostic(d, " ");
|
|
17063
|
+
}
|
|
17030
17064
|
}
|
|
17031
17065
|
function printDiagnostic(d, indent) {
|
|
17032
17066
|
const where = [];
|
|
17033
17067
|
if (d.nodeId) where.push(`node ${d.nodeId}`);
|
|
17034
17068
|
if (d.edgeId) where.push(`edge ${d.edgeId}`);
|
|
17035
17069
|
if (d.channelId) where.push(`channel ${d.channelId}`);
|
|
17070
|
+
if (d.memoryId) where.push(`memory ${d.memoryId}`);
|
|
17071
|
+
if (d.modelId) where.push(`model ${d.modelId}`);
|
|
17072
|
+
if (d.functionId) where.push(`function ${d.functionId}`);
|
|
17036
17073
|
if (d.paramId) where.push(`param ${d.paramId}`);
|
|
17037
17074
|
if (d.outputId) where.push(`output ${d.outputId}`);
|
|
17038
17075
|
const location = where.length > 0 ? ` (${where.join(", ")})` : "";
|
|
@@ -19593,7 +19630,11 @@ function deriveRequirements(workflow) {
|
|
|
19593
19630
|
}
|
|
19594
19631
|
|
|
19595
19632
|
// ../workflow-core/src/deploy/spec.ts
|
|
19596
|
-
var
|
|
19633
|
+
var COMPONENT_WORKSPACE_PATH = "/var/lib/foresthub/workspace";
|
|
19634
|
+
var STATE_ROOT = ".";
|
|
19635
|
+
function workspaceDir(container) {
|
|
19636
|
+
return `${STATE_ROOT}/workspaces/${container}`;
|
|
19637
|
+
}
|
|
19597
19638
|
var RefAllocator = class {
|
|
19598
19639
|
byKey = /* @__PURE__ */ new Map();
|
|
19599
19640
|
used = /* @__PURE__ */ new Set();
|
|
@@ -19626,7 +19667,7 @@ function ggufNameError(name) {
|
|
|
19626
19667
|
const t = (name ?? "").trim();
|
|
19627
19668
|
if (!t) return "a model filename is required";
|
|
19628
19669
|
if (!t.toLowerCase().endsWith(".gguf")) return "must be a .gguf file (llama-server only loads GGUF)";
|
|
19629
|
-
if (t.includes("/")) return "just the filename, not a path \u2014 the file goes in ./
|
|
19670
|
+
if (t.includes("/")) return "just the filename, not a path \u2014 the file goes in the model's workspace dir (./workspaces/llama-\u2026/)";
|
|
19630
19671
|
return null;
|
|
19631
19672
|
}
|
|
19632
19673
|
function hardwareAddressKey(family, chipOrDevice, index) {
|
|
@@ -19758,7 +19799,7 @@ function buildDeploymentSpec(workflow, inputs, meta3, customComponents = []) {
|
|
|
19758
19799
|
const ref = refs.alloc(`mqtt:${JSON.stringify(conn)}:${b.password ?? ""}`, `mqtt-${brokerHost(b.brokerUrl)}`);
|
|
19759
19800
|
externalResources[ref] = conn;
|
|
19760
19801
|
mapping[ch.id] = { ref };
|
|
19761
|
-
if (b.password) resourceSecrets[ref] =
|
|
19802
|
+
if (b.password) resourceSecrets[ref] = b.password;
|
|
19762
19803
|
}
|
|
19763
19804
|
const llamaComponents = [];
|
|
19764
19805
|
for (const m of req.customModels) {
|
|
@@ -19775,7 +19816,7 @@ function buildDeploymentSpec(workflow, inputs, meta3, customComponents = []) {
|
|
|
19775
19816
|
image: meta3.llamaServerImage,
|
|
19776
19817
|
command: [
|
|
19777
19818
|
"--model",
|
|
19778
|
-
|
|
19819
|
+
`${COMPONENT_WORKSPACE_PATH}/${b.modelFile}`,
|
|
19779
19820
|
"--host",
|
|
19780
19821
|
"0.0.0.0",
|
|
19781
19822
|
"--port",
|
|
@@ -19783,11 +19824,13 @@ function buildDeploymentSpec(workflow, inputs, meta3, customComponents = []) {
|
|
|
19783
19824
|
"--ctx-size",
|
|
19784
19825
|
String(b.ctxSize ?? 4096)
|
|
19785
19826
|
],
|
|
19786
|
-
|
|
19827
|
+
// The model lives in this sidecar's own workspace; read-only, so no chown
|
|
19828
|
+
// is needed (docs §5). The operator drops the GGUF in workspaceDir(service).
|
|
19829
|
+
volumes: [`${workspaceDir(service)}:${COMPONENT_WORKSPACE_PATH}:ro`]
|
|
19787
19830
|
});
|
|
19788
19831
|
} else {
|
|
19789
19832
|
externalResources[ref] = { type: "selfhosted", url: b.url };
|
|
19790
|
-
if (b.apiKey) resourceSecrets[ref] =
|
|
19833
|
+
if (b.apiKey) resourceSecrets[ref] = b.apiKey;
|
|
19791
19834
|
}
|
|
19792
19835
|
}
|
|
19793
19836
|
const manifest = {};
|
|
@@ -19806,17 +19849,21 @@ function buildDeploymentSpec(workflow, inputs, meta3, customComponents = []) {
|
|
|
19806
19849
|
pull: "never",
|
|
19807
19850
|
// built locally before deploy, in no registry
|
|
19808
19851
|
config: config2,
|
|
19809
|
-
|
|
19852
|
+
// Durable memory: a host bind mount under the state root, read-write, at the
|
|
19853
|
+
// in-container workspace path (docs/device-filesystem.md §10).
|
|
19854
|
+
volumes: [`${workspaceDir("engine")}:${COMPONENT_WORKSPACE_PATH}`],
|
|
19855
|
+
// Run as root: writes the rw workspace bind mount without a host chown step
|
|
19856
|
+
// (the OSS default, §5), and also opens root-owned device nodes when hardware
|
|
19857
|
+
// (cdev passthrough / sysfs-privileged) is mapped in below.
|
|
19858
|
+
user: "0:0"
|
|
19810
19859
|
};
|
|
19811
19860
|
if (cdev.size > 0) engine.devices = [...cdev];
|
|
19812
19861
|
if (privileged) engine.privileged = true;
|
|
19813
|
-
if (cdev.size > 0 || privileged) engine.user = "0:0";
|
|
19814
19862
|
const components = [engine, ...llamaComponents, ...customComponents];
|
|
19815
19863
|
assertNoNameCollisions(components);
|
|
19816
19864
|
const spec = {
|
|
19817
19865
|
schemaVersion: 1,
|
|
19818
19866
|
id: meta3.id,
|
|
19819
|
-
status: meta3.status,
|
|
19820
19867
|
components
|
|
19821
19868
|
};
|
|
19822
19869
|
if (meta3.createdAt) spec.createdAt = meta3.createdAt;
|
|
@@ -36714,7 +36761,7 @@ async function promptModels(models, seed) {
|
|
|
36714
36761
|
});
|
|
36715
36762
|
if (location === "device") {
|
|
36716
36763
|
const modelFile = await dist_default6({
|
|
36717
|
-
message: `${m.label}: model filename in
|
|
36764
|
+
message: `${m.label}: model filename, dropped in its workspace dir (e.g. model.gguf)`,
|
|
36718
36765
|
validate: (v) => ggufNameError(v) ?? true
|
|
36719
36766
|
});
|
|
36720
36767
|
const ctxSize = await dist_default6({ message: `${m.label}: context window in tokens`, default: "4096", validate: isUint });
|
|
@@ -36849,27 +36896,42 @@ import path11 from "node:path";
|
|
|
36849
36896
|
|
|
36850
36897
|
// cli/deploy/generate.ts
|
|
36851
36898
|
import { createHash } from "node:crypto";
|
|
36852
|
-
var
|
|
36899
|
+
var CONFIG_PATH = "/etc/foresthub/config.json";
|
|
36900
|
+
var SECRETS_PATH = "/etc/foresthub/secrets.json";
|
|
36853
36901
|
function configFileName(name) {
|
|
36854
36902
|
return `${name}-config.json`;
|
|
36855
36903
|
}
|
|
36904
|
+
function secretsFileName(name) {
|
|
36905
|
+
return `${name}-secrets.json`;
|
|
36906
|
+
}
|
|
36856
36907
|
function namedVolumeSource(mount) {
|
|
36857
36908
|
const src = mount.split(":")[0] ?? "";
|
|
36858
36909
|
return src && !src.startsWith(".") && !src.startsWith("/") ? src : null;
|
|
36859
36910
|
}
|
|
36860
|
-
function serviceBlock(c) {
|
|
36911
|
+
function serviceBlock(c, secretDoc) {
|
|
36912
|
+
const hasSecretDoc = secretDoc !== void 0 && Object.keys(secretDoc).length > 0;
|
|
36861
36913
|
const lines = [` ${c.name}:`, ` image: ${c.image}`, ` pull_policy: ${c.pull ?? "missing"}`, " restart: unless-stopped"];
|
|
36914
|
+
const labels = [];
|
|
36862
36915
|
if (c.config !== void 0) {
|
|
36863
36916
|
const hash2 = createHash("sha256").update(JSON.stringify(c.config)).digest("hex");
|
|
36864
|
-
|
|
36917
|
+
labels.push(` com.foresthub.config-hash: "${hash2}"`);
|
|
36865
36918
|
}
|
|
36919
|
+
if (hasSecretDoc) {
|
|
36920
|
+
const hash2 = createHash("sha256").update(JSON.stringify(secretDoc)).digest("hex");
|
|
36921
|
+
labels.push(` com.foresthub.secrets-hash: "${hash2}"`);
|
|
36922
|
+
}
|
|
36923
|
+
if (labels.length > 0) lines.push(" labels:", ...labels);
|
|
36866
36924
|
if (c.user) lines.push(` user: "${c.user}"`);
|
|
36867
36925
|
if (c.command && c.command.length > 0) {
|
|
36868
36926
|
lines.push(" command:");
|
|
36869
36927
|
for (const arg of c.command) lines.push(` - "${arg}"`);
|
|
36870
36928
|
}
|
|
36871
36929
|
lines.push(" env_file:", ` - path: ${c.name}.env`, " required: false");
|
|
36872
|
-
const volumes = [
|
|
36930
|
+
const volumes = [
|
|
36931
|
+
...c.config !== void 0 ? [`./${configFileName(c.name)}:${CONFIG_PATH}:ro`] : [],
|
|
36932
|
+
...hasSecretDoc ? [`./${secretsFileName(c.name)}:${SECRETS_PATH}:ro`] : [],
|
|
36933
|
+
...c.volumes ?? []
|
|
36934
|
+
];
|
|
36873
36935
|
if (volumes.length > 0) {
|
|
36874
36936
|
lines.push(" volumes:");
|
|
36875
36937
|
for (const v of volumes) lines.push(` - ${v}`);
|
|
@@ -36891,8 +36953,8 @@ function serviceBlock(c) {
|
|
|
36891
36953
|
}
|
|
36892
36954
|
return lines.join("\n");
|
|
36893
36955
|
}
|
|
36894
|
-
function composeYaml(spec) {
|
|
36895
|
-
const services = spec.components.map(serviceBlock).join("\n\n");
|
|
36956
|
+
function composeYaml(spec, secretDocs = {}) {
|
|
36957
|
+
const services = spec.components.map((c) => serviceBlock(c, secretDocs[c.name])).join("\n\n");
|
|
36896
36958
|
const named = [...new Set(spec.components.flatMap((c) => (c.volumes ?? []).map(namedVolumeSource).filter((s) => s !== null)))];
|
|
36897
36959
|
const volumesBlock = named.length > 0 ? `
|
|
36898
36960
|
volumes:
|
|
@@ -36906,7 +36968,7 @@ services:
|
|
|
36906
36968
|
${services}
|
|
36907
36969
|
${volumesBlock}`;
|
|
36908
36970
|
}
|
|
36909
|
-
function envFile(cfg
|
|
36971
|
+
function envFile(cfg) {
|
|
36910
36972
|
const localProviders = ALL_PROVIDERS.filter((p) => Boolean(cfg.llmKeys[p]));
|
|
36911
36973
|
const providerSection = localProviders.length === 0 ? "" : `# ----- LLM provider keys -----
|
|
36912
36974
|
# Each key here = that provider runs locally with your API key.
|
|
@@ -36917,25 +36979,23 @@ ${localProviders.map((p) => `${p.toUpperCase()}_API_KEY=${cfg.llmKeys[p] ?? ""}`
|
|
|
36917
36979
|
ENGINE_WEB_SEARCH_PROVIDER=${cfg.webSearch.provider}
|
|
36918
36980
|
ENGINE_WEB_SEARCH_API_KEY=${cfg.webSearch.apiKey}
|
|
36919
36981
|
|
|
36920
|
-
` : "";
|
|
36921
|
-
const resourceSecretsSection = Object.keys(resourceSecrets).length > 0 ? `# ----- External-resource secrets -----
|
|
36922
|
-
FH_RESOURCE_SECRETS=${JSON.stringify(resourceSecrets)}
|
|
36923
|
-
|
|
36924
36982
|
` : "";
|
|
36925
36983
|
return `# Edge Agents engine \u2014 operator configuration.
|
|
36926
36984
|
# Auto-generated by \`fh-workflow deploy\`. Loaded into the engine via the compose
|
|
36927
36985
|
# \`env_file\`. Secret values: chmod 600 this file after editing.
|
|
36928
36986
|
|
|
36929
|
-
${providerSection}${webSearchSection}
|
|
36987
|
+
${providerSection}${webSearchSection}# ----- Runtime -----
|
|
36930
36988
|
ENGINE_LOG_LEVEL=${cfg.logLevel} # debug | info | warn | error
|
|
36931
36989
|
`;
|
|
36932
36990
|
}
|
|
36933
|
-
function readme(spec, cfg, hasProviderModel) {
|
|
36991
|
+
function readme(spec, cfg, hasProviderModel, hasSecrets = false) {
|
|
36934
36992
|
const localProviders = ALL_PROVIDERS.filter((p) => Boolean(cfg.llmKeys[p]));
|
|
36935
36993
|
const providerBlock = localProviders.length === 0 ? "_No local API keys set._ An Agent that uses a catalog model will fail at build \u2014 set the matching key in `engine.env`." : localProviders.map((p) => `- **${p}** \u2014 runs locally (your API key)`).join("\n");
|
|
36936
36994
|
const engine = spec.components.find((c) => c.name === "engine");
|
|
36937
36995
|
const hasHardware = (engine?.devices?.length ?? 0) > 0 || Boolean(engine?.privileged);
|
|
36938
|
-
const
|
|
36996
|
+
const deviceModels = Object.entries(cfg.models).flatMap(
|
|
36997
|
+
([id, b]) => b.location === "device" ? [{ dir: `./workspaces/${sidecarServiceName(id)}`, file: b.modelFile }] : []
|
|
36998
|
+
);
|
|
36939
36999
|
const hasNetworkModel = Object.values(cfg.models).some((b) => b.location === "network");
|
|
36940
37000
|
const hasMqtt = Object.keys(cfg.mqtt).length > 0;
|
|
36941
37001
|
const hasExternalService = hasMqtt || hasNetworkModel;
|
|
@@ -36959,19 +37019,20 @@ and tighten it to your security policy before deploying.`);
|
|
|
36959
37019
|
if (hasExternalService) {
|
|
36960
37020
|
notes.push(`## External resources
|
|
36961
37021
|
|
|
36962
|
-
The broker/endpoint credentials the engine connects with live in
|
|
36963
|
-
|
|
36964
|
-
|
|
36965
|
-
|
|
37022
|
+
The broker/endpoint credentials the engine connects with live in the mounted secret
|
|
37023
|
+
document \`engine-secrets.json\` (a JSON map of resource id -> secret value, mounted
|
|
37024
|
+
read-only at \`/etc/foresthub/secrets.json\`) \u2014 keep it \`chmod 600\`. This
|
|
37025
|
+
bundle does not start those services; it assumes the broker/endpoint already exists
|
|
37026
|
+
and is reachable from the controller.`);
|
|
36966
37027
|
}
|
|
36967
|
-
if (
|
|
37028
|
+
if (deviceModels.length > 0) {
|
|
36968
37029
|
notes.push(`## On-device models
|
|
36969
37030
|
|
|
36970
37031
|
This bundle runs a llama-server container per on-device model; the engine reaches it
|
|
36971
|
-
over the compose network by service name.
|
|
36972
|
-
|
|
36973
|
-
|
|
36974
|
-
${
|
|
37032
|
+
over the compose network by service name. Each GGUF must sit in that model's workspace
|
|
37033
|
+
dir below (read-only mounted into its container). They are too large for the main
|
|
37034
|
+
\`scp\` line, so step 3 transfers them separately:
|
|
37035
|
+
${deviceModels.map((m) => `- \`${m.dir}/${m.file}\``).join("\n")}`);
|
|
36975
37036
|
}
|
|
36976
37037
|
if (hasNetworkModel) {
|
|
36977
37038
|
notes.push(`## Network models
|
|
@@ -36985,13 +37046,13 @@ Ollama, ...) on another machine. This bundle does not start that server for you.
|
|
|
36985
37046
|
The web-search API key is in \`engine.env\` (\`ENGINE_WEB_SEARCH_API_KEY\`).`);
|
|
36986
37047
|
}
|
|
36987
37048
|
const notesBlock = notes.length ? "\n" + notes.join("\n\n") + "\n" : "";
|
|
36988
|
-
const modelsTransfer =
|
|
37049
|
+
const modelsTransfer = deviceModels.length ? `
|
|
36989
37050
|
|
|
36990
37051
|
# On-device model weights \u2014 too large for the line above (GGUF can be several GB).
|
|
36991
|
-
#
|
|
36992
|
-
scp -r
|
|
36993
|
-
# ...or download them directly into ~/fh-engine/
|
|
36994
|
-
const runBlock =
|
|
37052
|
+
# Put each GGUF in its model's workspace dir, then copy the workspaces tree:
|
|
37053
|
+
scp -r workspaces/ $CONTROLLER_USER@$CONTROLLER_ADDR:~/fh-engine/
|
|
37054
|
+
# ...or download them directly into ~/fh-engine/workspaces/<model>/ on the controller.` : "";
|
|
37055
|
+
const runBlock = deviceModels.length ? `ssh $CONTROLLER_USER@$CONTROLLER_ADDR 'cd ~/fh-engine && docker load -i fh-engine.tar'
|
|
36995
37056
|
ssh $CONTROLLER_USER@$CONTROLLER_ADDR 'cd ~/fh-engine && docker compose up -d'
|
|
36996
37057
|
|
|
36997
37058
|
# The engine and the llama-server sidecar start independently; the engine reaches the
|
|
@@ -37010,7 +37071,7 @@ the workflow from \`engine-config.json\` and runs it autonomously:
|
|
|
37010
37071
|
- \`engine-config.json\` \u2014 the engine's single boot config (workflow + bindings + device manifest)
|
|
37011
37072
|
- \`deployment-spec.json\` \u2014 the full resolved deployment spec (deployment record)
|
|
37012
37073
|
- \`engine.env\` \u2014 operator configuration loaded into the engine (already filled in, \`chmod 600\` it)
|
|
37013
|
-
- \`fh-engine.tar\` \u2014 image tarball (you build this in step 1 below)
|
|
37074
|
+
${hasSecrets ? "- `engine-secrets.json` \u2014 resource credentials, mounted read-only at `/etc/foresthub/secrets.json` (already filled in, `chmod 600` it)\n" : ""}- \`fh-engine.tar\` \u2014 image tarball (you build this in step 1 below)
|
|
37014
37075
|
${notesBlock}
|
|
37015
37076
|
## 1. Build the image (on the dev machine)
|
|
37016
37077
|
|
|
@@ -37047,7 +37108,7 @@ docker save fh-engine:latest -o ../path/to/this/bundle/fh-engine.tar
|
|
|
37047
37108
|
ssh $CONTROLLER_USER@$CONTROLLER_ADDR 'mkdir -p ~/fh-engine'
|
|
37048
37109
|
|
|
37049
37110
|
cd path/to/this/bundle
|
|
37050
|
-
scp fh-engine.tar docker-compose.yml engine-config.json deployment-spec.json engine.env \\
|
|
37111
|
+
scp fh-engine.tar docker-compose.yml engine-config.json deployment-spec.json engine.env ${hasSecrets ? "engine-secrets.json " : ""}\\
|
|
37051
37112
|
$CONTROLLER_USER@$CONTROLLER_ADDR:~/fh-engine/${modelsTransfer}
|
|
37052
37113
|
\`\`\`
|
|
37053
37114
|
|
|
@@ -37057,14 +37118,18 @@ scp fh-engine.tar docker-compose.yml engine-config.json deployment-spec.json eng
|
|
|
37057
37118
|
${runBlock}
|
|
37058
37119
|
\`\`\`
|
|
37059
37120
|
|
|
37060
|
-
## Agent memory
|
|
37121
|
+
## Agent memory & durable data
|
|
37122
|
+
|
|
37123
|
+
Each container's durable data is a plain host directory in this bundle,
|
|
37124
|
+
\`./workspaces/<container>/\` (mounted read-write at \`/var/lib/foresthub/workspace\`):
|
|
37061
37125
|
|
|
37062
|
-
|
|
37063
|
-
|
|
37064
|
-
|
|
37065
|
-
|
|
37066
|
-
|
|
37067
|
-
|
|
37126
|
+
- \`./workspaces/engine/\` \u2014 the engine's memory files.
|
|
37127
|
+
- \`./workspaces/<model>/\` \u2014 an on-device model's GGUF weights (see above).
|
|
37128
|
+
|
|
37129
|
+
These are ordinary files: back them up by copying the folder, inspect them directly.
|
|
37130
|
+
They persist across restarts and redeploys **as long as you keep this bundle directory
|
|
37131
|
+
stable** \u2014 deploy into the same path each time; a fresh directory starts empty. This
|
|
37132
|
+
bundle is the only copy; there is no backend mirror.
|
|
37068
37133
|
`;
|
|
37069
37134
|
}
|
|
37070
37135
|
function slugify2(name) {
|
|
@@ -37099,15 +37164,24 @@ async function writeOutput(spec, resourceSecrets, cfg, req, componentEnv = {}) {
|
|
|
37099
37164
|
for (const c of spec.components) {
|
|
37100
37165
|
if (c.config !== void 0) await emit(configFileName(c.name), json2(c.config));
|
|
37101
37166
|
}
|
|
37167
|
+
const secretDocs = Object.keys(resourceSecrets).length > 0 ? { engine: resourceSecrets } : {};
|
|
37102
37168
|
await emit("deployment-spec.json", json2(spec));
|
|
37103
|
-
await emit("docker-compose.yml", composeYaml(spec));
|
|
37104
|
-
await emit("engine.env", envFile(cfg
|
|
37105
|
-
|
|
37169
|
+
await emit("docker-compose.yml", composeYaml(spec, secretDocs));
|
|
37170
|
+
await emit("engine.env", envFile(cfg), true);
|
|
37171
|
+
for (const [name, doc] of Object.entries(secretDocs)) {
|
|
37172
|
+
await emit(secretsFileName(name), json2(doc), true);
|
|
37173
|
+
}
|
|
37174
|
+
await emit("README.md", readme(spec, cfg, req.hasProviderModel, Object.keys(secretDocs).length > 0));
|
|
37106
37175
|
for (const [name, text] of Object.entries(componentEnv)) {
|
|
37107
37176
|
await emit(`${name}.env`, text, true);
|
|
37108
37177
|
}
|
|
37109
|
-
|
|
37110
|
-
|
|
37178
|
+
const workspaceSources = new Set(
|
|
37179
|
+
spec.components.flatMap(
|
|
37180
|
+
(c) => (c.volumes ?? []).map((v) => v.split(":")[0] ?? "").filter((src) => src.startsWith("./workspaces/"))
|
|
37181
|
+
)
|
|
37182
|
+
);
|
|
37183
|
+
for (const src of workspaceSources) {
|
|
37184
|
+
await fs8.mkdir(path11.join(dir, src), { recursive: true });
|
|
37111
37185
|
}
|
|
37112
37186
|
return written;
|
|
37113
37187
|
}
|
|
@@ -37391,7 +37465,6 @@ async function deployCommand(workflowPath, args2) {
|
|
|
37391
37465
|
cfg,
|
|
37392
37466
|
{
|
|
37393
37467
|
id: slugify2(workflowName),
|
|
37394
|
-
status: "active",
|
|
37395
37468
|
engineImage: ENGINE_IMAGE,
|
|
37396
37469
|
llamaServerImage: LLAMA_SERVER_IMAGE
|
|
37397
37470
|
},
|
package/dist-cli/deployment.yaml
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (c) 2026 ForestHub.
|
|
3
|
+
|
|
1
4
|
openapi: 3.0.3
|
|
2
5
|
info:
|
|
3
6
|
title: ForestHub Deployment Spec Contract
|
|
4
7
|
version: 0.1.0
|
|
5
|
-
description: "
|
|
8
|
+
description: "The resolved, component-agnostic set of containers to run on a device for one deployment."
|
|
6
9
|
|
|
7
10
|
paths: {}
|
|
8
11
|
components:
|
|
9
12
|
schemas:
|
|
10
13
|
DeploymentSpec:
|
|
11
14
|
type: object
|
|
12
|
-
required: [schemaVersion, id,
|
|
13
|
-
description: "The resolved set of components to run on one device for one deployment, plus its identity
|
|
15
|
+
required: [schemaVersion, id, components]
|
|
16
|
+
description: "The resolved set of components to run on one device for one deployment, plus its identity. Every decision is frozen at packaging time."
|
|
14
17
|
properties:
|
|
15
18
|
schemaVersion:
|
|
16
19
|
type: integer
|
|
@@ -22,52 +25,45 @@ components:
|
|
|
22
25
|
createdAt:
|
|
23
26
|
type: string
|
|
24
27
|
format: date-time
|
|
25
|
-
description: "When
|
|
26
|
-
status:
|
|
27
|
-
type: string
|
|
28
|
-
enum: [active, inactive]
|
|
29
|
-
description: "Whether this spec is the one a device should currently be running. The active spec is the reconcile/render target; inactive specs are history or rollback candidates."
|
|
28
|
+
description: "When this spec was produced, stamped by the producer."
|
|
30
29
|
components:
|
|
31
30
|
type: array
|
|
32
|
-
description: "The components to run for this deployment, each a fully resolved container.
|
|
31
|
+
description: "The components to run for this deployment, each a fully resolved container. Unordered — components declare no dependencies on one another."
|
|
33
32
|
items:
|
|
34
33
|
$ref: "#/components/schemas/DeployComponent"
|
|
35
34
|
|
|
36
35
|
DeployComponent:
|
|
37
36
|
type: object
|
|
38
37
|
required: [name, image]
|
|
39
|
-
description: "One resolved container to run as part of a deployment. Generic by design:
|
|
38
|
+
description: "One resolved container to run as part of a deployment. Generic by design: carries only runtime-neutral container knobs, never a component-typed config schema."
|
|
40
39
|
properties:
|
|
41
40
|
name:
|
|
42
41
|
type: string
|
|
43
|
-
description:
|
|
42
|
+
description: "Unique component name within the deployment."
|
|
44
43
|
image:
|
|
45
44
|
type: string
|
|
46
|
-
description: 'OCI image reference, frozen at packaging time. OSS: a
|
|
45
|
+
description: 'OCI image reference, frozen at packaging time. OSS: a locally-built convention tag, e.g. "foresthub/engine:local". Paid: a registry-qualified, digest-pinned ref, e.g. "ghcr.io/foresthubai/engine:1.2.0@sha256:...".'
|
|
47
46
|
pull:
|
|
48
47
|
type: string
|
|
49
48
|
enum: [always, missing, never]
|
|
50
|
-
description: 'Image pull policy
|
|
49
|
+
description: 'Image pull policy. Omit to default to "missing" (pull only if absent locally); "never" for a locally-built image in no registry; "always" re-pulls on every start.'
|
|
51
50
|
command:
|
|
52
51
|
type: array
|
|
53
|
-
description: 'Overrides the image''s default command/entrypoint arguments, in exec form — one token per element, e.g. ["--model", "/
|
|
52
|
+
description: 'Overrides the image''s default command/entrypoint arguments, in exec form — one token per element, e.g. ["--model", "/path/x.gguf", "--ctx-size", "4096"]. Omit to use the image''s default command.'
|
|
54
53
|
items:
|
|
55
54
|
type: string
|
|
56
55
|
config:
|
|
57
56
|
type: object
|
|
58
57
|
additionalProperties: true
|
|
59
|
-
description: "Structured config for the component, frozen at packaging time.
|
|
60
|
-
configPath:
|
|
61
|
-
type: string
|
|
62
|
-
description: 'Absolute in-container path to mount the config file at. Omit to use the convention default "/etc/foresthub/config.json", which first-party components read by default. Set it to drop in a JSON-configured third-party image that reads its config elsewhere, e.g. "/app/config.json". Ignored when config is absent.'
|
|
58
|
+
description: "Structured config for the component, frozen at packaging time. Omit for a component configured only by its image defaults and its device-local env. Never contains secrets."
|
|
63
59
|
volumes:
|
|
64
60
|
type: array
|
|
65
|
-
description: 'Persistent or host volume mounts in compose short form, e.g. "engine
|
|
61
|
+
description: 'Persistent or host volume mounts in compose short form, e.g. "./workspaces/engine:/var/lib/foresthub/workspace" or "./data:/data:ro". Empty when the component is stateless and mounts nothing beyond its config files.'
|
|
66
62
|
items:
|
|
67
63
|
type: string
|
|
68
64
|
devices:
|
|
69
65
|
type: array
|
|
70
|
-
description: 'Resolved host device nodes to pass into the container, e.g. "/dev/gpiochip0", "/dev/ttyUSB0".
|
|
66
|
+
description: 'Resolved host device nodes to pass into the container, e.g. "/dev/gpiochip0", "/dev/ttyUSB0". Empty when the component binds no cdev hardware.'
|
|
71
67
|
items:
|
|
72
68
|
type: string
|
|
73
69
|
ports:
|
|
@@ -80,4 +76,30 @@ components:
|
|
|
80
76
|
description: "Run the container privileged. Required for hardware with no single device node to grant (ADC/DAC/PWM use sysfs paths like /sys/class/pwm, /sys/bus/iio). False when the component uses only cdev hardware (use devices) or none."
|
|
81
77
|
user:
|
|
82
78
|
type: string
|
|
83
|
-
description: 'Container user as "UID[:GID]", e.g. "0:0" for root.
|
|
79
|
+
description: 'Container user as "UID[:GID]", e.g. "0:0" for root. Set when a nonroot image must reach root-owned resources. Omit to use the image''s default user.'
|
|
80
|
+
healthcheck:
|
|
81
|
+
$ref: "#/components/schemas/HealthCheck"
|
|
82
|
+
|
|
83
|
+
HealthCheck:
|
|
84
|
+
type: object
|
|
85
|
+
required: [test]
|
|
86
|
+
description: "In-container readiness/liveness probe. Present only when the image ships a probe tool; omit otherwise."
|
|
87
|
+
properties:
|
|
88
|
+
test:
|
|
89
|
+
type: array
|
|
90
|
+
description: 'The probe command in compose exec form. The first token is the probe kind: "CMD" runs the remaining tokens as an argv, "CMD-SHELL" runs the single following string in a shell, "NONE" disables a healthcheck the image baked in. Point it at a tool present in the image, e.g. ["CMD", "curl", "-f", "http://localhost:8080/health"]. A component reachable only by liveness (no readiness endpoint) probes a cheaper signal; one with no in-image probe at all omits the enclosing healthcheck entirely.'
|
|
91
|
+
items:
|
|
92
|
+
type: string
|
|
93
|
+
interval:
|
|
94
|
+
type: string
|
|
95
|
+
description: 'How often to run the probe once the container is up, as a compose/Go duration, e.g. "30s". Omit to use the renderer default.'
|
|
96
|
+
timeout:
|
|
97
|
+
type: string
|
|
98
|
+
description: 'How long one probe may run before it counts as a failure, e.g. "10s". Omit to use the renderer default.'
|
|
99
|
+
retries:
|
|
100
|
+
type: integer
|
|
101
|
+
minimum: 1
|
|
102
|
+
description: "Consecutive probe failures before the container is marked unhealthy. Omit to use the renderer default."
|
|
103
|
+
startPeriod:
|
|
104
|
+
type: string
|
|
105
|
+
description: 'Grace window after container start during which probe failures neither count against retries nor mark the container unhealthy, sized to the component''s worst-case warmup, e.g. a llama-server loading a multi-GB model. A container still not healthy when this elapses is the universal failure backstop. e.g. "40s".'
|