diff --git a/AGENTS.md b/AGENTS.md index f52596f9e..ef91fbef2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/apps/hub/src/hub-error-handler.test.ts b/apps/hub/src/hub-error-handler.test.ts index ab7979b16..0ed078ad2 100644 --- a/apps/hub/src/hub-error-handler.test.ts +++ b/apps/hub/src/hub-error-handler.test.ts @@ -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); @@ -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"); @@ -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"); }); }); diff --git a/apps/hub/src/hub-error-handler.ts b/apps/hub/src/hub-error-handler.ts index 981fb671c..33c533de3 100644 --- a/apps/hub/src/hub-error-handler.ts +++ b/apps/hub/src/hub-error-handler.ts @@ -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 @@ -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, ); }; diff --git a/apps/hub/src/tenant-create-guard.ts b/apps/hub/src/tenant-create-guard.ts index ee83714f1..07b9f85da 100644 --- a/apps/hub/src/tenant-create-guard.ts +++ b/apps/hub/src/tenant-create-guard.ts @@ -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"; @@ -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, ); } @@ -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, ); } diff --git a/apps/hub/test/composition.test.ts b/apps/hub/test/composition.test.ts index 8aa1d6501..2813c76a7 100644 --- a/apps/hub/test/composition.test.ts +++ b/apps/hub/test/composition.test.ts @@ -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/); }); }); diff --git a/apps/web/src/agents-api.ts b/apps/web/src/agents-api.ts index 629d0f186..2ba1329e1 100644 --- a/apps/web/src/agents-api.ts +++ b/apps/web/src/agents-api.ts @@ -83,7 +83,9 @@ async function getJSON(path: string, schema: Validator): Promise { return parsed; } -const ErrorEnvelope = type({ error: { message: "string" } }); +const ErrorEnvelope = type({ + error: { code: "string", userMessage: "string", refId: "string" }, +}); async function postJSON( path: string, @@ -111,7 +113,7 @@ async function postJSON( 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); diff --git a/apps/web/src/routines-api.ts b/apps/web/src/routines-api.ts index 49d87da01..bd2221d48 100644 --- a/apps/web/src/routines-api.ts +++ b/apps/web/src/routines-api.ts @@ -108,13 +108,14 @@ async function request( 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( diff --git a/apps/web/src/skills-api.ts b/apps/web/src/skills-api.ts index f5e25a018..1e4fc31f2 100644 --- a/apps/web/src/skills-api.ts +++ b/apps/web/src/skills-api.ts @@ -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 = (data: unknown) => T | ArkErrors; @@ -91,7 +93,7 @@ async function request( throw new ApiQueryError( envelope instanceof type.errors ? `The server answered ${String(response.status)}.` - : envelope.error.message, + : envelope.error.userMessage, response.status, path, ); diff --git a/apps/web/test/create-agent-panel.test.tsx b/apps/web/test/create-agent-panel.test.tsx index 5e37f267b..e9c7542ec 100644 --- a/apps/web/test/create-agent-panel.test.tsx +++ b/apps/web/test/create-agent-panel.test.tsx @@ -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, diff --git a/apps/web/test/skills-page.test.tsx b/apps/web/test/skills-page.test.tsx index 98a360f82..9fe1e4f3f 100644 --- a/apps/web/test/skills-page.test.tsx +++ b/apps/web/test/skills-page.test.tsx @@ -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 }, @@ -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 }, ); } diff --git a/bun.lock b/bun.lock index 141dcaaa5..dec333b15 100644 --- a/bun.lock +++ b/bun.lock @@ -193,6 +193,7 @@ "name": "@workbench/access-policy", "version": "0.0.1", "dependencies": { + "@corbits/error-sink": "workspace:*", "@corbits/migration-runner": "workspace:*", "@intx/hub-api": "workspace:*", "@workbench/hub-client": "workspace:*", @@ -211,6 +212,7 @@ "version": "0.0.1", "dependencies": { "@corbits/chat": "workspace:*", + "@corbits/error-sink": "workspace:*", "@corbits/folded-run-one-shot": "workspace:*", "@corbits/skills": "workspace:*", "@corbits/workflow-catalog": "workspace:*", @@ -223,6 +225,7 @@ "@intx/log": "0.3.0", "@intx/types": "workspace:*", "@intx/workflow": "workspace:*", + "@workbench/hub-client": "workspace:*", "arktype": "catalog:", "drizzle-orm": "catalog:", "hono": "^4.11.9", @@ -292,6 +295,7 @@ "@intx/db": "workspace:*", "@intx/hub-sessions": "workspace:*", "@intx/types": "workspace:*", + "@workbench/hub-client": "workspace:*", "arktype": "catalog:", "drizzle-orm": "catalog:", "hono": "^4.11.9", @@ -367,6 +371,7 @@ "@intx/db": "workspace:*", "@intx/hub-api": "workspace:*", "@intx/types": "workspace:*", + "@workbench/hub-client": "workspace:*", "arktype": "catalog:", "drizzle-orm": "catalog:", "hono": "^4.11.9", @@ -383,6 +388,7 @@ "@corbits/api-query": "workspace:*", "@corbits/migration-runner": "workspace:*", "@intx/hub-api": "workspace:*", + "@workbench/hub-client": "workspace:*", "arktype": "catalog:", "drizzle-orm": "catalog:", "hono": "^4.11.9", @@ -568,6 +574,7 @@ "version": "0.0.1", "dependencies": { "@intx/hub-api": "workspace:*", + "@workbench/hub-client": "workspace:*", "arktype": "catalog:", "hono": "^4.11.9", }, @@ -886,6 +893,7 @@ "@intx/hub-api": "workspace:*", "@intx/inference-catalog": "0.3.0", "@intx/types": "workspace:*", + "@workbench/hub-client": "workspace:*", "arktype": "catalog:", "drizzle-orm": "catalog:", "hono": "^4.11.9", @@ -920,6 +928,7 @@ "@intx/hub-common": "0.3.0", "@intx/log": "0.3.0", "@intx/types": "workspace:*", + "@workbench/hub-client": "workspace:*", "arktype": "catalog:", "drizzle-orm": "catalog:", "hono": "^4.11.9", @@ -1020,6 +1029,7 @@ "dependencies": { "@corbits/artifacts-hub": "workspace:*", "@corbits/memory": "github:corbitsdev/corbits-memory#9e6f213fa2c002b531d3f6af1aa0abd737b8afe3", + "@workbench/hub-client": "workspace:*", "arktype": "catalog:", "hono": "^4.11.9", }, @@ -1145,6 +1155,7 @@ "@corbits/api-query": "workspace:*", "@corbits/migration-runner": "workspace:*", "@intx/hub-api": "workspace:*", + "@workbench/hub-client": "workspace:*", "arktype": "catalog:", "drizzle-orm": "catalog:", "hono": "^4.11.9", @@ -1160,6 +1171,7 @@ "version": "0.0.1", "dependencies": { "@intx/hub-api": "workspace:*", + "@workbench/hub-client": "workspace:*", "arktype": "catalog:", "hono": "^4.11.9", "y-protocols": "^1.0.7", @@ -1207,6 +1219,7 @@ "@intx/hub-api": "workspace:*", "@intx/hub-common": "0.3.0", "@intx/log": "0.3.0", + "@workbench/hub-client": "workspace:*", "arktype": "catalog:", "cronstrue": "^3.24.0", "drizzle-orm": "catalog:", @@ -1238,6 +1251,7 @@ "@intx/db": "workspace:*", "@intx/hub-api": "workspace:*", "@intx/log": "0.3.0", + "@workbench/hub-client": "workspace:*", "arktype": "catalog:", "drizzle-orm": "catalog:", "hono": "^4.11.9", @@ -1256,6 +1270,7 @@ "@intx/db": "workspace:*", "@intx/hub-api": "workspace:*", "@intx/types": "workspace:*", + "@workbench/hub-client": "workspace:*", "arktype": "catalog:", "drizzle-orm": "catalog:", "hono": "^4.11.9", @@ -1342,6 +1357,7 @@ "@intx/db": "workspace:*", "@intx/hub-api": "workspace:*", "@intx/types": "workspace:*", + "@workbench/hub-client": "workspace:*", "arktype": "catalog:", "drizzle-orm": "catalog:", "hono": "^4.11.9", @@ -1358,6 +1374,7 @@ "@intx/db": "workspace:*", "@intx/hub-api": "workspace:*", "@intx/hub-sessions": "workspace:*", + "@workbench/hub-client": "workspace:*", "arktype": "catalog:", "drizzle-orm": "catalog:", "hono": "^4.11.9", @@ -1491,6 +1508,7 @@ "@intx/hub-sessions": "workspace:*", "@intx/log": "0.3.0", "@intx/types": "workspace:*", + "@workbench/hub-client": "workspace:*", "arktype": "catalog:", "drizzle-orm": "catalog:", "hono": "^4.11.9", diff --git a/package.json b/package.json index 021c1c6ae..56c1bff64 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/access-policy/package.json b/packages/access-policy/package.json index ff4e9b5f1..c0ff73ce2 100644 --- a/packages/access-policy/package.json +++ b/packages/access-policy/package.json @@ -14,6 +14,7 @@ "test": "bun test" }, "dependencies": { + "@corbits/error-sink": "workspace:*", "@corbits/migration-runner": "workspace:*", "@intx/hub-api": "workspace:*", "@workbench/hub-client": "workspace:*", diff --git a/packages/access-policy/src/routes.ts b/packages/access-policy/src/routes.ts index 8f0c87908..eb7ea98bf 100644 --- a/packages/access-policy/src/routes.ts +++ b/packages/access-policy/src/routes.ts @@ -9,16 +9,17 @@ import { Hono } from "hono"; import { type } from "arktype"; import type { RequireGrant, TenantEnv } from "@intx/hub-api"; -import { cookiesFromHeader, type ApiCall } from "@workbench/hub-client"; +import { reportError } from "@corbits/error-sink"; +import { + cookiesFromHeader, + makeErrorEnvelope, + type ApiCall, +} from "@workbench/hub-client"; import { canCreateTenancy } from "./policy"; import type { AccessPolicyStore } from "./store"; import { CreatePendingInvite, UpdateAccessPolicy } from "./types"; -const ErrorEnvelope = (code: string, message: string) => ({ - error: { code, message }, -}); - const CreateChildTenant = type({ name: "string > 0", slug: "string > 0", @@ -47,7 +48,10 @@ export function createAccessPolicyRoutes( const patch = UpdateAccessPolicy(raw); if (patch instanceof type.errors) { return c.json( - ErrorEnvelope("bad_request", `invalid policy: ${patch.summary}`), + makeErrorEnvelope({ + code: "bad_request", + userMessage: `invalid policy: ${patch.summary}`, + }), 400, ); } @@ -75,7 +79,10 @@ export function createAccessPolicyRoutes( const parsed = CreatePendingInvite(raw); if (parsed instanceof type.errors) { return c.json( - ErrorEnvelope("bad_request", `invalid invite: ${parsed.summary}`), + makeErrorEnvelope({ + code: "bad_request", + userMessage: `invalid invite: ${parsed.summary}`, + }), 400, ); } @@ -108,7 +115,10 @@ export function createAccessPolicyRoutes( const body = CreateChildTenant(raw); if (body instanceof type.errors) { return c.json( - ErrorEnvelope("bad_request", `invalid tenant: ${body.summary}`), + makeErrorEnvelope({ + code: "bad_request", + userMessage: `invalid tenant: ${body.summary}`, + }), 400, ); } @@ -123,11 +133,21 @@ export function createAccessPolicyRoutes( cookies, ); if (principalResponse.status !== 200) { + const userMessage = "Could not resolve your roles on this workbench."; + const refId = reportError( + new Error(`principal lookup returned ${principalResponse.status}`), + { + operation: "accessPolicy.childTenant.roleLookup", + tenantId: tenant.id, + extra: { status: principalResponse.status }, + }, + ); return c.json( - ErrorEnvelope( - "role_lookup_failed", - "Could not resolve your roles on this workbench.", - ), + makeErrorEnvelope({ + code: "role_lookup_failed", + userMessage, + refId, + }), 502, ); } @@ -140,10 +160,11 @@ export function createAccessPolicyRoutes( if (!canCreateTenancy(policy, roleNames)) { return c.json( - ErrorEnvelope( - "tenancy_creation_forbidden", - "This workbench's policy doesn't allow you to create a sub-workbench here.", - ), + makeErrorEnvelope({ + code: "tenancy_creation_forbidden", + userMessage: + "This workbench's policy doesn't allow you to create a sub-workbench here.", + }), 403, ); } diff --git a/packages/agent-directory-tools/package.json b/packages/agent-directory-tools/package.json index b9f302b50..a9ba5af2d 100644 --- a/packages/agent-directory-tools/package.json +++ b/packages/agent-directory-tools/package.json @@ -2,7 +2,7 @@ "name": "@corbits/agent-directory-tools", "private": true, "description": "Myra's manager tools: list_agents and create_agent as an @intx/agent tool bundle — creates a new specialist agent definition in the caller's own tenant and, by default, mints or reopens that specialist's own 1:1 chat. Creation is free; the reactor never parks the create/mint pair", - "version": "0.0.5", + "version": "0.0.6", "license": "LGPL-2.1-or-later", "type": "module", "exports": { diff --git a/packages/agent-directory-tools/src/client.test.ts b/packages/agent-directory-tools/src/client.test.ts index f23b2f91b..39faf2bbd 100644 --- a/packages/agent-directory-tools/src/client.test.ts +++ b/packages/agent-directory-tools/src/client.test.ts @@ -94,7 +94,13 @@ test("createAgentDefinition rejects a response whose currentVersion is a number, test("createAgentDefinition throws CreateAgentDefinitionError on a 400", async () => { const fetchImpl = (async () => new Response( - JSON.stringify({ error: { code: "bad_request", message: "bad handle" } }), + JSON.stringify({ + error: { + code: "bad_request", + userMessage: "bad handle", + refId: "ref_test", + }, + }), { status: 400 }, )) as unknown as typeof fetch; @@ -111,7 +117,11 @@ test("createAgentDefinition throws CreateAgentDefinitionError on a 409 conflict" const fetchImpl = (async () => new Response( JSON.stringify({ - error: { code: "conflict", message: "already exists" }, + error: { + code: "conflict", + userMessage: "already exists", + refId: "ref_test", + }, }), { status: 409 }, )) as unknown as typeof fetch; @@ -189,7 +199,11 @@ test("inviteParticipant throws NoOwnChannelError on a 404", async () => { const fetchImpl = (async () => new Response( JSON.stringify({ - error: { code: "not_found", message: "no channel found" }, + error: { + code: "not_found", + userMessage: "no channel found", + refId: "ref_test", + }, }), { status: 404 }, )) as unknown as typeof fetch; @@ -231,7 +245,11 @@ test("mintAgentDm throws NoOwnWorkbenchError on a 404", async () => { const fetchImpl = (async () => new Response( JSON.stringify({ - error: { code: "not_found", message: "no workbench found" }, + error: { + code: "not_found", + userMessage: "no workbench found", + refId: "ref_test", + }, }), { status: 404 }, )) as unknown as typeof fetch; diff --git a/packages/agent-directory-tools/src/client.ts b/packages/agent-directory-tools/src/client.ts index dc3696da5..c9f897f9c 100644 --- a/packages/agent-directory-tools/src/client.ts +++ b/packages/agent-directory-tools/src/client.ts @@ -85,19 +85,23 @@ function authHeaders( }; } -/** Pulls `error.message` out of a Hono `app.onError` envelope - * (`{error: {code, message}}`), if `body` matches that shape — same - * shape `@corbits/capability-tools`' client reads. */ +/** Pulls `error.userMessage` out of the canonical hub envelope + * (`{error: {code, userMessage, refId}}`), if `body` matches that shape — + * same shape `@corbits/capability-tools`' client reads. */ function errorMessageFrom(body: unknown): string | undefined { if (body === null || typeof body !== "object" || !("error" in body)) { return undefined; } const error = (body as { error: unknown }).error; - if (error === null || typeof error !== "object" || !("message" in error)) { + if ( + error === null || + typeof error !== "object" || + !("userMessage" in error) + ) { return undefined; } - const message = (error as { message: unknown }).message; - return typeof message === "string" ? message : undefined; + const userMessage = (error as { userMessage: unknown }).userMessage; + return typeof userMessage === "string" ? userMessage : undefined; } async function readErrorMessage( diff --git a/packages/agent-directory-tools/src/tool.test.ts b/packages/agent-directory-tools/src/tool.test.ts index c3f5e85f1..3f5365281 100644 --- a/packages/agent-directory-tools/src/tool.test.ts +++ b/packages/agent-directory-tools/src/tool.test.ts @@ -257,7 +257,13 @@ test("create_agent reports a create-succeeded/mint-failed half-failure honestly, ); } return new Response( - JSON.stringify({ error: { code: "not_found", message: "no workbench" } }), + JSON.stringify({ + error: { + code: "not_found", + userMessage: "no workbench", + refId: "ref_test", + }, + }), { status: 404 }, ); }) as unknown as typeof fetch; @@ -379,7 +385,11 @@ test("create_agent surfaces the create route's own rejection honestly on failure globalThis.fetch = (async () => new Response( JSON.stringify({ - error: { code: "conflict", message: "already exists" }, + error: { + code: "conflict", + userMessage: "already exists", + refId: "ref_test", + }, }), { status: 409 }, )) as unknown as typeof fetch; diff --git a/packages/agent-directory/package.json b/packages/agent-directory/package.json index 4b2abb0ff..61f72e536 100644 --- a/packages/agent-directory/package.json +++ b/packages/agent-directory/package.json @@ -15,6 +15,7 @@ }, "dependencies": { "@corbits/chat": "workspace:*", + "@corbits/error-sink": "workspace:*", "@corbits/folded-run-one-shot": "workspace:*", "@corbits/skills": "workspace:*", "@corbits/workflow-catalog": "workspace:*", @@ -27,6 +28,7 @@ "@intx/log": "0.3.0", "@intx/types": "workspace:*", "@intx/workflow": "workspace:*", + "@workbench/hub-client": "workspace:*", "arktype": "catalog:", "drizzle-orm": "catalog:", "hono": "^4.11.9", diff --git a/packages/agent-directory/src/agent-definition-draft-routes.ts b/packages/agent-directory/src/agent-definition-draft-routes.ts index 8c60edfb7..7d3ac64a5 100644 --- a/packages/agent-directory/src/agent-definition-draft-routes.ts +++ b/packages/agent-directory/src/agent-definition-draft-routes.ts @@ -20,6 +20,7 @@ import { FoldedRunFailedError, FoldedRunTimedOutError, } from "@corbits/folded-run-one-shot"; +import { makeErrorEnvelope } from "@workbench/hub-client"; import { AgentDefinitionDraftReferenceOutOfInventoryError, AgentDefinitionDraftReplyUnparseableError, @@ -29,10 +30,6 @@ import { const log = getLogger(["agent-directory", "agent-definition-draft-routes"]); -const ErrorEnvelope = (code: string, message: string) => ({ - error: { code, message }, -}); - const DRAFT_FAILED_MESSAGE = "Myra couldn't draft a starting prompt for that. Write one yourself, or try again."; @@ -98,10 +95,10 @@ export function createAgentDefinitionDraftRoutes( const draftAgentDefinition = deps.draftAgentDefinition; if (draftAgentDefinition === undefined) { return c.json( - ErrorEnvelope( - "unavailable", - "Agent drafting is not configured on this hub.", - ), + makeErrorEnvelope({ + code: "unavailable", + userMessage: "Agent drafting is not configured on this hub.", + }), 503, ); } @@ -110,10 +107,10 @@ export function createAgentDefinitionDraftRoutes( ); if (body instanceof type.errors) { return c.json( - ErrorEnvelope( - "bad_request", - `This couldn't be read: ${body.summary}`, - ), + makeErrorEnvelope({ + code: "bad_request", + userMessage: `This couldn't be read: ${body.summary}`, + }), 400, ); } @@ -123,10 +120,10 @@ export function createAgentDefinitionDraftRoutes( if (inFlightDraftingPrincipals.has(principal.id)) { return c.json( - ErrorEnvelope( - "dispatch_in_progress", - "Myra is already drafting your last agent.", - ), + makeErrorEnvelope({ + code: "dispatch_in_progress", + userMessage: "Myra is already drafting your last agent.", + }), 409, ); } @@ -146,7 +143,10 @@ export function createAgentDefinitionDraftRoutes( }`; if (isDraftingFailure(err)) { return c.json( - ErrorEnvelope("drafting_failed", DRAFT_FAILED_MESSAGE), + makeErrorEnvelope({ + code: "drafting_failed", + userMessage: DRAFT_FAILED_MESSAGE, + }), 422, ); } diff --git a/packages/agent-directory/src/routes.ts b/packages/agent-directory/src/routes.ts index 72ccc5d78..d22b32b60 100644 --- a/packages/agent-directory/src/routes.ts +++ b/packages/agent-directory/src/routes.ts @@ -68,6 +68,8 @@ import { } from "./capability-inventory"; import type { DefinitionAssetHistory } from "./definition-history"; import { listVisibleAgentDefinitions } from "./visible-definitions"; +import { reportError } from "@corbits/error-sink"; +import { makeErrorEnvelope } from "@workbench/hub-client"; /** * Resolves the pinned skill names a definition carries into the @@ -99,19 +101,15 @@ export type CreateAgentDefinitionRoutesDeps = { tenantDefaultModel?: CreateAgentDefinitionCoreDeps["tenantDefaultModel"]; }; -function errorEnvelope(code: string, message: string) { - return { error: { code, message } }; -} - /** The same 404 shape a missing definition gets — deliberately reused * for a workbench host's definition too (see `hostGuardedRow`), so a * caller cannot distinguish "no such definition" from "that id names a * workbench host" by response shape alone. */ function definitionNotFound(definitionId: string) { - return errorEnvelope( - "not_found", - `No agent definition "${definitionId}" in this workbench`, - ); + return makeErrorEnvelope({ + code: "not_found", + userMessage: `No agent definition "${definitionId}" in this workbench`, + }); } /** @@ -151,13 +149,22 @@ export function createAgentDefinitionRoutes({ // one rather than letting it read as a 500. app.onError((err, c) => { if (err instanceof SkillRegistryError) { - return c.json(errorEnvelope("bad_request", err.message), 400); + return c.json( + makeErrorEnvelope({ code: "bad_request", userMessage: err.message }), + 400, + ); } if (err instanceof CapabilityOutOfInventoryError) { - return c.json(errorEnvelope("bad_request", err.message), 400); + return c.json( + makeErrorEnvelope({ code: "bad_request", userMessage: err.message }), + 400, + ); } if (err instanceof RetiredWorkflowEnvelopeError) { - return c.json(errorEnvelope("conflict", err.message), 409); + return c.json( + makeErrorEnvelope({ code: "conflict", userMessage: err.message }), + 409, + ); } throw err; }); @@ -168,10 +175,10 @@ export function createAgentDefinitionRoutes({ ); if (body instanceof type.errors) { return c.json( - errorEnvelope( - "bad_request", - `invalid agent definition: ${body.summary}`, - ), + makeErrorEnvelope({ + code: "bad_request", + userMessage: `invalid agent definition: ${body.summary}`, + }), 400, ); } @@ -220,7 +227,10 @@ export function createAgentDefinitionRoutes({ )); } catch (cause) { if (cause instanceof DuplicateAgentHandleError) { - return c.json(errorEnvelope("conflict", cause.message), 409); + return c.json( + makeErrorEnvelope({ code: "conflict", userMessage: cause.message }), + 409, + ); } throw cause; } @@ -414,7 +424,10 @@ export function createAgentDefinitionRoutes({ ); if (body instanceof type.errors) { return c.json( - errorEnvelope("bad_request", `invalid restore: ${body.summary}`), + makeErrorEnvelope({ + code: "bad_request", + userMessage: `invalid restore: ${body.summary}`, + }), 400, ); } @@ -438,10 +451,10 @@ export function createAgentDefinitionRoutes({ }); if (entryBytes === null) { return c.json( - errorEnvelope( - "not_found", - `agent "${row.name}" has no instructions at that point in its history`, - ), + makeErrorEnvelope({ + code: "not_found", + userMessage: `agent "${row.name}" has no instructions at that point in its history`, + }), 404, ); } @@ -495,7 +508,10 @@ export function createAgentDefinitionRoutes({ ); if (body instanceof type.errors) { return c.json( - errorEnvelope("bad_request", `invalid capability: ${body.summary}`), + makeErrorEnvelope({ + code: "bad_request", + userMessage: `invalid capability: ${body.summary}`, + }), 400, ); } @@ -598,10 +614,10 @@ export function createAgentDefinitionRoutes({ ); if (body instanceof type.errors) { return c.json( - errorEnvelope( - "bad_request", - `invalid agent instructions: ${body.summary}`, - ), + makeErrorEnvelope({ + code: "bad_request", + userMessage: `invalid agent instructions: ${body.summary}`, + }), 400, ); } @@ -665,14 +681,20 @@ export function createAgentDefinitionRoutes({ .set({ displayName: body.name, updatedAt: now }) .where(eq(asset.id, row.assetId)); }); - } catch { + } catch (err) { + const refId = reportError(err, { + operation: "agentDirectory.updateInstructions.rename", + tenantId: tenant.id, + }); return c.json( - errorEnvelope( - "partial_failure", - `The instructions saved, but renaming "${row.name}" to ` + + makeErrorEnvelope({ + code: "partial_failure", + userMessage: + `The instructions saved, but renaming "${row.name}" to ` + `"${body.name}" failed — the agent now answers with the new ` + `instructions under its old name. Retry to finish the rename.`, - ), + refId, + }), 500, ); } @@ -769,7 +791,10 @@ export function createAgentDefinitionRoutes({ ); if (body instanceof type.errors) { return c.json( - errorEnvelope("bad_request", `invalid status: ${body.summary}`), + makeErrorEnvelope({ + code: "bad_request", + userMessage: `invalid status: ${body.summary}`, + }), 400, ); } @@ -809,7 +834,10 @@ export function createAgentDefinitionRoutes({ ); if (body instanceof type.errors) { return c.json( - errorEnvelope("bad_request", `invalid skills list: ${body.summary}`), + makeErrorEnvelope({ + code: "bad_request", + userMessage: `invalid skills list: ${body.summary}`, + }), 400, ); } @@ -824,10 +852,10 @@ export function createAgentDefinitionRoutes({ }); if (row === undefined || row.assetId === null) { return c.json( - errorEnvelope( - "not_found", - `No agent definition "${definitionId}" in this workbench`, - ), + makeErrorEnvelope({ + code: "not_found", + userMessage: `No agent definition "${definitionId}" in this workbench`, + }), 404, ); } diff --git a/packages/agent-directory/src/workflow-capability-routes.ts b/packages/agent-directory/src/workflow-capability-routes.ts index 3d9c29329..58fcafd60 100644 --- a/packages/agent-directory/src/workflow-capability-routes.ts +++ b/packages/agent-directory/src/workflow-capability-routes.ts @@ -69,6 +69,7 @@ import { } from "./definition-asset"; import type { PinnedSkillIndexResolver } from "./routes"; import type { DefinitionSkillsStore } from "./skills-store"; +import { makeErrorEnvelope } from "@workbench/hub-client"; /** * The tenant + principal + run a presented sidecar token and run @@ -96,15 +97,11 @@ export type WorkflowCapabilitiesEnv = { Variables: { workflowCapabilityScope: WorkflowCapabilityRunScope }; }; -function errorEnvelope(code: string, message: string) { - return { error: { code, message } }; -} - function definitionNotFound(definitionId: string) { - return errorEnvelope( - "not_found", - `No agent definition "${definitionId}" in this workbench`, - ); + return makeErrorEnvelope({ + code: "not_found", + userMessage: `No agent definition "${definitionId}" in this workbench`, + }); } /** Same host-guard `./routes.ts` applies: a workbench host is never a @@ -139,10 +136,16 @@ export function createWorkflowCapabilityRoutes( app.onError((err, c) => { if (err instanceof CapabilityOutOfInventoryError) { - return c.json(errorEnvelope("bad_request", err.message), 400); + return c.json( + makeErrorEnvelope({ code: "bad_request", userMessage: err.message }), + 400, + ); } if (err instanceof RetiredWorkflowEnvelopeError) { - return c.json(errorEnvelope("conflict", err.message), 409); + return c.json( + makeErrorEnvelope({ code: "conflict", userMessage: err.message }), + 409, + ); } throw err; }); @@ -156,10 +159,11 @@ export function createWorkflowCapabilityRoutes( const scope = await deps.authenticator.resolve(token, address); if (scope === null) { return c.json( - errorEnvelope( - "unauthorized", - "Missing or unrecognized sidecar bearer token / run address", - ), + makeErrorEnvelope({ + code: "unauthorized", + userMessage: + "Missing or unrecognized sidecar bearer token / run address", + }), 401, ); } @@ -190,10 +194,11 @@ export function createWorkflowCapabilityRoutes( }); if (run === undefined || run.definitionId !== definitionId) { return c.json( - errorEnvelope( - "forbidden", - "A workflow run may only request capabilities for its own agent definition", - ), + makeErrorEnvelope({ + code: "forbidden", + userMessage: + "A workflow run may only request capabilities for its own agent definition", + }), 403, ); } @@ -201,7 +206,10 @@ export function createWorkflowCapabilityRoutes( const body = AddCapabilityInput(await c.req.json().catch(() => undefined)); if (body instanceof type.errors) { return c.json( - errorEnvelope("bad_request", `invalid capability: ${body.summary}`), + makeErrorEnvelope({ + code: "bad_request", + userMessage: `invalid capability: ${body.summary}`, + }), 400, ); } diff --git a/packages/agent-directory/src/workflow-create-routes.ts b/packages/agent-directory/src/workflow-create-routes.ts index bec54ebcc..5e92b412d 100644 --- a/packages/agent-directory/src/workflow-create-routes.ts +++ b/packages/agent-directory/src/workflow-create-routes.ts @@ -55,15 +55,12 @@ import { CapabilityOutOfInventoryError, type CapabilityInventoryProvider, } from "./capability-inventory"; +import { makeErrorEnvelope } from "@workbench/hub-client"; import type { WorkflowCapabilityRunScope, WorkflowRunAuthenticator, } from "./workflow-capability-routes"; -function errorEnvelope(code: string, message: string) { - return { error: { code, message } }; -} - export type WorkflowAgentCreateEnv = { Variables: { workflowCapabilityScope: WorkflowCapabilityRunScope }; }; @@ -119,10 +116,16 @@ export function createWorkflowAgentCreateRoutes( app.onError((err, c) => { if (err instanceof CapabilityOutOfInventoryError) { - return c.json(errorEnvelope("bad_request", err.message), 400); + return c.json( + makeErrorEnvelope({ code: "bad_request", userMessage: err.message }), + 400, + ); } if (err instanceof DuplicateAgentHandleError) { - return c.json(errorEnvelope("conflict", err.message), 409); + return c.json( + makeErrorEnvelope({ code: "conflict", userMessage: err.message }), + 409, + ); } throw err; }); @@ -136,10 +139,11 @@ export function createWorkflowAgentCreateRoutes( const scope = await deps.authenticator.resolve(token, address); if (scope === null) { return c.json( - errorEnvelope( - "unauthorized", - "Missing or unrecognized sidecar bearer token / run address", - ), + makeErrorEnvelope({ + code: "unauthorized", + userMessage: + "Missing or unrecognized sidecar bearer token / run address", + }), 401, ); } @@ -154,10 +158,10 @@ export function createWorkflowAgentCreateRoutes( ); if (body instanceof type.errors) { return c.json( - errorEnvelope( - "bad_request", - `invalid agent definition: ${body.summary}`, - ), + makeErrorEnvelope({ + code: "bad_request", + userMessage: `invalid agent definition: ${body.summary}`, + }), 400, ); } diff --git a/packages/agent-directory/src/workflow-skill-pin-routes.ts b/packages/agent-directory/src/workflow-skill-pin-routes.ts index a9df1b759..979384e4a 100644 --- a/packages/agent-directory/src/workflow-skill-pin-routes.ts +++ b/packages/agent-directory/src/workflow-skill-pin-routes.ts @@ -41,6 +41,7 @@ import { } from "./definition-asset"; import type { PinnedSkillIndexResolver } from "./routes"; import type { DefinitionSkillsStore } from "./skills-store"; +import { makeErrorEnvelope } from "@workbench/hub-client"; import type { WorkflowCapabilityRunScope, WorkflowRunAuthenticator as WorkflowCapabilityRunAuthenticator, @@ -58,15 +59,11 @@ export type WorkflowSkillPinEnv = { Variables: { workflowSkillPinScope: WorkflowSkillPinRunScope }; }; -function errorEnvelope(code: string, message: string) { - return { error: { code, message } }; -} - function definitionNotFound(definitionId: string) { - return errorEnvelope( - "not_found", - `No agent definition "${definitionId}" in this workbench`, - ); + return makeErrorEnvelope({ + code: "not_found", + userMessage: `No agent definition "${definitionId}" in this workbench`, + }); } /** Same host-guard `./routes.ts`/`./workflow-capability-routes.ts` @@ -110,7 +107,10 @@ export function createWorkflowSkillPinRoutes( // conflict, never a server fault. app.onError((err, c) => { if (err instanceof RetiredWorkflowEnvelopeError) { - return c.json(errorEnvelope("conflict", err.message), 409); + return c.json( + makeErrorEnvelope({ code: "conflict", userMessage: err.message }), + 409, + ); } throw err; }); @@ -124,10 +124,11 @@ export function createWorkflowSkillPinRoutes( const scope = await deps.authenticator.resolve(token, address); if (scope === null) { return c.json( - errorEnvelope( - "unauthorized", - "Missing or unrecognized sidecar bearer token / run address", - ), + makeErrorEnvelope({ + code: "unauthorized", + userMessage: + "Missing or unrecognized sidecar bearer token / run address", + }), 401, ); } @@ -140,7 +141,10 @@ export function createWorkflowSkillPinRoutes( const body = PinBody(await c.req.json().catch(() => undefined)); if (body instanceof type.errors) { return c.json( - errorEnvelope("bad_request", `invalid pin: ${body.summary}`), + makeErrorEnvelope({ + code: "bad_request", + userMessage: `invalid pin: ${body.summary}`, + }), 400, ); } diff --git a/packages/agent-directory/test/routes.test.ts b/packages/agent-directory/test/routes.test.ts index 33b311980..81e31863a 100644 --- a/packages/agent-directory/test/routes.test.ts +++ b/packages/agent-directory/test/routes.test.ts @@ -354,8 +354,8 @@ test("a malformed body is rejected with a field-scoped 400", async () => { systemPrompt: "hello", }); expect(response.status).toBe(400); - const body = (await response.json()) as { error: { message: string } }; - expect(body.error.message).toContain("invalid agent definition"); + const body = (await response.json()) as { error: { userMessage: string } }; + expect(body.error.userMessage).toContain("invalid agent definition"); }); test("a missing system prompt is rejected before any asset is created", async () => { @@ -886,10 +886,10 @@ test("GET /:definitionId answers 409, never a 500, for an asset still on the ret const response = await app.request("/def_1"); expect(response.status).toBe(409); const body = (await response.json()) as { - error: { code: string; message: string }; + error: { code: string; userMessage: string }; }; expect(body.error.code).toBe("conflict"); - expect(body.error.message).toContain("workflow.json"); + expect(body.error.userMessage).toContain("workflow.json"); }); test("PUT /:definitionId answers 409 and writes nothing for an asset still on the retired envelope", async () => { @@ -1400,8 +1400,8 @@ test("pinning a skill the registry cannot resolve is a 400, not a 500", async () skills: ["ghost"], }); expect(response.status).toBe(400); - const body = (await response.json()) as { error: { message: string } }; - expect(body.error.message).toContain("ghost"); + const body = (await response.json()) as { error: { userMessage: string } }; + expect(body.error.userMessage).toContain("ghost"); }); test("a create request with no pinned skills stores the author's prompt verbatim", async () => { @@ -1649,8 +1649,8 @@ test("adding a tool package pin the tenant's inventory doesn't offer is a 400, n }); expect(response.status).toBe(400); expect(populateCalled).toBe(false); - const body = (await response.json()) as { error: { message: string } }; - expect(body.error.message).toContain("@corbits/nonexistent-tools"); + const body = (await response.json()) as { error: { userMessage: string } }; + expect(body.error.userMessage).toContain("@corbits/nonexistent-tools"); }); test("adding a skill the inventory doesn't offer is a 400, never written", async () => { diff --git a/packages/agent-workflow-authoring/package.json b/packages/agent-workflow-authoring/package.json index b87f531e8..c51e7119b 100644 --- a/packages/agent-workflow-authoring/package.json +++ b/packages/agent-workflow-authoring/package.json @@ -17,6 +17,7 @@ "@intx/db": "workspace:*", "@intx/hub-sessions": "workspace:*", "@intx/types": "workspace:*", + "@workbench/hub-client": "workspace:*", "arktype": "catalog:", "drizzle-orm": "catalog:", "hono": "^4.11.9" diff --git a/packages/agent-workflow-authoring/src/workflow-routes.test.ts b/packages/agent-workflow-authoring/src/workflow-routes.test.ts index fedd3a199..0454d8a64 100644 --- a/packages/agent-workflow-authoring/src/workflow-routes.test.ts +++ b/packages/agent-workflow-authoring/src/workflow-routes.test.ts @@ -123,10 +123,10 @@ test("an invalid codebase (rejected by the workflow kind handler) comes back 400 ); expect(res.status).toBe(400); const body = (await res.json()) as { - error: { code: string; message: string }; + error: { code: string; userMessage: string; refId: string }; }; expect(body.error.code).toBe("invalid"); - expect(body.error.message).toMatch(/interchange\.workflow/); + expect(body.error.userMessage).toMatch(/interchange\.workflow/); }); test("a malformed request body is rejected 400 before the registry ever runs", async () => { diff --git a/packages/agent-workflow-authoring/src/workflow-routes.ts b/packages/agent-workflow-authoring/src/workflow-routes.ts index ff9fcdded..38dc74d0d 100644 --- a/packages/agent-workflow-authoring/src/workflow-routes.ts +++ b/packages/agent-workflow-authoring/src/workflow-routes.ts @@ -15,6 +15,7 @@ // deliberately out of scope here — see `./registry.ts`'s doc comment. import { type } from "arktype"; import { Hono } from "hono"; +import { makeErrorEnvelope } from "@workbench/hub-client"; import { WorkflowAuthorError, type WorkflowAuthorRegistry } from "./registry"; @@ -73,7 +74,10 @@ export function createWorkflowAuthorRoutes( app.onError((err, c) => { if (err instanceof WorkflowAuthorError) { return c.json( - { error: { code: err.reason, message: err.message } }, + makeErrorEnvelope({ + code: err.reason, + userMessage: err.message, + }), statusFor(err.reason), ); } @@ -89,13 +93,11 @@ export function createWorkflowAuthorRoutes( const scope = await deps.authenticator.resolve(token, address); if (scope === null) { return c.json( - { - error: { - code: "unauthorized", - message: - "Missing or unrecognized sidecar bearer token / run address", - }, - }, + makeErrorEnvelope({ + code: "unauthorized", + userMessage: + "Missing or unrecognized sidecar bearer token / run address", + }), 401, ); } @@ -107,7 +109,10 @@ export function createWorkflowAuthorRoutes( const body = AuthorBody(await c.req.json().catch(() => undefined)); if (body instanceof type.errors) { return c.json( - { error: { code: "bad_request", message: body.summary } }, + makeErrorEnvelope({ + code: "bad_request", + userMessage: body.summary, + }), 400, ); } @@ -120,7 +125,10 @@ export function createWorkflowAuthorRoutes( const body = RepublishBody(await c.req.json().catch(() => undefined)); if (body instanceof type.errors) { return c.json( - { error: { code: "bad_request", message: body.summary } }, + makeErrorEnvelope({ + code: "bad_request", + userMessage: body.summary, + }), 400, ); } diff --git a/packages/agent-workflow-authoring/tsconfig.json b/packages/agent-workflow-authoring/tsconfig.json index 8e1b336e9..d7611c122 100644 --- a/packages/agent-workflow-authoring/tsconfig.json +++ b/packages/agent-workflow-authoring/tsconfig.json @@ -1,25 +1,8 @@ { - "extends": "./tsconfig.src.json", + "extends": "../../tsconfig.base.json", + "include": ["src", "test"], "compilerOptions": { - "composite": false, - "noEmit": true, - "disableSourceOfProjectReferenceRedirect": true, - "declaration": false, - "declarationMap": false, - "emitDeclarationOnly": false, - "rootDir": "../.." - }, - "include": ["src"], - "exclude": [], - "references": [ - { - "path": "../../vendor/intx/db/tsconfig.src.json" - }, - { - "path": "../../vendor/intx/hub-sessions/tsconfig.src.json" - }, - { - "path": "../../vendor/intx/types/tsconfig.src.json" - } - ] + "types": ["bun"], + "noEmit": true + } } diff --git a/packages/agent-workflow-authoring/tsconfig.src.json b/packages/agent-workflow-authoring/tsconfig.src.json deleted file mode 100644 index 3c897c63c..000000000 --- a/packages/agent-workflow-authoring/tsconfig.src.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "include": ["src", "package.json", "src/**/*.json"], - "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"], - "compilerOptions": { - "types": ["bun"], - "composite": true, - "emitDeclarationOnly": true, - "outDir": "dist", - "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo" - }, - "references": [ - { - "path": "../../vendor/intx/db/tsconfig.src.json" - }, - { - "path": "../../vendor/intx/hub-sessions/tsconfig.src.json" - }, - { - "path": "../../vendor/intx/types/tsconfig.src.json" - } - ] -} diff --git a/packages/artifacts-hub/package.json b/packages/artifacts-hub/package.json index 8841166f6..3afa65fd9 100644 --- a/packages/artifacts-hub/package.json +++ b/packages/artifacts-hub/package.json @@ -21,6 +21,7 @@ "@intx/db": "workspace:*", "@intx/hub-api": "workspace:*", "@intx/types": "workspace:*", + "@workbench/hub-client": "workspace:*", "arktype": "catalog:", "drizzle-orm": "catalog:", "hono": "^4.11.9" diff --git a/packages/artifacts-hub/src/routes.ts b/packages/artifacts-hub/src/routes.ts index dd36032c2..19008bf2b 100644 --- a/packages/artifacts-hub/src/routes.ts +++ b/packages/artifacts-hub/src/routes.ts @@ -33,6 +33,7 @@ import { import { isTextDecodableMediaType } from "@corbits/artifact-ui/renderer-kind"; import type { RequireGrant, TenantEnv } from "@intx/hub-api"; import { Hono } from "hono"; +import { makeErrorEnvelope } from "@workbench/hub-client"; const DEFAULT_LIMIT = 50; const MAX_LIMIT = 100; @@ -320,7 +321,10 @@ export function createArtifactRoutes( >; } catch { return c.json( - { error: { code: "bad_request", message: "Invalid multipart body" } }, + makeErrorEnvelope({ + code: "bad_request", + userMessage: "Invalid multipart body", + }), 400, ); } @@ -334,23 +338,19 @@ export function createArtifactRoutes( if (files.length === 0) { return c.json( - { - error: { - code: "bad_request", - message: "Expected at least one file field", - }, - }, + makeErrorEnvelope({ + code: "bad_request", + userMessage: "Expected at least one file field", + }), 400, ); } if (files.length > MAX_UPLOAD_FILE_COUNT) { return c.json( - { - error: { - code: "payload_too_large", - message: `Too many files: ${files.length} exceeds the ${MAX_UPLOAD_FILE_COUNT} file limit`, - }, - }, + makeErrorEnvelope({ + code: "payload_too_large", + userMessage: `Too many files: ${files.length} exceeds the ${MAX_UPLOAD_FILE_COUNT} file limit`, + }), 413, ); } @@ -360,24 +360,20 @@ export function createArtifactRoutes( for (const file of files) { if (file.size > MAX_UPLOAD_BYTES) { return c.json( - { - error: { - code: "payload_too_large", - message: `File "${file.name}" exceeds the ${MAX_UPLOAD_BYTES} byte limit`, - }, - }, + makeErrorEnvelope({ + code: "payload_too_large", + userMessage: `File "${file.name}" exceeds the ${MAX_UPLOAD_BYTES} byte limit`, + }), 413, ); } totalBytes += file.size; if (totalBytes > MAX_UPLOAD_TOTAL_BYTES) { return c.json( - { - error: { - code: "payload_too_large", - message: `Upload exceeds the ${MAX_UPLOAD_TOTAL_BYTES} byte aggregate limit`, - }, - }, + makeErrorEnvelope({ + code: "payload_too_large", + userMessage: `Upload exceeds the ${MAX_UPLOAD_TOTAL_BYTES} byte aggregate limit`, + }), 413, ); } @@ -394,7 +390,10 @@ export function createArtifactRoutes( } catch (err) { if (err instanceof UnsupportedUploadTypeError) { return c.json( - { error: { code: "unsupported_media_type", message: err.message } }, + makeErrorEnvelope({ + code: "unsupported_media_type", + userMessage: err.message, + }), 415, ); } @@ -410,7 +409,10 @@ export function createArtifactRoutes( } catch (err) { if (err instanceof ArtifactCountsIncompleteError) { return c.json( - { error: { code: "counts_unavailable", message: err.message } }, + makeErrorEnvelope({ + code: "counts_unavailable", + userMessage: err.message, + }), 503, ); } @@ -443,18 +445,19 @@ export function createArtifactRoutes( const result = await deps.store.preview(tenant.id, artifactId); if (result.status === "not_found") { return c.json( - { error: { code: "not_found", message: "Artifact not found" } }, + makeErrorEnvelope({ + code: "not_found", + userMessage: "Artifact not found", + }), 404, ); } if (result.status === "unsupported") { return c.json( - { - error: { - code: "unsupported_media_type", - message: "Artifact is not previewable HTML", - }, - }, + makeErrorEnvelope({ + code: "unsupported_media_type", + userMessage: "Artifact is not previewable HTML", + }), 415, ); } @@ -473,7 +476,10 @@ export function createArtifactRoutes( const row = await deps.store.get(tenant.id, artifactId); if (row === null) { return c.json( - { error: { code: "not_found", message: "Artifact not found" } }, + makeErrorEnvelope({ + code: "not_found", + userMessage: "Artifact not found", + }), 404, ); } @@ -496,12 +502,10 @@ export function createUnavailableArtifactRoutes( json: (body: unknown, status: 503) => Response | Promise; }) => c.json( - { - error: { - code: "unavailable", - message: "Artifacts plane is not configured on this hub", - }, - }, + makeErrorEnvelope({ + code: "unavailable", + userMessage: "Artifacts plane is not configured on this hub", + }), 503, ); diff --git a/packages/artifacts-hub/src/template-library.ts b/packages/artifacts-hub/src/template-library.ts index fadca2bd0..1b2f604e4 100644 --- a/packages/artifacts-hub/src/template-library.ts +++ b/packages/artifacts-hub/src/template-library.ts @@ -41,6 +41,7 @@ import type { RequireGrant, TenantEnv } from "@intx/hub-api"; import { hexEncode } from "@intx/types"; import { and, eq, sql } from "drizzle-orm"; import { Hono } from "hono"; +import { makeErrorEnvelope } from "@workbench/hub-client"; export const WORKBENCH_TEMPLATE_ARTIFACT_KIND = "workbench-template"; @@ -444,12 +445,10 @@ export function createTemplateLibraryRoutes( `template library seed failed for ${tenantId}: ${cause instanceof Error ? cause.message : String(cause)}`, ); return Response.json( - { - error: { - code: "unavailable", - message: "The template library isn't ready yet", - }, - }, + makeErrorEnvelope({ + code: "unavailable", + userMessage: "The template library isn't ready yet", + }), { status: 503 }, ); } @@ -470,7 +469,10 @@ export function createTemplateLibraryRoutes( const entry = await deps.store.get(tenant.id, c.req.param("templateId")); if (entry === null) { return c.json( - { error: { code: "not_found", message: "Unknown template" } }, + makeErrorEnvelope({ + code: "not_found", + userMessage: "Unknown template", + }), 404, ); } @@ -495,12 +497,10 @@ export function createUnavailableTemplateLibraryRoutes( json: (body: unknown, status: 503) => Response | Promise; }) => c.json( - { - error: { - code: "unavailable", - message: "Artifacts plane is not configured on this hub", - }, - }, + makeErrorEnvelope({ + code: "unavailable", + userMessage: "Artifacts plane is not configured on this hub", + }), 503, ); diff --git a/packages/artifacts-hub/src/workflow-routes.test.ts b/packages/artifacts-hub/src/workflow-routes.test.ts index bb2e605de..63f3773af 100644 --- a/packages/artifacts-hub/src/workflow-routes.test.ts +++ b/packages/artifacts-hub/src/workflow-routes.test.ts @@ -230,10 +230,10 @@ describe("POST / (create)", () => { }); expect(res.status).toBe(413); const body = (await res.json()) as { - error: { code: string; message: string }; + error: { code: string; userMessage: string; refId: string }; }; expect(body.error.code).toBe("content_too_large"); - expect(body.error.message).toMatch(/shorten|split/); + expect(body.error.userMessage).toMatch(/shorten|split/); expect(created).toHaveLength(0); }); @@ -276,10 +276,10 @@ describe("POST / (create)", () => { const limited = await request(); expect(limited.status).toBe(429); const body = (await limited.json()) as { - error: { code: string; message: string }; + error: { code: string; userMessage: string; refId: string }; }; expect(body.error.code).toBe("rate_limited"); - expect(body.error.message).toMatch(/wait/i); + expect(body.error.userMessage).toMatch(/wait/i); }); }); @@ -441,10 +441,10 @@ describe("POST /binary (create binary artifact)", () => { }); expect(res.status).toBe(413); const body = (await res.json()) as { - error: { code: string; message: string }; + error: { code: string; userMessage: string; refId: string }; }; expect(body.error.code).toBe("content_too_large"); - expect(body.error.message).toMatch(/byte/i); + expect(body.error.userMessage).toMatch(/byte/i); expect(createdBinary).toHaveLength(0); }); diff --git a/packages/artifacts-hub/src/workflow-routes.ts b/packages/artifacts-hub/src/workflow-routes.ts index 2792c7559..dc65fb769 100644 --- a/packages/artifacts-hub/src/workflow-routes.ts +++ b/packages/artifacts-hub/src/workflow-routes.ts @@ -41,6 +41,7 @@ import { type SerializedArtifactListItem, } from "@corbits/artifacts"; import { Hono } from "hono"; +import { makeErrorEnvelope } from "@workbench/hub-client"; import type { ResolvedWorkflowRunScope, @@ -289,13 +290,11 @@ export function createWorkflowArtifactRoutes( const scope = await deps.authenticator.resolve(token, address); if (scope === null) { return c.json( - { - error: { - code: "unauthorized", - message: - "Missing or unrecognized sidecar bearer token / run address", - }, - }, + makeErrorEnvelope({ + code: "unauthorized", + userMessage: + "Missing or unrecognized sidecar bearer token / run address", + }), 401, ); } @@ -309,28 +308,32 @@ export function createWorkflowArtifactRoutes( body = await c.req.json(); } catch { return c.json( - { error: { code: "bad_request", message: "Invalid JSON body" } }, + makeErrorEnvelope({ + code: "bad_request", + userMessage: "Invalid JSON body", + }), 400, ); } const parsed = CreateWorkflowArtifactBody(body); if (parsed instanceof type.errors) { return c.json( - { error: { code: "bad_request", message: parsed.summary } }, + makeErrorEnvelope({ + code: "bad_request", + userMessage: parsed.summary, + }), 400, ); } if (parsed.content.length > MAX_ARTIFACT_CONTENT_CHARS) { return c.json( - { - error: { - code: "content_too_large", - message: - `content is ${parsed.content.length} characters, over the ` + - `${MAX_ARTIFACT_CONTENT_CHARS}-character limit — shorten it ` + - "or split it into multiple artifacts and try again.", - }, - }, + makeErrorEnvelope({ + code: "content_too_large", + userMessage: + `content is ${parsed.content.length} characters, over the ` + + `${MAX_ARTIFACT_CONTENT_CHARS}-character limit — shorten it ` + + "or split it into multiple artifacts and try again.", + }), 413, ); } @@ -338,15 +341,13 @@ export function createWorkflowArtifactRoutes( const scope = c.get("workflowRunScope"); if (!createRateLimiter.allow(scope.runId)) { return c.json( - { - error: { - code: "rate_limited", - message: - `too many artifact writes for this run in the last minute ` + - `(limit ${MAX_CREATES_PER_RUN_PER_MINUTE}/min) — wait a ` + - "moment before creating more.", - }, - }, + makeErrorEnvelope({ + code: "rate_limited", + userMessage: + `too many artifact writes for this run in the last minute ` + + `(limit ${MAX_CREATES_PER_RUN_PER_MINUTE}/min) — wait a ` + + "moment before creating more.", + }), 429, ); } @@ -367,14 +368,20 @@ export function createWorkflowArtifactRoutes( body = await c.req.json(); } catch { return c.json( - { error: { code: "bad_request", message: "Invalid JSON body" } }, + makeErrorEnvelope({ + code: "bad_request", + userMessage: "Invalid JSON body", + }), 400, ); } const parsed = CreateWorkflowBinaryArtifactBody(body); if (parsed instanceof type.errors) { return c.json( - { error: { code: "bad_request", message: parsed.summary } }, + makeErrorEnvelope({ + code: "bad_request", + userMessage: parsed.summary, + }), 400, ); } @@ -382,15 +389,13 @@ export function createWorkflowArtifactRoutes( const bytes = Buffer.from(parsed.contentBase64, "base64"); if (bytes.byteLength > MAX_WORKFLOW_BINARY_BYTES) { return c.json( - { - error: { - code: "content_too_large", - message: - `content is ${bytes.byteLength} bytes, over the ` + - `${MAX_WORKFLOW_BINARY_BYTES}-byte limit — shorten it or ` + - "split it into multiple artifacts and try again.", - }, - }, + makeErrorEnvelope({ + code: "content_too_large", + userMessage: + `content is ${bytes.byteLength} bytes, over the ` + + `${MAX_WORKFLOW_BINARY_BYTES}-byte limit — shorten it or ` + + "split it into multiple artifacts and try again.", + }), 413, ); } @@ -398,15 +403,13 @@ export function createWorkflowArtifactRoutes( const scope = c.get("workflowRunScope"); if (!createRateLimiter.allow(scope.runId)) { return c.json( - { - error: { - code: "rate_limited", - message: - `too many artifact writes for this run in the last minute ` + - `(limit ${MAX_CREATES_PER_RUN_PER_MINUTE}/min) — wait a ` + - "moment before creating more.", - }, - }, + makeErrorEnvelope({ + code: "rate_limited", + userMessage: + `too many artifact writes for this run in the last minute ` + + `(limit ${MAX_CREATES_PER_RUN_PER_MINUTE}/min) — wait a ` + + "moment before creating more.", + }), 429, ); } @@ -421,7 +424,10 @@ export function createWorkflowArtifactRoutes( } catch (err) { if (err instanceof UnsupportedUploadTypeError) { return c.json( - { error: { code: "unsupported_media_type", message: err.message } }, + makeErrorEnvelope({ + code: "unsupported_media_type", + userMessage: err.message, + }), 415, ); } @@ -435,7 +441,10 @@ export function createWorkflowArtifactRoutes( const row = await deps.store.get(scope, artifactId); if (row === null) { return c.json( - { error: { code: "not_found", message: "Artifact not found" } }, + makeErrorEnvelope({ + code: "not_found", + userMessage: "Artifact not found", + }), 404, ); } @@ -455,12 +464,10 @@ export function createUnavailableWorkflowArtifactRoutes(): Hono Response | Promise; }) => c.json( - { - error: { - code: "unavailable", - message: "Artifacts plane is not configured on this hub", - }, - }, + makeErrorEnvelope({ + code: "unavailable", + userMessage: "Artifacts plane is not configured on this hub", + }), 503, ); app.post("/", unavailable); diff --git a/packages/bench/package.json b/packages/bench/package.json index 133a7ded4..a3cc68444 100644 --- a/packages/bench/package.json +++ b/packages/bench/package.json @@ -18,6 +18,7 @@ "@corbits/api-query": "workspace:*", "@corbits/migration-runner": "workspace:*", "@intx/hub-api": "workspace:*", + "@workbench/hub-client": "workspace:*", "arktype": "catalog:", "drizzle-orm": "catalog:", "hono": "^4.11.9", diff --git a/packages/bench/src/routes.ts b/packages/bench/src/routes.ts index 2e20855f0..72480c99d 100644 --- a/packages/bench/src/routes.ts +++ b/packages/bench/src/routes.ts @@ -7,10 +7,7 @@ import { type } from "arktype"; import type { RequireGrant, TenantEnv } from "@intx/hub-api"; import type { BenchSettingsStore } from "./store"; - -const ErrorEnvelope = (code: string, message: string) => ({ - error: { code, message }, -}); +import { makeErrorEnvelope } from "@workbench/hub-client"; // `BenchCreateType` in packages/bench-ui/src/create-bench-dialog.tsx allows // exactly "global" | "sub" today; validated strictly here rather than as a @@ -44,7 +41,10 @@ export function createBenchRoutes( const patch = PatchBody(raw); if (patch instanceof type.errors) { return c.json( - ErrorEnvelope("bad_request", `invalid body: ${patch.summary}`), + makeErrorEnvelope({ + code: "bad_request", + userMessage: `invalid body: ${patch.summary}`, + }), 400, ); } diff --git a/packages/bench/tsconfig.json b/packages/bench/tsconfig.json index 363a89fac..d7611c122 100644 --- a/packages/bench/tsconfig.json +++ b/packages/bench/tsconfig.json @@ -1,25 +1,8 @@ { - "extends": "./tsconfig.src.json", - "compilerOptions": { - "composite": false, - "noEmit": true, - "disableSourceOfProjectReferenceRedirect": true, - "declaration": false, - "declarationMap": false, - "emitDeclarationOnly": false, - "rootDir": "../.." - }, + "extends": "../../tsconfig.base.json", "include": ["src", "test"], - "exclude": [], - "references": [ - { - "path": "../../vendor/intx/hub-api/tsconfig.src.json" - }, - { - "path": "../api-query/tsconfig.src.json" - }, - { - "path": "../migration-runner/tsconfig.src.json" - } - ] + "compilerOptions": { + "types": ["bun"], + "noEmit": true + } } diff --git a/packages/bench/tsconfig.src.json b/packages/bench/tsconfig.src.json deleted file mode 100644 index 4cc48381c..000000000 --- a/packages/bench/tsconfig.src.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "include": ["src", "package.json", "src/**/*.json"], - "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"], - "compilerOptions": { - "types": ["bun"], - "composite": true, - "emitDeclarationOnly": true, - "outDir": "dist", - "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo" - }, - "references": [ - { - "path": "../../vendor/intx/hub-api/tsconfig.src.json" - }, - { - "path": "../api-query/tsconfig.src.json" - }, - { - "path": "../migration-runner/tsconfig.src.json" - } - ] -} diff --git a/packages/capability-tools/package.json b/packages/capability-tools/package.json index 5decb7591..5d27b216e 100644 --- a/packages/capability-tools/package.json +++ b/packages/capability-tools/package.json @@ -2,7 +2,7 @@ "name": "@corbits/capability-tools", "private": true, "description": "The request_capability tool as an @intx/agent tool bundle (CL-6084): an agent asks in-chat for a tool package, skill, or model it doesn't have, gated behind Interchange's native per-invocation approval, and \u2014 once approved \u2014 calls the sanctioned workflow-run capabilities surface with the run's own bearer token, never a model-supplied identity", - "version": "0.0.3", + "version": "0.0.4", "license": "LGPL-2.1-or-later", "type": "module", "exports": { diff --git a/packages/capability-tools/src/client.test.ts b/packages/capability-tools/src/client.test.ts index 6a48a0806..d512ced3b 100644 --- a/packages/capability-tools/src/client.test.ts +++ b/packages/capability-tools/src/client.test.ts @@ -56,8 +56,9 @@ test("addCapability throws CapabilityOutOfInventoryError on the route's fail-clo JSON.stringify({ error: { code: "bad_request", - message: + userMessage: '"@corbits/nonexistent" for "toolPackage" was never offered in this workbench\'s inventory', + refId: "ref_test", }, }), { status: 400 }, diff --git a/packages/capability-tools/src/client.ts b/packages/capability-tools/src/client.ts index 205fc132d..672f97ccf 100644 --- a/packages/capability-tools/src/client.ts +++ b/packages/capability-tools/src/client.ts @@ -87,18 +87,22 @@ function authHeaders( }; } -/** Pulls `error.message` out of a Hono `app.onError` envelope - * (`{error: {code, message}}`), if `body` matches that shape. */ +/** Pulls `error.userMessage` out of the canonical hub envelope + * (`{error: {code, userMessage, refId}}`), if `body` matches that shape. */ function errorMessageFrom(body: unknown): string | undefined { if (body === null || typeof body !== "object" || !("error" in body)) { return undefined; } const error = (body as { error: unknown }).error; - if (error === null || typeof error !== "object" || !("message" in error)) { + if ( + error === null || + typeof error !== "object" || + !("userMessage" in error) + ) { return undefined; } - const message = (error as { message: unknown }).message; - return typeof message === "string" ? message : undefined; + const userMessage = (error as { userMessage: unknown }).userMessage; + return typeof userMessage === "string" ? userMessage : undefined; } function endpoint(config: CapabilityToolClientConfig, path: string): string { diff --git a/packages/capability-tools/src/tool.test.ts b/packages/capability-tools/src/tool.test.ts index c73c78d02..9289a5484 100644 --- a/packages/capability-tools/src/tool.test.ts +++ b/packages/capability-tools/src/tool.test.ts @@ -147,7 +147,11 @@ test("an out-of-inventory request reports what's actually available, never a fab if (call === 1) { return new Response( JSON.stringify({ - error: { code: "bad_request", message: "out of inventory" }, + error: { + code: "bad_request", + userMessage: "out of inventory", + refId: "ref_test", + }, }), { status: 400 }, ); @@ -189,7 +193,11 @@ test("falls back to the route's own message if the inventory itself can't be fet if (call === 1) { return new Response( JSON.stringify({ - error: { code: "bad_request", message: "nothing named that" }, + error: { + code: "bad_request", + userMessage: "nothing named that", + refId: "ref_test", + }, }), { status: 400 }, ); diff --git a/packages/catalog-tools/package.json b/packages/catalog-tools/package.json index 13f94eae7..842ccfef6 100644 --- a/packages/catalog-tools/package.json +++ b/packages/catalog-tools/package.json @@ -2,7 +2,7 @@ "name": "@corbits/catalog-tools", "private": true, "description": "The list_model_concepts, pick_models and estimate_run_cost tools as an @intx/agent bundle: an agent asks for a model by what the work needs and gets back an ordered, priced chain of what this bench can actually reach — never a model named by hand.", - "version": "0.0.1", + "version": "0.0.2", "license": "LGPL-2.1-or-later", "type": "module", "exports": { diff --git a/packages/catalog-tools/src/client.test.ts b/packages/catalog-tools/src/client.test.ts index 228486c6a..9fe23814e 100644 --- a/packages/catalog-tools/src/client.test.ts +++ b/packages/catalog-tools/src/client.test.ts @@ -62,7 +62,8 @@ describe("catalog tool client", () => { JSON.stringify({ error: { code: "bad_request", - message: '"jazz" is not a kind of work', + userMessage: '"jazz" is not a kind of work', + refId: "ref_test", }, }), { status: 400 }, diff --git a/packages/catalog-tools/src/client.ts b/packages/catalog-tools/src/client.ts index d098f5317..487eb8737 100644 --- a/packages/catalog-tools/src/client.ts +++ b/packages/catalog-tools/src/client.ts @@ -87,6 +87,24 @@ function authHeaders(config: CatalogToolClientConfig): Record { }; } +/** Pulls `error.userMessage` out of the canonical hub envelope + * (`{error: {code, userMessage, refId}}`), if `body` matches that shape. */ +function errorMessageFrom(body: unknown): string | undefined { + if (body === null || typeof body !== "object" || !("error" in body)) { + return undefined; + } + const error = (body as { error: unknown }).error; + if ( + error === null || + typeof error !== "object" || + !("userMessage" in error) + ) { + return undefined; + } + const userMessage = (error as { userMessage: unknown }).userMessage; + return typeof userMessage === "string" ? userMessage : undefined; +} + async function call( config: CatalogToolClientConfig, path: string, @@ -108,13 +126,7 @@ async function call( if (!response.ok) { const detail: unknown = await response.json().catch(() => undefined); const message = - typeof detail === "object" && - detail !== null && - "error" in detail && - typeof (detail as { error: { message?: unknown } }).error.message === - "string" - ? (detail as { error: { message: string } }).error.message - : `${response.status} ${response.statusText}`; + errorMessageFrom(detail) ?? `${response.status} ${response.statusText}`; throw new Error(`${what} failed: ${message}`); } const parsed = schema(await response.json()); diff --git a/packages/chat/src/routes.ts b/packages/chat/src/routes.ts index 749d06686..417c79015 100644 --- a/packages/chat/src/routes.ts +++ b/packages/chat/src/routes.ts @@ -110,7 +110,7 @@ import { DefinitionProjectionMissingError, } from "@corbits/folded-runs"; import type { WorkbenchTenancyStore } from "./workbench-tenancy"; -import { cookiesFromHeader } from "@workbench/hub-client"; +import { cookiesFromHeader, makeErrorEnvelope } from "@workbench/hub-client"; import type { AgentTurnStore } from "./agent-turns"; import type { ThreadStore } from "./threads"; import { ThreadDepthCapError } from "./threads"; @@ -345,10 +345,6 @@ export type CreateChatRoutesDeps = { const log = getLogger(["chat", "routes"]); -const ErrorEnvelope = (code: string, message: string) => ({ - error: { code, message }, -}); - const CreateWorkbenchBody = type({ kind: "string", "name?": "string", @@ -947,10 +943,10 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { ); if (body instanceof type.errors) { return c.json( - ErrorEnvelope( - "bad_request", - `invalid workbench body: ${body.summary}`, - ), + makeErrorEnvelope({ + code: "bad_request", + userMessage: `invalid workbench body: ${body.summary}`, + }), 400, ); } @@ -961,12 +957,13 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { body.principalId === undefined ) { return c.json( - ErrorEnvelope( - "bad_request", - "creating a chat requires either a definitionId naming the " + + makeErrorEnvelope({ + code: "bad_request", + userMessage: + "creating a chat requires either a definitionId naming the " + "one agent it launches with, or a principalId naming the " + "one bench member it's a direct conversation with", - ), + }), 400, ); } @@ -976,11 +973,12 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { body.principalId !== undefined ) { return c.json( - ErrorEnvelope( - "bad_request", - "a chat's counterpart is exactly one agent or one person, " + + makeErrorEnvelope({ + code: "bad_request", + userMessage: + "a chat's counterpart is exactly one agent or one person, " + "never both", - ), + }), 400, ); } @@ -1022,10 +1020,10 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { if (isChatWithPrincipal(body)) { if (body.principalId === principal.id) { return c.json( - ErrorEnvelope( - "conflict", - "you cannot start a direct chat with yourself", - ), + makeErrorEnvelope({ + code: "conflict", + userMessage: "you cannot start a direct chat with yourself", + }), 409, ); } @@ -1039,10 +1037,11 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { target.status !== "active" ) { return c.json( - ErrorEnvelope( - "bad_request", - "principalId does not name an active member of this bench", - ), + makeErrorEnvelope({ + code: "bad_request", + userMessage: + "principalId does not name an active member of this bench", + }), 400, ); } @@ -1194,14 +1193,20 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { // 4xx — never an unhandled 500 — with the same compensation // every other agent-mint failure already ran above. if (err instanceof DefinitionProjectionMissingError) { - return c.json(ErrorEnvelope("not_launchable", err.guidance), 409); + return c.json( + makeErrorEnvelope({ + code: "not_launchable", + userMessage: err.guidance, + }), + 409, + ); } if (err instanceof InferenceResolutionError) { return c.json( - ErrorEnvelope( - "not_launchable", - MODEL_UNAVAILABLE_CONSUMER_MESSAGE, - ), + makeErrorEnvelope({ + code: "not_launchable", + userMessage: MODEL_UNAVAILABLE_CONSUMER_MESSAGE, + }), 409, ); } @@ -1281,10 +1286,11 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { ); if (memberPrincipal === undefined) { return c.json( - ErrorEnvelope( - "bad_request", - "principalId does not name an active member of this bench", - ), + makeErrorEnvelope({ + code: "bad_request", + userMessage: + "principalId does not name an active member of this bench", + }), 400, ); } @@ -1528,7 +1534,13 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { const tenant = c.get("tenant"); const workbenchId = c.req.param("id"); if (!(await workbenchInTenant(deps.store, tenant.id, workbenchId))) { - return c.json(ErrorEnvelope("not_found", "workbench not found"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "workbench not found", + }), + 404, + ); } if (deps.threads === undefined) { return c.json({ rootThreadId: "", items: [] as const }); @@ -1594,10 +1606,22 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { const tenant = c.get("tenant"); const workbenchId = c.req.param("id"); if (!(await workbenchInTenant(deps.store, tenant.id, workbenchId))) { - return c.json(ErrorEnvelope("not_found", "workbench not found"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "workbench not found", + }), + 404, + ); } if (deps.threads === undefined) { - return c.json(ErrorEnvelope("not_found", "threads not available"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "threads not available", + }), + 404, + ); } const body = type({ parentMessageId: "string", @@ -1605,7 +1629,10 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { })(await c.req.json().catch(() => undefined)); if (body instanceof type.errors) { return c.json( - ErrorEnvelope("bad_request", `invalid body: ${body.summary}`), + makeErrorEnvelope({ + code: "bad_request", + userMessage: `invalid body: ${body.summary}`, + }), 400, ); } @@ -1643,14 +1670,32 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { const workbenchId = c.req.param("id"); const threadId = c.req.param("threadId"); if (!(await workbenchInTenant(deps.store, tenant.id, workbenchId))) { - return c.json(ErrorEnvelope("not_found", "workbench not found"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "workbench not found", + }), + 404, + ); } if (deps.threads === undefined) { - return c.json(ErrorEnvelope("not_found", "threads not available"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "threads not available", + }), + 404, + ); } const thread = await deps.threads.getThread(tenant.id, threadId); if (thread === undefined || thread.workbenchId !== workbenchId) { - return c.json(ErrorEnvelope("not_found", "thread not found"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "thread not found", + }), + 404, + ); } // A message's thread is the one it was assigned to, or the root // thread when it was never assigned at all — `workbench_thread_messages` @@ -1663,7 +1708,13 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { // of them, a fresh chat's very first agent reply included. const membership = await resolveThreadMembership(tenant.id, workbenchId); if (membership === undefined) { - return c.json(ErrorEnvelope("not_found", "threads not available"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "threads not available", + }), + 404, + ); } const listed = await deps.roomMessages.listMessages({ tenantId: tenant.id, @@ -1705,10 +1756,22 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { const tenant = c.get("tenant"); const workbenchId = c.req.param("id"); if (!(await workbenchInTenant(deps.store, tenant.id, workbenchId))) { - return c.json(ErrorEnvelope("not_found", "workbench not found"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "workbench not found", + }), + 404, + ); } if (deps.threads === undefined) { - return c.json(ErrorEnvelope("not_found", "threads not available"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "threads not available", + }), + 404, + ); } const body = type({ runRef: "string", @@ -1716,7 +1779,10 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { })(await c.req.json().catch(() => undefined)); if (body instanceof type.errors) { return c.json( - ErrorEnvelope("bad_request", `invalid body: ${body.summary}`), + makeErrorEnvelope({ + code: "bad_request", + userMessage: `invalid body: ${body.summary}`, + }), 400, ); } @@ -1760,7 +1826,13 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { principal.refId, ); if (access === undefined) { - return c.json(ErrorEnvelope("not_found", "workbench not found"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "workbench not found", + }), + 404, + ); } const listParams = { tenantId: access.ownerTenantId, workbenchId }; @@ -1840,13 +1912,31 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { principal.refId, )) === undefined ) { - return c.json(ErrorEnvelope("not_found", "workbench not found"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "workbench not found", + }), + 404, + ); } let blob: string | Uint8Array; try { blob = await deps.platform.fetchBlob(workbenchId, blobId); - } catch { - return c.json(ErrorEnvelope("not_found", "blob not found"), 404); + } catch (err) { + const refId = reportError(err, { + operation: "chat.blob.fetch", + tenantId: tenant.id, + roomId: workbenchId, + }); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "blob not found", + refId, + }), + 404, + ); } const contentBase64 = typeof blob === "string" @@ -1879,10 +1969,10 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { const parsed = PostMessageBody(raw); if (parsed instanceof type.errors) { return c.json( - ErrorEnvelope( - "bad_request", - `invalid message body: ${parsed.summary}`, - ), + makeErrorEnvelope({ + code: "bad_request", + userMessage: `invalid message body: ${parsed.summary}`, + }), 400, ); } @@ -1900,7 +1990,13 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { principal.refId, ); if (access === undefined) { - return c.json(ErrorEnvelope("not_found", "workbench not found"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "workbench not found", + }), + 404, + ); } const ownerTenantId = access.ownerTenantId; @@ -1928,10 +2024,10 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { ); if (denied !== undefined) { return c.json( - ErrorEnvelope( - "forbidden", - "You can't add people to this workbench", - ), + makeErrorEnvelope({ + code: "forbidden", + userMessage: "You can't add people to this workbench", + }), 403, ); } @@ -1941,7 +2037,13 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { workbenchId, ); if (existing === undefined) { - return c.json(ErrorEnvelope("not_found", "workbench not found"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "workbench not found", + }), + 404, + ); } let currentSettings = existing.settings; @@ -1980,21 +2082,30 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { } catch (err) { if (err instanceof InferenceResolutionError) { return c.json( - ErrorEnvelope( - "not_launchable", - MODEL_UNAVAILABLE_CONSUMER_MESSAGE, - ), + makeErrorEnvelope({ + code: "not_launchable", + userMessage: MODEL_UNAVAILABLE_CONSUMER_MESSAGE, + }), 409, ); } if (err instanceof DefinitionProjectionMissingError) { return c.json( - ErrorEnvelope("not_launchable", err.guidance), + makeErrorEnvelope({ + code: "not_launchable", + userMessage: err.guidance, + }), 409, ); } if (err instanceof KindIsChatError) { - return c.json(ErrorEnvelope(err.code, err.message), 409); + return c.json( + makeErrorEnvelope({ + code: err.code, + userMessage: err.message, + }), + 409, + ); } throw err; } @@ -2011,10 +2122,11 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { target.status !== "active" ) { return c.json( - ErrorEnvelope( - "bad_request", - "principalId does not name an active member of this bench", - ), + makeErrorEnvelope({ + code: "bad_request", + userMessage: + "principalId does not name an active member of this bench", + }), 400, ); } @@ -2056,7 +2168,10 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { }); } catch (err) { if (err instanceof KindIsChatError) { - return c.json(ErrorEnvelope(err.code, err.message), 409); + return c.json( + makeErrorEnvelope({ code: err.code, userMessage: err.message }), + 409, + ); } throw err; } @@ -2099,7 +2214,13 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { parsed.threadId, ); if (existing === undefined || existing.workbenchId !== workbenchId) { - return c.json(ErrorEnvelope("not_found", "thread not found"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "thread not found", + }), + 404, + ); } targetThreadId = existing.id; } else if (parsed.inReplyToMessageId !== undefined) { @@ -2112,7 +2233,13 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { }); } catch (cause) { if (cause instanceof ThreadDepthCapError) { - return c.json(ErrorEnvelope("conflict", cause.message), 409); + return c.json( + makeErrorEnvelope({ + code: "conflict", + userMessage: cause.message, + }), + 409, + ); } throw cause; } @@ -2209,7 +2336,10 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { async (c) => { if (deps.blockResponses === undefined) { return c.json( - ErrorEnvelope("not_found", "block responses not available"), + makeErrorEnvelope({ + code: "not_found", + userMessage: "block responses not available", + }), 404, ); } @@ -2228,7 +2358,13 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { principal.refId, ); if (access === undefined) { - return c.json(ErrorEnvelope("not_found", "workbench not found"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "workbench not found", + }), + 404, + ); } const ownerTenantId = access.ownerTenantId; @@ -2237,10 +2373,10 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { ); if (body instanceof type.errors) { return c.json( - ErrorEnvelope( - "bad_request", - `invalid response body: ${body.summary}`, - ), + makeErrorEnvelope({ + code: "bad_request", + userMessage: `invalid response body: ${body.summary}`, + }), 400, ); } @@ -2360,10 +2496,11 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { claimToken, ); return c.json( - ErrorEnvelope( - "notify_failed", - `your answer was saved, but the agent couldn't be notified — try again (ref ${refId})`, - ), + makeErrorEnvelope({ + code: "notify_failed", + userMessage: `your answer was saved, but the agent couldn't be notified — try again (ref ${refId})`, + refId, + }), 500, ); } @@ -2380,7 +2517,10 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { async (c) => { if (deps.blockResponses === undefined) { return c.json( - ErrorEnvelope("not_found", "block responses not available"), + makeErrorEnvelope({ + code: "not_found", + userMessage: "block responses not available", + }), 404, ); } @@ -2399,7 +2539,13 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { principal.refId, ); if (access === undefined) { - return c.json(ErrorEnvelope("not_found", "workbench not found"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "workbench not found", + }), + 404, + ); } // Every response on file for this block, read once and filtered down @@ -2427,7 +2573,10 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { async (c) => { if (deps.reactions === undefined) { return c.json( - ErrorEnvelope("not_found", "reactions not available"), + makeErrorEnvelope({ + code: "not_found", + userMessage: "reactions not available", + }), 404, ); } @@ -2445,7 +2594,13 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { principal.refId, ); if (access === undefined) { - return c.json(ErrorEnvelope("not_found", "workbench not found"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "workbench not found", + }), + 404, + ); } const ownerTenantId = access.ownerTenantId; if ( @@ -2456,7 +2611,13 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { messageId, )) ) { - return c.json(ErrorEnvelope("not_found", "message not found"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "message not found", + }), + 404, + ); } const body = ToggleReactionBody( @@ -2464,19 +2625,19 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { ); if (body instanceof type.errors) { return c.json( - ErrorEnvelope( - "bad_request", - `invalid reaction body: ${body.summary}`, - ), + makeErrorEnvelope({ + code: "bad_request", + userMessage: `invalid reaction body: ${body.summary}`, + }), 400, ); } if (!isKnownReactionEmoji(body.emoji)) { return c.json( - ErrorEnvelope( - "bad_request", - `${JSON.stringify(body.emoji)} is not a supported reaction`, - ), + makeErrorEnvelope({ + code: "bad_request", + userMessage: `${JSON.stringify(body.emoji)} is not a supported reaction`, + }), 400, ); } @@ -2515,7 +2676,13 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { deps.requireGrant(idResource("room", "id"), "write"), async (c) => { if (deps.pins === undefined) { - return c.json(ErrorEnvelope("not_found", "pins not available"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "pins not available", + }), + 404, + ); } const tenant = c.get("tenant"); @@ -2531,7 +2698,13 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { principal.refId, ); if (access === undefined) { - return c.json(ErrorEnvelope("not_found", "workbench not found"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "workbench not found", + }), + 404, + ); } const ownerTenantId = access.ownerTenantId; if ( @@ -2542,7 +2715,13 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { messageId, )) ) { - return c.json(ErrorEnvelope("not_found", "message not found"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "message not found", + }), + 404, + ); } const row = await deps.pins.pinMessage({ @@ -2575,7 +2754,13 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { deps.requireGrant(idResource("room", "id"), "write"), async (c) => { if (deps.pins === undefined) { - return c.json(ErrorEnvelope("not_found", "pins not available"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "pins not available", + }), + 404, + ); } const tenant = c.get("tenant"); @@ -2591,7 +2776,13 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { principal.refId, ); if (access === undefined) { - return c.json(ErrorEnvelope("not_found", "workbench not found"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "workbench not found", + }), + 404, + ); } await deps.pins.unpinMessage( @@ -2614,7 +2805,13 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { deps.requireGrant(idResource("room", "id"), "read"), async (c) => { if (deps.pins === undefined) { - return c.json(ErrorEnvelope("not_found", "pins not available"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "pins not available", + }), + 404, + ); } const tenant = c.get("tenant"); @@ -2629,7 +2826,13 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { principal.refId, ); if (access === undefined) { - return c.json(ErrorEnvelope("not_found", "workbench not found"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "workbench not found", + }), + 404, + ); } const ownerTenantId = access.ownerTenantId; @@ -2690,7 +2893,13 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { existing === undefined && !(await workbenchInTenant(deps.store, tenant.id, workbenchId)) ) { - return c.json(ErrorEnvelope("not_found", "workbench not found"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "workbench not found", + }), + 404, + ); } // A definition already in the room isn't invitable — resolve each @@ -2735,7 +2944,13 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { workbenchId, ); if (existing === undefined) { - return c.json(ErrorEnvelope("not_found", "workbench not found"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "workbench not found", + }), + 404, + ); } const agentParticipants = participantsOf(existing.settings).filter( @@ -2780,7 +2995,10 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { const body = RefreshAgentBody(await c.req.json().catch(() => undefined)); if (body instanceof type.errors) { return c.json( - ErrorEnvelope("bad_request", `invalid refresh body: ${body.summary}`), + makeErrorEnvelope({ + code: "bad_request", + userMessage: `invalid refresh body: ${body.summary}`, + }), 400, ); } @@ -2803,7 +3021,10 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { const body = InviteAgentBody(await c.req.json().catch(() => undefined)); if (body instanceof type.errors) { return c.json( - ErrorEnvelope("bad_request", `invalid invite body: ${body.summary}`), + makeErrorEnvelope({ + code: "bad_request", + userMessage: `invalid invite body: ${body.summary}`, + }), 400, ); } @@ -2817,7 +3038,13 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { workbenchId, ); if (existing === undefined) { - return c.json(ErrorEnvelope("not_found", "workbench not found"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "workbench not found", + }), + 404, + ); } try { @@ -2845,15 +3072,27 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { } catch (err) { if (err instanceof InferenceResolutionError) { return c.json( - ErrorEnvelope("not_launchable", MODEL_UNAVAILABLE_CONSUMER_MESSAGE), + makeErrorEnvelope({ + code: "not_launchable", + userMessage: MODEL_UNAVAILABLE_CONSUMER_MESSAGE, + }), 409, ); } if (err instanceof DefinitionProjectionMissingError) { - return c.json(ErrorEnvelope("not_launchable", err.guidance), 409); + return c.json( + makeErrorEnvelope({ + code: "not_launchable", + userMessage: err.guidance, + }), + 409, + ); } if (err instanceof KindIsChatError) { - return c.json(ErrorEnvelope(err.code, err.message), 409); + return c.json( + makeErrorEnvelope({ code: err.code, userMessage: err.message }), + 409, + ); } throw err; } @@ -2875,10 +3114,10 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { ); if (step instanceof type.errors) { return c.json( - ErrorEnvelope( - "bad_request", - `invalid onboarding step: ${step.summary}`, - ), + makeErrorEnvelope({ + code: "bad_request", + userMessage: `invalid onboarding step: ${step.summary}`, + }), 400, ); } @@ -2890,7 +3129,13 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { workbenchId, ); if (existing === undefined) { - return c.json(ErrorEnvelope("not_found", "workbench not found"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "workbench not found", + }), + 404, + ); } const data: ConnectGithubBlockData = { @@ -2927,20 +3172,20 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { const address = decodedOrNull(c.req.param("address")); if (address === null) { return c.json( - ErrorEnvelope( - "bad_request", - "invalid participant: malformed address", - ), + makeErrorEnvelope({ + code: "bad_request", + userMessage: "invalid participant: malformed address", + }), 400, ); } const params = RemoveParticipantParams({ address }); if (params instanceof type.errors) { return c.json( - ErrorEnvelope( - "bad_request", - `invalid participant: ${params.summary}`, - ), + makeErrorEnvelope({ + code: "bad_request", + userMessage: `invalid participant: ${params.summary}`, + }), 400, ); } @@ -2954,16 +3199,23 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { workbenchId, ); if (existing === undefined) { - return c.json(ErrorEnvelope("not_found", "workbench not found"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "workbench not found", + }), + 404, + ); } if (kindOf(existing.settings) === "chat") { return c.json( - ErrorEnvelope( - "conflict", - "a chat's participants are fixed at creation; removal is " + + makeErrorEnvelope({ + code: "conflict", + userMessage: + "a chat's participants are fixed at creation; removal is " + "only for workbenches", - ), + }), 409, ); } @@ -2972,7 +3224,13 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { (candidate) => candidate.address === params.address, ); if (participant === undefined) { - return c.json(ErrorEnvelope("not_found", "participant not found"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "participant not found", + }), + 404, + ); } await removeWorkbenchParticipant( @@ -3001,7 +3259,10 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { const body = MoveWorkbenchBody(await c.req.json().catch(() => undefined)); if (body instanceof type.errors) { return c.json( - ErrorEnvelope("bad_request", `invalid move body: ${body.summary}`), + makeErrorEnvelope({ + code: "bad_request", + userMessage: `invalid move body: ${body.summary}`, + }), 400, ); } @@ -3017,7 +3278,13 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { workbenchId, ); if (existing === undefined) { - return c.json(ErrorEnvelope("not_found", "workbench not found"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "workbench not found", + }), + 404, + ); } const principal = c.get("principal"); @@ -3043,35 +3310,41 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { switch (outcome.kind) { case "no_tenancy": return c.json( - ErrorEnvelope( - "conflict", - "this workbench predates the child-tenancy rollout and carries " + + makeErrorEnvelope({ + code: "conflict", + userMessage: + "this workbench predates the child-tenancy rollout and carries " + "no native tenant of its own; it cannot be moved until it " + "is backfilled a tenancy", - ), + }), 409, ); case "destination_not_found": return c.json( - ErrorEnvelope("not_found", "destination tenant not found"), + makeErrorEnvelope({ + code: "not_found", + userMessage: "destination tenant not found", + }), 404, ); case "cycle": return c.json( - ErrorEnvelope( - "conflict", - "the destination is this workbench's own tenant, or a " + + makeErrorEnvelope({ + code: "conflict", + userMessage: + "the destination is this workbench's own tenant, or a " + "descendant of it; moving it there would make the " + "workbench its own ancestor", - ), + }), 409, ); case "forbidden": return c.json( - ErrorEnvelope( - "forbidden", - "you do not have a manage grant in the destination tenant", - ), + makeErrorEnvelope({ + code: "forbidden", + userMessage: + "you do not have a manage grant in the destination tenant", + }), 403, ); case "moved": @@ -3099,12 +3372,21 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { const body = CreateShareBody(await c.req.json().catch(() => undefined)); if (body instanceof type.errors) { return c.json( - ErrorEnvelope("bad_request", `invalid share body: ${body.summary}`), + makeErrorEnvelope({ + code: "bad_request", + userMessage: `invalid share body: ${body.summary}`, + }), 400, ); } if (deps.shares === undefined) { - return c.json(ErrorEnvelope("not_found", "shares not available"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "shares not available", + }), + 404, + ); } const tenant = c.get("tenant"); @@ -3118,7 +3400,13 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { workbenchId, ); if (existing === undefined) { - return c.json(ErrorEnvelope("not_found", "workbench not found"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "workbench not found", + }), + 404, + ); } const outcome = await deps.shares.createShare({ @@ -3131,19 +3419,21 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { switch (outcome.kind) { case "trust_missing": return c.json( - ErrorEnvelope( - "forbidden", - "no bilateral trust with the target tenant — establish " + + makeErrorEnvelope({ + code: "forbidden", + userMessage: + "no bilateral trust with the target tenant — establish " + "trust before sharing", - ), + }), 403, ); case "already_shared": return c.json( - ErrorEnvelope( - "conflict", - "this workbench is already shared with " + "that tenant", - ), + makeErrorEnvelope({ + code: "conflict", + userMessage: + "this workbench is already shared with " + "that tenant", + }), 409, ); case "created": { @@ -3182,7 +3472,13 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { deps.requireGrant(idResource("workflow-run", "id"), "read"), async (c) => { if (deps.shares === undefined) { - return c.json(ErrorEnvelope("not_found", "shares not available"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "shares not available", + }), + 404, + ); } const tenant = c.get("tenant"); @@ -3193,7 +3489,13 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { workbenchId, ); if (existing === undefined) { - return c.json(ErrorEnvelope("not_found", "workbench not found"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "workbench not found", + }), + 404, + ); } const rows = await deps.shares.listSharesForWorkbench( @@ -3217,7 +3519,13 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { deps.requireGrant(idResource("workflow-run", "id"), "manage"), async (c) => { if (deps.shares === undefined) { - return c.json(ErrorEnvelope("not_found", "shares not available"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "shares not available", + }), + 404, + ); } const tenant = c.get("tenant"); @@ -3229,7 +3537,13 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { workbenchId, ); if (existing === undefined) { - return c.json(ErrorEnvelope("not_found", "workbench not found"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "workbench not found", + }), + 404, + ); } const revoked = await deps.shares.revokeShare( @@ -3238,7 +3552,13 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { projectedTenantId, ); if (!revoked) { - return c.json(ErrorEnvelope("not_found", "share not found"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "share not found", + }), + 404, + ); } return c.body(null, 204); }, @@ -3255,15 +3575,21 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { ); if (body instanceof type.errors) { return c.json( - ErrorEnvelope( - "bad_request", - `invalid share-member body: ${body.summary}`, - ), + makeErrorEnvelope({ + code: "bad_request", + userMessage: `invalid share-member body: ${body.summary}`, + }), 400, ); } if (deps.shares === undefined) { - return c.json(ErrorEnvelope("not_found", "shares not available"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "shares not available", + }), + 404, + ); } const tenant = c.get("tenant"); @@ -3280,7 +3606,13 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { // workbench would. const share = await deps.shares.getShare(workbenchId, tenant.id); if (share === undefined) { - return c.json(ErrorEnvelope("not_found", "workbench not found"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "workbench not found", + }), + 404, + ); } const outcome = await deps.shares.addShareMember({ @@ -3290,7 +3622,13 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { addedBy: principal.id, }); if (outcome === "no_share") { - return c.json(ErrorEnvelope("not_found", "workbench not found"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "workbench not found", + }), + 404, + ); } return c.json({ principalId: body.principalId }, 200); }, @@ -3301,7 +3639,13 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { deps.requireGrant(idResource("workflow-run", "id"), "manage"), async (c) => { if (deps.shares === undefined) { - return c.json(ErrorEnvelope("not_found", "shares not available"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "shares not available", + }), + 404, + ); } const tenant = c.get("tenant"); @@ -3314,7 +3658,13 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { principalId, ); if (!removed) { - return c.json(ErrorEnvelope("not_found", "member not found"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "member not found", + }), + 404, + ); } return c.body(null, 204); }, @@ -3364,7 +3714,13 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { ); } catch (err) { if (err instanceof SettingsValidationError) { - return c.json(ErrorEnvelope("bad_request", err.message), 400); + return c.json( + makeErrorEnvelope({ + code: "bad_request", + userMessage: err.message, + }), + 400, + ); } throw err; } @@ -3392,7 +3748,13 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { const workbenchId = c.req.param("id"); const row = await deps.store.getWorkbenchSettings(tenant.id, workbenchId); if (row === undefined) { - return c.json(ErrorEnvelope("not_found", "workbench not found"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "workbench not found", + }), + 404, + ); } return c.json(await withResolvedContextWindow(tenant.id, row)); }, @@ -3411,7 +3773,13 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { workbenchId, ); if (existing === undefined) { - return c.json(ErrorEnvelope("not_found", "workbench not found"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "workbench not found", + }), + 404, + ); } let patch: Record; @@ -3421,7 +3789,13 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { ); } catch (err) { if (err instanceof SettingsValidationError) { - return c.json(ErrorEnvelope("bad_request", err.message), 400); + return c.json( + makeErrorEnvelope({ + code: "bad_request", + userMessage: err.message, + }), + 400, + ); } throw err; } @@ -3431,7 +3805,13 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { patch["chat/participants"] !== undefined ) { const refusal = new KindIsChatError(); - return c.json(ErrorEnvelope(refusal.code, refusal.message), 409); + return c.json( + makeErrorEnvelope({ + code: refusal.code, + userMessage: refusal.message, + }), + 409, + ); } // `chat/participants` is normalized to records on write even when @@ -3519,7 +3899,13 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { principal.refId, ); if (access === undefined) { - return c.json(ErrorEnvelope("not_found", "workbench not found"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "workbench not found", + }), + 404, + ); } const row = await deps.store.getReadState( access.ownerTenantId, @@ -3543,10 +3929,10 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { const body = PutReadStateBody(await c.req.json().catch(() => undefined)); if (body instanceof type.errors) { return c.json( - ErrorEnvelope( - "bad_request", - `invalid read-state body: ${body.summary}`, - ), + makeErrorEnvelope({ + code: "bad_request", + userMessage: `invalid read-state body: ${body.summary}`, + }), 400, ); } @@ -3563,7 +3949,13 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { principal.refId, ); if (access === undefined) { - return c.json(ErrorEnvelope("not_found", "workbench not found"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "workbench not found", + }), + 404, + ); } const row = await deps.store.putReadState({ @@ -3597,7 +3989,13 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { principal.refId, )) === undefined ) { - return c.json(ErrorEnvelope("not_found", "workbench not found"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "workbench not found", + }), + 404, + ); } publish(workbenchId, { type: "chat.typing", @@ -3630,15 +4028,21 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { principal.refId, )) === undefined ) { - return c.json(ErrorEnvelope("not_found", "workbench not found"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "workbench not found", + }), + 404, + ); } const before = presence.snapshot(workbenchId); if (!before.some((member) => member.principalId === principal.id)) { return c.json( - ErrorEnvelope( - "not_found", - "principal has no open stream on this workbench", - ), + makeErrorEnvelope({ + code: "not_found", + userMessage: "principal has no open stream on this workbench", + }), 404, ); } @@ -3671,7 +4075,13 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { principal.refId, )) === undefined ) { - return c.json(ErrorEnvelope("not_found", "workbench not found"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "workbench not found", + }), + 404, + ); } return streamSSE(c, async (stream) => { @@ -3705,7 +4115,10 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { async (c) => { if (deps.agentTurns === undefined) { return c.json( - ErrorEnvelope("not_found", "turn history not available"), + makeErrorEnvelope({ + code: "not_found", + userMessage: "turn history not available", + }), 404, ); } @@ -3720,7 +4133,13 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { principal.refId, ); if (access === undefined) { - return c.json(ErrorEnvelope("not_found", "workbench not found"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "workbench not found", + }), + 404, + ); } const items = await deps.agentTurns.listTurns({ tenantId: access.ownerTenantId, @@ -3749,7 +4168,13 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { principal.refId, ); if (access === undefined) { - return c.json(ErrorEnvelope("not_found", "workbench not found"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "workbench not found", + }), + 404, + ); } const result = await cancelWorkbenchTurn( { @@ -3772,7 +4197,10 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { async (c) => { if (deps.agentTurns === undefined) { return c.json( - ErrorEnvelope("not_found", "turn history not available"), + makeErrorEnvelope({ + code: "not_found", + userMessage: "turn history not available", + }), 404, ); } @@ -3787,14 +4215,26 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { principal.refId, ); if (access === undefined) { - return c.json(ErrorEnvelope("not_found", "workbench not found"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "workbench not found", + }), + 404, + ); } const turn = await deps.agentTurns.getTurn({ tenantId: access.ownerTenantId, turnId: c.req.param("turnId"), }); if (turn === undefined || turn.workbenchId !== workbenchId) { - return c.json(ErrorEnvelope("not_found", "turn not found"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "turn not found", + }), + 404, + ); } // Only a still-running turn gets a catch-up snapshot attached — a // settled turn's reply already lives in the timeline as an ordinary diff --git a/packages/chat/src/workbench-tenancy-routes.ts b/packages/chat/src/workbench-tenancy-routes.ts index 63950de8e..be0405e02 100644 --- a/packages/chat/src/workbench-tenancy-routes.ts +++ b/packages/chat/src/workbench-tenancy-routes.ts @@ -9,6 +9,7 @@ import type { AppEnv } from "@intx/hub-api"; import { Hono } from "hono"; import { type } from "arktype"; +import { makeErrorEnvelope } from "@workbench/hub-client"; import type { WorkbenchTenancyStore } from "./workbench-tenancy"; @@ -29,7 +30,10 @@ export function createWorkbenchTenancyRoutes( const user = c.get("user"); if (!user) { return c.json( - { error: { code: "unauthorized", message: "Authentication required" } }, + makeErrorEnvelope({ + code: "unauthorized", + userMessage: "Authentication required", + }), 401, ); } @@ -37,7 +41,10 @@ export function createWorkbenchTenancyRoutes( const body = TenantIdsBody(await c.req.json().catch(() => undefined)); if (body instanceof type.errors) { return c.json( - { error: { code: "invalid_body", message: body.summary } }, + makeErrorEnvelope({ + code: "invalid_body", + userMessage: body.summary, + }), 400, ); } diff --git a/packages/chat/src/workflow-participant-routes.ts b/packages/chat/src/workflow-participant-routes.ts index c432af461..06bab8c62 100644 --- a/packages/chat/src/workflow-participant-routes.ts +++ b/packages/chat/src/workflow-participant-routes.ts @@ -67,10 +67,8 @@ import { } from "./connect-pending"; import type { WorkbenchTenancyStore } from "./workbench-tenancy"; import { MODEL_UNAVAILABLE_CONSUMER_MESSAGE } from "./model-unavailable"; - -function errorEnvelope(code: string, message: string) { - return { error: { code, message } }; -} +import { reportError } from "@corbits/error-sink"; +import { makeErrorEnvelope } from "@workbench/hub-client"; /** * The tenant + principal + run a presented sidecar token and run @@ -166,10 +164,11 @@ export function createWorkflowParticipantRoutes( const scope = await deps.authenticator.resolve(token, address); if (scope === null) { return c.json( - errorEnvelope( - "unauthorized", - "Missing or unrecognized sidecar bearer token / run address", - ), + makeErrorEnvelope({ + code: "unauthorized", + userMessage: + "Missing or unrecognized sidecar bearer token / run address", + }), 401, ); } @@ -184,7 +183,10 @@ export function createWorkflowParticipantRoutes( ); if (body instanceof type.errors) { return c.json( - errorEnvelope("bad_request", `invalid invite body: ${body.summary}`), + makeErrorEnvelope({ + code: "bad_request", + userMessage: `invalid invite body: ${body.summary}`, + }), 400, ); } @@ -195,10 +197,10 @@ export function createWorkflowParticipantRoutes( ); if (workbench === undefined) { return c.json( - errorEnvelope( - "not_found", - `The calling run "${scope.address}" is not a participant of any workbench in this workbench`, - ), + makeErrorEnvelope({ + code: "not_found", + userMessage: `The calling run "${scope.address}" is not a participant of any workbench in this workbench`, + }), 404, ); } @@ -228,16 +230,28 @@ export function createWorkflowParticipantRoutes( // when every asset candidate for the definition has gone // unresolvable (DB/blob drift). if (err instanceof DefinitionProjectionMissingError) { - return c.json(errorEnvelope("not_launchable", err.guidance), 409); + return c.json( + makeErrorEnvelope({ + code: "not_launchable", + userMessage: err.guidance, + }), + 409, + ); } if (err instanceof InferenceResolutionError) { return c.json( - errorEnvelope("not_launchable", MODEL_UNAVAILABLE_CONSUMER_MESSAGE), + makeErrorEnvelope({ + code: "not_launchable", + userMessage: MODEL_UNAVAILABLE_CONSUMER_MESSAGE, + }), 409, ); } if (err instanceof KindIsChatError) { - return c.json(errorEnvelope(err.code, err.message), 409); + return c.json( + makeErrorEnvelope({ code: err.code, userMessage: err.message }), + 409, + ); } throw err; } @@ -260,7 +274,10 @@ export function createWorkflowParticipantRoutes( const body = MintDmInput(await c.req.json().catch(() => undefined)); if (body instanceof type.errors) { return c.json( - errorEnvelope("bad_request", `invalid mint-dm body: ${body.summary}`), + makeErrorEnvelope({ + code: "bad_request", + userMessage: `invalid mint-dm body: ${body.summary}`, + }), 400, ); } @@ -271,10 +288,10 @@ export function createWorkflowParticipantRoutes( ); if (workbench === undefined) { return c.json( - errorEnvelope( - "not_found", - `The calling run "${scope.address}" is not a participant of any workbench in this workbench`, - ), + makeErrorEnvelope({ + code: "not_found", + userMessage: `The calling run "${scope.address}" is not a participant of any workbench in this workbench`, + }), 404, ); } @@ -286,11 +303,17 @@ export function createWorkflowParticipantRoutes( const creatorUserId = await deps.tenancy.getWorkbenchOwnerUserId(ownerTenantId); if (creatorUserId === undefined) { + const userMessage = `No owner user id for tenant "${ownerTenantId}" — cannot mint an agent DM`; + const refId = reportError(new Error(userMessage), { + operation: "chat.mintDm.ownerUnresolved", + tenantId: ownerTenantId, + }); return c.json( - errorEnvelope( - "owner_unresolved", - `No owner user id for tenant "${ownerTenantId}" — cannot mint an agent DM`, - ), + makeErrorEnvelope({ + code: "owner_unresolved", + userMessage, + refId, + }), 500, ); } @@ -300,11 +323,17 @@ export function createWorkflowParticipantRoutes( tenantId: ownerTenantId, }); if (cookies === undefined) { + const userMessage = `Could not mint a session for owner "${creatorUserId}" to create an agent DM`; + const refId = reportError(new Error(userMessage), { + operation: "chat.mintDm.sessionUnmintable", + tenantId: ownerTenantId, + }); return c.json( - errorEnvelope( - "session_unmintable", - `Could not mint a session for owner "${creatorUserId}" to create an agent DM`, - ), + makeErrorEnvelope({ + code: "session_unmintable", + userMessage, + refId, + }), 500, ); } @@ -330,16 +359,28 @@ export function createWorkflowParticipantRoutes( ); } catch (err) { if (err instanceof DefinitionProjectionMissingError) { - return c.json(errorEnvelope("not_launchable", err.guidance), 409); + return c.json( + makeErrorEnvelope({ + code: "not_launchable", + userMessage: err.guidance, + }), + 409, + ); } if (err instanceof InferenceResolutionError) { return c.json( - errorEnvelope("not_launchable", MODEL_UNAVAILABLE_CONSUMER_MESSAGE), + makeErrorEnvelope({ + code: "not_launchable", + userMessage: MODEL_UNAVAILABLE_CONSUMER_MESSAGE, + }), 409, ); } if (err instanceof KindIsChatError) { - return c.json(errorEnvelope(err.code, err.message), 409); + return c.json( + makeErrorEnvelope({ code: err.code, userMessage: err.message }), + 409, + ); } throw err; } @@ -369,7 +410,10 @@ export function createWorkflowParticipantRoutes( const body = PostMessageInput(await c.req.json().catch(() => undefined)); if (body instanceof type.errors) { return c.json( - errorEnvelope("bad_request", `invalid message body: ${body.summary}`), + makeErrorEnvelope({ + code: "bad_request", + userMessage: `invalid message body: ${body.summary}`, + }), 400, ); } @@ -380,10 +424,10 @@ export function createWorkflowParticipantRoutes( ); if (workbench === undefined) { return c.json( - errorEnvelope( - "not_found", - `The calling run "${scope.address}" is not a participant of any workbench in this workbench`, - ), + makeErrorEnvelope({ + code: "not_found", + userMessage: `The calling run "${scope.address}" is not a participant of any workbench in this workbench`, + }), 404, ); } diff --git a/packages/chat/test/block-responses-routes.test.ts b/packages/chat/test/block-responses-routes.test.ts index fa567c208..0f70f9d20 100644 --- a/packages/chat/test/block-responses-routes.test.ts +++ b/packages/chat/test/block-responses-routes.test.ts @@ -417,10 +417,10 @@ describe("block response routes — question answers", () => { const failed = await submit(); expect(failed.status).toBe(500); const failedBody = (await failed.json()) as { - error: { code: string; message: string }; + error: { code: string; userMessage: string }; }; expect(failedBody.error.code).toBe("notify_failed"); - expect(failedBody.error.message).toMatch(/ref /); + expect(failedBody.error.userMessage).toMatch(/ref /); // The answer itself was already durable even though the notify failed. const afterFailure = await getResponses( diff --git a/packages/chat/test/routes.test.ts b/packages/chat/test/routes.test.ts index 32e982212..d17603f55 100644 --- a/packages/chat/test/routes.test.ts +++ b/packages/chat/test/routes.test.ts @@ -370,14 +370,16 @@ describe("POST /workbenches", () => { expect(response.status).toBe(409); const errorBody = (await response.json()) as { - error: { code: string; message: string }; + error: { code: string; userMessage: string }; }; expect(errorBody.error.code).toBe("not_launchable"); - expect(errorBody.error.message).toBe( + expect(errorBody.error.userMessage).toBe( "This agent's model isn't available here.", ); - expect(errorBody.error.message).not.toMatch(/cannot resolve an inference/); - expect(errorBody.error.message).not.toMatch(/HTTP/); + expect(errorBody.error.userMessage).not.toMatch( + /cannot resolve an inference/, + ); + expect(errorBody.error.userMessage).not.toMatch(/HTTP/); const tenancy = deps.tenancy as ReturnType< typeof createInMemoryWorkbenchTenancyStore @@ -412,10 +414,10 @@ describe("POST /workbenches", () => { expect(response.status).toBe(409); const errorBody = (await response.json()) as { - error: { code: string; message: string }; + error: { code: string; userMessage: string }; }; expect(errorBody.error.code).toBe("not_launchable"); - expect(errorBody.error.message).toMatch(/save its instructions/); + expect(errorBody.error.userMessage).toMatch(/save its instructions/); const tenancy = deps.tenancy as ReturnType< typeof createInMemoryWorkbenchTenancyStore @@ -971,15 +973,13 @@ describe("POST /workbenches/:id/invite", () => { expect(response.status).toBe(409); const errorBody = (await response.json()) as { - error: { code: string; message: string }; + error: { code: string; userMessage: string }; }; expect(errorBody.error.code).toBe("not_launchable"); - expect(errorBody.error.message).toBe( + expect(errorBody.error.userMessage).toBe( "This agent's model isn't available here.", ); - expect(errorBody.error.message).not.toMatch(/cannot resolve an inference/); - expect(errorBody.error.message).not.toMatch(/HTTP/); - expect(errorBody.error.message).not.toMatch( + expect(errorBody.error.userMessage).not.toMatch( /No launchable inference source/, ); }); @@ -1008,10 +1008,10 @@ describe("POST /workbenches/:id/invite", () => { expect(response.status).toBe(409); const errorBody = (await response.json()) as { - error: { code: string; message: string }; + error: { code: string; userMessage: string }; }; expect(errorBody.error.code).toBe("not_launchable"); - expect(errorBody.error.message).toMatch(/save its instructions/); + expect(errorBody.error.userMessage).toMatch(/save its instructions/); }); }); @@ -1871,9 +1871,11 @@ describe("POST /workbenches/:id/messages — invite pre-step (CL-5879 mention-pu expect(response.status).toBe(403); const body = (await response.json()) as { - error: { code: string; message: string }; + error: { code: string; userMessage: string }; }; - expect(body.error.message).toBe("You can't add people to this workbench"); + expect(body.error.userMessage).toBe( + "You can't add people to this workbench", + ); const platform = deps.platform as ReturnType; expect(platform.sentMail).toHaveLength(0); diff --git a/packages/commands/package.json b/packages/commands/package.json index dcd18f636..d881adc0e 100644 --- a/packages/commands/package.json +++ b/packages/commands/package.json @@ -14,6 +14,7 @@ }, "dependencies": { "@intx/hub-api": "workspace:*", + "@workbench/hub-client": "workspace:*", "arktype": "catalog:", "hono": "^4.11.9" }, diff --git a/packages/commands/src/routes.ts b/packages/commands/src/routes.ts index c081e607e..63987dab1 100644 --- a/packages/commands/src/routes.ts +++ b/packages/commands/src/routes.ts @@ -14,6 +14,7 @@ import type { TenantEnv } from "@intx/hub-api"; import type { RequireGrant } from "@intx/hub-api"; import { dispatchSlashCommand } from "./dispatch"; import type { CommandListing, CommandRegistry } from "./registry"; +import { makeErrorEnvelope } from "@workbench/hub-client"; export type CreateCommandRoutesDeps = { registry: CommandRegistry; @@ -30,10 +31,6 @@ export type CreateCommandRoutesDeps = { ) => Promise; }; -const ErrorEnvelope = (code: string, message: string) => ({ - error: { code, message }, -}); - const ExecuteCommandBody = type({ name: "string", "args?": "string", @@ -73,7 +70,10 @@ export function createCommandRoutes( ); if (body instanceof type.errors) { return c.json( - ErrorEnvelope("bad_request", `invalid command body: ${body.summary}`), + makeErrorEnvelope({ + code: "bad_request", + userMessage: `invalid command body: ${body.summary}`, + }), 400, ); } @@ -85,7 +85,13 @@ export function createCommandRoutes( body.workbenchId, ); if (!belongs) { - return c.json(ErrorEnvelope("not_found", "workbench not found"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "workbench not found", + }), + 404, + ); } const result = await dispatchSlashCommand( diff --git a/packages/commands/tsconfig.json b/packages/commands/tsconfig.json index d8e8d65b3..d7611c122 100644 --- a/packages/commands/tsconfig.json +++ b/packages/commands/tsconfig.json @@ -1,19 +1,8 @@ { - "extends": "./tsconfig.src.json", - "compilerOptions": { - "composite": false, - "noEmit": true, - "disableSourceOfProjectReferenceRedirect": true, - "declaration": false, - "declarationMap": false, - "emitDeclarationOnly": false, - "rootDir": "../.." - }, + "extends": "../../tsconfig.base.json", "include": ["src", "test"], - "exclude": [], - "references": [ - { - "path": "../../vendor/intx/hub-api/tsconfig.src.json" - } - ] + "compilerOptions": { + "types": ["bun"], + "noEmit": true + } } diff --git a/packages/commands/tsconfig.src.json b/packages/commands/tsconfig.src.json deleted file mode 100644 index 8b907e524..000000000 --- a/packages/commands/tsconfig.src.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "include": ["src", "package.json", "src/**/*.json"], - "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"], - "compilerOptions": { - "types": ["bun"], - "composite": true, - "emitDeclarationOnly": true, - "outDir": "dist", - "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo" - }, - "references": [ - { - "path": "../../vendor/intx/hub-api/tsconfig.src.json" - } - ] -} diff --git a/packages/connections/src/mcp-server-routes.ts b/packages/connections/src/mcp-server-routes.ts index 8765e260f..cf12fcac2 100644 --- a/packages/connections/src/mcp-server-routes.ts +++ b/packages/connections/src/mcp-server-routes.ts @@ -25,6 +25,7 @@ import { createHubAPI, ensureCredential, ensureProvider, + makeErrorEnvelope, parseAs, type ApiCall, } from "@workbench/hub-client"; @@ -37,10 +38,6 @@ import { fireConnectedHook, type ServiceConnectedHook } from "./connected-hook"; import { reportError } from "@corbits/error-sink"; import { MCP_PRESETS, mcpPresetBySlug } from "./mcp-presets"; -const ErrorEnvelope = (code: string, message: string) => ({ - error: { code, message }, -}); - /** Every stored MCP server provider is named `mcp:` — the same * convention `@corbits/mcp-tools`' `mcpCredentialHandle` builds from the * `server` argument `mcp_list_tools`/`mcp_call` take. */ @@ -230,7 +227,10 @@ export function createMcpServerRoutes( const parsed = SubmitMcpServer(body); if (parsed instanceof type.errors) { return c.json( - ErrorEnvelope("bad_request", `Invalid MCP server: ${parsed.summary}`), + makeErrorEnvelope({ + code: "bad_request", + userMessage: `Invalid MCP server: ${parsed.summary}`, + }), 400, ); } @@ -241,10 +241,10 @@ export function createMcpServerRoutes( : undefined; if (parsed.presetSlug !== undefined && preset === undefined) { return c.json( - ErrorEnvelope( - "bad_request", - `Unknown MCP server preset: "${parsed.presetSlug}"`, - ), + makeErrorEnvelope({ + code: "bad_request", + userMessage: `Unknown MCP server preset: "${parsed.presetSlug}"`, + }), 400, ); } @@ -253,10 +253,10 @@ export function createMcpServerRoutes( (parsed.token === undefined || parsed.token.length === 0) ) { return c.json( - ErrorEnvelope( - "token_required", - `${preset.displayName} needs an access token — create one at ${preset.docsUrl} and paste it in.`, - ), + makeErrorEnvelope({ + code: "token_required", + userMessage: `${preset.displayName} needs an access token — create one at ${preset.docsUrl} and paste it in.`, + }), 400, ); } @@ -264,10 +264,11 @@ export function createMcpServerRoutes( const url = preset?.url ?? parsed.url; if (name === undefined || url === undefined) { return c.json( - ErrorEnvelope( - "bad_request", - "Invalid MCP server: provide either presetSlug, or both name and url.", - ), + makeErrorEnvelope({ + code: "bad_request", + userMessage: + "Invalid MCP server: provide either presetSlug, or both name and url.", + }), 400, ); } @@ -276,8 +277,14 @@ export function createMcpServerRoutes( if (!test.ok) { return c.json( test.requiresOAuth === true - ? ErrorEnvelope("oauth_required", test.message) - : ErrorEnvelope("connect_failed", test.message), + ? makeErrorEnvelope({ + code: "oauth_required", + userMessage: test.message, + }) + : makeErrorEnvelope({ + code: "connect_failed", + userMessage: test.message, + }), 422, ); } @@ -345,16 +352,18 @@ export function createMcpServerRoutes( ); // Never widen extra beyond identifiers safe to print — `cause` here // can carry the pasted bearer token in scope above. - reportError(cause, { + const refId = reportError(cause, { operation: "persist_mcp_server_connection", tenantId: tenant.id, extra: { slug }, }); return c.json( - ErrorEnvelope( - "connection_setup_failed", - "That MCP server checked out, but saving the connection failed. Try again in a moment.", - ), + makeErrorEnvelope({ + code: "connection_setup_failed", + userMessage: + "That MCP server checked out, but saving the connection failed. Try again in a moment.", + refId, + }), 500, ); } @@ -391,15 +400,18 @@ export function createMcpServerRoutes( ); if (inheritedProvider !== undefined) { return c.json( - ErrorEnvelope( - "forbidden", - `"${slug}" is inherited from a parent workbench — disconnect it from the workbench that owns it, not from a child.`, - ), + makeErrorEnvelope({ + code: "forbidden", + userMessage: `"${slug}" is inherited from a parent workbench — disconnect it from the workbench that owns it, not from a child.`, + }), 403, ); } return c.json( - ErrorEnvelope("not_found", `No MCP server connected at "${slug}"`), + makeErrorEnvelope({ + code: "not_found", + userMessage: `No MCP server connected at "${slug}"`, + }), 404, ); } diff --git a/packages/connections/src/routes.test.ts b/packages/connections/src/routes.test.ts index 3e316fe74..c9340873b 100644 --- a/packages/connections/src/routes.test.ts +++ b/packages/connections/src/routes.test.ts @@ -660,8 +660,8 @@ describe("POST /:connectorId/complete", () => { }); // The probe's own message is still the thing the person who just // typed the key sees in the 422 body... - const body = (await response.json()) as { error: { message: string } }; - expect(body.error.message).toBe("the key was rejected"); + const body = (await response.json()) as { error: { userMessage: string } }; + expect(body.error.userMessage).toBe("the key was rejected"); // ...but the provider-health record never carries it — only a closed // category the shell banner maps to fixed copy (CL-6092). const record = providerHealth.get(TENANT.id, "rejecting-connector"); diff --git a/packages/connections/src/routes.ts b/packages/connections/src/routes.ts index 313049115..608c01b18 100644 --- a/packages/connections/src/routes.ts +++ b/packages/connections/src/routes.ts @@ -28,6 +28,7 @@ import { createHubAPI, ensureCredential, ensureProvider, + makeErrorEnvelope, parseAs, OLLAMA_PLACEHOLDER_SECRET, seedCatalog, @@ -178,10 +179,6 @@ export async function disconnectConnector( // `provider-health.ts`'s own header for why. const CREDENTIAL_TEST_FAILURE_CATEGORY = "credential_failure" as const; -const ErrorEnvelope = (code: string, message: string) => ({ - error: { code, message }, -}); - // CL-6351: a fresh Ollama connect whose instance has only embedding // models pulled still succeeds (the URL and instance are real) but has // no model any workbench turn can actually use — surfaced as a guided @@ -401,7 +398,10 @@ export function createConnectionRoutes( const descriptor = findApiKeyDescriptor(connectorId); if (descriptor === undefined || descriptor.probe === undefined) { return c.json( - ErrorEnvelope("not_found", `Unknown connector: ${connectorId}`), + makeErrorEnvelope({ + code: "not_found", + userMessage: `Unknown connector: ${connectorId}`, + }), 404, ); } @@ -409,10 +409,10 @@ export function createConnectionRoutes( const parsed = await parseApiKeyBody(c); if (parsed instanceof type.errors) { return c.json( - ErrorEnvelope( - "bad_request", - `An API key is required: ${parsed.summary}`, - ), + makeErrorEnvelope({ + code: "bad_request", + userMessage: `An API key is required: ${parsed.summary}`, + }), 400, ); } @@ -429,7 +429,13 @@ export function createConnectionRoutes( descriptor.id, CREDENTIAL_TEST_FAILURE_CATEGORY, ); - return c.json(ErrorEnvelope("invalid_credential", test.message), 422); + return c.json( + makeErrorEnvelope({ + code: "invalid_credential", + userMessage: test.message, + }), + 422, + ); } const cookies = cookiesFromHeader(c.req.header("cookie")); @@ -553,16 +559,18 @@ export function createConnectionRoutes( ); // Never widen extra beyond identifiers safe to print — the pasted // `parsed.apiKey` is in scope above. - reportError(cause, { + const refId = reportError(cause, { operation: "persist_api_key_connection", tenantId: tenant.id, extra: { connectorId }, }); return c.json( - ErrorEnvelope( - "connection_setup_failed", - "The key checked out, but saving the connection failed. Try again in a moment.", - ), + makeErrorEnvelope({ + code: "connection_setup_failed", + userMessage: + "The key checked out, but saving the connection failed. Try again in a moment.", + refId, + }), 500, ); } @@ -580,7 +588,10 @@ export function createConnectionRoutes( const connectorId = c.req.param("connectorId"); if (registry[connectorId] === undefined) { return c.json( - ErrorEnvelope("not_found", `Unknown connector: ${connectorId}`), + makeErrorEnvelope({ + code: "not_found", + userMessage: `Unknown connector: ${connectorId}`, + }), 404, ); } @@ -596,7 +607,10 @@ export function createConnectionRoutes( ); if (!result.disconnected) { return c.json( - ErrorEnvelope("not_found", `${connectorId} is not connected`), + makeErrorEnvelope({ + code: "not_found", + userMessage: `${connectorId} is not connected`, + }), 404, ); } @@ -606,16 +620,17 @@ export function createConnectionRoutes( deps.log( `disconnect failed for connector ${connectorId} on tenant ${tenant.id}: ${message}`, ); - reportError(cause, { + const refId = reportError(cause, { operation: "disconnect_connector", tenantId: tenant.id, extra: { connectorId }, }); return c.json( - ErrorEnvelope( - "disconnect_failed", - "Couldn't disconnect — try again.", - ), + makeErrorEnvelope({ + code: "disconnect_failed", + userMessage: "Couldn't disconnect — try again.", + refId, + }), 500, ); } diff --git a/packages/evals/src/routes.ts b/packages/evals/src/routes.ts index a778bfcd7..ab4ef62bf 100644 --- a/packages/evals/src/routes.ts +++ b/packages/evals/src/routes.ts @@ -16,10 +16,7 @@ import type { RequireGrant, TenantEnv } from "@intx/hub-api"; import { ALL_EVALS } from "./cases/index.ts"; import type { EvalRunRecord, EvalRunStore } from "./store/store.ts"; - -const ErrorEnvelope = (code: string, message: string) => ({ - error: { code, message }, -}); +import { makeErrorEnvelope } from "@workbench/hub-client"; const DEFAULT_LIMIT = 20; const MAX_LIMIT = 100; @@ -103,17 +100,20 @@ export function createEvalRunRoutes( const raw = RunsQuery(c.req.query()); if (raw instanceof type.errors) { return c.json( - ErrorEnvelope("bad_request", `invalid query: ${raw.summary}`), + makeErrorEnvelope({ + code: "bad_request", + userMessage: `invalid query: ${raw.summary}`, + }), 400, ); } const limit = parseLimit(raw.limit); if (limit === undefined) { return c.json( - ErrorEnvelope( - "bad_request", - `limit must be an integer between 1 and ${MAX_LIMIT}`, - ), + makeErrorEnvelope({ + code: "bad_request", + userMessage: `limit must be an integer between 1 and ${MAX_LIMIT}`, + }), 400, ); } @@ -134,7 +134,13 @@ export function createEvalRunRoutes( const runId = c.req.param("runId"); const record = await deps.store.get(runId); if (record === null) { - return c.json(ErrorEnvelope("not_found", "eval run not found"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "eval run not found", + }), + 404, + ); } return c.json(toDetail(record)); }, diff --git a/packages/inference-catalog/package.json b/packages/inference-catalog/package.json index 2320979c2..ad19a38e2 100644 --- a/packages/inference-catalog/package.json +++ b/packages/inference-catalog/package.json @@ -24,6 +24,7 @@ "@intx/hub-api": "workspace:*", "@intx/inference-catalog": "0.3.0", "@intx/types": "workspace:*", + "@workbench/hub-client": "workspace:*", "arktype": "catalog:", "drizzle-orm": "catalog:", "hono": "^4.11.9", diff --git a/packages/inference-catalog/src/workflow-catalog-routes.ts b/packages/inference-catalog/src/workflow-catalog-routes.ts index f8df5143f..732db1985 100644 --- a/packages/inference-catalog/src/workflow-catalog-routes.ts +++ b/packages/inference-catalog/src/workflow-catalog-routes.ts @@ -13,6 +13,7 @@ import { Hono } from "hono"; import { type } from "arktype"; import type { ModelPricingRow, ResolvedOffering } from "@intx/db"; import { Capability } from "@intx/types"; +import { makeErrorEnvelope } from "@workbench/hub-client"; import { CONCEPTS } from "./concepts"; import { EMPTY_POLICY, type BenchModelPolicy } from "./policy"; @@ -99,13 +100,11 @@ export function createWorkflowCatalogRoutes( const scope = await deps.authenticator.resolve(token, address); if (scope === null) { return c.json( - { - error: { - code: "unauthorized", - message: - "Missing or unrecognized sidecar bearer token / run address", - }, - }, + makeErrorEnvelope({ + code: "unauthorized", + userMessage: + "Missing or unrecognized sidecar bearer token / run address", + }), 401, ); } @@ -164,19 +163,20 @@ export function createWorkflowCatalogRoutes( const body = ChainBody(raw); if (body instanceof type.errors) { return c.json( - { - error: { - code: "bad_request", - message: `invalid body: ${body.summary}`, - }, - }, + makeErrorEnvelope({ + code: "bad_request", + userMessage: `invalid body: ${body.summary}`, + }), 400, ); } const need = needFrom(body); if (need instanceof Error) { return c.json( - { error: { code: "bad_request", message: need.message } }, + makeErrorEnvelope({ + code: "bad_request", + userMessage: need.message, + }), 400, ); } @@ -191,7 +191,10 @@ export function createWorkflowCatalogRoutes( } catch (err) { if (err instanceof UnknownConceptError) { return c.json( - { error: { code: "bad_request", message: err.message } }, + makeErrorEnvelope({ + code: "bad_request", + userMessage: err.message, + }), 400, ); } @@ -205,19 +208,20 @@ export function createWorkflowCatalogRoutes( const body = EstimateBody(raw); if (body instanceof type.errors) { return c.json( - { - error: { - code: "bad_request", - message: `invalid body: ${body.summary}`, - }, - }, + makeErrorEnvelope({ + code: "bad_request", + userMessage: `invalid body: ${body.summary}`, + }), 400, ); } const need = needFrom(body); if (need instanceof Error) { return c.json( - { error: { code: "bad_request", message: need.message } }, + makeErrorEnvelope({ + code: "bad_request", + userMessage: need.message, + }), 400, ); } @@ -243,7 +247,10 @@ export function createWorkflowCatalogRoutes( } catch (err) { if (err instanceof UnknownConceptError) { return c.json( - { error: { code: "bad_request", message: err.message } }, + makeErrorEnvelope({ + code: "bad_request", + userMessage: err.message, + }), 400, ); } diff --git a/packages/inference-catalog/test/workflow-catalog-routes.test.ts b/packages/inference-catalog/test/workflow-catalog-routes.test.ts index 4c75b9806..31942bf1a 100644 --- a/packages/inference-catalog/test/workflow-catalog-routes.test.ts +++ b/packages/inference-catalog/test/workflow-catalog-routes.test.ts @@ -115,8 +115,8 @@ describe("workflow inference-catalog routes", () => { post("/chain", { concept: "cheap-loop", capabilities: ["plain-text"] }), ); expect(response.status).toBe(400); - const body = (await response.json()) as { error: { message: string } }; - expect(body.error.message).toContain("exactly one"); + const body = (await response.json()) as { error: { userMessage: string } }; + expect(body.error.userMessage).toContain("exactly one"); }); test("naming neither is refused too", async () => { @@ -136,8 +136,8 @@ describe("workflow inference-catalog routes", () => { post("/chain", { concept: "vibes-based" }), ); expect(response.status).toBe(400); - const body = (await response.json()) as { error: { message: string } }; - expect(body.error.message).toContain("cheap-loop"); + const body = (await response.json()) as { error: { userMessage: string } }; + expect(body.error.userMessage).toContain("cheap-loop"); }); test("POST /estimate prices the work before it is spent", async () => { diff --git a/packages/inference-catalog/tsconfig.json b/packages/inference-catalog/tsconfig.json index 3e18089f4..d7611c122 100644 --- a/packages/inference-catalog/tsconfig.json +++ b/packages/inference-catalog/tsconfig.json @@ -1,31 +1,8 @@ { - "extends": "./tsconfig.src.json", - "compilerOptions": { - "composite": false, - "noEmit": true, - "disableSourceOfProjectReferenceRedirect": true, - "declaration": false, - "declarationMap": false, - "emitDeclarationOnly": false, - "rootDir": "../.." - }, + "extends": "../../tsconfig.base.json", "include": ["src", "test"], - "exclude": [], - "references": [ - { - "path": "../../vendor/intx/db/tsconfig.src.json" - }, - { - "path": "../../vendor/intx/hub-api/tsconfig.src.json" - }, - { - "path": "../../vendor/intx/types/tsconfig.src.json" - }, - { - "path": "../migration-runner/tsconfig.src.json" - }, - { - "path": "../ollama-adapter/tsconfig.src.json" - } - ] + "compilerOptions": { + "types": ["bun"], + "noEmit": true + } } diff --git a/packages/inference-catalog/tsconfig.src.json b/packages/inference-catalog/tsconfig.src.json deleted file mode 100644 index bc80e04b3..000000000 --- a/packages/inference-catalog/tsconfig.src.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "include": ["src", "package.json", "src/**/*.json"], - "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"], - "compilerOptions": { - "types": ["bun"], - "composite": true, - "emitDeclarationOnly": true, - "outDir": "dist", - "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo" - }, - "references": [ - { - "path": "../../vendor/intx/db/tsconfig.src.json" - }, - { - "path": "../../vendor/intx/hub-api/tsconfig.src.json" - }, - { - "path": "../../vendor/intx/types/tsconfig.src.json" - }, - { - "path": "../migration-runner/tsconfig.src.json" - }, - { - "path": "../ollama-adapter/tsconfig.src.json" - } - ] -} diff --git a/packages/inference-settings/src/api.ts b/packages/inference-settings/src/api.ts index 947af14ba..16b084346 100644 --- a/packages/inference-settings/src/api.ts +++ b/packages/inference-settings/src/api.ts @@ -100,11 +100,13 @@ async function request( } if (!response.ok) { const body: unknown = await response.json().catch(() => undefined); - const envelope = type({ error: { message: "string" } })(body); + const envelope = type({ + error: { code: "string", userMessage: "string", refId: "string" }, + })(body); throw new InferenceSettingsApiError( envelope instanceof type.errors ? `The server answered ${response.status} while ${verb}.` - : envelope.error.message, + : envelope.error.userMessage, response.status, ); } @@ -141,11 +143,13 @@ async function requestVoid( } if (!response.ok) { const body: unknown = await response.json().catch(() => undefined); - const envelope = type({ error: { message: "string" } })(body); + const envelope = type({ + error: { code: "string", userMessage: "string", refId: "string" }, + })(body); throw new InferenceSettingsApiError( envelope instanceof type.errors ? `The server answered ${response.status} while ${verb}.` - : envelope.error.message, + : envelope.error.userMessage, response.status, ); } diff --git a/packages/insights/package.json b/packages/insights/package.json index 6e461d28c..71672591c 100644 --- a/packages/insights/package.json +++ b/packages/insights/package.json @@ -22,6 +22,7 @@ "@intx/hub-common": "0.3.0", "@intx/log": "0.3.0", "@intx/types": "workspace:*", + "@workbench/hub-client": "workspace:*", "arktype": "catalog:", "drizzle-orm": "catalog:", "hono": "^4.11.9", diff --git a/packages/insights/src/routes.ts b/packages/insights/src/routes.ts index 5ff8501f6..928c4c235 100644 --- a/packages/insights/src/routes.ts +++ b/packages/insights/src/routes.ts @@ -28,10 +28,7 @@ import { } from "./queries"; import type { UsageStore } from "./store"; import type { TurnLatencyStore } from "./latency-store"; - -const ErrorEnvelope = (code: string, message: string) => ({ - error: { code, message }, -}); +import { makeErrorEnvelope } from "@workbench/hub-client"; const RangeQuery = type({ "from?": "string", @@ -106,14 +103,20 @@ function parseRangeQuery( const raw = RangeQuery(query); if (raw instanceof type.errors) { return Response.json( - ErrorEnvelope("bad_request", `invalid query: ${raw.summary}`), + makeErrorEnvelope({ + code: "bad_request", + userMessage: `invalid query: ${raw.summary}`, + }), { status: 400 }, ); } const range = parseRange(raw); if (range instanceof type.errors) { return Response.json( - ErrorEnvelope("bad_request", "invalid from/to timestamp"), + makeErrorEnvelope({ + code: "bad_request", + userMessage: "invalid from/to timestamp", + }), { status: 400 }, ); } diff --git a/packages/memory-hub/package.json b/packages/memory-hub/package.json index 5e6af629f..328d11641 100644 --- a/packages/memory-hub/package.json +++ b/packages/memory-hub/package.json @@ -15,6 +15,7 @@ "dependencies": { "@corbits/artifacts-hub": "workspace:*", "@corbits/memory": "github:corbitsdev/corbits-memory#9e6f213fa2c002b531d3f6af1aa0abd737b8afe3", + "@workbench/hub-client": "workspace:*", "arktype": "catalog:", "hono": "^4.11.9" }, diff --git a/packages/memory-hub/src/workflow-routes.test.ts b/packages/memory-hub/src/workflow-routes.test.ts index 46a7980f8..29de9969d 100644 --- a/packages/memory-hub/src/workflow-routes.test.ts +++ b/packages/memory-hub/src/workflow-routes.test.ts @@ -155,10 +155,10 @@ describe("POST /add", () => { }); expect(res.status).toBe(413); const body = (await res.json()) as { - error: { code: string; message: string }; + error: { code: string; userMessage: string; refId: string }; }; expect(body.error.code).toBe("text_too_large"); - expect(body.error.message).toMatch(/shorten|split/); + expect(body.error.userMessage).toMatch(/shorten|split/); expect(calls).toHaveLength(0); }); @@ -197,10 +197,10 @@ describe("POST /add", () => { const limited = await request(); expect(limited.status).toBe(429); const body = (await limited.json()) as { - error: { code: string; message: string }; + error: { code: string; userMessage: string; refId: string }; }; expect(body.error.code).toBe("rate_limited"); - expect(body.error.message).toMatch(/wait/i); + expect(body.error.userMessage).toMatch(/wait/i); }); test("rate-limits per run, not globally — a different run's 1st add still succeeds", async () => { diff --git a/packages/memory-hub/src/workflow-routes.ts b/packages/memory-hub/src/workflow-routes.ts index 4c6948823..5a6b1e507 100644 --- a/packages/memory-hub/src/workflow-routes.ts +++ b/packages/memory-hub/src/workflow-routes.ts @@ -24,6 +24,7 @@ */ import { type } from "arktype"; import { Hono } from "hono"; +import { makeErrorEnvelope } from "@workbench/hub-client"; import type { ResolvedWorkflowRunScope, WorkflowRunAuthenticator, @@ -189,13 +190,11 @@ export function createWorkflowMemoryRoutes( const scope = await deps.authenticator.resolve(token, address); if (scope === null) { return c.json( - { - error: { - code: "unauthorized", - message: - "Missing or unrecognized sidecar bearer token / run address", - }, - }, + makeErrorEnvelope({ + code: "unauthorized", + userMessage: + "Missing or unrecognized sidecar bearer token / run address", + }), 401, ); } @@ -209,14 +208,20 @@ export function createWorkflowMemoryRoutes( body = await c.req.json(); } catch { return c.json( - { error: { code: "bad_request", message: "Invalid JSON body" } }, + makeErrorEnvelope({ + code: "bad_request", + userMessage: "Invalid JSON body", + }), 400, ); } const parsed = SearchBody(body); if (parsed instanceof type.errors) { return c.json( - { error: { code: "bad_request", message: parsed.summary } }, + makeErrorEnvelope({ + code: "bad_request", + userMessage: parsed.summary, + }), 400, ); } @@ -239,28 +244,32 @@ export function createWorkflowMemoryRoutes( body = await c.req.json(); } catch { return c.json( - { error: { code: "bad_request", message: "Invalid JSON body" } }, + makeErrorEnvelope({ + code: "bad_request", + userMessage: "Invalid JSON body", + }), 400, ); } const parsed = AddBody(body); if (parsed instanceof type.errors) { return c.json( - { error: { code: "bad_request", message: parsed.summary } }, + makeErrorEnvelope({ + code: "bad_request", + userMessage: parsed.summary, + }), 400, ); } if (parsed.text.length > MAX_ADD_TEXT_CHARS) { return c.json( - { - error: { - code: "text_too_large", - message: - `text is ${parsed.text.length} characters, over the ` + - `${MAX_ADD_TEXT_CHARS}-character limit — shorten it or split ` + - "it into multiple memory entries and try again.", - }, - }, + makeErrorEnvelope({ + code: "text_too_large", + userMessage: + `text is ${parsed.text.length} characters, over the ` + + `${MAX_ADD_TEXT_CHARS}-character limit — shorten it or split ` + + "it into multiple memory entries and try again.", + }), 413, ); } @@ -268,15 +277,13 @@ export function createWorkflowMemoryRoutes( const scope = c.get("workflowRunScope"); if (!addRateLimiter.allow(scope.runId)) { return c.json( - { - error: { - code: "rate_limited", - message: - `too many memory writes for this run in the last minute ` + - `(limit ${MAX_ADDS_PER_RUN_PER_MINUTE}/min) — wait a moment ` + - "before adding more.", - }, - }, + makeErrorEnvelope({ + code: "rate_limited", + userMessage: + `too many memory writes for this run in the last minute ` + + `(limit ${MAX_ADDS_PER_RUN_PER_MINUTE}/min) — wait a moment ` + + "before adding more.", + }), 429, ); } @@ -313,12 +320,10 @@ export function createUnavailableWorkflowMemoryRoutes(): Hono json: (body: unknown, status: 503) => Response | Promise; }) => c.json( - { - error: { - code: "unavailable", - message: "Memory plane is not configured on this hub", - }, - }, + makeErrorEnvelope({ + code: "unavailable", + userMessage: "Memory plane is not configured on this hub", + }), 503, ); app.post("/search", unavailable); diff --git a/packages/onboarding/src/routes.ts b/packages/onboarding/src/routes.ts index 850424ca3..d4814cc4c 100644 --- a/packages/onboarding/src/routes.ts +++ b/packages/onboarding/src/routes.ts @@ -1,7 +1,7 @@ // `POST /provision`, mounted outside the hub's tenant-prefixed routes // because a brand-new user belongs to no tenant yet: authenticated, // idempotent, and answering either the provisioning result or the hub's -// `{ error: { code, message } }` envelope. What it decides and why lives +// `{ error: { code, userMessage, refId } }` envelope. What it decides and why lives // in ./provision.ts. import type { AppEnv } from "@intx/hub-api"; diff --git a/packages/onboarding/test/routes.test.ts b/packages/onboarding/test/routes.test.ts index cf5a9a1d1..c2a1838bf 100644 --- a/packages/onboarding/test/routes.test.ts +++ b/packages/onboarding/test/routes.test.ts @@ -1,6 +1,6 @@ // The route's own error handling: a provisioning failure must never // reach the caller as a bare, unhandled 500 — it should come back as -// the same `{ error: { code, message } }` envelope every other hub +// the same `{ error: { code, userMessage, refId } }` envelope every other hub // route uses, so the web layer can tell "nothing to do" apart from // "this broke" instead of both looking like silence. diff --git a/packages/plugins-ui/src/mcp-servers-api.ts b/packages/plugins-ui/src/mcp-servers-api.ts index 4bed6c754..9b126a754 100644 --- a/packages/plugins-ui/src/mcp-servers-api.ts +++ b/packages/plugins-ui/src/mcp-servers-api.ts @@ -43,7 +43,7 @@ const ConnectResult = type({ }); const ErrorEnvelope = type({ - error: { message: "string", "code?": "string" }, + error: { code: "string", userMessage: "string", refId: "string" }, }); export type McpPreset = { @@ -89,9 +89,10 @@ async function readError( if (envelope instanceof type.errors) { return { message: `The server answered ${response.status} while ${verb}.` }; } - return envelope.error.code === undefined - ? { message: envelope.error.message } - : { message: envelope.error.message, code: envelope.error.code }; + return { + message: envelope.error.userMessage, + code: envelope.error.code, + }; } async function readErrorMessage( diff --git a/packages/preferences/package.json b/packages/preferences/package.json index c8e8303b6..030091158 100644 --- a/packages/preferences/package.json +++ b/packages/preferences/package.json @@ -18,6 +18,7 @@ "@corbits/api-query": "workspace:*", "@corbits/migration-runner": "workspace:*", "@intx/hub-api": "workspace:*", + "@workbench/hub-client": "workspace:*", "arktype": "catalog:", "drizzle-orm": "catalog:", "hono": "^4.11.9", diff --git a/packages/preferences/src/routes.ts b/packages/preferences/src/routes.ts index bbd401aa5..9e1e23198 100644 --- a/packages/preferences/src/routes.ts +++ b/packages/preferences/src/routes.ts @@ -9,10 +9,7 @@ import { type } from "arktype"; import type { RequireGrant, TenantEnv } from "@intx/hub-api"; import type { PreferencesStore } from "./store"; - -const ErrorEnvelope = (code: string, message: string) => ({ - error: { code, message }, -}); +import { makeErrorEnvelope } from "@workbench/hub-client"; const PatchBody = type("Record"); @@ -44,7 +41,10 @@ export function createPreferencesRoutes( const patch = PatchBody(raw); if (patch instanceof type.errors) { return c.json( - ErrorEnvelope("bad_request", `invalid body: ${patch.summary}`), + makeErrorEnvelope({ + code: "bad_request", + userMessage: `invalid body: ${patch.summary}`, + }), 400, ); } diff --git a/packages/preferences/tsconfig.json b/packages/preferences/tsconfig.json index 363a89fac..d7611c122 100644 --- a/packages/preferences/tsconfig.json +++ b/packages/preferences/tsconfig.json @@ -1,25 +1,8 @@ { - "extends": "./tsconfig.src.json", - "compilerOptions": { - "composite": false, - "noEmit": true, - "disableSourceOfProjectReferenceRedirect": true, - "declaration": false, - "declarationMap": false, - "emitDeclarationOnly": false, - "rootDir": "../.." - }, + "extends": "../../tsconfig.base.json", "include": ["src", "test"], - "exclude": [], - "references": [ - { - "path": "../../vendor/intx/hub-api/tsconfig.src.json" - }, - { - "path": "../api-query/tsconfig.src.json" - }, - { - "path": "../migration-runner/tsconfig.src.json" - } - ] + "compilerOptions": { + "types": ["bun"], + "noEmit": true + } } diff --git a/packages/preferences/tsconfig.src.json b/packages/preferences/tsconfig.src.json deleted file mode 100644 index 4cc48381c..000000000 --- a/packages/preferences/tsconfig.src.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "include": ["src", "package.json", "src/**/*.json"], - "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"], - "compilerOptions": { - "types": ["bun"], - "composite": true, - "emitDeclarationOnly": true, - "outDir": "dist", - "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo" - }, - "references": [ - { - "path": "../../vendor/intx/hub-api/tsconfig.src.json" - }, - { - "path": "../api-query/tsconfig.src.json" - }, - { - "path": "../migration-runner/tsconfig.src.json" - } - ] -} diff --git a/packages/presence/package.json b/packages/presence/package.json index 3f6f87883..3be8b6209 100644 --- a/packages/presence/package.json +++ b/packages/presence/package.json @@ -16,6 +16,7 @@ }, "dependencies": { "@intx/hub-api": "workspace:*", + "@workbench/hub-client": "workspace:*", "arktype": "catalog:", "hono": "^4.11.9", "y-protocols": "^1.0.7", diff --git a/packages/presence/src/routes.ts b/packages/presence/src/routes.ts index 484d9362d..037d995ba 100644 --- a/packages/presence/src/routes.ts +++ b/packages/presence/src/routes.ts @@ -21,6 +21,7 @@ import { type PresenceState, type PresenceStatePatch, } from "./room-registry"; +import { makeErrorEnvelope } from "@workbench/hub-client"; import { MAX_DOC_UPDATE_BYTES, maxBase64LengthFor, @@ -31,10 +32,6 @@ import { const DEFAULT_HEARTBEAT_TIMEOUT_MS = 45_000; -function errorEnvelope(code: string, message: string) { - return { error: { code, message } }; -} - export interface CreatePresenceRoutesDeps { registry?: PresenceRoomRegistry; heartbeatTimeoutMs?: number; @@ -134,7 +131,10 @@ export function createPresenceRoutes( const body = PresenceJoinBody(await c.req.json().catch(() => ({}))); if (body instanceof type.errors) { return c.json( - errorEnvelope("bad_request", `invalid join body: ${body.summary}`), + makeErrorEnvelope({ + code: "bad_request", + userMessage: `invalid join body: ${body.summary}`, + }), 400, ); } @@ -165,7 +165,10 @@ export function createPresenceRoutes( const body = PresenceHeartbeatBody(await c.req.json().catch(() => ({}))); if (body instanceof type.errors) { return c.json( - errorEnvelope("bad_request", `invalid heartbeat body: ${body.summary}`), + makeErrorEnvelope({ + code: "bad_request", + userMessage: `invalid heartbeat body: ${body.summary}`, + }), 400, ); } @@ -186,7 +189,10 @@ export function createPresenceRoutes( const heartbeatResult = registry.heartbeat(key, principal.id, patch, now()); if (heartbeatResult === undefined) { return c.json( - errorEnvelope("not_joined", "principal has not joined this room"), + makeErrorEnvelope({ + code: "not_joined", + userMessage: "principal has not joined this room", + }), 404, ); } @@ -201,7 +207,10 @@ export function createPresenceRoutes( const body = PresenceDocUpdateBody(await c.req.json().catch(() => ({}))); if (body instanceof type.errors) { return c.json( - errorEnvelope("bad_request", `invalid update body: ${body.summary}`), + makeErrorEnvelope({ + code: "bad_request", + userMessage: `invalid update body: ${body.summary}`, + }), 400, ); } @@ -213,10 +222,10 @@ export function createPresenceRoutes( // would have caught it anyway. if (body.update.length > maxBase64UpdateLength) { return c.json( - errorEnvelope( - "payload_too_large", - `update exceeds the ${maxDocUpdateBytes} byte limit`, - ), + makeErrorEnvelope({ + code: "payload_too_large", + userMessage: `update exceeds the ${maxDocUpdateBytes} byte limit`, + }), 413, ); } @@ -227,7 +236,10 @@ export function createPresenceRoutes( } catch (err) { if (err instanceof InvalidBase64Error) { return c.json( - errorEnvelope("bad_request", "update is not valid base64"), + makeErrorEnvelope({ + code: "bad_request", + userMessage: "update is not valid base64", + }), 400, ); } @@ -240,10 +252,10 @@ export function createPresenceRoutes( // count is still checked directly before it ever reaches Yjs. if (bytes.byteLength > maxDocUpdateBytes) { return c.json( - errorEnvelope( - "payload_too_large", - `update exceeds the ${maxDocUpdateBytes} byte limit`, - ), + makeErrorEnvelope({ + code: "payload_too_large", + userMessage: `update exceeds the ${maxDocUpdateBytes} byte limit`, + }), 413, ); } @@ -257,7 +269,10 @@ export function createPresenceRoutes( registry.applyDocUpdate(key, bytes, principal.id); } catch { return c.json( - errorEnvelope("bad_request", "update is not a valid Yjs update"), + makeErrorEnvelope({ + code: "bad_request", + userMessage: "update is not a valid Yjs update", + }), 400, ); } diff --git a/packages/presence/tsconfig.json b/packages/presence/tsconfig.json index d8e8d65b3..d7611c122 100644 --- a/packages/presence/tsconfig.json +++ b/packages/presence/tsconfig.json @@ -1,19 +1,8 @@ { - "extends": "./tsconfig.src.json", - "compilerOptions": { - "composite": false, - "noEmit": true, - "disableSourceOfProjectReferenceRedirect": true, - "declaration": false, - "declarationMap": false, - "emitDeclarationOnly": false, - "rootDir": "../.." - }, + "extends": "../../tsconfig.base.json", "include": ["src", "test"], - "exclude": [], - "references": [ - { - "path": "../../vendor/intx/hub-api/tsconfig.src.json" - } - ] + "compilerOptions": { + "types": ["bun"], + "noEmit": true + } } diff --git a/packages/presence/tsconfig.src.json b/packages/presence/tsconfig.src.json deleted file mode 100644 index 8b907e524..000000000 --- a/packages/presence/tsconfig.src.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "include": ["src", "package.json", "src/**/*.json"], - "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"], - "compilerOptions": { - "types": ["bun"], - "composite": true, - "emitDeclarationOnly": true, - "outDir": "dist", - "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo" - }, - "references": [ - { - "path": "../../vendor/intx/hub-api/tsconfig.src.json" - } - ] -} diff --git a/packages/routines-tools/package.json b/packages/routines-tools/package.json index 30b2a4db0..3f8081749 100644 --- a/packages/routines-tools/package.json +++ b/packages/routines-tools/package.json @@ -2,7 +2,7 @@ "name": "@corbits/routines-tools", "private": true, "description": "Myra's routine-management tool bundle (routine_list, routine_create, routine_update, routine_run_now): an @intx/agent tool bundle calling @corbits/routines' workflow-run-authenticated routine routes, so Myra can create and manage the workbench's recurring/triggered automations from chat without reimplementing scheduling, cron, or launch logic", - "version": "0.0.5", + "version": "0.0.6", "license": "LGPL-2.1-or-later", "type": "module", "exports": { diff --git a/packages/routines-tools/src/client.test.ts b/packages/routines-tools/src/client.test.ts index bef6a0f62..c2745373a 100644 --- a/packages/routines-tools/src/client.test.ts +++ b/packages/routines-tools/src/client.test.ts @@ -107,7 +107,11 @@ test("createRoutine surfaces the route's own error message on a non-ok response" const fetchImpl = (async () => new Response( JSON.stringify({ - error: { code: "not_found", message: "definition not found" }, + error: { + code: "not_found", + userMessage: "definition not found", + refId: "ref_test", + }, }), { status: 404 }, )) as unknown as typeof fetch; diff --git a/packages/routines-tools/src/client.ts b/packages/routines-tools/src/client.ts index ac9b6746f..125408239 100644 --- a/packages/routines-tools/src/client.ts +++ b/packages/routines-tools/src/client.ts @@ -112,18 +112,22 @@ const RunRoutineNowResponse = type({ runId: "string", }); -/** Pulls `error.message` out of a Hono `app.onError` envelope - * (`{error: {code, message}}`), if `body` matches that shape. */ +/** Pulls `error.userMessage` out of the canonical hub envelope + * (`{error: {code, userMessage, refId}}`), if `body` matches that shape. */ function errorMessageFrom(body: unknown): string | undefined { if (body === null || typeof body !== "object" || !("error" in body)) { return undefined; } const error = (body as { error: unknown }).error; - if (error === null || typeof error !== "object" || !("message" in error)) { + if ( + error === null || + typeof error !== "object" || + !("userMessage" in error) + ) { return undefined; } - const message = (error as { message: unknown }).message; - return typeof message === "string" ? message : undefined; + const userMessage = (error as { userMessage: unknown }).userMessage; + return typeof userMessage === "string" ? userMessage : undefined; } function authHeaders(config: RoutineToolClientConfig): Record { diff --git a/packages/routines/package.json b/packages/routines/package.json index 5f33664b3..e4e5de902 100644 --- a/packages/routines/package.json +++ b/packages/routines/package.json @@ -25,6 +25,7 @@ "@intx/hub-api": "workspace:*", "@intx/hub-common": "0.3.0", "@intx/log": "0.3.0", + "@workbench/hub-client": "workspace:*", "arktype": "catalog:", "drizzle-orm": "catalog:", "hono": "catalog:", diff --git a/packages/routines/src/routes.ts b/packages/routines/src/routes.ts index ecebb11af..49a1d4f43 100644 --- a/packages/routines/src/routes.ts +++ b/packages/routines/src/routes.ts @@ -29,6 +29,7 @@ import type { RoutineStore, UpdateRoutineInput, } from "./store"; +import { makeErrorEnvelope } from "@workbench/hub-client"; import { MyraRoutineDraftingUnavailableError, RoutineDraftReferenceOutOfInventoryError, @@ -173,10 +174,6 @@ export type CreateRoutineRoutesDeps = { workbenchNotice?: WorkbenchNoticePort | undefined; }; -const ErrorEnvelope = (code: string, message: string) => ({ - error: { code, message }, -}); - const DRAFT_FAILED_MESSAGE = "Myra couldn't draft a routine from that. Try rephrasing, or build it from the catalog instead."; @@ -511,7 +508,10 @@ export function createRoutineRoutes( const body = CreateRoutineBody(await c.req.json().catch(() => undefined)); if (body instanceof type.errors) { return c.json( - ErrorEnvelope("bad_request", `invalid routine body: ${body.summary}`), + makeErrorEnvelope({ + code: "bad_request", + userMessage: `invalid routine body: ${body.summary}`, + }), 400, ); } @@ -526,7 +526,10 @@ export function createRoutineRoutes( ); if (!owned) { return c.json( - ErrorEnvelope("not_found", "definition not found"), + makeErrorEnvelope({ + code: "not_found", + userMessage: "definition not found", + }), 404, ); } @@ -541,7 +544,10 @@ export function createRoutineRoutes( )) ) { return c.json( - ErrorEnvelope("not_found", "webhook trigger not found"), + makeErrorEnvelope({ + code: "not_found", + userMessage: "webhook trigger not found", + }), 404, ); } @@ -563,10 +569,10 @@ export function createRoutineRoutes( // see this file's git history for the removed `deliverySpace` port). if (needsDelivery) { return c.json( - ErrorEnvelope( - "bad_request", - "deliveryWorkbenchId is required for this workflow", - ), + makeErrorEnvelope({ + code: "bad_request", + userMessage: "deliveryWorkbenchId is required for this workflow", + }), 400, ); } @@ -578,7 +584,13 @@ export function createRoutineRoutes( body.input ?? {}, ); if (!validated.ok) { - return c.json(ErrorEnvelope("bad_request", validated.message), 400); + return c.json( + makeErrorEnvelope({ + code: "bad_request", + userMessage: validated.message, + }), + 400, + ); } } @@ -685,7 +697,13 @@ export function createRoutineRoutes( const tenant = c.get("tenant"); const row = await deps.store.getRoutine(tenant.id, c.req.param("id")); if (row === undefined) { - return c.json(ErrorEnvelope("not_found", "routine not found"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "routine not found", + }), + 404, + ); } return c.json(routineView(row)); }, @@ -698,10 +716,10 @@ export function createRoutineRoutes( const body = UpdateRoutineBody(await c.req.json().catch(() => undefined)); if (body instanceof type.errors) { return c.json( - ErrorEnvelope( - "bad_request", - `invalid routine patch: ${body.summary}`, - ), + makeErrorEnvelope({ + code: "bad_request", + userMessage: `invalid routine patch: ${body.summary}`, + }), 400, ); } @@ -711,7 +729,13 @@ export function createRoutineRoutes( const routineId = c.req.param("id"); const existing = await deps.store.getRoutine(tenant.id, routineId); if (existing === undefined) { - return c.json(ErrorEnvelope("not_found", "routine not found"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "routine not found", + }), + 404, + ); } if ( @@ -724,7 +748,10 @@ export function createRoutineRoutes( )) ) { return c.json( - ErrorEnvelope("not_found", "webhook trigger not found"), + makeErrorEnvelope({ + code: "not_found", + userMessage: "webhook trigger not found", + }), 404, ); } @@ -770,7 +797,13 @@ export function createRoutineRoutes( c.req.param("id"), ); if (!deleted) { - return c.json(ErrorEnvelope("not_found", "routine not found"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "routine not found", + }), + 404, + ); } return c.body(null, 204); }, @@ -790,7 +823,13 @@ export function createRoutineRoutes( routineId, ); if (existing === undefined) { - return c.json(ErrorEnvelope("not_found", "routine not found"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "routine not found", + }), + 404, + ); } const rows = await deps.store.listRunsForRoutine(tenant.id, routineId); const items = await Promise.all( @@ -807,7 +846,10 @@ export function createRoutineRoutes( const body = RunNowBody(await c.req.json().catch(() => ({}))); if (body instanceof type.errors) { return c.json( - ErrorEnvelope("bad_request", `invalid run body: ${body.summary}`), + makeErrorEnvelope({ + code: "bad_request", + userMessage: `invalid run body: ${body.summary}`, + }), 400, ); } @@ -817,7 +859,13 @@ export function createRoutineRoutes( const routineId = c.req.param("id"); const existing = await deps.store.getRoutine(tenant.id, routineId); if (existing === undefined) { - return c.json(ErrorEnvelope("not_found", "routine not found"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "routine not found", + }), + 404, + ); } // "Run now" is an unscheduled fire of the exact launcher a @@ -833,10 +881,11 @@ export function createRoutineRoutes( existing.deliveryWorkbenchId === "") ) { return c.json( - ErrorEnvelope( - "bad_request", - "routine has no deliveryWorkbenchId; set one before running", - ), + makeErrorEnvelope({ + code: "bad_request", + userMessage: + "routine has no deliveryWorkbenchId; set one before running", + }), 400, ); } @@ -866,17 +915,20 @@ export function createRoutineRoutes( async (c) => { if (deps.drafts === undefined) { return c.json( - ErrorEnvelope( - "unavailable", - "Routine drafting is not configured on this hub.", - ), + makeErrorEnvelope({ + code: "unavailable", + userMessage: "Routine drafting is not configured on this hub.", + }), 503, ); } const body = CreateDraftBody(await c.req.json().catch(() => undefined)); if (body instanceof type.errors) { return c.json( - ErrorEnvelope("bad_request", `invalid draft body: ${body.summary}`), + makeErrorEnvelope({ + code: "bad_request", + userMessage: `invalid draft body: ${body.summary}`, + }), 400, ); } @@ -886,10 +938,10 @@ export function createRoutineRoutes( if (deps.drafting !== undefined) { if (inFlightDraftingPrincipals.has(principal.id)) { return c.json( - ErrorEnvelope( - "dispatch_in_progress", - "Myra is already working on your last request.", - ), + makeErrorEnvelope({ + code: "dispatch_in_progress", + userMessage: "Myra is already working on your last request.", + }), 409, ); } @@ -919,7 +971,10 @@ export function createRoutineRoutes( }`; if (isDraftingFailure(err)) { return c.json( - ErrorEnvelope("drafting_failed", DRAFT_FAILED_MESSAGE), + makeErrorEnvelope({ + code: "drafting_failed", + userMessage: DRAFT_FAILED_MESSAGE, + }), 422, ); } @@ -961,17 +1016,23 @@ export function createRoutineRoutes( async (c) => { if (deps.drafts === undefined) { return c.json( - ErrorEnvelope( - "unavailable", - "Routine drafting is not configured on this hub.", - ), + makeErrorEnvelope({ + code: "unavailable", + userMessage: "Routine drafting is not configured on this hub.", + }), 503, ); } const tenant = c.get("tenant"); const draft = await deps.drafts.getDraft(tenant.id, c.req.param("id")); if (draft === undefined) { - return c.json(ErrorEnvelope("not_found", "draft not found"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "draft not found", + }), + 404, + ); } return c.json(draftView(draft)); }, @@ -983,17 +1044,20 @@ export function createRoutineRoutes( async (c) => { if (deps.drafts === undefined) { return c.json( - ErrorEnvelope( - "unavailable", - "Routine drafting is not configured on this hub.", - ), + makeErrorEnvelope({ + code: "unavailable", + userMessage: "Routine drafting is not configured on this hub.", + }), 503, ); } const body = ApproveDraftBody(await c.req.json().catch(() => ({}))); if (body instanceof type.errors) { return c.json( - ErrorEnvelope("bad_request", `invalid approve body: ${body.summary}`), + makeErrorEnvelope({ + code: "bad_request", + userMessage: `invalid approve body: ${body.summary}`, + }), 400, ); } @@ -1002,14 +1066,20 @@ export function createRoutineRoutes( const draftId = c.req.param("id"); const draft = await deps.drafts.getDraft(tenant.id, draftId); if (draft === undefined) { - return c.json(ErrorEnvelope("not_found", "draft not found"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "draft not found", + }), + 404, + ); } if (draft.status !== "reviewed") { return c.json( - ErrorEnvelope( - "bad_request", - `draft is ${draft.status}; only reviewed drafts can be approved`, - ), + makeErrorEnvelope({ + code: "bad_request", + userMessage: `draft is ${draft.status}; only reviewed drafts can be approved`, + }), 400, ); } @@ -1023,10 +1093,11 @@ export function createRoutineRoutes( : draft.definitionId; if (definitionId === null || definitionId === "") { return c.json( - ErrorEnvelope( - "bad_request", - "draft has no definitionId; review must pin a workflow definition", - ), + makeErrorEnvelope({ + code: "bad_request", + userMessage: + "draft has no definitionId; review must pin a workflow definition", + }), 400, ); } @@ -1034,7 +1105,10 @@ export function createRoutineRoutes( const owned = await deps.definitionInTenant(tenant.id, definitionId); if (!owned) { return c.json( - ErrorEnvelope("not_found", "definition not found"), + makeErrorEnvelope({ + code: "not_found", + userMessage: "definition not found", + }), 404, ); } @@ -1054,7 +1128,10 @@ export function createRoutineRoutes( )) ) { return c.json( - ErrorEnvelope("not_found", "webhook trigger not found"), + makeErrorEnvelope({ + code: "not_found", + userMessage: "webhook trigger not found", + }), 404, ); } @@ -1094,10 +1171,10 @@ export function createRoutineRoutes( async (c) => { if (deps.drafts === undefined) { return c.json( - ErrorEnvelope( - "unavailable", - "Routine drafting is not configured on this hub.", - ), + makeErrorEnvelope({ + code: "unavailable", + userMessage: "Routine drafting is not configured on this hub.", + }), 503, ); } @@ -1110,10 +1187,10 @@ export function createRoutineRoutes( return c.json(draftView(draft)); } catch (err) { return c.json( - ErrorEnvelope( - "bad_request", - err instanceof Error ? err.message : "discard failed", - ), + makeErrorEnvelope({ + code: "bad_request", + userMessage: err instanceof Error ? err.message : "discard failed", + }), 400, ); } diff --git a/packages/routines/src/workflow-routine-routes.test.ts b/packages/routines/src/workflow-routine-routes.test.ts index 1226095d2..3906d274a 100644 --- a/packages/routines/src/workflow-routine-routes.test.ts +++ b/packages/routines/src/workflow-routine-routes.test.ts @@ -192,7 +192,7 @@ test("an unresolvable definitionId 404s with up to 8 candidate name (wfd_id) pai }); expect(response.status).toBe(404); const message = (body["error"] as Record)[ - "message" + "userMessage" ] as string; expect(message).toContain("digest-writer (wfd_digest)"); expect(message).toContain("other-agent (wfd_other)"); diff --git a/packages/routines/src/workflow-routine-routes.ts b/packages/routines/src/workflow-routine-routes.ts index b2a8efff8..0cdd13820 100644 --- a/packages/routines/src/workflow-routine-routes.ts +++ b/packages/routines/src/workflow-routine-routes.ts @@ -30,6 +30,7 @@ import { type } from "arktype"; import { RoutineTrigger } from "./trigger"; import type { RoutineStore, UpdateRoutineInput } from "./store"; +import { makeErrorEnvelope } from "@workbench/hub-client"; import { fireOnceTriggerIfNeeded, isDeliveryWorkbenchRequired, @@ -140,10 +141,6 @@ export type CreateWorkflowRoutineRoutesDeps = { workbenchNotice?: WorkbenchNoticePort | undefined; }; -const ErrorEnvelope = (code: string, message: string) => ({ - error: { code, message }, -}); - /** "definition not found" plus up to 8 `name (wfd_id)` candidates, so a * model that passed a bad id or name can self-correct. */ async function definitionNotFoundMessage( @@ -202,10 +199,11 @@ export function createWorkflowRoutineRoutes( const scope = await deps.authenticator.resolve(token, address); if (scope === null) { return c.json( - ErrorEnvelope( - "unauthorized", - "Missing or unrecognized sidecar bearer token / run address", - ), + makeErrorEnvelope({ + code: "unauthorized", + userMessage: + "Missing or unrecognized sidecar bearer token / run address", + }), 401, ); } @@ -226,7 +224,10 @@ export function createWorkflowRoutineRoutes( ); if (body instanceof type.errors) { return c.json( - ErrorEnvelope("bad_request", `invalid routine body: ${body.summary}`), + makeErrorEnvelope({ + code: "bad_request", + userMessage: `invalid routine body: ${body.summary}`, + }), 400, ); } @@ -246,10 +247,10 @@ export function createWorkflowRoutineRoutes( } if (!owned) { return c.json( - ErrorEnvelope( - "not_found", - await definitionNotFoundMessage(deps, scope.tenantId), - ), + makeErrorEnvelope({ + code: "not_found", + userMessage: await definitionNotFoundMessage(deps, scope.tenantId), + }), 404, ); } @@ -264,7 +265,10 @@ export function createWorkflowRoutineRoutes( )) ) { return c.json( - ErrorEnvelope("not_found", "webhook trigger not found"), + makeErrorEnvelope({ + code: "not_found", + userMessage: "webhook trigger not found", + }), 404, ); } @@ -297,10 +301,10 @@ export function createWorkflowRoutineRoutes( if (needsDelivery) { return c.json( - ErrorEnvelope( - "bad_request", - "deliveryWorkbenchId is required for this workflow", - ), + makeErrorEnvelope({ + code: "bad_request", + userMessage: "deliveryWorkbenchId is required for this workflow", + }), 400, ); } @@ -312,7 +316,13 @@ export function createWorkflowRoutineRoutes( body.input ?? {}, ); if (!validated.ok) { - return c.json(ErrorEnvelope("bad_request", validated.message), 400); + return c.json( + makeErrorEnvelope({ + code: "bad_request", + userMessage: validated.message, + }), + 400, + ); } } @@ -371,7 +381,10 @@ export function createWorkflowRoutineRoutes( ); if (body instanceof type.errors) { return c.json( - ErrorEnvelope("bad_request", `invalid routine patch: ${body.summary}`), + makeErrorEnvelope({ + code: "bad_request", + userMessage: `invalid routine patch: ${body.summary}`, + }), 400, ); } @@ -379,7 +392,13 @@ export function createWorkflowRoutineRoutes( const routineId = c.req.param("id"); const existing = await deps.store.getRoutine(scope.tenantId, routineId); if (existing === undefined) { - return c.json(ErrorEnvelope("not_found", "routine not found"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "routine not found", + }), + 404, + ); } if ( @@ -392,7 +411,10 @@ export function createWorkflowRoutineRoutes( )) ) { return c.json( - ErrorEnvelope("not_found", "webhook trigger not found"), + makeErrorEnvelope({ + code: "not_found", + userMessage: "webhook trigger not found", + }), 404, ); } @@ -429,7 +451,10 @@ export function createWorkflowRoutineRoutes( const body = RunNowBody(await c.req.json().catch(() => ({}))); if (body instanceof type.errors) { return c.json( - ErrorEnvelope("bad_request", `invalid run body: ${body.summary}`), + makeErrorEnvelope({ + code: "bad_request", + userMessage: `invalid run body: ${body.summary}`, + }), 400, ); } @@ -437,7 +462,13 @@ export function createWorkflowRoutineRoutes( const routineId = c.req.param("id"); const existing = await deps.store.getRoutine(scope.tenantId, routineId); if (existing === undefined) { - return c.json(ErrorEnvelope("not_found", "routine not found"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "routine not found", + }), + 404, + ); } // "Run now" is an unscheduled fire of the exact launcher a scheduled @@ -453,10 +484,11 @@ export function createWorkflowRoutineRoutes( existing.deliveryWorkbenchId === "") ) { return c.json( - ErrorEnvelope( - "bad_request", - "routine has no deliveryWorkbenchId; set one before running", - ), + makeErrorEnvelope({ + code: "bad_request", + userMessage: + "routine has no deliveryWorkbenchId; set one before running", + }), 400, ); } diff --git a/packages/routines/test/routes.test.ts b/packages/routines/test/routes.test.ts index 287efd53c..5137dd213 100644 --- a/packages/routines/test/routes.test.ts +++ b/packages/routines/test/routes.test.ts @@ -815,7 +815,7 @@ describe("createRoutineRoutes", () => { const app = mountAs(createRoutineRoutes(deps), "user_1"); const { response, body } = await createRoutine(app, VALID_BODY); expect(response.status).toBe(400); - expect((body["error"] as Record)["message"]).toBe( + expect((body["error"] as Record)["userMessage"]).toBe( '"Agent" is required', ); }); diff --git a/packages/routines/test/routine-drafts.test.ts b/packages/routines/test/routine-drafts.test.ts index bdf682724..829224dc9 100644 --- a/packages/routines/test/routine-drafts.test.ts +++ b/packages/routines/test/routine-drafts.test.ts @@ -144,13 +144,17 @@ describe("POST /routine-drafts with a Myra-backed drafting port", () => { const { response, body } = await createDraft(app, DRAFT_BODY); expect(response.status).toBe(422); - expect(body).toEqual({ - error: { - code: "drafting_failed", - message: - "Myra couldn't draft a routine from that. Try rephrasing, or build it from the catalog instead.", - }, - }); + const error = body.error as { + code: string; + userMessage: string; + refId: string; + }; + expect(error.code).toBe("drafting_failed"); + expect(error.userMessage).toBe( + "Myra couldn't draft a routine from that. Try rephrasing, or build it from the catalog instead.", + ); + expect(typeof error.refId).toBe("string"); + expect(error.refId.length).toBeGreaterThan(0); }); test("an unparseable Myra reply also surfaces the honest drafting_failed envelope", async () => { @@ -208,12 +212,17 @@ describe("in-flight drafting guard", () => { const second = await createDraft(app, DRAFT_BODY); expect(second.response.status).toBe(409); - expect(second.body).toEqual({ - error: { - code: "dispatch_in_progress", - message: "Myra is already working on your last request.", - }, - }); + const error = second.body.error as { + code: string; + userMessage: string; + refId: string; + }; + expect(error.code).toBe("dispatch_in_progress"); + expect(error.userMessage).toBe( + "Myra is already working on your last request.", + ); + expect(typeof error.refId).toBe("string"); + expect(error.refId.length).toBeGreaterThan(0); releaseFirst(); const firstResult = await first; diff --git a/packages/run-key-history/package.json b/packages/run-key-history/package.json index c54c0d5ba..a23b0fc03 100644 --- a/packages/run-key-history/package.json +++ b/packages/run-key-history/package.json @@ -17,6 +17,7 @@ "@intx/db": "workspace:*", "@intx/hub-api": "workspace:*", "@intx/log": "0.3.0", + "@workbench/hub-client": "workspace:*", "arktype": "catalog:", "drizzle-orm": "catalog:", "hono": "^4.11.9", diff --git a/packages/run-key-history/src/routes.ts b/packages/run-key-history/src/routes.ts index 5aed9c685..3d7c9d336 100644 --- a/packages/run-key-history/src/routes.ts +++ b/packages/run-key-history/src/routes.ts @@ -9,6 +9,7 @@ import { type } from "arktype"; import type { DB } from "@intx/db"; import type { RequireGrant, TenantEnv } from "@intx/hub-api"; +import { makeErrorEnvelope } from "@workbench/hub-client"; import { countRunIdentityStates, @@ -16,10 +17,6 @@ import { getRunKeyLifecycle, } from "./diagnostics"; -const ErrorEnvelope = (code: string, message: string) => ({ - error: { code, message }, -}); - const SummaryQuery = type({ "sidecarId?": "string", }); @@ -50,7 +47,13 @@ export function createRunKeyHistoryRoutes( getRunKeyLifecycle(deps.db, runAddress), ]); if (status === null) { - return c.json(ErrorEnvelope("not_found", "run not found"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "run not found", + }), + 404, + ); } return c.json({ status, lifecycle }); }, @@ -69,7 +72,10 @@ export function createRunKeyHistoryRoutes( const raw = SummaryQuery(c.req.query()); if (raw instanceof type.errors) { return c.json( - ErrorEnvelope("bad_request", `invalid query: ${raw.summary}`), + makeErrorEnvelope({ + code: "bad_request", + userMessage: `invalid query: ${raw.summary}`, + }), 400, ); } diff --git a/packages/run-key-history/tsconfig.json b/packages/run-key-history/tsconfig.json index 83db4b481..d7611c122 100644 --- a/packages/run-key-history/tsconfig.json +++ b/packages/run-key-history/tsconfig.json @@ -1,22 +1,8 @@ { - "extends": "./tsconfig.src.json", - "compilerOptions": { - "composite": false, - "noEmit": true, - "disableSourceOfProjectReferenceRedirect": true, - "declaration": false, - "declarationMap": false, - "emitDeclarationOnly": false, - "rootDir": "../.." - }, + "extends": "../../tsconfig.base.json", "include": ["src", "test"], - "exclude": [], - "references": [ - { - "path": "../../vendor/intx/db/tsconfig.src.json" - }, - { - "path": "../../vendor/intx/hub-api/tsconfig.src.json" - } - ] + "compilerOptions": { + "types": ["bun"], + "noEmit": true + } } diff --git a/packages/run-key-history/tsconfig.src.json b/packages/run-key-history/tsconfig.src.json deleted file mode 100644 index 34abb1fa3..000000000 --- a/packages/run-key-history/tsconfig.src.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "include": ["src", "package.json", "src/**/*.json"], - "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"], - "compilerOptions": { - "types": ["bun"], - "composite": true, - "emitDeclarationOnly": true, - "outDir": "dist", - "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo" - }, - "references": [ - { - "path": "../../vendor/intx/db/tsconfig.src.json" - }, - { - "path": "../../vendor/intx/hub-api/tsconfig.src.json" - } - ] -} diff --git a/packages/run-scope/package.json b/packages/run-scope/package.json index f8c463ca6..b0ca484f5 100644 --- a/packages/run-scope/package.json +++ b/packages/run-scope/package.json @@ -17,6 +17,7 @@ "@intx/db": "workspace:*", "@intx/hub-api": "workspace:*", "@intx/types": "workspace:*", + "@workbench/hub-client": "workspace:*", "arktype": "catalog:", "drizzle-orm": "catalog:", "hono": "^4.11.9" diff --git a/packages/run-scope/src/scope-routes.ts b/packages/run-scope/src/scope-routes.ts index a4dc84e84..537369e56 100644 --- a/packages/run-scope/src/scope-routes.ts +++ b/packages/run-scope/src/scope-routes.ts @@ -33,6 +33,7 @@ import { getDescendantTenants, type DB } from "@intx/db"; import { workflowDefinition, workflowRun } from "@intx/db/schema"; import type { WorkflowRunStatus } from "@intx/types"; import { foldedRun } from "@corbits/folded-runs"; +import { makeErrorEnvelope } from "@workbench/hub-client"; /** A routine fire's parent, resolved by `resolveRoutineFires` below. */ export type RoutineFireInfo = { @@ -301,7 +302,10 @@ export function createTopLevelRunRoutes( const query = LimitQuery(rawLimit === undefined ? {} : { limit: rawLimit }); if (query instanceof type.errors) { return c.json( - { error: { code: "bad_request", message: query.summary } }, + makeErrorEnvelope({ + code: "bad_request", + userMessage: query.summary, + }), 400, ); } @@ -309,7 +313,10 @@ export function createTopLevelRunRoutes( const feedQuery = FeedQuery(rawFeed === undefined ? {} : { feed: rawFeed }); if (feedQuery instanceof type.errors) { return c.json( - { error: { code: "bad_request", message: feedQuery.summary } }, + makeErrorEnvelope({ + code: "bad_request", + userMessage: feedQuery.summary, + }), 400, ); } diff --git a/packages/settings-ui/src/api-request.ts b/packages/settings-ui/src/api-request.ts index 480bec4cb..de5ef6bcd 100644 --- a/packages/settings-ui/src/api-request.ts +++ b/packages/settings-ui/src/api-request.ts @@ -2,24 +2,24 @@ // (credentials-api.ts, connections-api.ts, access-policy-api.ts, // granola-webhook-api.ts): the same envelope-first error message on every // non-2xx response, matching `apps/web/src/onboarding.ts`'s -// `readErrorEnvelope` — the hub's own `{error:{message}}` body wins when -// present, and the fallback names what was happening ("while loading -// credentials") rather than the raw route, which nobody reading a settings -// panel should ever have to see. Each seam keeps its own `Error` subclass -// so a catch site can still tell which API failed; only the request shape -// is shared here. +// `readErrorEnvelope` — the hub's own `{error:{userMessage, refId}}` body +// wins when present, and the fallback names what was happening ("while +// loading credentials") rather than the raw route, which nobody reading a +// settings panel should ever have to see. Each seam keeps its own `Error` +// subclass so a catch site can still tell which API failed; only the +// request shape is shared here. import { type } from "arktype"; import type { ArkErrors } from "arktype"; const ErrorEnvelope = type({ - error: { message: "string", "code?": "string" }, + error: { code: "string", userMessage: "string", refId: "string" }, }); /** - * Resolves a non-2xx response's message: the hub's own envelope message - * when the body carries one, otherwise a generic, path-free sentence - * naming the status and what the caller was doing. + * Resolves a non-2xx response's message: the hub's own envelope + * `userMessage` when the body carries one, otherwise a generic, path-free + * sentence naming the status and what the caller was doing. */ export function readErrorEnvelope( status: number, @@ -29,7 +29,7 @@ export function readErrorEnvelope( const envelope = ErrorEnvelope(body); return envelope instanceof type.errors ? `The server answered ${status} while ${verb}.` - : envelope.error.message; + : envelope.error.userMessage; } export type Validator = (data: unknown) => T | ArkErrors; diff --git a/packages/settings-ui/test/access-policy-api.test.ts b/packages/settings-ui/test/access-policy-api.test.ts index d4ef52526..f41598847 100644 --- a/packages/settings-ui/test/access-policy-api.test.ts +++ b/packages/settings-ui/test/access-policy-api.test.ts @@ -42,7 +42,13 @@ describe("getAccessPolicy", () => { test("prefers the server's envelope message on a non-2xx", async () => { globalThis.fetch = (async () => json( - { error: { message: "Not on this bench." } }, + { + error: { + code: "forbidden", + userMessage: "Not on this bench.", + refId: "ref_1", + }, + }, 403, )) as unknown as typeof fetch; diff --git a/packages/settings-ui/test/api-request.test.ts b/packages/settings-ui/test/api-request.test.ts index bc6445477..a162fd74d 100644 --- a/packages/settings-ui/test/api-request.test.ts +++ b/packages/settings-ui/test/api-request.test.ts @@ -20,7 +20,13 @@ describe("readErrorEnvelope", () => { expect( readErrorEnvelope( 403, - { error: { code: "forbidden", message: "Not on this bench." } }, + { + error: { + code: "forbidden", + userMessage: "Not on this bench.", + refId: "ref_1", + }, + }, "loading credentials", ), ).toBe("Not on this bench."); @@ -50,7 +56,13 @@ describe("apiRequest", () => { } test("throws the caller's Error subclass with the envelope message on non-2xx", async () => { - stub(403, { error: { message: "Not permitted." } }); + stub(403, { + error: { + code: "forbidden", + userMessage: "Not permitted.", + refId: "ref_1", + }, + }); await expect( apiRequest( "/api/tenants/tnt_1/credentials", diff --git a/packages/settings-ui/test/connections-api.test.ts b/packages/settings-ui/test/connections-api.test.ts index db5772ab3..468314c38 100644 --- a/packages/settings-ui/test/connections-api.test.ts +++ b/packages/settings-ui/test/connections-api.test.ts @@ -109,7 +109,16 @@ describe("completeConnectorCredential", () => { // straight from this call, with no separate test step beforehand. test("throws ConnectionsApiError with the probe's own message on a 422", async () => { stubFetch(() => - json({ error: { code: "invalid_credential", message: "bad key" } }, 422), + json( + { + error: { + code: "invalid_credential", + userMessage: "bad key", + refId: "ref_1", + }, + }, + 422, + ), ); await expect( completeConnectorCredential("tnt_1", "granola", "key"), @@ -129,7 +138,16 @@ describe("disconnectConnector", () => { test("throws ConnectionsApiError with the envelope message on a non-2xx", async () => { stubFetch(() => - json({ error: { code: "disconnect_failed", message: "try again" } }, 500), + json( + { + error: { + code: "disconnect_failed", + userMessage: "try again", + refId: "ref_1", + }, + }, + 500, + ), ); await expect(disconnectConnector("tnt_1", "granola")).rejects.toThrow( "try again", diff --git a/packages/settings-ui/test/connections-disconnect-flow.test.tsx b/packages/settings-ui/test/connections-disconnect-flow.test.tsx index ee603bc74..6802a0d03 100644 --- a/packages/settings-ui/test/connections-disconnect-flow.test.tsx +++ b/packages/settings-ui/test/connections-disconnect-flow.test.tsx @@ -186,7 +186,13 @@ describe("Connections disconnect", () => { } if (url === "/api/tenants/ten_1/connections/anthropic/disconnect") { return json( - { error: { code: "disconnect_failed", message: "nope" } }, + { + error: { + code: "disconnect_failed", + userMessage: "nope", + refId: "ref_1", + }, + }, 500, ); } diff --git a/packages/settings-ui/test/connector-credential-dialog.test.tsx b/packages/settings-ui/test/connector-credential-dialog.test.tsx index ea1c0130e..35b5c5142 100644 --- a/packages/settings-ui/test/connector-credential-dialog.test.tsx +++ b/packages/settings-ui/test/connector-credential-dialog.test.tsx @@ -147,7 +147,8 @@ describe("ConnectorCredentialDialog", () => { JSON.stringify({ error: { code: "invalid_credential", - message: "That key doesn't work.", + userMessage: "That key doesn't work.", + refId: "ref_1", }, }), { status: 422, headers: { "content-type": "application/json" } }, diff --git a/packages/sidecar-placement/package.json b/packages/sidecar-placement/package.json index c83013244..6b3871cfe 100644 --- a/packages/sidecar-placement/package.json +++ b/packages/sidecar-placement/package.json @@ -16,6 +16,7 @@ "@intx/db": "workspace:*", "@intx/hub-api": "workspace:*", "@intx/types": "workspace:*", + "@workbench/hub-client": "workspace:*", "arktype": "catalog:", "drizzle-orm": "catalog:", "hono": "^4.11.9" diff --git a/packages/sidecar-placement/src/routes.test.ts b/packages/sidecar-placement/src/routes.test.ts index c35f5b156..8d9fa7bc4 100644 --- a/packages/sidecar-placement/src/routes.test.ts +++ b/packages/sidecar-placement/src/routes.test.ts @@ -105,8 +105,13 @@ test("PUT / enabling exclusive placement 409s when the hub has no provisioner", body: JSON.stringify({ enabled: true }), }); expect(response.status).toBe(409); - const body = (await response.json()) as { error: { code: string } }; + const body = (await response.json()) as { + error: { code: string; userMessage: string; refId: string }; + }; expect(body.error.code).toBe("no_provisioner_configured"); + expect(body.error.userMessage).toContain("Isolated capacity isn't available"); + expect(typeof body.error.refId).toBe("string"); + expect(body.error.refId.length).toBeGreaterThan(0); expect(await store.getEnabled(TENANT.id)).toBe(false); }); @@ -135,8 +140,14 @@ test("PUT / with a non-boolean enabled 400s", async () => { body: JSON.stringify({ enabled: "yes" }), }); expect(response.status).toBe(400); - const body = (await response.json()) as { error: { code: string } }; + const body = (await response.json()) as { + error: { code: string; userMessage: string; refId: string }; + }; expect(body.error.code).toBe("bad_request"); + expect(typeof body.error.userMessage).toBe("string"); + expect(body.error.userMessage.length).toBeGreaterThan(0); + expect(typeof body.error.refId).toBe("string"); + expect(body.error.refId.length).toBeGreaterThan(0); }); test("PUT / with no body / invalid JSON 400s rather than 500", async () => { diff --git a/packages/sidecar-placement/src/routes.ts b/packages/sidecar-placement/src/routes.ts index 8db32dd60..3ed31642a 100644 --- a/packages/sidecar-placement/src/routes.ts +++ b/packages/sidecar-placement/src/routes.ts @@ -6,13 +6,10 @@ import { Hono } from "hono"; import { type } from "arktype"; import type { RequireGrant, TenantEnv } from "@intx/hub-api"; +import { makeErrorEnvelope } from "@workbench/hub-client"; import type { SidecarPlacementStore } from "./store"; -const ErrorEnvelope = (code: string, message: string) => ({ - error: { code, message }, -}); - const PutBody = type({ enabled: "boolean" }); export type CreateSidecarPlacementRoutesDeps = { @@ -48,17 +45,21 @@ export function createSidecarPlacementRoutes( const body = PutBody(raw); if (body instanceof type.errors) { return c.json( - ErrorEnvelope("bad_request", `invalid body: ${body.summary}`), + makeErrorEnvelope({ + code: "bad_request", + userMessage: `invalid body: ${body.summary}`, + }), 400, ); } if (body.enabled && !deps.hasProvisioner) { return c.json( - ErrorEnvelope( - "no_provisioner_configured", - "Isolated capacity isn't available on this server yet. Ask your operator to enable it before turning this on.", - ), + makeErrorEnvelope({ + code: "no_provisioner_configured", + userMessage: + "Isolated capacity isn't available on this server yet. Ask your operator to enable it before turning this on.", + }), 409, ); } diff --git a/packages/sidecar-placement/tsconfig.json b/packages/sidecar-placement/tsconfig.json index cd0974202..d7611c122 100644 --- a/packages/sidecar-placement/tsconfig.json +++ b/packages/sidecar-placement/tsconfig.json @@ -1,25 +1,8 @@ { - "extends": "./tsconfig.src.json", - "compilerOptions": { - "composite": false, - "noEmit": true, - "disableSourceOfProjectReferenceRedirect": true, - "declaration": false, - "declarationMap": false, - "emitDeclarationOnly": false, - "rootDir": "../.." - }, + "extends": "../../tsconfig.base.json", "include": ["src", "test"], - "exclude": [], - "references": [ - { - "path": "../../vendor/intx/db/tsconfig.src.json" - }, - { - "path": "../../vendor/intx/hub-api/tsconfig.src.json" - }, - { - "path": "../../vendor/intx/types/tsconfig.src.json" - } - ] + "compilerOptions": { + "types": ["bun"], + "noEmit": true + } } diff --git a/packages/sidecar-placement/tsconfig.src.json b/packages/sidecar-placement/tsconfig.src.json deleted file mode 100644 index 641d83570..000000000 --- a/packages/sidecar-placement/tsconfig.src.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "include": ["src", "package.json", "src/**/*.json"], - "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"], - "compilerOptions": { - "types": ["bun"], - "composite": true, - "emitDeclarationOnly": true, - "outDir": "dist", - "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo" - }, - "references": [ - { - "path": "../../vendor/intx/db/tsconfig.src.json" - }, - { - "path": "../../vendor/intx/hub-api/tsconfig.src.json" - }, - { - "path": "../../vendor/intx/types/tsconfig.src.json" - } - ] -} diff --git a/packages/skills/package.json b/packages/skills/package.json index c28fe9948..6d98095af 100644 --- a/packages/skills/package.json +++ b/packages/skills/package.json @@ -17,6 +17,7 @@ "@intx/db": "workspace:*", "@intx/hub-api": "workspace:*", "@intx/hub-sessions": "workspace:*", + "@workbench/hub-client": "workspace:*", "arktype": "catalog:", "drizzle-orm": "catalog:", "hono": "^4.11.9", diff --git a/packages/skills/src/routes.ts b/packages/skills/src/routes.ts index 4fc483873..73fdee539 100644 --- a/packages/skills/src/routes.ts +++ b/packages/skills/src/routes.ts @@ -15,6 +15,7 @@ import { type SkillRegistryErrorReason, } from "./registry"; import { parseSkillMd, SkillContentError } from "./skill-md"; +import { makeErrorEnvelope } from "@workbench/hub-client"; /** Which workflow definitions pin a given skill. */ export type PinnedByResolver = { @@ -74,10 +75,6 @@ const STATUS_BY_REASON: Record< conflict: 409, }; -function errorEnvelope(code: string, message: string) { - return { error: { code, message } }; -} - export type CreateSkillRoutesDeps = { registry: SkillRegistry; pinnedBy: PinnedByResolver; @@ -94,7 +91,7 @@ export function createSkillRoutes({ app.onError((err, c) => { if (err instanceof SkillRegistryError) { return c.json( - errorEnvelope(err.reason, err.message), + makeErrorEnvelope({ code: err.reason, userMessage: err.message }), STATUS_BY_REASON[err.reason], ); } @@ -121,7 +118,10 @@ export function createSkillRoutes({ const body = CreateSkillBody(await c.req.json().catch(() => undefined)); if (body instanceof type.errors) { return c.json( - errorEnvelope("bad_request", `invalid skill: ${body.summary}`), + makeErrorEnvelope({ + code: "bad_request", + userMessage: `invalid skill: ${body.summary}`, + }), 400, ); } @@ -131,7 +131,13 @@ export function createSkillRoutes({ fields = parseSkillMd(body.source); } catch (cause) { if (cause instanceof SkillContentError) { - return c.json(errorEnvelope("bad_request", cause.message), 400); + return c.json( + makeErrorEnvelope({ + code: "bad_request", + userMessage: cause.message, + }), + 400, + ); } throw cause; } @@ -165,7 +171,10 @@ export function createSkillRoutes({ const body = UpdateSkillBody(await c.req.json().catch(() => undefined)); if (body instanceof type.errors) { return c.json( - errorEnvelope("bad_request", `invalid update: ${body.summary}`), + makeErrorEnvelope({ + code: "bad_request", + userMessage: `invalid update: ${body.summary}`, + }), 400, ); } @@ -190,7 +199,10 @@ export function createSkillRoutes({ const commitSha = commitShaSchema(c.req.param("commitSha")); if (commitSha instanceof type.errors) { return c.json( - errorEnvelope("bad_request", `invalid version: ${commitSha.summary}`), + makeErrorEnvelope({ + code: "bad_request", + userMessage: `invalid version: ${commitSha.summary}`, + }), 400, ); } @@ -208,7 +220,10 @@ export function createSkillRoutes({ const body = RestoreBody(await c.req.json().catch(() => undefined)); if (body instanceof type.errors) { return c.json( - errorEnvelope("bad_request", `invalid restore: ${body.summary}`), + makeErrorEnvelope({ + code: "bad_request", + userMessage: `invalid restore: ${body.summary}`, + }), 400, ); } @@ -224,7 +239,10 @@ export function createSkillRoutes({ const body = ScopeBody(await c.req.json().catch(() => undefined)); if (body instanceof type.errors) { return c.json( - errorEnvelope("bad_request", `invalid scope: ${body.summary}`), + makeErrorEnvelope({ + code: "bad_request", + userMessage: `invalid scope: ${body.summary}`, + }), 400, ); } diff --git a/packages/skills/src/workflow-routes.ts b/packages/skills/src/workflow-routes.ts index 26a8e6278..3dd83533a 100644 --- a/packages/skills/src/workflow-routes.ts +++ b/packages/skills/src/workflow-routes.ts @@ -12,6 +12,7 @@ // skill. import { type } from "arktype"; import { Hono } from "hono"; +import { makeErrorEnvelope } from "@workbench/hub-client"; import { SkillRegistryError, type SkillRegistry } from "./registry"; @@ -65,7 +66,10 @@ export function createWorkflowSkillRoutes( app.onError((err, c) => { if (err instanceof SkillRegistryError) { return c.json( - { error: { code: err.reason, message: err.message } }, + makeErrorEnvelope({ + code: err.reason, + userMessage: err.message, + }), err.reason === "not_found" ? 404 : 400, ); } @@ -81,13 +85,11 @@ export function createWorkflowSkillRoutes( const scope = await deps.authenticator.resolve(token, address); if (scope === null) { return c.json( - { - error: { - code: "unauthorized", - message: - "Missing or unrecognized sidecar bearer token / run address", - }, - }, + makeErrorEnvelope({ + code: "unauthorized", + userMessage: + "Missing or unrecognized sidecar bearer token / run address", + }), 401, ); } @@ -110,7 +112,10 @@ export function createWorkflowSkillRoutes( const body = SearchBody(await c.req.json().catch(() => undefined)); if (body instanceof type.errors) { return c.json( - { error: { code: "bad_request", message: body.summary } }, + makeErrorEnvelope({ + code: "bad_request", + userMessage: body.summary, + }), 400, ); } @@ -128,7 +133,10 @@ export function createWorkflowSkillRoutes( const body = LoadBody(await c.req.json().catch(() => undefined)); if (body instanceof type.errors) { return c.json( - { error: { code: "bad_request", message: body.summary } }, + makeErrorEnvelope({ + code: "bad_request", + userMessage: body.summary, + }), 400, ); } @@ -150,7 +158,10 @@ export function createWorkflowSkillRoutes( const body = CreateBody(await c.req.json().catch(() => undefined)); if (body instanceof type.errors) { return c.json( - { error: { code: "bad_request", message: body.summary } }, + makeErrorEnvelope({ + code: "bad_request", + userMessage: body.summary, + }), 400, ); } @@ -173,7 +184,10 @@ export function createWorkflowSkillRoutes( const body = UpdateBody(await c.req.json().catch(() => undefined)); if (body instanceof type.errors) { return c.json( - { error: { code: "bad_request", message: body.summary } }, + makeErrorEnvelope({ + code: "bad_request", + userMessage: body.summary, + }), 400, ); } diff --git a/packages/skills/test/routes.test.ts b/packages/skills/test/routes.test.ts index 3430425a9..3468d86c4 100644 --- a/packages/skills/test/routes.test.ts +++ b/packages/skills/test/routes.test.ts @@ -98,8 +98,12 @@ test("POST / with a malformed source SKILL.md is a 400 and creates nothing", asy }), }); expect(response.status).toBe(400); - const payload = (await response.json()) as { error: { message: string } }; - expect(payload.error.message).toContain("frontmatter delimiter"); + const payload = (await response.json()) as { + error: { userMessage: string; refId: string }; + }; + expect(payload.error.userMessage).toContain("frontmatter delimiter"); + expect(typeof payload.error.refId).toBe("string"); + expect(payload.error.refId.length).toBeGreaterThan(0); const list = (await (await app.request("/")).json()) as { skills: unknown[]; diff --git a/packages/skills/tsconfig.json b/packages/skills/tsconfig.json index 03cb017a3..d7611c122 100644 --- a/packages/skills/tsconfig.json +++ b/packages/skills/tsconfig.json @@ -1,25 +1,8 @@ { - "extends": "./tsconfig.src.json", - "compilerOptions": { - "composite": false, - "noEmit": true, - "disableSourceOfProjectReferenceRedirect": true, - "declaration": false, - "declarationMap": false, - "emitDeclarationOnly": false, - "rootDir": "../.." - }, + "extends": "../../tsconfig.base.json", "include": ["src", "test"], - "exclude": [], - "references": [ - { - "path": "../../vendor/intx/db/tsconfig.src.json" - }, - { - "path": "../../vendor/intx/hub-api/tsconfig.src.json" - }, - { - "path": "../../vendor/intx/hub-sessions/tsconfig.src.json" - } - ] + "compilerOptions": { + "types": ["bun"], + "noEmit": true + } } diff --git a/packages/skills/tsconfig.src.json b/packages/skills/tsconfig.src.json deleted file mode 100644 index b58252957..000000000 --- a/packages/skills/tsconfig.src.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "include": ["src", "package.json", "src/**/*.json"], - "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"], - "compilerOptions": { - "types": ["bun"], - "composite": true, - "emitDeclarationOnly": true, - "outDir": "dist", - "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo" - }, - "references": [ - { - "path": "../../vendor/intx/db/tsconfig.src.json" - }, - { - "path": "../../vendor/intx/hub-api/tsconfig.src.json" - }, - { - "path": "../../vendor/intx/hub-sessions/tsconfig.src.json" - } - ] -} diff --git a/packages/webhook-triggers/package.json b/packages/webhook-triggers/package.json index bc1fe4620..aa0dd474e 100644 --- a/packages/webhook-triggers/package.json +++ b/packages/webhook-triggers/package.json @@ -23,6 +23,7 @@ "@intx/hub-sessions": "workspace:*", "@intx/log": "0.3.0", "@intx/types": "workspace:*", + "@workbench/hub-client": "workspace:*", "arktype": "catalog:", "drizzle-orm": "catalog:", "hono": "^4.11.9", diff --git a/packages/webhook-triggers/src/ingress-routes.ts b/packages/webhook-triggers/src/ingress-routes.ts index 86b859c8c..622255c2e 100644 --- a/packages/webhook-triggers/src/ingress-routes.ts +++ b/packages/webhook-triggers/src/ingress-routes.ts @@ -24,15 +24,15 @@ import { } from "./signature"; import type { WebhookTriggerRow } from "./schema"; import type { WebhookTriggerStore } from "./store"; +import { makeErrorEnvelope } from "@workbench/hub-client"; const log = getLogger(["webhook-triggers", "ingress"]); -const ErrorEnvelope = (code: string, message: string) => ({ - error: { code, message }, -}); - const unauthorizedResponse = () => - ErrorEnvelope("unauthorized", "invalid or missing signature"); + makeErrorEnvelope({ + code: "unauthorized", + userMessage: "invalid or missing signature", + }); export type CreateWebhookIngressRoutesDeps = { store: WebhookTriggerStore; @@ -104,7 +104,10 @@ export function createWebhookIngressRoutes( payload = rawBody === "" ? {} : JSON.parse(rawBody); } catch { return c.json( - ErrorEnvelope("bad_request", "payload is not valid JSON"), + makeErrorEnvelope({ + code: "bad_request", + userMessage: "payload is not valid JSON", + }), 400, ); } diff --git a/packages/webhook-triggers/src/management-routes.ts b/packages/webhook-triggers/src/management-routes.ts index 3c7a09f56..c1ff0f2a1 100644 --- a/packages/webhook-triggers/src/management-routes.ts +++ b/packages/webhook-triggers/src/management-routes.ts @@ -20,10 +20,7 @@ import { pgErrorCode, PG_UNIQUE_VIOLATION } from "@intx/db"; import { generateWebhookSecret } from "./signature"; import type { WebhookTriggerRow } from "./schema"; import type { WebhookTriggerStore } from "./store"; - -const ErrorEnvelope = (code: string, message: string) => ({ - error: { code, message }, -}); +import { makeErrorEnvelope } from "@workbench/hub-client"; /** * True for a Postgres unique-violation (`23505`) — the shape a duplicate @@ -91,7 +88,10 @@ export function createWebhookTriggerRoutes( const body = CreateTriggerBody(await c.req.json().catch(() => undefined)); if (body instanceof type.errors) { return c.json( - ErrorEnvelope("bad_request", `invalid trigger body: ${body.summary}`), + makeErrorEnvelope({ + code: "bad_request", + userMessage: `invalid trigger body: ${body.summary}`, + }), 400, ); } @@ -105,7 +105,13 @@ export function createWebhookTriggerRoutes( body.workflowDefinitionId, ); if (!owned) { - return c.json(ErrorEnvelope("not_found", "definition not found"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "definition not found", + }), + 404, + ); } } @@ -125,10 +131,11 @@ export function createWebhookTriggerRoutes( } catch (cause) { if (isUniqueViolation(cause)) { return c.json( - ErrorEnvelope( - "conflict", - "a trigger with this name already exists for this workflow definition", - ), + makeErrorEnvelope({ + code: "conflict", + userMessage: + "a trigger with this name already exists for this workflow definition", + }), 409, ); } @@ -151,7 +158,13 @@ export function createWebhookTriggerRoutes( const tenant = c.get("tenant"); const row = await deps.store.get(tenant.id, c.req.param("id")); if (row === undefined) { - return c.json(ErrorEnvelope("not_found", "trigger not found"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "trigger not found", + }), + 404, + ); } return c.json(publicView(row)); }, @@ -169,7 +182,13 @@ export function createWebhookTriggerRoutes( secret, ); if (row === undefined) { - return c.json(ErrorEnvelope("not_found", "trigger not found"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "trigger not found", + }), + 404, + ); } return c.json({ ...publicView(row), secret }); }, @@ -182,7 +201,10 @@ export function createWebhookTriggerRoutes( const body = SetEnabledBody(await c.req.json().catch(() => undefined)); if (body instanceof type.errors) { return c.json( - ErrorEnvelope("bad_request", `invalid enabled body: ${body.summary}`), + makeErrorEnvelope({ + code: "bad_request", + userMessage: `invalid enabled body: ${body.summary}`, + }), 400, ); } @@ -193,7 +215,13 @@ export function createWebhookTriggerRoutes( body.enabled, ); if (row === undefined) { - return c.json(ErrorEnvelope("not_found", "trigger not found"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "trigger not found", + }), + 404, + ); } return c.json(publicView(row)); }, @@ -206,7 +234,13 @@ export function createWebhookTriggerRoutes( const tenant = c.get("tenant"); const removed = await deps.store.remove(tenant.id, c.req.param("id")); if (!removed) { - return c.json(ErrorEnvelope("not_found", "trigger not found"), 404); + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "trigger not found", + }), + 404, + ); } return c.body(null, 204); }, diff --git a/packages/webhook-triggers/test/ingress-routes.test.ts b/packages/webhook-triggers/test/ingress-routes.test.ts index 7bed35274..0924845b1 100644 --- a/packages/webhook-triggers/test/ingress-routes.test.ts +++ b/packages/webhook-triggers/test/ingress-routes.test.ts @@ -78,9 +78,15 @@ describe("POST /:triggerId", () => { expect(trigger?.lastFiredAt).not.toBeNull(); }); - const expectedUnauthorizedBody = { - error: { code: "unauthorized", message: "invalid or missing signature" }, - }; + async function expectUnauthorizedEnvelope(response: Response): Promise { + const body = (await response.json()) as { + error: { code: string; userMessage: string; refId: string }; + }; + expect(body.error.code).toBe("unauthorized"); + expect(body.error.userMessage).toBe("invalid or missing signature"); + expect(typeof body.error.refId).toBe("string"); + expect(body.error.refId.length).toBeGreaterThan(0); + } test("rejects a missing signature header with the generic unauthorized response", async () => { const { app, store } = buildApp(); @@ -96,7 +102,7 @@ describe("POST /:triggerId", () => { }); expect(response.status).toBe(401); - expect(await response.json()).toEqual(expectedUnauthorizedBody); + await expectUnauthorizedEnvelope(response); }); test("rejects a missing timestamp header with the generic unauthorized response", async () => { @@ -114,7 +120,7 @@ describe("POST /:triggerId", () => { }); expect(response.status).toBe(401); - expect(await response.json()).toEqual(expectedUnauthorizedBody); + await expectUnauthorizedEnvelope(response); }); test("rejects a stale timestamp (a replayed delivery) with the generic unauthorized response", async () => { @@ -134,7 +140,7 @@ describe("POST /:triggerId", () => { }); expect(response.status).toBe(401); - expect(await response.json()).toEqual(expectedUnauthorizedBody); + await expectUnauthorizedEnvelope(response); }); test("rejects a signature computed with the wrong secret with the generic unauthorized response", async () => { @@ -158,7 +164,7 @@ describe("POST /:triggerId", () => { }); expect(response.status).toBe(401); - expect(await response.json()).toEqual(expectedUnauthorizedBody); + await expectUnauthorizedEnvelope(response); }); test("responds to an unknown trigger id with the same generic unauthorized response, not a 404", async () => { @@ -168,7 +174,7 @@ describe("POST /:triggerId", () => { body: "{}", }); expect(response.status).toBe(401); - expect(await response.json()).toEqual(expectedUnauthorizedBody); + await expectUnauthorizedEnvelope(response); }); test("responds to a disabled trigger with the same generic unauthorized response even with a valid signature", async () => { @@ -187,7 +193,7 @@ describe("POST /:triggerId", () => { }); expect(response.status).toBe(401); - expect(await response.json()).toEqual(expectedUnauthorizedBody); + await expectUnauthorizedEnvelope(response); }); test("400s on a validly signed but non-JSON body", async () => { diff --git a/scripts/checks/error-envelope.ts b/scripts/checks/error-envelope.ts new file mode 100644 index 000000000..aa72932a7 --- /dev/null +++ b/scripts/checks/error-envelope.ts @@ -0,0 +1,99 @@ +// check:error-envelope — a structural invariant: hub routes answer a +// failure with `@workbench/hub-client`'s `makeErrorEnvelope` (`code`, +// `userMessage`, `refId`). A locally-defined `{ error: { code, message } }` +// factory is a second envelope, and a second envelope is how `refId` +// support silently goes missing. This check greps for those factories — +// never for a comment or an arktype parser that happens to be named +// `ErrorEnvelope`. +// +// Documented exceptions live in ALLOWLIST below. Each entry is an +// explicit ruling that the named file may keep a helper because it +// already wraps the canonical `makeErrorEnvelope` (onboarding, +// workflow-catalog) or *is* the canonical helper (hub-client). +import { Glob } from "bun"; +import path from "node:path"; +import { + emptyReport, + reportAndExit, + rootFromArgs, + type CheckReport, +} from "./lib/repo"; + +const SCAN_DIRS = ["apps", "packages", "workflows"]; + +const ALLOWLIST = new Set([ + "packages/hub-client/src/error-envelope.ts", + "packages/onboarding/src/routes.ts", + "packages/workflow-catalog/src/connect-github-routes.ts", + "packages/workflow-catalog/src/template-block-routes.ts", +]); + +// Arrow: `const ErrorEnvelope = (code: string, message: string) => ({ +// error: { code, message }, +// });` +// Function: `function errorEnvelope(code: string, message: string) { +// return { error: { code, message } }; +// }` +const ARROW_FACTORY = + /(?:export\s+)?(?:const|let|var)\s+(ErrorEnvelope|errorEnvelope)\s*=\s*\(\s*code(?:\s*:\s*string)?\s*,\s*message(?:\s*:\s*string)?\s*\)\s*=>\s*\(?\s*\{\s*error:\s*\{\s*code\s*,\s*message\s*\}\s*,?\s*\}\s*\)?/s; +const FUNCTION_FACTORY = + /(?:export\s+)?function\s+(ErrorEnvelope|errorEnvelope)\s*\(\s*code(?:\s*:\s*string)?\s*,\s*message(?:\s*:\s*string)?\s*\)\s*\{\s*return\s*\{\s*error:\s*\{\s*code\s*,\s*message\s*\}\s*,?\s*\}\s*;?\s*\}/s; + +export async function scanFiles( + root: string, + dirs: readonly string[], +): Promise { + const files: string[] = []; + for (const dir of dirs) { + const glob = new Glob(`${dir}/**/*.{ts,tsx}`); + for await (const file of glob.scan({ cwd: root, dot: false })) { + if (file.includes("node_modules/")) continue; + if (file.includes("/dist/") || file.startsWith("dist/")) continue; + if (file.includes(".test.") || file.includes("/test/")) continue; + files.push(file); + } + } + return files; +} + +export function auditLocalErrorEnvelopeFactories( + files: readonly { relPath: string; contents: string }[], +): CheckReport { + const report = emptyReport(); + for (const { relPath, contents } of files) { + if (ALLOWLIST.has(relPath)) { + report.notes.push( + `${relPath}: allowlisted (canonical makeErrorEnvelope helper)`, + ); + continue; + } + const arrow = ARROW_FACTORY.test(contents); + const fn = FUNCTION_FACTORY.test(contents); + if (!arrow && !fn) continue; + report.violations.push( + `${relPath}: defines a local { error: { code, message } } factory. ` + + `Hub routes must use makeErrorEnvelope from @workbench/hub-client ` + + `so every failure carries code, userMessage, and refId.`, + ); + } + return report; +} + +async function main(): Promise { + const args = Bun.argv.slice(2); + const root = rootFromArgs(args); + const relPaths = await scanFiles(root, SCAN_DIRS); + const files = await Promise.all( + relPaths.map(async (relPath) => ({ + relPath, + contents: await Bun.file(path.join(root, relPath)).text(), + })), + ); + const report = auditLocalErrorEnvelopeFactories(files); + report.notes.push( + `scanned ${files.length} file(s) under ${SCAN_DIRS.join(", ")}`, + ); + reportAndExit("check:error-envelope", report); +} + +if (import.meta.main) await main(); diff --git a/scripts/checks/report-error-baseline.txt b/scripts/checks/report-error-baseline.txt index 157efd028..f0562c5ba 100644 --- a/scripts/checks/report-error-baseline.txt +++ b/scripts/checks/report-error-baseline.txt @@ -112,7 +112,6 @@ packages/agent-directory-tools/src/tool.ts 1 // The agent was genuinely created packages/agent-directory-tools/src/tool.ts 1 if (err instanceof CreateAgentDefinitionError) { packages/agent-directory-tools/src/tool.ts 1 return errorResult(call.id, err); packages/agent-directory/src/definition-history.ts 1 return null; -packages/agent-directory/src/routes.ts 1 return c.json( packages/agent-lifecycle/src/index.ts 1 log.error`lifecycle sweep failed to undeploy ${address}: ${ packages/approvals/src/allowance.ts 1 deps.log( packages/approvals/src/allowance.ts 1 return { outcome: "park", reason: "classification_failed" }; @@ -140,7 +139,6 @@ packages/chat/src/platform-adapter.ts 1 wakeLogger.error`inference-source reconc packages/chat/src/platform-adapter.ts 1 wakeLogger.error`pinned-tool-package reconcile for ${live.binding.roomAddress} (run ${live.run.id}) failed, leaving it as-is: ${ packages/chat/src/platform-adapter.ts 1 wakeLogger.error`relaunch sweep: could not relaunch ${live.binding.roomAddress} (run ${live.run.id} is ${live.run.status}): ${ packages/chat/src/routes.ts 1 log.error( -packages/chat/src/routes.ts 1 return c.json(ErrorEnvelope("not_found", "blob not found"), 404); packages/chat/src/routes.ts 2 log.error( packages/chat/src/turn-context.ts 1 contextLog.warn`failed to assemble turn context on workbench ${input.workbenchId}: ${ packages/chat/src/workbench-service.ts 1 fanoutLog.error( diff --git a/scripts/checks/test/error-envelope.test.ts b/scripts/checks/test/error-envelope.test.ts new file mode 100644 index 000000000..30961ac1a --- /dev/null +++ b/scripts/checks/test/error-envelope.test.ts @@ -0,0 +1,117 @@ +import { expect, test } from "bun:test"; +import { auditLocalErrorEnvelopeFactories } from "../error-envelope"; + +test("clean files pass with no violations", () => { + const report = auditLocalErrorEnvelopeFactories([ + { + relPath: "packages/onboarding/src/provision.ts", + contents: "export const x = 1;", + }, + ]); + expect(report.violations).toEqual([]); +}); + +test("an arrow ErrorEnvelope factory is a violation naming the file", () => { + const report = auditLocalErrorEnvelopeFactories([ + { + relPath: "packages/insights/src/routes.ts", + contents: `const ErrorEnvelope = (code: string, message: string) => ({ + error: { code, message }, +});`, + }, + ]); + expect(report.violations).toHaveLength(1); + expect(report.violations[0]).toContain("packages/insights/src/routes.ts"); + expect(report.violations[0]).toContain("makeErrorEnvelope"); +}); + +test("a function errorEnvelope factory is a violation naming the file", () => { + const report = auditLocalErrorEnvelopeFactories([ + { + relPath: "packages/skills/src/routes.ts", + contents: `function errorEnvelope(code: string, message: string) { + return { error: { code, message } }; +}`, + }, + ]); + expect(report.violations).toHaveLength(1); + expect(report.violations[0]).toContain("packages/skills/src/routes.ts"); +}); + +test("an arktype parser named ErrorEnvelope is not a factory", () => { + const report = auditLocalErrorEnvelopeFactories([ + { + relPath: "packages/settings-ui/src/api-request.ts", + contents: `const ErrorEnvelope = type({ + error: { message: "string", "code?": "string" }, +});`, + }, + ]); + expect(report.violations).toEqual([]); +}); + +test("a comment that mentions the old envelope is not a violation", () => { + const report = auditLocalErrorEnvelopeFactories([ + { + relPath: "packages/onboarding/src/provision.ts", + contents: + "// the same `{ error: { code, message } }` envelope every other hub route uses", + }, + ]); + expect(report.violations).toEqual([]); +}); + +test("makeErrorEnvelope usage is not a violation", () => { + const report = auditLocalErrorEnvelopeFactories([ + { + relPath: "packages/sidecar-placement/src/routes.ts", + contents: `import { makeErrorEnvelope } from "@workbench/hub-client"; +return c.json(makeErrorEnvelope({ code: "bad_request", userMessage: "no" }), 400);`, + }, + ]); + expect(report.violations).toEqual([]); +}); + +test("reports every violation across multiple files, not just the first", () => { + const report = auditLocalErrorEnvelopeFactories([ + { + relPath: "a.ts", + contents: `const ErrorEnvelope = (code: string, message: string) => ({ + error: { code, message }, +});`, + }, + { + relPath: "b.ts", + contents: `function errorEnvelope(code: string, message: string) { + return { error: { code, message } }; +}`, + }, + { relPath: "c.ts", contents: "clean" }, + ]); + expect(report.violations).toHaveLength(2); +}); + +test("allowlisted files that wrap makeErrorEnvelope pass", () => { + const report = auditLocalErrorEnvelopeFactories([ + { + relPath: "packages/onboarding/src/routes.ts", + contents: `function reportOnboardingError() { + return makeErrorEnvelope({ code: "x", userMessage: "y", refId: "z" }); +}`, + }, + { + relPath: "packages/hub-client/src/error-envelope.ts", + contents: `export function makeErrorEnvelope(args: { + code: string; + userMessage: string; + refId?: string; +}) { + return { error: { code: args.code, userMessage: args.userMessage, refId: "x" } }; +}`, + }, + ]); + expect(report.violations).toEqual([]); + expect( + report.notes.some((n) => n.includes("packages/onboarding/src/routes.ts")), + ).toBe(true); +}); diff --git a/tsconfig.build.json b/tsconfig.build.json index 4149945da..cb98dcaa1 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -16,9 +16,6 @@ { "path": "./packages/agent-runtime/tsconfig.src.json" }, - { - "path": "./packages/agent-workflow-authoring/tsconfig.src.json" - }, { "path": "./packages/api-query/tsconfig.src.json" }, @@ -28,9 +25,6 @@ { "path": "./packages/artifact-ui/tsconfig.src.json" }, - { - "path": "./packages/bench/tsconfig.src.json" - }, { "path": "./packages/bench-ui/tsconfig.src.json" }, @@ -52,9 +46,6 @@ { "path": "./packages/command-palette/tsconfig.src.json" }, - { - "path": "./packages/commands/tsconfig.src.json" - }, { "path": "./packages/context-menu/tsconfig.src.json" }, @@ -88,9 +79,6 @@ { "path": "./packages/inbox/tsconfig.src.json" }, - { - "path": "./packages/inference-catalog/tsconfig.src.json" - }, { "path": "./packages/interaction-tools/tsconfig.src.json" }, @@ -121,21 +109,12 @@ { "path": "./packages/ollama-adapter/tsconfig.src.json" }, - { - "path": "./packages/preferences/tsconfig.src.json" - }, - { - "path": "./packages/presence/tsconfig.src.json" - }, { "path": "./packages/reddit-tools/tsconfig.src.json" }, { "path": "./packages/routines-tools/tsconfig.src.json" }, - { - "path": "./packages/run-key-history/tsconfig.src.json" - }, { "path": "./packages/sandbox-sidecar/tsconfig.src.json" }, @@ -145,12 +124,6 @@ { "path": "./packages/shell-layout/tsconfig.src.json" }, - { - "path": "./packages/sidecar-placement/tsconfig.src.json" - }, - { - "path": "./packages/skills/tsconfig.src.json" - }, { "path": "./packages/skills-tools/tsconfig.src.json" }, diff --git a/workflows/assistant/src/index.ts b/workflows/assistant/src/index.ts index d9a6a36c4..60eb03649 100644 --- a/workflows/assistant/src/index.ts +++ b/workflows/assistant/src/index.ts @@ -45,11 +45,11 @@ export const ASSISTANT_STEP_ID = "assistant"; */ export const ASSISTANT_TOOL_PACKAGE_PINS: readonly ToolPackagePin[] = [ { name: "@corbits/memory-tools", version: "0.0.4" }, - { name: "@corbits/capability-tools", version: "0.0.3" }, - { name: "@corbits/routines-tools", version: "0.0.5" }, - { name: "@corbits/agent-directory-tools", version: "0.0.5" }, + { name: "@corbits/capability-tools", version: "0.0.4" }, + { name: "@corbits/routines-tools", version: "0.0.6" }, + { name: "@corbits/agent-directory-tools", version: "0.0.6" }, { name: "@corbits/connections-tools", version: "0.0.6" }, - { name: "@corbits/catalog-tools", version: "0.0.1" }, + { name: "@corbits/catalog-tools", version: "0.0.2" }, { name: "@corbits/skills-tools", version: "0.0.6" }, { name: "@corbits/mcp-tools", version: "0.0.10" }, { name: "@corbits/interaction-tools", version: "0.0.4" },