Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
fix(deploy): consolidate deployment detection into single source of t…
…ruth
  • Loading branch information
waleedlatif1 committed Mar 16, 2026
commit 1fcff2bbd95042cd8abbe1befbb2241f059d520a
49 changes: 8 additions & 41 deletions apps/sim/app/api/workflows/[id]/deploy/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { db, workflow, workflowDeploymentVersion } from '@sim/db'
import { db, workflowDeploymentVersion } from '@sim/db'
import { createLogger } from '@sim/logger'
import { and, desc, eq } from 'drizzle-orm'
import { and, eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { generateRequestId } from '@/lib/core/utils/request'
Expand All @@ -22,8 +22,11 @@ import {
validateWorkflowSchedules,
} from '@/lib/workflows/schedules'
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
import {
checkNeedsRedeployment,
createErrorResponse,
createSuccessResponse,
} from '@/app/api/workflows/utils'

const logger = createLogger('WorkflowDeployAPI')

Expand Down Expand Up @@ -55,43 +58,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
})
}

let needsRedeployment = false
const [active] = await db
.select({ state: workflowDeploymentVersion.state })
.from(workflowDeploymentVersion)
.where(
and(
eq(workflowDeploymentVersion.workflowId, id),
eq(workflowDeploymentVersion.isActive, true)
)
)
.orderBy(desc(workflowDeploymentVersion.createdAt))
.limit(1)

if (active?.state) {
const { loadWorkflowFromNormalizedTables } = await import('@/lib/workflows/persistence/utils')
const normalizedData = await loadWorkflowFromNormalizedTables(id)
if (normalizedData) {
const [workflowRecord] = await db
.select({ variables: workflow.variables })
.from(workflow)
.where(eq(workflow.id, id))
.limit(1)

const currentState = {
blocks: normalizedData.blocks,
edges: normalizedData.edges,
loops: normalizedData.loops,
parallels: normalizedData.parallels,
variables: workflowRecord?.variables || {},
}
const { hasWorkflowChanged } = await import('@/lib/workflows/comparison')
needsRedeployment = hasWorkflowChanged(
currentState as WorkflowState,
active.state as WorkflowState
)
}
}
const needsRedeployment = await checkNeedsRedeployment(id)

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

Expand Down
62 changes: 8 additions & 54 deletions apps/sim/app/api/workflows/[id]/status/route.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import { db, workflow, workflowDeploymentVersion } from '@sim/db'
import { createLogger } from '@sim/logger'
import { and, desc, eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { generateRequestId } from '@/lib/core/utils/request'
import { hasWorkflowChanged } from '@/lib/workflows/comparison'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import { validateWorkflowAccess } from '@/app/api/workflows/middleware'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
import {
checkNeedsRedeployment,
createErrorResponse,
createSuccessResponse,
} from '@/app/api/workflows/utils'

const logger = createLogger('WorkflowStatusAPI')

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

let needsRedeployment = false

if (validation.workflow.isDeployed) {
const normalizedData = await loadWorkflowFromNormalizedTables(id)

if (!normalizedData) {
return createSuccessResponse({
isDeployed: validation.workflow.isDeployed,
deployedAt: validation.workflow.deployedAt,
isPublished: validation.workflow.isPublished,
needsRedeployment: false,
})
}

const [workflowRecord] = await db
.select({ variables: workflow.variables })
.from(workflow)
.where(eq(workflow.id, id))
.limit(1)

const currentState = {
blocks: normalizedData.blocks,
edges: normalizedData.edges,
loops: normalizedData.loops,
parallels: normalizedData.parallels,
variables: workflowRecord?.variables || {},
lastSaved: Date.now(),
}

const [active] = await db
.select({ state: workflowDeploymentVersion.state })
.from(workflowDeploymentVersion)
.where(
and(
eq(workflowDeploymentVersion.workflowId, id),
eq(workflowDeploymentVersion.isActive, true)
)
)
.orderBy(desc(workflowDeploymentVersion.createdAt))
.limit(1)

if (active?.state) {
needsRedeployment = hasWorkflowChanged(
currentState as WorkflowState,
active.state as WorkflowState
)
}
}
const needsRedeployment = validation.workflow.isDeployed
? await checkNeedsRedeployment(id)
: false

return createSuccessResponse({
isDeployed: validation.workflow.isDeployed,
Expand Down
45 changes: 45 additions & 0 deletions apps/sim/app/api/workflows/utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { db, workflow, workflowDeploymentVersion } from '@sim/db'
import { createLogger } from '@sim/logger'
import { and, desc, eq } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { hasWorkflowChanged } from '@/lib/workflows/comparison'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
import type { WorkflowState } from '@/stores/workflows/workflow/types'

const logger = createLogger('WorkflowUtils')

Expand All @@ -18,6 +23,46 @@ export function createSuccessResponse(data: any) {
return NextResponse.json(data)
}

/**
* Checks whether a deployed workflow has changes that require redeployment.
* Compares the current persisted state (from normalized tables) against the
* active deployment version state.
*
* This is the single source of truth for redeployment detection — used by
* both the /deploy and /status endpoints to ensure consistent results.
*/
export async function checkNeedsRedeployment(workflowId: string): Promise<boolean> {
const [active] = await db
.select({ state: workflowDeploymentVersion.state })
.from(workflowDeploymentVersion)
.where(
and(
eq(workflowDeploymentVersion.workflowId, workflowId),
eq(workflowDeploymentVersion.isActive, true)
)
)
.orderBy(desc(workflowDeploymentVersion.createdAt))
.limit(1)

if (!active?.state) return false

const [normalizedData, [workflowRecord]] = await Promise.all([
loadWorkflowFromNormalizedTables(workflowId),
db.select({ variables: workflow.variables }).from(workflow).where(eq(workflow.id, workflowId)).limit(1),
])
if (!normalizedData) return false

const currentState = {
blocks: normalizedData.blocks,
edges: normalizedData.edges,
loops: normalizedData.loops,
parallels: normalizedData.parallels,
variables: workflowRecord?.variables || {},
}

return hasWorkflowChanged(currentState as WorkflowState, active.state as WorkflowState)
}

/**
* Verifies user's workspace permissions using the permissions table
* @param userId User ID to check
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import { startsWithUuid } from '@/executor/constants'
import { useA2AAgentByWorkflow } from '@/hooks/queries/a2a/agents'
import { useApiKeys } from '@/hooks/queries/api-keys'
import {
deploymentKeys,
invalidateDeploymentQueries,
useActivateDeploymentVersion,
useChatDeploymentInfo,
useDeploymentInfo,
Expand Down Expand Up @@ -61,7 +61,6 @@ interface DeployModalProps {
needsRedeployment: boolean
deployedState: WorkflowState
isLoadingDeployedState: boolean
refetchDeployedState: () => Promise<void>
}

interface WorkflowDeploymentInfoUI {
Expand All @@ -84,7 +83,6 @@ export function DeployModal({
needsRedeployment,
deployedState,
isLoadingDeployedState,
refetchDeployedState,
}: DeployModalProps) {
const queryClient = useQueryClient()
const { navigateToSettings } = useSettingsNavigation()
Expand Down Expand Up @@ -298,17 +296,17 @@ export function DeployModal({
setDeployWarnings([])

try {
// Deploy mutation handles query invalidation in its onSuccess callback
const result = await deployMutation.mutateAsync({ workflowId, deployChatEnabled: false })
if (result.warnings && result.warnings.length > 0) {
setDeployWarnings(result.warnings)
}
await refetchDeployedState()
} catch (error: unknown) {
logger.error('Error deploying workflow:', { error })
const errorMessage = error instanceof Error ? error.message : 'Failed to deploy workflow'
setDeployError(errorMessage)
}
}, [workflowId, deployMutation, refetchDeployedState])
}, [workflowId, deployMutation])

const handlePromoteToLive = useCallback(
async (version: number) => {
Expand All @@ -321,13 +319,12 @@ export function DeployModal({
if (result.warnings && result.warnings.length > 0) {
setDeployWarnings(result.warnings)
}
await refetchDeployedState()
} catch (error) {
logger.error('Error promoting version:', { error })
throw error
}
},
[workflowId, activateVersionMutation, refetchDeployedState]
[workflowId, activateVersionMutation]
)

const handleUndeploy = useCallback(async () => {
Expand Down Expand Up @@ -367,13 +364,12 @@ export function DeployModal({
if (result.warnings && result.warnings.length > 0) {
setDeployWarnings(result.warnings)
}
await refetchDeployedState()
} catch (error: unknown) {
logger.error('Error redeploying workflow:', { error })
const errorMessage = error instanceof Error ? error.message : 'Failed to redeploy workflow'
setDeployError(errorMessage)
}
}, [workflowId, deployMutation, refetchDeployedState])
}, [workflowId, deployMutation])

const handleCloseModal = useCallback(() => {
setChatSubmitting(false)
Expand All @@ -385,17 +381,16 @@ export function DeployModal({
const handleChatDeployed = useCallback(async () => {
if (!workflowId) return

queryClient.invalidateQueries({ queryKey: deploymentKeys.versions(workflowId) })
invalidateDeploymentQueries(queryClient, workflowId)

await refetchDeployedState()
useWorkflowRegistry.getState().setWorkflowNeedsRedeployment(workflowId, false)

if (chatSuccessTimeoutRef.current) {
clearTimeout(chatSuccessTimeoutRef.current)
}
setChatSuccess(true)
chatSuccessTimeoutRef.current = setTimeout(() => setChatSuccess(false), 2000)
}, [workflowId, queryClient, refetchDeployedState])
}, [workflowId, queryClient])

const handleRefetchChat = useCallback(async () => {
await refetchChatInfo()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ import { Button, Tooltip } from '@/components/emcn'
import { DeployModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal'
import {
useChangeDetection,
useDeployedState,
useDeployment,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks'
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow'
import { useDeployedWorkflowState, useDeploymentInfo } from '@/hooks/queries/deployments'
import type { WorkspaceUserPermissions } from '@/hooks/use-user-permissions'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'

Expand Down Expand Up @@ -38,24 +38,32 @@ export function Deploy({ activeWorkflowId, userPermissions, className }: DeployP
)
const isDeployed = deploymentStatus?.isDeployed || false

// Fetch and manage deployed state
const { deployedState, isLoadingDeployedState, refetchDeployedState } = useDeployedState({
workflowId: activeWorkflowId,
isDeployed,
isRegistryLoading,
})
// Server-side deployment info (authoritative source for needsRedeployment)
const { data: deploymentInfoData, isLoading: isLoadingDeploymentInfo } = useDeploymentInfo(
activeWorkflowId,
{ enabled: isDeployed && !isRegistryLoading }
)

// Fetch deployed state snapshot for change detection and modal
const isDeployedStateEnabled = Boolean(activeWorkflowId) && isDeployed && !isRegistryLoading
const { data: deployedStateData, isLoading: isLoadingDeployedState } = useDeployedWorkflowState(
activeWorkflowId,
{ enabled: isDeployedStateEnabled }
)
const deployedState = isDeployedStateEnabled ? (deployedStateData ?? null) : null

const { changeDetected } = useChangeDetection({
workflowId: activeWorkflowId,
deployedState,
isLoadingDeployedState,
serverNeedsRedeployment: deploymentInfoData?.needsRedeployment,
isServerLoading: isLoadingDeploymentInfo,
})

// Handle deployment operations
const { isDeploying, handleDeployClick } = useDeployment({
workflowId: activeWorkflowId,
isDeployed,
refetchDeployedState,
})

const isEmpty = !hasBlocks()
Expand Down Expand Up @@ -122,7 +130,6 @@ export function Deploy({ activeWorkflowId, userPermissions, className }: DeployP
needsRedeployment={changeDetected}
deployedState={deployedState!}
Comment thread
waleedlatif1 marked this conversation as resolved.
Outdated
isLoadingDeployedState={isLoadingDeployedState}
refetchDeployedState={refetchDeployedState}
/>
</>
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,2 @@
export { useChangeDetection } from './use-change-detection'
export { useDeployedState } from './use-deployed-state'
export { useDeployment } from './use-deployment'
Loading
Loading