Skip to content

Commit 64cbcab

Browse files
committed
Fixes
1 parent 569d222 commit 64cbcab

4 files changed

Lines changed: 197 additions & 3 deletions

File tree

apps/sim/lib/copilot/tools/server/workflow/edit-workflow/builders.ts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -427,7 +427,28 @@ export function createValidatedEdge(
427427
skippedItems?: SkippedItem[]
428428
): boolean {
429429
if (!modifiedState.blocks[targetBlockId]) {
430-
logger.warn(`Target block "${targetBlockId}" not found. Edge skipped.`, {
430+
// The target doesn't exist yet. It may be created by a later operation in
431+
// this batch or by a future edit_workflow call. Record the connection as
432+
// pending on the source block (persisted in block.data) so it is resolved
433+
// automatically once the target appears, instead of being silently dropped.
434+
const pendingSource = modifiedState.blocks[sourceBlockId]
435+
if (pendingSource) {
436+
if (!pendingSource.data) pendingSource.data = {}
437+
if (!pendingSource.data.pendingConnections) pendingSource.data.pendingConnections = {}
438+
const pending = pendingSource.data.pendingConnections as Record<
439+
string,
440+
Array<{ target: string; targetHandle: string }>
441+
>
442+
if (!pending[sourceHandle]) pending[sourceHandle] = []
443+
if (
444+
!pending[sourceHandle].some(
445+
(p) => p.target === targetBlockId && p.targetHandle === targetHandle
446+
)
447+
) {
448+
pending[sourceHandle].push({ target: targetBlockId, targetHandle })
449+
}
450+
}
451+
logger.warn(`Target block "${targetBlockId}" not found. Connection deferred until it exists.`, {
431452
sourceBlockId,
432453
targetBlockId,
433454
sourceHandle,
@@ -436,7 +457,7 @@ export function createValidatedEdge(
436457
type: 'invalid_edge_target',
437458
operationType,
438459
blockId: sourceBlockId,
439-
reason: `Edge from "${sourceBlockId}" to "${targetBlockId}" skipped - target block does not exist`,
460+
reason: `Edge from "${sourceBlockId}" to "${targetBlockId}" deferred - target block does not exist yet; it will be created automatically once the target block is added`,
440461
details: { sourceHandle, targetHandle, targetId: targetBlockId },
441462
})
442463
return false
@@ -513,6 +534,17 @@ export function createValidatedEdge(
513534
// Use normalized handle if available (e.g., 'if' -> 'condition-{uuid}')
514535
const finalSourceHandle = sourceValidation.normalizedHandle || sourceHandle
515536

537+
// Avoid creating duplicate edges (e.g., when a pending connection resolves to
538+
// the same edge a later operation already created).
539+
const edgeExists = (modifiedState.edges || []).some(
540+
(e: any) =>
541+
e.source === sourceBlockId &&
542+
e.sourceHandle === finalSourceHandle &&
543+
e.target === targetBlockId &&
544+
e.targetHandle === targetHandle
545+
)
546+
if (edgeExists) return true
547+
516548
modifiedState.edges.push({
517549
id: generateId(),
518550
source: sourceBlockId,

apps/sim/lib/copilot/tools/server/workflow/edit-workflow/engine.ts

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ import type { PermissionGroupConfig } from '@/lib/permission-groups/types'
33
import { isValidKey } from '@/lib/workflows/sanitization/key-validation'
44
import { validateEdges } from '@/stores/workflows/workflow/edge-validation'
55
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
6-
import { addConnectionsAsEdges, normalizeBlockIdsInOperations } from './builders'
6+
import {
7+
addConnectionsAsEdges,
8+
createValidatedEdge,
9+
normalizeBlockIdsInOperations,
10+
} from './builders'
711
import {
812
handleAddOperation,
913
handleDeleteOperation,
@@ -239,6 +243,13 @@ export function applyOperationsToWorkflowState(
239243
totalEdges: (modifiedState as any).edges?.length,
240244
})
241245
}
246+
247+
// Pass 3: resolve pending connections whose target block now exists. These are
248+
// forward-reference connections that were recorded (on block.data) when their
249+
// target didn't exist yet — possibly in an earlier edit_workflow call. Now
250+
// that this batch's blocks are all created, create the edges that resolve.
251+
resolvePendingConnections(modifiedState, skippedItems)
252+
242253
// Remove edges that cross scope boundaries. This runs after all operations
243254
// and deferred connections are applied so that every block has its final
244255
// parentId. Running it per-operation would incorrectly drop edges between
@@ -280,6 +291,54 @@ export function applyOperationsToWorkflowState(
280291
return { state: modifiedState, validationErrors, skippedItems }
281292
}
282293

294+
/**
295+
* Resolves pending forward-reference connections recorded on block.data.
296+
*
297+
* When a connection references a target block that does not exist yet, the edge
298+
* is recorded as pending on the source block instead of being dropped. This runs
299+
* after all blocks for the current batch exist (and picks up pending entries
300+
* persisted from earlier edit_workflow calls), creating any edges whose target
301+
* now exists and leaving still-unresolved entries pending.
302+
*/
303+
function resolvePendingConnections(modifiedState: any, skippedItems: SkippedItem[]): void {
304+
const blocks = modifiedState.blocks || {}
305+
for (const [sourceId, block] of Object.entries(blocks) as [string, any][]) {
306+
const pending = block?.data?.pendingConnections as
307+
| Record<string, Array<{ target: string; targetHandle: string }>>
308+
| undefined
309+
if (!pending) continue
310+
311+
for (const [handle, targets] of Object.entries(pending)) {
312+
const stillPending: Array<{ target: string; targetHandle: string }> = []
313+
for (const { target, targetHandle } of targets) {
314+
if (blocks[target]) {
315+
createValidatedEdge(
316+
modifiedState,
317+
sourceId,
318+
target,
319+
handle,
320+
targetHandle || 'target',
321+
'resolve_pending_connection',
322+
logger,
323+
skippedItems
324+
)
325+
} else {
326+
stillPending.push({ target, targetHandle })
327+
}
328+
}
329+
if (stillPending.length > 0) {
330+
pending[handle] = stillPending
331+
} else {
332+
delete pending[handle]
333+
}
334+
}
335+
336+
if (Object.keys(pending).length === 0) {
337+
delete block.data.pendingConnections
338+
}
339+
}
340+
}
341+
283342
/**
284343
* Removes edges that cross scope boundaries after all operations are applied.
285344
* An edge is invalid if:

apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.test.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,3 +438,93 @@ describe('handleEditOperation nestedNodes merge', () => {
438438
expect(replacementBlock.data?.parentId).toBe('outer-loop')
439439
})
440440
})
441+
442+
describe('forward-reference connections (pending resolution)', () => {
443+
function makeMinimalWorkflow() {
444+
return {
445+
blocks: {
446+
'start-1': {
447+
id: 'start-1',
448+
type: 'function',
449+
name: 'Start',
450+
position: { x: 0, y: 0 },
451+
enabled: true,
452+
subBlocks: {},
453+
outputs: {},
454+
data: {},
455+
},
456+
},
457+
edges: [] as any[],
458+
loops: {},
459+
parallels: {},
460+
}
461+
}
462+
463+
// Valid UUIDs so block_ids are not normalized/remapped on add.
464+
const BLOCK_A = '11111111-1111-4111-8111-111111111111'
465+
const BLOCK_B = '22222222-2222-4222-8222-222222222222'
466+
467+
it('defers a connection to a not-yet-created block and resolves it on a later apply', () => {
468+
const workflow = makeMinimalWorkflow()
469+
470+
// First apply: add block A connecting to block B, which does not exist yet.
471+
const first = applyOperationsToWorkflowState(workflow, [
472+
{
473+
operation_type: 'add',
474+
block_id: BLOCK_A,
475+
params: {
476+
type: 'function',
477+
name: 'Block A',
478+
inputs: { code: 'return 1' },
479+
connections: { source: BLOCK_B },
480+
},
481+
},
482+
])
483+
484+
// No edge created yet; the connection is recorded as pending on block A.
485+
expect(first.state.edges.some((e: any) => e.target === BLOCK_B)).toBe(false)
486+
expect(first.state.blocks[BLOCK_A].data.pendingConnections.source).toEqual([
487+
{ target: BLOCK_B, targetHandle: 'target' },
488+
])
489+
490+
// Second apply (simulating a later edit_workflow call): add block B.
491+
const second = applyOperationsToWorkflowState(first.state, [
492+
{
493+
operation_type: 'add',
494+
block_id: BLOCK_B,
495+
params: { type: 'function', name: 'Block B', inputs: { code: 'return 2' } },
496+
},
497+
])
498+
499+
// The pending edge is now created and the pending record cleared.
500+
const edge = second.state.edges.find((e: any) => e.source === BLOCK_A && e.target === BLOCK_B)
501+
expect(edge).toBeDefined()
502+
expect(second.state.blocks[BLOCK_A].data?.pendingConnections).toBeUndefined()
503+
})
504+
505+
it('resolves a forward-reference connection within a single apply regardless of operation order', () => {
506+
const workflow = makeMinimalWorkflow()
507+
508+
const { state } = applyOperationsToWorkflowState(workflow, [
509+
{
510+
operation_type: 'add',
511+
block_id: BLOCK_A,
512+
params: {
513+
type: 'function',
514+
name: 'Block A',
515+
inputs: { code: 'return 1' },
516+
connections: { source: BLOCK_B },
517+
},
518+
},
519+
{
520+
operation_type: 'add',
521+
block_id: BLOCK_B,
522+
params: { type: 'function', name: 'Block B', inputs: { code: 'return 2' } },
523+
},
524+
])
525+
526+
const edge = state.edges.find((e: any) => e.source === BLOCK_A && e.target === BLOCK_B)
527+
expect(edge).toBeDefined()
528+
expect(state.blocks[BLOCK_A].data?.pendingConnections).toBeUndefined()
529+
})
530+
})

apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -659,6 +659,12 @@ export function handleEditOperation(op: EditWorkflowOperation, ctx: OperationCon
659659
if (params?.connections) {
660660
modifiedState.edges = modifiedState.edges.filter((edge: any) => edge.source !== block_id)
661661

662+
// Re-specifying connections fully replaces this block's outgoing edges, so
663+
// drop any previously-recorded pending (forward-reference) connections too.
664+
if (block.data?.pendingConnections) {
665+
block.data.pendingConnections = undefined
666+
}
667+
662668
deferredConnections.push({
663669
blockId: block_id,
664670
connections: params.connections,
@@ -1014,6 +1020,13 @@ export function handleInsertIntoSubflowOperation(
10141020
// Remove existing edges from this block first
10151021
modifiedState.edges = modifiedState.edges.filter((edge: any) => edge.source !== block_id)
10161022

1023+
// Re-specifying connections fully replaces this block's outgoing edges, so
1024+
// drop any previously-recorded pending (forward-reference) connections too.
1025+
const connBlock = modifiedState.blocks[block_id]
1026+
if (connBlock?.data?.pendingConnections) {
1027+
connBlock.data.pendingConnections = undefined
1028+
}
1029+
10171030
// Add to deferred connections list
10181031
deferredConnections.push({
10191032
blockId: block_id,

0 commit comments

Comments
 (0)