@moldesk/core 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.
- package/dist/cloud/accounts.d.ts +69 -0
- package/dist/cloud/accounts.js +113 -0
- package/dist/cloud/accounts.test.d.ts +1 -0
- package/dist/cloud/accounts.test.js +188 -0
- package/dist/cloud/backend.test.d.ts +1 -0
- package/dist/cloud/backend.test.js +339 -0
- package/dist/cloud/deps.d.ts +15 -0
- package/dist/cloud/deps.js +30 -0
- package/dist/cloud/manage.d.ts +57 -0
- package/dist/cloud/manage.js +210 -0
- package/dist/cloud/manage.test.d.ts +1 -0
- package/dist/cloud/manage.test.js +105 -0
- package/dist/cloud/quote.d.ts +131 -0
- package/dist/cloud/quote.js +225 -0
- package/dist/cloud/quote.test.d.ts +1 -0
- package/dist/cloud/quote.test.js +264 -0
- package/dist/cloud/run.d.ts +114 -0
- package/dist/cloud/run.js +498 -0
- package/dist/cloud/run.test.d.ts +1 -0
- package/dist/cloud/run.test.js +597 -0
- package/dist/cloud/state.d.ts +159 -0
- package/dist/cloud/state.js +395 -0
- package/dist/cloud/state.test.d.ts +1 -0
- package/dist/cloud/state.test.js +199 -0
- package/dist/cloud/worker/cache.d.ts +75 -0
- package/dist/cloud/worker/cache.js +152 -0
- package/dist/cloud/worker/cache.test.d.ts +1 -0
- package/dist/cloud/worker/cache.test.js +123 -0
- package/dist/cloud/worker/main.d.ts +2 -0
- package/dist/cloud/worker/main.js +34 -0
- package/dist/cloud/worker/pipeline.d.ts +26 -0
- package/dist/cloud/worker/pipeline.js +160 -0
- package/dist/cloud/worker/pipeline.test.d.ts +1 -0
- package/dist/cloud/worker/pipeline.test.js +298 -0
- package/dist/cloud/worker/server.d.ts +38 -0
- package/dist/cloud/worker/server.js +287 -0
- package/dist/cloud/worker/server.test.d.ts +1 -0
- package/dist/cloud/worker/server.test.js +391 -0
- package/dist/cloud/worker/watchdog.d.ts +27 -0
- package/dist/cloud/worker/watchdog.js +46 -0
- package/dist/cloud/worker-release.d.ts +12 -0
- package/dist/cloud/worker-release.js +25 -0
- package/dist/cloud/worker-release.test.d.ts +1 -0
- package/dist/cloud/worker-release.test.js +21 -0
- package/dist/index.d.ts +17 -3
- package/dist/index.js +9 -2
- package/dist/run-lifecycle.d.ts +74 -0
- package/dist/run-lifecycle.js +178 -0
- package/dist/run.d.ts +61 -1
- package/dist/run.js +54 -125
- package/dist/run.test.js +47 -1
- package/package.json +10 -6
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { getAdapter } from "@moldesk/adapters";
|
|
4
|
+
import { effectiveRuntimeSource, listAvailableModels, selectPythonRuntime } from "@moldesk/registry";
|
|
5
|
+
import { PythonRuntimeProvider, cacheAsset, getMoldeskPaths, getSystemReport, materializeAsset, preparePythonEnvironment, } from "@moldesk/runtime";
|
|
6
|
+
import { MoldeskError } from "../../errors.js";
|
|
7
|
+
import { digest } from "../../hash.js";
|
|
8
|
+
import { acquireVolumeLock, cacheRoot, publishCache, verifyCache } from "./cache.js";
|
|
9
|
+
const REMOTE_PLATFORM = "linux-x64";
|
|
10
|
+
function defaultResolve(name) {
|
|
11
|
+
const manifest = listAvailableModels().find((entry) => entry.name === name);
|
|
12
|
+
const adapter = getAdapter(name);
|
|
13
|
+
return manifest && adapter ? { manifest, adapter } : undefined;
|
|
14
|
+
}
|
|
15
|
+
function fail(code, message, remediation) {
|
|
16
|
+
return new MoldeskError({ code, message, remediation });
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* The real worker pipeline: prepare assets, install the Linux runtime, verify (CUDA included), build
|
|
20
|
+
* the adapter's own command with POSIX paths, and execute. It reuses the same installer, adapter and
|
|
21
|
+
* execution code as local runs and never accepts a command or manifest from the client; the client's
|
|
22
|
+
* job only selects a bundled model and must match that manifest's digest exactly.
|
|
23
|
+
*/
|
|
24
|
+
export function createManifestPipeline(options = {}) {
|
|
25
|
+
const steps = {
|
|
26
|
+
cacheAsset,
|
|
27
|
+
materializeAsset,
|
|
28
|
+
preparePythonEnvironment,
|
|
29
|
+
getSystemReport,
|
|
30
|
+
runtime: new PythonRuntimeProvider(),
|
|
31
|
+
...options.steps,
|
|
32
|
+
};
|
|
33
|
+
const resolve = options.resolveModel ?? defaultResolve;
|
|
34
|
+
return async (context) => {
|
|
35
|
+
const { job, jobDir, inputDir, outputDir, signal } = context;
|
|
36
|
+
const resolved = resolve(job.model);
|
|
37
|
+
if (!resolved)
|
|
38
|
+
throw fail("WORKER_MODEL_UNKNOWN", `This worker does not bundle ${job.model}.`, "Use a worker release that matches the client.");
|
|
39
|
+
const { manifest, adapter } = resolved;
|
|
40
|
+
if (digest(manifest) !== job.manifestDigest || manifest.modelVersion !== job.modelVersion) {
|
|
41
|
+
throw fail("WORKER_MANIFEST_MISMATCH", "The worker's bundled manifest differs from the one the job was approved against.", "Use a worker release that matches the client version.");
|
|
42
|
+
}
|
|
43
|
+
if (manifest.cloud === undefined)
|
|
44
|
+
throw fail("WORKER_MODEL_NOT_CLOUD", `${job.model} is not cloud-eligible.`, "Use a model with a cloud block.");
|
|
45
|
+
const persistentRoot = job.volumeMount ? cacheRoot(job.volumeMount) : undefined;
|
|
46
|
+
// Raw download objects live on the volume for persistent runs so a rebuild never re-downloads.
|
|
47
|
+
const paths = getMoldeskPaths({ MOLDESK_HOME: persistentRoot ? path.join(persistentRoot, "home") : path.join(jobDir, "home") });
|
|
48
|
+
const prepareAssetsInto = async (assetsTarget) => {
|
|
49
|
+
fs.mkdirSync(assetsTarget, { recursive: true });
|
|
50
|
+
for (const asset of manifest.assets ?? []) {
|
|
51
|
+
if (signal.aborted)
|
|
52
|
+
throw fail("RUN_CANCELLED", "Cancelled.", "None.");
|
|
53
|
+
const object = await steps.cacheAsset(asset, paths, {
|
|
54
|
+
onProgress: (progress) => context.log("stderr", `[assets] ${progress.message ?? progress.step}\n`),
|
|
55
|
+
});
|
|
56
|
+
await steps.materializeAsset(asset, object, assetsTarget);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
// --- CPU stage of a persistent run: assets only. No Python, no CUDA, no verification probe. ---
|
|
60
|
+
if (job.mode === "prepare-assets") {
|
|
61
|
+
if (!persistentRoot)
|
|
62
|
+
throw fail("WORKER_BAD_JOB", "prepare-assets needs a persistent volume.", "Retry with a persistent cache policy.");
|
|
63
|
+
context.setStage("preparing_assets");
|
|
64
|
+
const params = { root: persistentRoot, key: job.cacheKey, manifestDigest: job.manifestDigest, layoutRevision: job.assetLayoutRevision };
|
|
65
|
+
const existing = await verifyCache(params);
|
|
66
|
+
if (existing.ok) {
|
|
67
|
+
return { exitCode: 0, runtime: { dependencyFreeze: [], cache: { checksumManifestSha256: existing.metadata.checksumManifestSha256, sizeBytes: existing.metadata.sizeBytes, fileCount: existing.metadata.files.length, reused: true } } };
|
|
68
|
+
}
|
|
69
|
+
const lock = acquireVolumeLock({ root: persistentRoot, key: job.cacheKey, owner: job.runId, ttlSeconds: 3600, reclaimStale: job.reclaimStaleLock });
|
|
70
|
+
try {
|
|
71
|
+
const staging = path.join(persistentRoot, `${job.cacheKey}.staging-${job.runId}`);
|
|
72
|
+
fs.rmSync(staging, { recursive: true, force: true });
|
|
73
|
+
await prepareAssetsInto(path.join(staging, "assets"));
|
|
74
|
+
const metadata = await publishCache({ ...params, stagingDir: staging });
|
|
75
|
+
return { exitCode: 0, runtime: { dependencyFreeze: [], cache: { checksumManifestSha256: metadata.checksumManifestSha256, sizeBytes: metadata.sizeBytes, fileCount: metadata.files.length, reused: false } } };
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
lock.release();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
// --- Assets for an execute job: ephemeral prepares them here; persistent verifies the volume's cache. ---
|
|
82
|
+
let assetsDir = path.join(jobDir, "assets");
|
|
83
|
+
const modelDir = path.join(jobDir, "runtime");
|
|
84
|
+
context.setStage("preparing_assets");
|
|
85
|
+
if (persistentRoot) {
|
|
86
|
+
const verified = await verifyCache({ root: persistentRoot, key: job.cacheKey, manifestDigest: job.manifestDigest, layoutRevision: job.assetLayoutRevision });
|
|
87
|
+
if (!verified.ok)
|
|
88
|
+
throw Object.assign(fail(verified.code, verified.message, "The cache will be rebuilt on a CPU machine."), {});
|
|
89
|
+
assetsDir = verified.assetsDir;
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
await prepareAssetsInto(assetsDir);
|
|
93
|
+
}
|
|
94
|
+
// --- Linux runtime in an ephemeral environment. ---
|
|
95
|
+
context.setStage("installing_runtime");
|
|
96
|
+
const runtime = selectPythonRuntime(manifest.runtimes, REMOTE_PLATFORM);
|
|
97
|
+
const source = effectiveRuntimeSource(manifest, runtime);
|
|
98
|
+
if (!source)
|
|
99
|
+
throw fail("SOURCE_PROVENANCE_MISSING", `${job.model} has no pinned source.`, "Fix the manifest.");
|
|
100
|
+
const prepared = await steps.preparePythonEnvironment({
|
|
101
|
+
targetDir: modelDir,
|
|
102
|
+
repository: source.repository,
|
|
103
|
+
revision: source.revision,
|
|
104
|
+
runtime,
|
|
105
|
+
paths,
|
|
106
|
+
onProgress: (progress) => context.log("stderr", `[install] ${progress.message ?? progress.step}\n`),
|
|
107
|
+
});
|
|
108
|
+
// --- GPU/CUDA verification happens here, after install and before any inference. ---
|
|
109
|
+
context.setStage("verifying_runtime");
|
|
110
|
+
const verification = await adapter.verifyInstallation({ manifestName: manifest.name, modelDir, assetsDir, platform: REMOTE_PLATFORM });
|
|
111
|
+
if (!verification.passed) {
|
|
112
|
+
throw fail("INSTALL_VERIFICATION_FAILED", `Verification failed for ${manifest.name}: ${verification.output ?? "unknown reason"}.`, "The worker image or GPU is not compatible with this model.");
|
|
113
|
+
}
|
|
114
|
+
// --- Build the command with the adapter, from worker-local POSIX paths. ---
|
|
115
|
+
const primary = job.inputs.find((input) => input.role === "job");
|
|
116
|
+
if (!primary)
|
|
117
|
+
throw fail("WORKER_BAD_JOB", "The job has no primary input.", "Retry with a valid job.");
|
|
118
|
+
const runContext = {
|
|
119
|
+
manifestName: manifest.name,
|
|
120
|
+
inputPath: path.join(inputDir, ...primary.relativePath.split("/")),
|
|
121
|
+
outputDir,
|
|
122
|
+
modelDir,
|
|
123
|
+
assetsDir,
|
|
124
|
+
params: job.params,
|
|
125
|
+
runtimeExecutable: prepared.executable,
|
|
126
|
+
platform: REMOTE_PLATFORM,
|
|
127
|
+
};
|
|
128
|
+
const command = await adapter.command(runContext);
|
|
129
|
+
context.setStage("running");
|
|
130
|
+
const execution = await steps.runtime.execute({
|
|
131
|
+
executable: command.executable,
|
|
132
|
+
args: command.args,
|
|
133
|
+
cwd: jobDir,
|
|
134
|
+
env: command.env,
|
|
135
|
+
signal,
|
|
136
|
+
onStdout: (chunk) => context.log("stdout", chunk),
|
|
137
|
+
onStderr: (chunk) => context.log("stderr", chunk),
|
|
138
|
+
});
|
|
139
|
+
const system = await steps.getSystemReport().catch(() => undefined);
|
|
140
|
+
const gpu = system?.nvidia.gpus[0];
|
|
141
|
+
const report = {
|
|
142
|
+
pythonVersion: prepared.pythonVersion,
|
|
143
|
+
dependencyFreeze: prepared.lock,
|
|
144
|
+
sourceRevision: source.revision,
|
|
145
|
+
...(gpu
|
|
146
|
+
? {
|
|
147
|
+
gpu: {
|
|
148
|
+
name: gpu.name,
|
|
149
|
+
...(gpu.totalVramBytes !== undefined ? { memoryMb: Math.round(gpu.totalVramBytes / 1024 ** 2) } : {}),
|
|
150
|
+
...(system?.nvidia.driverVersion ? { driverVersion: system.nvidia.driverVersion } : {}),
|
|
151
|
+
...(system?.nvidia.driverCudaVersion ? { cudaVersion: system.nvidia.driverCudaVersion } : {}),
|
|
152
|
+
},
|
|
153
|
+
}
|
|
154
|
+
: {}),
|
|
155
|
+
command: { executable: command.executable, args: command.args, cwd: jobDir },
|
|
156
|
+
installationFingerprint: `python-${digest({ lock: prepared.lock, python: prepared.pythonVersion, revision: source.revision, platform: REMOTE_PLATFORM }).slice(0, 32)}`,
|
|
157
|
+
};
|
|
158
|
+
return { exitCode: execution.cancelled ? 130 : execution.exitCode, runtime: report };
|
|
159
|
+
};
|
|
160
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,298 @@
|
|
|
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 { parseManifest } from "@moldesk/registry";
|
|
6
|
+
import { digest } from "../../hash.js";
|
|
7
|
+
import { createManifestPipeline } from "./pipeline.js";
|
|
8
|
+
import { startDeadlineWatchdog } from "./watchdog.js";
|
|
9
|
+
const DIGEST = "a".repeat(64);
|
|
10
|
+
const dirs = [];
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
for (const dir of dirs.splice(0))
|
|
13
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
14
|
+
});
|
|
15
|
+
function manifest() {
|
|
16
|
+
return parseManifest({
|
|
17
|
+
schemaVersion: 1,
|
|
18
|
+
name: "fake-model",
|
|
19
|
+
displayName: "Fake",
|
|
20
|
+
modelVersion: "1.0.0",
|
|
21
|
+
adapterVersion: "0.1.0",
|
|
22
|
+
description: "t",
|
|
23
|
+
category: "other",
|
|
24
|
+
status: "planned",
|
|
25
|
+
source: { repository: "https://example.com/r", revision: "b".repeat(40) },
|
|
26
|
+
runtimes: [{ kind: "python", python: "3.11", installer: "uv", platforms: ["linux-x64"], requirements: [{ name: "torch" }] }],
|
|
27
|
+
hardware: { platforms: ["linux-x64"] },
|
|
28
|
+
assets: [{ id: "w", url: "https://example.com/w.bin", sha256: "c".repeat(64), target: "w.bin", sizeBytes: 3 }],
|
|
29
|
+
input: { formats: [".txt"], required: true },
|
|
30
|
+
outputs: [{ id: "o", glob: "*.out", required: true }],
|
|
31
|
+
cloud: {
|
|
32
|
+
schemaVersion: 1,
|
|
33
|
+
platform: "linux-x64",
|
|
34
|
+
images: { gpu: `r.example/g@sha256:${DIGEST}`, cpu: `r.example/c@sha256:${DIGEST}` },
|
|
35
|
+
minVramGb: 24,
|
|
36
|
+
minRamGb: 32,
|
|
37
|
+
ephemeralDiskGb: 40,
|
|
38
|
+
volumeGb: 40,
|
|
39
|
+
assetLayoutRevision: "1",
|
|
40
|
+
cpuAssetPreparation: true,
|
|
41
|
+
},
|
|
42
|
+
}, "x/manifest.yaml");
|
|
43
|
+
}
|
|
44
|
+
function setup(overrides = {}) {
|
|
45
|
+
const jobDir = fs.mkdtempSync(path.join(os.tmpdir(), "moldesk pipeline "));
|
|
46
|
+
dirs.push(jobDir);
|
|
47
|
+
const m = manifest();
|
|
48
|
+
const calls = [];
|
|
49
|
+
let seenContext;
|
|
50
|
+
const adapter = {
|
|
51
|
+
modelName: "fake-model",
|
|
52
|
+
params: [],
|
|
53
|
+
async validateInput() { },
|
|
54
|
+
async installPlan() {
|
|
55
|
+
return { steps: [] };
|
|
56
|
+
},
|
|
57
|
+
async command(context) {
|
|
58
|
+
calls.push("command");
|
|
59
|
+
seenContext = context;
|
|
60
|
+
return { executable: "/job/x/runtime/.venv/bin/fake", args: ["--in", context.inputPath, "--out", context.outputDir], env: { A: "1" } };
|
|
61
|
+
},
|
|
62
|
+
async collectOutputs() {
|
|
63
|
+
return [];
|
|
64
|
+
},
|
|
65
|
+
async verifyInstallation() {
|
|
66
|
+
calls.push("verify");
|
|
67
|
+
return { passed: overrides.verify ?? true, output: "cuda probe failed" };
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
const steps = {
|
|
71
|
+
async cacheAsset() {
|
|
72
|
+
calls.push("cacheAsset");
|
|
73
|
+
return "/obj/w";
|
|
74
|
+
},
|
|
75
|
+
async materializeAsset() {
|
|
76
|
+
calls.push("materializeAsset");
|
|
77
|
+
return "/assets/w.bin";
|
|
78
|
+
},
|
|
79
|
+
async preparePythonEnvironment(request) {
|
|
80
|
+
calls.push("prepareEnv");
|
|
81
|
+
expect(request.runtime.platforms).toEqual(["linux-x64"]);
|
|
82
|
+
return { executable: path.join(request.targetDir, ".venv", "bin", "python"), lock: ["torch==2.2.1"], pythonVersion: "3.11.9", postInstall: [] };
|
|
83
|
+
},
|
|
84
|
+
async getSystemReport() {
|
|
85
|
+
return { nvidia: { available: true, gpus: [{ name: "A40", totalVramBytes: 48 * 1024 ** 3 }], driverVersion: "550", driverCudaVersion: "12.4" } };
|
|
86
|
+
},
|
|
87
|
+
runtime: {
|
|
88
|
+
kind: "python",
|
|
89
|
+
async inspect() {
|
|
90
|
+
return { kind: "python", available: true };
|
|
91
|
+
},
|
|
92
|
+
async prepare() {
|
|
93
|
+
throw new Error("unused");
|
|
94
|
+
},
|
|
95
|
+
async execute(request) {
|
|
96
|
+
calls.push("execute");
|
|
97
|
+
request.onStdout?.("model output\n");
|
|
98
|
+
return { exitCode: overrides.exitCode ?? 0, stdoutPath: "", stderrPath: "" };
|
|
99
|
+
},
|
|
100
|
+
async remove() { },
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
const stages = [];
|
|
104
|
+
const logs = [];
|
|
105
|
+
const job = (patch = {}) => ({
|
|
106
|
+
version: 1,
|
|
107
|
+
runId: "run-1",
|
|
108
|
+
model: "fake-model",
|
|
109
|
+
modelVersion: "1.0.0",
|
|
110
|
+
manifestDigest: digest(m),
|
|
111
|
+
cachePolicy: "ephemeral",
|
|
112
|
+
gpuId: "g",
|
|
113
|
+
datacenterId: "dc",
|
|
114
|
+
images: m.cloud.images,
|
|
115
|
+
containerDiskGb: 40,
|
|
116
|
+
volumeGb: 40,
|
|
117
|
+
assetLayoutRevision: "1",
|
|
118
|
+
cpuAssetPreparation: true,
|
|
119
|
+
params: { seed: 1 },
|
|
120
|
+
inputs: [{ role: "job", relativePath: "in.txt", sha256: "d".repeat(64), sizeBytes: 3 }],
|
|
121
|
+
deadlineAt: "2099-01-01T00:00:00.000Z",
|
|
122
|
+
cacheKey: "k".repeat(64),
|
|
123
|
+
mode: "execute",
|
|
124
|
+
...patch,
|
|
125
|
+
});
|
|
126
|
+
const run = (patch = {}, signal = new AbortController().signal) => createManifestPipeline({ resolveModel: (name) => (name === "fake-model" ? { manifest: m, adapter } : undefined), steps })({
|
|
127
|
+
job: job(patch),
|
|
128
|
+
jobDir,
|
|
129
|
+
inputDir: path.join(jobDir, "input"),
|
|
130
|
+
outputDir: path.join(jobDir, "output"),
|
|
131
|
+
signal,
|
|
132
|
+
setStage: (stage) => void stages.push(stage),
|
|
133
|
+
log: (_stream, text) => void logs.push(text),
|
|
134
|
+
});
|
|
135
|
+
return { run, calls, stages, logs, jobDir, context: () => seenContext };
|
|
136
|
+
}
|
|
137
|
+
describe("manifest pipeline", () => {
|
|
138
|
+
it("runs assets, runtime, verification and execution in order and reports what ran", async () => {
|
|
139
|
+
const box = setup();
|
|
140
|
+
const result = await box.run();
|
|
141
|
+
expect(box.calls).toEqual(["cacheAsset", "materializeAsset", "prepareEnv", "verify", "command", "execute"]);
|
|
142
|
+
expect(box.stages).toEqual(["preparing_assets", "installing_runtime", "verifying_runtime", "running"]);
|
|
143
|
+
expect(result.exitCode).toBe(0);
|
|
144
|
+
expect(result.runtime).toMatchObject({
|
|
145
|
+
pythonVersion: "3.11.9",
|
|
146
|
+
dependencyFreeze: ["torch==2.2.1"],
|
|
147
|
+
sourceRevision: "b".repeat(40),
|
|
148
|
+
gpu: { name: "A40", memoryMb: 49152, driverVersion: "550", cudaVersion: "12.4" },
|
|
149
|
+
command: { executable: "/job/x/runtime/.venv/bin/fake", cwd: box.jobDir },
|
|
150
|
+
});
|
|
151
|
+
expect(result.runtime.installationFingerprint).toMatch(/^python-[0-9a-f]{32}$/);
|
|
152
|
+
expect(box.logs.join("")).toContain("model output");
|
|
153
|
+
});
|
|
154
|
+
it("builds the adapter command from worker-local paths and the approved params only", async () => {
|
|
155
|
+
const box = setup();
|
|
156
|
+
await box.run();
|
|
157
|
+
const context = box.context();
|
|
158
|
+
expect(context.platform).toBe("linux-x64");
|
|
159
|
+
expect(context.params).toEqual({ seed: 1 });
|
|
160
|
+
expect(context.inputPath).toBe(path.join(box.jobDir, "input", "in.txt"));
|
|
161
|
+
expect(context.outputDir).toBe(path.join(box.jobDir, "output"));
|
|
162
|
+
expect(context.runtimeExecutable).toBe(path.join(box.jobDir, "runtime", ".venv", "bin", "python"));
|
|
163
|
+
});
|
|
164
|
+
it("refuses a job approved against a different manifest, an unknown model or a non-cloud model", async () => {
|
|
165
|
+
await expect(setup().run({ manifestDigest: "0".repeat(64) })).rejects.toMatchObject({ code: "WORKER_MANIFEST_MISMATCH" });
|
|
166
|
+
await expect(setup().run({ model: "other" })).rejects.toMatchObject({ code: "WORKER_MODEL_UNKNOWN" });
|
|
167
|
+
});
|
|
168
|
+
it("stops at failed verification before any inference", async () => {
|
|
169
|
+
const box = setup({ verify: false });
|
|
170
|
+
await expect(box.run()).rejects.toMatchObject({ code: "INSTALL_VERIFICATION_FAILED" });
|
|
171
|
+
expect(box.calls).not.toContain("execute");
|
|
172
|
+
expect(box.calls).not.toContain("command");
|
|
173
|
+
});
|
|
174
|
+
it("returns the model's nonzero exit code unchanged", async () => {
|
|
175
|
+
expect((await setup({ exitCode: 3 }).run()).exitCode).toBe(3);
|
|
176
|
+
});
|
|
177
|
+
it("does not start when already cancelled", async () => {
|
|
178
|
+
const controller = new AbortController();
|
|
179
|
+
controller.abort();
|
|
180
|
+
const box = setup();
|
|
181
|
+
await expect(box.run({}, controller.signal)).rejects.toMatchObject({ code: "RUN_CANCELLED" });
|
|
182
|
+
expect(box.calls).toEqual([]);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
describe("persistent cache modes", () => {
|
|
186
|
+
const key = "k".repeat(64);
|
|
187
|
+
function volume() {
|
|
188
|
+
const mount = fs.mkdtempSync(path.join(os.tmpdir(), "moldesk volume "));
|
|
189
|
+
dirs.push(mount);
|
|
190
|
+
return mount;
|
|
191
|
+
}
|
|
192
|
+
const persistent = (mount, mode) => ({ cachePolicy: "persistent", mode, volumeMount: mount, cacheKey: key });
|
|
193
|
+
it("prepare-assets prepares and publishes the cache without installing Python or probing CUDA", async () => {
|
|
194
|
+
const mount = volume();
|
|
195
|
+
const box = setup();
|
|
196
|
+
const result = await box.run(persistent(mount, "prepare-assets"));
|
|
197
|
+
expect(box.calls).toEqual(["cacheAsset", "materializeAsset"]);
|
|
198
|
+
expect(box.stages).toEqual(["preparing_assets"]);
|
|
199
|
+
expect(result.runtime?.cache).toMatchObject({ reused: false, fileCount: 0 });
|
|
200
|
+
expect(fs.existsSync(path.join(mount, "moldesk-cache", key, "ready.json"))).toBe(true);
|
|
201
|
+
expect(fs.readdirSync(path.join(mount, "moldesk-cache")).filter((n) => n.includes("staging") || n.endsWith(".lock"))).toEqual([]);
|
|
202
|
+
});
|
|
203
|
+
it("a second prepare-assets on a valid cache downloads nothing", async () => {
|
|
204
|
+
const mount = volume();
|
|
205
|
+
const first = setup();
|
|
206
|
+
await first.run(persistent(mount, "prepare-assets"));
|
|
207
|
+
const second = setup();
|
|
208
|
+
const result = await second.run(persistent(mount, "prepare-assets"));
|
|
209
|
+
expect(second.calls).toEqual([]);
|
|
210
|
+
expect(result.runtime?.cache?.reused).toBe(true);
|
|
211
|
+
});
|
|
212
|
+
it("an execute job on a warm volume skips asset preparation and uses the verified assets directory", async () => {
|
|
213
|
+
const mount = volume();
|
|
214
|
+
// Real materialize so the cache has content to hash.
|
|
215
|
+
const prepare = setup();
|
|
216
|
+
await prepare.run(persistent(mount, "prepare-assets"));
|
|
217
|
+
const box = setup();
|
|
218
|
+
await box.run(persistent(mount, "execute"));
|
|
219
|
+
expect(box.calls).toEqual(["prepareEnv", "verify", "command", "execute"]);
|
|
220
|
+
expect(box.context().assetsDir).toBe(path.join(mount, "moldesk-cache", key, "assets"));
|
|
221
|
+
});
|
|
222
|
+
it("an execute job fails fast, before installing anything, when the volume has no valid cache", async () => {
|
|
223
|
+
const mount = volume();
|
|
224
|
+
const missing = setup();
|
|
225
|
+
await expect(missing.run(persistent(mount, "execute"))).rejects.toMatchObject({ code: "CLOUD_CACHE_MISSING" });
|
|
226
|
+
expect(missing.calls).toEqual([]);
|
|
227
|
+
const prepared = setup();
|
|
228
|
+
await prepared.run(persistent(mount, "prepare-assets"));
|
|
229
|
+
fs.writeFileSync(path.join(mount, "moldesk-cache", key, "assets", "injected.bin"), "x");
|
|
230
|
+
const damaged = setup();
|
|
231
|
+
await expect(damaged.run(persistent(mount, "execute"))).rejects.toMatchObject({ code: "CLOUD_CACHE_INVALID" });
|
|
232
|
+
expect(damaged.calls).toEqual([]);
|
|
233
|
+
});
|
|
234
|
+
it("prepare-assets without a volume is a bad job", async () => {
|
|
235
|
+
await expect(setup().run({ mode: "prepare-assets" })).rejects.toMatchObject({ code: "WORKER_BAD_JOB" });
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
describe("deadline watchdog", () => {
|
|
239
|
+
function fakeEnv(responses) {
|
|
240
|
+
const calls = [];
|
|
241
|
+
const timers = [];
|
|
242
|
+
const logs = [];
|
|
243
|
+
const fetchFn = (async (url, init) => {
|
|
244
|
+
calls.push({ url, auth: new Headers(init.headers).get("authorization"), body: JSON.parse(String(init.body)) });
|
|
245
|
+
const next = responses.shift() ?? 204;
|
|
246
|
+
if (next instanceof Error)
|
|
247
|
+
throw next;
|
|
248
|
+
return new Response(null, { status: next });
|
|
249
|
+
});
|
|
250
|
+
return { calls, timers, logs, fetchFn };
|
|
251
|
+
}
|
|
252
|
+
it("terminates its own pod at the deadline with the pod-scoped key", async () => {
|
|
253
|
+
const env = fakeEnv([204]);
|
|
254
|
+
const dog = startDeadlineWatchdog({
|
|
255
|
+
deadlineAt: "2026-10-13T13:00:00.000Z",
|
|
256
|
+
podId: "pod9",
|
|
257
|
+
apiKey: "pod-scoped-key",
|
|
258
|
+
fetch: env.fetchFn,
|
|
259
|
+
now: () => Date.parse("2026-10-13T12:00:00.000Z"),
|
|
260
|
+
setTimer: (run, ms) => void env.timers.push({ ms, run }),
|
|
261
|
+
log: (m) => env.logs.push(m),
|
|
262
|
+
});
|
|
263
|
+
expect(env.timers[0].ms).toBe(3_600_000);
|
|
264
|
+
env.timers[0].run();
|
|
265
|
+
await dog.done;
|
|
266
|
+
expect(env.calls).toEqual([{ url: "https://api.runpod.io/v2/pods/pod9/action", auth: "Bearer pod-scoped-key", body: { action: "terminate" } }]);
|
|
267
|
+
expect(env.logs.join(" ")).not.toContain("pod-scoped-key");
|
|
268
|
+
});
|
|
269
|
+
it("fires immediately when the deadline already passed and retries until accepted", async () => {
|
|
270
|
+
const env = fakeEnv([503, new Error("network"), 404]);
|
|
271
|
+
const dog = startDeadlineWatchdog({
|
|
272
|
+
deadlineAt: "2026-10-13T11:00:00.000Z",
|
|
273
|
+
podId: "pod9",
|
|
274
|
+
apiKey: "k",
|
|
275
|
+
fetch: env.fetchFn,
|
|
276
|
+
now: () => Date.parse("2026-10-13T12:00:00.000Z"),
|
|
277
|
+
retryMs: 30_000,
|
|
278
|
+
setTimer: (run, ms) => void env.timers.push({ ms, run }),
|
|
279
|
+
});
|
|
280
|
+
expect(env.timers[0].ms).toBe(0);
|
|
281
|
+
env.timers[0].run();
|
|
282
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
283
|
+
expect(env.timers[1].ms).toBe(30_000);
|
|
284
|
+
env.timers[1].run();
|
|
285
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
286
|
+
env.timers[2].run();
|
|
287
|
+
await dog.done;
|
|
288
|
+
expect(env.calls).toHaveLength(3);
|
|
289
|
+
});
|
|
290
|
+
it("does nothing once stopped", async () => {
|
|
291
|
+
const env = fakeEnv([204]);
|
|
292
|
+
const dog = startDeadlineWatchdog({ deadlineAt: "2000-01-01T00:00:00.000Z", podId: "p", apiKey: "k", fetch: env.fetchFn, now: () => 0, setTimer: (run, ms) => void env.timers.push({ ms, run }) });
|
|
293
|
+
dog.stop();
|
|
294
|
+
env.timers[0].run();
|
|
295
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
296
|
+
expect(env.calls).toHaveLength(0);
|
|
297
|
+
});
|
|
298
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import { type JobStage, type RemoteRuntimeReport, type WireJob } from "@moldesk/runtime";
|
|
3
|
+
export declare const WORKER_VERSION = "1";
|
|
4
|
+
export declare const DEFAULT_MAX_LOG_BYTES: number;
|
|
5
|
+
export interface PipelineContext {
|
|
6
|
+
job: WireJob;
|
|
7
|
+
jobDir: string;
|
|
8
|
+
inputDir: string;
|
|
9
|
+
outputDir: string;
|
|
10
|
+
signal: AbortSignal;
|
|
11
|
+
setStage(stage: JobStage): void;
|
|
12
|
+
log(stream: "stdout" | "stderr", text: string): void;
|
|
13
|
+
}
|
|
14
|
+
export interface PipelineResult {
|
|
15
|
+
exitCode: number;
|
|
16
|
+
runtime?: RemoteRuntimeReport;
|
|
17
|
+
}
|
|
18
|
+
/** The real work (assets, runtime, command, execution). Injected so the HTTP surface is testable on its own. */
|
|
19
|
+
export type WorkerPipeline = (context: PipelineContext) => Promise<PipelineResult>;
|
|
20
|
+
export interface WorkerServerOptions {
|
|
21
|
+
/** Per-job bearer token; every route, including health, requires it. */
|
|
22
|
+
token: string;
|
|
23
|
+
/** Jobs live under `<rootDir>/<runId>/{input,output}`. */
|
|
24
|
+
rootDir: string;
|
|
25
|
+
pipeline: WorkerPipeline;
|
|
26
|
+
/** Extra secrets to scrub from logs (the token itself is always scrubbed). */
|
|
27
|
+
secrets?: string[];
|
|
28
|
+
/** In-memory log tail limit; older chunks are evicted and reported as lost to clients that missed them. */
|
|
29
|
+
maxLogBytes?: number;
|
|
30
|
+
}
|
|
31
|
+
export interface RunningWorker {
|
|
32
|
+
server: http.Server;
|
|
33
|
+
listen(port: number, host?: string): Promise<number>;
|
|
34
|
+
close(): Promise<void>;
|
|
35
|
+
/** Terminal state reached; used by tests and the idle watchdog. */
|
|
36
|
+
isFinished(): boolean;
|
|
37
|
+
}
|
|
38
|
+
export declare function createWorkerServer(options: WorkerServerOptions): RunningWorker;
|