Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions apps/sim/lib/logs/execution/snapshot/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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',
},
},
},
},
}
Expand Down
81 changes: 8 additions & 73 deletions apps/sim/lib/logs/execution/snapshot/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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')
}
Expand All @@ -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<string, any> = {}

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<string, any> = {}

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<string, any> = {}
for (const [loopId, loop] of Object.entries(state.loops || {})) {
normalizedLoops[loopId] = normalizeValue(loop)
}

const normalizedParallels: Record<string, any> = {}
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()
108 changes: 32 additions & 76 deletions apps/sim/lib/workflows/comparison/compare.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>
}

/** 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.
Expand Down Expand Up @@ -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 = {
Expand All @@ -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],
Expand All @@ -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<string, unknown> | undefined
const previousSub = previousSubBlocks[subId] as Record<string, unknown> | undefined

if (!currentSub || !previousSub) {
changes.push({
field: subId,
oldValue: previousSub?.value ?? null,
newValue: currentSub?.value ?? null,
oldValue: (previousSub as Record<string, unknown> | undefined)?.value ?? null,
newValue: (currentSub as Record<string, unknown> | 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') {
Expand All @@ -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({
Expand Down
25 changes: 24 additions & 1 deletion apps/sim/lib/workflows/comparison/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Loading