@pipelex/sdk 0.49.0 → 0.50.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.
@@ -40,54 +40,78 @@ The MIME type and size are known client-side, so the record is assembled without
40
40
 
41
41
  `uploadFile` needs the client, and the client needs the API key. A browser page holds the bytes but must never hold the key, so the work is split in two: the side holding the key requests a **grant** for one file, hands it to the page, and the page sends the file straight to storage with it. The bytes cross neither the credential-holding server nor the API gateway, so the gateway's request quota, which caps a base64 `/v1/upload` near 7.5 MiB, does not apply: the file may be as large as the service's own limit, which the grant reports as `max_bytes`.
42
42
 
43
- **Requesting the grant** is a client method over `POST /v1/upload/grant`:
43
+ **Describing the file** happens where the bytes are, with the standalone `describeUpload(file, { filename?, maxBytes? })`, which reads a `File`'s name, type and size and computes the SHA-256 of its bytes with Web Crypto:
44
44
 
45
45
  ```ts
46
- const grant = await client.requestUploadGrant({
47
- filename: file.name,
48
- content_type: file.type, // optional; empty or omitted signs no type
49
- size: file.size,
50
- });
51
- // { uri, url, headers, expires_at, max_bytes }
46
+ import { describeUpload } from "@pipelex/sdk/upload";
47
+
48
+ const description = await describeUpload(file);
49
+ // { filename, content_type, size, sha256 }
50
+ ```
51
+
52
+ A nameless `Blob` takes its name from `filename`, which also replaces a `File`'s own, and a file with no name at all is refused with an `InputPreparationError` before it is read. The whole file is read into memory once to hash it; a file that cannot be read, such as one changed on disk since it was picked, is an `InputPreparationError` whose `cause` is the runtime's error.
53
+
54
+ `sha256` is left out, rather than the call failing, in two cases, and the grant then works as it does for any request without a hash, below:
55
+
56
+ - **A file larger than `maxBytes`** is described without its hash and never read. The bound is 52428800 bytes (50 MiB) by default, the platform's own default limit on one upload, because reading a file of several gigabytes picked by mistake would freeze or crash a browser tab before the grant route could refuse its size. Without the hash, the grant request carries the size alone. Where the deployment's limit is no larger than `maxBytes`, as it is with the default on a deployment at the platform's default limit, the route's `413` refuses it with its own message. On a deployment whose grants report a larger `max_bytes`, a file between `maxBytes` and that limit is granted as any request without a hash is: it is uploaded under a fresh name, with no deduplication and no checksum for storage to hold its body to, and the description's missing `sha256` is how the caller sees it. A caller on such a deployment passes its limit, which every grant reports as `max_bytes`, as `maxBytes` to hash up to it, and a `maxBytes` that is not a positive number is refused with an `InputPreparationError` before anything is read.
57
+ - **Where the runtime offers no Web Crypto digest**: a browser withholds `crypto.subtle` from a page served over plain `http` from any host but the local one. The upload still succeeds, only without the deduplication.
58
+
59
+ **Requesting the grant** is a client method over `POST /v1/upload/grant`, which takes the description as it is:
60
+
61
+ ```ts
62
+ const grant = await client.requestUploadGrant(description);
63
+ // { file_id, uri, stored, url, headers, expires_at, max_bytes }
52
64
  ```
53
65
 
54
- The file is described, never sent. The grant (`UploadGrant`) is a presigned, create-only `PUT` for one new object under the caller's organization: `url` is where to send it, `headers` are the signed headers to send unchanged, `uri` is the `pipelex-storage://` reference the object will carry, and `expires_at` is when storage stops accepting it, a few minutes later. The wire is snake_case like every product route, so the grant can travel to the page as JSON without a remap. A declared `size` over the limit is a `413` `ApiResponseError` whose `code` is `payload_too_large`, a deployment without the route answers a `404`, and a `2xx` whose body is not a JSON object is an `ApiResponseError` the SDK cannot read, `runtime` and not retryable, rather than a grant.
66
+ The file is described, never sent. Beside the description, the request takes `kind`, `uploaded` by default or `test` for a test input made for one method, which then names that method's bare catalog id in `method_id`. The grant (`UploadGrant`) is one shape with nullable fields, and **a caller branches on `stored`, never on whether `url` is present**:
67
+
68
+ - **`stored: true`**: the organization already holds these bytes. Nothing is to be sent: `uri` is the file's `pipelex-storage://` reference now, `file_id` its record, and `url`, `headers` and `expires_at` are `null`. The same person asking again for the same bytes under the same name and kind gets their existing record back.
69
+ - **`stored: false`**: a presigned, create-only `PUT` for the object. `url` is where to send it, `headers` are the signed headers to send unchanged, `uri` is the reference the object will carry, `file_id` its record, `pending` until the bytes land, and `expires_at` is when storage stops accepting the `PUT`, a few minutes later.
55
70
 
56
- **The grant is a bearer capability.** Until it expires, whoever holds it can write that one object, so the SDK never logs it, and its caller should not either. The route never replays a grant for a repeated request: a caller that lost one asks for a new one.
71
+ With a `sha256`, the object is named after the bytes, `<org>/assets/<sha256>.<ext>`, and the grant signs the hash as `x-amz-checksum-sha256`, so storage holds the body to exactly those bytes. Without one, the grant always names a new object under a fresh name and answers `stored: false`, as every grant did before hashes, so a client that cannot hash still uploads; a client that can hash always should. The platform types the object by the name's extension and never stores the declared `content_type`, which only serves a name with no extension. The wire is snake_case like every product route, so the description can travel to the server and the grant back to the page as JSON without a remap. A declared `size` over the limit is a `413` `ApiResponseError` whose `code` is `payload_too_large`; a `sha256` that is not 64 lowercase hexadecimal digits is a `422`, and so is a `size` other than the one the organization holds those bytes at (`blob_mismatch`), which means the hash was computed over other bytes than the ones described; a deployment without the route answers a `404`; and a `2xx` whose body is not a JSON object is an `ApiResponseError` the SDK cannot read, `runtime` and not retryable, rather than a grant.
57
72
 
58
- **Sending the file** is the standalone `uploadWithGrant(grant, file, { signal?, timeoutMs? })`, which takes a `Blob` or a `File` and returns `{ uri }`:
73
+ **A grant with a target is a bearer capability.** Until it expires, whoever holds it can write that one object, so the SDK never logs it, and its caller should not either. The route never replays a grant for a repeated request: a caller that lost one asks for a new one, which, with a `sha256`, answers the same file record.
74
+
75
+ **Sending the file** is the standalone `uploadWithGrant(grant, file, { signal?, timeoutMs? })`, which takes a `Blob` or a `File` and returns `{ uri, file_id }`:
59
76
 
60
77
  ```ts
61
- import { uploadWithGrant, RejectedAssetError } from "@pipelex/sdk/upload";
78
+ import { describeUpload, uploadWithGrant, RejectedAssetError } from "@pipelex/sdk/upload";
62
79
 
63
- const { uri } = await uploadWithGrant(grant, file);
80
+ const { uri, file_id } = await uploadWithGrant(grant, file);
64
81
  ```
65
82
 
66
- It sends the file as the raw body of a `PUT` to `grant.url`, with `grant.headers` exactly as received. The grant signed `Content-Length` too, and the body sets it, so the file must be exactly the declared size. Redirects are refused rather than followed, because a presigned URL is valid only at the address it was signed for. **The `uri` names nothing until the `PUT` has succeeded**, so fill a form field or a run input with it only after `uploadWithGrant` resolves.
83
+ For a grant that answers `stored: true` it sends nothing, reads nothing and returns at once. Otherwise it sends the file as the raw body of a `PUT` to `grant.url`, with `grant.headers` exactly as received. The grant signed `Content-Length` too, and the body sets it, so the file must be exactly the declared size. Redirects are refused rather than followed, because a presigned URL is valid only at the address it was signed for. **For a grant that is not stored, the `uri` names nothing until the `PUT` has succeeded**, so fill a form field or a run input with it only after `uploadWithGrant` resolves.
84
+
85
+ Two of storage's answers are no failure. A `412` refuses the create-only `PUT` because the object already exists; when the grant names the file by its bytes, that object can only hold those bytes, stored there meanwhile by another upload of the same file, such as the same file dropped twice at once, or by the platform's own copy of a run's file holding them, made when another run took it. So the call resolves as stored, with the grant's `uri` and `file_id`, the answer a `stored: true` grant gives, and the platform settles the file's record once it sees the bytes, as for any upload. The SDK tells such a grant by its signed headers, never by the shape of its key: the platform signs `x-amz-checksum-sha256` on every grant asked for with a hash and on none asked for without one, whose `412` keeps its meaning of a used grant. A `409 ConditionalRequestConflict` says another write of the object was in flight, which storage documents as retryable, so the call sends the `PUT` again after pauses of 250 ms, 500 ms, 1 s and 2 s, within its time limit, and fails with `conflict` only when every attempt meets it.
86
+
87
+ A page and the server that holds the key split the work this way: the page calls `describeUpload` and sends the description to the server, the server passes it to `requestUploadGrant` unchanged and hands the grant back, and the page calls `uploadWithGrant`. The server never holds the bytes.
67
88
 
68
- **The time limit.** The whole exchange with storage runs under a time limit — the request, the file going out, the answer coming back and the read of an error body — so a stalled upload settles even when the caller passes no signal. By default the limit is 60 s plus 1 s for every started 128 KiB of the file, which assumes about 1 Mbit/s and comes to 460 s for a 50 MiB file. `timeoutMs` replaces the default, for a slower link or a caller that wants a tighter bound; it must be a positive number no larger than 2147483647, the longest delay a timer honours, or the call throws an `InputPreparationError` before anything is sent. A `signal` ends the upload whenever it fires, so it can shorten the limit but never lengthen it, and a caller's abort still propagates as the signal's own reason even when the limit ran out too. When the limit runs out before storage answers, the call throws an `UploadTransportError` whose `code` is `timeout` and whose message names the seconds allowed. When it runs out while storage's error body is still arriving, storage has already answered, so the call settles as that answer instead, classified from its status and whatever part of the body arrived. After a timeout, whether storage stored the file is unknown: retrying with the same grant before it expires either stores the file or answers the `412` of a used grant, and then the grant's `uri` already names the file. The grant's expiry does not bound the upload, because storage checks the signature when the request starts, not when it ends.
89
+ **The time limit.** The whole exchange with storage runs under a time limit — the request, the file going out, the answer coming back and the read of an error body — so a stalled upload settles even when the caller passes no signal. By default the limit is 60 s plus 1 s for every started 128 KiB of the file, which assumes about 1 Mbit/s and comes to 460 s for a 50 MiB file. `timeoutMs` replaces the default, for a slower link or a caller that wants a tighter bound; it must be a positive number no larger than 2147483647, the longest delay a timer honours, or the call throws an `InputPreparationError` before anything is sent. A `signal` ends the upload whenever it fires, so it can shorten the limit but never lengthen it, and a caller's abort still propagates as the signal's own reason even when the limit ran out too. When the limit runs out before storage answers, the call throws an `UploadTransportError` whose `code` is `timeout` and whose message names the seconds allowed. When it runs out while storage's error body is still arriving, or during the pause after a conflict, storage has already answered, so the call settles as that answer instead, classified from its status and whatever part of the body arrived. After a timeout, whether storage stored the file is unknown: retrying with the same grant before it expires either stores the file or finds it stored, which for a grant asked for without a hash is the `412` of a used grant, and then the grant's `uri` names the file. The grant's expiry does not bound the upload, because storage checks the signature when the request starts, not when it ends.
69
90
 
70
- **The browser-safe entry.** `uploadWithGrant`, the grant types and the three error classes it raises (`InputPreparationError`, `RejectedAssetError`, `UploadTransportError`) are exported from `@pipelex/sdk/upload`, whose import graph reaches no Node builtin, no `undici` and not the client, so a page bundles it with nothing marked external. They are the same function and the same classes the main entry exports, so an `instanceof` check agrees whichever entry the error came through. The unit suite bundles both entries for the browser with esbuild and fails if anything Node-only becomes reachable.
91
+ **The browser-safe entry.** `describeUpload`, `uploadWithGrant`, the grant types and the three error classes they raise (`InputPreparationError`, `RejectedAssetError`, `UploadTransportError`) are exported from `@pipelex/sdk/upload`, whose import graph reaches no Node builtin, no `undici` and not the client, so a page bundles it with nothing marked external. They are the same functions and the same classes the main entry exports, so an `instanceof` check agrees whichever entry the error came through. The unit suite bundles both entries for the browser with esbuild and fails if anything Node-only becomes reachable.
71
92
 
72
93
  **Storage's refusals** map onto the input-preparation errors, so a caller that already handles `uploadFile` needs no new case:
73
94
 
74
95
  | Storage answers | Error | `code` | Meaning |
75
96
  | --- | --- | --- | --- |
97
+ | nothing sent, the grant answered `stored: true` | none | | Stored already; `uri` names the file. |
76
98
  | `2xx` | none | | Stored; `uri` names the object. |
77
- | `412 PreconditionFailed` | `RejectedAssetError` | `grant_used` | The grant was already used. A grant writes one object, once — so after an earlier attempt that failed at storage, the grant's `uri` may already name the file. |
99
+ | `412 PreconditionFailed`, for a grant that names its bytes | none | | Stored meanwhile, by another upload of the same bytes or by the platform's copy of a run's file: the object can only hold those bytes, so the call resolves with the grant's `uri` and `file_id`. |
100
+ | `412 PreconditionFailed`, for a grant asked for without a hash | `RejectedAssetError` | `grant_used` | The grant was already used. Such a grant writes one object, once — so after an earlier attempt that failed at storage, the grant's `uri` may already name the file. |
78
101
  | `403 SignatureDoesNotMatch` | `RejectedAssetError` | `signature_mismatch` | The file's size, content type or metadata differ from what the grant signed. |
79
102
  | `403 AccessDenied`, "Request has expired" | `RejectedAssetError` | `grant_expired` | The grant expired. |
80
103
  | `403 AccessDenied`, headers "not signed" | `RejectedAssetError` | `unsigned_header` | The request carried a storage header the grant did not sign. |
104
+ | `400 BadDigest` | `RejectedAssetError` | `store_refused` | The body's bytes differ from the ones whose SHA-256 the grant was asked with. Send the file the grant was requested for, or describe the file again and ask for a new grant. |
81
105
  | `400 RequestTimeout` | `UploadTransportError` | `storage_timeout` | Storage stopped waiting for the body and wrote nothing, so the same grant can be retried while it is valid. |
82
- | `409 ConditionalRequestConflict` | `UploadTransportError` | `conflict` | Another upload with the same grant was still in progress. Whether the object was written is unknown, so the same-grant retry applies. A grant is meant to be sent once at a time. |
106
+ | `409 ConditionalRequestConflict` on every attempt | `UploadTransportError` | `conflict` | Another write of the same object was still in progress after every pause. Whether the object was written is unknown, so the same-grant retry applies. A single `409` followed by any other answer is read as that answer. |
83
107
  | `408`, `429` | `UploadTransportError` | `unexpected` | Storage timed out or throttled the request rather than refusing the file, so the error is retryable: a later attempt can succeed, and the same-grant retry applies. |
84
108
  | any other `4xx` | `RejectedAssetError` | `store_refused` | Storage's own message is relayed. |
85
109
  | `501` | `UploadTransportError` | `server_error` | Storage does not implement the request it was sent, so it wrote nothing, and a retry meets the same answer: the error is not retryable. |
86
- | any other `5xx` | `UploadTransportError` | `server_error` | Whether the object was written is unknown: retrying with the same grant either stores it or answers the `412` of a used grant. |
110
+ | any other `5xx` | `UploadTransportError` | `server_error` | Whether the object was written is unknown: retrying with the same grant either stores it or finds it stored, which for a grant asked for without a hash is the `412` of a used grant. |
87
111
  | a redirect | `UploadTransportError` | `redirected` | Refused, not followed. |
88
112
  | no answer within the time limit | `UploadTransportError` | `timeout` | The limit above ran out before storage answered. Whether the object was written is unknown, so the same-grant retry applies. |
89
113
  | no response | `UploadTransportError` | `unreachable` | Storage unreachable. In a browser a refused cross-origin request looks exactly like this, so the message also points at the page's CSP `connect-src`. If the connection failed after the file went out, the object may have been written, so the message gives the same-grant retry too. |
90
- | nothing sent | `UploadTransportError` | `invalid_grant_url` | The grant's `url` is not an absolute `http(s)` URL free of user info, so it is refused before any request. A browser would resolve a relative URL against the page and send the credential there. |
114
+ | nothing sent | `UploadTransportError` | `invalid_grant_url` | The grant is not stored and its `url` is not an absolute `http(s)` URL free of user info, so it is refused before any request. A browser would resolve a relative URL against the page and send the credential there. |
91
115
 
92
116
  `RejectedAssetError.code` and `UploadTransportError.code` are what a caller branches on — to request a new grant on `grant_used` or `grant_expired`, to retry with the same grant on `timeout`, or on `server_error` or `unexpected` when the error is `retryable` (a `501` is not), or to tell a timeout from an unreachable host — rather than the message, the status or the error's `name`, since an expired grant and an unsigned header both answer `403 AccessDenied` and only S3's message tells them apart. The SDK sets `code` on every error it raises. `RejectedAssetError.status` is storage's, and its `filename` is the `File`'s name, or the object name the grant's `uri` ends in for a nameless `Blob`. `UploadTransportError.status` is storage's status when storage answered, and undefined when it did not. Neither error wraps storage's response, and only S3's error code and message reach a message: its error body can echo the signed request, credential included, so it is never kept, and only its first 16 KiB are read, which is where S3 writes its code and message. **No error carries the grant's URL.** The runtime's network error can: Node and Chrome name the whole URL when they cannot build a request from it, and Bun puts it on the error's `path`. So an unreachable storage is described by its origin and by the error names and codes along the cause chain (`TypeError, caused by Error ENOTFOUND`), and the runtime's error is neither relayed nor kept as `cause`. A caller's abort propagates as the signal's own reason, unwrapped, including one that lands while storage's error body is still arriving.
93
117
 
@@ -1,6 +1,6 @@
1
1
  # A method's saved examples
2
2
 
3
- An **example** is a named set of inputs saved on a stored method, so the method can be run on them again in one gesture, together with the latest run of exactly those inputs. A method has any number of them. Like the method itself, an example belongs to the organization: any member reads, edits, runs and deletes any example of any of its methods, and `created_by_user_id` only records who saved it. The client reaches them on the hosted API's `/v1/methods/{method_id}/examples` routes, and the types are in `src/product-models.ts`.
3
+ An **example** is a named set of inputs saved on a stored method, so the method can be run on them again in one gesture, together with the latest run of exactly those inputs. A method has any number of them. Like the method itself, an example belongs to the organization: every role of it reads and runs any example of any of its methods, every role that may change its methods (`admin` and `member`) saves, edits and deletes any of them and records their latest runs, and `created_by_user_id` only records who saved it. A runner is refused every example write with a `403` `role_not_permitted`. The client reaches them on the hosted API's `/v1/methods/{method_id}/examples` routes, and the types are in `src/product-models.ts`.
4
4
 
5
5
  ## The example
6
6
 
@@ -13,6 +13,7 @@ An **example** is a named set of inputs saved on a stored method, so the method
13
13
  | `input_revision` | Starts at 1 and rises by one each time the inputs change. |
14
14
  | `latest_run_id` | The latest run of the current inputs, or `null`. It may name a run deleted since, whose read then answers `404`. |
15
15
  | `created_by_user_id`, `created_at`, `updated_at` | Who saved it, when, and when it last changed. |
16
+ | `kept_by_assistant` | Whether the build assistant keeps this example's inputs current with its test inputs: `true` on an example the assistant's save created, `false` on every other, and `false` from the first inputs change a person makes. A rename leaves it as it is ([below](#the-build-assistants-example)). |
16
17
 
17
18
  An example holds no output and no baseline: its result is its latest run's, read by the run's id (`getRunResult`).
18
19
 
@@ -26,17 +27,18 @@ An example holds no output and no baseline: its result is its latest run's, read
26
27
  | `getMethodExample(methodId, exampleId)` | `GET /v1/methods/{id}/examples/{example_id}` | The `MethodExample`. |
27
28
  | `updateMethodExample(methodId, exampleId, { name?, inputs? })` | `PATCH /v1/methods/{id}/examples/{example_id}` | The `MethodExample` as it now stands. |
28
29
  | `recordMethodExampleLatestRun(methodId, exampleId, { run_id, input_revision })` | `PUT /v1/methods/{id}/examples/{example_id}/latest-run` | A `MethodExampleLatestRunResult`: `{ outcome: "recorded" \| "stale", example }`. |
30
+ | `saveAssistantMethodExample(methodId, { name, inputs, example_id?, input_revision? })` | `POST /v1/methods/{id}/examples/assistant-save` | A `MethodExampleAssistantSaveResult`: `{ outcome: "kept" \| "created", example }`. |
29
31
  | `deleteMethodExample(methodId, exampleId)` | `DELETE /v1/methods/{id}/examples/{example_id}` | Nothing; the platform answers `204`. |
30
32
 
31
33
  Every method takes a bare catalog id and refuses a suffixed one (`mt_x@3`) with `RequestArgumentError` before sending, as the other method routes do, and refuses a method id or an example id that is empty, `.` or `..`, or that holds a lone surrogate, the same way, as every route that puts an id in its path does ([What is refused](#what-is-refused-and-where)). The bodies are closed on the platform, so the client sends the fields the route takes and nothing else an untyped caller adds.
32
34
 
33
- **Every example the client hands back has been checked.** Each route answering one, the page rows and the latest-run verdict's `example` included, checks it once, against every field the Python SDK's model requires, with the strict type that model holds it to: string `example_id`, `method_id`, `name`, `created_by_user_id`, `created_at` and `updated_at`, object `inputs`, `input_revision` an integer within `Number.MAX_SAFE_INTEGER` either way, and `latest_run_id` present as a string or `null`. An example breaking one is an answer the SDK cannot read, thrown as an `ApiResponseError` (`runtime`, not retryable) whose message names the field, so a missing `latest_run_id` never reads as an example with no latest run, and a revision of `"2"` or `true` is never read as one. Both SDKs refuse the same answers but one: a revision spelled `2.0`, which JavaScript reads as the number `2` and cannot tell from it, while the Python SDK refuses the float.
35
+ **Every example the client hands back has been checked.** Each route answering one, the page rows and the verdicts' `example` included, checks it once, against every field the Python SDK's model requires, with the strict type that model holds it to: string `example_id`, `method_id`, `name`, `created_by_user_id`, `created_at` and `updated_at`, object `inputs`, `input_revision` an integer within `Number.MAX_SAFE_INTEGER` either way, `latest_run_id` present as a string or `null`, and `kept_by_assistant` a boolean. An example breaking one is an answer the SDK cannot read, thrown as an `ApiResponseError` (`runtime`, not retryable) whose message names the field, so a missing `latest_run_id` never reads as an example with no latest run, and a revision of `"2"` or `true` is never read as one. Both SDKs refuse the same answers but one: a revision spelled `2.0`, which JavaScript reads as the number `2` and cannot tell from it, while the Python SDK refuses the float.
34
36
 
35
37
  **The list is paged by an opaque cursor.** Pass `nextCursor` back as `cursor`; `null` is the last page. The platform reads examples whole and stops a read at 1 MB, so a page may be short while `nextCursor` is set: a caller that wants every example follows the cursor, which `iterateMethodExamples` does through the loop `iterateMethods` and `iterateRuns` share, stopping on a `null` cursor or one the server failed to advance and throwing `PagingNotTerminatingError` past the same page ceiling.
36
38
 
37
39
  ## Revisions and the latest run
38
40
 
39
- **New inputs raise the revision and clear the latest run.** An edit whose `inputs` differ from the stored ones sets `input_revision` to the next number and `latest_run_id` to `null`, since the run no longer shows what the inputs produce. The platform compares the inputs as canonical JSON with sorted keys, so the same inputs sent again, in another key order included, change nothing, while `3` becoming `3.0` is a change. A rename alone keeps both. The edit takes no revision or token of its own: concurrent edits are serialized on the platform, which answers a retryable `503` with nothing written when they kept landing under one.
41
+ **New inputs raise the revision and clear the latest run.** An edit whose `inputs` differ from the stored ones sets `input_revision` to the next number and `latest_run_id` to `null`, since the run no longer shows what the inputs produce, and `kept_by_assistant` to `false`, since those inputs are a person's. The platform compares the inputs as canonical JSON with sorted keys, so the same inputs sent again, in another key order included, change nothing, while `3` becoming `3.0` is a change. A rename alone keeps both. The edit takes no revision or token of its own: concurrent edits are serialized on the platform, which answers a retryable `503` with nothing written when they kept landing under one.
40
42
 
41
43
  **A run is recorded against the revision the client names.** To run an example and keep its result, read the example, start a run with its `inputs`, and record that run with the `input_revision` you read:
42
44
 
@@ -54,6 +56,34 @@ if (verdict.outcome === "stale") {
54
56
 
55
57
  A run started this way sends the inputs the example holds, digit for digit, an integer past `Number.MAX_SAFE_INTEGER` included ([below](#numbers-in-an-examples-inputs)). The write is conditioned on the example still holding that revision. **A stale run is a verdict, not an error**: a run outlasting an edit is the expected course of events whenever someone edits an example while one of its runs is in flight, so the platform answers a `200` discriminated on `outcome`, as it does for a publish, and both arms carry the example as it stands. Branch on `outcome`; an answer whose `outcome` is neither, or whose `example` is not an example the client can read, is an answer the SDK cannot read, thrown as an `ApiResponseError` (`runtime`, not retryable). The platform trusts the revision and does not compare the run's own inputs with the example's, so record a run only against the revision whose inputs it was started with. A run recorded when the example is created goes in `createMethodExample`'s `run_id` instead.
56
58
 
59
+ ## The build assistant's example
60
+
61
+ The build assistant writes test inputs for the method it is building and keeps one example of the method current with them, so that a person finds its latest inputs saved and can run them again. **A person's edit is never written over.** `saveAssistantMethodExample` is that save, and `kept_by_assistant` says whose an example is: `true` on an example the assistant's save created, `false` from the first inputs change made through `updateMethodExample`, after which the example is the person's. A rename leaves the flag as it is, and a create through `createMethodExample` is a person's and never flagged.
62
+
63
+ The assistant names the example it read and the revision it read it at. Find its example as the method's newest with `kept_by_assistant` true, the last such in `listMethodExamples`' oldest-first order, and send its `example_id` with its `input_revision`:
64
+
65
+ ```ts
66
+ let assistants: MethodExample | undefined;
67
+ for await (const example of client.iterateMethodExamples(methodId)) {
68
+ if (example.kept_by_assistant) assistants = example;
69
+ }
70
+ const saved = await client.saveAssistantMethodExample(methodId, {
71
+ name: "Assistant test inputs",
72
+ inputs: testInputs,
73
+ ...(assistants && {
74
+ example_id: assistants.example_id,
75
+ input_revision: assistants.input_revision,
76
+ }),
77
+ });
78
+ if (saved.outcome === "created") {
79
+ // A person took the previous example, or there was none: saved.example is the assistant's now.
80
+ }
81
+ ```
82
+
83
+ The platform writes the inputs into the named example only while it still exists, still carries the flag and still holds that revision, in one conditional write it never retries, and answers `kept` with the example, its revision raised and its latest run cleared as any inputs change does, its name untouched since a person may have chosen it. Otherwise, when a person's edit, another save or a deletion landed first, and when no example is named, which is how the first save on a method creates the assistant's example, it saves the inputs as a new example flagged as the assistant's under `name`, at revision 1, and answers `created`. **Both are a `200` verdict**, since a person taking the example while the assistant works is the expected course of events; branch on `outcome`. A retry of a save that landed answers `kept` and writes nothing again. The SDK has no helper finding the assistant's example, since the caller already lists the examples it shows.
84
+
85
+ The client sends `name`, `inputs` and, when given, `example_id` with `input_revision`, and nothing else, a `null` reading as not given. `name` is the name a new example takes, so it is required even when the save names an example, whose own name a `kept` save leaves alone.
86
+
57
87
  ## Numbers in an example's inputs
58
88
 
59
89
  The platform stores an example's inputs as JSON text and answers them exactly as they were saved, but JavaScript reads every JSON number as a double, which holds an integer exactly only up to `Number.MAX_SAFE_INTEGER` (2^53 - 1). Inputs saved by another client, the Python SDK or a raw request, can hold a larger one, such as `12345678901234567891`, which a double reads as `12345678901234567000`. Sent back, to `updateMethodExample` or as a run's inputs, the rounded number would overwrite the saved inputs, clearing the latest run, or run the method on inputs nobody saved and record that run under the revision that holds the real ones, since the platform trusts the revision it is given.
@@ -72,6 +102,7 @@ The client refuses before sending, with `RequestArgumentError` (`input`, not ret
72
102
  - a `createMethodExample` without its `name` or its `inputs` (a `null` one counting as not given), or whose `run_id` is given but is not a non-empty string, a `null` reading as not given;
73
103
  - an `updateMethodExample` naming neither `name` nor `inputs` (a `null` field counts as not given, as the platform reads it);
74
104
  - a `recordMethodExampleLatestRun` whose `run_id` is not a non-empty string or whose `input_revision` is not a positive integer, the example id checked first, then the run id, then the revision;
105
+ - a `saveAssistantMethodExample` without its `name` or its `inputs`, whose `example_id` is given but is not a non-empty string, whose `input_revision` is given but is not a positive integer, or that gives only one of the two;
75
106
  - an `iterateMethodExamples` whose method id any method above refuses, at the call itself rather than when the iterator is first pulled;
76
107
  - a body JSON cannot carry, such as inputs holding a cycle, or a `bigint` on a runtime without `JSON.rawJSON` ([exact-integers.md](./exact-integers.md)).
77
108
 
@@ -81,8 +112,10 @@ The platform refuses the rest, each as an `ApiResponseError` whose `code` names
81
112
  |---|---|---|
82
113
  | `not_found` | `404` | An unknown method or example, an example already deleted included. |
83
114
  | `run_not_found` | `404` | A `run_id`, on the create or the latest-run write, that is not one of this method's runs in the caller's organization; nothing is written. |
115
+ | `role_not_permitted` | `403` | Any example write by a role that may not change methods, such as a runner, which an admin of the organization must change. |
84
116
  | `method_being_deleted` | `409` | The method's erasure has started. Its examples are erased with it. |
85
117
  | `payload_too_large` | `413` | An example larger than one stored item holds. |
118
+ | `input_files_missing` | `422` | A save, the create, an inputs edit or the assistant's, whose inputs hold a file a run made whose object is no longer stored, since that run was deleted; its `missing_files` member names every such address, and nothing is saved. The platform copies a run's file an example takes into the organization's store, so the example outlives the run that made it. |
86
119
  | `validation_failed` | `422` | A name empty, blank or over 200 characters, inputs that are not a JSON object, a `limit` outside 1 to 100, or an example id longer than one can be (64 characters). |
87
120
  | `malformed_request` | `400` | A cursor the listing did not issue for this method. |
88
121
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pipelex/sdk",
3
- "version": "0.49.0",
3
+ "version": "0.50.0",
4
4
  "description": "TypeScript SDK for the Pipelex hosted API — execute methods, manage runs, and call the product surface.",
5
5
  "license": "MIT",
6
6
  "repository": {