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