@moldesk/core 0.0.3 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -12,5 +12,9 @@ export type { InstalledModel, InstalledPostInstallResult } from "./install-state
12
12
  export { createInstallationPlan, installModel, INSTALL_CONFIRMATION_DISK_BYTES, INSTALL_CONFIRMATION_DOWNLOAD_BYTES, requiresInstallConfirmation, runtimeFingerprint, uninstallModel, } from "./installation.js";
13
13
  export type { InstallationPlanResult, InstallCostEstimate, InstallModelOptions, InstallModelResult, UninstallModelOptions, } from "./installation.js";
14
14
  export { runModel } from "./run.js";
15
- export type { RunModelOptions, RunModelResult, RunRecord } from "./run.js";
15
+ export type { RunArtifact, RunModelOptions, RunModelResult, RunRecord, RunWarning } from "./run.js";
16
+ export { openInBrowser } from "@moldesk/runtime";
17
+ export type { OpenResult } from "@moldesk/runtime";
18
+ export { applyReportUpdate, asMoldeskError, createStandaloneReport, prepareReportUpdate } from "./visualization.js";
19
+ export type { ReportUpdateOptions, StandaloneReportOptions, StandaloneReportResult, UpdatePlan, UpdateResult, } from "./visualization.js";
16
20
  export { VERSION } from "./version.js";
package/dist/index.js CHANGED
@@ -6,4 +6,6 @@ export { evaluateCompatibility, shortReason } from "./compatibility.js";
6
6
  export { listInstalledModels, } from "./install-state.js";
7
7
  export { createInstallationPlan, installModel, INSTALL_CONFIRMATION_DISK_BYTES, INSTALL_CONFIRMATION_DOWNLOAD_BYTES, requiresInstallConfirmation, runtimeFingerprint, uninstallModel, } from "./installation.js";
8
8
  export { runModel } from "./run.js";
9
+ export { openInBrowser } from "@moldesk/runtime";
10
+ export { applyReportUpdate, asMoldeskError, createStandaloneReport, prepareReportUpdate } from "./visualization.js";
9
11
  export { VERSION } from "./version.js";
@@ -0,0 +1,47 @@
1
+ import type { ModelAdapterDefinition, RunContext } from "@moldesk/adapters";
2
+ import { type ViewerBundle } from "@moldesk/visualizer";
3
+ export interface RunArtifact {
4
+ id: string;
5
+ kind: "molecular-report";
6
+ path: string;
7
+ sha256: string;
8
+ sizeBytes: number;
9
+ sourceSha256: string[];
10
+ viewerVersion: string;
11
+ bundleSha256: string;
12
+ }
13
+ export interface RunWarning {
14
+ code: string;
15
+ message: string;
16
+ }
17
+ export interface RunReportInput {
18
+ adapter: ModelAdapterDefinition;
19
+ context: RunContext;
20
+ runDirPath: string;
21
+ record: {
22
+ id: string;
23
+ model: string;
24
+ modelVersion: string;
25
+ input: Array<{
26
+ storedPath: string;
27
+ sha256: string;
28
+ }>;
29
+ outputs: Array<{
30
+ id: string;
31
+ path: string;
32
+ sha256: string;
33
+ }>;
34
+ };
35
+ signal?: AbortSignal;
36
+ /** Test seam; defaults to the installed viewer bundle. */
37
+ bundle?: ViewerBundle;
38
+ }
39
+ /**
40
+ * Builds `artifacts/viewer.html` for a successful run. Never throws: any failure becomes a
41
+ * warning so a successful scientific run stays successful. Cancellation yields a warning
42
+ * and no file. Writes nothing until the report is fully built and verified.
43
+ */
44
+ export declare function generateRunReport(input: RunReportInput): Promise<{
45
+ artifact?: RunArtifact;
46
+ warning?: RunWarning;
47
+ }>;
@@ -0,0 +1,102 @@
1
+ import { createHash } from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { isWithinRoot } from "@moldesk/runtime";
5
+ import { exportReport, REPORT_LIMITS, validateDescription, writeFileExclusive, } from "@moldesk/visualizer";
6
+ import { MoldeskError } from "./errors.js";
7
+ class Cancelled extends Error {
8
+ }
9
+ function refuse(code, message) {
10
+ return new MoldeskError({ code, message, remediation: "The model results are unaffected; re-run with --no-visualize to skip the report." });
11
+ }
12
+ function checkAbort(signal) {
13
+ if (signal?.aborted)
14
+ throw new Cancelled();
15
+ }
16
+ /**
17
+ * Resolves one adapter-declared source. Only exact paths the run already recorded (and
18
+ * checksummed) are allowed; the file is re-checked as a regular, non-symlink file inside
19
+ * the run directory at the moment of reading, and its bytes must still match the recorded
20
+ * checksum. The adapter never gets to request an arbitrary read.
21
+ */
22
+ function readSource(kind, sourcePath, known, root) {
23
+ const expected = known.get(sourcePath);
24
+ if (expected === undefined) {
25
+ throw refuse("VISUALIZATION_SOURCE_NOT_ALLOWED", `The report referenced a ${kind} file that this run did not record: ${path.basename(sourcePath)}`);
26
+ }
27
+ const stat = fs.lstatSync(sourcePath);
28
+ if (stat.isSymbolicLink() || !stat.isFile()) {
29
+ throw refuse("VISUALIZATION_SOURCE_NOT_ALLOWED", `${path.basename(sourcePath)} is not a regular file.`);
30
+ }
31
+ if (!isWithinRoot(root, fs.realpathSync(sourcePath))) {
32
+ throw refuse("VISUALIZATION_SOURCE_NOT_ALLOWED", `${path.basename(sourcePath)} resolves outside the run directory.`);
33
+ }
34
+ if (stat.size > REPORT_LIMITS.maxResourceBytes) {
35
+ throw refuse("VISUALIZATION_SOURCE_TOO_LARGE", `${path.basename(sourcePath)} is too large to embed (${stat.size} bytes).`);
36
+ }
37
+ const bytes = fs.readFileSync(sourcePath);
38
+ if (createHash("sha256").update(bytes).digest("hex") !== expected) {
39
+ throw refuse("VISUALIZATION_SOURCE_CHANGED", `${path.basename(sourcePath)} changed after it was checksummed.`);
40
+ }
41
+ return bytes;
42
+ }
43
+ /**
44
+ * Builds `artifacts/viewer.html` for a successful run. Never throws: any failure becomes a
45
+ * warning so a successful scientific run stays successful. Cancellation yields a warning
46
+ * and no file. Writes nothing until the report is fully built and verified.
47
+ */
48
+ export async function generateRunReport(input) {
49
+ try {
50
+ checkAbort(input.signal);
51
+ const outputs = input.record.outputs.map(({ id, path: p }) => ({ id, path: p }));
52
+ const description = await input.adapter.describeVisualization?.(input.context, outputs);
53
+ if (!description)
54
+ return {};
55
+ checkAbort(input.signal);
56
+ const valid = validateDescription(description);
57
+ const knownOutputs = new Map(input.record.outputs.map((o) => [o.path, o.sha256]));
58
+ const knownInputs = new Map(input.record.input.map((i) => [i.storedPath, i.sha256]));
59
+ const roots = {
60
+ output: fs.realpathSync(path.join(input.runDirPath, "output")),
61
+ input: fs.realpathSync(path.join(input.runDirPath, "input")),
62
+ };
63
+ const resources = [];
64
+ for (const r of valid.resources) {
65
+ const bytes = readSource(r.source.kind, r.source.path, r.source.kind === "output" ? knownOutputs : knownInputs, roots[r.source.kind]);
66
+ resources.push({ id: r.id, label: r.label, format: r.format, bytes });
67
+ checkAbort(input.signal);
68
+ }
69
+ const exported = exportReport({
70
+ title: valid.title,
71
+ resources,
72
+ scenes: valid.scenes,
73
+ panels: valid.panels,
74
+ provenance: { runId: input.record.id, model: input.record.model, modelVersion: input.record.modelVersion },
75
+ }, input.bundle);
76
+ checkAbort(input.signal);
77
+ const artifactsDir = path.join(input.runDirPath, "artifacts");
78
+ fs.mkdirSync(artifactsDir, { recursive: true });
79
+ const dest = path.join(artifactsDir, "viewer.html");
80
+ await writeFileExclusive(dest, exported.html);
81
+ return {
82
+ artifact: {
83
+ id: "molecular-report",
84
+ kind: "molecular-report",
85
+ path: dest,
86
+ sha256: exported.sha256,
87
+ sizeBytes: exported.sizeBytes,
88
+ sourceSha256: exported.sourceSha256,
89
+ viewerVersion: exported.viewerVersion,
90
+ bundleSha256: exported.bundleSha256,
91
+ },
92
+ };
93
+ }
94
+ catch (error) {
95
+ if (error instanceof Cancelled) {
96
+ return { warning: { code: "VISUALIZATION_CANCELLED", message: "Report generation was cancelled. The model results are unaffected." } };
97
+ }
98
+ const code = error instanceof MoldeskError ? error.code : typeof error?.code === "string" ? error.code : "VISUALIZATION_FAILED";
99
+ const message = error instanceof Error ? error.message : String(error);
100
+ return { warning: { code, message: `Could not create the molecular report: ${message}` } };
101
+ }
102
+ }
package/dist/run.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { type RuntimeKind } from "@moldesk/registry";
2
2
  import { type MoldeskPaths, type RuntimeProvider, type SystemReport } from "@moldesk/runtime";
3
+ import { type RunArtifact, type RunWarning } from "./run-report.js";
3
4
  /**
4
5
  * `runModel` only rejects for usage errors discovered before a run directory
5
6
  * is allocated (unknown model, not installed, invalid input/params — spec
@@ -21,6 +22,8 @@ export interface RunModelOptions {
21
22
  onLog?: (stream: "stdout" | "stderr", chunk: string) => void;
22
23
  /** Injected for testability; defaults to a real `PythonRuntimeProvider`. */
23
24
  runtimeProvider?: RuntimeProvider;
25
+ /** Create the offline molecular report for a successful run when the adapter describes one. Default true. */
26
+ visualize?: boolean;
24
27
  }
25
28
  export interface RunRecord {
26
29
  schemaVersion: 1;
@@ -76,7 +79,12 @@ export interface RunRecord {
76
79
  code: string;
77
80
  message: string;
78
81
  };
82
+ /** Derived files (not scientific outputs). Updating one later does not rewrite this record. */
83
+ artifacts?: RunArtifact[];
84
+ /** Nonfatal problems, e.g. the report could not be created. They never change `status`. */
85
+ warnings?: RunWarning[];
79
86
  }
87
+ export type { RunArtifact, RunWarning };
80
88
  export interface RunModelResult {
81
89
  status: "succeeded" | "failed" | "cancelled";
82
90
  record: RunRecord;
package/dist/run.js CHANGED
@@ -7,6 +7,7 @@ import { getMoldeskPaths, getSystemReport, redactEnv, runDir, stageCompanionInpu
7
7
  import { MoldeskError } from "./errors.js";
8
8
  import { digest, sha256File } from "./hash.js";
9
9
  import { listInstalledModels } from "./install-state.js";
10
+ import { generateRunReport } from "./run-report.js";
10
11
  import { VERSION } from "./version.js";
11
12
  function runFailure(code, message, remediation, details) {
12
13
  return new MoldeskError({ code, message, remediation, details });
@@ -120,19 +121,20 @@ export async function runModel(modelName, inputPath, options = {}) {
120
121
  };
121
122
  // --- Step 6: write the initial record. From this point, every path below finalizes run.json. ---
122
123
  writeRunRecord(runDirPath, record);
124
+ const runContext = {
125
+ manifestName: manifest.name,
126
+ inputPath: storedInputPath,
127
+ outputDir,
128
+ modelDir: installed.installDir,
129
+ assetsDir: path.join(installed.installDir, "assets"),
130
+ params: effectiveParams,
131
+ runtimeExecutable: installed.runtime.python.executable,
132
+ platform: installed.runtime.python.platform,
133
+ ...(companionInputs.length > 0 ? { companionInputs } : {}),
134
+ };
123
135
  try {
124
136
  // --- 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
- });
137
+ const commandSpec = await adapter.command(runContext);
136
138
  record.command = {
137
139
  executable: commandSpec.executable,
138
140
  args: commandSpec.args,
@@ -173,17 +175,7 @@ export async function runModel(modelName, inputPath, options = {}) {
173
175
  };
174
176
  }
175
177
  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
- });
178
+ const collected = await adapter.collectOutputs(runContext);
187
179
  // --- Step 12: checksum + size each output. ---
188
180
  record.outputs = await Promise.all(collected.map(async (output) => ({
189
181
  id: output.id,
@@ -198,6 +190,15 @@ export async function runModel(modelName, inputPath, options = {}) {
198
190
  record.status = options.signal?.aborted ? "cancelled" : "failed";
199
191
  record.error = toErrorInfo(error, "RUN_EXECUTION_FAILED");
200
192
  }
193
+ // --- Derived report. Deliberately outside the try/catch above: describing or exporting a
194
+ // report can only ever add a warning, never change the model's outcome or error. ---
195
+ if (record.status === "succeeded" && options.visualize !== false) {
196
+ const report = await generateRunReport({ adapter, context: runContext, runDirPath, record, signal: options.signal });
197
+ if (report.artifact)
198
+ record.artifacts = [report.artifact];
199
+ if (report.warning)
200
+ record.warnings = [report.warning];
201
+ }
201
202
  // --- Step 13: write the final record atomically. ---
202
203
  const finishedAt = new Date().toISOString();
203
204
  record.finishedAt = finishedAt;
@@ -212,6 +213,10 @@ export async function runModel(modelName, inputPath, options = {}) {
212
213
  const target = path.join(options.outputDir, path.basename(output.path));
213
214
  fs.copyFileSync(output.path, target, fs.constants.COPYFILE_EXCL);
214
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
+ }
215
220
  }
216
221
  catch (error) {
217
222
  const message = error instanceof Error ? error.message : String(error);
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,181 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { afterEach, describe, expect, it } from "vitest";
4
+ import { getAdapter } from "@moldesk/adapters";
5
+ import { decodeEnvelope, extractEnvelope } from "@moldesk/visualizer";
6
+ import { runModel } from "./run.js";
7
+ import { fakeProvider, homes, installFixture, tempPaths, writeInputFixture, writeSequenceOutput } from "./testSupport/run-fixture.js";
8
+ const adapter = getAdapter("proteinmpnn");
9
+ afterEach(() => {
10
+ delete adapter.describeVisualization;
11
+ for (const home of homes.splice(0))
12
+ fs.rmSync(home, { recursive: true, force: true });
13
+ });
14
+ const ok = (request) => ({
15
+ exitCode: 0,
16
+ stdoutPath: path.join(request.cwd, "stdout.log"),
17
+ stderrPath: path.join(request.cwd, "stderr.log"),
18
+ });
19
+ /** Plumbing fixture: ProteinMPNN's real output file, described as a one-scene report. */
20
+ const describeSequences = async (_ctx, outputs) => {
21
+ const seq = outputs.find((o) => o.id === "sequences");
22
+ return {
23
+ title: "Test report",
24
+ resources: [{ id: "r1", source: { kind: "output", path: seq.path }, format: "pdb", label: "input.fa" }],
25
+ scenes: [{ id: "s1", title: "S", structures: [{ id: "a", resourceId: "r1" }] }],
26
+ panels: [],
27
+ };
28
+ };
29
+ async function run(hook, options = {}, behavior = (r) => { writeSequenceOutput(r); return ok(r); }) {
30
+ const paths = tempPaths();
31
+ await installFixture(paths);
32
+ const inputPath = writeInputFixture(paths.home);
33
+ if (hook)
34
+ adapter.describeVisualization = hook;
35
+ return { paths, result: await runModel("proteinmpnn", inputPath, { paths, runtimeProvider: fakeProvider(behavior), ...options }) };
36
+ }
37
+ describe("runModel visualization", () => {
38
+ it("adds a verified self-contained artifact without touching scientific outputs", async () => {
39
+ const { result } = await run(describeSequences);
40
+ expect(result.status).toBe("succeeded");
41
+ const [artifact] = result.record.artifacts;
42
+ expect(artifact.path).toBe(path.join(result.record.command.cwd, "artifacts", "viewer.html"));
43
+ expect(artifact.kind).toBe("molecular-report");
44
+ expect(artifact.sourceSha256).toEqual([result.record.outputs[0].sha256]);
45
+ expect(result.record.outputs.map((o) => o.id)).toEqual(["sequences"]);
46
+ expect(result.record.warnings).toBeUndefined();
47
+ const html = fs.readFileSync(artifact.path, "utf8");
48
+ const report = decodeEnvelope(extractEnvelope(html));
49
+ expect(Buffer.from(report.resources[0].data, "base64").toString()).toBe(">input\nMKV\n");
50
+ expect(report.provenance).toMatchObject({ runId: result.record.id, model: "proteinmpnn" });
51
+ const onDisk = JSON.parse(fs.readFileSync(path.join(result.record.command.cwd, "run.json"), "utf8"));
52
+ expect(onDisk.artifacts[0].sha256).toBe(artifact.sha256);
53
+ });
54
+ it("allows staged input sources", async () => {
55
+ const { result } = await run(async (ctx) => ({
56
+ title: "t",
57
+ resources: [{ id: "r1", source: { kind: "input", path: ctx.inputPath }, format: "pdb", label: "in.pdb" }],
58
+ scenes: [{ id: "s1", title: "S", structures: [{ id: "a", resourceId: "r1" }] }],
59
+ panels: [],
60
+ }));
61
+ expect(result.record.artifacts).toHaveLength(1);
62
+ });
63
+ it("keeps a successful run successful when the hook throws, with a warning", async () => {
64
+ const { result } = await run(async () => {
65
+ throw new Error("boom");
66
+ });
67
+ expect(result.status).toBe("succeeded");
68
+ expect(result.record.error).toBeUndefined();
69
+ expect(result.record.artifacts).toBeUndefined();
70
+ expect(result.record.warnings).toEqual([{ code: "VISUALIZATION_FAILED", message: "Could not create the molecular report: boom" }]);
71
+ expect(fs.existsSync(path.join(result.record.command.cwd, "artifacts"))).toBe(false);
72
+ });
73
+ it("surfaces a typed MoldeskError code from the hook as the warning code", async () => {
74
+ const { MoldeskError } = await import("./errors.js");
75
+ const { result } = await run(async () => {
76
+ throw new MoldeskError({ code: "VISUALIZATION_RECEPTOR_MISSING", message: "no receptor", remediation: "x" });
77
+ });
78
+ expect(result.status).toBe("succeeded");
79
+ expect(result.record.warnings[0].code).toBe("VISUALIZATION_RECEPTOR_MISSING");
80
+ });
81
+ it("treats undefined as 'no report applies': no artifact and no warning", async () => {
82
+ const { result } = await run(async () => undefined);
83
+ expect(result.record.artifacts).toBeUndefined();
84
+ expect(result.record.warnings).toBeUndefined();
85
+ });
86
+ it("works for adapters without the hook", async () => {
87
+ const { result } = await run(undefined);
88
+ expect(result.status).toBe("succeeded");
89
+ expect(result.record.artifacts).toBeUndefined();
90
+ expect(result.record.warnings).toBeUndefined();
91
+ });
92
+ it("refuses sources the run did not record, writing nothing", async () => {
93
+ const { result } = await run(async (ctx) => ({
94
+ title: "t",
95
+ resources: [{ id: "r1", source: { kind: "output", path: path.join(path.dirname(ctx.outputDir), "run.json") }, format: "json", label: "x" }],
96
+ scenes: [],
97
+ panels: [],
98
+ }));
99
+ expect(result.status).toBe("succeeded");
100
+ expect(result.record.warnings[0].code).toBe("VISUALIZATION_SOURCE_NOT_ALLOWED");
101
+ expect(fs.existsSync(path.join(result.record.command.cwd, "artifacts"))).toBe(false);
102
+ });
103
+ it("refuses a recorded output that was replaced by a symlink", async () => {
104
+ const { result } = await run(async (ctx, outputs) => {
105
+ const seq = outputs.find((o) => o.id === "sequences");
106
+ const target = path.join(path.dirname(ctx.outputDir), "elsewhere.fa");
107
+ fs.writeFileSync(target, ">input\nMKV\n");
108
+ fs.rmSync(seq.path);
109
+ fs.symlinkSync(target, seq.path);
110
+ return describeSequences(ctx, outputs);
111
+ });
112
+ expect(result.record.warnings[0].code).toBe("VISUALIZATION_SOURCE_NOT_ALLOWED");
113
+ expect(result.record.artifacts).toBeUndefined();
114
+ });
115
+ it("refuses a recorded output that changed after it was checksummed", async () => {
116
+ const { result } = await run(async (ctx, outputs) => {
117
+ fs.appendFileSync(outputs.find((o) => o.id === "sequences").path, ">tampered\n");
118
+ return describeSequences(ctx, outputs);
119
+ });
120
+ expect(result.record.warnings[0].code).toBe("VISUALIZATION_SOURCE_CHANGED");
121
+ });
122
+ it("rejects an invalid description with a warning", async () => {
123
+ const { result } = await run(async () => ({ title: "t", resources: [], scenes: [{ id: "s", title: "S", structures: [{ id: "a", resourceId: "missing" }] }], panels: [] }));
124
+ expect(result.status).toBe("succeeded");
125
+ expect(result.record.warnings[0].code).toBe("INVALID_REPORT");
126
+ });
127
+ it("skips the hook entirely with visualize: false", async () => {
128
+ let called = false;
129
+ const { result } = await run(async (...args) => {
130
+ called = true;
131
+ return describeSequences(...args);
132
+ }, { visualize: false });
133
+ expect(called).toBe(false);
134
+ expect(result.record.artifacts).toBeUndefined();
135
+ });
136
+ it.each([
137
+ ["failed", () => ({ exitCode: 1 })],
138
+ ["cancelled", () => ({ exitCode: 0, cancelled: true })],
139
+ ])("never describes a %s run", async (status, outcome) => {
140
+ let called = false;
141
+ const { result } = await run(async () => {
142
+ called = true;
143
+ return undefined;
144
+ }, {}, (r) => ({ ...ok(r), ...outcome() }));
145
+ expect(result.status).toBe(status);
146
+ expect(called).toBe(false);
147
+ });
148
+ it("cancellation during report generation keeps the model outcome and writes nothing", async () => {
149
+ const controller = new AbortController();
150
+ const { result } = await run(async (...args) => {
151
+ const d = await describeSequences(...args);
152
+ controller.abort();
153
+ return d;
154
+ }, { signal: controller.signal });
155
+ expect(result.status).toBe("succeeded");
156
+ expect(result.record.warnings[0].code).toBe("VISUALIZATION_CANCELLED");
157
+ expect(fs.existsSync(path.join(result.record.command.cwd, "artifacts"))).toBe(false);
158
+ });
159
+ it("copies the report to --output under a collision-resistant name, never overwriting", async () => {
160
+ const paths = tempPaths();
161
+ await installFixture(paths);
162
+ adapter.describeVisualization = describeSequences;
163
+ const outputDir = path.join(paths.home, "results");
164
+ const exec = (inputName) => runModel("proteinmpnn", writeInputFixture(paths.home, inputName), {
165
+ paths,
166
+ outputDir,
167
+ runtimeProvider: fakeProvider((r) => {
168
+ writeSequenceOutput(r);
169
+ return ok(r);
170
+ }),
171
+ });
172
+ const first = await exec("a.pdb");
173
+ expect(first.exportError).toBeUndefined();
174
+ const copied = path.join(outputDir, `moldesk-report-${first.record.id}.html`);
175
+ expect(fs.readFileSync(copied).equals(fs.readFileSync(first.record.artifacts[0].path))).toBe(true);
176
+ expect(fs.existsSync(path.join(outputDir, "input.fa"))).toBe(true);
177
+ const second = await exec("b.pdb");
178
+ expect(second.status).toBe("succeeded");
179
+ expect(second.exportError?.code).toBe("OUTPUT_COLLISION");
180
+ });
181
+ });
@@ -0,0 +1,10 @@
1
+ import { getMoldeskPaths, type InstallCommandRunner, type ExecuteRequest, type ExecutionResult, type RuntimeProvider } from "@moldesk/runtime";
2
+ export declare const homes: string[];
3
+ export declare function tempPaths(): import("@moldesk/runtime").MoldeskPaths;
4
+ export declare function successfulInstallRunner(revision: string): InstallCommandRunner;
5
+ /** Installs a real ProteinMPNN fixture (fake install commands, no network) so `runModel` has a genuine `InstalledModel` to resolve. */
6
+ export declare function installFixture(paths: ReturnType<typeof getMoldeskPaths>): Promise<string>;
7
+ /** A fake RuntimeProvider whose `execute()` is fully scripted by the test. */
8
+ export declare function fakeProvider(behavior: (request: ExecuteRequest) => ExecutionResult | Promise<ExecutionResult>): RuntimeProvider;
9
+ export declare function writeSequenceOutput(request: ExecuteRequest): void;
10
+ export declare function writeInputFixture(dir: string, name?: string): string;
@@ -0,0 +1,70 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { getMoldeskPaths } from "@moldesk/runtime";
5
+ import { listAvailableModels } from "@moldesk/registry";
6
+ import { createInstallationPlan, installModel } from "../installation.js";
7
+ export const homes = [];
8
+ export function tempPaths() {
9
+ // Intentionally includes a space to exercise the "paths containing spaces" requirement.
10
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "moldesk core run "));
11
+ homes.push(home);
12
+ return getMoldeskPaths({ MOLDESK_HOME: home });
13
+ }
14
+ export function successfulInstallRunner(revision) {
15
+ return async (command, args) => {
16
+ if (command === "git" && args[0] === "init") {
17
+ const source = args[1];
18
+ fs.mkdirSync(path.join(source, "vanilla_model_weights"), { recursive: true });
19
+ fs.mkdirSync(path.join(path.dirname(source), "assets", "vanilla_model_weights"), { recursive: true });
20
+ fs.writeFileSync(path.join(source, "protein_mpnn_run.py"), "# pinned source\n");
21
+ fs.writeFileSync(path.join(source, "vanilla_model_weights", "v_48_020.pt"), "weights");
22
+ fs.writeFileSync(path.join(path.dirname(source), "assets", "vanilla_model_weights", "v_48_020.pt"), "weights");
23
+ }
24
+ if (command === "git" && args.includes("rev-parse"))
25
+ return { code: 0, stdout: `${revision}\n`, stderr: "" };
26
+ if (command === "uv" && args[0] === "venv")
27
+ fs.mkdirSync(path.join(args.at(-1), "bin"), { recursive: true });
28
+ if (command === "uv" && args.includes("freeze"))
29
+ return { code: 0, stdout: "numpy==1.26.4\ntorch==2.2.1\n", stderr: "" };
30
+ if (args[0] === "--version")
31
+ return { code: 0, stdout: "Python 3.11.9\n", stderr: "" };
32
+ return { code: 0, stdout: "", stderr: "" };
33
+ };
34
+ }
35
+ /** Installs a real ProteinMPNN fixture (fake install commands, no network) so `runModel` has a genuine `InstalledModel` to resolve. */
36
+ export async function installFixture(paths) {
37
+ const manifest = { ...listAvailableModels().find((item) => item.name === "proteinmpnn"), assets: [] };
38
+ const runtime = manifest.runtimes.find((item) => item.kind === "python");
39
+ const planned = await createInstallationPlan(manifest, runtime, paths);
40
+ await installModel(planned, { paths, runner: successfulInstallRunner(manifest.source.revision), uvExecutable: "uv" });
41
+ return planned.targetDir;
42
+ }
43
+ /** A fake RuntimeProvider whose `execute()` is fully scripted by the test. */
44
+ export function fakeProvider(behavior) {
45
+ return {
46
+ kind: "python",
47
+ async inspect() {
48
+ return { kind: "python", available: true };
49
+ },
50
+ async prepare(request) {
51
+ return { kind: "python", executable: request.targetDir, fingerprint: request.runtimeFingerprint };
52
+ },
53
+ async execute(request) {
54
+ request.onSpawn?.(4242);
55
+ return behavior(request);
56
+ },
57
+ async remove() { },
58
+ };
59
+ }
60
+ export function writeSequenceOutput(request) {
61
+ const seqsDir = path.join(request.cwd, "output", "seqs");
62
+ fs.mkdirSync(seqsDir, { recursive: true });
63
+ fs.writeFileSync(path.join(seqsDir, "input.fa"), ">input\nMKV\n");
64
+ }
65
+ export function writeInputFixture(dir, name = "input.pdb") {
66
+ fs.mkdirSync(dir, { recursive: true });
67
+ const inputPath = path.join(dir, name);
68
+ fs.writeFileSync(inputPath, "ATOM 1 N MET A 1\n");
69
+ return inputPath;
70
+ }
@@ -0,0 +1,41 @@
1
+ import { type MolecularFormat, type UpdatePlan, type UpdateResult, type ViewerBundle } from "@moldesk/visualizer";
2
+ import { MoldeskError } from "./errors.js";
3
+ export interface StandaloneReportOptions {
4
+ /** Explicit report file. Must not already exist. Default: an owned path under the cache. */
5
+ outputPath?: string;
6
+ signal?: AbortSignal;
7
+ env?: NodeJS.ProcessEnv;
8
+ /** Test seam; defaults to the installed viewer bundle. */
9
+ bundle?: ViewerBundle;
10
+ }
11
+ export interface StandaloneReportResult {
12
+ id: string;
13
+ path: string;
14
+ fileUrl: string;
15
+ sha256: string;
16
+ sizeBytes: number;
17
+ format: MolecularFormat;
18
+ sourceSha256: string[];
19
+ viewerVersion: string;
20
+ bundleSha256: string;
21
+ }
22
+ /** Direct `moldesk view <file>`: one scene from one local PDB/mmCIF file. */
23
+ export declare function createStandaloneReport(inputPath: string, options?: StandaloneReportOptions): Promise<StandaloneReportResult>;
24
+ export type { UpdatePlan, UpdateResult };
25
+ export interface ReportUpdateOptions {
26
+ /** Write the updated report here instead of replacing the original. Must not exist. */
27
+ outputPath?: string;
28
+ /** Test seam; defaults to the installed viewer bundle. */
29
+ bundle?: ViewerBundle;
30
+ now?: () => Date;
31
+ }
32
+ /**
33
+ * Inspects an existing report as data (never executing it) and decides whether the
34
+ * installed viewer would replace it. Nothing is written. The plan carries what is
35
+ * needed to detect a concurrent change between confirmation and commit.
36
+ */
37
+ export declare function prepareReportUpdate(reportPath: string, options?: ReportUpdateOptions): Promise<UpdatePlan>;
38
+ /** Commits a plan: backup + atomic replace in place, or an exclusive write to --output. */
39
+ export declare function applyReportUpdate(plan: UpdatePlan): Promise<UpdateResult>;
40
+ /** Maps typed visualizer errors onto MoldeskError so the CLI can pick exit codes by code. */
41
+ export declare function asMoldeskError(error: unknown): MoldeskError;
@@ -0,0 +1,160 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+ import { visualizationDir } from "@moldesk/runtime";
6
+ import { applyUpdate, exportReport, planUpdate, REPORT_LIMITS, writeFileExclusive, } from "@moldesk/visualizer";
7
+ import { MoldeskError } from "./errors.js";
8
+ const EXTENSIONS = {
9
+ ".pdb": "pdb",
10
+ ".cif": "mmcif",
11
+ ".mmcif": "mmcif",
12
+ };
13
+ const URL_LIKE = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//;
14
+ function invalid(message, remediation, code = "INVALID_VIEW_INPUT") {
15
+ return new MoldeskError({ code, message, remediation });
16
+ }
17
+ function throwIfAborted(signal) {
18
+ if (signal?.aborted)
19
+ throw new MoldeskError({ code: "VIEW_CANCELLED", message: "Cancelled.", remediation: "Run the command again." });
20
+ }
21
+ /** Lightweight preflight only; real parsing stays in the browser's Mol*. */
22
+ function preflight(text, format, file) {
23
+ if (text.includes("\0")) {
24
+ throw invalid(`${file} looks like a binary file, not a structure.`, "Provide a text PDB or mmCIF file.");
25
+ }
26
+ const ok = format === "pdb"
27
+ ? /^(ATOM |HETATM)/m.test(text)
28
+ : /^data_\S*/m.test(text) && /^_atom_site\./m.test(text);
29
+ if (!ok) {
30
+ throw invalid(format === "pdb"
31
+ ? `${file} has no ATOM or HETATM records.`
32
+ : `${file} is not a macromolecular mmCIF file (no _atom_site category).`, format === "pdb"
33
+ ? "Provide a PDB file with coordinates."
34
+ : "Provide a macromolecular mmCIF. Arbitrary crystallographic CIF files are not supported.");
35
+ }
36
+ }
37
+ /** Direct `moldesk view <file>`: one scene from one local PDB/mmCIF file. */
38
+ export async function createStandaloneReport(inputPath, options = {}) {
39
+ if (URL_LIKE.test(inputPath)) {
40
+ throw invalid("URLs are not supported.", "Download the file first and pass its local path.");
41
+ }
42
+ const file = path.resolve(inputPath);
43
+ const format = EXTENSIONS[path.extname(file).toLowerCase()];
44
+ if (!format) {
45
+ throw invalid(`Unsupported file type: ${path.basename(file)}`, "Use a .pdb, .cif, or .mmcif file.");
46
+ }
47
+ let stat;
48
+ try {
49
+ stat = fs.statSync(file);
50
+ }
51
+ catch {
52
+ throw invalid(`File not found: ${file}`, "Check the path and try again.");
53
+ }
54
+ if (!stat.isFile())
55
+ throw invalid(`Not a regular file: ${file}`, "Pass a structure file, not a directory.");
56
+ if (stat.size === 0)
57
+ throw invalid(`File is empty: ${file}`, "Provide a non-empty structure file.");
58
+ if (stat.size > REPORT_LIMITS.maxResourceBytes) {
59
+ throw invalid(`File is too large (${stat.size} bytes; limit ${REPORT_LIMITS.maxResourceBytes}).`, "Use a smaller structure file.");
60
+ }
61
+ throwIfAborted(options.signal);
62
+ const bytes = fs.readFileSync(file);
63
+ preflight(bytes.toString("utf8"), format, path.basename(file));
64
+ throwIfAborted(options.signal);
65
+ const id = randomUUID();
66
+ const name = path.basename(file);
67
+ let exported;
68
+ try {
69
+ exported = exportReport({
70
+ id,
71
+ title: name,
72
+ resources: [{ id: "structure", label: name, format, bytes }],
73
+ scenes: [{ id: "scene-1", title: name, structures: [{ id: "structure-1", resourceId: "structure" }] }],
74
+ panels: [{ id: "structure-panel", kind: "structure", sceneIds: ["scene-1"] }],
75
+ provenance: {},
76
+ }, options.bundle);
77
+ }
78
+ catch (error) {
79
+ throw asMoldeskError(error);
80
+ }
81
+ throwIfAborted(options.signal);
82
+ const dest = options.outputPath !== undefined
83
+ ? path.resolve(options.outputPath)
84
+ : path.join(visualizationDir(id, options.env), "viewer.html");
85
+ if (options.outputPath !== undefined && fs.existsSync(dest) && fs.statSync(dest).isDirectory()) {
86
+ throw invalid(`--output must be a file path, not a directory: ${dest}`, "Pass a file name such as ./report.html.");
87
+ }
88
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
89
+ try {
90
+ await writeFileExclusive(dest, exported.html);
91
+ }
92
+ catch (error) {
93
+ throw asMoldeskError(error);
94
+ }
95
+ return {
96
+ id,
97
+ path: dest,
98
+ fileUrl: pathToFileURL(dest).href,
99
+ sha256: exported.sha256,
100
+ sizeBytes: exported.sizeBytes,
101
+ format,
102
+ sourceSha256: exported.sourceSha256,
103
+ viewerVersion: exported.viewerVersion,
104
+ bundleSha256: exported.bundleSha256,
105
+ };
106
+ }
107
+ /**
108
+ * Inspects an existing report as data (never executing it) and decides whether the
109
+ * installed viewer would replace it. Nothing is written. The plan carries what is
110
+ * needed to detect a concurrent change between confirmation and commit.
111
+ */
112
+ export async function prepareReportUpdate(reportPath, options = {}) {
113
+ if (URL_LIKE.test(reportPath)) {
114
+ throw invalid("URLs are not supported.", "Pass the local path of a report file.", "INVALID_REPORT");
115
+ }
116
+ const target = path.resolve(reportPath);
117
+ const output = options.outputPath === undefined ? undefined : path.resolve(options.outputPath);
118
+ if (output !== undefined && output === target) {
119
+ throw invalid("--output must differ from the report being updated.", "Omit --output to update in place with a backup.", "OUTPUT_EXISTS");
120
+ }
121
+ try {
122
+ if (output !== undefined)
123
+ fs.mkdirSync(path.dirname(output), { recursive: true });
124
+ return await planUpdate(target, { output, bundle: options.bundle, now: options.now });
125
+ }
126
+ catch (error) {
127
+ throw asMoldeskError(error);
128
+ }
129
+ }
130
+ /** Commits a plan: backup + atomic replace in place, or an exclusive write to --output. */
131
+ export async function applyReportUpdate(plan) {
132
+ try {
133
+ return await applyUpdate(plan);
134
+ }
135
+ catch (error) {
136
+ throw asMoldeskError(error);
137
+ }
138
+ }
139
+ const REMEDIATION = {
140
+ OUTPUT_EXISTS: "Choose a different --output path or remove the existing file.",
141
+ INVALID_REPORT: "Check that the file is a valid MoleculeDesk report.",
142
+ UNSUPPORTED_REPORT: "Your report has not been changed.",
143
+ REPORT_CONFLICT: "Run the command again.",
144
+ VISUALIZER_BUNDLE_INVALID: "Reinstall MoleculeDesk.",
145
+ };
146
+ /** Maps typed visualizer errors onto MoldeskError so the CLI can pick exit codes by code. */
147
+ export function asMoldeskError(error) {
148
+ if (error instanceof MoldeskError)
149
+ return error;
150
+ const code = error?.code;
151
+ if (error instanceof Error && typeof code === "string" && code in REMEDIATION) {
152
+ return new MoldeskError({ code, message: error.message, remediation: REMEDIATION[code], cause: error });
153
+ }
154
+ return new MoldeskError({
155
+ code: "VISUALIZATION_FAILED",
156
+ message: error instanceof Error ? error.message : String(error),
157
+ remediation: "Try again; if this persists, report an issue.",
158
+ cause: error,
159
+ });
160
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,94 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import { extractEnvelope, decodeEnvelope, loadBundle } from "@moldesk/visualizer";
6
+ import { applyReportUpdate, createStandaloneReport, prepareReportUpdate } from "./visualization.js";
7
+ const PDB = "ATOM 1 N ALA A 1 11.104 13.207 2.100 1.00 20.00 N\nEND\n";
8
+ const CIF = "data_x\nloop_\n_atom_site.group_PDB\n_atom_site.id\nATOM 1\n";
9
+ let dir;
10
+ let env;
11
+ beforeEach(() => {
12
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), "moldesk-view-"));
13
+ env = { ...process.env, MOLDESK_HOME: path.join(dir, "home") };
14
+ });
15
+ afterEach(() => fs.rmSync(dir, { recursive: true, force: true }));
16
+ const write = (name, content) => {
17
+ const p = path.join(dir, name);
18
+ fs.writeFileSync(p, content);
19
+ return p;
20
+ };
21
+ describe("createStandaloneReport", () => {
22
+ it("creates a report under the cache by default, preserving the original bytes", async () => {
23
+ const result = await createStandaloneReport(write("p.pdb", PDB), { env });
24
+ expect(result.path.startsWith(path.join(dir, "home", "cache", "visualizations"))).toBe(true);
25
+ expect(result.path.endsWith("viewer.html")).toBe(true);
26
+ expect(result.fileUrl).toBe(new URL(`file://${encodeURI(result.path)}`).href);
27
+ const report = decodeEnvelope(extractEnvelope(fs.readFileSync(result.path, "utf8")));
28
+ expect(Buffer.from(report.resources[0].data, "base64").toString()).toBe(PDB);
29
+ expect(report.scenes).toHaveLength(1);
30
+ expect(result.format).toBe("pdb");
31
+ });
32
+ it("accepts .cif and .mmcif as macromolecular mmCIF", async () => {
33
+ for (const [i, ext] of [".cif", ".mmcif", ".CIF"].entries()) {
34
+ const r = await createStandaloneReport(write(`s${i}${ext}`, CIF), { env, outputPath: path.join(dir, `out${i}.html`) });
35
+ expect(r.format).toBe("mmcif");
36
+ }
37
+ });
38
+ it("writes to --output, creating parents, and refuses to overwrite", async () => {
39
+ const out = path.join(dir, "nested", "report.html");
40
+ await createStandaloneReport(write("p.pdb", PDB), { env, outputPath: out });
41
+ expect(fs.existsSync(out)).toBe(true);
42
+ await expect(createStandaloneReport(write("q.pdb", PDB), { env, outputPath: out })).rejects.toMatchObject({ code: "OUTPUT_EXISTS" });
43
+ await expect(createStandaloneReport(write("q.pdb", PDB), { env, outputPath: dir })).rejects.toMatchObject({ code: "INVALID_VIEW_INPUT" });
44
+ });
45
+ it.each([
46
+ ["URL", () => "https://example.com/a.pdb"],
47
+ ["missing file", () => path.join(dir, "nope.pdb")],
48
+ ["directory", () => { fs.mkdirSync(path.join(dir, "d.pdb")); return path.join(dir, "d.pdb"); }],
49
+ ["unsupported extension", () => write("a.txt", PDB)],
50
+ ["empty file", () => write("e.pdb", "")],
51
+ ["binary file", () => write("b.pdb", Buffer.from([0x41, 0, 0x54, 0x4f, 0x4d]))],
52
+ ["PDB without atoms", () => write("n.pdb", "HEADER only\nREMARK nothing\n")],
53
+ ["CIF without atom_site", () => write("n.cif", "data_x\n_cell.length_a 1\n")],
54
+ ])("rejects %s before exporting", async (_name, make) => {
55
+ const input = make();
56
+ await expect(createStandaloneReport(input, { env })).rejects.toMatchObject({ code: "INVALID_VIEW_INPUT" });
57
+ expect(fs.existsSync(path.join(dir, "home", "cache", "visualizations"))).toBe(false);
58
+ });
59
+ it("is cancelled before writing when the signal is already aborted", async () => {
60
+ const controller = new AbortController();
61
+ controller.abort();
62
+ await expect(createStandaloneReport(write("p.pdb", PDB), { env, signal: controller.signal })).rejects.toMatchObject({ code: "VIEW_CANCELLED" });
63
+ expect(fs.existsSync(path.join(dir, "home"))).toBe(false);
64
+ });
65
+ });
66
+ describe("report update", () => {
67
+ const otherBundle = () => {
68
+ const real = loadBundle();
69
+ return { ...real, js: `${real.js}\n/* other build */`, metadata: { ...real.metadata, viewerVersion: "9.9.9", bundleSha256: "f".repeat(64) } };
70
+ };
71
+ const make = async () => (await createStandaloneReport(write("p.pdb", PDB), { env, outputPath: path.join(dir, "r.html") })).path;
72
+ it("is a no-op for a freshly generated report", async () => {
73
+ expect((await prepareReportUpdate(await make())).status).toBe("noop");
74
+ });
75
+ it("replaces in place with a backup when the installed bundle differs", async () => {
76
+ const file = await make();
77
+ const before = fs.readFileSync(file);
78
+ const plan = await prepareReportUpdate(file, { bundle: otherBundle() });
79
+ expect(plan.status).toBe("replace");
80
+ const result = await applyReportUpdate(plan);
81
+ expect(fs.readFileSync(result.backupPath).equals(before)).toBe(true);
82
+ expect(extractEnvelope(fs.readFileSync(file, "utf8")).viewer.version).toBe("9.9.9");
83
+ });
84
+ it("maps failures to typed MoldeskErrors without touching the file", async () => {
85
+ const file = await make();
86
+ await expect(prepareReportUpdate(file, { outputPath: file })).rejects.toMatchObject({ code: "OUTPUT_EXISTS" });
87
+ await expect(prepareReportUpdate("https://x.test/r.html")).rejects.toMatchObject({ code: "INVALID_REPORT" });
88
+ await expect(prepareReportUpdate(write("plain.html", "<html></html>"))).rejects.toMatchObject({ code: "INVALID_REPORT" });
89
+ const plan = await prepareReportUpdate(file, { bundle: otherBundle() });
90
+ fs.appendFileSync(file, "<!-- changed -->");
91
+ await expect(applyReportUpdate(plan)).rejects.toMatchObject({ code: "REPORT_CONFLICT" });
92
+ expect(fs.readFileSync(file, "utf8").endsWith("<!-- changed -->")).toBe(true);
93
+ });
94
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moldesk/core",
3
- "version": "0.0.3",
3
+ "version": "0.0.4",
4
4
  "description": "Shared engine logic for MoleculeDesk (CLI + desktop)",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -19,9 +19,10 @@
19
19
  "dist"
20
20
  ],
21
21
  "dependencies": {
22
- "@moldesk/runtime": "0.0.3",
23
- "@moldesk/adapters": "0.0.3",
24
- "@moldesk/registry": "0.0.3"
22
+ "@moldesk/adapters": "0.0.4",
23
+ "@moldesk/runtime": "0.0.4",
24
+ "@moldesk/visualizer": "0.0.4",
25
+ "@moldesk/registry": "0.0.4"
25
26
  },
26
27
  "devDependencies": {
27
28
  "typescript": "^5.6.3",