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/apps/hub/src/index.ts b/apps/hub/src/index.ts index f64981ec3a..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 { createGitWorkflowPusher } from "@corbits/seeding"; +import { + CATALOG_WORKFLOWS, + catalogWorkflowDeployableOnThisPin, + createGitWorkflowPusher, +} from "@corbits/seeding"; import { createHubAPI } from "@corbits/hub-api-client"; import { createDrizzlePendingSeedStore, @@ -1805,6 +1809,10 @@ export async function createHub(config: HubConfig) { grantStore: chatGrantStore, conditionRegistry: chatConditionRegistry, }), + 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 new file mode 100644 index 0000000000..8bc966ab79 --- /dev/null +++ b/apps/web/src/pages/routines-available-strings.ts @@ -0,0 +1,57 @@ +// 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"; + +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 57fcf31c0a..48c28ce3d1 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,138 @@ 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, + NOT_DEPLOYABLE_YET_REASON, + 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 notDeployableYet = !entry.deployable; + const disabled = notDeployableYet || !entry.connectionsSatisfied; + const busy = pendingAssetName === entry.assetName; + return ( +
  • +
    + {entry.displayName} + + {entry.description} + + {notDeployableYet ? ( + + {NOT_DEPLOYABLE_YET_REASON} + + ) : !entry.connectionsSatisfied ? ( + + {missingConnectionsReason(entry.missingConnections)} + + {CONNECT_LINK_LABEL} + + + ) : null} +
    + +
  • + ); + })} +
+
+ ); +} + export function scheduleSentence(cron: string): string { return cronSentence(cron) ?? cron; } @@ -109,6 +236,7 @@ export function RoutinesRoute() { const routinesQuery = useGlobalRoutines(); const actions = useRoutineActions(); const rows = routinesQuery.kind === "ready" ? routinesQuery.data : []; + const { selectedTenantId } = useBench(); return (
@@ -117,6 +245,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..ab8ec39358 100644 --- a/apps/web/src/routines-api.ts +++ b/apps/web/src/routines-api.ts @@ -32,6 +32,23 @@ const ScheduledWorkflowsResponse = type({ items: ScheduledWorkflowDefinition.array(), }); +export const AvailableCatalogWorkflow = type({ + assetName: "string", + displayName: "string", + description: "string", + requiredConnections: "string[]", + missingConnections: "string[]", + connectionsSatisfied: "boolean", + deployable: "boolean", + "notDeployableReason?": "'credential_bindings_unsupported'", +}); + +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 +105,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/apps/web/test/routines-available-section.test.tsx b/apps/web/test/routines-available-section.test.tsx new file mode 100644 index 0000000000..92018f461d --- /dev/null +++ b/apps/web/test/routines-available-section.test.tsx @@ -0,0 +1,205 @@ +// 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, + deployable: true, +}; + +const echo = { + assetName: "echo", + displayName: "Echo", + description: "Replies with the exact text it received.", + 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( + 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 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; + 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/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 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 new file mode 100644 index 0000000000..ce1e9fe4c5 --- /dev/null +++ b/packages/workflows/src/schedule/available-catalog.test.ts @@ -0,0 +1,91 @@ +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("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"], + 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/available-catalog.ts b/packages/workflows/src/schedule/available-catalog.ts new file mode 100644 index 0000000000..7279adbdc4 --- /dev/null +++ b/packages/workflows/src/schedule/available-catalog.ts @@ -0,0 +1,210 @@ +// 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, 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` (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; + readonly description: string; + 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 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, + isDeployableOnThisPin = () => true, + } = 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), + ); + const deployable = isDeployableOnThisPin(assetName); + + available.push({ + assetName, + displayName: entry.displayName, + description: entry.whatItDoes, + requiredConnections: entry.requiredConnections, + missingConnections, + connectionsSatisfied: missingConnections.length === 0, + deployable, + ...(deployable + ? {} + : { notDeployableReason: "credential_bindings_unsupported" as const }), + }); + } + return available; +} + +/** + * 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, + connectorIds: ReadonlySet, +): Promise> { + const satisfaction = new Map(); + if (connectorIds.size === 0) return satisfaction; + + 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 }, + }); + + 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, 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, isDeployableOnThisPin } = 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 allConnectorIds = new Set( + catalogAssetNames.flatMap( + (assetName) => workflowCatalogEntry(assetName)?.requiredConnections ?? [], + ), + ); + 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.test.ts b/packages/workflows/src/schedule/scheduled-route.test.ts index 0736455ac9..044a85d1a3 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,35 @@ 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, + deployable: true, + }; + 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); + }); }); diff --git a/packages/workflows/src/schedule/scheduled-route.ts b/packages/workflows/src/schedule/scheduled-route.ts index 5f0bff58d9..eb75dacc9b 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,22 @@ 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[]; + /** `@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, + catalogAssetNames: readonly string[], + ) => Promise; }; export function createScheduledWorkflowRoutes({ @@ -37,9 +61,26 @@ export function createScheduledWorkflowRoutes({ requireGrant, 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(); + 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"),