Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 20 additions & 4 deletions apps/sim/lib/workflows/editing/engine.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createLogger } from '@sim/logger'
import { toArray } from '@sim/utils/object'
import {
generateLoopBlocks,
generateParallelBlocks,
Expand Down Expand Up @@ -202,6 +203,9 @@ export function applyOperationsToWorkflowState(
const declinedOperations = new Set<number>()
/** The operation that queued each entry of `ctx.deferredConnections`, index for index. */
const deferredConnectionOrigins: number[] = []
/** The operation whose deferred connection created each edge this batch added, by edge id. */
const edgeOrigins = new Map<string, number>()
const currentEdges = () => toArray<{ id: string }>(modifiedState.edges)
const recordedRefusalSince = (skipsBefore: number) =>
skippedItems.slice(skipsBefore).some((item) => !isDeferredSkippedItem(item))

Expand Down Expand Up @@ -261,6 +265,7 @@ export function applyOperationsToWorkflowState(
}

const skipsBefore = skippedItems.length
const edgesBefore = currentEdges().length
addConnectionsAsEdges(
modifiedState,
blockId,
Expand All @@ -270,7 +275,9 @@ export function applyOperationsToWorkflowState(
validationErrors
)
const origin = deferredConnectionOrigins[connectionIndex]
if (origin !== undefined && recordedRefusalSince(skipsBefore)) declinedOperations.add(origin)
if (origin === undefined) continue
if (recordedRefusalSince(skipsBefore)) declinedOperations.add(origin)
for (const edge of currentEdges().slice(edgesBefore)) edgeOrigins.set(edge.id, origin)
}

logger.info('Finished processing deferred connections', {
Expand All @@ -287,8 +294,13 @@ export function applyOperationsToWorkflowState(
// Remove edges that cross scope boundaries. This runs after all operations
// and deferred connections are applied so that every block has its final
// parentId. Running it per-operation would incorrectly drop edges between
// blocks that are both being moved into the same subflow in one batch.
removeInvalidScopeEdges(modifiedState, skippedItems)
// blocks that are both being moved into the same subflow in one batch. A dropped
// edge this batch created declines the operation that asked for it; an existing
// edge a move left crossing scopes is the move's documented effect, not a refusal.
for (const edgeId of removeInvalidScopeEdges(modifiedState, skippedItems)) {
const origin = edgeOrigins.get(edgeId)
if (origin !== undefined) declinedOperations.add(origin)
}

reindexToolCanonicalModesAfterEdits(
workflowState.blocks as Record<string, BlockState> | undefined,
Expand Down Expand Up @@ -454,8 +466,10 @@ function resolvePendingConnections(modifiedState: any, skippedItems: SkippedItem
* - Same scope: both blocks share the same parentId
* - Container→child: source is the parent container of the target (start handles)
* - Child→container: target is the parent container of the source (end handles)
*
* Returns the ids of the dropped edges.
*/
function removeInvalidScopeEdges(modifiedState: any, skippedItems: SkippedItem[]): void {
function removeInvalidScopeEdges(modifiedState: any, skippedItems: SkippedItem[]): string[] {
const { valid, dropped } = validateEdges(modifiedState.edges || [], modifiedState.blocks || {})
modifiedState.edges = valid

Expand All @@ -480,4 +494,6 @@ function removeInvalidScopeEdges(modifiedState: any, skippedItems: SkippedItem[]
reasons: dropped.map(({ reason }) => reason),
})
}

return dropped.map(({ edge }) => edge.id)
}
38 changes: 38 additions & 0 deletions apps/sim/lib/workflows/editing/operations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1487,6 +1487,44 @@ describe('connection shape validation', () => {
expect(skippedItems.filter((item) => item.type === 'invalid_source_handle')).toHaveLength(2)
expect(declinedOperationCount).toBe(1)
})

it('charges an edge dropped by the final scope check to the operation that created it', () => {
const { state, skippedItems, declinedOperationCount } = applyOperationsToWorkflowState(
makeLoopWorkflow(),
[
{
operation_type: 'add',
block_id: BLOCK_A,
params: {
type: 'function',
name: 'Block A',
inputs: { code: 'return 1' },
connections: { source: 'agent-1' },
},
},
]
)

expect(state.edges.some((edge: { source: string }) => edge.source === BLOCK_A)).toBe(false)
expect(skippedItems.filter((item) => item.type === 'invalid_edge_scope')).toHaveLength(1)
expect(declinedOperationCount).toBe(1)
})

it('does not charge a move for the existing edges it leaves crossing scopes', () => {
const { skippedItems, declinedOperationCount } = applyOperationsToWorkflowState(
makeLoopWorkflow(),
[
{
operation_type: 'extract_from_subflow',
block_id: 'agent-1',
params: { subflowId: 'loop-1' },
},
]
)

expect(skippedItems.filter((item) => item.type === 'invalid_edge_scope')).toHaveLength(1)
expect(declinedOperationCount).toBe(0)
})
})

describe('conditional input validation through workflow operations', () => {
Expand Down
Loading