@pipelex/sdk 0.59.0 → 0.60.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 -2
- package/dist/cli/script.d.ts +1 -1
- package/dist/client.d.ts +42 -45
- package/dist/client.js +55 -59
- package/dist/client.js.map +1 -1
- package/dist/errors.d.ts +2 -2
- package/dist/errors.js +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js.map +1 -1
- package/dist/models.d.ts +7 -3
- package/dist/product-models.d.ts +0 -36
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/webhooks.d.ts +19 -1
- package/docs/architecture.md +4 -4
- package/docs/crate-routes.md +5 -5
- package/docs/errors.md +2 -0
- package/docs/input-preparation.md +1 -1
- package/docs/method-copy.md +5 -14
- package/docs/webhooks.md +15 -4
- package/package.json +1 -1
package/dist/product-models.d.ts
CHANGED
|
@@ -467,42 +467,6 @@ export interface MethodCopyLimit {
|
|
|
467
467
|
value: number;
|
|
468
468
|
reached: number;
|
|
469
469
|
}
|
|
470
|
-
/** Query for one page of a method's copies. */
|
|
471
|
-
export interface ListMethodCopiesQuery {
|
|
472
|
-
/**
|
|
473
|
-
* Page size, from 1 to 100; the API defaults to 50 and refuses a `limit` outside that range
|
|
474
|
-
* with a `422`.
|
|
475
|
-
*/
|
|
476
|
-
limit?: number;
|
|
477
|
-
/** Opaque `nextCursor` from the previous page. */
|
|
478
|
-
cursor?: string;
|
|
479
|
-
}
|
|
480
|
-
/**
|
|
481
|
-
* The original's record of one copy made of it — a row of `listMethodCopies`. It names the
|
|
482
|
-
* destination by its id alone, never its name, so the listing tells no reader the name of an
|
|
483
|
-
* organization they do not belong to: show a destination's name only from the reader's own
|
|
484
|
-
* memberships. The marker stays when its copy is erased, and the copy then reads as not found.
|
|
485
|
-
*/
|
|
486
|
-
export interface MethodCopyMarker {
|
|
487
|
-
/** The copy's method id, in the destination organization. */
|
|
488
|
-
copy_method_id: string;
|
|
489
|
-
/** The canonical id of the organization the copy was made in. */
|
|
490
|
-
destination_org_id: string;
|
|
491
|
-
/** The canonical id of the member who made the copy. */
|
|
492
|
-
copied_by_user_id: string;
|
|
493
|
-
/** ISO 8601 UTC instant the copy's row was written. */
|
|
494
|
-
copied_at: string;
|
|
495
|
-
}
|
|
496
|
-
/**
|
|
497
|
-
* One page of a method's copies, in the order of the copies' ids, which says nothing of when each
|
|
498
|
-
* was made. `nextCursor` is opaque — pass it straight back to `listMethodCopies` to continue;
|
|
499
|
-
* `null` means this was the last page. A method never copied, and a copy, which names its
|
|
500
|
-
* original through `copied_from` and has no copies of its own, answer an empty page.
|
|
501
|
-
*/
|
|
502
|
-
export interface MethodCopyPage {
|
|
503
|
-
items: MethodCopyMarker[];
|
|
504
|
-
nextCursor: string | null;
|
|
505
|
-
}
|
|
506
470
|
/**
|
|
507
471
|
* One saved example of a method — what `createMethodExample`, `getMethodExample`,
|
|
508
472
|
* `updateMethodExample` and both arms of `recordMethodExampleLatestRun` and
|
package/dist/version.d.ts
CHANGED
package/dist/version.js
CHANGED
package/dist/webhooks.d.ts
CHANGED
|
@@ -206,7 +206,11 @@ export interface WebhookDeliveryAttempt {
|
|
|
206
206
|
}
|
|
207
207
|
/** One delivery of a run's webhook, as anyone who can read the run sees it. */
|
|
208
208
|
export interface WebhookDeliverySummary {
|
|
209
|
-
/**
|
|
209
|
+
/**
|
|
210
|
+
* The receiver's `webhook-id`, the same on every attempt and replay: `whd_` followed by ASCII
|
|
211
|
+
* letters, digits and underscores, at most 64 characters in all. Treat it as opaque, a key to
|
|
212
|
+
* deduplicate on and nothing to parse.
|
|
213
|
+
*/
|
|
210
214
|
delivery_id: string;
|
|
211
215
|
/** The run whose event the delivery carries. */
|
|
212
216
|
pipeline_run_id: string;
|
|
@@ -255,6 +259,20 @@ export interface WebhookReplayAccepted {
|
|
|
255
259
|
*/
|
|
256
260
|
attempt: number;
|
|
257
261
|
}
|
|
262
|
+
/** What a delivery's replay sends beside its path: the request's own header. */
|
|
263
|
+
export interface WebhookReplayOptions {
|
|
264
|
+
/**
|
|
265
|
+
* The replay's idempotency key, sent as the `Idempotency-Key` header, and not sent at all when
|
|
266
|
+
* absent or `null`. Generate one per replay the caller means to ask for (a `crypto.randomUUID()`
|
|
267
|
+
* is a good key), and keep it across that replay's retries: the same replay sent again under the
|
|
268
|
+
* same key is answered with the stored answer, naming the same attempt, so a replay whose answer
|
|
269
|
+
* was lost can be sent again without the delivery being sent twice. A key reused for another
|
|
270
|
+
* request is refused `409` `request.idempotency_conflict`, and one whose earlier request is still
|
|
271
|
+
* being handled `409` `request.idempotency_key_in_use`, whose `retry` is `yes`. A key is 1 to 255
|
|
272
|
+
* visible ASCII characters, anything else a `RequestArgumentError` before any request.
|
|
273
|
+
*/
|
|
274
|
+
idempotencyKey?: string | null;
|
|
275
|
+
}
|
|
258
276
|
/**
|
|
259
277
|
* An organization's webhook signing secret — `GET /v1/organizations/{org_id}/webhook-secret` and
|
|
260
278
|
* its rotation. Only the current secret's value is served: the previous one, still signing until
|
package/docs/architecture.md
CHANGED
|
@@ -146,7 +146,7 @@ Flat `src/` (mirrors `mthds-js`'s `runners/api` flatness — the SDK has one cli
|
|
|
146
146
|
- **An idempotency key on a run start (hosted):** `start` and `startAndWaitForResult` take `idempotencyKey` (`PipelexStartRequestOptions`, intersected into `PipelexStartOptions`), sent as the `Idempotency-Key` header of `POST /v1/start` and never in the body; with no key there is no header. The hosted API binds the key to the run, answers a repeat of the same start with that run, and refuses the key with other options; a bare runner ignores the header. The client refuses a key that is not 1 to 255 visible ASCII characters with a `RequestArgumentError` before any request, the blocking `execute` fallback of `startAndWaitForResult` sends none, and the key leaves the error's problem as it is: a start that may have reached the server stays `client.outcome_unknown`, and the caller who sent a key recovers the run by sending the same start again under it. [errors.md](./errors.md#a-starts-idempotency-key--idempotencykey) holds the contract.
|
|
147
147
|
- **Method selectors on `validate`:** the first argument is a `PipelexValidateSource` — the method's tree in one of its four forms, `{ method_ref }` (runner-resolved by address) or `{ method_id }` (hosted, platform-resolved) — under the strict tooling XOR: exactly one, no linkage exception, refused with a `RequestArgumentError` before any request. Its options (`ValidateOptions`: `render`, `views`, `timeoutMs`, `signal`) ride the third argument. A selector-resolution failure is a non-2xx `ApiResponseError`, never an `is_valid: false` verdict.
|
|
148
148
|
- **The files a step reads ride the tree.** A `PipeDocGen` step's `template_file` is a file of the method's tree like any other, at the tree path the step names; the separate `resources` field is gone from every route. The valid verdict's `warnings` carry the template check's findings as problem items, located by the template file they name, and its optional `tree_files_read` and `tree_file_readers` name the files of the method's tree each step reads, whatever form the method was sent in ([which step reads which file](./method-trees.md#which-step-reads-which-file)). See [method-trees.md](./method-trees.md#the-files-a-step-reads).
|
|
149
|
-
- **Crate routes (`/v1/resolve`, `/v1/codegen`, `/v1/pipe-io`):** `resolve` emits the **normalized library crate** (the MTHDS standard's Library Crate Format, fingerprint riding inside the payload); `codegen` projects that crate through two explicit axes — `kind` (`types`) × `target` (`ts-zod` / `python-pydantic` / `python-structures`) — into stamped artifacts plus their `codegen.lock`. All three share one closure envelope (the method's tree, in one of the four forms a run takes, XOR a `method_ref`, the address form server-resolved and the registry form a `
|
|
149
|
+
- **Crate routes (`/v1/resolve`, `/v1/codegen`, `/v1/pipe-io`):** `resolve` emits the **normalized library crate** (the MTHDS standard's Library Crate Format, fingerprint riding inside the payload); `codegen` projects that crate through two explicit axes — `kind` (`types`) × `target` (`ts-zod` / `python-pydantic` / `python-structures`) — into stamped artifacts plus their `codegen.lock`. All three share one closure envelope (the method's tree, in one of the four forms a run takes, XOR a `method_ref`, the address form server-resolved and the registry form a `422` `request.unsupported`) and return a **discriminated 200 verdict** (`is_valid`, with `CrateInvalidReport` as the shared invalid arm) rather than a bare payload, the same discipline as `validate`. They also take the hosted `method_id` (`PipelexHostedToolingExtensions`) as a third closure form under the strict three-way XOR — a pass-through the platform resolves, meaningless against a bare runner. Writing the artifacts and lock verbatim reproduces a local `pipelex codegen types` run byte-for-byte, so the offline `pipelex codegen check` passes — the SDK stays transport-only and does not write files. Verifying such a tree afterwards is `runCodegenCheck`, a standalone pure helper rather than a client method, because it needs no server at all. `pipeIo` derives a method's three I/O artifacts — the standard's `PipeIOContracts`, `InputForm` and `OutputForm`, imported from `mthds/protocol` — for the pipe the route selects (the request's qualified `pipe_ref`, else the method's own entry pipe) or for every pipe under `all_pipes`, with the runnability facts beside them and the closure's files echoed on `include_files`, and it runs no dry run; a selection it refuses is a `422` problem coded `request.pipe_not_found`, `request.pipe_unspecified` or `request.pipe_ambiguous`. See [crate-routes.md](./crate-routes.md).
|
|
150
150
|
- **Model reference check (`/v1/models/check`):** `checkModelReference(reference, category?)`, a Pipelex API extension served by any `pipelex-api` runner from pipelex 0.78.0 and on the hosted API, whose platform relays it byte-identically. It is a **diagnostic endpoint** like `validate`: a reference that resolves nowhere is a `200` verdict with `resolution: "not_found"` and the names the caller may have meant (`suggestions`, `other_kinds`, `other_categories`), never a thrown error. Only a request that cannot produce a verdict throws `ApiResponseError`, a `422` `request.invalid` whose `location.field` names the parameter it refused: `reference` for a reference that is missing, repeated, blank, a sigil or a namespace alone, or past the runner's length limit, and `type` for a category that is repeated, empty or unknown. The SDK validates none of it, so the runner's rule is the only one. `ModelReferenceVerdict` is a union of one arm per reference kind, discriminated on the wire's own `kind` (`PresetReferenceVerdict`, `AliasReferenceVerdict`, `WaterfallReferenceVerdict`, `HandleReferenceVerdict`): a `matches` entry carries no kind of its own, but every entry of one verdict shares the verdict's, so narrowing on `kind` types `matches` as `PresetMatch[]`, `AliasMatch[]`, `WaterfallMatch[]` or `HandleMatch[]` without a field the wire does not carry. `ModelCheckCategory` is the protocol's model categories plus `doc_gen`, the family of `PipeDocGen`, which the protocol and so `models` leave out but a method's `model` field can name. `resolution` is typed open, `ModelReferenceResolution | (string & {})`, under the spec's reader rule that a client reading a value it does not know treats the reference as unresolved, and so is every category a verdict names, `ModelCheckCategory | (string & {})` as `mthds` types `ModelInfo.type`, since a runner of a later version may add one; the `category` a request names stays closed. A match's `resolves_to` is `null` when the reference resolves by name but a run through it would reach no model the runner can call, which a consumer shows as a warning rather than as an unknown name.
|
|
151
151
|
- **Tools (`/v1/lint`, `/v1/format`):** `lint`, `format` — single-file static diagnostics and canonical formatting, served by any pipelex-api runner (no inference, no bundle load). Both are **diagnostic endpoints** like `validate`: a malformed `.mthds` file is a produced verdict on a **200** carrying `diagnostics[]`, never a thrown error — `format` additionally echoes the content back unchanged with `changed: false`. Only a no-verdict condition is non-2xx: a malformed body, **malformed formatter `options`** (e.g. a non-numeric `column_width` ⇒ 422), auth, or a server fault, all mapped to the typed `ApiResponseError`. `lint` is the cheap static check (syntax / schema / semantic); it does not replace `validate`, which loads the bundle, resolves across files, and dry-runs the pipes.
|
|
152
152
|
- **Durable run lifecycle (hosted extension — NOT protocol):** `getRunStatus`, `getRunProgress` (`GET /v1/runs/{id}/progress`, the run's progress snapshot, see [run-results.md](./run-results.md#following-a-run-while-it-runs--onprogress-and-getrunprogress)), `getRunResult` (with an optional `artifacts` selection sent as `?artifacts=`, see [run-results.md](./run-results.md#reading-only-some-artifacts--artifacts)), `waitForResult` (whose `onProgress` reads the progress route on each poll that finds the run running and delivers each fresher snapshot by `isFresherRunProgress`, a failed progress read never failing the wait), `startAndWaitForResult` (handshakes `/v1/version`, takes the durable start+poll path on a hosted deployment, and self-heals to the blocking `execute` against a bare runner). The handshake's outcome is cached for the client's lifetime, under the rule the Python SDK follows too: an answer that is not a usable version assumes hosted and lets the start surface the real error, while no answer at all throws its `ApiUnreachableError` at once, caches nothing and sends no start. An answer is anything the server sent back: an error status, a body that is not JSON, not UTF-8 or no version, and a body that arrived but could not be decoded, such as a broken gzip stream, which fetch fails with the decompressor's code (`Z_DATA_ERROR` and the other zlib codes, brotli's `ERR__ERROR_…`, zstd's `ZSTD_error_…`). A run that ended without completing answers the results read with a `409` `run.failed` problem document, naming the terminal status as its `run_status` parameter and nesting the run's own problem as `run_error`; `getRunResult` turns it into the `failed` arm (`status`, `message` from `detail`, `problem`, `error`) and the waiting calls on the durable path into a `RunFailedError` carrying the same (the blocking fallback's failure is the runner's `ApiResponseError`). The status read serves the run's own problem as `RunRead.error`. See [errors.md](./errors.md#a-failed-run--runfailederror). The status read also carries `degraded` and the `degradation` that explains it, whose `reason` tells a status the platform could not refresh (`status_unavailable`) from a run whose workflow task keeps failing until the platform ends it (`workflow_task_failing`), checked field by field so it never fails the poll, and the run's `request_id`, the id to quote to support. See [run-results.md](./run-results.md#a-degraded-status--degraded-and-degradation). Reading what a completed run consumed is `summarizeUsage(results)`, a standalone pure helper rather than a client method, because it folds the usage pair the results already carry and needs no server. See [run-usage.md](./run-usage.md#summarizing-a-run--summarizeusage).
|
|
@@ -159,10 +159,10 @@ The hosted catalog/account routes the webapp drives. All ride the same `{base}/v
|
|
|
159
159
|
- **User profile:** `getMe` (`GET /v1/me`).
|
|
160
160
|
- **Methods catalog:** `listMethods`, `getMethod`, `createMethod`, `writeDraft`, `renameMethod`, `publishMethod`, `listMethodVersions`, `getMethodVersion`, `deleteMethod`. **A saved method has a draft and immutable published versions.** Its draft is a method tree with its `input_data`, which `writeDraft` (`PUT /v1/methods/{id}/draft`) replaces whole, without validating it; `updated_at` is the draft's token, which moves on every draft write and nothing else, and `draft_digest` the draft's tree digest (`sha256:` and 64 hex digits), `null` for an empty draft. A save takes the tree as `files`, sent as given, or as `directory`, `tree` or `contents`, sent inline or by hash after uploading the blobs the organization lacks, and a read lists the tree's files, each with a short-lived link to its bytes, which `writeMethodTree` writes to disk ([method-trees.md](./method-trees.md)). A draft write may carry `expected_updated_at`, the token the caller last saw, and is then a compare-and-swap: a draft that moved since is a `409` `pipelex.catalog.update_conflict` and nothing is written. `renameMethod` (`PATCH /v1/methods/{id}`) changes the name alone and moves no token. `publishMethod` (`POST /v1/methods/{id}/publish`) takes the required `expected_draft_updated_at` and answers a `MethodPublishResult` discriminated on `outcome` — `published` with the new version, `unchanged` with the existing latest version when the draft's digest equals it, `refused` with a `reason` (`invalid` or `not_runnable`), a `message` and the runner's `validation` verdict, its problem items read member by member as `validate` reads them — every arm carrying the `method`; the SDK reads the nested method as `getMethod` does, and an answer whose `outcome` is none of these, or a refusal whose `validation` is not a verdict object, is an answer it cannot read. A publish that produced no verdict is thrown rather than answered: only a draft that differs from the latest version reaches the runner, and a runner that cannot be reached, answers unusably or does not finish within the platform's deadline is a `502` or `503` with nothing written, so a retry is safe, and a retry of a publish that landed while its answer was lost answers `unchanged` with the version it wrote. A method is stored as one item that keeps room for the summary a publish adds, so a draft that saves can always be published: a creation or a draft write is a `413` `request.too_large` when it would grow the method past that room, below the store's item limit, and a rename is the same `413` for a name so long it would leave the method too large to publish. A version is numbered from 1, never reused, and holds the draft's tree as it stood, its `tree_digest` (`null` for a version stored before methods were trees), its `crate_fingerprint` (`null` when the runner could not resolve the bundle in memory), the runner's version, and when and by whom it was published; `listMethodVersions` pages the summaries newest first (`MethodVersionPage`, `{items, nextCursor}`, a `limit` from 1 to 100, 20 by default, and a `422` outside that range), and `getMethodVersion` reads one with its `files` listed as a method's are, or answers a `500` `runtime.unexpected` for a version stored before methods were trees and never rewritten as one. A method read carries `latest_version` and `latest_published`, both `null` until the first publish, so the publish state is derived: never published, draft unchanged (`draft_digest` not `null` and equal to `latest_published.tree_digest`), or draft ahead. The method routes take a bare id and address the method itself, never one of its versions; the platform does not parse a suffix there, so `mt_x@3` would answer a `404` that reads as a method that does not exist, and each route refuses a suffixed id with `RequestArgumentError` before anything is sent, naming `parseMethodSelector` and `getMethodVersion`. The whole-method `updateMethod` is gone, replaced by the draft write and the rename. **`deleteMethod` is asynchronous and its return type says so:** `DELETE /v1/methods/{id}` answers `202` with a `MethodDeletionAccepted` (`method_id`, `deletion_state`, `deletion_job_id`) the moment the platform has claimed the method and terminated its in-flight workflows — the rest of the cascade (runs, events, S3 objects) is enqueued. A resolved promise means "accepted", never "gone"; completion is the row disappearing from `listMethods`, not any field of that body. The claim is a conditional write, so a double-clicked delete is a `409` `pipelex.catalog.being_deleted` rather than a second cascade over the same runs, and an unknown or foreign-org id is a `404`. The erasure deletes the method's versions with the rest. (This doc previously stated the SDK had no `delete` because the platform had no such route — the route has always existed; the claim was simply false.) `listMethods` returns **one page**, not the whole catalog — a `MethodPage` (`{items, nextCursor}`) served from a narrow index projection, so its cost is the page size rather than the size of the catalog. It used to return every method with its full `.mthds` bundle attached, which hit DynamoDB's 1 MB page cap at a couple hundred methods and came back truncated with no error and no flag, so methods silently vanished from the UI. Rows are therefore `MethodSummary`, **not** `MethodDetail`: no `files`, no `updated_at`, because neither is in the projection and putting the source back is what restored the truncation. Reach for `getMethod(id)` when you need a method's files. `query.q` is applied server-side across the whole catalog, not over the page — filtering one page client-side would be searching 50 rows of 10,000 and calling it a search. `query.published` filters the same way, sent as `published=true` (the methods with a published version) or `published=false` (those never published), and combines with `q`. Each row carries the method's publish state, so a list tells its methods apart without reading each one: `latest_version` and `latest_published` (a `MethodVersionSummary`), both `null` when the method was never published, and `draft_digest`, `null` for an empty draft; all three are optional, absent from a platform that does not serve them. Never published is `latest_version === null`, the draft unchanged is a `draft_digest` that is not `null` and equals `latest_published.tree_digest`, and the draft ahead is two digests that differ. Both filters run after the index read, so **a filtered page can come back empty and still carry a `nextCursor`**: an empty page is not the end of the list, and only a `null` cursor is. `iterateMethods` is the async iterator that follows the cursor for callers who genuinely want everything (`for await (const m of client.iterateMethods())`); it keeps going **past empty pages**, since `{items: [], nextCursor: "…"}` means "nothing matched in the slice I just read, keep going". A server that keeps minting fresh cursors forever stops it at a page ceiling far past any real catalog with a `PagingNotTerminatingError` carrying `pageLimit`, never a silently truncated stream; `iterateRuns` shares the same loop. It gives up only when the server stops advancing its cursor, and **throws** rather than returning a partial answer; like `iterateRuns` it is deliberately not a `listAllMethods(): Promise<MethodSummary[]>`, because an all-at-once helper needs a page cap and a cap means silently returning a truncated list. `MethodSummary.deletion_state` rides the list rows: a method mid-erasure stays listed so the UI can render it as "Deleting…", while `getMethod` refuses it with a 409. Full read responses (`MethodDetail`, from `getMethod`) carry `org_id`, `created_by_user_id`, a server-derived `description` (recomputed from the draft's top-level `description` on every draft write), the draft token and digest, the latest version's summary and the draft's `files` on top of the write contract's `name` / `input_data`; the writes and the rename answer `MethodData`, the same method without `files`.
|
|
161
161
|
- **Method examples:** `createMethodExample`, `listMethodExamples`, `iterateMethodExamples`, `getMethodExample`, `updateMethodExample`, `recordMethodExampleLatestRun`, `saveAssistantMethodExample`, `deleteMethodExample`, on `/v1/methods/{id}/examples`. An example is a named set of inputs saved on a method, shared across the organization, with an `input_revision` that rises each time its inputs change and the `latest_run_id` of those inputs, which an inputs edit clears. `recordMethodExampleLatestRun` records a run against the revision whose inputs it used and answers a `MethodExampleLatestRunResult` discriminated on `outcome`, `recorded` or `stale`, each arm carrying the `example`: a run that outlasted an edit is a verdict, never thrown, and an answer off the union is one the SDK cannot read; `saveAssistantMethodExample` is the build assistant's save, which writes into the example it names only while that example is still the assistant's (`kept_by_assistant`) at the revision it read, and otherwise creates one, answering a `MethodExampleAssistantSaveResult` discriminated on `outcome`, `kept` or `created`; one helper, `readVerdict`, reads these verdicts and `publishMethod`'s, the example checked once, by the reader every example goes through. Every answer carrying an example is read with `parseExactJson` and every request body written with `stringifyExactJson`, so an integer past `Number.MAX_SAFE_INTEGER` in the inputs reads as a `bigint` and goes back digit for digit, to an edit or as a run's inputs; `3.0` still reads as `3`, and inputs holding one sent back unchanged are an edit for the platform, which compares inputs as canonical JSON. Every example the client hands back, the page rows and the verdict's `example` included, is checked by `readMethodExample` against the fields the Python SDK's model requires, with the strict types it holds them to, an example breaking one being an answer the SDK cannot read. The list is paged oldest first by an opaque cursor, and a page may be short while `nextCursor` is set; `iterateMethodExamples` follows it through the loop `iterateMethods` and `iterateRuns` share, and refuses a method id at the call rather than at the first pull. Every method takes a bare catalog id, and the client refuses an example id that is empty, `.` or `..` or holds a lone surrogate, a create without its name or inputs or whose `run_id` is given but empty, an edit naming neither `name` nor `inputs`, and a latest-run write without a run id or a positive integer revision before sending. See [method-examples.md](./method-examples.md).
|
|
162
|
-
- **Method copies:** `copyMethod`,
|
|
162
|
+
- **Method copies:** `copyMethod`, on `/v1/methods/{id}/copy`. `copyMethod` makes a new, unpublished method in an organization the caller may change methods in, the method's own for a duplicate, holding the original's draft tree, its form inputs and its examples, the stored files they name copied into the destination, and answers a `MethodCopyResult` discriminated on `outcome`: `copied` with the copy as `method` and what was copied, or `refused` with the `references` of a bundle naming storage the destination cannot read or, in a duplicate, a file under the original's run files, which erasing the original would remove. The two arms carry different members, so the verdict is read by the copy itself rather than by `readVerdict`, which reads verdicts whose arms share one: an `outcome` off the union, a `copied` answer whose `method` is not an object and a `refused` one whose `references` is not an array are each an answer the SDK cannot read. Its body is closed, `destination_org_id` with `name` and `expected_draft_updated_at` when given, and its last argument takes an `idempotencyKey`, sent as the `Idempotency-Key` header through the same `requestRaw` option and the same `idempotencyKeyOf` check a start uses. Every method read carries `copied_from`, the original a copy names, `null` on a method created afresh. `MethodErrorCode` names the copy's own codes, `pipelex.org.not_found` and `pipelex.catalog.copy_too_large`, whose `limit` member is typed `MethodCopyLimit`. It takes a bare catalog id. See [method-copy.md](./method-copy.md).
|
|
163
163
|
- **Organizations:** `listMemberships`, `createOrganization`, `renameOrganization` (`PATCH`). Each membership carries the caller's `role_in_org`, an `OrgRole` (`admin`, `member` or `runner`) read open: a role a later platform adds arrives as its raw string, so a `switch` over it says what an unknown role does. The role is for display; what it may do is the platform's to decide, and a call refused to the caller's role is an `ApiResponseError`. The org "switch" is a WorkOS-session op (`/api/active-org`), not a `/v1` route — out of SDK scope.
|
|
164
164
|
- **Organization branding:** `getBranding` (`GET /v1/branding`, any role, web session or API key) and `updateOrganizationBranding` (`PATCH /v1/organizations/{org_id}/branding`, an admin's write) answer the same `OrganizationBranding`: the organization's name, its light and dark colour sets (`BrandingColors`) and its square and wide logos (`LogoDescriptor`). The write's `OrganizationBrandingUpdate` sets a key given a value, clears a key given `null` and keeps a key left out; a refused logo or colour is named in `ApiResponseError.errors`, by slot or by dotted key, a logo the platform measured with a `LogoRefusalCode`. See [organization-branding.md](./organization-branding.md).
|
|
165
|
-
- **Caller webhooks:** `listRunWebhookDeliveries`, `getRunWebhookDelivery` and `replayRunWebhookDelivery`, on `/v1/runs/{id}/webhook-deliveries`, `/v1/runs/{id}/webhook-deliveries/{delivery_id}` and its `/replay`, read a run's delivery log and send a settled delivery again, any member by web session or API key; `getWebhookSecret`, on `GET /v1/organizations/{org_id}/webhook-secret`, and `rotateWebhookSecret`, on `POST /v1/organizations/{org_id}/webhook-secret/rotate`, read and rotate the organization's signing secret, an admin's routes from a web session only, the rotation taking an `idempotencyKey` the platform never replays. See [webhooks.md](./webhooks.md).
|
|
165
|
+
- **Caller webhooks:** `listRunWebhookDeliveries`, `getRunWebhookDelivery` and `replayRunWebhookDelivery`, on `/v1/runs/{id}/webhook-deliveries`, `/v1/runs/{id}/webhook-deliveries/{delivery_id}` and its `/replay`, read a run's delivery log and send a settled delivery again, any member by web session or API key, the replay taking an `idempotencyKey` under which the platform stores its answer; `getWebhookSecret`, on `GET /v1/organizations/{org_id}/webhook-secret`, and `rotateWebhookSecret`, on `POST /v1/organizations/{org_id}/webhook-secret/rotate`, read and rotate the organization's signing secret, an admin's routes from a web session only, the rotation taking an `idempotencyKey` the platform never replays. See [webhooks.md](./webhooks.md).
|
|
166
166
|
- **Billing:** `getSubscription`, `listPlans`, `listInvoices`, `createCheckout`, `changePlan` (409 `conflict` ⇒ no subscription), `getBillingPortal` (409 `conflict` ⇒ no subscription).
|
|
167
167
|
- **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.
|
|
168
168
|
- **Onboarding:** `submitOnboarding`.
|
|
@@ -183,4 +183,4 @@ Two layers, split by whether a server is in the loop:
|
|
|
183
183
|
- **Unit suites (`tests/*.test.ts`, `make test`):** mock the fetch boundary (`vi.spyOn(globalThis, "fetch")`) and pin the client's wire behavior — URLs, request bodies, status→error mapping, poll semantics. These run everywhere, including CI. The codegen-check suite is the exception that proves the rule: it has no server to mock, so it runs against **vendored real codegen output** under `tests/fixtures/codegen/` — artifacts and lock emitted by an actual `pipelex codegen types` run, committed beside the `.mthds` they came from. A hand-written stamp would only prove the port agrees with itself. The artifacts are vendored with a trailing `.txt` and their real names restored at load time, because `format:check`, `lint`, and `typecheck:test` all glob `tests/**/*.ts` — and a single reformatted byte would invalidate the very hash the fixture exists to pin.
|
|
184
184
|
- **The command's case table (`tests/cli.test.ts`):** runs every case of `tests/fixtures/cli-cases.json` through the `pipelex-sdk` command in-process, in a fresh working directory, with `fetch` answering from the case's recorded routes, so the command runs on the real client. The table is written for the Python SDK's command of the same name to run too; [`cli.md`](cli.md) describes the format.
|
|
185
185
|
- **The packed command (`make test-pack`):** packs the package with `npm pack` and runs `pipelex-sdk --help` and `--version` from the tarball through `npx`, from a directory outside the package. It needs the registry for the dependencies, so CI's `js (make all)` job runs it and `make test` does not.
|
|
186
|
-
- **E2E suites (`tests/e2e/*.e2e.ts`, `make test-e2e`):** no mocks — they drive a **live pipelex-api server** (`PIPELEX_E2E_BASE_URL`, default `http://localhost:8081`) and verify the diagnostic-endpoint contract end-to-end with correct and malformed MTHDS bundles: clean content lints with zero diagnostics and formats to itself (idempotently), malformed content comes back as a 200 verdict (`diagnostics[]` on lint; `diagnostics[]` plus the content echoed back unchanged with `changed: false` on format; `is_valid: false` + `errors[]` on validate), and malformed formatter options surface as a 422 `ApiResponseError`. The crate suite adds the case only a live server can settle: the `kind` / `target` vocabulary is a mirror of a Python `StrEnum` in another repo, so nothing but a real call proves the two still agree — and it cross-checks that `codegen`'s `crate_fingerprint` equals the `fingerprint` `resolve` returns for the same closure. It then runs `runCodegenCheck` over the artifacts and lock the server just emitted — verbatim first (current, with the lock header's fingerprint matching the response's), then mutated in memory once per drift category, for a `.ts` target and a `.py` one. The unit suite pins the port against vendored bytes; only this pins it against the stamp and lock bytes the server writes **today**, which is the pairing that matters when a consumer commits the server's artifacts and gates CI on the SDK's verdict about them. The same suite drives `pipeIo` across all three selectors: the artifacts under the resolved ref, their equality with `validate`'s views for the same closure, the `include_files` echo, `all_pipes` on a method with no entry pipe, a `method_ref` package's manifest entry pipe, the invalid arm, and the
|
|
186
|
+
- **E2E suites (`tests/e2e/*.e2e.ts`, `make test-e2e`):** no mocks — they drive a **live pipelex-api server** (`PIPELEX_E2E_BASE_URL`, default `http://localhost:8081`) and verify the diagnostic-endpoint contract end-to-end with correct and malformed MTHDS bundles: clean content lints with zero diagnostics and formats to itself (idempotently), malformed content comes back as a 200 verdict (`diagnostics[]` on lint; `diagnostics[]` plus the content echoed back unchanged with `changed: false` on format; `is_valid: false` + `errors[]` on validate), and malformed formatter options surface as a 422 `ApiResponseError`. The crate suite adds the case only a live server can settle: the `kind` / `target` vocabulary is a mirror of a Python `StrEnum` in another repo, so nothing but a real call proves the two still agree — and it cross-checks that `codegen`'s `crate_fingerprint` equals the `fingerprint` `resolve` returns for the same closure. It then runs `runCodegenCheck` over the artifacts and lock the server just emitted — verbatim first (current, with the lock header's fingerprint matching the response's), then mutated in memory once per drift category, for a `.ts` target and a `.py` one. The unit suite pins the port against vendored bytes; only this pins it against the stamp and lock bytes the server writes **today**, which is the pairing that matters when a consumer commits the server's artifacts and gates CI on the SDK's verdict about them. The same suite drives `pipeIo` across all three selectors: the artifacts under the resolved ref, their equality with `validate`'s views for the same closure, the `include_files` echo, `all_pipes` on a method with no entry pipe, a `method_ref` package's manifest entry pipe, the invalid arm, the selection `422` coded `request.pipe_not_found` and naming the pipe in its `params`, and a registry-form `method_ref`'s `422 request.unsupported`, which `resolve` answers too. Its `method_id` case, like the `prepareInputs` suite's, runs only when `PIPELEX_E2E_METHOD_ID` names a stored method the key's organization owns, since only a hosted origin can resolve one, and is skipped otherwise. The distinct `.e2e.ts` suffix + `vitest.e2e.config.ts` keep them out of the default `vitest run`, so `make test` / CI never require a server. An unreachable server is an error, never a silent skip, and it is reported once: the suite's global setup (`tests/e2e/global-setup.ts`) probes the target a single time and stops the run with a one-line message, instead of letting every suite fail in `beforeAll` and print a socket-error dump each. It probes only after the account check below went ahead, so a run the check refuses sends no request at all, and `make test-e2e` sends none before it. The per-suite `beforeAll` guard stays as the backstop. Both probe **`/v1/version`**, deliberately: a bare runner serves the origin-level `/health` and a hosted origin does not (it answers `/v1/health` instead), so `/v1/version` is the one unauthenticated route both answer — probing anything else reports a perfectly reachable hosted origin as down. The target itself is resolved once, in the Makefile, and exported, so the URL the global setup checks and probes is always the URL the suites call: the shell environment (or a `make test-e2e PIPELEX_E2E_BASE_URL=...` override) wins, then `.env`, then `http://localhost:8081`. The global setup prints the resolved target before the first case, since `.env` can supply it invisibly, as its scheme and host alone. The key is never read from `.env`: against a hosted origin the suites act for the registered test account, QA Builder in QA SDK (dev), whose key the workspace's `test-account run` hands over beside what the registry expects of it, and the suite's global setup, `tests/e2e/live-account.ts`, compares `GET /v1/me` with those expectations before the first case, refuses the production API unless `PIPELEX_E2E_ALLOW_PRODUCTION=1` asks for it, and says nothing is checked against a loopback runner ([`docs/live-accounts.md`](https://github.com/Pipelex/pipelex-sdk/blob/main/docs/live-accounts.md) at the repository's root, which the package does not ship). A hosted origin serves every route the suite calls but `lint` and `format`, which it does not expose (gateway `403`), so their cases need a bare runner and skip on a hosted origin, as the `method_id` cases skip without a stored method: `tests/e2e/origin.ts` holds the one predicate every file asks, `isHostedOrigin`, which reads any origin whose host is not one of this machine's loopback spellings (`localhost`, a subdomain of it, `127.x.x.x` or `::1`) as a hosted plane. A local runner runs them. The upload-grant suite needs the same hosted platform: it requests a grant, sends a small PDF with `uploadWithGrant`, reads the bytes back equal through `fetchArtifact`, and proves the refusals the error mapping reads into a `RejectedAssetError`'s `reason` — `grant_used` for the `412` of a used grant, `signature_mismatch` for the `403 SignatureDoesNotMatch` of a file one byte longer than declared, `store_refused` for the `400 BadDigest` of other bytes than the hash named — and the platform's `413 request.too_large` for a declared size over the cap, its `params` naming the cap and the size. It runs in Node, whose `fetch` sends no CORS preflight, so the bucket's CORS rule is proven by a page, not by it. The artifact suite is the mirror image: it uploads a file with `prepareInputs`, runs a pass-through and brings the file back down with `downloadArtifacts` over `working_memory`, bytes equal, so it needs a hosted platform carrying `POST /v1/pipe-io` (which `prepareInputs` reads), the upload, run-lifecycle and bulk resolve routes, and a key for it, and it fails honestly rather than skipping against a bare runner. The problems suite proves that the problem on a platform refusal is the one the server sent: an `ApiResponseError` reads a problem document as received, and builds one from the status by the edge's rules when the body is none, so each case asserts which it was against `responseBody`. A run of a method id the catalog does not hold and a request body the platform's own validation refuses are the platform's problems, the second a `request.invalid` whose `location` and `params` name the field, with no `errors`, since a problem with a single fault carries none. A rejected API key is refused before the platform sees it, by the gateway's authorizer, with a bare `403 {"message":"Forbidden"}`, so its problem is the SDK's reading of the status, `auth.invalid_credential`. It needs a hosted platform and a key for it, and fails honestly against a bare runner.
|
package/docs/crate-routes.md
CHANGED
|
@@ -6,7 +6,7 @@ Three routes project a **closure** of MTHDS files into the artifacts downstream
|
|
|
6
6
|
>
|
|
7
7
|
> Measured 2026-08-23 against `api.pipelex.com` (`pipelex-hosted@0.10.1`) with a real API key: an empty body to either route comes back as an RFC 7807 `422` naming the missing fields — a request-shape verdict only a route that reached the service can produce — and a real closure comes back as a `200` carrying the artifacts. `api-dev.pipelex.com` has served them since 2026-08-13. Use a key when re-measuring: unauthenticated, every path answers `401` whether or not it is allowlisted, so a keyless probe cannot tell the two states apart.
|
|
8
8
|
>
|
|
9
|
-
> **`pipe-io` is newer.** A runner serves it from `pipelex-api` v0.33.0, and codes its pipe-selection refusals `request.pipe_not_found`, `request.pipe_unspecified` and `request.pipe_ambiguous` from v0.90.0, which is what `prepareInputs` branches on (see [What throws](#what-throws)).
|
|
9
|
+
> **`pipe-io` is newer.** A runner serves it from `pipelex-api` v0.33.0, and codes its pipe-selection refusals `request.pipe_not_found`, `request.pipe_unspecified` and `request.pipe_ambiguous` from v0.90.0, which is what `prepareInputs` branches on (see [What throws](#what-throws)). The platform's tooling proxy and the gateway's allowlist list it too, and the dev plane serves it (measured on `api-dev.pipelex.com` on 2026-10-31). `prepareInputs` reads this route, so it needs an origin that serves it.
|
|
10
10
|
>
|
|
11
11
|
> **`lint` / `format` are the exception and answer `403` on both origins.** That blocks nothing, because linting and formatting `.mthds` are toolchain capabilities rather than hosted ones: `plxt` carries both, and the post-edit hook this repo builds (`npm run build:hook`, vendored into `pipelex-plugins`) runs them offline through `@pipelex/tools-wasm` with no credentials — from the published package, `client.lint` / `client.format` are the documented fallback, against a runner. Exposing them on the hosted origins is a known, non-critical item on the platform's list, tracked in the workspace ledger as L-260929-b58f26.
|
|
12
12
|
|
|
@@ -21,7 +21,7 @@ interface CrateRequestBase {
|
|
|
21
21
|
tree?: MethodTreeSource; // or a listed tree, sent inline or by hash
|
|
22
22
|
contents?: Readonly<Record<string, Uint8Array | string>>; // or each file's bytes or text by tree path
|
|
23
23
|
dependencies?: Record<string, MethodFiles>; // beside the tree: the packages it calls by address
|
|
24
|
-
method_ref?: string; // address form server-resolved; registry form reserved →
|
|
24
|
+
method_ref?: string; // address form server-resolved; registry form reserved → 422 request.unsupported
|
|
25
25
|
read_scope?: string; // Pipelex-API extension; the hosted platform stamps its own
|
|
26
26
|
}
|
|
27
27
|
|
|
@@ -49,7 +49,7 @@ When the engine can attribute a fault to a file, the `errors[]` problem item's `
|
|
|
49
49
|
|
|
50
50
|
`dependencies` carries the method packages the tree calls by address, each a `files` map keyed by its address, posted as given. They are loaded beside the method, never merged into it, so a cross-package call resolves without the server fetching anything. A call into a package nothing shipped makes the closure invalid, a `200` verdict whose `unresolved_package_dependency` item names the package; `dependencies` without the tree is the server's `422`.
|
|
51
51
|
|
|
52
|
-
An **address-form** `method_ref` (`github.com/<owner>/<repo>[/<selector>][@<tag>]`) is resolved by the server through the same fetch path as a `method_ref` run: the package's whole tree is read from the commit at the tag and loaded as a sent tree is, with the packages it ships under its own `.mthds/methods/` beside it. Any non-address reference stays reserved and
|
|
52
|
+
An **address-form** `method_ref` (`github.com/<owner>/<repo>[/<selector>][@<tag>]`) is resolved by the server through the same fetch path as a `method_ref` run: the package's whole tree is read from the commit at the tag and loaded as a sent tree is, with the packages it ships under its own `.mthds/methods/` beside it. Any non-address reference stays reserved until a method registry exists: the server refuses it with a `422` `request.unsupported` whose `params.reason` is `registry_form_method_ref` and whose `location.field` is `method_ref`, on a runner from `pipelex-api` v0.90.0, which answered `501` before, and on the hosted plane, which relays the runner's refusal.
|
|
53
53
|
|
|
54
54
|
`method_id` is the hosted platform's selector, and it is a **pass-through**: nothing is expanded client-side — the platform resolves the id against the org's catalog and injects the stored tree as `files` before the runner sees the request. A bare `mt_…` names the method's latest published version, `mt_…@<n>` the fixed version `n` and `mt_…@draft` its draft, and the suffix rides the string untouched. It is meaningless against a bare runner (no catalog). An unknown or foreign-org id is a `404` `pipelex.catalog.not_found` (indistinguishable by design), as is a version the method never published; a bare id of a method never published is a `409` `pipelex.catalog.not_published`, whose detail names `mt_…@draft`; a malformed suffix is a `422`; a stored method with no MTHDS source is a `422`.
|
|
55
55
|
|
|
@@ -158,10 +158,10 @@ Only a **no-verdict** condition, as the typed `ApiResponseError` — branch on i
|
|
|
158
158
|
| ------------- | --------------------------------------------------------------------------------------------------------- |
|
|
159
159
|
| `422` | Request shape, `request.invalid` with its `location.field`: no closure selector or more than one, `dependencies` without the tree, an unknown `kind`/`target`, a `pipe_ref` on `kind: "types"`, a `method_id` whose version suffix is malformed; and a fetched package with no `.mthds` file or a stored method with no MTHDS source. |
|
|
160
160
|
| `422` | The tree's refusals, as on `validate`: `method.package_invalid` for a key, an entry or a size the tree's rule refuses (a `files` that is not a map among them), `request.blobs_missing` for a hash the server does not hold, and a tree holding no `.mthds` bundle. |
|
|
161
|
-
| `422` | On `pipeIo`, a pipe selection the route refuses: `request.pipe_not_found` for a `pipe_ref` that names no pipe, `request.pipe_unspecified` for a method with no entry pipe, `request.pipe_ambiguous` for a code that matches pipes in several domains
|
|
161
|
+
| `422` | On `pipeIo`, a pipe selection the route refuses: `request.pipe_not_found` for a `pipe_ref` that names no pipe, `request.pipe_unspecified` for a method with no entry pipe or, with no manifest, several bundles' `main_pipe` declarations, `request.pipe_ambiguous` for a bare code that matches pipes in several domains. Its `params` name the pipe as `pipe` and the candidates as `candidates` where there are some; its `detail` is the registry's sentence for the code, which names no candidate for `request.pipe_unspecified`. A manifest `main_pipe` that no bundle or several define is the method's own fault instead, `method.pipe_unresolved` or `method.ref_ambiguous`. |
|
|
162
162
|
| `404` | `pipelex.catalog.not_found`: an unknown or foreign-org `method_id` (indistinguishable by design), or a `mt_…@<n>` naming a version the method never published; no package at a `method_ref` address. |
|
|
163
163
|
| `409` | `pipelex.catalog.not_published`: a bare `method_id` of a method never published; `mt_…@draft` reaches its draft. |
|
|
164
|
-
| `
|
|
164
|
+
| `422` | `request.unsupported`: a registry-form `method_ref`, reserved until a method registry exists (the address form resolves). A runner older than `pipelex-api` v0.90.0 answers it `501`. |
|
|
165
165
|
| `401` / `403` | Auth, or on a hosted origin a route the gateway does not list yet (`403 {"message":"Forbidden"}`). |
|
|
166
166
|
| `403` | A tree holding a `.py` file on a deployment that is not sandbox-hosted, one whose Python declares a structure class on one that is, or a `read_scope` a `jwt` caller may not read under. |
|
|
167
167
|
| `502` | On a hosted origin, a cold `method_ref` clone that outran the gateway's 30-second cap; a retry clears it. |
|
package/docs/errors.md
CHANGED
|
@@ -96,6 +96,8 @@ What the hosted API answers a start sent under a key:
|
|
|
96
96
|
|
|
97
97
|
**A copy takes a key too.** `copyMethod` sends an `idempotencyKey` the same way, held to the same rule, and the hosted API keeps it for the same 24 hours, scoped to the caller and the method's organization. A copy binds no run, so its answers under a key differ from a start's: [method-copy.md](./method-copy.md#retrying-a-copy--idempotencykey) gives them.
|
|
98
98
|
|
|
99
|
+
**So do a secret rotation and a delivery replay.** `rotateWebhookSecret` and `replayRunWebhookDelivery` send an `idempotencyKey` the same way, held to the same rule. A rotation's answer carries a secret, so the platform never replays it, while a replay's stored answer is given again: [webhooks.md](./webhooks.md) gives both.
|
|
100
|
+
|
|
99
101
|
## Each class's problem
|
|
100
102
|
|
|
101
103
|
The classes remain, so a `catch` can still tell an argument refusal from a refused request or a failed run, but every one carries its failure as its problem, and the code is what a program branches on.
|
|
@@ -166,7 +166,7 @@ The route selects the pipe, and the ref it answers with is the one prepared —
|
|
|
166
166
|
|
|
167
167
|
1. **`pipe_ref` when given.** Qualified-only: `domain.pipe_code`, normalized (trimmed, and blank is absent) and checked **before any request**. A bare code is refused with an `InputPreparationError` asking for the qualified form, since a request names a pipe by its qualified ref and search is a run-route affordance this helper never grows. An `alias->domain.pipe_code` ref is refused for a different reason: the alias names a dependency package's pipe, and preparation covers the method's *own* pipes, whose input form the pipe I/O route describes. The run route takes such a ref; preparation cannot.
|
|
168
168
|
2. **Otherwise the method's own entry pipe**, chosen by the route: the `main_pipe` of the `METHODS.toml` at the tree's root, whether the tree was sent or fetched with a package, else the closure's `main_pipe` when exactly one domain declares one. A method that names its entry pipe in `METHODS.toml` alone — the published `github.com/Pipelex/methods/documents` does — therefore prepares with no `pipe_ref`.
|
|
169
|
-
3. **A selection the route refuses** — a `pipe_ref` that names no pipe of the method, a method with no entry pipe, one whose
|
|
169
|
+
3. **A selection the route refuses** — a `pipe_ref` that names no pipe of the method, a method with no entry pipe, one whose bundles declare several `main_pipe`s — comes back as a `422` whose code is `request.pipe_not_found` or `request.pipe_unspecified`. The route's third selection code, `request.pipe_ambiguous`, for a bare code several domains define, does not arise from here, since a bare `pipe_ref` is refused before any request, but the helper reads it as a refused selection too. The helper turns exactly those three codes into an `InputPreparationError` carrying the server's problem, whose `params` name the unknown ref as `pipe` and the refs the request could select as `candidates`, with the `ApiResponseError` as its `cause`; its message ends with the problem's `detail`, the registry's sentence for the code, which names no candidate for `request.pipe_unspecified`. A manifest `main_pipe` the closure does not define, or defines in several domains, is the method's own fault, a `422` `method.pipe_unresolved` or `method.ref_ambiguous`, which stays an `ApiResponseError`. The run route refuses a selector-less run in the same cases, so preparation never succeeds for a pipe the run would not take.
|
|
170
170
|
|
|
171
171
|
There is no single-pipe fallback: a method that declares one pipe and no `main_pipe` is refused a selector-less run, and a selector-less preparation with it.
|
|
172
172
|
|
package/docs/method-copy.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Copying a method into another organization
|
|
2
2
|
|
|
3
|
-
A stored method belongs to the organization that holds it, and access to it comes from membership in that organization. `copyMethod` makes a new method in another organization the caller may change methods in, holding the original's draft, its form inputs and its saved examples, and leaves the original as it was. It is how a method reaches a team from the personal workspace that holds it, how it moves from a team into the caller's personal workspace, and, with the method's own organization as the destination, how a method is duplicated.
|
|
3
|
+
A stored method belongs to the organization that holds it, and access to it comes from membership in that organization. `copyMethod` makes a new method in another organization the caller may change methods in, holding the original's draft, its form inputs and its saved examples, and leaves the original as it was. It is how a method reaches a team from the personal workspace that holds it, how it moves from a team into the caller's personal workspace, and, with the method's own organization as the destination, how a method is duplicated. The client reaches it on the hosted API's `POST /v1/methods/{method_id}/copy`, and the types are in `src/product-models.ts`.
|
|
4
4
|
|
|
5
5
|
```ts
|
|
6
6
|
import { PipelexApiClient } from "@pipelex/sdk";
|
|
@@ -19,18 +19,17 @@ if (result.outcome === "refused") {
|
|
|
19
19
|
}
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
-
## The
|
|
22
|
+
## The method
|
|
23
23
|
|
|
24
24
|
| Method | Route | Answer |
|
|
25
25
|
|---|---|---|
|
|
26
26
|
| `copyMethod(methodId, { destination_org_id, name?, expected_draft_updated_at? }, { idempotencyKey? })` | `POST /v1/methods/{id}/copy` | A `MethodCopyResult`, discriminated on `outcome`: `copied` or `refused`. |
|
|
27
|
-
| `listMethodCopies(methodId, { limit?, cursor? })` | `GET /v1/methods/{id}/copies` | One `MethodCopyPage`, `{ items, nextCursor }`, of `MethodCopyMarker` rows. `limit` is 1 to 100, 50 by default. |
|
|
28
27
|
|
|
29
|
-
|
|
28
|
+
It takes a bare catalog id, as every method route does, and refuses a suffixed one (`mt_…@3`) with a `RequestArgumentError` before anything is sent.
|
|
30
29
|
|
|
31
30
|
**The copy's body is closed.** The client sends `destination_org_id` and, when given, `name` and `expected_draft_updated_at`, and nothing else; a `null` reads as not given. `destination_org_id` is the canonical id of the destination, as a membership's `org_id` gives it; `name` is the copy's name, the original's when it is left out; `expected_draft_updated_at` is the original's draft token the caller last saw (`MethodData.updated_at`). A `destination_org_id` that is not a non-empty string, and a `name` or a token given that is not a string, are refused with a `RequestArgumentError` before any request.
|
|
32
31
|
|
|
33
|
-
**Only a web session may copy.** A Pipelex API key belongs to one organization and cannot speak for another, so the platform refuses a copy sent with one `403` `auth.forbidden`, whatever its role.
|
|
32
|
+
**Only a web session may copy.** A Pipelex API key belongs to one organization and cannot speak for another, so the platform refuses a copy sent with one `403` `auth.forbidden`, whatever its role.
|
|
34
33
|
|
|
35
34
|
## What a copy holds
|
|
36
35
|
|
|
@@ -83,7 +82,7 @@ A non-2xx answer means no verdict could be produced, and none of them leaves any
|
|
|
83
82
|
| `502` | `runtime.unavailable` | The platform could not read the caller's memberships. Its `retry` is `yes`. |
|
|
84
83
|
| `500` | `pipelex.storage.not_configured` | The platform has no usable credentials for storage. Its `retry` is `no`. |
|
|
85
84
|
| `503` | `pipelex.storage.unavailable` | Storage failed a read, a copy or a write. Its `retry` is `yes`. |
|
|
86
|
-
| `503` | `runtime.unavailable` | The copy did not finish inside its deadline, a stored file it copied was written over meanwhile, or DynamoDB failed a read or a write. Its `retry` is `yes`. When DynamoDB failed both the write of the copy's row and the read that would confirm it, the `detail` names the copy's method id and says that a retry may make a second copy: the copy may stand in the destination, without the
|
|
85
|
+
| `503` | `runtime.unavailable` | The copy did not finish inside its deadline, a stored file it copied was written over meanwhile, or DynamoDB failed a read or a write. Its `retry` is `yes`. When DynamoDB failed both the write of the copy's row and the read that would confirm it, the `detail` names the copy's method id and says that a retry may make a second copy: the copy may stand in the destination, without the destination's records of its files, so look for that id in the destination before copying again. |
|
|
87
86
|
|
|
88
87
|
`MethodErrorCode` names `pipelex.org.not_found` and `pipelex.catalog.copy_too_large` beside the other method codes ([errors.md](./errors.md#the-method-codes)).
|
|
89
88
|
|
|
@@ -115,14 +114,6 @@ try {
|
|
|
115
114
|
|
|
116
115
|
Without a key, a copy whose answer was lost cannot be told from one that never ran, and sending it again may make a second copy. Even with one, two rare windows can make a second copy on a retry. A platform process can die after writing the copy and before storing its answer, a window the creation of a method shares. And the `503` whose `detail` names the copy released the key while the copy may stand, which is why it names the copy: look for that method id in the destination before copying again.
|
|
117
116
|
|
|
118
|
-
## The copies of a method
|
|
119
|
-
|
|
120
|
-
The original keeps a marker of each copy made of it, and `listMethodCopies` pages them. A `MethodCopyMarker` holds `copy_method_id`, `destination_org_id`, `copied_by_user_id` and `copied_at`. The destination is named by its id alone, never by its name, so the listing tells no reader the name of an organization they do not belong to: a surface shows a destination's name from the reader's own memberships (`listMemberships`), and a copy in an organization the reader does not belong to stays an id. The rows come in the order of the copies' ids, which says nothing of when each was made; sort by `copied_at` to show them by date.
|
|
121
|
-
|
|
122
|
-
Pass the page's `nextCursor` back as `cursor` to continue; `null` is the last page. A method never copied lists an empty page, as does a copy, which names its original through `copied_from` and has no copies of its own. A marker stays when its copy is erased, and the copy then reads as not found. The marker is written once the copy exists, and a failure to write it does not fail the copy, so a copy can, rarely, be missing from the list. The original's erasure and its discard delete its markers with it.
|
|
123
|
-
|
|
124
|
-
The listing answers `404` `pipelex.catalog.not_found` for an unknown method, `409` `pipelex.catalog.being_deleted` while its erasure runs, and `422` `request.invalid` for a cursor it did not issue for that method or a `limit` outside 1 to 100. A page whose `items` is not an array or whose `next_cursor` is neither a string nor `null` is an answer the SDK cannot read.
|
|
125
|
-
|
|
126
117
|
## `copied_from` on a method
|
|
127
118
|
|
|
128
119
|
Every read of a method, `getMethod` and the `method` of every write, carries `copied_from`: `{ org_id, method_id, draft_digest }` of the original on a copy, `null` on a method created afresh, and absent from a platform that predates copies. It is written once, when the copy is made, and never rewritten, so it names the draft the copy started from even after the original has changed or been erased; its `draft_digest` is `null` for a draft that held no tree. It holds opaque ids only, never a name, and the original's organization may be one the reader does not belong to. The list rows (`MethodSummary`) do not carry it.
|
package/docs/webhooks.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Caller webhooks
|
|
2
2
|
|
|
3
|
-
A run can call webhooks of its caller once it has ended, so the caller learns of the end without polling. The run registers the URLs when it starts, in `callback_urls`; when it ends, the runtime sends each URL one signed `POST` carrying a `run.finished` event; the caller's receiver verifies the call, then fetches the results by the run's id. The contract is the spec `conformance/specs/pipelex-caller-webhooks.md`, in the `conformance` repository, which both the hosted plane and a self-hosted runner honour. This page covers the four parts the SDK takes: registering the URLs, verifying a call (`verifyWebhook`), reading the hosted delivery log, and reading or rotating the organization's signing secret. The Python SDK's twin is [`python/docs/webhooks.md`](
|
|
3
|
+
A run can call webhooks of its caller once it has ended, so the caller learns of the end without polling. The run registers the URLs when it starts, in `callback_urls`; when it ends, the runtime sends each URL one signed `POST` carrying a `run.finished` event; the caller's receiver verifies the call, then fetches the results by the run's id. The contract is the spec `conformance/specs/pipelex-caller-webhooks.md`, in the `conformance` repository, which both the hosted plane and a self-hosted runner honour. This page covers the four parts the SDK takes: registering the URLs, verifying a call (`verifyWebhook`), reading the hosted delivery log, and reading or rotating the organization's signing secret. The Python SDK's twin is [`python/docs/webhooks.md`](https://github.com/Pipelex/pipelex-sdk/blob/main/python/docs/webhooks.md).
|
|
4
4
|
|
|
5
5
|
## Registering the webhooks — `callback_urls`
|
|
6
6
|
|
|
@@ -98,8 +98,19 @@ The event never carries the run's results: fetch them with `getRunResult(event.d
|
|
|
98
98
|
The hosted plane records each delivery, one per URL a run registered, with every attempt. Any member of the organization reads them, by web session or API key:
|
|
99
99
|
|
|
100
100
|
- `listRunWebhookDeliveries(runId)` — `GET /v1/runs/{id}/webhook-deliveries`, a `WebhookDeliveryList` whose `deliveries` are `WebhookDeliverySummary` rows in the order of the run's URLs, none for a run that registered none or has not ended. Each has its `delivery_id` (the receiver's `webhook-id`), its `url_index` and redacted `url`, its `state` (`pending` on its retry schedule, `delivered` once a receiver answered `2xx`, `failed` past its last attempt) and its `attempts`, each with when it was sent, the HTTP `status_code` the receiver answered, or the `failure` that kept an answer from coming (`timeout`, `connection`, `tls`, `refused_address`), and its `duration_ms`. No response body is kept.
|
|
101
|
-
- `getRunWebhookDelivery(runId, deliveryId)` — one `WebhookDeliveryDetail`, the summary and
|
|
102
|
-
- `replayRunWebhookDelivery(runId, deliveryId)` — sends a settled delivery again: one new attempt under the same `webhook-id`, with the same `data`, the next `attempt`, a fresh timestamp and the signatures of the secrets live then. It answers `202` with a `WebhookReplayAccepted` naming the attempt; a replay asked again before it is sent is that same attempt. A delivery still `pending` is refused `409` `pipelex.org.not_manageable` with the reason `webhook_delivery_pending`, and one holding the most attempts a delivery holds with `webhook_delivery_attempts_exhausted`; past the organization's replay rate the platform answers `429` `account.rate_limited` with its `retry_after`.
|
|
101
|
+
- `getRunWebhookDelivery(runId, deliveryId)` — one `WebhookDeliveryDetail`, the summary and its `event`: the body its latest attempt sent, or, before any attempt, the body its first attempt will send. Every attempt and replay of the delivery sends that body, only its `attempt` changing, so the `event` matches what a receiver logged only once an attempt was made: it then equals, as JSON, the body that attempt brought the receiver. Compare the two as parsed JSON values, never as text, since the read promises the same value, not the same bytes.
|
|
102
|
+
- `replayRunWebhookDelivery(runId, deliveryId, { idempotencyKey? })` — sends a settled delivery again: one new attempt under the same `webhook-id`, with the same `data`, the next `attempt`, a fresh timestamp and the signatures of the secrets live then. It answers `202` with a `WebhookReplayAccepted` naming the attempt; a replay asked again before it is sent is that same attempt. A delivery still `pending` is refused `409` `pipelex.org.not_manageable` with the reason `webhook_delivery_pending`, and one holding the most attempts a delivery holds with `webhook_delivery_attempts_exhausted`; past the organization's replay rate the platform answers `429` `account.rate_limited` with its `retry_after`.
|
|
103
|
+
|
|
104
|
+
### Retrying a replay — `idempotencyKey`
|
|
105
|
+
|
|
106
|
+
`replayRunWebhookDelivery`'s last argument takes an `idempotencyKey`, sent as the `Idempotency-Key` header of the replay and not sent at all when absent or `null`. The platform stores the replay's answer under the key, so the same replay sent again under the same key gets the stored answer, naming the same attempt, and the delivery is not sent twice: a replay whose answer was lost, as on a client timeout, can be sent again safely. Generate one key per replay you mean to ask for (a `crypto.randomUUID()` is a good one), and keep it across that replay's retries:
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
const idempotencyKey = crypto.randomUUID();
|
|
110
|
+
const accepted = await client.replayRunWebhookDelivery(runId, deliveryId, { idempotencyKey });
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
A key reused for another request is refused `409` `request.idempotency_conflict`, whose `retry` is `no`, and a key whose earlier request is still being handled `409` `request.idempotency_key_in_use`, whose `retry` is `yes`: send it again under the same key after its `retryAfter`. A key is 1 to 255 visible ASCII characters, `!` to `~`; anything else, an empty string included, is a `RequestArgumentError` naming `idempotency_key` before anything is sent, the rule a start's key is held to ([errors.md](./errors.md#a-starts-idempotency-key--idempotencykey)).
|
|
103
114
|
|
|
104
115
|
The states and failures are read open: one this SDK does not name arrives as its raw string.
|
|
105
116
|
|
|
@@ -116,4 +127,4 @@ Both refuse another role with `403` `auth.forbidden` whose `params.requirement`
|
|
|
116
127
|
|
|
117
128
|
The contract's known-answer vectors, `tests/caller_webhooks/fixtures/signature-vectors.json` in the `conformance` repository, are vendored byte for byte at `tests/fixtures/caller-webhooks/signature-vectors.json`, with `tests/fixtures/caller-webhooks/vendored.json` recording the commit it was copied at and its SHA-256. `tests/webhooks.test.ts` fails when the copy no longer has that digest, verifies every verification case at its `receiver_clock` and compares its verdict and refusal, and verifies every signing case under each secret that signed it. The shared case file `tests/fixtures/webhook-events.json` adds what the vectors leave to each SDK: the problem of each refusal, what each verified body reads as (the hosted and self-hosted shapes, an unknown type, the unreadable bodies) and the secrets refused.
|
|
118
129
|
|
|
119
|
-
All three files are shared with the Python SDK the way every case file is ([`docs/layout.md`](
|
|
130
|
+
All three files are shared with the Python SDK the way every case file is ([`docs/layout.md`](https://github.com/Pipelex/pipelex-sdk/blob/main/docs/layout.md) at the repository's root): the source is here, `scripts/shared-files.mjs` declares the copies under `python/tests/fixtures/`, and `make check-shared-files` fails on a stale copy. To refresh the vectors, copy the file from the conformance repository at the commit that last changed it, update the commit and the digest in `vendored.json`, and run `make shared-files` at the repository's root.
|