@pipelex/sdk 0.41.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;
@@ -188,7 +344,7 @@ export class PipelexApiClient {
188
344
  * no request path can miss the `User-Agent` or the bearer. Requests to third
189
345
  * parties (presigned object-store URLs) never go through here.
190
346
  */
191
- requestHeaders(hasBody) {
347
+ requestHeaders(hasBody, idempotencyKey) {
192
348
  const headers = { Accept: "application/json" };
193
349
  if (this.userAgent !== undefined) {
194
350
  headers["User-Agent"] = this.userAgent;
@@ -199,6 +355,9 @@ export class PipelexApiClient {
199
355
  if (hasBody) {
200
356
  headers["Content-Type"] = "application/json";
201
357
  }
358
+ if (idempotencyKey !== undefined) {
359
+ headers[IDEMPOTENCY_KEY_HEADER] = idempotencyKey;
360
+ }
202
361
  return headers;
203
362
  }
204
363
  /**
@@ -208,10 +367,15 @@ export class PipelexApiClient {
208
367
  * cleanly. Non-2xx interpretation is left to the caller. `url` is a fully
209
368
  * resolved absolute URL. `mayStartRun` marks a request that may create a run,
210
369
  * whose `ApiUnreachableError` then says whether it may have (see `RunCreation`).
370
+ * `idempotencyKey`, already checked by `idempotencyKeyOf`, is sent as the
371
+ * `Idempotency-Key` header; only `start` passes one.
211
372
  */
212
373
  async requestRaw(method, url, options = {}) {
213
374
  const hasBody = options.body !== undefined;
214
- const headers = this.requestHeaders(hasBody);
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;
378
+ const headers = this.requestHeaders(hasBody, options.idempotencyKey);
215
379
  const timeoutMs = options.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
216
380
  // A longer delay overflows the timer, which then fires at once as a false timeout.
217
381
  if (!isTimerDelay(timeoutMs)) {
@@ -234,7 +398,7 @@ export class PipelexApiClient {
234
398
  response = await fetch(url, {
235
399
  method,
236
400
  headers,
237
- body: hasBody ? JSON.stringify(options.body) : undefined,
401
+ body: bodyText,
238
402
  signal: controller.signal,
239
403
  });
240
404
  // The body streams after the headers; keep the timer/abort armed until it
@@ -327,16 +491,46 @@ export class PipelexApiClient {
327
491
  return withParsedPython(wire, (cause) => this.unreadableAnswer(method, `/${API_PREFIX}/${endpoint}`, res, `${subject} whose \`python\` field could not be read`, cause));
328
492
  }
329
493
  /**
330
- * One page of a cursor-paged product route (`listMethods`, `listMethodVersions`, `listRuns`),
331
- * read as the platform always serializes it: `items` an array, `next_cursor` a string or
332
- * `null`. A page breaking either is an answer the SDK cannot read, so it throws the
333
- * `ApiResponseError` `unreadableAnswer` builds, `runtime` and not retryable, rather than
334
- * handing the iterators a page they cannot walk: `items` that is not iterable, or a missing
335
- * 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.
336
504
  */
337
- 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) {
338
532
  const res = await this.requestProductAnswer("GET", endpoint, undefined);
339
- const page = this.readObjectAnswer("GET", endpoint, res);
533
+ const page = this.readObjectAnswer("GET", endpoint, res, {}, rows?.parse);
340
534
  const { items, next_cursor: nextCursor } = page;
341
535
  if (!Array.isArray(items)) {
342
536
  throw this.unreadableAnswer("GET", `/${API_PREFIX}/${endpoint}`, res, "a page whose `items` is not an array");
@@ -344,7 +538,40 @@ export class PipelexApiClient {
344
538
  if (nextCursor !== null && typeof nextCursor !== "string") {
345
539
  throw this.unreadableAnswer("GET", `/${API_PREFIX}/${endpoint}`, res, "a page whose `next_cursor` is neither a string nor null");
346
540
  }
347
- 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;
348
575
  }
349
576
  /** Issue a product request and return its 2xx answer, a non-2xx thrown as `ApiResponseError`. */
350
577
  async requestProductAnswer(method, endpoint, body, options = {}) {
@@ -366,25 +593,35 @@ export class PipelexApiClient {
366
593
  return this.readAnswerAt(method, `/${API_PREFIX}/${endpoint}`, res);
367
594
  }
368
595
  /** The JSON object body of a `/v1` route's 2xx answer; see `readObjectAnswerAt`. */
369
- readObjectAnswer(method, endpoint, res, run = {}) {
370
- 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);
371
598
  }
372
599
  /**
373
600
  * The JSON body of a 2xx answer, naming the route by its `path` from the origin as a refusal
374
- * does. A body that is not UTF-8, or not JSON, an empty one included, is an answer the SDK
375
- * cannot read, so it throws the `ApiResponseError` `unreadableAnswer` builds, with the parse
376
- * 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.
377
606
  * The one place a success body is parsed.
378
607
  */
379
- readAnswerAt(method, path, res, run = {}) {
608
+ readAnswerAt(method, path, res, run = {}, parse = readJson) {
380
609
  if (!res.utf8) {
381
610
  throw this.unreadableAnswer(method, path, res, "a body that is not UTF-8", undefined, run);
382
611
  }
383
612
  try {
384
- return JSON.parse(res.body);
613
+ return parse(res.body);
385
614
  }
386
615
  catch (err) {
387
- 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);
388
625
  }
389
626
  }
390
627
  /**
@@ -392,8 +629,8 @@ export class PipelexApiClient {
392
629
  * reads it. A body that is JSON but not an object — `null`, a number, a string, an array — is
393
630
  * an answer the SDK cannot read either, and throws the same `ApiResponseError`, with no `cause`.
394
631
  */
395
- readObjectAnswerAt(method, path, res, run = {}) {
396
- 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);
397
634
  if (!isPlainObject(answer)) {
398
635
  throw this.unreadableAnswer(method, path, res, "a body that is not an object", undefined, run);
399
636
  }
@@ -498,6 +735,9 @@ export class PipelexApiClient {
498
735
  * that exceeds that surfaces as `PipelineExecuteTimeoutError` pointing at the
499
736
  * durable start+poll path. Throws `RunStillRunningError` on the protocol's
500
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.
501
741
  */
502
742
  async execute(options) {
503
743
  const extensions = buildExtensions(options.extra);
@@ -543,8 +783,9 @@ export class PipelexApiClient {
543
783
  this.throwApiResponseError("POST", "execute", res, STARTS_RUN);
544
784
  }
545
785
  // Wrap the base result in the enriched subtype (adds the `.main_stuff` accessor; the
546
- // `main_stuff_name` extension + working memory ride `pipe_output`).
547
- 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);
548
789
  if (!isCompletedRunAnswer(answer)) {
549
790
  throw this.unreadableAnswer("POST", `/${API_PREFIX}/execute`, res, "a body that is not the answer the route returns", undefined, STARTS_RUN);
550
791
  }
@@ -575,8 +816,16 @@ export class PipelexApiClient {
575
816
  * durable — poll `getRunStatus` / `getRunResult`. A `method_ref` run's ack
576
817
  * additionally carries `method_provenance` — the address, the tag, and the
577
818
  * commit SHA that was actually fetched.
819
+ *
820
+ * `options.idempotencyKey` is sent as the `Idempotency-Key` header, and nothing
821
+ * is sent without it: the hosted API answers the same start sent again under the
822
+ * same key with the run the first one started, so a failure whose
823
+ * `runMayHaveStarted` is true can be retried unchanged (see
824
+ * `PipelexStartRequestOptions`). A malformed key is a `RequestArgumentError`
825
+ * before anything is sent.
578
826
  */
579
827
  async start(options) {
828
+ const idempotencyKey = idempotencyKeyOf(options);
580
829
  const extensions = buildExtensions(options.extra);
581
830
  const api = buildApiRunExtensions(options);
582
831
  const hosted = buildHostedRunExtensions(options);
@@ -627,6 +876,7 @@ export class PipelexApiClient {
627
876
  const res = await this.requestRaw("POST", url, {
628
877
  body: request,
629
878
  timeoutMs: needsLongCeiling ? DEFAULT_REQUEST_TIMEOUT_MS : POLL_REQUEST_TIMEOUT_MS,
879
+ idempotencyKey,
630
880
  ...STARTS_RUN,
631
881
  });
632
882
  // A bare runner with no run store 404s here just as it does on the result
@@ -1095,7 +1345,7 @@ export class PipelexApiClient {
1095
1345
  * lifecycle routes are absent (a bare runner).
1096
1346
  */
1097
1347
  async getRunStatus(runId, options = {}) {
1098
- const endpoint = `${RUNS}/${encodeURIComponent(runId)}/status`;
1348
+ const endpoint = `${RUNS}/${idPathSegment(runId, "getRunStatus", RUN_ID_ARGUMENT)}/status`;
1099
1349
  const url = this.url(endpoint);
1100
1350
  const res = await this.requestRaw("GET", url, {
1101
1351
  timeoutMs: POLL_REQUEST_TIMEOUT_MS,
@@ -1128,12 +1378,17 @@ export class PipelexApiClient {
1128
1378
  * before any request. `MissingMainStuffError` is thrown only for a read that asked
1129
1379
  * for `main_stuff` — no selection, or one naming it.
1130
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
+ *
1131
1386
  * Throws `RunLifecycleUnavailableError` when the lifecycle routes are absent
1132
1387
  * (a bare runner).
1133
1388
  */
1134
1389
  async getRunResult(runId, options = {}) {
1135
1390
  assertArtifactSelection(options.artifacts);
1136
- const base = `${RUNS}/${encodeURIComponent(runId)}/results`;
1391
+ const base = `${RUNS}/${idPathSegment(runId, "getRunResult", RUN_ID_ARGUMENT)}/results`;
1137
1392
  // Deduplicated, in the caller's order; the platform reads `a,b` as the set {a, b}. The names
1138
1393
  // are the validated vocabulary above, so they need no escaping and the comma stays literal.
1139
1394
  const endpoint = options.artifacts === undefined
@@ -1158,7 +1413,8 @@ export class PipelexApiClient {
1158
1413
  if (res.status < 200 || res.status >= 300) {
1159
1414
  this.throwApiResponseError("GET", endpoint, res);
1160
1415
  }
1161
- 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);
1162
1418
  if (selectionIncludesMainStuff(options.artifacts) && result.main_stuff == null) {
1163
1419
  throw new MissingMainStuffError(`Completed run '${runId}' returned no main stuff — a completed run always delivers a main stuff.`, runId);
1164
1420
  }
@@ -1227,10 +1483,18 @@ export class PipelexApiClient {
1227
1483
  * up in the run history first. An error from the wait, once `onStarted` has named the run,
1228
1484
  * started nothing, so its `runMayHaveStarted` is false, and its `retryable` is about waiting
1229
1485
  * again on that id with `waitForResult`, never about calling this method again.
1486
+ *
1487
+ * `options.idempotencyKey` rides the durable start, so a call that failed while its start may
1488
+ * have created a run can be made again with the same options and the same key: the hosted API
1489
+ * answers the start with the run the first call started, and the wait follows it. The blocking
1490
+ * execute of a bare runner carries no key, a bare runner having no idempotency to honour it. A
1491
+ * malformed key is refused before the version handshake.
1230
1492
  */
1231
1493
  async startAndWaitForResult(options, pollOptions) {
1232
1494
  // Before the run starts: a refusal after it would carry no run id to re-poll by.
1233
1495
  assertWaitOptions(pollOptions);
1496
+ // Before the handshake too: a key the start would refuse is the caller's to fix, whatever runs.
1497
+ idempotencyKeyOf(options);
1234
1498
  const { onStarted, onStarting, ...waitOptions } = pollOptions ?? {};
1235
1499
  const hosted = await this.supportsRunLifecycle();
1236
1500
  // The handshake may have outlasted the caller's patience: an abort that landed during it
@@ -1293,22 +1557,13 @@ export class PipelexApiClient {
1293
1557
  * come back empty and still carry a `nextCursor`: keep following it until it is `null`.
1294
1558
  */
1295
1559
  async listMethods(query = {}) {
1296
- const params = new URLSearchParams();
1297
- // `!== undefined`, not truthiness — mirroring `listRuns`. Omission means
1298
- // "the caller did not ask"; an explicit empty string is bad input and the
1299
- // API should say so, rather than being silently dropped into an unfiltered
1300
- // query that returns everything and reads as working. `published: false`
1301
- // is a filter of its own, never omission.
1302
- if (query.q !== undefined)
1303
- params.set("q", query.q);
1304
- if (query.published !== undefined)
1305
- params.set("published", String(query.published));
1306
- if (query.limit !== undefined)
1307
- params.set("limit", String(query.limit));
1308
- if (query.cursor !== undefined)
1309
- params.set("cursor", query.cursor);
1310
- const suffix = params.toString();
1311
- 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}`);
1312
1567
  }
1313
1568
  /**
1314
1569
  * Every method in the catalog, streamed — follows the cursor for you.
@@ -1346,10 +1601,12 @@ export class PipelexApiClient {
1346
1601
  * Takes a bare catalog id: the method routes address the method itself, never a version of
1347
1602
  * it. A caller holding `mt_…@3` strips the suffix with `parseMethodSelector` and reads that
1348
1603
  * version with `getMethodVersion`. Every method route throws `RequestArgumentError` for a
1349
- * 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.
1350
1607
  */
1351
1608
  async getMethod(methodId) {
1352
- return this.requestMethodData("GET", methodPath(methodId));
1609
+ return this.requestMethodData("GET", methodPath(methodId, "getMethod"));
1353
1610
  }
1354
1611
  /**
1355
1612
  * Resolve a stored method's id into the runnable MTHDS closure of its DRAFT — a
@@ -1416,7 +1673,7 @@ export class PipelexApiClient {
1416
1673
  * write.
1417
1674
  */
1418
1675
  async writeDraft(methodId, input) {
1419
- return this.requestMethodData("PUT", `${methodPath(methodId)}/draft`, withWirePython(input));
1676
+ return this.requestMethodData("PUT", `${methodPath(methodId, "writeDraft")}/draft`, withWirePython(input));
1420
1677
  }
1421
1678
  /**
1422
1679
  * Rename a method — `PATCH /v1/methods/{id}` — and return it. The name belongs to the method,
@@ -1431,7 +1688,7 @@ export class PipelexApiClient {
1431
1688
  * writes kept landing under the rename.
1432
1689
  */
1433
1690
  async renameMethod(methodId, input) {
1434
- return this.requestMethodData("PATCH", methodPath(methodId), {
1691
+ return this.requestMethodData("PATCH", methodPath(methodId, "renameMethod"), {
1435
1692
  name: input.name,
1436
1693
  });
1437
1694
  }
@@ -1473,19 +1730,16 @@ export class PipelexApiClient {
1473
1730
  throw new RequestArgumentError("publishMethod() needs expected_draft_updated_at: the draft token (the method's " +
1474
1731
  "updated_at) the caller last saw, so a publish never takes a draft it has not seen.");
1475
1732
  }
1476
- const endpoint = `${methodPath(methodId)}/publish`;
1733
+ const endpoint = `${methodPath(methodId, "publishMethod")}/publish`;
1477
1734
  const res = await this.requestProductAnswer("POST", endpoint, {
1478
1735
  expected_draft_updated_at: token,
1479
1736
  });
1480
- const answer = this.readObjectAnswer("POST", endpoint, res);
1481
- const { outcome, method: stored } = answer;
1482
- if (outcome !== "published" && outcome !== "unchanged" && outcome !== "refused") {
1483
- throw this.unreadableAnswer("POST", `/${API_PREFIX}/${endpoint}`, res, "a publish result whose `outcome` is not `published`, `unchanged` or `refused`");
1484
- }
1485
- if (!isPlainObject(stored)) {
1486
- throw this.unreadableAnswer("POST", `/${API_PREFIX}/${endpoint}`, res, "a publish result whose `method` is not an object");
1487
- }
1488
- 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");
1489
1743
  return { ...answer, outcome, method };
1490
1744
  }
1491
1745
  /**
@@ -1502,16 +1756,9 @@ export class PipelexApiClient {
1502
1756
  * is an answer the SDK cannot read, thrown as an `ApiResponseError` too.
1503
1757
  */
1504
1758
  async listMethodVersions(methodId, query = {}) {
1505
- const params = new URLSearchParams();
1506
- // `!== undefined`, not truthiness — mirroring `listMethods`: an explicit empty cursor is bad
1507
- // input the API should refuse, not an omission.
1508
- if (query.limit !== undefined)
1509
- params.set("limit", String(query.limit));
1510
- if (query.cursor !== undefined)
1511
- params.set("cursor", query.cursor);
1512
- const suffix = params.toString();
1513
- const endpoint = `${methodPath(methodId)}/versions`;
1514
- 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}`);
1515
1762
  }
1516
1763
  /**
1517
1764
  * One published version of a method, with its sources —
@@ -1525,9 +1772,9 @@ export class PipelexApiClient {
1525
1772
  */
1526
1773
  async getMethodVersion(methodId, version) {
1527
1774
  if (!Number.isSafeInteger(version) || version < 1) {
1528
- 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));
1529
1776
  }
1530
- const endpoint = `${methodPath(methodId)}/versions/${version}`;
1777
+ const endpoint = `${methodPath(methodId, "getMethodVersion")}/versions/${version}`;
1531
1778
  const res = await this.requestProductAnswer("GET", endpoint, undefined);
1532
1779
  const wire = this.readObjectAnswer("GET", endpoint, res);
1533
1780
  return this.readStoredSources("GET", endpoint, res, wire, "a published version");
@@ -1551,7 +1798,203 @@ export class PipelexApiClient {
1551
1798
  * as `getMethod` does: the whole method is erased, never one of its versions.
1552
1799
  */
1553
1800
  async deleteMethod(methodId) {
1554
- 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"));
1555
1998
  }
1556
1999
  /** The caller's org memberships + active-org feature flags — `GET /v1/organizations/memberships`. */
1557
2000
  async listMemberships() {
@@ -1563,7 +2006,8 @@ export class PipelexApiClient {
1563
2006
  }
1564
2007
  /** Rename an organization — `PATCH /v1/organizations/{org_id}`. */
1565
2008
  async renameOrganization(orgId, input) {
1566
- 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);
1567
2011
  }
1568
2012
  /** The active org's subscription state — `GET /v1/billing/subscription`. */
1569
2013
  async getSubscription() {
@@ -1610,14 +2054,16 @@ export class PipelexApiClient {
1610
2054
  }
1611
2055
  /** Revoke a Pipelex API key — `DELETE /v1/pipelex-api-keys/{id}`. */
1612
2056
  async revokePipelexApiKey(id) {
1613
- 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}`);
1614
2059
  }
1615
2060
  /**
1616
2061
  * Rotate a Pipelex API key — `POST /v1/pipelex-api-keys/{id}/rotate` (no
1617
2062
  * body). Returns the new plaintext `api_key` once; the old key stops working.
1618
2063
  */
1619
2064
  async rotatePipelexApiKey(id) {
1620
- 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`);
1621
2067
  }
1622
2068
  /** Submit the onboarding questionnaire — `POST /v1/onboarding/submit`. */
1623
2069
  async submitOnboarding(input) {
@@ -1780,25 +2226,18 @@ export class PipelexApiClient {
1780
2226
  * `method_version`.
1781
2227
  */
1782
2228
  async listRuns(methodId, query = {}) {
1783
- const params = new URLSearchParams({ method_id: methodId });
1784
- // `!== undefined`, not truthiness: omission means "the caller did not ask
1785
- // for this bound". An explicitly supplied empty string is not omission, it
1786
- // is bad input — dropping it turned a broken date into a silently
1787
- // UNFILTERED query returning every run, which reads as working. Forwarded,
1788
- // it reaches the API's instant parse and comes back a 400 saying so.
1789
- if (query.createdFrom !== undefined)
1790
- params.set("created_from", query.createdFrom);
1791
- if (query.createdTo !== undefined)
1792
- params.set("created_to", query.createdTo);
1793
- if (query.createdBy !== undefined)
1794
- params.set("created_by", query.createdBy);
1795
- for (const status of runStatusFilter(query.status) ?? [])
1796
- params.append("status", status);
1797
- if (query.limit !== undefined)
1798
- params.set("limit", String(query.limit));
1799
- if (query.cursor !== undefined)
1800
- params.set("cursor", query.cursor);
1801
- 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}`);
1802
2241
  return { items: page.items.map(withCheckedReport), nextCursor: page.nextCursor };
1803
2242
  }
1804
2243
  /**
@@ -1840,13 +2279,20 @@ export class PipelexApiClient {
1840
2279
  * executed) and `inputs`. Kept off the status read, which pollers hit every
1841
2280
  * few seconds. A failed run's stored report, `error`, is checked field by
1842
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.
1843
2286
  */
1844
2287
  async getRunDetail(runId) {
1845
- 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));
1846
2291
  }
1847
2292
  /** Patch a run's status (admin/manual) — `PUT /v1/runs/{id}`. */
1848
2293
  async updateRun(runId, input) {
1849
- 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);
1850
2296
  }
1851
2297
  /**
1852
2298
  * Blocking `POST /v1/execute` adapted onto `RunResults` — the bare-runner
@@ -1934,10 +2380,7 @@ function describeRefusedBaseUrl(value) {
1934
2380
  const shown = `"${parsed.protocol}//${parsed.host}"`;
1935
2381
  if (parts.length === 0)
1936
2382
  return shown;
1937
- const named = parts.length === 1
1938
- ? parts[0]
1939
- : `${parts.slice(0, -1).join(", ")} and ${parts[parts.length - 1]}`;
1940
- return `${shown} with ${named} (not shown)`;
2383
+ return `${shown} with ${inWords(parts, "and")} (not shown)`;
1941
2384
  }
1942
2385
  // The protocol's own request fields — `extra` is for extension args only.
1943
2386
  // `files` / `bundle_b64` are reserved too: they are named run-source options,
@@ -2190,6 +2633,55 @@ function nonEmptyFiles(files) {
2190
2633
  function nonEmptyString(value) {
2191
2634
  return value != null && value.length > 0 ? value : undefined;
2192
2635
  }
2636
+ /** The request header the hosted API reads a run start's idempotency key from. */
2637
+ const IDEMPOTENCY_KEY_HEADER = "Idempotency-Key";
2638
+ /**
2639
+ * The longest idempotency key a start sends. It is room for any generated key, and keeps the
2640
+ * hosted API's stored record of the key far inside its store's limit, which a longer key could
2641
+ * exceed, so that the start would run without the protection the key was sent for.
2642
+ */
2643
+ const MAX_IDEMPOTENCY_KEY_LENGTH = 255;
2644
+ /**
2645
+ * Visible ASCII, `!` to `~`: what a header value carries unchanged through every proxy and both
2646
+ * SDKs' transports, where fetch refuses a line break, httpx a character outside ASCII, and a space
2647
+ * at either end is trimmed on the way.
2648
+ */
2649
+ const VISIBLE_ASCII = /^[\x21-\x7E]+$/;
2650
+ /**
2651
+ * The idempotency key a start sends, or `undefined` when the options carry none (absent or
2652
+ * `null`). A key that is not a string of 1 to 255 visible ASCII characters is refused with a
2653
+ * `RequestArgumentError` rather than dropped, an empty one included, since a start sent without
2654
+ * the key its caller gave would lose the protection the caller asked for without a word. The
2655
+ * message never quotes the key, which a caller may derive from something private. The Python SDK
2656
+ * holds a key to the same rule, in the same words.
2657
+ */
2658
+ function idempotencyKeyOf(options) {
2659
+ const key = options.idempotencyKey;
2660
+ if (key == null)
2661
+ return undefined;
2662
+ const fault = idempotencyKeyFault(key);
2663
+ if (fault !== undefined) {
2664
+ throw new RequestArgumentError(`The idempotency key must be 1 to ${MAX_IDEMPOTENCY_KEY_LENGTH} visible ASCII characters ` +
2665
+ `(\`!\` to \`~\`), and this one ${fault}. Leave it out to start the run without one.`);
2666
+ }
2667
+ return key;
2668
+ }
2669
+ /**
2670
+ * What is wrong with an idempotency key, or `undefined` when it is a valid one. The characters are
2671
+ * checked before the length, which then counts ASCII characters alone, so the two SDKs, which
2672
+ * count a character outside ASCII differently, word every refusal alike.
2673
+ */
2674
+ function idempotencyKeyFault(key) {
2675
+ if (typeof key !== "string")
2676
+ return "is not a string";
2677
+ if (key.length === 0)
2678
+ return "is empty";
2679
+ if (!VISIBLE_ASCII.test(key))
2680
+ return "holds a character outside visible ASCII";
2681
+ if (key.length > MAX_IDEMPOTENCY_KEY_LENGTH)
2682
+ return `is ${key.length} characters long`;
2683
+ return undefined;
2684
+ }
2193
2685
  function withValidateMarkdownRender(render) {
2194
2686
  const formats = new Set(render ?? []);
2195
2687
  formats.add(VALIDATE_MARKDOWN_RENDER_FORMAT);