@moldesk/runtime 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.
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Cloud API credentials. The OS credential store is the default. An explicit provider environment
3
+ * variable (e.g. RUNPOD_API_KEY) is accepted as a documented development override and takes
4
+ * precedence when set. Files such as `.env` are never read automatically, and there is no
5
+ * plaintext-file fallback: if neither source holds a key, the caller is told to connect one.
6
+ * Secrets are never written to run records, the cloud journal, error messages or command arguments.
7
+ */
8
+ export declare const KEYRING_SERVICE = "moldesk";
9
+ export declare const DEFAULT_ACCOUNT_REF = "default";
10
+ /** Environment variable that carries each provider's key. */
11
+ export declare const PROVIDER_ENV_VARS: Readonly<Record<string, string>>;
12
+ export interface SecretBackend {
13
+ get(service: string, account: string): Promise<string | null>;
14
+ set(service: string, account: string, secret: string): Promise<void>;
15
+ /** Returns whether an entry existed. */
16
+ delete(service: string, account: string): Promise<boolean>;
17
+ }
18
+ /** OS keychain via a maintained N-API binding; loaded lazily so unsupported platforms only lose this feature. */
19
+ export declare function createKeyringBackend(): SecretBackend;
20
+ /** A key must be a single printable token; anything else is almost certainly a paste mistake. */
21
+ export declare function validateSecretShape(secret: string): void;
22
+ export type CredentialSource = "env" | "keyring";
23
+ export interface ResolvedCredential {
24
+ /** The secret itself. Hand it to a client; never log or persist it. */
25
+ key: string;
26
+ source: CredentialSource;
27
+ }
28
+ export interface CredentialStoreOptions {
29
+ backend?: SecretBackend;
30
+ /** Development override source. Defaults to `process.env`. */
31
+ env?: NodeJS.ProcessEnv;
32
+ }
33
+ export declare class CredentialStore {
34
+ private readonly backend;
35
+ private readonly env;
36
+ constructor(options?: CredentialStoreOptions);
37
+ private envVar;
38
+ private account;
39
+ /** Explicit environment override first, then the OS store. Returns null when nothing is configured. */
40
+ resolve(provider: string, accountRef?: string): Promise<ResolvedCredential | null>;
41
+ /** Like `resolve` but throws an actionable error when no credential exists. */
42
+ require(provider: string, accountRef?: string): Promise<ResolvedCredential>;
43
+ /** Where the key would come from, without returning it. */
44
+ status(provider: string, accountRef?: string): Promise<{
45
+ source: CredentialSource | "none";
46
+ }>;
47
+ /** Shape-check a candidate key for a supported provider without storing it. */
48
+ validate(provider: string, key: string): Promise<void>;
49
+ set(provider: string, key: string, accountRef?: string): Promise<void>;
50
+ /** Removes the stored key. An environment variable is the user's own and is never touched. */
51
+ delete(provider: string, accountRef?: string): Promise<boolean>;
52
+ private checked;
53
+ }
54
+ /**
55
+ * Stable, non-reversible identifier for a credential, used only as a conservative account scope
56
+ * when the provider exposes no account identity. Keyed so the stored value cannot be matched
57
+ * against a guessed key without the per-installation salt.
58
+ */
59
+ export declare function credentialFingerprint(key: string, installationSalt: string): string;
60
+ /** Replaces every occurrence of each secret (and its URL-encoded form) with a fixed marker. */
61
+ export declare function redactSecrets(text: string, secrets: readonly string[]): string;
@@ -0,0 +1,161 @@
1
+ import { createHmac } from "node:crypto";
2
+ import { createRequire } from "node:module";
3
+ import { MoldeskError } from "../providers.js";
4
+ /**
5
+ * Cloud API credentials. The OS credential store is the default. An explicit provider environment
6
+ * variable (e.g. RUNPOD_API_KEY) is accepted as a documented development override and takes
7
+ * precedence when set. Files such as `.env` are never read automatically, and there is no
8
+ * plaintext-file fallback: if neither source holds a key, the caller is told to connect one.
9
+ * Secrets are never written to run records, the cloud journal, error messages or command arguments.
10
+ */
11
+ export const KEYRING_SERVICE = "moldesk";
12
+ export const DEFAULT_ACCOUNT_REF = "default";
13
+ /** Environment variable that carries each provider's key. */
14
+ export const PROVIDER_ENV_VARS = { runpod: "RUNPOD_API_KEY" };
15
+ const SECRET_MIN_LENGTH = 8;
16
+ const SECRET_MAX_LENGTH = 512;
17
+ function storeUnavailable(action, cause) {
18
+ return new MoldeskError({
19
+ code: "CREDENTIAL_STORE_UNAVAILABLE",
20
+ message: `The operating system credential store is unavailable (${action}).`,
21
+ remediation: "Unlock your keychain/secret service, or provide the key through the provider environment variable for this session.",
22
+ cause,
23
+ });
24
+ }
25
+ /** OS keychain via a maintained N-API binding; loaded lazily so unsupported platforms only lose this feature. */
26
+ export function createKeyringBackend() {
27
+ let ctor;
28
+ const entry = (service, account, action) => {
29
+ try {
30
+ ctor ??= createRequire(import.meta.url)("@napi-rs/keyring").Entry;
31
+ return new ctor(service, account);
32
+ }
33
+ catch (error) {
34
+ throw storeUnavailable(action, error);
35
+ }
36
+ };
37
+ return {
38
+ async get(service, account) {
39
+ const target = entry(service, account, "read");
40
+ try {
41
+ return target.getPassword() ?? null;
42
+ }
43
+ catch (error) {
44
+ throw storeUnavailable("read", error);
45
+ }
46
+ },
47
+ async set(service, account, secret) {
48
+ const target = entry(service, account, "write");
49
+ try {
50
+ target.setPassword(secret);
51
+ }
52
+ catch (error) {
53
+ throw storeUnavailable("write", error);
54
+ }
55
+ },
56
+ async delete(service, account) {
57
+ const target = entry(service, account, "delete");
58
+ try {
59
+ return target.deletePassword();
60
+ }
61
+ catch (error) {
62
+ throw storeUnavailable("delete", error);
63
+ }
64
+ },
65
+ };
66
+ }
67
+ /** A key must be a single printable token; anything else is almost certainly a paste mistake. */
68
+ export function validateSecretShape(secret) {
69
+ const trimmed = secret.trim();
70
+ if (trimmed !== secret || secret.length < SECRET_MIN_LENGTH || secret.length > SECRET_MAX_LENGTH || /[\s\u0000-\u001f\u007f]/.test(secret)) {
71
+ throw new MoldeskError({
72
+ code: "CREDENTIAL_INVALID",
73
+ message: "The API key is malformed (empty, too short/long, or contains whitespace or control characters).",
74
+ remediation: "Copy the key again from the provider console without surrounding spaces or newlines.",
75
+ });
76
+ }
77
+ }
78
+ export class CredentialStore {
79
+ backend;
80
+ env;
81
+ constructor(options = {}) {
82
+ this.backend = options.backend ?? createKeyringBackend();
83
+ this.env = options.env ?? process.env;
84
+ }
85
+ envVar(provider) {
86
+ const name = PROVIDER_ENV_VARS[provider];
87
+ if (!name)
88
+ throw new MoldeskError({ code: "CREDENTIAL_UNSUPPORTED_PROVIDER", message: `Unknown cloud provider "${provider}".`, remediation: "Use a supported provider, e.g. runpod." });
89
+ return name;
90
+ }
91
+ account(provider, accountRef) {
92
+ if (!/^[A-Za-z0-9._-]{1,64}$/.test(accountRef)) {
93
+ throw new MoldeskError({ code: "CREDENTIAL_INVALID", message: `Unsafe account reference ${JSON.stringify(accountRef)}.`, remediation: "Use letters, digits, '.', '_' or '-'." });
94
+ }
95
+ return `${provider}:${accountRef}`;
96
+ }
97
+ /** Explicit environment override first, then the OS store. Returns null when nothing is configured. */
98
+ async resolve(provider, accountRef = DEFAULT_ACCOUNT_REF) {
99
+ const name = this.envVar(provider);
100
+ const fromEnv = this.env[name];
101
+ if (fromEnv)
102
+ return this.checked({ key: fromEnv, source: "env" });
103
+ const stored = await this.backend.get(KEYRING_SERVICE, this.account(provider, accountRef));
104
+ return stored ? this.checked({ key: stored, source: "keyring" }) : null;
105
+ }
106
+ /** Like `resolve` but throws an actionable error when no credential exists. */
107
+ async require(provider, accountRef = DEFAULT_ACCOUNT_REF) {
108
+ const resolved = await this.resolve(provider, accountRef);
109
+ if (!resolved) {
110
+ throw new MoldeskError({
111
+ code: "CREDENTIAL_MISSING",
112
+ message: `No ${provider} API key is configured.`,
113
+ remediation: `Run \`moldesk cloud connect ${provider}\`, or set ${this.envVar(provider)} in the environment (development override).`,
114
+ });
115
+ }
116
+ return resolved;
117
+ }
118
+ /** Where the key would come from, without returning it. */
119
+ async status(provider, accountRef = DEFAULT_ACCOUNT_REF) {
120
+ const resolved = await this.resolve(provider, accountRef);
121
+ return { source: resolved?.source ?? "none" };
122
+ }
123
+ /** Shape-check a candidate key for a supported provider without storing it. */
124
+ async validate(provider, key) {
125
+ this.envVar(provider);
126
+ validateSecretShape(key);
127
+ }
128
+ async set(provider, key, accountRef = DEFAULT_ACCOUNT_REF) {
129
+ this.envVar(provider);
130
+ validateSecretShape(key);
131
+ await this.backend.set(KEYRING_SERVICE, this.account(provider, accountRef), key);
132
+ }
133
+ /** Removes the stored key. An environment variable is the user's own and is never touched. */
134
+ async delete(provider, accountRef = DEFAULT_ACCOUNT_REF) {
135
+ this.envVar(provider);
136
+ return this.backend.delete(KEYRING_SERVICE, this.account(provider, accountRef));
137
+ }
138
+ checked(credential) {
139
+ validateSecretShape(credential.key);
140
+ return credential;
141
+ }
142
+ }
143
+ /**
144
+ * Stable, non-reversible identifier for a credential, used only as a conservative account scope
145
+ * when the provider exposes no account identity. Keyed so the stored value cannot be matched
146
+ * against a guessed key without the per-installation salt.
147
+ */
148
+ export function credentialFingerprint(key, installationSalt) {
149
+ return createHmac("sha256", installationSalt).update(key).digest("hex").slice(0, 32);
150
+ }
151
+ /** Replaces every occurrence of each secret (and its URL-encoded form) with a fixed marker. */
152
+ export function redactSecrets(text, secrets) {
153
+ let out = text;
154
+ for (const secret of secrets) {
155
+ if (secret.length < SECRET_MIN_LENGTH)
156
+ continue;
157
+ for (const variant of new Set([secret, encodeURIComponent(secret)]))
158
+ out = out.split(variant).join("[REDACTED]");
159
+ }
160
+ return out;
161
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,124 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+ import { CredentialStore, KEYRING_SERVICE, credentialFingerprint, redactSecrets, validateSecretShape, } from "./credentials.js";
6
+ const KEY = "rpa_TESTKEY0123456789";
7
+ const dirs = [];
8
+ function emptyDir() {
9
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "moldesk cred "));
10
+ dirs.push(dir);
11
+ return dir;
12
+ }
13
+ afterEach(() => {
14
+ for (const dir of dirs.splice(0))
15
+ fs.rmSync(dir, { recursive: true, force: true });
16
+ });
17
+ function memoryBackend(initial = {}) {
18
+ const data = new Map(Object.entries(initial));
19
+ return {
20
+ data,
21
+ async get(service, account) {
22
+ return data.get(`${service}/${account}`) ?? null;
23
+ },
24
+ async set(service, account, secret) {
25
+ data.set(`${service}/${account}`, secret);
26
+ },
27
+ async delete(service, account) {
28
+ return data.delete(`${service}/${account}`);
29
+ },
30
+ };
31
+ }
32
+ describe("CredentialStore", () => {
33
+ it("stores in the OS backend under a namespaced account and resolves it", async () => {
34
+ const backend = memoryBackend();
35
+ const store = new CredentialStore({ backend, env: {} });
36
+ await store.set("runpod", KEY);
37
+ expect([...backend.data.keys()]).toEqual([`${KEYRING_SERVICE}/runpod:default`]);
38
+ expect(await store.resolve("runpod")).toEqual({ key: KEY, source: "keyring" });
39
+ expect(await store.status("runpod")).toEqual({ source: "keyring" });
40
+ });
41
+ it("prefers an explicit environment override, then the keyring", async () => {
42
+ const backend = memoryBackend({ [`${KEYRING_SERVICE}/runpod:default`]: `${KEY}-keyring` });
43
+ const withEnv = new CredentialStore({ backend, env: { RUNPOD_API_KEY: `${KEY}-env` } });
44
+ expect(await withEnv.resolve("runpod")).toEqual({ key: `${KEY}-env`, source: "env" });
45
+ const keyringOnly = new CredentialStore({ backend, env: {} });
46
+ expect(await keyringOnly.resolve("runpod")).toEqual({ key: `${KEY}-keyring`, source: "keyring" });
47
+ });
48
+ it("never reads a .env file from the working directory or anywhere else", async () => {
49
+ const dir = emptyDir();
50
+ fs.writeFileSync(path.join(dir, ".env"), `RUNPOD_API_KEY=${KEY}-dotenv\n`);
51
+ const original = process.cwd();
52
+ process.chdir(dir);
53
+ try {
54
+ const store = new CredentialStore({ backend: memoryBackend(), env: {} });
55
+ expect(await store.resolve("runpod")).toBeNull();
56
+ expect(await store.status("runpod")).toEqual({ source: "none" });
57
+ }
58
+ finally {
59
+ process.chdir(original);
60
+ }
61
+ });
62
+ it("has no plaintext fallback: with nothing configured it reports none and require() is actionable", async () => {
63
+ const store = new CredentialStore({ backend: memoryBackend(), env: {} });
64
+ expect(await store.resolve("runpod")).toBeNull();
65
+ expect(await store.status("runpod")).toEqual({ source: "none" });
66
+ await expect(store.require("runpod")).rejects.toMatchObject({ code: "CREDENTIAL_MISSING" });
67
+ });
68
+ it("deletes only the stored key, never the environment override", async () => {
69
+ const backend = memoryBackend();
70
+ const store = new CredentialStore({ backend, env: { RUNPOD_API_KEY: KEY } });
71
+ await store.set("runpod", `${KEY}-stored`);
72
+ expect(await store.delete("runpod")).toBe(true);
73
+ expect(await store.delete("runpod")).toBe(false);
74
+ expect(await store.resolve("runpod")).toMatchObject({ source: "env", key: KEY });
75
+ });
76
+ it("separates accounts and rejects unsafe references and unknown providers", async () => {
77
+ const backend = memoryBackend();
78
+ const store = new CredentialStore({ backend, env: {} });
79
+ await store.set("runpod", `${KEY}-a`, "work");
80
+ await store.set("runpod", `${KEY}-b`, "home");
81
+ expect((await store.resolve("runpod", "work"))?.key).toBe(`${KEY}-a`);
82
+ expect((await store.resolve("runpod", "home"))?.key).toBe(`${KEY}-b`);
83
+ await expect(store.set("runpod", KEY, "../escape")).rejects.toMatchObject({ code: "CREDENTIAL_INVALID" });
84
+ await expect(store.resolve("nope")).rejects.toMatchObject({ code: "CREDENTIAL_UNSUPPORTED_PROVIDER" });
85
+ });
86
+ it("rejects malformed keys on write and when found in the environment", async () => {
87
+ const store = new CredentialStore({ backend: memoryBackend(), env: { RUNPOD_API_KEY: "has space in it" } });
88
+ await expect(store.set("runpod", "short")).rejects.toMatchObject({ code: "CREDENTIAL_INVALID" });
89
+ await expect(store.set("runpod", `${KEY}\n`)).rejects.toMatchObject({ code: "CREDENTIAL_INVALID" });
90
+ await expect(store.resolve("runpod")).rejects.toMatchObject({ code: "CREDENTIAL_INVALID" });
91
+ });
92
+ it("surfaces a backend failure as CREDENTIAL_STORE_UNAVAILABLE without falling back", async () => {
93
+ const failing = {
94
+ async get() {
95
+ throw Object.assign(new Error("locked"), { code: "CREDENTIAL_STORE_UNAVAILABLE" });
96
+ },
97
+ async set() { },
98
+ async delete() {
99
+ return false;
100
+ },
101
+ };
102
+ const store = new CredentialStore({ backend: failing, env: {} });
103
+ await expect(store.resolve("runpod")).rejects.toMatchObject({ code: "CREDENTIAL_STORE_UNAVAILABLE" });
104
+ });
105
+ });
106
+ describe("helpers", () => {
107
+ it("validates secret shape", () => {
108
+ expect(() => validateSecretShape(KEY)).not.toThrow();
109
+ expect(() => validateSecretShape("")).toThrow();
110
+ expect(() => validateSecretShape("a b c d e f g h")).toThrow();
111
+ });
112
+ it("fingerprints deterministically per salt without exposing the key", () => {
113
+ const one = credentialFingerprint(KEY, "salt-1");
114
+ expect(one).toBe(credentialFingerprint(KEY, "salt-1"));
115
+ expect(one).not.toBe(credentialFingerprint(KEY, "salt-2"));
116
+ expect(one).not.toContain(KEY);
117
+ expect(one).toMatch(/^[0-9a-f]{32}$/);
118
+ });
119
+ it("redacts secrets and their URL-encoded form, ignoring tiny strings", () => {
120
+ const text = `token ${KEY} and ${encodeURIComponent("a/b+c=d-secret")} ok`;
121
+ expect(redactSecrets(text, [KEY, "a/b+c=d-secret"])).toBe("token [REDACTED] and [REDACTED] ok");
122
+ expect(redactSecrets("abc abc", ["abc"])).toBe("abc abc");
123
+ });
124
+ });
@@ -0,0 +1,197 @@
1
+ import type { CloudResourceRoleName } from "./types.js";
2
+ /**
3
+ * Wire and journal contract between the core orchestrator and a remote execution backend. All
4
+ * types are plain JSON and versioned, so a restarted client can reconstruct a job from the journal
5
+ * and a worker of a different release can reject what it does not understand.
6
+ */
7
+ export declare const EXECUTION_CONTRACT_VERSION = 1;
8
+ export type CachePolicy = "ephemeral" | "persistent";
9
+ export interface JobInputFile {
10
+ /** `"job"` for the primary input, or the adapter-declared companion id. */
11
+ role: string;
12
+ /** Path relative to the job's `input/` directory; never absolute and never containing `..`. */
13
+ relativePath: string;
14
+ sha256: string;
15
+ sizeBytes: number;
16
+ /** Client-local source. Never sent to the worker or written to the journal. */
17
+ sourcePath: string;
18
+ }
19
+ export interface ApprovedJob {
20
+ version: typeof EXECUTION_CONTRACT_VERSION;
21
+ runId: string;
22
+ model: string;
23
+ modelVersion: string;
24
+ manifestDigest: string;
25
+ cachePolicy: CachePolicy;
26
+ gpuId: string;
27
+ datacenterId: string;
28
+ /** Digest-pinned worker images from the manifest's `cloud` block. */
29
+ images: {
30
+ gpu: string;
31
+ cpu: string;
32
+ };
33
+ containerDiskGb: number;
34
+ volumeGb: number;
35
+ assetLayoutRevision: string;
36
+ cpuAssetPreparation: boolean;
37
+ /** Validated, defaulted adapter params. */
38
+ params: Record<string, unknown>;
39
+ inputs: JobInputFile[];
40
+ /** After this instant every resource of the job must be gone. */
41
+ deadlineAt: string;
42
+ /** Digest of model, source revision, asset list and layout revision; names the on-volume cache. */
43
+ cacheKey: string;
44
+ /** Existing persistent volume to reuse, when one is known and compatible. */
45
+ reuseVolumeId?: string;
46
+ }
47
+ /** What a backend returns once the worker is executing; JSON-serializable. */
48
+ export interface JobHandle {
49
+ version: typeof EXECUTION_CONTRACT_VERSION;
50
+ runId: string;
51
+ podId: string;
52
+ volumeId?: string;
53
+ /** Verified HTTPS worker endpoint. The per-job token is held in the credential store, not here. */
54
+ endpoint: string;
55
+ }
56
+ export type JobStage = "provisioning" | "booting" | "uploading" | "preparing_assets" | "installing_runtime" | "verifying_runtime" | "running" | "collecting";
57
+ export type JobState = "pending" | "running" | "succeeded" | "failed" | "cancelled";
58
+ export interface RemoteRuntimeReport {
59
+ pythonVersion?: string;
60
+ /** `uv pip freeze` output lines. */
61
+ dependencyFreeze: string[];
62
+ sourceRevision?: string;
63
+ gpu?: {
64
+ name: string;
65
+ memoryMb?: number;
66
+ driverVersion?: string;
67
+ cudaVersion?: string;
68
+ };
69
+ /** The command the worker ran; `cwd` is a POSIX path on the worker. */
70
+ command?: {
71
+ executable: string;
72
+ args: string[];
73
+ cwd: string;
74
+ };
75
+ installationFingerprint?: string;
76
+ /** Persistent-cache details reported by `prepare-assets` jobs. */
77
+ cache?: {
78
+ checksumManifestSha256: string;
79
+ sizeBytes: number;
80
+ fileCount: number;
81
+ reused: boolean;
82
+ };
83
+ }
84
+ export interface JobSnapshot {
85
+ stage: JobStage;
86
+ state: JobState;
87
+ exitCode?: number;
88
+ error?: {
89
+ code: string;
90
+ message: string;
91
+ };
92
+ /**
93
+ * Resumable log window: pass `cursor` back as `after` to continue. The worker keeps only a bounded
94
+ * tail, so `lostEntries` counts chunks after `after` that were evicted before they could be fetched
95
+ * (exact: chunks are numbered contiguously). `evictedBytes` is the worker's cumulative evicted size.
96
+ */
97
+ logs: {
98
+ cursor: number;
99
+ stdout: string;
100
+ stderr: string;
101
+ lostEntries?: number;
102
+ evictedBytes?: number;
103
+ };
104
+ runtime?: RemoteRuntimeReport;
105
+ }
106
+ export interface InventoryFile {
107
+ /** POSIX path relative to the job's `output/` directory. */
108
+ path: string;
109
+ sizeBytes: number;
110
+ sha256: string;
111
+ }
112
+ export interface ArtifactInventory {
113
+ version: typeof EXECUTION_CONTRACT_VERSION;
114
+ files: InventoryFile[];
115
+ }
116
+ /**
117
+ * Journals paid-resource intent for a backend. The backend must call `beginCreate` before asking a
118
+ * provider to create anything, `created` immediately after the response, `beginDelete` before a
119
+ * delete and `deleted` only once the provider confirmed deletion or absence.
120
+ */
121
+ export interface ResourceLedger {
122
+ beginCreate(role: CloudResourceRoleName): {
123
+ intentId: string;
124
+ name: string;
125
+ };
126
+ created(intentId: string, providerId: string): void;
127
+ beginDelete(intentId: string): void;
128
+ deleted(intentId: string): void;
129
+ }
130
+ /** Journals persistent-cache state for a backend. Only used for the `persistent` policy. */
131
+ export interface CacheLedger {
132
+ /** A cache the journal believes is READY for this job's key, if any. */
133
+ lookup(): {
134
+ volumeId: string;
135
+ datacenterId: string;
136
+ } | undefined;
137
+ /** Record the volume and a PREPARING cache row. */
138
+ begin(volume: {
139
+ volumeId: string;
140
+ datacenterId: string;
141
+ }): void;
142
+ verifying(): void;
143
+ ready(details: {
144
+ sizeBytes: number;
145
+ checksumManifestDigest: string;
146
+ }): void;
147
+ failed(): void;
148
+ /** Same-client lease around preparation; false when another process holds it. */
149
+ acquireLease(ttlSeconds: number): boolean;
150
+ releaseLease(): void;
151
+ }
152
+ export interface BackendContext {
153
+ ledger: ResourceLedger;
154
+ cache?: CacheLedger;
155
+ signal?: AbortSignal;
156
+ onProgress?: (event: {
157
+ stage: JobStage;
158
+ message?: string;
159
+ }) => void;
160
+ }
161
+ export interface ExecutionBackend {
162
+ /** Provision, bootstrap the worker, upload inputs, prepare assets and runtime, and begin execution. */
163
+ start(job: ApprovedJob, context: BackendContext): Promise<JobHandle>;
164
+ /** Rebuild a handle for a Pod this client created earlier (e.g. after a restart) so results can still be fetched. */
165
+ reconnect?(runId: string, podId: string, volumeId?: string): JobHandle;
166
+ observe(handle: JobHandle, after?: number): Promise<JobSnapshot>;
167
+ cancel(handle: JobHandle): Promise<void>;
168
+ /** Download and verify every inventory file into `localOutputDir`; never reruns inference. */
169
+ collect(handle: JobHandle, localOutputDir: string, options?: {
170
+ signal?: AbortSignal;
171
+ }): Promise<ArtifactInventory>;
172
+ /**
173
+ * Terminate every paid resource this job owns (GPU/CPU pods; never the persistent volume) and
174
+ * confirm via the provider. Safe to call repeatedly; absence is success.
175
+ */
176
+ release(handle: JobHandle | {
177
+ runId: string;
178
+ }, context: Pick<BackendContext, "ledger">): Promise<void>;
179
+ }
180
+ export type WorkerMode = "execute" | "prepare-assets";
181
+ /** Job description sent to the worker: the approved job without client-local paths. */
182
+ export type WireJob = Omit<ApprovedJob, "inputs" | "reuseVolumeId"> & {
183
+ inputs: Array<Omit<JobInputFile, "sourcePath">>;
184
+ /** `prepare-assets` runs only asset preparation (CPU Pod); `execute` runs the model. */
185
+ mode: WorkerMode;
186
+ /** Where the persistent network volume is mounted in this Pod; absent for ephemeral runs. */
187
+ volumeMount?: string;
188
+ /** Lets a worker reclaim an expired on-volume cache lock; set only after the client confirmed the old owner is gone. */
189
+ reclaimStaleLock?: boolean;
190
+ };
191
+ export declare function toWireJob(job: ApprovedJob, extra?: {
192
+ mode?: WorkerMode;
193
+ volumeMount?: string;
194
+ reclaimStaleLock?: boolean;
195
+ }): WireJob;
196
+ /** Port the worker listens on inside the Pod; the provider proxy exposes it over HTTPS. */
197
+ export declare const WORKER_PORT = 8000;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Wire and journal contract between the core orchestrator and a remote execution backend. All
3
+ * types are plain JSON and versioned, so a restarted client can reconstruct a job from the journal
4
+ * and a worker of a different release can reject what it does not understand.
5
+ */
6
+ export const EXECUTION_CONTRACT_VERSION = 1;
7
+ export function toWireJob(job, extra = {}) {
8
+ const { inputs, reuseVolumeId: _reuse, ...rest } = job;
9
+ return {
10
+ ...rest,
11
+ inputs: inputs.map(({ sourcePath: _sourcePath, ...file }) => file),
12
+ mode: extra.mode ?? "execute",
13
+ ...(extra.volumeMount ? { volumeMount: extra.volumeMount } : {}),
14
+ ...(extra.reclaimStaleLock ? { reclaimStaleLock: true } : {}),
15
+ };
16
+ }
17
+ /** Port the worker listens on inside the Pod; the provider proxy exposes it over HTTPS. */
18
+ export const WORKER_PORT = 8000;
@@ -0,0 +1,73 @@
1
+ import { type SecretBackend } from "./credentials.js";
2
+ import { type ApprovedJob, type ArtifactInventory, type BackendContext, type ExecutionBackend, type JobHandle, type JobSnapshot } from "./execution.js";
3
+ import type { CloudProvider } from "./types.js";
4
+ import { WorkerClient } from "./worker-client.js";
5
+ export declare const MIN_WORKER_READY_TIMEOUT_MS: number;
6
+ export declare const VOLUME_MOUNT = "/workspace";
7
+ export interface WorkerBundle {
8
+ /** HTTPS URL of the release-pinned worker bundle (a .tar.gz containing `worker/main.js`). */
9
+ url: string;
10
+ sha256: string;
11
+ }
12
+ /** Per-job reconnect tokens belong in the OS credential store, never in the journal or run record. */
13
+ export interface TokenStore {
14
+ set(runId: string, token: string): Promise<void>;
15
+ get(runId: string): Promise<string | null>;
16
+ delete(runId: string): Promise<void>;
17
+ }
18
+ export declare function createKeyringTokenStore(backend?: SecretBackend): TokenStore;
19
+ /**
20
+ * Container start command: fetch the pinned bundle, verify its checksum, and run it. The image must
21
+ * provide Node 20+, curl, tar and sha256sum. Nothing here comes from the user's input.
22
+ */
23
+ export declare const WORKER_BOOTSTRAP_SCRIPT: string;
24
+ export interface RunpodBackendOptions {
25
+ provider: CloudProvider;
26
+ tokenStore: TokenStore;
27
+ workerBundle?: WorkerBundle;
28
+ /** Builds the worker's HTTPS endpoint for a Pod. Defaults to Runpod's proxy. */
29
+ endpointFor?: (podId: string) => string;
30
+ createClient?: (endpoint: string, token: string) => WorkerClient;
31
+ sleep?: (ms: number) => Promise<void>;
32
+ /** Clamped to at least 5 minutes: cold image pulls were observed taking ~100 s or more. */
33
+ readyTimeoutMs?: number;
34
+ pollIntervalMs?: number;
35
+ /** Persistent: how long to wait for a terminated CPU Pod to disappear before the GPU Pod starts. */
36
+ terminationWaitMs?: number;
37
+ /** Test hook. */
38
+ now?: () => number;
39
+ }
40
+ export declare class RunpodExecutionBackend implements ExecutionBackend {
41
+ private readonly options;
42
+ private readonly clients;
43
+ private readonly tracked;
44
+ /** Intents already confirmed deleted, so release() stays idempotent. */
45
+ private readonly deletedIntents;
46
+ private readonly sleep;
47
+ private readonly readyTimeoutMs;
48
+ constructor(options: RunpodBackendOptions);
49
+ private endpoint;
50
+ private client;
51
+ private clientFor;
52
+ start(job: ApprovedJob, context: BackendContext): Promise<JobHandle>;
53
+ private startPersistent;
54
+ /** Cold path: CPU Pod prepares the volume, is terminated and confirmed gone, then capacity is rechecked. */
55
+ private prepareOnCpu;
56
+ private createWorkerPod;
57
+ /** Returns a reason string when the Pod can never serve (so booting should stop waiting). */
58
+ private podFatal;
59
+ private waitForTerminal;
60
+ /** Starts a GPU worker. With `watchCacheFailure`, a cache-missing/invalid failure returns no handle so the caller can rebuild. */
61
+ private startGpu;
62
+ /** Terminate through the provider, then require the provider to show the Pod gone before marking it deleted. */
63
+ private terminateAndConfirm;
64
+ reconnect(runId: string, podId: string, volumeId?: string): JobHandle;
65
+ observe(handle: JobHandle, after?: number): Promise<JobSnapshot>;
66
+ cancel(handle: JobHandle): Promise<void>;
67
+ collect(handle: JobHandle, localOutputDir: string, options?: {
68
+ signal?: AbortSignal;
69
+ }): Promise<ArtifactInventory>;
70
+ release(handle: JobHandle | {
71
+ runId: string;
72
+ }, context: Pick<BackendContext, "ledger">): Promise<void>;
73
+ }