@pipelex/sdk 0.51.0 → 0.53.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.
Files changed (88) hide show
  1. package/README.md +20 -19
  2. package/dist/answer-reading.d.ts +70 -0
  3. package/dist/answer-reading.js +106 -0
  4. package/dist/answer-reading.js.map +1 -0
  5. package/dist/artifacts.js +45 -32
  6. package/dist/artifacts.js.map +1 -1
  7. package/dist/cli/present.d.ts +11 -14
  8. package/dist/cli/present.js +41 -37
  9. package/dist/cli/present.js.map +1 -1
  10. package/dist/cli/run.js +8 -7
  11. package/dist/cli/run.js.map +1 -1
  12. package/dist/cli/script.d.ts +1 -1
  13. package/dist/cli/source.js +2 -2
  14. package/dist/cli/source.js.map +1 -1
  15. package/dist/client.d.ts +254 -213
  16. package/dist/client.js +542 -472
  17. package/dist/client.js.map +1 -1
  18. package/dist/codegen-check.d.ts +21 -15
  19. package/dist/codegen-check.js +54 -27
  20. package/dist/codegen-check.js.map +1 -1
  21. package/dist/errors.d.ts +298 -455
  22. package/dist/errors.js +426 -534
  23. package/dist/errors.js.map +1 -1
  24. package/dist/exact-json.d.ts +8 -12
  25. package/dist/exact-json.js +19 -12
  26. package/dist/exact-json.js.map +1 -1
  27. package/dist/hooks/check-core.d.ts +12 -7
  28. package/dist/hooks/check-core.js +20 -14
  29. package/dist/hooks/check-core.js.map +1 -1
  30. package/dist/hooks/validate-stage.js +12 -3
  31. package/dist/hooks/validate-stage.js.map +1 -1
  32. package/dist/index.d.ts +4 -5
  33. package/dist/index.js +7 -6
  34. package/dist/index.js.map +1 -1
  35. package/dist/method-selector.js +7 -5
  36. package/dist/method-selector.js.map +1 -1
  37. package/dist/method-trees.d.ts +6 -7
  38. package/dist/method-trees.js +31 -37
  39. package/dist/method-trees.js.map +1 -1
  40. package/dist/models.d.ts +33 -210
  41. package/dist/models.js +2 -1
  42. package/dist/models.js.map +1 -1
  43. package/dist/prepare-inputs.d.ts +2 -2
  44. package/dist/prepare-inputs.js +51 -54
  45. package/dist/prepare-inputs.js.map +1 -1
  46. package/dist/problem-items.d.ts +19 -0
  47. package/dist/problem-items.js +52 -0
  48. package/dist/problem-items.js.map +1 -0
  49. package/dist/product-models.d.ts +43 -29
  50. package/dist/records.d.ts +12 -0
  51. package/dist/records.js +23 -0
  52. package/dist/records.js.map +1 -0
  53. package/dist/runs.d.ts +49 -38
  54. package/dist/runs.js +45 -19
  55. package/dist/runs.js.map +1 -1
  56. package/dist/transport-reading.d.ts +18 -0
  57. package/dist/{run-may-have-started.js → transport-reading.js} +15 -44
  58. package/dist/transport-reading.js.map +1 -0
  59. package/dist/upload-grant.d.ts +9 -6
  60. package/dist/upload-grant.js +42 -29
  61. package/dist/upload-grant.js.map +1 -1
  62. package/dist/upload.js +53 -33
  63. package/dist/upload.js.map +1 -1
  64. package/dist/version.d.ts +1 -1
  65. package/dist/version.js +1 -1
  66. package/docs/architecture.md +19 -19
  67. package/docs/artifact-download.md +6 -6
  68. package/docs/cli.md +1 -1
  69. package/docs/crate-routes.md +13 -14
  70. package/docs/errors.md +154 -267
  71. package/docs/exact-integers.md +1 -1
  72. package/docs/files.md +13 -13
  73. package/docs/hook-bundle.md +15 -1
  74. package/docs/input-preparation.md +14 -14
  75. package/docs/method-copy.md +35 -33
  76. package/docs/method-examples.md +13 -14
  77. package/docs/method-trees.md +27 -28
  78. package/docs/organization-branding.md +14 -16
  79. package/docs/run-results.md +8 -8
  80. package/package.json +1 -1
  81. package/dist/error-models.d.ts +0 -302
  82. package/dist/error-models.js +0 -203
  83. package/dist/error-models.js.map +0 -1
  84. package/dist/error-verdicts.d.ts +0 -46
  85. package/dist/error-verdicts.js +0 -120
  86. package/dist/error-verdicts.js.map +0 -1
  87. package/dist/run-may-have-started.d.ts +0 -31
  88. package/dist/run-may-have-started.js.map +0 -1
package/dist/client.js CHANGED
@@ -1,10 +1,12 @@
1
1
  // Method-source predicates come from the standard, not a local restatement: which
2
2
  // source combinations are legal is an invariant of `RunRequest` itself, so this
3
3
  // client and the MTHDS runners cannot disagree about what they reject.
4
- import { assertOneMethodSource, parseJsonBody } from "mthds/protocol";
5
- import { assertArtifactSelection, assertWaitOptions, pollUntilResult, readRunDegradation, RUN_STATUSES, runProgressFault, runStatusFilter, selectionIncludesMainStuff, throwIfAborted, withCheckedProgressDegradations, } from "./runs.js";
6
- import { ApiResponseError, ApiUnreachableError, MissingMainStuffError, PagingNotTerminatingError, PipelineExecuteTimeoutError, PipelineRequestError, RequestArgumentError, RunLifecycleUnavailableError, RunStillRunningError, } from "./errors.js";
7
- import { isPlainObject, isValidationItem, readFieldError, readMigration, readProviderMetadata, readRunErrorReport, } from "./error-models.js";
4
+ import { assertOneMethodSource, buildProblem, isProblem, parseJsonBody, readProblemBody, } from "mthds/protocol";
5
+ import { assertArtifactSelection, assertWaitOptions, pollUntilResult, readRunDegradation, RUN_STATUSES, runProgressFault, runStatusFilter, selectionIncludesMainStuff, throwIfAborted, withCheckedProgressHealth, } from "./runs.js";
6
+ import { ABORT_TIMEOUT, ApiResponseError, argumentInvalidProblem, ApiUnreachableError, MissingMainStuffError, PagingNotTerminatingError, PipelineExecuteTimeoutError, ProblemError, RequestArgumentError, RunLifecycleUnavailableError, RunStillRunningError, } from "./errors.js";
7
+ import { isPlainObject } from "./records.js";
8
+ import { readProblemItems } from "./problem-items.js";
9
+ import { outcomeUnknownProblem, readHostedAnswer, runIdNamedBy } from "./answer-reading.js";
8
10
  import { HANDSHAKE_TTL_MS, INLINE_TREE_MAX_BYTES, MAX_CONCURRENT_TRANSFERS, REQUEST_SENDING, SAVE_SENDING, announcedTreeLimits, contentsTreeSource, declaresBlobStore, eitherSignal, emptyTreeRefusal, fileByBlob, forEachConcurrently, hashMethodFiles, inlineFilesOf, inlineMapBytes, inlineThreshold, isMethodTreeTooLarge, listDirectoryTree, listerLimitsOf, missingBlobsOf, presentMap, readTreeBytesInto, redactSignedLinks, sha256Hex, treeShapeFault, treeListerFor, treeTotalSize, uniqueBlobs, uploadBlob, writeMethodTreeTo, } from "./method-trees.js";
9
11
  import { buildUserAgent } from "./user-agent.js";
10
12
  import { uploadFile as uploadFileImpl } from "./upload.js";
@@ -141,9 +143,8 @@ const BARE_RUNNER_IMPLEMENTATION = "pipelex-api";
141
143
  *
142
144
  * Implements `MTHDSProtocol<DictPipeOutput>` so the protocol-execution methods
143
145
  * stay shaped like the standard's wire surface (`mthds/protocol`), all but
144
- * `validate`, whose verdict is the one the hosted plane answers: since protocol
145
- * `2.0.0` the protocol's verdict lists its faults as problem items, which the
146
- * hosted plane does not send yet (see `PipelexValidationResult`). The Pipelex
146
+ * `validate`, whose verdict is the one the hosted plane answers: the protocol's problem items
147
+ * beside members of the hosted plane's own (see `PipelexValidationResult`). The Pipelex
147
148
  * extensions (the crate and tools routes, the run lifecycle) ride on top.
148
149
  */
149
150
  /**
@@ -161,7 +162,7 @@ function requestBodyText(method, url, body) {
161
162
  catch (err) {
162
163
  const reason = err instanceof Error ? err.message : String(err);
163
164
  throw new RequestArgumentError(`The body of ${method} ${new URL(url).pathname} cannot be written as JSON, so nothing was ` +
164
- `sent: ${reason}`, { cause: err });
165
+ `sent: ${reason}`, { problem: argumentInvalidProblem("body", "not_json"), cause: err });
165
166
  }
166
167
  }
167
168
  /**
@@ -200,7 +201,7 @@ function repeatedFilter(call, name, values) {
200
201
  throw new TypeError(`${call}() takes "${name}" as a list of strings.`);
201
202
  }
202
203
  if (values.length === 0) {
203
- throw new RequestArgumentError(`${call}() takes "${name}" as one or more values; omit it to list every ${name}.`);
204
+ throw new RequestArgumentError(`${call}() takes "${name}" as one or more values; omit it to list every ${name}.`, { problem: argumentInvalidProblem(name, "empty") });
204
205
  }
205
206
  return values;
206
207
  }
@@ -211,40 +212,110 @@ function inWords(items, conjunction) {
211
212
  return `${items.slice(0, -1).join(", ")} ${conjunction} ${items[items.length - 1]}`;
212
213
  }
213
214
  /** What each id a route puts in its path is, as a refusal of it names it. */
214
- const METHOD_ID_ARGUMENT = "a method id, the method's non-empty bare catalog id";
215
- const EXAMPLE_ID_ARGUMENT = "an example id, the example's non-empty `example_id`";
216
- const RUN_ID_ARGUMENT = "a run id, the run's non-empty `pipeline_run_id`";
217
- const ORG_ID_ARGUMENT = "an organization id, the organization's non-empty `org_id`";
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`";
215
+ const METHOD_ID_ARGUMENT = {
216
+ name: "method_id",
217
+ what: "a method id, the method's non-empty bare catalog id",
218
+ };
219
+ const EXAMPLE_ID_ARGUMENT = {
220
+ name: "example_id",
221
+ what: "an example id, the example's non-empty `example_id`",
222
+ };
223
+ const RUN_ID_ARGUMENT = {
224
+ name: "run_id",
225
+ what: "a run id, the run's non-empty `pipeline_run_id`",
226
+ };
227
+ const ORG_ID_ARGUMENT = {
228
+ name: "org_id",
229
+ what: "an organization id, the organization's non-empty `org_id`",
230
+ };
231
+ const API_KEY_ID_ARGUMENT = {
232
+ name: "key_id",
233
+ what: "an API key id, the key's non-empty `id`",
234
+ };
235
+ const FILE_ID_ARGUMENT = {
236
+ name: "file_id",
237
+ what: "a file id, the file's non-empty `file_id`",
238
+ };
239
+ const ARCHIVE_ID_ARGUMENT = {
240
+ name: "archive_id",
241
+ what: "an archive id, the archive's non-empty `archive_id`",
242
+ };
221
243
  /** What the run id of each example write is, as a refusal of it names it. */
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";
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";
244
+ const CREATE_RUN_ID_ARGUMENT = {
245
+ name: "run_id",
246
+ what: "a run_id naming a run of this method that used these inputs, a non-empty string, or none",
247
+ };
248
+ const RECORD_RUN_ID_ARGUMENT = {
249
+ name: "run_id",
250
+ what: "a run_id naming a run of this method that used the example's inputs, a non-empty string",
251
+ };
224
252
  /** 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";
253
+ const ASSISTANT_EXAMPLE_ID_ARGUMENT = {
254
+ name: "example_id",
255
+ what: "an example_id naming the assistant's example the caller read, a non-empty string, or none",
256
+ };
257
+ const ASSISTANT_REVISION_ARGUMENT = {
258
+ name: "input_revision",
259
+ what: "an input_revision, the revision the caller read the assistant's example at, a positive integer",
260
+ };
227
261
  /** 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";
262
+ const STORAGE_URI_ARGUMENT = {
263
+ name: "uri",
264
+ what: "a `pipelex-storage://` address, a non-empty string",
265
+ };
266
+ const RENAME_NAME_ARGUMENT = {
267
+ name: "name",
268
+ what: "the file's new name, a string",
269
+ };
270
+ const LINK_TARGET_ARGUMENT = {
271
+ name: "files",
272
+ what: "a file to link, an object naming exactly one of `file_id` and `uri`, each a non-empty string",
273
+ };
274
+ const ARCHIVE_FILE_ID_ARGUMENT = {
275
+ name: "file_ids",
276
+ what: "a file id to zip, a non-empty string",
277
+ };
278
+ const ARCHIVE_RUN_ID_ARGUMENT = {
279
+ name: "run_id",
280
+ what: "a run_id naming the run whose files to zip, a non-empty string",
281
+ };
282
+ const ARCHIVE_NAME_ARGUMENT = {
283
+ name: "name",
284
+ what: "a name for the zip, a string, or none",
285
+ };
286
+ const DRAFT_PATH_ARGUMENT = {
287
+ name: "path",
288
+ what: "the tree path the file takes in the draft, a non-empty string",
289
+ };
290
+ const DRAFT_TOKEN_ARGUMENT = {
291
+ name: "expected_updated_at",
292
+ what: "the draft token the caller last read, the method's `updated_at`, a string, or none",
293
+ };
236
294
  /** 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";
295
+ const COPY_DESTINATION_ARGUMENT = {
296
+ name: "destination_org_id",
297
+ what: "a destination_org_id, the canonical id of the organization to copy into, a non-empty string",
298
+ };
299
+ const COPY_NAME_ARGUMENT = {
300
+ name: "name",
301
+ what: "a name for the copy, a string, or none",
302
+ };
303
+ const COPY_DRAFT_TOKEN_ARGUMENT = {
304
+ name: "expected_draft_updated_at",
305
+ what: "an expected_draft_updated_at, the draft token the caller last read, the method's " +
306
+ "`updated_at`, a string, or none",
307
+ };
241
308
  /**
242
309
  * The refusal of an argument a call cannot take, worded as every such refusal is:
243
310
  * `<call>() takes <what>; got <got>.`, with what made it unusable as `cause` when there is one.
311
+ * Its problem is `client.argument_invalid`, naming the argument and `reason`.
244
312
  */
245
- function argumentRefusal(call, what, got, cause) {
246
- const message = `${call}() takes ${what}; got ${got}.`;
247
- return new RequestArgumentError(message, cause === undefined ? undefined : { cause });
313
+ function argumentRefusal(call, argument, got, reason, cause) {
314
+ const message = `${call}() takes ${argument.what}; got ${got}.`;
315
+ return new RequestArgumentError(message, {
316
+ cause,
317
+ problem: argumentInvalidProblem(argument.name, reason),
318
+ });
248
319
  }
249
320
  /**
250
321
  * A caller's id, checked to be a non-empty string: the one check every id a call takes goes
@@ -260,7 +331,7 @@ function requireNonEmptyId(value, call, what) {
260
331
  got = "an empty string";
261
332
  else
262
333
  got = value === null ? "null" : typeof value;
263
- throw argumentRefusal(call, what, got);
334
+ throw argumentRefusal(call, what, got, typeof value === "string" ? "empty" : "not_a_string");
264
335
  }
265
336
  /**
266
337
  * The check the example writes that take a name and inputs share, the create and the assistant's
@@ -276,7 +347,9 @@ function requireNameAndInputs(name, inputs, call, needs) {
276
347
  got = "neither";
277
348
  else
278
349
  got = name == null ? "no name" : "no inputs";
279
- throw new RequestArgumentError(`${call}() needs ${needs}; got ${got}.`);
350
+ throw new RequestArgumentError(`${call}() needs ${needs}; got ${got}.`, {
351
+ problem: argumentInvalidProblem(name == null ? "name" : "inputs", "missing"),
352
+ });
280
353
  }
281
354
  /**
282
355
  * A caller's value as the refusal of a number argument names it: a number by its value, anything
@@ -303,31 +376,31 @@ function describeNumberArgument(value) {
303
376
  function idPathSegment(value, call, what) {
304
377
  const id = requireNonEmptyId(value, call, what);
305
378
  if (id === "." || id === "..") {
306
- throw argumentRefusal(call, what, `"${id}", a dot segment, which the URL would resolve into another route's path`);
379
+ throw argumentRefusal(call, what, `"${id}", a dot segment, which the URL would resolve into another route's path`, "dot_segment");
307
380
  }
308
381
  try {
309
382
  return encodeURIComponent(id);
310
383
  }
311
384
  catch (err) {
312
385
  const got = "a string holding a lone surrogate, which a URL cannot carry";
313
- throw argumentRefusal(call, what, got, err);
386
+ throw argumentRefusal(call, what, got, "lone_surrogate", err);
314
387
  }
315
388
  }
316
389
  /**
317
390
  * The path of a method route, `methods/{id}`, for a bare catalog id, its segment refused as
318
391
  * `idPathSegment` refuses one; `call` names the method in the message. The method routes address
319
392
  * the method itself, never one of its versions, and the platform does not parse a suffix there:
320
- * `mt_x@3` would be looked up as an id of its own and answer `404 not_found`, which reads as a
321
- * method that does not exist. So a suffixed id is refused before anything is sent, saying how to
322
- * read what it names. Stripping it instead would answer the draft for a caller that named a
323
- * version.
393
+ * `mt_x@3` would be looked up as an id of its own and answer `404` `pipelex.catalog.not_found`,
394
+ * which reads as a method that does not exist. So a suffixed id is refused before anything is sent,
395
+ * saying how to read what it names. Stripping it instead would answer the draft for a caller that
396
+ * named a version.
324
397
  */
325
398
  function methodPath(methodId, call) {
326
399
  const segment = idPathSegment(methodId, call, METHOD_ID_ARGUMENT);
327
400
  if (methodId.includes("@")) {
328
401
  throw new RequestArgumentError(`"${methodId}" carries a version suffix, and the method routes take a bare catalog id: ` +
329
402
  "they address the method itself, never one of its versions. Strip the suffix with " +
330
- "parseMethodSelector, and read a published version with getMethodVersion.");
403
+ "parseMethodSelector, and read a published version with getMethodVersion.", { problem: argumentInvalidProblem("method_id", "version_suffix") });
331
404
  }
332
405
  return `methods/${segment}`;
333
406
  }
@@ -360,13 +433,13 @@ const RUN_FILES_LISTS = ["inputs", "outputs", "intermediates", "links"];
360
433
  */
361
434
  function fileLinkTarget(value) {
362
435
  if (!isPlainObject(value)) {
363
- throw argumentRefusal("linkFiles", LINK_TARGET_ARGUMENT, describeNumberArgument(value));
436
+ throw argumentRefusal("linkFiles", LINK_TARGET_ARGUMENT, describeNumberArgument(value), "entry_mistyped");
364
437
  }
365
438
  const fileId = value.file_id ?? undefined;
366
439
  const uri = value.uri ?? undefined;
367
440
  if ((fileId === undefined) === (uri === undefined)) {
368
441
  const got = fileId === undefined ? "an object naming neither" : "an object naming both";
369
- throw argumentRefusal("linkFiles", LINK_TARGET_ARGUMENT, got);
442
+ throw argumentRefusal("linkFiles", LINK_TARGET_ARGUMENT, got, "not_exactly_one_selector");
370
443
  }
371
444
  if (fileId !== undefined) {
372
445
  return { file_id: requireNonEmptyId(fileId, "linkFiles", LINK_TARGET_ARGUMENT) };
@@ -433,13 +506,13 @@ export class PipelexApiClient {
433
506
  // (e.g. `.../v1`) composes as `/v1/v1/...` and fails with a misleading
434
507
  // endpoint error instead of a clear base-URL one. Trailing slashes are
435
508
  // stripped first; a remaining path/query/fragment/credentials is rejected. The
436
- // refusal is `config`: the value typically comes from PIPELEX_BASE_URL, the environment.
437
- // It never quotes the value whole: what the rule refuses is where a secret travels
438
- // (a password, a token in a query), so it names those parts without their text.
509
+ // refusal is `client.not_configured`: the value typically comes from PIPELEX_BASE_URL, the
510
+ // environment. It never quotes the value whole: what the rule refuses is where a secret
511
+ // travels (a password, a token in a query), so it names those parts without their text.
439
512
  if (!isValidBaseUrl(normalizedBaseUrl)) {
440
513
  throw new RequestArgumentError(`Invalid API base URL ${describeRefusedBaseUrl(normalizedBaseUrl)}: it must be host-only ` +
441
514
  `(http/https, no path, query, fragment, or credentials). Endpoints ` +
442
- `compose as {base}/v1/{endpoint}.`, { verdict: { errorDomain: "config", retryable: false } });
515
+ `compose as {base}/v1/{endpoint}.`, { problem: buildProblem("client.not_configured", { setting: "base_url" }) });
443
516
  }
444
517
  this.baseUrl = normalizedBaseUrl;
445
518
  this.originUrl = new URL("/", this.baseUrl).origin;
@@ -482,9 +555,11 @@ export class PipelexApiClient {
482
555
  * Issue one HTTP request and return the raw status/headers/body. Wraps
483
556
  * DNS/connect/TLS/timeout failures as `ApiUnreachableError`; a caller-driven
484
557
  * abort (Ctrl-C / agent walk-away) propagates as-is so the poll loop can stop
485
- * cleanly. Non-2xx interpretation is left to the caller. `url` is a fully
486
- * resolved absolute URL. `mayStartRun` marks a request that may create a run,
487
- * whose `ApiUnreachableError` then says whether it may have (see `RunCreation`).
558
+ * cleanly. A body that arrived and could not be decoded, such as a `gzip` body that is not
559
+ * gzip, is an answer, so it throws the `ApiResponseError` of that answer, read as an answer
560
+ * with an empty body (see `isUndecodableBody`). Non-2xx interpretation is left to the caller.
561
+ * `url` is a fully resolved absolute URL. `mayStartRun` marks a request that may create a run,
562
+ * whose errors then say whether it may have (see `RunCreation`).
488
563
  * `idempotencyKey`, already checked by `idempotencyKeyOf`, is sent as the
489
564
  * `Idempotency-Key` header; only `start` and `copyMethod` pass one.
490
565
  */
@@ -537,7 +612,12 @@ export class PipelexApiClient {
537
612
  // is classified from the controller rather than from `err`: a browser errors a
538
613
  // body stream cut short by that abort with a generic AbortError instead.
539
614
  const code = extractNetworkErrorCode(controller.signal.aborted ? controller.signal.reason : err);
540
- throw new ApiUnreachableError(`Could not reach Pipelex API at ${this.baseUrl} (${code ?? "network error"})`, this.baseUrl, code, { cause: err, mayStartRun: options.mayStartRun === true });
615
+ // An answer came back, and its body could not be decoded: an answer the SDK cannot read,
616
+ // never an unreachable API.
617
+ if (response !== undefined && isUndecodableBody(code)) {
618
+ throw this.undecodableAnswer(method, url, response, code, err, options);
619
+ }
620
+ throw new ApiUnreachableError(`Could not reach Pipelex API at ${this.baseUrl} (${code ?? "network error"})`, this.baseUrl, code, { cause: err, mayStartRun: options.mayStartRun === true, timeoutMs });
541
621
  }
542
622
  finally {
543
623
  clearTimeout(timer);
@@ -561,10 +641,22 @@ export class PipelexApiClient {
561
641
  utf8,
562
642
  };
563
643
  }
644
+ /**
645
+ * The `ApiResponseError` of an answer whose body arrived and could not be decoded: the answer's
646
+ * status and headers, read as an answer with an empty body (`readHostedAnswer`), so a `2xx` is
647
+ * `client.response_unreadable` naming its status, or `client.outcome_unknown` on a request that
648
+ * may create a run, which the server accepted. `code` is the decompressor's, named in the
649
+ * message, and the decoding failure is its `cause`.
650
+ */
651
+ undecodableAnswer(method, url, response, code, cause, run) {
652
+ const path = new URL(url).pathname;
653
+ return new ApiResponseError(`API ${method} ${path} answered ${response.status} with a body that could not be decoded ` +
654
+ `(${code ?? "decoding error"})`, this.baseUrl, response.status, response.statusText, "", { cause, headers: response.headers, mayStartRun: run.mayStartRun === true });
655
+ }
564
656
  /**
565
657
  * Issue a Pipelex-product request (`/v1/me`, `/v1/methods`, `/v1/billing/*`,
566
658
  * …) and parse its JSON body, mapping a non-2xx response to the typed
567
- * `ApiResponseError` so callers branch on its `errorDomain` and `type`, not the
659
+ * `ApiResponseError` so callers branch on its problem's `code`, not the
568
660
  * HTTP status. Empty-body tolerant — DELETE / onboarding / updateRun
569
661
  * answer 2xx with no body, returned as `undefined`. Uses the management-call
570
662
  * timeout, not the blocking-execute ceiling.
@@ -639,8 +731,8 @@ export class PipelexApiClient {
639
731
  * One page of a cursor-paged product route (`listMethods`, `listMethodVersions`,
640
732
  * `listMethodExamples`, `listRuns`, `listFiles`), read as the platform always serializes it: `items` an
641
733
  * array, `next_cursor` a string or `null`. A page breaking either is an answer the SDK cannot
642
- * read, so it throws the `ApiResponseError` `unreadableAnswer` builds, `runtime` and not
643
- * retryable, rather than handing the iterators a page they cannot walk: `items` that is not
734
+ * read, so it throws the `ApiResponseError` `unreadableAnswer` builds,
735
+ * `client.response_unreadable`, rather than handing the iterators a page they cannot walk: `items` that is not
644
736
  * iterable, or a missing cursor that is neither the end nor a next page, which `iterateRuns`
645
737
  * would follow forever. A route reading its rows itself passes `rows`: `parse` reads the page's
646
738
  * text, and `item` reads each row, throwing as `unreadableAnswer` does for one it cannot read.
@@ -773,42 +865,42 @@ export class PipelexApiClient {
773
865
  /**
774
866
  * The `ApiResponseError` of a 2xx answer the SDK cannot read, its message
775
867
  * `API <method> <path> answered <status> with <what>`: the answer's status, its raw text as
776
- * `responseBody`, no hosted problem member but the `X-Request-ID` header, the failure that made it
777
- * unreadable as `cause`, and the verdict the fallback gives a 2xx, `runtime` and not retryable.
778
- * An answer that carries signed links (`RawResponse.carriesLinks`) is held with every link
779
- * hidden, and with no `cause`, which could quote one. On a request that may create a run, the run may have started: the server accepted it.
868
+ * `responseBody`, the failure that made it unreadable as `cause`, and the problem
869
+ * `client.response_unreadable` naming the status. On a request that may create a run, the run
870
+ * may exist, since the server accepted the request, so the problem is `client.outcome_unknown`
871
+ * instead, naming the run when the body names one. An answer that carries signed links
872
+ * (`RawResponse.carriesLinks`) is held with every link hidden, and with no `cause`, which could
873
+ * quote one.
780
874
  */
781
875
  unreadableAnswer(method, path, res, what, cause, run = {}) {
782
876
  const carriesLinks = res.carriesLinks === true;
783
- return new ApiResponseError(`API ${method} ${path} answered ${res.status} with ${what}`, this.baseUrl, res.status, res.statusText, carriesLinks ? redactSignedLinks(res.body) : res.body, undefined, undefined, undefined, undefined, {
877
+ return new ApiResponseError(`API ${method} ${path} answered ${res.status} with ${what}`, this.baseUrl, res.status, res.statusText, carriesLinks ? redactSignedLinks(res.body) : res.body, {
784
878
  cause: carriesLinks ? undefined : cause,
785
- details: { requestId: nonEmptyHeader(res.headers, REQUEST_ID_HEADER) },
879
+ problem: run.mayStartRun === true
880
+ ? outcomeUnknownProblem(runIdNamedBy(res.body))
881
+ : buildProblem("client.response_unreadable", { status: res.status }),
786
882
  headers: res.headers,
787
- mayStartRun: run.mayStartRun === true,
788
883
  });
789
884
  }
790
885
  /**
791
886
  * Throw the `ApiResponseError` of a non-2xx answer, naming the route by its `path` from the
792
- * origin (`/v1/...`, or `/health` for the liveness probe).
887
+ * origin (`/v1/...`, or `/health` for the liveness probe). The answer is read as its problem
888
+ * (`readHostedAnswer`): the body as received when it is a problem document, whose `detail` the
889
+ * message quotes, and otherwise the problem the edge's bare answer reads as, the message
890
+ * quoting the body as it came, or the status text for an empty one.
793
891
  */
794
892
  throwApiResponseErrorAt(method, path, res, run = {}) {
795
- const { errorType, serverMessage, validationErrors, code, details, document } = parseErrorBody(res.body);
796
- // The body's `request_id` wins; the header is the fallback for a response whose body
797
- // carries none (a gateway error page, a non-problem body).
798
- const requestId = details.requestId ?? nonEmptyHeader(res.headers, REQUEST_ID_HEADER);
799
- throw new ApiResponseError(`API ${method} ${path} failed (${res.status}): ${serverMessage ?? (res.body || res.statusText)}`, this.baseUrl, res.status, res.statusText, res.body, errorType, serverMessage, validationErrors, code, {
800
- details: { ...details, requestId },
801
- problemDocument: document,
802
- headers: res.headers,
803
- mayStartRun: run.mayStartRun === true,
804
- });
893
+ const { problem, fromBody } = readHostedAnswer({ status: res.status, headers: res.headers, body: res.body }, { createsRun: run.mayStartRun === true });
894
+ const said = fromBody ? problem.detail : res.body || res.statusText;
895
+ throw new ApiResponseError(`API ${method} ${path} failed (${res.status}): ${said}`, this.baseUrl, res.status, res.statusText, res.body, { problem, headers: res.headers });
805
896
  }
806
897
  /**
807
898
  * Translate a "route absent" 404 (a bare pipelex-api with no platform block)
808
- * into a clear `RunLifecycleUnavailableError`. The platform's own 404s (run
809
- * not found / cross-org) carry a structured error envelope (a `code` field)
810
- * and are left for normal handling. `explanation` replaces the sentence saying
811
- * why the route is missing, for a route a hosted API may also lack.
899
+ * into a clear `RunLifecycleUnavailableError`. A 404 a route answered on purpose (a run
900
+ * not found, cross-org included, or a method that does not exist) names its own `code`
901
+ * and is left for normal handling as `ApiResponseError` (`isMissingRoute404`).
902
+ * `explanation` replaces the sentence saying why the route is missing, for a route a
903
+ * hosted API may also lack.
812
904
  */
813
905
  throwIfLifecycleUnavailable(res, url, explanation = "PIPELEX_BASE_URL points at a bare runner that does not serve it.") {
814
906
  if (res.status !== 404)
@@ -848,9 +940,9 @@ export class PipelexApiClient {
848
940
  * there this throws.
849
941
  *
850
942
  * It goes through the same transport as every route: an unreachable origin is an
851
- * `ApiUnreachableError`, and a non-2xx answer an `ApiResponseError` whose verdict the SDK's
852
- * fallback reads from the status, since the probe answers no problem document. So is a 2xx
853
- * whose body is not a JSON object, such as a gateway's HTML page: `runtime`, not retryable.
943
+ * `ApiUnreachableError`, and a non-2xx answer an `ApiResponseError` whose problem is read from
944
+ * the status when the probe answers no problem document. So is a 2xx whose body is not a JSON
945
+ * object, such as a gateway's HTML page: `client.response_unreadable`.
854
946
  */
855
947
  async health() {
856
948
  const res = await this.requestRaw("GET", `${this.originUrl}/health`, {
@@ -950,15 +1042,15 @@ export class PipelexApiClient {
950
1042
  *
951
1043
  * `options.idempotencyKey` is sent as the `Idempotency-Key` header, and nothing
952
1044
  * is sent without it: the hosted API answers the same start sent again under the
953
- * same key with the run the first one started, so a failure whose
954
- * `runMayHaveStarted` is true can be retried unchanged (see
1045
+ * same key with the run the first one started, so a failure whose problem's
1046
+ * `retry` is `outcome_unknown` can be retried unchanged (see
955
1047
  * `PipelexStartRequestOptions`). A malformed key is a `RequestArgumentError`
956
1048
  * before anything is sent.
957
1049
  *
958
1050
  * The method's tree is prepared before the request as `execute` prepares it. The prepared body
959
1051
  * is the same for the same tree, so a start retried under its idempotency key sends the body the
960
1052
  * key was bound to. A start whose hash entries name a blob the API does not hold yet is refused
961
- * with a `422` `missing_blobs`, which releases its key: send it again unchanged, which asks the
1053
+ * with a `422` `request.blobs_missing`, which releases its key: send it again unchanged, which asks the
962
1054
  * blob route and uploads what is missing first.
963
1055
  */
964
1056
  async start(options) {
@@ -1018,7 +1110,9 @@ export class PipelexApiClient {
1018
1110
  * `/validate` is a diagnostic endpoint: every produced verdict rides a **200**,
1019
1111
  * discriminated on `is_valid`. This returns the `PipelexValidationResult` union
1020
1112
  * verbatim — `is_valid: true` ⇒ the typed `PipelexValidationReport` (structural
1021
- * artifacts), `is_valid: false` ⇒ a `PipelexInvalidReport` (`validation_errors[]`).
1113
+ * artifacts), `is_valid: false` ⇒ a `PipelexInvalidReport`, whose `errors[]` are problem
1114
+ * items. Both arms' items, the invalid arm's `errors[]` and the valid arm's `warnings[]`, are
1115
+ * read member by member (`readProblemItems`).
1022
1116
  * An invalid method is NOT thrown — the caller pattern-matches `is_valid`. Only a
1023
1117
  * *no-verdict* condition (a malformed request, a tree the route refuses, auth, a
1024
1118
  * server fault) is non-2xx and surfaces as `ApiResponseError`.
@@ -1048,8 +1142,8 @@ export class PipelexApiClient {
1048
1142
  *
1049
1143
  * A selector-resolution failure (fetch failure, no package at the address, an
1050
1144
  * unknown or foreign-org id, a bare id of a method never published, `409`
1051
- * `method_not_published`, a version never published, `404`
1052
- * `method_version_not_found`, or a malformed suffix, `422`) is a non-2xx
1145
+ * `pipelex.catalog.not_published`, a version never published, `404`
1146
+ * `pipelex.catalog.not_found`, or a malformed suffix, `422`) is a non-2xx
1053
1147
  * `ApiResponseError` — never an `is_valid: false` verdict, which is reserved
1054
1148
  * for actual MTHDS content.
1055
1149
  *
@@ -1085,7 +1179,7 @@ export class PipelexApiClient {
1085
1179
  // null source — mirroring the server's strict tooling XOR instead of silently picking.
1086
1180
  if (source === null || typeof source !== "object" || Array.isArray(source)) {
1087
1181
  throw new RequestArgumentError("validate() takes the method as an object naming exactly one of directory, contents, " +
1088
- "tree, files, method_ref or method_id.");
1182
+ "tree, files, method_ref or method_id.", { problem: argumentInvalidProblem("source", "not_an_object") });
1089
1183
  }
1090
1184
  const forms = protocolTreeForms("validate", source);
1091
1185
  const methodRef = nonEmptyString(source.method_ref);
@@ -1097,7 +1191,7 @@ export class PipelexApiClient {
1097
1191
  given.push("method_id");
1098
1192
  if (given.length !== 1) {
1099
1193
  throw new RequestArgumentError("validate() takes exactly one of the method's tree (directory, contents, tree or files), " +
1100
- `method_ref or method_id; got ${given.length === 0 ? "none" : given.join(" and ")}.`);
1194
+ `method_ref or method_id; got ${given.length === 0 ? "none" : given.join(" and ")}.`, { problem: argumentInvalidProblem("files", "not_exactly_one_selector") });
1101
1195
  }
1102
1196
  const signal = options.signal;
1103
1197
  const sent = await this.treeToSend("validate", forms, REQUEST_SENDING, signal);
@@ -1123,7 +1217,7 @@ export class PipelexApiClient {
1123
1217
  if (res.status < 200 || res.status >= 300) {
1124
1218
  this.throwApiResponseError("POST", "validate", res);
1125
1219
  }
1126
- return this.readObjectAnswer("POST", "validate", res);
1220
+ return withVerdictItems(this.readObjectAnswer("POST", "validate", res));
1127
1221
  }
1128
1222
  // ── Tools extensions (Pipelex API — `/v1/lint`, `/v1/format`) ─────────
1129
1223
  //
@@ -1263,10 +1357,9 @@ export class PipelexApiClient {
1263
1357
  * Returns a **200 verdict** whatever the resolution: a reference that resolves nowhere is
1264
1358
  * `resolution: "not_found"` with the names it may have meant, never a thrown error. Narrow
1265
1359
  * on `kind` to read the shape of `matches`. A request that cannot produce a verdict throws
1266
- * the typed `ApiResponseError`, a `422` whose `errorType` says why: `InvalidModelReference`
1267
- * (a blank reference, a sigil or a namespace alone, or one past the runner's length limit),
1268
- * `InvalidModelCategory` (an unknown `type`) or `ValidationError`. None of that is checked
1269
- * here, so the runner's rule is the only one.
1360
+ * the typed `ApiResponseError`, a `422` problem whose `code` says why: a blank reference, a
1361
+ * sigil or a namespace alone, one past the runner's length limit, or an unknown `type`. None of
1362
+ * that is checked here, so the runner's rule is the only one.
1270
1363
  */
1271
1364
  async checkModelReference(reference, category) {
1272
1365
  const query = new URLSearchParams({ reference });
@@ -1313,9 +1406,9 @@ export class PipelexApiClient {
1313
1406
  // serves both, verdict discipline intact (200 `is_valid:false`, 501, 422);
1314
1407
  // api-dev.pipelex.com has since 2026-08-13. `lint`/`format` are the two still
1315
1408
  // unexposed — see their section above for why that blocks nothing. `pipe-io` is
1316
- // newer: a runner serves it from `pipelex-api` v0.33.0, and types its selection
1317
- // refusals `EntryPipeNotFoundError` / `EntryPipeAmbiguousError` from v0.33.1
1318
- // (v0.33.0 typed them `ValidationError`), and a hosted origin
1409
+ // newer: a runner serves it from `pipelex-api` v0.33.0, and codes its selection
1410
+ // refusals `request.pipe_not_found`, `request.pipe_unspecified` and
1411
+ // `request.pipe_ambiguous`, and a hosted origin
1319
1412
  // serves it once the platform's proxy and the gateway list it (see
1320
1413
  // `docs/crate-routes.md`).
1321
1414
  //
@@ -1336,7 +1429,7 @@ export class PipelexApiClient {
1336
1429
  *
1337
1430
  * Returns a **200 verdict**: pattern-match `is_valid` before reading the arm — a
1338
1431
  * closure that does not parse/load/validate comes back as `is_valid: false` with
1339
- * `validation_errors[]`, not as a thrown error. Only a no-verdict condition throws
1432
+ * problem items in `errors[]`, not as a thrown error. Only a no-verdict condition throws
1340
1433
  * `ApiResponseError`: a malformed selector (none, or more than one, of
1341
1434
  * `files` / `method_ref` / `method_id`) is a 422; a selector-resolution failure
1342
1435
  * (fetch failure, no package at the address, an unknown or foreign-org id) is
@@ -1404,11 +1497,11 @@ export class PipelexApiClient {
1404
1497
  * {@link resolve} (the request's members are posted as given, its `directory`, `tree` or
1405
1498
  * `contents` as the `files` map, and the selector XOR is the server's to enforce).
1406
1499
  * Only a no-verdict condition throws `ApiResponseError`: a malformed selector and an
1407
- * over-limit file are `ValidationError` `422`s; a selection the route refuses is a
1408
- * `422` whose `errorType` is `EntryPipeNotFoundError` (an unknown `pipe_ref`, no entry
1409
- * pipe) or `EntryPipeAmbiguousError` (an ambiguous code, several entry pipes), the
1410
- * candidates in its `serverMessage`; a registry-form `method_ref` is a `501`; a pipe
1411
- * whose artifacts cannot be derived is a `500`.
1500
+ * over-limit file are `422` problems; a selection the route refuses is a `422` whose
1501
+ * `code` is `request.pipe_not_found` (an unknown `pipe_ref`), `request.pipe_unspecified`
1502
+ * (no entry pipe) or `request.pipe_ambiguous` (an ambiguous code, several entry pipes), the
1503
+ * candidates in its `params`; a registry-form `method_ref` is a `501`; a pipe whose
1504
+ * artifacts cannot be derived is a `500`.
1412
1505
  *
1413
1506
  * A `method_ref` gets the fetch-sized budget, as on the other crate routes. On the
1414
1507
  * hosted API the gateway caps a request at about 30 seconds whatever the client allows,
@@ -1459,7 +1552,7 @@ export class PipelexApiClient {
1459
1552
  if (res.status < 200 || res.status >= 300) {
1460
1553
  this.throwApiResponseError("POST", endpoint, res);
1461
1554
  }
1462
- return this.readObjectAnswer("POST", endpoint, res);
1555
+ return withVerdictItems(this.readObjectAnswer("POST", endpoint, res));
1463
1556
  }
1464
1557
  /**
1465
1558
  * The request budget for a crate route's closure (`resolve`, `codegen`, `pipeIo`): the
@@ -1487,9 +1580,9 @@ export class PipelexApiClient {
1487
1580
  * is live and the run is stuck, its workflow task failing until the platform
1488
1581
  * ends it at `degradation.ends_at`. `retry_after_seconds` carries the server's
1489
1582
  * `Retry-After` hint when present, and `request_id` the id to quote to support.
1490
- * A failed run's stored report, `error`, and the `degradation` are each
1491
- * checked field by field (see `RunErrorReport` and `RunDegradation`), so
1492
- * neither ever fails the read. Throws `RunLifecycleUnavailableError` when the
1583
+ * A failed run's problem, `error`, is kept when it is a problem document and
1584
+ * read as `null` otherwise, and the `degradation` is checked field by field
1585
+ * (see `RunDegradation`), so neither ever fails the read. Throws `RunLifecycleUnavailableError` when the
1493
1586
  * lifecycle routes are absent (a bare runner).
1494
1587
  */
1495
1588
  async getRunStatus(runId, options = {}) {
@@ -1503,7 +1596,7 @@ export class PipelexApiClient {
1503
1596
  if (res.status < 200 || res.status >= 300) {
1504
1597
  this.throwApiResponseError("GET", endpoint, res);
1505
1598
  }
1506
- const run = withCheckedDegradation(withCheckedReport(this.readObjectAnswer("GET", endpoint, res)));
1599
+ const run = withCheckedDegradation(withCheckedProblem(this.readObjectAnswer("GET", endpoint, res)));
1507
1600
  const retryAfter = parseRetryAfter(res.headers);
1508
1601
  return retryAfter !== null ? { ...run, retry_after_seconds: retryAfter } : run;
1509
1602
  }
@@ -1543,17 +1636,17 @@ export class PipelexApiClient {
1543
1636
  if (fault !== undefined) {
1544
1637
  throw this.unreadableAnswer("GET", `/${API_PREFIX}/${endpoint}`, res, `a snapshot whose ${fault}`);
1545
1638
  }
1546
- return withCheckedProgressDegradations(answer);
1639
+ return withCheckedProgressHealth(answer);
1547
1640
  }
1548
1641
  /**
1549
1642
  * Single-shot result lookup — `GET /v1/runs/{pipeline_run_id}/results`.
1550
1643
  * Maps the server's poll semantics to a discriminated union:
1551
1644
  * - HTTP 202 → `running` (with the `Retry-After` hint)
1552
1645
  * - HTTP 200 → `completed` (with the result artifacts)
1553
- * - HTTP 409 → `failed` (terminal non-`COMPLETED`), carrying the problem's `detail` as
1554
- * `message`, its `run_status` member as `status` (recovered from `detail` on a platform
1555
- * that predates the member) and its `error` member, the run's stored error report, typed
1556
- * as `error`
1646
+ * - HTTP 409 `run.failed` → `failed` (terminal non-`COMPLETED`), carrying the problem as
1647
+ * `problem`, its `detail` as `message`, the terminal status its `run_status` parameter names
1648
+ * as `status`, and the run's own problem, which it nests as `run_error`, as `error`; a `409`
1649
+ * `run.not_finished` → `running`, and any other `409` throws `ApiResponseError`
1557
1650
  * - HTTP 503 → `running` (Temporal degraded — retry, never fail a poller)
1558
1651
  *
1559
1652
  * `options.artifacts` narrows the read to the named artifacts, sent as one
@@ -1593,7 +1686,10 @@ export class PipelexApiClient {
1593
1686
  };
1594
1687
  }
1595
1688
  if (res.status === 409) {
1596
- return runResultFailed(runId, res.body);
1689
+ const conflict = runResultConflict(runId, res);
1690
+ if (conflict !== undefined)
1691
+ return conflict;
1692
+ this.throwApiResponseError("GET", endpoint, res);
1597
1693
  }
1598
1694
  this.throwIfLifecycleUnavailable(res, url);
1599
1695
  if (res.status < 200 || res.status >= 300) {
@@ -1625,8 +1721,8 @@ export class PipelexApiClient {
1625
1721
  * real error; no answer at all propagates, uncached. An answer is anything the
1626
1722
  * server sent back: a non-2xx status, a body that is not JSON, not UTF-8 or no
1627
1723
  * version, and a body that arrived but could not be decoded, such as a broken
1628
- * gzip stream, which `requestRaw` reports as an `ApiUnreachableError` carrying
1629
- * the decompressor's code (see `isUndecodableBody`).
1724
+ * gzip stream, which `requestRaw` reports as an `ApiResponseError` (see
1725
+ * `isUndecodableBody`).
1630
1726
  *
1631
1727
  * `signal` stops the handshake: its abort is thrown as is and nothing is cached.
1632
1728
  *
@@ -1647,9 +1743,9 @@ export class PipelexApiClient {
1647
1743
  // The caller's abort is no answer: it propagates, and nothing is cached.
1648
1744
  if (signal?.aborted === true)
1649
1745
  throw signal.reason;
1650
- // No answer at all propagates. A body that arrived and could not be decoded is an answer,
1651
- // and so is every other failure here: a non-2xx status, or a body that is no version.
1652
- if (error instanceof ApiUnreachableError && !isUndecodableBody(error))
1746
+ // No answer at all propagates. Every other failure here is an answer: a non-2xx status, a
1747
+ // body that is no version, or one that arrived and could not be decoded.
1748
+ if (error instanceof ApiUnreachableError)
1653
1749
  throw error;
1654
1750
  this.lifecycleAvailable = true;
1655
1751
  }
@@ -1682,11 +1778,13 @@ export class PipelexApiClient {
1682
1778
  * rather than listing, reading and hashing the tree again.
1683
1779
  *
1684
1780
  * An error from the start, or from the blocking execute, says whether that request may still
1685
- * have started a run whose id never reached the caller (`runMayHaveStarted`); when it may have,
1686
- * it is not retryable, since calling this method again could pay for the run twice: look the run
1687
- * up in the run history first. An error from the wait, once `onStarted` has named the run,
1688
- * started nothing, so its `runMayHaveStarted` is false, and its `retryable` is about waiting
1689
- * again on that id with `waitForResult`, never about calling this method again.
1781
+ * have started a run whose id never reached the caller: its problem's `retry` is then
1782
+ * `outcome_unknown`, since calling this method again could pay for the run twice, unless it is
1783
+ * made with the same `idempotencyKey`: look the run up in the run history first. An error from
1784
+ * the wait, once `onStarted` has named the run, started nothing, and its problem is about that
1785
+ * run: `client.follow_failed` for a wait that ran out of time, whose step is to read the run
1786
+ * again with `waitForResult`, or `run.failed` for a run that ended without success, never a
1787
+ * reason to call this method again.
1690
1788
  *
1691
1789
  * `options.idempotencyKey` rides the durable start, so a call that failed while its start may
1692
1790
  * have created a run can be made again with the same options and the same key: the hosted API
@@ -1753,7 +1851,7 @@ export class PipelexApiClient {
1753
1851
  // The hosted catalog/account routes the webapp drives. Every one rides the
1754
1852
  // same `{base}/v1/*` surface, `Authorization: Bearer`, org-from-JWT contract
1755
1853
  // as the protocol routes, and maps a non-2xx `problem+json` to a typed
1756
- // `ApiResponseError` (branch on `.errorDomain` and `.type`, not the status).
1854
+ // `ApiResponseError` (branch on its problem's `code`, not the status).
1757
1855
  /** The authenticated user's profile — `GET /v1/me`. */
1758
1856
  async getMe() {
1759
1857
  return this.requestProduct("GET", "me");
@@ -1830,10 +1928,11 @@ export class PipelexApiClient {
1830
1928
  * and for an id that is empty, `.` or `..`, which the URL would resolve into another route's
1831
1929
  * path.
1832
1930
  *
1833
- * Throws `ApiResponseError`: `404` `not_found` for an unknown or foreign-org method, `409`
1834
- * `method_being_deleted` while its erasure runs, and a `409` whose verdict is `runtime` for a
1835
- * method whose draft names a tree the platform holds no record of. An answer whose `files` is
1836
- * not a listing is an answer the SDK cannot read, thrown as an `ApiResponseError` too.
1931
+ * Throws `ApiResponseError`: `404` `pipelex.catalog.not_found` for an unknown or foreign-org
1932
+ * method, `409` `pipelex.catalog.being_deleted` while its erasure runs, and a `500`
1933
+ * `runtime.unexpected` for a method whose draft names a tree the platform holds no record of. An
1934
+ * answer whose `files` is not a listing is an answer the SDK cannot read, thrown as an
1935
+ * `ApiResponseError` too.
1837
1936
  */
1838
1937
  async getMethod(methodId) {
1839
1938
  const endpoint = methodPath(methodId, "getMethod");
@@ -1842,8 +1941,8 @@ export class PipelexApiClient {
1842
1941
  }
1843
1942
  /**
1844
1943
  * Create a method — `POST /v1/methods`. The new method holds its tree as its draft and has no
1845
- * published version, so its bare id answers `409 method_not_published` on the run and tooling
1846
- * routes until its first `publishMethod`; its draft runs as `mt_…@draft` at once.
1944
+ * published version, so its bare id answers `409` `pipelex.catalog.not_published` on the run and
1945
+ * tooling routes until its first `publishMethod`; its draft runs as `mt_…@draft` at once.
1847
1946
  *
1848
1947
  * The tree is `files`, sent as given, or `tree` or `contents`, which the client sends inline
1849
1948
  * when its inline map weighs 256 KiB or less, or when the platform keeps no blob store, and
@@ -1853,12 +1952,12 @@ export class PipelexApiClient {
1853
1952
  *
1854
1953
  * Throws `RequestArgumentError` for two forms of the tree, or `contents` holding no file, before
1855
1954
  * any request; `MethodFileTransferError` when a file the organization lacks could not be
1856
- * uploaded, and nothing is saved. Throws `ApiResponseError`: `422` `invalid_method_files` for a
1857
- * map the standard refuses, with its `tree_refusal`; `422` `method_tree_too_large` for a tree
1858
- * past one of the deployment's limits, named in its `limit`; `422` `blob_mismatch` for a hash
1859
- * stated at another size than the one held; `422` `missing_blobs` for a hash entry whose bytes
1860
- * the organization does not hold; `413` `payload_too_large` for a method that would leave no
1861
- * room for a publish.
1955
+ * uploaded, and nothing is saved. Throws `ApiResponseError`: `422` `method.package_invalid` for
1956
+ * a map the standard refuses, naming the path in its `params`, and for a tree past one of the
1957
+ * deployment's limits, its `params` carrying `reason` `too_large` and the integer `limit`;
1958
+ * `422` `request.invalid` for a hash stated at another size than the one held; `422`
1959
+ * `request.blobs_missing` for a hash entry whose bytes the organization does not hold; `413`
1960
+ * `request.too_large` for a method that would leave no room for a publish.
1862
1961
  */
1863
1962
  async createMethod(input) {
1864
1963
  const source = await this.treeToSend("createMethod", input, SAVE_SENDING);
@@ -1881,23 +1980,23 @@ export class PipelexApiClient {
1881
1980
  * `files: null` emptying the draft, or `tree` or `contents`, sent inline or by hash as
1882
1981
  * `createMethod` sends them. `input_data` is three-way (see `MethodDraftInput`). With
1883
1982
  * `expected_updated_at`, the write is a compare-and-swap on the draft token: a draft that
1884
- * moved since is refused with a `409` whose `code` is `method_update_conflict`, and nothing is
1983
+ * moved since is refused with a `409` whose `code` is `pipelex.catalog.update_conflict`, and nothing is
1885
1984
  * written. Without it, last writer wins.
1886
1985
  *
1887
1986
  * Takes a bare catalog id, as `getMethod` does. Throws `RequestArgumentError` for a draft that
1888
1987
  * gives its tree in no form or in two, or `contents` holding no file, before any request;
1889
1988
  * `MethodFileTransferError` when a file the organization lacks could not be uploaded. Throws
1890
- * `ApiResponseError`: `404` `not_found` for an unknown or foreign-org method, `409`
1891
- * `method_being_deleted` while its erasure runs, the `422` refusals of a tree `createMethod`
1892
- * lists, `413` `payload_too_large` for a draft that would grow the method past the room a
1893
- * publish needs, `403` for a read-only key, and a `503` that wrote nothing, safe to retry,
1989
+ * `ApiResponseError`: `404` `pipelex.catalog.not_found` for an unknown or foreign-org method,
1990
+ * `409` `pipelex.catalog.being_deleted` while its erasure runs, the `422` refusals of a tree
1991
+ * `createMethod` lists, `413` `request.too_large` for a draft that would grow the method past the
1992
+ * room a publish needs, `403` for a read-only key, and a `503` that wrote nothing, safe to retry,
1894
1993
  * when the method kept changing under the write.
1895
1994
  */
1896
1995
  async writeDraft(methodId, input) {
1897
1996
  const endpoint = `${methodPath(methodId, "writeDraft")}/draft`;
1898
1997
  if (treeFormsGiven(input).length === 0) {
1899
1998
  throw new RequestArgumentError("writeDraft() replaces the draft's whole tree: give it as directory, contents, tree or " +
1900
- "files (files: null empties the draft).");
1999
+ "files (files: null empties the draft).", { problem: argumentInvalidProblem("files", "missing") });
1901
2000
  }
1902
2001
  const source = await this.treeToSend("writeDraft", input, SAVE_SENDING);
1903
2002
  return this.saveMethodTree("PUT", endpoint, "writeDraft", source, (files) => {
@@ -2016,7 +2115,7 @@ export class PipelexApiClient {
2016
2115
  const given = treeFormsGiven(input);
2017
2116
  if (given.length > 1) {
2018
2117
  throw new RequestArgumentError(`${call}() takes the method's tree in one form, directory, contents, tree or files; got ` +
2019
- `${given.join(" and ")}.`);
2118
+ `${given.join(" and ")}.`, { problem: argumentInvalidProblem("files", "mutually_exclusive") });
2020
2119
  }
2021
2120
  if (given[0] === "directory") {
2022
2121
  const directory = input.directory;
@@ -2242,7 +2341,7 @@ export class PipelexApiClient {
2242
2341
  if (bytes.length !== blob.size || bytesSha256 !== blob.sha256) {
2243
2342
  throw new RequestArgumentError(`The tree read ${bytes.length} bytes hashing to ${bytesSha256} for '${file.path}', which ` +
2244
2343
  `its listing names as ${blob.size} bytes hashing to ${blob.sha256}, so they were not ` +
2245
- `uploaded and ${sending.unsent}.`);
2344
+ `uploaded and ${sending.unsent}.`, { problem: argumentInvalidProblem("tree", "changed_since_listed") });
2246
2345
  }
2247
2346
  throwIfAborted(userSignal);
2248
2347
  await uploadBlob(blob, bytes, {
@@ -2257,11 +2356,11 @@ export class PipelexApiClient {
2257
2356
  * draft, so the `updated_at` a caller holds stays valid for its next `writeDraft` or
2258
2357
  * `publishMethod`.
2259
2358
  *
2260
- * Takes a bare catalog id, as `getMethod` does. Throws `ApiResponseError`: `404` `not_found`,
2261
- * `409` `method_being_deleted`, `403` for a read-only key, `422` for an empty name or one
2262
- * holding a lone surrogate, `413` `payload_too_large` for a name so long it would leave the
2263
- * method too large to publish, and a `503` that wrote nothing, safe to retry, when draft
2264
- * writes kept landing under the rename.
2359
+ * Takes a bare catalog id, as `getMethod` does. Throws `ApiResponseError`: `404`
2360
+ * `pipelex.catalog.not_found`, `409` `pipelex.catalog.being_deleted`, `403` for a read-only key,
2361
+ * `422` for an empty name or one holding a lone surrogate, `413` `request.too_large` for a name
2362
+ * so long it would leave the method too large to publish, and a `503` that wrote nothing, safe
2363
+ * to retry, when draft writes kept landing under the rename.
2265
2364
  */
2266
2365
  async renameMethod(methodId, input) {
2267
2366
  return this.requestMethodData("PATCH", methodPath(methodId, "renameMethod"), {
@@ -2279,16 +2378,17 @@ export class PipelexApiClient {
2279
2378
  * The answer is a `MethodPublishResult` discriminated on `outcome` — branch on it:
2280
2379
  * `published` with the new `version`; `unchanged` with the existing latest `version`;
2281
2380
  * `refused` with a `reason` (`invalid`, or `not_runnable` for a draft that validates with
2282
- * pending signatures), a `message` and the runner's `validation` verdict. Every arm carries the
2283
- * `method`. A publish moves no token.
2381
+ * pending signatures), a `message` and the runner's `validation` verdict, whose problem items
2382
+ * are read member by member as `validate` reads them. Every arm carries the `method`. A publish
2383
+ * moves no token.
2284
2384
  *
2285
2385
  * Takes a bare catalog id, as `getMethod` does. Throws `RequestArgumentError` when
2286
2386
  * `expected_draft_updated_at` is not a string, before any request. Throws `ApiResponseError`
2287
- * when no verdict was produced: `409` `method_update_conflict` for a draft that moved since
2288
- * the token, `409` `method_being_deleted`, `404` `not_found`, `422` for a draft with no
2289
- * `.mthds` file or whose file names a run could not assemble (one name used by a `.mthds` and
2290
- * a Python file), `413` `payload_too_large` for a draft too large to publish, and `403` for a
2291
- * read-only key.
2387
+ * when no verdict was produced: `409` `pipelex.catalog.update_conflict` for a draft that moved since
2388
+ * the token, `409` `pipelex.catalog.being_deleted`, `404` `pipelex.catalog.not_found`, `422` for
2389
+ * a draft with no `.mthds` file or whose file names a run could not assemble (one name used by a
2390
+ * `.mthds` and a Python file), `413` `request.too_large` for a draft too large to publish, and
2391
+ * `403` for a read-only key.
2292
2392
  *
2293
2393
  * Only a draft that differs from the latest version reaches the runner, and a runner that
2294
2394
  * cannot be reached, answers unusably or does not finish within the platform's deadline is a
@@ -2296,15 +2396,16 @@ export class PipelexApiClient {
2296
2396
  * itself is relayed under its own status. A retry of a publish that landed while its answer was
2297
2397
  * lost, as on a client timeout, answers `unchanged` with the version it wrote.
2298
2398
  *
2299
- * An answer whose `outcome` is not one of the outcomes above, or that carries no `method`
2300
- * object, is an answer the SDK cannot read, thrown as an `ApiResponseError` too.
2399
+ * An answer whose `outcome` is not one of the outcomes above, that carries no `method` object,
2400
+ * or a refusal whose `validation` is not a verdict object with a boolean `is_valid`, is an answer
2401
+ * the SDK cannot read, thrown as an `ApiResponseError` too.
2301
2402
  */
2302
2403
  async publishMethod(methodId, input) {
2303
2404
  const token = input
2304
2405
  ?.expected_draft_updated_at;
2305
2406
  if (typeof token !== "string") {
2306
2407
  throw new RequestArgumentError("publishMethod() needs expected_draft_updated_at: the draft token (the method's " +
2307
- "updated_at) the caller last saw, so a publish never takes a draft it has not seen.");
2408
+ "updated_at) the caller last saw, so a publish never takes a draft it has not seen.", { problem: argumentInvalidProblem("expected_draft_updated_at", "not_a_string") });
2308
2409
  }
2309
2410
  const endpoint = `${methodPath(methodId, "publishMethod")}/publish`;
2310
2411
  const res = await this.requestProductAnswer("POST", endpoint, {
@@ -2315,7 +2416,22 @@ export class PipelexApiClient {
2315
2416
  outcomes: ["published", "unchanged", "refused"],
2316
2417
  member: "method",
2317
2418
  });
2318
- return { ...answer, outcome, method: subject };
2419
+ const method = subject;
2420
+ if (outcome !== "refused") {
2421
+ return { ...answer, outcome, method };
2422
+ }
2423
+ // The refused arm carries the runner's verdict, whose problem items are read member by
2424
+ // member as `validate` reads them, so one malformed item never costs the caller the others.
2425
+ const validation = answer.validation;
2426
+ if (!isPlainObject(validation) || typeof validation.is_valid !== "boolean") {
2427
+ throw this.unreadableAnswer("POST", `/${API_PREFIX}/${endpoint}`, res, "a publish result whose `validation` is not an object");
2428
+ }
2429
+ return {
2430
+ ...answer,
2431
+ outcome,
2432
+ validation: withVerdictItems(validation),
2433
+ method,
2434
+ };
2319
2435
  }
2320
2436
  /**
2321
2437
  * One page of a method's published versions, newest first, without their files —
@@ -2325,10 +2441,11 @@ export class PipelexApiClient {
2325
2441
  * may be short while `nextCursor` is set, because the platform reads versions whole. A method
2326
2442
  * never published answers an empty page.
2327
2443
  *
2328
- * Takes a bare catalog id, as `getMethod` does. Throws `ApiResponseError`: `404` `not_found`
2329
- * for an unknown method, `409` `method_being_deleted`, `400` `malformed_request` for a cursor
2330
- * the listing did not issue for this method, `422` for a `limit` outside 1 to 100. A page whose `items` is not an array or whose `next_cursor` is neither a string nor `null`
2331
- * is an answer the SDK cannot read, thrown as an `ApiResponseError` too.
2444
+ * Takes a bare catalog id, as `getMethod` does. Throws `ApiResponseError`: `404`
2445
+ * `pipelex.catalog.not_found` for an unknown method, `409` `pipelex.catalog.being_deleted`, and
2446
+ * `422` `request.invalid` for a cursor the listing did not issue for this method or a `limit`
2447
+ * outside 1 to 100. A page whose `items` is not an array or whose `next_cursor` is neither a
2448
+ * string nor `null` is an answer the SDK cannot read, thrown as an `ApiResponseError` too.
2332
2449
  */
2333
2450
  async listMethodVersions(methodId, query = {}) {
2334
2451
  const search = productQuery({ limit: query.limit, cursor: query.cursor });
@@ -2342,14 +2459,14 @@ export class PipelexApiClient {
2342
2459
  *
2343
2460
  * Takes a bare catalog id, as `getMethod` does, and the version number. Throws
2344
2461
  * `RequestArgumentError` for a `version` that is not a positive integer, before any request.
2345
- * Throws `ApiResponseError`: `404` `method_version_not_found` for a version the method never
2346
- * published, `404` `not_found` for an unknown method, `409` `method_being_deleted`, and `409`
2347
- * `conflict`, whose verdict is `runtime`, for a version stored before methods were trees and
2348
- * never rewritten as one, which names no tree to serve.
2462
+ * Throws `ApiResponseError`: `404` `pipelex.catalog.not_found` for a version the method never
2463
+ * published and for an unknown method, `409` `pipelex.catalog.being_deleted`, and `500`
2464
+ * `runtime.unexpected` for a version stored before methods were trees and never rewritten as
2465
+ * one, which names no tree to serve.
2349
2466
  */
2350
2467
  async getMethodVersion(methodId, version) {
2351
2468
  if (!Number.isSafeInteger(version) || version < 1) {
2352
- throw argumentRefusal("getMethodVersion", "a version number, a positive integer", describeNumberArgument(version));
2469
+ throw argumentRefusal("getMethodVersion", { name: "version", what: "a version number, a positive integer" }, describeNumberArgument(version), "not_a_positive_integer");
2353
2470
  }
2354
2471
  const endpoint = `${methodPath(methodId, "getMethodVersion")}/versions/${version}`;
2355
2472
  const res = await this.requestProductAnswer("GET", endpoint, undefined);
@@ -2370,7 +2487,9 @@ export class PipelexApiClient {
2370
2487
  * The answer is a `MethodCopyResult` discriminated on `outcome` — branch on it: `copied` with
2371
2488
  * the copy as `method`, `examples_copied` and `stored_files_copied`; `refused` with the
2372
2489
  * `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.
2490
+ * }`) a `.mthds` file of the draft holds that the destination cannot read and, in a duplicate,
2491
+ * that names a file under the original's run files, which erasing the original would remove,
2492
+ * nothing written.
2374
2493
  *
2375
2494
  * The route serves web sessions only: a Pipelex API key belongs to one organization and cannot
2376
2495
  * speak for another. `options.idempotencyKey` is sent as the `Idempotency-Key` header, so a copy
@@ -2383,21 +2502,31 @@ export class PipelexApiClient {
2383
2502
  * `destination_org_id` that is not a non-empty string, a `name` or an
2384
2503
  * `expected_draft_updated_at` given that is not a string, and a malformed idempotency key.
2385
2504
  * 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.
2505
+ * sees but for the `503` whose `detail` names the copy: `403` `auth.forbidden` for a request
2506
+ * that is not from a web session, or for a role that may not change methods, in the method's
2507
+ * organization or in the destination; `404` `pipelex.org.not_found` for a destination the
2508
+ * caller does not belong to, still being set up or unknown, alike; `404`
2509
+ * `pipelex.catalog.not_found` for an unknown method; `409` `pipelex.catalog.being_deleted`;
2510
+ * `409` `pipelex.catalog.update_conflict` for a draft that moved since
2511
+ * `expected_draft_updated_at`; a `409` whose `domain` is `runtime` and `retry` `no`, for a
2512
+ * draft whose tree the method's organization holds no record of or no longer holds every file
2513
+ * of; `413`
2514
+ * `request.too_large` for a copied example, or the copy's row, too large for one stored item,
2515
+ * the row keeping room for the summary a publish adds, so a `name` that would leave none is
2516
+ * refused; `422` `request.invalid` for a body that does not validate, such as a name holding a
2517
+ * lone surrogate; `422` `pipelex.catalog.copy_too_large` past one of the copy's bounds on the
2518
+ * stored files it copies, named by the problem's `limit` member (`MethodCopyLimit`); a `502`
2519
+ * `runtime.unavailable`, with `retry` `yes`, when the platform could not read the caller's
2520
+ * memberships; a `503` `pipelex.storage.unavailable`, with `retry` `yes`, when storage failed,
2521
+ * and a `500` `pipelex.storage.not_configured`, with `retry` `no`, when the platform has no
2522
+ * usable credentials for storage; and a `503` `runtime.unavailable`, with `retry` `yes`, past
2523
+ * the copy's deadline, when a stored file it copies is written over meanwhile, or when DynamoDB
2524
+ * fails. When DynamoDB failed both the copy's row write and the read that would confirm it,
2525
+ * that `503`'s `detail` names the copy's method id: the copy may stand in the destination, so
2526
+ * look for that id there before copying again, since a retry may make a second copy. An answer
2527
+ * whose `outcome` is neither arm's, a `copied` one whose `method` is not an object or a
2528
+ * `refused` one whose `references` is not an array, is an answer the SDK cannot read, thrown as
2529
+ * an `ApiResponseError` too.
2401
2530
  */
2402
2531
  async copyMethod(methodId, input, options = {}) {
2403
2532
  const call = "copyMethod";
@@ -2408,13 +2537,13 @@ export class PipelexApiClient {
2408
2537
  };
2409
2538
  if (name != null) {
2410
2539
  if (typeof name !== "string") {
2411
- throw argumentRefusal(call, COPY_NAME_ARGUMENT, describeNumberArgument(name));
2540
+ throw argumentRefusal(call, COPY_NAME_ARGUMENT, describeNumberArgument(name), "not_a_string");
2412
2541
  }
2413
2542
  body.name = name;
2414
2543
  }
2415
2544
  if (token != null) {
2416
2545
  if (typeof token !== "string") {
2417
- throw argumentRefusal(call, COPY_DRAFT_TOKEN_ARGUMENT, describeNumberArgument(token));
2546
+ throw argumentRefusal(call, COPY_DRAFT_TOKEN_ARGUMENT, describeNumberArgument(token), "not_a_string");
2418
2547
  }
2419
2548
  body.expected_draft_updated_at = token;
2420
2549
  }
@@ -2454,11 +2583,11 @@ export class PipelexApiClient {
2454
2583
  * and a copy itself, answer an empty page. The listing is open to every member of the method's
2455
2584
  * organization, a runner included, as every read of a method is.
2456
2585
  *
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.
2586
+ * Takes a bare catalog id, as `getMethod` does. Throws `ApiResponseError`: `404`
2587
+ * `pipelex.catalog.not_found` for an unknown method, `409` `pipelex.catalog.being_deleted`, and
2588
+ * `422` `request.invalid` for a cursor the listing did not issue for this method or a `limit`
2589
+ * outside 1 to 100. A page whose `items` is not an array or whose `next_cursor` is neither a
2590
+ * string nor `null` is an answer the SDK cannot read, thrown as an `ApiResponseError` too.
2462
2591
  */
2463
2592
  async listMethodCopies(methodId, query = {}) {
2464
2593
  const search = productQuery({ limit: query.limit, cursor: query.cursor });
@@ -2478,7 +2607,7 @@ export class PipelexApiClient {
2478
2607
  * it with a `409`.
2479
2608
  *
2480
2609
  * A double-clicked delete is safe: the claim is a conditional write, so the
2481
- * second call is an `ApiResponseError` (`409 method_being_deleted`) rather than a
2610
+ * second call is an `ApiResponseError` (`409` `pipelex.catalog.being_deleted`) rather than a
2482
2611
  * second cascade over the same runs. An unknown or foreign-org id is a `404`. The
2483
2612
  * erasure deletes the method's published versions with the rest. Takes a bare catalog id,
2484
2613
  * as `getMethod` does: the whole method is erased, never one of its versions.
@@ -2489,8 +2618,9 @@ export class PipelexApiClient {
2489
2618
  // ── Method examples (`/v1/methods/{id}/examples`) ──────────────────────
2490
2619
  //
2491
2620
  // Every example route takes a bare catalog id, as `getMethod` does, and answers `404`
2492
- // `not_found` for an unknown method or example and `409` `method_being_deleted` while the
2493
- // method's erasure runs. The writes are `403` to a read-only key, which may still read.
2621
+ // `pipelex.catalog.not_found` for an unknown method or example and `409`
2622
+ // `pipelex.catalog.being_deleted` while the method's erasure runs. The writes are `403` to a
2623
+ // read-only key, which may still read.
2494
2624
  //
2495
2625
  // Every answer carrying an example is read with `parseExactJson`, so an integer past
2496
2626
  // `Number.MAX_SAFE_INTEGER` in its inputs, which another client may have saved, is a `bigint`
@@ -2507,8 +2637,8 @@ export class PipelexApiClient {
2507
2637
  * closed, and a `null` `run_id` reads as not given. Throws `RequestArgumentError` before any
2508
2638
  * request when `name` or `inputs` is not given (`null` counting as not given), and for a
2509
2639
  * `run_id` that is given but is not a non-empty string. Throws `ApiResponseError`: `404`
2510
- * `run_not_found` for a `run_id` that is not one of this method's runs in the caller's
2511
- * organization, with nothing written; `413` `payload_too_large` for an example larger than one
2640
+ * `run.not_found` for a `run_id` that is not one of this method's runs in the caller's
2641
+ * organization, with nothing written; `413` `request.too_large` for an example larger than one
2512
2642
  * stored item holds; `422` for a name that is empty, blank or over 200 characters, or inputs
2513
2643
  * that are not a JSON object; and the codes every example route answers.
2514
2644
  */
@@ -2530,12 +2660,12 @@ export class PipelexApiClient {
2530
2660
  * may be short while `nextCursor` is set, because the platform reads examples whole and stops
2531
2661
  * a read at 1 MB; `iterateMethodExamples` follows the cursor for a caller that wants them all.
2532
2662
  *
2533
- * Throws `ApiResponseError`: `400` `malformed_request` for a cursor the listing did not issue
2534
- * for this method, `422` for a `limit` outside 1 to 100, and the codes every example route
2535
- * answers. A page whose `items` is not an array, whose `next_cursor` is neither a string nor
2536
- * `null`, or one of whose items is not an example `readMethodExample` can read, is an answer
2537
- * the SDK cannot read, thrown as an `ApiResponseError` too. An example whose inputs hold an
2538
- * integer past the safe range is read like any other, that integer as a `bigint`.
2663
+ * Throws `ApiResponseError`: `422` `request.invalid` for a cursor the listing did not issue for
2664
+ * this method or a `limit` outside 1 to 100, and the codes every example route answers. A page
2665
+ * whose `items` is not an array, whose `next_cursor` is neither a string nor `null`, or one of
2666
+ * whose items is not an example `readMethodExample` can read, is an answer the SDK cannot read,
2667
+ * thrown as an `ApiResponseError` too. An example whose inputs hold an integer past the safe
2668
+ * range is read like any other, that integer as a `bigint`.
2539
2669
  */
2540
2670
  async listMethodExamples(methodId, query = {}) {
2541
2671
  const search = productQuery({ limit: query.limit, cursor: query.cursor });
@@ -2602,7 +2732,7 @@ export class PipelexApiClient {
2602
2732
  *
2603
2733
  * Throws `RequestArgumentError` before any request for an example id that is empty, `.` or
2604
2734
  * `..`, or when neither `name` nor `inputs` is given. Throws `ApiResponseError`: `413`
2605
- * `payload_too_large` for inputs larger than one stored item holds, `422` for a name that is
2735
+ * `request.too_large` for inputs larger than one stored item holds, `422` for a name that is
2606
2736
  * empty, blank or over 200 characters, or inputs that are not a JSON object, a `503` that wrote
2607
2737
  * nothing, safe to retry, when concurrent edits kept landing under the write, and the codes
2608
2738
  * every example route answers.
@@ -2616,7 +2746,7 @@ export class PipelexApiClient {
2616
2746
  if (inputs != null)
2617
2747
  body.inputs = inputs;
2618
2748
  if (Object.keys(body).length === 0) {
2619
- throw new RequestArgumentError("updateMethodExample() needs a new name, new inputs, or both; got neither.");
2749
+ throw new RequestArgumentError("updateMethodExample() needs a new name, new inputs, or both; got neither.", { problem: argumentInvalidProblem("name", "nothing_to_update") });
2620
2750
  }
2621
2751
  return this.requestMethodExample("PATCH", endpoint, body);
2622
2752
  }
@@ -2632,13 +2762,13 @@ export class PipelexApiClient {
2632
2762
  * since and nothing was written. A stale run is the expected end of a run that outlasted an
2633
2763
  * edit, so it is answered, never thrown. Both arms carry the `example` as it stands.
2634
2764
  *
2635
- * Throws `RequestArgumentError` before any request, checking in this order: an example id that
2636
- * is empty, `.` or `..`, or holds a lone surrogate, a `run_id` that is not a non-empty string,
2637
- * and an `input_revision` that is not a positive integer. Throws `ApiResponseError` when no
2638
- * verdict was produced: `404` `not_found` for an example deleted since, `404` `run_not_found`
2639
- * for a run that is not one of this method's, with nothing written, and the codes every example
2640
- * route answers. An answer whose `outcome` is neither arm's, or whose `example` is not an
2641
- * example `readMethodExample` can read, is an answer the SDK cannot read, thrown as an
2765
+ * Throws `RequestArgumentError` before any request, checking in this order: an example id that is
2766
+ * empty, `.` or `..`, or holds a lone surrogate, a `run_id` that is not a non-empty string, and
2767
+ * an `input_revision` that is not a positive integer. Throws `ApiResponseError` when no verdict
2768
+ * was produced: `404` `pipelex.catalog.not_found` for an example deleted since, `404`
2769
+ * `run.not_found` for a run that is not one of this method's, with nothing written, and the codes
2770
+ * every example route answers. An answer whose `outcome` is neither arm's, or whose `example` is
2771
+ * not an example `readMethodExample` can read, is an answer the SDK cannot read, thrown as an
2642
2772
  * `ApiResponseError` too, read as `publishMethod` reads its verdict.
2643
2773
  */
2644
2774
  async recordMethodExampleLatestRun(methodId, exampleId, input) {
@@ -2647,7 +2777,10 @@ export class PipelexApiClient {
2647
2777
  const { run_id: runId, input_revision: revision } = input ?? {};
2648
2778
  const run = requireNonEmptyId(runId, call, RECORD_RUN_ID_ARGUMENT);
2649
2779
  if (typeof revision !== "number" || !Number.isSafeInteger(revision) || revision < 1) {
2650
- throw argumentRefusal(call, "an input_revision, the example's revision whose inputs the run used, a positive integer", describeNumberArgument(revision));
2780
+ throw argumentRefusal(call, {
2781
+ name: "input_revision",
2782
+ what: "an input_revision, the example's revision whose inputs the run used, a positive integer",
2783
+ }, describeNumberArgument(revision), "not_a_positive_integer");
2651
2784
  }
2652
2785
  const res = await this.requestProductAnswer("PUT", endpoint, {
2653
2786
  run_id: run,
@@ -2685,8 +2818,8 @@ export class PipelexApiClient {
2685
2818
  * the body is closed, and `null` reads as not given. Throws `RequestArgumentError` before any
2686
2819
  * request when `name` or `inputs` is not given, for an `example_id` that is not a non-empty
2687
2820
  * 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
2821
+ * given. Throws `ApiResponseError`: `403` `auth.forbidden` for a role that may not change
2822
+ * methods, such as a runner; `413` `request.too_large` for an example larger than one stored item
2690
2823
  * holds; `422` for a name that is empty, blank or over 200 characters, or inputs that are not a
2691
2824
  * JSON object; and the codes every example route answers. An answer whose `outcome` is neither
2692
2825
  * arm's, or whose `example` is not an example `readMethodExample` can read, is an answer the SDK
@@ -2703,13 +2836,15 @@ export class PipelexApiClient {
2703
2836
  }
2704
2837
  if (revision != null) {
2705
2838
  if (typeof revision !== "number" || !Number.isSafeInteger(revision) || revision < 1) {
2706
- throw argumentRefusal(call, ASSISTANT_REVISION_ARGUMENT, describeNumberArgument(revision));
2839
+ throw argumentRefusal(call, ASSISTANT_REVISION_ARGUMENT, describeNumberArgument(revision), "not_a_positive_integer");
2707
2840
  }
2708
2841
  body.input_revision = revision;
2709
2842
  }
2710
2843
  if ((body.example_id === undefined) !== (body.input_revision === undefined)) {
2711
2844
  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"}.`);
2845
+ `it was read at, or neither; got only ${body.example_id === undefined ? "input_revision" : "example_id"}.`, {
2846
+ problem: argumentInvalidProblem(body.example_id === undefined ? "example_id" : "input_revision", "missing"),
2847
+ });
2713
2848
  }
2714
2849
  const res = await this.requestProductAnswer("POST", endpoint, body);
2715
2850
  const { answer, outcome, subject } = this.readVerdict("POST", endpoint, res, {
@@ -2725,10 +2860,10 @@ export class PipelexApiClient {
2725
2860
  * Delete an example — `DELETE /v1/methods/{id}/examples/{example_id}`. The platform answers
2726
2861
  * `204`. Deleting the example leaves its latest run in the method's history.
2727
2862
  *
2728
- * Throws `RequestArgumentError` for an example id that is empty, `.` or `..`, before any
2729
- * request: `..` would address the method, and its erasure would answer the delete. Throws
2730
- * `ApiResponseError`: `404` `not_found` when there is no such example, one already deleted
2731
- * included, and the codes every example route answers.
2863
+ * Throws `RequestArgumentError` for an example id that is empty, `.` or `..`, before any request:
2864
+ * `..` would address the method, and its erasure would answer the delete. Throws
2865
+ * `ApiResponseError`: `404` `pipelex.catalog.not_found` when there is no such example, one
2866
+ * already deleted included, and the codes every example route answers.
2732
2867
  */
2733
2868
  async deleteMethodExample(methodId, exampleId) {
2734
2869
  await this.requestProduct("DELETE", methodExamplePath(methodId, exampleId, "deleteMethodExample"));
@@ -2771,17 +2906,17 @@ export class PipelexApiClient {
2771
2906
  *
2772
2907
  * Throws `RequestArgumentError` for an organization id that is empty, `.` or `..`, before any
2773
2908
  * request. Throws `ApiResponseError`, refusing every change of the request when it refuses one:
2774
- * `403` `forbidden` when `orgId` is not the active organization, `403` `role_not_permitted` for
2775
- * a caller who is not its admin, `404` for a missing organization, `409` `conflict` for a
2776
- * personal workspace, which has no branding, and `422` `validation_failed` for a colour that is
2777
- * not `#rrggbb` or a key the body does not know. A logo's refusal names its slot in the error's
2778
- * `errors[]`, each entry's `field` being `square_logo` or `wide_logo` and its `detail` the
2779
- * sentence to show under that slot: `400` `invalid_storage_uri` for a URI that is not a storage
2780
- * URI, `403` `forbidden` for another organization's file, and `422` `validation_failed` with a
2781
- * `LogoRefusalCode` per broken rule for a file the platform refused. A failure of the platform's
2782
- * storage while reading or copying a logo is a `500` `storage_backend_failed`, retryable, with
2783
- * nothing written. A `503` `service_unavailable` is a failure of its database, after which the
2784
- * write may have landed or not: the same input sent again is safe.
2909
+ * `403` `pipelex.org.mismatch` when `orgId` is not the active organization, `403`
2910
+ * `auth.forbidden` for a caller who is not its admin, `404` `pipelex.org.not_found` for a missing
2911
+ * organization, `409` `pipelex.org.not_manageable` for a personal workspace, which has no
2912
+ * branding, and `422` `request.invalid` for a colour that is not `#rrggbb` or a key the body does
2913
+ * not know. A logo's refusal names its slot in its problem's `errors`, each item's
2914
+ * `location.field` being `square_logo` or `wide_logo` and its `detail` the sentence to show under
2915
+ * that slot: `422` `pipelex.storage.uri_invalid` for a URI that is not a storage URI, `403`
2916
+ * `pipelex.org.mismatch` for another organization's file, and `422` `request.invalid` with an
2917
+ * item per broken rule, its code one `LogoRefusalCode` names, for a file the platform refused. A
2918
+ * `503` `runtime.unavailable` is a failure of the platform's storage or its database, after which
2919
+ * the write may have landed or not: the same input sent again is safe.
2785
2920
  */
2786
2921
  async updateOrganizationBranding(orgId, input) {
2787
2922
  const segment = idPathSegment(orgId, "updateOrganizationBranding", ORG_ID_ARGUMENT);
@@ -2805,7 +2940,7 @@ export class PipelexApiClient {
2805
2940
  }
2806
2941
  /**
2807
2942
  * Switch the existing subscription's plan — `POST /v1/billing/change-plan`.
2808
- * A 409 `conflict` (`ApiResponseError.code`) means there is no subscription
2943
+ * A 409 `pipelex.billing.subscription_conflict` (`ApiResponseError.code`) means there is no subscription
2809
2944
  * to change — start one via `createCheckout` first.
2810
2945
  */
2811
2946
  async changePlan(input) {
@@ -2813,7 +2948,7 @@ export class PipelexApiClient {
2813
2948
  }
2814
2949
  /**
2815
2950
  * A Stripe billing-portal session URL — `GET /v1/billing/portal`. A 409
2816
- * `conflict` (`ApiResponseError.code`) means there is no subscription yet.
2951
+ * `pipelex.billing.subscription_conflict` (`ApiResponseError.code`) means there is no subscription yet.
2817
2952
  */
2818
2953
  async getBillingPortal() {
2819
2954
  return this.requestProduct("GET", "billing/portal");
@@ -2824,7 +2959,7 @@ export class PipelexApiClient {
2824
2959
  }
2825
2960
  /**
2826
2961
  * Mint a Pipelex API key — `POST /v1/pipelex-api-keys`. The plaintext
2827
- * `api_key` is returned ONCE. A 409 `pipelex_api_key_limit_reached`
2962
+ * `api_key` is returned ONCE. A 409 `pipelex.billing.plan_limit`
2828
2963
  * (`ApiResponseError.code`) means the per-account key limit is hit.
2829
2964
  */
2830
2965
  async createPipelexApiKey(input) {
@@ -2897,8 +3032,9 @@ export class PipelexApiClient {
2897
3032
  /**
2898
3033
  * Upload a base64 file — `POST /v1/upload`, answering the file's `uri`, the `filename` as sent
2899
3034
  * and the `file_id` of its record. An answer with no non-empty string `uri` names no
2900
- * stored file, so it is an answer the SDK cannot read: an `ApiResponseError`, `runtime` and
2901
- * not retryable, which `uploadFile` wraps as an `UploadTransportError` with code `unexpected`.
3035
+ * stored file, so it is an answer the SDK cannot read: an `ApiResponseError` whose problem is
3036
+ * `client.response_unreadable`, which `uploadFile` wraps as an `UploadTransportError` with the
3037
+ * reason `unexpected`.
2902
3038
  */
2903
3039
  async upload(input) {
2904
3040
  const res = await this.requestProductAnswer("POST", "upload", input);
@@ -2926,9 +3062,9 @@ export class PipelexApiClient {
2926
3062
  * target is short-lived and a bearer capability, so keep it out of logs. The route
2927
3063
  * never replays a grant: ask again for a new one rather than retrying; with a
2928
3064
  * `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`.
3065
+ * is a `413` `ApiResponseError` (`code` `request.too_large`), a malformed `sha256` and
3066
+ * a `size` other than the one the organization holds those bytes at are a `422`
3067
+ * (`request.invalid`), and a deployment without the route answers a `404`. See `docs/input-preparation.md`.
2932
3068
  */
2933
3069
  async requestUploadGrant(input, options = {}) {
2934
3070
  return this.requestProductObject("POST", "upload/grant", input, options);
@@ -2998,7 +3134,7 @@ export class PipelexApiClient {
2998
3134
  *
2999
3135
  * Throws `ApiResponseError`: `400` for a date bound that is not an instant with an offset, a
3000
3136
  * range whose start is after its end, or a cursor of another list or other filters; `404`
3001
- * `run_not_found` for a run that is not the organization's; `422` for a `limit` outside 1 to
3137
+ * `run.not_found` for a run that is not the organization's; `422` for a `limit` outside 1 to
3002
3138
  * 200, an unknown `kind`, more than 100 values of `format` or `mediaType`, or an id that cannot
3003
3139
  * be one. A page whose `items` is not an array or whose `next_cursor` is neither a string nor
3004
3140
  * `null` is an `ApiResponseError` of an answer the SDK cannot read. See `docs/files.md`.
@@ -3052,10 +3188,11 @@ export class PipelexApiClient {
3052
3188
  * `stored` when its bytes are there.
3053
3189
  *
3054
3190
  * 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`.
3191
+ * surrogate, before any request. Throws `ApiResponseError`: `404` `pipelex.storage.not_found` for
3192
+ * a file that is not the organization's; `422` for a `usesLimit` outside 1 to 200 or an id that
3193
+ * cannot be one. An answer that is not an object, or whose `uses` is not a page of `items` and
3194
+ * `next_cursor`, is an answer the SDK cannot read, thrown as an `ApiResponseError` too. See
3195
+ * `docs/files.md`.
3059
3196
  */
3060
3197
  async getFile(fileId, query = {}) {
3061
3198
  const search = productQuery({ uses_limit: query.usesLimit, uses_cursor: query.usesCursor });
@@ -3082,10 +3219,10 @@ export class PipelexApiClient {
3082
3219
  * kept records.
3083
3220
  *
3084
3221
  * 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.
3222
+ * Throws `ApiResponseError`: `pipelex.storage.uri_invalid` for a malformed address, `403`
3223
+ * `pipelex.org.mismatch` for another organization's, `404` `run.not_found` for a run that is not
3224
+ * the organization's, and `422` for an address over 512 characters. An answer whose `items` is
3225
+ * not an array is an answer the SDK cannot read, thrown as an `ApiResponseError` too.
3089
3226
  */
3090
3227
  async getFilesByUri(uri, options = {}) {
3091
3228
  const checkedUri = requireNonEmptyId(uri, "getFilesByUri", STORAGE_URI_ARGUMENT);
@@ -3114,16 +3251,16 @@ export class PipelexApiClient {
3114
3251
  *
3115
3252
  * Sends `name` alone: the body is closed. Throws `RequestArgumentError` for a file id that is
3116
3253
  * 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
3254
+ * any request. Throws `ApiResponseError`: `403` `auth.forbidden` past the rule above; `404`
3255
+ * `pipelex.storage.not_found` for a file that is not the organization's; `422` for a name over
3256
+ * 512 characters or with nothing left once cleaned. An answer that is not an object is an answer the SDK cannot
3120
3257
  * read, thrown as an `ApiResponseError` too.
3121
3258
  */
3122
3259
  async renameFile(fileId, input) {
3123
3260
  const segment = idPathSegment(fileId, "renameFile", FILE_ID_ARGUMENT);
3124
3261
  const { name } = input ?? {};
3125
3262
  if (typeof name !== "string") {
3126
- throw argumentRefusal("renameFile", RENAME_NAME_ARGUMENT, describeNumberArgument(name));
3263
+ throw argumentRefusal("renameFile", RENAME_NAME_ARGUMENT, describeNumberArgument(name), "not_a_string");
3127
3264
  }
3128
3265
  return this.requestProductObject("PATCH", `files/${segment}`, { name });
3129
3266
  }
@@ -3150,14 +3287,14 @@ export class PipelexApiClient {
3150
3287
  * Sends each file as `{ file_id }` or `{ uri }` and nothing else: the body is closed. Throws
3151
3288
  * `RequestArgumentError` before any request when `files` is not a list, or one of its items does
3152
3289
  * 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
3290
+ * `422` `request.invalid` for more than 100 files, or an id or address longer than one can be;
3291
+ * `500` `pipelex.storage.not_configured` when the platform cannot sign at all. An answer that is not one item per requested file, each an
3155
3292
  * object with its `url` or its `error`, is an answer the SDK cannot read, thrown as an
3156
3293
  * `ApiResponseError` whose body hides every link.
3157
3294
  */
3158
3295
  async linkFiles(files) {
3159
3296
  if (!Array.isArray(files)) {
3160
- throw argumentRefusal("linkFiles", "a list of files to link", describeNumberArgument(files));
3297
+ throw argumentRefusal("linkFiles", { name: "files", what: "a list of files to link" }, describeNumberArgument(files), "not_a_list");
3161
3298
  }
3162
3299
  const targets = files.map((target) => fileLinkTarget(target));
3163
3300
  const res = withLinks(await this.requestProductAnswer("POST", "files/links", { files: targets }));
@@ -3193,18 +3330,18 @@ export class PipelexApiClient {
3193
3330
  * list of non-empty strings, when `run_id` is not a non-empty string, and when `name` is not a
3194
3331
  * string. Throws `ApiResponseError`:
3195
3332
  *
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.
3333
+ * - `422` `pipelex.files.archive_too_large` past a cap, at most 500 files or 2,000,000,000
3334
+ * bytes, whose problem's `limit` member names it (`FileArchiveLimit`), so the person can be told
3335
+ * how far over the selection is;
3336
+ * - `422` `request.invalid` for more than 1,000 ids listed, or an id longer than one can be;
3337
+ * - `404` `pipelex.storage.not_found` for a file that is not the organization's, the `detail`
3338
+ * naming up to five of them, or a run whose files hold nothing stored to zip; `404`
3339
+ * `run.not_found` for a run that is not the organization's;
3340
+ * - `409` `run.not_finished`, `retry` `yes`, for a run whose files are not all recorded yet,
3341
+ * which the same request clears a few seconds after the run ends, and a `409` with `retry` `no`
3342
+ * for a selected file whose upload has not finished;
3343
+ * - `503` `runtime.unavailable`, `retry` `yes`, when the archive could not be queued: the same
3344
+ * request sent again asks for a new archive.
3208
3345
  *
3209
3346
  * An answer that is not an archive, an object with a string `archive_id` and a known `state`, is
3210
3347
  * an answer the SDK cannot read, thrown as an `ApiResponseError` too.
@@ -3212,7 +3349,7 @@ export class PipelexApiClient {
3212
3349
  async requestFileArchive(request) {
3213
3350
  const call = "requestFileArchive";
3214
3351
  if (!isPlainObject(request)) {
3215
- throw argumentRefusal(call, "an archive request, an object naming `file_ids` or `run_id`", describeNumberArgument(request));
3352
+ throw argumentRefusal(call, { name: "request", what: "an archive request, an object naming `file_ids` or `run_id`" }, describeNumberArgument(request), "not_an_object");
3216
3353
  }
3217
3354
  const raw = request;
3218
3355
  const fileIds = raw.file_ids ?? undefined;
@@ -3220,12 +3357,12 @@ export class PipelexApiClient {
3220
3357
  const name = raw.name ?? undefined;
3221
3358
  if ((fileIds === undefined) === (runId === undefined)) {
3222
3359
  throw new RequestArgumentError(`${call}() names the files to zip by exactly one of file_ids and run_id; got ` +
3223
- `${fileIds === undefined ? "neither" : "both"}.`);
3360
+ `${fileIds === undefined ? "neither" : "both"}.`, { problem: argumentInvalidProblem("file_ids", "not_exactly_one") });
3224
3361
  }
3225
3362
  const body = {};
3226
3363
  if (fileIds !== undefined) {
3227
3364
  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));
3365
+ throw argumentRefusal(call, { name: "file_ids", what: "file_ids, a non-empty list of the file ids to zip" }, Array.isArray(fileIds) ? "an empty list" : describeNumberArgument(fileIds), Array.isArray(fileIds) ? "empty" : "not_a_list");
3229
3366
  }
3230
3367
  body.file_ids = fileIds.map((id) => requireNonEmptyId(id, call, ARCHIVE_FILE_ID_ARGUMENT));
3231
3368
  }
@@ -3234,7 +3371,7 @@ export class PipelexApiClient {
3234
3371
  }
3235
3372
  if (name !== undefined) {
3236
3373
  if (typeof name !== "string") {
3237
- throw argumentRefusal(call, ARCHIVE_NAME_ARGUMENT, describeNumberArgument(name));
3374
+ throw argumentRefusal(call, ARCHIVE_NAME_ARGUMENT, describeNumberArgument(name), "not_a_string");
3238
3375
  }
3239
3376
  body.name = name;
3240
3377
  }
@@ -3254,10 +3391,10 @@ export class PipelexApiClient {
3254
3391
  * still `queued` or `building` an hour after it was asked for reads as `failed`.
3255
3392
  *
3256
3393
  * 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.
3394
+ * surrogate, before any request. Throws `ApiResponseError`: `404` `pipelex.storage.not_found` for
3395
+ * an archive that is not the organization's, never existed, or has expired, a day after
3396
+ * `created_at`; `500` when the link cannot be signed. An answer that is not an archive is an
3397
+ * answer the SDK cannot read, thrown as an `ApiResponseError` whose body hides the link.
3261
3398
  */
3262
3399
  async getFileArchive(archiveId) {
3263
3400
  const segment = idPathSegment(archiveId, "getFileArchive", ARCHIVE_ID_ARGUMENT);
@@ -3303,14 +3440,15 @@ export class PipelexApiClient {
3303
3440
  * `expected_updated_at`, and nothing else: the body is closed, and a `null` token reads as not
3304
3441
  * given. Throws `RequestArgumentError` before any request for a `file_id` or a `path` that is not
3305
3442
  * 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.
3443
+ * `ApiResponseError`: `403` `auth.forbidden` for a role that may not change methods; `404`
3444
+ * for an unknown method or a file that is not the organization's; `409` `pipelex.catalog.being_deleted`,
3445
+ * `409` `pipelex.catalog.update_conflict` for a stale `expected_updated_at`, and `409` `conflict` for a
3446
+ * file whose upload has not finished or whose stored bytes do not match its record; and `422`
3447
+ * `method.package_invalid` for a path the draft cannot hold, such as `.env` or `../logo.png`,
3448
+ * naming the path in its `params`, and past the deployment's limits, its `params` carrying
3449
+ * `reason` `too_large` and the integer `limit`. An answer whose `blob` is neither arm's, or
3450
+ * whose `file` or `method` is not an object, is an answer the SDK cannot read, thrown as an
3451
+ * `ApiResponseError` too.
3314
3452
  */
3315
3453
  async copyFileIntoDraft(methodId, input) {
3316
3454
  const call = "copyFileIntoDraft";
@@ -3322,7 +3460,7 @@ export class PipelexApiClient {
3322
3460
  };
3323
3461
  if (token != null) {
3324
3462
  if (typeof token !== "string") {
3325
- throw argumentRefusal(call, DRAFT_TOKEN_ARGUMENT, describeNumberArgument(token));
3463
+ throw argumentRefusal(call, DRAFT_TOKEN_ARGUMENT, describeNumberArgument(token), "not_a_string");
3326
3464
  }
3327
3465
  body.expected_updated_at = token;
3328
3466
  }
@@ -3356,7 +3494,7 @@ export class PipelexApiClient {
3356
3494
  * `iterateRuns` does the latter for you.
3357
3495
  *
3358
3496
  * Each item is a `RunHistoryItem` — the id, status, timestamps, pipe and,
3359
- * for a failed run, its error report. The run's organization, creator,
3497
+ * for a failed run, its own problem. The run's organization, creator,
3360
3498
  * method and workflow id are not on the list; `getRunDetail` returns them.
3361
3499
  *
3362
3500
  * `createdFrom` / `createdTo` are applied server-side as index key
@@ -3389,7 +3527,7 @@ export class PipelexApiClient {
3389
3527
  cursor: query.cursor,
3390
3528
  });
3391
3529
  const page = await this.requestPage(`runs${search}`);
3392
- return { items: page.items.map(withCheckedReport), nextCursor: page.nextCursor };
3530
+ return { items: page.items.map(withCheckedProblem), nextCursor: page.nextCursor };
3393
3531
  }
3394
3532
  /**
3395
3533
  * Every run of a method, streamed — follows the cursor for you.
@@ -3427,8 +3565,8 @@ export class PipelexApiClient {
3427
3565
  * The whole record for one run — `GET /v1/runs/{id}`.
3428
3566
  *
3429
3567
  * The ONLY call that returns what the run actually executed and its
3430
- * `inputs`. Kept off the status read, which pollers hit every few seconds. A failed run's stored report, `error`, is checked field by
3431
- * field (see `RunErrorReport`).
3568
+ * `inputs`. Kept off the status read, which pollers hit every few seconds. A failed run's problem, `error`, is kept when it is a
3569
+ * problem document and read as `null` otherwise.
3432
3570
  *
3433
3571
  * Read with `parseExactJson`, so an integer past `Number.MAX_SAFE_INTEGER` in the inputs is a
3434
3572
  * `bigint` holding its exact digits, and the inputs passed back to `start` or saved as an
@@ -3437,7 +3575,7 @@ export class PipelexApiClient {
3437
3575
  async getRunDetail(runId) {
3438
3576
  const endpoint = `runs/${idPathSegment(runId, "getRunDetail", RUN_ID_ARGUMENT)}`;
3439
3577
  const res = await this.requestProductAnswer("GET", endpoint, undefined);
3440
- return withCheckedReport(this.readObjectAnswer("GET", endpoint, res, {}, parseExactJson));
3578
+ return withCheckedProblem(this.readObjectAnswer("GET", endpoint, res, {}, parseExactJson));
3441
3579
  }
3442
3580
  /**
3443
3581
  * One page of a run's files, sorted by where they sit in the run — `GET /v1/runs/{id}/files`.
@@ -3507,7 +3645,7 @@ export class PipelexApiClient {
3507
3645
  Object.keys(api).length === 0 &&
3508
3646
  Object.keys(hosted).length === 0 &&
3509
3647
  Object.keys(extensions).length === 0) {
3510
- throw new RequestArgumentError(`Either pipe_code, the method's tree (directory, contents, tree or files), a method_ref, a hosted method_id or a server-specific extension arg (extra) must be provided to ${call}().`);
3648
+ throw new RequestArgumentError(`Either pipe_code, the method's tree (directory, contents, tree or files), a method_ref, a hosted method_id or a server-specific extension arg (extra) must be provided to ${call}().`, { problem: argumentInvalidProblem("pipe_code", "nothing_to_run") });
3511
3649
  }
3512
3650
  assertMethodRefPairsWithNothing(options, treeForms);
3513
3651
  assertLinkageMethodIdIsBare(options, treeForms);
@@ -3667,7 +3805,7 @@ function buildExtensions(extra) {
3667
3805
  const snapshot = { ...extra };
3668
3806
  const reserved = Object.keys(snapshot).filter((key) => RESERVED_EXTRA_KEYS.has(key));
3669
3807
  if (reserved.length > 0) {
3670
- throw new RequestArgumentError(`extra carries reserved request args [${reserved.sort().join(", ")}] — pass them as named options instead.`);
3808
+ throw new RequestArgumentError(`extra carries reserved request args [${reserved.sort().join(", ")}] — pass them as named options instead.`, { problem: argumentInvalidProblem("extra", "reserved_argument") });
3671
3809
  }
3672
3810
  const result = {};
3673
3811
  for (const [key, value] of Object.entries(snapshot)) {
@@ -3749,7 +3887,7 @@ function carriesExtraBundle(extensions) {
3749
3887
  }
3750
3888
  /** The refusal of a `directory` that is not a path, `call` naming the caller's method. */
3751
3889
  function directoryRefusal(call) {
3752
- return new RequestArgumentError(`${call}() got a directory that is not a path: give the method's directory as a string.`);
3890
+ return new RequestArgumentError(`${call}() got a directory that is not a path: give the method's directory as a string.`, { problem: argumentInvalidProblem("directory", "not_a_string") });
3753
3891
  }
3754
3892
  /**
3755
3893
  * `value` as a listed tree, or the refusal of one that is not, `call` naming the caller's method:
@@ -3760,7 +3898,7 @@ function checkedTreeSource(call, value) {
3760
3898
  const fault = treeShapeFault(value);
3761
3899
  if (fault !== undefined) {
3762
3900
  throw new RequestArgumentError(`${call}() got a tree that is not a listed method tree — its files, its digest and a ` +
3763
- `readFile — such as the MethodTree listMethodTree returns; got ${fault}.`);
3901
+ `readFile — such as the MethodTree listMethodTree returns; got ${fault}.`, { problem: argumentInvalidProblem("tree", "not_a_tree") });
3764
3902
  }
3765
3903
  return value;
3766
3904
  }
@@ -3788,7 +3926,7 @@ async function inlineWithin(tree, call, sending, maxInlineBytes, read, signal) {
3788
3926
  function inlineLimitRefusal(call, sending, measured, weight, limit) {
3789
3927
  return new RequestArgumentError(`${call}() cannot send the method's tree: the API keeps no blob store, so the tree travels ` +
3790
3928
  `inline, and ${measured} ${weight} bytes, past the ${limit} bytes it takes inline (its ` +
3791
- `max_inline_bytes), so ${sending.unsent}.`);
3929
+ `max_inline_bytes), so ${sending.unsent}.`, { problem: argumentInvalidProblem("files", "past_inline_limit") });
3792
3930
  }
3793
3931
  /**
3794
3932
  * Copy the Pipelex API's own run arguments onto the wire body. Named options,
@@ -3819,9 +3957,9 @@ function buildHostedRunExtensions(options) {
3819
3957
  * The forms of the method's tree a protocol route (a run, `validate`) was given, checked as the
3820
3958
  * MTHDS standard checks them: its `assertOneMethodSource` on `files` and `tree` (both at once, or
3821
3959
  * either holding no file, refused whatever else the request carries), its refusal rethrown as a
3822
- * `RequestArgumentError` with the same message and the standard's error as `cause`, so it carries
3823
- * a verdict like every other argument the client refuses; and `contents` holding no file refused
3824
- * the same way. A `null` member and an empty `directory` are absent.
3960
+ * `RequestArgumentError` with the same message, the standard's error as `cause` and its problem
3961
+ * (`client.argument_invalid`, naming the argument), like every other argument the client refuses;
3962
+ * and `contents` holding no file refused the same way. A `null` member and an empty `directory` are absent.
3825
3963
  */
3826
3964
  function protocolTreeForms(call, options) {
3827
3965
  // A `tree` the standard's check would read as a tree is checked as one first, so a value that
@@ -3832,8 +3970,8 @@ function protocolTreeForms(call, options) {
3832
3970
  assertOneMethodSource({ files: options.files ?? null, tree: options.tree ?? null });
3833
3971
  }
3834
3972
  catch (err) {
3835
- if (err instanceof PipelineRequestError) {
3836
- throw new RequestArgumentError(err.message, { cause: err });
3973
+ if (err instanceof ProblemError) {
3974
+ throw new RequestArgumentError(err.message, { cause: err, problem: err.problem });
3837
3975
  }
3838
3976
  throw err;
3839
3977
  }
@@ -3889,11 +4027,11 @@ function assertMethodRefPairsWithNothing(options, treeForms) {
3889
4027
  return;
3890
4028
  if (treeForms.length > 0) {
3891
4029
  throw new RequestArgumentError(`method_ref and the method's tree (${treeForms.join(", ")}) are mutually exclusive: a ` +
3892
- "published method's tree is its package's own. Send one or the other.");
4030
+ "published method's tree is its package's own. Send one or the other.", { problem: argumentInvalidProblem("method_ref", "mutually_exclusive") });
3893
4031
  }
3894
4032
  if (nonEmptyString(options.method_id) !== undefined) {
3895
4033
  throw new RequestArgumentError("method_ref and method_id are mutually exclusive: an address run carries its own provenance " +
3896
- "and takes no run-history linkage id. Send exactly one method selector.");
4034
+ "and takes no run-history linkage id. Send exactly one method selector.", { problem: argumentInvalidProblem("method_ref", "mutually_exclusive") });
3897
4035
  }
3898
4036
  }
3899
4037
  /**
@@ -3915,7 +4053,7 @@ function assertLinkageMethodIdIsBare(options, treeForms) {
3915
4053
  throw new RequestArgumentError(`method_id "${methodId}" beside the method's tree is run-history linkage and must be a bare ` +
3916
4054
  "catalog id: the tree is what runs, so a version suffix would claim a version that did " +
3917
4055
  "not. Send the bare id (parseMethodSelector(...).method_id), or drop the tree to run the " +
3918
- "version the selector names.");
4056
+ "version the selector names.", { problem: argumentInvalidProblem("method_id", "version_suffix_beside_inline_source") });
3919
4057
  }
3920
4058
  function nonEmptyString(value) {
3921
4059
  return value != null && value.length > 0 ? value : undefined;
@@ -3950,24 +4088,27 @@ function idempotencyKeyOf(options, request = "start the run") {
3950
4088
  const fault = idempotencyKeyFault(key);
3951
4089
  if (fault !== undefined) {
3952
4090
  throw new RequestArgumentError(`The idempotency key must be 1 to ${MAX_IDEMPOTENCY_KEY_LENGTH} visible ASCII characters ` +
3953
- `(\`!\` to \`~\`), and this one ${fault}. Leave it out to ${request} without one.`);
4091
+ `(\`!\` to \`~\`), and this one ${fault.words}. Leave it out to ${request} without one.`, { problem: argumentInvalidProblem("idempotency_key", fault.reason) });
3954
4092
  }
3955
4093
  return key;
3956
4094
  }
3957
4095
  /**
3958
- * What is wrong with an idempotency key, or `undefined` when it is a valid one. The characters are
3959
- * checked before the length, which then counts ASCII characters alone, so the two SDKs, which
3960
- * count a character outside ASCII differently, word every refusal alike.
4096
+ * What is wrong with an idempotency key, or `undefined` when it is a valid one: in words for the
4097
+ * message, and as the problem's reason. The characters are checked before the length, which then
4098
+ * counts ASCII characters alone, so the two SDKs, which count a character outside ASCII
4099
+ * differently, word every refusal alike.
3961
4100
  */
3962
4101
  function idempotencyKeyFault(key) {
3963
4102
  if (typeof key !== "string")
3964
- return "is not a string";
4103
+ return { words: "is not a string", reason: "not_a_string" };
3965
4104
  if (key.length === 0)
3966
- return "is empty";
3967
- if (!VISIBLE_ASCII.test(key))
3968
- return "holds a character outside visible ASCII";
3969
- if (key.length > MAX_IDEMPOTENCY_KEY_LENGTH)
3970
- return `is ${key.length} characters long`;
4105
+ return { words: "is empty", reason: "empty" };
4106
+ if (!VISIBLE_ASCII.test(key)) {
4107
+ return { words: "holds a character outside visible ASCII", reason: "not_visible_ascii" };
4108
+ }
4109
+ if (key.length > MAX_IDEMPOTENCY_KEY_LENGTH) {
4110
+ return { words: `is ${key.length} characters long`, reason: "too_long" };
4111
+ }
3971
4112
  return undefined;
3972
4113
  }
3973
4114
  function withValidateMarkdownRender(render) {
@@ -3984,28 +4125,32 @@ const GATEWAY_TIMEOUT_THRESHOLD_MS = 28_000;
3984
4125
  * Whether a request that failed `elapsedMs` after it was sent was cut off by the hosted gateway's
3985
4126
  * ~30-second limit on a request it waits on, rather than refused.
3986
4127
  *
3987
- * It is when, after at least ~28 seconds, the failure is a `503` or `504` answer, or the client's
3988
- * own time limit (`ApiUnreachableError` with the code `ABORT_TIMEOUT`). A fast `503` is the API
3989
- * saying the request was not handled, and any other unreachable host is never the gateway's
3990
- * cut-off, however long it took. The fetch API cannot say whether `ABORT_TIMEOUT` ran out before
4128
+ * It is when, after at least ~28 seconds, the failure is a `503` or `504` answer whose body is no
4129
+ * problem document, or the client's own time limit (`ApiUnreachableError` with the code
4130
+ * `ABORT_TIMEOUT`). A fast `503` is the API saying the request was not handled, a problem document
4131
+ * is the API's own answer however long it took, and any other unreachable host is never the
4132
+ * gateway's cut-off. The fetch API cannot say whether `ABORT_TIMEOUT` ran out before
3991
4133
  * the connection was made, so it is read as the gateway's; with Node's own dispatcher it never
3992
4134
  * does past the threshold, since undici gives up a connection that does not come after ten
3993
4135
  * seconds (`UND_ERR_CONNECT_TIMEOUT`), the TLS handshake included. The Python SDK's
3994
4136
  * `is_gateway_cut_off` reads the same failures, its `ABORT_TIMEOUT` being httpx's read or write
3995
4137
  * timeout.
3996
4138
  *
3997
- * The blocking `execute` turns such a failure into a `PipelineExecuteTimeoutError`. Whether a
3998
- * failed `start` or `execute` may have started a run needs no timing: the error says it as
3999
- * `runMayHaveStarted`, which reads every `503` but the platform's coded one as a run that may
4000
- * have started, however quickly it came back.
4139
+ * The blocking `execute` turns such a failure into a `PipelineExecuteTimeoutError`, for its
4140
+ * message. Whether a failed `start` or `execute` may have started a run needs no timing: the
4141
+ * error's problem says it, its `retry` being `outcome_unknown` for every answer from the edge
4142
+ * that is no problem document past a `5xx` but a `501`, however quickly it came back, while a
4143
+ * `503` problem document from the platform says as received whether it started one.
4001
4144
  */
4002
4145
  export function isGatewayCutOff(error, elapsedMs) {
4003
4146
  if (elapsedMs < GATEWAY_TIMEOUT_THRESHOLD_MS)
4004
4147
  return false;
4005
- if (error instanceof ApiResponseError)
4006
- return error.status === 503 || error.status === 504;
4148
+ if (error instanceof ApiResponseError) {
4149
+ return ((error.status === 503 || error.status === 504) &&
4150
+ readProblemBody(error.responseBody) === undefined);
4151
+ }
4007
4152
  if (error instanceof ApiUnreachableError)
4008
- return error.code === "ABORT_TIMEOUT";
4153
+ return error.networkCode === ABORT_TIMEOUT;
4009
4154
  return false;
4010
4155
  }
4011
4156
  /**
@@ -4016,12 +4161,12 @@ export function isGatewayCutOff(error, elapsedMs) {
4016
4161
  */
4017
4162
  const UNDECODABLE_BODY_CODE = /^(Z_|ERR__ERROR_|ZSTD_error_)/;
4018
4163
  /**
4019
- * Whether an `ApiUnreachableError` is an answer whose body could not be decoded, rather than no
4020
- * answer at all. The Python SDK's twin is the `ApiUnreachableError` whose cause is httpx's
4164
+ * Whether the network code of a failure met while a body was read says the body arrived and could
4165
+ * not be decoded, rather than that it never arrived. The Python SDK's twin is httpx's
4021
4166
  * `DecodingError`.
4022
4167
  */
4023
- function isUndecodableBody(error) {
4024
- return error.code !== undefined && UNDECODABLE_BODY_CODE.test(error.code);
4168
+ function isUndecodableBody(code) {
4169
+ return code !== undefined && UNDECODABLE_BODY_CODE.test(code);
4025
4170
  }
4026
4171
  /** A SHA-256 as the platform names a blob: 64 lowercase hexadecimal digits. */
4027
4172
  const SHA256_HEX = /^[0-9a-f]{64}$/;
@@ -4086,21 +4231,23 @@ function extractNetworkErrorCode(err) {
4086
4231
  return undefined;
4087
4232
  }
4088
4233
  /**
4089
- * Whether a 404 is an unmatched-route 404 (no platform deployed) rather than
4090
- * the platform's structured run-not-found 404. The platform wraps its 404s in
4091
- * a structured envelope with a stable `code`; a bare runner returns
4092
- * Starlette's default `{"detail": "Not Found"}` (no `code`).
4234
+ * Whether a 404 says that no route serves the path (no run store deployed), rather than that a
4235
+ * route answered it on purpose, such as a run or a method that does not exist. A route that
4236
+ * answers a 404 of its own names its failure by a `code`, whether in a problem document
4237
+ * (`run.not_found`, or a runner's `method.not_found` for a `method_ref` it cannot find) or in
4238
+ * an older native body the platform still answers (`run_not_found`, `not_found`), and one defect
4239
+ * elsewhere in the document does not make the route missing. So the 404 is a missing route when
4240
+ * its body is not a JSON object naming a non-empty string `code`, as a bare runner's framework
4241
+ * default `{"detail": "Not Found"}` and an empty or non-JSON body are not, or when that code is
4242
+ * `request.not_found`, the problem a runner answers for a path no route serves (pipelex-api 0.90
4243
+ * and later). The Python SDK's `_is_missing_route_404` reads the same rule.
4093
4244
  */
4094
4245
  function isMissingRoute404(body) {
4095
- if (!body)
4096
- return true;
4097
4246
  const reading = parseJsonBody(body);
4098
- if (!reading.ok)
4247
+ if (!reading.ok || !isPlainObject(reading.value))
4099
4248
  return true;
4100
- const parsed = reading.value;
4101
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
4102
- return true;
4103
- return !("code" in parsed);
4249
+ const code = reading.value["code"];
4250
+ return typeof code !== "string" || code === "" || code === "request.not_found";
4104
4251
  }
4105
4252
  /**
4106
4253
  * Whether a 2xx answer to `execute` is the completed run the route returns, as far as the SDK reads
@@ -4129,43 +4276,72 @@ function parseRetryAfter(headers) {
4129
4276
  const seconds = Number(raw);
4130
4277
  return Number.isFinite(seconds) && seconds >= 0 ? seconds : null;
4131
4278
  }
4132
- /** The response header the platform and the runner stamp the request's correlation id on. */
4133
- const REQUEST_ID_HEADER = "x-request-id";
4134
4279
  /**
4135
- * Build the failed arm from the results read's `409` problem document.
4280
+ * Read the results read's `409` answer. The platform answers it with a problem document:
4281
+ *
4282
+ * - `run.failed`: the run ended without success, and the answer is the failed arm, its `problem`
4283
+ * that document as received, its `status` the terminal status its `run_status` parameter names
4284
+ * (`FAILED` when it names none the SDK knows), its `message` the document's `detail`, and its
4285
+ * `error` the run's own problem, which the document nests as `run_error`, or `null` when it nests
4286
+ * none that is a problem document;
4287
+ * - `run.not_finished`: the run is still going, and the answer is the running arm, waiting the
4288
+ * document's `retry_after`, rounded up to whole seconds, else the `Retry-After` header's (or the
4289
+ * default) before the next read.
4136
4290
  *
4137
- * The platform's document carries `detail` (`Run finished with status <STATUS>: <message>`,
4138
- * or `...; no result available` when the run has no report) and two extension members:
4139
- * `run_status`, the run's terminal status — named so because a problem's own `status` is the
4140
- * HTTP status — and `error`, the run's stored error report or `null`. The status is read from
4141
- * `run_status`. A platform that predates the member sends only `detail`, whose leading
4142
- * `Run finished with status <STATUS>` the platform keeps for exactly this reader, so the status
4143
- * word is recovered from it then; a `409` that yields no known status either way (the one this
4144
- * route answers for a stored result it refuses to read) reads as `FAILED`, and its `detail`
4145
- * still says what happened. The report is checked field by field by `readRunErrorReport`: an
4146
- * object is taken as the report, each named field kept when it has its declared type, and
4147
- * anything that is not an object reads as no report.
4291
+ * Any other `409`, a problem document of another code or a body that is none, is no answer about
4292
+ * the run's end, so it reads as `undefined` and the caller throws its `ApiResponseError`.
4148
4293
  */
4149
- function runResultFailed(runId, body) {
4150
- const { serverMessage, document } = parseErrorBody(body);
4151
- const message = serverMessage ?? "Run finished without a result.";
4294
+ function runResultConflict(runId, res) {
4295
+ const problem = readProblemBody(res.body);
4296
+ if (problem === undefined)
4297
+ return undefined;
4298
+ if (problem.code === "run.not_finished") {
4299
+ // The run's own delay, in whole seconds as the running arm counts them, else the header's.
4300
+ const retryAfter = problem.retry_after !== undefined
4301
+ ? Math.ceil(problem.retry_after)
4302
+ : parseRetryAfter(res.headers);
4303
+ return {
4304
+ state: "running",
4305
+ pipeline_run_id: runId,
4306
+ retry_after_seconds: retryAfter ?? DEFAULT_DEGRADED_RETRY_SECONDS,
4307
+ };
4308
+ }
4309
+ if (problem.code !== "run.failed")
4310
+ return undefined;
4311
+ const nested = problem.run_error;
4152
4312
  return {
4153
4313
  state: "failed",
4154
4314
  pipeline_run_id: runId,
4155
- status: knownRunStatus(document?.run_status) ?? statusFromDetail(message) ?? "FAILED",
4156
- message,
4157
- error: readRunErrorReport(document?.error),
4315
+ status: knownRunStatus(problem.params?.run_status) ?? "FAILED",
4316
+ message: problem.detail,
4317
+ problem: problem.status === undefined ? { ...problem, status: res.status } : problem,
4318
+ error: isProblem(nested) ? nested : null,
4158
4319
  };
4159
4320
  }
4160
4321
  /**
4161
- * A run record with its stored report checked by `readRunErrorReport`. Only when the record
4162
- * carries the `error` key, so a server that does not serve it still leaves it absent; a record
4163
- * that is not an object is returned as it came.
4322
+ * A verdict with its problem items read member by member (`readProblemItems`): the `errors` of an
4323
+ * invalid arm and the `warnings` of a valid one, each only when the answer carries the key. An
4324
+ * answer that is not an object is returned as it came.
4325
+ */
4326
+ function withVerdictItems(answer) {
4327
+ if (!isPlainObject(answer))
4328
+ return answer;
4329
+ const read = { ...answer };
4330
+ if (answer.is_valid === false)
4331
+ read.errors = readProblemItems(answer.errors);
4332
+ else if (Object.hasOwn(answer, "warnings"))
4333
+ read.warnings = readProblemItems(answer.warnings);
4334
+ return read;
4335
+ }
4336
+ /**
4337
+ * A run record with its `error` kept only when it is a problem document, and read as `null`
4338
+ * otherwise. Only when the record carries the `error` key, so a server that does not serve it
4339
+ * still leaves it absent; a record that is not an object is returned as it came.
4164
4340
  */
4165
- function withCheckedReport(record) {
4341
+ function withCheckedProblem(record) {
4166
4342
  if (!isPlainObject(record) || !Object.hasOwn(record, "error"))
4167
4343
  return record;
4168
- return { ...record, error: readRunErrorReport(record.error) };
4344
+ return { ...record, error: isProblem(record.error) ? record.error : null };
4169
4345
  }
4170
4346
  /**
4171
4347
  * A status read with its `degradation` checked by `readRunDegradation`. Only when the read carries
@@ -4181,110 +4357,4 @@ function knownRunStatus(value) {
4181
4357
  ? value
4182
4358
  : undefined;
4183
4359
  }
4184
- /** The status word of a `detail` reading `Run finished with status <STATUS>…`, for a platform without `run_status`. */
4185
- function statusFromDetail(detail) {
4186
- return knownRunStatus(/status\s+([A-Z_]+)/.exec(detail)?.[1]);
4187
- }
4188
- /**
4189
- * Extract the members of an error body.
4190
- *
4191
- * The API serializes errors as RFC 9457 problem documents — the platform's (`type`, `title`,
4192
- * `status`, `code`, `detail`, `instance`, `request_id`, `errors[]`, plus an extension member
4193
- * such as a failed run's `run_status` and `error`) and the runner's (the same standard slots
4194
- * plus `error_type`, `error_domain`, `error_category`, `retryable`, `user_action`, `model`,
4195
- * `provider`, `provider_metadata`, `validation_errors`, `migration`) — and, on older routes, as
4196
- * `{"detail": {"error_type": ..., "message": ...}}` (HTTPException with dict detail). Both
4197
- * shapes are handled, with top-level `error_type` / `message` fallbacks. Falls through to empty
4198
- * on a non-JSON or non-object body.
4199
- *
4200
- * Each typed member is kept only when it has the type the problem document gives it, so a
4201
- * malformed member reads as absent rather than as a wrong value, and the nested members
4202
- * (`provider_metadata`, `migration`, `validation_errors`, `errors`) are checked field by field as a
4203
- * stored report's are; `document` keeps the decoded object whole, members named or not.
4204
- */
4205
- function parseErrorBody(body) {
4206
- const empty = {
4207
- errorType: undefined,
4208
- serverMessage: undefined,
4209
- validationErrors: undefined,
4210
- code: undefined,
4211
- details: {},
4212
- document: undefined,
4213
- };
4214
- if (!body)
4215
- return empty;
4216
- // Read by the protocol's rules, as every body is: JSON nested deeper than `MAX_BODY_NESTING`, or
4217
- // holding a number past the range of a double, carries no member, as `pipelex-sdk` reads it.
4218
- const reading = parseJsonBody(body);
4219
- if (!reading.ok || !isPlainObject(reading.value)) {
4220
- return empty;
4221
- }
4222
- const root = reading.value;
4223
- const detail = root.detail;
4224
- let errorType;
4225
- let serverMessage;
4226
- if (detail && typeof detail === "object") {
4227
- const d = detail;
4228
- if (typeof d.error_type === "string")
4229
- errorType = d.error_type;
4230
- if (typeof d.message === "string")
4231
- serverMessage = d.message;
4232
- }
4233
- else if (typeof detail === "string") {
4234
- serverMessage = detail;
4235
- }
4236
- if (errorType === undefined && typeof root.error_type === "string")
4237
- errorType = root.error_type;
4238
- if (serverMessage === undefined && typeof root.message === "string")
4239
- serverMessage = root.message;
4240
- // `validation_errors` rides the problem envelope as a top-level array (the
4241
- // VERBOSE projection of `ErrorReport.validation_errors`, retained under STRICT
4242
- // too — it describes the caller's own bundle, not server internals). Each item is
4243
- // kept when it is an object with a string `category` and `message`, as on a stored report.
4244
- const validationErrors = Array.isArray(root.validation_errors)
4245
- ? root.validation_errors.filter(isValidationItem)
4246
- : undefined;
4247
- // The platform's closed native code (`conflict`, `not_found`, …), one-to-one with `type`.
4248
- const code = typeof root.code === "string" ? root.code : undefined;
4249
- const details = {
4250
- type: stringMember(root.type),
4251
- title: stringMember(root.title),
4252
- instance: stringMember(root.instance),
4253
- requestId: stringMember(root.request_id),
4254
- errorDomain: stringMember(root.error_domain),
4255
- errorCategory: stringMember(root.error_category),
4256
- retryable: typeof root.retryable === "boolean" ? root.retryable : undefined,
4257
- userAction: parseUserAction(root.user_action),
4258
- model: stringMember(root.model),
4259
- provider: stringMember(root.provider),
4260
- // The nested members are checked one level down by the readers a stored report goes
4261
- // through, so a misfit field reads as absent here exactly as it does there.
4262
- providerMetadata: isPlainObject(root.provider_metadata)
4263
- ? readProviderMetadata(root.provider_metadata)
4264
- : undefined,
4265
- migration: isPlainObject(root.migration) ? readMigration(root.migration) : undefined,
4266
- errors: Array.isArray(root.errors)
4267
- ? root.errors.filter(isPlainObject).map(readFieldError)
4268
- : undefined,
4269
- };
4270
- return { errorType, serverMessage, validationErrors, code, details, document: root };
4271
- }
4272
- /** A problem member kept only when it is a non-empty string. */
4273
- function stringMember(value) {
4274
- return typeof value === "string" && value.length > 0 ? value : undefined;
4275
- }
4276
- /** A `user_action` member is kept only whole: an object with a string `kind` and a non-empty `detail`. */
4277
- function parseUserAction(value) {
4278
- if (!isPlainObject(value))
4279
- return undefined;
4280
- const { kind, detail } = value;
4281
- if (typeof kind !== "string" || typeof detail !== "string" || detail.length === 0) {
4282
- return undefined;
4283
- }
4284
- return { kind, detail };
4285
- }
4286
- function nonEmptyHeader(headers, name) {
4287
- const value = headers.get(name)?.trim();
4288
- return value ? value : undefined;
4289
- }
4290
4360
  //# sourceMappingURL=client.js.map