@pipelex/sdk 0.42.0 → 0.43.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
@@ -12,9 +12,10 @@ import { encodeBundleResources, hasBundleResources } from "./bundle-resources.js
12
12
  import { prepareInputs as prepareInputsImpl } from "./prepare-inputs.js";
13
13
  import { downloadArtifacts as downloadArtifactsImpl, fetchArtifact as fetchArtifactImpl, resolveArtifacts as resolveArtifactsImpl, } from "./artifacts.js";
14
14
  import { PipelexExecuteResult, resultsFromExecute } from "./execute-result.js";
15
+ import { ExactJsonUnsupportedError, parseExactJson, stringifyExactJson } from "./exact-json.js";
15
16
  import { MAX_TIMER_DELAY_MS, isTimerDelay } from "./timers.js";
16
17
  // A pure RUNAWAY guard on the paged-list iterators (`iterateMethods`,
17
- // `iterateRuns`), deliberately not a coverage limit.
18
+ // `iterateRuns`, `iterateMethodExamples`), deliberately not a coverage limit.
18
19
  //
19
20
  // It counts TOTAL pages, not empty ones. An earlier version capped consecutive
20
21
  // empty pages, which is the wrong axis: an empty page whose cursor ADVANCED is
@@ -31,7 +32,8 @@ import { MAX_TIMER_DELAY_MS, isTimerDelay } from "./timers.js";
31
32
  const MAX_PAGES = 10_000;
32
33
  /**
33
34
  * Follow a paged list's cursor until the server says there is no more, yielding every row —
34
- * the one loop behind `iterateMethods` and `iterateRuns`, so the two cannot drift apart.
35
+ * the one loop behind `iterateMethods`, `iterateRuns` and `iterateMethodExamples`, so they cannot
36
+ * drift apart.
35
37
  *
36
38
  * It yields lazily: a caller that breaks early stops the requests. It stops when `nextCursor`
37
39
  * is `null`, the normal end, and when the server hands back the cursor it was just sent, which
@@ -79,6 +81,8 @@ async function* followCursor(route, readPage) {
79
81
  /** Decoders of an answer's bytes: the strict one tells a body that is not UTF-8. */
80
82
  const STRICT_UTF8 = new TextDecoder("utf-8", { fatal: true });
81
83
  const LENIENT_UTF8 = new TextDecoder("utf-8");
84
+ /** `JSON.parse`, the `JsonReader` an answer is read with unless its route names another. */
85
+ const readJson = (text) => JSON.parse(text);
82
86
  const STARTS_RUN = Object.freeze({ mayStartRun: true });
83
87
  /** Hosted default — the client composes every endpoint as `{base}/v1/{endpoint}`. */
84
88
  export const DEFAULT_API_BASE_URL = "https://api.pipelex.com";
@@ -127,21 +131,173 @@ function withWirePython(input) {
127
131
  return python === undefined ? rest : { ...rest, python: serializeMethodFiles(python) };
128
132
  }
129
133
  /**
130
- * The path of a method route, `methods/{id}`, for a bare catalog id. The method routes address
134
+ * A request's body as the JSON text it is sent as, written by `stringifyExactJson` in one pass,
135
+ * so a `bigint` read from an answer carrying a person's data goes back as its exact digits, and
136
+ * every getter and `toJSON` in the body runs once. A body JSON cannot carry, one holding a cycle
137
+ * or whose own `toJSON` throws for instance, is the caller's argument: it is refused with
138
+ * `RequestArgumentError`, the failure as its `cause`, before anything is sent, and `method` and
139
+ * `url` name the request in the message.
140
+ */
141
+ function requestBodyText(method, url, body) {
142
+ try {
143
+ return stringifyExactJson(body);
144
+ }
145
+ catch (err) {
146
+ const reason = err instanceof Error ? err.message : String(err);
147
+ throw new RequestArgumentError(`The body of ${method} ${new URL(url).pathname} cannot be written as JSON, so nothing was ` +
148
+ `sent: ${reason}`, { cause: err });
149
+ }
150
+ }
151
+ /**
152
+ * The query string of a product list route, `?` and its parameters in the order given, or `""`
153
+ * when none is kept, as the Python SDK's `_product_query` builds it. Each entry is kept on
154
+ * presence (`!== undefined`), never truthiness: omission means the caller did not ask, while an
155
+ * explicit empty `q`, cursor or date is bad input the API should refuse, not something to drop
156
+ * into an unfiltered query that returns everything and reads as working. `false` is a filter of
157
+ * its own, sent as `false`, and an array is sent as one repeated parameter per value.
158
+ */
159
+ function productQuery(params) {
160
+ const search = new URLSearchParams();
161
+ for (const [key, value] of Object.entries(params)) {
162
+ if (value === undefined)
163
+ continue;
164
+ if (typeof value === "object")
165
+ for (const item of value)
166
+ search.append(key, item);
167
+ else
168
+ search.set(key, String(value));
169
+ }
170
+ const text = search.toString();
171
+ return text ? `?${text}` : "";
172
+ }
173
+ /** `items` joined as a sentence names them, the last after `conjunction`: `a`, `a or b`, `a, b or c`. */
174
+ function inWords(items, conjunction) {
175
+ if (items.length < 2)
176
+ return items.join("");
177
+ return `${items.slice(0, -1).join(", ")} ${conjunction} ${items[items.length - 1]}`;
178
+ }
179
+ /** What each id a route puts in its path is, as a refusal of it names it. */
180
+ const METHOD_ID_ARGUMENT = "a method id, the method's non-empty bare catalog id";
181
+ const EXAMPLE_ID_ARGUMENT = "an example id, the example's non-empty `example_id`";
182
+ const RUN_ID_ARGUMENT = "a run id, the run's non-empty `pipeline_run_id`";
183
+ const ORG_ID_ARGUMENT = "an organization id, the organization's non-empty `org_id`";
184
+ const API_KEY_ID_ARGUMENT = "an API key id, the key's non-empty `id`";
185
+ /** What the run id of each example write is, as a refusal of it names it. */
186
+ const CREATE_RUN_ID_ARGUMENT = "a run_id naming a run of this method that used these inputs, a non-empty string, or none";
187
+ const RECORD_RUN_ID_ARGUMENT = "a run_id naming a run of this method that used the example's inputs, a non-empty string";
188
+ /**
189
+ * The refusal of an argument a call cannot take, worded as every such refusal is:
190
+ * `<call>() takes <what>; got <got>.`, with what made it unusable as `cause` when there is one.
191
+ */
192
+ function argumentRefusal(call, what, got, cause) {
193
+ const message = `${call}() takes ${what}; got ${got}.`;
194
+ return new RequestArgumentError(message, cause === undefined ? undefined : { cause });
195
+ }
196
+ /**
197
+ * A caller's id, checked to be a non-empty string: the one check every id a call takes goes
198
+ * through, whether it enters a path (`idPathSegment`) or a body (a run id an example write
199
+ * records). Anything else is refused with `RequestArgumentError` before anything is sent, `call`
200
+ * naming the method in the message and `what` the id it takes.
201
+ */
202
+ function requireNonEmptyId(value, call, what) {
203
+ if (typeof value === "string" && value !== "")
204
+ return value;
205
+ let got;
206
+ if (typeof value === "string")
207
+ got = "an empty string";
208
+ else
209
+ got = value === null ? "null" : typeof value;
210
+ throw argumentRefusal(call, what, got);
211
+ }
212
+ /**
213
+ * A caller's value as the refusal of a number argument names it: a number by its value, anything
214
+ * else by its type, as `requireNonEmptyId` names a value, and `null` as itself. Never by `String`,
215
+ * which throws for an object with no prototype and would replace the refusal with a `TypeError`.
216
+ */
217
+ function describeNumberArgument(value) {
218
+ if (typeof value === "number")
219
+ return String(value);
220
+ return value === null ? "null" : typeof value;
221
+ }
222
+ /**
223
+ * A caller's id as one segment of a route's path, percent-encoded: the one place an id enters a
224
+ * path. `encodeURIComponent` escapes every character a path gives meaning to but the dot, so an id
225
+ * of `.` or `..` would reach the URL as a dot segment, which the URL resolves before the request
226
+ * leaves: `methods/mt_x/examples/..` is sent as `methods/mt_x/`, and deleting that "example"
227
+ * would erase the method. An empty id addresses the route above it the same way, `runs/` for a
228
+ * run. So a value that is not a non-empty string (`requireNonEmptyId`) and a dot segment are each
229
+ * refused before anything is sent, and so is a string holding a lone surrogate, which has no
230
+ * UTF-8 spelling and so no percent-encoding: `encodeURIComponent` throws a `URIError` for it.
231
+ * `call` names the method in the message and `what` the id it takes. An id spelling a dot in
232
+ * percent-encoding (`%2e`) is no dot segment once its `%` is escaped.
233
+ */
234
+ function idPathSegment(value, call, what) {
235
+ const id = requireNonEmptyId(value, call, what);
236
+ if (id === "." || id === "..") {
237
+ throw argumentRefusal(call, what, `"${id}", a dot segment, which the URL would resolve into another route's path`);
238
+ }
239
+ try {
240
+ return encodeURIComponent(id);
241
+ }
242
+ catch (err) {
243
+ const got = "a string holding a lone surrogate, which a URL cannot carry";
244
+ throw argumentRefusal(call, what, got, err);
245
+ }
246
+ }
247
+ /**
248
+ * The path of a method route, `methods/{id}`, for a bare catalog id, its segment refused as
249
+ * `idPathSegment` refuses one; `call` names the method in the message. The method routes address
131
250
  * the method itself, never one of its versions, and the platform does not parse a suffix there:
132
251
  * `mt_x@3` would be looked up as an id of its own and answer `404 not_found`, which reads as a
133
252
  * method that does not exist. So a suffixed id is refused before anything is sent, saying how to
134
253
  * read what it names. Stripping it instead would answer the draft for a caller that named a
135
254
  * version.
136
255
  */
137
- function methodPath(methodId) {
138
- if (typeof methodId === "string" && methodId.includes("@")) {
256
+ function methodPath(methodId, call) {
257
+ const segment = idPathSegment(methodId, call, METHOD_ID_ARGUMENT);
258
+ if (methodId.includes("@")) {
139
259
  throw new RequestArgumentError(`"${methodId}" carries a version suffix, and the method routes take a bare catalog id: ` +
140
260
  "they address the method itself, never one of its versions. Strip the suffix with " +
141
261
  "parseMethodSelector, and read a published version with getMethodVersion.");
142
262
  }
143
- return `methods/${encodeURIComponent(methodId)}`;
263
+ return `methods/${segment}`;
144
264
  }
265
+ /**
266
+ * The path of one saved example, `methods/{id}/examples/{example_id}`, for a bare catalog id and
267
+ * an example id, each refused as `methodPath` and `idPathSegment` refuse one: an empty example id
268
+ * would address the examples list rather than an example, and `..` the method itself. `call`
269
+ * names the method in the message.
270
+ */
271
+ function methodExamplePath(methodId, exampleId, call) {
272
+ const path = methodPath(methodId, call);
273
+ return `${path}/examples/${idPathSegment(exampleId, call, EXAMPLE_ID_ARGUMENT)}`;
274
+ }
275
+ const isString = (value) => typeof value === "string";
276
+ /**
277
+ * Each field of a `MethodExample`, what it must be, and the check it must pass: every field the
278
+ * Python SDK's model requires, held to the same strict types it holds them to, so both SDKs refuse
279
+ * the same answers, the ones a caller acts on (`example_id`, `inputs`, `input_revision`,
280
+ * `latest_run_id`) among them. `latest_run_id` must be present, `null` saying there is no latest
281
+ * run, and `input_revision` must be a safe integer, so a string, a boolean, a fraction and an
282
+ * integer past the safe range, which `parseExactJson` reads as a `bigint`, are each refused by
283
+ * both. The one spelling they read apart is a revision of `2.0`: JavaScript reads it as the number
284
+ * `2` and cannot tell it from `2`, while the Python SDK refuses the float.
285
+ *
286
+ * Keyed by the fields of `MethodExample` and checked against them by `satisfies`, so a field
287
+ * added to the interface without its row here, or a row naming no field of it, fails to compile.
288
+ * The rows are checked in the order written.
289
+ */
290
+ const METHOD_EXAMPLE_FIELDS = {
291
+ example_id: ["a string", isString],
292
+ method_id: ["a string", isString],
293
+ name: ["a string", isString],
294
+ inputs: ["an object", isPlainObject],
295
+ input_revision: ["an integer", (value) => Number.isSafeInteger(value)],
296
+ latest_run_id: ["a string or null", (value) => value === null || isString(value)],
297
+ created_by_user_id: ["a string", isString],
298
+ created_at: ["a string", isString],
299
+ updated_at: ["a string", isString],
300
+ };
145
301
  export class PipelexApiClient {
146
302
  apiKey;
147
303
  baseUrl;
@@ -216,6 +372,9 @@ export class PipelexApiClient {
216
372
  */
217
373
  async requestRaw(method, url, options = {}) {
218
374
  const hasBody = options.body !== undefined;
375
+ // Written before the request is made, so a body JSON cannot carry is refused as the caller's
376
+ // argument rather than caught below and reported as a network failure.
377
+ const bodyText = hasBody ? requestBodyText(method, url, options.body) : undefined;
219
378
  const headers = this.requestHeaders(hasBody, options.idempotencyKey);
220
379
  const timeoutMs = options.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
221
380
  // A longer delay overflows the timer, which then fires at once as a false timeout.
@@ -239,7 +398,7 @@ export class PipelexApiClient {
239
398
  response = await fetch(url, {
240
399
  method,
241
400
  headers,
242
- body: hasBody ? JSON.stringify(options.body) : undefined,
401
+ body: bodyText,
243
402
  signal: controller.signal,
244
403
  });
245
404
  // The body streams after the headers; keep the timer/abort armed until it
@@ -332,16 +491,46 @@ export class PipelexApiClient {
332
491
  return withParsedPython(wire, (cause) => this.unreadableAnswer(method, `/${API_PREFIX}/${endpoint}`, res, `${subject} whose \`python\` field could not be read`, cause));
333
492
  }
334
493
  /**
335
- * One page of a cursor-paged product route (`listMethods`, `listMethodVersions`, `listRuns`),
336
- * read as the platform always serializes it: `items` an array, `next_cursor` a string or
337
- * `null`. A page breaking either is an answer the SDK cannot read, so it throws the
338
- * `ApiResponseError` `unreadableAnswer` builds, `runtime` and not retryable, rather than
339
- * handing the iterators a page they cannot walk: `items` that is not iterable, or a missing
340
- * cursor that is neither the end nor a next page, which `iterateRuns` would follow forever.
494
+ * A verdict, the `200` a route answers discriminated on `outcome` with the object the verdict
495
+ * is about as one `member`: `publishMethod`'s `method`, `recordMethodExampleLatestRun`'s
496
+ * `example`. The answer is read as `readObjectAnswer` reads it, then an `outcome` that is none
497
+ * of `outcomes` is an answer the SDK cannot read, and so is a `member` that is not an object,
498
+ * the check `readObjectAnswerAt` holds a route's whole answer to: each throws the
499
+ * `ApiResponseError` `unreadableAnswer` builds, `verdict` naming the answer in the message.
500
+ * A route whose member has a reader of its own passes it as `readMember`, which then checks the
501
+ * member in place of that object check, so the member is checked once. `parse` reads the
502
+ * answer's text, `readJson` unless the route names another. Returns the answer, its `outcome`
503
+ * and the member as `subject`, for the route to read further.
341
504
  */
342
- async requestPage(endpoint) {
505
+ readVerdict(method, endpoint, res, shape) {
506
+ const answer = this.readObjectAnswer(method, endpoint, res, {}, shape.parse);
507
+ const outcome = answer.outcome;
508
+ const subject = answer[shape.member];
509
+ if (!shape.outcomes.includes(outcome)) {
510
+ const expected = inWords(shape.outcomes.map((name) => `\`${name}\``), "or");
511
+ throw this.unreadableAnswer(method, `/${API_PREFIX}/${endpoint}`, res, `${shape.verdict} whose \`outcome\` is not ${expected}`);
512
+ }
513
+ if (shape.readMember !== undefined) {
514
+ return { answer, outcome, subject: shape.readMember(subject) };
515
+ }
516
+ if (!isPlainObject(subject)) {
517
+ throw this.unreadableAnswer(method, `/${API_PREFIX}/${endpoint}`, res, `${shape.verdict} whose \`${shape.member}\` is not an object`);
518
+ }
519
+ return { answer, outcome, subject: subject };
520
+ }
521
+ /**
522
+ * One page of a cursor-paged product route (`listMethods`, `listMethodVersions`,
523
+ * `listMethodExamples`, `listRuns`), read as the platform always serializes it: `items` an
524
+ * array, `next_cursor` a string or `null`. A page breaking either is an answer the SDK cannot
525
+ * read, so it throws the `ApiResponseError` `unreadableAnswer` builds, `runtime` and not
526
+ * retryable, rather than handing the iterators a page they cannot walk: `items` that is not
527
+ * iterable, or a missing cursor that is neither the end nor a next page, which `iterateRuns`
528
+ * would follow forever. A route reading its rows itself passes `rows`: `parse` reads the page's
529
+ * text, and `item` reads each row, throwing as `unreadableAnswer` does for one it cannot read.
530
+ */
531
+ async requestPage(endpoint, rows) {
343
532
  const res = await this.requestProductAnswer("GET", endpoint, undefined);
344
- const page = this.readObjectAnswer("GET", endpoint, res);
533
+ const page = this.readObjectAnswer("GET", endpoint, res, {}, rows?.parse);
345
534
  const { items, next_cursor: nextCursor } = page;
346
535
  if (!Array.isArray(items)) {
347
536
  throw this.unreadableAnswer("GET", `/${API_PREFIX}/${endpoint}`, res, "a page whose `items` is not an array");
@@ -349,7 +538,40 @@ export class PipelexApiClient {
349
538
  if (nextCursor !== null && typeof nextCursor !== "string") {
350
539
  throw this.unreadableAnswer("GET", `/${API_PREFIX}/${endpoint}`, res, "a page whose `next_cursor` is neither a string nor null");
351
540
  }
352
- return { items: items, nextCursor };
541
+ if (rows === undefined)
542
+ return { items: items, nextCursor };
543
+ return { items: items.map((row) => rows.item(row, res)), nextCursor };
544
+ }
545
+ /**
546
+ * A route answering one method example (`createMethodExample`, `getMethodExample`,
547
+ * `updateMethodExample`), its answer read with `parseExactJson`, so an integer past the safe
548
+ * range in the inputs is a `bigint` holding its exact digits, and checked by
549
+ * `readMethodExample`.
550
+ */
551
+ async requestMethodExample(method, endpoint, body) {
552
+ const res = await this.requestProductAnswer(method, endpoint, body);
553
+ const answer = this.readObjectAnswer(method, endpoint, res, {}, parseExactJson);
554
+ return this.readMethodExample(method, endpoint, res, answer, "an example");
555
+ }
556
+ /**
557
+ * A method example as `res` carried it, checked against `METHOD_EXAMPLE_FIELDS`: a value that
558
+ * is not an object, or that misses a field the Python SDK's model requires or holds one of
559
+ * another type, is an answer the SDK cannot read, thrown as the `ApiResponseError`
560
+ * `unreadableAnswer` builds, `where` naming the example's place in the answer in the message.
561
+ * Every route answering an example reads it here, the page rows and the latest-run verdict's
562
+ * member included.
563
+ */
564
+ readMethodExample(method, endpoint, res, value, where) {
565
+ const path = `/${API_PREFIX}/${endpoint}`;
566
+ if (!isPlainObject(value)) {
567
+ throw this.unreadableAnswer(method, path, res, `${where} that is not an object`);
568
+ }
569
+ for (const [field, [expected, fits]] of Object.entries(METHOD_EXAMPLE_FIELDS)) {
570
+ if (!fits(value[field])) {
571
+ throw this.unreadableAnswer(method, path, res, `${where} whose \`${field}\` is not ${expected}`);
572
+ }
573
+ }
574
+ return value;
353
575
  }
354
576
  /** Issue a product request and return its 2xx answer, a non-2xx thrown as `ApiResponseError`. */
355
577
  async requestProductAnswer(method, endpoint, body, options = {}) {
@@ -371,25 +593,35 @@ export class PipelexApiClient {
371
593
  return this.readAnswerAt(method, `/${API_PREFIX}/${endpoint}`, res);
372
594
  }
373
595
  /** The JSON object body of a `/v1` route's 2xx answer; see `readObjectAnswerAt`. */
374
- readObjectAnswer(method, endpoint, res, run = {}) {
375
- return this.readObjectAnswerAt(method, `/${API_PREFIX}/${endpoint}`, res, run);
596
+ readObjectAnswer(method, endpoint, res, run = {}, parse = readJson) {
597
+ return this.readObjectAnswerAt(method, `/${API_PREFIX}/${endpoint}`, res, run, parse);
376
598
  }
377
599
  /**
378
600
  * The JSON body of a 2xx answer, naming the route by its `path` from the origin as a refusal
379
- * does. A body that is not UTF-8, or not JSON, an empty one included, is an answer the SDK
380
- * cannot read, so it throws the `ApiResponseError` `unreadableAnswer` builds, with the parse
381
- * failure as `cause`.
601
+ * does, its text read by `parse`: `JSON.parse`, or `parseExactJson` for an answer carrying a
602
+ * person's data. A body that is not UTF-8, or not JSON, an empty one included, is an answer the
603
+ * SDK cannot read, so it throws the `ApiResponseError` `unreadableAnswer` builds, with the parse
604
+ * failure as `cause`; so is a body holding a number `parseExactJson` cannot read exactly on this
605
+ * runtime (`ExactJsonUnsupportedError`), rather than read rounded.
382
606
  * The one place a success body is parsed.
383
607
  */
384
- readAnswerAt(method, path, res, run = {}) {
608
+ readAnswerAt(method, path, res, run = {}, parse = readJson) {
385
609
  if (!res.utf8) {
386
610
  throw this.unreadableAnswer(method, path, res, "a body that is not UTF-8", undefined, run);
387
611
  }
388
612
  try {
389
- return JSON.parse(res.body);
613
+ return parse(res.body);
390
614
  }
391
615
  catch (err) {
392
- throw this.unreadableAnswer(method, path, res, res.body ? "a body that is not JSON" : "an empty body where JSON was expected", err, run);
616
+ let what;
617
+ if (err instanceof ExactJsonUnsupportedError) {
618
+ what =
619
+ "a body holding a number past Number.MAX_SAFE_INTEGER, which this runtime cannot read " +
620
+ "exactly";
621
+ }
622
+ else
623
+ what = res.body ? "a body that is not JSON" : "an empty body where JSON was expected";
624
+ throw this.unreadableAnswer(method, path, res, what, err, run);
393
625
  }
394
626
  }
395
627
  /**
@@ -397,8 +629,8 @@ export class PipelexApiClient {
397
629
  * reads it. A body that is JSON but not an object — `null`, a number, a string, an array — is
398
630
  * an answer the SDK cannot read either, and throws the same `ApiResponseError`, with no `cause`.
399
631
  */
400
- readObjectAnswerAt(method, path, res, run = {}) {
401
- const answer = this.readAnswerAt(method, path, res, run);
632
+ readObjectAnswerAt(method, path, res, run = {}, parse = readJson) {
633
+ const answer = this.readAnswerAt(method, path, res, run, parse);
402
634
  if (!isPlainObject(answer)) {
403
635
  throw this.unreadableAnswer(method, path, res, "a body that is not an object", undefined, run);
404
636
  }
@@ -503,6 +735,9 @@ export class PipelexApiClient {
503
735
  * that exceeds that surfaces as `PipelineExecuteTimeoutError` pointing at the
504
736
  * durable start+poll path. Throws `RunStillRunningError` on the protocol's
505
737
  * optional 202 degrade.
738
+ *
739
+ * The answer is read with `parseExactJson`, so an integer past `Number.MAX_SAFE_INTEGER` in the
740
+ * working memory, the main output included, is a `bigint` holding its exact digits.
506
741
  */
507
742
  async execute(options) {
508
743
  const extensions = buildExtensions(options.extra);
@@ -548,8 +783,9 @@ export class PipelexApiClient {
548
783
  this.throwApiResponseError("POST", "execute", res, STARTS_RUN);
549
784
  }
550
785
  // Wrap the base result in the enriched subtype (adds the `.main_stuff` accessor; the
551
- // `main_stuff_name` extension + working memory ride `pipe_output`).
552
- const answer = this.readObjectAnswer("POST", "execute", res, STARTS_RUN);
786
+ // `main_stuff_name` extension + working memory ride `pipe_output`). Read exactly: the working
787
+ // memory holds the run's inputs and outputs.
788
+ const answer = this.readObjectAnswer("POST", "execute", res, STARTS_RUN, parseExactJson);
553
789
  if (!isCompletedRunAnswer(answer)) {
554
790
  throw this.unreadableAnswer("POST", `/${API_PREFIX}/execute`, res, "a body that is not the answer the route returns", undefined, STARTS_RUN);
555
791
  }
@@ -1109,7 +1345,7 @@ export class PipelexApiClient {
1109
1345
  * lifecycle routes are absent (a bare runner).
1110
1346
  */
1111
1347
  async getRunStatus(runId, options = {}) {
1112
- const endpoint = `${RUNS}/${encodeURIComponent(runId)}/status`;
1348
+ const endpoint = `${RUNS}/${idPathSegment(runId, "getRunStatus", RUN_ID_ARGUMENT)}/status`;
1113
1349
  const url = this.url(endpoint);
1114
1350
  const res = await this.requestRaw("GET", url, {
1115
1351
  timeoutMs: POLL_REQUEST_TIMEOUT_MS,
@@ -1142,12 +1378,17 @@ export class PipelexApiClient {
1142
1378
  * before any request. `MissingMainStuffError` is thrown only for a read that asked
1143
1379
  * for `main_stuff` — no selection, or one naming it.
1144
1380
  *
1381
+ * A completed result is read with `parseExactJson`, so an integer past
1382
+ * `Number.MAX_SAFE_INTEGER` anywhere in it, the main output, the working memory and the graph
1383
+ * included, is a `bigint` holding its exact digits, and an output passed back to `start` goes
1384
+ * back as the run produced it.
1385
+ *
1145
1386
  * Throws `RunLifecycleUnavailableError` when the lifecycle routes are absent
1146
1387
  * (a bare runner).
1147
1388
  */
1148
1389
  async getRunResult(runId, options = {}) {
1149
1390
  assertArtifactSelection(options.artifacts);
1150
- const base = `${RUNS}/${encodeURIComponent(runId)}/results`;
1391
+ const base = `${RUNS}/${idPathSegment(runId, "getRunResult", RUN_ID_ARGUMENT)}/results`;
1151
1392
  // Deduplicated, in the caller's order; the platform reads `a,b` as the set {a, b}. The names
1152
1393
  // are the validated vocabulary above, so they need no escaping and the comma stays literal.
1153
1394
  const endpoint = options.artifacts === undefined
@@ -1172,7 +1413,8 @@ export class PipelexApiClient {
1172
1413
  if (res.status < 200 || res.status >= 300) {
1173
1414
  this.throwApiResponseError("GET", endpoint, res);
1174
1415
  }
1175
- const result = this.readObjectAnswer("GET", endpoint, res);
1416
+ // Read exactly: the output, the working memory and the graph hold the run's data.
1417
+ const result = this.readObjectAnswer("GET", endpoint, res, {}, parseExactJson);
1176
1418
  if (selectionIncludesMainStuff(options.artifacts) && result.main_stuff == null) {
1177
1419
  throw new MissingMainStuffError(`Completed run '${runId}' returned no main stuff — a completed run always delivers a main stuff.`, runId);
1178
1420
  }
@@ -1315,22 +1557,13 @@ export class PipelexApiClient {
1315
1557
  * come back empty and still carry a `nextCursor`: keep following it until it is `null`.
1316
1558
  */
1317
1559
  async listMethods(query = {}) {
1318
- const params = new URLSearchParams();
1319
- // `!== undefined`, not truthiness — mirroring `listRuns`. Omission means
1320
- // "the caller did not ask"; an explicit empty string is bad input and the
1321
- // API should say so, rather than being silently dropped into an unfiltered
1322
- // query that returns everything and reads as working. `published: false`
1323
- // is a filter of its own, never omission.
1324
- if (query.q !== undefined)
1325
- params.set("q", query.q);
1326
- if (query.published !== undefined)
1327
- params.set("published", String(query.published));
1328
- if (query.limit !== undefined)
1329
- params.set("limit", String(query.limit));
1330
- if (query.cursor !== undefined)
1331
- params.set("cursor", query.cursor);
1332
- const suffix = params.toString();
1333
- return this.requestPage(suffix ? `methods?${suffix}` : "methods");
1560
+ const search = productQuery({
1561
+ q: query.q,
1562
+ published: query.published,
1563
+ limit: query.limit,
1564
+ cursor: query.cursor,
1565
+ });
1566
+ return this.requestPage(`methods${search}`);
1334
1567
  }
1335
1568
  /**
1336
1569
  * Every method in the catalog, streamed — follows the cursor for you.
@@ -1368,10 +1601,12 @@ export class PipelexApiClient {
1368
1601
  * Takes a bare catalog id: the method routes address the method itself, never a version of
1369
1602
  * it. A caller holding `mt_…@3` strips the suffix with `parseMethodSelector` and reads that
1370
1603
  * version with `getMethodVersion`. Every method route throws `RequestArgumentError` for a
1371
- * suffixed id, before any request, rather than read back the `404` the platform would answer.
1604
+ * suffixed id, before any request, rather than read back the `404` the platform would answer,
1605
+ * and for an id that is empty, `.` or `..`, which the URL would resolve into another route's
1606
+ * path.
1372
1607
  */
1373
1608
  async getMethod(methodId) {
1374
- return this.requestMethodData("GET", methodPath(methodId));
1609
+ return this.requestMethodData("GET", methodPath(methodId, "getMethod"));
1375
1610
  }
1376
1611
  /**
1377
1612
  * Resolve a stored method's id into the runnable MTHDS closure of its DRAFT — a
@@ -1438,7 +1673,7 @@ export class PipelexApiClient {
1438
1673
  * write.
1439
1674
  */
1440
1675
  async writeDraft(methodId, input) {
1441
- return this.requestMethodData("PUT", `${methodPath(methodId)}/draft`, withWirePython(input));
1676
+ return this.requestMethodData("PUT", `${methodPath(methodId, "writeDraft")}/draft`, withWirePython(input));
1442
1677
  }
1443
1678
  /**
1444
1679
  * Rename a method — `PATCH /v1/methods/{id}` — and return it. The name belongs to the method,
@@ -1453,7 +1688,7 @@ export class PipelexApiClient {
1453
1688
  * writes kept landing under the rename.
1454
1689
  */
1455
1690
  async renameMethod(methodId, input) {
1456
- return this.requestMethodData("PATCH", methodPath(methodId), {
1691
+ return this.requestMethodData("PATCH", methodPath(methodId, "renameMethod"), {
1457
1692
  name: input.name,
1458
1693
  });
1459
1694
  }
@@ -1495,19 +1730,16 @@ export class PipelexApiClient {
1495
1730
  throw new RequestArgumentError("publishMethod() needs expected_draft_updated_at: the draft token (the method's " +
1496
1731
  "updated_at) the caller last saw, so a publish never takes a draft it has not seen.");
1497
1732
  }
1498
- const endpoint = `${methodPath(methodId)}/publish`;
1733
+ const endpoint = `${methodPath(methodId, "publishMethod")}/publish`;
1499
1734
  const res = await this.requestProductAnswer("POST", endpoint, {
1500
1735
  expected_draft_updated_at: token,
1501
1736
  });
1502
- const answer = this.readObjectAnswer("POST", endpoint, res);
1503
- const { outcome, method: stored } = answer;
1504
- if (outcome !== "published" && outcome !== "unchanged" && outcome !== "refused") {
1505
- throw this.unreadableAnswer("POST", `/${API_PREFIX}/${endpoint}`, res, "a publish result whose `outcome` is not `published`, `unchanged` or `refused`");
1506
- }
1507
- if (!isPlainObject(stored)) {
1508
- throw this.unreadableAnswer("POST", `/${API_PREFIX}/${endpoint}`, res, "a publish result whose `method` is not an object");
1509
- }
1510
- const method = this.readStoredSources("POST", endpoint, res, stored, "a stored method");
1737
+ const { answer, outcome, subject } = this.readVerdict("POST", endpoint, res, {
1738
+ verdict: "a publish result",
1739
+ outcomes: ["published", "unchanged", "refused"],
1740
+ member: "method",
1741
+ });
1742
+ const method = this.readStoredSources("POST", endpoint, res, subject, "a stored method");
1511
1743
  return { ...answer, outcome, method };
1512
1744
  }
1513
1745
  /**
@@ -1524,16 +1756,9 @@ export class PipelexApiClient {
1524
1756
  * is an answer the SDK cannot read, thrown as an `ApiResponseError` too.
1525
1757
  */
1526
1758
  async listMethodVersions(methodId, query = {}) {
1527
- const params = new URLSearchParams();
1528
- // `!== undefined`, not truthiness — mirroring `listMethods`: an explicit empty cursor is bad
1529
- // input the API should refuse, not an omission.
1530
- if (query.limit !== undefined)
1531
- params.set("limit", String(query.limit));
1532
- if (query.cursor !== undefined)
1533
- params.set("cursor", query.cursor);
1534
- const suffix = params.toString();
1535
- const endpoint = `${methodPath(methodId)}/versions`;
1536
- return this.requestPage(suffix ? `${endpoint}?${suffix}` : endpoint);
1759
+ const search = productQuery({ limit: query.limit, cursor: query.cursor });
1760
+ const endpoint = `${methodPath(methodId, "listMethodVersions")}/versions`;
1761
+ return this.requestPage(`${endpoint}${search}`);
1537
1762
  }
1538
1763
  /**
1539
1764
  * One published version of a method, with its sources —
@@ -1547,9 +1772,9 @@ export class PipelexApiClient {
1547
1772
  */
1548
1773
  async getMethodVersion(methodId, version) {
1549
1774
  if (!Number.isSafeInteger(version) || version < 1) {
1550
- throw new RequestArgumentError(`getMethodVersion() takes a version number, a positive integer; got ${String(version)}.`);
1775
+ throw argumentRefusal("getMethodVersion", "a version number, a positive integer", describeNumberArgument(version));
1551
1776
  }
1552
- const endpoint = `${methodPath(methodId)}/versions/${version}`;
1777
+ const endpoint = `${methodPath(methodId, "getMethodVersion")}/versions/${version}`;
1553
1778
  const res = await this.requestProductAnswer("GET", endpoint, undefined);
1554
1779
  const wire = this.readObjectAnswer("GET", endpoint, res);
1555
1780
  return this.readStoredSources("GET", endpoint, res, wire, "a published version");
@@ -1573,7 +1798,203 @@ export class PipelexApiClient {
1573
1798
  * as `getMethod` does: the whole method is erased, never one of its versions.
1574
1799
  */
1575
1800
  async deleteMethod(methodId) {
1576
- return this.requestProduct("DELETE", methodPath(methodId));
1801
+ return this.requestProduct("DELETE", methodPath(methodId, "deleteMethod"));
1802
+ }
1803
+ // ── Method examples (`/v1/methods/{id}/examples`) ──────────────────────
1804
+ //
1805
+ // Every example route takes a bare catalog id, as `getMethod` does, and answers `404`
1806
+ // `not_found` for an unknown method or example and `409` `method_being_deleted` while the
1807
+ // method's erasure runs. The writes are `403` to a read-only key, which may still read.
1808
+ //
1809
+ // Every answer carrying an example is read with `parseExactJson`, so an integer past
1810
+ // `Number.MAX_SAFE_INTEGER` in its inputs, which another client may have saved, is a `bigint`
1811
+ // holding its exact digits, and every request body is written with `stringifyExactJson`, so
1812
+ // such inputs go back digit for digit, to `updateMethodExample` or as a run's inputs. Each
1813
+ // example is checked by `readMethodExample`: one missing a field the Python SDK's model
1814
+ // requires, or holding one of another type, is an answer the SDK cannot read.
1815
+ /**
1816
+ * Save an example on a method — `POST /v1/methods/{id}/examples`: a named set of inputs, and
1817
+ * with `run_id`, a run of this method that used exactly those inputs, recorded as its latest
1818
+ * run. Answers the example as stored, at `input_revision` 1.
1819
+ *
1820
+ * Sends `name`, `inputs` and, when given, `run_id`, and nothing else: the route's body is
1821
+ * closed, and a `null` `run_id` reads as not given. Throws `RequestArgumentError` before any
1822
+ * request when `name` or `inputs` is not given (`null` counting as not given), and for a
1823
+ * `run_id` that is given but is not a non-empty string. Throws `ApiResponseError`: `404`
1824
+ * `run_not_found` for a `run_id` that is not one of this method's runs in the caller's
1825
+ * organization, with nothing written; `413` `payload_too_large` for an example larger than one
1826
+ * stored item holds; `422` for a name that is empty, blank or over 200 characters, or inputs
1827
+ * that are not a JSON object; and the codes every example route answers.
1828
+ */
1829
+ async createMethodExample(methodId, input) {
1830
+ const endpoint = `${methodPath(methodId, "createMethodExample")}/examples`;
1831
+ const { name, inputs, run_id: runId, } = input ?? {};
1832
+ if (name == null || inputs == null) {
1833
+ let got;
1834
+ if (name == null && inputs == null)
1835
+ got = "neither";
1836
+ else
1837
+ got = name == null ? "no name" : "no inputs";
1838
+ throw new RequestArgumentError(`createMethodExample() needs the example's name and its inputs; got ${got}.`);
1839
+ }
1840
+ const body = { name, inputs };
1841
+ if (runId != null) {
1842
+ body.run_id = requireNonEmptyId(runId, "createMethodExample", CREATE_RUN_ID_ARGUMENT);
1843
+ }
1844
+ return this.requestMethodExample("POST", endpoint, body);
1845
+ }
1846
+ /**
1847
+ * One page of a method's examples, oldest first, inputs included —
1848
+ * `GET /v1/methods/{id}/examples`.
1849
+ *
1850
+ * Pass the page's `nextCursor` back as `cursor` to continue; `null` is the last page. A page
1851
+ * may be short while `nextCursor` is set, because the platform reads examples whole and stops
1852
+ * a read at 1 MB; `iterateMethodExamples` follows the cursor for a caller that wants them all.
1853
+ *
1854
+ * Throws `ApiResponseError`: `400` `malformed_request` for a cursor the listing did not issue
1855
+ * for this method, `422` for a `limit` outside 1 to 100, and the codes every example route
1856
+ * answers. A page whose `items` is not an array, whose `next_cursor` is neither a string nor
1857
+ * `null`, or one of whose items is not an example `readMethodExample` can read, is an answer
1858
+ * the SDK cannot read, thrown as an `ApiResponseError` too. An example whose inputs hold an
1859
+ * integer past the safe range is read like any other, that integer as a `bigint`.
1860
+ */
1861
+ async listMethodExamples(methodId, query = {}) {
1862
+ const search = productQuery({ limit: query.limit, cursor: query.cursor });
1863
+ const endpoint = `${methodPath(methodId, "listMethodExamples")}/examples${search}`;
1864
+ return this.requestPage(endpoint, {
1865
+ parse: parseExactJson,
1866
+ item: (row, res) => this.readMethodExample("GET", endpoint, res, row, "a page item"),
1867
+ });
1868
+ }
1869
+ /**
1870
+ * Every example of a method, oldest first, streamed — follows the cursor of
1871
+ * `listMethodExamples` for you.
1872
+ *
1873
+ * ```ts
1874
+ * for await (const example of client.iterateMethodExamples(methodId)) {
1875
+ * rerun(example); // stop whenever you like
1876
+ * }
1877
+ * ```
1878
+ *
1879
+ * An iterator rather than an all-at-once list, for the reason `iterateMethods` is one: a
1880
+ * helper returning an array needs a page cap, and a cap returns a truncated list with no
1881
+ * error. It runs the same loop as `iterateMethods` and `iterateRuns`, so it keeps going through
1882
+ * a short or empty page that carries a cursor, stops on a `null` cursor or on a cursor the
1883
+ * server failed to advance, and throws `PagingNotTerminatingError` past the same page ceiling.
1884
+ *
1885
+ * Throws `RequestArgumentError` at the call, before the iterator is pulled or anything is sent,
1886
+ * for a method id `listMethodExamples` refuses: a suffixed one, or one that is empty, `.` or `..`
1887
+ * or holds a lone surrogate.
1888
+ */
1889
+ iterateMethodExamples(methodId, query = {}) {
1890
+ // Checked here rather than in a generator's body, which runs only once the iterator is pulled
1891
+ // and would refuse the id at the first pull, away from the call that passed it.
1892
+ methodPath(methodId, "iterateMethodExamples");
1893
+ return followCursor("listMethodExamples", (cursor) => this.listMethodExamples(methodId, { ...query, cursor }));
1894
+ }
1895
+ /**
1896
+ * One example of a method — `GET /v1/methods/{id}/examples/{example_id}`, read consistently,
1897
+ * so an example saved a moment ago is found.
1898
+ *
1899
+ * Throws `RequestArgumentError` for an example id that is empty, `.` or `..`, or holds a lone
1900
+ * surrogate, before any request. Throws `ApiResponseError`: `422` for an example id longer
1901
+ * than one can be (64 characters), and the codes every example route answers.
1902
+ */
1903
+ async getMethodExample(methodId, exampleId) {
1904
+ const endpoint = methodExamplePath(methodId, exampleId, "getMethodExample");
1905
+ return this.requestMethodExample("GET", endpoint);
1906
+ }
1907
+ /**
1908
+ * Rename an example, give it new inputs, or both —
1909
+ * `PATCH /v1/methods/{id}/examples/{example_id}` — and return it as it now stands.
1910
+ *
1911
+ * **New inputs raise `input_revision` by one and clear `latest_run_id`**, since the run no
1912
+ * longer shows what the inputs produce; inputs that are the same as the stored ones, their
1913
+ * keys in another order included, change nothing, while `3` becoming `3.0` is a change. A
1914
+ * rename alone keeps both. The edit takes no revision or token: the latest-run write is what
1915
+ * the revision guards. Sends `name` and `inputs` when given, and nothing else; `null` reads as
1916
+ * not given, as the platform reads it.
1917
+ *
1918
+ * **Send `inputs` only when they changed.** Inputs read from an example and sent back unchanged
1919
+ * are a change for the platform when they hold a float with no fractional part, `3.0`, which
1920
+ * JavaScript reads as `3` (see `MethodExample.inputs`): they raise the revision and clear the
1921
+ * latest run. A rename sent alone never does. An integer past `Number.MAX_SAFE_INTEGER` is no
1922
+ * such case: it reads as a `bigint` and is sent back as its exact digits.
1923
+ *
1924
+ * Throws `RequestArgumentError` before any request for an example id that is empty, `.` or
1925
+ * `..`, or when neither `name` nor `inputs` is given. Throws `ApiResponseError`: `413`
1926
+ * `payload_too_large` for inputs larger than one stored item holds, `422` for a name that is
1927
+ * empty, blank or over 200 characters, or inputs that are not a JSON object, a `503` that wrote
1928
+ * nothing, safe to retry, when concurrent edits kept landing under the write, and the codes
1929
+ * every example route answers.
1930
+ */
1931
+ async updateMethodExample(methodId, exampleId, input) {
1932
+ const endpoint = methodExamplePath(methodId, exampleId, "updateMethodExample");
1933
+ const { name, inputs } = input ?? {};
1934
+ const body = {};
1935
+ if (name != null)
1936
+ body.name = name;
1937
+ if (inputs != null)
1938
+ body.inputs = inputs;
1939
+ if (Object.keys(body).length === 0) {
1940
+ throw new RequestArgumentError("updateMethodExample() needs a new name, new inputs, or both; got neither.");
1941
+ }
1942
+ return this.requestMethodExample("PATCH", endpoint, body);
1943
+ }
1944
+ /**
1945
+ * Record a run as an example's latest —
1946
+ * `PUT /v1/methods/{id}/examples/{example_id}/latest-run` — **only while the example still
1947
+ * holds the revision whose inputs the run used**.
1948
+ *
1949
+ * `input_revision` is the example's `input_revision` the caller read when it started the run
1950
+ * with the example's inputs; the platform trusts it and does not compare the run's own inputs.
1951
+ * The answer is a `MethodExampleLatestRunResult` discriminated on `outcome` — branch on it:
1952
+ * `recorded`, the run is now the example's latest; `stale`, the example's inputs were edited
1953
+ * since and nothing was written. A stale run is the expected end of a run that outlasted an
1954
+ * edit, so it is answered, never thrown. Both arms carry the `example` as it stands.
1955
+ *
1956
+ * Throws `RequestArgumentError` before any request, checking in this order: an example id that
1957
+ * is empty, `.` or `..`, or holds a lone surrogate, a `run_id` that is not a non-empty string,
1958
+ * and an `input_revision` that is not a positive integer. Throws `ApiResponseError` when no
1959
+ * verdict was produced: `404` `not_found` for an example deleted since, `404` `run_not_found`
1960
+ * for a run that is not one of this method's, with nothing written, and the codes every example
1961
+ * route answers. An answer whose `outcome` is neither arm's, or whose `example` is not an
1962
+ * example `readMethodExample` can read, is an answer the SDK cannot read, thrown as an
1963
+ * `ApiResponseError` too, read as `publishMethod` reads its verdict.
1964
+ */
1965
+ async recordMethodExampleLatestRun(methodId, exampleId, input) {
1966
+ const call = "recordMethodExampleLatestRun";
1967
+ const endpoint = `${methodExamplePath(methodId, exampleId, call)}/latest-run`;
1968
+ const { run_id: runId, input_revision: revision } = input ?? {};
1969
+ const run = requireNonEmptyId(runId, call, RECORD_RUN_ID_ARGUMENT);
1970
+ if (typeof revision !== "number" || !Number.isSafeInteger(revision) || revision < 1) {
1971
+ throw argumentRefusal(call, "an input_revision, the example's revision whose inputs the run used, a positive integer", describeNumberArgument(revision));
1972
+ }
1973
+ const res = await this.requestProductAnswer("PUT", endpoint, {
1974
+ run_id: run,
1975
+ input_revision: revision,
1976
+ });
1977
+ const { answer, outcome, subject } = this.readVerdict("PUT", endpoint, res, {
1978
+ verdict: "a latest-run result",
1979
+ outcomes: ["recorded", "stale"],
1980
+ member: "example",
1981
+ parse: parseExactJson,
1982
+ // Checked once, by the check every example the client hands back goes through.
1983
+ readMember: (value) => this.readMethodExample("PUT", endpoint, res, value, "a latest-run result's `example`"),
1984
+ });
1985
+ return { ...answer, outcome, example: subject };
1986
+ }
1987
+ /**
1988
+ * Delete an example — `DELETE /v1/methods/{id}/examples/{example_id}`. The platform answers
1989
+ * `204`. Deleting the example leaves its latest run in the method's history.
1990
+ *
1991
+ * Throws `RequestArgumentError` for an example id that is empty, `.` or `..`, before any
1992
+ * request: `..` would address the method, and its erasure would answer the delete. Throws
1993
+ * `ApiResponseError`: `404` `not_found` when there is no such example, one already deleted
1994
+ * included, and the codes every example route answers.
1995
+ */
1996
+ async deleteMethodExample(methodId, exampleId) {
1997
+ await this.requestProduct("DELETE", methodExamplePath(methodId, exampleId, "deleteMethodExample"));
1577
1998
  }
1578
1999
  /** The caller's org memberships + active-org feature flags — `GET /v1/organizations/memberships`. */
1579
2000
  async listMemberships() {
@@ -1585,7 +2006,8 @@ export class PipelexApiClient {
1585
2006
  }
1586
2007
  /** Rename an organization — `PATCH /v1/organizations/{org_id}`. */
1587
2008
  async renameOrganization(orgId, input) {
1588
- return this.requestProduct("PATCH", `organizations/${encodeURIComponent(orgId)}`, input);
2009
+ const segment = idPathSegment(orgId, "renameOrganization", ORG_ID_ARGUMENT);
2010
+ return this.requestProduct("PATCH", `organizations/${segment}`, input);
1589
2011
  }
1590
2012
  /** The active org's subscription state — `GET /v1/billing/subscription`. */
1591
2013
  async getSubscription() {
@@ -1632,14 +2054,16 @@ export class PipelexApiClient {
1632
2054
  }
1633
2055
  /** Revoke a Pipelex API key — `DELETE /v1/pipelex-api-keys/{id}`. */
1634
2056
  async revokePipelexApiKey(id) {
1635
- await this.requestProduct("DELETE", `pipelex-api-keys/${encodeURIComponent(id)}`);
2057
+ const segment = idPathSegment(id, "revokePipelexApiKey", API_KEY_ID_ARGUMENT);
2058
+ await this.requestProduct("DELETE", `pipelex-api-keys/${segment}`);
1636
2059
  }
1637
2060
  /**
1638
2061
  * Rotate a Pipelex API key — `POST /v1/pipelex-api-keys/{id}/rotate` (no
1639
2062
  * body). Returns the new plaintext `api_key` once; the old key stops working.
1640
2063
  */
1641
2064
  async rotatePipelexApiKey(id) {
1642
- return this.requestProduct("POST", `pipelex-api-keys/${encodeURIComponent(id)}/rotate`);
2065
+ const segment = idPathSegment(id, "rotatePipelexApiKey", API_KEY_ID_ARGUMENT);
2066
+ return this.requestProduct("POST", `pipelex-api-keys/${segment}/rotate`);
1643
2067
  }
1644
2068
  /** Submit the onboarding questionnaire — `POST /v1/onboarding/submit`. */
1645
2069
  async submitOnboarding(input) {
@@ -1802,25 +2226,18 @@ export class PipelexApiClient {
1802
2226
  * `method_version`.
1803
2227
  */
1804
2228
  async listRuns(methodId, query = {}) {
1805
- const params = new URLSearchParams({ method_id: methodId });
1806
- // `!== undefined`, not truthiness: omission means "the caller did not ask
1807
- // for this bound". An explicitly supplied empty string is not omission, it
1808
- // is bad input — dropping it turned a broken date into a silently
1809
- // UNFILTERED query returning every run, which reads as working. Forwarded,
1810
- // it reaches the API's instant parse and comes back a 400 saying so.
1811
- if (query.createdFrom !== undefined)
1812
- params.set("created_from", query.createdFrom);
1813
- if (query.createdTo !== undefined)
1814
- params.set("created_to", query.createdTo);
1815
- if (query.createdBy !== undefined)
1816
- params.set("created_by", query.createdBy);
1817
- for (const status of runStatusFilter(query.status) ?? [])
1818
- params.append("status", status);
1819
- if (query.limit !== undefined)
1820
- params.set("limit", String(query.limit));
1821
- if (query.cursor !== undefined)
1822
- params.set("cursor", query.cursor);
1823
- const page = await this.requestPage(`runs?${params.toString()}`);
2229
+ // An empty date is sent, not dropped: it reaches the API's instant parse and comes back a
2230
+ // `400` saying so, where dropping it would return every run (`productQuery`).
2231
+ const search = productQuery({
2232
+ method_id: methodId,
2233
+ created_from: query.createdFrom,
2234
+ created_to: query.createdTo,
2235
+ created_by: query.createdBy,
2236
+ status: runStatusFilter(query.status),
2237
+ limit: query.limit,
2238
+ cursor: query.cursor,
2239
+ });
2240
+ const page = await this.requestPage(`runs${search}`);
1824
2241
  return { items: page.items.map(withCheckedReport), nextCursor: page.nextCursor };
1825
2242
  }
1826
2243
  /**
@@ -1862,13 +2279,20 @@ export class PipelexApiClient {
1862
2279
  * executed) and `inputs`. Kept off the status read, which pollers hit every
1863
2280
  * few seconds. A failed run's stored report, `error`, is checked field by
1864
2281
  * field (see `RunErrorReport`).
2282
+ *
2283
+ * Read with `parseExactJson`, so an integer past `Number.MAX_SAFE_INTEGER` in the inputs is a
2284
+ * `bigint` holding its exact digits, and the inputs passed back to `start` or saved as an
2285
+ * example's go back as the run used them.
1865
2286
  */
1866
2287
  async getRunDetail(runId) {
1867
- return withCheckedReport(await this.requestProductObject("GET", `runs/${encodeURIComponent(runId)}`));
2288
+ const endpoint = `runs/${idPathSegment(runId, "getRunDetail", RUN_ID_ARGUMENT)}`;
2289
+ const res = await this.requestProductAnswer("GET", endpoint, undefined);
2290
+ return withCheckedReport(this.readObjectAnswer("GET", endpoint, res, {}, parseExactJson));
1868
2291
  }
1869
2292
  /** Patch a run's status (admin/manual) — `PUT /v1/runs/{id}`. */
1870
2293
  async updateRun(runId, input) {
1871
- await this.requestProduct("PUT", `runs/${encodeURIComponent(runId)}`, input);
2294
+ const segment = idPathSegment(runId, "updateRun", RUN_ID_ARGUMENT);
2295
+ await this.requestProduct("PUT", `runs/${segment}`, input);
1872
2296
  }
1873
2297
  /**
1874
2298
  * Blocking `POST /v1/execute` adapted onto `RunResults` — the bare-runner
@@ -1956,10 +2380,7 @@ function describeRefusedBaseUrl(value) {
1956
2380
  const shown = `"${parsed.protocol}//${parsed.host}"`;
1957
2381
  if (parts.length === 0)
1958
2382
  return shown;
1959
- const named = parts.length === 1
1960
- ? parts[0]
1961
- : `${parts.slice(0, -1).join(", ")} and ${parts[parts.length - 1]}`;
1962
- return `${shown} with ${named} (not shown)`;
2383
+ return `${shown} with ${inWords(parts, "and")} (not shown)`;
1963
2384
  }
1964
2385
  // The protocol's own request fields — `extra` is for extension args only.
1965
2386
  // `files` / `bundle_b64` are reserved too: they are named run-source options,