@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/README.md +35 -1
- package/dist/cli/present.d.ts +3 -2
- package/dist/cli/present.js +5 -4
- package/dist/cli/present.js.map +1 -1
- package/dist/cli/script.d.ts +1 -1
- package/dist/client.d.ts +222 -13
- package/dist/client.js +596 -104
- package/dist/client.js.map +1 -1
- package/dist/error-verdicts.js +14 -3
- package/dist/error-verdicts.js.map +1 -1
- package/dist/errors.d.ts +27 -6
- package/dist/errors.js +10 -6
- package/dist/errors.js.map +1 -1
- package/dist/exact-json.d.ts +64 -0
- package/dist/exact-json.js +127 -0
- package/dist/exact-json.js.map +1 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/product-models.d.ts +136 -2
- package/dist/product-models.js +1 -1
- package/dist/runs.d.ts +3 -1
- package/dist/runs.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/docs/architecture.md +5 -1
- package/docs/cli.md +1 -1
- package/docs/errors.md +41 -5
- package/docs/exact-integers.md +32 -0
- package/docs/method-examples.md +89 -0
- package/docs/run-results.md +2 -0
- package/package.json +1 -1
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 `
|
|
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
|
-
*
|
|
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
|
-
|
|
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/${
|
|
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
|
-
|
|
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:
|
|
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
|
-
*
|
|
331
|
-
*
|
|
332
|
-
* `
|
|
333
|
-
*
|
|
334
|
-
*
|
|
335
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
375
|
-
*
|
|
376
|
-
*
|
|
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
|
|
613
|
+
return parse(res.body);
|
|
385
614
|
}
|
|
386
615
|
catch (err) {
|
|
387
|
-
|
|
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
|
-
|
|
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}/${
|
|
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}/${
|
|
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
|
-
|
|
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
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
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.
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
}
|
|
1485
|
-
|
|
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
|
|
1506
|
-
|
|
1507
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1784
|
-
//
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|