From d70d8101c543a550d9366d91ddc109642ccd8111 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 4 Sep 2026 18:33:25 -0700 Subject: [PATCH 01/11] Add tests for catalog instantiate deploying any sourced workflow (CL-7073) --- .../templates/template-block-routes.test.ts | 32 +++++++++++++++++++ .../test/routine-launchability.test.ts | 4 ++- packages/seeding/test/seed.test.ts | 7 ++-- .../test/tool-registry-coverage.test.ts | 4 ++- 4 files changed, 42 insertions(+), 5 deletions(-) diff --git a/apps/hub/src/templates/template-block-routes.test.ts b/apps/hub/src/templates/template-block-routes.test.ts index f6488c0d7f..1b6427995c 100644 --- a/apps/hub/src/templates/template-block-routes.test.ts +++ b/apps/hub/src/templates/template-block-routes.test.ts @@ -120,6 +120,38 @@ describe("POST /:assetName/deploy", () => { expect(deployed).toHaveLength(0); }); + test("deploys any on-demand catalog workflow, not just code-review (CL-7073)", async () => { + const { app, deployed } = buildApp(); + const res = await app.request("/last-30-days-research/deploy", { + method: "POST", + }); + expect(res.status).toBe(201); + expect(deployed).toHaveLength(1); + const source = deployed[0]; + if (source === undefined) throw new Error("nothing deployed"); + expect(source.assetName).toBe("last-30-days-research"); + const definition = JSON.parse(source.workflowJson) as { + triggers: { type: string; to: string }[]; + }; + expect(definition.triggers).toEqual([ + { type: "mail", to: "last-30-days-research@acme.example" }, + ]); + }); + + 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); + }); + test("a failing deploy port answers a 500 envelope, never a raw error", async () => { const { app } = buildApp({ deployWorkflowSource: async () => { diff --git a/packages/seeding/test/routine-launchability.test.ts b/packages/seeding/test/routine-launchability.test.ts index ce339aaff5..427fa0b001 100644 --- a/packages/seeding/test/routine-launchability.test.ts +++ b/packages/seeding/test/routine-launchability.test.ts @@ -38,7 +38,9 @@ type SerializedStepDefinition = { test("every default and on-demand catalog workflow's deployed definition is a well-formed, launchable step graph", () => { for (const workflow of [...DEFAULT_WORKFLOWS, ...CATALOG_WORKFLOWS]) { - const json = workflow.buildJson("example.test", FAKE_MODEL); + const json = workflow.buildJson("example.test", [ + { provider: FAKE_MODEL.provider, model: FAKE_MODEL.model }, + ]); const definition = JSON.parse(json) as SerializedStepDefinition; expect( diff --git a/packages/seeding/test/seed.test.ts b/packages/seeding/test/seed.test.ts index 2d7e28f397..b1876023ce 100644 --- a/packages/seeding/test/seed.test.ts +++ b/packages/seeding/test/seed.test.ts @@ -879,12 +879,12 @@ describe("seedTenant", () => { } }); - test("the default set consumed by real tenant provisioning is assistant only; echo, workbench-digest, and last-30-days-research are on-demand catalog entries", () => { + test("the default set consumed by real tenant provisioning is assistant only; echo, workbench-digest, last-30-days-research, and code-review are on-demand catalog entries", () => { // provisionPersonalTenantIfNeeded (@workbench/onboarding) deploys // DEFAULT_WORKFLOWS for every real signup (CL-7074: Myra only). The // remaining catalog workflows — echo, workbench-digest, - // last-30-days-research — deploy on demand (CL-7073) from - // CATALOG_WORKFLOWS, never automatically onto a real signup. The + // last-30-days-research, code-review — deploy on demand (CL-7073) + // from CATALOG_WORKFLOWS, never automatically onto a real signup. The // catalog-test workflows exist only to exercise the platform // continuously and must never reach a real user through either // array — they are seeded only via the explicit @@ -894,6 +894,7 @@ describe("seedTenant", () => { "echo", "workbench-digest", "last-30-days-research", + "code-review", ]); }); diff --git a/packages/seeding/test/tool-registry-coverage.test.ts b/packages/seeding/test/tool-registry-coverage.test.ts index 8bb9481752..02ce07158d 100644 --- a/packages/seeding/test/tool-registry-coverage.test.ts +++ b/packages/seeding/test/tool-registry-coverage.test.ts @@ -75,7 +75,9 @@ function pinsFor(workflows: readonly DefaultWorkflow[]): { }[] { const found: { workflow: string; pin: ToolPackagePinLike }[] = []; for (const workflow of workflows) { - const json = workflow.buildJson("example.com", MODEL); + const json = workflow.buildJson("example.com", [ + { provider: MODEL.provider, model: MODEL.model }, + ]); for (const pin of corbitsPinsIn(json)) { found.push({ workflow: workflow.assetName, pin }); } From 65b7fa39f468fc7074b554bd5769ccee9b9e41c7 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 4 Sep 2026 18:33:31 -0700 Subject: [PATCH 02/11] Catalog instantiate deploys any sourced catalog workflow onto the bench (CL-7073) Generalizes POST /:assetName/deploy to any @corbits/seeding CATALOG_WORKFLOWS entry instead of hardcoding code-review: DefaultWorkflow.buildJson now takes the tenant's real inference preferences directly, so the hub's native deploy path and seedTenant's HTTP self-call path share one buildJson per entry. code-review moves from a one-off builder in block-workflows.ts into CATALOG_WORKFLOWS alongside echo, workbench-digest, and last-30-days-research. assistant (seeded) and heartbeat (test-only) stay unreachable through this route. --- apps/hub/src/templates/block-workflows.ts | 72 +++++++++--------- bun.lock | 12 ++- packages/seeding/package.json | 2 + packages/seeding/src/index.ts | 1 + packages/seeding/src/seed.ts | 92 +++++++++++++++++------ 5 files changed, 119 insertions(+), 60 deletions(-) diff --git a/apps/hub/src/templates/block-workflows.ts b/apps/hub/src/templates/block-workflows.ts index 04434367a5..4e996083cc 100644 --- a/apps/hub/src/templates/block-workflows.ts +++ b/apps/hub/src/templates/block-workflows.ts @@ -1,35 +1,33 @@ -// The source form a template's referenced block workflow deploys as — -// the missing half of instantiation CL-6405 closes: `./instantiate.ts` -// resolves a manifest's participants through the agent-directory create -// path, and this module resolves its `blocks` into the same -// source-form deploy (a serialized definition rendered into a -// `@corbits/workflows`'s `./source` tree and projected onto a -// `workflow_definition` row — see `./template-block-routes.ts` and the -// hub's `deployWorkflowSource` binding). +// 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`. +// `@corbits/seeding`'s `CATALOG_WORKFLOWS` is the one source of truth +// for which asset names are deployable this way and how to build each +// one's definition JSON — the exact same list and `buildJson` the CLI's +// `workbench seed` / the first-login provisioning hook would use to +// deploy the same asset via its own HTTP self-call path. This module +// only adapts that shared build step to the tenant's real, ordered +// inference preferences and to the source-form deploy this route uses +// (see `./template-block-routes.ts` and the hub's `deployWorkflowSource` +// binding). // -// Server-only, on purpose: building `code-review`'s definition pulls in -// `@corbits/code-review-workflow` and with it `@intx/agent`/`@intx/workflow` -// — the heavy graph `./templates.ts` keeps every manifest consumer off. -// Only `./template-block-routes.ts` (mounted in `apps/hub`) imports -// this; it is deliberately not re-exported from the package root. - -import { - buildCodeReviewWorkflow, - serializeCodeReviewWorkflow, - type CodeReviewWorkflowInput, -} from "@corbits/code-review-workflow"; +// Server-only, on purpose: building a catalog workflow's definition +// pulls in its workflow package (e.g. `@corbits/code-review-workflow`) +// and with it `@intx/agent`/`@intx/workflow` — the heavy graph +// `./templates.ts` keeps every manifest consumer off. Only +// `./template-block-routes.ts` (mounted in `apps/hub`) imports this; it +// is deliberately not re-exported from the package root. +import { deployableCatalogWorkflow } from "@corbits/seeding"; import { workflowCatalogEntry } from "@workbench/templates"; -// Matches the conversational default every folded builder in this -// codebase uses (`AGENT_DEFINITION_TURN_TIMEOUT_MS`, -// `ASSISTANT_TURN_TIMEOUT_MS`): a review turn reads a diff and posts -// one review, the same order of work as a research or assistant turn. -const BLOCK_WORKFLOW_TURN_TIMEOUT_MS = 2 * 60 * 1000; - export interface BlockWorkflowBuildInput { readonly tenantDomain: string; - readonly inferencePreferences: CodeReviewWorkflowInput["inferencePreferences"]; + readonly inferencePreferences: readonly { + readonly provider: string; + readonly model: string; + }[]; } export interface BlockWorkflowSource { @@ -39,25 +37,25 @@ export interface BlockWorkflowSource { } /** - * The serialized source-form definition for one template block, or - * `undefined` for an asset name no template block builder covers yet — - * the GTM blocks resolve through their own (future) path, exactly like - * `instantiateWorkbenchTemplate`'s participants; a route answering + * The serialized source-form definition for one catalog workflow, or + * `undefined` for an asset name with no declared source package — + * `assistant` (seeded, never redeployed here) and `heartbeat` + * (test-only, never deployed onto a real bench) both answer `undefined`, + * same as any name outside the catalog entirely. A route answering * `undefined` as a 404 is the honest statement of that gap. */ export function buildBlockWorkflowSource( assetName: string, input: BlockWorkflowBuildInput, ): BlockWorkflowSource | undefined { - if (assetName !== "code-review") return undefined; - const definition = buildCodeReviewWorkflow({ - triggerAddress: `${assetName}@${input.tenantDomain}`, - inferencePreferences: input.inferencePreferences, - turnTimeoutMs: BLOCK_WORKFLOW_TURN_TIMEOUT_MS, - }); + const entry = deployableCatalogWorkflow(assetName); + if (entry === undefined) return undefined; return { assetName, displayName: workflowCatalogEntry(assetName)?.displayName ?? assetName, - workflowJson: serializeCodeReviewWorkflow(definition), + workflowJson: entry.buildJson( + input.tenantDomain, + input.inferencePreferences, + ), }; } diff --git a/bun.lock b/bun.lock index a4da59237f..d8e99222bb 100644 --- a/bun.lock +++ b/bun.lock @@ -41,7 +41,6 @@ "@corbits/artifacts-hub": "workspace:*", "@corbits/bench": "workspace:*", "@corbits/chat": "workspace:*", - "@corbits/code-review-workflow": "workspace:*", "@corbits/commands": "workspace:*", "@corbits/connections": "workspace:*", "@corbits/credential-providers": "workspace:*", @@ -1238,15 +1237,26 @@ "version": "0.0.1", "dependencies": { "@corbits/assistant-workflow": "workspace:*", + "@corbits/attio-task-agent-workflow": "workspace:*", + "@corbits/code-review-workflow": "workspace:*", + "@corbits/collateral-generation-workflow": "workspace:*", "@corbits/connections": "workspace:*", + "@corbits/diligence-brief-workflow": "workspace:*", "@corbits/echo-workflow": "workspace:*", + "@corbits/exa-topic-watch-workflow": "workspace:*", + "@corbits/granola-call-workflow": "workspace:*", "@corbits/heartbeat-workflow": "workspace:*", "@corbits/hub-api-client": "workspace:*", "@corbits/inference-catalog": "workspace:*", "@corbits/last-30-days-research-workflow": "workspace:*", + "@corbits/morning-brief-workflow": "workspace:*", + "@corbits/pain-point-collateral-workflow": "workspace:*", + "@corbits/process-granola-call-workflow": "workspace:*", + "@corbits/reddit-opportunity-scanner-workflow": "workspace:*", "@corbits/tool-registry-publish": "workspace:*", "@corbits/workbench-digest-workflow": "workspace:*", "@corbits/workflows": "workspace:*", + "@intx/agent": "workspace:*", "@intx/inference": "workspace:*", "@intx/types": "workspace:*", "@workbench/templates": "workspace:*", diff --git a/packages/seeding/package.json b/packages/seeding/package.json index 7c4ac3ff6e..94d3627019 100644 --- a/packages/seeding/package.json +++ b/packages/seeding/package.json @@ -14,6 +14,7 @@ }, "dependencies": { "@corbits/assistant-workflow": "workspace:*", + "@corbits/code-review-workflow": "workspace:*", "@corbits/echo-workflow": "workspace:*", "@corbits/heartbeat-workflow": "workspace:*", "@corbits/hub-api-client": "workspace:*", @@ -22,6 +23,7 @@ "@corbits/tool-registry-publish": "workspace:*", "@corbits/workbench-digest-workflow": "workspace:*", "@corbits/workflows": "workspace:*", + "@intx/agent": "workspace:*", "@intx/inference": "workspace:*", "@intx/types": "workspace:*", "@corbits/connections": "workspace:*", diff --git a/packages/seeding/src/index.ts b/packages/seeding/src/index.ts index 2139ce0ac6..78506cd4fb 100644 --- a/packages/seeding/src/index.ts +++ b/packages/seeding/src/index.ts @@ -22,6 +22,7 @@ export { CATALOG_WORKFLOWS, DEFAULT_WORKFLOWS, SEED_GRANTS, + deployableCatalogWorkflow, inferenceCredentialName, PLACEHOLDER_CATALOG_API_KEY, ensureCredential, diff --git a/packages/seeding/src/seed.ts b/packages/seeding/src/seed.ts index a0a6ba760e..d77c934d23 100644 --- a/packages/seeding/src/seed.ts +++ b/packages/seeding/src/seed.ts @@ -25,10 +25,15 @@ import { Capability, } from "@intx/types"; import { type } from "arktype"; +import type { InferencePreference } from "@intx/agent"; import { buildAssistantWorkflow, serializeAssistantWorkflow, } from "@corbits/assistant-workflow"; +import { + buildCodeReviewWorkflow, + serializeCodeReviewWorkflow, +} from "@corbits/code-review-workflow"; import { buildWorkbenchDigestWorkflow, serializeWorkbenchDigestWorkflow, @@ -79,6 +84,10 @@ const WORKBENCH_DIGEST_TURN_TIMEOUT_MS = 30 * 1000; // other conversational workflows above, not the short catalog-test // budget. const LAST_30_DAYS_RESEARCH_TURN_TIMEOUT_MS = 2 * 60 * 1000; +// Matches the conversational default every folded builder in this +// codebase uses: a review turn reads a diff and posts one review, the +// same order of work as a research or assistant turn. +const CODE_REVIEW_TURN_TIMEOUT_MS = 2 * 60 * 1000; const RUN_START_TIMEOUT_MS = 30_000; const RUN_POLL_INTERVAL_MS = 1000; @@ -177,7 +186,20 @@ export type DefaultWorkflow = { * (schedulable automation). Conversational agents stay false. */ automatable: boolean; - buildJson: (tenantDomain: string, model: ModelSource) => 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 — never a full `ModelSource` — so this same + * function serves both `seedTenant`'s HTTP-deploy path (which also + * needs a `ModelSource`'s `baseURL`/`apiKey` for the deployment's + * `sources`, resolved separately) and a native in-process deploy path + * (`apps/hub/src/templates/block-workflows.ts`) that only ever has the + * tenant's real, possibly multi-entry inference preferences on hand. + */ + buildJson: ( + tenantDomain: string, + inferencePreferences: readonly InferencePreference[], + ) => string; /** * Overrides the deploy's inference source for this workflow only, * given the hub's own base URL. Present on the catalog-test workflow @@ -247,13 +269,11 @@ export const DEFAULT_WORKFLOWS: readonly DefaultWorkflow[] = [ assetName: SETUP_AGENT_ASSET_NAME, displayName: catalogDisplayName(SETUP_AGENT_ASSET_NAME), automatable: catalogAutomatable(SETUP_AGENT_ASSET_NAME), - buildJson: (tenantDomain, model) => + buildJson: (tenantDomain, inferencePreferences) => serializeAssistantWorkflow( buildAssistantWorkflow({ triggerAddress: `${SETUP_AGENT_ASSET_NAME}@${tenantDomain}`, - inferencePreferences: [ - { provider: model.provider, model: model.model }, - ], + inferencePreferences, turnTimeoutMs: ASSISTANT_TURN_TIMEOUT_MS, }), ), @@ -276,13 +296,11 @@ export const CATALOG_WORKFLOWS: readonly DefaultWorkflow[] = [ assetName: "echo", displayName: catalogDisplayName("echo"), automatable: catalogAutomatable("echo"), - buildJson: (tenantDomain, model) => + buildJson: (tenantDomain, inferencePreferences) => serializeEchoWorkflow( buildEchoWorkflow({ triggerAddress: `echo@${tenantDomain}`, - inferencePreferences: [ - { provider: model.provider, model: model.model }, - ], + inferencePreferences, turnTimeoutMs: ECHO_TURN_TIMEOUT_MS, }), ), @@ -291,12 +309,10 @@ export const CATALOG_WORKFLOWS: readonly DefaultWorkflow[] = [ assetName: "workbench-digest", displayName: catalogDisplayName("workbench-digest"), automatable: catalogAutomatable("workbench-digest"), - buildJson: (_tenantDomain, model) => + buildJson: (_tenantDomain, inferencePreferences) => serializeWorkbenchDigestWorkflow( buildWorkbenchDigestWorkflow({ - inferencePreferences: [ - { provider: model.provider, model: model.model }, - ], + inferencePreferences, turnTimeoutMs: WORKBENCH_DIGEST_TURN_TIMEOUT_MS, }), ), @@ -309,17 +325,34 @@ export const CATALOG_WORKFLOWS: readonly DefaultWorkflow[] = [ // Deployed automation, on demand. Seed never POSTs a wrapper row; // last-30-days-research stays a deployed workflow without a native // ScheduleTrigger. - buildJson: (tenantDomain, model) => + buildJson: (tenantDomain, inferencePreferences) => serializeLast30DaysResearchWorkflow( buildLast30DaysResearchWorkflow({ triggerAddress: `last-30-days-research@${tenantDomain}`, - inferencePreferences: [ - { provider: model.provider, model: model.model }, - ], + inferencePreferences, turnTimeoutMs: LAST_30_DAYS_RESEARCH_TURN_TIMEOUT_MS, }), ), }, + { + assetName: "code-review", + displayName: catalogDisplayName("code-review"), + automatable: catalogAutomatable("code-review"), + // Deployed automation, on demand, same as every other entry here + // (CL-7073): the instantiate route used to build this one definition + // through its own hardcoded copy in + // `apps/hub/src/templates/block-workflows.ts`; that copy is gone and + // this entry is now the one source of truth for it, same as every + // other catalog workflow. + buildJson: (tenantDomain, inferencePreferences) => + serializeCodeReviewWorkflow( + buildCodeReviewWorkflow({ + triggerAddress: `code-review@${tenantDomain}`, + inferencePreferences, + turnTimeoutMs: CODE_REVIEW_TURN_TIMEOUT_MS, + }), + ), + }, ]; /** @@ -342,13 +375,11 @@ export const CATALOG_TEST_WORKFLOWS: readonly DefaultWorkflow[] = [ assetName: "heartbeat", displayName: catalogDisplayName("heartbeat"), automatable: catalogAutomatable("heartbeat"), - buildJson: (tenantDomain, model) => + buildJson: (tenantDomain, inferencePreferences) => serializeHeartbeatWorkflow( buildHeartbeatWorkflow({ triggerAddress: `heartbeat@${tenantDomain}`, - inferencePreferences: [ - { provider: model.provider, model: model.model }, - ], + inferencePreferences, turnTimeoutMs: HEARTBEAT_TURN_TIMEOUT_MS, }), ), @@ -356,6 +387,21 @@ export const CATALOG_TEST_WORKFLOWS: readonly DefaultWorkflow[] = [ }, ]; +/** + * The deployable-through-the-catalog-instantiate-route entry for one + * asset name (CL-7073), or `undefined` if none exists. `CATALOG_WORKFLOWS` + * is the one source of truth for "has a source package under + * `workflows/` and can be deployed on demand" — `DEFAULT_WORKFLOWS` + * (seeded already, never re-deployed through this path) and + * `CATALOG_TEST_WORKFLOWS` (test-only, never deployed onto a real bench) + * both answer `undefined` here on purpose. + */ +export function deployableCatalogWorkflow( + assetName: string, +): DefaultWorkflow | undefined { + return CATALOG_WORKFLOWS.find((workflow) => workflow.assetName === assetName); +} + // The grants the deploy, trigger, and run-listing routes gate on, // planted at the wildcard scope the authz glob matcher resolves // against any concrete deployment (the deployment id is minted at @@ -1018,7 +1064,9 @@ export async function seedTenant(args: SeedTenantArgs): Promise { const pushed = await args.pushWorkflow({ remoteUrl: `${hubUrl}/api/tenants/${tenant.tenantId}/assets/workflow/${workflow.assetName}.git`, tokenSecret, - workflowJson: workflow.buildJson(tenant.domain, workflowModel), + workflowJson: workflow.buildJson(tenant.domain, [ + { provider: workflowModel.provider, model: workflowModel.model }, + ]), packageName: `@workbench-seed/${workflow.assetName}`, }); log( From c6fd4d17c71faf93d7f6a606a5adbb776a7a3cbe Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 4 Sep 2026 18:33:34 -0700 Subject: [PATCH 03/11] Update docs: catalog instantiate deploys any sourced workflow (CL-7073) --- docs/seed-reconciliation.md | 34 ++++++++++++++++++++++++++++------ 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/docs/seed-reconciliation.md b/docs/seed-reconciliation.md index 2814c4ca41..98dac3adff 100644 --- a/docs/seed-reconciliation.md +++ b/docs/seed-reconciliation.md @@ -61,12 +61,10 @@ not plant wrapper rows. real signup gets automatically: `assistant` (Myra), and nothing else. A fresh bench used to also pay a git push and a sidecar probe for `echo`, `workbench-digest`, and `last-30-days-research` — three workflows -nobody had asked for yet. Those three now live in `CATALOG_WORKFLOWS`, -same shape (`DefinitionWithAgentSteps`-backed `DefaultWorkflow` -entries), deployed the same way (`ensureWorkflowAsset` → `pushWorkflow` -→ `ensureDeployment`), but only when something asks for one by name — -the catalog/instantiate surface, or a test standing up its own fixture -bench — never automatically at signup. +nobody had asked for yet. Those three, plus `code-review`, now live in +`CATALOG_WORKFLOWS`, same shape (`DefinitionWithAgentSteps`-backed +`DefaultWorkflow` entries) and deployable through the catalog +instantiate route (CL-7073), but never automatically at signup. There is no orphan-retire for an entry that moved from `DEFAULT_WORKFLOWS` to `CATALOG_WORKFLOWS`: an asset already deployed @@ -75,6 +73,30 @@ on an existing bench from before the move is left exactly as it is. signup set for workflows that exist only to exercise the platform continuously. +### Deployable through the catalog (CL-7073) + +`CATALOG_WORKFLOWS` is the one source of truth for which catalog asset +names have a source package under `workflows/` and can be +deployed on demand. Two callers reuse it, sharing the same `buildJson` +per entry rather than each hand-rolling a definition: + +- `seedTenant` (`workbench seed`, the first-login provisioning hook) + can deploy any of `CATALOG_WORKFLOWS` the same way it deploys + `DEFAULT_WORKFLOWS`, over its HTTP self-call path + (`ensureWorkflowAsset` → `pushWorkflow` → `ensureDeployment`). +- The hub's `POST /:assetName/deploy` template-block route + (`apps/hub/src/templates/template-block-routes.ts`, + `apps/hub/src/templates/block-workflows.ts`) deploys any + `CATALOG_WORKFLOWS` entry natively, in-process, against the tenant's + real inference preferences — the same route that already deployed + `code-review` for template instantiation, generalized (CL-7073) so + `code-review` is just another `CATALOG_WORKFLOWS` entry rather than a + hardcoded special case. Idempotent: a tenant that already carries a + deployed definition under that asset name answers with the existing + definition (`created: false`) rather than deploying a second time. + `assistant` (seeded already) and `heartbeat` (test-only, + `CATALOG_TEST_WORKFLOWS`) are never reachable through this route. + ## Default skills (boot-time seeding) `plantDefaultSkills` (`packages/seeding/src/seed.ts`) plants each From fb48ea9c8dcced78dedc0171b4e461a647e4a74f Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 4 Sep 2026 18:53:29 -0700 Subject: [PATCH 04/11] Add tests for full catalog workflow registration and route coverage (CL-7073) Adds an invariant test asserting every workflows/ source directory is registered in exactly one of DEFAULT_WORKFLOWS/CATALOG_WORKFLOWS/ CATALOG_TEST_WORKFLOWS/EXCLUDED_WORKFLOW_SOURCES, plus route tests for grant denial (403 envelope with refId) and the GTM template's first block (granola-call) now deploying through the instantiate route. --- .../templates/template-block-routes.test.ts | 44 +++++++++++- packages/seeding/test/seed.test.ts | 30 +++++--- .../test/workflow-source-registration.test.ts | 71 +++++++++++++++++++ 3 files changed, 135 insertions(+), 10 deletions(-) create mode 100644 packages/seeding/test/workflow-source-registration.test.ts diff --git a/apps/hub/src/templates/template-block-routes.test.ts b/apps/hub/src/templates/template-block-routes.test.ts index 1b6427995c..4ccd764765 100644 --- a/apps/hub/src/templates/template-block-routes.test.ts +++ b/apps/hub/src/templates/template-block-routes.test.ts @@ -6,6 +6,7 @@ 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 { createTemplateBlockRoutes, @@ -115,11 +116,52 @@ describe("POST /:assetName/deploy", () => { test("an asset name no block builder covers answers 404, deploying nothing", async () => { const { app, deployed } = buildApp(); - const res = await app.request("/granola-call/deploy", { method: "POST" }); + const res = await app.request("/does-not-exist/deploy", { + method: "POST", + }); expect(res.status).toBe(404); expect(deployed).toHaveLength(0); }); + test("the GTM template's first block (granola-call) deploys through the route now that it is registered (CL-7073)", async () => { + const { app, deployed } = buildApp(); + const res = await app.request("/granola-call/deploy", { method: "POST" }); + expect(res.status).toBe(201); + expect(deployed).toHaveLength(1); + const source = deployed[0]; + if (source === undefined) throw new Error("nothing deployed"); + expect(source.assetName).toBe("granola-call"); + const definition = JSON.parse(source.workflowJson) as { + triggers: { type: string; to: string }[]; + }; + expect(definition.triggers).toEqual([ + { type: "mail", to: "granola-call@acme.example" }, + ]); + }); + + 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("deploys any on-demand catalog workflow, not just code-review (CL-7073)", async () => { const { app, deployed } = buildApp(); const res = await app.request("/last-30-days-research/deploy", { diff --git a/packages/seeding/test/seed.test.ts b/packages/seeding/test/seed.test.ts index b1876023ce..b6291fc941 100644 --- a/packages/seeding/test/seed.test.ts +++ b/packages/seeding/test/seed.test.ts @@ -879,22 +879,34 @@ describe("seedTenant", () => { } }); - test("the default set consumed by real tenant provisioning is assistant only; echo, workbench-digest, last-30-days-research, and code-review are on-demand catalog entries", () => { + test("the default set consumed by real tenant provisioning is assistant only; every other workflows/ source package is an on-demand catalog entry", () => { // provisionPersonalTenantIfNeeded (@workbench/onboarding) deploys - // DEFAULT_WORKFLOWS for every real signup (CL-7074: Myra only). The - // remaining catalog workflows — echo, workbench-digest, - // last-30-days-research, code-review — deploy on demand (CL-7073) - // from CATALOG_WORKFLOWS, never automatically onto a real signup. The - // catalog-test workflows exist only to exercise the platform - // continuously and must never reach a real user through either - // array — they are seeded only via the explicit - // CATALOG_TEST_WORKFLOWS opt-in. + // DEFAULT_WORKFLOWS for every real signup (CL-7074: Myra only). Every + // other workflows/ source package with a builder — echo, + // workbench-digest, last-30-days-research, code-review, granola-call, + // morning-brief, exa-topic-watch, process-granola-call, + // attio-task-agent, pain-point-collateral, + // reddit-opportunity-scanner, collateral-generation, diligence-brief + // — deploys on demand (CL-7073) from CATALOG_WORKFLOWS, never + // automatically onto a real signup. The catalog-test workflows exist + // only to exercise the platform continuously and must never reach a + // real user through either array — they are seeded only via the + // explicit CATALOG_TEST_WORKFLOWS opt-in. expect(DEFAULT_WORKFLOWS.map((w) => w.assetName)).toEqual(["assistant"]); expect(CATALOG_WORKFLOWS.map((w) => w.assetName)).toEqual([ "echo", "workbench-digest", "last-30-days-research", "code-review", + "granola-call", + "morning-brief", + "exa-topic-watch", + "process-granola-call", + "attio-task-agent", + "pain-point-collateral", + "reddit-opportunity-scanner", + "collateral-generation", + "diligence-brief", ]); }); diff --git a/packages/seeding/test/workflow-source-registration.test.ts b/packages/seeding/test/workflow-source-registration.test.ts new file mode 100644 index 0000000000..3c0fb8a134 --- /dev/null +++ b/packages/seeding/test/workflow-source-registration.test.ts @@ -0,0 +1,71 @@ +// CL-7073 critique: `CATALOG_WORKFLOWS` had drifted to 4 entries while +// `workflows/` carried roughly 15 source packages — most of a template's +// blocks (the GTM template's, in particular) 404ed through the catalog +// instantiate route because nothing in `@corbits/seeding` knew about +// them. This test makes that drift structural: every directory under +// `workflows/` must appear in exactly one of `DEFAULT_WORKFLOWS`, +// `CATALOG_WORKFLOWS`, `CATALOG_TEST_WORKFLOWS`, or +// `EXCLUDED_WORKFLOW_SOURCES` (with a one-line reason). A new +// `workflows/` package that nobody wires up fails this test +// instead of silently 404ing through the route. +import { expect, test } from "bun:test"; +import { readdirSync, statSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +import { + CATALOG_WORKFLOWS, + CATALOG_TEST_WORKFLOWS, + DEFAULT_WORKFLOWS, + EXCLUDED_WORKFLOW_SOURCES, +} from "../src/seed"; + +const workflowsDir = path.join( + fileURLToPath(new URL("..", import.meta.url)), + "..", + "..", + "workflows", +); + +function workflowSourceDirectories(): string[] { + return readdirSync(workflowsDir).filter((entry) => + statSync(path.join(workflowsDir, entry)).isDirectory(), + ); +} + +test("every workflows/ source directory is registered in exactly one bucket", () => { + const sources = workflowSourceDirectories(); + expect(sources.length).toBeGreaterThan(0); + + const registeredNames = [ + ...DEFAULT_WORKFLOWS, + ...CATALOG_WORKFLOWS, + ...CATALOG_TEST_WORKFLOWS, + ].map((workflow) => workflow.assetName); + const excludedNames = EXCLUDED_WORKFLOW_SOURCES.map( + (excluded) => excluded.name, + ); + + for (const source of sources) { + const isRegistered = registeredNames.includes(source); + const isExcluded = excludedNames.includes(source); + if (isRegistered === isExcluded) { + throw new Error( + `workflows/${source} must be registered in exactly one of ` + + `DEFAULT_WORKFLOWS/CATALOG_WORKFLOWS/CATALOG_TEST_WORKFLOWS or ` + + `EXCLUDED_WORKFLOW_SOURCES (registered=${isRegistered}, excluded=${isExcluded})`, + ); + } + } + + // Every excluded entry names a real source directory and a real + // reason — an excluded name that stops matching any directory (the + // package was deleted, or actually got registered) is a stale entry. + for (const excluded of EXCLUDED_WORKFLOW_SOURCES) { + expect(sources).toContain(excluded.name); + expect(excluded.reason.length).toBeGreaterThan(0); + } + + // No asset name is double-registered across the deployable buckets. + expect(new Set(registeredNames).size).toBe(registeredNames.length); +}); From 142c5b0ccf0ff990645695afaae02c805cb10028 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 4 Sep 2026 18:53:35 -0700 Subject: [PATCH 05/11] Register every workflows/ source package as a deployable catalog entry (CL-7073) CATALOG_WORKFLOWS had 4 entries against roughly 15 workflows/ source packages, so most of a template's blocks (the GTM template's, in particular) 404ed through the catalog instantiate route. Registers granola-call, morning-brief, exa-topic-watch, process-granola-call, attio-task-agent, pain-point-collateral, reddit-opportunity-scanner, collateral-generation, and diligence-brief alongside the existing entries, each with its own buildJson. Also drops the dead @corbits/code-review-workflow dependency from apps/hub/package.json, which nothing there imports directly anymore. --- apps/hub/package.json | 1 - apps/hub/src/templates/block-workflows.ts | 2 +- packages/seeding/package.json | 9 ++ packages/seeding/src/seed.ts | 182 ++++++++++++++++++++++ 4 files changed, 192 insertions(+), 2 deletions(-) diff --git a/apps/hub/package.json b/apps/hub/package.json index 95340320b9..5609701632 100644 --- a/apps/hub/package.json +++ b/apps/hub/package.json @@ -47,7 +47,6 @@ "@corbits/tool-registry-publish": "workspace:*", "@corbits/turn-artifacts": "workspace:*", "@corbits/url-path": "workspace:*", - "@corbits/code-review-workflow": "workspace:*", "@corbits/webhook-triggers": "workspace:*", "@corbits/workflows": "workspace:*", "@intx/authz": "0.3.0", diff --git a/apps/hub/src/templates/block-workflows.ts b/apps/hub/src/templates/block-workflows.ts index 4e996083cc..0315c4872d 100644 --- a/apps/hub/src/templates/block-workflows.ts +++ b/apps/hub/src/templates/block-workflows.ts @@ -13,7 +13,7 @@ // binding). // // Server-only, on purpose: building a catalog workflow's definition -// pulls in its workflow package (e.g. `@corbits/code-review-workflow`) +// pulls in its workflow package (e.g. `@corbits/granola-call-workflow`) // and with it `@intx/agent`/`@intx/workflow` — the heavy graph // `./templates.ts` keeps every manifest consumer off. Only // `./template-block-routes.ts` (mounted in `apps/hub`) imports this; it diff --git a/packages/seeding/package.json b/packages/seeding/package.json index 94d3627019..9d85948e75 100644 --- a/packages/seeding/package.json +++ b/packages/seeding/package.json @@ -14,12 +14,21 @@ }, "dependencies": { "@corbits/assistant-workflow": "workspace:*", + "@corbits/attio-task-agent-workflow": "workspace:*", "@corbits/code-review-workflow": "workspace:*", + "@corbits/collateral-generation-workflow": "workspace:*", + "@corbits/diligence-brief-workflow": "workspace:*", "@corbits/echo-workflow": "workspace:*", + "@corbits/exa-topic-watch-workflow": "workspace:*", + "@corbits/granola-call-workflow": "workspace:*", "@corbits/heartbeat-workflow": "workspace:*", "@corbits/hub-api-client": "workspace:*", "@corbits/inference-catalog": "workspace:*", "@corbits/last-30-days-research-workflow": "workspace:*", + "@corbits/morning-brief-workflow": "workspace:*", + "@corbits/pain-point-collateral-workflow": "workspace:*", + "@corbits/process-granola-call-workflow": "workspace:*", + "@corbits/reddit-opportunity-scanner-workflow": "workspace:*", "@corbits/tool-registry-publish": "workspace:*", "@corbits/workbench-digest-workflow": "workspace:*", "@corbits/workflows": "workspace:*", diff --git a/packages/seeding/src/seed.ts b/packages/seeding/src/seed.ts index d77c934d23..2b34e98c3d 100644 --- a/packages/seeding/src/seed.ts +++ b/packages/seeding/src/seed.ts @@ -50,6 +50,42 @@ 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"; import { WORKFLOW_CATALOG } from "@workbench/templates"; import { capabilitiesForDeployment } from "@corbits/inference-catalog/offering-capabilities"; import { quirksForDeployment } from "@corbits/inference-catalog/ollama-context-defaults"; @@ -88,6 +124,20 @@ const LAST_30_DAYS_RESEARCH_TURN_TIMEOUT_MS = 2 * 60 * 1000; // codebase uses: a review turn reads a diff and posts one review, the // same order of work as a research or assistant turn. const CODE_REVIEW_TURN_TIMEOUT_MS = 2 * 60 * 1000; +// Same conversational default as the entries above: one mail-triggered +// reasoning turn per run, no multi-step DAG. +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; +// A transcript-plus-extraction-plus-verification pass over a long call +// can run well past the shortest steps in the catalog (see the +// workflow's own README), so this gets extra headroom. +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; const RUN_START_TIMEOUT_MS = 30_000; const RUN_POLL_INTERVAL_MS = 1000; @@ -353,6 +403,123 @@ export const CATALOG_WORKFLOWS: readonly DefaultWorkflow[] = [ }), ), }, + { + assetName: "granola-call", + displayName: catalogDisplayName("granola-call"), + automatable: catalogAutomatable("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"), + automatable: catalogAutomatable("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"), + automatable: catalogAutomatable("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"), + automatable: catalogAutomatable("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"), + automatable: catalogAutomatable("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"), + automatable: catalogAutomatable("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"), + automatable: catalogAutomatable("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"), + automatable: catalogAutomatable("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"), + automatable: catalogAutomatable("diligence-brief"), + buildJson: (tenantDomain, inferencePreferences) => + serializeDiligenceBriefWorkflow( + buildDiligenceBriefWorkflow({ + triggerAddress: `diligence-brief@${tenantDomain}`, + inferencePreferences, + turnTimeoutMs: DILIGENCE_BRIEF_TURN_TIMEOUT_MS, + }), + ), + }, ]; /** @@ -387,6 +554,21 @@ export const CATALOG_TEST_WORKFLOWS: readonly DefaultWorkflow[] = [ }, ]; +/** + * Every `workflows/` source directory that deliberately carries no + * `DefaultWorkflow` entry anywhere (`DEFAULT_WORKFLOWS`, + * `CATALOG_WORKFLOWS`, `CATALOG_TEST_WORKFLOWS`), with a one-line reason + * each. Kept empty on purpose right now — every current source directory + * is registered somewhere — so a future package that's genuinely not a + * deployable workflow (a shared library living under `workflows/` by + * convention, say) has a place to say so instead of failing + * `seed.test.ts`'s registration-invariant test silently by omission. + */ +export const EXCLUDED_WORKFLOW_SOURCES: readonly { + readonly name: string; + readonly reason: string; +}[] = []; + /** * The deployable-through-the-catalog-instantiate-route entry for one * asset name (CL-7073), or `undefined` if none exists. `CATALOG_WORKFLOWS` From b56308bd42e66e539bc2d043264c3ba484b734ec Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 4 Sep 2026 18:53:39 -0700 Subject: [PATCH 06/11] Update docs: registration invariant covers every workflows/ source package (CL-7073) --- docs/seed-reconciliation.md | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/docs/seed-reconciliation.md b/docs/seed-reconciliation.md index 98dac3adff..b6f84a1797 100644 --- a/docs/seed-reconciliation.md +++ b/docs/seed-reconciliation.md @@ -61,10 +61,27 @@ not plant wrapper rows. real signup gets automatically: `assistant` (Myra), and nothing else. A fresh bench used to also pay a git push and a sidecar probe for `echo`, `workbench-digest`, and `last-30-days-research` — three workflows -nobody had asked for yet. Those three, plus `code-review`, now live in -`CATALOG_WORKFLOWS`, same shape (`DefinitionWithAgentSteps`-backed -`DefaultWorkflow` entries) and deployable through the catalog -instantiate route (CL-7073), but never automatically at signup. +nobody had asked for yet. Those three, plus every other +`workflows/` source package that exports a builder +(`code-review`, `granola-call`, `morning-brief`, `exa-topic-watch`, +`process-granola-call`, `attio-task-agent`, `pain-point-collateral`, +`reddit-opportunity-scanner`, `collateral-generation`, +`diligence-brief`), now live in `CATALOG_WORKFLOWS`, same shape +(`DefinitionWithAgentSteps`-backed `DefaultWorkflow` entries) and +deployable through the catalog instantiate route (CL-7073), but never +automatically at signup. + +Registration is total, not partial: `packages/seeding/test/workflow-source-registration.test.ts` +asserts every directory under `workflows/` appears in exactly one of +`DEFAULT_WORKFLOWS`, `CATALOG_WORKFLOWS`, `CATALOG_TEST_WORKFLOWS`, or +the explicit `EXCLUDED_WORKFLOW_SOURCES` list (a one-line reason per +entry, currently empty — every source directory is registered +somewhere today). A new `workflows/` package that nobody wires up +fails that test instead of 404ing silently through the catalog +instantiate route, which is exactly the drift CL-7073's critique +caught: `CATALOG_WORKFLOWS` had four entries while a template's blocks +(the GTM template's, in particular) named source packages that were +never registered at all. There is no orphan-retire for an entry that moved from `DEFAULT_WORKFLOWS` to `CATALOG_WORKFLOWS`: an asset already deployed From 1f71d2f5aaf7d0de06848fe399da4e03ab81413b Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 4 Sep 2026 19:07:27 -0700 Subject: [PATCH 07/11] Fix e2e: only deploy-and-confirm-live catalog workflows that need no connection (CL-7073) CATALOG_WORKFLOWS now covers every workflows/ source package (CL-7073), including several whose definition wires a real credentialBindings entry (granola-call, morning-brief, process-granola-call, pain-point-collateral, collateral-generation, diligence-brief). Each requires its plugin actually connected to resolve at deploy, which this walking-skeleton tenant never does, and hits deployWorkflowFromSource's existing credentialCipher-required guard the same way a real unconnected tenant would. local-rip.test.ts's "deploy and go live" hop now excludes those from its assertion; every catalog entry (credential-bound included) still gets route coverage in template-block-routes.test.ts against fakes. --- scripts/e2e/local-rip.test.ts | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/scripts/e2e/local-rip.test.ts b/scripts/e2e/local-rip.test.ts index 371cff7c2b..cc432d62bd 100644 --- a/scripts/e2e/local-rip.test.ts +++ b/scripts/e2e/local-rip.test.ts @@ -424,9 +424,36 @@ describe.skipIf(databaseUrl === undefined)( ); await hop( - "the default workflow (assistant) plus the on-demand catalog (echo, workbench-digest) deploy and go live (CL-7074: only assistant is seeded automatically; the rest deploy here via the same seeding-library path a real on-demand deploy would use)", + "the default workflow (assistant) plus the credential-free on-demand catalog (echo, workbench-digest, last-30-days-research, code-review) deploy and go live (CL-7074: only assistant is seeded automatically; the rest deploy here via the same seeding-library path a real on-demand deploy would use)", async () => { - const workflows = [...DEFAULT_WORKFLOWS, ...CATALOG_WORKFLOWS]; + // CATALOG_WORKFLOWS grew (CL-7073) to cover every workflows/ + // source package, including several whose definition wires a + // real `credentialBindings` entry (granola-call, + // morning-brief, process-granola-call, pain-point-collateral, + // collateral-generation, diligence-brief) — each REQUIRES its + // plugin actually connected on the deploying tenant to + // resolve, and this walking-skeleton tenant connects none of + // them. Deploying one here would not exercise a real gap in + // this repo's own code; it would hit + // `deployWorkflowFromSource`'s existing, documented + // `credentialCipher`-required guard + // (`vendor/intx/hub-sessions/src/session-service.ts`) the + // exact way a real, unconnected tenant would. This hop keeps + // asserting deploy-and-go-live for the workflows that need no + // connection to run; `template-block-routes.test.ts` covers + // every catalog entry's route wiring (including the + // credential-bound ones) against fakes. + const workflows = [...DEFAULT_WORKFLOWS, ...CATALOG_WORKFLOWS].filter( + (workflow) => + ![ + "granola-call", + "morning-brief", + "process-granola-call", + "pain-point-collateral", + "collateral-generation", + "diligence-brief", + ].includes(workflow.assetName), + ); await deploySeededWorkflows(workflows); for (const workflow of workflows) { const assetsRes = await api( From bc740f2c8495052166eab247a200f42b21f81c08 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 4 Sep 2026 19:48:52 -0700 Subject: [PATCH 08/11] Add tests for the credential-cipher deploy gate (CL-7073) The six catalog workflows whose definitions carry credentialBindings (granola-call, morning-brief, process-granola-call, pain-point-collateral, collateral-generation, diligence-brief) cannot deploy through POST /template-blocks/:assetName/deploy on this Interchange pin: deployCodeSourcedWorkflow throws without a credentialCipher this front does not supply. These tests pin the derivation of that six-name set from each entry's own buildJson output, assert the route refuses them with a 409 not_deployable_yet envelope instead of hitting the deployer's 500 path, and swap local-rip's hand-typed exclusion list for the same derivation helper. --- .../templates/template-block-routes.test.ts | 26 ++++++++-- .../seeding/src/catalog-cipher-gate.test.ts | 52 +++++++++++++++++++ scripts/e2e/local-rip.test.ts | 46 +++++++--------- 3 files changed, 93 insertions(+), 31 deletions(-) create mode 100644 packages/seeding/src/catalog-cipher-gate.test.ts diff --git a/apps/hub/src/templates/template-block-routes.test.ts b/apps/hub/src/templates/template-block-routes.test.ts index 4ccd764765..610c76eb56 100644 --- a/apps/hub/src/templates/template-block-routes.test.ts +++ b/apps/hub/src/templates/template-block-routes.test.ts @@ -123,19 +123,21 @@ describe("POST /:assetName/deploy", () => { expect(deployed).toHaveLength(0); }); - test("the GTM template's first block (granola-call) deploys through the route now that it is registered (CL-7073)", async () => { + test("the GTM template's first block (exa-topic-watch) deploys through the route now that it is registered (CL-7073)", async () => { const { app, deployed } = buildApp(); - const res = await app.request("/granola-call/deploy", { method: "POST" }); + const res = await app.request("/exa-topic-watch/deploy", { + method: "POST", + }); expect(res.status).toBe(201); expect(deployed).toHaveLength(1); const source = deployed[0]; if (source === undefined) throw new Error("nothing deployed"); - expect(source.assetName).toBe("granola-call"); + expect(source.assetName).toBe("exa-topic-watch"); const definition = JSON.parse(source.workflowJson) as { triggers: { type: string; to: string }[]; }; expect(definition.triggers).toEqual([ - { type: "mail", to: "granola-call@acme.example" }, + { type: "mail", to: "exa-topic-watch@acme.example" }, ]); }); @@ -194,6 +196,22 @@ describe("POST /:assetName/deploy", () => { expect(deployed).toHaveLength(0); }); + test("a credential-bound catalog workflow answers 409 not_deployable_yet, deploying nothing (CL-7073)", async () => { + const { app, deployed } = buildApp(); + const res = await app.request("/granola-call/deploy", { method: "POST" }); + expect(res.status).toBe(409); + const body = (await res.json()) as { + error: { code: string; userMessage: string; refId: string }; + }; + expect(body.error.code).toBe("not_deployable_yet"); + expect(body.error.userMessage).not.toMatch( + /cipher|pin|vendor|Interchange/i, + ); + expect(typeof body.error.refId).toBe("string"); + expect(body.error.refId.length).toBeGreaterThan(0); + expect(deployed).toHaveLength(0); + }); + test("a failing deploy port answers a 500 envelope, never a raw error", async () => { const { app } = buildApp({ deployWorkflowSource: async () => { diff --git a/packages/seeding/src/catalog-cipher-gate.test.ts b/packages/seeding/src/catalog-cipher-gate.test.ts new file mode 100644 index 0000000000..86be78c4a3 --- /dev/null +++ b/packages/seeding/src/catalog-cipher-gate.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from "bun:test"; + +import { + CATALOG_WORKFLOWS, + catalogWorkflowDeployableOnThisPin, + catalogWorkflowRequiresCredentialCipher, +} from "./seed"; + +// The six catalog entries whose definitions carry `credentialBindings` +// (CL-7073 catalog critique finding 1): `deployCodeSourcedWorkflow` +// (vendor/intx/hub-sessions) refuses to resolve these without a +// `credentialCipher`, a seam the current pin's +// `POST /template-blocks/:assetName/deploy` front does not supply. This +// is the one list asserted against the derivation helper — the route +// refusal, the available-catalog `deployable: false` flag, and the e2e +// exclusion list all call `catalogWorkflowDeployableOnThisPin` instead of +// keeping their own copy. +const CREDENTIAL_BOUND_ASSET_NAMES = [ + "granola-call", + "morning-brief", + "process-granola-call", + "pain-point-collateral", + "collateral-generation", + "diligence-brief", +]; + +describe("catalogWorkflowRequiresCredentialCipher", () => { + test("names exactly the six credential-bound catalog entries", () => { + const flagged = CATALOG_WORKFLOWS.filter((entry) => + catalogWorkflowRequiresCredentialCipher(entry), + ).map((entry) => entry.assetName); + expect(flagged.sort()).toEqual([...CREDENTIAL_BOUND_ASSET_NAMES].sort()); + }); +}); + +describe("catalogWorkflowDeployableOnThisPin", () => { + test("is false for every credential-bound entry", () => { + for (const assetName of CREDENTIAL_BOUND_ASSET_NAMES) { + expect(catalogWorkflowDeployableOnThisPin(assetName)).toBe(false); + } + }); + + test("is true for a catalog entry with no credential bindings", () => { + expect(catalogWorkflowDeployableOnThisPin("code-review")).toBe(true); + }); + + test("is false for a name outside the catalog entirely", () => { + expect(catalogWorkflowDeployableOnThisPin("not-a-real-workflow")).toBe( + false, + ); + }); +}); diff --git a/scripts/e2e/local-rip.test.ts b/scripts/e2e/local-rip.test.ts index cc432d62bd..f29af7ed85 100644 --- a/scripts/e2e/local-rip.test.ts +++ b/scripts/e2e/local-rip.test.ts @@ -56,6 +56,7 @@ import { describe, expect, test } from "bun:test"; import { resetSchema, setupDatabase } from "../db-setup.ts"; import { CATALOG_WORKFLOWS, + catalogWorkflowDeployableOnThisPin, createGitWorkflowPusher, DEFAULT_WORKFLOWS, isLiveDeploymentStatus, @@ -427,33 +428,24 @@ describe.skipIf(databaseUrl === undefined)( "the default workflow (assistant) plus the credential-free on-demand catalog (echo, workbench-digest, last-30-days-research, code-review) deploy and go live (CL-7074: only assistant is seeded automatically; the rest deploy here via the same seeding-library path a real on-demand deploy would use)", async () => { // CATALOG_WORKFLOWS grew (CL-7073) to cover every workflows/ - // source package, including several whose definition wires a - // real `credentialBindings` entry (granola-call, - // morning-brief, process-granola-call, pain-point-collateral, - // collateral-generation, diligence-brief) — each REQUIRES its - // plugin actually connected on the deploying tenant to - // resolve, and this walking-skeleton tenant connects none of - // them. Deploying one here would not exercise a real gap in - // this repo's own code; it would hit - // `deployWorkflowFromSource`'s existing, documented - // `credentialCipher`-required guard - // (`vendor/intx/hub-sessions/src/session-service.ts`) the - // exact way a real, unconnected tenant would. This hop keeps - // asserting deploy-and-go-live for the workflows that need no - // connection to run; `template-block-routes.test.ts` covers - // every catalog entry's route wiring (including the - // credential-bound ones) against fakes. - const workflows = [...DEFAULT_WORKFLOWS, ...CATALOG_WORKFLOWS].filter( - (workflow) => - ![ - "granola-call", - "morning-brief", - "process-granola-call", - "pain-point-collateral", - "collateral-generation", - "diligence-brief", - ].includes(workflow.assetName), - ); + // source package, including six whose definition wires a real + // `credentialBindings` entry. On the current Interchange pin, + // this front's `deployWorkflowSource` port has no + // `credentialCipher` seam to resolve those bindings (see + // `catalogWorkflowDeployableOnThisPin`, + // `docs/seed-reconciliation.md`) — these six cannot deploy + // through this front at all right now, and are excluded here + // until the Interchange re-pin (CL-7107 / PR #632, pin + // 692c3106) adds that seam. This hop keeps asserting + // deploy-and-go-live for every workflow this pin CAN deploy; + // `template-block-routes.test.ts` covers the excluded ones' + // 409 route refusal against fakes. + const workflows = [ + ...DEFAULT_WORKFLOWS, + ...CATALOG_WORKFLOWS.filter((workflow) => + catalogWorkflowDeployableOnThisPin(workflow.assetName), + ), + ]; await deploySeededWorkflows(workflows); for (const workflow of workflows) { const assetsRes = await api( From 9b0a1d7aed3739e8c5697de85a8695d2884466d9 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 4 Sep 2026 19:49:00 -0700 Subject: [PATCH 09/11] Refuse credential-bound catalog workflows with 409, not a 500 (CL-7073) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit catalogWorkflowDeployableOnThisPin (@corbits/seeding) derives, from each CATALOG_WORKFLOWS entry's own rendered definition, whether it carries credentialBindings the current Interchange pin's deploy front has no credentialCipher seam to resolve. The template-blocks deploy route now calls it before invoking deployWorkflowSource and answers a 409 not_deployable_yet envelope with consumer-safe copy for the six credential-bound entries, instead of letting the vendored deployer throw into the route's generic 500 branch. Per the CL-7073 critique ruling: this does not route through the adopting deploy front or add a credential-cipher seam to the vendored deployer — that seam lands with the Interchange re-pin (CL-7107 / PR #632, pin 692c3106). Until then this is the honest gap to state, not paper over. --- .../src/templates/template-block-routes.ts | 21 +++++++++++ packages/seeding/src/index.ts | 2 + packages/seeding/src/seed.ts | 37 +++++++++++++++++++ 3 files changed, 60 insertions(+) diff --git a/apps/hub/src/templates/template-block-routes.ts b/apps/hub/src/templates/template-block-routes.ts index d363386efd..e21e6489e9 100644 --- a/apps/hub/src/templates/template-block-routes.ts +++ b/apps/hub/src/templates/template-block-routes.ts @@ -11,6 +11,7 @@ import { Hono } from "hono"; import type { RequireGrant, TenantEnv } from "@intx/hub-api"; import { makeErrorEnvelope } from "@corbits/error-sink"; +import { catalogWorkflowDeployableOnThisPin } from "@corbits/seeding"; import { buildBlockWorkflowSource, @@ -20,6 +21,16 @@ import { const DEPLOY_FAILED_MESSAGE = "Couldn't set up this template's workflow. Try again in a moment."; +// Six catalog workflows carry `credentialBindings` their definition needs +// resolved at deploy time; this pin's `deployWorkflowSource` port has no +// `credentialCipher` seam to resolve them (see +// `catalogWorkflowDeployableOnThisPin`, `docs/seed-reconciliation.md`). +// Refusing here, before `deployWorkflowSource` is ever called, keeps that +// gap an honest 409 instead of the deployer throwing into the 500 branch +// below. +const NOT_DEPLOYABLE_YET_MESSAGE = + "Coming with the next platform update."; + export type TemplateBlockRoutesDeps = { requireGrant: RequireGrant; /** Where a failure's real cause goes — the same CL-6360 idiom @@ -76,6 +87,16 @@ export function createTemplateBlockRoutes( ); } + if (!catalogWorkflowDeployableOnThisPin(assetName)) { + return c.json( + makeErrorEnvelope({ + code: "not_deployable_yet", + userMessage: NOT_DEPLOYABLE_YET_MESSAGE, + }), + 409, + ); + } + try { const result = await deps.deployWorkflowSource({ tenantId: tenant.id, diff --git a/packages/seeding/src/index.ts b/packages/seeding/src/index.ts index 78506cd4fb..911b911b52 100644 --- a/packages/seeding/src/index.ts +++ b/packages/seeding/src/index.ts @@ -22,6 +22,8 @@ export { CATALOG_WORKFLOWS, DEFAULT_WORKFLOWS, SEED_GRANTS, + catalogWorkflowDeployableOnThisPin, + catalogWorkflowRequiresCredentialCipher, deployableCatalogWorkflow, inferenceCredentialName, PLACEHOLDER_CATALOG_API_KEY, diff --git a/packages/seeding/src/seed.ts b/packages/seeding/src/seed.ts index 2b34e98c3d..afd3521717 100644 --- a/packages/seeding/src/seed.ts +++ b/packages/seeding/src/seed.ts @@ -584,6 +584,43 @@ export function deployableCatalogWorkflow( return CATALOG_WORKFLOWS.find((workflow) => workflow.assetName === assetName); } +/** + * Whether a catalog entry's own definition carries `credentialBindings` — + * the same field `deployCodeSourcedWorkflow` (`vendor/intx/hub-sessions`) + * refuses to resolve without a `credentialCipher`, a seam the current + * Interchange pin's `POST /template-blocks/:assetName/deploy` front does + * not supply (see `docs/seed-reconciliation.md`; closes at the re-pin, + * CL-7107 / PR #632, pin 692c3106). Derived by rendering the entry's own + * `buildJson` with placeholder deploy args and reading the serialized + * definition's `credentialBindings` back — never a hand-kept list, so this + * can never drift from the workflows that actually declare bindings. + */ +export function catalogWorkflowRequiresCredentialCipher( + entry: DefaultWorkflow, +): boolean { + const rendered = entry.buildJson("example.workbench.invalid", []); + const parsed = JSON.parse(rendered) as { + credentialBindings?: readonly unknown[]; + }; + return (parsed.credentialBindings?.length ?? 0) > 0; +} + +/** + * Whether a catalog asset name can deploy through the current + * `POST /template-blocks/:assetName/deploy` front on this Interchange pin. + * `false` for a name with no `CATALOG_WORKFLOWS` entry at all (nothing + * deployable) or one whose entry requires a `credentialCipher` this pin + * cannot supply — the route and the available-catalog listing both call + * this instead of keeping their own copy of which six entries qualify. + */ +export function catalogWorkflowDeployableOnThisPin( + assetName: string, +): boolean { + const entry = deployableCatalogWorkflow(assetName); + if (entry === undefined) return false; + return !catalogWorkflowRequiresCredentialCipher(entry); +} + // The grants the deploy, trigger, and run-listing routes gate on, // planted at the wildcard scope the authz glob matcher resolves // against any concrete deployment (the deployment id is minted at From 0ba49eac63db8cebb87e8b5c1193d89571db9719 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 4 Sep 2026 19:49:04 -0700 Subject: [PATCH 10/11] Update docs: credential-bound catalog workflows until the re-pin (CL-7073) Records why the six credential-bound CATALOG_WORKFLOWS entries 409 instead of deploying on this pin, and that they become addable at the Interchange re-pin (CL-7107 / PR #632, pin 692c3106). --- docs/seed-reconciliation.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/seed-reconciliation.md b/docs/seed-reconciliation.md index b6f84a1797..c180364fe2 100644 --- a/docs/seed-reconciliation.md +++ b/docs/seed-reconciliation.md @@ -187,3 +187,23 @@ runs against that existing credential (`existingCredentialId`, no missing rows are planted, existing ones 409-skip, nothing is deleted. Removing an env var never deletes the planted credential: credentials are operator data once planted, not seeds to garbage-collect. + +## Credential-bound catalog workflows (CL-7073) + +Six `CATALOG_WORKFLOWS` entries — granola-call, morning-brief, +process-granola-call, pain-point-collateral, collateral-generation, +diligence-brief — declare `credentialBindings` in their definition. +`deployCodeSourcedWorkflow` (`vendor/intx/hub-sessions`) refuses to +resolve those bindings without a `credentialCipher`, and the current +Interchange pin's `POST /template-blocks/:assetName/deploy` front (the +route the Routines "Available" catalog's Add action drives) has no +seam to supply one. `catalogWorkflowDeployableOnThisPin` +(`packages/seeding/src/seed.ts`) is the one place that knows this: the +deploy route refuses these six with a 409 `not_deployable_yet` before +the deployer ever throws, and the available-catalog route marks them +`deployable: false` so the UI offers them with disabled, honest copy +instead of a working-looking Add button. These six become addable at +the Interchange re-pin (CL-7107 / PR #632, pin 692c3106), which adds +the `credentialCipher` parameter this front is missing — no code in +this ledger's callers needs to change, only the entry's derived +`requiresCredentialCipher` result once the seam exists. From 4377eff59e4cddc0eca1490e822fcd31bbdff6eb Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 4 Sep 2026 19:54:33 -0700 Subject: [PATCH 11/11] Fix formatting (prettier) --- apps/hub/src/templates/template-block-routes.ts | 3 +-- packages/seeding/src/seed.ts | 4 +--- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/apps/hub/src/templates/template-block-routes.ts b/apps/hub/src/templates/template-block-routes.ts index e21e6489e9..5a6082fcde 100644 --- a/apps/hub/src/templates/template-block-routes.ts +++ b/apps/hub/src/templates/template-block-routes.ts @@ -28,8 +28,7 @@ const DEPLOY_FAILED_MESSAGE = // Refusing here, before `deployWorkflowSource` is ever called, keeps that // gap an honest 409 instead of the deployer throwing into the 500 branch // below. -const NOT_DEPLOYABLE_YET_MESSAGE = - "Coming with the next platform update."; +const NOT_DEPLOYABLE_YET_MESSAGE = "Coming with the next platform update."; export type TemplateBlockRoutesDeps = { requireGrant: RequireGrant; diff --git a/packages/seeding/src/seed.ts b/packages/seeding/src/seed.ts index afd3521717..48c9f781bf 100644 --- a/packages/seeding/src/seed.ts +++ b/packages/seeding/src/seed.ts @@ -613,9 +613,7 @@ export function catalogWorkflowRequiresCredentialCipher( * cannot supply — the route and the available-catalog listing both call * this instead of keeping their own copy of which six entries qualify. */ -export function catalogWorkflowDeployableOnThisPin( - assetName: string, -): boolean { +export function catalogWorkflowDeployableOnThisPin(assetName: string): boolean { const entry = deployableCatalogWorkflow(assetName); if (entry === undefined) return false; return !catalogWorkflowRequiresCredentialCipher(entry);