Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## [Unreleased]

### 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.

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

### Fixed
Expand Down
1 change: 1 addition & 0 deletions webapp-js/.claude/skills/bootstrap/scripts/bootstrap.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,7 @@ The server listens on loopback only, because anyone who can reach it runs method
- [\`docs/add-method.md\`](docs/add-method.md) — adding a method, and removing one.
- [\`docs/codegen.md\`](docs/codegen.md) — the generated types and the checks that keep them current.
- [\`docs/input-form.md\`](docs/input-form.md) — how the input form and the result view are rendered from a method's contract.
- [\`docs/errors.md\`](docs/errors.md) — what a person reads when something fails, and where a failed run's reason comes from.
- [\`CLAUDE.md\`](CLAUDE.md) — the project guide for coding agents.

## License
Expand Down
8 changes: 5 additions & 3 deletions webapp-js/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,11 @@ src/
ResultEnv.tsx # client component — the kernel's ResultEnvProvider with this app's resolvers, mounted in the layout
HydrationMark.tsx # client component — sets html[data-hydrated] for a browser script to wait on, mounted in the layout
CostReport.tsx # per-run token usage + cost breakdown, inside RunDetails' disclosure
ErrorDisplay.tsx # server component (renders classified PipelineError)
ErrorDisplay.tsx # server component (renders classified PipelineError: reason, next step, retry line, support line)
types/
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
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 @@ -121,7 +122,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/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`).

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

Expand Down Expand Up @@ -237,7 +238,8 @@ Conventions:
- **One client**: instantiate `PipelexApiClient` once via `getPipelexClient()`. Never `new PipelexApiClient()` directly in actions or components.
- **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` from the result lookup and classifies it there too.
- **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.
- **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
1 change: 1 addition & 0 deletions webapp-js/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ Next.js 16 (App Router), React 19, TypeScript 5 (strict), Tailwind CSS 4 (config
- [`docs/add-method.md`](docs/add-method.md) — adding a method, and removing one.
- [`docs/codegen.md`](docs/codegen.md) — the generated types and the checks that keep them current.
- [`docs/input-form.md`](docs/input-form.md) — how the input form and the result view are rendered from a method's contract.
- [`docs/errors.md`](docs/errors.md) — what a person reads when something fails, and where a failed run's reason comes from.
- [`docs/ci.md`](docs/ci.md) — what the pull-request checks prove, and how `make create` is proven against the live API.
- [`docs/chrome-lineage.md`](docs/chrome-lineage.md) — what this template took from the gallery, and what it changed.
- [`CLAUDE.md`](CLAUDE.md) — the project guide for coding agents.
Expand Down
36 changes: 36 additions & 0 deletions webapp-js/docs/errors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Errors: what a person reads when something goes wrong

Every failure this app can meet reaches the person using it as one `PipelineError`, rendered by `<ErrorDisplay>`. The error is classified on the server, inside the helpers that call the SDK (`executeBlockingRun`, `startDurableRun`, `pollDurableRun`, the grant action), by `classifyPipelineError` in `src/lib/errors.ts`, because the SDK's error classes only match with `instanceof` there. The helpers return the classified error instead of throwing it, since a production build of Next.js turns a thrown Server Action error into an opaque digest.

## The fields of a classified error

| Field | What `<ErrorDisplay>` does with it |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `title` | The headline. |
| `message` | One or two sentences on what happened, in plain language. |
| `apiMessage` | The API's own words, in a block of their own, when `message` re-frames them. |
| `hint` | The next step, with an optional snippet to copy and a link. |
| `retry` | Whether running it again can succeed, as the runtime judged it: a line offering a re-run when it can, and saying a re-run unchanged will fail when it cannot. Absent when nobody said, and then nothing is claimed. |
| `support` | One line to quote to support, shown selectable in place of the bare run id. |
| `details` | The raw technical facts, in a "Technical details" disclosure that starts closed. |

## A failed durable run

A durable run that ends without a result is read from **its stored error report**, the runtime's own account of the failure. The platform stores the report on the run and serves it on the status read (`RunRead.error`) and in the result lookup's refusal, which `@pipelex/sdk` hands back on the failed arm of `getRunResult`. `pollDurableRun` takes the result lookup's report, or the status read's when the lookup has none (a platform that does not serve it there yet), puts it on the `RunFailedError` it classifies, and passes the status read's `finished_at` along. The classification reads each part of the display from the report:

| The display | From the report |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| The headline | "The pipeline run failed: " and the report's `title`, the stable label of the error class. |
| What happened | The report's `message`, which names the failing pipe, without the provider's raw text (below). |
| The next step | `user_action.detail`, the runtime's advice: change an input, choose another model, check billing. A `wait_and_retry` advice is dropped, because it says the system will retry automatically and a failed run is retried by nothing; the retry line speaks instead. |
| The retry line | `retryable`. True offers a re-run; false says a re-run unchanged fails the same way; absent says nothing. |
| The support line | The run id, `error_type` and the time the run ended, as `run <id> · <error_type> · failed <UTC time>`. |
| Technical details | The run's status and the report's classification (`error_type`, `error_domain`, `error_category`, `retryable`, the user action's kind, `model`, `type_uri`), the message shown above, and the validation items of a method that failed validation. |

**The provider's raw text never reaches the person.** When a model provider refused a call, the runtime writes the provider SDK's text into the report's message verbatim, and that text can be the raw body of the provider's error, naming the deployment's own provider account, or a whole HTML page from an edge in front of the provider. The report names that text as `provider_metadata.message`, so the classification cuts it out of the message wherever it appears, and leaves `provider_metadata` out of the technical details as well. What remains still names the pipe, the provider, the model and the HTTP status: `Pipe 'summarize' (path: two_steps > summarize) failed: openai inference failed for model 'claude-4.8-opus' (HTTP 412)`.

**A run with no report keeps the SDK's sentence**, "Run finished with status FAILED; no result available", with the bare run id. That is every cancelled, terminated or timed-out run, every run the platform finalized itself, and every failed run on a platform that serves no report; the absence of a report says nothing about why.

## Other failures

A run that fails on the blocking path comes back as a refused request, an `ApiResponseError`, and is classified by its HTTP status like any other refusal. Every other kind (an unreachable API, a missing key, a durable-run lifecycle the URL does not serve, a file upload that failed, an output that does not match the method's contract) has its own branch in `classifyPipelineError`, and [`CLAUDE.md`](../CLAUDE.md) says how to add one.
Loading
Loading