@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.
Files changed (52) hide show
  1. package/README.md +3 -2
  2. package/dist/bundle-resources.d.ts +62 -0
  3. package/dist/bundle-resources.js +165 -0
  4. package/dist/bundle-resources.js.map +1 -0
  5. package/dist/cli/run.js +8 -111
  6. package/dist/cli/run.js.map +1 -1
  7. package/dist/cli/script.d.ts +1 -1
  8. package/dist/cli/source.d.ts +2 -1
  9. package/dist/cli/source.js +61 -3
  10. package/dist/cli/source.js.map +1 -1
  11. package/dist/client.d.ts +116 -30
  12. package/dist/client.js +235 -75
  13. package/dist/client.js.map +1 -1
  14. package/dist/error-models.d.ts +64 -18
  15. package/dist/error-models.js +20 -9
  16. package/dist/error-models.js.map +1 -1
  17. package/dist/errors.d.ts +125 -23
  18. package/dist/errors.js +124 -22
  19. package/dist/errors.js.map +1 -1
  20. package/dist/execute-result.js +4 -4
  21. package/dist/execute-result.js.map +1 -1
  22. package/dist/hooks/bundle-gather.d.ts +28 -1
  23. package/dist/hooks/bundle-gather.js +163 -3
  24. package/dist/hooks/bundle-gather.js.map +1 -1
  25. package/dist/hooks/claude-mthds-check.d.ts +4 -2
  26. package/dist/hooks/claude-mthds-check.js +5 -2
  27. package/dist/hooks/claude-mthds-check.js.map +1 -1
  28. package/dist/index.d.ts +7 -5
  29. package/dist/index.js +6 -2
  30. package/dist/index.js.map +1 -1
  31. package/dist/models.d.ts +42 -4
  32. package/dist/prepare-inputs.d.ts +58 -4
  33. package/dist/prepare-inputs.js +307 -61
  34. package/dist/prepare-inputs.js.map +1 -1
  35. package/dist/product-models.d.ts +13 -5
  36. package/dist/run-may-have-started.d.ts +31 -0
  37. package/dist/run-may-have-started.js +138 -0
  38. package/dist/run-may-have-started.js.map +1 -0
  39. package/dist/runs.d.ts +95 -7
  40. package/dist/runs.js +18 -0
  41. package/dist/runs.js.map +1 -1
  42. package/dist/version.d.ts +1 -1
  43. package/dist/version.js +1 -1
  44. package/docs/architecture.md +6 -3
  45. package/docs/bundle-resources.md +107 -0
  46. package/docs/cli.md +2 -1
  47. package/docs/crate-routes.md +5 -1
  48. package/docs/errors.md +87 -17
  49. package/docs/hook-bundle.md +2 -0
  50. package/docs/input-preparation.md +26 -4
  51. package/docs/run-results.md +39 -0
  52. 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, { cause, problem: { requestId: nonEmptyHeader(res.headers, REQUEST_ID_HEADER) } });
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, { problem: { ...problem, requestId }, problemDocument: document });
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
- let runId = "";
394
- try {
395
- const parsed = JSON.parse(res.body);
396
- if (parsed && typeof parsed === "object") {
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 || "<unknown>"} is still ` +
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
- return new PipelexExecuteResult(this.readObjectAnswer("POST", "execute", res));
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`, can make the
543
- // request *body* multi-megabyte, and the whole upload is
544
- // charged against this budget — the same payload must not time out on the
545
- // durable path yet succeed on the fallback. And a `method_ref` start makes
546
- // the server FETCH the package before the ack (provenance rides the 202),
547
- // whose clone timeout runs well past 30s on a cold cache — aborting it here
548
- // would surface as `ApiUnreachableError`, blaming the network for a healthy
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 this.readObjectAnswer("POST", "start", res);
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 one internal raise is the fetch-sized budget `crateRequestTimeoutMs`
774
- * gives a crate route whose closure is a `method_ref`, because the server may clone a
775
- * repository before it answers; it is matched to what the server does, not a
776
- * caller-facing parameter.
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 a request
788
- * at 16 `.mthds` files of 1 MiB each — and none of these routes runs inference.
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 verbatim, and the selector XOR is the server's to enforce).
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. On the
960
- * hosted API the gateway caps a request at about 30 seconds whatever the client
961
- * allows, so a cold `method_ref` clone can answer a `502` that a retry clears once the
962
- * runner has cached the clone.
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 Temporal was unreachable and
975
- * `status` is the last-known value; `retry_after_seconds` carries the
976
- * server's backoff hint when present. A failed run's stored report, `error`,
977
- * is checked field by field (see `RunErrorReport`). Throws
978
- * `RunLifecycleUnavailableError` when the lifecycle routes are absent (a bare
979
- * runner).
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 extension the runner
1854
- // resolves itself (`method_ref`, a run source in its own right). Reserved on
1855
- // `extra` for the same reason the protocol fields are: `extra` merges last, so
1856
- // a smuggled copy would overwrite the validated named option and bypass the
1857
- // selector-exclusivity checks.
1858
- const PIPELEX_API_REQUEST_KEYS = ["method_ref"];
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 closure
1922
- * is a `method_ref` the server may have to fetch first.
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`. A caller timing
2067
- * a request that may create a run, such as `start` from `startAndWaitForResult`'s `onStarting`,
2068
- * reads it to know the server may still be handling the request the gateway gave up on.
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