import { useCallback, useEffect, useRef } from 'react' import { toast } from '@sim/emcn' import { createLogger } from '@sim/logger' import { BLOCK_OPERATIONS, BLOCKS_OPERATIONS, EDGES_OPERATIONS, OPERATION_TARGETS, SUBBLOCK_OPERATIONS, SUBFLOW_OPERATIONS, VARIABLE_OPERATIONS, WORKFLOW_OPERATIONS, } from '@sim/realtime-protocol/constants' import { generateId } from '@sim/utils/id' import { getWorkflowBlockNameConflict } from '@sim/workflow-types/workflow' import { useQueryClient } from '@tanstack/react-query' import { isEqual } from 'es-toolkit' import type { Edge } from 'reactflow' import { useShallow } from 'zustand/react/shallow' import { requestJson } from '@/lib/api/client/request' import { getWorkflowStateContract } from '@/lib/api/contracts' import { useSession } from '@/lib/auth/auth-client' import { type WorkflowSearchSubflowFieldId, workflowSearchSubflowFieldMatchesExpected, } from '@/lib/workflows/search-replace/subflow-fields' import { isSyntheticToolSubBlockId } from '@/lib/workflows/tool-input/synthetic-subblocks' import { useSocket } from '@/app/workspace/providers/socket-provider' import { getBlock } from '@/blocks' import { getSubBlocksDependingOnChange } from '@/blocks/utils' import { invalidateDeploymentQueries } from '@/hooks/queries/deployments' import { useUndoRedo } from '@/hooks/use-undo-redo' import { registerEmitFunctions, useOperationQueue, useOperationQueueStore, } from '@/stores/operation-queue/store' import { usePanelEditorStore } from '@/stores/panel' import { useCodeUndoRedoStore, useUndoRedoStore } from '@/stores/undo-redo' import { useVariablesStore } from '@/stores/variables/store' import { useWorkflowDiffStore } from '@/stores/workflow-diff/store' import { applyWorkflowStateToStores, WORKFLOW_DIFF_SETTLED_EVENT, } from '@/stores/workflow-diff/utils' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { filterNewEdges, filterValidEdges, mergeSubblockState } from '@/stores/workflows/utils' import { useWorkflowStore } from '@/stores/workflows/workflow/store' import type { BlockState, Loop, Parallel, Position, WorkflowState, } from '@/stores/workflows/workflow/types' import { filterAcyclicEdges, findAllDescendantNodes, isBlockProtected, } from '@/stores/workflows/workflow/utils' const logger = createLogger('CollaborativeWorkflow') export function useCollaborativeWorkflow() { const queryClient = useQueryClient() const undoRedo = useUndoRedo() const isUndoRedoInProgress = useRef(false) const lastDiffOperationId = useRef(null) useEffect(() => { const moveHandler = (e: any) => { const { blockId, before, after } = e.detail || {} if (!blockId || !before || !after) return if (isUndoRedoInProgress.current) return undoRedo.recordBatchMoveBlocks([{ blockId, before, after }]) } const parentUpdateHandler = (e: any) => { const { blockId, oldParentId, newParentId, oldPosition, newPosition, affectedEdges } = e.detail || {} if (!blockId) return if (isUndoRedoInProgress.current) return undoRedo.recordUpdateParent( blockId, oldParentId, newParentId, oldPosition, newPosition, affectedEdges ) } const diffOperationHandler = (e: any) => { const { type, baselineSnapshot, proposedState, diffAnalysis, beforeAccept, afterAccept, beforeReject, afterReject, } = e.detail || {} // Don't record during undo/redo operations if (isUndoRedoInProgress.current) return // Generate a unique ID for this diff operation to prevent duplicates // Use block keys from the relevant states for each operation type let stateForId if (type === 'apply-diff') { stateForId = proposedState } else if (type === 'accept-diff') { stateForId = afterAccept } else if (type === 'reject-diff') { stateForId = afterReject } const blockKeys = stateForId?.blocks ? Object.keys(stateForId.blocks).sort().join(',') : '' const operationId = `${type}-${blockKeys}` if (lastDiffOperationId.current === operationId) { logger.debug('Skipping duplicate diff operation', { type, operationId }) return // Skip duplicate } lastDiffOperationId.current = operationId if (type === 'apply-diff' && baselineSnapshot && proposedState) { undoRedo.recordApplyDiff(baselineSnapshot, proposedState, diffAnalysis) } else if (type === 'accept-diff' && beforeAccept && afterAccept) { undoRedo.recordAcceptDiff(beforeAccept, afterAccept, diffAnalysis, baselineSnapshot) } else if (type === 'reject-diff' && beforeReject && afterReject) { undoRedo.recordRejectDiff(beforeReject, afterReject, diffAnalysis, baselineSnapshot) } } window.addEventListener('workflow-record-move', moveHandler) window.addEventListener('workflow-record-parent-update', parentUpdateHandler) window.addEventListener('record-diff-operation', diffOperationHandler) return () => { window.removeEventListener('workflow-record-move', moveHandler) window.removeEventListener('workflow-record-parent-update', parentUpdateHandler) window.removeEventListener('record-diff-operation', diffOperationHandler) } }, [undoRedo]) const { isConnected, currentWorkflowId, emitWorkflowOperation, emitSubblockUpdate, emitVariableUpdate, onWorkflowOperation, onSubblockUpdate, onVariableUpdate, onWorkflowDeleted, onWorkflowReverted, onWorkflowUpdated, onWorkflowDeployed, onOperationConfirmed, onOperationFailed, } = useSocket() const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId) const { data: session } = useSession() const { hasActiveDiff, isShowingDiff } = useWorkflowDiffStore( useShallow((state) => ({ hasActiveDiff: state.hasActiveDiff, isShowingDiff: state.isShowingDiff, })) ) const isBaselineDiffView = hasActiveDiff && !isShowingDiff // Track if we're applying remote changes to avoid infinite loops const isApplyingRemoteChange = useRef(false) const reloadSequencesRef = useRef>({}) const { addToQueue, confirmOperation, failOperation, cancelOperationsForBlock, cancelOperationsForVariable, } = useOperationQueue() // Register emit functions with operation queue store useEffect(() => { const registeredWorkflowId = isConnected && currentWorkflowId === activeWorkflowId ? currentWorkflowId : null registerEmitFunctions( emitWorkflowOperation, emitSubblockUpdate, emitVariableUpdate, registeredWorkflowId ) }, [ activeWorkflowId, currentWorkflowId, emitWorkflowOperation, emitSubblockUpdate, emitVariableUpdate, isConnected, ]) useEffect(() => { const handleWorkflowOperation = (data: any) => { const { operation, target, payload, userId, metadata } = data if (isApplyingRemoteChange.current) return // Filter broadcasts by workflowId to prevent cross-workflow updates if (metadata?.workflowId && metadata.workflowId !== activeWorkflowId) { logger.debug('Ignoring workflow operation for different workflow', { broadcastWorkflowId: metadata.workflowId, activeWorkflowId, }) return } logger.info(`Received ${operation} on ${target} from user ${userId}`) // Apply the operation to local state isApplyingRemoteChange.current = true try { if (target === OPERATION_TARGETS.BLOCK) { switch (operation) { case BLOCK_OPERATIONS.UPDATE_NAME: useWorkflowStore.getState().updateBlockName(payload.id, payload.name) break case BLOCK_OPERATIONS.UPDATE_ADVANCED_MODE: useWorkflowStore.getState().setBlockAdvancedMode(payload.id, payload.advancedMode) break case BLOCK_OPERATIONS.UPDATE_CANONICAL_MODE: useWorkflowStore .getState() .setBlockCanonicalMode(payload.id, payload.canonicalId, payload.canonicalMode) break case BLOCK_OPERATIONS.REPLACE_CANONICAL_MODES: useWorkflowStore .getState() .setBlockCanonicalModes(payload.id, payload.data?.canonicalModes ?? {}) break } } else if (target === OPERATION_TARGETS.BLOCKS) { switch (operation) { case BLOCKS_OPERATIONS.BATCH_UPDATE_POSITIONS: { const { updates } = payload if (Array.isArray(updates)) { useWorkflowStore.getState().batchUpdatePositions(updates) } break } } } else if (target === OPERATION_TARGETS.SUBBLOCK) { switch (operation) { case SUBBLOCK_OPERATIONS.BATCH_UPDATE: { const { updates } = payload if (Array.isArray(updates)) { updates.forEach( (update: { blockId: string; subblockId: string; value: unknown }) => { useSubBlockStore .getState() .setValue(update.blockId, update.subblockId, update.value) useWorkflowStore .getState() .syncDynamicHandleSubblockValue( update.blockId, update.subblockId, update.value ) } ) } break } } } else if (target === OPERATION_TARGETS.EDGES) { switch (operation) { case EDGES_OPERATIONS.BATCH_REMOVE_EDGES: { const { ids } = payload if (Array.isArray(ids) && ids.length > 0) { useWorkflowStore.getState().batchRemoveEdges(ids) const updatedBlocks = useWorkflowStore.getState().blocks const updatedEdges = useWorkflowStore.getState().edges const graph = { blocksById: updatedBlocks, edgesById: Object.fromEntries(updatedEdges.map((e) => [e.id, e])), } const undoRedoStore = useUndoRedoStore.getState() const stackKeys = Object.keys(undoRedoStore.stacks) stackKeys.forEach((key) => { const [wfId, uId] = key.split(':') if (wfId === activeWorkflowId) { undoRedoStore.pruneInvalidEntries(wfId, uId, graph) } }) } break } case EDGES_OPERATIONS.BATCH_ADD_EDGES: { const { edges } = payload if (Array.isArray(edges) && edges.length > 0) { const blocks = useWorkflowStore.getState().blocks const currentEdges = useWorkflowStore.getState().edges const validEdges = filterValidEdges(edges, blocks) const newEdges = filterNewEdges(validEdges, currentEdges) if (newEdges.length > 0) { useWorkflowStore.getState().batchAddEdges(newEdges, { skipValidation: true }) } } break } } } else if (target === OPERATION_TARGETS.SUBFLOW) { switch (operation) { case SUBFLOW_OPERATIONS.UPDATE: // Handle subflow configuration updates (loop/parallel type changes, etc.) if (payload.type === 'loop') { const { config } = payload if (config.loopType !== undefined) { useWorkflowStore.getState().updateLoopType(payload.id, config.loopType) } if (config.iterations !== undefined) { useWorkflowStore.getState().updateLoopCount(payload.id, config.iterations) } if (config.forEachItems !== undefined) { useWorkflowStore.getState().setLoopForEachItems(payload.id, config.forEachItems) } if (config.whileCondition !== undefined) { useWorkflowStore .getState() .setLoopWhileCondition(payload.id, config.whileCondition) } if (config.doWhileCondition !== undefined) { useWorkflowStore .getState() .setLoopDoWhileCondition(payload.id, config.doWhileCondition) } } else if (payload.type === 'parallel') { const { config } = payload if (config.parallelType !== undefined) { useWorkflowStore.getState().updateParallelType(payload.id, config.parallelType) } if (config.count !== undefined) { useWorkflowStore.getState().updateParallelCount(payload.id, config.count) } if (config.batchSize !== undefined) { useWorkflowStore.getState().updateParallelBatchSize(payload.id, config.batchSize) } if (config.distribution !== undefined) { useWorkflowStore .getState() .updateParallelCollection(payload.id, config.distribution) } } break } } else if (target === OPERATION_TARGETS.VARIABLE) { switch (operation) { case VARIABLE_OPERATIONS.ADD: useVariablesStore.getState().addVariable( { workflowId: payload.workflowId, name: payload.name, type: payload.type, value: payload.value, }, payload.id ) break case VARIABLE_OPERATIONS.UPDATE: if (payload.field === 'name') { useVariablesStore .getState() .updateVariable(payload.variableId, { name: payload.value }) } else if (payload.field === 'value') { useVariablesStore .getState() .updateVariable(payload.variableId, { value: payload.value }) } else if (payload.field === 'type') { useVariablesStore .getState() .updateVariable(payload.variableId, { type: payload.value }) } break case VARIABLE_OPERATIONS.REMOVE: useOperationQueueStore.getState().cancelOperationsForVariable(payload.variableId) useVariablesStore.getState().deleteVariable(payload.variableId) break } } else if (target === OPERATION_TARGETS.WORKFLOW) { switch (operation) { case WORKFLOW_OPERATIONS.REPLACE_STATE: if (payload.state) { logger.info('Received workflow state replacement from remote user', { userId, blockCount: Object.keys(payload.state.blocks || {}).length, edgeCount: (payload.state.edges || []).length, hasActiveDiff, isShowingDiff, }) useWorkflowStore.getState().replaceWorkflowState(payload.state) // Extract and apply subblock values const subBlockValues: Record> = {} Object.entries(payload.state.blocks || {}).forEach( ([blockId, block]: [string, any]) => { subBlockValues[blockId] = {} Object.entries(block.subBlocks || {}).forEach( ([subBlockId, subBlock]: [string, any]) => { subBlockValues[blockId][subBlockId] = subBlock.value } ) } ) if (activeWorkflowId) { useSubBlockStore.getState().setWorkflowValues(activeWorkflowId, subBlockValues) } logger.info('Successfully applied remote workflow state replacement') } break } } if (target === OPERATION_TARGETS.BLOCKS) { switch (operation) { case BLOCKS_OPERATIONS.BATCH_ADD_BLOCKS: { const { blocks, edges, subBlockValues: addedSubBlockValues } = payload logger.info('Received batch-add-blocks from remote user', { userId, blockCount: (blocks || []).length, edgeCount: (edges || []).length, }) if (blocks && blocks.length > 0) { useWorkflowStore .getState() .batchAddBlocks(blocks, edges || [], addedSubBlockValues || {}) } logger.info('Successfully applied batch-add-blocks from remote user') break } case BLOCKS_OPERATIONS.BATCH_REMOVE_BLOCKS: { const { ids } = payload logger.info('Received batch-remove-blocks from remote user', { userId, count: (ids || []).length, }) if (ids && ids.length > 0) { const operationQueue = useOperationQueueStore.getState() ids.forEach((id: string) => operationQueue.cancelOperationsForBlock(id)) useWorkflowStore.getState().batchRemoveBlocks(ids) } logger.info('Successfully applied batch-remove-blocks from remote user') break } case BLOCKS_OPERATIONS.BATCH_TOGGLE_ENABLED: { const { blockIds } = payload logger.info('Received batch-toggle-enabled from remote user', { userId, count: (blockIds || []).length, }) if (blockIds && blockIds.length > 0) { useWorkflowStore.getState().batchToggleEnabled(blockIds) } logger.info('Successfully applied batch-toggle-enabled from remote user') break } case BLOCKS_OPERATIONS.BATCH_TOGGLE_HANDLES: { const { blockIds } = payload logger.info('Received batch-toggle-handles from remote user', { userId, count: (blockIds || []).length, }) if (blockIds && blockIds.length > 0) { useWorkflowStore.getState().batchToggleHandles(blockIds) } logger.info('Successfully applied batch-toggle-handles from remote user') break } case BLOCKS_OPERATIONS.BATCH_TOGGLE_LOCKED: { const { blockIds } = payload logger.info('Received batch-toggle-locked from remote user', { userId, count: (blockIds || []).length, }) if (blockIds && blockIds.length > 0) { useWorkflowStore.getState().batchToggleLocked(blockIds) } logger.info('Successfully applied batch-toggle-locked from remote user') break } case BLOCKS_OPERATIONS.BATCH_UPDATE_PARENT: { const { updates } = payload logger.info('Received batch-update-parent from remote user', { userId, count: (updates || []).length, }) if (updates && updates.length > 0) { useWorkflowStore.getState().batchUpdateBlocksWithParent( updates.map( (u: { id: string; parentId: string; position: { x: number; y: number } }) => ({ id: u.id, position: u.position, parentId: u.parentId || undefined, }) ) ) } logger.info('Successfully applied batch-update-parent from remote user') break } } } } catch (error) { logger.error('Error applying remote operation:', error) } finally { isApplyingRemoteChange.current = false } } const handleSubblockUpdate = (data: any) => { const { workflowId, blockId, subblockId, value, userId } = data if (isApplyingRemoteChange.current) return // Filter broadcasts by workflowId to prevent cross-workflow updates if (workflowId && workflowId !== activeWorkflowId) { logger.debug('Ignoring subblock update for different workflow', { broadcastWorkflowId: workflowId, activeWorkflowId, }) return } logger.info(`Received subblock update from user ${userId}: ${blockId}.${subblockId}`) isApplyingRemoteChange.current = true try { useSubBlockStore.getState().setValue(blockId, subblockId, value) useWorkflowStore.getState().syncDynamicHandleSubblockValue(blockId, subblockId, value) const blockType = useWorkflowStore.getState().blocks?.[blockId]?.type if (activeWorkflowId && blockType === 'function' && subblockId === 'code') { useCodeUndoRedoStore.getState().clear(activeWorkflowId, blockId, subblockId) } } catch (error) { logger.error('Error applying remote subblock update:', error) } finally { isApplyingRemoteChange.current = false } } const handleVariableUpdate = (data: any) => { const { workflowId, variableId, field, value, userId } = data if (isApplyingRemoteChange.current) return // Filter broadcasts by workflowId to prevent cross-workflow updates if (workflowId && workflowId !== activeWorkflowId) { logger.debug('Ignoring variable update for different workflow', { broadcastWorkflowId: workflowId, activeWorkflowId, }) return } logger.info(`Received variable update from user ${userId}: ${variableId}.${field}`) isApplyingRemoteChange.current = true try { if (field === 'name') { useVariablesStore.getState().updateVariable(variableId, { name: value }) } else if (field === 'value') { useVariablesStore.getState().updateVariable(variableId, { value }) } else if (field === 'type') { useVariablesStore.getState().updateVariable(variableId, { type: value }) } } catch (error) { logger.error('Error applying remote variable update:', error) } finally { isApplyingRemoteChange.current = false } } const handleWorkflowDeleted = (data: any) => { const { workflowId } = data logger.warn(`Workflow ${workflowId} has been deleted`) if (activeWorkflowId === workflowId) { logger.info( `Currently active workflow ${workflowId} was deleted, stopping collaborative operations` ) const currentUserId = session?.user?.id || 'unknown' useUndoRedoStore.getState().clear(workflowId, currentUserId) isApplyingRemoteChange.current = false } } const reloadWorkflowFromApi = async (workflowId: string, reason: string): Promise => { const reloadSequence = (reloadSequencesRef.current[workflowId] ?? 0) + 1 reloadSequencesRef.current[workflowId] = reloadSequence const isLatestReload = () => reloadSequencesRef.current[workflowId] === reloadSequence const pendingExternalUpdateAtStart = useWorkflowDiffStore.getState().pendingExternalUpdates[workflowId] ?? 0 useWorkflowDiffStore.getState().setWorkflowReconciliationInProgress(workflowId, true) const failLatestReconciliation = (message: string) => { if (!isLatestReload()) return const diffStore = useWorkflowDiffStore.getState() if ((diffStore.pendingExternalUpdates[workflowId] ?? 0) <= pendingExternalUpdateAtStart) { diffStore.clearExternalUpdatePending(workflowId) } diffStore.setWorkflowReconciliationInProgress(workflowId, false) diffStore.setWorkflowReconciliationError(workflowId, message) if ((useWorkflowDiffStore.getState().pendingExternalUpdates[workflowId] ?? 0) > 0) { window.dispatchEvent( new CustomEvent(WORKFLOW_DIFF_SETTLED_EVENT, { detail: { workflowId } }) ) } } // The contract's `state` is `workflowStateSchema` (loose at the wire // level — `subBlocks.value` is `unknown`, optional flags omitted), // but downstream consumers (replaceWorkflowState, the undo/redo // graph) operate on the store's narrower `WorkflowState`. The // server-of-record persists store-shaped values, so the runtime // shape is the store type; we narrow once here at the trust // boundary instead of sprinkling per-field casts. let workflowState: WorkflowState | null = null try { const responseData = await requestJson(getWorkflowStateContract, { params: { id: workflowId }, }) const wireState = responseData.data?.state if (wireState) { // double-cast-allowed: workflowStateSchema is structurally a supertype of the store's WorkflowState (subBlocks.value is `unknown`, optional booleans, etc.); the server persists store-shaped values so the runtime shape matches workflowState = wireState as unknown as WorkflowState if (Object.hasOwn(responseData.data, 'variables')) { workflowState.variables = responseData.data.variables || {} } } } catch (error) { logger.error(`Failed to fetch workflow data after ${reason}`, { error }) failLatestReconciliation( 'Failed to sync the latest workflow changes. Refresh and try again.' ) return false } if (!isLatestReload()) { logger.debug(`Ignoring stale workflow reload after ${reason}`, { workflowId }) return false } if (!workflowState) { logger.error(`No state found in workflow data after ${reason}`, { workflowId }) failLatestReconciliation('No workflow state was returned while syncing latest changes.') return false } if (useWorkflowRegistry.getState().activeWorkflowId !== workflowId) { logger.debug(`Ignoring workflow reload after active workflow changed`, { workflowId }) if (isLatestReload()) { useWorkflowDiffStore.getState().setWorkflowReconciliationInProgress(workflowId, false) } return false } const diffStateBeforeApply = useWorkflowDiffStore.getState() const pendingExternalUpdateBeforeApply = diffStateBeforeApply.pendingExternalUpdates[workflowId] ?? 0 if ( diffStateBeforeApply.hasActiveDiff || pendingExternalUpdateBeforeApply > pendingExternalUpdateAtStart || useOperationQueueStore.getState().hasPendingOperations(workflowId) ) { logger.info(`Deferring workflow reload apply after ${reason}`, { workflowId }) useWorkflowDiffStore.getState().markExternalUpdatePending(workflowId) if (isLatestReload()) { useWorkflowDiffStore.getState().setWorkflowReconciliationInProgress(workflowId, false) if (useWorkflowRegistry.getState().activeWorkflowId === workflowId) { void replayPendingExternalUpdate( workflowId, 'deferred external update after reload apply was skipped' ) } } return false } isApplyingRemoteChange.current = true try { const stateToApply: WorkflowState = { blocks: workflowState.blocks || {}, edges: workflowState.edges || [], loops: workflowState.loops || {}, parallels: workflowState.parallels || {}, lastSaved: workflowState.lastSaved || Date.now(), } if (Object.hasOwn(workflowState, 'variables')) { stateToApply.variables = workflowState.variables || {} } applyWorkflowStateToStores(workflowId, stateToApply) const graph = { blocksById: workflowState.blocks || {}, edgesById: Object.fromEntries((workflowState.edges || []).map((e) => [e.id, e])), } const undoRedoStore = useUndoRedoStore.getState() const stackKeys = Object.keys(undoRedoStore.stacks) stackKeys.forEach((key) => { const [wfId, userId] = key.split(':') if (wfId === workflowId) { undoRedoStore.pruneInvalidEntries(wfId, userId, graph) } }) logger.info(`Successfully reloaded workflow state after ${reason}`, { workflowId }) const diffStore = useWorkflowDiffStore.getState() const pendingExternalUpdate = diffStore.pendingExternalUpdates[workflowId] ?? 0 if (pendingExternalUpdate <= pendingExternalUpdateAtStart) { diffStore.clearExternalUpdatePending(workflowId) } diffStore.setWorkflowReconciliationError(workflowId, null) return true } finally { isApplyingRemoteChange.current = false if (isLatestReload()) { useWorkflowDiffStore.getState().setWorkflowReconciliationInProgress(workflowId, false) if (useWorkflowRegistry.getState().activeWorkflowId === workflowId) { void replayPendingExternalUpdate( workflowId, 'deferred external update after reconciliation' ) } } } } const replayPendingExternalUpdate = async (workflowId: string, reason: string) => { const diffStore = useWorkflowDiffStore.getState() if ( useWorkflowRegistry.getState().activeWorkflowId !== workflowId || diffStore.hasActiveDiff || diffStore.reconcilingWorkflows[workflowId] || !diffStore.pendingExternalUpdates[workflowId] ) { return } const queueStore = useOperationQueueStore.getState() if (queueStore.hasPendingOperations(workflowId)) { return } try { await reloadWorkflowFromApi(workflowId, reason) } catch (error) { logger.error(`Error reloading workflow state after ${reason}:`, error) } } const handleWorkflowReverted = async (data: any) => { const { workflowId } = data logger.info(`Workflow ${workflowId} has been reverted to deployed state`) if (activeWorkflowId !== workflowId) return useWorkflowDiffStore.getState().markRemoteUpdateSeen(workflowId) try { await reloadWorkflowFromApi(workflowId, 'revert') } catch (error) { logger.error('Error reloading workflow state after revert:', error) } } const handleWorkflowUpdated = async (data: any) => { const { workflowId } = data logger.info(`Workflow ${workflowId} has been updated externally`) if (activeWorkflowId !== workflowId) return const diffStore = useWorkflowDiffStore.getState() const { hasActiveDiff } = diffStore if (hasActiveDiff) { logger.info('Deferring workflow-updated: active diff in progress', { workflowId }) diffStore.markExternalUpdatePending(workflowId) return } if (diffStore.reconcilingWorkflows[workflowId]) { logger.info('Deferring workflow-updated: workflow reconciliation is in progress', { workflowId, }) diffStore.markExternalUpdatePending(workflowId) return } const operationQueue = useOperationQueueStore.getState() if (operationQueue.hasPendingOperations(workflowId)) { logger.info('Deferring workflow-updated: local operations are still pending', { workflowId, }) diffStore.markExternalUpdatePending(workflowId) void operationQueue.waitForWorkflowOperations(workflowId).then((ready) => { if (!ready) { const latestQueue = useOperationQueueStore.getState() if (latestQueue.hasPendingOperations(workflowId) && !latestQueue.hasOperationError) { return } const diffStore = useWorkflowDiffStore.getState() diffStore.clearExternalUpdatePending(workflowId) diffStore.setWorkflowReconciliationError( workflowId, 'Failed to save local workflow changes before syncing external updates.' ) return } void replayPendingExternalUpdate(workflowId, 'deferred external update after local save') }) return } diffStore.markRemoteUpdateSeen(workflowId) try { await reloadWorkflowFromApi(workflowId, 'external update') } catch (error) { logger.error('Error reloading workflow state after external update:', error) } } const handleDiffSettled = async (event: Event) => { const customEvent = event as CustomEvent<{ workflowId?: string }> const workflowId = customEvent.detail?.workflowId if (!workflowId || activeWorkflowId !== workflowId) return const diffStore = useWorkflowDiffStore.getState() if (!diffStore.pendingExternalUpdates[workflowId]) return await replayPendingExternalUpdate(workflowId, 'deferred external update') } const handleWorkflowDeployed = (data: any) => { const { workflowId } = data logger.info(`Workflow ${workflowId} deployment state changed`) if (workflowId !== activeWorkflowId) return invalidateDeploymentQueries(queryClient, workflowId) } const handleOperationConfirmed = (data: any) => { const { operationId } = data logger.debug('Operation confirmed', { operationId }) confirmOperation(operationId) if (activeWorkflowId) { void replayPendingExternalUpdate( activeWorkflowId, 'deferred external update after operation confirm' ) } } const handleOperationFailed = (data: any) => { const { operationId, error, retryable } = data logger.warn('Operation failed', { operationId, error, retryable }) failOperation(operationId, retryable) } onWorkflowOperation(handleWorkflowOperation) onSubblockUpdate(handleSubblockUpdate) onVariableUpdate(handleVariableUpdate) onWorkflowDeleted(handleWorkflowDeleted) onWorkflowReverted(handleWorkflowReverted) onWorkflowUpdated(handleWorkflowUpdated) onWorkflowDeployed(handleWorkflowDeployed) onOperationConfirmed(handleOperationConfirmed) onOperationFailed(handleOperationFailed) window.addEventListener(WORKFLOW_DIFF_SETTLED_EVENT, handleDiffSettled) if (activeWorkflowId) { void replayPendingExternalUpdate( activeWorkflowId, 'pending external update after workflow activation' ) } return () => { window.removeEventListener(WORKFLOW_DIFF_SETTLED_EVENT, handleDiffSettled) } }, [ onWorkflowOperation, onSubblockUpdate, onVariableUpdate, onWorkflowDeleted, onWorkflowReverted, onWorkflowUpdated, onWorkflowDeployed, onOperationConfirmed, onOperationFailed, activeWorkflowId, queryClient, confirmOperation, failOperation, emitWorkflowOperation, ]) const executeQueuedOperation = useCallback( (operation: string, target: string, payload: any, localAction: () => void) => { if (isApplyingRemoteChange.current) { return } // Skip socket operations when viewing baseline diff (readonly) if (isBaselineDiffView) { logger.debug('Skipping socket operation while viewing baseline diff:', operation) return } // Queue operations if we have an active workflow - queue handles socket readiness if (!activeWorkflowId) { logger.debug('Skipping operation - no active workflow', { operation, target }) return } const operationId = generateId() addToQueue({ id: operationId, operation: { operation, target, payload, }, workflowId: activeWorkflowId, userId: session?.user?.id || 'unknown', }) localAction() }, [addToQueue, session?.user?.id, isBaselineDiffView, activeWorkflowId] ) const collaborativeBatchUpdatePositions = useCallback( ( updates: Array<{ id: string; position: Position }>, options?: { previousPositions?: Map } ) => { if (isBaselineDiffView) { return } if (!activeWorkflowId) { logger.debug('Skipping batch position update - no active workflow') return } if (updates.length === 0) return const operationId = generateId() addToQueue({ id: operationId, operation: { operation: BLOCKS_OPERATIONS.BATCH_UPDATE_POSITIONS, target: OPERATION_TARGETS.BLOCKS, payload: { updates }, }, workflowId: activeWorkflowId || '', userId: session?.user?.id || 'unknown', }) useWorkflowStore.getState().batchUpdatePositions(updates) if (options?.previousPositions && options.previousPositions.size > 0) { const moves = updates .filter((u) => options.previousPositions!.has(u.id)) .map((u) => { const prev = options.previousPositions!.get(u.id)! const block = useWorkflowStore.getState().blocks[u.id] return { blockId: u.id, before: prev, after: { x: u.position.x, y: u.position.y, parentId: block?.data?.parentId, }, } }) .filter((m) => m.before.x !== m.after.x || m.before.y !== m.after.y) if (moves.length > 0) { undoRedo.recordBatchMoveBlocks(moves) } } }, [isBaselineDiffView, addToQueue, activeWorkflowId, session?.user?.id, undoRedo] ) const collaborativeUpdateBlockName = useCallback( (id: string, name: string): { success: boolean; error?: string } => { const blocks = useWorkflowStore.getState().blocks const block = blocks[id] if (block) { if (isBlockProtected(id, blocks)) { logger.error('Cannot rename locked block') toast({ message: 'Cannot rename locked blocks' }) return { success: false, error: 'Block is locked' } } } const trimmedName = name.trim() const currentBlocks = useWorkflowStore.getState().blocks const siblingNamesById = Object.fromEntries( Object.entries(currentBlocks).map(([blockId, b]) => [blockId, b.name]) ) const conflict = getWorkflowBlockNameConflict(id, trimmedName, siblingNamesById) if (conflict?.reason === 'empty') { logger.error('Cannot rename block to empty name') toast.error('Block name cannot be empty') return { success: false, error: 'Block name cannot be empty' } } if (conflict?.reason === 'reserved') { logger.error(`Cannot rename block to reserved name: "${trimmedName}"`) toast.error(`"${trimmedName}" is a reserved name and cannot be used`) return { success: false, error: `"${trimmedName}" is a reserved name` } } if (conflict?.reason === 'duplicate') { const conflictName = currentBlocks[conflict.conflictingBlockId as string].name logger.error(`Cannot rename block to "${trimmedName}" - conflicts with "${conflictName}"`) toast.error(`Block name "${trimmedName}" already exists`) return { success: false, error: `Block name "${trimmedName}" already exists` } } executeQueuedOperation( BLOCK_OPERATIONS.UPDATE_NAME, OPERATION_TARGETS.BLOCK, { id, name: trimmedName }, () => { const result = useWorkflowStore.getState().updateBlockName(id, trimmedName) if (result.success && result.changedSubblocks.length > 0) { logger.info('Emitting cascaded subblock updates from block rename', { blockId: id, newName: trimmedName, updateCount: result.changedSubblocks.length, }) result.changedSubblocks.forEach( ({ blockId, subBlockId, newValue, }: { blockId: string subBlockId: string newValue: any }) => { const operationId = generateId() addToQueue({ id: operationId, operation: { operation: SUBBLOCK_OPERATIONS.UPDATE, target: OPERATION_TARGETS.SUBBLOCK, payload: { blockId, subblockId: subBlockId, value: newValue }, }, workflowId: activeWorkflowId || '', userId: session?.user?.id || 'unknown', }) } ) } } ) return { success: true } }, [executeQueuedOperation, addToQueue, activeWorkflowId, session?.user?.id] ) const collaborativeBatchToggleBlockEnabled = useCallback( (ids: string[]) => { if (isBaselineDiffView) { return } if (ids.length === 0) return const currentBlocks = useWorkflowStore.getState().blocks const previousStates: Record = {} const validIds: string[] = [] // For each ID, collect non-locked blocks and their descendants for undo/redo for (const id of ids) { const block = currentBlocks[id] if (!block) continue // Skip protected blocks (locked or inside a locked ancestor) if (isBlockProtected(id, currentBlocks)) continue validIds.push(id) previousStates[id] = block.enabled // If it's a loop or parallel, also capture descendants' previous states for undo/redo if (block.type === 'loop' || block.type === 'parallel') { findAllDescendantNodes(id, currentBlocks).forEach((descId) => { if (!isBlockProtected(descId, currentBlocks)) { previousStates[descId] = currentBlocks[descId]?.enabled ?? true } }) } } if (validIds.length === 0) return const operationId = generateId() addToQueue({ id: operationId, operation: { operation: BLOCKS_OPERATIONS.BATCH_TOGGLE_ENABLED, target: OPERATION_TARGETS.BLOCKS, payload: { blockIds: validIds, previousStates }, }, workflowId: activeWorkflowId || '', userId: session?.user?.id || 'unknown', }) useWorkflowStore.getState().batchToggleEnabled(validIds) undoRedo.recordBatchToggleEnabled(validIds, previousStates) }, [isBaselineDiffView, addToQueue, activeWorkflowId, session?.user?.id, undoRedo] ) const collaborativeBatchUpdateParent = useCallback( ( updates: Array<{ blockId: string newParentId: string | null newPosition: { x: number; y: number } affectedEdges: Edge[] }> ) => { if (isBaselineDiffView) { return } if (!activeWorkflowId) { logger.debug('Skipping batch update parent - no active workflow') return } if (updates.length === 0) return const batchUpdates = updates.map((u) => { const block = useWorkflowStore.getState().blocks[u.blockId] const oldParentId = block?.data?.parentId const oldPosition = block?.position || { x: 0, y: 0 } return { blockId: u.blockId, oldParentId, newParentId: u.newParentId || undefined, oldPosition, newPosition: u.newPosition, affectedEdges: u.affectedEdges, } }) // Collect all edge IDs to remove const edgeIdsToRemove = updates.flatMap((u) => u.affectedEdges.map((e) => e.id)) if (edgeIdsToRemove.length > 0) { const edgeOperationId = generateId() addToQueue({ id: edgeOperationId, operation: { operation: EDGES_OPERATIONS.BATCH_REMOVE_EDGES, target: OPERATION_TARGETS.EDGES, payload: { ids: edgeIdsToRemove }, }, workflowId: activeWorkflowId || '', userId: session?.user?.id || 'unknown', }) useWorkflowStore.getState().batchRemoveEdges(edgeIdsToRemove) } // Batch update positions and parents useWorkflowStore.getState().batchUpdateBlocksWithParent( updates.map((u) => ({ id: u.blockId, position: u.newPosition, parentId: u.newParentId || undefined, })) ) undoRedo.recordBatchUpdateParent(batchUpdates) const operationId = generateId() addToQueue({ id: operationId, operation: { operation: BLOCKS_OPERATIONS.BATCH_UPDATE_PARENT, target: OPERATION_TARGETS.BLOCKS, payload: { updates: batchUpdates.map((u) => ({ id: u.blockId, parentId: u.newParentId || '', position: u.newPosition, })), }, }, workflowId: activeWorkflowId || '', userId: session?.user?.id || 'unknown', }) logger.debug('Batch updated parent for blocks', { updateCount: updates.length }) }, [isBaselineDiffView, undoRedo, addToQueue, activeWorkflowId, session?.user?.id] ) const collaborativeToggleBlockAdvancedMode = useCallback( (id: string) => { const block = useWorkflowStore.getState().blocks[id] if (!block) return const newAdvancedMode = !block.advancedMode executeQueuedOperation( BLOCK_OPERATIONS.UPDATE_ADVANCED_MODE, OPERATION_TARGETS.BLOCK, { id, advancedMode: newAdvancedMode }, () => useWorkflowStore.getState().setBlockAdvancedMode(id, newAdvancedMode) ) }, [executeQueuedOperation] ) const collaborativeSetBlockCanonicalMode = useCallback( (id: string, canonicalId: string, canonicalMode: 'basic' | 'advanced') => { if (isBaselineDiffView) { return } useWorkflowStore.getState().setBlockCanonicalMode(id, canonicalId, canonicalMode) if (!activeWorkflowId) { return } const operationId = generateId() addToQueue({ id: operationId, operation: { operation: BLOCK_OPERATIONS.UPDATE_CANONICAL_MODE, target: OPERATION_TARGETS.BLOCK, payload: { id, canonicalId, canonicalMode }, }, workflowId: activeWorkflowId, userId: session?.user?.id || 'unknown', }) }, [isBaselineDiffView, activeWorkflowId, addToQueue, session?.user?.id] ) /** * Wholesale-replaces `block.data.canonicalModes`, rather than merging one key like * {@link collaborativeSetBlockCanonicalMode}. Needed to reindex nested tool-input overrides on * reorder/removal: a merge can't atomically drop a now-stale index key, and sequential * per-key sets can clobber each other when two tools swap positions. */ const collaborativeSetBlockCanonicalModes = useCallback( (id: string, canonicalModes: Record) => { if (isBaselineDiffView) { return } useWorkflowStore.getState().setBlockCanonicalModes(id, canonicalModes) if (!activeWorkflowId) { return } const operationId = generateId() addToQueue({ id: operationId, operation: { operation: BLOCK_OPERATIONS.REPLACE_CANONICAL_MODES, target: OPERATION_TARGETS.BLOCK, payload: { id, data: { canonicalModes } }, }, workflowId: activeWorkflowId, userId: session?.user?.id || 'unknown', }) }, [isBaselineDiffView, activeWorkflowId, addToQueue, session?.user?.id] ) const collaborativeBatchToggleBlockHandles = useCallback( (ids: string[]) => { if (isBaselineDiffView) { return } if (ids.length === 0) return const blocks = useWorkflowStore.getState().blocks const previousStates: Record = {} const validIds: string[] = [] for (const id of ids) { const block = blocks[id] if (block && !isBlockProtected(id, blocks)) { previousStates[id] = block.horizontalHandles ?? false validIds.push(id) } } if (validIds.length === 0) return const operationId = generateId() addToQueue({ id: operationId, operation: { operation: BLOCKS_OPERATIONS.BATCH_TOGGLE_HANDLES, target: OPERATION_TARGETS.BLOCKS, payload: { blockIds: validIds, previousStates }, }, workflowId: activeWorkflowId || '', userId: session?.user?.id || 'unknown', }) useWorkflowStore.getState().batchToggleHandles(validIds) undoRedo.recordBatchToggleHandles(validIds, previousStates) }, [isBaselineDiffView, addToQueue, activeWorkflowId, session?.user?.id, undoRedo] ) const collaborativeBatchToggleLocked = useCallback( (ids: string[]) => { if (isBaselineDiffView) { return } if (ids.length === 0) return const currentBlocks = useWorkflowStore.getState().blocks const previousStates: Record = {} const validIds: string[] = [] for (const id of ids) { const block = currentBlocks[id] if (!block) continue validIds.push(id) previousStates[id] = block.locked ?? false if (block.type === 'loop' || block.type === 'parallel') { findAllDescendantNodes(id, currentBlocks).forEach((descId) => { previousStates[descId] = currentBlocks[descId]?.locked ?? false }) } } if (validIds.length === 0) return const operationId = generateId() addToQueue({ id: operationId, operation: { operation: BLOCKS_OPERATIONS.BATCH_TOGGLE_LOCKED, target: OPERATION_TARGETS.BLOCKS, payload: { blockIds: validIds, previousStates }, }, workflowId: activeWorkflowId || '', userId: session?.user?.id || 'unknown', }) useWorkflowStore.getState().batchToggleLocked(validIds) undoRedo.recordBatchToggleLocked(validIds, previousStates) }, [isBaselineDiffView, addToQueue, activeWorkflowId, session?.user?.id, undoRedo] ) const collaborativeBatchAddEdges = useCallback( (edges: Edge[], options?: { skipUndoRedo?: boolean }) => { if (isBaselineDiffView) { return false } if (!activeWorkflowId) { logger.debug('Skipping batch add edges - no active workflow') return false } if (edges.length === 0) return false const blocks = useWorkflowStore.getState().blocks const currentEdges = useWorkflowStore.getState().edges const validEdges = filterValidEdges(edges, blocks) const newEdges = filterNewEdges(validEdges, currentEdges) // Reject cyclic edges here, before they are queued for realtime/DB // persistence — the local store also runs this check, but only after // an unfiltered payload would already be enqueued. Filtering once // here keeps the queued payload and the local store in agreement. const acyclicEdges = filterAcyclicEdges(newEdges, currentEdges) if (acyclicEdges.length === 0) return false const operationId = generateId() addToQueue({ id: operationId, operation: { operation: EDGES_OPERATIONS.BATCH_ADD_EDGES, target: OPERATION_TARGETS.EDGES, payload: { edges: acyclicEdges }, }, workflowId: activeWorkflowId || '', userId: session?.user?.id || 'unknown', }) useWorkflowStore.getState().batchAddEdges(acyclicEdges, { skipValidation: true }) if (!options?.skipUndoRedo) { acyclicEdges.forEach((edge) => undoRedo.recordAddEdge(edge.id)) } return true }, [isBaselineDiffView, addToQueue, activeWorkflowId, session?.user?.id, undoRedo] ) const collaborativeBatchRemoveEdges = useCallback( (edgeIds: string[], options?: { skipUndoRedo?: boolean }) => { if (isBaselineDiffView) { return false } if (!activeWorkflowId) { logger.debug('Skipping batch remove edges - no active workflow') return false } if (edgeIds.length === 0) return false const edgeSnapshots: Edge[] = [] const validEdgeIds: string[] = [] for (const edgeId of edgeIds) { const edge = useWorkflowStore.getState().edges.find((e) => e.id === edgeId) if (edge) { const sourceExists = useWorkflowStore.getState().blocks[edge.source] const targetExists = useWorkflowStore.getState().blocks[edge.target] if (sourceExists && targetExists) { edgeSnapshots.push(edge) validEdgeIds.push(edgeId) } } } if (validEdgeIds.length === 0) { logger.debug('No valid edges to remove') return false } const operationId = generateId() addToQueue({ id: operationId, operation: { operation: EDGES_OPERATIONS.BATCH_REMOVE_EDGES, target: OPERATION_TARGETS.EDGES, payload: { ids: validEdgeIds }, }, workflowId: activeWorkflowId || '', userId: session?.user?.id || 'unknown', }) useWorkflowStore.getState().batchRemoveEdges(validEdgeIds) if (!options?.skipUndoRedo && edgeSnapshots.length > 0) { undoRedo.recordBatchRemoveEdges(edgeSnapshots) } logger.info('Batch removed edges', { count: validEdgeIds.length }) return true }, [isBaselineDiffView, addToQueue, activeWorkflowId, session, undoRedo] ) const collaborativeSetSubblockValue = useCallback( (blockId: string, subblockId: string, value: any, options?: { _visited?: Set }) => { if (isApplyingRemoteChange.current) return if (isBaselineDiffView) { logger.debug('Skipping collaborative subblock update while viewing baseline diff') return } // ALWAYS update local store first for immediate UI feedback useSubBlockStore.getState().setValue(blockId, subblockId, value) useWorkflowStore.getState().syncDynamicHandleSubblockValue(blockId, subblockId, value) if (activeWorkflowId && !isSyntheticToolSubBlockId(subblockId)) { const operationId = generateId() addToQueue({ id: operationId, operation: { operation: SUBBLOCK_OPERATIONS.UPDATE, target: OPERATION_TARGETS.SUBBLOCK, payload: { blockId, subblockId, value }, }, workflowId: activeWorkflowId, userId: session?.user?.id || 'unknown', }) } // Handle dependent subblock clearing (recursive calls) try { const visited = options?._visited || new Set() if (visited.has(subblockId)) return visited.add(subblockId) const blockType = useWorkflowStore.getState().blocks?.[blockId]?.type const blockConfig = blockType ? getBlock(blockType) : null if (blockConfig?.subBlocks && Array.isArray(blockConfig.subBlocks)) { const dependents = getSubBlocksDependingOnChange(blockConfig.subBlocks, subblockId) for (const dep of dependents) { if (!dep?.id || dep.id === subblockId) continue const currentDepValue = useSubBlockStore.getState().getValue(blockId, dep.id) if ( currentDepValue === '' || currentDepValue === null || currentDepValue === undefined ) { continue } collaborativeSetSubblockValue(blockId, dep.id, '', { _visited: visited }) } } } catch { // Best-effort; do not block on clearing } }, [activeWorkflowId, addToQueue, session?.user?.id, isBaselineDiffView] ) const collaborativeBatchSetSubblockValues = useCallback( ( updates: Array<{ blockId: string subblockId: string value: unknown expectedValue?: unknown }>, options: { subflowUpdates?: Array<{ blockId: string blockType: 'loop' | 'parallel' fieldId: WorkflowSearchSubflowFieldId before: unknown after: unknown }> } = {} ) => { const undoSubflowUpdates = options.subflowUpdates ?? [] if ( isApplyingRemoteChange.current || (updates.length === 0 && undoSubflowUpdates.length === 0) ) { return false } if (isBaselineDiffView) { logger.debug('Skipping collaborative batch subblock update while viewing baseline diff') return false } if (!activeWorkflowId) { logger.debug('Skipping batch subblock update - no active workflow') return false } const staleUpdate = updates.find((update) => { if (!Object.hasOwn(update, 'expectedValue')) return false const currentValue = useSubBlockStore.getState().getValue(update.blockId, update.subblockId) return !isEqual(currentValue, update.expectedValue) }) if (staleUpdate) { logger.warn('Skipping batch subblock update because expected value changed', { blockId: staleUpdate.blockId, subblockId: staleUpdate.subblockId, }) return false } const staleSubflowUpdate = undoSubflowUpdates.find((update) => { const currentBlock = useWorkflowStore.getState().blocks[update.blockId] if (!currentBlock || currentBlock.type !== update.blockType) return true return !workflowSearchSubflowFieldMatchesExpected( currentBlock, update.fieldId, update.before ) }) if (staleSubflowUpdate) { logger.warn('Skipping batch subflow update because expected value changed', { blockId: staleSubflowUpdate.blockId, fieldId: staleSubflowUpdate.fieldId, }) return false } const persistedUpdates = updates.filter( (update) => !isSyntheticToolSubBlockId(update.subblockId) ) if (updates.length > 0) { updates.forEach((update) => { useSubBlockStore.getState().setValue(update.blockId, update.subblockId, update.value) useWorkflowStore .getState() .syncDynamicHandleSubblockValue(update.blockId, update.subblockId, update.value) }) if (persistedUpdates.length > 0) { const operationId = generateId() addToQueue({ id: operationId, operation: { operation: SUBBLOCK_OPERATIONS.BATCH_UPDATE, target: OPERATION_TARGETS.SUBBLOCK, payload: { updates: persistedUpdates }, }, workflowId: activeWorkflowId, userId: session?.user?.id || 'unknown', }) } } undoRedo.recordBatchUpdateSubblocks( persistedUpdates.map((update) => ({ blockId: update.blockId, subBlockId: update.subblockId, before: update.expectedValue, after: update.value, })), undoSubflowUpdates ) return true }, [activeWorkflowId, addToQueue, isBaselineDiffView, session?.user?.id, undoRedo] ) // Immediate tag selection (uses queue but processes immediately, no debouncing) const collaborativeSetTagSelection = useCallback( (blockId: string, subblockId: string, value: string) => { if (isApplyingRemoteChange.current) return if (isBaselineDiffView) { return } if (!activeWorkflowId) { logger.debug('Skipping tag selection - no active workflow', { blockId, subblockId }) return } // Apply locally first (immediate UI feedback) useSubBlockStore.getState().setValue(blockId, subblockId, value) if (isSyntheticToolSubBlockId(subblockId)) return // Use the operation queue but with immediate processing (no debouncing) const operationId = generateId() addToQueue({ id: operationId, operation: { operation: SUBBLOCK_OPERATIONS.UPDATE, target: OPERATION_TARGETS.SUBBLOCK, payload: { blockId, subblockId, value }, }, workflowId: activeWorkflowId || '', userId: session?.user?.id || 'unknown', }) }, [isBaselineDiffView, addToQueue, activeWorkflowId, session?.user?.id] ) const collaborativeUpdateLoopType = useCallback( (loopId: string, loopType: 'for' | 'forEach' | 'while' | 'doWhile') => { const currentBlock = useWorkflowStore.getState().blocks[loopId] if (!currentBlock || currentBlock.type !== 'loop') return const childNodes = Object.values(useWorkflowStore.getState().blocks) .filter((b) => b.data?.parentId === loopId) .map((b) => b.id) const currentIterations = currentBlock.data?.count || 5 const currentCollection = currentBlock.data?.collection || '' const existingLoop = useWorkflowStore.getState().loops[loopId] const existingForEachItems = existingLoop?.forEachItems ?? currentCollection ?? '' const existingWhileCondition = existingLoop?.whileCondition ?? currentBlock.data?.whileCondition ?? '' const existingDoWhileCondition = existingLoop?.doWhileCondition ?? currentBlock.data?.doWhileCondition ?? '' const config: any = { id: loopId, nodes: childNodes, iterations: currentIterations, loopType, forEachItems: existingForEachItems ?? '', whileCondition: existingWhileCondition ?? '', doWhileCondition: existingDoWhileCondition ?? '', } executeQueuedOperation( SUBFLOW_OPERATIONS.UPDATE, OPERATION_TARGETS.SUBFLOW, { id: loopId, type: 'loop', config }, () => { useWorkflowStore.getState().updateLoopType(loopId, loopType) useWorkflowStore.getState().setLoopForEachItems(loopId, existingForEachItems ?? '') useWorkflowStore.getState().setLoopWhileCondition(loopId, existingWhileCondition ?? '') useWorkflowStore .getState() .setLoopDoWhileCondition(loopId, existingDoWhileCondition ?? '') } ) }, [executeQueuedOperation] ) const collaborativeUpdateParallelType = useCallback( (parallelId: string, parallelType: 'count' | 'collection') => { const currentBlock = useWorkflowStore.getState().blocks[parallelId] if (!currentBlock || currentBlock.type !== 'parallel') return const childNodes = Object.values(useWorkflowStore.getState().blocks) .filter((b) => b.data?.parentId === parallelId) .map((b) => b.id) let newCount = currentBlock.data?.count || 5 let newDistribution = currentBlock.data?.collection || '' const batchSize = currentBlock.data?.batchSize || 20 if (parallelType === 'count') { newDistribution = '' } else { newCount = 1 newDistribution = newDistribution || '' } const config = { id: parallelId, nodes: childNodes, count: newCount, distribution: newDistribution, parallelType, batchSize, } executeQueuedOperation( SUBFLOW_OPERATIONS.UPDATE, OPERATION_TARGETS.SUBFLOW, { id: parallelId, type: 'parallel', config }, () => { useWorkflowStore.getState().updateParallelType(parallelId, parallelType) useWorkflowStore.getState().updateParallelCount(parallelId, newCount) useWorkflowStore.getState().updateParallelCollection(parallelId, newDistribution) useWorkflowStore.getState().updateParallelBatchSize(parallelId, batchSize) } ) }, [executeQueuedOperation] ) // Unified iteration management functions - count and collection only const collaborativeUpdateIterationCount = useCallback( (nodeId: string, iterationType: 'loop' | 'parallel', count: number) => { const currentBlock = useWorkflowStore.getState().blocks[nodeId] if (!currentBlock || currentBlock.type !== iterationType) return const childNodes = Object.values(useWorkflowStore.getState().blocks) .filter((b) => b.data?.parentId === nodeId) .map((b) => b.id) const clampedCount = Math.max(1, count) if (iterationType === 'loop') { const currentLoopType = currentBlock.data?.loopType || 'for' const existingLoop = useWorkflowStore.getState().loops[nodeId] const nextForEachItems = existingLoop?.forEachItems ?? currentBlock.data?.collection ?? '' const nextWhileCondition = existingLoop?.whileCondition ?? currentBlock.data?.whileCondition ?? '' const nextDoWhileCondition = existingLoop?.doWhileCondition ?? currentBlock.data?.doWhileCondition ?? '' const config = { id: nodeId, nodes: childNodes, iterations: clampedCount, loopType: currentLoopType, forEachItems: nextForEachItems, whileCondition: nextWhileCondition, doWhileCondition: nextDoWhileCondition, } executeQueuedOperation( SUBFLOW_OPERATIONS.UPDATE, OPERATION_TARGETS.SUBFLOW, { id: nodeId, type: 'loop', config }, () => useWorkflowStore.getState().updateLoopCount(nodeId, clampedCount) ) } else { const currentDistribution = currentBlock.data?.collection || '' const currentParallelType = currentBlock.data?.parallelType || 'count' const batchSize = currentBlock.data?.batchSize || 20 const config = { id: nodeId, nodes: childNodes, count: clampedCount, distribution: currentDistribution, parallelType: currentParallelType, batchSize, } executeQueuedOperation( SUBFLOW_OPERATIONS.UPDATE, OPERATION_TARGETS.SUBFLOW, { id: nodeId, type: 'parallel', config }, () => useWorkflowStore.getState().updateParallelCount(nodeId, clampedCount) ) } }, [executeQueuedOperation] ) const collaborativeUpdateIterationCollection = useCallback( (nodeId: string, iterationType: 'loop' | 'parallel', collection: string) => { const currentBlock = useWorkflowStore.getState().blocks[nodeId] if (!currentBlock || currentBlock.type !== iterationType) return const childNodes = Object.values(useWorkflowStore.getState().blocks) .filter((b) => b.data?.parentId === nodeId) .map((b) => b.id) if (iterationType === 'loop') { const currentIterations = currentBlock.data?.count || 5 const currentLoopType = currentBlock.data?.loopType || 'for' const existingLoop = useWorkflowStore.getState().loops[nodeId] let nextForEachItems = existingLoop?.forEachItems ?? currentBlock.data?.collection ?? '' let nextWhileCondition = existingLoop?.whileCondition ?? currentBlock.data?.whileCondition ?? '' let nextDoWhileCondition = existingLoop?.doWhileCondition ?? currentBlock.data?.doWhileCondition ?? '' if (currentLoopType === 'forEach') { nextForEachItems = collection } else if (currentLoopType === 'while') { nextWhileCondition = collection } else if (currentLoopType === 'doWhile') { nextDoWhileCondition = collection } const config: any = { id: nodeId, nodes: childNodes, iterations: currentIterations, loopType: currentLoopType, forEachItems: nextForEachItems ?? '', whileCondition: nextWhileCondition ?? '', doWhileCondition: nextDoWhileCondition ?? '', } executeQueuedOperation( SUBFLOW_OPERATIONS.UPDATE, OPERATION_TARGETS.SUBFLOW, { id: nodeId, type: 'loop', config }, () => { useWorkflowStore.getState().setLoopForEachItems(nodeId, nextForEachItems ?? '') useWorkflowStore.getState().setLoopWhileCondition(nodeId, nextWhileCondition ?? '') useWorkflowStore.getState().setLoopDoWhileCondition(nodeId, nextDoWhileCondition ?? '') } ) } else { const currentCount = currentBlock.data?.count || 5 const currentParallelType = currentBlock.data?.parallelType || 'count' const batchSize = currentBlock.data?.batchSize || 20 const config = { id: nodeId, nodes: childNodes, count: currentCount, distribution: collection, parallelType: currentParallelType, batchSize, } executeQueuedOperation( SUBFLOW_OPERATIONS.UPDATE, OPERATION_TARGETS.SUBFLOW, { id: nodeId, type: 'parallel', config }, () => useWorkflowStore.getState().updateParallelCollection(nodeId, collection) ) } }, [executeQueuedOperation] ) const collaborativeUpdateParallelBatchSize = useCallback( (parallelId: string, batchSize: number) => { const currentBlock = useWorkflowStore.getState().blocks[parallelId] if (!currentBlock || currentBlock.type !== 'parallel') return const childNodes = Object.values(useWorkflowStore.getState().blocks) .filter((b) => b.data?.parentId === parallelId) .map((b) => b.id) const currentCount = currentBlock.data?.count || 5 const currentDistribution = currentBlock.data?.collection || '' const currentParallelType = currentBlock.data?.parallelType || 'count' const clampedBatchSize = Math.max(1, Math.min(20, batchSize)) const config = { id: parallelId, nodes: childNodes, count: currentCount, distribution: currentDistribution, parallelType: currentParallelType, batchSize: clampedBatchSize, } executeQueuedOperation( SUBFLOW_OPERATIONS.UPDATE, OPERATION_TARGETS.SUBFLOW, { id: parallelId, type: 'parallel', config }, () => useWorkflowStore.getState().updateParallelBatchSize(parallelId, clampedBatchSize) ) }, [executeQueuedOperation] ) const collaborativeUpdateVariable = useCallback( (variableId: string, field: 'name' | 'value' | 'type', value: any) => { executeQueuedOperation( VARIABLE_OPERATIONS.UPDATE, OPERATION_TARGETS.VARIABLE, { variableId, field, value }, () => { if (field === 'name') { useVariablesStore.getState().updateVariable(variableId, { name: value }) } else if (field === 'value') { useVariablesStore.getState().updateVariable(variableId, { value }) } else if (field === 'type') { useVariablesStore.getState().updateVariable(variableId, { type: value }) } } ) }, [executeQueuedOperation] ) const collaborativeAddVariable = useCallback( (variableData: { name: string; type: any; value: any; workflowId: string }) => { const id = generateId() // Optimistically add to local store first useVariablesStore.getState().addVariable(variableData, id) const processedVariable = useVariablesStore.getState().variables[id] if (processedVariable) { const payloadWithProcessedName = { ...variableData, id, name: processedVariable.name, } // Queue operation with processed name for server & other clients // Empty callback because local store is already updated above executeQueuedOperation( VARIABLE_OPERATIONS.ADD, OPERATION_TARGETS.VARIABLE, payloadWithProcessedName, () => {} ) } return id }, [executeQueuedOperation] ) const collaborativeDeleteVariable = useCallback( (variableId: string) => { cancelOperationsForVariable(variableId) executeQueuedOperation( VARIABLE_OPERATIONS.REMOVE, OPERATION_TARGETS.VARIABLE, { variableId }, () => { useVariablesStore.getState().deleteVariable(variableId) } ) }, [executeQueuedOperation, cancelOperationsForVariable] ) const collaborativeBatchAddBlocks = useCallback( ( blocks: BlockState[], edges: Edge[] = [], loops: Record = {}, parallels: Record = {}, subBlockValues: Record> = {}, options?: { skipUndoRedo?: boolean } ) => { if (!activeWorkflowId) { logger.debug('Skipping batch add blocks - no active workflow') return false } if (isBaselineDiffView) { logger.debug('Skipping batch add blocks while viewing baseline diff') return false } if (blocks.length === 0) return false // Filter out invalid edges (e.g., edges targeting trigger blocks) // Combine existing blocks with new blocks for validation const existingBlocks = useWorkflowStore.getState().blocks const newBlocksMap = blocks.reduce( (acc, block) => { acc[block.id] = block return acc }, {} as Record ) const allBlocks = { ...existingBlocks, ...newBlocksMap } const validEdges = filterValidEdges(edges, allBlocks) logger.info('Batch adding blocks collaboratively', { blockCount: blocks.length, edgeCount: validEdges.length, filteredEdges: edges.length - validEdges.length, }) const operationId = generateId() addToQueue({ id: operationId, operation: { operation: BLOCKS_OPERATIONS.BATCH_ADD_BLOCKS, target: OPERATION_TARGETS.BLOCKS, payload: { blocks, edges: validEdges, loops, parallels, subBlockValues }, }, workflowId: activeWorkflowId || '', userId: session?.user?.id || 'unknown', }) useWorkflowStore.getState().batchAddBlocks(blocks, validEdges, subBlockValues, { skipEdgeValidation: true, }) if (!options?.skipUndoRedo) { undoRedo.recordBatchAddBlocks(blocks, validEdges, subBlockValues) } return true }, [addToQueue, activeWorkflowId, session?.user?.id, isBaselineDiffView, undoRedo] ) const collaborativeBatchRemoveBlocks = useCallback( (blockIds: string[], options?: { skipUndoRedo?: boolean }) => { if (isBaselineDiffView) { return false } if (!activeWorkflowId) { logger.debug('Skipping batch remove blocks - no active workflow') return false } if (blockIds.length === 0) return false blockIds.forEach((id) => cancelOperationsForBlock(id)) const allBlocksToRemove = new Set(blockIds) const findAllDescendants = (parentId: string) => { Object.entries(useWorkflowStore.getState().blocks).forEach(([blockId, block]) => { if (block.data?.parentId === parentId) { allBlocksToRemove.add(blockId) findAllDescendants(blockId) } }) } blockIds.forEach((id) => findAllDescendants(id)) const currentEditedBlockId = usePanelEditorStore.getState().currentBlockId if (currentEditedBlockId && allBlocksToRemove.has(currentEditedBlockId)) { usePanelEditorStore.getState().clearCurrentBlock() } const mergedBlocks = mergeSubblockState( useWorkflowStore.getState().blocks, activeWorkflowId || undefined ) const blockSnapshots: BlockState[] = [] const subBlockValues: Record> = {} allBlocksToRemove.forEach((blockId) => { const block = mergedBlocks[blockId] if (block) { blockSnapshots.push(block) if (block.subBlocks) { const values: Record = {} Object.entries(block.subBlocks).forEach(([subBlockId, subBlock]) => { if (subBlock.value !== null && subBlock.value !== undefined) { values[subBlockId] = subBlock.value } }) if (Object.keys(values).length > 0) { subBlockValues[blockId] = values } } } }) const edgeSnapshots = useWorkflowStore .getState() .edges.filter((e) => allBlocksToRemove.has(e.source) || allBlocksToRemove.has(e.target)) logger.info('Batch removing blocks collaboratively', { requestedCount: blockIds.length, totalCount: allBlocksToRemove.size, }) const operationId = generateId() addToQueue({ id: operationId, operation: { operation: BLOCKS_OPERATIONS.BATCH_REMOVE_BLOCKS, target: OPERATION_TARGETS.BLOCKS, payload: { ids: Array.from(allBlocksToRemove) }, }, workflowId: activeWorkflowId || '', userId: session?.user?.id || 'unknown', }) useWorkflowStore.getState().batchRemoveBlocks(blockIds) if (!options?.skipUndoRedo && blockSnapshots.length > 0) { undoRedo.recordBatchRemoveBlocks(blockSnapshots, edgeSnapshots, subBlockValues) } return true }, [ isBaselineDiffView, addToQueue, activeWorkflowId, session?.user?.id, cancelOperationsForBlock, undoRedo, ] ) return { isConnected, currentWorkflowId, // Collaborative operations collaborativeBatchUpdatePositions, collaborativeUpdateBlockName, collaborativeBatchToggleBlockEnabled, collaborativeBatchUpdateParent, collaborativeToggleBlockAdvancedMode, collaborativeSetBlockCanonicalMode, collaborativeSetBlockCanonicalModes, collaborativeBatchToggleBlockHandles, collaborativeBatchToggleLocked, collaborativeBatchAddBlocks, collaborativeBatchRemoveBlocks, collaborativeBatchAddEdges, collaborativeBatchRemoveEdges, collaborativeSetSubblockValue, collaborativeBatchSetSubblockValues, collaborativeSetTagSelection, // Collaborative variable operations collaborativeUpdateVariable, collaborativeAddVariable, collaborativeDeleteVariable, // Collaborative loop/parallel operations collaborativeUpdateLoopType, collaborativeUpdateParallelType, collaborativeUpdateParallelBatchSize, // Unified iteration operations collaborativeUpdateIterationCount, collaborativeUpdateIterationCollection, // Undo/Redo operations (wrapped to prevent recording moves during undo/redo) undo: useCallback(async () => { isUndoRedoInProgress.current = true await undoRedo.undo() // Use a longer delay to ensure all async operations complete setTimeout(() => { isUndoRedoInProgress.current = false }, 100) }, [undoRedo]), redo: useCallback(async () => { isUndoRedoInProgress.current = true await undoRedo.redo() // Use a longer delay to ensure all async operations complete setTimeout(() => { isUndoRedoInProgress.current = false }, 100) }, [undoRedo]), getUndoRedoSizes: undoRedo.getStackSizes, clearUndoRedo: undoRedo.clearStacks, } }