@pipelex/sdk 0.32.0 → 0.34.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 (87) hide show
  1. package/README.md +40 -5
  2. package/dist/artifacts.js +23 -9
  3. package/dist/artifacts.js.map +1 -1
  4. package/dist/cli/args.d.ts +41 -0
  5. package/dist/cli/args.js +96 -0
  6. package/dist/cli/args.js.map +1 -0
  7. package/dist/cli/bundle.d.ts +45 -0
  8. package/dist/cli/bundle.js +148 -0
  9. package/dist/cli/bundle.js.map +1 -0
  10. package/dist/cli/environment.d.ts +36 -0
  11. package/dist/cli/environment.js +72 -0
  12. package/dist/cli/environment.js.map +1 -0
  13. package/dist/cli/help.d.ts +4 -0
  14. package/dist/cli/help.js +56 -0
  15. package/dist/cli/help.js.map +1 -0
  16. package/dist/cli/inputs.d.ts +13 -0
  17. package/dist/cli/inputs.js +70 -0
  18. package/dist/cli/inputs.js.map +1 -0
  19. package/dist/cli/io.d.ts +79 -0
  20. package/dist/cli/io.js +96 -0
  21. package/dist/cli/io.js.map +1 -0
  22. package/dist/cli/main.d.ts +18 -0
  23. package/dist/cli/main.js +58 -0
  24. package/dist/cli/main.js.map +1 -0
  25. package/dist/cli/method.d.ts +56 -0
  26. package/dist/cli/method.js +108 -0
  27. package/dist/cli/method.js.map +1 -0
  28. package/dist/cli/present.d.ts +43 -0
  29. package/dist/cli/present.js +191 -0
  30. package/dist/cli/present.js.map +1 -0
  31. package/dist/cli/run.d.ts +17 -0
  32. package/dist/cli/run.js +290 -0
  33. package/dist/cli/run.js.map +1 -0
  34. package/dist/cli/script.d.ts +23 -0
  35. package/dist/cli/script.js +212 -0
  36. package/dist/cli/script.js.map +1 -0
  37. package/dist/cli/source.d.ts +49 -0
  38. package/dist/cli/source.js +71 -0
  39. package/dist/cli/source.js.map +1 -0
  40. package/dist/cli.d.ts +10 -0
  41. package/dist/cli.js +65 -0
  42. package/dist/cli.js.map +1 -0
  43. package/dist/client.d.ts +125 -17
  44. package/dist/client.js +383 -109
  45. package/dist/client.js.map +1 -1
  46. package/dist/codegen-check.d.ts +9 -0
  47. package/dist/codegen-check.js +12 -6
  48. package/dist/codegen-check.js.map +1 -1
  49. package/dist/error-models.d.ts +60 -2
  50. package/dist/error-models.js +169 -3
  51. package/dist/error-models.js.map +1 -1
  52. package/dist/error-verdicts.d.ts +34 -0
  53. package/dist/error-verdicts.js +69 -0
  54. package/dist/error-verdicts.js.map +1 -0
  55. package/dist/errors.d.ts +250 -48
  56. package/dist/errors.js +330 -64
  57. package/dist/errors.js.map +1 -1
  58. package/dist/hooks/validate-client.d.ts +3 -3
  59. package/dist/hooks/validate-client.js +3 -3
  60. package/dist/index.d.ts +4 -4
  61. package/dist/index.js +3 -2
  62. package/dist/index.js.map +1 -1
  63. package/dist/prepare-inputs.d.ts +6 -0
  64. package/dist/prepare-inputs.js +46 -14
  65. package/dist/prepare-inputs.js.map +1 -1
  66. package/dist/runs.d.ts +28 -1
  67. package/dist/runs.js +2 -1
  68. package/dist/runs.js.map +1 -1
  69. package/dist/upload-grant.d.ts +15 -10
  70. package/dist/upload-grant.js +36 -17
  71. package/dist/upload-grant.js.map +1 -1
  72. package/dist/upload.d.ts +6 -0
  73. package/dist/upload.js +25 -7
  74. package/dist/upload.js.map +1 -1
  75. package/dist/user-agent.d.ts +3 -2
  76. package/dist/user-agent.js +3 -2
  77. package/dist/user-agent.js.map +1 -1
  78. package/dist/version.d.ts +1 -1
  79. package/dist/version.js +1 -1
  80. package/docs/architecture.md +15 -9
  81. package/docs/artifact-download.md +3 -3
  82. package/docs/cli.md +123 -0
  83. package/docs/client-identification.md +1 -1
  84. package/docs/errors.md +96 -11
  85. package/docs/input-preparation.md +16 -8
  86. package/docs/run-results.md +20 -0
  87. package/package.json +5 -1
package/dist/client.js CHANGED
@@ -2,8 +2,9 @@
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
4
  import { assertExclusiveRunSources, hasBundlePayload, parseMethodFiles, serializeMethodFiles, } from "mthds/protocol";
5
- import { assertArtifactSelection, assertWaitOptions, pollUntilResult, selectionIncludesMainStuff, } from "./runs.js";
6
- import { ApiResponseError, ApiUnreachableError, EmptyMethodSourceError, MissingMainStuffError, PipelineExecuteTimeoutError, PipelineRequestError, RunLifecycleUnavailableError, RunStillRunningError, } from "./errors.js";
5
+ import { assertArtifactSelection, assertWaitOptions, pollUntilResult, selectionIncludesMainStuff, throwIfAborted, } from "./runs.js";
6
+ import { ApiResponseError, ApiUnreachableError, EmptyMethodSourceError, MissingMainStuffError, PagingNotTerminatingError, PipelineExecuteTimeoutError, PipelineRequestError, RequestArgumentError, RunLifecycleUnavailableError, RunStillRunningError, } from "./errors.js";
7
+ import { isPlainObject, isValidationItem, readFieldError, readMigration, readProviderMetadata, readRunErrorReport, } from "./error-models.js";
7
8
  import { methodSourceToContents } from "./method-source.js";
8
9
  import { buildUserAgent } from "./user-agent.js";
9
10
  import { uploadFile as uploadFileImpl } from "./upload.js";
@@ -25,6 +26,9 @@ import { MAX_TIMER_DELAY_MS, isTimerDelay } from "./timers.js";
25
26
  // cannot fire on real data; it exists only so a server minting fresh cursors
26
27
  // forever cannot hang a caller indefinitely.
27
28
  const MAX_PAGES = 10_000;
29
+ /** Decoders of an answer's bytes: the strict one tells a body that is not UTF-8. */
30
+ const STRICT_UTF8 = new TextDecoder("utf-8", { fatal: true });
31
+ const LENIENT_UTF8 = new TextDecoder("utf-8");
28
32
  /** Hosted default — the client composes every endpoint as `{base}/v1/{endpoint}`. */
29
33
  export const DEFAULT_API_BASE_URL = "https://api.pipelex.com";
30
34
  // The client composes every endpoint from one origin (PIPELEX_BASE_URL): `{base}/v1/{endpoint}`.
@@ -44,10 +48,22 @@ const VALIDATE_MARKDOWN_RENDER_FORMAT = "markdown";
44
48
  * `RunLifecycleUnavailableError` on the first poll.
45
49
  */
46
50
  const BARE_RUNNER_IMPLEMENTATION = "pipelex-api";
47
- /** Parse a wire method into the public shape (`python` → `MethodFile[]`). */
48
- function methodDataFromWire(wire) {
51
+ /**
52
+ * Parse a wire method into the public shape (`python` → `MethodFile[]`). A stored `python` that
53
+ * is not the serialized `[{ name, content }]` list is server data the SDK cannot read, not a
54
+ * caller's argument, so `mthds`'s refusal is handed to `unreadable`, which builds the error to
55
+ * throw from the answer that carried it.
56
+ */
57
+ function methodDataFromWire(wire, unreadable) {
49
58
  const { python, ...rest } = wire;
50
- return python == null ? rest : { ...rest, python: parseMethodFiles(python) };
59
+ if (python == null)
60
+ return rest;
61
+ try {
62
+ return { ...rest, python: parseMethodFiles(python) };
63
+ }
64
+ catch (err) {
65
+ throw unreadable(err);
66
+ }
51
67
  }
52
68
  /**
53
69
  * Serialize a write payload for the wire. `python` is three-way: OMITTED stays
@@ -82,11 +98,14 @@ export class PipelexApiClient {
82
98
  // this constructor and must be held to that rule, or a path-prefixed value
83
99
  // (e.g. `.../v1`) composes as `/v1/v1/...` and fails with a misleading
84
100
  // endpoint error instead of a clear base-URL one. Trailing slashes are
85
- // stripped first; a remaining path/query/fragment/credentials is rejected.
101
+ // stripped first; a remaining path/query/fragment/credentials is rejected. The
102
+ // refusal is `config`: the value typically comes from PIPELEX_BASE_URL, the environment.
103
+ // It never quotes the value whole: what the rule refuses is where a secret travels
104
+ // (a password, a token in a query), so it names those parts without their text.
86
105
  if (!isValidBaseUrl(normalizedBaseUrl)) {
87
- throw new PipelineRequestError(`Invalid API base URL "${normalizedBaseUrl}": must be host-only ` +
106
+ throw new RequestArgumentError(`Invalid API base URL ${describeRefusedBaseUrl(normalizedBaseUrl)}: it must be host-only ` +
88
107
  `(http/https, no path, query, fragment, or credentials). Endpoints ` +
89
- `compose as {base}/v1/{endpoint}.`);
108
+ `compose as {base}/v1/{endpoint}.`, { verdict: { errorDomain: "config", retryable: false } });
90
109
  }
91
110
  this.baseUrl = normalizedBaseUrl;
92
111
  this.originUrl = new URL("/", this.baseUrl).origin;
@@ -142,7 +161,7 @@ export class PipelexApiClient {
142
161
  userSignal.addEventListener("abort", onUserAbort, { once: true });
143
162
  }
144
163
  let response;
145
- let body;
164
+ let bytes;
146
165
  try {
147
166
  response = await fetch(url, {
148
167
  method,
@@ -153,7 +172,7 @@ export class PipelexApiClient {
153
172
  // The body streams after the headers; keep the timer/abort armed until it
154
173
  // has fully arrived, or a stalled body would hang past the advertised
155
174
  // timeout with no way to cancel.
156
- body = await response.text();
175
+ bytes = new Uint8Array(await response.arrayBuffer());
157
176
  }
158
177
  catch (err) {
159
178
  // A caller-initiated abort (not our timeout) propagates untouched so
@@ -175,32 +194,23 @@ export class PipelexApiClient {
175
194
  if (userSignal)
176
195
  userSignal.removeEventListener("abort", onUserAbort);
177
196
  }
197
+ let body;
198
+ let utf8 = true;
199
+ try {
200
+ body = STRICT_UTF8.decode(bytes);
201
+ }
202
+ catch {
203
+ utf8 = false;
204
+ body = LENIENT_UTF8.decode(bytes);
205
+ }
178
206
  return {
179
207
  status: response.status,
180
208
  statusText: response.statusText,
181
209
  headers: response.headers,
182
210
  body,
211
+ utf8,
183
212
  };
184
213
  }
185
- /**
186
- * Issue a request and parse the JSON body, throwing a plain `Error` on a non-2xx
187
- * response. Used only by `health` — the origin-level liveness probe, which sits
188
- * outside `/v1` and outside the RFC 7807 error taxonomy the `/v1` routes share.
189
- * Every `/v1` route goes through a helper that maps its problem body to the typed
190
- * `ApiResponseError` instead.
191
- */
192
- async requestJson(method, url, body) {
193
- const res = await fetch(url, {
194
- method,
195
- headers: this.requestHeaders(body !== undefined),
196
- body: body !== undefined ? JSON.stringify(body) : undefined,
197
- });
198
- if (!res.ok) {
199
- const text = await res.text().catch(() => "");
200
- throw new Error(`API ${method} ${url} failed (${res.status}): ${text || res.statusText}`);
201
- }
202
- return res.json();
203
- }
204
214
  /**
205
215
  * Issue a Pipelex-product request (`/v1/me`, `/v1/methods`, `/v1/billing/*`,
206
216
  * …) and parse its JSON body, mapping a non-2xx response to the typed
@@ -210,6 +220,59 @@ export class PipelexApiClient {
210
220
  * timeout, not the blocking-execute ceiling.
211
221
  */
212
222
  async requestProduct(method, endpoint, body, options = {}) {
223
+ const res = await this.requestProductAnswer(method, endpoint, body, options);
224
+ return (res.body ? this.readAnswer(method, endpoint, res) : undefined);
225
+ }
226
+ /**
227
+ * A product request whose answer is an object the SDK or its caller reads before anything else
228
+ * — a bulk resolution's `items`, a run record's `error`, an upload grant's `url`. Unlike
229
+ * `requestProduct`, an empty
230
+ * 2xx is no answer here: it and a body that is not a JSON object throw the `ApiResponseError`
231
+ * of an answer the SDK cannot read (see `readObjectAnswerAt`).
232
+ */
233
+ async requestProductObject(method, endpoint, body, options = {}) {
234
+ const res = await this.requestProductAnswer(method, endpoint, body, options);
235
+ return this.readObjectAnswer(method, endpoint, res);
236
+ }
237
+ /**
238
+ * A methods-catalog route answering one stored method (`getMethod`, `createMethod`,
239
+ * `updateMethod`), parsed into the public shape. A stored `mthds` that is neither a string nor
240
+ * absent (`null` stays "no source", which `getMethodClosure` reports), and a stored `python`
241
+ * the SDK cannot parse, are an answer it cannot read, so each throws an `ApiResponseError` built
242
+ * from that answer, the latter with `mthds`'s refusal as `cause`: `runtime`, not retryable,
243
+ * since no change to the call fixes it.
244
+ */
245
+ async requestMethodData(method, endpoint, body) {
246
+ const res = await this.requestProductAnswer(method, endpoint, body);
247
+ const wire = this.readObjectAnswer(method, endpoint, res);
248
+ const source = wire.mthds;
249
+ if (source != null && typeof source !== "string") {
250
+ throw this.unreadableAnswer(method, `/${API_PREFIX}/${endpoint}`, res, "a stored method whose `mthds` field is not a string");
251
+ }
252
+ return methodDataFromWire(wire, (cause) => this.unreadableAnswer(method, `/${API_PREFIX}/${endpoint}`, res, "a stored method whose `python` field could not be read", cause));
253
+ }
254
+ /**
255
+ * One page of a cursor-paged product route (`listMethods`, `listRuns`), read as the platform
256
+ * always serializes it: `items` an array, `next_cursor` a string or `null`. A page breaking
257
+ * either is an answer the SDK cannot read, so it throws the `ApiResponseError` `unreadableAnswer`
258
+ * builds, `runtime` and not retryable, rather than handing the iterators a page they cannot
259
+ * walk: `items` that is not iterable, or a missing cursor that is neither the end nor a next
260
+ * page, which `iterateRuns` would follow forever.
261
+ */
262
+ async requestPage(endpoint) {
263
+ const res = await this.requestProductAnswer("GET", endpoint, undefined);
264
+ const page = this.readObjectAnswer("GET", endpoint, res);
265
+ const { items, next_cursor: nextCursor } = page;
266
+ if (!Array.isArray(items)) {
267
+ throw this.unreadableAnswer("GET", `/${API_PREFIX}/${endpoint}`, res, "a page whose `items` is not an array");
268
+ }
269
+ if (nextCursor !== null && typeof nextCursor !== "string") {
270
+ throw this.unreadableAnswer("GET", `/${API_PREFIX}/${endpoint}`, res, "a page whose `next_cursor` is neither a string nor null");
271
+ }
272
+ return { items: items, nextCursor };
273
+ }
274
+ /** Issue a product request and return its 2xx answer, a non-2xx thrown as `ApiResponseError`. */
275
+ async requestProductAnswer(method, endpoint, body, options = {}) {
213
276
  const res = await this.requestRaw(method, this.url(endpoint), {
214
277
  body,
215
278
  timeoutMs: POLL_REQUEST_TIMEOUT_MS,
@@ -218,14 +281,67 @@ export class PipelexApiClient {
218
281
  if (res.status < 200 || res.status >= 300) {
219
282
  this.throwApiResponseError(method, endpoint, res);
220
283
  }
221
- return (res.body ? JSON.parse(res.body) : undefined);
284
+ return res;
222
285
  }
223
286
  throwApiResponseError(method, endpoint, res) {
287
+ this.throwApiResponseErrorAt(method, `/${API_PREFIX}/${endpoint}`, res);
288
+ }
289
+ /** The JSON body of a `/v1` route's 2xx answer; see `readAnswerAt`. */
290
+ readAnswer(method, endpoint, res) {
291
+ return this.readAnswerAt(method, `/${API_PREFIX}/${endpoint}`, res);
292
+ }
293
+ /** The JSON object body of a `/v1` route's 2xx answer; see `readObjectAnswerAt`. */
294
+ readObjectAnswer(method, endpoint, res) {
295
+ return this.readObjectAnswerAt(method, `/${API_PREFIX}/${endpoint}`, res);
296
+ }
297
+ /**
298
+ * The JSON body of a 2xx answer, naming the route by its `path` from the origin as a refusal
299
+ * does. A body that is not UTF-8, or not JSON, an empty one included, is an answer the SDK
300
+ * cannot read, so it throws the `ApiResponseError` `unreadableAnswer` builds, with the parse
301
+ * failure as `cause`.
302
+ * The one place a success body is parsed.
303
+ */
304
+ readAnswerAt(method, path, res) {
305
+ if (!res.utf8)
306
+ throw this.unreadableAnswer(method, path, res, "a body that is not UTF-8");
307
+ try {
308
+ return JSON.parse(res.body);
309
+ }
310
+ catch (err) {
311
+ throw this.unreadableAnswer(method, path, res, res.body ? "a body that is not JSON" : "an empty body where JSON was expected", err);
312
+ }
313
+ }
314
+ /**
315
+ * The JSON body of a 2xx answer from a route that answers an object, read as `readAnswerAt`
316
+ * reads it. A body that is JSON but not an object — `null`, a number, a string, an array — is
317
+ * an answer the SDK cannot read either, and throws the same `ApiResponseError`, with no `cause`.
318
+ */
319
+ readObjectAnswerAt(method, path, res) {
320
+ const answer = this.readAnswerAt(method, path, res);
321
+ if (!isPlainObject(answer)) {
322
+ throw this.unreadableAnswer(method, path, res, "a body that is not an object");
323
+ }
324
+ return answer;
325
+ }
326
+ /**
327
+ * The `ApiResponseError` of a 2xx answer the SDK cannot read, its message
328
+ * `API <method> <path> answered <status> with <what>`: the answer's status, its raw text as
329
+ * `responseBody`, no problem member but the `X-Request-ID` header, the failure that made it
330
+ * unreadable as `cause`, and the verdict the fallback gives a 2xx, `runtime` and not retryable.
331
+ */
332
+ unreadableAnswer(method, path, res, what, cause) {
333
+ return new ApiResponseError(`API ${method} ${path} answered ${res.status} with ${what}`, this.baseUrl, res.status, res.statusText, res.body, undefined, undefined, undefined, undefined, { cause, problem: { requestId: nonEmptyHeader(res.headers, REQUEST_ID_HEADER) } });
334
+ }
335
+ /**
336
+ * Throw the `ApiResponseError` of a non-2xx answer, naming the route by its `path` from the
337
+ * origin (`/v1/...`, or `/health` for the liveness probe).
338
+ */
339
+ throwApiResponseErrorAt(method, path, res) {
224
340
  const { errorType, serverMessage, validationErrors, code, problem, document } = parseErrorBody(res.body);
225
341
  // The body's `request_id` wins; the header is the fallback for a response whose body
226
342
  // carries none (a gateway error page, a non-problem body).
227
343
  const requestId = problem.requestId ?? nonEmptyHeader(res.headers, REQUEST_ID_HEADER);
228
- throw new ApiResponseError(`API ${method} /${API_PREFIX}/${endpoint} failed (${res.status}): ${serverMessage ?? (res.body || res.statusText)}`, this.baseUrl, res.status, res.statusText, res.body, errorType, serverMessage, validationErrors, code, { problem: { ...problem, requestId }, problemDocument: document });
344
+ 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, { problem: { ...problem, requestId }, problemDocument: document });
229
345
  }
230
346
  /**
231
347
  * Translate a "route absent" 404 (a bare pipelex-api with no platform block)
@@ -267,9 +383,24 @@ export class PipelexApiClient {
267
383
  "running server-side. Poll its results (hosted) or use start().", runId, parseRetryAfter(res.headers), res.headers.get("location"));
268
384
  }
269
385
  // ── Health ────────────────────────────────────────────────────────
386
+ /**
387
+ * The origin-level liveness probe — `GET /health`, which sits at the origin, NOT under the
388
+ * `/v1` prefix. A bare runner serves it; a hosted origin answers `/v1/health` instead, so
389
+ * there this throws.
390
+ *
391
+ * It goes through the same transport as every route: an unreachable origin is an
392
+ * `ApiUnreachableError`, and a non-2xx answer an `ApiResponseError` whose verdict the SDK's
393
+ * fallback reads from the status, since the probe answers no problem document. So is a 2xx
394
+ * whose body is not a JSON object, such as a gateway's HTML page: `runtime`, not retryable.
395
+ */
270
396
  async health() {
271
- // `/health` is origin-level, NOT under the `/v1` prefix.
272
- return this.requestJson("GET", `${this.originUrl}/health`);
397
+ const res = await this.requestRaw("GET", `${this.originUrl}/health`, {
398
+ timeoutMs: POLL_REQUEST_TIMEOUT_MS,
399
+ });
400
+ if (res.status < 200 || res.status >= 300) {
401
+ this.throwApiResponseErrorAt("GET", "/health", res);
402
+ }
403
+ return this.readObjectAnswerAt("GET", "/health", res);
273
404
  }
274
405
  // ── Protocol surface ─────────────────────────────────────────────────
275
406
  /**
@@ -295,9 +426,9 @@ export class PipelexApiClient {
295
426
  Object.keys(api).length === 0 &&
296
427
  Object.keys(hosted).length === 0 &&
297
428
  Object.keys(extensions).length === 0) {
298
- throw new PipelineRequestError("Either pipe_code, mthds_contents, a method bundle (files/bundle_b64), a method_ref, a hosted method_id or a server-specific extension arg (extra) must be provided to execute().");
429
+ throw new RequestArgumentError("Either pipe_code, mthds_contents, a method bundle (files/bundle_b64), a method_ref, a hosted method_id or a server-specific extension arg (extra) must be provided to execute().");
299
430
  }
300
- assertExclusiveRunSources(options);
431
+ assertRunSourcesExclusive(options);
301
432
  assertMethodRefPairsWithNothing(options);
302
433
  const request = {
303
434
  pipe_code: options.pipe_code,
@@ -312,7 +443,8 @@ export class PipelexApiClient {
312
443
  ...hosted,
313
444
  ...extensions,
314
445
  };
315
- const startedAt = Date.now();
446
+ // Timed on the monotonic clock, which a change of the system's time does not move.
447
+ const startedAt = performance.now();
316
448
  try {
317
449
  const res = await this.requestRaw("POST", this.url("execute"), {
318
450
  body: request,
@@ -323,7 +455,7 @@ export class PipelexApiClient {
323
455
  }
324
456
  // Wrap the base result in the enriched subtype (adds the `.main_stuff` accessor; the
325
457
  // `main_stuff_name` extension + working memory ride `pipe_output`).
326
- return new PipelexExecuteResult(JSON.parse(res.body));
458
+ return new PipelexExecuteResult(this.readObjectAnswer("POST", "execute", res));
327
459
  }
328
460
  catch (err) {
329
461
  if (err instanceof RunStillRunningError)
@@ -331,8 +463,8 @@ export class PipelexApiClient {
331
463
  // The hosted gateway terminates synchronous requests at ~30s. A run that
332
464
  // exceeds that comes back as a gateway 503/504 (or a client abort) —
333
465
  // translate it into a clear, actionable error pointing at start+poll.
334
- const elapsedMs = Date.now() - startedAt;
335
- if (isGatewayTimeout(err, elapsedMs)) {
466
+ const elapsedMs = performance.now() - startedAt;
467
+ if (isGatewayCutOff(err, elapsedMs)) {
336
468
  throw new PipelineExecuteTimeoutError(elapsedMs, { cause: err });
337
469
  }
338
470
  throw err;
@@ -361,9 +493,9 @@ export class PipelexApiClient {
361
493
  Object.keys(api).length === 0 &&
362
494
  Object.keys(hosted).length === 0 &&
363
495
  Object.keys(extensions).length === 0) {
364
- throw new PipelineRequestError("Either pipe_code, mthds_contents, a method bundle (files/bundle_b64), a method_ref, a hosted method_id or a server-specific extension arg (extra) must be provided to start().");
496
+ throw new RequestArgumentError("Either pipe_code, mthds_contents, a method bundle (files/bundle_b64), a method_ref, a hosted method_id or a server-specific extension arg (extra) must be provided to start().");
365
497
  }
366
- assertExclusiveRunSources(options);
498
+ assertRunSourcesExclusive(options);
367
499
  assertMethodRefPairsWithNothing(options);
368
500
  // `?? undefined` so JSON.stringify drops absent fields from the wire body.
369
501
  const request = {
@@ -380,16 +512,19 @@ export class PipelexApiClient {
380
512
  ...extensions,
381
513
  };
382
514
  const url = this.url("start");
383
- // `start` returns a 202 fast, so the poll timeout normally fits — with two
384
- // exceptions that get the blocking-execute ceiling instead. A method bundle
385
- // can make the request *body* multi-megabyte, and the whole upload is
515
+ // `start` returns a 202 fast, so the poll timeout normally fits — with
516
+ // exceptions that get the blocking-execute ceiling instead. A method bundle,
517
+ // inline as `mthds_contents` or in `files`/`bundle_b64`, can make the
518
+ // request *body* multi-megabyte, and the whole upload is
386
519
  // charged against this budget — the same payload must not time out on the
387
520
  // durable path yet succeed on the fallback. And a `method_ref` start makes
388
521
  // the server FETCH the package before the ack (provenance rides the 202),
389
522
  // whose clone timeout runs well past 30s on a cold cache — aborting it here
390
523
  // would surface as `ApiUnreachableError`, blaming the network for a healthy
391
524
  // server that is still cloning.
392
- const needsLongCeiling = hasBundlePayload(options) || nonEmptyString(options.method_ref) !== undefined;
525
+ const needsLongCeiling = hasBundlePayload(options) ||
526
+ (options.mthds_contents?.length ?? 0) > 0 ||
527
+ nonEmptyString(options.method_ref) !== undefined;
393
528
  const res = await this.requestRaw("POST", url, {
394
529
  body: request,
395
530
  timeoutMs: needsLongCeiling ? DEFAULT_REQUEST_TIMEOUT_MS : POLL_REQUEST_TIMEOUT_MS,
@@ -401,7 +536,7 @@ export class PipelexApiClient {
401
536
  if (res.status < 200 || res.status >= 300) {
402
537
  this.throwApiResponseError("POST", "start", res);
403
538
  }
404
- return JSON.parse(res.body);
539
+ return this.readObjectAnswer("POST", "start", res);
405
540
  }
406
541
  /**
407
542
  * Parse, validate, and dry-run an MTHDS bundle — `POST /v1/validate`.
@@ -480,19 +615,19 @@ export class PipelexApiClient {
480
615
  // A selector object. The illegal shapes are compile errors for typed
481
616
  // callers (`ValidateMethodSelector` pins the other key to `never`); the
482
617
  // runtime checks back them for untyped (JS) callers — a typed
483
- // `PipelineRequestError`, never a native TypeError off a null source —
618
+ // `RequestArgumentError`, never a native TypeError off a null source —
484
619
  // mirroring the server's strict tooling XOR instead of silently picking.
485
620
  if (source === null || source === undefined || typeof source !== "object") {
486
- throw new PipelineRequestError("validate() takes inline contents (a string[]) or a method selector object " +
621
+ throw new RequestArgumentError("validate() takes inline contents (a string[]) or a method selector object " +
487
622
  "({ method_ref } or { method_id }).");
488
623
  }
489
624
  const methodRef = nonEmptyString(source.method_ref);
490
625
  const methodId = nonEmptyString(source.method_id);
491
626
  if ((methodRef === undefined) === (methodId === undefined)) {
492
- throw new PipelineRequestError("validate() takes exactly one method selector: inline contents, { method_ref }, or { method_id }.");
627
+ throw new RequestArgumentError("validate() takes exactly one method selector: inline contents, { method_ref }, or { method_id }.");
493
628
  }
494
629
  if (mthdsSources !== undefined) {
495
- throw new PipelineRequestError("mthds_sources labels inline mthds_contents; a method_ref / method_id validation gets " +
630
+ throw new RequestArgumentError("mthds_sources labels inline mthds_contents; a method_ref / method_id validation gets " +
496
631
  "its source labels from the package's (or the stored method's) real file names.");
497
632
  }
498
633
  if (methodRef !== undefined)
@@ -512,7 +647,7 @@ export class PipelexApiClient {
512
647
  if (res.status < 200 || res.status >= 300) {
513
648
  this.throwApiResponseError("POST", "validate", res);
514
649
  }
515
- return JSON.parse(res.body);
650
+ return this.readObjectAnswer("POST", "validate", res);
516
651
  }
517
652
  /**
518
653
  * Validate paired MTHDS files while preserving URI attribution for diagnostics.
@@ -524,7 +659,7 @@ export class PipelexApiClient {
524
659
  */
525
660
  async validateFiles(files, options = {}) {
526
661
  if (files.length === 0) {
527
- throw new PipelineRequestError("At least one MTHDS file must be provided to validateFiles().");
662
+ throw new RequestArgumentError("At least one MTHDS file must be provided to validateFiles().");
528
663
  }
529
664
  const mthdsContents = files.map((file) => file.content);
530
665
  const hasAnyUri = files.some((file) => file.uri !== undefined);
@@ -638,7 +773,7 @@ export class PipelexApiClient {
638
773
  if (res.status < 200 || res.status >= 300) {
639
774
  this.throwApiResponseError("POST", endpoint, res);
640
775
  }
641
- return JSON.parse(res.body);
776
+ return this.readObjectAnswer("POST", endpoint, res);
642
777
  }
643
778
  /** The model deck the runner can route to — `GET /v1/models[?type=]`. */
644
779
  async models(category) {
@@ -649,7 +784,7 @@ export class PipelexApiClient {
649
784
  if (res.status < 200 || res.status >= 300) {
650
785
  this.throwApiResponseError("GET", endpoint, res);
651
786
  }
652
- return JSON.parse(res.body);
787
+ return this.readObjectAnswer("GET", endpoint, res);
653
788
  }
654
789
  /**
655
790
  * Protocol + implementation versions — `GET /v1/version` (always public).
@@ -662,7 +797,7 @@ export class PipelexApiClient {
662
797
  if (res.status < 200 || res.status >= 300) {
663
798
  this.throwApiResponseError("GET", "version", res);
664
799
  }
665
- return JSON.parse(res.body);
800
+ return this.readObjectAnswer("GET", "version", res);
666
801
  }
667
802
  // ── Crate extensions (Pipelex API — `/v1/resolve`, `/v1/codegen`, `/v1/pipe-io`) ──
668
803
  //
@@ -776,8 +911,10 @@ export class PipelexApiClient {
776
911
  * Self-healing: a finished-but-unrecorded run resolves to its true terminal
777
912
  * status on read. `degraded: true` means Temporal was unreachable and
778
913
  * `status` is the last-known value; `retry_after_seconds` carries the
779
- * server's backoff hint when present. Throws `RunLifecycleUnavailableError`
780
- * when the lifecycle routes are absent (a bare runner).
914
+ * server's backoff hint when present. A failed run's stored report, `error`,
915
+ * is checked field by field (see `RunErrorReport`). Throws
916
+ * `RunLifecycleUnavailableError` when the lifecycle routes are absent (a bare
917
+ * runner).
781
918
  */
782
919
  async getRunStatus(runId, options = {}) {
783
920
  const endpoint = `${RUNS}/${encodeURIComponent(runId)}/status`;
@@ -790,7 +927,7 @@ export class PipelexApiClient {
790
927
  if (res.status < 200 || res.status >= 300) {
791
928
  this.throwApiResponseError("GET", endpoint, res);
792
929
  }
793
- const run = JSON.parse(res.body);
930
+ const run = withCheckedReport(this.readObjectAnswer("GET", endpoint, res));
794
931
  const retryAfter = parseRetryAfter(res.headers);
795
932
  return retryAfter !== null ? { ...run, retry_after_seconds: retryAfter } : run;
796
933
  }
@@ -843,7 +980,7 @@ export class PipelexApiClient {
843
980
  if (res.status < 200 || res.status >= 300) {
844
981
  this.throwApiResponseError("GET", endpoint, res);
845
982
  }
846
- const result = JSON.parse(res.body);
983
+ const result = this.readObjectAnswer("GET", endpoint, res);
847
984
  if (selectionIncludesMainStuff(options.artifacts) && result.main_stuff == null) {
848
985
  throw new MissingMainStuffError(`Completed run '${runId}' returned no main stuff — a completed run always delivers a main stuff.`, runId);
849
986
  }
@@ -857,8 +994,18 @@ export class PipelexApiClient {
857
994
  * Whether the configured server serves the durable run lifecycle, decided
858
995
  * via the `GET /v1/version` handshake and cached for the client's lifetime. A
859
996
  * bare `pipelex-api` runner has no run store; anything else is assumed hosted.
860
- * When the handshake itself fails, assume hosted (the SDK default) and let the
861
- * start call surface the real error.
997
+ *
998
+ * The rule, the one the Python SDK follows: an answer that is not a usable
999
+ * version means assume hosted (the SDK default), and let the start surface the
1000
+ * real error; no answer at all propagates, uncached. An answer is anything the
1001
+ * server sent back: a non-2xx status, a body that is not JSON, not UTF-8 or no
1002
+ * version, and a body that arrived but could not be decoded, such as a broken
1003
+ * gzip stream, which `requestRaw` reports as an `ApiUnreachableError` carrying
1004
+ * the decompressor's code (see `isUndecodableBody`).
1005
+ *
1006
+ * @throws {ApiUnreachableError} The handshake got no answer. Nothing is cached,
1007
+ * so the next call asks again, and no start is sent to a host that did not
1008
+ * answer: sending it would wait a second time for the same silence.
862
1009
  */
863
1010
  async supportsRunLifecycle() {
864
1011
  if (this.lifecycleAvailable === undefined) {
@@ -867,7 +1014,11 @@ export class PipelexApiClient {
867
1014
  const impl = info.implementation;
868
1015
  this.lifecycleAvailable = !(typeof impl === "string" && impl === BARE_RUNNER_IMPLEMENTATION);
869
1016
  }
870
- catch {
1017
+ catch (error) {
1018
+ // No answer at all propagates. A body that arrived and could not be decoded is an answer,
1019
+ // and so is every other failure here: a non-2xx status, or a body that is no version.
1020
+ if (error instanceof ApiUnreachableError && !isUndecodableBody(error))
1021
+ throw error;
871
1022
  this.lifecycleAvailable = true;
872
1023
  }
873
1024
  }
@@ -880,11 +1031,27 @@ export class PipelexApiClient {
880
1031
  * path that survives the gateway's ~30s synchronous ceiling.
881
1032
  * - **Bare runner** (no run store): the blocking `POST /v1/execute`, which
882
1033
  * has no gateway cap off-platform and returns the native `pipe_output`.
1034
+ *
1035
+ * `pollOptions` are the wait's options, plus `onStarted`, called once with the
1036
+ * start acknowledgement as soon as the durable run exists, so the caller holds
1037
+ * the run's id while it waits; never on the blocking path, which has none to
1038
+ * give; and `onStarting`, called right before each request that may create a
1039
+ * run, so the caller knows until then that none exists (see
1040
+ * `StartAndWaitForResultOptions`).
1041
+ *
1042
+ * `pollOptions.signal` is also read before the run is created: a caller that aborts while the
1043
+ * version handshake is in flight gets its abort, and neither the start nor the blocking execute
1044
+ * is sent. Once one of them is sent, the abort stops only the wait.
883
1045
  */
884
1046
  async startAndWaitForResult(options, pollOptions) {
885
1047
  // Before the run starts: a RangeError after it would carry no run id to re-poll by.
886
1048
  assertWaitOptions(pollOptions);
887
- if (await this.supportsRunLifecycle()) {
1049
+ const { onStarted, onStarting, ...waitOptions } = pollOptions ?? {};
1050
+ const hosted = await this.supportsRunLifecycle();
1051
+ // The handshake may have outlasted the caller's patience: an abort that landed during it
1052
+ // creates no run.
1053
+ throwIfAborted(waitOptions.signal);
1054
+ if (hosted) {
888
1055
  // A runner can look hosted yet lack the durable routes — `implementation`
889
1056
  // is an extension field, so a compliant bare runner that omits it is
890
1057
  // misdetected here. Such a runner raises `RunLifecycleUnavailableError`
@@ -893,16 +1060,21 @@ export class PipelexApiClient {
893
1060
  // the durable attempt.
894
1061
  let ack;
895
1062
  try {
1063
+ onStarting?.();
896
1064
  ack = await this.start(options);
897
1065
  }
898
1066
  catch (err) {
899
1067
  if (!(err instanceof RunLifecycleUnavailableError))
900
1068
  throw err;
901
1069
  this.lifecycleAvailable = false;
1070
+ throwIfAborted(waitOptions.signal);
1071
+ onStarting?.();
902
1072
  return this.executeBlocking(options);
903
1073
  }
904
- return this.waitForResult(ack.pipeline_run_id, pollOptions);
1074
+ onStarted?.(ack);
1075
+ return this.waitForResult(ack.pipeline_run_id, waitOptions);
905
1076
  }
1077
+ onStarting?.();
906
1078
  return this.executeBlocking(options);
907
1079
  }
908
1080
  // ── Pipelex product surface (hosted management routes) ─────────────────
@@ -944,8 +1116,7 @@ export class PipelexApiClient {
944
1116
  if (query.cursor !== undefined)
945
1117
  params.set("cursor", query.cursor);
946
1118
  const suffix = params.toString();
947
- const page = await this.requestProduct("GET", suffix ? `methods?${suffix}` : "methods");
948
- return { items: page.items, nextCursor: page.next_cursor };
1119
+ return this.requestPage(suffix ? `methods?${suffix}` : "methods");
949
1120
  }
950
1121
  /**
951
1122
  * Every method in the catalog, streamed — follows the cursor for you.
@@ -997,16 +1168,15 @@ export class PipelexApiClient {
997
1168
  // partial answer with no error is back to the original bug one layer
998
1169
  // up; an error is the only honest response to a server that will not
999
1170
  // finish. Unreachable on real data — see MAX_PAGES.
1000
- throw new Error(`listMethods did not terminate after ${MAX_PAGES} pages; refusing to keep paging. ` +
1001
- `This is a server-side fault, not a coverage limit.`);
1171
+ throw new PagingNotTerminatingError(`listMethods did not terminate after ${MAX_PAGES} pages; refusing to keep paging. ` +
1172
+ `This is a server-side fault, not a coverage limit.`, MAX_PAGES);
1002
1173
  }
1003
1174
  cursor = page.nextCursor;
1004
1175
  }
1005
1176
  }
1006
1177
  /** Fetch one method by id — `GET /v1/methods/{id}`. */
1007
1178
  async getMethod(methodId) {
1008
- const wire = await this.requestProduct("GET", `methods/${encodeURIComponent(methodId)}`);
1009
- return methodDataFromWire(wire);
1179
+ return this.requestMethodData("GET", `methods/${encodeURIComponent(methodId)}`);
1010
1180
  }
1011
1181
  /**
1012
1182
  * Resolve a stored method's id into its runnable MTHDS closure — a client-side
@@ -1038,13 +1208,11 @@ export class PipelexApiClient {
1038
1208
  }
1039
1209
  /** Create a method — `POST /v1/methods`. */
1040
1210
  async createMethod(input) {
1041
- const wire = await this.requestProduct("POST", "methods", methodWriteToWire(input));
1042
- return methodDataFromWire(wire);
1211
+ return this.requestMethodData("POST", "methods", methodWriteToWire(input));
1043
1212
  }
1044
1213
  /** Replace a method (rename = changed `name`) — `PUT /v1/methods/{id}`. */
1045
1214
  async updateMethod(methodId, input) {
1046
- const wire = await this.requestProduct("PUT", `methods/${encodeURIComponent(methodId)}`, methodWriteToWire(input));
1047
- return methodDataFromWire(wire);
1215
+ return this.requestMethodData("PUT", `methods/${encodeURIComponent(methodId)}`, methodWriteToWire(input));
1048
1216
  }
1049
1217
  /**
1050
1218
  * Erase a method and everything it produced — `DELETE /v1/methods/{id}`.
@@ -1149,7 +1317,7 @@ export class PipelexApiClient {
1149
1317
  * only: a deployment without the route answers a `404` `ApiResponseError`.
1150
1318
  */
1151
1319
  async resolveStorageUrls(input, options = {}) {
1152
- return this.requestProduct("POST", "resolve-storage-url/bulk", input, options);
1320
+ return this.requestProductObject("POST", "resolve-storage-url/bulk", input, options);
1153
1321
  }
1154
1322
  /**
1155
1323
  * Resolve a whole list of `pipelex-storage://` references through the bulk
@@ -1182,9 +1350,18 @@ export class PipelexApiClient {
1182
1350
  async downloadArtifacts(request) {
1183
1351
  return downloadArtifactsImpl(this, request);
1184
1352
  }
1185
- /** Upload a base64 file — `POST /v1/upload`. */
1353
+ /**
1354
+ * Upload a base64 file — `POST /v1/upload`. An answer with no non-empty string `uri` names no
1355
+ * stored file, so it is an answer the SDK cannot read: an `ApiResponseError`, `runtime` and
1356
+ * not retryable, which `uploadFile` wraps as an `UploadTransportError` with code `unexpected`.
1357
+ */
1186
1358
  async upload(input) {
1187
- return this.requestProduct("POST", "upload", input);
1359
+ const res = await this.requestProductAnswer("POST", "upload", input);
1360
+ const uploaded = this.readObjectAnswer("POST", "upload", res);
1361
+ if (typeof uploaded.uri !== "string" || uploaded.uri === "") {
1362
+ throw this.unreadableAnswer("POST", `/${API_PREFIX}/upload`, res, "an answer with no string `uri`");
1363
+ }
1364
+ return uploaded;
1188
1365
  }
1189
1366
  /**
1190
1367
  * Request a grant to upload one file straight to storage — `POST /v1/upload/grant`.
@@ -1203,7 +1380,7 @@ export class PipelexApiClient {
1203
1380
  * `docs/input-preparation.md`.
1204
1381
  */
1205
1382
  async requestUploadGrant(input, options = {}) {
1206
- return this.requestProduct("POST", "upload/grant", input, options);
1383
+ return this.requestProductObject("POST", "upload/grant", input, options);
1207
1384
  }
1208
1385
  /**
1209
1386
  * Upload one local asset and return its {@link UploadRecord} — the single-asset
@@ -1277,8 +1454,8 @@ export class PipelexApiClient {
1277
1454
  params.set("limit", String(query.limit));
1278
1455
  if (query.cursor !== undefined)
1279
1456
  params.set("cursor", query.cursor);
1280
- const page = await this.requestProduct("GET", `runs?${params.toString()}`);
1281
- return { items: page.items, nextCursor: page.next_cursor };
1457
+ const page = await this.requestPage(`runs?${params.toString()}`);
1458
+ return { items: page.items.map(withCheckedReport), nextCursor: page.nextCursor };
1282
1459
  }
1283
1460
  /**
1284
1461
  * Every run of a method, streamed — follows the cursor for you.
@@ -1333,10 +1510,11 @@ export class PipelexApiClient {
1333
1510
  *
1334
1511
  * The ONLY call that returns `mthds_contents` (what the run actually
1335
1512
  * executed) and `inputs`. Kept off the status read, which pollers hit every
1336
- * few seconds.
1513
+ * few seconds. A failed run's stored report, `error`, is checked field by
1514
+ * field (see `RunErrorReport`).
1337
1515
  */
1338
1516
  async getRunDetail(runId) {
1339
- return this.requestProduct("GET", `runs/${encodeURIComponent(runId)}`);
1517
+ return withCheckedReport(await this.requestProductObject("GET", `runs/${encodeURIComponent(runId)}`));
1340
1518
  }
1341
1519
  /** Patch a run's status (admin/manual) — `PUT /v1/runs/{id}`. */
1342
1520
  async updateRun(runId, input) {
@@ -1395,6 +1573,42 @@ function isValidBaseUrl(value) {
1395
1573
  return false;
1396
1574
  return !parsed.search && !parsed.hash;
1397
1575
  }
1576
+ /**
1577
+ * A refused base URL as its refusal may show it: the scheme and the host, then the names of the
1578
+ * parts beyond them that the URL carried, never their text. Credentials, a query and a path are
1579
+ * exactly where a secret travels in a URL, and the refusal reaches logs and, through an app that
1580
+ * relays an error's message, a browser. A value that is not an http or https URL is not shown at
1581
+ * all, since nothing says which of its characters are a secret: `localhost:8081`, with no scheme,
1582
+ * parses as a URL whose scheme is `localhost:`.
1583
+ */
1584
+ function describeRefusedBaseUrl(value) {
1585
+ let parsed;
1586
+ try {
1587
+ parsed = new URL(value);
1588
+ }
1589
+ catch {
1590
+ return "(not shown: it is not an absolute URL)";
1591
+ }
1592
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
1593
+ return "(not shown: it is not an http or https URL)";
1594
+ }
1595
+ const parts = [];
1596
+ if (parsed.username || parsed.password)
1597
+ parts.push("credentials");
1598
+ if (parsed.pathname !== "/" && parsed.pathname !== "")
1599
+ parts.push("a path");
1600
+ if (parsed.search)
1601
+ parts.push("a query");
1602
+ if (parsed.hash)
1603
+ parts.push("a fragment");
1604
+ const shown = `"${parsed.protocol}//${parsed.host}"`;
1605
+ if (parts.length === 0)
1606
+ return shown;
1607
+ const named = parts.length === 1
1608
+ ? parts[0]
1609
+ : `${parts.slice(0, -1).join(", ")} and ${parts[parts.length - 1]}`;
1610
+ return `${shown} with ${named} (not shown)`;
1611
+ }
1398
1612
  // The protocol's own request fields — `extra` is for extension args only.
1399
1613
  // `files` / `bundle_b64` are reserved too: they are named run-source options,
1400
1614
  // so smuggling them through `extra` (which merges last into the body) would
@@ -1458,7 +1672,7 @@ function buildExtensions(extra) {
1458
1672
  const snapshot = { ...extra };
1459
1673
  const reserved = Object.keys(snapshot).filter((key) => RESERVED_EXTRA_KEYS.has(key));
1460
1674
  if (reserved.length > 0) {
1461
- throw new PipelineRequestError(`extra carries reserved request args [${reserved.sort().join(", ")}] — pass them as named options instead.`);
1675
+ throw new RequestArgumentError(`extra carries reserved request args [${reserved.sort().join(", ")}] — pass them as named options instead.`);
1462
1676
  }
1463
1677
  const result = {};
1464
1678
  for (const [key, value] of Object.entries(snapshot)) {
@@ -1508,6 +1722,23 @@ function buildHostedRunExtensions(options) {
1508
1722
  const methodId = nonEmptyString(options.method_id);
1509
1723
  return methodId === undefined ? {} : { method_id: methodId };
1510
1724
  }
1725
+ /**
1726
+ * The standard's run-source exclusivity check (`assertExclusiveRunSources`: the two bundle
1727
+ * encodings together, or a bundle beside inline contents), its refusal rethrown as a
1728
+ * `RequestArgumentError` with the same message and the standard's error as `cause`, so it carries
1729
+ * a verdict like every other argument the client refuses.
1730
+ */
1731
+ function assertRunSourcesExclusive(options) {
1732
+ try {
1733
+ assertExclusiveRunSources(options);
1734
+ }
1735
+ catch (err) {
1736
+ if (err instanceof PipelineRequestError) {
1737
+ throw new RequestArgumentError(err.message, { cause: err });
1738
+ }
1739
+ throw err;
1740
+ }
1741
+ }
1511
1742
  /**
1512
1743
  * Enforce the run routes' `method_ref` exclusivity, mirroring the server's own
1513
1744
  * 422s so an illegal pairing fails before anything hits the wire. A
@@ -1528,13 +1759,13 @@ function assertMethodRefPairsWithNothing(options) {
1528
1759
  if (nonEmptyString(options.method_ref) === undefined)
1529
1760
  return;
1530
1761
  if (options.mthds_contents != null && options.mthds_contents.length > 0) {
1531
- throw new PipelineRequestError("method_ref and inline mthds_contents are mutually exclusive; send one or the other.");
1762
+ throw new RequestArgumentError("method_ref and inline mthds_contents are mutually exclusive; send one or the other.");
1532
1763
  }
1533
1764
  if (options.files != null || options.bundle_b64 != null) {
1534
- throw new PipelineRequestError("method_ref and a method bundle (bundle_b64 / files) are mutually exclusive; send one or the other.");
1765
+ throw new RequestArgumentError("method_ref and a method bundle (bundle_b64 / files) are mutually exclusive; send one or the other.");
1535
1766
  }
1536
1767
  if (nonEmptyString(options.method_id) !== undefined) {
1537
- throw new PipelineRequestError("method_ref and method_id are mutually exclusive: an address run carries its own provenance " +
1768
+ throw new RequestArgumentError("method_ref and method_id are mutually exclusive: an address run carries its own provenance " +
1538
1769
  "and takes no run-history linkage id. Send exactly one method selector.");
1539
1770
  }
1540
1771
  }
@@ -1556,19 +1787,52 @@ function withValidateMarkdownRender(render) {
1556
1787
  return [...formats];
1557
1788
  }
1558
1789
  // The hosted gateway caps synchronous requests at 30s. A failure at/after this
1559
- // threshold on the blocking execute is the timeout, not a transient outage —
1560
- // the threshold guards against mislabelling a fast 503 (runner genuinely down)
1561
- // as a timeout.
1790
+ // threshold is the gateway's cut-off, not a transient outage — the threshold
1791
+ // guards against mislabelling a fast 503 (runner genuinely down) as a timeout.
1792
+ // The one source of the threshold in this SDK: `isGatewayCutOff` reads it.
1562
1793
  const GATEWAY_TIMEOUT_THRESHOLD_MS = 28_000;
1563
- function isGatewayTimeout(err, elapsedMs) {
1794
+ /**
1795
+ * Whether a request that failed `elapsedMs` after it was sent was cut off by the hosted gateway's
1796
+ * ~30-second limit on a request it waits on, rather than refused.
1797
+ *
1798
+ * It is when, after at least ~28 seconds, the failure is a `503` or `504` answer, or the client's
1799
+ * own time limit (`ApiUnreachableError` with the code `ABORT_TIMEOUT`). A fast `503` is the API
1800
+ * saying the request was not handled, and any other unreachable host is never the gateway's
1801
+ * cut-off, however long it took. The fetch API cannot say whether `ABORT_TIMEOUT` ran out before
1802
+ * the connection was made, so it is read as the gateway's; with Node's own dispatcher it never
1803
+ * does past the threshold, since undici gives up a connection that does not come after ten
1804
+ * seconds (`UND_ERR_CONNECT_TIMEOUT`), the TLS handshake included. The Python SDK's
1805
+ * `is_gateway_cut_off` reads the same failures, its `ABORT_TIMEOUT` being httpx's read or write
1806
+ * timeout.
1807
+ *
1808
+ * The blocking `execute` turns such a failure into a `PipelineExecuteTimeoutError`. A caller timing
1809
+ * a request that may create a run, such as `start` from `startAndWaitForResult`'s `onStarting`,
1810
+ * reads it to know the server may still be handling the request the gateway gave up on.
1811
+ */
1812
+ export function isGatewayCutOff(error, elapsedMs) {
1564
1813
  if (elapsedMs < GATEWAY_TIMEOUT_THRESHOLD_MS)
1565
1814
  return false;
1566
- if (err instanceof ApiResponseError)
1567
- return err.status === 503 || err.status === 504;
1568
- if (err instanceof ApiUnreachableError)
1569
- return err.code === "ABORT_TIMEOUT";
1815
+ if (error instanceof ApiResponseError)
1816
+ return error.status === 503 || error.status === 504;
1817
+ if (error instanceof ApiUnreachableError)
1818
+ return error.code === "ABORT_TIMEOUT";
1570
1819
  return false;
1571
1820
  }
1821
+ /**
1822
+ * The codes Node's fetch gives a body that arrived and could not be decoded. It decodes a
1823
+ * `Content-Encoding` while it reads the body, after the status and the headers, and a body that
1824
+ * does not decode fails the read with the decompressor's own code: zlib's `Z_…` for `gzip` and
1825
+ * `deflate`, brotli's `ERR__ERROR_…` for `br`, zstd's `ZSTD_error_…` for `zstd`.
1826
+ */
1827
+ const UNDECODABLE_BODY_CODE = /^(Z_|ERR__ERROR_|ZSTD_error_)/;
1828
+ /**
1829
+ * Whether an `ApiUnreachableError` is an answer whose body could not be decoded, rather than no
1830
+ * answer at all. The Python SDK's twin is the `ApiUnreachableError` whose cause is httpx's
1831
+ * `DecodingError`.
1832
+ */
1833
+ function isUndecodableBody(error) {
1834
+ return error.code !== undefined && UNDECODABLE_BODY_CODE.test(error.code);
1835
+ }
1572
1836
  function extractNetworkErrorCode(err) {
1573
1837
  if (err instanceof DOMException && err.name === "TimeoutError") {
1574
1838
  return "ABORT_TIMEOUT";
@@ -1634,21 +1898,31 @@ const REQUEST_ID_HEADER = "x-request-id";
1634
1898
  * `Run finished with status <STATUS>` the platform keeps for exactly this reader, so the status
1635
1899
  * word is recovered from it then; a `409` that yields no known status either way (the one this
1636
1900
  * route answers for a stored result it refuses to read) reads as `FAILED`, and its `detail`
1637
- * still says what happened. The report is relayed whole, as the runner wrote it: an object is
1638
- * taken as the report, anything else reads as no report.
1901
+ * still says what happened. The report is checked field by field by `readRunErrorReport`: an
1902
+ * object is taken as the report, each named field kept when it has its declared type, and
1903
+ * anything that is not an object reads as no report.
1639
1904
  */
1640
1905
  function runResultFailed(runId, body) {
1641
1906
  const { serverMessage, document } = parseErrorBody(body);
1642
1907
  const message = serverMessage ?? "Run finished without a result.";
1643
- const rawReport = document?.error;
1644
1908
  return {
1645
1909
  state: "failed",
1646
1910
  pipeline_run_id: runId,
1647
1911
  status: knownRunStatus(document?.run_status) ?? statusFromDetail(message) ?? "FAILED",
1648
1912
  message,
1649
- error: isPlainObject(rawReport) ? rawReport : null,
1913
+ error: readRunErrorReport(document?.error),
1650
1914
  };
1651
1915
  }
1916
+ /**
1917
+ * A run record with its stored report checked by `readRunErrorReport`. Only when the record
1918
+ * carries the `error` key, so a server that does not serve it still leaves it absent; a record
1919
+ * that is not an object is returned as it came.
1920
+ */
1921
+ function withCheckedReport(record) {
1922
+ if (!isPlainObject(record) || !Object.hasOwn(record, "error"))
1923
+ return record;
1924
+ return { ...record, error: readRunErrorReport(record.error) };
1925
+ }
1652
1926
  function knownRunStatus(value) {
1653
1927
  return typeof value === "string" && KNOWN_RUN_STATUSES.includes(value)
1654
1928
  ? value
@@ -1671,8 +1945,9 @@ function statusFromDetail(detail) {
1671
1945
  * on a non-JSON or non-object body.
1672
1946
  *
1673
1947
  * Each typed member is kept only when it has the type the problem document gives it, so a
1674
- * malformed member reads as absent rather than as a wrong value; `document` keeps the decoded
1675
- * object whole, members named or not.
1948
+ * malformed member reads as absent rather than as a wrong value, and the nested members
1949
+ * (`provider_metadata`, `migration`, `validation_errors`, `errors`) are checked field by field as a
1950
+ * stored report's are; `document` keeps the decoded object whole, members named or not.
1676
1951
  */
1677
1952
  function parseErrorBody(body) {
1678
1953
  const empty = {
@@ -1715,10 +1990,10 @@ function parseErrorBody(body) {
1715
1990
  serverMessage = root.message;
1716
1991
  // `validation_errors` rides the problem envelope as a top-level array (the
1717
1992
  // VERBOSE projection of `ErrorReport.validation_errors`, retained under STRICT
1718
- // too — it describes the caller's own bundle, not server internals). Kept as a
1719
- // shallow array guard; per-item shape is the typed `ValidationErrorItem` contract.
1993
+ // too — it describes the caller's own bundle, not server internals). Each item is
1994
+ // kept when it is an object with a string `category` and `message`, as on a stored report.
1720
1995
  const validationErrors = Array.isArray(root.validation_errors)
1721
- ? root.validation_errors
1996
+ ? root.validation_errors.filter(isValidationItem)
1722
1997
  : undefined;
1723
1998
  // The platform's closed native code (`conflict`, `not_found`, …), one-to-one with `type`.
1724
1999
  const code = typeof root.code === "string" ? root.code : undefined;
@@ -1733,19 +2008,18 @@ function parseErrorBody(body) {
1733
2008
  userAction: parseUserAction(root.user_action),
1734
2009
  model: stringMember(root.model),
1735
2010
  provider: stringMember(root.provider),
2011
+ // The nested members are checked one level down by the readers a stored report goes
2012
+ // through, so a misfit field reads as absent here exactly as it does there.
1736
2013
  providerMetadata: isPlainObject(root.provider_metadata)
1737
- ? root.provider_metadata
2014
+ ? readProviderMetadata(root.provider_metadata)
1738
2015
  : undefined,
1739
- migration: isPlainObject(root.migration) ? root.migration : undefined,
2016
+ migration: isPlainObject(root.migration) ? readMigration(root.migration) : undefined,
1740
2017
  errors: Array.isArray(root.errors)
1741
- ? root.errors.filter(isPlainObject)
2018
+ ? root.errors.filter(isPlainObject).map(readFieldError)
1742
2019
  : undefined,
1743
2020
  };
1744
2021
  return { errorType, serverMessage, validationErrors, code, problem, document: root };
1745
2022
  }
1746
- function isPlainObject(value) {
1747
- return typeof value === "object" && value !== null && !Array.isArray(value);
1748
- }
1749
2023
  /** A problem member kept only when it is a non-empty string. */
1750
2024
  function stringMember(value) {
1751
2025
  return typeof value === "string" && value.length > 0 ? value : undefined;