fix(subflows): recurse into all descendants for lock, enable, and protection checks (#3412)

* fix(subflows): recurse into all descendants for lock, enable, and protection checks

* fix(subflows): prevent container resize on initial render and clean up code

- Add canvasReadyRef to skip container dimension recalculation during
  ReactFlow init — position changes from extent clamping fired before
  block heights are measured, causing containers to resize on page load
- Resolve globals.css merge conflict, remove global z-index overrides
  (handled via ReactFlow zIndex prop instead)
- Clean up subflow-node: hoist static helpers to module scope, remove
  unused ref, fix nested ternary readability, rename outlineColor→ringColor

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(subflows): use full ancestor-chain protection for descendant enable-toggle

The enable-toggle for descendants was checking only direct `locked` status
instead of walking the full ancestor chain via `isBlockProtected`. This meant
a block nested 2+ levels inside a locked subflow could still be toggled.
Also added TSDoc clarifying why boxShadow works for subflow ring indicators.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* revert(subflows): remove canvasReadyRef height-gating approach

The canvasReadyRef gating in onNodesChange didn't fully fix the
container resize-on-load issue. Reverting to address properly later.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: remove unintentional edge-interaction CSS from globals

Leftover from merge conflict resolution — not part of this PR's changes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(editor): correct isAncestorLocked when block and ancestor both locked, restore fade-in transition

isAncestorLocked was derived from isBlockProtected which short-circuits
on block.locked, so a self-locked block inside a locked ancestor showed
"Unlock block" instead of "Ancestor container is locked". Now walks the
ancestor chain independently.

Also restores the accidentally removed transition-opacity duration-150
class on the ReactFlow container.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(subflows): use full ancestor-chain protection for top-level enable-toggle, restore edge-label z-index

The top-level block check in batchToggleEnabled used block.locked (self
only) while descendants used isBlockProtected (full ancestor chain). A
block inside a locked ancestor but not itself locked would bypass the
check. Now all three layers (store, collaborative hook, DB operations)
consistently use isBlockProtected/isDbBlockProtected at both levels.

Also restores the accidentally removed edge-labels z-index rule, bumped
from 60 to 1001 so labels render above child nodes (zIndex: 1000).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(subflows): extract isAncestorProtected utility, add cycle detection to all traversals

- Extract isAncestorProtected from utils.ts so editor.tsx doesn't
  duplicate the ancestor-chain walk. isBlockProtected now delegates to it.
- Add visited-set cycle detection to all ancestor walks
  (isBlockProtected, isAncestorProtected, isDbBlockProtected) and
  descendant searches (findAllDescendantNodes, findDbDescendants) to
  guard against corrupt parentId references.
- Document why click-catching div has no event bubbling concern
  (ReactFlow renders children as viewport siblings, not DOM children).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Waleed
2026-03-04 15:51:32 -08:00
committed by GitHub
parent 127994f077
commit 6b355e9b54
8 changed files with 247 additions and 290 deletions

View File

@@ -40,6 +40,10 @@ import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/component
import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config'
import { getSubBlockStableKey } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/utils'
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import {
isAncestorProtected,
isBlockProtected,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/block-protection-utils'
import { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview'
import { getBlock } from '@/blocks/registry'
import type { SubBlockType } from '@/blocks/types'
@@ -107,12 +111,11 @@ export function Editor() {
const userPermissions = useUserPermissionsContext()
// Check if block is locked (or inside a locked container) and compute edit permission
// Check if block is locked (or inside a locked ancestor) and compute edit permission
// Locked blocks cannot be edited by anyone (admins can only lock/unlock)
const blocks = useWorkflowStore((state) => state.blocks)
const parentId = currentBlock?.data?.parentId as string | undefined
const isParentLocked = parentId ? (blocks[parentId]?.locked ?? false) : false
const isLocked = (currentBlock?.locked ?? false) || isParentLocked
const isLocked = currentBlockId ? isBlockProtected(currentBlockId, blocks) : false
const isAncestorLocked = currentBlockId ? isAncestorProtected(currentBlockId, blocks) : false
const canEditBlock = userPermissions.canEdit && !isLocked
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
@@ -247,10 +250,7 @@ export function Editor() {
const block = blocks[blockId]
if (!block) return
const parentId = block.data?.parentId as string | undefined
const isParentLocked = parentId ? (blocks[parentId]?.locked ?? false) : false
const isLocked = (block.locked ?? false) || isParentLocked
if (!userPermissions.canEdit || isLocked) return
if (!userPermissions.canEdit || isBlockProtected(blockId, blocks)) return
renamingBlockIdRef.current = blockId
setEditedName(block.name || '')
@@ -364,11 +364,11 @@ export function Editor() {
)}
</div>
<div className='flex shrink-0 items-center gap-[8px]'>
{/* Locked indicator - clickable to unlock if user has admin permissions, block is locked, and parent is not locked */}
{/* Locked indicator - clickable to unlock if user has admin permissions, block is locked directly, and not locked by an ancestor */}
{isLocked && currentBlock && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
{userPermissions.canAdmin && currentBlock.locked && !isParentLocked ? (
{userPermissions.canAdmin && currentBlock.locked && !isAncestorLocked ? (
<Button
variant='ghost'
className='p-0'
@@ -385,8 +385,8 @@ export function Editor() {
</Tooltip.Trigger>
<Tooltip.Content side='top'>
<p>
{isParentLocked
? 'Parent container is locked'
{isAncestorLocked
? 'Ancestor container is locked'
: userPermissions.canAdmin && currentBlock.locked
? 'Unlock block'
: 'Block is locked'}

View File

@@ -1,4 +1,4 @@
import { memo, useMemo, useRef } from 'react'
import { memo, useMemo } from 'react'
import { RepeatIcon, SplitIcon } from 'lucide-react'
import { Handle, type NodeProps, Position, useReactFlow } from 'reactflow'
import { Badge } from '@/components/emcn'
@@ -28,6 +28,28 @@ export interface SubflowNodeData {
executionStatus?: 'success' | 'error' | 'not-executed'
}
const HANDLE_STYLE = {
top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px`,
transform: 'translateY(-50%)',
} as const
/**
* Reusable class names for Handle components.
* Matches the styling pattern from workflow-block.tsx.
*/
const getHandleClasses = (position: 'left' | 'right') => {
const baseClasses = '!z-[10] !cursor-crosshair !border-none !transition-[colors] !duration-150'
const colorClasses = '!bg-[var(--workflow-edge)]'
const positionClasses = {
left: '!left-[-8px] !h-5 !w-[7px] !rounded-l-[2px] !rounded-r-none hover:!left-[-11px] hover:!w-[10px] hover:!rounded-l-full',
right:
'!right-[-8px] !h-5 !w-[7px] !rounded-r-[2px] !rounded-l-none hover:!right-[-11px] hover:!w-[10px] hover:!rounded-r-full',
}
return cn(baseClasses, colorClasses, positionClasses[position])
}
/**
* Subflow node component for loop and parallel execution containers.
* Renders a resizable container with a header displaying the block name and icon,
@@ -38,7 +60,6 @@ export interface SubflowNodeData {
*/
export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<SubflowNodeData>) => {
const { getNodes } = useReactFlow()
const blockRef = useRef<HTMLDivElement>(null)
const userPermissions = useUserPermissionsContext()
const currentWorkflow = useCurrentWorkflow()
@@ -52,7 +73,6 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
const isLocked = currentBlock?.locked ?? false
const isPreview = data?.isPreview || false
// Focus state
const setCurrentBlockId = usePanelEditorStore((state) => state.setCurrentBlockId)
const currentBlockId = usePanelEditorStore((state) => state.currentBlockId)
const isFocused = currentBlockId === id
@@ -84,7 +104,7 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
}
return level
}, [id, data?.parentId, getNodes])
}, [data?.parentId, getNodes])
const startHandleId = data.kind === 'loop' ? 'loop-start-source' : 'parallel-start-source'
const endHandleId = data.kind === 'loop' ? 'loop-end-source' : 'parallel-end-source'
@@ -92,27 +112,6 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
const blockIconBg = data.kind === 'loop' ? '#2FB3FF' : '#FEE12B'
const blockName = data.name || (data.kind === 'loop' ? 'Loop' : 'Parallel')
/**
* Reusable styles and positioning for Handle components.
* Matches the styling pattern from workflow-block.tsx.
*/
const getHandleClasses = (position: 'left' | 'right') => {
const baseClasses = '!z-[10] !cursor-crosshair !border-none !transition-[colors] !duration-150'
const colorClasses = '!bg-[var(--workflow-edge)]'
const positionClasses = {
left: '!left-[-8px] !h-5 !w-[7px] !rounded-l-[2px] !rounded-r-none hover:!left-[-11px] hover:!w-[10px] hover:!rounded-l-full',
right:
'!right-[-8px] !h-5 !w-[7px] !rounded-r-[2px] !rounded-l-none hover:!right-[-11px] hover:!w-[10px] hover:!rounded-r-full',
}
return cn(baseClasses, colorClasses, positionClasses[position])
}
const getHandleStyle = () => {
return { top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px`, transform: 'translateY(-50%)' }
}
/**
* Determine the ring styling based on subflow state priority:
* 1. Focused (selected in editor), selected (shift-click/box), or preview selected - blue ring
@@ -127,46 +126,37 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
diffStatus === 'new' ||
diffStatus === 'edited' ||
!!runPathStatus
/**
* Compute the outline color for the subflow ring.
* Uses CSS outline instead of box-shadow ring because in ReactFlow v11,
* child nodes are DOM children of parent nodes and paint over the parent's
* internal ring overlay. Outline renders on the element's own compositing
* layer, so it stays visible above nested child nodes.
* Compute the ring color for the subflow selection indicator.
* Uses boxShadow (not CSS outline) to match the ring styling of regular workflow blocks.
* This works because ReactFlow renders child nodes as sibling divs at the viewport level
* (not as DOM children), so children at zIndex 1000 don't clip the parent's boxShadow.
*/
const outlineColor = hasRing
? isFocused || isSelected || isPreviewSelected
? 'var(--brand-secondary)'
: diffStatus === 'new'
? 'var(--brand-tertiary-2)'
: diffStatus === 'edited'
? 'var(--warning)'
: runPathStatus === 'success'
? executionStatus
? 'var(--brand-tertiary-2)'
: 'var(--border-success)'
: runPathStatus === 'error'
? 'var(--text-error)'
: undefined
: undefined
const getRingColor = (): string | undefined => {
if (!hasRing) return undefined
if (isFocused || isSelected || isPreviewSelected) return 'var(--brand-secondary)'
if (diffStatus === 'new') return 'var(--brand-tertiary-2)'
if (diffStatus === 'edited') return 'var(--warning)'
if (runPathStatus === 'success') {
return executionStatus ? 'var(--brand-tertiary-2)' : 'var(--border-success)'
}
if (runPathStatus === 'error') return 'var(--text-error)'
return undefined
}
const ringColor = getRingColor()
return (
<div className='group pointer-events-none relative'>
<div
ref={blockRef}
className={cn(
'relative select-none rounded-[8px] border border-[var(--border-1)]',
'transition-block-bg'
)}
className='relative select-none rounded-[8px] border border-[var(--border-1)] transition-block-bg'
style={{
width: data.width || 500,
height: data.height || 300,
position: 'relative',
overflow: 'visible',
pointerEvents: 'none',
...(outlineColor && {
outline: `1.75px solid ${outlineColor}`,
outlineOffset: '-1px',
...(ringColor && {
boxShadow: `0 0 0 1.75px ${ringColor}`,
}),
}}
data-node-id={id}
@@ -181,9 +171,7 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
{/* Header Section — only interactive area for dragging */}
<div
onClick={() => setCurrentBlockId(id)}
className={cn(
'workflow-drag-handle flex cursor-grab items-center justify-between rounded-t-[8px] border-[var(--border)] border-b bg-[var(--surface-2)] py-[8px] pr-[12px] pl-[8px] [&:active]:cursor-grabbing'
)}
className='workflow-drag-handle flex cursor-grab items-center justify-between rounded-t-[8px] border-[var(--border)] border-b bg-[var(--surface-2)] py-[8px] pr-[12px] pl-[8px] [&:active]:cursor-grabbing'
style={{ pointerEvents: 'auto' }}
>
<div className='flex min-w-0 flex-1 items-center gap-[10px]'>
@@ -209,6 +197,17 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
</div>
</div>
{/*
* Click-catching background — selects this subflow when the body area is clicked.
* No event bubbling concern: ReactFlow renders child nodes as viewport-level siblings,
* not as DOM children of this component, so child clicks never reach this div.
*/}
<div
className='absolute inset-0 top-[44px] rounded-b-[8px]'
style={{ pointerEvents: isPreview ? 'none' : 'auto' }}
onClick={() => setCurrentBlockId(id)}
/>
{!isPreview && (
<div
className='absolute right-[8px] bottom-[8px] z-20 flex h-[32px] w-[32px] cursor-se-resize items-center justify-center text-muted-foreground'
@@ -217,12 +216,9 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
)}
<div
className='h-[calc(100%-50px)] pt-[16px] pr-[80px] pb-[16px] pl-[16px]'
className='relative h-[calc(100%-50px)] pt-[16px] pr-[80px] pb-[16px] pl-[16px]'
data-dragarea='true'
style={{
position: 'relative',
pointerEvents: 'none',
}}
style={{ pointerEvents: 'none' }}
>
{/* Subflow Start */}
<div
@@ -255,7 +251,7 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
position={Position.Left}
className={getHandleClasses('left')}
style={{
...getHandleStyle(),
...HANDLE_STYLE,
pointerEvents: 'auto',
}}
/>
@@ -266,7 +262,7 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
position={Position.Right}
className={getHandleClasses('right')}
style={{
...getHandleStyle(),
...HANDLE_STYLE,
pointerEvents: 'auto',
}}
id={endHandleId}

View File

@@ -1,4 +1,7 @@
import type { BlockState } from '@/stores/workflows/workflow/types'
import { isAncestorProtected, isBlockProtected } from '@/stores/workflows/workflow/utils'
export { isAncestorProtected, isBlockProtected }
/**
* Result of filtering protected blocks from a deletion operation
@@ -12,28 +15,6 @@ export interface FilterProtectedBlocksResult {
allProtected: boolean
}
/**
* Checks if a block is protected from editing/deletion.
* A block is protected if it is locked or if its parent container is locked.
*
* @param blockId - The ID of the block to check
* @param blocks - Record of all blocks in the workflow
* @returns True if the block is protected
*/
export function isBlockProtected(blockId: string, blocks: Record<string, BlockState>): boolean {
const block = blocks[blockId]
if (!block) return false
// Block is locked directly
if (block.locked) return true
// Block is inside a locked container
const parentId = block.data?.parentId
if (parentId && blocks[parentId]?.locked) return true
return false
}
/**
* Checks if an edge is protected from modification.
* An edge is protected only if its target block is protected.

View File

@@ -196,17 +196,14 @@ const edgeTypes: EdgeTypes = {
const defaultEdgeOptions = { type: 'custom' }
const reactFlowStyles = [
'bg-[var(--bg)]',
'[&_.react-flow__edges]:!z-0',
'[&_.react-flow__node]:z-[21]',
'[&_.react-flow__handle]:!z-[30]',
'[&_.react-flow__edge-labels]:!z-[60]',
'[&_.react-flow__pane]:!bg-[var(--bg)]',
'[&_.react-flow__edge-labels]:!z-[1001]',
'[&_.react-flow__pane]:select-none',
'[&_.react-flow__selectionpane]:select-none',
'[&_.react-flow__renderer]:!bg-[var(--bg)]',
'[&_.react-flow__viewport]:!bg-[var(--bg)]',
'[&_.react-flow__background]:hidden',
'[&_.react-flow__node-subflowNode.selected]:!shadow-none',
].join(' ')
const reactFlowFitViewOptions = { padding: 0.6, maxZoom: 1.0 } as const
const reactFlowProOptions = { hideAttribution: true } as const
@@ -2412,6 +2409,12 @@ const WorkflowContent = React.memo(() => {
const nodeType = block.type === 'note' ? 'noteBlock' : 'workflowBlock'
const dragHandle = block.type === 'note' ? '.note-drag-handle' : '.workflow-drag-handle'
// Compute zIndex for blocks inside containers so they render above the
// parent subflow's interactive body area (which needs pointer-events for
// click-to-select). Container nodes use zIndex: depth (0, 1, 2...),
// so child blocks use a baseline that is always above any container.
const childZIndex = block.data?.parentId ? 1000 : undefined
// Create stable node object - React Flow will handle shallow comparison
nodeArray.push({
id: block.id,
@@ -2420,6 +2423,7 @@ const WorkflowContent = React.memo(() => {
parentId: block.data?.parentId,
dragHandle,
draggable: !isBlockProtected(block.id, blocks),
...(childZIndex !== undefined && { zIndex: childZIndex }),
extent: (() => {
// Clamp children to subflow body (exclude header)
const parentId = block.data?.parentId as string | undefined
@@ -3768,21 +3772,20 @@ const WorkflowContent = React.memo(() => {
return (
<div className='flex h-full w-full flex-col overflow-hidden'>
<div className='relative h-full w-full flex-1'>
{/* Loading spinner - always mounted, animation paused when hidden to avoid overhead */}
<div
className={`absolute inset-0 z-[5] flex items-center justify-center bg-[var(--bg)] transition-opacity duration-150 ${isWorkflowReady ? 'pointer-events-none opacity-0' : 'opacity-100'}`}
>
<div
className={`h-[18px] w-[18px] rounded-full ${isWorkflowReady ? '' : 'animate-spin'}`}
style={{
background:
'conic-gradient(from 0deg, hsl(var(--muted-foreground)) 0deg 120deg, transparent 120deg 180deg, hsl(var(--muted-foreground)) 180deg 300deg, transparent 300deg 360deg)',
mask: 'radial-gradient(farthest-side, transparent calc(100% - 1.5px), black calc(100% - 1.5px))',
WebkitMask:
'radial-gradient(farthest-side, transparent calc(100% - 1.5px), black calc(100% - 1.5px))',
}}
/>
</div>
{!isWorkflowReady && (
<div className='absolute inset-0 z-[5] flex items-center justify-center bg-[var(--bg)]'>
<div
className='h-[18px] w-[18px] animate-spin rounded-full'
style={{
background:
'conic-gradient(from 0deg, hsl(var(--muted-foreground)) 0deg 120deg, transparent 120deg 180deg, hsl(var(--muted-foreground)) 180deg 300deg, transparent 300deg 360deg)',
mask: 'radial-gradient(farthest-side, transparent calc(100% - 1.5px), black calc(100% - 1.5px))',
WebkitMask:
'radial-gradient(farthest-side, transparent calc(100% - 1.5px), black calc(100% - 1.5px))',
}}
/>
</div>
)}
{isWorkflowReady && (
<>
@@ -3835,7 +3838,7 @@ const WorkflowContent = React.memo(() => {
noWheelClassName='allow-scroll'
edgesFocusable={true}
edgesUpdatable={effectivePermissions.canEdit}
className={`workflow-container h-full transition-opacity duration-150 ${reactFlowStyles} ${isCanvasReady ? 'opacity-100' : 'opacity-0'} ${isHandMode ? 'canvas-mode-hand' : 'canvas-mode-cursor'}`}
className={`workflow-container h-full bg-[var(--bg)] transition-opacity duration-150 ${reactFlowStyles} ${isCanvasReady ? 'opacity-100' : 'opacity-0'} ${isHandMode ? 'canvas-mode-hand' : 'canvas-mode-cursor'}`}
onNodeDrag={effectivePermissions.canEdit ? onNodeDrag : undefined}
onNodeDragStop={effectivePermissions.canEdit ? onNodeDragStop : undefined}
onSelectionDragStart={effectivePermissions.canEdit ? onSelectionDragStart : undefined}
@@ -3847,7 +3850,7 @@ const WorkflowContent = React.memo(() => {
elevateEdgesOnSelect={true}
onlyRenderVisibleElements={false}
deleteKeyCode={null}
elevateNodesOnSelect={true}
elevateNodesOnSelect={false}
autoPanOnConnect={effectivePermissions.canEdit}
autoPanOnNodeDrag={effectivePermissions.canEdit}
/>

View File

@@ -27,6 +27,7 @@ import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { filterNewEdges, filterValidEdges, mergeSubblockState } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { BlockState, Loop, Parallel, Position } from '@/stores/workflows/workflow/types'
import { findAllDescendantNodes, isBlockProtected } from '@/stores/workflows/workflow/utils'
const logger = createLogger('CollaborativeWorkflow')
@@ -748,9 +749,7 @@ export function useCollaborativeWorkflow() {
const block = blocks[id]
if (block) {
const parentId = block.data?.parentId
const isParentLocked = parentId ? blocks[parentId]?.locked : false
if (block.locked || isParentLocked) {
if (isBlockProtected(id, blocks)) {
logger.error('Cannot rename locked block')
useNotificationStore.getState().addNotification({
level: 'info',
@@ -858,21 +857,21 @@ export function useCollaborativeWorkflow() {
const previousStates: Record<string, boolean> = {}
const validIds: string[] = []
// For each ID, collect non-locked blocks and their children for undo/redo
// For each ID, collect non-locked blocks and their descendants for undo/redo
for (const id of ids) {
const block = currentBlocks[id]
if (!block) continue
// Skip locked blocks
if (block.locked) continue
// Skip protected blocks (locked or inside a locked ancestor)
if (isBlockProtected(id, currentBlocks)) continue
validIds.push(id)
previousStates[id] = block.enabled
// If it's a loop or parallel, also capture children's previous states for undo/redo
// If it's a loop or parallel, also capture descendants' previous states for undo/redo
if (block.type === 'loop' || block.type === 'parallel') {
Object.entries(currentBlocks).forEach(([blockId, b]) => {
if (b.data?.parentId === id && !b.locked) {
previousStates[blockId] = b.enabled
findAllDescendantNodes(id, currentBlocks).forEach((descId) => {
if (!isBlockProtected(descId, currentBlocks)) {
previousStates[descId] = currentBlocks[descId]?.enabled ?? true
}
})
}
@@ -1038,21 +1037,12 @@ export function useCollaborativeWorkflow() {
const blocks = useWorkflowStore.getState().blocks
const isProtected = (blockId: string): boolean => {
const block = blocks[blockId]
if (!block) return false
if (block.locked) return true
const parentId = block.data?.parentId
if (parentId && blocks[parentId]?.locked) return true
return false
}
const previousStates: Record<string, boolean> = {}
const validIds: string[] = []
for (const id of ids) {
const block = blocks[id]
if (block && !isProtected(id)) {
if (block && !isBlockProtected(id, blocks)) {
previousStates[id] = block.horizontalHandles ?? false
validIds.push(id)
}
@@ -1100,10 +1090,8 @@ export function useCollaborativeWorkflow() {
previousStates[id] = block.locked ?? false
if (block.type === 'loop' || block.type === 'parallel') {
Object.entries(currentBlocks).forEach(([blockId, b]) => {
if (b.data?.parentId === id) {
previousStates[blockId] = b.locked ?? false
}
findAllDescendantNodes(id, currentBlocks).forEach((descId) => {
previousStates[descId] = currentBlocks[descId]?.locked ?? false
})
}
}

View File

@@ -39,6 +39,56 @@ const db = socketDb
const DEFAULT_LOOP_ITERATIONS = 5
const DEFAULT_PARALLEL_COUNT = 5
/** Minimal block shape needed for protection and descendant checks */
interface DbBlockRef {
id: string
locked?: boolean | null
data: unknown
}
/**
* Checks if a block is protected (locked or inside a locked ancestor).
* Works with raw DB records.
*/
function isDbBlockProtected(blockId: string, blocksById: Record<string, DbBlockRef>): boolean {
const block = blocksById[blockId]
if (!block) return false
if (block.locked) return true
const visited = new Set<string>()
let parentId = (block.data as Record<string, unknown> | null)?.parentId as string | undefined
while (parentId && !visited.has(parentId)) {
visited.add(parentId)
if (blocksById[parentId]?.locked) return true
parentId = (blocksById[parentId]?.data as Record<string, unknown> | null)?.parentId as
| string
| undefined
}
return false
}
/**
* Finds all descendant block IDs of a container (recursive).
* Works with raw DB block arrays.
*/
function findDbDescendants(containerId: string, allBlocks: DbBlockRef[]): string[] {
const descendants: string[] = []
const visited = new Set<string>()
const stack = [containerId]
while (stack.length > 0) {
const current = stack.pop()!
if (visited.has(current)) continue
visited.add(current)
for (const b of allBlocks) {
const pid = (b.data as Record<string, unknown> | null)?.parentId
if (pid === current) {
descendants.push(b.id)
stack.push(b.id)
}
}
}
return descendants
}
/**
* Shared function to handle auto-connect edge insertion
* @param tx - Database transaction
@@ -753,20 +803,8 @@ async function handleBlocksOperationTx(
allBlocks.map((b: BlockRecord) => [b.id, b])
)
// Helper to check if a block is protected (locked or inside locked parent)
const isProtected = (blockId: string): boolean => {
const block = blocksById[blockId]
if (!block) return false
if (block.locked) return true
const parentId = (block.data as Record<string, unknown> | null)?.parentId as
| string
| undefined
if (parentId && blocksById[parentId]?.locked) return true
return false
}
// Filter out protected blocks from deletion request
const deletableIds = ids.filter((id) => !isProtected(id))
const deletableIds = ids.filter((id) => !isDbBlockProtected(id, blocksById))
if (deletableIds.length === 0) {
logger.info('All requested blocks are protected, skipping deletion')
return
@@ -778,18 +816,14 @@ async function handleBlocksOperationTx(
)
}
// Collect all block IDs including children of subflows
// Collect all block IDs including all descendants of subflows
const allBlocksToDelete = new Set<string>(deletableIds)
for (const id of deletableIds) {
const block = blocksById[id]
if (block && isSubflowBlockType(block.type)) {
// Include all children of the subflow (they should be deleted with parent)
for (const b of allBlocks) {
const parentId = (b.data as Record<string, unknown> | null)?.parentId
if (parentId === id) {
allBlocksToDelete.add(b.id)
}
for (const descId of findDbDescendants(id, allBlocks)) {
allBlocksToDelete.add(descId)
}
}
}
@@ -902,19 +936,18 @@ async function handleBlocksOperationTx(
)
const blocksToToggle = new Set<string>()
// Collect all blocks to toggle including children of containers
// Collect all blocks to toggle including descendants of containers
for (const id of blockIds) {
const block = blocksById[id]
if (!block || block.locked) continue
if (!block || isDbBlockProtected(id, blocksById)) continue
blocksToToggle.add(id)
// If it's a loop or parallel, also include all children
// If it's a loop or parallel, also include all non-locked descendants
if (block.type === 'loop' || block.type === 'parallel') {
for (const b of allBlocks) {
const parentId = (b.data as Record<string, unknown> | null)?.parentId
if (parentId === id && !b.locked) {
blocksToToggle.add(b.id)
for (const descId of findDbDescendants(id, allBlocks)) {
if (!isDbBlockProtected(descId, blocksById)) {
blocksToToggle.add(descId)
}
}
}
@@ -966,20 +999,10 @@ async function handleBlocksOperationTx(
allBlocks.map((b: HandleBlockRecord) => [b.id, b])
)
// Helper to check if a block is protected (locked or inside locked parent)
const isProtected = (blockId: string): boolean => {
const block = blocksById[blockId]
if (!block) return false
if (block.locked) return true
const parentId = (block.data as Record<string, unknown> | null)?.parentId as
| string
| undefined
if (parentId && blocksById[parentId]?.locked) return true
return false
}
// Filter to only toggle handles on unprotected blocks
const blocksToToggle = blockIds.filter((id) => blocksById[id] && !isProtected(id))
const blocksToToggle = blockIds.filter(
(id) => blocksById[id] && !isDbBlockProtected(id, blocksById)
)
if (blocksToToggle.length === 0) {
logger.info('All requested blocks are protected, skipping handles toggle')
break
@@ -1025,20 +1048,17 @@ async function handleBlocksOperationTx(
)
const blocksToToggle = new Set<string>()
// Collect all blocks to toggle including children of containers
// Collect all blocks to toggle including descendants of containers
for (const id of blockIds) {
const block = blocksById[id]
if (!block) continue
blocksToToggle.add(id)
// If it's a loop or parallel, also include all children
// If it's a loop or parallel, also include all descendants
if (block.type === 'loop' || block.type === 'parallel') {
for (const b of allBlocks) {
const parentId = (b.data as Record<string, unknown> | null)?.parentId
if (parentId === id) {
blocksToToggle.add(b.id)
}
for (const descId of findDbDescendants(id, allBlocks)) {
blocksToToggle.add(descId)
}
}
}
@@ -1088,31 +1108,19 @@ async function handleBlocksOperationTx(
allBlocks.map((b: ParentBlockRecord) => [b.id, b])
)
// Helper to check if a block is protected (locked or inside locked parent)
const isProtected = (blockId: string): boolean => {
const block = blocksById[blockId]
if (!block) return false
if (block.locked) return true
const currentParentId = (block.data as Record<string, unknown> | null)?.parentId as
| string
| undefined
if (currentParentId && blocksById[currentParentId]?.locked) return true
return false
}
for (const update of updates) {
const { id, parentId, position } = update
if (!id) continue
// Skip protected blocks (locked or inside locked container)
if (isProtected(id)) {
if (isDbBlockProtected(id, blocksById)) {
logger.info(`Skipping block ${id} parent update - block is protected`)
continue
}
// Skip if trying to move into a locked container
if (parentId && blocksById[parentId]?.locked) {
logger.info(`Skipping block ${id} parent update - target parent ${parentId} is locked`)
// Skip if trying to move into a locked container (or any of its ancestors)
if (parentId && isDbBlockProtected(parentId, blocksById)) {
logger.info(`Skipping block ${id} parent update - target parent ${parentId} is protected`)
continue
}
@@ -1235,18 +1243,7 @@ async function handleEdgeOperationTx(tx: any, workflowId: string, operation: str
}
}
const isBlockProtected = (blockId: string): boolean => {
const block = blocksById[blockId]
if (!block) return false
if (block.locked) return true
const parentId = (block.data as Record<string, unknown> | null)?.parentId as
| string
| undefined
if (parentId && blocksById[parentId]?.locked) return true
return false
}
if (isBlockProtected(payload.target)) {
if (isDbBlockProtected(payload.target, blocksById)) {
logger.info(`Skipping edge add - target block is protected`)
break
}
@@ -1334,18 +1331,7 @@ async function handleEdgeOperationTx(tx: any, workflowId: string, operation: str
}
}
const isBlockProtected = (blockId: string): boolean => {
const block = blocksById[blockId]
if (!block) return false
if (block.locked) return true
const parentId = (block.data as Record<string, unknown> | null)?.parentId as
| string
| undefined
if (parentId && blocksById[parentId]?.locked) return true
return false
}
if (isBlockProtected(edgeToRemove.targetBlockId)) {
if (isDbBlockProtected(edgeToRemove.targetBlockId, blocksById)) {
logger.info(`Skipping edge remove - target block is protected`)
break
}
@@ -1455,19 +1441,8 @@ async function handleEdgesOperationTx(
}
}
const isBlockProtected = (blockId: string): boolean => {
const block = blocksById[blockId]
if (!block) return false
if (block.locked) return true
const parentId = (block.data as Record<string, unknown> | null)?.parentId as
| string
| undefined
if (parentId && blocksById[parentId]?.locked) return true
return false
}
const safeEdgeIds = edgesToRemove
.filter((e: EdgeToRemove) => !isBlockProtected(e.targetBlockId))
.filter((e: EdgeToRemove) => !isDbBlockProtected(e.targetBlockId, blocksById))
.map((e: EdgeToRemove) => e.id)
if (safeEdgeIds.length === 0) {
@@ -1552,20 +1527,9 @@ async function handleEdgesOperationTx(
}
}
const isBlockProtected = (blockId: string): boolean => {
const block = blocksById[blockId]
if (!block) return false
if (block.locked) return true
const parentId = (block.data as Record<string, unknown> | null)?.parentId as
| string
| undefined
if (parentId && blocksById[parentId]?.locked) return true
return false
}
// Filter edges - only add edges where target block is not protected
const safeEdges = (edges as Array<Record<string, unknown>>).filter(
(e) => !isBlockProtected(e.target as string)
(e) => !isDbBlockProtected(e.target as string, blocksById)
)
if (safeEdges.length === 0) {

View File

@@ -20,8 +20,10 @@ import type {
WorkflowStore,
} from '@/stores/workflows/workflow/types'
import {
findAllDescendantNodes,
generateLoopBlocks,
generateParallelBlocks,
isBlockProtected,
wouldCreateCycle,
} from '@/stores/workflows/workflow/utils'
@@ -374,21 +376,21 @@ export const useWorkflowStore = create<WorkflowStore>()(
const blocksToToggle = new Set<string>()
// For each ID, collect blocks to toggle (skip locked blocks entirely)
// If it's a container, also include non-locked children
// If it's a container, also include non-locked descendants
for (const id of ids) {
const block = currentBlocks[id]
if (!block) continue
// Skip locked blocks entirely (including their children)
if (block.locked) continue
// Skip protected blocks entirely (locked or inside a locked ancestor)
if (isBlockProtected(id, currentBlocks)) continue
blocksToToggle.add(id)
// If it's a loop or parallel, also include non-locked children
// If it's a loop or parallel, also include non-locked descendants
if (block.type === 'loop' || block.type === 'parallel') {
Object.entries(currentBlocks).forEach(([blockId, b]) => {
if (b.data?.parentId === id && !b.locked) {
blocksToToggle.add(blockId)
findAllDescendantNodes(id, currentBlocks).forEach((descId) => {
if (!isBlockProtected(descId, currentBlocks)) {
blocksToToggle.add(descId)
}
})
}
@@ -415,18 +417,8 @@ export const useWorkflowStore = create<WorkflowStore>()(
const currentBlocks = get().blocks
const newBlocks = { ...currentBlocks }
// Helper to check if a block is protected (locked or inside locked parent)
const isProtected = (blockId: string): boolean => {
const block = currentBlocks[blockId]
if (!block) return false
if (block.locked) return true
const parentId = block.data?.parentId
if (parentId && currentBlocks[parentId]?.locked) return true
return false
}
for (const id of ids) {
if (!newBlocks[id] || isProtected(id)) continue
if (!newBlocks[id] || isBlockProtected(id, currentBlocks)) continue
newBlocks[id] = {
...newBlocks[id],
horizontalHandles: !newBlocks[id].horizontalHandles,
@@ -1267,19 +1259,17 @@ export const useWorkflowStore = create<WorkflowStore>()(
const blocksToToggle = new Set<string>()
// For each ID, collect blocks to toggle
// If it's a container, also include all children
// If it's a container, also include all descendants
for (const id of ids) {
const block = currentBlocks[id]
if (!block) continue
blocksToToggle.add(id)
// If it's a loop or parallel, also include all children
// If it's a loop or parallel, also include all descendants
if (block.type === 'loop' || block.type === 'parallel') {
Object.entries(currentBlocks).forEach(([blockId, b]) => {
if (b.data?.parentId === id) {
blocksToToggle.add(blockId)
}
findAllDescendantNodes(id, currentBlocks).forEach((descId) => {
blocksToToggle.add(descId)
})
}
}

View File

@@ -143,21 +143,56 @@ export function findAllDescendantNodes(
blocks: Record<string, BlockState>
): string[] {
const descendants: string[] = []
const findDescendants = (parentId: string) => {
const children = Object.values(blocks)
.filter((block) => block.data?.parentId === parentId)
.map((block) => block.id)
children.forEach((childId) => {
descendants.push(childId)
findDescendants(childId)
})
const visited = new Set<string>()
const stack = [containerId]
while (stack.length > 0) {
const current = stack.pop()!
if (visited.has(current)) continue
visited.add(current)
for (const block of Object.values(blocks)) {
if (block.data?.parentId === current) {
descendants.push(block.id)
stack.push(block.id)
}
}
}
findDescendants(containerId)
return descendants
}
/**
* Checks if any ancestor container of a block is locked.
* Unlike {@link isBlockProtected}, this ignores the block's own locked state.
*
* @param blockId - The ID of the block to check
* @param blocks - Record of all blocks in the workflow
* @returns True if any ancestor is locked
*/
export function isAncestorProtected(blockId: string, blocks: Record<string, BlockState>): boolean {
const visited = new Set<string>()
let parentId = blocks[blockId]?.data?.parentId
while (parentId && !visited.has(parentId)) {
visited.add(parentId)
if (blocks[parentId]?.locked) return true
parentId = blocks[parentId]?.data?.parentId
}
return false
}
/**
* Checks if a block is protected from editing/deletion.
* A block is protected if it is locked or if any ancestor container is locked.
*
* @param blockId - The ID of the block to check
* @param blocks - Record of all blocks in the workflow
* @returns True if the block is protected
*/
export function isBlockProtected(blockId: string, blocks: Record<string, BlockState>): boolean {
const block = blocks[blockId]
if (!block) return false
if (block.locked) return true
return isAncestorProtected(blockId, blocks)
}
/**
* Builds a complete collection of loops from the UI blocks
*