|
|
@@ -246,7 +246,7 @@ function WorkflowEditorInner() {
|
|
|
const { currentNodes, currentConnections } = getCurrentWorkflowData()
|
|
|
pinExecution(execution, currentNodes, currentConnections)
|
|
|
setViewingExecution(true) // Must be called AFTER pinExecution (which resets it to false)
|
|
|
- setShowExecutionList(false)
|
|
|
+ // Keep execution list open for continuous navigation
|
|
|
showToast('info', 'Viewing execution results')
|
|
|
}, [getCurrentWorkflowData, pinExecution, setViewingExecution, showToast])
|
|
|
|
|
|
@@ -254,7 +254,7 @@ function WorkflowEditorInner() {
|
|
|
const handlePinExecution = useCallback((execution: ExecutionDetail) => {
|
|
|
const { currentNodes, currentConnections } = getCurrentWorkflowData()
|
|
|
pinExecution(execution, currentNodes, currentConnections)
|
|
|
- setShowExecutionList(false)
|
|
|
+ // Keep execution list open for continuous navigation
|
|
|
showToast('success', 'Execution data pinned')
|
|
|
}, [getCurrentWorkflowData, pinExecution, showToast])
|
|
|
|
|
|
@@ -364,10 +364,51 @@ function WorkflowEditorInner() {
|
|
|
executeWorkflow(triggerNodeId)
|
|
|
}, [executeWorkflow])
|
|
|
|
|
|
+ // Helper to create a simple hash of node config for cache validation
|
|
|
+ const hashConfig = useCallback((config: any): string => {
|
|
|
+ return JSON.stringify(config || {})
|
|
|
+ }, [])
|
|
|
+
|
|
|
// Execute workflow up to a specific node (run single node)
|
|
|
const executeNode = useCallback((targetNodeId: string) => {
|
|
|
if (!id) return
|
|
|
|
|
|
+ // Build cached outputs for upstream nodes that have unchanged configs
|
|
|
+ // This allows the backend to skip re-executing nodes that haven't changed
|
|
|
+ const cachedOutputs: Record<string, { output: any; configHash: string }> = {}
|
|
|
+
|
|
|
+ // Collect all upstream node IDs (nodes that feed into the target)
|
|
|
+ const collectUpstreamNodes = (nodeId: string, visited: Set<string> = new Set()): string[] => {
|
|
|
+ if (visited.has(nodeId)) return []
|
|
|
+ visited.add(nodeId)
|
|
|
+
|
|
|
+ const upstreamIds: string[] = []
|
|
|
+ for (const edge of edges) {
|
|
|
+ if (edge.target === nodeId) {
|
|
|
+ upstreamIds.push(edge.source)
|
|
|
+ upstreamIds.push(...collectUpstreamNodes(edge.source, visited))
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return upstreamIds
|
|
|
+ }
|
|
|
+
|
|
|
+ const upstreamNodeIds = collectUpstreamNodes(targetNodeId)
|
|
|
+
|
|
|
+ // For each upstream node, if we have cached output, include it
|
|
|
+ for (const nodeId of upstreamNodeIds) {
|
|
|
+ const cachedOutput = lastExecutionResults[nodeId]
|
|
|
+ if (cachedOutput !== undefined) {
|
|
|
+ // Find the node to get its current config
|
|
|
+ const node = nodes.find(n => n.id === nodeId)
|
|
|
+ if (node) {
|
|
|
+ cachedOutputs[nodeId] = {
|
|
|
+ output: cachedOutput,
|
|
|
+ configHash: hashConfig(node.data?.config),
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
// Set pending marker BEFORE HTTP request to capture early WebSocket events
|
|
|
pendingExecIdRef.current = 'pending'
|
|
|
|
|
|
@@ -382,7 +423,8 @@ function WorkflowEditorInner() {
|
|
|
setShowExecutionPanel(true)
|
|
|
pendingEventsRef.current = []
|
|
|
|
|
|
- workflowsApi.executeToNode(id, targetNodeId).then((result) => {
|
|
|
+ // Pass cached outputs to the backend
|
|
|
+ workflowsApi.executeToNode(id, targetNodeId, undefined, cachedOutputs).then((result) => {
|
|
|
pendingExecIdRef.current = result.executionId
|
|
|
setExecutionState((prev) => ({
|
|
|
...prev,
|
|
|
@@ -399,7 +441,7 @@ function WorkflowEditorInner() {
|
|
|
setExecutingNodeId(null)
|
|
|
showToast('error', `Execution failed: ${error.message}`)
|
|
|
})
|
|
|
- }, [id, showToast])
|
|
|
+ }, [id, showToast, nodes, edges, lastExecutionResults, hashConfig])
|
|
|
|
|
|
// WebSocket subscription for execution updates - subscribe on mount, filter in handler
|
|
|
useEffect(() => {
|
|
|
@@ -659,9 +701,35 @@ function WorkflowEditorInner() {
|
|
|
wsClient.subscribe(['executions.*'])
|
|
|
wsClient.on('executions.*', handleExecutionEvent)
|
|
|
|
|
|
+ // Handle WebSocket reconnection - reset execution state to prevent stuck workflows
|
|
|
+ const handleReconnect = () => {
|
|
|
+ console.log('WebSocket reconnected - resetting execution state')
|
|
|
+ // If there was a pending execution, it's now stale - reset it
|
|
|
+ if (pendingExecIdRef.current) {
|
|
|
+ pendingExecIdRef.current = null
|
|
|
+ pendingEventsRef.current = []
|
|
|
+ setExecutionState((prev) => {
|
|
|
+ // Only reset if execution was still running
|
|
|
+ if (prev.status === 'running') {
|
|
|
+ showToast('info', 'Connection restored - execution state reset. Please re-run if needed.')
|
|
|
+ return {
|
|
|
+ executionId: null,
|
|
|
+ status: 'idle',
|
|
|
+ nodeStates: {},
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return prev
|
|
|
+ })
|
|
|
+ setExecutingNodeId(null)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ wsClient.onReconnect(handleReconnect)
|
|
|
+
|
|
|
return () => {
|
|
|
wsClient.unsubscribe(['executions.*'])
|
|
|
wsClient.off('executions.*', handleExecutionEvent)
|
|
|
+ wsClient.offReconnect(handleReconnect)
|
|
|
}
|
|
|
}, [showToast]) // Subscribe on mount, unsubscribe on unmount
|
|
|
|
|
|
@@ -1562,7 +1630,8 @@ function WorkflowEditorInner() {
|
|
|
traverse(nodeId)
|
|
|
|
|
|
for (const { node: sourceNode } of orderedNodes) {
|
|
|
- const nodeName = sourceNode.data.label || sourceNode.data.type
|
|
|
+ // Use custom label if set, otherwise fall back to default label or type
|
|
|
+ const nodeName = sourceNode.data.config?._customLabel || sourceNode.data.label || sourceNode.data.type
|
|
|
// Check if this source node is a direct upstream (immediate predecessor) of the target node
|
|
|
const isDirectUpstream = directUpstreamIds.has(sourceNode.id)
|
|
|
// Get the targetInput for direct upstream nodes
|
|
|
@@ -1624,52 +1693,42 @@ function WorkflowEditorInner() {
|
|
|
if (nodeDef) {
|
|
|
if (sourceNode.data.type === 'loop') {
|
|
|
const loopConfig = sourceNode.data.config || {}
|
|
|
- const inputField = loopConfig.inputField || 'body'
|
|
|
+ const itemVariable = loopConfig.itemVariableName || 'item'
|
|
|
+ const indexVariable = loopConfig.indexVariableName || 'index'
|
|
|
|
|
|
- const incomingEdgesForLoop = edges.filter((e) => e.target === sourceNode.id)
|
|
|
+ // Get sample item from the loop node's own execution result
|
|
|
+ // The loop node stores the first item in its 'loop' output during execution
|
|
|
let sampleItem: any = null
|
|
|
-
|
|
|
- for (const edge of incomingEdgesForLoop) {
|
|
|
- const upstreamResult = lastExecutionResults[edge.source]
|
|
|
- if (!upstreamResult) continue
|
|
|
-
|
|
|
- const pathParts = inputField.split('.')
|
|
|
- let arrayData = upstreamResult
|
|
|
- for (const part of pathParts) {
|
|
|
- if (arrayData && typeof arrayData === 'object' && part in arrayData) {
|
|
|
- arrayData = arrayData[part]
|
|
|
- } else {
|
|
|
- arrayData = null
|
|
|
- break
|
|
|
- }
|
|
|
- }
|
|
|
- if (Array.isArray(arrayData) && arrayData.length > 0) {
|
|
|
- sampleItem = arrayData[0]
|
|
|
- break
|
|
|
- }
|
|
|
+ const loopNodeResult = lastExecutionResults[sourceNode.id]
|
|
|
+ if (loopNodeResult?.loop?.[itemVariable] !== undefined) {
|
|
|
+ sampleItem = loopNodeResult.loop[itemVariable]
|
|
|
+ } else if (loopNodeResult?._items && Array.isArray(loopNodeResult._items) && loopNodeResult._items.length > 0) {
|
|
|
+ // Fallback to _items array if loop output not available
|
|
|
+ sampleItem = loopNodeResult._items[0]
|
|
|
}
|
|
|
|
|
|
+ // Show the user-configured variable names as primary access methods
|
|
|
const loopFields: FieldInfo[] = [
|
|
|
{
|
|
|
- path: 'currentIndex',
|
|
|
- type: 'number',
|
|
|
+ path: itemVariable,
|
|
|
+ type: sampleItem !== null && typeof sampleItem === 'object' ? 'object' : 'any',
|
|
|
nodeName,
|
|
|
nodeId: sourceNode.id,
|
|
|
- displayName: 'currentIndex',
|
|
|
+ displayName: `${itemVariable} (current item)`,
|
|
|
isDirectUpstream,
|
|
|
targetInput,
|
|
|
+ children: sampleItem !== null && typeof sampleItem === 'object'
|
|
|
+ ? extractFieldsFromData(sampleItem, itemVariable, nodeName, sourceNode.id, 0, isDirectUpstream, targetInput)
|
|
|
+ : undefined,
|
|
|
},
|
|
|
{
|
|
|
- path: 'currentItem',
|
|
|
- type: sampleItem !== null && typeof sampleItem === 'object' ? 'object' : 'any',
|
|
|
+ path: indexVariable,
|
|
|
+ type: 'number',
|
|
|
nodeName,
|
|
|
nodeId: sourceNode.id,
|
|
|
- displayName: 'currentItem',
|
|
|
+ displayName: `${indexVariable} (current index)`,
|
|
|
isDirectUpstream,
|
|
|
targetInput,
|
|
|
- children: sampleItem !== null && typeof sampleItem === 'object'
|
|
|
- ? extractFieldsFromData(sampleItem, 'currentItem', nodeName, sourceNode.id, 0, isDirectUpstream, targetInput)
|
|
|
- : undefined,
|
|
|
},
|
|
|
{
|
|
|
path: 'totalItems',
|
|
|
@@ -1680,6 +1739,24 @@ function WorkflowEditorInner() {
|
|
|
isDirectUpstream,
|
|
|
targetInput,
|
|
|
},
|
|
|
+ {
|
|
|
+ path: 'isFirst',
|
|
|
+ type: 'boolean',
|
|
|
+ nodeName,
|
|
|
+ nodeId: sourceNode.id,
|
|
|
+ displayName: 'isFirst',
|
|
|
+ isDirectUpstream,
|
|
|
+ targetInput,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ path: 'isLast',
|
|
|
+ type: 'boolean',
|
|
|
+ nodeName,
|
|
|
+ nodeId: sourceNode.id,
|
|
|
+ displayName: 'isLast',
|
|
|
+ isDirectUpstream,
|
|
|
+ targetInput,
|
|
|
+ },
|
|
|
]
|
|
|
|
|
|
fields.push(...loopFields)
|
|
|
@@ -1768,6 +1845,17 @@ function WorkflowEditorInner() {
|
|
|
/>
|
|
|
)}
|
|
|
|
|
|
+ {/* Execution List Panel - left side panel for browsing past executions */}
|
|
|
+ {id && showExecutionList && (
|
|
|
+ <ExecutionListPanel
|
|
|
+ workflowId={id}
|
|
|
+ isOpen={showExecutionList}
|
|
|
+ onClose={() => setShowExecutionList(false)}
|
|
|
+ onViewExecution={handleViewExecution}
|
|
|
+ onPinExecution={handlePinExecution}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+
|
|
|
{/* Flow canvas */}
|
|
|
<div className={`flex-1 overflow-hidden ${(showExecutionPanel || isViewingExecution || pinnedExecution) ? 'w-2/3' : 'w-full'}`}>
|
|
|
<ReactFlow
|
|
|
@@ -1879,17 +1967,6 @@ function WorkflowEditorInner() {
|
|
|
}}
|
|
|
/>
|
|
|
)}
|
|
|
-
|
|
|
- {/* Execution List Panel - side panel for browsing past executions */}
|
|
|
- {id && showExecutionList && (
|
|
|
- <ExecutionListPanel
|
|
|
- workflowId={id}
|
|
|
- isOpen={showExecutionList}
|
|
|
- onClose={() => setShowExecutionList(false)}
|
|
|
- onViewExecution={handleViewExecution}
|
|
|
- onPinExecution={handlePinExecution}
|
|
|
- />
|
|
|
- )}
|
|
|
</div>
|
|
|
|
|
|
{/* Delete confirmation modal */}
|