Skip to content

Commit d96f6a1

Browse files
feat(custom-block): deploy a workflow as a reusable org-scoped block
1 parent b8e88b1 commit d96f6a1

49 files changed

Lines changed: 19556 additions & 85 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { createLogger } from '@sim/logger'
2+
import { getErrorMessage } from '@sim/utils/errors'
3+
import type { NextRequest } from 'next/server'
4+
import { NextResponse } from 'next/server'
5+
import {
6+
deleteCustomBlockContract,
7+
updateCustomBlockContract,
8+
} from '@/lib/api/contracts/custom-blocks'
9+
import { parseRequest } from '@/lib/api/server'
10+
import { getSession } from '@/lib/auth'
11+
import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
12+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
13+
import {
14+
CustomBlockValidationError,
15+
deleteCustomBlock,
16+
getCustomBlockById,
17+
updateCustomBlock,
18+
} from '@/lib/workflows/custom-blocks/operations'
19+
import { isOrganizationAdminOrOwner } from '@/lib/workspaces/permissions/utils'
20+
21+
const logger = createLogger('CustomBlockAPI')
22+
23+
type RouteContext = { params: Promise<{ id: string }> }
24+
25+
/** Load the block and confirm the caller is an admin/owner of its organization. */
26+
async function authorizeManage(userId: string, id: string) {
27+
const block = await getCustomBlockById(id)
28+
if (!block) return { error: NextResponse.json({ error: 'Not found' }, { status: 404 }) }
29+
30+
if (!(await isFeatureEnabled('deploy-as-block', { userId, orgId: block.organizationId }))) {
31+
return {
32+
error: NextResponse.json({ error: 'Deploy as block is not enabled' }, { status: 403 }),
33+
}
34+
}
35+
if (!(await isOrganizationAdminOrOwner(userId, block.organizationId))) {
36+
return { error: NextResponse.json({ error: 'Admin permissions required' }, { status: 403 }) }
37+
}
38+
return { block }
39+
}
40+
41+
export const PATCH = withRouteHandler(async (request: NextRequest, context: RouteContext) => {
42+
const session = await getSession()
43+
if (!session?.user?.id) {
44+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
45+
}
46+
47+
const parsed = await parseRequest(updateCustomBlockContract, request, context)
48+
if (!parsed.success) return parsed.response
49+
50+
const { id } = parsed.data.params
51+
const authz = await authorizeManage(session.user.id, id)
52+
if (authz.error) return authz.error
53+
54+
const { name, description, enabled, iconUrl, exposedOutputs } = parsed.data.body
55+
try {
56+
await updateCustomBlock(id, {
57+
name,
58+
description,
59+
enabled,
60+
iconUrl,
61+
exposedOutputs,
62+
})
63+
return NextResponse.json({ success: true as const })
64+
} catch (error) {
65+
if (error instanceof CustomBlockValidationError) {
66+
return NextResponse.json({ error: error.message }, { status: 400 })
67+
}
68+
logger.error('Failed to update custom block', { id, error: getErrorMessage(error) })
69+
throw error
70+
}
71+
})
72+
73+
export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => {
74+
const session = await getSession()
75+
if (!session?.user?.id) {
76+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
77+
}
78+
79+
const parsed = await parseRequest(deleteCustomBlockContract, request, context)
80+
if (!parsed.success) return parsed.response
81+
82+
const { id } = parsed.data.params
83+
const authz = await authorizeManage(session.user.id, id)
84+
if (authz.error) return authz.error
85+
86+
await deleteCustomBlock(id)
87+
return NextResponse.json({ success: true as const })
88+
})
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { createLogger } from '@sim/logger'
2+
import { getErrorMessage } from '@sim/utils/errors'
3+
import type { NextRequest } from 'next/server'
4+
import { NextResponse } from 'next/server'
5+
import {
6+
listCustomBlocksContract,
7+
publishCustomBlockContract,
8+
} from '@/lib/api/contracts/custom-blocks'
9+
import { parseRequest } from '@/lib/api/server'
10+
import { getSession } from '@/lib/auth'
11+
import { isOrganizationOnEnterprisePlan } from '@/lib/billing'
12+
import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
13+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
14+
import {
15+
CustomBlockValidationError,
16+
type CustomBlockWithInputs,
17+
listCustomBlocksWithInputs,
18+
publishCustomBlock,
19+
} from '@/lib/workflows/custom-blocks/operations'
20+
import {
21+
checkWorkspaceAccess,
22+
getWorkspaceWithOwner,
23+
hasWorkspaceAdminAccess,
24+
} from '@/lib/workspaces/permissions/utils'
25+
26+
const logger = createLogger('CustomBlocksAPI')
27+
28+
/** Wire shape for a custom block. Keeps the icon field name explicit for the client. */
29+
function toWire(block: CustomBlockWithInputs) {
30+
return {
31+
id: block.id,
32+
organizationId: block.organizationId,
33+
workflowId: block.workflowId,
34+
type: block.type,
35+
name: block.name,
36+
description: block.description,
37+
iconUrl: block.iconUrl,
38+
enabled: block.enabled,
39+
inputFields: block.inputFields,
40+
exposedOutputs: block.exposedOutputs,
41+
}
42+
}
43+
44+
export const GET = withRouteHandler(async (request: NextRequest) => {
45+
const session = await getSession()
46+
if (!session?.user?.id) {
47+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
48+
}
49+
50+
const parsed = await parseRequest(listCustomBlocksContract, request, {})
51+
if (!parsed.success) return parsed.response
52+
53+
const userId = session.user.id
54+
const { workspaceId } = parsed.data.query
55+
56+
const access = await checkWorkspaceAccess(workspaceId, userId)
57+
if (!access.hasAccess) {
58+
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
59+
}
60+
61+
const organizationId = access.workspace?.organizationId
62+
if (!organizationId) {
63+
return NextResponse.json({ enabled: false, customBlocks: [] })
64+
}
65+
66+
if (!(await isFeatureEnabled('deploy-as-block', { userId, orgId: organizationId }))) {
67+
return NextResponse.json({ enabled: false, customBlocks: [] })
68+
}
69+
70+
const enabled = await isOrganizationOnEnterprisePlan(organizationId)
71+
const blocks = enabled ? await listCustomBlocksWithInputs(organizationId) : []
72+
return NextResponse.json({ enabled, customBlocks: blocks.map(toWire) })
73+
})
74+
75+
export const POST = withRouteHandler(async (request: NextRequest) => {
76+
const session = await getSession()
77+
if (!session?.user?.id) {
78+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
79+
}
80+
81+
const parsed = await parseRequest(publishCustomBlockContract, request, {})
82+
if (!parsed.success) return parsed.response
83+
84+
const userId = session.user.id
85+
const { workspaceId, workflowId, name, description, iconUrl, exposedOutputs } = parsed.data.body
86+
87+
if (!(await hasWorkspaceAdminAccess(userId, workspaceId))) {
88+
return NextResponse.json({ error: 'Admin permissions required' }, { status: 403 })
89+
}
90+
91+
const ws = await getWorkspaceWithOwner(workspaceId)
92+
if (!ws?.organizationId) {
93+
return NextResponse.json(
94+
{ error: 'Publishing a block requires the workspace to belong to an organization' },
95+
{ status: 400 }
96+
)
97+
}
98+
const organizationId = ws.organizationId
99+
100+
if (!(await isFeatureEnabled('deploy-as-block', { userId, orgId: organizationId }))) {
101+
return NextResponse.json({ error: 'Deploy as block is not enabled' }, { status: 403 })
102+
}
103+
104+
if (!(await isOrganizationOnEnterprisePlan(organizationId))) {
105+
return NextResponse.json(
106+
{ error: 'Deploy as block requires an enterprise plan' },
107+
{ status: 403 }
108+
)
109+
}
110+
111+
try {
112+
const block = await publishCustomBlock({
113+
organizationId,
114+
workflowId,
115+
userId,
116+
name,
117+
description,
118+
iconUrl,
119+
exposedOutputs,
120+
})
121+
return NextResponse.json({ customBlock: toWire(block) })
122+
} catch (error) {
123+
if (error instanceof CustomBlockValidationError) {
124+
return NextResponse.json({ error: error.message }, { status: 400 })
125+
}
126+
logger.error('Failed to publish custom block', { error: getErrorMessage(error) })
127+
throw error
128+
}
129+
})

apps/sim/app/api/workflows/[id]/execute/route.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ import {
6161
cleanupExecutionBase64Cache,
6262
hydrateUserFilesWithBase64,
6363
} from '@/lib/uploads/utils/user-file-base64.server'
64+
import { getCustomBlockRowsForWorkspace } from '@/lib/workflows/custom-blocks/operations'
6465
import { executeWorkflow } from '@/lib/workflows/executor/execute-workflow'
6566
import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core'
6667
import { type ExecutionEvent, encodeSSEEvent } from '@/lib/workflows/executor/execution-events'
@@ -72,6 +73,7 @@ import {
7273
import { createStreamingResponse } from '@/lib/workflows/streaming/streaming'
7374
import { createHttpResponseFromBlock, workflowHasResponseBlock } from '@/lib/workflows/utils'
7475
import { executeWorkflowJob, type WorkflowExecutionPayload } from '@/background/workflow-execution'
76+
import { withCustomBlockOverlay } from '@/blocks/custom/server-overlay'
7577
import {
7678
PublicApiNotAllowedError,
7779
validatePublicApiAllowed,
@@ -838,12 +840,17 @@ async function handleExecutePost(
838840
variables: deployedVariables,
839841
}
840842

841-
const serializedWorkflow = new Serializer().serializeWorkflow(
842-
workflowData.blocks,
843-
workflowData.edges,
844-
workflowData.loops,
845-
workflowData.parallels,
846-
false
843+
// Custom blocks resolve only inside the org overlay; wrap this pre-execution
844+
// serialize (used for input file-field discovery) the same way the core does.
845+
const customBlockRows = await getCustomBlockRowsForWorkspace(workspaceId)
846+
const serializedWorkflow = await withCustomBlockOverlay(customBlockRows, async () =>
847+
new Serializer().serializeWorkflow(
848+
workflowData.blocks,
849+
workflowData.edges,
850+
workflowData.loops,
851+
workflowData.parallels,
852+
false
853+
)
847854
)
848855

849856
const executionContext = {
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
'use client'
2+
3+
import { useState } from 'react'
4+
import { cn } from '@sim/emcn'
5+
6+
interface DropZoneProps {
7+
onDrop: (e: React.DragEvent) => void
8+
children: React.ReactNode
9+
className?: string
10+
}
11+
12+
/** File drop target with a dashed accent overlay while dragging. Shared by the
13+
* whitelabeling settings and the deploy-as-block icon upload. */
14+
export function DropZone({ onDrop, children, className }: DropZoneProps) {
15+
const [isDragging, setIsDragging] = useState(false)
16+
17+
return (
18+
<div
19+
className={cn('relative', className)}
20+
onDragOver={(e) => {
21+
if (e.dataTransfer.types.includes('Files')) {
22+
e.preventDefault()
23+
setIsDragging(true)
24+
}
25+
}}
26+
onDragLeave={(e) => {
27+
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
28+
setIsDragging(false)
29+
}
30+
}}
31+
onDrop={(e) => {
32+
setIsDragging(false)
33+
onDrop(e)
34+
}}
35+
>
36+
{children}
37+
{isDragging && (
38+
<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)]' />
39+
)}
40+
</div>
41+
)
42+
}

apps/sim/app/workspace/[workspaceId]/layout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { getQueryClient } from '@/app/_shell/providers/get-query-client'
77
import { ImpersonationBanner } from '@/app/workspace/[workspaceId]/components/impersonation-banner'
88
import { WorkspaceChrome } from '@/app/workspace/[workspaceId]/components/workspace-chrome'
99
import { prefetchWorkspaceSidebar } from '@/app/workspace/[workspaceId]/prefetch'
10+
import { CustomBlocksLoader } from '@/app/workspace/[workspaceId]/providers/custom-blocks-loader'
1011
import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
1112
import { ProviderModelsLoader } from '@/app/workspace/[workspaceId]/providers/provider-models-loader'
1213
import { SettingsLoader } from '@/app/workspace/[workspaceId]/providers/settings-loader'
@@ -43,6 +44,7 @@ export default async function WorkspaceLayout({
4344
<ToastProvider>
4445
<SettingsLoader />
4546
<ProviderModelsLoader />
47+
<CustomBlocksLoader />
4648
<GlobalCommandsProvider>
4749
<div className='flex h-screen w-full flex-col overflow-hidden bg-[var(--surface-1)]'>
4850
<ImpersonationBanner />

apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import {
4646
isIterationType,
4747
parseTime,
4848
} from '@/app/workspace/[workspaceId]/logs/components/log-details/utils'
49+
import { isCustomBlockType } from '@/blocks/custom/build-config'
4950
import { useCodeViewerFeatures } from '@/hooks/use-code-viewer'
5051

5152
const DEFAULT_TREE_PANE_WIDTH = 240
@@ -667,7 +668,10 @@ const TraceDetailPane = memo(function TraceDetailPane({ span }: { span: TraceSpa
667668
const endedAt = parseTime(span.endTime)
668669

669670
const metaEntries: { label: string; value: string }[] = []
670-
metaEntries.push({ label: 'Type', value: span.type })
671+
metaEntries.push({
672+
label: 'Type',
673+
value: isCustomBlockType(span.type) ? 'custom block' : span.type,
674+
})
671675
metaEntries.push({ label: 'Duration', value: formatDuration(duration, { precision: 2 }) || '—' })
672676
if (span.provider) metaEntries.push({ label: 'Provider', value: span.provider })
673677
if (span.model) metaEntries.push({ label: 'Model', value: span.model })
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
'use client'
2+
3+
import { useEffect } from 'react'
4+
import { useParams } from 'next/navigation'
5+
import { buildCustomBlockConfig } from '@/blocks/custom/build-config'
6+
import { hydrateClientCustomBlocks } from '@/blocks/custom/client-overlay'
7+
import { getCustomBlockIcon } from '@/blocks/custom/custom-block-icon'
8+
import { useCustomBlocks } from '@/hooks/queries/custom-blocks'
9+
10+
/**
11+
* Hydrates the client custom-block registry overlay from the active workspace's
12+
* org custom blocks. Mounted once in the workspace layout so every surface that
13+
* resolves blocks synchronously — the canvas, the block palette, copilot mentions,
14+
* and the Access Control "Blocks" list — sees custom blocks. Re-hydrates on
15+
* workspace switch (the query key changes) and on any publish/edit/unpublish.
16+
*/
17+
export function CustomBlocksLoader() {
18+
const params = useParams()
19+
const workspaceId = params?.workspaceId as string | undefined
20+
const { data } = useCustomBlocks(workspaceId)
21+
22+
useEffect(() => {
23+
hydrateClientCustomBlocks(
24+
(data ?? []).map((block) =>
25+
buildCustomBlockConfig(
26+
{
27+
type: block.type,
28+
name: block.name,
29+
description: block.description,
30+
workflowId: block.workflowId,
31+
exposedOutputs: block.exposedOutputs,
32+
},
33+
block.inputFields,
34+
{
35+
icon: getCustomBlockIcon(block.iconUrl),
36+
bgColor: block.iconUrl ? 'transparent' : undefined,
37+
}
38+
)
39+
)
40+
)
41+
}, [data])
42+
43+
return null
44+
}

0 commit comments

Comments
 (0)