From e52abd2b94b845a333e86d7fa7a95fbd9ed5f255 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 01:42:43 -0700 Subject: [PATCH] feat(hub-zero): native connector registry and cut delivery join (CL-8126) --- apps/hub/package.json | 4 +- apps/hub/src/hub-zero-t4-registry.test.ts | 177 ++++ apps/hub/src/index.ts | 33 +- apps/hub/src/native-connector-registry.ts | 838 ++++++++++++++++++ .../src/pinned-package-credential-bindings.ts | 2 +- apps/hub/src/templates/catalog-blocks.ts | 4 +- apps/hub/src/workflow-scheduler.ts | 92 +- apps/hub/test/workflow-scheduler.test.ts | 113 +-- bun.lock | 18 +- scripts/checks/hub-workbench-routes.ts | 71 +- .../checks/test/hub-workbench-routes.test.ts | 43 +- 11 files changed, 1143 insertions(+), 252 deletions(-) create mode 100644 apps/hub/src/hub-zero-t4-registry.test.ts create mode 100644 apps/hub/src/native-connector-registry.ts diff --git a/apps/hub/package.json b/apps/hub/package.json index f6d02cc57e..ecef461a9d 100644 --- a/apps/hub/package.json +++ b/apps/hub/package.json @@ -77,12 +77,12 @@ "@corbits/codex-provider": "workspace:*", "@corbits/xai-provider": "workspace:*", "@corbits/hub-api-client": "workspace:*", - "@workbench/templates": "workspace:*", "arktype": "catalog:", "better-auth": "catalog:", "drizzle-orm": "catalog:", "hono": "catalog:", - "postgres": "catalog:" + "postgres": "catalog:", + "simple-icons": "^16.28.0" }, "devDependencies": { "@types/bun": "catalog:", diff --git a/apps/hub/src/hub-zero-t4-registry.test.ts b/apps/hub/src/hub-zero-t4-registry.test.ts new file mode 100644 index 0000000000..df7022c4b7 --- /dev/null +++ b/apps/hub/src/hub-zero-t4-registry.test.ts @@ -0,0 +1,177 @@ +// Hub-zero T4 (CL-8126) red test: the hub's connector surface and +// scheduled-delivery path cut over to native primitives, with zero import +// from the legacy template package. +// +// Part A (structural): `index.ts`, `pinned-package-credential-bindings.ts`, +// and `workflow-scheduler.ts` carry no legacy template import, and the +// delivery join (settings-row read + run-participant join) is gone — +// scheduled runs launch without joining a chat. +// +// Part B (behavioral): the native registry adapter serves the same +// connector set and preset list the routes mount. +// +// Every needle below is built from concatenated fragments so this file +// itself never trips the `check:hub-workbench-routes` done-when scan. +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import path from "node:path"; + +const HUB_SRC = path.join( + import.meta.dir, + "..", + "..", + "..", + "apps", + "hub", + "src", +); + +function readHub(rel: string): string { + return readFileSync(path.join(HUB_SRC, rel), "utf8"); +} + +const LEGACY_PKG = "@work" + "bench/templates"; +const LIST_SETTINGS = "list" + "WorkbenchSettings"; +const GET_SETTINGS = "get" + "WorkbenchSettings"; +const JOIN_DEPS = "Scheduled" + "DeliveryJoinDeps"; +const JOIN_FN = "joinScheduled" + "DefinitionToChat"; +const JOIN_PORT = "join" + "DeliveryChat"; +const JOIN_INPUT = "Join" + "RunParticipantInput"; +const COMMAND_GUARD = "workbench" + "BelongsToTenant"; + +describe("hub-zero T4: no legacy template import in the hub", () => { + for (const rel of [ + "index.ts", + "pinned-package-credential-bindings.ts", + "workflow-scheduler.ts", + ]) { + test(`${rel} imports no legacy template package`, () => { + expect( + readHub(rel).includes(LEGACY_PKG), + `${rel} still imports ${LEGACY_PKG}`, + ).toBe(false); + }); + } +}); + +describe("hub-zero T4: the scheduled delivery join is cut", () => { + test("no settings-row list read survives in the hub", () => { + for (const rel of ["index.ts", "workflow-scheduler.ts"]) { + expect( + readHub(rel).includes(LIST_SETTINGS), + `${rel} still reads ${LIST_SETTINGS}`, + ).toBe(false); + } + }); + + test("no delivery-join seam survives in workflow-scheduler.ts", () => { + const source = readHub("workflow-scheduler.ts"); + for (const needle of [JOIN_DEPS, JOIN_FN, JOIN_PORT, JOIN_INPUT]) { + expect( + source.includes(needle), + `workflow-scheduler.ts still contains ${needle}`, + ).toBe(false); + } + }); + + test("no delivery-join seam survives in index.ts", () => { + const source = readHub("index.ts"); + for (const needle of [JOIN_DEPS, JOIN_FN, JOIN_PORT]) { + expect(source.includes(needle), `index.ts still contains ${needle}`).toBe( + false, + ); + } + }); + + test("the single get-variant read stays inside the command guard", () => { + // T5-owned: `createCommandRoutes`' membership check. List-only would + // strand it; the get-variant is the documented exception. + const source = readHub("index.ts"); + const occurrences = source.split(GET_SETTINGS).length - 1; + expect(occurrences).toBe(1); + expect(source.includes(COMMAND_GUARD)).toBe(true); + }); +}); + +describe("hub-zero T4: the native connector registry", () => { + test("serves the full connector set the routes mount", async () => { + const { CONNECTOR_REGISTRY } = await import("./native-connector-registry"); + const ids = new Set(Object.keys(CONNECTOR_REGISTRY)); + for (const id of [ + "anthropic", + "openai", + "google-genai", + "xai", + "codex", + "xai-oauth", + "groq", + "deepseek", + "mistral", + "openrouter", + "huggingface", + "ollama", + "granola", + "granola-webhook", + "manus", + "exa", + "scrapecreators", + "linear", + "gmail", + "github", + ]) { + expect(ids.has(id), `native registry is missing connector ${id}`).toBe( + true, + ); + } + }); + + test("every descriptor carries the shape the route factories read", async () => { + const { CONNECTOR_REGISTRY } = await import("./native-connector-registry"); + for (const [key, descriptor] of Object.entries(CONNECTOR_REGISTRY)) { + expect(descriptor.id, `${key} id`).toBe(key); + expect(descriptor.displayName.length > 0, `${key} displayName`).toBe( + true, + ); + expect(Array.isArray(descriptor.feedsTools), `${key} feedsTools`).toBe( + true, + ); + } + }); + + test("tool-feeding connectors keep their tool packages", async () => { + const { CONNECTOR_REGISTRY } = await import("./native-connector-registry"); + const feedsToolsFor = (id: string): readonly string[] => + CONNECTOR_REGISTRY[id]?.feedsTools ?? []; + expect(feedsToolsFor("github")).toContain("@corbits/github-tools"); + expect(feedsToolsFor("exa")).toContain("@corbits/web-search-tools"); + expect(feedsToolsFor("granola")).toContain("@corbits/granola-tools"); + expect(feedsToolsFor("linear")).toContain("@corbits/linear-tools"); + expect(feedsToolsFor("manus")).toContain("@corbits/manus-tools"); + }); + + test("serves the curated preset list the MCP mounts read", async () => { + const { MCP_PRESETS } = await import("./native-connector-registry"); + const slugs = new Set(MCP_PRESETS.map((preset) => preset.slug)); + for (const slug of [ + "granola", + "exa", + "linear", + "github-mcp", + "notion", + "sentry", + "attio", + "railway", + "posthog", + "sumble", + "canva", + ]) { + expect(slugs.has(slug), `preset list is missing ${slug}`).toBe(true); + } + for (const preset of MCP_PRESETS) { + expect(preset.url.length > 0, `${preset.slug} url`).toBe(true); + expect(preset.connectionMode.length > 0, `${preset.slug} mode`).toBe( + true, + ); + } + }); +}); diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index facaa01409..75ec41e3ad 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -60,7 +60,6 @@ import { createTurnCancelRegistry, createChatOrchestrator, createChatRoutes, - joinRunParticipant, createDrizzleBlockResponseStore, createDrizzleChatStore, createDrizzleClientIdStore, @@ -160,10 +159,6 @@ import { createWebhookTriggerRoutes, launchWebhookTrigger, } from "@corbits/webhook-triggers"; -import { - deliveryWorkbenchRequiredForWorkflowName, - isConversationalWorkflowName, -} from "@workbench/templates"; import { createTemplateBlockRoutes } from "./templates/template-block-routes"; import { createWorkflowDetailRoute, @@ -172,6 +167,7 @@ import { WORKFLOW_SOURCE_ENTRY, ensureRunSession, recordAgentSessionAtProvision, + isConversationalWorkflowName, } from "@corbits/workflows"; import { createSidecarProvisioner as createE2BSidecarProvisioner, @@ -245,10 +241,7 @@ import { listMcpServerConnections, } from "@corbits/connections"; import type { ServiceConnectedHook } from "@corbits/connections"; -import { - CONNECTOR_REGISTRY, - MCP_PRESETS, -} from "@workbench/templates/connectors"; +import { CONNECTOR_REGISTRY, MCP_PRESETS } from "./native-connector-registry"; import { createProviderHealthPort, createProviderHealthStore, @@ -299,7 +292,6 @@ import { launchScheduledDefinitionFromDb, listScheduledDefinitionsFromDb, runNowScheduledDefinition, - type ScheduledDeliveryJoinDeps, } from "./workflow-scheduler"; import { createToolGrantsForPins } from "./tool-grants"; import { drainHubServer, shutdownHub } from "./shutdown"; @@ -1766,18 +1758,12 @@ export async function createHub(config: HubConfig) { }), }), ); - const scheduledDeliveryJoinDeps: ScheduledDeliveryJoinDeps = { - deliveryChatRequired: deliveryWorkbenchRequiredForWorkflowName, - resolveDeliveryChat: async (tenantId) => { - const rows = await chatStore.listWorkbenchSettings(tenantId); - const first = [...rows].sort((a, b) => - a.workbenchId.localeCompare(b.workbenchId), - )[0]; - return first?.workbenchId; - }, - joinDeliveryChat: (input) => - joinRunParticipant({ store: chatStore }, input), - }; + // Hub-zero T4 (CL-8126): the scheduled delivery join is cut — a + // triggered run launches straight into the runner with no + // settings-row lookup and no chat join. Pre-cutover rows carry no + // migration burden: the join never persisted anything (it read the + // settings registry and joined the run to a chat in memory per + // trigger), so there is no backfill and no default to apply. app.route( `${TENANT_PREFIX}/workflows`, createScheduledWorkflowRoutes({ @@ -1788,7 +1774,7 @@ export async function createHub(config: HubConfig) { }), runNow: async (args) => runNowScheduledDefinition( - { db, sidecarRouter, ...scheduledDeliveryJoinDeps }, + { db, sidecarRouter }, { tenantId: args.tenantId, definitionId: args.definitionId, @@ -2526,7 +2512,6 @@ export async function createHub(config: HubConfig) { launch: launchScheduledDefinitionFromDb({ db, sidecarRouter, - ...scheduledDeliveryJoinDeps, }), ...(config.routineSchedulerPollIntervalMs !== undefined ? { pollIntervalMs: config.routineSchedulerPollIntervalMs } diff --git a/apps/hub/src/native-connector-registry.ts b/apps/hub/src/native-connector-registry.ts new file mode 100644 index 0000000000..54a6897b1a --- /dev/null +++ b/apps/hub/src/native-connector-registry.ts @@ -0,0 +1,838 @@ +// The hub's native connector registry (hub-zero T4, CL-8126): every +// connector and curated MCP preset this build actually ships, built from +// `@corbits/connections`' generic descriptor shape and route factories — a +// hub-local port of the legacy template package's registry, with zero +// import from that package. `@corbits/connections` itself carries no +// default connector set, so the hub composes its own here and passes +// `CONNECTOR_REGISTRY` / `MCP_PRESETS` into the route factories that need +// one (connections, connections/oauth, workflow-connections, mcp-servers, +// the chat orchestrator's connector registry). +// +// Ported one-for-one: descriptor ids, auth kinds, credential plugins, docs +// URLs, `feedsTools` tool packages, probe functions, OAuth configs, icons, +// and the preset list are identical to the legacy source, so the settings +// surface, connect flows, and pinned-package bindings behave exactly as +// before. The one deliberate divergence is documented on +// `chatgptAccountIdFromIdToken` below. +// +// Server-only, on purpose: the hub (bun) serves this to the settings +// surface over HTTP — it never ships in a browser bundle from here. +import { + OLLAMA_PLACEHOLDER_SECRET, + PROVIDER_TEST_CONFIG, + testProviderCredential, + type SupportedCredentialProvider, +} from "@corbits/connections/credential-test"; +import { + createConnectorRegistry, + type ConnectorDescriptor, + type ConnectorRegistry, +} from "@corbits/connections/registry"; +import type { McpPreset } from "@corbits/connections/mcp-presets"; +import { mcpPresetConnectorIds } from "@corbits/connections/mcp-presets"; +import { + exchangeCodeForToken, + HUGGINGFACE_AUTHORIZE_URL, + HUGGINGFACE_SCOPE, +} from "@corbits/connections/huggingface-connect"; +import { + exchangeCodeForGoogleToken, + GMAIL_SCOPE, + GOOGLE_AUTHORIZE_URL, +} from "@corbits/connections/gmail-connect"; + +// Decodes the ChatGPT account id out of a Codex id_token (a JWT payload +// claim), mirroring `@corbits/codex-provider`'s `accountIdFromIdToken` +// without routing the registry through that package's client surface. +// Undefined on any malformed input — the account id is a label, never a +// gate. (The legacy source kept this local so the registry could ship in +// the web bundle; the hub keeps the same local copy so the exchange result +// stays byte-identical through the cutover.) +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" — the account id is a label, never a gate, so there is + // nothing to report. + 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; +} +// 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, + siNotion, + siOllama, + siOpenrouter, + siPosthog, + siRailway, + siSentry, +} 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 "@corbits/connections/openrouter-connect"; +import { + exchangeCodeForGithubToken, + GITHUB_AUTHORIZE_URL, +} from "@corbits/connections/github-connect"; +import { + testExaCredential, + testGitHubCredential, + testGranolaCredential, + testLinearCredential, + testManusCredential, + testScrapeCreatorsCredential, +} from "@corbits/connections/probes"; + +const INFERENCE_PROVIDER_DOCS_URL: 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 (pure data — the exchange closures + // stay plain `fetch`), 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 — the failure IS the result. + 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 — the failure IS the result. + 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: [], + }, +}); + +/** + * 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/apps/hub/src/pinned-package-credential-bindings.ts b/apps/hub/src/pinned-package-credential-bindings.ts index 57681a93c2..eff436b0b3 100644 --- a/apps/hub/src/pinned-package-credential-bindings.ts +++ b/apps/hub/src/pinned-package-credential-bindings.ts @@ -14,7 +14,7 @@ import type { CredentialBinding } from "@intx/types"; import type { ToolPackagePin } from "@intx/types/tool-packages"; import type { PinnedPackageCredentialBindingsFor } from "@corbits/chat"; -import { CONNECTOR_REGISTRY } from "@workbench/templates/connectors"; +import { CONNECTOR_REGISTRY } from "./native-connector-registry"; export type IsConnectorConnected = ( tenantId: string, diff --git a/apps/hub/src/templates/catalog-blocks.ts b/apps/hub/src/templates/catalog-blocks.ts index ce4bd7bf76..19fba4c2f9 100644 --- a/apps/hub/src/templates/catalog-blocks.ts +++ b/apps/hub/src/templates/catalog-blocks.ts @@ -9,8 +9,8 @@ // same as any name outside the catalog entirely. // // Display names come from the native `WORKFLOW_CATALOG` entry -// (`@corbits/workflows`, the package `@workbench/templates` -// re-exports its own catalog mirror from), so the labels served here +// (`@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 diff --git a/apps/hub/src/workflow-scheduler.ts b/apps/hub/src/workflow-scheduler.ts index 1951f56296..1ec69011b0 100644 --- a/apps/hub/src/workflow-scheduler.ts +++ b/apps/hub/src/workflow-scheduler.ts @@ -17,7 +17,6 @@ import { workflowDefinition, workflowDefinitionVersion, } from "@intx/db/schema"; -import { handleFromName, type JoinRunParticipantInput } from "@corbits/chat"; import { reportError } from "@corbits/error-sink"; import { cronMatchesMinute, @@ -37,46 +36,11 @@ export type ScheduledDefinition = { cron: string; }; -export type ScheduledDeliveryJoinDeps = { - deliveryChatRequired?: (name: string) => boolean | Promise; - resolveDeliveryChat?: (tenantId: string) => Promise; - joinDeliveryChat?: (input: JoinRunParticipantInput) => Promise; -}; - -/** - * After a scheduled tick launches, join the run to a chat when the - * catalog says this workflow delivers there. No chat → still launched - * (join omitted). Inbox-mode workflows skip the join. A join throw is the - * caller's to log; this helper rethrows. - */ -export async function joinScheduledDefinitionToChat( - deps: ScheduledDeliveryJoinDeps, - def: ScheduledDefinition, - address: string, -): Promise { - if (def.creatorPrincipalId === null) return; - if ( - deps.deliveryChatRequired !== undefined && - (await deps.deliveryChatRequired(def.name)) !== true - ) { - return; - } - if ( - deps.resolveDeliveryChat === undefined || - deps.joinDeliveryChat === undefined - ) { - return; - } - const chatId = await deps.resolveDeliveryChat(def.tenantId); - if (chatId === undefined) return; - await deps.joinDeliveryChat({ - tenantId: def.tenantId, - workbenchId: chatId, - principalId: def.creatorPrincipalId, - address, - handle: handleFromName(def.name, address), - }); -} +// Hub-zero T4 (CL-8126) cut the scheduled delivery join that lived here: +// resolving a delivery chat from the settings registry and joining the run +// to it after launch. A triggered run now launches straight into the +// runner with no settings-row lookup and no chat join (owner ruling: chat +// is T5-owned). export type WorkflowSchedulerDeps = { listScheduledDefinitions: () => Promise; @@ -230,7 +194,7 @@ export function listScheduledDefinitionsFromDb( const SCHEDULE_TICK_CONTENT = "Scheduled tick."; export function launchScheduledDefinitionFromDb( - deps: NativeWorkflowRoutineTriggerDeps & ScheduledDeliveryJoinDeps, + deps: NativeWorkflowRoutineTriggerDeps, ): WorkflowSchedulerDeps["launch"] { return async (def) => { if (def.creatorPrincipalId === null) return; @@ -242,25 +206,13 @@ export function launchScheduledDefinitionFromDb( if (tenant === undefined) { throw new Error(`no tenant "${def.tenantId}"`); } - const triggered = await triggerNativeWorkflowRoutineRun(deps, { + await triggerNativeWorkflowRoutineRun(deps, { tenantId: def.tenantId, definitionId: def.definitionId, principalId: def.creatorPrincipalId, fromDomain: tenant.domain, content: SCHEDULE_TICK_CONTENT, }); - try { - await joinScheduledDefinitionToChat(deps, def, triggered.address); - } catch (error) { - reportError(error, { - operation: "workflow-scheduler.join-chat", - tenantId: def.tenantId, - extra: { - definitionId: def.definitionId, - address: triggered.address, - }, - }); - } }; } @@ -275,12 +227,11 @@ export type RunNowScheduledDefinitionArgs = { }; /** - * Fire a scheduled definition now, then join the run to a chat the - * same way the poller does. Join failures are reported and do not fail - * the launch — the caller still gets `{ runId }`. + * Fire a scheduled definition now and return its `{ runId }`. No delivery + * join: the run launches straight into the runner (hub-zero T4, CL-8126). */ export async function runNowScheduledDefinition( - deps: NativeWorkflowRoutineTriggerDeps & ScheduledDeliveryJoinDeps, + deps: NativeWorkflowRoutineTriggerDeps, args: RunNowScheduledDefinitionArgs, ): Promise<{ runId: string }> { const triggered = await triggerNativeWorkflowRoutineRun(deps, { @@ -290,28 +241,5 @@ export async function runNowScheduledDefinition( fromDomain: args.fromDomain, content: args.content, }); - try { - await joinScheduledDefinitionToChat( - deps, - { - definitionId: args.definitionId, - tenantId: args.tenantId, - creatorPrincipalId: args.principalId, - definitionAssetId: args.definitionAssetId, - name: args.name, - cron: "", - }, - triggered.address, - ); - } catch (error) { - reportError(error, { - operation: "scheduled-workflow.run-now.join-chat", - tenantId: args.tenantId, - extra: { - definitionId: args.definitionId, - address: triggered.address, - }, - }); - } return { runId: triggered.runId }; } diff --git a/apps/hub/test/workflow-scheduler.test.ts b/apps/hub/test/workflow-scheduler.test.ts index 2a48ade364..f2660c0ece 100644 --- a/apps/hub/test/workflow-scheduler.test.ts +++ b/apps/hub/test/workflow-scheduler.test.ts @@ -1,10 +1,8 @@ // Scheduler poller: cron match → CAS → launch. Skip-missed, no backoff. -import { afterEach, describe, expect, jest, spyOn, test } from "bun:test"; -import * as errorSink from "@corbits/error-sink"; +import { afterEach, describe, expect, jest, test } from "bun:test"; import { createWorkflowScheduler, DEFAULT_WORKFLOW_SCHEDULER_POLL_INTERVAL_MS, - joinScheduledDefinitionToChat, runNowScheduledDefinition, tickWorkflowScheduler, type ScheduledDefinition, @@ -239,63 +237,9 @@ describe("createWorkflowScheduler's setInterval wiring", () => { }); }); -describe("joinScheduledDefinitionToChat", () => { - test("joins the first chat when delivery is required", async () => { - const joined: unknown[] = []; - await joinScheduledDefinitionToChat( - { - deliveryChatRequired: async () => true, - resolveDeliveryChat: async () => "chn_1", - joinDeliveryChat: async (input) => { - joined.push(input); - }, - }, - scheduled(), - "run@example.test", - ); - expect(joined).toEqual([ - { - tenantId: "t1", - workbenchId: "chn_1", - principalId: "user_1", - address: "run@example.test", - handle: "workbench-digest", - }, - ]); - }); - - test("omits join when the tenant has no chat and still returns", async () => { - let joined = 0; - await joinScheduledDefinitionToChat( - { - deliveryChatRequired: async () => true, - resolveDeliveryChat: async () => undefined, - joinDeliveryChat: async () => { - joined += 1; - }, - }, - scheduled(), - "run@example.test", - ); - expect(joined).toBe(0); - }); - - test("omits join when the catalog says inbox delivery", async () => { - let joined = 0; - await joinScheduledDefinitionToChat( - { - deliveryChatRequired: async () => false, - resolveDeliveryChat: async () => "chn_1", - joinDeliveryChat: async () => { - joined += 1; - }, - }, - scheduled(), - "run@example.test", - ); - expect(joined).toBe(0); - }); -}); +// Hub-zero T4 (CL-8126) cut the scheduled delivery join: a triggered run +// launches straight into the runner with no settings-row lookup and no +// chat join, so there is no `joinScheduledDefinitionToChat` left to test. type FakeRunRow = { id: string; @@ -347,61 +291,14 @@ function runNowArgs() { } describe("runNowScheduledDefinition", () => { - test("joins the first chat after a successful trigger", async () => { - const joined: unknown[] = []; + test("launches without a delivery join and returns runId", async () => { const result = await runNowScheduledDefinition( { db: createFakeTriggerDb([LIVE_ANCHOR]) as never, sidecarRouter: { routeMail: () => true } as never, - deliveryChatRequired: async () => true, - resolveDeliveryChat: async () => "chn_1", - joinDeliveryChat: async (input) => { - joined.push(input); - }, }, runNowArgs(), ); expect(result).toEqual({ runId: "wfr_anchor1" }); - expect(joined).toEqual([ - { - tenantId: "t1", - workbenchId: "chn_1", - principalId: "clicker_1", - address: "wfr_anchor1@acme.workbench.test", - handle: "workbench-digest", - }, - ]); - }); - - test("join throw reports and still returns runId", async () => { - const boom = new Error("join failed"); - const report = spyOn(errorSink, "reportError").mockReturnValue("ref_test"); - try { - const result = await runNowScheduledDefinition( - { - db: createFakeTriggerDb([LIVE_ANCHOR]) as never, - sidecarRouter: { routeMail: () => true } as never, - deliveryChatRequired: async () => true, - resolveDeliveryChat: async () => "chn_1", - joinDeliveryChat: async () => { - throw boom; - }, - }, - runNowArgs(), - ); - expect(result).toEqual({ runId: "wfr_anchor1" }); - expect(report).toHaveBeenCalledTimes(1); - expect(report.mock.calls[0]?.[0]).toBe(boom); - expect(report.mock.calls[0]?.[1]).toEqual({ - operation: "scheduled-workflow.run-now.join-chat", - tenantId: "t1", - extra: { - definitionId: "wfd_digest", - address: "wfr_anchor1@acme.workbench.test", - }, - }); - } finally { - report.mockRestore(); - } }); }); diff --git a/bun.lock b/bun.lock index 7c3ac61eff..c95ac7b651 100644 --- a/bun.lock +++ b/bun.lock @@ -103,12 +103,12 @@ "@intx/workflow": "workspace:*", "@intx/workflow-deploy": "workspace:*", "@modelcontextprotocol/sdk": "catalog:", - "@workbench/templates": "workspace:*", "arktype": "catalog:", "better-auth": "catalog:", "drizzle-orm": "catalog:", "hono": "catalog:", "postgres": "catalog:", + "simple-icons": "^16.28.0", }, "devDependencies": { "@types/bun": "catalog:", @@ -3611,8 +3611,18 @@ "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@corbits/artifacts-hub/@corbits/artifacts": ["@corbits/artifacts@github:corbitsdev/corbits-artifacts#dc435ba", { "dependencies": { "@hono/standard-validator": "^0.2.3" }, "peerDependencies": { "@intx/hub-api": "^0.2.2", "@intx/types": "^0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.2", "hono": "^4.12.32", "hono-openapi": "^1.2.0", "postgres": "^3.4.9" } }, "corbitsdev-corbits-artifacts-dc435ba", "sha512-VksIyrrJY9nRke0QyjgqGdW3gLD73Bi3+CzpJWG+l8vRJKdUxVi7ms2Mp95NmJXr4TpVX6i1z4odufX/QBxnSw=="], + + "@corbits/chat-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], + + "@corbits/context-menu/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], + "@corbits/mailbox/hono-openapi": ["hono-openapi@1.3.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.11.2", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-NLVeVkhKZ3drmQNEIPac8HX8Y54uf1hJAgIM/7MfDsaeVVmB+QILWQxx5x3R3NvRHgedcbEbOCGY2uR7WQYyMw=="], + "@corbits/plugins-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], + + "@corbits/settings-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], + "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], @@ -3635,6 +3645,12 @@ "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.9", "", {}, "sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw=="], + "@workbench/hub/@corbits/artifacts": ["@corbits/artifacts@github:corbitsdev/corbits-artifacts#dc435ba", { "dependencies": { "@hono/standard-validator": "^0.2.3" }, "peerDependencies": { "@intx/hub-api": "^0.2.2", "@intx/types": "^0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.2", "hono": "^4.12.32", "hono-openapi": "^1.2.0", "postgres": "^3.4.9" } }, "corbitsdev-corbits-artifacts-dc435ba", "sha512-VksIyrrJY9nRke0QyjgqGdW3gLD73Bi3+CzpJWG+l8vRJKdUxVi7ms2Mp95NmJXr4TpVX6i1z4odufX/QBxnSw=="], + + "@workbench/sidecar/@corbits/oauth-core": ["@corbits/oauth-core@github:corbitsdev/corbits-oauth-core#e1e69e6", { "dependencies": { "arktype": "2.2.3" } }, "corbitsdev-corbits-oauth-core-e1e69e6", "sha512-wUrD73iVyk/Dtb4yRn3hCh6N8syfsEvAkRz4XYqT0FIB3sYDSMYEWHcDMJx9nq64my/HBOeN1J6PHIOtpjhtPg=="], + + "@workbench/web/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], + "ajv-formats/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], "better-call/@better-auth/utils": ["@better-auth/utils@0.5.0", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA=="], diff --git a/scripts/checks/hub-workbench-routes.ts b/scripts/checks/hub-workbench-routes.ts index e78b7c3728..29b1faaf03 100644 --- a/scripts/checks/hub-workbench-routes.ts +++ b/scripts/checks/hub-workbench-routes.ts @@ -1,21 +1,25 @@ -// Hub-zero T3 (CL-8114): the hub mounts no workbench-scoped GitHub-connect -// surface. `@corbits/connections` keeps owning the `createConnectGithubRoutes` -// factory (its own package tests still cover it) — what is gone is the hub -// composition root's `${TENANT_PREFIX}/workbenches` mount of it, whose -// `getTemplateSettings` / `persistSelectedRepos` / `onReviewingStarted` ports -// were the last hub readers of the `workbench_settings` row. GitHub -// connect/disconnect now goes through the native tenant-scoped +// Hub-zero T3 (CL-8114) deleted the hub's workbench-scoped GitHub-connect +// mount; hub-zero T4 (CL-8126) cut the scheduled delivery join and the last +// `@workbench/templates` import. `@corbits/connections` keeps owning the +// `createConnectGithubRoutes` factory (its own package tests still cover +// it). GitHub connect/disconnect goes through the native tenant-scoped // `/api/tenants/:tenantId/connections/*` routes only; the room card's // state/start-reviewing rebind is a connections follow-up (the card renders // its existing no-port disabled framing until then). // -// This check locks the done-when: no `createConnectGithubRoutes` reference -// and no bare `${TENANT_PREFIX}/workbenches` mount in `apps/hub/src`, the -// brief's `workbench-settings|/workbenches.*github|github.*workbench` rg -// empty over `apps/hub/src`, and `listWorkbenchSettings` surviving in -// `apps/hub` only inside the T4-owned scheduled-delivery seam -// (`scheduledDeliveryJoinDeps`), which reads the row for delivery — never -// for GitHub connect. +// This check locks both done-whens: no `createConnectGithubRoutes` +// reference and no bare `${TENANT_PREFIX}/workbenches` mount in +// `apps/hub/src`, the brief's `workbench-settings|/workbenches.*github| +// github.*workbench` rg empty over `apps/hub/src`, no `@workbench/templates` +// import anywhere in `apps/hub/src`, and no `listWorkbenchSettings` at all. +// +// Get/update variants (the T3 critic note): `getWorkbenchSettings` survives +// exactly once, inside `createCommandRoutes`' membership check — the +// T5-owned command guard a file carries the `COMMAND_GUARD_MARKER` for. +// `updateWorkbenchSettings` has no such exception. Any get/update call site +// outside a marker-carrying file is a settings-row reader the cutover +// missed. List-only was never an option: the surviving call site is a get, +// and the check treats each variant on its own terms. import { readFileSync } from "node:fs"; import { emptyReport, @@ -29,11 +33,11 @@ export type SourceFile = { readonly contents: string; }; -/** The T4-owned seam still allowed to read the settings row: scheduled - * delivery resolving the workbench a run belongs to. Any - * `listWorkbenchSettings` call site outside a file containing this marker - * is a new workbench-settings reader the hub-zero cutover missed. */ -const DELIVERY_SEAM_MARKER = "scheduledDeliveryJoinDeps"; +/** The T5-owned command guard allowed to hold the surviving get-variant + * read: `createCommandRoutes`' membership check. Any settings-row get/update + * call site outside a file containing this marker is a reader the hub-zero + * cutover missed. */ +const COMMAND_GUARD_MARKER = "workbenchBelongsToTenant"; /** The brief's done-when rg, per line, case-insensitive. */ const DONE_WHEN_PATTERN = @@ -72,18 +76,37 @@ export function auditHubWorkbenchRoutes( } } } + for (const file of files) { + if (file.contents.includes("@workbench/templates")) { + report.violations.push( + `${file.relPath}: still imports @workbench/templates — ` + + "the hub's connector surface is native now " + + "(./native-connector-registry.ts); the legacy dep must stay deleted", + ); + } + } for (const file of files) { if (!file.contents.includes("listWorkbenchSettings")) continue; - if (file.contents.includes(DELIVERY_SEAM_MARKER)) { + report.violations.push( + `${file.relPath}: listWorkbenchSettings survives — ` + + "T4 cut the scheduled-delivery seam that owned the only list read", + ); + } + for (const file of files) { + const readsSettingsRow = + file.contents.includes("getWorkbenchSettings") || + file.contents.includes("updateWorkbenchSettings"); + if (!readsSettingsRow) continue; + if (file.contents.includes(COMMAND_GUARD_MARKER)) { report.notes.push( - `${file.relPath}: listWorkbenchSettings survives inside the ` + - "T4-owned scheduled-delivery seam only — documented, not a violation", + `${file.relPath}: a settings-row get/update read survives inside ` + + "the T5-owned command guard only — documented, not a violation", ); continue; } report.violations.push( - `${file.relPath}: listWorkbenchSettings outside the T4-owned ` + - "scheduled-delivery seam — a workbench-settings reader hub-zero missed", + `${file.relPath}: a settings-row get/update read outside the ` + + "T5-owned command guard — a workbench-settings reader hub-zero missed", ); } return report; diff --git a/scripts/checks/test/hub-workbench-routes.test.ts b/scripts/checks/test/hub-workbench-routes.test.ts index 3884da970e..6b329c9ba9 100644 --- a/scripts/checks/test/hub-workbench-routes.test.ts +++ b/scripts/checks/test/hub-workbench-routes.test.ts @@ -2,19 +2,22 @@ import { expect, test } from "bun:test"; import { auditHubWorkbenchRoutes } from "../hub-workbench-routes"; const CLEAN_INDEX = `import { createApp } from "@corbits/hub"; -const scheduledDeliveryJoinDeps = {}; -async function resolve() { - const rows = await chatStore.listWorkbenchSettings(tenantId); - return rows; +function workbenchBelongsToTenant() { + return true; +} +async function guard(tenantId: string, chatId: string) { + const row = await chatStore.getWorkbenchSettings(tenantId, chatId); + return row !== undefined && workbenchBelongsToTenant(); } app.route(\`\${TENANT_PREFIX}/chat\`, createChatRoutes({})); `; -test("a hub index with no github mount and only the delivery-seam reader passes", () => { +test("a hub index with the guarded get-variant read and no github mount passes", () => { const report = auditHubWorkbenchRoutes([ { relPath: "apps/hub/src/index.ts", contents: CLEAN_INDEX }, ]); expect(report.violations).toEqual([]); + expect(report.notes.length).toBeGreaterThan(0); }); test("the createConnectGithubRoutes reference is a violation naming it", () => { @@ -58,13 +61,37 @@ test("a done-when rg line in hub src is a violation naming the file", () => { expect(report.violations[0]).toContain("apps/hub/src/other.ts"); }); -test("listWorkbenchSettings outside the delivery seam is a violation", () => { +test("a legacy templates import is a violation naming the package", () => { const report = auditHubWorkbenchRoutes([ { relPath: "apps/hub/src/other.ts", - contents: `await chatStore.listWorkbenchSettings(tenantId);\n`, + contents: `import { CONNECTOR_REGISTRY } from "@workbench/templates/connectors";\n`, }, ]); expect(report.violations).toHaveLength(1); - expect(report.violations[0]).toContain("listWorkbenchSettings"); + expect(report.violations[0]).toContain("@workbench/templates"); +}); + +test("listWorkbenchSettings anywhere is a violation — T4 cut the only seam", () => { + const report = auditHubWorkbenchRoutes([ + { + relPath: "apps/hub/src/index.ts", + contents: `${CLEAN_INDEX}\nawait chatStore.listWorkbenchSettings(tenantId);\n`, + }, + ]); + expect(report.violations.length).toBeGreaterThan(0); + expect(report.violations.join("\n")).toContain("listWorkbenchSettings"); +}); + +test("a get/update read outside the command guard is a violation", () => { + for (const call of [ + "await chatStore.getWorkbenchSettings(tenantId, chatId);", + "await chatStore.updateWorkbenchSettings({ tenantId });", + ]) { + const report = auditHubWorkbenchRoutes([ + { relPath: "apps/hub/src/other.ts", contents: `${call}\n` }, + ]); + expect(report.violations).toHaveLength(1); + expect(report.violations[0]).toContain("command guard"); + } });