diff --git a/apps/sim/app/api/mothership/execute/route.ts b/apps/sim/app/api/mothership/execute/route.ts index 37236aa160f..fa227d69351 100644 --- a/apps/sim/app/api/mothership/execute/route.ts +++ b/apps/sim/app/api/mothership/execute/route.ts @@ -8,6 +8,7 @@ import { checkInternalAuth } from '@/lib/auth/hybrid' import { buildIntegrationToolSchemas } from '@/lib/copilot/chat/payload' import { processContextsServer } from '@/lib/copilot/chat/process-contents' import { generateWorkspaceContext } from '@/lib/copilot/chat/workspace-context' +import { computeWorkspaceEntitlements } from '@/lib/copilot/entitlements' import { MothershipStreamV1EventType, MothershipStreamV1TextChannel, @@ -141,25 +142,32 @@ export const POST = withRouteHandler(async (req: NextRequest) => { const lastUserMessage = messages.filter((m) => m.role === 'user').at(-1)?.content // double-cast-allowed: the contract validates contexts as open kind/label objects; processContextsServer narrows on `kind` at runtime const agentMentions = contexts as unknown as ChatContext[] | undefined - const [workspaceContext, integrationTools, userSkillTool, userPermission, agentContexts] = - await Promise.all([ - generateWorkspaceContext(workspaceId, userId), - buildIntegrationToolSchemas(userId, messageId, undefined, workspaceId), - buildUserSkillTool(workspaceId), - getUserEntityPermissions(userId, 'workspace', workspaceId).catch(() => null), - processContextsServer( - agentMentions, - userId, - lastUserMessage, - workspaceId, - effectiveChatId - ).catch((error) => { - reqLogger.warn('Failed to resolve agent contexts for execution', { - error: toError(error).message, - }) - return [] - }), - ]) + const [ + workspaceContext, + integrationTools, + userSkillTool, + userPermission, + entitlements, + agentContexts, + ] = await Promise.all([ + generateWorkspaceContext(workspaceId, userId), + buildIntegrationToolSchemas(userId, messageId, undefined, workspaceId), + buildUserSkillTool(workspaceId), + getUserEntityPermissions(userId, 'workspace', workspaceId).catch(() => null), + computeWorkspaceEntitlements(workspaceId, userId), + processContextsServer( + agentMentions, + userId, + lastUserMessage, + workspaceId, + effectiveChatId + ).catch((error) => { + reqLogger.warn('Failed to resolve agent contexts for execution', { + error: toError(error).message, + }) + return [] + }), + ]) const requestPayload: Record = { messages, responseFormat, @@ -182,6 +190,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { ...(integrationTools.length > 0 ? { integrationTools } : {}), ...(userSkillTool ? { mothershipTools: [userSkillTool] } : {}), ...(userPermission ? { userPermission } : {}), + ...(entitlements.length > 0 ? { entitlements } : {}), } let allowExplicitAbort = true diff --git a/apps/sim/lib/copilot/chat/payload.test.ts b/apps/sim/lib/copilot/chat/payload.test.ts index 1afbfbbaac3..ee050104ec1 100644 --- a/apps/sim/lib/copilot/chat/payload.test.ts +++ b/apps/sim/lib/copilot/chat/payload.test.ts @@ -227,4 +227,34 @@ describe('buildCopilotRequestPayload', () => { }) ) }) + + it('passes entitlements through and omits the field when empty', async () => { + const withEntitlements = await buildCopilotRequestPayload( + { + message: 'publish as a block', + userId: 'user-1', + userMessageId: 'msg-1', + mode: 'agent', + model: 'claude-opus-4-8', + workspaceId: 'ws-1', + entitlements: ['custom-blocks'], + }, + { selectedModel: 'claude-opus-4-8' } + ) + expect(withEntitlements).toEqual(expect.objectContaining({ entitlements: ['custom-blocks'] })) + + const withoutEntitlements = await buildCopilotRequestPayload( + { + message: 'publish as a block', + userId: 'user-1', + userMessageId: 'msg-1', + mode: 'agent', + model: 'claude-opus-4-8', + workspaceId: 'ws-1', + entitlements: [], + }, + { selectedModel: 'claude-opus-4-8' } + ) + expect(withoutEntitlements).not.toHaveProperty('entitlements') + }) }) diff --git a/apps/sim/lib/copilot/chat/payload.ts b/apps/sim/lib/copilot/chat/payload.ts index e718b33cce3..6af11bd8683 100644 --- a/apps/sim/lib/copilot/chat/payload.ts +++ b/apps/sim/lib/copilot/chat/payload.ts @@ -36,6 +36,8 @@ interface BuildPayloadParams { workspaceContext?: string vfs?: VfsSnapshotV1 userPermission?: string + /** Plan/flag-gated org capabilities (e.g. "custom-blocks") the mothership gates tools/prompts on. */ + entitlements?: string[] userTimezone?: string userMetadata?: { name?: string @@ -370,6 +372,7 @@ export async function buildCopilotRequestPayload( ...(params.workspaceContext ? { workspaceContext: params.workspaceContext } : {}), ...(params.vfs ? { vfs: params.vfs } : {}), ...(params.userPermission ? { userPermission: params.userPermission } : {}), + ...(params.entitlements?.length ? { entitlements: params.entitlements } : {}), ...(params.userTimezone ? { userTimezone: params.userTimezone } : {}), ...(params.userMetadata && (params.userMetadata.name || params.userMetadata.email || params.userMetadata.timezone) diff --git a/apps/sim/lib/copilot/chat/post.ts b/apps/sim/lib/copilot/chat/post.ts index ca5943ae3b7..99345d240e6 100644 --- a/apps/sim/lib/copilot/chat/post.ts +++ b/apps/sim/lib/copilot/chat/post.ts @@ -25,6 +25,7 @@ import { finalizeAssistantTurn } from '@/lib/copilot/chat/terminal-state' import { generateWorkspaceSnapshot } from '@/lib/copilot/chat/workspace-context' import { chatPubSub } from '@/lib/copilot/chat-status' import { COPILOT_REQUEST_MODES } from '@/lib/copilot/constants' +import { computeWorkspaceEntitlements } from '@/lib/copilot/entitlements' import { CopilotChatFinalizeOutcome, CopilotChatPersistOutcome, @@ -175,6 +176,7 @@ type UnifiedChatBranch = contexts: Array<{ type: string; content: string; tag?: string; path?: string }> fileAttachments?: UnifiedChatRequest['fileAttachments'] userPermission?: string + entitlements?: string[] userTimezone?: string userMetadata?: { name?: string; email?: string; timezone?: string } workflowId: string @@ -212,6 +214,7 @@ type UnifiedChatBranch = contexts: Array<{ type: string; content: string; tag?: string; path?: string }> fileAttachments?: UnifiedChatRequest['fileAttachments'] userPermission?: string + entitlements?: string[] userTimezone?: string userMetadata?: { name?: string; email?: string; timezone?: string } workspaceContext?: string @@ -624,6 +627,7 @@ async function resolveBranch(params: { workspaceContext: payloadParams.workspaceContext, vfs: payloadParams.vfs, userPermission: payloadParams.userPermission, + entitlements: payloadParams.entitlements, userTimezone: payloadParams.userTimezone, userMetadata: payloadParams.userMetadata, }, @@ -679,6 +683,7 @@ async function resolveBranch(params: { workspaceContext: payloadParams.workspaceContext, vfs: payloadParams.vfs, userPermission: payloadParams.userPermission, + entitlements: payloadParams.entitlements, userTimezone: payloadParams.userTimezone, userMetadata: payloadParams.userMetadata, includeMothershipTools: true, @@ -899,6 +904,9 @@ export async function handleUnifiedChatPost(req: NextRequest) { } ) : Promise.resolve(null) + const entitlementsPromise = workspaceId + ? computeWorkspaceEntitlements(workspaceId, authenticatedUserId) + : Promise.resolve([]) // Wrap the pre-LLM prep work in spans so the trace waterfall shows // where time is going between "request received" and "llm.stream // opens". Previously these ran bare under the root and inflated the @@ -953,10 +961,11 @@ export async function handleUnifiedChatPost(req: NextRequest) { activeOtelRoot.context ) - const [agentContexts, userPermission, workspaceSnapshot, , executionContext] = + const [agentContexts, userPermission, entitlements, workspaceSnapshot, , executionContext] = await Promise.all([ agentContextsPromise, userPermissionPromise, + entitlementsPromise, workspaceContextPromise, persistUserMessagePromise, executionContextPromise, @@ -991,6 +1000,7 @@ export async function handleUnifiedChatPost(req: NextRequest) { contexts: agentContexts, fileAttachments: body.fileAttachments, userPermission: userPermission ?? undefined, + entitlements, userTimezone: body.userTimezone, userMetadata, workflowId: branch.workflowId, @@ -1012,6 +1022,7 @@ export async function handleUnifiedChatPost(req: NextRequest) { contexts: agentContexts, fileAttachments: body.fileAttachments, userPermission: userPermission ?? undefined, + entitlements, userTimezone: body.userTimezone, userMetadata, workspaceContext, diff --git a/apps/sim/lib/copilot/entitlements.ts b/apps/sim/lib/copilot/entitlements.ts new file mode 100644 index 00000000000..8ef560cacea --- /dev/null +++ b/apps/sim/lib/copilot/entitlements.ts @@ -0,0 +1,72 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { LRUCache } from 'lru-cache' +import { isCustomBlocksEligible } from '@/lib/workflows/custom-blocks/operations' + +const logger = createLogger('CopilotEntitlements') + +/** + * Cross-repo contract: the mothership (Go) matches these exact strings against + * its `core.Entitlement*` constants to gate agent surfaces. + */ +export const CUSTOM_BLOCKS_ENTITLEMENT = 'custom-blocks' + +/** + * Workspace entitlements — plan/flag-gated org capabilities sent to the + * mothership as the chat payload's `entitlements` array. The Go side hides the + * matching tools, skills, and prompt sections when an entitlement is absent, so + * a non-entitled org's agents never hear of the feature. + * + * Adding an entitlement: + * 1. Add the kebab-case name and a fail-closed evaluator here. Every payload + * site (interactive chat, headless execute, inbox) picks it up automatically. + * 2. Go repo: add the matching `Entitlement*` constant in `internal/core` and + * gate surfaces declaratively — `RequiredEntitlement` on tool definitions, + * `entitlement:` frontmatter on skills, a conditional section in + * `BuildAgentEnvelope`, or a variant swap in `Capabilities`. + * 3. Keep enforcement in sim: the Go gating is advertisement-only (the payload + * is forgeable), so the sim-side tool handler must re-check the same + * predicate at execution time. + */ +const ENTITLEMENT_EVALUATORS: Record< + string, + (workspaceId: string, userId?: string) => Promise +> = { + [CUSTOM_BLOCKS_ENTITLEMENT]: isCustomBlocksEligible, +} + +const entitlementsCache = new LRUCache>({ + max: 500, + ttl: 5_000, +}) + +/** + * The entitlements to send to the mothership for a request in this workspace. + * Each evaluator fails closed (an error means the entitlement is absent). + * Cached briefly so the several per-message callers collapse to one evaluation. + */ +export function computeWorkspaceEntitlements( + workspaceId: string, + userId?: string +): Promise { + const cacheKey = `${workspaceId}:${userId ?? ''}` + const cached = entitlementsCache.get(cacheKey) + if (cached) return cached + + const promise = Promise.all( + Object.entries(ENTITLEMENT_EVALUATORS).map(async ([name, evaluate]) => { + try { + return (await evaluate(workspaceId, userId)) ? name : null + } catch (error) { + logger.warn('Entitlement evaluation failed; treating as absent', { + entitlement: name, + workspaceId, + error: getErrorMessage(error), + }) + return null + } + }) + ).then((names) => names.filter((name): name is string => name !== null)) + entitlementsCache.set(cacheKey, promise) + return promise +} diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 4a4c24a1594..ffa1866a79c 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -23,6 +23,7 @@ export interface ToolCatalogEntry { | 'deploy' | 'deploy_api' | 'deploy_chat' + | 'deploy_custom_block' | 'deploy_mcp' | 'diff_workflows' | 'download_to_workspace_file' @@ -121,6 +122,7 @@ export interface ToolCatalogEntry { | 'deploy' | 'deploy_api' | 'deploy_chat' + | 'deploy_custom_block' | 'deploy_mcp' | 'diff_workflows' | 'download_to_workspace_file' @@ -536,7 +538,7 @@ export const Deploy: ToolCatalogEntry = { properties: { request: { description: - 'Detailed deployment instructions. Include deployment type (api/chat/mcp) and ALL user-specified options: identifier, title, description, authType, password, allowedEmails, welcomeMessage, outputConfigs (block outputs to display).', + 'Detailed deployment instructions. Include the deployment type and ALL user-specified options: identifier, title, description, authType, password, allowedEmails, welcomeMessage, outputConfigs (block outputs to display).', type: 'string', }, }, @@ -772,6 +774,118 @@ export const DeployChat: ToolCatalogEntry = { requiredPermission: 'admin', } +export const DeployCustomBlock: ToolCatalogEntry = { + id: 'deploy_custom_block', + name: 'deploy_custom_block', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + action: { + type: 'string', + description: 'Whether to publish (deploy) or unpublish (undeploy) the custom block', + enum: ['deploy', 'undeploy'], + default: 'deploy', + }, + description: { + type: 'string', + description: 'Short description shown in the block picker, max 280 characters', + }, + exposedOutputs: { + type: 'array', + description: + "Outputs the block exposes, each mapping a child block output path to a friendly name (use get_block_outputs for valid paths). Omit to expose the terminal block's whole result", + items: { + type: 'object', + properties: { + blockId: { type: 'string', description: 'Block UUID inside the workflow' }, + name: { type: 'string', description: 'Friendly output name shown on the block' }, + path: { + type: 'string', + description: + "Dot-path into that block's output (from get_block_outputs relativeOutputs)", + }, + }, + required: ['blockId', 'path', 'name'], + }, + }, + iconUrl: { + type: 'string', + description: + 'Optional icon image for the block: a workspace file VFS path (e.g. "files/icon.png", copied into public icon storage at publish) or an external image URL. Omit to use the organization\'s default icon', + }, + inputs: { + type: 'array', + description: + "Optional per-input placeholder overrides. Input names and types are derived from the workflow's input trigger and cannot be changed here", + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Stable id of the input trigger field' }, + placeholder: { + type: 'string', + description: "Placeholder text shown in the block's input field", + }, + }, + required: ['id'], + }, + }, + name: { + type: 'string', + description: + 'Display name for the block, max 60 characters (required on first publish; defaults to the existing block name when republishing)', + }, + workflowId: { type: 'string', description: 'Workflow ID (defaults to active workflow)' }, + }, + }, + resultSchema: { + type: 'object', + properties: { + action: { + type: 'string', + description: 'Action performed by the tool, such as "deploy" or "undeploy".', + }, + blockId: { type: 'string', description: 'Custom block record ID.' }, + blockType: { + type: 'string', + description: 'Stable block type slug (custom_block_*) used in workflow state.', + }, + deploymentConfig: { + type: 'object', + description: + "Structured deployment configuration keyed by surface name. Includes the block's type, name, description, icon, derived input fields, and exposed outputs.", + }, + deploymentStatus: { + type: 'object', + description: + 'Structured per-surface deployment status keyed by surface name, including customBlock and the underlying api surface when applicable.', + }, + deploymentType: { + type: 'string', + description: + 'Deployment surface this result describes. For deploy_custom_block this is always "custom_block".', + }, + isDeployed: { + type: 'boolean', + description: 'Whether the custom block is published after this tool call.', + }, + name: { type: 'string', description: 'Display name of the custom block.' }, + removed: { + type: 'boolean', + description: 'Whether the custom block was unpublished during an undeploy action.', + }, + updated: { + type: 'boolean', + description: 'Whether an existing custom block was updated instead of created.', + }, + workflowId: { type: 'string', description: 'Workflow ID the custom block is bound to.' }, + }, + required: ['deploymentType', 'deploymentStatus'], + }, + requiredPermission: 'admin', +} + export const DeployMcp: ToolCatalogEntry = { id: 'deploy_mcp', name: 'deploy_mcp', @@ -4581,6 +4695,7 @@ export const TOOL_CATALOG: Record = { [Deploy.id]: Deploy, [DeployApi.id]: DeployApi, [DeployChat.id]: DeployChat, + [DeployCustomBlock.id]: DeployCustomBlock, [DeployMcp.id]: DeployMcp, [DiffWorkflows.id]: DiffWorkflows, [DownloadToWorkspaceFile.id]: DownloadToWorkspaceFile, diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index dcaea0db6ea..fcbf88408a6 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -10,7 +10,7 @@ export interface ToolRuntimeSchemaEntry { } export const TOOL_RUNTIME_SCHEMAS: Record = { - agent: { + ['agent']: { parameters: { properties: { request: { @@ -23,7 +23,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - auth: { + ['auth']: { parameters: { properties: { request: { @@ -36,7 +36,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - check_deployment_status: { + ['check_deployment_status']: { parameters: { type: 'object', properties: { @@ -48,7 +48,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - complete_scheduled_task: { + ['complete_scheduled_task']: { parameters: { type: 'object', properties: { @@ -61,7 +61,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - crawl_website: { + ['crawl_website']: { parameters: { type: 'object', properties: { @@ -96,7 +96,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - create_file: { + ['create_file']: { parameters: { type: 'object', properties: { @@ -162,7 +162,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - create_file_folder: { + ['create_file_folder']: { parameters: { type: 'object', properties: { @@ -180,7 +180,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - create_workflow: { + ['create_workflow']: { parameters: { type: 'object', properties: { @@ -205,7 +205,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - create_workspace_mcp_server: { + ['create_workspace_mcp_server']: { parameters: { type: 'object', properties: { @@ -238,7 +238,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - delete_file: { + ['delete_file']: { parameters: { type: 'object', properties: { @@ -268,7 +268,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - delete_file_folder: { + ['delete_file_folder']: { parameters: { type: 'object', properties: { @@ -284,7 +284,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - delete_workflow: { + ['delete_workflow']: { parameters: { type: 'object', properties: { @@ -300,7 +300,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - delete_workspace_mcp_server: { + ['delete_workspace_mcp_server']: { parameters: { type: 'object', properties: { @@ -313,12 +313,12 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - deploy: { + ['deploy']: { parameters: { properties: { request: { description: - 'Detailed deployment instructions. Include deployment type (api/chat/mcp) and ALL user-specified options: identifier, title, description, authType, password, allowedEmails, welcomeMessage, outputConfigs (block outputs to display).', + 'Detailed deployment instructions. Include the deployment type and ALL user-specified options: identifier, title, description, authType, password, allowedEmails, welcomeMessage, outputConfigs (block outputs to display).', type: 'string', }, }, @@ -327,7 +327,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - deploy_api: { + ['deploy_api']: { parameters: { type: 'object', properties: { @@ -411,7 +411,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { ], }, }, - deploy_chat: { + ['deploy_chat']: { parameters: { type: 'object', properties: { @@ -570,7 +570,134 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { ], }, }, - deploy_mcp: { + ['deploy_custom_block']: { + parameters: { + type: 'object', + properties: { + action: { + type: 'string', + description: 'Whether to publish (deploy) or unpublish (undeploy) the custom block', + enum: ['deploy', 'undeploy'], + default: 'deploy', + }, + description: { + type: 'string', + description: 'Short description shown in the block picker, max 280 characters', + }, + exposedOutputs: { + type: 'array', + description: + "Outputs the block exposes, each mapping a child block output path to a friendly name (use get_block_outputs for valid paths). Omit to expose the terminal block's whole result", + items: { + type: 'object', + properties: { + blockId: { + type: 'string', + description: 'Block UUID inside the workflow', + }, + name: { + type: 'string', + description: 'Friendly output name shown on the block', + }, + path: { + type: 'string', + description: + "Dot-path into that block's output (from get_block_outputs relativeOutputs)", + }, + }, + required: ['blockId', 'path', 'name'], + }, + }, + iconUrl: { + type: 'string', + description: + 'Optional icon image for the block: a workspace file VFS path (e.g. "files/icon.png", copied into public icon storage at publish) or an external image URL. Omit to use the organization\'s default icon', + }, + inputs: { + type: 'array', + description: + "Optional per-input placeholder overrides. Input names and types are derived from the workflow's input trigger and cannot be changed here", + items: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'Stable id of the input trigger field', + }, + placeholder: { + type: 'string', + description: "Placeholder text shown in the block's input field", + }, + }, + required: ['id'], + }, + }, + name: { + type: 'string', + description: + 'Display name for the block, max 60 characters (required on first publish; defaults to the existing block name when republishing)', + }, + workflowId: { + type: 'string', + description: 'Workflow ID (defaults to active workflow)', + }, + }, + }, + resultSchema: { + type: 'object', + properties: { + action: { + type: 'string', + description: 'Action performed by the tool, such as "deploy" or "undeploy".', + }, + blockId: { + type: 'string', + description: 'Custom block record ID.', + }, + blockType: { + type: 'string', + description: 'Stable block type slug (custom_block_*) used in workflow state.', + }, + deploymentConfig: { + type: 'object', + description: + "Structured deployment configuration keyed by surface name. Includes the block's type, name, description, icon, derived input fields, and exposed outputs.", + }, + deploymentStatus: { + type: 'object', + description: + 'Structured per-surface deployment status keyed by surface name, including customBlock and the underlying api surface when applicable.', + }, + deploymentType: { + type: 'string', + description: + 'Deployment surface this result describes. For deploy_custom_block this is always "custom_block".', + }, + isDeployed: { + type: 'boolean', + description: 'Whether the custom block is published after this tool call.', + }, + name: { + type: 'string', + description: 'Display name of the custom block.', + }, + removed: { + type: 'boolean', + description: 'Whether the custom block was unpublished during an undeploy action.', + }, + updated: { + type: 'boolean', + description: 'Whether an existing custom block was updated instead of created.', + }, + workflowId: { + type: 'string', + description: 'Workflow ID the custom block is bound to.', + }, + }, + required: ['deploymentType', 'deploymentStatus'], + }, + }, + ['deploy_mcp']: { parameters: { type: 'object', properties: { @@ -686,7 +813,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['deploymentType', 'deploymentStatus'], }, }, - diff_workflows: { + ['diff_workflows']: { parameters: { type: 'object', properties: { @@ -710,7 +837,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - download_to_workspace_file: { + ['download_to_workspace_file']: { parameters: { type: 'object', properties: { @@ -759,7 +886,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - edit_content: { + ['edit_content']: { parameters: { type: 'object', properties: { @@ -791,7 +918,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - edit_workflow: { + ['edit_workflow']: { parameters: { type: 'object', properties: { @@ -830,7 +957,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - enrichment_run: { + ['enrichment_run']: { parameters: { type: 'object', properties: { @@ -874,7 +1001,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['matched', 'result'], }, }, - ffmpeg: { + ['ffmpeg']: { parameters: { type: 'object', properties: { @@ -1055,7 +1182,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - file: { + ['file']: { parameters: { properties: { prompt: { @@ -1068,7 +1195,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - function_execute: { + ['function_execute']: { parameters: { type: 'object', properties: { @@ -1206,7 +1333,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - generate_api_key: { + ['generate_api_key']: { parameters: { type: 'object', properties: { @@ -1224,7 +1351,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - generate_audio: { + ['generate_audio']: { parameters: { type: 'object', properties: { @@ -1376,7 +1503,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - generate_image: { + ['generate_image']: { parameters: { type: 'object', properties: { @@ -1504,7 +1631,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - generate_video: { + ['generate_video']: { parameters: { type: 'object', properties: { @@ -1671,7 +1798,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_block_outputs: { + ['get_block_outputs']: { parameters: { type: 'object', properties: { @@ -1692,7 +1819,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_block_upstream_references: { + ['get_block_upstream_references']: { parameters: { type: 'object', properties: { @@ -1714,7 +1841,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_deployed_workflow_state: { + ['get_deployed_workflow_state']: { parameters: { type: 'object', properties: { @@ -1727,7 +1854,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_deployment_log: { + ['get_deployment_log']: { parameters: { type: 'object', properties: { @@ -1740,7 +1867,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_page_contents: { + ['get_page_contents']: { parameters: { type: 'object', properties: { @@ -1768,14 +1895,14 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_platform_actions: { + ['get_platform_actions']: { parameters: { type: 'object', properties: {}, }, resultSchema: undefined, }, - get_scheduled_task_logs: { + ['get_scheduled_task_logs']: { parameters: { type: 'object', properties: { @@ -1800,7 +1927,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_workflow_data: { + ['get_workflow_data']: { parameters: { type: 'object', properties: { @@ -1819,7 +1946,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_workflow_run_options: { + ['get_workflow_run_options']: { parameters: { type: 'object', properties: { @@ -1832,7 +1959,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - glob: { + ['glob']: { parameters: { type: 'object', properties: { @@ -1851,7 +1978,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - grep: { + ['grep']: { parameters: { type: 'object', properties: { @@ -1899,7 +2026,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - knowledge: { + ['knowledge']: { parameters: { properties: { request: { @@ -1912,7 +2039,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - knowledge_base: { + ['knowledge_base']: { parameters: { type: 'object', properties: { @@ -2105,7 +2232,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - list_file_folders: { + ['list_file_folders']: { parameters: { type: 'object', properties: { @@ -2117,7 +2244,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - list_integration_tools: { + ['list_integration_tools']: { parameters: { properties: { integration: { @@ -2131,14 +2258,14 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - list_user_workspaces: { + ['list_user_workspaces']: { parameters: { type: 'object', properties: {}, }, resultSchema: undefined, }, - list_workspace_mcp_servers: { + ['list_workspace_mcp_servers']: { parameters: { type: 'object', properties: { @@ -2151,7 +2278,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - load_deployment: { + ['load_deployment']: { parameters: { type: 'object', properties: { @@ -2170,7 +2297,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - load_integration_tool: { + ['load_integration_tool']: { parameters: { properties: { tool_ids: { @@ -2187,7 +2314,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - manage_credential: { + ['manage_credential']: { parameters: { type: 'object', properties: { @@ -2216,7 +2343,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - manage_custom_tool: { + ['manage_custom_tool']: { parameters: { type: 'object', properties: { @@ -2296,7 +2423,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - manage_folder: { + ['manage_folder']: { parameters: { type: 'object', properties: { @@ -2335,7 +2462,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - manage_mcp_tool: { + ['manage_mcp_tool']: { parameters: { type: 'object', properties: { @@ -2387,7 +2514,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - manage_scheduled_task: { + ['manage_scheduled_task']: { parameters: { type: 'object', properties: { @@ -2462,7 +2589,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - manage_skill: { + ['manage_skill']: { parameters: { type: 'object', properties: { @@ -2495,7 +2622,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - materialize_file: { + ['materialize_file']: { parameters: { type: 'object', properties: { @@ -2519,7 +2646,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - media: { + ['media']: { parameters: { properties: { prompt: { @@ -2532,7 +2659,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - move_file: { + ['move_file']: { parameters: { type: 'object', properties: { @@ -2553,7 +2680,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - move_file_folder: { + ['move_file_folder']: { parameters: { type: 'object', properties: { @@ -2571,7 +2698,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - move_workflow: { + ['move_workflow']: { parameters: { type: 'object', properties: { @@ -2591,7 +2718,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - oauth_get_auth_link: { + ['oauth_get_auth_link']: { parameters: { type: 'object', properties: { @@ -2605,7 +2732,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - oauth_request_access: { + ['oauth_request_access']: { parameters: { type: 'object', properties: { @@ -2619,7 +2746,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - open_resource: { + ['open_resource']: { parameters: { type: 'object', properties: { @@ -2653,7 +2780,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - promote_to_live: { + ['promote_to_live']: { parameters: { type: 'object', properties: { @@ -2672,7 +2799,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - query_logs: { + ['query_logs']: { parameters: { type: 'object', properties: { @@ -2783,7 +2910,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - read: { + ['read']: { parameters: { type: 'object', properties: { @@ -2810,7 +2937,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - redeploy: { + ['redeploy']: { parameters: { type: 'object', properties: { @@ -2889,7 +3016,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { ], }, }, - rename_file: { + ['rename_file']: { parameters: { type: 'object', properties: { @@ -2925,7 +3052,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - rename_file_folder: { + ['rename_file_folder']: { parameters: { type: 'object', properties: { @@ -2942,7 +3069,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - rename_workflow: { + ['rename_workflow']: { parameters: { type: 'object', properties: { @@ -2959,7 +3086,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - research: { + ['research']: { parameters: { properties: { topic: { @@ -2972,7 +3099,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - respond: { + ['respond']: { parameters: { additionalProperties: true, properties: { @@ -2995,7 +3122,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - restore_resource: { + ['restore_resource']: { parameters: { type: 'object', properties: { @@ -3013,7 +3140,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - run: { + ['run']: { parameters: { properties: { context: { @@ -3030,7 +3157,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - run_block: { + ['run_block']: { parameters: { type: 'object', properties: { @@ -3062,7 +3189,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - run_from_block: { + ['run_from_block']: { parameters: { type: 'object', properties: { @@ -3094,7 +3221,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - run_workflow: { + ['run_workflow']: { parameters: { type: 'object', properties: { @@ -3132,7 +3259,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - run_workflow_until_block: { + ['run_workflow_until_block']: { parameters: { type: 'object', properties: { @@ -3175,7 +3302,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - scheduled_task: { + ['scheduled_task']: { parameters: { properties: { request: { @@ -3188,7 +3315,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - scrape_page: { + ['scrape_page']: { parameters: { type: 'object', properties: { @@ -3209,7 +3336,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - search_documentation: { + ['search_documentation']: { parameters: { type: 'object', properties: { @@ -3226,7 +3353,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - search_library_docs: { + ['search_library_docs']: { parameters: { type: 'object', properties: { @@ -3247,7 +3374,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - search_online: { + ['search_online']: { parameters: { type: 'object', properties: { @@ -3287,7 +3414,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - search_patterns: { + ['search_patterns']: { parameters: { type: 'object', properties: { @@ -3309,7 +3436,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - set_block_enabled: { + ['set_block_enabled']: { parameters: { type: 'object', properties: { @@ -3331,7 +3458,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - set_environment_variables: { + ['set_environment_variables']: { parameters: { type: 'object', properties: { @@ -3365,7 +3492,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - set_global_workflow_variables: { + ['set_global_workflow_variables']: { parameters: { type: 'object', properties: { @@ -3406,7 +3533,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - superagent: { + ['superagent']: { parameters: { properties: { task: { @@ -3420,7 +3547,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - table: { + ['table']: { parameters: { properties: { request: { @@ -3433,7 +3560,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - update_deployment_version: { + ['update_deployment_version']: { parameters: { type: 'object', properties: { @@ -3462,7 +3589,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - update_scheduled_task_history: { + ['update_scheduled_task_history']: { parameters: { type: 'object', properties: { @@ -3480,7 +3607,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - update_workspace_mcp_server: { + ['update_workspace_mcp_server']: { parameters: { type: 'object', properties: { @@ -3505,7 +3632,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - user_memory: { + ['user_memory']: { parameters: { type: 'object', properties: { @@ -3554,7 +3681,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - user_table: { + ['user_table']: { parameters: { type: 'object', properties: { @@ -3917,7 +4044,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - workflow: { + ['workflow']: { parameters: { properties: { prompt: { @@ -3930,7 +4057,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - workspace_file: { + ['workspace_file']: { parameters: { type: 'object', properties: { diff --git a/apps/sim/lib/copilot/tool-executor/register-handlers.ts b/apps/sim/lib/copilot/tool-executor/register-handlers.ts index b39027e3526..999dea91b28 100644 --- a/apps/sim/lib/copilot/tool-executor/register-handlers.ts +++ b/apps/sim/lib/copilot/tool-executor/register-handlers.ts @@ -8,6 +8,7 @@ import { DeleteWorkspaceMcpServer, DeployApi, DeployChat, + DeployCustomBlock, DeployMcp, DiffWorkflows, FunctionExecute, @@ -53,6 +54,7 @@ import { } from '@/lib/copilot/generated/tool-catalog-v1' import { createServerToolHandler } from '@/lib/copilot/tools/registry/server-tool-adapter' import { getRegisteredServerToolNames } from '@/lib/copilot/tools/server/router' +import { executeDeployCustomBlock } from '../tools/handlers/deployment/custom-block' import { executeDeployApi, executeDeployChat, @@ -156,6 +158,7 @@ function buildHandlerMap(): Record { [DeployApi.id]: h(executeDeployApi), [DeployChat.id]: h(executeDeployChat), [DeployMcp.id]: h(executeDeployMcp), + [DeployCustomBlock.id]: h(executeDeployCustomBlock), [Redeploy.id]: h(executeRedeploy), [CheckDeploymentStatus.id]: h(executeCheckDeploymentStatus), [ListWorkspaceMcpServers.id]: h(executeListWorkspaceMcpServers), diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.test.ts b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.test.ts new file mode 100644 index 00000000000..b5e8850c279 --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.test.ts @@ -0,0 +1,414 @@ +/** + * @vitest-environment node + */ + +import { auditMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ExecutionContext } from '@/lib/copilot/request/types' + +const { + ensureWorkflowAccessMock, + getWorkspaceWithOwnerMock, + isFeatureEnabledMock, + isOrganizationOnEnterprisePlanMock, + publishCustomBlockMock, + updateCustomBlockMock, + deleteCustomBlockMock, + getCustomBlockWithInputsByWorkflowIdMock, + listWorkspaceFilesMock, + fetchWorkspaceFileBufferMock, + uploadFileMock, +} = vi.hoisted(() => ({ + ensureWorkflowAccessMock: vi.fn(), + getWorkspaceWithOwnerMock: vi.fn(), + isFeatureEnabledMock: vi.fn(), + isOrganizationOnEnterprisePlanMock: vi.fn(), + publishCustomBlockMock: vi.fn(), + updateCustomBlockMock: vi.fn(), + deleteCustomBlockMock: vi.fn(), + getCustomBlockWithInputsByWorkflowIdMock: vi.fn(), + listWorkspaceFilesMock: vi.fn(), + fetchWorkspaceFileBufferMock: vi.fn(), + uploadFileMock: vi.fn(), +})) + +vi.mock('@sim/audit', () => auditMock) + +vi.mock('../access', () => ({ + ensureWorkflowAccess: ensureWorkflowAccessMock, + ensureWorkspaceAccess: vi.fn(), +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getWorkspaceWithOwner: getWorkspaceWithOwnerMock, +})) + +vi.mock('@/lib/core/config/feature-flags', () => ({ + isFeatureEnabled: isFeatureEnabledMock, +})) + +vi.mock('@/lib/billing', () => ({ + isOrganizationOnEnterprisePlan: isOrganizationOnEnterprisePlanMock, +})) + +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + listWorkspaceFiles: listWorkspaceFilesMock, + fetchWorkspaceFileBuffer: fetchWorkspaceFileBufferMock, +})) + +vi.mock('@/lib/uploads/core/storage-service', () => ({ + uploadFile: uploadFileMock, +})) + +vi.mock('@/lib/uploads/utils/file-utils', () => ({ + isImageFileType: (type: string) => type.startsWith('image/'), +})) + +vi.mock('@/lib/workflows/custom-blocks/operations', () => { + class CustomBlockValidationError extends Error {} + return { + CustomBlockValidationError, + publishCustomBlock: publishCustomBlockMock, + updateCustomBlock: updateCustomBlockMock, + deleteCustomBlock: deleteCustomBlockMock, + getCustomBlockWithInputsByWorkflowId: getCustomBlockWithInputsByWorkflowIdMock, + } +}) + +import { executeDeployCustomBlock } from './custom-block' + +const context = { userId: 'user-1', workflowId: 'wf-1' } as ExecutionContext + +const publishedBlock = { + id: 'cb-1', + organizationId: 'org-1', + workflowId: 'wf-1', + workflowName: 'Test Workflow', + workspaceId: 'ws-1', + workspaceName: 'Workspace', + type: 'custom_block_abc123', + name: 'Enrich Lead', + description: 'Enrich a lead by email', + iconUrl: null, + enabled: true, + inputFields: [{ id: 'f1', name: 'email', type: 'string' }], + exposedOutputs: [{ blockId: 'b1', path: 'content', name: 'summary' }], +} + +describe('executeDeployCustomBlock', () => { + beforeEach(() => { + vi.clearAllMocks() + ensureWorkflowAccessMock.mockResolvedValue({ + workflow: { id: 'wf-1', workspaceId: 'ws-1', name: 'Test Workflow', isDeployed: true }, + }) + getWorkspaceWithOwnerMock.mockResolvedValue({ id: 'ws-1', organizationId: 'org-1' }) + isFeatureEnabledMock.mockResolvedValue(true) + isOrganizationOnEnterprisePlanMock.mockResolvedValue(true) + getCustomBlockWithInputsByWorkflowIdMock.mockResolvedValue(null) + }) + + it('publishes a new custom block', async () => { + publishCustomBlockMock.mockResolvedValue(publishedBlock) + + const result = await executeDeployCustomBlock( + { + name: 'Enrich Lead', + description: 'Enrich a lead by email', + exposedOutputs: [{ blockId: 'b1', path: 'content', name: 'summary' }], + }, + context + ) + + expect(ensureWorkflowAccessMock).toHaveBeenCalledWith('wf-1', 'user-1', 'admin') + expect(publishCustomBlockMock).toHaveBeenCalledWith({ + organizationId: 'org-1', + workspaceId: 'ws-1', + workflowId: 'wf-1', + userId: 'user-1', + name: 'Enrich Lead', + description: 'Enrich a lead by email', + iconUrl: undefined, + inputs: undefined, + exposedOutputs: [{ blockId: 'b1', path: 'content', name: 'summary' }], + }) + expect(result.success).toBe(true) + expect(result.output).toMatchObject({ + workflowId: 'wf-1', + blockType: 'custom_block_abc123', + isDeployed: true, + updated: false, + deploymentType: 'custom_block', + deploymentStatus: { customBlock: { isDeployed: true, name: 'Enrich Lead' } }, + }) + }) + + it('returns a clean admin-permission error when workflow access is denied', async () => { + ensureWorkflowAccessMock.mockRejectedValue(new Error('Unauthorized workflow access')) + + const result = await executeDeployCustomBlock({ name: 'Enrich Lead' }, context) + + expect(result.success).toBe(false) + expect(result.error).toContain('admin permission') + expect(publishCustomBlockMock).not.toHaveBeenCalled() + }) + + it('surfaces workflow-not-found from access resolution', async () => { + ensureWorkflowAccessMock.mockRejectedValue(new Error('Workflow wf-1 not found')) + + const result = await executeDeployCustomBlock({ name: 'Enrich Lead' }, context) + + expect(result.success).toBe(false) + expect(result.error).toContain('not found') + }) + + it('requires a name on first publish', async () => { + const result = await executeDeployCustomBlock({}, context) + + expect(result.success).toBe(false) + expect(result.error).toContain('name is required') + expect(publishCustomBlockMock).not.toHaveBeenCalled() + }) + + it('requires the workflow to be deployed on first publish', async () => { + ensureWorkflowAccessMock.mockResolvedValue({ + workflow: { id: 'wf-1', workspaceId: 'ws-1', name: 'Test Workflow', isDeployed: false }, + }) + + const result = await executeDeployCustomBlock({ name: 'Enrich Lead' }, context) + + expect(result.success).toBe(false) + expect(result.error).toContain('deploy_api') + expect(publishCustomBlockMock).not.toHaveBeenCalled() + }) + + it('updates an existing block in place', async () => { + getCustomBlockWithInputsByWorkflowIdMock + .mockResolvedValueOnce(publishedBlock) + .mockResolvedValueOnce({ ...publishedBlock, name: 'Enrich Lead v2' }) + + const result = await executeDeployCustomBlock({ name: 'Enrich Lead v2' }, context) + + expect(updateCustomBlockMock).toHaveBeenCalledWith('cb-1', { + name: 'Enrich Lead v2', + description: undefined, + iconUrl: undefined, + inputs: undefined, + exposedOutputs: undefined, + }) + expect(publishCustomBlockMock).not.toHaveBeenCalled() + expect(result.success).toBe(true) + expect(result.output).toMatchObject({ updated: true, name: 'Enrich Lead v2' }) + }) + + it('unpublishes the block on undeploy', async () => { + getCustomBlockWithInputsByWorkflowIdMock.mockResolvedValue(publishedBlock) + + const result = await executeDeployCustomBlock({ action: 'undeploy' }, context) + + expect(deleteCustomBlockMock).toHaveBeenCalledWith('cb-1') + expect(result.success).toBe(true) + expect(result.output).toMatchObject({ + isDeployed: false, + removed: true, + action: 'undeploy', + blockType: 'custom_block_abc123', + }) + }) + + it('updates an existing block without requiring the enterprise plan', async () => { + isOrganizationOnEnterprisePlanMock.mockResolvedValue(false) + getCustomBlockWithInputsByWorkflowIdMock + .mockResolvedValueOnce(publishedBlock) + .mockResolvedValueOnce(publishedBlock) + + const result = await executeDeployCustomBlock({ description: 'refreshed copy' }, context) + + expect(result.success).toBe(true) + expect(updateCustomBlockMock).toHaveBeenCalled() + expect(publishCustomBlockMock).not.toHaveBeenCalled() + }) + + it('undeploys without requiring the enterprise plan', async () => { + isOrganizationOnEnterprisePlanMock.mockResolvedValue(false) + getCustomBlockWithInputsByWorkflowIdMock.mockResolvedValue(publishedBlock) + + const result = await executeDeployCustomBlock({ action: 'undeploy' }, context) + + expect(result.success).toBe(true) + expect(deleteCustomBlockMock).toHaveBeenCalledWith('cb-1') + }) + + it('does not clear the stored name when a republish sends whitespace', async () => { + getCustomBlockWithInputsByWorkflowIdMock + .mockResolvedValueOnce(publishedBlock) + .mockResolvedValueOnce(publishedBlock) + + const result = await executeDeployCustomBlock({ name: ' ' }, context) + + expect(updateCustomBlockMock).toHaveBeenCalledWith( + 'cb-1', + expect.objectContaining({ name: undefined }) + ) + expect(result.success).toBe(true) + }) + + it('rejects oversized exposedOutputs and inputs arrays', async () => { + const outputs = Array.from({ length: 51 }, (_, i) => ({ + blockId: `b${i}`, + path: 'content', + name: `out${i}`, + })) + const tooManyOutputs = await executeDeployCustomBlock( + { name: 'Enrich Lead', exposedOutputs: outputs }, + context + ) + expect(tooManyOutputs.success).toBe(false) + expect(tooManyOutputs.error).toContain('50') + + const inputs = Array.from({ length: 51 }, (_, i) => ({ id: `f${i}` })) + const tooManyInputs = await executeDeployCustomBlock({ name: 'Enrich Lead', inputs }, context) + expect(tooManyInputs.success).toBe(false) + expect(tooManyInputs.error).toContain('50') + expect(publishCustomBlockMock).not.toHaveBeenCalled() + }) + + it('rejects oversized per-item fields', async () => { + const longPlaceholder = await executeDeployCustomBlock( + { name: 'Enrich Lead', inputs: [{ id: 'f1', placeholder: 'x'.repeat(201) }] }, + context + ) + expect(longPlaceholder.success).toBe(false) + expect(longPlaceholder.error).toContain('200') + + const longOutputName = await executeDeployCustomBlock( + { + name: 'Enrich Lead', + exposedOutputs: [{ blockId: 'b1', path: 'content', name: 'x'.repeat(61) }], + }, + context + ) + expect(longOutputName.success).toBe(false) + expect(longOutputName.error).toContain('60') + expect(publishCustomBlockMock).not.toHaveBeenCalled() + }) + + it('rejects exposedOutputs entries missing required fields', async () => { + const result = await executeDeployCustomBlock( + { name: 'Enrich Lead', exposedOutputs: [{ blockId: 'b1', path: '', name: 'out' }] }, + context + ) + expect(result.success).toBe(false) + expect(result.error).toContain('blockId, path, and name') + }) + + it('fails undeploy when the workflow is not published as a block', async () => { + const result = await executeDeployCustomBlock({ action: 'undeploy' }, context) + + expect(result.success).toBe(false) + expect(deleteCustomBlockMock).not.toHaveBeenCalled() + }) + + it('fails when the feature flag is off', async () => { + isFeatureEnabledMock.mockResolvedValue(false) + + const result = await executeDeployCustomBlock({ name: 'Enrich Lead' }, context) + + expect(result.success).toBe(false) + expect(result.error).toContain('not enabled') + }) + + it('fails when the org is not on the enterprise plan', async () => { + isOrganizationOnEnterprisePlanMock.mockResolvedValue(false) + + const result = await executeDeployCustomBlock({ name: 'Enrich Lead' }, context) + + expect(result.success).toBe(false) + expect(result.error).toContain('enterprise') + }) + + it('ingests a workspace-file icon into public icon storage', async () => { + listWorkspaceFilesMock.mockResolvedValue([ + { + name: 'icon.png', + folderPath: null, + type: 'image/png', + size: 1024, + key: 'workspace/ws-1/123-abc-icon.png', + }, + ]) + fetchWorkspaceFileBufferMock.mockResolvedValue(Buffer.from('png-bytes')) + uploadFileMock.mockResolvedValue({ path: '/api/files/serve/s3/workspace-logos%2Ficon.png' }) + publishCustomBlockMock.mockResolvedValue(publishedBlock) + + const result = await executeDeployCustomBlock( + { name: 'Enrich Lead', iconUrl: 'files/icon.png' }, + context + ) + + expect(uploadFileMock).toHaveBeenCalledWith( + expect.objectContaining({ + context: 'workspace-logos', + contentType: 'image/png', + customKey: expect.stringMatching(/^workspace-logos\/\d+-[A-Za-z0-9_-]+-icon\.png$/), + preserveKey: true, + metadata: { workspaceId: 'ws-1', userId: 'user-1', originalName: 'icon.png' }, + }) + ) + expect(publishCustomBlockMock).toHaveBeenCalledWith( + expect.objectContaining({ iconUrl: '/api/files/serve/s3/workspace-logos%2Ficon.png' }) + ) + expect(result.success).toBe(true) + }) + + it('passes an external icon URL through without ingestion', async () => { + publishCustomBlockMock.mockResolvedValue(publishedBlock) + + const result = await executeDeployCustomBlock( + { name: 'Enrich Lead', iconUrl: 'https://example.com/icon.png' }, + context + ) + + expect(uploadFileMock).not.toHaveBeenCalled() + expect(publishCustomBlockMock).toHaveBeenCalledWith( + expect.objectContaining({ iconUrl: 'https://example.com/icon.png' }) + ) + expect(result.success).toBe(true) + }) + + it('fails when the icon workspace file does not exist', async () => { + listWorkspaceFilesMock.mockResolvedValue([]) + + const result = await executeDeployCustomBlock( + { name: 'Enrich Lead', iconUrl: 'files/missing.png' }, + context + ) + + expect(result.success).toBe(false) + expect(result.error).toContain('not found') + expect(publishCustomBlockMock).not.toHaveBeenCalled() + }) + + it('fails when the icon workspace file is not an image', async () => { + listWorkspaceFilesMock.mockResolvedValue([ + { name: 'notes.pdf', folderPath: null, type: 'application/pdf', size: 1024, key: 'k' }, + ]) + + const result = await executeDeployCustomBlock( + { name: 'Enrich Lead', iconUrl: 'files/notes.pdf' }, + context + ) + + expect(result.success).toBe(false) + expect(result.error).toContain('image') + expect(uploadFileMock).not.toHaveBeenCalled() + }) + + it('fails when the workspace has no organization', async () => { + getWorkspaceWithOwnerMock.mockResolvedValue({ id: 'ws-1', organizationId: null }) + + const result = await executeDeployCustomBlock({ name: 'Enrich Lead' }, context) + + expect(result.success).toBe(false) + expect(result.error).toContain('organization') + }) +}) diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts new file mode 100644 index 00000000000..65d2bed86d6 --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts @@ -0,0 +1,281 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { toError } from '@sim/utils/errors' +import { generateShortId } from '@sim/utils/id' +import { isOrganizationOnEnterprisePlan } from '@/lib/billing' +import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' +import { canonicalizeVfsPath, canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils' +import { isFeatureEnabled } from '@/lib/core/config/feature-flags' +import { + fetchWorkspaceFileBuffer, + listWorkspaceFiles, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { uploadFile } from '@/lib/uploads/core/storage-service' +import { isImageFileType } from '@/lib/uploads/utils/file-utils' +import { + CustomBlockValidationError, + type CustomBlockWithInputs, + deleteCustomBlock, + getCustomBlockWithInputsByWorkflowId, + publishCustomBlock, + updateCustomBlock, +} from '@/lib/workflows/custom-blocks/operations' +import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' +import { ensureWorkflowAccess } from '../access' +import type { DeployCustomBlockParams } from '../param-types' + +const MAX_ICON_BYTES = 5 * 1024 * 1024 +const MAX_INPUT_ENTRIES = 50 +const MAX_OUTPUT_ENTRIES = 50 + +/** + * Resolve the agent-supplied icon reference to a publicly servable URL. A VFS + * workspace-file path (`files/...`) is ingested: the file is copied into the + * world-readable `workspace-logos` storage context (the same context the icon + * upload UI writes to), because a raw workspace-file URL is membership-gated + * and the block's icon must render for org members in other workspaces. Any + * other non-empty value (an external or already-public URL) passes through. + */ +async function resolveIconUrl( + raw: string | undefined, + userId: string, + workspaceId: string +): Promise { + const value = raw?.trim() + if (!value) return undefined + if (!value.startsWith('files/')) return value + + const canonical = canonicalizeVfsPath(value) + const files = await listWorkspaceFiles(workspaceId, { hydrateFolderPaths: true }) + const record = files.find( + (f) => canonicalWorkspaceFilePath({ folderPath: f.folderPath, name: f.name }) === canonical + ) + if (!record) { + throw new CustomBlockValidationError(`Icon file not found in this workspace: ${value}`) + } + if (!isImageFileType(record.type)) { + throw new CustomBlockValidationError( + 'Icon file must be an image (PNG, JPEG, GIF, WebP, or SVG)' + ) + } + if (record.size > MAX_ICON_BYTES) { + throw new CustomBlockValidationError('Icon file must be 5MB or smaller') + } + + const buffer = await fetchWorkspaceFileBuffer(record) + const safeFileName = record.name.replace(/[^a-zA-Z0-9.-]/g, '_') + const uploaded = await uploadFile({ + file: buffer, + fileName: record.name, + contentType: record.type, + context: 'workspace-logos', + customKey: `workspace-logos/${Date.now()}-${generateShortId()}-${safeFileName}`, + preserveKey: true, + metadata: { workspaceId, userId, originalName: record.name }, + }) + return uploaded.path +} + +function customBlockOutput(block: CustomBlockWithInputs, action: 'deploy' | 'undeploy') { + const isDeployed = action === 'deploy' + return { + workflowId: block.workflowId, + blockId: block.id, + blockType: block.type, + name: block.name, + action, + isDeployed, + removed: !isDeployed, + deploymentType: 'custom_block', + deploymentStatus: { + customBlock: { + isDeployed, + blockType: block.type, + name: block.name, + enabled: block.enabled, + }, + }, + deploymentConfig: { + customBlock: { + blockType: block.type, + name: block.name, + description: block.description, + iconUrl: block.iconUrl, + inputFields: block.inputFields, + exposedOutputs: block.exposedOutputs, + organizationId: block.organizationId, + }, + }, + } +} + +export async function executeDeployCustomBlock( + params: DeployCustomBlockParams, + context: ExecutionContext +): Promise { + try { + const workflowId = params.workflowId || context.workflowId + if (!workflowId) { + return { success: false, error: 'workflowId is required' } + } + const action = params.action === 'undeploy' ? 'undeploy' : 'deploy' + + let workflowRecord: Awaited>['workflow'] + try { + workflowRecord = (await ensureWorkflowAccess(workflowId, context.userId, 'admin')).workflow + } catch (error) { + const message = toError(error).message + if (message.includes('not found')) { + return { success: false, error: message } + } + return { + success: false, + error: "Managing a custom block requires admin permission on the workflow's workspace", + } + } + const workspaceId = workflowRecord.workspaceId + if (!workspaceId) { + return { success: false, error: 'Workflow must belong to a workspace' } + } + + const ws = await getWorkspaceWithOwner(workspaceId) + const organizationId = ws?.organizationId + if (!organizationId) { + return { + success: false, + error: 'Publishing a block requires the workspace to belong to an organization', + } + } + if ( + !(await isFeatureEnabled('deploy-as-block', { + userId: context.userId, + orgId: organizationId, + })) + ) { + return { success: false, error: 'Custom blocks are not enabled for this organization' } + } + + const existing = await getCustomBlockWithInputsByWorkflowId(workflowId) + + if (action === 'undeploy') { + if (!existing) { + return { success: false, error: 'This workflow is not published as a custom block' } + } + await deleteCustomBlock(existing.id) + recordAudit({ + workspaceId, + actorId: context.userId, + action: AuditAction.CUSTOM_BLOCK_DELETED, + resourceType: AuditResourceType.CUSTOM_BLOCK, + resourceId: existing.id, + resourceName: existing.name, + description: `Unpublished custom block "${existing.name}"`, + metadata: { organizationId, type: existing.type, workflowId, source: 'copilot' }, + }) + return { success: true, output: customBlockOutput(existing, 'undeploy') } + } + + const name = params.name?.trim() + const description = params.description?.trim() + if (name && name.length > 60) { + return { success: false, error: 'name must be 60 characters or fewer' } + } + if (description && description.length > 280) { + return { success: false, error: 'description must be 280 characters or fewer' } + } + if (params.inputs && params.inputs.length > MAX_INPUT_ENTRIES) { + return { success: false, error: `inputs must be ${MAX_INPUT_ENTRIES} entries or fewer` } + } + if (params.inputs?.some((entry) => !entry?.id?.trim())) { + return { success: false, error: 'each inputs entry requires the trigger field id' } + } + if (params.inputs?.some((entry) => (entry.placeholder?.length ?? 0) > 200)) { + return { success: false, error: 'input placeholders must be 200 characters or fewer' } + } + if (params.exposedOutputs && params.exposedOutputs.length > MAX_OUTPUT_ENTRIES) { + return { + success: false, + error: `exposedOutputs must be ${MAX_OUTPUT_ENTRIES} entries or fewer`, + } + } + if ( + params.exposedOutputs?.some( + (entry) => !entry?.blockId?.trim() || !entry?.path?.trim() || !entry?.name?.trim() + ) + ) { + return { + success: false, + error: 'each exposedOutputs entry requires blockId, path, and name', + } + } + if (params.exposedOutputs?.some((entry) => entry.name.length > 60)) { + return { success: false, error: 'exposed output names must be 60 characters or fewer' } + } + const iconUrl = await resolveIconUrl(params.iconUrl, context.userId, workspaceId) + + if (existing) { + await updateCustomBlock(existing.id, { + name: name || undefined, + description, + iconUrl, + inputs: params.inputs, + exposedOutputs: params.exposedOutputs, + }) + const updated = await getCustomBlockWithInputsByWorkflowId(workflowId) + if (!updated) { + return { success: false, error: 'Custom block not found after update' } + } + recordAudit({ + workspaceId, + actorId: context.userId, + action: AuditAction.CUSTOM_BLOCK_UPDATED, + resourceType: AuditResourceType.CUSTOM_BLOCK, + resourceId: updated.id, + resourceName: updated.name, + description: `Updated custom block "${updated.name}"`, + metadata: { organizationId, type: updated.type, workflowId, source: 'copilot' }, + }) + return { success: true, output: { ...customBlockOutput(updated, 'deploy'), updated: true } } + } + + if (!(await isOrganizationOnEnterprisePlan(organizationId))) { + return { success: false, error: 'Custom blocks require an enterprise plan' } + } + if (!name) { + return { success: false, error: 'name is required when publishing a new custom block' } + } + if (!workflowRecord.isDeployed) { + return { + success: false, + error: + 'Workflow must be deployed before publishing as a custom block. Use deploy_api first.', + } + } + const block = await publishCustomBlock({ + organizationId, + workspaceId, + workflowId, + userId: context.userId, + name, + description: description ?? '', + iconUrl, + inputs: params.inputs, + exposedOutputs: params.exposedOutputs, + }) + recordAudit({ + workspaceId, + actorId: context.userId, + action: AuditAction.CUSTOM_BLOCK_PUBLISHED, + resourceType: AuditResourceType.CUSTOM_BLOCK, + resourceId: block.id, + resourceName: block.name, + description: `Published custom block "${block.name}"`, + metadata: { organizationId, type: block.type, workflowId, source: 'copilot' }, + }) + return { success: true, output: { ...customBlockOutput(block, 'deploy'), updated: false } } + } catch (error) { + if (error instanceof CustomBlockValidationError) { + return { success: false, error: error.message } + } + return { success: false, error: toError(error).message } + } +} diff --git a/apps/sim/lib/copilot/tools/handlers/param-types.ts b/apps/sim/lib/copilot/tools/handlers/param-types.ts index da437b4993a..19eae663ba8 100644 --- a/apps/sim/lib/copilot/tools/handlers/param-types.ts +++ b/apps/sim/lib/copilot/tools/handlers/param-types.ts @@ -173,6 +173,24 @@ export interface DeployMcpParams { parameterDescriptions?: Array<{ name: string; description: string }> } +export interface DeployCustomBlockParams { + workflowId?: string + action?: 'deploy' | 'undeploy' + /** Block display name (max 60 chars). Required on first publish. */ + name?: string + /** Block-picker description (max 280 chars). */ + description?: string + /** Icon image URL; omit for the organization's default icon. */ + iconUrl?: string + /** + * Per-input placeholder overrides keyed by the input trigger field's stable id. + * The field set itself is always derived from the workflow's deployment. + */ + inputs?: Array<{ id: string; placeholder?: string }> + /** Curated outputs; omit to expose the terminal block's whole result. */ + exposedOutputs?: Array<{ blockId: string; path: string; name: string }> +} + export interface CheckDeploymentStatusParams { workflowId?: string } diff --git a/apps/sim/lib/mothership/inbox/executor.ts b/apps/sim/lib/mothership/inbox/executor.ts index 86ac2312b2d..903018024d6 100644 --- a/apps/sim/lib/mothership/inbox/executor.ts +++ b/apps/sim/lib/mothership/inbox/executor.ts @@ -13,6 +13,7 @@ import { } from '@/lib/copilot/chat/persisted-message' import { generateWorkspaceContext } from '@/lib/copilot/chat/workspace-context' import { chatPubSub } from '@/lib/copilot/chat-status' +import { computeWorkspaceEntitlements } from '@/lib/copilot/entitlements' import { runHeadlessCopilotLifecycle } from '@/lib/copilot/request/lifecycle/headless' import { requestChatTitle } from '@/lib/copilot/request/lifecycle/start' import type { OrchestratorResult } from '@/lib/copilot/request/types' @@ -208,14 +209,21 @@ export async function executeInboxTask(taskId: string): Promise { return { attachments, ...downloaded } } - const [attachmentResult, workspaceContext, integrationTools, userSkillTool, userPermission] = - await Promise.all([ - fetchAttachments(), - generateWorkspaceContext(ws.id, userId), - buildIntegrationToolSchemas(userId, undefined, undefined, ws.id), - buildUserSkillTool(ws.id), - getUserEntityPermissions(userId, 'workspace', ws.id).catch(() => null), - ]) + const [ + attachmentResult, + workspaceContext, + integrationTools, + userSkillTool, + userPermission, + entitlements, + ] = await Promise.all([ + fetchAttachments(), + generateWorkspaceContext(ws.id, userId), + buildIntegrationToolSchemas(userId, undefined, undefined, ws.id), + buildUserSkillTool(ws.id), + getUserEntityPermissions(userId, 'workspace', ws.id).catch(() => null), + computeWorkspaceEntitlements(ws.id, userId), + ]) const { attachments, fileAttachments, storedAttachments } = attachmentResult const truncatedTask = { @@ -237,6 +245,7 @@ export async function executeInboxTask(taskId: string): Promise { ...(integrationTools.length > 0 ? { integrationTools } : {}), ...(userSkillTool ? { mothershipTools: [userSkillTool] } : {}), ...(userPermission ? { userPermission } : {}), + ...(entitlements.length > 0 ? { entitlements } : {}), ...(fileAttachments.length > 0 ? { fileAttachments } : {}), } diff --git a/apps/sim/lib/workflows/custom-blocks/operations.ts b/apps/sim/lib/workflows/custom-blocks/operations.ts index 28d7dc5af35..5e5786d68bb 100644 --- a/apps/sim/lib/workflows/custom-blocks/operations.ts +++ b/apps/sim/lib/workflows/custom-blocks/operations.ts @@ -19,15 +19,36 @@ const logger = createLogger('CustomBlocksOperations') * enterprise plan). Applying it in every org-scoped resolver keeps execution, the * copilot VFS, and workspace context from surfacing blocks the API withholds (e.g. * after an org drops off the enterprise plan). Returns `null` when ineligible. + * + * Pass `userId` when the caller acts for a specific user so per-user flag + * targeting matches the REST routes; workspace-scoped resolvers (VFS, context, + * executor overlay) omit it and evaluate at org level. */ -async function eligibleOrgForWorkspace(workspaceId: string): Promise { +async function eligibleOrgForWorkspace( + workspaceId: string, + userId?: string +): Promise { const ws = await getWorkspaceWithOwner(workspaceId, { includeArchived: true }) if (!ws?.organizationId) return null - if (!(await isFeatureEnabled('deploy-as-block', { orgId: ws.organizationId }))) return null + if (!(await isFeatureEnabled('deploy-as-block', { userId, orgId: ws.organizationId }))) { + return null + } if (!(await isOrganizationOnEnterprisePlan(ws.organizationId))) return null return ws.organizationId } +/** + * Whether the workspace's org may use custom blocks (`deploy-as-block` flag + + * enterprise plan). Feeds the 'custom-blocks' entitlement in + * `@/lib/copilot/entitlements` and matches the REST route gates. + */ +export async function isCustomBlocksEligible( + workspaceId: string, + userId?: string +): Promise { + return (await eligibleOrgForWorkspace(workspaceId, userId)) !== null +} + /** A persisted custom block plus its live-derived Start input fields. */ export interface CustomBlockWithInputs { id: string @@ -159,6 +180,37 @@ export async function listCustomBlockSummariesForWorkspace( .where(and(eq(customBlock.organizationId, organizationId), eq(customBlock.enabled, true))) } +/** + * Hydrate a joined custom-block row into the wire shape. Field set derived live + * from the deployed Start; stored placeholders merged in. Derive even for a + * disabled block — the source workflow's deployment is independent of the block's + * enabled flag, and the edit form needs the real fields so a save doesn't + * overwrite the block's stored placeholders. + */ +async function hydrateCustomBlockRow(joined: { + block: typeof customBlock.$inferSelect + workflowName: string + workspaceId: string | null + workspaceName: string | null +}): Promise { + const { block: row, workflowName, workspaceId, workspaceName } = joined + return { + id: row.id, + organizationId: row.organizationId, + workflowId: row.workflowId, + workflowName, + workspaceId, + workspaceName, + type: row.type, + name: row.name, + description: row.description, + iconUrl: row.iconUrl, + enabled: row.enabled, + inputFields: applyInputPlaceholders(row.inputs, await deriveInputFields(row.workflowId)), + exposedOutputs: row.outputs ?? [], + } +} + /** The org's custom blocks with live-derived input fields (client overlay + list API). */ export async function listCustomBlocksWithInputs( organizationId: string @@ -175,27 +227,30 @@ export async function listCustomBlocksWithInputs( .leftJoin(workspace, eq(workspace.id, workflow.workspaceId)) .where(eq(customBlock.organizationId, organizationId)) - return Promise.all( - rows.map(async ({ block: row, workflowName, workspaceId, workspaceName }) => ({ - id: row.id, - organizationId: row.organizationId, - workflowId: row.workflowId, - workflowName, - workspaceId, - workspaceName, - type: row.type, - name: row.name, - description: row.description, - iconUrl: row.iconUrl, - enabled: row.enabled, - // Field set derived live from the deployed Start; stored placeholders merged - // in. Derive even for a disabled block — the source workflow's deployment is - // independent of the block's enabled flag, and the edit form needs the real - // fields so a save doesn't overwrite the block's stored placeholders. - inputFields: applyInputPlaceholders(row.inputs, await deriveInputFields(row.workflowId)), - exposedOutputs: row.outputs ?? [], - })) - ) + return Promise.all(rows.map(hydrateCustomBlockRow)) +} + +/** + * The custom block bound to a workflow (with live-derived input fields), or `null` + * when the workflow isn't published as a block. One block per workflow is enforced + * at publish time. Used by the copilot deploy_custom_block tool. + */ +export async function getCustomBlockWithInputsByWorkflowId( + workflowId: string +): Promise { + const [row] = await db + .select({ + block: customBlock, + workflowName: workflow.name, + workspaceId: workflow.workspaceId, + workspaceName: workspace.name, + }) + .from(customBlock) + .innerJoin(workflow, eq(workflow.id, customBlock.workflowId)) + .leftJoin(workspace, eq(workspace.id, workflow.workspaceId)) + .where(eq(customBlock.workflowId, workflowId)) + .limit(1) + return row ? hydrateCustomBlockRow(row) : null } /** Fetch a single custom block row by id. */ diff --git a/apps/sim/package.json b/apps/sim/package.json index f58c8e61b80..d3c8e6b473b 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -213,7 +213,7 @@ "twilio": "5.9.0", "undici": "7.28.0", "unpdf": "1.4.0", - "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", + "xlsx": "npm:@e965/xlsx@0.20.3", "zod": "4.3.6", "zustand": "^5.0.13" }, diff --git a/bun.lock b/bun.lock index 0f9ba9efcf1..ebd17dbdbfc 100644 --- a/bun.lock +++ b/bun.lock @@ -282,7 +282,7 @@ "twilio": "5.9.0", "undici": "7.28.0", "unpdf": "1.4.0", - "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", + "xlsx": "npm:@e965/xlsx@0.20.3", "zod": "4.3.6", "zustand": "^5.0.13", }, @@ -4068,7 +4068,7 @@ "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], - "xlsx": ["xlsx@https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", { "bin": { "xlsx": "./bin/xlsx.njs" } }], + "xlsx": ["@e965/xlsx@0.20.3", "", { "bin": { "xlsx": "bin/xlsx.njs" } }, "sha512-703RN/3OdsRD5mtse2HBX7Um7xwaP9tlswEG6svOtjqokXoX7rJdQj7DyabD2I+xk22RgaIIU+R6BHgkpZGB/w=="], "xml": ["xml@1.0.1", "", {}, "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw=="],