Skip to content

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.

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.

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.

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.

  • verb: the author-facing verb whose call was rejected: "create", "modify", "remove", "rename", "move", "copy", or "copyIn". undefined for batch-level rejections that have no single offending call.
  • path: the author-declared, source-side path for the failing call. undefined when verb is undefined.
  • reason: the closed cause of the rejection (see below).
  • origin: derived from reason: "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.

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.

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;
}

Write factories that treat rejection as an expected outcome:

  • Prevent path-collision by reading first. The most common rejection is a re-run hitting a file the previous run created. Branch on find().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: true only 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 the source-outside-package removal above shows.
  • In tests, assert on the structured fields. result.error.verb, .path, and .reason pin down which directive failed and why, which makes them far more mutation-resistant than matching on message text. Remember the "modify" label quirk when the failing call is a replaceContent.