improvement(variables): variable experience and removed execution time (#349)

* improvement(variables): removed string type and kept plaintext without processing input

* improvement(function): removed execution time

* fix(variables): inputting variables and validating format

* fix(variables): sync

* fix(tests): upgraded tests

* Apply suggestions from code review

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
This commit is contained in:
Emir Karabeg
2025-05-11 22:03:07 -07:00
committed by GitHub
parent c27698f7b3
commit 230143af3c
13 changed files with 476 additions and 143 deletions

View File

@@ -63,11 +63,23 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
variablesRecord[variable.id] = variable
})
// Get existing variables to merge with the incoming ones
const existingVariables = (workflowRecord[0].variables as Record<string, Variable>) || {}
// Create a timestamp based on the current request
// Merge variables: Keep existing ones and update/add new ones
// This prevents variables from being deleted during race conditions
const mergedVariables = {
...existingVariables,
...variablesRecord
}
// Update workflow with variables
await db
.update(workflow)
.set({
variables: variablesRecord,
variables: mergedVariables,
updatedAt: new Date(),
})
.where(eq(workflow.id, workflowId))

View File

@@ -1,7 +1,16 @@
'use client'
import { useEffect, useRef } from 'react'
import { ChevronDown, Copy, MoreVertical, Plus, Trash } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import {
AlertCircle,
AlertTriangle,
Check,
ChevronDown,
Copy,
MoreVertical,
Plus,
Trash,
} from 'lucide-react'
import { highlight, languages } from 'prismjs'
import 'prismjs/components/prism-javascript'
import 'prismjs/themes/prism.css'
@@ -51,6 +60,9 @@ export function Variables({ panelWidth }: VariablesProps) {
// Track editor references
const editorRefs = useRef<Record<string, HTMLDivElement | null>>({})
// Track which variables are currently being edited
const [activeEditors, setActiveEditors] = useState<Record<string, boolean>>({})
// Auto-save when variables are added/edited
const handleAddVariable = () => {
if (!activeWorkflowId) return
@@ -68,8 +80,8 @@ export function Variables({ panelWidth }: VariablesProps) {
const getTypeIcon = (type: VariableType) => {
switch (type) {
case 'string':
return 'Aa'
case 'plain':
return 'Abc'
case 'number':
return '123'
case 'boolean':
@@ -78,7 +90,7 @@ export function Variables({ panelWidth }: VariablesProps) {
return '{}'
case 'array':
return '[]'
case 'plain':
case 'string':
return 'Abc'
default:
return '?'
@@ -87,8 +99,8 @@ export function Variables({ panelWidth }: VariablesProps) {
const getPlaceholder = (type: VariableType) => {
switch (type) {
case 'string':
return '"Hello world"'
case 'plain':
return 'Plain text value'
case 'number':
return '42'
case 'boolean':
@@ -97,7 +109,7 @@ export function Variables({ panelWidth }: VariablesProps) {
return '{\n "key": "value"\n}'
case 'array':
return '[\n 1,\n 2,\n 3\n]'
case 'plain':
case 'string':
return 'Plain text value'
default:
return ''
@@ -117,16 +129,100 @@ export function Variables({ panelWidth }: VariablesProps) {
}
}
// Handle editor value changes - store exactly what user types
const handleEditorChange = (variable: Variable, newValue: string) => {
// Store the raw value directly, no parsing or formatting
updateVariable(variable.id, {
value: newValue,
// Clear any previous validation errors so they'll be recalculated
validationError: undefined,
})
}
// Only track focus state for UI purposes
const handleEditorBlur = (variableId: string) => {
setActiveEditors((prev) => ({
...prev,
[variableId]: false,
}))
}
// Track when editor becomes active
const handleEditorFocus = (variableId: string) => {
setActiveEditors((prev) => ({
...prev,
[variableId]: true,
}))
}
// Always return raw value without any formatting
const formatValue = (variable: Variable) => {
if (variable.value === '') return ''
try {
// Use the VariableManager to format values consistently
return VariableManager.formatForEditor(variable.value, variable.type)
} catch (e) {
console.error('Error formatting value:', e)
// If formatting fails, return as is
return typeof variable.value === 'string' ? variable.value : JSON.stringify(variable.value)
// Always return raw value exactly as typed
return typeof variable.value === 'string' ? variable.value : JSON.stringify(variable.value)
}
// Get validation status based on type and value
const getValidationStatus = (variable: Variable): string | undefined => {
// Empty values don't need validation
if (variable.value === '') return undefined
// Otherwise validate based on type
switch (variable.type) {
case 'number':
return isNaN(Number(variable.value)) ? 'Not a valid number' : undefined
case 'boolean':
return !/^(true|false)$/i.test(String(variable.value).trim())
? 'Expected "true" or "false"'
: undefined
case 'object':
try {
// Handle both JavaScript and JSON syntax
let valueToValidate = String(variable.value).trim()
// If it's clearly JS syntax, convert it to valid JSON
if (valueToValidate.includes("'") || /\b\w+\s*:/.test(valueToValidate)) {
// Replace JS single quotes with double quotes, but handle escaped quotes correctly
valueToValidate = valueToValidate
.replace(/(\w+)\s*:/g, '"$1":') // Convert unquoted property names to quoted
.replace(/'/g, '"') // Replace single quotes with double quotes
}
const parsed = JSON.parse(valueToValidate)
return !parsed || typeof parsed !== 'object' || Array.isArray(parsed)
? 'Not a valid JSON object'
: undefined
} catch {
return 'Invalid JSON object syntax'
}
case 'array':
try {
// Use actual JavaScript evaluation instead of trying to convert to JSON
// This properly handles all valid JS array syntax including mixed types
let valueToEvaluate = String(variable.value).trim()
// Basic security check to prevent arbitrary code execution
if (!valueToEvaluate.startsWith('[') || !valueToEvaluate.endsWith(']')) {
return 'Not a valid array format'
}
// Use Function constructor to safely evaluate the array expression
// This is safer than eval() and handles all JS array syntax correctly
const parsed = new Function(`return ${valueToEvaluate}`)()
// Verify it's actually an array
if (!Array.isArray(parsed)) {
return 'Not a valid array'
}
return undefined // Valid array
} catch (e) {
console.log('Array parsing error:', e)
return 'Invalid array syntax'
}
default:
return undefined
}
}
@@ -140,20 +236,6 @@ export function Variables({ panelWidth }: VariablesProps) {
})
}, [workflowVariables])
// Handle editor value changes
const handleEditorChange = (variable: Variable, newValue: string) => {
try {
// Use the VariableManager to consistently parse input values
const processedValue = VariableManager.parseInputForStorage(newValue, variable.type)
// Update the variable with the processed value
updateVariable(variable.id, { value: processedValue })
} catch (e) {
// If processing fails, use the raw value
updateVariable(variable.id, { value: newValue })
}
}
return (
<ScrollArea className="h-full">
<div className="p-4 space-y-3">
@@ -199,11 +281,11 @@ export function Variables({ panelWidth }: VariablesProps) {
</Tooltip>
<DropdownMenuContent align="end" className="min-w-32">
<DropdownMenuItem
onClick={() => updateVariable(variable.id, { type: 'string' })}
onClick={() => updateVariable(variable.id, { type: 'plain' })}
className="cursor-pointer flex items-center"
>
<div className="w-5 text-center mr-2 font-mono text-sm">Aa</div>
<span>String</span>
<div className="w-5 text-center mr-2 font-mono text-sm">Abc</div>
<span>Plain</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => updateVariable(variable.id, { type: 'number' })}
@@ -233,13 +315,6 @@ export function Variables({ panelWidth }: VariablesProps) {
<div className="w-5 text-center mr-2 font-mono text-sm">[]</div>
<span>Array</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => updateVariable(variable.id, { type: 'plain' })}
className="cursor-pointer flex items-center"
>
<div className="w-5 text-center mr-2 font-mono text-sm">Abc</div>
<span>Plain</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -281,6 +356,10 @@ export function Variables({ panelWidth }: VariablesProps) {
ref={(el) => {
editorRefs.current[variable.id] = el
}}
style={{
maxWidth: panelWidth ? `${panelWidth - 50}px` : '100%',
overflowWrap: 'break-word',
}}
>
{variable.value === '' && (
<div className="absolute top-[8.5px] left-4 text-muted-foreground/50 pointer-events-none select-none">
@@ -291,6 +370,8 @@ export function Variables({ panelWidth }: VariablesProps) {
key={`editor-${variable.id}-${variable.type}`}
value={formatValue(variable)}
onValueChange={handleEditorChange.bind(null, variable)}
onBlur={() => handleEditorBlur(variable.id)}
onFocus={() => handleEditorFocus(variable.id)}
highlight={(code) =>
highlight(
code,
@@ -302,10 +383,31 @@ export function Variables({ panelWidth }: VariablesProps) {
style={{
fontFamily: 'inherit',
lineHeight: '21px',
width: '100%',
wordWrap: 'break-word',
whiteSpace: 'pre-wrap',
}}
className="focus:outline-none w-full"
textareaClassName="focus:outline-none focus:ring-0 bg-transparent resize-none w-full overflow-hidden whitespace-pre-wrap"
textareaClassName="focus:outline-none focus:ring-0 bg-transparent resize-none w-full whitespace-pre-wrap break-words overflow-visible"
/>
{/* Show validation indicator for any non-empty variable */}
{variable.value !== '' && (
<Tooltip>
<TooltipTrigger asChild>
<div className="absolute top-[4px] right-[0px] cursor-help group">
{getValidationStatus(variable) && (
<div className="p-1 rounded-md group-hover:bg-muted/80 group-hover:shadow-sm transition-all duration-200 border border-transparent group-hover:border-muted/50">
<AlertTriangle className="h-4 w-4 text-muted-foreground opacity-30 group-hover:opacity-100 transition-opacity duration-200" />
</div>
)}
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs">
{getValidationStatus(variable) && <p>{getValidationStatus(variable)}</p>}
</TooltipContent>
</Tooltip>
)}
</div>
</div>
))}

View File

@@ -30,7 +30,6 @@ export const FunctionBlock: BlockConfig<CodeExecutionOutput> = {
type: {
result: 'any',
stdout: 'string',
executionTime: 'number',
},
},
},

View File

@@ -1284,7 +1284,6 @@ export class Executor {
response: {
result: output?.result,
stdout: output?.stdout || '',
executionTime: output?.executionTime || 0,
},
}
}

View File

@@ -599,7 +599,7 @@ describe('InputResolver', () => {
const result = resolver.resolveInputs(block, mockContext)
// String should be quoted in code context
expect(result.code).toContain('const name = "\"Hello\"";')
expect(result.code).toContain('const name = "Hello";')
// Number should not be quoted
expect(result.code).toContain('const num = 42;')
})

View File

@@ -242,8 +242,11 @@ export class InputResolver {
}
try {
// Handle 'string' type the same as 'plain' for backward compatibility
const type = variable.type === 'string' ? 'plain' : variable.type
// Use the centralized VariableManager to resolve variable values
return VariableManager.resolveForExecution(variable.value, variable.type)
return VariableManager.resolveForExecution(variable.value, type)
} catch (error) {
logger.error(`Error processing variable ${variable.name} (type: ${variable.type}):`, error)
return variable.value // Fallback to original value on error
@@ -266,14 +269,23 @@ export class InputResolver {
currentBlock?: SerializedBlock
): string {
try {
// Handle 'string' type the same as 'plain' for backward compatibility
const normalizedType = type === 'string' ? 'plain' : type
// For plain text, use exactly what's entered without modifications
if (normalizedType === 'plain' && typeof value === 'string') {
return value
}
// Determine if this needs special handling for code contexts
const needsCodeStringLiteral = this.needsCodeStringLiteral(currentBlock, String(value))
const isFunctionBlock = currentBlock?.metadata?.id === 'function'
// Use the appropriate formatting method based on context
if (needsCodeStringLiteral) {
return VariableManager.formatForCodeContext(value, type as any)
// Always use code formatting for function blocks
if (isFunctionBlock || needsCodeStringLiteral) {
return VariableManager.formatForCodeContext(value, normalizedType as any)
} else {
return VariableManager.formatForTemplateInterpolation(value, type as any)
return VariableManager.formatForTemplateInterpolation(value, normalizedType as any)
}
} catch (error) {
logger.error(`Error formatting value for interpolation (type: ${type}):`, error)
@@ -1065,6 +1077,11 @@ export class InputResolver {
// Check if this is a block that executes code
if (block.metadata?.id && codeExecutionBlocks.includes(block.metadata.id)) {
// Always return true for function blocks - they need properly formatted string literals
if (block.metadata.id === 'function') {
return true
}
// Specifically for condition blocks, stringifyForCondition handles quoting
// so we don't need extra quoting here unless it's within an expression.
if (block.metadata.id === 'condition' && !expression) {

View File

@@ -185,15 +185,15 @@ describe('VariableManager', () => {
describe('formatForCodeContext', () => {
it('should format plain type variables for code context', () => {
expect(VariableManager.formatForCodeContext('hello world', 'plain')).toBe('"hello world"')
expect(VariableManager.formatForCodeContext(42, 'plain')).toBe('"42"')
expect(VariableManager.formatForCodeContext(true, 'plain')).toBe('"true"')
expect(VariableManager.formatForCodeContext('hello world', 'plain')).toBe('hello world')
expect(VariableManager.formatForCodeContext(42, 'plain')).toBe('42')
expect(VariableManager.formatForCodeContext(true, 'plain')).toBe('true')
})
it('should format string type variables for code context', () => {
expect(VariableManager.formatForCodeContext('hello world', 'string')).toBe('"hello world"')
expect(VariableManager.formatForCodeContext(42, 'string')).toBe('"42"')
expect(VariableManager.formatForCodeContext(true, 'string')).toBe('"true"')
expect(VariableManager.formatForCodeContext(42, 'string')).toBe('42')
expect(VariableManager.formatForCodeContext(true, 'string')).toBe('true')
})
it('should format number type variables for code context', () => {

View File

@@ -31,16 +31,20 @@ export class VariableManager {
if (forExecution) {
return value
}
// For storage/display, convert to empty string for string types
return type === 'string' || type === 'plain' ? '' : value
// For storage/display, convert to empty string for text types
return type === 'plain' || type === 'string' ? '' : value
}
// For 'plain' type, we want to preserve quotes exactly as entered
if (type === 'plain') {
return typeof value === 'string' ? value : String(value)
}
// Remove quotes from string values if present (used by multiple types)
const unquoted = typeof value === 'string' ? value.replace(/^["'](.*)["']$/s, '$1') : value
switch (type) {
case 'plain':
case 'string':
case 'string': // Handle string type the same as plain for compatibility
return String(unquoted)
case 'number':
@@ -122,17 +126,18 @@ export class VariableManager {
if (value === undefined) return context === 'code' ? 'undefined' : ''
if (value === null) return context === 'code' ? 'null' : ''
// For plain type, preserve exactly as is without conversion
if (type === 'plain') {
return typeof value === 'string' ? value : String(value)
}
// Convert to native type first to ensure consistent handling
// We don't use forExecution=true for formatting since we don't want to preserve null/undefined
const typedValue = this.convertToNativeType(value, type, false)
switch (type) {
case 'plain':
case 'string':
if (context === 'code') {
// In code contexts, strings must be quoted
return JSON.stringify(String(typedValue))
}
case 'string': // Handle string type the same as plain for compatibility
// For plain text and strings, we don't add quotes in any context
return String(typedValue)
case 'number':
@@ -211,6 +216,19 @@ export class VariableManager {
* Formats a value for use in code contexts with proper JavaScript syntax.
*/
static formatForCodeContext(value: any, type: VariableType): string {
// Special handling for null/undefined in code context
if (value === null) return 'null'
if (value === undefined) return 'undefined'
// For plain text, use exactly what the user typed, without any conversion
// This may cause JavaScript errors if they don't enter valid JS code
if (type === 'plain') {
return typeof value === 'string' ? value : String(value)
} else if (type === 'string') {
// For backwards compatibility, add quotes only for string type in code context
return typeof value === 'string' ? JSON.stringify(value) : this.formatValue(value, type, 'code')
}
return this.formatValue(value, type, 'code')
}

View File

@@ -13,10 +13,90 @@ const SAVE_DEBOUNCE_DELAY = 500 // 500ms debounce delay
const saveTimers = new Map<string, NodeJS.Timeout>()
// Track which workflows have already been loaded
const loadedWorkflows = new Set<string>()
// Track recently added variable IDs with timestamps
const recentlyAddedVariables = new Map<string, number>()
// Time window in ms to consider a variable as "recently added" (3 seconds)
const RECENT_VARIABLE_WINDOW = 3000
// Clear a workspace from the loaded tracking when switching workspaces
export function clearWorkflowVariablesTracking() {
loadedWorkflows.clear()
// Also clear any old entries from recentlyAddedVariables
const now = Date.now()
recentlyAddedVariables.forEach((timestamp, id) => {
if (now - timestamp > RECENT_VARIABLE_WINDOW * 2) {
recentlyAddedVariables.delete(id)
}
})
}
/**
* Check if variable format is valid according to type without modifying it
* Only provides validation feedback - does not change the value
*/
function validateVariable(variable: Variable): string | undefined {
try {
// We only care about the validation result, not the parsed value
switch (variable.type) {
case 'number':
// Check if it's a valid number
if (isNaN(Number(variable.value))) {
return 'Not a valid number'
}
break
case 'boolean':
// Check if it's a valid boolean
if (!/^(true|false)$/i.test(String(variable.value).trim())) {
return 'Expected "true" or "false"'
}
break
case 'object':
// Check if it's a valid JSON object
try {
const parsed = JSON.parse(String(variable.value))
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
return 'Not a valid JSON object'
}
} catch {
return 'Invalid JSON object syntax'
}
break
case 'array':
// Check if it's a valid JSON array
try {
const parsed = JSON.parse(String(variable.value))
if (!Array.isArray(parsed)) {
return 'Not a valid JSON array'
}
} catch {
return 'Invalid JSON array syntax'
}
break
}
return undefined
} catch (e) {
return e instanceof Error ? e.message : 'Invalid format'
}
}
/**
* Migrates a variable from 'string' type to 'plain' type
* Handles the value conversion appropriately
*/
function migrateStringToPlain(variable: Variable): Variable {
if (variable.type !== 'string') {
return variable
}
// Convert string type to plain
const updated = {
...variable,
type: 'plain' as const,
}
// For plain text, we want to preserve values exactly as they are,
// including any quote characters that may be part of the text
return updated
}
export const useVariablesStore = create<VariablesStore>()(
@@ -60,49 +140,52 @@ export const useVariablesStore = create<VariablesStore>()(
nameIndex++
}
// Handle initial value
let variableValue = variable.value
// Auto-add quotes for string values if they aren't already quoted
if (
variable.type === 'string' &&
typeof variableValue === 'string' &&
variableValue.trim() !== ''
) {
// Only add quotes if not already properly quoted
const trimmedValue = variableValue.trim()
// Check if entire string is already properly quoted
const isAlreadyQuoted =
(trimmedValue.startsWith('"') &&
trimmedValue.endsWith('"') &&
trimmedValue.length >= 2) ||
(trimmedValue.startsWith("'") &&
trimmedValue.endsWith("'") &&
trimmedValue.length >= 2)
if (!isAlreadyQuoted) {
// Escape any existing quotes in the content
const escapedValue = variableValue.replace(/"/g, '\\"')
variableValue = `"${escapedValue}"`
}
// Check for type conversion - only for backward compatibility
if (variable.type === 'string') {
variable.type = 'plain'
}
// Create the new variable with empty value
const newVariable: Variable = {
id,
workflowId: variable.workflowId,
name: uniqueName,
type: variable.type,
value: variable.value || '',
validationError: undefined,
}
// Check for validation errors without modifying the value
const validationError = validateVariable(newVariable)
if (validationError) {
newVariable.validationError = validationError
}
// Mark this variable as recently added with current timestamp
recentlyAddedVariables.set(id, Date.now())
set((state) => ({
variables: {
...state.variables,
[id]: {
id,
workflowId: variable.workflowId,
name: uniqueName,
type: variable.type,
value: variableValue,
},
[id]: newVariable,
},
}))
// Auto-save to DB
get().saveVariables(variable.workflowId)
// Use the same debounced save mechanism as updateVariable
const workflowId = variable.workflowId
// Clear existing timer for this workflow if it exists
if (saveTimers.has(workflowId)) {
clearTimeout(saveTimers.get(workflowId))
}
// Set new debounced save timer
const timer = setTimeout(() => {
get().saveVariables(workflowId)
saveTimers.delete(workflowId)
}, SAVE_DEBOUNCE_DELAY)
saveTimers.set(workflowId, timer)
return id
},
@@ -203,38 +286,27 @@ export const useVariablesStore = create<VariablesStore>()(
update = { ...update, name: uniqueName }
}
// Auto-add quotes for string values if they aren't already quoted
if (
update.value !== undefined &&
state.variables[id].type === 'string' &&
typeof update.value === 'string' &&
update.value.trim() !== ''
) {
// Only add quotes if not already properly quoted
const trimmedValue = update.value.trim()
// Check if entire string is already properly quoted
const isAlreadyQuoted =
(trimmedValue.startsWith('"') &&
trimmedValue.endsWith('"') &&
trimmedValue.length >= 2) ||
(trimmedValue.startsWith("'") &&
trimmedValue.endsWith("'") &&
trimmedValue.length >= 2)
if (!isAlreadyQuoted) {
// Escape any existing quotes in the content
const escapedValue = update.value.replace(/"/g, '\\"')
update = { ...update, value: `"${escapedValue}"` }
}
// If type is being updated to 'string', convert it to 'plain' instead
if (update.type === 'string') {
update = { ...update, type: 'plain' }
}
// Create updated variable to check for validation
const updatedVariable: Variable = {
...state.variables[id],
...update,
validationError: undefined, // Initialize property to be updated later
}
// If the type or value changed, check for validation errors
if (update.type || update.value !== undefined) {
// Only add validation feedback - never modify the value
updatedVariable.validationError = validateVariable(updatedVariable)
}
const updated = {
...state.variables,
[id]: {
...state.variables[id],
...update,
},
[id]: updatedVariable,
}
// Debounced auto-save to DB
@@ -264,8 +336,19 @@ export const useVariablesStore = create<VariablesStore>()(
const workflowId = state.variables[id].workflowId
const { [id]: _, ...rest } = state.variables
// Auto-save to DB - no debounce for deletion
setTimeout(() => get().saveVariables(workflowId), 0)
// Use the same debounced save mechanism for consistency
// Clear existing timer for this workflow if it exists
if (saveTimers.has(workflowId)) {
clearTimeout(saveTimers.get(workflowId))
}
// Set new debounced save timer
const timer = setTimeout(() => {
get().saveVariables(workflowId)
saveTimers.delete(workflowId)
}, SAVE_DEBOUNCE_DELAY)
saveTimers.set(workflowId, timer)
return { variables: rest }
})
@@ -289,6 +372,9 @@ export const useVariablesStore = create<VariablesStore>()(
uniqueName = `${baseName} (${nameIndex})`
nameIndex++
}
// Mark this duplicated variable as recently added
recentlyAddedVariables.set(newId, Date.now())
set((state) => ({
variables: {
@@ -303,21 +389,78 @@ export const useVariablesStore = create<VariablesStore>()(
},
}))
// Auto-save to DB
get().saveVariables(variable.workflowId)
// Use the same debounced save mechanism
const workflowId = variable.workflowId
// Clear existing timer for this workflow if it exists
if (saveTimers.has(workflowId)) {
clearTimeout(saveTimers.get(workflowId))
}
// Set new debounced save timer
const timer = setTimeout(() => {
get().saveVariables(workflowId)
saveTimers.delete(workflowId)
}, SAVE_DEBOUNCE_DELAY)
saveTimers.set(workflowId, timer)
return newId
},
loadVariables: async (workflowId) => {
// Skip if already loaded to prevent redundant API calls
if (loadedWorkflows.has(workflowId)) return
// Skip if already loaded to prevent redundant API calls, but ensure
// we check for the special case of recently added variables first
if (loadedWorkflows.has(workflowId)) {
// Even if workflow is loaded, check if we have recent variables to protect
const workflowVariables = Object.values(get().variables)
.filter((v) => v.workflowId === workflowId)
const now = Date.now()
const hasRecentVariables = workflowVariables.some(v =>
recentlyAddedVariables.has(v.id) &&
(now - (recentlyAddedVariables.get(v.id) || 0) < RECENT_VARIABLE_WINDOW)
)
// No force reload needed if no recent variables and we've already loaded
if (!hasRecentVariables) {
return
}
// Otherwise continue and do a full load+merge to protect recent variables
}
try {
set({ isLoading: true, error: null })
const response = await fetch(`${API_ENDPOINTS.WORKFLOWS}/${workflowId}/variables`)
// Capture current variables for this workflow before we modify anything
const currentWorkflowVariables = Object.values(get().variables)
.filter((v) => v.workflowId === workflowId)
.reduce((acc, v) => {
acc[v.id] = v
return acc
}, {} as Record<string, Variable>)
// Check which variables were recently added (within the last few seconds)
const now = Date.now()
const protectedVariableIds = new Set<string>()
// Identify variables that should be protected from being overwritten
Object.keys(currentWorkflowVariables).forEach(id => {
// Protect recently added variables
if (recentlyAddedVariables.has(id) &&
(now - (recentlyAddedVariables.get(id) || 0) < RECENT_VARIABLE_WINDOW)) {
protectedVariableIds.add(id)
}
// Also protect variables that are currently being edited (have pending changes)
if (saveTimers.has(workflowId)) {
protectedVariableIds.add(id)
}
})
// Handle 404 workflow not found gracefully
if (response.status === 404) {
logger.info(`No variables found for workflow ${workflowId}, initializing empty set`)
@@ -332,6 +475,13 @@ export const useVariablesStore = create<VariablesStore>()(
},
{} as Record<string, Variable>
)
// Add back protected variables that should not be removed
Object.keys(currentWorkflowVariables).forEach(id => {
if (protectedVariableIds.has(id)) {
otherVariables[id] = currentWorkflowVariables[id]
}
})
// Mark this workflow as loaded to prevent further attempts
loadedWorkflows.add(workflowId)
@@ -352,6 +502,12 @@ export const useVariablesStore = create<VariablesStore>()(
if (data && typeof data === 'object') {
set((state) => {
// Migrate any 'string' type variables to 'plain'
const migratedData: Record<string, Variable> = {}
for (const [id, variable] of Object.entries(data)) {
migratedData[id] = migrateStringToPlain(variable as Variable)
}
// Merge with existing variables from other workflows
const otherVariables = Object.values(state.variables).reduce(
(acc, variable) => {
@@ -362,12 +518,22 @@ export const useVariablesStore = create<VariablesStore>()(
},
{} as Record<string, Variable>
)
// Create the final variables object, prioritizing protected variables
const finalVariables = { ...otherVariables, ...migratedData }
// Restore any protected variables that shouldn't be overwritten
Object.keys(currentWorkflowVariables).forEach(id => {
if (protectedVariableIds.has(id)) {
finalVariables[id] = currentWorkflowVariables[id]
}
})
// Mark this workflow as loaded
loadedWorkflows.add(workflowId)
return {
variables: { ...otherVariables, ...data },
variables: finalVariables,
isLoading: false,
}
})
@@ -383,6 +549,13 @@ export const useVariablesStore = create<VariablesStore>()(
},
{} as Record<string, Variable>
)
// Add back protected variables that should not be removed
Object.keys(currentWorkflowVariables).forEach(id => {
if (protectedVariableIds.has(id)) {
otherVariables[id] = currentWorkflowVariables[id]
}
})
// Mark this workflow as loaded
loadedWorkflows.add(workflowId)
@@ -417,6 +590,12 @@ export const useVariablesStore = create<VariablesStore>()(
const workflowVariables = Object.values(get().variables).filter(
(variable) => variable.workflowId === workflowId
)
// Record the last save attempt timestamp for each variable to track sync state
workflowVariables.forEach(variable => {
// Mark save attempt time for all variables being saved
recentlyAddedVariables.set(variable.id, Date.now())
})
// Send to DB
const response = await fetch(`${API_ENDPOINTS.WORKFLOWS}/${workflowId}/variables`, {
@@ -451,10 +630,8 @@ export const useVariablesStore = create<VariablesStore>()(
isLoading: false,
})
// Reload from DB to ensure consistency
// Reset tracking to force a reload
loadedWorkflows.delete(workflowId)
get().loadVariables(workflowId)
// Don't reload variables after save error - this could cause data loss
// Just clear the loading state
}
},
@@ -467,6 +644,14 @@ export const useVariablesStore = create<VariablesStore>()(
// Reset the loaded workflow tracking
resetLoaded: () => {
loadedWorkflows.clear()
// Clean up stale entries from recentlyAddedVariables
const now = Date.now()
recentlyAddedVariables.forEach((timestamp, id) => {
if (now - timestamp > RECENT_VARIABLE_WINDOW * 2) {
recentlyAddedVariables.delete(id)
}
})
},
}),
{

View File

@@ -1,4 +1,8 @@
export type VariableType = 'string' | 'number' | 'boolean' | 'object' | 'array' | 'plain'
/**
* Variable types supported in the application
* Note: 'string' is deprecated - use 'plain' for text values instead
*/
export type VariableType = 'plain' | 'number' | 'boolean' | 'object' | 'array' | 'string'
/**
* Represents a workflow variable with workflow-specific naming
@@ -10,6 +14,7 @@ export interface Variable {
name: string // Must be unique per workflow
type: VariableType
value: any
validationError?: string // Tracks format validation errors
}
export interface VariablesStore {

View File

@@ -85,7 +85,6 @@ describe('Function Execute Tool', () => {
output: {
result: 42,
stdout: 'console.log output',
executionTime: 15,
},
})
@@ -98,7 +97,6 @@ describe('Function Execute Tool', () => {
expect(result.success).toBe(true)
expect(result.output.result).toBe(42)
expect(result.output.stdout).toBe('console.log output')
expect(result.output.executionTime).toBe(15)
})
test('should handle execution errors', async () => {

View File

@@ -55,7 +55,6 @@ export const functionExecuteTool: ToolConfig<CodeExecutionInput, CodeExecutionOu
output: {
result: result.output.result,
stdout: result.output.stdout,
executionTime: result.output.executionTime,
},
}
},

View File

@@ -10,6 +10,5 @@ export interface CodeExecutionOutput extends ToolResponse {
output: {
result: any
stdout: string
executionTime: number
}
}