mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
fix(subflows): subflow-child selection issues, subflow error logs (#3656)
* fix(subflows): subflow-child selection issues, subflow error logs * address comments * make selection context more reliable * fix more multiselect issues * fix shift selection ordering to work correctly * fix more comments * address more comments * reuse helper
This commit is contained in:
+8
-7
@@ -168,7 +168,7 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
|
||||
<ActionBar blockId={id} blockType={data.kind} disabled={!userPermissions.canEdit} />
|
||||
)}
|
||||
|
||||
{/* Header Section — only interactive area for dragging */}
|
||||
{/* Header Section */}
|
||||
<div
|
||||
onClick={() => setCurrentBlockId(id)}
|
||||
className='workflow-drag-handle flex cursor-grab items-center justify-between rounded-t-[8px] border-[var(--border)] border-b bg-[var(--surface-2)] py-[8px] pr-[12px] pl-[8px] [&:active]:cursor-grabbing'
|
||||
@@ -198,14 +198,15 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
|
||||
</div>
|
||||
|
||||
{/*
|
||||
* Subflow body background. Uses pointer-events: none so that edges rendered
|
||||
* inside the subflow remain clickable. The subflow node wrapper also has
|
||||
* pointer-events: none (set in workflow.tsx), so body-area clicks pass
|
||||
* through to the pane. Subflow selection is done via the header above.
|
||||
* Subflow body background. Captures clicks to select the subflow in the
|
||||
* panel editor, matching the header click behavior. Child nodes and edges
|
||||
* are rendered as sibling divs at the viewport level by ReactFlow (not as
|
||||
* DOM children), so enabling pointer events here doesn't block them.
|
||||
*/}
|
||||
<div
|
||||
className='absolute inset-0 top-[44px] rounded-b-[8px]'
|
||||
style={{ pointerEvents: 'none' }}
|
||||
className='workflow-drag-handle absolute inset-0 top-[44px] cursor-grab rounded-b-[8px] [&:active]:cursor-grabbing'
|
||||
style={{ pointerEvents: isPreview ? 'none' : 'auto' }}
|
||||
onClick={() => setCurrentBlockId(id)}
|
||||
/>
|
||||
|
||||
{!isPreview && (
|
||||
|
||||
@@ -221,3 +221,68 @@ export function resolveParentChildSelectionConflicts(
|
||||
|
||||
return hasConflict ? resolved : nodes
|
||||
}
|
||||
|
||||
export function getNodeSelectionContextId(
|
||||
node: Pick<Node, 'id' | 'parentId'>,
|
||||
blocks: Record<string, { data?: { parentId?: string } }>
|
||||
): string | null {
|
||||
return node.parentId || blocks[node.id]?.data?.parentId || null
|
||||
}
|
||||
|
||||
export function getEdgeSelectionContextId(
|
||||
edge: Pick<Edge, 'source' | 'target'>,
|
||||
nodes: Array<Pick<Node, 'id' | 'parentId'>>,
|
||||
blocks: Record<string, { data?: { parentId?: string } }>
|
||||
): string | null {
|
||||
const sourceNode = nodes.find((node) => node.id === edge.source)
|
||||
const targetNode = nodes.find((node) => node.id === edge.target)
|
||||
const sourceContextId = sourceNode ? getNodeSelectionContextId(sourceNode, blocks) : null
|
||||
const targetContextId = targetNode ? getNodeSelectionContextId(targetNode, blocks) : null
|
||||
if (sourceContextId) return sourceContextId
|
||||
if (targetContextId) return targetContextId
|
||||
return null
|
||||
}
|
||||
|
||||
export function resolveSelectionContextConflicts(
|
||||
nodes: Node[],
|
||||
blocks: Record<string, { data?: { parentId?: string } }>,
|
||||
preferredContextId?: string | null
|
||||
): Node[] {
|
||||
const selectedNodes = nodes.filter((node) => node.selected)
|
||||
if (selectedNodes.length <= 1) return nodes
|
||||
|
||||
const allowedContextId =
|
||||
preferredContextId !== undefined
|
||||
? preferredContextId
|
||||
: getNodeSelectionContextId(selectedNodes[0], blocks)
|
||||
let hasConflict = false
|
||||
|
||||
const resolved = nodes.map((node) => {
|
||||
if (!node.selected) return node
|
||||
const contextId = getNodeSelectionContextId(node, blocks)
|
||||
if (contextId !== allowedContextId) {
|
||||
hasConflict = true
|
||||
return { ...node, selected: false }
|
||||
}
|
||||
return node
|
||||
})
|
||||
|
||||
return hasConflict ? resolved : nodes
|
||||
}
|
||||
|
||||
export function resolveSelectionConflicts(
|
||||
nodes: Node[],
|
||||
blocks: Record<string, { data?: { parentId?: string } }>,
|
||||
preferredNodeId?: string
|
||||
): Node[] {
|
||||
const afterParentChild = resolveParentChildSelectionConflicts(nodes, blocks)
|
||||
|
||||
const preferredContextId =
|
||||
preferredNodeId !== undefined
|
||||
? afterParentChild.find((n) => n.id === preferredNodeId && n.selected)
|
||||
? getNodeSelectionContextId(afterParentChild.find((n) => n.id === preferredNodeId)!, blocks)
|
||||
: undefined
|
||||
: undefined
|
||||
|
||||
return resolveSelectionContextConflicts(afterParentChild, blocks, preferredContextId)
|
||||
}
|
||||
|
||||
@@ -59,11 +59,13 @@ import {
|
||||
filterProtectedBlocks,
|
||||
getClampedPositionForNode,
|
||||
getDescendantBlockIds,
|
||||
getEdgeSelectionContextId,
|
||||
getNodeSelectionContextId,
|
||||
getWorkflowLockToggleIds,
|
||||
isBlockProtected,
|
||||
isEdgeProtected,
|
||||
isInEditableElement,
|
||||
resolveParentChildSelectionConflicts,
|
||||
resolveSelectionConflicts,
|
||||
validateTriggerPaste,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
|
||||
import { useSocket } from '@/app/workspace/providers/socket-provider'
|
||||
@@ -168,16 +170,17 @@ function mapEdgesByNode(edges: Edge[], nodeIds: Set<string>): Map<string, Edge[]
|
||||
|
||||
/**
|
||||
* Syncs the panel editor with the current selection state.
|
||||
* Shows block details when exactly one block is selected, clears otherwise.
|
||||
* Shows the last selected block in the panel. Clears when nothing is selected.
|
||||
*/
|
||||
function syncPanelWithSelection(selectedIds: string[]) {
|
||||
const { currentBlockId, clearCurrentBlock, setCurrentBlockId } = usePanelEditorStore.getState()
|
||||
if (selectedIds.length === 1 && selectedIds[0] !== currentBlockId) {
|
||||
setCurrentBlockId(selectedIds[0])
|
||||
} else if (selectedIds.length === 0 && currentBlockId) {
|
||||
clearCurrentBlock()
|
||||
} else if (selectedIds.length > 1 && currentBlockId) {
|
||||
clearCurrentBlock()
|
||||
if (selectedIds.length === 0) {
|
||||
if (currentBlockId) clearCurrentBlock()
|
||||
} else {
|
||||
const lastSelectedId = selectedIds[selectedIds.length - 1]
|
||||
if (lastSelectedId !== currentBlockId) {
|
||||
setCurrentBlockId(lastSelectedId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,7 +249,6 @@ const WorkflowContent = React.memo(
|
||||
const [selectedEdges, setSelectedEdges] = useState<SelectedEdgesMap>(new Map())
|
||||
const [isErrorConnectionDrag, setIsErrorConnectionDrag] = useState(false)
|
||||
const canvasContainerRef = useRef<HTMLDivElement>(null)
|
||||
const selectedIdsRef = useRef<string[] | null>(null)
|
||||
const embeddedFitFrameRef = useRef<number | null>(null)
|
||||
const hasCompletedInitialEmbeddedFitRef = useRef(false)
|
||||
const canvasMode = useCanvasModeStore((state) => state.mode)
|
||||
@@ -2477,6 +2479,16 @@ const WorkflowContent = React.memo(
|
||||
// Local state for nodes - allows smooth drag without store updates on every frame
|
||||
const [displayNodes, setDisplayNodes] = useState<Node[]>([])
|
||||
|
||||
const selectedNodeIds = useMemo(
|
||||
() => displayNodes.filter((node) => node.selected).map((node) => node.id),
|
||||
[displayNodes]
|
||||
)
|
||||
const selectedNodeIdsKey = selectedNodeIds.join(',')
|
||||
|
||||
useEffect(() => {
|
||||
syncPanelWithSelection(selectedNodeIds)
|
||||
}, [selectedNodeIdsKey])
|
||||
|
||||
useEffect(() => {
|
||||
// Check for pending selection (from paste/duplicate), otherwise preserve existing selection
|
||||
if (pendingSelection && pendingSelection.length > 0) {
|
||||
@@ -2488,10 +2500,8 @@ const WorkflowContent = React.memo(
|
||||
...node,
|
||||
selected: pendingSet.has(node.id),
|
||||
}))
|
||||
const resolved = resolveParentChildSelectionConflicts(withSelection, blocks)
|
||||
const resolved = resolveSelectionConflicts(withSelection, blocks)
|
||||
setDisplayNodes(resolved)
|
||||
const selectedIds = resolved.filter((node) => node.selected).map((node) => node.id)
|
||||
syncPanelWithSelection(selectedIds)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2709,19 +2719,20 @@ const WorkflowContent = React.memo(
|
||||
/** Handles node changes - applies changes and resolves parent-child selection conflicts. */
|
||||
const onNodesChange = useCallback(
|
||||
(changes: NodeChange[]) => {
|
||||
selectedIdsRef.current = null
|
||||
setDisplayNodes((nds) => {
|
||||
const updated = applyNodeChanges(changes, nds)
|
||||
const hasSelectionChange = changes.some((c) => c.type === 'select')
|
||||
const hasSelectionChange = changes.some((c) => c.type === 'select')
|
||||
setDisplayNodes((currentNodes) => {
|
||||
const updated = applyNodeChanges(changes, currentNodes)
|
||||
if (!hasSelectionChange) return updated
|
||||
const resolved = resolveParentChildSelectionConflicts(updated, blocks)
|
||||
selectedIdsRef.current = resolved.filter((node) => node.selected).map((node) => node.id)
|
||||
return resolved
|
||||
|
||||
const preferredNodeId = [...changes]
|
||||
.reverse()
|
||||
.find(
|
||||
(change): change is NodeChange & { id: string; selected: boolean } =>
|
||||
change.type === 'select' && 'selected' in change && change.selected === true
|
||||
)?.id
|
||||
|
||||
return resolveSelectionConflicts(updated, blocks, preferredNodeId)
|
||||
})
|
||||
const selectedIds = selectedIdsRef.current as string[] | null
|
||||
if (selectedIds !== null) {
|
||||
syncPanelWithSelection(selectedIds)
|
||||
}
|
||||
|
||||
// Handle position changes (e.g., from keyboard arrow key movement)
|
||||
// Update container dimensions when child nodes are moved and persist to backend
|
||||
@@ -3160,7 +3171,10 @@ const WorkflowContent = React.memo(
|
||||
parentId: currentParentId,
|
||||
})
|
||||
|
||||
// Capture all selected nodes' positions for multi-node undo/redo
|
||||
// Capture all selected nodes' positions for multi-node undo/redo.
|
||||
// Also include the dragged node itself — during shift+click+drag, ReactFlow
|
||||
// may have toggled (deselected) the node before drag starts, so it might not
|
||||
// appear in the selected set yet.
|
||||
const allNodes = getNodes()
|
||||
const selectedNodes = allNodes.filter((n) => n.selected)
|
||||
multiNodeDragStartRef.current.clear()
|
||||
@@ -3174,6 +3188,33 @@ const WorkflowContent = React.memo(
|
||||
})
|
||||
}
|
||||
})
|
||||
if (!multiNodeDragStartRef.current.has(node.id)) {
|
||||
multiNodeDragStartRef.current.set(node.id, {
|
||||
x: node.position.x,
|
||||
y: node.position.y,
|
||||
parentId: currentParentId ?? undefined,
|
||||
})
|
||||
}
|
||||
|
||||
// When shift+clicking an already-selected node, ReactFlow toggles (deselects)
|
||||
// it via onNodesChange before drag starts. Re-select the dragged node so all
|
||||
// previously selected nodes move together as a group — but only if the
|
||||
// deselection wasn't from a parent-child conflict (e.g. dragging a child
|
||||
// when its parent subflow is selected).
|
||||
const draggedNodeInSelected = allNodes.find((n) => n.id === node.id)
|
||||
if (draggedNodeInSelected && !draggedNodeInSelected.selected && selectedNodes.length > 0) {
|
||||
const draggedParentId = blocks[node.id]?.data?.parentId
|
||||
const parentIsSelected =
|
||||
draggedParentId && selectedNodes.some((n) => n.id === draggedParentId)
|
||||
const contextMismatch =
|
||||
getNodeSelectionContextId(draggedNodeInSelected, blocks) !==
|
||||
getNodeSelectionContextId(selectedNodes[0], blocks)
|
||||
if (!parentIsSelected && !contextMismatch) {
|
||||
setDisplayNodes((currentNodes) =>
|
||||
currentNodes.map((n) => (n.id === node.id ? { ...n, selected: true } : n))
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
[blocks, setDragStartPosition, getNodes, setPotentialParentId]
|
||||
)
|
||||
@@ -3453,7 +3494,7 @@ const WorkflowContent = React.memo(
|
||||
})
|
||||
|
||||
// Apply visual deselection of children
|
||||
setDisplayNodes((allNodes) => resolveParentChildSelectionConflicts(allNodes, blocks))
|
||||
setDisplayNodes((allNodes) => resolveSelectionConflicts(allNodes, blocks))
|
||||
},
|
||||
[blocks]
|
||||
)
|
||||
@@ -3604,19 +3645,25 @@ const WorkflowContent = React.memo(
|
||||
|
||||
/**
|
||||
* Handles node click to select the node in ReactFlow.
|
||||
* Parent-child conflict resolution happens automatically in onNodesChange.
|
||||
* Uses the controlled display node state so parent-child conflicts are resolved
|
||||
* consistently for click, shift-click, and marquee selection.
|
||||
*/
|
||||
const handleNodeClick = useCallback(
|
||||
(event: React.MouseEvent, node: Node) => {
|
||||
const isMultiSelect = event.shiftKey || event.metaKey || event.ctrlKey
|
||||
setNodes((nodes) =>
|
||||
nodes.map((n) => ({
|
||||
...n,
|
||||
selected: isMultiSelect ? (n.id === node.id ? true : n.selected) : n.id === node.id,
|
||||
setDisplayNodes((currentNodes) => {
|
||||
const updated = currentNodes.map((currentNode) => ({
|
||||
...currentNode,
|
||||
selected: isMultiSelect
|
||||
? currentNode.id === node.id
|
||||
? true
|
||||
: currentNode.selected
|
||||
: currentNode.id === node.id,
|
||||
}))
|
||||
)
|
||||
return resolveSelectionConflicts(updated, blocks, isMultiSelect ? node.id : undefined)
|
||||
})
|
||||
},
|
||||
[setNodes]
|
||||
[blocks]
|
||||
)
|
||||
|
||||
/** Handles edge selection with container context tracking and Shift-click multi-selection. */
|
||||
@@ -3624,16 +3671,10 @@ const WorkflowContent = React.memo(
|
||||
(event: React.MouseEvent, edge: any) => {
|
||||
event.stopPropagation() // Prevent bubbling
|
||||
|
||||
// Determine if edge is inside a loop by checking its source/target nodes
|
||||
const sourceNode = getNodes().find((n) => n.id === edge.source)
|
||||
const targetNode = getNodes().find((n) => n.id === edge.target)
|
||||
|
||||
// An edge is inside a loop if either source or target has a parent
|
||||
// If source and target have different parents, prioritize source's parent
|
||||
const parentLoopId = sourceNode?.parentId || targetNode?.parentId
|
||||
|
||||
// Create a unique identifier that combines edge ID and parent context
|
||||
const contextId = `${edge.id}${parentLoopId ? `-${parentLoopId}` : ''}`
|
||||
const contextId = `${edge.id}${(() => {
|
||||
const selectionContextId = getEdgeSelectionContextId(edge, getNodes(), blocks)
|
||||
return selectionContextId ? `-${selectionContextId}` : ''
|
||||
})()}`
|
||||
|
||||
if (event.shiftKey) {
|
||||
// Shift-click: toggle edge in selection
|
||||
@@ -3651,7 +3692,7 @@ const WorkflowContent = React.memo(
|
||||
setSelectedEdges(new Map([[contextId, edge.id]]))
|
||||
}
|
||||
},
|
||||
[getNodes]
|
||||
[blocks, getNodes]
|
||||
)
|
||||
|
||||
/** Stable delete handler to avoid creating new function references per edge. */
|
||||
|
||||
@@ -92,6 +92,57 @@ describe('DAGBuilder disabled subflow validation', () => {
|
||||
// Should not throw - loop is effectively disabled since all inner blocks are disabled
|
||||
expect(() => builder.build(workflow)).not.toThrow()
|
||||
})
|
||||
|
||||
it('does not mutate serialized loop config nodes during DAG build', () => {
|
||||
const workflow: SerializedWorkflow = {
|
||||
version: '1',
|
||||
blocks: [
|
||||
createBlock('start', BlockType.STARTER),
|
||||
createBlock('loop-1', BlockType.LOOP),
|
||||
{ ...createBlock('inner-block', BlockType.FUNCTION), enabled: false },
|
||||
],
|
||||
connections: [{ source: 'start', target: 'loop-1' }],
|
||||
loops: {
|
||||
'loop-1': {
|
||||
id: 'loop-1',
|
||||
nodes: ['inner-block'],
|
||||
iterations: 3,
|
||||
},
|
||||
},
|
||||
parallels: {},
|
||||
}
|
||||
|
||||
const builder = new DAGBuilder()
|
||||
builder.build(workflow)
|
||||
|
||||
expect(workflow.loops?.['loop-1']?.nodes).toEqual(['inner-block'])
|
||||
})
|
||||
|
||||
it('does not mutate serialized parallel config nodes during DAG build', () => {
|
||||
const workflow: SerializedWorkflow = {
|
||||
version: '1',
|
||||
blocks: [
|
||||
createBlock('start', BlockType.STARTER),
|
||||
createBlock('parallel-1', BlockType.PARALLEL),
|
||||
{ ...createBlock('inner-block', BlockType.FUNCTION), enabled: false },
|
||||
],
|
||||
connections: [{ source: 'start', target: 'parallel-1' }],
|
||||
loops: {},
|
||||
parallels: {
|
||||
'parallel-1': {
|
||||
id: 'parallel-1',
|
||||
nodes: ['inner-block'],
|
||||
count: 2,
|
||||
parallelType: 'count',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const builder = new DAGBuilder()
|
||||
builder.build(workflow)
|
||||
|
||||
expect(workflow.parallels?.['parallel-1']?.nodes).toEqual(['inner-block'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('DAGBuilder nested parallel support', () => {
|
||||
|
||||
@@ -113,13 +113,19 @@ export class DAGBuilder {
|
||||
private initializeConfigs(workflow: SerializedWorkflow, dag: DAG): void {
|
||||
if (workflow.loops) {
|
||||
for (const [loopId, loopConfig] of Object.entries(workflow.loops)) {
|
||||
dag.loopConfigs.set(loopId, loopConfig)
|
||||
dag.loopConfigs.set(loopId, {
|
||||
...loopConfig,
|
||||
nodes: [...(loopConfig.nodes ?? [])],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (workflow.parallels) {
|
||||
for (const [parallelId, parallelConfig] of Object.entries(workflow.parallels)) {
|
||||
dag.parallelConfigs.set(parallelId, parallelConfig)
|
||||
dag.parallelConfigs.set(parallelId, {
|
||||
...parallelConfig,
|
||||
nodes: [...(parallelConfig.nodes ?? [])],
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -150,9 +156,7 @@ export class DAGBuilder {
|
||||
if (!sentinelStartNode) return
|
||||
|
||||
if (!nodes || nodes.length === 0) {
|
||||
throw new Error(
|
||||
`${type} has no blocks inside. Add at least one block to the ${type.toLowerCase()}.`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const hasConnections = Array.from(sentinelStartNode.outgoingEdges.values()).some((edge) =>
|
||||
|
||||
@@ -8,24 +8,21 @@ const logger = createLogger('LoopConstructor')
|
||||
export class LoopConstructor {
|
||||
execute(dag: DAG, reachableBlocks: Set<string>): void {
|
||||
for (const [loopId, loopConfig] of dag.loopConfigs) {
|
||||
const loopNodes = loopConfig.nodes
|
||||
|
||||
if (loopNodes.length === 0) {
|
||||
if (!reachableBlocks.has(loopId)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!this.hasReachableNodes(loopNodes, reachableBlocks)) {
|
||||
continue
|
||||
const loopNodes = loopConfig.nodes
|
||||
const hasReachableChildren = loopNodes.some((nodeId) => reachableBlocks.has(nodeId))
|
||||
|
||||
if (!hasReachableChildren) {
|
||||
loopConfig.nodes = []
|
||||
}
|
||||
|
||||
this.createSentinelPair(dag, loopId)
|
||||
}
|
||||
}
|
||||
|
||||
private hasReachableNodes(loopNodes: string[], reachableBlocks: Set<string>): boolean {
|
||||
return loopNodes.some((nodeId) => reachableBlocks.has(nodeId))
|
||||
}
|
||||
|
||||
private createSentinelPair(dag: DAG, loopId: string): void {
|
||||
const startId = buildSentinelStartId(loopId)
|
||||
const endId = buildSentinelEndId(loopId)
|
||||
|
||||
@@ -11,24 +11,21 @@ const logger = createLogger('ParallelConstructor')
|
||||
export class ParallelConstructor {
|
||||
execute(dag: DAG, reachableBlocks: Set<string>): void {
|
||||
for (const [parallelId, parallelConfig] of dag.parallelConfigs) {
|
||||
const parallelNodes = parallelConfig.nodes
|
||||
|
||||
if (parallelNodes.length === 0) {
|
||||
if (!reachableBlocks.has(parallelId)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!this.hasReachableNodes(parallelNodes, reachableBlocks)) {
|
||||
continue
|
||||
const parallelNodes = parallelConfig.nodes
|
||||
const hasReachableChildren = parallelNodes.some((nodeId) => reachableBlocks.has(nodeId))
|
||||
|
||||
if (!hasReachableChildren) {
|
||||
parallelConfig.nodes = []
|
||||
}
|
||||
|
||||
this.createSentinelPair(dag, parallelId)
|
||||
}
|
||||
}
|
||||
|
||||
private hasReachableNodes(parallelNodes: string[], reachableBlocks: Set<string>): boolean {
|
||||
return parallelNodes.some((nodeId) => reachableBlocks.has(nodeId))
|
||||
}
|
||||
|
||||
private createSentinelPair(dag: DAG, parallelId: string): void {
|
||||
const startId = buildParallelSentinelStartId(parallelId)
|
||||
const endId = buildParallelSentinelEndId(parallelId)
|
||||
|
||||
@@ -56,6 +56,29 @@ export class LoopOrchestrator {
|
||||
if (!loopConfig) {
|
||||
throw new Error(`Loop config not found: ${loopId}`)
|
||||
}
|
||||
|
||||
if (loopConfig.nodes.length === 0) {
|
||||
const errorMessage =
|
||||
'Loop has no executable blocks inside. Add or enable at least one block in the loop.'
|
||||
const loopType = loopConfig.loopType || 'for'
|
||||
logger.error(errorMessage, { loopId })
|
||||
await this.addLoopErrorLog(ctx, loopId, loopType, errorMessage, {})
|
||||
const errorScope: LoopScope = {
|
||||
iteration: 0,
|
||||
maxIterations: 0,
|
||||
loopType,
|
||||
currentIterationOutputs: new Map(),
|
||||
allIterationOutputs: [],
|
||||
condition: 'false',
|
||||
validationError: errorMessage,
|
||||
}
|
||||
if (!ctx.loopExecutions) {
|
||||
ctx.loopExecutions = new Map()
|
||||
}
|
||||
ctx.loopExecutions.set(loopId, errorScope)
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
|
||||
const scope: LoopScope = {
|
||||
iteration: 0,
|
||||
currentIterationOutputs: new Map(),
|
||||
@@ -93,6 +116,24 @@ export class LoopOrchestrator {
|
||||
|
||||
case 'forEach': {
|
||||
scope.loopType = 'forEach'
|
||||
if (
|
||||
loopConfig.forEachItems === undefined ||
|
||||
loopConfig.forEachItems === null ||
|
||||
loopConfig.forEachItems === ''
|
||||
) {
|
||||
const errorMessage =
|
||||
'ForEach loop collection is empty. Provide an array or a reference that resolves to a collection.'
|
||||
logger.error(errorMessage, { loopId })
|
||||
await this.addLoopErrorLog(ctx, loopId, loopType, errorMessage, {
|
||||
forEachItems: loopConfig.forEachItems,
|
||||
})
|
||||
scope.items = []
|
||||
scope.maxIterations = 0
|
||||
scope.validationError = errorMessage
|
||||
scope.condition = buildLoopIndexCondition(0)
|
||||
ctx.loopExecutions?.set(loopId, scope)
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
let items: any[]
|
||||
try {
|
||||
items = resolveArrayInput(ctx, loopConfig.forEachItems, this.resolver)
|
||||
|
||||
@@ -57,6 +57,15 @@ export class ParallelOrchestrator {
|
||||
throw new Error(`Parallel config not found: ${parallelId}`)
|
||||
}
|
||||
|
||||
if (terminalNodesCount === 0 || parallelConfig.nodes.length === 0) {
|
||||
const errorMessage =
|
||||
'Parallel has no executable blocks inside. Add or enable at least one block in the parallel.'
|
||||
logger.error(errorMessage, { parallelId })
|
||||
await this.addParallelErrorLog(ctx, parallelId, errorMessage, {})
|
||||
this.setErrorScope(ctx, parallelId, errorMessage)
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
|
||||
let items: any[] | undefined
|
||||
let branchCount: number
|
||||
let isEmpty = false
|
||||
@@ -67,7 +76,10 @@ export class ParallelOrchestrator {
|
||||
items = resolved.items
|
||||
isEmpty = resolved.isEmpty ?? false
|
||||
} catch (error) {
|
||||
const errorMessage = `Parallel Items did not resolve: ${error instanceof Error ? error.message : String(error)}`
|
||||
const baseErrorMessage = error instanceof Error ? error.message : String(error)
|
||||
const errorMessage = baseErrorMessage.startsWith('Parallel collection distribution is empty')
|
||||
? baseErrorMessage
|
||||
: `Parallel Items did not resolve: ${baseErrorMessage}`
|
||||
logger.error(errorMessage, { parallelId, distribution: parallelConfig.distribution })
|
||||
await this.addParallelErrorLog(ctx, parallelId, errorMessage, {
|
||||
distribution: parallelConfig.distribution,
|
||||
@@ -258,7 +270,9 @@ export class ParallelOrchestrator {
|
||||
config.distribution === null ||
|
||||
config.distribution === ''
|
||||
) {
|
||||
return []
|
||||
throw new Error(
|
||||
'Parallel collection distribution is empty. Provide an array or a reference that resolves to a collection.'
|
||||
)
|
||||
}
|
||||
return resolveArrayInput(ctx, config.distribution, this.resolver)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user