diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 4a4c24a1594..e87ffbcbd7b 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 deployment type (api/chat/mcp/custom_block) and ALL user-specified options: identifier, title, description, authType, password, allowedEmails, welcomeMessage, outputConfigs (block outputs to display), and for custom blocks the block name, description, icon URL, and exposed outputs.', 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..3753edce6f1 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -318,7 +318,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { 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 deployment type (api/chat/mcp/custom_block) and ALL user-specified options: identifier, title, description, authType, password, allowedEmails, welcomeMessage, outputConfigs (block outputs to display), and for custom blocks the block name, description, icon URL, and exposed outputs.', type: 'string', }, }, @@ -570,6 +570,133 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { ], }, }, + 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', 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..91e3f81be6a --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.test.ts @@ -0,0 +1,309 @@ +/** + * @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('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('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..dc1e568bba0 --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts @@ -0,0 +1,243 @@ +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 + +/** + * 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' + + const { workflow: workflowRecord } = await ensureWorkflowAccess( + workflowId, + context.userId, + 'admin' + ) + 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' } + } + if (!(await isOrganizationOnEnterprisePlan(organizationId))) { + return { success: false, error: 'Custom blocks require an enterprise plan' } + } + + 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' } + } + const iconUrl = await resolveIconUrl(params.iconUrl, context.userId, workspaceId) + + if (existing) { + await updateCustomBlock(existing.id, { + name, + 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 (!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/workflows/custom-blocks/operations.ts b/apps/sim/lib/workflows/custom-blocks/operations.ts index 28d7dc5af35..f90e71efb69 100644 --- a/apps/sim/lib/workflows/custom-blocks/operations.ts +++ b/apps/sim/lib/workflows/custom-blocks/operations.ts @@ -159,6 +159,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 +206,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 dd211bc8798..4a18b86ceb7 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -212,7 +212,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 6bdcb6cb463..d64e241065b 100644 --- a/bun.lock +++ b/bun.lock @@ -280,7 +280,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", }, @@ -4000,7 +4000,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=="],