diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index f5aac11c1d..243622add2 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -114,8 +114,6 @@ import { launchWebhookTrigger, } from "@corbits/webhook-triggers"; import { - createWorkflowDetailRoute, - createScheduledWorkflowRoutes, ensureRunSession, isConversationalWorkflowName, } from "@corbits/workflows"; @@ -219,10 +217,7 @@ import type { SidecarProvisioner } from "@intx/hub-sessions"; import { withTurnPartWriteDefaults } from "./turn-part-content-default"; import { createDrizzleTurnTextSnapshotReader } from "./turn-text-snapshot"; import { createBootAssetWiring, REGISTRIES } from "./asset-service-factory"; -import { - runNowScheduledDefinition, - triggerNativeWorkflowRoutineRun, -} from "./native-workflow-routine-launch"; +import { triggerNativeWorkflowRoutineRun } from "./native-workflow-routine-launch"; import { createCronEmitter } from "@corbits/workflow-schedule/emitter"; import { createToolGrantsForPins } from "./tool-grants"; import { drainHubServer, shutdownHub } from "./shutdown"; @@ -1497,53 +1492,18 @@ export async function createHub(config: HubConfig) { `${TENANT_PREFIX}/inbox`, createInboxRoutes({ db: mailboxDb, bus: mailboxBus }), ); - // A workflow definition's own detail page (CL-7371): what it is, - // whether it can run right now, its steps, and its access surface. - // Mounted alongside — not inside — the vendored - // `createWorkflowDefinitionRoutes` (`vendor/intx/hub-api/src/app.ts` - // already mounts that one at this same `/workflows/definitions` - // prefix): this GET is a hub-composed read over native - // rows, so it lives in `@corbits/workflows`'s `./detail`, not the - // vendored tree. - app.route( - `${TENANT_PREFIX}/workflows/definitions`, - createWorkflowDetailRoute({ - db, - requireGrant: createRequireGrant({ - grantStore: chatGrantStore, - conditionRegistry: grantConditionRegistry, - }), - }), - ); - // Hub-zero T4 (CL-8126): the scheduled delivery join is cut — a - // triggered run launches straight into the runner with no - // settings-row lookup and no chat join. Pre-cutover rows carry no - // migration burden: the join never persisted anything (it read the - // settings registry and joined the run to a chat in memory per - // trigger), so there is no backfill and no default to apply. - app.route( - `${TENANT_PREFIX}/workflows`, - createScheduledWorkflowRoutes({ - db, - requireGrant: createRequireGrant({ - grantStore: chatGrantStore, - conditionRegistry: grantConditionRegistry, - }), - runNow: async (args) => - runNowScheduledDefinition( - { db, sidecarRouter }, - { - tenantId: args.tenantId, - definitionId: args.definitionId, - principalId: args.principalId, - fromDomain: args.fromDomain, - content: args.content, - name: args.name, - definitionAssetId: args.assetId, - }, - ), - }), - ); + // CL-8160: the Workbench-composed workflow detail route + // (`@corbits/workflows`'s former `./detail/detail-route.ts`, mounted + // here at this same `/workflows/definitions` prefix as the vendored + // `createWorkflowDefinitionRoutes`) and the scheduled-workflow routes + // (`./schedule/scheduled-route.ts`: list/run-now/available-catalog) are + // both deleted per the owner ruling that workflow detail and schedule + // state derive from stock `@intx/hub-api` reads, not a hub-composed + // route. Stock's `GET /workflows/definitions` (mounted below by + // `createWorkflowDefinitionRoutes`) does not yet expose enough to + // reconstruct what these routes gave (no manifest/package metadata, no + // wire projection, no grant snapshot) — see the PR for CL-8160 for the + // upstream ask. { const mailboxApp = new Hono(); mountMailbox(mailboxApp, { @@ -1892,9 +1852,9 @@ export async function createHub(config: HubConfig) { // Recurring auto-fire for schedule-triggered deployments. The cron and // the tick arithmetic belong to `@corbits/workflow-schedule`; this root // only supplies the two host bindings the emitter cannot own — which - // deployments are live, and how a tick fires. The fire is the same - // native trigger `runNowScheduledDefinition` uses, so a scheduled tick - // and a run-now are the same primitive with a different clock. + // deployments are live, and how a tick fires. CL-8160 deleted the + // hub-mounted run-now route (`triggerNativeWorkflowRoutineRun`'s only + // other caller); this cron fire is the sole remaining caller now. const cronEmitter = createCronEmitter({ listCronDeployments: async () => (await listDeployedCronDefinitions(db)).map((definition) => ({ diff --git a/apps/hub/src/native-workflow-routine-launch.ts b/apps/hub/src/native-workflow-routine-launch.ts index 8449fec2e3..dd55f27b48 100644 --- a/apps/hub/src/native-workflow-routine-launch.ts +++ b/apps/hub/src/native-workflow-routine-launch.ts @@ -184,32 +184,3 @@ export async function triggerNativeWorkflowRoutineRun( return { runId: anchor.id, address }; } - -export type RunNowScheduledDefinitionArgs = { - tenantId: string; - definitionId: string; - principalId: string; - fromDomain: string; - content: string; - name: string; - definitionAssetId: string; -}; - -/** - * Fire a schedule-triggered definition now and return its `{ runId }`. No - * delivery join: the run launches straight into the runner (hub-zero T4, - * CL-8126). - */ -export async function runNowScheduledDefinition( - deps: NativeWorkflowRoutineTriggerDeps, - args: RunNowScheduledDefinitionArgs, -): Promise<{ runId: string }> { - const triggered = await triggerNativeWorkflowRoutineRun(deps, { - tenantId: args.tenantId, - definitionId: args.definitionId, - principalId: args.principalId, - fromDomain: args.fromDomain, - content: args.content, - }); - return { runId: triggered.runId }; -} diff --git a/apps/web/src/pages/workflow-detail-page.tsx b/apps/web/src/pages/workflow-detail-page.tsx index c24d8744f3..935b7fe5a0 100644 --- a/apps/web/src/pages/workflow-detail-page.tsx +++ b/apps/web/src/pages/workflow-detail-page.tsx @@ -1,25 +1,13 @@ -// `/workflows/` (CL-7371) — a workflow definition's own -// page: what it is, whether it can run right now, its steps in execution -// order, and its access surface. Read-only, first useful version: header -// (name, lifecycle, source commit), steps, declared-vs-approved grants and -// credential binding names, and a "why not launchable" strip when the -// lifecycle isn't `deployed`. -// -// Never renders a credential value — only the binding names the hub -// route already redacted to (`@corbits/workflows`'s `./detail`'s -// `detail-route.ts`) — and never reads `workflow.json` (see -// docs/workflow-model.md's retirement). -import { - Badge, - EmptyState, - PageShell, - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@corbits/react-ui"; +// `/workflows/` (CL-7371) — a workflow definition's own +// page. CL-8160: the hub-composed detail read (name, lifecycle, source +// commit, steps, declared-vs-approved grants, credential bindings) is +// gone with `@corbits/workflows`'s deleted `./detail/detail-route.ts` — +// stock's `GET /workflows/definitions` (`workflow-detail-api.ts`) exposes +// only name, description, status (`deployed` | `stopped`), current +// version, and timestamps. This renders exactly that; see the CL-8160 PR +// for the upstream ask to expose more (package manifest, wire projection, +// grant snapshot) through a stock route. +import { Badge, EmptyState, PageShell } from "@corbits/react-ui"; import type { BadgeTone } from "@corbits/react-ui"; import { Clock, FlowArrow } from "@corbits/icons"; @@ -37,52 +25,35 @@ import { type WorkflowDefinitionDetailT, } from "../workflow-detail-api"; -const LIFECYCLE_LABEL: Readonly< - Record +const STATUS_LABEL: Readonly< + Record > = { - "source-only": "Source only", - "pending-approval": "Pending approval", deployed: "Deployed", - superseded: "Superseded", - "build-failed": "Build failed", + stopped: "Stopped", }; -const LIFECYCLE_TONE: Readonly< - Record +const STATUS_TONE: Readonly< + Record > = { - "source-only": "neutral", - "pending-approval": "warning", deployed: "success", - superseded: "neutral", - "build-failed": "danger", + stopped: "neutral", }; -function shortSha(sha: string): string { - return sha.length > 0 ? sha.slice(0, 7) : ""; -} - -/** The header row: display name, lifecycle badge, and (when known) the - * source commit that produced the current definition. */ +/** The header row: display name, status badge, and current version. */ function WorkflowDetailHeader({ detail, }: { readonly detail: WorkflowDefinitionDetailT; }) { - const sha = - detail.source !== undefined && detail.source !== null - ? shortSha(detail.source.commitSha) - : ""; return (
- - {LIFECYCLE_LABEL[detail.lifecycle]} + + {STATUS_LABEL[detail.status]} - {sha !== "" ? ( - - {sha} - - ) : null} + + v{detail.currentVersion} +
{detail.description !== undefined && detail.description !== null ? (

@@ -93,15 +64,14 @@ function WorkflowDetailHeader({ ); } -/** Why this definition can't be launched right now, and the honest next - * action — absent entirely once it is `deployed`, never a strip with - * nothing true to say. */ +/** Why this definition can't be launched right now — absent entirely once + * it is `deployed`, never a strip with nothing true to say. */ export function NotLaunchableStrip({ - lifecycle, + status, }: { - readonly lifecycle: WorkflowDefinitionDetailT["lifecycle"]; + readonly status: WorkflowDefinitionDetailT["status"]; }) { - const reason = workflowNotLaunchableReason(lifecycle); + const reason = workflowNotLaunchableReason(status); if (reason === null) return null; return (

@@ -110,103 +80,6 @@ export function NotLaunchableStrip({ ); } -/** Every step in execution order, with its role, model, director, tool - * pins, and the grants the deploy-time capability walk froze onto it. */ -export function WorkflowStepsSection({ - steps, -}: { - readonly steps: WorkflowDefinitionDetailT["steps"]; -}) { - return ( -
-

- Steps -

- {steps.length === 0 ? ( -

- No approved steps yet — this workflow has not been deployed. -

- ) : ( - - - - Step - Role - Director - Model - Tools - Grants - - - - {steps.map((step) => ( - - {step.id} - {step.role} - {step.director ?? "—"} - {step.model ?? "—"} - - {step.toolPins.length === 0 ? "—" : step.toolPins.join(", ")} - - - {step.grants.length === 0 ? "—" : step.grants.join(", ")} - - - ))} - -
- )} -
- ); -} - -/** Declared (what the source asks for) vs. approved (what the last freeze - * actually granted) — and the credential binding names a step can use. - * Names only, never a resolved credential value. */ -export function WorkflowAccessSection({ - detail, -}: { - readonly detail: WorkflowDefinitionDetailT; -}) { - return ( -
-

- Access -

-
- - Declared grants - - - {detail.grants.declared.length === 0 - ? "None declared" - : detail.grants.declared.join(", ")} - -
-
- - Approved grants - - - {detail.grants.approved.length === 0 - ? "None approved yet" - : detail.grants.approved.join(", ")} - -
-
- - Credential bindings - - - {detail.credentialBindings.length === 0 - ? "None" - : detail.credentialBindings.join(", ")} - -
-
- ); -} - /** The whole page body, given a resolved detail — pure, so the layout is * testable without a fetch or a router. */ export function WorkflowDetailPage({ @@ -219,15 +92,13 @@ export function WorkflowDetailPage({
- - - +
diff --git a/apps/web/src/path-ids.ts b/apps/web/src/path-ids.ts index feffc5b0ba..fcf34fbc24 100644 --- a/apps/web/src/path-ids.ts +++ b/apps/web/src/path-ids.ts @@ -68,9 +68,13 @@ export function routineSegmentFromPath(path: string): string | null { return entityIdFromTopLevelPath(path, ROUTINES_PATH_PREFIX); } -/** The definition asset id `/workflows/` addresses — `null` for the +/** The workflow definition id `/workflows/` addresses — `null` for the * bare prefix or a path outside it. A workflow definition has no slug of - * its own, so — like a routine — it is addressed by its opaque id. */ + * its own, so — like a routine — it is addressed by its opaque id. + * CL-8160: since the detail page now reads stock's `workflow_definition` + * list directly, this is `workflowDefinition.id`, not `asset.id` (the + * former Workbench-composed detail route's own address space) — the + * export name is kept to limit the diff. */ export function workflowDefinitionAssetIdFromPath(path: string): string | null { return entityIdFromTopLevelPath(path, WORKFLOWS_PATH_PREFIX); } diff --git a/apps/web/src/routines-api.ts b/apps/web/src/routines-api.ts index b4a1ed82a9..d32d6739ad 100644 --- a/apps/web/src/routines-api.ts +++ b/apps/web/src/routines-api.ts @@ -1,9 +1,24 @@ // The Routines page's seam to authored workflow definitions that carry a // ScheduleTrigger: list, run-now, and pause/resume. Pause/resume is the // same agent-directory status PUT seed uses (`stopped` / `deployed`). +// +// CL-8160: the hub routes `listScheduledWorkflows` and +// `runScheduledWorkflowNow`/`listAvailableCatalogWorkflows` used to call +// (`@corbits/workflows`'s deleted `./schedule/scheduled-route.ts`) are +// gone. Owner ruling: schedule state derives from stock reads client-side +// or stays a package-internal pure function — no hub route. Stock's +// `GET /workflows/definitions` (`vendor/intx/hub-api`) exposes no wire +// projection, so there is no stock-derivable way to know which +// definitions carry a `ScheduleTrigger` or what its cron is, and +// `listAvailableCatalogWorkflows`'s connection-satisfaction read has no +// stock equivalent either. Both resolve to an empty list rather than +// fetch a route that no longer exists (same pattern as `tenantKeys`' +// `routineActivity` comment in `query-client.ts` for CL-8087's deleted +// `feed=fires` route): the Routines roster and Available section render +// their existing empty states until an upstream ask lands. See the +// CL-8160 PR for exactly what is missing. import { type } from "arktype"; -import type { ArkErrors } from "arktype"; import { useQuery } from "@tanstack/react-query"; import type { APIQuery } from "@corbits/api-query"; import { @@ -28,10 +43,6 @@ export const ScheduledWorkflowDefinition = type({ export type ScheduledWorkflowDefinition = typeof ScheduledWorkflowDefinition.infer; -const ScheduledWorkflowsResponse = type({ - items: ScheduledWorkflowDefinition.array(), -}); - export const AvailableCatalogWorkflow = type({ assetName: "string", displayName: "string", @@ -43,77 +54,24 @@ export const AvailableCatalogWorkflow = type({ export type AvailableCatalogWorkflow = typeof AvailableCatalogWorkflow.infer; -const AvailableCatalogWorkflowsResponse = type({ - items: AvailableCatalogWorkflow.array(), -}); - -const RunNowResponse = type({ runId: "string" }); - -type Validator = (data: unknown) => T | ArkErrors; - -async function request( - path: string, - schema: Validator, - init?: RequestInit, -): Promise { - let response: Response; - try { - response = await fetch(path, { - ...init, - headers: { "content-type": "application/json", ...init?.headers }, - }); - } catch (cause) { - throw new ApiQueryError( - cause instanceof Error ? cause.message : String(cause), - undefined, - path, - ); - } - if (response.status === 401) { - throw new ApiQueryError("Not signed in.", 401, path); - } - if (!response.ok) { - const detail = await response - .json() - .then( - (body: { error?: { userMessage?: string } }) => - body.error?.userMessage ?? "", - ) - .catch(() => ""); - throw new ApiQueryError( - detail === "" ? `The server answered ${response.status}.` : detail, - response.status, - path, - ); - } - if (response.status === 204) return undefined as T; - const body: unknown = await response.json().catch(() => undefined); - const parsed = schema(body); - if (parsed instanceof type.errors) { - throw new ApiQueryError( - `Unexpected response shape: ${parsed.summary}`, - undefined, - path, - ); - } - return parsed; -} - +/** CL-8160: no route exists at this path any more — kept as a documented + * dead address, not a live fetch target, for any caller that still reads + * it for logging/keys. */ export function scheduledWorkflowsPath(tenantId: string): string { return `/api/tenants/${tenantId}/workflows/scheduled`; } +/** CL-8160: no route exists at this path any more — see the file header. */ export function availableCatalogWorkflowsPath(tenantId: string): string { return `/api/tenants/${tenantId}/workflows/available`; } +/** CL-8160: always empty — see the file header for why there is no + * stock-derivable replacement yet. */ export function listAvailableCatalogWorkflows( - tenantId: string, + _tenantId: string, ): Promise { - return request( - availableCatalogWorkflowsPath(tenantId), - AvailableCatalogWorkflowsResponse, - ).then((page) => page.items); + return Promise.resolve([]); } export function scheduledWorkflowRunPath( @@ -123,26 +81,28 @@ export function scheduledWorkflowRunPath( return `/api/tenants/${tenantId}/workflows/scheduled/${encodeURIComponent(definitionId)}/run`; } +/** CL-8160: always empty — see the file header for why there is no + * stock-derivable replacement yet. */ export function listScheduledWorkflows( - tenantId: string, + _tenantId: string, ): Promise { - return request( - scheduledWorkflowsPath(tenantId), - ScheduledWorkflowsResponse, - ).then((page) => page.items); + return Promise.resolve([]); } +/** CL-8160: unreachable in practice — `listScheduledWorkflows` never + * returns a row for `onRunNow` to be called with — kept only so + * `useRoutineActions`' shape does not need to change too. Throws rather + * than fetching a route that no longer exists. */ export function runScheduledWorkflowNow( - tenantId: string, - definitionId: string, + _tenantId: string, + _definitionId: string, ): Promise<{ runId: string }> { - return request( - scheduledWorkflowRunPath(tenantId, definitionId), - RunNowResponse, - { - method: "POST", - body: JSON.stringify({}), - }, + return Promise.reject( + new ApiQueryError( + "Scheduled run-now has no stock route yet (CL-8160).", + undefined, + scheduledWorkflowRunPath(_tenantId, _definitionId), + ), ); } diff --git a/apps/web/src/shell/context-menu/items.test.tsx b/apps/web/src/shell/context-menu/items.test.tsx index d7d674914a..de9996e1a7 100644 --- a/apps/web/src/shell/context-menu/items.test.tsx +++ b/apps/web/src/shell/context-menu/items.test.tsx @@ -186,41 +186,15 @@ describe("shellContextMenuFor: routine", () => { expect(navigate).toHaveBeenCalledWith("/routines/rt-1"); }); - test("run-now invalidates routine queries via onRoutineRan once the run starts", async () => { - const realFetch = globalThis.fetch; - globalThis.fetch = mock(() => - Promise.resolve( - new Response(JSON.stringify({ runId: "run-1" }), { - status: 200, - headers: { "content-type": "application/json" }, - }), - ), - ) as unknown as typeof fetch; - const onRoutineRan = mock((_tenantId: string) => undefined); - try { - const menu = shellContextMenuFor(target, actions({ onRoutineRan })); - findItem(menu.entries, "run-now").onSelect(); - for (let i = 0; i < 10 && onRoutineRan.mock.calls.length === 0; i++) { - await Promise.resolve(); - } - expect(onRoutineRan).toHaveBeenCalledWith("tenant-1"); - expect(toastMock).toHaveBeenCalledWith("Nightly Digest started"); - } finally { - globalThis.fetch = realFetch; - } - }); - - test("run-now does not invalidate routine queries when the run fails to start", async () => { - const realFetch = globalThis.fetch; + // CL-8160: `runScheduledWorkflowNow` no longer calls a hub route at all + // (`@corbits/workflows`'s scheduled-workflow routes are deleted — see + // `apps/web/src/routines-api.ts`'s file header) — it always rejects. The + // former "run starts" success-path test asserted an outcome that is no + // longer reachable through any fetch response and is deleted rather + // than adapted; the failure path below still holds, just without a + // mocked fetch to drive it. + test("run-now surfaces a toast and reports the error, since it has no stock route yet", async () => { const report = spyOn(errorSink, "reportError").mockReturnValue("ref_test"); - globalThis.fetch = mock(() => - Promise.resolve( - new Response(JSON.stringify({ error: { message: "boom" } }), { - status: 500, - headers: { "content-type": "application/json" }, - }), - ), - ) as unknown as typeof fetch; const onRoutineRan = mock((_tenantId: string) => undefined); try { const menu = shellContextMenuFor(target, actions({ onRoutineRan })); @@ -228,7 +202,6 @@ describe("shellContextMenuFor: routine", () => { for (let i = 0; i < 10 && toastMock.mock.calls.length === 0; i++) { await Promise.resolve(); } - expect(toastMock).toHaveBeenCalledWith("Couldn't start the routine"); expect(onRoutineRan).not.toHaveBeenCalled(); expect(report).toHaveBeenCalled(); expect(report.mock.calls[0]?.[1]).toEqual({ @@ -237,7 +210,6 @@ describe("shellContextMenuFor: routine", () => { }); } finally { report.mockRestore(); - globalThis.fetch = realFetch; } }); }); diff --git a/apps/web/src/workflow-detail-api.ts b/apps/web/src/workflow-detail-api.ts index f5836f01df..0abe8aadb1 100644 --- a/apps/web/src/workflow-detail-api.ts +++ b/apps/web/src/workflow-detail-api.ts @@ -1,7 +1,10 @@ -// The workflow detail page's one seam to the hub's read route -// (`@corbits/workflows`'s `./detail/detail-route.ts`, mounted at -// `${TENANT_PREFIX}/workflows/definitions/:definitionAssetId/detail` in -// `apps/hub/src/index.ts`). Wire schema and pure display helpers live in +// The workflow detail page's one seam to reading a definition. CL-8160: +// the Workbench-composed hub route (`@corbits/workflows`'s former +// `./detail/detail-route.ts`) is deleted — there is no `GET .../:id` +// route at all, stock or otherwise (`vendor/intx/hub-api/src/routes/ +// workflow-definitions.ts` only lists, lists versions, and rolls back). +// This walks the stock list a page at a time and stops at the matching +// id. Wire schema and pure display helpers live in // `@corbits/workflows/client`, browser-safe like `routines-api.ts`'s own // definitions listing — this file is fetch composition only. import { type } from "arktype"; @@ -16,6 +19,21 @@ export { workflowNotLaunchableReason, } from "@corbits/workflows/client"; +const StockWorkflowDefinition = type({ + id: "string", + "description?": "string | null", + name: "string", + currentVersion: "string", + status: "'deployed' | 'stopped'", + createdAt: "string", + updatedAt: "string", +}); + +const StockWorkflowDefinitionsPage = type({ + data: StockWorkflowDefinition.array(), + nextCursor: "string | null", +}); + type Validator = (data: unknown) => T | ArkErrors; async function request(path: string, schema: Validator): Promise { @@ -60,12 +78,42 @@ async function request(path: string, schema: Validator): Promise { return parsed; } -export function getWorkflowDefinitionDetail( +const MAX_PAGES_SCANNED = 20; + +export async function getWorkflowDefinitionDetail( tenantId: string, - definitionAssetId: string, + definitionId: string, ): Promise { - return request( - `/api/tenants/${tenantId}/workflows/definitions/${encodeURIComponent(definitionAssetId)}/detail`, - WorkflowDefinitionDetail, - ); + let cursor: string | null | undefined; + for (let page = 0; page < MAX_PAGES_SCANNED; page += 1) { + const query = cursor != null ? `?cursor=${encodeURIComponent(cursor)}` : ""; + const listPath = `/api/tenants/${tenantId}/workflows/definitions${query}`; + const body = await request(listPath, StockWorkflowDefinitionsPage); + const found = body.data.find((item) => item.id === definitionId); + if (found !== undefined) { + const detail: WorkflowDefinitionDetailT = { + definitionId: found.id, + name: found.name, + status: found.status, + currentVersion: found.currentVersion, + createdAt: found.createdAt, + updatedAt: found.updatedAt, + ...(found.description !== undefined && found.description !== null + ? { description: found.description } + : {}), + }; + const parsed = WorkflowDefinitionDetail(detail); + if (parsed instanceof type.errors) { + throw new ApiQueryError( + `Unexpected response shape: ${parsed.summary}`, + undefined, + listPath, + ); + } + return parsed; + } + if (body.nextCursor == null) break; + cursor = body.nextCursor; + } + throw new ApiQueryError("Workflow not found.", 404, definitionId); } diff --git a/apps/web/test/routine-detail-page.test.tsx b/apps/web/test/routine-detail-page.test.tsx index 700859484f..adf60cfef1 100644 --- a/apps/web/test/routine-detail-page.test.tsx +++ b/apps/web/test/routine-detail-page.test.tsx @@ -295,18 +295,12 @@ describe("RoutineDetailRoute", () => { window.localStorage.clear(); } - test("an id renders the scheduled workflow itself", async () => { - const { container, root } = await renderRoute("wfd_mine", { - tnt_1: [scheduledRecord({ definitionId: "wfd_mine", name: "My digest" })], - }); - try { - expect(container.textContent).toContain("My digest"); - expect(container.textContent).toContain("Acme Team"); - expect(container.textContent).not.toContain("0 9 * * *"); - } finally { - cleanup(container, root); - } - }); + // CL-8160: `listScheduledWorkflows` (`../src/routines-api.ts`) now + // always resolves empty — the hub route this mocked + // (`/api/tenants/:id/workflows/scheduled`) is deleted with no + // stock-derivable replacement yet. The "renders the scheduled workflow + // itself" case this suite used to prove is unreachable; deleted rather + // than adapted. test("an unknown id says no scheduled workflow matches", async () => { const { container, root } = await renderRoute("wfd_deleted", { diff --git a/apps/web/test/routines-available-section.test.tsx b/apps/web/test/routines-available-section.test.tsx deleted file mode 100644 index 490cd70bc7..0000000000 --- a/apps/web/test/routines-available-section.test.tsx +++ /dev/null @@ -1,122 +0,0 @@ -// Screen-level proof for the Routines page's "Available" section -// (CL-7073): lists catalog workflows the bench hasn't added yet, and -// shows a missing-required-connection reason with a link to Plugins. -// CL-8160 deleted the hub's on-demand catalog-blocks deploy route -// (`apps/hub/src/catalog-blocks/*`) — the section is read-only until a -// deploy path lands on stock rails. - -import { describe, expect, test } from "bun:test"; -import { act, createElement } from "react"; -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("shows a missing-connection reason and links to Plugins when a required connection is absent", async () => { - const { container, root } = await render((async ( - input: RequestInfo | URL, - ) => { - 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 { - expect(container.textContent).toContain("Connect GitHub first."); - const link = container.querySelector("a"); - expect(link?.getAttribute("href")).toBe("/plugins"); - } finally { - restoreFetch(container, root); - } - }); -}); diff --git a/apps/web/test/routines-page.test.tsx b/apps/web/test/routines-page.test.tsx index 9e8d9c6733..2cb114740b 100644 --- a/apps/web/test/routines-page.test.tsx +++ b/apps/web/test/routines-page.test.tsx @@ -190,161 +190,13 @@ describe("GlobalRoutinesList", () => { }); }); -describe("RoutinesRoute — membership-based aggregation", () => { - function jsonResponse(body: unknown): Response { - return new Response(JSON.stringify(body), { - status: 200, - headers: { "content-type": "application/json" }, - }); - } - - function scheduledRecord( - overrides: Record, - ): Record { - return { - definitionId: "wfd_1", - assetId: "ast_1", - name: "My digest", - tenantId: "tnt_1", - status: "deployed", - cron: "0 9 * * *", - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - ...overrides, - }; - } - - const memberships = [ - { - principalId: "prn_me", - tenantId: "tnt_1", - tenantName: "Acme Team", - tenantSlug: "acme", - kind: "user", - status: "active", - roles: [], - }, - { - principalId: "prn_me_2", - tenantId: "tnt_2", - tenantName: "Beta Team", - tenantSlug: "beta", - kind: "user", - status: "active", - roles: [], - }, - ]; - - const itemsByTenant: Record[]> = { - tnt_1: [ - scheduledRecord({ - definitionId: "wfd_mine", - name: "My digest", - tenantId: "tnt_1", - }), - scheduledRecord({ - definitionId: "wfd_paused", - name: "Paused digest", - tenantId: "tnt_1", - status: "stopped", - }), - ], - tnt_2: [ - scheduledRecord({ - definitionId: "wfd_theirs", - name: "Their digest", - tenantId: "tnt_2", - }), - ], - }; - - function mockFetch(): typeof fetch { - return (async (input: RequestInfo | URL): Promise => { - const url = String(input); - if (url.includes("/api/me/principals")) { - return jsonResponse({ data: memberships, nextCursor: null }); - } - const scheduledMatch = url.match( - /\/api\/tenants\/([^/]+)\/workflows\/scheduled$/, - ); - if (scheduledMatch) { - return jsonResponse({ - items: itemsByTenant[scheduledMatch[1] as string] ?? [], - }); - } - if (url.includes("/chat/workbenches") && url.includes("kind=workbench")) { - return jsonResponse({ items: [] }); - } - return Promise.reject(new Error(`unrouted fetch: ${url}`)); - }) as typeof fetch; - } - - async function renderRoute(): Promise<{ - container: HTMLDivElement; - root: Root; - }> { - const { BenchProvider } = await import("../src/bench-context"); - const { RoutinesRoute } = await import("../src/pages/routines-page"); - const { TestQueryProvider } = await import("./test-query-provider"); - - const container = document.createElement("div"); - document.body.appendChild(container); - const root: Root = createRoot(container); - await act(async () => { - root.render( - - - {createElement(RoutinesRoute)} - - , - ); - }); - for (let i = 0; i < 8; i++) { - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 10)); - }); - } - return { container, root }; - } - - test("lists scheduled definitions from every bench, including stopped ones", async () => { - const realFetch = globalThis.fetch; - globalThis.fetch = mockFetch(); - const { container, root } = await renderRoute(); - try { - expect(container.textContent).toContain("My digest"); - expect(container.textContent).toContain("Their digest"); - expect(container.textContent).toContain("Paused digest"); - expect(container.textContent).toContain("Acme Team"); - expect(container.textContent).toContain("Beta Team"); - expect(container.querySelector('button[role="switch"]')).not.toBeNull(); - expect(container.textContent).not.toContain("New routine"); - expect(container.textContent).not.toContain("0 9 * * *"); - } finally { - act(() => root.unmount()); - container.remove(); - globalThis.fetch = realFetch; - window.localStorage.clear(); - } - }); - - test("each row links to its definition by id — the address a rename cannot break", async () => { - const realFetch = globalThis.fetch; - globalThis.fetch = mockFetch(); - const { container, root } = await renderRoute(); - try { - const hrefs = [...container.querySelectorAll("a")].map((a) => - a.getAttribute("href"), - ); - expect(hrefs).toContain("/routines/wfd_mine"); - expect(hrefs).toContain("/routines/wfd_theirs"); - expect(hrefs).toContain("/routines/wfd_paused"); - expect(hrefs).not.toContain("/routines/my-digest"); - } finally { - act(() => root.unmount()); - container.remove(); - globalThis.fetch = realFetch; - window.localStorage.clear(); - } - }); -}); +// CL-8160: the "RoutinesRoute — membership-based aggregation" suite that +// lived here drove `RoutinesRoute` against a mocked +// `/api/tenants/:id/workflows/scheduled` fetch — that route +// (`@corbits/workflows`'s deleted `./schedule/scheduled-route.ts`) is +// gone, and `listScheduledWorkflows` (`../src/routines-api.ts`) now +// always resolves empty rather than fetching a route that no longer +// exists. Deleted rather than adapted: there is nothing left to mock +// against, and no stock-derivable replacement yet (see the CL-8160 PR). +// `GlobalRoutinesList`'s own presentational tests above are untouched — +// only the network-backed aggregation is gone. diff --git a/apps/web/test/workflow-detail-page.test.tsx b/apps/web/test/workflow-detail-page.test.tsx index 784e79fa0f..647e20a04b 100644 --- a/apps/web/test/workflow-detail-page.test.tsx +++ b/apps/web/test/workflow-detail-page.test.tsx @@ -1,8 +1,9 @@ -// `/workflows/` (CL-7371): a workflow definition's own -// page. Covers the pure `WorkflowDetailPage` body against fixtures for the -// three things that matter first — the lifecycle badge/copy actually -// reflects `lifecycle`, steps render in order, and access reads declared -// vs. approved grants plus credential binding names, never a value. +// `/workflows/` (CL-7371, thinned CL-8160): a workflow +// definition's own page. Covers the pure `WorkflowDetailPage` body +// against fixtures for what stock's `GET /workflows/definitions` (via +// `getWorkflowDefinitionDetail`) actually exposes — name, description, +// status, and current version — now that the hub-composed detail read +// (lifecycle, source commit, steps, grants, credential bindings) is gone. import { describe, expect, test } from "bun:test"; import { renderToStaticMarkup } from "react-dom/server"; @@ -10,84 +11,47 @@ import { WorkflowDetailPage } from "../src/pages/workflow-detail-page"; import type { WorkflowDefinitionDetailT } from "../src/workflow-detail-api"; const baseDetail: WorkflowDefinitionDetailT = { - definitionAssetId: "asset_outreach", - assetName: "outreach", - displayName: "Outreach", + definitionId: "wfd_1", + name: "Outreach", description: "Sends outreach messages", - lifecycle: "deployed", - currentDefinitionId: "wfd_1", - wireHash: "hash_1", - source: { - commitSha: "abcdef1234567890", - entry: "src/index.ts", - origin: "asset", - }, - steps: [ - { - id: "s1", - role: "step", - director: "outreach-agent", - model: "claude-sonnet-5", - toolPins: ["@corbits/mail-tools"], - grants: ["mail:send"], - }, - ], - grants: { - declared: ["mail:*:send"], - approved: ["mail:*:send"], - }, - credentialBindings: ["gmail"], + status: "deployed", + currentVersion: "3", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-02T00:00:00.000Z", }; describe("WorkflowDetailPage", () => { - test("a deployed workflow shows no not-launchable strip and renders its steps", () => { + test("a deployed workflow shows no not-launchable strip", () => { const html = renderToStaticMarkup( , ); expect(html).toContain("Deployed"); - expect(html).toContain("abcdef1"); - expect(html).toContain("outreach-agent"); - expect(html).toContain("claude-sonnet-5"); - expect(html).toContain("@corbits/mail-tools"); - expect(html).toContain("mail:send"); - expect(html).not.toContain( - "This workflow's source has never been deployed", - ); + expect(html).toContain("v3"); + expect(html).toContain("Sends outreach messages"); + expect(html).not.toContain("resume it to make it launchable"); }); - test("a pending-approval workflow shows the why-not-launchable strip", () => { + test("a stopped workflow shows the why-not-launchable strip", () => { const detail: WorkflowDefinitionDetailT = { ...baseDetail, - lifecycle: "pending-approval", - source: null, + status: "stopped", }; const html = renderToStaticMarkup(); - expect(html).toContain("Pending approval"); - expect(html).toContain("waiting on human approval"); + expect(html).toContain("Stopped"); + expect(html).toContain("resume it to make it launchable"); }); - test("access section reads declared vs. approved grants and credential names only", () => { - const html = renderToStaticMarkup( - , - ); - expect(html).toContain("Declared grants"); - expect(html).toContain("Approved grants"); - expect(html).toContain("gmail"); - }); - - test("a source-only workflow with no steps says so plainly", () => { + test("a definition with no description renders without one", () => { const detail: WorkflowDefinitionDetailT = { - definitionAssetId: "asset_new", - assetName: "new-workflow", - displayName: "New workflow", - lifecycle: "source-only", - steps: [], - grants: { declared: [], approved: [] }, - credentialBindings: [], + definitionId: "wfd_2", + name: "New workflow", + status: "deployed", + currentVersion: "1", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", }; const html = renderToStaticMarkup(); - expect(html).toContain("Source only"); - expect(html).toContain("No approved steps yet"); - expect(html).toContain("deploy it to make it launchable"); + expect(html).toContain("New workflow"); + expect(html).toContain("Deployed"); }); }); diff --git a/packages/workflows/src/client.ts b/packages/workflows/src/client.ts index 0118597a44..4cd50dcede 100644 --- a/packages/workflows/src/client.ts +++ b/packages/workflows/src/client.ts @@ -25,15 +25,7 @@ export { workflowNotLaunchableReason, workflowDetailPath, WorkflowDefinitionDetail, - WorkflowDetailSource, - WorkflowDetailStep, } from "./detail/definition-detail"; -export { - deriveWorkflowLifecycle, - type DefinitionLifecycleRow, - type WorkflowLifecycle, - type WorkflowLifecycleResult, -} from "./detail/definition-lifecycle"; export { WorkflowTriggerField, WORKFLOW_CATALOG, diff --git a/packages/workflows/src/detail/definition-detail.ts b/packages/workflows/src/detail/definition-detail.ts index 450e9174e2..2968f5cec5 100644 --- a/packages/workflows/src/detail/definition-detail.ts +++ b/packages/workflows/src/detail/definition-detail.ts @@ -1,66 +1,48 @@ -// The wire shape for `GET /api/tenants/:tenantId/workflows/definitions/ -// :definitionAssetId/detail` (`./detail-route.ts`) — a definition's own -// page reads this and nothing else. Pure/browser-safe: no `@intx/*`, no -// `drizzle-orm`, no `hono` — the same promise `@corbits/workflows/client` -// makes, so `apps/web` can import it directly. +// The wire shape a workflow's own page (`apps/web/src/pages/ +// workflow-detail-page.tsx`) reads. Pure/browser-safe: no `@intx/*`, no +// `drizzle-orm`, no `hono` — `@corbits/workflows/client` re-exports this +// directly so `apps/web` can import it. +// +// CL-8160: the Workbench-composed detail read (former `./detail-route.ts`, +// which joined `workflow_definition`/`workflow_definition_version`/`asset` +// through native freeze reads for steps, grants, credential bindings, a +// five-value lifecycle, and source commit info) is gone — the hub mounts +// no route of its own for this any more. The client now renders directly +// off `@intx/hub-api`'s stock `GET /api/tenants/:tenantId/workflows/ +// definitions` list (`vendor/intx/hub-api/src/routes/workflow-definitions.ts`, +// `WorkflowDefinitionResponse` in `vendor/intx/types/src/workflows.ts`), +// which exposes only `id`, `name`, `description`, `currentVersion`, +// `status` (`deployed` | `stopped`), `createdAt`, `updatedAt` — no asset +// display name, no manifest/package metadata, no wire projection (so no +// steps, no schedule trigger), and no grant/credential-binding read. See +// `docs/CL-8160-upstream-ask.md`-equivalent PR note: this is a real gap, +// not a design choice made here. import { type } from "arktype"; -export const WorkflowDetailStep = type({ - id: "string", - role: "string", - "director?": "string | null", - "model?": "string | null", - toolPins: "string[]", - grants: "string[]", -}); -export type WorkflowDetailStep = typeof WorkflowDetailStep.infer; - -export const WorkflowDetailSource = type({ - commitSha: "string", - entry: "string", - origin: "string", -}); -export type WorkflowDetailSource = typeof WorkflowDetailSource.infer; - export const WorkflowDefinitionDetail = type({ - definitionAssetId: "string", - assetName: "string", - displayName: "string", + definitionId: "string", + name: "string", "description?": "string | null", - lifecycle: - "'source-only' | 'pending-approval' | 'deployed' | 'superseded' | 'build-failed'", - "currentDefinitionId?": "string | null", - "wireHash?": "string | null", - "source?": WorkflowDetailSource.or("null"), - steps: WorkflowDetailStep.array(), - grants: { - declared: "string[]", - approved: "string[]", - }, - credentialBindings: "string[]", + status: "'deployed' | 'stopped'", + currentVersion: "string", + createdAt: "string", + updatedAt: "string", }); export type WorkflowDefinitionDetail = typeof WorkflowDefinitionDetail.infer; -/** Copy for the "why not launchable" strip — the next honest action for - * every lifecycle short of `deployed`. `null` for `deployed`: nothing to - * say, the strip does not render. */ +/** The next honest action for a definition that is not launchable right + * now. `null` for `deployed`: nothing to say, the strip does not render. + * Stock only distinguishes `deployed`/`stopped` — the richer + * source-only/pending-approval/superseded/build-failed vocabulary the + * native freeze read used to derive is gone with it. */ export function workflowNotLaunchableReason( - lifecycle: WorkflowDefinitionDetail["lifecycle"], + status: WorkflowDefinitionDetail["status"], ): string | null { - switch (lifecycle) { - case "deployed": - return null; - case "source-only": - return "This workflow's source has never been deployed — deploy it to make it launchable."; - case "pending-approval": - return "A deploy is waiting on human approval before it can run."; - case "superseded": - return "A newer deploy replaced this one — redeploy or roll forward to make it launchable again."; - case "build-failed": - return "The last deploy attempt did not produce a runnable definition — check the deploy and try again."; - } + return status === "deployed" + ? null + : "This workflow is stopped — resume it to make it launchable."; } -export function workflowDetailPath(definitionAssetId: string): string { - return `/workflows/${encodeURIComponent(definitionAssetId)}`; +export function workflowDetailPath(definitionId: string): string { + return `/workflows/${encodeURIComponent(definitionId)}`; } diff --git a/packages/workflows/src/detail/definition-lifecycle.test.ts b/packages/workflows/src/detail/definition-lifecycle.test.ts deleted file mode 100644 index 7192e09354..0000000000 --- a/packages/workflows/src/detail/definition-lifecycle.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - deriveWorkflowLifecycle, - type DefinitionLifecycleRow, -} from "./definition-lifecycle"; - -function row(patch: Partial): DefinitionLifecycleRow { - return { - id: "wfd_1", - wireHash: "hash_1", - approvedWireHash: "hash_1", - grantSnapshot: { perStep: [], grantRequirements: [] }, - wireProjection: { stepOrder: [], steps: {} }, - status: "deployed", - createdAt: "2026-01-01T00:00:00.000Z", - ...patch, - }; -} - -describe("deriveWorkflowLifecycle", () => { - test("no definition rows is source-only — the native cutover removed the deploy-attempt signal", () => { - const result = deriveWorkflowLifecycle([]); - expect(result).toEqual({ - lifecycle: "source-only", - currentDefinitionId: null, - wireHash: null, - }); - }); - - test("newest row lacking an approved hash is pending-approval", () => { - const result = deriveWorkflowLifecycle([ - row({ id: "wfd_2", approvedWireHash: null }), - ]); - expect(result.lifecycle).toBe("pending-approval"); - expect(result.currentDefinitionId).toBe("wfd_2"); - }); - - test("newest row approved and deployed is deployed", () => { - const result = deriveWorkflowLifecycle([row({ id: "wfd_3" })]); - expect(result).toEqual({ - lifecycle: "deployed", - currentDefinitionId: "wfd_3", - wireHash: "hash_1", - }); - }); - - test("newest row approved but stopped is superseded", () => { - const result = deriveWorkflowLifecycle([ - row({ id: "wfd_4", status: "stopped" }), - ]); - expect(result.lifecycle).toBe("superseded"); - }); - - test("picks the newest of several rows by createdAt", () => { - const older = row({ - id: "wfd_old", - status: "stopped", - createdAt: "2026-01-01T00:00:00.000Z", - }); - const newer = row({ - id: "wfd_new", - status: "deployed", - createdAt: "2026-02-01T00:00:00.000Z", - }); - const result = deriveWorkflowLifecycle([older, newer]); - expect(result.lifecycle).toBe("deployed"); - expect(result.currentDefinitionId).toBe("wfd_new"); - }); -}); diff --git a/packages/workflows/src/detail/definition-lifecycle.ts b/packages/workflows/src/detail/definition-lifecycle.ts deleted file mode 100644 index 4b13d62355..0000000000 --- a/packages/workflows/src/detail/definition-lifecycle.ts +++ /dev/null @@ -1,98 +0,0 @@ -// Pure lifecycle derivation for a workflow definition's asset — the -// question `GET .../detail` (`./detail-route.ts`) exists to answer: is -// this thing runnable right now, and if not, what stage is it stuck at? -// -// Native rows carry no "lifecycle" column of their own — `workflow_model.md` -// keys `workflow_definition` on `(asset_id, wire_hash)`, so a redeploy mints -// a new row rather than mutating one. What a person needs is a single -// reading of the newest row for the asset, derived from native rows alone -// (CL-7591 cut over off the deleted Workbench durability store: native -// deployments/triggers are the only deploy-attempt signal). Kept in -// its own module, with no DB import, so the states below are covered by a -// plain unit test rather than a route fixture. -import { isFrozen } from "../launchable/target-rule"; - -export type WorkflowLifecycle = - | "source-only" - | "pending-approval" - | "deployed" - | "superseded" - | "build-failed"; - -/** The one row's worth of state the derivation needs — the newest - * `workflow_definition` row for an asset, or absent entirely. */ -export type DefinitionLifecycleRow = { - readonly id: string; - readonly wireHash: string | null; - /** `workflow_definition_version.approved_wire_hash` for this row's - * current version — `null` means the freeze never landed. */ - readonly approvedWireHash: string | null; - /** The rest of the same freeze, alongside `approvedWireHash` — folded - * into `isFrozen` below rather than read alone. */ - readonly grantSnapshot: unknown; - readonly wireProjection: unknown; - readonly status: "deployed" | "stopped"; - /** ISO timestamp, used only to pick the newest row when more than one - * is passed in. */ - readonly createdAt: string; -}; - -export type WorkflowLifecycleResult = { - readonly lifecycle: WorkflowLifecycle; - readonly currentDefinitionId: string | null; - readonly wireHash: string | null; -}; - -/** - * Derive an asset's lifecycle from its `workflow_definition` rows (any - * order) — native rows only. - * - * - No rows → `source-only`: nothing has ever produced a definition row - * for this asset. (The native cutover removed the Workbench - * deploy-attempt signal, so a failed deploy leaves no rows and reads - * the same as never-attempted. `build-failed` stays in the - * `WorkflowLifecycle` union only as wire/UI shape compatibility — the - * route no longer produces it.) - * - Rows exist: the newest one decides. Not yet frozen (see `isFrozen`) → - * `pending-approval`. Frozen and `status: "deployed"` → `deployed`. - * Frozen but rolled back / replaced (`status: "stopped"`) → - * `superseded`. - */ -export function deriveWorkflowLifecycle( - rows: readonly DefinitionLifecycleRow[], -): WorkflowLifecycleResult { - if (rows.length === 0) { - return { - lifecycle: "source-only", - currentDefinitionId: null, - wireHash: null, - }; - } - - // A tie on `createdAt` (redeploys can mint rows in the same request, at - // timestamp granularity that doesn't separate them) breaks on `id` so - // "newest" is a deterministic total order, never array-input-order. - const newest = [...rows].sort((a, b) => { - if (a.createdAt !== b.createdAt) { - return a.createdAt < b.createdAt ? 1 : -1; - } - return a.id < b.id ? 1 : a.id > b.id ? -1 : 0; - })[0]; - if (newest === undefined) { - throw new Error("deriveWorkflowLifecycle: unreachable — rows non-empty"); - } - - if (!isFrozen(newest)) { - return { - lifecycle: "pending-approval", - currentDefinitionId: newest.id, - wireHash: newest.wireHash, - }; - } - - return { - lifecycle: newest.status === "deployed" ? "deployed" : "superseded", - currentDefinitionId: newest.id, - wireHash: newest.wireHash, - }; -} diff --git a/packages/workflows/src/detail/detail-route.drizzle.test.ts b/packages/workflows/src/detail/detail-route.drizzle.test.ts deleted file mode 100644 index 22de14f695..0000000000 --- a/packages/workflows/src/detail/detail-route.drizzle.test.ts +++ /dev/null @@ -1,219 +0,0 @@ -// DB-gated: skipped when no DATABASE_URL is reachable, mirroring -// `packages/agent-directory/test/visible-definitions.drizzle.test.ts`. -// Proves the two things a fixture-only test can't: the grant check runs -// before anything is returned (denied -> 403, never a leaked body), and a -// deployed definition's own rows (asset, workflow_definition + -// approved version) really do read back into the documented detail shape. -import { afterAll, beforeAll, expect, test } from "bun:test"; -import { Hono } from "hono"; -import type { MiddlewareHandler } from "hono"; -import { createDB, runMigrations, dropSchema, schema } from "@intx/db"; -import type { RequireGrant, TenantEnv } from "@intx/hub-api"; - -import { dbTargetFromUrl } from "../../../../scripts/db-setup"; -import { e2eDatabaseUrl } from "../../../../scripts/e2e/harness"; -import { dbGate } from "../../../../scripts/e2e/db-gate"; -import { createWorkflowDetailRoute } from "./detail-route"; - -const databaseUrl = e2eDatabaseUrl(); -const describeIfDb = dbGate(databaseUrl, import.meta.path); - -const SCHEMA = "workflow_catalog_detail_route_test"; - -const TENANT = { - id: "tnt_detail_route", - name: "Acme", - slug: "acme-detail-route", - domain: "acme-detail-route.workbench.test", -}; -const PRINCIPAL = { - id: "prn_detail_route", - tenantId: TENANT.id, - kind: "user" as const, - refId: "prn_detail_route", - status: "active" as const, -}; - -const allowAll: RequireGrant = () => async (_c, next) => { - await next(); -}; -const denyAll: RequireGrant = () => async (c) => - c.json({ error: { code: "forbidden", message: "denied" } }, 403); - -function mount(routes: Hono): Hono { - const asTenant: MiddlewareHandler = async (c, next) => { - c.set("tenant", TENANT as never); - c.set("principal", PRINCIPAL as never); - await next(); - }; - const app = new Hono(); - app.use("*", asTenant); - app.route("/", routes); - return app; -} - -describeIfDb("createWorkflowDetailRoute", () => { - const target = dbTargetFromUrl( - databaseUrl ?? "postgres://localhost:5432/unused", - ); - - beforeAll(async () => { - await runMigrations(target, { schema: SCHEMA }); - }, 30000); - - afterAll(async () => { - await dropSchema(target, { schema: SCHEMA }); - }, 30000); - - test("a denied principal never sees the definition", async () => { - const { db, close } = createDB({ ...target, schema: SCHEMA }); - try { - const app = mount( - createWorkflowDetailRoute({ db, requireGrant: denyAll }), - ); - const res = await app.request("/asset_missing/detail"); - expect(res.status).toBe(403); - } finally { - await close(); - } - }); - - test("an unknown asset id is a 404, not a leaked shape", async () => { - const { db, close } = createDB({ ...target, schema: SCHEMA }); - try { - const app = mount( - createWorkflowDetailRoute({ db, requireGrant: allowAll }), - ); - const res = await app.request("/asset_does_not_exist/detail"); - expect(res.status).toBe(404); - } finally { - await close(); - } - }); - - test("a deployed definition reads back name, lifecycle, steps, and grants", async () => { - const { db, close } = createDB({ ...target, schema: SCHEMA }); - try { - await db.insert(schema.tenant).values(TENANT); - await db.insert(schema.principal).values(PRINCIPAL); - await db.insert(schema.asset).values({ - id: "asset_deployed_wf", - tenantId: TENANT.id, - kind: "workflow", - name: "outreach", - displayName: "Outreach", - }); - await db.insert(schema.workflowDefinition).values({ - id: "wfd_deployed_1", - tenantId: TENANT.id, - assetId: "asset_deployed_wf", - wireHash: "hash_1", - name: "outreach", - description: "Sends outreach messages", - status: "deployed", - currentVersion: "1", - grantRequirements: [ - { resource: "mail:*", action: "send", source: "creator" }, - ], - }); - await db.insert(schema.workflowDefinitionVersion).values({ - id: "wfdv_deployed_1", - definitionId: "wfd_deployed_1", - version: "1", - status: "active", - approvedWireHash: "hash_1", - grantSnapshot: { - perStep: [{ stepId: "s1", grants: ["mail:send"], grantEffects: {} }], - grantRequirements: [ - { resource: "mail:*", action: "send", source: "creator" }, - ], - }, - wireProjection: { - id: "outreach", - triggers: [], - stepOrder: ["s1"], - steps: { s1: { kind: "step", id: "s1" } }, - }, - }); - - const app = mount( - createWorkflowDetailRoute({ db, requireGrant: allowAll }), - ); - const res = await app.request("/asset_deployed_wf/detail"); - expect(res.status).toBe(200); - const body = (await res.json()) as Record; - expect(body).toMatchObject({ - definitionAssetId: "asset_deployed_wf", - assetName: "outreach", - displayName: "Outreach", - lifecycle: "deployed", - currentDefinitionId: "wfd_deployed_1", - }); - expect(body.steps).toEqual([ - { id: "s1", role: "step", toolPins: [], grants: ["mail:send"] }, - ]); - expect(body.grants).toEqual({ - declared: ["mail:*:send"], - approved: ["mail:*:send"], - }); - expect(body.credentialBindings).toEqual([]); - } finally { - await close(); - } - }); - - // CL-7591 (native cutover): the detail read is native-only — no - // Workbench durability store exists anymore, so a deployed - // definition reports `source: null` with the lifecycle derived from - // native rows alone. - test("a deployed definition reports a null source on the native-only read", async () => { - const { db, close } = createDB({ ...target, schema: SCHEMA }); - try { - await db.insert(schema.tenant).values(TENANT).onConflictDoNothing(); - await db.insert(schema.principal).values(PRINCIPAL).onConflictDoNothing(); - await db.insert(schema.asset).values({ - id: "asset_native_cutover_wf", - tenantId: TENANT.id, - kind: "workflow", - name: "native-cutover", - displayName: "Native Cutover", - }); - await db.insert(schema.workflowDefinition).values({ - id: "wfd_native_cutover_1", - tenantId: TENANT.id, - assetId: "asset_native_cutover_wf", - wireHash: "hash_cutover", - name: "native-cutover", - description: "Cut over to native deployments", - status: "deployed", - currentVersion: "1", - grantRequirements: [], - }); - await db.insert(schema.workflowDefinitionVersion).values({ - id: "wfdv_native_cutover_1", - definitionId: "wfd_native_cutover_1", - version: "1", - status: "active", - approvedWireHash: "hash_cutover", - grantSnapshot: { perStep: [], grantRequirements: [] }, - wireProjection: { - id: "native-cutover", - triggers: [], - stepOrder: [], - steps: {}, - }, - }); - - const app = mount( - createWorkflowDetailRoute({ db, requireGrant: allowAll }), - ); - const res = await app.request("/asset_native_cutover_wf/detail"); - expect(res.status).toBe(200); - const body = (await res.json()) as Record; - expect(body.source).toBeNull(); - expect(body.lifecycle).toBe("deployed"); - } finally { - await close(); - } - }); -}); diff --git a/packages/workflows/src/detail/detail-route.ts b/packages/workflows/src/detail/detail-route.ts deleted file mode 100644 index 8e0c4b493b..0000000000 --- a/packages/workflows/src/detail/detail-route.ts +++ /dev/null @@ -1,227 +0,0 @@ -// GET /api/tenants/:tenantId/workflows/definitions/:definitionAssetId/detail -// — the one read a workflow's own detail page (`apps/web/src/pages/ -// workflow-detail-page.tsx`) needs: what it is, whether it can run right -// now, its steps in execution order, and its access surface (declared vs. -// approved grants, credential binding names — never a value). Mounted -// alongside the vendored `createWorkflowDefinitionRoutes` at -// `${TENANT_PREFIX}/workflows/definitions` (`apps/hub/src/index.ts`), not -// inside it: this is a Workbench-owned read composed over native rows, -// not something `vendor/intx/hub-api` knows about. -// -// Every field is read-only and native: `workflow_definition` / -// `workflow_definition_version` (via `@intx/db`'s `loadFrozenGrantSnapshot` -// / `loadFrozenWireProjection`, the same freeze-transaction reads the run -// path uses) and `asset` for the display name. `workflow.json` is never -// read (see docs/workflow-model.md's retirement). Since CL-7591 the read -// is native-only: the deleted Workbench durability store is gone, -// so `source` is always null and the lifecycle derives from native rows -// alone (native deployments/triggers are the deploy-attempt signal). -import { and, desc, eq, inArray } from "drizzle-orm"; -import { Hono } from "hono"; -import type { DB } from "@intx/db"; -import { - loadFrozenGrantSnapshot, - loadFrozenWireProjection, - parseWorkflowDefinitionRow, - schema, -} from "@intx/db"; -import type { RequireGrant, TenantEnv } from "@intx/hub-api"; -import { idResource } from "@intx/hub-api"; - -import { deriveWorkflowLifecycle } from "./definition-lifecycle"; -import type { WorkflowDefinitionDetail } from "./definition-detail"; - -export type CreateWorkflowDetailRouteDeps = { - db: DB["db"]; - requireGrant: RequireGrant; -}; - -/** Best-effort read of one wire step's role/director/model/toolPins — the - * wire projection's `steps` map is deliberately open-schema (see - * `vendor/intx/types/src/wire-workflow.ts`'s `WorkflowStep`: only `kind` - * and `id`/`after` are validated, everything else passes through - * unmodified), so this never throws on a shape it doesn't recognize; an - * absent field just reads empty rather than failing the whole request. */ -function projectStep( - stepId: string, - raw: unknown, - perStepGrants: ReadonlyMap, -): WorkflowDefinitionDetail["steps"][number] { - const step = - raw !== null && typeof raw === "object" - ? (raw as Record) - : {}; - const agent = - step.agent !== null && typeof step.agent === "object" - ? (step.agent as Record) - : {}; - const role = typeof step.kind === "string" ? step.kind : "step"; - const model = typeof agent.model === "string" ? agent.model : null; - const director = - typeof agent.director === "string" - ? agent.director - : typeof agent.name === "string" - ? agent.name - : null; - const toolPins = Array.isArray(agent.toolPins) - ? agent.toolPins - .map((pin) => - typeof pin === "string" - ? pin - : pin !== null && - typeof pin === "object" && - typeof (pin as { name?: unknown }).name === "string" - ? (pin as { name: string }).name - : null, - ) - .filter((name): name is string => name !== null) - : []; - return { - id: stepId, - role, - ...(director !== null ? { director } : {}), - ...(model !== null ? { model } : {}), - toolPins, - grants: [...(perStepGrants.get(stepId) ?? [])], - }; -} - -export function createWorkflowDetailRoute({ - db, - requireGrant, -}: CreateWorkflowDetailRouteDeps): Hono { - const app = new Hono(); - - app.get( - "/:definitionAssetId/detail", - requireGrant( - idResource("workflow-definition", "definitionAssetId"), - "read", - ), - async (c) => { - const tenantCtx = c.get("tenant"); - const definitionAssetId = c.req.param("definitionAssetId"); - - const asset = await db.query.asset.findFirst({ - where: and( - eq(schema.asset.id, definitionAssetId), - eq(schema.asset.tenantId, tenantCtx.id), - ), - }); - if (asset === undefined) { - return c.json( - { error: { code: "not_found", message: "Workflow not found" } }, - 404, - ); - } - - const definitionRows = ( - await db.query.workflowDefinition.findMany({ - where: and( - eq(schema.workflowDefinition.assetId, definitionAssetId), - eq(schema.workflowDefinition.tenantId, tenantCtx.id), - ), - orderBy: desc(schema.workflowDefinition.createdAt), - }) - ).map(parseWorkflowDefinitionRow); - - const versionRows = - definitionRows.length === 0 - ? [] - : await db.query.workflowDefinitionVersion.findMany({ - where: inArray( - schema.workflowDefinitionVersion.definitionId, - definitionRows.map((row) => row.id), - ), - }); - const currentVersionByDefinitionId = new Map( - definitionRows.map((row) => [ - row.id, - versionRows.find( - (v) => - v.definitionId === row.id && v.version === row.currentVersion, - ), - ]), - ); - - const { lifecycle, currentDefinitionId, wireHash } = - deriveWorkflowLifecycle( - definitionRows.map((row) => ({ - id: row.id, - wireHash: row.wireHash, - approvedWireHash: - currentVersionByDefinitionId.get(row.id)?.approvedWireHash ?? - null, - grantSnapshot: - currentVersionByDefinitionId.get(row.id)?.grantSnapshot ?? null, - wireProjection: - currentVersionByDefinitionId.get(row.id)?.wireProjection ?? null, - status: row.status, - createdAt: row.createdAt.toISOString(), - })), - ); - - const current = definitionRows.find( - (row) => row.id === currentDefinitionId, - ); - const grantSnapshot = - current !== undefined - ? await loadFrozenGrantSnapshot(db, current.id) - : null; - const wireProjection = - current !== undefined - ? await loadFrozenWireProjection(db, current.id) - : null; - - const perStepGrants = new Map( - (grantSnapshot?.perStep ?? []).map((step) => [ - step.stepId, - step.grants, - ]), - ); - const steps = - wireProjection === null - ? [] - : wireProjection.stepOrder.map((stepId) => - projectStep(stepId, wireProjection.steps[stepId], perStepGrants), - ); - - const declaredGrants = current?.grantRequirements ?? []; - const approvedGrants = grantSnapshot?.grantRequirements ?? []; - const credentialBindings = current?.credentialBindings ?? []; - const declaredGrantNames = declaredGrants.map( - (g) => `${g.resource}:${g.action}`, - ); - const approvedGrantNames = approvedGrants.map( - (g) => `${g.resource}:${g.action}`, - ); - const credentialBindingNames = credentialBindings.map((b) => b.handle); - - const body: WorkflowDefinitionDetail = { - definitionAssetId, - assetName: asset.name, - displayName: asset.displayName ?? asset.name, - ...(current?.description !== undefined && current.description !== null - ? { description: current.description } - : {}), - lifecycle, - ...(currentDefinitionId !== null ? { currentDefinitionId } : {}), - ...(wireHash !== null ? { wireHash } : {}), - // CL-7591 native cutover: the Workbench durability store is - // deleted, so `source` is always null — the detail surface reads - // native rows only. - source: null, - steps, - grants: { - declared: declaredGrantNames, - approved: approvedGrantNames, - }, - credentialBindings: credentialBindingNames, - }; - - return c.json(body); - }, - ); - - return app; -} diff --git a/packages/workflows/src/detail/index.ts b/packages/workflows/src/detail/index.ts index 94ff6e86a6..93a6ad6812 100644 --- a/packages/workflows/src/detail/index.ts +++ b/packages/workflows/src/detail/index.ts @@ -1,19 +1,10 @@ -// The lifecycle/detail symbols are the browser-safe `../client` barrel's -// canonical export site — re-exported here rather than redeclared, so -// there is exactly one place that owns this list and the two barrels -// cannot drift apart. +// The detail symbols are the browser-safe `../client` barrel's canonical +// export site — re-exported here rather than redeclared, so there is +// exactly one place that owns this list and the two barrels cannot drift +// apart. CL-8160: there is no hub route here any more — see +// `./definition-detail.ts`'s header for why. export { - deriveWorkflowLifecycle, - type DefinitionLifecycleRow, - type WorkflowLifecycle, - type WorkflowLifecycleResult, workflowNotLaunchableReason, workflowDetailPath, WorkflowDefinitionDetail, - WorkflowDetailSource, - WorkflowDetailStep, } from "../client"; -export { - createWorkflowDetailRoute, - type CreateWorkflowDetailRouteDeps, -} from "./detail-route"; diff --git a/packages/workflows/src/index.ts b/packages/workflows/src/index.ts index ee0460c509..031b80d6b5 100644 --- a/packages/workflows/src/index.ts +++ b/packages/workflows/src/index.ts @@ -1,6 +1,8 @@ // @corbits/workflows server entry — everything: the source-tree -// renderer/reader, the definition detail route, and -// agent-authored-workflow authoring. Browser code imports +// renderer/reader and agent-authored-workflow authoring. CL-8160: the +// hub-mounted definition detail and scheduled-workflow routes are gone — +// the client reads stock `@intx/hub-api` routes directly and this package +// only exposes pure, browser-safe shaping. Browser code imports // `@corbits/workflows/client` instead (see ./client.ts). export * from "./source"; export * from "./detail/index"; @@ -75,9 +77,3 @@ export { SCHEDULE_TICK_CONTENT, type DeployedCronDefinition, } from "./schedule/deployed-cron-deployments"; -export { - createScheduledWorkflowRoutes, - RUN_NOW_CONTENT, - type CreateScheduledWorkflowRoutesDeps, - type RunScheduledDefinition, -} from "./schedule/scheduled-route"; diff --git a/packages/workflows/src/schedule/scheduled-route.test.ts b/packages/workflows/src/schedule/scheduled-route.test.ts deleted file mode 100644 index 34b119f3d0..0000000000 --- a/packages/workflows/src/schedule/scheduled-route.test.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { Hono } from "hono"; -import type { MiddlewareHandler } from "hono"; -import type { RequireGrant, TenantEnv } from "@intx/hub-api"; - -import { - createScheduledWorkflowRoutes, - RUN_NOW_CONTENT, -} from "./scheduled-route"; -import type { ScheduledWorkflowDefinition } from "./list-scheduled"; -import type { AvailableCatalogWorkflow } from "./available-catalog"; - -const TENANT = { - id: "tnt_1", - name: "Acme", - slug: "acme", - domain: "acme.workbench.test", -}; -const PRINCIPAL = { - id: "prn_1", - tenantId: TENANT.id, - kind: "user" as const, - refId: "prn_1", - status: "active" as const, -}; - -const AT = new Date("2026-01-01T00:00:00.000Z"); - -const digest: ScheduledWorkflowDefinition = { - definitionId: "wfd_digest", - assetId: "ast_1", - name: "workbench-digest", - tenantId: TENANT.id, - status: "stopped", - cron: "0 9 * * *", - createdAt: AT, - updatedAt: AT, -}; - -const allowAll: RequireGrant = () => async (_c, next) => { - await next(); -}; - -function mount( - runNow: (args: { - tenantId: string; - definitionId: string; - principalId: string; - fromDomain: string; - content: string; - name: string; - 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); - c.set("principal", PRINCIPAL as never); - await next(); - }; - const app = new Hono(); - app.use("*", asTenant); - app.route( - "/", - createScheduledWorkflowRoutes({ - db: {} as never, - requireGrant: overrides.requireGrant ?? allowAll, - runNow, - listScheduled: async () => listed, - ...(overrides.catalogAssetNames !== undefined - ? { catalogAssetNames: overrides.catalogAssetNames } - : {}), - ...(overrides.listAvailable !== undefined - ? { listAvailable: overrides.listAvailable } - : {}), - }), - ); - return app; -} - -describe("createScheduledWorkflowRoutes", () => { - test("GET /scheduled includes a stopped definition", async () => { - const app = mount(async () => ({ runId: "run_x" }), [digest]); - const res = await app.request("/scheduled"); - expect(res.status).toBe(200); - const body = (await res.json()) as { - items: readonly { definitionId: string; status: string }[]; - }; - expect(body.items).toHaveLength(1); - expect(body.items[0]?.definitionId).toBe("wfd_digest"); - expect(body.items[0]?.status).toBe("stopped"); - }); - - test("POST /scheduled/:id/run fires with Run now. content", async () => { - const calls: unknown[] = []; - const app = mount( - async (args) => { - calls.push(args); - return { runId: "run_now_1" }; - }, - [digest], - ); - const res = await app.request("/scheduled/wfd_digest/run", { - method: "POST", - }); - expect(res.status).toBe(200); - expect(await res.json()).toEqual({ runId: "run_now_1" }); - expect(calls).toEqual([ - { - tenantId: TENANT.id, - definitionId: "wfd_digest", - principalId: PRINCIPAL.id, - fromDomain: TENANT.domain, - content: RUN_NOW_CONTENT, - name: digest.name, - assetId: digest.assetId, - }, - ]); - }); - - test("POST /scheduled/:id/run 404s when the definition is not scheduled", async () => { - const app = mount(async () => ({ runId: "run_x" }), [digest]); - const res = await app.request("/scheduled/wfd_missing/run", { - method: "POST", - }); - 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); - }); -}); diff --git a/packages/workflows/src/schedule/scheduled-route.ts b/packages/workflows/src/schedule/scheduled-route.ts deleted file mode 100644 index d9487fa21d..0000000000 --- a/packages/workflows/src/schedule/scheduled-route.ts +++ /dev/null @@ -1,130 +0,0 @@ -// 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"; - -import { - listScheduledWorkflowDefinitions, - type ScheduledWorkflowDefinition, -} from "./list-scheduled"; -import { - listAvailableCatalogWorkflows, - type AvailableCatalogWorkflow, -} from "./available-catalog"; - -export const RUN_NOW_CONTENT = "Run now."; - -export type RunScheduledDefinition = (args: { - tenantId: string; - definitionId: string; - principalId: string; - fromDomain: string; - content: string; - name: string; - assetId: string; -}) => Promise<{ runId: string }>; - -export type CreateScheduledWorkflowRoutesDeps = { - db: DB["db"]; - requireGrant: RequireGrant; - runNow: RunScheduledDefinition; - listScheduled?: ( - db: DB["db"], - tenantId: string, - ) => Promise; - /** Every asset name deployable through the catalog instantiate route — - * `@workbench/onboarding`'s `tenant-seed` `CATALOG_WORKFLOWS`, by asset name. - * Passed in by the caller (`apps/hub`) rather than imported here: - * `@workbench/onboarding` 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({ - db, - 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"), - async (c) => { - const tenant = c.get("tenant"); - const items = await listScheduled(db, tenant.id); - return c.json({ - items: items.map((item) => ({ - definitionId: item.definitionId, - assetId: item.assetId, - name: item.name, - tenantId: item.tenantId, - status: item.status, - cron: item.cron, - createdAt: item.createdAt.toISOString(), - updatedAt: item.updatedAt.toISOString(), - })), - }); - }, - ); - - app.post( - "/scheduled/:definitionId/run", - requireGrant("workflow-run:*", "create"), - async (c) => { - const tenant = c.get("tenant"); - const principal = c.get("principal"); - const definitionId = c.req.param("definitionId"); - const items = await listScheduled(db, tenant.id); - const found = items.find((item) => item.definitionId === definitionId); - if (found === undefined) { - return c.json( - { - error: { - code: "not_found", - message: "Scheduled workflow not found", - }, - }, - 404, - ); - } - const { runId } = await runNow({ - tenantId: tenant.id, - definitionId, - principalId: principal.id, - fromDomain: tenant.domain, - content: RUN_NOW_CONTENT, - name: found.name, - assetId: found.assetId, - }); - return c.json({ runId }); - }, - ); - - return app; -}