@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,391 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
6
|
+
import { WorkerClient, validateInventory } from "@moldesk/runtime";
|
|
7
|
+
import { createWorkerServer } from "./server.js";
|
|
8
|
+
const TOKEN = "job-token-0123456789abcdef";
|
|
9
|
+
const RUN = "run-1";
|
|
10
|
+
const dirs = [];
|
|
11
|
+
const workers = [];
|
|
12
|
+
function tmp() {
|
|
13
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "moldesk worker "));
|
|
14
|
+
dirs.push(dir);
|
|
15
|
+
return dir;
|
|
16
|
+
}
|
|
17
|
+
afterEach(async () => {
|
|
18
|
+
for (const worker of workers.splice(0))
|
|
19
|
+
await worker.close();
|
|
20
|
+
for (const dir of dirs.splice(0))
|
|
21
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
22
|
+
});
|
|
23
|
+
const sha = (text) => createHash("sha256").update(text).digest("hex");
|
|
24
|
+
async function boot(pipeline, token = TOKEN, maxLogBytes) {
|
|
25
|
+
const rootDir = tmp();
|
|
26
|
+
const worker = createWorkerServer({ token, rootDir, pipeline, ...(maxLogBytes ? { maxLogBytes } : {}) });
|
|
27
|
+
workers.push(worker);
|
|
28
|
+
const port = await worker.listen(0, "127.0.0.1");
|
|
29
|
+
const endpoint = `http://127.0.0.1:${port}`;
|
|
30
|
+
const client = (overrides = {}) => new WorkerClient({ endpoint, token: TOKEN, allowInsecureLoopback: true, sleep: async () => undefined, ...overrides });
|
|
31
|
+
return { worker, rootDir, endpoint, client };
|
|
32
|
+
}
|
|
33
|
+
function inputFile(content = "job-bytes") {
|
|
34
|
+
const dir = tmp();
|
|
35
|
+
const sourcePath = path.join(dir, "in.txt");
|
|
36
|
+
fs.writeFileSync(sourcePath, content);
|
|
37
|
+
return { role: "job", relativePath: "in.txt", sha256: sha(content), sizeBytes: Buffer.byteLength(content), sourcePath };
|
|
38
|
+
}
|
|
39
|
+
function wire(input, runId = RUN) {
|
|
40
|
+
const { sourcePath: _s, ...rest } = input;
|
|
41
|
+
return {
|
|
42
|
+
version: 1,
|
|
43
|
+
runId,
|
|
44
|
+
model: "m",
|
|
45
|
+
modelVersion: "1",
|
|
46
|
+
manifestDigest: "d",
|
|
47
|
+
cachePolicy: "ephemeral",
|
|
48
|
+
gpuId: "g",
|
|
49
|
+
datacenterId: "dc",
|
|
50
|
+
images: { gpu: "g", cpu: "c" },
|
|
51
|
+
containerDiskGb: 10,
|
|
52
|
+
volumeGb: 10,
|
|
53
|
+
assetLayoutRevision: "1",
|
|
54
|
+
cpuAssetPreparation: true,
|
|
55
|
+
params: {},
|
|
56
|
+
inputs: [rest],
|
|
57
|
+
deadlineAt: "2099-01-01T00:00:00.000Z",
|
|
58
|
+
cacheKey: "k".repeat(64),
|
|
59
|
+
mode: "execute",
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
const writes = (files, exitCode = 0) => async ({ outputDir, log, setStage }) => {
|
|
63
|
+
setStage("running");
|
|
64
|
+
log("stdout", `token is ${TOKEN}\n`);
|
|
65
|
+
for (const [name, text] of Object.entries(files)) {
|
|
66
|
+
fs.mkdirSync(path.dirname(path.join(outputDir, name)), { recursive: true });
|
|
67
|
+
fs.writeFileSync(path.join(outputDir, name), text);
|
|
68
|
+
}
|
|
69
|
+
return { exitCode, runtime: { dependencyFreeze: ["x==1"], pythonVersion: "3.11" } };
|
|
70
|
+
};
|
|
71
|
+
async function untilDone(client, runId = RUN) {
|
|
72
|
+
for (let i = 0; i < 200; i += 1) {
|
|
73
|
+
const snapshot = await client.status(runId);
|
|
74
|
+
if (snapshot.state !== "running")
|
|
75
|
+
return snapshot;
|
|
76
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
77
|
+
}
|
|
78
|
+
throw new Error("job did not finish");
|
|
79
|
+
}
|
|
80
|
+
describe("worker server + client", () => {
|
|
81
|
+
it("requires the job token on every route and refuses plaintext non-loopback endpoints", async () => {
|
|
82
|
+
const { client, endpoint } = await boot(writes({}));
|
|
83
|
+
await expect(client({ token: "wrong-token-wrong-token" }).health()).rejects.toMatchObject({ code: "CLOUD_WORKER_AUTH_FAILED" });
|
|
84
|
+
await expect(client().health()).resolves.toMatchObject({ contract: 1 });
|
|
85
|
+
expect(() => new WorkerClient({ endpoint: "http://example.com", token: TOKEN })).toThrow(/HTTPS/);
|
|
86
|
+
expect(() => new WorkerClient({ endpoint: endpoint, token: TOKEN })).toThrow(/HTTPS/); // loopback http only when explicitly allowed
|
|
87
|
+
});
|
|
88
|
+
it("runs the full flow: verified upload, status with log cursor, inventory and verified collection", async () => {
|
|
89
|
+
const { client } = await boot(writes({ "boltz_results_in/predictions/a/a_model_0.cif": "CIF-DATA", "conf.json": "{}" }));
|
|
90
|
+
const c = client();
|
|
91
|
+
const input = inputFile();
|
|
92
|
+
await c.uploadInput(RUN, input);
|
|
93
|
+
await c.start(wire(input));
|
|
94
|
+
const done = await untilDone(c);
|
|
95
|
+
expect(done).toMatchObject({ state: "succeeded", exitCode: 0, runtime: { pythonVersion: "3.11" } });
|
|
96
|
+
expect(done.logs.stdout).not.toContain(TOKEN);
|
|
97
|
+
expect(done.logs.stdout).toContain("[REDACTED]");
|
|
98
|
+
expect((await c.status(RUN, done.logs.cursor)).logs.stdout).toBe("");
|
|
99
|
+
const out = tmp();
|
|
100
|
+
const inventory = await c.collect(RUN, out);
|
|
101
|
+
expect(inventory.files.map((file) => file.path)).toEqual(["boltz_results_in/predictions/a/a_model_0.cif", "conf.json"]);
|
|
102
|
+
expect(fs.readFileSync(path.join(out, "boltz_results_in/predictions/a/a_model_0.cif"), "utf8")).toBe("CIF-DATA");
|
|
103
|
+
expect(fs.readdirSync(out).filter((name) => name.startsWith(".moldesk-staging"))).toEqual([]);
|
|
104
|
+
});
|
|
105
|
+
it("rejects an upload whose bytes do not match the declared SHA-256 and a start with missing inputs", async () => {
|
|
106
|
+
const { client } = await boot(writes({}));
|
|
107
|
+
const c = client();
|
|
108
|
+
const input = inputFile();
|
|
109
|
+
await expect(c.uploadInput(RUN, { ...input, sha256: sha("something else") })).rejects.toMatchObject({ code: "CLOUD_WORKER_ERROR" });
|
|
110
|
+
await expect(c.start(wire(input))).rejects.toThrow(/was not uploaded/);
|
|
111
|
+
});
|
|
112
|
+
it("serves one job per worker", async () => {
|
|
113
|
+
const { client } = await boot(writes({ "o.txt": "x" }));
|
|
114
|
+
const c = client();
|
|
115
|
+
const input = inputFile();
|
|
116
|
+
await c.uploadInput(RUN, input);
|
|
117
|
+
await c.start(wire(input));
|
|
118
|
+
await expect(c.start(wire(input))).rejects.toThrow(/already started/);
|
|
119
|
+
await expect(c.status("other-run")).rejects.toThrow(/different job/);
|
|
120
|
+
});
|
|
121
|
+
it("reports a failing pipeline with its error and nonzero exit", async () => {
|
|
122
|
+
const { client } = await boot(async () => {
|
|
123
|
+
throw Object.assign(new Error(`boom ${TOKEN}`), { code: "INSTALL_VERIFICATION_FAILED" });
|
|
124
|
+
});
|
|
125
|
+
const c = client();
|
|
126
|
+
const input = inputFile();
|
|
127
|
+
await c.uploadInput(RUN, input);
|
|
128
|
+
await c.start(wire(input));
|
|
129
|
+
const done = await untilDone(c);
|
|
130
|
+
expect(done.state).toBe("failed");
|
|
131
|
+
expect(done.error?.code).toBe("INSTALL_VERIFICATION_FAILED");
|
|
132
|
+
expect(done.error?.message).not.toContain(TOKEN);
|
|
133
|
+
});
|
|
134
|
+
it("cancels a running job", async () => {
|
|
135
|
+
const { client } = await boot(({ signal }) => new Promise((_resolve, reject) => signal.addEventListener("abort", () => reject(new Error("aborted")))));
|
|
136
|
+
const c = client();
|
|
137
|
+
const input = inputFile();
|
|
138
|
+
await c.uploadInput(RUN, input);
|
|
139
|
+
await c.start(wire(input));
|
|
140
|
+
await c.cancel(RUN);
|
|
141
|
+
expect((await untilDone(c)).state).toBe("cancelled");
|
|
142
|
+
});
|
|
143
|
+
it("withholds results until the job finishes", async () => {
|
|
144
|
+
const { client } = await boot(({ signal }) => new Promise((_resolve, reject) => signal.addEventListener("abort", () => reject(new Error("x")))));
|
|
145
|
+
const c = client();
|
|
146
|
+
const input = inputFile();
|
|
147
|
+
await c.uploadInput(RUN, input);
|
|
148
|
+
await c.start(wire(input));
|
|
149
|
+
await expect(c.inventory(RUN)).rejects.toThrow(/has not finished/);
|
|
150
|
+
});
|
|
151
|
+
it("does not follow symlinks out of the output directory", async () => {
|
|
152
|
+
const secret = path.join(tmp(), "secret.txt");
|
|
153
|
+
fs.writeFileSync(secret, "TOP SECRET");
|
|
154
|
+
const { client, endpoint } = await boot(async ({ outputDir }) => {
|
|
155
|
+
fs.writeFileSync(path.join(outputDir, "ok.txt"), "fine");
|
|
156
|
+
fs.symlinkSync(secret, path.join(outputDir, "leak.txt"));
|
|
157
|
+
return { exitCode: 0 };
|
|
158
|
+
});
|
|
159
|
+
const c = client();
|
|
160
|
+
const input = inputFile();
|
|
161
|
+
await c.uploadInput(RUN, input);
|
|
162
|
+
await c.start(wire(input));
|
|
163
|
+
await untilDone(c);
|
|
164
|
+
expect((await c.inventory(RUN)).files.map((file) => file.path)).toEqual(["ok.txt"]);
|
|
165
|
+
for (const route of ["leak.txt", "..%2F..%2Fetc%2Fpasswd"]) {
|
|
166
|
+
const response = await fetch(`${endpoint}/v1/jobs/${RUN}/files/${route}`, { headers: { Authorization: `Bearer ${TOKEN}` } });
|
|
167
|
+
expect([400, 404]).toContain(response.status);
|
|
168
|
+
expect(await response.text()).not.toContain("TOP SECRET");
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
it("rejects corrupted downloads instead of accepting them, and promotes nothing", async () => {
|
|
172
|
+
const { client } = await boot(writes({ "a.txt": "AAAA", "b.txt": "BBBB" }));
|
|
173
|
+
const corrupting = async (input, init) => {
|
|
174
|
+
const response = await fetch(input, init);
|
|
175
|
+
if (String(input).includes("/files/b.txt"))
|
|
176
|
+
return new Response("XXXX", { status: 200, headers: { "Content-Length": "4" } });
|
|
177
|
+
return response;
|
|
178
|
+
};
|
|
179
|
+
const c = client({ fetch: corrupting });
|
|
180
|
+
const input = inputFile();
|
|
181
|
+
await c.uploadInput(RUN, input);
|
|
182
|
+
await c.start(wire(input));
|
|
183
|
+
await untilDone(c);
|
|
184
|
+
const out = tmp();
|
|
185
|
+
await expect(c.collect(RUN, out)).rejects.toMatchObject({ code: "CLOUD_TRANSFER_CORRUPT" });
|
|
186
|
+
expect(fs.readdirSync(out)).toEqual([]);
|
|
187
|
+
});
|
|
188
|
+
it("resumes an interrupted download by byte range and re-verifies the whole file", async () => {
|
|
189
|
+
const payload = "0123456789".repeat(50);
|
|
190
|
+
const { client } = await boot(writes({ "big.bin": payload }));
|
|
191
|
+
let interrupted = false;
|
|
192
|
+
let sentFirst = false;
|
|
193
|
+
const ranges = [];
|
|
194
|
+
const flaky = async (input, init) => {
|
|
195
|
+
const headers = new Headers(init?.headers);
|
|
196
|
+
if (String(input).includes("/files/big.bin"))
|
|
197
|
+
ranges.push(headers.get("range"));
|
|
198
|
+
const response = await fetch(input, init);
|
|
199
|
+
if (String(input).includes("/files/big.bin") && !interrupted) {
|
|
200
|
+
interrupted = true;
|
|
201
|
+
const bytes = Buffer.from(await response.arrayBuffer());
|
|
202
|
+
return new Response(new ReadableStream({
|
|
203
|
+
async pull(controller) {
|
|
204
|
+
if (!sentFirst) {
|
|
205
|
+
sentFirst = true;
|
|
206
|
+
controller.enqueue(bytes.subarray(0, 100));
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
await new Promise((resolve) => setTimeout(resolve, 30)); // let the reader consume the first chunk
|
|
210
|
+
controller.error(new Error("connection reset"));
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
}), { status: 200 });
|
|
214
|
+
}
|
|
215
|
+
return response;
|
|
216
|
+
};
|
|
217
|
+
const c = client({ fetch: flaky });
|
|
218
|
+
const input = inputFile();
|
|
219
|
+
await c.uploadInput(RUN, input);
|
|
220
|
+
await c.start(wire(input));
|
|
221
|
+
await untilDone(c);
|
|
222
|
+
const out = tmp();
|
|
223
|
+
await c.collect(RUN, out);
|
|
224
|
+
expect(fs.readFileSync(path.join(out, "big.bin"), "utf8")).toBe(payload);
|
|
225
|
+
expect(ranges).toEqual([null, "bytes=100-"]);
|
|
226
|
+
});
|
|
227
|
+
it("refuses to overwrite an existing local output", async () => {
|
|
228
|
+
const { client } = await boot(writes({ "a.txt": "A" }));
|
|
229
|
+
const c = client();
|
|
230
|
+
const input = inputFile();
|
|
231
|
+
await c.uploadInput(RUN, input);
|
|
232
|
+
await c.start(wire(input));
|
|
233
|
+
await untilDone(c);
|
|
234
|
+
const out = tmp();
|
|
235
|
+
fs.writeFileSync(path.join(out, "a.txt"), "existing");
|
|
236
|
+
await expect(c.collect(RUN, out)).rejects.toMatchObject({ code: "CLOUD_OUTPUT_COLLISION" });
|
|
237
|
+
expect(fs.readFileSync(path.join(out, "a.txt"), "utf8")).toBe("existing");
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
/** A body that yields `first` and then never delivers or ends (a stalled connection); like real fetch, aborting the request errors it. */
|
|
241
|
+
function stalledBody(first, signal) {
|
|
242
|
+
let sent = false;
|
|
243
|
+
return new ReadableStream({
|
|
244
|
+
start(controller) {
|
|
245
|
+
signal?.addEventListener("abort", () => controller.error(new DOMException("aborted", "AbortError")), { once: true });
|
|
246
|
+
},
|
|
247
|
+
pull(controller) {
|
|
248
|
+
if (!sent) {
|
|
249
|
+
sent = true;
|
|
250
|
+
controller.enqueue(first);
|
|
251
|
+
return undefined;
|
|
252
|
+
}
|
|
253
|
+
return new Promise(() => undefined); // never resolves
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
describe("timeouts and cancellation while streaming", () => {
|
|
258
|
+
const payload = "0123456789".repeat(50);
|
|
259
|
+
async function finishedJob(overrides, wrap) {
|
|
260
|
+
const { client } = await boot(writes({ "big.bin": payload }));
|
|
261
|
+
const setup = client();
|
|
262
|
+
const input = inputFile();
|
|
263
|
+
await setup.uploadInput(RUN, input);
|
|
264
|
+
await setup.start(wire(input));
|
|
265
|
+
await untilDone(setup);
|
|
266
|
+
const real = fetch;
|
|
267
|
+
return client({ ...overrides, ...(wrap ? { fetch: wrap(real) } : {}) });
|
|
268
|
+
}
|
|
269
|
+
it("recovers from a stalled download by resuming from the bytes already received", async () => {
|
|
270
|
+
const ranges = [];
|
|
271
|
+
let stalled = false;
|
|
272
|
+
const c = await finishedJob({ idleTimeoutMs: 400 }, (real) => async (input, init) => {
|
|
273
|
+
if (String(input).includes("/files/big.bin"))
|
|
274
|
+
ranges.push(new Headers(init?.headers).get("range"));
|
|
275
|
+
const response = await real(input, init);
|
|
276
|
+
if (String(input).includes("/files/big.bin") && !stalled) {
|
|
277
|
+
stalled = true;
|
|
278
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
279
|
+
return new Response(stalledBody(bytes.subarray(0, 100), init?.signal), { status: 200 });
|
|
280
|
+
}
|
|
281
|
+
return response;
|
|
282
|
+
});
|
|
283
|
+
const out = tmp();
|
|
284
|
+
await c.collect(RUN, out);
|
|
285
|
+
expect(fs.readFileSync(path.join(out, "big.bin"), "utf8")).toBe(payload);
|
|
286
|
+
expect(ranges).toEqual([null, "bytes=100-"]);
|
|
287
|
+
});
|
|
288
|
+
it("gives up on a download that keeps stalling instead of hanging, and promotes nothing", async () => {
|
|
289
|
+
const c = await finishedJob({ idleTimeoutMs: 40 }, (real) => async (input, init) => {
|
|
290
|
+
const response = await real(input, init);
|
|
291
|
+
if (!String(input).includes("/files/big.bin"))
|
|
292
|
+
return response;
|
|
293
|
+
return new Response(stalledBody(new Uint8Array(await response.arrayBuffer()).subarray(0, 10), init?.signal), { status: 200 });
|
|
294
|
+
});
|
|
295
|
+
const out = tmp();
|
|
296
|
+
const started = Date.now();
|
|
297
|
+
await expect(c.collect(RUN, out)).rejects.toMatchObject({ code: "CLOUD_TRANSFER_FAILED", message: expect.stringMatching(/stalled/) });
|
|
298
|
+
expect(Date.now() - started).toBeLessThan(3000);
|
|
299
|
+
expect(fs.readdirSync(out)).toEqual([]);
|
|
300
|
+
});
|
|
301
|
+
it("cancellation interrupts a stalled body immediately, not after the idle timeout", async () => {
|
|
302
|
+
const controller = new AbortController();
|
|
303
|
+
const c = await finishedJob({ idleTimeoutMs: 60_000 }, (real) => async (input, init) => {
|
|
304
|
+
const response = await real(input, init);
|
|
305
|
+
if (!String(input).includes("/files/big.bin"))
|
|
306
|
+
return response;
|
|
307
|
+
return new Response(stalledBody(new Uint8Array(await response.arrayBuffer()).subarray(0, 10), init?.signal), { status: 200 });
|
|
308
|
+
});
|
|
309
|
+
const out = tmp();
|
|
310
|
+
setTimeout(() => controller.abort(), 30);
|
|
311
|
+
const started = Date.now();
|
|
312
|
+
await expect(c.collect(RUN, out, { signal: controller.signal })).rejects.toMatchObject({ code: "RUN_CANCELLED" });
|
|
313
|
+
expect(Date.now() - started).toBeLessThan(2000);
|
|
314
|
+
expect(fs.readdirSync(out)).toEqual([]);
|
|
315
|
+
});
|
|
316
|
+
it("times out a JSON response whose body never finishes", async () => {
|
|
317
|
+
const { client } = await boot(writes({}));
|
|
318
|
+
const c = client({
|
|
319
|
+
requestTimeoutMs: 50,
|
|
320
|
+
fetch: async (_input, init) => new Response(stalledBody(new TextEncoder().encode('{"stage":'), init?.signal), { status: 200 }),
|
|
321
|
+
});
|
|
322
|
+
await expect(c.status(RUN)).rejects.toMatchObject({ code: "CLOUD_WORKER_TIMEOUT" });
|
|
323
|
+
});
|
|
324
|
+
it("a client-level signal cancels in-flight JSON requests too", async () => {
|
|
325
|
+
const controller = new AbortController();
|
|
326
|
+
const { client } = await boot(writes({}));
|
|
327
|
+
const c = client({ signal: controller.signal, requestTimeoutMs: 60_000, fetch: async (_input, init) => new Response(stalledBody(new TextEncoder().encode("{"), init?.signal), { status: 200 }) });
|
|
328
|
+
setTimeout(() => controller.abort(), 20);
|
|
329
|
+
await expect(c.status(RUN)).rejects.toMatchObject({ code: "RUN_CANCELLED" });
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
describe("bounded log tail", () => {
|
|
333
|
+
const chatty = async ({ log }) => {
|
|
334
|
+
for (let i = 1; i <= 20; i += 1)
|
|
335
|
+
log("stdout", `line-${String(i).padStart(2, "0")}-${"x".repeat(90)}\n`); // ~100 bytes each
|
|
336
|
+
return { exitCode: 0 };
|
|
337
|
+
};
|
|
338
|
+
it("reports exactly how many chunks a late reader missed, and keeps the newest", async () => {
|
|
339
|
+
const { client } = await boot(chatty, TOKEN, 450);
|
|
340
|
+
const c = client();
|
|
341
|
+
const input = inputFile();
|
|
342
|
+
await c.uploadInput(RUN, input);
|
|
343
|
+
await c.start(wire(input));
|
|
344
|
+
await untilDone(c);
|
|
345
|
+
const all = await c.status(RUN, 0);
|
|
346
|
+
const retained = all.logs.stdout.split("\n").filter(Boolean);
|
|
347
|
+
expect(retained.length).toBeGreaterThan(0);
|
|
348
|
+
expect(retained.length).toBeLessThan(20);
|
|
349
|
+
expect(retained.at(-1)).toContain("line-20");
|
|
350
|
+
expect(all.logs.lostEntries).toBe(20 - retained.length);
|
|
351
|
+
expect(all.logs.evictedBytes).toBeGreaterThanOrEqual((20 - retained.length) * 99);
|
|
352
|
+
expect(all.logs.cursor).toBe(20);
|
|
353
|
+
});
|
|
354
|
+
it("reports nothing lost to a reader that kept up, or one that is partly behind", async () => {
|
|
355
|
+
const { client } = await boot(chatty, TOKEN, 450);
|
|
356
|
+
const c = client();
|
|
357
|
+
const input = inputFile();
|
|
358
|
+
await c.uploadInput(RUN, input);
|
|
359
|
+
await c.start(wire(input));
|
|
360
|
+
await untilDone(c);
|
|
361
|
+
const retainedCount = (await c.status(RUN, 0)).logs.stdout.split("\n").filter(Boolean).length;
|
|
362
|
+
expect((await c.status(RUN, 20)).logs.lostEntries).toBeUndefined();
|
|
363
|
+
// A reader positioned exactly at the oldest retained chunk lost nothing.
|
|
364
|
+
expect((await c.status(RUN, 20 - retainedCount)).logs.lostEntries).toBeUndefined();
|
|
365
|
+
// One chunk earlier and exactly one chunk was missed.
|
|
366
|
+
expect((await c.status(RUN, 20 - retainedCount - 1)).logs.lostEntries).toBe(1);
|
|
367
|
+
});
|
|
368
|
+
it("never reports loss when the buffer was not exceeded", async () => {
|
|
369
|
+
const { client } = await boot(chatty, TOKEN, 1_000_000);
|
|
370
|
+
const c = client();
|
|
371
|
+
const input = inputFile();
|
|
372
|
+
await c.uploadInput(RUN, input);
|
|
373
|
+
await c.start(wire(input));
|
|
374
|
+
await untilDone(c);
|
|
375
|
+
const all = await c.status(RUN, 0);
|
|
376
|
+
expect(all.logs.lostEntries).toBeUndefined();
|
|
377
|
+
expect(all.logs.stdout.split("\n").filter(Boolean)).toHaveLength(20);
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
describe("validateInventory", () => {
|
|
381
|
+
const entry = (p) => ({ path: p, sizeBytes: 1, sha256: "a".repeat(64) });
|
|
382
|
+
it("rejects traversal, absolute, backslash, duplicate and malformed entries", () => {
|
|
383
|
+
for (const bad of ["../x", "/abs", "a\\b", "a//b", "./a", ""]) {
|
|
384
|
+
expect(() => validateInventory({ version: 1, files: [entry(bad)] })).toThrow(/unsafe or malformed/);
|
|
385
|
+
}
|
|
386
|
+
expect(() => validateInventory({ version: 1, files: [entry("A.txt"), entry("a.TXT")] })).toThrow(/more than once/);
|
|
387
|
+
expect(() => validateInventory({ version: 1, files: [{ path: "a", sizeBytes: -1, sha256: "a".repeat(64) }] })).toThrow();
|
|
388
|
+
expect(() => validateInventory({ version: 2, files: [] })).toThrow(/invalid inventory/);
|
|
389
|
+
expect(() => validateInventory({ version: 1, files: [entry("ok/a.txt"), entry("b")] })).not.toThrow();
|
|
390
|
+
});
|
|
391
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-Pod deadline watchdog. It terminates the Pod it runs in at the approved deadline using the
|
|
3
|
+
* Pod-scoped key Runpod injects (RUNPOD_API_KEY / RUNPOD_POD_ID), which was observed to terminate
|
|
4
|
+
* only its own Pod. Best effort only: it cannot help when the container never boots or the key is
|
|
5
|
+
* not injected, and its expiry behavior is unverified (docs/runpod-spike-findings.md). The user is
|
|
6
|
+
* told, before approving, that automatic shutdown is not guaranteed.
|
|
7
|
+
*/
|
|
8
|
+
export interface WatchdogOptions {
|
|
9
|
+
deadlineAt: string;
|
|
10
|
+
podId: string;
|
|
11
|
+
/** Pod-scoped key; never the account key and never logged. */
|
|
12
|
+
apiKey: string;
|
|
13
|
+
baseUrl?: string;
|
|
14
|
+
fetch?: typeof fetch;
|
|
15
|
+
now?: () => number;
|
|
16
|
+
setTimer?: (callback: () => void, ms: number) => {
|
|
17
|
+
unref?: () => void;
|
|
18
|
+
} | unknown;
|
|
19
|
+
retryMs?: number;
|
|
20
|
+
log?: (message: string) => void;
|
|
21
|
+
}
|
|
22
|
+
export interface Watchdog {
|
|
23
|
+
/** Resolves after a termination request was accepted (2xx or 404). */
|
|
24
|
+
done: Promise<void>;
|
|
25
|
+
stop(): void;
|
|
26
|
+
}
|
|
27
|
+
export declare function startDeadlineWatchdog(options: WatchdogOptions): Watchdog;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export function startDeadlineWatchdog(options) {
|
|
2
|
+
const fetchFn = options.fetch ?? fetch;
|
|
3
|
+
const now = options.now ?? Date.now;
|
|
4
|
+
const base = options.baseUrl ?? "https://api.runpod.io/v2";
|
|
5
|
+
const retryMs = options.retryMs ?? 30_000;
|
|
6
|
+
const setTimer = options.setTimer ?? ((callback, ms) => setTimeout(callback, ms));
|
|
7
|
+
const log = options.log ?? (() => undefined);
|
|
8
|
+
let stopped = false;
|
|
9
|
+
let resolveDone;
|
|
10
|
+
const done = new Promise((resolve) => {
|
|
11
|
+
resolveDone = resolve;
|
|
12
|
+
});
|
|
13
|
+
const attempt = async () => {
|
|
14
|
+
if (stopped)
|
|
15
|
+
return;
|
|
16
|
+
try {
|
|
17
|
+
const response = await fetchFn(`${base}/pods/${encodeURIComponent(options.podId)}/action`, {
|
|
18
|
+
method: "POST",
|
|
19
|
+
headers: { Authorization: `Bearer ${options.apiKey}`, "Content-Type": "application/json", "User-Agent": "moldesk-watchdog/1" },
|
|
20
|
+
body: JSON.stringify({ action: "terminate" }),
|
|
21
|
+
});
|
|
22
|
+
if (response.ok || response.status === 404) {
|
|
23
|
+
log(`deadline reached; termination requested (status ${response.status})`);
|
|
24
|
+
return resolveDone();
|
|
25
|
+
}
|
|
26
|
+
log(`termination attempt failed with status ${response.status}`);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
log("termination attempt failed (network)");
|
|
30
|
+
}
|
|
31
|
+
schedule(retryMs);
|
|
32
|
+
};
|
|
33
|
+
function schedule(ms) {
|
|
34
|
+
const timer = setTimer(() => void attempt(), ms);
|
|
35
|
+
// Must keep the process alive until it fires; never unref.
|
|
36
|
+
void timer;
|
|
37
|
+
}
|
|
38
|
+
const remaining = Date.parse(options.deadlineAt) - now();
|
|
39
|
+
schedule(Math.max(0, remaining));
|
|
40
|
+
return {
|
|
41
|
+
done,
|
|
42
|
+
stop: () => {
|
|
43
|
+
stopped = true;
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { WorkerBundle } from "@moldesk/runtime";
|
|
2
|
+
/**
|
|
3
|
+
* The published, checksummed worker bundle matching this release. Intentionally unset until a
|
|
4
|
+
* release bundle has been built, published and validated on a clean machine; cloud runs refuse to
|
|
5
|
+
* start without one. See packages/core/scripts/build-worker.mjs.
|
|
6
|
+
*/
|
|
7
|
+
export declare const RELEASED_WORKER_BUNDLE: WorkerBundle | undefined;
|
|
8
|
+
/**
|
|
9
|
+
* Environment override for development and self-hosting: both values are required, the URL must be
|
|
10
|
+
* HTTPS and the checksum a full SHA-256, so a bundle is never fetched without verification.
|
|
11
|
+
*/
|
|
12
|
+
export declare function resolveWorkerBundle(env?: NodeJS.ProcessEnv): WorkerBundle | undefined;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { MoldeskError } from "../errors.js";
|
|
2
|
+
/**
|
|
3
|
+
* The published, checksummed worker bundle matching this release. Intentionally unset until a
|
|
4
|
+
* release bundle has been built, published and validated on a clean machine; cloud runs refuse to
|
|
5
|
+
* start without one. See packages/core/scripts/build-worker.mjs.
|
|
6
|
+
*/
|
|
7
|
+
export const RELEASED_WORKER_BUNDLE = undefined;
|
|
8
|
+
/**
|
|
9
|
+
* Environment override for development and self-hosting: both values are required, the URL must be
|
|
10
|
+
* HTTPS and the checksum a full SHA-256, so a bundle is never fetched without verification.
|
|
11
|
+
*/
|
|
12
|
+
export function resolveWorkerBundle(env = process.env) {
|
|
13
|
+
const url = env.MOLDESK_WORKER_BUNDLE_URL;
|
|
14
|
+
const sha256 = env.MOLDESK_WORKER_BUNDLE_SHA256;
|
|
15
|
+
if (url === undefined && sha256 === undefined)
|
|
16
|
+
return RELEASED_WORKER_BUNDLE;
|
|
17
|
+
if (!url || !sha256 || !/^https:\/\//.test(url) || !/^[0-9a-f]{64}$/i.test(sha256)) {
|
|
18
|
+
throw new MoldeskError({
|
|
19
|
+
code: "CLOUD_WORKER_BUNDLE_INVALID",
|
|
20
|
+
message: "MOLDESK_WORKER_BUNDLE_URL and MOLDESK_WORKER_BUNDLE_SHA256 must both be set, with an https URL and a 64-character SHA-256.",
|
|
21
|
+
remediation: "Set both variables, or unset them to use the bundle published with this release.",
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
return { url, sha256: sha256.toLowerCase() };
|
|
25
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { RELEASED_WORKER_BUNDLE, resolveWorkerBundle } from "./worker-release.js";
|
|
3
|
+
describe("resolveWorkerBundle", () => {
|
|
4
|
+
it("has no released bundle yet, so cloud runs refuse by default", () => {
|
|
5
|
+
expect(RELEASED_WORKER_BUNDLE).toBeUndefined();
|
|
6
|
+
expect(resolveWorkerBundle({})).toBeUndefined();
|
|
7
|
+
});
|
|
8
|
+
it("accepts an https URL with a SHA-256 override and normalizes case", () => {
|
|
9
|
+
expect(resolveWorkerBundle({ MOLDESK_WORKER_BUNDLE_URL: "https://example.com/w.tgz", MOLDESK_WORKER_BUNDLE_SHA256: "A".repeat(64) })).toEqual({ url: "https://example.com/w.tgz", sha256: "a".repeat(64) });
|
|
10
|
+
});
|
|
11
|
+
it("rejects partial, non-https or unverifiable overrides", () => {
|
|
12
|
+
for (const env of [
|
|
13
|
+
{ MOLDESK_WORKER_BUNDLE_URL: "https://example.com/w.tgz" },
|
|
14
|
+
{ MOLDESK_WORKER_BUNDLE_SHA256: "a".repeat(64) },
|
|
15
|
+
{ MOLDESK_WORKER_BUNDLE_URL: "http://example.com/w.tgz", MOLDESK_WORKER_BUNDLE_SHA256: "a".repeat(64) },
|
|
16
|
+
{ MOLDESK_WORKER_BUNDLE_URL: "https://example.com/w.tgz", MOLDESK_WORKER_BUNDLE_SHA256: "short" },
|
|
17
|
+
]) {
|
|
18
|
+
expect(() => resolveWorkerBundle(env)).toThrow(expect.objectContaining({ code: "CLOUD_WORKER_BUNDLE_INVALID" }));
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
});
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
export { collectSystemReport, getSystemReport, isImageCachedLocally, pullDockerImage, probeDockerGpuAccess, } from "@moldesk/runtime";
|
|
1
|
+
export { collectSystemReport, getSystemReport, isImageCachedLocally, pullDockerImage, probeDockerGpuAccess, CredentialStore, getMoldeskPaths, } from "@moldesk/runtime";
|
|
2
|
+
export type { CloudPod, CloudProvider, CloudVolume, SecretBackend } from "@moldesk/runtime";
|
|
2
3
|
export type { DiskInfo, DockerCapability, NvidiaCapability, NvidiaGpu, SystemReport, SystemReportDeps, ToolCapability, } from "@moldesk/runtime";
|
|
3
|
-
export { listAvailableModels } from "@moldesk/registry";
|
|
4
|
+
export { listAvailableModels, parseManifest } from "@moldesk/registry";
|
|
4
5
|
export type { AssetSpec, CommandSpec, HardwareRequirements, InputSpec, ModelManifestV1, ModelStatus, OutputSpec, PlatformId, PostInstallHook, PythonRequirement, PythonRuntimeSpec, RuntimeKind, RuntimeSpec, SourceSpec, } from "@moldesk/registry";
|
|
5
6
|
export { detectHostPlatformId, selectPythonRuntime } from "@moldesk/registry";
|
|
6
7
|
export { MoldeskError } from "./errors.js";
|
|
@@ -11,8 +12,21 @@ export { listInstalledModels, } from "./install-state.js";
|
|
|
11
12
|
export type { InstalledModel, InstalledPostInstallResult } from "./install-state.js";
|
|
12
13
|
export { createInstallationPlan, installModel, INSTALL_CONFIRMATION_DISK_BYTES, INSTALL_CONFIRMATION_DOWNLOAD_BYTES, requiresInstallConfirmation, runtimeFingerprint, uninstallModel, } from "./installation.js";
|
|
13
14
|
export type { InstallationPlanResult, InstallCostEstimate, InstallModelOptions, InstallModelResult, UninstallModelOptions, } from "./installation.js";
|
|
15
|
+
export { CloudJournal, cloudResourceName } from "./cloud/state.js";
|
|
16
|
+
export type { CloudCacheRecord, CloudCacheState, CloudCachePolicy, CloudJobRecord, CloudJobStage, CloudJobStatus, CloudResourceRecord, CloudResourceRole, CloudResourceState, NewCloudJob, ProviderResourceView, ReconciliationReport, } from "./cloud/state.js";
|
|
17
|
+
export { connectCloudProvider, defaultProviderFactory, disconnectCloudProvider, getCloudStatus, listCloudResources, } from "./cloud/accounts.js";
|
|
18
|
+
export type { CloudAccountDeps, CloudProviderFactory, CloudResourceListing, CloudStatus, ConnectResult, } from "./cloud/accounts.js";
|
|
19
|
+
export { planCloudRun, runCloudModel, confirmRunResourcesGone } from "./cloud/run.js";
|
|
20
|
+
export type { CleanupOutcome, CloudPlan, CloudQuoteOptions, CloudRunDeps, CloudRunModelOptions, CloudRunOptions, ResolvedModel, } from "./cloud/run.js";
|
|
21
|
+
export { assertApprovalValid, buildQuote, APPROVAL_MAX_AGE_MS, DEFAULT_MAX_LIFETIME_SECONDS, LIFETIME_RISK_WARNING, STORAGE_CHARGES_WARNING } from "./cloud/quote.js";
|
|
22
|
+
export type { CloudAcknowledgment, CloudApproval, CloudQuote, QuoteInputs, QuoteRange, QuoteWarning } from "./cloud/quote.js";
|
|
23
|
+
export { executeCloudCacheDeletion, listCloudCaches, planCloudCacheDeletion, recoverCloudRun } from "./cloud/manage.js";
|
|
24
|
+
export type { CacheDeletionPlan, CacheDeletionResult, CloudCacheListing, RecoverDeps, RecoverResult } from "./cloud/manage.js";
|
|
25
|
+
export { createRunpodCloudDeps } from "./cloud/deps.js";
|
|
26
|
+
export type { RunpodDepsOptions } from "./cloud/deps.js";
|
|
27
|
+
export { resolveWorkerBundle } from "./cloud/worker-release.js";
|
|
14
28
|
export { runModel } from "./run.js";
|
|
15
|
-
export type { RunArtifact, RunModelOptions, RunModelResult, RunRecord, RunWarning } from "./run.js";
|
|
29
|
+
export type { RunArtifact, RunCompute, RunModelOptions, RunModelResult, RunRecord, RunWarning } from "./run.js";
|
|
16
30
|
export { openInBrowser } from "@moldesk/runtime";
|
|
17
31
|
export type { OpenResult } from "@moldesk/runtime";
|
|
18
32
|
export { applyReportUpdate, asMoldeskError, createStandaloneReport, prepareReportUpdate } from "./visualization.js";
|
package/dist/index.js
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
|
-
export { collectSystemReport, getSystemReport, isImageCachedLocally, pullDockerImage, probeDockerGpuAccess, } from "@moldesk/runtime";
|
|
2
|
-
export { listAvailableModels } from "@moldesk/registry";
|
|
1
|
+
export { collectSystemReport, getSystemReport, isImageCachedLocally, pullDockerImage, probeDockerGpuAccess, CredentialStore, getMoldeskPaths, } from "@moldesk/runtime";
|
|
2
|
+
export { listAvailableModels, parseManifest } from "@moldesk/registry";
|
|
3
3
|
export { detectHostPlatformId, selectPythonRuntime } from "@moldesk/registry";
|
|
4
4
|
export { MoldeskError } from "./errors.js";
|
|
5
5
|
export { evaluateCompatibility, shortReason } from "./compatibility.js";
|
|
6
6
|
export { listInstalledModels, } from "./install-state.js";
|
|
7
7
|
export { createInstallationPlan, installModel, INSTALL_CONFIRMATION_DISK_BYTES, INSTALL_CONFIRMATION_DOWNLOAD_BYTES, requiresInstallConfirmation, runtimeFingerprint, uninstallModel, } from "./installation.js";
|
|
8
|
+
export { CloudJournal, cloudResourceName } from "./cloud/state.js";
|
|
9
|
+
export { connectCloudProvider, defaultProviderFactory, disconnectCloudProvider, getCloudStatus, listCloudResources, } from "./cloud/accounts.js";
|
|
10
|
+
export { planCloudRun, runCloudModel, confirmRunResourcesGone } from "./cloud/run.js";
|
|
11
|
+
export { assertApprovalValid, buildQuote, APPROVAL_MAX_AGE_MS, DEFAULT_MAX_LIFETIME_SECONDS, LIFETIME_RISK_WARNING, STORAGE_CHARGES_WARNING } from "./cloud/quote.js";
|
|
12
|
+
export { executeCloudCacheDeletion, listCloudCaches, planCloudCacheDeletion, recoverCloudRun } from "./cloud/manage.js";
|
|
13
|
+
export { createRunpodCloudDeps } from "./cloud/deps.js";
|
|
14
|
+
export { resolveWorkerBundle } from "./cloud/worker-release.js";
|
|
8
15
|
export { runModel } from "./run.js";
|
|
9
16
|
export { openInBrowser } from "@moldesk/runtime";
|
|
10
17
|
export { applyReportUpdate, asMoldeskError, createStandaloneReport, prepareReportUpdate } from "./visualization.js";
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { type ModelAdapterDefinition, type ResolveParamDefaultsContext, type RunContext } from "@moldesk/adapters";
|
|
2
|
+
import type { ModelManifestV1, RuntimeKind } from "@moldesk/registry";
|
|
3
|
+
import { type MoldeskPaths } from "@moldesk/runtime";
|
|
4
|
+
import type { RunModelResult, RunRecord } from "./run.js";
|
|
5
|
+
export declare function toErrorInfo(error: unknown, fallbackCode: string): {
|
|
6
|
+
code: string;
|
|
7
|
+
message: string;
|
|
8
|
+
};
|
|
9
|
+
export declare function writeRunRecord(runDirPath: string, record: RunRecord): void;
|
|
10
|
+
export interface PreparedRunParams {
|
|
11
|
+
suppliedParams: Record<string, string>;
|
|
12
|
+
effectiveParams: Record<string, unknown>;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Step 3 shared by local and cloud runs: applies platform-dependent adapter defaults (an explicit
|
|
16
|
+
* `--param` always wins), then validates. Rejects with INVALID_RUN_PARAMS before any run directory exists.
|
|
17
|
+
*/
|
|
18
|
+
export declare function prepareRunParams(adapter: ModelAdapterDefinition, manifestName: string, installed: ResolveParamDefaultsContext["installed"], params: Record<string, string> | undefined): PreparedRunParams;
|
|
19
|
+
export interface RunDirectories {
|
|
20
|
+
runId: string;
|
|
21
|
+
runDirPath: string;
|
|
22
|
+
inputDir: string;
|
|
23
|
+
outputDir: string;
|
|
24
|
+
}
|
|
25
|
+
/** Allocates the run ID and its `input/` and `output/` directories. */
|
|
26
|
+
export declare function allocateRunDirectory(paths: MoldeskPaths): RunDirectories;
|
|
27
|
+
export interface CreateRunSnapshotParams {
|
|
28
|
+
dirs: RunDirectories;
|
|
29
|
+
manifest: ModelManifestV1;
|
|
30
|
+
adapter: ModelAdapterDefinition;
|
|
31
|
+
runtime: RuntimeKind;
|
|
32
|
+
installationFingerprint: string;
|
|
33
|
+
resolvedInputPath: string;
|
|
34
|
+
suppliedParams: Record<string, unknown>;
|
|
35
|
+
effectiveParams: Record<string, unknown>;
|
|
36
|
+
signal?: AbortSignal;
|
|
37
|
+
}
|
|
38
|
+
export type RunSnapshot = {
|
|
39
|
+
ok: true;
|
|
40
|
+
record: RunRecord;
|
|
41
|
+
startedAt: string;
|
|
42
|
+
storedInputPath: string;
|
|
43
|
+
companionInputs: Array<{
|
|
44
|
+
id: string;
|
|
45
|
+
path: string;
|
|
46
|
+
}>;
|
|
47
|
+
}
|
|
48
|
+
/** Snapshotting failed after the run directory was allocated; `record` is already finalized on disk. */
|
|
49
|
+
| {
|
|
50
|
+
ok: false;
|
|
51
|
+
record: RunRecord;
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* Copies and hashes the job input (and declared companion inputs), captures hardware, and writes
|
|
55
|
+
* the initial `preparing` record. A failure here finalizes `run.json` as failed/cancelled instead
|
|
56
|
+
* of rejecting, since the run directory already exists.
|
|
57
|
+
*/
|
|
58
|
+
export declare function createRunSnapshot(params: CreateRunSnapshotParams): Promise<RunSnapshot>;
|
|
59
|
+
/** Checksums and sizes the adapter's collected outputs (spec step 12). */
|
|
60
|
+
export declare function collectRunOutputs(adapter: ModelAdapterDefinition, context: RunContext): Promise<RunRecord["outputs"]>;
|
|
61
|
+
export interface FinalizeRunParams {
|
|
62
|
+
adapter: ModelAdapterDefinition;
|
|
63
|
+
context: RunContext;
|
|
64
|
+
runDirPath: string;
|
|
65
|
+
record: RunRecord;
|
|
66
|
+
startedAt: string;
|
|
67
|
+
signal?: AbortSignal;
|
|
68
|
+
/** Create the offline report for a succeeded run. Default true. */
|
|
69
|
+
visualize?: boolean;
|
|
70
|
+
/** Copy outputs here after a succeeded run; never changes the run's own status. */
|
|
71
|
+
outputDir?: string;
|
|
72
|
+
}
|
|
73
|
+
/** Derived report, final atomic `run.json` write, and the post-finalization `--output` copy. */
|
|
74
|
+
export declare function finalizeRun(params: FinalizeRunParams): Promise<RunModelResult>;
|