@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/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-D7a-pqL0.js"></script>
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" || req.method === "POST") {
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
- qos: Number(data.arguments.qos),
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 ENGINE_MEMORY_PATH = "/var/lib/foresthub/memory";
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 ./models/";
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] = { ...resourceSecrets[ref], password: b.password };
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
- `/models/${b.modelFile}`,
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
- volumes: ["./models:/models:ro"]
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] = { apiKey: b.apiKey };
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
- volumes: [`engine-memory:${ENGINE_MEMORY_PATH}`]
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 ./models/ (e.g. model.gguf)`,
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 DEFAULT_CONFIG_PATH = "/etc/foresthub/config.json";
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
- lines.push(" labels:", ` com.foresthub.config-hash: "${hash2}"`);
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 = [...c.config !== void 0 ? [`./${configFileName(c.name)}:${c.configPath ?? DEFAULT_CONFIG_PATH}:ro`] : [], ...c.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, resourceSecrets = {}) {
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}${resourceSecretsSection}# ----- Runtime -----
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 deviceModelFiles = Object.values(cfg.models).flatMap((b) => b.location === "device" ? [b.modelFile] : []);
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 \`engine.env\`
36963
- (\`FH_RESOURCE_SECRETS\`) \u2014 keep it \`chmod 600\`. This bundle does not start those
36964
- services; it assumes the broker/endpoint already exists and is reachable from the
36965
- controller.`);
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 (deviceModelFiles.length > 0) {
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. The GGUF file(s) below must sit in a
36972
- \`models/\` folder next to the compose file on the controller. They are too large for
36973
- the main \`scp\` line, so step 3 transfers them separately:
36974
- ${deviceModelFiles.map((f) => `- \`./models/${f}\``).join("\n")}`);
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 = deviceModelFiles.length ? `
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
- # Copy them from this bundle:
36992
- scp -r models/ $CONTROLLER_USER@$CONTROLLER_ADDR:~/fh-engine/
36993
- # ...or download them directly into ~/fh-engine/models/ on the controller.` : "";
36994
- const runBlock = deviceModelFiles.length ? `ssh $CONTROLLER_USER@$CONTROLLER_ADDR 'cd ~/fh-engine && docker load -i fh-engine.tar'
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
- The workflow's memory files live in the named volume \`engine-memory\` (mounted at
37063
- \`/var/lib/foresthub/memory\`). They survive restarts and \`docker compose down\`;
37064
- only \`docker compose down -v\` deletes them. The volume is namespaced by the
37065
- compose project name, which defaults to the bundle directory's name \u2014 keep it
37066
- stable across updates, or pin it via \`COMPOSE_PROJECT_NAME\`. In this standalone
37067
- bundle the volume is the only copy of the memory.
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, resourceSecrets), true);
37105
- await emit("README.md", readme(spec, cfg, req.hasProviderModel));
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
- if (Object.values(cfg.models).some((b) => b.location === "device")) {
37110
- await fs8.mkdir(path11.join(dir, "models"), { recursive: true });
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
  },
@@ -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: "Resolved, contract-defined description of a device's component set for one deployment (types only). Produced once by a packaging step (the CLI for the OSS path, the FE/backend for the paid path) and consumed two ways: the OSS one-shot CLI renders it to containers and exits; the paid ranger reconciles desired-vs-actual against it. Components are generic: the spec never names engine, llama-server, or any specific component, so a new OSS or paid component is added without touching this contract."
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, status, components]
13
- description: "The resolved set of components to run on one device for one deployment, plus its identity and lifecycle status. Every decision (which components run, their images, device access, config file content) is computed once by the packaging step and frozen here, so a renderer renders it without re-deriving anything. The spec is the self-contained, restartable source of truth: spec + cached images + device-local env files are all a device needs to (re)start offline."
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 the packaging step produced this spec. Stamped by the producer; drives deployment history ordering."
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. The list is unordered: components do not declare dependencies on one another. Coupling between components (e.g. the engine reaching an on-device model) is expressed as a URL the consumer connects to at runtime with retry, never as a start-ordering edge, so the model works identically whether the dependency runs on this device or another one. The packaging step expands any one-to-many component (e.g. one llama-server image per on-device model) into separate concrete entries here."
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: it carries only runtime-neutral container knobs the renderer passes through, never a component-typed config schema. A component's own config shape lives in that component's contract (e.g. the engine's EngineConfig in engine.yaml), referenced directly by whoever produces and consumes that config; this spec only transports the rendered config content as opaque file bytes. Adding a component, OSS or paid, means producing one of these, not editing this contract."
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: 'Unique component name within the deployment. Doubles as the container/service name the renderer emits, the basename of any device-local env file the operator supplies ("<name>.env"), and the address other components reach this one by over the container network.'
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 local convention tag built before deploy, e.g. "foresthub/engine:local". Paid: a registry-qualified, digest-pinned ref the ranger resolves and freezes, e.g. "ghcr.io/foresthubai/engine:1.2.0@sha256:abc123...". Which registry and how the daemon authenticates to it are device-side config, not part of this string.'
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, emitted as the renderer''s pull_policy. Omit to default to "missing" (pull only if absent locally — correct for any registry image). Use "never" for an image built locally and present in no registry (the OSS engine); "always" re-pulls on every start.'
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", "/models/x.gguf", "--ctx-size", "4096"]. Runtime-neutral: every runtime can override a container''s command. Lets a CLI-flag-configured image (llama-server and many third-party servers) run unwrapped — the packaging step puts the flags here instead of baking a JSON-to-flags translation into a wrapper image. Omit to use the image''s own default command.'
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. The renderer serializes it to JSON, writes it to a device-local file, bind-mounts that file read-only at configPath, and hashes it into a recreate trigger (compose does not track bind-mount content). How the component interprets the file — read it as JSON, convert it to CLI args, expand it into several native config files — is the image's entrypoint, not this spec; a non-JSON image is wrapped with a thin entrypoint that converts. Omit for a component configured only by its image defaults and its device-local env. Never contains secrets; those arrive as environment variables from the device-local env file."
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-memory:/var/lib/foresthub/memory" or "./models:/models:ro". Empty when the component is stateless and mounts nothing beyond its config files.'
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". One entry per distinct cdev node the component binds, computed once from the workflow''s hardware against the device manifest. Empty when the component uses no cdev hardware; ADC/DAC/PWM have no single node and go through privileged instead.'
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. The renderer passes it through verbatim. Needed when a nonroot image must reach root-owned resources: the engine image runs nonroot but has to open the root-owned device nodes / sysfs files granted via devices/privileged, so its component sets "0:0". Omit to use the image''s own default user.'
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".'