dsh-plugin-local-infer 0.1.2 → 0.1.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.
Files changed (251) hide show
  1. package/README.md +32 -6
  2. package/cordis.patch.yml +7 -0
  3. package/docs/zh/README.md +24 -4
  4. package/dsh/client.js +4200 -4049
  5. package/package.json +33 -11
  6. package/packages/core/lib/adapter.d.ts +177 -11
  7. package/packages/core/lib/adapter.js +74 -1
  8. package/packages/core/lib/config.d.ts +1 -1
  9. package/packages/core/lib/config.js +1 -1
  10. package/packages/core/lib/debug.d.ts +1 -1
  11. package/packages/core/lib/debug.js +1 -1
  12. package/packages/core/lib/defaults.d.ts +1 -1
  13. package/packages/core/lib/defaults.js +1 -1
  14. package/packages/core/lib/deps.d.ts +48 -20
  15. package/packages/core/lib/deps.js +24 -8
  16. package/packages/core/lib/estimator.d.ts +1 -1
  17. package/packages/core/lib/estimator.js +1 -1
  18. package/packages/core/lib/guideline.d.ts +1 -1
  19. package/packages/core/lib/guideline.js +1 -1
  20. package/packages/core/lib/hardware.d.ts +1 -1
  21. package/packages/core/lib/hardware.js +1 -1
  22. package/packages/core/lib/hf-cache.d.ts +70 -0
  23. package/packages/core/lib/hf-cache.js +83 -0
  24. package/packages/core/lib/index.d.ts +16 -9
  25. package/packages/core/lib/index.js +10 -6
  26. package/packages/core/lib/infer-cache.d.ts +39 -0
  27. package/packages/core/lib/infer-cache.js +53 -0
  28. package/packages/core/lib/launch-command.d.ts +8 -4
  29. package/packages/core/lib/launch-command.js +13 -6
  30. package/packages/core/lib/launch-record.d.ts +1 -1
  31. package/packages/core/lib/launch-record.js +1 -1
  32. package/packages/core/lib/logs.d.ts +8 -1
  33. package/packages/core/lib/logs.js +12 -4
  34. package/packages/core/lib/models.d.ts +120 -14
  35. package/packages/core/lib/models.js +209 -17
  36. package/packages/core/lib/parameter.d.ts +31 -3
  37. package/packages/core/lib/parameter.js +1 -1
  38. package/packages/core/lib/progress.d.ts +54 -0
  39. package/packages/core/lib/progress.js +120 -0
  40. package/packages/core/lib/registrar.d.ts +25 -14
  41. package/packages/core/lib/registrar.js +30 -10
  42. package/packages/core/lib/reserve.d.ts +11 -3
  43. package/packages/core/lib/reserve.js +6 -4
  44. package/packages/core/lib/status.d.ts +12 -6
  45. package/packages/core/lib/status.js +21 -10
  46. package/packages/core/lib/sudo.d.ts +80 -0
  47. package/packages/core/lib/sudo.js +78 -0
  48. package/packages/core/package.json +1 -1
  49. package/packages/orchestrator/lib/config.d.ts +63 -0
  50. package/packages/orchestrator/lib/config.js +111 -0
  51. package/packages/orchestrator/lib/index.d.ts +51 -0
  52. package/packages/orchestrator/lib/index.js +73 -0
  53. package/packages/orchestrator/lib/route.d.ts +60 -0
  54. package/packages/orchestrator/lib/route.js +159 -0
  55. package/packages/orchestrator/locale/en.json +6 -0
  56. package/packages/orchestrator/package.json +39 -0
  57. package/packages/orchestrator/prompt.md +87 -0
  58. package/packages/providers/lib/acquire.d.ts +95 -15
  59. package/packages/providers/lib/acquire.js +239 -40
  60. package/packages/providers/lib/adapters.d.ts +155 -221
  61. package/packages/providers/lib/adapters.js +589 -935
  62. package/packages/providers/lib/debug.d.ts +1 -1
  63. package/packages/providers/lib/debug.js +1 -1
  64. package/packages/providers/lib/deps.d.ts +31 -212
  65. package/packages/providers/lib/deps.js +71 -205
  66. package/packages/providers/lib/engines/basalt/basalt.d.ts +130 -0
  67. package/packages/providers/lib/engines/basalt/basalt.js +304 -0
  68. package/packages/providers/lib/engines/basalt/basaltAdapter.d.ts +85 -0
  69. package/packages/providers/lib/engines/basalt/basaltAdapter.js +439 -0
  70. package/packages/providers/lib/engines/basalt/basaltLinux.d.ts +23 -0
  71. package/packages/providers/lib/engines/basalt/basaltLinux.js +47 -0
  72. package/packages/providers/lib/engines/basalt/basaltMacOS.d.ts +17 -0
  73. package/packages/providers/lib/engines/basalt/basaltMacOS.js +19 -0
  74. package/packages/providers/lib/engines/basalt/basaltWindows.d.ts +24 -0
  75. package/packages/providers/lib/engines/basalt/basaltWindows.js +49 -0
  76. package/packages/providers/lib/engines/engineOs.d.ts +24 -0
  77. package/packages/providers/lib/engines/engineOs.js +17 -0
  78. package/packages/providers/lib/{hyperqwen.d.ts → engines/hyperqwen/hyperqwen.d.ts} +2 -2
  79. package/packages/providers/lib/{hyperqwen.js → engines/hyperqwen/hyperqwen.js} +14 -2
  80. package/packages/providers/lib/engines/hyperqwen/hyperqwenAdapter.d.ts +38 -0
  81. package/packages/providers/lib/engines/hyperqwen/hyperqwenAdapter.js +115 -0
  82. package/packages/providers/lib/engines/hyperqwen/hyperqwenArch.d.ts +9 -0
  83. package/packages/providers/lib/engines/hyperqwen/hyperqwenArch.js +9 -0
  84. package/packages/providers/lib/engines/hyperqwen/hyperqwenDebian.d.ts +9 -0
  85. package/packages/providers/lib/engines/hyperqwen/hyperqwenDebian.js +9 -0
  86. package/packages/providers/lib/engines/hyperqwen/hyperqwenFedora.d.ts +9 -0
  87. package/packages/providers/lib/engines/hyperqwen/hyperqwenFedora.js +9 -0
  88. package/packages/providers/lib/engines/hyperqwen/hyperqwenLinux.d.ts +10 -0
  89. package/packages/providers/lib/engines/hyperqwen/hyperqwenLinux.js +14 -0
  90. package/packages/providers/lib/engines/hyperqwen/hyperqwenMacOS.d.ts +9 -0
  91. package/packages/providers/lib/engines/hyperqwen/hyperqwenMacOS.js +9 -0
  92. package/packages/providers/lib/engines/hyperqwen/hyperqwenWindows.d.ts +9 -0
  93. package/packages/providers/lib/engines/hyperqwen/hyperqwenWindows.js +9 -0
  94. package/packages/providers/lib/{infernix.d.ts → engines/infernix/infernix.d.ts} +7 -4
  95. package/packages/providers/lib/{infernix.js → engines/infernix/infernix.js} +53 -8
  96. package/packages/providers/lib/engines/infernix/infernixAdapter.d.ts +52 -0
  97. package/packages/providers/lib/engines/infernix/infernixAdapter.js +235 -0
  98. package/packages/providers/lib/engines/infernix/infernixArch.d.ts +9 -0
  99. package/packages/providers/lib/engines/infernix/infernixArch.js +9 -0
  100. package/packages/providers/lib/engines/infernix/infernixDebian.d.ts +9 -0
  101. package/packages/providers/lib/engines/infernix/infernixDebian.js +9 -0
  102. package/packages/providers/lib/engines/infernix/infernixFedora.d.ts +9 -0
  103. package/packages/providers/lib/engines/infernix/infernixFedora.js +9 -0
  104. package/packages/providers/lib/engines/infernix/infernixLinux.d.ts +17 -0
  105. package/packages/providers/lib/engines/infernix/infernixLinux.js +35 -0
  106. package/packages/providers/lib/engines/infernix/infernixMacOS.d.ts +16 -0
  107. package/packages/providers/lib/engines/infernix/infernixMacOS.js +18 -0
  108. package/packages/providers/lib/engines/infernix/infernixWindows.d.ts +16 -0
  109. package/packages/providers/lib/engines/infernix/infernixWindows.js +34 -0
  110. package/packages/providers/lib/{llamacpp.d.ts → engines/llamacpp/llamacpp.d.ts} +2 -2
  111. package/packages/providers/lib/{llamacpp.js → engines/llamacpp/llamacpp.js} +12 -2
  112. package/packages/providers/lib/engines/llamacpp/llamacppAdapter.d.ts +50 -0
  113. package/packages/providers/lib/engines/llamacpp/llamacppAdapter.js +274 -0
  114. package/packages/providers/lib/engines/llamacpp/llamacppArch.d.ts +9 -0
  115. package/packages/providers/lib/engines/llamacpp/llamacppArch.js +9 -0
  116. package/packages/providers/lib/engines/llamacpp/llamacppDebian.d.ts +9 -0
  117. package/packages/providers/lib/engines/llamacpp/llamacppDebian.js +9 -0
  118. package/packages/providers/lib/engines/llamacpp/llamacppFedora.d.ts +9 -0
  119. package/packages/providers/lib/engines/llamacpp/llamacppFedora.js +9 -0
  120. package/packages/providers/lib/engines/llamacpp/llamacppLinux.d.ts +17 -0
  121. package/packages/providers/lib/engines/llamacpp/llamacppLinux.js +28 -0
  122. package/packages/providers/lib/engines/llamacpp/llamacppMacOS.d.ts +15 -0
  123. package/packages/providers/lib/engines/llamacpp/llamacppMacOS.js +17 -0
  124. package/packages/providers/lib/engines/llamacpp/llamacppWindows.d.ts +15 -0
  125. package/packages/providers/lib/engines/llamacpp/llamacppWindows.js +26 -0
  126. package/packages/providers/lib/{ninfer.d.ts → engines/ninfer/ninfer.d.ts} +2 -2
  127. package/packages/providers/lib/{ninfer.js → engines/ninfer/ninfer.js} +25 -3
  128. package/packages/providers/lib/engines/ninfer/ninferAdapter.d.ts +81 -0
  129. package/packages/providers/lib/engines/ninfer/ninferAdapter.js +392 -0
  130. package/packages/providers/lib/engines/ninfer/ninferArch.d.ts +9 -0
  131. package/packages/providers/lib/engines/ninfer/ninferArch.js +9 -0
  132. package/packages/providers/lib/engines/ninfer/ninferDebian.d.ts +9 -0
  133. package/packages/providers/lib/engines/ninfer/ninferDebian.js +9 -0
  134. package/packages/providers/lib/engines/ninfer/ninferFedora.d.ts +9 -0
  135. package/packages/providers/lib/engines/ninfer/ninferFedora.js +9 -0
  136. package/packages/providers/lib/engines/ninfer/ninferLinux.d.ts +17 -0
  137. package/packages/providers/lib/engines/ninfer/ninferLinux.js +37 -0
  138. package/packages/providers/lib/engines/ninfer/ninferMacOS.d.ts +15 -0
  139. package/packages/providers/lib/engines/ninfer/ninferMacOS.js +17 -0
  140. package/packages/providers/lib/engines/ninfer/ninferWindows.d.ts +29 -0
  141. package/packages/providers/lib/engines/ninfer/ninferWindows.js +59 -0
  142. package/packages/providers/lib/engines/strata/strata.d.ts +1 -0
  143. package/packages/providers/lib/engines/strata/strata.js +264 -0
  144. package/packages/providers/lib/engines/strata/strataAdapter.d.ts +97 -0
  145. package/packages/providers/lib/engines/strata/strataAdapter.js +463 -0
  146. package/packages/providers/lib/engines/strata/strataArch.d.ts +9 -0
  147. package/packages/providers/lib/engines/strata/strataArch.js +9 -0
  148. package/packages/providers/lib/engines/strata/strataDebian.d.ts +9 -0
  149. package/packages/providers/lib/engines/strata/strataDebian.js +9 -0
  150. package/packages/providers/lib/engines/strata/strataFedora.d.ts +9 -0
  151. package/packages/providers/lib/engines/strata/strataFedora.js +9 -0
  152. package/packages/providers/lib/engines/strata/strataLinux.d.ts +21 -0
  153. package/packages/providers/lib/engines/strata/strataLinux.js +30 -0
  154. package/packages/providers/lib/engines/strata/strataMacOS.d.ts +16 -0
  155. package/packages/providers/lib/engines/strata/strataMacOS.js +17 -0
  156. package/packages/providers/lib/engines/strata/strataWindows.d.ts +27 -0
  157. package/packages/providers/lib/engines/strata/strataWindows.js +37 -0
  158. package/packages/providers/lib/engines.d.ts +8 -5
  159. package/packages/providers/lib/engines.js +22 -11
  160. package/packages/providers/lib/guidelines.d.ts +5 -4
  161. package/packages/providers/lib/guidelines.js +18 -5
  162. package/packages/providers/lib/index.d.ts +33 -24
  163. package/packages/providers/lib/index.js +46 -24
  164. package/packages/providers/lib/model-defaults.d.ts +10 -5
  165. package/packages/providers/lib/model-defaults.js +47 -5
  166. package/packages/providers/lib/platform.d.ts +8 -5
  167. package/packages/providers/lib/platform.js +27 -7
  168. package/packages/providers/lib/presets.d.ts +6 -4
  169. package/packages/providers/lib/presets.js +53 -5
  170. package/packages/providers/lib/systemDeps.d.ts +313 -0
  171. package/packages/providers/lib/systemDeps.js +284 -0
  172. package/packages/providers/lib/vram.d.ts +34 -8
  173. package/packages/providers/lib/vram.js +81 -8
  174. package/packages/providers/package.json +2 -2
  175. package/packages/service/lib/index.d.ts +187 -23
  176. package/packages/service/lib/index.js +667 -90
  177. package/packages/service/lib/launch-target.d.ts +19 -0
  178. package/packages/service/lib/launch-target.js +28 -0
  179. package/packages/service/lib/run-logs.d.ts +72 -0
  180. package/packages/service/lib/run-logs.js +125 -0
  181. package/packages/service/lib/types.d.ts +166 -8
  182. package/packages/service/lib/types.js +1 -1
  183. package/packages/service/package.json +2 -2
  184. package/packages/tool/lib/index.d.ts +1 -1
  185. package/packages/tool/lib/index.js +1 -1
  186. package/packages/tool/package.json +2 -2
  187. package/packages/ui/lib/config.d.ts +89 -0
  188. package/packages/ui/lib/config.js +251 -0
  189. package/packages/ui/lib/deps.d.ts +4 -4
  190. package/packages/ui/lib/deps.js +1 -1
  191. package/packages/ui/lib/form.d.ts +1 -1
  192. package/packages/ui/lib/form.js +1 -1
  193. package/packages/ui/lib/index.d.ts +7 -1
  194. package/packages/ui/lib/index.js +12 -2
  195. package/packages/ui/lib/meter.d.ts +1 -1
  196. package/packages/ui/lib/meter.js +1 -1
  197. package/packages/ui/lib/ops/engine.d.ts +216 -0
  198. package/packages/ui/lib/ops/engine.js +697 -0
  199. package/packages/ui/lib/ops/get-steps.d.ts +98 -0
  200. package/packages/ui/lib/ops/get-steps.js +359 -0
  201. package/packages/ui/lib/ops/hf.d.ts +42 -0
  202. package/packages/ui/lib/ops/hf.js +150 -0
  203. package/packages/ui/lib/ops/logs.d.ts +78 -0
  204. package/packages/ui/lib/ops/logs.js +119 -0
  205. package/packages/ui/lib/ops/model.d.ts +71 -0
  206. package/packages/ui/lib/ops/model.js +123 -0
  207. package/packages/ui/lib/request.d.ts +106 -0
  208. package/packages/ui/lib/request.js +80 -0
  209. package/packages/ui/lib/route.d.ts +106 -422
  210. package/packages/ui/lib/route.js +136 -1845
  211. package/packages/ui/lib/routes/chat-template.d.ts +1 -0
  212. package/packages/ui/lib/routes/chat-template.js +1 -0
  213. package/packages/ui/lib/routes/deps.d.ts +1 -0
  214. package/packages/ui/lib/routes/deps.js +1 -0
  215. package/packages/ui/lib/routes/download.d.ts +1 -0
  216. package/packages/ui/lib/routes/download.js +1 -0
  217. package/packages/ui/lib/routes/get.d.ts +1 -0
  218. package/packages/ui/lib/routes/get.js +106 -0
  219. package/packages/ui/lib/routes/install.d.ts +16 -0
  220. package/packages/ui/lib/routes/install.js +36 -0
  221. package/packages/ui/lib/routes/launch.d.ts +24 -0
  222. package/packages/ui/lib/routes/launch.js +74 -0
  223. package/packages/ui/lib/routes/logs.d.ts +57 -0
  224. package/packages/ui/lib/routes/logs.js +117 -0
  225. package/packages/ui/lib/routes/provider-delete.d.ts +15 -0
  226. package/packages/ui/lib/routes/provider-delete.js +37 -0
  227. package/packages/ui/lib/routes/register.d.ts +16 -0
  228. package/packages/ui/lib/routes/register.js +28 -0
  229. package/packages/ui/lib/routes/save.d.ts +18 -0
  230. package/packages/ui/lib/routes/save.js +159 -0
  231. package/packages/ui/lib/routes/stop.d.ts +19 -0
  232. package/packages/ui/lib/routes/stop.js +32 -0
  233. package/packages/ui/lib/routes/stream.d.ts +40 -0
  234. package/packages/ui/lib/routes/stream.js +68 -0
  235. package/packages/ui/lib/routes/summary.d.ts +15 -0
  236. package/packages/ui/lib/routes/summary.js +26 -0
  237. package/packages/ui/lib/routes/unregister.d.ts +14 -0
  238. package/packages/ui/lib/routes/unregister.js +26 -0
  239. package/packages/ui/lib/viewmodels/deps.d.ts +1 -0
  240. package/packages/ui/lib/viewmodels/deps.js +1 -0
  241. package/packages/ui/lib/viewmodels/depsHelpers.d.ts +1 -0
  242. package/packages/ui/lib/viewmodels/depsHelpers.js +94 -0
  243. package/packages/ui/lib/viewmodels/summary.d.ts +145 -0
  244. package/packages/ui/lib/viewmodels/summary.js +245 -0
  245. package/packages/ui/lib/viewmodels/summaryHelpers.d.ts +30 -0
  246. package/packages/ui/lib/viewmodels/summaryHelpers.js +42 -0
  247. package/packages/ui/lib/warnings.d.ts +1 -1
  248. package/packages/ui/lib/warnings.js +1 -1
  249. package/packages/ui/package.json +4 -4
  250. package/packages/providers/lib/strata.d.ts +0 -15
  251. package/packages/providers/lib/strata.js +0 -14
@@ -1,72 +1,53 @@
1
1
  /**
2
- * The concrete engine adapters: NInfer (docker and local) and Hyper-Qwen
3
- * (compose). Each composes the provider `ParameterSpec` data into a
4
- * `LaunchPlan` and drives its deploy, health-check, and stop steps through an
5
- * injectable `LifecycleHost`, so the tests stub the host and never run a real
6
- * build, docker, or network in CI. The node implementation
7
- * (`nodeLifecycleHost`) is what the service wires in on a live host.
2
+ * The shared engine-adapter helpers: the spec-to-argv and spec-to-env
3
+ * translators the concrete adapters (the `engines/<engine>/<engine>Adapter.ts`
4
+ * files) compose
5
+ * into a `LaunchPlan`, the card's Custom Arguments translators, the
6
+ * `LifecycleHost` the deploy, health-check, and stop steps drive, and the
7
+ * health poll. The node implementation (`nodeLifecycleHost`) is what the
8
+ * service wires in on a live host.
8
9
  * @module dsh-plugin-local-infer/providers/adapters
9
10
  */
10
- import { exec, spawn as nodeSpawn } from 'node:child_process';
11
- import { basename, dirname, join } from 'node:path';
12
- import { EngineAdapter, formatLaunchCommand } from '../../core/lib/index.js';
13
- import { syncAndRebuildEngine } from "./acquire.js";
14
- import { NINFER_PARAMETERS } from "./ninfer.js";
15
- import { HYPERQWEN_PARAMETERS } from "./hyperqwen.js";
16
- import { LLAMACPP_MODEL_ARTIFACTS, LLAMACPP_PARAMETERS, llamaCppModelKey, } from "./llamacpp.js";
17
- import { INFERNIX_MODEL_ARTIFACTS, INFERNIX_PARAMETERS, infernixArtifactFor, infernixDraftModelPath, infernixModelKey, } from "./infernix.js";
18
- export { INFERNIX_MODEL_ARTIFACTS } from "./infernix.js";
19
- import { STRATA_PARAMETERS } from "./strata.js";
11
+ import { exec, execFile, spawn as nodeSpawn } from 'node:child_process';
12
+ import { mkdir, writeFile as fsWriteFile } from 'node:fs/promises';
13
+ import { createRequire } from 'node:module';
14
+ import { dirname, isAbsolute, join } from 'node:path';
15
+ import { commandLogLine, guardSudoCommand, LOG_TAIL_BYTES, SUDO_PROBE_COMMAND, sudoFailureDetail, urlLogLine, } from '../../core/lib/index.js';
20
16
  /**
21
- * The NInfer model artifacts, keyed by the model registry id. The 27B entry
22
- * is the default the DEFAULT_MODELS list names; the 35B-A3B entries are the
23
- * Qwen3.8 measured artifact (`qwen3-35b`) and the Qwen3.6 default
24
- * (`qwen36-35b`).
17
+ * deployTargets normalizes a spec's `deploy` declaration into a target list: a
18
+ * single target wraps in a one-element list, a list passes through, and an
19
+ * absent `deploy` reads as an empty list (the un-migrated fallback). The
20
+ * spec-to-spelling helpers key off this list, so a multi-target spec
21
+ * (cli + env, the Strata shared specs) contributes to every matching helper.
25
22
  */
26
- export const NINFER_MODEL_ARTIFACTS = {
27
- 'qwen3-27b': {
28
- file: 'qwen3_8_27b_nvfp4.ninfer',
29
- repo: 'neroued/Qwen3.8-27B-nvfp4-NInfer',
30
- modelId: 'Qwen3.8-27B',
31
- },
32
- // The Swift 1.5 ablated 27B the local launcher bootstraps: the nvfp4-full
33
- // DFlash2 artifact the qwen-ninfer script serves. It reuses the 27B model id
34
- // the engine advertises, so a chat turn routes to the same alias.
35
- 'qwen3-27b-swift15': {
36
- file: 'qwen3_8_27b_swift15abl_nvfp4full-dflash2.ninfer',
37
- repo: 'kaushikvira/Qwen3.8-27B-swift15-uncensored-ajgazin-nvfp4full-dflash2-NInfer-v3',
38
- modelId: 'Qwen3.8-27B',
39
- },
40
- 'qwen3-35b': {
41
- file: 'qwen3_8_35b_a3b.ninfer',
42
- repo: 'Neroued/Qwen3.8-35B-A3B-NInfer',
43
- modelId: 'Qwen3.8-35B-A3B',
44
- },
45
- 'qwen36-35b': {
46
- file: 'qwen3_6_35b_a3b.ninfer',
47
- repo: 'neroued/Qwen3.6-35B-A3B-NInfer',
48
- modelId: 'Qwen3.6-35B-A3B',
49
- },
50
- };
51
- /**
52
- * artifactFor resolves the artifact a model id deploys. An id no entry names
53
- * falls back to the 27B artifact, so a bare config always renders a plan.
54
- */
55
- function artifactFor(modelId) {
56
- const artifact = modelId !== undefined ? NINFER_MODEL_ARTIFACTS[modelId] : undefined;
57
- return artifact ?? NINFER_MODEL_ARTIFACTS['qwen3-27b'];
23
+ export function deployTargets(spec) {
24
+ const deploy = spec.deploy;
25
+ if (deploy === undefined)
26
+ return [];
27
+ // Array.isArray narrows to any[], which does not exclude the readonly list
28
+ // arm, so the single-target arm is cast to keep the union narrow.
29
+ if (Array.isArray(deploy))
30
+ return deploy;
31
+ return [deploy];
58
32
  }
59
33
  /**
60
34
  * flagsFromSpecs turns a spec surface and a config into the CLI flag list: each
61
35
  * spec's `toArgv` runs over the config value (falling back to the spec
62
36
  * default), and empty results are skipped. A spec with no `toArgv` contributes
63
- * nothing.
37
+ * nothing. A declared `deploy` target gates the spelling: a `cli` target in the
38
+ * spec's target list (or no target, the un-migrated fallback that reads the
39
+ * `toArgv` presence) spells CLI flags, so a spec that deploys only to the env
40
+ * or a config file contributes no flag even when it carries a `toArgv`
41
+ * spelling.
64
42
  */
65
43
  export function flagsFromSpecs(specs, config) {
66
44
  const flags = [];
67
45
  for (const spec of specs) {
68
46
  if (spec.toArgv === undefined)
69
47
  continue;
48
+ const targets = deployTargets(spec);
49
+ if (targets.length > 0 && !targets.some((target) => target.target === 'cli'))
50
+ continue;
70
51
  const value = config[spec.key] ?? spec.defaultValue;
71
52
  if (value === undefined)
72
53
  continue;
@@ -129,14 +110,21 @@ export function customArgEnv(config) {
129
110
  }
130
111
  /**
131
112
  * envFromSpecs turns a spec surface and a config into the environment map: each
132
- * spec's `toEnv` runs over the config value (falling back to the spec
133
- * default). A spec with no `toEnv` contributes nothing.
113
+ * spec's `toEnv` runs over the config value (falling back to the spec default).
114
+ * A spec with no `toEnv` contributes nothing. A declared `deploy` target gates
115
+ * the spelling: an `env` target in the spec's target list (or no target, the
116
+ * un-migrated fallback that reads the `toEnv` presence) spells env vars, so a
117
+ * spec that deploys only to the CLI or a config file contributes no var even
118
+ * when it carries a `toEnv` spelling.
134
119
  */
135
120
  export function envFromSpecs(specs, config) {
136
121
  const env = {};
137
122
  for (const spec of specs) {
138
123
  if (spec.toEnv === undefined)
139
124
  continue;
125
+ const targets = deployTargets(spec);
126
+ if (targets.length > 0 && !targets.some((target) => target.target === 'env'))
127
+ continue;
140
128
  const value = config[spec.key] ?? spec.defaultValue;
141
129
  if (value === undefined)
142
130
  continue;
@@ -145,65 +133,436 @@ export function envFromSpecs(specs, config) {
145
133
  }
146
134
  return env;
147
135
  }
136
+ /**
137
+ * configFileFromSpecs turns a spec surface and a config into the content of the
138
+ * config file the deploy step writes: every spec whose target list carries a
139
+ * `config` target resolves its value (`toConfig(value) ?? value`, falling back
140
+ * to the spec default) and nests it under that target's `block` / `key` (the
141
+ * key defaults to the spec key), then the result renders in the requested
142
+ * format. A spec with no `deploy`, or no `config` target, contributes nothing.
143
+ *
144
+ * A `block` ending in `[]` (e.g. `models[]`) follows the array-entry
145
+ * convention: the key lands inside the single object of a one-element array
146
+ * named by the prefix, and every spec sharing that block merges into that one
147
+ * entry. A plain block name nests as before, into one top-level object.
148
+ *
149
+ * A spec that carries both a `config` and a `cli` target contributes nothing
150
+ * here: the launch spells the CLI flag and leaves the on-disk config file (and
151
+ * any hand edits in it) untouched. Only a spec whose sole deploy path is the
152
+ * config file writes it.
153
+ *
154
+ * The YAML format loads the `yaml` package lazily (a root devDependency, so the
155
+ * JSON and env paths never touch it at runtime); a missing package throws only
156
+ * when the YAML format is requested.
157
+ */
158
+ export function configFileFromSpecs(specs, config, format) {
159
+ const object = {};
160
+ for (const spec of specs) {
161
+ const targets = deployTargets(spec);
162
+ const deployTarget = targets.find((target) => target.target === 'config');
163
+ if (deployTarget === undefined)
164
+ continue;
165
+ // Prefer the CLI flag over the config write: a spec that also carries a
166
+ // cli target spells the flag and leaves the on-disk config file (and any
167
+ // hand edits in it) untouched.
168
+ if (targets.some((target) => target.target === 'cli'))
169
+ continue;
170
+ const value = config[spec.key] ?? spec.defaultValue;
171
+ if (value === undefined)
172
+ continue;
173
+ const resolved = spec.toConfig === undefined ? value : (spec.toConfig(value) ?? value);
174
+ const block = deployTarget.block;
175
+ const key = deployTarget.key ?? spec.key;
176
+ if (block === undefined) {
177
+ object[key] = resolved;
178
+ continue;
179
+ }
180
+ configBlockEntry(object, block)[key] = resolved;
181
+ }
182
+ switch (format) {
183
+ case 'json':
184
+ return JSON.stringify(object, null, 2);
185
+ case 'env':
186
+ return renderEnv(object);
187
+ case 'yaml':
188
+ return renderYaml(object);
189
+ }
190
+ }
191
+ /**
192
+ * ARRAY_BLOCK_PATTERN matches the array-entry block convention: the name in
193
+ * front of `[]` is the array's key, the `[]` marks the block's keys as entry
194
+ * keys. A plain block name never matches.
195
+ */
196
+ const ARRAY_BLOCK_PATTERN = /^(?<name>.+)\[\]$/;
197
+ /**
198
+ * configBlockEntry returns the object one config block merges its keys into,
199
+ * creating it on first use. A plain block names a top-level object. A `name[]`
200
+ * block names the single object of a one-element array under `name`, so every
201
+ * spec sharing the block writes into the same entry.
202
+ */
203
+ function configBlockEntry(object, block) {
204
+ const arrayBlock = ARRAY_BLOCK_PATTERN.exec(block);
205
+ const name = arrayBlock?.groups?.name ?? block;
206
+ const existing = object[name];
207
+ // The array form reads back the entry it created and re-stamps the
208
+ // one-element array, so the rendered shape stays `{"name": [{...}]}`.
209
+ const held = arrayBlock === null
210
+ ? existing
211
+ : Array.isArray(existing)
212
+ ? existing[0]
213
+ : undefined;
214
+ const entry = held !== null && typeof held === 'object'
215
+ ? held
216
+ : {};
217
+ object[name] = arrayBlock === null ? entry : [entry];
218
+ return entry;
219
+ }
220
+ /**
221
+ * renderEnv flattens a nested config object into flat `KEY=VALUE` lines: a
222
+ * top-level block object emits one line per leaf (the leaf key, not the block),
223
+ * and a top-level scalar emits itself. Non-string leaves stringify through
224
+ * String (numbers, booleans) or JSON (objects, arrays).
225
+ */
226
+ function renderEnv(object) {
227
+ const lines = [];
228
+ for (const [key, value] of Object.entries(object)) {
229
+ if (value !== null && typeof value === 'object') {
230
+ for (const [leaf, leafValue] of Object.entries(value)) {
231
+ lines.push(`${leaf}=${envValue(leafValue)}`);
232
+ }
233
+ }
234
+ else {
235
+ lines.push(`${key}=${envValue(value)}`);
236
+ }
237
+ }
238
+ return lines.join('\n');
239
+ }
240
+ /**
241
+ * envValue stringifies one `.env` leaf: strings pass through, numbers and
242
+ * booleans through String, and anything else through JSON.
243
+ */
244
+ function envValue(value) {
245
+ if (typeof value === 'string')
246
+ return value;
247
+ if (typeof value === 'number' || typeof value === 'boolean')
248
+ return String(value);
249
+ return JSON.stringify(value);
250
+ }
251
+ /**
252
+ * renderYaml stringifies a config object through the `yaml` package. The
253
+ * package is a root devDependency (the YAML format is a test and optional path),
254
+ * so it loads lazily through createRequire and the JSON/env paths never pay for
255
+ * it at runtime.
256
+ */
257
+ function renderYaml(object) {
258
+ // createRequire anchors the lookup at this file, so the root devDependency
259
+ // resolves through the parent node_modules even from the compiled lib/.
260
+ const { stringify } = createRequire(import.meta.url)('yaml');
261
+ return stringify(object);
262
+ }
263
+ /**
264
+ * The cancel grace period in ms: an aborted run gets SIGINT first, and a child
265
+ * still alive after the grace period gets SIGTERM, so a stuck child cannot
266
+ * keep the run open.
267
+ */
268
+ const RUN_ABORT_GRACE_MS = 5_000;
269
+ /**
270
+ * The plain-run cap in ms: a 10-minute ceiling, so a hung CUDA build cannot
271
+ * hang the panel. The streaming surface carries no overall cap (a full CUDA
272
+ * build legitimately runs long), so the ceiling applies to run only.
273
+ */
274
+ const RUN_TIMEOUT_MS = 600_000;
275
+ /**
276
+ * The tail kept of a command's combined output: the detail a failed
277
+ * CommandOutcome carries, and the stdout tail a clean-exit CommandOutcome
278
+ * carries, are each the last KiB of stdout plus stderr, so a chatty run
279
+ * stays readable without growing the host's memory or the log sink.
280
+ */
281
+ const COMMAND_DETAIL_TAIL = 1_024;
282
+ /**
283
+ * The streaming output's high-water mark and tail: a streamed command's
284
+ * collected output is trimmed to the last KiB once it passes the mark, so a
285
+ * long build's stream cannot grow the host's memory without bound.
286
+ */
287
+ const STREAM_OUTPUT_HIGH_WATER = 2_048;
288
+ const STREAM_OUTPUT_TAIL = 1_024;
289
+ /**
290
+ * The credential probe's timeout: a hung sudo must not sit in front of the
291
+ * guarded command, so a slow probe reads as "a prompt is coming" (the safe
292
+ * side), matching the service install runner's probe timeout.
293
+ */
294
+ const SUDO_PROBE_TIMEOUT_MS = 5_000;
295
+ /**
296
+ * sudoProbeCached answers whether sudo already holds cached (or NOPASSWD)
297
+ * credentials: it runs SUDO_PROBE_COMMAND non-interactively and reads a clean
298
+ * exit as cached. A refused -n, a missing sudo, and a hung probe all read as
299
+ * "a prompt is coming", the safe side. Each package owns its child-process
300
+ * wiring (the watchChildAbort note), so the lifecycle host keeps its own copy
301
+ * beside the service's sudoProbe.
302
+ */
303
+ function sudoProbeCached() {
304
+ const [command, ...args] = SUDO_PROBE_COMMAND;
305
+ if (command === undefined)
306
+ return Promise.resolve(false);
307
+ return new Promise(resolve => {
308
+ execFile(command, args, { timeout: SUDO_PROBE_TIMEOUT_MS }, (error) => {
309
+ resolve(error === null);
310
+ });
311
+ });
312
+ }
313
+ /**
314
+ * The streaming children createNodeLifecycleHost spawned detached (process-
315
+ * group leaders). signalProcess reads the set so the negative-pid group
316
+ * signal only ever targets a child that actually leads its group: the plain
317
+ * run's exec'd child (exec's options cannot set detached) stays in the
318
+ * host's group and gets the direct signal.
319
+ */
320
+ const detachedChildren = new WeakSet();
321
+ /**
322
+ * signalProcess delivers one signal to one child: a process-group leader
323
+ * gets it through its negative pid, so a compound command line
324
+ * (`a && sudo b`) dies whole instead of leaving sudo as a group survivor;
325
+ * every other child gets the direct signal. A simple sudo line is already
326
+ * safe there: the shell execs a lone command, so the shell process is sudo
327
+ * itself. A group that is already gone (ESRCH) or a platform that rejects
328
+ * negative pids falls back to the direct signal, which reaches the shell.
329
+ */
330
+ function signalProcess(child, signal) {
331
+ if (child.pid !== undefined && detachedChildren.has(child)) {
332
+ try {
333
+ process.kill(-child.pid, signal);
334
+ return;
335
+ }
336
+ catch {
337
+ // Group gone or unsupported: the direct signal below still reaches the
338
+ // shell, and a child that already exited no-ops the way ChildProcess
339
+ // .kill always does.
340
+ }
341
+ }
342
+ child.kill(signal);
343
+ }
344
+ /**
345
+ * signalChild signals an in-flight child with the cancel rule: SIGINT first,
346
+ * and SIGTERM after the grace period if the child is still alive. The child
347
+ * already marked killed or exited is left alone. The optional graceMs
348
+ * overrides the 5 s constant so a test can exercise the escalation without
349
+ * waiting. The abort watch and the sudo deadline kill share this one rule,
350
+ * the mirror of the service's copy (packages/service/src/index.ts,
351
+ * signalChild): each package owns its child-process wiring, so a change to
352
+ * the escalation rule lands in two places by design.
353
+ */
354
+ function signalChild(child, graceMs) {
355
+ if (child.killed || child.exitCode !== null)
356
+ return;
357
+ signalProcess(child, 'SIGINT');
358
+ const escalate = setTimeout(() => {
359
+ // A group signal never marks ChildProcess.kill, so the guard reads the
360
+ // exit state: a child that has neither exited nor been signalled is
361
+ // still alive and gets SIGTERM.
362
+ if (child.exitCode === null && child.signalCode === null)
363
+ signalProcess(child, 'SIGTERM');
364
+ }, graceMs ?? RUN_ABORT_GRACE_MS);
365
+ escalate.unref();
366
+ }
367
+ /**
368
+ * watchChildAbort kills the in-flight child when the RunOptions signal
369
+ * aborts (the card's cancel of a Get/Launch download) through the shared
370
+ * signalChild rule. A signal that is already aborted kills immediately. A
371
+ * near-identical copy lives in the service package
372
+ * (packages/service/src/index.ts, the download runner's abort path): each
373
+ * package owns its own child-process wiring, so a change to the escalation
374
+ * rule lands in two places by design.
375
+ */
376
+ function watchChildAbort(child, signal, graceMs) {
377
+ if (signal === undefined)
378
+ return;
379
+ const onAbort = () => {
380
+ signalChild(child, graceMs);
381
+ };
382
+ if (signal.aborted) {
383
+ onAbort();
384
+ return;
385
+ }
386
+ signal.addEventListener('abort', onAbort, { once: true });
387
+ }
148
388
  /**
149
389
  * createNodeLifecycleHost builds the node `LifecycleHost`: `run` through the
150
390
  * system shell (a 10-minute cap, so a hung CUDA build cannot hang the panel),
151
- * `spawn` a detached child the stop step can signal, and `fetch` the health
152
- * URL. A fetch that throws (no route, connection refused) reads as status 0,
391
+ * `spawn` a detached child the stop step can signal, `writeFile` the config
392
+ * file the deploy step renders (creating the parent directory), and `fetch`
393
+ * the health URL. A fetch that throws (no route, connection refused) reads as status 0,
153
394
  * which the poll treats as not-ready. When `debug` is provided, the host logs
154
395
  * every command it issues (each `run` line and each `spawn` argv) through it,
155
- * so a DSH debug-mode host captures the full build-to-launch sequence.
396
+ * so a DSH debug-mode host captures the full build-to-launch sequence. A
397
+ * `RunOptions.shell` (the engine OS profile's shell, e.g. pwsh.exe for
398
+ * the Windows vcpkg route) runs the command line in that shell instead of the
399
+ * system default. A `RunOptions.graceMs` overrides the 5 s SIGTERM escalation
400
+ * window of the abort path for that run. The optional `sudo` hook wires the
401
+ * sudo-prompt rule: a run whose `RunOptions.needsSudo` declares sudo probes
402
+ * cached credentials first, announces a waiting prompt through the hook once,
403
+ * and dies at the password deadline with the `sudoFailureDetail` outcome. A
404
+ * host built without the hook never probes, announces, or caps a run.
156
405
  */
157
- export function createNodeLifecycleHost(debug) {
406
+ export function createNodeLifecycleHost(debug, sudo) {
158
407
  // The debug logger is a no-op outside debug mode, so the guard reads as
159
408
  // "log when a real sink is wired" and the command path stays quiet otherwise.
160
409
  const log = debug ?? undefined;
410
+ /**
411
+ * guardSudo drives core's sudo-prompt rule for one declared run: the run
412
+ * carries needsSudo and the host has the hook, so the command probes first,
413
+ * announces once, and arms the password deadline whose fire calls kill. An
414
+ * undeclared run, or a host without the hook, resolves undefined: the run
415
+ * goes through exactly as before.
416
+ */
417
+ const guardSudo = async (command, options, kill) => {
418
+ if (sudo === undefined || options?.needsSudo !== true)
419
+ return undefined;
420
+ return guardSudoCommand({
421
+ needsSudo: true,
422
+ command,
423
+ probe: sudoProbeCached,
424
+ notify: sudo.notify,
425
+ kill,
426
+ ...(sudo.timeoutMs === undefined ? {} : { timeoutMs: sudo.timeoutMs }),
427
+ });
428
+ };
161
429
  return {
162
- async run(command) {
430
+ async run(command, options) {
163
431
  log?.(`run: ${command}`);
164
- return new Promise(resolve => {
165
- exec(command, { timeout: 600_000 }, (error, stdout, stderr) => {
166
- if (error === null) {
167
- resolve({ ok: true });
168
- return;
169
- }
170
- const detail = `${stderr ?? ''}${stdout ?? ''}${String(error)}`.trim().slice(-1024);
171
- resolve({ ok: false, detail });
172
- });
432
+ // The guard arms before the exec; the outer child exists so the
433
+ // deadline's kill reaches the run through the shared signalChild rule.
434
+ // The exec'd child cannot spawn detached (exec's options forbid it), so
435
+ // it takes the direct signal; the deploy lines this path runs are
436
+ // single `sudo ln -sf ...` commands, whose shell execs sudo itself.
437
+ let child;
438
+ const guard = await guardSudo(command, options, () => {
439
+ if (child !== undefined)
440
+ signalChild(child, options?.graceMs);
173
441
  });
442
+ try {
443
+ const outcome = await new Promise((resolve) => {
444
+ // A profile shell (the Windows vcpkg route's pwsh.exe) runs the
445
+ // command line in that shell; absent keeps the system default.
446
+ const started = exec(command, { timeout: RUN_TIMEOUT_MS, shell: options?.shell }, (error, stdout, stderr) => {
447
+ if (error === null) {
448
+ // A clean exit still carries its output tail: the run's log
449
+ // sink (auditRun, the Get extras fallback) appends it after
450
+ // the `$ <command>` line, so success output reaches the Logs
451
+ // section the way streamed output does.
452
+ const output = `${stdout ?? ''}${stderr ?? ''}`.trim();
453
+ resolve(output === ''
454
+ ? { ok: true }
455
+ : { ok: true, stdout: output.slice(-COMMAND_DETAIL_TAIL) });
456
+ return;
457
+ }
458
+ const detail = `${stderr ?? ''}${stdout ?? ''}${String(error)}`.trim().slice(-COMMAND_DETAIL_TAIL);
459
+ resolve({ ok: false, detail });
460
+ });
461
+ child = started;
462
+ watchChildAbort(started, options?.signal, options?.graceMs);
463
+ });
464
+ // The password deadline killed the child: name the missed sudo prompt
465
+ // regardless of the exit the signalled child reported.
466
+ if (guard?.timedOut() === true) {
467
+ return { ok: false, detail: sudoFailureDetail(command) };
468
+ }
469
+ return outcome;
470
+ }
471
+ finally {
472
+ // The command settled (its close or error path resolved it): the
473
+ // password deadline is no longer owed.
474
+ guard?.disarm();
475
+ }
174
476
  },
175
- async runStream(command, onOutput) {
477
+ async runStream(command, onOutput, options) {
176
478
  log?.(`run: ${command}`);
177
- return new Promise(resolve => {
178
- // No overall timeout: a full CUDA build legitimately runs long, and
179
- // the card prices a stalled step with its own output-idle watchdog.
180
- const child = nodeSpawn(command, { shell: true, stdio: ['ignore', 'pipe', 'pipe'] });
181
- let output = '';
182
- const append = (chunk) => {
183
- const text = chunk.toString('utf8');
184
- onOutput(text);
185
- output += text;
186
- if (output.length > 2048) {
187
- output = output.slice(-1024);
188
- }
189
- };
190
- child.stdout.on('data', append);
191
- child.stderr.on('data', append);
192
- child.on('error', error => {
193
- resolve({ ok: false, detail: error.message });
194
- });
195
- child.on('close', code => {
196
- if (code === 0) {
197
- resolve({ ok: true });
198
- return;
199
- }
200
- resolve({ ok: false, detail: `exit ${code}: ${output.trim().slice(-1024)}` });
201
- });
479
+ let child;
480
+ const guard = await guardSudo(command, options, () => {
481
+ if (child !== undefined)
482
+ signalChild(child, options?.graceMs);
202
483
  });
484
+ try {
485
+ const outcome = await new Promise((resolve) => {
486
+ // No overall timeout: a full CUDA build legitimately runs long, and
487
+ // the card prices a stalled step with its own output-idle watchdog.
488
+ // A profile shell (the Windows vcpkg route's pwsh.exe) runs the
489
+ // command line in that shell; absent keeps the system default.
490
+ // The build shell spawns detached so it leads its own process
491
+ // group and signalProcess's negative-pid signal reaches the whole
492
+ // line (build commands are compound). libuv's detached spawn only
493
+ // changes the process group (Node >= 22 ships libuv >= 1.48, which
494
+ // does not create a session), so a sudo prompt still reaches the
495
+ // launching terminal.
496
+ const started = nodeSpawn(command, {
497
+ shell: options?.shell ?? true,
498
+ detached: true,
499
+ stdio: ['ignore', 'pipe', 'pipe'],
500
+ });
501
+ child = started;
502
+ detachedChildren.add(started);
503
+ watchChildAbort(started, options?.signal, options?.graceMs);
504
+ let output = '';
505
+ const append = (chunk) => {
506
+ const text = chunk.toString('utf8');
507
+ onOutput(text);
508
+ output += text;
509
+ if (output.length > STREAM_OUTPUT_HIGH_WATER) {
510
+ output = output.slice(-STREAM_OUTPUT_TAIL);
511
+ }
512
+ };
513
+ started.stdout.on('data', append);
514
+ started.stderr.on('data', append);
515
+ started.on('error', error => {
516
+ resolve({ ok: false, detail: error.message });
517
+ });
518
+ started.on('close', code => {
519
+ if (code === 0) {
520
+ // The stdout tail of a clean exit (see the plain run's rule); a
521
+ // streaming caller has already seen the chunks live, so only a
522
+ // sink-less reader needs the collected tail.
523
+ const tail = output.trim();
524
+ resolve(tail === '' ? { ok: true } : { ok: true, stdout: tail.slice(-COMMAND_DETAIL_TAIL) });
525
+ return;
526
+ }
527
+ resolve({
528
+ ok: false,
529
+ detail: `exit ${code}: ${output.trim().slice(-COMMAND_DETAIL_TAIL)}`,
530
+ });
531
+ });
532
+ });
533
+ // The password deadline killed the child: name the missed sudo prompt
534
+ // regardless of the exit the signalled child reported.
535
+ if (guard?.timedOut() === true) {
536
+ return { ok: false, detail: sudoFailureDetail(command) };
537
+ }
538
+ return outcome;
539
+ }
540
+ finally {
541
+ guard?.disarm();
542
+ }
203
543
  },
204
544
  spawn(argv, env, cwd) {
205
545
  log?.(`spawn: ${argv.join(' ')}${cwd !== undefined ? ` (cwd: ${cwd})` : ''}`);
206
- const child = nodeSpawn(argv[0] ?? '', argv.slice(1), {
546
+ // An empty argv (or an empty command) would spawn '' and hang the watcher
547
+ // on a child that never starts. Settle the handle with a spawn error
548
+ // detail instead, so the launch step reports the missing command.
549
+ const command = argv[0];
550
+ if (command === undefined || command === '') {
551
+ const detail = 'spawn: no command in argv';
552
+ const info = { code: null, signal: null, error: detail };
553
+ return {
554
+ pid: -1,
555
+ kill: () => { },
556
+ logs: detail,
557
+ onExit: (listener) => {
558
+ listener(info);
559
+ },
560
+ // It settled with the spawn error at construction, so the state read
561
+ // reports true from the start.
562
+ hasExited: () => true,
563
+ };
564
+ }
565
+ const child = nodeSpawn(command, argv.slice(1), {
207
566
  env: { ...process.env, ...env },
208
567
  cwd,
209
568
  detached: true,
@@ -218,7 +577,7 @@ export function createNodeLifecycleHost(debug) {
218
577
  // bound; the tail is what the log file and the card keep.
219
578
  let logs = '';
220
579
  const append = (chunk) => {
221
- logs = (logs + chunk.toString('utf8')).slice(-65536);
580
+ logs = (logs + chunk.toString('utf8')).slice(-LOG_TAIL_BYTES);
222
581
  };
223
582
  child.stdout?.on('data', append);
224
583
  child.stderr?.on('data', append);
@@ -228,10 +587,10 @@ export function createNodeLifecycleHost(debug) {
228
587
  // breaks on a crashing child both emit 'error' here, so every path gets
229
588
  // a listener and the failure lands in the logs instead of the crash.
230
589
  child.stdout?.on('error', (error) => {
231
- logs = (logs + `stdout error: ${error.message}\n`).slice(-65536);
590
+ logs = (logs + `stdout error: ${error.message}\n`).slice(-LOG_TAIL_BYTES);
232
591
  });
233
592
  child.stderr?.on('error', (error) => {
234
- logs = (logs + `stderr error: ${error.message}\n`).slice(-65536);
593
+ logs = (logs + `stderr error: ${error.message}\n`).slice(-LOG_TAIL_BYTES);
235
594
  });
236
595
  // The one-shot exit contract: 'error' names a child that never ran,
237
596
  // 'exit' carries the code or signal, and 'close' follows once the pipes
@@ -256,7 +615,7 @@ export function createNodeLifecycleHost(debug) {
256
615
  };
257
616
  let exitSignal = null;
258
617
  child.on('error', (error) => {
259
- logs = (logs + `spawn error: ${error.message}\n`).slice(-65536);
618
+ logs = (logs + `spawn error: ${error.message}\n`).slice(-LOG_TAIL_BYTES);
260
619
  // The error fires before the stdio drains; settle immediately so a
261
620
  // launch of a missing binary reports rather than hangs the watcher.
262
621
  settle({ code: null, signal: null, error: error.message });
@@ -285,8 +644,19 @@ export function createNodeLifecycleHost(debug) {
285
644
  }
286
645
  listeners.add(listener);
287
646
  },
647
+ // A state read of the settle state the onExit listener reads: a live
648
+ // child reports false, and a child that ended (a crash, a pkill, or
649
+ // a spawn failure) reports true.
650
+ hasExited: () => exitInfo !== null,
288
651
  };
289
652
  },
653
+ async writeFile(path, content) {
654
+ log?.(`writeFile: ${path}`);
655
+ // The parent directory may not exist (a fresh model directory), so the
656
+ // mkdir runs before the write; recursive keeps an existing dir a no-op.
657
+ await mkdir(dirname(path), { recursive: true });
658
+ await fsWriteFile(path, content);
659
+ },
290
660
  async fetch(url) {
291
661
  try {
292
662
  const response = await fetch(url);
@@ -313,7 +683,7 @@ export const nodeLifecycleHost = createNodeLifecycleHost();
313
683
  * signals a pid a recycled process has taken over. A handle whose host cannot
314
684
  * watch (onExit absent) stays tracked until stop removes it.
315
685
  */
316
- function trackChild(children, key, handle) {
686
+ export function trackChild(children, key, handle) {
317
687
  children.set(key, handle);
318
688
  handle.onExit?.(() => {
319
689
  // Compare identity before deleting: a newer launch that already replaced
@@ -325,862 +695,146 @@ function trackChild(children, key, handle) {
325
695
  });
326
696
  }
327
697
  /**
328
- * pollHealth polls a plan's health URL until a 200 or the attempt cap, the
329
- * same loop the qwen-ninfer launcher runs. A poll interval of 0 skips the
330
- * delay, which the tests use to run the loop without a real sleep.
698
+ * resolveLaunchArgv resolves a plan's launch argv against the engine
699
+ * directory the spawn runs in: a relative path that carries a separator (a
700
+ * build-tree binary, the NInfer Windows tree) is joined into an absolute
701
+ * path, and a bare name (a binary on the path after the rebuild's symlink
702
+ * step) is left as-is, so the spawn resolves the right command on every
703
+ * platform.
331
704
  */
332
- export async function pollHealth(host, plan, options) {
333
- const pollMs = options?.pollMs ?? 1000;
334
- const maxAttempts = options?.maxAttempts ?? 120;
335
- let last = { status: 0, ok: false };
336
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
337
- last = await host.fetch(plan.healthUrl);
338
- if (last.status === 200) {
339
- return { healthy: true, status: 200 };
340
- }
341
- if (pollMs > 0 && attempt < maxAttempts) {
342
- await delay(pollMs);
343
- }
344
- }
345
- return {
346
- healthy: false,
347
- status: last.status,
348
- detail: `health check did not return 200 within ${maxAttempts} attempts`,
349
- };
705
+ export function resolveLaunchArgv(engineDir, argv) {
706
+ const resolved = [...(argv ?? [])];
707
+ const first = resolved[0];
708
+ if (engineDir !== undefined &&
709
+ first !== undefined &&
710
+ !isAbsolute(first) &&
711
+ (first.includes('\\') || first.includes('/'))) {
712
+ resolved[0] = join(engineDir, first);
713
+ }
714
+ return resolved;
350
715
  }
351
- function delay(ms) {
352
- return new Promise(resolve => {
353
- setTimeout(resolve, ms);
354
- });
355
- }
356
- /**
357
- * The NInfer docker image the adapter pulls and runs. The engineFork platform
358
- * config overrides the git source for a local build; the image stays the
359
- * upstream ghcr artifact.
360
- */
361
- export const NINFER_DOCKER_IMAGE = 'ghcr.io/iamwavecut/ninfer-all';
362
716
  /**
363
- * NInferAdapter is the canonical both-capable engine. `renderLaunch` renders a
364
- * local `ninfer-serve` process or a `docker run` of the ghcr image from the
365
- * same spec surface; `deploy` builds the engine in the engine directory (or
366
- * pulls the image), downloads the model into the model directory, and starts
367
- * the engine; `healthCheck` polls the health URL; `stop` signals the tracked
368
- * process and stops the named container.
717
+ * auditRun runs one mutating host command through the run's log sink: the
718
+ * `$ <command>` line reaches the sink before the child spawns, and the
719
+ * outcome's success output tail reaches it after the child settles, so the
720
+ * launch or stop run reads the command ahead of its result (the URL-query
721
+ * rule applied to execs: line first, settled outcome after). No sink (a
722
+ * headless call) and the command runs as before. A failed outcome lands no
723
+ * output here: the caller's own failure line carries the detail's output
724
+ * tail. The deploy and stop steps share this one audit rule for their direct
725
+ * `host.run` calls; the git and build steps run through the acquire step's
726
+ * own command-first echo.
369
727
  */
370
- export class NInferAdapter extends EngineAdapter {
371
- id = 'ninfer';
372
- supports = { docker: true, local: true };
373
- host;
374
- children = new Map();
375
- constructor(host = nodeLifecycleHost) {
376
- super();
377
- this.host = host;
378
- }
379
- parameters() {
380
- return NINFER_PARAMETERS;
381
- }
382
- modelRef(config) {
383
- // The id the engine advertises on its OpenAI endpoint is the artifact's
384
- // --model-id alias, not the VramModel registry key the config names, so
385
- // the registrar publishes the alias and a chat turn routes to it.
386
- const artifact = artifactFor(typeof config.model === 'string' ? config.model : 'qwen3-27b');
387
- return { id: artifact.modelId, name: artifact.modelId };
388
- }
389
- launchSignature(config = {}) {
390
- // The local plan runs the ninfer-serve binary; the docker plan's host
391
- // process is docker run of the ghcr image, so the image name is the
392
- // command-line marker the reserve probe matches on.
393
- const port = this.port(config);
394
- if (this.buildMode(config) === 'docker') {
395
- return { binary: NINFER_DOCKER_IMAGE, port };
396
- }
397
- return { binary: 'ninfer-serve', port };
398
- }
399
- renderLaunch(config, _hw) {
400
- const port = this.port(config);
401
- const modelId = typeof config.model === 'string' ? config.model : 'qwen3-27b';
402
- const artifact = artifactFor(modelId);
403
- // A model key that names a local artifact file (a directory-scanned
404
- // model) overrides the artifact map's file, so a file the user has on
405
- // disk launches as-is.
406
- const file = modelId.endsWith('.ninfer') ? modelId : artifact.file;
407
- const modelDirectory = typeof config.modelDirectory === 'string' ? config.modelDirectory : '';
408
- const flags = flagsFromSpecs(NINFER_PARAMETERS, config);
409
- if (this.buildMode(config) === 'docker') {
410
- const modelPath = modelDirectory === '' ? file : join(modelDirectory, file);
411
- const containerModelPath = `/models/${basename(file)}`;
412
- const modelMount = modelDirectory === ''
413
- ? `${file}:${containerModelPath}`
414
- : `${dirname(modelPath)}:/models`;
415
- const containerName = `ninfer-${port}`;
416
- const customArgs = customArgFlags(config);
417
- return {
418
- engineId: this.id,
419
- kind: 'docker',
420
- argv: [
421
- 'docker',
422
- 'run',
423
- '--rm',
424
- '--gpus',
425
- 'all',
426
- '--name',
427
- containerName,
428
- '-p',
429
- `127.0.0.1:${port}:8080`,
430
- '--ulimit',
431
- 'memlock=-1',
432
- '-v',
433
- 'ninfer-cache:/cache',
434
- '-v',
435
- modelMount,
436
- NINFER_DOCKER_IMAGE,
437
- 'serve',
438
- containerModelPath,
439
- '--model-id',
440
- artifact.modelId,
441
- ...flags,
442
- ...customArgs,
443
- ],
444
- env: {},
445
- containerName,
446
- modelId,
447
- ...(modelDirectory === '' ? {} : { modelDirectory }),
448
- port,
449
- healthUrl: `http://127.0.0.1:${port}/health`,
450
- };
451
- }
452
- const modelPath = modelDirectory === '' ? file : join(modelDirectory, file);
453
- const customArgs = customArgFlags(config);
454
- return {
455
- engineId: this.id,
456
- kind: 'process',
457
- argv: [
458
- 'ninfer-serve',
459
- modelPath,
460
- '--host',
461
- '127.0.0.1',
462
- '--port',
463
- String(port),
464
- '--model-id',
465
- artifact.modelId,
466
- ...flags,
467
- ...customArgs,
468
- ],
469
- env: {},
470
- modelId,
471
- ...(modelDirectory === '' ? {} : { modelDirectory }),
472
- port,
473
- healthUrl: `http://127.0.0.1:${port}/health`,
474
- };
475
- }
476
- async deploy(plan, platform, onProgress, options) {
477
- if (plan.kind === 'process') {
478
- const engineDir = platform?.engineDirectory;
479
- let buildLogs = '';
480
- // Auto Rebuild off (the card's checkbox): the git sync (fetch, rebase,
481
- // rebuild) is skipped and the checkout launches as-is.
482
- if (engineDir !== undefined && options?.autoRebuild !== false) {
483
- // The git sync is the checkout download: the card's button reads
484
- // "Downloading..." for it, not "Building...".
485
- onProgress?.('download');
486
- // The conditional rebuild fetches the checkout, prices it against the
487
- // remote branch, and rebuilds only when it is behind (or a fresh
488
- // clone). The decision, the build command, and its output are captured
489
- // into buildLogs and threaded to the card's Logs section.
490
- const sync = await syncAndRebuildEngine(this.host, {
491
- engineId: this.id,
492
- directory: engineDir,
493
- ...(platform?.engineFork !== undefined ? { fork: platform.engineFork } : {}),
494
- ...(plan.argv?.[0] !== undefined ? { binary: plan.argv[0] } : {}),
495
- ...(options?.allowRunDeps !== undefined ? { allowRunDeps: options.allowRunDeps } : {}),
496
- }, () => { });
497
- buildLogs = sync.logs;
498
- if (!sync.ok) {
499
- return { ok: false, detail: sync.detail ?? 'unknown', logs: buildLogs };
500
- }
501
- // The build step reads as "Building..." only when a build actually
502
- // ran (a fresh clone, or a rebase of a checkout that was behind).
503
- if (sync.rebuilt) {
504
- onProgress?.('build');
505
- }
506
- }
507
- const withLogs = (result) => buildLogs !== '' ? { ...result, logs: buildLogs } : result;
508
- onProgress?.('download');
509
- const download = await this.downloadModel(plan);
510
- if (!download.ok)
511
- return withLogs(download);
512
- // A chat template the user picked as an HF repo reference (the combo
513
- // control's known choices) is not a path the engine can read, so the
514
- // deploy step downloads it into the model directory and rewrites the
515
- // --chat-template flag to the local file before the spawn.
516
- onProgress?.('download');
517
- const templateResult = await this.resolveChatTemplate(plan);
518
- if (!templateResult.ok)
519
- return withLogs(templateResult);
520
- const handle = this.host.spawn(plan.argv ?? [], plan.env ?? {}, engineDir);
521
- trackChild(this.children, this.planKey(plan), handle);
522
- return withLogs({ ok: true, handle });
523
- }
524
- if (plan.kind === 'docker') {
525
- // The image pull is the docker build step: the card's button reads
526
- // "Building..." for it.
527
- onProgress?.('build');
528
- const pull = await this.host.run(`docker pull ${NINFER_DOCKER_IMAGE}`);
529
- if (!pull.ok) {
530
- return { ok: false, detail: `docker pull failed: ${pull.detail ?? 'unknown'}` };
531
- }
532
- const handle = this.host.spawn(plan.argv ?? [], plan.env ?? {});
533
- trackChild(this.children, this.planKey(plan), handle);
534
- return { ok: true, handle };
535
- }
536
- // NInfer renders process and docker plans; a compose plan is a no-op.
537
- return { ok: true };
538
- }
539
- async healthCheck(plan, options) {
540
- return pollHealth(this.host, plan, options);
541
- }
542
- async stop(plan) {
543
- if (plan.kind === 'docker' && plan.containerName !== undefined) {
544
- await this.host.run(`docker stop ${plan.containerName}`);
545
- }
546
- const handle = this.children.get(this.planKey(plan));
547
- if (handle !== undefined) {
548
- handle.kill();
549
- this.children.delete(this.planKey(plan));
550
- }
551
- }
552
- buildMode(config) {
553
- return config.buildMode === 'docker' ? 'docker' : 'local';
554
- }
555
- port(config) {
556
- return typeof config.port === 'number' ? config.port : 18020;
557
- }
558
- planKey(plan) {
559
- return `${plan.engineId}:${plan.port}`;
560
- }
561
- async downloadModel(plan) {
562
- const artifact = plan.modelId !== undefined ? NINFER_MODEL_ARTIFACTS[plan.modelId] : undefined;
563
- if (artifact === undefined)
564
- return { ok: true };
565
- const target = plan.modelDirectory ?? '.';
566
- const download = await this.host.run(`hf download ${artifact.repo} ${artifact.file} --local-dir ${target}`);
567
- if (!download.ok) {
568
- return { ok: false, detail: `model download failed: ${download.detail ?? 'unknown'}` };
569
- }
570
- return { ok: true };
571
- }
572
- /**
573
- * resolveChatTemplate rewrites a --chat-template flag that carries an HF repo
574
- * reference into the local file the engine reads. A value with a slash that
575
- * is not an absolute path (the combo control's known choices) is a repo the
576
- * deploy step downloads into the model directory; an already-local path is
577
- * left as-is. The download is best-effort: a template that is already on
578
- * disk is used without a re-fetch, and a missing hf CLI leaves the flag
579
- * untouched so the engine reports the missing template itself.
580
- */
581
- async resolveChatTemplate(plan) {
582
- const argv = plan.argv;
583
- if (argv === undefined)
584
- return { ok: true };
585
- const index = argv.indexOf('--chat-template');
586
- if (index === -1)
587
- return { ok: true };
588
- const value = argv[index + 1];
589
- if (value === undefined)
590
- return { ok: true };
591
- // An absolute path (a leading /) is already local; a value with no slash
592
- // is a bare file name the engine resolves against its own directory.
593
- if (value.startsWith('/') || !value.includes('/'))
594
- return { ok: true };
595
- // An HF repo reference: download the template file into the model
596
- // directory and point the flag at the local copy.
597
- const target = plan.modelDirectory ?? '.';
598
- const localPath = `${target.endsWith('/') ? '' : `${target}/`}chat_template.jinja`;
599
- // A template already on disk skips the re-fetch; the hf CLI is optional,
600
- // so a download failure is reported but does not abort the launch.
601
- const probe = await this.host.run(`[ -f ${localPath} ]`);
602
- if (!probe.ok) {
603
- const download = await this.host.run(`hf download ${value} chat_template.jinja --local-dir ${target}`);
604
- if (!download.ok) {
605
- return {
606
- ok: false,
607
- detail: `chat template download failed: ${download.detail ?? 'unknown'}`,
608
- };
609
- }
610
- }
611
- argv[index + 1] = localPath;
612
- return { ok: true };
613
- }
728
+ export async function auditRun(host, command, log, options) {
729
+ log?.(commandLogLine(command));
730
+ const outcome = await host.run(command, options);
731
+ if (log !== undefined && outcome.ok && outcome.stdout !== undefined && outcome.stdout !== '') {
732
+ // The host capped the tail (COMMAND_DETAIL_TAIL), so a docker pull or an
733
+ // hf download lands its closing lines without refilling the run.
734
+ log(outcome.stdout.endsWith('\n') ? outcome.stdout : `${outcome.stdout}\n`);
735
+ }
736
+ return outcome;
614
737
  }
615
738
  /**
616
- * HyperQwenAdapter is the docker-compose engine. `renderLaunch` renders a
617
- * compose plan (the compose file, the profile, and the `.env` the spec surface
618
- * maps to); `deploy` runs `docker compose up`; `healthCheck` polls the health
619
- * URL; `stop` runs `docker compose down`.
739
+ * stopProcessPlan signals the engine process a stop targets when the adapter
740
+ * tracks no child handle for it: an engine a DSH restart re-adopted from a
741
+ * stored launch record carries no in-memory handle, so the stop signals the
742
+ * pid the record stamped on the plan (kill on Linux and macOS, taskkill on a
743
+ * Windows plan). The optional `log` is the stop run's log sink: the signal
744
+ * command's `$ <command>` line reaches it before the child runs. A plan that
745
+ * names no pid is a no-op (nothing to signal), and a kill the host reports as
746
+ * failed throws with the host's detail, so the stop route names the broken
747
+ * step in the card's Logs section.
620
748
  */
621
- export class HyperQwenAdapter extends EngineAdapter {
622
- id = 'hyperqwen';
623
- supports = { docker: true, local: false };
624
- host;
625
- constructor(host = nodeLifecycleHost) {
626
- super();
627
- this.host = host;
628
- }
629
- parameters() {
630
- return HYPERQWEN_PARAMETERS;
631
- }
632
- modelRef(_config) {
633
- // vLLM serves the model under the name the compose file passes, and the
634
- // plugin tracks no per-model data for Hyper-Qwen yet, so the engine id is
635
- // the stable entry the registrar publishes until that data lands.
636
- return { id: 'hyperqwen', name: 'Hyper-Qwen' };
637
- }
638
- launchSignature(config = {}) {
639
- // The compose host process is `docker compose ... up`, so the marker is
640
- // the command, not an image. The toEnv keys (SPEC, CTX, KV, ...) name the
641
- // .env the compose file reads, not the host process env, so they ride
642
- // along as the env-key element the matcher checks when a process line
643
- // carries env data.
644
- const port = this.port(config);
645
- const envKeys = Object.keys(envFromSpecs(HYPERQWEN_PARAMETERS, config));
646
- return { binary: 'docker compose', port, envKeys };
647
- }
648
- renderLaunch(config, _hw) {
649
- const port = this.port(config);
650
- const profile = this.profile(config);
651
- // The spec env and the card's Custom Arguments merge into one env map: the
652
- // spec keys are the .env the compose file reads, and the custom rows are
653
- // the user's own overrides appended after them.
654
- const env = { ...envFromSpecs(HYPERQWEN_PARAMETERS, config), ...customArgEnv(config) };
655
- const composeFile = typeof config.composeFile === 'string' ? config.composeFile : 'docker-compose.yml';
656
- return {
657
- engineId: this.id,
658
- kind: 'compose',
659
- env,
660
- composeFile,
661
- profile,
662
- port,
663
- healthUrl: `http://127.0.0.1:${port}/health`,
664
- };
665
- }
666
- async deploy(plan, platform, onProgress, _options) {
667
- // The card's docker-tag field carries the compose arguments (the profile
668
- // the up step selects); the deploy step applies it to the plan so the up
669
- // line and the card's Logs section agree.
670
- if (platform?.dockerTag !== undefined) {
671
- plan.composeArgs = platform.dockerTag;
672
- }
673
- // The core formatter is the single source of the compose up line: the
674
- // card's Logs section prints the same string this run gets.
675
- const command = formatLaunchCommand(plan);
676
- if (command === undefined) {
677
- return { ok: false, detail: 'compose plan carries no command' };
678
- }
679
- // The compose up is the docker build step: the card's button reads
680
- // "Building..." for it.
681
- onProgress?.('build');
682
- const up = await this.host.run(command);
683
- if (!up.ok) {
684
- return { ok: false, detail: `compose up failed: ${up.detail ?? 'unknown'}` };
685
- }
686
- return { ok: true };
687
- }
688
- async healthCheck(plan, options) {
689
- return pollHealth(this.host, plan, options);
690
- }
691
- async stop(plan) {
692
- // The down step brings down the containers the up step started, so it
693
- // runs the same compose arguments (the profile the up selected); an
694
- // absent composeArgs falls back to the engine's default profile.
695
- const composeArgs = plan.composeArgs?.split(/\s+/).filter((token) => token !== '');
696
- const args = composeArgs !== undefined && composeArgs.length > 0
697
- ? composeArgs.join(' ')
698
- : `--profile ${plan.profile ?? 'single'}`;
699
- await this.host.run(`docker compose -f ${plan.composeFile ?? 'docker-compose.yml'} ${args} down`);
700
- }
701
- port(config) {
702
- return typeof config.port === 'number' ? config.port : 18020;
703
- }
704
- profile(config) {
705
- return typeof config.profile === 'string' ? config.profile : 'single';
749
+ export async function stopProcessPlan(host, plan, log) {
750
+ if (plan.pid === undefined)
751
+ return;
752
+ const command = plan.os === 'windows' ? `taskkill /PID ${plan.pid} /T` : `kill ${plan.pid}`;
753
+ const outcome = await auditRun(host, command, log);
754
+ if (!outcome.ok) {
755
+ throw new Error(`stop: signal pid ${plan.pid} failed: ${outcome.detail ?? 'unknown'}`);
706
756
  }
707
757
  }
708
758
  /**
709
- * The llama.cpp docker image the adapter pulls and runs. The engineFork
710
- * platform config overrides the git source for a local build; the image stays
711
- * the upstream ggml artifact.
759
+ * The health poll's defaults: the interval between attempts in ms and the
760
+ * attempt cap. The service passes the config's healthPollMs and
761
+ * healthMaxAttempts through, and an adapter that polls without options (or
762
+ * with a partial one) reads these, so the defaults have one home.
712
763
  */
713
- export const LLAMACPP_DOCKER_IMAGE = 'ghcr.io/ggml-org/llama.cpp';
764
+ export const HEALTH_POLL_DEFAULT_MS = 1_000;
765
+ export const HEALTH_POLL_MAX_ATTEMPTS = 120;
714
766
  /**
715
- * llamaCppArtifact resolves the artifact a model key deploys. A key no entry
716
- * names falls back to the 27B artifact, so a bare config always renders a plan.
767
+ * logHealthPoll brackets a health poll with the URL-query logging rule (the
768
+ * exec rule's URL twin): the one `> GET <url>` line reaches the run's log
769
+ * sink before the first request, the retries stay silent, and the settled
770
+ * verdict line (`> GET <url> ready`, Strata's still-downloading line, or the
771
+ * `!!` failure with the last detail) lands after the poll returns. The
772
+ * shared poll and the Strata poll both wrap their loop in this, so the rule
773
+ * has one home. `log` absent (a headless poll) runs the poll unlogged.
717
774
  */
718
- function llamaCppArtifact(modelKey) {
719
- const artifact = modelKey !== undefined ? LLAMACPP_MODEL_ARTIFACTS[modelKey] : undefined;
720
- return artifact ?? LLAMACPP_MODEL_ARTIFACTS['qwen3-27b'];
775
+ export async function logHealthPoll(log, url, poll) {
776
+ log?.(urlLogLine(url));
777
+ const result = await poll();
778
+ if (log !== undefined) {
779
+ log(result.healthy
780
+ ? urlLogLine(url, 'ready')
781
+ : result.state === 'downloading'
782
+ ? urlLogLine(url, `still downloading${result.detail !== undefined ? `: ${result.detail}` : ''}`)
783
+ : `!! health check failed for ${url}: ${result.detail ?? `last status ${result.status ?? 'unknown'}`}\n`);
784
+ }
785
+ return result;
721
786
  }
722
787
  /**
723
- * LlamaCppAdapter is the llama.cpp engine, both-capable like NInfer.
724
- * `renderLaunch` renders a local `llama-server` process or a `docker run` of the
725
- * ggml image from the same `LLAMACPP_PARAMETERS` surface; `deploy` builds the
726
- * engine in the engine directory (or pulls the image), downloads the GGUF model
727
- * into the model directory, and starts the engine; `healthCheck` polls the health
728
- * URL; `stop` signals the tracked process and stops the named container.
788
+ * pollHealth polls a plan's health URL until a 200 or the attempt cap, the
789
+ * same loop the qwen-ninfer launcher runs. A poll interval of 0 skips the
790
+ * delay, which the tests use to run the loop without a real sleep. `handle`
791
+ * is the tracked child the deploy step spawned: when it reports it has
792
+ * exited (hasExited true), the poll ends immediately with a failed result
793
+ * instead of running the full attempt cap, so a crash reports at once. A
794
+ * handle that cannot report its exit state (a stub) keeps the full poll.
795
+ * The poll's URL logging (the one `> GET` line ahead of the settled verdict)
796
+ * rides the shared logHealthPoll wrap when options.onLog carries the run's
797
+ * log sink.
729
798
  */
730
- export class LlamaCppAdapter extends EngineAdapter {
731
- id = 'llamacpp';
732
- supports = { docker: true, local: true };
733
- host;
734
- children = new Map();
735
- constructor(host = nodeLifecycleHost) {
736
- super();
737
- this.host = host;
738
- }
739
- parameters() {
740
- return LLAMACPP_PARAMETERS;
741
- }
742
- modelRef(config) {
743
- // The engine serves the model under the artifact's advertised id, so the
744
- // registrar publishes that alias and a chat turn routes to it.
745
- const artifact = llamaCppArtifact(llamaCppModelKey(config));
746
- return { id: artifact.modelId, name: artifact.modelId };
747
- }
748
- launchSignature(config = {}) {
749
- // The local plan runs the llama-server binary; the docker plan's host
750
- // process is docker run of the ggml image, so the image name is the
751
- // command-line marker the reserve probe matches on.
752
- const port = this.port(config);
753
- if (this.buildMode(config) === 'docker') {
754
- return { binary: LLAMACPP_DOCKER_IMAGE, port };
755
- }
756
- return { binary: 'llama-server', port };
757
- }
758
- renderLaunch(config, _hw) {
759
- const port = this.port(config);
760
- const modelId = llamaCppModelKey(config);
761
- const artifact = llamaCppArtifact(modelId);
762
- // A model key that names a local GGUF file (a directory-scanned model)
763
- // overrides the artifact map's file, so a file the user has on disk
764
- // launches as-is.
765
- const file = modelId.endsWith('.gguf') ? modelId : artifact.file;
766
- const modelDirectory = typeof config.modelDirectory === 'string' ? config.modelDirectory : '';
767
- const flags = flagsFromSpecs(LLAMACPP_PARAMETERS, config);
768
- if (this.buildMode(config) === 'docker') {
769
- const modelPath = modelDirectory === '' ? file : join(modelDirectory, file);
770
- const containerModelPath = `/models/${basename(file)}`;
771
- const modelMount = modelDirectory === ''
772
- ? `${file}:${containerModelPath}`
773
- : `${dirname(modelPath)}:/models`;
774
- const containerName = `llamacpp-${port}`;
775
- const customArgs = customArgFlags(config);
776
- return {
777
- engineId: this.id,
778
- kind: 'docker',
779
- argv: [
780
- 'docker',
781
- 'run',
782
- '--rm',
783
- '--gpus',
784
- 'all',
785
- '--name',
786
- containerName,
787
- '-p',
788
- `127.0.0.1:${port}:8080`,
789
- '-v',
790
- modelMount,
791
- LLAMACPP_DOCKER_IMAGE,
792
- '--model',
793
- containerModelPath,
794
- ...flags,
795
- ...customArgs,
796
- ],
797
- env: {},
798
- containerName,
799
- modelId,
800
- ...(modelDirectory === '' ? {} : { modelDirectory }),
801
- port,
802
- healthUrl: `http://127.0.0.1:${port}/health`,
803
- };
804
- }
805
- const modelPath = modelDirectory === '' ? file : join(modelDirectory, file);
806
- const customArgs = customArgFlags(config);
807
- return {
808
- engineId: this.id,
809
- kind: 'process',
810
- argv: [
811
- 'llama-server',
812
- '--model',
813
- modelPath,
814
- '--host',
815
- '127.0.0.1',
816
- '--port',
817
- String(port),
818
- ...flags,
819
- ...customArgs,
820
- ],
821
- env: {},
822
- modelId,
823
- ...(modelDirectory === '' ? {} : { modelDirectory }),
824
- port,
825
- healthUrl: `http://127.0.0.1:${port}/health`,
826
- };
827
- }
828
- async deploy(plan, platform, onProgress, options) {
829
- if (plan.kind === 'process') {
830
- const engineDir = platform?.engineDirectory;
831
- let buildLogs = '';
832
- // Auto Rebuild off (the card's checkbox): the git sync (fetch, rebase,
833
- // rebuild) is skipped and the checkout launches as-is.
834
- if (engineDir !== undefined && options?.autoRebuild !== false) {
835
- // The git sync is the checkout download: the card's button reads
836
- // "Downloading..." for it, not "Building...".
837
- onProgress?.('download');
838
- // The conditional rebuild fetches the checkout, prices it against the
839
- // remote branch, and rebuilds only when it is behind (or a fresh
840
- // clone). The decision, the build command, and its output are captured
841
- // into buildLogs and threaded to the card's Logs section.
842
- const sync = await syncAndRebuildEngine(this.host, {
843
- engineId: this.id,
844
- directory: engineDir,
845
- ...(platform?.engineFork !== undefined ? { fork: platform.engineFork } : {}),
846
- ...(plan.argv?.[0] !== undefined ? { binary: plan.argv[0] } : {}),
847
- ...(options?.allowRunDeps !== undefined ? { allowRunDeps: options.allowRunDeps } : {}),
848
- }, () => { });
849
- buildLogs = sync.logs;
850
- if (!sync.ok) {
851
- return { ok: false, detail: sync.detail ?? 'unknown', logs: buildLogs };
852
- }
853
- // The build step reads as "Building..." only when a build actually
854
- // ran (a fresh clone, or a rebase of a checkout that was behind).
855
- if (sync.rebuilt) {
856
- onProgress?.('build');
857
- }
799
+ export async function pollHealth(host, plan, options, handle) {
800
+ return logHealthPoll(options?.onLog, plan.healthUrl, async () => {
801
+ const pollMs = options?.pollMs ?? HEALTH_POLL_DEFAULT_MS;
802
+ const maxAttempts = options?.maxAttempts ?? HEALTH_POLL_MAX_ATTEMPTS;
803
+ let last = { status: 0, ok: false };
804
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
805
+ // The state read runs before the fetch: a child that settled (a crash,
806
+ // a pkill, or a spawn failure) will never report 200, so the poll ends
807
+ // here with a failed result naming the exit.
808
+ if (handle?.hasExited?.() === true) {
809
+ return {
810
+ healthy: false,
811
+ state: 'failed',
812
+ detail: 'the engine process exited before it reported ready',
813
+ };
858
814
  }
859
- const withLogs = (result) => buildLogs !== '' ? { ...result, logs: buildLogs } : result;
860
- onProgress?.('download');
861
- const download = await this.downloadModel(plan);
862
- if (!download.ok)
863
- return withLogs(download);
864
- const handle = this.host.spawn(plan.argv ?? [], plan.env ?? {}, engineDir);
865
- trackChild(this.children, this.planKey(plan), handle);
866
- return withLogs({ ok: true, handle });
867
- }
868
- if (plan.kind === 'docker') {
869
- // The image pull is the docker build step: the card's button reads
870
- // "Building..." for it.
871
- onProgress?.('build');
872
- const pull = await this.host.run(`docker pull ${LLAMACPP_DOCKER_IMAGE}`);
873
- if (!pull.ok) {
874
- return { ok: false, detail: `docker pull failed: ${pull.detail ?? 'unknown'}` };
815
+ last = await host.fetch(plan.healthUrl);
816
+ if (last.status === 200) {
817
+ return { healthy: true, status: 200 };
818
+ }
819
+ if (pollMs > 0 && attempt < maxAttempts) {
820
+ await delay(pollMs);
875
821
  }
876
- const handle = this.host.spawn(plan.argv ?? [], plan.env ?? {});
877
- trackChild(this.children, this.planKey(plan), handle);
878
- return { ok: true, handle };
879
- }
880
- // llama.cpp renders process and docker plans; a compose plan is a no-op.
881
- return { ok: true };
882
- }
883
- async healthCheck(plan, options) {
884
- return pollHealth(this.host, plan, options);
885
- }
886
- async stop(plan) {
887
- if (plan.kind === 'docker' && plan.containerName !== undefined) {
888
- await this.host.run(`docker stop ${plan.containerName}`);
889
- }
890
- const handle = this.children.get(this.planKey(plan));
891
- if (handle !== undefined) {
892
- handle.kill();
893
- this.children.delete(this.planKey(plan));
894
- }
895
- }
896
- buildMode(config) {
897
- return config.buildMode === 'docker' ? 'docker' : 'local';
898
- }
899
- port(config) {
900
- return typeof config.port === 'number' ? config.port : 8080;
901
- }
902
- planKey(plan) {
903
- return `${plan.engineId}:${plan.port}`;
904
- }
905
- async downloadModel(plan) {
906
- const artifact = plan.modelId !== undefined ? LLAMACPP_MODEL_ARTIFACTS[plan.modelId] : undefined;
907
- if (artifact === undefined)
908
- return { ok: true };
909
- const target = plan.modelDirectory ?? '.';
910
- const download = await this.host.run(`hf download ${artifact.repo} ${artifact.file} --local-dir ${target}`);
911
- if (!download.ok) {
912
- return { ok: false, detail: `model download failed: ${download.detail ?? 'unknown'}` };
913
822
  }
914
- return { ok: true };
915
- }
916
- }
917
- /**
918
- * The Strata docker image the adapter pulls and runs. Strata is the
919
- * placeholder engine: the image and the parameter data land when the engine
920
- * is available, and the adapter renders a bare docker plan until then.
921
- */
922
- export const STRATA_DOCKER_IMAGE = 'ghcr.io/strata/strata';
923
- /**
924
- * StrataAdapter is the placeholder docker-only engine. `renderLaunch` renders
925
- * a `docker run` of the Strata image (no spec surface yet, so no flags);
926
- * `deploy` pulls the image and starts the container; `healthCheck` polls the
927
- * health URL; `stop` stops the named container. The parameter surface and the
928
- * per-model data land with the engine, as data edits, never an adapter rewire.
929
- */
930
- export class StrataAdapter extends EngineAdapter {
931
- id = 'strata';
932
- supports = { docker: true, local: false };
933
- host;
934
- children = new Map();
935
- constructor(host = nodeLifecycleHost) {
936
- super();
937
- this.host = host;
938
- }
939
- parameters() {
940
- return STRATA_PARAMETERS;
941
- }
942
- modelRef(_config) {
943
- // Strata serves the model under the name the container passes, and the
944
- // plugin tracks no per-model data for Strata yet, so the engine id is the
945
- // stable entry the registrar publishes until that data lands.
946
- return { id: 'strata', name: 'Strata' };
947
- }
948
- launchSignature(config = {}) {
949
- // Strata is docker-only, so the signature is the image name the docker
950
- // run carries plus the port the plan binds.
951
- return { binary: STRATA_DOCKER_IMAGE, port: this.port(config) };
952
- }
953
- renderLaunch(config, _hw) {
954
- const port = this.port(config);
955
- const containerName = `strata-${port}`;
956
- const customArgs = customArgFlags(config);
957
823
  return {
958
- engineId: this.id,
959
- kind: 'docker',
960
- argv: [
961
- 'docker',
962
- 'run',
963
- '--rm',
964
- '--gpus',
965
- 'all',
966
- '--name',
967
- containerName,
968
- '-p',
969
- `127.0.0.1:${port}:8080`,
970
- STRATA_DOCKER_IMAGE,
971
- ...customArgs,
972
- ],
973
- env: {},
974
- containerName,
975
- port,
976
- healthUrl: `http://127.0.0.1:${port}/health`,
824
+ healthy: false,
825
+ status: last.status,
826
+ detail: `health check did not return 200 within ${maxAttempts} attempts`,
977
827
  };
978
- }
979
- async deploy(plan, _platform, onProgress, _options) {
980
- // The image pull is the docker build step: the card's button reads
981
- // "Building..." for it.
982
- onProgress?.('build');
983
- const pull = await this.host.run(`docker pull ${STRATA_DOCKER_IMAGE}`);
984
- if (!pull.ok) {
985
- return { ok: false, detail: `docker pull failed: ${pull.detail ?? 'unknown'}` };
986
- }
987
- const handle = this.host.spawn(plan.argv ?? [], plan.env ?? {});
988
- trackChild(this.children, this.planKey(plan), handle);
989
- return { ok: true, handle };
990
- }
991
- async healthCheck(plan, options) {
992
- return pollHealth(this.host, plan, options);
993
- }
994
- async stop(plan) {
995
- if (plan.containerName !== undefined) {
996
- await this.host.run(`docker stop ${plan.containerName}`);
997
- }
998
- const handle = this.children.get(this.planKey(plan));
999
- if (handle !== undefined) {
1000
- handle.kill();
1001
- this.children.delete(this.planKey(plan));
1002
- }
1003
- }
1004
- port(config) {
1005
- return typeof config.port === 'number' ? config.port : 8080;
1006
- }
1007
- planKey(plan) {
1008
- return `${plan.engineId}:${plan.port}`;
1009
- }
828
+ });
1010
829
  }
1011
830
  /**
1012
- * InfernixAdapter is the local-only engine for Infernix, the C++/CUDA fork
1013
- * of NInfer (https://github.com/wallawalla47/infernix) tuned for Qwen models
1014
- * on a single RTX 50-series GPU. `renderLaunch` renders a local
1015
- * `infernix-serve` process from the `INFERNIX_PARAMETERS` surface (the
1016
- * Infernix-only fields, from `--ngram-volume` to the quality routes, spell
1017
- * onto the argv only for this engine); `deploy` builds the engine in the
1018
- * engine directory (the engine's own cmake preset), downloads the model
1019
- * artifact (and the n-gram volume, for the multi-file Flash-Next repo) into
1020
- * the model directory, and starts the engine; `healthCheck` polls the health
1021
- * URL; `stop` signals the tracked process. The engine ships no docker image,
1022
- * so the adapter supports the local build mode only.
831
+ * delay waits `ms` milliseconds. The health polls use it between attempts; a
832
+ * poll interval of 0 skips the wait, which the tests use to run the loop
833
+ * without a real sleep.
1023
834
  */
1024
- export class InfernixAdapter extends EngineAdapter {
1025
- id = 'infernix';
1026
- supports = { docker: false, local: true };
1027
- host;
1028
- children = new Map();
1029
- constructor(host = nodeLifecycleHost) {
1030
- super();
1031
- this.host = host;
1032
- }
1033
- parameters() {
1034
- return INFERNIX_PARAMETERS;
1035
- }
1036
- modelRef(config) {
1037
- // The engine serves the model under the artifact's advertised id, so the
1038
- // registrar publishes that alias and a chat turn routes to it. A model
1039
- // key that names a local artifact file (a directory-scanned model)
1040
- // reuses the 27B alias, the same fallback the NInfer adapter applies.
1041
- const artifact = infernixArtifactFor(infernixModelKey(config));
1042
- return { id: artifact.modelId, name: artifact.modelId };
1043
- }
1044
- launchSignature(config = {}) {
1045
- // The local plan runs the infernix-serve binary (the .exe on Windows),
1046
- // so the binary name is the command-line marker the reserve probe
1047
- // matches on, and the port the plan binds rides along.
1048
- return { binary: 'infernix-serve', port: this.port(config) };
1049
- }
1050
- renderLaunch(config, _hw) {
1051
- const port = this.port(config);
1052
- const modelKey = infernixModelKey(config);
1053
- const artifact = infernixArtifactFor(modelKey);
1054
- // A model key that names a local artifact file (a directory-scanned
1055
- // model, a .ninfer or .infernix name) overrides the artifact map's file,
1056
- // so a file the user has on disk launches as-is.
1057
- const file = modelKey.endsWith('.ninfer') || modelKey.endsWith('.infernix')
1058
- ? modelKey
1059
- : artifact.file;
1060
- const modelDirectory = typeof config.modelDirectory === 'string' ? config.modelDirectory : '';
1061
- const modelPath = modelDirectory === '' ? file : join(modelDirectory, file);
1062
- // The engine binary is the .exe on a Windows host, where the published
1063
- // release zip ships the server and its DLLs.
1064
- const binary = process.platform === 'win32' ? 'infernix-serve.exe' : 'infernix-serve';
1065
- const flags = flagsFromSpecs(INFERNIX_PARAMETERS, config);
1066
- // The dflash2 backend always loads a drafter through --draft-model, so the
1067
- // flag is spelled here from the resolved path (the selected DFlash2 preset,
1068
- // or the Custom Draft model field) rather than by a spec toArgv. A config
1069
- // that names no drafter path is the misconfiguration the dflash2
1070
- // guideline names, so the flag is spelled only when a path resolves.
1071
- if (config.spec === 'dflash2') {
1072
- const draftPath = infernixDraftModelPath(config);
1073
- if (draftPath !== null) {
1074
- flags.push('--draft-model', draftPath);
1075
- }
1076
- }
1077
- const customArgs = customArgFlags(config);
1078
- return {
1079
- engineId: this.id,
1080
- kind: 'process',
1081
- argv: [
1082
- binary,
1083
- modelPath,
1084
- '--host',
1085
- '127.0.0.1',
1086
- '--port',
1087
- String(port),
1088
- '--model-id',
1089
- artifact.modelId,
1090
- ...flags,
1091
- ...customArgs,
1092
- ],
1093
- env: {},
1094
- modelId: modelKey,
1095
- ...(modelDirectory === '' ? {} : { modelDirectory }),
1096
- port,
1097
- healthUrl: `http://127.0.0.1:${port}/health`,
1098
- };
1099
- }
1100
- async deploy(plan, platform, onProgress, options) {
1101
- if (plan.kind !== 'process') {
1102
- // Infernix renders process plans only; a docker or compose plan is a
1103
- // no-op.
1104
- return { ok: true };
1105
- }
1106
- const engineDir = platform?.engineDirectory;
1107
- let buildLogs = '';
1108
- // Auto Rebuild off (the card's checkbox): the git sync (fetch, rebase,
1109
- // rebuild) is skipped and the checkout launches as-is.
1110
- if (engineDir !== undefined && options?.autoRebuild !== false) {
1111
- // The git sync is the checkout download: the card's button reads
1112
- // "Downloading..." for it, not "Building...".
1113
- onProgress?.('download');
1114
- // The conditional rebuild fetches the checkout, prices it against the
1115
- // remote branch, and rebuilds only when it is behind (or a fresh
1116
- // clone). The engine's own quick start (the cmake preset, then the
1117
- // preset's build) rides the acquisition-data build steps. The decision,
1118
- // the build command, and its output are captured into buildLogs and
1119
- // threaded to the card's Logs section.
1120
- const sync = await syncAndRebuildEngine(this.host, {
1121
- engineId: this.id,
1122
- directory: engineDir,
1123
- ...(platform?.engineFork !== undefined ? { fork: platform.engineFork } : {}),
1124
- ...(plan.argv?.[0] !== undefined ? { binary: plan.argv[0] } : {}),
1125
- ...(options?.allowRunDeps !== undefined ? { allowRunDeps: options.allowRunDeps } : {}),
1126
- }, () => { });
1127
- buildLogs = sync.logs;
1128
- if (!sync.ok) {
1129
- return { ok: false, detail: sync.detail ?? 'unknown', logs: buildLogs };
1130
- }
1131
- // The build step reads as "Building..." only when a build actually
1132
- // ran (a fresh clone, or a rebase of a checkout that was behind).
1133
- if (sync.rebuilt) {
1134
- onProgress?.('build');
1135
- }
1136
- }
1137
- const withLogs = (result) => buildLogs !== '' ? { ...result, logs: buildLogs } : result;
1138
- onProgress?.('download');
1139
- const download = await this.downloadModel(plan);
1140
- if (!download.ok)
1141
- return withLogs(download);
1142
- const handle = this.host.spawn(plan.argv ?? [], plan.env ?? {}, engineDir);
1143
- this.children.set(this.planKey(plan), handle);
1144
- return withLogs({ ok: true, handle });
1145
- }
1146
- async healthCheck(plan, options) {
1147
- return pollHealth(this.host, plan, options);
1148
- }
1149
- async stop(plan) {
1150
- const handle = this.children.get(this.planKey(plan));
1151
- if (handle !== undefined) {
1152
- handle.kill();
1153
- this.children.delete(this.planKey(plan));
1154
- }
1155
- }
1156
- port(config) {
1157
- // 8080 is the infernix-serve CLI default, so the form seeds it and the
1158
- // adapter falls back to it when a config names no port.
1159
- return typeof config.port === 'number' ? config.port : 8080;
1160
- }
1161
- planKey(plan) {
1162
- return `${plan.engineId}:${plan.port}`;
1163
- }
1164
- async downloadModel(plan) {
1165
- const artifact = plan.modelId !== undefined ? INFERNIX_MODEL_ARTIFACTS[plan.modelId] : undefined;
1166
- // A model key that names a local artifact file carries no artifact entry,
1167
- // so the file the user has on disk launches as-is without a download.
1168
- if (artifact === undefined)
1169
- return { ok: true };
1170
- const target = plan.modelDirectory ?? '.';
1171
- // A multi-file artifact (the Flash-Next shards) downloads the whole repo,
1172
- // which carries the n-gram volume beside the shards, so the
1173
- // --ngram-volume flag points at a file the same download left on disk.
1174
- // A single-file artifact downloads the file alone.
1175
- const repo = artifact.repo;
1176
- const files = artifact.files ?? [];
1177
- const download = files.length > 1
1178
- ? await this.host.run(`hf download ${repo} --local-dir ${target}`)
1179
- : await this.host.run(`hf download ${repo} ${artifact.file} --local-dir ${target}`);
1180
- if (!download.ok) {
1181
- return { ok: false, detail: `model download failed: ${download.detail ?? 'unknown'}` };
1182
- }
1183
- return { ok: true };
1184
- }
835
+ export function delay(ms) {
836
+ return new Promise(resolve => {
837
+ setTimeout(resolve, ms);
838
+ });
1185
839
  }
1186
- //# sourceMappingURL=adapters.js.map
840
+ //# sourceMappingURL=adapters.js.map