Skip to content

Commit 2e775e9

Browse files
committed
improvement(deploy): state transitions
1 parent 79ffccc commit 2e775e9

28 files changed

Lines changed: 1854 additions & 167 deletions

File tree

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

Lines changed: 5 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import { db, workflow, workflowDeploymentVersion } from '@sim/db'
1+
import { db, workflowDeploymentVersion } from '@sim/db'
22
import { createLogger } from '@sim/logger'
33
import { and, desc, eq } from 'drizzle-orm'
44
import { NextResponse } from 'next/server'
55
import { hasWorkflowChanged } from '@/lib/workflows/comparison'
6-
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
6+
import { loadWorkflowDeploymentSnapshot } from '@/lib/workflows/persistence/utils'
77
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
88
import type { WorkflowState } from '@/stores/workflows/workflow/types'
99

@@ -46,25 +46,10 @@ export async function checkNeedsRedeployment(workflowId: string): Promise<boolea
4646

4747
if (!active?.state) return false
4848

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
49+
const currentState = await loadWorkflowDeploymentSnapshot(workflowId)
50+
if (!currentState) return false
5851

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)
52+
return hasWorkflowChanged(currentState, active.state as WorkflowState)
6853
}
6954

7055
/**

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

Lines changed: 122 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
44
import { createLogger } from '@sim/logger'
5+
import { toError } from '@sim/utils/errors'
56
import { useQueryClient } from '@tanstack/react-query'
67
import { useParams } from 'next/navigation'
78
import {
@@ -21,6 +22,8 @@ import { getBaseUrl } from '@/lib/core/utils/urls'
2122
import { getInputFormatExample as getInputFormatExampleUtil } from '@/lib/workflows/operations/deployment-utils'
2223
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
2324
import { 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'
2427
import { runPreDeployChecks } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-predeploy-checks'
2528
import { startsWithUuid } from '@/executor/constants'
2629
import { useA2AAgentByWorkflow } from '@/hooks/queries/a2a/agents'
@@ -40,6 +43,7 @@ import { useWorkflowMap } from '@/hooks/queries/workflows'
4043
import { useWorkspaceSettings } from '@/hooks/queries/workspace'
4144
import { usePermissionConfig } from '@/hooks/use-permission-config'
4245
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
46+
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
4347
import { mergeSubblockState } from '@/stores/workflows/utils'
4448
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
4549
import 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

6773
interface 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

9231004
interface 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

Comments
 (0)