22
33import { useCallback , useEffect , useMemo , useRef , useState } from 'react'
44import { createLogger } from '@sim/logger'
5+ import { toError } from '@sim/utils/errors'
56import { useQueryClient } from '@tanstack/react-query'
67import { useParams } from 'next/navigation'
78import {
@@ -21,6 +22,8 @@ import { getBaseUrl } from '@/lib/core/utils/urls'
2122import { getInputFormatExample as getInputFormatExampleUtil } from '@/lib/workflows/operations/deployment-utils'
2223import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
2324import { CreateApiKeyModal } from '@/app/workspace/[workspaceId]/settings/components/api-keys/components'
25+ import { syncLocalDraftFromServer } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/sync-local-draft'
26+ import type { DeployReadiness } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-deploy-readiness'
2427import { runPreDeployChecks } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-predeploy-checks'
2528import { startsWithUuid } from '@/executor/constants'
2629import { useA2AAgentByWorkflow } from '@/hooks/queries/a2a/agents'
@@ -40,6 +43,7 @@ import { useWorkflowMap } from '@/hooks/queries/workflows'
4043import { useWorkspaceSettings } from '@/hooks/queries/workspace'
4144import { usePermissionConfig } from '@/hooks/use-permission-config'
4245import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
46+ import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
4347import { mergeSubblockState } from '@/stores/workflows/utils'
4448import { useWorkflowStore } from '@/stores/workflows/workflow/store'
4549import type { WorkflowState } from '@/stores/workflows/workflow/types'
@@ -62,6 +66,8 @@ interface DeployModalProps {
6266 needsRedeployment : boolean
6367 deployedState ?: WorkflowState | null
6468 isLoadingDeployedState : boolean
69+ deployReadiness : DeployReadiness
70+ isDeploymentSettling : boolean
6571}
6672
6773interface WorkflowDeploymentInfoUI {
@@ -84,6 +90,8 @@ export function DeployModal({
8490 needsRedeployment,
8591 deployedState,
8692 isLoadingDeployedState,
93+ deployReadiness,
94+ isDeploymentSettling,
8795} : DeployModalProps ) {
8896 const queryClient = useQueryClient ( )
8997 const params = useParams ( )
@@ -97,6 +105,7 @@ export function DeployModal({
97105 const [ chatSubmitting , setChatSubmitting ] = useState ( false )
98106 const [ deployError , setDeployError ] = useState < string | null > ( null )
99107 const [ deployWarnings , setDeployWarnings ] = useState < string [ ] > ( [ ] )
108+ const [ isFinalizingDeploy , setIsFinalizingDeploy ] = useState ( false )
100109 const [ isChatFormValid , setIsChatFormValid ] = useState ( false )
101110 const [ selectedStreamingOutputs , setSelectedStreamingOutputs ] = useState < string [ ] > ( [ ] )
102111
@@ -112,6 +121,7 @@ export function DeployModal({
112121
113122 const [ chatSuccess , setChatSuccess ] = useState ( false )
114123 const chatSuccessTimeoutRef = useRef < ReturnType < typeof setTimeout > | null > ( null )
124+ const deployActionIdRef = useRef ( 0 )
115125
116126 const [ isCreateKeyModalOpen , setIsCreateKeyModalOpen ] = useState ( false )
117127 const [ isApiInfoModalOpen , setIsApiInfoModalOpen ] = useState ( false )
@@ -176,6 +186,30 @@ export function DeployModal({
176186
177187 const versions = versionsData ?. versions ?? [ ]
178188
189+ const isWorkflowStillActive = useCallback ( ( targetWorkflowId : string ) => {
190+ return useWorkflowRegistry . getState ( ) . activeWorkflowId === targetWorkflowId
191+ } , [ ] )
192+
193+ const syncDraftAfterDeploy = useCallback ( async ( ) : Promise < string | null > => {
194+ if ( ! workflowId ) return null
195+
196+ try {
197+ await syncLocalDraftFromServer ( workflowId )
198+ return null
199+ } catch ( error ) {
200+ logger . warn ( 'Workflow deployed, but local draft sync failed' , {
201+ workflowId,
202+ error : toError ( error ) . message ,
203+ } )
204+ return 'Deployment succeeded, but local sync failed. Refresh if the status looks stale.'
205+ }
206+ } , [ workflowId ] )
207+
208+ useEffect ( ( ) => {
209+ deployActionIdRef . current += 1
210+ setIsFinalizingDeploy ( false )
211+ } , [ workflowId ] )
212+
179213 const getApiKeyLabel = useCallback (
180214 ( value ?: string | null ) => {
181215 if ( value && value . trim ( ) . length > 0 ) {
@@ -289,18 +323,38 @@ export function DeployModal({
289323 setDeployError ( null )
290324 setDeployWarnings ( [ ] )
291325
326+ let actionId : number | null = null
292327 try {
293- // Deploy mutation handles query invalidation in its onSuccess callback
294- const result = await deployMutation . mutateAsync ( { workflowId } )
295- if ( result . warnings && result . warnings . length > 0 ) {
296- setDeployWarnings ( result . warnings )
328+ if ( ! ( await deployReadiness . waitUntilReady ( ) ) ) {
329+ if ( ! isWorkflowStillActive ( workflowId ) ) return
330+ setDeployError ( deployReadiness . tooltip )
331+ return
297332 }
333+ if ( ! isWorkflowStillActive ( workflowId ) ) return
334+
335+ actionId = deployActionIdRef . current + 1
336+ deployActionIdRef . current = actionId
337+ setIsFinalizingDeploy ( true )
338+ let result : Awaited < ReturnType < typeof deployMutation . mutateAsync > >
339+ let syncWarning : string | null
340+ try {
341+ result = await deployMutation . mutateAsync ( { workflowId } )
342+ syncWarning = await syncDraftAfterDeploy ( )
343+ } finally {
344+ if ( deployActionIdRef . current === actionId && isWorkflowStillActive ( workflowId ) ) {
345+ setIsFinalizingDeploy ( false )
346+ }
347+ }
348+ if ( ! isWorkflowStillActive ( workflowId ) || deployActionIdRef . current !== actionId ) return
349+ setDeployWarnings ( [ ...( result . warnings || [ ] ) , ...( syncWarning ? [ syncWarning ] : [ ] ) ] )
298350 } catch ( error : unknown ) {
351+ if ( actionId !== null && deployActionIdRef . current !== actionId ) return
352+ if ( ! isWorkflowStillActive ( workflowId ) ) return
299353 logger . error ( 'Error deploying workflow:' , { error } )
300- const errorMessage = error instanceof Error ? error . message : 'Failed to deploy workflow'
354+ const errorMessage = toError ( error ) . message || 'Failed to deploy workflow'
301355 setDeployError ( errorMessage )
302356 }
303- } , [ workflowId , deployMutation ] )
357+ } , [ workflowId , deployMutation , deployReadiness , syncDraftAfterDeploy , isWorkflowStillActive ] )
304358
305359 const handlePromoteToLive = useCallback (
306360 async ( version : number ) => {
@@ -310,34 +364,46 @@ export function DeployModal({
310364
311365 try {
312366 const result = await activateVersionMutation . mutateAsync ( { workflowId, version } )
367+ if ( ! isWorkflowStillActive ( workflowId ) ) return
313368 if ( result . warnings && result . warnings . length > 0 ) {
314369 setDeployWarnings ( result . warnings )
315370 }
316371 } catch ( error ) {
372+ if ( ! isWorkflowStillActive ( workflowId ) ) return
317373 logger . error ( 'Error promoting version:' , { error } )
318374 throw error
319375 }
320376 } ,
321- [ workflowId , activateVersionMutation ]
377+ [ workflowId , activateVersionMutation , isWorkflowStillActive ]
322378 )
323379
324380 const handleUndeploy = useCallback ( async ( ) => {
325381 if ( ! workflowId ) return
326382
327383 try {
328384 await undeployMutation . mutateAsync ( { workflowId } )
385+ if ( ! isWorkflowStillActive ( workflowId ) ) return
329386 setShowUndeployConfirm ( false )
330387 onOpenChange ( false )
331388 } catch ( error : unknown ) {
389+ if ( ! isWorkflowStillActive ( workflowId ) ) return
332390 logger . error ( 'Error undeploying workflow:' , { error } )
333391 }
334- } , [ workflowId , undeployMutation , onOpenChange ] )
392+ } , [ workflowId , undeployMutation , onOpenChange , isWorkflowStillActive ] )
335393
336394 const handleRedeploy = useCallback ( async ( ) => {
337395 if ( ! workflowId ) return
338396
339397 setDeployError ( null )
340398 setDeployWarnings ( [ ] )
399+ let actionId : number | null = null
400+
401+ if ( ! ( await deployReadiness . waitUntilReady ( ) ) ) {
402+ if ( ! isWorkflowStillActive ( workflowId ) ) return
403+ setDeployError ( deployReadiness . tooltip )
404+ return
405+ }
406+ if ( ! isWorkflowStillActive ( workflowId ) ) return
341407
342408 const { blocks, edges, loops, parallels } = useWorkflowStore . getState ( )
343409 const liveBlocks = mergeSubblockState ( blocks , workflowId )
@@ -354,16 +420,29 @@ export function DeployModal({
354420 }
355421
356422 try {
357- const result = await deployMutation . mutateAsync ( { workflowId } )
358- if ( result . warnings && result . warnings . length > 0 ) {
359- setDeployWarnings ( result . warnings )
423+ actionId = deployActionIdRef . current + 1
424+ deployActionIdRef . current = actionId
425+ setIsFinalizingDeploy ( true )
426+ let result : Awaited < ReturnType < typeof deployMutation . mutateAsync > >
427+ let syncWarning : string | null
428+ try {
429+ result = await deployMutation . mutateAsync ( { workflowId } )
430+ syncWarning = await syncDraftAfterDeploy ( )
431+ } finally {
432+ if ( deployActionIdRef . current === actionId && isWorkflowStillActive ( workflowId ) ) {
433+ setIsFinalizingDeploy ( false )
434+ }
360435 }
436+ if ( ! isWorkflowStillActive ( workflowId ) || deployActionIdRef . current !== actionId ) return
437+ setDeployWarnings ( [ ...( result . warnings || [ ] ) , ...( syncWarning ? [ syncWarning ] : [ ] ) ] )
361438 } catch ( error : unknown ) {
439+ if ( actionId !== null && deployActionIdRef . current !== actionId ) return
440+ if ( ! isWorkflowStillActive ( workflowId ) ) return
362441 logger . error ( 'Error redeploying workflow:' , { error } )
363- const errorMessage = error instanceof Error ? error . message : 'Failed to redeploy workflow'
442+ const errorMessage = toError ( error ) . message || 'Failed to redeploy workflow'
364443 setDeployError ( errorMessage )
365444 }
366- } , [ workflowId , deployMutation ] )
445+ } , [ workflowId , deployMutation , deployReadiness , syncDraftAfterDeploy , isWorkflowStillActive ] )
367446
368447 const handleCloseModal = useCallback ( ( ) => {
369448 setChatSubmitting ( false )
@@ -456,7 +535,7 @@ export function DeployModal({
456535 // deleteTrigger?.click()
457536 // }, [])
458537
459- const isSubmitting = deployMutation . isPending
538+ const isSubmitting = deployMutation . isPending || isFinalizingDeploy
460539 const isUndeploying = undeployMutation . isPending
461540
462541 return (
@@ -610,6 +689,8 @@ export function DeployModal({
610689 needsRedeployment = { needsRedeployment }
611690 isSubmitting = { isSubmitting }
612691 isUndeploying = { isUndeploying }
692+ deployReadiness = { deployReadiness }
693+ isDeploymentSettling = { isDeploymentSettling }
613694 onDeploy = { onDeploy }
614695 onRedeploy = { handleRedeploy }
615696 onUndeploy = { ( ) => setShowUndeployConfirm ( true ) }
@@ -922,12 +1003,13 @@ export function DeployModal({
9221003
9231004interface StatusBadgeProps {
9241005 isWarning : boolean
1006+ isSyncing ?: boolean
9251007}
9261008
927- function StatusBadge ( { isWarning } : StatusBadgeProps ) {
928- const label = isWarning ? 'Update deployment' : 'Live'
1009+ function StatusBadge ( { isWarning, isSyncing = false } : StatusBadgeProps ) {
1010+ const label = isSyncing ? 'Syncing changes' : isWarning ? 'Update deployment' : 'Live'
9291011 return (
930- < Badge variant = { isWarning ? 'amber' : 'green' } size = 'lg' dot >
1012+ < Badge variant = { isSyncing || isWarning ? 'amber' : 'green' } size = 'lg' dot >
9311013 { label }
9321014 </ Badge >
9331015 )
@@ -961,6 +1043,8 @@ interface GeneralFooterProps {
9611043 needsRedeployment : boolean
9621044 isSubmitting : boolean
9631045 isUndeploying : boolean
1046+ deployReadiness : DeployReadiness
1047+ isDeploymentSettling : boolean
9641048 onDeploy : ( ) => Promise < void >
9651049 onRedeploy : ( ) => Promise < void >
9661050 onUndeploy : ( ) => void
@@ -971,17 +1055,27 @@ function GeneralFooter({
9711055 needsRedeployment,
9721056 isSubmitting,
9731057 isUndeploying,
1058+ deployReadiness,
1059+ isDeploymentSettling,
9741060 onDeploy,
9751061 onRedeploy,
9761062 onUndeploy,
9771063} : GeneralFooterProps ) {
1064+ const isDeployBlocked =
1065+ deployReadiness . isBlocked || isDeploymentSettling || isSubmitting || isUndeploying
1066+ const syncingLabel = deployReadiness . isSyncing ? deployReadiness . label : 'Syncing...'
1067+ const blockedMessage =
1068+ deployReadiness . isBlocked && ! isDeploymentSettling && ! isSubmitting && ! isUndeploying
1069+ ? deployReadiness . tooltip
1070+ : null
1071+
9781072 if ( ! isDeployed ) {
9791073 return (
9801074 < ModalFooter className = 'items-center justify-between' >
981- < div / >
1075+ < div className = 'max-w-[260px] text-muted-foreground text-xs' > { blockedMessage } </ div >
9821076 < div className = 'flex items-center gap-2' >
983- < Button variant = 'tertiary' onClick = { onDeploy } disabled = { isSubmitting } >
984- { isSubmitting ? 'Deploying...' : 'Deploy' }
1077+ < Button variant = 'tertiary' onClick = { onDeploy } disabled = { isDeployBlocked } >
1078+ { isSubmitting ? 'Deploying...' : isDeploymentSettling ? syncingLabel : 'Deploy' }
9851079 </ Button >
9861080 </ div >
9871081 </ ModalFooter >
@@ -990,14 +1084,19 @@ function GeneralFooter({
9901084
9911085 return (
9921086 < ModalFooter className = 'items-center justify-between' >
993- < StatusBadge isWarning = { needsRedeployment } />
1087+ < div className = 'flex min-w-0 flex-col gap-1' >
1088+ < StatusBadge isWarning = { needsRedeployment } isSyncing = { isDeploymentSettling } />
1089+ { blockedMessage && (
1090+ < div className = 'max-w-[300px] text-muted-foreground text-xs' > { blockedMessage } </ div >
1091+ ) }
1092+ </ div >
9941093 < div className = 'flex items-center gap-2' >
9951094 < Button variant = 'default' onClick = { onUndeploy } disabled = { isUndeploying || isSubmitting } >
9961095 { isUndeploying ? 'Undeploying...' : 'Undeploy' }
9971096 </ Button >
9981097 { needsRedeployment && (
999- < Button variant = 'tertiary' onClick = { onRedeploy } disabled = { isSubmitting || isUndeploying } >
1000- { isSubmitting ? 'Updating...' : 'Update' }
1098+ < Button variant = 'tertiary' onClick = { onRedeploy } disabled = { isDeployBlocked } >
1099+ { isSubmitting ? 'Updating...' : isDeploymentSettling ? syncingLabel : 'Update' }
10011100 </ Button >
10021101 ) }
10031102 </ div >
0 commit comments