diff --git a/sim/app/api/db/workflow/variables/[id]/route.ts b/sim/app/api/db/workflow/variables/[id]/route.ts new file mode 100644 index 0000000000..9c040af1d0 --- /dev/null +++ b/sim/app/api/db/workflow/variables/[id]/route.ts @@ -0,0 +1,151 @@ +import { NextRequest, NextResponse } from 'next/server' +import { eq } from 'drizzle-orm' +import { z } from 'zod' +import { getSession } from '@/lib/auth' +import { createLogger } from '@/lib/logs/console-logger' +import { db } from '@/db' +import { workflow } from '@/db/schema' +import { Variable } from '@/stores/panel/variables/types' + +const logger = createLogger('WorkflowVariablesAPI') + +// Schema for workflow variables updates +const VariablesSchema = z.object({ + variables: z.array( + z.object({ + id: z.string(), + workflowId: z.string(), + name: z.string(), + type: z.enum(['string', 'number', 'boolean', 'object', 'array']), + value: z.union([z.string(), z.number(), z.boolean(), z.record(z.any()), z.array(z.any())]), + }) + ), +}) + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const requestId = crypto.randomUUID().slice(0, 8) + const workflowId = (await params).id + + try { + const session = await getSession() + if (!session?.user?.id) { + logger.warn(`[${requestId}] Unauthorized workflow variables update attempt`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + // Check if the workflow belongs to the user + const workflowRecord = await db + .select() + .from(workflow) + .where(eq(workflow.id, workflowId)) + .limit(1) + + if (!workflowRecord.length) { + logger.warn(`[${requestId}] Workflow not found: ${workflowId}`) + return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) + } + + if (workflowRecord[0].userId !== session.user.id) { + logger.warn( + `[${requestId}] User ${session.user.id} attempted to update variables for workflow ${workflowId} owned by ${workflowRecord[0].userId}` + ) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const body = await req.json() + + try { + const { variables } = VariablesSchema.parse(body) + + // Format variables for storage + const variablesRecord: Record = {} + variables.forEach((variable) => { + variablesRecord[variable.id] = variable + }) + + // Update workflow with variables + await db + .update(workflow) + .set({ + variables: variablesRecord, + updatedAt: new Date(), + }) + .where(eq(workflow.id, workflowId)) + + return NextResponse.json({ success: true }) + } catch (validationError) { + if (validationError instanceof z.ZodError) { + logger.warn(`[${requestId}] Invalid workflow variables data`, { + errors: validationError.errors, + }) + return NextResponse.json( + { error: 'Invalid request data', details: validationError.errors }, + { status: 400 } + ) + } + throw validationError + } + } catch (error) { + logger.error(`[${requestId}] Error updating workflow variables`, error) + return NextResponse.json( + { error: 'Failed to update workflow variables' }, + { status: 500 } + ) + } +} + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const requestId = crypto.randomUUID().slice(0, 8) + const workflowId = (await params).id + + try { + // Get the session directly in the API route + const session = await getSession() + if (!session?.user?.id) { + logger.warn(`[${requestId}] Unauthorized workflow variables access attempt`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + // Check if the workflow belongs to the user + const workflowRecord = await db + .select() + .from(workflow) + .where(eq(workflow.id, workflowId)) + .limit(1) + + if (!workflowRecord.length) { + logger.warn(`[${requestId}] Workflow not found: ${workflowId}`) + return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) + } + + if (workflowRecord[0].userId !== session.user.id) { + logger.warn( + `[${requestId}] User ${session.user.id} attempted to access variables for workflow ${workflowId} owned by ${workflowRecord[0].userId}` + ) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + // Return variables if they exist + const variables = workflowRecord[0].variables as Record || {} + + // Add cache headers to prevent frequent reloading + const headers = new Headers({ + 'Cache-Control': 'max-age=60, stale-while-revalidate=300', // Cache for 1 minute, stale for 5 + 'ETag': `"${requestId}-${Object.keys(variables).length}"`, + }) + + return NextResponse.json({ data: variables }, { + status: 200, + headers, + }) + } catch (error: any) { + logger.error(`[${requestId}] Workflow variables fetch error`, error) + return NextResponse.json({ error: error.message }, { status: 500 }) + } +} \ No newline at end of file diff --git a/sim/app/w/[id]/components/console/components/console-entry/console-entry.tsx b/sim/app/w/[id]/components/panel/components/console/components/console-entry/console-entry.tsx similarity index 97% rename from sim/app/w/[id]/components/console/components/console-entry/console-entry.tsx rename to sim/app/w/[id]/components/panel/components/console/components/console-entry/console-entry.tsx index 349824f763..0f234c1ac9 100644 --- a/sim/app/w/[id]/components/console/components/console-entry/console-entry.tsx +++ b/sim/app/w/[id]/components/panel/components/console/components/console-entry/console-entry.tsx @@ -1,7 +1,7 @@ import { useMemo, useState } from 'react' import { format } from 'date-fns' import { AlertCircle, AlertTriangle, Calendar, CheckCircle2, Clock, Terminal } from 'lucide-react' -import { ConsoleEntry as ConsoleEntryType } from '@/stores/console/types' +import { ConsoleEntry as ConsoleEntryType } from '@/stores/panel/console/types' import { getBlock } from '@/blocks' import { JSONView } from '../json-view/json-view' diff --git a/sim/app/w/[id]/components/console/components/json-view/json-view.tsx b/sim/app/w/[id]/components/panel/components/console/components/json-view/json-view.tsx similarity index 100% rename from sim/app/w/[id]/components/console/components/json-view/json-view.tsx rename to sim/app/w/[id]/components/panel/components/console/components/json-view/json-view.tsx diff --git a/sim/app/w/[id]/components/panel/components/console/console.tsx b/sim/app/w/[id]/components/panel/components/console/console.tsx new file mode 100644 index 0000000000..92da7e8e76 --- /dev/null +++ b/sim/app/w/[id]/components/panel/components/console/console.tsx @@ -0,0 +1,36 @@ +'use client' + +import { useMemo } from 'react' +import { ScrollArea } from '@/components/ui/scroll-area' +import { useConsoleStore } from '@/stores/panel/console/store' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +import { ConsoleEntry } from './components/console-entry/console-entry' + +interface ConsoleProps { + panelWidth: number +} + +export function Console({ panelWidth }: ConsoleProps) { + const entries = useConsoleStore((state) => state.entries) + const { activeWorkflowId } = useWorkflowRegistry() + + const filteredEntries = useMemo(() => { + return entries.filter((entry) => entry.workflowId === activeWorkflowId) + }, [entries, activeWorkflowId]) + + return ( + +
+ {filteredEntries.length === 0 ? ( +
+ No console entries +
+ ) : ( + filteredEntries.map((entry) => ( + + )) + )} +
+
+ ) +} diff --git a/sim/app/w/[id]/components/panel/components/variables/variables.tsx b/sim/app/w/[id]/components/panel/components/variables/variables.tsx new file mode 100644 index 0000000000..9024873bcc --- /dev/null +++ b/sim/app/w/[id]/components/panel/components/variables/variables.tsx @@ -0,0 +1,327 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' +import { ChevronDown, Copy, MoreVertical, Plus, Trash } from 'lucide-react' +import { highlight, languages } from 'prismjs' +import 'prismjs/components/prism-javascript' +import 'prismjs/themes/prism.css' +import Editor from 'react-simple-code-editor' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { Input } from '@/components/ui/input' +import { ScrollArea } from '@/components/ui/scroll-area' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +import { useVariablesStore } from '../../../../../../../stores/panel/variables/store' +import { Variable, VariableType } from '../../../../../../../stores/panel/variables/types' + +interface VariablesProps { + panelWidth: number +} + +export function Variables({ panelWidth }: VariablesProps) { + const { activeWorkflowId, workflows } = useWorkflowRegistry() + const { + variables: storeVariables, + addVariable, + updateVariable, + deleteVariable, + duplicateVariable, + getVariablesByWorkflowId, + loadVariables, + } = useVariablesStore() + + // Get variables for the current workflow + const workflowVariables = activeWorkflowId ? getVariablesByWorkflowId(activeWorkflowId) : [] + + // Load variables when workflow changes + useEffect(() => { + if (activeWorkflowId && workflows[activeWorkflowId]) { + loadVariables(activeWorkflowId) + } + }, [activeWorkflowId, workflows, loadVariables]) + + // Track editor references + const editorRefs = useRef>({}) + + // Auto-save when variables are added/edited + const handleAddVariable = () => { + if (!activeWorkflowId) return + + // Create a default variable - naming is handled in the store + const id = addVariable({ + name: '', // Store will generate an appropriate name + type: 'string', + value: '', + workflowId: activeWorkflowId, + }) + + return id + } + + const getTypeIcon = (type: VariableType) => { + switch (type) { + case 'string': + return 'Aa' + case 'number': + return '123' + case 'boolean': + return '0/1' + case 'object': + return '{}' + case 'array': + return '[]' + default: + return '?' + } + } + + const getPlaceholder = (type: VariableType) => { + switch (type) { + case 'string': + return '"Hello world"' + case 'number': + return '42' + case 'boolean': + return 'true' + case 'object': + return '{\n "key": "value"\n}' + case 'array': + return '[\n 1,\n 2,\n 3\n]' + default: + return '' + } + } + + const getEditorLanguage = (type: VariableType) => { + switch (type) { + case 'object': + case 'array': + case 'boolean': + case 'number': + return 'javascript' + default: + return 'javascript' + } + } + + const formatValue = (variable: Variable) => { + if (variable.value === '') return '' + + try { + if (variable.type === 'object' || variable.type === 'array') { + // Try to prettify if it's JSON + const parsed = JSON.parse(variable.value as string) + return JSON.stringify(parsed, null, 2) + } + + // For string type, remove surrounding quotes for display + if (variable.type === 'string') { + const value = variable.value as string + const trimmed = value.trim() + + // Remove surrounding quotes if they exist + if ( + (trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("'") && trimmed.endsWith("'")) + ) { + // Get the content between quotes and unescape any internal quotes + return trimmed.slice(1, -1).replace(/\\"/g, '"') + } + return value + } + } catch (e) { + // If not valid JSON, return as is + } + + return variable.value as string + } + + // Clear editor refs when variables change + useEffect(() => { + // Clean up any references to deleted variables + Object.keys(editorRefs.current).forEach((id) => { + if (!workflowVariables.some((v) => v.id === id)) { + delete editorRefs.current[id] + } + }) + }, [workflowVariables]) + + // Handle editor value changes + const handleEditorChange = (variable: Variable, newValue: string) => { + // For string type, we send the raw input value so the store can handle quoting + updateVariable(variable.id, { value: newValue }) + } + + return ( + +
+ {/* Variables List */} + {workflowVariables.length === 0 ? ( +
+
No variables yet
+ +
+ ) : ( + <> +
+ {workflowVariables.map((variable) => ( +
+
+
+ updateVariable(variable.id, { name: e.target.value })} + /> + + + + + + + + + Set variable type + + + updateVariable(variable.id, { type: 'string' })} + className="cursor-pointer flex items-center" + > +
Aa
+ String +
+ updateVariable(variable.id, { type: 'number' })} + className="cursor-pointer flex items-center" + > +
123
+ Number +
+ updateVariable(variable.id, { type: 'boolean' })} + className="cursor-pointer flex items-center" + > +
0/1
+ Boolean +
+ updateVariable(variable.id, { type: 'object' })} + className="cursor-pointer flex items-center" + > +
{'{}'}
+ Object +
+ updateVariable(variable.id, { type: 'array' })} + className="cursor-pointer flex items-center" + > +
[]
+ Array +
+
+
+ +
+ + + + + + duplicateVariable(variable.id)} + className="cursor-pointer text-muted-foreground" + > + + Duplicate + + + deleteVariable(variable.id)} + className="cursor-pointer text-destructive focus:text-destructive" + > + + Delete + + + +
+
+
+ +
{ + editorRefs.current[variable.id] = el + }} + > + {variable.value === '' && ( +
+ {getPlaceholder(variable.type)} +
+ )} + + highlight( + code, + languages[getEditorLanguage(variable.type)], + getEditorLanguage(variable.type) + ) + } + padding={0} + style={{ + fontFamily: 'inherit', + lineHeight: '21px', + }} + className="focus:outline-none w-full" + textareaClassName="focus:outline-none focus:ring-0 bg-transparent resize-none w-full overflow-hidden whitespace-pre-wrap" + /> +
+
+ ))} +
+ + {/* Add Variable Button */} + + + )} +
+
+ ) +} diff --git a/sim/app/w/[id]/components/console/console.tsx b/sim/app/w/[id]/components/panel/panel.tsx similarity index 50% rename from sim/app/w/[id]/components/console/console.tsx rename to sim/app/w/[id]/components/panel/panel.tsx index 3ae6c32ac3..b567cae48a 100644 --- a/sim/app/w/[id]/components/console/console.tsx +++ b/sim/app/w/[id]/components/panel/panel.tsx @@ -1,28 +1,27 @@ 'use client' -import { useEffect, useMemo, useState } from 'react' -import { PanelLeftClose, PanelRightClose, Terminal } from 'lucide-react' +import { useEffect, useState } from 'react' +import { PanelRight } from 'lucide-react' import { Button } from '@/components/ui/button' -import { ScrollArea } from '@/components/ui/scroll-area' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { useConsoleStore } from '@/stores/console/store' +import { useConsoleStore } from '@/stores/panel/console/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -import { ConsoleEntry } from './components/console-entry/console-entry' +import { usePanelStore } from '../../../../../stores/panel/store' +import { Console } from './components/console/console' +import { Variables } from './components/variables/variables' -export function Console() { +export function Panel() { const [width, setWidth] = useState(336) // 84 * 4 = 336px (default width) const [isDragging, setIsDragging] = useState(false) - const isOpen = useConsoleStore((state) => state.isOpen) - const toggleConsole = useConsoleStore((state) => state.toggleConsole) - const entries = useConsoleStore((state) => state.entries) + const isOpen = usePanelStore((state) => state.isOpen) + const togglePanel = usePanelStore((state) => state.togglePanel) + const activeTab = usePanelStore((state) => state.activeTab) + const setActiveTab = usePanelStore((state) => state.setActiveTab) + const clearConsole = useConsoleStore((state) => state.clearConsole) const { activeWorkflowId } = useWorkflowRegistry() - const filteredEntries = useMemo(() => { - return entries.filter((entry) => entry.workflowId === activeWorkflowId) - }, [entries, activeWorkflowId]) - const handleMouseDown = (e: React.MouseEvent) => { setIsDragging(true) e.preventDefault() @@ -56,14 +55,14 @@ export function Console() { - Open Console + Open Panel ) } @@ -79,43 +78,61 @@ export function Console() { />
-

Console

- +
+ + +
+ + {activeTab === 'console' && ( + + )}
- -
- {filteredEntries.length === 0 ? ( -
- No console entries -
- ) : ( - filteredEntries.map((entry) => ( - - )) - )} -
-
+
+ {activeTab === 'console' ? ( + + ) : ( + + )} +
- Close Console + Close Panel
diff --git a/sim/app/w/[id]/hooks/use-workflow-execution.ts b/sim/app/w/[id]/hooks/use-workflow-execution.ts index e9be3aaeda..1e31ab7d71 100644 --- a/sim/app/w/[id]/hooks/use-workflow-execution.ts +++ b/sim/app/w/[id]/hooks/use-workflow-execution.ts @@ -2,7 +2,7 @@ import { useCallback, useState } from 'react' import { v4 as uuidv4 } from 'uuid' import { createLogger } from '@/lib/logs/console-logger' import { buildTraceSpans } from '@/lib/logs/trace-spans' -import { useConsoleStore } from '@/stores/console/store' +import { useConsoleStore } from '@/stores/panel/console/store' import { useExecutionStore } from '@/stores/execution/store' import { useNotificationStore } from '@/stores/notifications/store' import { useEnvironmentStore } from '@/stores/settings/environment/store' @@ -10,6 +10,7 @@ import { useGeneralStore } from '@/stores/settings/general/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { mergeSubblockState } from '@/stores/workflows/utils' import { useWorkflowStore } from '@/stores/workflows/workflow/store' +import { useVariablesStore } from '@/stores/panel/variables/store' import { Executor } from '@/executor' import { ExecutionResult } from '@/executor/types' import { Serializer } from '@/serializer' @@ -23,6 +24,7 @@ export function useWorkflowExecution() { const { toggleConsole } = useConsoleStore() const { getAllVariables } = useEnvironmentStore() const { isDebugModeEnabled } = useGeneralStore() + const { getVariablesByWorkflowId, variables } = useVariablesStore() const { isExecuting, isDebugging, @@ -117,11 +119,21 @@ export function useWorkflowExecution() { {} as Record ) + // Get workflow variables + const workflowVars = activeWorkflowId ? getVariablesByWorkflowId(activeWorkflowId) : [] + const workflowVariables = workflowVars.reduce( + (acc, variable) => { + acc[variable.id] = variable + return acc + }, + {} as Record + ) + // Create serialized workflow const workflow = new Serializer().serializeWorkflow(mergedStates, edges, loops) // Create executor and store in global state - const newExecutor = new Executor(workflow, currentBlockStates, envVarValues) + const newExecutor = new Executor(workflow, currentBlockStates, envVarValues, workflowVariables) setExecutor(newExecutor) // Execute workflow @@ -246,6 +258,7 @@ export function useWorkflowExecution() { addNotification, toggleConsole, getAllVariables, + getVariablesByWorkflowId, setIsExecuting, setIsDebugging, isDebugModeEnabled, diff --git a/sim/app/w/[id]/layout.tsx b/sim/app/w/[id]/layout.tsx index 4c40bff5d6..d2d546be06 100644 --- a/sim/app/w/[id]/layout.tsx +++ b/sim/app/w/[id]/layout.tsx @@ -1,7 +1,7 @@ import { Chat } from './components/chat/chat' -import { Console } from './components/console/console' import { ControlBar } from './components/control-bar/control-bar' import { ErrorBoundary } from './components/error' +import { Panel } from './components/panel/panel' import { Toolbar } from './components/toolbar/toolbar' export default function WorkflowLayout({ children }: { children: React.ReactNode }) { @@ -10,7 +10,7 @@ export default function WorkflowLayout({ children }: { children: React.ReactNode {/* */} - +
{children}
diff --git a/sim/app/w/[id]/workflow.tsx b/sim/app/w/[id]/workflow.tsx index 89d54a4828..c1fb2c1035 100644 --- a/sim/app/w/[id]/workflow.tsx +++ b/sim/app/w/[id]/workflow.tsx @@ -14,6 +14,7 @@ import 'reactflow/dist/style.css' import { createLogger } from '@/lib/logs/console-logger' import { useExecutionStore } from '@/stores/execution/store' import { useNotificationStore } from '@/stores/notifications/store' +import { useVariablesStore } from '@/stores/panel/variables/store' import { useGeneralStore } from '@/stores/settings/general/store' import { initializeSyncManagers, isSyncInitialized } from '@/stores/sync-registry' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' @@ -54,6 +55,7 @@ function WorkflowContent() { useWorkflowStore() const { setValue: setSubBlockValue } = useSubBlockStore() const { markAllAsRead } = useNotificationStore() + const { resetLoaded: resetVariablesLoaded } = useVariablesStore() // Execution and debug mode state const { activeBlockIds, pendingBlocks } = useExecutionStore() @@ -163,6 +165,8 @@ function WorkflowContent() { const checkInterval = setInterval(() => { if (!isActivelyLoadingFromDB()) { clearInterval(checkInterval) + // Reset variables loaded state before setting active workflow + resetVariablesLoaded() setActiveWorkflow(currentId) markAllAsRead(currentId) } @@ -170,6 +174,8 @@ function WorkflowContent() { return } + // Reset variables loaded state before setting active workflow + resetVariablesLoaded() setActiveWorkflow(currentId) markAllAsRead(currentId) } @@ -183,6 +189,7 @@ function WorkflowContent() { router, isInitialized, markAllAsRead, + resetVariablesLoaded, ]) // Transform blocks and loops into ReactFlow nodes diff --git a/sim/components/ui/tag-dropdown.tsx b/sim/components/ui/tag-dropdown.tsx index 744bb4704e..ebb4421bcb 100644 --- a/sim/components/ui/tag-dropdown.tsx +++ b/sim/components/ui/tag-dropdown.tsx @@ -1,8 +1,12 @@ import React, { useEffect, useMemo, useState } from 'react' import { createLogger } from '@/lib/logs/console-logger' import { cn } from '@/lib/utils' +import { useVariablesStore } from '@/stores/panel/variables/store' +import { Variable } from '@/stores/panel/variables/types' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' +import { getBlock } from '@/blocks' const logger = createLogger('TagDropdown') @@ -76,6 +80,20 @@ export const TagDropdown: React.FC = ({ // Get available tags from workflow state const blocks = useWorkflowStore((state) => state.blocks) const edges = useWorkflowStore((state) => state.edges) + const workflowId = useWorkflowRegistry((state) => state.activeWorkflowId) + + // Get variables from variables store + const getVariablesByWorkflowId = useVariablesStore((state) => state.getVariablesByWorkflowId) + const loadVariables = useVariablesStore((state) => state.loadVariables) + const variables = useVariablesStore((state) => state.variables) + const workflowVariables = workflowId ? getVariablesByWorkflowId(workflowId) : [] + + // Load variables when workflowId changes + useEffect(() => { + if (workflowId) { + loadVariables(workflowId) + } + }, [workflowId, loadVariables]) // Extract search term from input const searchTerm = useMemo(() => { @@ -85,7 +103,7 @@ export const TagDropdown: React.FC = ({ }, [inputValue, cursorPosition]) // Get source block and compute tags - const { tags } = useMemo(() => { + const { tags, variableInfoMap = {} } = useMemo(() => { // Helper function to get output paths const getOutputPaths = (obj: any, prefix = '', isStarterBlock = false): string[] => { if (typeof obj !== 'object' || obj === null) { @@ -108,10 +126,28 @@ export const TagDropdown: React.FC = ({ }) } + // Variables as tags - format as variable.{variableName} + const variableTags = workflowVariables.map( + (variable: Variable) => `variable.${variable.name.replace(/\s+/g, '')}` + ) + + // Create a map of variable tags to their type information + const variableInfoMap = workflowVariables.reduce( + (acc, variable) => { + const tagName = `variable.${variable.name.replace(/\s+/g, '')}` + acc[tagName] = { + type: variable.type, + id: variable.id, + } + return acc + }, + {} as Record + ) + // If we have an active source block ID from a drop, use that specific block only if (activeSourceBlockId) { const sourceBlock = blocks[activeSourceBlockId] - if (!sourceBlock) return { tags: [] } + if (!sourceBlock) return { tags: [...variableTags] } const blockName = sourceBlock.name || sourceBlock.type const normalizedBlockName = blockName.replace(/\s+/g, '').toLowerCase() @@ -124,9 +160,12 @@ export const TagDropdown: React.FC = ({ .getValue(activeSourceBlockId, 'metrics') as unknown as Metric[] if (Array.isArray(metricsValue)) { return { - tags: metricsValue.map( - (metric) => `${normalizedBlockName}.response.${metric.name.toLowerCase()}` - ), + tags: [ + ...variableTags, + ...metricsValue.map( + (metric) => `${normalizedBlockName}.response.${metric.name.toLowerCase()}` + ), + ], } } } catch (e) { @@ -149,7 +188,10 @@ export const TagDropdown: React.FC = ({ const fields = extractFieldsFromSchema(responseFormat) if (fields.length > 0) { return { - tags: fields.map((field: Field) => `${normalizedBlockName}.response.${field.name}`), + tags: [ + ...variableTags, + ...fields.map((field: Field) => `${normalizedBlockName}.response.${field.name}`), + ], } } } @@ -161,7 +203,7 @@ export const TagDropdown: React.FC = ({ // Fall back to default outputs if no response format const outputPaths = getOutputPaths(sourceBlock.outputs, '', sourceBlock.type === 'starter') return { - tags: outputPaths.map((path) => `${normalizedBlockName}.${path}`), + tags: [...variableTags, ...outputPaths.map((path) => `${normalizedBlockName}.${path}`)], } } @@ -217,8 +259,8 @@ export const TagDropdown: React.FC = ({ return outputPaths.map((path) => `${normalizedBlockName}.${path}`) }) - return { tags: sourceTags } - }, [blocks, edges, blockId, activeSourceBlockId]) + return { tags: [...variableTags, ...sourceTags], variableInfoMap } + }, [blocks, edges, blockId, activeSourceBlockId, workflowVariables]) // Filter tags based on search term const filteredTags = useMemo(() => { @@ -226,6 +268,22 @@ export const TagDropdown: React.FC = ({ return tags.filter((tag: string) => tag.toLowerCase().includes(searchTerm)) }, [tags, searchTerm]) + // Group tags into variables and blocks + const { variableTags, blockTags } = useMemo(() => { + const varTags: string[] = [] + const blkTags: string[] = [] + + filteredTags.forEach((tag) => { + if (tag.startsWith('variable.')) { + varTags.push(tag) + } else { + blkTags.push(tag) + } + }) + + return { variableTags: varTags, blockTags: blkTags } + }, [filteredTags]) + // Reset selection when filtered results change useEffect(() => { setSelectedIndex(0) @@ -240,7 +298,25 @@ export const TagDropdown: React.FC = ({ const lastOpenBracket = textBeforeCursor.lastIndexOf('<') if (lastOpenBracket === -1) return - const newValue = textBeforeCursor.slice(0, lastOpenBracket) + '<' + tag + '>' + textAfterCursor + // Process the tag if it's a variable tag + let processedTag = tag + if (tag.startsWith('variable.')) { + // Get the variable name from the tag (after 'variable.') + const variableName = tag.substring('variable.'.length) + + // Find the variable in the store by name + const variableObj = Object.values(variables).find( + (v) => v.name.replace(/\s+/g, '') === variableName + ) + + // We still use the full tag format internally to maintain compatibility + if (variableObj) { + processedTag = tag + } + } + + const newValue = + textBeforeCursor.slice(0, lastOpenBracket) + '<' + processedTag + '>' + textAfterCursor onSelect(newValue) onClose?.() @@ -296,24 +372,107 @@ export const TagDropdown: React.FC = ({ {filteredTags.length === 0 ? (
No matching tags found
) : ( - filteredTags.map((tag: string, index: number) => ( - - )) + <> + {variableTags.length > 0 && ( + <> +
+ Variables +
+
+ {variableTags.map((tag: string, index: number) => { + const variableInfo = variableInfoMap?.[tag] || null + const tagIndex = filteredTags.indexOf(tag) + + return ( + + ) + })} +
+ + )} + + {blockTags.length > 0 && ( + <> + {variableTags.length > 0 &&
} +
+ Blocks +
+
+ {blockTags.map((tag: string, index: number) => { + const tagIndex = filteredTags.indexOf(tag) + + // Get block name from tag (first part before the dot) + const blockName = tag.split('.')[0] + + // Get block type from blocks + const blockType = Object.values(blocks).find( + (block) => + (block.name || block.type).replace(/\s+/g, '').toLowerCase() === blockName + )?.type + + // Get block color from block config + const blockConfig = blockType ? getBlock(blockType) : null + const blockColor = blockConfig?.bgColor || '#2F55FF' // Default to blue if not found + + return ( + + ) + })} +
+ + )} + )}
diff --git a/sim/db/migrations/0022_gray_galactus.sql b/sim/db/migrations/0022_gray_galactus.sql new file mode 100644 index 0000000000..d3bede4e8d --- /dev/null +++ b/sim/db/migrations/0022_gray_galactus.sql @@ -0,0 +1 @@ +ALTER TABLE "workflow" ADD COLUMN "variables" json DEFAULT '{}'; \ No newline at end of file diff --git a/sim/db/migrations/meta/0022_snapshot.json b/sim/db/migrations/meta/0022_snapshot.json new file mode 100644 index 0000000000..5f91f5d696 --- /dev/null +++ b/sim/db/migrations/meta/0022_snapshot.json @@ -0,0 +1,1273 @@ +{ + "id": "bcb7ed22-334b-449b-871e-c64e973289ca", + "prevId": "30efcb43-6f93-4c90-8251-b9305702c135", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_unique": { + "name": "api_key_key_unique", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.marketplace": { + "name": "marketplace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stars": { + "name": "stars", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "views": { + "name": "views", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "marketplace_workflow_id_workflow_id_fk": { + "name": "marketplace_workflow_id_workflow_id_fk", + "tableFrom": "marketplace", + "tableTo": "workflow", + "columnsFrom": [ + "workflow_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "marketplace_author_id_user_id_fk": { + "name": "marketplace_author_id_user_id_fk", + "tableFrom": "marketplace", + "tableTo": "user", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.marketplace_star": { + "name": "marketplace_star", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "marketplace_id": { + "name": "marketplace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_marketplace_idx": { + "name": "user_marketplace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "marketplace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "marketplace_star_marketplace_id_marketplace_id_fk": { + "name": "marketplace_star_marketplace_id_marketplace_id_fk", + "tableFrom": "marketplace_star", + "tableTo": "marketplace", + "columnsFrom": [ + "marketplace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "marketplace_star_user_id_user_id_fk": { + "name": "marketplace_star_user_id_user_id_fk", + "tableFrom": "marketplace_star", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "general": { + "name": "general", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_user_id_fk": { + "name": "user_stats_user_id_user_id_fk", + "tableFrom": "user_stats", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stats_user_id_unique": { + "name": "user_stats_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook": { + "name": "webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_config": { + "name": "provider_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "path_idx": { + "name": "path_idx", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_workflow_id_workflow_id_fk": { + "name": "webhook_workflow_id_workflow_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow", + "columnsFrom": [ + "workflow_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#3972F6'" + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_deployed": { + "name": "is_deployed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_published": { + "name": "is_published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "collaborators": { + "name": "collaborators", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + } + }, + "indexes": {}, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_logs": { + "name": "workflow_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "duration": { + "name": "duration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "workflow_logs_workflow_id_workflow_id_fk": { + "name": "workflow_logs_workflow_id_workflow_id_fk", + "tableFrom": "workflow_logs", + "tableTo": "workflow", + "columnsFrom": [ + "workflow_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedule": { + "name": "workflow_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_ran_at": { + "name": "last_ran_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow", + "columnsFrom": [ + "workflow_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workflow_schedule_workflow_id_unique": { + "name": "workflow_schedule_workflow_id_unique", + "nullsNotDistinct": false, + "columns": [ + "workflow_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/sim/db/migrations/meta/_journal.json b/sim/db/migrations/meta/_journal.json index a872aeb4c3..b71d93ceff 100644 --- a/sim/db/migrations/meta/_journal.json +++ b/sim/db/migrations/meta/_journal.json @@ -155,6 +155,13 @@ "when": 1742850849852, "tag": "0021_shocking_korath", "breakpoints": true + }, + { + "idx": 22, + "version": "7", + "when": 1742889909342, + "tag": "0022_gray_galactus", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/sim/db/schema.ts b/sim/db/schema.ts index d9216957be..b6f37b8add 100644 --- a/sim/db/schema.ts +++ b/sim/db/schema.ts @@ -78,6 +78,7 @@ export const workflow = pgTable('workflow', { collaborators: json('collaborators').notNull().default('[]'), runCount: integer('run_count').notNull().default(0), lastRunAt: timestamp('last_run_at'), + variables: json('variables').default('{}'), }) export const waitlist = pgTable('waitlist', { diff --git a/sim/executor/index.ts b/sim/executor/index.ts index 0c6e2a562d..ce8184aeb8 100644 --- a/sim/executor/index.ts +++ b/sim/executor/index.ts @@ -1,5 +1,5 @@ import { createLogger } from '@/lib/logs/console-logger' -import { useConsoleStore } from '@/stores/console/store' +import { useConsoleStore } from '@/stores/panel/console/store' import { useExecutionStore } from '@/stores/execution/store' import { useGeneralStore } from '@/stores/settings/general/store' import { BlockOutput } from '@/blocks/types' @@ -39,12 +39,13 @@ export class Executor { private workflow: SerializedWorkflow, private initialBlockStates: Record = {}, private environmentVariables: Record = {}, + private workflowVariables: Record = {}, workflowInput?: any ) { this.validateWorkflow() this.workflowInput = workflowInput || {} - this.resolver = new InputResolver(workflow, environmentVariables) + this.resolver = new InputResolver(workflow, environmentVariables, workflowVariables) this.loopManager = new LoopManager(workflow.loops || {}) this.pathTracker = new PathTracker(workflow) diff --git a/sim/executor/resolver.ts b/sim/executor/resolver.ts index 40b51b5e65..7c1ab64ce3 100644 --- a/sim/executor/resolver.ts +++ b/sim/executor/resolver.ts @@ -10,7 +10,8 @@ export class InputResolver { constructor( private workflow: SerializedWorkflow, - private environmentVariables: Record + private environmentVariables: Record, + private workflowVariables: Record = {} ) { // Create maps for efficient lookups this.blockById = new Map(workflow.blocks.map((block) => [block.id, block])) @@ -59,8 +60,11 @@ export class InputResolver { // Handle string values that may contain references if (typeof value === 'string') { - // Resolve block references - let resolvedValue = this.resolveBlockReferences(value, context, block) + // First check for variable references + let resolvedValue = this.resolveVariableReferences(value) + + // Then resolve block references + resolvedValue = this.resolveBlockReferences(resolvedValue, context, block) // Check if this is an API key field const isApiKey = @@ -96,6 +100,44 @@ export class InputResolver { return result } + /** + * Resolves workflow variable references in a string (). + * + * @param value - String containing variable references + * @returns String with resolved variable references + */ + resolveVariableReferences(value: string): string { + const variableMatches = value.match(/]+)>/g) + if (!variableMatches) return value + + let resolvedValue = value + + for (const match of variableMatches) { + const variableName = match.slice(' { + const normalizedName = (variable.name || '').replace(/\s+/g, '') + return normalizedName === variableName + } + ) + + if (foundVariable) { + const [_, variable] = foundVariable + // Format the value appropriately + const formattedValue = + typeof variable.value === 'object' + ? JSON.stringify(variable.value) + : String(variable.value) + + resolvedValue = resolvedValue.replace(match, formattedValue) + } + } + + return resolvedValue + } + /** * Resolves block references in a string ( or ). * Handles inactive paths, missing blocks, and formats values appropriately. @@ -117,6 +159,11 @@ export class InputResolver { let resolvedValue = value for (const match of blockMatches) { + // Skip variables - they've already been processed + if (match.startsWith(' { useConsoleStore.setState({ entries: [], isOpen: false }) useChatStore.setState({ messages: [], isProcessing: false, error: null }) useCustomToolsStore.setState({ tools: {} }) + useVariablesStore.getState().resetLoaded() // Reset variables store tracking } // Helper function to log all store states @@ -265,6 +268,7 @@ export const logAllStores = () => { chat: useChatStore.getState(), customTools: useCustomToolsStore.getState(), subBlock: useSubBlockStore.getState(), + variables: useVariablesStore.getState(), } return state diff --git a/sim/stores/console/store.ts b/sim/stores/panel/console/store.ts similarity index 100% rename from sim/stores/console/store.ts rename to sim/stores/panel/console/store.ts diff --git a/sim/stores/console/types.ts b/sim/stores/panel/console/types.ts similarity index 100% rename from sim/stores/console/types.ts rename to sim/stores/panel/console/types.ts diff --git a/sim/stores/panel/store.ts b/sim/stores/panel/store.ts new file mode 100644 index 0000000000..fc2afbb2dd --- /dev/null +++ b/sim/stores/panel/store.ts @@ -0,0 +1,25 @@ +import { create } from 'zustand' +import { devtools, persist } from 'zustand/middleware' +import { PanelStore, PanelTab } from './types' + +export const usePanelStore = create()( + devtools( + persist( + (set) => ({ + isOpen: false, + activeTab: 'console', + + togglePanel: () => { + set((state) => ({ isOpen: !state.isOpen })) + }, + + setActiveTab: (tab: PanelTab) => { + set({ activeTab: tab }) + }, + }), + { + name: 'panel-store', + } + ) + ) +) \ No newline at end of file diff --git a/sim/stores/panel/types.ts b/sim/stores/panel/types.ts new file mode 100644 index 0000000000..bc2a563437 --- /dev/null +++ b/sim/stores/panel/types.ts @@ -0,0 +1,8 @@ +export type PanelTab = 'console' | 'variables' + +export interface PanelStore { + isOpen: boolean + activeTab: PanelTab + togglePanel: () => void + setActiveTab: (tab: PanelTab) => void +} \ No newline at end of file diff --git a/sim/stores/panel/variables/store.ts b/sim/stores/panel/variables/store.ts new file mode 100644 index 0000000000..bf7538ed55 --- /dev/null +++ b/sim/stores/panel/variables/store.ts @@ -0,0 +1,378 @@ +import { create } from 'zustand' +import { createLogger } from '@/lib/logs/console-logger' +import { devtools, persist } from 'zustand/middleware' +import { Variable, VariablesStore } from './types' +import { API_ENDPOINTS } from '@/stores/constants' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' + +const logger = createLogger('Variables Store') +const SAVE_DEBOUNCE_DELAY = 500 // 500ms debounce delay + +// Map to store debounce timers for each workflow +const saveTimers = new Map() +// Track which workflows have already been loaded +const loadedWorkflows = new Set() + +export const useVariablesStore = create()( + devtools( + persist( + (set, get) => ({ + variables: {}, + isLoading: false, + error: null, + isEditing: null, + + addVariable: (variable) => { + const id = crypto.randomUUID() + + // Get variables for this workflow + const workflowVariables = get().getVariablesByWorkflowId(variable.workflowId) + + // Auto-generate variable name if not provided or it's a default pattern name + if (!variable.name || /^variable\d+$/.test(variable.name)) { + // Find the highest existing Variable N number + const existingNumbers = workflowVariables + .map(v => { + const match = v.name.match(/^variable(\d+)$/) + return match ? parseInt(match[1]) : 0 + }) + .filter(n => !isNaN(n)) + + // Set new number to max + 1, or 1 if none exist + const nextNumber = existingNumbers.length > 0 + ? Math.max(...existingNumbers) + 1 + : 1 + + variable.name = `variable${nextNumber}` + } + + // Ensure name uniqueness within the workflow + let uniqueName = variable.name + let nameIndex = 1 + + // Check if name already exists in this workflow + while (workflowVariables.some(v => v.name === uniqueName)) { + uniqueName = `${variable.name} (${nameIndex})` + 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}"` + } + } + + set((state) => ({ + variables: { + ...state.variables, + [id]: { + id, + workflowId: variable.workflowId, + name: uniqueName, + type: variable.type, + value: variableValue, + }, + }, + })) + + // Auto-save to DB + get().saveVariables(variable.workflowId) + + return id + }, + + updateVariable: (id, update) => { + set((state) => { + if (!state.variables[id]) return state + + // If name is being updated, ensure it's unique + if (update.name) { + const workflowId = state.variables[id].workflowId + const workflowVariables = Object.values(state.variables).filter( + v => v.workflowId === workflowId && v.id !== id + ) + + let uniqueName = update.name + let nameIndex = 1 + + // Check if name already exists in this workflow + while (workflowVariables.some(v => v.name === uniqueName)) { + uniqueName = `${update.name} (${nameIndex})` + nameIndex++ + } + + // Update with unique name + 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}"` } + } + } + + const updated = { + ...state.variables, + [id]: { + ...state.variables[id], + ...update, + }, + } + + // Debounced auto-save to DB + const workflowId = state.variables[id].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 { variables: updated } + }) + }, + + deleteVariable: (id) => { + set((state) => { + if (!state.variables[id]) return state + + const workflowId = state.variables[id].workflowId + const { [id]: _, ...rest } = state.variables + + // Auto-save to DB - no debounce for deletion + setTimeout(() => get().saveVariables(workflowId), 0) + + return { variables: rest } + }) + }, + + duplicateVariable: (id) => { + const state = get() + if (!state.variables[id]) return '' + + const variable = state.variables[id] + const newId = crypto.randomUUID() + + // Ensure the duplicated name is unique + const workflowVariables = get().getVariablesByWorkflowId(variable.workflowId) + let baseName = `${variable.name} (copy)` + let uniqueName = baseName + let nameIndex = 1 + + // Check if name already exists in this workflow + while (workflowVariables.some(v => v.name === uniqueName)) { + uniqueName = `${baseName} (${nameIndex})` + nameIndex++ + } + + set((state) => ({ + variables: { + ...state.variables, + [newId]: { + id: newId, + workflowId: variable.workflowId, + name: uniqueName, + type: variable.type, + value: variable.value, + }, + }, + })) + + // Auto-save to DB + get().saveVariables(variable.workflowId) + + return newId + }, + + loadVariables: async (workflowId) => { + // Skip if already loaded to prevent redundant API calls + if (loadedWorkflows.has(workflowId)) return + + try { + set({ isLoading: true, error: null }) + + const response = await fetch(`${API_ENDPOINTS.WORKFLOW_VARIABLES}/${workflowId}`) + + // Handle 404 workflow not found gracefully + if (response.status === 404) { + logger.info(`No variables found for workflow ${workflowId}, initializing empty set`) + set((state) => { + // Keep variables from other workflows + const otherVariables = Object.values(state.variables).reduce((acc, variable) => { + if (variable.workflowId !== workflowId) { + acc[variable.id] = variable + } + return acc + }, {} as Record) + + // Mark this workflow as loaded to prevent further attempts + loadedWorkflows.add(workflowId) + + return { + variables: otherVariables, + isLoading: false, + } + }) + return + } + + if (!response.ok) { + throw new Error(`Failed to load workflow variables: ${response.statusText}`) + } + + const { data } = await response.json() + + if (data && typeof data === 'object') { + set((state) => { + // Merge with existing variables from other workflows + const otherVariables = Object.values(state.variables).reduce((acc, variable) => { + if (variable.workflowId !== workflowId) { + acc[variable.id] = variable + } + return acc + }, {} as Record) + + // Mark this workflow as loaded + loadedWorkflows.add(workflowId) + + return { + variables: { ...otherVariables, ...data }, + isLoading: false, + } + }) + } else { + set((state) => { + // Keep variables from other workflows + const otherVariables = Object.values(state.variables).reduce((acc, variable) => { + if (variable.workflowId !== workflowId) { + acc[variable.id] = variable + } + return acc + }, {} as Record) + + // Mark this workflow as loaded + loadedWorkflows.add(workflowId) + + return { + variables: otherVariables, + isLoading: false, + } + }) + } + } catch (error) { + logger.error('Error loading workflow variables:', { error, workflowId }) + set({ + error: error instanceof Error ? error.message : 'Unknown error', + isLoading: false, + }) + } + }, + + saveVariables: async (workflowId) => { + try { + // Skip if workflow doesn't exist in the registry + const workflowExists = useWorkflowRegistry.getState().workflows[workflowId] + if (!workflowExists) { + logger.info(`Skipping variable save for non-existent workflow: ${workflowId}`) + return + } + + set({ isLoading: true, error: null }) + + // Get only variables for this workflow + const workflowVariables = Object.values(get().variables).filter( + (variable) => variable.workflowId === workflowId + ) + + // Send to DB + const response = await fetch(`${API_ENDPOINTS.WORKFLOW_VARIABLES}/${workflowId}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + variables: workflowVariables, + }), + }) + + // Handle 404 workflow not found gracefully + if (response.status === 404) { + logger.info(`Cannot save variables - workflow ${workflowId} not found in database yet`) + // Reset loading state but don't treat as error + set({ isLoading: false }) + return + } + + if (!response.ok) { + throw new Error(`Failed to save workflow variables: ${response.statusText}`) + } + + set({ isLoading: false }) + } catch (error) { + logger.error('Error saving workflow variables:', { error, workflowId }) + set({ + error: error instanceof Error ? error.message : 'Unknown error', + isLoading: false, + }) + + // Reload from DB to ensure consistency + // Reset tracking to force a reload + loadedWorkflows.delete(workflowId) + get().loadVariables(workflowId) + } + }, + + getVariablesByWorkflowId: (workflowId) => { + return Object.values(get().variables).filter((variable) => variable.workflowId === workflowId) + }, + + // Reset the loaded workflow tracking + resetLoaded: () => { + loadedWorkflows.clear() + }, + }), + { + name: 'variables-store', + } + ) + ) +) diff --git a/sim/stores/panel/variables/types.ts b/sim/stores/panel/variables/types.ts new file mode 100644 index 0000000000..2406593de8 --- /dev/null +++ b/sim/stores/panel/variables/types.ts @@ -0,0 +1,52 @@ +export type VariableType = 'string' | 'number' | 'boolean' | 'object' | 'array' + +/** + * Represents a workflow variable with workflow-specific naming + * Variable names must be unique within each workflow + */ +export interface Variable { + id: string + workflowId: string + name: string // Must be unique per workflow + type: VariableType + value: any +} + +export interface VariablesStore { + variables: Record + isLoading: boolean + error: string | null + isEditing: string | null + + /** + * Adds a new variable with automatic name uniqueness validation + * If a variable with the same name exists, it will be suffixed with a number + */ + addVariable: (variable: Omit) => string + + /** + * Updates a variable, ensuring name remains unique within the workflow + * If an updated name conflicts with existing ones, a numbered suffix is added + */ + updateVariable: (id: string, update: Partial>) => void + + deleteVariable: (id: string) => void + + /** + * Duplicates a variable with a "(copy)" suffix, ensuring name uniqueness + */ + duplicateVariable: (id: string) => string + + loadVariables: (workflowId: string) => Promise + saveVariables: (workflowId: string) => Promise + + /** + * Returns all variables for a specific workflow + */ + getVariablesByWorkflowId: (workflowId: string) => Variable[] + + /** + * Resets tracking of loaded workflows + */ + resetLoaded: () => void +} diff --git a/sim/stores/workflows/sync.ts b/sim/stores/workflows/sync.ts index 22eca6f40a..3f6baa5eb4 100644 --- a/sim/stores/workflows/sync.ts +++ b/sim/stores/workflows/sync.ts @@ -12,6 +12,10 @@ import { BlockState } from './workflow/types' const logger = createLogger('Workflows Sync') +// Add debounce utility +let syncDebounceTimer: NodeJS.Timeout | null = null; +const DEBOUNCE_DELAY = 500; // 500ms delay + // Flag to prevent immediate sync back to DB after loading from DB let isLoadingFromDB = false let loadingFromDBToken: string | null = null @@ -177,8 +181,10 @@ export async function fetchWorkflowsFromDB(): Promise { const workflowCount = Object.keys(registryWorkflows).length logger.info(`DB loading complete. Workflows in registry: ${workflowCount}`) - // Trigger one final sync to ensure consistency - if (workflowCount > 0) { + // Only trigger a final sync if necessary (don't do this for normal loads) + // This helps reduce unnecessary POST requests + const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId + if (workflowCount > 0 && activeWorkflowId && activeDBSyncNeeded()) { // Small delay for state to fully settle before allowing syncs setTimeout(() => { workflowSync.sync() @@ -188,8 +194,26 @@ export async function fetchWorkflowsFromDB(): Promise { } } -// Syncs workflows to the database -export const workflowSync = createSingletonSyncManager('workflow-sync', () => ({ +// Helper to determine if an active DB sync is actually needed +function activeDBSyncNeeded(): boolean { + // In most cases after initial load, we don't need to sync back to DB + // Only sync if we have detected a change that needs to be persisted + const lastSynced = localStorage.getItem('last_db_sync_timestamp') + const currentTime = Date.now() + + if (!lastSynced) { + // First sync - record it and return true + localStorage.setItem('last_db_sync_timestamp', currentTime.toString()) + return true + } + + // Add additional checks here if needed for specific workflow changes + // For now, we'll simply avoid the automatic sync after load + return false +} + +// Create the basic sync configuration +const workflowSyncConfig = { endpoint: API_ENDPOINTS.WORKFLOW, preparePayload: () => { if (typeof window === 'undefined') return {} @@ -222,10 +246,35 @@ export const workflowSync = createSingletonSyncManager('workflow-sync', () => ({ workflows: workflowsData, } }, - method: 'POST', + method: 'POST' as const, syncOnInterval: true, syncOnExit: true, - onSyncSuccess: async (data) => { + onSyncSuccess: async () => { logger.info('Workflows synced to DB successfully') }, -})) +}; + +// Create the sync manager +const baseWorkflowSync = createSingletonSyncManager('workflow-sync', () => workflowSyncConfig); + +// Create a debounced version of the sync manager +export const workflowSync = { + ...baseWorkflowSync, + sync: () => { + // Clear any existing timeout + if (syncDebounceTimer) { + clearTimeout(syncDebounceTimer); + } + + // Set new timeout + syncDebounceTimer = setTimeout(() => { + // Perform the sync + baseWorkflowSync.sync(); + + // Update the last sync timestamp + if (typeof window !== 'undefined') { + localStorage.setItem('last_db_sync_timestamp', Date.now().toString()); + } + }, DEBOUNCE_DELAY); + } +};