dsh-plugin-local-infer 0.1.3 → 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 (250) hide show
  1. package/README.md +22 -4
  2. package/cordis.patch.yml +7 -0
  3. package/docs/zh/README.md +14 -2
  4. package/dsh/client.js +4195 -4522
  5. package/package.json +28 -11
  6. package/packages/core/lib/adapter.d.ts +133 -9
  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 +44 -17
  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 +20 -9
  41. package/packages/core/lib/registrar.js +27 -7
  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 +92 -14
  59. package/packages/providers/lib/acquire.js +215 -37
  60. package/packages/providers/lib/adapters.d.ts +136 -12
  61. package/packages/providers/lib/adapters.js +597 -70
  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 +27 -230
  65. package/packages/providers/lib/deps.js +61 -214
  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/engines/{hyperqwen.d.ts → hyperqwen/hyperqwen.d.ts} +2 -2
  79. package/packages/providers/lib/engines/{hyperqwen.js → hyperqwen/hyperqwen.js} +14 -2
  80. package/packages/providers/lib/engines/{hyperqwenAdapter.d.ts → hyperqwen/hyperqwenAdapter.d.ts} +6 -6
  81. package/packages/providers/lib/engines/{hyperqwenAdapter.js → hyperqwen/hyperqwenAdapter.js} +24 -14
  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/engines/{infernix.d.ts → infernix/infernix.d.ts} +7 -4
  95. package/packages/providers/lib/engines/{infernix.js → infernix/infernix.js} +52 -7
  96. package/packages/providers/lib/engines/{infernixAdapter.d.ts → infernix/infernixAdapter.d.ts} +5 -6
  97. package/packages/providers/lib/engines/{infernixAdapter.js → infernix/infernixAdapter.js} +75 -42
  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/engines/{llamacpp.d.ts → llamacpp/llamacpp.d.ts} +2 -2
  111. package/packages/providers/lib/engines/{llamacpp.js → llamacpp/llamacpp.js} +12 -2
  112. package/packages/providers/lib/engines/{llamacppAdapter.d.ts → llamacpp/llamacppAdapter.d.ts} +5 -6
  113. package/packages/providers/lib/engines/{llamacppAdapter.js → llamacpp/llamacppAdapter.js} +87 -45
  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/engines/{ninfer.d.ts → ninfer/ninfer.d.ts} +2 -2
  127. package/packages/providers/lib/engines/{ninfer.js → ninfer/ninfer.js} +24 -2
  128. package/packages/providers/lib/engines/{ninferAdapter.d.ts → ninfer/ninferAdapter.d.ts} +5 -6
  129. package/packages/providers/lib/engines/{ninferAdapter.js → ninfer/ninferAdapter.js} +123 -55
  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.js → strata/strata.js} +70 -3
  144. package/packages/providers/lib/engines/{strataAdapter.d.ts → strata/strataAdapter.d.ts} +5 -6
  145. package/packages/providers/lib/engines/{strataAdapter.js → strata/strataAdapter.js} +147 -111
  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 +7 -5
  159. package/packages/providers/lib/engines.js +21 -10
  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 -29
  163. package/packages/providers/lib/index.js +45 -33
  164. package/packages/providers/lib/model-defaults.d.ts +5 -1
  165. package/packages/providers/lib/model-defaults.js +25 -1
  166. package/packages/providers/lib/platform.d.ts +8 -5
  167. package/packages/providers/lib/platform.js +23 -9
  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 +176 -21
  176. package/packages/service/lib/index.js +589 -86
  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 +163 -6
  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 +12 -2
  188. package/packages/ui/lib/config.js +18 -2
  189. package/packages/ui/lib/deps.d.ts +1 -1
  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 +142 -34
  198. package/packages/ui/lib/ops/engine.js +469 -265
  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 +26 -11
  202. package/packages/ui/lib/ops/hf.js +115 -25
  203. package/packages/ui/lib/ops/logs.d.ts +70 -15
  204. package/packages/ui/lib/ops/logs.js +96 -35
  205. package/packages/ui/lib/ops/model.d.ts +2 -10
  206. package/packages/ui/lib/ops/model.js +9 -16
  207. package/packages/ui/lib/request.d.ts +1 -1
  208. package/packages/ui/lib/request.js +1 -1
  209. package/packages/ui/lib/route.d.ts +22 -6
  210. package/packages/ui/lib/route.js +32 -9
  211. package/packages/ui/lib/routes/chat-template.d.ts +1 -14
  212. package/packages/ui/lib/routes/chat-template.js +1 -37
  213. package/packages/ui/lib/routes/deps.d.ts +1 -16
  214. package/packages/ui/lib/routes/deps.js +1 -27
  215. package/packages/ui/lib/routes/download.d.ts +1 -16
  216. package/packages/ui/lib/routes/download.js +1 -48
  217. package/packages/ui/lib/routes/get.d.ts +1 -18
  218. package/packages/ui/lib/routes/get.js +66 -17
  219. package/packages/ui/lib/routes/install.d.ts +1 -1
  220. package/packages/ui/lib/routes/install.js +1 -1
  221. package/packages/ui/lib/routes/launch.d.ts +13 -7
  222. package/packages/ui/lib/routes/launch.js +33 -9
  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 +1 -1
  226. package/packages/ui/lib/routes/provider-delete.js +1 -1
  227. package/packages/ui/lib/routes/register.d.ts +1 -1
  228. package/packages/ui/lib/routes/register.js +1 -1
  229. package/packages/ui/lib/routes/save.d.ts +1 -1
  230. package/packages/ui/lib/routes/save.js +22 -2
  231. package/packages/ui/lib/routes/stop.d.ts +7 -4
  232. package/packages/ui/lib/routes/stop.js +7 -4
  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 +1 -1
  236. package/packages/ui/lib/routes/summary.js +1 -1
  237. package/packages/ui/lib/routes/unregister.d.ts +1 -1
  238. package/packages/ui/lib/routes/unregister.js +1 -1
  239. package/packages/ui/lib/viewmodels/deps.d.ts +1 -31
  240. package/packages/ui/lib/viewmodels/deps.js +1 -89
  241. package/packages/ui/lib/viewmodels/depsHelpers.d.ts +1 -28
  242. package/packages/ui/lib/viewmodels/depsHelpers.js +72 -11
  243. package/packages/ui/lib/viewmodels/summary.d.ts +7 -2
  244. package/packages/ui/lib/viewmodels/summary.js +55 -35
  245. package/packages/ui/lib/viewmodels/summaryHelpers.d.ts +1 -1
  246. package/packages/ui/lib/viewmodels/summaryHelpers.js +1 -1
  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/engines/strata.d.ts +0 -53
@@ -1,24 +1,53 @@
1
1
  /**
2
2
  * The shared engine-adapter helpers: the spec-to-argv and spec-to-env
3
- * translators the concrete adapters (the `engines/*Adapter.ts` files) compose
3
+ * translators the concrete adapters (the `engines/<engine>/<engine>Adapter.ts`
4
+ * files) compose
4
5
  * into a `LaunchPlan`, the card's Custom Arguments translators, the
5
6
  * `LifecycleHost` the deploy, health-check, and stop steps drive, and the
6
7
  * health poll. The node implementation (`nodeLifecycleHost`) is what the
7
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 { 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';
16
+ /**
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.
22
+ */
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];
32
+ }
11
33
  /**
12
34
  * flagsFromSpecs turns a spec surface and a config into the CLI flag list: each
13
35
  * spec's `toArgv` runs over the config value (falling back to the spec
14
36
  * default), and empty results are skipped. A spec with no `toArgv` contributes
15
- * 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.
16
42
  */
17
43
  export function flagsFromSpecs(specs, config) {
18
44
  const flags = [];
19
45
  for (const spec of specs) {
20
46
  if (spec.toArgv === undefined)
21
47
  continue;
48
+ const targets = deployTargets(spec);
49
+ if (targets.length > 0 && !targets.some((target) => target.target === 'cli'))
50
+ continue;
22
51
  const value = config[spec.key] ?? spec.defaultValue;
23
52
  if (value === undefined)
24
53
  continue;
@@ -81,14 +110,21 @@ export function customArgEnv(config) {
81
110
  }
82
111
  /**
83
112
  * envFromSpecs turns a spec surface and a config into the environment map: each
84
- * spec's `toEnv` runs over the config value (falling back to the spec
85
- * 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.
86
119
  */
87
120
  export function envFromSpecs(specs, config) {
88
121
  const env = {};
89
122
  for (const spec of specs) {
90
123
  if (spec.toEnv === undefined)
91
124
  continue;
125
+ const targets = deployTargets(spec);
126
+ if (targets.length > 0 && !targets.some((target) => target.target === 'env'))
127
+ continue;
92
128
  const value = config[spec.key] ?? spec.defaultValue;
93
129
  if (value === undefined)
94
130
  continue;
@@ -97,65 +133,436 @@ export function envFromSpecs(specs, config) {
97
133
  }
98
134
  return env;
99
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
+ }
100
388
  /**
101
389
  * createNodeLifecycleHost builds the node `LifecycleHost`: `run` through the
102
390
  * system shell (a 10-minute cap, so a hung CUDA build cannot hang the panel),
103
- * `spawn` a detached child the stop step can signal, and `fetch` the health
104
- * 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,
105
394
  * which the poll treats as not-ready. When `debug` is provided, the host logs
106
395
  * every command it issues (each `run` line and each `spawn` argv) through it,
107
- * 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.
108
405
  */
109
- export function createNodeLifecycleHost(debug) {
406
+ export function createNodeLifecycleHost(debug, sudo) {
110
407
  // The debug logger is a no-op outside debug mode, so the guard reads as
111
408
  // "log when a real sink is wired" and the command path stays quiet otherwise.
112
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
+ };
113
429
  return {
114
- async run(command) {
430
+ async run(command, options) {
115
431
  log?.(`run: ${command}`);
116
- return new Promise(resolve => {
117
- exec(command, { timeout: 600_000 }, (error, stdout, stderr) => {
118
- if (error === null) {
119
- resolve({ ok: true });
120
- return;
121
- }
122
- const detail = `${stderr ?? ''}${stdout ?? ''}${String(error)}`.trim().slice(-1024);
123
- resolve({ ok: false, detail });
124
- });
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);
125
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
+ }
126
476
  },
127
- async runStream(command, onOutput) {
477
+ async runStream(command, onOutput, options) {
128
478
  log?.(`run: ${command}`);
129
- return new Promise(resolve => {
130
- // No overall timeout: a full CUDA build legitimately runs long, and
131
- // the card prices a stalled step with its own output-idle watchdog.
132
- const child = nodeSpawn(command, { shell: true, stdio: ['ignore', 'pipe', 'pipe'] });
133
- let output = '';
134
- const append = (chunk) => {
135
- const text = chunk.toString('utf8');
136
- onOutput(text);
137
- output += text;
138
- if (output.length > 2048) {
139
- output = output.slice(-1024);
140
- }
141
- };
142
- child.stdout.on('data', append);
143
- child.stderr.on('data', append);
144
- child.on('error', error => {
145
- resolve({ ok: false, detail: error.message });
146
- });
147
- child.on('close', code => {
148
- if (code === 0) {
149
- resolve({ ok: true });
150
- return;
151
- }
152
- resolve({ ok: false, detail: `exit ${code}: ${output.trim().slice(-1024)}` });
153
- });
479
+ let child;
480
+ const guard = await guardSudo(command, options, () => {
481
+ if (child !== undefined)
482
+ signalChild(child, options?.graceMs);
154
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
+ }
155
543
  },
156
544
  spawn(argv, env, cwd) {
157
545
  log?.(`spawn: ${argv.join(' ')}${cwd !== undefined ? ` (cwd: ${cwd})` : ''}`);
158
- 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), {
159
566
  env: { ...process.env, ...env },
160
567
  cwd,
161
568
  detached: true,
@@ -170,7 +577,7 @@ export function createNodeLifecycleHost(debug) {
170
577
  // bound; the tail is what the log file and the card keep.
171
578
  let logs = '';
172
579
  const append = (chunk) => {
173
- logs = (logs + chunk.toString('utf8')).slice(-65536);
580
+ logs = (logs + chunk.toString('utf8')).slice(-LOG_TAIL_BYTES);
174
581
  };
175
582
  child.stdout?.on('data', append);
176
583
  child.stderr?.on('data', append);
@@ -180,10 +587,10 @@ export function createNodeLifecycleHost(debug) {
180
587
  // breaks on a crashing child both emit 'error' here, so every path gets
181
588
  // a listener and the failure lands in the logs instead of the crash.
182
589
  child.stdout?.on('error', (error) => {
183
- logs = (logs + `stdout error: ${error.message}\n`).slice(-65536);
590
+ logs = (logs + `stdout error: ${error.message}\n`).slice(-LOG_TAIL_BYTES);
184
591
  });
185
592
  child.stderr?.on('error', (error) => {
186
- logs = (logs + `stderr error: ${error.message}\n`).slice(-65536);
593
+ logs = (logs + `stderr error: ${error.message}\n`).slice(-LOG_TAIL_BYTES);
187
594
  });
188
595
  // The one-shot exit contract: 'error' names a child that never ran,
189
596
  // 'exit' carries the code or signal, and 'close' follows once the pipes
@@ -208,7 +615,7 @@ export function createNodeLifecycleHost(debug) {
208
615
  };
209
616
  let exitSignal = null;
210
617
  child.on('error', (error) => {
211
- logs = (logs + `spawn error: ${error.message}\n`).slice(-65536);
618
+ logs = (logs + `spawn error: ${error.message}\n`).slice(-LOG_TAIL_BYTES);
212
619
  // The error fires before the stdio drains; settle immediately so a
213
620
  // launch of a missing binary reports rather than hangs the watcher.
214
621
  settle({ code: null, signal: null, error: error.message });
@@ -237,8 +644,19 @@ export function createNodeLifecycleHost(debug) {
237
644
  }
238
645
  listeners.add(listener);
239
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,
240
651
  };
241
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
+ },
242
660
  async fetch(url) {
243
661
  try {
244
662
  const response = await fetch(url);
@@ -276,29 +694,138 @@ export function trackChild(children, key, handle) {
276
694
  }
277
695
  });
278
696
  }
697
+ /**
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.
704
+ */
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;
715
+ }
716
+ /**
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.
727
+ */
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;
737
+ }
738
+ /**
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.
748
+ */
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'}`);
756
+ }
757
+ }
758
+ /**
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.
763
+ */
764
+ export const HEALTH_POLL_DEFAULT_MS = 1_000;
765
+ export const HEALTH_POLL_MAX_ATTEMPTS = 120;
766
+ /**
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.
774
+ */
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;
786
+ }
279
787
  /**
280
788
  * pollHealth polls a plan's health URL until a 200 or the attempt cap, the
281
789
  * same loop the qwen-ninfer launcher runs. A poll interval of 0 skips the
282
- * delay, which the tests use to run the loop without a real sleep.
283
- */
284
- export async function pollHealth(host, plan, options) {
285
- const pollMs = options?.pollMs ?? 1000;
286
- const maxAttempts = options?.maxAttempts ?? 120;
287
- let last = { status: 0, ok: false };
288
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
289
- last = await host.fetch(plan.healthUrl);
290
- if (last.status === 200) {
291
- return { healthy: true, status: 200 };
292
- }
293
- if (pollMs > 0 && attempt < maxAttempts) {
294
- await delay(pollMs);
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.
798
+ */
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
+ };
814
+ }
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);
821
+ }
295
822
  }
296
- }
297
- return {
298
- healthy: false,
299
- status: last.status,
300
- detail: `health check did not return 200 within ${maxAttempts} attempts`,
301
- };
823
+ return {
824
+ healthy: false,
825
+ status: last.status,
826
+ detail: `health check did not return 200 within ${maxAttempts} attempts`,
827
+ };
828
+ });
302
829
  }
303
830
  /**
304
831
  * delay waits `ms` milliseconds. The health polls use it between attempts; a
@@ -310,4 +837,4 @@ export function delay(ms) {
310
837
  setTimeout(resolve, ms);
311
838
  });
312
839
  }
313
- //# sourceMappingURL=adapters.js.map
840
+ //# sourceMappingURL=adapters.js.map