@pipelex/sdk 0.22.0 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/artifacts.d.ts +92 -27
- package/dist/artifacts.js +308 -100
- package/dist/artifacts.js.map +1 -1
- package/dist/client.d.ts +9 -4
- package/dist/client.js +18 -6
- package/dist/client.js.map +1 -1
- package/dist/errors.d.ts +30 -5
- package/dist/errors.js +9 -5
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +4 -4
- package/dist/index.js.map +1 -1
- package/dist/runs.d.ts +10 -1
- package/dist/runs.js +17 -1
- package/dist/runs.js.map +1 -1
- package/dist/timers.d.ts +11 -0
- package/dist/timers.js +14 -0
- package/dist/timers.js.map +1 -0
- package/dist/upload-grant.d.ts +38 -15
- package/dist/upload-grant.js +200 -67
- package/dist/upload-grant.js.map +1 -1
- package/dist/upload.js +9 -2
- package/dist/upload.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/docs/architecture.md +7 -6
- package/docs/artifact-download.md +50 -15
- package/docs/input-preparation.md +14 -8
- package/docs/run-results.md +1 -1
- package/package.json +2 -2
package/docs/architecture.md
CHANGED
|
@@ -32,7 +32,7 @@ The package is ESM-only (`"type": "module"`, `dist/index.js` built by `tsc` with
|
|
|
32
32
|
|
|
33
33
|
Condition order in `exports` matters: `types` is listed first (TS/Node resolution convention — the type-checker looks at the first matching condition), then `import`, then `default` as the CJS fallback.
|
|
34
34
|
|
|
35
|
-
The map has a second entry, **`@pipelex/sdk/upload`** (`dist/upload-grant.js`), the browser-safe one. The main entry is Node-first — `upload.ts` names `node:fs/promises` and `artifacts.ts` names `undici`, so a browser-targeting bundler must mark them external — because the client holds an API key and belongs on a server. The subpath carries only what a page needs to store a file with an upload grant: `uploadWithGrant`, the grant types and the three error classes it raises. Its runtime import graph is `errors.ts` and `mthds/protocol`, nothing else, and the unit suite bundles it for the browser with esbuild, nothing marked external, so a Node-only module becoming reachable fails a test rather than a consumer's build. Both entries load the same `dist/errors.js` and `dist/upload-grant.js`, so the function and the error classes are identical whichever entry a consumer imports them through. Whether the main entry should become browser-safe as a whole is a separate question, because it touches how path strings are read.
|
|
35
|
+
The map has a second entry, **`@pipelex/sdk/upload`** (`dist/upload-grant.js`), the browser-safe one. The main entry is Node-first — `upload.ts` names `node:fs/promises` and `artifacts.ts` names `undici`, so a browser-targeting bundler must mark them external — because the client holds an API key and belongs on a server. The subpath carries only what a page needs to store a file with an upload grant: `uploadWithGrant`, the grant types and the three error classes it raises. Its runtime import graph is `errors.ts`, `timers.ts` and `mthds/protocol`, nothing else, and the unit suite bundles it for the browser with esbuild, nothing marked external, so a Node-only module becoming reachable fails a test rather than a consumer's build. Both entries load the same `dist/errors.js` and `dist/upload-grant.js`, so the function and the error classes are identical whichever entry a consumer imports them through. Whether the main entry should become browser-safe as a whole is a separate question, because it touches how path strings are read.
|
|
36
36
|
|
|
37
37
|
## Wire conventions
|
|
38
38
|
|
|
@@ -86,15 +86,16 @@ Flat `src/` (mirrors `mthds-js`'s `runners/api` flatness — the SDK has one cli
|
|
|
86
86
|
- `product-models.ts` — the snake_case wire shapes for the hosted-product management routes (profile, methods catalog, organizations, billing, API keys, gateway key, onboarding, storage, runs list/detail/update). Each interface models only the fields the product actually consumes — no speculative mirrors.
|
|
87
87
|
- `method-source.ts` — `methodSourceToContents`, the canonical parser turning a stored method's polymorphic `mthds` source (raw single-bundle text XOR a JSON `{name, content}[]` file array) into a flat list of file contents. A verbatim port of the platform's `_method_source_to_contents` so the SDK and runtime read a stored source identically; exported from the barrel and consumed by `getMethodClosure`. See [input-preparation.md](./input-preparation.md#getmethodclosure--the-explicit-expansion-utility).
|
|
88
88
|
- `codegen-check.ts` — `runCodegenCheck`, the **pure** offline drift check over a committed codegen tree: the stamp grammar, the `codegen.lock` parser, the hash, and the drift taxonomy, ported from pipelex's `codegen/check.py` so a verdict computed here equals the one `pipelex codegen check` computes over the same bytes (the drift `detail` sentences included, verbatim). It touches neither the client nor `mthds/protocol` — its only import is `smol-toml` — and it hashes through WebCrypto rather than `node:crypto` so it adds no Node builtin to the barrel's import graph (`upload.ts` still names `node:fs/promises`, so a browser-targeting bundler must mark `node:*` external). It raises `CodegenLockError`, which extends `Error` rather than the protocol base. No filesystem, no fetch, no API key — the caller walks its own tree and hands in the text. See [crate-routes.md](./crate-routes.md#the-offline-check--runcodegencheck).
|
|
89
|
-
- `upload-grant.ts` — `uploadWithGrant`, the sender of an upload grant (`requestUploadGrant`, `POST /v1/upload/grant`): the file as the raw body of a `PUT` to the grant's URL with its signed headers unchanged, redirects refused, and storage's refusals mapped onto the input-preparation errors (`RejectedAssetError` with a `code` for a `4xx` such as the `412` of a used grant or the `403` of a signature mismatch, `UploadTransportError` for a `5xx`, storage's `RequestTimeout`, a redirect or no response). It is the browser-safe `@pipelex/sdk/upload` entry, so it imports the error classes and the wire types only, never the client. See [input-preparation.md](./input-preparation.md#uploading-with-a-grant--requestuploadgrant--uploadwithgrant).
|
|
90
|
-
- `artifacts.ts` — the artifact stack, the download twin of `prepareInputs`, in layers: `
|
|
89
|
+
- `upload-grant.ts` — `uploadWithGrant`, the sender of an upload grant (`requestUploadGrant`, `POST /v1/upload/grant`): the file as the raw body of a `PUT` to the grant's URL with its signed headers unchanged, redirects refused, the whole exchange bounded by a time limit scaled by the file's size unless the caller passes `timeoutMs`, only the first 16 KiB of storage's error body read, and storage's refusals mapped onto the input-preparation errors (`RejectedAssetError` with a `code` for a `4xx` such as the `412` of a used grant or the `403` of a signature mismatch, `UploadTransportError` with a `code` for the time limit running out, a `5xx`, storage's `RequestTimeout`, a `409` conflict, a redirect or no response). It is the browser-safe `@pipelex/sdk/upload` entry, so it imports the error classes, the timer bound in `timers.ts` and the wire types only, never the client. See [input-preparation.md](./input-preparation.md#uploading-with-a-grant--requestuploadgrant--uploadwithgrant).
|
|
90
|
+
- `artifacts.ts` — the artifact stack, the download twin of `prepareInputs`, in layers: `locateArtifacts` (a pure, exported walk for `pipelex-storage://` references, each with every `$`-rooted path it sits at) and `collectArtifacts` (the same walk's bare references), `resolveArtifacts` (the bulk resolve route over a whole list, chunked at its bound), `fetchArtifact` (one reference as a bounded `Response`) and `downloadArtifacts` (Node-only, a run's produced files saved under a directory as a produced verdict), plus `artifactFilename`, the rule that names a saved file after the field it fills. The three network operations are client methods over a small structural seam (`ArtifactCapableClient`: the raw `resolveStorageUrls` call and `getRunResult`); the module's static import graph stays free of Node builtins, because `node:fs/promises`, `node:path` and `undici` are loaded by dynamic import behind `isNodeRuntime()` — a browser-targeting bundler still has to mark `node:*` and `undici` external, as it already must for `upload.ts`. `undici` is a runtime dependency for one reason: the fetch runs on an `Agent` carrying `headersTimeout` and `bodyTimeout`, the per-stall bounds an `AbortSignal` budget does not give, and Node exposes its bundled undici's `fetch` but not its `Agent`. See [artifact-download.md](./artifact-download.md).
|
|
91
91
|
- `runs.ts` — the run-lifecycle types (`RunStatus`, `RunRead`, `RunResults`, `RunResultState`) and the single poll loop (`pollUntilResult`). `RunResults.main_stuff` (the resolved main output content) is always present for a completed run: on the hosted path it is the `main_stuff.json` S3 artifact; on the bare-runner blocking path the SDK resolves it from the returned working memory via the response's `main_stuff_name`, so both paths deliver the same shape. Consumers read `main_stuff` directly. `working_memory` — every named stuff of the run, `{ root, aliases }`, typed `DictWorkingMemory` from `models.ts` — reads the same on both paths: the hosted path relays the `working_memory.json` artifact, and the blocking path lifts it off `pipe_output`, which still rides whole (blocking path only). `graph_spec` — the executed graph — also reaches the client on both paths: the hosted path relays the `graphspec.json` artifact, and the blocking path lifts it off `pipe_output` beside `graph_assembly_error`, the graph's twin of `usage_assembly_error`. That error field is declared ahead of the wire on the hosted side, where the platform's results body relays no such key and the field is therefore absent, reading `undefined` rather than `null`. A completed run that cannot deliver a main stuff throws `MissingMainStuffError`. `runs.ts` also owns `TokensUsageRecord` — one client-facing usage record per inference call, carried by `RunResults.tokens_usages` on both paths. That is a mirror of a wire contract this SDK does not own (inference accounting is a Pipelex runtime extension rather than part of the MTHDS standard): every field is optional and the interface carries an index signature so pre-contract artifacts (relayed verbatim, never migrated) still type-check. See [`run-usage.md`](./run-usage.md) for the field reference, the cost/null semantics, and the old-artifact rules, and [`run-results.md`](./run-results.md) for the field-by-field account of `RunResults` — the durable handle, the output and how to read it, the run's working memory, the executed graph and how to render or keep it, and the produced files with their short-lived links.
|
|
92
92
|
- `usage.ts` — `summarizeUsage`, the **pure** fold of a run's usage pair (`tokens_usages` / `usage_assembly_error`) into one `UsageSummary`: a `state` (`records`, `no_inference` or `unavailable`), the null-aware total cost with its `cost_partial` flag, the `input` and `output` token totals, the call count, the relayed assembly error, and a `by_pipe` rollup sorted by cost with the unattributed calls grouped under a `null` `pipe_code`. It applies the rules [`run-usage.md`](./run-usage.md#summarizing-a-run--summarizeusage) states, so that every consumer reads one computation rather than re-deriving those rules by hand. It imports types from `runs.ts` and nothing else: no client, no fetch, no `mthds/protocol`.
|
|
93
93
|
- `execute-result.ts` — `PipelexExecuteResult`, a `DictRunResultExecute` subtype that adds a resolved `.main_stuff` accessor (dug out of `pipe_output`'s working memory via the response's `main_stuff_name`, throwing `MissingMainStuffError` if unlocatable). `execute()` returns it, giving blocking and durable results the **same output accessor** — `result.main_stuff` — so callers never branch on which path ran. The neutral `mthds` wire model leaves `main_stuff_name` in its extension index signature; this Pipelex-branded subtype declares it as a typed field (Pipelex owns that concept). `resultsFromExecute` (the public lift from that result onto `RunResults`, in the same module) reads that accessor too, keeping the resolution single-sourced; the `executeBlocking` fallback is its only caller inside the SDK, and a consumer driving `execute()` directly calls it by name.
|
|
94
94
|
- `errors.ts` — the transport and run-lifecycle typed errors, the input-preparation family under `InputPreparationError`, and the artifact family under `ArtifactOperationError` (`ScopeUnavailableError`, `ArtifactFetchError`, `ArtifactAuthenticationError`, the last carrying the download verdict so far); all derive from the protocol-base `PipelineRequestError` (re-exported from `mthds/protocol`) except `ClientAuthenticationError`. It is not the whole error surface: `codegen-check.ts` exports `CodegenLockError`, which extends plain `Error` because nothing was requested over the wire.
|
|
95
95
|
- `user-agent.ts` — client identification per the workspace spec `docs/specs/client-identification.md`: the exported `AppInfo` option, its RFC 9110 token validation (a `TypeError` on a bad field, a `RangeError` past the 512-character ceiling), its rendering, the runtime detection (Node, Bun, Deno, or a browser, where no header is set) and `buildUserAgent`, which the client calls once at construction. The client's private `requestHeaders` is the one place API request headers are built, so every route carries the value; the artifact fetch of a presigned link never goes through it. See [client-identification.md](./client-identification.md).
|
|
96
|
+
- `timers.ts` — `MAX_TIMER_DELAY_MS`, the longest delay `setTimeout` honours, and `isTimerDelay`, which every caller-supplied `timeoutMs` is checked against before it reaches a timer: a longer delay overflows and fires at once. It imports nothing, so the browser-safe upload entry uses it too.
|
|
96
97
|
- `version.ts` — `SDK_VERSION`, in its own module so the client stamps it on the `User-Agent` without importing the barrel, which re-exports it.
|
|
97
|
-
- `index.ts` — the public barrel: `export * from "mthds/protocol"` (single import source for the standard surface) plus the client, models, run types, the standalone pure helpers (`runCodegenCheck`, `summarizeUsage`, `collectArtifacts`, `artifactFilename`), and errors.
|
|
98
|
+
- `index.ts` — the public barrel: `export * from "mthds/protocol"` (single import source for the standard surface) plus the client, models, run types, the standalone pure helpers (`runCodegenCheck`, `summarizeUsage`, `locateArtifacts`, `collectArtifacts`, `artifactFilename`), and errors.
|
|
98
99
|
|
|
99
100
|
## Client surface (current)
|
|
100
101
|
|
|
@@ -120,12 +121,12 @@ The hosted catalog/account routes the webapp drives. All ride the same `{base}/v
|
|
|
120
121
|
- **Pipelex API keys (`plx_sk_…`):** `listPipelexApiKeys`, `createPipelexApiKey` (409 `pipelex_api_key_limit_reached`), `revokePipelexApiKey`, `rotatePipelexApiKey`. The plaintext `api_key` is returned once, on create/rotate.
|
|
121
122
|
- **Gateway API key (LLM inference key):** `createGatewayApiKey` (the JSON body is always sent, even with `promo_code: null`), `getGatewayApiKey`.
|
|
122
123
|
- **Onboarding:** `submitOnboarding`.
|
|
123
|
-
- **Storage:** `resolveStorageUrl`, `resolveStorageUrls` (`POST /v1/resolve-storage-url/bulk` — the single route applied to a list of at most 100 references, one verdict per reference in request order, a refused reference a value on its item; a deployment without the route answers a `404` `ApiResponseError`), `upload` (JSON base64 — the multipart hop is browser→BFF only), `requestUploadGrant` (`POST /v1/upload/grant` — a presigned, create-only `PUT` for one new object, which the holder of the bytes sends with the standalone `uploadWithGrant`, also exported from the browser-safe `@pipelex/sdk/upload` entry; a declared size over the cap is a `413` `ApiResponseError`). The higher-level `uploadFile` / `prepareInputs` preparation surface is built on top of `upload`; `prepareInputs` additionally composes one `validate` call with `views: ["input_form"]` for the signature it walks, and takes the method as inline `files`, a `method_ref` or a `method_id` like every other method-taking operation — see [input-preparation.md](./input-preparation.md). The download twin — `collectArtifacts`, `resolveArtifacts`, `fetchArtifact`, `downloadArtifacts` — is built on top of `resolveStorageUrls` and `getRunResult`, never on the embedded `public_url`, and is documented on [artifact-download.md](./artifact-download.md); downloading is a separate explicit gesture, and no run call takes a `download` option.
|
|
124
|
+
- **Storage:** `resolveStorageUrl`, `resolveStorageUrls` (`POST /v1/resolve-storage-url/bulk` — the single route applied to a list of at most 100 references, one verdict per reference in request order, a refused reference a value on its item; a deployment without the route answers a `404` `ApiResponseError`), `upload` (JSON base64 — the multipart hop is browser→BFF only), `requestUploadGrant` (`POST /v1/upload/grant` — a presigned, create-only `PUT` for one new object, which the holder of the bytes sends with the standalone `uploadWithGrant`, also exported from the browser-safe `@pipelex/sdk/upload` entry; a declared size over the cap is a `413` `ApiResponseError`). The higher-level `uploadFile` / `prepareInputs` preparation surface is built on top of `upload`; `prepareInputs` additionally composes one `validate` call with `views: ["input_form"]` for the signature it walks, and takes the method as inline `files`, a `method_ref` or a `method_id` like every other method-taking operation — see [input-preparation.md](./input-preparation.md). The download twin — `locateArtifacts`, `collectArtifacts`, `resolveArtifacts`, `fetchArtifact`, `downloadArtifacts` — is built on top of `resolveStorageUrls` and `getRunResult`, never on the embedded `public_url`, and is documented on [artifact-download.md](./artifact-download.md); downloading is a separate explicit gesture, and no run call takes a `download` option.
|
|
124
125
|
- **Runs list/detail/update:** `listRuns` (by `method_id`) returns **one page**, not the whole history — a `RunPage` (`{items, nextCursor}`) served from a time-ordered index, so its cost is the page size rather than the size of the history. `iterateRuns` is the async iterator that follows the cursor for callers who genuinely want everything (`for await (const run of client.iterateRuns(id))`); it is deliberately not a `listAllRuns(): Promise<PipelineRun[]>`, because an all-at-once helper needs a page cap and a cap means silently returning a truncated list. Both accept `createdFrom` / `createdTo`, applied server-side as index key conditions — these are **instants, not days** (ISO-8601 with a UTC offset; a naive timestamp is a 400), because only the caller knows which timezone's day it means. `getRunDetail` (`GET /v1/runs/{id}`) is the only call returning `mthds_contents` — what the run actually executed — and `inputs`; it is kept off the status read that pollers hit every few seconds and off the list, where the bundle would be multiplied by the page size. `updateRun` is the admin/manual status patch. The status/results/start lifecycle routes live in the run-lifecycle section above.
|
|
125
126
|
|
|
126
127
|
## Hook bundle (`dist-hooks/check.mjs`)
|
|
127
128
|
|
|
128
|
-
`src/hooks/` holds the post-edit `.mthds` hook pipeline (local lint/format via `@pipelex/tools-wasm`, then a `POST /v1/validate` verdict through this SDK), bundled into a single dependency-free ESM file by `npm run build:hook`. Its validate client is built by `src/hooks/validate-client.ts` with `appInfo: { name: "pipelex-mthds-check", version: SDK_VERSION }`, the `hook` surface's registry token, so the platform attributes its calls to the hook rather than to a plain SDK caller. The one specifier left external is `undici`, reached only by the artifact fetch path through a dynamic import the hook never executes, so the bundle does not inline an HTTP client it cannot use. **Distribution is source-checkout vendoring, not npm:** `pipelex-plugins` runs `make vendor-hook`, which builds `dist-hooks/check.mjs` in a sibling checkout of this repo and copies it in as a static hook asset — the published `@pipelex/sdk` tarball deliberately does not ship the bundle (it inlines the ~4 MB WASM engine, and no npm consumer of the SDK's library API uses it). `dist-hooks/` is gitignored; the consumer repo's own checks gate a stale vendored copy.
|
|
129
|
+
`src/hooks/` holds the post-edit `.mthds` hook pipeline (local lint/format via `@pipelex/tools-wasm`, then a `POST /v1/validate` verdict through this SDK), bundled into a single dependency-free ESM file by `npm run build:hook`. Its validate client is built by `src/hooks/validate-client.ts` with `appInfo: { name: "pipelex-mthds-check", version: SDK_VERSION }`, the `hook` surface's registry token, so the platform attributes its calls to the hook rather than to a plain SDK caller. The one specifier left external is `undici`, reached only by the artifact fetch path through a dynamic import the hook never executes, so the bundle does not inline an HTTP client it cannot use. **Distribution is source-checkout vendoring, not npm:** `pipelex-plugins` runs `make vendor-hook`, which builds `dist-hooks/check.mjs` in a sibling checkout of this repo and copies it in as a static hook asset — the published `@pipelex/sdk` tarball deliberately does not ship the bundle (it inlines the ~4 MB WASM engine, and no npm consumer of the SDK's library API uses it). `dist-hooks/` is gitignored; the consumer repo's own checks gate a stale vendored copy. `@pipelex/tools-wasm` is pinned to an exact version in `package.json`, because the engine carries the MTHDS schema the hook lints against: a newer schema reaches the hook only by a deliberate bump followed by a rebuild and a re-vendor, and the bundle's provenance line (`@pipelex/sdk <version> (<commit>) + @pipelex/tools-wasm <version> (npm)`) names the engine it embeds.
|
|
129
130
|
|
|
130
131
|
## Testing
|
|
131
132
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
# Artifact download (`collectArtifacts` / `resolveArtifacts` / `fetchArtifact` / `downloadArtifacts`)
|
|
1
|
+
# Artifact download (`locateArtifacts` / `collectArtifacts` / `resolveArtifacts` / `fetchArtifact` / `downloadArtifacts`)
|
|
2
2
|
|
|
3
|
-
> **Status: implemented** (`src/artifacts.ts`). This is the download twin of [input preparation](./input-preparation.md): where `prepareInputs` turns local files into `pipelex-storage://` references before a run, these
|
|
3
|
+
> **Status: implemented** (`src/artifacts.ts`). This is the download twin of [input preparation](./input-preparation.md): where `prepareInputs` turns local files into `pipelex-storage://` references before a run, these operations turn the references a run produced back into bytes on disk, or into a bounded stream, afterwards. They are layered so that each is usable without the next.
|
|
4
4
|
>
|
|
5
|
-
> **They need a platform that serves the bulk resolve route.** Everything below
|
|
5
|
+
> **They need a platform that serves the bulk resolve route.** Everything below the pure walk mints its links through `POST /v1/resolve-storage-url/bulk`, a hosted-platform route. The public bare runner (`pipelex-api`) has no resolve route at all, single or bulk, and a hosted deployment that predates the route answers a `404`; in both cases the operation throws the existing `ApiResponseError` and nothing is downloaded. `resolveStorageUrl`, the single-reference primitive, stays for the callers that have one link to mint.
|
|
6
6
|
|
|
7
7
|
## Why this exists
|
|
8
8
|
|
|
@@ -12,20 +12,28 @@ Downloading is **explicit and separate from running**, the input-preparation rul
|
|
|
12
12
|
|
|
13
13
|
Nothing here reads the embedded `public_url`. Every link is minted fresh by the platform, which is what makes a download work long after the embedded link died, and what keeps the tenant boundary where the platform enforces it.
|
|
14
14
|
|
|
15
|
-
## The
|
|
15
|
+
## The operations
|
|
16
16
|
|
|
17
|
-
### `collectArtifacts(value)` — the pure walk
|
|
17
|
+
### `locateArtifacts(value)` and `collectArtifacts(value)` — the pure walk
|
|
18
18
|
|
|
19
19
|
```ts
|
|
20
|
-
import { collectArtifacts } from "@pipelex/sdk";
|
|
20
|
+
import { collectArtifacts, locateArtifacts } from "@pipelex/sdk";
|
|
21
|
+
|
|
22
|
+
const locations = locateArtifacts(results.main_stuff);
|
|
23
|
+
// [{ uri: "pipelex-storage://org/runs/01J…/outputs/2325fcfe.png",
|
|
24
|
+
// found_at: ["$.rooms[0].staged_photo.url"] }, …]
|
|
21
25
|
|
|
22
26
|
const uris = collectArtifacts(results.main_stuff);
|
|
23
|
-
// ["pipelex-storage://org/runs/01J…/outputs/
|
|
27
|
+
// ["pipelex-storage://org/runs/01J…/outputs/2325fcfe.png", …] — the same walk, references only
|
|
24
28
|
```
|
|
25
29
|
|
|
26
|
-
Walks any JSON-shaped value and returns every string that **is** a `pipelex-storage://` reference — the whole string, scheme first, with something after the scheme. A string that merely contains a reference does not count; the bare scheme does not count; nothing else is looked at. The result is deduplicated and kept in discovery order. It is a contract rather than a heuristic, because the scheme is unambiguous: the runtime serializes a produced file as content carrying its reference in `url`, and nothing else on the wire starts that way.
|
|
30
|
+
Walks any JSON-shaped value and returns every string that **is** a `pipelex-storage://` reference — the whole string, scheme first, with something after the scheme. A string that merely contains a reference does not count; the bare scheme does not count; nothing else is looked at. The result is deduplicated and kept in discovery order, the order of each reference's first sighting. It is a contract rather than a heuristic, because the scheme is unambiguous: the runtime serializes a produced file as content carrying its reference in `url`, and nothing else on the wire starts that way.
|
|
31
|
+
|
|
32
|
+
`locateArtifacts` also says where each reference sits. Its `found_at` lists every path at which the reference occurs, in walk order, so a reference the output repeats is one entry with several paths, and `found_at[0]` is where it was first seen — the path a saved file is named after. `collectArtifacts` is the same walk's references alone.
|
|
27
33
|
|
|
28
|
-
|
|
34
|
+
**Path notation.** A path is rooted at `$`, the walked value itself. An object key matching `^[A-Za-z_][A-Za-z0-9_]*$` is written `.key`, any other key `["…"]` in JSON string escaping, and an array index `[n]`. So a nested field reads `$.rooms[3].staged_photo.url`, a list member `$.items[0].url` (a list output arrives as the `{ items: [...] }` envelope), a key that is not an identifier `$["a key"].url`, and an output that is itself a reference `$`. A path is the exact location of the string, the final `url` of a content object included, so a consumer can follow it into the JSON without guessing.
|
|
35
|
+
|
|
36
|
+
Both are pure — no network, no key — and exported standalone, so a consumer can count, list or place a result's files without resolving any of them.
|
|
29
37
|
|
|
30
38
|
### `resolveArtifacts(uris)` — fresh links for a whole list
|
|
31
39
|
|
|
@@ -66,7 +74,7 @@ export async function GET(request: Request, { params }: { params: { uri: string
|
|
|
66
74
|
|
|
67
75
|
Resolves the reference fresh and returns the object store's response as a bounded `Response`. The bounds:
|
|
68
76
|
|
|
69
|
-
- **A timeout** covering the connection, the headers and the whole body (`timeoutMs`, default 120 s). On Node the fetch also runs on an `undici` dispatcher carrying `headersTimeout` and `bodyTimeout` at the same value, the per-stall bounds an `AbortSignal` alone does not give.
|
|
77
|
+
- **A timeout** covering the connection, the headers and the whole body (`timeoutMs`, default 120 s). On Node the fetch also runs on an `undici` dispatcher carrying `headersTimeout` and `bodyTimeout` at the same value, the per-stall bounds an `AbortSignal` alone does not give. It must be a positive number no larger than 2147483647, the longest delay a timer honours, or the call throws an `ArtifactOperationError` before anything is resolved.
|
|
70
78
|
- **Redirects refused** (`redirect: "manual"`): a presigned link has no reason to redirect, and one that does is refused rather than followed.
|
|
71
79
|
- **The byte cap enforced mid-stream** (`maxBytes`, default 1 GiB): a declared `Content-Length` over the cap is refused before a byte is read, and a body that crosses the cap while streaming errors the returned stream — never buffered.
|
|
72
80
|
- **No credentials forwarded**: the request carries no headers of ours. The link's authorization is in its query string, and nothing else may ride along to the store.
|
|
@@ -100,7 +108,34 @@ if (!verdict.all_saved) {
|
|
|
100
108
|
|
|
101
109
|
**How it downloads.** The whole set is resolved through the bulk route ahead of the workers, then a bounded number of workers (`concurrency`, default 4) each take the next reference and fetch it → open its file with `wx` → stream the body in. Resolution is just-in-time where it matters: a link that has expired by the time its worker reaches it — a large set downloaded a few at a time can outlive the fifteen-minute link — is resolved again for that reference alone, so no fetch ever runs on a stale signature.
|
|
102
110
|
|
|
103
|
-
**Filenames
|
|
111
|
+
**Filenames: each file is named after the field it fills.** The name comes from the first path in the reference's `found_at`, by the rule `artifactFilename(location, contentType, scope)` (exported) applies:
|
|
112
|
+
|
|
113
|
+
1. A final `url` key is dropped, since the runtime's image and document contents carry their reference there. A reference under any other key keeps that key, and a `url` key that is not final is kept.
|
|
114
|
+
2. Each key is reduced to `[A-Za-z0-9_]`, every other character becoming `_` — `-` and `.` included, since one is the separator and the other would fake an extension. An index stays its digits.
|
|
115
|
+
3. The segments are joined with `-`. A reference that is the walked value itself, or its `url`, has no segment left and takes the scope's name, so an output that is one image is saved as `main_stuff.png`.
|
|
116
|
+
4. A name over the length cap (128 characters, extension included) keeps its tail: whole leading segments are dropped first, since the last ones are the specific ones, and a single segment still too long is cut to fit.
|
|
117
|
+
5. A stem Windows reserves for a device — `con`, `prn`, `aux`, `nul`, `com0` to `com9` or `lpt0` to `lpt9`, in any case — gets a trailing `_`, so a field named `aux` is saved as `aux_.png`. Windows reserves those names whatever the extension, and a field name is the method author's to choose.
|
|
118
|
+
6. The extension is the one the storage key's last segment carries, reduced to `[A-Za-z0-9]`, when it has a short one; otherwise the content type's, for the types a run produces (`image/png` gives `.png`, `application/pdf` gives `.pdf`); otherwise there is none.
|
|
119
|
+
|
|
120
|
+
For example, a home-staging method whose output is
|
|
121
|
+
|
|
122
|
+
```json
|
|
123
|
+
{
|
|
124
|
+
"rooms": [
|
|
125
|
+
{
|
|
126
|
+
"original_photo": { "url": "pipelex-storage://org/assets/53174b03.png", "public_url": "…" },
|
|
127
|
+
"staged_photo": { "url": "pipelex-storage://org/runs/01J…/outputs/2325fcfe.png", "public_url": "…" }
|
|
128
|
+
},
|
|
129
|
+
{ "original_photo": { "url": "…" }, "staged_photo": { "url": "…" } }
|
|
130
|
+
]
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
is saved as `rooms-0-original_photo.png`, `rooms-0-staged_photo.png`, `rooms-1-original_photo.png` and `rooms-1-staged_photo.png`, where the storage keys alone (`53174b03.png`, `2325fcfe.png`) would not say which picture is which. Each verdict item's `found_at` carries the unreduced path, `$.rooms[0].staged_photo.url`.
|
|
135
|
+
|
|
136
|
+
The result is always a bare filename — ASCII letters, digits, `_` and the `-` joins, then an optional extension — never empty, never starting with a dot and never a device name, so it can name nothing but a regular file directly inside `dir`. `artifactFilename` takes a location from `locateArtifacts`, which is how a consumer predicts a name before downloading; it throws `ArtifactOperationError` for a location with no string `uri`, no `found_at` array, or one whose `found_at[0]` is not a path in the notation above, or for an unknown scope.
|
|
137
|
+
|
|
138
|
+
Files are **never overwritten**: a name already on disk gets a numeric suffix (`report-1.pdf`, `report-2.pdf`), through exclusive creation rather than an exists-check, so two workers cannot race for one name. Two references whose paths reduce to one name (`"staged photo"` and `"staged-photo"`) are told apart by the same suffix, and the verdict says which file is which. A reference found at several paths is saved once, under the name of the first. `dir` is created if missing.
|
|
104
139
|
|
|
105
140
|
**Cleanup.** A failed or aborted download unlinks its partial file; nothing truncated is ever left under a final name.
|
|
106
141
|
|
|
@@ -110,8 +145,8 @@ if (!verdict.all_saved) {
|
|
|
110
145
|
{
|
|
111
146
|
scope: "main_stuff" | "working_memory",
|
|
112
147
|
artifacts: [
|
|
113
|
-
{ uri, path, content_type, size, error: null }, // saved — path is absolute
|
|
114
|
-
{ uri, path: null, content_type, size: null, error: { code, detail } }, // not saved
|
|
148
|
+
{ uri, found_at, path, content_type, size, error: null }, // saved — path is absolute
|
|
149
|
+
{ uri, found_at, path: null, content_type, size: null, error: { code, detail } }, // not saved
|
|
115
150
|
],
|
|
116
151
|
saved_paths: [ /* the absolute paths of the saved ones, same order */ ],
|
|
117
152
|
all_saved: boolean, // every reference saved; vacuously true for an empty walk
|
|
@@ -119,7 +154,7 @@ if (!verdict.all_saved) {
|
|
|
119
154
|
}
|
|
120
155
|
```
|
|
121
156
|
|
|
122
|
-
`artifacts.length` is the count of references walked, errors included. An empty walk over a present scope — an output that references no stored file — is a verdict with empty lists and `all_saved: true`, not an error, and it touches neither the network nor the disk. `content_type` is the platform's guess from the reference, known before the fetch
|
|
157
|
+
`artifacts.length` is the count of references walked, errors included. An empty walk over a present scope — an output that references no stored file — is a verdict with empty lists and `all_saved: true`, not an error, and it touches neither the network nor the disk. `found_at` is the reference's paths in the walked scope, exactly as `locateArtifacts` reports them, and `content_type` is the platform's guess from the reference, known before the fetch; both are on both arms, so an item that was not saved still says which field it would have filled.
|
|
123
158
|
|
|
124
159
|
Per-item `error.code` is the fetch vocabulary above plus the download's own: `resolve_failed` (an expired link could not be re-resolved, for a reason that is not the credential), `total_limit_exceeded` (the item that would take the call past `maxTotalBytes`, and, once the files already saved leave no room, every item not yet started, whose `detail` says it was skipped; an item refused only because files still in flight hold the room stops nothing else, since one of them may yet fail and give it back), `write_failed` (the file could not be created, written or closed) and `aborted` (in flight or not yet started when the signal fired).
|
|
125
160
|
|
|
@@ -128,7 +163,7 @@ Per-item `error.code` is the fetch vocabulary above plus the download's own: `re
|
|
|
128
163
|
- `RunStillRunningError` (with the retry hint) or `RunFailedError` — a `run_id` naming a run that has not completed;
|
|
129
164
|
- `ScopeUnavailableError` — the requested scope's artifact is `null` or missing from the body (`scope` and `runId` on the error). Reading by `run_id`, a null `main_stuff` is already `MissingMainStuffError` from `getRunResult`;
|
|
130
165
|
- `ArtifactAuthenticationError` — the resolve route refused the credential (`401` / `403`), on the first resolve or on a re-resolve part-way through. It carries `verdict`, the result as it stood: the refusal stops the workers taking new items but lets the fetches already running finish, since they are on presigned links that do not carry the credential, so every file saved is real and listed and the rest are marked `aborted` with a detail naming the credential failure;
|
|
131
|
-
- `ArtifactOperationError` — outside Node, an unusable `dir`, both selectors or neither, or nonsense bounds;
|
|
166
|
+
- `ArtifactOperationError` — outside Node, an unusable `dir`, both selectors or neither, an unknown `scope`, or nonsense bounds;
|
|
132
167
|
- and the transport and lifecycle errors of the reads it makes, unchanged: `ApiResponseError` for a deployment without the bulk route, `RunLifecycleUnavailableError` for a bare runner asked by `run_id`, `ApiUnreachableError`.
|
|
133
168
|
|
|
134
169
|
Everything else that can go wrong with one reference is that reference's `error`.
|
|
@@ -54,7 +54,7 @@ The file is described, never sent. The grant (`UploadGrant`) is a presigned, cre
|
|
|
54
54
|
|
|
55
55
|
**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.
|
|
56
56
|
|
|
57
|
-
**Sending the file** is the standalone `uploadWithGrant(grant, file, { signal? })`, which takes a `Blob` or a `File` and returns `{ uri }`:
|
|
57
|
+
**Sending the file** is the standalone `uploadWithGrant(grant, file, { signal?, timeoutMs? })`, which takes a `Blob` or a `File` and returns `{ uri }`:
|
|
58
58
|
|
|
59
59
|
```ts
|
|
60
60
|
import { uploadWithGrant, RejectedAssetError } from "@pipelex/sdk/upload";
|
|
@@ -64,6 +64,8 @@ const { uri } = await uploadWithGrant(grant, file);
|
|
|
64
64
|
|
|
65
65
|
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.
|
|
66
66
|
|
|
67
|
+
**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.
|
|
68
|
+
|
|
67
69
|
**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 the entry for the browser with esbuild and fails if anything Node-only becomes reachable.
|
|
68
70
|
|
|
69
71
|
**Storage's refusals** map onto the input-preparation errors, so a caller that already handles `uploadFile` needs no new case:
|
|
@@ -75,14 +77,16 @@ It sends the file as the raw body of a `PUT` to `grant.url`, with `grant.headers
|
|
|
75
77
|
| `403 SignatureDoesNotMatch` | `RejectedAssetError` | `signature_mismatch` | The file's size, content type or metadata differ from what the grant signed. |
|
|
76
78
|
| `403 AccessDenied`, "Request has expired" | `RejectedAssetError` | `grant_expired` | The grant expired. |
|
|
77
79
|
| `403 AccessDenied`, headers "not signed" | `RejectedAssetError` | `unsigned_header` | The request carried a storage header the grant did not sign. |
|
|
78
|
-
| `400 RequestTimeout` | `UploadTransportError` | | Storage stopped waiting for the body and wrote nothing, so the same grant can be retried while it is valid. |
|
|
80
|
+
| `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. |
|
|
81
|
+
| `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. |
|
|
79
82
|
| any other `4xx` | `RejectedAssetError` | `store_refused` | Storage's own message is relayed. |
|
|
80
|
-
| `5xx` | `UploadTransportError` | | Whether the object was written is unknown: retrying with the same grant either stores it or answers the `412` of a used grant. |
|
|
81
|
-
| a redirect | `UploadTransportError` | | Refused, not followed. |
|
|
82
|
-
| no
|
|
83
|
-
|
|
|
83
|
+
| `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. |
|
|
84
|
+
| a redirect | `UploadTransportError` | `redirected` | Refused, not followed. |
|
|
85
|
+
| 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. |
|
|
86
|
+
| 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. |
|
|
87
|
+
| 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. |
|
|
84
88
|
|
|
85
|
-
`RejectedAssetError.code`
|
|
89
|
+
`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 `server_error`, 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.
|
|
86
90
|
|
|
87
91
|
### `prepareInputs` — signature-driven input preparation
|
|
88
92
|
|
|
@@ -225,7 +229,9 @@ The contract distinguishes these semantic outcomes, each a typed subclass of `In
|
|
|
225
229
|
- **rejected asset** (`RejectedAssetError`) — the server refused it (e.g. a `413` past the service-defined size cap — see "Storage policy" — surfaced as a clear rejection with `code` `too_large`, not a raw transport error), or storage refused an upload with a grant (a used or expired grant, or a file that differs from what the grant signed — see the table above for each `code`);
|
|
226
230
|
- **unsupported server capability** (`UnsupportedUploadCapabilityError`) — the configured deployment has no upload route;
|
|
227
231
|
- **authentication / authorization failure** (`UploadAuthenticationError`) — `401` / `403`;
|
|
228
|
-
- **transport failure** (`UploadTransportError`) — a network or server fault,
|
|
232
|
+
- **transport failure** (`UploadTransportError`) — a network or server fault, or any other unexpected upload failure; its `status` is the HTTP status when a response produced it, and its `code` says which failure it was. From `uploadFile` the code is `server_error` for a `5xx`, `timeout` when the client's own request timeout ran out, `unreachable` when no response came back, and `unexpected` for any other status or failure; `uploadWithGrant`'s codes are in the table above.
|
|
233
|
+
|
|
234
|
+
A malformed data URL — no comma, bad base64, bad percent-encoding — is a plain `InputPreparationError`, raised while the asset is read and before anything is uploaded.
|
|
229
235
|
|
|
230
236
|
All preparation failures are raised **before any run is created**.
|
|
231
237
|
|
package/docs/run-results.md
CHANGED
|
@@ -201,4 +201,4 @@ const resolved = await client.resolveStorageUrl({ uri: "pipelex-storage://..." }
|
|
|
201
201
|
|
|
202
202
|
The same rule holds in a browser: resolve on the server, hand the client a link it uses immediately, and never let a presigned URL outlive the request it was minted for.
|
|
203
203
|
|
|
204
|
-
Bringing the files down is the SDK's job, not something to re-implement over `resolveStorageUrl`: `
|
|
204
|
+
Bringing the files down is the SDK's job, not something to re-implement over `resolveStorageUrl`: `locateArtifacts` lists a result's references without touching the network, each with the paths of the fields it fills (`collectArtifacts` gives the references alone), `resolveArtifacts` mints fresh links for a whole list in one bulk call, `fetchArtifact` streams one file within safe bounds, and `downloadArtifacts` saves everything a run produced under a directory, each file named after the field it fills, by run id even days later. They all share one page, [`artifact-download.md`](./artifact-download.md). The upload direction — turning local files into `pipelex-storage://` references before a run — is the mirror of this and has its own page too, [`input-preparation.md`](./input-preparation.md).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pipelex/sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.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": {
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
54
|
"@eslint/js": "^9.39.4",
|
|
55
|
-
"@pipelex/tools-wasm": "
|
|
55
|
+
"@pipelex/tools-wasm": "0.3.0",
|
|
56
56
|
"@types/node": "^22.12.0",
|
|
57
57
|
"@vitest/coverage-v8": "^4.1.9",
|
|
58
58
|
"dependency-cruiser": "^17.4.3",
|