From ca419382433152dcd8dfb292ddd71e574fc8e8ba Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 13 Jul 2026 12:28:04 -0700 Subject: [PATCH 1/7] docs(spec+plan): error-code registry --- .../plans/2026-07-12-error-code-registry.md | 113 ++++++++++++++++++ .../2026-07-12-error-code-registry-design.md | 105 ++++++++++++++++ 2 files changed, 218 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-12-error-code-registry.md create mode 100644 docs/superpowers/specs/2026-07-12-error-code-registry-design.md diff --git a/docs/superpowers/plans/2026-07-12-error-code-registry.md b/docs/superpowers/plans/2026-07-12-error-code-registry.md new file mode 100644 index 00000000..e71600e5 --- /dev/null +++ b/docs/superpowers/plans/2026-07-12-error-code-registry.md @@ -0,0 +1,113 @@ +# Error-code registry — implementation plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax. Follow TDD. + +**Goal:** Stable numeric `DAWN_Exxxx` error codes with optional `docsPath`, surfaced on CLI / HTTP-SSE / tool-result channels, plus a generated `/docs/errors` page and a drift guard. Wire the ~10 highest-value families. + +**Architecture:** A frozen registry in `@dawn-ai/sdk` (the leaf dep). `CliError` and the error surfaces carry an optional `code`; formatters append the docs link. Codes are a TS literal union so producers can't invent them. + +**Spec:** `docs/superpowers/specs/2026-07-12-error-code-registry-design.md` + +**Conventions:** `src`→`.js` imports, `test`→`.ts`; `exactOptionalPropertyTypes` → conditional-spread; `pnpm --filter lint`; changeset **patch**. + +--- + +## Task 1: The registry module (`@dawn-ai/sdk`) + +**Files:** +- Create: `packages/sdk/src/errors.ts` +- Modify: `packages/sdk/src/index.ts` (export) +- Test: `packages/sdk/test/errors.test.ts` + +- [ ] **Step 1: Failing test** — `errors.test.ts`: every descriptor's `code` matches `/^DAWN_E\d{4}$/`, codes are unique, every `docsPath` (when present) matches `/^\/docs\/[a-z0-9-]+(#[a-z0-9-]+)?$/`; `describeError(code)` returns the descriptor; `errorDocsUrl(code)` returns `https://dawnai.org` or `undefined`. +- [ ] **Step 2: Run → fail** (`pnpm --filter @dawn-ai/sdk test errors`). +- [ ] **Step 3: Implement** `errors.ts`: + +```ts +export interface DawnErrorDescriptor { + readonly code: `DAWN_E${number}` + readonly title: string + readonly docsPath?: string +} +const DOCS_BASE = "https://dawnai.org" +// Ranges: E1xxx config/check · E2xxx sandbox · E3xxx permissions · E4xxx model/provider · E5xxx runtime/import +export const DAWN_ERRORS = { + DAWN_E1001: { code: "DAWN_E1001", title: "Invalid tool scope", docsPath: "/docs/tools" }, + DAWN_E1002: { code: "DAWN_E1002", title: "Invalid sandbox config", docsPath: "/docs/sandbox" }, + DAWN_E1003: { code: "DAWN_E1003", title: "Unknown build target", docsPath: "/docs/deployment" }, + DAWN_E2001: { code: "DAWN_E2001", title: "Sandbox unavailable", docsPath: "/docs/sandbox" }, + DAWN_E2002: { code: "DAWN_E2002", title: "Sandbox preflight failed", docsPath: "/docs/sandbox" }, + DAWN_E3001: { code: "DAWN_E3001", title: "Permission denied", docsPath: "/docs/permissions" }, + DAWN_E4001: { code: "DAWN_E4001", title: "Model provider package missing", docsPath: "/docs/configuration" }, + DAWN_E4002: { code: "DAWN_E4002", title: "Unknown model id", docsPath: "/docs/configuration" }, + DAWN_E5001: { code: "DAWN_E5001", title: "Import or export mismatch" }, + DAWN_E5002: { code: "DAWN_E5002", title: "Tool file has the wrong shape", docsPath: "/docs/tools" }, +} as const satisfies Record + +export type DawnErrorCode = keyof typeof DAWN_ERRORS +export function describeError(code: DawnErrorCode): DawnErrorDescriptor { return DAWN_ERRORS[code] } +export function errorDocsUrl(code: DawnErrorCode, base = DOCS_BASE): string | undefined { + const p = DAWN_ERRORS[code].docsPath + return p ? `${base}${p}` : undefined +} +``` + IMPORTANT: before finalizing each `docsPath`, confirm the target `/docs/` page exists (`ls apps/web/content/docs`) and pick a real heading anchor where useful; if no page fits, omit `docsPath` (a code without docs is valid). Adjust the table accordingly (e.g. there may be no `/docs/models` — the spec used `/docs/configuration`; verify). +- [ ] **Step 4: Export** from `packages/sdk/src/index.ts`: `export { DAWN_ERRORS, describeError, errorDocsUrl } from "./errors.js"` + the types. +- [ ] **Step 5: Run → pass**; `pnpm --filter @dawn-ai/sdk typecheck && lint`. +- [ ] **Step 6: Commit** `feat(sdk): DAWN_Exxxx error-code registry`. + +--- + +## Task 2: `CliError` carries a code; CLI prints the docs link + +**Files:** +- Modify: `packages/cli/src/lib/output.ts` (CliError), `packages/cli/src/index.ts` (renderError/run) +- Test: `packages/cli/test/error-code-render.test.ts` + +- [ ] **Step 1: Failing test** — a `new CliError("msg", 1, { code: "DAWN_E2001" })` rendered by the CLI's error path produces `msg` followed by a line containing `[DAWN_E2001]` and `https://dawnai.org/docs/sandbox`; a `CliError` with no code renders exactly `msg` (unchanged). +- [ ] **Step 2: Run → fail.** +- [ ] **Step 3: Implement** — add `readonly code?: DawnErrorCode` to `CliError` (accept in the options arg alongside `cause`). In `index.ts` where a `CliError` is printed (`run()`), after writing `error.message`, if `error.code` is set, write a second line: ` [${error.code}] See ${errorDocsUrl(error.code)}` (omit the URL clause if `errorDocsUrl` is undefined → just ` [${code}]`). Import from `@dawn-ai/sdk`. +- [ ] **Step 4: Run → pass**; full `@dawn-ai/cli` suite green (additive; no existing message assertions should break — verify). +- [ ] **Step 5: Commit** `feat(cli): CliError carries an error code + prints the docs link`. + +--- + +## Task 3: Wire the high-value families to codes + +**Files (modify, add the `code` at each throw/return site):** +- `packages/cli/src/commands/check.ts` — the three `Invalid …` throws → E1001/E1002/E1003. +- `packages/sandbox/src/docker/docker-sandbox.ts` + `kubernetes/kube-sandbox.ts` — "Sandbox unavailable" `Error`s → wrap so the code reaches the user (these are plain `Error`; the CLI catches non-CliError via `renderError`. Simplest: attach `code` where they surface as `CliError`, OR add a `code` field the runtime error body reads. Keep scope tight: for the throw sites that become `CliError` at the CLI boundary, pass the code there). +- `packages/core/src/capabilities/permission-gate.ts` — `GateResult` gains optional `code`; denial `reason` returned as a tool result is prefixed `[DAWN_E3001] `. +- `packages/langchain/src/chat-model-factory.ts` — provider-package-missing `Error` → E4001; unknown-model-id warning → include `[DAWN_E4002]`. +- `packages/cli/src/lib/runtime/tool-discovery.ts` — replace the hardcoded `Docs: https://dawnai.org/docs/tools` with the registry (E5002) so the URL is centralized. + +- [ ] **Step 1:** For each site, add the code (TDD where a unit test exists for that error; otherwise a focused assertion). Keep messages otherwise unchanged. Do NOT attempt all 48 CliError sites — only the families in the registry. +- [ ] **Step 2:** `GateResult` code test: a denied tool op returns a result string beginning `[DAWN_E3001]`. +- [ ] **Step 3:** HTTP/SSE bodies (`packages/cli/src/lib/dev/server-errors.ts` + `agui-handler.ts`): add optional `code?`/`docsUrl?` to the error body shape; populate where a coded error is caught. Additive; existing `kind` retained. Unit test: a body built with a code includes `code`+`docsUrl`. +- [ ] **Step 4:** Full suites green (`sdk`, `cli`, `core`, `sandbox`, `langchain` — run each `--filter` test); typecheck/lint. +- [ ] **Step 5: Commit** `feat: adopt error codes at the high-value failure sites (check/sandbox/permissions/model/tool-discovery)`. + +--- + +## Task 4: Generated `/docs/errors` page + drift guard + +**Files:** +- Create: `scripts/generate-error-docs.mjs`, `apps/web/content/docs/errors.mdx`, `apps/web/app/docs/errors/page.tsx` +- Modify: `apps/web/app/components/docs/nav.ts` (nav entry), `scripts/check-docs.mjs` (guard) +- Test: `packages/sdk/test/errors.test.ts` (extend) or a script test + +- [ ] **Step 1:** `generate-error-docs.mjs` imports `DAWN_ERRORS` (from the built sdk dist or via tsx) and writes `errors.mdx`: a table `Code | Meaning | Docs`. Model on `scripts/generate-docs.mjs`. Add an npm script if the repo generates docs in CI, or commit the output + guard it's in sync. +- [ ] **Step 2:** `errors.mdx` + `page.tsx` wrapper + nav entry (new MDX pages REQUIRE the `page.tsx` under `apps/web/app/docs//` and a `nav.ts` entry or `check-docs.mjs` topology fails — see the AGENTS.md/docs conventions). +- [ ] **Step 3:** Extend `check-docs.mjs` (or a sibling `check-error-docs.mjs` wired into the `validate` lane) to assert: every registry `docsPath` resolves to a real `/docs/` (reuse `packages/cli/src/lib/docs-bundle.ts` nav parsing), and `/docs/errors` lists exactly the registry's codes (fails on drift). +- [ ] **Step 4:** `node scripts/check-docs.mjs` + the new guard pass; `pnpm --filter @dawn-ai/web build` renders `/docs/errors`. +- [ ] **Step 5: Commit** `docs: generated /docs/errors reference + drift guard`. + +--- + +## Task 5: Changeset + full verify + PR + +- [ ] **Step 1:** `.changeset/error-code-registry.md` — **patch** for the touched publishable packages (confirm via `git log --oneline origin/main..HEAD --name-only -- packages/ | grep '^packages/' | cut -d/ -f2 | sort -u`; expect `sdk`, `cli`, `core`, `sandbox`, `langchain`). +- [ ] **Step 2:** Full local verify: `pnpm build && pnpm typecheck && pnpm lint && pnpm test && node scripts/check-docs.mjs && pnpm pack:check`. +- [ ] **Step 3:** Rebase on `origin/main`, push, open PR, watch `validate` + review; fix findings. + +**Notes:** Branch e.g. `feat/error-code-registry`; pin before subagent dispatch. Keep every message-text change additive so existing snapshot/assertion tests stay green. diff --git a/docs/superpowers/specs/2026-07-12-error-code-registry-design.md b/docs/superpowers/specs/2026-07-12-error-code-registry-design.md new file mode 100644 index 00000000..57c4e4ce --- /dev/null +++ b/docs/superpowers/specs/2026-07-12-error-code-registry-design.md @@ -0,0 +1,105 @@ +# Error-code registry — design + +**Date:** 2026-07-12 +**Status:** approved (brainstorm) +**Topic:** A central registry of stable numeric `DAWN_Exxxx` error codes with optional `docsPath`, so user-facing failures become linkable, searchable, and self-documenting across all three error surfaces (CLI, HTTP/SSE, tool results). + +## Problem + +Today a Dawn failure carries only a free-text `message` and (for `CliError`) an `exitCode` of 1 or 2. There is no stable, machine-readable identifier and no consistent path to docs. Consequences: +- A user who hits `Sandbox unavailable: docker run failed …` or `Invalid tool scope: …` has nothing to search or link. +- One error already hardcodes `Docs: https://dawnai.org/docs/tools` (`tool-discovery.ts`) — proving the value, but inconsistently and with a divergent base URL. +- The three surfaces errors reach users through are unaligned: CLI stderr (`CliError`), HTTP/SSE bodies (`server-errors.ts` has a 2-value `kind` enum), and **tool-result strings** (permission `GateResult.reason` is returned to the model as the tool result). + +## Goal + +A single source of truth mapping a stable **numeric code** → `{ title, docsPath? }`, importable by every package that produces user-facing errors, surfaced on all three channels, rendered into a generated `/docs/errors` reference page, and guarded so codes and their doc links can't rot. + +**Decisions locked:** numeric codes (`DAWN_E1001`-style); wire the ~10 highest-value families now (not all ~48 sites); incremental adoption after. + +## Non-goals + +- Not coding every `CliError` usage/argument error (exit-code-2 sites stay as-is for now). +- Not changing exit codes or error-handling control flow. +- Not internationalization; `title` is English. +- Not a stack-trace/telemetry system. + +## Architecture + +### The registry — `packages/sdk/src/errors.ts` + +`sdk` is the leaf that `core`, `langchain`, and `cli` all depend on (no reverse deps), so a registry here is importable everywhere without cycles. + +```ts +export interface DawnErrorDescriptor { + readonly code: `DAWN_E${number}` + readonly title: string // stable, short, human-readable + readonly docsPath?: string // "/docs/#" convention; optional +} + +// Frozen registry. Numeric ranges by category: +// E1xxx config / dawn check E2xxx sandbox +// E3xxx permissions E4xxx model / provider +// E5xxx runtime / import +export const DAWN_ERRORS = { /* code → descriptor */ } as const + +export type DawnErrorCode = keyof typeof DAWN_ERRORS +export function describeError(code: DawnErrorCode): DawnErrorDescriptor +export function errorDocsUrl(code: DawnErrorCode, base?: string): string | undefined +``` + +`docsPath` uses the existing `/docs/#` convention; the printed link uses the canonical base `https://dawnai.org` (centralized here — replaces the hardcoded URL in `tool-discovery.ts`). + +### The ~10 high-value families to wire (initial set) + +| Code | Family | Origin today | docsPath | +| --- | --- | --- | --- | +| E1001 | Invalid tool scope | `check.ts` / `collect-tool-scope-errors.ts` | `/docs/tools#scoping` | +| E1002 | Invalid sandbox config | `collect-sandbox-errors.ts` | `/docs/sandbox#config` | +| E1003 | Unknown build target | `check.ts` build-target validation | `/docs/deployment` | +| E2001 | Sandbox unavailable (provider) | `docker-sandbox.ts` / `kube-sandbox.ts` | `/docs/sandbox#what-it-is--and-isnt` | +| E2002 | Sandbox preflight failed | `collect-sandbox-errors.ts` | `/docs/sandbox#quickstart` | +| E3001 | Permission denied (HITL) | `permission-gate.ts` (`GateResult.reason`) | `/docs/permissions` | +| E4001 | Model provider package missing | `chat-model-factory.ts` | `/docs/models` (or configuration) | +| E4002 | Unknown model id (advisory warn) | `warn-unknown-model-ids` / factory | `/docs/models` | +| E5001 | Import/export mismatch | `diagnostics.ts` / `import-module.ts` | `/docs/troubleshooting` | +| E5002 | Tool file wrong shape | `tool-discovery.ts` (already links docs) | `/docs/tools` | + +(Exact codes/anchors finalized in the plan against real heading anchors; any missing docs anchor is either added or the `docsPath` omitted — a code without docs is valid.) + +### Surfacing on the three channels + +1. **CLI** — extend `CliError` (`packages/cli/src/lib/output.ts`) with an optional `code?: DawnErrorCode`. `run()`/`renderError` (`index.ts`) append a final line when a code is present: ` [DAWN_E2001] See https://dawnai.org/docs/sandbox#…`. Message text is unchanged; the code line is additive. Non-`CliError` throwables are unaffected. +2. **HTTP/SSE** — `server-errors.ts` error bodies and the AG-UI handler's `{error:{kind,message}}` gain optional `code?` + `docsUrl?` fields (additive; existing `kind` retained). +3. **Tool results** — the permission `GateResult` (`permission-gate.ts`) gains an optional `code`; the denial string returned as the tool result gets a `[DAWN_E3001]` prefix so a denial is machine-identifiable in a transcript. (Sandbox fs/exec `throw`s can adopt codes incrementally.) + +### Generated docs page + guard + +- **`/docs/errors`** (`apps/web/content/docs/errors.mdx` + `page.tsx` wrapper + nav entry): a generated table of every registry code → title → docs link. Generation script `scripts/generate-error-docs.mjs` reads `DAWN_ERRORS` and writes the MDX table (run in build or committed + checked). Model on `scripts/generate-docs.mjs` (the existing generated-docs pattern). +- **check-docs guard**: extend `scripts/check-docs.mjs` (or a sibling) to assert every registry `docsPath` resolves to a real `/docs/` page (reuse `docs-bundle.ts`'s nav parsing) and that `/docs/errors` lists exactly the registry's codes. Prevents code/doc drift. + +## Data flow + +Producer imports `DAWN_ERRORS`/`DawnErrorCode` from `@dawn-ai/sdk` → constructs the error with a `code` (CliError, error body, or GateResult) → the surface's formatter appends the doc link from the descriptor. The registry is the only place codes and doc links are defined. + +## Error handling / edge cases + +- A code with no `docsPath` prints just `[DAWN_E4002]` (still searchable). +- `describeError` on an unknown code is a compile error (codes are a literal union), so producers can't invent codes. +- `NO_COLOR`/non-TTY: the appended link line is plain text (no styling assumptions). + +## Testing + +- Unit (`packages/sdk`): the registry is internally consistent (unique codes, valid `DAWN_E` format, `docsPath` matches `/docs/…#…` shape). +- Unit (`cli`): a `CliError` with a code renders the `[CODE] See ` line; without a code renders unchanged (snapshot of `renderError`). +- Unit: an HTTP/SSE error body includes `code`/`docsUrl` when constructed with a code; a `GateResult` denial string is prefixed with its code. +- `scripts/check-docs.mjs` guard: fails if a `docsPath` points at a nonexistent page or `/docs/errors` drifts from the registry (fixture test). +- No behavior change for any existing test that asserts on message text (codes are additive lines) — verify the full `cli` suite stays green. + +## Rollout + +One PR. Changeset: **patch** for `@dawn-ai/sdk`, `@dawn-ai/cli`, `@dawn-ai/core` (+ any surface package touched). Additive and backward-compatible; the foundation for `dawn verify` (spec: verify-env-preflight) to emit codes. + +## Build order note + +This is the foundation the `verify` preflight builds on (its FAIL results can carry E-codes), so build it before verify-env-preflight; independent of AGENTS.md. From 29d5325c536c8f6d71eabb3d14462ab528bf241b Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 13 Jul 2026 12:31:59 -0700 Subject: [PATCH 2/7] feat(sdk): DAWN_Exxxx error-code registry Co-Authored-By: Claude Opus 4.8 --- packages/sdk/src/errors.ts | 97 ++++++++++++++++++++++++++++++++ packages/sdk/src/index.ts | 2 + packages/sdk/test/errors.test.ts | 67 ++++++++++++++++++++++ 3 files changed, 166 insertions(+) create mode 100644 packages/sdk/src/errors.ts create mode 100644 packages/sdk/test/errors.test.ts diff --git a/packages/sdk/src/errors.ts b/packages/sdk/src/errors.ts new file mode 100644 index 00000000..844102ab --- /dev/null +++ b/packages/sdk/src/errors.ts @@ -0,0 +1,97 @@ +/** + * The Dawn error-code registry. + * + * A single, frozen source of truth mapping a stable numeric code + * (`DAWN_Exxxx`) to a short human-readable `title` and an optional `docsPath`. + * Producers across every package import codes from here so a failure becomes + * linkable, searchable, and self-documenting on all three surfaces (CLI + * stderr, HTTP/SSE bodies, and tool-result strings). + * + * Numeric ranges by category: + * E1xxx config / `dawn check` + * E2xxx sandbox + * E3xxx permissions + * E4xxx model / provider + * E5xxx runtime / import + */ + +export interface DawnErrorDescriptor { + /** Stable machine-readable identifier, e.g. `DAWN_E2001`. */ + readonly code: `DAWN_E${number}` + /** Stable, short, human-readable English title. */ + readonly title: string + /** `/docs/#` convention; optional (a code without docs is valid). */ + readonly docsPath?: string +} + +/** Canonical docs base for rendered error links. */ +const DOCS_BASE = "https://dawnai.org" + +export const DAWN_ERRORS = { + DAWN_E1001: { + code: "DAWN_E1001", + title: "Invalid tool scope", + docsPath: "/docs/tools#scoping-a-routes-tools", + }, + DAWN_E1002: { + code: "DAWN_E1002", + title: "Invalid sandbox config", + docsPath: "/docs/configuration#sandbox", + }, + DAWN_E1003: { + code: "DAWN_E1003", + title: "Unknown build target", + docsPath: "/docs/deployment", + }, + DAWN_E2001: { + code: "DAWN_E2001", + title: "Sandbox unavailable", + docsPath: "/docs/sandbox#what-it-is--and-isnt", + }, + DAWN_E2002: { + code: "DAWN_E2002", + title: "Sandbox preflight failed", + docsPath: "/docs/sandbox#quickstart", + }, + DAWN_E3001: { + code: "DAWN_E3001", + title: "Permission denied", + docsPath: "/docs/permissions", + }, + DAWN_E4001: { + code: "DAWN_E4001", + title: "Model provider package missing", + docsPath: "/docs/configuration", + }, + DAWN_E4002: { + code: "DAWN_E4002", + title: "Unknown model id", + docsPath: "/docs/configuration", + }, + DAWN_E5001: { + code: "DAWN_E5001", + title: "Import or export mismatch", + }, + DAWN_E5002: { + code: "DAWN_E5002", + title: "Tool file has the wrong shape", + docsPath: "/docs/tools", + }, +} as const satisfies Record + +/** The union of all registered error codes. Producers cannot invent codes. */ +export type DawnErrorCode = keyof typeof DAWN_ERRORS + +/** Look up the descriptor for a registered code. */ +export function describeError(code: DawnErrorCode): DawnErrorDescriptor { + return DAWN_ERRORS[code] +} + +/** + * The canonical docs URL for a code, or `undefined` when the code has no + * `docsPath` (still a valid, searchable code). + */ +export function errorDocsUrl(code: DawnErrorCode, base = DOCS_BASE): string | undefined { + const path = describeError(code).docsPath + return path ? `${base}${path}` : undefined +} diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 7fd4c5f5..1a456087 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -10,6 +10,8 @@ export type { } from "./agent.js" export { agent, isDawnAgent } from "./agent.js" export type { BackendAdapter } from "./backend-adapter.js" +export type { DawnErrorCode, DawnErrorDescriptor } from "./errors.js" +export { DAWN_ERRORS, describeError, errorDocsUrl } from "./errors.js" export type { AnthropicModelId, GoogleModelId, diff --git a/packages/sdk/test/errors.test.ts b/packages/sdk/test/errors.test.ts new file mode 100644 index 00000000..ffbb995e --- /dev/null +++ b/packages/sdk/test/errors.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest" + +import { DAWN_ERRORS, type DawnErrorCode, describeError, errorDocsUrl } from "../src/errors.js" + +const CODE_RE = /^DAWN_E\d{4}$/ +const DOCS_PATH_RE = /^\/docs\/[a-z0-9-]+(#[a-z0-9-]+)?$/ + +describe("DAWN_ERRORS registry", () => { + const entries = Object.entries(DAWN_ERRORS) + + it("has at least the wired families", () => { + expect(entries.length).toBeGreaterThanOrEqual(10) + }) + + it("every descriptor code matches DAWN_E\\d{4} and equals its key", () => { + for (const [key, descriptor] of entries) { + expect(descriptor.code).toMatch(CODE_RE) + expect(descriptor.code).toBe(key) + } + }) + + it("codes are unique", () => { + const codes = entries.map(([, d]) => d.code) + expect(new Set(codes).size).toBe(codes.length) + }) + + it("every descriptor has a non-empty title", () => { + for (const [, descriptor] of entries) { + expect(descriptor.title.length).toBeGreaterThan(0) + } + }) + + it("every docsPath (when present) matches the /docs/# shape", () => { + for (const [, descriptor] of entries) { + if (descriptor.docsPath !== undefined) { + expect(descriptor.docsPath).toMatch(DOCS_PATH_RE) + } + } + }) +}) + +describe("describeError", () => { + it("returns the descriptor for a code", () => { + expect(describeError("DAWN_E2001")).toBe(DAWN_ERRORS.DAWN_E2001) + }) +}) + +describe("errorDocsUrl", () => { + it("returns the canonical URL when the code has a docsPath", () => { + const url = errorDocsUrl("DAWN_E2001") + expect(url).toBe("https://dawnai.org/docs/sandbox#what-it-is--and-isnt") + }) + + it("returns undefined for a code without a docsPath", () => { + const codeWithoutDocs = Object.values(DAWN_ERRORS).find((d) => d.docsPath === undefined) + expect(codeWithoutDocs).toBeDefined() + if (codeWithoutDocs) { + expect(errorDocsUrl(codeWithoutDocs.code as DawnErrorCode)).toBeUndefined() + } + }) + + it("honors a custom base", () => { + expect(errorDocsUrl("DAWN_E2001", "https://example.test")).toBe( + "https://example.test/docs/sandbox#what-it-is--and-isnt", + ) + }) +}) From 4fc10b546ae16609e4f8e13f2976c42d555d2a79 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 13 Jul 2026 12:34:47 -0700 Subject: [PATCH 3/7] feat(cli): CliError carries an error code + prints the docs link Co-Authored-By: Claude Opus 4.8 --- packages/cli/src/index.ts | 5 +++ packages/cli/src/lib/output.ts | 12 ++++- packages/cli/test/error-code-render.test.ts | 50 +++++++++++++++++++++ 3 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 packages/cli/test/error-code-render.test.ts diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index fae1b82b..07760965 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -11,6 +11,7 @@ import { realpathSync } from "node:fs" import { resolve } from "node:path" import { fileURLToPath } from "node:url" +import { errorDocsUrl } from "@dawn-ai/sdk" import { Command, CommanderError } from "commander" import { registerAddCommand } from "./commands/add.js" @@ -82,6 +83,10 @@ export async function run( } catch (error) { if (error instanceof CliError) { writeLine(io.stderr, error.message) + if (error.code !== undefined) { + const url = errorDocsUrl(error.code) + writeLine(io.stderr, url ? ` [${error.code}] See ${url}` : ` [${error.code}]`) + } return error.exitCode } diff --git a/packages/cli/src/lib/output.ts b/packages/cli/src/lib/output.ts index 68807086..733ade06 100644 --- a/packages/cli/src/lib/output.ts +++ b/packages/cli/src/lib/output.ts @@ -4,13 +4,23 @@ export interface CommandIo { readonly stderr: (message: string) => void } +import type { DawnErrorCode } from "@dawn-ai/sdk" + export class CliError extends Error { readonly exitCode: number + readonly code?: DawnErrorCode - constructor(message: string, exitCode = 1, options?: { readonly cause?: unknown }) { + constructor( + message: string, + exitCode = 1, + options?: { readonly cause?: unknown; readonly code?: DawnErrorCode }, + ) { super(message, options) this.name = "CliError" this.exitCode = exitCode + if (options?.code !== undefined) { + this.code = options.code + } } } diff --git a/packages/cli/test/error-code-render.test.ts b/packages/cli/test/error-code-render.test.ts new file mode 100644 index 00000000..54da62f2 --- /dev/null +++ b/packages/cli/test/error-code-render.test.ts @@ -0,0 +1,50 @@ +import { Command } from "commander" +import { afterEach, describe, expect, it, vi } from "vitest" + +import { run } from "../src/index.js" +import { CliError } from "../src/lib/output.js" + +async function runWithError(error: unknown): Promise<{ code: number; stderr: string }> { + vi.spyOn(Command.prototype, "parseAsync").mockRejectedValue(error) + const stderr: string[] = [] + const code = await run([], { + stderr: (message) => { + stderr.push(message) + }, + stdin: async () => "", + stdout: () => {}, + }) + return { code, stderr: stderr.join("") } +} + +describe("CliError with an error code", () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it("renders the message followed by a [CODE] line with the docs URL", async () => { + const { code, stderr } = await runWithError( + new CliError("Sandbox unavailable: docker run failed", 1, { code: "DAWN_E2001" }), + ) + expect(code).toBe(1) + expect(stderr).toContain("Sandbox unavailable: docker run failed") + expect(stderr).toContain("[DAWN_E2001]") + expect(stderr).toContain("https://dawnai.org/docs/sandbox#what-it-is--and-isnt") + // The code line comes after the message. + expect(stderr.indexOf("[DAWN_E2001]")).toBeGreaterThan(stderr.indexOf("Sandbox unavailable")) + }) + + it("renders just the [CODE] when the code has no docsPath", async () => { + const { stderr } = await runWithError( + new CliError("Import mismatch", 1, { code: "DAWN_E5001" }), + ) + expect(stderr).toContain("Import mismatch") + expect(stderr).toContain("[DAWN_E5001]") + expect(stderr).not.toContain("See https://") + }) + + it("renders exactly the message when there is no code (unchanged)", async () => { + const { stderr } = await runWithError(new CliError("plain boom")) + expect(stderr).toBe("plain boom\n") + }) +}) From 8f7df6fe18946df981c785d99f102b6206c3f0a1 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 13 Jul 2026 12:49:09 -0700 Subject: [PATCH 4/7] feat: adopt error codes at the high-value failure sites (check/sandbox/permissions/model/tool-discovery) Co-Authored-By: Claude Opus 4.8 --- packages/cli/src/commands/check.ts | 10 ++- packages/cli/src/lib/dev/runtime-server.ts | 17 ++++- packages/cli/src/lib/dev/server-errors.ts | 44 +++++++++-- .../cli/src/lib/runtime/tool-discovery.ts | 12 ++- packages/cli/test/check-error-codes.test.ts | 75 +++++++++++++++++++ packages/cli/test/server-errors.test.ts | 33 ++++++++ .../core/src/capabilities/permission-gate.ts | 34 +++++++-- .../test/capabilities/permission-gate.test.ts | 17 +++++ packages/langchain/src/chat-model-factory.ts | 8 +- packages/sandbox/package.json | 1 + packages/sandbox/src/docker/docker-sandbox.ts | 5 +- packages/sandbox/src/errors.ts | 17 +++++ .../sandbox/src/kubernetes/kube-sandbox.ts | 11 ++- .../sandbox/test/sandbox-error-code.test.ts | 25 +++++++ pnpm-lock.yaml | 3 + 15 files changed, 282 insertions(+), 30 deletions(-) create mode 100644 packages/cli/test/check-error-codes.test.ts create mode 100644 packages/cli/test/server-errors.test.ts create mode 100644 packages/sandbox/src/errors.ts create mode 100644 packages/sandbox/test/sandbox-error-code.test.ts diff --git a/packages/cli/src/commands/check.ts b/packages/cli/src/commands/check.ts index f01744b0..99f7d0ab 100644 --- a/packages/cli/src/commands/check.ts +++ b/packages/cli/src/commands/check.ts @@ -51,7 +51,9 @@ export async function runCheckCommand(options: CheckOptions, io: CommandIo): Pro writeLine(io.stdout, `\n${warning}`) } if (scopeIssues.errors.length > 0) { - throw new CliError(`Invalid tool scope:\n${scopeIssues.errors.join("\n")}`) + throw new CliError(`Invalid tool scope:\n${scopeIssues.errors.join("\n")}`, 1, { + code: "DAWN_E1001", + }) } let loadedConfig: Pick = {} @@ -69,6 +71,8 @@ export async function runCheckCommand(options: CheckOptions, io: CommandIo): Pro if (unknown.length > 0) { throw new CliError( `Invalid build config:\nUnknown build target(s): ${unknown.join(", ")}. Known targets: ${known.join(", ")}.`, + 1, + { code: "DAWN_E1003" }, ) } } @@ -77,7 +81,9 @@ export async function runCheckCommand(options: CheckOptions, io: CommandIo): Pro await collectSandboxErrors(loadedConfig) for (const w of sandboxWarnings) console.warn(`⚠ sandbox: ${w}`) if (sandboxErrors.length > 0) { - throw new CliError(`Invalid sandbox config:\n${sandboxErrors.join("\n")}`) + throw new CliError(`Invalid sandbox config:\n${sandboxErrors.join("\n")}`, 1, { + code: "DAWN_E1002", + }) } } catch (error) { if (error instanceof CliError) throw error diff --git a/packages/cli/src/lib/dev/runtime-server.ts b/packages/cli/src/lib/dev/runtime-server.ts index c109ceb2..c2f59901 100644 --- a/packages/cli/src/lib/dev/runtime-server.ts +++ b/packages/cli/src/lib/dev/runtime-server.ts @@ -22,7 +22,11 @@ import { } from "./memory-handler.js" import { loadMiddleware, runMiddleware } from "./middleware.js" import { createRuntimeRegistry, type RuntimeRegistry } from "./runtime-registry.js" -import { createExecutionErrorBody, createRequestErrorBody } from "./server-errors.js" +import { + createExecutionErrorBody, + createRequestErrorBody, + dawnErrorCodeOf, +} from "./server-errors.js" export interface RuntimeServer { readonly close: () => Promise @@ -125,7 +129,16 @@ export async function createRuntimeRequestListener( return } - sendJson(response, 500, createExecutionErrorBody("Unexpected runtime server failure")) + const code = dawnErrorCodeOf(error) + sendJson( + response, + 500, + createExecutionErrorBody( + "Unexpected runtime server failure", + undefined, + code ? { code } : undefined, + ), + ) } finally { state.activeRequests-- } diff --git a/packages/cli/src/lib/dev/server-errors.ts b/packages/cli/src/lib/dev/server-errors.ts index cd70f6d5..c42e4e94 100644 --- a/packages/cli/src/lib/dev/server-errors.ts +++ b/packages/cli/src/lib/dev/server-errors.ts @@ -1,3 +1,5 @@ +import { DAWN_ERRORS, type DawnErrorCode, errorDocsUrl } from "@dawn-ai/sdk" + export type RuntimeServerErrorKind = "request_error" | "execution_error" export interface RuntimeServerErrorBody { @@ -5,31 +7,57 @@ export interface RuntimeServerErrorBody { readonly kind: RuntimeServerErrorKind readonly message: string readonly details?: Record + readonly code?: DawnErrorCode + readonly docsUrl?: string } } -export function createRequestErrorBody( +interface ErrorBodyOptions { + readonly code?: DawnErrorCode +} + +function buildBody( + kind: RuntimeServerErrorKind, message: string, details?: Record, + options?: ErrorBodyOptions, ): RuntimeServerErrorBody { + const code = options?.code + const docsUrl = code ? errorDocsUrl(code) : undefined return { error: { ...(details ? { details } : {}), - kind: "request_error", + ...(code ? { code } : {}), + ...(docsUrl ? { docsUrl } : {}), + kind, message, }, } } +export function createRequestErrorBody( + message: string, + details?: Record, + options?: ErrorBodyOptions, +): RuntimeServerErrorBody { + return buildBody("request_error", message, details, options) +} + export function createExecutionErrorBody( message: string, details?: Record, + options?: ErrorBodyOptions, ): RuntimeServerErrorBody { - return { - error: { - ...(details ? { details } : {}), - kind: "execution_error", - message, - }, + return buildBody("execution_error", message, details, options) +} + +/** Read a Dawn error code off a caught error, if it carries a real registry code. */ +export function dawnErrorCodeOf(error: unknown): DawnErrorCode | undefined { + if (error && typeof error === "object" && "code" in error) { + const code = (error as { code?: unknown }).code + if (typeof code === "string" && code in DAWN_ERRORS) { + return code as DawnErrorCode + } } + return undefined } diff --git a/packages/cli/src/lib/runtime/tool-discovery.ts b/packages/cli/src/lib/runtime/tool-discovery.ts index 2414eac8..0c9605a8 100644 --- a/packages/cli/src/lib/runtime/tool-discovery.ts +++ b/packages/cli/src/lib/runtime/tool-discovery.ts @@ -3,6 +3,7 @@ import { basename, join } from "node:path" import { pathToFileURL } from "node:url" import type { WorkspaceFs } from "@dawn-ai/sdk" +import { describeError, errorDocsUrl } from "@dawn-ai/sdk" import { importModule } from "./import-module.js" import { registerTsxLoader } from "./register-tsx-loader.js" @@ -128,6 +129,13 @@ export function injectGeneratedSchemas( }) } +/** `[DAWN_E5002] See ` footer, with the docs URL centralized in the registry. */ +function toolShapeDocsFooter(): string { + const code = describeError("DAWN_E5002").code + const url = errorDocsUrl("DAWN_E5002") + return url ? `[${code}] See ${url}` : `[${code}]` +} + async function loadToolDefinition( filePath: string, scope: ToolScope, @@ -179,13 +187,13 @@ async function loadToolDefinition( ` /** Describe what the tool does. */\n` + ` export default async (input: { readonly query: string }) =>\n` + ` search.invoke({ query: input.query })\n\n` + - `Docs: https://dawnai.org/docs/tools`, + toolShapeDocsFooter(), ) } throw new Error( `Tool file ${filePath} must default export a function (got ${describeExport(definition)}).\n` + - `Docs: https://dawnai.org/docs/tools`, + toolShapeDocsFooter(), ) } diff --git a/packages/cli/test/check-error-codes.test.ts b/packages/cli/test/check-error-codes.test.ts new file mode 100644 index 00000000..cedc28a2 --- /dev/null +++ b/packages/cli/test/check-error-codes.test.ts @@ -0,0 +1,75 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { dirname, join } from "node:path" +import { afterEach, describe, expect, test } from "vitest" + +import { run } from "../src/index.js" + +const tempDirs: string[] = [] + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { force: true, recursive: true }))) +}) + +const VALID_ROUTE = `import type { RuntimeContext } from "@dawn-ai/sdk" +export async function workflow(_input: unknown, _ctx: RuntimeContext) { + return {} +} +` + +async function createFixtureApp(files: Readonly>) { + const appRoot = await mkdtemp(join(tmpdir(), "dawn-cli-check-codes-")) + tempDirs.push(appRoot) + const appFiles = { + "package.json": '{"type":"module"}\n', + "dawn.config.ts": "export default {};\n", + "src/app/hello/index.ts": VALID_ROUTE, + ...files, + } + await Promise.all( + Object.entries(appFiles).map(async ([relativePath, source]) => { + const filePath = join(appRoot, relativePath) + await mkdir(dirname(filePath), { recursive: true }) + await writeFile(filePath, source) + }), + ) + return appRoot +} + +async function invoke(argv: readonly string[]) { + const stdout: string[] = [] + const stderr: string[] = [] + const exitCode = await run([...argv], { + stderr: (message: string) => { + stderr.push(message) + }, + stdout: (message: string) => { + stdout.push(message) + }, + }) + return { exitCode, stderr: stderr.join(""), stdout: stdout.join("") } +} + +describe("dawn check emits error codes", () => { + test("unknown build target → [DAWN_E1003] with docs link", async () => { + const appRoot = await createFixtureApp({ + "dawn.config.ts": 'export default { build: { targets: ["nonsense"] } };\n', + }) + const result = await invoke(["check", "--cwd", appRoot]) + expect(result.exitCode).toBe(1) + expect(result.stderr).toContain("Invalid build config") + expect(result.stderr).toContain("[DAWN_E1003]") + expect(result.stderr).toContain("https://dawnai.org/docs/deployment") + }) + + test("invalid sandbox config → [DAWN_E1002] with docs link", async () => { + const appRoot = await createFixtureApp({ + "dawn.config.ts": 'export default { sandbox: { provider: { name: "bad" } } };\n', + }) + const result = await invoke(["check", "--cwd", appRoot]) + expect(result.exitCode).toBe(1) + expect(result.stderr).toContain("Invalid sandbox config") + expect(result.stderr).toContain("[DAWN_E1002]") + expect(result.stderr).toContain("https://dawnai.org/docs/configuration#sandbox") + }) +}) diff --git a/packages/cli/test/server-errors.test.ts b/packages/cli/test/server-errors.test.ts new file mode 100644 index 00000000..a7c38431 --- /dev/null +++ b/packages/cli/test/server-errors.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest" + +import { createExecutionErrorBody, createRequestErrorBody } from "../src/lib/dev/server-errors.js" + +describe("server error bodies with an error code", () => { + it("populates code + docsUrl when a coded error is caught", () => { + const body = createExecutionErrorBody("Sandbox unavailable: docker run failed", undefined, { + code: "DAWN_E2001", + }) + expect(body.error.kind).toBe("execution_error") + expect(body.error.code).toBe("DAWN_E2001") + expect(body.error.docsUrl).toBe("https://dawnai.org/docs/sandbox#what-it-is--and-isnt") + }) + + it("omits code + docsUrl when no code is given (unchanged shape)", () => { + const body = createRequestErrorBody("Malformed request body") + expect(body.error.message).toBe("Malformed request body") + expect(body.error).not.toHaveProperty("code") + expect(body.error).not.toHaveProperty("docsUrl") + }) + + it("includes code but omits docsUrl for a code without a docsPath", () => { + const body = createRequestErrorBody("Import mismatch", undefined, { code: "DAWN_E5001" }) + expect(body.error.code).toBe("DAWN_E5001") + expect(body.error).not.toHaveProperty("docsUrl") + }) + + it("retains details alongside a code", () => { + const body = createRequestErrorBody("boom", { thread: "t1" }, { code: "DAWN_E2001" }) + expect(body.error.details).toEqual({ thread: "t1" }) + expect(body.error.code).toBe("DAWN_E2001") + }) +}) diff --git a/packages/core/src/capabilities/permission-gate.ts b/packages/core/src/capabilities/permission-gate.ts index d305734f..9af406e0 100644 --- a/packages/core/src/capabilities/permission-gate.ts +++ b/packages/core/src/capabilities/permission-gate.ts @@ -5,12 +5,19 @@ import { suggestedMemoryPattern, suggestedPathPattern, } from "@dawn-ai/permissions" -import type { ConstraintContext, ConstraintPredicate } from "@dawn-ai/sdk" +import type { ConstraintContext, ConstraintPredicate, DawnErrorCode } from "@dawn-ai/sdk" import { interrupt } from "@langchain/langgraph" export type PathOperation = "readFile" | "writeFile" | "listDir" -export type GateResult = { allowed: true } | { allowed: false; reason: string } +export type GateResult = + | { allowed: true } + | { allowed: false; reason: string; code?: DawnErrorCode } + +/** Prefix a denial reason with its error code when the tool result is returned to the model. */ +function codedReason(gate: { reason: string; code?: DawnErrorCode }): string { + return gate.code ? `[${gate.code}] ${gate.reason}` : gate.reason +} export async function gatePathOp( permissions: PermissionsStore | undefined, @@ -104,10 +111,18 @@ export async function gateToolOp( const decision = permissions.match("tool", toolName) if (decision === "allow") return { allowed: true } if (decision === "deny") { - return { allowed: false, reason: `Permission denied by user: tool ${toolName}` } + return { + allowed: false, + reason: `Permission denied by user: tool ${toolName}`, + code: "DAWN_E3001", + } } if (permissions.mode === "non-interactive") { - return { allowed: false, reason: `Permission denied (fail-closed): tool ${toolName}` } + return { + allowed: false, + reason: `Permission denied (fail-closed): tool ${toolName}`, + code: "DAWN_E3001", + } } if (opts?.interruptCapable === false) { return { @@ -116,6 +131,7 @@ export async function gateToolOp( `Permission denied: tool "${toolName}" requires approval and interactive ` + `permission prompts are not available in this execution context. ` + `Add an allow rule for "tool" to the permissions config in dawn.config.ts.`, + code: "DAWN_E3001", } } const result = await emitPermissionInterrupt({ @@ -125,7 +141,11 @@ export async function gateToolOp( permissions, }) if (result === "deny") { - return { allowed: false, reason: `Permission denied by user: tool ${toolName}` } + return { + allowed: false, + reason: `Permission denied by user: tool ${toolName}`, + code: "DAWN_E3001", + } } return { allowed: true } } @@ -217,7 +237,7 @@ export function wrapToolWithApproval< ...tool, run: async (input: unknown, context: C) => { const gate = await gateToolOp(permissions, tool.name, buildArgsPreview(input), opts) - if (!gate.allowed) return gate.reason + if (!gate.allowed) return codedReason(gate) return tool.run(input, context) }, } @@ -279,7 +299,7 @@ export function wrapToolWithConstraint< (verdict as { approve?: unknown }).approve === true ) { const gate = await gateToolOp(permissions, tool.name, buildArgsPreview(input)) - if (!gate.allowed) return gate.reason + if (!gate.allowed) return codedReason(gate) return tool.run(input, context) } // Any other value (false, undefined, { approve: false }, a number, …) is diff --git a/packages/core/test/capabilities/permission-gate.test.ts b/packages/core/test/capabilities/permission-gate.test.ts index e81103dc..8878133a 100644 --- a/packages/core/test/capabilities/permission-gate.test.ts +++ b/packages/core/test/capabilities/permission-gate.test.ts @@ -188,6 +188,23 @@ describe("wrapToolWithApproval", () => { const wrapped = wrapToolWithApproval({ name: "x", run: async () => "ran" }, permissions) expect(String(await wrapped.run({}, { signal }))).toMatch(/fail-closed/) }) + + it("prefixes the denial tool result with the [DAWN_E3001] code", async () => { + const permissions = createPermissionsStore({ + appRoot, + config: { version: 1, allow: {}, deny: { tool: ["deployProd"] } }, + mode: "interactive", + }) + await permissions.load() + const wrapped = wrapToolWithApproval( + { name: "deployProd", run: async () => "ran" }, + permissions, + ) + const result = String(await wrapped.run({}, { signal })) + expect(result.startsWith("[DAWN_E3001] ")).toBe(true) + // The original reason is preserved after the code prefix. + expect(result).toMatch(/denied.*deployProd/i) + }) }) describe("wrapToolWithConstraint", () => { diff --git a/packages/langchain/src/chat-model-factory.ts b/packages/langchain/src/chat-model-factory.ts index 85ed8d18..13c6e595 100644 --- a/packages/langchain/src/chat-model-factory.ts +++ b/packages/langchain/src/chat-model-factory.ts @@ -1,5 +1,5 @@ import type { BuiltInModelProviderId, ReasoningConfig } from "@dawn-ai/sdk" -import { validateModelId } from "@dawn-ai/sdk" +import { errorDocsUrl, validateModelId } from "@dawn-ai/sdk" type Importer = (specifier: string) => Promise> type ChatModelConstructor = new (options: Record) => unknown @@ -35,7 +35,7 @@ export function warnOnUnknownModelId(opts: { warnedModelIds.add(key) const suggestions = verdict.suggestions.map((s) => `"${s}"`).join(", ") console.warn( - `[dawn:models] model "${opts.model}" is not a known ${verdict.provider} model id.` + + `[dawn:models] [DAWN_E4002] model "${opts.model}" is not a known ${verdict.provider} model id.` + (suggestions ? ` Did you mean ${suggestions}?` : "") + " Proceeding anyway.", ) @@ -45,7 +45,9 @@ export function missingProviderPackageMessage( provider: BuiltInModelProviderId, packageName: string, ): string { - return `Provider "${provider}" requires ${packageName}. Install it with: pnpm add ${packageName}` + const url = errorDocsUrl("DAWN_E4001") + const docs = url ? ` See ${url}` : "" + return `Provider "${provider}" requires ${packageName}. Install it with: pnpm add ${packageName} [DAWN_E4001]${docs}` } export async function createChatModel(options: { diff --git a/packages/sandbox/package.json b/packages/sandbox/package.json index 692aabb4..f9ee06d0 100644 --- a/packages/sandbox/package.json +++ b/packages/sandbox/package.json @@ -40,6 +40,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@dawn-ai/sdk": "workspace:*", "@dawn-ai/workspace": "workspace:*", "@kubernetes/client-node": "^1.4.0" }, diff --git a/packages/sandbox/src/docker/docker-sandbox.ts b/packages/sandbox/src/docker/docker-sandbox.ts index e4a8c00e..564a3c2e 100644 --- a/packages/sandbox/src/docker/docker-sandbox.ts +++ b/packages/sandbox/src/docker/docker-sandbox.ts @@ -1,4 +1,5 @@ import type { SandboxHandle, SandboxPolicy, SandboxProvider } from "@dawn-ai/workspace" +import { sandboxUnavailable } from "../errors.js" import { createDocker, type Docker } from "./docker-cli.js" import { dockerExec } from "./docker-exec.js" import { dockerFilesystem } from "./docker-filesystem.js" @@ -103,7 +104,7 @@ export function dockerSandbox(opts: DockerSandboxOptions): SandboxProvider { { signal }, ) if (init.exitCode !== 0) { - throw new Error( + throw sandboxUnavailable( `Sandbox unavailable: could not initialize workspace ownership for thread "${threadId}": ${init.stderr.trim() || "unknown error"}. Run \`dawn check\`.`, ) } @@ -131,7 +132,7 @@ export function dockerSandbox(opts: DockerSandboxOptions): SandboxProvider { { signal }, ) if (created.exitCode !== 0) { - throw new Error( + throw sandboxUnavailable( `Sandbox unavailable: docker run failed for thread "${threadId}": ${created.stderr.trim() || "unknown error"}. Run \`dawn check\`.`, ) } diff --git a/packages/sandbox/src/errors.ts b/packages/sandbox/src/errors.ts new file mode 100644 index 00000000..822dfe93 --- /dev/null +++ b/packages/sandbox/src/errors.ts @@ -0,0 +1,17 @@ +import type { DawnErrorCode } from "@dawn-ai/sdk" + +/** An `Error` tagged with a stable Dawn registry code so surfaces can link docs. */ +export interface DawnCodedError extends Error { + readonly code: DawnErrorCode +} + +/** + * Construct a "sandbox unavailable" error carrying the `DAWN_E2001` code. The + * code rides on the error object so an HTTP/SSE error body (or any caught-error + * surface) can attach the docs link without re-deriving it from the message. + */ +export function sandboxUnavailable(message: string): DawnCodedError { + const error = new Error(message) as Error & { code: DawnErrorCode } + error.code = "DAWN_E2001" + return error +} diff --git a/packages/sandbox/src/kubernetes/kube-sandbox.ts b/packages/sandbox/src/kubernetes/kube-sandbox.ts index 694cfaff..dc06ecdc 100644 --- a/packages/sandbox/src/kubernetes/kube-sandbox.ts +++ b/packages/sandbox/src/kubernetes/kube-sandbox.ts @@ -1,5 +1,6 @@ import { createHash } from "node:crypto" import type { SandboxHandle, SandboxPolicy, SandboxProvider } from "@dawn-ai/workspace" +import { sandboxUnavailable } from "../errors.js" import { createDefaultKubeClient } from "./default-kube-client.js" import type { KubeClient, KubePodSpec } from "./kube-client.js" import { kubeExec } from "./kube-exec.js" @@ -239,17 +240,19 @@ async function waitForRunning( const phase = await client.readNamespacedPodPhase(ns, name) if (phase === "Running") return if (phase === null) { - throw new Error( + throw sandboxUnavailable( `Sandbox unavailable: pod "${name}" disappeared while starting. Run \`dawn check\`.`, ) } if (phase === "Failed" || phase === "Succeeded") { // A SIGTERM'd `sleep infinity` exits 0 → Succeeded; treat it as a dead keeper // rather than polling out the full timeout waiting for a Running it'll never reach. - throw new Error(`Sandbox unavailable: pod "${name}" entered ${phase}. Run \`dawn check\`.`) + throw sandboxUnavailable( + `Sandbox unavailable: pod "${name}" entered ${phase}. Run \`dawn check\`.`, + ) } if (Date.now() > deadline) { - throw new Error( + throw sandboxUnavailable( `Sandbox unavailable: pod "${name}" not Running within ${timeoutMs}ms. Run \`dawn check\`.`, ) } @@ -271,7 +274,7 @@ async function waitForGone( } if ((await client.readNamespacedPodPhase(ns, name)) === null) return if (Date.now() > deadline) { - throw new Error( + throw sandboxUnavailable( `Sandbox unavailable: pod "${name}" still terminating after ${timeoutMs}ms. Run \`dawn check\`.`, ) } diff --git a/packages/sandbox/test/sandbox-error-code.test.ts b/packages/sandbox/test/sandbox-error-code.test.ts new file mode 100644 index 00000000..e0e3c08a --- /dev/null +++ b/packages/sandbox/test/sandbox-error-code.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from "vitest" + +import type { Docker } from "../src/docker/docker-cli.js" +import { dockerSandbox } from "../src/docker/docker-sandbox.js" + +const signal = () => new AbortController().signal + +describe("sandbox unavailable errors carry the DAWN_E2001 code", () => { + test("a failed container creation throws an error tagged DAWN_E2001", async () => { + const docker: Docker = { + run: async (args: readonly string[]) => { + // Fail only the detached container creation (`run -d …`). + if (args[0] === "run" && args.includes("-d")) { + return { stdout: "", stderr: "no space left on device", exitCode: 1 } + } + return { stdout: "", stderr: "", exitCode: 0 } + }, + exec: async () => ({ stdout: "", stderr: "", exitCode: 0 }), + } + const p = dockerSandbox({ image: "node:22-slim", docker }) + await expect( + p.acquire({ threadId: "t1", policy: { network: { mode: "deny" } }, signal: signal() }), + ).rejects.toMatchObject({ code: "DAWN_E2001" }) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index db6ebb67..2044fde3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -602,6 +602,9 @@ importers: packages/sandbox: dependencies: + '@dawn-ai/sdk': + specifier: workspace:* + version: link:../sdk '@dawn-ai/workspace': specifier: workspace:* version: link:../workspace From 6d44b803ba665019cb5c81cce8a8cd31e065ac30 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 13 Jul 2026 12:52:59 -0700 Subject: [PATCH 5/7] docs: generated /docs/errors reference + drift guard Co-Authored-By: Claude Opus 4.8 --- apps/web/app/components/docs/nav.ts | 1 + apps/web/app/docs/errors/page.tsx | 9 +++++ apps/web/content/docs/errors.mdx | 25 ++++++++++++++ package.json | 1 + scripts/check-docs.mjs | 53 +++++++++++++++++++++++++++++ scripts/generate-error-docs.mjs | 41 ++++++++++++++++++++++ 6 files changed, 130 insertions(+) create mode 100644 apps/web/app/docs/errors/page.tsx create mode 100644 apps/web/content/docs/errors.mdx create mode 100644 scripts/generate-error-docs.mjs diff --git a/apps/web/app/components/docs/nav.ts b/apps/web/app/components/docs/nav.ts index a7892cd1..fa89b6d2 100644 --- a/apps/web/app/components/docs/nav.ts +++ b/apps/web/app/components/docs/nav.ts @@ -68,6 +68,7 @@ export const DOCS_NAV: readonly DocsNavSection[] = [ { label: "API", href: "/docs/api" }, { label: "CLI", href: "/docs/cli" }, { label: "Configuration", href: "/docs/configuration" }, + { label: "Error Codes", href: "/docs/errors" }, { label: "Observability", href: "/docs/observability" }, { label: "Upgrading", href: "/docs/upgrading" }, { label: "FAQ", href: "/docs/faq" }, diff --git a/apps/web/app/docs/errors/page.tsx b/apps/web/app/docs/errors/page.tsx new file mode 100644 index 00000000..760e46bf --- /dev/null +++ b/apps/web/app/docs/errors/page.tsx @@ -0,0 +1,9 @@ +import type { Metadata } from "next" +import Content from "../../../content/docs/errors.mdx" +import { DocsPage } from "../../components/docs/DocsPage" + +export const metadata: Metadata = { title: "Error Codes" } + +export default function Page() { + return +} diff --git a/apps/web/content/docs/errors.mdx b/apps/web/content/docs/errors.mdx new file mode 100644 index 00000000..4f18dd2e --- /dev/null +++ b/apps/web/content/docs/errors.mdx @@ -0,0 +1,25 @@ +{/* GENERATED by scripts/generate-error-docs.mjs — do not edit by hand. */} + +# Error codes + +Every user-facing Dawn failure carries a stable `DAWN_Exxxx` code. The code is +printed on the surface where the failure appears — CLI stderr, HTTP/SSE error +bodies, and permission-denial tool results — so a failure is searchable and +linkable regardless of how you hit it. + +Codes are grouped by range: `E1xxx` config / `dawn check`, `E2xxx` sandbox, +`E3xxx` permissions, `E4xxx` model / provider, and `E5xxx` runtime / import. +A code may omit a docs link and still be a valid, searchable identifier. + +| Code | Meaning | Docs | +| --- | --- | --- | +| `DAWN_E1001` | Invalid tool scope | [/docs/tools#scoping-a-routes-tools](/docs/tools#scoping-a-routes-tools) | +| `DAWN_E1002` | Invalid sandbox config | [/docs/configuration#sandbox](/docs/configuration#sandbox) | +| `DAWN_E1003` | Unknown build target | [/docs/deployment](/docs/deployment) | +| `DAWN_E2001` | Sandbox unavailable | [/docs/sandbox#what-it-is--and-isnt](/docs/sandbox#what-it-is--and-isnt) | +| `DAWN_E2002` | Sandbox preflight failed | [/docs/sandbox#quickstart](/docs/sandbox#quickstart) | +| `DAWN_E3001` | Permission denied | [/docs/permissions](/docs/permissions) | +| `DAWN_E4001` | Model provider package missing | [/docs/configuration](/docs/configuration) | +| `DAWN_E4002` | Unknown model id | [/docs/configuration](/docs/configuration) | +| `DAWN_E5001` | Import or export mismatch | — | +| `DAWN_E5002` | Tool file has the wrong shape | [/docs/tools](/docs/tools) | diff --git a/package.json b/package.json index cefb5b4b..11c7fb22 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "check:build-cache": "node scripts/check-build-cache-config.mjs", "ci:validate": "pnpm lint && pnpm check:build-cache && pnpm build && pnpm typecheck && pnpm test && pnpm test:release-publish && pnpm test:upload-release-assets && pnpm test:backfill-release-tags && node scripts/check-docs.mjs && pnpm pack:check && pnpm verify:harness:self-test && pnpm verify:harness", "dev": "turbo run dev --parallel", + "docs:errors": "node scripts/generate-error-docs.mjs", "lint": "pnpm exec biome check --config-path packages/config-biome/biome.json package.json scripts && turbo run lint", "lint:fix": "pnpm exec biome check --write --unsafe --config-path packages/config-biome/biome.json package.json scripts && turbo run lint -- --write", "pack:check": "pnpm test:pack-check && node scripts/pack-check.mjs", diff --git a/scripts/check-docs.mjs b/scripts/check-docs.mjs index 6d478184..11725526 100644 --- a/scripts/check-docs.mjs +++ b/scripts/check-docs.mjs @@ -102,6 +102,59 @@ for (const href of uniqueNavDocHrefs) { } } +// Error-code registry ↔ docs drift guard. Every registry `docsPath` must +// resolve to a real /docs/ nav page, and /docs/errors must list exactly +// the registry's codes. Reuses docs-bundle nav parsing for page existence. +const sdkEntryUrl = pathToFileURL(resolve(repoRoot, "packages/sdk/dist/index.js")).href +const sdkEntry = await import(sdkEntryUrl).catch((error) => { + failures.push( + `Error-docs guard could not import packages/sdk/dist/index.js — did you run pnpm build? (${error.message})`, + ) + return null +}) +const docsBundleUrl = pathToFileURL(resolve(repoRoot, "packages/cli/dist/lib/docs-bundle.js")).href +const docsBundle = await import(docsBundleUrl).catch((error) => { + failures.push( + `Error-docs guard could not import packages/cli/dist/lib/docs-bundle.js — did you run pnpm build? (${error.message})`, + ) + return null +}) + +if (sdkEntry?.DAWN_ERRORS && docsBundle?.parseNav) { + const registry = sdkEntry.DAWN_ERRORS + const codes = Object.keys(registry) + const navSlugs = new Set(docsBundle.parseNav(docsNav).map((entry) => entry.slug)) + + for (const code of codes) { + const docsPath = registry[code].docsPath + if (!docsPath) { + continue + } + const slug = docsPath.replace(/^\/docs\//, "").replace(/#.*$/, "") + if (!navSlugs.has(slug)) { + failures.push( + `DAWN_ERRORS.${code} docsPath ${docsPath} points at /docs/${slug}, which is not a known docs page`, + ) + } + } + + const errorsMdxPath = resolve(repoRoot, "apps/web/content/docs/errors.mdx") + const errorsMdx = readFileSync(errorsMdxPath, "utf8") + const listed = new Set([...errorsMdx.matchAll(/DAWN_E\d{4}/g)].map((m) => m[0])) + const missing = codes.filter((code) => !listed.has(code)) + const extra = [...listed].filter((code) => !codes.includes(code)) + if (missing.length > 0) { + failures.push( + `apps/web/content/docs/errors.mdx is missing registry codes: ${missing.join(", ")} — run node scripts/generate-error-docs.mjs`, + ) + } + if (extra.length > 0) { + failures.push( + `apps/web/content/docs/errors.mdx lists codes not in the registry: ${extra.join(", ")} — run node scripts/generate-error-docs.mjs`, + ) + } +} + // CLI surface check — drive from the commander registry to catch docs drift. // Every user-facing command name and every long option must be referenced in // cli.mdx. The internal `dev-child` command is excluded (not user-facing). diff --git a/scripts/generate-error-docs.mjs b/scripts/generate-error-docs.mjs new file mode 100644 index 00000000..297bbf88 --- /dev/null +++ b/scripts/generate-error-docs.mjs @@ -0,0 +1,41 @@ +// Generates apps/web/content/docs/errors.mdx from the DAWN_ERRORS registry so +// the public error-code reference can never drift from the code. Reads the +// built @dawn-ai/sdk dist (run after `pnpm build`). The check-docs guard +// enforces that the committed output lists exactly the registry's codes. +import { writeFileSync } from "node:fs" +import { resolve } from "node:path" +import { fileURLToPath, pathToFileURL } from "node:url" + +const repoRoot = resolve(fileURLToPath(import.meta.url), "..", "..") +const sdkDist = pathToFileURL(resolve(repoRoot, "packages/sdk/dist/index.js")).href +const outPath = resolve(repoRoot, "apps/web/content/docs/errors.mdx") + +const { DAWN_ERRORS } = await import(sdkDist) + +const rows = Object.values(DAWN_ERRORS) + .sort((a, b) => a.code.localeCompare(b.code)) + .map((descriptor) => { + const docs = descriptor.docsPath ? `[${descriptor.docsPath}](${descriptor.docsPath})` : "—" + return `| \`${descriptor.code}\` | ${descriptor.title} | ${docs} |` + }) + +const body = `{/* GENERATED by scripts/generate-error-docs.mjs — do not edit by hand. */} + +# Error codes + +Every user-facing Dawn failure carries a stable \`DAWN_Exxxx\` code. The code is +printed on the surface where the failure appears — CLI stderr, HTTP/SSE error +bodies, and permission-denial tool results — so a failure is searchable and +linkable regardless of how you hit it. + +Codes are grouped by range: \`E1xxx\` config / \`dawn check\`, \`E2xxx\` sandbox, +\`E3xxx\` permissions, \`E4xxx\` model / provider, and \`E5xxx\` runtime / import. +A code may omit a docs link and still be a valid, searchable identifier. + +| Code | Meaning | Docs | +| --- | --- | --- | +${rows.join("\n")} +` + +writeFileSync(outPath, body) +console.log(`[generate-error-docs] wrote ${rows.length} code(s) to ${outPath}`) From ab116c6d67a415c0188a9a8b672b5508435dfcce Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 13 Jul 2026 12:53:44 -0700 Subject: [PATCH 6/7] chore: changeset for error-code registry (patch) Co-Authored-By: Claude Opus 4.8 --- .changeset/error-code-registry.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .changeset/error-code-registry.md diff --git a/.changeset/error-code-registry.md b/.changeset/error-code-registry.md new file mode 100644 index 00000000..e72b1a1b --- /dev/null +++ b/.changeset/error-code-registry.md @@ -0,0 +1,16 @@ +--- +"@dawn-ai/sdk": patch +"@dawn-ai/cli": patch +"@dawn-ai/core": patch +"@dawn-ai/sandbox": patch +"@dawn-ai/langchain": patch +--- + +Add a central `DAWN_Exxxx` error-code registry in `@dawn-ai/sdk` and surface +codes on the failure channels. `CliError` now carries an optional `code` and the +CLI prints `[CODE] See `; HTTP/SSE error bodies gain optional `code`/`docsUrl`; +permission denials returned as tool results are prefixed with `[DAWN_E3001]`. +The high-value families are wired (`dawn check` config errors, sandbox +unavailable, permission denied, missing model provider / unknown model id, and +tool-file shape errors), and a generated `/docs/errors` reference page is guarded +against drift. Additive and backward-compatible. From 517753b87a8b4840b6cefea78db406f8f8a874e3 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 13 Jul 2026 13:10:14 -0700 Subject: [PATCH 7/7] ci: build sandbox with its deps so the new @dawn-ai/sdk import resolves in the gated lanes --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 22538a70..dd5b21a9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -120,7 +120,7 @@ jobs: run: pnpm install --frozen-lockfile - name: Build sandbox package - run: pnpm --filter @dawn-ai/workspace build && pnpm --filter @dawn-ai/sandbox build + run: pnpm --filter @dawn-ai/sandbox... build - name: Pull sandbox image run: docker pull node:22-slim @@ -249,7 +249,7 @@ jobs: run: pnpm install --frozen-lockfile - name: Build sandbox package - run: pnpm --filter @dawn-ai/workspace build && pnpm --filter @dawn-ai/sandbox build + run: pnpm --filter @dawn-ai/sandbox... build - name: Setup Helm uses: azure/setup-helm@b9e51907a09c216f16ebe8536097933489208112 # v4.3.0