Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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