@moldesk/core 0.0.1 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/run.d.ts ADDED
@@ -0,0 +1,89 @@
1
+ import { type RuntimeKind } from "@moldesk/registry";
2
+ import { type MoldeskPaths, type RuntimeProvider, type SystemReport } from "@moldesk/runtime";
3
+ /**
4
+ * `runModel` only rejects for usage errors discovered before a run directory
5
+ * is allocated (unknown model, not installed, invalid input/params — spec
6
+ * §4.2 steps 1-3). From the moment a run directory exists (step 4 onward),
7
+ * every outcome — success, nonzero exit, missing output, cancellation, or an
8
+ * unexpected internal failure — resolves as a typed `RunModelResult` with a
9
+ * finalized `run.json`, never a rejection. The CLI distinguishes the two by
10
+ * catch (usage error, mapped via `MoldeskError.code`) vs. `result.status`.
11
+ */
12
+ export interface RunModelOptions {
13
+ paths?: MoldeskPaths;
14
+ runtime?: RuntimeKind;
15
+ /** Raw `--param key=value` strings, validated against the adapter's declared params. */
16
+ params?: Record<string, string>;
17
+ /** Copy declared outputs here after a successful run. Refuses to overwrite existing files. */
18
+ outputDir?: string;
19
+ /** CLI-constructed abort signal (SIGINT/SIGTERM) for graceful cancellation. */
20
+ signal?: AbortSignal;
21
+ onLog?: (stream: "stdout" | "stderr", chunk: string) => void;
22
+ /** Injected for testability; defaults to a real `PythonRuntimeProvider`. */
23
+ runtimeProvider?: RuntimeProvider;
24
+ }
25
+ export interface RunRecord {
26
+ schemaVersion: 1;
27
+ id: string;
28
+ status: "preparing" | "running" | "succeeded" | "failed" | "cancelled";
29
+ model: string;
30
+ modelVersion: string;
31
+ adapterVersion: string;
32
+ moldeskVersion: string;
33
+ runtime: RuntimeKind;
34
+ installationFingerprint: string;
35
+ manifestSha256: string;
36
+ hardware: SystemReport;
37
+ /** `role` is `"job"` for the primary submitted input and the companion's
38
+ * declared `id` for anything staged via `resolveCompanionInputs`/
39
+ * `stageCompanionInputs`. Optional/additive so existing single-input records
40
+ * (every entry implicitly the primary input) remain valid. */
41
+ input: Array<{
42
+ originalPath: string;
43
+ storedPath: string;
44
+ sha256: string;
45
+ role?: string;
46
+ }>;
47
+ parameters: {
48
+ supplied: Record<string, unknown>;
49
+ effective: Record<string, unknown>;
50
+ };
51
+ command: {
52
+ executable: string;
53
+ args: string[];
54
+ cwd: string;
55
+ env?: Record<string, string>;
56
+ };
57
+ startedAt: string;
58
+ finishedAt?: string;
59
+ durationMs?: number;
60
+ process?: {
61
+ pid?: number;
62
+ exitCode?: number;
63
+ signal?: string;
64
+ };
65
+ outputs: Array<{
66
+ id: string;
67
+ path: string;
68
+ sha256: string;
69
+ sizeBytes: number;
70
+ }>;
71
+ logs: {
72
+ stdout: string;
73
+ stderr: string;
74
+ };
75
+ error?: {
76
+ code: string;
77
+ message: string;
78
+ };
79
+ }
80
+ export interface RunModelResult {
81
+ status: "succeeded" | "failed" | "cancelled";
82
+ record: RunRecord;
83
+ /** Set only if `--output` copy failed after an otherwise-succeeded run; see the module doc. */
84
+ exportError?: {
85
+ code: string;
86
+ message: string;
87
+ };
88
+ }
89
+ export declare function runModel(modelName: string, inputPath: string, options?: RunModelOptions): Promise<RunModelResult>;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,124 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { getMoldeskPaths } from "@moldesk/runtime";
6
+ import { listAvailableModels } from "@moldesk/registry";
7
+ import { createInstallationPlan, installModel } from "./installation.js";
8
+ /**
9
+ * Proves `ModelAdapterDefinition.resolveParamDefaults` (the hook `run.ts` calls
10
+ * before `validateParams`, per the Boltz milestone's "resolved accelerator must
11
+ * already be in run.json's parameters.effective" requirement): its output lands
12
+ * in `run.json`'s `parameters.effective`, and an explicit `--param` still wins
13
+ * over it. Mocks `@moldesk/adapters`'s `getAdapter` to return the real
14
+ * ProteinMPNN adapter augmented with a fake `resolveParamDefaults`, so the rest
15
+ * of `runModel`'s install/execute plumbing is exercised unchanged.
16
+ */
17
+ vi.mock("@moldesk/adapters", async (importOriginal) => {
18
+ const actual = await importOriginal();
19
+ const fakeAdapter = {
20
+ ...actual.adapterCatalog["proteinmpnn"],
21
+ resolveParamDefaults: () => ({ num_seq_per_target: 5 }),
22
+ };
23
+ return {
24
+ ...actual,
25
+ getAdapter: (name) => (name === "proteinmpnn" ? fakeAdapter : actual.getAdapter(name)),
26
+ };
27
+ });
28
+ const { runModel } = await import("./run.js");
29
+ const homes = [];
30
+ function tempPaths() {
31
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "moldesk core run dyn "));
32
+ homes.push(home);
33
+ return getMoldeskPaths({ MOLDESK_HOME: home });
34
+ }
35
+ afterEach(() => {
36
+ for (const home of homes.splice(0))
37
+ fs.rmSync(home, { recursive: true, force: true });
38
+ });
39
+ function successfulInstallRunner(revision) {
40
+ return async (command, args) => {
41
+ if (command === "git" && args[0] === "init") {
42
+ const source = args[1];
43
+ fs.mkdirSync(path.join(source, "vanilla_model_weights"), { recursive: true });
44
+ fs.mkdirSync(path.join(path.dirname(source), "assets", "vanilla_model_weights"), { recursive: true });
45
+ fs.writeFileSync(path.join(source, "protein_mpnn_run.py"), "# pinned source\n");
46
+ fs.writeFileSync(path.join(source, "vanilla_model_weights", "v_48_020.pt"), "weights");
47
+ fs.writeFileSync(path.join(path.dirname(source), "assets", "vanilla_model_weights", "v_48_020.pt"), "weights");
48
+ }
49
+ if (command === "git" && args.includes("rev-parse"))
50
+ return { code: 0, stdout: `${revision}\n`, stderr: "" };
51
+ if (command === "uv" && args[0] === "venv")
52
+ fs.mkdirSync(path.join(args.at(-1), "bin"), { recursive: true });
53
+ if (command === "uv" && args.includes("freeze"))
54
+ return { code: 0, stdout: "numpy==1.26.4\ntorch==2.2.1\n", stderr: "" };
55
+ if (args[0] === "--version")
56
+ return { code: 0, stdout: "Python 3.11.9\n", stderr: "" };
57
+ return { code: 0, stdout: "", stderr: "" };
58
+ };
59
+ }
60
+ async function installFixture(paths) {
61
+ const manifest = { ...listAvailableModels().find((item) => item.name === "proteinmpnn"), assets: [] };
62
+ const runtime = manifest.runtimes.find((item) => item.kind === "python");
63
+ const planned = await createInstallationPlan(manifest, runtime, paths);
64
+ await installModel(planned, { paths, runner: successfulInstallRunner(manifest.source.revision), uvExecutable: "uv" });
65
+ }
66
+ function fakeProvider(behavior) {
67
+ return {
68
+ kind: "python",
69
+ async inspect() {
70
+ return { kind: "python", available: true };
71
+ },
72
+ async prepare(request) {
73
+ return { kind: "python", executable: request.targetDir, fingerprint: request.runtimeFingerprint };
74
+ },
75
+ async execute(request) {
76
+ request.onSpawn?.(4242);
77
+ return behavior(request);
78
+ },
79
+ async remove() { },
80
+ };
81
+ }
82
+ function writeSequenceOutput(request) {
83
+ const seqsDir = path.join(request.cwd, "output", "seqs");
84
+ fs.mkdirSync(seqsDir, { recursive: true });
85
+ fs.writeFileSync(path.join(seqsDir, "input.fa"), ">input\nMKV\n");
86
+ }
87
+ function writeInputFixture(dir) {
88
+ fs.mkdirSync(dir, { recursive: true });
89
+ const inputPath = path.join(dir, "input.pdb");
90
+ fs.writeFileSync(inputPath, "ATOM 1 N MET A 1\n");
91
+ return inputPath;
92
+ }
93
+ describe("runModel dynamic param defaults (resolveParamDefaults)", () => {
94
+ it("merges resolveParamDefaults's output into run.json's parameters.effective when the caller supplied nothing for that key", async () => {
95
+ const paths = tempPaths();
96
+ await installFixture(paths);
97
+ const inputPath = writeInputFixture(paths.home);
98
+ const result = await runModel("proteinmpnn", inputPath, {
99
+ paths,
100
+ runtimeProvider: fakeProvider((request) => {
101
+ writeSequenceOutput(request);
102
+ return { exitCode: 0, stdoutPath: path.join(request.cwd, "stdout.log"), stderrPath: path.join(request.cwd, "stderr.log") };
103
+ }),
104
+ });
105
+ expect(result.status).toBe("succeeded");
106
+ expect(result.record.parameters.effective.num_seq_per_target).toBe(5);
107
+ expect(result.record.parameters.supplied.num_seq_per_target).toBeUndefined();
108
+ });
109
+ it("lets an explicit --param override resolveParamDefaults's value", async () => {
110
+ const paths = tempPaths();
111
+ await installFixture(paths);
112
+ const inputPath = writeInputFixture(paths.home);
113
+ const result = await runModel("proteinmpnn", inputPath, {
114
+ paths,
115
+ params: { num_seq_per_target: "9" },
116
+ runtimeProvider: fakeProvider((request) => {
117
+ writeSequenceOutput(request);
118
+ return { exitCode: 0, stdoutPath: path.join(request.cwd, "stdout.log"), stderrPath: path.join(request.cwd, "stderr.log") };
119
+ }),
120
+ });
121
+ expect(result.status).toBe("succeeded");
122
+ expect(result.record.parameters.effective.num_seq_per_target).toBe(9);
123
+ });
124
+ });
package/dist/run.js ADDED
@@ -0,0 +1,222 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { getAdapter, validateParams } from "@moldesk/adapters";
5
+ import { listAvailableModels } from "@moldesk/registry";
6
+ import { getMoldeskPaths, getSystemReport, redactEnv, runDir, stageCompanionInputs, PythonRuntimeProvider, } from "@moldesk/runtime";
7
+ import { MoldeskError } from "./errors.js";
8
+ import { digest, sha256File } from "./hash.js";
9
+ import { listInstalledModels } from "./install-state.js";
10
+ import { VERSION } from "./version.js";
11
+ function runFailure(code, message, remediation, details) {
12
+ return new MoldeskError({ code, message, remediation, details });
13
+ }
14
+ function toErrorInfo(error, fallbackCode) {
15
+ if (error instanceof MoldeskError)
16
+ return { code: error.code, message: error.message };
17
+ return { code: fallbackCode, message: error instanceof Error ? error.message : String(error) };
18
+ }
19
+ function writeRunRecord(runDirPath, record) {
20
+ const file = path.join(runDirPath, "run.json");
21
+ const temporary = `${file}.tmp-${process.pid}-${randomUUID()}`;
22
+ fs.writeFileSync(temporary, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 });
23
+ fs.renameSync(temporary, file);
24
+ }
25
+ export async function runModel(modelName, inputPath, options = {}) {
26
+ const paths = options.paths ?? getMoldeskPaths();
27
+ // --- Step 1: load and validate manifest. No run directory yet: a failure here rejects. ---
28
+ const manifest = listAvailableModels().find((entry) => entry.name === modelName);
29
+ if (!manifest) {
30
+ throw runFailure("UNKNOWN_MODEL", `Unknown model "${modelName}".`, "Run `moldesk list` to see available models.");
31
+ }
32
+ // --- Step 2: resolve a ready installation. ---
33
+ const allInstalled = listInstalledModels(paths).filter((record) => record.model === modelName);
34
+ const candidates = options.runtime ? allInstalled.filter((record) => record.runtime.kind === options.runtime) : allInstalled;
35
+ if (candidates.length === 0) {
36
+ throw runFailure("MODEL_NOT_INSTALLED", options.runtime
37
+ ? `${modelName} is not installed for the "${options.runtime}" runtime.`
38
+ : `${modelName} is not installed.`, `Run \`moldesk install ${modelName}\`.`);
39
+ }
40
+ if (!options.runtime && candidates.length > 1) {
41
+ throw runFailure("RUN_RUNTIME_SELECTION_REQUIRED", `${modelName} has ${candidates.length} installed runtime variants.`, "Pass --runtime python|docker to select one.");
42
+ }
43
+ const installed = candidates[0];
44
+ if (installed.runtime.kind !== "python" || !installed.runtime.python) {
45
+ throw runFailure("RUNTIME_EXECUTION_UNSUPPORTED", `Running ${modelName} via the "${installed.runtime.kind}" runtime is not yet supported.`, "Install and run a Python-runtime variant of this model instead.");
46
+ }
47
+ // --- Step 3: validate input and params. Still no run directory: failures here reject. ---
48
+ const adapter = getAdapter(modelName);
49
+ if (!adapter) {
50
+ throw runFailure("ADAPTER_NOT_FOUND", `No adapter is registered for ${modelName}.`, "Repair the registry installation.");
51
+ }
52
+ const resolvedInputPath = path.resolve(inputPath);
53
+ await adapter.validateInput(resolvedInputPath);
54
+ // Platform-dependent defaults (e.g. Boltz's `accelerator`) come from the adapter,
55
+ // not a static `ParamDescriptor.default`, since the right value depends on which
56
+ // platform-specific runtime entry was actually installed. Pre-seed `supplied` with
57
+ // them (as strings, since `validateParams` coerces from `--param` strings) for any
58
+ // key the caller didn't explicitly pass — an explicit `--param` always wins.
59
+ const dynamicDefaults = adapter.resolveParamDefaults?.({ manifestName: manifest.name, installed }) ?? {};
60
+ const suppliedParams = { ...(options.params ?? {}) };
61
+ for (const [key, value] of Object.entries(dynamicDefaults)) {
62
+ if (suppliedParams[key] === undefined)
63
+ suppliedParams[key] = String(value);
64
+ }
65
+ const { effective: effectiveParams, errors: paramErrors } = validateParams(adapter.params ?? [], suppliedParams);
66
+ if (paramErrors.length > 0) {
67
+ throw runFailure("INVALID_RUN_PARAMS", paramErrors.join("; "), "Fix the listed --param values and retry.", { errors: paramErrors });
68
+ }
69
+ // --- Step 4: allocate run ID and directory. From here on, every outcome resolves. ---
70
+ const runId = `${new Date().toISOString().replace(/[:.]/g, "-")}-${randomUUID()}`;
71
+ const runDirPath = runDir(runId, { MOLDESK_HOME: paths.home });
72
+ const inputDir = path.join(runDirPath, "input");
73
+ const outputDir = path.join(runDirPath, "output");
74
+ fs.mkdirSync(inputDir, { recursive: true });
75
+ fs.mkdirSync(outputDir, { recursive: true });
76
+ // --- Step 5: copy/snapshot the original input. ---
77
+ const storedInputPath = path.join(inputDir, path.basename(resolvedInputPath));
78
+ fs.copyFileSync(resolvedInputPath, storedInputPath);
79
+ const inputSha256 = await sha256File(storedInputPath);
80
+ const inputRecords = [
81
+ { originalPath: resolvedInputPath, storedPath: storedInputPath, sha256: inputSha256, role: "job" },
82
+ ];
83
+ // Models whose job file references separate companion files (e.g. DiffDock-L's
84
+ // protein PDB + ligand file) declare them via `resolveCompanionInputs`, given the
85
+ // *original* (pre-copy) resolved job path so relative references resolve against
86
+ // where the job file actually lives. Absent means unchanged single-file behavior.
87
+ const companionRefs = await adapter.resolveCompanionInputs?.(resolvedInputPath) ?? [];
88
+ const stagedCompanions = companionRefs.length > 0
89
+ ? stageCompanionInputs({ jobFilePath: resolvedInputPath, refs: companionRefs, inputDir })
90
+ : [];
91
+ for (const staged of stagedCompanions) {
92
+ inputRecords.push({
93
+ originalPath: staged.originalPath,
94
+ storedPath: staged.storedPath,
95
+ sha256: await sha256File(staged.storedPath),
96
+ role: staged.id,
97
+ });
98
+ }
99
+ const companionInputs = stagedCompanions.map((staged) => ({ id: staged.id, path: staged.storedPath }));
100
+ const hardware = await getSystemReport();
101
+ const startedAt = new Date().toISOString();
102
+ const record = {
103
+ schemaVersion: 1,
104
+ id: runId,
105
+ status: "preparing",
106
+ model: manifest.name,
107
+ modelVersion: manifest.modelVersion,
108
+ adapterVersion: manifest.adapterVersion,
109
+ moldeskVersion: VERSION,
110
+ runtime: installed.runtime.kind,
111
+ installationFingerprint: installed.runtime.fingerprint,
112
+ manifestSha256: digest(manifest),
113
+ hardware,
114
+ input: inputRecords,
115
+ parameters: { supplied: options.params ?? {}, effective: effectiveParams },
116
+ command: { executable: "", args: [], cwd: runDirPath },
117
+ startedAt,
118
+ outputs: [],
119
+ logs: { stdout: path.join(runDirPath, "stdout.log"), stderr: path.join(runDirPath, "stderr.log") },
120
+ };
121
+ // --- Step 6: write the initial record. From this point, every path below finalizes run.json. ---
122
+ writeRunRecord(runDirPath, record);
123
+ try {
124
+ // --- Step 7: build the structured command. ---
125
+ const commandSpec = await adapter.command({
126
+ manifestName: manifest.name,
127
+ inputPath: storedInputPath,
128
+ outputDir,
129
+ modelDir: installed.installDir,
130
+ assetsDir: path.join(installed.installDir, "assets"),
131
+ params: effectiveParams,
132
+ runtimeExecutable: installed.runtime.python.executable,
133
+ platform: installed.runtime.python.platform,
134
+ ...(companionInputs.length > 0 ? { companionInputs } : {}),
135
+ });
136
+ record.command = {
137
+ executable: commandSpec.executable,
138
+ args: commandSpec.args,
139
+ cwd: runDirPath,
140
+ ...(commandSpec.env ? { env: redactEnv(commandSpec.env) } : {}),
141
+ };
142
+ // --- Step 8: execute. Rewritten to "running" with a real pid the instant the process spawns. ---
143
+ const provider = options.runtimeProvider ?? new PythonRuntimeProvider();
144
+ const execution = await provider.execute({
145
+ executable: commandSpec.executable,
146
+ args: commandSpec.args,
147
+ cwd: runDirPath,
148
+ env: commandSpec.env,
149
+ signal: options.signal,
150
+ onSpawn: (pid) => {
151
+ record.status = "running";
152
+ record.process = { pid };
153
+ writeRunRecord(runDirPath, record);
154
+ },
155
+ // --- Step 9: live log tee for the CLI; the provider itself persists stdout.log/stderr.log. ---
156
+ onStdout: (chunk) => options.onLog?.("stdout", chunk),
157
+ onStderr: (chunk) => options.onLog?.("stderr", chunk),
158
+ });
159
+ record.logs = { stdout: execution.stdoutPath, stderr: execution.stderrPath };
160
+ // `execution.pid` is optional per `ExecutionResult`; fall back to the pid already
161
+ // captured via `onSpawn` rather than letting a provider that omits it erase the record.
162
+ record.process = { pid: execution.pid ?? record.process?.pid, exitCode: execution.exitCode, signal: execution.signal };
163
+ // --- Step 11: exit-code precedence — cancelled, then nonzero exit, then "exit 0 isn't sufficient". ---
164
+ if (execution.cancelled) {
165
+ record.status = "cancelled";
166
+ record.error = { code: "RUN_CANCELLED", message: "Run was cancelled." };
167
+ }
168
+ else if (execution.exitCode !== 0) {
169
+ record.status = "failed";
170
+ record.error = {
171
+ code: "RUN_EXECUTION_FAILED",
172
+ message: `${modelName} exited with code ${execution.exitCode}.`,
173
+ };
174
+ }
175
+ else {
176
+ const collected = await adapter.collectOutputs({
177
+ manifestName: manifest.name,
178
+ inputPath: storedInputPath,
179
+ outputDir,
180
+ modelDir: installed.installDir,
181
+ assetsDir: path.join(installed.installDir, "assets"),
182
+ params: effectiveParams,
183
+ runtimeExecutable: installed.runtime.python.executable,
184
+ platform: installed.runtime.python.platform,
185
+ ...(companionInputs.length > 0 ? { companionInputs } : {}),
186
+ });
187
+ // --- Step 12: checksum + size each output. ---
188
+ record.outputs = await Promise.all(collected.map(async (output) => ({
189
+ id: output.id,
190
+ path: output.path,
191
+ sha256: await sha256File(output.path),
192
+ sizeBytes: fs.statSync(output.path).size,
193
+ })));
194
+ record.status = "succeeded";
195
+ }
196
+ }
197
+ catch (error) {
198
+ record.status = options.signal?.aborted ? "cancelled" : "failed";
199
+ record.error = toErrorInfo(error, "RUN_EXECUTION_FAILED");
200
+ }
201
+ // --- Step 13: write the final record atomically. ---
202
+ const finishedAt = new Date().toISOString();
203
+ record.finishedAt = finishedAt;
204
+ record.durationMs = Date.parse(finishedAt) - Date.parse(startedAt);
205
+ writeRunRecord(runDirPath, record);
206
+ const result = { status: record.status, record };
207
+ // --- --output: a distinct, post-finalization copy step; never changes the run's own status. ---
208
+ if (result.status === "succeeded" && options.outputDir) {
209
+ try {
210
+ fs.mkdirSync(options.outputDir, { recursive: true });
211
+ for (const output of record.outputs) {
212
+ const target = path.join(options.outputDir, path.basename(output.path));
213
+ fs.copyFileSync(output.path, target, fs.constants.COPYFILE_EXCL);
214
+ }
215
+ }
216
+ catch (error) {
217
+ const message = error instanceof Error ? error.message : String(error);
218
+ result.exportError = { code: "OUTPUT_COLLISION", message };
219
+ }
220
+ }
221
+ return result;
222
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,210 @@
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 { getMoldeskPaths } from "@moldesk/runtime";
6
+ import { listAvailableModels } from "@moldesk/registry";
7
+ import { createInstallationPlan, installModel } from "./installation.js";
8
+ import { runModel } from "./run.js";
9
+ const homes = [];
10
+ function tempPaths() {
11
+ // Intentionally includes a space to exercise the "paths containing spaces" requirement.
12
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "moldesk core run "));
13
+ homes.push(home);
14
+ return getMoldeskPaths({ MOLDESK_HOME: home });
15
+ }
16
+ afterEach(() => {
17
+ for (const home of homes.splice(0))
18
+ fs.rmSync(home, { recursive: true, force: true });
19
+ });
20
+ function successfulInstallRunner(revision) {
21
+ return async (command, args) => {
22
+ if (command === "git" && args[0] === "init") {
23
+ const source = args[1];
24
+ fs.mkdirSync(path.join(source, "vanilla_model_weights"), { recursive: true });
25
+ fs.mkdirSync(path.join(path.dirname(source), "assets", "vanilla_model_weights"), { recursive: true });
26
+ fs.writeFileSync(path.join(source, "protein_mpnn_run.py"), "# pinned source\n");
27
+ fs.writeFileSync(path.join(source, "vanilla_model_weights", "v_48_020.pt"), "weights");
28
+ fs.writeFileSync(path.join(path.dirname(source), "assets", "vanilla_model_weights", "v_48_020.pt"), "weights");
29
+ }
30
+ if (command === "git" && args.includes("rev-parse"))
31
+ return { code: 0, stdout: `${revision}\n`, stderr: "" };
32
+ if (command === "uv" && args[0] === "venv")
33
+ fs.mkdirSync(path.join(args.at(-1), "bin"), { recursive: true });
34
+ if (command === "uv" && args.includes("freeze"))
35
+ return { code: 0, stdout: "numpy==1.26.4\ntorch==2.2.1\n", stderr: "" };
36
+ if (args[0] === "--version")
37
+ return { code: 0, stdout: "Python 3.11.9\n", stderr: "" };
38
+ return { code: 0, stdout: "", stderr: "" };
39
+ };
40
+ }
41
+ /** Installs a real ProteinMPNN fixture (fake install commands, no network) so `runModel` has a genuine `InstalledModel` to resolve. */
42
+ async function installFixture(paths) {
43
+ const manifest = { ...listAvailableModels().find((item) => item.name === "proteinmpnn"), assets: [] };
44
+ const runtime = manifest.runtimes.find((item) => item.kind === "python");
45
+ const planned = await createInstallationPlan(manifest, runtime, paths);
46
+ await installModel(planned, { paths, runner: successfulInstallRunner(manifest.source.revision), uvExecutable: "uv" });
47
+ return planned.targetDir;
48
+ }
49
+ /** A fake RuntimeProvider whose `execute()` is fully scripted by the test. */
50
+ function fakeProvider(behavior) {
51
+ return {
52
+ kind: "python",
53
+ async inspect() {
54
+ return { kind: "python", available: true };
55
+ },
56
+ async prepare(request) {
57
+ return { kind: "python", executable: request.targetDir, fingerprint: request.runtimeFingerprint };
58
+ },
59
+ async execute(request) {
60
+ request.onSpawn?.(4242);
61
+ return behavior(request);
62
+ },
63
+ async remove() { },
64
+ };
65
+ }
66
+ function writeSequenceOutput(request) {
67
+ const seqsDir = path.join(request.cwd, "output", "seqs");
68
+ fs.mkdirSync(seqsDir, { recursive: true });
69
+ fs.writeFileSync(path.join(seqsDir, "input.fa"), ">input\nMKV\n");
70
+ }
71
+ function writeInputFixture(dir, name = "input.pdb") {
72
+ fs.mkdirSync(dir, { recursive: true });
73
+ const inputPath = path.join(dir, name);
74
+ fs.writeFileSync(inputPath, "ATOM 1 N MET A 1\n");
75
+ return inputPath;
76
+ }
77
+ describe("runModel", () => {
78
+ it("succeeds end-to-end, checksumming outputs and finalizing run.json", async () => {
79
+ const paths = tempPaths();
80
+ await installFixture(paths);
81
+ const inputPath = writeInputFixture(paths.home);
82
+ const result = await runModel("proteinmpnn", inputPath, {
83
+ paths,
84
+ runtimeProvider: fakeProvider((request) => {
85
+ writeSequenceOutput(request);
86
+ return { exitCode: 0, stdoutPath: path.join(request.cwd, "stdout.log"), stderrPath: path.join(request.cwd, "stderr.log") };
87
+ }),
88
+ });
89
+ expect(result.status).toBe("succeeded");
90
+ expect(result.record.outputs).toHaveLength(1);
91
+ expect(result.record.outputs[0].sha256).toMatch(/^[0-9a-f]{64}$/);
92
+ expect(result.record.process?.pid).toBe(4242);
93
+ const onDisk = JSON.parse(fs.readFileSync(path.join(result.record.command.cwd, "run.json"), "utf8"));
94
+ expect(onDisk.status).toBe("succeeded");
95
+ }, 15000);
96
+ it("fails on nonzero exit without attempting output collection", async () => {
97
+ const paths = tempPaths();
98
+ await installFixture(paths);
99
+ const inputPath = writeInputFixture(paths.home);
100
+ let collectAttempted = false;
101
+ const result = await runModel("proteinmpnn", inputPath, {
102
+ paths,
103
+ runtimeProvider: fakeProvider((request) => {
104
+ // Deliberately do NOT write an output file; if collectOutputs ran, it would throw
105
+ // MISSING_REQUIRED_OUTPUT instead of leaving RUN_EXECUTION_FAILED as the error.
106
+ collectAttempted = fs.existsSync(path.join(request.cwd, "output", "seqs"));
107
+ return { exitCode: 1, stdoutPath: path.join(request.cwd, "stdout.log"), stderrPath: path.join(request.cwd, "stderr.log") };
108
+ }),
109
+ });
110
+ expect(collectAttempted).toBe(false);
111
+ expect(result.status).toBe("failed");
112
+ expect(result.record.error?.code).toBe("RUN_EXECUTION_FAILED");
113
+ expect(result.record.outputs).toHaveLength(0);
114
+ });
115
+ it("fails on missing required output despite exit code 0", async () => {
116
+ const paths = tempPaths();
117
+ await installFixture(paths);
118
+ const inputPath = writeInputFixture(paths.home);
119
+ const result = await runModel("proteinmpnn", inputPath, {
120
+ paths,
121
+ runtimeProvider: fakeProvider((request) => ({
122
+ exitCode: 0,
123
+ stdoutPath: path.join(request.cwd, "stdout.log"),
124
+ stderrPath: path.join(request.cwd, "stderr.log"),
125
+ })),
126
+ });
127
+ expect(result.status).toBe("failed");
128
+ expect(result.record.error?.code).toBe("MISSING_REQUIRED_OUTPUT");
129
+ });
130
+ it("rejects for an invalid (non-.pdb) input before allocating a run directory", async () => {
131
+ const paths = tempPaths();
132
+ await installFixture(paths);
133
+ const inputPath = writeInputFixture(paths.home, "input.txt");
134
+ await expect(runModel("proteinmpnn", inputPath, { paths })).rejects.toMatchObject({ code: "INVALID_RUN_INPUT" });
135
+ expect(fs.existsSync(paths.runs)).toBe(false);
136
+ });
137
+ it("rejects for invalid params before allocating a run directory", async () => {
138
+ const paths = tempPaths();
139
+ await installFixture(paths);
140
+ const inputPath = writeInputFixture(paths.home);
141
+ await expect(runModel("proteinmpnn", inputPath, { paths, params: { num_seq_per_target: "not-a-number" } })).rejects.toMatchObject({ code: "INVALID_RUN_PARAMS" });
142
+ expect(fs.existsSync(paths.runs)).toBe(false);
143
+ });
144
+ it("marks a run cancelled regardless of the reported exit code", async () => {
145
+ const paths = tempPaths();
146
+ await installFixture(paths);
147
+ const inputPath = writeInputFixture(paths.home);
148
+ const result = await runModel("proteinmpnn", inputPath, {
149
+ paths,
150
+ runtimeProvider: fakeProvider((request) => ({
151
+ exitCode: 0,
152
+ stdoutPath: path.join(request.cwd, "stdout.log"),
153
+ stderrPath: path.join(request.cwd, "stderr.log"),
154
+ cancelled: true,
155
+ })),
156
+ });
157
+ expect(result.status).toBe("cancelled");
158
+ expect(result.record.error?.code).toBe("RUN_CANCELLED");
159
+ });
160
+ it("finalizes run.json even when execution throws unexpectedly after allocation", async () => {
161
+ const paths = tempPaths();
162
+ await installFixture(paths);
163
+ const inputPath = writeInputFixture(paths.home);
164
+ const result = await runModel("proteinmpnn", inputPath, {
165
+ paths,
166
+ runtimeProvider: fakeProvider(() => {
167
+ throw new Error("simulated runtime crash");
168
+ }),
169
+ });
170
+ expect(result.status).toBe("failed");
171
+ expect(result.record.error?.message).toContain("simulated runtime crash");
172
+ expect(result.record.finishedAt).toBeDefined();
173
+ });
174
+ it("writes status:running with the real pid before execute() resolves", async () => {
175
+ const paths = tempPaths();
176
+ await installFixture(paths);
177
+ const inputPath = writeInputFixture(paths.home);
178
+ let observedMidRun;
179
+ await runModel("proteinmpnn", inputPath, {
180
+ paths,
181
+ runtimeProvider: fakeProvider((request) => {
182
+ writeSequenceOutput(request);
183
+ const runJsonPath = path.join(request.cwd, "run.json");
184
+ observedMidRun = JSON.parse(fs.readFileSync(runJsonPath, "utf8"));
185
+ return { exitCode: 0, stdoutPath: path.join(request.cwd, "stdout.log"), stderrPath: path.join(request.cwd, "stderr.log") };
186
+ }),
187
+ });
188
+ expect(observedMidRun?.status).toBe("running");
189
+ expect(observedMidRun?.process?.pid).toBe(4242);
190
+ });
191
+ it("copies outputs to --output and reports a collision without changing the run's own status", async () => {
192
+ const paths = tempPaths();
193
+ await installFixture(paths);
194
+ const inputPath = writeInputFixture(paths.home);
195
+ const exportDir = fs.mkdtempSync(path.join(os.tmpdir(), "moldesk core run export "));
196
+ homes.push(exportDir);
197
+ fs.writeFileSync(path.join(exportDir, "input.fa"), "pre-existing");
198
+ const result = await runModel("proteinmpnn", inputPath, {
199
+ paths,
200
+ outputDir: exportDir,
201
+ runtimeProvider: fakeProvider((request) => {
202
+ writeSequenceOutput(request);
203
+ return { exitCode: 0, stdoutPath: path.join(request.cwd, "stdout.log"), stderrPath: path.join(request.cwd, "stderr.log") };
204
+ }),
205
+ });
206
+ expect(result.status).toBe("succeeded");
207
+ expect(result.exportError?.code).toBe("OUTPUT_COLLISION");
208
+ expect(fs.readFileSync(path.join(exportDir, "input.fa"), "utf8")).toBe("pre-existing");
209
+ });
210
+ });
@@ -0,0 +1 @@
1
+ export declare const VERSION: string;
@@ -0,0 +1,9 @@
1
+ import fs from "node:fs";
2
+ // Read from this package's own package.json (one level above both src/ and dist/)
3
+ // so the reported version can never drift from the published one. A hard-coded
4
+ // constant was missed by the 0.0.2 bump and made `moldesk --version` print 0.0.1.
5
+ function readVersion() {
6
+ const manifest = new URL("../package.json", import.meta.url);
7
+ return JSON.parse(fs.readFileSync(manifest, "utf8")).version;
8
+ }
9
+ export const VERSION = readVersion();
@@ -0,0 +1 @@
1
+ export {};