diff --git a/apps/hub/src/templates/block-workflows.ts b/apps/hub/src/catalog-blocks/block-workflows.ts similarity index 97% rename from apps/hub/src/templates/block-workflows.ts rename to apps/hub/src/catalog-blocks/block-workflows.ts index 743437c255..e72625f7ac 100644 --- a/apps/hub/src/templates/block-workflows.ts +++ b/apps/hub/src/catalog-blocks/block-workflows.ts @@ -17,7 +17,7 @@ // // Server-only, on purpose: each `buildJson` closure pulls in its // workflow package (e.g. `@corbits/granola-call-workflow`) and with it -// `@intx/agent`/`@intx/workflow`. Only `./template-block-routes.ts` +// `@intx/agent`/`@intx/workflow`. Only `./catalog-block-routes.ts` // (mounted in `apps/hub`) imports this; it is deliberately not // re-exported from the package root. import { deployableCatalogBlock } from "./catalog-blocks"; diff --git a/apps/hub/src/templates/template-block-routes.test.ts b/apps/hub/src/catalog-blocks/catalog-block-routes.test.ts similarity index 94% rename from apps/hub/src/templates/template-block-routes.test.ts rename to apps/hub/src/catalog-blocks/catalog-block-routes.test.ts index 3eb9a160c7..4f5127a22b 100644 --- a/apps/hub/src/templates/template-block-routes.test.ts +++ b/apps/hub/src/catalog-blocks/catalog-block-routes.test.ts @@ -1,4 +1,4 @@ -// Exercises `createTemplateBlockRoutes`' HTTP surface through the real +// Exercises `createCatalogBlockRoutes`' HTTP surface through the real // `buildBlockWorkflowSource` builder, mounted the same way // `./connect-github-routes.test.ts` mounts its routes: a bare `Hono` // with a tenant-injecting middleware, every port a plain fake. @@ -20,9 +20,9 @@ import type { RequireGrant, TenantEnv } from "@intx/hub-api"; import { makeErrorEnvelope } from "@corbits/error-sink"; import { - createTemplateBlockRoutes, - type TemplateBlockRoutesDeps, -} from "./template-block-routes"; + createCatalogBlockRoutes, + type CatalogBlockRoutesDeps, +} from "./catalog-block-routes"; const TENANT = { id: "tnt_1", @@ -62,12 +62,12 @@ function mountAs(routes: Hono): Hono { } type DeployedSource = Parameters< - TemplateBlockRoutesDeps["deployWorkflowSource"] + CatalogBlockRoutesDeps["deployWorkflowSource"] >[0]; -function buildApp(overrides: Partial = {}) { +function buildApp(overrides: Partial = {}) { const deployed: DeployedSource[] = []; - const deps: TemplateBlockRoutesDeps = { + const deps: CatalogBlockRoutesDeps = { requireGrant: allowAll, log: () => {}, inferencePreferences: async () => [ @@ -79,7 +79,7 @@ function buildApp(overrides: Partial = {}) { }, ...overrides, }; - return { app: mountAs(createTemplateBlockRoutes(deps)), deployed }; + return { app: mountAs(createCatalogBlockRoutes(deps)), deployed }; } describe("POST /:assetName/deploy", () => { diff --git a/apps/hub/src/templates/template-block-routes.ts b/apps/hub/src/catalog-blocks/catalog-block-routes.ts similarity index 69% rename from apps/hub/src/templates/template-block-routes.ts rename to apps/hub/src/catalog-blocks/catalog-block-routes.ts index ca32c3d726..42f2837536 100644 --- a/apps/hub/src/templates/template-block-routes.ts +++ b/apps/hub/src/catalog-blocks/catalog-block-routes.ts @@ -1,12 +1,14 @@ -// The instantiate path's block-workflow deploy surface (CL-6405): -// `instantiateWorkbenchTemplate`'s `deployBlockWorkflow` port binds to -// `POST /:assetName/deploy` here, the way its `createParticipantAgent` -// port binds to `POST /agent-definitions`. Mounted per-tenant inside -// the platform's native tenant middleware, mirroring -// `./connect-github-routes.ts`: every side effect a host needs — the -// tenant's default inference preferences, and the actual source-form -// asset write + `workflow_definition` projection — arrives as an -// injected port, so `apps/hub` is the only place drizzle and the +// The on-demand catalog-workflow deploy surface (CL-6405, generalized by +// CL-7073 to any catalog entry — CL-8156 dropped the "template" naming +// once workbench template picking was deleted): `POST /:assetName/deploy` +// deploys a `workflows/` package's block as a real `workflow`-kind +// asset on the requesting tenant, the same surface the "Available" +// catalog-workflows section (`apps/web`'s routines page) drives. +// Mounted per-tenant inside the platform's native tenant middleware, +// mirroring `./connect-github-routes.ts`: every side effect a host needs +// — the tenant's default inference preferences, and the actual +// source-form asset write + `workflow_definition` projection — arrives +// as an injected port, so `apps/hub` is the only place drizzle and the // `AssetService` are touched and this stays testable with plain fakes. import { Hono } from "hono"; import type { RequireGrant, TenantEnv } from "@intx/hub-api"; @@ -18,9 +20,9 @@ import { } from "./block-workflows"; const DEPLOY_FAILED_MESSAGE = - "Couldn't set up this template's workflow. Try again in a moment."; + "Couldn't set up this workflow. Try again in a moment."; -export type TemplateBlockRoutesDeps = { +export type CatalogBlockRoutesDeps = { requireGrant: RequireGrant; /** Where a failure's real cause goes — the same CL-6360 idiom * `./connect-github-routes.ts` documents: the client sees one honest @@ -48,8 +50,8 @@ export type TemplateBlockRoutesDeps = { }): Promise<{ readonly id: string; readonly created: boolean }>; }; -export function createTemplateBlockRoutes( - deps: TemplateBlockRoutesDeps, +export function createCatalogBlockRoutes( + deps: CatalogBlockRoutesDeps, ): Hono { const app = new Hono(); @@ -69,7 +71,7 @@ export function createTemplateBlockRoutes( return c.json( makeErrorEnvelope({ code: "not_found", - userMessage: `"${assetName}" isn't a deployable template workflow.`, + userMessage: `"${assetName}" isn't a deployable catalog workflow.`, }), 404, ); @@ -88,9 +90,12 @@ export function createTemplateBlockRoutes( result.created ? 201 : 200, ); } catch (cause) { + // report-error-ignore: the client sees one honest userMessage + // below; the real cause goes to deps.log, the same CL-6360 idiom + // `./connect-github-routes.ts` documents. const message = cause instanceof Error ? cause.message : String(cause); deps.log( - `template-blocks: deploying "${assetName}" failed for tenant ${tenant.id}: ${message}`, + `catalog-blocks: deploying "${assetName}" failed for tenant ${tenant.id}: ${message}`, ); return c.json( makeErrorEnvelope({ diff --git a/apps/hub/src/templates/catalog-blocks.test.ts b/apps/hub/src/catalog-blocks/catalog-blocks.test.ts similarity index 97% rename from apps/hub/src/templates/catalog-blocks.test.ts rename to apps/hub/src/catalog-blocks/catalog-blocks.test.ts index 8df70a615d..b98064333b 100644 --- a/apps/hub/src/templates/catalog-blocks.test.ts +++ b/apps/hub/src/catalog-blocks/catalog-blocks.test.ts @@ -1,5 +1,5 @@ // Pins the hub's native catalog-block adapter (`./catalog-blocks`): the -// deployable set served by `POST /template-blocks/:assetName/deploy`, +// deployable set served by `POST /catalog-blocks/:assetName/deploy`, // rendered from the `workflows/*` builder packages against // `@corbits/workflows` catalog metadata — with zero import from // `@workbench/onboarding`. diff --git a/apps/hub/src/templates/catalog-blocks.ts b/apps/hub/src/catalog-blocks/catalog-blocks.ts similarity index 93% rename from apps/hub/src/templates/catalog-blocks.ts rename to apps/hub/src/catalog-blocks/catalog-blocks.ts index 19fba4c2f9..1aca86bdc8 100644 --- a/apps/hub/src/templates/catalog-blocks.ts +++ b/apps/hub/src/catalog-blocks/catalog-blocks.ts @@ -1,5 +1,5 @@ // The hub's native catalog-block adapter (CL-8113, hub-zero T2): the -// deployable set `POST /template-blocks/:assetName/deploy` serves, +// deployable set `POST /catalog-blocks/:assetName/deploy` serves, // composed directly from the `workflows/*` builder packages against // `@corbits/workflows` catalog metadata — the same composition // `@workbench/onboarding`'s `CATALOG_WORKFLOWS` table wraps, with zero @@ -9,18 +9,16 @@ // same as any name outside the catalog entirely. // // Display names come from the native `WORKFLOW_CATALOG` entry -// (`@corbits/workflows`, which the legacy template package re-exports its -// own catalog mirror from), so the labels served here -// and the catalog's can never drift apart silently. Turn timeouts match -// the seed table's per-workflow budgets one-for-one: a research turn -// fans out and writes long-form, a call-transcript pass runs past the -// shortest catalog steps, everything else runs the conversational -// default. +// (`@corbits/workflows`), so the labels served here and the catalog's +// can never drift apart silently. Turn timeouts match the seed table's +// per-workflow budgets one-for-one: a research turn fans out and writes +// long-form, a call-transcript pass runs past the shortest catalog +// steps, everything else runs the conversational default. // // Server-only, on purpose: each `buildJson` closure pulls in its // workflow package (e.g. `@corbits/granola-call-workflow`) and with it // `@intx/agent`/`@intx/workflow`. Only `./block-workflows.ts` -// (mounted in `apps/hub` through `./template-block-routes.ts`) imports +// (mounted in `apps/hub` through `./catalog-block-routes.ts`) imports // this; it is deliberately not re-exported from the package root. import { workflowCatalogEntry } from "@corbits/workflows"; import { @@ -103,7 +101,7 @@ export type CatalogBlock = { /** * Renders the definition's JSON given the tenant's mail domain and the * ordered provider/model preferences to deploy against. Takes the bare - * preference list so this same function serves the template-block + * preference list so this same function serves the catalog-block * route's in-process deploy path, which only ever has the tenant's * real inference preferences on hand. */ @@ -118,7 +116,7 @@ function catalogDisplayName(assetName: string): string { } /** - * Workflows a tenant can deploy on demand through the template-block + * Workflows a tenant can deploy on demand through the catalog-block * route: every native catalog entry with a source package under * `workflows/`, minus `assistant` (seeded already) and * `heartbeat` (test-only). Nothing here reaches a bench until @@ -293,7 +291,7 @@ export const CATALOG_BLOCK_ASSET_NAMES: readonly string[] = CATALOG_BLOCKS.map( ); /** - * The deployable-through-the-template-block-route entry for one asset + * The deployable-through-the-catalog-block-route entry for one asset * name, or `undefined` if none exists. `CATALOG_BLOCKS` is the one * source of truth for "has a source package under `workflows/` * and can be deployed on demand". diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index 41a6be3cce..c47c5fa904 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -78,9 +78,6 @@ import { isWorkbenchHostDefinitionName, listConnectedProviders, listDefaultInferencePreferences, - localPartOf, - parseParticipants, - postRoomMessage, startWorkflowCommand, settleConnectedService, createCryptoProviderCache, @@ -150,7 +147,6 @@ import { createWebhookTriggerRoutes, launchWebhookTrigger, } from "@corbits/webhook-triggers"; -import { createTemplateBlockRoutes } from "./templates/template-block-routes"; import { createWorkflowDetailRoute, createScheduledWorkflowRoutes, @@ -225,6 +221,7 @@ import { DEFAULT_RETURN_PATH_ALLOWLIST, listMcpServerConnections, } from "@corbits/connections"; +import { createCatalogBlockRoutes } from "./catalog-blocks/catalog-block-routes"; import type { ServiceConnectedHook } from "@corbits/connections"; import { CONNECTOR_REGISTRY, MCP_PRESETS } from "./native-connector-registry"; import { @@ -2128,16 +2125,16 @@ export async function createHub(config: HubConfig) { providerHealth: providerHealthStore, }), ); - // Template block workflows (CL-6405, cut over to native deploy in - // CL-7364): the instantiate path's `deployBlockWorkflow` port lands - // here — the same source-form materialization pattern (asset + - // `@corbits/workflows`'s `./source` tree) applied to a template's referenced - // block definition (`code-review` today), now deployed through the - // same `workflowDeployer` the agent-authored deploy path above uses - // rather than a hub-local inert freeze. + // On-demand catalog workflow deploy (CL-6405, generalized by CL-7073): + // the "Available" catalog-workflows section (`apps/web`'s routines + // page) drives this to add any `workflows/*` package on demand, the + // same source-form materialization pattern (asset + + // `@corbits/workflows`'s `./source` tree) applied through the same + // `workflowDeployer` the agent-authored deploy path above uses rather + // than a hub-local inert freeze. app.route( - `${TENANT_PREFIX}/template-blocks`, - createTemplateBlockRoutes({ + `${TENANT_PREFIX}/catalog-blocks`, + createCatalogBlockRoutes({ requireGrant: createRequireGrant({ grantStore: chatGrantStore, conditionRegistry: chatConditionRegistry, @@ -2196,16 +2193,16 @@ export async function createHub(config: HubConfig) { principal: { kind: "hub" }, tree: { files: renderWorkflowSourceTree({ - packageName: `@workbench-template/${assetName}`, + packageName: `@workbench-catalog-block/${assetName}`, workflowJson, }), - message: `Deploy template block ${assetName}`, + message: `Deploy catalog block ${assetName}`, }, }); - // Native deploy, not a hub-local inert freeze (CL-7364): the same + // Native deploy, not a hub-local inert freeze: the same // `workflowDeployer` the agent-authored deploy path above drives, - // so a template block's definition goes through the real + // so a catalog block's definition goes through the real // bundle → sidecar probe → capability walk → gate → freeze // pipeline instead of a hub-side shortcut. const result = await workflowDeployer.deploy({ diff --git a/apps/web/package.json b/apps/web/package.json index 805d5f1864..0869ad3c47 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -46,7 +46,6 @@ "@radix-ui/react-slot": "^1.2.3", "@tanstack/react-query": "catalog:", "@corbits/connections": "workspace:*", - "@workbench/templates": "workspace:*", "arktype": "catalog:", "react": "^19.2.0", "react-dom": "^19.2.0", diff --git a/apps/web/src/client-bootstrap.ts b/apps/web/src/client-bootstrap.ts index b720863b85..bd575cfdb6 100644 --- a/apps/web/src/client-bootstrap.ts +++ b/apps/web/src/client-bootstrap.ts @@ -11,7 +11,7 @@ // config — this module never guesses them. When stock Interchange lacks a // capability the result carries the typed gap instead of throwing. -import { WORKFLOW_CATALOG } from "@workbench/templates"; +import { WORKFLOW_CATALOG } from "@corbits/workflows/catalog"; import type { ClientLogger } from "@corbits/client-log"; diff --git a/apps/web/src/connect-service-actions.ts b/apps/web/src/connect-service-actions.ts index d4f1369847..27edc03141 100644 --- a/apps/web/src/connect-service-actions.ts +++ b/apps/web/src/connect-service-actions.ts @@ -31,11 +31,8 @@ import { listMcpPresets, McpServersApiError, } from "@corbits/plugins-ui"; -import { mcpPresetBySlug } from "@corbits/connections/mcp-presets"; -import { - CONNECTOR_REGISTRY, - MCP_PRESETS, -} from "@workbench/templates/connectors"; +import { mcpPresetBySlug, MCP_PRESETS } from "@corbits/connections/mcp-presets"; +import { CONNECTOR_REGISTRY } from "@corbits/connections/registry"; function bareConnectorId(connectorId: string): string { return connectorId.startsWith("mcp:") diff --git a/apps/web/src/instant-agent-create.test.ts b/apps/web/src/instant-agent-create.test.ts index 0b8918d65f..afb690f471 100644 --- a/apps/web/src/instant-agent-create.test.ts +++ b/apps/web/src/instant-agent-create.test.ts @@ -1,14 +1,11 @@ -import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import { afterEach, describe, expect, test } from "bun:test"; import { QueryClient } from "@tanstack/react-query"; -import { - CODE_REVIEW_TEMPLATE, - serializeWorkbenchDefinition, -} from "@workbench/templates"; import { - createWorkbenchFromTemplate, + createWorkbench, NEW_WORKBENCH_TITLE, WorkbenchPostCreateError, + WorkbenchPreconditionError, } from "./instant-agent-create"; function newQueryClient(): QueryClient { @@ -17,7 +14,7 @@ function newQueryClient(): QueryClient { }); } -describe("createWorkbenchFromTemplate", () => { +describe("createWorkbench", () => { const realFetch = globalThis.fetch; afterEach(() => { @@ -54,11 +51,7 @@ describe("createWorkbenchFromTemplate", () => { skills: [] as readonly string[], }; - // The picker's "Create workbench" is clickable more than once per - // session (a second visit, a second row) — each click must mint its - // own, genuinely distinct workbench, never reopen or alias the last - // one it created. - test("picking the same row twice in a row mints two distinct workbenches, not one reused", async () => { + test("mints two distinct workbenches on two calls, never reusing one", async () => { let nextId = 0; const navigated: string[] = []; const calls = stubFetch((path) => { @@ -78,15 +71,13 @@ describe("createWorkbenchFromTemplate", () => { throw new Error(`unexpected fetch: ${path}`); }); - await createWorkbenchFromTemplate( + await createWorkbench( "tnt_1", - "blank", (to) => navigated.push(to), newQueryClient(), ); - await createWorkbenchFromTemplate( + await createWorkbench( "tnt_1", - "blank", (to) => navigated.push(to), newQueryClient(), ); @@ -97,354 +88,33 @@ describe("createWorkbenchFromTemplate", () => { expect(createCalls).toHaveLength(2); expect(navigated).toEqual(["/w/chan-1", "/w/chan-2"]); expect(navigated[0]).not.toBe(navigated[1]); - - // Blank ("Just start talking") has no template to name the bench - // after, so it keeps the generic title rather than something - // invented. const body = JSON.parse(String(createCalls[0]?.init?.body)); expect(body.name).toBe(NEW_WORKBENCH_TITLE); - }); - - // CL-6982: the + / picker create path mints a `kind: "workbench"` - // channel, never a Myra DM clone (`kind: "chat"` + `definitionId`). - test("blank create POSTs kind=workbench with no definitionId and invites nobody (CL-6982)", async () => { - const calls = stubFetch((path) => { - if (path.includes("/workflows/definitions")) { - return json({ data: [assistantDefinitionWire], nextCursor: null }); - } - if (path.endsWith("/chat/workbenches")) { - return json({ - id: "chan-1", - title: NEW_WORKBENCH_TITLE, - kind: "workbench", - pinned: true, - participants: [], - }); - } - throw new Error(`unexpected fetch: ${path}`); - }); - - await createWorkbenchFromTemplate( - "tnt_1", - "blank", - () => undefined, - newQueryClient(), - ); - - const createCall = calls.find((call) => - call.path.endsWith("/chat/workbenches"), - ); - const body = JSON.parse(String(createCall?.init?.body)) as { - readonly kind?: string; - readonly definitionId?: string; - readonly reuseExisting?: boolean; - readonly name?: string; - }; expect(body.kind).toBe("workbench"); expect(body.definitionId).toBeUndefined(); - expect(body.reuseExisting).toBeUndefined(); - expect(body.name).toBe(NEW_WORKBENCH_TITLE); - expect(calls.some((call) => call.path.includes("/invite"))).toBe(false); - }); - - test("a blank intent carries selected agents in its pre-invite message", async () => { - const calls = stubFetch((path) => { - if (path.includes("/workflows/definitions")) { - return json({ data: [assistantDefinitionWire], nextCursor: null }); - } - if (path.endsWith("/chat/workbenches")) { - return json({ - id: "chan-1", - title: NEW_WORKBENCH_TITLE, - kind: "workbench", - pinned: false, - participants: [], - }); - } - if (path.endsWith("/chat/workbenches/chan-1/messages")) { - return json({ id: "msg-1", createdAt: "2026-01-01T00:00:00.000Z" }); - } - if (path.endsWith("/chat/workbenches/chan-1/settings")) { - return json({ - id: "chan-1", - title: "Research our next partner", - kind: "workbench", - pinned: false, - participants: [], - settings: {}, - contextWindow: { value: 0, source: "inherit" }, - }); - } - throw new Error(`unexpected fetch: ${path}`); - }); - - await createWorkbenchFromTemplate( - "tnt_1", - "blank", - () => undefined, - newQueryClient(), - "Research our next partner", - ["def-scout", "def-scout", "def-quill"], - ); - - const createCallIndex = calls.findIndex((call) => - call.path.endsWith("/chat/workbenches"), - ); - const messageCallIndex = calls.findIndex((call) => - call.path.endsWith("/chat/workbenches/chan-1/messages"), - ); - const messageCall = calls[messageCallIndex]; - expect(createCallIndex).toBeLessThan(messageCallIndex); - expect(JSON.parse(String(messageCall?.init?.body))).toEqual({ - parts: [{ kind: "text", text: "Research our next partner" }], - invite: [ - { kind: "agent", definitionId: "def-scout" }, - { kind: "agent", definitionId: "def-quill" }, - ], - }); - expect(calls.some((call) => call.path.endsWith("/invite"))).toBe(false); - }); - - test("opens the new room while its sidebar refresh is still in flight", async () => { - const navigated: string[] = []; - let finishRefresh: (() => void) | undefined; - const refresh = new Promise((resolve) => { - finishRefresh = resolve; - }); - const queryClient = newQueryClient(); - const invalidate = spyOn( - queryClient, - "invalidateQueries", - ).mockImplementation(() => refresh); - stubFetch((path) => { - if (path.includes("/workflows/definitions")) { - return json({ data: [assistantDefinitionWire], nextCursor: null }); - } - if (path.endsWith("/chat/workbenches")) { - return json({ - id: "chan-ready-now", - title: NEW_WORKBENCH_TITLE, - kind: "workbench", - pinned: false, - participants: [], - }); - } - if (path.endsWith("/chat/workbenches/chan-ready-now/messages")) { - return json({ id: "msg-1", createdAt: "2026-01-01T00:00:00.000Z" }); - } - if (path.endsWith("/chat/workbenches/chan-ready-now/settings")) { - return json({ - id: "chan-ready-now", - title: "Ship the release", - kind: "workbench", - pinned: false, - participants: [], - settings: {}, - contextWindow: { value: 0, source: "inherit" }, - }); - } - throw new Error(`unexpected fetch: ${path}`); - }); - - try { - const completed = await Promise.race([ - createWorkbenchFromTemplate( - "tnt_1", - "blank", - (to) => navigated.push(to), - queryClient, - "Ship the release", - ["def-assistant"], - ).then(() => "completed"), - new Promise<"timed out">((resolve) => { - setTimeout(() => resolve("timed out"), 50); - }), - ]); - - expect(completed).toBe("completed"); - expect(navigated).toEqual(["/w/chan-ready-now"]); - } finally { - finishRefresh?.(); - invalidate.mockRestore(); - } }); - test("an opening-message failure leaves a minted blank workbench recoverable", async () => { - const navigated: string[] = []; + test("fails with WorkbenchPreconditionError when Myra isn't deployed yet", async () => { stubFetch((path) => { if (path.includes("/workflows/definitions")) { - return json({ data: [assistantDefinitionWire], nextCursor: null }); - } - if (path.endsWith("/chat/workbenches")) { - return json({ - id: "chan-recoverable", - title: NEW_WORKBENCH_TITLE, - kind: "workbench", - pinned: false, - participants: [], - }); - } - if (path.endsWith("/chat/workbenches/chan-recoverable/messages")) { - return json({ error: "agent launch failed" }, 409); + return json({ data: [], nextCursor: null }); } throw new Error(`unexpected fetch: ${path}`); }); let cause: unknown; try { - await createWorkbenchFromTemplate( - "tnt_1", - "blank", - (to) => navigated.push(to), - newQueryClient(), - "Research our next partner", - ["def-scout"], - ); + await createWorkbench("tnt_1", () => undefined, newQueryClient()); } catch (error) { cause = error; } - - expect(cause).toBeInstanceOf(WorkbenchPostCreateError); - if (!(cause instanceof WorkbenchPostCreateError)) { - throw new Error("expected a recoverable post-create error"); - } - expect(cause.workbenchId).toBe("chan-recoverable"); - expect(cause.stage).toBe("opening-message"); - expect(navigated).toEqual([]); - }); - - test("picking the code-review definition names the bench after it and invites exactly its three reviewers", async () => { - const navigated: string[] = []; - let nextReviewerId = 0; - const calls = stubFetch((path) => { - if (path.includes("/workflows/definitions")) { - return json({ data: [assistantDefinitionWire], nextCursor: null }); - } - if (path.endsWith("/library/templates/code-review")) { - return json({ - id: "code-review", - content: serializeWorkbenchDefinition(CODE_REVIEW_TEMPLATE), - }); - } - if (path.endsWith("/chat/workbenches")) { - return json({ - id: "chan-1", - title: "Code review", - kind: "workbench", - pinned: false, - participants: [], - }); - } - if (path.endsWith("/template-blocks/code-review/deploy")) { - return json({ id: "def-code-review-block", created: true }); - } - if (path.endsWith("/agent-definitions")) { - nextReviewerId += 1; - return json({ - ...assistantDefinitionWire, - id: `def-reviewer-${nextReviewerId}`, - }); - } - if (path.endsWith("/chat/workbenches/chan-1/onboarding")) { - return json({ id: "msg-onboarding" }, 201); - } - if (path.endsWith("/chat/workbenches/chan-1/invite")) { - return json({ address: "agent:invited", definitionId: "def-reviewer" }); - } - if (path.endsWith("/chat/workbenches/chan-1/settings")) { - return json({ - id: "chan-1", - title: "Code review", - kind: "workbench", - pinned: false, - participants: [], - settings: {}, - contextWindow: { value: 0, source: "inherit" }, - }); - } - throw new Error(`unexpected fetch: ${path}`); - }); - - const queryClient = newQueryClient(); - // Seed the cache the way a person browsing before creating this - // workbench would have: a `workbenches` list fetched before any of - // the reviewer roster below was invited. - const staleQueryKey = ["tenant", "tnt_1", "workbenches", "chat"] as const; - queryClient.setQueryData(staleQueryKey, { items: [] }); - - await createWorkbenchFromTemplate( - "tnt_1", - "code-review", - (to) => navigated.push(to), - queryClient, - ); - - const createCall = calls.find((call) => - call.path.endsWith("/chat/workbenches"), - ); - const createBody = JSON.parse(String(createCall?.init?.body)); - expect(createBody.name).toBe(CODE_REVIEW_TEMPLATE.title); - - const createAgentCalls = calls.filter((call) => - call.path.endsWith("/agent-definitions"), - ); - expect(createAgentCalls).toHaveLength(CODE_REVIEW_TEMPLATE.agents.length); - - const inviteCalls = calls.filter((call) => - call.path.endsWith("/chat/workbenches/chan-1/invite"), - ); - const invitedIds = inviteCalls.map( - (call) => JSON.parse(String(call.init?.body)).definitionId, - ); - const createdIds = createAgentCalls.map( - (_, index) => `def-reviewer-${index + 1}`, + expect(cause).toBeInstanceOf(WorkbenchPreconditionError); + expect((cause as WorkbenchPreconditionError).kind).toBe( + "setup-agent-missing", ); - expect(invitedIds.sort()).toEqual([...createdIds].sort()); - expect(invitedIds).not.toContain("def-assistant"); - - const settingsBody = JSON.parse( - String( - calls.find((call) => - call.path.endsWith("/chat/workbenches/chan-1/settings"), - )?.init?.body, - ), - ); - expect(settingsBody).toEqual({ - "template/id": "code-review", - "template/pendingConnections": ["github"], - }); - - const onboardingCall = calls.find((call) => - call.path.endsWith("/chat/workbenches/chan-1/onboarding"), - ); - expect(JSON.parse(String(onboardingCall?.init?.body))).toEqual({ - kind: "connect-github", - requiredForTemplate: "Code review", - promise: CODE_REVIEW_TEMPLATE.promise, - steps: CODE_REVIEW_TEMPLATE.onboardingSteps.map(({ title, why }) => ({ - title, - why, - })), - }); - - const createBodyParsed = JSON.parse(String(createCall?.init?.body)); - expect(createBodyParsed.kind).toBe("workbench"); - expect(createBodyParsed.definitionId).toBeUndefined(); - expect(navigated).toEqual(["/w/chan-1"]); - - // CL-6594: a room this function navigates to must never carry a - // `workbenches` cache captured before its own reviewer roster - // finished being invited — that staleness is what left an invited - // agent with no name, no avatar, and no `@mention` in the room the - // owner reported it from. - expect(queryClient.getQueryState(staleQueryKey)?.isInvalidated).toBe(true); }); - // CL-6656: the prompt-box path mints a blank "New Workbench" and used to - // leave that placeholder in the sidebar forever. With a known first - // message, create renames via the same `chat/name` settings PATCH the - // sidebar rename uses. - test("blank create with a first message renames off New Workbench via chat/name", async () => { + test("a first message renames the room off New Workbench via chat/name", async () => { const navigated: string[] = []; const calls = stubFetch((path) => { if (path.includes("/workflows/definitions")) { @@ -481,9 +151,8 @@ describe("createWorkbenchFromTemplate", () => { throw new Error(`unexpected fetch: ${path}`); }); - await createWorkbenchFromTemplate( + await createWorkbench( "tnt_1", - "blank", (to) => navigated.push(to), newQueryClient(), "Plan the Q3 launch", @@ -492,174 +161,49 @@ describe("createWorkbenchFromTemplate", () => { const settingsCall = calls.find((call) => call.path.endsWith("/chat/workbenches/chan-adhoc/settings"), ); - expect(settingsCall).toBeDefined(); expect(JSON.parse(String(settingsCall?.init?.body))).toEqual({ "chat/name": "Plan the Q3 launch", }); expect(navigated).toEqual(["/w/chan-adhoc"]); }); - test("prefab create with a first message does not overwrite the template title", async () => { - const calls = stubFetch((path) => { - if (path.includes("/workflows/definitions")) { - return json({ data: [assistantDefinitionWire], nextCursor: null }); - } - if (path.endsWith("/library/templates/code-review")) { - return json({ - id: "code-review", - content: serializeWorkbenchDefinition(CODE_REVIEW_TEMPLATE), - }); - } - if (path.endsWith("/chat/workbenches")) { - return json({ - id: "chan-1", - title: "Code review", - kind: "workbench", - pinned: false, - participants: [], - }); - } - if (path.endsWith("/template-blocks/code-review/deploy")) { - return json({ id: "def-code-review-block", created: true }); - } - if (path.endsWith("/agent-definitions")) { - return json({ ...assistantDefinitionWire, id: "def-reviewer-1" }); - } - if (path.endsWith("/chat/workbenches/chan-1/onboarding")) { - return json({ id: "msg-onboarding" }, 201); - } - if (path.endsWith("/chat/workbenches/chan-1/invite")) { - return json({ - address: "agent:invited", - definitionId: "def-reviewer-1", - }); - } - if (path.endsWith("/chat/workbenches/chan-1/settings")) { - return json({ - id: "chan-1", - title: "Code review", - kind: "workbench", - pinned: false, - participants: [], - settings: {}, - contextWindow: { value: 0, source: "inherit" }, - }); - } - if (path.endsWith("/chat/workbenches/chan-1/messages")) { - return json({ - id: "msg-1", - createdAt: "2026-01-01T00:00:00.000Z", - sender: { name: "Sawyer", address: "principal-1" }, - parts: [{ kind: "text", text: "Review the auth PR" }], - }); - } - throw new Error(`unexpected fetch: ${path}`); - }); - - await createWorkbenchFromTemplate( - "tnt_1", - "code-review", - () => {}, - newQueryClient(), - "Review the auth PR", - ); - - const settingsBodies = calls - .filter((call) => call.path.endsWith("/chat/workbenches/chan-1/settings")) - .map((call) => JSON.parse(String(call.init?.body))); - expect( - settingsBodies.some( - (body) => - typeof body === "object" && - body !== null && - "chat/name" in body && - body["chat/name"] === "Review the auth PR", - ), - ).toBe(false); - }); - - // GitHub already connected is not a different create path: the in-room - // card reads live connected state and flips itself to repo pick, so the - // create flow posts the same walkthrough card and never picks repos or - // starts reviewing on the person's behalf. - test("with GitHub already connected, create posts the same walkthrough and never starts reviewing itself", async () => { - const calls = stubFetch((path) => { + test("an opening-message failure leaves a minted workbench recoverable", async () => { + stubFetch((path) => { if (path.includes("/workflows/definitions")) { return json({ data: [assistantDefinitionWire], nextCursor: null }); } - if (path.endsWith("/library/templates/code-review")) { - return json({ - id: "code-review", - content: serializeWorkbenchDefinition(CODE_REVIEW_TEMPLATE), - }); - } if (path.endsWith("/chat/workbenches")) { return json({ - id: "chan-1", - title: "Code review", - kind: "workbench", - pinned: false, - participants: [], - }); - } - if (path.endsWith("/template-blocks/code-review/deploy")) { - return json({ id: "def-code-review-block", created: true }); - } - if (path.endsWith("/agent-definitions")) { - return json({ ...assistantDefinitionWire, id: "def-reviewer-1" }); - } - if (path.endsWith("/chat/workbenches/chan-1/onboarding")) { - return json({ id: "msg-onboarding" }, 201); - } - if (path.endsWith("/chat/workbenches/chan-1/invite")) { - return json({ address: "agent:invited", definitionId: "def-reviewer" }); - } - if (path.endsWith("/chat/workbenches/chan-1/settings")) { - return json({ - id: "chan-1", - title: "Code review", + id: "chan-recoverable", + title: NEW_WORKBENCH_TITLE, kind: "workbench", pinned: false, participants: [], - settings: {}, - contextWindow: { value: 0, source: "inherit" }, }); } - if (path.includes("/credentials/resolve/")) { - return json({ - id: "cred_github", - tenantId: "tnt_1", - name: "GitHub", - status: "active", - }); + if (path.endsWith("/chat/workbenches/chan-recoverable/messages")) { + return json({ error: "agent launch failed" }, 409); } throw new Error(`unexpected fetch: ${path}`); }); - await createWorkbenchFromTemplate( - "tnt_1", - "code-review", - () => undefined, - newQueryClient(), - ); + let cause: unknown; + try { + await createWorkbench( + "tnt_1", + () => undefined, + newQueryClient(), + "Research our next partner", + ); + } catch (error) { + cause = error; + } - const onboardingCall = calls.find((call) => - call.path.endsWith("/chat/workbenches/chan-1/onboarding"), - ); - expect(JSON.parse(String(onboardingCall?.init?.body))).toEqual({ - kind: "connect-github", - requiredForTemplate: "Code review", - promise: CODE_REVIEW_TEMPLATE.promise, - steps: CODE_REVIEW_TEMPLATE.onboardingSteps.map(({ title, why }) => ({ - title, - why, - })), - }); - expect(calls.some((call) => call.path.includes("/github/state"))).toBe( - false, - ); - expect( - calls.some((call) => call.path.includes("/github/start-reviewing")), - ).toBe(false); + expect(cause).toBeInstanceOf(WorkbenchPostCreateError); + if (!(cause instanceof WorkbenchPostCreateError)) { + throw new Error("expected a recoverable post-create error"); + } + expect(cause.workbenchId).toBe("chan-recoverable"); + expect(cause.stage).toBe("opening-message"); }); }); diff --git a/apps/web/src/instant-agent-create.ts b/apps/web/src/instant-agent-create.ts index 9bd20e5018..416da7eef8 100644 --- a/apps/web/src/instant-agent-create.ts +++ b/apps/web/src/instant-agent-create.ts @@ -1,39 +1,27 @@ // Every "create a workbench" affordance — the sidebar's "+", the command // palette's "New workbench", and the zero-workbench land-hop on `/` — -// opens the template picker (`pages/new-workbench-picker.tsx`) and calls -// `createWorkbenchFromTemplate` below once a row is chosen. Blank `+` -// mints an empty `kind: "workbench"` channel (no host, no definitionId). -// A named workbench definition mints that same empty channel, then -// instantiates what the definition describes — its agents, its block -// workflows, its pending plugins — and runs the definition's own -// onboarding walkthrough in the room. Explicitly defining a brand-new +// opens the picker (`pages/new-workbench-picker.tsx`) and calls +// `createWorkbench` below once a person hits Create. CL-8156 deleted +// workbench template picking (`@workbench/templates`): a new workbench is +// a plain tenant + Myra now (CL-8154's converge path deploys Myra), so +// this mints an empty `kind: "workbench"` channel — no host, no +// definitionId — and, when the prompt box supplied an opening message, +// sends it and renames the room off it. Explicitly defining a brand-new // agent, with its own name/purpose/model/skills chosen up front, stays // `CreateAgentPanel`'s job (Settings → Agents), unchanged. import type { QueryClient } from "@tanstack/react-query"; import { - createWorkbench, + createWorkbench as createWorkbenchChannel, inviteAgent, partsForSend, patchWorkbenchSettings, - postWorkbenchOnboardingStep, sendMessage, workbenchesQueryKeyPrefix, } from "@corbits/chat-ui"; -import { - instantiateWorkbenchTemplate, - templateSettingsPatch, - type WorkbenchDefinition, - type WorkbenchOnboardingStep, -} from "@workbench/templates"; import { reportError } from "@corbits/error-sink"; -import { - deployWorkbenchTemplateBlock, - fetchWorkbenchTemplateManifest, -} from "./workbench-templates-api"; - -import { createAgentDefinition, listAgentDefinitions } from "./agents-api"; +import { listAgentDefinitions } from "./agents-api"; import { autoNameFromFirstMessage, NEW_WORKBENCH_TITLE, @@ -41,8 +29,6 @@ import { import { findMyraDefinition } from "./myra-workbench"; import { workbenchPath } from "./workbench-path"; -import type { WorkbenchTemplateId } from "./workbench-templates"; - export { NEW_WORKBENCH_TITLE }; export function refreshWorkbenchLists( @@ -64,30 +50,18 @@ export function refreshWorkbenchLists( } /** - * Marks the two precondition failures below as intentionally - * user-facing: their `message` is authored copy, never a raw request - * path or schema summary, so a caller can show it verbatim. Every - * other throw on this path (`ApiQueryError`, `ChatApiError`, or a - * plain `Error` from a package that hasn't opted in) must go through - * that error type's own describer instead — allow-listing safe - * throws, rather than denylisting unsafe ones, so a new error type - * added later fails safe (masked) instead of leaking by default. - * - * `kind` lets a caller tell "the setup agent isn't deployed yet" apart - * from "this template genuinely doesn't exist here" without parsing - * `message` text: the first is very often a still-provisioning bench - * (CL-6457's background deploy hasn't finished, or never started - * without a credential) — `createWorkbenchFromTemplate` throws it only - * after its own native definitions read found no setup agent, so that - * read already is the readiness check and the caller can dead-end - * honestly; the second never resolves itself and should surface as-is. + * Marks the bench-not-ready precondition below as intentionally + * user-facing: its `message` is authored copy, never a raw request path + * or schema summary, so a caller can show it verbatim. Every other throw + * on this path (`ApiQueryError`, `ChatApiError`, or a plain `Error` from a + * package that hasn't opted in) must go through that error type's own + * describer instead — allow-listing safe throws, rather than denylisting + * unsafe ones, so a new error type added later fails safe (masked) + * instead of leaking by default. */ export class WorkbenchPreconditionError extends Error { - readonly kind: "setup-agent-missing" | "template-unavailable"; - constructor( - message: string, - kind: "setup-agent-missing" | "template-unavailable", - ) { + readonly kind: "setup-agent-missing"; + constructor(message: string, kind: "setup-agent-missing") { super(message); this.kind = kind; } @@ -113,152 +87,49 @@ const SETUP_AGENT_MISSING_MESSAGE = "Your workbench is still finishing setup. Try again in a moment."; /** - * Raises the walkthrough's first card in the room. Only a - * `connect-plugin github` step has an in-room card today; the steps - * after it (`pick-github-repos`, `start-webhook-trigger`) are driven by - * that same card as the person works through it, so they need no client - * action here. The whole ordered walkthrough rides along in the card's - * body, so its step rail renders the definition's own copy. - */ -async function postOnboardingWalkthrough( - tenantId: string, - workbenchId: string, - definition: WorkbenchDefinition, - steps: readonly WorkbenchOnboardingStep[], -): Promise { - const labels = steps.map(({ title, why }) => ({ title, why })); - for (const step of steps) { - if (step.kind !== "connect-plugin") continue; - if (step.connectorId !== "github") { - throw new Error( - `workbench template "${definition.id}" asks to connect ` + - `"${step.connectorId}", which has no in-room onboarding card yet`, - ); - } - await postWorkbenchOnboardingStep(tenantId, workbenchId, { - kind: "connect-github", - requiredForTemplate: definition.title, - promise: definition.promise, - steps: labels, - }); - } -} - -/** - * The template picker's "Create workbench" action: mints an empty - * `kind: "workbench"` channel with no host and no `definitionId`, then - * instantiates the picked definition into it — its agents (existing - * principals invited after mint, new ones created first), its block - * workflows, its pending plugins — and posts the first step of the - * definition's onboarding walkthrough as a card in the room. Talking to - * an agent is clicking that agent (find-or-reopen its one DM); this - * function is the create verb for a room. + * The picker's "Create workbench" action: mints an empty `kind: + * "workbench"` channel with no host and no `definitionId`. Talking to an + * agent is clicking that agent (find-or-reopen its one DM); this function + * is the create verb for a room. * - * The bench has to be past setup — its default assistant definition - * deployed — before a room can invite anyone into it, so a bench that - * isn't fails with `WorkbenchPreconditionError` rather than minting a - * room nobody can join. That definition is the readiness gate only: no - * definition names it as a host, and none invites it. A template id - * with no definition (`blank`, "Just start talking") mints a plain - * untagged channel under the generic `NEW_WORKBENCH_TITLE`. + * The bench has to be past setup — its default Myra definition deployed — + * before a room can invite anyone into it, so a bench that isn't fails + * with `WorkbenchPreconditionError` rather than minting a room nobody can + * join. * - * `queryClient` invalidates the workbenches list once every agent the - * definition names has been invited — `ChatWorkspace`'s own - * in-room "Invite agent" dialog does the same - * (`workbenchesQueryKeyPrefix`, `chat-workspace.tsx`'s - * `refreshWorkbenchLists`) so the room the invite landed in never - * shows a participant it already has data for as if it never joined. - * Without this, the room this function `navigate`s to can start life - * holding a `workbenches` query cached from before the last invite - * resolved. + * `queryClient` invalidates the workbenches list once every selected + * agent has been invited — `ChatWorkspace`'s own in-room "Invite agent" + * dialog does the same (`workbenchesQueryKeyPrefix`, + * `chat-workspace.tsx`'s `refreshWorkbenchLists`) so the room the invite + * landed in never shows a participant it already has data for as if it + * never joined. Without this, the room this function `navigate`s to can + * start life holding a `workbenches` query cached from before the last + * invite resolved. * * `firstMessage`, when given (the picker's prompt box), is sent as the - * signed-in person's own opening message once the room and the - * definition's agents exist, so it lands after the onboarding card - * rather than racing it. For a blank / ad-hoc mint that same text also - * renames the room off `NEW_WORKBENCH_TITLE` via - * `patchWorkbenchSettings` (`chat/name`), matching the sidebar rename - * path; prefab titles are left alone. + * signed-in person's own opening message once the room exists, and + * renames the room off `NEW_WORKBENCH_TITLE` via `patchWorkbenchSettings` + * (`chat/name`), matching the sidebar rename path. */ -export async function createWorkbenchFromTemplate( +export async function createWorkbench( tenantId: string, - templateId: WorkbenchTemplateId, navigate: (to: string) => void, queryClient: QueryClient, firstMessage?: string, selectedAgentDefinitionIds: readonly string[] = [], ): Promise { - if (templateId !== "blank" && selectedAgentDefinitionIds.length > 0) { - throw new Error("Only a new blank workbench can select agents directly."); - } const definitions = await listAgentDefinitions(tenantId); - const setupTemplate = findMyraDefinition(definitions); - if (setupTemplate === undefined) { + if (findMyraDefinition(definitions) === undefined) { throw new WorkbenchPreconditionError( SETUP_AGENT_MISSING_MESSAGE, "setup-agent-missing", ); } - // The definition comes from the bench library, never from a - // hardcoded catalog import; reading it is what seeds the shelf. - // `blank` is the one id with no definition by design; any - // other id resolving to nothing means this build ships no such - // template — fail loud rather than mint a workbench missing its - // agents. The picker only offers ids the library listed, so this is - // the race-loser's message, not the everyday path. - const definition = - templateId === "blank" - ? undefined - : ((await fetchWorkbenchTemplateManifest(tenantId, templateId)) ?? - undefined); - if (templateId !== "blank" && definition === undefined) { - throw new WorkbenchPreconditionError( - `A ${templateId} workbench isn't available here yet.`, - "template-unavailable", - ); - } - const workbench = await createWorkbench(tenantId, { + const workbench = await createWorkbenchChannel(tenantId, { kind: "workbench", - name: definition?.title ?? NEW_WORKBENCH_TITLE, + name: NEW_WORKBENCH_TITLE, }); - if (definition !== undefined) { - await instantiateWorkbenchTemplate(definition, { - async listAgentHandles() { - const current = await listAgentDefinitions(tenantId); - return current.map((definition) => ({ - handle: definition.name, - id: definition.id, - })); - }, - async createParticipantAgent(request) { - const created = await createAgentDefinition(tenantId, request); - return { id: created.id }; - }, - async deployBlockWorkflow(block) { - return deployWorkbenchTemplateBlock(tenantId, block.assetName); - }, - async inviteParticipantAgent(id) { - await inviteAgent(tenantId, workbench.id, id); - }, - async recordPendingConnections(pendingConnections) { - await patchWorkbenchSettings( - tenantId, - workbench.id, - templateSettingsPatch(definition.id, pendingConnections), - ); - }, - async beginOnboarding(steps) { - await postOnboardingWalkthrough( - tenantId, - workbench.id, - definition, - steps, - ); - }, - }); - } - if (firstMessage !== undefined && firstMessage.trim() !== "") { const selectedAgentInvites = [...new Set(selectedAgentDefinitionIds)].map( (definitionId) => ({ kind: "agent" as const, definitionId }), @@ -275,19 +146,15 @@ export async function createWorkbenchFromTemplate( }, ); } catch (cause) { - if (templateId === "blank") { - throw new WorkbenchPostCreateError( - workbench.id, - "opening-message", - cause, - ); - } - throw cause; + throw new WorkbenchPostCreateError( + workbench.id, + "opening-message", + cause, + ); } // Blank / ad-hoc mints stay "New Workbench" until named. When the // prompt box already supplied the opening message, rename via the same - // `chat/name` settings PATCH the sidebar rename uses — prefab titles - // (`definition?.title`) are left alone by `autoNameFromFirstMessage`. + // `chat/name` settings PATCH the sidebar rename uses. const autoTitle = autoNameFromFirstMessage(workbench.title, firstMessage); if (autoTitle !== undefined) { try { @@ -295,13 +162,14 @@ export async function createWorkbenchFromTemplate( "chat/name": autoTitle, }); } catch (cause) { - if (templateId === "blank") { - throw new WorkbenchPostCreateError(workbench.id, "rename", cause); - } - throw cause; + throw new WorkbenchPostCreateError(workbench.id, "rename", cause); } refreshWorkbenchLists(queryClient, tenantId, workbench.id); } + } else if (selectedAgentDefinitionIds.length > 0) { + for (const definitionId of new Set(selectedAgentDefinitionIds)) { + await inviteAgent(tenantId, workbench.id, definitionId); + } } refreshWorkbenchLists(queryClient, tenantId, workbench.id); diff --git a/apps/web/src/myra-workbench.ts b/apps/web/src/myra-workbench.ts index 4f54a59ca6..c3fd26e3ca 100644 --- a/apps/web/src/myra-workbench.ts +++ b/apps/web/src/myra-workbench.ts @@ -7,7 +7,7 @@ import { createDefaultAgentWorkbench, findDefinitionByAssetName, } from "@corbits/chat-ui"; -import { WORKFLOW_CATALOG } from "@workbench/templates"; +import { WORKFLOW_CATALOG } from "@corbits/workflows/catalog"; import { listAgentDefinitions, type AgentDefinition } from "./agents-api"; diff --git a/apps/web/src/pages/new-workbench-picker.test.ts b/apps/web/src/pages/new-workbench-picker.test.ts deleted file mode 100644 index 5217c1374d..0000000000 --- a/apps/web/src/pages/new-workbench-picker.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -// CL-6495: the create-workbench catch used to discard whatever -// `createWorkbenchFromTemplate` threw behind a fixed "try again" toast — -// honest for a transient failure, a lie for a precondition this bench -// doesn't meet (no setup agent, an unavailable template). These pin the -// wording `describeWorkbenchCreateFailure` picks for each shape of cause. -// -// The check is an allow-list (only `WorkbenchPreconditionError` is shown -// verbatim), not a denylist of `ApiQueryError` — a plain `Error` from -// anywhere else on the path (`listPluginsForTenant`, `instantiateWorkbenchTemplate`, -// or any future throw) must fall to the generic message rather than leak -// a raw request path or schema summary into the toast. - -import { describe, expect, test } from "bun:test"; -import { ApiQueryError } from "@corbits/api-query"; -import { ChatApiError } from "@corbits/chat-ui"; - -import { describeWorkbenchCreateFailure } from "./new-workbench-picker"; -import { - WorkbenchPostCreateError, - WorkbenchPreconditionError, -} from "../instant-agent-create"; - -const GENERIC = "Something went wrong creating this workbench. Try again."; - -describe("describeWorkbenchCreateFailure", () => { - test("a recoverable post-create failure gives the person its support reference", () => { - expect( - describeWorkbenchCreateFailure( - new WorkbenchPostCreateError( - "chan_1", - "opening-message", - new Error("agent launch failed"), - ), - "ref_1", - ), - ).toBe( - "Workbench created, but we couldn't send the opening message or add the selected agent. Try again from the room. Reference: ref_1", - ); - }); - - test("a WorkbenchPreconditionError (no setup agent, unavailable template) is shown verbatim", () => { - expect( - describeWorkbenchCreateFailure( - new WorkbenchPreconditionError( - "A code-review workbench isn't available here yet.", - "template-unavailable", - ), - ), - ).toBe("A code-review workbench isn't available here yet."); - }); - - test("a plain Error carrying internal detail is masked, not shown verbatim", () => { - // The exact shape `listPluginsForTenant`/`instantiateWorkbenchTemplate` - // throw: a message embedding a raw status code or schema summary. - expect( - describeWorkbenchCreateFailure( - new Error( - "Unexpected response shape resolving GitHub: must be an object", - ), - ), - ).toBe(GENERIC); - }); - - test("an ApiQueryError runs through describeApiError so the status drives the wording", () => { - expect(describeWorkbenchCreateFailure(new ApiQueryError("boom", 404))).toBe( - "This isn't here anymore.", - ); - expect(describeWorkbenchCreateFailure(new ApiQueryError("boom", 401))).toBe( - "You don't have access to this.", - ); - }); - - test("an ApiQueryError never leaks its raw message (path, status text) into the toast", () => { - expect( - describeWorkbenchCreateFailure( - new ApiQueryError( - "The server answered 500.", - 500, - "/api/tenants/tnt_1/template-blocks/code-review/deploy", - ), - ), - ).not.toContain("/api/tenants"); - }); - - test("a ChatApiError — createWorkbench, patchWorkbenchSettings, and the GitHub-connect steps all throw this — runs through describeChatError", () => { - expect(describeWorkbenchCreateFailure(new ChatApiError("boom", 401))).toBe( - "You're signed out. Sign in again to continue.", - ); - expect(describeWorkbenchCreateFailure(new ChatApiError("boom", 403))).toBe( - "You don't have access to this.", - ); - expect(describeWorkbenchCreateFailure(new ChatApiError("boom", 500))).toBe( - "Something went wrong on our end. Try again in a moment.", - ); - }); - - test("a ChatApiError never leaks its raw message (it always embeds the request path)", () => { - expect( - describeWorkbenchCreateFailure( - new ChatApiError( - "The server answered 500 for /api/tenants/tnt_1/chat/workbenches.", - 500, - ), - ), - ).not.toContain("/api/tenants"); - }); - - test("a non-Error cause falls back to the same generic, honest message", () => { - expect(describeWorkbenchCreateFailure("boom")).toBe(GENERIC); - }); -}); diff --git a/apps/web/src/pages/new-workbench-picker.tsx b/apps/web/src/pages/new-workbench-picker.tsx index 406dd6be9d..79cd6f61ed 100644 --- a/apps/web/src/pages/new-workbench-picker.tsx +++ b/apps/web/src/pages/new-workbench-picker.tsx @@ -1,21 +1,11 @@ -// CL-6628: flips the picker's hierarchy from "choose a kind, then create" -// to "say what you want, or choose a shortcut" — a prompt box is the -// primary act, with the prefab rows (still CL-6342/CL-6344's real -// instantiation paths) demoted to one-click shortcuts underneath. Typing -// a goal and hitting Enter creates a blank room and hands that text to -// `createWorkbenchFromTemplate` as `firstMessage`, so Myra's first read of -// the room is the person's actual intent rather than a kind label. A -// prefab click still creates immediately — no radio-then-Create -// second step anywhere on this screen. +// CL-8156: workbench template picking is gone — a new workbench is a +// plain tenant + Myra (CL-8154's converge path deploys Myra behind the +// scenes). This screen is just the prompt box: say what you want and hit +// Enter, or open an empty channel and add people/agents as you go. import { Button, toast } from "@corbits/react-ui"; import { useDismissablePopover } from "@corbits/react-ui/hooks/use-dismissable-popover"; -import { - ChatCircle, - GitPullRequest, - MagnifyingGlass, - PaperPlaneRight, -} from "@corbits/icons"; +import { PaperPlaneRight } from "@corbits/icons"; import { ChatApiError, CHAT_STRINGS, @@ -29,12 +19,10 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { ApiQueryError, describeApiError } from "@corbits/api-query"; import { reportError } from "@corbits/error-sink"; -import { useAPIQuery } from "../api"; import { CreateAgentPanel } from "./create-agent-panel"; -import { TemplateLibraryPage } from "../workbench-templates-api"; import { useBench } from "../bench-context"; import { - createWorkbenchFromTemplate, + createWorkbench, refreshWorkbenchLists, WorkbenchPostCreateError, WorkbenchPreconditionError, @@ -42,24 +30,18 @@ import { import { useNavigate } from "../navigation"; import { StageTopBar } from "../shell/stage-top-bar"; import { workbenchPath } from "../workbench-path"; -import { - WORKBENCH_TEMPLATES, - type WorkbenchTemplateId, -} from "../workbench-templates"; const GENERIC_CREATE_FAILURE = "Something went wrong creating this workbench. Try again."; /** * Allow-lists what's safe to show verbatim, rather than denylisting - * what to hide — a new error type `createWorkbenchFromTemplate`'s path - * starts throwing later lands here unrecognized and falls to the - * generic message, not into the toast raw. Only - * `WorkbenchPreconditionError` carries authored, always-safe copy - * ("try again" is a lie for a missing template, so its own message - * says so instead); `ApiQueryError` and `ChatApiError` both embed raw - * request paths and schema summaries in `.message` and must go through - * their own describer, never shown directly. + * what to hide — a new error type `createWorkbench`'s path starts + * throwing later lands here unrecognized and falls to the generic + * message, not into the toast raw. Only `WorkbenchPreconditionError` + * carries authored, always-safe copy; `ApiQueryError` and `ChatApiError` + * both embed raw request paths and schema summaries in `.message` and + * must go through their own describer, never shown directly. */ export function describeWorkbenchCreateFailure( cause: unknown, @@ -82,16 +64,6 @@ export function describeWorkbenchCreateFailure( return GENERIC_CREATE_FAILURE; } -const CARD_ICON: Record = { - "code-review": GitPullRequest, - "due-diligence": MagnifyingGlass, - blank: ChatCircle, -}; - -/** The one kind that needs no manifest: an empty room is always - * something this bench can set up. */ -const BLANK_TEMPLATE_ID: WorkbenchTemplateId = "blank"; - const PROMPT_PLACEHOLDER = "What do you want your Workbench to do?"; const AGENT_LISTBOX_ID = "new-workbench-agent-listbox"; @@ -118,12 +90,6 @@ export function NewWorkbenchPickerRoute() { currentTenantIdRef.current = null; }; }, [selectedTenantId]); - const library = useAPIQuery( - selectedTenantId === null - ? "" - : `/api/tenants/${selectedTenantId}/library/templates`, - TemplateLibraryPage, - ); const [prompt, setPrompt] = useState(""); const [selectedAgentDefinitionIds, setSelectedAgentDefinitionIds] = useState< readonly string[] @@ -133,13 +99,11 @@ export function NewWorkbenchPickerRoute() { const [agentQuery, setAgentQuery] = useState(""); const [activeAgentIndex, setActiveAgentIndex] = useState(0); const [creating, setCreating] = useState(false); - // Set only when `createWorkbenchFromTemplate` hit the missing-setup-agent + // Set only when `createWorkbench` hit the missing-setup-agent // precondition — thrown only after its own native definitions read found // no setup agent, so that read is the readiness check, never a guess - // from the error alone. (That precondition is distinct from - // template-unavailable, which is what a template-that-will-never-exist - // looks like.) Distinct from `creating`'s loader: this is a dead end - // until setup finishes, not a request in flight. + // from the error alone. Distinct from `creating`'s loader: this is a + // dead end until setup finishes, not a request in flight. const [stillSettingUp, setStillSettingUp] = useState(false); const promptRef = useRef(null); const { @@ -165,18 +129,14 @@ export function NewWorkbenchPickerRoute() { // dead end and a plain toast-and-retry) replays the exact same request // rather than silently falling back to blank. const lastAttemptRef = useRef<{ - readonly templateId: WorkbenchTemplateId; readonly firstMessage: string | undefined; readonly selectedIds: readonly string[]; } | null>(null); - // The prompt box only exists in the DOM once the library read settles - // and neither dead-end state is showing — an unconditional mount-time - // effect would fire while that branch renders `WorkbenchLoadingState` - // instead, before `promptRef` has anything to focus. Re-running on - // `showingPrompt` catches the moment the textarea actually mounts. - const showingPrompt = - !stillSettingUp && !creating && library.kind !== "loading"; + // The prompt box only exists in the DOM once neither dead-end state is + // showing — an unconditional mount-time effect would fire while a dead + // end renders instead, before `promptRef` has anything to focus. + const showingPrompt = !stillSettingUp && !creating; useEffect(() => { if (showingPrompt) promptRef.current?.focus(); }, [showingPrompt]); @@ -200,21 +160,6 @@ export function NewWorkbenchPickerRoute() { }); }, [invitableAgents.data]); - // What this bench's library can actually serve (CL-6458). A kind whose - // manifest the library doesn't hold is shown as not set up rather than - // offered and then dead-ended on a 404 at create time. - const servedTemplateIds = - library.kind === "ready" - ? new Set(library.data.data.map((entry) => entry.id)) - : new Set(); - const offeredTemplates = WORKBENCH_TEMPLATES.filter( - (template) => - template.id !== BLANK_TEMPLATE_ID && servedTemplateIds.has(template.id), - ); - const unavailableTemplates = WORKBENCH_TEMPLATES.filter( - (template) => - template.id !== BLANK_TEMPLATE_ID && !offeredTemplates.includes(template), - ); const filteredAgents = (invitableAgents.data ?? []).filter((agent) => { const query = agentQuery.trim().toLocaleLowerCase(); return ( @@ -228,18 +173,16 @@ export function NewWorkbenchPickerRoute() { ); async function handleCreate( - templateId: WorkbenchTemplateId, firstMessage?: string, selectedIds: readonly string[] = [], ) { if (selectedTenantId === null || creating) return; - lastAttemptRef.current = { templateId, firstMessage, selectedIds }; + lastAttemptRef.current = { firstMessage, selectedIds }; setCreating(true); setStillSettingUp(false); try { - await createWorkbenchFromTemplate( + await createWorkbench( selectedTenantId, - templateId, navigate, queryClient, firstMessage, @@ -258,15 +201,6 @@ export function NewWorkbenchPickerRoute() { toast(describeWorkbenchCreateFailure(cause, refId)); return; } - // The missing-setup-agent precondition is thrown only after a - // successful native definitions read found no setup agent - // (CL-6457's background drain still running, or never started - // without a credential) — that read IS the readiness check, so no - // second round-trip is needed before showing the retryable - // still-setting-up state. (CL-8112 T1 cut the deleted - // `/api/onboarding/provisioning-status` re-check here.) - // TODO(CL-8112-T6/T7): read native readiness here once T6/T7 - // builds it, if the panel needs more than the definition's absence. if ( cause instanceof WorkbenchPreconditionError && cause.kind === "setup-agent-missing" @@ -289,17 +223,13 @@ export function NewWorkbenchPickerRoute() { function retryLastAttempt() { const attempt = lastAttemptRef.current; if (attempt === null) return; - void handleCreate( - attempt.templateId, - attempt.firstMessage, - attempt.selectedIds, - ); + void handleCreate(attempt.firstMessage, attempt.selectedIds); } function handlePromptSubmit() { const trimmed = prompt.trim(); if (trimmed === "") return; - void handleCreate(BLANK_TEMPLATE_ID, trimmed, selectedAgentDefinitionIds); + void handleCreate(trimmed, selectedAgentDefinitionIds); } function toggleAgent(definitionId: string) { @@ -351,22 +281,19 @@ export function NewWorkbenchPickerRoute() { ) : creating ? ( // `delayMs={0}`: we already know this is a genuine wait the - // instant the person hits Enter or clicks a card, so the default - // "hold back briefly in case it resolves fast" delay only bought - // a blank pane here (CL-6623 finding #3) — show the loader - // outright instead of leaving a gap before it mounts. + // instant the person hits Enter, so the default "hold back + // briefly in case it resolves fast" delay only bought a blank + // pane here (CL-6623 finding #3) — show the loader outright + // instead of leaving a gap before it mounts. - ) : library.kind === "loading" ? ( - ) : ( <>

What do you want your Workbench to do?

- Tell it what you're trying to get done, or pick one below. Takes - about ten seconds. + Tell it what you're trying to get done. Takes about ten seconds.

- {library.kind === "error" ? ( -

- Couldn't load what this bench can set up, so only a plain room - is on offer right now.{" "} - -

- ) : null} - -
- Or start from a template - Agents are already in the room. One click opens it. -
-
- {offeredTemplates.map((template) => { - const Icon = CARD_ICON[template.id]; - return ( - - ); - })} - - {unavailableTemplates.map((template) => { - const Icon = CARD_ICON[template.id]; - return ( - - - - - {template.title} - - - Not set up on this bench yet. - - - - ); - })} -