@moldesk/core 0.0.4 → 0.0.6
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/cloud/accounts.d.ts +69 -0
- package/dist/cloud/accounts.js +113 -0
- package/dist/cloud/accounts.test.d.ts +1 -0
- package/dist/cloud/accounts.test.js +188 -0
- package/dist/cloud/backend.test.d.ts +1 -0
- package/dist/cloud/backend.test.js +339 -0
- package/dist/cloud/deps.d.ts +15 -0
- package/dist/cloud/deps.js +30 -0
- package/dist/cloud/manage.d.ts +57 -0
- package/dist/cloud/manage.js +210 -0
- package/dist/cloud/manage.test.d.ts +1 -0
- package/dist/cloud/manage.test.js +105 -0
- package/dist/cloud/quote.d.ts +131 -0
- package/dist/cloud/quote.js +225 -0
- package/dist/cloud/quote.test.d.ts +1 -0
- package/dist/cloud/quote.test.js +264 -0
- package/dist/cloud/run.d.ts +114 -0
- package/dist/cloud/run.js +498 -0
- package/dist/cloud/run.test.d.ts +1 -0
- package/dist/cloud/run.test.js +597 -0
- package/dist/cloud/state.d.ts +159 -0
- package/dist/cloud/state.js +395 -0
- package/dist/cloud/state.test.d.ts +1 -0
- package/dist/cloud/state.test.js +199 -0
- package/dist/cloud/worker/cache.d.ts +75 -0
- package/dist/cloud/worker/cache.js +152 -0
- package/dist/cloud/worker/cache.test.d.ts +1 -0
- package/dist/cloud/worker/cache.test.js +123 -0
- package/dist/cloud/worker/main.d.ts +2 -0
- package/dist/cloud/worker/main.js +34 -0
- package/dist/cloud/worker/pipeline.d.ts +26 -0
- package/dist/cloud/worker/pipeline.js +160 -0
- package/dist/cloud/worker/pipeline.test.d.ts +1 -0
- package/dist/cloud/worker/pipeline.test.js +298 -0
- package/dist/cloud/worker/server.d.ts +38 -0
- package/dist/cloud/worker/server.js +287 -0
- package/dist/cloud/worker/server.test.d.ts +1 -0
- package/dist/cloud/worker/server.test.js +391 -0
- package/dist/cloud/worker/watchdog.d.ts +27 -0
- package/dist/cloud/worker/watchdog.js +46 -0
- package/dist/cloud/worker-release.d.ts +12 -0
- package/dist/cloud/worker-release.js +25 -0
- package/dist/cloud/worker-release.test.d.ts +1 -0
- package/dist/cloud/worker-release.test.js +21 -0
- package/dist/index.d.ts +17 -3
- package/dist/index.js +9 -2
- package/dist/run-lifecycle.d.ts +74 -0
- package/dist/run-lifecycle.js +178 -0
- package/dist/run.d.ts +61 -1
- package/dist/run.js +54 -125
- package/dist/run.test.js +47 -1
- package/package.json +10 -6
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { validateParams } from "@moldesk/adapters";
|
|
5
|
+
import { getSystemReport, runDir, stageCompanionInputs, } from "@moldesk/runtime";
|
|
6
|
+
import { MoldeskError } from "./errors.js";
|
|
7
|
+
import { digest, sha256File } from "./hash.js";
|
|
8
|
+
import { generateRunReport } from "./run-report.js";
|
|
9
|
+
import { VERSION } from "./version.js";
|
|
10
|
+
export function toErrorInfo(error, fallbackCode) {
|
|
11
|
+
if (error instanceof MoldeskError)
|
|
12
|
+
return { code: error.code, message: error.message };
|
|
13
|
+
return { code: fallbackCode, message: error instanceof Error ? error.message : String(error) };
|
|
14
|
+
}
|
|
15
|
+
export function writeRunRecord(runDirPath, record) {
|
|
16
|
+
const file = path.join(runDirPath, "run.json");
|
|
17
|
+
const temporary = `${file}.tmp-${process.pid}-${randomUUID()}`;
|
|
18
|
+
fs.writeFileSync(temporary, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 });
|
|
19
|
+
fs.renameSync(temporary, file);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Step 3 shared by local and cloud runs: applies platform-dependent adapter defaults (an explicit
|
|
23
|
+
* `--param` always wins), then validates. Rejects with INVALID_RUN_PARAMS before any run directory exists.
|
|
24
|
+
*/
|
|
25
|
+
export function prepareRunParams(adapter, manifestName, installed, params) {
|
|
26
|
+
// Platform-dependent defaults (e.g. Boltz's `accelerator`) come from the adapter,
|
|
27
|
+
// not a static `ParamDescriptor.default`, since the right value depends on which
|
|
28
|
+
// platform-specific runtime entry was actually installed. Pre-seed `supplied` with
|
|
29
|
+
// them (as strings, since `validateParams` coerces from `--param` strings) for any
|
|
30
|
+
// key the caller didn't explicitly pass — an explicit `--param` always wins.
|
|
31
|
+
const dynamicDefaults = adapter.resolveParamDefaults?.({ manifestName, installed }) ?? {};
|
|
32
|
+
const suppliedParams = { ...(params ?? {}) };
|
|
33
|
+
for (const [key, value] of Object.entries(dynamicDefaults)) {
|
|
34
|
+
if (suppliedParams[key] === undefined)
|
|
35
|
+
suppliedParams[key] = String(value);
|
|
36
|
+
}
|
|
37
|
+
const { effective, errors } = validateParams(adapter.params ?? [], suppliedParams);
|
|
38
|
+
if (errors.length > 0) {
|
|
39
|
+
throw new MoldeskError({
|
|
40
|
+
code: "INVALID_RUN_PARAMS",
|
|
41
|
+
message: errors.join("; "),
|
|
42
|
+
remediation: "Fix the listed --param values and retry.",
|
|
43
|
+
details: { errors },
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
return { suppliedParams, effectiveParams: effective };
|
|
47
|
+
}
|
|
48
|
+
/** Allocates the run ID and its `input/` and `output/` directories. */
|
|
49
|
+
export function allocateRunDirectory(paths) {
|
|
50
|
+
const runId = `${new Date().toISOString().replace(/[:.]/g, "-")}-${randomUUID()}`;
|
|
51
|
+
const runDirPath = runDir(runId, { MOLDESK_HOME: paths.home });
|
|
52
|
+
const inputDir = path.join(runDirPath, "input");
|
|
53
|
+
const outputDir = path.join(runDirPath, "output");
|
|
54
|
+
fs.mkdirSync(inputDir, { recursive: true });
|
|
55
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
56
|
+
return { runId, runDirPath, inputDir, outputDir };
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Copies and hashes the job input (and declared companion inputs), captures hardware, and writes
|
|
60
|
+
* the initial `preparing` record. A failure here finalizes `run.json` as failed/cancelled instead
|
|
61
|
+
* of rejecting, since the run directory already exists.
|
|
62
|
+
*/
|
|
63
|
+
export async function createRunSnapshot(params) {
|
|
64
|
+
const { dirs, manifest, adapter, resolvedInputPath } = params;
|
|
65
|
+
const inputRecords = [];
|
|
66
|
+
let hardware;
|
|
67
|
+
const buildRecord = (system, startedAt) => ({
|
|
68
|
+
schemaVersion: 1,
|
|
69
|
+
id: dirs.runId,
|
|
70
|
+
status: "preparing",
|
|
71
|
+
model: manifest.name,
|
|
72
|
+
modelVersion: manifest.modelVersion,
|
|
73
|
+
adapterVersion: manifest.adapterVersion,
|
|
74
|
+
moldeskVersion: VERSION,
|
|
75
|
+
runtime: params.runtime,
|
|
76
|
+
installationFingerprint: params.installationFingerprint,
|
|
77
|
+
manifestSha256: digest(manifest),
|
|
78
|
+
hardware: system,
|
|
79
|
+
input: inputRecords,
|
|
80
|
+
parameters: { supplied: params.suppliedParams, effective: params.effectiveParams },
|
|
81
|
+
command: { executable: "", args: [], cwd: dirs.runDirPath },
|
|
82
|
+
startedAt,
|
|
83
|
+
outputs: [],
|
|
84
|
+
logs: { stdout: path.join(dirs.runDirPath, "stdout.log"), stderr: path.join(dirs.runDirPath, "stderr.log") },
|
|
85
|
+
});
|
|
86
|
+
try {
|
|
87
|
+
hardware = await getSystemReport();
|
|
88
|
+
// --- Step 5: copy/snapshot the original input. ---
|
|
89
|
+
const storedInputPath = path.join(dirs.inputDir, path.basename(resolvedInputPath));
|
|
90
|
+
fs.copyFileSync(resolvedInputPath, storedInputPath);
|
|
91
|
+
const inputSha256 = await sha256File(storedInputPath);
|
|
92
|
+
inputRecords.push({ originalPath: resolvedInputPath, storedPath: storedInputPath, sha256: inputSha256, role: "job" });
|
|
93
|
+
// Models whose job file references separate companion files (e.g. DiffDock-L's
|
|
94
|
+
// protein PDB + ligand file) declare them via `resolveCompanionInputs`, given the
|
|
95
|
+
// *original* (pre-copy) resolved job path so relative references resolve against
|
|
96
|
+
// where the job file actually lives. Absent means unchanged single-file behavior.
|
|
97
|
+
const companionRefs = await adapter.resolveCompanionInputs?.(resolvedInputPath) ?? [];
|
|
98
|
+
const stagedCompanions = companionRefs.length > 0
|
|
99
|
+
? stageCompanionInputs({ jobFilePath: resolvedInputPath, refs: companionRefs, inputDir: dirs.inputDir })
|
|
100
|
+
: [];
|
|
101
|
+
for (const staged of stagedCompanions) {
|
|
102
|
+
inputRecords.push({
|
|
103
|
+
originalPath: staged.originalPath,
|
|
104
|
+
storedPath: staged.storedPath,
|
|
105
|
+
sha256: await sha256File(staged.storedPath),
|
|
106
|
+
role: staged.id,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
const companionInputs = stagedCompanions.map((staged) => ({ id: staged.id, path: staged.storedPath }));
|
|
110
|
+
const startedAt = new Date().toISOString();
|
|
111
|
+
const record = buildRecord(hardware, startedAt);
|
|
112
|
+
// --- Step 6: write the initial record. From this point, every path below finalizes run.json. ---
|
|
113
|
+
writeRunRecord(dirs.runDirPath, record);
|
|
114
|
+
return { ok: true, record, startedAt, storedInputPath, companionInputs };
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
// If hardware capture itself failed, retry once; a second failure leaves no record to finalize
|
|
118
|
+
// and propagates (no fabricated hardware report).
|
|
119
|
+
hardware ??= await getSystemReport();
|
|
120
|
+
const startedAt = new Date().toISOString();
|
|
121
|
+
const record = buildRecord(hardware, startedAt);
|
|
122
|
+
record.status = params.signal?.aborted ? "cancelled" : "failed";
|
|
123
|
+
record.error = toErrorInfo(error, "RUN_SNAPSHOT_FAILED");
|
|
124
|
+
const finishedAt = new Date().toISOString();
|
|
125
|
+
record.finishedAt = finishedAt;
|
|
126
|
+
record.durationMs = Date.parse(finishedAt) - Date.parse(startedAt);
|
|
127
|
+
writeRunRecord(dirs.runDirPath, record);
|
|
128
|
+
return { ok: false, record };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/** Checksums and sizes the adapter's collected outputs (spec step 12). */
|
|
132
|
+
export async function collectRunOutputs(adapter, context) {
|
|
133
|
+
const collected = await adapter.collectOutputs(context);
|
|
134
|
+
return Promise.all(collected.map(async (output) => ({
|
|
135
|
+
id: output.id,
|
|
136
|
+
path: output.path,
|
|
137
|
+
sha256: await sha256File(output.path),
|
|
138
|
+
sizeBytes: fs.statSync(output.path).size,
|
|
139
|
+
})));
|
|
140
|
+
}
|
|
141
|
+
/** Derived report, final atomic `run.json` write, and the post-finalization `--output` copy. */
|
|
142
|
+
export async function finalizeRun(params) {
|
|
143
|
+
const { adapter, context, runDirPath, record, startedAt, signal, visualize, outputDir } = params;
|
|
144
|
+
// --- Derived report. Deliberately outside the execution try/catch: describing or exporting a
|
|
145
|
+
// report can only ever add a warning, never change the model's outcome or error. ---
|
|
146
|
+
if (record.status === "succeeded" && visualize !== false) {
|
|
147
|
+
const report = await generateRunReport({ adapter, context, runDirPath, record, signal });
|
|
148
|
+
if (report.artifact)
|
|
149
|
+
record.artifacts = [report.artifact];
|
|
150
|
+
if (report.warning)
|
|
151
|
+
record.warnings = [...(record.warnings ?? []), report.warning];
|
|
152
|
+
}
|
|
153
|
+
// --- Step 13: write the final record atomically. ---
|
|
154
|
+
const finishedAt = new Date().toISOString();
|
|
155
|
+
record.finishedAt = finishedAt;
|
|
156
|
+
record.durationMs = Date.parse(finishedAt) - Date.parse(startedAt);
|
|
157
|
+
writeRunRecord(runDirPath, record);
|
|
158
|
+
const result = { status: record.status, record };
|
|
159
|
+
// --- --output: a distinct, post-finalization copy step; never changes the run's own status. ---
|
|
160
|
+
if (result.status === "succeeded" && outputDir) {
|
|
161
|
+
try {
|
|
162
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
163
|
+
for (const output of record.outputs) {
|
|
164
|
+
const target = path.join(outputDir, path.basename(output.path));
|
|
165
|
+
fs.copyFileSync(output.path, target, fs.constants.COPYFILE_EXCL);
|
|
166
|
+
}
|
|
167
|
+
// The report is self-contained; copy it under a name that cannot collide with outputs.
|
|
168
|
+
for (const artifact of record.artifacts ?? []) {
|
|
169
|
+
fs.copyFileSync(artifact.path, path.join(outputDir, `moldesk-report-${record.id}.html`), fs.constants.COPYFILE_EXCL);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
174
|
+
result.exportError = { code: "OUTPUT_COLLISION", message };
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return result;
|
|
178
|
+
}
|
package/dist/run.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type RuntimeKind } from "@moldesk/registry";
|
|
2
2
|
import { type MoldeskPaths, type RuntimeProvider, type SystemReport } from "@moldesk/runtime";
|
|
3
|
-
import {
|
|
3
|
+
import type { CloudRunDeps, CloudRunOptions } from "./cloud/run.js";
|
|
4
|
+
import type { RunArtifact, RunWarning } from "./run-report.js";
|
|
4
5
|
/**
|
|
5
6
|
* `runModel` only rejects for usage errors discovered before a run directory
|
|
6
7
|
* is allocated (unknown model, not installed, invalid input/params — spec
|
|
@@ -24,6 +25,16 @@ export interface RunModelOptions {
|
|
|
24
25
|
runtimeProvider?: RuntimeProvider;
|
|
25
26
|
/** Create the offline molecular report for a successful run when the adapter describes one. Default true. */
|
|
26
27
|
visualize?: boolean;
|
|
28
|
+
/** `"local"` (default) runs on this machine; `"runpod"` runs on rented compute and requires `cloud`. */
|
|
29
|
+
compute?: "local" | "runpod";
|
|
30
|
+
/** Approved quote and limits for a remote run. Rejected for local runs. */
|
|
31
|
+
cloud?: CloudRunOptions;
|
|
32
|
+
/** Injected for tests; defaults to the production Runpod wiring. */
|
|
33
|
+
cloudDeps?: CloudRunDeps;
|
|
34
|
+
onProgress?: (event: {
|
|
35
|
+
stage: string;
|
|
36
|
+
message?: string;
|
|
37
|
+
}) => void;
|
|
27
38
|
}
|
|
28
39
|
export interface RunRecord {
|
|
29
40
|
schemaVersion: 1;
|
|
@@ -83,6 +94,47 @@ export interface RunRecord {
|
|
|
83
94
|
artifacts?: RunArtifact[];
|
|
84
95
|
/** Nonfatal problems, e.g. the report could not be created. They never change `status`. */
|
|
85
96
|
warnings?: RunWarning[];
|
|
97
|
+
/** Present only for remote runs. `hardware` above stays the local client's; the machine that ran inference is here. */
|
|
98
|
+
compute?: RunCompute;
|
|
99
|
+
}
|
|
100
|
+
export interface RunCompute {
|
|
101
|
+
provider: string;
|
|
102
|
+
cachePolicy: "ephemeral" | "persistent";
|
|
103
|
+
quoteId: string;
|
|
104
|
+
/** Last pipeline stage reached, e.g. "running" or "collecting". */
|
|
105
|
+
stage: string;
|
|
106
|
+
datacenterId: string;
|
|
107
|
+
gpuId: string;
|
|
108
|
+
/** Provider resource IDs that were created for this run (pods and volumes). */
|
|
109
|
+
resources: Array<{
|
|
110
|
+
role: "cpu" | "gpu" | "volume";
|
|
111
|
+
providerId: string;
|
|
112
|
+
}>;
|
|
113
|
+
/** What actually ran inference, reported by the worker. */
|
|
114
|
+
hardware?: {
|
|
115
|
+
name: string;
|
|
116
|
+
memoryMb?: number;
|
|
117
|
+
driverVersion?: string;
|
|
118
|
+
cudaVersion?: string;
|
|
119
|
+
};
|
|
120
|
+
/** POSIX command and cwd on the worker; `record.command.cwd` stays the local run directory. */
|
|
121
|
+
remoteCommand?: {
|
|
122
|
+
executable: string;
|
|
123
|
+
args: string[];
|
|
124
|
+
cwd: string;
|
|
125
|
+
};
|
|
126
|
+
pythonVersion?: string;
|
|
127
|
+
dependencyFreeze?: string[];
|
|
128
|
+
sourceRevision?: string;
|
|
129
|
+
cleanup: "confirmed" | "pending";
|
|
130
|
+
/** Warnings the user explicitly acknowledged before approval, e.g. "lifetime-risk". */
|
|
131
|
+
acknowledgedRisks?: string[];
|
|
132
|
+
workerLogCursor?: number;
|
|
133
|
+
/** `complete: false` means the worker evicted log chunks before they could be fetched (bounded tail). */
|
|
134
|
+
logs?: {
|
|
135
|
+
complete: boolean;
|
|
136
|
+
lostChunks: number;
|
|
137
|
+
};
|
|
86
138
|
}
|
|
87
139
|
export type { RunArtifact, RunWarning };
|
|
88
140
|
export interface RunModelResult {
|
|
@@ -93,5 +145,13 @@ export interface RunModelResult {
|
|
|
93
145
|
code: string;
|
|
94
146
|
message: string;
|
|
95
147
|
};
|
|
148
|
+
/** Remote runs only: paid resources could not be confirmed deleted. The scientific result is still valid. */
|
|
149
|
+
cleanupPending?: {
|
|
150
|
+
code: "CLOUD_CLEANUP_PENDING";
|
|
151
|
+
resources: Array<{
|
|
152
|
+
role: "cpu" | "gpu" | "volume";
|
|
153
|
+
providerId: string;
|
|
154
|
+
}>;
|
|
155
|
+
};
|
|
96
156
|
}
|
|
97
157
|
export declare function runModel(modelName: string, inputPath: string, options?: RunModelOptions): Promise<RunModelResult>;
|
package/dist/run.js
CHANGED
|
@@ -1,30 +1,39 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
2
|
-
import fs from "node:fs";
|
|
3
1
|
import path from "node:path";
|
|
4
|
-
import { getAdapter
|
|
2
|
+
import { getAdapter } from "@moldesk/adapters";
|
|
5
3
|
import { listAvailableModels } from "@moldesk/registry";
|
|
6
|
-
import { getMoldeskPaths,
|
|
4
|
+
import { getMoldeskPaths, redactEnv, PythonRuntimeProvider, } from "@moldesk/runtime";
|
|
7
5
|
import { MoldeskError } from "./errors.js";
|
|
8
|
-
import { digest, sha256File } from "./hash.js";
|
|
9
6
|
import { listInstalledModels } from "./install-state.js";
|
|
10
|
-
import {
|
|
11
|
-
import { VERSION } from "./version.js";
|
|
7
|
+
import { allocateRunDirectory, collectRunOutputs, createRunSnapshot, finalizeRun, prepareRunParams, toErrorInfo, writeRunRecord } from "./run-lifecycle.js";
|
|
12
8
|
function runFailure(code, message, remediation, details) {
|
|
13
9
|
return new MoldeskError({ code, message, remediation, details });
|
|
14
10
|
}
|
|
15
|
-
function toErrorInfo(error, fallbackCode) {
|
|
16
|
-
if (error instanceof MoldeskError)
|
|
17
|
-
return { code: error.code, message: error.message };
|
|
18
|
-
return { code: fallbackCode, message: error instanceof Error ? error.message : String(error) };
|
|
19
|
-
}
|
|
20
|
-
function writeRunRecord(runDirPath, record) {
|
|
21
|
-
const file = path.join(runDirPath, "run.json");
|
|
22
|
-
const temporary = `${file}.tmp-${process.pid}-${randomUUID()}`;
|
|
23
|
-
fs.writeFileSync(temporary, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 });
|
|
24
|
-
fs.renameSync(temporary, file);
|
|
25
|
-
}
|
|
26
11
|
export async function runModel(modelName, inputPath, options = {}) {
|
|
27
12
|
const paths = options.paths ?? getMoldeskPaths();
|
|
13
|
+
// --- Remote execution is dispatched before any local installation lookup: it never needs one. ---
|
|
14
|
+
if (options.compute === "runpod") {
|
|
15
|
+
if (!options.cloud) {
|
|
16
|
+
throw runFailure("CLOUD_OPTIONS_REQUIRED", "Cloud execution needs an approved quote.", "Pass --cache-policy, --max-budget and --max-lifetime and approve the quote.");
|
|
17
|
+
}
|
|
18
|
+
if (options.runtime === "docker") {
|
|
19
|
+
throw runFailure("CLOUD_RUNTIME_UNSUPPORTED", "The docker runtime is not supported for cloud execution.", "Use the python runtime.");
|
|
20
|
+
}
|
|
21
|
+
const { runCloudModel } = await import("./cloud/run.js");
|
|
22
|
+
const { createRunpodCloudDeps } = await import("./cloud/deps.js");
|
|
23
|
+
const deps = options.cloudDeps ?? (await createRunpodCloudDeps({ paths }));
|
|
24
|
+
return runCloudModel(modelName, inputPath, {
|
|
25
|
+
params: options.params,
|
|
26
|
+
outputDir: options.outputDir,
|
|
27
|
+
signal: options.signal,
|
|
28
|
+
onLog: options.onLog,
|
|
29
|
+
visualize: options.visualize,
|
|
30
|
+
cloud: options.cloud,
|
|
31
|
+
onProgress: options.onProgress,
|
|
32
|
+
}, { ...deps, paths });
|
|
33
|
+
}
|
|
34
|
+
if (options.cloud || options.cloudDeps) {
|
|
35
|
+
throw runFailure("CLOUD_OPTIONS_WITHOUT_CLOUD", "Cloud options were given for a local run.", "Add --compute runpod, or remove the cloud options.");
|
|
36
|
+
}
|
|
28
37
|
// --- Step 1: load and validate manifest. No run directory yet: a failure here rejects. ---
|
|
29
38
|
const manifest = listAvailableModels().find((entry) => entry.name === modelName);
|
|
30
39
|
if (!manifest) {
|
|
@@ -52,75 +61,25 @@ export async function runModel(modelName, inputPath, options = {}) {
|
|
|
52
61
|
}
|
|
53
62
|
const resolvedInputPath = path.resolve(inputPath);
|
|
54
63
|
await adapter.validateInput(resolvedInputPath);
|
|
55
|
-
|
|
56
|
-
// not a static `ParamDescriptor.default`, since the right value depends on which
|
|
57
|
-
// platform-specific runtime entry was actually installed. Pre-seed `supplied` with
|
|
58
|
-
// them (as strings, since `validateParams` coerces from `--param` strings) for any
|
|
59
|
-
// key the caller didn't explicitly pass — an explicit `--param` always wins.
|
|
60
|
-
const dynamicDefaults = adapter.resolveParamDefaults?.({ manifestName: manifest.name, installed }) ?? {};
|
|
61
|
-
const suppliedParams = { ...(options.params ?? {}) };
|
|
62
|
-
for (const [key, value] of Object.entries(dynamicDefaults)) {
|
|
63
|
-
if (suppliedParams[key] === undefined)
|
|
64
|
-
suppliedParams[key] = String(value);
|
|
65
|
-
}
|
|
66
|
-
const { effective: effectiveParams, errors: paramErrors } = validateParams(adapter.params ?? [], suppliedParams);
|
|
67
|
-
if (paramErrors.length > 0) {
|
|
68
|
-
throw runFailure("INVALID_RUN_PARAMS", paramErrors.join("; "), "Fix the listed --param values and retry.", { errors: paramErrors });
|
|
69
|
-
}
|
|
64
|
+
const { effectiveParams } = prepareRunParams(adapter, manifest.name, { runtime: installed.runtime }, options.params);
|
|
70
65
|
// --- Step 4: allocate run ID and directory. From here on, every outcome resolves. ---
|
|
71
|
-
const
|
|
72
|
-
const runDirPath
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const storedInputPath = path.join(inputDir, path.basename(resolvedInputPath));
|
|
79
|
-
fs.copyFileSync(resolvedInputPath, storedInputPath);
|
|
80
|
-
const inputSha256 = await sha256File(storedInputPath);
|
|
81
|
-
const inputRecords = [
|
|
82
|
-
{ originalPath: resolvedInputPath, storedPath: storedInputPath, sha256: inputSha256, role: "job" },
|
|
83
|
-
];
|
|
84
|
-
// Models whose job file references separate companion files (e.g. DiffDock-L's
|
|
85
|
-
// protein PDB + ligand file) declare them via `resolveCompanionInputs`, given the
|
|
86
|
-
// *original* (pre-copy) resolved job path so relative references resolve against
|
|
87
|
-
// where the job file actually lives. Absent means unchanged single-file behavior.
|
|
88
|
-
const companionRefs = await adapter.resolveCompanionInputs?.(resolvedInputPath) ?? [];
|
|
89
|
-
const stagedCompanions = companionRefs.length > 0
|
|
90
|
-
? stageCompanionInputs({ jobFilePath: resolvedInputPath, refs: companionRefs, inputDir })
|
|
91
|
-
: [];
|
|
92
|
-
for (const staged of stagedCompanions) {
|
|
93
|
-
inputRecords.push({
|
|
94
|
-
originalPath: staged.originalPath,
|
|
95
|
-
storedPath: staged.storedPath,
|
|
96
|
-
sha256: await sha256File(staged.storedPath),
|
|
97
|
-
role: staged.id,
|
|
98
|
-
});
|
|
99
|
-
}
|
|
100
|
-
const companionInputs = stagedCompanions.map((staged) => ({ id: staged.id, path: staged.storedPath }));
|
|
101
|
-
const hardware = await getSystemReport();
|
|
102
|
-
const startedAt = new Date().toISOString();
|
|
103
|
-
const record = {
|
|
104
|
-
schemaVersion: 1,
|
|
105
|
-
id: runId,
|
|
106
|
-
status: "preparing",
|
|
107
|
-
model: manifest.name,
|
|
108
|
-
modelVersion: manifest.modelVersion,
|
|
109
|
-
adapterVersion: manifest.adapterVersion,
|
|
110
|
-
moldeskVersion: VERSION,
|
|
66
|
+
const dirs = allocateRunDirectory(paths);
|
|
67
|
+
const { runDirPath, outputDir } = dirs;
|
|
68
|
+
// --- Steps 5-6: snapshot inputs and write the initial record. ---
|
|
69
|
+
const snapshot = await createRunSnapshot({
|
|
70
|
+
dirs,
|
|
71
|
+
manifest,
|
|
72
|
+
adapter,
|
|
111
73
|
runtime: installed.runtime.kind,
|
|
112
74
|
installationFingerprint: installed.runtime.fingerprint,
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
};
|
|
122
|
-
// --- Step 6: write the initial record. From this point, every path below finalizes run.json. ---
|
|
123
|
-
writeRunRecord(runDirPath, record);
|
|
75
|
+
resolvedInputPath,
|
|
76
|
+
suppliedParams: options.params ?? {},
|
|
77
|
+
effectiveParams,
|
|
78
|
+
signal: options.signal,
|
|
79
|
+
});
|
|
80
|
+
if (!snapshot.ok)
|
|
81
|
+
return { status: snapshot.record.status, record: snapshot.record };
|
|
82
|
+
const { record, startedAt, storedInputPath, companionInputs } = snapshot;
|
|
124
83
|
const runContext = {
|
|
125
84
|
manifestName: manifest.name,
|
|
126
85
|
inputPath: storedInputPath,
|
|
@@ -175,14 +134,8 @@ export async function runModel(modelName, inputPath, options = {}) {
|
|
|
175
134
|
};
|
|
176
135
|
}
|
|
177
136
|
else {
|
|
178
|
-
const collected = await adapter.collectOutputs(runContext);
|
|
179
137
|
// --- Step 12: checksum + size each output. ---
|
|
180
|
-
record.outputs = await
|
|
181
|
-
id: output.id,
|
|
182
|
-
path: output.path,
|
|
183
|
-
sha256: await sha256File(output.path),
|
|
184
|
-
sizeBytes: fs.statSync(output.path).size,
|
|
185
|
-
})));
|
|
138
|
+
record.outputs = await collectRunOutputs(adapter, runContext);
|
|
186
139
|
record.status = "succeeded";
|
|
187
140
|
}
|
|
188
141
|
}
|
|
@@ -190,38 +143,14 @@ export async function runModel(modelName, inputPath, options = {}) {
|
|
|
190
143
|
record.status = options.signal?.aborted ? "cancelled" : "failed";
|
|
191
144
|
record.error = toErrorInfo(error, "RUN_EXECUTION_FAILED");
|
|
192
145
|
}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
const finishedAt = new Date().toISOString();
|
|
204
|
-
record.finishedAt = finishedAt;
|
|
205
|
-
record.durationMs = Date.parse(finishedAt) - Date.parse(startedAt);
|
|
206
|
-
writeRunRecord(runDirPath, record);
|
|
207
|
-
const result = { status: record.status, record };
|
|
208
|
-
// --- --output: a distinct, post-finalization copy step; never changes the run's own status. ---
|
|
209
|
-
if (result.status === "succeeded" && options.outputDir) {
|
|
210
|
-
try {
|
|
211
|
-
fs.mkdirSync(options.outputDir, { recursive: true });
|
|
212
|
-
for (const output of record.outputs) {
|
|
213
|
-
const target = path.join(options.outputDir, path.basename(output.path));
|
|
214
|
-
fs.copyFileSync(output.path, target, fs.constants.COPYFILE_EXCL);
|
|
215
|
-
}
|
|
216
|
-
// The report is self-contained; copy it under a name that cannot collide with outputs.
|
|
217
|
-
for (const artifact of record.artifacts ?? []) {
|
|
218
|
-
fs.copyFileSync(artifact.path, path.join(options.outputDir, `moldesk-report-${record.id}.html`), fs.constants.COPYFILE_EXCL);
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
catch (error) {
|
|
222
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
223
|
-
result.exportError = { code: "OUTPUT_COLLISION", message };
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
return result;
|
|
146
|
+
return finalizeRun({
|
|
147
|
+
adapter,
|
|
148
|
+
context: runContext,
|
|
149
|
+
runDirPath,
|
|
150
|
+
record,
|
|
151
|
+
startedAt,
|
|
152
|
+
signal: options.signal,
|
|
153
|
+
visualize: options.visualize,
|
|
154
|
+
outputDir: options.outputDir,
|
|
155
|
+
});
|
|
227
156
|
}
|
package/dist/run.test.js
CHANGED
|
@@ -1,11 +1,23 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import { afterEach, describe, expect, it } from "vitest";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
5
|
import { getMoldeskPaths } from "@moldesk/runtime";
|
|
6
6
|
import { listAvailableModels } from "@moldesk/registry";
|
|
7
7
|
import { createInstallationPlan, installModel } from "./installation.js";
|
|
8
8
|
import { runModel } from "./run.js";
|
|
9
|
+
const hashFailure = vi.hoisted(() => ({ message: undefined }));
|
|
10
|
+
vi.mock("./hash.js", async (importOriginal) => {
|
|
11
|
+
const actual = await importOriginal();
|
|
12
|
+
return {
|
|
13
|
+
...actual,
|
|
14
|
+
sha256File: async (file) => {
|
|
15
|
+
if (hashFailure.message)
|
|
16
|
+
throw new Error(hashFailure.message);
|
|
17
|
+
return actual.sha256File(file);
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
});
|
|
9
21
|
const homes = [];
|
|
10
22
|
function tempPaths() {
|
|
11
23
|
// Intentionally includes a space to exercise the "paths containing spaces" requirement.
|
|
@@ -14,6 +26,7 @@ function tempPaths() {
|
|
|
14
26
|
return getMoldeskPaths({ MOLDESK_HOME: home });
|
|
15
27
|
}
|
|
16
28
|
afterEach(() => {
|
|
29
|
+
hashFailure.message = undefined;
|
|
17
30
|
for (const home of homes.splice(0))
|
|
18
31
|
fs.rmSync(home, { recursive: true, force: true });
|
|
19
32
|
});
|
|
@@ -171,6 +184,39 @@ describe("runModel", () => {
|
|
|
171
184
|
expect(result.record.error?.message).toContain("simulated runtime crash");
|
|
172
185
|
expect(result.record.finishedAt).toBeDefined();
|
|
173
186
|
});
|
|
187
|
+
it("finalizes run.json as failed when input snapshotting throws after the run directory is allocated", async () => {
|
|
188
|
+
const paths = tempPaths();
|
|
189
|
+
await installFixture(paths);
|
|
190
|
+
const inputPath = writeInputFixture(paths.home);
|
|
191
|
+
let executed = false;
|
|
192
|
+
hashFailure.message = "simulated hashing failure";
|
|
193
|
+
const result = await runModel("proteinmpnn", inputPath, {
|
|
194
|
+
paths,
|
|
195
|
+
runtimeProvider: fakeProvider(() => {
|
|
196
|
+
executed = true;
|
|
197
|
+
throw new Error("must not execute");
|
|
198
|
+
}),
|
|
199
|
+
});
|
|
200
|
+
expect(executed).toBe(false);
|
|
201
|
+
expect(result.status).toBe("failed");
|
|
202
|
+
expect(result.record.error).toEqual({ code: "RUN_SNAPSHOT_FAILED", message: "simulated hashing failure" });
|
|
203
|
+
expect(result.record.finishedAt).toBeDefined();
|
|
204
|
+
const onDisk = JSON.parse(fs.readFileSync(path.join(result.record.command.cwd, "run.json"), "utf8"));
|
|
205
|
+
expect(onDisk.status).toBe("failed");
|
|
206
|
+
expect(onDisk.error.code).toBe("RUN_SNAPSHOT_FAILED");
|
|
207
|
+
expect(onDisk.finishedAt).toBeDefined();
|
|
208
|
+
});
|
|
209
|
+
it("dispatches remote runs before any local installation lookup and rejects mixed or incomplete options", async () => {
|
|
210
|
+
const paths = tempPaths();
|
|
211
|
+
const inputPath = writeInputFixture(paths.home);
|
|
212
|
+
const cloud = { cachePolicy: "ephemeral", maxBudgetUsd: 1, maxLifetimeSeconds: 60, quote: {}, approval: { quoteId: "q", approvedAt: "t", acknowledged: ["lifetime-risk"] } };
|
|
213
|
+
await expect(runModel("proteinmpnn", inputPath, { paths, compute: "runpod" })).rejects.toMatchObject({ code: "CLOUD_OPTIONS_REQUIRED" });
|
|
214
|
+
await expect(runModel("proteinmpnn", inputPath, { paths, compute: "runpod", cloud, runtime: "docker" })).rejects.toMatchObject({ code: "CLOUD_RUNTIME_UNSUPPORTED" });
|
|
215
|
+
await expect(runModel("proteinmpnn", inputPath, { paths, cloud })).rejects.toMatchObject({ code: "CLOUD_OPTIONS_WITHOUT_CLOUD" });
|
|
216
|
+
// Nothing is installed here, yet the remote path is reached (it fails on its own missing backend, not MODEL_NOT_INSTALLED).
|
|
217
|
+
await expect(runModel("proteinmpnn", inputPath, { paths, compute: "runpod", cloud, cloudDeps: { provider: {} } })).rejects.toMatchObject({ code: "CLOUD_BACKEND_MISSING" });
|
|
218
|
+
expect(fs.existsSync(paths.runs)).toBe(false);
|
|
219
|
+
});
|
|
174
220
|
it("writes status:running with the real pid before execute() resolves", async () => {
|
|
175
221
|
const paths = tempPaths();
|
|
176
222
|
await installFixture(paths);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@moldesk/core",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.6",
|
|
4
4
|
"description": "Shared engine logic for MoleculeDesk (CLI + desktop)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -19,18 +19,22 @@
|
|
|
19
19
|
"dist"
|
|
20
20
|
],
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"
|
|
23
|
-
"@moldesk/
|
|
24
|
-
"@moldesk/
|
|
25
|
-
"@moldesk/
|
|
22
|
+
"better-sqlite3": "~12.11.1",
|
|
23
|
+
"@moldesk/adapters": "0.0.6",
|
|
24
|
+
"@moldesk/registry": "0.0.6",
|
|
25
|
+
"@moldesk/visualizer": "0.0.6",
|
|
26
|
+
"@moldesk/runtime": "0.0.6"
|
|
26
27
|
},
|
|
27
28
|
"devDependencies": {
|
|
29
|
+
"esbuild": "^0.25",
|
|
30
|
+
"@types/better-sqlite3": "^9.6.0",
|
|
28
31
|
"typescript": "^5.6.3",
|
|
29
32
|
"vitest": "^3.0.0"
|
|
30
33
|
},
|
|
31
34
|
"scripts": {
|
|
32
35
|
"build": "tsc -p tsconfig.json",
|
|
33
36
|
"typecheck": "tsc --noEmit",
|
|
34
|
-
"test": "vitest run"
|
|
37
|
+
"test": "vitest run",
|
|
38
|
+
"build:worker": "node scripts/build-worker.mjs"
|
|
35
39
|
}
|
|
36
40
|
}
|