diff --git a/apps/sim/app/api/chat/utils.ts b/apps/sim/app/api/chat/utils.ts index 67ab219ac4..2e12204959 100644 --- a/apps/sim/app/api/chat/utils.ts +++ b/apps/sim/app/api/chat/utils.ts @@ -11,6 +11,7 @@ import { hasAdminPermission } from '@/lib/permissions/utils' import { processStreamingBlockLogs } from '@/lib/tokenization' import { getEmailDomain } from '@/lib/urls/utils' import { decryptSecret, generateRequestId } from '@/lib/utils' +import { TriggerUtils } from '@/lib/workflows/triggers' import { getBlock } from '@/blocks' import { db } from '@/db' import { chat, userStats, workflow } from '@/db/schema' @@ -613,9 +614,29 @@ export async function executeWorkflowForChat( // Set up logging on the executor loggingSession.setupExecutor(executor) + // Determine the start block for chat execution + const startBlock = TriggerUtils.findStartBlock(mergedStates, 'chat') + + if (!startBlock) { + const errorMessage = + 'No Chat trigger configured for this workflow. Add a Chat Trigger block to enable chat execution.' + logger.error(`[${requestId}] ${errorMessage}`) + await loggingSession.safeCompleteWithError({ + endedAt: new Date().toISOString(), + totalDurationMs: 0, + error: { + message: errorMessage, + stackTrace: undefined, + }, + }) + throw new Error(errorMessage) + } + + const startBlockId = startBlock.blockId + let result try { - result = await executor.execute(workflowId) + result = await executor.execute(workflowId, startBlockId) } catch (error: any) { logger.error(`[${requestId}] Chat workflow execution failed:`, error) await loggingSession.safeCompleteWithError({ diff --git a/apps/sim/app/api/workflows/[id]/execute/route.test.ts b/apps/sim/app/api/workflows/[id]/execute/route.test.ts index 8d7aceb531..00626d98c4 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.test.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.test.ts @@ -292,7 +292,7 @@ describe('Workflow Execution API Route', () => { const Executor = (await import('@/executor')).Executor expect(Executor).toHaveBeenCalled() - expect(executeMock).toHaveBeenCalledWith('workflow-id') + expect(executeMock).toHaveBeenCalledWith('workflow-id', 'starter-id') }) /** @@ -337,7 +337,7 @@ describe('Workflow Execution API Route', () => { const Executor = (await import('@/executor')).Executor expect(Executor).toHaveBeenCalled() - expect(executeMock).toHaveBeenCalledWith('workflow-id') + expect(executeMock).toHaveBeenCalledWith('workflow-id', 'starter-id') expect(Executor).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index e71ae5f620..d77ba91b27 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -12,6 +12,7 @@ import { LoggingSession } from '@/lib/logs/execution/logging-session' import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans' import { decryptSecret, generateRequestId } from '@/lib/utils' import { loadDeployedWorkflowState } from '@/lib/workflows/db-helpers' +import { TriggerUtils } from '@/lib/workflows/triggers' import { createHttpResponseFromBlock, updateWorkflowRunCounts, @@ -272,6 +273,32 @@ async function executeWorkflow( true // Enable validation during execution ) + // Determine API trigger start block + // Direct API execution ONLY works with API trigger blocks (or legacy starter in api/run mode) + const startBlock = TriggerUtils.findStartBlock(mergedStates, 'api', false) // isChildWorkflow = false + + if (!startBlock) { + logger.error(`[${requestId}] No API trigger configured for this workflow`) + throw new Error( + 'No API trigger configured for this workflow. Add an API Trigger block or use a Start block in API mode.' + ) + } + + const startBlockId = startBlock.blockId + const triggerBlock = startBlock.block + + // Check if the API trigger has any outgoing connections (except for legacy starter blocks) + // Legacy starter blocks have their own validation in the executor + if (triggerBlock.type !== 'starter') { + const outgoingConnections = serializedWorkflow.connections.filter( + (conn) => conn.source === startBlockId + ) + if (outgoingConnections.length === 0) { + logger.error(`[${requestId}] API trigger has no outgoing connections`) + throw new Error('API Trigger block must be connected to other blocks to execute') + } + } + const executor = new Executor({ workflow: serializedWorkflow, currentBlockStates: processedBlockStates, @@ -287,7 +314,7 @@ async function executeWorkflow( // Set up logging on the executor loggingSession.setupExecutor(executor) - const result = await executor.execute(workflowId) + const result = await executor.execute(workflowId, startBlockId) // Check if we got a StreamingExecution result (with stream + execution properties) // For API routes, we only care about the ExecutionResult part, not the stream diff --git a/apps/sim/app/api/workflows/[id]/yaml/route.ts b/apps/sim/app/api/workflows/[id]/yaml/route.ts index 54e33237fa..122eaf48f7 100644 --- a/apps/sim/app/api/workflows/[id]/yaml/route.ts +++ b/apps/sim/app/api/workflows/[id]/yaml/route.ts @@ -240,6 +240,58 @@ async function upsertCustomToolsFromBlocks( } } +/** + * Convert blocks with 'inputs' field to standard 'subBlocks' structure + * This handles trigger blocks that may come from YAML/copilot with legacy format + */ +function normalizeBlockStructure(blocks: Record): Record { + const normalizedBlocks: Record = {} + + for (const [blockId, block] of Object.entries(blocks)) { + const normalizedBlock = { ...block } + + // Check if this is a trigger block with 'inputs' field + if (block.inputs && ( + block.type === 'api_trigger' || + block.type === 'input_trigger' || + block.type === 'starter' || + block.type === 'chat_trigger' || + block.type === 'generic_webhook' + )) { + // Convert inputs.inputFormat to subBlocks.inputFormat + if (block.inputs.inputFormat) { + if (!normalizedBlock.subBlocks) { + normalizedBlock.subBlocks = {} + } + + normalizedBlock.subBlocks.inputFormat = { + id: 'inputFormat', + type: 'input-format', + value: block.inputs.inputFormat + } + } + + // Copy any other inputs fields to subBlocks + for (const [inputKey, inputValue] of Object.entries(block.inputs)) { + if (inputKey !== 'inputFormat' && !normalizedBlock.subBlocks[inputKey]) { + normalizedBlock.subBlocks[inputKey] = { + id: inputKey, + type: 'short-input', // Default type, may need adjustment based on actual field + value: inputValue + } + } + } + + // Remove the inputs field after conversion + delete normalizedBlock.inputs + } + + normalizedBlocks[blockId] = normalizedBlock + } + + return normalizedBlocks +} + /** * PUT /api/workflows/[id]/yaml * Consolidated YAML workflow saving endpoint @@ -344,6 +396,11 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ }) } + // Normalize blocks that use 'inputs' field to standard 'subBlocks' structure + if (workflowState.blocks) { + workflowState.blocks = normalizeBlockStructure(workflowState.blocks) + } + // Ensure all blocks have required fields Object.values(workflowState.blocks).forEach((block: any) => { if (block.enabled === undefined) { diff --git a/apps/sim/app/api/workflows/route.ts b/apps/sim/app/api/workflows/route.ts index eff74ee62f..7d95b4398b 100644 --- a/apps/sim/app/api/workflows/route.ts +++ b/apps/sim/app/api/workflows/route.ts @@ -5,7 +5,7 @@ import { getSession } from '@/lib/auth' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' import { db } from '@/db' -import { workflow, workflowBlocks, workspace } from '@/db/schema' +import { workflow, workspace } from '@/db/schema' import { verifyWorkspaceMembership } from './utils' const logger = createLogger('WorkflowAPI') @@ -95,132 +95,31 @@ export async function POST(req: NextRequest) { const { name, description, color, workspaceId, folderId } = CreateWorkflowSchema.parse(body) const workflowId = crypto.randomUUID() - const starterId = crypto.randomUUID() const now = new Date() logger.info(`[${requestId}] Creating workflow ${workflowId} for user ${session.user.id}`) - await db.transaction(async (tx) => { - await tx.insert(workflow).values({ - id: workflowId, - userId: session.user.id, - workspaceId: workspaceId || null, - folderId: folderId || null, - name, - description, - color, - lastSynced: now, - createdAt: now, - updatedAt: now, - isDeployed: false, - collaborators: [], - runCount: 0, - variables: {}, - isPublished: false, - marketplaceData: null, - }) - - await tx.insert(workflowBlocks).values({ - id: starterId, - workflowId: workflowId, - type: 'starter', - name: 'Start', - positionX: '100', - positionY: '100', - enabled: true, - horizontalHandles: true, - isWide: false, - advancedMode: false, - triggerMode: false, - height: '95', - subBlocks: { - startWorkflow: { - id: 'startWorkflow', - type: 'dropdown', - value: 'manual', - }, - webhookPath: { - id: 'webhookPath', - type: 'short-input', - value: '', - }, - webhookSecret: { - id: 'webhookSecret', - type: 'short-input', - value: '', - }, - scheduleType: { - id: 'scheduleType', - type: 'dropdown', - value: 'daily', - }, - minutesInterval: { - id: 'minutesInterval', - type: 'short-input', - value: '', - }, - minutesStartingAt: { - id: 'minutesStartingAt', - type: 'short-input', - value: '', - }, - hourlyMinute: { - id: 'hourlyMinute', - type: 'short-input', - value: '', - }, - dailyTime: { - id: 'dailyTime', - type: 'short-input', - value: '', - }, - weeklyDay: { - id: 'weeklyDay', - type: 'dropdown', - value: 'MON', - }, - weeklyDayTime: { - id: 'weeklyDayTime', - type: 'short-input', - value: '', - }, - monthlyDay: { - id: 'monthlyDay', - type: 'short-input', - value: '', - }, - monthlyTime: { - id: 'monthlyTime', - type: 'short-input', - value: '', - }, - cronExpression: { - id: 'cronExpression', - type: 'short-input', - value: '', - }, - timezone: { - id: 'timezone', - type: 'dropdown', - value: 'UTC', - }, - }, - outputs: { - response: { - type: { - input: 'any', - }, - }, - }, - createdAt: now, - updatedAt: now, - }) - - logger.info( - `[${requestId}] Successfully created workflow ${workflowId} with start block in workflow_blocks table` - ) + await db.insert(workflow).values({ + id: workflowId, + userId: session.user.id, + workspaceId: workspaceId || null, + folderId: folderId || null, + name, + description, + color, + lastSynced: now, + createdAt: now, + updatedAt: now, + isDeployed: false, + collaborators: [], + runCount: 0, + variables: {}, + isPublished: false, + marketplaceData: null, }) + logger.info(`[${requestId}] Successfully created empty workflow ${workflowId}`) + return NextResponse.json({ id: workflowId, name, diff --git a/apps/sim/app/api/workspaces/route.ts b/apps/sim/app/api/workspaces/route.ts index ddb4246e74..434032fdbb 100644 --- a/apps/sim/app/api/workspaces/route.ts +++ b/apps/sim/app/api/workspaces/route.ts @@ -3,7 +3,7 @@ import { NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { createLogger } from '@/lib/logs/console/logger' import { db } from '@/db' -import { permissions, workflow, workflowBlocks, workspace } from '@/db/schema' +import { permissions, workflow, workspace } from '@/db/schema' const logger = createLogger('Workspaces') @@ -110,9 +110,7 @@ async function createWorkspace(userId: string, name: string) { updatedAt: now, }) - // Create initial workflow for the workspace with start block - const starterId = crypto.randomUUID() - + // Create initial workflow for the workspace (empty canvas) // Create the workflow await tx.insert(workflow).values({ id: workflowId, @@ -133,61 +131,7 @@ async function createWorkspace(userId: string, name: string) { marketplaceData: null, }) - // Insert the start block into workflow_blocks table - await tx.insert(workflowBlocks).values({ - id: starterId, - workflowId: workflowId, - type: 'starter', - name: 'Start', - positionX: '100', - positionY: '100', - enabled: true, - horizontalHandles: true, - isWide: false, - advancedMode: false, - height: '95', - subBlocks: { - startWorkflow: { - id: 'startWorkflow', - type: 'dropdown', - value: 'manual', - }, - webhookPath: { - id: 'webhookPath', - type: 'short-input', - value: '', - }, - webhookSecret: { - id: 'webhookSecret', - type: 'short-input', - value: '', - }, - scheduleType: { - id: 'scheduleType', - type: 'dropdown', - value: 'daily', - }, - minutesInterval: { - id: 'minutesInterval', - type: 'short-input', - value: '', - }, - minutesStartingAt: { - id: 'minutesStartingAt', - type: 'short-input', - value: '', - }, - }, - outputs: { - response: { - type: { - input: 'any', - }, - }, - }, - createdAt: now, - updatedAt: now, - }) + // No blocks are inserted - empty canvas logger.info( `Created workspace ${workspaceId} with initial workflow ${workflowId} for user ${userId}` diff --git a/apps/sim/app/api/yaml/diff/create/route.ts b/apps/sim/app/api/yaml/diff/create/route.ts index 627d539e13..e7ec007fce 100644 --- a/apps/sim/app/api/yaml/diff/create/route.ts +++ b/apps/sim/app/api/yaml/diff/create/route.ts @@ -61,6 +61,58 @@ const CreateDiffRequestSchema = z.object({ .optional(), }) +/** + * Convert blocks with 'inputs' field to standard 'subBlocks' structure + * This handles trigger blocks that may come from YAML/copilot with legacy format + */ +function normalizeBlockStructure(blocks: Record): Record { + const normalizedBlocks: Record = {} + + for (const [blockId, block] of Object.entries(blocks)) { + const normalizedBlock = { ...block } + + // Check if this is a trigger block with 'inputs' field + if (block.inputs && ( + block.type === 'api_trigger' || + block.type === 'input_trigger' || + block.type === 'starter' || + block.type === 'chat_trigger' || + block.type === 'generic_webhook' + )) { + // Convert inputs.inputFormat to subBlocks.inputFormat + if (block.inputs.inputFormat) { + if (!normalizedBlock.subBlocks) { + normalizedBlock.subBlocks = {} + } + + normalizedBlock.subBlocks.inputFormat = { + id: 'inputFormat', + type: 'input-format', + value: block.inputs.inputFormat + } + } + + // Copy any other inputs fields to subBlocks + for (const [inputKey, inputValue] of Object.entries(block.inputs)) { + if (inputKey !== 'inputFormat' && !normalizedBlock.subBlocks[inputKey]) { + normalizedBlock.subBlocks[inputKey] = { + id: inputKey, + type: 'short-input', // Default type, may need adjustment based on actual field + value: inputValue + } + } + } + + // Remove the inputs field after conversion + delete normalizedBlock.inputs + } + + normalizedBlocks[blockId] = normalizedBlock + } + + return normalizedBlocks +} + export async function POST(request: NextRequest) { const requestId = generateRequestId() @@ -202,6 +254,9 @@ export async function POST(request: NextRequest) { const finalResult = result if (result.success && result.diff?.proposedState) { + // Normalize blocks that use 'inputs' field to standard 'subBlocks' structure + result.diff.proposedState.blocks = normalizeBlockStructure(result.diff.proposedState.blocks) + // First, fix parent-child relationships based on edges const blocks = result.diff.proposedState.blocks const edges = result.diff.proposedState.edges || [] @@ -271,6 +326,9 @@ export async function POST(request: NextRequest) { if (result.success && result.blocks && !result.diff) { logger.info(`[${requestId}] Transforming sim agent blocks response to diff format`) + // Normalize blocks that use 'inputs' field to standard 'subBlocks' structure + result.blocks = normalizeBlockStructure(result.blocks) + // First, fix parent-child relationships based on edges const blocks = result.blocks const edges = result.edges || [] diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/deploy-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/deploy-modal.tsx index d589b78cec..59f0b5aaa2 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/deploy-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/deploy-modal.tsx @@ -85,10 +85,16 @@ export function DeployModal({ let inputFormatExample = '' try { const blocks = Object.values(useWorkflowStore.getState().blocks) + + // Check for API trigger block first (takes precedence) + const apiTriggerBlock = blocks.find((block) => block.type === 'api_trigger') + // Fall back to legacy starter block const starterBlock = blocks.find((block) => block.type === 'starter') - if (starterBlock) { - const inputFormat = useSubBlockStore.getState().getValue(starterBlock.id, 'inputFormat') + const targetBlock = apiTriggerBlock || starterBlock + + if (targetBlock) { + const inputFormat = useSubBlockStore.getState().getValue(targetBlock.id, 'inputFormat') if (inputFormat && Array.isArray(inputFormat) && inputFormat.length > 0) { const exampleData: Record = {} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/chat.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/chat.tsx index cdad8823ad..de576b808c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/chat.tsx @@ -330,6 +330,25 @@ export function Chat({ chatMessage, setChatMessage }: ChatProps) { if (event === 'final' && data) { const result = data as ExecutionResult + + // If final result is a failure, surface error and stop + if ('success' in result && !result.success) { + addMessage({ + content: `Error: ${result.error || 'Workflow execution failed'}`, + workflowId: activeWorkflowId, + type: 'workflow', + }) + + // Clear any existing message streams + for (const msgId of messageIdMap.values()) { + finalizeMessageStream(msgId) + } + messageIdMap.clear() + + // Stop processing + return + } + const nonStreamingLogs = result.logs?.filter((log) => !messageIdMap.has(log.blockId)) || [] @@ -343,34 +362,25 @@ export function Chat({ chatMessage, setChatMessage }: ChatProps) { const blockIdForOutput = extractBlockIdFromOutputId(outputId) const path = extractPathFromOutputId(outputId, blockIdForOutput) const log = nonStreamingLogs.find((l) => l.blockId === blockIdForOutput) - if (log) { - let outputValue: any = log.output - + let output = log.output if (path) { - // Parse JSON content safely - outputValue = parseOutputContentSafely(outputValue) - + output = parseOutputContentSafely(output) const pathParts = path.split('.') + let current = output for (const part of pathParts) { - if ( - outputValue && - typeof outputValue === 'object' && - part in outputValue - ) { - outputValue = outputValue[part] + if (current && typeof current === 'object' && part in current) { + current = current[part] } else { - outputValue = undefined + current = undefined break } } + output = current } - if (outputValue !== undefined) { + if (output !== undefined) { addMessage({ - content: - typeof outputValue === 'string' - ? outputValue - : `\`\`\`json\n${JSON.stringify(outputValue, null, 2)}\n\`\`\``, + content: typeof output === 'string' ? output : JSON.stringify(output), workflowId: activeWorkflowId, type: 'workflow', }) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/console/components/console-entry/console-entry.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/console/components/console-entry/console-entry.tsx index 82ccf17ac8..3062c08732 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/console/components/console-entry/console-entry.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/console/components/console-entry/console-entry.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { format } from 'date-fns' import { AlertCircle, + AlertTriangle, Check, ChevronDown, ChevronUp, @@ -369,8 +370,10 @@ export function ConsoleEntry({ entry, consoleWidth }: ConsoleEntryProps) { } }, [showCopySuccess]) - const BlockIcon = blockConfig?.icon - const blockColor = blockConfig?.bgColor || '#6B7280' + // Special handling for serialization errors + const BlockIcon = entry.blockType === 'serializer' ? AlertTriangle : blockConfig?.icon + const blockColor = + entry.blockType === 'serializer' ? '#EF4444' : blockConfig?.bgColor || '#6B7280' // Handle image load error callback const handleImageLoadError = (hasError: boolean) => { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/trigger-selector/trigger-placeholder.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/trigger-selector/trigger-placeholder.tsx new file mode 100644 index 0000000000..9051476ec8 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/trigger-selector/trigger-placeholder.tsx @@ -0,0 +1,46 @@ +'use client' + +import { Plus, Zap } from 'lucide-react' +import { cn } from '@/lib/utils' + +interface TriggerPlaceholderProps { + onClick: () => void + className?: string +} + +export function TriggerPlaceholder({ onClick, className }: TriggerPlaceholderProps) { + return ( +
+ +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/trigger-selector/trigger-selector-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/trigger-selector/trigger-selector-modal.tsx new file mode 100644 index 0000000000..ba9374f04c --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/trigger-selector/trigger-selector-modal.tsx @@ -0,0 +1,152 @@ +'use client' + +import { useMemo, useState } from 'react' +import { Search } from 'lucide-react' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { cn } from '@/lib/utils' +import type { TriggerInfo } from '@/lib/workflows/trigger-utils' +import { getAllTriggerBlocks, getTriggerDisplayName } from '@/lib/workflows/trigger-utils' + +interface TriggerSelectorModalProps { + open: boolean + onClose: () => void + onSelect: (triggerId: string, enableTriggerMode?: boolean) => void +} + +export function TriggerSelectorModal({ open, onClose, onSelect }: TriggerSelectorModalProps) { + const [hoveredId, setHoveredId] = useState(null) + const [searchQuery, setSearchQuery] = useState('') + + // Get all trigger options from the centralized source + const triggerOptions = useMemo(() => getAllTriggerBlocks(), []) + + const filteredOptions = useMemo(() => { + if (!searchQuery.trim()) return triggerOptions + + const query = searchQuery.toLowerCase() + return triggerOptions.filter( + (option) => + option.name.toLowerCase().includes(query) || + option.description.toLowerCase().includes(query) + ) + }, [searchQuery, triggerOptions]) + + const coreOptions = useMemo( + () => filteredOptions.filter((opt) => opt.category === 'core'), + [filteredOptions] + ) + + const integrationOptions = useMemo( + () => filteredOptions.filter((opt) => opt.category === 'integration'), + [filteredOptions] + ) + + const TriggerOptionCard = ({ option }: { option: TriggerInfo }) => { + const Icon = option.icon + const isHovered = hoveredId === option.id + return ( + + ) + } + + return ( + !open && onClose()}> + + + + How do you want to trigger this workflow? + +

+ Choose how your workflow will be started. You can add more triggers later from the + sidebar. +

+
+ +
+ {/* Search Input */} +
+ + setSearchQuery(e.target.value)} + className='h-6 flex-1 border-0 bg-transparent px-0 text-muted-foreground text-sm leading-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0' + autoComplete='off' + autoCorrect='off' + autoCapitalize='off' + spellCheck='false' + /> +
+
+ +
+ {/* Core Triggers Section */} + {coreOptions.length > 0 && ( + <> +

Core Triggers

+
+ {coreOptions.map((option) => ( + + ))} +
+ + )} + + {/* Integration Triggers Section */} + {integrationOptions.length > 0 && ( + <> +

+ Integration Triggers +

+
+ {integrationOptions.map((option) => ( + + ))} +
+ + )} + + {filteredOptions.length === 0 && ( +
+ No triggers found matching "{searchQuery}" +
+ )} +
+
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/trigger-warning-dialog.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/trigger-warning-dialog.tsx new file mode 100644 index 0000000000..2b8c2a55ed --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/trigger-warning-dialog.tsx @@ -0,0 +1,60 @@ +import { + AlertDialog, + AlertDialogAction, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' + +export enum TriggerWarningType { + DUPLICATE_TRIGGER = 'duplicate_trigger', + LEGACY_INCOMPATIBILITY = 'legacy_incompatibility', +} + +interface TriggerWarningDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + triggerName: string + type: TriggerWarningType +} + +export function TriggerWarningDialog({ + open, + onOpenChange, + triggerName, + type, +}: TriggerWarningDialogProps) { + const getTitle = () => { + switch (type) { + case TriggerWarningType.LEGACY_INCOMPATIBILITY: + return 'Cannot mix trigger types' + case TriggerWarningType.DUPLICATE_TRIGGER: + return `Only one ${triggerName} trigger allowed` + } + } + + const getDescription = () => { + switch (type) { + case TriggerWarningType.LEGACY_INCOMPATIBILITY: + return 'Cannot add new trigger blocks when a legacy Start block exists. Available in newer workflows.' + case TriggerWarningType.DUPLICATE_TRIGGER: + return `A workflow can only have one ${triggerName} trigger block. Please remove the existing one before adding a new one.` + } + } + + return ( + + + + {getTitle()} + {getDescription()} + + + onOpenChange(false)}>Got it + + + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/index.ts index d132f528e0..2d16bb19ef 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/index.ts @@ -10,6 +10,7 @@ export { EvalInput } from './eval-input' export { FileSelectorInput } from './file-selector/file-selector-input' export { FileUpload } from './file-upload' export { FolderSelectorInput } from './folder-selector/components/folder-selector-input' +export { InputMapping } from './input-mapping/input-mapping' export { KnowledgeBaseSelector } from './knowledge-base-selector/knowledge-base-selector' export { LongInput } from './long-input' export { McpDynamicArgs } from './mcp-dynamic-args/mcp-dynamic-args' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/input-mapping/input-mapping.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/input-mapping/input-mapping.tsx new file mode 100644 index 0000000000..a2d1f536df --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/input-mapping/input-mapping.tsx @@ -0,0 +1,312 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { formatDisplayText } from '@/components/ui/formatted-text' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { checkTagTrigger, TagDropdown } from '@/components/ui/tag-dropdown' +import { cn } from '@/lib/utils' +import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' + +interface InputFormatField { + name: string + type?: string +} + +interface InputTriggerBlock { + type: 'input_trigger' + subBlocks?: { + inputFormat?: { value?: InputFormatField[] } + } +} + +function isInputTriggerBlock(value: unknown): value is InputTriggerBlock { + return ( + !!value && typeof value === 'object' && (value as { type?: unknown }).type === 'input_trigger' + ) +} + +function isInputFormatField(value: unknown): value is InputFormatField { + if (typeof value !== 'object' || value === null) return false + if (!('name' in value)) return false + const { name, type } = value as { name: unknown; type?: unknown } + if (typeof name !== 'string' || name.trim() === '') return false + if (type !== undefined && typeof type !== 'string') return false + return true +} + +interface InputMappingProps { + blockId: string + subBlockId: string + isPreview?: boolean + previewValue?: any + disabled?: boolean +} + +// Simple mapping UI: for each field in child Input Trigger's inputFormat, render an input with TagDropdown support +export function InputMapping({ + blockId, + subBlockId, + isPreview = false, + previewValue, + disabled = false, +}: InputMappingProps) { + const [mapping, setMapping] = useSubBlockValue(blockId, subBlockId) + const [selectedWorkflowId] = useSubBlockValue(blockId, 'workflowId') + + const { workflows } = useWorkflowRegistry.getState() + + // Fetch child workflow state via registry API endpoint, using cached metadata when possible + // Here we rely on live store; the serializer/executor will resolve at runtime too. + // We only need the inputFormat from an Input Trigger in the selected child workflow state. + const [childInputFields, setChildInputFields] = useState>( + [] + ) + + useEffect(() => { + let isMounted = true + const controller = new AbortController() + async function fetchChildSchema() { + try { + if (!selectedWorkflowId) { + if (isMounted) setChildInputFields([]) + return + } + const res = await fetch(`/api/workflows/${selectedWorkflowId}`, { + signal: controller.signal, + }) + if (!res.ok) { + if (isMounted) setChildInputFields([]) + return + } + const { data } = await res.json() + const blocks = (data?.state?.blocks as Record) || {} + const triggerEntry = Object.entries(blocks).find(([, b]) => isInputTriggerBlock(b)) + if (!triggerEntry) { + if (isMounted) setChildInputFields([]) + return + } + const triggerBlock = triggerEntry[1] + if (!isInputTriggerBlock(triggerBlock)) { + if (isMounted) setChildInputFields([]) + return + } + const inputFormat = triggerBlock.subBlocks?.inputFormat?.value + if (Array.isArray(inputFormat)) { + const fields = (inputFormat as unknown[]) + .filter(isInputFormatField) + .map((f) => ({ name: f.name, type: f.type })) + if (isMounted) setChildInputFields(fields) + } else { + if (isMounted) setChildInputFields([]) + } + } catch { + if (isMounted) setChildInputFields([]) + } + } + fetchChildSchema() + return () => { + isMounted = false + controller.abort() + } + }, [selectedWorkflowId]) + + const valueObj: Record = useMemo(() => { + if (isPreview && previewValue && typeof previewValue === 'object') return previewValue + if (mapping && typeof mapping === 'object') return mapping as Record + try { + if (typeof mapping === 'string') return JSON.parse(mapping) + } catch {} + return {} + }, [mapping, isPreview, previewValue]) + + const update = (field: string, value: string) => { + if (disabled) return + const updated = { ...valueObj, [field]: value } + setMapping(updated) + } + + if (!selectedWorkflowId) { + return ( +
+ + + +

No workflow selected

+

+ Select a workflow above to configure inputs +

+
+ ) + } + + if (!childInputFields || childInputFields.length === 0) { + return ( +
+ + + +

No input fields defined

+

+ The selected workflow needs an Input Trigger with defined fields +

+
+ ) + } + + return ( +
+ {childInputFields.map((field) => { + return ( + update(field.name, value)} + blockId={blockId} + subBlockId={subBlockId} + disabled={isPreview || disabled} + /> + ) + })} +
+ ) +} + +// Individual field component with TagDropdown support +function InputMappingField({ + fieldName, + fieldType, + value, + onChange, + blockId, + subBlockId, + disabled, +}: { + fieldName: string + fieldType?: string + value: string + onChange: (value: string) => void + blockId: string + subBlockId: string + disabled: boolean +}) { + const [showTags, setShowTags] = useState(false) + const [cursorPosition, setCursorPosition] = useState(0) + const inputRef = useRef(null) + const overlayRef = useRef(null) + + const handleChange = (e: React.ChangeEvent) => { + if (disabled) { + e.preventDefault() + return + } + + const newValue = e.target.value + const newCursorPosition = e.target.selectionStart ?? 0 + + onChange(newValue) + setCursorPosition(newCursorPosition) + + // Check for tag trigger + const tagTrigger = checkTagTrigger(newValue, newCursorPosition) + setShowTags(tagTrigger.show) + } + + // Sync scroll position between input and overlay + const handleScroll = (e: React.UIEvent) => { + if (overlayRef.current) { + overlayRef.current.scrollLeft = e.currentTarget.scrollLeft + } + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + setShowTags(false) + } + } + + const handleTagSelect = (newValue: string) => { + onChange(newValue) + } + + return ( +
+
+ + {fieldType && ( + + {fieldType} + + )} +
+
+ { + setShowTags(false) + }} + onBlur={() => { + setShowTags(false) + }} + onScroll={handleScroll} + onKeyDown={handleKeyDown} + autoComplete='off' + style={{ overflowX: 'auto' }} + disabled={disabled} + /> +
+
+ {formatDisplayText(value, true)} +
+
+ + { + setShowTags(false) + }} + /> +
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/sub-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/sub-block.tsx index c8a49a5d89..4511803a25 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/sub-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/sub-block.tsx @@ -18,6 +18,7 @@ import { FileUpload, FolderSelectorInput, InputFormat, + InputMapping, KnowledgeBaseSelector, LongInput, McpDynamicArgs, @@ -450,6 +451,17 @@ export function SubBlock({ /> ) } + case 'input-mapping': { + return ( + + ) + } case 'response-format': return ( ) {

Description

{config.longDescription}

- {config.outputs && ( + {config.outputs && Object.keys(config.outputs).length > 0 && (

Output

diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts index 8fa2a1321f..e5ca24009d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts @@ -3,7 +3,7 @@ import { v4 as uuidv4 } from 'uuid' import { createLogger } from '@/lib/logs/console/logger' import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans' import { processStreamingBlockLogs } from '@/lib/tokenization' -import { getBlock } from '@/blocks' +import { TriggerUtils } from '@/lib/workflows/triggers' import type { BlockOutput } from '@/blocks/types' import { Executor } from '@/executor' import type { BlockLog, ExecutionResult, StreamingExecution } from '@/executor/types' @@ -447,7 +447,30 @@ export function useWorkflowExecution() { ) } } catch (error: any) { - controller.error(error) + // Create a proper error result for logging + const errorResult = { + success: false, + error: error.message || 'Workflow execution failed', + output: {}, + logs: [], + metadata: { + duration: 0, + startTime: new Date().toISOString(), + source: 'chat' as const, + }, + } + + // Send the error as final event so downstream handlers can treat it uniformly + controller.enqueue( + encoder.encode(`data: ${JSON.stringify({ event: 'final', data: errorResult })}\n\n`) + ) + + // Persist the error to logs so it shows up in the logs page + persistLogs(executionId, errorResult).catch((err) => + logger.error('Error persisting error logs:', err) + ) + + // Do not error the controller to allow consumers to process the final event } finally { controller.close() setIsExecuting(false) @@ -560,22 +583,14 @@ export function useWorkflowExecution() { } }) - // Filter out trigger blocks for manual execution + // Do not filter out trigger blocks; executor may need to start from them const filteredStates = Object.entries(mergedStates).reduce( (acc, [id, block]) => { - // Skip blocks with undefined type if (!block || !block.type) { logger.warn(`Skipping block with undefined type: ${id}`, block) return acc } - - const blockConfig = getBlock(block.type) - const isTriggerBlock = blockConfig?.category === 'triggers' - - // Skip trigger blocks during manual execution - if (!isTriggerBlock) { - acc[id] = block - } + acc[id] = block return acc }, {} as typeof mergedStates @@ -632,15 +647,8 @@ export function useWorkflowExecution() { {} as Record ) - // Filter edges to exclude connections to/from trigger blocks - const triggerBlockIds = Object.keys(mergedStates).filter((id) => { - const blockConfig = getBlock(mergedStates[id].type) - return blockConfig?.category === 'triggers' - }) - - const filteredEdges = workflowEdges.filter( - (edge) => !triggerBlockIds.includes(edge.source) && !triggerBlockIds.includes(edge.target) - ) + // Keep edges intact to allow execution starting from trigger blocks + const filteredEdges = workflowEdges // Derive subflows from the current filtered graph to avoid stale state const runtimeLoops = generateLoopBlocks(filteredStates) @@ -663,12 +671,158 @@ export function useWorkflowExecution() { selectedOutputIds = chatStore.getState().getSelectedWorkflowOutput(activeWorkflowId) } - // Create executor options + // Determine start block and workflow input based on execution type + let startBlockId: string | undefined + let finalWorkflowInput = workflowInput + + if (isExecutingFromChat) { + // For chat execution, find the appropriate chat trigger + const startBlock = TriggerUtils.findStartBlock(filteredStates, 'chat') + + if (!startBlock) { + throw new Error(TriggerUtils.getTriggerValidationMessage('chat', 'missing')) + } + + startBlockId = startBlock.blockId + } else { + // For manual editor runs: look for Manual trigger OR API trigger + const entries = Object.entries(filteredStates) + + // Find manual triggers and API triggers + const manualTriggers = TriggerUtils.findTriggersByType(filteredStates, 'manual') + const apiTriggers = TriggerUtils.findTriggersByType(filteredStates, 'api') + + logger.info('Manual run trigger check:', { + manualTriggersCount: manualTriggers.length, + apiTriggersCount: apiTriggers.length, + manualTriggers: manualTriggers.map((t) => ({ + type: t.type, + name: t.name, + isLegacy: t.type === 'starter', + })), + apiTriggers: apiTriggers.map((t) => ({ + type: t.type, + name: t.name, + isLegacy: t.type === 'starter', + })), + }) + + let selectedTrigger: any = null + let selectedBlockId: string | null = null + + // Check for API triggers first (they take precedence over manual triggers) + if (apiTriggers.length === 1) { + selectedTrigger = apiTriggers[0] + const blockEntry = entries.find(([, block]) => block === selectedTrigger) + if (blockEntry) { + selectedBlockId = blockEntry[0] + + // Extract test values from the API trigger's inputFormat + if (selectedTrigger.type === 'api_trigger' || selectedTrigger.type === 'starter') { + const inputFormatValue = selectedTrigger.subBlocks?.inputFormat?.value + if (Array.isArray(inputFormatValue)) { + const testInput: Record = {} + inputFormatValue.forEach((field: any) => { + if (field && typeof field === 'object' && field.name && field.value !== undefined) { + testInput[field.name] = field.value + } + }) + + // Use the test input as workflow input + if (Object.keys(testInput).length > 0) { + finalWorkflowInput = testInput + logger.info('Using API trigger test values for manual run:', testInput) + } + } + } + } + } else if (apiTriggers.length > 1) { + const error = new Error('Multiple API Trigger blocks found. Keep only one.') + logger.error('Multiple API triggers found') + setIsExecuting(false) + throw error + } else if (manualTriggers.length === 1) { + // No API trigger, check for manual trigger + selectedTrigger = manualTriggers[0] + const blockEntry = entries.find(([, block]) => block === selectedTrigger) + if (blockEntry) { + selectedBlockId = blockEntry[0] + } + } else if (manualTriggers.length > 1) { + const error = new Error('Multiple Input Trigger blocks found. Keep only one.') + logger.error('Multiple input triggers found') + setIsExecuting(false) + throw error + } else { + // Fallback: Check for legacy starter block + const starterBlock = Object.values(filteredStates).find((block) => block.type === 'starter') + if (starterBlock) { + // Found a legacy starter block, use it as a manual trigger + const blockEntry = Object.entries(filteredStates).find( + ([, block]) => block === starterBlock + ) + if (blockEntry) { + selectedBlockId = blockEntry[0] + selectedTrigger = starterBlock + logger.info('Using legacy starter block for manual run') + } + } + + if (!selectedBlockId || !selectedTrigger) { + const error = new Error('Manual run requires an Input Trigger or API Trigger block') + logger.error('No input or API triggers found for manual run') + setIsExecuting(false) + throw error + } + } + + if (selectedBlockId && selectedTrigger) { + startBlockId = selectedBlockId + + // Check if the trigger has any outgoing connections (except for legacy starter blocks) + // Legacy starter blocks have their own validation in the executor + if (selectedTrigger.type !== 'starter') { + const outgoingConnections = workflowEdges.filter((edge) => edge.source === startBlockId) + if (outgoingConnections.length === 0) { + const triggerName = selectedTrigger.name || selectedTrigger.type + const error = new Error(`${triggerName} must be connected to other blocks to execute`) + logger.error('Trigger has no outgoing connections', { triggerName, startBlockId }) + setIsExecuting(false) + throw error + } + } + + logger.info('Trigger found for manual run:', { + startBlockId, + triggerType: selectedTrigger.type, + triggerName: selectedTrigger.name, + isLegacyStarter: selectedTrigger.type === 'starter', + usingTestValues: selectedTrigger.type === 'api_trigger', + }) + } + } + + // If we don't have a valid startBlockId at this point, throw an error + if (!startBlockId) { + const error = new Error('No valid trigger block found to start execution') + logger.error('No startBlockId found after trigger search') + setIsExecuting(false) + throw error + } + + // Log the final startBlockId + logger.info('Final execution setup:', { + startBlockId, + isExecutingFromChat, + hasWorkflowInput: !!workflowInput, + }) + + // Create executor options with the final workflow input const executorOptions: ExecutorOptions = { workflow, currentBlockStates, envVarValues, - workflowInput, + workflowInput: finalWorkflowInput, workflowVariables, contextExtensions: { stream: isExecutingFromChat, @@ -687,8 +841,8 @@ export function useWorkflowExecution() { const newExecutor = new Executor(executorOptions) setExecutor(newExecutor) - // Execute workflow - return newExecutor.execute(activeWorkflowId || '') + // Execute workflow with the determined start block + return newExecutor.execute(activeWorkflowId || '', startBlockId) } const handleExecutionError = (error: any, options?: { executionId?: string }) => { @@ -729,7 +883,7 @@ export function useWorkflowExecution() { try { // Prefer attributing to specific subflow if we have a structured error let blockId = 'serialization' - let blockName = 'Serialization' + let blockName = 'Workflow' let blockType = 'serializer' if (error instanceof WorkflowValidationError) { blockId = error.blockId || blockId diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index f51523d0d1..797b950ac0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -13,12 +13,19 @@ import ReactFlow, { } from 'reactflow' import 'reactflow/dist/style.css' import { createLogger } from '@/lib/logs/console/logger' +import { TriggerUtils } from '@/lib/workflows/triggers' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { ControlBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar' import { DiffControls } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/diff-controls' import { ErrorBoundary } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/error/index' import { Panel } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel' import { SubflowNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node' +import { TriggerPlaceholder } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/trigger-selector/trigger-placeholder' +import { TriggerSelectorModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/trigger-selector/trigger-selector-modal' +import { + TriggerWarningDialog, + TriggerWarningType, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/trigger-warning-dialog' import { WorkflowBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block' import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge' import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks' @@ -75,6 +82,20 @@ const WorkflowContent = React.memo(() => { // Enhanced edge selection with parent context and unique identifier const [selectedEdgeInfo, setSelectedEdgeInfo] = useState(null) + // State for trigger warning dialog + const [triggerWarning, setTriggerWarning] = useState<{ + open: boolean + triggerName: string + type: TriggerWarningType + }>({ + open: false, + triggerName: '', + type: TriggerWarningType.DUPLICATE_TRIGGER, + }) + + // State for trigger selector modal + const [showTriggerSelector, setShowTriggerSelector] = useState(false) + // Hooks const params = useParams() const router = useRouter() @@ -100,6 +121,11 @@ const WorkflowContent = React.memo(() => { // Extract workflow data from the abstraction const { blocks, edges, loops, parallels, isDiffMode } = currentWorkflow + // Check if workflow is empty (no blocks) + const isWorkflowEmpty = useMemo(() => { + return Object.keys(blocks).length === 0 + }, [blocks]) + // Get diff analysis for edge reconstruction const { diffAnalysis, isShowingDiff, isDiffReady } = useWorkflowDiffStore() @@ -475,11 +501,27 @@ const WorkflowContent = React.memo(() => { return } - const { type } = event.detail + const { type, enableTriggerMode } = event.detail if (!type) return if (type === 'connectionBlock') return + // Check for single trigger constraint + if (TriggerUtils.wouldViolateSingleInstance(blocks, type)) { + // Check if it's because of a legacy starter block + if (TriggerUtils.hasLegacyStarter(blocks) && TriggerUtils.isAnyTriggerType(type)) { + setTriggerWarning({ + open: true, + triggerName: 'new trigger', + type: TriggerWarningType.LEGACY_INCOMPATIBILITY, + }) + } else { + const triggerName = TriggerUtils.getDefaultTriggerName(type) || 'trigger' + setTriggerWarning({ open: true, triggerName, type: TriggerWarningType.DUPLICATE_TRIGGER }) + } + return + } + // Special handling for container nodes (loop or parallel) if (type === 'loop' || type === 'parallel') { // Create a unique ID and name for the container @@ -549,7 +591,11 @@ const WorkflowContent = React.memo(() => { // Create a new block with a unique ID const id = crypto.randomUUID() - const name = `${blockConfig.name} ${Object.values(blocks).filter((b) => b.type === type).length + 1}` + // Prefer semantic default names for triggers to support , , references + const defaultTriggerName = TriggerUtils.getDefaultTriggerName(type) + const name = + defaultTriggerName || + `${blockConfig.name} ${Object.values(blocks).filter((b) => b.type === type).length + 1}` // Auto-connect logic const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled @@ -573,8 +619,38 @@ const WorkflowContent = React.memo(() => { } } + // Centralized trigger constraints + const additionIssue = TriggerUtils.getTriggerAdditionIssue(blocks, type) + if (additionIssue) { + if (additionIssue.issue === 'legacy') { + setTriggerWarning({ + open: true, + triggerName: additionIssue.triggerName, + type: TriggerWarningType.LEGACY_INCOMPATIBILITY, + }) + } else { + setTriggerWarning({ + open: true, + triggerName: additionIssue.triggerName, + type: TriggerWarningType.DUPLICATE_TRIGGER, + }) + } + return + } + // Add the block to the workflow with auto-connect edge - addBlock(id, type, name, centerPosition, undefined, undefined, undefined, autoConnectEdge) + // Enable trigger mode if this is a trigger-capable block from the triggers tab + addBlock( + id, + type, + name, + centerPosition, + undefined, + undefined, + undefined, + autoConnectEdge, + enableTriggerMode + ) } window.addEventListener('add-block-from-toolbar', handleAddBlockFromToolbar as EventListener) @@ -593,8 +669,37 @@ const WorkflowContent = React.memo(() => { findClosestOutput, determineSourceHandle, effectivePermissions.canEdit, + setTriggerWarning, ]) + // Handler for trigger selection from modal + const handleTriggerSelect = useCallback( + (triggerId: string, enableTriggerMode?: boolean) => { + setShowTriggerSelector(false) + + // Get the trigger name + const triggerName = TriggerUtils.getDefaultTriggerName(triggerId) || triggerId + + // Create the trigger block at the center of the viewport + const centerPosition = project({ x: window.innerWidth / 2, y: window.innerHeight / 2 }) + const id = `${triggerId}_${Date.now()}` + + // Add the trigger block with trigger mode if specified + addBlock( + id, + triggerId, + triggerName, + centerPosition, + undefined, + undefined, + undefined, + undefined, + enableTriggerMode || false + ) + }, + [project, addBlock] + ) + // Update the onDrop handler const onDrop = useCallback( (event: React.DragEvent) => { @@ -604,6 +709,26 @@ const WorkflowContent = React.memo(() => { const data = JSON.parse(event.dataTransfer.getData('application/json')) if (data.type === 'connectionBlock') return + // Check for single trigger constraint + if (TriggerUtils.wouldViolateSingleInstance(blocks, data.type)) { + // Check if it's because of a legacy starter block + if (TriggerUtils.hasLegacyStarter(blocks) && TriggerUtils.isAnyTriggerType(data.type)) { + setTriggerWarning({ + open: true, + triggerName: 'new trigger', + type: TriggerWarningType.LEGACY_INCOMPATIBILITY, + }) + } else { + const triggerName = TriggerUtils.getDefaultTriggerName(data.type) || 'trigger' + setTriggerWarning({ + open: true, + triggerName, + type: TriggerWarningType.DUPLICATE_TRIGGER, + }) + } + return + } + const reactFlowBounds = event.currentTarget.getBoundingClientRect() const position = project({ x: event.clientX - reactFlowBounds.left, @@ -698,12 +823,15 @@ const WorkflowContent = React.memo(() => { // Generate id and name here so they're available in all code paths const id = crypto.randomUUID() + // Prefer semantic default names for triggers to support , , references + const defaultTriggerNameDrop = TriggerUtils.getDefaultTriggerName(data.type) const name = data.type === 'loop' ? `Loop ${Object.values(blocks).filter((b) => b.type === 'loop').length + 1}` : data.type === 'parallel' ? `Parallel ${Object.values(blocks).filter((b) => b.type === 'parallel').length + 1}` - : `${blockConfig!.name} ${Object.values(blocks).filter((b) => b.type === data.type).length + 1}` + : defaultTriggerNameDrop || + `${blockConfig!.name} ${Object.values(blocks).filter((b) => b.type === data.type).length + 1}` if (containerInfo) { // Calculate position relative to the container node @@ -718,10 +846,22 @@ const WorkflowContent = React.memo(() => { ) // Add block with parent info - addBlock(id, data.type, name, relativePosition, { - parentId: containerInfo.loopId, - extent: 'parent', - }) + // Note: Blocks dropped inside containers don't get trigger mode from drag + // since containers don't support trigger blocks + addBlock( + id, + data.type, + name, + relativePosition, + { + parentId: containerInfo.loopId, + extent: 'parent', + }, + undefined, + undefined, + undefined, + false + ) // Resize the container node to fit the new block // Immediate resize without delay @@ -775,6 +915,20 @@ const WorkflowContent = React.memo(() => { } } } else { + // Centralized trigger constraints + const dropIssue = TriggerUtils.getTriggerAdditionIssue(blocks, data.type) + if (dropIssue) { + setTriggerWarning({ + open: true, + triggerName: dropIssue.triggerName, + type: + dropIssue.issue === 'legacy' + ? TriggerWarningType.LEGACY_INCOMPATIBILITY + : TriggerWarningType.DUPLICATE_TRIGGER, + }) + return + } + // Regular auto-connect logic const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled let autoConnectEdge @@ -795,7 +949,19 @@ const WorkflowContent = React.memo(() => { } // Regular canvas drop with auto-connect edge - addBlock(id, data.type, name, position, undefined, undefined, undefined, autoConnectEdge) + // Use enableTriggerMode from drag data if present (when dragging from Triggers tab) + const enableTriggerMode = data.enableTriggerMode || false + addBlock( + id, + data.type, + name, + position, + undefined, + undefined, + undefined, + autoConnectEdge, + enableTriggerMode + ) } } catch (err) { logger.error('Error dropping block:', { err }) @@ -810,6 +976,7 @@ const WorkflowContent = React.memo(() => { determineSourceHandle, isPointInLoopNodeWrapper, getNodes, + setTriggerWarning, ] ) @@ -1676,6 +1843,25 @@ const WorkflowContent = React.memo(() => { {/* Show DiffControls if diff is available (regardless of current view mode) */} + + {/* Trigger warning dialog */} + setTriggerWarning({ ...triggerWarning, open })} + triggerName={triggerWarning.triggerName} + type={triggerWarning.type} + /> + + {/* Trigger selector for empty workflows - only show after workflow has loaded */} + {isWorkflowReady && isWorkflowEmpty && effectivePermissions.canEdit && ( + setShowTriggerSelector(true)} /> + )} + + setShowTriggerSelector(false)} + onSelect={handleTriggerSelect} + />
) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/toolbar/components/toolbar-block/toolbar-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/toolbar/components/toolbar-block/toolbar-block.tsx index 98a9f2fdf0..ad3573d24a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/toolbar/components/toolbar-block/toolbar-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/toolbar/components/toolbar-block/toolbar-block.tsx @@ -7,9 +7,14 @@ import type { BlockConfig } from '@/blocks/types' export type ToolbarBlockProps = { config: BlockConfig disabled?: boolean + enableTriggerMode?: boolean } -export function ToolbarBlock({ config, disabled = false }: ToolbarBlockProps) { +export function ToolbarBlock({ + config, + disabled = false, + enableTriggerMode = false, +}: ToolbarBlockProps) { const userPermissions = useUserPermissionsContext() const handleDragStart = (e: React.DragEvent) => { @@ -17,7 +22,13 @@ export function ToolbarBlock({ config, disabled = false }: ToolbarBlockProps) { e.preventDefault() return } - e.dataTransfer.setData('application/json', JSON.stringify({ type: config.type })) + e.dataTransfer.setData( + 'application/json', + JSON.stringify({ + type: config.type, + enableTriggerMode, + }) + ) e.dataTransfer.effectAllowed = 'move' } @@ -29,10 +40,11 @@ export function ToolbarBlock({ config, disabled = false }: ToolbarBlockProps) { const event = new CustomEvent('add-block-from-toolbar', { detail: { type: config.type, + enableTriggerMode, }, }) window.dispatchEvent(event) - }, [config.type, disabled]) + }, [config.type, disabled, enableTriggerMode]) const blockContent = (
{ - const allBlocks = getAllBlocks() + // Get blocks based on the active tab using centralized logic + const sourceBlocks = activeTab === 'blocks' ? getBlocksForSidebar() : getTriggersForSidebar() // Filter blocks based on search query - const filteredBlocks = allBlocks.filter((block) => { - if (block.type === 'starter' || block.hideFromToolbar) return false - - return ( + const filteredBlocks = sourceBlocks.filter((block) => { + const matchesSearch = !searchQuery.trim() || block.name.toLowerCase().includes(searchQuery.toLowerCase()) || block.description.toLowerCase().includes(searchQuery.toLowerCase()) - ) + + return matchesSearch }) - // Separate blocks by category: 'blocks', 'tools', and 'triggers' + // Separate blocks by category const regularBlockConfigs = filteredBlocks.filter((block) => block.category === 'blocks') const toolConfigs = filteredBlocks.filter((block) => block.category === 'tools') - const triggerConfigs = filteredBlocks.filter((block) => block.category === 'triggers') + // For triggers tab, include both 'triggers' category and tools with trigger capability + const triggerConfigs = + activeTab === 'triggers' + ? filteredBlocks + : filteredBlocks.filter((block) => block.category === 'triggers') // Create regular block items and sort alphabetically const regularBlockItems: BlockItem[] = regularBlockConfigs @@ -54,23 +64,25 @@ export function Toolbar({ userPermissions, isWorkspaceSelectorVisible = false }: })) .sort((a, b) => a.name.localeCompare(b.name)) - // Create special blocks (loop and parallel) if they match search + // Create special blocks (loop and parallel) only for blocks tab const specialBlockItems: BlockItem[] = [] - if (!searchQuery.trim() || 'loop'.toLowerCase().includes(searchQuery.toLowerCase())) { - specialBlockItems.push({ - name: 'Loop', - type: 'loop', - isCustom: true, - }) - } - - if (!searchQuery.trim() || 'parallel'.toLowerCase().includes(searchQuery.toLowerCase())) { - specialBlockItems.push({ - name: 'Parallel', - type: 'parallel', - isCustom: true, - }) + if (activeTab === 'blocks') { + if (!searchQuery.trim() || 'loop'.toLowerCase().includes(searchQuery.toLowerCase())) { + specialBlockItems.push({ + name: 'Loop', + type: 'loop', + isCustom: true, + }) + } + + if (!searchQuery.trim() || 'parallel'.toLowerCase().includes(searchQuery.toLowerCase())) { + specialBlockItems.push({ + name: 'Parallel', + type: 'parallel', + isCustom: true, + }) + } } // Sort special blocks alphabetically @@ -95,65 +107,98 @@ export function Toolbar({ userPermissions, isWorkspaceSelectorVisible = false }: tools: toolConfigs, triggers: triggerBlockItems, } - }, [searchQuery]) + }, [searchQuery, activeTab]) return (
- {/* Search */} -
-
- - setSearchQuery(e.target.value)} - className='h-6 flex-1 border-0 bg-transparent px-0 text-muted-foreground text-sm leading-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0' - autoComplete='off' - autoCorrect='off' - autoCapitalize='off' - spellCheck='false' - /> + {/* Tabs */} + +
+ + + + Blocks + + + + Triggers + +
-
- - {/* Content */} - -
- {/* Regular Blocks Section */} - {regularBlocks.map((block) => ( - - ))} - - {/* Special Blocks Section (Loop & Parallel) */} - {specialBlocks.map((block) => { - if (block.type === 'loop') { - return - } - if (block.type === 'parallel') { - return - } - return null - })} - - {/* Triggers Section */} - {triggers.map((trigger) => ( - - ))} - {/* Tools Section */} - {tools.map((tool) => ( - - ))} + {/* Search */} +
+
+ + setSearchQuery(e.target.value)} + className='h-6 flex-1 border-0 bg-transparent px-0 text-muted-foreground text-sm leading-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0' + autoComplete='off' + autoCorrect='off' + autoCapitalize='off' + spellCheck='false' + /> +
- + + {/* Blocks Tab Content */} + + +
+ {/* Regular Blocks */} + {regularBlocks.map((block) => ( + + ))} + + {/* Special Blocks (Loop & Parallel) */} + {specialBlocks.map((block) => { + if (block.type === 'loop') { + return + } + if (block.type === 'parallel') { + return ( + + ) + } + return null + })} + + {/* Tools */} + {tools.map((tool) => ( + + ))} +
+
+
+ + {/* Triggers Tab Content */} + + +
+ {triggers.length > 0 ? ( + triggers.map((trigger) => ( + + )) + ) : ( +
+ {searchQuery ? 'No triggers found' : 'Add triggers from the workflow canvas'} +
+ )} +
+
+
+
) } diff --git a/apps/sim/blocks/blocks/api_trigger.ts b/apps/sim/blocks/blocks/api_trigger.ts new file mode 100644 index 0000000000..5ef45eeb1d --- /dev/null +++ b/apps/sim/blocks/blocks/api_trigger.ts @@ -0,0 +1,34 @@ +import { ApiIcon } from '@/components/icons' +import type { BlockConfig } from '@/blocks/types' + +export const ApiTriggerBlock: BlockConfig = { + type: 'api_trigger', + name: 'API Trigger', + description: 'Expose as HTTP API endpoint', + longDescription: + 'API trigger to start the workflow via authenticated HTTP calls with structured input.', + category: 'triggers', + bgColor: '#10B981', // Emerald for API + icon: ApiIcon, + subBlocks: [ + { + id: 'inputFormat', + title: 'Input Format', + type: 'input-format', + layout: 'full', + description: 'Define the JSON input schema accepted by the API endpoint.', + }, + ], + tools: { + access: [], + }, + inputs: {}, + outputs: { + // Dynamic outputs will be added from inputFormat at runtime + // Always includes 'input' field plus any fields defined in inputFormat + }, + triggers: { + enabled: true, + available: ['api'], + }, +} diff --git a/apps/sim/blocks/blocks/chat_trigger.ts b/apps/sim/blocks/blocks/chat_trigger.ts new file mode 100644 index 0000000000..7566a2dfc1 --- /dev/null +++ b/apps/sim/blocks/blocks/chat_trigger.ts @@ -0,0 +1,30 @@ +import type { SVGProps } from 'react' +import { createElement } from 'react' +import { MessageCircle } from 'lucide-react' +import type { BlockConfig } from '@/blocks/types' + +const ChatTriggerIcon = (props: SVGProps) => createElement(MessageCircle, props) + +export const ChatTriggerBlock: BlockConfig = { + type: 'chat_trigger', + name: 'Chat Trigger', + description: 'Start workflow from a chat deployment', + longDescription: 'Chat trigger to run the workflow via deployed chat interfaces.', + category: 'triggers', + bgColor: '#8B5CF6', + icon: ChatTriggerIcon, + subBlocks: [], + tools: { + access: [], + }, + inputs: {}, + outputs: { + input: { type: 'string', description: 'User message' }, + conversationId: { type: 'string', description: 'Conversation ID' }, + files: { type: 'array', description: 'Uploaded files' }, + }, + triggers: { + enabled: true, + available: ['chat'], + }, +} diff --git a/apps/sim/blocks/blocks/generic_webhook.ts b/apps/sim/blocks/blocks/generic_webhook.ts index 22a94e13c9..6cb0b548ca 100644 --- a/apps/sim/blocks/blocks/generic_webhook.ts +++ b/apps/sim/blocks/blocks/generic_webhook.ts @@ -1,13 +1,17 @@ -import { WebhookIcon } from '@/components/icons' +import type { SVGProps } from 'react' +import { createElement } from 'react' +import { Webhook } from 'lucide-react' import type { BlockConfig } from '@/blocks/types' +const WebhookIcon = (props: SVGProps) => createElement(Webhook, props) + export const GenericWebhookBlock: BlockConfig = { type: 'generic_webhook', name: 'Webhook', description: 'Receive webhooks from any service by configuring a custom webhook.', category: 'triggers', icon: WebhookIcon, - bgColor: '#10B981', // Green color for triggers + bgColor: '#F97316', // Orange color for webhooks subBlocks: [ // Generic webhook configuration - always visible diff --git a/apps/sim/blocks/blocks/input_trigger.ts b/apps/sim/blocks/blocks/input_trigger.ts new file mode 100644 index 0000000000..b30e06c08a --- /dev/null +++ b/apps/sim/blocks/blocks/input_trigger.ts @@ -0,0 +1,37 @@ +import type { SVGProps } from 'react' +import { createElement } from 'react' +import { Play } from 'lucide-react' +import type { BlockConfig } from '@/blocks/types' + +const InputTriggerIcon = (props: SVGProps) => createElement(Play, props) + +export const InputTriggerBlock: BlockConfig = { + type: 'input_trigger', + name: 'Input Trigger', + description: 'Start workflow manually with a defined input schema', + longDescription: + 'Manually trigger the workflow from the editor with a structured input schema. This enables typed inputs for parent workflows to map into.', + category: 'triggers', + bgColor: '#3B82F6', + icon: InputTriggerIcon, + subBlocks: [ + { + id: 'inputFormat', + title: 'Input Format', + type: 'input-format', + layout: 'full', + description: 'Define the JSON input schema for this workflow when run manually.', + }, + ], + tools: { + access: [], + }, + inputs: {}, + outputs: { + // Dynamic outputs will be derived from inputFormat + }, + triggers: { + enabled: true, + available: ['manual'], + }, +} diff --git a/apps/sim/blocks/blocks/schedule.ts b/apps/sim/blocks/blocks/schedule.ts index b9e7ddafc4..ff3eefaf57 100644 --- a/apps/sim/blocks/blocks/schedule.ts +++ b/apps/sim/blocks/blocks/schedule.ts @@ -1,6 +1,10 @@ -import { ScheduleIcon } from '@/components/icons' +import type { SVGProps } from 'react' +import { createElement } from 'react' +import { Clock } from 'lucide-react' import type { BlockConfig } from '@/blocks/types' +const ScheduleIcon = (props: SVGProps) => createElement(Clock, props) + export const ScheduleBlock: BlockConfig = { type: 'schedule', name: 'Schedule', @@ -8,7 +12,7 @@ export const ScheduleBlock: BlockConfig = { longDescription: 'Integrate Schedule into the workflow. Can trigger a workflow on a schedule configuration.', category: 'triggers', - bgColor: '#7B68EE', + bgColor: '#6366F1', icon: ScheduleIcon, subBlocks: [ diff --git a/apps/sim/blocks/blocks/starter.ts b/apps/sim/blocks/blocks/starter.ts index 7a9ed26d4f..68efe3c013 100644 --- a/apps/sim/blocks/blocks/starter.ts +++ b/apps/sim/blocks/blocks/starter.ts @@ -9,6 +9,7 @@ export const StarterBlock: BlockConfig = { category: 'blocks', bgColor: '#2FB3FF', icon: StartIcon, + hideFromToolbar: true, subBlocks: [ // Main trigger selector { diff --git a/apps/sim/blocks/blocks/workflow.ts b/apps/sim/blocks/blocks/workflow.ts index 69eda686af..734913dbb1 100644 --- a/apps/sim/blocks/blocks/workflow.ts +++ b/apps/sim/blocks/blocks/workflow.ts @@ -80,4 +80,5 @@ export const WorkflowBlock: BlockConfig = { result: { type: 'json', description: 'Workflow execution result' }, error: { type: 'string', description: 'Error message' }, }, + hideFromToolbar: true, } diff --git a/apps/sim/blocks/blocks/workflow_input.ts b/apps/sim/blocks/blocks/workflow_input.ts new file mode 100644 index 0000000000..a8c9d313cf --- /dev/null +++ b/apps/sim/blocks/blocks/workflow_input.ts @@ -0,0 +1,58 @@ +import { WorkflowIcon } from '@/components/icons' +import type { BlockConfig } from '@/blocks/types' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' + +// Helper: list workflows excluding self +const getAvailableWorkflows = (): Array<{ label: string; id: string }> => { + try { + const { workflows, activeWorkflowId } = useWorkflowRegistry.getState() + return Object.entries(workflows) + .filter(([id]) => id !== activeWorkflowId) + .map(([id, w]) => ({ label: w.name || `Workflow ${id.slice(0, 8)}`, id })) + .sort((a, b) => a.label.localeCompare(b.label)) + } catch { + return [] + } +} + +// New workflow block variant that visualizes child Input Trigger schema for mapping +export const WorkflowInputBlock: BlockConfig = { + type: 'workflow_input', + name: 'Workflow', + description: 'Execute another workflow and map variables to its Input Trigger schema.', + category: 'blocks', + bgColor: '#6366F1', // Indigo - modern and professional + icon: WorkflowIcon, + subBlocks: [ + { + id: 'workflowId', + title: 'Select Workflow', + type: 'dropdown', + options: getAvailableWorkflows, + required: true, + }, + // Renders dynamic mapping UI based on selected child workflow's Input Trigger inputFormat + { + id: 'inputMapping', + title: 'Input Mapping', + type: 'input-mapping', + layout: 'full', + description: + "Map fields defined in the child workflow's Input Trigger to variables/values in this workflow.", + dependsOn: ['workflowId'], + }, + ], + tools: { + access: ['workflow_executor'], + }, + inputs: { + workflowId: { type: 'string', description: 'ID of the child workflow' }, + inputMapping: { type: 'json', description: 'Mapping of input fields to values' }, + }, + outputs: { + success: { type: 'boolean', description: 'Execution success status' }, + childWorkflowName: { type: 'string', description: 'Child workflow name' }, + result: { type: 'json', description: 'Workflow execution result' }, + error: { type: 'string', description: 'Error message' }, + }, +} diff --git a/apps/sim/blocks/registry.ts b/apps/sim/blocks/registry.ts index 81080e3680..dcecbab0a3 100644 --- a/apps/sim/blocks/registry.ts +++ b/apps/sim/blocks/registry.ts @@ -6,8 +6,10 @@ import { AgentBlock } from '@/blocks/blocks/agent' import { AirtableBlock } from '@/blocks/blocks/airtable' import { ApiBlock } from '@/blocks/blocks/api' +import { ApiTriggerBlock } from '@/blocks/blocks/api_trigger' import { ArxivBlock } from '@/blocks/blocks/arxiv' import { BrowserUseBlock } from '@/blocks/blocks/browser_use' +import { ChatTriggerBlock } from '@/blocks/blocks/chat_trigger' import { ClayBlock } from '@/blocks/blocks/clay' import { ConditionBlock } from '@/blocks/blocks/condition' import { ConfluenceBlock } from '@/blocks/blocks/confluence' @@ -29,6 +31,7 @@ import { GoogleSheetsBlock } from '@/blocks/blocks/google_sheets' import { HuggingFaceBlock } from '@/blocks/blocks/huggingface' import { HunterBlock } from '@/blocks/blocks/hunter' import { ImageGeneratorBlock } from '@/blocks/blocks/image_generator' +import { InputTriggerBlock } from '@/blocks/blocks/input_trigger' import { JinaBlock } from '@/blocks/blocks/jina' import { JiraBlock } from '@/blocks/blocks/jira' import { KnowledgeBlock } from '@/blocks/blocks/knowledge' @@ -76,6 +79,7 @@ import { WebhookBlock } from '@/blocks/blocks/webhook' import { WhatsAppBlock } from '@/blocks/blocks/whatsapp' import { WikipediaBlock } from '@/blocks/blocks/wikipedia' import { WorkflowBlock } from '@/blocks/blocks/workflow' +import { WorkflowInputBlock } from '@/blocks/blocks/workflow_input' import { XBlock } from '@/blocks/blocks/x' import { YouTubeBlock } from '@/blocks/blocks/youtube' import type { BlockConfig } from '@/blocks/types' @@ -142,6 +146,9 @@ export const registry: Record = { stagehand_agent: StagehandAgentBlock, slack: SlackBlock, starter: StarterBlock, + input_trigger: InputTriggerBlock, + chat_trigger: ChatTriggerBlock, + api_trigger: ApiTriggerBlock, supabase: SupabaseBlock, tavily: TavilyBlock, telegram: TelegramBlock, @@ -155,6 +162,7 @@ export const registry: Record = { whatsapp: WhatsAppBlock, wikipedia: WikipediaBlock, workflow: WorkflowBlock, + workflow_input: WorkflowInputBlock, x: XBlock, youtube: YouTubeBlock, } diff --git a/apps/sim/blocks/types.ts b/apps/sim/blocks/types.ts index 55158b6d89..2108e78bf4 100644 --- a/apps/sim/blocks/types.ts +++ b/apps/sim/blocks/types.ts @@ -54,6 +54,7 @@ export type SubBlockType = | 'input-format' // Input structure format | 'response-format' // Response structure format | 'file-upload' // File uploader + | 'input-mapping' // Map parent variables to child workflow input schema export type SubBlockLayout = 'full' | 'half' diff --git a/apps/sim/components/ui/tag-dropdown.tsx b/apps/sim/components/ui/tag-dropdown.tsx index 1ee1067d01..97ef575310 100644 --- a/apps/sim/components/ui/tag-dropdown.tsx +++ b/apps/sim/components/ui/tag-dropdown.tsx @@ -4,6 +4,7 @@ import { ChevronRight } from 'lucide-react' import { BlockPathCalculator } from '@/lib/block-path-calculator' import { extractFieldsFromSchema, parseResponseFormatSafely } from '@/lib/response-format' import { cn } from '@/lib/utils' +import { getBlockOutputPaths, getBlockOutputType } from '@/lib/workflows/block-outputs' import { getBlock } from '@/blocks' import type { BlockConfig } from '@/blocks/types' import { Serializer } from '@/serializer' @@ -146,6 +147,11 @@ const getOutputTypeForPath = ( return field.type } } + } else if (blockConfig?.category === 'triggers') { + // For trigger blocks, use the dynamic output helper + const blockState = useWorkflowStore.getState().blocks[blockId] + const subBlocks = blockState?.subBlocks || {} + return getBlockOutputType(block.type, outputPath, subBlocks) } else { const operationValue = getSubBlockValue(blockId, 'operation') if (blockConfig && operationValue) { @@ -630,7 +636,29 @@ export const TagDropdown: React.FC = ({ let blockTags: string[] - if (accessibleBlock.type === 'evaluator') { + // For trigger blocks, use the dynamic output helper + if (blockConfig.category === 'triggers' || accessibleBlock.type === 'starter') { + const subBlocks = blocks[accessibleBlockId]?.subBlocks || {} + const dynamicOutputs = getBlockOutputPaths(accessibleBlock.type, subBlocks) + + if (dynamicOutputs.length > 0) { + blockTags = dynamicOutputs.map((path) => `${normalizedBlockName}.${path}`) + } else if (accessibleBlock.type === 'starter') { + // Legacy starter block fallback + const startWorkflowValue = getSubBlockValue(accessibleBlockId, 'startWorkflow') + if (startWorkflowValue === 'chat') { + blockTags = [ + `${normalizedBlockName}.input`, + `${normalizedBlockName}.conversationId`, + `${normalizedBlockName}.files`, + ] + } else { + blockTags = [normalizedBlockName] + } + } else { + blockTags = [] + } + } else if (accessibleBlock.type === 'evaluator') { const metricsValue = getSubBlockValue(accessibleBlockId, 'metrics') if (metricsValue && Array.isArray(metricsValue) && metricsValue.length > 0) { @@ -651,34 +679,7 @@ export const TagDropdown: React.FC = ({ blockTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`) } } else if (!blockConfig.outputs || Object.keys(blockConfig.outputs).length === 0) { - if (accessibleBlock.type === 'starter') { - const startWorkflowValue = getSubBlockValue(accessibleBlockId, 'startWorkflow') - - if (startWorkflowValue === 'chat') { - // For chat mode, provide input, conversationId, and files - blockTags = [ - `${normalizedBlockName}.input`, - `${normalizedBlockName}.conversationId`, - `${normalizedBlockName}.files`, - ] - } else { - const inputFormatValue = getSubBlockValue(accessibleBlockId, 'inputFormat') - - if ( - inputFormatValue && - Array.isArray(inputFormatValue) && - inputFormatValue.length > 0 - ) { - blockTags = inputFormatValue - .filter((field: { name?: string }) => field.name && field.name.trim() !== '') - .map((field: { name: string }) => `${normalizedBlockName}.${field.name}`) - } else { - blockTags = [normalizedBlockName] - } - } - } else { - blockTags = [normalizedBlockName] - } + blockTags = [normalizedBlockName] } else { const blockState = blocks[accessibleBlockId] if (blockState?.triggerMode && blockConfig.triggers?.enabled) { diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.ts b/apps/sim/executor/handlers/workflow/workflow-handler.ts index fc3603417d..d4c8e311bf 100644 --- a/apps/sim/executor/handlers/workflow/workflow-handler.ts +++ b/apps/sim/executor/handlers/workflow/workflow-handler.ts @@ -22,8 +22,20 @@ const MAX_WORKFLOW_DEPTH = 10 export class WorkflowBlockHandler implements BlockHandler { private serializer = new Serializer() + // Tolerant JSON parser for mapping values + // Keeps handler self-contained without introducing utilities + private safeParse(input: unknown): unknown { + if (typeof input !== 'string') return input + try { + return JSON.parse(input) + } catch { + return input + } + } + canHandle(block: SerializedBlock): boolean { - return block.metadata?.id === BlockType.WORKFLOW + const id = block.metadata?.id + return id === BlockType.WORKFLOW || id === 'workflow_input' } async execute( @@ -63,13 +75,22 @@ export class WorkflowBlockHandler implements BlockHandler { ) // Prepare the input for the child workflow - // The input from this block should be passed as start.input to the child workflow - let childWorkflowInput = {} - - if (inputs.input !== undefined) { - // If input is provided, use it directly + // Prefer structured mapping if provided; otherwise fall back to legacy 'input' passthrough + let childWorkflowInput: Record = {} + + if (inputs.inputMapping !== undefined && inputs.inputMapping !== null) { + // Handle inputMapping - could be object or stringified JSON + const raw = inputs.inputMapping + const normalized = this.safeParse(raw) + + if (normalized && typeof normalized === 'object' && !Array.isArray(normalized)) { + childWorkflowInput = normalized as Record + } else { + childWorkflowInput = {} + } + } else if (inputs.input !== undefined) { + // Legacy behavior: pass under start.input childWorkflowInput = inputs.input - logger.info(`Passing input to child workflow: ${JSON.stringify(childWorkflowInput)}`) } // Remove the workflowId from the input to avoid confusion @@ -308,10 +329,11 @@ export class WorkflowBlockHandler implements BlockHandler { } return failure as Record } - let result = childResult - if (childResult?.output) { - result = childResult.output - } + + // childResult is an ExecutionResult with structure { success, output, metadata, logs } + // We want the actual output from the execution + const result = childResult.output || {} + return { success: true, childWorkflowName, diff --git a/apps/sim/executor/index.ts b/apps/sim/executor/index.ts index c2777d3542..aad90f6a9e 100644 --- a/apps/sim/executor/index.ts +++ b/apps/sim/executor/index.ts @@ -630,17 +630,25 @@ export class Executor { */ private validateWorkflow(startBlockId?: string): void { if (startBlockId) { - // If starting from a specific block (webhook trigger or schedule trigger), validate that block exists const startBlock = this.actualWorkflow.blocks.find((block) => block.id === startBlockId) if (!startBlock || !startBlock.enabled) { throw new Error(`Start block ${startBlockId} not found or disabled`) } - // Trigger blocks (webhook and schedule) can have incoming connections, so no need to check that + return + } + + const starterBlock = this.actualWorkflow.blocks.find( + (block) => block.metadata?.id === BlockType.STARTER + ) + + const hasTriggerBlocks = this.actualWorkflow.blocks.some((block) => { + return block.metadata?.category === 'triggers' || block.config?.params?.triggerMode === true + }) + + if (hasTriggerBlocks) { + // When triggers exist, we allow execution without a starter block } else { - // Default validation for starter block - const starterBlock = this.actualWorkflow.blocks.find( - (block) => block.metadata?.id === BlockType.STARTER - ) + // Legacy workflows: require a valid starter block and basic connection checks if (!starterBlock || !starterBlock.enabled) { throw new Error('Workflow must have an enabled starter block') } @@ -652,22 +660,15 @@ export class Executor { throw new Error('Starter block cannot have incoming connections') } - // Check if there are any trigger blocks on the canvas - const hasTriggerBlocks = this.actualWorkflow.blocks.some((block) => { - return block.metadata?.category === 'triggers' || block.config?.params?.triggerMode === true - }) - - // Only check outgoing connections for starter blocks if there are no trigger blocks - if (!hasTriggerBlocks) { - const outgoingFromStarter = this.actualWorkflow.connections.filter( - (conn) => conn.source === starterBlock.id - ) - if (outgoingFromStarter.length === 0) { - throw new Error('Starter block must have at least one outgoing connection') - } + const outgoingFromStarter = this.actualWorkflow.connections.filter( + (conn) => conn.source === starterBlock.id + ) + if (outgoingFromStarter.length === 0) { + throw new Error('Starter block must have at least one outgoing connection') } } + // General graph validations const blockIds = new Set(this.actualWorkflow.blocks.map((block) => block.id)) for (const conn of this.actualWorkflow.connections) { if (!blockIds.has(conn.source)) { @@ -762,20 +763,52 @@ export class Executor { // Determine which block to initialize as the starting point let initBlock: SerializedBlock | undefined if (startBlockId) { - // Starting from a specific block (webhook trigger or schedule trigger) + // Starting from a specific block (webhook trigger, schedule trigger, or new trigger blocks) initBlock = this.actualWorkflow.blocks.find((block) => block.id === startBlockId) } else { - // Default to starter block + // Default to starter block (legacy) or find any trigger block initBlock = this.actualWorkflow.blocks.find( (block) => block.metadata?.id === BlockType.STARTER ) + + // If no starter block, look for appropriate trigger block based on context + if (!initBlock) { + if (this.isChildExecution) { + const inputTriggerBlocks = this.actualWorkflow.blocks.filter( + (block) => block.metadata?.id === 'input_trigger' + ) + if (inputTriggerBlocks.length === 1) { + initBlock = inputTriggerBlocks[0] + } else if (inputTriggerBlocks.length > 1) { + throw new Error('Child workflow has multiple Input Trigger blocks. Keep only one.') + } + } else { + // Parent workflows can use any trigger block + const triggerBlocks = this.actualWorkflow.blocks.filter( + (block) => + block.metadata?.id === 'input_trigger' || + block.metadata?.id === 'api_trigger' || + block.metadata?.id === 'chat_trigger' + ) + if (triggerBlocks.length > 0) { + initBlock = triggerBlocks[0] + } + } + } } if (initBlock) { // Initialize the starting block with the workflow input try { + // Get inputFormat from either old location (config.params) or new location (metadata.subBlocks) const blockParams = initBlock.config.params - const inputFormat = blockParams?.inputFormat + let inputFormat = blockParams?.inputFormat + + // For new trigger blocks (api_trigger, etc), inputFormat is in metadata.subBlocks + const metadataWithSubBlocks = initBlock.metadata as any + if (!inputFormat && metadataWithSubBlocks?.subBlocks?.inputFormat?.value) { + inputFormat = metadataWithSubBlocks.subBlocks.inputFormat.value + } // If input format is defined, structure the input according to the schema if (inputFormat && Array.isArray(inputFormat) && inputFormat.length > 0) { @@ -841,11 +874,31 @@ export class Executor { // Use the structured input if we processed fields, otherwise use raw input const finalInput = hasProcessedFields ? structuredInput : rawInputData - // Initialize the starting block with structured input (flattened) - const blockOutput = { - input: finalInput, - conversationId: this.workflowInput?.conversationId, // Add conversationId to root - ...finalInput, // Add input fields directly at top level + // Initialize the starting block with structured input + let blockOutput: any + + // For API/Input triggers, normalize primitives and mirror objects under input + if ( + initBlock.metadata?.id === 'api_trigger' || + initBlock.metadata?.id === 'input_trigger' + ) { + const isObject = + finalInput !== null && typeof finalInput === 'object' && !Array.isArray(finalInput) + if (isObject) { + blockOutput = { ...finalInput } + // Provide a mirrored input object for universal references + blockOutput.input = { ...finalInput } + } else { + // Primitive input: only expose under input + blockOutput = { input: finalInput } + } + } else { + // For legacy starter blocks, keep the old behavior + blockOutput = { + input: finalInput, + conversationId: this.workflowInput?.conversationId, // Add conversationId to root + ...finalInput, // Add input fields directly at top level + } } // Add files if present (for all trigger types) @@ -863,54 +916,81 @@ export class Executor { // This ensures files are captured in trace spans and execution logs this.createStartedBlockWithFilesLog(initBlock, blockOutput, context) } else { - // Handle structured input (like API calls or chat messages) - if (this.workflowInput && typeof this.workflowInput === 'object') { - // Check if this is a chat workflow input (has both input and conversationId) - if ( - Object.hasOwn(this.workflowInput, 'input') && - Object.hasOwn(this.workflowInput, 'conversationId') - ) { - // Chat workflow: extract input, conversationId, and files to root level - const starterOutput: any = { - input: this.workflowInput.input, - conversationId: this.workflowInput.conversationId, - } + // Handle triggers without inputFormat + let starterOutput: any + + // Handle different trigger types + if (initBlock.metadata?.id === 'chat_trigger') { + // Chat trigger: extract input, conversationId, and files + starterOutput = { + input: this.workflowInput?.input || '', + conversationId: this.workflowInput?.conversationId || '', + } - // Add files if present - if (this.workflowInput.files && Array.isArray(this.workflowInput.files)) { - starterOutput.files = this.workflowInput.files + if (this.workflowInput?.files && Array.isArray(this.workflowInput.files)) { + starterOutput.files = this.workflowInput.files + } + } else if ( + initBlock.metadata?.id === 'api_trigger' || + initBlock.metadata?.id === 'input_trigger' + ) { + // API/Input trigger without inputFormat: normalize primitives and mirror objects under input + const rawCandidate = + this.workflowInput?.input !== undefined + ? this.workflowInput.input + : this.workflowInput + const isObject = + rawCandidate !== null && + typeof rawCandidate === 'object' && + !Array.isArray(rawCandidate) + if (isObject) { + starterOutput = { + ...(rawCandidate as Record), + input: { ...(rawCandidate as Record) }, } - - context.blockStates.set(initBlock.id, { - output: starterOutput, - executed: true, - executionTime: 0, - }) - - // Create a block log for the starter block if it has files - // This ensures files are captured in trace spans and execution logs - this.createStartedBlockWithFilesLog(initBlock, starterOutput, context) } else { - // API workflow: spread the raw data directly (no wrapping) - const starterOutput = { ...this.workflowInput } - - context.blockStates.set(initBlock.id, { - output: starterOutput, - executed: true, - executionTime: 0, - }) + starterOutput = { input: rawCandidate } } } else { - // Fallback for primitive input values - const starterOutput = { - input: this.workflowInput, + // Legacy starter block handling + if (this.workflowInput && typeof this.workflowInput === 'object') { + // Check if this is a chat workflow input (has both input and conversationId) + if ( + Object.hasOwn(this.workflowInput, 'input') && + Object.hasOwn(this.workflowInput, 'conversationId') + ) { + // Chat workflow: extract input, conversationId, and files to root level + starterOutput = { + input: this.workflowInput.input, + conversationId: this.workflowInput.conversationId, + } + + // Add files if present + if (this.workflowInput.files && Array.isArray(this.workflowInput.files)) { + starterOutput.files = this.workflowInput.files + } + } else { + // API workflow: spread the raw data directly (no wrapping) + starterOutput = { ...this.workflowInput } + } + } else { + // Fallback for primitive input values + starterOutput = { + input: this.workflowInput, + } } + } - context.blockStates.set(initBlock.id, { - output: starterOutput, - executed: true, - executionTime: 0, - }) + context.blockStates.set(initBlock.id, { + output: starterOutput, + executed: true, + executionTime: 0, + }) + + // Create a block log for the starter block if it has files + // This ensures files are captured in trace spans and execution logs + if (starterOutput.files) { + this.createStartedBlockWithFilesLog(initBlock, starterOutput, context) } } } catch (e) { diff --git a/apps/sim/executor/resolver/resolver.ts b/apps/sim/executor/resolver/resolver.ts index 1b96091260..bdb8bc57a8 100644 --- a/apps/sim/executor/resolver/resolver.ts +++ b/apps/sim/executor/resolver/resolver.ts @@ -1,6 +1,7 @@ import { BlockPathCalculator } from '@/lib/block-path-calculator' import { createLogger } from '@/lib/logs/console/logger' import { VariableManager } from '@/lib/variables/variable-manager' +import { TRIGGER_REFERENCE_ALIAS_MAP } from '@/lib/workflows/triggers' import { getBlock } from '@/blocks/index' import type { LoopManager } from '@/executor/loops/loops' import type { ExecutionContext } from '@/executor/types' @@ -520,15 +521,19 @@ export class InputResolver { // System references and regular block references are both processed // Accessibility validation happens later in validateBlockReference - // Special case for "start" references - if (blockRef.toLowerCase() === 'start') { - // Find the starter block - const starterBlock = this.workflow.blocks.find((block) => block.metadata?.id === 'starter') - if (starterBlock) { - const blockState = context.blockStates.get(starterBlock.id) + // Special case for trigger block references (start, api, chat, manual) + const blockRefLower = blockRef.toLowerCase() + const triggerType = + TRIGGER_REFERENCE_ALIAS_MAP[blockRefLower as keyof typeof TRIGGER_REFERENCE_ALIAS_MAP] + if (triggerType) { + const triggerBlock = this.workflow.blocks.find( + (block) => block.metadata?.id === triggerType + ) + if (triggerBlock) { + const blockState = context.blockStates.get(triggerBlock.id) if (blockState) { - // For starter block, start directly with the flattened output - // This enables direct access to and + // For trigger blocks, start directly with the flattened output + // This enables direct access to , , etc let replacementValue: any = blockState.output for (const part of pathParts) { @@ -537,7 +542,7 @@ export class InputResolver { `[resolveBlockReferences] Invalid path "${part}" - replacementValue is not an object:`, replacementValue ) - throw new Error(`Invalid path "${part}" in "${path}" for starter block.`) + throw new Error(`Invalid path "${part}" in "${path}" for trigger block.`) } // Handle array indexing syntax like "files[0]" or "items[1]" @@ -550,14 +555,14 @@ export class InputResolver { const arrayValue = replacementValue[arrayName] if (!Array.isArray(arrayValue)) { throw new Error( - `Property "${arrayName}" is not an array in path "${path}" for starter block.` + `Property "${arrayName}" is not an array in path "${path}" for trigger block.` ) } // Then access the array element if (index < 0 || index >= arrayValue.length) { throw new Error( - `Array index ${index} is out of bounds for "${arrayName}" (length: ${arrayValue.length}) in path "${path}" for starter block.` + `Array index ${index} is out of bounds for "${arrayName}" (length: ${arrayValue.length}) in path "${path}" for trigger block.` ) } @@ -569,17 +574,22 @@ export class InputResolver { if (replacementValue === undefined) { logger.warn( - `[resolveBlockReferences] No value found at path "${part}" in starter block.` + `[resolveBlockReferences] No value found at path "${part}" in trigger block.` ) - throw new Error(`No value found at path "${path}" in starter block.`) + throw new Error(`No value found at path "${path}" in trigger block.`) } } // Format the value based on block type and path let formattedValue: string - // Special handling for all blocks referencing starter input - if (blockRef.toLowerCase() === 'start' && pathParts.join('.').includes('input')) { + // Special handling for all blocks referencing trigger input + // For starter and chat triggers, check for 'input' field. For API trigger, any field access counts + const isTriggerInputRef = + (blockRefLower === 'start' && pathParts.join('.').includes('input')) || + (blockRefLower === 'chat' && pathParts.join('.').includes('input')) || + (blockRefLower === 'api' && pathParts.length > 0) + if (isTriggerInputRef) { const blockType = currentBlock.metadata?.id // Format based on which block is consuming this value diff --git a/apps/sim/hooks/use-collaborative-workflow.ts b/apps/sim/hooks/use-collaborative-workflow.ts index 03ab9b37db..fe1604168c 100644 --- a/apps/sim/hooks/use-collaborative-workflow.ts +++ b/apps/sim/hooks/use-collaborative-workflow.ts @@ -544,7 +544,8 @@ export function useCollaborativeWorkflow() { data?: Record, parentId?: string, extent?: 'parent', - autoConnectEdge?: Edge + autoConnectEdge?: Edge, + triggerMode?: boolean ) => { // Skip socket operations when in diff mode if (isShowingDiff) { @@ -577,6 +578,7 @@ export function useCollaborativeWorkflow() { horizontalHandles: true, isWide: false, advancedMode: false, + triggerMode: triggerMode || false, height: 0, parentId, extent, @@ -586,7 +588,7 @@ export function useCollaborativeWorkflow() { // Skip if applying remote changes if (isApplyingRemoteChange.current) { workflowStore.addBlock(id, type, name, position, data, parentId, extent, { - triggerMode: false, + triggerMode: triggerMode || false, }) if (autoConnectEdge) { workflowStore.addEdge(autoConnectEdge) @@ -611,7 +613,7 @@ export function useCollaborativeWorkflow() { // Apply locally first (immediate UI feedback) workflowStore.addBlock(id, type, name, position, data, parentId, extent, { - triggerMode: false, + triggerMode: triggerMode || false, }) if (autoConnectEdge) { workflowStore.addEdge(autoConnectEdge) @@ -653,7 +655,7 @@ export function useCollaborativeWorkflow() { horizontalHandles: true, isWide: false, advancedMode: false, - triggerMode: false, + triggerMode: triggerMode || false, height: 0, // Default height, will be set by the UI parentId, extent, @@ -680,7 +682,7 @@ export function useCollaborativeWorkflow() { // Apply locally workflowStore.addBlock(id, type, name, position, data, parentId, extent, { - triggerMode: false, + triggerMode: triggerMode || false, }) if (autoConnectEdge) { workflowStore.addEdge(autoConnectEdge) diff --git a/apps/sim/lib/workflows/block-outputs.ts b/apps/sim/lib/workflows/block-outputs.ts new file mode 100644 index 0000000000..f7184c4a71 --- /dev/null +++ b/apps/sim/lib/workflows/block-outputs.ts @@ -0,0 +1,114 @@ +import { getBlock } from '@/blocks' +import type { BlockConfig } from '@/blocks/types' + +/** + * Get the effective outputs for a block, including dynamic outputs from inputFormat + */ +export function getBlockOutputs( + blockType: string, + subBlocks?: Record +): Record { + const blockConfig = getBlock(blockType) + if (!blockConfig) return {} + + // Start with the static outputs defined in the config + let outputs = { ...(blockConfig.outputs || {}) } + + // Special handling for starter block (legacy) + if (blockType === 'starter') { + const startWorkflowValue = subBlocks?.startWorkflow?.value + + if (startWorkflowValue === 'chat') { + // Chat mode outputs + return { + input: { type: 'string', description: 'User message' }, + conversationId: { type: 'string', description: 'Conversation ID' }, + files: { type: 'array', description: 'Uploaded files' }, + } + } + if ( + startWorkflowValue === 'api' || + startWorkflowValue === 'run' || + startWorkflowValue === 'manual' + ) { + // API/manual mode - use inputFormat fields only + const inputFormatValue = subBlocks?.inputFormat?.value + outputs = {} + + if (Array.isArray(inputFormatValue)) { + inputFormatValue.forEach((field: { name?: string; type?: string }) => { + if (field.name && field.name.trim() !== '') { + outputs[field.name] = { + type: (field.type || 'any') as any, + description: `Field from input format`, + } + } + }) + } + + return outputs + } + } + + // For blocks with inputFormat, add dynamic outputs + if (hasInputFormat(blockConfig) && subBlocks?.inputFormat?.value) { + const inputFormatValue = subBlocks.inputFormat.value + + if (Array.isArray(inputFormatValue)) { + // For API and Input triggers, only use inputFormat fields + if (blockType === 'api_trigger' || blockType === 'input_trigger') { + outputs = {} // Clear all default outputs + + // Add each field from inputFormat as an output at root level + inputFormatValue.forEach((field: { name?: string; type?: string }) => { + if (field.name && field.name.trim() !== '') { + outputs[field.name] = { + type: (field.type || 'any') as any, + description: `Field from input format`, + } + } + }) + } + } else if (blockType === 'api_trigger' || blockType === 'input_trigger') { + // If no inputFormat defined, API/Input trigger has no outputs + outputs = {} + } + } + + return outputs +} + +/** + * Check if a block config has an inputFormat sub-block + */ +function hasInputFormat(blockConfig: BlockConfig): boolean { + return blockConfig.subBlocks?.some((sb) => sb.type === 'input-format') || false +} + +/** + * Get output paths for a block (for tag dropdown) + */ +export function getBlockOutputPaths(blockType: string, subBlocks?: Record): string[] { + const outputs = getBlockOutputs(blockType, subBlocks) + return Object.keys(outputs) +} + +/** + * Get the type of a specific output path + */ +export function getBlockOutputType( + blockType: string, + outputPath: string, + subBlocks?: Record +): string { + const outputs = getBlockOutputs(blockType, subBlocks) + const output = outputs[outputPath] + + if (!output) return 'any' + + if (typeof output === 'object' && 'type' in output) { + return output.type + } + + return typeof output === 'string' ? output : 'any' +} diff --git a/apps/sim/lib/workflows/trigger-utils.ts b/apps/sim/lib/workflows/trigger-utils.ts new file mode 100644 index 0000000000..931039188d --- /dev/null +++ b/apps/sim/lib/workflows/trigger-utils.ts @@ -0,0 +1,108 @@ +import { getAllBlocks, getBlock } from '@/blocks' +import type { BlockConfig } from '@/blocks/types' + +export interface TriggerInfo { + id: string + name: string + description: string + icon: React.ComponentType<{ className?: string }> + color: string + category: 'core' | 'integration' + enableTriggerMode?: boolean +} + +/** + * Get all blocks that can act as triggers + * This includes both dedicated trigger blocks and tools with trigger capabilities + */ +export function getAllTriggerBlocks(): TriggerInfo[] { + const allBlocks = getAllBlocks() + const triggers: TriggerInfo[] = [] + + for (const block of allBlocks) { + // Skip hidden blocks + if (block.hideFromToolbar) continue + + // Check if it's a core trigger block (category: 'triggers') + if (block.category === 'triggers') { + triggers.push({ + id: block.type, + name: block.name, + description: block.description, + icon: block.icon, + color: block.bgColor, + category: 'core', + }) + } + // Check if it's a tool with trigger capability (has trigger-config subblock) + else if (hasTriggerCapability(block)) { + triggers.push({ + id: block.type, + name: block.name, + description: block.description.replace(' or trigger workflows from ', ', trigger from '), + icon: block.icon, + color: block.bgColor, + category: 'integration', + enableTriggerMode: true, + }) + } + } + + // Sort: core triggers first, then integration triggers, alphabetically within each category + return triggers.sort((a, b) => { + if (a.category !== b.category) { + return a.category === 'core' ? -1 : 1 + } + return a.name.localeCompare(b.name) + }) +} + +/** + * Check if a block has trigger capability (contains a trigger-config subblock) + */ +export function hasTriggerCapability(block: BlockConfig): boolean { + return block.subBlocks.some((subBlock) => subBlock.type === 'trigger-config') +} + +/** + * Get blocks that should appear in the triggers tab + * This includes all trigger blocks and tools with trigger mode + */ +export function getTriggersForSidebar(): BlockConfig[] { + const allBlocks = getAllBlocks() + return allBlocks.filter((block) => { + if (block.hideFromToolbar) return false + // Include blocks with triggers category or trigger-config subblock + return block.category === 'triggers' || hasTriggerCapability(block) + }) +} + +/** + * Get blocks that should appear in the blocks tab + * This excludes only dedicated trigger blocks, not tools with trigger capability + */ +export function getBlocksForSidebar(): BlockConfig[] { + const allBlocks = getAllBlocks() + return allBlocks.filter((block) => { + if (block.hideFromToolbar) return false + if (block.type === 'starter') return false // Legacy block + // Only exclude blocks with 'triggers' category + // Tools with trigger capability should still appear in blocks tab + return block.category !== 'triggers' + }) +} + +/** + * Get the proper display name for a trigger block in the UI + */ +export function getTriggerDisplayName(blockType: string): string { + const block = getBlock(blockType) + if (!block) return blockType + + // Special case for generic_webhook - show as "Webhook" in UI + if (blockType === 'generic_webhook') { + return 'Webhook' + } + + return block.name +} diff --git a/apps/sim/lib/workflows/triggers.ts b/apps/sim/lib/workflows/triggers.ts new file mode 100644 index 0000000000..9acf44e5e8 --- /dev/null +++ b/apps/sim/lib/workflows/triggers.ts @@ -0,0 +1,344 @@ +import { getBlock } from '@/blocks' + +/** + * Unified trigger type definitions + */ +export const TRIGGER_TYPES = { + INPUT: 'input_trigger', + CHAT: 'chat_trigger', + API: 'api_trigger', + WEBHOOK: 'webhook', + SCHEDULE: 'schedule', + STARTER: 'starter', // Legacy +} as const + +export type TriggerType = (typeof TRIGGER_TYPES)[keyof typeof TRIGGER_TYPES] + +/** + * Mapping from reference alias (used in inline refs like , , etc.) + * to concrete trigger block type identifiers used across the system. + */ +export const TRIGGER_REFERENCE_ALIAS_MAP = { + start: TRIGGER_TYPES.STARTER, + api: TRIGGER_TYPES.API, + chat: TRIGGER_TYPES.CHAT, + manual: TRIGGER_TYPES.INPUT, +} as const + +export type TriggerReferenceAlias = keyof typeof TRIGGER_REFERENCE_ALIAS_MAP + +/** + * Trigger classification and utilities + */ +export class TriggerUtils { + /** + * Check if a block is any kind of trigger + */ + static isTriggerBlock(block: { type: string; triggerMode?: boolean }): boolean { + const blockConfig = getBlock(block.type) + + return ( + // New trigger blocks (explicit category) + blockConfig?.category === 'triggers' || + // Blocks with trigger mode enabled + block.triggerMode === true || + // Legacy starter block + block.type === TRIGGER_TYPES.STARTER + ) + } + + /** + * Check if a block is a specific trigger type + */ + static isTriggerType(block: { type: string }, triggerType: TriggerType): boolean { + return block.type === triggerType + } + + /** + * Check if a type string is any trigger type + */ + static isAnyTriggerType(type: string): boolean { + return Object.values(TRIGGER_TYPES).includes(type as TriggerType) + } + + /** + * Check if a block is a chat-compatible trigger + */ + static isChatTrigger(block: { type: string; subBlocks?: any }): boolean { + if (block.type === TRIGGER_TYPES.CHAT) { + return true + } + + // Legacy: starter block in chat mode + if (block.type === TRIGGER_TYPES.STARTER) { + return block.subBlocks?.startWorkflow?.value === 'chat' + } + + return false + } + + /** + * Check if a block is a manual-compatible trigger + */ + static isManualTrigger(block: { type: string; subBlocks?: any }): boolean { + if (block.type === TRIGGER_TYPES.INPUT) { + return true + } + + // Legacy: starter block in manual mode or without explicit mode (default to manual) + if (block.type === TRIGGER_TYPES.STARTER) { + // If startWorkflow is not set or is set to 'manual', treat as manual trigger + const startWorkflowValue = block.subBlocks?.startWorkflow?.value + return startWorkflowValue === 'manual' || startWorkflowValue === undefined + } + + return false + } + + /** + * Check if a block is an API-compatible trigger + * @param block - Block to check + * @param isChildWorkflow - Whether this is being called from a child workflow context + */ + static isApiTrigger(block: { type: string; subBlocks?: any }, isChildWorkflow = false): boolean { + if (isChildWorkflow) { + // Child workflows (workflow-in-workflow) only work with input_trigger + return block.type === TRIGGER_TYPES.INPUT + } + // Direct API calls only work with api_trigger + if (block.type === TRIGGER_TYPES.API) { + return true + } + + // Legacy: starter block in API mode + if (block.type === TRIGGER_TYPES.STARTER) { + const mode = block.subBlocks?.startWorkflow?.value + return mode === 'api' || mode === 'run' + } + + return false + } + + /** + * Get the default name for a trigger type + */ + static getDefaultTriggerName(triggerType: string): string | null { + // Use the block's actual name from the registry + const block = getBlock(triggerType) + if (block) { + // Special case for generic_webhook - show as "Webhook" in UI + if (triggerType === 'generic_webhook') { + return 'Webhook' + } + return block.name + } + + // Fallback for legacy or unknown types + switch (triggerType) { + case TRIGGER_TYPES.CHAT: + return 'Chat' + case TRIGGER_TYPES.INPUT: + return 'Input Trigger' + case TRIGGER_TYPES.API: + return 'API' + case TRIGGER_TYPES.WEBHOOK: + return 'Webhook' + case TRIGGER_TYPES.SCHEDULE: + return 'Schedule' + default: + return null + } + } + + /** + * Find trigger blocks of a specific type in a workflow + */ + static findTriggersByType( + blocks: T[] | Record, + triggerType: 'chat' | 'manual' | 'api', + isChildWorkflow = false + ): T[] { + const blockArray = Array.isArray(blocks) ? blocks : Object.values(blocks) + + switch (triggerType) { + case 'chat': + return blockArray.filter((block) => TriggerUtils.isChatTrigger(block)) + case 'manual': + return blockArray.filter((block) => TriggerUtils.isManualTrigger(block)) + case 'api': + return blockArray.filter((block) => TriggerUtils.isApiTrigger(block, isChildWorkflow)) + default: + return [] + } + } + + /** + * Find the appropriate start block for a given execution context + */ + static findStartBlock( + blocks: Record, + executionType: 'chat' | 'manual' | 'api', + isChildWorkflow = false + ): { blockId: string; block: T } | null { + const entries = Object.entries(blocks) + + // Look for new trigger blocks first + const triggers = TriggerUtils.findTriggersByType(blocks, executionType, isChildWorkflow) + if (triggers.length > 0) { + const blockId = entries.find(([, b]) => b === triggers[0])?.[0] + if (blockId) { + return { blockId, block: triggers[0] } + } + } + + // Legacy fallback: look for starter block + const starterEntry = entries.find(([, block]) => block.type === TRIGGER_TYPES.STARTER) + if (starterEntry) { + return { blockId: starterEntry[0], block: starterEntry[1] } + } + + return null + } + + /** + * Check if multiple triggers of a restricted type exist + */ + static hasMultipleTriggers( + blocks: T[] | Record, + triggerType: TriggerType + ): boolean { + const blockArray = Array.isArray(blocks) ? blocks : Object.values(blocks) + const count = blockArray.filter((block) => block.type === triggerType).length + return count > 1 + } + + /** + * Check if a trigger type requires single instance constraint + */ + static requiresSingleInstance(triggerType: string): boolean { + // API and Input triggers cannot coexist with each other + // Chat trigger must be unique + // Schedules and webhooks can coexist with API/Input triggers + return ( + triggerType === TRIGGER_TYPES.API || + triggerType === TRIGGER_TYPES.INPUT || + triggerType === TRIGGER_TYPES.CHAT + ) + } + + /** + * Check if a workflow has a legacy starter block + */ + static hasLegacyStarter(blocks: T[] | Record): boolean { + const blockArray = Array.isArray(blocks) ? blocks : Object.values(blocks) + return blockArray.some((block) => block.type === TRIGGER_TYPES.STARTER) + } + + /** + * Check if adding a trigger would violate single instance constraint + */ + static wouldViolateSingleInstance( + blocks: T[] | Record, + triggerType: string + ): boolean { + const blockArray = Array.isArray(blocks) ? blocks : Object.values(blocks) + const hasLegacyStarter = TriggerUtils.hasLegacyStarter(blocks) + + // Legacy starter block can't coexist with Chat, Input, or API triggers + if (hasLegacyStarter) { + if ( + triggerType === TRIGGER_TYPES.CHAT || + triggerType === TRIGGER_TYPES.INPUT || + triggerType === TRIGGER_TYPES.API + ) { + return true + } + // Legacy starter CAN coexist with schedules and webhooks + } + + // Can't add legacy starter if Chat, Input, or API triggers exist + if (triggerType === TRIGGER_TYPES.STARTER) { + const hasModernTriggers = blockArray.some( + (block) => + block.type === TRIGGER_TYPES.CHAT || + block.type === TRIGGER_TYPES.INPUT || + block.type === TRIGGER_TYPES.API + ) + if (hasModernTriggers) { + return true + } + } + + // Multiple schedules are allowed + // Schedules can coexist with anything (except the constraint above with legacy starter) + if (triggerType === TRIGGER_TYPES.SCHEDULE) { + return false // Always allow schedules + } + + // Webhooks can coexist with other triggers (multiple webhooks allowed) + if (triggerType === TRIGGER_TYPES.WEBHOOK) { + return false // Always allow webhooks + } + + // Only one Input trigger allowed + if (triggerType === TRIGGER_TYPES.INPUT) { + return blockArray.some((block) => block.type === TRIGGER_TYPES.INPUT) + } + + // Only one API trigger allowed + if (triggerType === TRIGGER_TYPES.API) { + return blockArray.some((block) => block.type === TRIGGER_TYPES.API) + } + + // Chat trigger must be unique + if (triggerType === TRIGGER_TYPES.CHAT) { + return blockArray.some((block) => block.type === TRIGGER_TYPES.CHAT) + } + + // For other trigger types, check single-instance rules + if (!TriggerUtils.requiresSingleInstance(triggerType)) { + return false + } + + return blockArray.some((block) => block.type === triggerType) + } + + /** + * Evaluate whether adding a trigger of the given type is allowed and, if not, why. + * Returns null if allowed; otherwise returns an object describing the violation. + * This avoids duplicating UI logic across toolbar/drop handlers. + */ + static getTriggerAdditionIssue( + blocks: T[] | Record, + triggerType: string + ): { issue: 'legacy' | 'duplicate'; triggerName: string } | null { + if (!TriggerUtils.wouldViolateSingleInstance(blocks, triggerType)) { + return null + } + + // Legacy starter present + adding modern trigger → legacy incompatibility + if (TriggerUtils.hasLegacyStarter(blocks) && TriggerUtils.isAnyTriggerType(triggerType)) { + return { issue: 'legacy', triggerName: 'new trigger' } + } + + // Otherwise treat as duplicate of a single-instance trigger + const triggerName = TriggerUtils.getDefaultTriggerName(triggerType) || 'trigger' + return { issue: 'duplicate', triggerName } + } + + /** + * Get trigger validation message + */ + static getTriggerValidationMessage( + triggerType: 'chat' | 'manual' | 'api', + issue: 'missing' | 'multiple' + ): string { + const triggerName = triggerType.charAt(0).toUpperCase() + triggerType.slice(1) + + if (issue === 'missing') { + return `${triggerName} execution requires a ${triggerName} Trigger block` + } + + return `Multiple ${triggerName} Trigger blocks found. Keep only one.` + } +} diff --git a/apps/sim/socket-server/database/operations.ts b/apps/sim/socket-server/database/operations.ts index 2ad00b49c6..fc97d6db17 100644 --- a/apps/sim/socket-server/database/operations.ts +++ b/apps/sim/socket-server/database/operations.ts @@ -234,6 +234,8 @@ async function handleBlockOperationTx( throw new Error('Missing required fields for add block operation') } + // Note: single-API-trigger enforcement is handled client-side to avoid disconnects + logger.debug(`[SERVER] Adding block: ${payload.type} (${payload.id})`, { isSubflowType: isSubflowBlockType(payload.type), }) diff --git a/apps/sim/stores/workflows/registry/store.ts b/apps/sim/stores/workflows/registry/store.ts index 56cf71f553..bf6e616968 100644 --- a/apps/sim/stores/workflows/registry/store.ts +++ b/apps/sim/stores/workflows/registry/store.ts @@ -12,7 +12,6 @@ import type { import { getNextWorkflowColor } from '@/stores/workflows/registry/utils' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' -import type { BlockState } from '@/stores/workflows/workflow/types' const logger = createLogger('WorkflowRegistry') @@ -641,104 +640,9 @@ export const useWorkflowRegistry = create()( logger.info(`Created workflow from marketplace: ${options.marketplaceId}`) } else { - // Create starter block for new workflow - const starterId = crypto.randomUUID() - const starterBlock = { - id: starterId, - type: 'starter' as const, - name: 'Start', - position: { x: 100, y: 100 }, - subBlocks: { - startWorkflow: { - id: 'startWorkflow', - type: 'dropdown' as const, - value: 'manual', - }, - webhookPath: { - id: 'webhookPath', - type: 'short-input' as const, - value: '', - }, - webhookSecret: { - id: 'webhookSecret', - type: 'short-input' as const, - value: '', - }, - scheduleType: { - id: 'scheduleType', - type: 'dropdown' as const, - value: 'daily', - }, - minutesInterval: { - id: 'minutesInterval', - type: 'short-input' as const, - value: '', - }, - minutesStartingAt: { - id: 'minutesStartingAt', - type: 'short-input' as const, - value: '', - }, - hourlyMinute: { - id: 'hourlyMinute', - type: 'short-input' as const, - value: '', - }, - dailyTime: { - id: 'dailyTime', - type: 'short-input' as const, - value: '', - }, - weeklyDay: { - id: 'weeklyDay', - type: 'dropdown' as const, - value: 'MON', - }, - weeklyDayTime: { - id: 'weeklyDayTime', - type: 'short-input' as const, - value: '', - }, - monthlyDay: { - id: 'monthlyDay', - type: 'short-input' as const, - value: '', - }, - monthlyTime: { - id: 'monthlyTime', - type: 'short-input' as const, - value: '', - }, - cronExpression: { - id: 'cronExpression', - type: 'short-input' as const, - value: '', - }, - timezone: { - id: 'timezone', - type: 'dropdown' as const, - value: 'UTC', - }, - }, - outputs: { - response: { - type: { - input: 'any', - }, - }, - }, - enabled: true, - horizontalHandles: true, - isWide: false, - advancedMode: false, - triggerMode: false, - height: 0, - } - + // Create empty workflow (no default blocks) initialState = { - blocks: { - [starterId]: starterBlock, - }, + blocks: {}, edges: [], loops: {}, parallels: {}, @@ -750,9 +654,7 @@ export const useWorkflowRegistry = create()( past: [], present: { state: { - blocks: { - [starterId]: starterBlock, - }, + blocks: {}, edges: [], loops: {}, parallels: {}, @@ -788,15 +690,8 @@ export const useWorkflowRegistry = create()( // Initialize subblock values to ensure they're available for sync if (!options.marketplaceId) { - // For non-marketplace workflows, initialize subblock values from the starter block + // For non-marketplace workflows, initialize empty subblock values const subblockValues: Record> = {} - const blocks = initialState.blocks as Record - for (const [blockId, block] of Object.entries(blocks)) { - subblockValues[blockId] = {} - for (const [subblockId, subblock] of Object.entries(block.subBlocks)) { - subblockValues[blockId][subblockId] = (subblock as any).value - } - } // Update the subblock store with the initial values useSubBlockStore.setState((state) => ({ diff --git a/apps/sim/stores/workflows/yaml/importer.ts b/apps/sim/stores/workflows/yaml/importer.ts index c31d16dbdd..3f6bdb6d37 100644 --- a/apps/sim/stores/workflows/yaml/importer.ts +++ b/apps/sim/stores/workflows/yaml/importer.ts @@ -123,6 +123,11 @@ function validateBlockTypes(yamlWorkflow: YamlWorkflow): { errors: string[]; war const errors: string[] = [] const warnings: string[] = [] + // Precompute counts that are used in validations to avoid O(n^2) checks + const apiTriggerCount = Object.values(yamlWorkflow.blocks).filter( + (b) => b.type === 'api_trigger' + ).length + Object.entries(yamlWorkflow.blocks).forEach(([blockId, block]) => { // Use shared structure validation const { errors: structureErrors, warnings: structureWarnings } = validateBlockStructure( @@ -157,6 +162,12 @@ function validateBlockTypes(yamlWorkflow: YamlWorkflow): { errors: string[]; war } }) } + // Enforce only one API trigger in YAML + if (block.type === 'api_trigger') { + if (apiTriggerCount > 1) { + errors.push('Only one API trigger is allowed per workflow (YAML contains multiple).') + } + } }) return { errors, warnings }