@pipelex/sdk 0.38.0 → 0.40.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 +3 -2
- package/dist/bundle-resources.d.ts +62 -0
- package/dist/bundle-resources.js +165 -0
- package/dist/bundle-resources.js.map +1 -0
- package/dist/cli/run.js +8 -111
- package/dist/cli/run.js.map +1 -1
- package/dist/cli/script.d.ts +1 -1
- package/dist/cli/source.d.ts +2 -1
- package/dist/cli/source.js +61 -3
- package/dist/cli/source.js.map +1 -1
- package/dist/client.d.ts +116 -30
- package/dist/client.js +235 -75
- package/dist/client.js.map +1 -1
- package/dist/error-models.d.ts +64 -18
- package/dist/error-models.js +20 -9
- package/dist/error-models.js.map +1 -1
- package/dist/errors.d.ts +125 -23
- package/dist/errors.js +124 -22
- package/dist/errors.js.map +1 -1
- package/dist/execute-result.js +4 -4
- package/dist/execute-result.js.map +1 -1
- package/dist/hooks/bundle-gather.d.ts +28 -1
- package/dist/hooks/bundle-gather.js +163 -3
- package/dist/hooks/bundle-gather.js.map +1 -1
- package/dist/hooks/claude-mthds-check.d.ts +4 -2
- package/dist/hooks/claude-mthds-check.js +5 -2
- package/dist/hooks/claude-mthds-check.js.map +1 -1
- package/dist/index.d.ts +7 -5
- package/dist/index.js +6 -2
- package/dist/index.js.map +1 -1
- package/dist/models.d.ts +42 -4
- package/dist/prepare-inputs.d.ts +58 -4
- package/dist/prepare-inputs.js +307 -61
- package/dist/prepare-inputs.js.map +1 -1
- package/dist/product-models.d.ts +13 -5
- package/dist/run-may-have-started.d.ts +31 -0
- package/dist/run-may-have-started.js +138 -0
- package/dist/run-may-have-started.js.map +1 -0
- package/dist/runs.d.ts +95 -7
- package/dist/runs.js +18 -0
- package/dist/runs.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/docs/architecture.md +6 -3
- package/docs/bundle-resources.md +107 -0
- package/docs/cli.md +2 -1
- package/docs/crate-routes.md +5 -1
- package/docs/errors.md +87 -17
- package/docs/hook-bundle.md +2 -0
- package/docs/input-preparation.md +26 -4
- package/docs/run-results.md +39 -0
- package/package.json +2 -2
package/dist/client.js
CHANGED
|
@@ -2,12 +2,13 @@
|
|
|
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, throwIfAborted, } from "./runs.js";
|
|
5
|
+
import { assertArtifactSelection, assertWaitOptions, pollUntilResult, readRunDegradation, selectionIncludesMainStuff, throwIfAborted, } from "./runs.js";
|
|
6
6
|
import { ApiResponseError, ApiUnreachableError, EmptyMethodSourceError, MissingMainStuffError, PagingNotTerminatingError, PipelineExecuteTimeoutError, PipelineRequestError, RequestArgumentError, RunLifecycleUnavailableError, RunStillRunningError, } from "./errors.js";
|
|
7
7
|
import { isPlainObject, isValidationItem, readFieldError, readMigration, readProviderMetadata, readRunErrorReport, } from "./error-models.js";
|
|
8
8
|
import { methodSourceToContents } from "./method-source.js";
|
|
9
9
|
import { buildUserAgent } from "./user-agent.js";
|
|
10
10
|
import { uploadFile as uploadFileImpl } from "./upload.js";
|
|
11
|
+
import { encodeBundleResources, hasBundleResources } from "./bundle-resources.js";
|
|
11
12
|
import { prepareInputs as prepareInputsImpl } from "./prepare-inputs.js";
|
|
12
13
|
import { downloadArtifacts as downloadArtifactsImpl, fetchArtifact as fetchArtifactImpl, resolveArtifacts as resolveArtifactsImpl, } from "./artifacts.js";
|
|
13
14
|
import { PipelexExecuteResult, resultsFromExecute } from "./execute-result.js";
|
|
@@ -29,6 +30,7 @@ const MAX_PAGES = 10_000;
|
|
|
29
30
|
/** Decoders of an answer's bytes: the strict one tells a body that is not UTF-8. */
|
|
30
31
|
const STRICT_UTF8 = new TextDecoder("utf-8", { fatal: true });
|
|
31
32
|
const LENIENT_UTF8 = new TextDecoder("utf-8");
|
|
33
|
+
const STARTS_RUN = Object.freeze({ mayStartRun: true });
|
|
32
34
|
/** Hosted default — the client composes every endpoint as `{base}/v1/{endpoint}`. */
|
|
33
35
|
export const DEFAULT_API_BASE_URL = "https://api.pipelex.com";
|
|
34
36
|
// The client composes every endpoint from one origin (PIPELEX_BASE_URL): `{base}/v1/{endpoint}`.
|
|
@@ -155,7 +157,8 @@ export class PipelexApiClient {
|
|
|
155
157
|
* DNS/connect/TLS/timeout failures as `ApiUnreachableError`; a caller-driven
|
|
156
158
|
* abort (Ctrl-C / agent walk-away) propagates as-is so the poll loop can stop
|
|
157
159
|
* cleanly. Non-2xx interpretation is left to the caller. `url` is a fully
|
|
158
|
-
* resolved absolute URL.
|
|
160
|
+
* resolved absolute URL. `mayStartRun` marks a request that may create a run,
|
|
161
|
+
* whose `ApiUnreachableError` then says whether it may have (see `RunCreation`).
|
|
159
162
|
*/
|
|
160
163
|
async requestRaw(method, url, options = {}) {
|
|
161
164
|
const hasBody = options.body !== undefined;
|
|
@@ -203,7 +206,7 @@ export class PipelexApiClient {
|
|
|
203
206
|
// is classified from the controller rather than from `err`: a browser errors a
|
|
204
207
|
// body stream cut short by that abort with a generic AbortError instead.
|
|
205
208
|
const code = extractNetworkErrorCode(controller.signal.aborted ? controller.signal.reason : err);
|
|
206
|
-
throw new ApiUnreachableError(`Could not reach Pipelex API at ${this.baseUrl} (${code ?? "network error"})`, this.baseUrl, code, { cause: err });
|
|
209
|
+
throw new ApiUnreachableError(`Could not reach Pipelex API at ${this.baseUrl} (${code ?? "network error"})`, this.baseUrl, code, { cause: err, mayStartRun: options.mayStartRun === true });
|
|
207
210
|
}
|
|
208
211
|
finally {
|
|
209
212
|
clearTimeout(timer);
|
|
@@ -306,16 +309,16 @@ export class PipelexApiClient {
|
|
|
306
309
|
}
|
|
307
310
|
return res;
|
|
308
311
|
}
|
|
309
|
-
throwApiResponseError(method, endpoint, res) {
|
|
310
|
-
this.throwApiResponseErrorAt(method, `/${API_PREFIX}/${endpoint}`, res);
|
|
312
|
+
throwApiResponseError(method, endpoint, res, run = {}) {
|
|
313
|
+
this.throwApiResponseErrorAt(method, `/${API_PREFIX}/${endpoint}`, res, run);
|
|
311
314
|
}
|
|
312
315
|
/** The JSON body of a `/v1` route's 2xx answer; see `readAnswerAt`. */
|
|
313
316
|
readAnswer(method, endpoint, res) {
|
|
314
317
|
return this.readAnswerAt(method, `/${API_PREFIX}/${endpoint}`, res);
|
|
315
318
|
}
|
|
316
319
|
/** The JSON object body of a `/v1` route's 2xx answer; see `readObjectAnswerAt`. */
|
|
317
|
-
readObjectAnswer(method, endpoint, res) {
|
|
318
|
-
return this.readObjectAnswerAt(method, `/${API_PREFIX}/${endpoint}`, res);
|
|
320
|
+
readObjectAnswer(method, endpoint, res, run = {}) {
|
|
321
|
+
return this.readObjectAnswerAt(method, `/${API_PREFIX}/${endpoint}`, res, run);
|
|
319
322
|
}
|
|
320
323
|
/**
|
|
321
324
|
* The JSON body of a 2xx answer, naming the route by its `path` from the origin as a refusal
|
|
@@ -324,14 +327,15 @@ export class PipelexApiClient {
|
|
|
324
327
|
* failure as `cause`.
|
|
325
328
|
* The one place a success body is parsed.
|
|
326
329
|
*/
|
|
327
|
-
readAnswerAt(method, path, res) {
|
|
328
|
-
if (!res.utf8)
|
|
329
|
-
throw this.unreadableAnswer(method, path, res, "a body that is not UTF-8");
|
|
330
|
+
readAnswerAt(method, path, res, run = {}) {
|
|
331
|
+
if (!res.utf8) {
|
|
332
|
+
throw this.unreadableAnswer(method, path, res, "a body that is not UTF-8", undefined, run);
|
|
333
|
+
}
|
|
330
334
|
try {
|
|
331
335
|
return JSON.parse(res.body);
|
|
332
336
|
}
|
|
333
337
|
catch (err) {
|
|
334
|
-
throw this.unreadableAnswer(method, path, res, res.body ? "a body that is not JSON" : "an empty body where JSON was expected", err);
|
|
338
|
+
throw this.unreadableAnswer(method, path, res, res.body ? "a body that is not JSON" : "an empty body where JSON was expected", err, run);
|
|
335
339
|
}
|
|
336
340
|
}
|
|
337
341
|
/**
|
|
@@ -339,10 +343,10 @@ export class PipelexApiClient {
|
|
|
339
343
|
* reads it. A body that is JSON but not an object — `null`, a number, a string, an array — is
|
|
340
344
|
* an answer the SDK cannot read either, and throws the same `ApiResponseError`, with no `cause`.
|
|
341
345
|
*/
|
|
342
|
-
readObjectAnswerAt(method, path, res) {
|
|
343
|
-
const answer = this.readAnswerAt(method, path, res);
|
|
346
|
+
readObjectAnswerAt(method, path, res, run = {}) {
|
|
347
|
+
const answer = this.readAnswerAt(method, path, res, run);
|
|
344
348
|
if (!isPlainObject(answer)) {
|
|
345
|
-
throw this.unreadableAnswer(method, path, res, "a body that is not an object");
|
|
349
|
+
throw this.unreadableAnswer(method, path, res, "a body that is not an object", undefined, run);
|
|
346
350
|
}
|
|
347
351
|
return answer;
|
|
348
352
|
}
|
|
@@ -351,20 +355,29 @@ export class PipelexApiClient {
|
|
|
351
355
|
* `API <method> <path> answered <status> with <what>`: the answer's status, its raw text as
|
|
352
356
|
* `responseBody`, no problem member but the `X-Request-ID` header, the failure that made it
|
|
353
357
|
* unreadable as `cause`, and the verdict the fallback gives a 2xx, `runtime` and not retryable.
|
|
358
|
+
* On a request that may create a run, the run may have started: the server accepted it.
|
|
354
359
|
*/
|
|
355
|
-
unreadableAnswer(method, path, res, what, cause) {
|
|
356
|
-
return new ApiResponseError(`API ${method} ${path} answered ${res.status} with ${what}`, this.baseUrl, res.status, res.statusText, res.body, undefined, undefined, undefined, undefined, {
|
|
360
|
+
unreadableAnswer(method, path, res, what, cause, run = {}) {
|
|
361
|
+
return new ApiResponseError(`API ${method} ${path} answered ${res.status} with ${what}`, this.baseUrl, res.status, res.statusText, res.body, undefined, undefined, undefined, undefined, {
|
|
362
|
+
cause,
|
|
363
|
+
problem: { requestId: nonEmptyHeader(res.headers, REQUEST_ID_HEADER) },
|
|
364
|
+
mayStartRun: run.mayStartRun === true,
|
|
365
|
+
});
|
|
357
366
|
}
|
|
358
367
|
/**
|
|
359
368
|
* Throw the `ApiResponseError` of a non-2xx answer, naming the route by its `path` from the
|
|
360
369
|
* origin (`/v1/...`, or `/health` for the liveness probe).
|
|
361
370
|
*/
|
|
362
|
-
throwApiResponseErrorAt(method, path, res) {
|
|
371
|
+
throwApiResponseErrorAt(method, path, res, run = {}) {
|
|
363
372
|
const { errorType, serverMessage, validationErrors, code, problem, document } = parseErrorBody(res.body);
|
|
364
373
|
// The body's `request_id` wins; the header is the fallback for a response whose body
|
|
365
374
|
// carries none (a gateway error page, a non-problem body).
|
|
366
375
|
const requestId = problem.requestId ?? nonEmptyHeader(res.headers, REQUEST_ID_HEADER);
|
|
367
|
-
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, {
|
|
376
|
+
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, {
|
|
377
|
+
problem: { ...problem, requestId },
|
|
378
|
+
problemDocument: document,
|
|
379
|
+
mayStartRun: run.mayStartRun === true,
|
|
380
|
+
});
|
|
368
381
|
}
|
|
369
382
|
/**
|
|
370
383
|
* Translate a "route absent" 404 (a bare pipelex-api with no platform block)
|
|
@@ -386,23 +399,21 @@ export class PipelexApiClient {
|
|
|
386
399
|
* error. Hosted does not emit 202 today, but the protocol permits it;
|
|
387
400
|
* raising a typed error (with the `pipeline_run_id` + `Location` + `Retry-After`
|
|
388
401
|
* hints) beats a generic parse failure on an unexpected body shape.
|
|
402
|
+
*
|
|
403
|
+
* The 202 must name the run it accepted, as `start`'s acknowledgement must: one whose body is
|
|
404
|
+
* not JSON, or names no run (an empty id included), leaves the caller a run it cannot follow, so it is an answer the
|
|
405
|
+
* SDK cannot read, which may have started a run, never a `RunStillRunningError` without an id.
|
|
406
|
+
* The Python SDK reads the same answers the same way.
|
|
389
407
|
*/
|
|
390
408
|
throwIfExecuteDegraded(res) {
|
|
391
409
|
if (res.status !== 202)
|
|
392
410
|
return;
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
const candidate = parsed.pipeline_run_id;
|
|
398
|
-
if (typeof candidate === "string")
|
|
399
|
-
runId = candidate;
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
catch {
|
|
403
|
-
// Non-JSON 202 body — keep runId empty; the error message covers it.
|
|
411
|
+
const ack = this.readObjectAnswer("POST", "execute", res, STARTS_RUN);
|
|
412
|
+
const runId = ack.pipeline_run_id;
|
|
413
|
+
if (typeof runId !== "string" || runId === "") {
|
|
414
|
+
throw this.unreadableAnswer("POST", `/${API_PREFIX}/execute`, res, "a body that is not the answer the route returns", undefined, STARTS_RUN);
|
|
404
415
|
}
|
|
405
|
-
throw new RunStillRunningError(`execute() was accepted asynchronously (202): run ${runId
|
|
416
|
+
throw new RunStillRunningError(`execute() was accepted asynchronously (202): run ${runId} is still ` +
|
|
406
417
|
"running server-side. Poll its results (hosted) or use start().", runId, parseRetryAfter(res.headers), res.headers.get("location"));
|
|
407
418
|
}
|
|
408
419
|
// ── Health ────────────────────────────────────────────────────────
|
|
@@ -454,6 +465,9 @@ export class PipelexApiClient {
|
|
|
454
465
|
assertRunSourcesExclusive(options);
|
|
455
466
|
assertMethodRefPairsWithNothing(options);
|
|
456
467
|
assertLinkageMethodIdIsBare(options);
|
|
468
|
+
assertResourcesAccompanyABundle(options);
|
|
469
|
+
// No await when there are none: a request without them leaves on the tick it always did.
|
|
470
|
+
const resources = options.resources == null ? undefined : await encodeBundleResources(options.resources);
|
|
457
471
|
const request = {
|
|
458
472
|
pipe_code: options.pipe_code,
|
|
459
473
|
mthds_contents: options.mthds_contents,
|
|
@@ -463,6 +477,7 @@ export class PipelexApiClient {
|
|
|
463
477
|
dynamic_output_concept_ref: options.dynamic_output_concept_ref,
|
|
464
478
|
files: nonEmptyFiles(options.files),
|
|
465
479
|
bundle_b64: nonEmptyString(options.bundle_b64),
|
|
480
|
+
resources,
|
|
466
481
|
...api,
|
|
467
482
|
...hosted,
|
|
468
483
|
...extensions,
|
|
@@ -472,14 +487,19 @@ export class PipelexApiClient {
|
|
|
472
487
|
try {
|
|
473
488
|
const res = await this.requestRaw("POST", this.url("execute"), {
|
|
474
489
|
body: request,
|
|
490
|
+
...STARTS_RUN,
|
|
475
491
|
});
|
|
476
492
|
this.throwIfExecuteDegraded(res);
|
|
477
493
|
if (res.status < 200 || res.status >= 300) {
|
|
478
|
-
this.throwApiResponseError("POST", "execute", res);
|
|
494
|
+
this.throwApiResponseError("POST", "execute", res, STARTS_RUN);
|
|
479
495
|
}
|
|
480
496
|
// Wrap the base result in the enriched subtype (adds the `.main_stuff` accessor; the
|
|
481
497
|
// `main_stuff_name` extension + working memory ride `pipe_output`).
|
|
482
|
-
|
|
498
|
+
const answer = this.readObjectAnswer("POST", "execute", res, STARTS_RUN);
|
|
499
|
+
if (!isCompletedRunAnswer(answer)) {
|
|
500
|
+
throw this.unreadableAnswer("POST", `/${API_PREFIX}/execute`, res, "a body that is not the answer the route returns", undefined, STARTS_RUN);
|
|
501
|
+
}
|
|
502
|
+
return new PipelexExecuteResult(answer);
|
|
483
503
|
}
|
|
484
504
|
catch (err) {
|
|
485
505
|
if (err instanceof RunStillRunningError)
|
|
@@ -522,6 +542,9 @@ export class PipelexApiClient {
|
|
|
522
542
|
assertRunSourcesExclusive(options);
|
|
523
543
|
assertMethodRefPairsWithNothing(options);
|
|
524
544
|
assertLinkageMethodIdIsBare(options);
|
|
545
|
+
assertResourcesAccompanyABundle(options);
|
|
546
|
+
// No await when there are none: a request without them leaves on the tick it always did.
|
|
547
|
+
const resources = options.resources == null ? undefined : await encodeBundleResources(options.resources);
|
|
525
548
|
// `?? undefined` so JSON.stringify drops absent fields from the wire body.
|
|
526
549
|
const request = {
|
|
527
550
|
pipe_code: options.pipe_code ?? undefined,
|
|
@@ -532,6 +555,7 @@ export class PipelexApiClient {
|
|
|
532
555
|
dynamic_output_concept_ref: options.dynamic_output_concept_ref ?? undefined,
|
|
533
556
|
files: nonEmptyFiles(options.files),
|
|
534
557
|
bundle_b64: nonEmptyString(options.bundle_b64),
|
|
558
|
+
resources,
|
|
535
559
|
...api,
|
|
536
560
|
...hosted,
|
|
537
561
|
...extensions,
|
|
@@ -539,29 +563,38 @@ export class PipelexApiClient {
|
|
|
539
563
|
const url = this.url("start");
|
|
540
564
|
// `start` returns a 202 fast, so the poll timeout normally fits — with
|
|
541
565
|
// exceptions that get the blocking-execute ceiling instead. A method bundle,
|
|
542
|
-
// inline as `mthds_contents` or in `files`/`bundle_b64`,
|
|
543
|
-
// request *body* multi-megabyte, and
|
|
544
|
-
// charged against this budget — the same payload must
|
|
545
|
-
// durable path yet succeed on the fallback. And a
|
|
546
|
-
// the server FETCH the package before the ack
|
|
547
|
-
// whose clone timeout runs well past 30s on a
|
|
548
|
-
// would surface as `ApiUnreachableError`,
|
|
549
|
-
// server that is still cloning.
|
|
566
|
+
// inline as `mthds_contents` or in `files`/`bundle_b64`, or the files its
|
|
567
|
+
// steps read (`resources`), can make the request *body* multi-megabyte, and
|
|
568
|
+
// the whole upload is charged against this budget — the same payload must
|
|
569
|
+
// not time out on the durable path yet succeed on the fallback. And a
|
|
570
|
+
// `method_ref` start makes the server FETCH the package before the ack
|
|
571
|
+
// (provenance rides the 202), whose clone timeout runs well past 30s on a
|
|
572
|
+
// cold cache — aborting it here would surface as `ApiUnreachableError`,
|
|
573
|
+
// blaming the network for a healthy server that is still cloning.
|
|
550
574
|
const needsLongCeiling = hasBundlePayload(options) ||
|
|
551
575
|
(options.mthds_contents?.length ?? 0) > 0 ||
|
|
576
|
+
resources !== undefined ||
|
|
552
577
|
nonEmptyString(options.method_ref) !== undefined;
|
|
553
578
|
const res = await this.requestRaw("POST", url, {
|
|
554
579
|
body: request,
|
|
555
580
|
timeoutMs: needsLongCeiling ? DEFAULT_REQUEST_TIMEOUT_MS : POLL_REQUEST_TIMEOUT_MS,
|
|
581
|
+
...STARTS_RUN,
|
|
556
582
|
});
|
|
557
583
|
// A bare runner with no run store 404s here just as it does on the result
|
|
558
584
|
// routes — surface the same clear `RunLifecycleUnavailableError` (and let
|
|
559
585
|
// `startAndWaitForResult` fall back to the blocking `execute`).
|
|
560
586
|
this.throwIfLifecycleUnavailable(res, url);
|
|
561
587
|
if (res.status < 200 || res.status >= 300) {
|
|
562
|
-
this.throwApiResponseError("POST", "start", res);
|
|
588
|
+
this.throwApiResponseError("POST", "start", res, STARTS_RUN);
|
|
589
|
+
}
|
|
590
|
+
const ack = this.readObjectAnswer("POST", "start", res, STARTS_RUN);
|
|
591
|
+
// The acknowledgement's one job is to name the run: without its id, the run the server
|
|
592
|
+
// accepted is lost to the caller, as surely as behind a body that is not JSON, and an empty id
|
|
593
|
+
// names no run either. Worded as the Python SDK words an answer its model refuses.
|
|
594
|
+
if (typeof ack.pipeline_run_id !== "string" || ack.pipeline_run_id === "") {
|
|
595
|
+
throw this.unreadableAnswer("POST", `/${API_PREFIX}/start`, res, "a body that is not the answer the route returns", undefined, STARTS_RUN);
|
|
563
596
|
}
|
|
564
|
-
return
|
|
597
|
+
return ack;
|
|
565
598
|
}
|
|
566
599
|
/**
|
|
567
600
|
* Parse, validate, and dry-run an MTHDS bundle — `POST /v1/validate`.
|
|
@@ -622,6 +655,15 @@ export class PipelexApiClient {
|
|
|
622
655
|
* the field regardless — which is why `PipelexValidationReport.input_form` is typed
|
|
623
656
|
* optional rather than required.
|
|
624
657
|
*
|
|
658
|
+
* `options.resources` sends the files the bundle's steps read, such as a
|
|
659
|
+
* `PipeDocGen` step's `template_file`, keyed by the path the step names, each as
|
|
660
|
+
* its bytes (see {@link ValidateOptions}). A step whose template file was not sent
|
|
661
|
+
* is an `is_valid: false` verdict saying so, and a template check's findings in an
|
|
662
|
+
* office template file ride the valid arm's `warnings`. A key no step names, or
|
|
663
|
+
* files over the server's limits, are no-verdict refusals: a `422`
|
|
664
|
+
* `BundleResourceNotNamedError`, a `413` `PayloadTooLarge` or
|
|
665
|
+
* `BundleResourcesTooLargeError`.
|
|
666
|
+
*
|
|
625
667
|
* A `{ method_ref }` source makes the server clone a repository before it
|
|
626
668
|
* validates anything, and it needs no special budget here: this route already
|
|
627
669
|
* defaults to the 20-minute execute ceiling, which clears the internal
|
|
@@ -659,6 +701,9 @@ export class PipelexApiClient {
|
|
|
659
701
|
throw new RequestArgumentError("mthds_sources labels inline mthds_contents; a method_ref / method_id validation gets " +
|
|
660
702
|
"its source labels from the package's (or the stored method's) real file names.");
|
|
661
703
|
}
|
|
704
|
+
if (methodRef !== undefined && hasBundleResources(options.resources)) {
|
|
705
|
+
throw new RequestArgumentError("resources accompany inline mthds_contents; a method_ref's files are the package's own.");
|
|
706
|
+
}
|
|
662
707
|
if (methodRef !== undefined)
|
|
663
708
|
body.method_ref = methodRef;
|
|
664
709
|
if (methodId !== undefined)
|
|
@@ -668,6 +713,11 @@ export class PipelexApiClient {
|
|
|
668
713
|
if (views !== undefined) {
|
|
669
714
|
body.views = views;
|
|
670
715
|
}
|
|
716
|
+
// No await when there are none: a request without them leaves on the tick it always did.
|
|
717
|
+
const resources = options.resources == null ? undefined : await encodeBundleResources(options.resources);
|
|
718
|
+
if (resources !== undefined) {
|
|
719
|
+
body.resources = resources;
|
|
720
|
+
}
|
|
671
721
|
const res = await this.requestRaw("POST", this.url("validate"), {
|
|
672
722
|
body,
|
|
673
723
|
timeoutMs: options.timeoutMs,
|
|
@@ -696,6 +746,7 @@ export class PipelexApiClient {
|
|
|
696
746
|
? files.map((file, index) => file.uri ?? `inline://file-${index + 1}.mthds`)
|
|
697
747
|
: undefined;
|
|
698
748
|
return this.validate(mthdsContents, options.allowSignatures ?? false, mthdsSources, options.render, options.views, {
|
|
749
|
+
resources: options.resources,
|
|
699
750
|
timeoutMs: options.timeoutMs,
|
|
700
751
|
signal: options.signal,
|
|
701
752
|
});
|
|
@@ -770,10 +821,11 @@ export class PipelexApiClient {
|
|
|
770
821
|
* never on a message.
|
|
771
822
|
*
|
|
772
823
|
* All of these are static and inference-free, so they default to the management-call
|
|
773
|
-
* timeout. The
|
|
774
|
-
*
|
|
775
|
-
* repository before it answers
|
|
776
|
-
*
|
|
824
|
+
* timeout. The internal raises are `crateRequestTimeoutMs`'s: the fetch-sized budget a
|
|
825
|
+
* crate route gets when its closure is a `method_ref`, because the server may clone a
|
|
826
|
+
* repository before it answers, and the execute ceiling it gets when the request
|
|
827
|
+
* carries `resources`, whose upload alone can outlast the default. Each is matched to
|
|
828
|
+
* what the request costs, not a caller-facing parameter.
|
|
777
829
|
*
|
|
778
830
|
* **These routes deliberately expose no `timeoutMs` / `signal`, and that is not an
|
|
779
831
|
* oversight to be "fixed" per route.** Automated reviewers have proposed adding them
|
|
@@ -784,8 +836,11 @@ export class PipelexApiClient {
|
|
|
784
836
|
* ceiling and takes caller transport options; every extension route rides the
|
|
785
837
|
* static core. Giving one static route an override while its siblings lack one is
|
|
786
838
|
* the inconsistency.
|
|
787
|
-
* 2. The input is **bounded server-side** — the runner's `pipelex_api/limits.py` caps
|
|
788
|
-
*
|
|
839
|
+
* 2. The input is **bounded server-side** — the runner's `pipelex_api/limits.py` caps the
|
|
840
|
+
* `.mthds` files of a request and the files sent with them in `resources`, each by a
|
|
841
|
+
* count, a size per file and, for `resources`, a total, all configurable per
|
|
842
|
+
* deployment — and none of these routes runs inference. The upload those limits allow
|
|
843
|
+
* is what `crateRequestTimeoutMs` budgets for, not the server's work.
|
|
789
844
|
* 3. On the hosted path an override would be **inert**: the gateway caps responses at
|
|
790
845
|
* ~30s (see `POLL_REQUEST_TIMEOUT_MS` above), so raising `timeoutMs` would still be
|
|
791
846
|
* cut off upstream, just with a less honest error.
|
|
@@ -907,7 +962,7 @@ export class PipelexApiClient {
|
|
|
907
962
|
* see {@link PipelexHostedToolingExtensions}).
|
|
908
963
|
*/
|
|
909
964
|
async resolve(request) {
|
|
910
|
-
return this.requestExtension("resolve", request, {
|
|
965
|
+
return this.requestExtension("resolve", await wireCrateRequest(request), {
|
|
911
966
|
timeoutMs: crateRequestTimeoutMs(request),
|
|
912
967
|
});
|
|
913
968
|
}
|
|
@@ -927,7 +982,7 @@ export class PipelexApiClient {
|
|
|
927
982
|
* a malformed selector is a 422; a registry-form `method_ref` is a `501`.
|
|
928
983
|
*/
|
|
929
984
|
async codegen(request) {
|
|
930
|
-
return this.requestExtension("codegen", request, {
|
|
985
|
+
return this.requestExtension("codegen", await wireCrateRequest(request), {
|
|
931
986
|
timeoutMs: crateRequestTimeoutMs(request),
|
|
932
987
|
});
|
|
933
988
|
}
|
|
@@ -947,8 +1002,14 @@ export class PipelexApiClient {
|
|
|
947
1002
|
* describes every pipe instead, and never refuses for want of an entry pipe.
|
|
948
1003
|
* `include_files: true` echoes the resolved closure's `.mthds` files as `files`.
|
|
949
1004
|
*
|
|
1005
|
+
* Beside inline `files`, `resources` sends the files the closure's steps read, such as a
|
|
1006
|
+
* `PipeDocGen` step's `template_file`, keyed by the path the step names, each as its bytes;
|
|
1007
|
+
* a step whose template file was not sent makes the closure invalid. `resolve` and `codegen`
|
|
1008
|
+
* take it too (see {@link CrateRequestBase}).
|
|
1009
|
+
*
|
|
950
1010
|
* Same 200-verdict discipline and same three-form closure selector as {@link resolve}
|
|
951
|
-
* (the request is posted
|
|
1011
|
+
* (the request is posted as given, its `resources` encoded to base64, and the selector XOR
|
|
1012
|
+
* and the `resources` pairing are the server's to enforce).
|
|
952
1013
|
* Only a no-verdict condition throws `ApiResponseError`: a malformed selector and an
|
|
953
1014
|
* over-limit file are `ValidationError` `422`s; a selection the route refuses is a
|
|
954
1015
|
* `422` whose `errorType` is `EntryPipeNotFoundError` (an unknown `pipe_ref`, no entry
|
|
@@ -956,13 +1017,14 @@ export class PipelexApiClient {
|
|
|
956
1017
|
* candidates in its `serverMessage`; a registry-form `method_ref` is a `501`; a pipe
|
|
957
1018
|
* whose artifacts cannot be derived is a `500`.
|
|
958
1019
|
*
|
|
959
|
-
* A `method_ref` gets the fetch-sized budget, as on the other crate routes
|
|
960
|
-
*
|
|
961
|
-
*
|
|
962
|
-
*
|
|
1020
|
+
* A `method_ref` gets the fetch-sized budget, as on the other crate routes, and a
|
|
1021
|
+
* request carrying `resources` the execute ceiling, since their upload alone can
|
|
1022
|
+
* outlast the default. On the hosted API the gateway caps a request at about 30
|
|
1023
|
+
* seconds whatever the client allows, so a cold `method_ref` clone can answer a `502`
|
|
1024
|
+
* that a retry clears once the runner has cached the clone.
|
|
963
1025
|
*/
|
|
964
1026
|
async pipeIo(request) {
|
|
965
|
-
return this.requestExtension("pipe-io", request, {
|
|
1027
|
+
return this.requestExtension("pipe-io", await wireCrateRequest(request), {
|
|
966
1028
|
timeoutMs: crateRequestTimeoutMs(request),
|
|
967
1029
|
});
|
|
968
1030
|
}
|
|
@@ -971,12 +1033,17 @@ export class PipelexApiClient {
|
|
|
971
1033
|
* Fetch a run's status by bare id — `GET /v1/runs/{pipeline_run_id}/status`.
|
|
972
1034
|
*
|
|
973
1035
|
* Self-healing: a finished-but-unrecorded run resolves to its true terminal
|
|
974
|
-
* status on read. `degraded: true` means
|
|
975
|
-
* `
|
|
976
|
-
*
|
|
977
|
-
* is
|
|
978
|
-
*
|
|
979
|
-
*
|
|
1036
|
+
* status on read. `degraded: true` means the read is not a plain fresh reading,
|
|
1037
|
+
* and `degradation.reason` says why (see `DegradationReason`):
|
|
1038
|
+
* `status_unavailable` when the platform could not reach the workflow engine
|
|
1039
|
+
* and `status` is the last one recorded, `workflow_task_failing` when `status`
|
|
1040
|
+
* is live and the run is stuck, its workflow task failing until the platform
|
|
1041
|
+
* ends it at `degradation.ends_at`. `retry_after_seconds` carries the server's
|
|
1042
|
+
* `Retry-After` hint when present, and `request_id` the id to quote to support.
|
|
1043
|
+
* A failed run's stored report, `error`, and the `degradation` are each
|
|
1044
|
+
* checked field by field (see `RunErrorReport` and `RunDegradation`), so
|
|
1045
|
+
* neither ever fails the read. Throws `RunLifecycleUnavailableError` when the
|
|
1046
|
+
* lifecycle routes are absent (a bare runner).
|
|
980
1047
|
*/
|
|
981
1048
|
async getRunStatus(runId, options = {}) {
|
|
982
1049
|
const endpoint = `${RUNS}/${encodeURIComponent(runId)}/status`;
|
|
@@ -989,7 +1056,7 @@ export class PipelexApiClient {
|
|
|
989
1056
|
if (res.status < 200 || res.status >= 300) {
|
|
990
1057
|
this.throwApiResponseError("GET", endpoint, res);
|
|
991
1058
|
}
|
|
992
|
-
const run = withCheckedReport(this.readObjectAnswer("GET", endpoint, res));
|
|
1059
|
+
const run = withCheckedDegradation(withCheckedReport(this.readObjectAnswer("GET", endpoint, res)));
|
|
993
1060
|
const retryAfter = parseRetryAfter(res.headers);
|
|
994
1061
|
return retryAfter !== null ? { ...run, retry_after_seconds: retryAfter } : run;
|
|
995
1062
|
}
|
|
@@ -1104,6 +1171,13 @@ export class PipelexApiClient {
|
|
|
1104
1171
|
* `pollOptions.signal` is also read before the run is created: a caller that aborts while the
|
|
1105
1172
|
* version handshake is in flight gets its abort, and neither the start nor the blocking execute
|
|
1106
1173
|
* is sent. Once one of them is sent, the abort stops only the wait.
|
|
1174
|
+
*
|
|
1175
|
+
* An error from the start, or from the blocking execute, says whether that request may still
|
|
1176
|
+
* have started a run whose id never reached the caller (`runMayHaveStarted`); when it may have,
|
|
1177
|
+
* it is not retryable, since calling this method again could pay for the run twice: look the run
|
|
1178
|
+
* up in the run history first. An error from the wait, once `onStarted` has named the run,
|
|
1179
|
+
* started nothing, so its `runMayHaveStarted` is false, and its `retryable` is about waiting
|
|
1180
|
+
* again on that id with `waitForResult`, never about calling this method again.
|
|
1107
1181
|
*/
|
|
1108
1182
|
async startAndWaitForResult(options, pollOptions) {
|
|
1109
1183
|
// Before the run starts: a refusal after it would carry no run id to re-poll by.
|
|
@@ -1622,7 +1696,8 @@ export class PipelexApiClient {
|
|
|
1622
1696
|
* Name the method exactly one of three ways, all server-resolved through the
|
|
1623
1697
|
* one `pipeIo` call this composes:
|
|
1624
1698
|
*
|
|
1625
|
-
* - `files` — the inline MTHDS closure
|
|
1699
|
+
* - `files` — the inline MTHDS closure, with the `resources` its steps read
|
|
1700
|
+
* (a `PipeDocGen` step's `template_file`), keyed by the path a step names;
|
|
1626
1701
|
* - `method_ref` — a published method's address, fetched by the runner;
|
|
1627
1702
|
* - `method_id` — a stored method's catalog id, resolved by the platform
|
|
1628
1703
|
* (hosted only; requires an API key).
|
|
@@ -1632,6 +1707,12 @@ export class PipelexApiClient {
|
|
|
1632
1707
|
* answers with, which states the kind of every input at every depth, so a file
|
|
1633
1708
|
* position is a fact of the method, never a guess from the value's shape. It
|
|
1634
1709
|
* needs an API serving `POST /v1/pipe-io`. See `docs/input-preparation.md`.
|
|
1710
|
+
*
|
|
1711
|
+
* A value at a file input that cannot be a file, such as a number or a
|
|
1712
|
+
* `data:` URL that does not decode, throws `InvalidInputValueError`, naming the
|
|
1713
|
+
* input. With `allowUploads: false` it uploads nothing and reads no file: a
|
|
1714
|
+
* value that would need an upload throws `UploadNotAllowedError`, naming the
|
|
1715
|
+
* input too.
|
|
1635
1716
|
*/
|
|
1636
1717
|
async prepareInputs(request) {
|
|
1637
1718
|
return prepareInputsImpl(this, request);
|
|
@@ -1770,6 +1851,8 @@ export class PipelexApiClient {
|
|
|
1770
1851
|
// this path runs the same method as the durable one, or it runs nothing.
|
|
1771
1852
|
files: options.files ?? undefined,
|
|
1772
1853
|
bundle_b64: options.bundle_b64 ?? undefined,
|
|
1854
|
+
// And so must the files its steps read, or the same method fails to load here.
|
|
1855
|
+
resources: options.resources ?? undefined,
|
|
1773
1856
|
method_ref: options.method_ref ?? undefined,
|
|
1774
1857
|
method_id: options.method_id ?? undefined,
|
|
1775
1858
|
extra: options.extra ?? undefined,
|
|
@@ -1850,12 +1933,13 @@ const PROTOCOL_REQUEST_KEYS = [
|
|
|
1850
1933
|
"files",
|
|
1851
1934
|
"bundle_b64",
|
|
1852
1935
|
];
|
|
1853
|
-
// The PIPELEX API's own request fields — the layer-2
|
|
1854
|
-
//
|
|
1855
|
-
//
|
|
1856
|
-
//
|
|
1857
|
-
// selector-exclusivity
|
|
1858
|
-
|
|
1936
|
+
// The PIPELEX API's own request fields — the layer-2 extensions the runner
|
|
1937
|
+
// reads itself (`method_ref`, a run source in its own right, and `resources`,
|
|
1938
|
+
// the files the bundle's steps read). Reserved on `extra` for the same reason
|
|
1939
|
+
// the protocol fields are: `extra` merges last, so a smuggled copy would
|
|
1940
|
+
// overwrite the validated named option and bypass the selector-exclusivity and
|
|
1941
|
+
// path checks.
|
|
1942
|
+
const PIPELEX_API_REQUEST_KEYS = ["method_ref", "resources"];
|
|
1859
1943
|
// The HOSTED API's own request fields — the layer-3 extensions this client
|
|
1860
1944
|
// names itself. Reserved on `extra` for the same reason the protocol fields
|
|
1861
1945
|
// are: `extra` merges last, so a smuggled copy would overwrite the validated
|
|
@@ -1918,10 +2002,20 @@ function buildExtensions(extra) {
|
|
|
1918
2002
|
const METHOD_REF_FETCH_TIMEOUT_MS = 180_000; // 3 min — covers the server's clone + resolve
|
|
1919
2003
|
/**
|
|
1920
2004
|
* The internal request budget for a crate route's `CrateRequestBase` closure
|
|
1921
|
-
* (`resolve`, `codegen`, `pipeIo`): the static-route default, unless the
|
|
1922
|
-
* is
|
|
2005
|
+
* (`resolve`, `codegen`, `pipeIo`): the static-route default, unless the request
|
|
2006
|
+
* is one the default would cut short through no fault of the server's.
|
|
2007
|
+
*
|
|
2008
|
+
* - **`resources`** make the request *body* as large as the runner's limits allow,
|
|
2009
|
+
* about 13 MiB of base64 by default, and the whole upload is charged against
|
|
2010
|
+
* this budget. They get the execute ceiling `start` gives a request carrying
|
|
2011
|
+
* them, and `validate` carries them under by default, so the same bundle and
|
|
2012
|
+
* files never time out on one route yet go through on another.
|
|
2013
|
+
* - **A `method_ref`** may make the server clone a repository first, which gets
|
|
2014
|
+
* the fetch-sized budget.
|
|
1923
2015
|
*/
|
|
1924
2016
|
function crateRequestTimeoutMs(request) {
|
|
2017
|
+
if (hasBundleResources(request.resources))
|
|
2018
|
+
return DEFAULT_REQUEST_TIMEOUT_MS;
|
|
1925
2019
|
return nonEmptyString(request.method_ref) !== undefined ? METHOD_REF_FETCH_TIMEOUT_MS : undefined;
|
|
1926
2020
|
}
|
|
1927
2021
|
/**
|
|
@@ -2027,6 +2121,43 @@ function assertLinkageMethodIdIsBare(options) {
|
|
|
2027
2121
|
"that did not. Send the bare id (parseMethodSelector(...).method_id), or drop the inline " +
|
|
2028
2122
|
"source to run the version the selector names.");
|
|
2029
2123
|
}
|
|
2124
|
+
/**
|
|
2125
|
+
* Enforce the run routes' `resources` pairing, mirroring the runner's own `422`s
|
|
2126
|
+
* so an illegal pairing fails before anything is encoded or sent. The files a
|
|
2127
|
+
* bundle's steps read accompany that bundle: inline `mthds_contents` or a method
|
|
2128
|
+
* bundle. Beside a `method_ref` they are refused, a published package's files
|
|
2129
|
+
* being its own, and so they are with no source at all, where no step could read
|
|
2130
|
+
* them. A hosted `method_id` without an inline source is left to the server: the
|
|
2131
|
+
* platform resolves it into the bundle the runner then reads them with.
|
|
2132
|
+
*
|
|
2133
|
+
* Presence follows the other pairing checks: `resources` counts when it carries a
|
|
2134
|
+
* file, `mthds_contents` when non-empty, a bundle when `hasBundlePayload` says so.
|
|
2135
|
+
*/
|
|
2136
|
+
function assertResourcesAccompanyABundle(options) {
|
|
2137
|
+
if (!hasBundleResources(options.resources))
|
|
2138
|
+
return;
|
|
2139
|
+
if (nonEmptyString(options.method_ref) !== undefined) {
|
|
2140
|
+
throw new RequestArgumentError("resources accompany inline mthds_contents or a method bundle; a method_ref's files are the package's own.");
|
|
2141
|
+
}
|
|
2142
|
+
const hasInlineSource = (options.mthds_contents != null && options.mthds_contents.length > 0) ||
|
|
2143
|
+
hasBundlePayload(options);
|
|
2144
|
+
if (!hasInlineSource && nonEmptyString(options.method_id) === undefined) {
|
|
2145
|
+
throw new RequestArgumentError("resources accompany the bundle whose steps read them: send them with mthds_contents or a method bundle.");
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
/**
|
|
2149
|
+
* A crate request as the wire takes it: the request as given, its `resources`
|
|
2150
|
+
* encoded into the base64 map, the key left off when there is nothing to send.
|
|
2151
|
+
* A request that carries no `resources` key is posted untouched.
|
|
2152
|
+
*/
|
|
2153
|
+
async function wireCrateRequest(request) {
|
|
2154
|
+
if (typeof request !== "object" || request === null || !("resources" in request)) {
|
|
2155
|
+
return request;
|
|
2156
|
+
}
|
|
2157
|
+
const { resources, ...rest } = request;
|
|
2158
|
+
const encoded = await encodeBundleResources(resources);
|
|
2159
|
+
return encoded === undefined ? rest : { ...rest, resources: encoded };
|
|
2160
|
+
}
|
|
2030
2161
|
/**
|
|
2031
2162
|
* Normalize a bundle encoding for the wire: an empty map / string is NOT a
|
|
2032
2163
|
* runnable bundle, so it must not be sent (the runner rejects a zero-file
|
|
@@ -2063,9 +2194,10 @@ const GATEWAY_TIMEOUT_THRESHOLD_MS = 28_000;
|
|
|
2063
2194
|
* `is_gateway_cut_off` reads the same failures, its `ABORT_TIMEOUT` being httpx's read or write
|
|
2064
2195
|
* timeout.
|
|
2065
2196
|
*
|
|
2066
|
-
* The blocking `execute` turns such a failure into a `PipelineExecuteTimeoutError`.
|
|
2067
|
-
*
|
|
2068
|
-
* reads
|
|
2197
|
+
* The blocking `execute` turns such a failure into a `PipelineExecuteTimeoutError`. Whether a
|
|
2198
|
+
* failed `start` or `execute` may have started a run needs no timing: the error says it as
|
|
2199
|
+
* `runMayHaveStarted`, which reads every `503` but the platform's coded one as a run that may
|
|
2200
|
+
* have started, however quickly it came back.
|
|
2069
2201
|
*/
|
|
2070
2202
|
export function isGatewayCutOff(error, elapsedMs) {
|
|
2071
2203
|
if (elapsedMs < GATEWAY_TIMEOUT_THRESHOLD_MS)
|
|
@@ -2125,6 +2257,25 @@ function isMissingRoute404(body) {
|
|
|
2125
2257
|
return true;
|
|
2126
2258
|
return !("code" in parsed);
|
|
2127
2259
|
}
|
|
2260
|
+
/**
|
|
2261
|
+
* Whether a 2xx answer to `execute` is the completed run the route returns, as far as the SDK reads
|
|
2262
|
+
* it: a string `pipeline_run_id`, and a `pipe_output` object whose `working_memory` carries its
|
|
2263
|
+
* `root` and `aliases` objects and whose own `pipeline_run_id` is a string, the envelope the Python
|
|
2264
|
+
* SDK's model of the answer requires. Anything less is an answer the SDK cannot read, rather than
|
|
2265
|
+
* a result that fails with a bare `TypeError` once its output is lifted. The stuffs inside `root`,
|
|
2266
|
+
* which the Python model also checks, are left to the read of the main stuff, whose
|
|
2267
|
+
* `MissingMainStuffError` names the run.
|
|
2268
|
+
*/
|
|
2269
|
+
function isCompletedRunAnswer(answer) {
|
|
2270
|
+
const { pipeline_run_id: runId, pipe_output: pipeOutput } = answer;
|
|
2271
|
+
if (typeof runId !== "string" || !isPlainObject(pipeOutput))
|
|
2272
|
+
return false;
|
|
2273
|
+
const workingMemory = pipeOutput["working_memory"];
|
|
2274
|
+
return (typeof pipeOutput["pipeline_run_id"] === "string" &&
|
|
2275
|
+
isPlainObject(workingMemory) &&
|
|
2276
|
+
isPlainObject(workingMemory["root"]) &&
|
|
2277
|
+
isPlainObject(workingMemory["aliases"]));
|
|
2278
|
+
}
|
|
2128
2279
|
/** Parse the `Retry-After` header (seconds form, which the platform uses). */
|
|
2129
2280
|
function parseRetryAfter(headers) {
|
|
2130
2281
|
const raw = headers.get("retry-after");
|
|
@@ -2181,6 +2332,15 @@ function withCheckedReport(record) {
|
|
|
2181
2332
|
return record;
|
|
2182
2333
|
return { ...record, error: readRunErrorReport(record.error) };
|
|
2183
2334
|
}
|
|
2335
|
+
/**
|
|
2336
|
+
* A status read with its `degradation` checked by `readRunDegradation`. Only when the read carries
|
|
2337
|
+
* the key, so a platform that predates it still leaves it absent; `degraded` is left as sent.
|
|
2338
|
+
*/
|
|
2339
|
+
function withCheckedDegradation(run) {
|
|
2340
|
+
if (!Object.hasOwn(run, "degradation"))
|
|
2341
|
+
return run;
|
|
2342
|
+
return { ...run, degradation: readRunDegradation(run.degradation) };
|
|
2343
|
+
}
|
|
2184
2344
|
function knownRunStatus(value) {
|
|
2185
2345
|
return typeof value === "string" && KNOWN_RUN_STATUSES.includes(value)
|
|
2186
2346
|
? value
|