diff --git a/apps/docs/content/docs/blocks/meta.json b/apps/docs/content/docs/blocks/meta.json index 770522e1dd7..b8bfa7fa993 100644 --- a/apps/docs/content/docs/blocks/meta.json +++ b/apps/docs/content/docs/blocks/meta.json @@ -1,4 +1,4 @@ { "title": "Blocks", - "pages": ["agent", "api", "condition", "function", "evaluator", "router"] + "pages": ["agent", "api", "condition", "function", "evaluator", "router", "workflow"] } diff --git a/apps/docs/content/docs/blocks/workflow.mdx b/apps/docs/content/docs/blocks/workflow.mdx new file mode 100644 index 00000000000..f45e0ce4173 --- /dev/null +++ b/apps/docs/content/docs/blocks/workflow.mdx @@ -0,0 +1,231 @@ +--- +title: Workflow +description: Execute other workflows as reusable components within your current workflow +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { Step, Steps } from 'fumadocs-ui/components/steps' +import { Tab, Tabs } from 'fumadocs-ui/components/tabs' +import { ThemeImage } from '@/components/ui/theme-image' + +The Workflow block allows you to execute other workflows as reusable components within your current workflow. This powerful feature enables modular design, code reuse, and the creation of complex nested workflows that can be composed from smaller, focused workflows. + + + + + Workflow blocks enable modular design by allowing you to compose complex workflows from smaller, reusable components. + + +## Overview + +The Workflow block serves as a bridge between workflows, enabling you to: + + + + Reuse existing workflows: Execute previously created workflows as components within new workflows + + + Create modular designs: Break down complex processes into smaller, manageable workflows + + + Maintain separation of concerns: Keep different business logic isolated in separate workflows + + + Enable team collaboration: Share and reuse workflows across different projects and team members + + + +## How It Works + +The Workflow block: + +1. Takes a reference to another workflow in your workspace +2. Passes input data from the current workflow to the child workflow +3. Executes the child workflow in an isolated context +4. Returns the results back to the parent workflow for further processing + +## Configuration Options + +### Workflow Selection + +Choose which workflow to execute from a dropdown list of available workflows in your workspace. The list includes: + +- All workflows you have access to in the current workspace +- Workflows shared with you by other team members +- Both enabled and disabled workflows (though only enabled workflows can be executed) + +### Input Data + +Define the data to pass to the child workflow: + +- **Single Variable Input**: Select a variable or block output to pass to the child workflow +- **Variable References**: Use `` to reference workflow variables +- **Block References**: Use `` to reference outputs from previous blocks +- **Automatic Mapping**: The selected data is automatically available as `start.response.input` in the child workflow +- **Optional**: The input field is optional - child workflows can run without input data +- **Type Preservation**: Variable types (strings, numbers, objects, etc.) are preserved when passed to the child workflow + +### Examples of Input References + +- `` - Pass a workflow variable +- `` - Pass the result from a previous block +- `` - Pass the original workflow input +- `` - Pass a specific field from an API response + +### Execution Context + +The child workflow executes with: + +- Its own isolated execution context +- Access to the same workspace resources (API keys, environment variables) +- Proper workspace membership and permission checks +- Independent logging and monitoring + +## Safety and Limitations + +To prevent infinite recursion and ensure system stability, the Workflow block includes several safety mechanisms: + + + **Cycle Detection**: The system automatically detects and prevents circular dependencies between workflows to avoid infinite loops. + + +- **Maximum Depth Limit**: Nested workflows are limited to a maximum depth of 10 levels +- **Cycle Detection**: Automatic detection and prevention of circular workflow dependencies +- **Timeout Protection**: Child workflows inherit timeout settings to prevent indefinite execution +- **Resource Limits**: Memory and execution time limits apply to prevent resource exhaustion + +## Inputs and Outputs + + + +
    +
  • + Workflow ID: The identifier of the workflow to execute +
  • +
  • + Input Variable: Variable or block reference to pass to the child workflow (e.g., `` or ``) +
  • +
+
+ +
    +
  • + Response: The complete output from the child workflow execution +
  • +
  • + Child Workflow Name: The name of the executed child workflow +
  • +
  • + Success Status: Boolean indicating whether the child workflow completed successfully +
  • +
  • + Error Information: Details about any errors that occurred during execution +
  • +
  • + Execution Metadata: Information about execution time, resource usage, and performance +
  • +
+
+
+ +## Example Usage + +Here's an example of how a Workflow block might be used to create a modular customer onboarding process: + +### Parent Workflow: Customer Onboarding +```yaml +# Main customer onboarding workflow +blocks: + - type: workflow + name: "Validate Customer Data" + workflowId: "customer-validation-workflow" + input: "" + + - type: workflow + name: "Setup Customer Account" + workflowId: "account-setup-workflow" + input: "" + + - type: workflow + name: "Send Welcome Email" + workflowId: "welcome-email-workflow" + input: "" +``` + +### Child Workflow: Customer Validation +```yaml +# Reusable customer validation workflow +# Access the input data using: start.response.input +blocks: + - type: function + name: "Validate Email" + code: | + const customerData = start.response.input; + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(customerData.email); + + - type: api + name: "Check Credit Score" + url: "https://api.creditcheck.com/score" + method: "POST" + body: "" +``` + +### Variable Reference Examples + +```yaml +# Using workflow variables +input: "" + +# Using block outputs +input: "" + +# Using nested object properties +input: "" + +# Using array elements (if supported by the resolver) +input: "" +``` + +## Access Control and Permissions + +The Workflow block respects workspace permissions and access controls: + +- **Workspace Membership**: Only workflows within the same workspace can be executed +- **Permission Inheritance**: Child workflows inherit the execution permissions of the parent workflow +- **API Key Access**: Child workflows have access to the same API keys and environment variables as the parent +- **User Context**: The execution maintains the original user context for audit and logging purposes + +## Best Practices + +- **Keep workflows focused**: Design child workflows to handle specific, well-defined tasks +- **Minimize nesting depth**: Avoid deeply nested workflow hierarchies for better maintainability +- **Handle errors gracefully**: Implement proper error handling for child workflow failures +- **Document dependencies**: Clearly document which workflows depend on others +- **Version control**: Consider versioning strategies for workflows that are used as components +- **Test independently**: Ensure child workflows can be tested and validated independently +- **Monitor performance**: Be aware that nested workflows can impact overall execution time + +## Common Patterns + +### Microservice Architecture +Break down complex business processes into smaller, focused workflows that can be developed and maintained independently. + +### Reusable Components +Create library workflows for common operations like data validation, email sending, or API integrations that can be reused across multiple projects. + +### Conditional Execution +Use workflow blocks within conditional logic to execute different business processes based on runtime conditions. + +### Parallel Processing +Combine workflow blocks with parallel execution to run multiple child workflows simultaneously for improved performance. + + + When designing modular workflows, think of each workflow as a function with clear inputs, outputs, and a single responsibility. + \ No newline at end of file diff --git a/apps/docs/public/static/dark/workflow-dark.png b/apps/docs/public/static/dark/workflow-dark.png new file mode 100644 index 00000000000..6a03a49990a Binary files /dev/null and b/apps/docs/public/static/dark/workflow-dark.png differ diff --git a/apps/docs/public/static/light/workflow-light.png b/apps/docs/public/static/light/workflow-light.png new file mode 100644 index 00000000000..b27376fefeb Binary files /dev/null and b/apps/docs/public/static/light/workflow-light.png differ diff --git a/apps/sim/app/api/workflows/[id]/route.ts b/apps/sim/app/api/workflows/[id]/route.ts new file mode 100644 index 00000000000..ac317fb6faf --- /dev/null +++ b/apps/sim/app/api/workflows/[id]/route.ts @@ -0,0 +1,79 @@ +import { and, eq } from 'drizzle-orm' +import { NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' +import { createLogger } from '@/lib/logs/console-logger' +import { db } from '@/db' +import { workflow, workspaceMember } from '@/db/schema' + +const logger = createLogger('WorkflowDetailAPI') + +export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) { + const requestId = crypto.randomUUID().slice(0, 8) + const startTime = Date.now() + + try { + // Get the session + const session = await getSession() + if (!session?.user?.id) { + logger.warn(`[${requestId}] Unauthorized workflow access attempt`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: workflowId } = await params + + if (!workflowId) { + return NextResponse.json({ error: 'Workflow ID is required' }, { status: 400 }) + } + + // Fetch the workflow from database + const workflowData = await db + .select() + .from(workflow) + .where(eq(workflow.id, workflowId)) + .then((rows) => rows[0]) + + if (!workflowData) { + logger.warn(`[${requestId}] Workflow ${workflowId} not found`) + return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) + } + + // Check if user has access to this workflow + // User can access if they own it OR if it's in a workspace they're part of + const canAccess = workflowData.userId === session.user.id + + if (!canAccess && workflowData.workspaceId) { + // Check workspace membership + const membership = await db + .select() + .from(workspaceMember) + .where( + and( + eq(workspaceMember.workspaceId, workflowData.workspaceId), + eq(workspaceMember.userId, session.user.id) + ) + ) + .then((rows) => rows[0]) + + if (membership) { + // User is a member of the workspace, allow access + const elapsed = Date.now() - startTime + logger.info(`[${requestId}] Workflow ${workflowId} fetched in ${elapsed}ms`) + return NextResponse.json({ data: workflowData }, { status: 200 }) + } + } else if (canAccess) { + // User owns the workflow, allow access + const elapsed = Date.now() - startTime + logger.info(`[${requestId}] Workflow ${workflowId} fetched in ${elapsed}ms`) + return NextResponse.json({ data: workflowData }, { status: 200 }) + } + + logger.warn( + `[${requestId}] User ${session.user.id} attempted to access workflow ${workflowId} without permission` + ) + return NextResponse.json({ error: 'Access denied' }, { status: 403 }) + } catch (error: any) { + const elapsed = Date.now() - startTime + logger.error(`[${requestId}] Error fetching workflow after ${elapsed}ms:`, error) + return NextResponse.json({ error: 'Failed to fetch workflow' }, { status: 500 }) + } +} diff --git a/apps/sim/blocks/blocks/workflow.ts b/apps/sim/blocks/blocks/workflow.ts new file mode 100644 index 00000000000..c46b9f92761 --- /dev/null +++ b/apps/sim/blocks/blocks/workflow.ts @@ -0,0 +1,86 @@ +import { ComponentIcon } from '@/components/icons' +import { createLogger } from '@/lib/logs/console-logger' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +import type { ToolResponse } from '@/tools/types' +import type { BlockConfig } from '../types' + +const logger = createLogger('WorkflowBlock') + +interface WorkflowResponse extends ToolResponse { + output: { + success: boolean + childWorkflowName: string + result: any + error?: string + } +} + +// Helper function to get available workflows for the dropdown +const getAvailableWorkflows = (): Array<{ label: string; id: string }> => { + try { + const { workflows, activeWorkflowId } = useWorkflowRegistry.getState() + + // Filter out the current workflow to prevent recursion + const availableWorkflows = Object.entries(workflows) + .filter(([id]) => id !== activeWorkflowId) + .map(([id, workflow]) => ({ + label: workflow.name || `Workflow ${id.slice(0, 8)}`, + id: id, + })) + .sort((a, b) => a.label.localeCompare(b.label)) + + return availableWorkflows + } catch (error) { + logger.error('Error getting available workflows:', error) + return [] + } +} + +export const WorkflowBlock: BlockConfig = { + type: 'workflow', + name: 'Workflow', + description: 'Execute another workflow as a block', + category: 'blocks', + bgColor: '#6366f1', + icon: ComponentIcon, + subBlocks: [ + { + id: 'workflowId', + title: 'Select Workflow', + type: 'dropdown', + options: getAvailableWorkflows, + }, + { + id: 'input', + title: 'Input Variable (Optional)', + type: 'short-input', + placeholder: 'Select a variable to pass to the child workflow', + description: 'This variable will be available as start.response.input in the child workflow', + }, + ], + tools: { + access: ['workflow_executor'], + }, + inputs: { + workflowId: { + type: 'string', + required: true, + description: 'ID of the workflow to execute', + }, + input: { + type: 'string', + required: false, + description: 'Variable reference to pass to the child workflow', + }, + }, + outputs: { + response: { + type: { + success: 'boolean', + childWorkflowName: 'string', + result: 'json', + error: 'string', + }, + }, + }, +} diff --git a/apps/sim/blocks/registry.ts b/apps/sim/blocks/registry.ts index aab7f1419f9..9fb874bc0a4 100644 --- a/apps/sim/blocks/registry.ts +++ b/apps/sim/blocks/registry.ts @@ -61,6 +61,7 @@ import { TwilioSMSBlock } from './blocks/twilio' import { TypeformBlock } from './blocks/typeform' import { VisionBlock } from './blocks/vision' import { WhatsAppBlock } from './blocks/whatsapp' +import { WorkflowBlock } from './blocks/workflow' import { XBlock } from './blocks/x' import { YouTubeBlock } from './blocks/youtube' import type { BlockConfig } from './types' @@ -123,6 +124,7 @@ export const registry: Record = { typeform: TypeformBlock, vision: VisionBlock, whatsapp: WhatsAppBlock, + workflow: WorkflowBlock, x: XBlock, youtube: YouTubeBlock, huggingface: HuggingFaceBlock, diff --git a/apps/sim/executor/__test-utils__/executor-mocks.ts b/apps/sim/executor/__test-utils__/executor-mocks.ts index f6988a5c445..43771b070e2 100644 --- a/apps/sim/executor/__test-utils__/executor-mocks.ts +++ b/apps/sim/executor/__test-utils__/executor-mocks.ts @@ -37,6 +37,7 @@ export const setupHandlerMocks = () => { ApiBlockHandler: createMockHandler('api'), LoopBlockHandler: createMockHandler('loop'), ParallelBlockHandler: createMockHandler('parallel'), + WorkflowBlockHandler: createMockHandler('workflow'), GenericBlockHandler: createMockHandler('generic'), })) } diff --git a/apps/sim/executor/handlers/index.ts b/apps/sim/executor/handlers/index.ts index c054ad487ce..51ad100c5ae 100644 --- a/apps/sim/executor/handlers/index.ts +++ b/apps/sim/executor/handlers/index.ts @@ -7,6 +7,7 @@ import { GenericBlockHandler } from './generic/generic-handler' import { LoopBlockHandler } from './loop/loop-handler' import { ParallelBlockHandler } from './parallel/parallel-handler' import { RouterBlockHandler } from './router/router-handler' +import { WorkflowBlockHandler } from './workflow/workflow-handler' export { AgentBlockHandler, @@ -18,4 +19,5 @@ export { LoopBlockHandler, ParallelBlockHandler, RouterBlockHandler, + WorkflowBlockHandler, } diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.test.ts b/apps/sim/executor/handlers/workflow/workflow-handler.test.ts new file mode 100644 index 00000000000..69c6a6f2e06 --- /dev/null +++ b/apps/sim/executor/handlers/workflow/workflow-handler.test.ts @@ -0,0 +1,273 @@ +import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest' +import type { SerializedBlock } from '@/serializer/types' +import type { ExecutionContext } from '../../types' +import { WorkflowBlockHandler } from './workflow-handler' + +// Mock fetch globally +global.fetch = vi.fn() + +describe('WorkflowBlockHandler', () => { + let handler: WorkflowBlockHandler + let mockBlock: SerializedBlock + let mockContext: ExecutionContext + let mockFetch: Mock + + beforeEach(() => { + handler = new WorkflowBlockHandler() + mockFetch = global.fetch as Mock + + mockBlock = { + id: 'workflow-block-1', + metadata: { id: 'workflow', name: 'Test Workflow Block' }, + position: { x: 0, y: 0 }, + config: { tool: 'workflow', params: {} }, + inputs: { workflowId: 'string' }, + outputs: {}, + enabled: true, + } + + mockContext = { + workflowId: 'parent-workflow-id', + blockStates: new Map(), + blockLogs: [], + metadata: { duration: 0 }, + environmentVariables: {}, + decisions: { router: new Map(), condition: new Map() }, + loopIterations: new Map(), + loopItems: new Map(), + executedBlocks: new Set(), + activeExecutionPath: new Set(), + completedLoops: new Set(), + workflow: { + version: '1.0', + blocks: [], + connections: [], + loops: {}, + }, + } + + // Reset all mocks + vi.clearAllMocks() + + // Clear the static execution stack + + ;(WorkflowBlockHandler as any).executionStack.clear() + + // Setup default fetch mock + mockFetch.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + data: { + name: 'Child Workflow', + state: { + blocks: [ + { + id: 'starter', + metadata: { id: 'starter', name: 'Starter' }, + position: { x: 0, y: 0 }, + config: { tool: 'starter', params: {} }, + inputs: {}, + outputs: {}, + enabled: true, + }, + ], + edges: [], + loops: {}, + parallels: {}, + }, + }, + }), + }) + }) + + describe('canHandle', () => { + it('should handle workflow blocks', () => { + expect(handler.canHandle(mockBlock)).toBe(true) + }) + + it('should not handle non-workflow blocks', () => { + const nonWorkflowBlock = { ...mockBlock, metadata: { id: 'function' } } + expect(handler.canHandle(nonWorkflowBlock)).toBe(false) + }) + }) + + describe('execute', () => { + it('should throw error when no workflowId is provided', async () => { + const inputs = {} + + await expect(handler.execute(mockBlock, inputs, mockContext)).rejects.toThrow( + 'No workflow selected for execution' + ) + }) + + it('should detect and prevent cyclic dependencies', async () => { + const inputs = { workflowId: 'child-workflow-id' } + + // Simulate a cycle by adding the execution to the stack + + ;(WorkflowBlockHandler as any).executionStack.add('parent-workflow-id_sub_child-workflow-id') + + const result = await handler.execute(mockBlock, inputs, mockContext) + + expect(result).toEqual({ + success: false, + error: 'Cyclic workflow dependency detected: parent-workflow-id_sub_child-workflow-id', + childWorkflowName: 'child-workflow-id', + }) + }) + + it('should enforce maximum depth limit', async () => { + const inputs = { workflowId: 'child-workflow-id' } + + // Create a deeply nested context (simulate 11 levels deep to exceed the limit of 10) + const deepContext = { + ...mockContext, + workflowId: + 'level1_sub_level2_sub_level3_sub_level4_sub_level5_sub_level6_sub_level7_sub_level8_sub_level9_sub_level10_sub_level11', + } + + const result = await handler.execute(mockBlock, inputs, deepContext) + + expect(result).toEqual({ + success: false, + error: 'Maximum workflow nesting depth of 10 exceeded', + childWorkflowName: 'child-workflow-id', + }) + }) + + it('should handle child workflow not found', async () => { + const inputs = { workflowId: 'non-existent-workflow' } + + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: 'Not Found', + }) + + const result = await handler.execute(mockBlock, inputs, mockContext) + + expect(result).toEqual({ + success: false, + error: 'Child workflow non-existent-workflow not found', + childWorkflowName: 'non-existent-workflow', + }) + }) + + it('should handle fetch errors gracefully', async () => { + const inputs = { workflowId: 'child-workflow-id' } + + mockFetch.mockRejectedValueOnce(new Error('Network error')) + + const result = await handler.execute(mockBlock, inputs, mockContext) + + expect(result).toEqual({ + success: false, + error: 'Child workflow child-workflow-id not found', + childWorkflowName: 'child-workflow-id', + }) + }) + }) + + describe('loadChildWorkflow', () => { + it('should return null for 404 responses', async () => { + const workflowId = 'non-existent-workflow' + + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: 'Not Found', + }) + + const result = await (handler as any).loadChildWorkflow(workflowId) + + expect(result).toBeNull() + }) + + it('should handle invalid workflow state', async () => { + const workflowId = 'invalid-workflow' + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + data: { + name: 'Invalid Workflow', + state: null, // Invalid state + }, + }), + }) + + const result = await (handler as any).loadChildWorkflow(workflowId) + + expect(result).toBeNull() + }) + }) + + describe('mapChildOutputToParent', () => { + it('should map successful child output correctly', () => { + const childResult = { + success: true, + output: { response: { data: 'test result' } }, + } + + const result = (handler as any).mapChildOutputToParent( + childResult, + 'child-id', + 'Child Workflow', + 100 + ) + + expect(result).toEqual({ + response: { + success: true, + childWorkflowName: 'Child Workflow', + result: { data: 'test result' }, + }, + }) + }) + + it('should map failed child output correctly', () => { + const childResult = { + success: false, + error: 'Child workflow failed', + } + + const result = (handler as any).mapChildOutputToParent( + childResult, + 'child-id', + 'Child Workflow', + 100 + ) + + expect(result).toEqual({ + response: { + success: false, + childWorkflowName: 'Child Workflow', + error: 'Child workflow failed', + }, + }) + }) + + it('should handle nested response structures', () => { + const childResult = { + response: { response: { nested: 'data' } }, + } + + const result = (handler as any).mapChildOutputToParent( + childResult, + 'child-id', + 'Child Workflow', + 100 + ) + + expect(result).toEqual({ + response: { + success: true, + childWorkflowName: 'Child Workflow', + result: { nested: 'data' }, + }, + }) + }) + }) +}) diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.ts b/apps/sim/executor/handlers/workflow/workflow-handler.ts new file mode 100644 index 00000000000..3aa0cca268a --- /dev/null +++ b/apps/sim/executor/handlers/workflow/workflow-handler.ts @@ -0,0 +1,214 @@ +import { createLogger } from '@/lib/logs/console-logger' +import type { BlockOutput } from '@/blocks/types' +import { Serializer } from '@/serializer' +import type { SerializedBlock } from '@/serializer/types' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +import { Executor } from '../../index' +import type { BlockHandler, ExecutionContext, StreamingExecution } from '../../types' + +const logger = createLogger('WorkflowBlockHandler') + +// Maximum allowed depth for nested workflow executions +const MAX_WORKFLOW_DEPTH = 10 + +/** + * Handler for workflow blocks that execute other workflows inline. + * Creates sub-execution contexts and manages data flow between parent and child workflows. + */ +export class WorkflowBlockHandler implements BlockHandler { + private serializer = new Serializer() + private static executionStack = new Set() + + canHandle(block: SerializedBlock): boolean { + return block.metadata?.id === 'workflow' + } + + async execute( + block: SerializedBlock, + inputs: Record, + context: ExecutionContext + ): Promise { + logger.info(`Executing workflow block: ${block.id}`) + + const workflowId = inputs.workflowId + + if (!workflowId) { + throw new Error('No workflow selected for execution') + } + + try { + // Check execution depth + const currentDepth = (context.workflowId?.split('_sub_').length || 1) - 1 + if (currentDepth >= MAX_WORKFLOW_DEPTH) { + throw new Error(`Maximum workflow nesting depth of ${MAX_WORKFLOW_DEPTH} exceeded`) + } + + // Check for cycles + const executionId = `${context.workflowId}_sub_${workflowId}` + if (WorkflowBlockHandler.executionStack.has(executionId)) { + throw new Error(`Cyclic workflow dependency detected: ${executionId}`) + } + + // Add current execution to stack + WorkflowBlockHandler.executionStack.add(executionId) + + // Load the child workflow from API + const childWorkflow = await this.loadChildWorkflow(workflowId) + + if (!childWorkflow) { + throw new Error(`Child workflow ${workflowId} not found`) + } + + // Get workflow metadata for logging + const { workflows } = useWorkflowRegistry.getState() + const workflowMetadata = workflows[workflowId] + const childWorkflowName = workflowMetadata?.name || childWorkflow.name || 'Unknown Workflow' + + logger.info( + `Executing child workflow: ${childWorkflowName} (${workflowId}) at depth ${currentDepth}` + ) + + // Prepare the input for the child workflow + // The input from this block should be passed as start.response.input to the child workflow + let childWorkflowInput = {} + + if (inputs.input !== undefined) { + // If input is provided, use it directly + childWorkflowInput = inputs.input + logger.info(`Passing input to child workflow: ${JSON.stringify(childWorkflowInput)}`) + } + + // Remove the workflowId from the input to avoid confusion + const { workflowId: _, input: __, ...otherInputs } = inputs + + // Execute child workflow inline + const subExecutor = new Executor({ + workflow: childWorkflow.serializedState, + workflowInput: childWorkflowInput, + envVarValues: context.environmentVariables, + }) + + const startTime = performance.now() + const result = await subExecutor.execute(executionId) + const duration = performance.now() - startTime + + // Remove current execution from stack after completion + WorkflowBlockHandler.executionStack.delete(executionId) + + // Log execution completion + logger.info(`Child workflow ${childWorkflowName} completed in ${Math.round(duration)}ms`) + + // Map child workflow output to parent block output + return this.mapChildOutputToParent(result, workflowId, childWorkflowName, duration) + } catch (error: any) { + logger.error(`Error executing child workflow ${workflowId}:`, error) + + // Clean up execution stack in case of error + const executionId = `${context.workflowId}_sub_${workflowId}` + WorkflowBlockHandler.executionStack.delete(executionId) + + // Get workflow name for error reporting + const { workflows } = useWorkflowRegistry.getState() + const workflowMetadata = workflows[workflowId] + const childWorkflowName = workflowMetadata?.name || workflowId + + return { + success: false, + error: error.message || 'Child workflow execution failed', + childWorkflowName: childWorkflowName, + } as Record + } + } + + /** + * Loads a child workflow from the API + */ + private async loadChildWorkflow(workflowId: string) { + try { + // Fetch workflow from API + const response = await fetch(`/api/workflows/${workflowId}`) + + if (!response.ok) { + if (response.status === 404) { + logger.error(`Child workflow ${workflowId} not found`) + return null + } + throw new Error(`Failed to fetch workflow: ${response.status} ${response.statusText}`) + } + + const { data: workflowData } = await response.json() + + if (!workflowData) { + logger.error(`Child workflow ${workflowId} returned empty data`) + return null + } + + logger.info(`Loaded child workflow: ${workflowData.name} (${workflowId})`) + + // Extract the workflow state + const workflowState = workflowData.state + + if (!workflowState || !workflowState.blocks) { + logger.error(`Child workflow ${workflowId} has invalid state`) + return null + } + + // Use blocks directly since DB format should match UI format + const serializedWorkflow = this.serializer.serializeWorkflow( + workflowState.blocks, + workflowState.edges || [], + workflowState.loops || {}, + workflowState.parallels || {} + ) + + return { + name: workflowData.name, + serializedState: serializedWorkflow, + } + } catch (error) { + logger.error(`Error loading child workflow ${workflowId}:`, error) + return null + } + } + + /** + * Maps child workflow output to parent block output format + */ + private mapChildOutputToParent( + childResult: any, + childWorkflowId: string, + childWorkflowName: string, + duration: number + ): BlockOutput { + const success = childResult.success !== false + + // If child workflow failed, return minimal output + if (!success) { + logger.warn(`Child workflow ${childWorkflowName} failed`) + return { + response: { + success: false, + childWorkflowName, + error: childResult.error || 'Child workflow execution failed', + }, + } as Record + } + + // Extract the actual result content from the nested structure + let result = childResult + if (childResult?.output?.response) { + result = childResult.output.response + } else if (childResult?.response?.response) { + result = childResult.response.response + } + + // Return a properly structured response with all required fields + return { + response: { + success: true, + childWorkflowName, + result, + }, + } as Record + } +} diff --git a/apps/sim/executor/index.test.ts b/apps/sim/executor/index.test.ts index 7d75e425450..c71f4f04736 100644 --- a/apps/sim/executor/index.test.ts +++ b/apps/sim/executor/index.test.ts @@ -664,6 +664,7 @@ describe('Executor', () => { ApiBlockHandler: createMockHandler('api'), LoopBlockHandler: createMockHandler('loop'), ParallelBlockHandler: createMockHandler('parallel'), + WorkflowBlockHandler: createMockHandler('workflow'), GenericBlockHandler: createMockHandler('generic', { canHandleCondition: () => true }), })) @@ -721,6 +722,7 @@ describe('Executor', () => { ApiBlockHandler: createMockHandler('api'), LoopBlockHandler: createMockHandler('loop'), ParallelBlockHandler: createMockHandler('parallel'), + WorkflowBlockHandler: createMockHandler('workflow'), GenericBlockHandler: createMockHandler('generic', { canHandleCondition: () => true }), })) diff --git a/apps/sim/executor/index.ts b/apps/sim/executor/index.ts index 112fe5422e7..9f29be36e23 100644 --- a/apps/sim/executor/index.ts +++ b/apps/sim/executor/index.ts @@ -14,6 +14,7 @@ import { LoopBlockHandler, ParallelBlockHandler, RouterBlockHandler, + WorkflowBlockHandler, } from './handlers/index' import { LoopManager } from './loops' import { ParallelManager } from './parallels' @@ -141,6 +142,7 @@ export class Executor { new ApiBlockHandler(), new LoopBlockHandler(this.resolver), new ParallelBlockHandler(this.resolver), + new WorkflowBlockHandler(), new GenericBlockHandler(), ] diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 6909ff94ae5..201dee7b38f 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -96,6 +96,7 @@ import { typeformFilesTool, typeformInsightsTool, typeformResponsesTool } from ' import type { ToolConfig } from './types' import { visionTool } from './vision' import { whatsappSendMessageTool } from './whatsapp' +import { workflowExecutorTool } from './workflow' import { xReadTool, xSearchTool, xUserTool, xWriteTool } from './x' import { youtubeSearchTool } from './youtube' @@ -216,4 +217,5 @@ export const tools: Record = { google_calendar_list: googleCalendarListTool, google_calendar_quick_add: googleCalendarQuickAddTool, google_calendar_invite: googleCalendarInviteTool, + workflow_executor: workflowExecutorTool, } diff --git a/apps/sim/tools/workflow/executor.ts b/apps/sim/tools/workflow/executor.ts new file mode 100644 index 00000000000..42b1a085f71 --- /dev/null +++ b/apps/sim/tools/workflow/executor.ts @@ -0,0 +1,71 @@ +import { createLogger } from '@/lib/logs/console-logger' +import type { ToolConfig, ToolResponse } from '@/tools/types' + +const logger = createLogger('WorkflowExecutorTool') + +interface WorkflowExecutorParams { + workflowId: string + inputMapping?: Record +} + +interface WorkflowExecutorResponse extends ToolResponse { + output: { + success: boolean + duration: number + childWorkflowId: string + childWorkflowName: string + [key: string]: any + } +} + +/** + * Tool for executing workflows as blocks within other workflows. + * This tool is used by the WorkflowBlockHandler to provide the execution capability. + */ +export const workflowExecutorTool: ToolConfig< + WorkflowExecutorParams, + WorkflowExecutorResponse['output'] +> = { + id: 'workflow_executor', + name: 'Workflow Executor', + description: 'Execute another workflow inline as a block', + version: '1.0.0', + params: { + workflowId: { + type: 'string', + required: true, + description: 'The ID of the workflow to execute', + }, + inputMapping: { + type: 'object', + required: false, + description: 'JSON object mapping parent data to child workflow inputs', + }, + }, + request: { + url: '/api/tools/workflow-executor', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => params, + isInternalRoute: true, + }, + transformResponse: async (response: any) => { + logger.info('Workflow executor tool response received', { response }) + + // Extract success state from response, default to false if not present + const success = response?.success ?? false + + return { + success, + duration: response?.duration ?? 0, + childWorkflowId: response?.childWorkflowId ?? '', + childWorkflowName: response?.childWorkflowName ?? '', + ...response, + } + }, + transformError: (error: any) => { + logger.error('Workflow executor tool error:', error) + + return error.message || 'Workflow execution failed' + }, +} diff --git a/apps/sim/tools/workflow/index.ts b/apps/sim/tools/workflow/index.ts new file mode 100644 index 00000000000..785a1d5cf04 --- /dev/null +++ b/apps/sim/tools/workflow/index.ts @@ -0,0 +1 @@ +export { workflowExecutorTool } from './executor'