@pipelex/sdk 0.49.0 → 0.50.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.
package/dist/client.js CHANGED
@@ -216,9 +216,23 @@ const EXAMPLE_ID_ARGUMENT = "an example id, the example's non-empty `example_id`
216
216
  const RUN_ID_ARGUMENT = "a run id, the run's non-empty `pipeline_run_id`";
217
217
  const ORG_ID_ARGUMENT = "an organization id, the organization's non-empty `org_id`";
218
218
  const API_KEY_ID_ARGUMENT = "an API key id, the key's non-empty `id`";
219
+ const FILE_ID_ARGUMENT = "a file id, the file's non-empty `file_id`";
220
+ const ARCHIVE_ID_ARGUMENT = "an archive id, the archive's non-empty `archive_id`";
219
221
  /** What the run id of each example write is, as a refusal of it names it. */
220
222
  const CREATE_RUN_ID_ARGUMENT = "a run_id naming a run of this method that used these inputs, a non-empty string, or none";
221
223
  const RECORD_RUN_ID_ARGUMENT = "a run_id naming a run of this method that used the example's inputs, a non-empty string";
224
+ /** What the example an assistant save keeps current is, as a refusal of it names it. */
225
+ const ASSISTANT_EXAMPLE_ID_ARGUMENT = "an example_id naming the assistant's example the caller read, a non-empty string, or none";
226
+ const ASSISTANT_REVISION_ARGUMENT = "an input_revision, the revision the caller read the assistant's example at, a positive integer";
227
+ /** What each member of the files routes' bodies is, as a refusal of it names it. */
228
+ const STORAGE_URI_ARGUMENT = "a `pipelex-storage://` address, a non-empty string";
229
+ const RENAME_NAME_ARGUMENT = "the file's new name, a string";
230
+ const LINK_TARGET_ARGUMENT = "a file to link, an object naming exactly one of `file_id` and `uri`, each a non-empty string";
231
+ const ARCHIVE_FILE_ID_ARGUMENT = "a file id to zip, a non-empty string";
232
+ const ARCHIVE_RUN_ID_ARGUMENT = "a run_id naming the run whose files to zip, a non-empty string";
233
+ const ARCHIVE_NAME_ARGUMENT = "a name for the zip, a string, or none";
234
+ const DRAFT_PATH_ARGUMENT = "the tree path the file takes in the draft, a non-empty string";
235
+ const DRAFT_TOKEN_ARGUMENT = "the draft token the caller last read, the method's `updated_at`, a string, or none";
222
236
  /**
223
237
  * The refusal of an argument a call cannot take, worded as every such refusal is:
224
238
  * `<call>() takes <what>; got <got>.`, with what made it unusable as `cause` when there is one.
@@ -243,6 +257,22 @@ function requireNonEmptyId(value, call, what) {
243
257
  got = value === null ? "null" : typeof value;
244
258
  throw argumentRefusal(call, what, got);
245
259
  }
260
+ /**
261
+ * The check the example writes that take a name and inputs share, the create and the assistant's
262
+ * save: a `name` or `inputs` not given, a `null` counting as not given, is refused with
263
+ * `RequestArgumentError` before anything is sent, `call` naming the method in the message and
264
+ * `needs` what it needs.
265
+ */
266
+ function requireNameAndInputs(name, inputs, call, needs) {
267
+ if (name != null && inputs != null)
268
+ return;
269
+ let got;
270
+ if (name == null && inputs == null)
271
+ got = "neither";
272
+ else
273
+ got = name == null ? "no name" : "no inputs";
274
+ throw new RequestArgumentError(`${call}() needs ${needs}; got ${got}.`);
275
+ }
246
276
  /**
247
277
  * A caller's value as the refusal of a number argument names it: a number by its value, anything
248
278
  * else by its type, as `requireNonEmptyId` names a value, and `null` as itself. Never by `String`,
@@ -307,6 +337,35 @@ function methodExamplePath(methodId, exampleId, call) {
307
337
  return `${path}/examples/${idPathSegment(exampleId, call, EXAMPLE_ID_ARGUMENT)}`;
308
338
  }
309
339
  const isString = (value) => typeof value === "string";
340
+ /** The states an archive is in, the field a caller branches on, in the order a refusal lists them. */
341
+ const FILE_ARCHIVE_STATES = ["queued", "building", "ready", "failed"];
342
+ /** What a copy into a draft did in the blob store, the field its answer is discriminated on. */
343
+ const DRAFT_FILE_BLOBS = ["copied", "already_held"];
344
+ /** Whether a run's records are all written, the field a reader of its files branches on. */
345
+ const RUN_FILES_STATES = ["listing", "listed"];
346
+ /** The lists of a page of a run's files, each an array on every page. */
347
+ const RUN_FILES_LISTS = ["inputs", "outputs", "intermediates", "links"];
348
+ /**
349
+ * One file `linkFiles` is asked to link, checked and reduced to what the closed body takes:
350
+ * `{ file_id }` or `{ uri }`, exactly one of them a non-empty string. Anything else, an object
351
+ * naming both, neither or an empty one included, is refused with `RequestArgumentError` before
352
+ * anything is sent, as the platform would refuse the whole request with a `422`.
353
+ */
354
+ function fileLinkTarget(value) {
355
+ if (!isPlainObject(value)) {
356
+ throw argumentRefusal("linkFiles", LINK_TARGET_ARGUMENT, describeNumberArgument(value));
357
+ }
358
+ const fileId = value.file_id ?? undefined;
359
+ const uri = value.uri ?? undefined;
360
+ if ((fileId === undefined) === (uri === undefined)) {
361
+ const got = fileId === undefined ? "an object naming neither" : "an object naming both";
362
+ throw argumentRefusal("linkFiles", LINK_TARGET_ARGUMENT, got);
363
+ }
364
+ if (fileId !== undefined) {
365
+ return { file_id: requireNonEmptyId(fileId, "linkFiles", LINK_TARGET_ARGUMENT) };
366
+ }
367
+ return { uri: requireNonEmptyId(uri, "linkFiles", LINK_TARGET_ARGUMENT) };
368
+ }
310
369
  /**
311
370
  * Each field of a `MethodExample`, what it must be, and the check it must pass: every field the
312
371
  * Python SDK's model requires, held to the same strict types it holds them to, so both SDKs refuse
@@ -331,6 +390,7 @@ const METHOD_EXAMPLE_FIELDS = {
331
390
  created_by_user_id: ["a string", isString],
332
391
  created_at: ["a string", isString],
333
392
  updated_at: ["a string", isString],
393
+ kept_by_assistant: ["a boolean", (value) => typeof value === "boolean"],
334
394
  };
335
395
  export class PipelexApiClient {
336
396
  apiKey;
@@ -2337,14 +2397,7 @@ export class PipelexApiClient {
2337
2397
  async createMethodExample(methodId, input) {
2338
2398
  const endpoint = `${methodPath(methodId, "createMethodExample")}/examples`;
2339
2399
  const { name, inputs, run_id: runId, } = input ?? {};
2340
- if (name == null || inputs == null) {
2341
- let got;
2342
- if (name == null && inputs == null)
2343
- got = "neither";
2344
- else
2345
- got = name == null ? "no name" : "no inputs";
2346
- throw new RequestArgumentError(`createMethodExample() needs the example's name and its inputs; got ${got}.`);
2347
- }
2400
+ requireNameAndInputs(name, inputs, "createMethodExample", "the example's name and its inputs");
2348
2401
  const body = { name, inputs };
2349
2402
  if (runId != null) {
2350
2403
  body.run_id = requireNonEmptyId(runId, "createMethodExample", CREATE_RUN_ID_ARGUMENT);
@@ -2492,6 +2545,64 @@ export class PipelexApiClient {
2492
2545
  });
2493
2546
  return { ...answer, outcome, example: subject };
2494
2547
  }
2548
+ /**
2549
+ * Save the build assistant's test inputs as its example of the method —
2550
+ * `POST /v1/methods/{id}/examples/assistant-save` — **never writing over a person's edit**.
2551
+ *
2552
+ * The assistant keeps one example of a method current with its test inputs, until a person
2553
+ * edits that example's inputs. Find its example as the method's newest with `kept_by_assistant`
2554
+ * true (`listMethodExamples` answers them oldest first), and send its `example_id` with the
2555
+ * `input_revision` you read it at. The platform writes the inputs into it only while it still
2556
+ * exists, still carries the flag and still holds that revision, in one conditional write that it
2557
+ * never retries. Otherwise, and when no example is named, which is how the first save on a
2558
+ * method creates the assistant's example, it saves the inputs as a new example flagged as the
2559
+ * assistant's, under `name`.
2560
+ *
2561
+ * The answer is a `MethodExampleAssistantSaveResult` discriminated on `outcome` — branch on it:
2562
+ * `kept`, the named example now holds the inputs, its name untouched; `created`, a new example
2563
+ * holds them. Both carry the `example`, which is the assistant's from then on. A retry of a save
2564
+ * that landed answers `kept` and writes nothing again.
2565
+ *
2566
+ * Sends `name`, `inputs` and, when given, `example_id` with `input_revision`, and nothing else:
2567
+ * the body is closed, and `null` reads as not given. Throws `RequestArgumentError` before any
2568
+ * request when `name` or `inputs` is not given, for an `example_id` that is not a non-empty
2569
+ * string or an `input_revision` that is not a positive integer, and when only one of the two is
2570
+ * given. Throws `ApiResponseError`: `403` `role_not_permitted` for a role that may not change
2571
+ * methods, such as a runner; `413` `payload_too_large` for an example larger than one stored item
2572
+ * holds; `422` for a name that is empty, blank or over 200 characters, or inputs that are not a
2573
+ * JSON object; and the codes every example route answers. An answer whose `outcome` is neither
2574
+ * arm's, or whose `example` is not an example `readMethodExample` can read, is an answer the SDK
2575
+ * cannot read, thrown as an `ApiResponseError` too.
2576
+ */
2577
+ async saveAssistantMethodExample(methodId, input) {
2578
+ const call = "saveAssistantMethodExample";
2579
+ const endpoint = `${methodPath(methodId, call)}/examples/assistant-save`;
2580
+ const { name, inputs, example_id: exampleId, input_revision: revision, } = input ?? {};
2581
+ requireNameAndInputs(name, inputs, call, "the name a new example takes and the inputs");
2582
+ const body = { name, inputs };
2583
+ if (exampleId != null) {
2584
+ body.example_id = requireNonEmptyId(exampleId, call, ASSISTANT_EXAMPLE_ID_ARGUMENT);
2585
+ }
2586
+ if (revision != null) {
2587
+ if (typeof revision !== "number" || !Number.isSafeInteger(revision) || revision < 1) {
2588
+ throw argumentRefusal(call, ASSISTANT_REVISION_ARGUMENT, describeNumberArgument(revision));
2589
+ }
2590
+ body.input_revision = revision;
2591
+ }
2592
+ if ((body.example_id === undefined) !== (body.input_revision === undefined)) {
2593
+ throw new RequestArgumentError(`${call}() names the assistant's example by both its example_id and the input_revision ` +
2594
+ `it was read at, or neither; got only ${body.example_id === undefined ? "input_revision" : "example_id"}.`);
2595
+ }
2596
+ const res = await this.requestProductAnswer("POST", endpoint, body);
2597
+ const { answer, outcome, subject } = this.readVerdict("POST", endpoint, res, {
2598
+ verdict: "an assistant save result",
2599
+ outcomes: ["kept", "created"],
2600
+ member: "example",
2601
+ parse: parseExactJson,
2602
+ readMember: (value) => this.readMethodExample("POST", endpoint, res, value, "an assistant save result's `example`"),
2603
+ });
2604
+ return { ...answer, outcome, example: subject };
2605
+ }
2495
2606
  /**
2496
2607
  * Delete an example — `DELETE /v1/methods/{id}/examples/{example_id}`. The platform answers
2497
2608
  * `204`. Deleting the example leaves its latest run in the method's history.
@@ -2666,7 +2777,8 @@ export class PipelexApiClient {
2666
2777
  return downloadArtifactsImpl(this, request);
2667
2778
  }
2668
2779
  /**
2669
- * Upload a base64 file — `POST /v1/upload`. An answer with no non-empty string `uri` names no
2780
+ * Upload a base64 file — `POST /v1/upload`, answering the file's `uri`, the `filename` as sent
2781
+ * and the `file_id` of its record. An answer with no non-empty string `uri` names no
2670
2782
  * stored file, so it is an answer the SDK cannot read: an `ApiResponseError`, `runtime` and
2671
2783
  * not retryable, which `uploadFile` wraps as an `UploadTransportError` with code `unexpected`.
2672
2784
  */
@@ -2681,18 +2793,24 @@ export class PipelexApiClient {
2681
2793
  /**
2682
2794
  * Request a grant to upload one file straight to storage — `POST /v1/upload/grant`.
2683
2795
  * `upload` for a caller that holds the bytes but not this client's credential, such
2684
- * as a browser page: the credential-holding side asks for the grant, hands it over,
2685
- * and the holder of the bytes sends them with `uploadWithGrant`, from the
2686
- * browser-safe `@pipelex/sdk/upload` entry. The bytes cross neither this client nor
2687
- * the API gateway, so the gateway's request quota does not cap the file below the
2688
- * service's own limit (`max_bytes`).
2689
- *
2690
- * The grant describes one new object: its `uri` exists once the `PUT` succeeds, and
2691
- * not before. It is create-only and short-lived, and it is a bearer capability, so
2692
- * keep it out of logs. The route never replays a grant: ask again for a new one
2693
- * rather than retrying. A declared `size` over the cap is a `413` `ApiResponseError`
2694
- * (`code` `payload_too_large`); a deployment without the route answers a `404`. See
2695
- * `docs/input-preparation.md`.
2796
+ * as a browser page: the holder of the bytes describes the file with `describeUpload`,
2797
+ * its SHA-256 included, the credential-holding side asks for the grant with that
2798
+ * description and hands the grant over, and the holder of the bytes sends them with
2799
+ * `uploadWithGrant`. Both are on the browser-safe `@pipelex/sdk/upload` entry. The
2800
+ * bytes cross neither this client nor the API gateway, so the gateway's request quota
2801
+ * does not cap the file below the service's own limit (`max_bytes`).
2802
+ *
2803
+ * **Branch on the answer's `stored`.** With the file's `sha256`, the object is named
2804
+ * after its bytes: when the organization already holds them, the grant answers
2805
+ * `stored: true` with the file's `uri` and `file_id` and no upload target, and nothing
2806
+ * is to be sent. Otherwise, and always without a `sha256`, it answers a presigned,
2807
+ * create-only `PUT` whose `uri` exists once the `PUT` succeeds, and not before. That
2808
+ * target is short-lived and a bearer capability, so keep it out of logs. The route
2809
+ * never replays a grant: ask again for a new one rather than retrying; with a
2810
+ * `sha256`, a new grant answers the same file record. A declared `size` over the cap
2811
+ * is a `413` `ApiResponseError` (`code` `payload_too_large`), a malformed `sha256` and
2812
+ * a `size` other than the one the organization holds those bytes at are a `422`, and a
2813
+ * deployment without the route answers a `404`. See `docs/input-preparation.md`.
2696
2814
  */
2697
2815
  async requestUploadGrant(input, options = {}) {
2698
2816
  return this.requestProductObject("POST", "upload/grant", input, options);
@@ -2805,6 +2923,306 @@ export class PipelexApiClient {
2805
2923
  async *iterateFiles(query = {}) {
2806
2924
  yield* followCursor("listFiles", (cursor) => this.listFiles({ ...query, cursor }));
2807
2925
  }
2926
+ /**
2927
+ * One file with its provenance, the methods whose lists hold it, and one page of its uses —
2928
+ * `GET /v1/files/{file_id}`.
2929
+ *
2930
+ * The answer is a `FileDetail`: the file's whole record (`FileRecord`), its `method_ids`, and
2931
+ * `uses`, one page of the examples that hold its address, then the runs that took it. Page the
2932
+ * uses by passing `uses.next_cursor` back as `usesCursor`; `null` is the last page. A record whose
2933
+ * upload looked unfinished past the upload grant's lifespan is settled by the read, so it answers
2934
+ * `stored` when its bytes are there.
2935
+ *
2936
+ * Throws `RequestArgumentError` for a file id that is empty, `.` or `..`, or holds a lone
2937
+ * surrogate, before any request. Throws `ApiResponseError`: `404` `not_found` for a file that is
2938
+ * not the organization's; `422` for a `usesLimit` outside 1 to 200 or an id that cannot be one.
2939
+ * An answer that is not an object, or whose `uses` is not a page of `items` and `next_cursor`, is
2940
+ * an answer the SDK cannot read, thrown as an `ApiResponseError` too. See `docs/files.md`.
2941
+ */
2942
+ async getFile(fileId, query = {}) {
2943
+ const search = productQuery({ uses_limit: query.usesLimit, uses_cursor: query.usesCursor });
2944
+ const endpoint = `files/${idPathSegment(fileId, "getFile", FILE_ID_ARGUMENT)}${search}`;
2945
+ const res = await this.requestProductAnswer("GET", endpoint, undefined);
2946
+ const detail = this.readObjectAnswer("GET", endpoint, res);
2947
+ const uses = detail.uses;
2948
+ if (!isPlainObject(uses) ||
2949
+ !Array.isArray(uses.items) ||
2950
+ (uses.next_cursor !== null && typeof uses.next_cursor !== "string")) {
2951
+ throw this.unreadableAnswer("GET", `/${API_PREFIX}/${endpoint}`, res, "a file whose `uses` is not a page of `items` and `next_cursor`");
2952
+ }
2953
+ return detail;
2954
+ }
2955
+ /**
2956
+ * The records holding one `pipelex-storage://` address — `GET /v1/files/by-uri` — in the order a
2957
+ * surface picks the one to show, the first being it.
2958
+ *
2959
+ * Several records hold one address when several members uploaded the same bytes. With `runId`,
2960
+ * the records of the member who started that run come first, so a run's inputs show the name its
2961
+ * starter gave the file; then the caller's; then the rest, each group oldest first. A record
2962
+ * whose upload has not finished is answered with its `state`, `pending`. An address no record
2963
+ * holds answers an empty `items`, which is how a client tells a file stored before the platform
2964
+ * kept records.
2965
+ *
2966
+ * Throws `RequestArgumentError` for a `uri` that is not a non-empty string, before any request.
2967
+ * Throws `ApiResponseError`: `400` `invalid_storage_uri` for a malformed address, `403`
2968
+ * `forbidden` for another organization's, `404` `run_not_found` for a run that is not the
2969
+ * organization's, and `422` for an address over 512 characters. An answer whose `items` is not an
2970
+ * array is an answer the SDK cannot read, thrown as an `ApiResponseError` too.
2971
+ */
2972
+ async getFilesByUri(uri, options = {}) {
2973
+ const checkedUri = requireNonEmptyId(uri, "getFilesByUri", STORAGE_URI_ARGUMENT);
2974
+ const endpoint = `files/by-uri${productQuery({ uri: checkedUri, run_id: options.runId })}`;
2975
+ const res = await this.requestProductAnswer("GET", endpoint, undefined);
2976
+ const answer = this.readObjectAnswer("GET", endpoint, res);
2977
+ if (!Array.isArray(answer.items)) {
2978
+ throw this.unreadableAnswer("GET", `/${API_PREFIX}/${endpoint}`, res, "an answer whose `items` is not an array");
2979
+ }
2980
+ return answer;
2981
+ }
2982
+ /**
2983
+ * Rename a file — `PATCH /v1/files/{file_id}` — and return its record as it now stands.
2984
+ *
2985
+ * **The platform holds the name to one rule, for every client**: it removes the characters no
2986
+ * list or header can show (control and format characters, a right-to-left override among
2987
+ * them), trims the ends, refuses a name with nothing left, and adds the file's own extension,
2988
+ * the one its `original_name` ends with, when the name does not end with it, case aside:
2989
+ * `March invoice` becomes `March invoice.pdf`, and `March.docx` becomes `March.docx.pdf`. A file
2990
+ * whose original name has no extension gains none. Read the saved name from the answer's
2991
+ * `name`. The rename changes `name` alone, never the file's bytes, its address or its
2992
+ * `original_name`.
2993
+ *
2994
+ * An `admin` or a `member` renames any file; any other role, a runner among them, only the files
2995
+ * whose record names it as `created_by`: its own uploads and the outputs of the runs it started.
2996
+ *
2997
+ * Sends `name` alone: the body is closed. Throws `RequestArgumentError` for a file id that is
2998
+ * empty, `.` or `..`, or holds a lone surrogate, and for a `name` that is not a string, before
2999
+ * any request. Throws `ApiResponseError`: `403` `role_not_permitted` past the rule above; `404`
3000
+ * `not_found` for a file that is not the organization's; `422` for a name over 512 characters or
3001
+ * with nothing left once cleaned. An answer that is not an object is an answer the SDK cannot
3002
+ * read, thrown as an `ApiResponseError` too.
3003
+ */
3004
+ async renameFile(fileId, input) {
3005
+ const segment = idPathSegment(fileId, "renameFile", FILE_ID_ARGUMENT);
3006
+ const { name } = input ?? {};
3007
+ if (typeof name !== "string") {
3008
+ throw argumentRefusal("renameFile", RENAME_NAME_ARGUMENT, describeNumberArgument(name));
3009
+ }
3010
+ return this.requestProductObject("PATCH", `files/${segment}`, { name });
3011
+ }
3012
+ /**
3013
+ * Fresh links to read files — `POST /v1/files/links` — one per requested file, in the request's
3014
+ * order, each named by exactly one of its record's id (`file_id`) and its address (`uri`).
3015
+ *
3016
+ * **Branch on each item's `error`, never on the status**: the request is a `200` whenever every
3017
+ * file got a verdict. A linked file carries its `url`, a presigned `GET` valid for fifteen
3018
+ * minutes, with its `expires_at`, the `name` the download saves under and its `media_type`, and
3019
+ * a `null` `error`. A file that got no link carries `error`, `{ code, detail }`, and `null` link
3020
+ * members: `not_found` for an unknown file id, `conflict` for a file whose upload has not
3021
+ * finished, `invalid_storage_uri` for a malformed address and `forbidden` for another
3022
+ * organization's.
3023
+ *
3024
+ * Each link is signed with a `Content-Disposition` naming the file, so a download saves under
3025
+ * the name a person sees rather than the bytes' hash. It opens in place (`inline`) for an image,
3026
+ * a PDF or plain text and downloads (`attachment`) for every other type, SVG, HTML and XML
3027
+ * included, so a file that could run scripts never opens from a link. A file named by its id is
3028
+ * linked under its record's name; an address under the name of its first stored record, the
3029
+ * caller's before another member's, whose id the item's `file_id` names. A link is a bearer
3030
+ * capability: keep it out of logs, and ask again for a fresh one rather than storing it.
3031
+ *
3032
+ * Sends each file as `{ file_id }` or `{ uri }` and nothing else: the body is closed. Throws
3033
+ * `RequestArgumentError` before any request when `files` is not a list, or one of its items does
3034
+ * not name exactly one of `file_id` and `uri` as a non-empty string. Throws `ApiResponseError`:
3035
+ * `422` for more than 100 files, or an id or address longer than one can be; `500` `config` when
3036
+ * the platform cannot sign at all. An answer that is not one item per requested file, each an
3037
+ * object with its `url` or its `error`, is an answer the SDK cannot read, thrown as an
3038
+ * `ApiResponseError` whose body hides every link.
3039
+ */
3040
+ async linkFiles(files) {
3041
+ if (!Array.isArray(files)) {
3042
+ throw argumentRefusal("linkFiles", "a list of files to link", describeNumberArgument(files));
3043
+ }
3044
+ const targets = files.map((target) => fileLinkTarget(target));
3045
+ const res = withLinks(await this.requestProductAnswer("POST", "files/links", { files: targets }));
3046
+ const answer = this.readObjectAnswer("POST", "files/links", res);
3047
+ const items = answer.items;
3048
+ if (!Array.isArray(items) || items.length !== targets.length) {
3049
+ throw this.unreadableAnswer("POST", `/${API_PREFIX}/files/links`, res, "an answer that is not one item per requested file");
3050
+ }
3051
+ for (const item of items) {
3052
+ const linked = isPlainObject(item) && item.error === null && typeof item.url === "string";
3053
+ const refused = isPlainObject(item) && isPlainObject(item.error) && item.url === null;
3054
+ if (!linked && !refused) {
3055
+ throw this.unreadableAnswer("POST", `/${API_PREFIX}/files/links`, res, "an item that carries neither a link nor an error");
3056
+ }
3057
+ }
3058
+ return answer;
3059
+ }
3060
+ /**
3061
+ * Ask for one zip of chosen files, or of a run's files — `POST /v1/files/archives` — and return
3062
+ * the archive, `queued`.
3063
+ *
3064
+ * The platform cannot build a zip inside a request, so the zip is two calls: this one queues the
3065
+ * archive and answers at once (`202`), and `getFileArchive` reads it until its `state` is `ready`,
3066
+ * whose `url` downloads the zip, or `failed`, whose `failure` says why. Name the files by exactly
3067
+ * one of `file_ids`, in the order the zip holds them, and `run_id`, whose zip holds what went in
3068
+ * and what came out of the run, never a file it made along the way nor an input given as a web
3069
+ * link. `name` is the name the zip downloads under (see `FileArchiveRequest`). An archive is never
3070
+ * a file: no list shows it, and it is gone a day after it was asked for.
3071
+ *
3072
+ * Sends `file_ids` or `run_id`, and `name` when given, and nothing else: the body is closed, and a
3073
+ * `null` member reads as not given. Throws `RequestArgumentError` before any request when the
3074
+ * request names both or neither of `file_ids` and `run_id`, when `file_ids` is not a non-empty
3075
+ * list of non-empty strings, when `run_id` is not a non-empty string, and when `name` is not a
3076
+ * string. Throws `ApiResponseError`:
3077
+ *
3078
+ * - `422` `file_archive_too_large` past a cap, at most 500 files or 2,000,000,000 bytes, whose
3079
+ * `problemDocument.limit` names it (`FileArchiveLimit`), so the person can be told how far over
3080
+ * the selection is;
3081
+ * - `422` `validation_failed` for more than 1,000 ids listed, or an id longer than one can be;
3082
+ * - `404` `not_found` for a file that is not the organization's, the `detail` naming up to five
3083
+ * of them, or a run whose files hold nothing stored to zip; `404` `run_not_found` for a run
3084
+ * that is not the organization's;
3085
+ * - `409` `conflict`: an `input` one, not retryable, for a selected file whose upload has not
3086
+ * finished; a `runtime` one, retryable, for a run whose files are not all recorded yet, which
3087
+ * the same request clears a few seconds after the run ends;
3088
+ * - `503` `service_unavailable`, retryable, when the archive could not be queued: the same request
3089
+ * sent again asks for a new archive.
3090
+ *
3091
+ * An answer that is not an archive, an object with a string `archive_id` and a known `state`, is
3092
+ * an answer the SDK cannot read, thrown as an `ApiResponseError` too.
3093
+ */
3094
+ async requestFileArchive(request) {
3095
+ const call = "requestFileArchive";
3096
+ if (!isPlainObject(request)) {
3097
+ throw argumentRefusal(call, "an archive request, an object naming `file_ids` or `run_id`", describeNumberArgument(request));
3098
+ }
3099
+ const raw = request;
3100
+ const fileIds = raw.file_ids ?? undefined;
3101
+ const runId = raw.run_id ?? undefined;
3102
+ const name = raw.name ?? undefined;
3103
+ if ((fileIds === undefined) === (runId === undefined)) {
3104
+ throw new RequestArgumentError(`${call}() names the files to zip by exactly one of file_ids and run_id; got ` +
3105
+ `${fileIds === undefined ? "neither" : "both"}.`);
3106
+ }
3107
+ const body = {};
3108
+ if (fileIds !== undefined) {
3109
+ if (!Array.isArray(fileIds) || fileIds.length === 0) {
3110
+ throw argumentRefusal(call, "file_ids, a non-empty list of the file ids to zip", Array.isArray(fileIds) ? "an empty list" : describeNumberArgument(fileIds));
3111
+ }
3112
+ body.file_ids = fileIds.map((id) => requireNonEmptyId(id, call, ARCHIVE_FILE_ID_ARGUMENT));
3113
+ }
3114
+ else {
3115
+ body.run_id = requireNonEmptyId(runId, call, ARCHIVE_RUN_ID_ARGUMENT);
3116
+ }
3117
+ if (name !== undefined) {
3118
+ if (typeof name !== "string") {
3119
+ throw argumentRefusal(call, ARCHIVE_NAME_ARGUMENT, describeNumberArgument(name));
3120
+ }
3121
+ body.name = name;
3122
+ }
3123
+ const res = await this.requestProductAnswer("POST", "files/archives", body);
3124
+ return this.readFileArchive("POST", "files/archives", res);
3125
+ }
3126
+ /**
3127
+ * Where an archive stands — `GET /v1/files/archives/{archive_id}` — with a fresh link to its zip
3128
+ * once it is `ready`.
3129
+ *
3130
+ * **Branch on `state`.** `queued` and `building` mean read it again shortly: a zip of a few files
3131
+ * is ready within seconds, and the largest the caps allow within a few minutes. `ready` carries
3132
+ * `url`, a presigned `GET` of the zip valid for fifteen minutes and signed with a
3133
+ * `Content-Disposition` naming the archive's `name`, with `archive_bytes` and `ready_at`; each read
3134
+ * signs a fresh link, so a caller that let one lapse reads the archive again. `failed` carries
3135
+ * `failure`, `file_gone` or `build_failed`, and the remedy for both is a new request. An archive
3136
+ * still `queued` or `building` an hour after it was asked for reads as `failed`.
3137
+ *
3138
+ * Throws `RequestArgumentError` for an archive id that is empty, `.` or `..`, or holds a lone
3139
+ * surrogate, before any request. Throws `ApiResponseError`: `404` `not_found` for an archive that
3140
+ * is not the organization's, never existed, or has expired, a day after `created_at`; `500` when
3141
+ * the link cannot be signed. An answer that is not an archive is an answer the SDK cannot read,
3142
+ * thrown as an `ApiResponseError` whose body hides the link.
3143
+ */
3144
+ async getFileArchive(archiveId) {
3145
+ const segment = idPathSegment(archiveId, "getFileArchive", ARCHIVE_ID_ARGUMENT);
3146
+ const endpoint = `files/archives/${segment}`;
3147
+ const res = await this.requestProductAnswer("GET", endpoint, undefined);
3148
+ return this.readFileArchive("GET", endpoint, res);
3149
+ }
3150
+ /**
3151
+ * An archive as `res` carried it, checked to be one: an object with a string `archive_id` and a
3152
+ * `state` this SDK knows, the field a caller branches on. Anything else is an answer the SDK
3153
+ * cannot read, thrown as the `ApiResponseError` `unreadableAnswer` builds with every link hidden,
3154
+ * since a ready archive's answer carries one.
3155
+ */
3156
+ readFileArchive(method, endpoint, res) {
3157
+ const linked = withLinks(res);
3158
+ const archive = this.readObjectAnswer(method, endpoint, linked);
3159
+ if (typeof archive.archive_id !== "string") {
3160
+ throw this.unreadableAnswer(method, `/${API_PREFIX}/${endpoint}`, linked, "an archive whose `archive_id` is not a string");
3161
+ }
3162
+ if (!FILE_ARCHIVE_STATES.includes(archive.state)) {
3163
+ throw this.unreadableAnswer(method, `/${API_PREFIX}/${endpoint}`, linked, `an archive whose \`state\` is not ${inWords(FILE_ARCHIVE_STATES.map((state) => `\`${state}\``), "or")}`);
3164
+ }
3165
+ return archive;
3166
+ }
3167
+ /**
3168
+ * Copy one of the organization's files into a method's draft —
3169
+ * `POST /v1/methods/{id}/draft/files/from-file`, "Add to method files".
3170
+ *
3171
+ * The file becomes a file of the draft at `path`, replacing a file already there. Its bytes are
3172
+ * copied into the organization's blob store by the storage service itself, so they never cross
3173
+ * the client or the API whatever their size, and are skipped when the store already holds them.
3174
+ * The method file then lives apart from its source: erasing the file's record, the run that made
3175
+ * it or the method it came from leaves it readable.
3176
+ *
3177
+ * The answer is a `DraftFileFromFileResult`, a `200` discriminated on `blob`: `copied`, or
3178
+ * `already_held` when the store held the bytes and nothing was copied. Both carry the `file` as
3179
+ * the draft's tree now names it and the `method` as the draft write stored it, whose
3180
+ * `updated_at` is the new draft token. With `expected_updated_at`, the write is a
3181
+ * compare-and-swap on the draft token; without it, the file is added to the draft as it stands,
3182
+ * never erasing someone else's save.
3183
+ *
3184
+ * Takes a bare catalog id, as `writeDraft` does. Sends `file_id`, `path` and, when given,
3185
+ * `expected_updated_at`, and nothing else: the body is closed, and a `null` token reads as not
3186
+ * given. Throws `RequestArgumentError` before any request for a `file_id` or a `path` that is not
3187
+ * a non-empty string, and an `expected_updated_at` that is not a string. Throws
3188
+ * `ApiResponseError`: `403` `role_not_permitted` for a role that may not change methods; `404`
3189
+ * for an unknown method or a file that is not the organization's; `409` `method_being_deleted`,
3190
+ * `409` `method_update_conflict` for a stale `expected_updated_at`, and `409` `conflict` for a
3191
+ * file whose upload has not finished or whose stored bytes do not match its record; `422`
3192
+ * `invalid_method_files` for a path the draft cannot hold, such as `.env` or `../logo.png`, and
3193
+ * `422` `method_tree_too_large` past the deployment's limits. An answer whose `blob` is neither
3194
+ * arm's, or whose `file` or `method` is not an object, is an answer the SDK cannot read, thrown
3195
+ * as an `ApiResponseError` too.
3196
+ */
3197
+ async copyFileIntoDraft(methodId, input) {
3198
+ const call = "copyFileIntoDraft";
3199
+ const endpoint = `${methodPath(methodId, call)}/draft/files/from-file`;
3200
+ const { file_id: fileId, path, expected_updated_at: token, } = input ?? {};
3201
+ const body = {
3202
+ file_id: requireNonEmptyId(fileId, call, FILE_ID_ARGUMENT),
3203
+ path: requireNonEmptyId(path, call, DRAFT_PATH_ARGUMENT),
3204
+ };
3205
+ if (token != null) {
3206
+ if (typeof token !== "string") {
3207
+ throw argumentRefusal(call, DRAFT_TOKEN_ARGUMENT, describeNumberArgument(token));
3208
+ }
3209
+ body.expected_updated_at = token;
3210
+ }
3211
+ const res = await this.requestProductAnswer("POST", endpoint, body);
3212
+ const answer = this.readObjectAnswer("POST", endpoint, res);
3213
+ let fault;
3214
+ if (!DRAFT_FILE_BLOBS.includes(answer.blob)) {
3215
+ fault = `whose \`blob\` is not ${inWords(DRAFT_FILE_BLOBS.map((blob) => `\`${blob}\``), "or")}`;
3216
+ }
3217
+ else if (!isPlainObject(answer.file))
3218
+ fault = "whose `file` is not an object";
3219
+ else if (!isPlainObject(answer.method))
3220
+ fault = "whose `method` is not an object";
3221
+ if (fault !== undefined) {
3222
+ throw this.unreadableAnswer("POST", `/${API_PREFIX}/${endpoint}`, res, `a copy into a draft ${fault}`);
3223
+ }
3224
+ return answer;
3225
+ }
2808
3226
  /**
2809
3227
  * One PAGE of a method's runs, newest first — `GET /v1/runs?method_id=`.
2810
3228
  *
@@ -2903,6 +3321,51 @@ export class PipelexApiClient {
2903
3321
  const res = await this.requestProductAnswer("GET", endpoint, undefined);
2904
3322
  return withCheckedReport(this.readObjectAnswer("GET", endpoint, res, {}, parseExactJson));
2905
3323
  }
3324
+ /**
3325
+ * One page of a run's files, sorted by where they sit in the run — `GET /v1/runs/{id}/files`.
3326
+ *
3327
+ * Each entry carries its `mark`, `input`, `final` or `intermediate`, and the record a surface
3328
+ * shows for it (`RunFileEntry`): `inputs` are what went in, the inputs given by their address
3329
+ * with the record of the member who started the run first, then the inline inputs; `outputs` are
3330
+ * the run's final outputs; `intermediates` the files it made along the way. An input given as a
3331
+ * web link gets no record and is listed in `links`, with the warning that replaying the run needs
3332
+ * it to still serve the same file.
3333
+ *
3334
+ * **`state` is `listing` until the platform has recorded every file of the run**, a few seconds
3335
+ * after it ends: read it again until it is `listed` before taking the list as whole. The run's
3336
+ * own records are paged by address with `limit` and `cursor`; the inputs by address and the links
3337
+ * come on the first page only. Pass `nextCursor` back as `cursor`; `null` is the last page.
3338
+ *
3339
+ * Throws `RequestArgumentError` for a run id that is empty, `.` or `..`, or holds a lone
3340
+ * surrogate, before any request. Throws `ApiResponseError`: `404` for a run that is not the
3341
+ * organization's, exactly as for one that does not exist; `400` for a run id holding a character
3342
+ * outside letters, digits, `_` and `-`, or a cursor another listing issued; `422` for a `limit`
3343
+ * outside 1 to 200. A page whose `state` is unknown, whose lists are not arrays, or whose `next_cursor` is neither a
3344
+ * string nor `null`, is an answer the SDK cannot read, thrown as an `ApiResponseError` too.
3345
+ */
3346
+ async listRunFiles(runId, query = {}) {
3347
+ const segment = idPathSegment(runId, "listRunFiles", RUN_ID_ARGUMENT);
3348
+ const search = productQuery({ limit: query.limit, cursor: query.cursor });
3349
+ const endpoint = `runs/${segment}/files${search}`;
3350
+ const res = await this.requestProductAnswer("GET", endpoint, undefined);
3351
+ const page = this.readObjectAnswer("GET", endpoint, res);
3352
+ const { next_cursor: nextCursor, ...rest } = page;
3353
+ const notAList = RUN_FILES_LISTS.find((list) => !Array.isArray(page[list]));
3354
+ let fault;
3355
+ if (!RUN_FILES_STATES.includes(page.state)) {
3356
+ fault = "whose `state` is not `listing` or `listed`";
3357
+ }
3358
+ else if (notAList !== undefined) {
3359
+ fault = `whose \`${notAList}\` is not an array`;
3360
+ }
3361
+ else if (nextCursor !== null && typeof nextCursor !== "string") {
3362
+ fault = "whose `next_cursor` is neither a string nor null";
3363
+ }
3364
+ if (fault !== undefined) {
3365
+ throw this.unreadableAnswer("GET", `/${API_PREFIX}/${endpoint}`, res, `a page of a run's files ${fault}`);
3366
+ }
3367
+ return { ...rest, nextCursor };
3368
+ }
2906
3369
  /** Patch a run's status (admin/manual) — `PUT /v1/runs/{id}`. */
2907
3370
  async updateRun(runId, input) {
2908
3371
  const segment = idPathSegment(runId, "updateRun", RUN_ID_ARGUMENT);