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.
- package/README.md +32 -6
- package/cordis.patch.yml +7 -0
- package/docs/zh/README.md +24 -4
- package/dsh/client.js +4200 -4049
- package/package.json +33 -11
- package/packages/core/lib/adapter.d.ts +177 -11
- package/packages/core/lib/adapter.js +74 -1
- package/packages/core/lib/config.d.ts +1 -1
- package/packages/core/lib/config.js +1 -1
- package/packages/core/lib/debug.d.ts +1 -1
- package/packages/core/lib/debug.js +1 -1
- package/packages/core/lib/defaults.d.ts +1 -1
- package/packages/core/lib/defaults.js +1 -1
- package/packages/core/lib/deps.d.ts +48 -20
- package/packages/core/lib/deps.js +24 -8
- package/packages/core/lib/estimator.d.ts +1 -1
- package/packages/core/lib/estimator.js +1 -1
- package/packages/core/lib/guideline.d.ts +1 -1
- package/packages/core/lib/guideline.js +1 -1
- package/packages/core/lib/hardware.d.ts +1 -1
- package/packages/core/lib/hardware.js +1 -1
- package/packages/core/lib/hf-cache.d.ts +70 -0
- package/packages/core/lib/hf-cache.js +83 -0
- package/packages/core/lib/index.d.ts +16 -9
- package/packages/core/lib/index.js +10 -6
- package/packages/core/lib/infer-cache.d.ts +39 -0
- package/packages/core/lib/infer-cache.js +53 -0
- package/packages/core/lib/launch-command.d.ts +8 -4
- package/packages/core/lib/launch-command.js +13 -6
- package/packages/core/lib/launch-record.d.ts +1 -1
- package/packages/core/lib/launch-record.js +1 -1
- package/packages/core/lib/logs.d.ts +8 -1
- package/packages/core/lib/logs.js +12 -4
- package/packages/core/lib/models.d.ts +120 -14
- package/packages/core/lib/models.js +209 -17
- package/packages/core/lib/parameter.d.ts +31 -3
- package/packages/core/lib/parameter.js +1 -1
- package/packages/core/lib/progress.d.ts +54 -0
- package/packages/core/lib/progress.js +120 -0
- package/packages/core/lib/registrar.d.ts +25 -14
- package/packages/core/lib/registrar.js +30 -10
- package/packages/core/lib/reserve.d.ts +11 -3
- package/packages/core/lib/reserve.js +6 -4
- package/packages/core/lib/status.d.ts +12 -6
- package/packages/core/lib/status.js +21 -10
- package/packages/core/lib/sudo.d.ts +80 -0
- package/packages/core/lib/sudo.js +78 -0
- package/packages/core/package.json +1 -1
- package/packages/orchestrator/lib/config.d.ts +63 -0
- package/packages/orchestrator/lib/config.js +111 -0
- package/packages/orchestrator/lib/index.d.ts +51 -0
- package/packages/orchestrator/lib/index.js +73 -0
- package/packages/orchestrator/lib/route.d.ts +60 -0
- package/packages/orchestrator/lib/route.js +159 -0
- package/packages/orchestrator/locale/en.json +6 -0
- package/packages/orchestrator/package.json +39 -0
- package/packages/orchestrator/prompt.md +87 -0
- package/packages/providers/lib/acquire.d.ts +95 -15
- package/packages/providers/lib/acquire.js +239 -40
- package/packages/providers/lib/adapters.d.ts +155 -221
- package/packages/providers/lib/adapters.js +589 -935
- package/packages/providers/lib/debug.d.ts +1 -1
- package/packages/providers/lib/debug.js +1 -1
- package/packages/providers/lib/deps.d.ts +31 -212
- package/packages/providers/lib/deps.js +71 -205
- package/packages/providers/lib/engines/basalt/basalt.d.ts +130 -0
- package/packages/providers/lib/engines/basalt/basalt.js +304 -0
- package/packages/providers/lib/engines/basalt/basaltAdapter.d.ts +85 -0
- package/packages/providers/lib/engines/basalt/basaltAdapter.js +439 -0
- package/packages/providers/lib/engines/basalt/basaltLinux.d.ts +23 -0
- package/packages/providers/lib/engines/basalt/basaltLinux.js +47 -0
- package/packages/providers/lib/engines/basalt/basaltMacOS.d.ts +17 -0
- package/packages/providers/lib/engines/basalt/basaltMacOS.js +19 -0
- package/packages/providers/lib/engines/basalt/basaltWindows.d.ts +24 -0
- package/packages/providers/lib/engines/basalt/basaltWindows.js +49 -0
- package/packages/providers/lib/engines/engineOs.d.ts +24 -0
- package/packages/providers/lib/engines/engineOs.js +17 -0
- package/packages/providers/lib/{hyperqwen.d.ts → engines/hyperqwen/hyperqwen.d.ts} +2 -2
- package/packages/providers/lib/{hyperqwen.js → engines/hyperqwen/hyperqwen.js} +14 -2
- package/packages/providers/lib/engines/hyperqwen/hyperqwenAdapter.d.ts +38 -0
- package/packages/providers/lib/engines/hyperqwen/hyperqwenAdapter.js +115 -0
- package/packages/providers/lib/engines/hyperqwen/hyperqwenArch.d.ts +9 -0
- package/packages/providers/lib/engines/hyperqwen/hyperqwenArch.js +9 -0
- package/packages/providers/lib/engines/hyperqwen/hyperqwenDebian.d.ts +9 -0
- package/packages/providers/lib/engines/hyperqwen/hyperqwenDebian.js +9 -0
- package/packages/providers/lib/engines/hyperqwen/hyperqwenFedora.d.ts +9 -0
- package/packages/providers/lib/engines/hyperqwen/hyperqwenFedora.js +9 -0
- package/packages/providers/lib/engines/hyperqwen/hyperqwenLinux.d.ts +10 -0
- package/packages/providers/lib/engines/hyperqwen/hyperqwenLinux.js +14 -0
- package/packages/providers/lib/engines/hyperqwen/hyperqwenMacOS.d.ts +9 -0
- package/packages/providers/lib/engines/hyperqwen/hyperqwenMacOS.js +9 -0
- package/packages/providers/lib/engines/hyperqwen/hyperqwenWindows.d.ts +9 -0
- package/packages/providers/lib/engines/hyperqwen/hyperqwenWindows.js +9 -0
- package/packages/providers/lib/{infernix.d.ts → engines/infernix/infernix.d.ts} +7 -4
- package/packages/providers/lib/{infernix.js → engines/infernix/infernix.js} +53 -8
- package/packages/providers/lib/engines/infernix/infernixAdapter.d.ts +52 -0
- package/packages/providers/lib/engines/infernix/infernixAdapter.js +235 -0
- package/packages/providers/lib/engines/infernix/infernixArch.d.ts +9 -0
- package/packages/providers/lib/engines/infernix/infernixArch.js +9 -0
- package/packages/providers/lib/engines/infernix/infernixDebian.d.ts +9 -0
- package/packages/providers/lib/engines/infernix/infernixDebian.js +9 -0
- package/packages/providers/lib/engines/infernix/infernixFedora.d.ts +9 -0
- package/packages/providers/lib/engines/infernix/infernixFedora.js +9 -0
- package/packages/providers/lib/engines/infernix/infernixLinux.d.ts +17 -0
- package/packages/providers/lib/engines/infernix/infernixLinux.js +35 -0
- package/packages/providers/lib/engines/infernix/infernixMacOS.d.ts +16 -0
- package/packages/providers/lib/engines/infernix/infernixMacOS.js +18 -0
- package/packages/providers/lib/engines/infernix/infernixWindows.d.ts +16 -0
- package/packages/providers/lib/engines/infernix/infernixWindows.js +34 -0
- package/packages/providers/lib/{llamacpp.d.ts → engines/llamacpp/llamacpp.d.ts} +2 -2
- package/packages/providers/lib/{llamacpp.js → engines/llamacpp/llamacpp.js} +12 -2
- package/packages/providers/lib/engines/llamacpp/llamacppAdapter.d.ts +50 -0
- package/packages/providers/lib/engines/llamacpp/llamacppAdapter.js +274 -0
- package/packages/providers/lib/engines/llamacpp/llamacppArch.d.ts +9 -0
- package/packages/providers/lib/engines/llamacpp/llamacppArch.js +9 -0
- package/packages/providers/lib/engines/llamacpp/llamacppDebian.d.ts +9 -0
- package/packages/providers/lib/engines/llamacpp/llamacppDebian.js +9 -0
- package/packages/providers/lib/engines/llamacpp/llamacppFedora.d.ts +9 -0
- package/packages/providers/lib/engines/llamacpp/llamacppFedora.js +9 -0
- package/packages/providers/lib/engines/llamacpp/llamacppLinux.d.ts +17 -0
- package/packages/providers/lib/engines/llamacpp/llamacppLinux.js +28 -0
- package/packages/providers/lib/engines/llamacpp/llamacppMacOS.d.ts +15 -0
- package/packages/providers/lib/engines/llamacpp/llamacppMacOS.js +17 -0
- package/packages/providers/lib/engines/llamacpp/llamacppWindows.d.ts +15 -0
- package/packages/providers/lib/engines/llamacpp/llamacppWindows.js +26 -0
- package/packages/providers/lib/{ninfer.d.ts → engines/ninfer/ninfer.d.ts} +2 -2
- package/packages/providers/lib/{ninfer.js → engines/ninfer/ninfer.js} +25 -3
- package/packages/providers/lib/engines/ninfer/ninferAdapter.d.ts +81 -0
- package/packages/providers/lib/engines/ninfer/ninferAdapter.js +392 -0
- package/packages/providers/lib/engines/ninfer/ninferArch.d.ts +9 -0
- package/packages/providers/lib/engines/ninfer/ninferArch.js +9 -0
- package/packages/providers/lib/engines/ninfer/ninferDebian.d.ts +9 -0
- package/packages/providers/lib/engines/ninfer/ninferDebian.js +9 -0
- package/packages/providers/lib/engines/ninfer/ninferFedora.d.ts +9 -0
- package/packages/providers/lib/engines/ninfer/ninferFedora.js +9 -0
- package/packages/providers/lib/engines/ninfer/ninferLinux.d.ts +17 -0
- package/packages/providers/lib/engines/ninfer/ninferLinux.js +37 -0
- package/packages/providers/lib/engines/ninfer/ninferMacOS.d.ts +15 -0
- package/packages/providers/lib/engines/ninfer/ninferMacOS.js +17 -0
- package/packages/providers/lib/engines/ninfer/ninferWindows.d.ts +29 -0
- package/packages/providers/lib/engines/ninfer/ninferWindows.js +59 -0
- package/packages/providers/lib/engines/strata/strata.d.ts +1 -0
- package/packages/providers/lib/engines/strata/strata.js +264 -0
- package/packages/providers/lib/engines/strata/strataAdapter.d.ts +97 -0
- package/packages/providers/lib/engines/strata/strataAdapter.js +463 -0
- package/packages/providers/lib/engines/strata/strataArch.d.ts +9 -0
- package/packages/providers/lib/engines/strata/strataArch.js +9 -0
- package/packages/providers/lib/engines/strata/strataDebian.d.ts +9 -0
- package/packages/providers/lib/engines/strata/strataDebian.js +9 -0
- package/packages/providers/lib/engines/strata/strataFedora.d.ts +9 -0
- package/packages/providers/lib/engines/strata/strataFedora.js +9 -0
- package/packages/providers/lib/engines/strata/strataLinux.d.ts +21 -0
- package/packages/providers/lib/engines/strata/strataLinux.js +30 -0
- package/packages/providers/lib/engines/strata/strataMacOS.d.ts +16 -0
- package/packages/providers/lib/engines/strata/strataMacOS.js +17 -0
- package/packages/providers/lib/engines/strata/strataWindows.d.ts +27 -0
- package/packages/providers/lib/engines/strata/strataWindows.js +37 -0
- package/packages/providers/lib/engines.d.ts +8 -5
- package/packages/providers/lib/engines.js +22 -11
- package/packages/providers/lib/guidelines.d.ts +5 -4
- package/packages/providers/lib/guidelines.js +18 -5
- package/packages/providers/lib/index.d.ts +33 -24
- package/packages/providers/lib/index.js +46 -24
- package/packages/providers/lib/model-defaults.d.ts +10 -5
- package/packages/providers/lib/model-defaults.js +47 -5
- package/packages/providers/lib/platform.d.ts +8 -5
- package/packages/providers/lib/platform.js +27 -7
- package/packages/providers/lib/presets.d.ts +6 -4
- package/packages/providers/lib/presets.js +53 -5
- package/packages/providers/lib/systemDeps.d.ts +313 -0
- package/packages/providers/lib/systemDeps.js +284 -0
- package/packages/providers/lib/vram.d.ts +34 -8
- package/packages/providers/lib/vram.js +81 -8
- package/packages/providers/package.json +2 -2
- package/packages/service/lib/index.d.ts +187 -23
- package/packages/service/lib/index.js +667 -90
- package/packages/service/lib/launch-target.d.ts +19 -0
- package/packages/service/lib/launch-target.js +28 -0
- package/packages/service/lib/run-logs.d.ts +72 -0
- package/packages/service/lib/run-logs.js +125 -0
- package/packages/service/lib/types.d.ts +166 -8
- package/packages/service/lib/types.js +1 -1
- package/packages/service/package.json +2 -2
- package/packages/tool/lib/index.d.ts +1 -1
- package/packages/tool/lib/index.js +1 -1
- package/packages/tool/package.json +2 -2
- package/packages/ui/lib/config.d.ts +89 -0
- package/packages/ui/lib/config.js +251 -0
- package/packages/ui/lib/deps.d.ts +4 -4
- package/packages/ui/lib/deps.js +1 -1
- package/packages/ui/lib/form.d.ts +1 -1
- package/packages/ui/lib/form.js +1 -1
- package/packages/ui/lib/index.d.ts +7 -1
- package/packages/ui/lib/index.js +12 -2
- package/packages/ui/lib/meter.d.ts +1 -1
- package/packages/ui/lib/meter.js +1 -1
- package/packages/ui/lib/ops/engine.d.ts +216 -0
- package/packages/ui/lib/ops/engine.js +697 -0
- package/packages/ui/lib/ops/get-steps.d.ts +98 -0
- package/packages/ui/lib/ops/get-steps.js +359 -0
- package/packages/ui/lib/ops/hf.d.ts +42 -0
- package/packages/ui/lib/ops/hf.js +150 -0
- package/packages/ui/lib/ops/logs.d.ts +78 -0
- package/packages/ui/lib/ops/logs.js +119 -0
- package/packages/ui/lib/ops/model.d.ts +71 -0
- package/packages/ui/lib/ops/model.js +123 -0
- package/packages/ui/lib/request.d.ts +106 -0
- package/packages/ui/lib/request.js +80 -0
- package/packages/ui/lib/route.d.ts +106 -422
- package/packages/ui/lib/route.js +136 -1845
- package/packages/ui/lib/routes/chat-template.d.ts +1 -0
- package/packages/ui/lib/routes/chat-template.js +1 -0
- package/packages/ui/lib/routes/deps.d.ts +1 -0
- package/packages/ui/lib/routes/deps.js +1 -0
- package/packages/ui/lib/routes/download.d.ts +1 -0
- package/packages/ui/lib/routes/download.js +1 -0
- package/packages/ui/lib/routes/get.d.ts +1 -0
- package/packages/ui/lib/routes/get.js +106 -0
- package/packages/ui/lib/routes/install.d.ts +16 -0
- package/packages/ui/lib/routes/install.js +36 -0
- package/packages/ui/lib/routes/launch.d.ts +24 -0
- package/packages/ui/lib/routes/launch.js +74 -0
- package/packages/ui/lib/routes/logs.d.ts +57 -0
- package/packages/ui/lib/routes/logs.js +117 -0
- package/packages/ui/lib/routes/provider-delete.d.ts +15 -0
- package/packages/ui/lib/routes/provider-delete.js +37 -0
- package/packages/ui/lib/routes/register.d.ts +16 -0
- package/packages/ui/lib/routes/register.js +28 -0
- package/packages/ui/lib/routes/save.d.ts +18 -0
- package/packages/ui/lib/routes/save.js +159 -0
- package/packages/ui/lib/routes/stop.d.ts +19 -0
- package/packages/ui/lib/routes/stop.js +32 -0
- package/packages/ui/lib/routes/stream.d.ts +40 -0
- package/packages/ui/lib/routes/stream.js +68 -0
- package/packages/ui/lib/routes/summary.d.ts +15 -0
- package/packages/ui/lib/routes/summary.js +26 -0
- package/packages/ui/lib/routes/unregister.d.ts +14 -0
- package/packages/ui/lib/routes/unregister.js +26 -0
- package/packages/ui/lib/viewmodels/deps.d.ts +1 -0
- package/packages/ui/lib/viewmodels/deps.js +1 -0
- package/packages/ui/lib/viewmodels/depsHelpers.d.ts +1 -0
- package/packages/ui/lib/viewmodels/depsHelpers.js +94 -0
- package/packages/ui/lib/viewmodels/summary.d.ts +145 -0
- package/packages/ui/lib/viewmodels/summary.js +245 -0
- package/packages/ui/lib/viewmodels/summaryHelpers.d.ts +30 -0
- package/packages/ui/lib/viewmodels/summaryHelpers.js +42 -0
- package/packages/ui/lib/warnings.d.ts +1 -1
- package/packages/ui/lib/warnings.js +1 -1
- package/packages/ui/package.json +4 -4
- package/packages/providers/lib/strata.d.ts +0 -15
- package/packages/providers/lib/strata.js +0 -14
|
@@ -1,72 +1,53 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* (`nodeLifecycleHost`) is what the
|
|
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 {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
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
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
*
|
|
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,
|
|
152
|
-
*
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
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(-
|
|
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(-
|
|
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(-
|
|
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(-
|
|
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
|
-
*
|
|
329
|
-
*
|
|
330
|
-
*
|
|
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
|
|
333
|
-
const
|
|
334
|
-
const
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
*
|
|
364
|
-
*
|
|
365
|
-
*
|
|
366
|
-
*
|
|
367
|
-
*
|
|
368
|
-
*
|
|
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
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
-
*
|
|
617
|
-
*
|
|
618
|
-
*
|
|
619
|
-
*
|
|
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
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
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
|
|
710
|
-
*
|
|
711
|
-
*
|
|
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
|
|
764
|
+
export const HEALTH_POLL_DEFAULT_MS = 1_000;
|
|
765
|
+
export const HEALTH_POLL_MAX_ATTEMPTS = 120;
|
|
714
766
|
/**
|
|
715
|
-
*
|
|
716
|
-
*
|
|
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
|
|
719
|
-
|
|
720
|
-
|
|
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
|
-
*
|
|
724
|
-
*
|
|
725
|
-
*
|
|
726
|
-
*
|
|
727
|
-
*
|
|
728
|
-
*
|
|
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
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
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
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
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
|
-
|
|
959
|
-
|
|
960
|
-
|
|
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
|
-
*
|
|
1013
|
-
* of
|
|
1014
|
-
*
|
|
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
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
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
|