Skip to content

Commit da7eca9

Browse files
authored
fix(change-detection): move change detection logic to client-side to prevent unnecessary API calls, consolidate utils (#2576)
* fix(change-detection): move change detection logic to client-side to prevent unnecessary API calls, consolidate utils * added tests * ack PR comments * added isPublished to API response
1 parent 92b2e34 commit da7eca9

File tree

11 files changed

+3085
-466
lines changed

11 files changed

+3085
-466
lines changed

apps/sim/app/api/workflows/[id]/deploy/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
6666
loops: normalizedData.loops,
6767
parallels: normalizedData.parallels,
6868
}
69-
const { hasWorkflowChanged } = await import('@/lib/workflows/utils')
69+
const { hasWorkflowChanged } = await import('@/lib/workflows/comparison')
7070
needsRedeployment = hasWorkflowChanged(currentState as any, active.state as any)
7171
}
7272
}

apps/sim/app/api/workflows/[id]/status/route.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ import { and, desc, eq } from 'drizzle-orm'
33
import type { NextRequest } from 'next/server'
44
import { generateRequestId } from '@/lib/core/utils/request'
55
import { createLogger } from '@/lib/logs/console/logger'
6+
import { hasWorkflowChanged } from '@/lib/workflows/comparison'
67
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
7-
import { hasWorkflowChanged } from '@/lib/workflows/utils'
88
import { validateWorkflowAccess } from '@/app/api/workflows/middleware'
99
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
1010

@@ -69,6 +69,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
6969
return createSuccessResponse({
7070
isDeployed: validation.workflow.isDeployed,
7171
deployedAt: validation.workflow.deployedAt,
72+
isPublished: validation.workflow.isPublished,
7273
needsRedeployment,
7374
})
7475
} catch (error) {
Lines changed: 52 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -1,111 +1,87 @@
1-
import { useEffect, useMemo, useState } from 'react'
2-
import { createLogger } from '@/lib/logs/console/logger'
1+
import { useMemo } from 'react'
2+
import { hasWorkflowChanged } from '@/lib/workflows/comparison'
33
import { useDebounce } from '@/hooks/use-debounce'
4-
import { useOperationQueueStore } from '@/stores/operation-queue/store'
54
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
65
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
76
import type { WorkflowState } from '@/stores/workflows/workflow/types'
87

9-
const logger = createLogger('useChangeDetection')
10-
118
interface UseChangeDetectionProps {
129
workflowId: string | null
1310
deployedState: WorkflowState | null
1411
isLoadingDeployedState: boolean
1512
}
1613

1714
/**
18-
* Hook to detect changes between current workflow state and deployed state
19-
* Uses API-based change detection for accuracy
15+
* Detects meaningful changes between current workflow state and deployed state.
16+
* Performs comparison entirely on the client - no API calls needed.
2017
*/
2118
export function useChangeDetection({
2219
workflowId,
2320
deployedState,
2421
isLoadingDeployedState,
2522
}: UseChangeDetectionProps) {
26-
const [changeDetected, setChangeDetected] = useState(false)
27-
const [blockStructureVersion, setBlockStructureVersion] = useState(0)
28-
const [edgeStructureVersion, setEdgeStructureVersion] = useState(0)
29-
const [subBlockStructureVersion, setSubBlockStructureVersion] = useState(0)
30-
31-
// Get current store state for change detection
32-
const currentBlocks = useWorkflowStore((state) => state.blocks)
33-
const currentEdges = useWorkflowStore((state) => state.edges)
34-
const lastSaved = useWorkflowStore((state) => state.lastSaved)
23+
const blocks = useWorkflowStore((state) => state.blocks)
24+
const edges = useWorkflowStore((state) => state.edges)
25+
const loops = useWorkflowStore((state) => state.loops)
26+
const parallels = useWorkflowStore((state) => state.parallels)
3527
const subBlockValues = useSubBlockStore((state) =>
3628
workflowId ? state.workflowValues[workflowId] : null
3729
)
3830

39-
// Track structure changes
40-
useEffect(() => {
41-
setBlockStructureVersion((version) => version + 1)
42-
}, [currentBlocks])
43-
44-
useEffect(() => {
45-
setEdgeStructureVersion((version) => version + 1)
46-
}, [currentEdges])
31+
// Build current state with subblock values merged into blocks
32+
const currentState = useMemo((): WorkflowState | null => {
33+
if (!workflowId) return null
4734

48-
useEffect(() => {
49-
setSubBlockStructureVersion((version) => version + 1)
50-
}, [subBlockValues])
35+
const blocksWithSubBlocks: WorkflowState['blocks'] = {}
36+
for (const [blockId, block] of Object.entries(blocks)) {
37+
const blockSubValues = subBlockValues?.[blockId] || {}
38+
const subBlocks: Record<string, any> = {}
5139

52-
// Reset version counters when workflow changes
53-
useEffect(() => {
54-
setBlockStructureVersion(0)
55-
setEdgeStructureVersion(0)
56-
setSubBlockStructureVersion(0)
57-
}, [workflowId])
58-
59-
// Create trigger for status check
60-
const statusCheckTrigger = useMemo(() => {
61-
return JSON.stringify({
62-
lastSaved: lastSaved ?? 0,
63-
blockVersion: blockStructureVersion,
64-
edgeVersion: edgeStructureVersion,
65-
subBlockVersion: subBlockStructureVersion,
66-
})
67-
}, [lastSaved, blockStructureVersion, edgeStructureVersion, subBlockStructureVersion])
68-
69-
const debouncedStatusCheckTrigger = useDebounce(statusCheckTrigger, 500)
40+
// Merge subblock values into the block's subBlocks structure
41+
for (const [subId, value] of Object.entries(blockSubValues)) {
42+
subBlocks[subId] = { value }
43+
}
7044

71-
useEffect(() => {
72-
// Avoid off-by-one false positives: wait until operation queue is idle
73-
const { operations, isProcessing } = useOperationQueueStore.getState()
74-
const hasPendingOps =
75-
isProcessing || operations.some((op) => op.status === 'pending' || op.status === 'processing')
45+
// Also include existing subBlocks from the block itself
46+
if (block.subBlocks) {
47+
for (const [subId, subBlock] of Object.entries(block.subBlocks)) {
48+
if (!subBlocks[subId]) {
49+
subBlocks[subId] = subBlock
50+
} else {
51+
subBlocks[subId] = { ...subBlock, value: subBlocks[subId].value }
52+
}
53+
}
54+
}
7655

77-
if (!workflowId || !deployedState) {
78-
setChangeDetected(false)
79-
return
56+
blocksWithSubBlocks[blockId] = {
57+
...block,
58+
subBlocks,
59+
}
8060
}
8161

82-
if (isLoadingDeployedState || hasPendingOps) {
83-
return
62+
return {
63+
blocks: blocksWithSubBlocks,
64+
edges,
65+
loops,
66+
parallels,
8467
}
68+
}, [workflowId, blocks, edges, loops, parallels, subBlockValues])
8569

86-
// Use the workflow status API to get accurate change detection
87-
// This uses the same logic as the deployment API (reading from normalized tables)
88-
const checkForChanges = async () => {
89-
try {
90-
const response = await fetch(`/api/workflows/${workflowId}/status`)
91-
if (response.ok) {
92-
const data = await response.json()
93-
setChangeDetected(data.needsRedeployment || false)
94-
} else {
95-
logger.error('Failed to fetch workflow status:', response.status, response.statusText)
96-
setChangeDetected(false)
97-
}
98-
} catch (error) {
99-
logger.error('Error fetching workflow status:', error)
100-
setChangeDetected(false)
101-
}
70+
// Compute change detection with debouncing for performance
71+
const rawChangeDetected = useMemo(() => {
72+
if (!currentState || !deployedState || isLoadingDeployedState) {
73+
return false
10274
}
75+
return hasWorkflowChanged(currentState, deployedState)
76+
}, [currentState, deployedState, isLoadingDeployedState])
10377

104-
checkForChanges()
105-
}, [workflowId, deployedState, debouncedStatusCheckTrigger, isLoadingDeployedState])
78+
// Debounce to avoid UI flicker during rapid edits
79+
const changeDetected = useDebounce(rawChangeDetected, 300)
10680

107-
return {
108-
changeDetected,
109-
setChangeDetected,
81+
const setChangeDetected = () => {
82+
// No-op: change detection is now computed, not stateful
83+
// Kept for API compatibility
11084
}
85+
86+
return { changeDetected, setChangeDetected }
11187
}

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -885,7 +885,8 @@ export function ToolInput({
885885
block.type === 'knowledge' ||
886886
block.type === 'function') &&
887887
block.type !== 'evaluator' &&
888-
block.type !== 'mcp'
888+
block.type !== 'mcp' &&
889+
block.type !== 'file'
889890
)
890891

891892
const value = isPreview ? previewValue : storeValue

apps/sim/lib/logs/execution/snapshot/service.ts

Lines changed: 15 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@ import type {
1111
WorkflowExecutionSnapshotInsert,
1212
WorkflowState,
1313
} from '@/lib/logs/types'
14+
import {
15+
normalizedStringify,
16+
normalizeEdge,
17+
normalizeValue,
18+
sortEdges,
19+
} from '@/lib/workflows/comparison'
1420

1521
const logger = createLogger('SnapshotService')
1622

@@ -45,7 +51,7 @@ export class SnapshotService implements ISnapshotService {
4551
id: uuidv4(),
4652
workflowId,
4753
stateHash,
48-
stateData: state, // Full state with positions, subblock values, etc.
54+
stateData: state,
4955
}
5056

5157
const [newSnapshot] = await db
@@ -107,7 +113,7 @@ export class SnapshotService implements ISnapshotService {
107113

108114
computeStateHash(state: WorkflowState): string {
109115
const normalizedState = this.normalizeStateForHashing(state)
110-
const stateString = this.normalizedStringify(normalizedState)
116+
const stateString = normalizedStringify(normalizedState)
111117
return createHash('sha256').update(stateString).digest('hex')
112118
}
113119

@@ -126,23 +132,10 @@ export class SnapshotService implements ISnapshotService {
126132
}
127133

128134
private normalizeStateForHashing(state: WorkflowState): any {
129-
// Use the same normalization logic as hasWorkflowChanged for consistency
130-
131-
// 1. Normalize edges (same as hasWorkflowChanged)
132-
const normalizedEdges = (state.edges || [])
133-
.map((edge) => ({
134-
source: edge.source,
135-
sourceHandle: edge.sourceHandle,
136-
target: edge.target,
137-
targetHandle: edge.targetHandle,
138-
}))
139-
.sort((a, b) =>
140-
`${a.source}-${a.sourceHandle}-${a.target}-${a.targetHandle}`.localeCompare(
141-
`${b.source}-${b.sourceHandle}-${b.target}-${b.targetHandle}`
142-
)
143-
)
135+
// 1. Normalize and sort edges
136+
const normalizedEdges = sortEdges((state.edges || []).map(normalizeEdge))
144137

145-
// 2. Normalize blocks (same as hasWorkflowChanged)
138+
// 2. Normalize blocks
146139
const normalizedBlocks: Record<string, any> = {}
147140

148141
for (const [blockId, block] of Object.entries(state.blocks || {})) {
@@ -155,18 +148,16 @@ export class SnapshotService implements ISnapshotService {
155148
...dataRest
156149
} = blockWithoutLayoutFields.data || {}
157150

158-
// Handle subBlocks with detailed comparison (same as hasWorkflowChanged)
151+
// Normalize subBlocks
159152
const subBlocks = blockWithoutLayoutFields.subBlocks || {}
160153
const normalizedSubBlocks: Record<string, any> = {}
161154

162155
for (const [subBlockId, subBlock] of Object.entries(subBlocks)) {
163-
// Normalize value with special handling for null/undefined
164156
const value = subBlock.value ?? null
165157

166158
normalizedSubBlocks[subBlockId] = {
167159
type: subBlock.type,
168-
value: this.normalizeValue(value),
169-
// Include other properties except value
160+
value: normalizeValue(value),
170161
...Object.fromEntries(
171162
Object.entries(subBlock).filter(([key]) => key !== 'value' && key !== 'type')
172163
),
@@ -183,12 +174,12 @@ export class SnapshotService implements ISnapshotService {
183174
// 3. Normalize loops and parallels
184175
const normalizedLoops: Record<string, any> = {}
185176
for (const [loopId, loop] of Object.entries(state.loops || {})) {
186-
normalizedLoops[loopId] = this.normalizeValue(loop)
177+
normalizedLoops[loopId] = normalizeValue(loop)
187178
}
188179

189180
const normalizedParallels: Record<string, any> = {}
190181
for (const [parallelId, parallel] of Object.entries(state.parallels || {})) {
191-
normalizedParallels[parallelId] = this.normalizeValue(parallel)
182+
normalizedParallels[parallelId] = normalizeValue(parallel)
192183
}
193184

194185
return {
@@ -198,46 +189,6 @@ export class SnapshotService implements ISnapshotService {
198189
parallels: normalizedParallels,
199190
}
200191
}
201-
202-
private normalizeValue(value: any): any {
203-
// Handle null/undefined consistently
204-
if (value === null || value === undefined) return null
205-
206-
// Handle arrays
207-
if (Array.isArray(value)) {
208-
return value.map((item) => this.normalizeValue(item))
209-
}
210-
211-
// Handle objects
212-
if (typeof value === 'object') {
213-
const normalized: Record<string, any> = {}
214-
for (const [key, val] of Object.entries(value)) {
215-
normalized[key] = this.normalizeValue(val)
216-
}
217-
return normalized
218-
}
219-
220-
// Handle primitives
221-
return value
222-
}
223-
224-
private normalizedStringify(obj: any): string {
225-
if (obj === null || obj === undefined) return 'null'
226-
if (typeof obj === 'string') return `"${obj}"`
227-
if (typeof obj === 'number' || typeof obj === 'boolean') return String(obj)
228-
229-
if (Array.isArray(obj)) {
230-
return `[${obj.map((item) => this.normalizedStringify(item)).join(',')}]`
231-
}
232-
233-
if (typeof obj === 'object') {
234-
const keys = Object.keys(obj).sort()
235-
const pairs = keys.map((key) => `"${key}":${this.normalizedStringify(obj[key])}`)
236-
return `{${pairs.join(',')}}`
237-
}
238-
239-
return String(obj)
240-
}
241192
}
242193

243194
export const snapshotService = new SnapshotService()

0 commit comments

Comments
 (0)