Skip to content

Commit fac8ec0

Browse files
authored
fix(workflow-edges): enforce edge/block validation server-side, not just client-side (#5571)
* fix(workflow-edges): enforce edge/block validation server-side, not just client-side Dragging a connection that creates a cycle correctly refused to render client-side, but the cyclic edge was still queued for realtime persistence and written to the DB, so it reappeared after refresh. Root cause: cycle detection (and several other edge/block rules) only lived in the client Zustand store and was never enforced by the realtime persistence layer, which is the actual source of truth on reload. - Move wouldCreateCycle, edge scope-boundary, annotation-only-block, duplicate-edge, and block-name-conflict checks into @sim/workflow-types so the client store, the collaborative queueing layer, and apps/realtime's DB write path all share one implementation - Wire these into apps/realtime/database/operations.ts's edge-add and block-rename handlers as the authoritative gate - Client-side behavior is unchanged (same call sites, same error messages, same rule ordering) — verified via existing + new test coverage * fix(workflow-edges): address review findings on realtime edge validation - Select triggerMode when fetching blocks for edge-add validation — isKnownWorkflowTriggerBlock checked block.triggerMode but the column was never fetched from the DB, so trigger-mode blocks could still receive an incoming edge (Cursor Bugbot) - Make filterUniqueWorkflowEdges incremental, so two duplicate edges within the same BATCH_ADD_EDGES payload are also deduped instead of both surviving (Greptile) * fix(workflow-edges): normalize empty-string handles in duplicate-edge check filterUniqueWorkflowEdges compared handles with ??, so a `sourceHandle: ''` edge wasn't recognized as a duplicate of an existing null-handle edge — even though both get persisted as the same null value at insert time (edge.sourceHandle || null). Falsy-coalesce in the comparison so '' and null/undefined are treated as the same "no handle" state everywhere. (Greptile) * improvement(workflow-edges): dedup realtime edge-add validation, reuse block-name-conflict helper /simplify pass on the already-merged-quality PR before sign-off: - Extract filterEdgesForPersist in apps/realtime/src/database/operations.ts: the single-edge ADD and batch BATCH_ADD_EDGES handlers hand-inlined the same six-step validation pipeline (missing block, protected target, annotation-only, trigger-target, scope boundary, duplicate, cycle) and each independently re-fetched blocksById/existingEdgesForCycleCheck. Two copies of one rule in the same file was exactly the drift risk this PR otherwise closes across client/server. One shared helper now backs both. Net -63 lines despite the new shared function. - Fix a real bug this surfaced: droppedCounts was keyed by the free-text, block-id-bearing scope-boundary message, so it could never aggregate across edges/runs. Now keyed by a stable 'scope boundary' reason. - use-collaborative-workflow.ts's collaborativeUpdateBlockName still hand-rolled the empty/reserved/duplicate block-name-conflict checks this PR centralized as getWorkflowBlockNameConflict (already adopted by store.ts). Switched it to the shared helper, which also fixes a latent check-order mismatch between the two (this pre-check ran reserved before duplicate; the store's real gate — after this PR's own store.ts change — runs duplicate before reserved, so they could disagree on which toast a name that was both reserved and duplicate would surface). * docs(workflow-edges): document the per-workflow write-serialization invariant No behavior change. Address Greptile P1 on filterEdgesForPersist ('concurrent duplicate writes can persist without a per-workflow write guard') by documenting, at the actual mechanism, why the concern doesn't apply here: persistWorkflowOperation's leading 'UPDATE workflow SET updatedAt ... WHERE id = workflowId' already takes a row lock that serializes every operation (including edge adds) for a given workflowId — a second concurrent call blocks on that UPDATE until the first transaction commits or rolls back, so the validate-then-insert sequence in filterEdgesForPersist can never interleave across two writers on the same workflow. Verified empirically, not just by reading: ran two concurrent transactions against a throwaway local Postgres against the exact statement shape used here (UPDATE the parent row, sleep to simulate the read/validate window, insert, commit). The second transaction's UPDATE blocked for the full duration of the first's transaction and only proceeded once the first committed — confirming the row lock, not any application-level guard, already provides the serialization Greptile flagged as missing. Added a comment at the lock site (not a second, redundant advisory lock) so a future change can't silently break this invariant by making the UPDATE conditional/skippable as a perceived no-op optimization. * fix(workflow-edges): validate edges in BATCH_ADD_BLOCKS before persisting Real gap Cursor's PR summary flagged ('BATCH_ADD_BLOCKS edge inserts... not fully covered by the new server pipeline'), confirmed by reading the code: this handler bulk-inserted the edges from a block-paste/duplicate/import payload directly into workflowEdges with zero validation — no missing-block, protected-target, annotation-only, trigger-target, scope-boundary, duplicate, or cycle check. A client sending edges through this operation instead of BATCH_ADD_EDGES could bypass every rule this PR otherwise enforces server-side, exactly the class of gap the PR exists to close. Routes it through the same filterEdgesForPersist used by the other two edge-add handlers. Runs after the block insert in this same handler, so the shared helper's blocksById lookup also sees the blocks this same batch just inserted (a transaction observes its own prior writes).
1 parent 43e61a1 commit fac8ec0

10 files changed

Lines changed: 809 additions & 268 deletions

File tree

apps/realtime/src/database/operations.ts

Lines changed: 286 additions & 134 deletions
Large diffs are not rendered by default.

apps/sim/executor/constants.ts

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
import {
2+
normalizeWorkflowBlockName,
3+
RESERVED_WORKFLOW_BLOCK_NAMES,
4+
} from '@sim/workflow-types/workflow'
15
import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
26
import type { LoopType, ParallelType } from '@/lib/workflows/types'
37

@@ -151,11 +155,12 @@ export const SPECIAL_REFERENCE_PREFIXES = [
151155
REFERENCE.PREFIX.VARIABLE,
152156
] as const
153157

154-
export const RESERVED_BLOCK_NAMES = [
155-
REFERENCE.PREFIX.LOOP,
156-
REFERENCE.PREFIX.PARALLEL,
157-
REFERENCE.PREFIX.VARIABLE,
158-
] as const
158+
/**
159+
* Delegates to the shared implementation in `@sim/workflow-types` so the
160+
* client store and the realtime persistence layer agree on the same reserved
161+
* names. Values intentionally mirror REFERENCE.PREFIX.{LOOP,PARALLEL,VARIABLE} above.
162+
*/
163+
export const RESERVED_BLOCK_NAMES = RESERVED_WORKFLOW_BLOCK_NAMES
159164

160165
export const LOOP_REFERENCE = {
161166
ITERATION: 'iteration',
@@ -478,12 +483,10 @@ export function escapeRegExp(value: string): string {
478483
* spaces and dots. Used for both block names and variable names to ensure
479484
* consistent matching.
480485
*
481-
* Dots are stripped because `.` is the reference path delimiter — a name like
482-
* "Trigger.dev 1" must normalize to "triggerdev1" so the reference
483-
* `<triggerdev1.output>` parses unambiguously. Dotted names could never be
484-
* referenced before (the first path segment cut the name at the dot), so
485-
* stripping dots cannot break any previously working reference.
486+
* Delegates to the shared implementation in `@sim/workflow-types` so the
487+
* client store and the realtime persistence layer normalize block names
488+
* identically when checking for reserved/duplicate names.
486489
*/
487490
export function normalizeName(name: string): string {
488-
return name.toLowerCase().replace(/\s+/g, '').replace(/\./g, '')
491+
return normalizeWorkflowBlockName(name)
489492
}

apps/sim/hooks/use-collaborative-workflow.ts

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
WORKFLOW_OPERATIONS,
1313
} from '@sim/realtime-protocol/constants'
1414
import { generateId } from '@sim/utils/id'
15+
import { getWorkflowBlockNameConflict } from '@sim/workflow-types/workflow'
1516
import { useQueryClient } from '@tanstack/react-query'
1617
import { isEqual } from 'es-toolkit'
1718
import type { Edge } from 'reactflow'
@@ -27,7 +28,6 @@ import { isSyntheticToolSubBlockId } from '@/lib/workflows/tool-input/synthetic-
2728
import { useSocket } from '@/app/workspace/providers/socket-provider'
2829
import { getBlock } from '@/blocks'
2930
import { getSubBlocksDependingOnChange } from '@/blocks/utils'
30-
import { normalizeName, RESERVED_BLOCK_NAMES } from '@/executor/constants'
3131
import { invalidateDeploymentQueries } from '@/hooks/queries/deployments'
3232
import { useUndoRedo } from '@/hooks/use-undo-redo'
3333
import {
@@ -54,7 +54,11 @@ import type {
5454
Position,
5555
WorkflowState,
5656
} from '@/stores/workflows/workflow/types'
57-
import { findAllDescendantNodes, isBlockProtected } from '@/stores/workflows/workflow/utils'
57+
import {
58+
filterAcyclicEdges,
59+
findAllDescendantNodes,
60+
isBlockProtected,
61+
} from '@/stores/workflows/workflow/utils'
5862

5963
const logger = createLogger('CollaborativeWorkflow')
6064

@@ -1028,27 +1032,26 @@ export function useCollaborativeWorkflow() {
10281032
}
10291033

10301034
const trimmedName = name.trim()
1031-
const normalizedNewName = normalizeName(trimmedName)
1035+
const currentBlocks = useWorkflowStore.getState().blocks
1036+
const siblingNamesById = Object.fromEntries(
1037+
Object.entries(currentBlocks).map(([blockId, b]) => [blockId, b.name])
1038+
)
1039+
const conflict = getWorkflowBlockNameConflict(id, trimmedName, siblingNamesById)
10321040

1033-
if (!normalizedNewName) {
1041+
if (conflict?.reason === 'empty') {
10341042
logger.error('Cannot rename block to empty name')
10351043
toast.error('Block name cannot be empty')
10361044
return { success: false, error: 'Block name cannot be empty' }
10371045
}
10381046

1039-
if ((RESERVED_BLOCK_NAMES as readonly string[]).includes(normalizedNewName)) {
1047+
if (conflict?.reason === 'reserved') {
10401048
logger.error(`Cannot rename block to reserved name: "${trimmedName}"`)
10411049
toast.error(`"${trimmedName}" is a reserved name and cannot be used`)
10421050
return { success: false, error: `"${trimmedName}" is a reserved name` }
10431051
}
10441052

1045-
const currentBlocks = useWorkflowStore.getState().blocks
1046-
const conflictingBlock = Object.entries(currentBlocks).find(
1047-
([blockId, block]) => blockId !== id && normalizeName(block.name) === normalizedNewName
1048-
)
1049-
1050-
if (conflictingBlock) {
1051-
const conflictName = conflictingBlock[1].name
1053+
if (conflict?.reason === 'duplicate') {
1054+
const conflictName = currentBlocks[conflict.conflictingBlockId as string].name
10521055
logger.error(`Cannot rename block to "${trimmedName}" - conflicts with "${conflictName}"`)
10531056
toast.error(`Block name "${trimmedName}" already exists`)
10541057
return { success: false, error: `Block name "${trimmedName}" already exists` }
@@ -1423,7 +1426,12 @@ export function useCollaborativeWorkflow() {
14231426
const currentEdges = useWorkflowStore.getState().edges
14241427
const validEdges = filterValidEdges(edges, blocks)
14251428
const newEdges = filterNewEdges(validEdges, currentEdges)
1426-
if (newEdges.length === 0) return false
1429+
// Reject cyclic edges here, before they are queued for realtime/DB
1430+
// persistence — the local store also runs this check, but only after
1431+
// an unfiltered payload would already be enqueued. Filtering once
1432+
// here keeps the queued payload and the local store in agreement.
1433+
const acyclicEdges = filterAcyclicEdges(newEdges, currentEdges)
1434+
if (acyclicEdges.length === 0) return false
14271435

14281436
const operationId = generateId()
14291437

@@ -1432,16 +1440,16 @@ export function useCollaborativeWorkflow() {
14321440
operation: {
14331441
operation: EDGES_OPERATIONS.BATCH_ADD_EDGES,
14341442
target: OPERATION_TARGETS.EDGES,
1435-
payload: { edges: newEdges },
1443+
payload: { edges: acyclicEdges },
14361444
},
14371445
workflowId: activeWorkflowId || '',
14381446
userId: session?.user?.id || 'unknown',
14391447
})
14401448

1441-
useWorkflowStore.getState().batchAddEdges(newEdges, { skipValidation: true })
1449+
useWorkflowStore.getState().batchAddEdges(acyclicEdges, { skipValidation: true })
14421450

14431451
if (!options?.skipUndoRedo) {
1444-
newEdges.forEach((edge) => undoRedo.recordAddEdge(edge.id))
1452+
acyclicEdges.forEach((edge) => undoRedo.recordAddEdge(edge.id))
14451453
}
14461454

14471455
return true

apps/sim/stores/workflows/utils.ts

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { generateId } from '@sim/utils/id'
22
import { mergeSubblockStateWithValues } from '@sim/workflow-persistence/subblocks'
3+
import { filterUniqueWorkflowEdges } from '@sim/workflow-types/workflow'
34
import type { Edge } from 'reactflow'
45
import { DEFAULT_DUPLICATE_OFFSET } from '@/lib/workflows/autolayout/constants'
56
import { getEffectiveBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
@@ -32,16 +33,7 @@ export function filterValidEdges(edges: Edge[], blocks: Record<string, BlockStat
3233
}
3334

3435
export function filterNewEdges(edgesToAdd: Edge[], currentEdges: Edge[]): Edge[] {
35-
return edgesToAdd.filter((edge) => {
36-
if (edge.source === edge.target) return false
37-
return !currentEdges.some(
38-
(e) =>
39-
e.source === edge.source &&
40-
e.sourceHandle === edge.sourceHandle &&
41-
e.target === edge.target &&
42-
e.targetHandle === edge.targetHandle
43-
)
44-
})
36+
return filterUniqueWorkflowEdges(edgesToAdd, currentEdges)
4537
}
4638

4739
export interface RegeneratedState {
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import type { Edge } from 'reactflow'
2+
import { describe, expect, it } from 'vitest'
3+
import { validateEdges } from '@/stores/workflows/workflow/edge-validation'
4+
import type { BlockState } from '@/stores/workflows/workflow/types'
5+
6+
function makeBlock(id: string, type: string, overrides?: Partial<BlockState>): BlockState {
7+
return {
8+
id,
9+
type,
10+
name: id,
11+
position: { x: 0, y: 0 },
12+
subBlocks: {},
13+
outputs: {},
14+
enabled: true,
15+
...overrides,
16+
}
17+
}
18+
19+
function makeEdge(id: string, source: string, target: string): Edge {
20+
return { id, source, target, type: 'default' }
21+
}
22+
23+
describe('validateEdges', () => {
24+
it('accepts an edge between two root-scope blocks', () => {
25+
const blocks = {
26+
a: makeBlock('a', 'starter'),
27+
b: makeBlock('b', 'function'),
28+
}
29+
const result = validateEdges([makeEdge('e1', 'a', 'b')], blocks)
30+
expect(result.valid).toHaveLength(1)
31+
expect(result.dropped).toHaveLength(0)
32+
})
33+
34+
it('drops an edge referencing a missing block', () => {
35+
const blocks = { a: makeBlock('a', 'starter') }
36+
const result = validateEdges([makeEdge('e1', 'a', 'missing')], blocks)
37+
expect(result.valid).toHaveLength(0)
38+
expect(result.dropped[0].reason).toBe('edge references a missing block')
39+
})
40+
41+
it('drops an edge touching an annotation-only (note) block', () => {
42+
const blocks = {
43+
a: makeBlock('a', 'note'),
44+
b: makeBlock('b', 'function'),
45+
}
46+
const result = validateEdges([makeEdge('e1', 'a', 'b')], blocks)
47+
expect(result.valid).toHaveLength(0)
48+
expect(result.dropped[0].reason).toBe('edge references an annotation-only block')
49+
})
50+
51+
it('drops an edge targeting a trigger block', () => {
52+
const blocks = {
53+
a: makeBlock('a', 'function'),
54+
b: makeBlock('b', 'function', { triggerMode: true }),
55+
}
56+
const result = validateEdges([makeEdge('e1', 'a', 'b')], blocks)
57+
expect(result.valid).toHaveLength(0)
58+
expect(result.dropped[0].reason).toBe('trigger blocks cannot be edge targets')
59+
})
60+
61+
it('drops an edge crossing loop scope boundaries', () => {
62+
const blocks = {
63+
loop: makeBlock('loop', 'loop'),
64+
inner: makeBlock('inner', 'function', { data: { parentId: 'loop', extent: 'parent' } }),
65+
outer: makeBlock('outer', 'function'),
66+
}
67+
const result = validateEdges([makeEdge('e1', 'inner', 'outer')], blocks)
68+
expect(result.valid).toHaveLength(0)
69+
expect(result.dropped[0].reason).toContain('different scopes')
70+
})
71+
72+
it('accepts an edge from a loop container into its own child', () => {
73+
const blocks = {
74+
loop: makeBlock('loop', 'loop'),
75+
inner: makeBlock('inner', 'function', { data: { parentId: 'loop', extent: 'parent' } }),
76+
}
77+
const result = validateEdges([makeEdge('e1', 'loop', 'inner')], blocks)
78+
expect(result.valid).toHaveLength(1)
79+
})
80+
81+
it('accepts an edge from a loop child back out to its own container', () => {
82+
const blocks = {
83+
loop: makeBlock('loop', 'loop'),
84+
inner: makeBlock('inner', 'function', { data: { parentId: 'loop', extent: 'parent' } }),
85+
}
86+
const result = validateEdges([makeEdge('e1', 'inner', 'loop')], blocks)
87+
expect(result.valid).toHaveLength(1)
88+
})
89+
90+
it('accepts edges between two siblings inside the same loop', () => {
91+
const blocks = {
92+
loop: makeBlock('loop', 'loop'),
93+
a: makeBlock('a', 'function', { data: { parentId: 'loop', extent: 'parent' } }),
94+
b: makeBlock('b', 'function', { data: { parentId: 'loop', extent: 'parent' } }),
95+
}
96+
const result = validateEdges([makeEdge('e1', 'a', 'b')], blocks)
97+
expect(result.valid).toHaveLength(1)
98+
})
99+
})

apps/sim/stores/workflows/workflow/edge-validation.ts

Lines changed: 9 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
import {
2+
getWorkflowEdgeScopeDropReason,
3+
isWorkflowAnnotationOnlyBlockType,
4+
} from '@sim/workflow-types/workflow'
15
import type { Edge } from 'reactflow'
26
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
3-
import { isAnnotationOnlyBlock } from '@/executor/constants'
47
import type { BlockState } from '@/stores/workflows/workflow/types'
58

69
interface DroppedEdge {
@@ -13,40 +16,6 @@ export interface EdgeValidationResult {
1316
dropped: DroppedEdge[]
1417
}
1518

16-
function isContainerBlock(block: BlockState | undefined): boolean {
17-
return block?.type === 'loop' || block?.type === 'parallel'
18-
}
19-
20-
function getParentId(block: BlockState | undefined): string | null {
21-
return block?.data?.parentId ?? null
22-
}
23-
24-
function getScopeDropReason(edge: Edge, blocks: Record<string, BlockState>): string | null {
25-
const sourceBlock = blocks[edge.source]
26-
const targetBlock = blocks[edge.target]
27-
28-
if (!sourceBlock || !targetBlock) {
29-
return 'edge references a missing block'
30-
}
31-
32-
const sourceParent = getParentId(sourceBlock)
33-
const targetParent = getParentId(targetBlock)
34-
35-
if (sourceParent === targetParent) {
36-
return null
37-
}
38-
39-
if (targetParent === edge.source && isContainerBlock(sourceBlock)) {
40-
return null
41-
}
42-
43-
if (sourceParent === edge.target && isContainerBlock(targetBlock)) {
44-
return null
45-
}
46-
47-
return `blocks are in different scopes (${sourceParent ?? 'root'} -> ${targetParent ?? 'root'})`
48-
}
49-
5019
export function validateEdges(
5120
edges: Edge[],
5221
blocks: Record<string, BlockState>
@@ -63,7 +32,10 @@ export function validateEdges(
6332
continue
6433
}
6534

66-
if (isAnnotationOnlyBlock(sourceBlock.type) || isAnnotationOnlyBlock(targetBlock.type)) {
35+
if (
36+
isWorkflowAnnotationOnlyBlockType(sourceBlock.type) ||
37+
isWorkflowAnnotationOnlyBlockType(targetBlock.type)
38+
) {
6739
dropped.push({ edge, reason: 'edge references an annotation-only block' })
6840
continue
6941
}
@@ -73,7 +45,7 @@ export function validateEdges(
7345
continue
7446
}
7547

76-
const scopeDropReason = getScopeDropReason(edge, blocks)
48+
const scopeDropReason = getWorkflowEdgeScopeDropReason(edge, blocks)
7749
if (scopeDropReason) {
7850
dropped.push({ edge, reason: scopeDropReason })
7951
continue

0 commit comments

Comments
 (0)