@foresthubai/workflow-cli 0.4.3 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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 });
@@ -13121,6 +13121,11 @@ function toFunctionInfo(fn) {
13121
13121
  }
13122
13122
 
13123
13123
  // ../workflow-core/src/function/serialization.ts
13124
+ function serialize(fn) {
13125
+ const outputAssignments = {};
13126
+ for (const o of fn.outputs) outputAssignments[o.uid] = o.expression;
13127
+ return { functionInfo: toFunctionInfo(fn), outputAssignments };
13128
+ }
13124
13129
  function deserialize(functionInfo, outputAssignments) {
13125
13130
  return {
13126
13131
  id: functionInfo.id,
@@ -13182,6 +13187,51 @@ function isToolFlow(type) {
13182
13187
  }
13183
13188
 
13184
13189
  // ../workflow-core/src/edge/serialization.ts
13190
+ function serialize2(edge) {
13191
+ const sourceHandle = edge.sourceHandle || "";
13192
+ const targetHandle = edge.targetHandle || "";
13193
+ const edgeType = edge.type;
13194
+ const from = { nodeId: edge.source, port: sourceHandle };
13195
+ const to = { nodeId: edge.target, port: targetHandle };
13196
+ switch (edgeType) {
13197
+ case "agentTask":
13198
+ return {
13199
+ id: edge.id,
13200
+ type: "agentTask",
13201
+ from,
13202
+ to,
13203
+ prompt: edge.data?.prompt ?? { expression: "", references: [], dataType: "string" }
13204
+ };
13205
+ case "agentChoice":
13206
+ return {
13207
+ id: edge.id,
13208
+ type: "agentChoice",
13209
+ from,
13210
+ to,
13211
+ ...edge.data?.description ? { description: edge.data.description } : {}
13212
+ };
13213
+ case "agentDelegate":
13214
+ return {
13215
+ id: edge.id,
13216
+ type: "agentDelegate",
13217
+ from,
13218
+ to,
13219
+ ...edge.data?.prompt ? { prompt: edge.data.prompt } : {},
13220
+ ...edge.data?.description ? { description: edge.data.description } : {}
13221
+ };
13222
+ case "control":
13223
+ return { id: edge.id, type: "control", from, to };
13224
+ case "tool":
13225
+ return { id: edge.id, type: "tool", from, to };
13226
+ default:
13227
+ return {
13228
+ id: edge.id,
13229
+ type: sourceHandle.startsWith("ctrl") || targetHandle.startsWith("ctrl") ? "control" : "tool",
13230
+ from,
13231
+ to
13232
+ };
13233
+ }
13234
+ }
13185
13235
  function deserialize2(apiEdge) {
13186
13236
  let data;
13187
13237
  if ((apiEdge.type === "agentTask" || apiEdge.type === "agentDelegate") && apiEdge.prompt) {
@@ -13271,6 +13321,14 @@ function isParameterActive(param, parameterValues, isToolInput) {
13271
13321
  function isEmpty(value) {
13272
13322
  return value === void 0 || value === null || value === "";
13273
13323
  }
13324
+ function pruneArguments(args2, parameters, isToolInput = false) {
13325
+ for (const param of parameters) {
13326
+ const inactive = !!param.activationRules?.length && !isParameterActive(param, args2, isToolInput);
13327
+ if (inactive || isEmpty(args2[param.id])) {
13328
+ delete args2[param.id];
13329
+ }
13330
+ }
13331
+ }
13274
13332
  function resolveExpressionType(param, args2, variables) {
13275
13333
  if (param.fromReference) {
13276
13334
  const ref = args2[param.fromReference];
@@ -13724,9 +13782,9 @@ var SerialWriteNodeDefinition = {
13724
13782
  {
13725
13783
  id: "portReference",
13726
13784
  label: "Port",
13727
- description: "Serial port to write to",
13785
+ description: "Serial port or log channel to write to",
13728
13786
  type: "channelSelect",
13729
- channelType: ["UART"]
13787
+ channelType: ["UART", "LOG"]
13730
13788
  },
13731
13789
  {
13732
13790
  id: "value",
@@ -14105,7 +14163,7 @@ function isNodeUsedAsTool(nodeId, nodeData, edges) {
14105
14163
  }
14106
14164
 
14107
14165
  // ../workflow-core/src/channel/Channel.ts
14108
- var ALL_CHANNEL_TYPES = ["GPIOIN", "GPIOOUT", "ADC", "PWM", "DAC", "UART", "MQTT"];
14166
+ var ALL_CHANNEL_TYPES = ["GPIOIN", "GPIOOUT", "ADC", "PWM", "DAC", "UART", "MQTT", "LOG"];
14109
14167
 
14110
14168
  // ../workflow-core/src/channel/ChannelDefinition.ts
14111
14169
  var CHANNEL_DEFINITION = {
@@ -14153,11 +14211,62 @@ var CHANNEL_DEFINITION = {
14153
14211
  description: "MQTT topic this channel publishes to / subscribes on (e.g. sensors/temperature)",
14154
14212
  type: "string",
14155
14213
  activationRules: [{ type: "parameterIn", parameterId: "type", values: ["MQTT"] }]
14214
+ },
14215
+ {
14216
+ id: "level",
14217
+ label: "Level",
14218
+ description: "Severity the engine records messages written to this channel at",
14219
+ type: "selection",
14220
+ default: "info",
14221
+ options: [
14222
+ { value: "debug", label: "Debug" },
14223
+ { value: "info", label: "Info" },
14224
+ { value: "warn", label: "Warn" },
14225
+ { value: "error", label: "Error" }
14226
+ ],
14227
+ activationRules: [{ type: "parameterIn", parameterId: "type", values: ["LOG"] }]
14228
+ },
14229
+ {
14230
+ id: "tag",
14231
+ label: "Tag",
14232
+ description: "Optional category stamped on each line so the backend can group workflow-emitted logs",
14233
+ type: "string",
14234
+ optional: true,
14235
+ activationRules: [{ type: "parameterIn", parameterId: "type", values: ["LOG"] }]
14156
14236
  }
14157
14237
  ]
14158
14238
  };
14159
14239
 
14160
14240
  // ../workflow-core/src/channel/serialization.ts
14241
+ function serialize3(ch) {
14242
+ const { id, label, type } = ch;
14243
+ const args2 = { ...ch.arguments, type };
14244
+ pruneArguments(args2, CHANNEL_DEFINITION.parameters);
14245
+ switch (type) {
14246
+ case "GPIOIN":
14247
+ return {
14248
+ type,
14249
+ id,
14250
+ label,
14251
+ bias: args2.bias,
14252
+ debounceMs: args2.debounceMs
14253
+ };
14254
+ case "GPIOOUT":
14255
+ return { type, id, label };
14256
+ case "ADC":
14257
+ return { type, id, label };
14258
+ case "PWM":
14259
+ return { type, id, label, frequency: args2.frequency };
14260
+ case "DAC":
14261
+ return { type, id, label };
14262
+ case "UART":
14263
+ return { type, id, label };
14264
+ case "MQTT":
14265
+ return { type, id, label, topic: args2.topic };
14266
+ case "LOG":
14267
+ return { type, id, label, level: args2.level, tag: args2.tag };
14268
+ }
14269
+ }
14161
14270
  function deserialize3(api) {
14162
14271
  const { id, label, type } = api;
14163
14272
  const args2 = {};
@@ -14172,6 +14281,10 @@ function deserialize3(api) {
14172
14281
  case "MQTT":
14173
14282
  args2.topic = api.topic;
14174
14283
  break;
14284
+ case "LOG":
14285
+ args2.level = api.level;
14286
+ args2.tag = api.tag;
14287
+ break;
14175
14288
  case "GPIOOUT":
14176
14289
  case "ADC":
14177
14290
  case "DAC":
@@ -14254,6 +14367,27 @@ var MemoryRegistry = new MemoryDefinitionRegistry();
14254
14367
  MemoryRegistry.initialize();
14255
14368
 
14256
14369
  // ../workflow-core/src/memory/serialization.ts
14370
+ function serialize4(mem) {
14371
+ const { id, label, type, arguments: args2 } = mem;
14372
+ switch (type) {
14373
+ case "MemoryFile":
14374
+ return {
14375
+ type,
14376
+ id,
14377
+ label,
14378
+ description: args2.description ?? "",
14379
+ content: args2.content ?? "",
14380
+ ...args2.maxSizeBytes != null ? { maxSizeBytes: args2.maxSizeBytes } : {}
14381
+ };
14382
+ case "VectorDatabase":
14383
+ return {
14384
+ type,
14385
+ id,
14386
+ label,
14387
+ ...args2.description != null ? { description: args2.description } : {}
14388
+ };
14389
+ }
14390
+ }
14257
14391
  function deserialize4(api) {
14258
14392
  const { id, label, type } = api;
14259
14393
  const args2 = {};
@@ -14301,6 +14435,18 @@ var ModelRegistry = new ModelDefinitionRegistry();
14301
14435
  ModelRegistry.initialize();
14302
14436
 
14303
14437
  // ../workflow-core/src/model/serialization.ts
14438
+ function serialize5(model) {
14439
+ const { id, label, type, arguments: args2 } = model;
14440
+ switch (type) {
14441
+ case "LLMModel":
14442
+ return {
14443
+ type,
14444
+ id,
14445
+ label,
14446
+ capabilities: args2.capabilities ?? ["chat"]
14447
+ };
14448
+ }
14449
+ }
14304
14450
  function deserialize5(api) {
14305
14451
  const { id, label, type } = api;
14306
14452
  const args2 = {};
@@ -14349,6 +14495,261 @@ function buildFunctionNodeDef(fn, t = (key) => key) {
14349
14495
  }
14350
14496
 
14351
14497
  // ../workflow-core/src/node/serialization.ts
14498
+ function serialize6(node, isToolInput) {
14499
+ const result = serializeNodeData(node, node.position, isToolInput);
14500
+ if (node.label) {
14501
+ result.label = node.label;
14502
+ }
14503
+ if ("arguments" in result && result.arguments) {
14504
+ const def = node.type !== "FunctionCall" ? NodeRegistry.getByType(node.type) : void 0;
14505
+ if (def) {
14506
+ pruneArguments(result.arguments, def.parameters, isToolInput);
14507
+ }
14508
+ }
14509
+ return result;
14510
+ }
14511
+ function serializeNodeData(data, position, isToolInput) {
14512
+ switch (data.type) {
14513
+ case "ReadPin":
14514
+ return {
14515
+ id: data.id,
14516
+ type: data.type,
14517
+ position,
14518
+ arguments: {
14519
+ pinReference: data.arguments.pinReference,
14520
+ signalType: data.arguments.signalType,
14521
+ output: data.arguments.output,
14522
+ toolDescription: data.arguments.toolDescription
14523
+ }
14524
+ };
14525
+ case "SerialRead":
14526
+ return {
14527
+ id: data.id,
14528
+ type: data.type,
14529
+ position,
14530
+ arguments: {
14531
+ portReference: data.arguments.portReference,
14532
+ ...data.arguments.prompt !== void 0 ? { prompt: data.arguments.prompt } : {},
14533
+ output: data.arguments.output
14534
+ }
14535
+ };
14536
+ case "WritePin":
14537
+ return {
14538
+ id: data.id,
14539
+ type: data.type,
14540
+ position,
14541
+ arguments: {
14542
+ pinReference: data.arguments.pinReference,
14543
+ signalType: data.arguments.signalType,
14544
+ value: data.arguments.value
14545
+ }
14546
+ };
14547
+ case "SerialWrite":
14548
+ return {
14549
+ id: data.id,
14550
+ type: data.type,
14551
+ position,
14552
+ arguments: {
14553
+ portReference: data.arguments.portReference,
14554
+ value: data.arguments.value
14555
+ }
14556
+ };
14557
+ case "Agent": {
14558
+ return {
14559
+ id: data.id,
14560
+ type: data.type,
14561
+ position,
14562
+ arguments: {
14563
+ name: data.arguments.name,
14564
+ model: data.arguments.model,
14565
+ instructions: data.arguments.instructions,
14566
+ maxTurns: data.arguments.maxTurns,
14567
+ outputDeclarations: data.arguments.outputDeclarations,
14568
+ memoryRefs: data.arguments.memoryRefs ?? [],
14569
+ answer: data.arguments.answer,
14570
+ toolDescription: data.arguments.toolDescription
14571
+ }
14572
+ };
14573
+ }
14574
+ case "If":
14575
+ return {
14576
+ id: data.id,
14577
+ type: data.type,
14578
+ position,
14579
+ arguments: {
14580
+ condition: data.arguments.condition
14581
+ }
14582
+ };
14583
+ case "OnFunctionCall":
14584
+ return {
14585
+ id: data.id,
14586
+ type: data.type,
14587
+ position
14588
+ };
14589
+ case "OnStartup":
14590
+ return {
14591
+ id: data.id,
14592
+ type: data.type,
14593
+ position
14594
+ };
14595
+ case "OnPinEdge":
14596
+ return {
14597
+ id: data.id,
14598
+ type: data.type,
14599
+ position,
14600
+ arguments: {
14601
+ pinReference: data.arguments.pinReference,
14602
+ edge: data.arguments.edge
14603
+ }
14604
+ };
14605
+ case "OnSerialReceive":
14606
+ return {
14607
+ id: data.id,
14608
+ type: data.type,
14609
+ position,
14610
+ arguments: {
14611
+ portReference: data.arguments.portReference,
14612
+ output: data.arguments.output
14613
+ }
14614
+ };
14615
+ case "OnThreshold":
14616
+ return {
14617
+ id: data.id,
14618
+ type: data.type,
14619
+ position,
14620
+ arguments: {
14621
+ variable: data.arguments.variable,
14622
+ threshold: data.arguments.threshold,
14623
+ direction: data.arguments.direction,
14624
+ deadband: data.arguments.deadband,
14625
+ output: data.arguments.output
14626
+ }
14627
+ };
14628
+ case "Delay":
14629
+ return {
14630
+ id: data.id,
14631
+ type: data.type,
14632
+ position,
14633
+ arguments: {
14634
+ delayMs: data.arguments.delayMs
14635
+ }
14636
+ };
14637
+ case "Ticker":
14638
+ return {
14639
+ id: data.id,
14640
+ type: data.type,
14641
+ position,
14642
+ arguments: {
14643
+ intervalValue: data.arguments.intervalValue,
14644
+ intervalUnit: data.arguments.intervalUnit
14645
+ }
14646
+ };
14647
+ case "Alarm":
14648
+ return {
14649
+ id: data.id,
14650
+ type: data.type,
14651
+ position,
14652
+ arguments: {
14653
+ time: data.arguments.time,
14654
+ days: data.arguments.days
14655
+ }
14656
+ };
14657
+ case "WebSearchTool":
14658
+ return {
14659
+ id: data.id,
14660
+ type: data.type,
14661
+ position,
14662
+ arguments: {
14663
+ maxResults: data.arguments.maxResults
14664
+ }
14665
+ };
14666
+ case "Retriever":
14667
+ return {
14668
+ id: data.id,
14669
+ type: data.type,
14670
+ position,
14671
+ arguments: {
14672
+ memoryReference: data.arguments.memoryReference,
14673
+ topK: data.arguments.topK,
14674
+ query: data.arguments.query,
14675
+ output: data.arguments.output,
14676
+ toolDescription: data.arguments.toolDescription
14677
+ }
14678
+ };
14679
+ case "WebFetch":
14680
+ return {
14681
+ id: data.id,
14682
+ type: data.type,
14683
+ position,
14684
+ arguments: {
14685
+ url: data.arguments.url,
14686
+ maxChars: data.arguments.maxChars,
14687
+ output: data.arguments.output
14688
+ }
14689
+ };
14690
+ case "FunctionCall": {
14691
+ const inputBindings = {};
14692
+ const outputBindings = {};
14693
+ const args2 = data.arguments;
14694
+ for (const arg of data.functionInfo.arguments) {
14695
+ const key = arg.uid ?? arg.name;
14696
+ const v = args2[key];
14697
+ if (v !== void 0) inputBindings[key] = v;
14698
+ }
14699
+ for (const ret of data.functionInfo.returns) {
14700
+ const key = ret.uid ?? ret.name;
14701
+ const v = args2[key];
14702
+ if (v !== void 0) outputBindings[key] = v;
14703
+ }
14704
+ const toolDescription = args2.toolDescription;
14705
+ return {
14706
+ id: data.id,
14707
+ type: data.type,
14708
+ functionId: data.functionInfo.id,
14709
+ position,
14710
+ arguments: {
14711
+ inputBindings,
14712
+ outputBindings,
14713
+ ...isToolInput && toolDescription !== void 0 ? { toolDescription } : {}
14714
+ }
14715
+ };
14716
+ }
14717
+ case "SetVariable":
14718
+ return {
14719
+ id: data.id,
14720
+ type: data.type,
14721
+ position,
14722
+ arguments: {
14723
+ variable: data.arguments.variable,
14724
+ value: data.arguments.value
14725
+ }
14726
+ };
14727
+ case "MqttPublish":
14728
+ return {
14729
+ id: data.id,
14730
+ type: data.type,
14731
+ position,
14732
+ arguments: {
14733
+ channelReference: data.arguments.channelReference ?? "",
14734
+ dataType: data.arguments.dataType,
14735
+ value: data.arguments.value,
14736
+ qos: Number(data.arguments.qos),
14737
+ retain: data.arguments.retain
14738
+ }
14739
+ };
14740
+ case "OnMqttMessage":
14741
+ return {
14742
+ id: data.id,
14743
+ type: data.type,
14744
+ position,
14745
+ arguments: {
14746
+ channelReference: data.arguments.channelReference ?? "",
14747
+ dataType: data.arguments.dataType,
14748
+ output: data.arguments.output
14749
+ }
14750
+ };
14751
+ }
14752
+ }
14352
14753
  function deserialize6(apiNode, resolveFunctionInfo) {
14353
14754
  return { ...deserializeNodeData(apiNode, resolveFunctionInfo), position: apiNode.position };
14354
14755
  }
@@ -14587,6 +14988,43 @@ var MAIN_CANVAS_ID = "main";
14587
14988
 
14588
14989
  // ../workflow-core/src/workflow/serialization.ts
14589
14990
  var KNOWN_CHANNEL_TYPES = new Set(ALL_CHANNEL_TYPES);
14991
+ function serialize7(state) {
14992
+ const mainCanvas = state.canvases[MAIN_CANVAS_ID];
14993
+ if (!mainCanvas) {
14994
+ throw new Error("Main canvas missing");
14995
+ }
14996
+ const mainNodes = mainCanvas.nodes.map((n) => serialize6(n, isNodeUsedAsTool(n.id, n, mainCanvas.edges)));
14997
+ const mainEdges = mainCanvas.edges.map(serialize2);
14998
+ const mainDeclared = extractDeclaredVariables(mainCanvas.variables);
14999
+ const functions = [];
15000
+ for (const [id, decl] of Object.entries(state.functions)) {
15001
+ const body = state.canvases[id];
15002
+ if (!body) {
15003
+ throw new Error(`[workflow-core] function ${id} has no body canvas \u2014 cannot serialize`);
15004
+ }
15005
+ const { functionInfo, outputAssignments } = serialize(decl);
15006
+ functions.push({
15007
+ functionInfo,
15008
+ outputAssignments,
15009
+ nodes: body.nodes.map((n) => serialize6(n, isNodeUsedAsTool(n.id, n, body.edges))),
15010
+ edges: body.edges.map(serialize2),
15011
+ declaredVariables: extractDeclaredVariables(body.variables)
15012
+ });
15013
+ }
15014
+ const channels = Object.values(state.channels).map(serialize3);
15015
+ const memory = Object.values(state.memory).map(serialize4);
15016
+ const models = Object.values(state.models).map(serialize5);
15017
+ return {
15018
+ schemaVersion: CURRENT_SCHEMA_VERSION,
15019
+ nodes: mainNodes,
15020
+ edges: mainEdges,
15021
+ functions,
15022
+ declaredVariables: mainDeclared,
15023
+ channels,
15024
+ memory,
15025
+ models
15026
+ };
15027
+ }
14590
15028
  function deserialize7(workflow) {
14591
15029
  const canvases = {};
14592
15030
  const functions = {};
@@ -14679,6 +15117,20 @@ function buildCanvasVariables(nodes, fnArgs, declaredVariables) {
14679
15117
  }
14680
15118
  return variables;
14681
15119
  }
15120
+ function extractDeclaredVariables(variables) {
15121
+ const result = [];
15122
+ for (const v of Object.values(variables)) {
15123
+ if (v.kind === "declared") {
15124
+ result.push({
15125
+ uid: v.uid,
15126
+ name: v.name,
15127
+ dataType: v.dataType,
15128
+ ...v.initialValue !== void 0 ? { initialValue: v.initialValue } : {}
15129
+ });
15130
+ }
15131
+ }
15132
+ return result;
15133
+ }
14682
15134
 
14683
15135
  // ../workflow-core/src/expression/types.ts
14684
15136
  function resolveExpression(apiExpr, availableVars) {
@@ -16595,7 +17047,11 @@ function pluralize(n) {
16595
17047
  // cli/check-schema.ts
16596
17048
  var import_ajv = __toESM(require_ajv(), 1);
16597
17049
  var import_ajv_formats = __toESM(require_dist(), 1);
16598
- import { existsSync, readFileSync, promises as fs5 } from "node:fs";
17050
+ import { promises as fs5 } from "node:fs";
17051
+ import path7 from "node:path";
17052
+
17053
+ // cli/contract.ts
17054
+ import { existsSync, readFileSync } from "node:fs";
16599
17055
  import path6 from "node:path";
16600
17056
  import { fileURLToPath as fileURLToPath2 } from "node:url";
16601
17057
 
@@ -18961,7 +19417,7 @@ var import_js_yaml = /* @__PURE__ */ __toESM2((/* @__PURE__ */ __commonJSMin(((e
18961
19417
  var { Type, Schema, FAILSAFE_SCHEMA, JSON_SCHEMA, CORE_SCHEMA, DEFAULT_SCHEMA, load, loadAll, dump, YAMLException, types, safeLoad, safeLoadAll, safeDump } = import_js_yaml.default;
18962
19418
  var index_vite_proxy_tmp_default = import_js_yaml.default;
18963
19419
 
18964
- // cli/check-schema.ts
19420
+ // cli/contract.ts
18965
19421
  var here = path6.dirname(fileURLToPath2(import.meta.url));
18966
19422
  var CONTRACT_DIR = existsSync(path6.join(here, "workflow.yaml")) ? here : path6.resolve(here, "../../../contract");
18967
19423
  function loadContractDocument(fileName) {
@@ -18987,6 +19443,8 @@ function openApiToJsonSchema(value) {
18987
19443
  }
18988
19444
  return out;
18989
19445
  }
19446
+
19447
+ // cli/check-schema.ts
18990
19448
  var cachedValidator;
18991
19449
  function compileWorkflowValidator() {
18992
19450
  if (cachedValidator) return cachedValidator;
@@ -19011,7 +19469,7 @@ async function checkSchemaCommand(filePath) {
19011
19469
  process.stderr.write("Usage: fh-workflow check-schema <file.json>\n");
19012
19470
  process.exit(1);
19013
19471
  }
19014
- const abs = path6.resolve(process.cwd(), filePath);
19472
+ const abs = path7.resolve(process.cwd(), filePath);
19015
19473
  let raw;
19016
19474
  try {
19017
19475
  raw = await fs5.readFile(abs, "utf-8");
@@ -19060,35 +19518,35 @@ function describeParams(e) {
19060
19518
  return "";
19061
19519
  }
19062
19520
 
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;
19521
+ // ../workflow-core/src/deploy/requirements.ts
19522
+ function getReferencedCatalogModelIds(workflow) {
19523
+ const declaredModel = new Set(Object.keys(workflow.models));
19524
+ const catalogIds = /* @__PURE__ */ new Set();
19525
+ for (const canvas of Object.values(workflow.canvases)) {
19526
+ for (const node of canvas.nodes) {
19527
+ const def = NodeRegistry.getByType(node.type);
19528
+ if (!def) continue;
19529
+ const args2 = node.arguments;
19530
+ const isToolInput = isNodeUsedAsTool(node.id, node, canvas.edges);
19531
+ for (const param of def.parameters) {
19532
+ if (param.type !== "modelSelect") continue;
19533
+ if (!isParameterActive(param, args2, isToolInput)) continue;
19534
+ const id = args2[param.id];
19535
+ if (typeof id === "string" && id !== "" && !declaredModel.has(id)) {
19536
+ catalogIds.add(id);
19537
+ }
19538
+ }
19087
19539
  }
19088
19540
  }
19541
+ return [...catalogIds];
19542
+ }
19543
+ function assertNeverChannel(t) {
19544
+ throw new Error(`unhandled channel type: ${String(t)}`);
19545
+ }
19546
+ function deriveRequirements(workflow) {
19089
19547
  const hardwareChannels = [];
19090
19548
  const mqttChannels = [];
19091
- for (const channel of workflow.channels) {
19549
+ for (const channel of Object.values(workflow.channels)) {
19092
19550
  switch (channel.type) {
19093
19551
  case "GPIOIN":
19094
19552
  case "GPIOOUT":
@@ -19109,14 +19567,267 @@ function inspect(workflow) {
19109
19567
  case "MQTT":
19110
19568
  mqttChannels.push({ id: channel.id, label: channel.label });
19111
19569
  break;
19570
+ case "LOG":
19571
+ break;
19112
19572
  default:
19113
- return assertNever(channel);
19573
+ return assertNeverChannel(channel.type);
19574
+ }
19575
+ }
19576
+ let hasRetriever = false;
19577
+ let hasWebSearch = false;
19578
+ for (const canvas of Object.values(workflow.canvases)) {
19579
+ for (const node of canvas.nodes) {
19580
+ if (node.type === "Retriever") hasRetriever = true;
19581
+ else if (node.type === "WebSearchTool") hasWebSearch = true;
19114
19582
  }
19115
19583
  }
19116
- const customModels = (workflow.models ?? []).map((m) => ({ id: m.id, label: m.label }));
19117
- return { hasProviderModel, hasRetriever, hasWebSearch, hardwareChannels, mqttChannels, customModels };
19584
+ const customModels = Object.values(workflow.models).map((m) => ({ id: m.id, label: m.label }));
19585
+ return {
19586
+ hasProviderModel: getReferencedCatalogModelIds(workflow).length > 0,
19587
+ hasRetriever,
19588
+ hasWebSearch,
19589
+ hardwareChannels,
19590
+ mqttChannels,
19591
+ customModels
19592
+ };
19118
19593
  }
19119
19594
 
19595
+ // ../workflow-core/src/deploy/spec.ts
19596
+ var ENGINE_MEMORY_PATH = "/var/lib/foresthub/memory";
19597
+ var RefAllocator = class {
19598
+ byKey = /* @__PURE__ */ new Map();
19599
+ used = /* @__PURE__ */ new Set();
19600
+ alloc(key, hint) {
19601
+ const existing = this.byKey.get(key);
19602
+ if (existing) return existing;
19603
+ let ref = hint;
19604
+ for (let n = 2; this.used.has(ref); n++) ref = `${hint}-${n}`;
19605
+ this.used.add(ref);
19606
+ this.byKey.set(key, ref);
19607
+ return ref;
19608
+ }
19609
+ };
19610
+ function basename(p) {
19611
+ const tail = p.replace(/\/+$/, "").split("/").pop() ?? p;
19612
+ return tail.replace(/[^A-Za-z0-9._:-]/g, "-") || "res";
19613
+ }
19614
+ function brokerHost(url2) {
19615
+ try {
19616
+ return new URL(url2).hostname || "mqtt";
19617
+ } catch {
19618
+ return "mqtt";
19619
+ }
19620
+ }
19621
+ function sidecarServiceName(modelId) {
19622
+ const slug = modelId.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
19623
+ return `llama-${slug || "model"}`;
19624
+ }
19625
+ function ggufNameError(name) {
19626
+ const t = (name ?? "").trim();
19627
+ if (!t) return "a model filename is required";
19628
+ if (!t.toLowerCase().endsWith(".gguf")) return "must be a .gguf file (llama-server only loads GGUF)";
19629
+ if (t.includes("/")) return "just the filename, not a path \u2014 the file goes in ./models/";
19630
+ return null;
19631
+ }
19632
+ function hardwareAddressKey(family, chipOrDevice, index) {
19633
+ const dev = chipOrDevice.trim();
19634
+ return family === "serial" ? `serial:${dev}` : `${family}:${dev}:${index}`;
19635
+ }
19636
+ function hardwareAddressLabel(family, chipOrDevice, index) {
19637
+ const dev = chipOrDevice.trim();
19638
+ if (family === "serial") return dev;
19639
+ return `${dev} ${family === "gpio" ? "line" : "channel"} ${index}`;
19640
+ }
19641
+ function hardwareConflicts(channels, bindings) {
19642
+ const conflicts = [];
19643
+ const claimed = /* @__PURE__ */ new Map();
19644
+ for (const ch of channels) {
19645
+ const b = bindings[ch.id];
19646
+ if (!b?.chipOrDevice || ch.addressable && b.index === void 0) continue;
19647
+ const key = hardwareAddressKey(ch.family, b.chipOrDevice, b.index);
19648
+ const holder = claimed.get(key);
19649
+ if (holder) {
19650
+ conflicts.push(`hardware "${ch.id}": ${hardwareAddressLabel(ch.family, b.chipOrDevice, b.index)} is already used by "${holder}"`);
19651
+ } else {
19652
+ claimed.set(key, ch.id);
19653
+ }
19654
+ }
19655
+ return conflicts;
19656
+ }
19657
+ function familyMismatches(channels, bindings) {
19658
+ const mismatches = [];
19659
+ for (const ch of channels) {
19660
+ const b = bindings[ch.id];
19661
+ if (!b) continue;
19662
+ if (ch.family !== "serial" && b.baud !== void 0) {
19663
+ mismatches.push(`hardware "${ch.id}": "baud" only applies to serial channels (this is a ${ch.family} channel)`);
19664
+ }
19665
+ if (ch.family === "serial" && b.index !== void 0) {
19666
+ mismatches.push(`hardware "${ch.id}": "index" does not apply to serial channels (the device path is the full address)`);
19667
+ }
19668
+ }
19669
+ return mismatches;
19670
+ }
19671
+ function assertNeverFamily(f) {
19672
+ throw new Error(`unhandled hardware family: ${String(f)}`);
19673
+ }
19674
+ function assertDeployable(req, inputs) {
19675
+ const missing = [];
19676
+ for (const ch of req.hardwareChannels) {
19677
+ const b = inputs.hardware[ch.id];
19678
+ if (!b?.chipOrDevice) missing.push(`hardware "${ch.id}": device path`);
19679
+ else if (ch.addressable && b.index === void 0) missing.push(`hardware "${ch.id}": index`);
19680
+ }
19681
+ for (const ch of req.mqttChannels) {
19682
+ if (!inputs.mqtt[ch.id]?.brokerUrl) missing.push(`mqtt "${ch.id}": broker URL`);
19683
+ }
19684
+ for (const m of req.customModels) {
19685
+ const b = inputs.models[m.id];
19686
+ if (b?.location === "device") {
19687
+ const err = ggufNameError(b.modelFile);
19688
+ if (err) missing.push(`model "${m.id}": ${err}`);
19689
+ } else if (!b?.url) {
19690
+ missing.push(`model "${m.id}": endpoint URL`);
19691
+ }
19692
+ }
19693
+ missing.push(...hardwareConflicts(req.hardwareChannels, inputs.hardware));
19694
+ missing.push(...familyMismatches(req.hardwareChannels, inputs.hardware));
19695
+ if (missing.length > 0) {
19696
+ throw new Error(`invalid deploy config:
19697
+ - ${missing.join("\n - ")}`);
19698
+ }
19699
+ }
19700
+ function assertNoNameCollisions(components) {
19701
+ const seen = /* @__PURE__ */ new Set();
19702
+ for (const c of components) {
19703
+ if (seen.has(c.name)) throw new Error(`duplicate component name "${c.name}"`);
19704
+ seen.add(c.name);
19705
+ }
19706
+ }
19707
+ function buildDeploymentSpec(workflow, inputs, meta3, customComponents = []) {
19708
+ const req = deriveRequirements(workflow);
19709
+ assertDeployable(req, inputs);
19710
+ const refs = new RefAllocator();
19711
+ const resourceSecrets = {};
19712
+ const gpios = {};
19713
+ const adcs = {};
19714
+ const dacs = {};
19715
+ const pwms = {};
19716
+ const serials = {};
19717
+ const externalResources = {};
19718
+ const mapping = {};
19719
+ const cdev = /* @__PURE__ */ new Set();
19720
+ let privileged = false;
19721
+ for (const ch of req.hardwareChannels) {
19722
+ const b = inputs.hardware[ch.id];
19723
+ if (!b) throw new Error(`unbound hardware channel ${ch.id}`);
19724
+ const dev = b.chipOrDevice;
19725
+ const ref = refs.alloc(`hw:${ch.family}:${dev}`, basename(dev));
19726
+ switch (ch.family) {
19727
+ case "gpio":
19728
+ gpios[ref] = { chip: dev };
19729
+ cdev.add(dev);
19730
+ break;
19731
+ case "serial":
19732
+ serials[ref] = b.baud ? { device: dev, baud: b.baud } : { device: dev };
19733
+ cdev.add(dev);
19734
+ break;
19735
+ case "pwm":
19736
+ pwms[ref] = { chip: dev };
19737
+ privileged = true;
19738
+ break;
19739
+ case "adc":
19740
+ adcs[ref] = { device: dev };
19741
+ privileged = true;
19742
+ break;
19743
+ case "dac":
19744
+ dacs[ref] = { device: dev };
19745
+ privileged = true;
19746
+ break;
19747
+ default:
19748
+ return assertNeverFamily(ch.family);
19749
+ }
19750
+ if (ch.addressable && b.index !== void 0) mapping[ch.id] = { ref, index: b.index };
19751
+ else mapping[ch.id] = { ref };
19752
+ }
19753
+ for (const ch of req.mqttChannels) {
19754
+ const b = inputs.mqtt[ch.id];
19755
+ if (!b) throw new Error(`unbound mqtt channel ${ch.id}`);
19756
+ const conn = { type: "mqtt", brokerUrl: b.brokerUrl };
19757
+ if (b.username) conn.username = b.username;
19758
+ const ref = refs.alloc(`mqtt:${JSON.stringify(conn)}:${b.password ?? ""}`, `mqtt-${brokerHost(b.brokerUrl)}`);
19759
+ externalResources[ref] = conn;
19760
+ mapping[ch.id] = { ref };
19761
+ if (b.password) resourceSecrets[ref] = { ...resourceSecrets[ref], password: b.password };
19762
+ }
19763
+ const llamaComponents = [];
19764
+ for (const m of req.customModels) {
19765
+ const b = inputs.models[m.id];
19766
+ if (!b) throw new Error(`unbound model ${m.id}`);
19767
+ const ref = refs.alloc(`model:${m.id}`, basename(m.id));
19768
+ mapping[m.id] = { ref };
19769
+ if (b.location === "device") {
19770
+ const port = b.port ?? 8080;
19771
+ const service = sidecarServiceName(m.id);
19772
+ externalResources[ref] = { type: "selfhosted", url: `http://${service}:${port}` };
19773
+ llamaComponents.push({
19774
+ name: service,
19775
+ image: meta3.llamaServerImage,
19776
+ command: [
19777
+ "--model",
19778
+ `/models/${b.modelFile}`,
19779
+ "--host",
19780
+ "0.0.0.0",
19781
+ "--port",
19782
+ String(port),
19783
+ "--ctx-size",
19784
+ String(b.ctxSize ?? 4096)
19785
+ ],
19786
+ volumes: ["./models:/models:ro"]
19787
+ });
19788
+ } else {
19789
+ externalResources[ref] = { type: "selfhosted", url: b.url };
19790
+ if (b.apiKey) resourceSecrets[ref] = { apiKey: b.apiKey };
19791
+ }
19792
+ }
19793
+ const manifest = {};
19794
+ if (Object.keys(gpios).length) manifest.gpios = gpios;
19795
+ if (Object.keys(adcs).length) manifest.adcs = adcs;
19796
+ if (Object.keys(dacs).length) manifest.dacs = dacs;
19797
+ if (Object.keys(pwms).length) manifest.pwms = pwms;
19798
+ if (Object.keys(serials).length) manifest.serials = serials;
19799
+ const config2 = { workflow: serialize7(workflow) };
19800
+ if (Object.keys(mapping).length) config2.mapping = mapping;
19801
+ if (Object.keys(externalResources).length) config2.externalResources = externalResources;
19802
+ if (Object.keys(manifest).length) config2.manifest = manifest;
19803
+ const engine = {
19804
+ name: "engine",
19805
+ image: meta3.engineImage,
19806
+ pull: "never",
19807
+ // built locally before deploy, in no registry
19808
+ config: config2,
19809
+ volumes: [`engine-memory:${ENGINE_MEMORY_PATH}`]
19810
+ };
19811
+ if (cdev.size > 0) engine.devices = [...cdev];
19812
+ if (privileged) engine.privileged = true;
19813
+ if (cdev.size > 0 || privileged) engine.user = "0:0";
19814
+ const components = [engine, ...llamaComponents, ...customComponents];
19815
+ assertNoNameCollisions(components);
19816
+ const spec = {
19817
+ schemaVersion: 1,
19818
+ id: meta3.id,
19819
+ status: meta3.status,
19820
+ components
19821
+ };
19822
+ if (meta3.createdAt) spec.createdAt = meta3.createdAt;
19823
+ return { spec, resourceSecrets };
19824
+ }
19825
+
19826
+ // cli/deploy/index.ts
19827
+ import { promises as fs9 } from "node:fs";
19828
+ import path12 from "node:path";
19829
+ import { parseArgs } from "node:util";
19830
+
19120
19831
  // ../node_modules/@inquirer/core/dist/lib/key.js
19121
19832
  var keybindings = ["emacs", "vim"];
19122
19833
  var keybindingLookup = new Set(keybindings);
@@ -20520,7 +21231,7 @@ var PromisePolyfill = class extends Promise {
20520
21231
  };
20521
21232
 
20522
21233
  // ../node_modules/@inquirer/core/dist/lib/create-prompt.js
20523
- import path7 from "node:path";
21234
+ import path8 from "node:path";
20524
21235
  var nativeSetImmediate = globalThis.setImmediate;
20525
21236
  function getCallSites() {
20526
21237
  const savedPrepareStackTrace = Error.prepareStackTrace;
@@ -20591,7 +21302,7 @@ function createPrompt(view) {
20591
21302
  if (nextView === void 0) {
20592
21303
  let callerFilename = callSites[1]?.getFileName();
20593
21304
  if (callerFilename && !callerFilename.startsWith("file://")) {
20594
- callerFilename = path7.resolve(callerFilename);
21305
+ callerFilename = path8.resolve(callerFilename);
20595
21306
  }
20596
21307
  throw new Error(`Prompt functions must return a string.
20597
21308
  at ${callerFilename}`);
@@ -20836,11 +21547,57 @@ var dist_default4 = createPrompt((config2, done) => {
20836
21547
  return `${lines}${cursorHide}`;
20837
21548
  });
20838
21549
 
21550
+ // ../node_modules/@inquirer/confirm/dist/index.js
21551
+ function getBooleanValue(value, defaultValue) {
21552
+ let answer = defaultValue !== false;
21553
+ if (/^(y|yes)/i.test(value))
21554
+ answer = true;
21555
+ else if (/^(n|no)/i.test(value))
21556
+ answer = false;
21557
+ return answer;
21558
+ }
21559
+ function boolToString(value) {
21560
+ return value ? "Yes" : "No";
21561
+ }
21562
+ var dist_default5 = createPrompt((config2, done) => {
21563
+ const { transformer = boolToString } = config2;
21564
+ const [status, setStatus] = useState("idle");
21565
+ const [value, setValue] = useState("");
21566
+ const theme = makeTheme(config2.theme);
21567
+ const prefix = usePrefix({ status, theme });
21568
+ useKeypress((key, rl) => {
21569
+ if (status !== "idle")
21570
+ return;
21571
+ if (isEnterKey(key)) {
21572
+ const answer = getBooleanValue(value, config2.default);
21573
+ setValue(transformer(answer));
21574
+ setStatus("done");
21575
+ done(answer);
21576
+ } else if (isTabKey(key)) {
21577
+ const answer = boolToString(!getBooleanValue(value, config2.default));
21578
+ rl.clearLine(0);
21579
+ rl.write(answer);
21580
+ setValue(answer);
21581
+ } else {
21582
+ setValue(rl.line);
21583
+ }
21584
+ });
21585
+ let formattedValue = value;
21586
+ let defaultValue = "";
21587
+ if (status === "done") {
21588
+ formattedValue = theme.style.answer(value);
21589
+ } else {
21590
+ defaultValue = ` ${theme.style.defaultAnswer(config2.default === false ? "y/N" : "Y/n")}`;
21591
+ }
21592
+ const message = theme.style.message(config2.message, status);
21593
+ return `${prefix} ${message}${defaultValue} ${formattedValue}`;
21594
+ });
21595
+
20839
21596
  // ../node_modules/@inquirer/input/dist/index.js
20840
21597
  var inputTheme = {
20841
21598
  validationFailureMode: "keep"
20842
21599
  };
20843
- var dist_default5 = createPrompt((config2, done) => {
21600
+ var dist_default6 = createPrompt((config2, done) => {
20844
21601
  const { prefill = "tab" } = config2;
20845
21602
  const theme = makeTheme(inputTheme, config2.theme);
20846
21603
  const [status, setStatus] = useState("idle");
@@ -20927,7 +21684,7 @@ var passwordTheme = {
20927
21684
  maskedText: "[input is masked]"
20928
21685
  }
20929
21686
  };
20930
- var dist_default6 = createPrompt((config2, done) => {
21687
+ var dist_default7 = createPrompt((config2, done) => {
20931
21688
  const { validate = () => true } = config2;
20932
21689
  const theme = makeTheme(passwordTheme, config2.theme);
20933
21690
  const [status, setStatus] = useState("idle");
@@ -21019,7 +21776,7 @@ function normalizeChoices2(choices) {
21019
21776
  return normalizedChoice;
21020
21777
  });
21021
21778
  }
21022
- var dist_default7 = createPrompt((config2, done) => {
21779
+ var dist_default8 = createPrompt((config2, done) => {
21023
21780
  const { loop = true, pageSize = 7 } = config2;
21024
21781
  const theme = makeTheme(selectTheme, config2.theme);
21025
21782
  const { keybindings: keybindings2 } = theme;
@@ -21148,8 +21905,145 @@ var dist_default7 = createPrompt((config2, done) => {
21148
21905
  });
21149
21906
 
21150
21907
  // cli/deploy/prompts.ts
21908
+ import { existsSync as existsSync3, promises as fs7 } from "node:fs";
21909
+ import path10 from "node:path";
21910
+
21911
+ // cli/deploy/components.ts
21151
21912
  import { existsSync as existsSync2, promises as fs6 } from "node:fs";
21152
- import path8 from "node:path";
21913
+ import path9 from "node:path";
21914
+ var import_ajv2 = __toESM(require_ajv(), 1);
21915
+ var import_ajv_formats2 = __toESM(require_dist(), 1);
21916
+ async function readComponentJson(dir) {
21917
+ const file2 = path9.join(dir, "component.json");
21918
+ let raw;
21919
+ try {
21920
+ raw = await fs6.readFile(file2, "utf-8");
21921
+ } catch (err) {
21922
+ if (err.code === "ENOENT") {
21923
+ throw new Error(`custom component: no component.json in ${dir}`, { cause: err });
21924
+ }
21925
+ throw err;
21926
+ }
21927
+ try {
21928
+ return JSON.parse(raw);
21929
+ } catch (err) {
21930
+ throw new Error(`${file2}: invalid JSON: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
21931
+ }
21932
+ }
21933
+ var cachedValidator2;
21934
+ function compileDeployComponentValidator() {
21935
+ if (cachedValidator2) return cachedValidator2;
21936
+ const deployment = openApiToJsonSchema(loadContractDocument("deployment.yaml"));
21937
+ deployment.$id = "deployment.yaml";
21938
+ const schemas = deployment.components.schemas;
21939
+ schemas.DeployComponent.additionalProperties = false;
21940
+ const ajv = new import_ajv2.default({ strict: false, allErrors: true });
21941
+ (0, import_ajv_formats2.default)(ajv);
21942
+ ajv.addSchema(deployment);
21943
+ cachedValidator2 = ajv.compile({ $ref: "deployment.yaml#/components/schemas/DeployComponent" });
21944
+ return cachedValidator2;
21945
+ }
21946
+ function validateComponent(data) {
21947
+ const validate = compileDeployComponentValidator();
21948
+ if (validate(data)) return [];
21949
+ return (validate.errors ?? []).map((e) => {
21950
+ const p = e.params;
21951
+ const which = p.additionalProperty ?? p.missingProperty;
21952
+ const detail = typeof which === "string" ? ` "${which}"` : "";
21953
+ return `${e.instancePath || "/"} ${e.message ?? "invalid"}${detail}`;
21954
+ });
21955
+ }
21956
+ function parseDeployComponents(raw) {
21957
+ const errors = [];
21958
+ for (const { source, data } of raw) {
21959
+ for (const message of validateComponent(data)) errors.push(`${source}: ${message}`);
21960
+ }
21961
+ if (errors.length > 0) {
21962
+ throw new Error(`invalid custom component(s):
21963
+ - ${errors.join("\n - ")}`);
21964
+ }
21965
+ return raw.map((r) => r.data);
21966
+ }
21967
+ function parseEnvExample(text) {
21968
+ return text.split("\n").map((line) => {
21969
+ const m = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line);
21970
+ return m ? { kind: "kv", key: m[1] ?? "", value: m[2] ?? "" } : { kind: "comment", text: line };
21971
+ });
21972
+ }
21973
+ var SECRET_HINT = /password|secret|key|token/i;
21974
+ async function resolveComponentEnv(dir, name, opts) {
21975
+ const file2 = path9.join(dir, `${name}.env.example`);
21976
+ let raw;
21977
+ try {
21978
+ raw = await fs6.readFile(file2, "utf-8");
21979
+ } catch (err) {
21980
+ if (err.code === "ENOENT") return null;
21981
+ throw err;
21982
+ }
21983
+ const lines = [];
21984
+ for (const e of parseEnvExample(raw)) {
21985
+ if (e.kind === "comment") {
21986
+ lines.push(e.text);
21987
+ } else if (e.value !== "" || !opts.interactive) {
21988
+ lines.push(`${e.key}=${e.value}`);
21989
+ } else {
21990
+ const value = SECRET_HINT.test(e.key) ? await dist_default7({ message: `${name}: ${e.key}`, mask: "*" }) : await dist_default6({ message: `${name}: ${e.key}` });
21991
+ lines.push(`${e.key}=${value}`);
21992
+ }
21993
+ }
21994
+ const header = `# Auto-generated by \`fh-workflow deploy\` from ${name}.env.example.
21995
+ # Loaded into the ${name} container via compose env_file; chmod 600 if it holds
21996
+ # secrets, and fill any empty values before \`up\`.
21997
+ `;
21998
+ return `${header}
21999
+ ${lines.join("\n")}
22000
+ `;
22001
+ }
22002
+ async function promptCustomComponents(preloaded) {
22003
+ const components = [];
22004
+ const env = {};
22005
+ const seen = /* @__PURE__ */ new Set();
22006
+ const add = async (loaded) => {
22007
+ seen.add(loaded.component.name);
22008
+ components.push(loaded.component);
22009
+ const text = await resolveComponentEnv(loaded.dir, loaded.component.name, { interactive: true });
22010
+ if (text !== null) env[loaded.component.name] = text;
22011
+ };
22012
+ if (preloaded.length > 0) {
22013
+ process.stdout.write(` Already added: ${preloaded.map((c) => c.component.name).join(", ")}
22014
+ `);
22015
+ }
22016
+ for (const loaded of preloaded) await add(loaded);
22017
+ while (await dist_default5({
22018
+ message: components.length === 0 ? "Add a custom component?" : "Add another custom component?",
22019
+ default: false
22020
+ })) {
22021
+ const dir = (await dist_default6({
22022
+ message: "Custom component folder",
22023
+ // Full inline validation: path, component.json presence, contract shape,
22024
+ // and a name not already taken — the same checks the batch path runs, so
22025
+ // a bad folder is rejected here instead of failing late in the build.
22026
+ validate: async (v) => {
22027
+ const t = v.trim();
22028
+ if (!t) return "enter a folder path";
22029
+ if (!existsSync2(t)) return `folder not found: ${t}`;
22030
+ if (!existsSync2(path9.join(t, "component.json"))) return `no component.json in ${t}`;
22031
+ let data;
22032
+ try {
22033
+ data = await readComponentJson(t);
22034
+ } catch (err) {
22035
+ return err instanceof Error ? err.message : String(err);
22036
+ }
22037
+ const errors = validateComponent(data);
22038
+ if (errors.length > 0) return errors.join("; ");
22039
+ const name = data.name;
22040
+ return seen.has(name) ? `"${name}" already added` : true;
22041
+ }
22042
+ })).trim();
22043
+ await add({ component: await readComponentJson(dir), dir });
22044
+ }
22045
+ return { components, env };
22046
+ }
21153
22047
 
21154
22048
  // ../node_modules/zod/v4/classic/external.js
21155
22049
  var external_exports = {};
@@ -21761,7 +22655,7 @@ __export(util_exports, {
21761
22655
  assert: () => assert,
21762
22656
  assertEqual: () => assertEqual,
21763
22657
  assertIs: () => assertIs,
21764
- assertNever: () => assertNever2,
22658
+ assertNever: () => assertNever,
21765
22659
  assertNotEqual: () => assertNotEqual,
21766
22660
  assignProp: () => assignProp,
21767
22661
  base64ToUint8Array: () => base64ToUint8Array,
@@ -21825,7 +22719,7 @@ function assertNotEqual(val) {
21825
22719
  }
21826
22720
  function assertIs(_arg) {
21827
22721
  }
21828
- function assertNever2(_x) {
22722
+ function assertNever(_x) {
21829
22723
  throw new Error("Unexpected value in exhaustive check");
21830
22724
  }
21831
22725
  function assert(_) {
@@ -21917,10 +22811,10 @@ function mergeDefs(...defs) {
21917
22811
  function cloneDef(schema) {
21918
22812
  return mergeDefs(schema._zod.def);
21919
22813
  }
21920
- function getElementAtPath(obj, path11) {
21921
- if (!path11)
22814
+ function getElementAtPath(obj, path13) {
22815
+ if (!path13)
21922
22816
  return obj;
21923
- return path11.reduce((acc, key) => acc?.[key], obj);
22817
+ return path13.reduce((acc, key) => acc?.[key], obj);
21924
22818
  }
21925
22819
  function promiseAllObject(promisesObj) {
21926
22820
  const keys = Object.keys(promisesObj);
@@ -22329,11 +23223,11 @@ function explicitlyAborted(x, startIndex = 0) {
22329
23223
  }
22330
23224
  return false;
22331
23225
  }
22332
- function prefixIssues(path11, issues) {
23226
+ function prefixIssues(path13, issues) {
22333
23227
  return issues.map((iss) => {
22334
23228
  var _a3;
22335
23229
  (_a3 = iss).path ?? (_a3.path = []);
22336
- iss.path.unshift(path11);
23230
+ iss.path.unshift(path13);
22337
23231
  return iss;
22338
23232
  });
22339
23233
  }
@@ -22480,16 +23374,16 @@ function flattenError(error51, mapper = (issue2) => issue2.message) {
22480
23374
  }
22481
23375
  function formatError(error51, mapper = (issue2) => issue2.message) {
22482
23376
  const fieldErrors = { _errors: [] };
22483
- const processError = (error52, path11 = []) => {
23377
+ const processError = (error52, path13 = []) => {
22484
23378
  for (const issue2 of error52.issues) {
22485
23379
  if (issue2.code === "invalid_union" && issue2.errors.length) {
22486
- issue2.errors.map((issues) => processError({ issues }, [...path11, ...issue2.path]));
23380
+ issue2.errors.map((issues) => processError({ issues }, [...path13, ...issue2.path]));
22487
23381
  } else if (issue2.code === "invalid_key") {
22488
- processError({ issues: issue2.issues }, [...path11, ...issue2.path]);
23382
+ processError({ issues: issue2.issues }, [...path13, ...issue2.path]);
22489
23383
  } else if (issue2.code === "invalid_element") {
22490
- processError({ issues: issue2.issues }, [...path11, ...issue2.path]);
23384
+ processError({ issues: issue2.issues }, [...path13, ...issue2.path]);
22491
23385
  } else {
22492
- const fullpath = [...path11, ...issue2.path];
23386
+ const fullpath = [...path13, ...issue2.path];
22493
23387
  if (fullpath.length === 0) {
22494
23388
  fieldErrors._errors.push(mapper(issue2));
22495
23389
  } else {
@@ -22516,17 +23410,17 @@ function formatError(error51, mapper = (issue2) => issue2.message) {
22516
23410
  }
22517
23411
  function treeifyError(error51, mapper = (issue2) => issue2.message) {
22518
23412
  const result = { errors: [] };
22519
- const processError = (error52, path11 = []) => {
23413
+ const processError = (error52, path13 = []) => {
22520
23414
  var _a3, _b;
22521
23415
  for (const issue2 of error52.issues) {
22522
23416
  if (issue2.code === "invalid_union" && issue2.errors.length) {
22523
- issue2.errors.map((issues) => processError({ issues }, [...path11, ...issue2.path]));
23417
+ issue2.errors.map((issues) => processError({ issues }, [...path13, ...issue2.path]));
22524
23418
  } else if (issue2.code === "invalid_key") {
22525
- processError({ issues: issue2.issues }, [...path11, ...issue2.path]);
23419
+ processError({ issues: issue2.issues }, [...path13, ...issue2.path]);
22526
23420
  } else if (issue2.code === "invalid_element") {
22527
- processError({ issues: issue2.issues }, [...path11, ...issue2.path]);
23421
+ processError({ issues: issue2.issues }, [...path13, ...issue2.path]);
22528
23422
  } else {
22529
- const fullpath = [...path11, ...issue2.path];
23423
+ const fullpath = [...path13, ...issue2.path];
22530
23424
  if (fullpath.length === 0) {
22531
23425
  result.errors.push(mapper(issue2));
22532
23426
  continue;
@@ -22558,8 +23452,8 @@ function treeifyError(error51, mapper = (issue2) => issue2.message) {
22558
23452
  }
22559
23453
  function toDotPath(_path) {
22560
23454
  const segs = [];
22561
- const path11 = _path.map((seg) => typeof seg === "object" ? seg.key : seg);
22562
- for (const seg of path11) {
23455
+ const path13 = _path.map((seg) => typeof seg === "object" ? seg.key : seg);
23456
+ for (const seg of path13) {
22563
23457
  if (typeof seg === "number")
22564
23458
  segs.push(`[${seg}]`);
22565
23459
  else if (typeof seg === "symbol")
@@ -35251,13 +36145,13 @@ function resolveRef(ref, ctx) {
35251
36145
  if (!ref.startsWith("#")) {
35252
36146
  throw new Error("External $ref is not supported, only local refs (#/...) are allowed");
35253
36147
  }
35254
- const path11 = ref.slice(1).split("/").filter(Boolean);
35255
- if (path11.length === 0) {
36148
+ const path13 = ref.slice(1).split("/").filter(Boolean);
36149
+ if (path13.length === 0) {
35256
36150
  return ctx.rootSchema;
35257
36151
  }
35258
36152
  const defsKey = ctx.version === "draft-2020-12" ? "$defs" : "definitions";
35259
- if (path11[0] === defsKey) {
35260
- const key = path11[1];
36153
+ if (path13[0] === defsKey) {
36154
+ const key = path13[1];
35261
36155
  if (!key || !ctx.defs[key]) {
35262
36156
  throw new Error(`Reference not found: ${ref}`);
35263
36157
  }
@@ -35679,55 +36573,18 @@ var mqttBindingSchema = external_exports.strictObject({
35679
36573
  password: external_exports.string().optional()
35680
36574
  });
35681
36575
  var modelBindingSchema = external_exports.discriminatedUnion("location", [
35682
- external_exports.strictObject({ location: external_exports.literal("device"), modelFile: external_exports.string() }),
36576
+ external_exports.strictObject({
36577
+ location: external_exports.literal("device"),
36578
+ modelFile: external_exports.string(),
36579
+ port: external_exports.number().int().positive().optional(),
36580
+ ctxSize: external_exports.number().int().positive().optional()
36581
+ }),
35683
36582
  external_exports.strictObject({ location: external_exports.literal("network"), url: external_exports.string(), apiKey: external_exports.string().optional() })
35684
36583
  ]);
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
- }
36584
+ var webSearchBindingSchema = external_exports.strictObject({
36585
+ provider: external_exports.string(),
36586
+ apiKey: external_exports.string()
36587
+ });
35731
36588
  function unknownIds(req, p) {
35732
36589
  const unknown2 = [];
35733
36590
  const check3 = (kind, ids, bindings) => {
@@ -35741,10 +36598,6 @@ function unknownIds(req, p) {
35741
36598
  check3("model", req.customModels.map((m) => m.id), p.models);
35742
36599
  return unknown2;
35743
36600
  }
35744
- var webSearchBindingSchema = external_exports.strictObject({
35745
- provider: external_exports.string(),
35746
- apiKey: external_exports.string()
35747
- });
35748
36601
  var deployConfigSchema = external_exports.strictObject({
35749
36602
  llmKeys: external_exports.partialRecord(external_exports.enum(ALL_PROVIDERS), external_exports.string()),
35750
36603
  outputDir: external_exports.string(),
@@ -35780,7 +36633,7 @@ async function promptHardware(channels, seed) {
35780
36633
  }
35781
36634
  for (const ch of channels) {
35782
36635
  if (result[ch.id]) continue;
35783
- const chipOrDevice = await dist_default5({
36636
+ const chipOrDevice = await dist_default6({
35784
36637
  message: `${ch.label} (${ch.family}): device path`,
35785
36638
  default: HARDWARE_EXAMPLE[ch.family],
35786
36639
  // Only serial claims the whole device; gpio/adc/dac/pwm share the path
@@ -35794,7 +36647,7 @@ async function promptHardware(channels, seed) {
35794
36647
  const dev = chipOrDevice.trim();
35795
36648
  const binding = { chipOrDevice: dev };
35796
36649
  if (ch.addressable) {
35797
- const idx = await dist_default5({
36650
+ const idx = await dist_default6({
35798
36651
  message: `${ch.label}: channel index / GPIO line`,
35799
36652
  validate: (v) => {
35800
36653
  const uint = isUint(v);
@@ -35806,7 +36659,7 @@ async function promptHardware(channels, seed) {
35806
36659
  binding.index = Number(idx.trim());
35807
36660
  }
35808
36661
  if (ch.family === "serial") {
35809
- const baud = await dist_default5({ message: `${ch.label}: baud rate`, default: "115200", validate: isUint });
36662
+ const baud = await dist_default6({ message: `${ch.label}: baud rate`, default: "115200", validate: isUint });
35810
36663
  binding.baud = Number(baud.trim());
35811
36664
  }
35812
36665
  result[ch.id] = binding;
@@ -35821,7 +36674,7 @@ async function promptMqtt(channels, seed) {
35821
36674
  const brokers = /* @__PURE__ */ new Map();
35822
36675
  for (const b of Object.values(result)) brokers.set(b.brokerUrl, b);
35823
36676
  if (brokers.size > 0) {
35824
- const choice = await dist_default7({
36677
+ const choice = await dist_default8({
35825
36678
  message: `${ch.label}: MQTT broker`,
35826
36679
  choices: [
35827
36680
  ...[...brokers.keys()].map((url2) => ({ value: url2, name: `reuse ${url2}` })),
@@ -35834,13 +36687,13 @@ async function promptMqtt(channels, seed) {
35834
36687
  continue;
35835
36688
  }
35836
36689
  }
35837
- const brokerUrl = await dist_default5({
36690
+ const brokerUrl = await dist_default6({
35838
36691
  message: `${ch.label}: broker URL`,
35839
36692
  default: "tcp://localhost:1883",
35840
36693
  validate: (v) => v.trim().length > 0 || "broker URL is required"
35841
36694
  });
35842
- const username = (await dist_default5({ message: `${ch.label}: username (optional)` })).trim();
35843
- const pass = await dist_default6({ message: `${ch.label}: password (optional)`, mask: "*" });
36695
+ const username = (await dist_default6({ message: `${ch.label}: username (optional)` })).trim();
36696
+ const pass = await dist_default7({ message: `${ch.label}: password (optional)`, mask: "*" });
35844
36697
  const binding = { brokerUrl: brokerUrl.trim() };
35845
36698
  if (username) binding.username = username;
35846
36699
  if (pass) binding.password = pass;
@@ -35852,7 +36705,7 @@ async function promptModels(models, seed) {
35852
36705
  const result = { ...seed };
35853
36706
  for (const m of models) {
35854
36707
  if (result[m.id]) continue;
35855
- const location = await dist_default7({
36708
+ const location = await dist_default8({
35856
36709
  message: `${m.label}: where does this model run?`,
35857
36710
  choices: [
35858
36711
  { value: "device", name: "on this device (generate a llama-server container)" },
@@ -35860,19 +36713,26 @@ async function promptModels(models, seed) {
35860
36713
  ]
35861
36714
  });
35862
36715
  if (location === "device") {
35863
- const modelFile = await dist_default5({
36716
+ const modelFile = await dist_default6({
35864
36717
  message: `${m.label}: model filename in ./models/ (e.g. model.gguf)`,
35865
36718
  validate: (v) => ggufNameError(v) ?? true
35866
36719
  });
35867
- result[m.id] = { location: "device", modelFile: modelFile.trim() };
36720
+ const ctxSize = await dist_default6({ message: `${m.label}: context window in tokens`, default: "4096", validate: isUint });
36721
+ const port = await dist_default6({ message: `${m.label}: sidecar port`, default: "8080", validate: isUint });
36722
+ result[m.id] = {
36723
+ location: "device",
36724
+ modelFile: modelFile.trim(),
36725
+ ctxSize: Number(ctxSize.trim()),
36726
+ port: Number(port.trim())
36727
+ };
35868
36728
  continue;
35869
36729
  }
35870
- const url2 = await dist_default5({
36730
+ const url2 = await dist_default6({
35871
36731
  message: `${m.label}: inference endpoint URL (a server you run \u2014 llama-server/vLLM/Ollama)`,
35872
36732
  default: "http://localhost:8080",
35873
36733
  validate: (v) => v.trim().length > 0 || "endpoint URL is required"
35874
36734
  });
35875
- const apiKey = await dist_default6({ message: `${m.label}: API key (optional)`, mask: "*" });
36735
+ const apiKey = await dist_default7({ message: `${m.label}: API key (optional)`, mask: "*" });
35876
36736
  const binding = { location: "network", url: url2.trim() };
35877
36737
  if (apiKey) binding.apiKey = apiKey;
35878
36738
  result[m.id] = binding;
@@ -35881,11 +36741,11 @@ async function promptModels(models, seed) {
35881
36741
  }
35882
36742
  async function promptWebSearch(seed) {
35883
36743
  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: "*" });
36744
+ const provider = (await dist_default6({ message: "Web search provider", default: "brave" })).trim() || "brave";
36745
+ const apiKey = await dist_default7({ message: "Web search API key", mask: "*" });
35886
36746
  return { provider, apiKey };
35887
36747
  }
35888
- async function promptMissing(partial2, outputDirDefault, req, workflowName) {
36748
+ async function promptMissing(partial2, outputDirDefault, req, workflowName, preloadedComponents) {
35889
36749
  const hwTodo = req.hardwareChannels.filter((ch) => !partial2.hardware?.[ch.id]);
35890
36750
  const mqttTodo = req.mqttChannels.filter((ch) => !partial2.mqtt?.[ch.id]);
35891
36751
  const modelsTodo = req.customModels.filter((m) => !partial2.models?.[m.id]);
@@ -35893,13 +36753,11 @@ async function promptMissing(partial2, outputDirDefault, req, workflowName) {
35893
36753
  const askWeb = req.hasWebSearch && !partial2.webSearch;
35894
36754
  let askOut = true;
35895
36755
  if (partial2.outputDir) {
35896
- const resolved = path8.resolve(process.cwd(), partial2.outputDir);
35897
- const nonEmpty = existsSync2(resolved) && (await fs6.readdir(resolved)).length > 0;
36756
+ const resolved = path10.resolve(process.cwd(), partial2.outputDir);
36757
+ const nonEmpty = existsSync3(resolved) && (await fs7.readdir(resolved)).length > 0;
35898
36758
  askOut = nonEmpty && !(partial2.force ?? false);
35899
36759
  }
35900
- const total = [askKeys, hwTodo.length > 0, mqttTodo.length > 0, modelsTodo.length > 0, askWeb, askOut].filter(
35901
- Boolean
35902
- ).length;
36760
+ const total = [askKeys, hwTodo.length > 0, mqttTodo.length > 0, modelsTodo.length > 0, askWeb, askOut].filter(Boolean).length + 1;
35903
36761
  let step = 0;
35904
36762
  const section = (label, count = 0) => {
35905
36763
  step += 1;
@@ -35925,7 +36783,7 @@ async function promptMissing(partial2, outputDirDefault, req, workflowName) {
35925
36783
  }
35926
36784
  for (const provider of selectedProviders) {
35927
36785
  if (llmKeys[provider]) continue;
35928
- const key = await dist_default6({ message: `${provider} API key`, mask: "*" });
36786
+ const key = await dist_default7({ message: `${provider} API key`, mask: "*" });
35929
36787
  if (key) llmKeys[provider] = key;
35930
36788
  }
35931
36789
  }
@@ -35937,6 +36795,8 @@ async function promptMissing(partial2, outputDirDefault, req, workflowName) {
35937
36795
  const models = await promptModels(req.customModels, partial2.models ?? {});
35938
36796
  if (askWeb) section("Web search");
35939
36797
  const webSearch = req.hasWebSearch ? await promptWebSearch(partial2.webSearch) : void 0;
36798
+ section("Custom components");
36799
+ const { components: customComponents, env: componentEnv } = await promptCustomComponents(preloadedComponents);
35940
36800
  if (askOut) section("Output");
35941
36801
  let outputDir;
35942
36802
  let force = partial2.force ?? false;
@@ -35944,16 +36804,16 @@ async function promptMissing(partial2, outputDirDefault, req, workflowName) {
35944
36804
  let candidate = partial2.outputDir;
35945
36805
  while (true) {
35946
36806
  if (!candidate) {
35947
- candidate = await dist_default5({ message: "Output directory", default: outputDirDefault });
36807
+ candidate = await dist_default6({ message: "Output directory", default: outputDirDefault });
35948
36808
  }
35949
- const resolved = path8.resolve(process.cwd(), candidate);
35950
- const exists = existsSync2(resolved);
35951
- const nonEmpty = exists && (await fs6.readdir(resolved)).length > 0;
36809
+ const resolved = path10.resolve(process.cwd(), candidate);
36810
+ const exists = existsSync3(resolved);
36811
+ const nonEmpty = exists && (await fs7.readdir(resolved)).length > 0;
35952
36812
  if (!nonEmpty || force) {
35953
36813
  outputDir = candidate;
35954
36814
  break;
35955
36815
  }
35956
- const action = await dist_default7({
36816
+ const action = await dist_default8({
35957
36817
  message: `${resolved} is not empty.`,
35958
36818
  choices: [
35959
36819
  { value: "overwrite", name: "overwrite the existing files" },
@@ -35970,7 +36830,7 @@ async function promptMissing(partial2, outputDirDefault, req, workflowName) {
35970
36830
  candidate = void 0;
35971
36831
  }
35972
36832
  }
35973
- return {
36833
+ const config2 = {
35974
36834
  llmKeys,
35975
36835
  outputDir,
35976
36836
  force,
@@ -35980,231 +36840,73 @@ async function promptMissing(partial2, outputDirDefault, req, workflowName) {
35980
36840
  models,
35981
36841
  webSearch
35982
36842
  };
36843
+ return { config: config2, customComponents, componentEnv };
35983
36844
  }
35984
36845
 
35985
36846
  // cli/deploy/write.ts
35986
- import { existsSync as existsSync3, promises as fs7 } from "node:fs";
35987
- import path9 from "node:path";
36847
+ import { existsSync as existsSync4, promises as fs8 } from "node:fs";
36848
+ import path11 from "node:path";
35988
36849
 
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 };
36850
+ // cli/deploy/generate.ts
36851
+ import { createHash } from "node:crypto";
36852
+ var DEFAULT_CONFIG_PATH = "/etc/foresthub/config.json";
36853
+ function configFileName(name) {
36854
+ return `${name}-config.json`;
36855
+ }
36856
+ function namedVolumeSource(mount) {
36857
+ const src = mount.split(":")[0] ?? "";
36858
+ return src && !src.startsWith(".") && !src.startsWith("/") ? src : null;
36859
+ }
36860
+ function serviceBlock(c) {
36861
+ const lines = [` ${c.name}:`, ` image: ${c.image}`, ` pull_policy: ${c.pull ?? "missing"}`, " restart: unless-stopped"];
36862
+ if (c.config !== void 0) {
36863
+ const hash2 = createHash("sha256").update(JSON.stringify(c.config)).digest("hex");
36864
+ lines.push(" labels:", ` com.foresthub.config-hash: "${hash2}"`);
36865
+ }
36866
+ if (c.user) lines.push(` user: "${c.user}"`);
36867
+ if (c.command && c.command.length > 0) {
36868
+ lines.push(" command:");
36869
+ for (const arg of c.command) lines.push(` - "${arg}"`);
36870
+ }
36871
+ lines.push(" env_file:", ` - path: ${c.name}.env`, " required: false");
36872
+ const volumes = [...c.config !== void 0 ? [`./${configFileName(c.name)}:${c.configPath ?? DEFAULT_CONFIG_PATH}:ro`] : [], ...c.volumes ?? []];
36873
+ if (volumes.length > 0) {
36874
+ lines.push(" volumes:");
36875
+ for (const v of volumes) lines.push(` - ${v}`);
36876
+ }
36877
+ if (c.devices && c.devices.length > 0) {
36878
+ lines.push(" devices:");
36879
+ for (const d of c.devices) lines.push(` - "${d}:${d}"`);
36880
+ }
36881
+ if (c.ports && c.ports.length > 0) {
36882
+ lines.push(" ports:");
36883
+ for (const p of c.ports) lines.push(` - "${p}"`);
36884
+ }
36885
+ if (c.privileged) {
36886
+ lines.push(
36887
+ " # ADC/DAC/PWM need sysfs access (/sys/class/pwm, /sys/bus/iio). privileged is",
36888
+ " # the simple default \u2014 tighten to specific bind-mounts if your policy requires.",
36889
+ " privileged: true"
36890
+ );
36103
36891
  }
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 };
36892
+ return lines.join("\n");
36111
36893
  }
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.
36894
+ function composeYaml(spec) {
36895
+ const services = spec.components.map(serviceBlock).join("\n\n");
36896
+ const named = [...new Set(spec.components.flatMap((c) => (c.volumes ?? []).map(namedVolumeSource).filter((s) => s !== null)))];
36897
+ const volumesBlock = named.length > 0 ? `
36898
+ volumes:
36899
+ ${named.map((n) => ` ${n}:`).join("\n")}
36900
+ ` : "";
36901
+ return `# Edge Agents engine \u2014 minimal standalone Compose deployment.
36190
36902
  # See README.md for the build/transfer/run workflow.
36191
- # See .env for operator-set values.
36903
+ # See the <component>.env files for operator-set values.
36192
36904
 
36193
36905
  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
- `;
36906
+ ${services}
36907
+ ${volumesBlock}`;
36206
36908
  }
36207
- function envFile(cfg) {
36909
+ function envFile(cfg, resourceSecrets = {}) {
36208
36910
  const localProviders = ALL_PROVIDERS.filter((p) => Boolean(cfg.llmKeys[p]));
36209
36911
  const providerSection = localProviders.length === 0 ? "" : `# ----- LLM provider keys -----
36210
36912
  # Each key here = that provider runs locally with your API key.
@@ -36216,48 +36918,35 @@ ENGINE_WEB_SEARCH_PROVIDER=${cfg.webSearch.provider}
36216
36918
  ENGINE_WEB_SEARCH_API_KEY=${cfg.webSearch.apiKey}
36217
36919
 
36218
36920
  ` : "";
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")}
36921
+ const resourceSecretsSection = Object.keys(resourceSecrets).length > 0 ? `# ----- External-resource secrets -----
36922
+ FH_RESOURCE_SECRETS=${JSON.stringify(resourceSecrets)}
36223
36923
 
36224
36924
  ` : "";
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.
36925
+ return `# Edge Agents engine \u2014 operator configuration.
36926
+ # Auto-generated by \`fh-workflow deploy\`. Loaded into the engine via the compose
36927
+ # \`env_file\`. Secret values: chmod 600 this file after editing.
36228
36928
 
36229
- ${providerSection}${webSearchSection}${onDeviceSection}# ----- Runtime -----
36929
+ ${providerSection}${webSearchSection}${resourceSecretsSection}# ----- Runtime -----
36230
36930
  ENGINE_LOG_LEVEL=${cfg.logLevel} # debug | info | warn | error
36231
36931
  `;
36232
36932
  }
36233
- function readme(cfg, req) {
36933
+ function readme(spec, cfg, hasProviderModel) {
36234
36934
  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") : "";
36935
+ const providerBlock = localProviders.length === 0 ? "_No local API keys set._ An Agent that uses a catalog model will fail at build \u2014 set the matching key in `engine.env`." : localProviders.map((p) => `- **${p}** \u2014 runs locally (your API key)`).join("\n");
36936
+ const engine = spec.components.find((c) => c.name === "engine");
36937
+ const hasHardware = (engine?.devices?.length ?? 0) > 0 || Boolean(engine?.privileged);
36938
+ const deviceModelFiles = Object.values(cfg.models).flatMap((b) => b.location === "device" ? [b.modelFile] : []);
36939
+ const hasNetworkModel = Object.values(cfg.models).some((b) => b.location === "network");
36940
+ const hasMqtt = Object.keys(cfg.mqtt).length > 0;
36941
+ const hasExternalService = hasMqtt || hasNetworkModel;
36253
36942
  const notes = [];
36254
- if (req.hasProviderModel || localProviders.length > 0) {
36943
+ if (hasProviderModel || localProviders.length > 0) {
36255
36944
  notes.push(`## LLM provider keys
36256
36945
 
36257
36946
  ${providerBlock}
36258
36947
 
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.`);
36948
+ The engine calls each provider directly with the API key from \`engine.env\`. Without
36949
+ a key, an Agent node that uses that provider's catalog model fails at build.`);
36261
36950
  }
36262
36951
  if (hasHardware) {
36263
36952
  notes.push(`## Hardware access
@@ -36270,9 +36959,10 @@ and tighten it to your security policy before deploying.`);
36270
36959
  if (hasExternalService) {
36271
36960
  notes.push(`## External resources
36272
36961
 
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.`);
36962
+ The broker/endpoint credentials the engine connects with live in \`engine.env\`
36963
+ (\`FH_RESOURCE_SECRETS\`) \u2014 keep it \`chmod 600\`. This bundle does not start those
36964
+ services; it assumes the broker/endpoint already exists and is reachable from the
36965
+ controller.`);
36276
36966
  }
36277
36967
  if (deviceModelFiles.length > 0) {
36278
36968
  notes.push(`## On-device models
@@ -36289,18 +36979,12 @@ ${deviceModelFiles.map((f) => `- \`./models/${f}\``).join("\n")}`);
36289
36979
  A network model points at an inference endpoint **you run yourself** (llama-server, vLLM,
36290
36980
  Ollama, ...) on another machine. This bundle does not start that server for you.`);
36291
36981
  }
36292
- if (req.hasWebSearch) {
36982
+ if (cfg.webSearch) {
36293
36983
  notes.push(`## Web search
36294
36984
 
36295
- The web-search API key is in \`.env\` (\`ENGINE_WEB_SEARCH_API_KEY\`).`);
36985
+ The web-search API key is in \`engine.env\` (\`ENGINE_WEB_SEARCH_API_KEY\`).`);
36296
36986
  }
36297
36987
  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
36988
  const modelsTransfer = deviceModelFiles.length ? `
36305
36989
 
36306
36990
  # On-device model weights \u2014 too large for the line above (GGUF can be several GB).
@@ -36310,21 +36994,22 @@ scp -r models/ $CONTROLLER_USER@$CONTROLLER_ADDR:~/fh-engine/
36310
36994
  const runBlock = deviceModelFiles.length ? `ssh $CONTROLLER_USER@$CONTROLLER_ADDR 'cd ~/fh-engine && docker load -i fh-engine.tar'
36311
36995
  ssh $CONTROLLER_USER@$CONTROLLER_ADDR 'cd ~/fh-engine && docker compose up -d'
36312
36996
 
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:
36997
+ # The engine and the llama-server sidecar start independently; the engine reaches the
36998
+ # model once it is up. These show every container, not just the engine:
36315
36999
  ssh $CONTROLLER_USER@$CONTROLLER_ADDR 'cd ~/fh-engine && docker compose ps'
36316
37000
  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
37001
  ssh $CONTROLLER_USER@$CONTROLLER_ADDR 'cd ~/fh-engine && docker compose up -d'
36318
37002
  ssh $CONTROLLER_USER@$CONTROLLER_ADDR 'cd ~/fh-engine && docker compose logs -f engine'`;
36319
- return `# ForestHub engine \u2014 deployment bundle
37003
+ return `# Edge Agents engine \u2014 deployment bundle
36320
37004
 
36321
37005
  Generated by \`fh-workflow deploy\`. This directory contains everything needed to
36322
37006
  run one engine instance on an edge controller, **standalone** \u2014 the engine boots
36323
- the workflow from \`workflow.json\` and runs it autonomously:
37007
+ the workflow from \`engine-config.json\` and runs it autonomously:
36324
37008
 
36325
37009
  - \`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)
37010
+ - \`engine-config.json\` \u2014 the engine's single boot config (workflow + bindings + device manifest)
37011
+ - \`deployment-spec.json\` \u2014 the full resolved deployment spec (deployment record)
37012
+ - \`engine.env\` \u2014 operator configuration loaded into the engine (already filled in, \`chmod 600\` it)
36328
37013
  - \`fh-engine.tar\` \u2014 image tarball (you build this in step 1 below)
36329
37014
  ${notesBlock}
36330
37015
  ## 1. Build the image (on the dev machine)
@@ -36341,10 +37026,10 @@ docker buildx build --platform linux/arm64 -t fh-engine:latest --load .
36341
37026
  docker buildx build --platform linux/amd64 -t fh-engine:latest --load .
36342
37027
  \`\`\`
36343
37028
 
36344
- ## 2. Review the generated \`.env\`
37029
+ ## 2. Review the generated \`engine.env\`
36345
37030
 
36346
37031
  \`\`\`bash
36347
- chmod 600 .env
37032
+ chmod 600 engine.env
36348
37033
  \`\`\`
36349
37034
 
36350
37035
  The wizard already filled in your operator values. Double-check before
@@ -36362,7 +37047,7 @@ docker save fh-engine:latest -o ../path/to/this/bundle/fh-engine.tar
36362
37047
  ssh $CONTROLLER_USER@$CONTROLLER_ADDR 'mkdir -p ~/fh-engine'
36363
37048
 
36364
37049
  cd path/to/this/bundle
36365
- scp fh-engine.tar docker-compose.yml workflow.json${transferExtraStr} .env \\
37050
+ scp fh-engine.tar docker-compose.yml engine-config.json deployment-spec.json engine.env \\
36366
37051
  $CONTROLLER_USER@$CONTROLLER_ADDR:~/fh-engine/${modelsTransfer}
36367
37052
  \`\`\`
36368
37053
 
@@ -36380,67 +37065,56 @@ only \`docker compose down -v\` deletes them. The volume is namespaced by the
36380
37065
  compose project name, which defaults to the bundle directory's name \u2014 keep it
36381
37066
  stable across updates, or pin it via \`COMPOSE_PROJECT_NAME\`. In this standalone
36382
37067
  bundle the volume is the only copy of the memory.
36383
-
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:
36387
-
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
- \`\`\`
36392
37068
  `;
36393
37069
  }
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
37070
  function slugify2(name) {
36399
37071
  return name.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
36400
37072
  }
36401
37073
 
36402
37074
  // 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);
37075
+ async function writeOutput(spec, resourceSecrets, cfg, req, componentEnv = {}) {
37076
+ const dir = path11.resolve(process.cwd(), cfg.outputDir);
37077
+ if (existsSync4(dir)) {
37078
+ const contents = await fs8.readdir(dir);
36408
37079
  if (contents.length > 0) {
36409
37080
  if (!cfg.force) {
36410
37081
  process.stderr.write(`output dir not empty: ${dir} (use --force to overwrite)
36411
37082
  `);
36412
37083
  process.exit(1);
36413
37084
  }
36414
- await fs7.rm(dir, { recursive: true, force: true });
36415
- await fs7.mkdir(dir, { recursive: true });
37085
+ await fs8.rm(dir, { recursive: true, force: true });
37086
+ await fs8.mkdir(dir, { recursive: true });
36416
37087
  }
36417
37088
  } else {
36418
- await fs7.mkdir(dir, { recursive: true });
37089
+ await fs8.mkdir(dir, { recursive: true });
36419
37090
  }
36420
37091
  const written = [];
36421
37092
  const emit = async (name, content, secret = false) => {
36422
- const out = path9.join(dir, name);
37093
+ const out = path11.join(dir, name);
36423
37094
  const opts = secret ? { encoding: "utf-8", mode: 384 } : { encoding: "utf-8" };
36424
- await fs7.writeFile(out, content, opts);
37095
+ await fs8.writeFile(out, content, opts);
36425
37096
  written.push(out);
36426
37097
  };
36427
37098
  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));
37099
+ for (const c of spec.components) {
37100
+ if (c.config !== void 0) await emit(configFileName(c.name), json2(c.config));
37101
+ }
37102
+ await emit("deployment-spec.json", json2(spec));
37103
+ await emit("docker-compose.yml", composeYaml(spec));
37104
+ await emit("engine.env", envFile(cfg, resourceSecrets), true);
37105
+ await emit("README.md", readme(spec, cfg, req.hasProviderModel));
37106
+ for (const [name, text] of Object.entries(componentEnv)) {
37107
+ await emit(`${name}.env`, text, true);
37108
+ }
36437
37109
  if (Object.values(cfg.models).some((b) => b.location === "device")) {
36438
- await fs7.mkdir(path9.join(dir, "models"), { recursive: true });
37110
+ await fs8.mkdir(path11.join(dir, "models"), { recursive: true });
36439
37111
  }
36440
37112
  return written;
36441
37113
  }
36442
37114
 
36443
37115
  // cli/deploy/index.ts
37116
+ var ENGINE_IMAGE = "fh-engine:latest";
37117
+ var LLAMA_SERVER_IMAGE = "ghcr.io/ggml-org/llama.cpp:server-b8589";
36444
37118
  var USAGE = `Usage: fh-workflow deploy <workflow.json> [flags]
36445
37119
 
36446
37120
  Generates a self-contained, standalone Engine deployment bundle (workflow.json,
@@ -36451,6 +37125,9 @@ autonomously. Missing values are filled in interactively, or supplied via
36451
37125
  LLM provider keys (set one for each catalog model an Agent uses):
36452
37126
  ${ALL_PROVIDERS.map((p) => ` --${p}-key KEY`).join("\n")}
36453
37127
 
37128
+ Custom components (extra containers to run alongside the engine):
37129
+ --component DIR folder with a component.json (repeatable)
37130
+
36454
37131
  Output:
36455
37132
  --output DIR default: ./<workflow-name>-bundle
36456
37133
  --force overwrite existing DIR
@@ -36472,6 +37149,7 @@ function parseFlags(args2) {
36472
37149
  output: { type: "string" },
36473
37150
  "log-level": { type: "string" },
36474
37151
  values: { type: "string" },
37152
+ component: { type: "string", multiple: true },
36475
37153
  force: { type: "boolean", default: false },
36476
37154
  help: { type: "boolean", short: "h", default: false }
36477
37155
  },
@@ -36488,6 +37166,7 @@ function parseFlags(args2) {
36488
37166
  output: values.output,
36489
37167
  logLevel: values["log-level"],
36490
37168
  values: values.values,
37169
+ component: values.component ?? [],
36491
37170
  force: values.force ?? false,
36492
37171
  help: values.help ?? false
36493
37172
  };
@@ -36513,10 +37192,10 @@ function partialFromFlags(flags, fileValues) {
36513
37192
  };
36514
37193
  }
36515
37194
  async function loadValues(source) {
36516
- const abs = path10.resolve(process.cwd(), source);
37195
+ const abs = path12.resolve(process.cwd(), source);
36517
37196
  let raw;
36518
37197
  try {
36519
- raw = await fs8.readFile(abs, "utf-8");
37198
+ raw = await fs9.readFile(abs, "utf-8");
36520
37199
  } catch (err) {
36521
37200
  if (err.code === "ENOENT") {
36522
37201
  process.stderr.write(`Values file not found: ${abs}
@@ -36589,15 +37268,41 @@ function configFromPartial(p, outputDirDefault) {
36589
37268
  webSearch: p.webSearch
36590
37269
  };
36591
37270
  }
37271
+ async function loadFlagComponents(dirs) {
37272
+ const uniqueDirs = [...new Set(dirs.map((d) => path12.resolve(process.cwd(), d)))];
37273
+ const entries = await Promise.all(
37274
+ uniqueDirs.map(async (dir) => ({ source: path12.join(dir, "component.json"), data: await readComponentJson(dir), dir }))
37275
+ );
37276
+ parseDeployComponents(entries);
37277
+ const seen = /* @__PURE__ */ new Set();
37278
+ const loaded = [];
37279
+ for (const e of entries) {
37280
+ const component = e.data;
37281
+ if (seen.has(component.name)) {
37282
+ throw new Error(`duplicate component name "${component.name}" (two --component folders declare it)`);
37283
+ }
37284
+ seen.add(component.name);
37285
+ loaded.push({ component, dir: e.dir });
37286
+ }
37287
+ return loaded;
37288
+ }
37289
+ async function resolveComponentsEnv(loaded) {
37290
+ const env = {};
37291
+ for (const { component, dir } of loaded) {
37292
+ const text = await resolveComponentEnv(dir, component.name, { interactive: false });
37293
+ if (text !== null) env[component.name] = text;
37294
+ }
37295
+ return env;
37296
+ }
36592
37297
  async function deployCommand(workflowPath, args2) {
36593
37298
  if (!workflowPath || workflowPath === "--help" || workflowPath === "-h") {
36594
37299
  process.stdout.write(USAGE);
36595
37300
  process.exit(workflowPath ? 0 : 1);
36596
37301
  }
36597
- const abs = path10.resolve(process.cwd(), workflowPath);
37302
+ const abs = path12.resolve(process.cwd(), workflowPath);
36598
37303
  let raw;
36599
37304
  try {
36600
- raw = await fs8.readFile(abs, "utf-8");
37305
+ raw = await fs9.readFile(abs, "utf-8");
36601
37306
  } catch (err) {
36602
37307
  const code = err.code;
36603
37308
  if (code === "ENOENT") {
@@ -36628,20 +37333,44 @@ async function deployCommand(workflowPath, args2) {
36628
37333
  process.stdout.write(USAGE);
36629
37334
  process.exit(0);
36630
37335
  }
36631
- const req = inspect(workflow);
37336
+ let domain2;
37337
+ try {
37338
+ domain2 = deserialize7(workflow);
37339
+ } catch (err) {
37340
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}
37341
+ `);
37342
+ process.exit(1);
37343
+ }
37344
+ const req = deriveRequirements(domain2);
36632
37345
  if (req.hasRetriever) {
36633
37346
  process.stderr.write(
36634
37347
  "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
37348
  );
36636
37349
  process.exit(1);
36637
37350
  }
36638
- const workflowName = path10.basename(abs, path10.extname(abs));
37351
+ const workflowName = path12.basename(abs, path12.extname(abs));
36639
37352
  const outputDirDefault = `./${slugify2(workflowName)}-bundle`;
36640
37353
  const fileValues = flags.values ? await loadValues(flags.values) : {};
36641
37354
  const partial2 = partialFromFlags(flags, fileValues);
37355
+ let preloaded;
37356
+ try {
37357
+ preloaded = await loadFlagComponents(flags.component);
37358
+ } catch (err) {
37359
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}
37360
+ `);
37361
+ process.exit(1);
37362
+ }
36642
37363
  let cfg;
37364
+ let customComponents;
37365
+ let componentEnv;
36643
37366
  if (process.stdin.isTTY) {
36644
- cfg = await promptMissing(partial2, outputDirDefault, req, workflowName);
37367
+ ({ config: cfg, customComponents, componentEnv } = await promptMissing(
37368
+ partial2,
37369
+ outputDirDefault,
37370
+ req,
37371
+ workflowName,
37372
+ preloaded
37373
+ ));
36645
37374
  } else {
36646
37375
  const missing = missingRequired(req, partial2);
36647
37376
  if (missing.length > 0) {
@@ -36652,21 +37381,41 @@ async function deployCommand(workflowPath, args2) {
36652
37381
  process.exit(1);
36653
37382
  }
36654
37383
  cfg = configFromPartial(partial2, outputDirDefault);
37384
+ customComponents = preloaded.map((c) => c.component);
37385
+ componentEnv = await resolveComponentsEnv(preloaded);
37386
+ }
37387
+ let built;
37388
+ try {
37389
+ built = buildDeploymentSpec(
37390
+ domain2,
37391
+ cfg,
37392
+ {
37393
+ id: slugify2(workflowName),
37394
+ status: "active",
37395
+ engineImage: ENGINE_IMAGE,
37396
+ llamaServerImage: LLAMA_SERVER_IMAGE
37397
+ },
37398
+ customComponents
37399
+ );
37400
+ } catch (err) {
37401
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}
37402
+ `);
37403
+ process.exit(1);
36655
37404
  }
36656
37405
  let files;
36657
37406
  try {
36658
- files = await writeOutput(workflow, cfg, req);
37407
+ files = await writeOutput(built.spec, built.resourceSecrets, cfg, req, componentEnv);
36659
37408
  } catch (err) {
36660
37409
  process.stderr.write(`${err instanceof Error ? err.message : String(err)}
36661
37410
  `);
36662
37411
  process.exit(1);
36663
37412
  }
36664
- const absOut = path10.resolve(process.cwd(), cfg.outputDir);
37413
+ const absOut = path12.resolve(process.cwd(), cfg.outputDir);
36665
37414
  process.stdout.write(`
36666
37415
  \u2713 Deployment bundle written to ${absOut}
36667
37416
  `);
36668
37417
  for (const f of files) {
36669
- process.stdout.write(` - ${path10.relative(process.cwd(), f)}
37418
+ process.stdout.write(` - ${path12.relative(process.cwd(), f)}
36670
37419
  `);
36671
37420
  }
36672
37421
  process.stdout.write(`