Skip to content

Commit 395a61d

Browse files
waleedlatif1claude
andauthored
fix(deploy): consolidate deployment detection into single source of truth (#3606)
* fix(deploy): consolidate deployment detection into single source of truth * fix(deploy): address PR review feedback - Remove redundant isLoading check (subset of isPending in RQ v5) - Make deployedState prop nullable to avoid non-null assertion - Destructure mutateAsync to eliminate ESLint suppression - Guard debounced invalidation against stale workflowId on switch * fix(deploy): clean up self-explanatory comments and fix unstable mutation dep - Remove self-explanatory comments in deploy.tsx, tool-input.tsx, use-child-workflow.ts - Tighten non-obvious comments in use-change-detection.ts - Destructure mutate/isPending in WorkflowToolDeployBadge to avoid unstable mutation object in useCallback deps (TanStack Query no-unstable-deps pattern) * lint * fix(deploy): skip expensive state merge when deployedState is null Avoid running mergeSubblockStateWithValues on every render for non-deployed workflows where changeDetected always returns false. * fix(deploy): add missing workflow table import in deploy route Pre-existing type error — workflow table was used but not imported. * fix(deploy): forward AbortSignal in fetchDeployedWorkflowState Match the pattern used by all other fetch helpers in the file so in-flight requests are cancelled on component unmount or query re-trigger. * perf(deploy): parallelize all DB queries in checkNeedsRedeployment All three queries (active version, normalized data, workflow variables) now run concurrently via Promise.all, saving one DB round trip on the common path. * fix(deploy): use sequential-then-parallel pattern in checkNeedsRedeployment * fix(deploy): use client-side comparison for editor header, remove server polling The lastSaved-based server polling was triggering API calls on every local store mutation (before socket persistence), wasting requests and checking stale DB state. Revert the editor header to pure client-side hasWorkflowChanged comparison — zero network during editing, instant badge updates. Child workflow badges still use server-side useDeploymentInfo (they don't have Zustand state). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(deploy): suppress transient Update flash during deployed state refetch Guard change detection on isFetching (not just isLoading) so the comparison is suppressed during background refetches after mutations, preventing a brief Update→Live badge flicker. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 680c9cd commit 395a61d

File tree

15 files changed

+191
-504
lines changed

15 files changed

+191
-504
lines changed

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

Lines changed: 7 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { db, workflow, workflowDeploymentVersion } from '@sim/db'
22
import { createLogger } from '@sim/logger'
3-
import { and, desc, eq } from 'drizzle-orm'
3+
import { and, eq } from 'drizzle-orm'
44
import type { NextRequest } from 'next/server'
55
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
66
import { generateRequestId } from '@/lib/core/utils/request'
@@ -22,8 +22,11 @@ import {
2222
validateWorkflowSchedules,
2323
} from '@/lib/workflows/schedules'
2424
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
25-
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
26-
import type { WorkflowState } from '@/stores/workflows/workflow/types'
25+
import {
26+
checkNeedsRedeployment,
27+
createErrorResponse,
28+
createSuccessResponse,
29+
} from '@/app/api/workflows/utils'
2730

2831
const logger = createLogger('WorkflowDeployAPI')
2932

@@ -55,43 +58,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
5558
})
5659
}
5760

58-
let needsRedeployment = false
59-
const [active] = await db
60-
.select({ state: workflowDeploymentVersion.state })
61-
.from(workflowDeploymentVersion)
62-
.where(
63-
and(
64-
eq(workflowDeploymentVersion.workflowId, id),
65-
eq(workflowDeploymentVersion.isActive, true)
66-
)
67-
)
68-
.orderBy(desc(workflowDeploymentVersion.createdAt))
69-
.limit(1)
70-
71-
if (active?.state) {
72-
const { loadWorkflowFromNormalizedTables } = await import('@/lib/workflows/persistence/utils')
73-
const normalizedData = await loadWorkflowFromNormalizedTables(id)
74-
if (normalizedData) {
75-
const [workflowRecord] = await db
76-
.select({ variables: workflow.variables })
77-
.from(workflow)
78-
.where(eq(workflow.id, id))
79-
.limit(1)
80-
81-
const currentState = {
82-
blocks: normalizedData.blocks,
83-
edges: normalizedData.edges,
84-
loops: normalizedData.loops,
85-
parallels: normalizedData.parallels,
86-
variables: workflowRecord?.variables || {},
87-
}
88-
const { hasWorkflowChanged } = await import('@/lib/workflows/comparison')
89-
needsRedeployment = hasWorkflowChanged(
90-
currentState as WorkflowState,
91-
active.state as WorkflowState
92-
)
93-
}
94-
}
61+
const needsRedeployment = await checkNeedsRedeployment(id)
9562

9663
logger.info(`[${requestId}] Successfully retrieved deployment info: ${id}`)
9764

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

Lines changed: 8 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
1-
import { db, workflow, workflowDeploymentVersion } from '@sim/db'
21
import { createLogger } from '@sim/logger'
3-
import { and, desc, eq } from 'drizzle-orm'
42
import type { NextRequest } from 'next/server'
53
import { generateRequestId } from '@/lib/core/utils/request'
6-
import { hasWorkflowChanged } from '@/lib/workflows/comparison'
7-
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
84
import { validateWorkflowAccess } from '@/app/api/workflows/middleware'
9-
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
10-
import type { WorkflowState } from '@/stores/workflows/workflow/types'
5+
import {
6+
checkNeedsRedeployment,
7+
createErrorResponse,
8+
createSuccessResponse,
9+
} from '@/app/api/workflows/utils'
1110

1211
const logger = createLogger('WorkflowStatusAPI')
1312

@@ -23,54 +22,9 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
2322
return createErrorResponse(validation.error.message, validation.error.status)
2423
}
2524

26-
let needsRedeployment = false
27-
28-
if (validation.workflow.isDeployed) {
29-
const normalizedData = await loadWorkflowFromNormalizedTables(id)
30-
31-
if (!normalizedData) {
32-
return createSuccessResponse({
33-
isDeployed: validation.workflow.isDeployed,
34-
deployedAt: validation.workflow.deployedAt,
35-
isPublished: validation.workflow.isPublished,
36-
needsRedeployment: false,
37-
})
38-
}
39-
40-
const [workflowRecord] = await db
41-
.select({ variables: workflow.variables })
42-
.from(workflow)
43-
.where(eq(workflow.id, id))
44-
.limit(1)
45-
46-
const currentState = {
47-
blocks: normalizedData.blocks,
48-
edges: normalizedData.edges,
49-
loops: normalizedData.loops,
50-
parallels: normalizedData.parallels,
51-
variables: workflowRecord?.variables || {},
52-
lastSaved: Date.now(),
53-
}
54-
55-
const [active] = await db
56-
.select({ state: workflowDeploymentVersion.state })
57-
.from(workflowDeploymentVersion)
58-
.where(
59-
and(
60-
eq(workflowDeploymentVersion.workflowId, id),
61-
eq(workflowDeploymentVersion.isActive, true)
62-
)
63-
)
64-
.orderBy(desc(workflowDeploymentVersion.createdAt))
65-
.limit(1)
66-
67-
if (active?.state) {
68-
needsRedeployment = hasWorkflowChanged(
69-
currentState as WorkflowState,
70-
active.state as WorkflowState
71-
)
72-
}
73-
}
25+
const needsRedeployment = validation.workflow.isDeployed
26+
? await checkNeedsRedeployment(id)
27+
: false
7428

7529
return createSuccessResponse({
7630
isDeployed: validation.workflow.isDeployed,

apps/sim/app/api/workflows/utils.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
1+
import { db, workflow, workflowDeploymentVersion } from '@sim/db'
12
import { createLogger } from '@sim/logger'
3+
import { and, desc, eq } from 'drizzle-orm'
24
import { NextResponse } from 'next/server'
5+
import { hasWorkflowChanged } from '@/lib/workflows/comparison'
6+
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
37
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
8+
import type { WorkflowState } from '@/stores/workflows/workflow/types'
49

510
const logger = createLogger('WorkflowUtils')
611

@@ -18,6 +23,50 @@ export function createSuccessResponse(data: any) {
1823
return NextResponse.json(data)
1924
}
2025

26+
/**
27+
* Checks whether a deployed workflow has changes that require redeployment.
28+
* Compares the current persisted state (from normalized tables) against the
29+
* active deployment version state.
30+
*
31+
* This is the single source of truth for redeployment detection — used by
32+
* both the /deploy and /status endpoints to ensure consistent results.
33+
*/
34+
export async function checkNeedsRedeployment(workflowId: string): Promise<boolean> {
35+
const [active] = await db
36+
.select({ state: workflowDeploymentVersion.state })
37+
.from(workflowDeploymentVersion)
38+
.where(
39+
and(
40+
eq(workflowDeploymentVersion.workflowId, workflowId),
41+
eq(workflowDeploymentVersion.isActive, true)
42+
)
43+
)
44+
.orderBy(desc(workflowDeploymentVersion.createdAt))
45+
.limit(1)
46+
47+
if (!active?.state) return false
48+
49+
const [normalizedData, [workflowRecord]] = await Promise.all([
50+
loadWorkflowFromNormalizedTables(workflowId),
51+
db
52+
.select({ variables: workflow.variables })
53+
.from(workflow)
54+
.where(eq(workflow.id, workflowId))
55+
.limit(1),
56+
])
57+
if (!normalizedData) return false
58+
59+
const currentState = {
60+
blocks: normalizedData.blocks,
61+
edges: normalizedData.edges,
62+
loops: normalizedData.loops,
63+
parallels: normalizedData.parallels,
64+
variables: workflowRecord?.variables || {},
65+
}
66+
67+
return hasWorkflowChanged(currentState as WorkflowState, active.state as WorkflowState)
68+
}
69+
2170
/**
2271
* Verifies user's workspace permissions using the permissions table
2372
* @param userId User ID to check

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/general.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ const logger = createLogger('GeneralDeploy')
2626

2727
interface GeneralDeployProps {
2828
workflowId: string | null
29-
deployedState: WorkflowState
29+
deployedState?: WorkflowState | null
3030
isLoadingDeployedState: boolean
3131
versions: WorkflowDeploymentVersionResponse[]
3232
versionsLoading: boolean

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import { startsWithUuid } from '@/executor/constants'
2525
import { useA2AAgentByWorkflow } from '@/hooks/queries/a2a/agents'
2626
import { useApiKeys } from '@/hooks/queries/api-keys'
2727
import {
28-
deploymentKeys,
28+
invalidateDeploymentQueries,
2929
useActivateDeploymentVersion,
3030
useChatDeploymentInfo,
3131
useDeploymentInfo,
@@ -59,9 +59,8 @@ interface DeployModalProps {
5959
workflowId: string | null
6060
isDeployed: boolean
6161
needsRedeployment: boolean
62-
deployedState: WorkflowState
62+
deployedState?: WorkflowState | null
6363
isLoadingDeployedState: boolean
64-
refetchDeployedState: () => Promise<void>
6564
}
6665

6766
interface WorkflowDeploymentInfoUI {
@@ -84,7 +83,6 @@ export function DeployModal({
8483
needsRedeployment,
8584
deployedState,
8685
isLoadingDeployedState,
87-
refetchDeployedState,
8886
}: DeployModalProps) {
8987
const queryClient = useQueryClient()
9088
const { navigateToSettings } = useSettingsNavigation()
@@ -298,17 +296,17 @@ export function DeployModal({
298296
setDeployWarnings([])
299297

300298
try {
299+
// Deploy mutation handles query invalidation in its onSuccess callback
301300
const result = await deployMutation.mutateAsync({ workflowId, deployChatEnabled: false })
302301
if (result.warnings && result.warnings.length > 0) {
303302
setDeployWarnings(result.warnings)
304303
}
305-
await refetchDeployedState()
306304
} catch (error: unknown) {
307305
logger.error('Error deploying workflow:', { error })
308306
const errorMessage = error instanceof Error ? error.message : 'Failed to deploy workflow'
309307
setDeployError(errorMessage)
310308
}
311-
}, [workflowId, deployMutation, refetchDeployedState])
309+
}, [workflowId, deployMutation])
312310

313311
const handlePromoteToLive = useCallback(
314312
async (version: number) => {
@@ -321,13 +319,12 @@ export function DeployModal({
321319
if (result.warnings && result.warnings.length > 0) {
322320
setDeployWarnings(result.warnings)
323321
}
324-
await refetchDeployedState()
325322
} catch (error) {
326323
logger.error('Error promoting version:', { error })
327324
throw error
328325
}
329326
},
330-
[workflowId, activateVersionMutation, refetchDeployedState]
327+
[workflowId, activateVersionMutation]
331328
)
332329

333330
const handleUndeploy = useCallback(async () => {
@@ -367,13 +364,12 @@ export function DeployModal({
367364
if (result.warnings && result.warnings.length > 0) {
368365
setDeployWarnings(result.warnings)
369366
}
370-
await refetchDeployedState()
371367
} catch (error: unknown) {
372368
logger.error('Error redeploying workflow:', { error })
373369
const errorMessage = error instanceof Error ? error.message : 'Failed to redeploy workflow'
374370
setDeployError(errorMessage)
375371
}
376-
}, [workflowId, deployMutation, refetchDeployedState])
372+
}, [workflowId, deployMutation])
377373

378374
const handleCloseModal = useCallback(() => {
379375
setChatSubmitting(false)
@@ -385,17 +381,16 @@ export function DeployModal({
385381
const handleChatDeployed = useCallback(async () => {
386382
if (!workflowId) return
387383

388-
queryClient.invalidateQueries({ queryKey: deploymentKeys.versions(workflowId) })
384+
invalidateDeploymentQueries(queryClient, workflowId)
389385

390-
await refetchDeployedState()
391386
useWorkflowRegistry.getState().setWorkflowNeedsRedeployment(workflowId, false)
392387

393388
if (chatSuccessTimeoutRef.current) {
394389
clearTimeout(chatSuccessTimeoutRef.current)
395390
}
396391
setChatSuccess(true)
397392
chatSuccessTimeoutRef.current = setTimeout(() => setChatSuccess(false), 2000)
398-
}, [workflowId, queryClient, refetchDeployedState])
393+
}, [workflowId, queryClient])
399394

400395
const handleRefetchChat = useCallback(async () => {
401396
await refetchChatInfo()

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx

Lines changed: 10 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@ import { Button, Tooltip } from '@/components/emcn'
66
import { DeployModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal'
77
import {
88
useChangeDetection,
9-
useDeployedState,
109
useDeployment,
1110
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks'
1211
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow'
12+
import { useDeployedWorkflowState } from '@/hooks/queries/deployments'
1313
import type { WorkspaceUserPermissions } from '@/hooks/use-user-permissions'
1414
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
1515

@@ -19,10 +19,6 @@ interface DeployProps {
1919
className?: string
2020
}
2121

22-
/**
23-
* Deploy component that handles workflow deployment
24-
* Manages deployed state, change detection, and deployment operations
25-
*/
2622
export function Deploy({ activeWorkflowId, userPermissions, className }: DeployProps) {
2723
const [isModalOpen, setIsModalOpen] = useState(false)
2824
const hydrationPhase = useWorkflowRegistry((state) => state.hydration.phase)
@@ -32,30 +28,28 @@ export function Deploy({ activeWorkflowId, userPermissions, className }: DeployP
3228
hydrationPhase === 'state-loading'
3329
const { hasBlocks } = useCurrentWorkflow()
3430

35-
// Get deployment status from registry
3631
const deploymentStatus = useWorkflowRegistry((state) =>
3732
state.getWorkflowDeploymentStatus(activeWorkflowId)
3833
)
3934
const isDeployed = deploymentStatus?.isDeployed || false
4035

41-
// Fetch and manage deployed state
42-
const { deployedState, isLoadingDeployedState, refetchDeployedState } = useDeployedState({
43-
workflowId: activeWorkflowId,
44-
isDeployed,
45-
isRegistryLoading,
46-
})
36+
const isDeployedStateEnabled = Boolean(activeWorkflowId) && isDeployed && !isRegistryLoading
37+
const {
38+
data: deployedStateData,
39+
isLoading: isLoadingDeployedState,
40+
isFetching: isFetchingDeployedState,
41+
} = useDeployedWorkflowState(activeWorkflowId, { enabled: isDeployedStateEnabled })
42+
const deployedState = isDeployedStateEnabled ? (deployedStateData ?? null) : null
4743

4844
const { changeDetected } = useChangeDetection({
4945
workflowId: activeWorkflowId,
5046
deployedState,
51-
isLoadingDeployedState,
47+
isLoadingDeployedState: isLoadingDeployedState || isFetchingDeployedState,
5248
})
5349

54-
// Handle deployment operations
5550
const { isDeploying, handleDeployClick } = useDeployment({
5651
workflowId: activeWorkflowId,
5752
isDeployed,
58-
refetchDeployedState,
5953
})
6054

6155
const isEmpty = !hasBlocks()
@@ -71,9 +65,6 @@ export function Deploy({ activeWorkflowId, userPermissions, className }: DeployP
7165
}
7266
}
7367

74-
/**
75-
* Get tooltip text based on current state
76-
*/
7768
const getTooltipText = () => {
7869
if (isEmpty) {
7970
return 'Cannot deploy an empty workflow'
@@ -120,9 +111,8 @@ export function Deploy({ activeWorkflowId, userPermissions, className }: DeployP
120111
workflowId={activeWorkflowId}
121112
isDeployed={isDeployed}
122113
needsRedeployment={changeDetected}
123-
deployedState={deployedState!}
114+
deployedState={deployedState}
124115
isLoadingDeployedState={isLoadingDeployedState}
125-
refetchDeployedState={refetchDeployedState}
126116
/>
127117
</>
128118
)

0 commit comments

Comments
 (0)