@pipelex/sdk 0.49.0 → 0.51.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,28 @@ 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";
236
+ /** What each argument of a method copy is, as a refusal of it names it. */
237
+ const COPY_DESTINATION_ARGUMENT = "a destination_org_id, the canonical id of the organization to copy into, a non-empty string";
238
+ const COPY_NAME_ARGUMENT = "a name for the copy, a string, or none";
239
+ const COPY_DRAFT_TOKEN_ARGUMENT = "an expected_draft_updated_at, the draft token the caller last read, the method's " +
240
+ "`updated_at`, a string, or none";
222
241
  /**
223
242
  * The refusal of an argument a call cannot take, worded as every such refusal is:
224
243
  * `<call>() takes <what>; got <got>.`, with what made it unusable as `cause` when there is one.
@@ -243,6 +262,22 @@ function requireNonEmptyId(value, call, what) {
243
262
  got = value === null ? "null" : typeof value;
244
263
  throw argumentRefusal(call, what, got);
245
264
  }
265
+ /**
266
+ * The check the example writes that take a name and inputs share, the create and the assistant's
267
+ * save: a `name` or `inputs` not given, a `null` counting as not given, is refused with
268
+ * `RequestArgumentError` before anything is sent, `call` naming the method in the message and
269
+ * `needs` what it needs.
270
+ */
271
+ function requireNameAndInputs(name, inputs, call, needs) {
272
+ if (name != null && inputs != null)
273
+ return;
274
+ let got;
275
+ if (name == null && inputs == null)
276
+ got = "neither";
277
+ else
278
+ got = name == null ? "no name" : "no inputs";
279
+ throw new RequestArgumentError(`${call}() needs ${needs}; got ${got}.`);
280
+ }
246
281
  /**
247
282
  * A caller's value as the refusal of a number argument names it: a number by its value, anything
248
283
  * else by its type, as `requireNonEmptyId` names a value, and `null` as itself. Never by `String`,
@@ -307,6 +342,37 @@ function methodExamplePath(methodId, exampleId, call) {
307
342
  return `${path}/examples/${idPathSegment(exampleId, call, EXAMPLE_ID_ARGUMENT)}`;
308
343
  }
309
344
  const isString = (value) => typeof value === "string";
345
+ /** The states an archive is in, the field a caller branches on, in the order a refusal lists them. */
346
+ const FILE_ARCHIVE_STATES = ["queued", "building", "ready", "failed"];
347
+ /** What a copy into a draft did in the blob store, the field its answer is discriminated on. */
348
+ const DRAFT_FILE_BLOBS = ["copied", "already_held"];
349
+ /** What a method copy did, the field its answer is discriminated on. */
350
+ const METHOD_COPY_OUTCOMES = ["copied", "refused"];
351
+ /** Whether a run's records are all written, the field a reader of its files branches on. */
352
+ const RUN_FILES_STATES = ["listing", "listed"];
353
+ /** The lists of a page of a run's files, each an array on every page. */
354
+ const RUN_FILES_LISTS = ["inputs", "outputs", "intermediates", "links"];
355
+ /**
356
+ * One file `linkFiles` is asked to link, checked and reduced to what the closed body takes:
357
+ * `{ file_id }` or `{ uri }`, exactly one of them a non-empty string. Anything else, an object
358
+ * naming both, neither or an empty one included, is refused with `RequestArgumentError` before
359
+ * anything is sent, as the platform would refuse the whole request with a `422`.
360
+ */
361
+ function fileLinkTarget(value) {
362
+ if (!isPlainObject(value)) {
363
+ throw argumentRefusal("linkFiles", LINK_TARGET_ARGUMENT, describeNumberArgument(value));
364
+ }
365
+ const fileId = value.file_id ?? undefined;
366
+ const uri = value.uri ?? undefined;
367
+ if ((fileId === undefined) === (uri === undefined)) {
368
+ const got = fileId === undefined ? "an object naming neither" : "an object naming both";
369
+ throw argumentRefusal("linkFiles", LINK_TARGET_ARGUMENT, got);
370
+ }
371
+ if (fileId !== undefined) {
372
+ return { file_id: requireNonEmptyId(fileId, "linkFiles", LINK_TARGET_ARGUMENT) };
373
+ }
374
+ return { uri: requireNonEmptyId(uri, "linkFiles", LINK_TARGET_ARGUMENT) };
375
+ }
310
376
  /**
311
377
  * Each field of a `MethodExample`, what it must be, and the check it must pass: every field the
312
378
  * Python SDK's model requires, held to the same strict types it holds them to, so both SDKs refuse
@@ -331,6 +397,7 @@ const METHOD_EXAMPLE_FIELDS = {
331
397
  created_by_user_id: ["a string", isString],
332
398
  created_at: ["a string", isString],
333
399
  updated_at: ["a string", isString],
400
+ kept_by_assistant: ["a boolean", (value) => typeof value === "boolean"],
334
401
  };
335
402
  export class PipelexApiClient {
336
403
  apiKey;
@@ -419,7 +486,7 @@ export class PipelexApiClient {
419
486
  * resolved absolute URL. `mayStartRun` marks a request that may create a run,
420
487
  * whose `ApiUnreachableError` then says whether it may have (see `RunCreation`).
421
488
  * `idempotencyKey`, already checked by `idempotencyKeyOf`, is sent as the
422
- * `Idempotency-Key` header; only `start` passes one.
489
+ * `Idempotency-Key` header; only `start` and `copyMethod` pass one.
423
490
  */
424
491
  async requestRaw(method, url, options = {}) {
425
492
  const hasBody = options.body !== undefined;
@@ -632,6 +699,7 @@ export class PipelexApiClient {
632
699
  body,
633
700
  timeoutMs: options.timeoutMs ?? POLL_REQUEST_TIMEOUT_MS,
634
701
  signal: options.signal,
702
+ idempotencyKey: options.idempotencyKey,
635
703
  });
636
704
  if (res.status < 200 || res.status >= 300) {
637
705
  this.throwApiResponseError(method, endpoint, res);
@@ -2287,6 +2355,116 @@ export class PipelexApiClient {
2287
2355
  const res = await this.requestProductAnswer("GET", endpoint, undefined);
2288
2356
  return this.readSavedTree("GET", endpoint, withLinks(res), "a published version");
2289
2357
  }
2358
+ /**
2359
+ * Copy a method into an organization the caller may change methods in —
2360
+ * `POST /v1/methods/{id}/copy`: the method's own organization for a duplicate, another team, or
2361
+ * the caller's personal workspace.
2362
+ *
2363
+ * The copy is a new, unpublished method of the destination, the caller's, holding the
2364
+ * original's draft tree (the same `draft_digest`), its name unless `name` gives another, its
2365
+ * form inputs and every saved example, each stored file they name copied into the destination,
2366
+ * and `copied_from` naming the original. Published versions and runs are not copied, and the
2367
+ * original stays as it was. The copy takes the draft, never the latest published version; with
2368
+ * `expected_draft_updated_at`, it takes exactly the draft the caller last saw.
2369
+ *
2370
+ * The answer is a `MethodCopyResult` discriminated on `outcome` — branch on it: `copied` with
2371
+ * the copy as `method`, `examples_copied` and `stored_files_copied`; `refused` with the
2372
+ * `reason` `bundle_names_other_storage`, a `message` and every `references` entry (`{ path, uri
2373
+ * }`) a `.mthds` file of the draft holds that the destination cannot read, nothing written.
2374
+ *
2375
+ * The route serves web sessions only: a Pipelex API key belongs to one organization and cannot
2376
+ * speak for another. `options.idempotencyKey` is sent as the `Idempotency-Key` header, so a copy
2377
+ * sent again under the same key is answered with the first one's answer rather than copied
2378
+ * twice (see `MethodCopyRequestOptions`).
2379
+ *
2380
+ * Takes a bare catalog id, as `getMethod` does. Sends `destination_org_id` and, when given,
2381
+ * `name` and `expected_draft_updated_at`, and nothing else: the body is closed, and a `null`
2382
+ * reads as not given. Throws `RequestArgumentError` before any request for a
2383
+ * `destination_org_id` that is not a non-empty string, a `name` or an
2384
+ * `expected_draft_updated_at` given that is not a string, and a malformed idempotency key.
2385
+ * Throws `ApiResponseError` when no verdict was produced, each refusal writing nothing a reader
2386
+ * sees: `403` `forbidden` for a request that is not from a web session; `403`
2387
+ * `role_not_permitted` for a role that may not change methods, in the method's organization or
2388
+ * in the destination; `404` `organization_not_found` for a destination the caller does not
2389
+ * belong to, still being set up or unknown, alike; `404` `not_found` for an unknown method;
2390
+ * `409` `method_being_deleted`; `409` `method_update_conflict` for a draft that moved since
2391
+ * `expected_draft_updated_at`; `409` `conflict`, `runtime`, for a draft whose tree the
2392
+ * organization holds no full record of; `413` `payload_too_large` for an example or a copy too
2393
+ * large for one stored item; `422` `validation_failed` for a body that does not validate, such
2394
+ * as a name holding a lone surrogate; `422` `method_copy_too_large` past one of the copy's
2395
+ * bounds on the stored files it copies, named by `problemDocument.limit` (`MethodCopyLimit`);
2396
+ * and, each retryable, a `502` when the platform could not read the caller's memberships, a
2397
+ * `500` `storage_backend_failed` and a `503` past the copy's deadline. An answer whose
2398
+ * `outcome` is neither arm's, a `copied` one whose `method` is not an object or a `refused` one
2399
+ * whose `references` is not an array, is an answer the SDK cannot read, thrown as an
2400
+ * `ApiResponseError` too.
2401
+ */
2402
+ async copyMethod(methodId, input, options = {}) {
2403
+ const call = "copyMethod";
2404
+ const endpoint = `${methodPath(methodId, call)}/copy`;
2405
+ const { destination_org_id: destinationOrgId, name, expected_draft_updated_at: token, } = input ?? {};
2406
+ const body = {
2407
+ destination_org_id: requireNonEmptyId(destinationOrgId, call, COPY_DESTINATION_ARGUMENT),
2408
+ };
2409
+ if (name != null) {
2410
+ if (typeof name !== "string") {
2411
+ throw argumentRefusal(call, COPY_NAME_ARGUMENT, describeNumberArgument(name));
2412
+ }
2413
+ body.name = name;
2414
+ }
2415
+ if (token != null) {
2416
+ if (typeof token !== "string") {
2417
+ throw argumentRefusal(call, COPY_DRAFT_TOKEN_ARGUMENT, describeNumberArgument(token));
2418
+ }
2419
+ body.expected_draft_updated_at = token;
2420
+ }
2421
+ const idempotencyKey = idempotencyKeyOf(options, "copy the method");
2422
+ const res = await this.requestProductAnswer("POST", endpoint, body, { idempotencyKey });
2423
+ const answer = this.readObjectAnswer("POST", endpoint, res);
2424
+ let fault;
2425
+ switch (answer.outcome) {
2426
+ case "copied":
2427
+ if (!isPlainObject(answer.method)) {
2428
+ fault = "a `copied` copy result whose `method` is not an object";
2429
+ }
2430
+ break;
2431
+ case "refused":
2432
+ if (!Array.isArray(answer.references)) {
2433
+ fault = "a `refused` copy result whose `references` is not an array";
2434
+ }
2435
+ break;
2436
+ default:
2437
+ fault = `a copy result whose \`outcome\` is not ${inWords(METHOD_COPY_OUTCOMES.map((outcome) => `\`${outcome}\``), "or")}`;
2438
+ }
2439
+ if (fault !== undefined) {
2440
+ throw this.unreadableAnswer("POST", `/${API_PREFIX}/${endpoint}`, res, fault);
2441
+ }
2442
+ return answer;
2443
+ }
2444
+ /**
2445
+ * One page of the copies made of a method, in the order of the copies' ids, which says nothing
2446
+ * of when each was made — `GET /v1/methods/{id}/copies`.
2447
+ *
2448
+ * Each row is the `MethodCopyMarker` the original keeps of a copy: the copy's id, the
2449
+ * destination's id, the member who made it and when. The destination is named by its id alone,
2450
+ * never its name, so a surface shows a destination's name only from the reader's own
2451
+ * memberships, and a copy in an organization the reader does not belong to stays a bare id. A
2452
+ * marker stays when its copy is erased, and the copy then reads as not found. Pass the page's
2453
+ * `nextCursor` back as `cursor` to continue; `null` is the last page. A method never copied,
2454
+ * and a copy itself, answer an empty page. The listing is open to every member of the method's
2455
+ * organization, a runner included, as every read of a method is.
2456
+ *
2457
+ * Takes a bare catalog id, as `getMethod` does. Throws `ApiResponseError`: `404` `not_found` for
2458
+ * an unknown method, `409` `method_being_deleted`, `400` `malformed_request` for a cursor the
2459
+ * listing did not issue for this method, `422` for a `limit` outside 1 to 100. A page whose
2460
+ * `items` is not an array or whose `next_cursor` is neither a string nor `null` is an answer the
2461
+ * SDK cannot read, thrown as an `ApiResponseError` too.
2462
+ */
2463
+ async listMethodCopies(methodId, query = {}) {
2464
+ const search = productQuery({ limit: query.limit, cursor: query.cursor });
2465
+ const endpoint = `${methodPath(methodId, "listMethodCopies")}/copies`;
2466
+ return this.requestPage(`${endpoint}${search}`);
2467
+ }
2290
2468
  /**
2291
2469
  * Erase a method and everything it produced — `DELETE /v1/methods/{id}`.
2292
2470
  *
@@ -2337,14 +2515,7 @@ export class PipelexApiClient {
2337
2515
  async createMethodExample(methodId, input) {
2338
2516
  const endpoint = `${methodPath(methodId, "createMethodExample")}/examples`;
2339
2517
  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
- }
2518
+ requireNameAndInputs(name, inputs, "createMethodExample", "the example's name and its inputs");
2348
2519
  const body = { name, inputs };
2349
2520
  if (runId != null) {
2350
2521
  body.run_id = requireNonEmptyId(runId, "createMethodExample", CREATE_RUN_ID_ARGUMENT);
@@ -2492,6 +2663,64 @@ export class PipelexApiClient {
2492
2663
  });
2493
2664
  return { ...answer, outcome, example: subject };
2494
2665
  }
2666
+ /**
2667
+ * Save the build assistant's test inputs as its example of the method —
2668
+ * `POST /v1/methods/{id}/examples/assistant-save` — **never writing over a person's edit**.
2669
+ *
2670
+ * The assistant keeps one example of a method current with its test inputs, until a person
2671
+ * edits that example's inputs. Find its example as the method's newest with `kept_by_assistant`
2672
+ * true (`listMethodExamples` answers them oldest first), and send its `example_id` with the
2673
+ * `input_revision` you read it at. The platform writes the inputs into it only while it still
2674
+ * exists, still carries the flag and still holds that revision, in one conditional write that it
2675
+ * never retries. Otherwise, and when no example is named, which is how the first save on a
2676
+ * method creates the assistant's example, it saves the inputs as a new example flagged as the
2677
+ * assistant's, under `name`.
2678
+ *
2679
+ * The answer is a `MethodExampleAssistantSaveResult` discriminated on `outcome` — branch on it:
2680
+ * `kept`, the named example now holds the inputs, its name untouched; `created`, a new example
2681
+ * holds them. Both carry the `example`, which is the assistant's from then on. A retry of a save
2682
+ * that landed answers `kept` and writes nothing again.
2683
+ *
2684
+ * Sends `name`, `inputs` and, when given, `example_id` with `input_revision`, and nothing else:
2685
+ * the body is closed, and `null` reads as not given. Throws `RequestArgumentError` before any
2686
+ * request when `name` or `inputs` is not given, for an `example_id` that is not a non-empty
2687
+ * string or an `input_revision` that is not a positive integer, and when only one of the two is
2688
+ * given. Throws `ApiResponseError`: `403` `role_not_permitted` for a role that may not change
2689
+ * methods, such as a runner; `413` `payload_too_large` for an example larger than one stored item
2690
+ * holds; `422` for a name that is empty, blank or over 200 characters, or inputs that are not a
2691
+ * JSON object; and the codes every example route answers. An answer whose `outcome` is neither
2692
+ * arm's, or whose `example` is not an example `readMethodExample` can read, is an answer the SDK
2693
+ * cannot read, thrown as an `ApiResponseError` too.
2694
+ */
2695
+ async saveAssistantMethodExample(methodId, input) {
2696
+ const call = "saveAssistantMethodExample";
2697
+ const endpoint = `${methodPath(methodId, call)}/examples/assistant-save`;
2698
+ const { name, inputs, example_id: exampleId, input_revision: revision, } = input ?? {};
2699
+ requireNameAndInputs(name, inputs, call, "the name a new example takes and the inputs");
2700
+ const body = { name, inputs };
2701
+ if (exampleId != null) {
2702
+ body.example_id = requireNonEmptyId(exampleId, call, ASSISTANT_EXAMPLE_ID_ARGUMENT);
2703
+ }
2704
+ if (revision != null) {
2705
+ if (typeof revision !== "number" || !Number.isSafeInteger(revision) || revision < 1) {
2706
+ throw argumentRefusal(call, ASSISTANT_REVISION_ARGUMENT, describeNumberArgument(revision));
2707
+ }
2708
+ body.input_revision = revision;
2709
+ }
2710
+ if ((body.example_id === undefined) !== (body.input_revision === undefined)) {
2711
+ throw new RequestArgumentError(`${call}() names the assistant's example by both its example_id and the input_revision ` +
2712
+ `it was read at, or neither; got only ${body.example_id === undefined ? "input_revision" : "example_id"}.`);
2713
+ }
2714
+ const res = await this.requestProductAnswer("POST", endpoint, body);
2715
+ const { answer, outcome, subject } = this.readVerdict("POST", endpoint, res, {
2716
+ verdict: "an assistant save result",
2717
+ outcomes: ["kept", "created"],
2718
+ member: "example",
2719
+ parse: parseExactJson,
2720
+ readMember: (value) => this.readMethodExample("POST", endpoint, res, value, "an assistant save result's `example`"),
2721
+ });
2722
+ return { ...answer, outcome, example: subject };
2723
+ }
2495
2724
  /**
2496
2725
  * Delete an example — `DELETE /v1/methods/{id}/examples/{example_id}`. The platform answers
2497
2726
  * `204`. Deleting the example leaves its latest run in the method's history.
@@ -2666,7 +2895,8 @@ export class PipelexApiClient {
2666
2895
  return downloadArtifactsImpl(this, request);
2667
2896
  }
2668
2897
  /**
2669
- * Upload a base64 file — `POST /v1/upload`. An answer with no non-empty string `uri` names no
2898
+ * Upload a base64 file — `POST /v1/upload`, answering the file's `uri`, the `filename` as sent
2899
+ * and the `file_id` of its record. An answer with no non-empty string `uri` names no
2670
2900
  * stored file, so it is an answer the SDK cannot read: an `ApiResponseError`, `runtime` and
2671
2901
  * not retryable, which `uploadFile` wraps as an `UploadTransportError` with code `unexpected`.
2672
2902
  */
@@ -2681,18 +2911,24 @@ export class PipelexApiClient {
2681
2911
  /**
2682
2912
  * Request a grant to upload one file straight to storage — `POST /v1/upload/grant`.
2683
2913
  * `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`.
2914
+ * as a browser page: the holder of the bytes describes the file with `describeUpload`,
2915
+ * its SHA-256 included, the credential-holding side asks for the grant with that
2916
+ * description and hands the grant over, and the holder of the bytes sends them with
2917
+ * `uploadWithGrant`. Both are on the browser-safe `@pipelex/sdk/upload` entry. The
2918
+ * bytes cross neither this client nor the API gateway, so the gateway's request quota
2919
+ * does not cap the file below the service's own limit (`max_bytes`).
2920
+ *
2921
+ * **Branch on the answer's `stored`.** With the file's `sha256`, the object is named
2922
+ * after its bytes: when the organization already holds them, the grant answers
2923
+ * `stored: true` with the file's `uri` and `file_id` and no upload target, and nothing
2924
+ * is to be sent. Otherwise, and always without a `sha256`, it answers a presigned,
2925
+ * create-only `PUT` whose `uri` exists once the `PUT` succeeds, and not before. That
2926
+ * target is short-lived and a bearer capability, so keep it out of logs. The route
2927
+ * never replays a grant: ask again for a new one rather than retrying; with a
2928
+ * `sha256`, a new grant answers the same file record. A declared `size` over the cap
2929
+ * is a `413` `ApiResponseError` (`code` `payload_too_large`), a malformed `sha256` and
2930
+ * a `size` other than the one the organization holds those bytes at are a `422`, and a
2931
+ * deployment without the route answers a `404`. See `docs/input-preparation.md`.
2696
2932
  */
2697
2933
  async requestUploadGrant(input, options = {}) {
2698
2934
  return this.requestProductObject("POST", "upload/grant", input, options);
@@ -2805,6 +3041,306 @@ export class PipelexApiClient {
2805
3041
  async *iterateFiles(query = {}) {
2806
3042
  yield* followCursor("listFiles", (cursor) => this.listFiles({ ...query, cursor }));
2807
3043
  }
3044
+ /**
3045
+ * One file with its provenance, the methods whose lists hold it, and one page of its uses —
3046
+ * `GET /v1/files/{file_id}`.
3047
+ *
3048
+ * The answer is a `FileDetail`: the file's whole record (`FileRecord`), its `method_ids`, and
3049
+ * `uses`, one page of the examples that hold its address, then the runs that took it. Page the
3050
+ * uses by passing `uses.next_cursor` back as `usesCursor`; `null` is the last page. A record whose
3051
+ * upload looked unfinished past the upload grant's lifespan is settled by the read, so it answers
3052
+ * `stored` when its bytes are there.
3053
+ *
3054
+ * Throws `RequestArgumentError` for a file id that is empty, `.` or `..`, or holds a lone
3055
+ * surrogate, before any request. Throws `ApiResponseError`: `404` `not_found` for a file that is
3056
+ * not the organization's; `422` for a `usesLimit` outside 1 to 200 or an id that cannot be one.
3057
+ * An answer that is not an object, or whose `uses` is not a page of `items` and `next_cursor`, is
3058
+ * an answer the SDK cannot read, thrown as an `ApiResponseError` too. See `docs/files.md`.
3059
+ */
3060
+ async getFile(fileId, query = {}) {
3061
+ const search = productQuery({ uses_limit: query.usesLimit, uses_cursor: query.usesCursor });
3062
+ const endpoint = `files/${idPathSegment(fileId, "getFile", FILE_ID_ARGUMENT)}${search}`;
3063
+ const res = await this.requestProductAnswer("GET", endpoint, undefined);
3064
+ const detail = this.readObjectAnswer("GET", endpoint, res);
3065
+ const uses = detail.uses;
3066
+ if (!isPlainObject(uses) ||
3067
+ !Array.isArray(uses.items) ||
3068
+ (uses.next_cursor !== null && typeof uses.next_cursor !== "string")) {
3069
+ throw this.unreadableAnswer("GET", `/${API_PREFIX}/${endpoint}`, res, "a file whose `uses` is not a page of `items` and `next_cursor`");
3070
+ }
3071
+ return detail;
3072
+ }
3073
+ /**
3074
+ * The records holding one `pipelex-storage://` address — `GET /v1/files/by-uri` — in the order a
3075
+ * surface picks the one to show, the first being it.
3076
+ *
3077
+ * Several records hold one address when several members uploaded the same bytes. With `runId`,
3078
+ * the records of the member who started that run come first, so a run's inputs show the name its
3079
+ * starter gave the file; then the caller's; then the rest, each group oldest first. A record
3080
+ * whose upload has not finished is answered with its `state`, `pending`. An address no record
3081
+ * holds answers an empty `items`, which is how a client tells a file stored before the platform
3082
+ * kept records.
3083
+ *
3084
+ * Throws `RequestArgumentError` for a `uri` that is not a non-empty string, before any request.
3085
+ * Throws `ApiResponseError`: `400` `invalid_storage_uri` for a malformed address, `403`
3086
+ * `forbidden` for another organization's, `404` `run_not_found` for a run that is not the
3087
+ * organization's, and `422` for an address over 512 characters. An answer whose `items` is not an
3088
+ * array is an answer the SDK cannot read, thrown as an `ApiResponseError` too.
3089
+ */
3090
+ async getFilesByUri(uri, options = {}) {
3091
+ const checkedUri = requireNonEmptyId(uri, "getFilesByUri", STORAGE_URI_ARGUMENT);
3092
+ const endpoint = `files/by-uri${productQuery({ uri: checkedUri, run_id: options.runId })}`;
3093
+ const res = await this.requestProductAnswer("GET", endpoint, undefined);
3094
+ const answer = this.readObjectAnswer("GET", endpoint, res);
3095
+ if (!Array.isArray(answer.items)) {
3096
+ throw this.unreadableAnswer("GET", `/${API_PREFIX}/${endpoint}`, res, "an answer whose `items` is not an array");
3097
+ }
3098
+ return answer;
3099
+ }
3100
+ /**
3101
+ * Rename a file — `PATCH /v1/files/{file_id}` — and return its record as it now stands.
3102
+ *
3103
+ * **The platform holds the name to one rule, for every client**: it removes the characters no
3104
+ * list or header can show (control and format characters, a right-to-left override among
3105
+ * them), trims the ends, refuses a name with nothing left, and adds the file's own extension,
3106
+ * the one its `original_name` ends with, when the name does not end with it, case aside:
3107
+ * `March invoice` becomes `March invoice.pdf`, and `March.docx` becomes `March.docx.pdf`. A file
3108
+ * whose original name has no extension gains none. Read the saved name from the answer's
3109
+ * `name`. The rename changes `name` alone, never the file's bytes, its address or its
3110
+ * `original_name`.
3111
+ *
3112
+ * An `admin` or a `member` renames any file; any other role, a runner among them, only the files
3113
+ * whose record names it as `created_by`: its own uploads and the outputs of the runs it started.
3114
+ *
3115
+ * Sends `name` alone: the body is closed. Throws `RequestArgumentError` for a file id that is
3116
+ * empty, `.` or `..`, or holds a lone surrogate, and for a `name` that is not a string, before
3117
+ * any request. Throws `ApiResponseError`: `403` `role_not_permitted` past the rule above; `404`
3118
+ * `not_found` for a file that is not the organization's; `422` for a name over 512 characters or
3119
+ * with nothing left once cleaned. An answer that is not an object is an answer the SDK cannot
3120
+ * read, thrown as an `ApiResponseError` too.
3121
+ */
3122
+ async renameFile(fileId, input) {
3123
+ const segment = idPathSegment(fileId, "renameFile", FILE_ID_ARGUMENT);
3124
+ const { name } = input ?? {};
3125
+ if (typeof name !== "string") {
3126
+ throw argumentRefusal("renameFile", RENAME_NAME_ARGUMENT, describeNumberArgument(name));
3127
+ }
3128
+ return this.requestProductObject("PATCH", `files/${segment}`, { name });
3129
+ }
3130
+ /**
3131
+ * Fresh links to read files — `POST /v1/files/links` — one per requested file, in the request's
3132
+ * order, each named by exactly one of its record's id (`file_id`) and its address (`uri`).
3133
+ *
3134
+ * **Branch on each item's `error`, never on the status**: the request is a `200` whenever every
3135
+ * file got a verdict. A linked file carries its `url`, a presigned `GET` valid for fifteen
3136
+ * minutes, with its `expires_at`, the `name` the download saves under and its `media_type`, and
3137
+ * a `null` `error`. A file that got no link carries `error`, `{ code, detail }`, and `null` link
3138
+ * members: `not_found` for an unknown file id, `conflict` for a file whose upload has not
3139
+ * finished, `invalid_storage_uri` for a malformed address and `forbidden` for another
3140
+ * organization's.
3141
+ *
3142
+ * Each link is signed with a `Content-Disposition` naming the file, so a download saves under
3143
+ * the name a person sees rather than the bytes' hash. It opens in place (`inline`) for an image,
3144
+ * a PDF or plain text and downloads (`attachment`) for every other type, SVG, HTML and XML
3145
+ * included, so a file that could run scripts never opens from a link. A file named by its id is
3146
+ * linked under its record's name; an address under the name of its first stored record, the
3147
+ * caller's before another member's, whose id the item's `file_id` names. A link is a bearer
3148
+ * capability: keep it out of logs, and ask again for a fresh one rather than storing it.
3149
+ *
3150
+ * Sends each file as `{ file_id }` or `{ uri }` and nothing else: the body is closed. Throws
3151
+ * `RequestArgumentError` before any request when `files` is not a list, or one of its items does
3152
+ * not name exactly one of `file_id` and `uri` as a non-empty string. Throws `ApiResponseError`:
3153
+ * `422` for more than 100 files, or an id or address longer than one can be; `500` `config` when
3154
+ * the platform cannot sign at all. An answer that is not one item per requested file, each an
3155
+ * object with its `url` or its `error`, is an answer the SDK cannot read, thrown as an
3156
+ * `ApiResponseError` whose body hides every link.
3157
+ */
3158
+ async linkFiles(files) {
3159
+ if (!Array.isArray(files)) {
3160
+ throw argumentRefusal("linkFiles", "a list of files to link", describeNumberArgument(files));
3161
+ }
3162
+ const targets = files.map((target) => fileLinkTarget(target));
3163
+ const res = withLinks(await this.requestProductAnswer("POST", "files/links", { files: targets }));
3164
+ const answer = this.readObjectAnswer("POST", "files/links", res);
3165
+ const items = answer.items;
3166
+ if (!Array.isArray(items) || items.length !== targets.length) {
3167
+ throw this.unreadableAnswer("POST", `/${API_PREFIX}/files/links`, res, "an answer that is not one item per requested file");
3168
+ }
3169
+ for (const item of items) {
3170
+ const linked = isPlainObject(item) && item.error === null && typeof item.url === "string";
3171
+ const refused = isPlainObject(item) && isPlainObject(item.error) && item.url === null;
3172
+ if (!linked && !refused) {
3173
+ throw this.unreadableAnswer("POST", `/${API_PREFIX}/files/links`, res, "an item that carries neither a link nor an error");
3174
+ }
3175
+ }
3176
+ return answer;
3177
+ }
3178
+ /**
3179
+ * Ask for one zip of chosen files, or of a run's files — `POST /v1/files/archives` — and return
3180
+ * the archive, `queued`.
3181
+ *
3182
+ * The platform cannot build a zip inside a request, so the zip is two calls: this one queues the
3183
+ * archive and answers at once (`202`), and `getFileArchive` reads it until its `state` is `ready`,
3184
+ * whose `url` downloads the zip, or `failed`, whose `failure` says why. Name the files by exactly
3185
+ * one of `file_ids`, in the order the zip holds them, and `run_id`, whose zip holds what went in
3186
+ * and what came out of the run, never a file it made along the way nor an input given as a web
3187
+ * link. `name` is the name the zip downloads under (see `FileArchiveRequest`). An archive is never
3188
+ * a file: no list shows it, and it is gone a day after it was asked for.
3189
+ *
3190
+ * Sends `file_ids` or `run_id`, and `name` when given, and nothing else: the body is closed, and a
3191
+ * `null` member reads as not given. Throws `RequestArgumentError` before any request when the
3192
+ * request names both or neither of `file_ids` and `run_id`, when `file_ids` is not a non-empty
3193
+ * list of non-empty strings, when `run_id` is not a non-empty string, and when `name` is not a
3194
+ * string. Throws `ApiResponseError`:
3195
+ *
3196
+ * - `422` `file_archive_too_large` past a cap, at most 500 files or 2,000,000,000 bytes, whose
3197
+ * `problemDocument.limit` names it (`FileArchiveLimit`), so the person can be told how far over
3198
+ * the selection is;
3199
+ * - `422` `validation_failed` for more than 1,000 ids listed, or an id longer than one can be;
3200
+ * - `404` `not_found` for a file that is not the organization's, the `detail` naming up to five
3201
+ * of them, or a run whose files hold nothing stored to zip; `404` `run_not_found` for a run
3202
+ * that is not the organization's;
3203
+ * - `409` `conflict`: an `input` one, not retryable, for a selected file whose upload has not
3204
+ * finished; a `runtime` one, retryable, for a run whose files are not all recorded yet, which
3205
+ * the same request clears a few seconds after the run ends;
3206
+ * - `503` `service_unavailable`, retryable, when the archive could not be queued: the same request
3207
+ * sent again asks for a new archive.
3208
+ *
3209
+ * An answer that is not an archive, an object with a string `archive_id` and a known `state`, is
3210
+ * an answer the SDK cannot read, thrown as an `ApiResponseError` too.
3211
+ */
3212
+ async requestFileArchive(request) {
3213
+ const call = "requestFileArchive";
3214
+ if (!isPlainObject(request)) {
3215
+ throw argumentRefusal(call, "an archive request, an object naming `file_ids` or `run_id`", describeNumberArgument(request));
3216
+ }
3217
+ const raw = request;
3218
+ const fileIds = raw.file_ids ?? undefined;
3219
+ const runId = raw.run_id ?? undefined;
3220
+ const name = raw.name ?? undefined;
3221
+ if ((fileIds === undefined) === (runId === undefined)) {
3222
+ throw new RequestArgumentError(`${call}() names the files to zip by exactly one of file_ids and run_id; got ` +
3223
+ `${fileIds === undefined ? "neither" : "both"}.`);
3224
+ }
3225
+ const body = {};
3226
+ if (fileIds !== undefined) {
3227
+ if (!Array.isArray(fileIds) || fileIds.length === 0) {
3228
+ throw argumentRefusal(call, "file_ids, a non-empty list of the file ids to zip", Array.isArray(fileIds) ? "an empty list" : describeNumberArgument(fileIds));
3229
+ }
3230
+ body.file_ids = fileIds.map((id) => requireNonEmptyId(id, call, ARCHIVE_FILE_ID_ARGUMENT));
3231
+ }
3232
+ else {
3233
+ body.run_id = requireNonEmptyId(runId, call, ARCHIVE_RUN_ID_ARGUMENT);
3234
+ }
3235
+ if (name !== undefined) {
3236
+ if (typeof name !== "string") {
3237
+ throw argumentRefusal(call, ARCHIVE_NAME_ARGUMENT, describeNumberArgument(name));
3238
+ }
3239
+ body.name = name;
3240
+ }
3241
+ const res = await this.requestProductAnswer("POST", "files/archives", body);
3242
+ return this.readFileArchive("POST", "files/archives", res);
3243
+ }
3244
+ /**
3245
+ * Where an archive stands — `GET /v1/files/archives/{archive_id}` — with a fresh link to its zip
3246
+ * once it is `ready`.
3247
+ *
3248
+ * **Branch on `state`.** `queued` and `building` mean read it again shortly: a zip of a few files
3249
+ * is ready within seconds, and the largest the caps allow within a few minutes. `ready` carries
3250
+ * `url`, a presigned `GET` of the zip valid for fifteen minutes and signed with a
3251
+ * `Content-Disposition` naming the archive's `name`, with `archive_bytes` and `ready_at`; each read
3252
+ * signs a fresh link, so a caller that let one lapse reads the archive again. `failed` carries
3253
+ * `failure`, `file_gone` or `build_failed`, and the remedy for both is a new request. An archive
3254
+ * still `queued` or `building` an hour after it was asked for reads as `failed`.
3255
+ *
3256
+ * Throws `RequestArgumentError` for an archive id that is empty, `.` or `..`, or holds a lone
3257
+ * surrogate, before any request. Throws `ApiResponseError`: `404` `not_found` for an archive that
3258
+ * is not the organization's, never existed, or has expired, a day after `created_at`; `500` when
3259
+ * the link cannot be signed. An answer that is not an archive is an answer the SDK cannot read,
3260
+ * thrown as an `ApiResponseError` whose body hides the link.
3261
+ */
3262
+ async getFileArchive(archiveId) {
3263
+ const segment = idPathSegment(archiveId, "getFileArchive", ARCHIVE_ID_ARGUMENT);
3264
+ const endpoint = `files/archives/${segment}`;
3265
+ const res = await this.requestProductAnswer("GET", endpoint, undefined);
3266
+ return this.readFileArchive("GET", endpoint, res);
3267
+ }
3268
+ /**
3269
+ * An archive as `res` carried it, checked to be one: an object with a string `archive_id` and a
3270
+ * `state` this SDK knows, the field a caller branches on. Anything else is an answer the SDK
3271
+ * cannot read, thrown as the `ApiResponseError` `unreadableAnswer` builds with every link hidden,
3272
+ * since a ready archive's answer carries one.
3273
+ */
3274
+ readFileArchive(method, endpoint, res) {
3275
+ const linked = withLinks(res);
3276
+ const archive = this.readObjectAnswer(method, endpoint, linked);
3277
+ if (typeof archive.archive_id !== "string") {
3278
+ throw this.unreadableAnswer(method, `/${API_PREFIX}/${endpoint}`, linked, "an archive whose `archive_id` is not a string");
3279
+ }
3280
+ if (!FILE_ARCHIVE_STATES.includes(archive.state)) {
3281
+ throw this.unreadableAnswer(method, `/${API_PREFIX}/${endpoint}`, linked, `an archive whose \`state\` is not ${inWords(FILE_ARCHIVE_STATES.map((state) => `\`${state}\``), "or")}`);
3282
+ }
3283
+ return archive;
3284
+ }
3285
+ /**
3286
+ * Copy one of the organization's files into a method's draft —
3287
+ * `POST /v1/methods/{id}/draft/files/from-file`, "Add to method files".
3288
+ *
3289
+ * The file becomes a file of the draft at `path`, replacing a file already there. Its bytes are
3290
+ * copied into the organization's blob store by the storage service itself, so they never cross
3291
+ * the client or the API whatever their size, and are skipped when the store already holds them.
3292
+ * The method file then lives apart from its source: erasing the file's record, the run that made
3293
+ * it or the method it came from leaves it readable.
3294
+ *
3295
+ * The answer is a `DraftFileFromFileResult`, a `200` discriminated on `blob`: `copied`, or
3296
+ * `already_held` when the store held the bytes and nothing was copied. Both carry the `file` as
3297
+ * the draft's tree now names it and the `method` as the draft write stored it, whose
3298
+ * `updated_at` is the new draft token. With `expected_updated_at`, the write is a
3299
+ * compare-and-swap on the draft token; without it, the file is added to the draft as it stands,
3300
+ * never erasing someone else's save.
3301
+ *
3302
+ * Takes a bare catalog id, as `writeDraft` does. Sends `file_id`, `path` and, when given,
3303
+ * `expected_updated_at`, and nothing else: the body is closed, and a `null` token reads as not
3304
+ * given. Throws `RequestArgumentError` before any request for a `file_id` or a `path` that is not
3305
+ * a non-empty string, and an `expected_updated_at` that is not a string. Throws
3306
+ * `ApiResponseError`: `403` `role_not_permitted` for a role that may not change methods; `404`
3307
+ * for an unknown method or a file that is not the organization's; `409` `method_being_deleted`,
3308
+ * `409` `method_update_conflict` for a stale `expected_updated_at`, and `409` `conflict` for a
3309
+ * file whose upload has not finished or whose stored bytes do not match its record; `422`
3310
+ * `invalid_method_files` for a path the draft cannot hold, such as `.env` or `../logo.png`, and
3311
+ * `422` `method_tree_too_large` past the deployment's limits. An answer whose `blob` is neither
3312
+ * arm's, or whose `file` or `method` is not an object, is an answer the SDK cannot read, thrown
3313
+ * as an `ApiResponseError` too.
3314
+ */
3315
+ async copyFileIntoDraft(methodId, input) {
3316
+ const call = "copyFileIntoDraft";
3317
+ const endpoint = `${methodPath(methodId, call)}/draft/files/from-file`;
3318
+ const { file_id: fileId, path, expected_updated_at: token, } = input ?? {};
3319
+ const body = {
3320
+ file_id: requireNonEmptyId(fileId, call, FILE_ID_ARGUMENT),
3321
+ path: requireNonEmptyId(path, call, DRAFT_PATH_ARGUMENT),
3322
+ };
3323
+ if (token != null) {
3324
+ if (typeof token !== "string") {
3325
+ throw argumentRefusal(call, DRAFT_TOKEN_ARGUMENT, describeNumberArgument(token));
3326
+ }
3327
+ body.expected_updated_at = token;
3328
+ }
3329
+ const res = await this.requestProductAnswer("POST", endpoint, body);
3330
+ const answer = this.readObjectAnswer("POST", endpoint, res);
3331
+ let fault;
3332
+ if (!DRAFT_FILE_BLOBS.includes(answer.blob)) {
3333
+ fault = `whose \`blob\` is not ${inWords(DRAFT_FILE_BLOBS.map((blob) => `\`${blob}\``), "or")}`;
3334
+ }
3335
+ else if (!isPlainObject(answer.file))
3336
+ fault = "whose `file` is not an object";
3337
+ else if (!isPlainObject(answer.method))
3338
+ fault = "whose `method` is not an object";
3339
+ if (fault !== undefined) {
3340
+ throw this.unreadableAnswer("POST", `/${API_PREFIX}/${endpoint}`, res, `a copy into a draft ${fault}`);
3341
+ }
3342
+ return answer;
3343
+ }
2808
3344
  /**
2809
3345
  * One PAGE of a method's runs, newest first — `GET /v1/runs?method_id=`.
2810
3346
  *
@@ -2903,6 +3439,51 @@ export class PipelexApiClient {
2903
3439
  const res = await this.requestProductAnswer("GET", endpoint, undefined);
2904
3440
  return withCheckedReport(this.readObjectAnswer("GET", endpoint, res, {}, parseExactJson));
2905
3441
  }
3442
+ /**
3443
+ * One page of a run's files, sorted by where they sit in the run — `GET /v1/runs/{id}/files`.
3444
+ *
3445
+ * Each entry carries its `mark`, `input`, `final` or `intermediate`, and the record a surface
3446
+ * shows for it (`RunFileEntry`): `inputs` are what went in, the inputs given by their address
3447
+ * with the record of the member who started the run first, then the inline inputs; `outputs` are
3448
+ * the run's final outputs; `intermediates` the files it made along the way. An input given as a
3449
+ * web link gets no record and is listed in `links`, with the warning that replaying the run needs
3450
+ * it to still serve the same file.
3451
+ *
3452
+ * **`state` is `listing` until the platform has recorded every file of the run**, a few seconds
3453
+ * after it ends: read it again until it is `listed` before taking the list as whole. The run's
3454
+ * own records are paged by address with `limit` and `cursor`; the inputs by address and the links
3455
+ * come on the first page only. Pass `nextCursor` back as `cursor`; `null` is the last page.
3456
+ *
3457
+ * Throws `RequestArgumentError` for a run id that is empty, `.` or `..`, or holds a lone
3458
+ * surrogate, before any request. Throws `ApiResponseError`: `404` for a run that is not the
3459
+ * organization's, exactly as for one that does not exist; `400` for a run id holding a character
3460
+ * outside letters, digits, `_` and `-`, or a cursor another listing issued; `422` for a `limit`
3461
+ * outside 1 to 200. A page whose `state` is unknown, whose lists are not arrays, or whose `next_cursor` is neither a
3462
+ * string nor `null`, is an answer the SDK cannot read, thrown as an `ApiResponseError` too.
3463
+ */
3464
+ async listRunFiles(runId, query = {}) {
3465
+ const segment = idPathSegment(runId, "listRunFiles", RUN_ID_ARGUMENT);
3466
+ const search = productQuery({ limit: query.limit, cursor: query.cursor });
3467
+ const endpoint = `runs/${segment}/files${search}`;
3468
+ const res = await this.requestProductAnswer("GET", endpoint, undefined);
3469
+ const page = this.readObjectAnswer("GET", endpoint, res);
3470
+ const { next_cursor: nextCursor, ...rest } = page;
3471
+ const notAList = RUN_FILES_LISTS.find((list) => !Array.isArray(page[list]));
3472
+ let fault;
3473
+ if (!RUN_FILES_STATES.includes(page.state)) {
3474
+ fault = "whose `state` is not `listing` or `listed`";
3475
+ }
3476
+ else if (notAList !== undefined) {
3477
+ fault = `whose \`${notAList}\` is not an array`;
3478
+ }
3479
+ else if (nextCursor !== null && typeof nextCursor !== "string") {
3480
+ fault = "whose `next_cursor` is neither a string nor null";
3481
+ }
3482
+ if (fault !== undefined) {
3483
+ throw this.unreadableAnswer("GET", `/${API_PREFIX}/${endpoint}`, res, `a page of a run's files ${fault}`);
3484
+ }
3485
+ return { ...rest, nextCursor };
3486
+ }
2906
3487
  /** Patch a run's status (admin/manual) — `PUT /v1/runs/{id}`. */
2907
3488
  async updateRun(runId, input) {
2908
3489
  const segment = idPathSegment(runId, "updateRun", RUN_ID_ARGUMENT);
@@ -3339,7 +3920,7 @@ function assertLinkageMethodIdIsBare(options, treeForms) {
3339
3920
  function nonEmptyString(value) {
3340
3921
  return value != null && value.length > 0 ? value : undefined;
3341
3922
  }
3342
- /** The request header the hosted API reads a run start's idempotency key from. */
3923
+ /** The request header the hosted API reads a request's idempotency key from. */
3343
3924
  const IDEMPOTENCY_KEY_HEADER = "Idempotency-Key";
3344
3925
  /**
3345
3926
  * The longest idempotency key a start sends. It is room for any generated key, and keeps the
@@ -3354,21 +3935,22 @@ const MAX_IDEMPOTENCY_KEY_LENGTH = 255;
3354
3935
  */
3355
3936
  const VISIBLE_ASCII = /^[\x21-\x7E]+$/;
3356
3937
  /**
3357
- * The idempotency key a start sends, or `undefined` when the options carry none (absent or
3358
- * `null`). A key that is not a string of 1 to 255 visible ASCII characters is refused with a
3359
- * `RequestArgumentError` rather than dropped, an empty one included, since a start sent without
3360
- * the key its caller gave would lose the protection the caller asked for without a word. The
3361
- * message never quotes the key, which a caller may derive from something private. The Python SDK
3362
- * holds a key to the same rule, in the same words.
3938
+ * The idempotency key a start or a method copy sends, or `undefined` when the options carry none
3939
+ * (absent or `null`). A key that is not a string of 1 to 255 visible ASCII characters is refused
3940
+ * with a `RequestArgumentError` rather than dropped, an empty one included, since a request sent
3941
+ * without the key its caller gave would lose the protection the caller asked for without a word.
3942
+ * `request` says what the request does, as the refusal's last sentence names it ("start the run",
3943
+ * "copy the method"). The message never quotes the key, which a caller may derive from something
3944
+ * private. The Python SDK holds a key to the same rule, in the same words.
3363
3945
  */
3364
- function idempotencyKeyOf(options) {
3946
+ function idempotencyKeyOf(options, request = "start the run") {
3365
3947
  const key = options.idempotencyKey;
3366
3948
  if (key == null)
3367
3949
  return undefined;
3368
3950
  const fault = idempotencyKeyFault(key);
3369
3951
  if (fault !== undefined) {
3370
3952
  throw new RequestArgumentError(`The idempotency key must be 1 to ${MAX_IDEMPOTENCY_KEY_LENGTH} visible ASCII characters ` +
3371
- `(\`!\` to \`~\`), and this one ${fault}. Leave it out to start the run without one.`);
3953
+ `(\`!\` to \`~\`), and this one ${fault}. Leave it out to ${request} without one.`);
3372
3954
  }
3373
3955
  return key;
3374
3956
  }