@pipelex/sdk 0.47.0 → 0.48.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 (46) hide show
  1. package/README.md +8 -6
  2. package/dist/cli/script.d.ts +1 -1
  3. package/dist/cli/source.d.ts +8 -7
  4. package/dist/cli/source.js +11 -5
  5. package/dist/cli/source.js.map +1 -1
  6. package/dist/client.d.ts +321 -210
  7. package/dist/client.js +819 -514
  8. package/dist/client.js.map +1 -1
  9. package/dist/errors.d.ts +5 -2
  10. package/dist/errors.js +5 -2
  11. package/dist/errors.js.map +1 -1
  12. package/dist/hooks/bundle-gather.d.ts +28 -40
  13. package/dist/hooks/bundle-gather.js +55 -230
  14. package/dist/hooks/bundle-gather.js.map +1 -1
  15. package/dist/hooks/claude-mthds-check.d.ts +7 -5
  16. package/dist/hooks/claude-mthds-check.js +8 -53
  17. package/dist/hooks/claude-mthds-check.js.map +1 -1
  18. package/dist/hooks/validate-stage.d.ts +27 -0
  19. package/dist/hooks/validate-stage.js +57 -0
  20. package/dist/hooks/validate-stage.js.map +1 -0
  21. package/dist/index.d.ts +3 -4
  22. package/dist/index.js +4 -3
  23. package/dist/index.js.map +1 -1
  24. package/dist/method-trees.d.ts +208 -73
  25. package/dist/method-trees.js +221 -34
  26. package/dist/method-trees.js.map +1 -1
  27. package/dist/models.d.ts +162 -59
  28. package/dist/prepare-inputs.d.ts +46 -27
  29. package/dist/prepare-inputs.js +123 -42
  30. package/dist/prepare-inputs.js.map +1 -1
  31. package/dist/product-models.d.ts +14 -16
  32. package/dist/version.d.ts +1 -1
  33. package/dist/version.js +1 -1
  34. package/docs/architecture.md +17 -18
  35. package/docs/cli.md +1 -1
  36. package/docs/crate-routes.md +42 -20
  37. package/docs/errors.md +5 -5
  38. package/docs/hook-bundle.md +1 -1
  39. package/docs/input-preparation.md +11 -11
  40. package/docs/method-trees.md +119 -21
  41. package/docs/run-results.md +5 -2
  42. package/package.json +2 -2
  43. package/dist/bundle-resources.d.ts +0 -62
  44. package/dist/bundle-resources.js +0 -165
  45. package/dist/bundle-resources.js.map +0 -1
  46. package/docs/bundle-resources.md +0 -107
package/dist/client.js CHANGED
@@ -1,14 +1,13 @@
1
- // Run-source predicates come from the standard, not a local restatement: which
1
+ // Method-source predicates come from the standard, not a local restatement: which
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
- import { assertExclusiveRunSources, hasBundlePayload, parseJsonBody } from "mthds/protocol";
4
+ import { assertOneMethodSource, parseJsonBody } from "mthds/protocol";
5
5
  import { assertArtifactSelection, assertWaitOptions, pollUntilResult, readRunDegradation, RUN_STATUSES, runProgressFault, runStatusFilter, selectionIncludesMainStuff, throwIfAborted, withCheckedProgressDegradations, } from "./runs.js";
6
6
  import { ApiResponseError, ApiUnreachableError, MissingMainStuffError, PagingNotTerminatingError, PipelineExecuteTimeoutError, PipelineRequestError, RequestArgumentError, RunLifecycleUnavailableError, RunStillRunningError, } from "./errors.js";
7
7
  import { isPlainObject, isValidationItem, readFieldError, readMigration, readProviderMetadata, readRunErrorReport, } from "./error-models.js";
8
- import { INLINE_TREE_MAX_BYTES, MAX_CONCURRENT_TRANSFERS, announcedTreeLimits, contentsTreeSource, declaresBlobStore, fileByBlob, forEachConcurrently, hashMethodFiles, inlineFilesOf, inlineMapBytes, readTreeBytes, redactSignedLinks, sha256Hex, treeTotalSize, uniqueBlobs, uploadBlob, writeMethodTreeTo, } from "./method-trees.js";
8
+ import { HANDSHAKE_TTL_MS, INLINE_TREE_MAX_BYTES, MAX_CONCURRENT_TRANSFERS, REQUEST_SENDING, SAVE_SENDING, announcedTreeLimits, contentsTreeSource, declaresBlobStore, eitherSignal, emptyTreeRefusal, fileByBlob, forEachConcurrently, hashMethodFiles, inlineFilesOf, inlineMapBytes, inlineThreshold, isMethodTreeTooLarge, listDirectoryTree, listerLimitsOf, missingBlobsOf, presentMap, readTreeBytesInto, redactSignedLinks, sha256Hex, treeShapeFault, treeTotalSize, uniqueBlobs, uploadBlob, writeMethodTreeTo, } from "./method-trees.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";
12
11
  import { prepareInputs as prepareInputsImpl } from "./prepare-inputs.js";
13
12
  import { downloadArtifacts as downloadArtifactsImpl, fetchArtifact as fetchArtifactImpl, resolveArtifacts as resolveArtifactsImpl, } from "./artifacts.js";
14
13
  import { PipelexExecuteResult, resultsFromExecute } from "./execute-result.js";
@@ -323,11 +322,13 @@ export class PipelexApiClient {
323
322
  /** Cached `/v1/version` handshake outcome — whether the durable lifecycle is served. */
324
323
  lifecycleAvailable;
325
324
  /**
326
- * The first `/v1/version` handshake that answered, kept for the client's lifetime: what a save
327
- * reads from it (`blob_store`) and the tree limits it announces do not change while a client
328
- * lives. A handshake that failed is not kept, so the next call asks again.
325
+ * The last `/v1/version` handshake that answered and when it was asked, kept for
326
+ * `HANDSHAKE_TTL_MS` as `mthds`'s `MthdsApiClient` keeps it: what sending a tree reads from it
327
+ * (`blob_store`, the tree limits) changes only with a deploy. A handshake that failed is not
328
+ * kept, so the next call asks again, and a refusal showing it stale drops it sooner
329
+ * (`forgetHandshake`).
329
330
  */
330
- versionInfo;
331
+ versionCache;
331
332
  /** The client's request ceiling (`PipelexApiClientOptions.requestTimeoutMs`). */
332
333
  requestTimeoutMs;
333
334
  /**
@@ -790,46 +791,44 @@ export class PipelexApiClient {
790
791
  *
791
792
  * The answer is read with `parseExactJson`, so an integer past `Number.MAX_SAFE_INTEGER` in the
792
793
  * working memory, the main output included, is a `bigint` holding its exact digits.
794
+ *
795
+ * The method's tree, given as a `directory`, `contents` or `tree`, is prepared before the
796
+ * request: listed, read, and sent inline or by hash after uploading the files the API lacks (see
797
+ * `PipelexRunOptions`). A tree this client refuses (two forms, no file, a directory it cannot
798
+ * list) is a `RequestArgumentError`, and a file that fails to upload a `MethodFileTransferError`,
799
+ * each before the run is asked.
793
800
  */
794
801
  async execute(options) {
795
- const extensions = buildExtensions(options.extra);
796
- const api = buildApiRunExtensions(options);
797
- const hosted = buildHostedRunExtensions(options);
798
- if (!options.pipe_code &&
799
- (!options.mthds_contents || options.mthds_contents.length === 0) &&
800
- !hasBundlePayload(options) &&
801
- Object.keys(api).length === 0 &&
802
- Object.keys(hosted).length === 0 &&
803
- Object.keys(extensions).length === 0) {
804
- 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().");
805
- }
806
- assertRunSourcesExclusive(options);
807
- assertMethodRefPairsWithNothing(options);
808
- assertLinkageMethodIdIsBare(options);
809
- assertResourcesAccompanyABundle(options);
810
- // No await when there are none: a request without them leaves on the tick it always did.
811
- const resources = options.resources == null ? undefined : await encodeBundleResources(options.resources);
812
- const request = {
813
- pipe_code: options.pipe_code,
814
- mthds_contents: options.mthds_contents,
815
- inputs: options.inputs,
816
- output_name: options.output_name,
817
- output_multiplicity: options.output_multiplicity,
818
- dynamic_output_concept_ref: options.dynamic_output_concept_ref,
819
- files: nonEmptyFiles(options.files),
820
- bundle_b64: nonEmptyString(options.bundle_b64),
821
- resources,
822
- ...api,
823
- ...hosted,
824
- ...extensions,
825
- };
826
- // Timed on the monotonic clock, which a change of the system's time does not move.
827
- const startedAt = performance.now();
802
+ return this.executeRun(options);
803
+ }
804
+ /**
805
+ * `execute`, stopped by `signal` before each step of the tree's preparation and right before the
806
+ * request is sent, never during it, since a request sent may have started a run. `sending` is
807
+ * the tree already prepared, which the blocking fallback of `startAndWaitForResult` sends as the
808
+ * start sent it rather than preparing it again.
809
+ */
810
+ async executeRun(options, signal, sending) {
811
+ const parts = this.runParts("execute", options);
812
+ const source = sending?.source ?? (await this.treeToSend("execute", parts.forms, REQUEST_SENDING, signal));
813
+ // Timed on the monotonic clock, which a change of the system's time does not move, from the
814
+ // request itself: the tree's preparation, uploads included, is no part of the gateway's cut-off.
815
+ // Set when the request is sent, which the answer read below always follows.
816
+ let sentAt = 0;
817
+ const res = await this.postMethodRequest(source, "execute", REQUEST_SENDING, { signal }, async (files) => {
818
+ const request = runRequestBody("execute", options, files, parts);
819
+ throwIfAborted(signal);
820
+ sentAt = performance.now();
821
+ try {
822
+ return await this.requestRaw("POST", this.url("execute"), {
823
+ body: request,
824
+ ...STARTS_RUN,
825
+ });
826
+ }
827
+ catch (err) {
828
+ throw executeCutOff(err, sentAt);
829
+ }
830
+ }, sending?.initial);
828
831
  try {
829
- const res = await this.requestRaw("POST", this.url("execute"), {
830
- body: request,
831
- ...STARTS_RUN,
832
- });
833
832
  this.throwIfExecuteDegraded(res);
834
833
  if (res.status < 200 || res.status >= 300) {
835
834
  this.throwApiResponseError("POST", "execute", res, STARTS_RUN);
@@ -844,16 +843,10 @@ export class PipelexApiClient {
844
843
  return new PipelexExecuteResult(answer);
845
844
  }
846
845
  catch (err) {
847
- if (err instanceof RunStillRunningError)
848
- throw err;
849
- // The hosted gateway terminates synchronous requests at ~30s. A run that
850
- // exceeds that comes back as a gateway 503/504 (or a client abort) —
851
- // translate it into a clear, actionable error pointing at start+poll.
852
- const elapsedMs = performance.now() - startedAt;
853
- if (isGatewayCutOff(err, elapsedMs)) {
854
- throw new PipelineExecuteTimeoutError(elapsedMs, { cause: err });
855
- }
856
- throw err;
846
+ // The hosted gateway terminates synchronous requests at ~30s. A run that exceeds that comes
847
+ // back as a gateway 503/504 (or a client abort) — translated into a clear, actionable error
848
+ // pointing at start+poll.
849
+ throw executeCutOff(err, sentAt);
857
850
  }
858
851
  }
859
852
  /**
@@ -875,62 +868,48 @@ export class PipelexApiClient {
875
868
  * `runMayHaveStarted` is true can be retried unchanged (see
876
869
  * `PipelexStartRequestOptions`). A malformed key is a `RequestArgumentError`
877
870
  * before anything is sent.
871
+ *
872
+ * The method's tree is prepared before the request as `execute` prepares it. The prepared body
873
+ * is the same for the same tree, so a start retried under its idempotency key sends the body the
874
+ * key was bound to. A start whose hash entries name a blob the API does not hold yet is refused
875
+ * with a `422` `missing_blobs`, which releases its key: send it again unchanged, which asks the
876
+ * blob route and uploads what is missing first.
878
877
  */
879
878
  async start(options) {
879
+ return this.startRun(options);
880
+ }
881
+ /**
882
+ * `start`, stopped by `signal` as `executeRun` is, `sending` being the tree already prepared.
883
+ */
884
+ async startRun(options, signal, sending) {
880
885
  const idempotencyKey = idempotencyKeyOf(options);
881
- const extensions = buildExtensions(options.extra);
882
- const api = buildApiRunExtensions(options);
883
- const hosted = buildHostedRunExtensions(options);
884
- if (!options.pipe_code &&
885
- (!options.mthds_contents || options.mthds_contents.length === 0) &&
886
- !hasBundlePayload(options) &&
887
- Object.keys(api).length === 0 &&
888
- Object.keys(hosted).length === 0 &&
889
- Object.keys(extensions).length === 0) {
890
- 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().");
891
- }
892
- assertRunSourcesExclusive(options);
893
- assertMethodRefPairsWithNothing(options);
894
- assertLinkageMethodIdIsBare(options);
895
- assertResourcesAccompanyABundle(options);
896
- // No await when there are none: a request without them leaves on the tick it always did.
897
- const resources = options.resources == null ? undefined : await encodeBundleResources(options.resources);
898
- // `?? undefined` so JSON.stringify drops absent fields from the wire body.
899
- const request = {
900
- pipe_code: options.pipe_code ?? undefined,
901
- mthds_contents: options.mthds_contents ?? undefined,
902
- inputs: options.inputs ?? undefined,
903
- output_name: options.output_name ?? undefined,
904
- output_multiplicity: options.output_multiplicity ?? undefined,
905
- dynamic_output_concept_ref: options.dynamic_output_concept_ref ?? undefined,
906
- files: nonEmptyFiles(options.files),
907
- bundle_b64: nonEmptyString(options.bundle_b64),
908
- resources,
909
- ...api,
910
- ...hosted,
911
- ...extensions,
912
- };
886
+ const parts = this.runParts("start", options);
887
+ const source = sending?.source ?? (await this.treeToSend("start", parts.forms, REQUEST_SENDING, signal));
913
888
  const url = this.url("start");
914
- // `start` returns a 202 fast, so the poll timeout normally fits — with
915
- // exceptions that get the blocking-execute ceiling instead. A method bundle,
916
- // inline as `mthds_contents` or in `files`/`bundle_b64`, or the files its
917
- // steps read (`resources`), can make the request *body* multi-megabyte, and
918
- // the whole upload is charged against this budget — the same payload must
919
- // not time out on the durable path yet succeed on the fallback. And a
920
- // `method_ref` start makes the server FETCH the package before the ack
921
- // (provenance rides the 202), whose clone timeout runs well past 30s on a
922
- // cold cache — aborting it here would surface as `ApiUnreachableError`,
923
- // blaming the network for a healthy server that is still cloning.
924
- const needsLongCeiling = hasBundlePayload(options) ||
925
- (options.mthds_contents?.length ?? 0) > 0 ||
926
- resources !== undefined ||
927
- nonEmptyString(options.method_ref) !== undefined;
928
- const res = await this.requestRaw("POST", url, {
929
- body: request,
930
- timeoutMs: needsLongCeiling ? this.requestTimeoutMs : POLL_REQUEST_TIMEOUT_MS,
931
- idempotencyKey,
932
- ...STARTS_RUN,
933
- });
889
+ const res = await this.postMethodRequest(source, "start", REQUEST_SENDING,
890
+ // A start under a key is bound to the refusal the platform stores, so it is not sent again.
891
+ { signal, retryTooLarge: idempotencyKey === undefined }, async (files) => {
892
+ const request = runRequestBody("start", options, files, parts);
893
+ // `start` returns a 202 fast, so the poll timeout normally fits — with exceptions that get
894
+ // the blocking-execute ceiling instead. A method's tree sent inline can make the request
895
+ // *body* multi-megabyte when the API keeps no blob store, and so can a bundle riding
896
+ // `extra` as a runner's `mthds_contents` or `bundle_b64`, as the CLI sends one; the whole
897
+ // upload is charged against this budget — the same payload must not time out on the
898
+ // durable path yet succeed on the fallback. And a `method_ref` start makes the server FETCH
899
+ // the package before the ack (provenance rides the 202), whose clone timeout runs well past
900
+ // 30s on a cold cache — aborting it here would surface as `ApiUnreachableError`, blaming
901
+ // the network for a healthy server that is still cloning.
902
+ const needsLongCeiling = files !== undefined ||
903
+ parts.api.method_ref !== undefined ||
904
+ carriesExtraBundle(parts.extensions);
905
+ throwIfAborted(signal);
906
+ return this.requestRaw("POST", url, {
907
+ body: request,
908
+ timeoutMs: needsLongCeiling ? this.requestTimeoutMs : POLL_REQUEST_TIMEOUT_MS,
909
+ idempotencyKey,
910
+ ...STARTS_RUN,
911
+ });
912
+ }, sending?.initial);
934
913
  // A bare runner with no run store 404s here just as it does on the result
935
914
  // routes — surface the same clear `RunLifecycleUnavailableError` (and let
936
915
  // `startAndWaitForResult` fall back to the blocking `execute`).
@@ -948,30 +927,38 @@ export class PipelexApiClient {
948
927
  return ack;
949
928
  }
950
929
  /**
951
- * Parse, validate, and dry-run an MTHDS bundle — `POST /v1/validate`.
930
+ * Parse, validate, and dry-run a method — `POST /v1/validate`.
952
931
  *
953
932
  * `/validate` is a diagnostic endpoint: every produced verdict rides a **200**,
954
933
  * discriminated on `is_valid`. This returns the `PipelexValidationResult` union
955
934
  * verbatim — `is_valid: true` ⇒ the typed `PipelexValidationReport` (structural
956
935
  * artifacts), `is_valid: false` ⇒ a `PipelexInvalidReport` (`validation_errors[]`).
957
- * An invalid bundle is NOT thrown — the caller pattern-matches `is_valid`. Only a
958
- * *no-verdict* condition (a malformed request, an `mthds_sources` length mismatch,
959
- * auth, a server fault) is non-2xx and surfaces as `ApiResponseError`.
960
- *
961
- * `source` selects WHAT is validated, in exactly one of three forms — the
962
- * strict tooling XOR (the routes are stateless, so there is no linkage
963
- * exception; a second selector is a request-shape `422`):
964
- *
965
- * - **inline contents** (a `string[]`) — the protocol's own envelope;
936
+ * An invalid method is NOT thrown — the caller pattern-matches `is_valid`. Only a
937
+ * *no-verdict* condition (a malformed request, a tree the route refuses, auth, a
938
+ * server fault) is non-2xx and surfaces as `ApiResponseError`.
939
+ *
940
+ * `source` names WHAT is validated, in exactly one form — the strict tooling XOR
941
+ * (the routes are stateless, so there is no linkage exception; a second selector
942
+ * is a request-shape `422`, refused here first with a `RequestArgumentError`):
943
+ *
944
+ * - **the method's tree**, in one of the four forms of `MethodTreeForms`: a
945
+ * `directory` the `mthds` package's lister lists under the standard's
946
+ * membership rule (Node only), `contents` by tree path, a listed `tree`, or the
947
+ * protocol's `files` map, sent as given. A `directory`, `contents` or `tree` is
948
+ * sent as the `files` map, inline or by hash, exactly as a run sends it (see
949
+ * `PipelexRunOptions`). Every `.mthds` file of the tree is judged, named by its
950
+ * tree path in the diagnostics' `source`, and every other file is one a step may
951
+ * read, such as a `PipeDocGen` step's template file, whose template check's
952
+ * findings ride the valid arm's `warnings`;
966
953
  * - **`{ method_ref }`** — a published method's address, resolved by the
967
954
  * server (pipelex-api >= 0.21.0) through the same fetch path as a
968
- * `method_ref` run, the package's real file names feeding the diagnostics'
955
+ * `method_ref` run, the package's real tree paths feeding the diagnostics'
969
956
  * source labels;
970
957
  * - **`{ method_id }`** — a stored method's catalog id, hosted-only: the
971
- * platform resolves the version it names and injects that version's `.mthds`
972
- * files before the runner sees the request (a bare runner rejects the request
973
- * as carrying no source it understands). A bare `mt_…` validates the latest
974
- * published version, `mt_…@<n>` version `n` and `mt_…@draft` the draft.
958
+ * platform resolves the version it names and sends that version's tree to the
959
+ * runner (a bare runner rejects the request as carrying no source it
960
+ * understands). A bare `mt_…` validates the latest published version,
961
+ * `mt_…@<n>` version `n` and `mt_…@draft` the draft.
975
962
  *
976
963
  * A selector-resolution failure (fetch failure, no package at the address, an
977
964
  * unknown or foreign-org id, a bare id of a method never published, `409`
@@ -980,40 +967,23 @@ export class PipelexApiClient {
980
967
  * `ApiResponseError` — never an `is_valid: false` verdict, which is reserved
981
968
  * for actual MTHDS content.
982
969
  *
983
- * `mthdsSources` (optional, parallel to inline contents) names each submitted
984
- * content — a Pipelex-API extension threaded onto `blueprint.source`, so
985
- * cross-file diagnostics name the owning file (an unnamed content yields
986
- * `source: null`). The server 422s a length mismatch; this client sends the
987
- * arrays verbatim and surfaces that as an `ApiResponseError`. It is an
988
- * inline-contents companion only: a `method_ref` / `method_id` validation gets
989
- * its source labels from the package's (or the stored method's) real file
990
- * names, so supplying it beside a selector is rejected client-side.
991
- *
992
- * `render` is the Pipelex-API presentation hint — a list of view-format tokens.
993
- * This client always asks for Markdown so both valid results and produced
994
- * validation-error verdicts carry `rendered_markdown`; callers may add more
995
- * tokens. Unknown tokens are server-side lenient-ignored (never a 422).
996
- *
997
- * `views` is the sibling opt-in for *structured* views where `render` carries
998
- * *rendered text*. The two lists are independent, each resolving its own tokens
999
- * against its own supported set, and each supported token adds a same-named
1000
- * top-level field to the valid arm — `input_form` and `output_form`. Unlike `render`,
1001
- * this client sends `views` ONLY when the caller asks: the point of an opt-in view
1002
- * is that the default response stays byte-identical, and the highest-frequency
1003
- * consumers (hook pipelines, CI gates, agent loops) never pay for bytes they
1004
- * discard. `pipelex-api` gates `input_form` on this token as of 0.18.0; a 0.17.0
1005
- * runner resolved no token (the key is silently ignored, never a 422) and emitted
1006
- * the field regardless — which is why `PipelexValidationReport.input_form` is typed
1007
- * optional rather than required.
1008
- *
1009
- * `options.resources` sends the files the bundle's steps read, such as a
1010
- * `PipeDocGen` step's `template_file`, keyed by the path the step names, each as
1011
- * its bytes (see {@link ValidateOptions}). A step whose template file was not sent
1012
- * is an `is_valid: false` verdict saying so, and a template check's findings in an
1013
- * office template file ride the valid arm's `warnings`. A key no step names, or
1014
- * files over the server's limits, are no-verdict refusals: a `422`
1015
- * `BundleResourceNotNamedError`, a `413` `PayloadTooLarge` or
1016
- * `BundleResourcesTooLargeError`.
970
+ * `allowSignatures` tolerates unimplemented pipe signatures, as the protocol's
971
+ * `validate(source, allowSignatures)` does: they surface as `pending_signatures`
972
+ * and `is_runnable`, never as an error.
973
+ *
974
+ * `options.render` is the Pipelex-API presentation hint — a list of view-format
975
+ * tokens. This client always asks for Markdown so both valid results and produced
976
+ * validation-error verdicts carry `rendered_markdown`; callers may add more tokens.
977
+ * Unknown tokens are server-side lenient-ignored (never a 422).
978
+ *
979
+ * `options.views` is the sibling opt-in for *structured* views where `render`
980
+ * carries *rendered text*. The two lists are independent, each resolving its own
981
+ * tokens against its own supported set, and each supported token adds a same-named
982
+ * top-level field to the valid arm — `input_form` and `output_form`. Unlike
983
+ * `render`, this client sends `views` ONLY when the caller asks: the point of an
984
+ * opt-in view is that the default response stays byte-identical, and the
985
+ * highest-frequency consumers (hook pipelines, CI gates, agent loops) never pay for
986
+ * bytes they discard.
1017
987
  *
1018
988
  * A `{ method_ref }` source makes the server clone a repository before it
1019
989
  * validates anything, and it needs no special budget here: this route already
@@ -1023,85 +993,52 @@ export class PipelexApiClient {
1023
993
  * exists to RAISE routes whose default is the ~30s poll ceiling; applying it here
1024
994
  * would lower this one.
1025
995
  */
1026
- async validate(source, allowSignatures = false, mthdsSources, render, views, options = {}) {
1027
- const body = {
1028
- allow_signatures: allowSignatures,
1029
- };
1030
- if (Array.isArray(source)) {
1031
- body.mthds_contents = source;
1032
- if (mthdsSources !== undefined) {
1033
- body.mthds_sources = mthdsSources;
1034
- }
1035
- }
1036
- else {
1037
- // A selector object. The illegal shapes are compile errors for typed
1038
- // callers (`ValidateMethodSelector` pins the other key to `never`); the
1039
- // runtime checks back them for untyped (JS) callers — a typed
1040
- // `RequestArgumentError`, never a native TypeError off a null source —
1041
- // mirroring the server's strict tooling XOR instead of silently picking.
1042
- if (source === null || source === undefined || typeof source !== "object") {
1043
- throw new RequestArgumentError("validate() takes inline contents (a string[]) or a method selector object " +
1044
- "({ method_ref } or { method_id }).");
1045
- }
1046
- const methodRef = nonEmptyString(source.method_ref);
1047
- const methodId = nonEmptyString(source.method_id);
1048
- if ((methodRef === undefined) === (methodId === undefined)) {
1049
- throw new RequestArgumentError("validate() takes exactly one method selector: inline contents, { method_ref }, or { method_id }.");
1050
- }
1051
- if (mthdsSources !== undefined) {
1052
- throw new RequestArgumentError("mthds_sources labels inline mthds_contents; a method_ref / method_id validation gets " +
1053
- "its source labels from the package's (or the stored method's) real file names.");
1054
- }
1055
- if (methodRef !== undefined && hasBundleResources(options.resources)) {
1056
- throw new RequestArgumentError("resources accompany inline mthds_contents; a method_ref's files are the package's own.");
1057
- }
996
+ async validate(source, allowSignatures = false, options = {}) {
997
+ // The illegal shapes are partly compile errors for typed callers; the runtime checks back them
998
+ // for untyped (JS) callers — a typed `RequestArgumentError`, never a native TypeError off a
999
+ // null source — mirroring the server's strict tooling XOR instead of silently picking.
1000
+ if (source === null || typeof source !== "object" || Array.isArray(source)) {
1001
+ throw new RequestArgumentError("validate() takes the method as an object naming exactly one of directory, contents, " +
1002
+ "tree, files, method_ref or method_id.");
1003
+ }
1004
+ const forms = protocolTreeForms("validate", source);
1005
+ const methodRef = nonEmptyString(source.method_ref);
1006
+ const methodId = nonEmptyString(source.method_id);
1007
+ const given = treeFormsGiven(forms);
1008
+ if (methodRef !== undefined)
1009
+ given.push("method_ref");
1010
+ if (methodId !== undefined)
1011
+ given.push("method_id");
1012
+ if (given.length !== 1) {
1013
+ throw new RequestArgumentError("validate() takes exactly one of the method's tree (directory, contents, tree or files), " +
1014
+ `method_ref or method_id; got ${given.length === 0 ? "none" : given.join(" and ")}.`);
1015
+ }
1016
+ const signal = options.signal;
1017
+ const sent = await this.treeToSend("validate", forms, REQUEST_SENDING, signal);
1018
+ const res = await this.postMethodRequest(sent, "validate", REQUEST_SENDING, { signal }, (files) => {
1019
+ const body = { allow_signatures: allowSignatures };
1020
+ if (files != null)
1021
+ body.files = files;
1058
1022
  if (methodRef !== undefined)
1059
1023
  body.method_ref = methodRef;
1060
1024
  if (methodId !== undefined)
1061
1025
  body.method_id = methodId;
1062
- }
1063
- body.render = withValidateMarkdownRender(render);
1064
- if (views !== undefined) {
1065
- body.views = views;
1066
- }
1067
- // No await when there are none: a request without them leaves on the tick it always did.
1068
- const resources = options.resources == null ? undefined : await encodeBundleResources(options.resources);
1069
- if (resources !== undefined) {
1070
- body.resources = resources;
1071
- }
1072
- const res = await this.requestRaw("POST", this.url("validate"), {
1073
- body,
1074
- timeoutMs: options.timeoutMs,
1075
- signal: options.signal,
1026
+ body.render = withValidateMarkdownRender(options.render);
1027
+ if (options.views !== undefined) {
1028
+ body.views = options.views;
1029
+ }
1030
+ throwIfAborted(signal);
1031
+ return this.requestRaw("POST", this.url("validate"), {
1032
+ body,
1033
+ timeoutMs: options.timeoutMs,
1034
+ signal,
1035
+ });
1076
1036
  });
1077
1037
  if (res.status < 200 || res.status >= 300) {
1078
1038
  this.throwApiResponseError("POST", "validate", res);
1079
1039
  }
1080
1040
  return this.readObjectAnswer("POST", "validate", res);
1081
1041
  }
1082
- /**
1083
- * Validate paired MTHDS files while preserving URI attribution for diagnostics.
1084
- *
1085
- * This adapter intentionally keeps the low-level `validate(...)` payload shape
1086
- * intact for existing consumers. When any file has a URI, every content gets a
1087
- * parallel source label; inline labels are deterministic so the server never
1088
- * sees a length-mismatched `mthds_sources` array.
1089
- */
1090
- async validateFiles(files, options = {}) {
1091
- if (files.length === 0) {
1092
- throw new RequestArgumentError("At least one MTHDS file must be provided to validateFiles().");
1093
- }
1094
- const mthdsContents = files.map((file) => file.content);
1095
- const hasAnyUri = files.some((file) => file.uri !== undefined);
1096
- const mthdsSources = hasAnyUri
1097
- ? files.map((file, index) => file.uri ?? `inline://file-${index + 1}.mthds`)
1098
- : undefined;
1099
- return this.validate(mthdsContents, options.allowSignatures ?? false, mthdsSources, options.render, options.views, {
1100
- resources: options.resources,
1101
- timeoutMs: options.timeoutMs,
1102
- signal: options.signal,
1103
- });
1104
- }
1105
1042
  // ── Tools extensions (Pipelex API — `/v1/lint`, `/v1/format`) ─────────
1106
1043
  //
1107
1044
  // NOT REACHABLE ON ANY HOSTED ENVIRONMENT. Both are served by any `pipelex-api`
@@ -1172,11 +1109,10 @@ export class PipelexApiClient {
1172
1109
  * never on a message.
1173
1110
  *
1174
1111
  * All of these are static and inference-free, so they default to the management-call
1175
- * timeout. The internal raises are `crateRequestTimeoutMs`'s: the fetch-sized budget a
1112
+ * timeout. The internal raise is `crateRequestTimeoutMs`'s: the fetch-sized budget a
1176
1113
  * crate route gets when its closure is a `method_ref`, because the server may clone a
1177
- * repository before it answers, and the execute ceiling it gets when the request
1178
- * carries `resources`, whose upload alone can outlast the default. Each is matched to
1179
- * what the request costs, not a caller-facing parameter.
1114
+ * repository before it answers. It is matched to what the request costs, not a
1115
+ * caller-facing parameter.
1180
1116
  *
1181
1117
  * **These routes deliberately expose no `timeoutMs` / `signal`, and that is not an
1182
1118
  * oversight to be "fixed" per route.** Automated reviewers have proposed adding them
@@ -1187,11 +1123,11 @@ export class PipelexApiClient {
1187
1123
  * ceiling and takes caller transport options; every extension route rides the
1188
1124
  * static core. Giving one static route an override while its siblings lack one is
1189
1125
  * the inconsistency.
1190
- * 2. The input is **bounded server-side** — the runner's `pipelex_api/limits.py` caps the
1191
- * `.mthds` files of a request and the files sent with them in `resources`, each by a
1192
- * count, a size per file and, for `resources`, a total, all configurable per
1193
- * deployment — and none of these routes runs inference. The upload those limits allow
1194
- * is what `crateRequestTimeoutMs` budgets for, not the server's work.
1126
+ * 2. The input is **bounded server-side** — the deployment caps a request's tree by its
1127
+ * count of files, a size per file, a total and the bytes it carries inline, all
1128
+ * announced by its `/v1/version` handshake — and none of these routes runs inference.
1129
+ * The upload those limits allow is what `crateRequestTimeoutMs` budgets for, not the
1130
+ * server's work.
1195
1131
  * 3. On the hosted path an override would be **inert**: the gateway caps responses at
1196
1132
  * ~30s (see `POLL_REQUEST_TIMEOUT_MS` above), so raising `timeoutMs` would still be
1197
1133
  * cut off upstream, just with a less honest error.
@@ -1199,6 +1135,12 @@ export class PipelexApiClient {
1199
1135
  * If a static route ever genuinely needs longer, give the WHOLE static family one
1200
1136
  * uniform transport-options parameter in a single pass — do not bolt it onto one
1201
1137
  * route.
1138
+ *
1139
+ * The one exception is the crate family's `signal` (`CrateRouteOptions`), given to all three
1140
+ * crate routes at once: a `directory`, `tree` or `contents` can cost the listing, the version
1141
+ * handshake, the blob route and uploads before the request, client-side work a caller such as
1142
+ * `prepareInputs` must be able to stop. It is read between those steps and stops nothing in flight, so it is no
1143
+ * transport option and the reasons above still hold.
1202
1144
  */
1203
1145
  async requestExtension(endpoint, body, options = {}) {
1204
1146
  const res = await this.requestRaw("POST", this.url(endpoint), {
@@ -1259,8 +1201,13 @@ export class PipelexApiClient {
1259
1201
  * The handshake for feature detection (hosted extensions or not).
1260
1202
  */
1261
1203
  async version() {
1204
+ return this.requestVersion();
1205
+ }
1206
+ /** `GET /v1/version`, as `version` asks it, stopped by `signal`. */
1207
+ async requestVersion(signal) {
1262
1208
  const res = await this.requestRaw("GET", this.url("version"), {
1263
1209
  timeoutMs: POLL_REQUEST_TIMEOUT_MS,
1210
+ signal,
1264
1211
  });
1265
1212
  if (res.status < 200 || res.status >= 300) {
1266
1213
  this.throwApiResponseError("GET", "version", res);
@@ -1287,8 +1234,10 @@ export class PipelexApiClient {
1287
1234
  // `docs/crate-routes.md`).
1288
1235
  //
1289
1236
  // All three are STATIC routes (no dry-run sweep), so like every static sibling they
1290
- // take no `timeoutMs`/`signal` — see the policy note on `requestExtension` before
1291
- // adding one here.
1237
+ // take no `timeoutMs` — see the policy note on `requestExtension` before adding one
1238
+ // here. Their `signal` (`CrateRouteOptions`) is that note's one exception: it is read
1239
+ // between the steps that prepare a `directory`, `tree` or `contents`, and stops nothing in
1240
+ // flight.
1292
1241
  /**
1293
1242
  * Resolve a closure into its normalized library crate — `POST /v1/resolve`.
1294
1243
  *
@@ -1307,15 +1256,23 @@ export class PipelexApiClient {
1307
1256
  * (fetch failure, no package at the address, an unknown or foreign-org id) is
1308
1257
  * non-2xx too — never an `is_valid: false` verdict.
1309
1258
  *
1310
- * The closure arrives in exactly one of three forms: inline `files`, an
1311
- * address-form `method_ref` (server-resolved, pipelex-api >= 0.21.0; the
1312
- * registry form stays a `501`), or a hosted `method_id` (platform-resolved —
1313
- * see {@link PipelexHostedToolingExtensions}).
1259
+ * The closure arrives in exactly one of three forms: the method's tree, as the
1260
+ * protocol's `files` map or as a `directory`, `tree` or `contents` the client sends
1261
+ * inline when small and by hash otherwise (see {@link CrateRequestBase}); an
1262
+ * address-form `method_ref` (server-resolved, pipelex-api >= 0.21.0; the registry form
1263
+ * stays a `501`); or a hosted `method_id` (platform-resolved — see
1264
+ * {@link PipelexHostedToolingExtensions}). An empty map or string is absent.
1265
+ *
1266
+ * `options.signal` stops the call between the steps a `directory`, `tree` or `contents`
1267
+ * costs before the request — the listing, the version handshake, the blob route, each
1268
+ * read and upload of a missing blob — and before the request itself, throwing the
1269
+ * abort; a request already sent runs to its end. A blob that fails to upload throws
1270
+ * `MethodFileTransferError`, and a tree this client refuses to send (no file, two
1271
+ * forms, a directory it cannot list, a `contents` value that is neither text nor
1272
+ * bytes) `RequestArgumentError`, each before the route is asked.
1314
1273
  */
1315
- async resolve(request) {
1316
- return this.requestExtension("resolve", await wireCrateRequest(request), {
1317
- timeoutMs: crateRequestTimeoutMs(request, this.requestTimeoutMs),
1318
- });
1274
+ async resolve(request, options = {}) {
1275
+ return this.crateRequest("resolve", "resolve", request, options);
1319
1276
  }
1320
1277
  /**
1321
1278
  * Project a closure's crate into stamped typed artifacts — `POST /v1/codegen`.
@@ -1332,10 +1289,8 @@ export class PipelexApiClient {
1332
1289
  * unknown `kind`/`target`, a `pipe_ref` on the concept-set-wide `types` kind, or
1333
1290
  * a malformed selector is a 422; a registry-form `method_ref` is a `501`.
1334
1291
  */
1335
- async codegen(request) {
1336
- return this.requestExtension("codegen", await wireCrateRequest(request), {
1337
- timeoutMs: crateRequestTimeoutMs(request, this.requestTimeoutMs),
1338
- });
1292
+ async codegen(request, options = {}) {
1293
+ return this.crateRequest("codegen", "codegen", request, options);
1339
1294
  }
1340
1295
  /**
1341
1296
  * Read a method's three I/O artifacts — `POST /v1/pipe-io`.
@@ -1348,19 +1303,20 @@ export class PipelexApiClient {
1348
1303
  * dry-runs every pipe; a caller that shows a method, prepares its inputs or generates
1349
1304
  * types for it reads this, and one that needs the dry-run verdict stays on `validate`.
1350
1305
  *
1351
- * Selection: the request's qualified `pipe_ref`, else a fetched package's manifest
1352
- * `main_pipe`, else the closure's single `main_pipe` declaration. `all_pipes: true`
1353
- * describes every pipe instead, and never refuses for want of an entry pipe.
1354
- * `include_files: true` echoes the resolved closure's `.mthds` files as `files`.
1306
+ * Selection: the request's qualified `pipe_ref`, else the `main_pipe` of the
1307
+ * `METHODS.toml` at the tree's root, sent in the tree or fetched with a package, else the
1308
+ * closure's single `main_pipe` declaration. `all_pipes: true` describes every pipe
1309
+ * instead, and never refuses for want of an entry pipe. `include_files: true` echoes the
1310
+ * method's own `.mthds` bundles as `files`, a map from tree path to each bundle's text,
1311
+ * in the shape of the request's `files` map.
1355
1312
  *
1356
- * Beside inline `files`, `resources` sends the files the closure's steps read, such as a
1357
- * `PipeDocGen` step's `template_file`, keyed by the path the step names, each as its bytes;
1358
- * a step whose template file was not sent makes the closure invalid. `resolve` and `codegen`
1359
- * take it too (see {@link CrateRequestBase}).
1313
+ * The files the closure's steps read, such as a `PipeDocGen` step's `template_file`, are
1314
+ * files of the method's tree like any other; a step whose template file is not in the tree
1315
+ * makes the closure invalid (see {@link CrateRequestBase}).
1360
1316
  *
1361
- * Same 200-verdict discipline and same three-form closure selector as {@link resolve}
1362
- * (the request is posted as given, its `resources` encoded to base64, and the selector XOR
1363
- * and the `resources` pairing are the server's to enforce).
1317
+ * Same 200-verdict discipline, same three-form closure selector and same `options` as
1318
+ * {@link resolve} (the request's members are posted as given, its `directory`, `tree` or
1319
+ * `contents` as the `files` map, and the selector XOR is the server's to enforce).
1364
1320
  * Only a no-verdict condition throws `ApiResponseError`: a malformed selector and an
1365
1321
  * over-limit file are `ValidationError` `422`s; a selection the route refuses is a
1366
1322
  * `422` whose `errorType` is `EntryPipeNotFoundError` (an unknown `pipe_ref`, no entry
@@ -1368,16 +1324,70 @@ export class PipelexApiClient {
1368
1324
  * candidates in its `serverMessage`; a registry-form `method_ref` is a `501`; a pipe
1369
1325
  * whose artifacts cannot be derived is a `500`.
1370
1326
  *
1371
- * A `method_ref` gets the fetch-sized budget, as on the other crate routes, and a
1372
- * request carrying `resources` the execute ceiling, since their upload alone can
1373
- * outlast the default. On the hosted API the gateway caps a request at about 30
1374
- * seconds whatever the client allows, so a cold `method_ref` clone can answer a `502`
1375
- * that a retry clears once the runner has cached the clone.
1376
- */
1377
- async pipeIo(request) {
1378
- return this.requestExtension("pipe-io", await wireCrateRequest(request), {
1379
- timeoutMs: crateRequestTimeoutMs(request, this.requestTimeoutMs),
1327
+ * A `method_ref` gets the fetch-sized budget, as on the other crate routes. On the
1328
+ * hosted API the gateway caps a request at about 30 seconds whatever the client allows,
1329
+ * so a cold `method_ref` clone can answer a `502` that a retry clears once the runner
1330
+ * has cached the clone.
1331
+ */
1332
+ async pipeIo(request, options = {}) {
1333
+ return this.crateRequest("pipe-io", "pipeIo", request, options);
1334
+ }
1335
+ /**
1336
+ * Post a crate request: its members as given, and its tree as the `files` map a save would
1337
+ * send, each key left off when there is nothing to send. An empty map or string is absent, as in
1338
+ * the Python SDK: `files: {}` or `null`, `contents: {}`, `directory: ""` and `dependencies: {}`
1339
+ * are not sent, so an empty one never reaches the wire as a second selector; a `directory` that
1340
+ * is not a string is refused, as on a run. A `directory`, `tree` or `contents` is prepared
1341
+ * through the path every request and save sends a tree by (`postMethodRequest`), inline when
1342
+ * small and by hash otherwise, uploading the files the API lacks first; the blob route is asked
1343
+ * under the request's `read_scope`, the scope its hash entries are read in. `signal` is read
1344
+ * before each step and right before the request (see `TreeSendOptions`). The request gets the
1345
+ * budget `crateRequestTimeoutMs` gives it for the `files` it carries.
1346
+ */
1347
+ async crateRequest(endpoint, call, request, options) {
1348
+ const signal = options.signal;
1349
+ throwIfAborted(signal);
1350
+ if (typeof request !== "object" || request === null) {
1351
+ return this.requestExtension(endpoint, request);
1352
+ }
1353
+ const { directory, contents, tree, files, dependencies, ...rest } = request;
1354
+ const source = await this.treeToSend(call, {
1355
+ directory: typeof directory === "string" ? nonEmptyString(directory) : directory,
1356
+ contents: presentMap(contents),
1357
+ tree: tree ?? undefined,
1358
+ files: presentMap(files),
1359
+ }, REQUEST_SENDING, signal);
1360
+ const packages = presentMap(dependencies);
1361
+ const res = await this.postMethodRequest(source, call, REQUEST_SENDING, { signal, readScope: nonEmptyString(request.read_scope) }, (sent) => {
1362
+ const body = { ...rest };
1363
+ if (sent != null)
1364
+ body.files = sent;
1365
+ if (packages !== undefined)
1366
+ body.dependencies = packages;
1367
+ throwIfAborted(signal);
1368
+ return this.requestRaw("POST", this.url(endpoint), {
1369
+ body,
1370
+ timeoutMs: this.crateRequestTimeoutMs(request, sent),
1371
+ });
1380
1372
  });
1373
+ if (res.status < 200 || res.status >= 300) {
1374
+ this.throwApiResponseError("POST", endpoint, res);
1375
+ }
1376
+ return this.readObjectAnswer("POST", endpoint, res);
1377
+ }
1378
+ /**
1379
+ * The request budget for a crate route's closure (`resolve`, `codegen`, `pipeIo`): the
1380
+ * static-route default, unless the closure is a `method_ref`, which may make the server clone a
1381
+ * repository first and gets the fetch-sized budget, or its `files` carry more inline than
1382
+ * `INLINE_TREE_MAX_BYTES`, which gets the client's request ceiling (`requestTimeoutMs`): a tree
1383
+ * sent to an API keeping no blob store travels inline whatever its weight, and so does a `files`
1384
+ * map given as is, and the upload of the body is charged against the budget. A tree sent by
1385
+ * hash keeps the default, its files uploaded before the request under the client's own ceiling.
1386
+ */
1387
+ crateRequestTimeoutMs(request, files) {
1388
+ if (nonEmptyString(request.method_ref) !== undefined)
1389
+ return METHOD_REF_FETCH_TIMEOUT_MS;
1390
+ return carriesLargeInlineTree(files) ? this.requestTimeoutMs : POLL_REQUEST_TIMEOUT_MS;
1381
1391
  }
1382
1392
  // ── Hosted extension: durable run lifecycle (NOT part of the protocol) ──
1383
1393
  /**
@@ -1532,19 +1542,25 @@ export class PipelexApiClient {
1532
1542
  * gzip stream, which `requestRaw` reports as an `ApiUnreachableError` carrying
1533
1543
  * the decompressor's code (see `isUndecodableBody`).
1534
1544
  *
1545
+ * `signal` stops the handshake: its abort is thrown as is and nothing is cached.
1546
+ *
1535
1547
  * @throws {ApiUnreachableError} The handshake got no answer. Nothing is cached,
1536
1548
  * so the next call asks again, and no start is sent to a host that did not
1537
1549
  * answer: sending it would wait a second time for the same silence.
1538
1550
  */
1539
- async supportsRunLifecycle() {
1551
+ async supportsRunLifecycle(signal) {
1540
1552
  if (this.lifecycleAvailable === undefined) {
1541
1553
  try {
1542
- const info = await this.version();
1543
- this.versionInfo ??= info;
1554
+ const askedAt = Date.now();
1555
+ const info = await this.requestVersion(signal);
1556
+ this.versionCache = { info, askedAt };
1544
1557
  const impl = info.implementation;
1545
1558
  this.lifecycleAvailable = !(typeof impl === "string" && impl === BARE_RUNNER_IMPLEMENTATION);
1546
1559
  }
1547
1560
  catch (error) {
1561
+ // The caller's abort is no answer: it propagates, and nothing is cached.
1562
+ if (signal?.aborted === true)
1563
+ throw signal.reason;
1548
1564
  // No answer at all propagates. A body that arrived and could not be decoded is an answer,
1549
1565
  // and so is every other failure here: a non-2xx status, or a body that is no version.
1550
1566
  if (error instanceof ApiUnreachableError && !isUndecodableBody(error))
@@ -1569,9 +1585,15 @@ export class PipelexApiClient {
1569
1585
  * run, so the caller knows until then that none exists (see
1570
1586
  * `StartAndWaitForResultOptions`).
1571
1587
  *
1572
- * `pollOptions.signal` is also read before the run is created: a caller that aborts while the
1573
- * version handshake is in flight gets its abort, and neither the start nor the blocking execute
1574
- * is sent. Once one of them is sent, the abort stops only the wait.
1588
+ * `pollOptions.signal` also stops everything before the run is created: the version handshake,
1589
+ * the listing of a `directory`, and the reads, the blob route and the uploads of a tree sent by
1590
+ * hash, each of them stopped in flight. It is read again right before the start and before the
1591
+ * blocking execute, so a caller that aborts before then gets its abort and no run is created.
1592
+ * Once one of them is sent, the abort stops only the wait.
1593
+ *
1594
+ * The method's tree is prepared once, before the start: when a runner turns out to lack the
1595
+ * durable routes, the blocking execute sends the same `files` map, its blobs already uploaded,
1596
+ * rather than listing, reading and hashing the tree again.
1575
1597
  *
1576
1598
  * An error from the start, or from the blocking execute, says whether that request may still
1577
1599
  * have started a run whose id never reached the caller (`runMayHaveStarted`); when it may have,
@@ -1592,10 +1614,12 @@ export class PipelexApiClient {
1592
1614
  // Before the handshake too: a key the start would refuse is the caller's to fix, whatever runs.
1593
1615
  idempotencyKeyOf(options);
1594
1616
  const { onStarted, onStarting, ...waitOptions } = pollOptions ?? {};
1595
- const hosted = await this.supportsRunLifecycle();
1617
+ const signal = waitOptions.signal;
1618
+ const hosted = await this.supportsRunLifecycle(signal);
1596
1619
  // The handshake may have outlasted the caller's patience: an abort that landed during it
1597
1620
  // creates no run.
1598
- throwIfAborted(waitOptions.signal);
1621
+ throwIfAborted(signal);
1622
+ const sending = await this.prepareRunTree(hosted ? "start" : "execute", options, signal);
1599
1623
  if (hosted) {
1600
1624
  // A runner can look hosted yet lack the durable routes — `implementation`
1601
1625
  // is an extension field, so a compliant bare runner that omits it is
@@ -1605,22 +1629,38 @@ export class PipelexApiClient {
1605
1629
  // the durable attempt.
1606
1630
  let ack;
1607
1631
  try {
1632
+ throwIfAborted(signal);
1608
1633
  onStarting?.();
1609
- ack = await this.start(options);
1634
+ ack = await this.startRun(options, signal, sending);
1610
1635
  }
1611
1636
  catch (err) {
1612
1637
  if (!(err instanceof RunLifecycleUnavailableError))
1613
1638
  throw err;
1614
1639
  this.lifecycleAvailable = false;
1615
- throwIfAborted(waitOptions.signal);
1640
+ throwIfAborted(signal);
1616
1641
  onStarting?.();
1617
- return this.executeBlocking(options);
1642
+ return this.executeBlocking(options, signal, sending);
1618
1643
  }
1619
1644
  onStarted?.(ack);
1620
1645
  return this.waitForResult(ack.pipeline_run_id, waitOptions);
1621
1646
  }
1647
+ throwIfAborted(signal);
1622
1648
  onStarting?.();
1623
- return this.executeBlocking(options);
1649
+ return this.executeBlocking(options, signal, sending);
1650
+ }
1651
+ /**
1652
+ * A run's tree prepared once for `startAndWaitForResult`, under `call`'s refusals: the run's
1653
+ * arguments checked, the tree listed or read, and its `files` prepared, uploads included, under
1654
+ * `signal`. A `files` map given as is, or no tree, needs no preparation.
1655
+ */
1656
+ async prepareRunTree(call, options, signal) {
1657
+ const parts = this.runParts(call, options);
1658
+ const source = await this.treeToSend(call, parts.forms, REQUEST_SENDING, signal);
1659
+ if (source === undefined || !("tree" in source))
1660
+ return { source, initial: undefined };
1661
+ const read = new Map();
1662
+ const prepared = await this.prepareFiles(source, call, REQUEST_SENDING, { signal }, read);
1663
+ return { source, initial: { prepared, read } };
1624
1664
  }
1625
1665
  // ── Pipelex product surface (hosted management routes) ─────────────────
1626
1666
  //
@@ -1735,24 +1775,15 @@ export class PipelexApiClient {
1735
1775
  * room for a publish.
1736
1776
  */
1737
1777
  async createMethod(input) {
1738
- const files = await this.methodTreeFiles("createMethod", input);
1739
- const body = { name: input.name };
1740
- if (files != null)
1741
- body.files = files;
1742
- if (input.input_data !== undefined)
1743
- body.input_data = input.input_data;
1744
- return this.requestMethodData("POST", "methods", body, this.saveTimeout(files));
1745
- }
1746
- /**
1747
- * The time limit of a save carrying `files`: the client's request ceiling, as for a `start`
1748
- * carrying its method, since a tree sent inline to a deployment with no blob store has no size
1749
- * bound and its whole upload is charged against the limit; the poll timeout for a save sending
1750
- * no tree.
1751
- */
1752
- saveTimeout(files) {
1753
- return files != null && Object.keys(files).length > 0
1754
- ? { timeoutMs: this.requestTimeoutMs }
1755
- : {};
1778
+ const source = await this.treeToSend("createMethod", input, SAVE_SENDING);
1779
+ return this.saveMethodTree("POST", "methods", "createMethod", source, (files) => {
1780
+ const body = { name: input.name };
1781
+ if (files != null)
1782
+ body.files = files;
1783
+ if (input.input_data !== undefined)
1784
+ body.input_data = input.input_data;
1785
+ return body;
1786
+ });
1756
1787
  }
1757
1788
  /**
1758
1789
  * Replace a method's draft — `PUT /v1/methods/{id}/draft` — and return the method with the
@@ -1778,24 +1809,54 @@ export class PipelexApiClient {
1778
1809
  */
1779
1810
  async writeDraft(methodId, input) {
1780
1811
  const endpoint = `${methodPath(methodId, "writeDraft")}/draft`;
1781
- if (input.files === undefined && input.tree == null && input.contents == null) {
1782
- throw new RequestArgumentError("writeDraft() replaces the draft's whole tree: give it as files (files: null empties the " +
1783
- "draft), tree or contents.");
1784
- }
1785
- const files = await this.methodTreeFiles("writeDraft", input);
1786
- const body = { files: files ?? null };
1787
- if (input.input_data !== undefined)
1788
- body.input_data = input.input_data;
1789
- if (input.expected_updated_at !== undefined) {
1790
- body.expected_updated_at = input.expected_updated_at;
1812
+ if (treeFormsGiven(input).length === 0) {
1813
+ throw new RequestArgumentError("writeDraft() replaces the draft's whole tree: give it as directory, contents, tree or " +
1814
+ "files (files: null empties the draft).");
1815
+ }
1816
+ const source = await this.treeToSend("writeDraft", input, SAVE_SENDING);
1817
+ return this.saveMethodTree("PUT", endpoint, "writeDraft", source, (files) => {
1818
+ const body = { files: files ?? null };
1819
+ if (input.input_data !== undefined)
1820
+ body.input_data = input.input_data;
1821
+ if (input.expected_updated_at !== undefined) {
1822
+ body.expected_updated_at = input.expected_updated_at;
1823
+ }
1824
+ return body;
1825
+ });
1826
+ }
1827
+ /**
1828
+ * Send a save carrying a method's tree (`createMethod`, `writeDraft`) through the path every
1829
+ * request sends a tree by (`postMethodRequest`), its body built by `bodyOf` from the `files`
1830
+ * prepared, under `saveTimeoutMs`, and read the stored method it answers as
1831
+ * `requestMethodData` reads it.
1832
+ */
1833
+ async saveMethodTree(method, endpoint, call, source, bodyOf) {
1834
+ const res = await this.postMethodRequest(source, call, SAVE_SENDING, {}, (files) => this.requestRaw(method, this.url(endpoint), {
1835
+ body: bodyOf(files),
1836
+ timeoutMs: this.saveTimeoutMs(files),
1837
+ }));
1838
+ if (res.status < 200 || res.status >= 300) {
1839
+ this.throwApiResponseError(method, endpoint, res);
1791
1840
  }
1792
- return this.requestMethodData("PUT", endpoint, body, this.saveTimeout(files));
1841
+ return this.readObjectAnswer(method, endpoint, res);
1842
+ }
1843
+ /**
1844
+ * The time limit of a save carrying `files`: the client's request ceiling, as for a `start`
1845
+ * carrying its method, since a tree sent inline to a deployment with no blob store weighs up to
1846
+ * its `max_inline_bytes`, a `files` map given as is whatever it weighs, and the whole upload is
1847
+ * charged against the limit; the poll timeout for a save sending no tree.
1848
+ */
1849
+ saveTimeoutMs(files) {
1850
+ return files != null && Object.keys(files).length > 0
1851
+ ? this.requestTimeoutMs
1852
+ : POLL_REQUEST_TIMEOUT_MS;
1793
1853
  }
1794
1854
  /**
1795
1855
  * The limits the deployment holds a method's tree to — `GET /v1/version`, read from
1796
1856
  * `limits.method_tree` of the public handshake — in the shape the `mthds` package's
1797
1857
  * `listMethodTree` takes as its `limits` option, so a tree the platform would refuse is refused
1798
- * while it is listed, before any file is hashed:
1858
+ * while it is listed, before any file is hashed. A `directory` given to any route is listed under
1859
+ * them already; they serve a caller listing a tree of its own:
1799
1860
  *
1800
1861
  * ```ts
1801
1862
  * import { listMethodTree } from "mthds";
@@ -1805,18 +1866,29 @@ export class PipelexApiClient {
1805
1866
  *
1806
1867
  * A limit the server does not announce is absent, so a bare runner, which announces none,
1807
1868
  * yields no limit, and none is ever assumed. A value that is not a positive integer is left out
1808
- * rather than trusted.
1869
+ * rather than trusted. The inline limit, `max_inline_bytes`, is no listing limit: the client
1870
+ * applies it itself when it chooses how a tree travels.
1809
1871
  */
1810
1872
  async methodTreeLimits() {
1811
- return announcedTreeLimits(await this.cachedVersion());
1873
+ return listerLimitsOf(announcedTreeLimits(await this.cachedVersion()));
1812
1874
  }
1813
1875
  /**
1814
- * The version handshake, asked once per client: the first answer is kept (see `versionInfo`),
1815
- * and a failure is thrown and not kept, so the next call asks again.
1876
+ * The version handshake, asked once per `HANDSHAKE_TTL_MS`: an answer is kept that long (see
1877
+ * `versionCache`), and a failure is thrown and not kept, so the next call asks again. `signal`
1878
+ * stops the ask, its abort thrown as is.
1816
1879
  */
1817
- async cachedVersion() {
1818
- this.versionInfo ??= await this.version();
1819
- return this.versionInfo;
1880
+ async cachedVersion(signal) {
1881
+ const cached = this.versionCache;
1882
+ if (cached !== undefined && Date.now() - cached.askedAt < HANDSHAKE_TTL_MS)
1883
+ return cached.info;
1884
+ const askedAt = Date.now();
1885
+ const info = await this.requestVersion(signal);
1886
+ this.versionCache = { info, askedAt };
1887
+ return info;
1888
+ }
1889
+ /** Drop the kept handshake, which a refusal has shown to be stale. */
1890
+ forgetHandshake() {
1891
+ this.versionCache = undefined;
1820
1892
  }
1821
1893
  /**
1822
1894
  * Write a saved method's tree to disk: each file of a listing `getMethod` or `getMethodVersion`
@@ -1843,60 +1915,167 @@ export class PipelexApiClient {
1843
1915
  await writeMethodTreeTo(files, options.destination, { timeoutMs: this.requestTimeoutMs });
1844
1916
  }
1845
1917
  /**
1846
- * The `files` a save sends: `files` as given, or `tree` or `contents` prepared, uploading what
1847
- * the organization lacks; `undefined` when the input gives none. Two forms are refused with
1848
- * `RequestArgumentError` before any request, naming `call`.
1918
+ * The method's tree a call was given, made ready to prepare: a `directory` listed, `contents`
1919
+ * turned into a tree, a `tree` checked, `files` kept as given; `undefined` when the input gives
1920
+ * none. Two forms are refused with `RequestArgumentError` before any request, naming `call`, and
1921
+ * so are a `directory` that is not a string and a `tree` that is not a listed tree. A
1922
+ * `directory` is listed by the `mthds` package's lister under the limits the deployment
1923
+ * announces, so a tree past one is refused while it is listed, before any file is hashed; the
1924
+ * handshake that announces them is asked under `signal`. It is listed once per call: a request
1925
+ * sent again sends the tree this listing read.
1849
1926
  */
1850
- async methodTreeFiles(call, input) {
1851
- const given = [
1852
- ["files", input.files !== undefined],
1853
- ["tree", input.tree != null],
1854
- ["contents", input.contents != null],
1855
- ]
1856
- .filter(([, present]) => present)
1857
- .map(([name]) => name);
1927
+ async treeToSend(call, input, sending, signal) {
1928
+ const given = treeFormsGiven(input);
1858
1929
  if (given.length > 1) {
1859
- throw new RequestArgumentError(`${call}() takes the method's tree in one form, files, tree or contents; got ` +
1930
+ throw new RequestArgumentError(`${call}() takes the method's tree in one form, directory, contents, tree or files; got ` +
1860
1931
  `${given.join(" and ")}.`);
1861
1932
  }
1933
+ if (given[0] === "directory") {
1934
+ const directory = input.directory;
1935
+ if (typeof directory !== "string")
1936
+ throw directoryRefusal(call);
1937
+ throwIfAborted(signal);
1938
+ const limits = listerLimitsOf(announcedTreeLimits(await this.cachedVersion(signal)));
1939
+ throwIfAborted(signal);
1940
+ return { tree: await listDirectoryTree(directory, limits, call) };
1941
+ }
1862
1942
  if (input.tree != null)
1863
- return this.prepareTreeFiles(input.tree, call);
1943
+ return { tree: checkedTreeSource(call, input.tree) };
1864
1944
  if (input.contents != null) {
1865
- return this.prepareTreeFiles(await contentsTreeSource(input.contents, call), call);
1866
- }
1867
- return input.files;
1868
- }
1869
- /**
1870
- * The `files` map that sends `tree`: inline, or by hash once the organization holds every file.
1871
- *
1872
- * A tree whose inline map weighs `INLINE_TREE_MAX_BYTES` or less travels inline, with no round
1873
- * trip, measured as the request spends it, its JSON with no whitespace in UTF-8. A larger tree
1874
- * travels inline too when the version handshake declares no blob store; otherwise
1875
- * `POST /v1/blobs/missing` is asked which files the organization lacks, naming the tree's
1876
- * digest, each one it lacks is uploaded once, and every file travels by hash. A file read to be
1877
- * measured is uploaded from memory rather than read a second time. A tree holding no file is
1878
- * refused with `RequestArgumentError` before any request, `call` naming the caller's method.
1879
- */
1880
- async prepareTreeFiles(tree, call) {
1881
- if (tree.files.length === 0) {
1882
- throw new RequestArgumentError(`${call}() got a tree holding no file, and a method tree holds at least one; give ` +
1883
- "files: null for an empty draft.");
1884
- }
1885
- let read;
1945
+ return { tree: await contentsTreeSource(input.contents, call, sending) };
1946
+ }
1947
+ return input.files === undefined ? undefined : { files: input.files };
1948
+ }
1949
+ /**
1950
+ * Post a request carrying a method's tree, by `mthds`'s `MthdsApiClient` rule: prepare the
1951
+ * tree's `files` (`prepareFiles`), hand them to `post`, which builds the body and sends it, and
1952
+ * answer the two refusals that call for sending the same request once more, as `mthds` does.
1953
+ *
1954
+ * - A `422` coded `request.blobs_missing` whose `missing_blobs` the tree sent by hash holds, such
1955
+ * as a blob dropped since the check: the blob route is asked again for those, they are
1956
+ * uploaded, and the request is sent once more (`postWithBlobRetry`).
1957
+ * - A `422` refusing a tree the client prepared as past one of the receiver's limits
1958
+ * (`isMethodTreeTooLarge`): the handshake that announced the limits may be stale, so it is
1959
+ * forgotten, and the tree prepared again on a fresh one and sent once more. A `files` map
1960
+ * given as is was not prepared, so it is never sent again; nor is a request whose
1961
+ * `options.retryTooLarge` is `false`, a start under an idempotency key, which the platform
1962
+ * binds to the refusal it stores.
1963
+ *
1964
+ * A `422` created nothing, so the request sent again is no repeat of one whose outcome is
1965
+ * unknown. `initial`, when given, is the tree already prepared for the first send, with the
1966
+ * bytes it read, so a caller sending one tree on two routes prepares it once. `options.signal`
1967
+ * is read before each step of the preparation and before each send. Answers the last answer
1968
+ * whole, its status unread, with the `files` it carried.
1969
+ */
1970
+ async postMethodRequest(source, call, sending, options, post, initial) {
1971
+ const read = initial?.read ?? new Map();
1972
+ let ready = initial?.prepared;
1973
+ const send = async () => {
1974
+ const prepared = ready ?? (await this.prepareFiles(source, call, sending, options, read));
1975
+ ready = undefined;
1976
+ throwIfAborted(options.signal);
1977
+ return this.postWithBlobRetry(prepared, read, post, sending, options);
1978
+ };
1979
+ const res = await send();
1980
+ if (source === undefined ||
1981
+ !("tree" in source) ||
1982
+ options.retryTooLarge === false ||
1983
+ !isMethodTreeTooLarge(res.status, res.body)) {
1984
+ return res;
1985
+ }
1986
+ this.forgetHandshake();
1987
+ return send();
1988
+ }
1989
+ /**
1990
+ * Send a request whose tree may have travelled by hash. When it did and the receiver answers a
1991
+ * `422` coded `request.blobs_missing` whose `missing_blobs` the tree holds, the client asks the
1992
+ * blob route where to upload them, uploads them and sends the same request once more, which the
1993
+ * refusal left uncreated; any other answer, or a list naming a blob the tree does not hold, is
1994
+ * answered as it came.
1995
+ */
1996
+ async postWithBlobRetry(prepared, read, post, sending, options) {
1997
+ const res = await post(prepared.files);
1998
+ const tree = prepared.byHashTree;
1999
+ if (tree === undefined)
2000
+ return res;
2001
+ const missing = missingBlobsOf(res.status, res.body);
2002
+ if (missing === undefined || missing.length === 0)
2003
+ return res;
2004
+ const byBlob = fileByBlob(tree);
2005
+ const files = missing.map((sha256) => byBlob.get(sha256));
2006
+ if (files.some((file) => file === undefined))
2007
+ return res;
2008
+ throwIfAborted(options.signal);
2009
+ const asked = await this.findMissingBlobs({ blobs: files.map((file) => ({ sha256: file.sha256, size: file.size })) }, options.readScope, options.signal);
2010
+ await this.uploadMissing(tree, asked, read, sending, options.signal);
2011
+ throwIfAborted(options.signal);
2012
+ return post(prepared.files);
2013
+ }
2014
+ /**
2015
+ * The `files` a request or a save sends for `source`: a `files` map as given, a tree prepared
2016
+ * (`prepareTree`), or none.
2017
+ */
2018
+ async prepareFiles(source, call, sending, options, read) {
2019
+ if (source === undefined)
2020
+ return { files: undefined, byHashTree: undefined };
2021
+ if ("files" in source)
2022
+ return { files: source.files, byHashTree: undefined };
2023
+ return this.prepareTree(source.tree, call, sending, options, read);
2024
+ }
2025
+ /**
2026
+ * The `files` map that sends `tree`: inline, or by hash once the receiver holds every file —
2027
+ * `mthds`'s `MthdsApiClient` rule, read from the version handshake the client keeps.
2028
+ *
2029
+ * When the handshake declares a blob store, a tree whose inline map weighs no more than the
2030
+ * inline threshold travels inline, measured as the request spends it, its JSON with no
2031
+ * whitespace in UTF-8; the threshold is the lower of 256 KiB (`INLINE_TREE_MAX_BYTES`) and the
2032
+ * `max_inline_bytes` the handshake announces, and a tree whose files alone pass it is not read
2033
+ * to be weighed. A heavier tree travels by hash: `POST /v1/blobs/missing` is asked which files
2034
+ * the receiver lacks, naming the tree's digest, each one it lacks is uploaded once, and every
2035
+ * file travels by hash. A `404` from the blob route says the handshake that declared the store
2036
+ * is stale, so it is forgotten and the tree prepared once more on a fresh one. When the
2037
+ * handshake declares no blob store, the whole tree travels inline whatever its weight, held to
2038
+ * the announced `max_inline_bytes` (`inlineWithin`). Each file is read at most once into `read`,
2039
+ * so a file read to be weighed is uploaded from memory. A tree holding no file is refused with
2040
+ * `RequestArgumentError` before any request, `call` naming the caller's method.
2041
+ * `options.readScope` is the scope the blob route is asked under, and `options.signal` is read
2042
+ * before each read, the handshake, the blob route and each upload, and is passed to each of
2043
+ * them, so an abort also stops a request or an upload under way.
2044
+ */
2045
+ async prepareTree(tree, call, sending, options, read, retried = false) {
2046
+ const { signal, readScope } = options;
2047
+ if (tree.files.length === 0)
2048
+ throw emptyTreeRefusal(call, "a tree", sending);
2049
+ throwIfAborted(signal);
2050
+ const version = await this.cachedVersion(signal);
2051
+ throwIfAborted(signal);
2052
+ const announced = announcedTreeLimits(version);
2053
+ if (!declaresBlobStore(version)) {
2054
+ const files = await inlineWithin(tree, call, sending, announced.maxInlineBytes, read, signal);
2055
+ return { files, byHashTree: undefined };
2056
+ }
2057
+ const threshold = inlineThreshold(announced);
1886
2058
  // An inline map is never smaller than the bytes it carries, so a tree whose bytes alone pass
1887
2059
  // the threshold is not read in full just to be measured.
1888
- if (treeTotalSize(tree) <= INLINE_TREE_MAX_BYTES) {
1889
- read = await readTreeBytes(tree);
1890
- const inline = inlineFilesOf(tree, read);
1891
- if (inlineMapBytes(inline) <= INLINE_TREE_MAX_BYTES)
1892
- return inline;
2060
+ if (treeTotalSize(tree) <= threshold) {
2061
+ const inline = inlineFilesOf(tree, await readTreeBytesInto(tree, read, signal));
2062
+ if (inlineMapBytes(inline) <= threshold)
2063
+ return { files: inline, byHashTree: undefined };
2064
+ }
2065
+ throwIfAborted(signal);
2066
+ let asked;
2067
+ try {
2068
+ asked = await this.findMissingBlobs({ blobs: uniqueBlobs(tree), digest: tree.digest }, readScope, signal);
1893
2069
  }
1894
- if (!declaresBlobStore(await this.cachedVersion())) {
1895
- return inlineFilesOf(tree, read ?? (await readTreeBytes(tree)));
2070
+ catch (err) {
2071
+ if (!retried && err instanceof ApiResponseError && err.status === 404) {
2072
+ this.forgetHandshake();
2073
+ return this.prepareTree(tree, call, sending, options, read, true);
2074
+ }
2075
+ throw err;
1896
2076
  }
1897
- const asked = await this.findMissingBlobs({ blobs: uniqueBlobs(tree), digest: tree.digest });
1898
- await this.uploadMissing(tree, asked, read);
1899
- return hashMethodFiles(tree);
2077
+ await this.uploadMissing(tree, asked, read, sending, signal);
2078
+ return { files: hashMethodFiles(tree), byHashTree: tree };
1900
2079
  }
1901
2080
  /**
1902
2081
  * Which of these blobs the organization lacks, and where to upload each —
@@ -1904,10 +2083,18 @@ export class PipelexApiClient {
1904
2083
  * blob held is left out, and an empty `missing` means nothing to upload. An answer whose
1905
2084
  * `missing` is not a list of blobs, each with its upload target, is an answer the SDK cannot
1906
2085
  * read, thrown as an `ApiResponseError` holding the answer with its links hidden.
1907
- */
1908
- async findMissingBlobs(request) {
1909
- const endpoint = "blobs/missing";
1910
- const res = withLinks(await this.requestProductAnswer("POST", endpoint, request));
2086
+ *
2087
+ * `readScope`, a crate request's `read_scope`, is sent as the route's `read_scope` query, the
2088
+ * Pipelex API's extension naming the blob store asked, so the blobs are uploaded into the store
2089
+ * the request's hash entries are read from; each upload target is signed for that scope. The
2090
+ * hosted platform's own route reads the caller's organization instead. `signal` stops the
2091
+ * request, its abort thrown as is.
2092
+ */
2093
+ async findMissingBlobs(request, readScope, signal) {
2094
+ const endpoint = readScope === undefined
2095
+ ? "blobs/missing"
2096
+ : `blobs/missing?${new URLSearchParams({ read_scope: readScope }).toString()}`;
2097
+ const res = withLinks(await this.requestProductAnswer("POST", endpoint, request, { signal }));
1911
2098
  const answer = this.readObjectAnswer("POST", endpoint, res);
1912
2099
  const missing = answer.missing;
1913
2100
  if (!Array.isArray(missing) || !missing.every(isMissingBlob)) {
@@ -1921,9 +2108,12 @@ export class PipelexApiClient {
1921
2108
  * is the platform's fault, thrown before anything is sent as the `ApiResponseError` of an answer
1922
2109
  * the SDK cannot read. The bytes the tree reads for a blob are checked against it before they
1923
2110
  * are sent, since storage keeps a blob under the hash it is named by: bytes of another hash or
1924
- * size are refused with `RequestArgumentError`, and nothing more is uploaded.
2111
+ * size are refused with `RequestArgumentError`, and nothing more is uploaded. `userSignal` is
2112
+ * read before each blob's read and before its upload, and reaches each upload under way: once it
2113
+ * has aborted, no further blob is read or sent, the uploads in flight are stopped, and the abort
2114
+ * is thrown rather than a transfer failure.
1925
2115
  */
1926
- async uploadMissing(tree, asked, read) {
2116
+ async uploadMissing(tree, asked, read, sending, userSignal) {
1927
2117
  const missing = asked.result.missing;
1928
2118
  const byBlob = fileByBlob(tree);
1929
2119
  const strangers = missing
@@ -1932,16 +2122,40 @@ export class PipelexApiClient {
1932
2122
  if (strangers.length > 0) {
1933
2123
  throw this.unreadableAnswer("POST", `/${API_PREFIX}/blobs/missing`, asked.res, `a body asking for blobs this tree does not hold: ${[...new Set(strangers)].sort().join(", ")}`);
1934
2124
  }
1935
- await forEachConcurrently(missing, MAX_CONCURRENT_TRANSFERS, async (blob, signal) => {
1936
- const file = byBlob.get(blob.sha256);
1937
- const bytes = read?.get(file.path) ?? (await tree.readFile(file.path));
1938
- const bytesSha256 = await sha256Hex(bytes);
1939
- if (bytes.length !== blob.size || bytesSha256 !== blob.sha256) {
1940
- throw new RequestArgumentError(`The tree read ${bytes.length} bytes hashing to ${bytesSha256} for '${file.path}', which ` +
1941
- `its listing names as ${blob.size} bytes hashing to ${blob.sha256}, so they were not ` +
1942
- "uploaded and the method was not saved.");
1943
- }
1944
- await uploadBlob(blob, bytes, { timeoutMs: this.requestTimeoutMs, signal });
2125
+ // The tasks share the one signal the run of uploads aborts on a failure, linked here once to
2126
+ // the caller's and detached when the uploads are over.
2127
+ let linked;
2128
+ try {
2129
+ await forEachConcurrently(missing, MAX_CONCURRENT_TRANSFERS, async (blob, signal) => {
2130
+ linked ??= eitherSignal(signal, userSignal);
2131
+ await this.uploadOne(tree, blob, byBlob, read, sending, linked.signal, userSignal);
2132
+ });
2133
+ }
2134
+ catch (err) {
2135
+ if (userSignal?.aborted === true)
2136
+ throw userSignal.reason;
2137
+ throw err;
2138
+ }
2139
+ finally {
2140
+ linked?.dispose();
2141
+ }
2142
+ }
2143
+ /** Read, check and upload one missing blob (see `uploadMissing`). */
2144
+ async uploadOne(tree, blob, byBlob, read, sending, signal, userSignal) {
2145
+ const file = byBlob.get(blob.sha256);
2146
+ throwIfAborted(userSignal);
2147
+ const bytes = read?.get(file.path) ?? (await tree.readFile(file.path));
2148
+ const bytesSha256 = await sha256Hex(bytes);
2149
+ if (bytes.length !== blob.size || bytesSha256 !== blob.sha256) {
2150
+ throw new RequestArgumentError(`The tree read ${bytes.length} bytes hashing to ${bytesSha256} for '${file.path}', which ` +
2151
+ `its listing names as ${blob.size} bytes hashing to ${blob.sha256}, so they were not ` +
2152
+ `uploaded and ${sending.unsent}.`);
2153
+ }
2154
+ throwIfAborted(userSignal);
2155
+ await uploadBlob(blob, bytes, {
2156
+ timeoutMs: this.requestTimeoutMs,
2157
+ signal,
2158
+ unsent: sending.unsent,
1945
2159
  });
1946
2160
  }
1947
2161
  /**
@@ -2438,8 +2652,9 @@ export class PipelexApiClient {
2438
2652
  * Name the method exactly one of three ways, all server-resolved through the
2439
2653
  * one `pipeIo` call this composes:
2440
2654
  *
2441
- * - `files` — the inline MTHDS closure, with the `resources` its steps read
2442
- * (a `PipeDocGen` step's `template_file`), keyed by the path a step names;
2655
+ * - the method's tree — a `directory`, `contents`, a listed `tree` or the `files`
2656
+ * map, sent as `pipeIo` sends it, with the files its steps read (a `PipeDocGen`
2657
+ * step's `template_file`) among its files;
2443
2658
  * - `method_ref` — a published method's address, fetched by the runner;
2444
2659
  * - `method_id` — a stored method's catalog id, resolved by the platform
2445
2660
  * (hosted only; requires an API key).
@@ -2544,9 +2759,8 @@ export class PipelexApiClient {
2544
2759
  /**
2545
2760
  * The whole record for one run — `GET /v1/runs/{id}`.
2546
2761
  *
2547
- * The ONLY call that returns `mthds_contents` (what the run actually
2548
- * executed) and `inputs`. Kept off the status read, which pollers hit every
2549
- * few seconds. A failed run's stored report, `error`, is checked field by
2762
+ * The ONLY call that returns what the run actually executed and its
2763
+ * `inputs`. Kept off the status read, which pollers hit every few seconds. A failed run's stored report, `error`, is checked field by
2550
2764
  * field (see `RunErrorReport`).
2551
2765
  *
2552
2766
  * Read with `parseExactJson`, so an integer past `Number.MAX_SAFE_INTEGER` in the inputs is a
@@ -2563,6 +2777,30 @@ export class PipelexApiClient {
2563
2777
  const segment = idPathSegment(runId, "updateRun", RUN_ID_ARGUMENT);
2564
2778
  await this.requestProduct("PUT", `runs/${segment}`, input);
2565
2779
  }
2780
+ /**
2781
+ * A run's arguments checked before anything is listed, read or sent, `call` naming the caller's
2782
+ * method in each refusal: the generic `extra` passthrough, the Pipelex API's and the hosted
2783
+ * API's own selectors, and the method's tree in its forms as the standard checks them; then the
2784
+ * "something to run" precondition, the `method_ref` pairings and the linkage form's bare
2785
+ * `method_id`.
2786
+ */
2787
+ runParts(call, options) {
2788
+ const extensions = buildExtensions(options.extra);
2789
+ const api = buildApiRunExtensions(options);
2790
+ const hosted = buildHostedRunExtensions(options);
2791
+ const forms = protocolTreeForms(call, options);
2792
+ const treeForms = treeFormsGiven(forms);
2793
+ if (!options.pipe_code &&
2794
+ treeForms.length === 0 &&
2795
+ Object.keys(api).length === 0 &&
2796
+ Object.keys(hosted).length === 0 &&
2797
+ Object.keys(extensions).length === 0) {
2798
+ throw new RequestArgumentError(`Either pipe_code, the method's tree (directory, contents, tree or files), a method_ref, a hosted method_id or a server-specific extension arg (extra) must be provided to ${call}().`);
2799
+ }
2800
+ assertMethodRefPairsWithNothing(options, treeForms);
2801
+ assertLinkageMethodIdIsBare(options, treeForms);
2802
+ return { forms, extensions, api, hosted };
2803
+ }
2566
2804
  /**
2567
2805
  * Blocking `POST /v1/execute` adapted onto `RunResults` — the bare-runner
2568
2806
  * path. Forwards every protocol field PLUS every extension surface: the
@@ -2574,24 +2812,24 @@ export class PipelexApiClient {
2574
2812
  * so a runner that cannot resolve it says so (Rule 4) instead of the client
2575
2813
  * silently dropping it.
2576
2814
  */
2577
- async executeBlocking(options) {
2578
- const response = await this.execute({
2815
+ async executeBlocking(options, signal, sending) {
2816
+ const response = await this.executeRun({
2579
2817
  pipe_code: options.pipe_code ?? undefined,
2580
- mthds_contents: options.mthds_contents ?? undefined,
2581
2818
  inputs: options.inputs ?? undefined,
2582
2819
  output_name: options.output_name ?? undefined,
2583
2820
  output_multiplicity: options.output_multiplicity ?? undefined,
2584
2821
  dynamic_output_concept_ref: options.dynamic_output_concept_ref ?? undefined,
2585
- // The bundle must survive the fallback too — a bare runner reached through
2586
- // this path runs the same method as the durable one, or it runs nothing.
2822
+ // The method's tree must survive the fallback too — a bare runner reached through this path
2823
+ // runs the same method as the durable one, or it runs nothing. Its forms are checked here as
2824
+ // the start checked them, and `sending` carries the tree the start prepared, sent as is.
2825
+ directory: options.directory ?? undefined,
2826
+ contents: options.contents ?? undefined,
2827
+ tree: options.tree ?? undefined,
2587
2828
  files: options.files ?? undefined,
2588
- bundle_b64: options.bundle_b64 ?? undefined,
2589
- // And so must the files its steps read, or the same method fails to load here.
2590
- resources: options.resources ?? undefined,
2591
2829
  method_ref: options.method_ref ?? undefined,
2592
2830
  method_id: options.method_id ?? undefined,
2593
2831
  extra: options.extra ?? undefined,
2594
- });
2832
+ }, signal, sending);
2595
2833
  return resultsFromExecute(response);
2596
2834
  }
2597
2835
  }
@@ -2652,26 +2890,23 @@ function describeRefusedBaseUrl(value) {
2652
2890
  return `${shown} with ${inWords(parts, "and")} (not shown)`;
2653
2891
  }
2654
2892
  // The protocol's own request fields — `extra` is for extension args only.
2655
- // `files` / `bundle_b64` are reserved too: they are named run-source options,
2656
- // so smuggling them through `extra` (which merges last into the body) would
2657
- // overwrite the validated fields and bypass the run-source exclusivity check.
2893
+ // `files` is reserved too: it is the method's tree, a named option, so smuggling
2894
+ // it through `extra` (which merges last into the body) would overwrite the
2895
+ // prepared tree and bypass the one-form check.
2658
2896
  const PROTOCOL_REQUEST_KEYS = [
2659
2897
  "pipe_code",
2660
- "mthds_contents",
2661
2898
  "inputs",
2662
2899
  "output_name",
2663
2900
  "output_multiplicity",
2664
2901
  "dynamic_output_concept_ref",
2665
2902
  "files",
2666
- "bundle_b64",
2667
2903
  ];
2668
- // The PIPELEX API's own request fields — the layer-2 extensions the runner
2669
- // reads itself (`method_ref`, a run source in its own right, and `resources`,
2670
- // the files the bundle's steps read). Reserved on `extra` for the same reason
2671
- // the protocol fields are: `extra` merges last, so a smuggled copy would
2672
- // overwrite the validated named option and bypass the selector-exclusivity and
2673
- // path checks.
2674
- const PIPELEX_API_REQUEST_KEYS = ["method_ref", "resources"];
2904
+ // The PIPELEX API's own request field — the layer-2 extension the runner reads
2905
+ // itself (`method_ref`, a run source in its own right). Reserved on `extra` for
2906
+ // the same reason the protocol fields are: `extra` merges last, so a smuggled
2907
+ // copy would overwrite the validated named option and bypass the
2908
+ // selector-exclusivity check.
2909
+ const PIPELEX_API_REQUEST_KEYS = ["method_ref"];
2675
2910
  // The HOSTED API's own request fields — the layer-3 extensions this client
2676
2911
  // names itself. Reserved on `extra` for the same reason the protocol fields
2677
2912
  // are: `extra` merges last, so a smuggled copy would overwrite the validated
@@ -2684,15 +2919,20 @@ const PIPELEX_API_REQUEST_KEYS = ["method_ref", "resources"];
2684
2919
  // keep passing that vendor's `method_id` straight through. That per-layer
2685
2920
  // split is normative for the whole layered client stack.
2686
2921
  const HOSTED_REQUEST_KEYS = ["method_id"];
2922
+ // The client-only options, which are never serialized as such: the three forms of
2923
+ // the method's tree the client turns into `files` (`directory`, `contents`, `tree`)
2924
+ // and the `bundleMain` hint. Reserved on `extra` so none of them reaches the wire
2925
+ // through the passthrough under its own name.
2926
+ const CLIENT_ONLY_KEYS = ["directory", "contents", "tree", "bundleMain"];
2687
2927
  // Keys that must never ride `extra`: the named request options above (which
2688
2928
  // `extra` would overwrite — it merges last into the body) plus the client-only
2689
- // `bundleMain` hint, which is documented as never-serialized and so must not
2690
- // reach the wire through the passthrough either.
2929
+ // options. A field the client no longer names, such as the runner's legacy
2930
+ // `mthds_contents`, is NOT reserved: `extra` is the passthrough for exactly that.
2691
2931
  const RESERVED_EXTRA_KEYS = new Set([
2692
2932
  ...PROTOCOL_REQUEST_KEYS,
2693
2933
  ...PIPELEX_API_REQUEST_KEYS,
2694
2934
  ...HOSTED_REQUEST_KEYS,
2695
- "bundleMain",
2935
+ ...CLIENT_ONLY_KEYS,
2696
2936
  ]);
2697
2937
  // Prototype-pollution vectors. An own `__proto__` (exactly what `JSON.parse`
2698
2938
  // yields, and `extra` is the field most likely populated from untrusted JSON),
@@ -2733,23 +2973,110 @@ function buildExtensions(extra) {
2733
2973
  // caps responses regardless.
2734
2974
  const METHOD_REF_FETCH_TIMEOUT_MS = 180_000; // 3 min — covers the server's clone + resolve
2735
2975
  /**
2736
- * The internal request budget for a crate route's `CrateRequestBase` closure
2737
- * (`resolve`, `codegen`, `pipeIo`): the static-route default, unless the request
2738
- * is one the default would cut short through no fault of the server's.
2739
- *
2740
- * - **`resources`** make the request *body* as large as the runner's limits allow,
2741
- * about 13 MiB of base64 by default, and the whole upload is charged against
2742
- * this budget. They get the client's request ceiling (`requestTimeoutMs`),
2743
- * which `start` gives a request carrying them and `validate` carries them under
2744
- * by default, so the same bundle and files never time out on one route yet go
2745
- * through on another.
2746
- * - **A `method_ref`** may make the server clone a repository first, which gets
2747
- * the fetch-sized budget.
2976
+ * Whether a crate request's `files` carry more inline than `INLINE_TREE_MAX_BYTES`, weighed as the
2977
+ * request spends them: a tree sent inline to an API keeping no blob store, whatever its weight, or
2978
+ * a `files` map given as is. A map of hash entries weighs little, and a value that is not a map
2979
+ * is not weighed.
2748
2980
  */
2749
- function crateRequestTimeoutMs(request, executeCeilingMs) {
2750
- if (hasBundleResources(request.resources))
2751
- return executeCeilingMs;
2752
- return nonEmptyString(request.method_ref) !== undefined ? METHOD_REF_FETCH_TIMEOUT_MS : undefined;
2981
+ function carriesLargeInlineTree(files) {
2982
+ if (files === null || files === undefined || !isPlainObject(files))
2983
+ return false;
2984
+ try {
2985
+ return inlineMapBytes(files) > INLINE_TREE_MAX_BYTES;
2986
+ }
2987
+ catch {
2988
+ // A map JSON cannot carry is refused as the caller's argument when the request is written.
2989
+ return false;
2990
+ }
2991
+ }
2992
+ /**
2993
+ * A run's wire body: the protocol's arguments, the method's `files`, then the Pipelex API's and
2994
+ * the hosted API's selectors and the `extra` passthrough. A start drops a `null` argument, as it
2995
+ * always has; `execute` sends its arguments as given. `?? undefined` so `JSON.stringify` drops an
2996
+ * absent field from the wire body.
2997
+ */
2998
+ function runRequestBody(call, options, files, parts) {
2999
+ const dropNull = call === "start";
3000
+ return {
3001
+ pipe_code: dropNull ? (options.pipe_code ?? undefined) : options.pipe_code,
3002
+ inputs: dropNull ? (options.inputs ?? undefined) : options.inputs,
3003
+ output_name: dropNull ? (options.output_name ?? undefined) : options.output_name,
3004
+ output_multiplicity: dropNull
3005
+ ? (options.output_multiplicity ?? undefined)
3006
+ : options.output_multiplicity,
3007
+ dynamic_output_concept_ref: dropNull
3008
+ ? (options.dynamic_output_concept_ref ?? undefined)
3009
+ : options.dynamic_output_concept_ref,
3010
+ files: files ?? undefined,
3011
+ ...parts.api,
3012
+ ...parts.hosted,
3013
+ ...parts.extensions,
3014
+ };
3015
+ }
3016
+ /**
3017
+ * The error a blocking execute throws for `err`: a `PipelineExecuteTimeoutError` when the hosted
3018
+ * gateway cut the request off (see `isGatewayCutOff`), timed from `sentAt`, and `err` otherwise.
3019
+ */
3020
+ function executeCutOff(err, sentAt) {
3021
+ if (err instanceof RunStillRunningError)
3022
+ return err;
3023
+ const elapsedMs = performance.now() - sentAt;
3024
+ return isGatewayCutOff(err, elapsedMs)
3025
+ ? new PipelineExecuteTimeoutError(elapsedMs, { cause: err })
3026
+ : err;
3027
+ }
3028
+ /**
3029
+ * Whether a run's `extra` carries a method bundle a runner reads, `mthds_contents` or
3030
+ * `bundle_b64`, as the `pipelex-sdk` command sends one: a start carrying one gets the request
3031
+ * ceiling, as the Python SDK gives it, since the bundle can make the body large.
3032
+ */
3033
+ function carriesExtraBundle(extensions) {
3034
+ const contents = extensions["mthds_contents"];
3035
+ const carriesContents = Array.isArray(contents) ? contents.length > 0 : Boolean(contents);
3036
+ return carriesContents || Boolean(extensions["bundle_b64"]);
3037
+ }
3038
+ /** The refusal of a `directory` that is not a path, `call` naming the caller's method. */
3039
+ function directoryRefusal(call) {
3040
+ return new RequestArgumentError(`${call}() got a directory that is not a path: give the method's directory as a string.`);
3041
+ }
3042
+ /**
3043
+ * `value` as a listed tree, or the refusal of one that is not, `call` naming the caller's method:
3044
+ * a `tree` must have its `files`, its `digest` and a `readFile`, as the `MethodTree` the `mthds`
3045
+ * package's `listMethodTree` returns has, which the shape check `prepareInputs` applies too.
3046
+ */
3047
+ function checkedTreeSource(call, value) {
3048
+ const fault = treeShapeFault(value);
3049
+ if (fault !== undefined) {
3050
+ throw new RequestArgumentError(`${call}() got a tree that is not a listed method tree — its files, its digest and a ` +
3051
+ `readFile — such as the MethodTree listMethodTree returns; got ${fault}.`);
3052
+ }
3053
+ return value;
3054
+ }
3055
+ /**
3056
+ * The whole tree inline, for a receiver keeping no blob store, held to the inline limit it
3057
+ * announces as `mthds`'s `MthdsApiClient` holds it: its files' declared sizes are checked before
3058
+ * anything is read, since the inline map weighs at least that much, then the encoded map, so a
3059
+ * tree the receiver would refuse is refused here with `RequestArgumentError`, naming the limit,
3060
+ * without being sent. Each file is read into `read`, under `signal`.
3061
+ */
3062
+ async function inlineWithin(tree, call, sending, maxInlineBytes, read, signal) {
3063
+ if (maxInlineBytes !== undefined && treeTotalSize(tree) > maxInlineBytes) {
3064
+ throw inlineLimitRefusal(call, sending, "its files alone weigh", treeTotalSize(tree), maxInlineBytes);
3065
+ }
3066
+ const inline = inlineFilesOf(tree, await readTreeBytesInto(tree, read, signal));
3067
+ if (maxInlineBytes !== undefined) {
3068
+ const weight = inlineMapBytes(inline);
3069
+ if (weight > maxInlineBytes) {
3070
+ throw inlineLimitRefusal(call, sending, "its inline map weighs", weight, maxInlineBytes);
3071
+ }
3072
+ }
3073
+ return inline;
3074
+ }
3075
+ /** The refusal of a tree past the inline limit of a receiver keeping no blob store. */
3076
+ function inlineLimitRefusal(call, sending, measured, weight, limit) {
3077
+ return new RequestArgumentError(`${call}() cannot send the method's tree: the API keeps no blob store, so the tree travels ` +
3078
+ `inline, and ${measured} ${weight} bytes, past the ${limit} bytes it takes inline (its ` +
3079
+ `max_inline_bytes), so ${sending.unsent}.`);
2753
3080
  }
2754
3081
  /**
2755
3082
  * Copy the Pipelex API's own run arguments onto the wire body. Named options,
@@ -2777,14 +3104,20 @@ function buildHostedRunExtensions(options) {
2777
3104
  return methodId === undefined ? {} : { method_id: methodId };
2778
3105
  }
2779
3106
  /**
2780
- * The standard's run-source exclusivity check (`assertExclusiveRunSources`: the two bundle
2781
- * encodings together, or a bundle beside inline contents), its refusal rethrown as a
3107
+ * The forms of the method's tree a protocol route (a run, `validate`) was given, checked as the
3108
+ * MTHDS standard checks them: its `assertOneMethodSource` on `files` and `tree` (both at once, or
3109
+ * either holding no file, refused whatever else the request carries), its refusal rethrown as a
2782
3110
  * `RequestArgumentError` with the same message and the standard's error as `cause`, so it carries
2783
- * a verdict like every other argument the client refuses.
3111
+ * a verdict like every other argument the client refuses; and `contents` holding no file refused
3112
+ * the same way. A `null` member and an empty `directory` are absent.
2784
3113
  */
2785
- function assertRunSourcesExclusive(options) {
3114
+ function protocolTreeForms(call, options) {
3115
+ // A `tree` the standard's check would read as a tree is checked as one first, so a value that
3116
+ // is not one is refused as the caller's argument rather than failing as a native TypeError.
3117
+ if (options.tree != null)
3118
+ checkedTreeSource(call, options.tree);
2786
3119
  try {
2787
- assertExclusiveRunSources(options);
3120
+ assertOneMethodSource({ files: options.files ?? null, tree: options.tree ?? null });
2788
3121
  }
2789
3122
  catch (err) {
2790
3123
  if (err instanceof PipelineRequestError) {
@@ -2792,32 +3125,59 @@ function assertRunSourcesExclusive(options) {
2792
3125
  }
2793
3126
  throw err;
2794
3127
  }
3128
+ const contents = options.contents ?? undefined;
3129
+ if (contents !== undefined &&
3130
+ typeof contents === "object" &&
3131
+ Object.keys(contents).length === 0) {
3132
+ throw emptyTreeRefusal(call, "contents", REQUEST_SENDING);
3133
+ }
3134
+ return {
3135
+ directory: typeof options.directory === "string"
3136
+ ? nonEmptyString(options.directory)
3137
+ : (options.directory ?? undefined),
3138
+ contents,
3139
+ tree: options.tree ?? undefined,
3140
+ files: options.files ?? undefined,
3141
+ };
3142
+ }
3143
+ /**
3144
+ * The names of the forms of the method's tree `input` gives, in the order the refusals name them.
3145
+ * `files` counts when it is not `undefined`, since a save's `files: null` empties a draft; the
3146
+ * other forms count when they are neither `undefined` nor `null`, and `directory` when it is not
3147
+ * empty either. A route that reads `files: null` as absent normalizes it before asking.
3148
+ */
3149
+ function treeFormsGiven(input) {
3150
+ const given = [];
3151
+ if (input.directory != null && input.directory !== "")
3152
+ given.push("directory");
3153
+ if (input.contents != null)
3154
+ given.push("contents");
3155
+ if (input.tree != null)
3156
+ given.push("tree");
3157
+ if (input.files !== undefined)
3158
+ given.push("files");
3159
+ return given;
2795
3160
  }
2796
3161
  /**
2797
3162
  * Enforce the run routes' `method_ref` exclusivity, mirroring the server's own
2798
- * 422s so an illegal pairing fails before anything hits the wire. A
2799
- * `method_ref` is a complete run source (the fetched package carries its
2800
- * `.mthds` and its entry pipe), so it pairs with NOTHING: not with inline
2801
- * `mthds_contents`, not with a method bundle, and not with the hosted
2802
- * `method_id` — an address run has its own provenance and needs no linkage id.
3163
+ * 422s so an illegal pairing fails before anything is listed, read or sent. A
3164
+ * `method_ref` is a complete run source (the fetched package carries its whole
3165
+ * tree and its entry pipe), so it pairs with NOTHING: not with the method's tree
3166
+ * in any form, and not with the hosted `method_id` — an address run has its own
3167
+ * provenance and needs no linkage id.
2803
3168
  *
2804
- * The one documented run-route exception is deliberately NOT here: inline
2805
- * source + `method_id` stays legal (the inline source runs; the id demotes to
2806
- * run-history linkage), its one condition, a bare id, held by
2807
- * `assertLinkageMethodIdIsBare` below. `pipe_code` beside a `method_ref` is legal too — it
2808
- * overrides the manifest's `main_pipe`. The wording of the first two errors
2809
- * mirrors the server's validator; presence semantics match it as well
2810
- * (`mthds_contents` counts when non-empty, a bundle encoding counts when the
2811
- * key is present, the selectors count when non-empty).
3169
+ * The one documented run-route exception is deliberately NOT here: a tree +
3170
+ * `method_id` stays legal (the tree runs; the id demotes to run-history linkage),
3171
+ * its one condition, a bare id, held by `assertLinkageMethodIdIsBare` below.
3172
+ * `pipe_code` beside a `method_ref` is legal too — it overrides the manifest's
3173
+ * `main_pipe`. The selectors count when non-empty.
2812
3174
  */
2813
- function assertMethodRefPairsWithNothing(options) {
3175
+ function assertMethodRefPairsWithNothing(options, treeForms) {
2814
3176
  if (nonEmptyString(options.method_ref) === undefined)
2815
3177
  return;
2816
- if (options.mthds_contents != null && options.mthds_contents.length > 0) {
2817
- throw new RequestArgumentError("method_ref and inline mthds_contents are mutually exclusive; send one or the other.");
2818
- }
2819
- if (options.files != null || options.bundle_b64 != null) {
2820
- throw new RequestArgumentError("method_ref and a method bundle (bundle_b64 / files) are mutually exclusive; send one or the other.");
3178
+ if (treeForms.length > 0) {
3179
+ throw new RequestArgumentError(`method_ref and the method's tree (${treeForms.join(", ")}) are mutually exclusive: a ` +
3180
+ "published method's tree is its package's own. Send one or the other.");
2821
3181
  }
2822
3182
  if (nonEmptyString(options.method_id) !== undefined) {
2823
3183
  throw new RequestArgumentError("method_ref and method_id are mutually exclusive: an address run carries its own provenance " +
@@ -2826,79 +3186,24 @@ function assertMethodRefPairsWithNothing(options) {
2826
3186
  }
2827
3187
  /**
2828
3188
  * Enforce the run routes' linkage clause, mirroring the platform's own `422` so a
2829
- * suffixed linkage id fails before anything hits the wire. Beside an inline source
2830
- * (`mthds_contents`, `files` or `bundle_b64`) the `method_id` is run-history linkage
2831
- * and must be a bare catalog id: the inline source is what runs, so a version suffix
2832
- * (`mt_…@3`, `mt_…@draft`) would claim a version that did not. A `method_id` alone
2833
- * keeps its suffix, which names the version to run.
2834
- *
2835
- * An inline source counts only when it reaches the wire, as the platform counts it:
2836
- * `mthds_contents` when non-empty and a bundle encoding when `hasBundlePayload` says
2837
- * it carries one, since `nonEmptyFiles` and `nonEmptyString` drop an empty `files: {}`
2838
- * or `bundle_b64: ""` from the body, and a suffixed id beside one then runs the version
2839
- * it names. That differs on purpose from `assertMethodRefPairsWithNothing`, which
2840
- * mirrors the platform's presence-based pairing validator. Only the suffix is checked,
2841
- * by its `@`, since the catalog id's alphabet has none: the id itself stays a
2842
- * pass-through the platform resolves.
3189
+ * suffixed linkage id fails before anything is listed, read or sent. Beside the
3190
+ * method's tree, in any form, the `method_id` is run-history linkage and must be a
3191
+ * bare catalog id: the tree is what runs, so a version suffix (`mt_…@3`,
3192
+ * `mt_…@draft`) would claim a version that did not. A `method_id` alone keeps its
3193
+ * suffix, which names the version to run. Only the suffix is checked, by its `@`,
3194
+ * since the catalog id's alphabet has none: the id itself stays a pass-through the
3195
+ * platform resolves.
2843
3196
  */
2844
- function assertLinkageMethodIdIsBare(options) {
3197
+ function assertLinkageMethodIdIsBare(options, treeForms) {
2845
3198
  const methodId = nonEmptyString(options.method_id);
2846
3199
  if (methodId === undefined || !methodId.includes("@"))
2847
3200
  return;
2848
- const hasInlineSource = (options.mthds_contents != null && options.mthds_contents.length > 0) ||
2849
- hasBundlePayload(options);
2850
- if (!hasInlineSource)
2851
- return;
2852
- throw new RequestArgumentError(`method_id "${methodId}" beside an inline source is run-history linkage and must be a bare ` +
2853
- "catalog id: the inline source is what runs, so a version suffix would claim a version " +
2854
- "that did not. Send the bare id (parseMethodSelector(...).method_id), or drop the inline " +
2855
- "source to run the version the selector names.");
2856
- }
2857
- /**
2858
- * Enforce the run routes' `resources` pairing, mirroring the runner's own `422`s
2859
- * so an illegal pairing fails before anything is encoded or sent. The files a
2860
- * bundle's steps read accompany that bundle: inline `mthds_contents` or a method
2861
- * bundle. Beside a `method_ref` they are refused, a published package's files
2862
- * being its own, and so they are with no source at all, where no step could read
2863
- * them. A hosted `method_id` without an inline source is left to the server: the
2864
- * platform resolves it into the bundle the runner then reads them with.
2865
- *
2866
- * Presence follows the other pairing checks: `resources` counts when it carries a
2867
- * file, `mthds_contents` when non-empty, a bundle when `hasBundlePayload` says so.
2868
- */
2869
- function assertResourcesAccompanyABundle(options) {
2870
- if (!hasBundleResources(options.resources))
3201
+ if (treeForms.length === 0)
2871
3202
  return;
2872
- if (nonEmptyString(options.method_ref) !== undefined) {
2873
- throw new RequestArgumentError("resources accompany inline mthds_contents or a method bundle; a method_ref's files are the package's own.");
2874
- }
2875
- const hasInlineSource = (options.mthds_contents != null && options.mthds_contents.length > 0) ||
2876
- hasBundlePayload(options);
2877
- if (!hasInlineSource && nonEmptyString(options.method_id) === undefined) {
2878
- throw new RequestArgumentError("resources accompany the bundle whose steps read them: send them with mthds_contents or a method bundle.");
2879
- }
2880
- }
2881
- /**
2882
- * A crate request as the wire takes it: the request as given, its `resources`
2883
- * encoded into the base64 map, the key left off when there is nothing to send.
2884
- * A request that carries no `resources` key is posted untouched.
2885
- */
2886
- async function wireCrateRequest(request) {
2887
- if (typeof request !== "object" || request === null || !("resources" in request)) {
2888
- return request;
2889
- }
2890
- const { resources, ...rest } = request;
2891
- const encoded = await encodeBundleResources(resources);
2892
- return encoded === undefined ? rest : { ...rest, resources: encoded };
2893
- }
2894
- /**
2895
- * Normalize a bundle encoding for the wire: an empty map / string is NOT a
2896
- * runnable bundle, so it must not be sent (the runner rejects a zero-file
2897
- * bundle). Exclusivity is still checked on presence upstream, so an empty
2898
- * encoding supplied alongside another source has already been rejected.
2899
- */
2900
- function nonEmptyFiles(files) {
2901
- return files != null && Object.keys(files).length > 0 ? files : undefined;
3203
+ throw new RequestArgumentError(`method_id "${methodId}" beside the method's tree is run-history linkage and must be a bare ` +
3204
+ "catalog id: the tree is what runs, so a version suffix would claim a version that did " +
3205
+ "not. Send the bare id (parseMethodSelector(...).method_id), or drop the tree to run the " +
3206
+ "version the selector names.");
2902
3207
  }
2903
3208
  function nonEmptyString(value) {
2904
3209
  return value != null && value.length > 0 ? value : undefined;