From 951bae1988e0d1187d44469d9c8fc5b33126c1f6 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 10:23:50 -0700 Subject: [PATCH 1/8] feat(connections): fold the concrete connector set back into the package CL-7384 split CONNECTOR_REGISTRY and MCP_PRESETS out into templates/connectors.ts so the connections package stayed a pure generic library. CL-8156 deletes templates/ entirely, so this data comes back to its one remaining home: packages/connections/src/registry.ts and mcp-presets.ts. Claude-Session: https://claude.ai/code/session_01RweuMUskU2nvcFhdckc7EG --- packages/connections/package.json | 2 + packages/connections/src/mcp-presets.ts | 157 ++++- packages/connections/src/registry.test.ts | 462 +++++++++++++++ packages/connections/src/registry.ts | 692 +++++++++++++++++++++- 4 files changed, 1297 insertions(+), 16 deletions(-) create mode 100644 packages/connections/src/registry.test.ts diff --git a/packages/connections/package.json b/packages/connections/package.json index 85025d53b7..bd11ec4e90 100644 --- a/packages/connections/package.json +++ b/packages/connections/package.json @@ -34,6 +34,7 @@ }, "dependencies": { "@corbits/code-review": "workspace:*", + "@corbits/codex-provider": "workspace:*", "@corbits/credential-providers": "workspace:*", "@corbits/error-sink": "workspace:*", "@corbits/github-tools": "workspace:*", @@ -43,6 +44,7 @@ "@corbits/mcp-tools": "workspace:*", "@corbits/oauth-core": "github:corbitsdev/corbits-oauth-core#v0.1.1", "@corbits/workflows": "workspace:*", + "@corbits/xai-provider": "workspace:*", "@intx/crypto": "0.3.0", "@intx/db": "workspace:*", "@intx/hub-api": "workspace:*", diff --git a/packages/connections/src/mcp-presets.ts b/packages/connections/src/mcp-presets.ts index 9d702d024c..f4cb0a5cb8 100644 --- a/packages/connections/src/mcp-presets.ts +++ b/packages/connections/src/mcp-presets.ts @@ -1,7 +1,15 @@ -// The `McpPreset` shape and the generic helpers a caller needs to work -// with a preset list — the concrete curated presets themselves (Granola, -// Exa, Linear, GitHub MCP, ...) moved to `templates/connectors.ts` as -// part of CL-7384, alongside `./registry.ts`'s connector descriptors. +// The `McpPreset` shape, the generic helpers a caller needs to work with a +// preset list, and Workbench's curated remote MCP servers — folded back in +// here by CL-8156 from `templates/connectors.ts` (CL-7384 had split them +// out) now that `@workbench/templates` no longer exists to own them. +import { + siGithub, + siNotion, + siPosthog, + siRailway, + siSentry, +} from "simple-icons"; + export type McpPresetConnectionMode = "oauth" | "keyless" | "token"; export type McpPreset = { @@ -48,3 +56,144 @@ export function mcpPresetByName( preset.slug === needle || preset.displayName.toLowerCase() === needle, ); } + +/** + * Curated remote MCP servers that Workbench can connect without asking a + * person for a URL, API key, client id, or client secret. OAuth entries have + * been verified to advertise OAuth discovery, PKCE, and dynamic client + * registration; keyless entries can be probed and stored immediately; token + * entries accept a pasted access token as the bearer (GitHub's MCP server + * does OAuth only for clients pre-registered with GitHub — it offers no + * dynamic client registration — so its connect here is the token walk). + */ +export const MCP_PRESETS: readonly McpPreset[] = [ + { + slug: "granola", + displayName: "Granola", + description: "Search meeting notes, transcripts, and action items.", + url: "https://mcp.granola.ai/mcp", + connectionMode: "oauth", + docsUrl: "https://docs.granola.ai/help-center/sharing/integrations/mcp", + nativeConnectorId: "granola", + }, + { + slug: "exa", + displayName: "Exa", + description: "Search and research the live web.", + url: "https://mcp.exa.ai/mcp", + connectionMode: "keyless", + docsUrl: "https://docs.exa.ai/reference/exa-mcp", + nativeConnectorId: "exa", + }, + { + slug: "linear", + displayName: "Linear", + description: "Read and update issues, projects, and comments.", + url: "https://mcp.linear.app/mcp", + connectionMode: "oauth", + docsUrl: "https://linear.app/docs/mcp", + nativeConnectorId: "linear", + }, + { + // Deliberately NOT slug "github" and NOT nativeConnectorId "github": + // the native GitHub connector (PAT/OAuth-App feeding + // @corbits/github-tools) stays its own card, and preset lookups by + // "github" must keep resolving to it, never here. + slug: "github-mcp", + displayName: "GitHub MCP", + description: "Search code, work with issues and pull requests.", + url: "https://api.githubcopilot.com/mcp/", + connectionMode: "token", + docsUrl: "https://github.com/settings/tokens", + icon: { path: siGithub.path, hex: siGithub.hex }, + tokenSteps: [ + "Open github.com/settings/tokens and generate a new token.", + "Give it the repo scope — that lets agents read code, issues, and pull requests.", + "Paste it below. It's stored encrypted, only your agents use it, and you can disconnect any time.", + ], + }, + { + slug: "notion", + displayName: "Notion", + description: "Search and update pages, databases, and workspace content.", + url: "https://mcp.notion.com/mcp", + connectionMode: "oauth", + docsUrl: "https://developers.notion.com/guides/mcp/get-started-with-mcp", + icon: { path: siNotion.path, hex: siNotion.hex }, + }, + { + slug: "sentry", + displayName: "Sentry", + description: "Investigate errors, traces, releases, and projects.", + url: "https://mcp.sentry.dev/mcp", + connectionMode: "oauth", + docsUrl: "https://mcp.sentry.dev/", + icon: { path: siSentry.path, hex: siSentry.hex }, + }, + { + slug: "attio", + displayName: "Attio", + description: "Work with CRM records, lists, notes, and tasks.", + url: "https://mcp.attio.com/mcp", + connectionMode: "oauth", + docsUrl: "https://docs.attio.com/mcp/overview", + }, + { + slug: "railway", + displayName: "Railway", + description: "Inspect and manage projects, services, and deployments.", + url: "https://mcp.railway.com", + connectionMode: "oauth", + docsUrl: "https://docs.railway.com/ai/mcp-server", + icon: { path: siRailway.path, hex: siRailway.hex }, + }, + { + slug: "posthog", + displayName: "PostHog", + description: "Explore product analytics, errors, flags, and experiments.", + url: "https://mcp.posthog.com/mcp", + connectionMode: "oauth", + docsUrl: "https://posthog.com/docs/model-context-protocol", + icon: { path: siPosthog.path, hex: siPosthog.hex }, + }, + { + slug: "sumble", + displayName: "Sumble", + description: "Research accounts, people, technologies, and buying signals.", + url: "https://mcp.sumble.com/", + connectionMode: "oauth", + docsUrl: "https://sumble.com/guides/account-research", + }, + { + // No simple-icons listing for Canva (CL-6647) — same gap as Granola + // and Sumble above, so this card falls back to the initial-letter + // tile rather than risk a hand-traced or doctored mark. + slug: "canva", + displayName: "Canva", + description: "Design decks, docs, and graphics.", + url: "https://mcp.canva.com/mcp", + connectionMode: "oauth", + docsUrl: "https://www.canva.dev/docs/mcp/", + oauthScopes: [ + "profile:read", + "design:meta:read", + "design:content:write", + "design:content:read", + "folder:read", + "folder:write", + "brandtemplate:content:read", + "brandtemplate:meta:read", + "brandtemplate:content:write", + "comment:write", + "comment:read", + "asset:read", + "asset:write", + "brandkit:read", + "help:answers:read", + "help:answers:write", + ], + }, +]; + +export const MCP_PRESET_CONNECTOR_IDS: readonly string[] = + mcpPresetConnectorIds(MCP_PRESETS); diff --git a/packages/connections/src/registry.test.ts b/packages/connections/src/registry.test.ts new file mode 100644 index 0000000000..c34e835c2a --- /dev/null +++ b/packages/connections/src/registry.test.ts @@ -0,0 +1,462 @@ +import { describe, expect, test } from "bun:test"; +import { CODEX_SCOPES } from "@corbits/codex-provider/constants"; +import { CONNECTOR_REGISTRY, connectorDescriptors } from "./registry"; +import { + MCP_PRESET_CONNECTOR_IDS, + MCP_PRESETS, + mcpPresetByName, + mcpPresetBySlug, +} from "./mcp-presets"; + +describe("CONNECTOR_REGISTRY", () => { + test("every entry has an id, displayName, and docsUrl", () => { + for (const descriptor of Object.values(CONNECTOR_REGISTRY)) { + expect(descriptor.id.length).toBeGreaterThan(0); + expect(descriptor.displayName.length).toBeGreaterThan(0); + expect(descriptor.docsUrl.length).toBeGreaterThan(0); + } + }); + + test("every api-key entry has a probe", () => { + for (const descriptor of Object.values(CONNECTOR_REGISTRY)) { + if (descriptor.authKind !== "api-key") continue; + expect(descriptor.probe).toBeDefined(); + } + }); + + // `github` is the one deliberate exception (CL-6386): it stays + // `authKind: "api-key"` (the PAT paste form is always available) but + // also carries an `oauth` config a caller checks `GET + // /oauth-configured` before offering, ahead of the paste form, as a + // hosted one-click connect. Every other api-key connector still names + // no `oauth` at all. + test("only github carries an oauth config alongside api-key", () => { + for (const descriptor of Object.values(CONNECTOR_REGISTRY)) { + if (descriptor.authKind !== "api-key") continue; + if (descriptor.id === "github") { + expect(descriptor.oauth).toBeDefined(); + continue; + } + expect(descriptor.oauth).toBeUndefined(); + } + }); + + // CL-6258: every provider row in Settings > Connections needs a logo + // tile -- a simple-icons mark where one exists, a monochrome initial + // tile (rendered from `displayName` alone) where it doesn't. + test("every inference provider with a simple-icons listing carries a hex-keyed icon", () => { + for (const id of [ + "anthropic", + "google-genai", + "deepseek", + "mistral", + "ollama", + "openrouter", + "huggingface", + ]) { + const descriptor = CONNECTOR_REGISTRY[id]; + expect(descriptor?.icon?.path.length).toBeGreaterThan(0); + expect(descriptor?.icon?.hex).toMatch(/^[0-9A-F]{6}$/i); + } + }); + + test("providers with no simple-icons listing fall back to no icon (initial tile)", () => { + for (const id of ["openai", "xai", "groq", "opencode-zen"]) { + expect(CONNECTOR_REGISTRY[id]?.icon).toBeUndefined(); + } + }); + + test("includes OpenRouter and Hugging Face as oauth-pkce connectors, no probe", () => { + for (const id of ["openrouter", "huggingface"]) { + const descriptor = CONNECTOR_REGISTRY[id]; + expect(descriptor?.authKind).toBe("oauth-pkce"); + expect(descriptor?.probe).toBeUndefined(); + expect(descriptor?.oauth).toBeDefined(); + expect(descriptor?.oauth?.usesPKCE).toBe(true); + expect(descriptor?.oauth?.deploysDefaultWorkflows).toBe(true); + expect(descriptor?.feedsTools).toEqual([]); + } + }); + + test("only Hugging Face requires a configured client id", () => { + expect(CONNECTOR_REGISTRY["openrouter"]?.oauth?.clientId).toBeUndefined(); + expect( + CONNECTOR_REGISTRY["huggingface"]?.oauth?.clientId?.({}), + ).toBeUndefined(); + expect( + CONNECTOR_REGISTRY["huggingface"]?.oauth?.clientId?.({ + huggingfaceClientId: "hf_1", + }), + ).toBe("hf_1"); + }); + + test("only Hugging Face echoes state back in its callback query", () => { + expect(CONNECTOR_REGISTRY["openrouter"]?.oauth?.echoesState).toBe(false); + expect(CONNECTOR_REGISTRY["huggingface"]?.oauth?.echoesState).toBe(true); + }); + + test("includes the eight non-OAuth inference providers", () => { + for (const id of [ + "anthropic", + "openai", + "google-genai", + "xai", + "opencode-zen", + "groq", + "deepseek", + "mistral", + ]) { + expect(CONNECTOR_REGISTRY[id]).toBeDefined(); + expect(CONNECTOR_REGISTRY[id]?.feedsTools).toEqual([]); + } + }); + + test("ollama collects a URL, not a key, and needs no probed secret to reach reachability", () => { + const descriptor = CONNECTOR_REGISTRY["ollama"]; + expect(descriptor?.authKind).toBe("api-key"); + expect(descriptor?.credentialInputKind).toBe("url"); + expect(descriptor?.credentialPlaceholder).toBe("http://localhost:11434"); + expect(descriptor?.probe).toBeDefined(); + expect(descriptor?.feedsTools).toEqual([]); + }); + + test("includes Codex and xai-oauth as oauth-loopback connectors, no probe", () => { + for (const id of ["codex", "xai-oauth"]) { + const descriptor = CONNECTOR_REGISTRY[id]; + expect(descriptor?.authKind).toBe("oauth-loopback"); + expect(descriptor?.probe).toBeUndefined(); + expect(descriptor?.oauth).toBeDefined(); + expect(descriptor?.oauth?.usesPKCE).toBe(true); + expect(descriptor?.oauth?.deploysDefaultWorkflows).toBe(true); + expect(descriptor?.feedsTools).toEqual([]); + // The OAuth client ids are the provider CLIs' public identifiers. + expect(descriptor?.oauth?.clientId?.({})).toBeDefined(); + } + }); + + test("pins each loopback provider to its own fixed redirect URI", () => { + const codexUrl = CONNECTOR_REGISTRY["codex"]?.oauth?.buildAuthorizeUrl({ + callbackUrl: "https://bench.example.com/ignored", + state: "s", + codeChallenge: "c", + clientId: "app_1", + }); + expect(codexUrl?.origin).toBe("https://auth.openai.com"); + expect(codexUrl?.searchParams.get("redirect_uri")).toBe( + "http://localhost:1455/auth/callback", + ); + expect(codexUrl?.searchParams.get("originator")).toBe("codex_cli_rs"); + // Pinned against the upstream Codex CLI's scope grant — the + // api.connectors.* scopes are what let the backend serve connector + // tools to this client, and the authorization server issues them only + // to the Codex client id. Asserted against the exported constant so + // the descriptor cannot drift from the adapter's refresh path. + expect(codexUrl?.searchParams.get("scope")?.split(" ")).toEqual([ + ...CODEX_SCOPES, + ]); + + const xaiUrl = CONNECTOR_REGISTRY["xai-oauth"]?.oauth?.buildAuthorizeUrl({ + callbackUrl: "https://bench.example.com/ignored", + state: "s", + codeChallenge: "c", + clientId: "client_1", + }); + expect(xaiUrl?.origin).toBe("https://auth.x.ai"); + expect(xaiUrl?.searchParams.get("redirect_uri")).toBe( + "http://127.0.0.1:1456/callback", + ); + expect(xaiUrl?.searchParams.get("scope")).toContain("grok-cli:access"); + }); + + test("exchanges a loopback code for tokens against the provider's own token endpoint", async () => { + const originalFetch = globalThis.fetch; + const requestedURLs: string[] = []; + globalThis.fetch = (async (input: string) => { + requestedURLs.push(input); + return new Response( + JSON.stringify({ + access_token: "at_1", + refresh_token: "rt_1", + expires_in: 3600, + }), + { status: 200 }, + ); + }) as unknown as typeof fetch; + try { + const result = await CONNECTOR_REGISTRY["codex"]?.oauth?.exchange({ + code: "abc", + codeVerifier: "ver", + redirectUri: "http://localhost:1455/auth/callback", + clientId: "app_1", + }); + expect(result).toMatchObject({ + ok: true, + apiKey: "at_1", + refreshToken: "rt_1", + }); + expect(requestedURLs).toEqual(["https://auth.openai.com/oauth/token"]); + expect(requestedURLs[0]).toContain("auth.openai.com"); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("reports a failed loopback exchange without throwing", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response("nope", { status: 400 })) as unknown as typeof fetch; + try { + const result = await CONNECTOR_REGISTRY["xai-oauth"]?.oauth?.exchange({ + code: "abc", + codeVerifier: "ver", + redirectUri: "http://127.0.0.1:1456/callback", + clientId: "client_1", + }); + expect(result?.ok).toBe(false); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("includes the tool connectors with the right feedsTools", () => { + expect(CONNECTOR_REGISTRY["granola"]?.feedsTools).toEqual([ + "@corbits/granola-tools", + ]); + expect(CONNECTOR_REGISTRY["manus"]?.feedsTools).toEqual([ + "@corbits/manus-tools", + ]); + expect(CONNECTOR_REGISTRY["manus"]?.credentialPlugin).toBe( + "http-x-manus-api-key", + ); + expect(CONNECTOR_REGISTRY["manus"]?.authKind).toBe("api-key"); + expect(CONNECTOR_REGISTRY["manus"]?.icon).toBeUndefined(); + expect(CONNECTOR_REGISTRY["exa"]?.feedsTools).toEqual([ + "@corbits/web-search-tools", + ]); + expect(CONNECTOR_REGISTRY["scrapecreators"]?.feedsTools).toEqual([ + "@corbits/reddit-tools", + ]); + expect(CONNECTOR_REGISTRY["scrapecreators"]?.displayName).toBe( + "ScrapeCreators", + ); + expect(CONNECTOR_REGISTRY["linear"]?.feedsTools).toEqual([ + "@corbits/linear-tools", + ]); + expect(CONNECTOR_REGISTRY["github"]?.feedsTools).toEqual([ + "@corbits/github-tools", + ]); + }); +}); + +describe("granola-webhook", () => { + test("is a webhook-secret connector with no probe, no oauth, and no feedsTools", () => { + const descriptor = CONNECTOR_REGISTRY["granola-webhook"]; + expect(descriptor?.authKind).toBe("webhook-secret"); + expect(descriptor?.probe).toBeUndefined(); + expect(descriptor?.oauth).toBeUndefined(); + expect(descriptor?.feedsTools).toEqual([]); + expect(descriptor?.credentialPlugin).toBe("http"); + }); +}); + +describe("connectorDescriptors", () => { + test("returns every registry entry", () => { + expect(connectorDescriptors(CONNECTOR_REGISTRY).length).toBe( + Object.keys(CONNECTOR_REGISTRY).length, + ); + }); + + test("each connector mediates through the header plugin its API actually expects", () => { + expect(CONNECTOR_REGISTRY["linear"]?.credentialPlugin).toBe( + "http-raw-authorization", + ); + expect(CONNECTOR_REGISTRY["exa"]?.credentialPlugin).toBe("http-x-api-key"); + expect(CONNECTOR_REGISTRY["scrapecreators"]?.credentialPlugin).toBe( + "http-x-api-key", + ); + expect(CONNECTOR_REGISTRY["manus"]?.credentialPlugin).toBe( + "http-x-manus-api-key", + ); + const nonBearer = new Set(["linear", "exa", "scrapecreators", "manus"]); + for (const [id, descriptor] of Object.entries(CONNECTOR_REGISTRY)) { + if (nonBearer.has(id)) continue; + expect(descriptor.credentialPlugin).toBe("http"); + } + }); +}); + +describe("gmail connector", () => { + test("is a pure oauth-code connector with a hosted-app env pair", () => { + const descriptor = CONNECTOR_REGISTRY["gmail"]; + expect(descriptor?.authKind).toBe("oauth-code"); + expect(descriptor?.credentialPlugin).toBe("http"); + expect(descriptor?.oauth).toBeDefined(); + expect(descriptor?.oauth?.clientId?.({ gmailClientId: "client-1" })).toBe( + "client-1", + ); + expect( + descriptor?.oauth?.clientSecret?.({ gmailClientSecret: "secret-1" }), + ).toBe("secret-1"); + expect(descriptor?.oauth?.clientId?.({})).toBeUndefined(); + }); + + test("asks Google for offline access and the gmail scope with PKCE", () => { + const descriptor = CONNECTOR_REGISTRY["gmail"]; + const url = descriptor?.oauth?.buildAuthorizeUrl({ + callbackUrl: "https://bench.example.com/callback", + state: "state-1", + codeChallenge: "challenge-1", + clientId: "client-1", + }); + if (url === undefined) throw new Error("no authorize url"); + expect(url.origin).toBe("https://accounts.google.com"); + expect(url.searchParams.get("client_id")).toBe("client-1"); + expect(url.searchParams.get("redirect_uri")).toBe( + "https://bench.example.com/callback", + ); + expect(url.searchParams.get("response_type")).toBe("code"); + expect(url.searchParams.get("scope")).toContain("gmail"); + expect(url.searchParams.get("state")).toBe("state-1"); + expect(url.searchParams.get("access_type")).toBe("offline"); + expect(url.searchParams.get("code_challenge")).toBe("challenge-1"); + expect(url.searchParams.get("code_challenge_method")).toBe("S256"); + }); +}); + +describe("MCP_PRESETS", () => { + test("lists only verified zero-configuration remote MCP servers", () => { + const slugs = MCP_PRESETS.map((preset) => preset.slug).sort(); + expect(slugs).toEqual( + [ + "attio", + "canva", + "exa", + "github-mcp", + "granola", + "linear", + "notion", + "posthog", + "railway", + "sentry", + "sumble", + ].sort(), + ); + for (const preset of MCP_PRESETS) { + expect(() => new URL(preset.url)).not.toThrow(); + expect(preset.url.startsWith("https://")).toBe(true); + } + }); + + test("Exa is keyless, GitHub MCP is token, every other preset uses OAuth", () => { + expect(mcpPresetBySlug(MCP_PRESETS, "exa")?.connectionMode).toBe("keyless"); + expect(mcpPresetBySlug(MCP_PRESETS, "github-mcp")?.connectionMode).toBe( + "token", + ); + for (const preset of MCP_PRESETS) { + if (preset.slug === "exa" || preset.slug === "github-mcp") continue; + expect(preset.connectionMode).toBe("oauth"); + } + }); + + test("GitHub MCP is a token preset that never shadows the github connector", () => { + const preset = mcpPresetBySlug(MCP_PRESETS, "github-mcp"); + expect(preset?.displayName).toBe("GitHub MCP"); + expect(preset?.url).toBe("https://api.githubcopilot.com/mcp/"); + // GitHub's MCP server accepts a personal access token as a bearer but + // offers no dynamic client registration, so OAuth can't complete here. + expect(preset?.connectionMode).toBe("token"); + expect(preset?.docsUrl).toBe("https://github.com/settings/tokens"); + expect(preset?.tokenSteps?.length).toBeGreaterThanOrEqual(2); + // The native `github` REST connector (PAT/OAuth-App) stays its own + // card: no nativeConnectorId hides it, and neither "github" the slug + // nor "GitHub" the display name resolves to this preset. + expect(preset?.nativeConnectorId).toBeUndefined(); + expect(CONNECTOR_REGISTRY["github"]).toBeDefined(); + expect(mcpPresetByName(MCP_PRESETS, "github")).toBeUndefined(); + expect(mcpPresetByName(MCP_PRESETS, "GitHub")).toBeUndefined(); + expect(mcpPresetByName(MCP_PRESETS, "github mcp")?.slug).toBe("github-mcp"); + }); + + test("uses Sumble's OAuth MCP host, not its product-page URL", () => { + expect(mcpPresetBySlug(MCP_PRESETS, "sumble")?.url).toBe( + "https://mcp.sumble.com/", + ); + expect(mcpPresetBySlug(MCP_PRESETS, "sumble")?.connectionMode).toBe( + "oauth", + ); + }); + + test("keeps every verified endpoint exact", () => { + expect( + Object.fromEntries( + MCP_PRESETS.map((preset) => [preset.slug, preset.url]), + ), + ).toEqual({ + granola: "https://mcp.granola.ai/mcp", + exa: "https://mcp.exa.ai/mcp", + "github-mcp": "https://api.githubcopilot.com/mcp/", + linear: "https://mcp.linear.app/mcp", + notion: "https://mcp.notion.com/mcp", + sentry: "https://mcp.sentry.dev/mcp", + attio: "https://mcp.attio.com/mcp", + railway: "https://mcp.railway.com", + posthog: "https://mcp.posthog.com/mcp", + sumble: "https://mcp.sumble.com/", + canva: "https://mcp.canva.com/mcp", + }); + }); + + test("every preset's nativeConnectorId names a real registry entry", () => { + for (const preset of MCP_PRESETS) { + if (preset.nativeConnectorId === undefined) continue; + expect(CONNECTOR_REGISTRY[preset.nativeConnectorId]).toBeDefined(); + } + }); + + test("MCP_PRESET_CONNECTOR_IDS matches the presets' native ids", () => { + expect(new Set(MCP_PRESET_CONNECTOR_IDS)).toEqual( + new Set(["granola", "exa", "linear"]), + ); + }); + + test("mcpPresetByName resolves by slug or display name, case-insensitively", () => { + expect(mcpPresetByName(MCP_PRESETS, "Exa")?.slug).toBe("exa"); + expect(mcpPresetByName(MCP_PRESETS, "EXA")?.slug).toBe("exa"); + expect(mcpPresetByName(MCP_PRESETS, "granola")?.slug).toBe("granola"); + expect(mcpPresetByName(MCP_PRESETS, "nope")).toBeUndefined(); + }); + + test("mcpPresetBySlug returns undefined for an unknown slug", () => { + expect(mcpPresetBySlug(MCP_PRESETS, "not-a-preset")).toBeUndefined(); + }); + + test("Canva is a DCR-verified OAuth preset with no simple-icons mark yet", () => { + expect(mcpPresetBySlug(MCP_PRESETS, "canva")?.connectionMode).toBe("oauth"); + expect(mcpPresetBySlug(MCP_PRESETS, "canva")?.icon).toBeUndefined(); + }); + + test("Canva lists the 16 live PRM OAuth scopes; other presets omit oauthScopes", () => { + expect(mcpPresetBySlug(MCP_PRESETS, "canva")?.oauthScopes).toEqual([ + "profile:read", + "design:meta:read", + "design:content:write", + "design:content:read", + "folder:read", + "folder:write", + "brandtemplate:content:read", + "brandtemplate:meta:read", + "brandtemplate:content:write", + "comment:write", + "comment:read", + "asset:read", + "asset:write", + "brandkit:read", + "help:answers:read", + "help:answers:write", + ]); + for (const preset of MCP_PRESETS) { + if (preset.slug === "canva") continue; + expect("oauthScopes" in preset).toBe(false); + } + }); +}); diff --git a/packages/connections/src/registry.ts b/packages/connections/src/registry.ts index 0c0ffe4761..051bcf6615 100644 --- a/packages/connections/src/registry.ts +++ b/packages/connections/src/registry.ts @@ -1,13 +1,18 @@ -// The browser-safe half of the Connections surface: the connector -// descriptor shape and the generic helpers a caller needs to work with a -// registry built from it. This package no longer bakes in any concrete -// connector set — CL-7384 moved every actual descriptor (GitHub, Gmail, -// Hugging Face, OpenRouter, the inference providers, ...) out to -// `templates/connectors.ts`, the product-data package a build supplies. -// A caller builds its own registry with `createConnectorRegistry` and -// passes it into every route factory that needs one — never a default -// baked in here, which would be exactly the fallback this package's -// callers are told never to leave lying around. +// The connector registry: the descriptor shape, the generic helpers a +// caller needs to work with a registry built from it, and Workbench's +// product-supplied connector set — every api-key connector this build +// actually ships (inference providers plus tool-credential connectors). +// CL-8156 folded the concrete set back in here from +// `templates/connectors.ts` (CL-7384 had split it out) now that +// `@workbench/templates` no longer exists to own it: a caller (the hub, +// the web app) imports `CONNECTOR_REGISTRY` from this module and passes +// it into the route factories that need one. +// +// The eight non-OAuth inference providers come straight from +// `PROVIDER_TEST_CONFIG` — the one source of provider metadata, +// re-exported here rather than duplicated. OpenRouter and Hugging Face +// are the two OAuth inference providers, wired below through the same +// `oauth` config shape `oauth-routes.ts` reads. export { missingCredentialDetail, parseMissingCredentialDetail, @@ -20,12 +25,81 @@ export type { OAuthExchangeResult, } from "./descriptor"; import type { ConnectorDescriptor } from "./descriptor"; +import { + OLLAMA_PLACEHOLDER_SECRET, + PROVIDER_TEST_CONFIG, + testProviderCredential, + type SupportedCredentialProvider, +} from "./credential-test"; +import { + exchangeCodeForToken, + HUGGINGFACE_AUTHORIZE_URL, + HUGGINGFACE_SCOPE, +} from "./huggingface-connect"; +import { + exchangeCodeForGoogleToken, + GMAIL_SCOPE, + GOOGLE_AUTHORIZE_URL, +} from "./gmail-connect"; +// simple-icons ships one brand per named export, tree-shaken by any bundler +// that respects its `sideEffects: false` — importing only the brands this +// registry actually has a listing for pulls in only those icons' data, not +// the whole ~3000-brand package (CC0-1.0 licensed — see the package's own +// LICENSE — so redistributing these marks needs no separate clearance). +// Granola and ScrapeCreators have no simple-icons listing (CL-6215's +// plugins-directory rebuild); OpenAI, xAI, Groq, and Opencode Zen have none +// either (CL-6258's connections logos) — those descriptors carry no `icon`, +// so a caller renders their monochrome initial tile instead. Exa publishes +// its own mark in its official brand kit, used below. Google's mark +// here is Gemini's, not the generic Google "G" — the model brand a person +// actually recognizes from connecting an AI provider, matching +// models.dev's own convention for this row. +import { + siAnthropic, + siDeepseek, + siGithub, + siGmail, + siGooglegemini, + siHuggingface, + siLinear, + siMistralai, + siOllama, + siOpenrouter, +} from "simple-icons"; +import { + CODEX_AUTHORIZE_EXTRA_PARAMS, + CODEX_AUTHORIZE_URL, + CODEX_CLIENT_ID, + CODEX_REDIRECT_URI, + CODEX_SCOPES, + CODEX_TOKEN_URL, +} from "@corbits/codex-provider/constants"; +import { + XAI_AUTHORIZE_URL, + XAI_CLIENT_ID, + XAI_REDIRECT_URI, + XAI_SCOPES, + XAI_TOKEN_URL, +} from "@corbits/xai-provider/constants"; +import { exchangeCodeForKey, OPENROUTER_AUTH_URL } from "./openrouter-connect"; +import { + exchangeCodeForGithubToken, + GITHUB_AUTHORIZE_URL, +} from "./github-connect"; +import { + testExaCredential, + testGitHubCredential, + testGranolaCredential, + testLinearCredential, + testManusCredential, + testScrapeCreatorsCredential, +} from "./probes"; export type ConnectorRegistry = Readonly>; /** Identity function with a name — lets a caller building its own - * connector set (e.g. `templates/connectors.ts`) get `ConnectorRegistry`'s - * type checking on the literal without importing the type by hand. */ + * connector set get `ConnectorRegistry`'s type checking on the literal + * without importing the type by hand. */ export function createConnectorRegistry( connectors: Readonly>, ): ConnectorRegistry { @@ -37,3 +111,597 @@ export function connectorDescriptors( ): readonly ConnectorDescriptor[] { return Object.values(registry); } + +// Decodes the ChatGPT account id out of a Codex id_token (a JWT payload +// claim), mirroring `@corbits/codex-provider`'s `accountIdFromIdToken` +// without pulling that package (and its oauth-core dependency graph) into +// this browser-shipped registry. Undefined on any malformed input — the +// account id is a label, never a gate. +function chatgptAccountIdFromIdToken(idToken: string): string | undefined { + const payload = idToken.split(".")[1]; + if (payload === undefined) return undefined; + let claims: Record; + try { + const base64 = payload.replaceAll("-", "+").replaceAll("_", "/"); + claims = JSON.parse( + atob(base64.padEnd(Math.ceil(base64.length / 4) * 4, "=")), + ) as Record; + } catch { + // report-error-ignore: a malformed id_token payload degrades to "no + // account id", never an incident. + return undefined; + } + if (typeof claims.chatgpt_account_id === "string") { + return claims.chatgpt_account_id; + } + const auth: unknown = claims["https://api.openai.com/auth"]; + if ( + typeof auth === "object" && + auth !== null && + "chatgpt_account_id" in auth + ) { + const nested = auth.chatgpt_account_id; + if (typeof nested === "string") return nested; + } + return undefined; +} + +const INFERENCE_PROVIDER_DOCS_URL: Readonly< + Record +> = { + anthropic: "https://console.anthropic.com/settings/keys", + openai: "https://platform.openai.com/api-keys", + "google-genai": "https://aistudio.google.com/apikey", + xai: "https://console.x.ai", + "xai-oauth": "https://x.ai", + codex: "https://developers.openai.com/codex", + "opencode-zen": "https://opencode.ai/zen", + groq: "https://console.groq.com/keys", + deepseek: "https://platform.deepseek.com/api_keys", + mistral: "https://console.mistral.ai/api-keys", + openrouter: "https://openrouter.ai", + huggingface: "https://huggingface.co/settings/tokens", + ollama: "https://ollama.com", +}; + +// Ollama's onboarding URL field default — the local-machine origin +// Ollama listens on out of the box, before anyone points it at a +// tailscale-tunneled or otherwise remote instance. +const OLLAMA_DEFAULT_URL = "http://localhost:11434"; + +const EXA_ICON = { + path: "M150.5 14.1064C150.5 14.3356 150.421 14.5579 150.277 14.736L88.4766 91 150.277 167.264C150.421 167.442 150.5 167.664 150.5 167.894V181C150.5 181.552 150.052 182 149.5 182H1C0.44772 182 0 181.552 0 181V0.999995C0 0.44771 0.447715 0 1 0H149.5C150.052 0 150.5 0.447715 150.5 1V14.1064ZM30.4059 162.719H121.728L76.0664 106.326 30.4059 162.719ZM19.2949 100.261V145.787L56.1572 100.261H19.2949ZM19.2949 80.9801H55.5434L19.2949 36.2121V80.9801ZM76.0664 75.6731L121.728 19.281H30.4059L76.0664 75.6731Z", + hex: "0143D9", + viewBox: "0 0 151 182", +} as const; + +// Every inference provider with a simple-icons listing. OpenAI, xAI, Groq, +// and Opencode Zen have none as of simple-icons' current release; those +// four fall through to the monochrome initial tile every iconless +// descriptor already gets. +const INFERENCE_PROVIDER_ICONS: Partial< + Record< + SupportedCredentialProvider, + { readonly path: string; readonly hex: string } + > +> = { + anthropic: { path: siAnthropic.path, hex: siAnthropic.hex }, + "google-genai": { path: siGooglegemini.path, hex: siGooglegemini.hex }, + deepseek: { path: siDeepseek.path, hex: siDeepseek.hex }, + mistral: { path: siMistralai.path, hex: siMistralai.hex }, + ollama: { path: siOllama.path, hex: siOllama.hex }, + openrouter: { path: siOpenrouter.path, hex: siOpenrouter.hex }, + huggingface: { path: siHuggingface.path, hex: siHuggingface.hex }, +}; + +function inferenceProviderDescriptors(): Record { + const entries: Record = {}; + for (const [id, config] of Object.entries(PROVIDER_TEST_CONFIG)) { + if (id === "openrouter" || id === "huggingface") continue; + if (id === "codex" || id === "xai-oauth") continue; + const providerId = id as SupportedCredentialProvider; + // Ollama collects a URL, not a secret — it has no auth layer at all + // (see `credential-test.ts`'s own `ollama` config entry). Every + // other connector's single form field is a real key, probed and + // stored as-is. + const icon = INFERENCE_PROVIDER_ICONS[providerId]; + entries[id] = + providerId === "ollama" + ? { + id, + displayName: config.displayName, + authKind: "api-key", + credentialPlugin: "http", + docsUrl: INFERENCE_PROVIDER_DOCS_URL[providerId], + feedsTools: [], + credentialInputKind: "url", + credentialPlaceholder: OLLAMA_DEFAULT_URL, + probe: (baseURL) => + testProviderCredential({ + provider: providerId, + apiKey: OLLAMA_PLACEHOLDER_SECRET, + baseURL, + }), + ...(icon !== undefined ? { icon } : {}), + } + : { + id, + displayName: config.displayName, + authKind: "api-key", + credentialPlugin: "http", + docsUrl: INFERENCE_PROVIDER_DOCS_URL[providerId], + feedsTools: [], + probe: (apiKey) => + testProviderCredential({ provider: providerId, apiKey }), + ...(icon !== undefined ? { icon } : {}), + }; + } + entries["openrouter"] = { + id: "openrouter", + displayName: PROVIDER_TEST_CONFIG.openrouter.displayName, + authKind: "oauth-pkce", + credentialPlugin: "http", + docsUrl: INFERENCE_PROVIDER_DOCS_URL.openrouter, + feedsTools: [], + icon: { path: siOpenrouter.path, hex: siOpenrouter.hex }, + oauth: { + authorizeUrl: OPENROUTER_AUTH_URL, + usesPKCE: true, + // OpenRouter round-trips its state purely via the connect cookie; + // it never echoes `state` back as a callback query param. + echoesState: false, + deploysDefaultWorkflows: true, + buildAuthorizeUrl: ({ callbackUrl, codeChallenge }) => { + const url = new URL(OPENROUTER_AUTH_URL); + url.searchParams.set("callback_url", callbackUrl); + if (codeChallenge !== undefined) { + url.searchParams.set("code_challenge", codeChallenge); + } + url.searchParams.set("code_challenge_method", "S256"); + return url; + }, + exchange: async ({ code, codeVerifier }) => { + const result = await exchangeCodeForKey({ + code, + codeVerifier: codeVerifier ?? "", + }); + return result.ok ? { ok: true, apiKey: result.key } : result; + }, + }, + }; + entries["huggingface"] = { + id: "huggingface", + displayName: PROVIDER_TEST_CONFIG.huggingface.displayName, + authKind: "oauth-pkce", + credentialPlugin: "http", + docsUrl: INFERENCE_PROVIDER_DOCS_URL.huggingface, + feedsTools: [], + icon: { path: siHuggingface.path, hex: siHuggingface.hex }, + oauth: { + authorizeUrl: HUGGINGFACE_AUTHORIZE_URL, + usesPKCE: true, + // HF echoes `state` back in the callback query — the belt-and- + // suspenders check `oauth-routes.ts` runs before consulting the + // state store at all. + echoesState: true, + deploysDefaultWorkflows: true, + // No client secret (a public app); the flow is disabled with a + // `not_configured` outcome when this key is absent from the env + // bag rather than round-tripping a doomed request. + clientId: (env) => env["huggingfaceClientId"], + buildAuthorizeUrl: ({ callbackUrl, state, codeChallenge, clientId }) => { + const url = new URL(HUGGINGFACE_AUTHORIZE_URL); + if (clientId !== undefined) url.searchParams.set("client_id", clientId); + url.searchParams.set("redirect_uri", callbackUrl); + url.searchParams.set("scope", HUGGINGFACE_SCOPE); + url.searchParams.set("state", state); + if (codeChallenge !== undefined) { + url.searchParams.set("code_challenge", codeChallenge); + } + url.searchParams.set("code_challenge_method", "S256"); + return url; + }, + exchange: async ({ code, codeVerifier, redirectUri, clientId }) => { + if (clientId === undefined) { + return { + ok: false, + message: "huggingface connect is not configured", + }; + } + const result = await exchangeCodeForToken({ + code, + codeVerifier: codeVerifier ?? "", + redirectUri, + clientId, + }); + if (!result.ok) return result; + return result.expiresAt !== undefined + ? { + ok: true, + apiKey: result.accessToken, + expiresAt: result.expiresAt, + } + : { ok: true, apiKey: result.accessToken }; + }, + }, + }; + // The two loopback OAuth providers (CL-7510). Their authorization + // servers only accept the fixed `http://localhost:` redirect URI + // the provider's own CLI registers (`authKind: "oauth-loopback"`), so + // `buildAuthorizeUrl` ignores the hub callback URL the generic + // mechanics would pass and pins the provider's own redirect instead. + // Every endpoint, client id, and scope list come from the provider + // packages' own constants subpath (browser-safe, pure data — the + // exchange closures stay plain `fetch` so the registry can ship in the + // web bundle), so the descriptor can never drift from the values the + // adapter's own refresh path uses. + entries["codex"] = { + id: "codex", + displayName: PROVIDER_TEST_CONFIG.codex.displayName, + authKind: "oauth-loopback", + credentialPlugin: "http", + docsUrl: INFERENCE_PROVIDER_DOCS_URL.codex, + feedsTools: [], + oauth: { + authorizeUrl: CODEX_AUTHORIZE_URL, + usesPKCE: true, + echoesState: true, + deploysDefaultWorkflows: true, + clientId: () => CODEX_CLIENT_ID, + buildAuthorizeUrl: ({ state, codeChallenge, clientId }) => { + const url = new URL(CODEX_AUTHORIZE_URL); + url.searchParams.set("client_id", clientId ?? CODEX_CLIENT_ID); + url.searchParams.set("redirect_uri", CODEX_REDIRECT_URI); + url.searchParams.set("response_type", "code"); + url.searchParams.set("scope", CODEX_SCOPES.join(" ")); + url.searchParams.set("state", state); + if (codeChallenge !== undefined) { + url.searchParams.set("code_challenge", codeChallenge); + } + url.searchParams.set("code_challenge_method", "S256"); + for (const [name, value] of Object.entries( + CODEX_AUTHORIZE_EXTRA_PARAMS, + )) { + url.searchParams.set(name, value); + } + return url; + }, + exchange: async ({ code, codeVerifier }) => { + try { + const response = await fetch(CODEX_TOKEN_URL, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + signal: AbortSignal.timeout(15_000), + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + code_verifier: codeVerifier ?? "", + client_id: CODEX_CLIENT_ID, + redirect_uri: CODEX_REDIRECT_URI, + }).toString(), + }); + if (!response.ok) { + return { + ok: false, + message: `Codex rejected the login (status ${response.status})`, + }; + } + const tokens = (await response.json()) as { + access_token?: unknown; + refresh_token?: unknown; + expires_in?: unknown; + id_token?: unknown; + }; + if (typeof tokens.access_token !== "string") { + return { ok: false, message: "Codex returned no access token" }; + } + const accountId = + typeof tokens.id_token === "string" + ? chatgptAccountIdFromIdToken(tokens.id_token) + : undefined; + return { + ok: true, + apiKey: tokens.access_token, + ...(typeof tokens.refresh_token === "string" + ? { refreshToken: tokens.refresh_token } + : {}), + ...(typeof tokens.expires_in === "number" + ? { + expiresAt: new Date( + Date.now() + tokens.expires_in * 1000, + ).toISOString(), + } + : {}), + ...(accountId !== undefined ? { accountId } : {}), + }; + } catch (cause) { + // report-error-ignore: a failed Codex exchange is surfaced to + // the caller in the returned message, never an incident. + return { + ok: false, + message: `Codex token exchange failed: ${cause instanceof Error ? cause.message : String(cause)}`, + }; + } + }, + }, + }; + entries["xai-oauth"] = { + id: "xai-oauth", + displayName: PROVIDER_TEST_CONFIG["xai-oauth"].displayName, + authKind: "oauth-loopback", + credentialPlugin: "http", + docsUrl: INFERENCE_PROVIDER_DOCS_URL["xai-oauth"], + feedsTools: [], + oauth: { + authorizeUrl: XAI_AUTHORIZE_URL, + usesPKCE: true, + echoesState: true, + deploysDefaultWorkflows: true, + clientId: () => XAI_CLIENT_ID, + buildAuthorizeUrl: ({ state, codeChallenge, clientId }) => { + const url = new URL(XAI_AUTHORIZE_URL); + url.searchParams.set("client_id", clientId ?? XAI_CLIENT_ID); + url.searchParams.set("redirect_uri", XAI_REDIRECT_URI); + url.searchParams.set("response_type", "code"); + url.searchParams.set("scope", XAI_SCOPES.join(" ")); + url.searchParams.set("state", state); + if (codeChallenge !== undefined) { + url.searchParams.set("code_challenge", codeChallenge); + } + url.searchParams.set("code_challenge_method", "S256"); + return url; + }, + exchange: async ({ code, codeVerifier }) => { + try { + const response = await fetch(XAI_TOKEN_URL, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + signal: AbortSignal.timeout(15_000), + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + code_verifier: codeVerifier ?? "", + client_id: XAI_CLIENT_ID, + redirect_uri: XAI_REDIRECT_URI, + }).toString(), + }); + if (!response.ok) { + return { + ok: false, + message: `xAI rejected the login (status ${response.status})`, + }; + } + const tokens = (await response.json()) as { + access_token?: unknown; + refresh_token?: unknown; + expires_in?: unknown; + }; + if (typeof tokens.access_token !== "string") { + return { ok: false, message: "xAI returned no access token" }; + } + return { + ok: true, + apiKey: tokens.access_token, + ...(typeof tokens.refresh_token === "string" + ? { refreshToken: tokens.refresh_token } + : {}), + ...(typeof tokens.expires_in === "number" + ? { + expiresAt: new Date( + Date.now() + tokens.expires_in * 1000, + ).toISOString(), + } + : {}), + }; + } catch (cause) { + // report-error-ignore: a failed xAI exchange is surfaced to + // the caller in the returned message, never an incident. + return { + ok: false, + message: `xAI token exchange failed: ${cause instanceof Error ? cause.message : String(cause)}`, + }; + } + }, + }, + }; + return entries; +} + +export const CONNECTOR_REGISTRY: ConnectorRegistry = createConnectorRegistry({ + ...inferenceProviderDescriptors(), + granola: { + id: "granola", + credentialPlugin: "http", + displayName: "Granola", + authKind: "api-key", + docsUrl: "https://www.granola.ai", + feedsTools: ["@corbits/granola-tools"], + probe: (apiKey) => testGranolaCredential(apiKey), + description: "Pull meeting notes and transcripts into your agents.", + }, + manus: { + id: "manus", + credentialPlugin: "http-x-manus-api-key", + displayName: "Manus", + authKind: "api-key", + docsUrl: "https://open.manus.ai/docs/v2/introduction", + feedsTools: ["@corbits/manus-tools"], + probe: (apiKey) => testManusCredential(apiKey), + description: + "Have Manus run tasks and produce files — including slide decks.", + }, + exa: { + id: "exa", + // Exa authenticates via an `x-api-key` header, not `authorization` — + // the `http` (Bearer) plugin doesn't fit; see the `http-x-api-key` + // provider in `@corbits/credential-providers`. + credentialPlugin: "http-x-api-key", + displayName: "Exa", + authKind: "api-key", + docsUrl: "https://exa.ai", + feedsTools: ["@corbits/web-search-tools"], + probe: (apiKey) => testExaCredential(apiKey), + description: "Neural web search built for agents.", + icon: EXA_ICON, + }, + scrapecreators: { + id: "scrapecreators", + // ScrapeCreators, same as Exa, authenticates via `x-api-key`. + credentialPlugin: "http-x-api-key", + displayName: "ScrapeCreators", + authKind: "api-key", + docsUrl: "https://scrapecreators.com", + feedsTools: ["@corbits/reddit-tools"], + probe: (apiKey) => testScrapeCreatorsCredential(apiKey), + description: "Read Reddit threads and comments.", + }, + linear: { + id: "linear", + credentialPlugin: "http-raw-authorization", + displayName: "Linear", + authKind: "api-key", + docsUrl: "https://linear.app/settings/api", + feedsTools: ["@corbits/linear-tools"], + probe: (apiKey) => testLinearCredential(apiKey), + description: "Read and write issues, projects, and comments.", + icon: { path: siLinear.path, hex: siLinear.hex }, + }, + gmail: { + id: "gmail", + displayName: "Gmail", + // Pure hosted-app OAuth — there is no Gmail equivalent of a PAT + // to paste, so unlike `github` below this connector has no + // api-key arm: unconfigured (`GMAIL_CLIENT_ID`/`GMAIL_CLIENT_SECRET` + // unset) means the card renders "not configured", never a dead + // paste form. The exchanged access token expires in about an + // hour; the refresh token rides `OAuthExchangeResult.refreshToken` + // into the credential row's `refreshSecret` so a refresh sweep + // can keep the connection alive. + authKind: "oauth-code", + credentialPlugin: "http", + docsUrl: "https://developers.google.com/gmail/api", + feedsTools: [], + description: "Read, draft, and send email — sending waits for your ok.", + icon: { path: siGmail.path, hex: siGmail.hex }, + oauth: { + authorizeUrl: GOOGLE_AUTHORIZE_URL, + usesPKCE: true, + echoesState: true, + deploysDefaultWorkflows: false, + clientId: (env) => env["gmailClientId"], + clientSecret: (env) => env["gmailClientSecret"], + buildAuthorizeUrl: ({ callbackUrl, state, codeChallenge, clientId }) => { + const url = new URL(GOOGLE_AUTHORIZE_URL); + if (clientId !== undefined) url.searchParams.set("client_id", clientId); + url.searchParams.set("redirect_uri", callbackUrl); + url.searchParams.set("response_type", "code"); + url.searchParams.set("scope", GMAIL_SCOPE); + url.searchParams.set("state", state); + // `offline` + forced consent is what makes Google issue a + // refresh token at all — without both, a re-connect silently + // returns only another one-hour access token. + url.searchParams.set("access_type", "offline"); + url.searchParams.set("prompt", "consent"); + if (codeChallenge !== undefined) { + url.searchParams.set("code_challenge", codeChallenge); + url.searchParams.set("code_challenge_method", "S256"); + } + return url; + }, + exchange: async ({ + code, + codeVerifier, + redirectUri, + clientId, + clientSecret, + }) => { + if (clientId === undefined || clientSecret === undefined) { + return { + ok: false, + message: "gmail connect is not configured", + }; + } + return exchangeCodeForGoogleToken({ + code, + ...(codeVerifier !== undefined ? { codeVerifier } : {}), + redirectUri, + clientId, + clientSecret, + }); + }, + }, + }, + github: { + id: "github", + // GitHub's REST API accepts a fine-grained PAT — or a GitHub OAuth + // App user token, which is the same Bearer shape — as a Bearer + // token. Absent entirely, github-tools degrades to a lower + // unauthenticated rate limit rather than "not connected" — see its + // tool.ts. `authKind` stays "api-key" (the PAT paste form is + // always available, CL-6386's guaranteed fallback); the `oauth` + // config below is this connector's one exception to "oauth fields + // are oauth-pkce/oauth-code only" — a caller checks + // `GET /oauth-configured`'s `github` entry to decide whether to + // offer the one-click app connect ahead of the paste form, never + // the other way around, so an unconfigured deploy never dead-ends. + credentialPlugin: "http", + displayName: "GitHub", + authKind: "api-key", + docsUrl: "https://github.com/settings/tokens", + feedsTools: ["@corbits/github-tools"], + probe: (apiKey, opts) => + testGitHubCredential(apiKey, undefined, opts?.baseUrl), + description: "Read repos, issues, and pull requests.", + icon: { path: siGithub.path, hex: siGithub.hex }, + oauth: { + authorizeUrl: GITHUB_AUTHORIZE_URL, + usesPKCE: false, + echoesState: true, + deploysDefaultWorkflows: false, + clientId: (env) => env["githubAppClientId"], + clientSecret: (env) => env["githubAppClientSecret"], + buildAuthorizeUrl: ({ callbackUrl, state, clientId }) => { + const url = new URL(GITHUB_AUTHORIZE_URL); + if (clientId !== undefined) url.searchParams.set("client_id", clientId); + url.searchParams.set("redirect_uri", callbackUrl); + url.searchParams.set("scope", "repo"); + url.searchParams.set("state", state); + return url; + }, + exchange: async ({ code, redirectUri, clientId, clientSecret }) => { + if (clientId === undefined || clientSecret === undefined) { + return { + ok: false, + message: "github app connect is not configured", + }; + } + const result = await exchangeCodeForGithubToken({ + code, + redirectUri, + clientId, + clientSecret, + }); + return result.ok ? { ok: true, apiKey: result.key } : result; + }, + }, + }, + "granola-webhook": { + id: "granola-webhook", + // No credential-provider plugin mediates this connector's secret — + // it is minted and encrypted entirely inside + // `@corbits/webhook-triggers` (see `management-routes.ts`), never + // stored as a Credential row. `credentialPlugin` still has to name + // something (`ConnectorDescriptor` requires it uniformly); `http` + // is inert here, kept only so registry-wide invariants (every + // non-bearer connector reads `http`) hold without a special case. + credentialPlugin: "http", + displayName: "Granola inbound webhook", + authKind: "webhook-secret", + docsUrl: "https://www.granola.ai", + // The direction is inverted from the `granola` api-key connector + // above: this issues a signed inbound address for Granola to call, + // feeding nothing — a routine's webhook trigger binding, not a + // tool package's credential handle. + feedsTools: [], + }, +}); From 625b9979e9cb081ce2acf28e514e26bda1a8f6e6 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 10:23:59 -0700 Subject: [PATCH 2/8] feat(hub)!: rename template-blocks deploy route to catalog-blocks The on-demand workflow-deploy route under apps/hub/src/templates/ was never actually workbench-template-specific (CL-7073 generalized it to any workflows/* package) -- the routines page's "Available" section drives it independently of workbench templates. Rename the module and its mount out of the deleted templates/ naming: createTemplateBlockRoutes -> createCatalogBlockRoutes, POST /template-blocks/:assetName/deploy -> POST /catalog-blocks/:assetName/deploy. Claude-Session: https://claude.ai/code/session_01RweuMUskU2nvcFhdckc7EG --- .../block-workflows.ts | 2 +- .../catalog-block-routes.test.ts} | 16 ++++----- .../catalog-block-routes.ts} | 35 +++++++++++-------- .../catalog-blocks.test.ts | 2 +- .../catalog-blocks.ts | 22 ++++++------ apps/hub/src/index.ts | 28 +++++++-------- 6 files changed, 54 insertions(+), 51 deletions(-) rename apps/hub/src/{templates => catalog-blocks}/block-workflows.ts (97%) rename apps/hub/src/{templates/template-block-routes.test.ts => catalog-blocks/catalog-block-routes.test.ts} (94%) rename apps/hub/src/{templates/template-block-routes.ts => catalog-blocks/catalog-block-routes.ts} (69%) rename apps/hub/src/{templates => catalog-blocks}/catalog-blocks.test.ts (97%) rename apps/hub/src/{templates => catalog-blocks}/catalog-blocks.ts (93%) 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..83d03efe59 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -150,7 +150,6 @@ import { createWebhookTriggerRoutes, launchWebhookTrigger, } from "@corbits/webhook-triggers"; -import { createTemplateBlockRoutes } from "./templates/template-block-routes"; import { createWorkflowDetailRoute, createScheduledWorkflowRoutes, @@ -225,6 +224,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 +2128,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 +2196,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({ From 67929db2c3594c280b087dae30774b48e312e6b5 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 10:24:04 -0700 Subject: [PATCH 3/8] feat(artifacts-hub)!: delete the dead template-library module createTemplateLibraryRoutes/seedTemplateLibrary were never mounted in apps/hub and had no remaining caller outside this package once CL-8156 deletes workbench template picking -- dead code, not a migration. Claude-Session: https://claude.ai/code/session_01RweuMUskU2nvcFhdckc7EG --- packages/artifacts-hub/src/index.ts | 16 - .../src/template-library.test.ts | 650 ------------------ .../artifacts-hub/src/template-library.ts | 509 -------------- 3 files changed, 1175 deletions(-) delete mode 100644 packages/artifacts-hub/src/template-library.test.ts delete mode 100644 packages/artifacts-hub/src/template-library.ts diff --git a/packages/artifacts-hub/src/index.ts b/packages/artifacts-hub/src/index.ts index 2bcd61ecc6..b6bc1678dc 100644 --- a/packages/artifacts-hub/src/index.ts +++ b/packages/artifacts-hub/src/index.ts @@ -9,22 +9,6 @@ export { type ArtifactUploadInput, type CreateArtifactRoutesDeps, } from "./routes"; -export { - WORKBENCH_TEMPLATE_ARTIFACT_KIND, - createTemplateLibraryDbStore, - createTemplateLibraryRoutes, - createTemplateLibrarySeeder, - createUnavailableTemplateLibraryRoutes, - seedTemplateLibrary, - type CreateTemplateLibraryRoutesDeps, - type CreateTemplateLibrarySeederArgs, - type SeedTemplateLibraryArgs, - type TemplateLibraryEngine, - type TemplateLibraryEntry, - type TemplateLibrarySeeder, - type TemplateLibraryStore, - type TemplateSeedOutcome, -} from "./template-library"; export { createWorkflowRunAuthenticator, type ResolvedWorkflowRunScope, diff --git a/packages/artifacts-hub/src/template-library.test.ts b/packages/artifacts-hub/src/template-library.test.ts deleted file mode 100644 index a634c2782b..0000000000 --- a/packages/artifacts-hub/src/template-library.test.ts +++ /dev/null @@ -1,650 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { RequireGrant } from "@intx/hub-api"; -import { Hono } from "hono"; - -import { - WORKBENCH_TEMPLATE_ARTIFACT_KIND, - createTemplateLibraryDbStore, - createTemplateLibraryRoutes, - createTemplateLibrarySeeder, - createUnavailableTemplateLibraryRoutes, - seedTemplateLibrary, - type TemplateLibraryEngine, - type TemplateLibrarySeeder, - type TemplateLibraryStore, -} from "./template-library"; - -const TENANT = { id: "tenant_a" }; -const SCOPE = { tenantId: TENANT.id, principalId: "prin_admin" }; - -const allowAll: RequireGrant = () => async (_c, next) => { - await next(); -}; - -type StoredRow = { - id: string; - tenantId: string; - kind: string; - title: string; - content: string; - version: number; - archivedAt: Date | null; - source: Record | null; -}; - -/** In-memory stand-in for the artifacts engine, shaped like the calls - * `seedTemplateLibrary` makes. */ -function memoryEngine(): { - engine: TemplateLibraryEngine; - rows: StoredRow[]; - userEdit: (title: string, content: string) => void; - userArchive: (title: string) => void; -} { - const rows: StoredRow[] = []; - let nextId = 1; - const engine = { - async createArtifact( - _tx: unknown, - args: { - scope: { tenantId: string }; - kind: string; - title: string; - content: string; - source: Record; - }, - ) { - const row: StoredRow = { - id: `art_${nextId++}`, - tenantId: args.scope.tenantId, - kind: args.kind, - title: args.title, - content: args.content, - version: 1, - archivedAt: null, - source: args.source, - }; - rows.push(row); - return row; - }, - async findArtifactByTitle( - _db: unknown, - tenantId: string, - title: string, - kind?: string, - ) { - const row = rows.find( - (r) => - r.tenantId === tenantId && - r.title === title && - r.kind === kind && - r.archivedAt === null, - ); - return row ? { artifactId: row.id, version: row.version } : null; - }, - async getArtifact(_db: unknown, artifactId: string) { - return rows.find((r) => r.id === artifactId) ?? null; - }, - async writeArtifactVersion( - _db: unknown, - args: { artifactId: string; content?: string }, - ) { - const row = rows.find((r) => r.id === args.artifactId); - if (!row || row.archivedAt !== null) throw new Error("not found"); - if (args.content !== undefined) row.content = args.content; - row.version += 1; - return { artifactId: row.id, version: row.version, title: row.title }; - }, - async listArtifacts( - _db: unknown, - tenantId: string, - filters: { kind?: string }, - ) { - return { - rows: rows.filter( - (r) => - r.tenantId === tenantId && - r.kind === filters.kind && - r.archivedAt === null, - ), - nextCursor: null, - }; - }, - async listSeedRows(_db: unknown, tenantId: string, kind: string) { - return rows.filter( - (r) => - r.tenantId === tenantId && - r.kind === kind && - r.source?.origin === "template-library-seed", - ); - }, - async updateArtifactSource( - _db: unknown, - artifactId: string, - source: Record, - ) { - const row = rows.find((r) => r.id === artifactId); - if (!row) throw new Error("not found"); - row.source = source; - }, - async setArtifactArchivedById( - _db: unknown, - artifactId: string, - archived: boolean, - ) { - const row = rows.find((r) => r.id === artifactId); - if (!row) throw new Error("not found"); - row.archivedAt = archived ? (row.archivedAt ?? new Date()) : null; - }, - } as unknown as TemplateLibraryEngine; - const userEdit = (title: string, content: string) => { - const row = rows.find((r) => r.title === title); - if (!row) throw new Error(`no row titled ${title}`); - row.content = content; - row.version += 1; - }; - const userArchive = (title: string) => { - const row = rows.find((r) => r.title === title); - if (!row) throw new Error(`no row titled ${title}`); - row.archivedAt = new Date(); - }; - return { engine, rows, userEdit, userArchive }; -} - -const fakeDb = { - transaction: async (fn: (tx: unknown) => Promise) => fn({}), -} as unknown as Parameters[0]["db"]; - -const CODE_REVIEW_ENTRY = { - id: "code-review", - content: '{"id":"code-review"}', -}; -const GTM_ENTRY = { id: "gtm", content: '{"id":"gtm"}' }; -const ENTRIES = [CODE_REVIEW_ENTRY, GTM_ENTRY]; - -describe("seedTemplateLibrary", () => { - test("seeds each template once under the workbench-template kind", async () => { - const { engine, rows } = memoryEngine(); - const outcomes = await seedTemplateLibrary({ - db: fakeDb, - scope: SCOPE, - entries: ENTRIES, - engine, - }); - expect(outcomes.map((o) => o.outcome)).toEqual(["created", "created"]); - expect(rows).toHaveLength(2); - expect(rows.every((r) => r.kind === WORKBENCH_TEMPLATE_ARTIFACT_KIND)).toBe( - true, - ); - }); - - test("a second boot leaves exactly one entry per template, untouched", async () => { - const { engine, rows } = memoryEngine(); - await seedTemplateLibrary({ - db: fakeDb, - scope: SCOPE, - entries: ENTRIES, - engine, - }); - const second = await seedTemplateLibrary({ - db: fakeDb, - scope: SCOPE, - entries: ENTRIES, - engine, - }); - expect(second.map((o) => o.outcome)).toEqual(["unchanged", "unchanged"]); - expect(rows).toHaveLength(2); - expect(rows.every((r) => r.version === 1)).toBe(true); - }); - - test("a changed shipped manifest revises the existing row, never duplicates it", async () => { - const { engine, rows } = memoryEngine(); - await seedTemplateLibrary({ - db: fakeDb, - scope: SCOPE, - entries: ENTRIES, - engine, - }); - const outcomes = await seedTemplateLibrary({ - db: fakeDb, - scope: SCOPE, - entries: [{ id: "code-review", content: '{"id":"code-review","v":2}' }], - engine, - }); - expect(outcomes).toContainEqual({ id: "code-review", outcome: "revised" }); - expect(rows).toHaveLength(2); - const revised = rows.find((r) => r.title === "code-review"); - expect(revised?.version).toBe(2); - expect(revised?.content).toBe('{"id":"code-review","v":2}'); - }); - - test("a user-edited template is never clobbered by a moved manifest", async () => { - const { engine, rows, userEdit } = memoryEngine(); - await seedTemplateLibrary({ - db: fakeDb, - scope: SCOPE, - entries: ENTRIES, - engine, - }); - userEdit("code-review", '{"id":"code-review","mine":true}'); - const outcomes = await seedTemplateLibrary({ - db: fakeDb, - scope: SCOPE, - entries: [ - { id: "code-review", content: '{"id":"code-review","v":2}' }, - { id: "gtm", content: '{"id":"gtm"}' }, - ], - engine, - }); - expect(outcomes).toContainEqual({ id: "code-review", outcome: "kept" }); - const edited = rows.find((r) => r.title === "code-review"); - expect(edited?.content).toBe('{"id":"code-review","mine":true}'); - }); - - test("a manifest change after an untouched earlier revision still converges", async () => { - const { engine, rows } = memoryEngine(); - await seedTemplateLibrary({ - db: fakeDb, - scope: SCOPE, - entries: ENTRIES, - engine, - }); - await seedTemplateLibrary({ - db: fakeDb, - scope: SCOPE, - entries: [ - { id: "code-review", content: '{"id":"code-review","v":2}' }, - GTM_ENTRY, - ], - engine, - }); - const outcomes = await seedTemplateLibrary({ - db: fakeDb, - scope: SCOPE, - entries: [ - { id: "code-review", content: '{"id":"code-review","v":3}' }, - GTM_ENTRY, - ], - engine, - }); - expect(outcomes).toContainEqual({ id: "code-review", outcome: "revised" }); - expect(rows.find((r) => r.title === "code-review")?.content).toBe( - '{"id":"code-review","v":3}', - ); - }); - - test("a template dropped from the manifest is retired (archived), not deleted", async () => { - const { engine, rows } = memoryEngine(); - await seedTemplateLibrary({ - db: fakeDb, - scope: SCOPE, - entries: ENTRIES, - engine, - }); - const outcomes = await seedTemplateLibrary({ - db: fakeDb, - scope: SCOPE, - entries: [GTM_ENTRY], - engine, - }); - expect(outcomes).toContainEqual({ id: "code-review", outcome: "retired" }); - const retired = rows.find((r) => r.title === "code-review"); - expect(retired?.archivedAt).not.toBeNull(); - expect(rows).toHaveLength(2); - }); - - test("a user-edited template dropped from the manifest is kept visible", async () => { - const { engine, rows, userEdit } = memoryEngine(); - await seedTemplateLibrary({ - db: fakeDb, - scope: SCOPE, - entries: ENTRIES, - engine, - }); - userEdit("code-review", '{"id":"code-review","mine":true}'); - const outcomes = await seedTemplateLibrary({ - db: fakeDb, - scope: SCOPE, - entries: [GTM_ENTRY], - engine, - }); - expect(outcomes).toContainEqual({ id: "code-review", outcome: "kept" }); - expect(rows.find((r) => r.title === "code-review")?.archivedAt).toBeNull(); - }); - - test("a retired template re-added to the manifest is restored to the current content", async () => { - const { engine, rows } = memoryEngine(); - await seedTemplateLibrary({ - db: fakeDb, - scope: SCOPE, - entries: ENTRIES, - engine, - }); - await seedTemplateLibrary({ - db: fakeDb, - scope: SCOPE, - entries: [GTM_ENTRY], - engine, - }); - const outcomes = await seedTemplateLibrary({ - db: fakeDb, - scope: SCOPE, - entries: [ - { id: "code-review", content: '{"id":"code-review","v":2}' }, - GTM_ENTRY, - ], - engine, - }); - expect(outcomes).toContainEqual({ id: "code-review", outcome: "restored" }); - const restored = rows.find((r) => r.title === "code-review"); - expect(restored?.archivedAt).toBeNull(); - expect(restored?.content).toBe('{"id":"code-review","v":2}'); - expect(rows).toHaveLength(2); - }); - - test("a template the user archived themselves stays archived", async () => { - const { engine, rows, userArchive } = memoryEngine(); - await seedTemplateLibrary({ - db: fakeDb, - scope: SCOPE, - entries: ENTRIES, - engine, - }); - userArchive("code-review"); - const outcomes = await seedTemplateLibrary({ - db: fakeDb, - scope: SCOPE, - entries: ENTRIES, - engine, - }); - expect(outcomes).toContainEqual({ id: "code-review", outcome: "kept" }); - expect( - rows.find((r) => r.title === "code-review")?.archivedAt, - ).not.toBeNull(); - }); - - test("a legacy seeded row without a seed marker adopts one when content matches", async () => { - const { engine, rows } = memoryEngine(); - await seedTemplateLibrary({ - db: fakeDb, - scope: SCOPE, - entries: ENTRIES, - engine, - }); - for (const row of rows) { - row.source = { origin: "template-library-seed", templateId: row.title }; - } - const adopt = await seedTemplateLibrary({ - db: fakeDb, - scope: SCOPE, - entries: ENTRIES, - engine, - }); - expect(adopt.map((o) => o.outcome)).toEqual(["unchanged", "unchanged"]); - const moved = await seedTemplateLibrary({ - db: fakeDb, - scope: SCOPE, - entries: [ - { id: "code-review", content: '{"id":"code-review","v":2}' }, - GTM_ENTRY, - ], - engine, - }); - expect(moved).toContainEqual({ id: "code-review", outcome: "revised" }); - }); - - test("a legacy seeded row whose content differs without a marker is preserved", async () => { - const { engine, rows } = memoryEngine(); - await seedTemplateLibrary({ - db: fakeDb, - scope: SCOPE, - entries: ENTRIES, - engine, - }); - for (const row of rows) { - row.source = { origin: "template-library-seed", templateId: row.title }; - } - const edited = rows.find((r) => r.title === "code-review"); - if (!edited) throw new Error("row missing"); - edited.content = '{"id":"code-review","mine":true}'; - const outcomes = await seedTemplateLibrary({ - db: fakeDb, - scope: SCOPE, - entries: [ - { id: "code-review", content: '{"id":"code-review","v":2}' }, - GTM_ENTRY, - ], - engine, - }); - expect(outcomes).toContainEqual({ id: "code-review", outcome: "kept" }); - expect(edited.content).toBe('{"id":"code-review","mine":true}'); - }); - - test("a user-created artifact sharing a template's title is never touched or duplicated", async () => { - const { engine, rows } = memoryEngine(); - rows.push({ - id: "art_user", - tenantId: TENANT.id, - kind: WORKBENCH_TEMPLATE_ARTIFACT_KIND, - title: "code-review", - content: '{"mine":true}', - version: 1, - archivedAt: null, - source: { origin: "member" }, - }); - const outcomes = await seedTemplateLibrary({ - db: fakeDb, - scope: SCOPE, - entries: [CODE_REVIEW_ENTRY], - engine, - }); - expect(outcomes).toEqual([{ id: "code-review", outcome: "kept" }]); - expect(rows).toHaveLength(1); - expect(rows[0]?.content).toBe('{"mine":true}'); - }); -}); - -const OTHER_TENANT = { id: "tenant_b" }; - -describe("createTemplateLibrarySeeder", () => { - test("seeds a tenant the first time it is asked, and not again after", async () => { - let passes = 0; - const seeder = createTemplateLibrarySeeder({ - db: fakeDb, - entries: ENTRIES, - seed: async (args) => { - passes += 1; - return seedTemplateLibrary(args); - }, - engine: memoryEngine().engine, - }); - - await seeder.ensureSeeded(SCOPE); - await seeder.ensureSeeded(SCOPE); - expect(passes).toBe(1); - }); - - test("concurrent first reads share one seed pass", async () => { - let passes = 0; - const seeder = createTemplateLibrarySeeder({ - db: fakeDb, - entries: ENTRIES, - seed: async (args) => { - passes += 1; - await new Promise((resolve) => setTimeout(resolve, 5)); - return seedTemplateLibrary(args); - }, - engine: memoryEngine().engine, - }); - - await Promise.all([seeder.ensureSeeded(SCOPE), seeder.ensureSeeded(SCOPE)]); - expect(passes).toBe(1); - }); - - test("a failed pass never latches: the next read retries instead of giving up", async () => { - let passes = 0; - const seeder = createTemplateLibrarySeeder({ - db: fakeDb, - entries: ENTRIES, - seed: async (args) => { - passes += 1; - if (passes === 1) throw new Error("database is booting"); - return seedTemplateLibrary(args); - }, - engine: memoryEngine().engine, - }); - - await expect(seeder.ensureSeeded(SCOPE)).rejects.toThrow( - "database is booting", - ); - await seeder.ensureSeeded(SCOPE); - expect(passes).toBe(2); - }); - - test("each tenant converges on its own first read — no operator bench involved", async () => { - const seededTenants: string[] = []; - const seeder = createTemplateLibrarySeeder({ - db: fakeDb, - entries: ENTRIES, - seed: async (args) => { - seededTenants.push(args.scope.tenantId); - return seedTemplateLibrary(args); - }, - engine: memoryEngine().engine, - }); - - await seeder.ensureSeeded(SCOPE); - await seeder.ensureSeeded({ - tenantId: OTHER_TENANT.id, - principalId: "prin_b", - }); - expect(seededTenants).toEqual([TENANT.id, OTHER_TENANT.id]); - }); -}); - -type TestEnv = { - Variables: { tenant: { id: string }; principal: { id: string } }; -}; - -function mountRoutes( - store: TemplateLibraryStore, - seeder: TemplateLibrarySeeder, -): Hono { - const app = new Hono(); - app.use("*", async (c, next) => { - c.set("tenant", TENANT); - c.set("principal", { id: SCOPE.principalId }); - await next(); - }); - app.route( - "/library/templates", - createTemplateLibraryRoutes({ - store, - seeder, - requireGrant: allowAll, - log: () => {}, - }), - ); - return app; -} - -const noopSeeder: TemplateLibrarySeeder = { ensureSeeded: async () => {} }; - -const fakeStore: TemplateLibraryStore = { - async list(tenantId) { - return tenantId === TENANT.id ? ENTRIES : []; - }, - async get(tenantId, templateId) { - if (tenantId !== TENANT.id) return null; - return ENTRIES.find((e) => e.id === templateId) ?? null; - }, -}; - -/** The bench the owner actually hit: a tenant nothing ever seeded, on a - * hub that never resolved an operator bench. Reads go through the real - * store over the same memory engine the seeder writes into, so a served - * template proves the read itself converged the shelf. */ -function mountUnseededTenant(): Hono { - const { engine } = memoryEngine(); - return mountRoutes( - createTemplateLibraryDbStore(fakeDb, engine), - createTemplateLibrarySeeder({ db: fakeDb, entries: ENTRIES, engine }), - ); -} - -describe("template library routes", () => { - test("lists the seeded entries", async () => { - const res = await mountRoutes(fakeStore, noopSeeder).request( - "/library/templates", - ); - expect(res.status).toBe(200); - expect(await res.json()).toEqual({ data: ENTRIES }); - }); - - test("fetches one template by id", async () => { - const res = await mountRoutes(fakeStore, noopSeeder).request( - "/library/templates/code-review", - ); - expect(res.status).toBe(200); - expect(await res.json()).toEqual(CODE_REVIEW_ENTRY); - }); - - test("404s an id the library does not hold", async () => { - const res = await mountRoutes(fakeStore, noopSeeder).request( - "/library/templates/standup", - ); - expect(res.status).toBe(404); - }); - - test("a never-seeded tenant's first list read seeds the shelf and serves it", async () => { - const res = await mountUnseededTenant().request("/library/templates"); - expect(res.status).toBe(200); - expect(await res.json()).toEqual({ data: ENTRIES }); - }); - - test("a never-seeded tenant's first template read serves the template instead of 404ing", async () => { - const res = await mountUnseededTenant().request( - "/library/templates/code-review", - ); - expect(res.status).toBe(200); - expect(await res.json()).toEqual(CODE_REVIEW_ENTRY); - }); - - test("a seed the read could not run answers 503, never a 404 that reads as 'no such template'", async () => { - const failing: TemplateLibrarySeeder = { - ensureSeeded: async () => { - throw new Error("artifacts database unreachable"); - }, - }; - const app = mountRoutes(fakeStore, failing); - - const list = await app.request("/library/templates"); - expect(list.status).toBe(503); - const one = await app.request("/library/templates/code-review"); - expect(one.status).toBe(503); - const body = (await one.json()) as { error: { code: string } }; - expect(body.error.code).toBe("unavailable"); - }); -}); - -describe("createUnavailableTemplateLibraryRoutes", () => { - test("both routes answer 503 instead of the mount silently not existing", async () => { - const app = new Hono(); - app.use("*", async (c, next) => { - c.set("tenant", TENANT); - c.set("principal", { id: SCOPE.principalId }); - await next(); - }); - app.route( - "/library/templates", - createUnavailableTemplateLibraryRoutes(allowAll), - ); - - const list = await app.request("/library/templates"); - expect(list.status).toBe(503); - const listBody = (await list.json()) as { error: { code: string } }; - expect(listBody.error.code).toBe("unavailable"); - - const one = await app.request("/library/templates/starter"); - expect(one.status).toBe(503); - }); -}); diff --git a/packages/artifacts-hub/src/template-library.ts b/packages/artifacts-hub/src/template-library.ts deleted file mode 100644 index f0be43df78..0000000000 --- a/packages/artifacts-hub/src/template-library.ts +++ /dev/null @@ -1,509 +0,0 @@ -/** - * The bench library's workbench-template shelf (CL-6344): seeding the - * shipped template manifests in as versioned artifact rows, and the - * tenant-scoped read routes a create flow instantiates from. - * - * What triggers a seed (CL-6458): the read itself. Every bench that can - * read the shelf owns one — the rows are tenant-scoped artifacts, so - * "the tenant being read" is the only honest owner, and a bench created - * long after the hub booted converges the first time its picker opens. - * There is no window to miss and no bench to wait for. - * - * Reconciliation semantics (CL-6400): every boot converges the shelf - * to the shipped manifests without ever fighting a member. The seed - * records the hash of the content it last wrote in the artifact's - * `source` (`seededContentHash`), so a re-seed can tell "the shipped - * manifest moved" (head content still matches the marker — revise) - * from "a member edited this" (head content diverged — keep). A - * template dropped from the manifests is archived (`source.retired`) - * when still seed-owned, and unarchived + converged when re-added; a - * member's own archive (no `retired` flag) is respected and never - * undone. Rows from before the marker existed adopt one when their - * content still matches the shipped manifest, and are otherwise - * preserved — ambiguity never clobbers. - * - * The engine calls are injected the same way `apps/hub`'s - * `artifacts-mount.ts` injects its engine seam, so seeding logic is - * testable without a live Postgres. - */ -import { - artifact, - createArtifact, - findArtifactByTitle, - getArtifact, - listArtifacts, - writeArtifactVersion, - type ArtifactDb, -} from "@corbits/artifacts"; -import { sha256 } from "@intx/crypto"; -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 "@corbits/error-sink"; - -export const WORKBENCH_TEMPLATE_ARTIFACT_KIND = "workbench-template"; - -/** One shelf entry to seed: the template's stable id and its serialized - * manifest. Kept as plain strings so this package never depends on - * `@workbench/templates` — the manifest vocabulary stays there. */ -export type TemplateLibraryEntry = { - readonly id: string; - readonly content: string; -}; - -export type TemplateSeedOutcome = { - readonly id: string; - readonly outcome: - "created" | "unchanged" | "revised" | "kept" | "retired" | "restored"; -}; - -export type SeededTemplateRow = { - readonly id: string; - readonly title: string; - readonly content: string; - readonly archivedAt: Date | null; - readonly source: Record | null; -}; - -export type TemplateLibraryEngine = { - createArtifact: typeof createArtifact; - findArtifactByTitle: typeof findArtifactByTitle; - getArtifact: typeof getArtifact; - writeArtifactVersion: typeof writeArtifactVersion; - listArtifacts: typeof listArtifacts; - listSeedRows: ( - db: ArtifactDb, - tenantId: string, - kind: string, - ) => Promise; - updateArtifactSource: ( - db: ArtifactDb, - artifactId: string, - source: Record, - ) => Promise; - setArtifactArchivedById: ( - db: ArtifactDb, - artifactId: string, - archived: boolean, - ) => Promise; -}; - -const SEED_ORIGIN = "template-library-seed"; - -async function contentHash(content: string): Promise { - return hexEncode(await sha256(content)); -} - -const productionEngine: TemplateLibraryEngine = { - createArtifact, - findArtifactByTitle, - getArtifact, - writeArtifactVersion, - listArtifacts, - async listSeedRows(db, tenantId, kind) { - const rows = await db - .select({ - id: artifact.id, - title: artifact.title, - content: artifact.content, - archivedAt: artifact.archivedAt, - source: artifact.source, - }) - .from(artifact) - .where( - and( - eq(artifact.tenantId, tenantId), - eq(artifact.kind, kind), - sql`${artifact.source}->>'origin' = ${SEED_ORIGIN}`, - ), - ); - return rows.map((row) => ({ - id: row.id, - title: row.title, - content: row.content, - archivedAt: row.archivedAt, - source: isRecord(row.source) ? row.source : null, - })); - }, - async updateArtifactSource(db, artifactId, source) { - await db - .update(artifact) - .set({ source }) - .where(eq(artifact.id, artifactId)); - }, - async setArtifactArchivedById(db, artifactId, archived) { - await db - .update(artifact) - .set({ archivedAt: archived ? new Date() : null }) - .where(eq(artifact.id, artifactId)); - }, -}; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -export type SeedTemplateLibraryArgs = { - db: ArtifactDb; - scope: { tenantId: string; principalId: string }; - entries: readonly TemplateLibraryEntry[]; - engine?: TemplateLibraryEngine; -}; - -function templateIdOf(row: SeededTemplateRow): string { - const templateId = row.source?.templateId; - return typeof templateId === "string" ? templateId : row.title; -} - -function seedMarkerOf(row: SeededTemplateRow): string | undefined { - const marker = row.source?.seededContentHash; - return typeof marker === "string" ? marker : undefined; -} - -function seedRetiredFlagOf(row: SeededTemplateRow): boolean { - return row.source?.retired === true; -} - -function seedSource(templateId: string, contentHash: string) { - return { - origin: SEED_ORIGIN, - templateId, - seededContentHash: contentHash, - }; -} - -async function reconcileEntry( - args: SeedTemplateLibraryArgs, - engine: TemplateLibraryEngine, - entry: TemplateLibraryEntry, - row: SeededTemplateRow | undefined, -): Promise { - const shippedHash = await contentHash(entry.content); - if (row === undefined) { - const titleClash = await engine.findArtifactByTitle( - args.db, - args.scope.tenantId, - entry.id, - WORKBENCH_TEMPLATE_ARTIFACT_KIND, - ); - if (titleClash !== null) { - return "kept"; - } - await args.db.transaction(async (tx) => { - await engine.createArtifact(tx, { - scope: args.scope, - ownerPrincipalId: null, - kind: WORKBENCH_TEMPLATE_ARTIFACT_KIND, - title: entry.id, - content: entry.content, - source: seedSource(entry.id, shippedHash), - }); - }); - return "created"; - } - - const wasSeedRetired = row.archivedAt !== null && seedRetiredFlagOf(row); - if (row.archivedAt !== null && !wasSeedRetired) { - return "kept"; - } - - const headHash = await contentHash(row.content); - const marker = seedMarkerOf(row); - const seedOwnsHead = marker === headHash; - - if (row.content === entry.content) { - if (wasSeedRetired) { - await engine.setArtifactArchivedById(args.db, row.id, false); - } - if (marker !== shippedHash || wasSeedRetired) { - await engine.updateArtifactSource( - args.db, - row.id, - seedSource(entry.id, shippedHash), - ); - } - return wasSeedRetired ? "restored" : "unchanged"; - } - - if (!seedOwnsHead) { - return "kept"; - } - - if (wasSeedRetired) { - await engine.setArtifactArchivedById(args.db, row.id, false); - } - await engine.writeArtifactVersion(args.db, { - scope: args.scope, - artifactId: row.id, - content: entry.content, - }); - await engine.updateArtifactSource( - args.db, - row.id, - seedSource(entry.id, shippedHash), - ); - return wasSeedRetired ? "restored" : "revised"; -} - -async function retireOrphan( - args: SeedTemplateLibraryArgs, - engine: TemplateLibraryEngine, - row: SeededTemplateRow, -): Promise { - if (row.archivedAt !== null) { - return seedRetiredFlagOf(row) ? "retired" : "kept"; - } - const marker = seedMarkerOf(row); - const seedOwnsHead = marker === (await contentHash(row.content)); - if (!seedOwnsHead) { - return "kept"; - } - await engine.updateArtifactSource(args.db, row.id, { - origin: SEED_ORIGIN, - templateId: templateIdOf(row), - seededContentHash: marker, - retired: true, - }); - await engine.setArtifactArchivedById(args.db, row.id, true); - return "retired"; -} - -export async function seedTemplateLibrary( - args: SeedTemplateLibraryArgs, -): Promise { - const engine = args.engine ?? productionEngine; - const seedRows = await engine.listSeedRows( - args.db, - args.scope.tenantId, - WORKBENCH_TEMPLATE_ARTIFACT_KIND, - ); - const rowsByTemplateId = new Map( - seedRows.map((row) => [templateIdOf(row), row]), - ); - const shippedIds = new Set(args.entries.map((entry) => entry.id)); - - const outcomes: TemplateSeedOutcome[] = []; - for (const entry of args.entries) { - const outcome = await reconcileEntry( - args, - engine, - entry, - rowsByTemplateId.get(entry.id), - ); - outcomes.push({ id: entry.id, outcome }); - } - for (const row of seedRows) { - const templateId = templateIdOf(row); - if (shippedIds.has(templateId)) continue; - const outcome = await retireOrphan(args, engine, row); - outcomes.push({ id: templateId, outcome }); - } - return outcomes; -} - -/** Minimal read port the routes need — production wraps the engine db. */ -export type TemplateLibraryStore = { - list(tenantId: string): Promise; - get( - tenantId: string, - templateId: string, - ): Promise; -}; - -export function createTemplateLibraryDbStore( - db: ArtifactDb, - engine: TemplateLibraryEngine = productionEngine, -): TemplateLibraryStore { - return { - async list(tenantId) { - const page = await engine.listArtifacts(db, tenantId, { - kind: WORKBENCH_TEMPLATE_ARTIFACT_KIND, - }); - const entries: TemplateLibraryEntry[] = []; - for (const row of page.rows) { - const full = await engine.getArtifact(db, row.id); - if (full === null) continue; - entries.push({ id: full.title, content: full.content }); - } - return entries; - }, - async get(tenantId, templateId) { - const found = await engine.findArtifactByTitle( - db, - tenantId, - templateId, - WORKBENCH_TEMPLATE_ARTIFACT_KIND, - ); - if (found === null) return null; - const row = await engine.getArtifact(db, found.artifactId); - if (row === null || row.tenantId !== tenantId) return null; - return { id: row.title, content: row.content }; - }, - }; -} - -/** - * What makes a tenant's shelf match the shipped manifests before it is - * read (CL-6458). Seeding is triggered by the read itself rather than by - * a boot-time window: a bench created after the hub booted converges the - * first time anyone opens the picker, in any boot order, with no - * operator bench in the picture. - */ -export type TemplateLibrarySeeder = { - ensureSeeded(scope: { tenantId: string; principalId: string }): Promise; -}; - -export type CreateTemplateLibrarySeederArgs = { - db: ArtifactDb; - entries: readonly TemplateLibraryEntry[]; - engine?: TemplateLibraryEngine; - seed?: typeof seedTemplateLibrary; - log?: (line: string) => void; -}; - -/** - * One reconciliation pass per tenant per process: concurrent first reads - * share a pass, a converged tenant costs nothing on later reads, and a - * failed pass is not remembered, so the next read retries it. Nothing - * here is a cache of the shelf's contents — `seedTemplateLibrary` is - * itself convergent (CL-6400), so a redeploy carrying changed manifests - * reconciles on the new process's first read. - */ -export function createTemplateLibrarySeeder( - args: CreateTemplateLibrarySeederArgs, -): TemplateLibrarySeeder { - const seed = args.seed ?? seedTemplateLibrary; - const log = args.log ?? ((): void => undefined); - const converged = new Set(); - const inFlight = new Map>(); - - async function runPass(scope: { - tenantId: string; - principalId: string; - }): Promise { - const outcomes = - args.engine === undefined - ? await seed({ db: args.db, scope, entries: args.entries }) - : await seed({ - db: args.db, - scope, - entries: args.entries, - engine: args.engine, - }); - for (const outcome of outcomes) { - log( - `template library ${scope.tenantId}: "${outcome.id}" ${outcome.outcome}`, - ); - } - converged.add(scope.tenantId); - } - - return { - async ensureSeeded(scope) { - if (args.entries.length === 0 || converged.has(scope.tenantId)) return; - const running = inFlight.get(scope.tenantId); - if (running !== undefined) return running; - const pass = runPass(scope).finally(() => { - inFlight.delete(scope.tenantId); - }); - inFlight.set(scope.tenantId, pass); - return pass; - }, - }; -} - -export type CreateTemplateLibraryRoutesDeps = { - store: TemplateLibraryStore; - seeder: TemplateLibrarySeeder; - requireGrant: RequireGrant; - /** Where a failed reconciliation's real cause goes; the client sees - * one honest "unavailable", never a 404 that reads as "no such - * template". */ - log: (line: string) => void; -}; - -/** `GET /` lists every seeded template entry; `GET /:templateId` fetches - * one. Both converge the tenant's shelf before reading it. Content - * travels as the seeded string — the client parses it with - * `@workbench/templates`'s manifest schema at its own boundary. */ -export function createTemplateLibraryRoutes( - deps: CreateTemplateLibraryRoutesDeps, -): Hono { - const app = new Hono(); - - async function converge( - tenantId: string, - principalId: string, - ): Promise { - try { - await deps.seeder.ensureSeeded({ tenantId, principalId }); - return null; - } catch (cause) { - deps.log( - `template library seed failed for ${tenantId}: ${cause instanceof Error ? cause.message : String(cause)}`, - ); - return Response.json( - makeErrorEnvelope({ - code: "unavailable", - userMessage: "The template library isn't ready yet", - }), - { status: 503 }, - ); - } - } - - app.get("/", deps.requireGrant("asset:*", "read"), async (c) => { - const tenant = c.get("tenant"); - const failed = await converge(tenant.id, c.get("principal").id); - if (failed !== null) return failed; - const entries = await deps.store.list(tenant.id); - return c.json({ data: entries }); - }); - - app.get("/:templateId", deps.requireGrant("asset:*", "read"), async (c) => { - const tenant = c.get("tenant"); - const failed = await converge(tenant.id, c.get("principal").id); - if (failed !== null) return failed; - const entry = await deps.store.get(tenant.id, c.req.param("templateId")); - if (entry === null) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "Unknown template", - }), - 404, - ); - } - return c.json(entry); - }); - - return app; -} - -/** - * Honest degraded surface when the artifacts plane is not mounted — the - * template library lives in the artifacts engine's db, so it is exactly - * as unavailable as `createUnavailableArtifactRoutes`' routes. Every - * route answers 503; without this mount the paths 404 and a client - * cannot tell "not configured" from "template not seeded". - */ -export function createUnavailableTemplateLibraryRoutes( - requireGrant: RequireGrant, -): Hono { - const app = new Hono(); - const unavailable = (c: { - json: (body: unknown, status: 503) => Response | Promise; - }) => - c.json( - makeErrorEnvelope({ - code: "unavailable", - userMessage: "Artifacts plane is not configured on this hub", - }), - 503, - ); - - app.get("/", requireGrant("asset:*", "read"), unavailable); - app.get("/:templateId", requireGrant("asset:*", "read"), unavailable); - return app; -} From 489bc9a17a6dbef59e0e2cdf84077bdcc519b92e Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 10:24:09 -0700 Subject: [PATCH 4/8] feat(evals)!: delete the install-template eval capability The install-template step, its Target.installTemplate capability, and the github-pr-review-factory case that drove it all rode the workbench-template instantiation path CL-8156 deletes. Deleted rather than ported: the underlying code-review reviewer roster and workflow still deploy natively (agent-directory create + the catalog-blocks route), just never through a "kind of workbench" bundle. Claude-Session: https://claude.ai/code/session_01RweuMUskU2nvcFhdckc7EG --- packages/evals/package.json | 1 - .../src/cases/github-pr-review-factory.ts | 164 ----------------- packages/evals/src/cases/index.ts | 3 - packages/evals/src/index.ts | 2 +- packages/evals/src/runner.test.ts | 64 ------- packages/evals/src/runner.ts | 9 - packages/evals/src/targets/real-target.ts | 168 ------------------ packages/evals/src/targets/world-snapshot.ts | 2 +- packages/evals/src/types.ts | 21 +-- 9 files changed, 3 insertions(+), 431 deletions(-) delete mode 100644 packages/evals/src/cases/github-pr-review-factory.ts diff --git a/packages/evals/package.json b/packages/evals/package.json index c5f0b1972d..1425855d82 100644 --- a/packages/evals/package.json +++ b/packages/evals/package.json @@ -18,7 +18,6 @@ "@corbits/error-sink": "workspace:*", "@corbits/migration-runner": "workspace:*", "@corbits/webhook-triggers": "workspace:*", - "@workbench/templates": "workspace:*", "@intx/agent": "workspace:*", "@intx/db": "workspace:*", "@intx/hub-api": "workspace:*", diff --git a/packages/evals/src/cases/github-pr-review-factory.ts b/packages/evals/src/cases/github-pr-review-factory.ts deleted file mode 100644 index 88a3a5360b..0000000000 --- a/packages/evals/src/cases/github-pr-review-factory.ts +++ /dev/null @@ -1,164 +0,0 @@ -// CL-6322 §8.2 case: "connect this GitHub organization and put every -// PR through an automated review with greybeard, cto, and critique, -// plus suggested fixes." Rewritten (CL-6340) against the product that -// actually shipped, not the chat-driven flow the case was first -// scripted around: -// -// - The reviewer roster is `@corbits/code-review`'s three lenses -// (architecture / correctness / release-risk), installed as agent -// definitions when the code-review template is instantiated from -// the seeded bench library (CL-6344/#140) — never `create_agent` -// × 3 in chat. The human's "greybeard, cto, critique" ask maps -// onto those lenses; the handles below are the shipped ones. -// - Per-repo grants and the per-repo `webhook_trigger` rows are -// minted at repo selection by the connect card's start-reviewing -// step (CL-6345/#142) — never a chat-driven `routine_create`. -// - On a PR event, ONE aggregated comment-only review is posted by -// the workflow run via `github_post_pr_review` (CL-6340/#62), -// free under the per-repo grant, with the reviewer lenses named -// in the body for audit attribution. A merge-class action still -// parks behind explicit human approval — -// `outwardGitHubActionsRespectGrantBoundary` grades both halves -// of that owner ruling together. -// -// Step 2 drives the real install (CL-6404: the `install-template` step -// rides #140's seeded-library instantiation route-for-route) and step -// 4 fires the real ingress route (`fire-webhook`). What still narrates -// instead of acting is the GitHub side: the `/complete` PAT prove and -// start-reviewing's repo listing hit real GitHub until CL-6403's -// baseUrl seam lands — README.md's scoreboard names those precisely. -// The scorers grade the world snapshot, so they go green the moment -// that seam falls — no scorer rewrite needed then. -// -// Pass 1 (this case) stops at "review posted" — outcome-ingest -// (GitHub state flowing back into memory so agents improve) is case -// two and out of scope here. -import { defineEval } from "../define-eval.ts"; -import { - agentDefinitionsHaveToolGrants, - approvalGated, - githubConnectedViaConnectionsLayer, - judge, - noBuildBeforeAnswers, - outwardGitHubActionsRespectGrantBoundary, - reviewCommentsAttributable, - routineCreatedOnlyAfterOk, - suggestedFixesStructurallyValid, - triggerIsWebhookPerPr, - wholeRunInspectable, -} from "../scorers/scorers.ts"; -import { - GITHUB_MERGE_PULL_REQUEST_TOOL, - ROUTINE_CREATE_TOOL, -} from "../scorers/tool-names.ts"; - -const TARGET_REPO = "abklabs/workbench"; - -// The shipped reviewer roster (`@corbits/code-review`'s -// CODE_REVIEW_REVIEWERS): what "greybeard, cto, and critique" actually -// installs. Handles are what the agent-directory create path stores; -// display names are what `aggregateReview` renders into the posted -// review body — the attribution markers the grant-boundary scorer -// checks for. -const REVIEWER_HANDLES = [ - "architecture-reviewer", - "correctness-reviewer", - "release-risk-reviewer", -] as const; -const REVIEWER_DISPLAY_NAMES = [ - "Architecture reviewer", - "Correctness reviewer", - "Release-risk reviewer", -] as const; - -export const githubPrReviewFactoryEval = defineEval({ - name: "github-pr-review-factory", - description: - "'connect this GitHub organization and put every PR through an " + - "automated review with greybeard, cto, and critique, plus " + - "suggested fixes' -> GitHub connected via the connections layer -> " + - "code-review template instantiated from the seeded library (three " + - "reviewer lenses + the github-pinned code-review workflow) -> " + - "per-repo grant and webhook trigger minted at repo selection -> on " + - "a fake pull_request.opened, one aggregated attributable review " + - "with suggestion fences posted free under the grant, while any " + - "merge-class action still parks -> whole run inspectable.", - steps: [ - { - human: - "connect this GitHub organization and put every PR through an " + - "automated review with greybeard, cto, and critique, plus " + - "suggested fixes", - expect: [ - noBuildBeforeAnswers(1), - judge( - "The reply asks only what only the human knows before building " + - "anything — which org/repos, and whether a GitHub connection " + - "already exists — never a plan-approval step (per AGENTS.md " + - "'interview for inputs, rip on construction, gate only at the " + - "boundary').", - ), - ], - }, - { - // The real install (CL-6404): the harness drives #140's - // seeded-library instantiation — manifest read, workbench mint, - // participant creation — through the same routes the picker's - // "Create workbench" uses. Remaining red half: - // `githubConnectedViaConnectionsLayer` needs the Plugins - // `/:connectorId/complete` PAT prove, which still hits real - // GitHub (CL-6403's baseUrl-seam lane). - kind: "install-template", - templateId: "code-review", - expect: [ - githubConnectedViaConnectionsLayer(), - agentDefinitionsHaveToolGrants(REVIEWER_HANDLES), - ], - }, - { - human: `looks good — review ${TARGET_REPO}, wire it up`, - expect: [ - // The shipped trigger wiring is start-reviewing's per-repo - // webhook_trigger mint, not a chat-driven routine_create — so - // routine_create must never fire here, and when it somehow - // does, only after the OK. - routineCreatedOnlyAfterOk(2), - approvalGated([ROUTINE_CREATE_TOOL]), - triggerIsWebhookPerPr(), - judge( - "The reply confirms GitHub is connected, names the three " + - "reviewer lenses, and confirms every PR on the selected repo " + - "now triggers a review — warm, direct, no wizard checklist.", - ), - ], - }, - { - // The harness fires a fake `pull_request.opened` delivery through - // the REAL ingress route against whatever enabled webhook_trigger - // start-reviewing minted (CL-6404's `fireWebhook`). While - // start-reviewing itself stays blocked on the GitHub REST seam - // (CL-6403), no trigger exists and the step honestly records that - // miss, keeping these scorers red rather than crashing the run. - kind: "fire-webhook", - payload: { - action: "opened", - repository: { full_name: TARGET_REPO }, - pull_request: { - number: 101, - html_url: `https://github.com/${TARGET_REPO}/pull/101`, - head: { sha: "f0e1d2c3b4a5968778695a4b3c2d1e0f12345678" }, - }, - }, - expect: [ - reviewCommentsAttributable(REVIEWER_HANDLES), - suggestedFixesStructurallyValid(), - outwardGitHubActionsRespectGrantBoundary( - TARGET_REPO, - GITHUB_MERGE_PULL_REQUEST_TOOL, - REVIEWER_DISPLAY_NAMES, - ), - wholeRunInspectable(), - ], - }, - ], -}); diff --git a/packages/evals/src/cases/index.ts b/packages/evals/src/cases/index.ts index 71ee85ae81..945bb4ee71 100644 --- a/packages/evals/src/cases/index.ts +++ b/packages/evals/src/cases/index.ts @@ -1,17 +1,14 @@ import { aiDailyResearchEval } from "./ai-daily-research.ts"; import { docsOnSdkChangeEval } from "./docs-on-sdk-change.ts"; import { exaTopicDigestEval } from "./exa-topic-digest.ts"; -import { githubPrReviewFactoryEval } from "./github-pr-review-factory.ts"; import type { EvalDefinition } from "../types.ts"; export { aiDailyResearchEval } from "./ai-daily-research.ts"; export { docsOnSdkChangeEval } from "./docs-on-sdk-change.ts"; export { exaTopicDigestEval } from "./exa-topic-digest.ts"; -export { githubPrReviewFactoryEval } from "./github-pr-review-factory.ts"; export const ALL_EVALS: readonly EvalDefinition[] = [ aiDailyResearchEval, docsOnSdkChangeEval, exaTopicDigestEval, - githubPrReviewFactoryEval, ]; diff --git a/packages/evals/src/index.ts b/packages/evals/src/index.ts index e6dca8b43f..872811fcc6 100644 --- a/packages/evals/src/index.ts +++ b/packages/evals/src/index.ts @@ -63,7 +63,7 @@ export { ALL_EVALS, aiDailyResearchEval, docsOnSdkChangeEval, - githubPrReviewFactoryEval, + exaTopicDigestEval, } from "./cases/index.ts"; export type { EvalRunRecord, EvalRunStore } from "./store/store.ts"; export { createPostgresEvalRunStore } from "./store/pg-store.ts"; diff --git a/packages/evals/src/runner.test.ts b/packages/evals/src/runner.test.ts index 4a93dd9d7c..d562135f73 100644 --- a/packages/evals/src/runner.test.ts +++ b/packages/evals/src/runner.test.ts @@ -241,70 +241,6 @@ test("runMatrix still closes the target when a run throws", async () => { expect(closed).toBe(true); }); -test("runEval drives an install-template step through the target's installTemplate", async () => { - const evalDef = defineEval({ - name: "install", - description: "test", - steps: [ - { - kind: "install-template", - templateId: "code-review", - expect: [ - (ctx) => { - expect(ctx.transcript[ctx.turnIndex]?.replyText).toContain( - "installed", - ); - return pass("installed"); - }, - ], - }, - ], - }); - const installed: string[] = []; - const target: Target = { - configName: "cfg", - async sendTurn() { - throw new Error("install-template must never ride sendTurn"); - }, - async installTemplate(templateId) { - installed.push(templateId); - return { - human: `(harness) install template "${templateId}"`, - replyText: `template "${templateId}" installed`, - toolCalls: [], - }; - }, - async close() {}, - }; - - const result = await runEval(evalDef, target); - expect(installed).toEqual(["code-review"]); - expect(result.steps[0]?.scorerReports[0]).toMatchObject({ - name: "installed", - pass: true, - }); -}); - -test("runEval fails loudly when an install-template step meets a target without the capability", async () => { - const evalDef = defineEval({ - name: "install-missing", - description: "test", - steps: [ - { kind: "install-template", templateId: "code-review", expect: [] }, - ], - }); - const target: Target = { - configName: "cfg", - async sendTurn(human) { - return { human, replyText: "unreachable", toolCalls: [] }; - }, - async close() {}, - }; - await expect(runEval(evalDef, target)).rejects.toThrow( - "no installTemplate capability", - ); -}); - test("runEval fires a fire-webhook step against the snapshot's enabled trigger", async () => { const evalDef = defineEval({ name: "fire", diff --git a/packages/evals/src/runner.ts b/packages/evals/src/runner.ts index 68502cd8d3..8e663beff2 100644 --- a/packages/evals/src/runner.ts +++ b/packages/evals/src/runner.ts @@ -34,15 +34,6 @@ async function playStep( if (step.kind === "persona") { return runPersonaStep(step, target, personaCall); } - if (step.kind === "install-template") { - if (target.installTemplate === undefined) { - throw new Error( - `runEval: step installs template "${step.templateId}" but target ` + - `"${target.configName}" has no installTemplate capability`, - ); - } - return [await target.installTemplate(step.templateId)]; - } if (step.kind === "fire-webhook") { if (target.fireWebhook === undefined) { throw new Error( diff --git a/packages/evals/src/targets/real-target.ts b/packages/evals/src/targets/real-target.ts index 06c929bf7e..61fd0811da 100644 --- a/packages/evals/src/targets/real-target.ts +++ b/packages/evals/src/targets/real-target.ts @@ -26,11 +26,6 @@ import { createGitWorkflowPusher } from "./git-workflow-push"; import { createHubAPI, type ApiCall } from "@corbits/hub-api-client"; import { getLogger } from "@intx/log"; import { completeCredentialSetup } from "@workbench/onboarding"; -import { - instantiateWorkbenchTemplate, - parseWorkbenchDefinition, - templateSettingsPatch, -} from "@workbench/templates"; import { signPayload, WEBHOOK_SIGNATURE_HEADER, @@ -674,168 +669,6 @@ export async function bootMyraTarget( ]; } - // The REAL install path (#140): the exact surfaces - // `apps/web/src/instant-agent-create.ts`'s - // `createWorkbenchFromTemplate` drives — seeded-library definition - // read, workbench mint, `instantiateWorkbenchTemplate` over - // HTTP-bound ports — never an eval-only instantiation mechanism. - // The library read is also what seeds this scratch tenant's shelf - // (CL-6458), so a passing install proves convergence on first read. - async function installTemplate(templateId: string): Promise { - const entryRes = await api( - hub.baseUrl, - "GET", - `/api/tenants/${seeded.tenantId}/library/templates/${templateId}`, - undefined, - cookies, - ); - expectStatus(`fetch seeded template "${templateId}"`, entryRes, 200); - const definition = parseWorkbenchDefinition( - stringField(entryRes.data, "content", `template "${templateId}"`), - ); - - // Hostless, exactly as the web create flow mints it: a template - // room has no `definitionId` and nobody is hosted in it. - const createBody: Record = { - kind: "workbench", - name: definition.title, - }; - const createRes = await api( - hub.baseUrl, - "POST", - `/api/tenants/${seeded.tenantId}/chat/workbenches`, - createBody, - cookies, - ); - expectStatus(`create workbench from "${templateId}"`, createRes, 201); - const workbenchId = stringField( - createRes.data, - "id", - `create workbench from "${templateId}"`, - ); - - const result = await instantiateWorkbenchTemplate(definition, { - async listAgentHandles() { - const res = await api( - hub.baseUrl, - "GET", - `/api/tenants/${seeded.tenantId}/workflows/definitions?limit=100`, - undefined, - cookies, - ); - expectStatus("list agent definitions", res, 200); - const rows = arrayField( - res.data, - "data", - "list agent definitions", - ) as { name: string; id: string }[]; - return rows.map((row) => ({ handle: row.name, id: row.id })); - }, - async createParticipantAgent(request) { - const res = await api( - hub.baseUrl, - "POST", - `/api/tenants/${seeded.tenantId}/agent-definitions`, - request, - cookies, - ); - if (res.status !== 200 && res.status !== 201) { - throw new Error( - `create participant agent "${request.handle}" returned ` + - `${String(res.status)}: ${JSON.stringify(res.data)}`, - ); - } - return { - id: stringField(res.data, "id", `created "${request.handle}"`), - }; - }, - async inviteParticipantAgent(id) { - const res = await api( - hub.baseUrl, - "POST", - `/api/tenants/${seeded.tenantId}/chat/workbenches/${workbenchId}/invite`, - { definitionId: id }, - cookies, - ); - if (res.status !== 200 && res.status !== 201) { - throw new Error( - `invite participant agent "${id}" returned ` + - `${String(res.status)}: ${JSON.stringify(res.data)}`, - ); - } - }, - async deployBlockWorkflow(block) { - const res = await api( - hub.baseUrl, - "POST", - `/api/tenants/${seeded.tenantId}/template-blocks/${block.assetName}/deploy`, - undefined, - cookies, - ); - if (res.status !== 200 && res.status !== 201) { - throw new Error( - `deploy template block "${block.assetName}" returned ` + - `${String(res.status)}: ${JSON.stringify(res.data)}`, - ); - } - return { created: res.status === 201 }; - }, - async recordPendingConnections(pendingConnections) { - const res = await api( - hub.baseUrl, - "PATCH", - `/api/tenants/${seeded.tenantId}/chat/workbenches/${workbenchId}/settings`, - templateSettingsPatch(definition.id, pendingConnections), - cookies, - ); - expectStatus("record pending connections", res, 200); - }, - async beginOnboarding(steps) { - for (const step of steps) { - if (step.kind !== "connect-plugin") continue; - const res = await api( - hub.baseUrl, - "POST", - `/api/tenants/${seeded.tenantId}/chat/workbenches/${workbenchId}/onboarding`, - { - kind: "connect-github", - requiredForTemplate: definition.title, - promise: definition.promise, - steps: steps.map(({ title, why }) => ({ title, why })), - }, - cookies, - ); - expectStatus("post the onboarding walkthrough card", res, 201); - } - }, - }); - - // Hub-zero T3 (CL-8114): the hub's workbench-scoped GitHub - // state/start-reviewing routes are deleted with no native - // equivalent yet, so this harness no longer drives the card's - // repo-pick walkthrough — the per-repo grant and - // `webhook_trigger` rows the fire-webhook step needs now come - // from a connections follow-up. The install honestly reports - // zero started triggers until then. - const startedTriggerCount = 0; - if (definition.plugins.required.includes("github")) { - // TODO(connections-follow-up): rebind the repo-pick walkthrough - // to the native tenant-scoped repo-review setup once it exists. - } - - return { - human: `(harness) install template "${templateId}"`, - replyText: - `template "${templateId}" installed into workbench ${workbenchId}: ` + - `created [${result.createdHandles.join(", ")}], ` + - `skipped [${result.skippedHandles.join(", ")}], ` + - `deployed blocks [${result.deployedBlockAssetNames.join(", ")}], ` + - `pending connections [${result.pendingConnections.join(", ")}], ` + - `webhook triggers started: ${String(startedTriggerCount)}`, - toolCalls: [], - }; - } - // Fires a trigger through the REAL ingress route // (`POST /api/webhooks/:triggerId`), HMAC-signed the way any // external sender signs. The signing secret is read off the @@ -902,7 +735,6 @@ export async function bootMyraTarget( return { configName: config.name, sendTurn, - installTemplate, fireWebhook, close: closeAll, ...(captureWorldSnapshot === undefined diff --git a/packages/evals/src/targets/world-snapshot.ts b/packages/evals/src/targets/world-snapshot.ts index feee7c4fd4..0263bf4069 100644 --- a/packages/evals/src/targets/world-snapshot.ts +++ b/packages/evals/src/targets/world-snapshot.ts @@ -31,7 +31,7 @@ import { connectorDescriptors, listMcpServerConnections, } from "@corbits/connections"; -import { CONNECTOR_REGISTRY } from "@workbench/templates/connectors"; +import { CONNECTOR_REGISTRY } from "@corbits/connections/registry"; import type { FakeReceipt, WorldSnapshot } from "../types.ts"; diff --git a/packages/evals/src/types.ts b/packages/evals/src/types.ts index e83cd35cdf..6a9659900b 100644 --- a/packages/evals/src/types.ts +++ b/packages/evals/src/types.ts @@ -145,17 +145,6 @@ export interface PersonaEvalStep { readonly expect: readonly Scorer[]; } -/** A harness step that installs a workbench template through the real - * install path (#140's seeded-library instantiation): fetch the seeded - * manifest, mint the workbench, create the participant agent - * definitions, record pending connections — never a chat narration of - * the install. Requires a `Target` with `installTemplate`. */ -export interface InstallTemplateEvalStep { - readonly kind: "install-template"; - readonly templateId: string; - readonly expect: readonly Scorer[]; -} - /** A harness step that fires the tenant's enabled `webhook_trigger` * through the real ingress route (`POST /api/webhooks/:triggerId`, * HMAC-signed) with `payload` as the delivery body. Requires a `Target` @@ -169,11 +158,7 @@ export interface FireWebhookEvalStep { readonly expect: readonly Scorer[]; } -export type EvalStep = - | ScriptedEvalStep - | PersonaEvalStep - | InstallTemplateEvalStep - | FireWebhookEvalStep; +export type EvalStep = ScriptedEvalStep | PersonaEvalStep | FireWebhookEvalStep; export interface EvalDefinition { readonly name: string; @@ -208,10 +193,6 @@ export interface Target { readonly configName: string; sendTurn(human: string): Promise; snapshotWorld?(): Promise; - /** Installs a seeded workbench template through the real install - * surfaces (library read → workbench mint → participant creation); - * the returned `Turn` records what the install actually did. */ - installTemplate?(templateId: string): Promise; /** Fires one webhook trigger through the real ingress route with a * properly signed delivery; the returned `Turn` records the * accepted launch (`instanceId`). */ From 9c5f3e9008e9c4307e3ba54a0c8f68c9299f2efa Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 10:24:20 -0700 Subject: [PATCH 5/8] feat(web)!: delete workbench template picking; a new workbench is a plain tenant + Myra Deletes the template-picker feature entirely: workbench-templates.ts, workbench-templates-api.ts, the prefab card grid and library read in new-workbench-picker.tsx, and createWorkbenchFromTemplate's instantiation path in instant-agent-create.ts (now just createWorkbench: mint a blank room, optionally invite selected agents, send an opening message). The picker keeps its prompt box, agent picker, and empty- channel button -- the readiness gate (Myra must be deployed before a room can invite anyone) is unchanged. @corbits/chat-ui's Jimmy quick-create row now builds its create-agent request straight off @corbits/jimmy-agent/metadata instead of through the deleted @workbench/templates barrel. Every other @workbench/templates import (WORKFLOW_CATALOG, CONNECTOR_REGISTRY, MCP_PRESETS, ...) is repointed at its native home: @corbits/workflows/catalog and @corbits/connections/{registry,mcp-presets}. Claude-Session: https://claude.ai/code/session_01RweuMUskU2nvcFhdckc7EG --- apps/web/package.json | 1 - apps/web/src/client-bootstrap.ts | 2 +- apps/web/src/connect-service-actions.ts | 7 +- apps/web/src/instant-agent-create.test.ts | 532 +------- apps/web/src/instant-agent-create.ts | 238 +--- apps/web/src/myra-workbench.ts | 2 +- .../src/pages/new-workbench-picker.test.ts | 111 -- apps/web/src/pages/new-workbench-picker.tsx | 205 +-- apps/web/src/pages/plugins-page.tsx | 6 +- apps/web/src/pages/routines-page.tsx | 4 +- apps/web/src/routines-api.ts | 20 + apps/web/src/shell/provider-health-banner.tsx | 2 +- apps/web/src/workbench-templates-api.ts | 115 -- apps/web/src/workbench-templates.ts | 40 - apps/web/test/create-agent-panel.test.tsx | 4 +- apps/web/test/new-workbench-picker.test.tsx | 1110 ----------------- .../test/routines-available-section.test.tsx | 10 +- packages/chat-ui/package.json | 2 +- packages/chat-ui/src/api.ts | 30 +- 19 files changed, 179 insertions(+), 2262 deletions(-) delete mode 100644 apps/web/src/pages/new-workbench-picker.test.ts delete mode 100644 apps/web/src/workbench-templates-api.ts delete mode 100644 apps/web/src/workbench-templates.ts delete mode 100644 apps/web/test/new-workbench-picker.test.tsx 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. - - - - ); - })} -