Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
47 changes: 7 additions & 40 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 { 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
49 changes: 49 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,50 @@ 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 @@ -26,7 +26,7 @@ const logger = createLogger('GeneralDeploy')

interface GeneralDeployProps {
workflowId: string | null
deployedState: WorkflowState
deployedState?: WorkflowState | null
isLoadingDeployedState: boolean
versions: WorkflowDeploymentVersionResponse[]
versionsLoading: boolean
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 @@ -59,9 +59,8 @@ interface DeployModalProps {
workflowId: string | null
isDeployed: boolean
needsRedeployment: boolean
deployedState: WorkflowState
deployedState?: WorkflowState | null
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 } from '@/hooks/queries/deployments'
import type { WorkspaceUserPermissions } from '@/hooks/use-user-permissions'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'

Expand All @@ -19,10 +19,6 @@ interface DeployProps {
className?: string
}

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

// Get deployment status from registry
const deploymentStatus = useWorkflowRegistry((state) =>
state.getWorkflowDeploymentStatus(activeWorkflowId)
)
const isDeployed = deploymentStatus?.isDeployed || false

// Fetch and manage deployed state
const { deployedState, isLoadingDeployedState, refetchDeployedState } = useDeployedState({
workflowId: activeWorkflowId,
isDeployed,
isRegistryLoading,
})
const isDeployedStateEnabled = Boolean(activeWorkflowId) && isDeployed && !isRegistryLoading
const {
data: deployedStateData,
isLoading: isLoadingDeployedState,
isFetching: isFetchingDeployedState,
} = useDeployedWorkflowState(activeWorkflowId, { enabled: isDeployedStateEnabled })
const deployedState = isDeployedStateEnabled ? (deployedStateData ?? null) : null

const { changeDetected } = useChangeDetection({
workflowId: activeWorkflowId,
deployedState,
isLoadingDeployedState,
isLoadingDeployedState: isLoadingDeployedState || isFetchingDeployedState,
})

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

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

/**
* Get tooltip text based on current state
*/
const getTooltipText = () => {
if (isEmpty) {
return 'Cannot deploy an empty workflow'
Expand Down Expand Up @@ -120,9 +111,8 @@ export function Deploy({ activeWorkflowId, userPermissions, className }: DeployP
workflowId={activeWorkflowId}
isDeployed={isDeployed}
needsRedeployment={changeDetected}
deployedState={deployedState!}
deployedState={deployedState}
isLoadingDeployedState={isLoadingDeployedState}
refetchDeployedState={refetchDeployedState}
/>
</>
)
Expand Down
Loading
Loading