mirror of
https://github.com/simstudioai/sim.git
synced 2026-08-29 02:27:35 +08:00
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:
@@ -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({
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user