fix(deploy): consolidate deployment detection into single source of truth (#3606)

* fix(deploy): consolidate deployment detection into single source of truth

* fix(deploy): address PR review feedback

- Remove redundant isLoading check (subset of isPending in RQ v5)
- Make deployedState prop nullable to avoid non-null assertion
- Destructure mutateAsync to eliminate ESLint suppression
- Guard debounced invalidation against stale workflowId on switch

* fix(deploy): clean up self-explanatory comments and fix unstable mutation dep

- Remove self-explanatory comments in deploy.tsx, tool-input.tsx, use-child-workflow.ts
- Tighten non-obvious comments in use-change-detection.ts
- Destructure mutate/isPending in WorkflowToolDeployBadge to avoid unstable
  mutation object in useCallback deps (TanStack Query no-unstable-deps pattern)

* lint

* fix(deploy): skip expensive state merge when deployedState is null

Avoid running mergeSubblockStateWithValues on every render for
non-deployed workflows where changeDetected always returns false.

* fix(deploy): add missing workflow table import in deploy route

Pre-existing type error — workflow table was used but not imported.

* fix(deploy): forward AbortSignal in fetchDeployedWorkflowState

Match the pattern used by all other fetch helpers in the file so
in-flight requests are cancelled on component unmount or query re-trigger.

* perf(deploy): parallelize all DB queries in checkNeedsRedeployment

All three queries (active version, normalized data, workflow variables)
now run concurrently via Promise.all, saving one DB round trip on the
common path.

* fix(deploy): use sequential-then-parallel pattern in checkNeedsRedeployment

* fix(deploy): use client-side comparison for editor header, remove server polling

The lastSaved-based server polling was triggering API calls on every
local store mutation (before socket persistence), wasting requests and
checking stale DB state. Revert the editor header to pure client-side
hasWorkflowChanged comparison — zero network during editing, instant
badge updates. Child workflow badges still use server-side
useDeploymentInfo (they don't have Zustand state).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(deploy): suppress transient Update flash during deployed state refetch

Guard change detection on isFetching (not just isLoading) so the
comparison is suppressed during background refetches after mutations,
preventing a brief Update→Live badge flicker.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Waleed
2026-03-16 14:38:57 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 680c9cddf0
commit 395a61d1b6
15 changed files with 191 additions and 504 deletions
@@ -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}`)
@@ -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,
+49
View File
@@ -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<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
@@ -26,7 +26,7 @@ const logger = createLogger('GeneralDeploy')
interface GeneralDeployProps {
workflowId: string | null
deployedState: WorkflowState
deployedState?: WorkflowState | null
isLoadingDeployedState: boolean
versions: WorkflowDeploymentVersionResponse[]
versionsLoading: boolean
@@ -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<void>
}
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()
@@ -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}
/>
</>
)
@@ -1,3 +1,2 @@
export { useChangeDetection } from './use-change-detection'
export { useDeployedState } from './use-deployed-state'
export { useDeployment } from './use-deployment'
@@ -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<string, any> }
}, [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])
@@ -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<WorkflowState | null>(null)
const [isLoadingDeployedState, setIsLoadingDeployedState] = useState<boolean>(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,
}
}
@@ -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<void>
}
/**
* 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,
@@ -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
@@ -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,
@@ -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()
+64 -22
View File
@@ -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<WorkflowState | null> {
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)
},
})
}
+2 -142
View File
@@ -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<ChildDeploymentStatus> {
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<DeployChildWorkflowResult> => {
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)
*/