@pipelex/sdk 0.17.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,841 @@
1
+ /**
2
+ * The artifact stack — the download twin of `prepareInputs`, in layers so each
3
+ * operation is usable without the next:
4
+ *
5
+ * - {@link collectArtifacts} — a pure walk of any JSON value for the strings that
6
+ * ARE `pipelex-storage://` references. No network, no key.
7
+ * - {@link resolveArtifacts} — the platform's bulk resolve route over a whole
8
+ * list, chunked at the route's bound, one verdict per reference.
9
+ * - {@link fetchArtifact} — a bounded `Response` for one reference: resolved
10
+ * fresh, timed out, redirects refused, the byte cap enforced mid-stream, no
11
+ * credentials forwarded, headers untouched.
12
+ * - {@link downloadArtifacts} — Node-only: a run's produced files saved under a
13
+ * directory by a bounded pool of workers, as a produced verdict.
14
+ *
15
+ * A produced file is never embedded in a run's results: the content carries its
16
+ * durable `pipelex-storage://` reference beside a signed `public_url` that expires
17
+ * on the provider's schedule. Nothing here reads that embedded link — every link
18
+ * is minted fresh by the platform, and re-minted when it has expired by the time a
19
+ * worker reaches it. See `docs/artifact-download.md`.
20
+ *
21
+ * `node:fs/promises`, `node:path` and `undici` are imported dynamically, behind
22
+ * `isNodeRuntime()`, so the module's static import graph stays free of Node
23
+ * builtins: `collectArtifacts` and `resolveArtifacts` run anywhere, `fetchArtifact`
24
+ * runs anywhere the global `fetch` does (the dispatcher is a Node refinement), and
25
+ * only `downloadArtifacts` touches a filesystem.
26
+ */
27
+ import { ApiResponseError, ArtifactAuthenticationError, ArtifactFetchError, ArtifactOperationError, RunFailedError, RunStillRunningError, ScopeUnavailableError, } from "./errors.js";
28
+ import { isNodeRuntime } from "./upload.js";
29
+ // ── Constants ────────────────────────────────────────────────────────
30
+ /** The scheme of a durable storage reference. */
31
+ export const PIPELEX_STORAGE_SCHEME = "pipelex-storage://";
32
+ /**
33
+ * How many references one bulk resolve request takes — the route's bound, fixed
34
+ * by the platform contract rather than configured per deployment. A longer list
35
+ * is a `422`, so {@link resolveArtifacts} chunks at this size.
36
+ */
37
+ export const BULK_RESOLVE_MAX_URIS = 100;
38
+ /**
39
+ * The per-file byte cap. An accident guard against filling a disk from a
40
+ * runaway output, not a judgment about artifact size: a produced file is
41
+ * server-side, so a caller cannot shrink it the way they can shrink an upload.
42
+ */
43
+ export const DEFAULT_ARTIFACT_MAX_BYTES = 1024 * 1024 * 1024;
44
+ /** The per-file budget for connecting, receiving the headers and reading the body. */
45
+ export const DEFAULT_ARTIFACT_TIMEOUT_MS = 120_000;
46
+ /** The cap on the bytes one `downloadArtifacts` call writes in total. */
47
+ export const DEFAULT_DOWNLOAD_MAX_TOTAL_BYTES = 4 * 1024 * 1024 * 1024;
48
+ /** How many artifacts one `downloadArtifacts` call has in flight at once. */
49
+ export const DEFAULT_DOWNLOAD_CONCURRENCY = 4;
50
+ /**
51
+ * A link this close to its `expires_at` is re-resolved rather than fetched: the
52
+ * object store checks the signature when the request arrives, and a few seconds
53
+ * of clock skew between the platform and this process must not turn a link the
54
+ * platform still considers live into a `403`.
55
+ */
56
+ const EXPIRY_MARGIN_MS = 10_000;
57
+ /** Longest filename `downloadArtifacts` writes, extension included. */
58
+ const MAX_FILENAME_LENGTH = 128;
59
+ /** Ceiling on collision suffixes before the never-overwrite rule gives up. */
60
+ const MAX_UNIQUE_ATTEMPTS = 10_000;
61
+ /**
62
+ * The extension to add when the storage key has none and the resolved content
63
+ * type is one of the artifact types a run produces. Deliberately short: an
64
+ * unknown type simply gets no extension, never a guessed one.
65
+ */
66
+ const EXTENSION_BY_CONTENT_TYPE = {
67
+ "image/png": ".png",
68
+ "image/jpeg": ".jpg",
69
+ "image/webp": ".webp",
70
+ "image/gif": ".gif",
71
+ "image/svg+xml": ".svg",
72
+ "application/pdf": ".pdf",
73
+ "text/plain": ".txt",
74
+ "text/markdown": ".md",
75
+ "text/html": ".html",
76
+ "text/csv": ".csv",
77
+ "application/json": ".json",
78
+ };
79
+ // ── collectArtifacts ─────────────────────────────────────────────────
80
+ /**
81
+ * Every `pipelex-storage://` reference inside a JSON-shaped value, deduplicated,
82
+ * in discovery order. A string counts only when it IS a reference — the whole
83
+ * string, scheme first, with something after the scheme; text that merely
84
+ * contains one does not. The scheme is unambiguous, so this walk is a contract
85
+ * rather than a heuristic: the runtime serializes a produced image or document
86
+ * as content carrying its reference in `url`, beside an expiring `public_url`
87
+ * this walk ignores. Pure — no network, no key — so a consumer can count or
88
+ * list a run's produced files without resolving any of them.
89
+ */
90
+ export function collectArtifacts(value) {
91
+ const found = new Set();
92
+ walkForReferences(value, found);
93
+ return [...found];
94
+ }
95
+ function walkForReferences(value, found) {
96
+ if (typeof value === "string") {
97
+ if (isStorageReference(value))
98
+ found.add(value);
99
+ return;
100
+ }
101
+ if (Array.isArray(value)) {
102
+ for (const item of value)
103
+ walkForReferences(item, found);
104
+ return;
105
+ }
106
+ if (typeof value === "object" && value !== null) {
107
+ for (const entry of Object.values(value))
108
+ walkForReferences(entry, found);
109
+ }
110
+ }
111
+ /** A string that is a storage reference: the scheme, then at least one character. */
112
+ export function isStorageReference(value) {
113
+ return value.startsWith(PIPELEX_STORAGE_SCHEME) && value.length > PIPELEX_STORAGE_SCHEME.length;
114
+ }
115
+ // ── artifactFilename ─────────────────────────────────────────────────
116
+ /**
117
+ * The bare filename a storage reference is saved under: the last segment of the
118
+ * storage key, reduced to a conservative character set so it can never name
119
+ * anything but a regular file directly inside the target directory. Path
120
+ * separators are the split point, so no traversal survives; leading dots are
121
+ * stripped, so no hidden file and no `..`; everything outside `[A-Za-z0-9._-]`
122
+ * becomes `_`; an empty result falls back to a numbered `artifact-N`. Length is
123
+ * capped with the extension preserved, and an extension is added from the
124
+ * content type when the key carries none. A collision on disk is not this
125
+ * function's concern: `downloadArtifacts` suffixes the stem (`name-1.ext`) on
126
+ * exclusive creation, so a file is never overwritten.
127
+ */
128
+ export function artifactFilename(uri, contentType, index) {
129
+ const key = uri.startsWith(PIPELEX_STORAGE_SCHEME)
130
+ ? uri.slice(PIPELEX_STORAGE_SCHEME.length)
131
+ : uri;
132
+ const segment = (key.split(/[?#]/)[0] ?? "")
133
+ .split(/[\\/]/)
134
+ .filter((part) => part !== "")
135
+ .pop() ?? "";
136
+ let decoded = segment;
137
+ try {
138
+ decoded = decodeURIComponent(segment);
139
+ }
140
+ catch {
141
+ // A malformed escape sequence is kept as typed; sanitization handles it.
142
+ }
143
+ let name = decoded
144
+ .replace(/[^A-Za-z0-9._-]/g, "_")
145
+ .replace(/^[._-]+/, "")
146
+ .replace(/[._-]+$/, "");
147
+ if (name === "") {
148
+ name = `artifact-${index + 1}`;
149
+ }
150
+ if (name.length > MAX_FILENAME_LENGTH) {
151
+ const ext = extensionOf(name);
152
+ // The extension is kept only if there is room left for a stem. An extension
153
+ // at least as long as the cap would give `slice` a negative start, which
154
+ // counts from the END and yields a name LONGER than the cap — so a
155
+ // pathological extension is dropped rather than preserved.
156
+ name =
157
+ ext.length < MAX_FILENAME_LENGTH
158
+ ? name.slice(0, MAX_FILENAME_LENGTH - ext.length) + ext
159
+ : name.slice(0, MAX_FILENAME_LENGTH);
160
+ }
161
+ if (extensionOf(name) === "") {
162
+ const ext = contentType == null
163
+ ? undefined
164
+ : EXTENSION_BY_CONTENT_TYPE[contentType.split(";")[0].trim().toLowerCase()];
165
+ if (ext !== undefined) {
166
+ name += ext;
167
+ }
168
+ }
169
+ return name;
170
+ }
171
+ /** `path.extname` for a bare filename, without the `node:path` import: `.ext`, or `""` (a leading dot is not an extension). */
172
+ function extensionOf(name) {
173
+ const dot = name.lastIndexOf(".");
174
+ return dot > 0 ? name.slice(dot) : "";
175
+ }
176
+ // ── resolveArtifacts ─────────────────────────────────────────────────
177
+ /**
178
+ * Resolve a list of references through the bulk route, chunked at
179
+ * {@link BULK_RESOLVE_MAX_URIS} per request, and answer one
180
+ * {@link ResolvedArtifact} per reference in request order, duplicates included.
181
+ * Per-reference failure is a value on the item; only what is not about a
182
+ * reference throws — the route's whole-request refusals as `ApiResponseError`
183
+ * (a caller with no organization, a malformed request, a signing failure, or a
184
+ * `404` from a deployment that does not serve the route) and an unreachable
185
+ * host as `ApiUnreachableError`. An empty list resolves to an empty list with
186
+ * no request made.
187
+ */
188
+ export async function resolveArtifacts(client, uris, options = {}) {
189
+ const items = [];
190
+ for (let start = 0; start < uris.length; start += BULK_RESOLVE_MAX_URIS) {
191
+ const chunk = uris.slice(start, start + BULK_RESOLVE_MAX_URIS);
192
+ const answer = await client.resolveStorageUrls({ uris: chunk }, { signal: options.signal });
193
+ const resolved = answer?.items;
194
+ if (!Array.isArray(resolved) || resolved.length !== chunk.length) {
195
+ throw new ArtifactOperationError(`The bulk resolve route answered ${Array.isArray(resolved) ? resolved.length : "no"} ` +
196
+ `item(s) for ${chunk.length} reference(s) — a malformed answer, so no reference can be ` +
197
+ "matched to its verdict.");
198
+ }
199
+ items.push(...resolved);
200
+ }
201
+ return items;
202
+ }
203
+ // ── fetchArtifact ────────────────────────────────────────────────────
204
+ /**
205
+ * A bounded `Response` for one reference: the link is minted fresh through the
206
+ * bulk route, then fetched with `redirect: "manual"` (a presigned link has no
207
+ * reason to redirect, and one that does is refused rather than followed), no
208
+ * headers of ours (the link carries its own authorization in the query string,
209
+ * and nothing must ride along to the object store), a budget covering the
210
+ * headers and the body, and a byte cap checked against `Content-Length` before
211
+ * a byte is read and again on every chunk. The status, status text and headers
212
+ * are the store's own, except that a `Content-Encoding` the fetch already decoded
213
+ * is dropped with the encoded `Content-Length`: a proxy relaying this response sets its own
214
+ * `Content-Disposition`, `X-Content-Type-Options`, CSP and caching headers,
215
+ * because this function does not.
216
+ *
217
+ * Only a `2xx` is returned. Anything else is thrown as `ArtifactFetchError`
218
+ * with a `code` (a redirect, a refused or vanished object, a store fault, a
219
+ * declared oversize, a timeout, a network fault, an unusable link, or the
220
+ * route's own per-reference refusal); a body that crosses the cap mid-stream
221
+ * errors the returned stream with the same error type (`too_large`). The
222
+ * caller's abort propagates as-is. Whole-request failures of the resolve step
223
+ * propagate unchanged (`ApiResponseError`, `ApiUnreachableError`).
224
+ */
225
+ export async function fetchArtifact(client, uri, options = {}) {
226
+ const bounds = fetchBounds(options);
227
+ const [resolved] = await resolveArtifacts(client, [uri], { signal: options.signal });
228
+ if (resolved.error !== null) {
229
+ throw new ArtifactFetchError(resolved.error.detail, uri, resolved.error.code);
230
+ }
231
+ const dispatcher = await dispatcherFor(bounds.timeoutMs);
232
+ return fetchResolvedUrl(uri, resolved.url, bounds, dispatcher, () => {
233
+ void dispatcher?.close().catch(() => undefined);
234
+ });
235
+ }
236
+ function fetchBounds(options) {
237
+ const maxBytes = options.maxBytes ?? DEFAULT_ARTIFACT_MAX_BYTES;
238
+ const timeoutMs = options.timeoutMs ?? DEFAULT_ARTIFACT_TIMEOUT_MS;
239
+ requirePositive("maxBytes", maxBytes);
240
+ requirePositive("timeoutMs", timeoutMs);
241
+ return { maxBytes, timeoutMs, allowHttp: options.allowHttp ?? false, signal: options.signal };
242
+ }
243
+ function requirePositive(name, value) {
244
+ if (!Number.isFinite(value) || value <= 0) {
245
+ throw new ArtifactOperationError(`"${name}" must be a positive number, got ${String(value)}.`);
246
+ }
247
+ }
248
+ /**
249
+ * The undici dispatcher a fetch runs on, carrying `headersTimeout` and
250
+ * `bodyTimeout` — the per-stall bounds that the overall `AbortSignal` budget
251
+ * does not give: an abort cancels the request, but a socket stalled mid-body
252
+ * is only reclaimed by the dispatcher's own timers. Null outside Node, or when
253
+ * `undici` cannot be loaded, in which case the fetch runs on the platform's
254
+ * default dispatcher with only the budget applied.
255
+ */
256
+ async function dispatcherFor(timeoutMs) {
257
+ if (!isNodeRuntime())
258
+ return null;
259
+ try {
260
+ const { Agent } = await import("undici");
261
+ return new Agent({ headersTimeout: timeoutMs, bodyTimeout: timeoutMs });
262
+ }
263
+ catch {
264
+ return null;
265
+ }
266
+ }
267
+ /** The parsed, boundary-approved URL — the object the fetch then uses verbatim. */
268
+ function checkUrl(uri, downloadUrl, allowHttp) {
269
+ let url;
270
+ try {
271
+ url = new URL(downloadUrl);
272
+ }
273
+ catch {
274
+ throw new ArtifactFetchError("The platform resolved the reference to a link that is not a valid absolute URL.", uri, "unsupported_url");
275
+ }
276
+ if (url.protocol === "http:" && !allowHttp) {
277
+ throw new ArtifactFetchError("The platform resolved the reference to a plain http link, which is refused by default; " +
278
+ "pass allowHttp: true to accept it (the local stack's object store hands out such links).", uri, "plain_http_refused");
279
+ }
280
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
281
+ throw new ArtifactFetchError(`The platform resolved the reference to a "${url.protocol.replace(":", "")}" link, which is not fetched; ` +
282
+ "only http(s) links are.", uri, "unsupported_url");
283
+ }
284
+ if (url.username !== "" || url.password !== "") {
285
+ throw new ArtifactFetchError("The platform resolved the reference to a link carrying credentials, which is not fetched.", uri, "unsupported_url");
286
+ }
287
+ return url;
288
+ }
289
+ /**
290
+ * The bounded fetch of an already-resolved link — the half of `fetchArtifact`
291
+ * that `downloadArtifacts` shares, its links coming from one bulk resolve ahead
292
+ * of the workers. `release` runs exactly once, when the exchange is over: the
293
+ * response refused, the body fully read, errored or cancelled.
294
+ */
295
+ async function fetchResolvedUrl(uri, downloadUrl, bounds, dispatcher, release) {
296
+ let url;
297
+ try {
298
+ url = checkUrl(uri, downloadUrl, bounds.allowHttp);
299
+ }
300
+ catch (err) {
301
+ release();
302
+ throw err;
303
+ }
304
+ const controller = new AbortController();
305
+ const userSignal = bounds.signal;
306
+ let timedOut = false;
307
+ let released = false;
308
+ // Runs exactly once, on every way the exchange can end — including the two
309
+ // aborts, so a returned body nobody ever reads still releases its resources.
310
+ // It closes over `timer`, declared below: nothing can call it before that
311
+ // declaration has run, since both aborts fire asynchronously.
312
+ const releaseOnce = () => {
313
+ if (released)
314
+ return;
315
+ released = true;
316
+ clearTimeout(timer);
317
+ userSignal?.removeEventListener("abort", onUserAbort);
318
+ release();
319
+ };
320
+ const onUserAbort = () => {
321
+ controller.abort(userSignal?.reason);
322
+ releaseOnce();
323
+ };
324
+ const timer = setTimeout(() => {
325
+ timedOut = true;
326
+ controller.abort(new DOMException("The artifact fetch timed out.", "TimeoutError"));
327
+ releaseOnce();
328
+ }, bounds.timeoutMs);
329
+ if (userSignal) {
330
+ if (userSignal.aborted)
331
+ controller.abort(userSignal.reason);
332
+ else
333
+ userSignal.addEventListener("abort", onUserAbort, { once: true });
334
+ }
335
+ const failure = (err) => {
336
+ if (timedOut) {
337
+ return new ArtifactFetchError(`Fetching the artifact timed out after ${bounds.timeoutMs}ms.`, uri, "timeout", undefined, { cause: err });
338
+ }
339
+ if (userSignal?.aborted)
340
+ return err;
341
+ if (err instanceof ArtifactFetchError)
342
+ return err;
343
+ return new ArtifactFetchError(`The artifact could not be fetched: ${err instanceof Error ? err.message : String(err)}.`, uri, "network", undefined, { cause: err });
344
+ };
345
+ if (controller.signal.aborted) {
346
+ // Already aborted before the request: the same outcome `fetch` would give,
347
+ // settled here so it does not depend on the platform's fetch checking first.
348
+ releaseOnce();
349
+ throw failure(controller.signal.reason);
350
+ }
351
+ let response;
352
+ try {
353
+ const init = {
354
+ redirect: "manual",
355
+ cache: "no-store",
356
+ signal: controller.signal,
357
+ };
358
+ if (dispatcher !== null)
359
+ init.dispatcher = dispatcher;
360
+ response = await fetch(url, init);
361
+ }
362
+ catch (err) {
363
+ releaseOnce();
364
+ throw failure(err);
365
+ }
366
+ const refusal = statusRefusal(uri, response);
367
+ if (refusal !== undefined) {
368
+ await discard(response);
369
+ releaseOnce();
370
+ throw refusal;
371
+ }
372
+ const declared = Number(response.headers.get("content-length"));
373
+ if (Number.isFinite(declared) && declared > bounds.maxBytes) {
374
+ await discard(response);
375
+ releaseOnce();
376
+ throw new ArtifactFetchError(`The artifact is ${formatMiB(declared)}, over the ${formatMiB(bounds.maxBytes)} cap.`, uri, "too_large", response.status);
377
+ }
378
+ if (response.body === null) {
379
+ releaseOnce();
380
+ return response;
381
+ }
382
+ const upstream = response.body.getReader();
383
+ // The body reader is cancelled on abort explicitly, on top of the request's
384
+ // own abort, so an abandoned stream never waits on the store's next chunk.
385
+ controller.signal.addEventListener("abort", () => {
386
+ void upstream.cancel(controller.signal.reason).catch(() => undefined);
387
+ }, { once: true });
388
+ let total = 0;
389
+ const bounded = new ReadableStream({
390
+ async pull(streamController) {
391
+ let chunk;
392
+ try {
393
+ chunk = await upstream.read();
394
+ }
395
+ catch (err) {
396
+ releaseOnce();
397
+ streamController.error(failure(err));
398
+ return;
399
+ }
400
+ if (chunk.done) {
401
+ releaseOnce();
402
+ // Cancelling the upstream reader on abort resolves a pending read as
403
+ // `done`, not as a rejection — so an end-of-stream that arrives while
404
+ // aborted is the abort, never a clean close that would pass a truncated
405
+ // body off as complete.
406
+ if (controller.signal.aborted) {
407
+ streamController.error(failure(controller.signal.reason));
408
+ }
409
+ else {
410
+ streamController.close();
411
+ }
412
+ return;
413
+ }
414
+ total += chunk.value.byteLength;
415
+ if (total > bounds.maxBytes) {
416
+ await upstream.cancel().catch(() => undefined);
417
+ releaseOnce();
418
+ streamController.error(new ArtifactFetchError(`The artifact crossed the ${formatMiB(bounds.maxBytes)} cap mid-stream.`, uri, "too_large", response.status));
419
+ return;
420
+ }
421
+ streamController.enqueue(chunk.value);
422
+ },
423
+ async cancel(reason) {
424
+ releaseOnce();
425
+ await upstream.cancel(reason).catch(() => undefined);
426
+ },
427
+ });
428
+ return new Response(bounded, {
429
+ status: response.status,
430
+ statusText: response.statusText,
431
+ headers: decodedHeaders(response.headers),
432
+ });
433
+ }
434
+ /**
435
+ * The codings every runtime's `fetch` decodes. `zstd` is left out: whether it is
436
+ * decoded depends on the runtime (Node 22's zlib has none), and a coding fetch
437
+ * did not decode must keep its header, since the bytes are still encoded.
438
+ */
439
+ const FETCH_DECODED_CODINGS = new Set(["gzip", "x-gzip", "deflate", "br"]);
440
+ /**
441
+ * The store's headers as they describe the body we hand on. When `fetch` has
442
+ * decoded every coding the `Content-Encoding` lists, the stream is the decoded
443
+ * bytes: the encoding and the encoded length are dropped, or a proxy relaying
444
+ * the response would label plain bytes as compressed and give the wrong length.
445
+ * Any other encoding is passed through with the still-encoded body it describes.
446
+ */
447
+ function decodedHeaders(headers) {
448
+ const codings = (headers.get("content-encoding") ?? "")
449
+ .split(",")
450
+ .map((coding) => coding.trim().toLowerCase())
451
+ .filter((coding) => coding !== "" && coding !== "identity");
452
+ if (codings.length === 0 || !codings.every((coding) => FETCH_DECODED_CODINGS.has(coding))) {
453
+ return headers;
454
+ }
455
+ const decoded = new Headers(headers);
456
+ decoded.delete("content-encoding");
457
+ decoded.delete("content-length");
458
+ return decoded;
459
+ }
460
+ function statusRefusal(uri, response) {
461
+ const status = response.status;
462
+ // A browser hands a manual redirect back as an opaque-redirect response, whose
463
+ // status is 0 and says nothing; Node's fetch hands back the 3xx itself.
464
+ if (response.type === "opaqueredirect") {
465
+ return new ArtifactFetchError("The resolved link redirected; redirects are not followed.", uri, "redirect_refused");
466
+ }
467
+ if (status >= 300 && status < 400) {
468
+ return new ArtifactFetchError(`The resolved link redirected (HTTP ${status}); redirects are not followed.`, uri, "redirect_refused", status);
469
+ }
470
+ // The link is minted per call, so a 401/403 is the store refusing a fresh
471
+ // signature (clock skew, a signing misconfiguration) rather than an expired link.
472
+ if (status === 401 || status === 403) {
473
+ return new ArtifactFetchError(`The object store refused the resolved link (HTTP ${status}).`, uri, "store_refused", status);
474
+ }
475
+ if (status === 404 || status === 410) {
476
+ return new ArtifactFetchError(`The stored file is no longer available (HTTP ${status}).`, uri, "not_found", status);
477
+ }
478
+ if (status < 200 || status >= 300) {
479
+ return new ArtifactFetchError(`The object store answered HTTP ${status} for the resolved link.`, uri, "store_error", status);
480
+ }
481
+ return undefined;
482
+ }
483
+ /** Release a response we are refusing, so its connection is not left dangling. */
484
+ async function discard(response) {
485
+ try {
486
+ await response.body?.cancel();
487
+ }
488
+ catch {
489
+ // Discarding a body we already refused is best-effort; nothing to report.
490
+ }
491
+ }
492
+ function formatMiB(bytes) {
493
+ const mib = bytes / (1024 * 1024);
494
+ return `${Number.isInteger(mib) ? mib : mib.toFixed(1)} MiB`;
495
+ }
496
+ /**
497
+ * Write the whole chunk, looping on short writes. `FileHandle.write()` resolves
498
+ * with the count it managed, which can fall short of the chunk on a filesystem
499
+ * under pressure; taking one call as "written" would report a truncated file
500
+ * as saved.
501
+ */
502
+ export async function writeFully(writer, chunk) {
503
+ let offset = 0;
504
+ while (offset < chunk.byteLength) {
505
+ const { bytesWritten } = await writer.write(chunk, offset, chunk.byteLength - offset);
506
+ if (bytesWritten <= 0) {
507
+ throw new Error(`the filesystem accepted no bytes at offset ${offset} of ${chunk.byteLength}`);
508
+ }
509
+ offset += bytesWritten;
510
+ }
511
+ }
512
+ /**
513
+ * Create `baseName` under `dir` exclusively (`wx`), suffixing the stem
514
+ * (`name-1.ext`, `name-2.ext`, …) until a free name is found. Exclusive creation
515
+ * is what makes "never overwrite" true rather than merely likely: an
516
+ * exists-check followed by a write would race a concurrent worker.
517
+ */
518
+ async function openUniqueFile(fs, path, dir, baseName) {
519
+ const ext = extensionOf(baseName);
520
+ const stem = baseName.slice(0, baseName.length - ext.length);
521
+ for (let attempt = 0; attempt < MAX_UNIQUE_ATTEMPTS; attempt += 1) {
522
+ const candidate = path.join(dir, attempt === 0 ? baseName : `${stem}-${attempt}${ext}`);
523
+ try {
524
+ return { handle: await fs.open(candidate, "wx"), path: candidate };
525
+ }
526
+ catch (err) {
527
+ if (err.code !== "EEXIST")
528
+ throw err;
529
+ }
530
+ }
531
+ throw new Error(`Could not find a free filename for ${baseName} in ${dir}.`);
532
+ }
533
+ /** A write to the target file failed — tagged so it reports as `write_failed`, not as a transport fault. */
534
+ class ArtifactWriteError extends Error {
535
+ inner;
536
+ constructor(inner) {
537
+ super(inner instanceof Error ? inner.message : String(inner));
538
+ this.name = "ArtifactWriteError";
539
+ this.inner = inner;
540
+ }
541
+ }
542
+ /** The download stopped before this item was attempted. */
543
+ const SKIPPED_ABORTED = "The download was aborted before this artifact was fetched.";
544
+ const SKIPPED_CREDENTIAL = "The download stopped on a credential failure before this artifact was fetched.";
545
+ const SKIPPED_TOTAL = "Skipped: the download's total byte limit was reached by an earlier artifact.";
546
+ function itemError(uri, contentType, code, detail) {
547
+ return { uri, path: null, content_type: contentType, size: null, error: { code, detail } };
548
+ }
549
+ function isExpired(expiresAt) {
550
+ const at = Date.parse(expiresAt);
551
+ return Number.isFinite(at) && at - EXPIRY_MARGIN_MS <= Date.now();
552
+ }
553
+ function isCredentialRefusal(err) {
554
+ return err instanceof ApiResponseError && (err.status === 401 || err.status === 403);
555
+ }
556
+ /** Read a run's results by id, turning a run that has not completed into its typed error. */
557
+ async function readCompletedResults(client, runId, signal) {
558
+ const state = await client.getRunResult(runId, { signal });
559
+ if (state.state === "running") {
560
+ throw new RunStillRunningError(`Run ${runId} is still running, so it has no artifacts to download yet` +
561
+ (state.retry_after_seconds != null ? ` — retry in ${state.retry_after_seconds}s.` : "."), runId, state.retry_after_seconds);
562
+ }
563
+ if (state.state === "failed") {
564
+ throw new RunFailedError(state.message, runId, state.status);
565
+ }
566
+ return state.result;
567
+ }
568
+ /**
569
+ * Save a run's produced files under `dir` — Node-only, like the path-string arm
570
+ * of `uploadFile`. Takes a `run_id` (the results are re-read, so a run is
571
+ * downloadable days later) or a `RunResults` in hand; walks the requested scope
572
+ * with {@link collectArtifacts}; resolves the whole set through the bulk route
573
+ * ahead of the workers; then a bounded pool of workers each fetch → open with
574
+ * `wx` → write, re-resolving any link that has expired by the time a worker
575
+ * reaches it. The embedded `public_url` is never used. Files are named by
576
+ * {@link artifactFilename} and never overwritten; a failed or aborted download
577
+ * unlinks its partial file.
578
+ *
579
+ * Returns a produced verdict: one entry per reference, errors as values. It
580
+ * throws only when no verdict can be produced — `RunStillRunningError` or
581
+ * `RunFailedError` for a run that has not completed, `ScopeUnavailableError`
582
+ * when the scope's artifact is null or missing, `ArtifactAuthenticationError`
583
+ * (carrying the verdict so far) when the resolve route refuses the credential,
584
+ * `ArtifactOperationError` outside Node or for an unusable `dir`, and the
585
+ * transport and lifecycle errors of the reads it makes (`ApiResponseError` for
586
+ * a deployment without the bulk route, `RunLifecycleUnavailableError` for a
587
+ * bare runner asked by id, `ApiUnreachableError`).
588
+ */
589
+ export async function downloadArtifacts(client, request) {
590
+ if (!isNodeRuntime()) {
591
+ throw new ArtifactOperationError("downloadArtifacts writes to a filesystem and is Node-only. In another runtime, resolve " +
592
+ "with resolveArtifacts or stream with fetchArtifact instead.");
593
+ }
594
+ const scope = request.scope ?? "main_stuff";
595
+ const concurrency = request.concurrency ?? DEFAULT_DOWNLOAD_CONCURRENCY;
596
+ const maxTotalBytes = request.maxTotalBytes ?? DEFAULT_DOWNLOAD_MAX_TOTAL_BYTES;
597
+ if (!Number.isInteger(concurrency) || concurrency < 1) {
598
+ throw new ArtifactOperationError(`"concurrency" must be a positive integer, got ${String(concurrency)}.`);
599
+ }
600
+ requirePositive("maxTotalBytes", maxTotalBytes);
601
+ const bounds = fetchBounds(request);
602
+ const hasRunId = typeof request.run_id === "string" && request.run_id !== "";
603
+ const hasResults = request.results != null;
604
+ if (hasRunId === hasResults) {
605
+ throw new ArtifactOperationError("downloadArtifacts takes exactly one of `run_id` (the results are re-read) or `results` " +
606
+ "(a RunResults in hand).");
607
+ }
608
+ const results = hasResults
609
+ ? request.results
610
+ : await readCompletedResults(client, request.run_id, request.signal);
611
+ const runId = results.pipeline_run_id;
612
+ // `working_memory` is read off the parsed body: the platform relays it whether
613
+ // or not `RunResults` declares the field.
614
+ const walked = scope === "main_stuff"
615
+ ? results.main_stuff
616
+ : results["working_memory"];
617
+ if (walked == null) {
618
+ throw new ScopeUnavailableError(scope, runId);
619
+ }
620
+ const uris = collectArtifacts(walked);
621
+ if (uris.length === 0) {
622
+ return { scope, artifacts: [], saved_paths: [], all_saved: true };
623
+ }
624
+ const fs = (await import("node:fs/promises"));
625
+ const path = (await import("node:path"));
626
+ const dir = path.resolve(request.dir);
627
+ try {
628
+ await fs.mkdir(dir, { recursive: true });
629
+ }
630
+ catch (cause) {
631
+ throw new ArtifactOperationError(`The download directory "${dir}" cannot be created or used: ${cause instanceof Error ? cause.message : String(cause)}.`, { cause });
632
+ }
633
+ // Dense, never sparse: `map` skips the holes of `new Array(n)`, and the verdict
634
+ // must name every reference, reached or not.
635
+ const outcomes = Array.from({ length: uris.length }, () => undefined);
636
+ const verdictSoFar = () => {
637
+ const artifacts = outcomes.map((outcome, index) => outcome ?? itemError(uris[index], null, "aborted", SKIPPED_CREDENTIAL));
638
+ return assembleVerdict(scope, artifacts, request.signal?.aborted === true);
639
+ };
640
+ let resolved;
641
+ try {
642
+ resolved = await resolveArtifacts(client, uris, { signal: request.signal });
643
+ }
644
+ catch (err) {
645
+ if (isCredentialRefusal(err)) {
646
+ throw new ArtifactAuthenticationError(`The resolve route refused the credential (${err.status}); no artifact was downloaded.`, err.status, verdictSoFar(), { cause: err });
647
+ }
648
+ throw err;
649
+ }
650
+ // One dispatcher for the whole batch, so the workers share connections to the
651
+ // store; closed once every worker has settled.
652
+ const dispatcher = await dispatcherFor(bounds.timeoutMs);
653
+ const signal = request.signal;
654
+ // A call, not a property read: the flag flips across an await, so it must not be narrowed.
655
+ const aborted = () => signal?.aborted === true;
656
+ const workerBounds = { ...bounds, signal };
657
+ // A credential refusal on a re-resolve stops the workers taking new items, and
658
+ // nothing else: a fetch already running is on a presigned link that does not
659
+ // carry the credential, so it is left to finish and its file is kept.
660
+ let credentialFailure;
661
+ // What the total cap is held against: the bytes of every file saved or being
662
+ // saved, a file in flight counting as the larger of its declared length and
663
+ // what it has written. Reserving the declared length up front is what stops
664
+ // parallel files from each passing the check and then all being cut together;
665
+ // a file that is unlinked gives its share back.
666
+ let committedBytes = 0;
667
+ // The bytes of the files saved so far. The limit is reached for good, and the
668
+ // items not yet started skipped, only when these leave no room: a refusal
669
+ // against another file's reservation is that item's alone, since the file in
670
+ // flight may still fail and give its share back.
671
+ let savedBytes = 0;
672
+ let limitReached = false;
673
+ let next = 0;
674
+ const saveOne = async (index, uri, url, contentType) => {
675
+ let response;
676
+ try {
677
+ response = await fetchResolvedUrl(uri, url, workerBounds, dispatcher, () => undefined);
678
+ }
679
+ catch (err) {
680
+ return itemError(uri, contentType, ...classifyFailure(err, signal));
681
+ }
682
+ const declaredLength = Number(response.headers.get("content-length"));
683
+ const reserved = Number.isFinite(declaredLength) ? declaredLength : 0;
684
+ if (committedBytes + reserved > maxTotalBytes) {
685
+ if (savedBytes + reserved > maxTotalBytes)
686
+ limitReached = true;
687
+ await discard(response);
688
+ return itemError(uri, contentType, "total_limit_exceeded", `Saving this ${formatMiB(reserved)} artifact would take the download past its ` +
689
+ `${formatMiB(maxTotalBytes)} total limit.`);
690
+ }
691
+ committedBytes += reserved;
692
+ let written = 0;
693
+ const share = () => Math.max(written, reserved);
694
+ let target;
695
+ try {
696
+ target = await openUniqueFile(fs, path, dir, artifactFilename(uri, contentType, index));
697
+ }
698
+ catch (err) {
699
+ committedBytes -= share();
700
+ await discard(response);
701
+ return itemError(uri, contentType, "write_failed", `The file could not be created: ${err instanceof Error ? err.message : String(err)}.`);
702
+ }
703
+ const removePartial = async () => {
704
+ committedBytes -= share();
705
+ await target.handle.close().catch(() => undefined);
706
+ await fs.unlink(target.path).catch(() => undefined);
707
+ };
708
+ if (response.body === null) {
709
+ try {
710
+ await target.handle.close();
711
+ }
712
+ catch (err) {
713
+ await removePartial();
714
+ return itemError(uri, contentType, "write_failed", `The file could not be closed: ${err instanceof Error ? err.message : String(err)}.`);
715
+ }
716
+ committedBytes -= share();
717
+ return { uri, path: target.path, content_type: contentType, size: 0, error: null };
718
+ }
719
+ const reader = response.body.getReader();
720
+ try {
721
+ for (;;) {
722
+ const { done, value } = await reader.read();
723
+ if (done)
724
+ break;
725
+ // Only the bytes past this file's reservation are new to the total.
726
+ const growth = Math.max(written + value.byteLength, reserved) - share();
727
+ if (committedBytes + growth > maxTotalBytes) {
728
+ if (savedBytes + written + value.byteLength > maxTotalBytes)
729
+ limitReached = true;
730
+ await reader.cancel().catch(() => undefined);
731
+ await removePartial();
732
+ return itemError(uri, contentType, "total_limit_exceeded", `This artifact took the download past its ${formatMiB(maxTotalBytes)} total limit.`);
733
+ }
734
+ committedBytes += growth;
735
+ written += value.byteLength;
736
+ try {
737
+ await writeFully(target.handle, value);
738
+ }
739
+ catch (err) {
740
+ throw new ArtifactWriteError(err);
741
+ }
742
+ }
743
+ }
744
+ catch (err) {
745
+ await reader.cancel().catch(() => undefined);
746
+ await removePartial();
747
+ if (err instanceof ArtifactWriteError) {
748
+ return itemError(uri, contentType, "write_failed", `The file could not be written: ${err.message}.`);
749
+ }
750
+ return itemError(uri, contentType, ...classifyFailure(err, signal));
751
+ }
752
+ try {
753
+ await target.handle.close();
754
+ }
755
+ catch (err) {
756
+ await removePartial();
757
+ return itemError(uri, contentType, "write_failed", `The file could not be closed: ${err instanceof Error ? err.message : String(err)}.`);
758
+ }
759
+ // A body shorter than it declared gives the unused reservation back.
760
+ committedBytes -= share() - written;
761
+ savedBytes += written;
762
+ return { uri, path: target.path, content_type: contentType, size: written, error: null };
763
+ };
764
+ const worker = async () => {
765
+ for (;;) {
766
+ const index = next;
767
+ next += 1;
768
+ if (index >= uris.length)
769
+ return;
770
+ const uri = uris[index];
771
+ let entry = resolved[index];
772
+ if (credentialFailure !== undefined || aborted()) {
773
+ outcomes[index] = itemError(uri, entry.content_type, "aborted", credentialFailure ? SKIPPED_CREDENTIAL : SKIPPED_ABORTED);
774
+ continue;
775
+ }
776
+ if (limitReached) {
777
+ outcomes[index] = itemError(uri, entry.content_type, "total_limit_exceeded", SKIPPED_TOTAL);
778
+ continue;
779
+ }
780
+ if (entry.error === null && isExpired(entry.expires_at)) {
781
+ try {
782
+ const again = await resolveArtifacts(client, [uri], { signal });
783
+ entry = again[0];
784
+ }
785
+ catch (err) {
786
+ if (isCredentialRefusal(err)) {
787
+ credentialFailure = err;
788
+ outcomes[index] = itemError(uri, entry.content_type, "aborted", SKIPPED_CREDENTIAL);
789
+ continue;
790
+ }
791
+ if (aborted()) {
792
+ outcomes[index] = itemError(uri, entry.content_type, "aborted", SKIPPED_ABORTED);
793
+ continue;
794
+ }
795
+ outcomes[index] = itemError(uri, entry.content_type, "resolve_failed", `The expired link could not be re-resolved: ${err instanceof Error ? err.message : String(err)}.`);
796
+ continue;
797
+ }
798
+ }
799
+ if (entry.error !== null) {
800
+ outcomes[index] = itemError(uri, null, entry.error.code, entry.error.detail);
801
+ continue;
802
+ }
803
+ outcomes[index] = await saveOne(index, uri, entry.url, entry.content_type);
804
+ }
805
+ };
806
+ try {
807
+ await Promise.all(Array.from({ length: Math.min(concurrency, uris.length) }, worker));
808
+ }
809
+ finally {
810
+ void dispatcher?.close().catch(() => undefined);
811
+ }
812
+ if (credentialFailure) {
813
+ throw new ArtifactAuthenticationError(`The resolve route refused the credential (${credentialFailure.status}) part-way through ` +
814
+ "the download; the verdict so far is on this error.", credentialFailure.status, verdictSoFar(), { cause: credentialFailure });
815
+ }
816
+ return assembleVerdict(scope, outcomes.map((outcome, index) => outcome ?? itemError(uris[index], null, "aborted", SKIPPED_ABORTED)), request.signal?.aborted === true);
817
+ }
818
+ function assembleVerdict(scope, artifacts, aborted) {
819
+ const saved_paths = artifacts.flatMap((artifact) => artifact.error === null ? [artifact.path] : []);
820
+ const verdict = {
821
+ scope,
822
+ artifacts,
823
+ saved_paths,
824
+ all_saved: saved_paths.length === artifacts.length,
825
+ };
826
+ if (aborted)
827
+ verdict.aborted = true;
828
+ return verdict;
829
+ }
830
+ /** An item's `[code, detail]` for an error thrown while fetching or reading it. */
831
+ function classifyFailure(err, signal) {
832
+ if (err instanceof ArtifactFetchError)
833
+ return [err.code, err.message];
834
+ if (signal?.aborted === true)
835
+ return ["aborted", "The download was aborted while this artifact was being fetched."];
836
+ return [
837
+ "network",
838
+ `The artifact could not be read: ${err instanceof Error ? err.message : String(err)}.`,
839
+ ];
840
+ }
841
+ //# sourceMappingURL=artifacts.js.map