Error handling
An authoring verb that gets rejected, either by the engine or by the SDK itself before any
round-trip, throws AuthoringError. It carries everything you need to recover, in author
vocabulary.
Fail-closed by design
Section titled “Fail-closed by design”A failed run writes nothing. Verbs only schedule directives against the engine’s staging
tree; disk writes happen at Commit, after your factory returns. A thrown
AuthoringError (or any error) before return therefore leaves the target project exactly as it was.
There is no partial-write state to clean up, which is why the verbs themselves are strict:
create rejects an existing path, replaceContent rejects a missing one, and overwrites
require an explicit { force: true }. See
Mutation verbs for each verb’s exact semantics.
How errors surface
Section titled “How errors surface”Calling the runner directly throws; runFactoryForTest captures the same AuthoringError
on result.error:
import { runFactoryForTest } from "@pbuilder/sdk/testing";import { AuthoringError } from "@pbuilder/sdk/commons";
const result = await runFactoryForTest(run, input);if (result.error instanceof AuthoringError) { switch (result.error.reason) { case "path-collision": console.error(`${result.error.verb} collided at ${result.error.path}`); break; default: console.error(result.error.message); }}The testing guide covers the harness itself.
When a factory throws a plain Error
Section titled “When a factory throws a plain Error”Not every failure is an AuthoringError. When your factory throws an ordinary Error
(throw new Error(...), or an error from a library it calls), the runner prints its message on
stderr as pbuilder-runner: <message>, cut off at 2,000 characters. Before printing, it
rewrites the absolute paths in the message so they don’t reveal where things live on your
machine:
| Path in your message | Shown as |
|---|---|
| Under the project root | <project-root>/..., for example open '<project-root>/src/a.ts' |
Under the @pbuilder/sdk package |
<sdk>/... |
| A Windows, UNC or WSL path | <outside-project> |
| Any other POSIX absolute path | a ../-relative path from the project root |
| A URL | left as it is, except that a file:// URL keeps file:// while its path is rewritten |
So a factory that fails with
ENOENT: no such file or directory, open '/home/ana/acme/src/a.ts'shows up as
pbuilder-runner: ENOENT: no such file or directory, open '<project-root>/src/a.ts'The runner inserts <project-root> and <sdk> on purpose. Seeing one in an error doesn’t
mean your factory or a template is broken.
The rewrite is best-effort and only covers path shapes. Anything else you interpolate into a message, such as an environment variable’s value or a token, is printed as is. Keep secrets out of error messages.
Fields
Section titled “Fields”verb: the author-facing verb whose call was rejected:"create","modify","remove","rename","move","copy", or"copyIn".undefinedfor batch-level rejections that have no single offending call.path: the author-declared, source-side path for the failing call.undefinedwhenverbisundefined.reason: the closed cause of the rejection (see below).origin: derived fromreason:"write-rejected"(the engine refused a write) or"authoring-rejected"(the SDK caught a misuse before any engine round-trip).appliedCount: how many directives applied within the failing run before the offender. It is a diagnostic only. A rejected run discards everything, so this count never promises partial persistence.
The "modify" label quirk. "modify" labels the underlying wire mutation, and is
shared by both .replaceContent() calls (the commons/dialect wholesale replace) and a
dialect handle’s .modify(fn) AST escape hatch. The two calls lower to the same wire
directive, so a rejection on either surfaces as verb: "modify". This is deliberate, not a
stale name left over from a rename. Assert against "modify", never "replaceContent".
reason values
Section titled “reason values”reason is a closed union. Exhaustive switch blocks are expected, and they get a compile
error if a value is missed:
reason |
Meaning |
|---|---|
path-collision |
The target path already exists and { force: true } was not passed. |
path-not-found |
The target path does not exist. |
unrepresentable-content |
The content could not be represented in the engine’s format. |
changes-too-large |
The run’s total change size exceeds the engine’s cap. |
outside-session |
An authoring verb was called outside an active session. |
unknown |
The rejection could not be classified. |
invalid-input |
The SDK rejected a call’s arguments before any engine round-trip. |
reserved-name |
The call used a name reserved by the SDK or engine. |
source-not-found |
A package-local source (scaffold/copyIn/create({ templateFile })) does not exist. |
source-not-regular-file |
A package-local source is not a regular file. |
source-unreadable |
A package-local source exists but could not be read. |
source-outside-package was removed from @pbuilder/sdk. The SDK no longer
re-derives a containment ceiling for package-local sources. Migration: drop the
case "source-outside-package": arm from any exhaustive switch (err.reason). TypeScript
will point at it.
Catching and recovering
Section titled “Catching and recovering”This is the same shape as above, now exhaustive, with a case for every reason value:
switch (err.reason) { case "path-collision": console.error(`${err.verb} collided at ${err.path}`); break; case "path-not-found": case "unrepresentable-content": case "changes-too-large": case "outside-session": case "unknown": case "invalid-input": case "reserved-name": case "source-not-found": case "source-not-regular-file": case "source-unreadable": console.error(err.message); break;}Writing good failure paths
Section titled “Writing good failure paths”Write factories that treat rejection as an expected outcome:
- Prevent
path-collisionby reading first. The most common rejection is a re-run hitting a file the previous run created. Branch onfind().read()’s trichotomy (create when absent, update when present) instead of reaching for{ force: true }, which turns every re-run into a silent overwrite. - Pass
force: trueonly when overwriting is the intent. Fail-closed collisions are the SDK protecting earlier output; forcing them away removes that protection for this run and every future one. - Let unexpected states fail loud. A rejected run discards everything and leaves the tree untouched, so throwing is always safe. A factory that detects a state it doesn’t understand should throw rather than guess.
- Switch exhaustively on
reason. The closed union means the compiler tells you when a new SDK version adds (or removes) a rejection cause, as thesource-outside-packageremoval above shows. - In tests, assert on the structured fields.
result.error.verb,.path, and.reasonpin down which directive failed and why, which makes them far more mutation-resistant than matching onmessagetext. Remember the"modify"label quirk when the failing call is areplaceContent.
Next steps
Section titled “Next steps”- Mutation verbs: the seven authoring verbs and the read-trichotomy rule.
- Pending directives: preview a factory’s planned changes before anything commits.