From f8960082391ace2962a7b308c58780a0c23a9385 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 4 Sep 2026 19:28:48 -0700 Subject: [PATCH 1/5] Add tests for the catalog Available list and Add action (CL-7073) Covers the pure available-list computation (deployed entries excluded, connection satisfaction, unknown catalog names skipped), the GET /available route (injected list, grant denial), and the Routines page's Available section (renders entries, Add posts through the existing template-blocks deploy route, disabled Add with a reason for a missing connection). --- .../test/routines-available-section.test.tsx | 166 ++++++++++++++++++ .../src/schedule/available-catalog.test.ts | 68 +++++++ .../src/schedule/scheduled-route.test.ts | 44 ++++- 3 files changed, 277 insertions(+), 1 deletion(-) create mode 100644 apps/web/test/routines-available-section.test.tsx create mode 100644 packages/workflows/src/schedule/available-catalog.test.ts diff --git a/apps/web/test/routines-available-section.test.tsx b/apps/web/test/routines-available-section.test.tsx new file mode 100644 index 0000000000..18cbe107f6 --- /dev/null +++ b/apps/web/test/routines-available-section.test.tsx @@ -0,0 +1,166 @@ +// 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 existing template-blocks deploy route, and a +// missing required connection disables Add with a reason instead of +// letting the request fail. + +import { describe, expect, test } from "bun:test"; +import { act, createElement } from "react"; +import { createRoot } from "react-dom/client"; +import type { Root } from "react-dom/client"; + +import { AvailableCatalogWorkflowsSection } from "../src/pages/routines-page"; +import { NavigationProvider } from "../src/navigation"; +import { TestQueryProvider } from "./test-query-provider"; + +const noop = () => undefined; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +const codeReview = { + assetName: "code-review", + displayName: "Code review", + description: "Reviews a pull request and posts one review back on it.", + requiredConnections: ["github"], + missingConnections: ["github"], + connectionsSatisfied: false, +}; + +const echo = { + assetName: "echo", + displayName: "Echo", + description: "Replies with the exact text it received.", + requiredConnections: [], + missingConnections: [], + connectionsSatisfied: true, +}; + +async function render( + fetchImpl: typeof fetch, +): Promise<{ container: HTMLDivElement; root: Root }> { + const container = document.createElement("div"); + document.body.appendChild(container); + const root: Root = createRoot(container); + const realFetch = globalThis.fetch; + globalThis.fetch = fetchImpl; + await act(async () => { + root.render( + + + {createElement(AvailableCatalogWorkflowsSection, { + tenantId: "tnt_1", + })} + + , + ); + }); + for (let i = 0; i < 8; i++) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + } + (container as unknown as { __realFetch: typeof fetch }).__realFetch = + realFetch; + return { container, root }; +} + +function restoreFetch(container: HTMLDivElement, root: Root): void { + act(() => root.unmount()); + container.remove(); + globalThis.fetch = ( + container as unknown as { __realFetch: typeof fetch } + ).__realFetch; + window.localStorage.clear(); +} + +describe("AvailableCatalogWorkflowsSection", () => { + test("renders every available entry with its display name and description", async () => { + const { container, root } = await render((async ( + input: RequestInfo | URL, + ) => { + const url = String(input); + if (url.includes("/workflows/available")) { + return jsonResponse({ items: [codeReview, echo] }); + } + return Promise.reject(new Error(`unrouted fetch: ${url}`)); + }) as typeof fetch); + try { + expect(container.textContent).toContain("Code review"); + expect(container.textContent).toContain( + "Reviews a pull request and posts one review back on it.", + ); + expect(container.textContent).toContain("Echo"); + } finally { + restoreFetch(container, root); + } + }); + + test("Add is disabled with a reason when a required connection is missing, and links to Plugins", async () => { + const { container, root } = await render((async ( + input: RequestInfo | URL, + ) => { + const url = String(input); + if (url.includes("/workflows/available")) { + return jsonResponse({ items: [codeReview] }); + } + 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"); + } finally { + restoreFetch(container, root); + } + }); + + test("Add posts to the template-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("/template-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); + } + }); +}); diff --git a/packages/workflows/src/schedule/available-catalog.test.ts b/packages/workflows/src/schedule/available-catalog.test.ts new file mode 100644 index 0000000000..f3323c586e --- /dev/null +++ b/packages/workflows/src/schedule/available-catalog.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from "bun:test"; + +import { availableCatalogWorkflowsFrom } from "./available-catalog"; + +describe("availableCatalogWorkflowsFrom", () => { + test("excludes a catalog name that already has a deployed definition", () => { + const result = availableCatalogWorkflowsFrom({ + catalogAssetNames: ["echo", "code-review"], + deployedNames: new Set(["echo"]), + isConnectorSatisfied: () => true, + }); + expect(result.map((r) => r.assetName)).toEqual(["code-review"]); + }); + + test("skips a catalog name with no WORKFLOW_CATALOG entry rather than throwing", () => { + const result = availableCatalogWorkflowsFrom({ + catalogAssetNames: ["not-a-real-workflow"], + deployedNames: new Set(), + isConnectorSatisfied: () => true, + }); + expect(result).toEqual([]); + }); + + test("a workflow with no required connections is always satisfied", () => { + const result = availableCatalogWorkflowsFrom({ + catalogAssetNames: ["echo"], + deployedNames: new Set(), + isConnectorSatisfied: () => false, + }); + expect(result).toHaveLength(1); + expect(result[0]?.requiredConnections).toEqual([]); + expect(result[0]?.missingConnections).toEqual([]); + expect(result[0]?.connectionsSatisfied).toBe(true); + }); + + test("a workflow whose required connection is not satisfied names it as missing", () => { + const result = availableCatalogWorkflowsFrom({ + catalogAssetNames: ["code-review"], + deployedNames: new Set(), + isConnectorSatisfied: () => false, + }); + expect(result).toHaveLength(1); + expect(result[0]?.requiredConnections).toEqual(["github"]); + expect(result[0]?.missingConnections).toEqual(["github"]); + expect(result[0]?.connectionsSatisfied).toBe(false); + }); + + test("a workflow whose required connection is satisfied carries no missing connections", () => { + const result = availableCatalogWorkflowsFrom({ + catalogAssetNames: ["code-review"], + deployedNames: new Set(), + isConnectorSatisfied: (id) => id === "github", + }); + expect(result).toHaveLength(1); + expect(result[0]?.missingConnections).toEqual([]); + expect(result[0]?.connectionsSatisfied).toBe(true); + }); + + test("carries the display name and description straight from WORKFLOW_CATALOG", () => { + const result = availableCatalogWorkflowsFrom({ + catalogAssetNames: ["echo"], + deployedNames: new Set(), + isConnectorSatisfied: () => true, + }); + expect(result[0]?.displayName.length).toBeGreaterThan(0); + expect(result[0]?.description.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/workflows/src/schedule/scheduled-route.test.ts b/packages/workflows/src/schedule/scheduled-route.test.ts index 0736455ac9..34b119f3d0 100644 --- a/packages/workflows/src/schedule/scheduled-route.test.ts +++ b/packages/workflows/src/schedule/scheduled-route.test.ts @@ -8,6 +8,7 @@ import { RUN_NOW_CONTENT, } from "./scheduled-route"; import type { ScheduledWorkflowDefinition } from "./list-scheduled"; +import type { AvailableCatalogWorkflow } from "./available-catalog"; const TENANT = { id: "tnt_1", @@ -51,6 +52,11 @@ function mount( assetId: string; }) => Promise<{ runId: string }>, listed: readonly ScheduledWorkflowDefinition[], + overrides: { + requireGrant?: RequireGrant; + catalogAssetNames?: readonly string[]; + listAvailable?: () => Promise; + } = {}, ): Hono { const asTenant: MiddlewareHandler = async (c, next) => { c.set("tenant", TENANT as never); @@ -63,9 +69,15 @@ function mount( "/", createScheduledWorkflowRoutes({ db: {} as never, - requireGrant: allowAll, + requireGrant: overrides.requireGrant ?? allowAll, runNow, listScheduled: async () => listed, + ...(overrides.catalogAssetNames !== undefined + ? { catalogAssetNames: overrides.catalogAssetNames } + : {}), + ...(overrides.listAvailable !== undefined + ? { listAvailable: overrides.listAvailable } + : {}), }), ); return app; @@ -118,4 +130,34 @@ describe("createScheduledWorkflowRoutes", () => { }); expect(res.status).toBe(404); }); + + test("GET /available answers the injected available list", async () => { + const entry: AvailableCatalogWorkflow = { + assetName: "code-review", + displayName: "Code review", + description: "Reviews a pull request and posts one review back on it.", + requiredConnections: ["github"], + missingConnections: ["github"], + connectionsSatisfied: false, + }; + const app = mount(async () => ({ runId: "run_x" }), [], { + catalogAssetNames: ["code-review"], + listAvailable: async () => [entry], + }); + const res = await app.request("/available"); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ items: [entry] }); + }); + + test("GET /available denies without the workflow:* read grant", async () => { + const deny: RequireGrant = () => async (c) => { + return c.json({ error: { code: "forbidden" } }, 403); + }; + const app = mount(async () => ({ runId: "run_x" }), [], { + requireGrant: deny, + listAvailable: async () => [], + }); + const res = await app.request("/available"); + expect(res.status).toBe(403); + }); }); From 3a1b024d0b9341bdd2020f8e7917e0f46160c196 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 4 Sep 2026 19:28:56 -0700 Subject: [PATCH 2/5] Routines: offer catalog workflows to add from the page (CL-7073) Hub: GET /api/tenants/:tenantId/workflows/available lists every CATALOG_WORKFLOWS entry with no deployed definition on the caller's bench yet, carrying WORKFLOW_CATALOG's display name/description, required connections, and whether the tenant satisfies them (a provider row exists and its newest credential is active). Read-only, grant workflow:* read; catalogAssetNames is injected from apps/hub since @corbits/seeding already depends on @corbits/workflows. Web: the Routines page gets an Available section listing those entries with an Add action that POSTs the existing /template-blocks/:assetName/deploy route, shows a busy state, and on success invalidates both the available and scheduled queries so the entry moves into the deployed list. A missing required connection disables Add with a reason in consumer language and a link to Plugins. 4xx envelopes surface with their refId. --- apps/hub/src/index.ts | 5 +- .../src/pages/routines-available-strings.ts | 54 +++++++ apps/web/src/pages/routines-page.tsx | 125 ++++++++++++++++ apps/web/src/query-client.ts | 2 + apps/web/src/routines-api.ts | 28 ++++ apps/web/src/workbench-templates-api.ts | 10 +- .../src/schedule/available-catalog.ts | 137 ++++++++++++++++++ .../workflows/src/schedule/scheduled-route.ts | 31 ++++ 8 files changed, 387 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/pages/routines-available-strings.ts create mode 100644 packages/workflows/src/schedule/available-catalog.ts diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index f64981ec3a..6d93c616c1 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -276,7 +276,7 @@ import { type PresenceRoomKey, } from "@corbits/presence"; import { supportedCredentialProviders } from "@corbits/connections/credential-test"; -import { createGitWorkflowPusher } from "@corbits/seeding"; +import { CATALOG_WORKFLOWS, createGitWorkflowPusher } from "@corbits/seeding"; import { createHubAPI } from "@corbits/hub-api-client"; import { createDrizzlePendingSeedStore, @@ -1805,6 +1805,9 @@ export async function createHub(config: HubConfig) { grantStore: chatGrantStore, conditionRegistry: chatConditionRegistry, }), + catalogAssetNames: CATALOG_WORKFLOWS.map( + (workflow) => workflow.assetName, + ), runNow: async (args) => runNowScheduledDefinition( { db, sidecarRouter, ...scheduledDeliveryJoinDeps }, diff --git a/apps/web/src/pages/routines-available-strings.ts b/apps/web/src/pages/routines-available-strings.ts new file mode 100644 index 0000000000..5e2a29e1bb --- /dev/null +++ b/apps/web/src/pages/routines-available-strings.ts @@ -0,0 +1,54 @@ +// Copy for the Routines page's "Available" section (CL-7073) — kept apart +// from JSX so a wording change never touches component logic. Every +// string here reads as consumer language: no "deploy", "asset", or other +// 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}`; +} + +/** + * Every connector id `WORKFLOW_CATALOG` currently names as a required + * connection (`packages/workflows/src/catalog.ts`), mapped to the + * consumer-facing name the Plugins page shows for it. A connector id + * with no entry here falls back to itself capitalized — never a raw + * lowercase id in copy, but also never a silent crash for a future + * catalog entry naming a connector this list hasn't caught up with yet. + */ +const CONNECTOR_DISPLAY_NAMES: Readonly> = { + github: "GitHub", + granola: "Granola", + linear: "Linear", + exa: "Exa", + scrapecreators: "ScrapeCreators", + attio: "Attio", +}; + +function connectorDisplayName(connectorId: string): string { + return ( + CONNECTOR_DISPLAY_NAMES[connectorId] ?? + connectorId.charAt(0).toUpperCase() + connectorId.slice(1) + ); +} + +export function missingConnectionsReason( + missingConnections: readonly string[], +): string { + const names = missingConnections.map(connectorDisplayName); + if (names.length === 1) { + return `Connect ${names[0]} first.`; + } + return `Connect ${names.join(" and ")} first.`; +} + +export const CONNECT_LINK_LABEL = "Go to Plugins"; diff --git a/apps/web/src/pages/routines-page.tsx b/apps/web/src/pages/routines-page.tsx index 57fcf31c0a..bf00d11d7f 100644 --- a/apps/web/src/pages/routines-page.tsx +++ b/apps/web/src/pages/routines-page.tsx @@ -1,7 +1,14 @@ // Routines: an ops table of authored workflow definitions that carry a // ScheduleTrigger, including paused (`stopped`) ones. Pause/resume and // run-now are the only writes; schedules are authored on the definition. +// The "Available" section (CL-7073) is scoped to the current bench only +// — 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 { + Button, EmptyState, RichEmptyState, RunNowButton, @@ -12,18 +19,132 @@ 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"; import { routineDetailPath } from "../global-routines"; +import { useBench } from "../bench-context"; +import { tenantKeys } from "../query-client"; +import { + listAvailableCatalogWorkflows, + type AvailableCatalogWorkflow, +} from "../routines-api"; +import { deployWorkbenchTemplateBlock } from "../workbench-templates-api"; 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"; export type { GlobalRoutineRow } from "../global-routines"; +export function AvailableCatalogWorkflowsSection({ + tenantId, +}: { + readonly tenantId: string; +}) { + const queryClient = useQueryClient(); + const [pendingAssetName, setPendingAssetName] = useState(null); + const query = useQuery({ + queryKey: tenantKeys.availableCatalogWorkflows(tenantId), + queryFn: () => listAvailableCatalogWorkflows(tenantId), + }); + + if (query.isLoading) return null; + const items: readonly AvailableCatalogWorkflow[] = query.data ?? []; + if (items.length === 0) return null; + + async function handleAdd(entry: AvailableCatalogWorkflow) { + setPendingAssetName(entry.assetName); + try { + await deployWorkbenchTemplateBlock(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 ( +
+
+

{AVAILABLE_SECTION_TITLE}

+

+ {AVAILABLE_SECTION_SUBTITLE} +

+
+
    + {items.map((entry) => { + const disabled = !entry.connectionsSatisfied; + const busy = pendingAssetName === entry.assetName; + return ( +
  • +
    + {entry.displayName} + + {entry.description} + + {disabled ? ( + + {missingConnectionsReason(entry.missingConnections)} + + {CONNECT_LINK_LABEL} + + + ) : null} +
    + +
  • + ); + })} +
+
+ ); +} + export function scheduleSentence(cron: string): string { return cronSentence(cron) ?? cron; } @@ -109,6 +230,7 @@ export function RoutinesRoute() { const routinesQuery = useGlobalRoutines(); const actions = useRoutineActions(); const rows = routinesQuery.kind === "ready" ? routinesQuery.data : []; + const { selectedTenantId } = useBench(); return (
@@ -117,6 +239,9 @@ export function RoutinesRoute() { subtitle="Scheduled workflows. Pause, resume, or run now." />
+ {selectedTenantId !== null ? ( + + ) : null} {routinesQuery.kind === "loading" ? (
} title="Loading routines…" /> diff --git a/apps/web/src/query-client.ts b/apps/web/src/query-client.ts index 5b0fbb2f6d..8570fd268c 100644 --- a/apps/web/src/query-client.ts +++ b/apps/web/src/query-client.ts @@ -91,6 +91,8 @@ export const tenantKeys = { runView: (tenantId: string, runId: string) => ["tenant", tenantId, "runs", runId] as const, routines: (tenantId: string) => ["tenant", tenantId, "routines"] as const, + availableCatalogWorkflows: (tenantId: string) => + ["tenant", tenantId, "routines", "available"] as const, skills: (tenantId: string) => ["tenant", tenantId, "skills"] as const, mcpServers: (tenantId: string) => ["tenant", tenantId, "mcp-servers"] as const, diff --git a/apps/web/src/routines-api.ts b/apps/web/src/routines-api.ts index a9831ca682..b4a1ed82a9 100644 --- a/apps/web/src/routines-api.ts +++ b/apps/web/src/routines-api.ts @@ -32,6 +32,21 @@ const ScheduledWorkflowsResponse = type({ items: ScheduledWorkflowDefinition.array(), }); +export const AvailableCatalogWorkflow = type({ + assetName: "string", + displayName: "string", + description: "string", + requiredConnections: "string[]", + missingConnections: "string[]", + connectionsSatisfied: "boolean", +}); + +export type AvailableCatalogWorkflow = typeof AvailableCatalogWorkflow.infer; + +const AvailableCatalogWorkflowsResponse = type({ + items: AvailableCatalogWorkflow.array(), +}); + const RunNowResponse = type({ runId: "string" }); type Validator = (data: unknown) => T | ArkErrors; @@ -88,6 +103,19 @@ export function scheduledWorkflowsPath(tenantId: string): string { return `/api/tenants/${tenantId}/workflows/scheduled`; } +export function availableCatalogWorkflowsPath(tenantId: string): string { + return `/api/tenants/${tenantId}/workflows/available`; +} + +export function listAvailableCatalogWorkflows( + tenantId: string, +): Promise { + return request( + availableCatalogWorkflowsPath(tenantId), + AvailableCatalogWorkflowsResponse, + ).then((page) => page.items); +} + export function scheduledWorkflowRunPath( tenantId: string, definitionId: string, diff --git a/apps/web/src/workbench-templates-api.ts b/apps/web/src/workbench-templates-api.ts index 92f3156bf6..23c9264711 100644 --- a/apps/web/src/workbench-templates-api.ts +++ b/apps/web/src/workbench-templates-api.ts @@ -13,6 +13,7 @@ import { type WorkbenchDefinition, } from "@workbench/templates"; import { ApiQueryError } from "@corbits/api-query"; +import { parseErrorEnvelope } from "@corbits/error-sink"; const TemplateLibraryEntry = type({ id: "string > 0", content: "string > 0" }); @@ -51,16 +52,17 @@ export async function deployWorkbenchTemplateBlock( path, ); } + const json: unknown = await response.json().catch(() => undefined); if (!response.ok) { + const envelope = parseErrorEnvelope(json); throw new ApiQueryError( - `The server answered ${response.status}.`, + envelope?.error.userMessage ?? `The server answered ${response.status}.`, response.status, path, + envelope?.error.refId, ); } - const parsed = TemplateBlockDeployResponse( - await response.json().catch(() => undefined), - ); + const parsed = TemplateBlockDeployResponse(json); if (parsed instanceof type.errors) { throw new ApiQueryError( `Couldn't set that up — the answer didn't look right: ${parsed.summary}`, diff --git a/packages/workflows/src/schedule/available-catalog.ts b/packages/workflows/src/schedule/available-catalog.ts new file mode 100644 index 0000000000..dd3ec31cb2 --- /dev/null +++ b/packages/workflows/src/schedule/available-catalog.ts @@ -0,0 +1,137 @@ +// The Routines page's "Available" section (CL-7073): every catalog +// workflow (`@corbits/seeding`'s `CATALOG_WORKFLOWS` — injected here by +// asset name so this package never depends back on `@corbits/seeding`, +// which already depends on `@corbits/workflows`) that has no deployed +// definition of that asset name on the caller's bench yet, alongside +// this package's own `WORKFLOW_CATALOG` display metadata and whether the +// tenant already satisfies its required connections. +// +// Connection satisfaction mirrors `@corbits/settings-ui`'s +// `connectorStatus` exactly (same provider-name-match, same +// active-credential rule) but reads the tables directly rather than +// importing a UI package into a domain package: a connector reads +// "satisfied" only when a `provider` row named after the connector id +// exists AND either it carries no credential yet (nothing to fail) or +// its newest credential is `active`. An `expired`/`error`/`revoked` +// credential reads as unsatisfied, the same "needs attention" case +// `connectorStatus` reports. +import { and, desc, eq } from "drizzle-orm"; +import type { DB } from "@intx/db"; +import { credential, provider, workflowDefinition } from "@intx/db/schema"; + +import { workflowCatalogEntry } from "../catalog"; + +export type AvailableCatalogWorkflow = { + readonly assetName: string; + readonly displayName: string; + readonly description: string; + readonly requiredConnections: readonly string[]; + readonly missingConnections: readonly string[]; + readonly connectionsSatisfied: boolean; +}; + +/** + * The read-only-of-a-database-round-trip core: given which asset names + * are already deployed and a synchronous "is this connector satisfied" + * lookup, computes the available list. Split out from + * `listAvailableCatalogWorkflows` so this — the actual per-entry + * filtering and shaping logic — has a test that needs no Postgres. + */ +export function availableCatalogWorkflowsFrom(args: { + readonly catalogAssetNames: readonly string[]; + readonly deployedNames: ReadonlySet; + readonly isConnectorSatisfied: (connectorId: string) => boolean; +}): readonly AvailableCatalogWorkflow[] { + const { catalogAssetNames, deployedNames, isConnectorSatisfied } = args; + const available: AvailableCatalogWorkflow[] = []; + for (const assetName of catalogAssetNames) { + if (deployedNames.has(assetName)) continue; + const entry = workflowCatalogEntry(assetName); + if (entry === undefined) continue; + + const missingConnections = entry.requiredConnections.filter( + (connectorId) => !isConnectorSatisfied(connectorId), + ); + + available.push({ + assetName, + displayName: entry.displayName, + description: entry.whatItDoes, + requiredConnections: entry.requiredConnections, + missingConnections, + connectionsSatisfied: missingConnections.length === 0, + }); + } + return available; +} + +async function connectorIsSatisfied( + db: DB["db"], + tenantId: string, + connectorId: string, +): Promise { + const providerRow = await db.query.provider.findFirst({ + where: and(eq(provider.tenantId, tenantId), eq(provider.name, connectorId)), + columns: { id: true }, + }); + if (providerRow === undefined) return false; + + const newestCredential = await db.query.credential.findFirst({ + where: eq(credential.providerId, providerRow.id), + orderBy: [desc(credential.createdAt)], + columns: { status: true }, + }); + if (newestCredential === undefined) return true; + return newestCredential.status === "active"; +} + +/** + * Every catalog asset name with no deployed `workflow_definition` on this + * tenant yet, enriched with `WORKFLOW_CATALOG` metadata and connection + * satisfaction. `catalogAssetNames` names the full deployable-through-the- + * -catalog-instantiate-route set (`@corbits/seeding`'s + * `CATALOG_WORKFLOWS`, by asset name) — the caller's job, not this + * package's, since importing that package here would cycle back through + * its own dependency on `@corbits/workflows`. A catalog name with no + * `WORKFLOW_CATALOG` entry is skipped rather than thrown on: the two + * lists are asserted equal elsewhere (`packages/seeding/test`), so this + * is defense against the caller passing a stale name, not an expected + * path. + */ +export async function listAvailableCatalogWorkflows(args: { + readonly db: DB["db"]; + readonly tenantId: string; + readonly catalogAssetNames: readonly string[]; +}): Promise { + const { db, tenantId, catalogAssetNames } = args; + + const deployed = await db.query.workflowDefinition.findMany({ + where: and( + eq(workflowDefinition.tenantId, tenantId), + eq(workflowDefinition.origin, "authored"), + eq(workflowDefinition.status, "deployed"), + ), + columns: { name: true }, + }); + const deployedNames = new Set(deployed.map((row) => row.name)); + + const connectorSatisfaction = new Map(); + const allConnectorIds = new Set( + catalogAssetNames.flatMap( + (assetName) => workflowCatalogEntry(assetName)?.requiredConnections ?? [], + ), + ); + for (const connectorId of allConnectorIds) { + connectorSatisfaction.set( + connectorId, + await connectorIsSatisfied(db, tenantId, connectorId), + ); + } + + return availableCatalogWorkflowsFrom({ + catalogAssetNames, + deployedNames, + isConnectorSatisfied: (connectorId) => + connectorSatisfaction.get(connectorId) ?? false, + }); +} diff --git a/packages/workflows/src/schedule/scheduled-route.ts b/packages/workflows/src/schedule/scheduled-route.ts index 5f0bff58d9..cc0c1ad926 100644 --- a/packages/workflows/src/schedule/scheduled-route.ts +++ b/packages/workflows/src/schedule/scheduled-route.ts @@ -1,6 +1,10 @@ // GET /api/tenants/:tenantId/workflows/scheduled — authored definitions // whose frozen projection has a ScheduleTrigger, including `stopped`. // POST /scheduled/:definitionId/run — fire that definition now. +// GET /api/tenants/:tenantId/workflows/available — this bench's sibling +// list (CL-7073): every catalog workflow with no deployed definition of +// that asset name yet, so the Routines page can offer it with an Add +// action. import { Hono } from "hono"; import type { DB } from "@intx/db"; import type { RequireGrant, TenantEnv } from "@intx/hub-api"; @@ -9,6 +13,10 @@ import { listScheduledWorkflowDefinitions, type ScheduledWorkflowDefinition, } from "./list-scheduled"; +import { + listAvailableCatalogWorkflows, + type AvailableCatalogWorkflow, +} from "./available-catalog"; export const RUN_NOW_CONTENT = "Run now."; @@ -30,6 +38,16 @@ export type CreateScheduledWorkflowRoutesDeps = { db: DB["db"], tenantId: string, ) => Promise; + /** Every asset name deployable through the catalog instantiate route — + * `@corbits/seeding`'s `CATALOG_WORKFLOWS`, by asset name. Passed in by + * the caller (`apps/hub`) rather than imported here: `@corbits/seeding` + * already depends on this package, so importing it back would cycle. */ + catalogAssetNames?: readonly string[]; + listAvailable?: ( + db: DB["db"], + tenantId: string, + catalogAssetNames: readonly string[], + ) => Promise; }; export function createScheduledWorkflowRoutes({ @@ -37,9 +55,22 @@ export function createScheduledWorkflowRoutes({ requireGrant, runNow, listScheduled = listScheduledWorkflowDefinitions, + catalogAssetNames = [], + listAvailable = (dbHandle, tenantId, names) => + listAvailableCatalogWorkflows({ + db: dbHandle, + tenantId, + catalogAssetNames: names, + }), }: CreateScheduledWorkflowRoutesDeps): Hono { const app = new Hono(); + app.get("/available", requireGrant("workflow:*", "read"), async (c) => { + const tenant = c.get("tenant"); + const items = await listAvailable(db, tenant.id, catalogAssetNames); + return c.json({ items }); + }); + app.get( "/scheduled", requireGrant("workflow-definition:*", "read"), From f79d27cf82fa86cf7b7152ffedccc5b258aa75e9 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 4 Sep 2026 19:29:00 -0700 Subject: [PATCH 3/5] Update docs: how a person adds a catalog workflow from Routines (CL-7073) --- PRODUCT.md | 5 ++++- docs/seed-reconciliation.md | 29 +++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/PRODUCT.md b/PRODUCT.md index 20842b15e3..f44d629dbd 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -182,7 +182,10 @@ frozen projection carries a native `ScheduleTrigger`. Cadence is set from inside conversation, not from a separate scheduling console: a person names what should happen again, on what schedule, and where the result should land. See `@corbits/workflows` for the schedule/cron -helpers and the hub's `workflow-scheduler.ts` for the poller. +helpers and the hub's `workflow-scheduler.ts` for the poller. The +Routines page also offers an Available section (CL-7073): every +catalog workflow this workbench hasn't added yet, with an Add action +that deploys it in place — no separate create flow. ## Inbox and approvals diff --git a/docs/seed-reconciliation.md b/docs/seed-reconciliation.md index c180364fe2..999346ab80 100644 --- a/docs/seed-reconciliation.md +++ b/docs/seed-reconciliation.md @@ -175,6 +175,35 @@ state of its own, so handing an old run's identity to a new sidecar process would silently pretend session state survived that never did. A genuine redeploy is the only honest repair. +## How a person adds a catalog workflow (CL-7073) + +The Routines page's Available section is the read side of this +invariant, surfaced to a person rather than an operator: `GET +/api/tenants/:tenantId/workflows/available` +(`packages/workflows/src/schedule/scheduled-route.ts`, +`available-catalog.ts`) answers every `CATALOG_WORKFLOWS` asset name +with no deployed `workflow_definition` on the caller's tenant yet, +each carrying `WORKFLOW_CATALOG`'s display name and one-line +description (`packages/workflows/src/catalog.ts`), its required +connections, and whether the tenant already satisfies every one of +them — a `provider` row named after the connector id exists and its +newest `credential` is `active` (mirroring +`@corbits/settings-ui`'s `connectorStatus` exactly, without importing +a UI package into a domain package). The route is injected the +catalog's asset-name list from `apps/hub` rather than importing +`@corbits/seeding` directly: that package already depends on +`@corbits/workflows`, so the reverse import would cycle. + +The web page's Add action POSTs the same +`/template-blocks/:assetName/deploy` route the GTM template's blocks +already use (`docs/seed-reconciliation.md`'s "Deployable through the +catalog" section above) — there is no second create path. A +disabled Add (a missing required connection) links to Plugins rather +than letting the request 404/500; the request itself is only ever +made once every required connection reads satisfied. On success the +entry moves out of Available into the ordinary scheduled/deployed +list the rest of the page already reads. + ## Env provider credentials (hub boot) `apps/hub/src/env-credential-plant.ts` delegates to From 0d32f93920ef9035b2f9586392e5d52d944f746e Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 4 Sep 2026 19:53:53 -0700 Subject: [PATCH 4/5] Add tests for ancestor-connection satisfaction and pin-deployability (CL-7073) Covers the Available catalog critique findings: a workflow's required connection now reads as satisfied when a parent bench (not only the exact deploying tenant) carries it, resolved with one IN query per table across every connector this tenant's catalog needs rather than one round trip per connector; and each entry's own deployable flag, false with a machine notDeployableReason for a catalog workflow this pin cannot deploy, disabling Add in the UI with honest, non-technical copy instead of a working-looking button. --- .../test/routines-available-section.test.tsx | 39 ++++++ .../available-catalog.drizzle.test.ts | 115 ++++++++++++++++++ .../src/schedule/available-catalog.test.ts | 23 ++++ .../src/schedule/scheduled-route.test.ts | 1 + 4 files changed, 178 insertions(+) create mode 100644 packages/workflows/src/schedule/available-catalog.drizzle.test.ts diff --git a/apps/web/test/routines-available-section.test.tsx b/apps/web/test/routines-available-section.test.tsx index 18cbe107f6..92018f461d 100644 --- a/apps/web/test/routines-available-section.test.tsx +++ b/apps/web/test/routines-available-section.test.tsx @@ -29,6 +29,7 @@ const codeReview = { requiredConnections: ["github"], missingConnections: ["github"], connectionsSatisfied: false, + deployable: true, }; const echo = { @@ -38,6 +39,18 @@ const echo = { requiredConnections: [], missingConnections: [], connectionsSatisfied: true, + deployable: true, +}; + +const granolaCall = { + assetName: "granola-call", + displayName: "Granola call", + description: "Polls Granola for new calls and starts a call-notes run.", + requiredConnections: ["granola"], + missingConnections: ["granola"], + connectionsSatisfied: false, + deployable: false, + notDeployableReason: "credential_bindings_unsupported", }; async function render( @@ -124,6 +137,32 @@ describe("AvailableCatalogWorkflowsSection", () => { } }); + test("Add is disabled with 'Coming with the next platform update.' for a not-yet-deployable entry, and never links to Plugins", async () => { + const { container, root } = await render((async ( + input: RequestInfo | URL, + ) => { + const url = String(input); + if (url.includes("/workflows/available")) { + return jsonResponse({ items: [granolaCall] }); + } + 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( + "Coming with the next platform update.", + ); + expect(container.textContent).not.toContain("Connect"); + expect(container.querySelector("a")).toBeNull(); + } finally { + restoreFetch(container, root); + } + }); + test("Add posts to the template-blocks deploy route and the entry disappears from Available", async () => { let available = [echo]; let deployCalls = 0; diff --git a/packages/workflows/src/schedule/available-catalog.drizzle.test.ts b/packages/workflows/src/schedule/available-catalog.drizzle.test.ts new file mode 100644 index 0000000000..f1ec5e3f06 --- /dev/null +++ b/packages/workflows/src/schedule/available-catalog.drizzle.test.ts @@ -0,0 +1,115 @@ +// DB-gated: skipped when no DATABASE_URL is reachable (a fresh checkout +// still runs the unit gates), mirroring +// `packages/granola-tools/test/credential-delivery.drizzle.test.ts`. Runs +// against its own Postgres schema, never the developer's or the +// walking-skeleton suite's. +// +// Proves the CL-7073 critique's finding 2: a catalog workflow's required +// connection reads as satisfied when the CONNECTED provider lives on an +// ancestor tenant, not only the exact deploying tenant — the same +// ancestor-chain precedence Plugins' `connectorStatus` and +// `@intx/db`'s credential resolution both use (child shadows parent). +import { afterAll, beforeAll, expect, test } from "bun:test"; +import { createDB, runMigrations, dropSchema } from "@intx/db"; +import { schema } from "@intx/db"; + +import { dbTargetFromUrl } from "../../../../scripts/db-setup"; +import { e2eDatabaseUrl } from "../../../../scripts/e2e/harness"; +import { dbGate } from "../../../../scripts/e2e/db-gate"; +import { listAvailableCatalogWorkflows } from "./available-catalog"; + +const databaseUrl = e2eDatabaseUrl(); +const describeIfDb = dbGate(databaseUrl, import.meta.path); + +const SCHEMA = "workflows_available_catalog_test"; + +describeIfDb( + "listAvailableCatalogWorkflows: ancestor-chain connection satisfaction", + () => { + const target = dbTargetFromUrl( + databaseUrl ?? "postgres://localhost:5432/unused", + ); + + beforeAll(async () => { + await runMigrations(target, { schema: SCHEMA }); + }); + + afterAll(async () => { + await dropSchema(target, { schema: SCHEMA }); + }); + + test("a child workbench sees code-review as connected via its parent bench's github connection", async () => { + const { db, close } = createDB({ ...target, schema: SCHEMA }); + try { + await db.insert(schema.tenant).values({ + id: "tnt_parent_bench", + name: "Parent Bench", + slug: "parent-bench", + domain: "parent-bench.workbench.test", + }); + await db.insert(schema.tenant).values({ + id: "tnt_child_workbench", + name: "Child Workbench", + slug: "child-workbench", + domain: "child-workbench.workbench.test", + parentId: "tnt_parent_bench", + }); + await db.insert(schema.provider).values({ + id: "prov_parent_github", + tenantId: "tnt_parent_bench", + name: "github", + plugin: "github", + }); + await db.insert(schema.credential).values({ + id: "cred_parent_github", + tenantId: "tnt_parent_bench", + providerId: "prov_parent_github", + name: "github-default", + type: "oauth_token", + secret: "encrypted-not-decrypted-by-this-path", + status: "active", + }); + + const result = await listAvailableCatalogWorkflows({ + db, + tenantId: "tnt_child_workbench", + catalogAssetNames: ["code-review"], + }); + + const codeReview = result.find( + (entry) => entry.assetName === "code-review", + ); + expect(codeReview?.connectionsSatisfied).toBe(true); + expect(codeReview?.missingConnections).toEqual([]); + } finally { + await close(); + } + }); + + test("a bench with no ancestor connection sees code-review as not yet connected", async () => { + const { db, close } = createDB({ ...target, schema: SCHEMA }); + try { + await db.insert(schema.tenant).values({ + id: "tnt_unconnected_bench", + name: "Unconnected Bench", + slug: "unconnected-bench", + domain: "unconnected-bench.workbench.test", + }); + + const result = await listAvailableCatalogWorkflows({ + db, + tenantId: "tnt_unconnected_bench", + catalogAssetNames: ["code-review"], + }); + + const codeReview = result.find( + (entry) => entry.assetName === "code-review", + ); + expect(codeReview?.connectionsSatisfied).toBe(false); + expect(codeReview?.missingConnections).toEqual(["github"]); + } finally { + await close(); + } + }); + }, +); diff --git a/packages/workflows/src/schedule/available-catalog.test.ts b/packages/workflows/src/schedule/available-catalog.test.ts index f3323c586e..ce1e9fe4c5 100644 --- a/packages/workflows/src/schedule/available-catalog.test.ts +++ b/packages/workflows/src/schedule/available-catalog.test.ts @@ -56,6 +56,29 @@ describe("availableCatalogWorkflowsFrom", () => { expect(result[0]?.connectionsSatisfied).toBe(true); }); + test("defaults every entry to deployable when no pin check is given", () => { + const result = availableCatalogWorkflowsFrom({ + catalogAssetNames: ["echo"], + deployedNames: new Set(), + isConnectorSatisfied: () => true, + }); + expect(result[0]?.deployable).toBe(true); + expect(result[0]?.notDeployableReason).toBeUndefined(); + }); + + test("marks an entry not deployable with a machine reason when the pin check says no", () => { + const result = availableCatalogWorkflowsFrom({ + catalogAssetNames: ["code-review"], + deployedNames: new Set(), + isConnectorSatisfied: () => true, + isDeployableOnThisPin: () => false, + }); + expect(result[0]?.deployable).toBe(false); + expect(result[0]?.notDeployableReason).toBe( + "credential_bindings_unsupported", + ); + }); + test("carries the display name and description straight from WORKFLOW_CATALOG", () => { const result = availableCatalogWorkflowsFrom({ catalogAssetNames: ["echo"], diff --git a/packages/workflows/src/schedule/scheduled-route.test.ts b/packages/workflows/src/schedule/scheduled-route.test.ts index 34b119f3d0..044a85d1a3 100644 --- a/packages/workflows/src/schedule/scheduled-route.test.ts +++ b/packages/workflows/src/schedule/scheduled-route.test.ts @@ -139,6 +139,7 @@ describe("createScheduledWorkflowRoutes", () => { requiredConnections: ["github"], missingConnections: ["github"], connectionsSatisfied: false, + deployable: true, }; const app = mount(async () => ({ runId: "run_x" }), [], { catalogAssetNames: ["code-review"], From f8a287f58056e30adf1fecc13bde2cfa533983e0 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 4 Sep 2026 19:54:00 -0700 Subject: [PATCH 5/5] Resolve catalog connections across ancestor benches; gate Add by pin (CL-7073) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit available-catalog.ts's connection satisfaction now walks the tenant's ancestor chain (getAncestorChain, @intx/db) the way Plugins' own connectorStatus and credential resolution do, so a child workbench sees a connection its parent bench made. Every connector a tenant's catalog might need is resolved in one provider query and one credential query (both IN-scoped), not one round trip per connector. Each AvailableCatalogWorkflow entry also carries deployable and, when false, a machine notDeployableReason — apps/hub wires @corbits/seeding's catalogWorkflowDeployableOnThisPin through the route so the six credential-bound catalog entries answer deployable: false. The Routines page disables Add for those with "Coming with the next platform update." and no Plugins link, instead of a button whose click always fails. --- apps/hub/src/index.ts | 7 +- .../src/pages/routines-available-strings.ts | 3 + apps/web/src/pages/routines-page.tsx | 10 +- apps/web/src/routines-api.ts | 2 + .../src/schedule/available-catalog.ts | 171 +++++++++++++----- .../workflows/src/schedule/scheduled-route.ts | 10 + 6 files changed, 151 insertions(+), 52 deletions(-) diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index 6d93c616c1..0e490c482b 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -276,7 +276,11 @@ import { type PresenceRoomKey, } from "@corbits/presence"; import { supportedCredentialProviders } from "@corbits/connections/credential-test"; -import { CATALOG_WORKFLOWS, createGitWorkflowPusher } from "@corbits/seeding"; +import { + CATALOG_WORKFLOWS, + catalogWorkflowDeployableOnThisPin, + createGitWorkflowPusher, +} from "@corbits/seeding"; import { createHubAPI } from "@corbits/hub-api-client"; import { createDrizzlePendingSeedStore, @@ -1808,6 +1812,7 @@ export async function createHub(config: HubConfig) { catalogAssetNames: CATALOG_WORKFLOWS.map( (workflow) => workflow.assetName, ), + catalogWorkflowDeployable: catalogWorkflowDeployableOnThisPin, runNow: async (args) => runNowScheduledDefinition( { db, sidecarRouter, ...scheduledDeliveryJoinDeps }, diff --git a/apps/web/src/pages/routines-available-strings.ts b/apps/web/src/pages/routines-available-strings.ts index 5e2a29e1bb..8bc966ab79 100644 --- a/apps/web/src/pages/routines-available-strings.ts +++ b/apps/web/src/pages/routines-available-strings.ts @@ -52,3 +52,6 @@ export function missingConnectionsReason( } export const CONNECT_LINK_LABEL = "Go to Plugins"; + +export const NOT_DEPLOYABLE_YET_REASON = + "Coming with the next platform update."; diff --git a/apps/web/src/pages/routines-page.tsx b/apps/web/src/pages/routines-page.tsx index bf00d11d7f..48c28ce3d1 100644 --- a/apps/web/src/pages/routines-page.tsx +++ b/apps/web/src/pages/routines-page.tsx @@ -45,6 +45,7 @@ import { AVAILABLE_SECTION_SUBTITLE, AVAILABLE_SECTION_TITLE, CONNECT_LINK_LABEL, + NOT_DEPLOYABLE_YET_REASON, addFailureMessage, addSuccessMessage, missingConnectionsReason, @@ -107,7 +108,8 @@ export function AvailableCatalogWorkflowsSection({
    {items.map((entry) => { - const disabled = !entry.connectionsSatisfied; + const notDeployableYet = !entry.deployable; + const disabled = notDeployableYet || !entry.connectionsSatisfied; const busy = pendingAssetName === entry.assetName; return (
  • {entry.description} - {disabled ? ( + {notDeployableYet ? ( + + {NOT_DEPLOYABLE_YET_REASON} + + ) : !entry.connectionsSatisfied ? ( {missingConnectionsReason(entry.missingConnections)} diff --git a/apps/web/src/routines-api.ts b/apps/web/src/routines-api.ts index b4a1ed82a9..ab8ec39358 100644 --- a/apps/web/src/routines-api.ts +++ b/apps/web/src/routines-api.ts @@ -39,6 +39,8 @@ export const AvailableCatalogWorkflow = type({ requiredConnections: "string[]", missingConnections: "string[]", connectionsSatisfied: "boolean", + deployable: "boolean", + "notDeployableReason?": "'credential_bindings_unsupported'", }); export type AvailableCatalogWorkflow = typeof AvailableCatalogWorkflow.infer; diff --git a/packages/workflows/src/schedule/available-catalog.ts b/packages/workflows/src/schedule/available-catalog.ts index dd3ec31cb2..7279adbdc4 100644 --- a/packages/workflows/src/schedule/available-catalog.ts +++ b/packages/workflows/src/schedule/available-catalog.ts @@ -3,24 +3,31 @@ // asset name so this package never depends back on `@corbits/seeding`, // which already depends on `@corbits/workflows`) that has no deployed // definition of that asset name on the caller's bench yet, alongside -// this package's own `WORKFLOW_CATALOG` display metadata and whether the -// tenant already satisfies its required connections. +// this package's own `WORKFLOW_CATALOG` display metadata, whether the +// tenant already satisfies its required connections, and whether the +// entry can deploy at all on this Interchange pin. // // Connection satisfaction mirrors `@corbits/settings-ui`'s -// `connectorStatus` exactly (same provider-name-match, same -// active-credential rule) but reads the tables directly rather than -// importing a UI package into a domain package: a connector reads -// "satisfied" only when a `provider` row named after the connector id -// exists AND either it carries no credential yet (nothing to fail) or -// its newest credential is `active`. An `expired`/`error`/`revoked` -// credential reads as unsatisfied, the same "needs attention" case -// `connectorStatus` reports. -import { and, desc, eq } from "drizzle-orm"; -import type { DB } from "@intx/db"; +// `connectorStatus` (same active-credential rule) but, like Plugins' +// own connector status and credential resolution, walks the tenant's +// ancestor chain (`@intx/db`'s `getAncestorChain`) rather than the exact +// tenant alone: a connector reads "satisfied" when the nearest ancestor +// (child shadows parent, same precedence `resolveProviderByName` uses) +// carrying a `provider` row named after the connector id either has no +// credential yet (nothing to fail) or its newest credential is `active`. +// An `expired`/`error`/`revoked` credential reads as unsatisfied, the +// same "needs attention" case `connectorStatus` reports. Every +// connector this tenant's catalog might need is resolved with exactly +// one `IN` query per table (`provider`, `credential`) rather than one +// round trip per connector. +import { and, desc, eq, inArray } from "drizzle-orm"; +import { getAncestorChain, type DB } from "@intx/db"; import { credential, provider, workflowDefinition } from "@intx/db/schema"; import { workflowCatalogEntry } from "../catalog"; +export type NotDeployableReason = "credential_bindings_unsupported"; + export type AvailableCatalogWorkflow = { readonly assetName: string; readonly displayName: string; @@ -28,21 +35,40 @@ export type AvailableCatalogWorkflow = { readonly requiredConnections: readonly string[]; readonly missingConnections: readonly string[]; readonly connectionsSatisfied: boolean; + /** + * Whether this entry can deploy through + * `POST /template-blocks/:assetName/deploy` on the current Interchange + * pin. `false` for a catalog workflow whose definition carries + * `credentialBindings` this pin's deploy front has no + * `credentialCipher` seam to resolve (see + * `catalogWorkflowDeployableOnThisPin`, + * `docs/seed-reconciliation.md`) — closes at the Interchange re-pin + * (CL-7107 / PR #632, pin 692c3106). + */ + readonly deployable: boolean; + readonly notDeployableReason?: NotDeployableReason; }; /** * The read-only-of-a-database-round-trip core: given which asset names - * are already deployed and a synchronous "is this connector satisfied" - * lookup, computes the available list. Split out from - * `listAvailableCatalogWorkflows` so this — the actual per-entry - * filtering and shaping logic — has a test that needs no Postgres. + * are already deployed and synchronous "is this connector satisfied" / + * "can this deploy on this pin" lookups, computes the available list. + * Split out from `listAvailableCatalogWorkflows` so this — the actual + * per-entry filtering and shaping logic — has a test that needs no + * Postgres. */ export function availableCatalogWorkflowsFrom(args: { readonly catalogAssetNames: readonly string[]; readonly deployedNames: ReadonlySet; readonly isConnectorSatisfied: (connectorId: string) => boolean; + readonly isDeployableOnThisPin?: (assetName: string) => boolean; }): readonly AvailableCatalogWorkflow[] { - const { catalogAssetNames, deployedNames, isConnectorSatisfied } = args; + const { + catalogAssetNames, + deployedNames, + isConnectorSatisfied, + isDeployableOnThisPin = () => true, + } = args; const available: AvailableCatalogWorkflow[] = []; for (const assetName of catalogAssetNames) { if (deployedNames.has(assetName)) continue; @@ -52,6 +78,7 @@ export function availableCatalogWorkflowsFrom(args: { const missingConnections = entry.requiredConnections.filter( (connectorId) => !isConnectorSatisfied(connectorId), ); + const deployable = isDeployableOnThisPin(assetName); available.push({ assetName, @@ -60,50 +87,97 @@ export function availableCatalogWorkflowsFrom(args: { requiredConnections: entry.requiredConnections, missingConnections, connectionsSatisfied: missingConnections.length === 0, + deployable, + ...(deployable + ? {} + : { notDeployableReason: "credential_bindings_unsupported" as const }), }); } return available; } -async function connectorIsSatisfied( +/** + * Resolves every named connector's satisfaction for one tenant in exactly + * one `provider` query and one `credential` query, walking the tenant's + * ancestor chain (child shadows parent) the same way Plugins' own + * connector status and credential resolution do — rather than one query + * pair per connector. + */ +async function connectionSatisfactionByConnector( db: DB["db"], tenantId: string, - connectorId: string, -): Promise { - const providerRow = await db.query.provider.findFirst({ - where: and(eq(provider.tenantId, tenantId), eq(provider.name, connectorId)), - columns: { id: true }, - }); - if (providerRow === undefined) return false; + connectorIds: ReadonlySet, +): Promise> { + const satisfaction = new Map(); + if (connectorIds.size === 0) return satisfaction; - const newestCredential = await db.query.credential.findFirst({ - where: eq(credential.providerId, providerRow.id), - orderBy: [desc(credential.createdAt)], - columns: { status: true }, + const chain = await getAncestorChain(db, tenantId); + const providerRows = await db.query.provider.findMany({ + where: and( + inArray(provider.tenantId, chain), + inArray(provider.name, [...connectorIds]), + ), + columns: { id: true, tenantId: true, name: true }, }); - if (newestCredential === undefined) return true; - return newestCredential.status === "active"; + + const credentialRows = + providerRows.length === 0 + ? [] + : await db.query.credential.findMany({ + where: inArray( + credential.providerId, + providerRows.map((row) => row.id), + ), + columns: { providerId: true, status: true, createdAt: true }, + orderBy: [desc(credential.createdAt)], + }); + const newestCredentialByProviderId = new Map< + string, + (typeof credentialRows)[number] + >(); + for (const row of credentialRows) { + if (!newestCredentialByProviderId.has(row.providerId)) { + newestCredentialByProviderId.set(row.providerId, row); + } + } + + for (const connectorId of connectorIds) { + let satisfied = false; + for (const tenantIdInChain of chain) { + const providerRow = providerRows.find( + (row) => row.tenantId === tenantIdInChain && row.name === connectorId, + ); + if (providerRow === undefined) continue; + const newest = newestCredentialByProviderId.get(providerRow.id); + satisfied = newest === undefined || newest.status === "active"; + break; + } + satisfaction.set(connectorId, satisfied); + } + return satisfaction; } /** * Every catalog asset name with no deployed `workflow_definition` on this - * tenant yet, enriched with `WORKFLOW_CATALOG` metadata and connection - * satisfaction. `catalogAssetNames` names the full deployable-through-the- - * -catalog-instantiate-route set (`@corbits/seeding`'s - * `CATALOG_WORKFLOWS`, by asset name) — the caller's job, not this - * package's, since importing that package here would cycle back through - * its own dependency on `@corbits/workflows`. A catalog name with no - * `WORKFLOW_CATALOG` entry is skipped rather than thrown on: the two - * lists are asserted equal elsewhere (`packages/seeding/test`), so this - * is defense against the caller passing a stale name, not an expected - * path. + * tenant yet, enriched with `WORKFLOW_CATALOG` metadata, connection + * satisfaction, and pin deployability. `catalogAssetNames` names the full + * deployable-through-the-catalog-instantiate-route set (`@corbits/seeding`'s + * `CATALOG_WORKFLOWS`, by asset name) and `isDeployableOnThisPin` names + * `@corbits/seeding`'s `catalogWorkflowDeployableOnThisPin` — both the + * caller's job, not this package's, since importing that package here + * would cycle back through its own dependency on `@corbits/workflows`. A + * catalog name with no `WORKFLOW_CATALOG` entry is skipped rather than + * thrown on: the two lists are asserted equal elsewhere + * (`packages/seeding/test`), so this is defense against the caller + * passing a stale name, not an expected path. */ export async function listAvailableCatalogWorkflows(args: { readonly db: DB["db"]; readonly tenantId: string; readonly catalogAssetNames: readonly string[]; + readonly isDeployableOnThisPin?: (assetName: string) => boolean; }): Promise { - const { db, tenantId, catalogAssetNames } = args; + const { db, tenantId, catalogAssetNames, isDeployableOnThisPin } = args; const deployed = await db.query.workflowDefinition.findMany({ where: and( @@ -115,23 +189,22 @@ export async function listAvailableCatalogWorkflows(args: { }); const deployedNames = new Set(deployed.map((row) => row.name)); - const connectorSatisfaction = new Map(); const allConnectorIds = new Set( catalogAssetNames.flatMap( (assetName) => workflowCatalogEntry(assetName)?.requiredConnections ?? [], ), ); - for (const connectorId of allConnectorIds) { - connectorSatisfaction.set( - connectorId, - await connectorIsSatisfied(db, tenantId, connectorId), - ); - } + const connectorSatisfaction = await connectionSatisfactionByConnector( + db, + tenantId, + allConnectorIds, + ); return availableCatalogWorkflowsFrom({ catalogAssetNames, deployedNames, isConnectorSatisfied: (connectorId) => connectorSatisfaction.get(connectorId) ?? false, + ...(isDeployableOnThisPin !== undefined ? { isDeployableOnThisPin } : {}), }); } diff --git a/packages/workflows/src/schedule/scheduled-route.ts b/packages/workflows/src/schedule/scheduled-route.ts index cc0c1ad926..eb75dacc9b 100644 --- a/packages/workflows/src/schedule/scheduled-route.ts +++ b/packages/workflows/src/schedule/scheduled-route.ts @@ -43,6 +43,12 @@ export type CreateScheduledWorkflowRoutesDeps = { * the caller (`apps/hub`) rather than imported here: `@corbits/seeding` * already depends on this package, so importing it back would cycle. */ catalogAssetNames?: readonly string[]; + /** `@corbits/seeding`'s `catalogWorkflowDeployableOnThisPin`, passed in + * rather than imported here for the same reason `catalogAssetNames` + * is: `@corbits/seeding` already depends on this package. Defaults to + * "everything is deployable" so a caller that never wires this in + * (a test double, an older caller) keeps its prior behavior. */ + catalogWorkflowDeployable?: (assetName: string) => boolean; listAvailable?: ( db: DB["db"], tenantId: string, @@ -56,11 +62,15 @@ export function createScheduledWorkflowRoutes({ runNow, listScheduled = listScheduledWorkflowDefinitions, catalogAssetNames = [], + catalogWorkflowDeployable, listAvailable = (dbHandle, tenantId, names) => listAvailableCatalogWorkflows({ db: dbHandle, tenantId, catalogAssetNames: names, + ...(catalogWorkflowDeployable !== undefined + ? { isDeployableOnThisPin: catalogWorkflowDeployable } + : {}), }), }: CreateScheduledWorkflowRoutesDeps): Hono { const app = new Hono();