Compare commits

...

26 Commits

Author SHA1 Message Date
waleed
a826b9785d refactor(workflow): use pre-computed lock state from contextMenuBlocks
contextMenuBlocks already has locked and isParentLocked properties
computed in use-canvas-context-menu.ts, so there's no need to look
up blocks again via hasProtectedBlocks.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 21:15:33 -08:00
waleed
813ec9b758 fix(socket): add comprehensive lock validation across operations
Based on audit findings, adds lock validation to multiple operations:

1. BATCH_TOGGLE_HANDLES - now skips locked/protected blocks at:
   - Store layer (batchToggleHandles)
   - Collaborative hook (collaborativeBatchToggleBlockHandles)
   - Server socket handler

2. BATCH_ADD_BLOCKS - server now filters blocks being added to
   locked parent containers

3. BATCH_UPDATE_PARENT - server now:
   - Skips protected blocks (locked or inside locked container)
   - Prevents moving blocks into locked containers

All validations use consistent isProtected() helper that checks both
direct lock and parent container lock.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 21:12:50 -08:00
waleed
52d9f31621 fix(undo-redo): use consistent target state for toggle redo
The redo logic for BATCH_TOGGLE_ENABLED and BATCH_TOGGLE_LOCKED was
incorrectly computing each block's new state as !previousStates[blockId].
However, the store's batchToggleEnabled/batchToggleLocked set ALL blocks
to the SAME target state based on the first block's previous state.

Now redo computes targetState = !previousStates[firstBlockId] and applies
it to all blocks, matching the store's behavior.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 21:01:55 -08:00
waleed
3fbcfc662c test(socket): update permission test for admin-only lock toggle
batch-toggle-locked is now admin-only, so write role should be denied.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 20:49:07 -08:00
waleed
3664a56fca fix(socket): add server-side lock validation and admin-only permissions
1. BATCH_TOGGLE_LOCKED now requires admin role - non-admin users with
   write role can no longer bypass UI restriction via direct socket
   messages

2. BATCH_REMOVE_BLOCKS now validates lock status server-side - filters
   out protected blocks (locked or inside locked parent) before deletion

3. Remove duplicate/outdated comment in regenerateBlockIds

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 20:45:40 -08:00
waleed
ef4acfdf39 fix(copilot): check parent lock in edit and delete operations
Both edit and delete operations now check if the block's parent
container is locked, not just if the block itself is locked. This
ensures consistent behavior with the UI which uses isBlockProtected
utility that checks both direct lock and parent lock.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 20:34:00 -08:00
waleed
395e6ed591 fix(lock): fix toggle locked target state and draggable check
1. BATCH_TOGGLE_LOCKED now uses first block from blocksToToggle set
   instead of blockIds[0], matching BATCH_TOGGLE_ENABLED pattern.
   Also added early exit if blocksToToggle is empty.

2. Blocks inside locked containers are now properly non-draggable.
   Changed draggable check from !block.locked to use isBlockProtected()
   which checks both block lock and parent container lock.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 20:29:14 -08:00
waleed
0eea69b07d fix(lock): prevent duplicates inside locked containers via regenerateBlockIds
1. regenerateBlockIds now checks if existing parent is locked before
   keeping the block inside it. If parent is locked, the duplicate
   is placed outside (parentId cleared) instead of creating an
   inconsistent state.

2. Remove unnecessary effectivePermissions.canAdmin and potentialParentId
   from onNodeDragStart dependency array.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 20:17:35 -08:00
waleed
802884f814 fix(copilot): add lock checks for insert and extract operations
- insert_into_subflow: Check if existing block being moved is locked
- extract_from_subflow: Check if block or parent subflow is locked

These operations now match the UI behavior where locked blocks
cannot be moved into/out of containers.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 20:04:06 -08:00
waleed
4c05ae1f34 fix(lock): address review comments for lock feature
1. Store batchToggleEnabled now uses continue to skip locked blocks
   entirely, matching database operation behavior

2. Copilot add operation now checks if parent container is locked
   before adding nested nodes (defensive check for consistency)

3. Remove unused filterUnprotectedEdges function

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 19:56:00 -08:00
waleed
8dad4d43b2 refactor(workflow): extend block protection utilities for edge protection
Add isEdgeProtected, filterUnprotectedEdges, and hasProtectedBlocks
utilities. Refactor workflow.tsx to use these helpers for:
- onEdgesChange edge removal filtering
- onConnect connection prevention
- onNodeDragStart drag prevention
- Keyboard edge deletion
- Block menu disableEdit calculation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 19:41:22 -08:00
waleed
c987b6ff6d refactor(workflow): extract block deletion protection into shared utility
Extract duplicated block protection logic from workflow.tsx into
a reusable filterProtectedBlocks helper in utils/block-protection-utils.ts.
This ensures consistent behavior between context menu delete and
keyboard delete operations.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 19:35:01 -08:00
waleed
901bffe44c fix(block-menu): paste should not be disabled for locked selection
Paste creates new blocks, doesn't modify selected ones. Changed from
disableEdit (includes lock state) to !userCanEdit (permission only),
matching the Duplicate action behavior.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 19:28:49 -08:00
waleed
7714dad8ab add lock block image 2026-01-31 19:16:34 -08:00
waleed
ab4b09c484 remove prefix square brackets in error notif 2026-01-31 19:12:21 -08:00
waleed
ee9f2e33c9 docs(quick-reference): add lock block action
Added documentation for the lock/unlock block feature (admin only).
Note: Image placeholder added, pending actual screenshot.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 19:06:34 -08:00
waleed
da5e0aa07d fix(enable): consistent behavior - can't enable if parent disabled
Same pattern as lock: must enable parent container first before
enabling children inside it.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 19:05:14 -08:00
waleed
ecb13a58d6 fix(lock): ensure consistent behavior across all UIs
Block Menu, Editor, Action Bar now all have identical behavior:
- Enable/Disable: disabled when locked OR parent locked
- Flip Handles: disabled when locked OR parent locked
- Delete: disabled when locked OR parent locked
- Remove from Subflow: disabled when locked OR parent locked
- Lock: always available for admins
- Unlock: disabled when parent is locked (unlock parent first)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 19:02:03 -08:00
waleed
bd36283d94 fix(lock): prevent unlocking blocks inside locked containers
- Editor: can't unlock block if parent container is locked
- Action bar: can't unlock block if parent container is locked
- Shows "Parent container is locked" tooltip in both cases

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 18:59:15 -08:00
waleed
73856af86d fix(lock): address code review feedback
- Fix toggle enabled using first toggleable block, not first block
- Delete button now checks isParentLocked
- Lock button now has disabled state
- Editor lock icon distinguishes block vs parent lock state

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 18:56:57 -08:00
waleed
3f908d6121 fix code block disabled state, allow unlock from editor 2026-01-31 18:45:42 -08:00
waleed
c19263e25f fix(duplicate): unlock all blocks when duplicating workflow
- Server-side workflow duplication now sets locked: false for all blocks
- regenerateWorkflowStateIds also unlocks blocks for templates
- Client-side regenerateBlockIds already handled this (for paste/import)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 18:42:06 -08:00
waleed
63eba0f6fb fix(duplicate): place duplicate outside locked container
When duplicating a block that's inside a locked loop/parallel,
the duplicate is now placed outside the container since nothing
should be added to a locked container.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 18:40:21 -08:00
waleed
4f342d38b0 Merge origin/staging into feat/lock
Resolved conflicts:
- Removed addBlock from store (now using batchAddBlocks)
- Updated lock tests to use test helper addBlock function
- Kept both staging's optimization tests and lock feature tests

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 18:02:03 -08:00
waleed
6907c88736 unlock duplicates of locked blocks 2026-01-31 17:33:02 -08:00
waleed
d533ea27e1 feat(canvas): added the ability to lock blocks 2026-01-31 15:51:14 -08:00
47 changed files with 12155 additions and 165 deletions

View File

@@ -180,6 +180,11 @@ A quick lookup for everyday actions in the Sim workflow editor. For keyboard sho
<td>Right-click → **Enable/Disable**</td>
<td><ActionImage src="/static/quick-reference/disable-block.png" alt="Disable block" /></td>
</tr>
<tr>
<td>Lock/Unlock a block</td>
<td>Hover block → Click lock icon (Admin only)</td>
<td><ActionImage src="/static/quick-reference/lock-block.png" alt="Lock block" /></td>
</tr>
<tr>
<td>Toggle handle orientation</td>
<td>Right-click → **Toggle Handles**</td>

View File

@@ -11,7 +11,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
/>
{/* MANUAL-CONTENT-START:intro */}
The [Pulse](https://www.pulseapi.com/) tool enables seamless extraction of text and structured content from a wide variety of documents—including PDFs, images, and Office files—using state-of-the-art OCR (Optical Character Recognition) powered by Pulse. Designed for automated agentic workflows, Pulse Parser makes it easy to unlock valuable information trapped in unstructured documents and integrate the extracted content directly into your workflow.
The [Pulse](https://www.runpulse.com) tool enables seamless extraction of text and structured content from a wide variety of documents—including PDFs, images, and Office files—using state-of-the-art OCR (Optical Character Recognition) powered by Pulse. Designed for automated agentic workflows, Pulse Parser makes it easy to unlock valuable information trapped in unstructured documents and integrate the extracted content directly into your workflow.
With Pulse, you can:

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

View File

@@ -1,5 +1,5 @@
import { memo, useCallback } from 'react'
import { ArrowLeftRight, ArrowUpDown, Circle, CircleOff, LogOut } from 'lucide-react'
import { ArrowLeftRight, ArrowUpDown, Circle, CircleOff, Lock, LogOut, Unlock } from 'lucide-react'
import { Button, Copy, PlayOutline, Tooltip, Trash2 } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
@@ -49,6 +49,7 @@ export const ActionBar = memo(
collaborativeBatchRemoveBlocks,
collaborativeBatchToggleBlockEnabled,
collaborativeBatchToggleBlockHandles,
collaborativeBatchToggleLocked,
} = useCollaborativeWorkflow()
const { setPendingSelection } = useWorkflowRegistry()
const { handleRunFromBlock } = useWorkflowExecution()
@@ -84,16 +85,28 @@ export const ActionBar = memo(
)
}, [blockId, addNotification, collaborativeBatchAddBlocks, setPendingSelection])
const { isEnabled, horizontalHandles, parentId, parentType } = useWorkflowStore(
const {
isEnabled,
horizontalHandles,
parentId,
parentType,
isLocked,
isParentLocked,
isParentDisabled,
} = useWorkflowStore(
useCallback(
(state) => {
const block = state.blocks[blockId]
const parentId = block?.data?.parentId
const parentBlock = parentId ? state.blocks[parentId] : undefined
return {
isEnabled: block?.enabled ?? true,
horizontalHandles: block?.horizontalHandles ?? false,
parentId,
parentType: parentId ? state.blocks[parentId]?.type : undefined,
parentType: parentBlock?.type,
isLocked: block?.locked ?? false,
isParentLocked: parentBlock?.locked ?? false,
isParentDisabled: parentBlock ? !parentBlock.enabled : false,
}
},
[blockId]
@@ -161,25 +174,27 @@ export const ActionBar = memo(
{!isNoteBlock && !isInsideSubflow && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={(e) => {
e.stopPropagation()
if (canRunFromBlock && !disabled) {
handleRunFromBlockClick()
}
}}
className={ACTION_BUTTON_STYLES}
disabled={disabled || !canRunFromBlock}
>
<PlayOutline className={ICON_SIZE} />
</Button>
<span className='inline-flex'>
<Button
variant='ghost'
onClick={(e) => {
e.stopPropagation()
if (canRunFromBlock && !disabled) {
handleRunFromBlockClick()
}
}}
className={ACTION_BUTTON_STYLES}
disabled={disabled || !canRunFromBlock}
>
<PlayOutline className={ICON_SIZE} />
</Button>
</span>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
{(() => {
if (disabled) return getTooltipMessage('Run from block')
if (isExecuting) return 'Execution in progress'
if (!dependenciesSatisfied) return 'Run upstream blocks first'
if (!dependenciesSatisfied) return 'Run previous blocks first'
return 'Run from block'
})()}
</Tooltip.Content>
@@ -193,18 +208,54 @@ export const ActionBar = memo(
variant='ghost'
onClick={(e) => {
e.stopPropagation()
if (!disabled) {
// Can't enable if parent is disabled (must enable parent first)
const cantEnable = !isEnabled && isParentDisabled
if (!disabled && !isLocked && !isParentLocked && !cantEnable) {
collaborativeBatchToggleBlockEnabled([blockId])
}
}}
className={ACTION_BUTTON_STYLES}
disabled={disabled}
disabled={
disabled || isLocked || isParentLocked || (!isEnabled && isParentDisabled)
}
>
{isEnabled ? <Circle className={ICON_SIZE} /> : <CircleOff className={ICON_SIZE} />}
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
{getTooltipMessage(isEnabled ? 'Disable Block' : 'Enable Block')}
{isLocked || isParentLocked
? 'Block is locked'
: !isEnabled && isParentDisabled
? 'Parent container is disabled'
: getTooltipMessage(isEnabled ? 'Disable Block' : 'Enable Block')}
</Tooltip.Content>
</Tooltip.Root>
)}
{userPermissions.canAdmin && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={(e) => {
e.stopPropagation()
// Can't unlock a block if its parent container is locked
if (!disabled && !(isLocked && isParentLocked)) {
collaborativeBatchToggleLocked([blockId])
}
}}
className={ACTION_BUTTON_STYLES}
disabled={disabled || (isLocked && isParentLocked)}
>
{isLocked ? <Unlock className={ICON_SIZE} /> : <Lock className={ICON_SIZE} />}
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
{isLocked && isParentLocked
? 'Parent container is locked'
: isLocked
? 'Unlock Block'
: 'Lock Block'}
</Tooltip.Content>
</Tooltip.Root>
)}
@@ -237,12 +288,12 @@ export const ActionBar = memo(
variant='ghost'
onClick={(e) => {
e.stopPropagation()
if (!disabled) {
if (!disabled && !isLocked && !isParentLocked) {
collaborativeBatchToggleBlockHandles([blockId])
}
}}
className={ACTION_BUTTON_STYLES}
disabled={disabled}
disabled={disabled || isLocked || isParentLocked}
>
{horizontalHandles ? (
<ArrowLeftRight className={ICON_SIZE} />
@@ -252,7 +303,9 @@ export const ActionBar = memo(
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
{getTooltipMessage(horizontalHandles ? 'Vertical Ports' : 'Horizontal Ports')}
{isLocked || isParentLocked
? 'Block is locked'
: getTooltipMessage(horizontalHandles ? 'Vertical Ports' : 'Horizontal Ports')}
</Tooltip.Content>
</Tooltip.Root>
)}
@@ -264,19 +317,23 @@ export const ActionBar = memo(
variant='ghost'
onClick={(e) => {
e.stopPropagation()
if (!disabled && userPermissions.canEdit) {
if (!disabled && userPermissions.canEdit && !isLocked && !isParentLocked) {
window.dispatchEvent(
new CustomEvent('remove-from-subflow', { detail: { blockIds: [blockId] } })
)
}
}}
className={ACTION_BUTTON_STYLES}
disabled={disabled || !userPermissions.canEdit}
disabled={disabled || !userPermissions.canEdit || isLocked || isParentLocked}
>
<LogOut className={ICON_SIZE} />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>{getTooltipMessage('Remove from Subflow')}</Tooltip.Content>
<Tooltip.Content side='top'>
{isLocked || isParentLocked
? 'Block is locked'
: getTooltipMessage('Remove from Subflow')}
</Tooltip.Content>
</Tooltip.Root>
)}
@@ -286,17 +343,19 @@ export const ActionBar = memo(
variant='ghost'
onClick={(e) => {
e.stopPropagation()
if (!disabled) {
if (!disabled && !isLocked && !isParentLocked) {
collaborativeBatchRemoveBlocks([blockId])
}
}}
className={ACTION_BUTTON_STYLES}
disabled={disabled}
disabled={disabled || isLocked || isParentLocked}
>
<Trash2 className={ICON_SIZE} />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>{getTooltipMessage('Delete Block')}</Tooltip.Content>
<Tooltip.Content side='top'>
{isLocked || isParentLocked ? 'Block is locked' : getTooltipMessage('Delete Block')}
</Tooltip.Content>
</Tooltip.Root>
</div>
)

View File

@@ -20,6 +20,9 @@ export interface BlockInfo {
horizontalHandles: boolean
parentId?: string
parentType?: string
locked?: boolean
isParentLocked?: boolean
isParentDisabled?: boolean
}
/**
@@ -46,10 +49,17 @@ export interface BlockMenuProps {
showRemoveFromSubflow?: boolean
/** Whether run from block is available (has snapshot, was executed, not inside subflow) */
canRunFromBlock?: boolean
/** Whether to disable edit actions (user can't edit OR blocks are locked) */
disableEdit?: boolean
/** Whether the user has edit permission (ignoring locked state) */
userCanEdit?: boolean
isExecuting?: boolean
/** Whether the selected block is a trigger (has no incoming edges) */
isPositionalTrigger?: boolean
/** Callback to toggle locked state of selected blocks */
onToggleLocked?: () => void
/** Whether the user has admin permissions */
canAdmin?: boolean
}
/**
@@ -78,13 +88,22 @@ export function BlockMenu({
showRemoveFromSubflow = false,
canRunFromBlock = false,
disableEdit = false,
userCanEdit = true,
isExecuting = false,
isPositionalTrigger = false,
onToggleLocked,
canAdmin = false,
}: BlockMenuProps) {
const isSingleBlock = selectedBlocks.length === 1
const allEnabled = selectedBlocks.every((b) => b.enabled)
const allDisabled = selectedBlocks.every((b) => !b.enabled)
const allLocked = selectedBlocks.every((b) => b.locked)
const allUnlocked = selectedBlocks.every((b) => !b.locked)
// Can't unlock blocks that have locked parents
const hasBlockWithLockedParent = selectedBlocks.some((b) => b.locked && b.isParentLocked)
// Can't enable blocks that have disabled parents
const hasBlockWithDisabledParent = selectedBlocks.some((b) => !b.enabled && b.isParentDisabled)
const hasSingletonBlock = selectedBlocks.some(
(b) =>
@@ -108,6 +127,12 @@ export function BlockMenu({
return 'Toggle Enabled'
}
const getToggleLockedLabel = () => {
if (allLocked) return 'Unlock'
if (allUnlocked) return 'Lock'
return 'Toggle Lock'
}
return (
<Popover
open={isOpen}
@@ -139,7 +164,7 @@ export function BlockMenu({
</PopoverItem>
<PopoverItem
className='group'
disabled={disableEdit || !hasClipboard}
disabled={!userCanEdit || !hasClipboard}
onClick={() => {
onPaste()
onClose()
@@ -150,7 +175,7 @@ export function BlockMenu({
</PopoverItem>
{!hasSingletonBlock && (
<PopoverItem
disabled={disableEdit}
disabled={!userCanEdit}
onClick={() => {
onDuplicate()
onClose()
@@ -164,13 +189,15 @@ export function BlockMenu({
{!allNoteBlocks && <PopoverDivider />}
{!allNoteBlocks && (
<PopoverItem
disabled={disableEdit}
disabled={disableEdit || hasBlockWithDisabledParent}
onClick={() => {
onToggleEnabled()
onClose()
if (!disableEdit && !hasBlockWithDisabledParent) {
onToggleEnabled()
onClose()
}
}}
>
{getToggleEnabledLabel()}
{hasBlockWithDisabledParent ? 'Parent is disabled' : getToggleEnabledLabel()}
</PopoverItem>
)}
{!allNoteBlocks && !isSubflow && (
@@ -195,6 +222,19 @@ export function BlockMenu({
Remove from Subflow
</PopoverItem>
)}
{canAdmin && onToggleLocked && (
<PopoverItem
disabled={hasBlockWithLockedParent}
onClick={() => {
if (!hasBlockWithLockedParent) {
onToggleLocked()
onClose()
}
}}
>
{hasBlockWithLockedParent ? 'Parent is locked' : getToggleLockedLabel()}
</PopoverItem>
)}
{/* Single block actions */}
{isSingleBlock && <PopoverDivider />}

View File

@@ -34,6 +34,8 @@ export interface CanvasMenuProps {
canUndo?: boolean
canRedo?: boolean
isInvitationsDisabled?: boolean
/** Whether the workflow has locked blocks (disables auto-layout) */
hasLockedBlocks?: boolean
}
/**
@@ -60,6 +62,7 @@ export function CanvasMenu({
disableEdit = false,
canUndo = false,
canRedo = false,
hasLockedBlocks = false,
}: CanvasMenuProps) {
return (
<Popover
@@ -129,11 +132,12 @@ export function CanvasMenu({
</PopoverItem>
<PopoverItem
className='group'
disabled={disableEdit}
disabled={disableEdit || hasLockedBlocks}
onClick={() => {
onAutoLayout()
onClose()
}}
title={hasLockedBlocks ? 'Unlock blocks to use auto-layout' : undefined}
>
<span>Auto-layout</span>
<span className='ml-auto opacity-70 group-hover:opacity-100'>L</span>

View File

@@ -9,7 +9,9 @@ import {
ChevronUp,
ExternalLink,
Loader2,
Lock,
Pencil,
Unlock,
} from 'lucide-react'
import { useParams } from 'next/navigation'
import { useShallow } from 'zustand/react/shallow'
@@ -46,6 +48,7 @@ import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { usePanelEditorStore } from '@/stores/panel'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
/** Stable empty object to avoid creating new references */
const EMPTY_SUBBLOCK_VALUES = {} as Record<string, any>
@@ -110,6 +113,14 @@ export function Editor() {
// Get user permissions
const userPermissions = useUserPermissionsContext()
// Check if block is locked (or inside a locked container) 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 canEditBlock = userPermissions.canEdit && !isLocked
// Get active workflow ID
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
@@ -150,9 +161,7 @@ export function Editor() {
blockSubBlockValues,
canonicalIndex
)
const displayAdvancedOptions = userPermissions.canEdit
? advancedMode
: advancedMode || advancedValuesPresent
const displayAdvancedOptions = canEditBlock ? advancedMode : advancedMode || advancedValuesPresent
const hasAdvancedOnlyFields = useMemo(() => {
for (const subBlock of subBlocksForCanonical) {
@@ -219,13 +228,14 @@ export function Editor() {
collaborativeSetBlockCanonicalMode,
collaborativeUpdateBlockName,
collaborativeToggleBlockAdvancedMode,
collaborativeBatchToggleLocked,
} = useCollaborativeWorkflow()
// Advanced mode toggle handler
const handleToggleAdvancedMode = useCallback(() => {
if (!currentBlockId || !userPermissions.canEdit) return
if (!currentBlockId || !canEditBlock) return
collaborativeToggleBlockAdvancedMode(currentBlockId)
}, [currentBlockId, userPermissions.canEdit, collaborativeToggleBlockAdvancedMode])
}, [currentBlockId, canEditBlock, collaborativeToggleBlockAdvancedMode])
// Rename state
const [isRenaming, setIsRenaming] = useState(false)
@@ -236,10 +246,10 @@ export function Editor() {
* Handles starting the rename process.
*/
const handleStartRename = useCallback(() => {
if (!userPermissions.canEdit || !currentBlock) return
if (!canEditBlock || !currentBlock) return
setEditedName(currentBlock.name || '')
setIsRenaming(true)
}, [userPermissions.canEdit, currentBlock])
}, [canEditBlock, currentBlock])
/**
* Handles saving the renamed block.
@@ -358,6 +368,36 @@ 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 */}
{isLocked && currentBlock && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
{userPermissions.canAdmin && currentBlock.locked && !isParentLocked ? (
<Button
variant='ghost'
className='p-0'
onClick={() => collaborativeBatchToggleLocked([currentBlockId!])}
aria-label='Unlock block'
>
<Unlock className='h-[14px] w-[14px] text-[var(--text-secondary)]' />
</Button>
) : (
<div className='flex items-center justify-center'>
<Lock className='h-[14px] w-[14px] text-[var(--text-secondary)]' />
</div>
)}
</Tooltip.Trigger>
<Tooltip.Content side='top'>
<p>
{isParentLocked
? 'Parent container is locked'
: userPermissions.canAdmin && currentBlock.locked
? 'Unlock block'
: 'Block is locked'}
</p>
</Tooltip.Content>
</Tooltip.Root>
)}
{/* Rename button */}
{currentBlock && (
<Tooltip.Root>
@@ -366,7 +406,7 @@ export function Editor() {
variant='ghost'
className='p-0'
onClick={isRenaming ? handleSaveRename : handleStartRename}
disabled={!userPermissions.canEdit}
disabled={!canEditBlock}
aria-label={isRenaming ? 'Save name' : 'Rename block'}
>
{isRenaming ? (
@@ -434,7 +474,7 @@ export function Editor() {
incomingConnections={incomingConnections}
handleConnectionsResizeMouseDown={handleConnectionsResizeMouseDown}
toggleConnectionsCollapsed={toggleConnectionsCollapsed}
userCanEdit={userPermissions.canEdit}
userCanEdit={canEditBlock}
isConnectionsAtMinHeight={isConnectionsAtMinHeight}
/>
) : (
@@ -542,14 +582,14 @@ export function Editor() {
config={subBlock}
isPreview={false}
subBlockValues={subBlockState}
disabled={!userPermissions.canEdit}
disabled={!canEditBlock}
fieldDiffStatus={undefined}
allowExpandInPreview={false}
canonicalToggle={
isCanonicalSwap && canonicalMode && canonicalId
? {
mode: canonicalMode,
disabled: !userPermissions.canEdit,
disabled: !canEditBlock,
onToggle: () => {
if (!currentBlockId) return
const nextMode =
@@ -579,7 +619,7 @@ export function Editor() {
)
})}
{hasAdvancedOnlyFields && userPermissions.canEdit && (
{hasAdvancedOnlyFields && canEditBlock && (
<div className='flex items-center gap-[10px] px-[2px] pt-[14px] pb-[12px]'>
<div
className='h-[1.25px] flex-1'
@@ -624,7 +664,7 @@ export function Editor() {
config={subBlock}
isPreview={false}
subBlockValues={subBlockState}
disabled={!userPermissions.canEdit}
disabled={!canEditBlock}
fieldDiffStatus={undefined}
allowExpandInPreview={false}
/>

View File

@@ -45,11 +45,13 @@ import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowI
import { useDeleteWorkflow, useImportWorkflow } from '@/app/workspace/[workspaceId]/w/hooks'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useChatStore } from '@/stores/chat/store'
import { useNotificationStore } from '@/stores/notifications/store'
import type { PanelTab } from '@/stores/panel'
import { usePanelStore, useVariablesStore as usePanelVariablesStore } from '@/stores/panel'
import { useVariablesStore } from '@/stores/variables/store'
import { getWorkflowWithValues } from '@/stores/workflows'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
const logger = createLogger('Panel')
/**
@@ -119,6 +121,11 @@ export const Panel = memo(function Panel() {
hydration.phase === 'state-loading'
const { handleAutoLayout: autoLayoutWithFitView } = useAutoLayout(activeWorkflowId || null)
// Check for locked blocks (disables auto-layout)
const hasLockedBlocks = useWorkflowStore((state) =>
Object.values(state.blocks).some((block) => block.locked)
)
// Delete workflow hook
const { isDeleting, handleDeleteWorkflow } = useDeleteWorkflow({
workspaceId,
@@ -230,11 +237,24 @@ export const Panel = memo(function Panel() {
setIsAutoLayouting(true)
try {
await autoLayoutWithFitView()
const result = await autoLayoutWithFitView()
if (!result.success && result.error) {
useNotificationStore.getState().addNotification({
level: 'info',
message: result.error,
workflowId: activeWorkflowId || undefined,
})
}
} finally {
setIsAutoLayouting(false)
}
}, [isExecuting, userPermissions.canEdit, isAutoLayouting, autoLayoutWithFitView])
}, [
isExecuting,
userPermissions.canEdit,
isAutoLayouting,
autoLayoutWithFitView,
activeWorkflowId,
])
/**
* Handles exporting workflow as JSON
@@ -404,7 +424,10 @@ export const Panel = memo(function Panel() {
<PopoverContent align='start' side='bottom' sideOffset={8}>
<PopoverItem
onClick={handleAutoLayout}
disabled={isExecuting || !userPermissions.canEdit || isAutoLayouting}
disabled={
isExecuting || !userPermissions.canEdit || isAutoLayouting || hasLockedBlocks
}
title={hasLockedBlocks ? 'Unlock blocks to use auto-layout' : undefined}
>
<Layout className='h-3 w-3' animate={isAutoLayouting} variant='clockwise' />
<span>Auto layout</span>

View File

@@ -80,6 +80,7 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
: undefined
const isEnabled = currentBlock?.enabled ?? true
const isLocked = currentBlock?.locked ?? false
const isPreview = data?.isPreview || false
// Focus state
@@ -200,7 +201,10 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
{blockName}
</span>
</div>
{!isEnabled && <Badge variant='gray-secondary'>disabled</Badge>}
<div className='flex items-center gap-1'>
{!isEnabled && <Badge variant='gray-secondary'>disabled</Badge>}
{isLocked && <Badge variant='gray-secondary'>locked</Badge>}
</div>
</div>
{!isPreview && (

View File

@@ -18,6 +18,8 @@ export interface UseBlockStateReturn {
diffStatus: DiffStatus
/** Whether this is a deleted block in diff mode */
isDeletedBlock: boolean
/** Whether the block is locked */
isLocked: boolean
}
/**
@@ -40,6 +42,11 @@ export function useBlockState(
? (data.blockState?.enabled ?? true)
: (currentBlock?.enabled ?? true)
// Determine if block is locked
const isLocked = data.isPreview
? (data.blockState?.locked ?? false)
: (currentBlock?.locked ?? false)
// Get diff status
const diffStatus: DiffStatus =
currentWorkflow.isDiffMode && currentBlock && hasDiffStatus(currentBlock)
@@ -68,5 +75,6 @@ export function useBlockState(
isActive,
diffStatus,
isDeletedBlock: isDeletedBlock ?? false,
isLocked,
}
}

View File

@@ -672,6 +672,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
currentWorkflow,
activeWorkflowId,
isEnabled,
isLocked,
handleClick,
hasRing,
ringStyles,
@@ -1100,7 +1101,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
{name}
</span>
</div>
<div className='relative z-10 flex flex-shrink-0 items-center gap-2'>
<div className='relative z-10 flex flex-shrink-0 items-center gap-1'>
{isWorkflowSelector &&
childWorkflowId &&
typeof childIsDeployed === 'boolean' &&
@@ -1133,6 +1134,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
</Tooltip.Root>
)}
{!isEnabled && <Badge variant='gray-secondary'>disabled</Badge>}
{isLocked && <Badge variant='gray-secondary'>locked</Badge>}
{type === 'schedule' && shouldShowScheduleBadge && scheduleInfo?.isDisabled && (
<Tooltip.Root>

View File

@@ -47,6 +47,7 @@ export function useBlockVisual({
isActive: isExecuting,
diffStatus,
isDeletedBlock,
isLocked,
} = useBlockState(blockId, currentWorkflow, data)
const currentBlockId = usePanelEditorStore((state) => state.currentBlockId)
@@ -103,6 +104,7 @@ export function useBlockVisual({
currentWorkflow,
activeWorkflowId,
isEnabled,
isLocked,
handleClick,
hasRing,
ringStyles,

View File

@@ -31,7 +31,8 @@ export function useCanvasContextMenu({ blocks, getNodes, setNodes }: UseCanvasCo
nodes.map((n) => {
const block = blocks[n.id]
const parentId = block?.data?.parentId
const parentType = parentId ? blocks[parentId]?.type : undefined
const parentBlock = parentId ? blocks[parentId] : undefined
const parentType = parentBlock?.type
return {
id: n.id,
type: block?.type || '',
@@ -39,6 +40,9 @@ export function useCanvasContextMenu({ blocks, getNodes, setNodes }: UseCanvasCo
horizontalHandles: block?.horizontalHandles ?? false,
parentId,
parentType,
locked: block?.locked ?? false,
isParentLocked: parentBlock?.locked ?? false,
isParentDisabled: parentBlock ? !parentBlock.enabled : false,
}
}),
[blocks]

View File

@@ -52,6 +52,16 @@ export async function applyAutoLayoutAndUpdateStore(
return { success: false, error: 'No blocks to layout' }
}
// Check for locked blocks - auto-layout is disabled when blocks are locked
const hasLockedBlocks = Object.values(blocks).some((block) => block.locked)
if (hasLockedBlocks) {
logger.info('Auto layout skipped: workflow contains locked blocks', { workflowId })
return {
success: false,
error: 'Auto-layout is disabled when blocks are locked. Unlock blocks to use auto-layout.',
}
}
// Merge with default options
const layoutOptions = {
spacing: {

View File

@@ -0,0 +1,87 @@
import type { BlockState } from '@/stores/workflows/workflow/types'
/**
* Result of filtering protected blocks from a deletion operation
*/
export interface FilterProtectedBlocksResult {
/** Block IDs that can be deleted (not protected) */
deletableIds: string[]
/** Block IDs that are protected and cannot be deleted */
protectedIds: string[]
/** Whether all blocks are protected (deletion should be cancelled entirely) */
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 if either its source or target block is protected.
*
* @param edge - The edge to check (must have source and target)
* @param blocks - Record of all blocks in the workflow
* @returns True if the edge is protected
*/
export function isEdgeProtected(
edge: { source: string; target: string },
blocks: Record<string, BlockState>
): boolean {
return isBlockProtected(edge.source, blocks) || isBlockProtected(edge.target, blocks)
}
/**
* Filters out protected blocks from a list of block IDs for deletion.
* Protected blocks are those that are locked or inside a locked container.
*
* @param blockIds - Array of block IDs to filter
* @param blocks - Record of all blocks in the workflow
* @returns Result containing deletable IDs, protected IDs, and whether all are protected
*/
export function filterProtectedBlocks(
blockIds: string[],
blocks: Record<string, BlockState>
): FilterProtectedBlocksResult {
const protectedIds = blockIds.filter((id) => isBlockProtected(id, blocks))
const deletableIds = blockIds.filter((id) => !protectedIds.includes(id))
return {
deletableIds,
protectedIds,
allProtected: protectedIds.length === blockIds.length && blockIds.length > 0,
}
}
/**
* Checks if any blocks in the selection are protected.
* Useful for determining if edit actions should be disabled.
*
* @param blockIds - Array of block IDs to check
* @param blocks - Record of all blocks in the workflow
* @returns True if any block is protected
*/
export function hasProtectedBlocks(
blockIds: string[],
blocks: Record<string, BlockState>
): boolean {
return blockIds.some((id) => isBlockProtected(id, blocks))
}

View File

@@ -1,4 +1,5 @@
export * from './auto-layout-utils'
export * from './block-protection-utils'
export * from './block-ring-utils'
export * from './node-position-utils'
export * from './workflow-canvas-helpers'

View File

@@ -55,7 +55,10 @@ import {
clearDragHighlights,
computeClampedPositionUpdates,
estimateBlockDimensions,
filterProtectedBlocks,
getClampedPositionForNode,
isBlockProtected,
isEdgeProtected,
isInEditableElement,
resolveParentChildSelectionConflicts,
validateTriggerPaste,
@@ -543,6 +546,7 @@ const WorkflowContent = React.memo(() => {
collaborativeBatchRemoveBlocks,
collaborativeBatchToggleBlockEnabled,
collaborativeBatchToggleBlockHandles,
collaborativeBatchToggleLocked,
undo,
redo,
} = useCollaborativeWorkflow()
@@ -1069,8 +1073,27 @@ const WorkflowContent = React.memo(() => {
const handleContextDelete = useCallback(() => {
const blockIds = contextMenuBlocks.map((b) => b.id)
collaborativeBatchRemoveBlocks(blockIds)
}, [contextMenuBlocks, collaborativeBatchRemoveBlocks])
const { deletableIds, protectedIds, allProtected } = filterProtectedBlocks(blockIds, blocks)
if (protectedIds.length > 0) {
if (allProtected) {
addNotification({
level: 'info',
message: 'Cannot delete locked blocks or blocks inside locked containers',
workflowId: activeWorkflowId || undefined,
})
return
}
addNotification({
level: 'info',
message: `Skipped ${protectedIds.length} protected block(s)`,
workflowId: activeWorkflowId || undefined,
})
}
if (deletableIds.length > 0) {
collaborativeBatchRemoveBlocks(deletableIds)
}
}, [contextMenuBlocks, collaborativeBatchRemoveBlocks, addNotification, activeWorkflowId, blocks])
const handleContextToggleEnabled = useCallback(() => {
const blockIds = contextMenuBlocks.map((block) => block.id)
@@ -1082,6 +1105,11 @@ const WorkflowContent = React.memo(() => {
collaborativeBatchToggleBlockHandles(blockIds)
}, [contextMenuBlocks, collaborativeBatchToggleBlockHandles])
const handleContextToggleLocked = useCallback(() => {
const blockIds = contextMenuBlocks.map((block) => block.id)
collaborativeBatchToggleLocked(blockIds)
}, [contextMenuBlocks, collaborativeBatchToggleLocked])
const handleContextRemoveFromSubflow = useCallback(() => {
const blocksToRemove = contextMenuBlocks.filter(
(block) => block.parentId && (block.parentType === 'loop' || block.parentType === 'parallel')
@@ -1951,7 +1979,6 @@ const WorkflowContent = React.memo(() => {
const loadingWorkflowRef = useRef<string | null>(null)
const currentWorkflowExists = Boolean(workflows[workflowIdParam])
/** Initializes workflow when it exists in registry and needs hydration. */
useEffect(() => {
const currentId = workflowIdParam
const currentWorkspaceHydration = hydration.workspaceId
@@ -2128,6 +2155,7 @@ const WorkflowContent = React.memo(() => {
parentId: block.data?.parentId,
extent: block.data?.extent || undefined,
dragHandle: '.workflow-drag-handle',
draggable: !isBlockProtected(block.id, blocks),
data: {
...block.data,
name: block.name,
@@ -2163,6 +2191,7 @@ const WorkflowContent = React.memo(() => {
position,
parentId: block.data?.parentId,
dragHandle,
draggable: !isBlockProtected(block.id, blocks),
extent: (() => {
// Clamp children to subflow body (exclude header)
const parentId = block.data?.parentId as string | undefined
@@ -2491,12 +2520,18 @@ const WorkflowContent = React.memo(() => {
const edgeIdsToRemove = changes
.filter((change: any) => change.type === 'remove')
.map((change: any) => change.id)
.filter((edgeId: string) => {
// Prevent removing edges connected to protected blocks
const edge = edges.find((e) => e.id === edgeId)
if (!edge) return true
return !isEdgeProtected(edge, blocks)
})
if (edgeIdsToRemove.length > 0) {
collaborativeBatchRemoveEdges(edgeIdsToRemove)
}
},
[collaborativeBatchRemoveEdges]
[collaborativeBatchRemoveEdges, edges, blocks]
)
/**
@@ -2558,6 +2593,16 @@ const WorkflowContent = React.memo(() => {
if (!sourceNode || !targetNode) return
// Prevent connections to/from protected blocks
if (isEdgeProtected(connection, blocks)) {
addNotification({
level: 'info',
message: 'Cannot connect to locked blocks or blocks inside locked containers',
workflowId: activeWorkflowId || undefined,
})
return
}
// Get parent information (handle container start node case)
const sourceParentId =
blocks[sourceNode.id]?.data?.parentId ||
@@ -2620,7 +2665,7 @@ const WorkflowContent = React.memo(() => {
connectionCompletedRef.current = true
}
},
[addEdge, getNodes, blocks]
[addEdge, getNodes, blocks, addNotification, activeWorkflowId]
)
/**
@@ -2715,6 +2760,9 @@ const WorkflowContent = React.memo(() => {
// Only consider container nodes that aren't the dragged node
if (n.type !== 'subflowNode' || n.id === node.id) return false
// Don't allow dropping into locked containers
if (blocks[n.id]?.locked) return false
// Get the container's absolute position
const containerAbsolutePos = getNodeAbsolutePosition(n.id)
@@ -2807,6 +2855,11 @@ const WorkflowContent = React.memo(() => {
/** Captures initial parent ID and position when drag starts. */
const onNodeDragStart = useCallback(
(_event: React.MouseEvent, node: any) => {
// Prevent dragging protected blocks
if (isBlockProtected(node.id, blocks)) {
return
}
// Store the original parent ID when starting to drag
const currentParentId = blocks[node.id]?.data?.parentId || null
setDragStartParentId(currentParentId)
@@ -2835,7 +2888,7 @@ const WorkflowContent = React.memo(() => {
}
})
},
[blocks, setDragStartPosition, getNodes, potentialParentId, setPotentialParentId]
[blocks, setDragStartPosition, getNodes, setPotentialParentId]
)
/** Handles node drag stop to establish parent-child relationships. */
@@ -2897,6 +2950,18 @@ const WorkflowContent = React.memo(() => {
// Don't process parent changes if the node hasn't actually changed parent or is being moved within same parent
if (potentialParentId === dragStartParentId) return
// Prevent moving locked blocks out of locked containers
// Unlocked blocks (e.g., duplicates) can be moved out freely
if (dragStartParentId && blocks[dragStartParentId]?.locked && blocks[node.id]?.locked) {
addNotification({
level: 'info',
message: 'Cannot move locked blocks out of locked containers',
workflowId: activeWorkflowId || undefined,
})
setPotentialParentId(dragStartParentId) // Reset to original parent
return
}
// Check if this is a starter block - starter blocks should never be in containers
const isStarterBlock = node.data?.type === 'starter'
if (isStarterBlock) {
@@ -3293,6 +3358,16 @@ const WorkflowContent = React.memo(() => {
/** Stable delete handler to avoid creating new function references per edge. */
const handleEdgeDelete = useCallback(
(edgeId: string) => {
// Prevent removing edges connected to protected blocks
const edge = edges.find((e) => e.id === edgeId)
if (edge && isEdgeProtected(edge, blocks)) {
addNotification({
level: 'info',
message: 'Cannot remove connections from locked blocks',
workflowId: activeWorkflowId || undefined,
})
return
}
removeEdge(edgeId)
// Remove this edge from selection (find by edge ID value)
setSelectedEdges((prev) => {
@@ -3305,7 +3380,7 @@ const WorkflowContent = React.memo(() => {
return next
})
},
[removeEdge]
[removeEdge, edges, blocks, addNotification, activeWorkflowId]
)
/** Transforms edges to include selection state and delete handlers. Memoized to prevent re-renders. */
@@ -3346,9 +3421,15 @@ const WorkflowContent = React.memo(() => {
// Handle edge deletion first (edges take priority if selected)
if (selectedEdges.size > 0) {
// Get all selected edge IDs and batch delete them
const edgeIds = Array.from(selectedEdges.values())
collaborativeBatchRemoveEdges(edgeIds)
// Get all selected edge IDs and filter out edges connected to protected blocks
const edgeIds = Array.from(selectedEdges.values()).filter((edgeId) => {
const edge = edges.find((e) => e.id === edgeId)
if (!edge) return true
return !isEdgeProtected(edge, blocks)
})
if (edgeIds.length > 0) {
collaborativeBatchRemoveEdges(edgeIds)
}
setSelectedEdges(new Map())
return
}
@@ -3365,7 +3446,29 @@ const WorkflowContent = React.memo(() => {
event.preventDefault()
const selectedIds = selectedNodes.map((node) => node.id)
collaborativeBatchRemoveBlocks(selectedIds)
const { deletableIds, protectedIds, allProtected } = filterProtectedBlocks(
selectedIds,
blocks
)
if (protectedIds.length > 0) {
if (allProtected) {
addNotification({
level: 'info',
message: 'Cannot delete locked blocks or blocks inside locked containers',
workflowId: activeWorkflowId || undefined,
})
return
}
addNotification({
level: 'info',
message: `Skipped ${protectedIds.length} protected block(s)`,
workflowId: activeWorkflowId || undefined,
})
}
if (deletableIds.length > 0) {
collaborativeBatchRemoveBlocks(deletableIds)
}
}
window.addEventListener('keydown', handleKeyDown)
@@ -3376,6 +3479,10 @@ const WorkflowContent = React.memo(() => {
getNodes,
collaborativeBatchRemoveBlocks,
effectivePermissions.canEdit,
blocks,
edges,
addNotification,
activeWorkflowId,
])
return (
@@ -3496,12 +3603,18 @@ const WorkflowContent = React.memo(() => {
(b) => b.parentId && (b.parentType === 'loop' || b.parentType === 'parallel')
)}
canRunFromBlock={runFromBlockState.canRun}
disableEdit={!effectivePermissions.canEdit}
disableEdit={
!effectivePermissions.canEdit ||
contextMenuBlocks.some((b) => b.locked || b.isParentLocked)
}
userCanEdit={effectivePermissions.canEdit}
isExecuting={isExecuting}
isPositionalTrigger={
contextMenuBlocks.length === 1 &&
edges.filter((e) => e.target === contextMenuBlocks[0]?.id).length === 0
}
onToggleLocked={handleContextToggleLocked}
canAdmin={effectivePermissions.canAdmin}
/>
<CanvasMenu
@@ -3524,6 +3637,7 @@ const WorkflowContent = React.memo(() => {
disableEdit={!effectivePermissions.canEdit}
canUndo={canUndo}
canRedo={canRedo}
hasLockedBlocks={Object.values(blocks).some((b) => b.locked)}
/>
</>
)}

View File

@@ -458,8 +458,8 @@ export function getCodeEditorProps(options?: {
'caret-[var(--text-primary)] dark:caret-white',
// Font smoothing
'[-webkit-font-smoothing:antialiased] [-moz-osx-font-smoothing:grayscale]',
// Disable interaction for streaming/preview
(isStreaming || isPreview) && 'pointer-events-none'
// Disable interaction for streaming/preview/disabled
(isStreaming || isPreview || disabled) && 'pointer-events-none'
),
}
}

View File

@@ -212,11 +212,11 @@ export class WorkflowBlockHandler implements BlockHandler {
/**
* Parses a potentially nested workflow error message to extract:
* - The chain of workflow names
* - The actual root error message (preserving the block prefix for the failing block)
* - The actual root error message (preserving the block name prefix for the failing block)
*
* Handles formats like:
* - "workflow-name" failed: error
* - [block_type] Block Name: "workflow-name" failed: error
* - Block Name: "workflow-name" failed: error
* - Workflow chain: A → B | error
*/
private parseNestedWorkflowError(message: string): { chain: string[]; rootError: string } {
@@ -234,8 +234,8 @@ export class WorkflowBlockHandler implements BlockHandler {
// Extract workflow names from patterns like:
// - "workflow-name" failed:
// - [block_type] Block Name: "workflow-name" failed:
const workflowPattern = /(?:\[[^\]]+\]\s*[^:]+:\s*)?"([^"]+)"\s*failed:\s*/g
// - Block Name: "workflow-name" failed:
const workflowPattern = /(?:\[[^\]]+\]\s*)?(?:[^:]+:\s*)?"([^"]+)"\s*failed:\s*/g
let match: RegExpExecArray | null
let lastIndex = 0
@@ -247,7 +247,7 @@ export class WorkflowBlockHandler implements BlockHandler {
}
// The root error is everything after the last match
// Keep the block prefix (e.g., [function] Function 1:) so we know which block failed
// Keep the block name prefix (e.g., Function 1:) so we know which block failed
const rootError = lastIndex > 0 ? remaining.slice(lastIndex) : remaining
return { chain, rootError: rootError.trim() || 'Unknown error' }

View File

@@ -47,7 +47,7 @@ export function buildBlockExecutionError(details: BlockExecutionErrorDetails): E
const blockName = details.block.metadata?.name || details.block.id
const blockType = details.block.metadata?.id || 'unknown'
const error = new Error(`[${blockType}] ${blockName}: ${errorMessage}`)
const error = new Error(`${blockName}: ${errorMessage}`)
Object.assign(error, {
blockId: details.block.id,

View File

@@ -409,6 +409,20 @@ export function useCollaborativeWorkflow() {
logger.info('Successfully applied batch-toggle-handles from remote user')
break
}
case BLOCKS_OPERATIONS.BATCH_TOGGLE_LOCKED: {
const { blockIds } = payload
logger.info('Received batch-toggle-locked from remote user', {
userId,
count: (blockIds || []).length,
})
if (blockIds && blockIds.length > 0) {
useWorkflowStore.getState().batchToggleLocked(blockIds)
}
logger.info('Successfully applied batch-toggle-locked from remote user')
break
}
case BLOCKS_OPERATIONS.BATCH_UPDATE_PARENT: {
const { updates } = payload
logger.info('Received batch-update-parent from remote user', {
@@ -823,14 +837,27 @@ export function useCollaborativeWorkflow() {
if (ids.length === 0) return
const currentBlocks = useWorkflowStore.getState().blocks
const previousStates: Record<string, boolean> = {}
const validIds: string[] = []
// For each ID, collect non-locked blocks and their children for undo/redo
for (const id of ids) {
const block = useWorkflowStore.getState().blocks[id]
if (block) {
previousStates[id] = block.enabled
validIds.push(id)
const block = currentBlocks[id]
if (!block) continue
// Skip locked blocks
if (block.locked) 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 (block.type === 'loop' || block.type === 'parallel') {
Object.entries(currentBlocks).forEach(([blockId, b]) => {
if (b.data?.parentId === id && !b.locked) {
previousStates[blockId] = b.enabled
}
})
}
}
@@ -992,12 +1019,25 @@ export function useCollaborativeWorkflow() {
if (ids.length === 0) return
const blocks = useWorkflowStore.getState().blocks
// Helper to check if a block is protected (locked or inside locked parent)
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 = useWorkflowStore.getState().blocks[id]
if (block) {
const block = blocks[id]
// Skip locked blocks and blocks inside locked containers
if (block && !isProtected(id)) {
previousStates[id] = block.horizontalHandles ?? false
validIds.push(id)
}
@@ -1025,6 +1065,58 @@ export function useCollaborativeWorkflow() {
[isBaselineDiffView, addToQueue, activeWorkflowId, session?.user?.id, undoRedo]
)
const collaborativeBatchToggleLocked = useCallback(
(ids: string[]) => {
if (isBaselineDiffView) {
return
}
if (ids.length === 0) return
const currentBlocks = useWorkflowStore.getState().blocks
const previousStates: Record<string, boolean> = {}
const validIds: string[] = []
// For each ID, collect blocks and their children for undo/redo
for (const id of ids) {
const block = currentBlocks[id]
if (!block) continue
validIds.push(id)
previousStates[id] = block.locked ?? false
// If it's a loop or parallel, also capture children's previous states for undo/redo
if (block.type === 'loop' || block.type === 'parallel') {
Object.entries(currentBlocks).forEach(([blockId, b]) => {
if (b.data?.parentId === id) {
previousStates[blockId] = b.locked ?? false
}
})
}
}
if (validIds.length === 0) return
const operationId = crypto.randomUUID()
addToQueue({
id: operationId,
operation: {
operation: BLOCKS_OPERATIONS.BATCH_TOGGLE_LOCKED,
target: OPERATION_TARGETS.BLOCKS,
payload: { blockIds: validIds, previousStates },
},
workflowId: activeWorkflowId || '',
userId: session?.user?.id || 'unknown',
})
useWorkflowStore.getState().batchToggleLocked(validIds)
undoRedo.recordBatchToggleLocked(validIds, previousStates)
},
[isBaselineDiffView, addToQueue, activeWorkflowId, session?.user?.id, undoRedo]
)
const collaborativeBatchAddEdges = useCallback(
(edges: Edge[], options?: { skipUndoRedo?: boolean }) => {
if (isBaselineDiffView) {
@@ -1669,6 +1761,7 @@ export function useCollaborativeWorkflow() {
collaborativeToggleBlockAdvancedMode,
collaborativeSetBlockCanonicalMode,
collaborativeBatchToggleBlockHandles,
collaborativeBatchToggleLocked,
collaborativeBatchAddBlocks,
collaborativeBatchRemoveBlocks,
collaborativeBatchAddEdges,

View File

@@ -20,6 +20,7 @@ import {
type BatchRemoveEdgesOperation,
type BatchToggleEnabledOperation,
type BatchToggleHandlesOperation,
type BatchToggleLockedOperation,
type BatchUpdateParentOperation,
captureLatestEdges,
captureLatestSubBlockValues,
@@ -415,6 +416,36 @@ export function useUndoRedo() {
[activeWorkflowId, userId]
)
const recordBatchToggleLocked = useCallback(
(blockIds: string[], previousStates: Record<string, boolean>) => {
if (!activeWorkflowId || blockIds.length === 0) return
const operation: BatchToggleLockedOperation = {
id: crypto.randomUUID(),
type: UNDO_REDO_OPERATIONS.BATCH_TOGGLE_LOCKED,
timestamp: Date.now(),
workflowId: activeWorkflowId,
userId,
data: { blockIds, previousStates },
}
const inverse: BatchToggleLockedOperation = {
id: crypto.randomUUID(),
type: UNDO_REDO_OPERATIONS.BATCH_TOGGLE_LOCKED,
timestamp: Date.now(),
workflowId: activeWorkflowId,
userId,
data: { blockIds, previousStates },
}
const entry = createOperationEntry(operation, inverse)
useUndoRedoStore.getState().push(activeWorkflowId, userId, entry)
logger.debug('Recorded batch toggle locked', { blockIds, previousStates })
},
[activeWorkflowId, userId]
)
const undo = useCallback(async () => {
if (!activeWorkflowId) return
@@ -777,7 +808,9 @@ export function useUndoRedo() {
const toggleOp = entry.inverse as BatchToggleEnabledOperation
const { blockIds, previousStates } = toggleOp.data
const validBlockIds = blockIds.filter((id) => useWorkflowStore.getState().blocks[id])
// Restore all blocks in previousStates (includes children of containers)
const allBlockIds = Object.keys(previousStates)
const validBlockIds = allBlockIds.filter((id) => useWorkflowStore.getState().blocks[id])
if (validBlockIds.length === 0) {
logger.debug('Undo batch-toggle-enabled skipped; no blocks exist')
break
@@ -788,14 +821,14 @@ export function useUndoRedo() {
operation: {
operation: BLOCKS_OPERATIONS.BATCH_TOGGLE_ENABLED,
target: OPERATION_TARGETS.BLOCKS,
payload: { blockIds: validBlockIds, previousStates },
payload: { blockIds, previousStates },
},
workflowId: activeWorkflowId,
userId,
})
// Use setBlockEnabled to directly restore to previous state
// This is more robust than conditional toggle in collaborative scenarios
// This restores all affected blocks including children of containers
validBlockIds.forEach((blockId) => {
useWorkflowStore.getState().setBlockEnabled(blockId, previousStates[blockId])
})
@@ -829,6 +862,36 @@ export function useUndoRedo() {
})
break
}
case UNDO_REDO_OPERATIONS.BATCH_TOGGLE_LOCKED: {
const toggleOp = entry.inverse as BatchToggleLockedOperation
const { blockIds, previousStates } = toggleOp.data
// Restore all blocks in previousStates (includes children of containers)
const allBlockIds = Object.keys(previousStates)
const validBlockIds = allBlockIds.filter((id) => useWorkflowStore.getState().blocks[id])
if (validBlockIds.length === 0) {
logger.debug('Undo batch-toggle-locked skipped; no blocks exist')
break
}
addToQueue({
id: opId,
operation: {
operation: BLOCKS_OPERATIONS.BATCH_TOGGLE_LOCKED,
target: OPERATION_TARGETS.BLOCKS,
payload: { blockIds, previousStates },
},
workflowId: activeWorkflowId,
userId,
})
// Use setBlockLocked to directly restore to previous state
// This restores all affected blocks including children of containers
validBlockIds.forEach((blockId) => {
useWorkflowStore.getState().setBlockLocked(blockId, previousStates[blockId])
})
break
}
case UNDO_REDO_OPERATIONS.APPLY_DIFF: {
const applyDiffInverse = entry.inverse as any
const { baselineSnapshot } = applyDiffInverse.data
@@ -1365,7 +1428,9 @@ export function useUndoRedo() {
const toggleOp = entry.operation as BatchToggleEnabledOperation
const { blockIds, previousStates } = toggleOp.data
const validBlockIds = blockIds.filter((id) => useWorkflowStore.getState().blocks[id])
// Process all blocks in previousStates (includes children of containers)
const allBlockIds = Object.keys(previousStates)
const validBlockIds = allBlockIds.filter((id) => useWorkflowStore.getState().blocks[id])
if (validBlockIds.length === 0) {
logger.debug('Redo batch-toggle-enabled skipped; no blocks exist')
break
@@ -1376,16 +1441,18 @@ export function useUndoRedo() {
operation: {
operation: BLOCKS_OPERATIONS.BATCH_TOGGLE_ENABLED,
target: OPERATION_TARGETS.BLOCKS,
payload: { blockIds: validBlockIds, previousStates },
payload: { blockIds, previousStates },
},
workflowId: activeWorkflowId,
userId,
})
// Use setBlockEnabled to directly set to toggled state
// Redo sets to !previousStates (the state after the original toggle)
// Compute target state the same way batchToggleEnabled does:
// use !firstBlock.enabled, where firstBlock is blockIds[0]
const firstBlockId = blockIds[0]
const targetEnabled = !previousStates[firstBlockId]
validBlockIds.forEach((blockId) => {
useWorkflowStore.getState().setBlockEnabled(blockId, !previousStates[blockId])
useWorkflowStore.getState().setBlockEnabled(blockId, targetEnabled)
})
break
}
@@ -1417,6 +1484,38 @@ export function useUndoRedo() {
})
break
}
case UNDO_REDO_OPERATIONS.BATCH_TOGGLE_LOCKED: {
const toggleOp = entry.operation as BatchToggleLockedOperation
const { blockIds, previousStates } = toggleOp.data
// Process all blocks in previousStates (includes children of containers)
const allBlockIds = Object.keys(previousStates)
const validBlockIds = allBlockIds.filter((id) => useWorkflowStore.getState().blocks[id])
if (validBlockIds.length === 0) {
logger.debug('Redo batch-toggle-locked skipped; no blocks exist')
break
}
addToQueue({
id: opId,
operation: {
operation: BLOCKS_OPERATIONS.BATCH_TOGGLE_LOCKED,
target: OPERATION_TARGETS.BLOCKS,
payload: { blockIds, previousStates },
},
workflowId: activeWorkflowId,
userId,
})
// Compute target state the same way batchToggleLocked does:
// use !firstBlock.locked, where firstBlock is blockIds[0]
const firstBlockId = blockIds[0]
const targetLocked = !previousStates[firstBlockId]
validBlockIds.forEach((blockId) => {
useWorkflowStore.getState().setBlockLocked(blockId, targetLocked)
})
break
}
case UNDO_REDO_OPERATIONS.APPLY_DIFF: {
// Redo apply-diff means re-applying the proposed state with diff markers
const applyDiffOp = entry.operation as any
@@ -1738,6 +1837,7 @@ export function useUndoRedo() {
recordBatchUpdateParent,
recordBatchToggleEnabled,
recordBatchToggleHandles,
recordBatchToggleLocked,
recordApplyDiff,
recordAcceptDiff,
recordRejectDiff,

View File

@@ -54,6 +54,7 @@ type SkippedItemType =
| 'block_not_found'
| 'invalid_block_type'
| 'block_not_allowed'
| 'block_locked'
| 'tool_not_allowed'
| 'invalid_edge_target'
| 'invalid_edge_source'
@@ -618,6 +619,7 @@ function createBlockFromParams(
subBlocks: {},
outputs: outputs,
data: parentId ? { parentId, extent: 'parent' as const } : {},
locked: false,
}
// Add validated inputs as subBlocks
@@ -1520,6 +1522,24 @@ function applyOperationsToWorkflowState(
break
}
// Check if block is locked or inside a locked container
const deleteBlock = modifiedState.blocks[block_id]
const deleteParentId = deleteBlock.data?.parentId as string | undefined
const deleteParentLocked = deleteParentId
? modifiedState.blocks[deleteParentId]?.locked
: false
if (deleteBlock.locked || deleteParentLocked) {
logSkippedItem(skippedItems, {
type: 'block_locked',
operationType: 'delete',
blockId: block_id,
reason: deleteParentLocked
? `Block "${block_id}" is inside locked container "${deleteParentId}" and cannot be deleted`
: `Block "${block_id}" is locked and cannot be deleted`,
})
break
}
// Find all child blocks to remove
const blocksToRemove = new Set<string>([block_id])
const findChildren = (parentId: string) => {
@@ -1555,6 +1575,21 @@ function applyOperationsToWorkflowState(
const block = modifiedState.blocks[block_id]
// Check if block is locked or inside a locked container
const editParentId = block.data?.parentId as string | undefined
const editParentLocked = editParentId ? modifiedState.blocks[editParentId]?.locked : false
if (block.locked || editParentLocked) {
logSkippedItem(skippedItems, {
type: 'block_locked',
operationType: 'edit',
blockId: block_id,
reason: editParentLocked
? `Block "${block_id}" is inside locked container "${editParentId}" and cannot be edited`
: `Block "${block_id}" is locked and cannot be edited`,
})
break
}
// Ensure block has essential properties
if (!block.type) {
logger.warn(`Block ${block_id} missing type property, skipping edit`, {
@@ -2122,6 +2157,19 @@ function applyOperationsToWorkflowState(
// Handle nested nodes (for loops/parallels created from scratch)
if (params.nestedNodes) {
// Defensive check: verify parent is not locked before adding children
// (Parent was just created with locked: false, but check for consistency)
const parentBlock = modifiedState.blocks[block_id]
if (parentBlock?.locked) {
logSkippedItem(skippedItems, {
type: 'block_locked',
operationType: 'add_nested_nodes',
blockId: block_id,
reason: `Container "${block_id}" is locked - cannot add nested nodes`,
})
break
}
Object.entries(params.nestedNodes).forEach(([childId, childBlock]: [string, any]) => {
// Validate childId is a valid string
if (!isValidKey(childId)) {
@@ -2209,6 +2257,18 @@ function applyOperationsToWorkflowState(
break
}
// Check if subflow is locked
if (subflowBlock.locked) {
logSkippedItem(skippedItems, {
type: 'block_locked',
operationType: 'insert_into_subflow',
blockId: block_id,
reason: `Subflow "${subflowId}" is locked - cannot insert block "${block_id}"`,
details: { subflowId },
})
break
}
if (subflowBlock.type !== 'loop' && subflowBlock.type !== 'parallel') {
logger.error('Subflow block has invalid type', {
subflowId,
@@ -2247,6 +2307,17 @@ function applyOperationsToWorkflowState(
break
}
// Check if existing block is locked
if (existingBlock.locked) {
logSkippedItem(skippedItems, {
type: 'block_locked',
operationType: 'insert_into_subflow',
blockId: block_id,
reason: `Block "${block_id}" is locked and cannot be moved into a subflow`,
})
break
}
// Moving existing block into subflow - just update parent
existingBlock.data = {
...existingBlock.data,
@@ -2392,6 +2463,30 @@ function applyOperationsToWorkflowState(
break
}
// Check if block is locked
if (block.locked) {
logSkippedItem(skippedItems, {
type: 'block_locked',
operationType: 'extract_from_subflow',
blockId: block_id,
reason: `Block "${block_id}" is locked and cannot be extracted from subflow`,
})
break
}
// Check if parent subflow is locked
const parentSubflow = modifiedState.blocks[subflowId]
if (parentSubflow?.locked) {
logSkippedItem(skippedItems, {
type: 'block_locked',
operationType: 'extract_from_subflow',
blockId: block_id,
reason: `Subflow "${subflowId}" is locked - cannot extract block "${block_id}"`,
details: { subflowId },
})
break
}
// Verify it's actually a child of this subflow
if (block.data?.parentId !== subflowId) {
logger.warn('Block is not a child of specified subflow', {

View File

@@ -296,6 +296,26 @@ describe('hasWorkflowChanged', () => {
})
expect(hasWorkflowChanged(state1, state2)).toBe(true)
})
it.concurrent('should detect locked/unlocked changes', () => {
const state1 = createWorkflowState({
blocks: { block1: createBlock('block1', { locked: false }) },
})
const state2 = createWorkflowState({
blocks: { block1: createBlock('block1', { locked: true }) },
})
expect(hasWorkflowChanged(state1, state2)).toBe(true)
})
it.concurrent('should not detect changes when locked state is the same', () => {
const state1 = createWorkflowState({
blocks: { block1: createBlock('block1', { locked: true }) },
})
const state2 = createWorkflowState({
blocks: { block1: createBlock('block1', { locked: true }) },
})
expect(hasWorkflowChanged(state1, state2)).toBe(false)
})
})
describe('SubBlock Changes', () => {

View File

@@ -157,7 +157,7 @@ export function generateWorkflowDiffSummary(
}
// Check other block properties (boolean fields)
// Use !! to normalize: null/undefined/false are all equivalent (falsy)
const blockFields = ['horizontalHandles', 'advancedMode', 'triggerMode'] as const
const blockFields = ['horizontalHandles', 'advancedMode', 'triggerMode', 'locked'] as const
for (const field of blockFields) {
if (!!currentBlock[field] !== !!previousBlock[field]) {
changes.push({

View File

@@ -100,6 +100,7 @@ function buildStartBlockState(
triggerMode: false,
height: 0,
data: {},
locked: false,
}
return { blockState, subBlockValues }

View File

@@ -0,0 +1,173 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types'
// Mock all external dependencies before imports
vi.mock('@sim/logger', () => ({
createLogger: () => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
}),
}))
vi.mock('@/stores/workflows/workflow/store', () => ({
useWorkflowStore: {
getState: () => ({
getWorkflowState: () => ({ blocks: {}, edges: [], loops: {}, parallels: {} }),
}),
},
}))
vi.mock('@/stores/workflows/utils', () => ({
mergeSubblockState: (blocks: Record<string, BlockState>) => blocks,
}))
vi.mock('@/lib/workflows/sanitization/key-validation', () => ({
isValidKey: (key: string) => key !== 'undefined' && key !== 'null' && key !== '',
}))
vi.mock('@/lib/workflows/autolayout', () => ({
transferBlockHeights: vi.fn(),
applyTargetedLayout: (blocks: Record<string, BlockState>) => blocks,
applyAutoLayout: () => ({ success: true, blocks: {} }),
}))
vi.mock('@/lib/workflows/autolayout/constants', () => ({
DEFAULT_HORIZONTAL_SPACING: 500,
DEFAULT_VERTICAL_SPACING: 400,
DEFAULT_LAYOUT_OPTIONS: {},
}))
vi.mock('@/stores/workflows/workflow/utils', () => ({
generateLoopBlocks: () => ({}),
generateParallelBlocks: () => ({}),
}))
import { WorkflowDiffEngine } from './diff-engine'
function createMockBlock(overrides: Partial<BlockState> = {}): BlockState {
return {
id: 'block-1',
type: 'agent',
name: 'Test Block',
enabled: true,
position: { x: 0, y: 0 },
subBlocks: {},
outputs: {},
...overrides,
} as BlockState
}
function createMockWorkflowState(blocks: Record<string, BlockState>): WorkflowState {
return {
blocks,
edges: [],
loops: {},
parallels: {},
}
}
describe('WorkflowDiffEngine', () => {
let engine: WorkflowDiffEngine
beforeEach(() => {
engine = new WorkflowDiffEngine()
vi.clearAllMocks()
})
describe('hasBlockChanged detection', () => {
describe('locked state changes', () => {
it.concurrent(
'should detect when block locked state changes from false to true',
async () => {
const freshEngine = new WorkflowDiffEngine()
const baseline = createMockWorkflowState({
'block-1': createMockBlock({ id: 'block-1', locked: false }),
})
const proposed = createMockWorkflowState({
'block-1': createMockBlock({ id: 'block-1', locked: true }),
})
const result = await freshEngine.createDiffFromWorkflowState(
proposed,
undefined,
baseline
)
expect(result.success).toBe(true)
expect(result.diff?.diffAnalysis?.edited_blocks).toContain('block-1')
}
)
it.concurrent('should not detect change when locked state is the same', async () => {
const freshEngine = new WorkflowDiffEngine()
const baseline = createMockWorkflowState({
'block-1': createMockBlock({ id: 'block-1', locked: true }),
})
const proposed = createMockWorkflowState({
'block-1': createMockBlock({ id: 'block-1', locked: true }),
})
const result = await freshEngine.createDiffFromWorkflowState(proposed, undefined, baseline)
expect(result.success).toBe(true)
expect(result.diff?.diffAnalysis?.edited_blocks).not.toContain('block-1')
})
it.concurrent('should detect change when locked goes from undefined to true', async () => {
const freshEngine = new WorkflowDiffEngine()
const baseline = createMockWorkflowState({
'block-1': createMockBlock({ id: 'block-1' }), // locked undefined
})
const proposed = createMockWorkflowState({
'block-1': createMockBlock({ id: 'block-1', locked: true }),
})
const result = await freshEngine.createDiffFromWorkflowState(proposed, undefined, baseline)
expect(result.success).toBe(true)
// The hasBlockChanged function uses !!locked for comparison
// so undefined -> true should be detected as a change
expect(result.diff?.diffAnalysis?.edited_blocks).toContain('block-1')
})
it.concurrent('should not detect change when both locked states are falsy', async () => {
const freshEngine = new WorkflowDiffEngine()
const baseline = createMockWorkflowState({
'block-1': createMockBlock({ id: 'block-1' }), // locked undefined
})
const proposed = createMockWorkflowState({
'block-1': createMockBlock({ id: 'block-1', locked: false }), // locked false
})
const result = await freshEngine.createDiffFromWorkflowState(proposed, undefined, baseline)
expect(result.success).toBe(true)
// undefined and false should both be falsy, so !! comparison makes them equal
expect(result.diff?.diffAnalysis?.edited_blocks).not.toContain('block-1')
})
})
})
describe('diff lifecycle', () => {
it.concurrent('should start with no diff', () => {
const freshEngine = new WorkflowDiffEngine()
expect(freshEngine.hasDiff()).toBe(false)
expect(freshEngine.getCurrentDiff()).toBeUndefined()
})
it.concurrent('should clear diff', () => {
const freshEngine = new WorkflowDiffEngine()
freshEngine.clearDiff()
expect(freshEngine.hasDiff()).toBe(false)
})
})
})

View File

@@ -215,6 +215,7 @@ function hasBlockChanged(currentBlock: BlockState, proposedBlock: BlockState): b
if (currentBlock.name !== proposedBlock.name) return true
if (currentBlock.enabled !== proposedBlock.enabled) return true
if (currentBlock.triggerMode !== proposedBlock.triggerMode) return true
if (!!currentBlock.locked !== !!proposedBlock.locked) return true
// Compare subBlocks
const currentSubKeys = Object.keys(currentBlock.subBlocks || {})

View File

@@ -189,6 +189,7 @@ export async function duplicateWorkflow(
parentId: newParentId,
extent: newExtent,
data: updatedData,
locked: false, // Duplicated blocks should always be unlocked
createdAt: now,
updatedAt: now,
}

View File

@@ -226,6 +226,7 @@ export async function loadWorkflowFromNormalizedTables(
subBlocks: (block.subBlocks as BlockState['subBlocks']) || {},
outputs: (block.outputs as BlockState['outputs']) || {},
data: blockData,
locked: block.locked,
}
blocksMap[block.id] = assembled
@@ -363,6 +364,7 @@ export async function saveWorkflowToNormalizedTables(
data: block.data || {},
parentId: block.data?.parentId || null,
extent: block.data?.extent || null,
locked: block.locked ?? false,
}))
await tx.insert(workflowBlocks).values(blockInserts)
@@ -627,7 +629,8 @@ export function regenerateWorkflowStateIds(state: RegenerateStateInput): Regener
// Regenerate blocks with updated references
Object.entries(state.blocks || {}).forEach(([oldId, block]) => {
const newId = blockIdMapping.get(oldId)!
const newBlock: BlockState = { ...block, id: newId }
// Duplicated blocks are always unlocked so users can edit them
const newBlock: BlockState = { ...block, id: newId, locked: false }
// Update parentId reference if it exists
if (newBlock.data?.parentId) {

View File

@@ -17,6 +17,7 @@ export const BLOCKS_OPERATIONS = {
BATCH_TOGGLE_ENABLED: 'batch-toggle-enabled',
BATCH_TOGGLE_HANDLES: 'batch-toggle-handles',
BATCH_UPDATE_PARENT: 'batch-update-parent',
BATCH_TOGGLE_LOCKED: 'batch-toggle-locked',
} as const
export type BlocksOperation = (typeof BLOCKS_OPERATIONS)[keyof typeof BLOCKS_OPERATIONS]
@@ -85,6 +86,7 @@ export const UNDO_REDO_OPERATIONS = {
BATCH_UPDATE_PARENT: 'batch-update-parent',
BATCH_TOGGLE_ENABLED: 'batch-toggle-enabled',
BATCH_TOGGLE_HANDLES: 'batch-toggle-handles',
BATCH_TOGGLE_LOCKED: 'batch-toggle-locked',
APPLY_DIFF: 'apply-diff',
ACCEPT_DIFF: 'accept-diff',
REJECT_DIFF: 'reject-diff',

View File

@@ -507,7 +507,37 @@ async function handleBlocksOperationTx(
})
if (blocks && blocks.length > 0) {
const blockValues = blocks.map((block: Record<string, unknown>) => {
// Fetch existing blocks to check for locked parents
const existingBlocks = await tx
.select({ id: workflowBlocks.id, locked: workflowBlocks.locked })
.from(workflowBlocks)
.where(eq(workflowBlocks.workflowId, workflowId))
type ExistingBlockRecord = (typeof existingBlocks)[number]
const lockedParentIds = new Set(
existingBlocks
.filter((b: ExistingBlockRecord) => b.locked)
.map((b: ExistingBlockRecord) => b.id)
)
// Filter out blocks being added to locked parents
const allowedBlocks = (blocks as Array<Record<string, unknown>>).filter((block) => {
const parentId = (block.data as Record<string, unknown> | null)?.parentId as
| string
| undefined
if (parentId && lockedParentIds.has(parentId)) {
logger.info(`Skipping block ${block.id} - parent ${parentId} is locked`)
return false
}
return true
})
if (allowedBlocks.length === 0) {
logger.info('All blocks filtered out due to locked parents, skipping add')
break
}
const blockValues = allowedBlocks.map((block: Record<string, unknown>) => {
const blockId = block.id as string
const mergedSubBlocks = mergeSubBlockValues(
block.subBlocks as Record<string, unknown>,
@@ -529,6 +559,7 @@ async function handleBlocksOperationTx(
advancedMode: (block.advancedMode as boolean) ?? false,
triggerMode: (block.triggerMode as boolean) ?? false,
height: (block.height as number) || 0,
locked: (block.locked as boolean) ?? false,
}
})
@@ -537,7 +568,7 @@ async function handleBlocksOperationTx(
// Create subflow entries for loop/parallel blocks (skip if already in payload)
const loopIds = new Set(loops ? Object.keys(loops) : [])
const parallelIds = new Set(parallels ? Object.keys(parallels) : [])
for (const block of blocks) {
for (const block of allowedBlocks) {
const blockId = block.id as string
if (block.type === 'loop' && !loopIds.has(blockId)) {
await tx.insert(workflowSubflows).values({
@@ -566,7 +597,7 @@ async function handleBlocksOperationTx(
// Update parent subflow node lists
const parentIds = new Set<string>()
for (const block of blocks) {
for (const block of allowedBlocks) {
const parentId = (block.data as Record<string, unknown>)?.parentId as string | undefined
if (parentId) {
parentIds.add(parentId)
@@ -624,44 +655,74 @@ async function handleBlocksOperationTx(
logger.info(`Batch removing ${ids.length} blocks from workflow ${workflowId}`)
// Fetch all blocks to check lock status and filter out protected blocks
const allBlocks = await tx
.select({
id: workflowBlocks.id,
type: workflowBlocks.type,
locked: workflowBlocks.locked,
data: workflowBlocks.data,
})
.from(workflowBlocks)
.where(eq(workflowBlocks.workflowId, workflowId))
type BlockRecord = (typeof allBlocks)[number]
const blocksById: Record<string, BlockRecord> = Object.fromEntries(
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))
if (deletableIds.length === 0) {
logger.info('All requested blocks are protected, skipping deletion')
return
}
if (deletableIds.length < ids.length) {
logger.info(
`Filtered out ${ids.length - deletableIds.length} protected blocks from deletion`
)
}
// Collect all block IDs including children of subflows
const allBlocksToDelete = new Set<string>(ids)
const allBlocksToDelete = new Set<string>(deletableIds)
for (const id of ids) {
const blockToRemove = await tx
.select({ type: workflowBlocks.type })
.from(workflowBlocks)
.where(and(eq(workflowBlocks.id, id), eq(workflowBlocks.workflowId, workflowId)))
.limit(1)
if (blockToRemove.length > 0 && isSubflowBlockType(blockToRemove[0].type)) {
const childBlocks = await tx
.select({ id: workflowBlocks.id })
.from(workflowBlocks)
.where(
and(
eq(workflowBlocks.workflowId, workflowId),
sql`${workflowBlocks.data}->>'parentId' = ${id}`
)
)
childBlocks.forEach((child: { id: string }) => allBlocksToDelete.add(child.id))
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)
}
}
}
}
const blockIdsArray = Array.from(allBlocksToDelete)
// Collect parent IDs BEFORE deleting blocks
// Collect parent IDs BEFORE deleting blocks (use blocksById, already fetched)
const parentIds = new Set<string>()
for (const id of ids) {
const parentInfo = await tx
.select({ parentId: sql<string | null>`${workflowBlocks.data}->>'parentId'` })
.from(workflowBlocks)
.where(and(eq(workflowBlocks.id, id), eq(workflowBlocks.workflowId, workflowId)))
.limit(1)
if (parentInfo.length > 0 && parentInfo[0].parentId) {
parentIds.add(parentInfo[0].parentId)
for (const id of deletableIds) {
const block = blocksById[id]
const parentId = (block?.data as Record<string, unknown> | null)?.parentId as
| string
| undefined
if (parentId) {
parentIds.add(parentId)
}
}
@@ -741,22 +802,61 @@ async function handleBlocksOperationTx(
`Batch toggling enabled state for ${blockIds.length} blocks in workflow ${workflowId}`
)
const blocks = await tx
.select({ id: workflowBlocks.id, enabled: workflowBlocks.enabled })
// Get all blocks in workflow to find children and check locked state
const allBlocks = await tx
.select({
id: workflowBlocks.id,
enabled: workflowBlocks.enabled,
locked: workflowBlocks.locked,
type: workflowBlocks.type,
data: workflowBlocks.data,
})
.from(workflowBlocks)
.where(and(eq(workflowBlocks.workflowId, workflowId), inArray(workflowBlocks.id, blockIds)))
.where(eq(workflowBlocks.workflowId, workflowId))
for (const block of blocks) {
type BlockRecord = (typeof allBlocks)[number]
const blocksById: Record<string, BlockRecord> = Object.fromEntries(
allBlocks.map((b: BlockRecord) => [b.id, b])
)
const blocksToToggle = new Set<string>()
// Collect all blocks to toggle including children of containers
for (const id of blockIds) {
const block = blocksById[id]
if (!block || block.locked) continue
blocksToToggle.add(id)
// If it's a loop or parallel, also include all children
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)
}
}
}
}
// Determine target enabled state based on first toggleable block
if (blocksToToggle.size === 0) break
const firstToggleableId = Array.from(blocksToToggle)[0]
const firstBlock = blocksById[firstToggleableId]
if (!firstBlock) break
const targetEnabled = !firstBlock.enabled
// Update all affected blocks
for (const blockId of blocksToToggle) {
await tx
.update(workflowBlocks)
.set({
enabled: !block.enabled,
enabled: targetEnabled,
updatedAt: new Date(),
})
.where(and(eq(workflowBlocks.id, block.id), eq(workflowBlocks.workflowId, workflowId)))
.where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId)))
}
logger.debug(`Batch toggled enabled state for ${blocks.length} blocks`)
logger.debug(`Batch toggled enabled state for ${blocksToToggle.size} blocks`)
break
}
@@ -768,22 +868,118 @@ async function handleBlocksOperationTx(
logger.info(`Batch toggling handles for ${blockIds.length} blocks in workflow ${workflowId}`)
const blocks = await tx
.select({ id: workflowBlocks.id, horizontalHandles: workflowBlocks.horizontalHandles })
// Fetch all blocks to check lock status and filter out protected blocks
const allBlocks = await tx
.select({
id: workflowBlocks.id,
horizontalHandles: workflowBlocks.horizontalHandles,
locked: workflowBlocks.locked,
data: workflowBlocks.data,
})
.from(workflowBlocks)
.where(and(eq(workflowBlocks.workflowId, workflowId), inArray(workflowBlocks.id, blockIds)))
.where(eq(workflowBlocks.workflowId, workflowId))
for (const block of blocks) {
type HandleBlockRecord = (typeof allBlocks)[number]
const blocksById: Record<string, HandleBlockRecord> = Object.fromEntries(
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))
if (blocksToToggle.length === 0) {
logger.info('All requested blocks are protected, skipping handles toggle')
break
}
for (const blockId of blocksToToggle) {
const block = blocksById[blockId]
await tx
.update(workflowBlocks)
.set({
horizontalHandles: !block.horizontalHandles,
updatedAt: new Date(),
})
.where(and(eq(workflowBlocks.id, block.id), eq(workflowBlocks.workflowId, workflowId)))
.where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId)))
}
logger.debug(`Batch toggled handles for ${blocks.length} blocks`)
logger.debug(`Batch toggled handles for ${blocksToToggle.length} blocks`)
break
}
case BLOCKS_OPERATIONS.BATCH_TOGGLE_LOCKED: {
const { blockIds } = payload
if (!Array.isArray(blockIds) || blockIds.length === 0) {
return
}
logger.info(`Batch toggling locked for ${blockIds.length} blocks in workflow ${workflowId}`)
// Get all blocks in workflow to find children
const allBlocks = await tx
.select({
id: workflowBlocks.id,
locked: workflowBlocks.locked,
type: workflowBlocks.type,
data: workflowBlocks.data,
})
.from(workflowBlocks)
.where(eq(workflowBlocks.workflowId, workflowId))
type LockedBlockRecord = (typeof allBlocks)[number]
const blocksById: Record<string, LockedBlockRecord> = Object.fromEntries(
allBlocks.map((b: LockedBlockRecord) => [b.id, b])
)
const blocksToToggle = new Set<string>()
// Collect all blocks to toggle including children 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 (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)
}
}
}
}
// Determine target locked state based on first toggleable block
if (blocksToToggle.size === 0) break
const firstToggleableId = Array.from(blocksToToggle)[0]
const firstBlock = blocksById[firstToggleableId]
if (!firstBlock) break
const targetLocked = !firstBlock.locked
// Update all affected blocks
for (const blockId of blocksToToggle) {
await tx
.update(workflowBlocks)
.set({
locked: targetLocked,
updatedAt: new Date(),
})
.where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId)))
}
logger.debug(`Batch toggled locked for ${blocksToToggle.size} blocks`)
break
}
@@ -795,19 +991,54 @@ async function handleBlocksOperationTx(
logger.info(`Batch updating parent for ${updates.length} blocks in workflow ${workflowId}`)
// Fetch all blocks to check lock status
const allBlocks = await tx
.select({
id: workflowBlocks.id,
locked: workflowBlocks.locked,
data: workflowBlocks.data,
})
.from(workflowBlocks)
.where(eq(workflowBlocks.workflowId, workflowId))
type ParentBlockRecord = (typeof allBlocks)[number]
const blocksById: Record<string, ParentBlockRecord> = Object.fromEntries(
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)) {
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`)
continue
}
// Fetch current parent to update subflow node lists
const [existing] = await tx
.select({
id: workflowBlocks.id,
parentId: sql<string | null>`${workflowBlocks.data}->>'parentId'`,
})
.from(workflowBlocks)
.where(and(eq(workflowBlocks.id, id), eq(workflowBlocks.workflowId, workflowId)))
.limit(1)
const existing = blocksById[id]
const existingParentId = (existing?.data as Record<string, unknown> | null)?.parentId as
| string
| undefined
if (!existing) {
logger.warn(`Block ${id} not found for batch-update-parent`)
@@ -852,8 +1083,8 @@ async function handleBlocksOperationTx(
await updateSubflowNodeList(tx, workflowId, parentId)
}
// If the block had a previous parent, update that parent's node list as well
if (existing?.parentId && existing.parentId !== parentId) {
await updateSubflowNodeList(tx, workflowId, existing.parentId)
if (existingParentId && existingParentId !== parentId) {
await updateSubflowNodeList(tx, workflowId, existingParentId)
}
}
@@ -1198,6 +1429,7 @@ async function handleWorkflowOperationTx(
advancedMode: block.advancedMode ?? false,
triggerMode: block.triggerMode ?? false,
height: block.height || 0,
locked: block.locked ?? false,
}))
await tx.insert(workflowBlocks).values(blockValues)

View File

@@ -214,6 +214,12 @@ describe('checkRolePermission', () => {
readAllowed: false,
},
{ operation: 'toggle-handles', adminAllowed: true, writeAllowed: true, readAllowed: false },
{
operation: 'batch-toggle-locked',
adminAllowed: true,
writeAllowed: false, // Admin-only operation
readAllowed: false,
},
{
operation: 'batch-update-positions',
adminAllowed: true,

View File

@@ -14,7 +14,10 @@ import {
const logger = createLogger('SocketPermissions')
// All write operations (admin and write roles have same permissions)
// Admin-only operations (require admin role)
const ADMIN_ONLY_OPERATIONS: string[] = [BLOCKS_OPERATIONS.BATCH_TOGGLE_LOCKED]
// Write operations (admin and write roles both have these permissions)
const WRITE_OPERATIONS: string[] = [
// Block operations
BLOCK_OPERATIONS.UPDATE_POSITION,
@@ -51,7 +54,7 @@ const READ_OPERATIONS: string[] = [
// Define operation permissions based on role
const ROLE_PERMISSIONS: Record<string, string[]> = {
admin: WRITE_OPERATIONS,
admin: [...ADMIN_ONLY_OPERATIONS, ...WRITE_OPERATIONS],
write: WRITE_OPERATIONS,
read: READ_OPERATIONS,
}

View File

@@ -208,6 +208,17 @@ export const BatchToggleHandlesSchema = z.object({
operationId: z.string().optional(),
})
export const BatchToggleLockedSchema = z.object({
operation: z.literal(BLOCKS_OPERATIONS.BATCH_TOGGLE_LOCKED),
target: z.literal(OPERATION_TARGETS.BLOCKS),
payload: z.object({
blockIds: z.array(z.string()),
previousStates: z.record(z.boolean()),
}),
timestamp: z.number(),
operationId: z.string().optional(),
})
export const BatchUpdateParentSchema = z.object({
operation: z.literal(BLOCKS_OPERATIONS.BATCH_UPDATE_PARENT),
target: z.literal(OPERATION_TARGETS.BLOCKS),
@@ -231,6 +242,7 @@ export const WorkflowOperationSchema = z.union([
BatchRemoveBlocksSchema,
BatchToggleEnabledSchema,
BatchToggleHandlesSchema,
BatchToggleLockedSchema,
BatchUpdateParentSchema,
EdgeOperationSchema,
BatchAddEdgesSchema,

View File

@@ -97,6 +97,14 @@ export interface BatchToggleHandlesOperation extends BaseOperation {
}
}
export interface BatchToggleLockedOperation extends BaseOperation {
type: typeof UNDO_REDO_OPERATIONS.BATCH_TOGGLE_LOCKED
data: {
blockIds: string[]
previousStates: Record<string, boolean>
}
}
export interface ApplyDiffOperation extends BaseOperation {
type: typeof UNDO_REDO_OPERATIONS.APPLY_DIFF
data: {
@@ -136,6 +144,7 @@ export type Operation =
| BatchUpdateParentOperation
| BatchToggleEnabledOperation
| BatchToggleHandlesOperation
| BatchToggleLockedOperation
| ApplyDiffOperation
| AcceptDiffOperation
| RejectDiffOperation

View File

@@ -167,6 +167,15 @@ export function createInverseOperation(operation: Operation): Operation {
},
}
case UNDO_REDO_OPERATIONS.BATCH_TOGGLE_LOCKED:
return {
...operation,
data: {
blockIds: operation.data.blockIds,
previousStates: operation.data.previousStates,
},
}
default: {
const exhaustiveCheck: never = operation
throw new Error(`Unhandled operation type: ${(exhaustiveCheck as Operation).type}`)

View File

@@ -432,4 +432,104 @@ describe('regenerateBlockIds', () => {
expect(duplicatedBlock.position).toEqual({ x: 280, y: 70 })
expect(duplicatedBlock.data?.parentId).toBe(loopId)
})
it('should unlock pasted block when source is locked', () => {
const blockId = 'block-1'
const blocksToCopy = {
[blockId]: createAgentBlock({
id: blockId,
name: 'Locked Agent',
position: { x: 100, y: 50 },
locked: true,
}),
}
const result = regenerateBlockIds(
blocksToCopy,
[],
{},
{},
{},
positionOffset,
{},
getUniqueBlockName
)
const newBlocks = Object.values(result.blocks)
expect(newBlocks).toHaveLength(1)
// Pasted blocks are always unlocked so users can edit them
const pastedBlock = newBlocks[0]
expect(pastedBlock.locked).toBe(false)
})
it('should keep pasted block unlocked when source is unlocked', () => {
const blockId = 'block-1'
const blocksToCopy = {
[blockId]: createAgentBlock({
id: blockId,
name: 'Unlocked Agent',
position: { x: 100, y: 50 },
locked: false,
}),
}
const result = regenerateBlockIds(
blocksToCopy,
[],
{},
{},
{},
positionOffset,
{},
getUniqueBlockName
)
const newBlocks = Object.values(result.blocks)
expect(newBlocks).toHaveLength(1)
const pastedBlock = newBlocks[0]
expect(pastedBlock.locked).toBe(false)
})
it('should unlock all pasted blocks regardless of source locked state', () => {
const lockedId = 'locked-1'
const unlockedId = 'unlocked-1'
const blocksToCopy = {
[lockedId]: createAgentBlock({
id: lockedId,
name: 'Originally Locked Agent',
position: { x: 100, y: 50 },
locked: true,
}),
[unlockedId]: createFunctionBlock({
id: unlockedId,
name: 'Originally Unlocked Function',
position: { x: 200, y: 50 },
locked: false,
}),
}
const result = regenerateBlockIds(
blocksToCopy,
[],
{},
{},
{},
positionOffset,
{},
getUniqueBlockName
)
const newBlocks = Object.values(result.blocks)
expect(newBlocks).toHaveLength(2)
// All pasted blocks should be unlocked so users can edit them
for (const block of newBlocks) {
expect(block.locked).toBe(false)
}
})
})

View File

@@ -203,6 +203,7 @@ export function prepareBlockState(options: PrepareBlockStateOptions): BlockState
advancedMode: false,
triggerMode,
height: 0,
locked: false,
}
}
@@ -481,6 +482,8 @@ export function regenerateBlockIds(
position: newPosition,
// Temporarily keep data as-is, we'll fix parentId in second pass
data: block.data ? { ...block.data } : block.data,
// Duplicated blocks are always unlocked so users can edit them
locked: false,
}
newBlocks[newId] = newBlock
@@ -508,15 +511,15 @@ export function regenerateBlockIds(
parentId: newParentId,
extent: 'parent',
}
} else if (existingBlockNames[oldParentId]) {
// Parent exists in existing workflow - keep original parentId (block stays in same subflow)
} else if (existingBlockNames[oldParentId] && !existingBlockNames[oldParentId].locked) {
// Parent exists in existing workflow and is not locked - keep original parentId
block.data = {
...block.data,
parentId: oldParentId,
extent: 'parent',
}
} else {
// Parent doesn't exist anywhere - clear the relationship
// Parent doesn't exist anywhere OR parent is locked - clear the relationship
block.data = { ...block.data, parentId: undefined, extent: undefined }
}
}

View File

@@ -1144,6 +1144,223 @@ describe('workflow store', () => {
})
})
describe('batchToggleLocked', () => {
it('should toggle block locked state', () => {
const { batchToggleLocked } = useWorkflowStore.getState()
addBlock('block-1', 'function', 'Test', { x: 0, y: 0 })
// Initial state is undefined (falsy)
expect(useWorkflowStore.getState().blocks['block-1'].locked).toBeFalsy()
batchToggleLocked(['block-1'])
expect(useWorkflowStore.getState().blocks['block-1'].locked).toBe(true)
batchToggleLocked(['block-1'])
expect(useWorkflowStore.getState().blocks['block-1'].locked).toBe(false)
})
it('should cascade lock to children when locking a loop', () => {
const { batchToggleLocked } = useWorkflowStore.getState()
addBlock('loop-1', 'loop', 'My Loop', { x: 0, y: 0 }, { loopType: 'for', count: 3 })
addBlock(
'child-1',
'function',
'Child',
{ x: 50, y: 50 },
{ parentId: 'loop-1' },
'loop-1',
'parent'
)
batchToggleLocked(['loop-1'])
const { blocks } = useWorkflowStore.getState()
expect(blocks['loop-1'].locked).toBe(true)
expect(blocks['child-1'].locked).toBe(true)
})
it('should cascade unlock to children when unlocking a parallel', () => {
const { batchToggleLocked } = useWorkflowStore.getState()
addBlock('parallel-1', 'parallel', 'My Parallel', { x: 0, y: 0 }, { count: 3 })
addBlock(
'child-1',
'function',
'Child',
{ x: 50, y: 50 },
{ parentId: 'parallel-1' },
'parallel-1',
'parent'
)
// Lock first
batchToggleLocked(['parallel-1'])
expect(useWorkflowStore.getState().blocks['child-1'].locked).toBe(true)
// Unlock
batchToggleLocked(['parallel-1'])
const { blocks } = useWorkflowStore.getState()
expect(blocks['parallel-1'].locked).toBe(false)
expect(blocks['child-1'].locked).toBe(false)
})
it('should toggle multiple blocks at once', () => {
const { batchToggleLocked } = useWorkflowStore.getState()
addBlock('block-1', 'function', 'Test 1', { x: 0, y: 0 })
addBlock('block-2', 'function', 'Test 2', { x: 100, y: 0 })
batchToggleLocked(['block-1', 'block-2'])
const { blocks } = useWorkflowStore.getState()
expect(blocks['block-1'].locked).toBe(true)
expect(blocks['block-2'].locked).toBe(true)
})
})
describe('setBlockLocked', () => {
it('should set block locked state', () => {
const { setBlockLocked } = useWorkflowStore.getState()
addBlock('block-1', 'function', 'Test', { x: 0, y: 0 })
setBlockLocked('block-1', true)
expect(useWorkflowStore.getState().blocks['block-1'].locked).toBe(true)
setBlockLocked('block-1', false)
expect(useWorkflowStore.getState().blocks['block-1'].locked).toBe(false)
})
it('should not update if locked state is already the target value', () => {
const { setBlockLocked } = useWorkflowStore.getState()
addBlock('block-1', 'function', 'Test', { x: 0, y: 0 })
// First set to true
setBlockLocked('block-1', true)
expect(useWorkflowStore.getState().blocks['block-1'].locked).toBe(true)
// Setting to true again should still be true
setBlockLocked('block-1', true)
expect(useWorkflowStore.getState().blocks['block-1'].locked).toBe(true)
})
})
describe('duplicateBlock with locked', () => {
it('should unlock duplicate when duplicating a locked block', () => {
const { setBlockLocked, duplicateBlock } = useWorkflowStore.getState()
addBlock('original', 'agent', 'Original Agent', { x: 0, y: 0 })
setBlockLocked('original', true)
expect(useWorkflowStore.getState().blocks.original.locked).toBe(true)
duplicateBlock('original')
const { blocks } = useWorkflowStore.getState()
const blockIds = Object.keys(blocks)
expect(blockIds.length).toBe(2)
const duplicatedId = blockIds.find((id) => id !== 'original')
expect(duplicatedId).toBeDefined()
if (duplicatedId) {
// Original should still be locked
expect(blocks.original.locked).toBe(true)
// Duplicate should be unlocked so users can edit it
expect(blocks[duplicatedId].locked).toBe(false)
}
})
it('should create unlocked duplicate when duplicating an unlocked block', () => {
const { duplicateBlock } = useWorkflowStore.getState()
addBlock('original', 'agent', 'Original Agent', { x: 0, y: 0 })
duplicateBlock('original')
const { blocks } = useWorkflowStore.getState()
const blockIds = Object.keys(blocks)
const duplicatedId = blockIds.find((id) => id !== 'original')
if (duplicatedId) {
expect(blocks[duplicatedId].locked).toBeFalsy()
}
})
it('should place duplicate outside locked container when duplicating block inside locked loop', () => {
const { batchToggleLocked, duplicateBlock } = useWorkflowStore.getState()
// Create a loop with a child block
addBlock('loop-1', 'loop', 'My Loop', { x: 0, y: 0 }, { loopType: 'for', count: 3 })
addBlock(
'child-1',
'function',
'Child',
{ x: 50, y: 50 },
{ parentId: 'loop-1' },
'loop-1',
'parent'
)
// Lock the loop (which cascades to the child)
batchToggleLocked(['loop-1'])
expect(useWorkflowStore.getState().blocks['child-1'].locked).toBe(true)
// Duplicate the child block
duplicateBlock('child-1')
const { blocks } = useWorkflowStore.getState()
const blockIds = Object.keys(blocks)
expect(blockIds.length).toBe(3) // loop, original child, duplicate
const duplicatedId = blockIds.find((id) => id !== 'loop-1' && id !== 'child-1')
expect(duplicatedId).toBeDefined()
if (duplicatedId) {
// Duplicate should be unlocked
expect(blocks[duplicatedId].locked).toBe(false)
// Duplicate should NOT have a parentId (placed outside the locked container)
expect(blocks[duplicatedId].data?.parentId).toBeUndefined()
// Original should still be inside the loop
expect(blocks['child-1'].data?.parentId).toBe('loop-1')
}
})
it('should keep duplicate inside unlocked container when duplicating block inside unlocked loop', () => {
const { duplicateBlock } = useWorkflowStore.getState()
// Create a loop with a child block (not locked)
addBlock('loop-1', 'loop', 'My Loop', { x: 0, y: 0 }, { loopType: 'for', count: 3 })
addBlock(
'child-1',
'function',
'Child',
{ x: 50, y: 50 },
{ parentId: 'loop-1' },
'loop-1',
'parent'
)
// Duplicate the child block (loop is NOT locked)
duplicateBlock('child-1')
const { blocks } = useWorkflowStore.getState()
const blockIds = Object.keys(blocks)
const duplicatedId = blockIds.find((id) => id !== 'loop-1' && id !== 'child-1')
if (duplicatedId) {
// Duplicate should still be inside the loop since it's not locked
expect(blocks[duplicatedId].data?.parentId).toBe('loop-1')
}
})
})
describe('updateBlockName', () => {
beforeEach(() => {
useWorkflowStore.setState({

View File

@@ -207,6 +207,7 @@ export const useWorkflowStore = create<WorkflowStore>()(
triggerMode?: boolean
height?: number
data?: Record<string, any>
locked?: boolean
}>,
edges?: Edge[],
subBlockValues?: Record<string, Record<string, unknown>>,
@@ -231,6 +232,7 @@ export const useWorkflowStore = create<WorkflowStore>()(
triggerMode: block.triggerMode ?? false,
height: block.height ?? 0,
data: block.data,
locked: block.locked ?? false,
}
}
@@ -365,24 +367,69 @@ export const useWorkflowStore = create<WorkflowStore>()(
},
batchToggleEnabled: (ids: string[]) => {
const newBlocks = { ...get().blocks }
if (ids.length === 0) return
const currentBlocks = get().blocks
const newBlocks = { ...currentBlocks }
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
for (const id of ids) {
if (newBlocks[id]) {
newBlocks[id] = { ...newBlocks[id], enabled: !newBlocks[id].enabled }
const block = currentBlocks[id]
if (!block) continue
// Skip locked blocks entirely (including their children)
if (block.locked) continue
blocksToToggle.add(id)
// If it's a loop or parallel, also include non-locked children
if (block.type === 'loop' || block.type === 'parallel') {
Object.entries(currentBlocks).forEach(([blockId, b]) => {
if (b.data?.parentId === id && !b.locked) {
blocksToToggle.add(blockId)
}
})
}
}
// If no blocks can be toggled, exit early
if (blocksToToggle.size === 0) return
// Determine target enabled state based on first toggleable block
const firstToggleableId = Array.from(blocksToToggle)[0]
const firstBlock = currentBlocks[firstToggleableId]
const targetEnabled = !firstBlock.enabled
// Apply the enabled state to all toggleable blocks
for (const blockId of blocksToToggle) {
newBlocks[blockId] = { ...newBlocks[blockId], enabled: targetEnabled }
}
set({ blocks: newBlocks, edges: [...get().edges] })
get().updateLastSaved()
},
batchToggleHandles: (ids: string[]) => {
const newBlocks = { ...get().blocks }
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]) {
newBlocks[id] = {
...newBlocks[id],
horizontalHandles: !newBlocks[id].horizontalHandles,
}
if (!newBlocks[id] || isProtected(id)) continue
newBlocks[id] = {
...newBlocks[id],
horizontalHandles: !newBlocks[id].horizontalHandles,
}
}
set({ blocks: newBlocks, edges: [...get().edges] })
@@ -527,9 +574,33 @@ export const useWorkflowStore = create<WorkflowStore>()(
if (!block) return
const newId = crypto.randomUUID()
const offsetPosition = {
x: block.position.x + DEFAULT_DUPLICATE_OFFSET.x,
y: block.position.y + DEFAULT_DUPLICATE_OFFSET.y,
// Check if block is inside a locked container - if so, place duplicate outside
const parentId = block.data?.parentId
const parentBlock = parentId ? get().blocks[parentId] : undefined
const isParentLocked = parentBlock?.locked ?? false
// If parent is locked, calculate position outside the container
let offsetPosition: Position
const newData = block.data ? { ...block.data } : undefined
if (isParentLocked && parentBlock) {
// Place duplicate outside the locked container (to the right of it)
const containerWidth = parentBlock.data?.width ?? 400
offsetPosition = {
x: parentBlock.position.x + containerWidth + 50,
y: parentBlock.position.y,
}
// Remove parent relationship since we're placing outside
if (newData) {
newData.parentId = undefined
newData.extent = undefined
}
} else {
offsetPosition = {
x: block.position.x + DEFAULT_DUPLICATE_OFFSET.x,
y: block.position.y + DEFAULT_DUPLICATE_OFFSET.y,
}
}
const newName = getUniqueBlockName(block.name, get().blocks)
@@ -557,6 +628,8 @@ export const useWorkflowStore = create<WorkflowStore>()(
name: newName,
position: offsetPosition,
subBlocks: newSubBlocks,
locked: false,
data: newData,
},
},
edges: [...get().edges],
@@ -1164,6 +1237,70 @@ export const useWorkflowStore = create<WorkflowStore>()(
getDragStartPosition: () => {
return get().dragStartPosition || null
},
setBlockLocked: (id: string, locked: boolean) => {
const block = get().blocks[id]
if (!block || block.locked === locked) return
const newState = {
blocks: {
...get().blocks,
[id]: {
...block,
locked,
},
},
edges: [...get().edges],
loops: { ...get().loops },
parallels: { ...get().parallels },
}
set(newState)
get().updateLastSaved()
},
batchToggleLocked: (ids: string[]) => {
if (ids.length === 0) return
const currentBlocks = get().blocks
const newBlocks = { ...currentBlocks }
const blocksToToggle = new Set<string>()
// For each ID, collect blocks to toggle
// If it's a container, also include all children
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 (block.type === 'loop' || block.type === 'parallel') {
Object.entries(currentBlocks).forEach(([blockId, b]) => {
if (b.data?.parentId === id) {
blocksToToggle.add(blockId)
}
})
}
}
// If no blocks found, exit early
if (blocksToToggle.size === 0) return
// Determine target locked state based on first block in original ids
const firstBlock = currentBlocks[ids[0]]
if (!firstBlock) return
const targetLocked = !firstBlock.locked
// Apply the locked state to all blocks
for (const blockId of blocksToToggle) {
newBlocks[blockId] = { ...newBlocks[blockId], locked: targetLocked }
}
set({ blocks: newBlocks, edges: [...get().edges] })
get().updateLastSaved()
},
}),
{ name: 'workflow-store' }
)

View File

@@ -87,6 +87,7 @@ export interface BlockState {
triggerMode?: boolean
data?: BlockData
layout?: BlockLayoutState
locked?: boolean
}
export interface SubBlockState {
@@ -131,6 +132,7 @@ export interface Loop {
whileCondition?: string // JS expression that evaluates to boolean (for while loops)
doWhileCondition?: string // JS expression that evaluates to boolean (for do-while loops)
enabled: boolean
locked?: boolean
}
export interface Parallel {
@@ -140,6 +142,7 @@ export interface Parallel {
count?: number // Number of parallel executions for count-based parallel
parallelType?: 'count' | 'collection' // Explicit parallel type to avoid inference bugs
enabled: boolean
locked?: boolean
}
export interface Variable {
@@ -233,6 +236,8 @@ export interface WorkflowActions {
workflowState: WorkflowState,
options?: { updateLastSaved?: boolean }
) => void
setBlockLocked: (id: string, locked: boolean) => void
batchToggleLocked: (ids: string[]) => void
}
export type WorkflowStore = WorkflowState & WorkflowActions

View File

@@ -0,0 +1 @@
ALTER TABLE "workflow_blocks" ADD COLUMN "locked" boolean DEFAULT false NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@@ -1044,6 +1044,13 @@
"when": 1769656977701,
"tag": "0149_next_cerise",
"breakpoints": true
},
{
"idx": 150,
"version": "7",
"when": 1769897862156,
"tag": "0150_flimsy_hemingway",
"breakpoints": true
}
]
}

View File

@@ -189,6 +189,7 @@ export const workflowBlocks = pgTable(
isWide: boolean('is_wide').notNull().default(false),
advancedMode: boolean('advanced_mode').notNull().default(false),
triggerMode: boolean('trigger_mode').notNull().default(false),
locked: boolean('locked').notNull().default(false),
height: decimal('height').notNull().default('0'),
subBlocks: jsonb('sub_blocks').notNull().default('{}'),

View File

@@ -21,6 +21,7 @@ export interface BlockFactoryOptions {
triggerMode?: boolean
data?: BlockData
parentId?: string
locked?: boolean
}
/**
@@ -67,6 +68,7 @@ export function createBlock(options: BlockFactoryOptions = {}): any {
height: options.height ?? 0,
advancedMode: options.advancedMode ?? false,
triggerMode: options.triggerMode ?? false,
locked: options.locked ?? false,
data: Object.keys(data).length > 0 ? data : undefined,
layout: {},
}