From 6ed659648e549e06abaa0023a9d3c9179c769349 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 1 Sep 2026 23:21:16 -0700 Subject: [PATCH 1/8] Enforce authorization on routine targets (CL-7354) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A create or PATCH-carried retarget now must clear the same two gates before it is persisted: resolveLaunchableDefinition must resolve the asset, and the acting principal must be authorized for workflow-definition:/read — the same verb listRoutineTargets already checks per row. A denial is a typed 403 alongside the existing 400/404/409 target envelopes, on both the tenant-session and Myra's workflow-run-authenticated routine surfaces. Also routes a pre-existing catch in postRoutineEnabledNotice through reportError, since this change's diff now touches that line and check:report-error requires it. --- packages/routines/src/routes.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/routines/src/routes.ts b/packages/routines/src/routes.ts index 8567d38a4..04bf85276 100644 --- a/packages/routines/src/routes.ts +++ b/packages/routines/src/routes.ts @@ -889,6 +889,27 @@ export function createRoutineRoutes( const effectiveDefinitionAssetId = body.definitionAssetId ?? existing.definitionAssetId; + if ( + body.definitionAssetId !== undefined && + body.definitionAssetId !== existing.definitionAssetId + ) { + const rejection = await rejectUnlaunchableTarget( + deps, + tenant.id, + principal.id, + body.definitionAssetId, + ); + if (rejection !== undefined) { + return c.json( + makeErrorEnvelope({ + code: rejection.code, + userMessage: rejection.userMessage, + }), + rejection.status, + ); + } + } + if ( body.trigger !== undefined && !(await webhookTriggerValid( From 95848b0761f713c694fcacc47838273c0a6fc3a4 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 1 Sep 2026 23:46:34 -0700 Subject: [PATCH 2/8] Add tests for workflow definition lifecycle and detail route Covers the pure lifecycle derivation (source-only, pending-approval, deployed, superseded, build-failed) and the detail route's authz-first, 404-on-unknown-asset, and happy-path shape, against a real Postgres. --- apps/web/test/workflow-detail-page.test.tsx | 83 +++++++++ .../src/definition-lifecycle.test.ts | 78 ++++++++ .../src/definition-lifecycle.ts | 85 +++++++++ .../test/detail-route.drizzle.test.ts | 171 ++++++++++++++++++ 4 files changed, 417 insertions(+) create mode 100644 apps/web/test/workflow-detail-page.test.tsx create mode 100644 packages/workflow-catalog/src/definition-lifecycle.test.ts create mode 100644 packages/workflow-catalog/src/definition-lifecycle.ts create mode 100644 packages/workflow-catalog/test/detail-route.drizzle.test.ts diff --git a/apps/web/test/workflow-detail-page.test.tsx b/apps/web/test/workflow-detail-page.test.tsx new file mode 100644 index 000000000..86ef08765 --- /dev/null +++ b/apps/web/test/workflow-detail-page.test.tsx @@ -0,0 +1,83 @@ +// `/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. +import { describe, expect, test } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; + +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", + 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"], +}; + +describe("WorkflowDetailPage", () => { + test("a deployed workflow shows no not-launchable strip and renders its steps", () => { + 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"); + }); + + test("a pending-approval workflow shows the why-not-launchable strip", () => { + const detail: WorkflowDefinitionDetailT = { + ...baseDetail, + lifecycle: "pending-approval", + source: null, + }; + const html = renderToStaticMarkup(); + expect(html).toContain("Pending approval"); + expect(html).toContain("waiting on human approval"); + }); + + 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", () => { + const detail: WorkflowDefinitionDetailT = { + definitionAssetId: "asset_new", + assetName: "new-workflow", + displayName: "New workflow", + lifecycle: "source-only", + steps: [], + grants: { declared: [], approved: [] }, + credentialBindings: [], + }; + const html = renderToStaticMarkup(); + expect(html).toContain("Source only"); + expect(html).toContain("No approved steps yet"); + expect(html).toContain("deploy it to make it launchable"); + }); +}); diff --git a/packages/workflow-catalog/src/definition-lifecycle.test.ts b/packages/workflow-catalog/src/definition-lifecycle.test.ts new file mode 100644 index 000000000..b49ba800e --- /dev/null +++ b/packages/workflow-catalog/src/definition-lifecycle.test.ts @@ -0,0 +1,78 @@ +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", + status: "deployed", + createdAt: "2026-01-01T00:00:00.000Z", + ...patch, + }; +} + +describe("deriveWorkflowLifecycle", () => { + test("no definition rows and no deploy attempt is source-only", () => { + const result = deriveWorkflowLifecycle([], false); + expect(result).toEqual({ + lifecycle: "source-only", + currentDefinitionId: null, + wireHash: null, + }); + }); + + test("no definition rows but a deploy attempt on record is build-failed", () => { + const result = deriveWorkflowLifecycle([], true); + expect(result).toEqual({ + lifecycle: "build-failed", + currentDefinitionId: null, + wireHash: null, + }); + }); + + test("newest row lacking an approved hash is pending-approval", () => { + const result = deriveWorkflowLifecycle( + [row({ id: "wfd_2", approvedWireHash: null })], + true, + ); + 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" })], true); + 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" })], + true, + ); + 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], true); + expect(result.lifecycle).toBe("deployed"); + expect(result.currentDefinitionId).toBe("wfd_new"); + }); +}); diff --git a/packages/workflow-catalog/src/definition-lifecycle.ts b/packages/workflow-catalog/src/definition-lifecycle.ts new file mode 100644 index 000000000..2b5ef0071 --- /dev/null +++ b/packages/workflow-catalog/src/definition-lifecycle.ts @@ -0,0 +1,85 @@ +// 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, folded against the one +// Workbench-owned signal native rows don't carry: whether a deploy was ever +// attempted at all (`@corbits/workflow-deploy-source`'s per-anchor-run +// record). Kept in its own module, with no DB import, so the four states +// below are covered by a plain unit test rather than a route fixture. +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; + 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) and whether a deploy was ever attempted for it. + * + * - No rows, no deploy attempt on record → `source-only`: nothing has ever + * tried to run this asset. + * - No rows, a deploy attempt IS on record → `build-failed`: a deploy was + * asked for and never produced a definition row at all. + * - Rows exist: the newest one decides. Unapproved (`approvedWireHash` + * still null) → `pending-approval`. Approved and `status: "deployed"` → + * `deployed`. Approved but rolled back / replaced (`status: "stopped"`) + * → `superseded`. + */ +export function deriveWorkflowLifecycle( + rows: readonly DefinitionLifecycleRow[], + hasDeployAttempt: boolean, +): WorkflowLifecycleResult { + if (rows.length === 0) { + return { + lifecycle: hasDeployAttempt ? "build-failed" : "source-only", + currentDefinitionId: null, + wireHash: null, + }; + } + + const newest = [...rows].sort((a, b) => + a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0, + )[0]; + if (newest === undefined) { + throw new Error("deriveWorkflowLifecycle: unreachable — rows non-empty"); + } + + if (newest.approvedWireHash === null) { + 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/workflow-catalog/test/detail-route.drizzle.test.ts b/packages/workflow-catalog/test/detail-route.drizzle.test.ts new file mode 100644 index 000000000..70162a418 --- /dev/null +++ b/packages/workflow-catalog/test/detail-route.drizzle.test.ts @@ -0,0 +1,171 @@ +// 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 { applyWorkflowDeploySourceMigrations } from "@corbits/workflow-deploy-source/migrations"; + +import { dbTargetFromUrl } from "../../../scripts/db-setup"; +import { e2eDatabaseUrl } from "../../../scripts/e2e/harness"; +import { dbGate } from "../../../scripts/e2e/db-gate"; +import { createWorkflowDetailRoute } from "../src/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 }); + await applyWorkflowDeploySourceMigrations( + databaseUrl ?? "postgres://localhost:5432/unused", + ); + }, 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(); + } + }); +}); From 23d731e175d74d673f8ffc7a0fdb52175a998b48 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 1 Sep 2026 23:46:44 -0700 Subject: [PATCH 3/8] Add a workflow detail page exposing definition behavior and access Adds GET .../workflows/definitions/:definitionAssetId/detail (@corbits/workflow-catalog), authorized per-definition and 404 on an unknown or cross-tenant asset, answering lifecycle, steps, and declared vs. approved grants plus credential binding names (never a value) from native workflow_definition/workflow_definition_version rows and @corbits/workflow-deploy-source. Adds the read-only /workflows/:id page in apps/web with a header, step table, access section, and a why-not-launchable strip for anything short of deployed. --- apps/hub/src/index.ts | 19 ++ apps/web/src/pages/workflow-detail-page.tsx | 287 ++++++++++++++++++ apps/web/src/path-ids.ts | 8 + apps/web/src/routes.tsx | 23 ++ apps/web/src/workflow-detail-api.ts | 68 +++++ bun.lock | 7 +- packages/workflow-catalog/package.json | 8 +- .../workflow-catalog/src/definition-detail.ts | 66 ++++ packages/workflow-catalog/src/detail-route.ts | 243 +++++++++++++++ packages/workflow-catalog/src/index.ts | 18 ++ 10 files changed, 741 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/pages/workflow-detail-page.tsx create mode 100644 apps/web/src/workflow-detail-api.ts create mode 100644 packages/workflow-catalog/src/definition-detail.ts create mode 100644 packages/workflow-catalog/src/detail-route.ts diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index cba163978..958a93b10 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -195,6 +195,7 @@ import { } from "@corbits/workflow-catalog"; import { createConnectGithubRoutes } from "@corbits/workflow-catalog/connect-github-routes"; import { createTemplateBlockRoutes } from "@corbits/workflow-catalog/template-block-routes"; +import { createWorkflowDetailRoute } from "@corbits/workflow-catalog/detail-route"; import { renderWorkflowSourceTree } from "@corbits/workflow-source"; import { freezeInertWorkflowDefinition } from "@corbits/workflow-freeze"; import { @@ -1721,6 +1722,24 @@ export async function createHub(config: HubConfig) { db, }), ); + // 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 Workbench-owned read composed over native + // rows plus `@corbits/workflow-deploy-source`, so it lives in + // `@corbits/workflow-catalog`, not the vendored tree. + app.route( + `${TENANT_PREFIX}/workflows/definitions`, + createWorkflowDetailRoute({ + db, + requireGrant: createRequireGrant({ + grantStore: chatGrantStore, + conditionRegistry: chatConditionRegistry, + }), + }), + ); // Run key identity diagnostics: read side of the append-only // `run_key_history` table above — per-run key lifecycle, divergence // against `workflow_run.public_key`, and tenant-wide counts by diff --git a/apps/web/src/pages/workflow-detail-page.tsx b/apps/web/src/pages/workflow-detail-page.tsx new file mode 100644 index 000000000..aca672f62 --- /dev/null +++ b/apps/web/src/pages/workflow-detail-page.tsx @@ -0,0 +1,287 @@ +// `/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/workflow-catalog`'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"; +import type { BadgeTone } from "@corbits/react-ui"; +import { Clock, FlowArrow } from "@corbits/icons"; + +import { useBench } from "../bench-context"; +import { WORKFLOWS_PATH_PREFIX, workflowDefinitionAssetIdFromPath } from "../path-ids"; +import { StageTopBar } from "../shell/stage-top-bar"; +import { tenantKeys } from "../query-client"; +import { useTenantQuery } from "../routines-api"; +import { + getWorkflowDefinitionDetail, + workflowNotLaunchableReason, + type WorkflowDefinitionDetailT, +} from "../workflow-detail-api"; + +const LIFECYCLE_LABEL: Readonly< + Record +> = { + "source-only": "Source only", + "pending-approval": "Pending approval", + deployed: "Deployed", + superseded: "Superseded", + "build-failed": "Build failed", +}; + +const LIFECYCLE_TONE: Readonly< + Record +> = { + "source-only": "neutral", + "pending-approval": "warning", + deployed: "success", + superseded: "neutral", + "build-failed": "danger", +}; + +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. */ +function WorkflowDetailHeader({ + detail, +}: { + readonly detail: WorkflowDefinitionDetailT; +}) { + const sha = detail.source !== undefined && detail.source !== null + ? shortSha(detail.source.commitSha) + : ""; + return ( +
+
+ + {LIFECYCLE_LABEL[detail.lifecycle]} + + {sha !== "" ? ( + + {sha} + + ) : null} +
+ {detail.description !== undefined && detail.description !== null ? ( +

+ {detail.description} +

+ ) : null} +
+ ); +} + +/** 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. */ +export function NotLaunchableStrip({ + lifecycle, +}: { + readonly lifecycle: WorkflowDefinitionDetailT["lifecycle"]; +}) { + const reason = workflowNotLaunchableReason(lifecycle); + if (reason === null) return null; + return ( +
+ {reason} +
+ ); +} + +/** 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({ + detail, +}: { + readonly detail: WorkflowDefinitionDetailT; +}) { + return ( +
+ + +
+ + + + +
+
+
+ ); +} + +/** A workflow-shaped screen with nothing to show yet. */ +function WorkflowNotice({ + title, + description, +}: { + readonly title: string; + readonly description: string; +}) { + return ( +
+ + + } title={title} description={description} /> + +
+ ); +} + +export function WorkflowDetailRoute({ path }: { readonly path: string }) { + const { selectedTenantId } = useBench(); + const definitionAssetId = workflowDefinitionAssetIdFromPath(path); + const tenantId = selectedTenantId ?? ""; + const enabled = definitionAssetId !== null && selectedTenantId !== null; + + const detailQuery = useTenantQuery( + [...tenantKeys.definitions(tenantId), "detail", definitionAssetId ?? ""], + enabled, + () => getWorkflowDefinitionDetail(tenantId, definitionAssetId ?? ""), + ); + + if (definitionAssetId === null) { + return ( + + ); + } + + if (detailQuery.kind === "loading" || detailQuery.kind === "unauthenticated") { + return ( +
+ + + } title="Loading workflow…" /> + +
+ ); + } + + if (detailQuery.kind === "error") { + return ( + + ); + } + + return ; +} diff --git a/apps/web/src/path-ids.ts b/apps/web/src/path-ids.ts index 30c080b7c..724ed06a7 100644 --- a/apps/web/src/path-ids.ts +++ b/apps/web/src/path-ids.ts @@ -16,6 +16,7 @@ export const SKILLS_PATH_PREFIX = "/skills"; export const FILES_PATH_PREFIX = "/files"; export const PLUGINS_PATH_PREFIX = "/plugins"; export const ROUTINES_PATH_PREFIX = "/routines"; +export const WORKFLOWS_PATH_PREFIX = "/workflows"; export const INSIGHTS_PATH_PREFIX = "/insights"; export const INSIGHTS_RUNS_PATH = `${INSIGHTS_PATH_PREFIX}/runs`; export const EVALS_PATH_PREFIX = "/evals"; @@ -68,6 +69,13 @@ export function routineSegmentFromPath(path: string): string | null { return entityIdFromTopLevelPath(path, ROUTINES_PATH_PREFIX); } +/** The definition asset 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. */ +export function workflowDefinitionAssetIdFromPath(path: string): string | null { + return entityIdFromTopLevelPath(path, WORKFLOWS_PATH_PREFIX); +} + /** Extract a settings section id from `/settings/:id` or `/settings/:id/…` * — only the first path segment, so a section with its own sub-selection * (e.g. `/settings/agents/:definitionId`) still resolves to its section id. */ diff --git a/apps/web/src/routes.tsx b/apps/web/src/routes.tsx index f15d43514..31b643b97 100644 --- a/apps/web/src/routes.tsx +++ b/apps/web/src/routes.tsx @@ -40,6 +40,7 @@ import { EVALS_PATH_PREFIX, SKILLS_PATH_PREFIX, ROUTINES_PATH_PREFIX, + WORKFLOWS_PATH_PREFIX, detailSlugFromPath, routineSegmentFromPath, } from "./path-ids"; @@ -100,6 +101,9 @@ const SkillDetailRoute = lazy(async () => ({ const RoutineDetailRoute = lazy(async () => ({ default: (await import("./pages/routine-detail-page")).RoutineDetailRoute, })); +const WorkflowDetailRoute = lazy(async () => ({ + default: (await import("./pages/workflow-detail-page")).WorkflowDetailRoute, +})); /** The signed-out screen (CL-6369) — a real route, not a conditional swap: * any unauthenticated request for another path bounces here with `?next=` @@ -159,6 +163,14 @@ export const SKILL_DETAIL_PATH = `${SKILLS_PATH_PREFIX}${SLUG_SEGMENT}`; const ROUTINE_SEGMENT = "/:routine"; export const ROUTINE_DETAIL_PATH = `${ROUTINES_PATH_PREFIX}${ROUTINE_SEGMENT}`; +/** + * A workflow definition has no slug either — same reasoning as a routine + * above — so `/workflows/:id` claims any single segment under + * `/workflows`, addressed by the definition's own opaque asset id. + */ +const WORKFLOW_SEGMENT = "/:workflow"; +export const WORKFLOW_DETAIL_PATH = `${WORKFLOWS_PATH_PREFIX}${WORKFLOW_SEGMENT}`; + function slugForDetailRoute(routePath: string, path: string): Slug | null { return detailSlugFromPath(path, routePath.slice(0, -SLUG_SEGMENT.length)); } @@ -219,6 +231,9 @@ export function matchesRoute(routePath: string, path: string): boolean { const segment = routineSegmentFromPath(path); return segment !== null && !segment.includes("/"); } + if (routePath === WORKFLOW_DETAIL_PATH) { + return path.startsWith(`${WORKFLOWS_PATH_PREFIX}/`) && !path.slice(WORKFLOWS_PATH_PREFIX.length + 1).includes("/"); + } if (routePath.endsWith(SLUG_SEGMENT)) { return slugForDetailRoute(routePath, path) !== null; } @@ -312,6 +327,14 @@ export const APP_ROUTES: readonly AppRoute[] = [ ), }, + { + // A workflow definition's own page (CL-7371) — no roster of its own + // yet, only reached by a deep link (e.g. from a routine's target). + path: WORKFLOW_DETAIL_PATH, + label: "Workflow", + icon: , + render: (path: string) => , + }, { // The renamed, remounted Library page (CL-6353) — "Library" stays out // of user-facing copy, but the underlying artifact machinery diff --git a/apps/web/src/workflow-detail-api.ts b/apps/web/src/workflow-detail-api.ts new file mode 100644 index 000000000..794608c37 --- /dev/null +++ b/apps/web/src/workflow-detail-api.ts @@ -0,0 +1,68 @@ +// The workflow detail page's one seam to the hub's read route +// (`@corbits/workflow-catalog/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 +// `@corbits/workflow-catalog`, browser-safe like `routines-api.ts`'s own +// definitions listing — this file is fetch composition only. +import { type } from "arktype"; +import type { ArkErrors } from "arktype"; +import { ApiQueryError, UnauthenticatedError } from "@corbits/api-query"; +import { WorkflowDefinitionDetail } from "@corbits/workflow-catalog"; +import type { WorkflowDefinitionDetail as WorkflowDefinitionDetailT } from "@corbits/workflow-catalog"; + +export type { WorkflowDefinitionDetail as WorkflowDefinitionDetailT } from "@corbits/workflow-catalog"; +export { workflowDetailPath, workflowNotLaunchableReason } from "@corbits/workflow-catalog"; + +type Validator = (data: unknown) => T | ArkErrors; + +async function request(path: string, schema: Validator): Promise { + let response: Response; + try { + response = await fetch(path, { + headers: { "content-type": "application/json" }, + }); + } catch (cause) { + throw new ApiQueryError( + cause instanceof Error ? cause.message : String(cause), + undefined, + path, + ); + } + if (response.status === 401) { + throw new UnauthenticatedError(); + } + if (!response.ok) { + const detail = await response + .json() + .then( + (body: { error?: { userMessage?: string; message?: string } }) => + body.error?.userMessage ?? body.error?.message ?? "", + ) + .catch(() => ""); + throw new ApiQueryError( + detail === "" ? `The server answered ${response.status}.` : detail, + response.status, + path, + ); + } + 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; +} + +export function getWorkflowDefinitionDetail( + tenantId: string, + definitionAssetId: string, +): Promise { + return request( + `/api/tenants/${tenantId}/workflows/definitions/${encodeURIComponent(definitionAssetId)}/detail`, + WorkflowDefinitionDetail, + ); +} diff --git a/bun.lock b/bun.lock index 596fab809..03583e08e 100644 --- a/bun.lock +++ b/bun.lock @@ -1528,7 +1528,7 @@ }, "packages/workflow-authoring-tools": { "name": "@corbits/workflow-authoring-tools", - "version": "0.0.2", + "version": "0.0.3", "dependencies": { "@intx/agent": "workspace:*", "@intx/types": "workspace:*", @@ -1550,19 +1550,20 @@ "@corbits/jimmy-agent": "workspace:*", "@corbits/scout-agent": "workspace:*", "@corbits/webhook-triggers": "workspace:*", + "@corbits/workflow-deploy-source": "workspace:*", + "@intx/db": "workspace:*", "@intx/hub-api": "workspace:*", "@workbench/hub-client": "workspace:*", "arktype": "catalog:", + "drizzle-orm": "catalog:", "hono": "^4.11.9", "postgres": "catalog:", }, "devDependencies": { "@corbits/workflow-freeze": "workspace:*", "@intx/crypto": "0.3.0", - "@intx/db": "workspace:*", "@types/bun": "catalog:", "@workbench/connections": "workspace:*", - "drizzle-orm": "catalog:", "typescript": "catalog:", }, }, diff --git a/packages/workflow-catalog/package.json b/packages/workflow-catalog/package.json index b307303ce..f78c10da0 100644 --- a/packages/workflow-catalog/package.json +++ b/packages/workflow-catalog/package.json @@ -8,7 +8,8 @@ "exports": { ".": "./src/index.ts", "./connect-github-routes": "./src/connect-github-routes.ts", - "./template-block-routes": "./src/template-block-routes.ts" + "./template-block-routes": "./src/template-block-routes.ts", + "./detail-route": "./src/detail-route.ts" }, "scripts": { "typecheck": "tsc --noEmit", @@ -22,19 +23,20 @@ "@corbits/jimmy-agent": "workspace:*", "@corbits/scout-agent": "workspace:*", "@corbits/webhook-triggers": "workspace:*", + "@corbits/workflow-deploy-source": "workspace:*", + "@intx/db": "workspace:*", "@intx/hub-api": "workspace:*", "@workbench/hub-client": "workspace:*", "arktype": "catalog:", + "drizzle-orm": "catalog:", "hono": "^4.11.9", "postgres": "catalog:" }, "devDependencies": { "@corbits/workflow-freeze": "workspace:*", "@intx/crypto": "0.3.0", - "@intx/db": "workspace:*", "@types/bun": "catalog:", "@workbench/connections": "workspace:*", - "drizzle-orm": "catalog:", "typescript": "catalog:" } } diff --git a/packages/workflow-catalog/src/definition-detail.ts b/packages/workflow-catalog/src/definition-detail.ts new file mode 100644 index 000000000..468cc32f4 --- /dev/null +++ b/packages/workflow-catalog/src/definition-detail.ts @@ -0,0 +1,66 @@ +// 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/routines/client` +// makes, so `apps/web` can import it directly. +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", + "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[]", +}); +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. */ +export function workflowNotLaunchableReason( + lifecycle: WorkflowDefinitionDetail["lifecycle"], +): 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."; + } +} + +export function workflowDetailPath(definitionAssetId: string): string { + return `/workflows/${encodeURIComponent(definitionAssetId)}`; +} diff --git a/packages/workflow-catalog/src/detail-route.ts b/packages/workflow-catalog/src/detail-route.ts new file mode 100644 index 000000000..131e898af --- /dev/null +++ b/packages/workflow-catalog/src/detail-route.ts @@ -0,0 +1,243 @@ +// 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 plus +// `@corbits/workflow-deploy-source`'s deploy-attempt record, 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). +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 type { WorkflowDeploySourceDb } from "@corbits/workflow-deploy-source"; +import { workflowDeploySource } from "@corbits/workflow-deploy-source"; + +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) ?? [])], + }; +} + +/** The `WorkflowDefinitionAssetSource` `package.commitSha`, when the deploy + * source names a source-tree package at a pinned commit — `""` for every + * other source shape (a registry pin, or a tarball package, neither of + * which carries a commit sha). Read defensively: `source` is a jsonb + * column typed as `WorkflowDefinitionSource` only by convention, not + * re-validated here. */ +function commitShaFromSource(source: unknown): string { + if (source === null || typeof source !== "object") return ""; + const pkg = (source as Record).package; + if (pkg === null || typeof pkg !== "object") return ""; + const commitSha = (pkg as Record).commitSha; + return typeof commitSha === "string" ? commitSha : ""; +} + +function originFromSource(source: unknown): string { + if (source === null || typeof source !== "object") return "unknown"; + const kind = (source as Record).kind; + return typeof kind === "string" ? kind : "unknown"; +} + +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 deploySourceRows = await ( + db as unknown as WorkflowDeploySourceDb> + ) + .select() + .from(workflowDeploySource) + .where( + and( + eq(workflowDeploySource.definitionAssetId, definitionAssetId), + eq(workflowDeploySource.tenantId, tenantCtx.id), + ), + ) + .orderBy(desc(workflowDeploySource.recordedAt)) + .limit(1); + const deploySource = deploySourceRows[0] ?? null; + + const { lifecycle, currentDefinitionId, wireHash } = deriveWorkflowLifecycle( + definitionRows.map((row) => ({ + id: row.id, + wireHash: row.wireHash, + approvedWireHash: + currentVersionByDefinitionId.get(row.id)?.approvedWireHash ?? null, + status: row.status, + createdAt: row.createdAt.toISOString(), + })), + deploySource !== null, + ); + + 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 } : {}), + ...(deploySource !== null + ? { + source: { + commitSha: commitShaFromSource(deploySource.source), + entry: deploySource.entry, + origin: originFromSource(deploySource.source), + }, + } + : { source: null }), + steps, + grants: { + declared: declaredGrantNames, + approved: approvedGrantNames, + }, + credentialBindings: credentialBindingNames, + }; + + return c.json(body); + }, + ); + + return app; +} diff --git a/packages/workflow-catalog/src/index.ts b/packages/workflow-catalog/src/index.ts index 17f484d50..2611961b2 100644 --- a/packages/workflow-catalog/src/index.ts +++ b/packages/workflow-catalog/src/index.ts @@ -49,6 +49,24 @@ export { type ConnectGithubSetupPorts, type StartReviewingReposResult, } from "./connect-github-setup"; +export { + deriveWorkflowLifecycle, + type DefinitionLifecycleRow, + type WorkflowLifecycle, + type WorkflowLifecycleResult, +} from "./definition-lifecycle"; +export { + WorkflowDefinitionDetail, + WorkflowDetailSource, + WorkflowDetailStep, + workflowDetailPath, + workflowNotLaunchableReason, +} from "./definition-detail"; +export type { + WorkflowDefinitionDetail as WorkflowDefinitionDetailT, + WorkflowDetailSource as WorkflowDetailSourceT, + WorkflowDetailStep as WorkflowDetailStepT, +} from "./definition-detail"; /** * One named field a mail trigger reads by name — the create-time UI's only From 0630795ee2f68ebbdb653f5586d82bec26fc64ff Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 1 Sep 2026 23:46:47 -0700 Subject: [PATCH 4/8] Update docs: workflow definition detail page --- docs/workflow-detail.md | 52 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 docs/workflow-detail.md diff --git a/docs/workflow-detail.md b/docs/workflow-detail.md new file mode 100644 index 000000000..e0456d592 --- /dev/null +++ b/docs/workflow-detail.md @@ -0,0 +1,52 @@ +# Workflow definition detail (CL-7371) + +A workflow definition's own page: `GET +/api/tenants/:tenantId/workflows/definitions/:definitionAssetId/detail` +(`packages/workflow-catalog/src/detail-route.ts`), read at `/workflows/ +:definitionAssetId` (`apps/web/src/pages/workflow-detail-page.tsx`). First +useful version — read-only, no editing surface here. + +## What it answers + +- **Lifecycle**: `source-only`, `pending-approval`, `deployed`, + `superseded`, or `build-failed` — derived by the pure + `deriveWorkflowLifecycle` (`packages/workflow-catalog/src/ + definition-lifecycle.ts`) from the asset's newest `workflow_definition` + row plus whether `@corbits/workflow-deploy-source` ever recorded a + deploy attempt for it. No new Postgres column: everything it reads is + native or already Workbench-owned. +- **Steps**: read from the frozen `wire_projection` (`@intx/db`'s + `loadFrozenWireProjection`) in `stepOrder`, each carrying its role + (`kind`), best-effort director/model/tool pins (the wire step schema is + deliberately open past `kind`/`id`/`after` — see + `vendor/intx/types/src/wire-workflow.ts` — so these read defensively and + are simply absent rather than erroring on an unrecognized shape), and the + grants the deploy-time capability walk froze onto that step + (`grantSnapshot.perStep`). +- **Access**: declared grants (the source's own `grant_requirements`) next + to approved grants (the last freeze's `grant_snapshot.grantRequirements`) + — a person can see the gap between what a workflow asks for and what was + actually approved. Credential binding **names** only + (`workflow_definition.credential_bindings[].handle`); no value is ever + read or returned. +- **Source**: the deploying commit sha, entry module, and origin kind, from + `@corbits/workflow-deploy-source`'s per-asset deploy record — `null` when + no deploy was ever attempted. + +## Authorization + +`requireGrant(idResource("workflow-definition", "definitionAssetId"), +"read")` runs before any row is read. An asset absent or owned by another +tenant reads as 404 from the route body itself (same convention as the +vendored `.../:definitionId/versions` route), never a 403 that would +confirm the id exists. + +## What's deliberately left out of this first version + +- No cross-links from the routine target picker or an agents roster row — + neither exists yet on the branch this shipped against. Wiring those in + is a follow-up once they land. +- `superseded` is read off `workflow_definition.status: "stopped"` on the + newest row for the asset, not a full multi-row version history; a + definition's older wire hashes are not separately browsable from this + page yet. From 711f5d68773e4325b19a84d006d359b60e1305b9 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 2 Sep 2026 01:50:39 -0700 Subject: [PATCH 5/8] Address review findings (CL-7371) --- apps/web/src/routes.tsx | 4 +++- apps/web/test/routes.test.tsx | 15 +++++++++++++++ .../workflow-catalog/src/definition-lifecycle.ts | 12 +++++++++--- 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/apps/web/src/routes.tsx b/apps/web/src/routes.tsx index 31b643b97..c85c09a68 100644 --- a/apps/web/src/routes.tsx +++ b/apps/web/src/routes.tsx @@ -43,6 +43,7 @@ import { WORKFLOWS_PATH_PREFIX, detailSlugFromPath, routineSegmentFromPath, + workflowDefinitionAssetIdFromPath, } from "./path-ids"; import { WORKBENCH_PATH_PREFIX, isWorkbenchPath } from "./workbench-path"; import { @@ -232,7 +233,8 @@ export function matchesRoute(routePath: string, path: string): boolean { return segment !== null && !segment.includes("/"); } if (routePath === WORKFLOW_DETAIL_PATH) { - return path.startsWith(`${WORKFLOWS_PATH_PREFIX}/`) && !path.slice(WORKFLOWS_PATH_PREFIX.length + 1).includes("/"); + const assetId = workflowDefinitionAssetIdFromPath(path); + return assetId !== null && !assetId.includes("/"); } if (routePath.endsWith(SLUG_SEGMENT)) { return slugForDetailRoute(routePath, path) !== null; diff --git a/apps/web/test/routes.test.tsx b/apps/web/test/routes.test.tsx index 1139a3a3a..eb255f8ea 100644 --- a/apps/web/test/routes.test.tsx +++ b/apps/web/test/routes.test.tsx @@ -19,6 +19,7 @@ import { NAV_ROUTES, ROUTINE_DETAIL_PATH, SKILL_DETAIL_PATH, + WORKFLOW_DETAIL_PATH, } from "../src/routes"; import type { SessionState } from "../src/session"; @@ -270,6 +271,20 @@ describe("route table", () => { expect(matchesRoute(AGENT_DETAIL_PATH, "/agents/%2Ftriage-bot")).toBe( false, ); + // A workflow detail path reuses workflowDefinitionAssetIdFromPath + // (CL-7371 review): a malformed percent-escape segment must never + // match the route at all, not match and then fail to resolve an id. + expect(matchesRoute(WORKFLOW_DETAIL_PATH, "/workflows/%E0%A4%A")).toBe( + false, + ); + }); + + test("workflow detail path matches a single opaque segment only", () => { + expect(matchesRoute(WORKFLOW_DETAIL_PATH, "/workflows/wfd_1")).toBe(true); + expect(matchesRoute(WORKFLOW_DETAIL_PATH, "/workflows")).toBe(false); + expect(matchesRoute(WORKFLOW_DETAIL_PATH, "/workflows/wfd_1/runs")).toBe( + false, + ); }); test("a detail path keeps its roster's sidebar row lit", () => { diff --git a/packages/workflow-catalog/src/definition-lifecycle.ts b/packages/workflow-catalog/src/definition-lifecycle.ts index 2b5ef0071..81e011b27 100644 --- a/packages/workflow-catalog/src/definition-lifecycle.ts +++ b/packages/workflow-catalog/src/definition-lifecycle.ts @@ -62,9 +62,15 @@ export function deriveWorkflowLifecycle( }; } - const newest = [...rows].sort((a, b) => - a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0, - )[0]; + // 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"); } From 5e82f80e7c7d4c4ec2684a6bec96f385b1f2ee39 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 2 Sep 2026 02:33:43 -0700 Subject: [PATCH 6/8] Fix CI after review pass (CL-7371) --- apps/web/src/pages/workflow-detail-page.tsx | 32 +++++++--- apps/web/src/workflow-detail-api.ts | 5 +- apps/web/test/workflow-detail-page.test.tsx | 18 ++++-- docs/workflow-detail.md | 2 +- packages/workflow-catalog/src/detail-route.ts | 61 +++++++++++++------ .../test/detail-route.drizzle.test.ts | 5 +- 6 files changed, 85 insertions(+), 38 deletions(-) diff --git a/apps/web/src/pages/workflow-detail-page.tsx b/apps/web/src/pages/workflow-detail-page.tsx index aca672f62..9df49408b 100644 --- a/apps/web/src/pages/workflow-detail-page.tsx +++ b/apps/web/src/pages/workflow-detail-page.tsx @@ -24,7 +24,10 @@ import type { BadgeTone } from "@corbits/react-ui"; import { Clock, FlowArrow } from "@corbits/icons"; import { useBench } from "../bench-context"; -import { WORKFLOWS_PATH_PREFIX, workflowDefinitionAssetIdFromPath } from "../path-ids"; +import { + WORKFLOWS_PATH_PREFIX, + workflowDefinitionAssetIdFromPath, +} from "../path-ids"; import { StageTopBar } from "../shell/stage-top-bar"; import { tenantKeys } from "../query-client"; import { useTenantQuery } from "../routines-api"; @@ -65,9 +68,10 @@ function WorkflowDetailHeader({ }: { readonly detail: WorkflowDefinitionDetailT; }) { - const sha = detail.source !== undefined && detail.source !== null - ? shortSha(detail.source.commitSha) - : ""; + const sha = + detail.source !== undefined && detail.source !== null + ? shortSha(detail.source.commitSha) + : ""; return (
@@ -213,7 +217,10 @@ export function WorkflowDetailPage({ return (
@@ -239,7 +246,11 @@ function WorkflowNotice({
- } title={title} description={description} /> + } + title={title} + description={description} + />
); @@ -266,10 +277,15 @@ export function WorkflowDetailRoute({ path }: { readonly path: string }) { ); } - if (detailQuery.kind === "loading" || detailQuery.kind === "unauthenticated") { + if ( + detailQuery.kind === "loading" || + detailQuery.kind === "unauthenticated" + ) { return (
- + } title="Loading workflow…" /> diff --git a/apps/web/src/workflow-detail-api.ts b/apps/web/src/workflow-detail-api.ts index 794608c37..19f51cf9a 100644 --- a/apps/web/src/workflow-detail-api.ts +++ b/apps/web/src/workflow-detail-api.ts @@ -11,7 +11,10 @@ import { WorkflowDefinitionDetail } from "@corbits/workflow-catalog"; import type { WorkflowDefinitionDetail as WorkflowDefinitionDetailT } from "@corbits/workflow-catalog"; export type { WorkflowDefinitionDetail as WorkflowDefinitionDetailT } from "@corbits/workflow-catalog"; -export { workflowDetailPath, workflowNotLaunchableReason } from "@corbits/workflow-catalog"; +export { + workflowDetailPath, + workflowNotLaunchableReason, +} from "@corbits/workflow-catalog"; type Validator = (data: unknown) => T | ArkErrors; diff --git a/apps/web/test/workflow-detail-page.test.tsx b/apps/web/test/workflow-detail-page.test.tsx index 86ef08765..784e79fa0 100644 --- a/apps/web/test/workflow-detail-page.test.tsx +++ b/apps/web/test/workflow-detail-page.test.tsx @@ -17,7 +17,11 @@ const baseDetail: WorkflowDefinitionDetailT = { lifecycle: "deployed", currentDefinitionId: "wfd_1", wireHash: "hash_1", - source: { commitSha: "abcdef1234567890", entry: "src/index.ts", origin: "asset" }, + source: { + commitSha: "abcdef1234567890", + entry: "src/index.ts", + origin: "asset", + }, steps: [ { id: "s1", @@ -37,14 +41,18 @@ const baseDetail: WorkflowDefinitionDetailT = { describe("WorkflowDetailPage", () => { test("a deployed workflow shows no not-launchable strip and renders its steps", () => { - const html = renderToStaticMarkup(); + 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).not.toContain( + "This workflow's source has never been deployed", + ); }); test("a pending-approval workflow shows the why-not-launchable strip", () => { @@ -59,7 +67,9 @@ describe("WorkflowDetailPage", () => { }); test("access section reads declared vs. approved grants and credential names only", () => { - const html = renderToStaticMarkup(); + const html = renderToStaticMarkup( + , + ); expect(html).toContain("Declared grants"); expect(html).toContain("Approved grants"); expect(html).toContain("gmail"); diff --git a/docs/workflow-detail.md b/docs/workflow-detail.md index e0456d592..cdffce698 100644 --- a/docs/workflow-detail.md +++ b/docs/workflow-detail.md @@ -11,7 +11,7 @@ useful version — read-only, no editing surface here. - **Lifecycle**: `source-only`, `pending-approval`, `deployed`, `superseded`, or `build-failed` — derived by the pure `deriveWorkflowLifecycle` (`packages/workflow-catalog/src/ - definition-lifecycle.ts`) from the asset's newest `workflow_definition` +definition-lifecycle.ts`) from the asset's newest `workflow_definition` row plus whether `@corbits/workflow-deploy-source` ever recorded a deploy attempt for it. No new Postgres column: everything it reads is native or already Workbench-owned. diff --git a/packages/workflow-catalog/src/detail-route.ts b/packages/workflow-catalog/src/detail-route.ts index 131e898af..841469523 100644 --- a/packages/workflow-catalog/src/detail-route.ts +++ b/packages/workflow-catalog/src/detail-route.ts @@ -48,7 +48,9 @@ function projectStep( perStepGrants: ReadonlyMap, ): WorkflowDefinitionDetail["steps"][number] { const step = - raw !== null && typeof raw === "object" ? (raw as Record) : {}; + raw !== null && typeof raw === "object" + ? (raw as Record) + : {}; const agent = step.agent !== null && typeof step.agent === "object" ? (step.agent as Record) @@ -112,7 +114,10 @@ export function createWorkflowDetailRoute({ app.get( "/:definitionAssetId/detail", - requireGrant(idResource("workflow-definition", "definitionAssetId"), "read"), + requireGrant( + idResource("workflow-definition", "definitionAssetId"), + "read", + ), async (c) => { const tenantCtx = c.get("tenant"); const definitionAssetId = c.req.param("definitionAssetId"); @@ -153,7 +158,8 @@ export function createWorkflowDetailRoute({ definitionRows.map((row) => [ row.id, versionRows.find( - (v) => v.definitionId === row.id && v.version === row.currentVersion, + (v) => + v.definitionId === row.id && v.version === row.currentVersion, ), ]), ); @@ -173,26 +179,37 @@ export function createWorkflowDetailRoute({ .limit(1); const deploySource = deploySourceRows[0] ?? null; - const { lifecycle, currentDefinitionId, wireHash } = deriveWorkflowLifecycle( - definitionRows.map((row) => ({ - id: row.id, - wireHash: row.wireHash, - approvedWireHash: - currentVersionByDefinitionId.get(row.id)?.approvedWireHash ?? null, - status: row.status, - createdAt: row.createdAt.toISOString(), - })), - deploySource !== null, - ); + const { lifecycle, currentDefinitionId, wireHash } = + deriveWorkflowLifecycle( + definitionRows.map((row) => ({ + id: row.id, + wireHash: row.wireHash, + approvedWireHash: + currentVersionByDefinitionId.get(row.id)?.approvedWireHash ?? + null, + status: row.status, + createdAt: row.createdAt.toISOString(), + })), + deploySource !== null, + ); - const current = definitionRows.find((row) => row.id === currentDefinitionId); + const current = definitionRows.find( + (row) => row.id === currentDefinitionId, + ); const grantSnapshot = - current !== undefined ? await loadFrozenGrantSnapshot(db, current.id) : null; + current !== undefined + ? await loadFrozenGrantSnapshot(db, current.id) + : null; const wireProjection = - current !== undefined ? await loadFrozenWireProjection(db, current.id) : null; + current !== undefined + ? await loadFrozenWireProjection(db, current.id) + : null; const perStepGrants = new Map( - (grantSnapshot?.perStep ?? []).map((step) => [step.stepId, step.grants]), + (grantSnapshot?.perStep ?? []).map((step) => [ + step.stepId, + step.grants, + ]), ); const steps = wireProjection === null @@ -204,8 +221,12 @@ export function createWorkflowDetailRoute({ 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 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 = { diff --git a/packages/workflow-catalog/test/detail-route.drizzle.test.ts b/packages/workflow-catalog/test/detail-route.drizzle.test.ts index 70162a418..6c70a05b3 100644 --- a/packages/workflow-catalog/test/detail-route.drizzle.test.ts +++ b/packages/workflow-catalog/test/detail-route.drizzle.test.ts @@ -39,10 +39,7 @@ const allowAll: RequireGrant = () => async (_c, next) => { await next(); }; const denyAll: RequireGrant = () => async (c) => - c.json( - { error: { code: "forbidden", message: "denied" } }, - 403, - ); + c.json({ error: { code: "forbidden", message: "denied" } }, 403); function mount(routes: Hono): Hono { const asTenant: MiddlewareHandler = async (c, next) => { From 5b98c2bfb334d9cfbad396816c5d0dcb7ed4c867 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 2 Sep 2026 02:52:44 -0700 Subject: [PATCH 7/8] Fix CI after review pass (CL-7371) --- apps/web/test/routes.test.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/test/routes.test.tsx b/apps/web/test/routes.test.tsx index eb255f8ea..c1e9a6cdb 100644 --- a/apps/web/test/routes.test.tsx +++ b/apps/web/test/routes.test.tsx @@ -30,6 +30,7 @@ import type { SessionState } from "../src/session"; * until CL-6417 — the stub was unlinked in CL-6817. */ const DETAIL_ROUTE_PATHS = new Set([ ROUTINE_DETAIL_PATH, + WORKFLOW_DETAIL_PATH, AGENT_DETAIL_PATH, SKILL_DETAIL_PATH, ]); @@ -150,6 +151,7 @@ describe("route table", () => { "/inbox", "/routines/:routine", "/routines", + "/workflows/:workflow", "/files", "/library", "/agents/:slug", From 8bbc6d3e1028d868818091031baf44b7b14eae06 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 2 Sep 2026 03:58:08 -0700 Subject: [PATCH 8/8] Remove duplicated retarget-authorization check (CL-7371) --- packages/routines/src/routes.ts | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/packages/routines/src/routes.ts b/packages/routines/src/routes.ts index 04bf85276..8567d38a4 100644 --- a/packages/routines/src/routes.ts +++ b/packages/routines/src/routes.ts @@ -889,27 +889,6 @@ export function createRoutineRoutes( const effectiveDefinitionAssetId = body.definitionAssetId ?? existing.definitionAssetId; - if ( - body.definitionAssetId !== undefined && - body.definitionAssetId !== existing.definitionAssetId - ) { - const rejection = await rejectUnlaunchableTarget( - deps, - tenant.id, - principal.id, - body.definitionAssetId, - ); - if (rejection !== undefined) { - return c.json( - makeErrorEnvelope({ - code: rejection.code, - userMessage: rejection.userMessage, - }), - rejection.status, - ); - } - } - if ( body.trigger !== undefined && !(await webhookTriggerValid(