fix(vars): local and api execution

This commit is contained in:
Emir Karabeg
2025-04-01 00:14:39 -07:00
parent 4030276c6d
commit 275183dc88
6 changed files with 298 additions and 24 deletions

View File

@@ -320,11 +320,33 @@ export async function GET(req: NextRequest) {
)
logger.info(`[${requestId}] Executing workflow ${schedule.workflowId}`)
// Get workflow variables
let workflowVariables = {}
if (workflowRecord.variables) {
try {
// Parse workflow variables if they're stored as a string
if (typeof workflowRecord.variables === 'string') {
workflowVariables = JSON.parse(workflowRecord.variables)
} else {
// Otherwise use as is (already parsed JSON)
workflowVariables = workflowRecord.variables
}
logger.debug(`[${requestId}] Loaded ${Object.keys(workflowVariables).length} workflow variables for: ${schedule.workflowId}`)
} catch (error) {
logger.error(`[${requestId}] Failed to parse workflow variables: ${schedule.workflowId}`, error)
// Continue execution even if variables can't be parsed
}
} else {
logger.debug(`[${requestId}] No workflow variables found for: ${schedule.workflowId}`)
}
const executor = new Executor(
serializedWorkflow,
processedBlockStates, // Use the processed block states
decryptedEnvVars,
input
input,
workflowVariables
)
const result = await executor.execute(schedule.workflowId)

View File

@@ -620,11 +620,32 @@ async function processWebhook(
`[${requestId}] Starting workflow execution with ${Object.keys(processedBlockStates).length} blocks`
)
// Get workflow variables
let workflowVariables = {}
if (foundWorkflow.variables) {
try {
// Parse workflow variables if they're stored as a string
if (typeof foundWorkflow.variables === 'string') {
workflowVariables = JSON.parse(foundWorkflow.variables)
} else {
// Otherwise use as is (already parsed JSON)
workflowVariables = foundWorkflow.variables
}
logger.debug(`[${requestId}] Loaded ${Object.keys(workflowVariables).length} workflow variables for: ${foundWorkflow.id}`)
} catch (error) {
logger.error(`[${requestId}] Failed to parse workflow variables: ${foundWorkflow.id}`, error)
// Continue execution even if variables can't be parsed
}
} else {
logger.debug(`[${requestId}] No workflow variables found for: ${foundWorkflow.id}`)
}
const executor = new Executor(
serializedWorkflow,
processedBlockStates,
decryptedEnvVars,
enrichedInput
enrichedInput,
workflowVariables
)
const result = await executor.execute(foundWorkflow.id)

View File

@@ -265,7 +265,8 @@ describe('Workflow Execution API Route', () => {
expect.anything(),
expect.anything(),
expect.anything(),
requestBody
requestBody,
expect.anything() // Expect workflow variables (5th parameter)
)
})
@@ -308,7 +309,8 @@ describe('Workflow Execution API Route', () => {
expect.anything(),
expect.anything(),
expect.anything(),
structuredInput
structuredInput,
expect.anything() // Expect workflow variables (5th parameter)
)
})
@@ -342,7 +344,8 @@ describe('Workflow Execution API Route', () => {
expect.anything(),
expect.anything(),
expect.anything(),
{}
{},
expect.anything() // Expect workflow variables (5th parameter)
)
})
@@ -447,4 +450,99 @@ describe('Workflow Execution API Route', () => {
.persistExecutionError
expect(persistExecutionError).toHaveBeenCalled()
})
/**
* Test that workflow variables are properly passed to the Executor
*/
it('should pass workflow variables to the Executor', async () => {
// Create mock variables for the workflow
const workflowVariables = {
'variable1': { id: 'var1', name: 'variable1', type: 'string', value: '"test value"' },
'variable2': { id: 'var2', name: 'variable2', type: 'boolean', value: 'true' }
}
// Mock workflow with variables
vi.doMock('@/app/api/workflows/middleware', () => ({
validateWorkflowAccess: vi.fn().mockResolvedValue({
workflow: {
id: 'workflow-with-vars-id',
userId: 'user-id',
state: {
blocks: {
'starter-id': {
id: 'starter-id',
type: 'starter',
name: 'Start',
position: { x: 100, y: 100 },
enabled: true,
},
'agent-id': {
id: 'agent-id',
type: 'agent',
name: 'Agent',
position: { x: 300, y: 100 },
enabled: true,
},
},
edges: [
{
id: 'edge-1',
source: 'starter-id',
target: 'agent-id',
sourceHandle: 'source',
targetHandle: 'target',
},
],
loops: {},
},
variables: workflowVariables,
},
}),
}))
// Create a constructor mock to capture the arguments
const executorConstructorMock = vi.fn().mockImplementation(() => ({
execute: vi.fn().mockResolvedValue({
success: true,
output: { response: 'Execution completed with variables' },
logs: [],
metadata: {
duration: 100,
startTime: new Date().toISOString(),
endTime: new Date().toISOString(),
},
}),
}))
// Override the executor mock
vi.doMock('@/executor', () => ({
Executor: executorConstructorMock,
}))
// Create a mock request
const req = createMockRequest('POST', { testInput: 'value' })
// Create params similar to what Next.js would provide
const params = Promise.resolve({ id: 'workflow-with-vars-id' })
// Import the handler after mocks are set up
const { POST } = await import('./route')
// Call the handler
await POST(req, { params })
// Verify the Executor was constructed with workflow variables
expect(executorConstructorMock).toHaveBeenCalled()
// Check that the 5th parameter (workflow variables) was passed
const executorCalls = executorConstructorMock.mock.calls
expect(executorCalls.length).toBeGreaterThan(0)
// Each call to the constructor should have at least 5 parameters
const lastCall = executorCalls[executorCalls.length - 1]
expect(lastCall.length).toBeGreaterThanOrEqual(5)
// The 5th parameter should be the workflow variables
expect(lastCall[4]).toEqual(workflowVariables)
})
})

View File

@@ -148,11 +148,38 @@ async function executeWorkflow(workflow: any, requestId: string, input?: any) {
{} as Record<string, Record<string, any>>
)
// Get workflow variables
let workflowVariables = {}
if (workflow.variables) {
try {
// Parse workflow variables if they're stored as a string
if (typeof workflow.variables === 'string') {
workflowVariables = JSON.parse(workflow.variables)
} else {
// Otherwise use as is (already parsed JSON)
workflowVariables = workflow.variables
}
logger.debug(`[${requestId}] Loaded ${Object.keys(workflowVariables).length} workflow variables for: ${workflowId}`)
} catch (error) {
logger.error(`[${requestId}] Failed to parse workflow variables: ${workflowId}`, error)
// Continue execution even if variables can't be parsed
}
} else {
logger.debug(`[${requestId}] No workflow variables found for: ${workflowId}`)
}
// Serialize and execute the workflow
logger.debug(`[${requestId}] Serializing workflow: ${workflowId}`)
const serializedWorkflow = new Serializer().serializeWorkflow(mergedStates, edges, loops)
const executor = new Executor(serializedWorkflow, processedBlockStates, decryptedEnvVars, input)
const executor = new Executor(
serializedWorkflow,
processedBlockStates,
decryptedEnvVars,
input,
workflowVariables
)
const result = await executor.execute(workflowId)
logger.info(`[${requestId}] Workflow execution completed: ${workflowId}`, {

View File

@@ -140,6 +140,7 @@ export function useWorkflowExecution() {
workflow,
currentBlockStates,
envVarValues,
undefined,
workflowVariables
)
setExecutor(newExecutor)

View File

@@ -51,6 +51,7 @@ export class InputResolver {
resolveInputs(block: SerializedBlock, context: ExecutionContext): Record<string, any> {
const inputs = { ...block.config.params }
const result: Record<string, any> = {}
const isFunctionBlock = block.metadata?.id === 'function'
// Process each input parameter
for (const [key, value] of Object.entries(inputs)) {
@@ -63,7 +64,7 @@ export class InputResolver {
// Handle string values that may contain references
if (typeof value === 'string') {
// First check for variable references
let resolvedValue = this.resolveVariableReferences(value)
let resolvedValue = this.resolveVariableReferences(value, block)
// Then resolve block references
resolvedValue = this.resolveBlockReferences(resolvedValue, context, block)
@@ -77,16 +78,23 @@ export class InputResolver {
// Resolve environment variables
resolvedValue = this.resolveEnvVariables(resolvedValue, isApiKey)
// Convert JSON strings to objects if possible
try {
if (resolvedValue.startsWith('{') || resolvedValue.startsWith('[')) {
result[key] = JSON.parse(resolvedValue)
} else {
// For function blocks, we need special handling for code input
if (isFunctionBlock && key === 'code') {
// For code input in function blocks, we don't want to parse JSON
result[key] = resolvedValue
}
// For other inputs, try to convert JSON strings to objects
else {
try {
if (resolvedValue.startsWith('{') || resolvedValue.startsWith('[')) {
result[key] = JSON.parse(resolvedValue)
} else {
result[key] = resolvedValue
}
} catch {
// If it's not valid JSON, keep it as a string
result[key] = resolvedValue
}
} catch {
// If it's not valid JSON, keep it as a string
result[key] = resolvedValue
}
}
// Handle objects and arrays recursively
@@ -106,9 +114,10 @@ export class InputResolver {
* Resolves workflow variable references in a string (<variable.name>).
*
* @param value - String containing variable references
* @param currentBlock - The current block, used to determine context
* @returns String with resolved variable references
*/
resolveVariableReferences(value: string): string {
resolveVariableReferences(value: string, currentBlock?: SerializedBlock): string {
const variableMatches = value.match(/<variable\.([^>]+)>/g)
if (!variableMatches) return value
@@ -125,11 +134,57 @@ export class InputResolver {
if (foundVariable) {
const [_, variable] = foundVariable
// Format the value appropriately
const formattedValue =
typeof variable.value === 'object'
? JSON.stringify(variable.value)
: String(variable.value)
// Process variable value based on its type
let processedValue = variable.value
// Handle string values that could be stored with quotes
if (variable.type === 'string' && typeof processedValue === 'string') {
// If the string value starts and ends with quotes, remove them
const trimmed = processedValue.trim()
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) ||
(trimmed.startsWith("'") && trimmed.endsWith("'"))) {
// Remove the quotes and unescape any escaped quotes
processedValue = trimmed.slice(1, -1).replace(/\\"/g, '"').replace(/\\'/g, "'")
}
}
// Handle boolean values that might be stored as strings
else if (variable.type === 'boolean' && typeof processedValue === 'string') {
processedValue = processedValue.trim().toLowerCase() === 'true'
}
// Handle number values that might be stored as strings
else if (variable.type === 'number' && typeof processedValue === 'string') {
const parsed = Number(processedValue)
if (!isNaN(parsed)) {
processedValue = parsed
}
}
// Handle object/array values that might be stored as JSON strings
else if ((variable.type === 'object' || variable.type === 'array') &&
typeof processedValue === 'string') {
try {
processedValue = JSON.parse(processedValue)
} catch (e) {
// Keep as string if parsing fails
}
}
// Determine if this needs to be a code-compatible string literal
const needsCodeStringLiteral = this.needsCodeStringLiteral(currentBlock, value);
// Format the processed value for insertion into the string based on context
let formattedValue: string;
if (variable.type === 'string' && needsCodeStringLiteral) {
// For code contexts like function and condition blocks, properly quote strings
formattedValue = JSON.stringify(processedValue);
} else if (typeof processedValue === 'object' && processedValue !== null) {
// For objects, always stringify
formattedValue = JSON.stringify(processedValue);
} else {
// For other types in normal contexts, use simple string conversion
formattedValue = String(processedValue);
}
resolvedValue = resolvedValue.replace(match, formattedValue)
}
@@ -138,6 +193,56 @@ export class InputResolver {
return resolvedValue
}
/**
* Determines if a value needs to be formatted as a code-compatible string literal
* based on the block type and context. Handles JavaScript and other code contexts.
*
* @param block - The block where the value is being used
* @param expression - The expression containing the value
* @returns Whether the value should be formatted as a string literal
*/
private needsCodeStringLiteral(block?: SerializedBlock, expression?: string): boolean {
if (!block) return false;
// These block types execute code and need properly formatted string literals
const codeExecutionBlocks = ['function', 'condition'];
// Check if this is a block that executes code
if (block.metadata?.id && codeExecutionBlocks.includes(block.metadata.id)) {
return true;
}
// Check if the expression is likely part of code
if (expression) {
const codeIndicators = [
// Function/method calls
/\(\s*$/, // Function call
/\.\w+\s*\(/, // Method call
// JavaScript/Python operators
/[=<>!+\-*/%](?:==?)?/, // Common operators
/\+=|-=|\*=|\/=|%=|\*\*=?/, // Assignment operators
// JavaScript keywords
/\b(if|else|for|while|return|var|let|const|function)\b/,
// Python keywords
/\b(if|else|elif|for|while|def|return|import|from|as|class|with|try|except)\b/,
// Common code patterns
/^['"]use strict['"];?$/, // JS strict mode
/\$\{.+?\}/, // JS template literals
/f['"].*?['"]/, // Python f-strings
/\bprint\s*\(/, // Python print
/\bconsole\.\w+\(/ // JS console methods
];
return codeIndicators.some(pattern => pattern.test(expression));
}
return false;
}
/**
* Resolves block references in a string (<blockId.property> or <blockName.property>).
* Handles inactive paths, missing blocks, and formats values appropriately.
@@ -387,8 +492,8 @@ export class InputResolver {
if (currentBlock.metadata?.id === 'condition') {
formattedValue = this.stringifyForCondition(replacementValue)
} else if (currentBlock.metadata?.id === 'function' && typeof replacementValue === 'string') {
// For function blocks, we need to properly quote string values to avoid syntax errors
} else if (typeof replacementValue === 'string' && this.needsCodeStringLiteral(currentBlock, value)) {
// For code blocks, quote string values properly for the given language
formattedValue = JSON.stringify(replacementValue)
} else {
formattedValue =
@@ -518,7 +623,7 @@ export class InputResolver {
// Handle strings
if (typeof value === 'string') {
// First resolve variable references
const resolvedVars = this.resolveVariableReferences(value)
const resolvedVars = this.resolveVariableReferences(value, currentBlock)
// Then resolve block references
const resolvedReferences = this.resolveBlockReferences(resolvedVars, context, currentBlock)