@pipelex/sdk 0.32.0 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.js CHANGED
@@ -3,7 +3,8 @@
3
3
  // client and the MTHDS runners cannot disagree about what they reject.
4
4
  import { assertExclusiveRunSources, hasBundlePayload, parseMethodFiles, serializeMethodFiles, } from "mthds/protocol";
5
5
  import { assertArtifactSelection, assertWaitOptions, pollUntilResult, selectionIncludesMainStuff, } from "./runs.js";
6
- import { ApiResponseError, ApiUnreachableError, EmptyMethodSourceError, MissingMainStuffError, PipelineExecuteTimeoutError, PipelineRequestError, RunLifecycleUnavailableError, RunStillRunningError, } from "./errors.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";
@@ -44,10 +45,22 @@ const VALIDATE_MARKDOWN_RENDER_FORMAT = "markdown";
44
45
  * `RunLifecycleUnavailableError` on the first poll.
45
46
  */
46
47
  const BARE_RUNNER_IMPLEMENTATION = "pipelex-api";
47
- /** Parse a wire method into the public shape (`python` → `MethodFile[]`). */
48
- function methodDataFromWire(wire) {
48
+ /**
49
+ * Parse a wire method into the public shape (`python` → `MethodFile[]`). A stored `python` that
50
+ * is not the serialized `[{ name, content }]` list is server data the SDK cannot read, not a
51
+ * caller's argument, so `mthds`'s refusal is handed to `unreadable`, which builds the error to
52
+ * throw from the answer that carried it.
53
+ */
54
+ function methodDataFromWire(wire, unreadable) {
49
55
  const { python, ...rest } = wire;
50
- return python == null ? rest : { ...rest, python: parseMethodFiles(python) };
56
+ if (python == null)
57
+ return rest;
58
+ try {
59
+ return { ...rest, python: parseMethodFiles(python) };
60
+ }
61
+ catch (err) {
62
+ throw unreadable(err);
63
+ }
51
64
  }
52
65
  /**
53
66
  * Serialize a write payload for the wire. `python` is three-way: OMITTED stays
@@ -82,11 +95,12 @@ export class PipelexApiClient {
82
95
  // this constructor and must be held to that rule, or a path-prefixed value
83
96
  // (e.g. `.../v1`) composes as `/v1/v1/...` and fails with a misleading
84
97
  // endpoint error instead of a clear base-URL one. Trailing slashes are
85
- // stripped first; a remaining path/query/fragment/credentials is rejected.
98
+ // stripped first; a remaining path/query/fragment/credentials is rejected. The
99
+ // refusal is `config`: the value typically comes from PIPELEX_BASE_URL, the environment.
86
100
  if (!isValidBaseUrl(normalizedBaseUrl)) {
87
- throw new PipelineRequestError(`Invalid API base URL "${normalizedBaseUrl}": must be host-only ` +
101
+ throw new RequestArgumentError(`Invalid API base URL "${normalizedBaseUrl}": must be host-only ` +
88
102
  `(http/https, no path, query, fragment, or credentials). Endpoints ` +
89
- `compose as {base}/v1/{endpoint}.`);
103
+ `compose as {base}/v1/{endpoint}.`, { verdict: { errorDomain: "config", retryable: false } });
90
104
  }
91
105
  this.baseUrl = normalizedBaseUrl;
92
106
  this.originUrl = new URL("/", this.baseUrl).origin;
@@ -182,25 +196,6 @@ export class PipelexApiClient {
182
196
  body,
183
197
  };
184
198
  }
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
199
  /**
205
200
  * Issue a Pipelex-product request (`/v1/me`, `/v1/methods`, `/v1/billing/*`,
206
201
  * …) and parse its JSON body, mapping a non-2xx response to the typed
@@ -210,6 +205,59 @@ export class PipelexApiClient {
210
205
  * timeout, not the blocking-execute ceiling.
211
206
  */
212
207
  async requestProduct(method, endpoint, body, options = {}) {
208
+ const res = await this.requestProductAnswer(method, endpoint, body, options);
209
+ return (res.body ? this.readAnswer(method, endpoint, res) : undefined);
210
+ }
211
+ /**
212
+ * A product request whose answer is an object the SDK or its caller reads before anything else
213
+ * — a bulk resolution's `items`, a run record's `error`, an upload grant's `url`. Unlike
214
+ * `requestProduct`, an empty
215
+ * 2xx is no answer here: it and a body that is not a JSON object throw the `ApiResponseError`
216
+ * of an answer the SDK cannot read (see `readObjectAnswerAt`).
217
+ */
218
+ async requestProductObject(method, endpoint, body, options = {}) {
219
+ const res = await this.requestProductAnswer(method, endpoint, body, options);
220
+ return this.readObjectAnswer(method, endpoint, res);
221
+ }
222
+ /**
223
+ * A methods-catalog route answering one stored method (`getMethod`, `createMethod`,
224
+ * `updateMethod`), parsed into the public shape. A stored `mthds` that is neither a string nor
225
+ * absent (`null` stays "no source", which `getMethodClosure` reports), and a stored `python`
226
+ * the SDK cannot parse, are an answer it cannot read, so each throws an `ApiResponseError` built
227
+ * from that answer, the latter with `mthds`'s refusal as `cause`: `runtime`, not retryable,
228
+ * since no change to the call fixes it.
229
+ */
230
+ async requestMethodData(method, endpoint, body) {
231
+ const res = await this.requestProductAnswer(method, endpoint, body);
232
+ const wire = this.readObjectAnswer(method, endpoint, res);
233
+ const source = wire.mthds;
234
+ if (source != null && typeof source !== "string") {
235
+ throw this.unreadableAnswer(method, `/${API_PREFIX}/${endpoint}`, res, "a stored method whose `mthds` field is not a string");
236
+ }
237
+ return methodDataFromWire(wire, (cause) => this.unreadableAnswer(method, `/${API_PREFIX}/${endpoint}`, res, "a stored method whose `python` field could not be read", cause));
238
+ }
239
+ /**
240
+ * One page of a cursor-paged product route (`listMethods`, `listRuns`), read as the platform
241
+ * always serializes it: `items` an array, `next_cursor` a string or `null`. A page breaking
242
+ * either is an answer the SDK cannot read, so it throws the `ApiResponseError` `unreadableAnswer`
243
+ * builds, `runtime` and not retryable, rather than handing the iterators a page they cannot
244
+ * walk: `items` that is not iterable, or a missing cursor that is neither the end nor a next
245
+ * page, which `iterateRuns` would follow forever.
246
+ */
247
+ async requestPage(endpoint) {
248
+ const res = await this.requestProductAnswer("GET", endpoint, undefined);
249
+ const page = this.readObjectAnswer("GET", endpoint, res);
250
+ const { items, next_cursor: nextCursor } = page;
251
+ if (!Array.isArray(items)) {
252
+ throw this.unreadableAnswer("GET", `/${API_PREFIX}/${endpoint}`, res, "a page whose `items` is not an array");
253
+ }
254
+ if (nextCursor !== null && typeof nextCursor !== "string") {
255
+ throw this.unreadableAnswer("GET", `/${API_PREFIX}/${endpoint}`, res, "a page whose `next_cursor` is neither a string nor null");
256
+ }
257
+ return { items: items, nextCursor };
258
+ }
259
+ /** Issue a product request and return its 2xx answer, a non-2xx thrown as `ApiResponseError`. */
260
+ async requestProductAnswer(method, endpoint, body, options = {}) {
213
261
  const res = await this.requestRaw(method, this.url(endpoint), {
214
262
  body,
215
263
  timeoutMs: POLL_REQUEST_TIMEOUT_MS,
@@ -218,14 +266,64 @@ export class PipelexApiClient {
218
266
  if (res.status < 200 || res.status >= 300) {
219
267
  this.throwApiResponseError(method, endpoint, res);
220
268
  }
221
- return (res.body ? JSON.parse(res.body) : undefined);
269
+ return res;
222
270
  }
223
271
  throwApiResponseError(method, endpoint, res) {
272
+ this.throwApiResponseErrorAt(method, `/${API_PREFIX}/${endpoint}`, res);
273
+ }
274
+ /** The JSON body of a `/v1` route's 2xx answer; see `readAnswerAt`. */
275
+ readAnswer(method, endpoint, res) {
276
+ return this.readAnswerAt(method, `/${API_PREFIX}/${endpoint}`, res);
277
+ }
278
+ /** The JSON object body of a `/v1` route's 2xx answer; see `readObjectAnswerAt`. */
279
+ readObjectAnswer(method, endpoint, res) {
280
+ return this.readObjectAnswerAt(method, `/${API_PREFIX}/${endpoint}`, res);
281
+ }
282
+ /**
283
+ * The JSON body of a 2xx answer, naming the route by its `path` from the origin as a refusal
284
+ * does. A body that is not JSON, an empty one included, is an answer the SDK cannot read, so it
285
+ * throws the `ApiResponseError` `unreadableAnswer` builds, with the parse failure as `cause`.
286
+ * The one place a success body is parsed.
287
+ */
288
+ readAnswerAt(method, path, res) {
289
+ try {
290
+ return JSON.parse(res.body);
291
+ }
292
+ catch (err) {
293
+ throw this.unreadableAnswer(method, path, res, res.body ? "a body that is not JSON" : "an empty body where JSON was expected", err);
294
+ }
295
+ }
296
+ /**
297
+ * The JSON body of a 2xx answer from a route that answers an object, read as `readAnswerAt`
298
+ * reads it. A body that is JSON but not an object — `null`, a number, a string, an array — is
299
+ * an answer the SDK cannot read either, and throws the same `ApiResponseError`, with no `cause`.
300
+ */
301
+ readObjectAnswerAt(method, path, res) {
302
+ const answer = this.readAnswerAt(method, path, res);
303
+ if (!isPlainObject(answer)) {
304
+ throw this.unreadableAnswer(method, path, res, "a body that is not an object");
305
+ }
306
+ return answer;
307
+ }
308
+ /**
309
+ * The `ApiResponseError` of a 2xx answer the SDK cannot read, its message
310
+ * `API <method> <path> answered <status> with <what>`: the answer's status, its raw text as
311
+ * `responseBody`, no problem member but the `X-Request-ID` header, the failure that made it
312
+ * unreadable as `cause`, and the verdict the fallback gives a 2xx, `runtime` and not retryable.
313
+ */
314
+ unreadableAnswer(method, path, res, what, cause) {
315
+ 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) } });
316
+ }
317
+ /**
318
+ * Throw the `ApiResponseError` of a non-2xx answer, naming the route by its `path` from the
319
+ * origin (`/v1/...`, or `/health` for the liveness probe).
320
+ */
321
+ throwApiResponseErrorAt(method, path, res) {
224
322
  const { errorType, serverMessage, validationErrors, code, problem, document } = parseErrorBody(res.body);
225
323
  // The body's `request_id` wins; the header is the fallback for a response whose body
226
324
  // carries none (a gateway error page, a non-problem body).
227
325
  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 });
326
+ 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
327
  }
230
328
  /**
231
329
  * Translate a "route absent" 404 (a bare pipelex-api with no platform block)
@@ -267,9 +365,24 @@ export class PipelexApiClient {
267
365
  "running server-side. Poll its results (hosted) or use start().", runId, parseRetryAfter(res.headers), res.headers.get("location"));
268
366
  }
269
367
  // ── Health ────────────────────────────────────────────────────────
368
+ /**
369
+ * The origin-level liveness probe — `GET /health`, which sits at the origin, NOT under the
370
+ * `/v1` prefix. A bare runner serves it; a hosted origin answers `/v1/health` instead, so
371
+ * there this throws.
372
+ *
373
+ * It goes through the same transport as every route: an unreachable origin is an
374
+ * `ApiUnreachableError`, and a non-2xx answer an `ApiResponseError` whose verdict the SDK's
375
+ * fallback reads from the status, since the probe answers no problem document. So is a 2xx
376
+ * whose body is not a JSON object, such as a gateway's HTML page: `runtime`, not retryable.
377
+ */
270
378
  async health() {
271
- // `/health` is origin-level, NOT under the `/v1` prefix.
272
- return this.requestJson("GET", `${this.originUrl}/health`);
379
+ const res = await this.requestRaw("GET", `${this.originUrl}/health`, {
380
+ timeoutMs: POLL_REQUEST_TIMEOUT_MS,
381
+ });
382
+ if (res.status < 200 || res.status >= 300) {
383
+ this.throwApiResponseErrorAt("GET", "/health", res);
384
+ }
385
+ return this.readObjectAnswerAt("GET", "/health", res);
273
386
  }
274
387
  // ── Protocol surface ─────────────────────────────────────────────────
275
388
  /**
@@ -295,9 +408,9 @@ export class PipelexApiClient {
295
408
  Object.keys(api).length === 0 &&
296
409
  Object.keys(hosted).length === 0 &&
297
410
  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().");
411
+ 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
412
  }
300
- assertExclusiveRunSources(options);
413
+ assertRunSourcesExclusive(options);
301
414
  assertMethodRefPairsWithNothing(options);
302
415
  const request = {
303
416
  pipe_code: options.pipe_code,
@@ -323,7 +436,7 @@ export class PipelexApiClient {
323
436
  }
324
437
  // Wrap the base result in the enriched subtype (adds the `.main_stuff` accessor; the
325
438
  // `main_stuff_name` extension + working memory ride `pipe_output`).
326
- return new PipelexExecuteResult(JSON.parse(res.body));
439
+ return new PipelexExecuteResult(this.readObjectAnswer("POST", "execute", res));
327
440
  }
328
441
  catch (err) {
329
442
  if (err instanceof RunStillRunningError)
@@ -361,9 +474,9 @@ export class PipelexApiClient {
361
474
  Object.keys(api).length === 0 &&
362
475
  Object.keys(hosted).length === 0 &&
363
476
  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().");
477
+ 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
478
  }
366
- assertExclusiveRunSources(options);
479
+ assertRunSourcesExclusive(options);
367
480
  assertMethodRefPairsWithNothing(options);
368
481
  // `?? undefined` so JSON.stringify drops absent fields from the wire body.
369
482
  const request = {
@@ -401,7 +514,7 @@ export class PipelexApiClient {
401
514
  if (res.status < 200 || res.status >= 300) {
402
515
  this.throwApiResponseError("POST", "start", res);
403
516
  }
404
- return JSON.parse(res.body);
517
+ return this.readObjectAnswer("POST", "start", res);
405
518
  }
406
519
  /**
407
520
  * Parse, validate, and dry-run an MTHDS bundle — `POST /v1/validate`.
@@ -480,19 +593,19 @@ export class PipelexApiClient {
480
593
  // A selector object. The illegal shapes are compile errors for typed
481
594
  // callers (`ValidateMethodSelector` pins the other key to `never`); the
482
595
  // runtime checks back them for untyped (JS) callers — a typed
483
- // `PipelineRequestError`, never a native TypeError off a null source —
596
+ // `RequestArgumentError`, never a native TypeError off a null source —
484
597
  // mirroring the server's strict tooling XOR instead of silently picking.
485
598
  if (source === null || source === undefined || typeof source !== "object") {
486
- throw new PipelineRequestError("validate() takes inline contents (a string[]) or a method selector object " +
599
+ throw new RequestArgumentError("validate() takes inline contents (a string[]) or a method selector object " +
487
600
  "({ method_ref } or { method_id }).");
488
601
  }
489
602
  const methodRef = nonEmptyString(source.method_ref);
490
603
  const methodId = nonEmptyString(source.method_id);
491
604
  if ((methodRef === undefined) === (methodId === undefined)) {
492
- throw new PipelineRequestError("validate() takes exactly one method selector: inline contents, { method_ref }, or { method_id }.");
605
+ throw new RequestArgumentError("validate() takes exactly one method selector: inline contents, { method_ref }, or { method_id }.");
493
606
  }
494
607
  if (mthdsSources !== undefined) {
495
- throw new PipelineRequestError("mthds_sources labels inline mthds_contents; a method_ref / method_id validation gets " +
608
+ throw new RequestArgumentError("mthds_sources labels inline mthds_contents; a method_ref / method_id validation gets " +
496
609
  "its source labels from the package's (or the stored method's) real file names.");
497
610
  }
498
611
  if (methodRef !== undefined)
@@ -512,7 +625,7 @@ export class PipelexApiClient {
512
625
  if (res.status < 200 || res.status >= 300) {
513
626
  this.throwApiResponseError("POST", "validate", res);
514
627
  }
515
- return JSON.parse(res.body);
628
+ return this.readObjectAnswer("POST", "validate", res);
516
629
  }
517
630
  /**
518
631
  * Validate paired MTHDS files while preserving URI attribution for diagnostics.
@@ -524,7 +637,7 @@ export class PipelexApiClient {
524
637
  */
525
638
  async validateFiles(files, options = {}) {
526
639
  if (files.length === 0) {
527
- throw new PipelineRequestError("At least one MTHDS file must be provided to validateFiles().");
640
+ throw new RequestArgumentError("At least one MTHDS file must be provided to validateFiles().");
528
641
  }
529
642
  const mthdsContents = files.map((file) => file.content);
530
643
  const hasAnyUri = files.some((file) => file.uri !== undefined);
@@ -638,7 +751,7 @@ export class PipelexApiClient {
638
751
  if (res.status < 200 || res.status >= 300) {
639
752
  this.throwApiResponseError("POST", endpoint, res);
640
753
  }
641
- return JSON.parse(res.body);
754
+ return this.readObjectAnswer("POST", endpoint, res);
642
755
  }
643
756
  /** The model deck the runner can route to — `GET /v1/models[?type=]`. */
644
757
  async models(category) {
@@ -649,7 +762,7 @@ export class PipelexApiClient {
649
762
  if (res.status < 200 || res.status >= 300) {
650
763
  this.throwApiResponseError("GET", endpoint, res);
651
764
  }
652
- return JSON.parse(res.body);
765
+ return this.readObjectAnswer("GET", endpoint, res);
653
766
  }
654
767
  /**
655
768
  * Protocol + implementation versions — `GET /v1/version` (always public).
@@ -662,7 +775,7 @@ export class PipelexApiClient {
662
775
  if (res.status < 200 || res.status >= 300) {
663
776
  this.throwApiResponseError("GET", "version", res);
664
777
  }
665
- return JSON.parse(res.body);
778
+ return this.readObjectAnswer("GET", "version", res);
666
779
  }
667
780
  // ── Crate extensions (Pipelex API — `/v1/resolve`, `/v1/codegen`, `/v1/pipe-io`) ──
668
781
  //
@@ -776,8 +889,10 @@ export class PipelexApiClient {
776
889
  * Self-healing: a finished-but-unrecorded run resolves to its true terminal
777
890
  * status on read. `degraded: true` means Temporal was unreachable and
778
891
  * `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).
892
+ * server's backoff hint when present. A failed run's stored report, `error`,
893
+ * is checked field by field (see `RunErrorReport`). Throws
894
+ * `RunLifecycleUnavailableError` when the lifecycle routes are absent (a bare
895
+ * runner).
781
896
  */
782
897
  async getRunStatus(runId, options = {}) {
783
898
  const endpoint = `${RUNS}/${encodeURIComponent(runId)}/status`;
@@ -790,7 +905,7 @@ export class PipelexApiClient {
790
905
  if (res.status < 200 || res.status >= 300) {
791
906
  this.throwApiResponseError("GET", endpoint, res);
792
907
  }
793
- const run = JSON.parse(res.body);
908
+ const run = withCheckedReport(this.readObjectAnswer("GET", endpoint, res));
794
909
  const retryAfter = parseRetryAfter(res.headers);
795
910
  return retryAfter !== null ? { ...run, retry_after_seconds: retryAfter } : run;
796
911
  }
@@ -843,7 +958,7 @@ export class PipelexApiClient {
843
958
  if (res.status < 200 || res.status >= 300) {
844
959
  this.throwApiResponseError("GET", endpoint, res);
845
960
  }
846
- const result = JSON.parse(res.body);
961
+ const result = this.readObjectAnswer("GET", endpoint, res);
847
962
  if (selectionIncludesMainStuff(options.artifacts) && result.main_stuff == null) {
848
963
  throw new MissingMainStuffError(`Completed run '${runId}' returned no main stuff — a completed run always delivers a main stuff.`, runId);
849
964
  }
@@ -944,8 +1059,7 @@ export class PipelexApiClient {
944
1059
  if (query.cursor !== undefined)
945
1060
  params.set("cursor", query.cursor);
946
1061
  const suffix = params.toString();
947
- const page = await this.requestProduct("GET", suffix ? `methods?${suffix}` : "methods");
948
- return { items: page.items, nextCursor: page.next_cursor };
1062
+ return this.requestPage(suffix ? `methods?${suffix}` : "methods");
949
1063
  }
950
1064
  /**
951
1065
  * Every method in the catalog, streamed — follows the cursor for you.
@@ -997,16 +1111,15 @@ export class PipelexApiClient {
997
1111
  // partial answer with no error is back to the original bug one layer
998
1112
  // up; an error is the only honest response to a server that will not
999
1113
  // 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.`);
1114
+ throw new PagingNotTerminatingError(`listMethods did not terminate after ${MAX_PAGES} pages; refusing to keep paging. ` +
1115
+ `This is a server-side fault, not a coverage limit.`, MAX_PAGES);
1002
1116
  }
1003
1117
  cursor = page.nextCursor;
1004
1118
  }
1005
1119
  }
1006
1120
  /** Fetch one method by id — `GET /v1/methods/{id}`. */
1007
1121
  async getMethod(methodId) {
1008
- const wire = await this.requestProduct("GET", `methods/${encodeURIComponent(methodId)}`);
1009
- return methodDataFromWire(wire);
1122
+ return this.requestMethodData("GET", `methods/${encodeURIComponent(methodId)}`);
1010
1123
  }
1011
1124
  /**
1012
1125
  * Resolve a stored method's id into its runnable MTHDS closure — a client-side
@@ -1038,13 +1151,11 @@ export class PipelexApiClient {
1038
1151
  }
1039
1152
  /** Create a method — `POST /v1/methods`. */
1040
1153
  async createMethod(input) {
1041
- const wire = await this.requestProduct("POST", "methods", methodWriteToWire(input));
1042
- return methodDataFromWire(wire);
1154
+ return this.requestMethodData("POST", "methods", methodWriteToWire(input));
1043
1155
  }
1044
1156
  /** Replace a method (rename = changed `name`) — `PUT /v1/methods/{id}`. */
1045
1157
  async updateMethod(methodId, input) {
1046
- const wire = await this.requestProduct("PUT", `methods/${encodeURIComponent(methodId)}`, methodWriteToWire(input));
1047
- return methodDataFromWire(wire);
1158
+ return this.requestMethodData("PUT", `methods/${encodeURIComponent(methodId)}`, methodWriteToWire(input));
1048
1159
  }
1049
1160
  /**
1050
1161
  * Erase a method and everything it produced — `DELETE /v1/methods/{id}`.
@@ -1149,7 +1260,7 @@ export class PipelexApiClient {
1149
1260
  * only: a deployment without the route answers a `404` `ApiResponseError`.
1150
1261
  */
1151
1262
  async resolveStorageUrls(input, options = {}) {
1152
- return this.requestProduct("POST", "resolve-storage-url/bulk", input, options);
1263
+ return this.requestProductObject("POST", "resolve-storage-url/bulk", input, options);
1153
1264
  }
1154
1265
  /**
1155
1266
  * Resolve a whole list of `pipelex-storage://` references through the bulk
@@ -1182,9 +1293,18 @@ export class PipelexApiClient {
1182
1293
  async downloadArtifacts(request) {
1183
1294
  return downloadArtifactsImpl(this, request);
1184
1295
  }
1185
- /** Upload a base64 file — `POST /v1/upload`. */
1296
+ /**
1297
+ * Upload a base64 file — `POST /v1/upload`. An answer with no non-empty string `uri` names no
1298
+ * stored file, so it is an answer the SDK cannot read: an `ApiResponseError`, `runtime` and
1299
+ * not retryable, which `uploadFile` wraps as an `UploadTransportError` with code `unexpected`.
1300
+ */
1186
1301
  async upload(input) {
1187
- return this.requestProduct("POST", "upload", input);
1302
+ const res = await this.requestProductAnswer("POST", "upload", input);
1303
+ const uploaded = this.readObjectAnswer("POST", "upload", res);
1304
+ if (typeof uploaded.uri !== "string" || uploaded.uri === "") {
1305
+ throw this.unreadableAnswer("POST", `/${API_PREFIX}/upload`, res, "an answer with no string `uri`");
1306
+ }
1307
+ return uploaded;
1188
1308
  }
1189
1309
  /**
1190
1310
  * Request a grant to upload one file straight to storage — `POST /v1/upload/grant`.
@@ -1203,7 +1323,7 @@ export class PipelexApiClient {
1203
1323
  * `docs/input-preparation.md`.
1204
1324
  */
1205
1325
  async requestUploadGrant(input, options = {}) {
1206
- return this.requestProduct("POST", "upload/grant", input, options);
1326
+ return this.requestProductObject("POST", "upload/grant", input, options);
1207
1327
  }
1208
1328
  /**
1209
1329
  * Upload one local asset and return its {@link UploadRecord} — the single-asset
@@ -1277,8 +1397,8 @@ export class PipelexApiClient {
1277
1397
  params.set("limit", String(query.limit));
1278
1398
  if (query.cursor !== undefined)
1279
1399
  params.set("cursor", query.cursor);
1280
- const page = await this.requestProduct("GET", `runs?${params.toString()}`);
1281
- return { items: page.items, nextCursor: page.next_cursor };
1400
+ const page = await this.requestPage(`runs?${params.toString()}`);
1401
+ return { items: page.items.map(withCheckedReport), nextCursor: page.nextCursor };
1282
1402
  }
1283
1403
  /**
1284
1404
  * Every run of a method, streamed — follows the cursor for you.
@@ -1333,10 +1453,11 @@ export class PipelexApiClient {
1333
1453
  *
1334
1454
  * The ONLY call that returns `mthds_contents` (what the run actually
1335
1455
  * executed) and `inputs`. Kept off the status read, which pollers hit every
1336
- * few seconds.
1456
+ * few seconds. A failed run's stored report, `error`, is checked field by
1457
+ * field (see `RunErrorReport`).
1337
1458
  */
1338
1459
  async getRunDetail(runId) {
1339
- return this.requestProduct("GET", `runs/${encodeURIComponent(runId)}`);
1460
+ return withCheckedReport(await this.requestProductObject("GET", `runs/${encodeURIComponent(runId)}`));
1340
1461
  }
1341
1462
  /** Patch a run's status (admin/manual) — `PUT /v1/runs/{id}`. */
1342
1463
  async updateRun(runId, input) {
@@ -1458,7 +1579,7 @@ function buildExtensions(extra) {
1458
1579
  const snapshot = { ...extra };
1459
1580
  const reserved = Object.keys(snapshot).filter((key) => RESERVED_EXTRA_KEYS.has(key));
1460
1581
  if (reserved.length > 0) {
1461
- throw new PipelineRequestError(`extra carries reserved request args [${reserved.sort().join(", ")}] — pass them as named options instead.`);
1582
+ throw new RequestArgumentError(`extra carries reserved request args [${reserved.sort().join(", ")}] — pass them as named options instead.`);
1462
1583
  }
1463
1584
  const result = {};
1464
1585
  for (const [key, value] of Object.entries(snapshot)) {
@@ -1508,6 +1629,23 @@ function buildHostedRunExtensions(options) {
1508
1629
  const methodId = nonEmptyString(options.method_id);
1509
1630
  return methodId === undefined ? {} : { method_id: methodId };
1510
1631
  }
1632
+ /**
1633
+ * The standard's run-source exclusivity check (`assertExclusiveRunSources`: the two bundle
1634
+ * encodings together, or a bundle beside inline contents), its refusal rethrown as a
1635
+ * `RequestArgumentError` with the same message and the standard's error as `cause`, so it carries
1636
+ * a verdict like every other argument the client refuses.
1637
+ */
1638
+ function assertRunSourcesExclusive(options) {
1639
+ try {
1640
+ assertExclusiveRunSources(options);
1641
+ }
1642
+ catch (err) {
1643
+ if (err instanceof PipelineRequestError) {
1644
+ throw new RequestArgumentError(err.message, { cause: err });
1645
+ }
1646
+ throw err;
1647
+ }
1648
+ }
1511
1649
  /**
1512
1650
  * Enforce the run routes' `method_ref` exclusivity, mirroring the server's own
1513
1651
  * 422s so an illegal pairing fails before anything hits the wire. A
@@ -1528,13 +1666,13 @@ function assertMethodRefPairsWithNothing(options) {
1528
1666
  if (nonEmptyString(options.method_ref) === undefined)
1529
1667
  return;
1530
1668
  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.");
1669
+ throw new RequestArgumentError("method_ref and inline mthds_contents are mutually exclusive; send one or the other.");
1532
1670
  }
1533
1671
  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.");
1672
+ throw new RequestArgumentError("method_ref and a method bundle (bundle_b64 / files) are mutually exclusive; send one or the other.");
1535
1673
  }
1536
1674
  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 " +
1675
+ throw new RequestArgumentError("method_ref and method_id are mutually exclusive: an address run carries its own provenance " +
1538
1676
  "and takes no run-history linkage id. Send exactly one method selector.");
1539
1677
  }
1540
1678
  }
@@ -1634,21 +1772,31 @@ const REQUEST_ID_HEADER = "x-request-id";
1634
1772
  * `Run finished with status <STATUS>` the platform keeps for exactly this reader, so the status
1635
1773
  * word is recovered from it then; a `409` that yields no known status either way (the one this
1636
1774
  * 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.
1775
+ * still says what happened. The report is checked field by field by `readRunErrorReport`: an
1776
+ * object is taken as the report, each named field kept when it has its declared type, and
1777
+ * anything that is not an object reads as no report.
1639
1778
  */
1640
1779
  function runResultFailed(runId, body) {
1641
1780
  const { serverMessage, document } = parseErrorBody(body);
1642
1781
  const message = serverMessage ?? "Run finished without a result.";
1643
- const rawReport = document?.error;
1644
1782
  return {
1645
1783
  state: "failed",
1646
1784
  pipeline_run_id: runId,
1647
1785
  status: knownRunStatus(document?.run_status) ?? statusFromDetail(message) ?? "FAILED",
1648
1786
  message,
1649
- error: isPlainObject(rawReport) ? rawReport : null,
1787
+ error: readRunErrorReport(document?.error),
1650
1788
  };
1651
1789
  }
1790
+ /**
1791
+ * A run record with its stored report checked by `readRunErrorReport`. Only when the record
1792
+ * carries the `error` key, so a server that does not serve it still leaves it absent; a record
1793
+ * that is not an object is returned as it came.
1794
+ */
1795
+ function withCheckedReport(record) {
1796
+ if (!isPlainObject(record) || !Object.hasOwn(record, "error"))
1797
+ return record;
1798
+ return { ...record, error: readRunErrorReport(record.error) };
1799
+ }
1652
1800
  function knownRunStatus(value) {
1653
1801
  return typeof value === "string" && KNOWN_RUN_STATUSES.includes(value)
1654
1802
  ? value
@@ -1671,8 +1819,9 @@ function statusFromDetail(detail) {
1671
1819
  * on a non-JSON or non-object body.
1672
1820
  *
1673
1821
  * 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.
1822
+ * malformed member reads as absent rather than as a wrong value, and the nested members
1823
+ * (`provider_metadata`, `migration`, `validation_errors`, `errors`) are checked field by field as a
1824
+ * stored report's are; `document` keeps the decoded object whole, members named or not.
1676
1825
  */
1677
1826
  function parseErrorBody(body) {
1678
1827
  const empty = {
@@ -1715,10 +1864,10 @@ function parseErrorBody(body) {
1715
1864
  serverMessage = root.message;
1716
1865
  // `validation_errors` rides the problem envelope as a top-level array (the
1717
1866
  // 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.
1867
+ // too — it describes the caller's own bundle, not server internals). Each item is
1868
+ // kept when it is an object with a string `category` and `message`, as on a stored report.
1720
1869
  const validationErrors = Array.isArray(root.validation_errors)
1721
- ? root.validation_errors
1870
+ ? root.validation_errors.filter(isValidationItem)
1722
1871
  : undefined;
1723
1872
  // The platform's closed native code (`conflict`, `not_found`, …), one-to-one with `type`.
1724
1873
  const code = typeof root.code === "string" ? root.code : undefined;
@@ -1733,19 +1882,18 @@ function parseErrorBody(body) {
1733
1882
  userAction: parseUserAction(root.user_action),
1734
1883
  model: stringMember(root.model),
1735
1884
  provider: stringMember(root.provider),
1885
+ // The nested members are checked one level down by the readers a stored report goes
1886
+ // through, so a misfit field reads as absent here exactly as it does there.
1736
1887
  providerMetadata: isPlainObject(root.provider_metadata)
1737
- ? root.provider_metadata
1888
+ ? readProviderMetadata(root.provider_metadata)
1738
1889
  : undefined,
1739
- migration: isPlainObject(root.migration) ? root.migration : undefined,
1890
+ migration: isPlainObject(root.migration) ? readMigration(root.migration) : undefined,
1740
1891
  errors: Array.isArray(root.errors)
1741
- ? root.errors.filter(isPlainObject)
1892
+ ? root.errors.filter(isPlainObject).map(readFieldError)
1742
1893
  : undefined,
1743
1894
  };
1744
1895
  return { errorType, serverMessage, validationErrors, code, problem, document: root };
1745
1896
  }
1746
- function isPlainObject(value) {
1747
- return typeof value === "object" && value !== null && !Array.isArray(value);
1748
- }
1749
1897
  /** A problem member kept only when it is a non-empty string. */
1750
1898
  function stringMember(value) {
1751
1899
  return typeof value === "string" && value.length > 0 ? value : undefined;