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
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,8 @@ behind human approval.

- `bun run check` (typecheck, lint, test, structural checks) must pass
before every commit. CI's `structural` job runs the same
`check:structural` list as local, including `check:report-error`, so
the two cannot drift.
`check:structural` list as local, including `check:report-error` and
`check:error-envelope`, so the two cannot drift.
- Commit sequence per change: tests first ("Add tests for X"), then
implementation ("X: what changed"), then docs ("Update docs: X"). One
logical change per commit; commit messages are written for a public
Expand Down
19 changes: 12 additions & 7 deletions apps/hub/src/hub-error-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,12 @@ describe("hubErrorHandler", () => {

expect(res.status).toBe(500);
const body = (await res.json()) as {
error: { code: string; refId: string };
error: { code: string; userMessage: string; refId: string };
};
expect(body.error.code).toBe("internal_error");
expect(body.error.userMessage).toBe(
"Something went wrong. Please try again.",
);
expect(typeof body.error.refId).toBe("string");
expect(body.error.refId.length).toBeGreaterThan(0);

Expand Down Expand Up @@ -74,10 +77,10 @@ describe("hubErrorHandler", () => {

expect(res.status).toBe(422);
const body = (await res.json()) as {
error: { code: string; message: string; refId: string };
error: { code: string; userMessage: string; refId: string };
};
expect(body.error.code).toBe("MultiStepFoldUnsupportedError");
expect(body.error.message).toBe(
expect(body.error.userMessage).toBe(
"definition wfd_research is not single-step (2 steps)",
);
expect(typeof body.error.refId).toBe("string");
Expand All @@ -100,12 +103,14 @@ describe("hubErrorHandler", () => {

expect(res.status).toBe(422);
const body = (await res.json()) as {
error: { code: string; message: string; refId: string };
error: { code: string; userMessage: string; refId: string };
};
expect(body.error.code).toBe("InferenceResolutionError");
expect(body.error.message).toBe("This agent's model isn't available here.");
expect(body.error.message).not.toContain("claude-sonnet-5");
expect(body.error.message).not.toMatch(/cannot resolve an inference/);
expect(body.error.userMessage).toBe(
"This agent's model isn't available here.",
);
expect(body.error.userMessage).not.toContain("claude-sonnet-5");
expect(body.error.userMessage).not.toMatch(/cannot resolve an inference/);
expect(typeof body.error.refId).toBe("string");
});
});
24 changes: 15 additions & 9 deletions apps/hub/src/hub-error-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import type { Context } from "hono";
import type { TenantEnv } from "@intx/hub-api";
import { reportError } from "@corbits/error-sink";
import { makeErrorEnvelope } from "@workbench/hub-client";

/**
* Duck-typed rather than an `instanceof` allowlist: any error carrying a
Expand Down Expand Up @@ -51,19 +52,24 @@ export function hubErrorHandler() {
});

if (hasGuidance(err)) {
const message =
const userMessage =
err.name === "InferenceResolutionError" ? err.guidance : err.message;
return c.json({ error: { code: err.name, message, refId } }, 422);
return c.json(
makeErrorEnvelope({
code: err.name,
userMessage,
refId,
}),
422,
);
}

return c.json(
{
error: {
code: "internal_error",
message: "Something went wrong. Please try again.",
refId,
},
},
makeErrorEnvelope({
code: "internal_error",
userMessage: "Something went wrong. Please try again.",
refId,
}),
500,
);
};
Expand Down
11 changes: 9 additions & 2 deletions apps/hub/src/tenant-create-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
checkSignupGate,
type AccessPolicyStore,
} from "@workbench/access-policy";
import { makeErrorEnvelope } from "@workbench/hub-client";

const NATIVE_TENANT_CREATE_PATH = "/api/tenants";

Expand Down Expand Up @@ -199,7 +200,10 @@ export function guardedHubApp(
const user = await deps.getSessionUser(c.req.raw.headers);
if (user === undefined) {
return c.json(
{ error: { code: "unauthorized", message: "Authentication required" } },
makeErrorEnvelope({
code: "unauthorized",
userMessage: "Authentication required",
}),
401,
);
}
Expand All @@ -224,7 +228,10 @@ export function guardedHubApp(
const verdict = await decideTenantCreate(deps, decideRequest);
if (!verdict.allowed) {
return c.json(
{ error: { code: verdict.code, message: verdict.message } },
makeErrorEnvelope({
code: verdict.code,
userMessage: verdict.message,
}),
verdict.status,
);
}
Expand Down
9 changes: 6 additions & 3 deletions apps/hub/test/composition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,9 +168,12 @@ describeIfDb("extension mounting", () => {
body: JSON.stringify({ tenantIds: [] }),
});
expect(gated.status).toBe(401);
expect(await gated.json()).toEqual({
error: { code: "unauthorized", message: "Authentication required" },
});
const kindsBody = (await gated.json()) as {
error: { code: string; userMessage: string; refId: string };
};
expect(kindsBody.error.code).toBe("unauthorized");
expect(kindsBody.error.userMessage).toBe("Authentication required");
expect(kindsBody.error.refId).toMatch(/\S/);
});
});

Expand Down
6 changes: 4 additions & 2 deletions apps/web/src/agents-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,9 @@ async function getJSON<T>(path: string, schema: Validator<T>): Promise<T> {
return parsed;
}

const ErrorEnvelope = type({ error: { message: "string" } });
const ErrorEnvelope = type({
error: { code: "string", userMessage: "string", refId: "string" },
});

async function postJSON<T>(
path: string,
Expand Down Expand Up @@ -111,7 +113,7 @@ async function postJSON<T>(
const message =
envelope instanceof type.errors
? `The server answered ${response.status}.`
: envelope.error.message;
: envelope.error.userMessage;
throw new ApiQueryError(message, response.status, path);
}
const parsed = schema(json);
Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/routines-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,13 +108,14 @@ async function request<T>(
throw new ApiQueryError("Not signed in.", 401, path);
}
if (!response.ok) {
// Envelope-first: the hub's own `error.message` is already plain,
// Envelope-first: the hub's own `error.userMessage` is already plain,
// human copy — kept verbatim. Only the fallback (no envelope message)
// is synthesized here, and it never repeats the request path.
const detail = await response
.json()
.then(
(body: { error?: { message?: string } }) => body.error?.message ?? "",
(body: { error?: { userMessage?: string } }) =>
body.error?.userMessage ?? "",
)
.catch(() => "");
throw new ApiQueryError(
Expand Down
6 changes: 4 additions & 2 deletions apps/web/src/skills-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,9 @@ const SkillVersionsResponse = type({ versions: SkillVersion.array() });
const SkillAtVersionResponse = type({ skill: SkillDetail });
const SkillResponse = type({ skill: SkillSummary });

const ErrorEnvelope = type({ error: { message: "string" } });
const ErrorEnvelope = type({
error: { code: "string", userMessage: "string", refId: "string" },
});

type Validator<T> = (data: unknown) => T | ArkErrors;

Expand Down Expand Up @@ -91,7 +93,7 @@ async function request<T>(
throw new ApiQueryError(
envelope instanceof type.errors
? `The server answered ${String(response.status)}.`
: envelope.error.message,
: envelope.error.userMessage,
response.status,
path,
);
Expand Down
3 changes: 2 additions & 1 deletion apps/web/test/create-agent-panel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,8 @@ describe("CreateAgentPanel drafting failure — fails closed", () => {
{
error: {
code: "drafting_failed",
message: "Myra couldn't draft a starting prompt for that.",
userMessage: "Myra couldn't draft a starting prompt for that.",
refId: "ref_1",
},
},
422,
Expand Down
12 changes: 10 additions & 2 deletions apps/web/test/skills-page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,9 @@ describe("SkillsPage", () => {
return new Response(
JSON.stringify({
error: {
message: "SKILL.md is missing its YAML frontmatter delimiter",
code: "invalid_skill",
userMessage: "SKILL.md is missing its YAML frontmatter delimiter",
refId: "ref_1",
},
}),
{ status: 400 },
Expand Down Expand Up @@ -396,7 +398,13 @@ describe("SkillsPage", () => {
});
if (method === "POST" && path.endsWith("/skills")) {
return new Response(
JSON.stringify({ error: { message: REGISTRY_DESCRIPTION_ERROR } }),
JSON.stringify({
error: {
code: "invalid_skill",
userMessage: REGISTRY_DESCRIPTION_ERROR,
refId: "ref_1",
},
}),
{ status: 400 },
);
}
Expand Down
18 changes: 18 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,10 @@
"setup:memory": "bun run scripts/setup-memory.ts",
"seed": "bun packages/cli/src/index.ts seed",
"reset": "bun packages/cli/src/index.ts reset",
"check:structural": "bun test scripts/checks/test && bun run check:deletion && bun run check:killdates && bun run check:licenses && bun run check:db-gate && bun run check:no-product-tenancy && bun run check:browser-safe-subpaths && bun run check:web-utilities && bun run check:tailwind-source && bun run check:ui-vocabulary && bun run check:react-ui-drift && bun run check:react-ui-pin && bun run check:tool-package-pins && bun run check:tool-package-freshness && bun run check:hub-git-safety && bun run check:report-error && bun run check:tsconfig-references",
"check:structural": "bun test scripts/checks/test && bun run check:deletion && bun run check:killdates && bun run check:licenses && bun run check:db-gate && bun run check:no-product-tenancy && bun run check:browser-safe-subpaths && bun run check:web-utilities && bun run check:tailwind-source && bun run check:ui-vocabulary && bun run check:react-ui-drift && bun run check:react-ui-pin && bun run check:tool-package-pins && bun run check:tool-package-freshness && bun run check:hub-git-safety && bun run check:report-error && bun run check:error-envelope && bun run check:tsconfig-references",
"check:deletion": "bun run scripts/checks/deletion.ts",
"check:report-error": "bun run scripts/checks/report-error.ts",
"check:error-envelope": "bun run scripts/checks/error-envelope.ts",
"check:killdates": "bun run scripts/checks/killdates.ts",
"check:packages": "bun run scripts/checks/packages.ts",
"check:licenses": "bun run scripts/checks/licenses.ts",
Expand Down
Loading
Loading