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
This commit is contained in:
Waleed Latif
2025-03-30 03:15:45 -07:00
committed by GitHub
parent 57d13a2733
commit db8b5642a0
18 changed files with 755 additions and 52 deletions
@@ -49,6 +49,13 @@ export default function NavClient({ children }: { children: React.ReactNode }) {
{/* Social media icons */}
<div className={`flex items-center ${isMobile ? 'gap-2' : 'gap-3'}`}>
<a
href={`${process.env.NEXT_PUBLIC_DOCS_URL}/docs`}
className="text-white/80 hover:text-white/100 text-xl p-2 rounded-md hover:scale-[1.04] transition-colors transition-transform duration-200"
rel="noopener noreferrer"
>
docs
</a>
<a
href="https://x.com/simstudioai"
className="text-white/80 hover:text-white/100 p-2 rounded-md group hover:scale-[1.04] transition-colors transition-transform duration-200"
+155 -12
View File
@@ -3,10 +3,24 @@
*
* @vitest-environment node
*/
import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createMockRequest } from '@/app/api/__test-utils__/utils'
describe('Workflow Execution API Route', () => {
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')
})
/**
+3 -1
View File
@@ -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) {
@@ -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<string, any> = {}
// 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.`,
},
],
@@ -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 (
<>
<Alert
@@ -317,19 +339,52 @@ function NotificationAlert({ notification, isFading, onHide }: NotificationAlert
<p>{!isDeployed ? 'Workflow currently not deployed' : message}</p>
{/* Optional sections with copyable content */}
{options?.sections?.map((section, index) => (
<div key={index} className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground">{section.label}</div>
{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 */}
<div className="relative group rounded-md border bg-muted/50 hover:bg-muted/80 transition-colors">
<pre className="p-3 text-xs font-mono whitespace-pre-wrap overflow-x-auto">
{section.content}
</pre>
<CopyButton text={section.content} />
return (
<div key={index} className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground">
{section.label}
</div>
{/* Copyable code block */}
<div className="relative group rounded-md border bg-muted/50 hover:bg-muted/80 transition-colors">
{section.label === 'API Key' ? (
<>
<pre
className="p-3 text-xs font-mono whitespace-pre-wrap overflow-x-auto cursor-pointer"
onClick={() => setShowApiKey(!showApiKey)}
title={
showApiKey ? 'Click to hide API Key' : 'Click to reveal API Key'
}
>
{showApiKey ? section.content : maskApiKey(section.content)}
</pre>
<div className="text-xs font-mono whitespace-pre-wrap overflow-x-auto">
<CopyButton text={section.content} showLabel={false} />
</div>
</>
) : section.label === 'Example curl command' ? (
<>
<pre className="p-3 text-xs font-mono whitespace-pre-wrap overflow-x-auto">
{formatCurlCommand(section.content, apiKey)}
</pre>
<CopyButton text={section.content} showLabel={false} />
</>
) : (
<>
<pre className="p-3 text-xs font-mono whitespace-pre-wrap overflow-x-auto">
{section.content}
</pre>
<CopyButton text={section.content} showLabel={false} />
</>
)}
</div>
</div>
</div>
))}
)
})}
{/* Status and Delete button row - with pulsing green indicator */}
<div className="flex items-center justify-between">
@@ -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 (
<div className="absolute right-full pr-5 top-0 space-y-2 flex flex-col items-end max-h-[400px] overflow-y-auto">
{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 (
<div key={connection.id} className="space-y-2">
{starterFields.map((field) => renderConnectionCard(connection, field))}
</div>
)
}
}
// Regular connection handling
return (
<div key={`${connection.id}-${index}`} className="space-y-2">
{Array.isArray(connection.outputType)
@@ -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<InputField[]>(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 (
<div
className="flex h-9 items-center justify-between px-3 py-1 cursor-pointer"
onClick={() => toggleCollapse(field.id)}
>
<div className="flex items-center">
<span
className={cn(
'text-sm',
isUnconfigured ? 'text-muted-foreground/50' : 'text-foreground'
)}
>
{field.name ? field.name : `Field ${index + 1}`}
</span>
{field.name && (
<Badge variant="outline" className="ml-2 py-0 h-5 text-xs font-normal bg-muted">
{field.type}
</Badge>
)}
</div>
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<Button variant="ghost" size="icon" onClick={addField} className="h-6 w-6 rounded-full">
<Plus className="h-3.5 w-3.5" />
<span className="sr-only">Add Field</span>
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => removeField(field.id)}
disabled={fields.length === 1}
className="h-6 w-6 rounded-full text-destructive hover:text-destructive"
>
<Trash className="h-3.5 w-3.5" />
<span className="sr-only">Delete Field</span>
</Button>
</div>
</div>
)
}
// Check if any fields have been configured
const hasConfiguredFields = fields.some((field) => field.name && field.name.trim() !== '')
// Main render
return (
<div className="space-y-2">
{fields.map((field, index) => {
const isUnconfigured = !field.name || field.name.trim() === ''
return (
<div
key={field.id}
data-field-id={field.id}
className={cn(
'rounded-md border shadow-sm',
isUnconfigured ? 'border-input/50' : 'border-input',
field.collapsed ? 'overflow-hidden' : 'overflow-visible'
)}
>
{renderFieldHeader(field, index)}
{!field.collapsed && (
<div className="px-3 pt-1.5 pb-2 space-y-2 border-t">
<div className="space-y-1.5">
<Label className="text-xs">Name</Label>
<Input
name="name"
value={field.name}
onChange={(e) => updateField(field.id, 'name', e.target.value)}
placeholder="firstName"
className="h-9 placeholder:text-muted-foreground/50"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Type</Label>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="w-full justify-between h-9 font-normal">
<div className="flex items-center">
<span>{field.type}</span>
</div>
<ChevronDown className="h-4 w-4 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[200px]">
<DropdownMenuItem
onClick={() => updateField(field.id, 'type', 'string')}
className="cursor-pointer"
>
<span className="font-mono mr-2">Aa</span>
<span>String</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => updateField(field.id, 'type', 'number')}
className="cursor-pointer"
>
<span className="font-mono mr-2">123</span>
<span>Number</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => updateField(field.id, 'type', 'boolean')}
className="cursor-pointer"
>
<span className="font-mono mr-2">0/1</span>
<span>Boolean</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => updateField(field.id, 'type', 'object')}
className="cursor-pointer"
>
<span className="font-mono mr-2">{'{}'}</span>
<span>Object</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => updateField(field.id, 'type', 'array')}
className="cursor-pointer"
>
<span className="font-mono mr-2">[]</span>
<span>Array</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
)}
</div>
)
})}
{!hasConfiguredFields && (
<div className="text-xs text-muted-foreground/70 italic mt-1 px-1">
Define fields above to enable structured API input
</div>
)}
</div>
)
}
@@ -28,7 +28,6 @@ export function SlackConfig({
return (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="slack-signing-secret">Signing Secret</Label>
<CopyableField
id="slack-signing-secret"
label="Signing Secret"
@@ -17,6 +17,7 @@ import { LongInput } from './components/long-input'
import { ScheduleConfig } from './components/schedule/schedule-config'
import { ShortInput } from './components/short-input'
import { SliderInput } from './components/slider-input'
import { InputFormat } from './components/starter/input-format'
import { Switch } from './components/switch'
import { Table } from './components/table'
import { TimeInput } from './components/time-input'
@@ -161,8 +162,10 @@ export function SubBlock({ blockId, config, isConnecting }: SubBlockProps) {
return <FileSelectorInput blockId={blockId} subBlock={config} disabled={isConnecting} />
case 'folder-selector':
return <FolderSelectorInput blockId={blockId} subBlock={config} disabled={isConnecting} />
case 'input-format':
return <InputFormat blockId={blockId} subBlockId={config.id} />
default:
return null
return <div>Unknown input type: {config.type}</div>
}
}
+8
View File
@@ -31,6 +31,14 @@ export const StarterBlock: BlockConfig<StarterBlockOutput> = {
],
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',
+5 -1
View File
@@ -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[]
+24 -2
View File
@@ -115,9 +115,31 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
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']
}
+4
View File
@@ -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)
+108 -12
View File
@@ -39,8 +39,8 @@ export class Executor {
private workflow: SerializedWorkflow,
private initialBlockStates: Record<string, BlockOutput> = {},
private environmentVariables: Record<string, string> = {},
private workflowVariables: Record<string, any> = {},
workflowInput?: any
workflowInput?: any,
private workflowVariables: Record<string, any> = {}
) {
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<string, any> = {}
// 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)
+3 -2
View File
@@ -107,7 +107,7 @@ export const useVariablesStore = create<VariablesStore>()(
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<VariablesStore>()(
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()
+11
View File
@@ -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<string, Record<string, Record<string, any>>> // Store values per workflow ID
}
+18
View File
@@ -130,6 +130,24 @@ export async function fetchWorkflowsFromDB(): Promise<void> {
})
})
// 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))
+27 -6
View File
@@ -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<string, SubBlockState>
)
// 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,