Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
### Fixed

- **A failed durable run says why**: the web app template's failure display now reads a failed run's stored error report and shows the runtime's reason with the failing pipe, its advice as the next step, whether running it again can help, and a line to quote to support with the run id, the error type and when the run ended, where it used to show only "Run finished with status FAILED; no result available". A model provider's raw error text is kept out of everything the person can read, and a run that ended with no stored report keeps the old sentence.
- **A refused run says what to do**: when the runner refuses to run a method, at the start of a durable run or on a blocking run, the web app template's failure display now shows the runtime's next step in place of a generic hint, says whether running it again can help when the runtime says so, and lists the method's validation items under the technical details, with an unknown model's reference and the model deck's suggestions. An answer that carries none of this keeps its old wording.

## [v0.5.5] - 2026-09-25

Expand Down
6 changes: 4 additions & 2 deletions webapp-js/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ src/
pipelineError.ts # BadPipelineOutputError (tagged)
test/fixtures/contracts/ # recorded codegen output the shared code's tests run against
test/fixtures/runReports.ts # failed runs' stored error reports the failure display's tests run against
test/fixtures/refusals.ts # a refused run's problem document, as the SDK throws it
e2e/
home.spec.ts # offline β€” the page renders its title and either the empty state or a form
liveApi.ts # requireLiveApi() β€” the key guard every live spec calls
Expand Down Expand Up @@ -122,7 +123,7 @@ The slice is the same for every source kind; only `methods/<name>/` and the way
- **`src/hooks/`** β€” `useRun<TInput,TOutput>`, the unified client state machine (`idle β†’ running β†’ done|error`) that dispatches blocking vs durable by `mode`. Holds the durable poll loop, the staleness token, the elapsed ticker, the wall-clock ceiling, the `classifyTransportError` wrapping, and the **transient-failure budget** (a momentary 5xx/network blip on one poll tick β€” flagged `transient` by `pollDurableRun` β€” or a rejected poll await is retried up to `MAX_TRANSIENT_POLL_FAILURES`, surfacing `health: "retrying"` meanwhile, rather than abandoning a run that's still completing server-side). The running state's `health` field (`RunHealth | null`) names _why_ the poll loop is in a resilient state so `<RunStatus>` can show reassuring, cause-specific copy instead of one alarming "degraded" note: `"reconnecting"` when the **server** reported `degraded` (its status endpoint served a last-known DB status because Temporal was unreachable), `"retrying"` for a **client-side** poll blip, `null` when polling cleanly. A durable `start` that returns `lifecycle_unavailable` (the configured URL doesn't serve the durable run lifecycle) surfaces as an explicit error β€” `useRun` never silently downgrades durable to blocking. Forms never branch on mode β€” they just call `run(input)`. `useFileInputs` is the file seam β€” drop, size early-exit, ask the method's grant action, send the file to storage with the SDK's `uploadWithGrant` (from the browser-safe `@pipelex/sdk/upload`), write the stored reference back at the field's dotted path, and hold the id in the set the kernel reads as `uploadingIds` meanwhile β€” so a form with a file input composes it rather than restating it.
- **`src/components/`** β€” React components. `"use client"` only when the component uses hooks, event handlers, or browser APIs (`RunDetails` does; `RunStatus` is a pure render). `RunResult` is the one kernel composition on the output side β€” `RunInputsForm`'s twin β€” and every method's form renders it: the kernel's `<StuffViewer>` under the same `presentation="app"` the form uses, inside a labelled `<section>` so the result is a region a screen reader can name and jump to. There is no per-output-shape component, and that is the point: a result view stops being a design decision the app has to take once the method's own declaration of what it produces is a committed artifact. `ResultEnv` is the kernel's `ResultEnvProvider` carrying this app's two resolvers, mounted once in the root layout β€” display through `assetPath`, sharing through `resolveShareUrl` β€” so every file arm in every result view paints a stored reference through the assets route.
- **`src/types/`** β€” the **adapter layer over `src/generated/`**, not a place where shapes are declared. Each `parseXxx(results: RunResults)` hands `wireOutput(results)` to the binder generated from that method's own contract, and translates a thrown `ZodError` into the app's tagged error model; the type itself is re-exported from the generated `types.ts`. Hand-written validation belongs here only where it adds semantics the concept does not declare. Narrowers throw on mismatch; that's deliberate (system boundary).
- **`src/test/fixtures/contracts/`** β€” recorded `contracts.ts` files, real codegen output for methods this app does not ship, so the shared code's tests (`runInputs`, `resultField`, `RunResult`, `ResultEnv`) run against the shapes a real method produces. They are test data, never imported by app code. `src/test/fixtures/runReports.ts` holds failed runs' stored error reports, one recorded and two in the runtime's shapes, for the tests of the failed-run display (`errors`, `durableRun`, `ErrorDisplay`).
- **`src/test/fixtures/contracts/`** β€” recorded `contracts.ts` files, real codegen output for methods this app does not ship, so the shared code's tests (`runInputs`, `resultField`, `RunResult`, `ResultEnv`) run against the shapes a real method produces. They are test data, never imported by app code. `src/test/fixtures/runReports.ts` holds failed runs' stored error reports, one recorded and two in the runtime's shapes, for the tests of the failed-run display (`errors`, `durableRun`, `ErrorDisplay`), and `src/test/fixtures/refusals.ts` a refused start's problem document for the refusal's.

## Generated types (`src/generated/`)

Expand Down Expand Up @@ -239,7 +240,8 @@ Conventions:
- **Narrow at the boundary, but never re-declare the shape**: the SDK returns loosely-typed output, so always pass the whole `RunResults` through a `parseXxx(results)` narrower in `src/types/`. That narrower hands `wireOutput(results)` to the generated binder and translates the thrown `ZodError` into a tagged subclass of `Error` (`BadPipelineOutputError`) via `describeSchemaFailure`. Do not `as` your way through, and do not hand-write the shape it validates β€” the method already declares it and `npm run codegen` projects it.
- **Return classified errors, don't throw across the server→client boundary**: the shared helpers return `{ ok: true, ... } | { ok: false, error: PipelineError }`. Throwing works in dev but Next.js production builds strip server-action error messages to opaque digests, which destroys the developer-facing error UX. `executeBlockingRun` / `startDurableRun` / `pollDurableRun` wrap the SDK call in `try/catch`, hand the caught value to `classifyPipelineError(err, env)`, and return the structured error. Render it client-side with `<ErrorDisplay>`.
- **Classification stays server-side, in the helpers.** `classifyPipelineError` `instanceof`-matches SDK error classes, which only exist server-side (they're stripped to opaque digests crossing the boundary) β€” so it runs inside the helpers, never on a poll/blocking result the client received. The durable `failed` poll constructs a `RunFailedError` carrying the run's **stored error report** β€” the result lookup's `error`, else the status read's, which the hosted platform serves even where its result lookup does not yet β€” and classifies it there too, passing the status read's `finished_at`.
- **A failed run is shown from its report, never from the lookup's sentence.** `classifyRunFailed` puts the report's `title` in the headline, its `message` as what happened, its `user_action.detail` as the hint (except a `wait_and_retry` detail, which promises an automatic retry a failed run never gets), its `retryable` verdict as `retry` (absent when the report has none, so nothing is claimed on a guess), and a `support` line with the run id, the `error_type` and when the run ended, which `<ErrorDisplay>` shows in place of the bare run id. **The provider's raw text never reaches the person**: the report's message quotes the provider SDK's text verbatim, which can be a raw error body or a whole HTML page, and the report names it as `provider_metadata.message`, so `visibleReportMessage` cuts it out and `provider_metadata` is left out of the technical details too. A run with no report β€” cancelled, terminated, timed out, finalized by the platform, or on a platform without the report β€” keeps the SDK's sentence. [`docs/errors.md`](docs/errors.md) is the reference.
- **A failed run is shown from its report, never from the lookup's sentence.** `classifyRunFailed` puts the report's `title` in the headline, its `message` as what happened, its `user_action.detail` as the hint (except a `wait_and_retry` detail, which promises an automatic retry a failed run never gets, and an `unknown` one, the runtime's fallback, which points at fields the display never shows), its `retryable` verdict as `retry` (absent when the report has none, so nothing is claimed on a guess), and a `support` line with the run id, the `error_type` and when the run ended, which `<ErrorDisplay>` shows in place of the bare run id. **The provider's raw text never reaches the person**: the report's message quotes the provider SDK's text verbatim, which can be a raw error body or a whole HTML page, and the report names it as `provider_metadata.message`, so `visibleReportMessage` cuts it out and `provider_metadata` is left out of the technical details too. A run with no report β€” cancelled, terminated, timed out, finalized by the platform, or on a platform without the report β€” keeps the SDK's sentence. [`docs/errors.md`](docs/errors.md) is the reference.
- **A refused run is shown from its problem document.** A runner refusal at `/v1/start` or `/v1/execute` is an `ApiResponseError` whose members are the problem document's, and `withRefusalAdvice` gives the `bad_request` and `server_error` arms of `classifyResponse` its `userAction.detail` as the hint (a `wait_and_retry` or `unknown` detail excepted, as on a failed run) and its `retryable` verdict as `retry`; the message is already its `detail`. `validationLines` writes each validation item into the details ahead of the raw body, with an unknown model's `model_reference` and `suggestions`, which the SDK's `ValidationErrorItem` does not declare yet. An answer carrying none of these keeps today's classification, and the 401/403 and blocking-only arms are untouched.
- **Inputs are gated, never hand-guarded.** Every action starts with `gateRunInputs(CONTRACT, data)` over the method's committed contract β€” the same gate the browser ran for the Run button β€” and returns its `{ ok: false, error }` unchanged. Do not add a per-input `if (!x) return badRequest()` beside it. What legitimately sits _after_ the gate is a check the contract cannot express β€” the file-reference scheme check is the one example, and it runs over the _gated_ inputs. A file's type and size are checked earlier still, by the grant action, before the file is stored.
- **Add new error kinds in `src/lib/errors.ts`**: extend `PipelineErrorKind`, add an `instanceof` branch in `classifyPipelineError` (import the class from `@pipelex/sdk`), and cover it in `src/lib/errors.test.ts`. Keep `classifyPipelineError` pure β€” env passed in by caller, no `process.env` reads inside. The dual-mode kinds (`execute_timeout`, `run_still_running`, `run_failed`, `run_timeout`, `lifecycle_unavailable`) follow this pattern. Two exceptions build a `PipelineError` inline (no thrown error to classify): pre-flight validation (`file_too_large`, `unsupported_file_type`, `bad_request`) in a Server Action or in `useFileInputs`'s size check, and the client-side poll ceiling (`buildClientTimeoutError`, kind `run_timeout`) in `useRun`.
- **`lifecycle_unavailable` has two sources.** A 404 from a URL that doesn't serve the run-lifecycle routes arrives as the SDK's `RunLifecycleUnavailableError` (`instanceof` branch β†’ `classifyLifecycleUnavailable`); a `/start` against a deployment whose orchestrator is blocking-only (the in-process `direct` mode) arrives as a 400 `ApiResponseError` with `error_type: "StartRequiresAsyncOrchestration"`, matched by an `errorType` branch in `classifyResponse` β†’ `classifyStartRequiresAsync`. Both restate the runtime's vocabulary ("orchestration mode", "fire-and-forget") in this app's term β€” **durable execution** β€” and frame the configured URL as the problem, steering to `PIPELEX_BASE_URL`; the messages differ because the root causes differ.
Expand Down
Loading
Loading