diff --git a/apps/docs/content/docs/en/triggers/index.mdx b/apps/docs/content/docs/en/triggers/index.mdx index 4976f27925..6bedd5918a 100644 --- a/apps/docs/content/docs/en/triggers/index.mdx +++ b/apps/docs/content/docs/en/triggers/index.mdx @@ -7,46 +7,35 @@ import { Card, Cards } from 'fumadocs-ui/components/card' ## Core Triggers -Pick one trigger per workflow to define how it starts: +Use the Start block for everything originating from the editor, deploy-to-API, or deploy-to-chat experiences. Other triggers remain available for event-driven workflows: - - HTTP endpoint that maps JSON bodies into workflow inputs + + Unified entry point that supports editor runs, API deployments and chat deployments - - Deployed chat interface with streaming responses - - - Typed manual input used in editor runs and child workflows - - - On-demand runs with no additional data + + Receive external webhook payloads Cron or interval based execution - - Receive external webhook payloads - ## Quick Comparison | Trigger | Start condition | |---------|-----------------| -| **API** | Authenticated HTTP POST | -| **Chat** | Chat deployment message | -| **Input Form** | On manual submit in editor or parent workflow | -| **Manual** | Run button in editor | +| **Start** | Editor runs, deploy-to-API requests, or chat messages | | **Schedule** | Timer managed in schedule modal | | **Webhook** | On inbound HTTP request | +> The Start block always exposes `input`, `conversationId`, and `files` fields. Add custom fields to the input format for additional structured data. + ## Using Triggers -1. Drop the trigger block in the start slot. +1. Drop the Start block in the start slot (or an alternate trigger like Webhook/Schedule). 2. Configure any required schema or auth. 3. Connect the block to the rest of the workflow. > Deployments power every trigger. Update the workflow, redeploy, and all trigger entry points pick up the new snapshot. Learn more in [Execution → Deployment Snapshots](/execution). -Legacy Starter blocks remain for existing flows but no longer appear in new builds. diff --git a/apps/docs/content/docs/en/triggers/meta.json b/apps/docs/content/docs/en/triggers/meta.json index a63ecb69d9..008a6ab1ee 100644 --- a/apps/docs/content/docs/en/triggers/meta.json +++ b/apps/docs/content/docs/en/triggers/meta.json @@ -1,3 +1,3 @@ { - "pages": ["index", "api", "chat", "input-form", "manual", "schedule", "starter", "webhook"] + "pages": ["index", "start", "schedule", "webhook", "starter"] } diff --git a/apps/docs/content/docs/en/triggers/start.mdx b/apps/docs/content/docs/en/triggers/start.mdx new file mode 100644 index 0000000000..53d1d3aed3 --- /dev/null +++ b/apps/docs/content/docs/en/triggers/start.mdx @@ -0,0 +1,90 @@ +--- +title: Start +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { Tab, Tabs } from 'fumadocs-ui/components/tabs' +import { Image } from '@/components/ui/image' + +The Start block is the default trigger for workflows built in Sim. It collects structured inputs and fans out to the rest of your graph for editor tests, API deployments, and chat experiences. + +
+ Start block with Input Format fields +
+ + +The Start block sits in the start slot when you create a workflow. Keep it there when you want the same entry point to serve editor runs, deploy-to-API requests, and chat sessions. Swap it with Webhook or Schedule triggers when you only need event-driven execution. + + +## Fields exposed by Start + +The Start block emits different data depending on the execution surface: + +- **Input Format fields** — Every field you add becomes available as ``. For example, a `customerId` field shows up as `` in downstream blocks and templates. +- **Chat-only fields** — When the workflow runs from the chat side panel or a deployed chat experience, Sim also provides `` (latest user message), `` (active session id), and `` (chat attachments). + +Keep Input Format fields scoped to the names you expect to reference later—those values are the only structured fields shared across editor, API, and chat runs. + +## Configure the Input Format + +Use the Input Format sub-block to define the schema that applies across execution modes: + +1. Add a field for each value you want to collect. +2. Choose a type (`string`, `number`, `boolean`, `object`, `array`, or `files`). File fields accept uploads from chat and API callers. +3. Provide default values when you want the manual run modal to populate test data automatically. These defaults are ignored for deployed executions. +4. Reorder fields to control how they appear in the editor form. + +Reference structured values downstream with expressions such as `` depending on the block you connect. + +## How it behaves per entry point + + + +
+

+ When you click Run in the editor, the Start block renders the Input Format as a form. Default values make it easy to retest without retyping data. Submitting the form triggers the workflow immediately and the values become available on <start.fieldName> (for example <start.sampleField>). +

+

+ File fields in the form upload directly into the corresponding ``; use those values to feed downstream tools or storage steps. +

+
+
+ +
+

+ Deploying to API turns the Input Format into a JSON contract for clients. Each field becomes part of the request body, and Sim coerces primitive types on ingestion. File fields expect objects that reference uploaded files; use the execution file upload endpoint before invoking the workflow. +

+

+ API callers can include additional optional properties. They are preserved inside `` outputs so you can experiment without redeploying immediately. +

+
+
+ +
+

+ In chat deployments the Start block binds to the active conversation. The latest message fills <start.input>, the session identifier is available at <start.conversationId>, and user attachments appear on <start.files>, alongside any Input Format fields scoped as <start.fieldName>. +

+

+ If you launch chat with additional structured context (for example from an embed), it merges into the corresponding `` outputs, keeping downstream blocks consistent with API and manual runs. +

+
+
+
+ +## Referencing Start data downstream + +- Connect `` directly into agents, tools, or functions that expect structured payloads. +- Use templating syntax like `` or `` (chat only) in prompt fields. +- Keep `` handy when you need to group outputs, update conversation history, or call back into the chat API. + +## Best practices + +- Treat the Start block as the single entry point when you want to support both API and chat callers. +- Prefer named Input Format fields over parsing raw JSON in downstream nodes; type coercion happens automatically. +- Add validation or routing immediately after Start if certain fields are required for your workflow to succeed. \ No newline at end of file diff --git a/apps/docs/content/docs/en/triggers/starter.mdx b/apps/docs/content/docs/en/triggers/starter.mdx deleted file mode 100644 index 9d2322cb5d..0000000000 --- a/apps/docs/content/docs/en/triggers/starter.mdx +++ /dev/null @@ -1,67 +0,0 @@ ---- -title: Starter (Deprecated) ---- - -import { Callout } from 'fumadocs-ui/components/callout' -import { Tab, Tabs } from 'fumadocs-ui/components/tabs' -import { Image } from '@/components/ui/image' -import { Video } from '@/components/ui/video' - - -The Starter block has been deprecated and replaced with more specialized Core Triggers. Please see the [Core Triggers documentation](/triggers) for the new API, Chat, Input Form, Manual, Schedule, and Webhook triggers. - - -The Starter block allows you to manually initiate workflow execution with input parameters, offering two input modes: structured parameters or conversational chat. - -
- Starter Block with Manual and Chat Mode Options -
- -## Execution Modes - -Choose your input method from the dropdown: - - - -
-
    -
  • API Friendly Structured inputs: Define specific parameters (text, number, boolean, JSON, file, date)
  • -
  • Testing while Building Your Workflow: Quick iteration while debugging workflows
  • -
- -
-
- -

Configure input parameters that will be available when deploying as an API endpoint.

-
-
- -
-
    -
  • Natural language: Users type questions or requests
  • -
  • Conversational: Ideal for AI-powered workflows
  • -
- -
-
- -

Chat with your workflow and access input text, conversation ID, and uploaded files for context-aware responses.

-
-
-
- -## Using Chat Variables - -In Chat mode, access user input and conversation context through special variables: - -- **``** - Contains the user's message text -- **``** - Unique identifier for the conversation thread -- **``** - Array of files uploaded by the user (if any) \ No newline at end of file diff --git a/apps/docs/public/static/start.png b/apps/docs/public/static/start.png new file mode 100644 index 0000000000..45368bbd91 Binary files /dev/null and b/apps/docs/public/static/start.png differ diff --git a/apps/docs/public/static/starter.png b/apps/docs/public/static/starter.png deleted file mode 100644 index 96a1c086e5..0000000000 Binary files a/apps/docs/public/static/starter.png and /dev/null differ diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index 1a9333a12d..ef9fdf4897 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -14,7 +14,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 { StartBlockPath, TriggerUtils } from '@/lib/workflows/triggers' import { createHttpResponseFromBlock, updateWorkflowRunCounts, @@ -317,10 +317,9 @@ export async function executeWorkflow( throw new Error(errorMsg) } - const startBlockId = startBlock.blockId - const triggerBlock = startBlock.block + const { blockId: startBlockId, block: triggerBlock, path: startPath } = startBlock - if (triggerBlock.type !== 'starter') { + if (startPath !== StartBlockPath.LEGACY_STARTER) { const outgoingConnections = serializedWorkflow.connections.filter( (conn) => conn.source === startBlockId ) @@ -557,7 +556,7 @@ export async function POST( : undefined), workflowTriggerType: body.workflowTriggerType || (isInternalCall && body.stream ? 'chat' : 'api'), - input: body.input !== undefined ? body.input : body, + input: body, } } @@ -607,13 +606,19 @@ export async function POST( const blocks = deployedData.blocks || {} logger.info(`[${requestId}] Loaded ${Object.keys(blocks).length} blocks from workflow`) + const startTriggerBlock = Object.values(blocks).find( + (block: any) => block.type === 'start_trigger' + ) as any const apiTriggerBlock = Object.values(blocks).find( (block: any) => block.type === 'api_trigger' ) as any + logger.info(`[${requestId}] Start trigger block found:`, !!startTriggerBlock) logger.info(`[${requestId}] API trigger block found:`, !!apiTriggerBlock) - if (apiTriggerBlock?.subBlocks?.inputFormat?.value) { - const inputFormat = apiTriggerBlock.subBlocks.inputFormat.value as Array<{ + const triggerBlock = startTriggerBlock || apiTriggerBlock + + if (triggerBlock?.subBlocks?.inputFormat?.value) { + const inputFormat = triggerBlock.subBlocks.inputFormat.value as Array<{ name: string type: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'files' }> diff --git a/apps/sim/app/api/workflows/[id]/status/route.ts b/apps/sim/app/api/workflows/[id]/status/route.ts index d9d6557741..21b759c7d6 100644 --- a/apps/sim/app/api/workflows/[id]/status/route.ts +++ b/apps/sim/app/api/workflows/[id]/status/route.ts @@ -31,7 +31,14 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ const normalizedData = await loadWorkflowFromNormalizedTables(id) if (!normalizedData) { - return createErrorResponse('Failed to load workflow state', 500) + // Workflow exists but has no blocks in normalized tables (empty workflow or not migrated) + // This is valid state - return success with no redeployment needed + return createSuccessResponse({ + isDeployed: validation.workflow.isDeployed, + deployedAt: validation.workflow.deployedAt, + isPublished: validation.workflow.isPublished, + needsRedeployment: false, + }) } const currentState = { diff --git a/apps/sim/app/api/workspaces/route.ts b/apps/sim/app/api/workspaces/route.ts index 2e74a01905..3c56f44d54 100644 --- a/apps/sim/app/api/workspaces/route.ts +++ b/apps/sim/app/api/workspaces/route.ts @@ -4,6 +4,8 @@ import { and, desc, eq, isNull } from 'drizzle-orm' import { NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { createLogger } from '@/lib/logs/console/logger' +import { saveWorkflowToNormalizedTables } from '@/lib/workflows/db-helpers' +import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults' const logger = createLogger('Workspaces') @@ -136,6 +138,13 @@ async function createWorkspace(userId: string, name: string) { `Created workspace ${workspaceId} with initial workflow ${workflowId} for user ${userId}` ) }) + + const { workflowState } = buildDefaultWorkflowArtifacts() + const seedResult = await saveWorkflowToNormalizedTables(workflowId, workflowState) + + if (!seedResult.success) { + throw new Error(seedResult.error || 'Failed to seed default workflow state') + } } catch (error) { logger.error(`Failed to create workspace ${workspaceId} with initial workflow:`, error) throw error 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 6df1037073..ce5608762e 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 @@ -17,6 +17,7 @@ import { getEnv } from '@/lib/env' import { createLogger } from '@/lib/logs/console/logger' import { cn } from '@/lib/utils' import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/db-helpers' +import { resolveStartCandidates, StartBlockPath } from '@/lib/workflows/triggers' import { DeploymentInfo } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components' import { ChatDeploy } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/chat-deploy' import { DeployedWorkflowModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deployment-controls/components/deployed-workflow-modal' @@ -114,13 +115,17 @@ export function DeployModal({ let inputFormatExample = '' try { const blocks = Object.values(useWorkflowStore.getState().blocks) + const candidates = resolveStartCandidates(useWorkflowStore.getState().blocks, { + execution: 'api', + }) - // 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') + const targetCandidate = + candidates.find((candidate) => candidate.path === StartBlockPath.UNIFIED) || + candidates.find((candidate) => candidate.path === StartBlockPath.SPLIT_API) || + candidates.find((candidate) => candidate.path === StartBlockPath.SPLIT_INPUT) || + candidates.find((candidate) => candidate.path === StartBlockPath.LEGACY_STARTER) - const targetBlock = apiTriggerBlock || starterBlock + const targetBlock = targetCandidate?.block if (targetBlock) { const inputFormat = useSubBlockStore.getState().getValue(targetBlock.id, 'inputFormat') diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar.tsx index a8ccd1d018..2c0e1cdd1c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { Bug, ChevronLeft, @@ -47,6 +47,7 @@ import { getKeyboardShortcutText, useKeyboardShortcuts, } from '@/app/workspace/[workspaceId]/w/hooks/use-keyboard-shortcuts' +import { useDebounce } from '@/hooks/use-debounce' import { useFolderStore } from '@/stores/folders/store' import { useOperationQueueStore } from '@/stores/operation-queue/store' import { usePanelStore } from '@/stores/panel/store' @@ -265,6 +266,39 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) { activeWorkflowId ? state.workflowValues[activeWorkflowId] : null ) + const [blockStructureVersion, setBlockStructureVersion] = useState(0) + const [edgeStructureVersion, setEdgeStructureVersion] = useState(0) + const [subBlockStructureVersion, setSubBlockStructureVersion] = useState(0) + + useEffect(() => { + setBlockStructureVersion((version) => version + 1) + }, [currentBlocks]) + + useEffect(() => { + setEdgeStructureVersion((version) => version + 1) + }, [currentEdges]) + + useEffect(() => { + setSubBlockStructureVersion((version) => version + 1) + }, [subBlockValues]) + + useEffect(() => { + setBlockStructureVersion(0) + setEdgeStructureVersion(0) + setSubBlockStructureVersion(0) + }, [activeWorkflowId]) + + const statusCheckTrigger = useMemo(() => { + return JSON.stringify({ + lastSaved: lastSaved ?? 0, + blockVersion: blockStructureVersion, + edgeVersion: edgeStructureVersion, + subBlockVersion: subBlockStructureVersion, + }) + }, [lastSaved, blockStructureVersion, edgeStructureVersion, subBlockStructureVersion]) + + const debouncedStatusCheckTrigger = useDebounce(statusCheckTrigger, 500) + useEffect(() => { // Avoid off-by-one false positives: wait until operation queue is idle const { operations, isProcessing } = useOperationQueueStore.getState() @@ -299,16 +333,7 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) { } checkForChanges() - }, [ - activeWorkflowId, - deployedState, - currentBlocks, - currentEdges, - subBlockValues, - isLoadingDeployedState, - useOperationQueueStore.getState().isProcessing, - useOperationQueueStore.getState().operations.length, - ]) + }, [activeWorkflowId, deployedState, debouncedStatusCheckTrigger, isLoadingDeployedState]) useEffect(() => { if (session?.user?.id && !isRegistryLoading) { 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 index e55a9b7ee4..5e355e8972 100644 --- 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 @@ -13,7 +13,7 @@ interface InputFormatField { } interface InputTriggerBlock { - type: 'input_trigger' + type: 'input_trigger' | 'start_trigger' subBlocks?: { inputFormat?: { value?: InputFormatField[] } } @@ -32,8 +32,9 @@ interface StarterBlockLegacy { } function isInputTriggerBlock(value: unknown): value is InputTriggerBlock { + const type = (value as { type?: unknown }).type return ( - !!value && typeof value === 'object' && (value as { type?: unknown }).type === 'input_trigger' + !!value && typeof value === 'object' && (type === 'input_trigger' || type === 'start_trigger') ) } 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 fa1c54d2b2..dc47d1d7ae 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 { TriggerUtils } from '@/lib/workflows/triggers' +import { resolveStartCandidates, StartBlockPath, TriggerUtils } from '@/lib/workflows/triggers' import type { BlockOutput } from '@/blocks/types' import { Executor } from '@/executor' import type { BlockLog, ExecutionResult, StreamingExecution } from '@/executor/types' @@ -825,125 +825,76 @@ export function useWorkflowExecution() { 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', + const candidates = resolveStartCandidates(filteredStates, { + execution: 'manual', + }) + + logger.info('Manual run start candidates:', { + count: candidates.length, + paths: candidates.map((candidate) => ({ + path: candidate.path, + type: candidate.block.type, + name: candidate.block.name, })), }) - 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 - const testInput = extractTestValuesFromInputFormat(inputFormatValue) - - // 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 apiCandidates = candidates.filter( + (candidate) => candidate.path === StartBlockPath.SPLIT_API + ) + if (apiCandidates.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 triggers - // Prefer manual_trigger over input_trigger for simple runs - const manualTrigger = manualTriggers.find((t) => t.type === 'manual_trigger') - const inputTrigger = manualTriggers.find((t) => t.type === 'input_trigger') - - selectedTrigger = manualTrigger || inputTrigger || manualTriggers[0] - const blockEntry = entries.find(([, block]) => block === selectedTrigger) - if (blockEntry) { - selectedBlockId = blockEntry[0] - - // Extract test values from input trigger's inputFormat if it's an input_trigger - if (selectedTrigger.type === 'input_trigger') { - const inputFormatValue = selectedTrigger.subBlocks?.inputFormat?.value - const testInput = extractTestValuesFromInputFormat(inputFormatValue) - - // Use the test input as workflow input - if (Object.keys(testInput).length > 0) { - finalWorkflowInput = testInput - logger.info('Using Input trigger test values for manual run:', testInput) - } - } - } - } 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 a Manual, Input Form, or API Trigger block') - logger.error('No manual/input or API triggers found for manual run') + const selectedCandidate = apiCandidates[0] ?? candidates[0] + + if (!selectedCandidate) { + const error = new Error('Manual run requires a Manual, Input Form, or API Trigger block') + logger.error('No manual/input or API triggers found for manual run') + setIsExecuting(false) + throw error + } + + startBlockId = selectedCandidate.blockId + const selectedTrigger = selectedCandidate.block + + if (selectedCandidate.path !== StartBlockPath.LEGACY_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 } } - 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 - } + if ( + selectedCandidate.path === StartBlockPath.SPLIT_API || + selectedCandidate.path === StartBlockPath.SPLIT_INPUT || + selectedCandidate.path === StartBlockPath.UNIFIED + ) { + const inputFormatValue = selectedTrigger.subBlocks?.inputFormat?.value + const testInput = extractTestValuesFromInputFormat(inputFormatValue) + + if (Object.keys(testInput).length > 0) { + finalWorkflowInput = testInput + logger.info('Using trigger test values for manual run:', { + startBlockId, + testFields: Object.keys(testInput), + path: selectedCandidate.path, + }) } - - logger.info('Trigger found for manual run:', { - startBlockId, - triggerType: selectedTrigger.type, - triggerName: selectedTrigger.name, - isLegacyStarter: selectedTrigger.type === 'starter', - usingTestValues: selectedTrigger.type === 'api_trigger', - }) } + + logger.info('Trigger found for manual run:', { + startBlockId, + triggerType: selectedTrigger.type, + triggerName: selectedTrigger.name, + startPath: selectedCandidate.path, + }) } // If we don't have a valid startBlockId at this point, throw an error diff --git a/apps/sim/blocks/blocks/api_trigger.ts b/apps/sim/blocks/blocks/api_trigger.ts index 5a830f9b6c..c53544303b 100644 --- a/apps/sim/blocks/blocks/api_trigger.ts +++ b/apps/sim/blocks/blocks/api_trigger.ts @@ -4,8 +4,8 @@ import type { BlockConfig } from '@/blocks/types' export const ApiTriggerBlock: BlockConfig = { type: 'api_trigger', triggerAllowed: true, - name: 'API', - description: 'Expose as HTTP API endpoint', + name: 'API (Legacy)', + description: 'Legacy block for exposing HTTP API endpoint. Prefer Start block.', longDescription: 'API trigger to start the workflow via authenticated HTTP calls with structured input.', bestPractices: ` @@ -14,6 +14,7 @@ export const ApiTriggerBlock: BlockConfig = { - In production, the curl would come in as e.g. curl -X POST -H "X-API-Key: $SIM_API_KEY" -H "Content-Type: application/json" -d '{"paramName":"example"}' https://www.staging.sim.ai/api/workflows/9e7e4f26-fc5e-4659-b270-7ea474b14f4a/execute -- If user asks to test via API, you might need to clarify the API key. `, category: 'triggers', + hideFromToolbar: true, bgColor: '#2F55FF', icon: ApiIcon, subBlocks: [ diff --git a/apps/sim/blocks/blocks/chat_trigger.ts b/apps/sim/blocks/blocks/chat_trigger.ts index 145cdc89b5..2efb6612fc 100644 --- a/apps/sim/blocks/blocks/chat_trigger.ts +++ b/apps/sim/blocks/blocks/chat_trigger.ts @@ -9,12 +9,13 @@ export const ChatTriggerBlock: BlockConfig = { type: 'chat_trigger', triggerAllowed: true, name: 'Chat', - description: 'Start workflow from a chat deployment', + description: 'Legacy chat start block. Prefer the unified Start block.', longDescription: 'Chat trigger to run the workflow via deployed chat interfaces.', bestPractices: ` - Can run the workflow manually to test implementation when this is the trigger point by passing in a message. `, category: 'triggers', + hideFromToolbar: true, bgColor: '#6F3DFA', icon: ChatTriggerIcon, subBlocks: [], diff --git a/apps/sim/blocks/blocks/input_trigger.ts b/apps/sim/blocks/blocks/input_trigger.ts index 073c38ad7e..c0f9eb0205 100644 --- a/apps/sim/blocks/blocks/input_trigger.ts +++ b/apps/sim/blocks/blocks/input_trigger.ts @@ -8,8 +8,8 @@ const InputTriggerIcon = (props: SVGProps) => createElement(FormI export const InputTriggerBlock: BlockConfig = { type: 'input_trigger', triggerAllowed: true, - name: 'Input Form', - description: 'Start workflow manually with a defined input schema', + name: 'Input Form (Legacy)', + description: 'Legacy manual start block with structured input. Prefer Start block.', longDescription: 'Manually trigger the workflow from the editor with a structured input schema. This enables typed inputs for parent workflows to map into.', bestPractices: ` @@ -18,6 +18,7 @@ export const InputTriggerBlock: BlockConfig = { - Also used in child workflows to map variables from the parent workflow. `, category: 'triggers', + hideFromToolbar: true, bgColor: '#3B82F6', icon: InputTriggerIcon, subBlocks: [ diff --git a/apps/sim/blocks/blocks/manual_trigger.ts b/apps/sim/blocks/blocks/manual_trigger.ts index 4d8a433d6d..ded5616efb 100644 --- a/apps/sim/blocks/blocks/manual_trigger.ts +++ b/apps/sim/blocks/blocks/manual_trigger.ts @@ -8,8 +8,8 @@ const ManualTriggerIcon = (props: SVGProps) => createElement(Play export const ManualTriggerBlock: BlockConfig = { type: 'manual_trigger', triggerAllowed: true, - name: 'Manual', - description: 'Start workflow manually from the editor', + name: 'Manual (Legacy)', + description: 'Legacy manual start block. Prefer the Start block.', longDescription: 'Trigger the workflow manually without defining an input schema. Useful for simple runs where no structured input is needed.', bestPractices: ` @@ -17,6 +17,7 @@ export const ManualTriggerBlock: BlockConfig = { - If you need structured inputs or child workflows to map variables from, prefer the Input Form Trigger. `, category: 'triggers', + hideFromToolbar: true, bgColor: '#2563EB', icon: ManualTriggerIcon, subBlocks: [], diff --git a/apps/sim/blocks/blocks/start_trigger.ts b/apps/sim/blocks/blocks/start_trigger.ts new file mode 100644 index 0000000000..411d531827 --- /dev/null +++ b/apps/sim/blocks/blocks/start_trigger.ts @@ -0,0 +1,38 @@ +import { StartIcon } from '@/components/icons' +import type { BlockConfig } from '@/blocks/types' + +export const StartTriggerBlock: BlockConfig = { + type: 'start_trigger', + triggerAllowed: true, + name: 'Start', + description: 'Unified workflow entry point for chat, manual and API runs', + longDescription: + 'Collect structured inputs and power manual runs, API executions, and deployed chat experiences from a single start block.', + bestPractices: ` + - The Start block always exposes "input", "conversationId", and "files" fields for chat compatibility. + - Add custom input format fields to collect additional structured data. + - Test manual runs by pre-filling default values inside the input format fields. + `, + category: 'triggers', + bgColor: '#2563EB', + icon: StartIcon, + hideFromToolbar: false, + subBlocks: [ + { + id: 'inputFormat', + title: 'Input Format', + type: 'input-format', + layout: 'full', + description: 'Add custom fields beyond the built-in input, conversationId, and files fields.', + }, + ], + tools: { + access: [], + }, + inputs: {}, + outputs: {}, + triggers: { + enabled: true, + available: ['chat', 'manual', 'api'], + }, +} diff --git a/apps/sim/blocks/blocks/workflow_input.ts b/apps/sim/blocks/blocks/workflow_input.ts index de5d6014f7..244922308e 100644 --- a/apps/sim/blocks/blocks/workflow_input.ts +++ b/apps/sim/blocks/blocks/workflow_input.ts @@ -19,11 +19,11 @@ const getAvailableWorkflows = (): Array<{ label: string; id: string }> => { export const WorkflowInputBlock: BlockConfig = { type: 'workflow_input', name: 'Workflow', - description: 'Execute another workflow and map variables to its Input Form Trigger schema.', - longDescription: `Execute another child workflow and map variables to its Input Form Trigger schema. Helps with modularizing workflows.`, + description: 'Execute another workflow and map variables to its Start trigger schema.', + longDescription: `Execute another child workflow and map variables to its Start trigger schema. Helps with modularizing workflows.`, bestPractices: ` - Usually clarify/check if the user has tagged a workflow to use as the child workflow. Understand the child workflow to determine the logical position of this block in the workflow. - - Remember, that the start point of the child workflow is the Input Form Trigger block. + - Remember, that the start point of the child workflow is the Start block. `, category: 'blocks', bgColor: '#6366F1', // Indigo - modern and professional @@ -36,14 +36,14 @@ export const WorkflowInputBlock: BlockConfig = { options: getAvailableWorkflows, required: true, }, - // Renders dynamic mapping UI based on selected child workflow's Input Trigger inputFormat + // Renders dynamic mapping UI based on selected child workflow's Start 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.", + "Map fields defined in the child workflow's Start block to variables/values in this workflow.", dependsOn: ['workflowId'], }, ], diff --git a/apps/sim/blocks/registry.ts b/apps/sim/blocks/registry.ts index 33eed5dabc..74832f4dd6 100644 --- a/apps/sim/blocks/registry.ts +++ b/apps/sim/blocks/registry.ts @@ -65,6 +65,7 @@ import { SharepointBlock } from '@/blocks/blocks/sharepoint' import { SlackBlock } from '@/blocks/blocks/slack' import { StagehandBlock } from '@/blocks/blocks/stagehand' import { StagehandAgentBlock } from '@/blocks/blocks/stagehand_agent' +import { StartTriggerBlock } from '@/blocks/blocks/start_trigger' import { StarterBlock } from '@/blocks/blocks/starter' import { SupabaseBlock } from '@/blocks/blocks/supabase' import { TavilyBlock } from '@/blocks/blocks/tavily' @@ -156,6 +157,7 @@ export const registry: Record = { stagehand_agent: StagehandAgentBlock, slack: SlackBlock, starter: StarterBlock, + start_trigger: StartTriggerBlock, input_trigger: InputTriggerBlock, chat_trigger: ChatTriggerBlock, manual_trigger: ManualTriggerBlock, diff --git a/apps/sim/executor/index.ts b/apps/sim/executor/index.ts index 2b00bc5e0e..c6b69c2fb3 100644 --- a/apps/sim/executor/index.ts +++ b/apps/sim/executor/index.ts @@ -34,6 +34,12 @@ import type { StreamingExecution, } from '@/executor/types' import { streamingResponseFormatProcessor } from '@/executor/utils' +import { + buildResolutionFromBlock, + buildStartBlockOutput, + type ExecutorStartResolution, + resolveExecutorStartBlock, +} from '@/executor/utils/start-block' import { VirtualBlockUtils } from '@/executor/utils/virtual-blocks' import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types' import { useExecutionStore } from '@/stores/execution/store' @@ -774,151 +780,37 @@ export class Executor { // Determine which block to initialize as the starting point let initBlock: SerializedBlock | undefined + let startResolution: ExecutorStartResolution | null = null + if (startBlockId) { - // Starting from a specific block (webhook trigger, schedule trigger, or new trigger blocks) initBlock = this.actualWorkflow.blocks.find((block) => block.id === startBlockId) + if (initBlock) { + startResolution = buildResolutionFromBlock(initBlock) + } } else { - // Default to starter block (legacy) or find any trigger block - initBlock = this.actualWorkflow.blocks.find( - (block) => block.metadata?.id === BlockType.STARTER - ) + const executionKind = this.getExecutionKindHint() + startResolution = resolveExecutorStartBlock(this.actualWorkflow.blocks, { + execution: executionKind, + isChildWorkflow: this.isChildExecution, + }) - // 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 (dedicated or trigger-mode) - const triggerBlocks = this.actualWorkflow.blocks.filter( - (block) => - block.metadata?.id === 'input_trigger' || - block.metadata?.id === 'api_trigger' || - block.metadata?.id === 'chat_trigger' || - block.metadata?.category === 'triggers' || - block.config?.params?.triggerMode === true - ) - if (triggerBlocks.length > 0) { - initBlock = triggerBlocks[0] - } - } + if (startResolution) { + initBlock = startResolution.block + } else { + initBlock = this.resolveFallbackStartBlock() } } 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 - 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) { - // Create structured input based on input format - const structuredInput: Record = {} - - // Process each field in the input format - for (const field of inputFormat) { - if (field.name && field.type) { - // Get the field value from workflow input if available - // First try to access via input.field, then directly from field - // This handles both input formats: { input: { field: value } } and { field: value } - let inputValue = - this.workflowInput?.input?.[field.name] !== undefined - ? this.workflowInput.input[field.name] // Try to get from input.field - : this.workflowInput?.[field.name] // Fallback to direct field access - - if (inputValue === undefined || inputValue === null) { - if (Object.hasOwn(field, 'value')) { - inputValue = (field as any).value - } - } - - let typedValue = inputValue - if (inputValue !== undefined && inputValue !== null) { - if (field.type === 'string' && typeof inputValue !== 'string') { - typedValue = String(inputValue) - } else if (field.type === 'number' && typeof inputValue !== 'number') { - const num = Number(inputValue) - typedValue = Number.isNaN(num) ? inputValue : num - } else if (field.type === 'boolean' && typeof inputValue !== 'boolean') { - typedValue = - inputValue === 'true' || - inputValue === true || - inputValue === 1 || - inputValue === '1' - } else if ( - (field.type === 'object' || field.type === 'array') && - typeof inputValue === 'string' - ) { - try { - typedValue = JSON.parse(inputValue) - } catch (e) { - logger.warn(`Failed to parse ${field.type} input for field ${field.name}:`, e) - } - } - } - - // Add the field to structured input - structuredInput[field.name] = typedValue - } - } - - // Check if we managed to process any fields - if not, use the raw input - const hasProcessedFields = Object.keys(structuredInput).length > 0 - - // If no fields matched the input format, extract the raw input to use instead - const rawInputData = - this.workflowInput?.input !== undefined - ? this.workflowInput.input // Use the input value - : this.workflowInput // Fallback to direct input - - // Use the structured input if we processed fields, otherwise use raw input - const finalInput = hasProcessedFields ? structuredInput : rawInputData - - // 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 - } - } + const resolution = startResolution ?? buildResolutionFromBlock(initBlock) - // Add files if present (for all trigger types) - if (this.workflowInput?.files && Array.isArray(this.workflowInput.files)) { - blockOutput.files = this.workflowInput.files - } + if (resolution) { + const blockOutput = buildStartBlockOutput({ + resolution, + workflowInput: this.workflowInput, + isDeployedExecution: this.contextExtensions?.isDeployedContext === true, + }) context.blockStates.set(initBlock.id, { output: blockOutput, @@ -926,142 +818,239 @@ export class Executor { 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, blockOutput, context) } else { - // 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 || '', - } + this.initializeLegacyTriggerBlock(initBlock, context) + } + } catch (error) { + logger.warn('Error processing starter block input format:', error) - 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) }, - } - } else { - starterOutput = { input: rawCandidate } + const blockOutput = this.buildFallbackTriggerOutput(initBlock) + context.blockStates.set(initBlock.id, { + output: blockOutput, + executed: true, + executionTime: 0, + }) + this.createStartedBlockWithFilesLog(initBlock, blockOutput, context) + } + + context.activeExecutionPath.add(initBlock.id) + context.executedBlocks.add(initBlock.id) + this.addConnectedBlocksToActivePath(initBlock.id, context) + } + + return context + } + + private getExecutionKindHint(): 'chat' | 'manual' | 'api' { + const triggerType = this.contextExtensions?.workflowTriggerType + if (triggerType === 'chat') { + return 'chat' + } + if (triggerType === 'api') { + return 'api' + } + if (this.contextExtensions?.stream === true) { + return 'chat' + } + if (this.contextExtensions?.isDeployedContext === true) { + return 'api' + } + return 'manual' + } + + private resolveFallbackStartBlock(): SerializedBlock | undefined { + if (this.isChildExecution) { + return undefined + } + + const excluded = new Set([ + 'start_trigger', + 'input_trigger', + 'api_trigger', + 'chat_trigger', + 'starter', + ]) + + const triggerBlocks = this.actualWorkflow.blocks.filter( + (block) => + block.metadata?.category === 'triggers' || block.config?.params?.triggerMode === true + ) + + return ( + triggerBlocks.find((block) => !excluded.has(block.metadata?.id ?? '')) || triggerBlocks[0] + ) + } + + private initializeLegacyTriggerBlock( + initBlock: SerializedBlock, + context: ExecutionContext + ): void { + const blockParams = initBlock.config.params + let inputFormat = blockParams?.inputFormat + const metadataWithSubBlocks = initBlock.metadata as { + subBlocks?: Record + } | null + + if (!inputFormat && metadataWithSubBlocks?.subBlocks?.inputFormat?.value) { + inputFormat = metadataWithSubBlocks.subBlocks.inputFormat.value + } + + if (inputFormat && Array.isArray(inputFormat) && inputFormat.length > 0) { + const structuredInput: Record = {} + const isDeployedExecution = this.contextExtensions?.isDeployedContext === true + + for (const field of inputFormat) { + if (field?.name && field.type) { + let inputValue = + this.workflowInput?.input?.[field.name] !== undefined + ? this.workflowInput.input[field.name] + : this.workflowInput?.[field.name] + + if ((inputValue === undefined || inputValue === null) && !isDeployedExecution) { + if (Object.hasOwn(field, 'value')) { + inputValue = (field as any).value } - } else { - // 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, + let typedValue = inputValue + if (inputValue !== undefined && inputValue !== null) { + if (field.type === 'string' && typeof inputValue !== 'string') { + typedValue = String(inputValue) + } else if (field.type === 'number' && typeof inputValue !== 'number') { + const num = Number(inputValue) + typedValue = Number.isNaN(num) ? inputValue : num + } else if (field.type === 'boolean' && typeof inputValue !== 'boolean') { + typedValue = + inputValue === 'true' || + inputValue === true || + inputValue === 1 || + inputValue === '1' + } else if ( + (field.type === 'object' || field.type === 'array') && + typeof inputValue === 'string' + ) { + try { + typedValue = JSON.parse(inputValue) + } catch (error) { + logger.warn(`Failed to parse ${field.type} input for field ${field.name}:`, error) } } } - context.blockStates.set(initBlock.id, { - output: starterOutput, - executed: true, - executionTime: 0, - }) + structuredInput[field.name] = typedValue + } + } - // 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) - } + const hasProcessedFields = Object.keys(structuredInput).length > 0 + const rawInputData = + this.workflowInput?.input !== undefined ? this.workflowInput.input : this.workflowInput + const finalInput = hasProcessedFields ? structuredInput : rawInputData + + let blockOutput: NormalizedBlockOutput + if (hasProcessedFields) { + blockOutput = { + ...(structuredInput as Record), + input: structuredInput, + conversationId: this.workflowInput?.conversationId, } - } catch (e) { - logger.warn('Error processing starter block input format:', e) - - // Error handler fallback - use appropriate structure - let blockOutput: any - 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 - blockOutput = { - input: this.workflowInput.input, - conversationId: this.workflowInput.conversationId, - } + } else if ( + finalInput !== null && + typeof finalInput === 'object' && + !Array.isArray(finalInput) + ) { + blockOutput = { + ...(finalInput as Record), + input: finalInput, + conversationId: this.workflowInput?.conversationId, + } + } else { + blockOutput = { + input: finalInput, + conversationId: this.workflowInput?.conversationId, + } + } - // Add files if present - if (this.workflowInput.files && Array.isArray(this.workflowInput.files)) { - blockOutput.files = this.workflowInput.files - } - } else { - // API workflow: spread the raw data directly (no wrapping) - blockOutput = { ...this.workflowInput } - } - } else { - // Primitive input - blockOutput = { - input: this.workflowInput, - } + if (this.workflowInput?.files && Array.isArray(this.workflowInput.files)) { + blockOutput.files = this.workflowInput.files + } + + context.blockStates.set(initBlock.id, { + output: blockOutput, + executed: true, + executionTime: 0, + }) + this.createStartedBlockWithFilesLog(initBlock, blockOutput, context) + return + } + + let starterOutput: NormalizedBlockOutput + + if (this.workflowInput && typeof this.workflowInput === 'object') { + if ( + Object.hasOwn(this.workflowInput, 'input') && + Object.hasOwn(this.workflowInput, 'conversationId') + ) { + starterOutput = { + input: this.workflowInput.input, + conversationId: this.workflowInput.conversationId, } - context.blockStates.set(initBlock.id, { - output: blockOutput, - executed: true, - executionTime: 0, - }) - this.createStartedBlockWithFilesLog(initBlock, blockOutput, context) + if (this.workflowInput.files && Array.isArray(this.workflowInput.files)) { + starterOutput.files = this.workflowInput.files + } + } else { + starterOutput = { ...this.workflowInput } } - // Ensure the starting block is in the active execution path - context.activeExecutionPath.add(initBlock.id) - // Mark the starting block as executed - context.executedBlocks.add(initBlock.id) + } else { + starterOutput = { + input: this.workflowInput, + } + } - // Add all blocks connected to the starting block to the active execution path - const connectedToStartBlock = this.actualWorkflow.connections - .filter((conn) => conn.source === initBlock.id) - .map((conn) => conn.target) + context.blockStates.set(initBlock.id, { + output: starterOutput, + executed: true, + executionTime: 0, + }) - connectedToStartBlock.forEach((blockId) => { - context.activeExecutionPath.add(blockId) - }) + if (starterOutput.files) { + this.createStartedBlockWithFilesLog(initBlock, starterOutput, context) } + } - return context + private buildFallbackTriggerOutput(_: SerializedBlock): NormalizedBlockOutput { + if (this.workflowInput && typeof this.workflowInput === 'object') { + if ( + Object.hasOwn(this.workflowInput, 'input') && + Object.hasOwn(this.workflowInput, 'conversationId') + ) { + const output: NormalizedBlockOutput = { + input: this.workflowInput.input, + conversationId: this.workflowInput.conversationId, + } + + if (this.workflowInput.files && Array.isArray(this.workflowInput.files)) { + output.files = this.workflowInput.files + } + + return output + } + + return { ...(this.workflowInput as Record) } as NormalizedBlockOutput + } + + return { input: this.workflowInput } as NormalizedBlockOutput + } + + private addConnectedBlocksToActivePath(sourceBlockId: string, context: ExecutionContext): void { + const connectedToSource = this.actualWorkflow.connections + .filter((conn) => conn.source === sourceBlockId) + .map((conn) => conn.target) + + connectedToSource.forEach((blockId) => { + context.activeExecutionPath.add(blockId) + }) } /** diff --git a/apps/sim/executor/resolver/resolver.ts b/apps/sim/executor/resolver/resolver.ts index 4bebd6d0fc..5bb77eedf6 100644 --- a/apps/sim/executor/resolver/resolver.ts +++ b/apps/sim/executor/resolver/resolver.ts @@ -1,7 +1,7 @@ import { createLogger } from '@/lib/logs/console/logger' import { VariableManager } from '@/lib/variables/variable-manager' import { extractReferencePrefixes, SYSTEM_REFERENCE_PREFIXES } from '@/lib/workflows/references' -import { TRIGGER_REFERENCE_ALIAS_MAP } from '@/lib/workflows/triggers' +import { TRIGGER_REFERENCE_ALIAS_MAP, TRIGGER_TYPES } from '@/lib/workflows/triggers' import { getBlock } from '@/blocks/index' import type { LoopManager } from '@/executor/loops/loops' import type { ExecutionContext } from '@/executor/types' @@ -497,9 +497,41 @@ export class InputResolver { 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 - ) + const candidateTypes: string[] = [] + const pushCandidate = (candidate?: string) => { + if (candidate && !candidateTypes.includes(candidate)) { + candidateTypes.push(candidate) + } + } + + pushCandidate(triggerType) + + if (blockRefLower === 'start' || blockRefLower === 'manual') { + pushCandidate(TRIGGER_TYPES.START) + pushCandidate(TRIGGER_TYPES.STARTER) + pushCandidate(TRIGGER_TYPES.INPUT) + pushCandidate(TRIGGER_TYPES.MANUAL) + pushCandidate(TRIGGER_TYPES.API) + pushCandidate(TRIGGER_TYPES.CHAT) + } else if (blockRefLower === 'api') { + pushCandidate(TRIGGER_TYPES.API) + pushCandidate(TRIGGER_TYPES.START) + pushCandidate(TRIGGER_TYPES.INPUT) + pushCandidate(TRIGGER_TYPES.STARTER) + } else if (blockRefLower === 'chat') { + pushCandidate(TRIGGER_TYPES.CHAT) + pushCandidate(TRIGGER_TYPES.START) + pushCandidate(TRIGGER_TYPES.STARTER) + } + + let triggerBlock: SerializedBlock | undefined + for (const candidateType of candidateTypes) { + triggerBlock = this.workflow.blocks.find((block) => block.metadata?.id === candidateType) + if (triggerBlock) { + break + } + } + if (triggerBlock) { const blockState = context.blockStates.get(triggerBlock.id) if (blockState) { diff --git a/apps/sim/executor/utils/start-block.test.ts b/apps/sim/executor/utils/start-block.test.ts new file mode 100644 index 0000000000..11f8a9a8ef --- /dev/null +++ b/apps/sim/executor/utils/start-block.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from 'vitest' +import { StartBlockPath } from '@/lib/workflows/triggers' +import type { UserFile } from '@/executor/types' +import { + buildResolutionFromBlock, + buildStartBlockOutput, + resolveExecutorStartBlock, +} from '@/executor/utils/start-block' +import type { SerializedBlock } from '@/serializer/types' + +function createBlock( + type: string, + id = type, + options?: { subBlocks?: Record } +): SerializedBlock { + return { + id, + position: { x: 0, y: 0 }, + config: { + tool: type, + params: options?.subBlocks?.inputFormat ? { inputFormat: options.subBlocks.inputFormat } : {}, + }, + inputs: {}, + outputs: {}, + metadata: { + id: type, + name: `block-${type}`, + category: 'triggers', + ...(options?.subBlocks ? { subBlocks: options.subBlocks } : {}), + } as SerializedBlock['metadata'] & { subBlocks?: Record }, + enabled: true, + } +} + +describe('start-block utilities', () => { + it('buildResolutionFromBlock returns null when metadata id missing', () => { + const block = createBlock('api_trigger') + ;(block.metadata as Record).id = undefined + + expect(buildResolutionFromBlock(block)).toBeNull() + }) + + it('resolveExecutorStartBlock prefers unified start block', () => { + const blocks = [ + createBlock('api_trigger', 'api'), + createBlock('starter', 'starter'), + createBlock('start_trigger', 'start'), + ] + + const resolution = resolveExecutorStartBlock(blocks, { + execution: 'api', + isChildWorkflow: false, + }) + + expect(resolution?.blockId).toBe('start') + expect(resolution?.path).toBe(StartBlockPath.UNIFIED) + }) + + it('buildStartBlockOutput normalizes unified start payload', () => { + const block = createBlock('start_trigger', 'start') + const resolution = { + blockId: 'start', + block, + path: StartBlockPath.UNIFIED, + } as const + + const output = buildStartBlockOutput({ + resolution, + workflowInput: { payload: 'value' }, + isDeployedExecution: true, + }) + + expect(output.payload).toBe('value') + expect(output.input).toBe('') + expect(output.conversationId).toBe('') + }) + + it('buildStartBlockOutput uses trigger schema for API triggers', () => { + const apiBlock = createBlock('api_trigger', 'api', { + subBlocks: { + inputFormat: { + value: [ + { name: 'name', type: 'string' }, + { name: 'count', type: 'number' }, + ], + }, + }, + }) + + const resolution = { + blockId: 'api', + block: apiBlock, + path: StartBlockPath.SPLIT_API, + } as const + + const files: UserFile[] = [ + { + id: 'file-1', + name: 'document.txt', + url: 'https://example.com/document.txt', + size: 42, + type: 'text/plain', + key: 'file-key', + uploadedAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 1000).toISOString(), + }, + ] + + const output = buildStartBlockOutput({ + resolution, + workflowInput: { + input: { + name: 'Ada', + count: '5', + }, + files, + }, + isDeployedExecution: false, + }) + + expect(output.name).toBe('Ada') + expect(output.input).toEqual({ name: 'Ada', count: 5 }) + expect(output.files).toEqual(files) + }) +}) diff --git a/apps/sim/executor/utils/start-block.ts b/apps/sim/executor/utils/start-block.ts new file mode 100644 index 0000000000..b42828b0dc --- /dev/null +++ b/apps/sim/executor/utils/start-block.ts @@ -0,0 +1,406 @@ +import { + classifyStartBlockType, + getLegacyStarterMode, + resolveStartCandidates, + StartBlockPath, +} from '@/lib/workflows/triggers' +import type { NormalizedBlockOutput, UserFile } from '@/executor/types' +import type { SerializedBlock } from '@/serializer/types' + +type ExecutionKind = 'chat' | 'manual' | 'api' + +export interface ExecutorStartResolution { + blockId: string + block: SerializedBlock + path: StartBlockPath +} + +export interface ResolveExecutorStartOptions { + execution: ExecutionKind + isChildWorkflow: boolean +} + +type StartCandidateWrapper = { + type: string + subBlocks?: Record + original: SerializedBlock +} + +export function resolveExecutorStartBlock( + blocks: SerializedBlock[], + options: ResolveExecutorStartOptions +): ExecutorStartResolution | null { + if (blocks.length === 0) { + return null + } + + const blockMap = blocks.reduce>((acc, block) => { + const type = block.metadata?.id + if (!type) { + return acc + } + + acc[block.id] = { + type, + subBlocks: extractSubBlocks(block), + original: block, + } + + return acc + }, {}) + + const candidates = resolveStartCandidates(blockMap, { + execution: options.execution, + isChildWorkflow: options.isChildWorkflow, + }) + + if (candidates.length === 0) { + return null + } + + if (options.isChildWorkflow && candidates.length > 1) { + throw new Error('Child workflow has multiple trigger blocks. Keep only one Start block.') + } + + const [primary] = candidates + return { + blockId: primary.blockId, + block: primary.block.original, + path: primary.path, + } +} + +export function buildResolutionFromBlock(block: SerializedBlock): ExecutorStartResolution | null { + const type = block.metadata?.id + if (!type) { + return null + } + + const path = classifyStartBlockType(type) + if (!path) { + return null + } + + return { + blockId: block.id, + block, + path, + } +} + +type InputFormatField = { + name?: string + type?: string | null + value?: unknown +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function readMetadataSubBlockValue(block: SerializedBlock, key: string): unknown { + const metadata = block.metadata + if (!metadata || typeof metadata !== 'object') { + return undefined + } + + const maybeWithSubBlocks = metadata as typeof metadata & { + subBlocks?: Record + } + + const raw = maybeWithSubBlocks.subBlocks?.[key] + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + return undefined + } + + return (raw as { value?: unknown }).value +} + +function extractInputFormat(block: SerializedBlock): InputFormatField[] { + const fromMetadata = readMetadataSubBlockValue(block, 'inputFormat') + const fromParams = block.config?.params?.inputFormat + const source = fromMetadata ?? fromParams + + if (!Array.isArray(source)) { + return [] + } + + return source + .filter((field): field is InputFormatField => isPlainObject(field)) + .map((field) => field) +} + +function coerceValue(type: string | null | undefined, value: unknown): unknown { + if (value === undefined || value === null) { + return value + } + + switch (type) { + case 'string': + return typeof value === 'string' ? value : String(value) + case 'number': { + if (typeof value === 'number') return value + const parsed = Number(value) + return Number.isNaN(parsed) ? value : parsed + } + case 'boolean': { + if (typeof value === 'boolean') return value + if (value === 'true' || value === '1' || value === 1) return true + if (value === 'false' || value === '0' || value === 0) return false + return value + } + case 'object': + case 'array': { + if (typeof value === 'string') { + try { + const parsed = JSON.parse(value) + return parsed + } catch { + return value + } + } + return value + } + default: + return value + } +} + +interface DerivedInputResult { + structuredInput: Record + finalInput: unknown + hasStructured: boolean +} + +function deriveInputFromFormat( + inputFormat: InputFormatField[], + workflowInput: unknown, + isDeployedExecution: boolean +): DerivedInputResult { + const structuredInput: Record = {} + + if (inputFormat.length === 0) { + return { + structuredInput, + finalInput: getRawInputCandidate(workflowInput), + hasStructured: false, + } + } + + for (const field of inputFormat) { + const fieldName = field.name?.trim() + if (!fieldName) continue + + let fieldValue: unknown + const workflowRecord = isPlainObject(workflowInput) ? workflowInput : undefined + + if (workflowRecord) { + const inputContainer = workflowRecord.input + if (isPlainObject(inputContainer) && Object.hasOwn(inputContainer, fieldName)) { + fieldValue = inputContainer[fieldName] + } else if (Object.hasOwn(workflowRecord, fieldName)) { + fieldValue = workflowRecord[fieldName] + } + } + + if ((fieldValue === undefined || fieldValue === null) && !isDeployedExecution) { + fieldValue = field.value + } + + structuredInput[fieldName] = coerceValue(field.type, fieldValue) + } + + const hasStructured = Object.keys(structuredInput).length > 0 + const finalInput = hasStructured ? structuredInput : getRawInputCandidate(workflowInput) + + return { + structuredInput, + finalInput, + hasStructured, + } +} + +function getRawInputCandidate(workflowInput: unknown): unknown { + if (isPlainObject(workflowInput) && Object.hasOwn(workflowInput, 'input')) { + return workflowInput.input + } + return workflowInput +} + +function isUserFile(candidate: unknown): candidate is UserFile { + if (!isPlainObject(candidate)) { + return false + } + + return ( + typeof candidate.id === 'string' && + typeof candidate.name === 'string' && + typeof candidate.url === 'string' && + typeof candidate.size === 'number' && + typeof candidate.type === 'string' + ) +} + +function getFilesFromWorkflowInput(workflowInput: unknown): UserFile[] | undefined { + if (!isPlainObject(workflowInput)) { + return undefined + } + const files = workflowInput.files + if (Array.isArray(files) && files.every(isUserFile)) { + return files + } + return undefined +} + +function mergeFilesIntoOutput( + output: NormalizedBlockOutput, + workflowInput: unknown +): NormalizedBlockOutput { + const files = getFilesFromWorkflowInput(workflowInput) + if (files) { + output.files = files + } + return output +} + +function ensureString(value: unknown): string { + return typeof value === 'string' ? value : '' +} + +function buildUnifiedStartOutput(workflowInput: unknown): NormalizedBlockOutput { + const output: NormalizedBlockOutput = {} + + if (isPlainObject(workflowInput)) { + for (const [key, value] of Object.entries(workflowInput)) { + if (key === 'onUploadError') continue + output[key] = value + } + } + + if (!Object.hasOwn(output, 'input')) { + output.input = '' + } + if (!Object.hasOwn(output, 'conversationId')) { + output.conversationId = '' + } + + return mergeFilesIntoOutput(output, workflowInput) +} + +function buildApiOrInputOutput(finalInput: unknown, workflowInput: unknown): NormalizedBlockOutput { + const isObjectInput = isPlainObject(finalInput) + + const output: NormalizedBlockOutput = isObjectInput + ? { + ...(finalInput as Record), + input: { ...(finalInput as Record) }, + } + : { input: finalInput } + + return mergeFilesIntoOutput(output, workflowInput) +} + +function buildChatOutput(workflowInput: unknown): NormalizedBlockOutput { + const source = isPlainObject(workflowInput) ? workflowInput : undefined + + const output: NormalizedBlockOutput = { + input: ensureString(source?.input), + conversationId: ensureString(source?.conversationId), + } + + return mergeFilesIntoOutput(output, workflowInput) +} + +function buildLegacyStarterOutput( + finalInput: unknown, + workflowInput: unknown, + mode: 'manual' | 'api' | 'chat' | null +): NormalizedBlockOutput { + if (mode === 'chat') { + return buildChatOutput(workflowInput) + } + + const output: NormalizedBlockOutput = {} + const finalObject = isPlainObject(finalInput) ? finalInput : undefined + + if (finalObject) { + Object.assign(output, finalObject) + output.input = { ...finalObject } + } else { + output.input = finalInput + } + + const conversationId = isPlainObject(workflowInput) ? workflowInput.conversationId : undefined + if (conversationId !== undefined) { + output.conversationId = ensureString(conversationId) + } + + return mergeFilesIntoOutput(output, workflowInput) +} + +function buildManualTriggerOutput( + finalInput: unknown, + workflowInput: unknown +): NormalizedBlockOutput { + const finalObject = isPlainObject(finalInput) ? finalInput : undefined + + const output: NormalizedBlockOutput = finalObject + ? { ...(finalObject as Record) } + : { input: finalInput } + + if (!Object.hasOwn(output, 'input')) { + output.input = getRawInputCandidate(workflowInput) + } + + return mergeFilesIntoOutput(output, workflowInput) +} + +function extractSubBlocks(block: SerializedBlock): Record | undefined { + const metadata = block.metadata + if (!metadata || typeof metadata !== 'object') { + return undefined + } + + const maybeWithSubBlocks = metadata as typeof metadata & { + subBlocks?: Record + } + + const subBlocks = maybeWithSubBlocks.subBlocks + if (subBlocks && typeof subBlocks === 'object' && !Array.isArray(subBlocks)) { + return subBlocks + } + + return undefined +} + +export interface StartBlockOutputOptions { + resolution: ExecutorStartResolution + workflowInput: unknown + isDeployedExecution: boolean +} + +export function buildStartBlockOutput(options: StartBlockOutputOptions): NormalizedBlockOutput { + const { resolution, workflowInput, isDeployedExecution } = options + const inputFormat = extractInputFormat(resolution.block) + const { finalInput } = deriveInputFromFormat(inputFormat, workflowInput, isDeployedExecution) + + switch (resolution.path) { + case StartBlockPath.UNIFIED: + return buildUnifiedStartOutput(workflowInput) + case StartBlockPath.SPLIT_API: + case StartBlockPath.SPLIT_INPUT: + return buildApiOrInputOutput(finalInput, workflowInput) + case StartBlockPath.SPLIT_CHAT: + return buildChatOutput(workflowInput) + case StartBlockPath.SPLIT_MANUAL: + return buildManualTriggerOutput(finalInput, workflowInput) + case StartBlockPath.LEGACY_STARTER: + return buildLegacyStarterOutput( + finalInput, + workflowInput, + getLegacyStarterMode({ subBlocks: extractSubBlocks(resolution.block) }) + ) + default: + return buildManualTriggerOutput(finalInput, workflowInput) + } +} diff --git a/apps/sim/lib/workflows/block-outputs.ts b/apps/sim/lib/workflows/block-outputs.ts index 6588c9350d..27fb948098 100644 --- a/apps/sim/lib/workflows/block-outputs.ts +++ b/apps/sim/lib/workflows/block-outputs.ts @@ -1,7 +1,43 @@ +import { classifyStartBlockType, StartBlockPath } from '@/lib/workflows/triggers' import { getBlock } from '@/blocks' import type { BlockConfig } from '@/blocks/types' import { getTrigger, isTriggerValid } from '@/triggers' +type InputFormatField = { name?: string; type?: string | undefined | null } + +function normalizeInputFormatValue(inputFormatValue: any): InputFormatField[] { + if ( + inputFormatValue === null || + inputFormatValue === undefined || + (Array.isArray(inputFormatValue) && inputFormatValue.length === 0) + ) { + return [] + } + + if (!Array.isArray(inputFormatValue)) { + return [] + } + + return inputFormatValue.filter((field) => field && typeof field === 'object') +} + +function applyInputFormatFields( + inputFormat: InputFormatField[], + outputs: Record +): Record { + for (const field of inputFormat) { + const fieldName = field?.name?.trim() + if (!fieldName) continue + + outputs[fieldName] = { + type: (field?.type || 'any') as any, + description: `Field from input format`, + } + } + + return outputs +} + /** * Get the effective outputs for a block, including dynamic outputs from inputFormat * and trigger outputs for blocks in trigger mode @@ -14,7 +50,6 @@ export function getBlockOutputs( const blockConfig = getBlock(blockType) if (!blockConfig) return {} - // If block is in trigger mode, use trigger outputs instead of block outputs if (triggerMode && blockConfig.triggers?.enabled) { const selectedTriggerIdValue = subBlocks?.selectedTriggerId?.value const triggerIdValue = subBlocks?.triggerId?.value @@ -34,11 +69,33 @@ export function getBlockOutputs( } } - // Start with the static outputs defined in the config let outputs = { ...(blockConfig.outputs || {}) } + const startPath = classifyStartBlockType(blockType) + + if (startPath === StartBlockPath.UNIFIED) { + outputs = { + input: { type: 'string', description: 'Primary user input or message' }, + conversationId: { type: 'string', description: 'Conversation thread identifier' }, + files: { type: 'files', description: 'User uploaded files' }, + } + + const normalizedInputFormat = normalizeInputFormatValue(subBlocks?.inputFormat?.value) + for (const field of normalizedInputFormat) { + const fieldName = field?.name?.trim() + if (!fieldName) continue + + outputs[fieldName] = { + type: (field?.type || 'any') as any, + description: `Field from input format`, + } + } + + return outputs + } + // Special handling for starter block (legacy) - if (blockType === 'starter') { + if (startPath === StartBlockPath.LEGACY_STARTER) { const startWorkflowValue = subBlocks?.startWorkflow?.value if (startWorkflowValue === 'chat') { @@ -55,70 +112,37 @@ export function getBlockOutputs( startWorkflowValue === 'manual' ) { // API/manual mode - use inputFormat fields only - let inputFormatValue = subBlocks?.inputFormat?.value + const normalizedInputFormat = normalizeInputFormatValue(subBlocks?.inputFormat?.value) outputs = {} - - if ( - inputFormatValue !== null && - inputFormatValue !== undefined && - !Array.isArray(inputFormatValue) - ) { - inputFormatValue = [] - } - - 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 + return applyInputFormatFields(normalizedInputFormat, outputs) } } // For blocks with inputFormat, add dynamic outputs if (hasInputFormat(blockConfig) && subBlocks?.inputFormat?.value) { - let inputFormatValue = subBlocks.inputFormat.value - - // Sanitize inputFormat - ensure it's an array - if ( - inputFormatValue !== null && - inputFormatValue !== undefined && - !Array.isArray(inputFormatValue) - ) { - // Invalid format, default to empty array - inputFormatValue = [] - } + const normalizedInputFormat = normalizeInputFormatValue(subBlocks.inputFormat.value) - if (Array.isArray(inputFormatValue)) { - // For API, Input triggers, and Generic Webhook, use inputFormat fields + if (!Array.isArray(subBlocks.inputFormat.value)) { + if (blockType === 'api_trigger' || blockType === 'input_trigger') { + outputs = {} + } + } else { if ( blockType === 'api_trigger' || blockType === 'input_trigger' || blockType === 'generic_webhook' ) { - // For generic_webhook, only clear outputs if inputFormat has fields - // Otherwise keep the default outputs (pass-through body) - if (inputFormatValue.length > 0 || blockType !== 'generic_webhook') { - outputs = {} // Clear all default outputs + if (normalizedInputFormat.length > 0 || blockType !== 'generic_webhook') { + 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`, - } - } - }) + outputs = applyInputFormatFields(normalizedInputFormat, outputs) } - } else if (blockType === 'api_trigger' || blockType === 'input_trigger') { + } + + if ( + !Array.isArray(subBlocks.inputFormat.value) && + (blockType === 'api_trigger' || blockType === 'input_trigger') + ) { // If no inputFormat defined, API/Input trigger has no outputs outputs = {} } @@ -151,6 +175,15 @@ export function getBlockOutputPaths( for (const [key, value] of Object.entries(obj)) { const path = prefix ? `${prefix}.${key}` : key + // For start_trigger, skip reserved fields at root level (they're always present but hidden from dropdown) + if ( + blockType === 'start_trigger' && + !prefix && + ['input', 'conversationId', 'files'].includes(key) + ) { + continue + } + // If value has 'type' property, it's a leaf node (output definition) if (value && typeof value === 'object' && 'type' in value) { // Special handling for 'files' type - expand to show array element properties diff --git a/apps/sim/lib/workflows/defaults.ts b/apps/sim/lib/workflows/defaults.ts new file mode 100644 index 0000000000..2c992b9c0e --- /dev/null +++ b/apps/sim/lib/workflows/defaults.ts @@ -0,0 +1,125 @@ +import { getBlockOutputs } from '@/lib/workflows/block-outputs' +import { getBlock } from '@/blocks' +import type { BlockConfig, SubBlockConfig } from '@/blocks/types' +import type { BlockState, SubBlockState, WorkflowState } from '@/stores/workflows/workflow/types' + +export interface DefaultWorkflowArtifacts { + workflowState: WorkflowState + subBlockValues: Record> + startBlockId: string +} + +const START_BLOCK_TYPE = 'start_trigger' +const DEFAULT_START_POSITION = { x: 0, y: 0 } + +function cloneDefaultValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((item) => cloneDefaultValue(item)) + } + + if (value && typeof value === 'object') { + return { ...(value as Record) } + } + + return value ?? null +} + +function resolveInitialValue(subBlock: SubBlockConfig): unknown { + if (typeof subBlock.value === 'function') { + try { + return cloneDefaultValue(subBlock.value({})) + } catch (error) { + // Ignore resolution errors and fall back to default/null values + } + } + + if (subBlock.defaultValue !== undefined) { + return cloneDefaultValue(subBlock.defaultValue) + } + + // Ensure structured fields are initialized with empty collections by default + if (subBlock.type === 'input-format' || subBlock.type === 'table') { + return [] + } + + return null +} + +function buildStartBlockConfig(): BlockConfig { + const blockConfig = getBlock(START_BLOCK_TYPE) + + if (!blockConfig) { + throw new Error('Start trigger block configuration is not registered') + } + + return blockConfig +} + +function buildStartBlockState( + blockConfig: BlockConfig, + blockId: string +): { blockState: BlockState; subBlockValues: Record } { + const subBlocks: Record = {} + const subBlockValues: Record = {} + + blockConfig.subBlocks.forEach((config) => { + const initialValue = resolveInitialValue(config) + + subBlocks[config.id] = { + id: config.id, + type: config.type, + value: (initialValue ?? null) as SubBlockState['value'], + } + + subBlockValues[config.id] = initialValue ?? null + }) + + const outputs = getBlockOutputs(blockConfig.type, subBlocks) + + const blockState: BlockState = { + id: blockId, + type: blockConfig.type, + name: blockConfig.name, + position: { ...DEFAULT_START_POSITION }, + subBlocks, + outputs, + enabled: true, + horizontalHandles: true, + isWide: false, + advancedMode: false, + triggerMode: false, + height: 0, + data: {}, + } + + return { blockState, subBlockValues } +} + +export function buildDefaultWorkflowArtifacts(): DefaultWorkflowArtifacts { + const blockConfig = buildStartBlockConfig() + const startBlockId = crypto.randomUUID() + + const { blockState, subBlockValues } = buildStartBlockState(blockConfig, startBlockId) + + const workflowState: WorkflowState = { + blocks: { + [startBlockId]: blockState, + }, + edges: [], + loops: {}, + parallels: {}, + lastSaved: Date.now(), + isDeployed: false, + deployedAt: undefined, + deploymentStatuses: {}, + needsRedeployment: false, + } + + return { + workflowState, + subBlockValues: { + [startBlockId]: subBlockValues, + }, + startBlockId, + } +} diff --git a/apps/sim/lib/workflows/triggers.ts b/apps/sim/lib/workflows/triggers.ts index c2f5501107..c70dd6a161 100644 --- a/apps/sim/lib/workflows/triggers.ts +++ b/apps/sim/lib/workflows/triggers.ts @@ -10,20 +10,200 @@ export const TRIGGER_TYPES = { API: 'api_trigger', WEBHOOK: 'webhook', SCHEDULE: 'schedule', + START: 'start_trigger', STARTER: 'starter', // Legacy } as const export type TriggerType = (typeof TRIGGER_TYPES)[keyof typeof TRIGGER_TYPES] +export enum StartBlockPath { + UNIFIED = 'unified_start', + LEGACY_STARTER = 'legacy_starter', + SPLIT_INPUT = 'legacy_input_trigger', + SPLIT_API = 'legacy_api_trigger', + SPLIT_CHAT = 'legacy_chat_trigger', + SPLIT_MANUAL = 'legacy_manual_trigger', +} + +type StartExecutionKind = 'chat' | 'manual' | 'api' + +const EXECUTION_PRIORITIES: Record = { + chat: [StartBlockPath.UNIFIED, StartBlockPath.SPLIT_CHAT, StartBlockPath.LEGACY_STARTER], + manual: [ + StartBlockPath.UNIFIED, + StartBlockPath.SPLIT_API, + StartBlockPath.SPLIT_INPUT, + StartBlockPath.SPLIT_MANUAL, + StartBlockPath.LEGACY_STARTER, + ], + api: [ + StartBlockPath.UNIFIED, + StartBlockPath.SPLIT_API, + StartBlockPath.SPLIT_INPUT, + StartBlockPath.LEGACY_STARTER, + ], +} + +const CHILD_PRIORITIES: StartBlockPath[] = [ + StartBlockPath.UNIFIED, + StartBlockPath.SPLIT_INPUT, + StartBlockPath.LEGACY_STARTER, +] + +const START_CONFLICT_TYPES: TriggerType[] = [ + TRIGGER_TYPES.START, + TRIGGER_TYPES.API, + TRIGGER_TYPES.INPUT, + TRIGGER_TYPES.MANUAL, + TRIGGER_TYPES.CHAT, +] + +type BlockWithType = { type: string; subBlocks?: Record | undefined } + +export interface StartBlockCandidate { + blockId: string + block: T + path: StartBlockPath +} + +export function classifyStartBlockType(type: string): StartBlockPath | null { + switch (type) { + case TRIGGER_TYPES.START: + return StartBlockPath.UNIFIED + case TRIGGER_TYPES.STARTER: + return StartBlockPath.LEGACY_STARTER + case TRIGGER_TYPES.INPUT: + return StartBlockPath.SPLIT_INPUT + case TRIGGER_TYPES.API: + return StartBlockPath.SPLIT_API + case TRIGGER_TYPES.CHAT: + return StartBlockPath.SPLIT_CHAT + case TRIGGER_TYPES.MANUAL: + return StartBlockPath.SPLIT_MANUAL + default: + return null + } +} + +export function classifyStartBlock(block: T): StartBlockPath | null { + return classifyStartBlockType(block.type) +} + +export function isLegacyStartPath(path: StartBlockPath): boolean { + return path !== StartBlockPath.UNIFIED +} + +function toEntries(blocks: Record | T[]): Array<[string, T]> { + if (Array.isArray(blocks)) { + return blocks.map((block, index) => { + const potentialId = (block as { id?: unknown }).id + const inferredId = typeof potentialId === 'string' ? potentialId : `${index}` + return [inferredId, block] + }) + } + return Object.entries(blocks) +} + +type ResolveStartOptions = { + execution: StartExecutionKind + isChildWorkflow?: boolean + allowLegacyStarter?: boolean +} + +function supportsExecution(path: StartBlockPath, execution: StartExecutionKind): boolean { + if (path === StartBlockPath.UNIFIED || path === StartBlockPath.LEGACY_STARTER) { + return true + } + + if (execution === 'chat') { + return path === StartBlockPath.SPLIT_CHAT + } + + if (execution === 'api') { + return path === StartBlockPath.SPLIT_API || path === StartBlockPath.SPLIT_INPUT + } + + return ( + path === StartBlockPath.SPLIT_API || + path === StartBlockPath.SPLIT_INPUT || + path === StartBlockPath.SPLIT_MANUAL + ) +} + +export function resolveStartCandidates( + blocks: Record | T[], + options: ResolveStartOptions +): StartBlockCandidate[] { + const entries = toEntries(blocks) + if (entries.length === 0) return [] + + const priorities = options.isChildWorkflow + ? CHILD_PRIORITIES + : EXECUTION_PRIORITIES[options.execution] + + const candidates: StartBlockCandidate[] = [] + + for (const [blockId, block] of entries) { + const path = classifyStartBlock(block) + if (!path) continue + + if (options.isChildWorkflow) { + if (!CHILD_PRIORITIES.includes(path)) { + continue + } + } else if (!supportsExecution(path, options.execution)) { + continue + } + + if (path === StartBlockPath.LEGACY_STARTER && options.allowLegacyStarter === false) { + continue + } + + candidates.push({ blockId, block, path }) + } + + candidates.sort((a, b) => { + const order = options.isChildWorkflow ? CHILD_PRIORITIES : priorities + const aIdx = order.indexOf(a.path) + const bIdx = order.indexOf(b.path) + if (aIdx === -1 && bIdx === -1) return 0 + if (aIdx === -1) return 1 + if (bIdx === -1) return -1 + return aIdx - bIdx + }) + + return candidates +} + +type SubBlockWithValue = { value?: unknown } + +function readSubBlockValue(subBlocks: Record | undefined, key: string): unknown { + const raw = subBlocks?.[key] + if (raw && typeof raw === 'object' && !Array.isArray(raw)) { + return (raw as SubBlockWithValue).value + } + return undefined +} + +export function getLegacyStarterMode(block: { + subBlocks?: Record +}): 'manual' | 'api' | 'chat' | null { + const modeValue = readSubBlockValue(block.subBlocks, 'startWorkflow') + if (modeValue === 'chat') return 'chat' + if (modeValue === 'api' || modeValue === 'run') return 'api' + if (modeValue === undefined || modeValue === 'manual') return 'manual' + return null +} + /** * 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, + start: TRIGGER_TYPES.START, api: TRIGGER_TYPES.API, chat: TRIGGER_TYPES.CHAT, - manual: TRIGGER_TYPES.INPUT, + manual: TRIGGER_TYPES.START, } as const export type TriggerReferenceAlias = keyof typeof TRIGGER_REFERENCE_ALIAS_MAP @@ -66,7 +246,7 @@ export class TriggerUtils { * Check if a block is a chat-compatible trigger */ static isChatTrigger(block: { type: string; subBlocks?: any }): boolean { - if (block.type === TRIGGER_TYPES.CHAT) { + if (block.type === TRIGGER_TYPES.CHAT || block.type === TRIGGER_TYPES.START) { return true } @@ -82,7 +262,11 @@ export class TriggerUtils { * Check if a block is a manual-compatible trigger */ static isManualTrigger(block: { type: string; subBlocks?: any }): boolean { - if (block.type === TRIGGER_TYPES.INPUT || block.type === TRIGGER_TYPES.MANUAL) { + if ( + block.type === TRIGGER_TYPES.INPUT || + block.type === TRIGGER_TYPES.MANUAL || + block.type === TRIGGER_TYPES.START + ) { return true } @@ -103,11 +287,11 @@ export class TriggerUtils { */ 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 + // Child workflows (workflow-in-workflow) support legacy input trigger and new start block + return block.type === TRIGGER_TYPES.INPUT || block.type === TRIGGER_TYPES.START } - // Direct API calls only work with api_trigger - if (block.type === TRIGGER_TYPES.API) { + // Direct API calls work with api_trigger and the new start block + if (block.type === TRIGGER_TYPES.API || block.type === TRIGGER_TYPES.START) { return true } @@ -144,6 +328,8 @@ export class TriggerUtils { return 'Manual' case TRIGGER_TYPES.API: return 'API' + case TRIGGER_TYPES.START: + return 'Start' case TRIGGER_TYPES.WEBHOOK: return 'Webhook' case TRIGGER_TYPES.SCHEDULE: @@ -182,25 +368,18 @@ export class TriggerUtils { 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] } - } - } + ): (StartBlockCandidate & { block: T }) | null { + const candidates = resolveStartCandidates(blocks, { + execution: executionType, + isChildWorkflow, + }) - // 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] } + if (candidates.length === 0) { + return null } - return null + const [primary] = candidates + return primary } /** @@ -227,7 +406,8 @@ export class TriggerUtils { triggerType === TRIGGER_TYPES.API || triggerType === TRIGGER_TYPES.INPUT || triggerType === TRIGGER_TYPES.MANUAL || - triggerType === TRIGGER_TYPES.CHAT + triggerType === TRIGGER_TYPES.CHAT || + triggerType === TRIGGER_TYPES.START ) } @@ -255,7 +435,8 @@ export class TriggerUtils { triggerType === TRIGGER_TYPES.CHAT || triggerType === TRIGGER_TYPES.INPUT || triggerType === TRIGGER_TYPES.MANUAL || - triggerType === TRIGGER_TYPES.API + triggerType === TRIGGER_TYPES.API || + triggerType === TRIGGER_TYPES.START ) { return true } @@ -267,13 +448,19 @@ export class TriggerUtils { block.type === TRIGGER_TYPES.CHAT || block.type === TRIGGER_TYPES.INPUT || block.type === TRIGGER_TYPES.MANUAL || - block.type === TRIGGER_TYPES.API + block.type === TRIGGER_TYPES.API || + block.type === TRIGGER_TYPES.START ) if (hasModernTriggers) { return true } } + // Start trigger cannot coexist with other single-instance trigger types + if (triggerType === TRIGGER_TYPES.START) { + return blockArray.some((block) => START_CONFLICT_TYPES.includes(block.type as TriggerType)) + } + // Only one Input trigger allowed if (triggerType === TRIGGER_TYPES.INPUT) { return blockArray.some((block) => block.type === TRIGGER_TYPES.INPUT) diff --git a/apps/sim/stores/workflows/registry/store.ts b/apps/sim/stores/workflows/registry/store.ts index f0cd3a7491..32ffb767fb 100644 --- a/apps/sim/stores/workflows/registry/store.ts +++ b/apps/sim/stores/workflows/registry/store.ts @@ -2,6 +2,7 @@ import { create } from 'zustand' import { devtools } from 'zustand/middleware' import { createLogger } from '@/lib/logs/console/logger' import { generateCreativeWorkflowName } from '@/lib/naming' +import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults' import { API_ENDPOINTS } from '@/stores/constants' import { useVariablesStore } from '@/stores/panel/variables/store' import type { @@ -439,7 +440,7 @@ export const useWorkflowRegistry = create()( deploymentStatuses: {}, } } else { - // If no state in DB, use empty state - server should have created start block + // If no state in DB, use empty state (Start block was created during workflow creation) workflowState = { blocks: {}, edges: [], @@ -451,9 +452,7 @@ export const useWorkflowRegistry = create()( lastSaved: Date.now(), } - logger.warn( - `Workflow ${id} has no state in DB - this should not happen with server-side start block creation` - ) + logger.info(`Workflow ${id} has no state yet - will load from DB or show empty canvas`) } if (workflowData?.isDeployed || workflowData?.deployedAt) { @@ -548,16 +547,34 @@ export const useWorkflowRegistry = create()( })) // Initialize subblock values to ensure they're available for sync - const subblockValues: Record> = {} + const { workflowState, subBlockValues } = buildDefaultWorkflowArtifacts() - // Update the subblock store with the initial values useSubBlockStore.setState((state) => ({ workflowValues: { ...state.workflowValues, - [serverWorkflowId]: subblockValues, + [serverWorkflowId]: subBlockValues, }, })) + try { + logger.info(`Persisting default Start block for new workflow ${serverWorkflowId}`) + const response = await fetch(`/api/workflows/${serverWorkflowId}/state`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(workflowState), + }) + + if (!response.ok) { + logger.error('Failed to persist default Start block:', await response.text()) + } else { + logger.info('Successfully persisted default Start block') + } + } catch (error) { + logger.error('Error persisting default Start block:', error) + } + // Don't set as active workflow here - let the navigation/URL change handle that // This prevents race conditions and flickering logger.info( @@ -652,106 +669,12 @@ export const useWorkflowRegistry = create()( parallels: currentWorkflowState.parallels || {}, } } else { - // Source is not active workflow, create with starter block for now - // In a future enhancement, we could fetch from DB - 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, - } - + const { workflowState } = buildDefaultWorkflowArtifacts() sourceState = { - blocks: { [starterId]: starterBlock }, - edges: [], - loops: {}, - parallels: {}, + blocks: workflowState.blocks, + edges: workflowState.edges, + loops: workflowState.loops, + parallels: workflowState.parallels, } }