import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import type { Edge } from 'reactflow' import { getTargetedLayoutImpact } from '@/lib/workflows/autolayout' import type { BlockWithDiff } from '@/lib/workflows/diff/types' import { isValidKey } from '@/lib/workflows/sanitization/key-validation' import { isUuid } from '@/executor/constants' import { mergeSubblockState } from '@/stores/workflows/utils' import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types' const logger = createLogger('WorkflowDiffEngine') function parseWorkflowStateJson(jsonContent: string): WorkflowState { const parsed = JSON.parse(jsonContent) as unknown if (!parsed || typeof parsed !== 'object') { throw new Error('Diff content must be a workflow state object') } const candidate = parsed as Partial if ( !candidate.blocks || typeof candidate.blocks !== 'object' || Array.isArray(candidate.blocks) || !Array.isArray(candidate.edges) ) { throw new Error('Diff content is missing workflow blocks or edges') } return { blocks: candidate.blocks, edges: candidate.edges, loops: candidate.loops ?? {}, parallels: candidate.parallels ?? {}, } } // Helper function to check if a block has changed function hasBlockChanged(currentBlock: BlockState, proposedBlock: BlockState): boolean { // Compare key fields that indicate a change if (currentBlock.type !== proposedBlock.type) return true if (currentBlock.name !== proposedBlock.name) return true if (currentBlock.enabled !== proposedBlock.enabled) return true if (currentBlock.triggerMode !== proposedBlock.triggerMode) return true if ((currentBlock.data?.parentId ?? null) !== (proposedBlock.data?.parentId ?? null)) return true // Compare subBlocks const currentSubKeys = Object.keys(currentBlock.subBlocks || {}) const proposedSubKeys = Object.keys(proposedBlock.subBlocks || {}) if (currentSubKeys.length !== proposedSubKeys.length) return true for (const key of currentSubKeys) { if (!proposedSubKeys.includes(key)) return true const currentSub = currentBlock.subBlocks[key] const proposedSub = proposedBlock.subBlocks?.[key] if (!proposedSub) return true if (JSON.stringify(currentSub.value) !== JSON.stringify(proposedSub.value)) return true } return false } // Helper function to compute field differences between blocks function computeFieldDiff( currentBlock: BlockState, proposedBlock: BlockState ): { changedFields: string[] unchangedFields: string[] } { const changedFields: string[] = [] const unchangedFields: string[] = [] // Check basic fields const fieldsToCheck = ['type', 'name', 'enabled', 'triggerMode', 'horizontalHandles'] as const for (const field of fieldsToCheck) { const currentValue = currentBlock[field] const proposedValue = proposedBlock[field] if (JSON.stringify(currentValue) !== JSON.stringify(proposedValue)) { changedFields.push(field) } else if (currentValue !== undefined) { unchangedFields.push(field) } } if ((currentBlock.data?.parentId ?? null) !== (proposedBlock.data?.parentId ?? null)) { changedFields.push('parentId') } else { unchangedFields.push('parentId') } // Check subBlocks - use just the key name for UI compatibility const currentSubKeys = Object.keys(currentBlock.subBlocks || {}) const proposedSubKeys = Object.keys(proposedBlock.subBlocks || {}) const allSubKeys = new Set([...currentSubKeys, ...proposedSubKeys]) for (const key of allSubKeys) { const currentSub = currentBlock.subBlocks?.[key] const proposedSub = proposedBlock.subBlocks?.[key] if (!currentSub && proposedSub) { // New subblock changedFields.push(key) } else if (currentSub && !proposedSub) { // Deleted subblock changedFields.push(key) } else if (currentSub && proposedSub) { // Check if value changed if (JSON.stringify(currentSub.value) !== JSON.stringify(proposedSub.value)) { changedFields.push(key) } else { unchangedFields.push(key) } } } return { changedFields, unchangedFields } } interface DiffMetadata { source: string timestamp: number } interface EdgeDiff { new_edges: string[] deleted_edges: string[] unchanged_edges: string[] } export interface DiffAnalysis { new_blocks: string[] edited_blocks: string[] deleted_blocks: string[] field_diffs?: Record edge_diff?: EdgeDiff } export interface WorkflowDiff { proposedState: WorkflowState diffAnalysis?: DiffAnalysis metadata: DiffMetadata } export interface DiffResult { success: boolean diff?: WorkflowDiff errors?: string[] } /** * Clean diff engine that handles workflow diff operations * without polluting core workflow stores */ export class WorkflowDiffEngine { private currentDiff: WorkflowDiff | undefined = undefined /** * Create a diff from workflow state */ async createDiff(jsonContent: string, diffAnalysis?: DiffAnalysis): Promise { try { logger.info('WorkflowDiffEngine.createDiff called with:', { jsonContentLength: jsonContent.length, diffAnalysis: diffAnalysis, diffAnalysisType: typeof diffAnalysis, diffAnalysisUndefined: diffAnalysis === undefined, diffAnalysisNull: diffAnalysis === null, }) // Get current workflow state for comparison const { useWorkflowStore } = await import('@/stores/workflows/workflow/store') const currentWorkflowState = useWorkflowStore.getState().getWorkflowState() logger.info('WorkflowDiffEngine current workflow state:', { blockCount: Object.keys(currentWorkflowState.blocks || {}).length, edgeCount: currentWorkflowState.edges?.length || 0, hasLoops: Object.keys(currentWorkflowState.loops || {}).length > 0, hasParallels: Object.keys(currentWorkflowState.parallels || {}).length > 0, }) // Merge subblock values from subblock store to ensure manual edits are included in baseline let mergedBaseline: WorkflowState = currentWorkflowState try { mergedBaseline = { ...currentWorkflowState, blocks: mergeSubblockState(currentWorkflowState.blocks), } logger.info('Merged subblock values into baseline for diff creation', { blockCount: Object.keys(mergedBaseline.blocks || {}).length, }) } catch (mergeError) { logger.warn('Failed to merge subblock values into baseline; proceeding with raw state', { error: toError(mergeError).message, }) } const proposedState = parseWorkflowStateJson(jsonContent) return this.createDiffFromWorkflowState(proposedState, diffAnalysis, mergedBaseline) } catch (error) { logger.error('Failed to create diff:', error) return { success: false, errors: [getErrorMessage(error, 'Failed to create diff')], } } } /** * Create a diff from a WorkflowState object directly (more efficient than YAML) * This follows the same logic as sim-agent's YamlDiffCreate handler */ async createDiffFromWorkflowState( proposedState: WorkflowState, diffAnalysis?: DiffAnalysis, baselineOverride?: WorkflowState ): Promise { try { logger.info('WorkflowDiffEngine.createDiffFromWorkflowState called with:', { blockCount: Object.keys(proposedState.blocks || {}).length, edgeCount: proposedState.edges?.length || 0, hasDiffAnalysis: !!diffAnalysis, }) // Determine baseline for comparison const { useWorkflowStore } = await import('@/stores/workflows/workflow/store') const currentWorkflowState = useWorkflowStore.getState().getWorkflowState() const hasBaselineOverride = !!baselineOverride const baselineForComparison = baselineOverride ?? this.currentDiff?.proposedState ?? currentWorkflowState const isEditingOnTopOfDiff = !baselineOverride && !!this.currentDiff if (isEditingOnTopOfDiff) { logger.info('Editing on top of existing diff - using diff as baseline for comparison', { diffBlockCount: Object.keys(this.currentDiff!.proposedState.blocks).length, }) } // Merge subblock values from subblock store to ensure manual edits are included let mergedBaseline: WorkflowState = baselineForComparison // Only merge subblock values if we're comparing against original workflow // If editing on top of diff or using an explicit override, trust provided values if (!isEditingOnTopOfDiff && !hasBaselineOverride) { try { mergedBaseline = { ...baselineForComparison, blocks: mergeSubblockState(baselineForComparison.blocks), } logger.info('Merged subblock values into baseline for diff creation', { blockCount: Object.keys(mergedBaseline.blocks || {}).length, }) } catch (mergeError) { logger.warn('Failed to merge subblock values into baseline; proceeding with raw state', { error: toError(mergeError).message, }) } } else { logger.info( 'Using diff state as baseline without merging subblocks (editing on top of diff)' ) } // Build a map of existing blocks by type:name for matching const existingBlockMap: Record = {} for (const [id, block] of Object.entries(mergedBaseline.blocks)) { const key = `${block.type}:${block.name}` existingBlockMap[key] = { id, block } } // Create ID mapping - preserve existing IDs where blocks match by type:name const idMap: Record = {} const finalBlocks: Record = {} // First pass: build ID mappings for (const [proposedId, proposedBlock] of Object.entries(proposedState.blocks)) { // CRITICAL: Skip invalid block IDs to prevent "undefined" keys in workflow state if (!isValidKey(proposedId)) { logger.error('Invalid proposedId detected in proposed state', { proposedId, proposedId_type: typeof proposedId, blockType: proposedBlock?.type, blockName: proposedBlock?.name, }) continue } const key = `${proposedBlock.type}:${proposedBlock.name}` // Check if this block exists in current state by type:name if (existingBlockMap[key]) { // Preserve existing ID idMap[proposedId] = existingBlockMap[key].id } else if (isUuid(proposedId)) { // New block with a valid UUID (e.g., from server's DB save) — keep it. // Minting a new UUID would create a mismatch between the client store // and the database, causing foreign key violations on subsequent // socket operations. idMap[proposedId] = proposedId } else { // New block with a non-UUID ID (e.g., from YAML parsing) — mint a UUID idMap[proposedId] = generateId() } } // Second pass: build final blocks with mapped IDs for (const [proposedId, proposedBlock] of Object.entries(proposedState.blocks)) { // CRITICAL: Skip invalid block IDs to prevent "undefined" keys in workflow state if (!isValidKey(proposedId)) { logger.error('Invalid proposedId detected in proposed state (second pass)', { proposedId, proposedId_type: typeof proposedId, blockType: proposedBlock?.type, blockName: proposedBlock?.name, }) continue } const finalId = idMap[proposedId] // CRITICAL: Validate finalId before using as key if (!isValidKey(finalId)) { logger.error('Invalid finalId generated from idMap', { proposedId, finalId, finalId_type: typeof finalId, blockType: proposedBlock?.type, blockName: proposedBlock?.name, }) continue } const key = `${proposedBlock.type}:${proposedBlock.name}` const existingBlock = existingBlockMap[key]?.block // Merge with existing block if found, otherwise use proposed. // Position strategy depends on whether the block's scope changed: // - Same scope (parentId unchanged): preserve existing position so // unchanged blocks anchor correctly for targeted layout. // - Different scope (parentId changed): use proposed position because // the coordinate system changed (absolute ↔ relative-to-container). const existingParent = existingBlock?.data?.parentId ?? null const proposedParentRaw = proposedBlock.data?.parentId ?? null const proposedParent = proposedParentRaw ? (idMap[proposedParentRaw] ?? proposedParentRaw) : null const scopeChanged = existingBlock ? existingParent !== proposedParent : false const finalBlock: BlockState & BlockWithDiff = existingBlock ? { ...existingBlock, ...proposedBlock, id: finalId, position: scopeChanged ? proposedBlock.position : existingBlock.position, } : { ...proposedBlock, id: finalId, } // Update parentId in data if it exists and has been remapped if (finalBlock.data?.parentId && idMap[finalBlock.data.parentId]) { finalBlock.data = { ...finalBlock.data, parentId: idMap[finalBlock.data.parentId], } } finalBlocks[finalId] = finalBlock } // Map edges with new IDs and standardized handles const edgeMap = new Map() proposedState.edges.forEach((edge) => { const source = idMap[edge.source] || edge.source const target = idMap[edge.target] || edge.target const sourceHandle = edge.sourceHandle || 'source' const targetHandle = edge.targetHandle || 'target' // Create a unique key for deduplication const edgeKey = `${source}-${sourceHandle}-${target}-${targetHandle}` // Only add if we haven't seen this edge combination before if (!edgeMap.has(edgeKey)) { edgeMap.set(edgeKey, { ...edge, id: generateId(), // Use UUID for unique edge IDs source, target, sourceHandle, targetHandle, type: edge.type || 'workflowEdge', }) } }) const finalEdges: Edge[] = Array.from(edgeMap.values()) // Build final proposed state // Always regenerate loops and parallels from finalBlocks because the block IDs may have // been remapped (via idMap) and the server's loops/parallels would have stale references. // This ensures the nodes arrays in loops/parallels contain the correct (remapped) block IDs, // which is critical for variable resolution in the tag dropdown. const { generateLoopBlocks, generateParallelBlocks } = await import( '@/stores/workflows/workflow/utils' ) // Build the proposed state const finalProposedState: WorkflowState = { blocks: finalBlocks, edges: finalEdges, loops: generateLoopBlocks(finalBlocks), parallels: generateParallelBlocks(finalBlocks), lastSaved: Date.now(), } // Use the proposed state directly - validation happens at the source const fullyCleanedState = finalProposedState // Transfer block heights from baseline workflow for better measurements in diff view // If editing on top of diff, this transfers from the diff (which already has good heights) // Otherwise transfers from original workflow logger.info('Transferring block heights from baseline workflow', { isEditingOnTopOfDiff, baselineBlockCount: Object.keys(mergedBaseline.blocks).length, }) try { const { transferBlockHeights } = await import('@/lib/workflows/autolayout') transferBlockHeights(mergedBaseline.blocks, finalBlocks) } catch (error) { logger.warn('Failed to transfer block heights', { error: toError(error).message, }) } // Apply autolayout to the proposed state logger.info('Applying autolayout to proposed workflow state') try { const { layoutBlockIds, resizedBlockIds, shiftSourceBlockIds } = getTargetedLayoutImpact({ before: mergedBaseline, after: fullyCleanedState, }) const totalBlocks = Object.keys(finalBlocks).length if ( layoutBlockIds.length === 0 && resizedBlockIds.length === 0 && shiftSourceBlockIds.length === 0 ) { logger.info('No blocks need layout; skipping autolayout', { totalBlocks, }) } else { // Always use targeted layout for copilot edits. When anchors exist // (some blocks unchanged), they preserve user positions. When no // anchors exist (all blocks are new), targeted layout degrades // gracefully to a full layout from the padding origin — same result // as applyAutoLayout but with one unified code path. logger.info('Using targeted layout for copilot edits', { blocksNeedingLayout: layoutBlockIds.length, resizedAnchorBlocks: resizedBlockIds.length, shiftSourceBlocks: shiftSourceBlockIds.length, anchors: totalBlocks - layoutBlockIds.length, totalBlocks, }) const { applyTargetedLayout } = await import('@/lib/workflows/autolayout') const { DEFAULT_HORIZONTAL_SPACING, DEFAULT_VERTICAL_SPACING } = await import( '@/lib/workflows/autolayout/constants' ) const layoutedBlocks = applyTargetedLayout(finalBlocks, fullyCleanedState.edges, { changedBlockIds: layoutBlockIds, resizedBlockIds, shiftSourceBlockIds, horizontalSpacing: DEFAULT_HORIZONTAL_SPACING, verticalSpacing: DEFAULT_VERTICAL_SPACING, }) Object.entries(layoutedBlocks).forEach(([id, layoutBlock]) => { if (finalBlocks[id]) { finalBlocks[id].position = layoutBlock.position if (layoutBlock.data) { finalBlocks[id].data = { ...finalBlocks[id].data, ...layoutBlock.data, } } if (layoutBlock.layout) { finalBlocks[id].layout = { ...finalBlocks[id].layout, ...layoutBlock.layout, } } if (typeof layoutBlock.height === 'number') { finalBlocks[id].height = layoutBlock.height } } }) logger.info('Successfully applied targeted layout to proposed state', { blocksLayouted: Object.keys(layoutedBlocks).length, blocksNeedingLayout: layoutBlockIds.length, }) } } catch (layoutError) { logger.warn('Error applying autolayout, using default positions', { error: toError(layoutError).message, }) } // Compute diff analysis if not provided let computed = diffAnalysis if (!computed) { // Generate diff analysis between current and proposed states const currentIds = new Set(Object.keys(mergedBaseline.blocks)) const proposedIds = new Set(Object.keys(finalBlocks)) const newBlocks: string[] = [] const editedBlocks: string[] = [] const deletedBlocks: string[] = [] // Find new and edited blocks for (const [id, block] of Object.entries(finalBlocks)) { if (!currentIds.has(id)) { newBlocks.push(id) } else { // Check if block was edited by comparing key fields const currentBlock = mergedBaseline.blocks[id] if (hasBlockChanged(currentBlock, block)) { editedBlocks.push(id) } } } // Find deleted blocks for (const id of currentIds) { if (!proposedIds.has(id)) { deletedBlocks.push(id) } } // Compute field diffs for edited blocks const fieldDiffs: Record = {} for (const id of editedBlocks) { const currentBlock = mergedBaseline.blocks[id] const proposedBlock = finalBlocks[id] const { changedFields, unchangedFields } = computeFieldDiff(currentBlock, proposedBlock) if (changedFields.length > 0) { fieldDiffs[id] = { changed_fields: changedFields, unchanged_fields: unchangedFields, } } } // Compute edge diffs const currentEdgeSet = new Set() const proposedEdgeSet = new Set() // Create edge identifiers for current state (using sim-agent format) mergedBaseline.edges.forEach((edge: Edge) => { const edgeId = `${edge.source}-${edge.sourceHandle || 'source'}-${edge.target}-${edge.targetHandle || 'target'}` currentEdgeSet.add(edgeId) }) // Create edge identifiers for proposed state fullyCleanedState.edges.forEach((edge) => { const edgeId = `${edge.source}-${edge.sourceHandle || 'source'}-${edge.target}-${edge.targetHandle || 'target'}` proposedEdgeSet.add(edgeId) }) // Classify edges const newEdges: string[] = [] const deletedEdges: string[] = [] const unchangedEdges: string[] = [] // Find new edges (in proposed but not current) proposedEdgeSet.forEach((edgeId) => { if (!currentEdgeSet.has(edgeId)) { newEdges.push(edgeId) } else { unchangedEdges.push(edgeId) } }) // Find deleted edges (in current but not proposed) currentEdgeSet.forEach((edgeId) => { if (!proposedEdgeSet.has(edgeId)) { deletedEdges.push(edgeId) } }) computed = { new_blocks: newBlocks, edited_blocks: editedBlocks, deleted_blocks: deletedBlocks, field_diffs: Object.keys(fieldDiffs).length > 0 ? fieldDiffs : undefined, edge_diff: { new_edges: newEdges, deleted_edges: deletedEdges, unchanged_edges: unchangedEdges, }, } } // Apply diff markers to blocks in the fully cleaned state if (computed) { for (const id of computed.new_blocks || []) { if (fullyCleanedState.blocks[id]) { ;(fullyCleanedState.blocks[id] as any).is_diff = 'new' } } for (const id of computed.edited_blocks || []) { if (fullyCleanedState.blocks[id]) { ;(fullyCleanedState.blocks[id] as any).is_diff = 'edited' // Also mark specific subblocks that changed if (computed.field_diffs?.[id]) { const fieldDiff = computed.field_diffs[id] const block = fullyCleanedState.blocks[id] // Apply diff markers to changed subblocks for (const changedField of fieldDiff.changed_fields) { if (block.subBlocks?.[changedField]) { // Add a diff marker to the subblock itself ;(block.subBlocks[changedField] as any).is_diff = 'changed' } } } } } // Note: We don't remove deleted blocks from fullyCleanedState, just mark them } // Store the diff with the fully sanitized state this.currentDiff = { proposedState: fullyCleanedState, diffAnalysis: computed, metadata: { source: 'workflow_state', timestamp: Date.now(), }, } logger.info('Successfully created diff from workflow state', { blockCount: Object.keys(fullyCleanedState.blocks).length, edgeCount: fullyCleanedState.edges.length, hasLoops: Object.keys(fullyCleanedState.loops || {}).length > 0, hasParallels: Object.keys(fullyCleanedState.parallels || {}).length > 0, newBlocks: computed?.new_blocks?.length || 0, editedBlocks: computed?.edited_blocks?.length || 0, deletedBlocks: computed?.deleted_blocks?.length || 0, newEdges: computed?.edge_diff?.new_edges?.length || 0, deletedEdges: computed?.edge_diff?.deleted_edges?.length || 0, unchangedEdges: computed?.edge_diff?.unchanged_edges?.length || 0, }) if (computed?.edge_diff?.deleted_edges && computed.edge_diff.deleted_edges.length > 0) { logger.info('Deleted edges detected:', { deletedEdges: computed.edge_diff.deleted_edges, }) } return { success: true, diff: this.currentDiff, } } catch (error) { logger.error('Failed to create diff from workflow state:', error) return { success: false, errors: [getErrorMessage(error, 'Failed to create diff from workflow state')], } } } /** * Merge new workflow state into existing diff * Used for cumulative updates within the same message */ async mergeDiff(jsonContent: string, diffAnalysis?: DiffAnalysis): Promise { try { logger.info('Merging diff from workflow state') // If no existing diff, create a new one if (!this.currentDiff) { logger.info('No existing diff, creating new diff') return this.createDiff(jsonContent, diffAnalysis) } const proposedState = parseWorkflowStateJson(jsonContent) const result = await this.createDiffFromWorkflowState( proposedState, diffAnalysis, this.currentDiff.proposedState ) if (result.success && result.diff) { logger.info('Diff merged successfully', { totalBlocksCount: Object.keys(result.diff.proposedState.blocks).length, totalEdgesCount: result.diff.proposedState.edges.length, }) } return result } catch (error) { logger.error('Failed to merge diff:', error) return { success: false, errors: [getErrorMessage(error, 'Failed to merge diff')], } } } /** * Get the current diff */ getCurrentDiff(): WorkflowDiff | undefined { return this.currentDiff } /** * Clear the current diff */ clearDiff(): void { this.currentDiff = undefined logger.info('Diff cleared') } /** * Check if a diff is active */ hasDiff(): boolean { return this.currentDiff !== undefined } /** * Get the workflow state for display (either diff or provided state) */ getDisplayState(currentState: WorkflowState): WorkflowState { if (this.currentDiff) { return this.currentDiff.proposedState } return currentState } /** * Accept the diff and return the clean state */ acceptDiff(): WorkflowState | null { if (!this.currentDiff) { logger.warn('No diff to accept') return null } try { // Clean up the proposed state by removing diff markers const cleanState = stripWorkflowDiffMarkers(this.currentDiff.proposedState) logger.info('Diff accepted', { blocksCount: Object.keys(cleanState.blocks).length, edgesCount: cleanState.edges.length, loopsCount: Object.keys(cleanState.loops).length, parallelsCount: Object.keys(cleanState.parallels).length, }) this.clearDiff() return cleanState } catch (error) { logger.error('Failed to accept diff:', error) return null } } } /** * Removes diff metadata from a workflow state so it can be persisted or re-used safely. */ export function stripWorkflowDiffMarkers(state: WorkflowState): WorkflowState { const cleanBlocks: Record = {} for (const [blockId, block] of Object.entries(state.blocks || {})) { // Validate block ID at the source - skip invalid IDs if (!isValidKey(blockId)) { logger.error('Invalid blockId detected in stripWorkflowDiffMarkers', { blockId, blockId_type: typeof blockId, blockType: block?.type, blockName: block?.name, }) continue } const cleanBlock: BlockState = structuredClone(block) const blockWithDiff = cleanBlock as BlockState & BlockWithDiff blockWithDiff.is_diff = undefined blockWithDiff.field_diffs = undefined if (cleanBlock.subBlocks) { Object.values(cleanBlock.subBlocks).forEach((subBlock) => { if (subBlock && typeof subBlock === 'object') { ;(subBlock as any).is_diff = undefined } }) } if (cleanBlock.outputs === undefined || cleanBlock.outputs === null) { cleanBlock.outputs = {} } cleanBlocks[blockId] = cleanBlock } return { ...state, blocks: cleanBlocks, edges: structuredClone(state.edges || []), loops: structuredClone(state.loops || {}), parallels: structuredClone(state.parallels || {}), } }