@moldesk/core 0.0.4 → 0.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cloud/accounts.d.ts +69 -0
- package/dist/cloud/accounts.js +113 -0
- package/dist/cloud/accounts.test.d.ts +1 -0
- package/dist/cloud/accounts.test.js +188 -0
- package/dist/cloud/backend.test.d.ts +1 -0
- package/dist/cloud/backend.test.js +339 -0
- package/dist/cloud/deps.d.ts +15 -0
- package/dist/cloud/deps.js +30 -0
- package/dist/cloud/manage.d.ts +57 -0
- package/dist/cloud/manage.js +210 -0
- package/dist/cloud/manage.test.d.ts +1 -0
- package/dist/cloud/manage.test.js +105 -0
- package/dist/cloud/quote.d.ts +131 -0
- package/dist/cloud/quote.js +225 -0
- package/dist/cloud/quote.test.d.ts +1 -0
- package/dist/cloud/quote.test.js +264 -0
- package/dist/cloud/run.d.ts +114 -0
- package/dist/cloud/run.js +498 -0
- package/dist/cloud/run.test.d.ts +1 -0
- package/dist/cloud/run.test.js +597 -0
- package/dist/cloud/state.d.ts +159 -0
- package/dist/cloud/state.js +395 -0
- package/dist/cloud/state.test.d.ts +1 -0
- package/dist/cloud/state.test.js +199 -0
- package/dist/cloud/worker/cache.d.ts +75 -0
- package/dist/cloud/worker/cache.js +152 -0
- package/dist/cloud/worker/cache.test.d.ts +1 -0
- package/dist/cloud/worker/cache.test.js +123 -0
- package/dist/cloud/worker/main.d.ts +2 -0
- package/dist/cloud/worker/main.js +34 -0
- package/dist/cloud/worker/pipeline.d.ts +26 -0
- package/dist/cloud/worker/pipeline.js +160 -0
- package/dist/cloud/worker/pipeline.test.d.ts +1 -0
- package/dist/cloud/worker/pipeline.test.js +298 -0
- package/dist/cloud/worker/server.d.ts +38 -0
- package/dist/cloud/worker/server.js +287 -0
- package/dist/cloud/worker/server.test.d.ts +1 -0
- package/dist/cloud/worker/server.test.js +391 -0
- package/dist/cloud/worker/watchdog.d.ts +27 -0
- package/dist/cloud/worker/watchdog.js +46 -0
- package/dist/cloud/worker-release.d.ts +12 -0
- package/dist/cloud/worker-release.js +25 -0
- package/dist/cloud/worker-release.test.d.ts +1 -0
- package/dist/cloud/worker-release.test.js +21 -0
- package/dist/index.d.ts +17 -3
- package/dist/index.js +9 -2
- package/dist/run-lifecycle.d.ts +74 -0
- package/dist/run-lifecycle.js +178 -0
- package/dist/run.d.ts +61 -1
- package/dist/run.js +54 -125
- package/dist/run.test.js +47 -1
- package/package.json +10 -6
|
@@ -0,0 +1,597 @@
|
|
|
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 { getMoldeskPaths, } from "@moldesk/runtime";
|
|
7
|
+
import { MoldeskError } from "../errors.js";
|
|
8
|
+
import { buildQuote } from "./quote.js";
|
|
9
|
+
import { planCloudRun, runCloudModel } from "./run.js";
|
|
10
|
+
import { CloudJournal } from "./state.js";
|
|
11
|
+
import { recoverCloudRun } from "./manage.js";
|
|
12
|
+
const DIGEST = "a".repeat(64);
|
|
13
|
+
const homes = [];
|
|
14
|
+
function manifest() {
|
|
15
|
+
return parseManifest({
|
|
16
|
+
schemaVersion: 1,
|
|
17
|
+
name: "fake-model",
|
|
18
|
+
displayName: "Fake",
|
|
19
|
+
modelVersion: "1.0.0",
|
|
20
|
+
adapterVersion: "0.1.0",
|
|
21
|
+
description: "t",
|
|
22
|
+
category: "other",
|
|
23
|
+
status: "planned",
|
|
24
|
+
runtimes: [{ kind: "python", python: "3.11", installer: "uv", platforms: ["linux-x64"], requirements: [{ name: "torch" }] }],
|
|
25
|
+
hardware: { platforms: ["linux-x64"] },
|
|
26
|
+
input: { formats: [".txt"], required: true },
|
|
27
|
+
outputs: [{ id: "result", glob: "*.out", required: true }],
|
|
28
|
+
cloud: {
|
|
29
|
+
schemaVersion: 1,
|
|
30
|
+
platform: "linux-x64",
|
|
31
|
+
images: { gpu: `registry.example/w/gpu@sha256:${DIGEST}`, cpu: `registry.example/w/cpu@sha256:${DIGEST}` },
|
|
32
|
+
minVramGb: 24,
|
|
33
|
+
minRamGb: 32,
|
|
34
|
+
ephemeralDiskGb: 40,
|
|
35
|
+
volumeGb: 40,
|
|
36
|
+
assetLayoutRevision: "1",
|
|
37
|
+
cpuAssetPreparation: true,
|
|
38
|
+
},
|
|
39
|
+
}, "x/manifest.yaml");
|
|
40
|
+
}
|
|
41
|
+
function adapter() {
|
|
42
|
+
return {
|
|
43
|
+
modelName: "fake-model",
|
|
44
|
+
params: [],
|
|
45
|
+
async validateInput() { },
|
|
46
|
+
async installPlan() {
|
|
47
|
+
return { steps: [] };
|
|
48
|
+
},
|
|
49
|
+
async command() {
|
|
50
|
+
throw new Error("the client must never build a command for a remote run");
|
|
51
|
+
},
|
|
52
|
+
async collectOutputs(context) {
|
|
53
|
+
const files = fs.existsSync(context.outputDir) ? fs.readdirSync(context.outputDir).filter((name) => name.endsWith(".out")) : [];
|
|
54
|
+
if (files.length === 0)
|
|
55
|
+
throw new MoldeskError({ code: "MISSING_REQUIRED_OUTPUT", message: "No .out file was produced.", remediation: "x" });
|
|
56
|
+
return files.map((name) => ({ id: "result", path: path.join(context.outputDir, name) }));
|
|
57
|
+
},
|
|
58
|
+
async verifyInstallation() {
|
|
59
|
+
return { passed: true };
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
const catalog = () => ({
|
|
64
|
+
retrievedAt: "2026-10-13T12:00:00.000Z",
|
|
65
|
+
gpus: [{ id: "a40", name: "A40", memoryGb: 48, priceUsdPerHour: 0.6, availability: "LOW", dataCenters: [{ id: "DC-1", availability: "LOW" }], cudaVersions: [] }],
|
|
66
|
+
cpus: [{ id: "cpu3c", name: "c", priceUsdPerVcpuHour: 0.03, ramGbPerVcpu: 2, minVcpu: 2, maxVcpu: 32 }],
|
|
67
|
+
dataCenters: [{ id: "DC-1", region: "EU", networkVolumeTypes: ["STANDARD"] }],
|
|
68
|
+
});
|
|
69
|
+
class FakeProvider {
|
|
70
|
+
name = "runpod";
|
|
71
|
+
storageRates;
|
|
72
|
+
pods = new Map();
|
|
73
|
+
nextId = 1;
|
|
74
|
+
failTerminate = false;
|
|
75
|
+
terminated = [];
|
|
76
|
+
async validateCredentials() {
|
|
77
|
+
return { ok: true, checkedAt: "t" };
|
|
78
|
+
}
|
|
79
|
+
async getCatalog() {
|
|
80
|
+
return { ...catalog(), ...(this.storageRates ? { storageRates: this.storageRates } : {}) };
|
|
81
|
+
}
|
|
82
|
+
async createPod(request) {
|
|
83
|
+
const pod = { id: `pod${this.nextId++}`, name: request.name, status: "RUNNING", dataCenterId: "DC-1", costUsdPerHour: 0.6, createdAt: null };
|
|
84
|
+
this.pods.set(pod.id, pod);
|
|
85
|
+
return pod;
|
|
86
|
+
}
|
|
87
|
+
async getPod(id) {
|
|
88
|
+
return this.pods.get(id) ?? null;
|
|
89
|
+
}
|
|
90
|
+
async listPods() {
|
|
91
|
+
return [...this.pods.values()];
|
|
92
|
+
}
|
|
93
|
+
async terminatePod(id) {
|
|
94
|
+
if (this.failTerminate)
|
|
95
|
+
throw new Error("provider down");
|
|
96
|
+
this.terminated.push(id);
|
|
97
|
+
const existed = this.pods.delete(id);
|
|
98
|
+
return { id, alreadyAbsent: !existed };
|
|
99
|
+
}
|
|
100
|
+
async createVolume() {
|
|
101
|
+
throw new Error("unused");
|
|
102
|
+
}
|
|
103
|
+
async getVolume() {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
async listVolumes() {
|
|
107
|
+
return [];
|
|
108
|
+
}
|
|
109
|
+
async deleteVolume() {
|
|
110
|
+
throw new Error("unused");
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
class FakeBackend {
|
|
114
|
+
provider;
|
|
115
|
+
script;
|
|
116
|
+
events;
|
|
117
|
+
starts = 0;
|
|
118
|
+
observes = 0;
|
|
119
|
+
collects = 0;
|
|
120
|
+
cancelled = false;
|
|
121
|
+
startError;
|
|
122
|
+
/** Pods are created BEFORE start() can fail, to test cleanup after a half-finished provision. */
|
|
123
|
+
createThenFail = false;
|
|
124
|
+
collectFailures = 0;
|
|
125
|
+
files = { "answer.out": "result-bytes" };
|
|
126
|
+
constructor(provider, script, events) {
|
|
127
|
+
this.provider = provider;
|
|
128
|
+
this.script = script;
|
|
129
|
+
this.events = events;
|
|
130
|
+
}
|
|
131
|
+
async start(job, context) {
|
|
132
|
+
this.starts += 1;
|
|
133
|
+
this.events.push("backend.start");
|
|
134
|
+
const intent = context.ledger.beginCreate("gpu");
|
|
135
|
+
const pod = await this.provider.createPod({ name: intent.name });
|
|
136
|
+
context.ledger.created(intent.intentId, pod.id);
|
|
137
|
+
if (this.startError)
|
|
138
|
+
throw this.startError;
|
|
139
|
+
return { version: 1, runId: job.runId, podId: pod.id, endpoint: "https://worker.example" };
|
|
140
|
+
}
|
|
141
|
+
reconnect(runId, podId) {
|
|
142
|
+
return { version: 1, runId, podId, endpoint: "https://worker.example" };
|
|
143
|
+
}
|
|
144
|
+
async observe(_handle, _after) {
|
|
145
|
+
const snapshot = this.script[Math.min(this.observes, this.script.length - 1)];
|
|
146
|
+
this.observes += 1;
|
|
147
|
+
return snapshot;
|
|
148
|
+
}
|
|
149
|
+
async cancel() {
|
|
150
|
+
this.cancelled = true;
|
|
151
|
+
}
|
|
152
|
+
hangCollect = false;
|
|
153
|
+
async collect(_handle, dir, options) {
|
|
154
|
+
this.collects += 1;
|
|
155
|
+
if (this.hangCollect) {
|
|
156
|
+
await new Promise((_resolve, reject) => options?.signal?.addEventListener("abort", () => reject(new MoldeskError({ code: "RUN_CANCELLED", message: "Cancelled during download.", remediation: "None." }))));
|
|
157
|
+
}
|
|
158
|
+
if (this.collectFailures > 0) {
|
|
159
|
+
this.collectFailures -= 1;
|
|
160
|
+
throw new Error("connection reset");
|
|
161
|
+
}
|
|
162
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
163
|
+
for (const [name, text] of Object.entries(this.files))
|
|
164
|
+
fs.writeFileSync(path.join(dir, name), text);
|
|
165
|
+
return { version: 1, files: Object.keys(this.files).map((name) => ({ path: name, sizeBytes: 1, sha256: "x" })) };
|
|
166
|
+
}
|
|
167
|
+
async release(handle, context) {
|
|
168
|
+
this.events.push("backend.release");
|
|
169
|
+
void handle;
|
|
170
|
+
void context;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const runningSnap = (extra = {}) => ({ stage: "running", state: "running", logs: { cursor: 1, stdout: "", stderr: "" }, ...extra });
|
|
174
|
+
const doneSnap = {
|
|
175
|
+
stage: "running",
|
|
176
|
+
state: "succeeded",
|
|
177
|
+
exitCode: 0,
|
|
178
|
+
logs: { cursor: 2, stdout: "predicting\n", stderr: "" },
|
|
179
|
+
runtime: { pythonVersion: "3.11.9", dependencyFreeze: ["torch==2.2.1"], gpu: { name: "A40", memoryMb: 49152 }, command: { executable: "/job/x/runtime/.venv/bin/fake", args: ["--in", "/job/x/input/in.txt"], cwd: "/job/x" }, installationFingerprint: "python-" + "f".repeat(16) },
|
|
180
|
+
};
|
|
181
|
+
function sandbox(script = [runningSnap(), doneSnap], overrides = {}) {
|
|
182
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), "moldesk cloud run "));
|
|
183
|
+
homes.push(home);
|
|
184
|
+
const paths = getMoldeskPaths({ MOLDESK_HOME: home });
|
|
185
|
+
const inputPath = path.join(home, "in.txt");
|
|
186
|
+
fs.writeFileSync(inputPath, "job");
|
|
187
|
+
const provider = new FakeProvider();
|
|
188
|
+
const events = [];
|
|
189
|
+
const backend = new FakeBackend(provider, script, events);
|
|
190
|
+
const clock = { ms: Date.parse("2026-10-13T12:00:00.000Z") };
|
|
191
|
+
const deps = {
|
|
192
|
+
provider,
|
|
193
|
+
paths,
|
|
194
|
+
backend,
|
|
195
|
+
accountScope: "acct",
|
|
196
|
+
resolveModel: () => ({ manifest: manifest(), adapter: adapter() }),
|
|
197
|
+
now: () => new Date(clock.ms),
|
|
198
|
+
sleep: async (ms) => {
|
|
199
|
+
clock.ms += ms;
|
|
200
|
+
},
|
|
201
|
+
pollIntervalMs: 1000,
|
|
202
|
+
recoveryWindowSeconds: 30,
|
|
203
|
+
...overrides,
|
|
204
|
+
};
|
|
205
|
+
const quoteOptions = { cachePolicy: "ephemeral", maxBudgetUsd: 5, maxLifetimeSeconds: 3600 };
|
|
206
|
+
const optionsFor = async (extra = {}) => {
|
|
207
|
+
const plan = await planCloudRun("fake-model", inputPath, { cloud: quoteOptions }, deps);
|
|
208
|
+
return { cloud: { ...quoteOptions, quote: plan.quote, approval: { quoteId: plan.quote.id, approvedAt: new Date(clock.ms).toISOString(), acknowledged: plan.quote.warnings.map((w) => w.id) } }, ...extra };
|
|
209
|
+
};
|
|
210
|
+
return { paths, inputPath, provider, backend, events, clock, deps, optionsFor };
|
|
211
|
+
}
|
|
212
|
+
afterEach(() => {
|
|
213
|
+
for (const home of homes.splice(0))
|
|
214
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
215
|
+
});
|
|
216
|
+
describe("runCloudModel", () => {
|
|
217
|
+
it("runs end to end: verified outputs, pod gone before the report, risks acknowledged, journal closed out", async () => {
|
|
218
|
+
const box = sandbox();
|
|
219
|
+
const logs = [];
|
|
220
|
+
const options = await box.optionsFor({ onLog: (_stream, chunk) => logs.push(chunk) });
|
|
221
|
+
const result = await runCloudModel("fake-model", box.inputPath, options, box.deps);
|
|
222
|
+
expect(result.status).toBe("succeeded");
|
|
223
|
+
expect(result.cleanupPending).toBeUndefined();
|
|
224
|
+
expect(box.events).toEqual(["backend.start", "backend.release"]);
|
|
225
|
+
expect(result.record.compute.acknowledgedRisks).toEqual(["lifetime-risk"]);
|
|
226
|
+
expect(box.provider.pods.size).toBe(0);
|
|
227
|
+
expect(logs.join("")).toContain("predicting");
|
|
228
|
+
expect(result.record.outputs).toHaveLength(1);
|
|
229
|
+
expect(result.record.outputs[0].sha256).toMatch(/^[0-9a-f]{64}$/);
|
|
230
|
+
expect(result.record.runtime).toBe("python");
|
|
231
|
+
expect(result.record.installationFingerprint).toBe("python-" + "f".repeat(16));
|
|
232
|
+
expect(result.record.compute).toMatchObject({
|
|
233
|
+
provider: "runpod",
|
|
234
|
+
cachePolicy: "ephemeral",
|
|
235
|
+
gpuId: "a40",
|
|
236
|
+
datacenterId: "DC-1",
|
|
237
|
+
cleanup: "confirmed",
|
|
238
|
+
hardware: { name: "A40" },
|
|
239
|
+
pythonVersion: "3.11.9",
|
|
240
|
+
dependencyFreeze: ["torch==2.2.1"],
|
|
241
|
+
});
|
|
242
|
+
expect(result.record.compute.resources).toEqual([{ role: "gpu", providerId: "pod1" }]);
|
|
243
|
+
expect(result.record.compute.remoteCommand.cwd).toBe("/job/x"); // POSIX worker path
|
|
244
|
+
expect(result.record.command.cwd).toContain("runs"); // local run directory is unchanged
|
|
245
|
+
const onDisk = JSON.parse(fs.readFileSync(path.join(result.record.command.cwd, "run.json"), "utf8"));
|
|
246
|
+
expect(onDisk.status).toBe("succeeded");
|
|
247
|
+
const journal = CloudJournal.open(box.paths);
|
|
248
|
+
expect(journal.getJob(result.record.id)).toMatchObject({ stage: "done", status: "succeeded", transferState: "verified" });
|
|
249
|
+
expect(journal.listResources({ states: ["create_pending", "active", "delete_pending"] })).toHaveLength(0);
|
|
250
|
+
journal.close();
|
|
251
|
+
});
|
|
252
|
+
it("rejects mismatched or expired approvals before any run directory, job or resource exists", async () => {
|
|
253
|
+
const box = sandbox();
|
|
254
|
+
const options = await box.optionsFor();
|
|
255
|
+
await expect(runCloudModel("fake-model", box.inputPath, { ...options, cloud: { ...options.cloud, approval: { quoteId: "other", approvedAt: new Date(box.clock.ms).toISOString(), acknowledged: ["lifetime-risk"] } } }, box.deps)).rejects.toMatchObject({ code: "CLOUD_APPROVAL_MISMATCH" });
|
|
256
|
+
box.clock.ms += 16 * 60 * 1000;
|
|
257
|
+
await expect(runCloudModel("fake-model", box.inputPath, options, box.deps)).rejects.toMatchObject({ code: "CLOUD_APPROVAL_EXPIRED" });
|
|
258
|
+
expect(fs.existsSync(box.paths.runs)).toBe(false);
|
|
259
|
+
expect(box.backend.starts).toBe(0);
|
|
260
|
+
expect(box.events).toEqual([]);
|
|
261
|
+
});
|
|
262
|
+
it("requires a new quote when the input file changed since it was quoted", async () => {
|
|
263
|
+
const box = sandbox();
|
|
264
|
+
const options = await box.optionsFor();
|
|
265
|
+
fs.writeFileSync(box.inputPath, "different job");
|
|
266
|
+
await expect(runCloudModel("fake-model", box.inputPath, options, box.deps)).rejects.toMatchObject({ code: "CLOUD_REQUOTE_REQUIRED" });
|
|
267
|
+
expect(box.events).toEqual([]);
|
|
268
|
+
});
|
|
269
|
+
it("rejects a run whose approval did not acknowledge the shutdown risk, before anything exists", async () => {
|
|
270
|
+
const box = sandbox();
|
|
271
|
+
const options = await box.optionsFor();
|
|
272
|
+
await expect(runCloudModel("fake-model", box.inputPath, { ...options, cloud: { ...options.cloud, approval: { ...options.cloud.approval, acknowledged: [] } } }, box.deps)).rejects.toMatchObject({ code: "CLOUD_ACKNOWLEDGMENT_REQUIRED" });
|
|
273
|
+
expect(fs.existsSync(box.paths.runs)).toBe(false);
|
|
274
|
+
expect(box.backend.starts).toBe(0);
|
|
275
|
+
expect(box.provider.nextId).toBe(1);
|
|
276
|
+
});
|
|
277
|
+
it("fails on a nonzero worker exit and still terminates the machine", async () => {
|
|
278
|
+
const box = sandbox([runningSnap(), { stage: "running", state: "failed", exitCode: 1, logs: { cursor: 2, stdout: "", stderr: "boom\n" } }]);
|
|
279
|
+
const result = await runCloudModel("fake-model", box.inputPath, await box.optionsFor(), box.deps);
|
|
280
|
+
expect(result.status).toBe("failed");
|
|
281
|
+
expect(result.record.error).toEqual({ code: "RUN_EXECUTION_FAILED", message: "fake-model exited with code 1." });
|
|
282
|
+
expect(box.provider.pods.size).toBe(0);
|
|
283
|
+
expect(result.record.outputs).toHaveLength(0);
|
|
284
|
+
});
|
|
285
|
+
it("treats a missing required output as failure even when the worker reported success", async () => {
|
|
286
|
+
const box = sandbox();
|
|
287
|
+
box.backend.files = {};
|
|
288
|
+
const result = await runCloudModel("fake-model", box.inputPath, await box.optionsFor(), box.deps);
|
|
289
|
+
expect(result.status).toBe("failed");
|
|
290
|
+
expect(result.record.error?.code).toBe("MISSING_REQUIRED_OUTPUT");
|
|
291
|
+
expect(box.provider.pods.size).toBe(0);
|
|
292
|
+
});
|
|
293
|
+
it("retries a failed download inside a bounded window without rerunning inference", async () => {
|
|
294
|
+
const box = sandbox();
|
|
295
|
+
box.backend.collectFailures = 2;
|
|
296
|
+
const result = await runCloudModel("fake-model", box.inputPath, await box.optionsFor(), box.deps);
|
|
297
|
+
expect(result.status).toBe("succeeded");
|
|
298
|
+
expect(box.backend.collects).toBe(3);
|
|
299
|
+
expect(box.backend.starts).toBe(1);
|
|
300
|
+
expect(box.backend.observes).toBe(2);
|
|
301
|
+
});
|
|
302
|
+
it("reports a transfer failure after the window, preserves the failure and still cleans up", async () => {
|
|
303
|
+
const box = sandbox();
|
|
304
|
+
box.backend.collectFailures = 1000;
|
|
305
|
+
const result = await runCloudModel("fake-model", box.inputPath, await box.optionsFor(), box.deps);
|
|
306
|
+
expect(result.status).toBe("failed");
|
|
307
|
+
expect(result.record.error?.code).toBe("CLOUD_TRANSFER_FAILED");
|
|
308
|
+
expect(box.backend.collects).toBeLessThan(20);
|
|
309
|
+
expect(box.backend.starts).toBe(1);
|
|
310
|
+
expect(box.provider.pods.size).toBe(0);
|
|
311
|
+
const journal = CloudJournal.open(box.paths);
|
|
312
|
+
expect(journal.getJob(result.record.id).transferState).toBe("failed");
|
|
313
|
+
journal.close();
|
|
314
|
+
});
|
|
315
|
+
it("cancels on abort, marks the run cancelled and terminates the machine", async () => {
|
|
316
|
+
const controller = new AbortController();
|
|
317
|
+
const box = sandbox([runningSnap({ logs: { cursor: 1, stdout: "working\n", stderr: "" } })]);
|
|
318
|
+
const options = await box.optionsFor({ signal: controller.signal, onLog: () => controller.abort() });
|
|
319
|
+
const result = await runCloudModel("fake-model", box.inputPath, options, box.deps);
|
|
320
|
+
expect(result.status).toBe("cancelled");
|
|
321
|
+
expect(result.record.error?.code).toBe("RUN_CANCELLED");
|
|
322
|
+
expect(box.backend.cancelled).toBe(true);
|
|
323
|
+
expect(box.provider.pods.size).toBe(0);
|
|
324
|
+
});
|
|
325
|
+
it("cancels a download that is in flight, marks the run cancelled and still terminates the machine", async () => {
|
|
326
|
+
const controller = new AbortController();
|
|
327
|
+
const box = sandbox();
|
|
328
|
+
box.backend.hangCollect = true;
|
|
329
|
+
const options = await box.optionsFor({ signal: controller.signal, onProgress: (event) => event.stage === "collecting" && setTimeout(() => controller.abort(), 10) });
|
|
330
|
+
const result = await runCloudModel("fake-model", box.inputPath, options, box.deps);
|
|
331
|
+
expect(result.status).toBe("cancelled");
|
|
332
|
+
expect(result.record.error?.code).toBe("RUN_CANCELLED");
|
|
333
|
+
expect(box.provider.pods.size).toBe(0);
|
|
334
|
+
expect(box.backend.starts).toBe(1);
|
|
335
|
+
});
|
|
336
|
+
it("stops at the approved lifetime and reports CLOUD_DEADLINE_EXCEEDED", async () => {
|
|
337
|
+
const box = sandbox([runningSnap()], { pollIntervalMs: 600_000 });
|
|
338
|
+
const result = await runCloudModel("fake-model", box.inputPath, await box.optionsFor(), box.deps);
|
|
339
|
+
expect(result.status).toBe("failed");
|
|
340
|
+
expect(result.record.error?.code).toBe("CLOUD_DEADLINE_EXCEEDED");
|
|
341
|
+
expect(box.backend.cancelled).toBe(true);
|
|
342
|
+
expect(box.provider.pods.size).toBe(0);
|
|
343
|
+
});
|
|
344
|
+
it("fails fast when the worker becomes unreachable and still cleans up", async () => {
|
|
345
|
+
const box = sandbox();
|
|
346
|
+
box.backend.observe = async () => {
|
|
347
|
+
throw new Error("ECONNRESET");
|
|
348
|
+
};
|
|
349
|
+
const result = await runCloudModel("fake-model", box.inputPath, await box.optionsFor(), box.deps);
|
|
350
|
+
expect(result.record.error?.code).toBe("CLOUD_WORKER_UNREACHABLE");
|
|
351
|
+
expect(box.provider.pods.size).toBe(0);
|
|
352
|
+
});
|
|
353
|
+
it("cleans up a machine created before start() failed", async () => {
|
|
354
|
+
const box = sandbox();
|
|
355
|
+
box.backend.startError = new Error("bootstrap failed");
|
|
356
|
+
const result = await runCloudModel("fake-model", box.inputPath, await box.optionsFor(), box.deps);
|
|
357
|
+
expect(result.status).toBe("failed");
|
|
358
|
+
expect(result.record.error?.message).toBe("bootstrap failed");
|
|
359
|
+
expect(box.provider.pods.size).toBe(0);
|
|
360
|
+
expect(box.provider.terminated).toEqual(["pod1"]);
|
|
361
|
+
});
|
|
362
|
+
it("surfaces CLOUD_CLEANUP_PENDING without discarding a valid result", async () => {
|
|
363
|
+
const box = sandbox();
|
|
364
|
+
box.provider.failTerminate = true;
|
|
365
|
+
const result = await runCloudModel("fake-model", box.inputPath, await box.optionsFor(), { ...box.deps, cleanupAttempts: 2 });
|
|
366
|
+
expect(result.status).toBe("succeeded");
|
|
367
|
+
expect(result.record.outputs).toHaveLength(1);
|
|
368
|
+
expect(result.cleanupPending).toEqual({ code: "CLOUD_CLEANUP_PENDING", resources: [{ role: "gpu", providerId: "pod1" }] });
|
|
369
|
+
expect(result.record.compute.cleanup).toBe("pending");
|
|
370
|
+
const journal = CloudJournal.open(box.paths);
|
|
371
|
+
expect(journal.getJob(result.record.id)).toMatchObject({ status: "cleanup_pending", error: { code: "CLOUD_CLEANUP_PENDING" } });
|
|
372
|
+
expect(journal.listOpenJobs()).toHaveLength(1);
|
|
373
|
+
journal.close();
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
describe("approval revalidation against the live catalog", () => {
|
|
377
|
+
it("rejects before allocating anything when an unknown storage rate has become known since approval", async () => {
|
|
378
|
+
const box = sandbox();
|
|
379
|
+
const options = await box.optionsFor(); // approved while disk rates were unknown
|
|
380
|
+
box.provider.storageRates = { containerDiskUsdPerGbMonth: 0.1 };
|
|
381
|
+
await expect(runCloudModel("fake-model", box.inputPath, options, box.deps)).rejects.toMatchObject({ code: "CLOUD_REQUOTE_REQUIRED" });
|
|
382
|
+
expect(fs.existsSync(box.paths.runs)).toBe(false);
|
|
383
|
+
expect(box.events).toEqual([]);
|
|
384
|
+
expect(box.backend.starts).toBe(0);
|
|
385
|
+
});
|
|
386
|
+
it("proceeds when the fresh quote has the same storage coverage", async () => {
|
|
387
|
+
const box = sandbox();
|
|
388
|
+
box.provider.storageRates = { containerDiskUsdPerGbMonth: 0.1 };
|
|
389
|
+
const options = await box.optionsFor();
|
|
390
|
+
const result = await runCloudModel("fake-model", box.inputPath, options, box.deps);
|
|
391
|
+
expect(result.status).toBe("succeeded");
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
describe("incomplete worker logs", () => {
|
|
395
|
+
const snap = (stdout, state, cursor, lost) => ({
|
|
396
|
+
stage: "running",
|
|
397
|
+
state,
|
|
398
|
+
...(state === "succeeded" ? { exitCode: 0 } : {}),
|
|
399
|
+
logs: { cursor, stdout, stderr: "", ...(lost ? { lostEntries: lost, evictedBytes: lost * 100 } : {}) },
|
|
400
|
+
});
|
|
401
|
+
it("marks the local logs, warns on the run and records incompleteness, without failing a valid result", async () => {
|
|
402
|
+
const box = sandbox([snap("tail-1\n", "running", 5, 4), snap("tail-2\n", "succeeded", 7, 2)]);
|
|
403
|
+
const result = await runCloudModel("fake-model", box.inputPath, await box.optionsFor(), box.deps);
|
|
404
|
+
expect(result.status).toBe("succeeded");
|
|
405
|
+
expect(result.record.compute.logs).toEqual({ complete: false, lostChunks: 6 });
|
|
406
|
+
const warnings = result.record.warnings.filter((w) => w.code === "CLOUD_LOGS_INCOMPLETE");
|
|
407
|
+
expect(warnings).toHaveLength(1);
|
|
408
|
+
expect(warnings[0].message).toMatch(/6 worker log chunk\(s\) were dropped/);
|
|
409
|
+
const stdout = fs.readFileSync(result.record.logs.stdout, "utf8");
|
|
410
|
+
expect(stdout).toMatch(/\[moldesk: 4 worker log chunk\(s\) were dropped.*incomplete here\]/);
|
|
411
|
+
expect(stdout).toContain("tail-1");
|
|
412
|
+
expect(stdout).toContain("tail-2");
|
|
413
|
+
expect(fs.readFileSync(result.record.logs.stderr, "utf8")).toMatch(/\[moldesk: 2 worker log chunk/);
|
|
414
|
+
const onDisk = JSON.parse(fs.readFileSync(path.join(result.record.command.cwd, "run.json"), "utf8"));
|
|
415
|
+
expect(onDisk.compute.logs).toEqual({ complete: false, lostChunks: 6 });
|
|
416
|
+
expect(onDisk.warnings.map((w) => w.code)).toContain("CLOUD_LOGS_INCOMPLETE");
|
|
417
|
+
});
|
|
418
|
+
it("leaves complete logs unmarked", async () => {
|
|
419
|
+
const box = sandbox([snap("a\n", "running", 1), snap("b\n", "succeeded", 2)]);
|
|
420
|
+
const result = await runCloudModel("fake-model", box.inputPath, await box.optionsFor(), box.deps);
|
|
421
|
+
expect(result.record.compute.logs).toBeUndefined();
|
|
422
|
+
expect((result.record.warnings ?? []).some((w) => w.code === "CLOUD_LOGS_INCOMPLETE")).toBe(false);
|
|
423
|
+
expect(fs.readFileSync(result.record.logs.stdout, "utf8")).toBe("a\nb\n");
|
|
424
|
+
});
|
|
425
|
+
it("recovery reports logs lost while the client was away", async () => {
|
|
426
|
+
const box = sandbox([snap("a\n", "running", 1), snap("b\n", "succeeded", 2)]);
|
|
427
|
+
box.provider.failTerminate = true;
|
|
428
|
+
box.backend.collectFailures = 1000;
|
|
429
|
+
const failed = await runCloudModel("fake-model", box.inputPath, await box.optionsFor(), { ...box.deps, cleanupAttempts: 1 });
|
|
430
|
+
box.backend.script.splice(0, box.backend.script.length, snap("later\n", "succeeded", 12, 9));
|
|
431
|
+
box.backend.observes = 0;
|
|
432
|
+
box.provider.failTerminate = false;
|
|
433
|
+
box.backend.collectFailures = 0;
|
|
434
|
+
const recovered = await recoverCloudRun(failed.record.id, { provider: box.provider, paths: box.paths, backend: box.backend, resolveModel: box.deps.resolveModel, sleep: box.deps.sleep, cleanupAttempts: 1 });
|
|
435
|
+
expect(recovered.result?.record.compute?.logs).toEqual({ complete: false, lostChunks: 9 });
|
|
436
|
+
expect(recovered.result?.record.warnings?.some((w) => w.code === "CLOUD_LOGS_INCOMPLETE")).toBe(true);
|
|
437
|
+
expect(fs.readFileSync(failed.record.logs.stdout, "utf8")).toContain("9 worker log chunk(s) were dropped");
|
|
438
|
+
});
|
|
439
|
+
});
|
|
440
|
+
describe("log persistence", () => {
|
|
441
|
+
const logSnap = (stdout, stderr, state, cursor) => ({ stage: "running", state, ...(state === "succeeded" ? { exitCode: 0 } : state === "failed" ? { exitCode: 1 } : {}), logs: { cursor, stdout, stderr } });
|
|
442
|
+
it("saves worker stdout/stderr to the files run.json references, without any terminal tee, and they outlive cleanup", async () => {
|
|
443
|
+
const box = sandbox([logSnap("step 1\n", "warn\n", "running", 1), logSnap("step 2\n", "", "succeeded", 2)]);
|
|
444
|
+
const result = await runCloudModel("fake-model", box.inputPath, await box.optionsFor(), box.deps); // no onLog
|
|
445
|
+
expect(box.provider.pods.size).toBe(0);
|
|
446
|
+
const onDisk = JSON.parse(fs.readFileSync(path.join(result.record.command.cwd, "run.json"), "utf8"));
|
|
447
|
+
expect(fs.readFileSync(onDisk.logs.stdout, "utf8")).toBe("step 1\nstep 2\n");
|
|
448
|
+
expect(fs.readFileSync(onDisk.logs.stderr, "utf8")).toBe("warn\n");
|
|
449
|
+
expect(onDisk.compute.workerLogCursor).toBe(2);
|
|
450
|
+
expect(path.dirname(onDisk.logs.stdout)).toBe(result.record.command.cwd);
|
|
451
|
+
});
|
|
452
|
+
it("keeps the logs of a failed run, including the tail drained before cleanup", async () => {
|
|
453
|
+
const box = sandbox([logSnap("", "", "running", 0), logSnap("", "Traceback...\n", "failed", 1)]);
|
|
454
|
+
const result = await runCloudModel("fake-model", box.inputPath, await box.optionsFor(), box.deps);
|
|
455
|
+
expect(result.status).toBe("failed");
|
|
456
|
+
expect(fs.readFileSync(result.record.logs.stderr, "utf8")).toBe("Traceback...\n");
|
|
457
|
+
});
|
|
458
|
+
it("drains the remaining tail when the run is cancelled mid-way", async () => {
|
|
459
|
+
const controller = new AbortController();
|
|
460
|
+
const box = sandbox([logSnap("early\n", "", "running", 1)]);
|
|
461
|
+
const options = await box.optionsFor({ signal: controller.signal, onLog: () => controller.abort() });
|
|
462
|
+
const result = await runCloudModel("fake-model", box.inputPath, options, box.deps);
|
|
463
|
+
expect(result.status).toBe("cancelled");
|
|
464
|
+
expect(fs.readFileSync(result.record.logs.stdout, "utf8")).toContain("early");
|
|
465
|
+
});
|
|
466
|
+
it("recovery appends only the log tail written since the saved cursor", async () => {
|
|
467
|
+
const box = sandbox([logSnap("a\n", "", "running", 1), logSnap("b\n", "", "succeeded", 2)]);
|
|
468
|
+
box.provider.failTerminate = true;
|
|
469
|
+
box.backend.collectFailures = 1000;
|
|
470
|
+
const failed = await runCloudModel("fake-model", box.inputPath, await box.optionsFor(), { ...box.deps, cleanupAttempts: 1 });
|
|
471
|
+
expect(fs.readFileSync(failed.record.logs.stdout, "utf8")).toBe("a\nb\n");
|
|
472
|
+
// The worker later produced one more line.
|
|
473
|
+
box.backend.script.splice(0, box.backend.script.length, logSnap("c\n", "", "succeeded", 3));
|
|
474
|
+
box.backend.observes = 0;
|
|
475
|
+
box.provider.failTerminate = false;
|
|
476
|
+
box.backend.collectFailures = 0;
|
|
477
|
+
await recoverCloudRun(failed.record.id, { provider: box.provider, paths: box.paths, backend: box.backend, resolveModel: box.deps.resolveModel, sleep: box.deps.sleep, cleanupAttempts: 1 });
|
|
478
|
+
expect(fs.readFileSync(failed.record.logs.stdout, "utf8")).toBe("a\nb\nc\n");
|
|
479
|
+
});
|
|
480
|
+
});
|
|
481
|
+
describe("preflight reconciliation", () => {
|
|
482
|
+
it("refuses new paid work while an orphaned or owed resource exists, before allocating anything", async () => {
|
|
483
|
+
const box = sandbox();
|
|
484
|
+
const options = await box.optionsFor();
|
|
485
|
+
const journal = CloudJournal.open(box.paths);
|
|
486
|
+
const prefix = journal.clientId.replace(/-/g, "").slice(0, 8);
|
|
487
|
+
journal.close();
|
|
488
|
+
box.provider.pods.set("podOrphan", { id: "podOrphan", name: `moldesk-${prefix}-deadbeef-gpu`, status: "RUNNING", dataCenterId: null, costUsdPerHour: 0.5, createdAt: null });
|
|
489
|
+
await expect(runCloudModel("fake-model", box.inputPath, options, box.deps)).rejects.toMatchObject({ code: "CLOUD_UNRESOLVED_RESOURCES" });
|
|
490
|
+
expect(fs.existsSync(box.paths.runs)).toBe(false);
|
|
491
|
+
expect(box.events).toEqual([]);
|
|
492
|
+
});
|
|
493
|
+
it("refuses when a previous run still owes cleanup", async () => {
|
|
494
|
+
const box = sandbox();
|
|
495
|
+
box.provider.failTerminate = true;
|
|
496
|
+
const first = await runCloudModel("fake-model", box.inputPath, await box.optionsFor(), { ...box.deps, cleanupAttempts: 1 });
|
|
497
|
+
expect(first.cleanupPending).toBeDefined();
|
|
498
|
+
await expect(runCloudModel("fake-model", box.inputPath, await box.optionsFor(), box.deps)).rejects.toMatchObject({ code: "CLOUD_UNRESOLVED_RESOURCES" });
|
|
499
|
+
expect(box.backend.starts).toBe(1);
|
|
500
|
+
});
|
|
501
|
+
it("does not start blind when the provider cannot be listed", async () => {
|
|
502
|
+
const box = sandbox();
|
|
503
|
+
const options = await box.optionsFor();
|
|
504
|
+
box.provider.listPods = async () => {
|
|
505
|
+
throw new Error("offline");
|
|
506
|
+
};
|
|
507
|
+
await expect(runCloudModel("fake-model", box.inputPath, options, box.deps)).rejects.toMatchObject({ code: "CLOUD_UNAVAILABLE" });
|
|
508
|
+
expect(fs.existsSync(box.paths.runs)).toBe(false);
|
|
509
|
+
});
|
|
510
|
+
});
|
|
511
|
+
describe("planCloudRun", () => {
|
|
512
|
+
it("rejects models without a cloud block and jobs with companion inputs", async () => {
|
|
513
|
+
const box = sandbox();
|
|
514
|
+
const plain = parseManifest({ ...JSON.parse(JSON.stringify(manifest())), cloud: undefined }, "x/manifest.yaml");
|
|
515
|
+
await expect(planCloudRun("fake-model", box.inputPath, { cloud: { cachePolicy: "ephemeral", maxBudgetUsd: 5, maxLifetimeSeconds: 3600 } }, { ...box.deps, resolveModel: () => ({ manifest: plain, adapter: adapter() }) })).rejects.toMatchObject({ code: "CLOUD_MODEL_UNSUPPORTED" });
|
|
516
|
+
const withCompanions = { ...adapter(), resolveCompanionInputs: async () => [{ id: "protein", sourcePath: "p.pdb" }] };
|
|
517
|
+
await expect(planCloudRun("fake-model", box.inputPath, { cloud: { cachePolicy: "ephemeral", maxBudgetUsd: 5, maxLifetimeSeconds: 3600 } }, { ...box.deps, resolveModel: () => ({ manifest: manifest(), adapter: withCompanions }) })).rejects.toMatchObject({ code: "CLOUD_COMPANION_INPUTS_UNSUPPORTED" });
|
|
518
|
+
});
|
|
519
|
+
it("produces the same quote that buildQuote would, without allocating anything", async () => {
|
|
520
|
+
const box = sandbox();
|
|
521
|
+
const plan = await planCloudRun("fake-model", box.inputPath, { cloud: { cachePolicy: "ephemeral", maxBudgetUsd: 5, maxLifetimeSeconds: 3600 } }, box.deps);
|
|
522
|
+
expect(plan.quote.id).toBe(buildQuote({ provider: "runpod", manifest: plan.manifest, cloud: plan.cloud, catalog: catalog(), cachePolicy: "ephemeral", maxBudgetUsd: 5, maxLifetimeSeconds: 3600, inputs: [{ role: "job", sha256: plan.input.sha256, sizeBytes: 3 }] }).id);
|
|
523
|
+
expect(fs.existsSync(box.paths.runs)).toBe(false);
|
|
524
|
+
});
|
|
525
|
+
});
|
|
526
|
+
describe("recoverCloudRun", () => {
|
|
527
|
+
const recoverDeps = (box) => ({ provider: box.provider, paths: box.paths, backend: box.backend, resolveModel: box.deps.resolveModel, sleep: box.deps.sleep, cleanupAttempts: 1 });
|
|
528
|
+
it("finishes owed cleanup after the provider recovers and never restarts inference", async () => {
|
|
529
|
+
const box = sandbox();
|
|
530
|
+
box.provider.failTerminate = true;
|
|
531
|
+
const first = await runCloudModel("fake-model", box.inputPath, await box.optionsFor(), { ...box.deps, cleanupAttempts: 1 });
|
|
532
|
+
expect(first.cleanupPending).toBeDefined();
|
|
533
|
+
box.provider.failTerminate = false;
|
|
534
|
+
const recovered = await recoverCloudRun(first.record.id, recoverDeps(box));
|
|
535
|
+
expect(recovered.cleanup.confirmed).toBe(true);
|
|
536
|
+
expect(box.provider.pods.size).toBe(0);
|
|
537
|
+
expect(box.backend.starts).toBe(1);
|
|
538
|
+
const journal = CloudJournal.open(box.paths);
|
|
539
|
+
expect(journal.getJob(first.record.id)).toMatchObject({ status: "succeeded" });
|
|
540
|
+
expect(journal.listOpenJobs()).toHaveLength(0);
|
|
541
|
+
journal.close();
|
|
542
|
+
const onDisk = JSON.parse(fs.readFileSync(path.join(first.record.command.cwd, "run.json"), "utf8"));
|
|
543
|
+
expect(onDisk.compute.cleanup).toBe("confirmed");
|
|
544
|
+
});
|
|
545
|
+
it("downloads results left on a still-alive pod after a transfer failure, then cleans up", async () => {
|
|
546
|
+
const box = sandbox();
|
|
547
|
+
box.provider.failTerminate = true;
|
|
548
|
+
box.backend.collectFailures = 1000;
|
|
549
|
+
const failed = await runCloudModel("fake-model", box.inputPath, await box.optionsFor(), { ...box.deps, cleanupAttempts: 1 });
|
|
550
|
+
expect(failed.record.error?.code).toBe("CLOUD_TRANSFER_FAILED");
|
|
551
|
+
expect(box.provider.pods.size).toBe(1);
|
|
552
|
+
box.provider.failTerminate = false;
|
|
553
|
+
box.backend.collectFailures = 0;
|
|
554
|
+
const recovered = await recoverCloudRun(failed.record.id, recoverDeps(box));
|
|
555
|
+
expect(recovered.collected).toBe(true);
|
|
556
|
+
expect(recovered.status).toBe("succeeded");
|
|
557
|
+
expect(recovered.result?.record.outputs).toHaveLength(1);
|
|
558
|
+
expect(box.provider.pods.size).toBe(0);
|
|
559
|
+
expect(box.backend.starts).toBe(1);
|
|
560
|
+
const onDisk = JSON.parse(fs.readFileSync(path.join(failed.record.command.cwd, "run.json"), "utf8"));
|
|
561
|
+
expect(onDisk.status).toBe("succeeded");
|
|
562
|
+
expect(onDisk.error).toBeUndefined();
|
|
563
|
+
});
|
|
564
|
+
it("marks an interrupted run failed and terminates its pod", async () => {
|
|
565
|
+
const box = sandbox();
|
|
566
|
+
box.provider.failTerminate = true;
|
|
567
|
+
const first = await runCloudModel("fake-model", box.inputPath, await box.optionsFor(), { ...box.deps, cleanupAttempts: 1 });
|
|
568
|
+
// Rewrite history as if the client died mid-run.
|
|
569
|
+
const file = path.join(first.record.command.cwd, "run.json");
|
|
570
|
+
const record = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
571
|
+
record.status = "running";
|
|
572
|
+
delete record.finishedAt;
|
|
573
|
+
delete record.error;
|
|
574
|
+
record.outputs = [];
|
|
575
|
+
fs.writeFileSync(file, JSON.stringify(record));
|
|
576
|
+
fs.rmSync(path.join(first.record.command.cwd, "output"), { recursive: true, force: true });
|
|
577
|
+
box.backend.script.splice(0, box.backend.script.length, runningSnap());
|
|
578
|
+
box.backend.observes = 0;
|
|
579
|
+
box.provider.failTerminate = false;
|
|
580
|
+
// The worker is still running: recovery must not silently kill an approved job.
|
|
581
|
+
const waiting = await recoverCloudRun(first.record.id, recoverDeps(box));
|
|
582
|
+
expect(waiting.status).toBe("running");
|
|
583
|
+
expect(waiting.cleanup.confirmed).toBe(false);
|
|
584
|
+
expect(box.provider.pods.size).toBe(1);
|
|
585
|
+
const forced = await recoverCloudRun(first.record.id, recoverDeps(box), { terminateRunning: true });
|
|
586
|
+
expect(forced.cleanup.confirmed).toBe(true);
|
|
587
|
+
expect(forced.status).toBe("failed");
|
|
588
|
+
expect(box.provider.pods.size).toBe(0);
|
|
589
|
+
const after = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
590
|
+
expect(after.error.code).toBe("CLOUD_RUN_INTERRUPTED");
|
|
591
|
+
expect(after.finishedAt).toBeDefined();
|
|
592
|
+
});
|
|
593
|
+
it("rejects unknown runs", async () => {
|
|
594
|
+
const box = sandbox();
|
|
595
|
+
await expect(recoverCloudRun("2026-01-01T00-00-00-000Z-00000000-0000-0000-0000-000000000000", recoverDeps(box))).rejects.toMatchObject({ code: "CLOUD_RUN_UNKNOWN" });
|
|
596
|
+
});
|
|
597
|
+
});
|