@moldesk/runtime 0.0.1 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist/docker/index.d.ts +41 -4
  2. package/dist/docker/index.js +140 -14
  3. package/dist/docker/index.test.d.ts +1 -0
  4. package/dist/docker/index.test.js +108 -0
  5. package/dist/filesystem/companion-inputs.d.ts +34 -0
  6. package/dist/filesystem/companion-inputs.js +123 -0
  7. package/dist/filesystem/companion-inputs.test.d.ts +1 -0
  8. package/dist/filesystem/companion-inputs.test.js +256 -0
  9. package/dist/filesystem/index.d.ts +4 -0
  10. package/dist/filesystem/index.js +2 -0
  11. package/dist/filesystem/paths.d.ts +20 -0
  12. package/dist/filesystem/paths.js +79 -0
  13. package/dist/filesystem/paths.test.d.ts +1 -0
  14. package/dist/filesystem/paths.test.js +35 -0
  15. package/dist/hardware/index.d.ts +21 -0
  16. package/dist/hardware/index.js +62 -0
  17. package/dist/hardware/index.test.d.ts +1 -0
  18. package/dist/hardware/index.test.js +27 -0
  19. package/dist/index.d.ts +22 -5
  20. package/dist/index.js +17 -12
  21. package/dist/install/index.d.ts +83 -0
  22. package/dist/install/index.js +487 -0
  23. package/dist/install/index.test.d.ts +1 -0
  24. package/dist/install/index.test.js +628 -0
  25. package/dist/nvidia/index.d.ts +29 -0
  26. package/dist/nvidia/index.js +119 -0
  27. package/dist/nvidia/index.test.d.ts +1 -0
  28. package/dist/nvidia/index.test.js +91 -0
  29. package/dist/process/index.d.ts +34 -0
  30. package/dist/process/index.js +99 -4
  31. package/dist/process/index.test.d.ts +1 -0
  32. package/dist/process/index.test.js +36 -0
  33. package/dist/providers/python.d.ts +14 -0
  34. package/dist/providers/python.js +80 -0
  35. package/dist/providers/python.test.d.ts +1 -0
  36. package/dist/providers/python.test.js +68 -0
  37. package/dist/providers.d.ts +49 -0
  38. package/dist/providers.js +1 -0
  39. package/dist/providers.test.d.ts +1 -0
  40. package/dist/providers.test.js +10 -0
  41. package/dist/python/index.d.ts +14 -3
  42. package/dist/python/index.js +30 -12
  43. package/dist/redact.d.ts +6 -0
  44. package/dist/redact.js +16 -0
  45. package/dist/redact.test.d.ts +1 -0
  46. package/dist/redact.test.js +16 -0
  47. package/dist/system.d.ts +39 -0
  48. package/dist/system.js +42 -0
  49. package/dist/system.test.d.ts +1 -0
  50. package/dist/system.test.js +138 -0
  51. package/package.json +8 -3
@@ -0,0 +1,119 @@
1
+ const PROBE_TIMEOUT_MS = 10_000;
2
+ const BYTES_PER_MIB = 1024 * 1024;
3
+ function splitCsvLines(output) {
4
+ return output
5
+ .split("\n")
6
+ .map((line) => line.trim())
7
+ .filter((line) => line.length > 0)
8
+ .map((line) => line.split(",").map((cell) => cell.trim()));
9
+ }
10
+ function parseNonNegativeInt(value) {
11
+ if (!/^\d+$/.test(value))
12
+ return undefined;
13
+ const parsed = Number.parseInt(value, 10);
14
+ return Number.isSafeInteger(parsed) ? parsed : undefined;
15
+ }
16
+ /**
17
+ * Extract the driver's max-supported CUDA version from `nvidia-smi -q -x` XML
18
+ * output. `<cuda_version>` is a singular top-level tag (appears once, before
19
+ * any per-GPU `<gpu>` repetition), so a first-match regex is unambiguous
20
+ * without needing a full XML parser. Never parse the decorative ASCII table.
21
+ */
22
+ export function parseDriverCudaVersionXml(xml) {
23
+ const match = xml.match(/<cuda_version>\s*([\d.]+)\s*<\/cuda_version>/i);
24
+ if (!match)
25
+ return undefined;
26
+ const value = match[1];
27
+ return value && value.toLowerCase() !== "not found" ? value : undefined;
28
+ }
29
+ function parseToolkitCudaVersion(nvccOutput) {
30
+ const match = nvccOutput.match(/release\s+(\d+\.\d+(?:\.\d+)?)/i);
31
+ return match ? match[1] : undefined;
32
+ }
33
+ /**
34
+ * Probe NVIDIA GPUs via `nvidia-smi` CSV output only (never the
35
+ * decorative table). Any missing tool / error / unparsable output
36
+ * becomes `{available:false}` rather than throwing.
37
+ */
38
+ export async function detectNvidia(capture, timeoutMs = PROBE_TIMEOUT_MS) {
39
+ let run;
40
+ if (capture) {
41
+ run = capture;
42
+ }
43
+ else {
44
+ const { defaultCapture } = await import("../process/index.js");
45
+ run = defaultCapture;
46
+ }
47
+ const gpuResult = await run("nvidia-smi", ["--query-gpu=index,name,memory.total", "--format=csv,noheader,nounits"], { timeoutMs });
48
+ if (gpuResult.timedOut) {
49
+ return {
50
+ available: false,
51
+ gpus: [],
52
+ error: `nvidia-smi timed out after ${timeoutMs}ms`,
53
+ };
54
+ }
55
+ if (gpuResult.code !== 0) {
56
+ const detail = `${gpuResult.stdout}\n${gpuResult.stderr}`.trim();
57
+ return {
58
+ available: false,
59
+ gpus: [],
60
+ error: detail.length > 0
61
+ ? `nvidia-smi unavailable: ${detail.split("\n")[0]}`
62
+ : "nvidia-smi not detected (no NVIDIA GPU or driver)",
63
+ };
64
+ }
65
+ const rows = splitCsvLines(gpuResult.stdout);
66
+ if (rows.length === 0) {
67
+ return {
68
+ available: false,
69
+ gpus: [],
70
+ error: "nvidia-smi returned no GPU rows",
71
+ };
72
+ }
73
+ const gpus = [];
74
+ for (const cells of rows) {
75
+ if (cells.length < 3) {
76
+ return {
77
+ available: false,
78
+ gpus: [],
79
+ error: `unrecognized nvidia-smi output: ${JSON.stringify(cells)}`,
80
+ };
81
+ }
82
+ const [indexRaw, nameRaw, memoryRaw] = cells;
83
+ const index = parseNonNegativeInt(indexRaw);
84
+ if (index === undefined || nameRaw.length === 0) {
85
+ return {
86
+ available: false,
87
+ gpus: [],
88
+ error: `unrecognized nvidia-smi output: ${JSON.stringify(cells)}`,
89
+ };
90
+ }
91
+ const memoryMib = parseNonNegativeInt(memoryRaw);
92
+ gpus.push({
93
+ index,
94
+ name: nameRaw,
95
+ totalVramBytes: memoryMib === undefined ? undefined : memoryMib * BYTES_PER_MIB,
96
+ });
97
+ }
98
+ // Driver/CUDA versions are best-effort; GPU presence decides availability.
99
+ let driverVersion;
100
+ let driverCudaVersion;
101
+ let cudaToolkitVersion;
102
+ const driverResult = await run("nvidia-smi", ["--query-gpu=driver_version", "--format=csv,noheader,nounits"], { timeoutMs });
103
+ if (!driverResult.timedOut && driverResult.code === 0) {
104
+ const first = driverResult.stdout.split("\n").map((l) => l.trim()).find(Boolean);
105
+ if (first && first.toLowerCase() !== "[not supported]")
106
+ driverVersion = first;
107
+ }
108
+ // Structured XML query for the driver's max-supported CUDA version — never
109
+ // scrape the decorative `nvidia-smi` banner table.
110
+ const cudaXmlResult = await run("nvidia-smi", ["-q", "-x"], { timeoutMs });
111
+ if (!cudaXmlResult.timedOut && cudaXmlResult.code === 0) {
112
+ driverCudaVersion = parseDriverCudaVersionXml(cudaXmlResult.stdout);
113
+ }
114
+ const toolkitResult = await run("nvcc", ["--version"], { timeoutMs });
115
+ if (!toolkitResult.timedOut && toolkitResult.code === 0) {
116
+ cudaToolkitVersion = parseToolkitCudaVersion(`${toolkitResult.stdout}\n${toolkitResult.stderr}`);
117
+ }
118
+ return { available: true, driverVersion, driverCudaVersion, cudaToolkitVersion, gpus };
119
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,91 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { detectNvidia, parseDriverCudaVersionXml } from "./index.js";
3
+ function stubCapture(handler) {
4
+ return async (command, args) => {
5
+ const r = handler(command, args);
6
+ return { code: r.code, stdout: r.stdout ?? "", stderr: r.stderr ?? "", timedOut: r.timedOut };
7
+ };
8
+ }
9
+ const CSV_ROWS = "0, NVIDIA A100, 81920\n";
10
+ describe("parseDriverCudaVersionXml", () => {
11
+ it("extracts the top-level cuda_version tag", () => {
12
+ const xml = "<nvidia_smi_log><cuda_version>12.4</cuda_version><gpu><cuda_version>ignored</cuda_version></gpu></nvidia_smi_log>";
13
+ expect(parseDriverCudaVersionXml(xml)).toBe("12.4");
14
+ });
15
+ it("returns undefined when the tag is absent", () => {
16
+ expect(parseDriverCudaVersionXml("<nvidia_smi_log></nvidia_smi_log>")).toBeUndefined();
17
+ });
18
+ it("returns undefined for a 'Not Found' placeholder value", () => {
19
+ expect(parseDriverCudaVersionXml("<cuda_version>Not Found</cuda_version>")).toBeUndefined();
20
+ });
21
+ it("never matches decorative table text", () => {
22
+ const decorative = "+-----------------------------------------------------------------------------------+\n| CUDA Version: 12.4 |\n";
23
+ expect(parseDriverCudaVersionXml(decorative)).toBeUndefined();
24
+ });
25
+ });
26
+ describe("detectNvidia — driver CUDA (XML) vs toolkit CUDA (nvcc)", () => {
27
+ it("reports driverCudaVersion from XML and cudaToolkitVersion from nvcc independently", async () => {
28
+ const capture = stubCapture((cmd, args) => {
29
+ if (cmd === "nvidia-smi" && args.join(" ").includes("memory.total"))
30
+ return { code: 0, stdout: CSV_ROWS };
31
+ if (cmd === "nvidia-smi" && args.includes("-x"))
32
+ return { code: 0, stdout: "<nvidia_smi_log><cuda_version>12.6</cuda_version></nvidia_smi_log>" };
33
+ if (cmd === "nvidia-smi")
34
+ return { code: 0, stdout: "560.35.03\n" };
35
+ if (cmd === "nvcc")
36
+ return { code: 0, stdout: "Cuda compilation tools, release 12.1\n" };
37
+ return { code: 1 };
38
+ });
39
+ const result = await detectNvidia(capture);
40
+ expect(result.available).toBe(true);
41
+ expect(result.driverCudaVersion).toBe("12.6");
42
+ expect(result.cudaToolkitVersion).toBe("12.1");
43
+ });
44
+ it("leaves driverCudaVersion undefined when only the toolkit is present (no driver-only host regression)", async () => {
45
+ const capture = stubCapture((cmd, args) => {
46
+ if (cmd === "nvidia-smi" && args.join(" ").includes("memory.total"))
47
+ return { code: 0, stdout: CSV_ROWS };
48
+ if (cmd === "nvidia-smi" && args.includes("-x"))
49
+ return { code: 1, stderr: "unsupported" };
50
+ if (cmd === "nvidia-smi")
51
+ return { code: 0, stdout: "560.35.03\n" };
52
+ if (cmd === "nvcc")
53
+ return { code: 0, stdout: "Cuda compilation tools, release 12.1\n" };
54
+ return { code: 1 };
55
+ });
56
+ const result = await detectNvidia(capture);
57
+ expect(result.driverCudaVersion).toBeUndefined();
58
+ expect(result.cudaToolkitVersion).toBe("12.1");
59
+ });
60
+ it("reports driverCudaVersion when nvcc (toolkit) is entirely absent — the driver-only Docker-host case", async () => {
61
+ const capture = stubCapture((cmd, args) => {
62
+ if (cmd === "nvidia-smi" && args.join(" ").includes("memory.total"))
63
+ return { code: 0, stdout: CSV_ROWS };
64
+ if (cmd === "nvidia-smi" && args.includes("-x"))
65
+ return { code: 0, stdout: "<nvidia_smi_log><cuda_version>12.6</cuda_version></nvidia_smi_log>" };
66
+ if (cmd === "nvidia-smi")
67
+ return { code: 0, stdout: "560.35.03\n" };
68
+ if (cmd === "nvcc")
69
+ return { code: 127, stderr: "command not found" };
70
+ return { code: 1 };
71
+ });
72
+ const result = await detectNvidia(capture);
73
+ expect(result.driverCudaVersion).toBe("12.6");
74
+ expect(result.cudaToolkitVersion).toBeUndefined();
75
+ });
76
+ it("leaves both undefined when neither probe succeeds, without failing GPU detection", async () => {
77
+ const capture = stubCapture((cmd, args) => {
78
+ if (cmd === "nvidia-smi" && args.join(" ").includes("memory.total"))
79
+ return { code: 0, stdout: CSV_ROWS };
80
+ if (cmd === "nvidia-smi")
81
+ return { code: 1, stderr: "unsupported" };
82
+ if (cmd === "nvcc")
83
+ return { code: 127, stderr: "command not found" };
84
+ return { code: 1 };
85
+ });
86
+ const result = await detectNvidia(capture);
87
+ expect(result.available).toBe(true);
88
+ expect(result.driverCudaVersion).toBeUndefined();
89
+ expect(result.cudaToolkitVersion).toBeUndefined();
90
+ });
91
+ });
@@ -1,12 +1,46 @@
1
1
  export interface RunOptions {
2
2
  cwd?: string;
3
3
  env?: NodeJS.ProcessEnv;
4
+ timeoutMs?: number;
4
5
  onStdout?: (chunk: string) => void;
5
6
  onStderr?: (chunk: string) => void;
7
+ /** External cancellation trigger, e.g. wired to CLI SIGINT/SIGTERM handlers. */
8
+ signal?: AbortSignal;
9
+ /**
10
+ * If set, a timeout or `signal` abort sends SIGTERM first and waits up to
11
+ * this many ms before escalating to SIGKILL. If unset (the default),
12
+ * termination is an immediate SIGKILL — this is the pre-existing behavior,
13
+ * unchanged, so callers that don't pass it keep identical semantics.
14
+ */
15
+ gracefulTimeoutMs?: number;
16
+ /** Called synchronously once the child is spawned, so callers can persist the pid before the promise resolves. */
17
+ onSpawn?: (pid: number) => void;
6
18
  }
7
19
  export interface RunResult {
8
20
  code: number;
9
21
  stdout: string;
10
22
  stderr: string;
23
+ timedOut?: boolean;
24
+ pid?: number;
25
+ signal?: string;
26
+ /** True when termination was triggered by `options.signal` aborting (distinct from a `timeoutMs` timeout). */
27
+ cancelled?: boolean;
11
28
  }
29
+ /**
30
+ * Spawn a command without a shell (never `sh -c`). Resolves on close;
31
+ * timeouts kill the child and resolve with `timedOut: true` instead of
32
+ * rejecting, so probes can degrade to capability results.
33
+ */
12
34
  export declare function runCommand(command: string, args: string[], options?: RunOptions): Promise<RunResult>;
35
+ /** Minimal capture result for probes. */
36
+ export interface CaptureResult {
37
+ code: number;
38
+ stdout: string;
39
+ stderr: string;
40
+ timedOut?: boolean;
41
+ }
42
+ export type CaptureFn = (command: string, args: string[], options?: {
43
+ timeoutMs?: number;
44
+ }) => Promise<CaptureResult>;
45
+ /** Default capture backed by runCommand. */
46
+ export declare const defaultCapture: CaptureFn;
@@ -1,12 +1,75 @@
1
1
  import { spawn } from "node:child_process";
2
+ const DEFAULT_TIMEOUT_MS = 15_000;
3
+ /**
4
+ * Spawn a command without a shell (never `sh -c`). Resolves on close;
5
+ * timeouts kill the child and resolve with `timedOut: true` instead of
6
+ * rejecting, so probes can degrade to capability results.
7
+ */
2
8
  export function runCommand(command, args, options = {}) {
3
- return new Promise((resolve, reject) => {
9
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
10
+ const gracefulTimeoutMs = options.gracefulTimeoutMs;
11
+ return new Promise((resolve) => {
12
+ let settled = false;
13
+ let timedOut = false;
14
+ let cancelled = false;
15
+ let forceKillTimer;
4
16
  const child = spawn(command, args, {
5
17
  cwd: options.cwd,
6
18
  env: options.env ?? process.env,
19
+ stdio: ["ignore", "pipe", "pipe"],
7
20
  });
21
+ if (typeof child.pid === "number") {
22
+ options.onSpawn?.(child.pid);
23
+ }
8
24
  let stdout = "";
9
25
  let stderr = "";
26
+ const terminate = () => {
27
+ try {
28
+ if (gracefulTimeoutMs && gracefulTimeoutMs > 0) {
29
+ child.kill("SIGTERM");
30
+ forceKillTimer = setTimeout(() => {
31
+ try {
32
+ child.kill("SIGKILL");
33
+ }
34
+ catch {
35
+ // ignore kill failures; close handler settles below
36
+ }
37
+ }, gracefulTimeoutMs);
38
+ }
39
+ else {
40
+ child.kill("SIGKILL");
41
+ }
42
+ }
43
+ catch {
44
+ // ignore kill failures; close handler settles below
45
+ }
46
+ };
47
+ const timer = timeoutMs > 0
48
+ ? setTimeout(() => {
49
+ timedOut = true;
50
+ terminate();
51
+ }, timeoutMs)
52
+ : undefined;
53
+ const onAbort = () => {
54
+ if (settled || timedOut)
55
+ return;
56
+ cancelled = true;
57
+ terminate();
58
+ };
59
+ options.signal?.addEventListener("abort", onAbort, { once: true });
60
+ if (options.signal?.aborted)
61
+ onAbort();
62
+ const settle = (result) => {
63
+ if (settled)
64
+ return;
65
+ settled = true;
66
+ if (timer)
67
+ clearTimeout(timer);
68
+ if (forceKillTimer)
69
+ clearTimeout(forceKillTimer);
70
+ options.signal?.removeEventListener("abort", onAbort);
71
+ resolve(result);
72
+ };
10
73
  child.stdout.on("data", (data) => {
11
74
  const text = data.toString();
12
75
  stdout += text;
@@ -17,9 +80,41 @@ export function runCommand(command, args, options = {}) {
17
80
  stderr += text;
18
81
  options.onStderr?.(text);
19
82
  });
20
- child.on("error", reject);
21
- child.on("close", (code) => {
22
- resolve({ code: code ?? 0, stdout, stderr });
83
+ child.on("error", (error) => {
84
+ const suffix = timedOut || /timed out/i.test(error.message)
85
+ ? ` (timed out after ${timeoutMs}ms)`
86
+ : "";
87
+ settle({
88
+ code: 127,
89
+ stdout,
90
+ stderr: `${stderr}${error.message}${suffix}`,
91
+ timedOut,
92
+ cancelled,
93
+ pid: child.pid,
94
+ });
95
+ });
96
+ child.on("close", (code, signal) => {
97
+ if (timedOut) {
98
+ settle({
99
+ code: 124,
100
+ stdout,
101
+ stderr: `${stderr}command timed out after ${timeoutMs}ms: ${command} ${args.join(" ")}`.trim(),
102
+ timedOut: true,
103
+ pid: child.pid,
104
+ signal: signal ?? undefined,
105
+ });
106
+ return;
107
+ }
108
+ settle({
109
+ code: code ?? 0,
110
+ stdout,
111
+ stderr,
112
+ cancelled,
113
+ pid: child.pid,
114
+ signal: signal ?? undefined,
115
+ });
23
116
  });
24
117
  });
25
118
  }
119
+ /** Default capture backed by runCommand. */
120
+ export const defaultCapture = (command, args, options) => runCommand(command, args, { timeoutMs: options?.timeoutMs ?? 10_000 });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,36 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { runCommand } from "./index.js";
3
+ describe("runCommand", () => {
4
+ it("exposes the child pid via onSpawn and on the result", async () => {
5
+ let spawnedPid;
6
+ const result = await runCommand(process.execPath, ["-e", "process.exit(0)"], {
7
+ onSpawn: (pid) => {
8
+ spawnedPid = pid;
9
+ },
10
+ });
11
+ expect(spawnedPid).toBeTypeOf("number");
12
+ expect(result.pid).toBe(spawnedPid);
13
+ expect(result.code).toBe(0);
14
+ });
15
+ it("escalates to SIGKILL after gracefulTimeoutMs when an aborted process ignores SIGTERM", async () => {
16
+ const controller = new AbortController();
17
+ const ignoreSigterm = "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000);";
18
+ const runPromise = runCommand(process.execPath, ["-e", ignoreSigterm], {
19
+ signal: controller.signal,
20
+ gracefulTimeoutMs: 150,
21
+ });
22
+ controller.abort();
23
+ const result = await runPromise;
24
+ expect(result.cancelled).toBe(true);
25
+ }, 10_000);
26
+ it("kills immediately with no graceful escalation when gracefulTimeoutMs is unset (pre-existing behavior)", async () => {
27
+ const controller = new AbortController();
28
+ const sleep = "setTimeout(() => {}, 10000);";
29
+ const started = Date.now();
30
+ const runPromise = runCommand(process.execPath, ["-e", sleep], { signal: controller.signal });
31
+ controller.abort();
32
+ const result = await runPromise;
33
+ expect(result.cancelled).toBe(true);
34
+ expect(Date.now() - started).toBeLessThan(2000);
35
+ }, 10_000);
36
+ });
@@ -0,0 +1,14 @@
1
+ import type { ExecuteRequest, ExecutionResult, PrepareRuntimeRequest, PreparedRuntime, RemoveRuntimeRequest, RuntimeCapability, RuntimeProvider } from "../providers.js";
2
+ /**
3
+ * Executes already-installed Python models. Resolves the interpreter from
4
+ * the on-disk managed venv (`prepare`) and streams process output to
5
+ * `stdout.log`/`stderr.log` under the caller-supplied `cwd` while also
6
+ * forwarding chunks live (`execute`).
7
+ */
8
+ export declare class PythonRuntimeProvider implements RuntimeProvider {
9
+ readonly kind: "python";
10
+ inspect(): Promise<RuntimeCapability>;
11
+ prepare(request: PrepareRuntimeRequest): Promise<PreparedRuntime>;
12
+ execute(request: ExecuteRequest): Promise<ExecutionResult>;
13
+ remove(_request: RemoveRuntimeRequest): Promise<void>;
14
+ }
@@ -0,0 +1,80 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { MoldeskError } from "@moldesk/registry";
4
+ import { runCommand } from "../process/index.js";
5
+ import { detectPython } from "../python/index.js";
6
+ /** Model runs have no fixed duration; termination is driven only by `signal`/graceful cancellation, not a timer. */
7
+ const NO_TIMEOUT_MS = 0;
8
+ const EXECUTE_GRACEFUL_TIMEOUT_MS = 5_000;
9
+ /**
10
+ * Executes already-installed Python models. Resolves the interpreter from
11
+ * the on-disk managed venv (`prepare`) and streams process output to
12
+ * `stdout.log`/`stderr.log` under the caller-supplied `cwd` while also
13
+ * forwarding chunks live (`execute`).
14
+ */
15
+ export class PythonRuntimeProvider {
16
+ kind = "python";
17
+ async inspect() {
18
+ const python = await detectPython();
19
+ return {
20
+ kind: "python",
21
+ available: python.available,
22
+ version: python.version,
23
+ error: python.error,
24
+ };
25
+ }
26
+ async prepare(request) {
27
+ const executable = path.join(request.targetDir, ".venv", "bin", "python");
28
+ if (!fs.existsSync(executable)) {
29
+ throw new MoldeskError({
30
+ code: "MODEL_NOT_INSTALLED",
31
+ message: `No managed Python environment found for "${request.modelName}" at ${executable}.`,
32
+ remediation: `Run "moldesk install ${request.modelName}" first.`,
33
+ details: { modelName: request.modelName, targetDir: request.targetDir },
34
+ });
35
+ }
36
+ return { kind: "python", executable, fingerprint: request.runtimeFingerprint };
37
+ }
38
+ async execute(request) {
39
+ const stdoutPath = path.join(request.cwd, "stdout.log");
40
+ const stderrPath = path.join(request.cwd, "stderr.log");
41
+ const stdoutFd = fs.openSync(stdoutPath, "a");
42
+ const stderrFd = fs.openSync(stderrPath, "a");
43
+ try {
44
+ const result = await runCommand(request.executable, request.args, {
45
+ cwd: request.cwd,
46
+ // Adapter-declared env vars (e.g. OPENDDE_ROOT_DIR, TORCH_HOME) overlay the
47
+ // inherited environment rather than replacing it — a bare `request.env`
48
+ // would drop PATH/HOME and break the model process.
49
+ env: request.env ? { ...process.env, ...request.env } : undefined,
50
+ timeoutMs: NO_TIMEOUT_MS,
51
+ gracefulTimeoutMs: EXECUTE_GRACEFUL_TIMEOUT_MS,
52
+ signal: request.signal,
53
+ onSpawn: request.onSpawn,
54
+ onStdout: (chunk) => {
55
+ fs.writeSync(stdoutFd, chunk);
56
+ request.onStdout?.(chunk);
57
+ },
58
+ onStderr: (chunk) => {
59
+ fs.writeSync(stderrFd, chunk);
60
+ request.onStderr?.(chunk);
61
+ },
62
+ });
63
+ return {
64
+ exitCode: result.code,
65
+ stdoutPath,
66
+ stderrPath,
67
+ pid: result.pid,
68
+ signal: result.signal,
69
+ cancelled: result.cancelled,
70
+ };
71
+ }
72
+ finally {
73
+ fs.closeSync(stdoutFd);
74
+ fs.closeSync(stderrFd);
75
+ }
76
+ }
77
+ async remove(_request) {
78
+ // No-op: uninstall already owns Python environment removal; kept for interface parity with a future Docker provider.
79
+ }
80
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,68 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+ import { PythonRuntimeProvider } from "./python.js";
6
+ const dirs = [];
7
+ function tempDir() {
8
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "moldesk-python-provider-"));
9
+ dirs.push(dir);
10
+ return dir;
11
+ }
12
+ afterEach(() => {
13
+ for (const dir of dirs.splice(0))
14
+ fs.rmSync(dir, { recursive: true, force: true });
15
+ });
16
+ describe("PythonRuntimeProvider.prepare", () => {
17
+ it("resolves the venv executable when present", async () => {
18
+ const targetDir = tempDir();
19
+ fs.mkdirSync(path.join(targetDir, ".venv", "bin"), { recursive: true });
20
+ fs.writeFileSync(path.join(targetDir, ".venv", "bin", "python"), "");
21
+ const provider = new PythonRuntimeProvider();
22
+ const prepared = await provider.prepare({
23
+ modelName: "proteinmpnn",
24
+ modelVersion: "v_48_020",
25
+ runtimeFingerprint: "python-abc",
26
+ targetDir,
27
+ });
28
+ expect(prepared.executable).toBe(path.join(targetDir, ".venv", "bin", "python"));
29
+ });
30
+ it("throws MODEL_NOT_INSTALLED when the venv is missing", async () => {
31
+ const provider = new PythonRuntimeProvider();
32
+ await expect(provider.prepare({ modelName: "proteinmpnn", modelVersion: "v_48_020", runtimeFingerprint: "python-abc", targetDir: tempDir() })).rejects.toMatchObject({ code: "MODEL_NOT_INSTALLED" });
33
+ });
34
+ });
35
+ describe("PythonRuntimeProvider.execute", () => {
36
+ it("streams stdout/stderr to log files under cwd and forwards live chunks", async () => {
37
+ const cwd = tempDir();
38
+ const chunks = [];
39
+ const provider = new PythonRuntimeProvider();
40
+ const result = await provider.execute({
41
+ executable: process.execPath,
42
+ args: ["-e", "process.stdout.write('out\\n'); process.stderr.write('err\\n');"],
43
+ cwd,
44
+ onStdout: (chunk) => chunks.push({ stream: "stdout", text: chunk }),
45
+ onStderr: (chunk) => chunks.push({ stream: "stderr", text: chunk }),
46
+ });
47
+ expect(result.exitCode).toBe(0);
48
+ expect(result.stdoutPath).toBe(path.join(cwd, "stdout.log"));
49
+ expect(result.stderrPath).toBe(path.join(cwd, "stderr.log"));
50
+ expect(fs.readFileSync(result.stdoutPath, "utf8")).toContain("out");
51
+ expect(fs.readFileSync(result.stderrPath, "utf8")).toContain("err");
52
+ expect(chunks.some((c) => c.stream === "stdout" && c.text.includes("out"))).toBe(true);
53
+ });
54
+ it("overlays adapter env vars on the inherited environment instead of replacing it", async () => {
55
+ const cwd = tempDir();
56
+ const provider = new PythonRuntimeProvider();
57
+ const result = await provider.execute({
58
+ executable: process.execPath,
59
+ args: ["-e", "process.stdout.write(JSON.stringify({ path: process.env.PATH ?? null, extra: process.env.MOLDESK_TEST_EXTRA ?? null }))"],
60
+ cwd,
61
+ env: { MOLDESK_TEST_EXTRA: "set-by-adapter" },
62
+ });
63
+ expect(result.exitCode).toBe(0);
64
+ const seen = JSON.parse(fs.readFileSync(result.stdoutPath, "utf8"));
65
+ expect(seen.extra).toBe("set-by-adapter");
66
+ expect(seen.path).toBe(process.env["PATH"] ?? null);
67
+ });
68
+ });
@@ -0,0 +1,49 @@
1
+ import type { RuntimeKind } from "@moldesk/registry";
2
+ export { MoldeskError } from "@moldesk/registry";
3
+ export type { MoldeskErrorData, RuntimeKind } from "@moldesk/registry";
4
+ export interface RuntimeCapability {
5
+ kind: RuntimeKind;
6
+ available: boolean;
7
+ version?: string;
8
+ error?: string;
9
+ }
10
+ export interface PrepareRuntimeRequest {
11
+ modelName: string;
12
+ modelVersion: string;
13
+ runtimeFingerprint: string;
14
+ targetDir: string;
15
+ }
16
+ export interface PreparedRuntime {
17
+ kind: RuntimeKind;
18
+ executable: string;
19
+ fingerprint: string;
20
+ }
21
+ export interface ExecuteRequest {
22
+ executable: string;
23
+ args: string[];
24
+ cwd: string;
25
+ env?: Record<string, string>;
26
+ signal?: AbortSignal;
27
+ onSpawn?: (pid: number) => void;
28
+ onStdout?: (chunk: string) => void;
29
+ onStderr?: (chunk: string) => void;
30
+ }
31
+ export interface ExecutionResult {
32
+ exitCode: number;
33
+ stdoutPath: string;
34
+ stderrPath: string;
35
+ pid?: number;
36
+ signal?: string;
37
+ cancelled?: boolean;
38
+ }
39
+ export interface RemoveRuntimeRequest {
40
+ modelName: string;
41
+ runtimeFingerprint: string;
42
+ }
43
+ export interface RuntimeProvider {
44
+ readonly kind: RuntimeKind;
45
+ inspect(): Promise<RuntimeCapability>;
46
+ prepare(request: PrepareRuntimeRequest): Promise<PreparedRuntime>;
47
+ execute(request: ExecuteRequest): Promise<ExecutionResult>;
48
+ remove(request: RemoveRuntimeRequest): Promise<void>;
49
+ }
@@ -0,0 +1 @@
1
+ export { MoldeskError } from "@moldesk/registry";
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,10 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { MoldeskError as RegistryMoldeskError } from "@moldesk/registry";
3
+ import { MoldeskError as RuntimeMoldeskError } from "./providers.js";
4
+ describe("runtime public contracts", () => {
5
+ it("re-exports the canonical MoldeskError class", () => {
6
+ expect(RuntimeMoldeskError).toBe(RegistryMoldeskError);
7
+ const error = new RuntimeMoldeskError({ code: "TEST", message: "test" });
8
+ expect(error).toBeInstanceOf(RegistryMoldeskError);
9
+ });
10
+ });