@foresthubai/workflow-cli 0.4.3 → 0.4.5

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-cli/cli.js CHANGED
@@ -3232,8 +3232,8 @@ var require_utils = __commonJS({
3232
3232
  }
3233
3233
  return ind;
3234
3234
  }
3235
- function removeDotSegments(path11) {
3236
- let input = path11;
3235
+ function removeDotSegments(path13) {
3236
+ let input = path13;
3237
3237
  const output = [];
3238
3238
  let nextSlash = -1;
3239
3239
  let len = 0;
@@ -3485,8 +3485,8 @@ var require_schemes = __commonJS({
3485
3485
  wsComponent.secure = void 0;
3486
3486
  }
3487
3487
  if (wsComponent.resourceName) {
3488
- const [path11, query] = wsComponent.resourceName.split("?");
3489
- wsComponent.path = path11 && path11 !== "/" ? path11 : void 0;
3488
+ const [path13, query] = wsComponent.resourceName.split("?");
3489
+ wsComponent.path = path13 && path13 !== "/" ? path13 : void 0;
3490
3490
  wsComponent.query = query;
3491
3491
  wsComponent.resourceName = void 0;
3492
3492
  }
@@ -3641,7 +3641,7 @@ var require_fast_uri = __commonJS({
3641
3641
  normalizeString(uri, options);
3642
3642
  } else if (typeof uri === "object") {
3643
3643
  uri = /** @type {T} */
3644
- parse3(serialize7(uri, options), options);
3644
+ parse3(serialize8(uri, options), options);
3645
3645
  }
3646
3646
  return uri;
3647
3647
  }
@@ -3649,13 +3649,13 @@ var require_fast_uri = __commonJS({
3649
3649
  const schemelessOptions = options ? Object.assign({ scheme: "null" }, options) : { scheme: "null" };
3650
3650
  const resolved = resolveComponent(parse3(baseURI, schemelessOptions), parse3(relativeURI, schemelessOptions), schemelessOptions, true);
3651
3651
  schemelessOptions.skipEscape = true;
3652
- return serialize7(resolved, schemelessOptions);
3652
+ return serialize8(resolved, schemelessOptions);
3653
3653
  }
3654
3654
  function resolveComponent(base, relative, options, skipNormalization) {
3655
3655
  const target = {};
3656
3656
  if (!skipNormalization) {
3657
- base = parse3(serialize7(base, options), options);
3658
- relative = parse3(serialize7(relative, options), options);
3657
+ base = parse3(serialize8(base, options), options);
3658
+ relative = parse3(serialize8(relative, options), options);
3659
3659
  }
3660
3660
  options = options || {};
3661
3661
  if (!options.tolerant && relative.scheme) {
@@ -3709,7 +3709,7 @@ var require_fast_uri = __commonJS({
3709
3709
  const normalizedB = normalizeComparableURI(uriB, options);
3710
3710
  return normalizedA !== void 0 && normalizedB !== void 0 && normalizedA.toLowerCase() === normalizedB.toLowerCase();
3711
3711
  }
3712
- function serialize7(cmpts, opts) {
3712
+ function serialize8(cmpts, opts) {
3713
3713
  const component = {
3714
3714
  host: cmpts.host,
3715
3715
  scheme: cmpts.scheme,
@@ -3887,7 +3887,7 @@ var require_fast_uri = __commonJS({
3887
3887
  function normalizeStringWithStatus(uri, opts) {
3888
3888
  const { parsed, malformedAuthorityOrPort } = parseWithStatus(uri, opts);
3889
3889
  return {
3890
- normalized: malformedAuthorityOrPort ? uri : serialize7(parsed, opts),
3890
+ normalized: malformedAuthorityOrPort ? uri : serialize8(parsed, opts),
3891
3891
  malformedAuthorityOrPort
3892
3892
  };
3893
3893
  }
@@ -3897,7 +3897,7 @@ var require_fast_uri = __commonJS({
3897
3897
  return malformedAuthorityOrPort ? void 0 : normalized;
3898
3898
  }
3899
3899
  if (typeof uri === "object") {
3900
- return serialize7(uri, opts);
3900
+ return serialize8(uri, opts);
3901
3901
  }
3902
3902
  }
3903
3903
  var fastUri = {
@@ -3906,7 +3906,7 @@ var require_fast_uri = __commonJS({
3906
3906
  resolve,
3907
3907
  resolveComponent,
3908
3908
  equal,
3909
- serialize: serialize7,
3909
+ serialize: serialize8,
3910
3910
  parse: parse3
3911
3911
  };
3912
3912
  module.exports = fastUri;
@@ -4034,7 +4034,7 @@ var require_core = __commonJS({
4034
4034
  uriResolver
4035
4035
  };
4036
4036
  }
4037
- var Ajv2 = class {
4037
+ var Ajv3 = class {
4038
4038
  constructor(opts = {}) {
4039
4039
  this.schemas = {};
4040
4040
  this.refs = {};
@@ -4404,9 +4404,9 @@ var require_core = __commonJS({
4404
4404
  }
4405
4405
  }
4406
4406
  };
4407
- Ajv2.ValidationError = validation_error_1.default;
4408
- Ajv2.MissingRefError = ref_error_1.default;
4409
- exports.default = Ajv2;
4407
+ Ajv3.ValidationError = validation_error_1.default;
4408
+ Ajv3.MissingRefError = ref_error_1.default;
4409
+ exports.default = Ajv3;
4410
4410
  function checkOptions(checkOpts, options, msg, log = "error") {
4411
4411
  for (const key in checkOpts) {
4412
4412
  const opt = key;
@@ -6517,7 +6517,7 @@ var require_ajv = __commonJS({
6517
6517
  var draft7MetaSchema = require_json_schema_draft_07();
6518
6518
  var META_SUPPORT_DATA = ["/properties"];
6519
6519
  var META_SCHEMA_ID = "http://json-schema.org/draft-07/schema";
6520
- var Ajv2 = class extends core_1.default {
6520
+ var Ajv3 = class extends core_1.default {
6521
6521
  _addVocabularies() {
6522
6522
  super._addVocabularies();
6523
6523
  draft7_1.default.forEach((v) => this.addVocabulary(v));
@@ -6536,11 +6536,11 @@ var require_ajv = __commonJS({
6536
6536
  return this.opts.defaultMeta = super.defaultMeta() || (this.getSchema(META_SCHEMA_ID) ? META_SCHEMA_ID : void 0);
6537
6537
  }
6538
6538
  };
6539
- exports.Ajv = Ajv2;
6540
- module.exports = exports = Ajv2;
6541
- module.exports.Ajv = Ajv2;
6539
+ exports.Ajv = Ajv3;
6540
+ module.exports = exports = Ajv3;
6541
+ module.exports.Ajv = Ajv3;
6542
6542
  Object.defineProperty(exports, "__esModule", { value: true });
6543
- exports.default = Ajv2;
6543
+ exports.default = Ajv3;
6544
6544
  var validate_1 = require_validate();
6545
6545
  Object.defineProperty(exports, "KeywordCxt", { enumerable: true, get: function() {
6546
6546
  return validate_1.KeywordCxt;
@@ -9932,7 +9932,7 @@ var require_core3 = __commonJS({
9932
9932
  uriResolver
9933
9933
  };
9934
9934
  }
9935
- var Ajv2 = class {
9935
+ var Ajv3 = class {
9936
9936
  constructor(opts = {}) {
9937
9937
  this.schemas = {};
9938
9938
  this.refs = {};
@@ -10302,9 +10302,9 @@ var require_core3 = __commonJS({
10302
10302
  }
10303
10303
  }
10304
10304
  };
10305
- Ajv2.ValidationError = validation_error_1.default;
10306
- Ajv2.MissingRefError = ref_error_1.default;
10307
- exports.default = Ajv2;
10305
+ Ajv3.ValidationError = validation_error_1.default;
10306
+ Ajv3.MissingRefError = ref_error_1.default;
10307
+ exports.default = Ajv3;
10308
10308
  function checkOptions(checkOpts, options, msg, log = "error") {
10309
10309
  for (const key in checkOpts) {
10310
10310
  const opt = key;
@@ -12415,7 +12415,7 @@ var require_ajv2 = __commonJS({
12415
12415
  var draft7MetaSchema = require_json_schema_draft_072();
12416
12416
  var META_SUPPORT_DATA = ["/properties"];
12417
12417
  var META_SCHEMA_ID = "http://json-schema.org/draft-07/schema";
12418
- var Ajv2 = class extends core_1.default {
12418
+ var Ajv3 = class extends core_1.default {
12419
12419
  _addVocabularies() {
12420
12420
  super._addVocabularies();
12421
12421
  draft7_1.default.forEach((v) => this.addVocabulary(v));
@@ -12434,11 +12434,11 @@ var require_ajv2 = __commonJS({
12434
12434
  return this.opts.defaultMeta = super.defaultMeta() || (this.getSchema(META_SCHEMA_ID) ? META_SCHEMA_ID : void 0);
12435
12435
  }
12436
12436
  };
12437
- exports.Ajv = Ajv2;
12438
- module.exports = exports = Ajv2;
12439
- module.exports.Ajv = Ajv2;
12437
+ exports.Ajv = Ajv3;
12438
+ module.exports = exports = Ajv3;
12439
+ module.exports.Ajv = Ajv3;
12440
12440
  Object.defineProperty(exports, "__esModule", { value: true });
12441
- exports.default = Ajv2;
12441
+ exports.default = Ajv3;
12442
12442
  var validate_1 = require_validate2();
12443
12443
  Object.defineProperty(exports, "KeywordCxt", { enumerable: true, get: function() {
12444
12444
  return validate_1.KeywordCxt;
@@ -12557,12 +12557,12 @@ var require_dist = __commonJS({
12557
12557
  var fastName = new codegen_1.Name("fastFormats");
12558
12558
  var formatsPlugin = (ajv, opts = { keywords: true }) => {
12559
12559
  if (Array.isArray(opts)) {
12560
- addFormats2(ajv, opts, formats_1.fullFormats, fullName);
12560
+ addFormats3(ajv, opts, formats_1.fullFormats, fullName);
12561
12561
  return ajv;
12562
12562
  }
12563
12563
  const [formats, exportName] = opts.mode === "fast" ? [formats_1.fastFormats, fastName] : [formats_1.fullFormats, fullName];
12564
12564
  const list = opts.formats || formats_1.formatNames;
12565
- addFormats2(ajv, list, formats, exportName);
12565
+ addFormats3(ajv, list, formats, exportName);
12566
12566
  if (opts.keywords)
12567
12567
  (0, limit_1.default)(ajv);
12568
12568
  return ajv;
@@ -12574,12 +12574,12 @@ var require_dist = __commonJS({
12574
12574
  throw new Error(`Unknown format "${name}"`);
12575
12575
  return f;
12576
12576
  };
12577
- function addFormats2(ajv, list, fs9, exportName) {
12577
+ function addFormats3(ajv, list, fs10, exportName) {
12578
12578
  var _a3;
12579
12579
  var _b;
12580
12580
  (_a3 = (_b = ajv.opts.code).formats) !== null && _a3 !== void 0 ? _a3 : _b.formats = (0, codegen_1._)`require("ajv-formats/dist/formats").${exportName}`;
12581
12581
  for (const f of list)
12582
- ajv.addFormat(f, fs9[f]);
12582
+ ajv.addFormat(f, fs10[f]);
12583
12583
  }
12584
12584
  module.exports = exports = formatsPlugin;
12585
12585
  Object.defineProperty(exports, "__esModule", { value: true });
@@ -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");
@@ -13121,6 +13141,11 @@ function toFunctionInfo(fn) {
13121
13141
  }
13122
13142
 
13123
13143
  // ../workflow-core/src/function/serialization.ts
13144
+ function serialize(fn) {
13145
+ const outputAssignments = {};
13146
+ for (const o of fn.outputs) outputAssignments[o.uid] = o.expression;
13147
+ return { functionInfo: toFunctionInfo(fn), outputAssignments };
13148
+ }
13124
13149
  function deserialize(functionInfo, outputAssignments) {
13125
13150
  return {
13126
13151
  id: functionInfo.id,
@@ -13182,6 +13207,51 @@ function isToolFlow(type) {
13182
13207
  }
13183
13208
 
13184
13209
  // ../workflow-core/src/edge/serialization.ts
13210
+ function serialize2(edge) {
13211
+ const sourceHandle = edge.sourceHandle || "";
13212
+ const targetHandle = edge.targetHandle || "";
13213
+ const edgeType = edge.type;
13214
+ const from = { nodeId: edge.source, port: sourceHandle };
13215
+ const to = { nodeId: edge.target, port: targetHandle };
13216
+ switch (edgeType) {
13217
+ case "agentTask":
13218
+ return {
13219
+ id: edge.id,
13220
+ type: "agentTask",
13221
+ from,
13222
+ to,
13223
+ prompt: edge.data?.prompt ?? { expression: "", references: [], dataType: "string" }
13224
+ };
13225
+ case "agentChoice":
13226
+ return {
13227
+ id: edge.id,
13228
+ type: "agentChoice",
13229
+ from,
13230
+ to,
13231
+ ...edge.data?.description ? { description: edge.data.description } : {}
13232
+ };
13233
+ case "agentDelegate":
13234
+ return {
13235
+ id: edge.id,
13236
+ type: "agentDelegate",
13237
+ from,
13238
+ to,
13239
+ ...edge.data?.prompt ? { prompt: edge.data.prompt } : {},
13240
+ ...edge.data?.description ? { description: edge.data.description } : {}
13241
+ };
13242
+ case "control":
13243
+ return { id: edge.id, type: "control", from, to };
13244
+ case "tool":
13245
+ return { id: edge.id, type: "tool", from, to };
13246
+ default:
13247
+ return {
13248
+ id: edge.id,
13249
+ type: sourceHandle.startsWith("ctrl") || targetHandle.startsWith("ctrl") ? "control" : "tool",
13250
+ from,
13251
+ to
13252
+ };
13253
+ }
13254
+ }
13185
13255
  function deserialize2(apiEdge) {
13186
13256
  let data;
13187
13257
  if ((apiEdge.type === "agentTask" || apiEdge.type === "agentDelegate") && apiEdge.prompt) {
@@ -13271,6 +13341,14 @@ function isParameterActive(param, parameterValues, isToolInput) {
13271
13341
  function isEmpty(value) {
13272
13342
  return value === void 0 || value === null || value === "";
13273
13343
  }
13344
+ function pruneArguments(args2, parameters, isToolInput = false) {
13345
+ for (const param of parameters) {
13346
+ const inactive = !!param.activationRules?.length && !isParameterActive(param, args2, isToolInput);
13347
+ if (inactive || isEmpty(args2[param.id])) {
13348
+ delete args2[param.id];
13349
+ }
13350
+ }
13351
+ }
13274
13352
  function resolveExpressionType(param, args2, variables) {
13275
13353
  if (param.fromReference) {
13276
13354
  const ref = args2[param.fromReference];
@@ -13724,9 +13802,9 @@ var SerialWriteNodeDefinition = {
13724
13802
  {
13725
13803
  id: "portReference",
13726
13804
  label: "Port",
13727
- description: "Serial port to write to",
13805
+ description: "Serial port or log channel to write to",
13728
13806
  type: "channelSelect",
13729
- channelType: ["UART"]
13807
+ channelType: ["UART", "LOG"]
13730
13808
  },
13731
13809
  {
13732
13810
  id: "value",
@@ -14105,7 +14183,7 @@ function isNodeUsedAsTool(nodeId, nodeData, edges) {
14105
14183
  }
14106
14184
 
14107
14185
  // ../workflow-core/src/channel/Channel.ts
14108
- var ALL_CHANNEL_TYPES = ["GPIOIN", "GPIOOUT", "ADC", "PWM", "DAC", "UART", "MQTT"];
14186
+ var ALL_CHANNEL_TYPES = ["GPIOIN", "GPIOOUT", "ADC", "PWM", "DAC", "UART", "MQTT", "LOG"];
14109
14187
 
14110
14188
  // ../workflow-core/src/channel/ChannelDefinition.ts
14111
14189
  var CHANNEL_DEFINITION = {
@@ -14153,11 +14231,62 @@ var CHANNEL_DEFINITION = {
14153
14231
  description: "MQTT topic this channel publishes to / subscribes on (e.g. sensors/temperature)",
14154
14232
  type: "string",
14155
14233
  activationRules: [{ type: "parameterIn", parameterId: "type", values: ["MQTT"] }]
14234
+ },
14235
+ {
14236
+ id: "level",
14237
+ label: "Level",
14238
+ description: "Severity the engine records messages written to this channel at",
14239
+ type: "selection",
14240
+ default: "info",
14241
+ options: [
14242
+ { value: "debug", label: "Debug" },
14243
+ { value: "info", label: "Info" },
14244
+ { value: "warn", label: "Warn" },
14245
+ { value: "error", label: "Error" }
14246
+ ],
14247
+ activationRules: [{ type: "parameterIn", parameterId: "type", values: ["LOG"] }]
14248
+ },
14249
+ {
14250
+ id: "tag",
14251
+ label: "Tag",
14252
+ description: "Optional category stamped on each line so the backend can group workflow-emitted logs",
14253
+ type: "string",
14254
+ optional: true,
14255
+ activationRules: [{ type: "parameterIn", parameterId: "type", values: ["LOG"] }]
14156
14256
  }
14157
14257
  ]
14158
14258
  };
14159
14259
 
14160
14260
  // ../workflow-core/src/channel/serialization.ts
14261
+ function serialize3(ch) {
14262
+ const { id, label, type } = ch;
14263
+ const args2 = { ...ch.arguments, type };
14264
+ pruneArguments(args2, CHANNEL_DEFINITION.parameters);
14265
+ switch (type) {
14266
+ case "GPIOIN":
14267
+ return {
14268
+ type,
14269
+ id,
14270
+ label,
14271
+ bias: args2.bias,
14272
+ debounceMs: args2.debounceMs
14273
+ };
14274
+ case "GPIOOUT":
14275
+ return { type, id, label };
14276
+ case "ADC":
14277
+ return { type, id, label };
14278
+ case "PWM":
14279
+ return { type, id, label, frequency: args2.frequency };
14280
+ case "DAC":
14281
+ return { type, id, label };
14282
+ case "UART":
14283
+ return { type, id, label };
14284
+ case "MQTT":
14285
+ return { type, id, label, topic: args2.topic };
14286
+ case "LOG":
14287
+ return { type, id, label, level: args2.level, tag: args2.tag };
14288
+ }
14289
+ }
14161
14290
  function deserialize3(api) {
14162
14291
  const { id, label, type } = api;
14163
14292
  const args2 = {};
@@ -14172,6 +14301,10 @@ function deserialize3(api) {
14172
14301
  case "MQTT":
14173
14302
  args2.topic = api.topic;
14174
14303
  break;
14304
+ case "LOG":
14305
+ args2.level = api.level;
14306
+ args2.tag = api.tag;
14307
+ break;
14175
14308
  case "GPIOOUT":
14176
14309
  case "ADC":
14177
14310
  case "DAC":
@@ -14254,6 +14387,27 @@ var MemoryRegistry = new MemoryDefinitionRegistry();
14254
14387
  MemoryRegistry.initialize();
14255
14388
 
14256
14389
  // ../workflow-core/src/memory/serialization.ts
14390
+ function serialize4(mem) {
14391
+ const { id, label, type, arguments: args2 } = mem;
14392
+ switch (type) {
14393
+ case "MemoryFile":
14394
+ return {
14395
+ type,
14396
+ id,
14397
+ label,
14398
+ description: args2.description ?? "",
14399
+ content: args2.content ?? "",
14400
+ ...args2.maxSizeBytes != null ? { maxSizeBytes: args2.maxSizeBytes } : {}
14401
+ };
14402
+ case "VectorDatabase":
14403
+ return {
14404
+ type,
14405
+ id,
14406
+ label,
14407
+ ...args2.description != null ? { description: args2.description } : {}
14408
+ };
14409
+ }
14410
+ }
14257
14411
  function deserialize4(api) {
14258
14412
  const { id, label, type } = api;
14259
14413
  const args2 = {};
@@ -14301,6 +14455,18 @@ var ModelRegistry = new ModelDefinitionRegistry();
14301
14455
  ModelRegistry.initialize();
14302
14456
 
14303
14457
  // ../workflow-core/src/model/serialization.ts
14458
+ function serialize5(model) {
14459
+ const { id, label, type, arguments: args2 } = model;
14460
+ switch (type) {
14461
+ case "LLMModel":
14462
+ return {
14463
+ type,
14464
+ id,
14465
+ label,
14466
+ capabilities: args2.capabilities ?? ["chat"]
14467
+ };
14468
+ }
14469
+ }
14304
14470
  function deserialize5(api) {
14305
14471
  const { id, label, type } = api;
14306
14472
  const args2 = {};
@@ -14349,6 +14515,269 @@ function buildFunctionNodeDef(fn, t = (key) => key) {
14349
14515
  }
14350
14516
 
14351
14517
  // ../workflow-core/src/node/serialization.ts
14518
+ function draft(value) {
14519
+ return value;
14520
+ }
14521
+ function serialize6(node, isToolInput) {
14522
+ const result = serializeNodeData(node, node.position, isToolInput);
14523
+ if (node.label) {
14524
+ result.label = node.label;
14525
+ }
14526
+ if ("arguments" in result && result.arguments) {
14527
+ const def = node.type !== "FunctionCall" ? NodeRegistry.getByType(node.type) : void 0;
14528
+ if (def) {
14529
+ pruneArguments(result.arguments, def.parameters, isToolInput);
14530
+ }
14531
+ const args2 = result.arguments;
14532
+ for (const key of Object.keys(args2)) {
14533
+ if (args2[key] === void 0) delete args2[key];
14534
+ }
14535
+ }
14536
+ return result;
14537
+ }
14538
+ function serializeNodeData(data, position, isToolInput) {
14539
+ switch (data.type) {
14540
+ case "ReadPin":
14541
+ return {
14542
+ id: data.id,
14543
+ type: data.type,
14544
+ position,
14545
+ arguments: {
14546
+ pinReference: draft(data.arguments.pinReference),
14547
+ signalType: data.arguments.signalType,
14548
+ output: data.arguments.output,
14549
+ toolDescription: data.arguments.toolDescription
14550
+ }
14551
+ };
14552
+ case "SerialRead":
14553
+ return {
14554
+ id: data.id,
14555
+ type: data.type,
14556
+ position,
14557
+ arguments: {
14558
+ portReference: draft(data.arguments.portReference),
14559
+ ...data.arguments.prompt !== void 0 ? { prompt: data.arguments.prompt } : {},
14560
+ output: data.arguments.output
14561
+ }
14562
+ };
14563
+ case "WritePin":
14564
+ return {
14565
+ id: data.id,
14566
+ type: data.type,
14567
+ position,
14568
+ arguments: {
14569
+ pinReference: draft(data.arguments.pinReference),
14570
+ signalType: data.arguments.signalType,
14571
+ value: data.arguments.value
14572
+ }
14573
+ };
14574
+ case "SerialWrite":
14575
+ return {
14576
+ id: data.id,
14577
+ type: data.type,
14578
+ position,
14579
+ arguments: {
14580
+ portReference: draft(data.arguments.portReference),
14581
+ value: data.arguments.value
14582
+ }
14583
+ };
14584
+ case "Agent": {
14585
+ return {
14586
+ id: data.id,
14587
+ type: data.type,
14588
+ position,
14589
+ arguments: {
14590
+ name: data.arguments.name,
14591
+ model: data.arguments.model,
14592
+ instructions: data.arguments.instructions,
14593
+ maxTurns: data.arguments.maxTurns,
14594
+ outputDeclarations: data.arguments.outputDeclarations,
14595
+ memoryRefs: data.arguments.memoryRefs ?? [],
14596
+ answer: data.arguments.answer,
14597
+ toolDescription: data.arguments.toolDescription
14598
+ }
14599
+ };
14600
+ }
14601
+ case "If":
14602
+ return {
14603
+ id: data.id,
14604
+ type: data.type,
14605
+ position,
14606
+ arguments: {
14607
+ condition: data.arguments.condition
14608
+ }
14609
+ };
14610
+ case "OnFunctionCall":
14611
+ return {
14612
+ id: data.id,
14613
+ type: data.type,
14614
+ position
14615
+ };
14616
+ case "OnStartup":
14617
+ return {
14618
+ id: data.id,
14619
+ type: data.type,
14620
+ position
14621
+ };
14622
+ case "OnPinEdge":
14623
+ return {
14624
+ id: data.id,
14625
+ type: data.type,
14626
+ position,
14627
+ arguments: {
14628
+ pinReference: draft(data.arguments.pinReference),
14629
+ edge: data.arguments.edge
14630
+ }
14631
+ };
14632
+ case "OnSerialReceive":
14633
+ return {
14634
+ id: data.id,
14635
+ type: data.type,
14636
+ position,
14637
+ arguments: {
14638
+ portReference: draft(data.arguments.portReference),
14639
+ output: data.arguments.output
14640
+ }
14641
+ };
14642
+ case "OnThreshold":
14643
+ return {
14644
+ id: data.id,
14645
+ type: data.type,
14646
+ position,
14647
+ arguments: {
14648
+ variable: draft(data.arguments.variable),
14649
+ threshold: draft(data.arguments.threshold),
14650
+ direction: data.arguments.direction,
14651
+ deadband: data.arguments.deadband,
14652
+ output: data.arguments.output
14653
+ }
14654
+ };
14655
+ case "Delay":
14656
+ return {
14657
+ id: data.id,
14658
+ type: data.type,
14659
+ position,
14660
+ arguments: {
14661
+ delayMs: draft(data.arguments.delayMs)
14662
+ }
14663
+ };
14664
+ case "Ticker":
14665
+ return {
14666
+ id: data.id,
14667
+ type: data.type,
14668
+ position,
14669
+ arguments: {
14670
+ intervalValue: draft(data.arguments.intervalValue),
14671
+ intervalUnit: data.arguments.intervalUnit
14672
+ }
14673
+ };
14674
+ case "Alarm":
14675
+ return {
14676
+ id: data.id,
14677
+ type: data.type,
14678
+ position,
14679
+ arguments: {
14680
+ time: data.arguments.time,
14681
+ days: data.arguments.days
14682
+ }
14683
+ };
14684
+ case "WebSearchTool":
14685
+ return {
14686
+ id: data.id,
14687
+ type: data.type,
14688
+ position,
14689
+ arguments: {
14690
+ maxResults: data.arguments.maxResults
14691
+ }
14692
+ };
14693
+ case "Retriever":
14694
+ return {
14695
+ id: data.id,
14696
+ type: data.type,
14697
+ position,
14698
+ arguments: {
14699
+ memoryReference: data.arguments.memoryReference,
14700
+ topK: draft(data.arguments.topK),
14701
+ query: data.arguments.query,
14702
+ output: data.arguments.output,
14703
+ toolDescription: data.arguments.toolDescription
14704
+ }
14705
+ };
14706
+ case "WebFetch":
14707
+ return {
14708
+ id: data.id,
14709
+ type: data.type,
14710
+ position,
14711
+ arguments: {
14712
+ url: data.arguments.url,
14713
+ maxChars: data.arguments.maxChars,
14714
+ output: data.arguments.output
14715
+ }
14716
+ };
14717
+ case "FunctionCall": {
14718
+ const inputBindings = {};
14719
+ const outputBindings = {};
14720
+ const args2 = data.arguments;
14721
+ for (const arg of data.functionInfo.arguments) {
14722
+ const key = arg.uid ?? arg.name;
14723
+ const v = args2[key];
14724
+ if (v !== void 0) inputBindings[key] = v;
14725
+ }
14726
+ for (const ret of data.functionInfo.returns) {
14727
+ const key = ret.uid ?? ret.name;
14728
+ const v = args2[key];
14729
+ if (v !== void 0) outputBindings[key] = v;
14730
+ }
14731
+ const toolDescription = args2.toolDescription;
14732
+ return {
14733
+ id: data.id,
14734
+ type: data.type,
14735
+ functionId: data.functionInfo.id,
14736
+ position,
14737
+ arguments: {
14738
+ inputBindings,
14739
+ outputBindings,
14740
+ ...isToolInput && toolDescription !== void 0 ? { toolDescription } : {}
14741
+ }
14742
+ };
14743
+ }
14744
+ case "SetVariable":
14745
+ return {
14746
+ id: data.id,
14747
+ type: data.type,
14748
+ position,
14749
+ arguments: {
14750
+ variable: draft(data.arguments.variable),
14751
+ value: data.arguments.value
14752
+ }
14753
+ };
14754
+ case "MqttPublish":
14755
+ return {
14756
+ id: data.id,
14757
+ type: data.type,
14758
+ position,
14759
+ arguments: {
14760
+ channelReference: data.arguments.channelReference ?? "",
14761
+ dataType: data.arguments.dataType,
14762
+ value: data.arguments.value,
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)),
14765
+ retain: data.arguments.retain
14766
+ }
14767
+ };
14768
+ case "OnMqttMessage":
14769
+ return {
14770
+ id: data.id,
14771
+ type: data.type,
14772
+ position,
14773
+ arguments: {
14774
+ channelReference: data.arguments.channelReference ?? "",
14775
+ dataType: data.arguments.dataType,
14776
+ output: data.arguments.output
14777
+ }
14778
+ };
14779
+ }
14780
+ }
14352
14781
  function deserialize6(apiNode, resolveFunctionInfo) {
14353
14782
  return { ...deserializeNodeData(apiNode, resolveFunctionInfo), position: apiNode.position };
14354
14783
  }
@@ -14587,6 +15016,43 @@ var MAIN_CANVAS_ID = "main";
14587
15016
 
14588
15017
  // ../workflow-core/src/workflow/serialization.ts
14589
15018
  var KNOWN_CHANNEL_TYPES = new Set(ALL_CHANNEL_TYPES);
15019
+ function serialize7(state) {
15020
+ const mainCanvas = state.canvases[MAIN_CANVAS_ID];
15021
+ if (!mainCanvas) {
15022
+ throw new Error("Main canvas missing");
15023
+ }
15024
+ const mainNodes = mainCanvas.nodes.map((n) => serialize6(n, isNodeUsedAsTool(n.id, n, mainCanvas.edges)));
15025
+ const mainEdges = mainCanvas.edges.map(serialize2);
15026
+ const mainDeclared = extractDeclaredVariables(mainCanvas.variables);
15027
+ const functions = [];
15028
+ for (const [id, decl] of Object.entries(state.functions)) {
15029
+ const body = state.canvases[id];
15030
+ if (!body) {
15031
+ throw new Error(`[workflow-core] function ${id} has no body canvas \u2014 cannot serialize`);
15032
+ }
15033
+ const { functionInfo, outputAssignments } = serialize(decl);
15034
+ functions.push({
15035
+ functionInfo,
15036
+ outputAssignments,
15037
+ nodes: body.nodes.map((n) => serialize6(n, isNodeUsedAsTool(n.id, n, body.edges))),
15038
+ edges: body.edges.map(serialize2),
15039
+ declaredVariables: extractDeclaredVariables(body.variables)
15040
+ });
15041
+ }
15042
+ const channels = Object.values(state.channels).map(serialize3);
15043
+ const memory = Object.values(state.memory).map(serialize4);
15044
+ const models = Object.values(state.models).map(serialize5);
15045
+ return {
15046
+ schemaVersion: CURRENT_SCHEMA_VERSION,
15047
+ nodes: mainNodes,
15048
+ edges: mainEdges,
15049
+ functions,
15050
+ declaredVariables: mainDeclared,
15051
+ channels,
15052
+ memory,
15053
+ models
15054
+ };
15055
+ }
14590
15056
  function deserialize7(workflow) {
14591
15057
  const canvases = {};
14592
15058
  const functions = {};
@@ -14679,6 +15145,20 @@ function buildCanvasVariables(nodes, fnArgs, declaredVariables) {
14679
15145
  }
14680
15146
  return variables;
14681
15147
  }
15148
+ function extractDeclaredVariables(variables) {
15149
+ const result = [];
15150
+ for (const v of Object.values(variables)) {
15151
+ if (v.kind === "declared") {
15152
+ result.push({
15153
+ uid: v.uid,
15154
+ name: v.name,
15155
+ dataType: v.dataType,
15156
+ ...v.initialValue !== void 0 ? { initialValue: v.initialValue } : {}
15157
+ });
15158
+ }
15159
+ }
15160
+ return result;
15161
+ }
14682
15162
 
14683
15163
  // ../workflow-core/src/expression/types.ts
14684
15164
  function resolveExpression(apiExpr, availableVars) {
@@ -16575,12 +17055,21 @@ function printReport(file2, result) {
16575
17055
  `);
16576
17056
  for (const d of result.memoryDiagnostics) printDiagnostic(d, " ");
16577
17057
  }
17058
+ if (result.modelDiagnostics.length > 0) {
17059
+ out.write(`
17060
+ models:
17061
+ `);
17062
+ for (const d of result.modelDiagnostics) printDiagnostic(d, " ");
17063
+ }
16578
17064
  }
16579
17065
  function printDiagnostic(d, indent) {
16580
17066
  const where = [];
16581
17067
  if (d.nodeId) where.push(`node ${d.nodeId}`);
16582
17068
  if (d.edgeId) where.push(`edge ${d.edgeId}`);
16583
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}`);
16584
17073
  if (d.paramId) where.push(`param ${d.paramId}`);
16585
17074
  if (d.outputId) where.push(`output ${d.outputId}`);
16586
17075
  const location = where.length > 0 ? ` (${where.join(", ")})` : "";
@@ -16595,7 +17084,11 @@ function pluralize(n) {
16595
17084
  // cli/check-schema.ts
16596
17085
  var import_ajv = __toESM(require_ajv(), 1);
16597
17086
  var import_ajv_formats = __toESM(require_dist(), 1);
16598
- import { existsSync, readFileSync, promises as fs5 } from "node:fs";
17087
+ import { promises as fs5 } from "node:fs";
17088
+ import path7 from "node:path";
17089
+
17090
+ // cli/contract.ts
17091
+ import { existsSync, readFileSync } from "node:fs";
16599
17092
  import path6 from "node:path";
16600
17093
  import { fileURLToPath as fileURLToPath2 } from "node:url";
16601
17094
 
@@ -18961,7 +19454,7 @@ var import_js_yaml = /* @__PURE__ */ __toESM2((/* @__PURE__ */ __commonJSMin(((e
18961
19454
  var { Type, Schema, FAILSAFE_SCHEMA, JSON_SCHEMA, CORE_SCHEMA, DEFAULT_SCHEMA, load, loadAll, dump, YAMLException, types, safeLoad, safeLoadAll, safeDump } = import_js_yaml.default;
18962
19455
  var index_vite_proxy_tmp_default = import_js_yaml.default;
18963
19456
 
18964
- // cli/check-schema.ts
19457
+ // cli/contract.ts
18965
19458
  var here = path6.dirname(fileURLToPath2(import.meta.url));
18966
19459
  var CONTRACT_DIR = existsSync(path6.join(here, "workflow.yaml")) ? here : path6.resolve(here, "../../../contract");
18967
19460
  function loadContractDocument(fileName) {
@@ -18987,6 +19480,8 @@ function openApiToJsonSchema(value) {
18987
19480
  }
18988
19481
  return out;
18989
19482
  }
19483
+
19484
+ // cli/check-schema.ts
18990
19485
  var cachedValidator;
18991
19486
  function compileWorkflowValidator() {
18992
19487
  if (cachedValidator) return cachedValidator;
@@ -19011,7 +19506,7 @@ async function checkSchemaCommand(filePath) {
19011
19506
  process.stderr.write("Usage: fh-workflow check-schema <file.json>\n");
19012
19507
  process.exit(1);
19013
19508
  }
19014
- const abs = path6.resolve(process.cwd(), filePath);
19509
+ const abs = path7.resolve(process.cwd(), filePath);
19015
19510
  let raw;
19016
19511
  try {
19017
19512
  raw = await fs5.readFile(abs, "utf-8");
@@ -19060,35 +19555,35 @@ function describeParams(e) {
19060
19555
  return "";
19061
19556
  }
19062
19557
 
19063
- // cli/deploy/index.ts
19064
- import { promises as fs8 } from "node:fs";
19065
- import path10 from "node:path";
19066
- import { parseArgs } from "node:util";
19067
-
19068
- // cli/deploy/inspect.ts
19069
- function assertNever(channel) {
19070
- throw new Error(`unhandled channel type: ${JSON.stringify(channel)}`);
19071
- }
19072
- function inspect(workflow) {
19073
- const customModelIds = new Set((workflow.models ?? []).map((m) => m.id));
19074
- let hasProviderModel = false;
19075
- let hasRetriever = false;
19076
- let hasWebSearch = false;
19077
- const allNodes = [...workflow.nodes, ...(workflow.functions ?? []).flatMap((f) => f.nodes ?? [])];
19078
- for (const node of allNodes) {
19079
- if (node.type === "Agent") {
19080
- const modelId = node.arguments.model;
19081
- if (!modelId) continue;
19082
- if (!customModelIds.has(modelId)) hasProviderModel = true;
19083
- } else if (node.type === "Retriever") {
19084
- hasRetriever = true;
19085
- } else if (node.type === "WebSearchTool") {
19086
- hasWebSearch = true;
19558
+ // ../workflow-core/src/deploy/requirements.ts
19559
+ function getReferencedCatalogModelIds(workflow) {
19560
+ const declaredModel = new Set(Object.keys(workflow.models));
19561
+ const catalogIds = /* @__PURE__ */ new Set();
19562
+ for (const canvas of Object.values(workflow.canvases)) {
19563
+ for (const node of canvas.nodes) {
19564
+ const def = NodeRegistry.getByType(node.type);
19565
+ if (!def) continue;
19566
+ const args2 = node.arguments;
19567
+ const isToolInput = isNodeUsedAsTool(node.id, node, canvas.edges);
19568
+ for (const param of def.parameters) {
19569
+ if (param.type !== "modelSelect") continue;
19570
+ if (!isParameterActive(param, args2, isToolInput)) continue;
19571
+ const id = args2[param.id];
19572
+ if (typeof id === "string" && id !== "" && !declaredModel.has(id)) {
19573
+ catalogIds.add(id);
19574
+ }
19575
+ }
19087
19576
  }
19088
19577
  }
19578
+ return [...catalogIds];
19579
+ }
19580
+ function assertNeverChannel(t) {
19581
+ throw new Error(`unhandled channel type: ${String(t)}`);
19582
+ }
19583
+ function deriveRequirements(workflow) {
19089
19584
  const hardwareChannels = [];
19090
19585
  const mqttChannels = [];
19091
- for (const channel of workflow.channels) {
19586
+ for (const channel of Object.values(workflow.channels)) {
19092
19587
  switch (channel.type) {
19093
19588
  case "GPIOIN":
19094
19589
  case "GPIOOUT":
@@ -19109,14 +19604,277 @@ function inspect(workflow) {
19109
19604
  case "MQTT":
19110
19605
  mqttChannels.push({ id: channel.id, label: channel.label });
19111
19606
  break;
19607
+ case "LOG":
19608
+ break;
19609
+ default:
19610
+ return assertNeverChannel(channel.type);
19611
+ }
19612
+ }
19613
+ let hasRetriever = false;
19614
+ let hasWebSearch = false;
19615
+ for (const canvas of Object.values(workflow.canvases)) {
19616
+ for (const node of canvas.nodes) {
19617
+ if (node.type === "Retriever") hasRetriever = true;
19618
+ else if (node.type === "WebSearchTool") hasWebSearch = true;
19619
+ }
19620
+ }
19621
+ const customModels = Object.values(workflow.models).map((m) => ({ id: m.id, label: m.label }));
19622
+ return {
19623
+ hasProviderModel: getReferencedCatalogModelIds(workflow).length > 0,
19624
+ hasRetriever,
19625
+ hasWebSearch,
19626
+ hardwareChannels,
19627
+ mqttChannels,
19628
+ customModels
19629
+ };
19630
+ }
19631
+
19632
+ // ../workflow-core/src/deploy/spec.ts
19633
+ var COMPONENT_WORKSPACE_PATH = "/var/lib/foresthub/workspace";
19634
+ var STATE_ROOT = ".";
19635
+ function workspaceDir(container) {
19636
+ return `${STATE_ROOT}/workspaces/${container}`;
19637
+ }
19638
+ var RefAllocator = class {
19639
+ byKey = /* @__PURE__ */ new Map();
19640
+ used = /* @__PURE__ */ new Set();
19641
+ alloc(key, hint) {
19642
+ const existing = this.byKey.get(key);
19643
+ if (existing) return existing;
19644
+ let ref = hint;
19645
+ for (let n = 2; this.used.has(ref); n++) ref = `${hint}-${n}`;
19646
+ this.used.add(ref);
19647
+ this.byKey.set(key, ref);
19648
+ return ref;
19649
+ }
19650
+ };
19651
+ function basename(p) {
19652
+ const tail = p.replace(/\/+$/, "").split("/").pop() ?? p;
19653
+ return tail.replace(/[^A-Za-z0-9._:-]/g, "-") || "res";
19654
+ }
19655
+ function brokerHost(url2) {
19656
+ try {
19657
+ return new URL(url2).hostname || "mqtt";
19658
+ } catch {
19659
+ return "mqtt";
19660
+ }
19661
+ }
19662
+ function sidecarServiceName(modelId) {
19663
+ const slug = modelId.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
19664
+ return `llama-${slug || "model"}`;
19665
+ }
19666
+ function ggufNameError(name) {
19667
+ const t = (name ?? "").trim();
19668
+ if (!t) return "a model filename is required";
19669
+ if (!t.toLowerCase().endsWith(".gguf")) return "must be a .gguf file (llama-server only loads GGUF)";
19670
+ if (t.includes("/")) return "just the filename, not a path \u2014 the file goes in the model's workspace dir (./workspaces/llama-\u2026/)";
19671
+ return null;
19672
+ }
19673
+ function hardwareAddressKey(family, chipOrDevice, index) {
19674
+ const dev = chipOrDevice.trim();
19675
+ return family === "serial" ? `serial:${dev}` : `${family}:${dev}:${index}`;
19676
+ }
19677
+ function hardwareAddressLabel(family, chipOrDevice, index) {
19678
+ const dev = chipOrDevice.trim();
19679
+ if (family === "serial") return dev;
19680
+ return `${dev} ${family === "gpio" ? "line" : "channel"} ${index}`;
19681
+ }
19682
+ function hardwareConflicts(channels, bindings) {
19683
+ const conflicts = [];
19684
+ const claimed = /* @__PURE__ */ new Map();
19685
+ for (const ch of channels) {
19686
+ const b = bindings[ch.id];
19687
+ if (!b?.chipOrDevice || ch.addressable && b.index === void 0) continue;
19688
+ const key = hardwareAddressKey(ch.family, b.chipOrDevice, b.index);
19689
+ const holder = claimed.get(key);
19690
+ if (holder) {
19691
+ conflicts.push(`hardware "${ch.id}": ${hardwareAddressLabel(ch.family, b.chipOrDevice, b.index)} is already used by "${holder}"`);
19692
+ } else {
19693
+ claimed.set(key, ch.id);
19694
+ }
19695
+ }
19696
+ return conflicts;
19697
+ }
19698
+ function familyMismatches(channels, bindings) {
19699
+ const mismatches = [];
19700
+ for (const ch of channels) {
19701
+ const b = bindings[ch.id];
19702
+ if (!b) continue;
19703
+ if (ch.family !== "serial" && b.baud !== void 0) {
19704
+ mismatches.push(`hardware "${ch.id}": "baud" only applies to serial channels (this is a ${ch.family} channel)`);
19705
+ }
19706
+ if (ch.family === "serial" && b.index !== void 0) {
19707
+ mismatches.push(`hardware "${ch.id}": "index" does not apply to serial channels (the device path is the full address)`);
19708
+ }
19709
+ }
19710
+ return mismatches;
19711
+ }
19712
+ function assertNeverFamily(f) {
19713
+ throw new Error(`unhandled hardware family: ${String(f)}`);
19714
+ }
19715
+ function assertDeployable(req, inputs) {
19716
+ const missing = [];
19717
+ for (const ch of req.hardwareChannels) {
19718
+ const b = inputs.hardware[ch.id];
19719
+ if (!b?.chipOrDevice) missing.push(`hardware "${ch.id}": device path`);
19720
+ else if (ch.addressable && b.index === void 0) missing.push(`hardware "${ch.id}": index`);
19721
+ }
19722
+ for (const ch of req.mqttChannels) {
19723
+ if (!inputs.mqtt[ch.id]?.brokerUrl) missing.push(`mqtt "${ch.id}": broker URL`);
19724
+ }
19725
+ for (const m of req.customModels) {
19726
+ const b = inputs.models[m.id];
19727
+ if (b?.location === "device") {
19728
+ const err = ggufNameError(b.modelFile);
19729
+ if (err) missing.push(`model "${m.id}": ${err}`);
19730
+ } else if (!b?.url) {
19731
+ missing.push(`model "${m.id}": endpoint URL`);
19732
+ }
19733
+ }
19734
+ missing.push(...hardwareConflicts(req.hardwareChannels, inputs.hardware));
19735
+ missing.push(...familyMismatches(req.hardwareChannels, inputs.hardware));
19736
+ if (missing.length > 0) {
19737
+ throw new Error(`invalid deploy config:
19738
+ - ${missing.join("\n - ")}`);
19739
+ }
19740
+ }
19741
+ function assertNoNameCollisions(components) {
19742
+ const seen = /* @__PURE__ */ new Set();
19743
+ for (const c of components) {
19744
+ if (seen.has(c.name)) throw new Error(`duplicate component name "${c.name}"`);
19745
+ seen.add(c.name);
19746
+ }
19747
+ }
19748
+ function buildDeploymentSpec(workflow, inputs, meta3, customComponents = []) {
19749
+ const req = deriveRequirements(workflow);
19750
+ assertDeployable(req, inputs);
19751
+ const refs = new RefAllocator();
19752
+ const resourceSecrets = {};
19753
+ const gpios = {};
19754
+ const adcs = {};
19755
+ const dacs = {};
19756
+ const pwms = {};
19757
+ const serials = {};
19758
+ const externalResources = {};
19759
+ const mapping = {};
19760
+ const cdev = /* @__PURE__ */ new Set();
19761
+ let privileged = false;
19762
+ for (const ch of req.hardwareChannels) {
19763
+ const b = inputs.hardware[ch.id];
19764
+ if (!b) throw new Error(`unbound hardware channel ${ch.id}`);
19765
+ const dev = b.chipOrDevice;
19766
+ const ref = refs.alloc(`hw:${ch.family}:${dev}`, basename(dev));
19767
+ switch (ch.family) {
19768
+ case "gpio":
19769
+ gpios[ref] = { chip: dev };
19770
+ cdev.add(dev);
19771
+ break;
19772
+ case "serial":
19773
+ serials[ref] = b.baud ? { device: dev, baud: b.baud } : { device: dev };
19774
+ cdev.add(dev);
19775
+ break;
19776
+ case "pwm":
19777
+ pwms[ref] = { chip: dev };
19778
+ privileged = true;
19779
+ break;
19780
+ case "adc":
19781
+ adcs[ref] = { device: dev };
19782
+ privileged = true;
19783
+ break;
19784
+ case "dac":
19785
+ dacs[ref] = { device: dev };
19786
+ privileged = true;
19787
+ break;
19112
19788
  default:
19113
- return assertNever(channel);
19789
+ return assertNeverFamily(ch.family);
19114
19790
  }
19791
+ if (ch.addressable && b.index !== void 0) mapping[ch.id] = { ref, index: b.index };
19792
+ else mapping[ch.id] = { ref };
19793
+ }
19794
+ for (const ch of req.mqttChannels) {
19795
+ const b = inputs.mqtt[ch.id];
19796
+ if (!b) throw new Error(`unbound mqtt channel ${ch.id}`);
19797
+ const conn = { type: "mqtt", brokerUrl: b.brokerUrl };
19798
+ if (b.username) conn.username = b.username;
19799
+ const ref = refs.alloc(`mqtt:${JSON.stringify(conn)}:${b.password ?? ""}`, `mqtt-${brokerHost(b.brokerUrl)}`);
19800
+ externalResources[ref] = conn;
19801
+ mapping[ch.id] = { ref };
19802
+ if (b.password) resourceSecrets[ref] = b.password;
19115
19803
  }
19116
- const customModels = (workflow.models ?? []).map((m) => ({ id: m.id, label: m.label }));
19117
- return { hasProviderModel, hasRetriever, hasWebSearch, hardwareChannels, mqttChannels, customModels };
19804
+ const llamaComponents = [];
19805
+ for (const m of req.customModels) {
19806
+ const b = inputs.models[m.id];
19807
+ if (!b) throw new Error(`unbound model ${m.id}`);
19808
+ const ref = refs.alloc(`model:${m.id}`, basename(m.id));
19809
+ mapping[m.id] = { ref };
19810
+ if (b.location === "device") {
19811
+ const port = b.port ?? 8080;
19812
+ const service = sidecarServiceName(m.id);
19813
+ externalResources[ref] = { type: "selfhosted", url: `http://${service}:${port}` };
19814
+ llamaComponents.push({
19815
+ name: service,
19816
+ image: meta3.llamaServerImage,
19817
+ command: [
19818
+ "--model",
19819
+ `${COMPONENT_WORKSPACE_PATH}/${b.modelFile}`,
19820
+ "--host",
19821
+ "0.0.0.0",
19822
+ "--port",
19823
+ String(port),
19824
+ "--ctx-size",
19825
+ String(b.ctxSize ?? 4096)
19826
+ ],
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`]
19830
+ });
19831
+ } else {
19832
+ externalResources[ref] = { type: "selfhosted", url: b.url };
19833
+ if (b.apiKey) resourceSecrets[ref] = b.apiKey;
19834
+ }
19835
+ }
19836
+ const manifest = {};
19837
+ if (Object.keys(gpios).length) manifest.gpios = gpios;
19838
+ if (Object.keys(adcs).length) manifest.adcs = adcs;
19839
+ if (Object.keys(dacs).length) manifest.dacs = dacs;
19840
+ if (Object.keys(pwms).length) manifest.pwms = pwms;
19841
+ if (Object.keys(serials).length) manifest.serials = serials;
19842
+ const config2 = { workflow: serialize7(workflow) };
19843
+ if (Object.keys(mapping).length) config2.mapping = mapping;
19844
+ if (Object.keys(externalResources).length) config2.externalResources = externalResources;
19845
+ if (Object.keys(manifest).length) config2.manifest = manifest;
19846
+ const engine = {
19847
+ name: "engine",
19848
+ image: meta3.engineImage,
19849
+ pull: "never",
19850
+ // built locally before deploy, in no registry
19851
+ config: config2,
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"
19859
+ };
19860
+ if (cdev.size > 0) engine.devices = [...cdev];
19861
+ if (privileged) engine.privileged = true;
19862
+ const components = [engine, ...llamaComponents, ...customComponents];
19863
+ assertNoNameCollisions(components);
19864
+ const spec = {
19865
+ schemaVersion: 1,
19866
+ id: meta3.id,
19867
+ components
19868
+ };
19869
+ if (meta3.createdAt) spec.createdAt = meta3.createdAt;
19870
+ return { spec, resourceSecrets };
19118
19871
  }
19119
19872
 
19873
+ // cli/deploy/index.ts
19874
+ import { promises as fs9 } from "node:fs";
19875
+ import path12 from "node:path";
19876
+ import { parseArgs } from "node:util";
19877
+
19120
19878
  // ../node_modules/@inquirer/core/dist/lib/key.js
19121
19879
  var keybindings = ["emacs", "vim"];
19122
19880
  var keybindingLookup = new Set(keybindings);
@@ -20520,7 +21278,7 @@ var PromisePolyfill = class extends Promise {
20520
21278
  };
20521
21279
 
20522
21280
  // ../node_modules/@inquirer/core/dist/lib/create-prompt.js
20523
- import path7 from "node:path";
21281
+ import path8 from "node:path";
20524
21282
  var nativeSetImmediate = globalThis.setImmediate;
20525
21283
  function getCallSites() {
20526
21284
  const savedPrepareStackTrace = Error.prepareStackTrace;
@@ -20591,7 +21349,7 @@ function createPrompt(view) {
20591
21349
  if (nextView === void 0) {
20592
21350
  let callerFilename = callSites[1]?.getFileName();
20593
21351
  if (callerFilename && !callerFilename.startsWith("file://")) {
20594
- callerFilename = path7.resolve(callerFilename);
21352
+ callerFilename = path8.resolve(callerFilename);
20595
21353
  }
20596
21354
  throw new Error(`Prompt functions must return a string.
20597
21355
  at ${callerFilename}`);
@@ -20836,11 +21594,57 @@ var dist_default4 = createPrompt((config2, done) => {
20836
21594
  return `${lines}${cursorHide}`;
20837
21595
  });
20838
21596
 
21597
+ // ../node_modules/@inquirer/confirm/dist/index.js
21598
+ function getBooleanValue(value, defaultValue) {
21599
+ let answer = defaultValue !== false;
21600
+ if (/^(y|yes)/i.test(value))
21601
+ answer = true;
21602
+ else if (/^(n|no)/i.test(value))
21603
+ answer = false;
21604
+ return answer;
21605
+ }
21606
+ function boolToString(value) {
21607
+ return value ? "Yes" : "No";
21608
+ }
21609
+ var dist_default5 = createPrompt((config2, done) => {
21610
+ const { transformer = boolToString } = config2;
21611
+ const [status, setStatus] = useState("idle");
21612
+ const [value, setValue] = useState("");
21613
+ const theme = makeTheme(config2.theme);
21614
+ const prefix = usePrefix({ status, theme });
21615
+ useKeypress((key, rl) => {
21616
+ if (status !== "idle")
21617
+ return;
21618
+ if (isEnterKey(key)) {
21619
+ const answer = getBooleanValue(value, config2.default);
21620
+ setValue(transformer(answer));
21621
+ setStatus("done");
21622
+ done(answer);
21623
+ } else if (isTabKey(key)) {
21624
+ const answer = boolToString(!getBooleanValue(value, config2.default));
21625
+ rl.clearLine(0);
21626
+ rl.write(answer);
21627
+ setValue(answer);
21628
+ } else {
21629
+ setValue(rl.line);
21630
+ }
21631
+ });
21632
+ let formattedValue = value;
21633
+ let defaultValue = "";
21634
+ if (status === "done") {
21635
+ formattedValue = theme.style.answer(value);
21636
+ } else {
21637
+ defaultValue = ` ${theme.style.defaultAnswer(config2.default === false ? "y/N" : "Y/n")}`;
21638
+ }
21639
+ const message = theme.style.message(config2.message, status);
21640
+ return `${prefix} ${message}${defaultValue} ${formattedValue}`;
21641
+ });
21642
+
20839
21643
  // ../node_modules/@inquirer/input/dist/index.js
20840
21644
  var inputTheme = {
20841
21645
  validationFailureMode: "keep"
20842
21646
  };
20843
- var dist_default5 = createPrompt((config2, done) => {
21647
+ var dist_default6 = createPrompt((config2, done) => {
20844
21648
  const { prefill = "tab" } = config2;
20845
21649
  const theme = makeTheme(inputTheme, config2.theme);
20846
21650
  const [status, setStatus] = useState("idle");
@@ -20927,7 +21731,7 @@ var passwordTheme = {
20927
21731
  maskedText: "[input is masked]"
20928
21732
  }
20929
21733
  };
20930
- var dist_default6 = createPrompt((config2, done) => {
21734
+ var dist_default7 = createPrompt((config2, done) => {
20931
21735
  const { validate = () => true } = config2;
20932
21736
  const theme = makeTheme(passwordTheme, config2.theme);
20933
21737
  const [status, setStatus] = useState("idle");
@@ -21019,7 +21823,7 @@ function normalizeChoices2(choices) {
21019
21823
  return normalizedChoice;
21020
21824
  });
21021
21825
  }
21022
- var dist_default7 = createPrompt((config2, done) => {
21826
+ var dist_default8 = createPrompt((config2, done) => {
21023
21827
  const { loop = true, pageSize = 7 } = config2;
21024
21828
  const theme = makeTheme(selectTheme, config2.theme);
21025
21829
  const { keybindings: keybindings2 } = theme;
@@ -21148,8 +21952,145 @@ var dist_default7 = createPrompt((config2, done) => {
21148
21952
  });
21149
21953
 
21150
21954
  // cli/deploy/prompts.ts
21955
+ import { existsSync as existsSync3, promises as fs7 } from "node:fs";
21956
+ import path10 from "node:path";
21957
+
21958
+ // cli/deploy/components.ts
21151
21959
  import { existsSync as existsSync2, promises as fs6 } from "node:fs";
21152
- import path8 from "node:path";
21960
+ import path9 from "node:path";
21961
+ var import_ajv2 = __toESM(require_ajv(), 1);
21962
+ var import_ajv_formats2 = __toESM(require_dist(), 1);
21963
+ async function readComponentJson(dir) {
21964
+ const file2 = path9.join(dir, "component.json");
21965
+ let raw;
21966
+ try {
21967
+ raw = await fs6.readFile(file2, "utf-8");
21968
+ } catch (err) {
21969
+ if (err.code === "ENOENT") {
21970
+ throw new Error(`custom component: no component.json in ${dir}`, { cause: err });
21971
+ }
21972
+ throw err;
21973
+ }
21974
+ try {
21975
+ return JSON.parse(raw);
21976
+ } catch (err) {
21977
+ throw new Error(`${file2}: invalid JSON: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
21978
+ }
21979
+ }
21980
+ var cachedValidator2;
21981
+ function compileDeployComponentValidator() {
21982
+ if (cachedValidator2) return cachedValidator2;
21983
+ const deployment = openApiToJsonSchema(loadContractDocument("deployment.yaml"));
21984
+ deployment.$id = "deployment.yaml";
21985
+ const schemas = deployment.components.schemas;
21986
+ schemas.DeployComponent.additionalProperties = false;
21987
+ const ajv = new import_ajv2.default({ strict: false, allErrors: true });
21988
+ (0, import_ajv_formats2.default)(ajv);
21989
+ ajv.addSchema(deployment);
21990
+ cachedValidator2 = ajv.compile({ $ref: "deployment.yaml#/components/schemas/DeployComponent" });
21991
+ return cachedValidator2;
21992
+ }
21993
+ function validateComponent(data) {
21994
+ const validate = compileDeployComponentValidator();
21995
+ if (validate(data)) return [];
21996
+ return (validate.errors ?? []).map((e) => {
21997
+ const p = e.params;
21998
+ const which = p.additionalProperty ?? p.missingProperty;
21999
+ const detail = typeof which === "string" ? ` "${which}"` : "";
22000
+ return `${e.instancePath || "/"} ${e.message ?? "invalid"}${detail}`;
22001
+ });
22002
+ }
22003
+ function parseDeployComponents(raw) {
22004
+ const errors = [];
22005
+ for (const { source, data } of raw) {
22006
+ for (const message of validateComponent(data)) errors.push(`${source}: ${message}`);
22007
+ }
22008
+ if (errors.length > 0) {
22009
+ throw new Error(`invalid custom component(s):
22010
+ - ${errors.join("\n - ")}`);
22011
+ }
22012
+ return raw.map((r) => r.data);
22013
+ }
22014
+ function parseEnvExample(text) {
22015
+ return text.split("\n").map((line) => {
22016
+ const m = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line);
22017
+ return m ? { kind: "kv", key: m[1] ?? "", value: m[2] ?? "" } : { kind: "comment", text: line };
22018
+ });
22019
+ }
22020
+ var SECRET_HINT = /password|secret|key|token/i;
22021
+ async function resolveComponentEnv(dir, name, opts) {
22022
+ const file2 = path9.join(dir, `${name}.env.example`);
22023
+ let raw;
22024
+ try {
22025
+ raw = await fs6.readFile(file2, "utf-8");
22026
+ } catch (err) {
22027
+ if (err.code === "ENOENT") return null;
22028
+ throw err;
22029
+ }
22030
+ const lines = [];
22031
+ for (const e of parseEnvExample(raw)) {
22032
+ if (e.kind === "comment") {
22033
+ lines.push(e.text);
22034
+ } else if (e.value !== "" || !opts.interactive) {
22035
+ lines.push(`${e.key}=${e.value}`);
22036
+ } else {
22037
+ const value = SECRET_HINT.test(e.key) ? await dist_default7({ message: `${name}: ${e.key}`, mask: "*" }) : await dist_default6({ message: `${name}: ${e.key}` });
22038
+ lines.push(`${e.key}=${value}`);
22039
+ }
22040
+ }
22041
+ const header = `# Auto-generated by \`fh-workflow deploy\` from ${name}.env.example.
22042
+ # Loaded into the ${name} container via compose env_file; chmod 600 if it holds
22043
+ # secrets, and fill any empty values before \`up\`.
22044
+ `;
22045
+ return `${header}
22046
+ ${lines.join("\n")}
22047
+ `;
22048
+ }
22049
+ async function promptCustomComponents(preloaded) {
22050
+ const components = [];
22051
+ const env = {};
22052
+ const seen = /* @__PURE__ */ new Set();
22053
+ const add = async (loaded) => {
22054
+ seen.add(loaded.component.name);
22055
+ components.push(loaded.component);
22056
+ const text = await resolveComponentEnv(loaded.dir, loaded.component.name, { interactive: true });
22057
+ if (text !== null) env[loaded.component.name] = text;
22058
+ };
22059
+ if (preloaded.length > 0) {
22060
+ process.stdout.write(` Already added: ${preloaded.map((c) => c.component.name).join(", ")}
22061
+ `);
22062
+ }
22063
+ for (const loaded of preloaded) await add(loaded);
22064
+ while (await dist_default5({
22065
+ message: components.length === 0 ? "Add a custom component?" : "Add another custom component?",
22066
+ default: false
22067
+ })) {
22068
+ const dir = (await dist_default6({
22069
+ message: "Custom component folder",
22070
+ // Full inline validation: path, component.json presence, contract shape,
22071
+ // and a name not already taken — the same checks the batch path runs, so
22072
+ // a bad folder is rejected here instead of failing late in the build.
22073
+ validate: async (v) => {
22074
+ const t = v.trim();
22075
+ if (!t) return "enter a folder path";
22076
+ if (!existsSync2(t)) return `folder not found: ${t}`;
22077
+ if (!existsSync2(path9.join(t, "component.json"))) return `no component.json in ${t}`;
22078
+ let data;
22079
+ try {
22080
+ data = await readComponentJson(t);
22081
+ } catch (err) {
22082
+ return err instanceof Error ? err.message : String(err);
22083
+ }
22084
+ const errors = validateComponent(data);
22085
+ if (errors.length > 0) return errors.join("; ");
22086
+ const name = data.name;
22087
+ return seen.has(name) ? `"${name}" already added` : true;
22088
+ }
22089
+ })).trim();
22090
+ await add({ component: await readComponentJson(dir), dir });
22091
+ }
22092
+ return { components, env };
22093
+ }
21153
22094
 
21154
22095
  // ../node_modules/zod/v4/classic/external.js
21155
22096
  var external_exports = {};
@@ -21761,7 +22702,7 @@ __export(util_exports, {
21761
22702
  assert: () => assert,
21762
22703
  assertEqual: () => assertEqual,
21763
22704
  assertIs: () => assertIs,
21764
- assertNever: () => assertNever2,
22705
+ assertNever: () => assertNever,
21765
22706
  assertNotEqual: () => assertNotEqual,
21766
22707
  assignProp: () => assignProp,
21767
22708
  base64ToUint8Array: () => base64ToUint8Array,
@@ -21825,7 +22766,7 @@ function assertNotEqual(val) {
21825
22766
  }
21826
22767
  function assertIs(_arg) {
21827
22768
  }
21828
- function assertNever2(_x) {
22769
+ function assertNever(_x) {
21829
22770
  throw new Error("Unexpected value in exhaustive check");
21830
22771
  }
21831
22772
  function assert(_) {
@@ -21917,10 +22858,10 @@ function mergeDefs(...defs) {
21917
22858
  function cloneDef(schema) {
21918
22859
  return mergeDefs(schema._zod.def);
21919
22860
  }
21920
- function getElementAtPath(obj, path11) {
21921
- if (!path11)
22861
+ function getElementAtPath(obj, path13) {
22862
+ if (!path13)
21922
22863
  return obj;
21923
- return path11.reduce((acc, key) => acc?.[key], obj);
22864
+ return path13.reduce((acc, key) => acc?.[key], obj);
21924
22865
  }
21925
22866
  function promiseAllObject(promisesObj) {
21926
22867
  const keys = Object.keys(promisesObj);
@@ -22329,11 +23270,11 @@ function explicitlyAborted(x, startIndex = 0) {
22329
23270
  }
22330
23271
  return false;
22331
23272
  }
22332
- function prefixIssues(path11, issues) {
23273
+ function prefixIssues(path13, issues) {
22333
23274
  return issues.map((iss) => {
22334
23275
  var _a3;
22335
23276
  (_a3 = iss).path ?? (_a3.path = []);
22336
- iss.path.unshift(path11);
23277
+ iss.path.unshift(path13);
22337
23278
  return iss;
22338
23279
  });
22339
23280
  }
@@ -22480,16 +23421,16 @@ function flattenError(error51, mapper = (issue2) => issue2.message) {
22480
23421
  }
22481
23422
  function formatError(error51, mapper = (issue2) => issue2.message) {
22482
23423
  const fieldErrors = { _errors: [] };
22483
- const processError = (error52, path11 = []) => {
23424
+ const processError = (error52, path13 = []) => {
22484
23425
  for (const issue2 of error52.issues) {
22485
23426
  if (issue2.code === "invalid_union" && issue2.errors.length) {
22486
- issue2.errors.map((issues) => processError({ issues }, [...path11, ...issue2.path]));
23427
+ issue2.errors.map((issues) => processError({ issues }, [...path13, ...issue2.path]));
22487
23428
  } else if (issue2.code === "invalid_key") {
22488
- processError({ issues: issue2.issues }, [...path11, ...issue2.path]);
23429
+ processError({ issues: issue2.issues }, [...path13, ...issue2.path]);
22489
23430
  } else if (issue2.code === "invalid_element") {
22490
- processError({ issues: issue2.issues }, [...path11, ...issue2.path]);
23431
+ processError({ issues: issue2.issues }, [...path13, ...issue2.path]);
22491
23432
  } else {
22492
- const fullpath = [...path11, ...issue2.path];
23433
+ const fullpath = [...path13, ...issue2.path];
22493
23434
  if (fullpath.length === 0) {
22494
23435
  fieldErrors._errors.push(mapper(issue2));
22495
23436
  } else {
@@ -22516,17 +23457,17 @@ function formatError(error51, mapper = (issue2) => issue2.message) {
22516
23457
  }
22517
23458
  function treeifyError(error51, mapper = (issue2) => issue2.message) {
22518
23459
  const result = { errors: [] };
22519
- const processError = (error52, path11 = []) => {
23460
+ const processError = (error52, path13 = []) => {
22520
23461
  var _a3, _b;
22521
23462
  for (const issue2 of error52.issues) {
22522
23463
  if (issue2.code === "invalid_union" && issue2.errors.length) {
22523
- issue2.errors.map((issues) => processError({ issues }, [...path11, ...issue2.path]));
23464
+ issue2.errors.map((issues) => processError({ issues }, [...path13, ...issue2.path]));
22524
23465
  } else if (issue2.code === "invalid_key") {
22525
- processError({ issues: issue2.issues }, [...path11, ...issue2.path]);
23466
+ processError({ issues: issue2.issues }, [...path13, ...issue2.path]);
22526
23467
  } else if (issue2.code === "invalid_element") {
22527
- processError({ issues: issue2.issues }, [...path11, ...issue2.path]);
23468
+ processError({ issues: issue2.issues }, [...path13, ...issue2.path]);
22528
23469
  } else {
22529
- const fullpath = [...path11, ...issue2.path];
23470
+ const fullpath = [...path13, ...issue2.path];
22530
23471
  if (fullpath.length === 0) {
22531
23472
  result.errors.push(mapper(issue2));
22532
23473
  continue;
@@ -22558,8 +23499,8 @@ function treeifyError(error51, mapper = (issue2) => issue2.message) {
22558
23499
  }
22559
23500
  function toDotPath(_path) {
22560
23501
  const segs = [];
22561
- const path11 = _path.map((seg) => typeof seg === "object" ? seg.key : seg);
22562
- for (const seg of path11) {
23502
+ const path13 = _path.map((seg) => typeof seg === "object" ? seg.key : seg);
23503
+ for (const seg of path13) {
22563
23504
  if (typeof seg === "number")
22564
23505
  segs.push(`[${seg}]`);
22565
23506
  else if (typeof seg === "symbol")
@@ -35251,13 +36192,13 @@ function resolveRef(ref, ctx) {
35251
36192
  if (!ref.startsWith("#")) {
35252
36193
  throw new Error("External $ref is not supported, only local refs (#/...) are allowed");
35253
36194
  }
35254
- const path11 = ref.slice(1).split("/").filter(Boolean);
35255
- if (path11.length === 0) {
36195
+ const path13 = ref.slice(1).split("/").filter(Boolean);
36196
+ if (path13.length === 0) {
35256
36197
  return ctx.rootSchema;
35257
36198
  }
35258
36199
  const defsKey = ctx.version === "draft-2020-12" ? "$defs" : "definitions";
35259
- if (path11[0] === defsKey) {
35260
- const key = path11[1];
36200
+ if (path13[0] === defsKey) {
36201
+ const key = path13[1];
35261
36202
  if (!key || !ctx.defs[key]) {
35262
36203
  throw new Error(`Reference not found: ${ref}`);
35263
36204
  }
@@ -35679,55 +36620,18 @@ var mqttBindingSchema = external_exports.strictObject({
35679
36620
  password: external_exports.string().optional()
35680
36621
  });
35681
36622
  var modelBindingSchema = external_exports.discriminatedUnion("location", [
35682
- external_exports.strictObject({ location: external_exports.literal("device"), modelFile: external_exports.string() }),
36623
+ external_exports.strictObject({
36624
+ location: external_exports.literal("device"),
36625
+ modelFile: external_exports.string(),
36626
+ port: external_exports.number().int().positive().optional(),
36627
+ ctxSize: external_exports.number().int().positive().optional()
36628
+ }),
35683
36629
  external_exports.strictObject({ location: external_exports.literal("network"), url: external_exports.string(), apiKey: external_exports.string().optional() })
35684
36630
  ]);
35685
- function ggufNameError(name) {
35686
- const t = (name ?? "").trim();
35687
- if (!t) return "a model filename is required";
35688
- if (!t.toLowerCase().endsWith(".gguf")) return "must be a .gguf file (llama-server only loads GGUF)";
35689
- if (t.includes("/")) return "just the filename, not a path \u2014 the file goes in ./models/";
35690
- return null;
35691
- }
35692
- function hardwareAddressKey(family, chipOrDevice, index) {
35693
- const dev = chipOrDevice.trim();
35694
- return family === "serial" ? `serial:${dev}` : `${family}:${dev}:${index}`;
35695
- }
35696
- function hardwareAddressLabel(family, chipOrDevice, index) {
35697
- const dev = chipOrDevice.trim();
35698
- if (family === "serial") return dev;
35699
- return `${dev} ${family === "gpio" ? "line" : "channel"} ${index}`;
35700
- }
35701
- function hardwareConflicts(channels, bindings) {
35702
- const conflicts = [];
35703
- const claimed = /* @__PURE__ */ new Map();
35704
- for (const ch of channels) {
35705
- const b = bindings[ch.id];
35706
- if (!b?.chipOrDevice || ch.addressable && b.index === void 0) continue;
35707
- const key = hardwareAddressKey(ch.family, b.chipOrDevice, b.index);
35708
- const holder = claimed.get(key);
35709
- if (holder) {
35710
- conflicts.push(`hardware "${ch.id}": ${hardwareAddressLabel(ch.family, b.chipOrDevice, b.index)} is already used by "${holder}"`);
35711
- } else {
35712
- claimed.set(key, ch.id);
35713
- }
35714
- }
35715
- return conflicts;
35716
- }
35717
- function familyMismatches(channels, bindings) {
35718
- const mismatches = [];
35719
- for (const ch of channels) {
35720
- const b = bindings[ch.id];
35721
- if (!b) continue;
35722
- if (ch.family !== "serial" && b.baud !== void 0) {
35723
- mismatches.push(`hardware "${ch.id}": "baud" only applies to serial channels (this is a ${ch.family} channel)`);
35724
- }
35725
- if (ch.family === "serial" && b.index !== void 0) {
35726
- mismatches.push(`hardware "${ch.id}": "index" does not apply to serial channels (the device path is the full address)`);
35727
- }
35728
- }
35729
- return mismatches;
35730
- }
36631
+ var webSearchBindingSchema = external_exports.strictObject({
36632
+ provider: external_exports.string(),
36633
+ apiKey: external_exports.string()
36634
+ });
35731
36635
  function unknownIds(req, p) {
35732
36636
  const unknown2 = [];
35733
36637
  const check3 = (kind, ids, bindings) => {
@@ -35741,10 +36645,6 @@ function unknownIds(req, p) {
35741
36645
  check3("model", req.customModels.map((m) => m.id), p.models);
35742
36646
  return unknown2;
35743
36647
  }
35744
- var webSearchBindingSchema = external_exports.strictObject({
35745
- provider: external_exports.string(),
35746
- apiKey: external_exports.string()
35747
- });
35748
36648
  var deployConfigSchema = external_exports.strictObject({
35749
36649
  llmKeys: external_exports.partialRecord(external_exports.enum(ALL_PROVIDERS), external_exports.string()),
35750
36650
  outputDir: external_exports.string(),
@@ -35780,7 +36680,7 @@ async function promptHardware(channels, seed) {
35780
36680
  }
35781
36681
  for (const ch of channels) {
35782
36682
  if (result[ch.id]) continue;
35783
- const chipOrDevice = await dist_default5({
36683
+ const chipOrDevice = await dist_default6({
35784
36684
  message: `${ch.label} (${ch.family}): device path`,
35785
36685
  default: HARDWARE_EXAMPLE[ch.family],
35786
36686
  // Only serial claims the whole device; gpio/adc/dac/pwm share the path
@@ -35794,7 +36694,7 @@ async function promptHardware(channels, seed) {
35794
36694
  const dev = chipOrDevice.trim();
35795
36695
  const binding = { chipOrDevice: dev };
35796
36696
  if (ch.addressable) {
35797
- const idx = await dist_default5({
36697
+ const idx = await dist_default6({
35798
36698
  message: `${ch.label}: channel index / GPIO line`,
35799
36699
  validate: (v) => {
35800
36700
  const uint = isUint(v);
@@ -35806,7 +36706,7 @@ async function promptHardware(channels, seed) {
35806
36706
  binding.index = Number(idx.trim());
35807
36707
  }
35808
36708
  if (ch.family === "serial") {
35809
- const baud = await dist_default5({ message: `${ch.label}: baud rate`, default: "115200", validate: isUint });
36709
+ const baud = await dist_default6({ message: `${ch.label}: baud rate`, default: "115200", validate: isUint });
35810
36710
  binding.baud = Number(baud.trim());
35811
36711
  }
35812
36712
  result[ch.id] = binding;
@@ -35821,7 +36721,7 @@ async function promptMqtt(channels, seed) {
35821
36721
  const brokers = /* @__PURE__ */ new Map();
35822
36722
  for (const b of Object.values(result)) brokers.set(b.brokerUrl, b);
35823
36723
  if (brokers.size > 0) {
35824
- const choice = await dist_default7({
36724
+ const choice = await dist_default8({
35825
36725
  message: `${ch.label}: MQTT broker`,
35826
36726
  choices: [
35827
36727
  ...[...brokers.keys()].map((url2) => ({ value: url2, name: `reuse ${url2}` })),
@@ -35834,13 +36734,13 @@ async function promptMqtt(channels, seed) {
35834
36734
  continue;
35835
36735
  }
35836
36736
  }
35837
- const brokerUrl = await dist_default5({
36737
+ const brokerUrl = await dist_default6({
35838
36738
  message: `${ch.label}: broker URL`,
35839
36739
  default: "tcp://localhost:1883",
35840
36740
  validate: (v) => v.trim().length > 0 || "broker URL is required"
35841
36741
  });
35842
- const username = (await dist_default5({ message: `${ch.label}: username (optional)` })).trim();
35843
- const pass = await dist_default6({ message: `${ch.label}: password (optional)`, mask: "*" });
36742
+ const username = (await dist_default6({ message: `${ch.label}: username (optional)` })).trim();
36743
+ const pass = await dist_default7({ message: `${ch.label}: password (optional)`, mask: "*" });
35844
36744
  const binding = { brokerUrl: brokerUrl.trim() };
35845
36745
  if (username) binding.username = username;
35846
36746
  if (pass) binding.password = pass;
@@ -35852,7 +36752,7 @@ async function promptModels(models, seed) {
35852
36752
  const result = { ...seed };
35853
36753
  for (const m of models) {
35854
36754
  if (result[m.id]) continue;
35855
- const location = await dist_default7({
36755
+ const location = await dist_default8({
35856
36756
  message: `${m.label}: where does this model run?`,
35857
36757
  choices: [
35858
36758
  { value: "device", name: "on this device (generate a llama-server container)" },
@@ -35860,19 +36760,26 @@ async function promptModels(models, seed) {
35860
36760
  ]
35861
36761
  });
35862
36762
  if (location === "device") {
35863
- const modelFile = await dist_default5({
35864
- message: `${m.label}: model filename in ./models/ (e.g. model.gguf)`,
36763
+ const modelFile = await dist_default6({
36764
+ message: `${m.label}: model filename, dropped in its workspace dir (e.g. model.gguf)`,
35865
36765
  validate: (v) => ggufNameError(v) ?? true
35866
36766
  });
35867
- result[m.id] = { location: "device", modelFile: modelFile.trim() };
36767
+ const ctxSize = await dist_default6({ message: `${m.label}: context window in tokens`, default: "4096", validate: isUint });
36768
+ const port = await dist_default6({ message: `${m.label}: sidecar port`, default: "8080", validate: isUint });
36769
+ result[m.id] = {
36770
+ location: "device",
36771
+ modelFile: modelFile.trim(),
36772
+ ctxSize: Number(ctxSize.trim()),
36773
+ port: Number(port.trim())
36774
+ };
35868
36775
  continue;
35869
36776
  }
35870
- const url2 = await dist_default5({
36777
+ const url2 = await dist_default6({
35871
36778
  message: `${m.label}: inference endpoint URL (a server you run \u2014 llama-server/vLLM/Ollama)`,
35872
36779
  default: "http://localhost:8080",
35873
36780
  validate: (v) => v.trim().length > 0 || "endpoint URL is required"
35874
36781
  });
35875
- const apiKey = await dist_default6({ message: `${m.label}: API key (optional)`, mask: "*" });
36782
+ const apiKey = await dist_default7({ message: `${m.label}: API key (optional)`, mask: "*" });
35876
36783
  const binding = { location: "network", url: url2.trim() };
35877
36784
  if (apiKey) binding.apiKey = apiKey;
35878
36785
  result[m.id] = binding;
@@ -35881,11 +36788,11 @@ async function promptModels(models, seed) {
35881
36788
  }
35882
36789
  async function promptWebSearch(seed) {
35883
36790
  if (seed) return seed;
35884
- const provider = (await dist_default5({ message: "Web search provider", default: "brave" })).trim() || "brave";
35885
- const apiKey = await dist_default6({ message: "Web search API key", mask: "*" });
36791
+ const provider = (await dist_default6({ message: "Web search provider", default: "brave" })).trim() || "brave";
36792
+ const apiKey = await dist_default7({ message: "Web search API key", mask: "*" });
35886
36793
  return { provider, apiKey };
35887
36794
  }
35888
- async function promptMissing(partial2, outputDirDefault, req, workflowName) {
36795
+ async function promptMissing(partial2, outputDirDefault, req, workflowName, preloadedComponents) {
35889
36796
  const hwTodo = req.hardwareChannels.filter((ch) => !partial2.hardware?.[ch.id]);
35890
36797
  const mqttTodo = req.mqttChannels.filter((ch) => !partial2.mqtt?.[ch.id]);
35891
36798
  const modelsTodo = req.customModels.filter((m) => !partial2.models?.[m.id]);
@@ -35893,13 +36800,11 @@ async function promptMissing(partial2, outputDirDefault, req, workflowName) {
35893
36800
  const askWeb = req.hasWebSearch && !partial2.webSearch;
35894
36801
  let askOut = true;
35895
36802
  if (partial2.outputDir) {
35896
- const resolved = path8.resolve(process.cwd(), partial2.outputDir);
35897
- const nonEmpty = existsSync2(resolved) && (await fs6.readdir(resolved)).length > 0;
36803
+ const resolved = path10.resolve(process.cwd(), partial2.outputDir);
36804
+ const nonEmpty = existsSync3(resolved) && (await fs7.readdir(resolved)).length > 0;
35898
36805
  askOut = nonEmpty && !(partial2.force ?? false);
35899
36806
  }
35900
- const total = [askKeys, hwTodo.length > 0, mqttTodo.length > 0, modelsTodo.length > 0, askWeb, askOut].filter(
35901
- Boolean
35902
- ).length;
36807
+ const total = [askKeys, hwTodo.length > 0, mqttTodo.length > 0, modelsTodo.length > 0, askWeb, askOut].filter(Boolean).length + 1;
35903
36808
  let step = 0;
35904
36809
  const section = (label, count = 0) => {
35905
36810
  step += 1;
@@ -35925,7 +36830,7 @@ async function promptMissing(partial2, outputDirDefault, req, workflowName) {
35925
36830
  }
35926
36831
  for (const provider of selectedProviders) {
35927
36832
  if (llmKeys[provider]) continue;
35928
- const key = await dist_default6({ message: `${provider} API key`, mask: "*" });
36833
+ const key = await dist_default7({ message: `${provider} API key`, mask: "*" });
35929
36834
  if (key) llmKeys[provider] = key;
35930
36835
  }
35931
36836
  }
@@ -35937,6 +36842,8 @@ async function promptMissing(partial2, outputDirDefault, req, workflowName) {
35937
36842
  const models = await promptModels(req.customModels, partial2.models ?? {});
35938
36843
  if (askWeb) section("Web search");
35939
36844
  const webSearch = req.hasWebSearch ? await promptWebSearch(partial2.webSearch) : void 0;
36845
+ section("Custom components");
36846
+ const { components: customComponents, env: componentEnv } = await promptCustomComponents(preloadedComponents);
35940
36847
  if (askOut) section("Output");
35941
36848
  let outputDir;
35942
36849
  let force = partial2.force ?? false;
@@ -35944,16 +36851,16 @@ async function promptMissing(partial2, outputDirDefault, req, workflowName) {
35944
36851
  let candidate = partial2.outputDir;
35945
36852
  while (true) {
35946
36853
  if (!candidate) {
35947
- candidate = await dist_default5({ message: "Output directory", default: outputDirDefault });
36854
+ candidate = await dist_default6({ message: "Output directory", default: outputDirDefault });
35948
36855
  }
35949
- const resolved = path8.resolve(process.cwd(), candidate);
35950
- const exists = existsSync2(resolved);
35951
- const nonEmpty = exists && (await fs6.readdir(resolved)).length > 0;
36856
+ const resolved = path10.resolve(process.cwd(), candidate);
36857
+ const exists = existsSync3(resolved);
36858
+ const nonEmpty = exists && (await fs7.readdir(resolved)).length > 0;
35952
36859
  if (!nonEmpty || force) {
35953
36860
  outputDir = candidate;
35954
36861
  break;
35955
36862
  }
35956
- const action = await dist_default7({
36863
+ const action = await dist_default8({
35957
36864
  message: `${resolved} is not empty.`,
35958
36865
  choices: [
35959
36866
  { value: "overwrite", name: "overwrite the existing files" },
@@ -35970,7 +36877,7 @@ async function promptMissing(partial2, outputDirDefault, req, workflowName) {
35970
36877
  candidate = void 0;
35971
36878
  }
35972
36879
  }
35973
- return {
36880
+ const config2 = {
35974
36881
  llmKeys,
35975
36882
  outputDir,
35976
36883
  force,
@@ -35980,229 +36887,86 @@ async function promptMissing(partial2, outputDirDefault, req, workflowName) {
35980
36887
  models,
35981
36888
  webSearch
35982
36889
  };
36890
+ return { config: config2, customComponents, componentEnv };
35983
36891
  }
35984
36892
 
35985
36893
  // cli/deploy/write.ts
35986
- import { existsSync as existsSync3, promises as fs7 } from "node:fs";
35987
- import path9 from "node:path";
36894
+ import { existsSync as existsSync4, promises as fs8 } from "node:fs";
36895
+ import path11 from "node:path";
35988
36896
 
35989
- // cli/deploy/builders.ts
35990
- var RefAllocator = class {
35991
- byKey = /* @__PURE__ */ new Map();
35992
- used = /* @__PURE__ */ new Set();
35993
- alloc(key, hint) {
35994
- const existing = this.byKey.get(key);
35995
- if (existing) return existing;
35996
- let ref = hint;
35997
- for (let n = 2; this.used.has(ref); n++) ref = `${hint}-${n}`;
35998
- this.used.add(ref);
35999
- this.byKey.set(key, ref);
36000
- return ref;
36001
- }
36002
- };
36003
- function basename(p) {
36004
- const tail = p.replace(/\/+$/, "").split("/").pop() ?? p;
36005
- return tail.replace(/[^A-Za-z0-9._:-]/g, "-") || "res";
36006
- }
36007
- function brokerHost(url2) {
36008
- try {
36009
- return new URL(url2).hostname || "mqtt";
36010
- } catch {
36011
- return "mqtt";
36012
- }
36013
- }
36014
- function sidecarServiceName(modelId) {
36015
- const slug = modelId.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
36016
- return `llama-${slug || "model"}`;
36017
- }
36018
- function assertNeverFamily(f) {
36019
- throw new Error(`unhandled hardware family: ${String(f)}`);
36020
- }
36021
- function assertDeployable(req, cfg) {
36022
- const missing = [];
36023
- for (const ch of req.hardwareChannels) {
36024
- const b = cfg.hardware[ch.id];
36025
- if (!b?.chipOrDevice) missing.push(`hardware "${ch.id}": device path`);
36026
- else if (ch.addressable && b.index === void 0) missing.push(`hardware "${ch.id}": index`);
36027
- }
36028
- for (const ch of req.mqttChannels) {
36029
- if (!cfg.mqtt[ch.id]?.brokerUrl) missing.push(`mqtt "${ch.id}": broker URL`);
36030
- }
36031
- for (const m of req.customModels) {
36032
- const b = cfg.models[m.id];
36033
- if (b?.location === "device") {
36034
- const err = ggufNameError(b.modelFile);
36035
- if (err) missing.push(`model "${m.id}": ${err}`);
36036
- } else if (!b?.url) {
36037
- missing.push(`model "${m.id}": endpoint URL`);
36038
- }
36039
- }
36040
- missing.push(...hardwareConflicts(req.hardwareChannels, cfg.hardware));
36041
- missing.push(...familyMismatches(req.hardwareChannels, cfg.hardware));
36042
- missing.push(...unknownIds(req, cfg));
36043
- if (missing.length > 0) {
36044
- throw new Error(`invalid deploy config:
36045
- - ${missing.join("\n - ")}`);
36046
- }
36047
- }
36048
- function buildDeployArtifacts(req, cfg) {
36049
- assertDeployable(req, cfg);
36050
- const refs = new RefAllocator();
36051
- const gpios = {};
36052
- const adcs = {};
36053
- const dacs = {};
36054
- const pwms = {};
36055
- const serials = {};
36056
- const externalResources = {};
36057
- const deploymentMapping = {};
36058
- for (const ch of req.hardwareChannels) {
36059
- const b = cfg.hardware[ch.id];
36060
- if (!b) throw new Error(`unbound hardware channel ${ch.id}`);
36061
- const dev = b.chipOrDevice;
36062
- const ref = refs.alloc(`hw:${ch.family}:${dev}`, basename(dev));
36063
- switch (ch.family) {
36064
- case "gpio":
36065
- gpios[ref] = { chip: dev };
36066
- break;
36067
- case "pwm":
36068
- pwms[ref] = { chip: dev };
36069
- break;
36070
- case "adc":
36071
- adcs[ref] = { device: dev };
36072
- break;
36073
- case "dac":
36074
- dacs[ref] = { device: dev };
36075
- break;
36076
- case "serial":
36077
- serials[ref] = b.baud ? { device: dev, baud: b.baud } : { device: dev };
36078
- break;
36079
- default:
36080
- return assertNeverFamily(ch.family);
36081
- }
36082
- deploymentMapping[ch.id] = ch.addressable ? { ref, index: b.index } : { ref };
36083
- }
36084
- for (const ch of req.mqttChannels) {
36085
- const b = cfg.mqtt[ch.id];
36086
- if (!b) throw new Error(`unbound mqtt channel ${ch.id}`);
36087
- const conn = { type: "mqtt", brokerUrl: b.brokerUrl };
36088
- if (b.username) conn.username = b.username;
36089
- if (b.password) conn.password = b.password;
36090
- const ref = refs.alloc(`mqtt:${JSON.stringify(conn)}`, `mqtt-${brokerHost(b.brokerUrl)}`);
36091
- externalResources[ref] = conn;
36092
- deploymentMapping[ch.id] = { ref };
36093
- }
36094
- for (const m of req.customModels) {
36095
- const b = cfg.models[m.id];
36096
- if (!b) throw new Error(`unbound model ${m.id}`);
36097
- const url2 = b.location === "device" ? `http://${sidecarServiceName(m.id)}:8080` : b.url;
36098
- const provider = { type: "selfhosted", url: url2 };
36099
- if (b.location === "network" && b.apiKey) provider.apiKey = b.apiKey;
36100
- const ref = refs.alloc(`model:${m.id}`, basename(m.id));
36101
- externalResources[ref] = provider;
36102
- deploymentMapping[m.id] = { ref };
36897
+ // cli/deploy/generate.ts
36898
+ import { createHash } from "node:crypto";
36899
+ var CONFIG_PATH = "/etc/foresthub/config.json";
36900
+ var SECRETS_PATH = "/etc/foresthub/secrets.json";
36901
+ function configFileName(name) {
36902
+ return `${name}-config.json`;
36903
+ }
36904
+ function secretsFileName(name) {
36905
+ return `${name}-secrets.json`;
36906
+ }
36907
+ function namedVolumeSource(mount) {
36908
+ const src = mount.split(":")[0] ?? "";
36909
+ return src && !src.startsWith(".") && !src.startsWith("/") ? src : null;
36910
+ }
36911
+ function serviceBlock(c, secretDoc) {
36912
+ const hasSecretDoc = secretDoc !== void 0 && Object.keys(secretDoc).length > 0;
36913
+ const lines = [` ${c.name}:`, ` image: ${c.image}`, ` pull_policy: ${c.pull ?? "missing"}`, " restart: unless-stopped"];
36914
+ const labels = [];
36915
+ if (c.config !== void 0) {
36916
+ const hash2 = createHash("sha256").update(JSON.stringify(c.config)).digest("hex");
36917
+ labels.push(` com.foresthub.config-hash: "${hash2}"`);
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);
36924
+ if (c.user) lines.push(` user: "${c.user}"`);
36925
+ if (c.command && c.command.length > 0) {
36926
+ lines.push(" command:");
36927
+ for (const arg of c.command) lines.push(` - "${arg}"`);
36928
+ }
36929
+ lines.push(" env_file:", ` - path: ${c.name}.env`, " required: false");
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
+ ];
36935
+ if (volumes.length > 0) {
36936
+ lines.push(" volumes:");
36937
+ for (const v of volumes) lines.push(` - ${v}`);
36938
+ }
36939
+ if (c.devices && c.devices.length > 0) {
36940
+ lines.push(" devices:");
36941
+ for (const d of c.devices) lines.push(` - "${d}:${d}"`);
36942
+ }
36943
+ if (c.ports && c.ports.length > 0) {
36944
+ lines.push(" ports:");
36945
+ for (const p of c.ports) lines.push(` - "${p}"`);
36946
+ }
36947
+ if (c.privileged) {
36948
+ lines.push(
36949
+ " # ADC/DAC/PWM need sysfs access (/sys/class/pwm, /sys/bus/iio). privileged is",
36950
+ " # the simple default \u2014 tighten to specific bind-mounts if your policy requires.",
36951
+ " privileged: true"
36952
+ );
36103
36953
  }
36104
- const deviceManifest = {};
36105
- if (Object.keys(gpios).length) deviceManifest.gpios = gpios;
36106
- if (Object.keys(adcs).length) deviceManifest.adcs = adcs;
36107
- if (Object.keys(dacs).length) deviceManifest.dacs = dacs;
36108
- if (Object.keys(pwms).length) deviceManifest.pwms = pwms;
36109
- if (Object.keys(serials).length) deviceManifest.serials = serials;
36110
- return { deviceManifest, externalResources, deploymentMapping };
36954
+ return lines.join("\n");
36111
36955
  }
36112
-
36113
- // cli/deploy/generate.ts
36114
- function composeYaml(cfg, req) {
36115
- const hasHardware = req.hardwareChannels.length > 0;
36116
- const hasExternal = req.mqttChannels.length > 0 || req.customModels.length > 0;
36117
- const hasMapping = hasHardware || hasExternal;
36118
- const env = ["ENGINE_LOG_LEVEL: ${ENGINE_LOG_LEVEL:-info}"];
36119
- for (const p of ALL_PROVIDERS.filter((p2) => Boolean(cfg.llmKeys[p2]))) {
36120
- env.push(`${p.toUpperCase()}_API_KEY: \${${p.toUpperCase()}_API_KEY:-}`);
36121
- }
36122
- if (req.hasWebSearch) {
36123
- env.push("ENGINE_WEB_SEARCH_PROVIDER: ${ENGINE_WEB_SEARCH_PROVIDER:-brave}");
36124
- env.push("ENGINE_WEB_SEARCH_API_KEY: ${ENGINE_WEB_SEARCH_API_KEY:-}");
36125
- }
36126
- env.push("ENGINE_CONFIG_FILE: /etc/foresthub/workflow.json");
36127
- env.push("ENGINE_MEMORY_DIR: /var/lib/foresthub/memory");
36128
- if (hasHardware) env.push("ENGINE_DEVICE_MANIFEST_FILE: /etc/foresthub/device_manifest.json");
36129
- if (hasExternal) env.push("ENGINE_EXTERNAL_RESOURCES_FILE: /etc/foresthub/external_resources.json");
36130
- if (hasMapping) env.push("ENGINE_DEPLOYMENT_MAPPING_FILE: /etc/foresthub/deployment_mapping.json");
36131
- const vols = ["./workflow.json:/etc/foresthub/workflow.json:ro"];
36132
- if (hasHardware) vols.push("./device_manifest.json:/etc/foresthub/device_manifest.json:ro");
36133
- if (hasExternal) vols.push("./external_resources.json:/etc/foresthub/external_resources.json:ro");
36134
- if (hasMapping) vols.push("./deployment_mapping.json:/etc/foresthub/deployment_mapping.json:ro");
36135
- vols.push("engine-memory:/var/lib/foresthub/memory");
36136
- const cdev = /* @__PURE__ */ new Set();
36137
- let needsPrivileged = false;
36138
- for (const ch of req.hardwareChannels) {
36139
- const dev = cfg.hardware[ch.id]?.chipOrDevice;
36140
- if (!dev) continue;
36141
- if (ch.family === "gpio" || ch.family === "serial") cdev.add(dev);
36142
- else needsPrivileged = true;
36143
- }
36144
- const envBlock = env.map((l) => ` ${l}`).join("\n");
36145
- const volBlock = vols.map((v) => ` - ${v}`).join("\n");
36146
- const deviceBlock = cdev.size > 0 ? `
36147
- devices:
36148
- ${[...cdev].map((d) => ` - "${d}:${d}"`).join("\n")}` : "";
36149
- const privilegedBlock = needsPrivileged ? "\n # ADC/DAC/PWM need sysfs access (/sys/class/pwm, /sys/bus/iio). privileged is\n # the simple default \u2014 tighten to specific bind-mounts if your policy requires.\n privileged: true" : "";
36150
- const needsRoot = cdev.size > 0 || needsPrivileged;
36151
- const userBlock = needsRoot ? `
36152
- # The engine image runs as nonroot and can't open root-owned device nodes /
36153
- # sysfs files; root is the host-agnostic way to reach the mapped hardware below.
36154
- user: "0:0"` : "";
36155
- const deviceModels = [];
36156
- for (const m of req.customModels) {
36157
- const b = cfg.models[m.id];
36158
- if (b?.location === "device")
36159
- deviceModels.push({ service: sidecarServiceName(m.id), modelFile: b.modelFile, ctxVar: ctxSizeVar(m.id) });
36160
- }
36161
- const dependsBlock = deviceModels.length ? "\n depends_on:\n" + deviceModels.map((d) => ` ${d.service}:
36162
- condition: service_healthy`).join("\n") : "";
36163
- const sidecars = deviceModels.map(
36164
- (d) => ` ${d.service}:
36165
- image: ghcr.io/ggml-org/llama.cpp:server-b8589
36166
- pull_policy: missing
36167
- restart: unless-stopped
36168
- command:
36169
- - --model
36170
- - /models/${d.modelFile}
36171
- - --host
36172
- - "0.0.0.0"
36173
- - --port
36174
- - "8080"
36175
- - --ctx-size
36176
- - "\${${d.ctxVar}:-4096}"
36177
- volumes:
36178
- - ./models:/models:ro
36179
- healthcheck:
36180
- test: ["CMD-SHELL", "curl -sf http://localhost:8080/health || exit 1"]
36181
- interval: 10s
36182
- timeout: 5s
36183
- retries: 30
36184
- start_period: 60s`
36185
- ).join("\n\n");
36186
- const sidecarBlock = sidecars ? `
36187
-
36188
- ${sidecars}` : "";
36189
- return `# ForestHub engine \u2014 minimal standalone Compose deployment.
36956
+ function composeYaml(spec, secretDocs = {}) {
36957
+ const services = spec.components.map((c) => serviceBlock(c, secretDocs[c.name])).join("\n\n");
36958
+ const named = [...new Set(spec.components.flatMap((c) => (c.volumes ?? []).map(namedVolumeSource).filter((s) => s !== null)))];
36959
+ const volumesBlock = named.length > 0 ? `
36960
+ volumes:
36961
+ ${named.map((n) => ` ${n}:`).join("\n")}
36962
+ ` : "";
36963
+ return `# Edge Agents engine \u2014 minimal standalone Compose deployment.
36190
36964
  # See README.md for the build/transfer/run workflow.
36191
- # See .env for operator-set values.
36965
+ # See the <component>.env files for operator-set values.
36192
36966
 
36193
36967
  services:
36194
- engine:
36195
- image: fh-engine:latest
36196
- pull_policy: never
36197
- restart: unless-stopped${userBlock}${dependsBlock}
36198
- environment:
36199
- ${envBlock}
36200
- volumes:
36201
- ${volBlock}${deviceBlock}${privilegedBlock}${sidecarBlock}
36202
-
36203
- volumes:
36204
- engine-memory:
36205
- `;
36968
+ ${services}
36969
+ ${volumesBlock}`;
36206
36970
  }
36207
36971
  function envFile(cfg) {
36208
36972
  const localProviders = ALL_PROVIDERS.filter((p) => Boolean(cfg.llmKeys[p]));
@@ -36216,48 +36980,33 @@ ENGINE_WEB_SEARCH_PROVIDER=${cfg.webSearch.provider}
36216
36980
  ENGINE_WEB_SEARCH_API_KEY=${cfg.webSearch.apiKey}
36217
36981
 
36218
36982
  ` : "";
36219
- const deviceModelIds = Object.entries(cfg.models).filter(([, b]) => b.location === "device").map(([id]) => id);
36220
- const onDeviceSection = deviceModelIds.length ? `# ----- On-device models -----
36221
- # Context window per llama-server sidecar.
36222
- ${deviceModelIds.map((id) => `${ctxSizeVar(id)}=4096`).join("\n")}
36223
-
36224
- ` : "";
36225
- return `# ForestHub engine \u2014 operator configuration.
36226
- # Auto-generated by \`fh-workflow deploy\`. \`docker compose\` auto-loads this file.
36227
- # Secret values: chmod 600 .env after editing.
36983
+ return `# Edge Agents engine \u2014 operator configuration.
36984
+ # Auto-generated by \`fh-workflow deploy\`. Loaded into the engine via the compose
36985
+ # \`env_file\`. Secret values: chmod 600 this file after editing.
36228
36986
 
36229
- ${providerSection}${webSearchSection}${onDeviceSection}# ----- Runtime -----
36987
+ ${providerSection}${webSearchSection}# ----- Runtime -----
36230
36988
  ENGINE_LOG_LEVEL=${cfg.logLevel} # debug | info | warn | error
36231
36989
  `;
36232
36990
  }
36233
- function readme(cfg, req) {
36991
+ function readme(spec, cfg, hasProviderModel, hasSecrets = false) {
36234
36992
  const localProviders = ALL_PROVIDERS.filter((p) => Boolean(cfg.llmKeys[p]));
36235
- 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 `.env`." : localProviders.map((p) => `- **${p}** \u2014 runs locally (your API key)`).join("\n");
36236
- const hasHardware = req.hardwareChannels.length > 0;
36237
- const hasExternal = req.mqttChannels.length > 0 || req.customModels.length > 0;
36238
- const deviceModelFiles = [];
36239
- let hasNetworkModel = false;
36240
- for (const m of req.customModels) {
36241
- const b = cfg.models[m.id];
36242
- if (b?.location === "device") deviceModelFiles.push(b.modelFile);
36243
- else if (b?.location === "network") hasNetworkModel = true;
36244
- }
36245
- const hasExternalService = req.mqttChannels.length > 0 || hasNetworkModel;
36246
- const deployFiles = [];
36247
- if (hasHardware) deployFiles.push("- `device_manifest.json` \u2014 hardware the engine binds at boot");
36248
- if (hasExternal)
36249
- deployFiles.push("- `external_resources.json` \u2014 MQTT brokers / model endpoints (**secrets \u2014 `chmod 600`**)");
36250
- if (hasHardware || hasExternal)
36251
- deployFiles.push("- `deployment_mapping.json` \u2014 maps the workflow's resources to the entries above");
36252
- const deployFilesBlock = deployFiles.length ? "\n" + deployFiles.join("\n") : "";
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");
36994
+ const engine = spec.components.find((c) => c.name === "engine");
36995
+ const hasHardware = (engine?.devices?.length ?? 0) > 0 || Boolean(engine?.privileged);
36996
+ const deviceModels = Object.entries(cfg.models).flatMap(
36997
+ ([id, b]) => b.location === "device" ? [{ dir: `./workspaces/${sidecarServiceName(id)}`, file: b.modelFile }] : []
36998
+ );
36999
+ const hasNetworkModel = Object.values(cfg.models).some((b) => b.location === "network");
37000
+ const hasMqtt = Object.keys(cfg.mqtt).length > 0;
37001
+ const hasExternalService = hasMqtt || hasNetworkModel;
36253
37002
  const notes = [];
36254
- if (req.hasProviderModel || localProviders.length > 0) {
37003
+ if (hasProviderModel || localProviders.length > 0) {
36255
37004
  notes.push(`## LLM provider keys
36256
37005
 
36257
37006
  ${providerBlock}
36258
37007
 
36259
- The engine calls each provider directly with the API key from \`.env\`. Without a
36260
- key, an Agent node that uses that provider's catalog model fails at build.`);
37008
+ The engine calls each provider directly with the API key from \`engine.env\`. Without
37009
+ a key, an Agent node that uses that provider's catalog model fails at build.`);
36261
37010
  }
36262
37011
  if (hasHardware) {
36263
37012
  notes.push(`## Hardware access
@@ -36270,18 +37019,20 @@ and tighten it to your security policy before deploying.`);
36270
37019
  if (hasExternalService) {
36271
37020
  notes.push(`## External resources
36272
37021
 
36273
- \`external_resources.json\` holds the broker/endpoint credentials the engine connects
36274
- with \u2014 keep it \`chmod 600\`. This bundle does not start those services; it assumes the
36275
- broker/endpoint already exists and is reachable from the 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.`);
36276
37027
  }
36277
- if (deviceModelFiles.length > 0) {
37028
+ if (deviceModels.length > 0) {
36278
37029
  notes.push(`## On-device models
36279
37030
 
36280
37031
  This bundle runs a llama-server container per on-device model; the engine reaches it
36281
- over the compose network by service name. The GGUF file(s) below must sit in a
36282
- \`models/\` folder next to the compose file on the controller. They are too large for
36283
- the main \`scp\` line, so step 3 transfers them separately:
36284
- ${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")}`);
36285
37036
  }
36286
37037
  if (hasNetworkModel) {
36287
37038
  notes.push(`## Network models
@@ -36289,43 +37040,38 @@ ${deviceModelFiles.map((f) => `- \`./models/${f}\``).join("\n")}`);
36289
37040
  A network model points at an inference endpoint **you run yourself** (llama-server, vLLM,
36290
37041
  Ollama, ...) on another machine. This bundle does not start that server for you.`);
36291
37042
  }
36292
- if (req.hasWebSearch) {
37043
+ if (cfg.webSearch) {
36293
37044
  notes.push(`## Web search
36294
37045
 
36295
- The web-search API key is in \`.env\` (\`ENGINE_WEB_SEARCH_API_KEY\`).`);
37046
+ The web-search API key is in \`engine.env\` (\`ENGINE_WEB_SEARCH_API_KEY\`).`);
36296
37047
  }
36297
37048
  const notesBlock = notes.length ? "\n" + notes.join("\n\n") + "\n" : "";
36298
- const transferExtra = [
36299
- hasHardware ? "device_manifest.json" : "",
36300
- hasExternal ? "external_resources.json" : "",
36301
- hasHardware || hasExternal ? "deployment_mapping.json" : ""
36302
- ].filter(Boolean);
36303
- const transferExtraStr = transferExtra.length ? " " + transferExtra.join(" ") : "";
36304
- const modelsTransfer = deviceModelFiles.length ? `
37049
+ const modelsTransfer = deviceModels.length ? `
36305
37050
 
36306
37051
  # On-device model weights \u2014 too large for the line above (GGUF can be several GB).
36307
- # Copy them from this bundle:
36308
- scp -r models/ $CONTROLLER_USER@$CONTROLLER_ADDR:~/fh-engine/
36309
- # ...or download them directly into ~/fh-engine/models/ on the controller.` : "";
36310
- 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'
36311
37056
  ssh $CONTROLLER_USER@$CONTROLLER_ADDR 'cd ~/fh-engine && docker compose up -d'
36312
37057
 
36313
- # The engine only starts once the llama-server sidecar reports healthy. If the engine
36314
- # stays "created", inspect the sidecar \u2014 these show every container, not just the engine:
37058
+ # The engine and the llama-server sidecar start independently; the engine reaches the
37059
+ # model once it is up. These show every container, not just the engine:
36315
37060
  ssh $CONTROLLER_USER@$CONTROLLER_ADDR 'cd ~/fh-engine && docker compose ps'
36316
37061
  ssh $CONTROLLER_USER@$CONTROLLER_ADDR 'cd ~/fh-engine && docker compose logs -f'` : `ssh $CONTROLLER_USER@$CONTROLLER_ADDR 'cd ~/fh-engine && docker load -i fh-engine.tar'
36317
37062
  ssh $CONTROLLER_USER@$CONTROLLER_ADDR 'cd ~/fh-engine && docker compose up -d'
36318
37063
  ssh $CONTROLLER_USER@$CONTROLLER_ADDR 'cd ~/fh-engine && docker compose logs -f engine'`;
36319
- return `# ForestHub engine \u2014 deployment bundle
37064
+ return `# Edge Agents engine \u2014 deployment bundle
36320
37065
 
36321
37066
  Generated by \`fh-workflow deploy\`. This directory contains everything needed to
36322
37067
  run one engine instance on an edge controller, **standalone** \u2014 the engine boots
36323
- the workflow from \`workflow.json\` and runs it autonomously:
37068
+ the workflow from \`engine-config.json\` and runs it autonomously:
36324
37069
 
36325
37070
  - \`docker-compose.yml\` \u2014 deployment template
36326
- - \`workflow.json\` \u2014 the workflow graph the engine executes${deployFilesBlock}
36327
- - \`.env\` \u2014 operator configuration (already filled in, \`chmod 600\` it)
36328
- - \`fh-engine.tar\` \u2014 image tarball (you build this in step 1 below)
37071
+ - \`engine-config.json\` \u2014 the engine's single boot config (workflow + bindings + device manifest)
37072
+ - \`deployment-spec.json\` \u2014 the full resolved deployment spec (deployment record)
37073
+ - \`engine.env\` \u2014 operator configuration loaded into the engine (already filled in, \`chmod 600\` it)
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)
36329
37075
  ${notesBlock}
36330
37076
  ## 1. Build the image (on the dev machine)
36331
37077
 
@@ -36341,10 +37087,10 @@ docker buildx build --platform linux/arm64 -t fh-engine:latest --load .
36341
37087
  docker buildx build --platform linux/amd64 -t fh-engine:latest --load .
36342
37088
  \`\`\`
36343
37089
 
36344
- ## 2. Review the generated \`.env\`
37090
+ ## 2. Review the generated \`engine.env\`
36345
37091
 
36346
37092
  \`\`\`bash
36347
- chmod 600 .env
37093
+ chmod 600 engine.env
36348
37094
  \`\`\`
36349
37095
 
36350
37096
  The wizard already filled in your operator values. Double-check before
@@ -36362,7 +37108,7 @@ docker save fh-engine:latest -o ../path/to/this/bundle/fh-engine.tar
36362
37108
  ssh $CONTROLLER_USER@$CONTROLLER_ADDR 'mkdir -p ~/fh-engine'
36363
37109
 
36364
37110
  cd path/to/this/bundle
36365
- scp fh-engine.tar docker-compose.yml workflow.json${transferExtraStr} .env \\
37111
+ scp fh-engine.tar docker-compose.yml engine-config.json deployment-spec.json engine.env ${hasSecrets ? "engine-secrets.json " : ""}\\
36366
37112
  $CONTROLLER_USER@$CONTROLLER_ADDR:~/fh-engine/${modelsTransfer}
36367
37113
  \`\`\`
36368
37114
 
@@ -36372,75 +37118,77 @@ scp fh-engine.tar docker-compose.yml workflow.json${transferExtraStr} .env \\
36372
37118
  ${runBlock}
36373
37119
  \`\`\`
36374
37120
 
36375
- ## Agent memory
37121
+ ## Agent memory & durable data
36376
37122
 
36377
- The workflow's memory files live in the named volume \`engine-memory\` (mounted at
36378
- \`/var/lib/foresthub/memory\`). They survive restarts and \`docker compose down\`;
36379
- only \`docker compose down -v\` deletes them. The volume is namespaced by the
36380
- compose project name, which defaults to the bundle directory's name \u2014 keep it
36381
- stable across updates, or pin it via \`COMPOSE_PROJECT_NAME\`. In this standalone
36382
- bundle the volume is the only copy of the memory.
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\`):
36383
37125
 
36384
- To keep the data at a host path instead, replace the named volume with a bind
36385
- mount. Named-volume ownership is handled by the image; a bind-mounted host
36386
- directory you must create and chown to the engine's nonroot user yourself:
37126
+ - \`./workspaces/engine/\` \u2014 the engine's memory files.
37127
+ - \`./workspaces/<model>/\` \u2014 an on-device model's GGUF weights (see above).
36387
37128
 
36388
- \`\`\`bash
36389
- sudo mkdir -p /data/foresthub/memory && sudo chown -R 65532:65532 /data/foresthub/memory
36390
- # docker-compose.yml: - /data/foresthub/memory:/var/lib/foresthub/memory
36391
- \`\`\`
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.
36392
37133
  `;
36393
37134
  }
36394
- function ctxSizeVar(modelId) {
36395
- const suffix = modelId.toUpperCase().replace(/[^A-Z0-9]+/g, "_").replace(/^_+|_+$/g, "");
36396
- return `LLAMA_CTX_SIZE_${suffix || "MODEL"}`;
36397
- }
36398
37135
  function slugify2(name) {
36399
37136
  return name.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
36400
37137
  }
36401
37138
 
36402
37139
  // cli/deploy/write.ts
36403
- async function writeOutput(workflow, cfg, req) {
36404
- const artifacts = buildDeployArtifacts(req, cfg);
36405
- const dir = path9.resolve(process.cwd(), cfg.outputDir);
36406
- if (existsSync3(dir)) {
36407
- const contents = await fs7.readdir(dir);
37140
+ async function writeOutput(spec, resourceSecrets, cfg, req, componentEnv = {}) {
37141
+ const dir = path11.resolve(process.cwd(), cfg.outputDir);
37142
+ if (existsSync4(dir)) {
37143
+ const contents = await fs8.readdir(dir);
36408
37144
  if (contents.length > 0) {
36409
37145
  if (!cfg.force) {
36410
37146
  process.stderr.write(`output dir not empty: ${dir} (use --force to overwrite)
36411
37147
  `);
36412
37148
  process.exit(1);
36413
37149
  }
36414
- await fs7.rm(dir, { recursive: true, force: true });
36415
- await fs7.mkdir(dir, { recursive: true });
37150
+ await fs8.rm(dir, { recursive: true, force: true });
37151
+ await fs8.mkdir(dir, { recursive: true });
36416
37152
  }
36417
37153
  } else {
36418
- await fs7.mkdir(dir, { recursive: true });
37154
+ await fs8.mkdir(dir, { recursive: true });
36419
37155
  }
36420
37156
  const written = [];
36421
37157
  const emit = async (name, content, secret = false) => {
36422
- const out = path9.join(dir, name);
37158
+ const out = path11.join(dir, name);
36423
37159
  const opts = secret ? { encoding: "utf-8", mode: 384 } : { encoding: "utf-8" };
36424
- await fs7.writeFile(out, content, opts);
37160
+ await fs8.writeFile(out, content, opts);
36425
37161
  written.push(out);
36426
37162
  };
36427
37163
  const json2 = (v) => JSON.stringify(v, null, 2) + "\n";
36428
- await emit("workflow.json", json2(workflow));
36429
- await emit("docker-compose.yml", composeYaml(cfg, req));
36430
- await emit(".env", envFile(cfg), true);
36431
- await emit("README.md", readme(cfg, req));
36432
- const hasHardware = req.hardwareChannels.length > 0;
36433
- const hasExternal = req.mqttChannels.length > 0 || req.customModels.length > 0;
36434
- if (hasHardware) await emit("device_manifest.json", json2(artifacts.deviceManifest));
36435
- if (hasExternal) await emit("external_resources.json", json2(artifacts.externalResources), true);
36436
- if (hasHardware || hasExternal) await emit("deployment_mapping.json", json2(artifacts.deploymentMapping));
36437
- if (Object.values(cfg.models).some((b) => b.location === "device")) {
36438
- await fs7.mkdir(path9.join(dir, "models"), { recursive: true });
37164
+ for (const c of spec.components) {
37165
+ if (c.config !== void 0) await emit(configFileName(c.name), json2(c.config));
37166
+ }
37167
+ const secretDocs = Object.keys(resourceSecrets).length > 0 ? { engine: resourceSecrets } : {};
37168
+ await emit("deployment-spec.json", json2(spec));
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));
37175
+ for (const [name, text] of Object.entries(componentEnv)) {
37176
+ await emit(`${name}.env`, text, true);
37177
+ }
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 });
36439
37185
  }
36440
37186
  return written;
36441
37187
  }
36442
37188
 
36443
37189
  // cli/deploy/index.ts
37190
+ var ENGINE_IMAGE = "fh-engine:latest";
37191
+ var LLAMA_SERVER_IMAGE = "ghcr.io/ggml-org/llama.cpp:server-b8589";
36444
37192
  var USAGE = `Usage: fh-workflow deploy <workflow.json> [flags]
36445
37193
 
36446
37194
  Generates a self-contained, standalone Engine deployment bundle (workflow.json,
@@ -36451,6 +37199,9 @@ autonomously. Missing values are filled in interactively, or supplied via
36451
37199
  LLM provider keys (set one for each catalog model an Agent uses):
36452
37200
  ${ALL_PROVIDERS.map((p) => ` --${p}-key KEY`).join("\n")}
36453
37201
 
37202
+ Custom components (extra containers to run alongside the engine):
37203
+ --component DIR folder with a component.json (repeatable)
37204
+
36454
37205
  Output:
36455
37206
  --output DIR default: ./<workflow-name>-bundle
36456
37207
  --force overwrite existing DIR
@@ -36472,6 +37223,7 @@ function parseFlags(args2) {
36472
37223
  output: { type: "string" },
36473
37224
  "log-level": { type: "string" },
36474
37225
  values: { type: "string" },
37226
+ component: { type: "string", multiple: true },
36475
37227
  force: { type: "boolean", default: false },
36476
37228
  help: { type: "boolean", short: "h", default: false }
36477
37229
  },
@@ -36488,6 +37240,7 @@ function parseFlags(args2) {
36488
37240
  output: values.output,
36489
37241
  logLevel: values["log-level"],
36490
37242
  values: values.values,
37243
+ component: values.component ?? [],
36491
37244
  force: values.force ?? false,
36492
37245
  help: values.help ?? false
36493
37246
  };
@@ -36513,10 +37266,10 @@ function partialFromFlags(flags, fileValues) {
36513
37266
  };
36514
37267
  }
36515
37268
  async function loadValues(source) {
36516
- const abs = path10.resolve(process.cwd(), source);
37269
+ const abs = path12.resolve(process.cwd(), source);
36517
37270
  let raw;
36518
37271
  try {
36519
- raw = await fs8.readFile(abs, "utf-8");
37272
+ raw = await fs9.readFile(abs, "utf-8");
36520
37273
  } catch (err) {
36521
37274
  if (err.code === "ENOENT") {
36522
37275
  process.stderr.write(`Values file not found: ${abs}
@@ -36589,15 +37342,41 @@ function configFromPartial(p, outputDirDefault) {
36589
37342
  webSearch: p.webSearch
36590
37343
  };
36591
37344
  }
37345
+ async function loadFlagComponents(dirs) {
37346
+ const uniqueDirs = [...new Set(dirs.map((d) => path12.resolve(process.cwd(), d)))];
37347
+ const entries = await Promise.all(
37348
+ uniqueDirs.map(async (dir) => ({ source: path12.join(dir, "component.json"), data: await readComponentJson(dir), dir }))
37349
+ );
37350
+ parseDeployComponents(entries);
37351
+ const seen = /* @__PURE__ */ new Set();
37352
+ const loaded = [];
37353
+ for (const e of entries) {
37354
+ const component = e.data;
37355
+ if (seen.has(component.name)) {
37356
+ throw new Error(`duplicate component name "${component.name}" (two --component folders declare it)`);
37357
+ }
37358
+ seen.add(component.name);
37359
+ loaded.push({ component, dir: e.dir });
37360
+ }
37361
+ return loaded;
37362
+ }
37363
+ async function resolveComponentsEnv(loaded) {
37364
+ const env = {};
37365
+ for (const { component, dir } of loaded) {
37366
+ const text = await resolveComponentEnv(dir, component.name, { interactive: false });
37367
+ if (text !== null) env[component.name] = text;
37368
+ }
37369
+ return env;
37370
+ }
36592
37371
  async function deployCommand(workflowPath, args2) {
36593
37372
  if (!workflowPath || workflowPath === "--help" || workflowPath === "-h") {
36594
37373
  process.stdout.write(USAGE);
36595
37374
  process.exit(workflowPath ? 0 : 1);
36596
37375
  }
36597
- const abs = path10.resolve(process.cwd(), workflowPath);
37376
+ const abs = path12.resolve(process.cwd(), workflowPath);
36598
37377
  let raw;
36599
37378
  try {
36600
- raw = await fs8.readFile(abs, "utf-8");
37379
+ raw = await fs9.readFile(abs, "utf-8");
36601
37380
  } catch (err) {
36602
37381
  const code = err.code;
36603
37382
  if (code === "ENOENT") {
@@ -36628,20 +37407,44 @@ async function deployCommand(workflowPath, args2) {
36628
37407
  process.stdout.write(USAGE);
36629
37408
  process.exit(0);
36630
37409
  }
36631
- const req = inspect(workflow);
37410
+ let domain2;
37411
+ try {
37412
+ domain2 = deserialize7(workflow);
37413
+ } catch (err) {
37414
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}
37415
+ `);
37416
+ process.exit(1);
37417
+ }
37418
+ const req = deriveRequirements(domain2);
36632
37419
  if (req.hasRetriever) {
36633
37420
  process.stderr.write(
36634
37421
  "error: workflow references a Retriever node (RAG).\n A standalone engine has no retriever, so the node cannot resolve and the\n engine fails at build. Remove the Retriever node to deploy standalone.\n"
36635
37422
  );
36636
37423
  process.exit(1);
36637
37424
  }
36638
- const workflowName = path10.basename(abs, path10.extname(abs));
37425
+ const workflowName = path12.basename(abs, path12.extname(abs));
36639
37426
  const outputDirDefault = `./${slugify2(workflowName)}-bundle`;
36640
37427
  const fileValues = flags.values ? await loadValues(flags.values) : {};
36641
37428
  const partial2 = partialFromFlags(flags, fileValues);
37429
+ let preloaded;
37430
+ try {
37431
+ preloaded = await loadFlagComponents(flags.component);
37432
+ } catch (err) {
37433
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}
37434
+ `);
37435
+ process.exit(1);
37436
+ }
36642
37437
  let cfg;
37438
+ let customComponents;
37439
+ let componentEnv;
36643
37440
  if (process.stdin.isTTY) {
36644
- cfg = await promptMissing(partial2, outputDirDefault, req, workflowName);
37441
+ ({ config: cfg, customComponents, componentEnv } = await promptMissing(
37442
+ partial2,
37443
+ outputDirDefault,
37444
+ req,
37445
+ workflowName,
37446
+ preloaded
37447
+ ));
36645
37448
  } else {
36646
37449
  const missing = missingRequired(req, partial2);
36647
37450
  if (missing.length > 0) {
@@ -36652,21 +37455,40 @@ async function deployCommand(workflowPath, args2) {
36652
37455
  process.exit(1);
36653
37456
  }
36654
37457
  cfg = configFromPartial(partial2, outputDirDefault);
37458
+ customComponents = preloaded.map((c) => c.component);
37459
+ componentEnv = await resolveComponentsEnv(preloaded);
37460
+ }
37461
+ let built;
37462
+ try {
37463
+ built = buildDeploymentSpec(
37464
+ domain2,
37465
+ cfg,
37466
+ {
37467
+ id: slugify2(workflowName),
37468
+ engineImage: ENGINE_IMAGE,
37469
+ llamaServerImage: LLAMA_SERVER_IMAGE
37470
+ },
37471
+ customComponents
37472
+ );
37473
+ } catch (err) {
37474
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}
37475
+ `);
37476
+ process.exit(1);
36655
37477
  }
36656
37478
  let files;
36657
37479
  try {
36658
- files = await writeOutput(workflow, cfg, req);
37480
+ files = await writeOutput(built.spec, built.resourceSecrets, cfg, req, componentEnv);
36659
37481
  } catch (err) {
36660
37482
  process.stderr.write(`${err instanceof Error ? err.message : String(err)}
36661
37483
  `);
36662
37484
  process.exit(1);
36663
37485
  }
36664
- const absOut = path10.resolve(process.cwd(), cfg.outputDir);
37486
+ const absOut = path12.resolve(process.cwd(), cfg.outputDir);
36665
37487
  process.stdout.write(`
36666
37488
  \u2713 Deployment bundle written to ${absOut}
36667
37489
  `);
36668
37490
  for (const f of files) {
36669
- process.stdout.write(` - ${path10.relative(process.cwd(), f)}
37491
+ process.stdout.write(` - ${path12.relative(process.cwd(), f)}
36670
37492
  `);
36671
37493
  }
36672
37494
  process.stdout.write(`