diff --git a/apps/sim/lib/logs/execution/snapshot/service.test.ts b/apps/sim/lib/logs/execution/snapshot/service.test.ts index 543a2b1a1..a0f775516 100644 --- a/apps/sim/lib/logs/execution/snapshot/service.test.ts +++ b/apps/sim/lib/logs/execution/snapshot/service.test.ts @@ -86,7 +86,13 @@ describe('SnapshotService', () => { type: 'agent', position: { x: 100, y: 200 }, - subBlocks: {}, + subBlocks: { + prompt: { + id: 'prompt', + type: 'short-input', + value: 'Hello world', + }, + }, outputs: {}, enabled: true, horizontalHandles: true, @@ -104,8 +110,14 @@ describe('SnapshotService', () => { blocks: { block1: { ...baseState.blocks.block1, - // Different block state - we can change outputs to make it different - outputs: { response: { type: 'string', description: 'different result' } }, + // Different subBlock value - this is a meaningful change + subBlocks: { + prompt: { + id: 'prompt', + type: 'short-input', + value: 'Different prompt', + }, + }, }, }, } diff --git a/apps/sim/lib/logs/execution/snapshot/service.ts b/apps/sim/lib/logs/execution/snapshot/service.ts index d753cbbd8..26922493a 100644 --- a/apps/sim/lib/logs/execution/snapshot/service.ts +++ b/apps/sim/lib/logs/execution/snapshot/service.ts @@ -11,12 +11,7 @@ import type { WorkflowExecutionSnapshotInsert, WorkflowState, } from '@/lib/logs/types' -import { - normalizedStringify, - normalizeEdge, - normalizeValue, - sortEdges, -} from '@/lib/workflows/comparison' +import { normalizedStringify, normalizeWorkflowState } from '@/lib/workflows/comparison' const logger = createLogger('SnapshotService') @@ -38,7 +33,9 @@ export class SnapshotService implements ISnapshotService { const existingSnapshot = await this.getSnapshotByHash(workflowId, stateHash) if (existingSnapshot) { - logger.debug(`Reusing existing snapshot for workflow ${workflowId} with hash ${stateHash}`) + logger.info( + `Reusing existing snapshot for workflow ${workflowId} (hash: ${stateHash.slice(0, 12)}...)` + ) return { snapshot: existingSnapshot, isNew: false, @@ -59,8 +56,9 @@ export class SnapshotService implements ISnapshotService { .values(snapshotData) .returning() - logger.debug(`Created new snapshot for workflow ${workflowId} with hash ${stateHash}`) - logger.debug(`Stored full state with ${Object.keys(state.blocks || {}).length} blocks`) + logger.info( + `Created new snapshot for workflow ${workflowId} (hash: ${stateHash.slice(0, 12)}..., blocks: ${Object.keys(state.blocks || {}).length})` + ) return { snapshot: { ...newSnapshot, @@ -112,7 +110,7 @@ export class SnapshotService implements ISnapshotService { } computeStateHash(state: WorkflowState): string { - const normalizedState = this.normalizeStateForHashing(state) + const normalizedState = normalizeWorkflowState(state) const stateString = normalizedStringify(normalizedState) return createHash('sha256').update(stateString).digest('hex') } @@ -130,69 +128,6 @@ export class SnapshotService implements ISnapshotService { logger.info(`Cleaned up ${deletedCount} orphaned snapshots older than ${olderThanDays} days`) return deletedCount } - - private normalizeStateForHashing(state: WorkflowState): any { - // 1. Normalize and sort edges - const normalizedEdges = sortEdges((state.edges || []).map(normalizeEdge)) - - // 2. Normalize blocks - const normalizedBlocks: Record = {} - - for (const [blockId, block] of Object.entries(state.blocks || {})) { - const { position, layout, height, ...blockWithoutLayoutFields } = block - - // Also exclude width/height from data object (container dimensions from autolayout) - const { - width: _dataWidth, - height: _dataHeight, - ...dataRest - } = blockWithoutLayoutFields.data || {} - - // Normalize subBlocks - const subBlocks = blockWithoutLayoutFields.subBlocks || {} - const normalizedSubBlocks: Record = {} - - for (const [subBlockId, subBlock] of Object.entries(subBlocks)) { - const value = subBlock.value ?? null - - normalizedSubBlocks[subBlockId] = { - type: subBlock.type, - value: normalizeValue(value), - ...Object.fromEntries( - Object.entries(subBlock).filter(([key]) => key !== 'value' && key !== 'type') - ), - } - } - - normalizedBlocks[blockId] = { - ...blockWithoutLayoutFields, - data: dataRest, - subBlocks: normalizedSubBlocks, - } - } - - // 3. Normalize loops and parallels - const normalizedLoops: Record = {} - for (const [loopId, loop] of Object.entries(state.loops || {})) { - normalizedLoops[loopId] = normalizeValue(loop) - } - - const normalizedParallels: Record = {} - for (const [parallelId, parallel] of Object.entries(state.parallels || {})) { - normalizedParallels[parallelId] = normalizeValue(parallel) - } - - // 4. Normalize variables (if present) - const normalizedVariables = state.variables ? normalizeValue(state.variables) : undefined - - return { - blocks: normalizedBlocks, - edges: normalizedEdges, - loops: normalizedLoops, - parallels: normalizedParallels, - ...(normalizedVariables !== undefined && { variables: normalizedVariables }), - } - } } export const snapshotService = new SnapshotService() diff --git a/apps/sim/lib/workflows/comparison/compare.ts b/apps/sim/lib/workflows/comparison/compare.ts index 077beda41..df7034586 100644 --- a/apps/sim/lib/workflows/comparison/compare.ts +++ b/apps/sim/lib/workflows/comparison/compare.ts @@ -1,31 +1,18 @@ -import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types' -import { SYSTEM_SUBBLOCK_IDS, TRIGGER_RUNTIME_SUBBLOCK_IDS } from '@/triggers/constants' +import type { WorkflowState } from '@/stores/workflows/workflow/types' import { + extractBlockFieldsForComparison, + extractSubBlockRest, + filterSubBlockIds, normalizedStringify, normalizeEdge, normalizeLoop, normalizeParallel, + normalizeSubBlockValue, normalizeValue, normalizeVariables, - sanitizeInputFormat, - sanitizeTools, sanitizeVariable, } from './normalize' -/** Block with optional diff markers added by copilot */ -type BlockWithDiffMarkers = BlockState & { - is_diff?: string - field_diffs?: Record -} - -/** SubBlock with optional diff marker */ -type SubBlockWithDiffMarker = { - id: string - type: string - value: unknown - is_diff?: string -} - /** * Compare the current workflow state with the deployed state to detect meaningful changes. * Uses generateWorkflowDiffSummary internally to ensure consistent change detection. @@ -125,36 +112,21 @@ export function generateWorkflowDiffSummary( for (const id of currentBlockIds) { if (!previousBlockIds.has(id)) continue - const currentBlock = currentBlocks[id] as BlockWithDiffMarkers - const previousBlock = previousBlocks[id] as BlockWithDiffMarkers + const currentBlock = currentBlocks[id] + const previousBlock = previousBlocks[id] const changes: FieldChange[] = [] - // Compare block-level properties (excluding visual-only fields) + // Use shared helpers for block field extraction (single source of truth) const { - position: _currentPos, - subBlocks: currentSubBlocks = {}, - layout: _currentLayout, - height: _currentHeight, - outputs: _currentOutputs, - is_diff: _currentIsDiff, - field_diffs: _currentFieldDiffs, - ...currentRest - } = currentBlock - + blockRest: currentRest, + normalizedData: currentDataRest, + subBlocks: currentSubBlocks, + } = extractBlockFieldsForComparison(currentBlock) const { - position: _previousPos, - subBlocks: previousSubBlocks = {}, - layout: _previousLayout, - height: _previousHeight, - outputs: _previousOutputs, - is_diff: _previousIsDiff, - field_diffs: _previousFieldDiffs, - ...previousRest - } = previousBlock - - // Exclude width/height from data object (container dimensions from autolayout) - const { width: _cw, height: _ch, ...currentDataRest } = currentRest.data || {} - const { width: _pw, height: _ph, ...previousDataRest } = previousRest.data || {} + blockRest: previousRest, + normalizedData: previousDataRest, + subBlocks: previousSubBlocks, + } = extractBlockFieldsForComparison(previousBlock) const normalizedCurrentBlock = { ...currentRest, data: currentDataRest, subBlocks: undefined } const normalizedPreviousBlock = { @@ -179,10 +151,11 @@ export function generateWorkflowDiffSummary( newValue: currentBlock.enabled, }) } - // Check other block properties + // Check other block properties (boolean fields) + // Use !! to normalize: null/undefined/false are all equivalent (falsy) const blockFields = ['horizontalHandles', 'advancedMode', 'triggerMode'] as const for (const field of blockFields) { - if (currentBlock[field] !== previousBlock[field]) { + if (!!currentBlock[field] !== !!previousBlock[field]) { changes.push({ field, oldValue: previousBlock[field], @@ -195,42 +168,27 @@ export function generateWorkflowDiffSummary( } } - // Compare subBlocks - const allSubBlockIds = [ + // Compare subBlocks using shared helper for filtering (single source of truth) + const allSubBlockIds = filterSubBlockIds([ ...new Set([...Object.keys(currentSubBlocks), ...Object.keys(previousSubBlocks)]), - ] - .filter( - (subId) => - !TRIGGER_RUNTIME_SUBBLOCK_IDS.includes(subId) && !SYSTEM_SUBBLOCK_IDS.includes(subId) - ) - .sort() + ]) for (const subId of allSubBlockIds) { - const currentSub = currentSubBlocks[subId] - const previousSub = previousSubBlocks[subId] + const currentSub = currentSubBlocks[subId] as Record | undefined + const previousSub = previousSubBlocks[subId] as Record | undefined if (!currentSub || !previousSub) { changes.push({ field: subId, - oldValue: previousSub?.value ?? null, - newValue: currentSub?.value ?? null, + oldValue: (previousSub as Record | undefined)?.value ?? null, + newValue: (currentSub as Record | undefined)?.value ?? null, }) continue } - // Compare subBlock values with sanitization - let currentValue: unknown = currentSub.value ?? null - let previousValue: unknown = previousSub.value ?? null - - if (subId === 'tools' && Array.isArray(currentValue) && Array.isArray(previousValue)) { - currentValue = sanitizeTools(currentValue) - previousValue = sanitizeTools(previousValue) - } - - if (subId === 'inputFormat' && Array.isArray(currentValue) && Array.isArray(previousValue)) { - currentValue = sanitizeInputFormat(currentValue) - previousValue = sanitizeInputFormat(previousValue) - } + // Use shared helper for subBlock value normalization (single source of truth) + const currentValue = normalizeSubBlockValue(subId, currentSub.value) + const previousValue = normalizeSubBlockValue(subId, previousSub.value) // For string values, compare directly to catch even small text changes if (typeof currentValue === 'string' && typeof previousValue === 'string') { @@ -245,11 +203,9 @@ export function generateWorkflowDiffSummary( } } - // Compare subBlock REST properties (type, id, etc. - excluding value and is_diff) - const currentSubWithDiff = currentSub as SubBlockWithDiffMarker - const previousSubWithDiff = previousSub as SubBlockWithDiffMarker - const { value: _cv, is_diff: _cd, ...currentSubRest } = currentSubWithDiff - const { value: _pv, is_diff: _pd, ...previousSubRest } = previousSubWithDiff + // Use shared helper for subBlock REST extraction (single source of truth) + const currentSubRest = extractSubBlockRest(currentSub) + const previousSubRest = extractSubBlockRest(previousSub) if (normalizedStringify(currentSubRest) !== normalizedStringify(previousSubRest)) { changes.push({ diff --git a/apps/sim/lib/workflows/comparison/index.ts b/apps/sim/lib/workflows/comparison/index.ts index 100c84a85..095795471 100644 --- a/apps/sim/lib/workflows/comparison/index.ts +++ b/apps/sim/lib/workflows/comparison/index.ts @@ -1,7 +1,30 @@ -export { hasWorkflowChanged } from './compare' +export type { FieldChange, WorkflowDiffSummary } from './compare' export { + formatDiffSummaryForDescription, + generateWorkflowDiffSummary, + hasWorkflowChanged, +} from './compare' +export type { + BlockWithDiffMarkers, + NormalizedWorkflowState, + SubBlockWithDiffMarker, +} from './normalize' +export { + EXCLUDED_BLOCK_DATA_FIELDS, + extractBlockFieldsForComparison, + extractSubBlockRest, + filterSubBlockIds, + normalizeBlockData, normalizedStringify, normalizeEdge, + normalizeLoop, + normalizeParallel, + normalizeSubBlockValue, normalizeValue, + normalizeVariables, + normalizeWorkflowState, + sanitizeInputFormat, + sanitizeTools, + sanitizeVariable, sortEdges, } from './normalize' diff --git a/apps/sim/lib/workflows/comparison/normalize.test.ts b/apps/sim/lib/workflows/comparison/normalize.test.ts index 66bf52f34..6c2cc5eb1 100644 --- a/apps/sim/lib/workflows/comparison/normalize.test.ts +++ b/apps/sim/lib/workflows/comparison/normalize.test.ts @@ -21,7 +21,11 @@ describe('Workflow Normalization Utilities', () => { expect(normalizeValue('hello')).toBe('hello') expect(normalizeValue(true)).toBe(true) expect(normalizeValue(false)).toBe(false) - expect(normalizeValue(null)).toBe(null) + }) + + it.concurrent('should normalize null and undefined to undefined', () => { + // null and undefined are semantically equivalent in our system + expect(normalizeValue(null)).toBe(undefined) expect(normalizeValue(undefined)).toBe(undefined) }) @@ -81,7 +85,7 @@ describe('Workflow Normalization Utilities', () => { expect(result[0]).toBe(1) expect(result[1]).toBe('string') expect(Object.keys(result[2] as Record)).toEqual(['a', 'b']) - expect(result[3]).toBe(null) + expect(result[3]).toBe(undefined) // null normalized to undefined expect(result[4]).toEqual([3, 2, 1]) // Array order preserved }) @@ -131,7 +135,11 @@ describe('Workflow Normalization Utilities', () => { expect(normalizedStringify(42)).toBe('42') expect(normalizedStringify('hello')).toBe('"hello"') expect(normalizedStringify(true)).toBe('true') - expect(normalizedStringify(null)).toBe('null') + }) + + it.concurrent('should treat null and undefined equivalently', () => { + // Both null and undefined normalize to undefined, which JSON.stringify returns as undefined + expect(normalizedStringify(null)).toBe(normalizedStringify(undefined)) }) it.concurrent('should produce different strings for different values', () => { @@ -143,8 +151,9 @@ describe('Workflow Normalization Utilities', () => { }) describe('normalizeLoop', () => { - it.concurrent('should return null/undefined as-is', () => { - expect(normalizeLoop(null)).toBe(null) + it.concurrent('should normalize null/undefined to undefined', () => { + // null and undefined are semantically equivalent + expect(normalizeLoop(null)).toBe(undefined) expect(normalizeLoop(undefined)).toBe(undefined) }) @@ -246,8 +255,9 @@ describe('Workflow Normalization Utilities', () => { }) describe('normalizeParallel', () => { - it.concurrent('should return null/undefined as-is', () => { - expect(normalizeParallel(null)).toBe(null) + it.concurrent('should normalize null/undefined to undefined', () => { + // null and undefined are semantically equivalent + expect(normalizeParallel(null)).toBe(undefined) expect(normalizeParallel(undefined)).toBe(undefined) }) diff --git a/apps/sim/lib/workflows/comparison/normalize.ts b/apps/sim/lib/workflows/comparison/normalize.ts index c467f73e0..dc414e25b 100644 --- a/apps/sim/lib/workflows/comparison/normalize.ts +++ b/apps/sim/lib/workflows/comparison/normalize.ts @@ -4,15 +4,66 @@ */ import type { Edge } from 'reactflow' -import type { Loop, Parallel, Variable } from '@/stores/workflows/workflow/types' +import { isNonEmptyValue } from '@/lib/workflows/subblocks/visibility' +import type { + BlockState, + Loop, + Parallel, + Variable, + WorkflowState, +} from '@/stores/workflows/workflow/types' +import { SYSTEM_SUBBLOCK_IDS, TRIGGER_RUNTIME_SUBBLOCK_IDS } from '@/triggers/constants' /** - * Normalizes a value for consistent comparison by sorting object keys recursively + * Block data fields to exclude from comparison/hashing. + * These are either: + * - Visual/runtime-derived fields + * - React Flow internal fields + * - Duplicated in loops/parallels state (source of truth is there, not block.data) + * - Duplicated in subBlocks (user config comes from subBlocks, block.data is just a copy) + */ +export const EXCLUDED_BLOCK_DATA_FIELDS: readonly string[] = [ + // Visual/layout fields + 'width', // Container dimensions from autolayout + 'height', // Container dimensions from autolayout + + // React Flow internal fields + 'id', // Duplicated from block.id + 'type', // React Flow node type (e.g., "subflowNode") + 'parentId', // Parent-child relationship for React Flow + 'extent', // React Flow extent setting + + // Loop fields - duplicated in loops state and/or subBlocks + 'nodes', // Subflow node membership (derived at runtime) + 'loopType', // Duplicated in loops state + 'count', // Iteration count (duplicated in loops state) + 'collection', // Items to iterate (duplicated in subBlocks) + 'whileCondition', // While condition (duplicated in subBlocks) + 'doWhileCondition', // Do-While condition (duplicated in subBlocks) + 'forEachItems', // ForEach items (duplicated in loops state) + 'iterations', // Loop iterations (duplicated in loops state) + + // Parallel fields - duplicated in parallels state and/or subBlocks + 'parallelType', // Duplicated in parallels state + 'distribution', // Parallel distribution (derived during execution) +] as const + +/** + * Normalizes a value for consistent comparison by: + * - Sorting object keys recursively + * - Filtering out null/undefined values from objects (treats them as equivalent to missing) + * - Recursively normalizing array elements + * * @param value - The value to normalize - * @returns A normalized version of the value with sorted keys + * @returns A normalized version of the value with sorted keys and no null/undefined fields */ export function normalizeValue(value: unknown): unknown { - if (value === null || value === undefined || typeof value !== 'object') { + // Treat null and undefined as equivalent - both become undefined (omitted from objects) + if (value === null || value === undefined) { + return undefined + } + + if (typeof value !== 'object') { return value } @@ -22,7 +73,11 @@ export function normalizeValue(value: unknown): unknown { const sorted: Record = {} for (const key of Object.keys(value as Record).sort()) { - sorted[key] = normalizeValue((value as Record)[key]) + const normalized = normalizeValue((value as Record)[key]) + // Only include non-null/undefined values + if (normalized !== undefined) { + sorted[key] = normalized + } } return sorted } @@ -48,27 +103,38 @@ interface NormalizedLoop { } /** - * Normalizes a loop configuration by extracting only the relevant fields for the loop type + * Normalizes a loop configuration by extracting only the relevant fields for the loop type. + * Sorts the nodes array for consistent comparison (order doesn't affect execution - edges determine flow). + * Only includes optional fields if they have non-null/undefined values. + * * @param loop - The loop configuration object * @returns Normalized loop with only relevant fields */ export function normalizeLoop(loop: Loop | null | undefined): NormalizedLoop | null | undefined { - if (!loop) return loop + if (!loop) return undefined // Normalize null to undefined const { id, nodes, loopType, iterations, forEachItems, whileCondition, doWhileCondition } = loop - const base: Pick = { id, nodes, loopType } + // Sort nodes for consistent comparison (execution order is determined by edges, not array order) + const sortedNodes = [...nodes].sort() + const base: NormalizedLoop = { id, nodes: sortedNodes, loopType } + + // Only add optional fields if they have non-null/undefined values switch (loopType) { case 'for': - return { ...base, iterations } + if (iterations != null) base.iterations = iterations + break case 'forEach': - return { ...base, forEachItems } + if (forEachItems != null) base.forEachItems = forEachItems + break case 'while': - return { ...base, whileCondition } + if (whileCondition != null) base.whileCondition = whileCondition + break case 'doWhile': - return { ...base, doWhileCondition } - default: - return base + if (doWhileCondition != null) base.doWhileCondition = doWhileCondition + break } + + return base } /** Normalized parallel result type with only essential fields */ @@ -81,29 +147,38 @@ interface NormalizedParallel { } /** - * Normalizes a parallel configuration by extracting only the relevant fields for the parallel type + * Normalizes a parallel configuration by extracting only the relevant fields for the parallel type. + * Sorts the nodes array for consistent comparison (parallel execution doesn't depend on array order). + * Only includes optional fields if they have non-null/undefined values. + * * @param parallel - The parallel configuration object * @returns Normalized parallel with only relevant fields */ export function normalizeParallel( parallel: Parallel | null | undefined ): NormalizedParallel | null | undefined { - if (!parallel) return parallel + if (!parallel) return undefined // Normalize null to undefined const { id, nodes, parallelType, count, distribution } = parallel - const base: Pick = { + + // Sort nodes for consistent comparison (parallel execution doesn't depend on array order) + const sortedNodes = [...nodes].sort() + const base: NormalizedParallel = { id, - nodes, + nodes: sortedNodes, parallelType, } + // Only add optional fields if they have non-null/undefined values switch (parallelType) { case 'count': - return { ...base, count } + if (count != null) base.count = count + break case 'collection': - return { ...base, distribution } - default: - return base + if (distribution != null) base.distribution = distribution + break } + + return base } /** Tool configuration with optional UI-only isExpanded field */ @@ -183,17 +258,25 @@ interface NormalizedEdge { } /** - * Normalizes an edge by extracting only the connection-relevant fields + * Normalizes an edge by extracting only the connection-relevant fields. + * Treats null and undefined as equivalent (omits the field if null/undefined). * @param edge - The edge object * @returns Normalized edge with only connection fields */ export function normalizeEdge(edge: Edge): NormalizedEdge { - return { + const normalized: NormalizedEdge = { source: edge.source, - sourceHandle: edge.sourceHandle, target: edge.target, - targetHandle: edge.targetHandle, } + // Only include handles if they have a non-null value + // This treats null and undefined as equivalent (both omitted) + if (edge.sourceHandle != null) { + normalized.sourceHandle = edge.sourceHandle + } + if (edge.targetHandle != null) { + normalized.targetHandle = edge.targetHandle + } + return normalized } /** @@ -220,3 +303,224 @@ export function sortEdges( ) ) } + +/** Block with optional diff markers added by copilot */ +export type BlockWithDiffMarkers = BlockState & { + is_diff?: string + field_diffs?: Record +} + +/** SubBlock with optional diff marker */ +export type SubBlockWithDiffMarker = { + id: string + type: string + value: unknown + is_diff?: string +} + +/** Normalized block structure for comparison */ +interface NormalizedBlock { + [key: string]: unknown + data: Record + subBlocks: Record +} + +/** Normalized subBlock structure */ +interface NormalizedSubBlock { + [key: string]: unknown + value: unknown +} + +/** Normalized workflow state structure */ +export interface NormalizedWorkflowState { + blocks: Record + edges: Array<{ + source: string + sourceHandle?: string | null + target: string + targetHandle?: string | null + }> + loops: Record + parallels: Record + variables: unknown +} + +/** Result of extracting block fields for comparison */ +export interface ExtractedBlockFields { + /** Block fields excluding visual-only fields (position, layout, height, outputs, diff markers) */ + blockRest: Record + /** Normalized data object excluding width/height/nodes/distribution */ + normalizedData: Record + /** SubBlocks map */ + subBlocks: Record +} + +/** + * Normalizes block data by excluding visual/runtime/duplicated fields. + * See EXCLUDED_BLOCK_DATA_FIELDS for the list of excluded fields. + * + * Also normalizes empty strings to undefined (removes them) because: + * - Legacy deployed states may have empty string fields that current states don't have + * - Empty string and undefined/missing are semantically equivalent for config fields + * + * @param data - The block data object + * @returns Normalized data object + */ +export function normalizeBlockData( + data: Record | undefined +): Record { + const normalized: Record = {} + + for (const [key, value] of Object.entries(data || {})) { + // Skip excluded fields + if (EXCLUDED_BLOCK_DATA_FIELDS.includes(key)) continue + // Skip empty/null/undefined values (treat as equivalent to missing) + if (!isNonEmptyValue(value)) continue + + normalized[key] = value + } + + return normalized +} + +/** + * Extracts block fields for comparison, excluding visual-only and runtime fields. + * Excludes: position, layout, height, outputs, is_diff, field_diffs + * + * @param block - The block state + * @returns Extracted fields suitable for comparison + */ +export function extractBlockFieldsForComparison(block: BlockState): ExtractedBlockFields { + const blockWithDiff = block as BlockWithDiffMarkers + const { + position: _position, + subBlocks = {}, + layout: _layout, + height: _height, + outputs: _outputs, + is_diff: _isDiff, + field_diffs: _fieldDiffs, + ...blockRest + } = blockWithDiff + + return { + blockRest, + normalizedData: normalizeBlockData(blockRest.data as Record | undefined), + subBlocks, + } +} + +/** + * Filters subBlock IDs to exclude system and trigger runtime subBlocks. + * + * @param subBlockIds - Array of subBlock IDs to filter + * @returns Filtered and sorted array of subBlock IDs + */ +export function filterSubBlockIds(subBlockIds: string[]): string[] { + return subBlockIds + .filter((id) => !SYSTEM_SUBBLOCK_IDS.includes(id) && !TRIGGER_RUNTIME_SUBBLOCK_IDS.includes(id)) + .sort() +} + +/** + * Normalizes a subBlock value with sanitization for specific subBlock types. + * Sanitizes: tools (removes isExpanded), inputFormat (removes collapsed) + * + * @param subBlockId - The subBlock ID + * @param value - The subBlock value + * @returns Normalized value + */ +export function normalizeSubBlockValue(subBlockId: string, value: unknown): unknown { + let normalizedValue = value ?? null + + if (subBlockId === 'tools' && Array.isArray(normalizedValue)) { + normalizedValue = sanitizeTools(normalizedValue) + } + if (subBlockId === 'inputFormat' && Array.isArray(normalizedValue)) { + normalizedValue = sanitizeInputFormat(normalizedValue) + } + + return normalizedValue +} + +/** + * Extracts subBlock fields for comparison, excluding diff markers. + * + * @param subBlock - The subBlock object + * @returns SubBlock fields excluding value and is_diff + */ +export function extractSubBlockRest(subBlock: Record): Record { + const { value: _v, is_diff: _sd, ...rest } = subBlock as SubBlockWithDiffMarker + return rest +} + +/** + * Normalizes a workflow state for comparison or hashing. + * Excludes non-functional fields (position, layout, height, outputs, diff markers) + * and system/trigger runtime subBlocks. + * + * @param state - The workflow state to normalize + * @returns A normalized workflow state suitable for comparison or hashing + */ +export function normalizeWorkflowState(state: WorkflowState): NormalizedWorkflowState { + // 1. Normalize and sort edges (connection-relevant fields only) + const normalizedEdges = sortEdges((state.edges || []).map(normalizeEdge)) + + // 2. Normalize blocks + const normalizedBlocks: Record = {} + + for (const [blockId, block] of Object.entries(state.blocks || {})) { + const { + blockRest, + normalizedData, + subBlocks: blockSubBlocks, + } = extractBlockFieldsForComparison(block) + + // Filter and normalize subBlocks (exclude system/trigger runtime subBlocks) + const normalizedSubBlocks: Record = {} + const subBlockIds = filterSubBlockIds(Object.keys(blockSubBlocks)) + + for (const subBlockId of subBlockIds) { + const subBlock = blockSubBlocks[subBlockId] as SubBlockWithDiffMarker + const value = normalizeSubBlockValue(subBlockId, subBlock.value) + const subBlockRest = extractSubBlockRest(subBlock as Record) + + normalizedSubBlocks[subBlockId] = { + ...subBlockRest, + value: normalizeValue(value), + } + } + + normalizedBlocks[blockId] = { + ...blockRest, + data: normalizedData, + subBlocks: normalizedSubBlocks, + } + } + + // 3. Normalize loops using specialized normalizeLoop (extracts only type-relevant fields) + const normalizedLoops: Record = {} + for (const [loopId, loop] of Object.entries(state.loops || {})) { + normalizedLoops[loopId] = normalizeValue(normalizeLoop(loop)) + } + + // 4. Normalize parallels using specialized normalizeParallel + const normalizedParallels: Record = {} + for (const [parallelId, parallel] of Object.entries(state.parallels || {})) { + normalizedParallels[parallelId] = normalizeValue(normalizeParallel(parallel)) + } + + // 5. Normalize variables (remove UI-only validationError field) + const variables = normalizeVariables(state.variables) + const normalizedVariablesObj = normalizeValue( + Object.fromEntries(Object.entries(variables).map(([id, v]) => [id, sanitizeVariable(v)])) + ) + + return { + blocks: normalizedBlocks, + edges: normalizedEdges, + loops: normalizedLoops, + parallels: normalizedParallels, + variables: normalizedVariablesObj, + } +} diff --git a/apps/sim/serializer/index.ts b/apps/sim/serializer/index.ts index cf85b32db..027c2dd4a 100644 --- a/apps/sim/serializer/index.ts +++ b/apps/sim/serializer/index.ts @@ -1,13 +1,16 @@ import { createLogger } from '@sim/logger' import type { Edge } from 'reactflow' import { BlockPathCalculator } from '@/lib/workflows/blocks/block-path-calculator' +import type { CanonicalModeOverrides } from '@/lib/workflows/subblocks/visibility' import { buildCanonicalIndex, buildSubBlockValues, evaluateSubBlockCondition, getCanonicalValues, + isCanonicalPair, isNonEmptyValue, isSubBlockFeatureEnabled, + resolveCanonicalMode, } from '@/lib/workflows/subblocks/visibility' import { getBlock } from '@/blocks' import type { SubBlockConfig } from '@/blocks/types' @@ -42,7 +45,8 @@ function shouldSerializeSubBlock( displayAdvancedOptions: boolean, isTriggerContext: boolean, isTriggerCategory: boolean, - canonicalIndex: ReturnType + canonicalIndex: ReturnType, + canonicalModeOverrides?: CanonicalModeOverrides ): boolean { if (!isSubBlockFeatureEnabled(subBlockConfig)) return false @@ -54,6 +58,18 @@ function shouldSerializeSubBlock( const isCanonicalMember = Boolean(canonicalIndex.canonicalIdBySubBlockId[subBlockConfig.id]) if (isCanonicalMember) { + const canonicalId = canonicalIndex.canonicalIdBySubBlockId[subBlockConfig.id] + const group = canonicalId ? canonicalIndex.groupsById[canonicalId] : undefined + if (group && isCanonicalPair(group)) { + const mode = + canonicalModeOverrides?.[group.canonicalId] ?? + (displayAdvancedOptions ? 'advanced' : resolveCanonicalMode(group, values)) + const matchesMode = + mode === 'advanced' + ? group.advancedIds.includes(subBlockConfig.id) + : group.basicId === subBlockConfig.id + return matchesMode && evaluateSubBlockCondition(subBlockConfig.condition, values) + } return evaluateSubBlockCondition(subBlockConfig.condition, values) } @@ -327,7 +343,8 @@ export class Serializer { legacyAdvancedMode, isTriggerContext, isTriggerCategory, - canonicalIndex + canonicalIndex, + canonicalModeOverrides ) ) @@ -351,7 +368,8 @@ export class Serializer { legacyAdvancedMode, isTriggerContext, isTriggerCategory, - canonicalIndex + canonicalIndex, + canonicalModeOverrides ) ) { params[id] = subBlockConfig.value(params) @@ -365,9 +383,7 @@ export class Serializer { const chosen = pairMode === 'advanced' ? advancedValue : basicValue const sourceIds = [group.basicId, ...group.advancedIds].filter(Boolean) as string[] - sourceIds.forEach((id) => { - if (id !== group.canonicalId) delete params[id] - }) + sourceIds.forEach((id) => delete params[id]) if (chosen !== undefined) { params[group.canonicalId] = chosen @@ -420,6 +436,8 @@ export class Serializer { const isTriggerContext = block.triggerMode ?? false const isTriggerCategory = blockConfig.category === 'triggers' const canonicalIndex = buildCanonicalIndex(blockConfig.subBlocks || []) + const canonicalModeOverrides = block.data?.canonicalModes + const allValues = buildSubBlockValues(block.subBlocks) // Iterate through the tool's parameters, not the block's subBlocks Object.entries(currentTool.params || {}).forEach(([paramId, paramConfig]) => { @@ -432,11 +450,12 @@ export class Serializer { shouldValidateParam = matchingConfigs.some((subBlockConfig: any) => { const includedByMode = shouldSerializeSubBlock( subBlockConfig, - params, + allValues, displayAdvancedOptions, isTriggerContext, isTriggerCategory, - canonicalIndex + canonicalIndex, + canonicalModeOverrides ) const isRequired = (() => { @@ -458,11 +477,12 @@ export class Serializer { const activeConfig = matchingConfigs.find((config: any) => shouldSerializeSubBlock( config, - params, + allValues, displayAdvancedOptions, isTriggerContext, isTriggerCategory, - canonicalIndex + canonicalIndex, + canonicalModeOverrides ) ) const displayName = activeConfig?.title || paramId