From db8b5642a05ce29093de512dbc7313484ea03728 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 30 Mar 2025 03:15:45 -0700 Subject: [PATCH] feat(starterinput): added option to specify input format for starter block for api triggered executions (#204) * added docs to landing page * added basic input format for starter block * improvements for input format for api calls, tested with and without input format * restore old db configs for idle timeout and connect timeout * added tests for execution with formatted input * added syncing of subblock values to make input format persist * fixed empty variable name bug * hide api key in deployment notif, fix db issues --- sim/app/(landing)/components/nav-client.tsx | 7 + .../api/workflows/[id]/execute/route.test.ts | 167 +++++++++++++- sim/app/api/workflows/[id]/execute/route.ts | 4 +- .../components/control-bar/control-bar.tsx | 57 ++++- .../notifications/notifications.tsx | 79 ++++++- .../connection-blocks/connection-blocks.tsx | 51 +++++ .../components/starter/input-format.tsx | 205 ++++++++++++++++++ .../components/providers/slack-config.tsx | 1 - .../components/sub-block/sub-block.tsx | 5 +- sim/blocks/blocks/starter.ts | 8 + sim/blocks/types.ts | 6 +- sim/components/ui/tag-dropdown.tsx | 26 ++- sim/db/index.ts | 4 + sim/executor/index.ts | 120 +++++++++- sim/stores/panel/variables/store.ts | 5 +- sim/stores/workflows/subblock/store.ts | 11 + sim/stores/workflows/sync.ts | 18 ++ sim/stores/workflows/utils.ts | 33 ++- 18 files changed, 755 insertions(+), 52 deletions(-) create mode 100644 sim/app/w/[id]/components/workflow-block/components/sub-block/components/starter/input-format.tsx diff --git a/sim/app/(landing)/components/nav-client.tsx b/sim/app/(landing)/components/nav-client.tsx index 0c881c33fe..6d63f40520 100644 --- a/sim/app/(landing)/components/nav-client.tsx +++ b/sim/app/(landing)/components/nav-client.tsx @@ -49,6 +49,13 @@ export default function NavClient({ children }: { children: React.ReactNode }) { {/* Social media icons */}
+ + docs + { + let executeMock = vi.fn().mockResolvedValue({ + success: true, + output: { + response: 'Test response', + }, + logs: [], + metadata: { + duration: 123, + startTime: new Date().toISOString(), + endTime: new Date().toISOString(), + }, + }) + beforeEach(() => { vi.resetModules() @@ -48,21 +62,24 @@ describe('Workflow Execution API Route', () => { }), })) + // Reset execute mock to track calls + executeMock = vi.fn().mockResolvedValue({ + success: true, + output: { + response: 'Test response', + }, + logs: [], + metadata: { + duration: 123, + startTime: new Date().toISOString(), + endTime: new Date().toISOString(), + }, + }) + // Mock executor vi.doMock('@/executor', () => ({ Executor: vi.fn().mockImplementation(() => ({ - execute: vi.fn().mockResolvedValue({ - success: true, - output: { - response: 'Test response', - }, - logs: [], - metadata: { - duration: 123, - startTime: new Date().toISOString(), - endTime: new Date().toISOString(), - }, - }), + execute: executeMock, })), })) @@ -110,6 +127,11 @@ describe('Workflow Execution API Route', () => { })), })), })), + update: vi.fn().mockImplementation(() => ({ + set: vi.fn().mockImplementation(() => ({ + where: vi.fn().mockResolvedValue(undefined), + })), + })), } return { db: mockDb } @@ -178,6 +200,9 @@ describe('Workflow Execution API Route', () => { // Verify executor was initialized const Executor = (await import('@/executor')).Executor expect(Executor).toHaveBeenCalled() + + // Verify execute was called with undefined input (GET requests don't have body) + expect(executeMock).toHaveBeenCalledWith('workflow-id') }) /** @@ -232,6 +257,124 @@ describe('Workflow Execution API Route', () => { // Verify executor was constructed const Executor = (await import('@/executor')).Executor expect(Executor).toHaveBeenCalled() + + // Verify execute was called with the input body + expect(executeMock).toHaveBeenCalledWith('workflow-id') + // Verify the body was passed to the executor constructor + expect(Executor).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.anything(), + requestBody + ) + }) + + /** + * Test POST execution with structured input matching the input format + */ + it('should execute workflow with structured input matching the input format', async () => { + // Create structured input matching the expected input format + const structuredInput = { + firstName: 'John', + age: 30, + isActive: true, + preferences: { theme: 'dark' }, + tags: ['test', 'api'], + } + + // Create a mock request with the structured input + const req = createMockRequest('POST', structuredInput) + + // Create params similar to what Next.js would provide + const params = Promise.resolve({ id: 'workflow-id' }) + + // Import the handler after mocks are set up + const { POST } = await import('./route') + + // Call the handler + const response = await POST(req, { params }) + + // Ensure response exists and is successful + expect(response).toBeDefined() + expect(response.status).toBe(200) + + // Parse the response body + const data = await response.json() + expect(data).toHaveProperty('success', true) + + // Verify the executor was constructed with the structured input + const Executor = (await import('@/executor')).Executor + expect(Executor).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.anything(), + structuredInput + ) + }) + + /** + * Test POST execution with empty request body + */ + it('should execute workflow with empty request body', async () => { + // Create a mock request with empty body + const req = createMockRequest('POST') + + // Create params similar to what Next.js would provide + const params = Promise.resolve({ id: 'workflow-id' }) + + // Import the handler after mocks are set up + const { POST } = await import('./route') + + // Call the handler + const response = await POST(req, { params }) + + // Ensure response exists and is successful + expect(response).toBeDefined() + expect(response.status).toBe(200) + + // Parse the response body + const data = await response.json() + expect(data).toHaveProperty('success', true) + + // Verify the executor was constructed with an empty object + const Executor = (await import('@/executor')).Executor + expect(Executor).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.anything(), + {} + ) + }) + + /** + * Test POST execution with invalid JSON body + */ + it('should handle invalid JSON in request body', async () => { + // Create a mock request with invalid JSON text + const req = new NextRequest('https://example.com/api/workflows/workflow-id/execute', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: 'this is not valid JSON', + }) + + // Create params similar to what Next.js would provide + const params = Promise.resolve({ id: 'workflow-id' }) + + // Import the handler after mocks are set up + const { POST } = await import('./route') + + // Call the handler - should throw an error when trying to parse the body + const response = await POST(req, { params }) + + // Expect error response due to JSON parsing failure + expect(response.status).toBe(500) + + const data = await response.json() + expect(data).toHaveProperty('error') + // Check for JSON parse error message rather than "Failed to execute workflow" + expect(data.error).toContain('JSON') }) /** diff --git a/sim/app/api/workflows/[id]/execute/route.ts b/sim/app/api/workflows/[id]/execute/route.ts index e802170965..d30d4fc931 100644 --- a/sim/app/api/workflows/[id]/execute/route.ts +++ b/sim/app/api/workflows/[id]/execute/route.ts @@ -234,7 +234,9 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ return createErrorResponse(validation.error.message, validation.error.status) } - const body = await request.json().catch(() => ({})) + const bodyText = await request.text() + const body = bodyText ? JSON.parse(bodyText) : {} + const result = await executeWorkflow(validation.workflow, requestId, body) return createSuccessResponse(result) } catch (error: any) { diff --git a/sim/app/w/[id]/components/control-bar/control-bar.tsx b/sim/app/w/[id]/components/control-bar/control-bar.tsx index b7f15ec599..c9e901c28e 100644 --- a/sim/app/w/[id]/components/control-bar/control-bar.tsx +++ b/sim/app/w/[id]/components/control-bar/control-bar.tsx @@ -43,6 +43,7 @@ import { useExecutionStore } from '@/stores/execution/store' import { useNotificationStore } from '@/stores/notifications/store' import { useGeneralStore } from '@/stores/settings/general/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' import { useWorkflowExecution } from '../../hooks/use-workflow-execution' import { HistoryDropdownItem } from './components/history-dropdown-item/history-dropdown-item' @@ -205,12 +206,64 @@ export function ControlBar() { removeWorkflow(activeWorkflowId) } + /** + * Get an example of the input format for the workflow + */ + const getInputFormatExample = () => { + let inputFormatExample = '' + try { + // Find the starter block in the workflow + const blocks = Object.values(useWorkflowStore.getState().blocks) + const starterBlock = blocks.find((block) => block.type === 'starter') + + if (starterBlock) { + const inputFormat = useSubBlockStore.getState().getValue(starterBlock.id, 'inputFormat') + + // If input format is defined, create an example + if (inputFormat && Array.isArray(inputFormat) && inputFormat.length > 0) { + const exampleData: Record = {} + + // Create example values for each field + inputFormat.forEach((field) => { + if (field.name) { + switch (field.type) { + case 'string': + exampleData[field.name] = 'example' + break + case 'number': + exampleData[field.name] = 42 + break + case 'boolean': + exampleData[field.name] = true + break + case 'object': + exampleData[field.name] = { key: 'value' } + break + case 'array': + exampleData[field.name] = [1, 2, 3] + break + } + } + }) + + inputFormatExample = ` -d '${JSON.stringify(exampleData)}'` + } + } + } catch (error) { + console.error('Error generating input format example:', error) + } + + return inputFormatExample + } + /** * Workflow deployment handler */ const handleDeploy = async () => { if (!activeWorkflowId) return + const inputFormatExample = getInputFormatExample() + // If already deployed, show the API info if (isDeployed) { // Try to find an existing API notification @@ -249,7 +302,7 @@ export function ControlBar() { { label: 'Example curl command', content: apiKey - ? `curl -X POST -H "X-API-Key: ${apiKey}" -H "Content-Type: application/json" ${endpoint}` + ? `curl -X POST -H "X-API-Key: ${apiKey}" -H "Content-Type: application/json"${inputFormatExample} ${endpoint}` : `You need an API key to call this endpoint. Visit your account settings to create one.`, }, ], @@ -292,7 +345,7 @@ export function ControlBar() { { label: 'Example curl command', content: apiKey - ? `curl -X POST -H "X-API-Key: ${apiKey}" -H "Content-Type: application/json" ${endpoint}` + ? `curl -X POST -H "X-API-Key: ${apiKey}" -H "Content-Type: application/json"${inputFormatExample} ${endpoint}` : `You need an API key to call this endpoint. Visit your account settings to create one.`, }, ], diff --git a/sim/app/w/[id]/components/notifications/notifications.tsx b/sim/app/w/[id]/components/notifications/notifications.tsx index c97e49f2cc..180f5ccd2f 100644 --- a/sim/app/w/[id]/components/notifications/notifications.tsx +++ b/sim/app/w/[id]/components/notifications/notifications.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react' -import { Info, Rocket, Store, Terminal, X } from 'lucide-react' +import { Copy, Eye, EyeOff, Info, Rocket, Store, Terminal, X } from 'lucide-react' import { ErrorIcon } from '@/components/icons' import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' import { @@ -258,6 +258,7 @@ interface NotificationAlertProps { function NotificationAlert({ notification, isFading, onHide }: NotificationAlertProps) { const { id, type, message, options, workflowId } = notification const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false) + const [showApiKey, setShowApiKey] = useState(false) const { setDeploymentStatus } = useWorkflowStore() const { isDeployed } = useWorkflowStore((state) => ({ isDeployed: state.isDeployed, @@ -288,6 +289,27 @@ function NotificationAlert({ notification, isFading, onHide }: NotificationAlert } } + // Function to mask API key with asterisks but keep first and last 4 chars visible + const maskApiKey = (key: string) => { + if (!key || key.includes('No API key found')) return key + if (key.length <= 8) return key + return `${key.substring(0, 4)}${'*'.repeat(key.length - 8)}${key.substring(key.length - 4)}` + } + + // Modify the curl command to use a placeholder for the API key + const formatCurlCommand = (command: string, apiKey: string) => { + if (!command.includes('curl')) return command + + // Replace the actual API key with a placeholder in the command + const sanitizedCommand = command.replace(apiKey, 'SIM_API_KEY') + + // Format the command with line breaks for better readability + return sanitizedCommand + .replace(' -H ', '\n -H ') + .replace(' -d ', '\n -d ') + .replace(' http', '\n http') + } + return ( <> {!isDeployed ? 'Workflow currently not deployed' : message}

{/* Optional sections with copyable content */} - {options?.sections?.map((section, index) => ( -
-
{section.label}
+ {options?.sections?.map((section, index) => { + // Get the API key from the sections to use in curl command formatting + const apiKey = options.sections?.find((s) => s.label === 'API Key')?.content || '' - {/* Copyable code block */} -
-
-                        {section.content}
-                      
- + return ( +
+
+ {section.label} +
+ + {/* Copyable code block */} +
+ {section.label === 'API Key' ? ( + <> +
 setShowApiKey(!showApiKey)}
+                              title={
+                                showApiKey ? 'Click to hide API Key' : 'Click to reveal API Key'
+                              }
+                            >
+                              {showApiKey ? section.content : maskApiKey(section.content)}
+                            
+
+ +
+ + ) : section.label === 'Example curl command' ? ( + <> +
+                              {formatCurlCommand(section.content, apiKey)}
+                            
+ + + ) : ( + <> +
+                              {section.content}
+                            
+ + + )} +
-
- ))} + ) + })} {/* Status and Delete button row - with pulsing green indicator */}
diff --git a/sim/app/w/[id]/components/workflow-block/components/connection-blocks/connection-blocks.tsx b/sim/app/w/[id]/components/workflow-block/components/connection-blocks/connection-blocks.tsx index c7126ad442..a3eefc651e 100644 --- a/sim/app/w/[id]/components/workflow-block/components/connection-blocks/connection-blocks.tsx +++ b/sim/app/w/[id]/components/workflow-block/components/connection-blocks/connection-blocks.tsx @@ -1,4 +1,5 @@ import { Card } from '@/components/ui/card' +import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { ConnectedBlock, useBlockConnections } from '@/app/w/[id]/hooks/use-block-connections' interface ConnectionBlocksProps { @@ -69,6 +70,42 @@ export function ConnectionBlocks({ blockId, setIsConnecting }: ConnectionBlocksP })) } + // Extract fields from starter block input format + const extractFieldsFromStarterInput = (connection: ConnectedBlock): ResponseField[] => { + // Only process for starter blocks + if (connection.type !== 'starter') return [] + + try { + // Get input format from subblock store + const inputFormat = useSubBlockStore.getState().getValue(connection.id, 'inputFormat') + + // Make sure we have a valid input format + if (!inputFormat || !Array.isArray(inputFormat) || inputFormat.length === 0) { + return [{ name: 'input', type: 'any' }] + } + + // Check if any fields have been configured with names + const hasConfiguredFields = inputFormat.some( + (field: any) => field.name && field.name.trim() !== '' + ) + + // If no fields have been configured, return the default input field + if (!hasConfiguredFields) { + return [{ name: 'input', type: 'any' }] + } + + // Map input fields to response fields + return inputFormat.map((field: any) => ({ + name: `input.${field.name}`, + type: field.type || 'string', + description: field.description, + })) + } catch (e) { + console.error('Error extracting fields from starter input format:', e) + return [{ name: 'input', type: 'any' }] + } + } + // Deduplicate connections by ID const connectionMap = incomingConnections.reduce( (acc, connection) => { @@ -112,6 +149,20 @@ export function ConnectionBlocks({ blockId, setIsConnecting }: ConnectionBlocksP return (
{sortedConnections.map((connection, index) => { + // Special handling for starter blocks with input format + if (connection.type === 'starter') { + const starterFields = extractFieldsFromStarterInput(connection) + + if (starterFields.length > 0) { + return ( +
+ {starterFields.map((field) => renderConnectionCard(connection, field))} +
+ ) + } + } + + // Regular connection handling return (
{Array.isArray(connection.outputType) diff --git a/sim/app/w/[id]/components/workflow-block/components/sub-block/components/starter/input-format.tsx b/sim/app/w/[id]/components/workflow-block/components/sub-block/components/starter/input-format.tsx new file mode 100644 index 0000000000..f3003ae6e2 --- /dev/null +++ b/sim/app/w/[id]/components/workflow-block/components/sub-block/components/starter/input-format.tsx @@ -0,0 +1,205 @@ +import { ChevronDown, Plus, Trash } from 'lucide-react' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { cn } from '@/lib/utils' +import { useSubBlockValue } from '../../hooks/use-sub-block-value' + +interface InputField { + id: string + name: string + type: 'string' | 'number' | 'boolean' | 'object' | 'array' + collapsed?: boolean +} + +interface InputFormatProps { + blockId: string + subBlockId: string +} + +// Default values +const DEFAULT_FIELD: InputField = { + id: crypto.randomUUID(), + name: '', + type: 'string', + collapsed: true, +} + +export function InputFormat({ blockId, subBlockId }: InputFormatProps) { + // State hooks + const [value, setValue] = useSubBlockValue(blockId, subBlockId) + const fields = value || [DEFAULT_FIELD] + + // Field operations + const addField = () => { + const newField: InputField = { + ...DEFAULT_FIELD, + id: crypto.randomUUID(), + } + setValue([...fields, newField]) + } + + const removeField = (id: string) => { + if (fields.length === 1) return + setValue(fields.filter((field) => field.id !== id)) + } + + // Update handlers + const updateField = (id: string, field: keyof InputField, value: any) => { + setValue(fields.map((f) => (f.id === id ? { ...f, [field]: value } : f))) + } + + const toggleCollapse = (id: string) => { + setValue(fields.map((f) => (f.id === id ? { ...f, collapsed: !f.collapsed } : f))) + } + + // Field header + const renderFieldHeader = (field: InputField, index: number) => { + const isUnconfigured = !field.name || field.name.trim() === '' + + return ( +
toggleCollapse(field.id)} + > +
+ + {field.name ? field.name : `Field ${index + 1}`} + + {field.name && ( + + {field.type} + + )} +
+
e.stopPropagation()}> + + + +
+
+ ) + } + + // Check if any fields have been configured + const hasConfiguredFields = fields.some((field) => field.name && field.name.trim() !== '') + + // Main render + return ( +
+ {fields.map((field, index) => { + const isUnconfigured = !field.name || field.name.trim() === '' + + return ( +
+ {renderFieldHeader(field, index)} + + {!field.collapsed && ( +
+
+ + updateField(field.id, 'name', e.target.value)} + placeholder="firstName" + className="h-9 placeholder:text-muted-foreground/50" + /> +
+ +
+ + + + + + + updateField(field.id, 'type', 'string')} + className="cursor-pointer" + > + Aa + String + + updateField(field.id, 'type', 'number')} + className="cursor-pointer" + > + 123 + Number + + updateField(field.id, 'type', 'boolean')} + className="cursor-pointer" + > + 0/1 + Boolean + + updateField(field.id, 'type', 'object')} + className="cursor-pointer" + > + {'{}'} + Object + + updateField(field.id, 'type', 'array')} + className="cursor-pointer" + > + [] + Array + + + +
+
+ )} +
+ ) + })} + + {!hasConfiguredFields && ( +
+ Define fields above to enable structured API input +
+ )} +
+ ) +} diff --git a/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/components/providers/slack-config.tsx b/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/components/providers/slack-config.tsx index ea5921a23f..59809f8eeb 100644 --- a/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/components/providers/slack-config.tsx +++ b/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/components/providers/slack-config.tsx @@ -28,7 +28,6 @@ export function SlackConfig({ return (
- case 'folder-selector': return + case 'input-format': + return default: - return null + return
Unknown input type: {config.type}
} } diff --git a/sim/blocks/blocks/starter.ts b/sim/blocks/blocks/starter.ts index 8deda94c22..5ee0c0c86c 100644 --- a/sim/blocks/blocks/starter.ts +++ b/sim/blocks/blocks/starter.ts @@ -31,6 +31,14 @@ export const StarterBlock: BlockConfig = { ], value: () => 'manual', }, + // Structured Input format - visible if manual run is selected + { + id: 'inputFormat', + title: 'Input Format (for API calls)', + type: 'input-format', + layout: 'full', + condition: { field: 'startWorkflow', value: 'manual' }, + }, // Webhook configuration { id: 'webhookProvider', diff --git a/sim/blocks/types.ts b/sim/blocks/types.ts index 412d2f44a8..754fc4d511 100644 --- a/sim/blocks/types.ts +++ b/sim/blocks/types.ts @@ -30,6 +30,7 @@ export type SubBlockType = | 'schedule-config' // Schedule status and information | 'file-selector' // File selector for Google Drive, etc. | 'folder-selector' // Folder selector for Gmail, etc. + | 'input-format' // Input structure format // Component width setting export type SubBlockLayout = 'full' | 'half' @@ -84,7 +85,10 @@ export interface SubBlockConfig { title?: string type: SubBlockType layout?: SubBlockLayout - options?: string[] | { label: string; id: string }[] | (() => string[] | { label: string; id: string }[]) + options?: + | string[] + | { label: string; id: string }[] + | (() => string[] | { label: string; id: string }[]) min?: number max?: number columns?: string[] diff --git a/sim/components/ui/tag-dropdown.tsx b/sim/components/ui/tag-dropdown.tsx index ff67413828..9663e52696 100644 --- a/sim/components/ui/tag-dropdown.tsx +++ b/sim/components/ui/tag-dropdown.tsx @@ -115,9 +115,31 @@ export const TagDropdown: React.FC = ({ return prefix ? [prefix] : [] } - // Special handling for starter block. - // TODO: In the future, we will support response formats and required input types. For now, we just take the input altogether. + // Special handling for starter block with input format if (isStarterBlock && prefix === 'response') { + try { + // Check if there's an input format defined + const inputFormatValue = useSubBlockStore + .getState() + .getValue(activeSourceBlockId || blockId, 'inputFormat') + if (inputFormatValue && Array.isArray(inputFormatValue) && inputFormatValue.length > 0) { + // Check if any fields have been configured with names + const hasConfiguredFields = inputFormatValue.some( + (field: any) => field.name && field.name.trim() !== '' + ) + + // If no fields have been configured, return the default input path + if (!hasConfiguredFields) { + return ['response.input'] + } + + // Return fields from input format + return inputFormatValue.map((field: any) => `response.input.${field.name}`) + } + } catch (e) { + logger.error('Error parsing input format:', { e }) + } + return ['response.input'] } diff --git a/sim/db/index.ts b/sim/db/index.ts index 974984d81e..dec8e63c94 100644 --- a/sim/db/index.ts +++ b/sim/db/index.ts @@ -8,5 +8,9 @@ const connectionString = process.env.POSTGRES_URL || process.env.DATABASE_URL! // Disable prefetch as it is not supported for "Transaction" pool mode const client = postgres(connectionString, { prepare: false, + idle_timeout: 30, // Keep connections alive for 30 seconds when idle + connect_timeout: 30, // Timeout after 30 seconds when connecting }) + +// Export the database client (never null) export const db = drizzle(client) diff --git a/sim/executor/index.ts b/sim/executor/index.ts index 1c9096f8c8..e46253808c 100644 --- a/sim/executor/index.ts +++ b/sim/executor/index.ts @@ -39,8 +39,8 @@ export class Executor { private workflow: SerializedWorkflow, private initialBlockStates: Record = {}, private environmentVariables: Record = {}, - private workflowVariables: Record = {}, - workflowInput?: any + workflowInput?: any, + private workflowVariables: Record = {} ) { this.validateWorkflow() this.workflowInput = workflowInput || {} @@ -334,18 +334,114 @@ export class Executor { const starterBlock = this.workflow.blocks.find((block) => block.metadata?.id === 'starter') if (starterBlock) { // Initialize the starter block with the workflow input - const starterOutput = { - response: { - input: this.workflowInput, - }, + try { + const blockParams = starterBlock.config.params + const inputFormat = blockParams?.inputFormat + + // 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 + const inputValue = this.workflowInput?.[field.name] + + // Convert the value to the appropriate type + let typedValue = inputValue + if (inputValue !== undefined) { + if (field.type === 'number' && typeof inputValue !== 'number') { + typedValue = Number(inputValue) + } else if (field.type === 'boolean' && typeof inputValue !== 'boolean') { + typedValue = inputValue === 'true' || inputValue === true + } 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 + } + } + + // Initialize the starter block with structured input + const starterOutput = { + response: { + input: structuredInput, + ...structuredInput, // Add input fields directly at response level too + }, + } + + context.blockStates.set(starterBlock.id, { + output: starterOutput, + executed: true, + executionTime: 0, + }) + } else { + // No input format defined or not an array, + // check if we're receiving input from API call + if (this.workflowInput && typeof this.workflowInput === 'object') { + // For API calls, use the raw input but make it accessible at both paths + const starterOutput = { + response: { + input: this.workflowInput, + ...this.workflowInput, // Make fields directly accessible at response level + }, + } + + logger.info(`Using API input type: ${typeof this.workflowInput}`, { + isArray: Array.isArray(this.workflowInput), + keys: Object.keys(this.workflowInput), + rawInput: JSON.stringify(this.workflowInput), + inputEmpty: Object.keys(this.workflowInput).length === 0, + }) + + context.blockStates.set(starterBlock.id, { + output: starterOutput, + executed: true, + executionTime: 0, + }) + } else { + // Fallback for other cases + const starterOutput = { + response: { + input: this.workflowInput, + }, + } + + context.blockStates.set(starterBlock.id, { + output: starterOutput, + executed: true, + executionTime: 0, + }) + } + } + } catch (e) { + logger.warn('Error processing starter block input format:', e) + // Fallback to raw input with both paths accessible + const starterOutput = { + response: { + input: this.workflowInput, + ...this.workflowInput, // Add input fields directly at response level too + }, + } + + context.blockStates.set(starterBlock.id, { + output: starterOutput, + executed: true, + executionTime: 0, + }) } - context.blockStates.set(starterBlock.id, { - output: starterOutput, - executed: true, - executionTime: 0, - }) - // Mark the starter block as executed and add its connections to the active path context.executedBlocks.add(starterBlock.id) diff --git a/sim/stores/panel/variables/store.ts b/sim/stores/panel/variables/store.ts index 4b5db159af..e08636b558 100644 --- a/sim/stores/panel/variables/store.ts +++ b/sim/stores/panel/variables/store.ts @@ -107,7 +107,7 @@ export const useVariablesStore = create()( if (!state.variables[id]) return state // If name is being updated, ensure it's unique - if (update.name) { + if (update.name !== undefined) { const oldVariable = state.variables[id] const oldVariableName = oldVariable.name const workflowId = oldVariable.workflowId @@ -124,7 +124,8 @@ export const useVariablesStore = create()( nameIndex++ } - // If name has changed, update references in subblocks + // Always update references in subblocks when name changes, even if empty + // This ensures references are updated even when name is completely cleared if (uniqueName !== oldVariableName) { // Update references in subblock store const subBlockStore = useSubBlockStore.getState() diff --git a/sim/stores/workflows/subblock/store.ts b/sim/stores/workflows/subblock/store.ts index 6600326620..5eb8a4792a 100644 --- a/sim/stores/workflows/subblock/store.ts +++ b/sim/stores/workflows/subblock/store.ts @@ -4,6 +4,17 @@ import { SubBlockConfig } from '@/blocks/types' import { loadSubblockValues, saveSubblockValues } from '../persistence' import { useWorkflowRegistry } from '../registry/store' +/** + * SubBlockState stores values for all subblocks in workflows + * + * Important implementation notes: + * 1. Values are stored per workflow, per block, per subblock + * 2. When workflows are synced to the database, the mergeSubblockState function + * in utils.ts combines the block structure with these values + * 3. If a subblock value exists here but not in the block structure + * (e.g., inputFormat in starter block), the merge function will include it + * in the synchronized state to ensure persistence + */ interface SubBlockState { workflowValues: Record>> // Store values per workflow ID } diff --git a/sim/stores/workflows/sync.ts b/sim/stores/workflows/sync.ts index b6989a31fd..9ef1d1dc8c 100644 --- a/sim/stores/workflows/sync.ts +++ b/sim/stores/workflows/sync.ts @@ -130,6 +130,24 @@ export async function fetchWorkflowsFromDB(): Promise { }) }) + // Get any additional subblock values that might not be in the state but are in the store + const storedValues = useSubBlockStore.getState().workflowValues[id] || {} + Object.entries(storedValues).forEach(([blockId, blockValues]) => { + if (!subblockValues[blockId]) { + subblockValues[blockId] = {} + } + + Object.entries(blockValues).forEach(([subblockId, value]) => { + // Only update if not already set or if value is null + if ( + subblockValues[blockId][subblockId] === null || + subblockValues[blockId][subblockId] === undefined + ) { + subblockValues[blockId][subblockId] = value + } + }) + }) + // 4. Store the workflow state and subblock values in localStorage // This ensures compatibility with existing code that loads from localStorage localStorage.setItem(`workflow-${id}`, JSON.stringify(workflowState)) diff --git a/sim/stores/workflows/utils.ts b/sim/stores/workflows/utils.ts index f00d481e36..2af9c49006 100644 --- a/sim/stores/workflows/utils.ts +++ b/sim/stores/workflows/utils.ts @@ -16,15 +16,24 @@ export function mergeSubblockState( const blocksToProcess = blockId ? { [blockId]: blocks[blockId] } : blocks const subBlockStore = useSubBlockStore.getState() + // Get all the values stored in the subblock store for this workflow + const workflowSubblockValues = workflowId ? subBlockStore.workflowValues[workflowId] || {} : {} + return Object.entries(blocksToProcess).reduce( (acc, [id, block]) => { - // Skip if block is undefined or doesn't have subBlocks - if (!block || !block.subBlocks) { + // Skip if block is undefined + if (!block) { return acc } + // Initialize subBlocks if not present + const blockSubBlocks = block.subBlocks || {} + + // Get stored values for this block + const blockValues = workflowSubblockValues[id] || {} + // Create a deep copy of the block's subBlocks to maintain structure - const mergedSubBlocks = Object.entries(block.subBlocks).reduce( + const mergedSubBlocks = Object.entries(blockSubBlocks).reduce( (subAcc, [subBlockId, subBlock]) => { // Skip if subBlock is undefined if (!subBlock) { @@ -37,9 +46,8 @@ export function mergeSubblockState( // If workflowId is provided, use it to get the value if (workflowId) { // Try to get the value from the subblock store for this specific workflow - const workflowValues = subBlockStore.workflowValues[workflowId] - if (workflowValues && workflowValues[id]) { - storedValue = workflowValues[id][subBlockId] + if (blockValues[subBlockId] !== undefined) { + storedValue = blockValues[subBlockId] } } else { // Fall back to the active workflow if no workflowId is provided @@ -57,6 +65,19 @@ export function mergeSubblockState( {} as Record ) + // Add any values that exist in the store but aren't in the block structure + // This handles cases where block config has been updated but values still exist + Object.entries(blockValues).forEach(([subBlockId, value]) => { + if (!mergedSubBlocks[subBlockId] && value !== null && value !== undefined) { + // Create a minimal subblock structure + mergedSubBlocks[subBlockId] = { + id: subBlockId, + type: 'short-input', // Default type that's safe to use + value: value, + } + } + }) + // Return the full block state with updated subBlocks acc[id] = { ...block,