Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 3.8k
feat(custom-block): deploy a workflow as a reusable org-scoped block#5407
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
d96f6a17c6b2f04551c4c11bca6b3be6122dfe5c6fe1200292cdfb69eebefa96f8ed0adfe3166892fd0e8c1e0030b5da483e1e453File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| import { createLogger } from '@sim/logger' | ||
| import { getErrorMessage } from '@sim/utils/errors' | ||
| import type { NextRequest } from 'next/server' | ||
| import { NextResponse } from 'next/server' | ||
| import { | ||
| deleteCustomBlockContract, | ||
| updateCustomBlockContract, | ||
| } from '@/lib/api/contracts/custom-blocks' | ||
| import { parseRequest } from '@/lib/api/server' | ||
| import { getSession } from '@/lib/auth' | ||
| import { isFeatureEnabled } from '@/lib/core/config/feature-flags' | ||
| import { withRouteHandler } from '@/lib/core/utils/with-route-handler' | ||
| import { | ||
| CustomBlockValidationError, | ||
| deleteCustomBlock, | ||
| getCustomBlockManageContext, | ||
| updateCustomBlock, | ||
| } from '@/lib/workflows/custom-blocks/operations' | ||
| import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils' | ||
| const logger = createLogger('CustomBlockAPI') | ||
| type RouteContext = { params: Promise<{ id: string }> } | ||
| /** | ||
| * Confirm the caller can manage (edit/delete) the block: admin of the block's | ||
| * SOURCE workflow's workspace — matching who could publish it. Org admins/owners | ||
| * hold admin on every org workspace, so they pass too; a workspace admin from a | ||
| * different workspace does not, so they cannot alter another workspace's block or | ||
| * its exposed outputs. | ||
| */ | ||
| async function authorizeManage(userId: string, id: string) { | ||
| const ctx = await getCustomBlockManageContext(id) | ||
| if (!ctx) return { error: NextResponse.json({ error: 'Not found' }, { status: 404 }) } | ||
| if (!(await isFeatureEnabled('deploy-as-block', { userId, orgId: ctx.organizationId }))) { | ||
| return { | ||
| error: NextResponse.json({ error: 'Deploy as block is not enabled' }, { status: 403 }), | ||
| } | ||
| } | ||
| if (!ctx.sourceWorkspaceId || !(await hasWorkspaceAdminAccess(userId, ctx.sourceWorkspaceId))) { | ||
| return { error: NextResponse.json({ error: 'Admin permissions required' }, { status: 403 }) } | ||
| } | ||
| return { error: null } | ||
| } | ||
| export const PATCH = withRouteHandler(async (request: NextRequest, context: RouteContext) => { | ||
| const session = await getSession() | ||
| if (!session?.user?.id) { | ||
| return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) | ||
| } | ||
| const parsed = await parseRequest(updateCustomBlockContract, request, context) | ||
| if (!parsed.success) return parsed.response | ||
| const { id } = parsed.data.params | ||
| const authz = await authorizeManage(session.user.id, id) | ||
| if (authz.error) return authz.error | ||
| const { name, description, enabled, iconUrl, exposedOutputs } = parsed.data.body | ||
| try { | ||
| await updateCustomBlock(id, { | ||
| name, | ||
| description, | ||
| enabled, | ||
| iconUrl, | ||
| exposedOutputs, | ||
| }) | ||
| return NextResponse.json({ success: true as const }) | ||
| } catch (error) { | ||
| if (error instanceof CustomBlockValidationError) { | ||
| return NextResponse.json({ error: error.message }, { status: 400 }) | ||
| } | ||
| logger.error('Failed to update custom block', { id, error: getErrorMessage(error) }) | ||
| throw error | ||
| } | ||
| }) | ||
| export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => { | ||
| const session = await getSession() | ||
| if (!session?.user?.id) { | ||
| return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) | ||
| } | ||
| const parsed = await parseRequest(deleteCustomBlockContract, request, context) | ||
| if (!parsed.success) return parsed.response | ||
| const { id } = parsed.data.params | ||
| const authz = await authorizeManage(session.user.id, id) | ||
| if (authz.error) return authz.error | ||
| await deleteCustomBlock(id) | ||
| return NextResponse.json({ success: true as const }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,130 @@ | ||
| import { createLogger } from '@sim/logger' | ||
| import { getErrorMessage } from '@sim/utils/errors' | ||
| import type { NextRequest } from 'next/server' | ||
| import { NextResponse } from 'next/server' | ||
| import { | ||
| listCustomBlocksContract, | ||
| publishCustomBlockContract, | ||
| } from '@/lib/api/contracts/custom-blocks' | ||
| import { parseRequest } from '@/lib/api/server' | ||
| import { getSession } from '@/lib/auth' | ||
| import { isOrganizationOnEnterprisePlan } from '@/lib/billing' | ||
| import { isFeatureEnabled } from '@/lib/core/config/feature-flags' | ||
| import { withRouteHandler } from '@/lib/core/utils/with-route-handler' | ||
| import { | ||
| CustomBlockValidationError, | ||
| type CustomBlockWithInputs, | ||
| listCustomBlocksWithInputs, | ||
| publishCustomBlock, | ||
| } from '@/lib/workflows/custom-blocks/operations' | ||
| import { | ||
| checkWorkspaceAccess, | ||
| getWorkspaceWithOwner, | ||
| hasWorkspaceAdminAccess, | ||
| } from '@/lib/workspaces/permissions/utils' | ||
| const logger = createLogger('CustomBlocksAPI') | ||
| /** Wire shape for a custom block. Keeps the icon field name explicit for the client. */ | ||
| function toWire(block: CustomBlockWithInputs) { | ||
| return { | ||
| id: block.id, | ||
| organizationId: block.organizationId, | ||
| workflowId: block.workflowId, | ||
| type: block.type, | ||
| name: block.name, | ||
| description: block.description, | ||
| iconUrl: block.iconUrl, | ||
| enabled: block.enabled, | ||
| inputFields: block.inputFields, | ||
| exposedOutputs: block.exposedOutputs, | ||
| } | ||
| } | ||
| export const GET = withRouteHandler(async (request: NextRequest) => { | ||
| const session = await getSession() | ||
| if (!session?.user?.id) { | ||
| return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) | ||
| } | ||
| const parsed = await parseRequest(listCustomBlocksContract, request, {}) | ||
| if (!parsed.success) return parsed.response | ||
| const userId = session.user.id | ||
| const { workspaceId } = parsed.data.query | ||
| const access = await checkWorkspaceAccess(workspaceId, userId) | ||
| if (!access.hasAccess) { | ||
| return NextResponse.json({ error: 'Access denied' }, { status: 403 }) | ||
| } | ||
| const organizationId = access.workspace?.organizationId | ||
| if (!organizationId) { | ||
| return NextResponse.json({ enabled: false, customBlocks: [] }) | ||
| } | ||
| if (!(await isFeatureEnabled('deploy-as-block', { userId, orgId: organizationId }))) { | ||
| return NextResponse.json({ enabled: false, customBlocks: [] }) | ||
| } | ||
| const enabled = await isOrganizationOnEnterprisePlan(organizationId) | ||
| const blocks = enabled ? await listCustomBlocksWithInputs(organizationId) : [] | ||
| return NextResponse.json({ enabled, customBlocks: blocks.map(toWire) }) | ||
| }) | ||
| export const POST = withRouteHandler(async (request: NextRequest) => { | ||
| const session = await getSession() | ||
| if (!session?.user?.id) { | ||
| return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) | ||
| } | ||
| const parsed = await parseRequest(publishCustomBlockContract, request, {}) | ||
| if (!parsed.success) return parsed.response | ||
| const userId = session.user.id | ||
| const { workspaceId, workflowId, name, description, iconUrl, exposedOutputs } = parsed.data.body | ||
| if (!(await hasWorkspaceAdminAccess(userId, workspaceId))) { | ||
| return NextResponse.json({ error: 'Admin permissions required' }, { status: 403 }) | ||
| } | ||
| const ws = await getWorkspaceWithOwner(workspaceId) | ||
| if (!ws?.organizationId) { | ||
| return NextResponse.json( | ||
| { error: 'Publishing a block requires the workspace to belong to an organization' }, | ||
| { status: 400 } | ||
| ) | ||
| } | ||
| const organizationId = ws.organizationId | ||
| if (!(await isFeatureEnabled('deploy-as-block', { userId, orgId: organizationId }))) { | ||
| return NextResponse.json({ error: 'Deploy as block is not enabled' }, { status: 403 }) | ||
| } | ||
| if (!(await isOrganizationOnEnterprisePlan(organizationId))) { | ||
| return NextResponse.json( | ||
| { error: 'Deploy as block requires an enterprise plan' }, | ||
| { status: 403 } | ||
| ) | ||
| } | ||
| try { | ||
| const block = await publishCustomBlock({ | ||
| organizationId, | ||
| workspaceId, | ||
| workflowId, | ||
| userId, | ||
| name, | ||
| description, | ||
| iconUrl, | ||
| exposedOutputs, | ||
| }) | ||
| return NextResponse.json({ customBlock: toWire(block) }) | ||
| } catch (error) { | ||
| if (error instanceof CustomBlockValidationError) { | ||
| return NextResponse.json({ error: error.message }, { status: 400 }) | ||
| } | ||
| logger.error('Failed to publish custom block', { error: getErrorMessage(error) }) | ||
| throw error | ||
| } | ||
| }) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -61,6 +61,7 @@ import { | ||
| cleanupExecutionBase64Cache, | ||
| hydrateUserFilesWithBase64, | ||
| } from '@/lib/uploads/utils/user-file-base64.server' | ||
| import { getCustomBlockRowsForWorkspace } from '@/lib/workflows/custom-blocks/operations' | ||
| import { executeWorkflow } from '@/lib/workflows/executor/execute-workflow' | ||
| import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core' | ||
| import { type ExecutionEvent, encodeSSEEvent } from '@/lib/workflows/executor/execution-events' | ||
| @@ -72,6 +73,7 @@ import { | ||
| import { createStreamingResponse } from '@/lib/workflows/streaming/streaming' | ||
| import { createHttpResponseFromBlock, workflowHasResponseBlock } from '@/lib/workflows/utils' | ||
| import { executeWorkflowJob, type WorkflowExecutionPayload } from '@/background/workflow-execution' | ||
| import { withCustomBlockOverlay } from '@/blocks/custom/server-overlay' | ||
| import { | ||
| PublicApiNotAllowedError, | ||
| validatePublicApiAllowed, | ||
| @@ -859,12 +861,17 @@ async function handleExecutePost( | ||
| variables: deployedVariables, | ||
| } | ||
| const serializedWorkflow = new Serializer().serializeWorkflow( | ||
| workflowData.blocks, | ||
| workflowData.edges, | ||
| workflowData.loops, | ||
| workflowData.parallels, | ||
| false | ||
| // Custom blocks resolve only inside the org overlay; wrap this pre-execution | ||
| // serialize (used for input file-field discovery) the same way the core does. | ||
| const customBlockRows = await getCustomBlockRowsForWorkspace(workspaceId) | ||
| const serializedWorkflow = await withCustomBlockOverlay(customBlockRows, async () => | ||
| new Serializer().serializeWorkflow( | ||
| workflowData.blocks, | ||
| workflowData.edges, | ||
| workflowData.loops, | ||
| workflowData.parallels, | ||
greptile-apps[bot] marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| false | ||
| ) | ||
| ) | ||
| const executionContext = { | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| 'use client' | ||
| import { useState } from 'react' | ||
| import { cn } from '@sim/emcn' | ||
| interface DropZoneProps { | ||
| onDrop: (e: React.DragEvent) => void | ||
| children: React.ReactNode | ||
| className?: string | ||
| } | ||
| /** File drop target with a dashed accent overlay while dragging. Shared by the | ||
| * whitelabeling settings and the deploy-as-block icon upload. */ | ||
| export function DropZone({ onDrop, children, className }: DropZoneProps) { | ||
| const [isDragging, setIsDragging] = useState(false) | ||
| return ( | ||
| <div | ||
| className={cn('relative', className)} | ||
| onDragOver={(e) => { | ||
| if (e.dataTransfer.types.includes('Files')) { | ||
| e.preventDefault() | ||
| setIsDragging(true) | ||
| } | ||
| }} | ||
| onDragLeave={(e) => { | ||
| if (!e.currentTarget.contains(e.relatedTarget as Node)) { | ||
| setIsDragging(false) | ||
| } | ||
| }} | ||
| onDrop={(e) => { | ||
| setIsDragging(false) | ||
| onDrop(e) | ||
| }} | ||
| > | ||
| {children} | ||
| {isDragging && ( | ||
| <div className='pointer-events-none absolute inset-0 z-10 rounded-lg border-[1.5px] border-[var(--brand-accent)] border-dashed bg-[color-mix(in_srgb,var(--brand-accent)_8%,transparent)]' /> | ||
| )} | ||
| </div> | ||
| ) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| 'use client' | ||
| import { useEffect } from 'react' | ||
| import { useParams } from 'next/navigation' | ||
| import { buildCustomBlockConfig } from '@/blocks/custom/build-config' | ||
| import { hydrateClientCustomBlocks } from '@/blocks/custom/client-overlay' | ||
| import { getCustomBlockIcon } from '@/blocks/custom/custom-block-icon' | ||
| import { useCustomBlocks } from '@/hooks/queries/custom-blocks' | ||
| /** | ||
| * Hydrates the client custom-block registry overlay from the active workspace's | ||
| * org custom blocks. Mounted once in the workspace layout so every surface that | ||
| * resolves blocks synchronously — the canvas, the block palette, copilot mentions, | ||
| * and the Access Control "Blocks" list — sees custom blocks. Re-hydrates on | ||
| * workspace switch (the query key changes) and on any publish/edit/unpublish. | ||
| */ | ||
| export function CustomBlocksLoader() { | ||
| const params = useParams() | ||
| const workspaceId = params?.workspaceId as string | undefined | ||
| const { data } = useCustomBlocks(workspaceId) | ||
| useEffect(() => { | ||
| hydrateClientCustomBlocks( | ||
| // Only enabled blocks are resolvable/executable server-side, so the client | ||
| // overlay (toolbar, canvas, palette) must exclude disabled ones too — else | ||
| // the block is offered but every run fails. | ||
| (data ?? []) | ||
| .filter((block) => block.enabled) | ||
| .map((block) => | ||
| buildCustomBlockConfig( | ||
| { | ||
| type: block.type, | ||
| name: block.name, | ||
| description: block.description, | ||
| workflowId: block.workflowId, | ||
| exposedOutputs: block.exposedOutputs, | ||
| }, | ||
| block.inputFields, | ||
| { | ||
| icon: getCustomBlockIcon(block.iconUrl), | ||
| bgColor: block.iconUrl ? 'transparent' : undefined, | ||
| } | ||
| ) | ||
| ) | ||
| ) | ||
cursor[bot] marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| }, [data]) | ||
| return null | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.