fix(ops): fix subflow resizing on exit (#2760)

* fix(sockets): broadcast handles and enabled/disabled state

* made all ops batched, removed all individual ops

* fix subflow resizing on exit

* removed unused custom event

* fix failing tests, update testing

* fix test mock
This commit is contained in:
Waleed
2026-01-09 22:35:03 -08:00
committed by GitHub
parent 47eb060311
commit 67440432bf
16 changed files with 771 additions and 820 deletions

View File

@@ -156,7 +156,7 @@ export function ConditionInput({
[key: string]: number[]
}>({})
const updateNodeInternals = useUpdateNodeInternals()
const removeEdge = useWorkflowStore((state) => state.removeEdge)
const batchRemoveEdges = useWorkflowStore((state) => state.batchRemoveEdges)
const edges = useWorkflowStore((state) => state.edges)
const prevStoreValueRef = useRef<string | null>(null)
@@ -657,11 +657,12 @@ export function ConditionInput({
if (isPreview || disabled || conditionalBlocks.length <= 2) return
// Remove any associated edges before removing the block
edges.forEach((edge) => {
if (edge.sourceHandle?.startsWith(`condition-${id}`)) {
removeEdge(edge.id)
}
})
const edgeIdsToRemove = edges
.filter((edge) => edge.sourceHandle?.startsWith(`condition-${id}`))
.map((edge) => edge.id)
if (edgeIdsToRemove.length > 0) {
batchRemoveEdges(edgeIdsToRemove)
}
if (conditionalBlocks.length === 1) return
shouldPersistRef.current = true

View File

@@ -373,16 +373,20 @@ export function useNodeUtilities(blocks: Record<string, any>) {
* Updates a node's parent with proper position calculation
* @param nodeId ID of the node being reparented
* @param newParentId ID of the new parent (or null to remove parent)
* @param updateBlockPosition Function to update the position of a block
* @param updateParentId Function to update the parent ID of a block
* @param batchUpdatePositions Function to batch update positions of blocks
* @param batchUpdateBlocksWithParent Function to batch update blocks with parent info
* @param resizeCallback Function to resize loop nodes after parent update
*/
const updateNodeParent = useCallback(
(
nodeId: string,
newParentId: string | null,
updateBlockPosition: (id: string, position: { x: number; y: number }) => void,
updateParentId: (id: string, parentId: string, extent: 'parent') => void,
batchUpdatePositions: (
updates: Array<{ id: string; position: { x: number; y: number } }>
) => void,
batchUpdateBlocksWithParent: (
updates: Array<{ id: string; position: { x: number; y: number }; parentId?: string }>
) => void,
resizeCallback: () => void
) => {
const node = getNodes().find((n) => n.id === nodeId)
@@ -394,15 +398,15 @@ export function useNodeUtilities(blocks: Record<string, any>) {
if (newParentId) {
const relativePosition = calculateRelativePosition(nodeId, newParentId)
updateBlockPosition(nodeId, relativePosition)
updateParentId(nodeId, newParentId, 'parent')
batchUpdatePositions([{ id: nodeId, position: relativePosition }])
batchUpdateBlocksWithParent([
{ id: nodeId, position: relativePosition, parentId: newParentId },
])
} else if (currentParentId) {
const absolutePosition = getNodeAbsolutePosition(nodeId)
// First set the absolute position so the node visually stays in place
updateBlockPosition(nodeId, absolutePosition)
// Then clear the parent relationship in the store (empty string removes parentId/extent)
updateParentId(nodeId, '', 'parent')
batchUpdatePositions([{ id: nodeId, position: absolutePosition }])
batchUpdateBlocksWithParent([{ id: nodeId, position: absolutePosition, parentId: '' }])
}
resizeCallback()

View File

@@ -443,11 +443,9 @@ const WorkflowContent = React.memo(() => {
}, [userPermissions, currentWorkflow.isSnapshotView])
const {
collaborativeAddEdge: addEdge,
collaborativeRemoveEdge: removeEdge,
collaborativeBatchAddEdges,
collaborativeBatchRemoveEdges,
collaborativeBatchUpdatePositions,
collaborativeUpdateParentId: updateParentId,
collaborativeBatchUpdateParent,
collaborativeBatchAddBlocks,
collaborativeBatchRemoveBlocks,
@@ -464,6 +462,34 @@ const WorkflowContent = React.memo(() => {
[collaborativeBatchUpdatePositions]
)
const addEdge = useCallback(
(edge: Edge) => {
collaborativeBatchAddEdges([edge])
},
[collaborativeBatchAddEdges]
)
const removeEdge = useCallback(
(edgeId: string) => {
collaborativeBatchRemoveEdges([edgeId])
},
[collaborativeBatchRemoveEdges]
)
const batchUpdateBlocksWithParent = useCallback(
(updates: Array<{ id: string; position: { x: number; y: number }; parentId?: string }>) => {
collaborativeBatchUpdateParent(
updates.map((u) => ({
blockId: u.id,
newParentId: u.parentId || null,
newPosition: u.position,
affectedEdges: [],
}))
)
},
[collaborativeBatchUpdateParent]
)
const addBlock = useCallback(
(
id: string,
@@ -570,8 +596,8 @@ const WorkflowContent = React.memo(() => {
const result = updateNodeParentUtil(
nodeId,
newParentId,
updateBlockPosition,
updateParentId,
collaborativeBatchUpdatePositions,
batchUpdateBlocksWithParent,
() => resizeLoopNodesWrapper()
)
@@ -594,8 +620,8 @@ const WorkflowContent = React.memo(() => {
},
[
getNodes,
updateBlockPosition,
updateParentId,
collaborativeBatchUpdatePositions,
batchUpdateBlocksWithParent,
blocks,
edgesForDisplay,
getNodeAbsolutePosition,
@@ -903,22 +929,15 @@ const WorkflowContent = React.memo(() => {
(blockId: string, edgesToRemove: Edge[]): void => {
if (edgesToRemove.length === 0) return
window.dispatchEvent(new CustomEvent('skip-edge-recording', { detail: { skip: true } }))
const edgeIds = edgesToRemove.map((edge) => edge.id)
collaborativeBatchRemoveEdges(edgeIds, { skipUndoRedo: true })
try {
edgesToRemove.forEach((edge) => {
removeEdge(edge.id)
})
logger.debug('Removed edges for node', {
blockId,
edgeCount: edgesToRemove.length,
})
} finally {
window.dispatchEvent(new CustomEvent('skip-edge-recording', { detail: { skip: false } }))
}
logger.debug('Removed edges for node', {
blockId,
edgeCount: edgesToRemove.length,
})
},
[removeEdge]
[collaborativeBatchRemoveEdges]
)
/** Finds the closest block to a position for auto-connect. */
@@ -1942,27 +1961,37 @@ const WorkflowContent = React.memo(() => {
const movingNodeIds = new Set(validBlockIds)
// Find boundary edges (edges that cross the subflow boundary)
const boundaryEdges = edgesForDisplay.filter((e) => {
const sourceInSelection = movingNodeIds.has(e.source)
const targetInSelection = movingNodeIds.has(e.target)
return sourceInSelection !== targetInSelection
})
// Collect absolute positions BEFORE updating parents
// Collect absolute positions BEFORE any mutations
const absolutePositions = new Map<string, { x: number; y: number }>()
for (const blockId of validBlockIds) {
absolutePositions.set(blockId, getNodeAbsolutePosition(blockId))
}
for (const blockId of validBlockIds) {
// Build batch update with all blocks and their affected edges
const updates = validBlockIds.map((blockId) => {
const absolutePosition = absolutePositions.get(blockId)!
const edgesForThisNode = boundaryEdges.filter(
(e) => e.source === blockId || e.target === blockId
)
removeEdgesForNode(blockId, edgesForThisNode)
updateNodeParent(blockId, null, edgesForThisNode)
}
return {
blockId,
newParentId: null,
newPosition: absolutePosition,
affectedEdges: edgesForThisNode,
}
})
// Immediately update displayNodes to prevent React Flow from using stale parent data
// Single atomic batch update (handles edge removal + parent update + undo/redo)
collaborativeBatchUpdateParent(updates)
// Update displayNodes once to prevent React Flow from using stale parent data
setDisplayNodes((nodes) =>
nodes.map((n) => {
const absPos = absolutePositions.get(n.id)
@@ -1977,6 +2006,8 @@ const WorkflowContent = React.memo(() => {
return n
})
)
// Note: Container resize happens automatically via the derivedNodes effect
} catch (err) {
logger.error('Failed to remove from subflow', { err })
}
@@ -1985,7 +2016,7 @@ const WorkflowContent = React.memo(() => {
window.addEventListener('remove-from-subflow', handleRemoveFromSubflow as EventListener)
return () =>
window.removeEventListener('remove-from-subflow', handleRemoveFromSubflow as EventListener)
}, [blocks, edgesForDisplay, removeEdgesForNode, updateNodeParent, getNodeAbsolutePosition])
}, [blocks, edgesForDisplay, getNodeAbsolutePosition, collaborativeBatchUpdateParent])
/** Handles node position changes - updates local state for smooth drag, syncs to store only on drag end. */
const onNodesChange = useCallback((changes: NodeChange[]) => {
@@ -2072,7 +2103,12 @@ const WorkflowContent = React.memo(() => {
// Create a mapping of node IDs to check for missing parent references
const nodeIds = new Set(Object.keys(blocks))
// Check for nodes with invalid parent references
// Check for nodes with invalid parent references and collect updates
const orphanedUpdates: Array<{
id: string
position: { x: number; y: number }
parentId: string
}> = []
Object.entries(blocks).forEach(([id, block]) => {
const parentId = block.data?.parentId
@@ -2084,22 +2120,28 @@ const WorkflowContent = React.memo(() => {
})
const absolutePosition = getNodeAbsolutePosition(id)
updateBlockPosition(id, absolutePosition)
updateParentId(id, '', 'parent')
orphanedUpdates.push({ id, position: absolutePosition, parentId: '' })
}
})
}, [blocks, updateBlockPosition, updateParentId, getNodeAbsolutePosition, isWorkflowReady])
// Batch update all orphaned nodes at once
if (orphanedUpdates.length > 0) {
batchUpdateBlocksWithParent(orphanedUpdates)
}
}, [blocks, batchUpdateBlocksWithParent, getNodeAbsolutePosition, isWorkflowReady])
/** Handles edge removal changes. */
const onEdgesChange = useCallback(
(changes: any) => {
changes.forEach((change: any) => {
if (change.type === 'remove') {
removeEdge(change.id)
}
})
const edgeIdsToRemove = changes
.filter((change: any) => change.type === 'remove')
.map((change: any) => change.id)
if (edgeIdsToRemove.length > 0) {
collaborativeBatchRemoveEdges(edgeIdsToRemove)
}
},
[removeEdge]
[collaborativeBatchRemoveEdges]
)
/**
@@ -2683,9 +2725,6 @@ const WorkflowContent = React.memo(() => {
const edgesToAdd: Edge[] = autoConnectEdge ? [autoConnectEdge] : []
// Skip recording these edges separately since they're part of the parent update
window.dispatchEvent(new CustomEvent('skip-edge-recording', { detail: { skip: true } }))
// Moving to a new parent container - pass both removed and added edges for undo/redo
const affectedEdges = [...edgesToRemove, ...edgesToAdd]
updateNodeParent(node.id, potentialParentId, affectedEdges)
@@ -2704,10 +2743,10 @@ const WorkflowContent = React.memo(() => {
})
)
// Now add the edges after parent update
edgesToAdd.forEach((edge) => addEdge(edge))
window.dispatchEvent(new CustomEvent('skip-edge-recording', { detail: { skip: false } }))
// Add edges after parent update (skip undo recording - it's part of parent update)
if (edgesToAdd.length > 0) {
collaborativeBatchAddEdges(edgesToAdd, { skipUndoRedo: true })
}
} else if (!potentialParentId && dragStartParentId) {
// Moving OUT of a subflow to canvas
// Get absolute position BEFORE removing from parent
@@ -2761,7 +2800,7 @@ const WorkflowContent = React.memo(() => {
potentialParentId,
updateNodeParent,
updateBlockPosition,
addEdge,
collaborativeBatchAddEdges,
tryCreateAutoConnectEdge,
blocks,
edgesForDisplay,

View File

@@ -25,8 +25,8 @@ import { Input as BaseInput, Skeleton } from '@/components/ui'
import { useSession } from '@/lib/auth/auth-client'
import { getSubscriptionStatus } from '@/lib/billing/client'
import type { PermissionGroupConfig } from '@/lib/permission-groups/types'
import { getUserColor } from '@/lib/workspaces/colors'
import { getUserRole } from '@/lib/workspaces/organization'
import { getUserColor } from '@/app/workspace/[workspaceId]/w/utils/get-user-color'
import { getAllBlocks } from '@/blocks'
import { useOrganization, useOrganizations } from '@/hooks/queries/organization'
import {

View File

@@ -2,14 +2,12 @@ import { useCallback, useEffect, useRef } from 'react'
import { createLogger } from '@sim/logger'
import type { Edge } from 'reactflow'
import { useSession } from '@/lib/auth/auth-client'
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
import { useSocket } from '@/app/workspace/providers/socket-provider'
import { getBlock } from '@/blocks'
import { useUndoRedo } from '@/hooks/use-undo-redo'
import {
BLOCK_OPERATIONS,
BLOCKS_OPERATIONS,
EDGE_OPERATIONS,
EDGES_OPERATIONS,
OPERATION_TARGETS,
SUBBLOCK_OPERATIONS,
@@ -34,7 +32,6 @@ const logger = createLogger('CollaborativeWorkflow')
export function useCollaborativeWorkflow() {
const undoRedo = useUndoRedo()
const isUndoRedoInProgress = useRef(false)
const skipEdgeRecording = useRef(false)
const lastDiffOperationId = useRef<string | null>(null)
useEffect(() => {
@@ -60,11 +57,6 @@ export function useCollaborativeWorkflow() {
)
}
const skipEdgeHandler = (e: any) => {
const { skip } = e.detail || {}
skipEdgeRecording.current = skip
}
const diffOperationHandler = (e: any) => {
const {
type,
@@ -110,12 +102,10 @@ export function useCollaborativeWorkflow() {
window.addEventListener('workflow-record-move', moveHandler)
window.addEventListener('workflow-record-parent-update', parentUpdateHandler)
window.addEventListener('skip-edge-recording', skipEdgeHandler)
window.addEventListener('record-diff-operation', diffOperationHandler)
return () => {
window.removeEventListener('workflow-record-move', moveHandler)
window.removeEventListener('workflow-record-parent-update', parentUpdateHandler)
window.removeEventListener('skip-edge-recording', skipEdgeHandler)
window.removeEventListener('record-diff-operation', diffOperationHandler)
}
}, [undoRedo])
@@ -208,105 +198,29 @@ export function useCollaborativeWorkflow() {
try {
if (target === OPERATION_TARGETS.BLOCK) {
switch (operation) {
case BLOCK_OPERATIONS.UPDATE_POSITION: {
const blockId = payload.id
if (!data.timestamp) {
logger.warn('Position update missing timestamp, applying without ordering check', {
blockId,
})
workflowStore.updateBlockPosition(payload.id, payload.position)
break
}
const updateTimestamp = data.timestamp
const lastTimestamp = lastPositionTimestamps.current.get(blockId) || 0
if (updateTimestamp >= lastTimestamp) {
workflowStore.updateBlockPosition(payload.id, payload.position)
lastPositionTimestamps.current.set(blockId, updateTimestamp)
} else {
// Skip out-of-order position update to prevent jagged movement
logger.debug('Skipping out-of-order position update', {
blockId,
updateTimestamp,
lastTimestamp,
position: payload.position,
})
}
break
}
case BLOCK_OPERATIONS.UPDATE_NAME:
workflowStore.updateBlockName(payload.id, payload.name)
break
case BLOCK_OPERATIONS.TOGGLE_ENABLED:
workflowStore.toggleBlockEnabled(payload.id)
break
case BLOCK_OPERATIONS.UPDATE_PARENT:
workflowStore.updateParentId(payload.id, payload.parentId, payload.extent)
break
case BLOCK_OPERATIONS.UPDATE_ADVANCED_MODE:
workflowStore.setBlockAdvancedMode(payload.id, payload.advancedMode)
break
case BLOCK_OPERATIONS.UPDATE_TRIGGER_MODE:
workflowStore.setBlockTriggerMode(payload.id, payload.triggerMode)
break
case BLOCK_OPERATIONS.TOGGLE_HANDLES: {
const currentBlock = workflowStore.blocks[payload.id]
if (currentBlock && currentBlock.horizontalHandles !== payload.horizontalHandles) {
workflowStore.toggleBlockHandles(payload.id)
}
break
}
}
} else if (target === OPERATION_TARGETS.BLOCKS) {
switch (operation) {
case BLOCKS_OPERATIONS.BATCH_UPDATE_POSITIONS: {
const { updates } = payload
if (Array.isArray(updates)) {
updates.forEach(({ id, position }: { id: string; position: Position }) => {
if (id && position) {
workflowStore.updateBlockPosition(id, position)
}
})
workflowStore.batchUpdatePositions(updates)
}
break
}
}
} else if (target === OPERATION_TARGETS.EDGE) {
switch (operation) {
case EDGE_OPERATIONS.ADD:
workflowStore.addEdge(payload as Edge)
break
case EDGE_OPERATIONS.REMOVE: {
workflowStore.removeEdge(payload.id)
const updatedBlocks = useWorkflowStore.getState().blocks
const updatedEdges = useWorkflowStore.getState().edges
const graph = {
blocksById: updatedBlocks,
edgesById: Object.fromEntries(updatedEdges.map((e) => [e.id, e])),
}
const undoRedoStore = useUndoRedoStore.getState()
const stackKeys = Object.keys(undoRedoStore.stacks)
stackKeys.forEach((key) => {
const [workflowId, userId] = key.split(':')
if (workflowId === activeWorkflowId) {
undoRedoStore.pruneInvalidEntries(workflowId, userId, graph)
}
})
break
}
}
} else if (target === OPERATION_TARGETS.EDGES) {
switch (operation) {
case EDGES_OPERATIONS.BATCH_REMOVE_EDGES: {
const { ids } = payload
if (Array.isArray(ids)) {
ids.forEach((id: string) => {
workflowStore.removeEdge(id)
})
if (Array.isArray(ids) && ids.length > 0) {
workflowStore.batchRemoveEdges(ids)
const updatedBlocks = useWorkflowStore.getState().blocks
const updatedEdges = useWorkflowStore.getState().edges
@@ -328,8 +242,8 @@ export function useCollaborativeWorkflow() {
}
case EDGES_OPERATIONS.BATCH_ADD_EDGES: {
const { edges } = payload
if (Array.isArray(edges)) {
edges.forEach((edge: Edge) => workflowStore.addEdge(edge))
if (Array.isArray(edges) && edges.length > 0) {
workflowStore.batchAddEdges(edges)
}
break
}
@@ -433,68 +347,15 @@ export function useCollaborativeWorkflow() {
if (target === OPERATION_TARGETS.BLOCKS) {
switch (operation) {
case BLOCKS_OPERATIONS.BATCH_ADD_BLOCKS: {
const {
blocks,
edges,
loops,
parallels,
subBlockValues: addedSubBlockValues,
} = payload
const { blocks, edges, subBlockValues: addedSubBlockValues } = payload
logger.info('Received batch-add-blocks from remote user', {
userId,
blockCount: (blocks || []).length,
edgeCount: (edges || []).length,
})
;(blocks || []).forEach((block: BlockState) => {
workflowStore.addBlock(
block.id,
block.type,
block.name,
block.position,
block.data,
block.data?.parentId,
block.data?.extent,
{
enabled: block.enabled,
horizontalHandles: block.horizontalHandles,
advancedMode: block.advancedMode,
triggerMode: block.triggerMode ?? false,
height: block.height,
}
)
})
;(edges || []).forEach((edge: Edge) => {
workflowStore.addEdge(edge)
})
if (loops) {
Object.entries(loops as Record<string, Loop>).forEach(([loopId, loopConfig]) => {
useWorkflowStore.setState((state) => ({
loops: { ...state.loops, [loopId]: loopConfig },
}))
})
}
if (parallels) {
Object.entries(parallels as Record<string, Parallel>).forEach(
([parallelId, parallelConfig]) => {
useWorkflowStore.setState((state) => ({
parallels: { ...state.parallels, [parallelId]: parallelConfig },
}))
}
)
}
if (addedSubBlockValues && activeWorkflowId) {
Object.entries(
addedSubBlockValues as Record<string, Record<string, unknown>>
).forEach(([blockId, subBlocks]) => {
Object.entries(subBlocks).forEach(([subBlockId, value]) => {
subBlockStore.setValue(blockId, subBlockId, value)
})
})
if (blocks && blocks.length > 0) {
workflowStore.batchAddBlocks(blocks, edges || [], addedSubBlockValues || {})
}
logger.info('Successfully applied batch-add-blocks from remote user')
@@ -507,13 +368,63 @@ export function useCollaborativeWorkflow() {
count: (ids || []).length,
})
;(ids || []).forEach((id: string) => {
workflowStore.removeBlock(id)
})
if (ids && ids.length > 0) {
workflowStore.batchRemoveBlocks(ids)
}
logger.info('Successfully applied batch-remove-blocks from remote user')
break
}
case BLOCKS_OPERATIONS.BATCH_TOGGLE_ENABLED: {
const { blockIds } = payload
logger.info('Received batch-toggle-enabled from remote user', {
userId,
count: (blockIds || []).length,
})
if (blockIds && blockIds.length > 0) {
workflowStore.batchToggleEnabled(blockIds)
}
logger.info('Successfully applied batch-toggle-enabled from remote user')
break
}
case BLOCKS_OPERATIONS.BATCH_TOGGLE_HANDLES: {
const { blockIds } = payload
logger.info('Received batch-toggle-handles from remote user', {
userId,
count: (blockIds || []).length,
})
if (blockIds && blockIds.length > 0) {
workflowStore.batchToggleHandles(blockIds)
}
logger.info('Successfully applied batch-toggle-handles from remote user')
break
}
case BLOCKS_OPERATIONS.BATCH_UPDATE_PARENT: {
const { updates } = payload
logger.info('Received batch-update-parent from remote user', {
userId,
count: (updates || []).length,
})
if (updates && updates.length > 0) {
workflowStore.batchUpdateBlocksWithParent(
updates.map(
(u: { id: string; parentId: string; position: { x: number; y: number } }) => ({
id: u.id,
position: u.position,
parentId: u.parentId || undefined,
})
)
)
}
logger.info('Successfully applied batch-update-parent from remote user')
break
}
}
}
} catch (error) {
@@ -790,9 +701,7 @@ export function useCollaborativeWorkflow() {
userId: session?.user?.id || 'unknown',
})
updates.forEach(({ id, position }) => {
workflowStore.updateBlockPosition(id, position)
})
workflowStore.batchUpdatePositions(updates)
if (options?.previousPositions && options.previousPositions.size > 0) {
const moves = updates
@@ -927,27 +836,13 @@ export function useCollaborativeWorkflow() {
userId: session?.user?.id || 'unknown',
})
for (const id of validIds) {
workflowStore.toggleBlockEnabled(id)
}
workflowStore.batchToggleEnabled(validIds)
undoRedo.recordBatchToggleEnabled(validIds, previousStates)
},
[addToQueue, activeWorkflowId, session?.user?.id, workflowStore, undoRedo]
)
const collaborativeUpdateParentId = useCallback(
(id: string, parentId: string, extent: 'parent') => {
executeQueuedOperation(
BLOCK_OPERATIONS.UPDATE_PARENT,
OPERATION_TARGETS.BLOCK,
{ id, parentId, extent },
() => workflowStore.updateParentId(id, parentId, extent)
)
},
[executeQueuedOperation, workflowStore]
)
const collaborativeBatchUpdateParent = useCallback(
(
updates: Array<{
@@ -979,16 +874,21 @@ export function useCollaborativeWorkflow() {
}
})
for (const update of updates) {
if (update.affectedEdges.length > 0) {
update.affectedEdges.forEach((e) => workflowStore.removeEdge(e.id))
}
workflowStore.updateBlockPosition(update.blockId, update.newPosition)
if (update.newParentId) {
workflowStore.updateParentId(update.blockId, update.newParentId, 'parent')
}
// Collect all edge IDs to remove
const edgeIdsToRemove = updates.flatMap((u) => u.affectedEdges.map((e) => e.id))
if (edgeIdsToRemove.length > 0) {
workflowStore.batchRemoveEdges(edgeIdsToRemove)
}
// Batch update positions and parents
workflowStore.batchUpdateBlocksWithParent(
updates.map((u) => ({
id: u.blockId,
position: u.newPosition,
parentId: u.newParentId || undefined,
}))
)
undoRedo.recordBatchUpdateParent(batchUpdates)
const operationId = crypto.randomUUID()
@@ -1031,37 +931,6 @@ export function useCollaborativeWorkflow() {
[executeQueuedOperation, workflowStore]
)
const collaborativeToggleBlockTriggerMode = useCallback(
(id: string) => {
const currentBlock = workflowStore.blocks[id]
if (!currentBlock) return
const newTriggerMode = !currentBlock.triggerMode
// When enabling trigger mode, check if block is inside a subflow
if (newTriggerMode && TriggerUtils.isBlockInSubflow(id, workflowStore.blocks)) {
// Dispatch custom event to show warning modal
window.dispatchEvent(
new CustomEvent('show-trigger-warning', {
detail: {
type: 'trigger_in_subflow',
triggerName: 'trigger',
},
})
)
return
}
executeQueuedOperation(
BLOCK_OPERATIONS.UPDATE_TRIGGER_MODE,
OPERATION_TARGETS.BLOCK,
{ id, triggerMode: newTriggerMode },
() => workflowStore.toggleBlockTriggerMode(id)
)
},
[executeQueuedOperation, workflowStore]
)
const collaborativeBatchToggleBlockHandles = useCallback(
(ids: string[]) => {
if (ids.length === 0) return
@@ -1092,57 +961,44 @@ export function useCollaborativeWorkflow() {
userId: session?.user?.id || 'unknown',
})
for (const id of validIds) {
workflowStore.toggleBlockHandles(id)
}
workflowStore.batchToggleHandles(validIds)
undoRedo.recordBatchToggleHandles(validIds, previousStates)
},
[addToQueue, activeWorkflowId, session?.user?.id, workflowStore, undoRedo]
)
const collaborativeAddEdge = useCallback(
(edge: Edge) => {
executeQueuedOperation(EDGE_OPERATIONS.ADD, OPERATION_TARGETS.EDGE, edge, () =>
workflowStore.addEdge(edge)
)
if (!skipEdgeRecording.current) {
undoRedo.recordAddEdge(edge.id)
const collaborativeBatchAddEdges = useCallback(
(edges: Edge[], options?: { skipUndoRedo?: boolean }) => {
if (!isInActiveRoom()) {
logger.debug('Skipping batch add edges - not in active workflow')
return false
}
if (edges.length === 0) return false
const operationId = crypto.randomUUID()
addToQueue({
id: operationId,
operation: {
operation: EDGES_OPERATIONS.BATCH_ADD_EDGES,
target: OPERATION_TARGETS.EDGES,
payload: { edges },
},
workflowId: activeWorkflowId || '',
userId: session?.user?.id || 'unknown',
})
workflowStore.batchAddEdges(edges)
if (!options?.skipUndoRedo) {
edges.forEach((edge) => undoRedo.recordAddEdge(edge.id))
}
return true
},
[executeQueuedOperation, workflowStore, undoRedo]
)
const collaborativeRemoveEdge = useCallback(
(edgeId: string) => {
const edge = workflowStore.edges.find((e) => e.id === edgeId)
if (!edge) {
logger.debug('Edge already removed, skipping operation', { edgeId })
return
}
const sourceExists = workflowStore.blocks[edge.source]
const targetExists = workflowStore.blocks[edge.target]
if (!sourceExists || !targetExists) {
logger.debug('Edge source or target block no longer exists, skipping operation', {
edgeId,
sourceExists: !!sourceExists,
targetExists: !!targetExists,
})
return
}
if (!skipEdgeRecording.current) {
undoRedo.recordBatchRemoveEdges([edge])
}
executeQueuedOperation(EDGE_OPERATIONS.REMOVE, OPERATION_TARGETS.EDGE, { id: edgeId }, () =>
workflowStore.removeEdge(edgeId)
)
},
[executeQueuedOperation, workflowStore, undoRedo]
[addToQueue, activeWorkflowId, session?.user?.id, isInActiveRoom, workflowStore, undoRedo]
)
const collaborativeBatchRemoveEdges = useCallback(
@@ -1187,7 +1043,7 @@ export function useCollaborativeWorkflow() {
userId: session?.user?.id || 'unknown',
})
validEdgeIds.forEach((id) => workflowStore.removeEdge(id))
workflowStore.batchRemoveEdges(validEdgeIds)
if (!options?.skipUndoRedo && edgeSnapshots.length > 0) {
undoRedo.recordBatchRemoveEdges(edgeSnapshots)
@@ -1619,48 +1475,7 @@ export function useCollaborativeWorkflow() {
userId: session?.user?.id || 'unknown',
})
blocks.forEach((block) => {
workflowStore.addBlock(
block.id,
block.type,
block.name,
block.position,
block.data,
block.data?.parentId,
block.data?.extent,
{
enabled: block.enabled,
horizontalHandles: block.horizontalHandles,
advancedMode: block.advancedMode,
triggerMode: block.triggerMode ?? false,
height: block.height,
}
)
})
edges.forEach((edge) => {
workflowStore.addEdge(edge)
})
if (Object.keys(loops).length > 0) {
useWorkflowStore.setState((state) => ({
loops: { ...state.loops, ...loops },
}))
}
if (Object.keys(parallels).length > 0) {
useWorkflowStore.setState((state) => ({
parallels: { ...state.parallels, ...parallels },
}))
}
if (activeWorkflowId) {
Object.entries(subBlockValues).forEach(([blockId, subBlocks]) => {
Object.entries(subBlocks).forEach(([subBlockId, value]) => {
subBlockStore.setValue(blockId, subBlockId, value)
})
})
}
workflowStore.batchAddBlocks(blocks, edges, subBlockValues)
if (!options?.skipUndoRedo) {
undoRedo.recordBatchAddBlocks(blocks, edges, subBlockValues)
@@ -1751,9 +1566,7 @@ export function useCollaborativeWorkflow() {
userId: session?.user?.id || 'unknown',
})
blockIds.forEach((id) => {
workflowStore.removeBlock(id)
})
workflowStore.batchRemoveBlocks(blockIds)
if (!options?.skipUndoRedo && blockSnapshots.length > 0) {
undoRedo.recordBatchRemoveBlocks(blockSnapshots, edgeSnapshots, subBlockValues)
@@ -1787,15 +1600,12 @@ export function useCollaborativeWorkflow() {
collaborativeBatchUpdatePositions,
collaborativeUpdateBlockName,
collaborativeBatchToggleBlockEnabled,
collaborativeUpdateParentId,
collaborativeBatchUpdateParent,
collaborativeToggleBlockAdvancedMode,
collaborativeToggleBlockTriggerMode,
collaborativeBatchToggleBlockHandles,
collaborativeBatchAddBlocks,
collaborativeBatchRemoveBlocks,
collaborativeAddEdge,
collaborativeRemoveEdge,
collaborativeBatchAddEdges,
collaborativeBatchRemoveEdges,
collaborativeSetSubblockValue,
collaborativeSetTagSelection,

View File

@@ -481,7 +481,7 @@ export function useUndoRedo() {
userId,
})
existingBlockIds.forEach((id) => workflowStore.removeBlock(id))
workflowStore.batchRemoveBlocks(existingBlockIds)
break
}
case UNDO_REDO_OPERATIONS.BATCH_ADD_BLOCKS: {
@@ -544,11 +544,12 @@ export function useUndoRedo() {
}
if (edgeSnapshots && edgeSnapshots.length > 0) {
edgeSnapshots.forEach((edge) => {
if (!workflowStore.edges.find((e) => e.id === edge.id)) {
workflowStore.addEdge(edge)
}
})
const edgesToAdd = edgeSnapshots.filter(
(edge) => !workflowStore.edges.find((e) => e.id === edge.id)
)
if (edgesToAdd.length > 0) {
workflowStore.batchAddEdges(edgesToAdd)
}
}
break
}
@@ -572,7 +573,7 @@ export function useUndoRedo() {
workflowId: activeWorkflowId,
userId,
})
edgesToRemove.forEach((id) => workflowStore.removeEdge(id))
workflowStore.batchRemoveEdges(edgesToRemove)
}
logger.debug('Undid batch-add-edges', { edgeCount: edgesToRemove.length })
break
@@ -597,7 +598,7 @@ export function useUndoRedo() {
workflowId: activeWorkflowId,
userId,
})
edgesToAdd.forEach((edge) => workflowStore.addEdge(edge))
workflowStore.batchAddEdges(edgesToAdd)
}
logger.debug('Undid batch-remove-edges', { edgeCount: edgesToAdd.length })
break
@@ -613,14 +614,11 @@ export function useUndoRedo() {
id: move.blockId,
position: { x: move.after.x, y: move.after.y },
})
workflowStore.updateBlockPosition(move.blockId, {
x: move.after.x,
y: move.after.y,
})
}
}
if (positionUpdates.length > 0) {
workflowStore.batchUpdatePositions(positionUpdates)
addToQueue({
id: opId,
operation: {
@@ -654,7 +652,7 @@ export function useUndoRedo() {
workflowId: activeWorkflowId,
userId,
})
edgesToAdd.forEach((edge) => workflowStore.addEdge(edge))
workflowStore.batchAddEdges(edgesToAdd)
}
}
@@ -675,9 +673,6 @@ export function useUndoRedo() {
userId,
})
// Update position locally
workflowStore.updateBlockPosition(blockId, newPosition)
// Send parent update to server
addToQueue({
id: opId,
@@ -696,26 +691,35 @@ export function useUndoRedo() {
userId,
})
// Update parent locally
workflowStore.updateParentId(blockId, newParentId || '', 'parent')
// Update position and parent locally using batch method
workflowStore.batchUpdateBlocksWithParent([
{
id: blockId,
position: newPosition,
parentId: newParentId,
},
])
// If we're removing FROM a subflow (undo of add to subflow), remove edges after
if (!newParentId && affectedEdges && affectedEdges.length > 0) {
affectedEdges.forEach((edge) => {
if (workflowStore.edges.find((e) => e.id === edge.id)) {
workflowStore.removeEdge(edge.id)
const edgeIdsToRemove = affectedEdges
.filter((edge) => workflowStore.edges.find((e) => e.id === edge.id))
.map((edge) => edge.id)
if (edgeIdsToRemove.length > 0) {
workflowStore.batchRemoveEdges(edgeIdsToRemove)
edgeIdsToRemove.forEach((edgeId) => {
addToQueue({
id: crypto.randomUUID(),
operation: {
operation: EDGE_OPERATIONS.REMOVE,
target: OPERATION_TARGETS.EDGE,
payload: { id: edge.id, isUndo: true },
payload: { id: edgeId, isUndo: true },
},
workflowId: activeWorkflowId,
userId,
})
}
})
})
}
}
} else {
logger.debug('Undo update-parent skipped; block missing', { blockId })
@@ -732,54 +736,67 @@ export function useUndoRedo() {
break
}
// Process each update
// Collect all edge operations first
const allEdgesToAdd: Edge[] = []
const allEdgeIdsToRemove: string[] = []
for (const update of validUpdates) {
const { blockId, newParentId, newPosition, affectedEdges } = update
const { newParentId, affectedEdges } = update
// Moving OUT of subflow (undoing insert) → restore edges first
if (!newParentId && affectedEdges && affectedEdges.length > 0) {
const edgesToAdd = affectedEdges.filter(
(e) => !workflowStore.edges.find((edge) => edge.id === e.id)
)
if (edgesToAdd.length > 0) {
addToQueue({
id: crypto.randomUUID(),
operation: {
operation: EDGES_OPERATIONS.BATCH_ADD_EDGES,
target: OPERATION_TARGETS.EDGES,
payload: { edges: edgesToAdd },
},
workflowId: activeWorkflowId,
userId,
})
edgesToAdd.forEach((edge) => workflowStore.addEdge(edge))
}
allEdgesToAdd.push(...edgesToAdd)
}
// Moving INTO subflow (undoing removal) → remove edges first
if (newParentId && affectedEdges && affectedEdges.length > 0) {
affectedEdges.forEach((edge) => {
if (workflowStore.edges.find((e) => e.id === edge.id)) {
workflowStore.removeEdge(edge.id)
}
})
addToQueue({
id: crypto.randomUUID(),
operation: {
operation: EDGES_OPERATIONS.BATCH_REMOVE_EDGES,
target: OPERATION_TARGETS.EDGES,
payload: { edgeIds: affectedEdges.map((e) => e.id) },
},
workflowId: activeWorkflowId,
userId,
})
const edgeIds = affectedEdges
.filter((edge) => workflowStore.edges.find((e) => e.id === edge.id))
.map((edge) => edge.id)
allEdgeIdsToRemove.push(...edgeIds)
}
// Update position and parent locally
workflowStore.updateBlockPosition(blockId, newPosition)
workflowStore.updateParentId(blockId, newParentId || '', 'parent')
}
// Apply edge operations in batch
if (allEdgesToAdd.length > 0) {
addToQueue({
id: crypto.randomUUID(),
operation: {
operation: EDGES_OPERATIONS.BATCH_ADD_EDGES,
target: OPERATION_TARGETS.EDGES,
payload: { edges: allEdgesToAdd },
},
workflowId: activeWorkflowId,
userId,
})
workflowStore.batchAddEdges(allEdgesToAdd)
}
if (allEdgeIdsToRemove.length > 0) {
workflowStore.batchRemoveEdges(allEdgeIdsToRemove)
addToQueue({
id: crypto.randomUUID(),
operation: {
operation: EDGES_OPERATIONS.BATCH_REMOVE_EDGES,
target: OPERATION_TARGETS.EDGES,
payload: { edgeIds: allEdgeIdsToRemove },
},
workflowId: activeWorkflowId,
userId,
})
}
// Update positions and parents locally in batch
const blockUpdates = validUpdates.map((update) => ({
id: update.blockId,
position: update.newPosition,
parentId: update.newParentId,
}))
workflowStore.batchUpdateBlocksWithParent(blockUpdates)
// Send batch update to server
addToQueue({
id: opId,
@@ -1104,11 +1121,12 @@ export function useUndoRedo() {
}
if (edgeSnapshots && edgeSnapshots.length > 0) {
edgeSnapshots.forEach((edge) => {
if (!workflowStore.edges.find((e) => e.id === edge.id)) {
workflowStore.addEdge(edge)
}
})
const edgesToAdd = edgeSnapshots.filter(
(edge) => !workflowStore.edges.find((e) => e.id === edge.id)
)
if (edgesToAdd.length > 0) {
workflowStore.batchAddEdges(edgesToAdd)
}
}
break
}
@@ -1134,7 +1152,7 @@ export function useUndoRedo() {
userId,
})
existingBlockIds.forEach((id) => workflowStore.removeBlock(id))
workflowStore.batchRemoveBlocks(existingBlockIds)
break
}
case UNDO_REDO_OPERATIONS.BATCH_REMOVE_EDGES: {
@@ -1157,7 +1175,7 @@ export function useUndoRedo() {
workflowId: activeWorkflowId,
userId,
})
edgesToRemove.forEach((id) => workflowStore.removeEdge(id))
workflowStore.batchRemoveEdges(edgesToRemove)
}
logger.debug('Redid batch-remove-edges', { edgeCount: edgesToRemove.length })
@@ -1183,7 +1201,7 @@ export function useUndoRedo() {
workflowId: activeWorkflowId,
userId,
})
edgesToAdd.forEach((edge) => workflowStore.addEdge(edge))
workflowStore.batchAddEdges(edgesToAdd)
}
logger.debug('Redid batch-add-edges', { edgeCount: edgesToAdd.length })
@@ -1200,14 +1218,11 @@ export function useUndoRedo() {
id: move.blockId,
position: { x: move.after.x, y: move.after.y },
})
workflowStore.updateBlockPosition(move.blockId, {
x: move.after.x,
y: move.after.y,
})
}
}
if (positionUpdates.length > 0) {
workflowStore.batchUpdatePositions(positionUpdates)
addToQueue({
id: opId,
operation: {
@@ -1229,21 +1244,24 @@ export function useUndoRedo() {
if (workflowStore.blocks[blockId]) {
// If we're removing FROM a subflow, remove edges first
if (!newParentId && affectedEdges && affectedEdges.length > 0) {
affectedEdges.forEach((edge) => {
if (workflowStore.edges.find((e) => e.id === edge.id)) {
workflowStore.removeEdge(edge.id)
const edgeIdsToRemove = affectedEdges
.filter((edge) => workflowStore.edges.find((e) => e.id === edge.id))
.map((edge) => edge.id)
if (edgeIdsToRemove.length > 0) {
workflowStore.batchRemoveEdges(edgeIdsToRemove)
edgeIdsToRemove.forEach((edgeId) => {
addToQueue({
id: crypto.randomUUID(),
operation: {
operation: EDGE_OPERATIONS.REMOVE,
target: OPERATION_TARGETS.EDGE,
payload: { id: edge.id, isRedo: true },
payload: { id: edgeId, isRedo: true },
},
workflowId: activeWorkflowId,
userId,
})
}
})
})
}
}
// Send position update to server
@@ -1264,9 +1282,6 @@ export function useUndoRedo() {
userId,
})
// Update position locally
workflowStore.updateBlockPosition(blockId, newPosition)
// Send parent update to server
addToQueue({
id: opId,
@@ -1285,8 +1300,14 @@ export function useUndoRedo() {
userId,
})
// Update parent locally
workflowStore.updateParentId(blockId, newParentId || '', 'parent')
// Update position and parent locally using batch method
workflowStore.batchUpdateBlocksWithParent([
{
id: blockId,
position: newPosition,
parentId: newParentId,
},
])
// If we're adding TO a subflow, restore edges after
if (newParentId && affectedEdges && affectedEdges.length > 0) {
@@ -1304,7 +1325,7 @@ export function useUndoRedo() {
workflowId: activeWorkflowId,
userId,
})
edgesToAdd.forEach((edge) => workflowStore.addEdge(edge))
workflowStore.batchAddEdges(edgesToAdd)
}
}
} else {
@@ -1322,54 +1343,68 @@ export function useUndoRedo() {
break
}
// Process each update
// Collect all edge operations first
const allEdgesToAdd: Edge[] = []
const allEdgeIdsToRemove: string[] = []
for (const update of validUpdates) {
const { blockId, newParentId, newPosition, affectedEdges } = update
const { newParentId, affectedEdges } = update
// Moving INTO subflow (redoing insert) → remove edges first
if (newParentId && affectedEdges && affectedEdges.length > 0) {
affectedEdges.forEach((edge) => {
if (workflowStore.edges.find((e) => e.id === edge.id)) {
workflowStore.removeEdge(edge.id)
}
})
addToQueue({
id: crypto.randomUUID(),
operation: {
operation: EDGES_OPERATIONS.BATCH_REMOVE_EDGES,
target: OPERATION_TARGETS.EDGES,
payload: { edgeIds: affectedEdges.map((e) => e.id) },
},
workflowId: activeWorkflowId,
userId,
})
const edgeIds = affectedEdges
.filter((edge) => workflowStore.edges.find((e) => e.id === edge.id))
.map((edge) => edge.id)
allEdgeIdsToRemove.push(...edgeIds)
}
// Update position and parent locally
workflowStore.updateBlockPosition(blockId, newPosition)
workflowStore.updateParentId(blockId, newParentId || '', 'parent')
// Moving OUT of subflow (redoing removal) → restore edges after
if (!newParentId && affectedEdges && affectedEdges.length > 0) {
const edgesToAdd = affectedEdges.filter(
(e) => !workflowStore.edges.find((edge) => edge.id === e.id)
)
if (edgesToAdd.length > 0) {
addToQueue({
id: crypto.randomUUID(),
operation: {
operation: EDGES_OPERATIONS.BATCH_ADD_EDGES,
target: OPERATION_TARGETS.EDGES,
payload: { edges: edgesToAdd },
},
workflowId: activeWorkflowId,
userId,
})
edgesToAdd.forEach((edge) => workflowStore.addEdge(edge))
}
allEdgesToAdd.push(...edgesToAdd)
}
}
// Apply edge removals in batch first
if (allEdgeIdsToRemove.length > 0) {
workflowStore.batchRemoveEdges(allEdgeIdsToRemove)
addToQueue({
id: crypto.randomUUID(),
operation: {
operation: EDGES_OPERATIONS.BATCH_REMOVE_EDGES,
target: OPERATION_TARGETS.EDGES,
payload: { edgeIds: allEdgeIdsToRemove },
},
workflowId: activeWorkflowId,
userId,
})
}
// Update positions and parents locally in batch
const blockUpdates = validUpdates.map((update) => ({
id: update.blockId,
position: update.newPosition,
parentId: update.newParentId,
}))
workflowStore.batchUpdateBlocksWithParent(blockUpdates)
// Apply edge additions in batch after
if (allEdgesToAdd.length > 0) {
addToQueue({
id: crypto.randomUUID(),
operation: {
operation: EDGES_OPERATIONS.BATCH_ADD_EDGES,
target: OPERATION_TARGETS.EDGES,
payload: { edges: allEdgesToAdd },
},
workflowId: activeWorkflowId,
userId,
})
workflowStore.batchAddEdges(allEdgesToAdd)
}
// Send batch update to server
addToQueue({
id: opId,

View File

@@ -34,7 +34,9 @@ import {
updateOllamaProviderModels,
} from '@/providers/utils'
const isHostedSpy = vi.spyOn(environmentModule, 'isHosted', 'get')
const isHostedSpy = vi.spyOn(environmentModule, 'isHosted', 'get') as unknown as {
mockReturnValue: (value: boolean) => void
}
const mockGetRotatingApiKey = vi.fn().mockReturnValue('rotating-server-key')
const originalRequire = module.require

View File

@@ -3,9 +3,7 @@ export const BLOCK_OPERATIONS = {
UPDATE_NAME: 'update-name',
TOGGLE_ENABLED: 'toggle-enabled',
UPDATE_PARENT: 'update-parent',
UPDATE_WIDE: 'update-wide',
UPDATE_ADVANCED_MODE: 'update-advanced-mode',
UPDATE_TRIGGER_MODE: 'update-trigger-mode',
TOGGLE_HANDLES: 'toggle-handles',
} as const
@@ -37,8 +35,6 @@ export const EDGES_OPERATIONS = {
export type EdgesOperation = (typeof EDGES_OPERATIONS)[keyof typeof EDGES_OPERATIONS]
export const SUBFLOW_OPERATIONS = {
ADD: 'add',
REMOVE: 'remove',
UPDATE: 'update',
} as const
@@ -94,3 +90,20 @@ export const UNDO_REDO_OPERATIONS = {
} as const
export type UndoRedoOperation = (typeof UNDO_REDO_OPERATIONS)[keyof typeof UNDO_REDO_OPERATIONS]
/**
* All socket operations that require permission checks.
* This is the single source of truth for valid operations.
*/
export const ALL_SOCKET_OPERATIONS = [
...Object.values(BLOCK_OPERATIONS),
...Object.values(BLOCKS_OPERATIONS),
...Object.values(EDGE_OPERATIONS),
...Object.values(EDGES_OPERATIONS),
...Object.values(WORKFLOW_OPERATIONS),
...Object.values(SUBBLOCK_OPERATIONS),
...Object.values(VARIABLE_OPERATIONS),
...Object.values(SUBFLOW_OPERATIONS),
] as const
export type SocketOperation = (typeof ALL_SOCKET_OPERATIONS)[number]

View File

@@ -396,28 +396,6 @@ async function handleBlockOperationTx(
break
}
case BLOCK_OPERATIONS.UPDATE_TRIGGER_MODE: {
if (!payload.id || payload.triggerMode === undefined) {
throw new Error('Missing required fields for update trigger mode operation')
}
const updateResult = await tx
.update(workflowBlocks)
.set({
triggerMode: payload.triggerMode,
updatedAt: new Date(),
})
.where(and(eq(workflowBlocks.id, payload.id), eq(workflowBlocks.workflowId, workflowId)))
.returning({ id: workflowBlocks.id })
if (updateResult.length === 0) {
throw new Error(`Block ${payload.id} not found in workflow ${workflowId}`)
}
logger.debug(`Updated block trigger mode: ${payload.id} -> ${payload.triggerMode}`)
break
}
case BLOCK_OPERATIONS.TOGGLE_HANDLES: {
if (!payload.id || payload.horizontalHandles === undefined) {
throw new Error('Missing required fields for toggle handles operation')

View File

@@ -207,19 +207,12 @@ describe('checkRolePermission', () => {
{ operation: 'update-name', adminAllowed: true, writeAllowed: true, readAllowed: false },
{ operation: 'toggle-enabled', adminAllowed: true, writeAllowed: true, readAllowed: false },
{ operation: 'update-parent', adminAllowed: true, writeAllowed: true, readAllowed: false },
{ operation: 'update-wide', adminAllowed: true, writeAllowed: true, readAllowed: false },
{
operation: 'update-advanced-mode',
adminAllowed: true,
writeAllowed: true,
readAllowed: false,
},
{
operation: 'update-trigger-mode',
adminAllowed: true,
writeAllowed: true,
readAllowed: false,
},
{ operation: 'toggle-handles', adminAllowed: true, writeAllowed: true, readAllowed: false },
{
operation: 'batch-update-positions',

View File

@@ -3,56 +3,56 @@ import { workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
import {
BLOCK_OPERATIONS,
BLOCKS_OPERATIONS,
EDGE_OPERATIONS,
EDGES_OPERATIONS,
SUBFLOW_OPERATIONS,
WORKFLOW_OPERATIONS,
} from '@/socket/constants'
const logger = createLogger('SocketPermissions')
// All write operations (admin and write roles have same permissions)
const WRITE_OPERATIONS: string[] = [
// Block operations
BLOCK_OPERATIONS.UPDATE_POSITION,
BLOCK_OPERATIONS.UPDATE_NAME,
BLOCK_OPERATIONS.TOGGLE_ENABLED,
BLOCK_OPERATIONS.UPDATE_PARENT,
BLOCK_OPERATIONS.UPDATE_ADVANCED_MODE,
BLOCK_OPERATIONS.TOGGLE_HANDLES,
// Batch block operations
BLOCKS_OPERATIONS.BATCH_UPDATE_POSITIONS,
BLOCKS_OPERATIONS.BATCH_ADD_BLOCKS,
BLOCKS_OPERATIONS.BATCH_REMOVE_BLOCKS,
BLOCKS_OPERATIONS.BATCH_TOGGLE_ENABLED,
BLOCKS_OPERATIONS.BATCH_TOGGLE_HANDLES,
BLOCKS_OPERATIONS.BATCH_UPDATE_PARENT,
// Edge operations
EDGE_OPERATIONS.ADD,
EDGE_OPERATIONS.REMOVE,
// Batch edge operations
EDGES_OPERATIONS.BATCH_ADD_EDGES,
EDGES_OPERATIONS.BATCH_REMOVE_EDGES,
// Subflow operations
SUBFLOW_OPERATIONS.UPDATE,
// Workflow operations
WORKFLOW_OPERATIONS.REPLACE_STATE,
]
// Read role can only update positions (for cursor sync, etc.)
const READ_OPERATIONS: string[] = [
BLOCK_OPERATIONS.UPDATE_POSITION,
BLOCKS_OPERATIONS.BATCH_UPDATE_POSITIONS,
]
// Define operation permissions based on role
const ROLE_PERMISSIONS: Record<string, string[]> = {
admin: [
'add',
'remove',
'update',
'update-position',
'batch-update-positions',
'batch-add-blocks',
'batch-remove-blocks',
'batch-add-edges',
'batch-remove-edges',
'batch-toggle-enabled',
'batch-toggle-handles',
'batch-update-parent',
'update-name',
'toggle-enabled',
'update-parent',
'update-wide',
'update-advanced-mode',
'update-trigger-mode',
'toggle-handles',
'replace-state',
],
write: [
'add',
'remove',
'update',
'update-position',
'batch-update-positions',
'batch-add-blocks',
'batch-remove-blocks',
'batch-add-edges',
'batch-remove-edges',
'batch-toggle-enabled',
'batch-toggle-handles',
'batch-update-parent',
'update-name',
'toggle-enabled',
'update-parent',
'update-wide',
'update-advanced-mode',
'update-trigger-mode',
'toggle-handles',
'replace-state',
],
read: ['update-position', 'batch-update-positions'],
admin: WRITE_OPERATIONS,
write: WRITE_OPERATIONS,
read: READ_OPERATIONS,
}
// Check if a role allows a specific operation (no DB query, pure logic)

View File

@@ -30,9 +30,7 @@ export const BlockOperationSchema = z.object({
BLOCK_OPERATIONS.UPDATE_NAME,
BLOCK_OPERATIONS.TOGGLE_ENABLED,
BLOCK_OPERATIONS.UPDATE_PARENT,
BLOCK_OPERATIONS.UPDATE_WIDE,
BLOCK_OPERATIONS.UPDATE_ADVANCED_MODE,
BLOCK_OPERATIONS.UPDATE_TRIGGER_MODE,
BLOCK_OPERATIONS.TOGGLE_HANDLES,
]),
target: z.literal(OPERATION_TARGETS.BLOCK),
@@ -87,7 +85,7 @@ export const EdgeOperationSchema = z.object({
})
export const SubflowOperationSchema = z.object({
operation: z.enum([SUBFLOW_OPERATIONS.ADD, SUBFLOW_OPERATIONS.REMOVE, SUBFLOW_OPERATIONS.UPDATE]),
operation: z.literal(SUBFLOW_OPERATIONS.UPDATE),
target: z.literal(OPERATION_TARGETS.SUBFLOW),
payload: z.object({
id: z.string(),

View File

@@ -247,28 +247,30 @@ describe('workflow store', () => {
})
})
describe('removeBlock', () => {
describe('batchRemoveBlocks', () => {
it('should remove a block', () => {
const { addBlock, removeBlock } = useWorkflowStore.getState()
const { addBlock, batchRemoveBlocks } = useWorkflowStore.getState()
addBlock('block-1', 'function', 'Test', { x: 0, y: 0 })
removeBlock('block-1')
batchRemoveBlocks(['block-1'])
const { blocks } = useWorkflowStore.getState()
expectBlockNotExists(blocks, 'block-1')
})
it('should remove connected edges when block is removed', () => {
const { addBlock, addEdge, removeBlock } = useWorkflowStore.getState()
const { addBlock, batchAddEdges, batchRemoveBlocks } = useWorkflowStore.getState()
addBlock('block-1', 'starter', 'Start', { x: 0, y: 0 })
addBlock('block-2', 'function', 'Middle', { x: 200, y: 0 })
addBlock('block-3', 'function', 'End', { x: 400, y: 0 })
addEdge({ id: 'e1', source: 'block-1', target: 'block-2' })
addEdge({ id: 'e2', source: 'block-2', target: 'block-3' })
batchAddEdges([
{ id: 'e1', source: 'block-1', target: 'block-2' },
{ id: 'e2', source: 'block-2', target: 'block-3' },
])
removeBlock('block-2')
batchRemoveBlocks(['block-2'])
const state = useWorkflowStore.getState()
expectBlockNotExists(state.blocks, 'block-2')
@@ -276,59 +278,59 @@ describe('workflow store', () => {
})
it('should not throw when removing non-existent block', () => {
const { removeBlock } = useWorkflowStore.getState()
const { batchRemoveBlocks } = useWorkflowStore.getState()
expect(() => removeBlock('non-existent')).not.toThrow()
expect(() => batchRemoveBlocks(['non-existent'])).not.toThrow()
})
})
describe('addEdge', () => {
describe('batchAddEdges', () => {
it('should add an edge between two blocks', () => {
const { addBlock, addEdge } = useWorkflowStore.getState()
const { addBlock, batchAddEdges } = useWorkflowStore.getState()
addBlock('block-1', 'starter', 'Start', { x: 0, y: 0 })
addBlock('block-2', 'function', 'End', { x: 200, y: 0 })
addEdge({ id: 'e1', source: 'block-1', target: 'block-2' })
batchAddEdges([{ id: 'e1', source: 'block-1', target: 'block-2' }])
const { edges } = useWorkflowStore.getState()
expectEdgeConnects(edges, 'block-1', 'block-2')
})
it('should not add duplicate edges', () => {
const { addBlock, addEdge } = useWorkflowStore.getState()
const { addBlock, batchAddEdges } = useWorkflowStore.getState()
addBlock('block-1', 'starter', 'Start', { x: 0, y: 0 })
addBlock('block-2', 'function', 'End', { x: 200, y: 0 })
addEdge({ id: 'e1', source: 'block-1', target: 'block-2' })
addEdge({ id: 'e2', source: 'block-1', target: 'block-2' })
batchAddEdges([{ id: 'e1', source: 'block-1', target: 'block-2' }])
batchAddEdges([{ id: 'e2', source: 'block-1', target: 'block-2' }])
const state = useWorkflowStore.getState()
expectEdgeCount(state, 1)
})
it('should prevent self-referencing edges', () => {
const { addBlock, addEdge } = useWorkflowStore.getState()
const { addBlock, batchAddEdges } = useWorkflowStore.getState()
addBlock('block-1', 'function', 'Self', { x: 0, y: 0 })
addEdge({ id: 'e1', source: 'block-1', target: 'block-1' })
batchAddEdges([{ id: 'e1', source: 'block-1', target: 'block-1' }])
const state = useWorkflowStore.getState()
expectEdgeCount(state, 0)
})
})
describe('removeEdge', () => {
describe('batchRemoveEdges', () => {
it('should remove an edge by id', () => {
const { addBlock, addEdge, removeEdge } = useWorkflowStore.getState()
const { addBlock, batchAddEdges, batchRemoveEdges } = useWorkflowStore.getState()
addBlock('block-1', 'starter', 'Start', { x: 0, y: 0 })
addBlock('block-2', 'function', 'End', { x: 200, y: 0 })
addEdge({ id: 'e1', source: 'block-1', target: 'block-2' })
batchAddEdges([{ id: 'e1', source: 'block-1', target: 'block-2' }])
removeEdge('e1')
batchRemoveEdges(['e1'])
const state = useWorkflowStore.getState()
expectEdgeCount(state, 0)
@@ -336,19 +338,19 @@ describe('workflow store', () => {
})
it('should not throw when removing non-existent edge', () => {
const { removeEdge } = useWorkflowStore.getState()
const { batchRemoveEdges } = useWorkflowStore.getState()
expect(() => removeEdge('non-existent')).not.toThrow()
expect(() => batchRemoveEdges(['non-existent'])).not.toThrow()
})
})
describe('clear', () => {
it('should clear all blocks and edges', () => {
const { addBlock, addEdge, clear } = useWorkflowStore.getState()
const { addBlock, batchAddEdges, clear } = useWorkflowStore.getState()
addBlock('block-1', 'starter', 'Start', { x: 0, y: 0 })
addBlock('block-2', 'function', 'End', { x: 200, y: 0 })
addEdge({ id: 'e1', source: 'block-1', target: 'block-2' })
batchAddEdges([{ id: 'e1', source: 'block-1', target: 'block-2' }])
clear()
@@ -358,18 +360,18 @@ describe('workflow store', () => {
})
})
describe('toggleBlockEnabled', () => {
describe('batchToggleEnabled', () => {
it('should toggle block enabled state', () => {
const { addBlock, toggleBlockEnabled } = useWorkflowStore.getState()
const { addBlock, batchToggleEnabled } = useWorkflowStore.getState()
addBlock('block-1', 'function', 'Test', { x: 0, y: 0 })
expect(useWorkflowStore.getState().blocks['block-1'].enabled).toBe(true)
toggleBlockEnabled('block-1')
batchToggleEnabled(['block-1'])
expect(useWorkflowStore.getState().blocks['block-1'].enabled).toBe(false)
toggleBlockEnabled('block-1')
batchToggleEnabled(['block-1'])
expect(useWorkflowStore.getState().blocks['block-1'].enabled).toBe(true)
})
})
@@ -398,13 +400,13 @@ describe('workflow store', () => {
})
})
describe('updateBlockPosition', () => {
describe('batchUpdatePositions', () => {
it('should update block position', () => {
const { addBlock, updateBlockPosition } = useWorkflowStore.getState()
const { addBlock, batchUpdatePositions } = useWorkflowStore.getState()
addBlock('block-1', 'function', 'Test', { x: 0, y: 0 })
updateBlockPosition('block-1', { x: 100, y: 200 })
batchUpdatePositions([{ id: 'block-1', position: { x: 100, y: 200 } }])
const { blocks } = useWorkflowStore.getState()
expect(blocks['block-1'].position).toEqual({ x: 100, y: 200 })

View File

@@ -7,7 +7,6 @@ import { getBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
import { getBlock } from '@/blocks'
import type { SubBlockConfig } from '@/blocks/types'
import { isAnnotationOnlyBlock } from '@/executor/constants'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { getUniqueBlockName, mergeSubblockState, normalizeName } from '@/stores/workflows/utils'
@@ -239,25 +238,12 @@ export const useWorkflowStore = create<WorkflowStore>()(
get().updateLastSaved()
},
updateBlockPosition: (id: string, position: Position) => {
set((state) => ({
blocks: {
...state.blocks,
[id]: {
...state.blocks[id],
position,
},
},
}))
},
updateNodeDimensions: (id: string, dimensions: { width: number; height: number }) => {
set((state) => {
// Check if the block exists before trying to update it
const block = state.blocks[id]
if (!block) {
logger.warn(`Cannot update dimensions: Block ${id} not found in workflow store`)
return state // Return unchanged state
return state
}
return {
@@ -281,196 +267,290 @@ export const useWorkflowStore = create<WorkflowStore>()(
}
})
get().updateLastSaved()
// Note: Socket.IO handles real-time sync automatically
},
updateParentId: (id: string, parentId: string, extent: 'parent') => {
const block = get().blocks[id]
if (!block) {
logger.warn(`Cannot set parent: Block ${id} not found`)
return
batchUpdateBlocksWithParent: (
updates: Array<{
id: string
position: { x: number; y: number }
parentId?: string
}>
) => {
const currentBlocks = get().blocks
const newBlocks = { ...currentBlocks }
for (const update of updates) {
const block = newBlocks[update.id]
if (!block) continue
// Compute new data based on whether we're adding or removing a parent
let newData = block.data
if (update.parentId) {
// Adding/changing parent - set parentId and extent
newData = { ...block.data, parentId: update.parentId, extent: 'parent' as const }
} else if (block.data?.parentId) {
// Removing parent - clear parentId and extent
const { parentId: _removed, extent: _removedExtent, ...restData } = block.data
newData = restData
}
newBlocks[update.id] = {
...block,
position: update.position,
data: newData,
}
}
if (parentId === id) {
logger.error('Blocked attempt to set block as its own parent', { blockId: id })
return
set({
blocks: newBlocks,
edges: [...get().edges],
loops: generateLoopBlocks(newBlocks),
parallels: generateParallelBlocks(newBlocks),
})
},
batchUpdatePositions: (updates: Array<{ id: string; position: Position }>) => {
const newBlocks = { ...get().blocks }
for (const { id, position } of updates) {
if (newBlocks[id]) {
newBlocks[id] = { ...newBlocks[id], position }
}
}
set({ blocks: newBlocks })
},
batchAddBlocks: (
blocks: Array<{
id: string
type: string
name: string
position: Position
subBlocks: Record<string, SubBlockState>
outputs: Record<string, any>
enabled: boolean
horizontalHandles?: boolean
advancedMode?: boolean
triggerMode?: boolean
height?: number
data?: Record<string, any>
}>,
edges?: Edge[],
subBlockValues?: Record<string, Record<string, unknown>>
) => {
const currentBlocks = get().blocks
const currentEdges = get().edges
const newBlocks = { ...currentBlocks }
const newEdges = [...currentEdges]
for (const block of blocks) {
newBlocks[block.id] = {
id: block.id,
type: block.type,
name: block.name,
position: block.position,
subBlocks: block.subBlocks,
outputs: block.outputs,
enabled: block.enabled ?? true,
horizontalHandles: block.horizontalHandles ?? true,
advancedMode: block.advancedMode ?? false,
triggerMode: block.triggerMode ?? false,
height: block.height ?? 0,
data: block.data,
}
}
if (block.data?.parentId === parentId) {
return
if (edges && edges.length > 0) {
const existingEdgeIds = new Set(currentEdges.map((e) => e.id))
for (const edge of edges) {
if (!existingEdgeIds.has(edge.id)) {
newEdges.push({
id: edge.id || crypto.randomUUID(),
source: edge.source,
target: edge.target,
sourceHandle: edge.sourceHandle,
targetHandle: edge.targetHandle,
type: edge.type || 'default',
data: edge.data || {},
})
}
}
}
const absolutePosition = { ...block.position }
const newData = !parentId
? {}
: {
...block.data,
parentId,
extent,
set({
blocks: newBlocks,
edges: newEdges,
loops: generateLoopBlocks(newBlocks),
parallels: generateParallelBlocks(newBlocks),
})
if (subBlockValues && Object.keys(subBlockValues).length > 0) {
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
if (activeWorkflowId) {
const subBlockStore = useSubBlockStore.getState()
const updatedWorkflowValues = {
...(subBlockStore.workflowValues[activeWorkflowId] || {}),
}
const newState = {
blocks: {
...get().blocks,
[id]: {
...block,
position: absolutePosition,
data: newData,
},
},
edges: [...get().edges],
loops: { ...get().loops },
parallels: { ...get().parallels },
for (const [blockId, values] of Object.entries(subBlockValues)) {
updatedWorkflowValues[blockId] = {
...(updatedWorkflowValues[blockId] || {}),
...values,
}
}
useSubBlockStore.setState((state) => ({
workflowValues: {
...state.workflowValues,
[activeWorkflowId]: updatedWorkflowValues,
},
}))
}
}
set(newState)
get().updateLastSaved()
// Note: Socket.IO handles real-time sync automatically
},
removeBlock: (id: string) => {
// First, clean up any subblock values for this block
const subBlockStore = useSubBlockStore.getState()
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
batchRemoveBlocks: (ids: string[]) => {
const currentBlocks = get().blocks
const currentEdges = get().edges
const newBlocks = { ...currentBlocks }
const newState = {
blocks: { ...get().blocks },
edges: [...get().edges].filter((edge) => edge.source !== id && edge.target !== id),
loops: { ...get().loops },
parallels: { ...get().parallels },
}
const blocksToRemove = new Set<string>(ids)
// Find and remove all child blocks if this is a parent node
const blocksToRemove = new Set([id])
// Recursively find all descendant blocks (children, grandchildren, etc.)
const findAllDescendants = (parentId: string) => {
Object.entries(newState.blocks).forEach(([blockId, block]) => {
Object.entries(newBlocks).forEach(([blockId, block]) => {
if (block.data?.parentId === parentId) {
blocksToRemove.add(blockId)
// Recursively find this block's children
findAllDescendants(blockId)
}
})
}
// Start recursive search from the target block
findAllDescendants(id)
logger.info('Found blocks to remove:', {
targetId: id,
totalBlocksToRemove: Array.from(blocksToRemove),
includesHierarchy: blocksToRemove.size > 1,
})
// Clean up subblock values before removing the block
if (activeWorkflowId && subBlockStore.workflowValues) {
const updatedWorkflowValues = {
...(subBlockStore.workflowValues[activeWorkflowId] || {}),
}
// Remove values for all blocks being deleted
blocksToRemove.forEach((blockId) => {
delete updatedWorkflowValues[blockId]
})
// Update subblock store
useSubBlockStore.setState((state) => ({
workflowValues: {
...state.workflowValues,
[activeWorkflowId]: updatedWorkflowValues,
},
}))
for (const id of ids) {
findAllDescendants(id)
}
// Remove all edges connected to any of the blocks being removed
newState.edges = newState.edges.filter(
const newEdges = currentEdges.filter(
(edge) => !blocksToRemove.has(edge.source) && !blocksToRemove.has(edge.target)
)
// Delete all blocks marked for removal
blocksToRemove.forEach((blockId) => {
delete newState.blocks[blockId]
delete newBlocks[blockId]
})
set(newState)
get().updateLastSaved()
// Note: Socket.IO handles real-time sync automatically
},
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
if (activeWorkflowId) {
const subBlockStore = useSubBlockStore.getState()
if (subBlockStore.workflowValues[activeWorkflowId]) {
const updatedWorkflowValues = {
...subBlockStore.workflowValues[activeWorkflowId],
}
addEdge: (edge: Edge) => {
// Prevent connections to/from annotation-only blocks (non-executable)
const sourceBlock = get().blocks[edge.source]
const targetBlock = get().blocks[edge.target]
blocksToRemove.forEach((blockId) => {
delete updatedWorkflowValues[blockId]
})
if (isAnnotationOnlyBlock(sourceBlock?.type) || isAnnotationOnlyBlock(targetBlock?.type)) {
return
useSubBlockStore.setState((state) => ({
workflowValues: {
...state.workflowValues,
[activeWorkflowId]: updatedWorkflowValues,
},
}))
}
}
// Prevent self-connections and cycles
if (wouldCreateCycle(get().edges, edge.source, edge.target)) {
logger.warn('Prevented edge that would create a cycle', {
set({
blocks: newBlocks,
edges: newEdges,
loops: generateLoopBlocks(newBlocks),
parallels: generateParallelBlocks(newBlocks),
})
get().updateLastSaved()
},
batchToggleEnabled: (ids: string[]) => {
const newBlocks = { ...get().blocks }
for (const id of ids) {
if (newBlocks[id]) {
newBlocks[id] = { ...newBlocks[id], enabled: !newBlocks[id].enabled }
}
}
set({ blocks: newBlocks, edges: [...get().edges] })
get().updateLastSaved()
},
batchToggleHandles: (ids: string[]) => {
const newBlocks = { ...get().blocks }
for (const id of ids) {
if (newBlocks[id]) {
newBlocks[id] = {
...newBlocks[id],
horizontalHandles: !newBlocks[id].horizontalHandles,
}
}
}
set({ blocks: newBlocks, edges: [...get().edges] })
get().updateLastSaved()
},
batchAddEdges: (edges: Edge[]) => {
const currentEdges = get().edges
const newEdges = [...currentEdges]
const existingEdgeIds = new Set(currentEdges.map((e) => e.id))
// Track existing connections to prevent duplicates (same source->target)
const existingConnections = new Set(currentEdges.map((e) => `${e.source}->${e.target}`))
for (const edge of edges) {
// Skip if edge ID already exists
if (existingEdgeIds.has(edge.id)) continue
// Skip self-referencing edges
if (edge.source === edge.target) continue
// Skip if connection already exists (same source and target)
const connectionKey = `${edge.source}->${edge.target}`
if (existingConnections.has(connectionKey)) continue
// Skip if would create a cycle
if (wouldCreateCycle([...newEdges], edge.source, edge.target)) continue
newEdges.push({
id: edge.id || crypto.randomUUID(),
source: edge.source,
target: edge.target,
sourceHandle: edge.sourceHandle,
targetHandle: edge.targetHandle,
type: edge.type || 'default',
data: edge.data || {},
})
return
existingEdgeIds.add(edge.id)
existingConnections.add(connectionKey)
}
// Check for duplicate connections
const isDuplicate = get().edges.some(
(existingEdge) =>
existingEdge.source === edge.source &&
existingEdge.target === edge.target &&
existingEdge.sourceHandle === edge.sourceHandle &&
existingEdge.targetHandle === edge.targetHandle
)
// If it's a duplicate connection, return early without adding the edge
if (isDuplicate) {
return
}
const newEdge: Edge = {
id: edge.id || crypto.randomUUID(),
source: edge.source,
target: edge.target,
sourceHandle: edge.sourceHandle,
targetHandle: edge.targetHandle,
type: edge.type || 'default',
data: edge.data || {},
}
const newEdges = [...get().edges, newEdge]
const newState = {
blocks: { ...get().blocks },
const blocks = get().blocks
set({
blocks: { ...blocks },
edges: newEdges,
loops: generateLoopBlocks(get().blocks),
parallels: get().generateParallelBlocks(),
}
loops: generateLoopBlocks(blocks),
parallels: generateParallelBlocks(blocks),
})
set(newState)
get().updateLastSaved()
},
removeEdge: (edgeId: string) => {
// Validate the edge exists
const edgeToRemove = get().edges.find((edge) => edge.id === edgeId)
if (!edgeToRemove) {
logger.warn(`Attempted to remove non-existent edge: ${edgeId}`)
return
}
batchRemoveEdges: (ids: string[]) => {
const idsSet = new Set(ids)
const newEdges = get().edges.filter((e) => !idsSet.has(e.id))
const blocks = get().blocks
const newEdges = get().edges.filter((edge) => edge.id !== edgeId)
const newState = {
blocks: { ...get().blocks },
set({
blocks: { ...blocks },
edges: newEdges,
loops: generateLoopBlocks(get().blocks),
parallels: get().generateParallelBlocks(),
}
loops: generateLoopBlocks(blocks),
parallels: generateParallelBlocks(blocks),
})
set(newState)
get().updateLastSaved()
},
@@ -483,16 +563,13 @@ export const useWorkflowStore = create<WorkflowStore>()(
lastSaved: Date.now(),
}
set(newState)
// Note: Socket.IO handles real-time sync automatically
return newState
},
updateLastSaved: () => {
set({ lastSaved: Date.now() })
// Note: Socket.IO handles real-time sync automatically
},
// Add method to get current workflow state (eliminates duplication in diff store)
getWorkflowState: (): WorkflowState => {
const state = get()
return {
@@ -540,25 +617,6 @@ export const useWorkflowStore = create<WorkflowStore>()(
})
},
toggleBlockEnabled: (id: string) => {
const newState = {
blocks: {
...get().blocks,
[id]: {
...get().blocks[id],
enabled: !get().blocks[id].enabled,
},
},
edges: [...get().edges],
loops: { ...get().loops },
parallels: { ...get().parallels },
}
set(newState)
get().updateLastSaved()
// Note: Socket.IO handles real-time sync automatically
},
setBlockEnabled: (id: string, enabled: boolean) => {
const block = get().blocks[id]
if (!block || block.enabled === enabled) return
@@ -592,10 +650,8 @@ export const useWorkflowStore = create<WorkflowStore>()(
const newName = getUniqueBlockName(block.name, get().blocks)
// Get merged state to capture current subblock values
const mergedBlock = mergeSubblockState(get().blocks, id)[id]
// Create new subblocks with merged values
const newSubBlocks = Object.entries(mergedBlock.subBlocks).reduce(
(acc, [subId, subBlock]) => ({
...acc,
@@ -623,7 +679,6 @@ export const useWorkflowStore = create<WorkflowStore>()(
parallels: get().generateParallelBlocks(),
}
// Update the subblock store with the duplicated values
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
if (activeWorkflowId) {
const subBlockValues =
@@ -641,25 +696,6 @@ export const useWorkflowStore = create<WorkflowStore>()(
set(newState)
get().updateLastSaved()
// Note: Socket.IO handles real-time sync automatically
},
toggleBlockHandles: (id: string) => {
const newState = {
blocks: {
...get().blocks,
[id]: {
...get().blocks[id],
horizontalHandles: !get().blocks[id].horizontalHandles,
},
},
edges: [...get().edges],
loops: { ...get().loops },
}
set(newState)
get().updateLastSaved()
// Note: Socket.IO handles real-time sync automatically
},
setBlockHandles: (id: string, horizontalHandles: boolean) => {
@@ -705,7 +741,6 @@ export const useWorkflowStore = create<WorkflowStore>()(
return { success: false, changedSubblocks: [] }
}
// Create a new state with the updated block name
const newState = {
blocks: {
...get().blocks,

View File

@@ -186,18 +186,29 @@ export interface WorkflowActions {
height?: number
}
) => void
updateBlockPosition: (id: string, position: Position) => void
updateNodeDimensions: (id: string, dimensions: { width: number; height: number }) => void
updateParentId: (id: string, parentId: string, extent: 'parent') => void
removeBlock: (id: string) => void
addEdge: (edge: Edge) => void
removeEdge: (edgeId: string) => void
batchUpdateBlocksWithParent: (
updates: Array<{
id: string
position: { x: number; y: number }
parentId?: string
}>
) => void
batchUpdatePositions: (updates: Array<{ id: string; position: Position }>) => void
batchAddBlocks: (
blocks: BlockState[],
edges?: Edge[],
subBlockValues?: Record<string, Record<string, unknown>>
) => void
batchRemoveBlocks: (ids: string[]) => void
batchToggleEnabled: (ids: string[]) => void
batchToggleHandles: (ids: string[]) => void
batchAddEdges: (edges: Edge[]) => void
batchRemoveEdges: (ids: string[]) => void
clear: () => Partial<WorkflowState>
updateLastSaved: () => void
toggleBlockEnabled: (id: string) => void
setBlockEnabled: (id: string, enabled: boolean) => void
duplicateBlock: (id: string) => void
toggleBlockHandles: (id: string) => void
setBlockHandles: (id: string, horizontalHandles: boolean) => void
updateBlockName: (
id: string,

View File

@@ -252,24 +252,54 @@ export function createWorkflowAccessContext(options: {
}
/**
* All socket operations that can be performed.
* Socket operations
*/
const BLOCK_OPERATIONS = {
UPDATE_POSITION: 'update-position',
UPDATE_NAME: 'update-name',
TOGGLE_ENABLED: 'toggle-enabled',
UPDATE_PARENT: 'update-parent',
UPDATE_ADVANCED_MODE: 'update-advanced-mode',
TOGGLE_HANDLES: 'toggle-handles',
} as const
const BLOCKS_OPERATIONS = {
BATCH_UPDATE_POSITIONS: 'batch-update-positions',
BATCH_ADD_BLOCKS: 'batch-add-blocks',
BATCH_REMOVE_BLOCKS: 'batch-remove-blocks',
BATCH_TOGGLE_ENABLED: 'batch-toggle-enabled',
BATCH_TOGGLE_HANDLES: 'batch-toggle-handles',
BATCH_UPDATE_PARENT: 'batch-update-parent',
} as const
const EDGE_OPERATIONS = {
ADD: 'add',
REMOVE: 'remove',
} as const
const EDGES_OPERATIONS = {
BATCH_ADD_EDGES: 'batch-add-edges',
BATCH_REMOVE_EDGES: 'batch-remove-edges',
} as const
const SUBFLOW_OPERATIONS = {
UPDATE: 'update',
} as const
const WORKFLOW_OPERATIONS = {
REPLACE_STATE: 'replace-state',
} as const
/**
* All socket operations that require permission checks.
*/
export const SOCKET_OPERATIONS = [
'add',
'remove',
'batch-add-blocks',
'batch-remove-blocks',
'update',
'update-position',
'update-name',
'toggle-enabled',
'update-parent',
'update-wide',
'update-advanced-mode',
'update-trigger-mode',
'toggle-handles',
'batch-update-positions',
'replace-state',
...Object.values(BLOCK_OPERATIONS),
...Object.values(BLOCKS_OPERATIONS),
...Object.values(EDGE_OPERATIONS),
...Object.values(EDGES_OPERATIONS),
...Object.values(SUBFLOW_OPERATIONS),
...Object.values(WORKFLOW_OPERATIONS),
] as const
export type SocketOperation = (typeof SOCKET_OPERATIONS)[number]
@@ -277,10 +307,10 @@ export type SocketOperation = (typeof SOCKET_OPERATIONS)[number]
/**
* Operations allowed for each role.
*/
export const ROLE_ALLOWED_OPERATIONS: Record<PermissionType, SocketOperation[]> = {
admin: [...SOCKET_OPERATIONS],
write: [...SOCKET_OPERATIONS],
read: ['update-position', 'batch-update-positions'],
export const ROLE_ALLOWED_OPERATIONS: Record<PermissionType, readonly SocketOperation[]> = {
admin: SOCKET_OPERATIONS,
write: SOCKET_OPERATIONS,
read: [BLOCK_OPERATIONS.UPDATE_POSITION, BLOCKS_OPERATIONS.BATCH_UPDATE_POSITIONS],
}
/**