Skip to content

Commit 82eff54

Browse files
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 * fix(custom-block): reseed deploy form, guard duplicate publish, run child deployed * test(custom-block): isolate custom-block rows fetch in execution-core test * fix(custom-block): allow cross-workspace exec, org-scope authority, keep field ids, hide disabled * feat(custom-block): run child under source owner's identity, workspace, and env * fix(custom-block): bind publish authz to the source workflow's workspace * fix(custom-block): gate edit/delete on source-workspace admin, not org admin * chore(custom-block): rebaseline route count to 887 after staging merge * fix(custom-block): sanitize failure output so it can't leak source workflow internals * fix(custom-block): derive inputs and curated outputs from deployed state, not draft * fix(custom-block): hide disabled blocks from the toolbar palette too * fix(custom-block): bill nested + failed-run hosted cost; expose real inputs to the agent * fix(custom-block): enforce enterprise + flag gate at every consumption path
1 parent 818fa00 commit 82eff54

49 files changed

Lines changed: 19802 additions & 54 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: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
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+
getCustomBlockManageContext,
17+
updateCustomBlock,
18+
} from '@/lib/workflows/custom-blocks/operations'
19+
import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils'
20+
21+
const logger = createLogger('CustomBlockAPI')
22+
23+
type RouteContext = { params: Promise<{ id: string }> }
24+
25+
/**
26+
* Confirm the caller can manage (edit/delete) the block: admin of the block's
27+
* SOURCE workflow's workspace — matching who could publish it. Org admins/owners
28+
* hold admin on every org workspace, so they pass too; a workspace admin from a
29+
* different workspace does not, so they cannot alter another workspace's block or
30+
* its exposed outputs.
31+
*/
32+
async function authorizeManage(userId: string, id: string) {
33+
const ctx = await getCustomBlockManageContext(id)
34+
if (!ctx) return { error: NextResponse.json({ error: 'Not found' }, { status: 404 }) }
35+
36+
if (!(await isFeatureEnabled('deploy-as-block', { userId, orgId: ctx.organizationId }))) {
37+
return {
38+
error: NextResponse.json({ error: 'Deploy as block is not enabled' }, { status: 403 }),
39+
}
40+
}
41+
if (!ctx.sourceWorkspaceId || !(await hasWorkspaceAdminAccess(userId, ctx.sourceWorkspaceId))) {
42+
return { error: NextResponse.json({ error: 'Admin permissions required' }, { status: 403 }) }
43+
}
44+
return { error: null }
45+
}
46+
47+
export const PATCH = withRouteHandler(async (request: NextRequest, context: RouteContext) => {
48+
const session = await getSession()
49+
if (!session?.user?.id) {
50+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
51+
}
52+
53+
const parsed = await parseRequest(updateCustomBlockContract, request, context)
54+
if (!parsed.success) return parsed.response
55+
56+
const { id } = parsed.data.params
57+
const authz = await authorizeManage(session.user.id, id)
58+
if (authz.error) return authz.error
59+
60+
const { name, description, enabled, iconUrl, exposedOutputs } = parsed.data.body
61+
try {
62+
await updateCustomBlock(id, {
63+
name,
64+
description,
65+
enabled,
66+
iconUrl,
67+
exposedOutputs,
68+
})
69+
return NextResponse.json({ success: true as const })
70+
} catch (error) {
71+
if (error instanceof CustomBlockValidationError) {
72+
return NextResponse.json({ error: error.message }, { status: 400 })
73+
}
74+
logger.error('Failed to update custom block', { id, error: getErrorMessage(error) })
75+
throw error
76+
}
77+
})
78+
79+
export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => {
80+
const session = await getSession()
81+
if (!session?.user?.id) {
82+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
83+
}
84+
85+
const parsed = await parseRequest(deleteCustomBlockContract, request, context)
86+
if (!parsed.success) return parsed.response
87+
88+
const { id } = parsed.data.params
89+
const authz = await authorizeManage(session.user.id, id)
90+
if (authz.error) return authz.error
91+
92+
await deleteCustomBlock(id)
93+
return NextResponse.json({ success: true as const })
94+
})
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
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+
workspaceId,
115+
workflowId,
116+
userId,
117+
name,
118+
description,
119+
iconUrl,
120+
exposedOutputs,
121+
})
122+
return NextResponse.json({ customBlock: toWire(block) })
123+
} catch (error) {
124+
if (error instanceof CustomBlockValidationError) {
125+
return NextResponse.json({ error: error.message }, { status: 400 })
126+
}
127+
logger.error('Failed to publish custom block', { error: getErrorMessage(error) })
128+
throw error
129+
}
130+
})

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,
@@ -859,12 +861,17 @@ async function handleExecutePost(
859861
variables: deployedVariables,
860862
}
861863

862-
const serializedWorkflow = new Serializer().serializeWorkflow(
863-
workflowData.blocks,
864-
workflowData.edges,
865-
workflowData.loops,
866-
workflowData.parallels,
867-
false
864+
// Custom blocks resolve only inside the org overlay; wrap this pre-execution
865+
// serialize (used for input file-field discovery) the same way the core does.
866+
const customBlockRows = await getCustomBlockRowsForWorkspace(workspaceId)
867+
const serializedWorkflow = await withCustomBlockOverlay(customBlockRows, async () =>
868+
new Serializer().serializeWorkflow(
869+
workflowData.blocks,
870+
workflowData.edges,
871+
workflowData.loops,
872+
workflowData.parallels,
873+
false
874+
)
868875
)
869876

870877
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: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
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+
// Only enabled blocks are resolvable/executable server-side, so the client
25+
// overlay (toolbar, canvas, palette) must exclude disabled ones too — else
26+
// the block is offered but every run fails.
27+
(data ?? [])
28+
.filter((block) => block.enabled)
29+
.map((block) =>
30+
buildCustomBlockConfig(
31+
{
32+
type: block.type,
33+
name: block.name,
34+
description: block.description,
35+
workflowId: block.workflowId,
36+
exposedOutputs: block.exposedOutputs,
37+
},
38+
block.inputFields,
39+
{
40+
icon: getCustomBlockIcon(block.iconUrl),
41+
bgColor: block.iconUrl ? 'transparent' : undefined,
42+
}
43+
)
44+
)
45+
)
46+
}, [data])
47+
48+
return null
49+
}

0 commit comments

Comments
 (0)