From 1094e109ebcfdff3c5a6c9a09dbf4c0f104a07b4 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 12:02:39 -0700 Subject: [PATCH] feat(hub)!: drop the catalog-blocks deploy mount The hub mounts only stock routes plus Corbits libraries; the on-demand catalog-block deploy route (POST /api/tenants/:tenantId/catalog-blocks/ :assetName/deploy) built workflow JSON from workflow packages and wrote an asset directly from apps/hub, which is hub-local product logic the owner ruling forbids. Deletes apps/hub/src/catalog-blocks/* (module, routes, and their tests) and the mount + its inline deployWorkflowSource/workflowDeployer wiring in apps/hub/src/index.ts. The Available section's on-demand Add action has no stock replacement yet, so it is removed along with the client's deployCatalogBlock call; the section is read-only until a deploy path lands on stock rails. The separate GET /workflows/available listing (packages/workflows) is untouched. Deleted tests: - apps/hub/src/catalog-blocks/catalog-block-routes.test.ts - apps/hub/src/catalog-blocks/catalog-blocks.test.ts - apps/web/test/routines-available-section.test.tsx: "Add posts to the catalog-blocks deploy route and the entry disappears from Available" (route deleted); the disabled-Add-button assertions in the missing- connection test were folded into a read-only reason+link check. --- .../hub/src/catalog-blocks/block-workflows.ts | 62 ---- .../catalog-block-routes.test.ts | 196 ----------- .../catalog-blocks/catalog-block-routes.ts | 112 ------- .../src/catalog-blocks/catalog-blocks.test.ts | 61 ---- apps/hub/src/catalog-blocks/catalog-blocks.ts | 303 ------------------ apps/hub/src/index.ts | 201 ------------ .../src/pages/routines-available-strings.ts | 14 +- apps/web/src/pages/routines-page.tsx | 53 +-- apps/web/src/routines-api.ts | 20 -- .../test/routines-available-section.test.tsx | 56 +--- 10 files changed, 9 insertions(+), 1069 deletions(-) delete mode 100644 apps/hub/src/catalog-blocks/block-workflows.ts delete mode 100644 apps/hub/src/catalog-blocks/catalog-block-routes.test.ts delete mode 100644 apps/hub/src/catalog-blocks/catalog-block-routes.ts delete mode 100644 apps/hub/src/catalog-blocks/catalog-blocks.test.ts delete mode 100644 apps/hub/src/catalog-blocks/catalog-blocks.ts diff --git a/apps/hub/src/catalog-blocks/block-workflows.ts b/apps/hub/src/catalog-blocks/block-workflows.ts deleted file mode 100644 index e72625f7ac..0000000000 --- a/apps/hub/src/catalog-blocks/block-workflows.ts +++ /dev/null @@ -1,62 +0,0 @@ -// The source form a catalog workflow deploys as when someone asks for it -// on demand — the instantiate path's block-workflow resolution -// (CL-6405) generalized to any catalog entry with a source package -// under `workflows/` (CL-7073), not just `code-review`. -// -// Runtime wiring (CL-8113, hub-zero T2): the per-workflow `buildJson` -// closures (each one closing over its workflow package's trigger -// address and turn timeouts) live on this hub's own native -// `./catalog-blocks` `CATALOG_BLOCKS` entries, reached here through -// `deployableCatalogBlock` — the hub names no builder of its own, so -// the catalog's deployable set and the block sources served here can -// never drift apart silently. `assistant` (seeded, never redeployed -// here) and `heartbeat` (test-only, never deployed onto a real bench) -// are outside `CATALOG_BLOCKS`, so they answer `undefined` here, same -// as any name outside the catalog entirely — a route answering -// `undefined` as a 404 is the honest statement of that. -// -// 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 `./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"; - -export interface BlockWorkflowBuildInput { - readonly tenantDomain: string; - readonly inferencePreferences: readonly { - readonly provider: string; - readonly model: string; - }[]; -} - -export interface BlockWorkflowSource { - readonly assetName: string; - readonly displayName: string; - readonly workflowJson: string; -} - -/** - * The serialized source-form definition for one catalog workflow — or - * `undefined` for `assistant`, `heartbeat`, and any name outside the - * catalog. `buildJson` is a required field on every `CATALOG_BLOCKS` - * entry, so a catalog name always answers here: the deployable set and - * the served sources cannot drift apart silently. - */ -export function buildBlockWorkflowSource( - assetName: string, - input: BlockWorkflowBuildInput, -): BlockWorkflowSource | undefined { - const workflow = deployableCatalogBlock(assetName); - if (workflow === undefined) { - return undefined; - } - return { - assetName: workflow.assetName, - displayName: workflow.displayName, - workflowJson: workflow.buildJson( - input.tenantDomain, - input.inferencePreferences, - ), - }; -} diff --git a/apps/hub/src/catalog-blocks/catalog-block-routes.test.ts b/apps/hub/src/catalog-blocks/catalog-block-routes.test.ts deleted file mode 100644 index 4f5127a22b..0000000000 --- a/apps/hub/src/catalog-blocks/catalog-block-routes.test.ts +++ /dev/null @@ -1,196 +0,0 @@ -// 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. -// -// WIRING PINS (CL-8113): the per-workflow `buildJson` closures live on -// the hub's native `./catalog-blocks` `CATALOG_BLOCKS` entries and -// `buildBlockWorkflowSource` wires them through -// `deployableCatalogBlock`, so every catalog workflow deploys below -// with its real rendered definition — no silent no-op, no fake deploy. -// These tests pin that wiring in the open: the trigger address stamped -// into each deployed definition proves the real builder ran against the -// requesting tenant's domain. Dropped: nothing — the already-deployed -// 200 (the port reports `created: false`) and the failing-deploy 500 -// (the route's catch branch) both run again. -import { describe, expect, test } from "bun:test"; -import { Hono } from "hono"; -import type { MiddlewareHandler } from "hono"; -import type { RequireGrant, TenantEnv } from "@intx/hub-api"; -import { makeErrorEnvelope } from "@corbits/error-sink"; - -import { - createCatalogBlockRoutes, - type CatalogBlockRoutesDeps, -} from "./catalog-block-routes"; - -const TENANT = { - id: "tnt_1", - name: "Acme", - slug: "acme", - domain: "acme.example", - parentId: null, - config: null, - createdAt: new Date(), - updatedAt: new Date(), -}; - -const PRINCIPAL = { - id: "prn_alice", - tenantId: TENANT.id, - kind: "user" as const, - refId: "prn_alice", - status: "active" as const, - createdAt: new Date(), - updatedAt: new Date(), -}; - -const allowAll: RequireGrant = () => async (_c, next) => { - await next(); -}; - -function mountAs(routes: Hono): Hono { - const asTenant: MiddlewareHandler = async (c, next) => { - c.set("tenant", TENANT); - c.set("principal", PRINCIPAL); - await next(); - }; - const app = new Hono(); - app.use("*", asTenant); - app.route("/", routes); - return app; -} - -type DeployedSource = Parameters< - CatalogBlockRoutesDeps["deployWorkflowSource"] ->[0]; - -function buildApp(overrides: Partial = {}) { - const deployed: DeployedSource[] = []; - const deps: CatalogBlockRoutesDeps = { - requireGrant: allowAll, - log: () => {}, - inferencePreferences: async () => [ - { provider: "anthropic", model: "claude-sonnet-5" }, - ], - deployWorkflowSource: async (args) => { - deployed.push(args); - return { id: "wfd_code_review", created: true }; - }, - ...overrides, - }; - return { app: mountAs(createCatalogBlockRoutes(deps)), deployed }; -} - -describe("POST /:assetName/deploy", () => { - test("every catalog workflow deploys its real rendered definition", async () => { - const { app, deployed } = buildApp(); - for (const assetName of [ - "code-review", - "exa-topic-watch", - "last-30-days-research", - "granola-call", - ]) { - const res = await app.request(`/${assetName}/deploy`, { - method: "POST", - }); - expect(res.status).toBe(201); - const body = (await res.json()) as { id: string; created: boolean }; - expect(body.created).toBe(true); - expect(typeof body.id).toBe("string"); - } - expect(deployed).toHaveLength(4); - for (const args of deployed) { - // A real rendered definition for THIS workflow: it parses, it - // has steps to launch, and it names its own asset. (Not every - // entry is mail-triggered, so the name — not a trigger address - // — is the per-workflow pin.) - expect(args.tenantId).toBe(TENANT.id); - expect(args.principalId).toBe(PRINCIPAL.id); - const parsed = JSON.parse(args.workflowJson) as { - stepOrder?: readonly string[]; - }; - expect(parsed.stepOrder?.length).toBeGreaterThan(0); - expect(args.workflowJson).toContain(args.assetName); - } - // code-review is mail-triggered: its builder stamps the requesting - // tenant's trigger address in, proving the tenant's domain reaches - // the real catalog builder (first deploy in the loop above). - expect(deployed[0]?.workflowJson).toContain("code-review@acme.example"); - }); - - test("an already-deployed definition answers 200 without redeploying", async () => { - const { app, deployed } = buildApp({ - deployWorkflowSource: async (args) => { - deployed.push(args); - return { id: "wfd_code_review", created: false }; - }, - }); - const res = await app.request("/code-review/deploy", { method: "POST" }); - expect(res.status).toBe(200); - const body = (await res.json()) as { id: string; created: boolean }; - expect(body).toEqual({ id: "wfd_code_review", created: false }); - expect(deployed).toHaveLength(1); - }); - - test("a failing deploy answers a 500 envelope, deploying nothing more", async () => { - const { app, deployed } = buildApp({ - deployWorkflowSource: async () => { - throw new Error("asset store is down"); - }, - }); - const res = await app.request("/code-review/deploy", { method: "POST" }); - expect(res.status).toBe(500); - const body = (await res.json()) as { - error: { code: string; userMessage: string }; - }; - expect(body.error.code).toBe("deploy_failed"); - expect(deployed).toHaveLength(0); - }); - - test("an asset name no block builder covers answers 404, deploying nothing", async () => { - const { app, deployed } = buildApp(); - const res = await app.request("/does-not-exist/deploy", { - method: "POST", - }); - expect(res.status).toBe(404); - expect(deployed).toHaveLength(0); - }); - - test("a denied grant answers a 403 envelope with a refId, deploying nothing", async () => { - const { app, deployed } = buildApp({ - requireGrant: () => async (c) => { - return c.json( - makeErrorEnvelope({ - code: "forbidden", - userMessage: "You do not have permission to perform this action", - }), - 403, - ); - }, - }); - const res = await app.request("/code-review/deploy", { method: "POST" }); - expect(res.status).toBe(403); - const body = (await res.json()) as { - error: { code: string; userMessage: string; refId: string }; - }; - expect(body.error.code).toBe("forbidden"); - expect(typeof body.error.refId).toBe("string"); - expect(body.error.refId.length).toBeGreaterThan(0); - expect(deployed).toHaveLength(0); - }); - - test("assistant is seeded, never deployed through this route", async () => { - const { app, deployed } = buildApp(); - const res = await app.request("/assistant/deploy", { method: "POST" }); - expect(res.status).toBe(404); - expect(deployed).toHaveLength(0); - }); - - test("heartbeat is test-only, never deployed through this route", async () => { - const { app, deployed } = buildApp(); - const res = await app.request("/heartbeat/deploy", { method: "POST" }); - expect(res.status).toBe(404); - expect(deployed).toHaveLength(0); - }); -}); diff --git a/apps/hub/src/catalog-blocks/catalog-block-routes.ts b/apps/hub/src/catalog-blocks/catalog-block-routes.ts deleted file mode 100644 index 42f2837536..0000000000 --- a/apps/hub/src/catalog-blocks/catalog-block-routes.ts +++ /dev/null @@ -1,112 +0,0 @@ -// 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"; -import { makeErrorEnvelope } from "@corbits/error-sink"; - -import { - buildBlockWorkflowSource, - type BlockWorkflowBuildInput, -} from "./block-workflows"; - -const DEPLOY_FAILED_MESSAGE = - "Couldn't set up this workflow. Try again in a moment."; - -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 - * `userMessage`, the raw detail lands here. */ - log: (line: string) => void; - /** The tenant's default inference preferences — the same resolution a - * fresh agent launch resolves against, so a deployed block runs on the - * model the bench actually connected. */ - inferencePreferences( - tenantId: string, - ): Promise; - /** The source-form deploy itself: renders `workflowJson` into a - * `@corbits/workflows`'s `./source` tree on a `workflow`-kind asset and - * projects it onto a `workflow_definition` row — the exact - * materialization `createAgentDefinitionCore` runs for a participant - * agent, minus its agent-only prompt/skills machinery. `created` is - * `false` when the tenant already carries a deployed definition under - * `assetName` (the port skips instead of double-deploying). */ - deployWorkflowSource(args: { - readonly tenantId: string; - readonly principalId: string; - readonly assetName: string; - readonly displayName: string; - readonly workflowJson: string; - }): Promise<{ readonly id: string; readonly created: boolean }>; -}; - -export function createCatalogBlockRoutes( - deps: CatalogBlockRoutesDeps, -): Hono { - const app = new Hono(); - - app.post( - "/:assetName/deploy", - deps.requireGrant("workflow:*", "create"), - async (c) => { - const tenant = c.get("tenant"); - const principal = c.get("principal"); - const assetName = c.req.param("assetName"); - - const source = buildBlockWorkflowSource(assetName, { - tenantDomain: tenant.domain, - inferencePreferences: await deps.inferencePreferences(tenant.id), - }); - if (source === undefined) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: `"${assetName}" isn't a deployable catalog workflow.`, - }), - 404, - ); - } - - try { - const result = await deps.deployWorkflowSource({ - tenantId: tenant.id, - principalId: principal.id, - assetName: source.assetName, - displayName: source.displayName, - workflowJson: source.workflowJson, - }); - return c.json( - { id: result.id, created: result.created }, - 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( - `catalog-blocks: deploying "${assetName}" failed for tenant ${tenant.id}: ${message}`, - ); - return c.json( - makeErrorEnvelope({ - code: "deploy_failed", - userMessage: DEPLOY_FAILED_MESSAGE, - }), - 500, - ); - } - }, - ); - - return app; -} diff --git a/apps/hub/src/catalog-blocks/catalog-blocks.test.ts b/apps/hub/src/catalog-blocks/catalog-blocks.test.ts deleted file mode 100644 index b98064333b..0000000000 --- a/apps/hub/src/catalog-blocks/catalog-blocks.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -// Pins the hub's native catalog-block adapter (`./catalog-blocks`): the -// 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`. -// -// ANTI-DRIFT PIN: the adapter's asset names are exactly the native -// `WORKFLOW_CATALOG` names minus `assistant` (seeded already, never -// redeployed here) and `heartbeat` (test-only, never deployed onto a -// real bench). A workflow added to the catalog without a builder entry -// here — or a builder entry with no catalog backing — fails this test, -// so the deployable set and the served sources cannot drift apart -// silently. -import { describe, expect, test } from "bun:test"; -import { WORKFLOW_CATALOG } from "@corbits/workflows"; - -import { - CATALOG_BLOCK_ASSET_NAMES, - deployableCatalogBlock, -} from "./catalog-blocks"; - -const FAKE_PREFERENCES = [{ provider: "anthropic", model: "claude-sonnet-5" }]; -const TENANT_DOMAIN = "acme.example"; - -describe("native catalog-block adapter", () => { - test("the deployable set is the native catalog minus assistant and heartbeat", () => { - const expected = WORKFLOW_CATALOG.map((entry) => entry.assetName) - .filter((name) => name !== "assistant" && name !== "heartbeat") - .sort(); - expect([...CATALOG_BLOCK_ASSET_NAMES].sort()).toEqual(expected); - }); - - test("every deployable block renders its real definition for the tenant", () => { - for (const assetName of CATALOG_BLOCK_ASSET_NAMES) { - const block = deployableCatalogBlock(assetName); - expect(block).toBeDefined(); - expect(block?.assetName).toBe(assetName); - expect(block?.displayName.length).toBeGreaterThan(0); - const parsed = JSON.parse( - block?.buildJson(TENANT_DOMAIN, FAKE_PREFERENCES) ?? "", - ) as { stepOrder?: readonly string[] }; - expect(parsed.stepOrder?.length).toBeGreaterThan(0); - expect(block?.buildJson(TENANT_DOMAIN, FAKE_PREFERENCES)).toContain( - assetName, - ); - } - }); - - test("code-review stamps the requesting tenant's trigger address", () => { - const block = deployableCatalogBlock("code-review"); - expect(block?.buildJson(TENANT_DOMAIN, FAKE_PREFERENCES)).toContain( - "code-review@acme.example", - ); - }); - - test("assistant, heartbeat, and unknown names answer undefined", () => { - expect(deployableCatalogBlock("assistant")).toBeUndefined(); - expect(deployableCatalogBlock("heartbeat")).toBeUndefined(); - expect(deployableCatalogBlock("does-not-exist")).toBeUndefined(); - }); -}); diff --git a/apps/hub/src/catalog-blocks/catalog-blocks.ts b/apps/hub/src/catalog-blocks/catalog-blocks.ts deleted file mode 100644 index 1aca86bdc8..0000000000 --- a/apps/hub/src/catalog-blocks/catalog-blocks.ts +++ /dev/null @@ -1,303 +0,0 @@ -// The hub's native catalog-block adapter (CL-8113, hub-zero T2): the -// 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 -// import from that package. `assistant` (seeded already, never -// redeployed here) and `heartbeat` (test-only, never deployed onto a -// real bench) are outside this set, so they answer `undefined` here, -// same as any name outside the catalog entirely. -// -// Display names come from the native `WORKFLOW_CATALOG` entry -// (`@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 `./catalog-block-routes.ts`) imports -// this; it is deliberately not re-exported from the package root. -import { workflowCatalogEntry } from "@corbits/workflows"; -import { - buildCodeReviewWorkflow, - serializeCodeReviewWorkflow, -} from "@corbits/code-review-workflow"; -import { - buildWorkbenchDigestWorkflow, - serializeWorkbenchDigestWorkflow, -} from "@corbits/workbench-digest-workflow"; -import { - buildEchoWorkflow, - serializeEchoWorkflow, -} from "@corbits/echo-workflow"; -import { - buildLast30DaysResearchWorkflow, - serializeLast30DaysResearchWorkflow, -} from "@corbits/last-30-days-research-workflow"; -import { - buildGranolaCallWorkflow, - serializeGranolaCallWorkflow, -} from "@corbits/granola-call-workflow"; -import { - buildMorningBriefWorkflow, - serializeMorningBriefWorkflow, -} from "@corbits/morning-brief-workflow"; -import { - buildExaTopicWatchWorkflow, - serializeExaTopicWatchWorkflow, -} from "@corbits/exa-topic-watch-workflow"; -import { - buildProcessGranolaCallWorkflow, - serializeProcessGranolaCallWorkflow, -} from "@corbits/process-granola-call-workflow"; -import { - buildAttioTaskAgentWorkflow, - serializeAttioTaskAgentWorkflow, -} from "@corbits/attio-task-agent-workflow"; -import { - buildPainPointCollateralWorkflow, - serializePainPointCollateralWorkflow, -} from "@corbits/pain-point-collateral-workflow"; -import { - buildRedditOpportunityScannerWorkflow, - serializeRedditOpportunityScannerWorkflow, -} from "@corbits/reddit-opportunity-scanner-workflow"; -import { - buildCollateralGenerationWorkflow, - serializeCollateralGenerationWorkflow, -} from "@corbits/collateral-generation-workflow"; -import { - buildDiligenceBriefWorkflow, - serializeDiligenceBriefWorkflow, -} from "@corbits/diligence-brief-workflow"; - -const ECHO_TURN_TIMEOUT_MS = 2 * 60 * 1000; -const WORKBENCH_DIGEST_TURN_TIMEOUT_MS = 30 * 1000; -const LAST_30_DAYS_RESEARCH_TURN_TIMEOUT_MS = 2 * 60 * 1000; -const CODE_REVIEW_TURN_TIMEOUT_MS = 2 * 60 * 1000; -const GRANOLA_CALL_TURN_TIMEOUT_MS = 2 * 60 * 1000; -const MORNING_BRIEF_TURN_TIMEOUT_MS = 2 * 60 * 1000; -const EXA_TOPIC_WATCH_TURN_TIMEOUT_MS = 2 * 60 * 1000; -const PROCESS_GRANOLA_CALL_TURN_TIMEOUT_MS = 5 * 60 * 1000; -const ATTIO_TASK_AGENT_TURN_TIMEOUT_MS = 2 * 60 * 1000; -const PAIN_POINT_COLLATERAL_TURN_TIMEOUT_MS = 2 * 60 * 1000; -const REDDIT_OPPORTUNITY_SCANNER_TURN_TIMEOUT_MS = 2 * 60 * 1000; -const COLLATERAL_GENERATION_TURN_TIMEOUT_MS = 2 * 60 * 1000; -const DILIGENCE_BRIEF_TURN_TIMEOUT_MS = 2 * 60 * 1000; - -export type CatalogBlockInferencePreference = { - readonly provider: string; - readonly model: string; -}; - -export type CatalogBlock = { - /** Asset name; lowercase-kebab so the smart-HTTP repo path is clean. */ - readonly assetName: string; - /** Friendly label stamped on the asset at create time. */ - readonly displayName: string; - /** - * 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 catalog-block - * route's in-process deploy path, which only ever has the tenant's - * real inference preferences on hand. - */ - readonly buildJson: ( - tenantDomain: string, - inferencePreferences: readonly CatalogBlockInferencePreference[], - ) => string; -}; - -function catalogDisplayName(assetName: string): string { - return workflowCatalogEntry(assetName)?.displayName ?? assetName; -} - -/** - * 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 - * something asks for it by name. - */ -export const CATALOG_BLOCKS: readonly CatalogBlock[] = [ - { - assetName: "echo", - displayName: catalogDisplayName("echo"), - buildJson: (tenantDomain, inferencePreferences) => - serializeEchoWorkflow( - buildEchoWorkflow({ - triggerAddress: `echo@${tenantDomain}`, - inferencePreferences, - turnTimeoutMs: ECHO_TURN_TIMEOUT_MS, - }), - ), - }, - { - assetName: "workbench-digest", - displayName: catalogDisplayName("workbench-digest"), - buildJson: (_tenantDomain, inferencePreferences) => - serializeWorkbenchDigestWorkflow( - buildWorkbenchDigestWorkflow({ - inferencePreferences, - turnTimeoutMs: WORKBENCH_DIGEST_TURN_TIMEOUT_MS, - }), - ), - }, - { - assetName: "last-30-days-research", - displayName: catalogDisplayName("last-30-days-research"), - buildJson: (tenantDomain, inferencePreferences) => - serializeLast30DaysResearchWorkflow( - buildLast30DaysResearchWorkflow({ - triggerAddress: `last-30-days-research@${tenantDomain}`, - inferencePreferences, - turnTimeoutMs: LAST_30_DAYS_RESEARCH_TURN_TIMEOUT_MS, - }), - ), - }, - { - assetName: "code-review", - displayName: catalogDisplayName("code-review"), - buildJson: (tenantDomain, inferencePreferences) => - serializeCodeReviewWorkflow( - buildCodeReviewWorkflow({ - triggerAddress: `code-review@${tenantDomain}`, - inferencePreferences, - turnTimeoutMs: CODE_REVIEW_TURN_TIMEOUT_MS, - }), - ), - }, - { - assetName: "granola-call", - displayName: catalogDisplayName("granola-call"), - buildJson: (tenantDomain, inferencePreferences) => - serializeGranolaCallWorkflow( - buildGranolaCallWorkflow({ - triggerAddress: `granola-call@${tenantDomain}`, - inferencePreferences, - turnTimeoutMs: GRANOLA_CALL_TURN_TIMEOUT_MS, - }), - ), - }, - { - assetName: "morning-brief", - displayName: catalogDisplayName("morning-brief"), - buildJson: (tenantDomain, inferencePreferences) => - serializeMorningBriefWorkflow( - buildMorningBriefWorkflow({ - triggerAddress: `morning-brief@${tenantDomain}`, - inferencePreferences, - turnTimeoutMs: MORNING_BRIEF_TURN_TIMEOUT_MS, - }), - ), - }, - { - assetName: "exa-topic-watch", - displayName: catalogDisplayName("exa-topic-watch"), - buildJson: (tenantDomain, inferencePreferences) => - serializeExaTopicWatchWorkflow( - buildExaTopicWatchWorkflow({ - triggerAddress: `exa-topic-watch@${tenantDomain}`, - inferencePreferences, - turnTimeoutMs: EXA_TOPIC_WATCH_TURN_TIMEOUT_MS, - }), - ), - }, - { - assetName: "process-granola-call", - displayName: catalogDisplayName("process-granola-call"), - buildJson: (tenantDomain, inferencePreferences) => - serializeProcessGranolaCallWorkflow( - buildProcessGranolaCallWorkflow({ - triggerAddress: `process-granola-call@${tenantDomain}`, - inferencePreferences, - turnTimeoutMs: PROCESS_GRANOLA_CALL_TURN_TIMEOUT_MS, - }), - ), - }, - { - assetName: "attio-task-agent", - displayName: catalogDisplayName("attio-task-agent"), - buildJson: (tenantDomain, inferencePreferences) => - serializeAttioTaskAgentWorkflow( - buildAttioTaskAgentWorkflow({ - triggerAddress: `attio-task-agent@${tenantDomain}`, - inferencePreferences, - turnTimeoutMs: ATTIO_TASK_AGENT_TURN_TIMEOUT_MS, - }), - ), - }, - { - assetName: "pain-point-collateral", - displayName: catalogDisplayName("pain-point-collateral"), - buildJson: (tenantDomain, inferencePreferences) => - serializePainPointCollateralWorkflow( - buildPainPointCollateralWorkflow({ - triggerAddress: `pain-point-collateral@${tenantDomain}`, - inferencePreferences, - turnTimeoutMs: PAIN_POINT_COLLATERAL_TURN_TIMEOUT_MS, - }), - ), - }, - { - assetName: "reddit-opportunity-scanner", - displayName: catalogDisplayName("reddit-opportunity-scanner"), - buildJson: (tenantDomain, inferencePreferences) => - serializeRedditOpportunityScannerWorkflow( - buildRedditOpportunityScannerWorkflow({ - triggerAddress: `reddit-opportunity-scanner@${tenantDomain}`, - inferencePreferences, - turnTimeoutMs: REDDIT_OPPORTUNITY_SCANNER_TURN_TIMEOUT_MS, - }), - ), - }, - { - assetName: "collateral-generation", - displayName: catalogDisplayName("collateral-generation"), - buildJson: (tenantDomain, inferencePreferences) => - serializeCollateralGenerationWorkflow( - buildCollateralGenerationWorkflow({ - triggerAddress: `collateral-generation@${tenantDomain}`, - inferencePreferences, - turnTimeoutMs: COLLATERAL_GENERATION_TURN_TIMEOUT_MS, - }), - ), - }, - { - assetName: "diligence-brief", - displayName: catalogDisplayName("diligence-brief"), - buildJson: (tenantDomain, inferencePreferences) => - serializeDiligenceBriefWorkflow( - buildDiligenceBriefWorkflow({ - triggerAddress: `diligence-brief@${tenantDomain}`, - inferencePreferences, - turnTimeoutMs: DILIGENCE_BRIEF_TURN_TIMEOUT_MS, - }), - ), - }, -]; - -/** - * The asset names of `CATALOG_BLOCKS`, strings only: callers that name - * the deployable set without building anything from it import this - * rather than `CATALOG_BLOCKS` itself, so no per-workflow `buildJson` - * closure crosses into their code. - */ -export const CATALOG_BLOCK_ASSET_NAMES: readonly string[] = CATALOG_BLOCKS.map( - (block) => block.assetName, -); - -/** - * 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". - */ -export function deployableCatalogBlock( - assetName: string, -): CatalogBlock | undefined { - return CATALOG_BLOCKS.find((block) => block.assetName === assetName); -} diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index 65d9c7a687..f5aac11c1d 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -16,10 +16,8 @@ import { createSignalCorrelationStore, createWorkflowRunDispatchStore, listAssetsForTenant, - listVisibleOfferings, } from "@intx/db"; import { - asset as assetTable, tenant as tenantTable, user as userTable, workflowDefinition, @@ -38,7 +36,6 @@ import { type AppEnv, type TenantEnv, } from "@intx/hub-api"; -import { WorkflowDefinitionInvalidError } from "@intx/workflow-deploy"; // CL-7362: computes the preview's wire hash from the probed-but-unapproved // projection `installAndApproveWorkflowSource` returns on `grants_not_approved` // — the gate itself only stamps this hash on the `ok:true` arm. @@ -93,8 +90,6 @@ import { createInboxRoutes, WORKBENCH_MAILBOX_VOCABULARY, } from "@corbits/inbox"; -import { generateId } from "@intx/hub-common"; - import { createInMemoryMailboxEventBus, createMailboxDb, @@ -121,10 +116,7 @@ import { import { createWorkflowDetailRoute, createScheduledWorkflowRoutes, - renderWorkflowSourceTree, - WORKFLOW_SOURCE_ENTRY, ensureRunSession, - recordAgentSessionAtProvision, isConversationalWorkflowName, } from "@corbits/workflows"; import { @@ -142,9 +134,7 @@ import { createSidecarRouter, createWorkflowAllocationService, createWorkflowDispatchService, - DEFAULT_ASSET_REF, resolveRoutableAddress, - WorkflowProvisioningError, type AgentRepoStore, type EventCollectorRegistry, type WsHandle, @@ -190,7 +180,6 @@ 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 { @@ -202,8 +191,6 @@ import { createWorkflowAuthorRoutes, listDeployedCronDefinitions, SCHEDULE_TICK_CONTENT, - WorkflowAuthorError, - type WorkflowDeployer, } from "@corbits/workflows"; import { createDrizzleServingRefreshStore, @@ -1599,103 +1586,6 @@ export async function createHub(config: HubConfig) { app.route("/", memoryApp); } - // The hub-side deploy seam the on-demand catalog-block route below - // drives — the SAME `prepareProvisionedDeployment` the native `POST - // /workflows/deployments` route drives. Inference sources ride as - // catalog offering ids (`listVisibleOfferings`); the caller never - // supplies or sees a provider secret. The tree is already on the asset - // at `commitSha`, so this does not re-populate. - // - // An agent deploying its own authored workflow does not come through - // here: `@corbits/workflow-authoring-tools` calls stock `POST - // /api/tenants/:tenantId/workflows/deployments` with the run bearer - // (CL-8171), resolving the same offering chain from the stock model - // discovery route. - const workflowDeployer: WorkflowDeployer = { - async deploy({ tenantId, principalId, assetId, commitSha, entry }) { - const tenantRow = await db.query.tenant.findFirst({ - where: eq(tenantTable.id, tenantId), - }); - if (tenantRow === undefined) { - throw new WorkflowAuthorError( - "not_found", - `tenant ${tenantId} not found`, - ); - } - - const offerings = [...(await listVisibleOfferings(db, tenantId))].sort( - (a, b) => a.offering.priority - b.offering.priority, - ); - const sourceOfferingIds = offerings.map((o) => o.offering.id); - const defaultSourceOfferingId = sourceOfferingIds[0]; - if (defaultSourceOfferingId === undefined) { - throw new WorkflowAuthorError( - "invalid", - "no catalog offerings visible to this tenant", - ); - } - - try { - const sessionId = generateId("session"); - const prepared = - await workflowAllocationService.prepareProvisionedDeployment({ - tenantId, - anchorRunId: generateId("workflowRun"), - deploymentDomain: tenantRow.domain, - source: { - kind: "asset", - assetId, - package: { format: "source", commitSha }, - }, - entry, - definitionAssetId: assetId, - sessionId, - sourceAuthorityPrincipalId: principalId, - sourceOfferingIds, - defaultSourceOfferingId, - deployContent: { systemPrompt: "" }, - }); - // The eager record every native launcher makes right after - // `prepareProvisionedDeployment` returns (CL-7481): this - // deployment mints no opening message of its own, but the - // trigger that eventually reconciles a principal onto it runs - // entirely through Interchange's own native route, with no - // hub-owned hook downstream to record the session - // afterward — so this is the only chance to record it at all. - await recordAgentSessionAtProvision({ - db, - eventCollectors, - runId: prepared.anchorRunId, - sessionId, - sourceAuthorityPrincipalId: principalId, - }); - return { - deploymentId: prepared.anchorRunId, - definitionAssetId: assetId, - status: prepared.status, - }; - } catch (err) { - // Mirrors `@intx/hub-api`'s own `/workflows/deployments` route: an - // install/gate rejection or an unapproved source chain is a - // client/definition error; a provisioning failure or anything else - // (a missing commit, an unreachable sidecar) is `unavailable`. - if (err instanceof WorkflowDefinitionInvalidError) { - throw new WorkflowAuthorError("invalid", err.message); - } - if (err instanceof WorkflowProvisioningError) { - throw new WorkflowAuthorError("unavailable", err.message); - } - reportError(err, { - operation: "workflow-author-deploy", - tenantId, - }); - throw new WorkflowAuthorError( - "unavailable", - err instanceof Error ? err.message : "Failed to deploy workflow", - ); - } - }, - }; // Agent-authored workflows (CL-7360): an agent publishes a workflow // codebase as a native `kind:"workflow"` asset through this // workflow-run-authenticated surface, then deploys it through stock @@ -1933,97 +1823,6 @@ export async function createHub(config: HubConfig) { providerHealth: providerHealthStore, }), ); - // 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}/catalog-blocks`, - createCatalogBlockRoutes({ - requireGrant: createRequireGrant({ - grantStore: chatGrantStore, - conditionRegistry: grantConditionRegistry, - }), - log: (line) => log.info`${line}`, - inferencePreferences: (tenantId) => - chatHostInferencePreferencesResolver(tenantId), - deployWorkflowSource: async ({ - tenantId, - principalId, - assetName, - displayName, - workflowJson, - }) => { - const existing = await db.query.workflowDefinition.findFirst({ - where: and( - eq(workflowDefinition.tenantId, tenantId), - eq(workflowDefinition.name, assetName), - eq(workflowDefinition.status, "deployed"), - ), - columns: { id: true }, - }); - if (existing !== undefined) { - return { id: existing.id, created: false }; - } - - // A prior attempt may have created the asset but died before the - // definition projected — reuse the shell instead of 409ing the - // retry, the same recovery `createAgentDefinitionCore` documents. - let assetId: string; - try { - const created = await assetService.createAsset({ - tenantId, - kind: "workflow", - name: assetName, - displayName, - creatorPrincipalId: principalId, - }); - assetId = created.id; - } catch (cause) { - const shell = await db.query.asset.findFirst({ - where: and( - eq(assetTable.tenantId, tenantId), - eq(assetTable.kind, "workflow"), - eq(assetTable.name, assetName), - ), - columns: { id: true }, - }); - if (shell === undefined) throw cause; - assetId = shell.id; - } - - const { commitSha } = await assetService.populateAsset({ - assetId, - ref: DEFAULT_ASSET_REF, - principal: { kind: "hub" }, - tree: { - files: renderWorkflowSourceTree({ - packageName: `@workbench-catalog-block/${assetName}`, - workflowJson, - }), - message: `Deploy catalog block ${assetName}`, - }, - }); - - // Native deploy, not a hub-local inert freeze: `workflowDeployer` - // above, 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({ - tenantId, - principalId, - assetId, - assetName, - commitSha, - entry: WORKFLOW_SOURCE_ENTRY, - }); - return { id: result.definitionAssetId, created: true }; - }, - }), - ); // MCP servers: the tenant-scoped connect/list/disconnect surface // Plugins drives (CL-6142), mirroring `connections` above but for // tenant-minted `mcp:` connectors rather than diff --git a/apps/web/src/pages/routines-available-strings.ts b/apps/web/src/pages/routines-available-strings.ts index 5e2a29e1bb..13ca8d349e 100644 --- a/apps/web/src/pages/routines-available-strings.ts +++ b/apps/web/src/pages/routines-available-strings.ts @@ -4,18 +4,8 @@ // platform-side noun (see `scripts/checks/ui-vocabulary.ts`). export const AVAILABLE_SECTION_TITLE = "Available"; -export const AVAILABLE_SECTION_SUBTITLE = "Add one of these to this workbench."; - -export const ADD_BUTTON_LABEL = "Add"; -export const ADD_BUTTON_BUSY_LABEL = "Adding…"; - -export function addSuccessMessage(displayName: string): string { - return `${displayName} added`; -} - -export function addFailureMessage(displayName: string, detail: string): string { - return `Couldn't add ${displayName}: ${detail}`; -} +export const AVAILABLE_SECTION_SUBTITLE = + "Workflows this workbench hasn't added yet."; /** * Every connector id `WORKFLOW_CATALOG` currently names as a required diff --git a/apps/web/src/pages/routines-page.tsx b/apps/web/src/pages/routines-page.tsx index a30ec3244e..4a9b7c2a60 100644 --- a/apps/web/src/pages/routines-page.tsx +++ b/apps/web/src/pages/routines-page.tsx @@ -5,10 +5,8 @@ // — adding a catalog workflow is a single-tenant write, unlike the // scheduled roster above, which aggregates every bench the account // belongs to. -import { useState } from "react"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { - Button, EmptyState, RichEmptyState, RunNowButton, @@ -19,12 +17,9 @@ import { TableHead, TableHeader, TableRow, - toast, } from "@corbits/react-ui"; import { Clock } from "@corbits/icons"; import { cronSentence } from "@corbits/workflows/client"; -import { reportError } from "@corbits/error-sink"; -import { describeApiError, ApiQueryError } from "@corbits/api-query"; import { useGlobalRoutines, useRoutineActions } from "../global-routines"; import type { GlobalRoutineRow } from "../global-routines"; @@ -32,7 +27,6 @@ import { routineDetailPath } from "../global-routines"; import { useBench } from "../bench-context"; import { tenantKeys } from "../query-client"; import { - deployCatalogBlock, listAvailableCatalogWorkflows, type AvailableCatalogWorkflow, } from "../routines-api"; @@ -40,13 +34,9 @@ import { Link } from "../navigation"; import { PLUGINS_PATH_PREFIX } from "../path-ids"; import { StageTopBar } from "../shell/stage-top-bar"; import { - ADD_BUTTON_BUSY_LABEL, - ADD_BUTTON_LABEL, AVAILABLE_SECTION_SUBTITLE, AVAILABLE_SECTION_TITLE, CONNECT_LINK_LABEL, - addFailureMessage, - addSuccessMessage, missingConnectionsReason, } from "./routines-available-strings"; @@ -57,8 +47,6 @@ export function AvailableCatalogWorkflowsSection({ }: { readonly tenantId: string; }) { - const queryClient = useQueryClient(); - const [pendingAssetName, setPendingAssetName] = useState(null); const query = useQuery({ queryKey: tenantKeys.availableCatalogWorkflows(tenantId), queryFn: () => listAvailableCatalogWorkflows(tenantId), @@ -68,35 +56,6 @@ export function AvailableCatalogWorkflowsSection({ const items: readonly AvailableCatalogWorkflow[] = query.data ?? []; if (items.length === 0) return null; - async function handleAdd(entry: AvailableCatalogWorkflow) { - setPendingAssetName(entry.assetName); - try { - await deployCatalogBlock(tenantId, entry.assetName); - await queryClient.invalidateQueries({ - queryKey: tenantKeys.availableCatalogWorkflows(tenantId), - }); - await queryClient.invalidateQueries({ - queryKey: tenantKeys.routines(tenantId), - }); - toast(addSuccessMessage(entry.displayName)); - } catch (cause) { - reportError(cause, { - operation: "catalog_workflow_add", - tenantId, - }); - const detail = describeApiError(cause, "adding this"); - const refId = cause instanceof ApiQueryError ? cause.refId : undefined; - toast( - addFailureMessage( - entry.displayName, - refId !== undefined ? `${detail} (Reference: ${refId})` : detail, - ), - ); - } finally { - setPendingAssetName(null); - } - } - return (
@@ -107,8 +66,6 @@ export function AvailableCatalogWorkflowsSection({
    {items.map((entry) => { - const disabled = !entry.connectionsSatisfied; - const busy = pendingAssetName === entry.assetName; return (
  • ) : null}
- ); })} diff --git a/apps/web/src/routines-api.ts b/apps/web/src/routines-api.ts index e19f517196..b4a1ed82a9 100644 --- a/apps/web/src/routines-api.ts +++ b/apps/web/src/routines-api.ts @@ -116,26 +116,6 @@ export function listAvailableCatalogWorkflows( ).then((page) => page.items); } -const CatalogBlockDeployResponse = type({ - id: "string > 0", - created: "boolean", -}); - -/** Deploys one catalog workflow on demand through the hub's - * `POST /catalog-blocks/:assetName/deploy` — the "Available" section's - * add action. `created: false` means the tenant already carried a - * deployed definition under this asset name. */ -export function deployCatalogBlock( - tenantId: string, - assetName: string, -): Promise<{ readonly created: boolean }> { - return request( - `/api/tenants/${tenantId}/catalog-blocks/${assetName}/deploy`, - CatalogBlockDeployResponse, - { method: "POST" }, - ).then((body) => ({ created: body.created })); -} - export function scheduledWorkflowRunPath( tenantId: string, definitionId: string, diff --git a/apps/web/test/routines-available-section.test.tsx b/apps/web/test/routines-available-section.test.tsx index 215ef992bd..490cd70bc7 100644 --- a/apps/web/test/routines-available-section.test.tsx +++ b/apps/web/test/routines-available-section.test.tsx @@ -1,8 +1,9 @@ // Screen-level proof for the Routines page's "Available" section -// (CL-7073): lists catalog workflows the bench hasn't added yet, Add -// posts through the catalog-blocks deploy route, and a missing required -// connection disables Add with a reason instead of letting the request -// fail. +// (CL-7073): lists catalog workflows the bench hasn't added yet, and +// shows a missing-required-connection reason with a link to Plugins. +// CL-8160 deleted the hub's on-demand catalog-blocks deploy route +// (`apps/hub/src/catalog-blocks/*`) — the section is read-only until a +// deploy path lands on stock rails. import { describe, expect, test } from "bun:test"; import { act, createElement } from "react"; @@ -100,7 +101,7 @@ describe("AvailableCatalogWorkflowsSection", () => { } }); - test("Add is disabled with a reason when a required connection is missing, and links to Plugins", async () => { + test("shows a missing-connection reason and links to Plugins when a required connection is absent", async () => { const { container, root } = await render((async ( input: RequestInfo | URL, ) => { @@ -111,11 +112,6 @@ describe("AvailableCatalogWorkflowsSection", () => { return Promise.reject(new Error(`unrouted fetch: ${url}`)); }) as typeof fetch); try { - const addButton = [...container.querySelectorAll("button")].find( - (button) => button.textContent?.trim() === "Add", - ); - expect(addButton).not.toBeUndefined(); - expect(addButton?.hasAttribute("disabled")).toBe(true); expect(container.textContent).toContain("Connect GitHub first."); const link = container.querySelector("a"); expect(link?.getAttribute("href")).toBe("/plugins"); @@ -123,44 +119,4 @@ describe("AvailableCatalogWorkflowsSection", () => { restoreFetch(container, root); } }); - - test("Add posts to the catalog-blocks deploy route and the entry disappears from Available", async () => { - let available = [echo]; - let deployCalls = 0; - const { container, root } = await render((async ( - input: RequestInfo | URL, - init?: RequestInit, - ) => { - const url = String(input); - if (url.includes("/workflows/available")) { - return jsonResponse({ items: available }); - } - if (url.includes("/catalog-blocks/echo/deploy")) { - deployCalls += 1; - expect(init?.method).toBe("POST"); - available = []; - return jsonResponse({ id: "wfd_echo", created: true }); - } - return Promise.reject(new Error(`unrouted fetch: ${url}`)); - }) as typeof fetch); - try { - const addButton = [...container.querySelectorAll("button")].find( - (button) => button.textContent?.trim() === "Add", - ); - expect(addButton).not.toBeUndefined(); - await act(async () => { - addButton?.click(); - await new Promise((resolve) => setTimeout(resolve, 10)); - }); - for (let i = 0; i < 8; i++) { - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 10)); - }); - } - expect(deployCalls).toBe(1); - expect(container.textContent).not.toContain("Echo"); - } finally { - restoreFetch(container, root); - } - }); });