@moldesk/runtime 0.0.5 → 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.
@@ -0,0 +1,146 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { RunpodProvider, RUNPOD_USER_AGENT } from "./runpod.js";
3
+ const SECRET = "rpa_TESTSECRET0123456789";
4
+ function json(status, body, headers = {}) {
5
+ return new Response(body === undefined ? null : JSON.stringify(body), { status, headers });
6
+ }
7
+ function setup(responses) {
8
+ const calls = [];
9
+ const sleeps = [];
10
+ const queue = [...responses];
11
+ const fakeFetch = (async (url, init) => {
12
+ const call = {
13
+ method: init.method ?? "GET",
14
+ url: String(url),
15
+ headers: init.headers,
16
+ body: init.body ? JSON.parse(String(init.body)) : undefined,
17
+ };
18
+ calls.push(call);
19
+ const next = queue.shift();
20
+ if (!next)
21
+ throw new Error("unexpected extra request");
22
+ if (next instanceof Error)
23
+ throw next;
24
+ return typeof next === "function" ? next(call) : next;
25
+ });
26
+ const provider = new RunpodProvider({
27
+ getApiKey: async () => SECRET,
28
+ fetch: fakeFetch,
29
+ sleep: async (ms) => {
30
+ sleeps.push(ms);
31
+ },
32
+ now: () => new Date("2026-10-13T12:00:00Z"),
33
+ });
34
+ return { provider, calls, sleeps };
35
+ }
36
+ const podBody = { id: "pod1", name: "n", status: "RUNNING", dataCenterId: "EU-RO-1", cost: 0.57, createdAt: "2026-10-13T11:55:45Z" };
37
+ describe("RunpodProvider", () => {
38
+ it("sends bearer auth and an explicit User-Agent, and validates credentials read-only", async () => {
39
+ const { provider, calls } = setup([json(200, { pods: [] })]);
40
+ expect(await provider.validateCredentials()).toEqual({ ok: true, checkedAt: "2026-10-13T12:00:00.000Z" });
41
+ expect(calls[0].method).toBe("GET");
42
+ expect(calls[0].url).toBe("https://api.runpod.io/v2/pods");
43
+ expect(calls[0].headers.Authorization).toBe(`Bearer ${SECRET}`);
44
+ expect(calls[0].headers["User-Agent"]).toBe(RUNPOD_USER_AGENT);
45
+ });
46
+ it("normalizes the catalog", async () => {
47
+ const { provider } = setup([
48
+ json(200, {
49
+ gpus: [
50
+ {
51
+ id: "NVIDIA A40",
52
+ name: "A40",
53
+ memory: 48,
54
+ price: { secure: 0.59 },
55
+ availability: "LOW",
56
+ dataCenters: [{ id: "EU-SE-1", availability: "LOW" }],
57
+ cudaVersions: [{ version: "12.8", available: true }, { version: "13.0", available: false }],
58
+ },
59
+ ],
60
+ }),
61
+ json(200, { cpus: [{ id: "cpu3c", name: "Compute", price: { securePerVcpu: 0.03 }, ramGbPerVcpu: 2, vcpu: { min: 2, max: 32 } }] }),
62
+ json(200, { dataCenters: [{ id: "AP-JP-1", region: "ASIA", networkVolumeTypes: ["STANDARD"] }, { id: "X", region: "R" }] }),
63
+ ]);
64
+ const catalog = await provider.getCatalog();
65
+ expect(catalog.gpus[0]).toMatchObject({ id: "NVIDIA A40", memoryGb: 48, priceUsdPerHour: 0.59, availability: "LOW", cudaVersions: ["12.8"] });
66
+ expect(catalog.cpus[0]).toMatchObject({ id: "cpu3c", priceUsdPerVcpuHour: 0.03, minVcpu: 2, maxVcpu: 32 });
67
+ expect(catalog.dataCenters.map((dc) => [dc.id, dc.networkVolumeTypes])).toEqual([["AP-JP-1", ["STANDARD"]], ["X", []]]);
68
+ });
69
+ it("creates a GPU pod with a volume mount using the verified request shape", async () => {
70
+ const { provider, calls } = setup([json(201, podBody)]);
71
+ const pod = await provider.createPod({
72
+ name: "moldesk-run1-gpu",
73
+ image: "img",
74
+ containerDiskGb: 20,
75
+ dataCenterId: "EU-RO-1",
76
+ gpu: { id: "NVIDIA A40", count: 1 },
77
+ volume: { id: "vol1", mountPath: "/workspace" },
78
+ ports: ["8000/http"],
79
+ entrypoint: ["/bin/bash", "-c"],
80
+ cmd: ["run"],
81
+ });
82
+ expect(pod).toMatchObject({ id: "pod1", status: "RUNNING", costUsdPerHour: 0.57 });
83
+ expect(calls[0].body).toEqual({
84
+ name: "moldesk-run1-gpu",
85
+ image: "img",
86
+ disk: 20,
87
+ dataCenterIds: ["EU-RO-1"],
88
+ gpu: { id: "NVIDIA A40", count: 1 },
89
+ mounts: { network: [{ volumeId: "vol1", path: "/workspace" }] },
90
+ ports: ["8000/http"],
91
+ args: JSON.stringify({ entrypoint: ["/bin/bash", "-c"], cmd: ["run"] }),
92
+ });
93
+ });
94
+ it("rejects a pod request that is neither or both of gpu and cpu before any request", async () => {
95
+ const { provider, calls } = setup([]);
96
+ await expect(provider.createPod({ name: "n", image: "i", containerDiskGb: 1, dataCenterId: "d" })).rejects.toMatchObject({ code: "CLOUD_REQUEST_REJECTED" });
97
+ expect(calls).toHaveLength(0);
98
+ });
99
+ it("maps capacity failure to CLOUD_CAPACITY_UNAVAILABLE without retrying a create", async () => {
100
+ const { provider, calls } = setup([json(400, { detail: "There are no longer any instances available with the requested specifications.", status: 400 })]);
101
+ await expect(provider.createPod({ name: "n", image: "i", containerDiskGb: 1, dataCenterId: "d", gpu: { id: "g", count: 1 } })).rejects.toMatchObject({ code: "CLOUD_CAPACITY_UNAVAILABLE" });
102
+ expect(calls).toHaveLength(1);
103
+ });
104
+ it("treats a 5xx or network failure on create as ambiguous and never retries it", async () => {
105
+ const first = setup([json(503, { detail: "boom" })]);
106
+ await expect(first.provider.createVolume({ name: "v", sizeGb: 10, dataCenterId: "d" })).rejects.toMatchObject({ code: "CLOUD_AMBIGUOUS_CREATE" });
107
+ expect(first.calls).toHaveLength(1);
108
+ const second = setup([new Error("socket hang up")]);
109
+ await expect(second.provider.createPod({ name: "n", image: "i", containerDiskGb: 1, dataCenterId: "d", cpu: { id: "cpu3c", vcpuCount: 2 } })).rejects.toMatchObject({ code: "CLOUD_AMBIGUOUS_CREATE" });
110
+ expect(second.calls).toHaveLength(1);
111
+ });
112
+ it("treats terminate and delete of an absent resource as success", async () => {
113
+ const { provider, calls } = setup([json(204, undefined), json(404, { detail: "pod not found" }), json(404, { detail: "nope" })]);
114
+ expect(await provider.terminatePod("p")).toEqual({ id: "p", alreadyAbsent: false });
115
+ expect(calls[0].body).toEqual({ action: "terminate" });
116
+ expect(await provider.terminatePod("p")).toEqual({ id: "p", alreadyAbsent: true });
117
+ expect(await provider.deleteVolume("v")).toEqual({ id: "v", alreadyAbsent: true });
118
+ });
119
+ it("returns null for a missing pod or volume", async () => {
120
+ const { provider } = setup([json(404, {}), json(404, {})]);
121
+ expect(await provider.getPod("x")).toBeNull();
122
+ expect(await provider.getVolume("x")).toBeNull();
123
+ });
124
+ it("retries idempotent calls on 429 honoring Retry-After, then succeeds", async () => {
125
+ const { provider, sleeps } = setup([json(429, { detail: "slow down" }, { "retry-after": "2" }), json(200, { pods: [podBody] })]);
126
+ expect(await provider.listPods()).toHaveLength(1);
127
+ expect(sleeps).toEqual([2000]);
128
+ });
129
+ it("gives up with CLOUD_RATE_LIMITED after bounded retries", async () => {
130
+ const { provider, calls } = setup([json(429, {}), json(429, {}), json(429, {})]);
131
+ await expect(provider.listVolumes()).rejects.toMatchObject({ code: "CLOUD_RATE_LIMITED" });
132
+ expect(calls).toHaveLength(3);
133
+ });
134
+ it("maps auth, payment and permission failures and never leaks the key", async () => {
135
+ for (const [status, code] of [[401, "CLOUD_AUTH_FAILED"], [402, "CLOUD_PAYMENT_REQUIRED"], [403, "CLOUD_FORBIDDEN"]]) {
136
+ const { provider } = setup([json(status, { detail: "denied" })]);
137
+ const error = await provider.listPods().then(() => undefined, (e) => e);
138
+ expect(error?.code).toBe(code);
139
+ expect(JSON.stringify({ message: error?.message, details: error?.details })).not.toContain(SECRET);
140
+ }
141
+ });
142
+ it("flags malformed responses instead of returning partial data", async () => {
143
+ const { provider } = setup([json(200, { pods: [{ id: "p", status: "WEIRD" }] })]);
144
+ await expect(provider.listPods()).rejects.toMatchObject({ code: "CLOUD_BAD_RESPONSE" });
145
+ });
146
+ });
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Provider-neutral contracts for rented compute. Everything here is JSON-serializable so it can be
3
+ * journaled and reconstructed after a client restart. This is separate from the local
4
+ * `RuntimeProvider`, which spawns processes on this machine.
5
+ */
6
+ export type CloudPodStatus = "PROVISIONING" | "STARTING" | "RUNNING" | "EXITED" | "ERROR" | "TERMINATED";
7
+ export interface CloudGpuType {
8
+ id: string;
9
+ name: string;
10
+ memoryGb: number;
11
+ /** Secure-cloud on-demand price, USD per GPU-hour. */
12
+ priceUsdPerHour: number;
13
+ /** Availability by datacenter at retrieval time; capacity is never reserved. */
14
+ availability: "NONE" | "LOW" | "MEDIUM" | "HIGH" | "UNKNOWN";
15
+ dataCenters: Array<{
16
+ id: string;
17
+ availability: string;
18
+ }>;
19
+ cudaVersions: string[];
20
+ }
21
+ export interface CloudCpuType {
22
+ id: string;
23
+ name: string;
24
+ priceUsdPerVcpuHour: number;
25
+ ramGbPerVcpu: number;
26
+ minVcpu: number;
27
+ maxVcpu: number;
28
+ }
29
+ export interface CloudDataCenter {
30
+ id: string;
31
+ region: string;
32
+ /** Empty means network volumes are unsupported there. */
33
+ networkVolumeTypes: string[];
34
+ }
35
+ /**
36
+ * Storage rates, only when the provider publishes them. Runpod's catalog API does not expose disk or
37
+ * network-volume prices (only historical billing), so its provider leaves this undefined and quotes
38
+ * state those costs as unknown instead of guessing.
39
+ */
40
+ export interface CloudStorageRates {
41
+ containerDiskUsdPerGbMonth?: number;
42
+ networkVolumeUsdPerGbMonth?: number;
43
+ }
44
+ export interface CloudCatalog {
45
+ retrievedAt: string;
46
+ storageRates?: CloudStorageRates;
47
+ gpus: CloudGpuType[];
48
+ cpus: CloudCpuType[];
49
+ dataCenters: CloudDataCenter[];
50
+ }
51
+ export interface CreatePodRequest {
52
+ name: string;
53
+ image: string;
54
+ containerDiskGb: number;
55
+ dataCenterId: string;
56
+ gpu?: {
57
+ id: string;
58
+ count: number;
59
+ };
60
+ cpu?: {
61
+ id: string;
62
+ vcpuCount: number;
63
+ };
64
+ volume?: {
65
+ id: string;
66
+ mountPath: string;
67
+ };
68
+ /** `port/protocol`, e.g. `8000/http`. */
69
+ ports?: string[];
70
+ env?: Record<string, string>;
71
+ entrypoint?: string[];
72
+ cmd?: string[];
73
+ }
74
+ export interface CloudPod {
75
+ id: string;
76
+ name: string;
77
+ status: CloudPodStatus;
78
+ dataCenterId: string | null;
79
+ /** Provider-reported hourly cost in USD while running. */
80
+ costUsdPerHour: number | null;
81
+ createdAt: string | null;
82
+ }
83
+ export interface CreateVolumeRequest {
84
+ name: string;
85
+ sizeGb: number;
86
+ dataCenterId: string;
87
+ }
88
+ export interface CloudVolume {
89
+ id: string;
90
+ name: string;
91
+ sizeGb: number;
92
+ dataCenterId: string;
93
+ type: string | null;
94
+ }
95
+ export interface CloudAccountCheck {
96
+ ok: true;
97
+ checkedAt: string;
98
+ }
99
+ /** Result of an idempotent delete: absence counts as success. */
100
+ export interface CloudDeleteResult {
101
+ id: string;
102
+ alreadyAbsent: boolean;
103
+ }
104
+ export interface CloudProvider {
105
+ readonly name: string;
106
+ /** Minimum read-only call that proves the credential works. */
107
+ validateCredentials(): Promise<CloudAccountCheck>;
108
+ getCatalog(): Promise<CloudCatalog>;
109
+ createPod(request: CreatePodRequest): Promise<CloudPod>;
110
+ getPod(id: string): Promise<CloudPod | null>;
111
+ listPods(): Promise<CloudPod[]>;
112
+ terminatePod(id: string): Promise<CloudDeleteResult>;
113
+ createVolume(request: CreateVolumeRequest): Promise<CloudVolume>;
114
+ getVolume(id: string): Promise<CloudVolume | null>;
115
+ listVolumes(): Promise<CloudVolume[]>;
116
+ deleteVolume(id: string): Promise<CloudDeleteResult>;
117
+ }
118
+ export type CloudErrorCode = "CLOUD_AUTH_FAILED" | "CLOUD_FORBIDDEN" | "CLOUD_PAYMENT_REQUIRED" | "CLOUD_RATE_LIMITED" | "CLOUD_CAPACITY_UNAVAILABLE" | "CLOUD_REQUEST_REJECTED" | "CLOUD_BAD_RESPONSE" | "CLOUD_UNAVAILABLE"
119
+ /** A create request may or may not have taken effect; discover candidates before retrying. */
120
+ | "CLOUD_AMBIGUOUS_CREATE";
121
+ export type CloudResourceRoleName = "cpu" | "gpu" | "volume";
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Provider-neutral contracts for rented compute. Everything here is JSON-serializable so it can be
3
+ * journaled and reconstructed after a client restart. This is separate from the local
4
+ * `RuntimeProvider`, which spawns processes on this machine.
5
+ */
6
+ export {};
@@ -0,0 +1,62 @@
1
+ import type { ArtifactInventory, JobInputFile, JobSnapshot, WireJob } from "./execution.js";
2
+ export declare const WORKER_USER_AGENT = "moldesk-worker-client/1";
3
+ export interface WorkerClientOptions {
4
+ /** HTTPS base URL of the worker (the provider proxy), no trailing slash required. */
5
+ endpoint: string;
6
+ /** Per-job bearer token. Never logged, persisted in the journal or sent anywhere else. */
7
+ token: string;
8
+ fetch?: typeof fetch;
9
+ sleep?: (ms: number) => Promise<void>;
10
+ requestTimeoutMs?: number;
11
+ /** Test-only: permit plain http (loopback). Production endpoints must be https. */
12
+ allowInsecureLoopback?: boolean;
13
+ /** A stream that delivers no bytes for this long is aborted (and retried by range). Default 60 s. */
14
+ idleTimeoutMs?: number;
15
+ /** Cancels every in-flight request, including bodies still streaming. */
16
+ signal?: AbortSignal;
17
+ }
18
+ /** Rejects absolute paths, traversal, backslashes, empty/`.` segments and control characters. */
19
+ export declare function isSafeRelativePath(value: string): boolean;
20
+ export declare class WorkerClient {
21
+ private readonly options;
22
+ private readonly fetchFn;
23
+ private readonly sleep;
24
+ private readonly base;
25
+ private readonly timeoutMs;
26
+ private readonly idleMs;
27
+ constructor(options: WorkerClientOptions);
28
+ /** Timeout + cancellation scope covering headers AND body streaming; the timer is only released by the caller. */
29
+ private guard;
30
+ private failure;
31
+ private requestInit;
32
+ /** Whole-request deadline: the body is read inside the same timeout/cancellation scope as the headers. */
33
+ private requestText;
34
+ private parse;
35
+ health(): Promise<{
36
+ contract: number;
37
+ workerVersion: string;
38
+ }>;
39
+ /** Polls health until ready. 404/connection failures (proxy not routed yet, container booting) are expected. */
40
+ waitUntilReady(options: {
41
+ timeoutMs: number;
42
+ intervalMs?: number;
43
+ abortIf?: () => Promise<string | undefined>;
44
+ signal?: AbortSignal;
45
+ }): Promise<void>;
46
+ /** Streams one input file, verifying size and SHA-256 on both ends. */
47
+ uploadInput(runId: string, file: Pick<JobInputFile, "relativePath" | "sha256" | "sizeBytes" | "sourcePath">): Promise<void>;
48
+ start(job: WireJob): Promise<void>;
49
+ status(runId: string, after?: number): Promise<JobSnapshot>;
50
+ cancel(runId: string): Promise<void>;
51
+ inventory(runId: string, signal?: AbortSignal): Promise<ArtifactInventory>;
52
+ /**
53
+ * Downloads every inventory file into a staging directory, verifies size and SHA-256 per file,
54
+ * then promotes them into `localOutputDir`. Nothing is promoted unless everything verified, and a
55
+ * partial file is resumed by byte range then re-verified in full. Never reruns inference.
56
+ */
57
+ collect(runId: string, localOutputDir: string, options?: {
58
+ signal?: AbortSignal;
59
+ }): Promise<ArtifactInventory>;
60
+ private downloadVerified;
61
+ }
62
+ export declare function validateInventory(inventory: ArtifactInventory): void;
@@ -0,0 +1,306 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { Readable } from "node:stream";
5
+ import { MoldeskError } from "../providers.js";
6
+ import { EXECUTION_CONTRACT_VERSION } from "./execution.js";
7
+ export const WORKER_USER_AGENT = "moldesk-worker-client/1";
8
+ const MAX_JSON_BYTES = 8 * 1024 * 1024;
9
+ function workerError(code, message, remediation, details, cause) {
10
+ return new MoldeskError({ code, message, remediation, details, cause });
11
+ }
12
+ /** Rejects absolute paths, traversal, backslashes, empty/`.` segments and control characters. */
13
+ export function isSafeRelativePath(value) {
14
+ if (!value || value.length > 1024 || value.includes("\\") || value.startsWith("/") || /^[A-Za-z]:/.test(value) || /[\u0000-\u001f]/.test(value))
15
+ return false;
16
+ return value.split("/").every((part) => part !== "" && part !== "." && part !== "..");
17
+ }
18
+ export class WorkerClient {
19
+ options;
20
+ fetchFn;
21
+ sleep;
22
+ base;
23
+ timeoutMs;
24
+ idleMs;
25
+ constructor(options) {
26
+ this.options = options;
27
+ const url = new URL(options.endpoint);
28
+ const loopback = url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "[::1]";
29
+ if (url.protocol !== "https:" && !(options.allowInsecureLoopback && loopback && url.protocol === "http:")) {
30
+ throw workerError("CLOUD_WORKER_INSECURE_ENDPOINT", "The worker endpoint must use HTTPS.", "There is no plaintext fallback; use the provider's HTTPS proxy.");
31
+ }
32
+ this.base = options.endpoint.replace(/\/+$/, "");
33
+ this.fetchFn = options.fetch ?? fetch;
34
+ this.sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
35
+ this.timeoutMs = options.requestTimeoutMs ?? 60_000;
36
+ this.idleMs = options.idleTimeoutMs ?? 60_000;
37
+ }
38
+ /** Timeout + cancellation scope covering headers AND body streaming; the timer is only released by the caller. */
39
+ guard(ms, external) {
40
+ const controller = new AbortController();
41
+ const source = external ?? this.options.signal;
42
+ let timer;
43
+ let timedOut = false;
44
+ const onAbort = () => controller.abort();
45
+ if (source?.aborted)
46
+ controller.abort();
47
+ else
48
+ source?.addEventListener("abort", onAbort, { once: true });
49
+ const arm = (next) => {
50
+ clearTimeout(timer);
51
+ timer = setTimeout(() => {
52
+ timedOut = true;
53
+ controller.abort();
54
+ }, next);
55
+ };
56
+ arm(ms);
57
+ return {
58
+ signal: controller.signal,
59
+ arm,
60
+ release: () => {
61
+ clearTimeout(timer);
62
+ source?.removeEventListener("abort", onAbort);
63
+ },
64
+ cancelled: () => source?.aborted === true,
65
+ timedOut: () => timedOut,
66
+ };
67
+ }
68
+ failure(guard, route, cause) {
69
+ if (guard.cancelled())
70
+ return workerError("RUN_CANCELLED", "Cancelled.", "None.", { route }, cause);
71
+ if (guard.timedOut())
72
+ return workerError("CLOUD_WORKER_TIMEOUT", "The worker did not respond in time.", "Retry; the machine may be overloaded or the connection stalled.", { route }, cause);
73
+ return workerError("CLOUD_WORKER_UNREACHABLE", "Could not reach the worker.", "Check the connection; the machine may still be booting.", { route }, cause);
74
+ }
75
+ requestInit(method, init, signal) {
76
+ return {
77
+ method,
78
+ headers: {
79
+ Authorization: `Bearer ${this.options.token}`,
80
+ "User-Agent": WORKER_USER_AGENT,
81
+ ...(init.json !== undefined ? { "Content-Type": "application/json" } : {}),
82
+ ...init.headers,
83
+ },
84
+ ...(init.json !== undefined ? { body: JSON.stringify(init.json) } : init.body !== undefined ? { body: init.body } : {}),
85
+ ...(init.duplex ? { duplex: "half" } : {}),
86
+ signal,
87
+ };
88
+ }
89
+ /** Whole-request deadline: the body is read inside the same timeout/cancellation scope as the headers. */
90
+ async requestText(method, route, init = {}) {
91
+ const guard = this.guard(init.timeoutMs ?? this.timeoutMs, init.signal);
92
+ try {
93
+ const response = await this.fetchFn(`${this.base}${route}`, this.requestInit(method, init, guard.signal));
94
+ const text = await response.text();
95
+ return { status: response.status, ok: response.ok, text };
96
+ }
97
+ catch (error) {
98
+ throw this.failure(guard, route, error);
99
+ }
100
+ finally {
101
+ guard.release();
102
+ }
103
+ }
104
+ parse(result, route) {
105
+ if (result.status === 401 || result.status === 403) {
106
+ throw workerError("CLOUD_WORKER_AUTH_FAILED", "The worker rejected the job token.", "The worker belongs to a different job; do not retry with another token.", { route, status: result.status });
107
+ }
108
+ if (result.text.length > MAX_JSON_BYTES)
109
+ throw workerError("CLOUD_BAD_RESPONSE", "Worker response too large.", "Retry.", { route });
110
+ if (!result.ok) {
111
+ let detail;
112
+ try {
113
+ detail = JSON.parse(result.text).message;
114
+ }
115
+ catch {
116
+ detail = result.text.slice(0, 200);
117
+ }
118
+ throw workerError("CLOUD_WORKER_ERROR", `Worker error ${result.status}${detail ? `: ${detail}` : ""}.`, "Retry or inspect the run logs.", { route, status: result.status });
119
+ }
120
+ try {
121
+ return JSON.parse(result.text);
122
+ }
123
+ catch (error) {
124
+ throw workerError("CLOUD_BAD_RESPONSE", "The worker returned malformed JSON.", "Retry.", { route }, error);
125
+ }
126
+ }
127
+ async health() {
128
+ const result = this.parse(await this.requestText("GET", "/v1/health", { timeoutMs: 15_000 }), "/v1/health");
129
+ if (result.contract !== EXECUTION_CONTRACT_VERSION) {
130
+ throw workerError("CLOUD_WORKER_VERSION_MISMATCH", `Worker speaks contract ${result.contract}; this client speaks ${EXECUTION_CONTRACT_VERSION}.`, "Use matching MoleculeDesk versions.");
131
+ }
132
+ return result;
133
+ }
134
+ /** Polls health until ready. 404/connection failures (proxy not routed yet, container booting) are expected. */
135
+ async waitUntilReady(options) {
136
+ const started = Date.now();
137
+ let last;
138
+ while (Date.now() - started < options.timeoutMs) {
139
+ if (options.signal?.aborted)
140
+ throw workerError("RUN_CANCELLED", "Cancelled while waiting for the worker.", "None.");
141
+ try {
142
+ await this.health();
143
+ return;
144
+ }
145
+ catch (error) {
146
+ last = error;
147
+ if (error instanceof MoldeskError && (error.code === "CLOUD_WORKER_AUTH_FAILED" || error.code === "CLOUD_WORKER_VERSION_MISMATCH"))
148
+ throw error;
149
+ }
150
+ const fatal = await options.abortIf?.();
151
+ if (fatal)
152
+ throw workerError("CLOUD_WORKER_BOOT_FAILED", fatal, "Inspect the Pod; it will be terminated.");
153
+ await this.sleep(options.intervalMs ?? 5000);
154
+ }
155
+ throw workerError("CLOUD_WORKER_BOOT_TIMEOUT", `The worker did not become ready within ${Math.round(options.timeoutMs / 1000)}s.`, "Cold image pulls can be slow; retry, or check provider status.", undefined, last);
156
+ }
157
+ /** Streams one input file, verifying size and SHA-256 on both ends. */
158
+ async uploadInput(runId, file) {
159
+ if (!isSafeRelativePath(file.relativePath))
160
+ throw workerError("CLOUD_UNSAFE_PATH", `Unsafe input path ${JSON.stringify(file.relativePath)}.`, "Use a simple file name.");
161
+ const stat = fs.statSync(file.sourcePath);
162
+ if (stat.size !== file.sizeBytes)
163
+ throw workerError("CLOUD_INPUT_CHANGED", "The input changed size after it was quoted.", "Request a new quote.");
164
+ const route = `/v1/jobs/${encodeURIComponent(runId)}/input/${file.relativePath.split("/").map(encodeURIComponent).join("/")}`;
165
+ const body = Readable.toWeb(fs.createReadStream(file.sourcePath));
166
+ const response = await this.requestText("PUT", route, {
167
+ body,
168
+ duplex: true,
169
+ timeoutMs: 10 * 60_000,
170
+ headers: { "Content-Type": "application/octet-stream", "Content-Length": String(file.sizeBytes), "X-Moldesk-Sha256": file.sha256 },
171
+ });
172
+ this.parse(response, route);
173
+ }
174
+ async start(job) {
175
+ const route = `/v1/jobs/${encodeURIComponent(job.runId)}/start`;
176
+ this.parse(await this.requestText("POST", route, { json: job }), route);
177
+ }
178
+ async status(runId, after = 0) {
179
+ const route = `/v1/jobs/${encodeURIComponent(runId)}/status?after=${after}`;
180
+ return this.parse(await this.requestText("GET", route, { timeoutMs: 20_000 }), route);
181
+ }
182
+ async cancel(runId) {
183
+ const route = `/v1/jobs/${encodeURIComponent(runId)}/cancel`;
184
+ this.parse(await this.requestText("POST", route, { timeoutMs: 20_000 }), route);
185
+ }
186
+ async inventory(runId, signal) {
187
+ const route = `/v1/jobs/${encodeURIComponent(runId)}/inventory`;
188
+ const inventory = this.parse(await this.requestText("GET", route, { signal }), route);
189
+ validateInventory(inventory);
190
+ return inventory;
191
+ }
192
+ /**
193
+ * Downloads every inventory file into a staging directory, verifies size and SHA-256 per file,
194
+ * then promotes them into `localOutputDir`. Nothing is promoted unless everything verified, and a
195
+ * partial file is resumed by byte range then re-verified in full. Never reruns inference.
196
+ */
197
+ async collect(runId, localOutputDir, options = {}) {
198
+ const inventory = await this.inventory(runId, options.signal);
199
+ const staging = path.join(localOutputDir, `.moldesk-staging-${randomUUID()}`);
200
+ fs.mkdirSync(staging, { recursive: true });
201
+ try {
202
+ for (const file of inventory.files) {
203
+ const target = path.join(staging, ...file.path.split("/"));
204
+ fs.mkdirSync(path.dirname(target), { recursive: true });
205
+ await this.downloadVerified(runId, file, target, options.signal);
206
+ }
207
+ for (const file of inventory.files) {
208
+ const finalPath = path.join(localOutputDir, ...file.path.split("/"));
209
+ if (fs.existsSync(finalPath)) {
210
+ throw workerError("CLOUD_OUTPUT_COLLISION", `Output ${file.path} already exists locally.`, "Use an empty run output directory.");
211
+ }
212
+ fs.mkdirSync(path.dirname(finalPath), { recursive: true });
213
+ fs.renameSync(path.join(staging, ...file.path.split("/")), finalPath);
214
+ }
215
+ return inventory;
216
+ }
217
+ finally {
218
+ fs.rmSync(staging, { recursive: true, force: true });
219
+ }
220
+ }
221
+ async downloadVerified(runId, file, target, signal) {
222
+ const route = `/v1/jobs/${encodeURIComponent(runId)}/files/${file.path.split("/").map(encodeURIComponent).join("/")}`;
223
+ for (let attempt = 1; attempt <= 3; attempt += 1) {
224
+ const have = fs.existsSync(target) ? fs.statSync(target).size : 0;
225
+ const resume = have > 0 && have < file.sizeBytes;
226
+ if (!resume && have > 0)
227
+ fs.rmSync(target, { force: true });
228
+ // One scope per attempt: it covers connecting, headers and every body chunk, and is re-armed on
229
+ // each chunk so only a genuinely stalled stream times out. Cancellation aborts it immediately.
230
+ const guard = this.guard(this.idleMs, signal);
231
+ let response;
232
+ try {
233
+ response = await this.fetchFn(`${this.base}${route}`, this.requestInit("GET", { headers: resume ? { Range: `bytes=${have}-` } : {} }, guard.signal));
234
+ }
235
+ catch (error) {
236
+ guard.release();
237
+ const failure = this.failure(guard, route, error);
238
+ if (failure.code === "RUN_CANCELLED" || attempt === 3)
239
+ throw failure.code === "RUN_CANCELLED" ? failure : workerError("CLOUD_TRANSFER_FAILED", `Could not download ${file.path}.`, "Retry recovery before the lifetime limit.", { path: file.path }, error);
240
+ continue;
241
+ }
242
+ if (response.status === 401 || response.status === 403) {
243
+ guard.release();
244
+ throw workerError("CLOUD_WORKER_AUTH_FAILED", "The worker rejected the job token.", "Do not retry.");
245
+ }
246
+ const okStatus = resume ? response.status === 206 : response.status === 200;
247
+ if (!okStatus || !response.body) {
248
+ guard.release();
249
+ if (resume && response.status === 200)
250
+ fs.rmSync(target, { force: true }); // server ignored Range: restart this file only
251
+ if (attempt === 3)
252
+ throw workerError("CLOUD_TRANSFER_FAILED", `Could not download ${file.path} (status ${response.status}).`, "Retry recovery before the lifetime limit.");
253
+ continue;
254
+ }
255
+ const sink = fs.createWriteStream(target, { flags: resume ? "a" : "w" });
256
+ const closeSink = () => new Promise((resolve, reject) => sink.end((error) => (error ? reject(error) : resolve())));
257
+ try {
258
+ for await (const chunk of Readable.fromWeb(response.body)) {
259
+ guard.arm(this.idleMs);
260
+ if (!sink.write(chunk))
261
+ await new Promise((resolve) => sink.once("drain", resolve));
262
+ }
263
+ await closeSink();
264
+ }
265
+ catch (error) {
266
+ // Flush what arrived so the next attempt can resume from it by byte range.
267
+ await closeSink().catch(() => undefined);
268
+ const cancelled = guard.cancelled();
269
+ const stalled = guard.timedOut();
270
+ guard.release();
271
+ if (cancelled)
272
+ throw workerError("RUN_CANCELLED", "Cancelled during download.", "None.", { path: file.path }, error);
273
+ if (attempt === 3) {
274
+ throw workerError("CLOUD_TRANSFER_FAILED", `The download of ${file.path} was ${stalled ? "stalled" : "interrupted"}.`, "Retry recovery before the lifetime limit.", { path: file.path }, error);
275
+ }
276
+ continue;
277
+ }
278
+ guard.release();
279
+ // Verify the complete file regardless of how it was assembled.
280
+ const size = fs.statSync(target).size;
281
+ const hash = createHash("sha256");
282
+ for await (const chunk of fs.createReadStream(target))
283
+ hash.update(chunk);
284
+ if (size === file.sizeBytes && hash.digest("hex") === file.sha256.toLowerCase())
285
+ return;
286
+ fs.rmSync(target, { force: true });
287
+ if (attempt === 3)
288
+ throw workerError("CLOUD_TRANSFER_CORRUPT", `${file.path} failed size/SHA-256 verification.`, "The result was corrupted in transit; it is not accepted.", { path: file.path });
289
+ }
290
+ }
291
+ }
292
+ export function validateInventory(inventory) {
293
+ if (inventory?.version !== EXECUTION_CONTRACT_VERSION || !Array.isArray(inventory.files)) {
294
+ throw workerError("CLOUD_BAD_RESPONSE", "The worker returned an invalid inventory.", "Retry.");
295
+ }
296
+ const seen = new Set();
297
+ for (const file of inventory.files) {
298
+ const key = file?.path?.toLowerCase();
299
+ if (!file || !isSafeRelativePath(file.path) || !Number.isInteger(file.sizeBytes) || file.sizeBytes < 0 || !/^[0-9a-f]{64}$/i.test(file.sha256)) {
300
+ throw workerError("CLOUD_UNSAFE_PATH", `The inventory contains an unsafe or malformed entry: ${JSON.stringify(file?.path)}.`, "The result is rejected; nothing was written locally.");
301
+ }
302
+ if (seen.has(key))
303
+ throw workerError("CLOUD_UNSAFE_PATH", `The inventory lists ${file.path} more than once (case-insensitive).`, "The result is rejected.");
304
+ seen.add(key);
305
+ }
306
+ }
package/dist/index.d.ts CHANGED
@@ -19,6 +19,17 @@ export type { ExecuteRequest, ExecutionResult, MoldeskErrorData, PrepareRuntimeR
19
19
  export { MoldeskError } from "./providers.js";
20
20
  export { PythonRuntimeProvider } from "./providers/python.js";
21
21
  export { redactEnv } from "./redact.js";
22
+ export { RunpodProvider, RUNPOD_API_BASE, RUNPOD_USER_AGENT } from "./cloud/runpod.js";
23
+ export { CredentialStore, KEYRING_SERVICE, DEFAULT_ACCOUNT_REF, PROVIDER_ENV_VARS, createKeyringBackend, credentialFingerprint, redactSecrets, validateSecretShape, } from "./cloud/credentials.js";
24
+ export type { CredentialSource, CredentialStoreOptions, ResolvedCredential, SecretBackend } from "./cloud/credentials.js";
25
+ export type { RunpodProviderOptions } from "./cloud/runpod.js";
26
+ export type * from "./cloud/types.js";
27
+ export { EXECUTION_CONTRACT_VERSION, WORKER_PORT, toWireJob } from "./cloud/execution.js";
28
+ export { RunpodExecutionBackend, WORKER_BOOTSTRAP_SCRIPT, MIN_WORKER_READY_TIMEOUT_MS, VOLUME_MOUNT, createKeyringTokenStore, } from "./cloud/runpod-backend.js";
29
+ export type { RunpodBackendOptions, TokenStore, WorkerBundle } from "./cloud/runpod-backend.js";
30
+ export { WorkerClient, WORKER_USER_AGENT, isSafeRelativePath, validateInventory } from "./cloud/worker-client.js";
31
+ export type { WorkerClientOptions } from "./cloud/worker-client.js";
32
+ export type * from "./cloud/execution.js";
22
33
  export { getHardwareInfo, getDiskInfo } from "./hardware/index.js";
23
34
  export { detectPython } from "./python/index.js";
24
35
  export { detectDocker, isImageCachedLocally, pullDockerImage, probeDockerGpuAccess, } from "./docker/index.js";