Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ interface GeneralDeployProps {
workflowId: string | null
deployedState?: WorkflowState | null
isLoadingDeployedState: boolean
/** A snapshot is expected but has not arrived — render loading, not "undeployed". */
isAwaitingSnapshot: boolean
versions: WorkflowDeploymentVersionResponse[]
versionsLoading: boolean
isPromotingVersion: boolean
Expand All @@ -51,6 +53,7 @@ export function GeneralDeploy({
workflowId,
deployedState,
isLoadingDeployedState,
isAwaitingSnapshot,
versions,
versionsLoading,
isPromotingVersion,
Expand Down Expand Up @@ -169,7 +172,13 @@ export function GeneralDeploy({
const showToggle = selectedVersion !== null && deployedState

const hasDeployedData = deployedState && Object.keys(deployedState.blocks || {}).length > 0
const showLoadingSkeleton = isLoadingDeployedState && !hasDeployedData
/*
* `isAwaitingSnapshot` counts as loading. A missing snapshot is not evidence
* that the workflow is undeployed — it is usually the snapshot not having
* arrived yet — and treating it as evidence is what rendered "Deploy your
* workflow to see a preview" directly above a row reading `v1 (live)`.
*/
const showLoadingSkeleton = (isLoadingDeployedState || isAwaitingSnapshot) && !hasDeployedData

if (showLoadingSkeleton) {
return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
tryAcquireDeployAction,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/deploy-action-lock'
import type { DeployReadiness } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-deploy-readiness'
import type { DeploymentViewState } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-deployment-view-state'
import { runPreDeployChecks } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-predeploy-checks'
import { normalizeName, startsWithUuid } from '@/executor/constants'
import { useApiKeys } from '@/hooks/queries/api-keys'
Expand Down Expand Up @@ -66,12 +67,10 @@ interface DeployModalProps {
open: boolean
onOpenChange: (open: boolean) => void
workflowId: string | null
isDeployed: boolean
needsRedeployment: boolean
/** The one derived deployment verdict, shared with the deploy chip. */
deployment: DeploymentViewState
deployedState?: WorkflowState | null
isLoadingDeployedState: boolean
deployReadiness: DeployReadiness
isDeploymentSettling: boolean
}

interface WorkflowDeploymentInfoUI {
Expand All @@ -96,13 +95,19 @@ export function DeployModal({
open,
onOpenChange,
workflowId,
isDeployed: isDeployedProp,
needsRedeployment,
deployedState,
isLoadingDeployedState,
deployment,
deployReadiness,
isDeploymentSettling,
}: DeployModalProps) {
const {
status: deploymentStatus,
isDeployed: isDeployedProp,
deployedState,
isAwaitingSnapshot,
isSettling: isDeploymentSettling,
} = deployment
const needsRedeployment = deploymentStatus === 'changed'
/* A snapshot that is expected but absent reads as loading everywhere. */
const isLoadingDeployedState = isAwaitingSnapshot
const queryClient = useQueryClient()
const params = useParams()
const workspaceId = params?.workspaceId as string
Expand Down Expand Up @@ -561,6 +566,7 @@ export function DeployModal({
workflowId={workflowId}
deployedState={deployedState}
isLoadingDeployedState={isLoadingDeployedState}
isAwaitingSnapshot={isAwaitingSnapshot}
versions={versions}
versionsLoading={versionsLoading}
isPromotingVersion={isActivatingVersion || activateVersionMutation.isPending}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,11 @@ import { Chip, Tooltip, toast } from '@sim/emcn'
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { DeployModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal'
import {
useChangeDetection,
useDeployment,
useDeploymentViewState,
useDeployReadiness,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks'
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow'
import { useDeployedWorkflowState, useDeploymentInfo } from '@/hooks/queries/deployments'
import type { WorkspaceUserPermissions } from '@/hooks/use-user-permissions'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'

Expand All @@ -26,26 +25,21 @@ export function Deploy({ activeWorkflowId, userPermissions, disabled = false }:
const isRegistryLoading = hydrationPhase === 'idle' || hydrationPhase === 'state-loading'
const { hasBlocks } = useCurrentWorkflow()

const { data: deploymentInfo } = useDeploymentInfo(activeWorkflowId, {
enabled: !isRegistryLoading,
})
const isDeployed = deploymentInfo?.isDeployed ?? false

const isDeployedStateEnabled = Boolean(activeWorkflowId) && isDeployed && !isRegistryLoading
const {
data: deployedStateData,
isLoading: isLoadingDeployedState,
isFetching: isFetchingDeployedState,
} = useDeployedWorkflowState(activeWorkflowId, { enabled: isDeployedStateEnabled })
const deployedState = isDeployedStateEnabled ? (deployedStateData ?? null) : null
const deployReadiness = useDeployReadiness(activeWorkflowId)

const { changeDetected, isChangeDetectionSettling } = useChangeDetection({
/*
* One derivation for the chip, the modal preview and the modal footer. They
* previously each read their own mix of raw flags, which is how the preview
* could say "Deploy your workflow to see a preview" while the version list
* beneath it said `v1 (live)`.
*/
const deployment = useDeploymentViewState({
workflowId: activeWorkflowId,
deployedState,
isLoadingDeployedState: isLoadingDeployedState || isFetchingDeployedState,
enabled: !isRegistryLoading,
deployReadiness,
})
const isDeploymentSettling = isChangeDetectionSettling || deployReadiness.isSyncing
const { status: buttonStatus, isDeployed, deployedState } = deployment
const isDeploymentSettling = deployment.isSettling

const { isDeploying, handleDeployClick } = useDeployment({
workflowId: activeWorkflowId,
Expand All @@ -60,6 +54,13 @@ export function Deploy({ activeWorkflowId, userPermissions, disabled = false }:
isDeploying ||
!canDeploy ||
isEmpty ||
/*
* A click is interpreted against `isDeployed`: deployed opens the modal,
* undeployed deploys. While that is unknown the click has no defined
* meaning, and guessing "undeployed" would turn a failed info read into an
* unintended new version.
*/
buttonStatus === 'unknown' ||
(!isDeployed && deployReadiness.isBlocked && !deployReadiness.isSyncing)

const onDeployClick = async () => {
Expand Down Expand Up @@ -104,29 +105,50 @@ export function Deploy({ activeWorkflowId, userPermissions, disabled = false }:
if (isDeploying) {
return 'Deploying...'
}
if (isChangeDetectionSettling) {
if (isDeploymentSettling) {
return 'Syncing deployment state...'
}
if (deployReadiness.isBlocked && !isDeployed) {
return deployReadiness.tooltip
}
if (changeDetected) {
if (buttonStatus === 'changed') {
return 'Update deployment'
}
if (isDeployed) {
if (buttonStatus === 'live') {
return 'Active deployment'
}
return 'Deploy workflow'
}

const getButtonLabel = () => {
if (changeDetected) {
return 'Update'
/*
* The label carries the busy state, matching every sibling control on this
* surface (`{isUndeploying ? 'Undeploying...' : 'Undeploy'}` in the modal
* footer) and the vocabulary `deployReadiness` already speaks. This chip was
* the one button that announced nothing and merely went disabled.
*
* Scoped to the deploy action, which is bounded by the mutation. The
* readiness states are deliberately NOT surfaced here: `saving` fires on
* every settled keystroke, so rendering it would reintroduce exactly the
* label churn this state machine exists to remove. Those stay in the
* tooltip, where they explain why the button is disabled.
*/
if (isDeploying) {
return 'Deploying...'
}
if (isDeployed) {
return 'Live'

switch (buttonStatus) {
case 'changed':
return 'Update'
case 'live':
return 'Live'
/*
* Only reachable before we know the workflow is deployed, so "Deploy" is
* the answer rather than a guess we would have to take back.
*/
default:
return 'Deploy'
}
return 'Deploy'
}

return (
Expand All @@ -150,12 +172,8 @@ export function Deploy({ activeWorkflowId, userPermissions, disabled = false }:
open={isModalOpen}
onOpenChange={setIsModalOpen}
workflowId={activeWorkflowId}
isDeployed={isDeployed}
needsRedeployment={changeDetected}
deployedState={deployedState}
isLoadingDeployedState={isLoadingDeployedState || isFetchingDeployedState}
deployment={deployment}
deployReadiness={deployReadiness}
isDeploymentSettling={isDeploymentSettling}
/>
</>
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
export { useChangeDetection } from './use-change-detection'
export { useChangeDetectionCanary } from './use-change-detection-canary'
export type { DeployButtonStatus } from './use-deploy-button-status'
export { resolveDeployButtonStatus } from './use-deploy-button-status'
export type { DeployReadiness } from './use-deploy-readiness'
export { getDeployReadinessState, useDeployReadiness } from './use-deploy-readiness'
export { useDeployment } from './use-deployment'
export type { DeploymentViewState } from './use-deployment-view-state'
export { useDeploymentViewState } from './use-deployment-view-state'
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { useEffect, useRef } from 'react'
import { createLogger } from '@sim/logger'

const logger = createLogger('ChangeDetectionCanary')

interface UseChangeDetectionCanaryProps {
workflowId: string | null
/** The client's in-memory answer, from `useChangeDetection`. */
clientChangeDetected: boolean
/** The fields the client's answer rests on, for attribution. */
clientChangedFields: string[]
/** The server's answer, already fetched by `useDeploymentInfo`. */
serverNeedsRedeployment: boolean | undefined
/** True while either operand is still loading — a disagreement means nothing yet. */
isSettling: boolean
/** True only when the operation queue is drained and no diff/reconcile is pending. */
isSettled: boolean
}

/**
* Reports when the client and the server disagree about whether a workflow needs
* redeploying.
*
* The two answers are computed from the same comparison over operands that are
* supposed to be equivalent: the server diffs the durable draft against the
* active deployment version, and the client diffs its merged in-memory state
* against the same version. Once the operation queue has drained they must
* agree, so a disagreement is a divergence between the client's state and what
* was actually persisted — the signature of every phantom "Update" this codebase
* has shipped.
*
* Costs nothing: `useDeploymentInfo` already fetches the server's answer for the
* `isDeployed` flag, and the client's answer is already computed for the button.
* Discarding both is why ten instances of this bug class were found by users
* rather than by us.
*/
export function useChangeDetectionCanary({
workflowId,
clientChangeDetected,
clientChangedFields,
serverNeedsRedeployment,
isSettling,
isSettled,
}: UseChangeDetectionCanaryProps): void {
/** Reported once per (workflow, verdict pair) so a steady disagreement logs once. */
const reportedRef = useRef<string | null>(null)

useEffect(() => {
if (!workflowId || isSettling || !isSettled || serverNeedsRedeployment === undefined) {
return
}

if (serverNeedsRedeployment === clientChangeDetected) {
reportedRef.current = null
return
}

const signature = `${workflowId}:${serverNeedsRedeployment}:${clientChangeDetected}`
if (reportedRef.current === signature) return
reportedRef.current = signature

logger.warn('Change detection disagrees with the server', {
workflowId,
serverNeedsRedeployment,
clientChangeDetected,
/*
* Only populated when the CLIENT sees changes. The inverse case — the
* server sees changes the client does not — reports an empty list, and
* that asymmetry is itself the diagnosis: the client's merged state
* matches the deployment while the persisted draft does not.
*/
clientChangedFields,
})
}, [
workflowId,
clientChangeDetected,
clientChangedFields,
serverNeedsRedeployment,
isSettling,
isSettled,
])
}
Loading
Loading