feat(custom-block): deploy a workflow as a reusable org-scoped block#5407
feat(custom-block): deploy a workflow as a reusable org-scoped block#5407TheodoreSpeaks wants to merge 6 commits into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
PR SummaryHigh Risk Overview Backend & data: New Execution: Custom blocks run via UI: Deploy modal Block tab ( Copilot: Custom blocks in Other: Reviewed by Cursor Bugbot for commit dfe5c6f. Bugbot is set up for automated code reviews on this repo. Configure here. |
|
@greptile review |
Greptile SummaryThis PR implements "Deploy as Block" — a new Enterprise/org-scoped feature that lets an org admin publish any deployed workflow as a reusable block available across every workspace in the org, gated by the
Confidence Score: 5/5The new execution path correctly enforces org-scoping at the authority lookup, applies owner-identity substitution, and cleans up child workflow outputs before returning them to the consumer — no new blocking issues found. The invocation boundary model is implemented correctly across the authority lookup, handler, and overlay layers. New findings are quality-of-life concerns that do not affect correctness or security. apps/sim/lib/workflows/custom-blocks/operations.ts for the N+1 query pattern in the list path; apps/sim/executor/handlers/workflow/workflow-handler.ts for the reserved output name collision noted in a prior review round. Important Files Changed
Sequence Diagram%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant Consumer as Consumer Workspace
participant Handler as WorkflowBlockHandler
participant Auth as getCustomBlockAuthority
participant DB as Database
participant OwnerEnv as Owner Env/Identity
participant Child as Child Executor
Consumer->>Handler: execute(custom_block_xyz block)
Handler->>Auth: getCustomBlockAuthority(type, consumerWorkspaceId)
Auth->>DB: getWorkspaceWithOwner(consumerWorkspaceId)
Auth->>DB: "SELECT custom_block JOIN workflow WHERE type=? AND org=?"
DB-->>Auth: workflowId, ownerUserId, exposedOutputs
Auth-->>Handler: authority
Handler->>DB: checkChildDeployment(workflowId, ownerUserId)
Handler->>DB: loadChildWorkflowDeployed(workflowId, ownerUserId)
Handler->>Handler: remapCustomBlockInputKeys(mapping, childBlocks)
Handler->>OwnerEnv: getPersonalAndWorkspaceEnv(ownerUserId, ownerWorkspaceId)
OwnerEnv-->>Handler: personalDecrypted + workspaceDecrypted
Handler->>Child: new Executor(serializedState, ownerEnvVars, ownerUserId)
Child-->>Handler: ExecutionResult
Handler->>Handler: projectCustomBlockOutput(result, exposedOutputs, childCost)
Handler-->>Consumer: BlockOutput success + namedOutputs
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant Consumer as Consumer Workspace
participant Handler as WorkflowBlockHandler
participant Auth as getCustomBlockAuthority
participant DB as Database
participant OwnerEnv as Owner Env/Identity
participant Child as Child Executor
Consumer->>Handler: execute(custom_block_xyz block)
Handler->>Auth: getCustomBlockAuthority(type, consumerWorkspaceId)
Auth->>DB: getWorkspaceWithOwner(consumerWorkspaceId)
Auth->>DB: "SELECT custom_block JOIN workflow WHERE type=? AND org=?"
DB-->>Auth: workflowId, ownerUserId, exposedOutputs
Auth-->>Handler: authority
Handler->>DB: checkChildDeployment(workflowId, ownerUserId)
Handler->>DB: loadChildWorkflowDeployed(workflowId, ownerUserId)
Handler->>Handler: remapCustomBlockInputKeys(mapping, childBlocks)
Handler->>OwnerEnv: getPersonalAndWorkspaceEnv(ownerUserId, ownerWorkspaceId)
OwnerEnv-->>Handler: personalDecrypted + workspaceDecrypted
Handler->>Child: new Executor(serializedState, ownerEnvVars, ownerUserId)
Child-->>Handler: ExecutionResult
Handler->>Handler: projectCustomBlockOutput(result, exposedOutputs, childCost)
Handler-->>Consumer: BlockOutput success + namedOutputs
Reviews (4): Last reviewed commit: "feat(custom-block): run child under sour..." | Re-trigger Greptile |
| * deletes the workflow → the custom_block row, so there is never an orphaned block. | ||
| * `null` when no enabled block matches the type. | ||
| */ | ||
| export async function getCustomBlockAuthority(type: string): Promise<{ | ||
| workflowId: string | ||
| organizationId: string | ||
| ownerUserId: string | ||
| exposedOutputs: CustomBlockOutput[] | ||
| } | null> { | ||
| const [row] = await db | ||
| .select({ | ||
| workflowId: customBlock.workflowId, | ||
| organizationId: customBlock.organizationId, | ||
| enabled: customBlock.enabled, | ||
| outputs: customBlock.outputs, | ||
| ownerUserId: workflow.userId, | ||
| }) | ||
| .from(customBlock) |
There was a problem hiding this comment.
getCustomBlockAuthority resolves by type only — no org-boundary check
The query uses only eq(customBlock.type, type) without joining to the executing workspace's org. If a workflow snapshot ever contains a custom_block_* type from a different org, the executor would silently load and run the source workflow under its owner's credentials with no cross-org check. Adding an organizationId parameter to the function and including it in the WHERE clause would make the invocation boundary explicit rather than relying solely on the overlay's serialization guard.
Greptile SummaryThis PR introduces the "deploy-as-block" feature, allowing org admins to publish a deployed workflow as a reusable, org-scoped custom block that appears in the block palette like any native block. It is a large but well-structured addition gated behind a feature flag and an enterprise plan check.
Confidence Score: 3/5The core invocation-boundary model and registry overlay are solid, but two correctness issues in the client overlay and the DB schema need fixing before this ships to production. The client overlay is hydrated with all blocks including disabled ones — an admin who disables a block to pull it from users palettes will find it still appears and is placeable (execution then fails with a confusing error). Separately, the DB has no unique constraint on (organization_id, workflow_id), so direct API calls can create multiple custom blocks for the same source workflow; the UI only surfaces the first match, leaving extras permanently orphaned in the DB but still resolvable at runtime. custom-blocks-loader.tsx (disabled-block filter missing), packages/db/schema.ts and operations.ts (missing unique constraint + server-side validation on workflow_id per org), and operations.ts getCustomBlockAuthority (no index on type alone for the execution hot path). Important Files Changed
Sequence Diagram%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant Admin as Org Admin
participant API as POST /api/custom-blocks
participant DB as custom_block table
participant Loader as CustomBlocksLoader
participant Overlay as Client Overlay
participant Canvas as Block Palette
participant Exec as WorkflowBlockHandler
participant Auth as getCustomBlockAuthority
participant Child as Child Workflow
Admin->>API: Publish workflow as block
API->>DB: INSERT custom_block
DB-->>API: row
API-->>Admin: customBlock
Loader->>API: GET /api/custom-blocks
API->>DB: SELECT all blocks for org
DB-->>API: all rows including disabled
API-->>Loader: customBlocks array
Loader->>Overlay: hydrateClientCustomBlocks all blocks
Overlay-->>Canvas: custom_block types resolve
Canvas->>Exec: execute custom block
Exec->>Auth: getCustomBlockAuthority by type
Auth->>DB: SELECT WHERE type equals value
DB-->>Auth: authority row
Auth-->>Exec: workflowId ownerUserId exposedOutputs
Exec->>Child: loadChildWorkflowDeployed
Child-->>Exec: deployed snapshot
Exec->>Child: execute
Child-->>Exec: ExecutionResult
Exec->>Exec: projectCustomBlockOutput
Exec-->>Canvas: BlockOutput curated only
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant Admin as Org Admin
participant API as POST /api/custom-blocks
participant DB as custom_block table
participant Loader as CustomBlocksLoader
participant Overlay as Client Overlay
participant Canvas as Block Palette
participant Exec as WorkflowBlockHandler
participant Auth as getCustomBlockAuthority
participant Child as Child Workflow
Admin->>API: Publish workflow as block
API->>DB: INSERT custom_block
DB-->>API: row
API-->>Admin: customBlock
Loader->>API: GET /api/custom-blocks
API->>DB: SELECT all blocks for org
DB-->>API: all rows including disabled
API-->>Loader: customBlocks array
Loader->>Overlay: hydrateClientCustomBlocks all blocks
Overlay-->>Canvas: custom_block types resolve
Canvas->>Exec: execute custom block
Exec->>Auth: getCustomBlockAuthority by type
Auth->>DB: SELECT WHERE type equals value
DB-->>Auth: authority row
Auth-->>Exec: workflowId ownerUserId exposedOutputs
Exec->>Child: loadChildWorkflowDeployed
Child-->>Exec: deployed snapshot
Exec->>Child: execute
Child-->>Exec: ExecutionResult
Exec->>Exec: projectCustomBlockOutput
Exec-->>Canvas: BlockOutput curated only
Reviews (2): Last reviewed commit: "feat(custom-block): deploy a workflow as..." | Re-trigger Greptile |
| * API/schedule/webhook run executes as. Using the owner (not the publisher) means | ||
| * the owner always has read on their own workflow, and owner deletion cascade- | ||
| * deletes the workflow → the custom_block row, so there is never an orphaned block. | ||
| * `null` when no enabled block matches the type. | ||
| */ | ||
| export async function getCustomBlockAuthority(type: string): Promise<{ | ||
| workflowId: string | ||
| organizationId: string | ||
| ownerUserId: string | ||
| exposedOutputs: CustomBlockOutput[] | ||
| } | null> { | ||
| const [row] = await db | ||
| .select({ | ||
| workflowId: customBlock.workflowId, | ||
| organizationId: customBlock.organizationId, | ||
| enabled: customBlock.enabled, | ||
| outputs: customBlock.outputs, | ||
| ownerUserId: workflow.userId, | ||
| }) | ||
| .from(customBlock) | ||
| .innerJoin(workflow, eq(workflow.id, customBlock.workflowId)) | ||
| .where(eq(customBlock.type, type)) | ||
| .limit(1) | ||
|
|
||
| if (!row || !row.enabled) return null | ||
| return { | ||
| workflowId: row.workflowId, | ||
| organizationId: row.organizationId, |
There was a problem hiding this comment.
Execution-time authority lookup scans the table on
type alone
getCustomBlockAuthority filters by WHERE type = $1 with no other predicate. The only index that includes type is the composite unique index on (organization_id, type). PostgreSQL uses a B-tree left-to-right, so a filter on just the second column of the composite index results in a sequential scan. This lookup runs on every custom block invocation — adding a standalone index on type would make the hot path efficient as the table grows.
|
@greptile review |
| description, | ||
| iconUrl, | ||
| exposedOutputs, | ||
| }) |
There was a problem hiding this comment.
Publish without source workspace admin
Medium Severity
Publishing checks workspace admin on the workspaceId in the request body only. publishCustomBlock verifies the target workflow belongs to that workspace’s organization, not that the workflow lives in that workspace or that the caller admins the workflow’s home workspace.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 7c6b2f0. Configure here.
| name: name.trim(), | ||
| description: description.trim(), | ||
| exposedOutputs, | ||
| ...(iconChanged ? { iconUrl: iconUrl || null } : {}), |
There was a problem hiding this comment.
Stale outputs mark form dirty
Medium Severity
The Update action compares visibleOutputs (filtered when workflow data loads) to existing.exposedOutputs. Orphaned outputs hidden from the UI still differ from stored data, so the form becomes dirty without edits and save persists the trimmed list.
Reviewed by Cursor Bugbot for commit 7c6b2f0. Configure here.
# Conflicts: # scripts/check-api-validation-contracts.ts
…eep field ids, hide disabled
| const allCustomBlocks = useMemo(() => { | ||
| if (!customBlocksData?.length) return [] | ||
| return customBlocksData | ||
| .filter((cb) => cb.workflowId !== currentWorkflowId) |
There was a problem hiding this comment.
Toolbar shows disabled blocks
Medium Severity
The Custom Blocks toolbar section builds its list from useCustomBlocks without filtering enabled, while CustomBlocksLoader explicitly drops disabled blocks so runs do not fail. Disabled org blocks still appear in the palette and can be placed, but execution rejects them as unavailable.
Reviewed by Cursor Bugbot for commit 3be6122. Configure here.
|
@greptile review |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
There are 4 total unresolved issues (including 3 from previous reviews).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit dfe5c6f. Configure here.
| const ws = wf.workspaceId ? await getWorkspaceWithOwner(wf.workspaceId) : null | ||
| if (!ws?.organizationId || ws.organizationId !== organizationId) { | ||
| throw new CustomBlockValidationError('Workflow does not belong to this organization') | ||
| } |
There was a problem hiding this comment.
Publish skips workspace check
Medium Severity
Publishing a custom block checks org membership and workspace admin on the request’s workspaceId, but never verifies that workflowId belongs to that workspace. An org admin in one workspace can publish another workspace’s deployed workflow as an org-wide block via the API.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit dfe5c6f. Configure here.


Summary
deploy-as-blockfeature flag; org admins disable it via Access Control like any other blockedit_workflow/get_blocks_metadataregistry overlay (the typed VFS snapshot is untouched, so the Go diff is unaffected)custom_blocktable (migration 0254 — additive, backward-compatible)Type of Change
Testing
bun run lint,bun run check:api-validation:strict, andbun run check:migrations origin/stagingall passChecklist