diff --git a/apps/sim/app/api/workflows/[id]/deploy/route.ts b/apps/sim/app/api/workflows/[id]/deploy/route.ts index 5ad26782382..fe84eda30c1 100644 --- a/apps/sim/app/api/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/workflows/[id]/deploy/route.ts @@ -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' @@ -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') @@ -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}`) diff --git a/apps/sim/app/api/workflows/[id]/status/route.ts b/apps/sim/app/api/workflows/[id]/status/route.ts index f53fbe05e4d..ac428d414fb 100644 --- a/apps/sim/app/api/workflows/[id]/status/route.ts +++ b/apps/sim/app/api/workflows/[id]/status/route.ts @@ -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') @@ -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, diff --git a/apps/sim/app/api/workflows/utils.ts b/apps/sim/app/api/workflows/utils.ts index a6646d39505..f460f551b1f 100644 --- a/apps/sim/app/api/workflows/utils.ts +++ b/apps/sim/app/api/workflows/utils.ts @@ -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') @@ -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 { + 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 diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/general.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/general.tsx index 7c28f551c9d..f45d36959a8 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/general.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/general.tsx @@ -26,7 +26,7 @@ const logger = createLogger('GeneralDeploy') interface GeneralDeployProps { workflowId: string | null - deployedState: WorkflowState + deployedState?: WorkflowState | null isLoadingDeployedState: boolean versions: WorkflowDeploymentVersionResponse[] versionsLoading: boolean diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx index 0c8b58b7b4c..fb5b18c5df1 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx @@ -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, @@ -59,9 +59,8 @@ interface DeployModalProps { workflowId: string | null isDeployed: boolean needsRedeployment: boolean - deployedState: WorkflowState + deployedState?: WorkflowState | null isLoadingDeployedState: boolean - refetchDeployedState: () => Promise } interface WorkflowDeploymentInfoUI { @@ -84,7 +83,6 @@ export function DeployModal({ needsRedeployment, deployedState, isLoadingDeployedState, - refetchDeployedState, }: DeployModalProps) { const queryClient = useQueryClient() const { navigateToSettings } = useSettingsNavigation() @@ -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) => { @@ -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 () => { @@ -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) @@ -385,9 +381,8 @@ 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) { @@ -395,7 +390,7 @@ export function DeployModal({ } setChatSuccess(true) chatSuccessTimeoutRef.current = setTimeout(() => setChatSuccess(false), 2000) - }, [workflowId, queryClient, refetchDeployedState]) + }, [workflowId, queryClient]) const handleRefetchChat = useCallback(async () => { await refetchChatInfo() diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx index ad8d3b098da..b04f2ed5ccd 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx @@ -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' @@ -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) @@ -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() @@ -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' @@ -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} /> ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/index.ts index 6d0f191100d..9bd9bf02271 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/index.ts @@ -1,3 +1,2 @@ export { useChangeDetection } from './use-change-detection' -export { useDeployedState } from './use-deployed-state' export { useDeployment } from './use-deployment' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-change-detection.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-change-detection.ts index 9a90c663321..7187bf7fc95 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-change-detection.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-change-detection.ts @@ -14,7 +14,9 @@ interface UseChangeDetectionProps { /** * Detects meaningful changes between current workflow state and deployed state. - * Performs comparison entirely on the client - no API calls needed. + * Performs comparison entirely on the client using hasWorkflowChanged — no API + * calls needed. The deployed state snapshot is fetched once via React Query and + * refreshed after deploy/undeploy/version-activate mutations. */ export function useChangeDetection({ workflowId, @@ -41,7 +43,7 @@ export function useChangeDetection({ }, [workflowId, allVariables]) const currentState = useMemo((): WorkflowState | null => { - if (!workflowId) return null + if (!workflowId || !deployedState) return null const mergedBlocks = mergeSubblockStateWithValues(blocks, subBlockValues ?? {}) @@ -52,12 +54,19 @@ export function useChangeDetection({ parallels, variables: workflowVariables, } as WorkflowState & { variables: Record } - }, [workflowId, blocks, edges, loops, parallels, subBlockValues, workflowVariables]) + }, [ + workflowId, + deployedState, + blocks, + edges, + loops, + parallels, + subBlockValues, + workflowVariables, + ]) const changeDetected = useMemo(() => { - if (!currentState || !deployedState || isLoadingDeployedState) { - return false - } + if (!currentState || !deployedState || isLoadingDeployedState) return false return hasWorkflowChanged(currentState, deployedState) }, [currentState, deployedState, isLoadingDeployedState]) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-deployed-state.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-deployed-state.ts deleted file mode 100644 index 0bcdad7006e..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-deployed-state.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { useCallback, useEffect, useState } from 'react' -import { createLogger } from '@sim/logger' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -import type { WorkflowState } from '@/stores/workflows/workflow/types' - -const logger = createLogger('useDeployedState') - -interface UseDeployedStateProps { - workflowId: string | null - isDeployed: boolean - isRegistryLoading: boolean -} - -/** - * Hook to fetch and manage deployed workflow state - * Includes race condition protection for workflow changes - */ -export function useDeployedState({ - workflowId, - isDeployed, - isRegistryLoading, -}: UseDeployedStateProps) { - const [deployedState, setDeployedState] = useState(null) - const [isLoadingDeployedState, setIsLoadingDeployedState] = useState(false) - - const setNeedsRedeploymentFlag = useWorkflowRegistry( - (state) => state.setWorkflowNeedsRedeployment - ) - - const fetchDeployedState = useCallback(async () => { - const registry = useWorkflowRegistry.getState() - const currentWorkflowId = registry.activeWorkflowId - const deploymentStatus = currentWorkflowId - ? registry.getWorkflowDeploymentStatus(currentWorkflowId) - : null - const currentIsDeployed = deploymentStatus?.isDeployed ?? false - - if (!currentWorkflowId || !currentIsDeployed) { - setDeployedState(null) - return - } - - const requestWorkflowId = currentWorkflowId - - try { - setIsLoadingDeployedState(true) - - const response = await fetch(`/api/workflows/${requestWorkflowId}/deployed`) - - if (requestWorkflowId !== useWorkflowRegistry.getState().activeWorkflowId) { - logger.debug('Workflow changed during deployed state fetch, ignoring response') - return - } - - if (!response.ok) { - if (response.status === 404) { - setDeployedState(null) - return - } - throw new Error(`Failed to fetch deployed state: ${response.statusText}`) - } - - const data = await response.json() - - if (requestWorkflowId === useWorkflowRegistry.getState().activeWorkflowId) { - setDeployedState(data.deployedState || null) - } else { - logger.debug('Workflow changed after deployed state response, ignoring result') - } - } catch (error) { - logger.error('Error fetching deployed state:', { error }) - if (requestWorkflowId === useWorkflowRegistry.getState().activeWorkflowId) { - setDeployedState(null) - } - } finally { - if (requestWorkflowId === useWorkflowRegistry.getState().activeWorkflowId) { - setIsLoadingDeployedState(false) - } - } - }, []) - - useEffect(() => { - if (!workflowId) { - setDeployedState(null) - setIsLoadingDeployedState(false) - return - } - - if (isRegistryLoading) { - setDeployedState(null) - setIsLoadingDeployedState(false) - return - } - - if (isDeployed) { - setNeedsRedeploymentFlag(workflowId, false) - fetchDeployedState() - } else { - setDeployedState(null) - setIsLoadingDeployedState(false) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [workflowId, isDeployed, isRegistryLoading, setNeedsRedeploymentFlag]) - - return { - deployedState, - isLoadingDeployedState, - refetchDeployedState: fetchDeployedState, - } -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-deployment.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-deployment.ts index b6a5d585e32..47ed17a7125 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-deployment.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-deployment.ts @@ -1,36 +1,24 @@ -import { useCallback, useState } from 'react' -import { createLogger } from '@sim/logger' +import { useCallback } from 'react' import { runPreDeployChecks } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-predeploy-checks' +import { useDeployWorkflow } from '@/hooks/queries/deployments' import { useNotificationStore } from '@/stores/notifications' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { mergeSubblockState } from '@/stores/workflows/utils' import { useWorkflowStore } from '@/stores/workflows/workflow/store' -const logger = createLogger('useDeployment') - interface UseDeploymentProps { workflowId: string | null isDeployed: boolean - refetchDeployedState: () => Promise } /** - * Hook to manage deployment operations (deploy, undeploy, redeploy) + * Hook to manage the deploy button click in the editor header. + * First deploy: runs pre-deploy checks, then deploys via mutation and opens modal. + * Already deployed: opens modal directly (validation happens on Update in modal). */ -export function useDeployment({ - workflowId, - isDeployed, - refetchDeployedState, -}: UseDeploymentProps) { - const [isDeploying, setIsDeploying] = useState(false) - const setDeploymentStatus = useWorkflowRegistry((state) => state.setDeploymentStatus) +export function useDeployment({ workflowId, isDeployed }: UseDeploymentProps) { + const { mutateAsync, isPending: isDeploying } = useDeployWorkflow() const addNotification = useNotificationStore((state) => state.addNotification) - /** - * Handle deploy button click - * First deploy: calls API to deploy, then opens modal on success - * Already deployed: opens modal directly (validation happens on Update in modal) - */ const handleDeployClick = useCallback(async () => { if (!workflowId) return { success: false, shouldOpenModal: false } @@ -56,39 +44,10 @@ export function useDeployment({ return { success: false, shouldOpenModal: false } } - setIsDeploying(true) try { - const response = await fetch(`/api/workflows/${workflowId}/deploy`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - deployChatEnabled: false, - }), - }) - - if (response.ok) { - const responseData = await response.json() - const isDeployedStatus = responseData.isDeployed ?? false - const deployedAtTime = responseData.deployedAt - ? new Date(responseData.deployedAt) - : undefined - setDeploymentStatus(workflowId, isDeployedStatus, deployedAtTime, responseData.apiKey || '') - await refetchDeployedState() - return { success: true, shouldOpenModal: true } - } - - const errorData = await response.json() - const errorMessage = errorData.error || 'Failed to deploy workflow' - addNotification({ - level: 'error', - message: errorMessage, - workflowId, - }) - return { success: false, shouldOpenModal: false } + await mutateAsync({ workflowId, deployChatEnabled: false }) + return { success: true, shouldOpenModal: true } } catch (error) { - logger.error('Error deploying workflow:', error) const errorMessage = error instanceof Error ? error.message : 'Failed to deploy workflow' addNotification({ level: 'error', @@ -96,10 +55,8 @@ export function useDeployment({ workflowId, }) return { success: false, shouldOpenModal: false } - } finally { - setIsDeploying(false) } - }, [workflowId, isDeployed, refetchDeployedState, setDeploymentStatus, addNotification]) + }, [workflowId, isDeployed, addNotification, mutateAsync]) return { isDeploying, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx index 467800aa263..d502415b57a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx @@ -53,18 +53,14 @@ import { type CustomTool as CustomToolDefinition, useCustomTools, } from '@/hooks/queries/custom-tools' +import { useDeploymentInfo, useDeployWorkflow } from '@/hooks/queries/deployments' import { useForceRefreshMcpTools, useMcpServers, useMcpToolsEvents, useStoredMcpTools, } from '@/hooks/queries/mcp' -import { - useChildDeploymentStatus, - useDeployChildWorkflow, - useWorkflowState, - useWorkflows, -} from '@/hooks/queries/workflows' +import { useWorkflowState, useWorkflows } from '@/hooks/queries/workflows' import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' import { usePermissionConfig } from '@/hooks/use-permission-config' import { useSettingsNavigation } from '@/hooks/use-settings-navigation' @@ -200,9 +196,6 @@ function WorkflowInputMapperInput({ ) } -/** - * Badge component showing deployment status for workflow tools - */ function WorkflowToolDeployBadge({ workflowId, onDeploySuccess, @@ -210,18 +203,17 @@ function WorkflowToolDeployBadge({ workflowId: string onDeploySuccess?: () => void }) { - const { data, isLoading } = useChildDeploymentStatus(workflowId) - const deployMutation = useDeployChildWorkflow() + const { data, isLoading } = useDeploymentInfo(workflowId) + const { mutate, isPending: isDeploying } = useDeployWorkflow() const userPermissions = useUserPermissionsContext() const isDeployed = data?.isDeployed ?? null - const needsRedeploy = data?.needsRedeploy ?? false - const isDeploying = deployMutation.isPending + const needsRedeploy = data?.needsRedeployment ?? false const deployWorkflow = useCallback(() => { if (isDeploying || !workflowId || !userPermissions.canAdmin) return - deployMutation.mutate( + mutate( { workflowId }, { onSuccess: () => { @@ -229,7 +221,7 @@ function WorkflowToolDeployBadge({ }, } ) - }, [isDeploying, workflowId, userPermissions.canAdmin, deployMutation, onDeploySuccess]) + }, [isDeploying, workflowId, userPermissions.canAdmin, mutate, onDeploySuccess]) if (isLoading || (isDeployed && !needsRedeploy)) { return null diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/hooks/use-child-workflow.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/hooks/use-child-workflow.ts index cf5756f902e..f13b80ec210 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/hooks/use-child-workflow.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/hooks/use-child-workflow.ts @@ -1,33 +1,18 @@ import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' import type { WorkflowBlockProps } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/types' -import { useChildDeploymentStatus } from '@/hooks/queries/workflows' +import { useDeploymentInfo } from '@/hooks/queries/deployments' -/** - * Return type for the useChildWorkflow hook - */ export interface UseChildWorkflowReturn { - /** The ID of the child workflow if configured */ childWorkflowId: string | undefined - /** The active version of the child workflow */ - childActiveVersion: number | null - /** Whether the child workflow is deployed */ childIsDeployed: boolean | null - /** Whether the child workflow needs redeployment due to changes */ childNeedsRedeploy: boolean - /** Whether the child version information is loading */ isLoadingChildVersion: boolean } /** - * Custom hook for managing child workflow information for workflow selector blocks. - * Cache invalidation is handled automatically by React Query when using - * the useDeployChildWorkflow mutation. - * - * @param blockId - The ID of the block - * @param blockType - The type of the block - * @param isPreview - Whether the block is in preview mode - * @param previewSubBlockValues - The subblock values in preview mode - * @returns Child workflow configuration and deployment status + * Manages child workflow deployment status for workflow selector blocks. + * Uses the shared useDeploymentInfo query (same source of truth as the + * editor header's Deploy button) for consistent deployment detection. */ export function useChildWorkflow( blockId: string, @@ -53,18 +38,16 @@ export function useChildWorkflow( } } - const { data, isLoading, isPending } = useChildDeploymentStatus( - isWorkflowSelector ? childWorkflowId : undefined + const { data, isPending } = useDeploymentInfo( + isWorkflowSelector ? (childWorkflowId ?? null) : null ) - const childActiveVersion = data?.activeVersion ?? null const childIsDeployed = data?.isDeployed ?? null - const childNeedsRedeploy = data?.needsRedeploy ?? false - const isLoadingChildVersion = isLoading || isPending + const childNeedsRedeploy = data?.needsRedeployment ?? false + const isLoadingChildVersion = isPending return { childWorkflowId, - childActiveVersion, childIsDeployed, childNeedsRedeploy, isLoadingChildVersion, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx index b2b470c21df..868163c2951 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx @@ -40,12 +40,12 @@ import { SELECTOR_TYPES_HYDRATION_REQUIRED, type SubBlockConfig } from '@/blocks import { getDependsOnFields } from '@/blocks/utils' import { useKnowledgeBase } from '@/hooks/kb/use-knowledge' import { useCustomTools } from '@/hooks/queries/custom-tools' +import { useDeployWorkflow } from '@/hooks/queries/deployments' import { useMcpServers, useMcpToolsQuery } from '@/hooks/queries/mcp' import { useCredentialName } from '@/hooks/queries/oauth/oauth-credentials' import { useReactivateSchedule, useScheduleInfo } from '@/hooks/queries/schedules' import { useSkills } from '@/hooks/queries/skills' import { useTablesList } from '@/hooks/queries/tables' -import { useDeployChildWorkflow } from '@/hooks/queries/workflows' import { useSelectorDisplayName } from '@/hooks/use-selector-display-name' import { useVariablesStore } from '@/stores/panel' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' @@ -918,7 +918,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({ data.subBlockValues ) - const { mutate: deployChildWorkflow, isPending: isDeploying } = useDeployChildWorkflow() + const { mutate: deployChildWorkflow, isPending: isDeploying } = useDeployWorkflow() const userPermissions = useUserPermissionsContext() diff --git a/apps/sim/hooks/queries/deployments.ts b/apps/sim/hooks/queries/deployments.ts index 3b341ba2b90..716408bebf3 100644 --- a/apps/sim/hooks/queries/deployments.ts +++ b/apps/sim/hooks/queries/deployments.ts @@ -1,9 +1,11 @@ import { useCallback } from 'react' import { createLogger } from '@sim/logger' +import type { QueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -import { fetchDeploymentVersionState } from './workflows' +import type { WorkflowState } from '@/stores/workflows/workflow/types' +import { fetchDeploymentVersionState, workflowKeys } from './workflows' const logger = createLogger('DeploymentQueries') @@ -14,6 +16,8 @@ export const deploymentKeys = { all: ['deployments'] as const, infos: () => [...deploymentKeys.all, 'info'] as const, info: (workflowId: string | null) => [...deploymentKeys.infos(), workflowId ?? ''] as const, + deployedState: (workflowId: string | null) => + [...deploymentKeys.all, 'deployedState', workflowId ?? ''] as const, allVersions: () => [...deploymentKeys.all, 'versions'] as const, versions: (workflowId: string | null) => [...deploymentKeys.allVersions(), workflowId ?? ''] as const, @@ -29,6 +33,18 @@ export const deploymentKeys = { formDetail: (formId: string | null) => [...deploymentKeys.formDetails(), formId ?? ''] as const, } +/** + * Invalidates the core deployment queries (info, deployedState, versions) for a workflow. + * Used by mutation onSuccess callbacks and manual invalidation after chat deployments. + */ +export function invalidateDeploymentQueries(queryClient: QueryClient, workflowId: string) { + return Promise.all([ + queryClient.invalidateQueries({ queryKey: deploymentKeys.info(workflowId) }), + queryClient.invalidateQueries({ queryKey: deploymentKeys.deployedState(workflowId) }), + queryClient.invalidateQueries({ queryKey: deploymentKeys.versions(workflowId) }), + ]) +} + /** * Response type from /api/workflows/[id]/deploy GET endpoint */ @@ -76,6 +92,40 @@ export function useDeploymentInfo(workflowId: string | null, options?: { enabled }) } +/** + * Fetches the deployed workflow state snapshot for a workflow + */ +async function fetchDeployedWorkflowState( + workflowId: string, + signal?: AbortSignal +): Promise { + const response = await fetch(`/api/workflows/${workflowId}/deployed`, { signal }) + + if (!response.ok) { + if (response.status === 404) return null + throw new Error('Failed to fetch deployed workflow state') + } + + const data = await response.json() + return data.deployedState || null +} + +/** + * Hook to fetch the deployed workflow state snapshot. + * Returns the full workflow state at the time of the last active deployment. + */ +export function useDeployedWorkflowState( + workflowId: string | null, + options?: { enabled?: boolean } +) { + return useQuery({ + queryKey: deploymentKeys.deployedState(workflowId), + queryFn: ({ signal }) => fetchDeployedWorkflowState(workflowId!, signal), + enabled: Boolean(workflowId) && (options?.enabled ?? true), + staleTime: 30 * 1000, + }) +} + /** * Response type from /api/workflows/[id]/deployments GET endpoint */ @@ -307,12 +357,12 @@ export function useDeployWorkflow() { useWorkflowRegistry.getState().setWorkflowNeedsRedeployment(variables.workflowId, false) - queryClient.invalidateQueries({ - queryKey: deploymentKeys.info(variables.workflowId), - }) - queryClient.invalidateQueries({ - queryKey: deploymentKeys.versions(variables.workflowId), - }) + return Promise.all([ + invalidateDeploymentQueries(queryClient, variables.workflowId), + queryClient.invalidateQueries({ + queryKey: workflowKeys.state(variables.workflowId), + }), + ]) }, onError: (error) => { logger.error('Failed to deploy workflow', { error }) @@ -351,15 +401,12 @@ export function useUndeployWorkflow() { setDeploymentStatus(variables.workflowId, false) - queryClient.invalidateQueries({ - queryKey: deploymentKeys.info(variables.workflowId), - }) - queryClient.invalidateQueries({ - queryKey: deploymentKeys.versions(variables.workflowId), - }) - queryClient.invalidateQueries({ - queryKey: deploymentKeys.chatStatus(variables.workflowId), - }) + return Promise.all([ + invalidateDeploymentQueries(queryClient, variables.workflowId), + queryClient.invalidateQueries({ + queryKey: deploymentKeys.chatStatus(variables.workflowId), + }), + ]) }, onError: (error) => { logger.error('Failed to undeploy workflow', { error }) @@ -624,12 +671,7 @@ export function useActivateDeploymentVersion() { ) }, onSettled: (_, __, variables) => { - queryClient.invalidateQueries({ - queryKey: deploymentKeys.info(variables.workflowId), - }) - queryClient.invalidateQueries({ - queryKey: deploymentKeys.versions(variables.workflowId), - }) + return invalidateDeploymentQueries(queryClient, variables.workflowId) }, }) } diff --git a/apps/sim/hooks/queries/workflows.ts b/apps/sim/hooks/queries/workflows.ts index 88d19f2cb3f..97d3df0b2b5 100644 --- a/apps/sim/hooks/queries/workflows.ts +++ b/apps/sim/hooks/queries/workflows.ts @@ -25,8 +25,6 @@ export const workflowKeys = { lists: () => [...workflowKeys.all, 'list'] as const, list: (workspaceId: string | undefined, scope: WorkflowQueryScope = 'active') => [...workflowKeys.lists(), workspaceId ?? '', scope] as const, - deploymentStatus: (workflowId: string | undefined) => - [...workflowKeys.all, 'deploymentStatus', workflowId ?? ''] as const, deploymentVersions: () => [...workflowKeys.all, 'deploymentVersion'] as const, deploymentVersion: (workflowId: string | undefined, version: number | undefined) => [...workflowKeys.deploymentVersions(), workflowId ?? '', version ?? 0] as const, @@ -520,10 +518,10 @@ export function useRevertToVersion() { queryKey: workflowKeys.state(variables.workflowId), }) queryClient.invalidateQueries({ - queryKey: workflowKeys.deploymentStatus(variables.workflowId), + queryKey: deploymentKeys.info(variables.workflowId), }) queryClient.invalidateQueries({ - queryKey: deploymentKeys.info(variables.workflowId), + queryKey: deploymentKeys.deployedState(variables.workflowId), }) queryClient.invalidateQueries({ queryKey: deploymentKeys.versions(variables.workflowId), @@ -590,144 +588,6 @@ export function useReorderWorkflows() { }) } -/** - * Child deployment status data returned from the API - */ -export interface ChildDeploymentStatus { - activeVersion: number | null - isDeployed: boolean - needsRedeploy: boolean -} - -/** - * Fetches deployment status for a child workflow. - * Uses Promise.all to fetch status and deployments in parallel for better performance. - */ -async function fetchChildDeploymentStatus( - workflowId: string, - signal?: AbortSignal -): Promise { - const fetchOptions = { - signal, - cache: 'no-store' as const, - headers: { 'Cache-Control': 'no-cache' }, - } - - const [statusRes, deploymentsRes] = await Promise.all([ - fetch(`/api/workflows/${workflowId}/status`, fetchOptions), - fetch(`/api/workflows/${workflowId}/deployments`, fetchOptions), - ]) - - if (!statusRes.ok) { - throw new Error('Failed to fetch workflow status') - } - - const statusData = await statusRes.json() - - let activeVersion: number | null = null - if (deploymentsRes.ok) { - const deploymentsJson = await deploymentsRes.json() - const versions = Array.isArray(deploymentsJson?.data?.versions) - ? deploymentsJson.data.versions - : Array.isArray(deploymentsJson?.versions) - ? deploymentsJson.versions - : [] - - const active = versions.find((v: { isActive?: boolean }) => v.isActive) - activeVersion = active ? Number(active.version) : null - } - - return { - activeVersion, - isDeployed: statusData.isDeployed || false, - needsRedeploy: statusData.needsRedeployment || false, - } -} - -/** - * Hook to fetch deployment status for a child workflow. - * Used by workflow selector blocks to show deployment badges. - */ -export function useChildDeploymentStatus(workflowId: string | undefined) { - return useQuery({ - queryKey: workflowKeys.deploymentStatus(workflowId), - queryFn: ({ signal }) => fetchChildDeploymentStatus(workflowId!, signal), - enabled: Boolean(workflowId), - staleTime: 0, - retry: false, - }) -} - -interface DeployChildWorkflowVariables { - workflowId: string -} - -interface DeployChildWorkflowResult { - isDeployed: boolean - deployedAt?: Date - apiKey?: string -} - -/** - * Mutation hook for deploying a child workflow. - * Invalidates the deployment status query on success. - */ -export function useDeployChildWorkflow() { - const queryClient = useQueryClient() - const setDeploymentStatus = useWorkflowRegistry((state) => state.setDeploymentStatus) - - return useMutation({ - mutationFn: async ({ - workflowId, - }: DeployChildWorkflowVariables): Promise => { - const response = await fetch(`/api/workflows/${workflowId}/deploy`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - deployChatEnabled: false, - }), - }) - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})) - throw new Error(errorData.error || 'Failed to deploy workflow') - } - - const responseData = await response.json() - return { - isDeployed: responseData.isDeployed ?? false, - deployedAt: responseData.deployedAt ? new Date(responseData.deployedAt) : undefined, - apiKey: responseData.apiKey || '', - } - }, - onSuccess: (data, variables) => { - logger.info('Child workflow deployed', { workflowId: variables.workflowId }) - - setDeploymentStatus(variables.workflowId, data.isDeployed, data.deployedAt, data.apiKey || '') - - queryClient.invalidateQueries({ - queryKey: workflowKeys.deploymentStatus(variables.workflowId), - }) - // Invalidate workflow state so tool input mappings refresh - queryClient.invalidateQueries({ - queryKey: workflowKeys.state(variables.workflowId), - }) - // Also invalidate deployment queries - queryClient.invalidateQueries({ - queryKey: deploymentKeys.info(variables.workflowId), - }) - queryClient.invalidateQueries({ - queryKey: deploymentKeys.versions(variables.workflowId), - }) - }, - onError: (error) => { - logger.error('Failed to deploy child workflow', { error }) - }, - }) -} - /** * Import workflow mutation (superuser debug) */