mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-29 00:38:03 -05:00
fix(snapshot): consolidate to use hasWorkflowChanges check (#3051)
* fix(snapshot): consolidate to use hasWorkflowChanges check * Remove debug logs * fix normalization logic * fix serializer for canonical modes
This commit is contained in:
committed by
GitHub
parent
0c0f19c717
commit
9e40342af8
@@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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<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()
|
||||
|
||||
@@ -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.
|
||||
@@ -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<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') {
|
||||
@@ -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({
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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<string, unknown>)).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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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<string, unknown> = {}
|
||||
for (const key of Object.keys(value as Record<string, unknown>).sort()) {
|
||||
sorted[key] = normalizeValue((value as Record<string, unknown>)[key])
|
||||
const normalized = normalizeValue((value as Record<string, unknown>)[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<NormalizedLoop, 'id' | 'nodes' | 'loopType'> = { 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<NormalizedParallel, 'id' | 'nodes' | 'parallelType'> = {
|
||||
|
||||
// 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<string, unknown>
|
||||
}
|
||||
|
||||
/** 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<string, unknown>
|
||||
subBlocks: Record<string, NormalizedSubBlock>
|
||||
}
|
||||
|
||||
/** Normalized subBlock structure */
|
||||
interface NormalizedSubBlock {
|
||||
[key: string]: unknown
|
||||
value: unknown
|
||||
}
|
||||
|
||||
/** Normalized workflow state structure */
|
||||
export interface NormalizedWorkflowState {
|
||||
blocks: Record<string, NormalizedBlock>
|
||||
edges: Array<{
|
||||
source: string
|
||||
sourceHandle?: string | null
|
||||
target: string
|
||||
targetHandle?: string | null
|
||||
}>
|
||||
loops: Record<string, unknown>
|
||||
parallels: Record<string, unknown>
|
||||
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<string, unknown>
|
||||
/** Normalized data object excluding width/height/nodes/distribution */
|
||||
normalizedData: Record<string, unknown>
|
||||
/** SubBlocks map */
|
||||
subBlocks: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, unknown> | undefined
|
||||
): Record<string, unknown> {
|
||||
const normalized: Record<string, unknown> = {}
|
||||
|
||||
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<string, unknown> | 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<string, unknown>): Record<string, unknown> {
|
||||
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<string, NormalizedBlock> = {}
|
||||
|
||||
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<string, NormalizedSubBlock> = {}
|
||||
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<string, unknown>)
|
||||
|
||||
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<string, unknown> = {}
|
||||
for (const [loopId, loop] of Object.entries(state.loops || {})) {
|
||||
normalizedLoops[loopId] = normalizeValue(normalizeLoop(loop))
|
||||
}
|
||||
|
||||
// 4. Normalize parallels using specialized normalizeParallel
|
||||
const normalizedParallels: Record<string, unknown> = {}
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<typeof buildCanonicalIndex>
|
||||
canonicalIndex: ReturnType<typeof buildCanonicalIndex>,
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user