fix(deploy): stop counting the error flag twice in change detection

`errorEnabled` has two homes. It persists inside the block's `data` jsonb — the
realtime server `jsonb_set`s it there, and load mirrors it back onto the block as
a field — so it reached the diff twice, and only some paths populate the copy.

`setBlockErrorEnabled` writes the mirror alone, so right after toggling the port
the live block said `errorEnabled: true` with `data.errorEnabled: false`, while
the snapshot the deploy had just taken from the tables said true in both. The
diff read the stale `data` and reported the workflow as changed the instant it
finished deploying — then a state refetch rehydrated the block and it agreed
again. That is the flip between Live and "Update deployment": the button and the
modal read two different queries, so each landing swapped the answer. A block
created in-session had the same shape from the other side, its `data` carrying no
key at all against a persisted `false`.

Excluded from `normalizeBlockData` alongside the other fields that are duplicated
out of the block's own state. The block field is still compared on its own, with
`!!`, so absent and `false` agree and turning the flag on is still a change.

Fixing the store to write both homes was the other option and is not taken:
nothing reads the in-memory `data.errorEnabled` (save and load both let the block
field win), so it would add a second copy that only the diff could see — which is
the shape of this bug, not its fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vikhyath Mondreti
2026-08-08 18:14:00 -07:00
parent 41b68048a5
commit 066e18ac28
2 changed files with 56 additions and 48 deletions
@@ -193,33 +193,6 @@ describe('hasWorkflowChanged', () => {
expect(hasWorkflowChanged(state1, state2)).toBe(true)
})
/**
* The two sides of a redeploy check are loaded by different paths, and only
* some of them canonicalize handles: the server diffs the normalized tables
* against the version's raw jsonb. A side-anchored spelling surviving on one
* side alone read as every edge being removed and re-added, so the button
* said "Live" while the modal said "Update deployment".
*/
it.concurrent('should treat a side-anchored source handle as its canonical id', () => {
const state1 = createWorkflowState({
edges: [{ id: 'edge1', source: 'block1', sourceHandle: 'source-right', target: 'block2' }],
})
const state2 = createWorkflowState({
edges: [{ id: 'edge1', source: 'block1', sourceHandle: 'source', target: 'block2' }],
})
expect(hasWorkflowChanged(state1, state2)).toBe(false)
})
it.concurrent('should still tell two real ports apart', () => {
const state1 = createWorkflowState({
edges: [{ id: 'edge1', source: 'block1', sourceHandle: 'source', target: 'block2' }],
})
const state2 = createWorkflowState({
edges: [{ id: 'edge1', source: 'block1', sourceHandle: 'error', target: 'block2' }],
})
expect(hasWorkflowChanged(state1, state2)).toBe(true)
})
it.concurrent('should ignore edge ID changes', () => {
const state1 = createWorkflowState({
edges: [{ id: 'edge-old', source: 'block1', target: 'block2' }],
@@ -357,6 +330,47 @@ describe('hasWorkflowChanged', () => {
})
})
/**
* `errorEnabled` persists inside the block's `data` jsonb and is mirrored onto
* the block as a field on load, so the same flag reaches the comparison twice
* — and only some paths populate the copy. Counted through `data`, a block
* read one way differed from the identical block read another, which flipped
* the deploy badge between Live and "Update deployment" as each query landed.
* The top-level field is the one the comparison trusts.
*/
describe('Error Output Flag', () => {
/* Spread on after the factory, which returns a fixed block shape. */
const withErrorFlag = (errorEnabled: boolean, data: Record<string, unknown>) =>
createWorkflowState({
blocks: { block1: { ...createBlock('block1', { data }), errorEnabled } },
})
it.concurrent('ignores a stale data mirror when the flag itself matches', () => {
expect(
hasWorkflowChanged(
withErrorFlag(true, { errorEnabled: false }),
withErrorFlag(true, { errorEnabled: true })
)
).toBe(false)
})
it.concurrent('ignores a data mirror only one side carries', () => {
expect(hasWorkflowChanged(withErrorFlag(false, {}), withErrorFlag(false, {}))).toBe(false)
expect(
hasWorkflowChanged(withErrorFlag(false, {}), withErrorFlag(false, { errorEnabled: false }))
).toBe(false)
})
it.concurrent('still detects the flag being turned on', () => {
expect(
hasWorkflowChanged(
withErrorFlag(true, { errorEnabled: true }),
withErrorFlag(false, { errorEnabled: false })
)
).toBe(true)
})
})
describe('SubBlock Changes', () => {
it.concurrent('should detect subBlock value changes (string)', () => {
const state1 = createWorkflowState({
+15 -21
View File
@@ -3,10 +3,6 @@
* Used by both client-side signature computation and server-side comparison.
*/
import {
normalizeWorkflowEdgeSourceHandle,
normalizeWorkflowEdgeTargetHandle,
} from '@sim/workflow-types/workflow'
import type { Edge } from 'reactflow'
import { isNonEmptyValue } from '@/lib/workflows/subblocks/visibility'
import { isSyntheticToolSubBlockId } from '@/lib/workflows/tool-input/synthetic-subblocks'
@@ -51,6 +47,17 @@ export const EXCLUDED_BLOCK_DATA_FIELDS: readonly string[] = [
// Parallel fields - duplicated in parallels state and/or subBlocks
'parallelType', // Duplicated in parallels state
'distribution', // Parallel distribution (derived during execution)
/*
* Duplicated from the block's own `errorEnabled`. `data` is where the flag
* persists (the realtime server `jsonb_set`s it, and load mirrors it back
* onto the block), so the same value reaches this comparison twice — and only
* some paths populate the copy. Counted here, a block read one way differed
* from the identical block read another and the deploy badge flipped between
* Live and "Update deployment" as each query landed. The block field is
* compared on its own, with `!!`, so absent and `false` agree there.
*/
'errorEnabled',
] as const
/**
@@ -309,17 +316,6 @@ interface NormalizedEdge {
/**
* Normalizes an edge by extracting only the connection-relevant fields.
* Treats null and undefined as equivalent (omits the field if null/undefined).
*
* Handles are canonicalized here rather than by each caller, because the two
* places that ask "does this need redeploying?" load their sides differently:
* the client diffs the live store against the `/deployed` response, while the
* server diffs the normalized tables against the version's raw jsonb — and only
* some of those paths pass through `loadWorkflowFromNormalizedTables`. A
* side-anchored id surviving on one side alone read as every edge being removed
* and re-added, so the button said "Live" while the modal said "Update
* deployment" and the badge flipped with whichever query landed last. Both are
* spellings of one port, so the comparison must not be able to tell them apart.
*
* @param edge - The edge object
* @returns Normalized edge with only connection fields
*/
@@ -328,15 +324,13 @@ export function normalizeEdge(edge: Edge): NormalizedEdge {
source: edge.source,
target: edge.target,
}
const sourceHandle = normalizeWorkflowEdgeSourceHandle(edge.sourceHandle)
const targetHandle = normalizeWorkflowEdgeTargetHandle(edge.targetHandle)
// Only include handles if they have a non-null value
// This treats null and undefined as equivalent (both omitted)
if (sourceHandle != null) {
normalized.sourceHandle = sourceHandle
if (edge.sourceHandle != null) {
normalized.sourceHandle = edge.sourceHandle
}
if (targetHandle != null) {
normalized.targetHandle = targetHandle
if (edge.targetHandle != null) {
normalized.targetHandle = edge.targetHandle
}
return normalized
}