Skip to content

Commit d54b8d3

Browse files
icecrasher321claude
andcommitted
fix(deploy): derive every deploy surface from one deployment verdict
The modal could render "Deploy your workflow to see a preview" directly above a version row reading `v1 (live)`, because the General tab inferred "not deployed" from the ABSENCE OF A SNAPSHOT. A missing snapshot is not evidence of anything — usually it just has not arrived — and treating it as evidence let the modal contradict itself. Underneath that, nothing re-fetched the snapshot once it came back empty. `refetchDeploymentBoundary` fires while the query is still disabled (it is gated on `isDeployed`, which is not true yet), and a disabled query cannot be refetched, so a null cached during the activation window survived the whole stale period. `useDeployedWorkflowState` now retries while it holds no snapshot and stops the instant one arrives — the query is only enabled once the workflow IS deployed, so a null there is a contradiction to resolve, not an answer. The deeper problem was that the chip, the modal footer and the preview each derived their own verdict from a different mix of raw flags. That is the same failure this branch fixes one layer down — several derivations of one fact, drifting — so it gets the same treatment. `useDeploymentViewState` derives once; the chip, the modal and the General tab consume it and are given no raw material to re-derive from. `DeployModal` now takes one `deployment` prop in place of four booleans it used to recombine. Also brings the chip in line with the busy-label pattern every sibling control on this surface already follows (`{isUndeploying ? 'Undeploying...' : 'Undeploy'}` in the modal footer): it now reads "Deploying..." while the deploy is in flight, where before it announced nothing and merely went disabled. Scoped to the deploy action, which the mutation bounds. The readiness states stay in the tooltip on purpose — `saving` fires on every settled keystroke, so putting it on the chip would reintroduce the label churn the state machine exists to remove. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 3db9152 commit d54b8d3

6 files changed

Lines changed: 177 additions & 63 deletions

File tree

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/general.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ interface GeneralDeployProps {
3333
workflowId: string | null
3434
deployedState?: WorkflowState | null
3535
isLoadingDeployedState: boolean
36+
/** A snapshot is expected but has not arrived — render loading, not "undeployed". */
37+
isAwaitingSnapshot: boolean
3638
versions: WorkflowDeploymentVersionResponse[]
3739
versionsLoading: boolean
3840
isPromotingVersion: boolean
@@ -51,6 +53,7 @@ export function GeneralDeploy({
5153
workflowId,
5254
deployedState,
5355
isLoadingDeployedState,
56+
isAwaitingSnapshot,
5457
versions,
5558
versionsLoading,
5659
isPromotingVersion,
@@ -169,7 +172,13 @@ export function GeneralDeploy({
169172
const showToggle = selectedVersion !== null && deployedState
170173

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

174183
if (showLoadingSkeleton) {
175184
return (

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
tryAcquireDeployAction,
3636
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/deploy-action-lock'
3737
import type { DeployReadiness } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-deploy-readiness'
38+
import type { DeploymentViewState } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-deployment-view-state'
3839
import { runPreDeployChecks } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-predeploy-checks'
3940
import { normalizeName, startsWithUuid } from '@/executor/constants'
4041
import { useApiKeys } from '@/hooks/queries/api-keys'
@@ -66,12 +67,10 @@ interface DeployModalProps {
6667
open: boolean
6768
onOpenChange: (open: boolean) => void
6869
workflowId: string | null
69-
isDeployed: boolean
70-
needsRedeployment: boolean
70+
/** The one derived deployment verdict, shared with the deploy chip. */
71+
deployment: DeploymentViewState
7172
deployedState?: WorkflowState | null
72-
isLoadingDeployedState: boolean
7373
deployReadiness: DeployReadiness
74-
isDeploymentSettling: boolean
7574
}
7675

7776
interface WorkflowDeploymentInfoUI {
@@ -96,13 +95,19 @@ export function DeployModal({
9695
open,
9796
onOpenChange,
9897
workflowId,
99-
isDeployed: isDeployedProp,
100-
needsRedeployment,
101-
deployedState,
102-
isLoadingDeployedState,
98+
deployment,
10399
deployReadiness,
104-
isDeploymentSettling,
105100
}: DeployModalProps) {
101+
const {
102+
status: deploymentStatus,
103+
isDeployed: isDeployedProp,
104+
deployedState,
105+
isAwaitingSnapshot,
106+
isSettling: isDeploymentSettling,
107+
} = deployment
108+
const needsRedeployment = deploymentStatus === 'changed'
109+
/* A snapshot that is expected but absent reads as loading everywhere. */
110+
const isLoadingDeployedState = isAwaitingSnapshot
106111
const queryClient = useQueryClient()
107112
const params = useParams()
108113
const workspaceId = params?.workspaceId as string
@@ -561,6 +566,7 @@ export function DeployModal({
561566
workflowId={workflowId}
562567
deployedState={deployedState}
563568
isLoadingDeployedState={isLoadingDeployedState}
569+
isAwaitingSnapshot={isAwaitingSnapshot}
564570
versions={versions}
565571
versionsLoading={versionsLoading}
566572
isPromotingVersion={isActivatingVersion || activateVersionMutation.isPending}

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx

Lines changed: 28 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,11 @@ import { Chip, Tooltip, toast } from '@sim/emcn'
55
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
66
import { DeployModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal'
77
import {
8-
resolveDeployButtonStatus,
9-
useChangeDetection,
10-
useChangeDetectionCanary,
118
useDeployment,
9+
useDeploymentViewState,
1210
useDeployReadiness,
1311
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks'
1412
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow'
15-
import { useDeployedWorkflowState, useDeploymentInfo } from '@/hooks/queries/deployments'
1613
import type { WorkspaceUserPermissions } from '@/hooks/use-user-permissions'
1714
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
1815

@@ -28,55 +25,21 @@ export function Deploy({ activeWorkflowId, userPermissions, disabled = false }:
2825
const isRegistryLoading = hydrationPhase === 'idle' || hydrationPhase === 'state-loading'
2926
const { hasBlocks } = useCurrentWorkflow()
3027

31-
const { data: deploymentInfo } = useDeploymentInfo(activeWorkflowId, {
32-
enabled: !isRegistryLoading,
33-
})
34-
const isDeployed = deploymentInfo?.isDeployed ?? false
35-
36-
const isDeployedStateEnabled = Boolean(activeWorkflowId) && isDeployed && !isRegistryLoading
37-
const {
38-
data: deployedStateData,
39-
isLoading: isLoadingDeployedState,
40-
isFetching: isFetchingDeployedState,
41-
} = useDeployedWorkflowState(activeWorkflowId, { enabled: isDeployedStateEnabled })
42-
const deployedState = isDeployedStateEnabled ? (deployedStateData ?? null) : null
4328
const deployReadiness = useDeployReadiness(activeWorkflowId)
4429

4530
/*
46-
* `isLoading` (no snapshot yet), NOT `isFetching`. A background refetch — which
47-
* `refetchOnWindowFocus` fires on every focus — still has the cached snapshot
48-
* to compare against, so treating it as loading blanked the answer and pushed
49-
* an already-correct "Update" back through "Live" and out again.
31+
* One derivation for the chip, the modal preview and the modal footer. They
32+
* previously each read their own mix of raw flags, which is how the preview
33+
* could say "Deploy your workflow to see a preview" while the version list
34+
* beneath it said `v1 (live)`.
5035
*/
51-
const { changeDetected, changedFields, isChangeDetectionSettling } = useChangeDetection({
36+
const deployment = useDeploymentViewState({
5237
workflowId: activeWorkflowId,
53-
deployedState,
54-
isLoadingDeployedState,
55-
})
56-
const isDeploymentSettling = isChangeDetectionSettling || deployReadiness.isSyncing
57-
58-
const serverNeedsRedeployment = isDeployedStateEnabled
59-
? deploymentInfo?.needsRedeployment
60-
: undefined
61-
62-
const buttonStatus = resolveDeployButtonStatus({
63-
workflowId: activeWorkflowId,
64-
isDeployed,
65-
isAwaitingFirstDeployedState: isLoadingDeployedState,
66-
clientChangeDetected: changeDetected,
67-
hasDeployedState: deployedState !== null,
68-
serverNeedsRedeployment,
69-
})
70-
const changeDetectedForModal = buttonStatus === 'changed'
71-
72-
useChangeDetectionCanary({
73-
workflowId: activeWorkflowId,
74-
clientChangeDetected: changeDetected,
75-
clientChangedFields: changedFields,
76-
serverNeedsRedeployment,
77-
isSettling: isDeploymentSettling || deployedState === null,
78-
isSettled: deployReadiness.status === 'ready',
38+
enabled: !isRegistryLoading,
39+
deployReadiness,
7940
})
41+
const { status: buttonStatus, isDeployed, deployedState } = deployment
42+
const isDeploymentSettling = deployment.isSettling
8043

8144
const { isDeploying, handleDeployClick } = useDeployment({
8245
workflowId: activeWorkflowId,
@@ -135,7 +98,7 @@ export function Deploy({ activeWorkflowId, userPermissions, disabled = false }:
13598
if (isDeploying) {
13699
return 'Deploying...'
137100
}
138-
if (isChangeDetectionSettling) {
101+
if (isDeploymentSettling) {
139102
return 'Syncing deployment state...'
140103
}
141104
if (deployReadiness.isBlocked && !isDeployed) {
@@ -151,6 +114,22 @@ export function Deploy({ activeWorkflowId, userPermissions, disabled = false }:
151114
}
152115

153116
const getButtonLabel = () => {
117+
/*
118+
* The label carries the busy state, matching every sibling control on this
119+
* surface (`{isUndeploying ? 'Undeploying...' : 'Undeploy'}` in the modal
120+
* footer) and the vocabulary `deployReadiness` already speaks. This chip was
121+
* the one button that announced nothing and merely went disabled.
122+
*
123+
* Scoped to the deploy action, which is bounded by the mutation. The
124+
* readiness states are deliberately NOT surfaced here: `saving` fires on
125+
* every settled keystroke, so rendering it would reintroduce exactly the
126+
* label churn this state machine exists to remove. Those stay in the
127+
* tooltip, where they explain why the button is disabled.
128+
*/
129+
if (isDeploying) {
130+
return 'Deploying...'
131+
}
132+
154133
switch (buttonStatus) {
155134
case 'changed':
156135
return 'Update'
@@ -186,12 +165,8 @@ export function Deploy({ activeWorkflowId, userPermissions, disabled = false }:
186165
open={isModalOpen}
187166
onOpenChange={setIsModalOpen}
188167
workflowId={activeWorkflowId}
189-
isDeployed={isDeployed}
190-
needsRedeployment={changeDetectedForModal}
191-
deployedState={deployedState}
192-
isLoadingDeployedState={isLoadingDeployedState || isFetchingDeployedState}
168+
deployment={deployment}
193169
deployReadiness={deployReadiness}
194-
isDeploymentSettling={isDeploymentSettling}
195170
/>
196171
</>
197172
)

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,5 @@ export { resolveDeployButtonStatus } from './use-deploy-button-status'
55
export type { DeployReadiness } from './use-deploy-readiness'
66
export { getDeployReadinessState, useDeployReadiness } from './use-deploy-readiness'
77
export { useDeployment } from './use-deployment'
8+
export type { DeploymentViewState } from './use-deployment-view-state'
9+
export { useDeploymentViewState } from './use-deployment-view-state'
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { useChangeDetection } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-change-detection'
2+
import { useChangeDetectionCanary } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-change-detection-canary'
3+
import {
4+
type DeployButtonStatus,
5+
resolveDeployButtonStatus,
6+
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-deploy-button-status'
7+
import type { DeployReadiness } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-deploy-readiness'
8+
import { useDeployedWorkflowState, useDeploymentInfo } from '@/hooks/queries/deployments'
9+
import type { WorkflowState } from '@/stores/workflows/workflow/types'
10+
11+
export interface DeploymentViewState {
12+
/** The single verdict every deploy surface renders from. */
13+
status: DeployButtonStatus
14+
isDeployed: boolean
15+
/** The active deployment's snapshot, or null while it is not in hand. */
16+
deployedState: WorkflowState | null
17+
/**
18+
* A snapshot is expected but has not arrived. Distinct from "there is no
19+
* snapshot": the difference is what separates a skeleton from telling the user
20+
* their workflow is not deployed.
21+
*/
22+
isAwaitingSnapshot: boolean
23+
isSettling: boolean
24+
changeDetected: boolean
25+
changedFields: string[]
26+
}
27+
28+
interface UseDeploymentViewStateProps {
29+
workflowId: string | null
30+
enabled: boolean
31+
deployReadiness: DeployReadiness
32+
}
33+
34+
/**
35+
* Owns every derived answer the deploy surface renders — the chip's label, the
36+
* modal's preview, the modal's footer — so they cannot disagree.
37+
*
38+
* They used to. The chip resolved a status; the modal read raw `isDeployed` and
39+
* `needsRedeployment`; the General tab decided "not deployed" from the *absence
40+
* of a snapshot*. That last one is the defect that produced "Deploy your
41+
* workflow to see a preview" sitting directly above a row reading `v1 (live)`:
42+
* a missing snapshot is not evidence of anything, and rendering it as one made
43+
* the modal contradict itself.
44+
*
45+
* Which is the same failure this PR fixes one layer down — several derivations
46+
* of one fact, drifting — so it gets the same treatment: derive once, pass it
47+
* down, and give the surfaces no raw material to re-derive from.
48+
*/
49+
export function useDeploymentViewState({
50+
workflowId,
51+
enabled,
52+
deployReadiness,
53+
}: UseDeploymentViewStateProps): DeploymentViewState {
54+
const { data: deploymentInfo } = useDeploymentInfo(workflowId, { enabled })
55+
const isDeployed = deploymentInfo?.isDeployed ?? false
56+
57+
const snapshotEnabled = Boolean(workflowId) && isDeployed && enabled
58+
const { data: deployedStateData, isLoading: isLoadingDeployedState } = useDeployedWorkflowState(
59+
workflowId,
60+
{ enabled: snapshotEnabled }
61+
)
62+
const deployedState = snapshotEnabled ? (deployedStateData ?? null) : null
63+
64+
/*
65+
* `isLoading` (no snapshot yet), NOT `isFetching`. A background refetch — which
66+
* `refetchOnWindowFocus` fires on every focus — still has the cached snapshot
67+
* to compare against, so treating it as loading blanked the answer and pushed
68+
* an already-correct "Update" back through "Live" and out again.
69+
*/
70+
const { changeDetected, changedFields, isChangeDetectionSettling } = useChangeDetection({
71+
workflowId,
72+
deployedState,
73+
isLoadingDeployedState,
74+
})
75+
76+
const serverNeedsRedeployment = snapshotEnabled ? deploymentInfo?.needsRedeployment : undefined
77+
78+
const status = resolveDeployButtonStatus({
79+
workflowId,
80+
isDeployed,
81+
isAwaitingFirstDeployedState: isLoadingDeployedState,
82+
clientChangeDetected: changeDetected,
83+
hasDeployedState: deployedState !== null,
84+
serverNeedsRedeployment,
85+
})
86+
87+
const isSettling = isChangeDetectionSettling || deployReadiness.isSyncing
88+
89+
useChangeDetectionCanary({
90+
workflowId,
91+
clientChangeDetected: changeDetected,
92+
clientChangedFields: changedFields,
93+
serverNeedsRedeployment,
94+
isSettling: isSettling || deployedState === null,
95+
isSettled: deployReadiness.status === 'ready',
96+
})
97+
98+
return {
99+
status,
100+
isDeployed,
101+
deployedState,
102+
isAwaitingSnapshot: snapshotEnabled && deployedState === null,
103+
isSettling,
104+
changeDetected,
105+
changedFields,
106+
}
107+
}

apps/sim/hooks/queries/deployments.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ export type { ChatDetail, DeploymentVersionsResponse }
3434
export const DEPLOYMENT_INFO_STALE_TIME = 30 * 1000
3535
export const DEPLOYMENT_STATUS_REFETCH_INTERVAL = 5 * 1000
3636
export const DEPLOYED_WORKFLOW_STATE_STALE_TIME = 30 * 1000
37+
/** Retry cadence while the deployed snapshot is expected but not yet readable. */
38+
export const DEPLOYED_STATE_RECOVERY_INTERVAL = 3 * 1000
3739
export const DEPLOYMENT_VERSIONS_STALE_TIME = 30 * 1000
3840
export const CHAT_DEPLOYMENT_STATUS_STALE_TIME = 30 * 1000
3941
export const CHAT_DETAIL_STALE_TIME = 30 * 1000
@@ -158,6 +160,19 @@ export function useDeployedWorkflowState(
158160
queryFn: ({ signal }) => fetchDeployedWorkflowState(workflowId!, signal),
159161
enabled: Boolean(workflowId) && (options?.enabled ?? true),
160162
staleTime: DEPLOYED_WORKFLOW_STATE_STALE_TIME,
163+
/*
164+
* Callers enable this only once deployment info reports the workflow as
165+
* deployed, so a null snapshot is a contradiction rather than an answer:
166+
* the version exists but is not readable yet.
167+
*
168+
* Nothing re-invalidates this key when activation cuts over —
169+
* `refetchDeploymentBoundary` fires while the query is still disabled, and a
170+
* disabled query cannot be refetched — so the null was cached for the whole
171+
* stale window. That is what put an empty preview next to a live version
172+
* row. Retrying resolves the contradiction and stops the instant it does.
173+
*/
174+
refetchInterval: (query) =>
175+
query.state.data == null ? DEPLOYED_STATE_RECOVERY_INTERVAL : false,
161176
})
162177
}
163178

0 commit comments

Comments
 (0)