Skip to content

Commit 0a1717d

Browse files
committed
feat(workflows): describe container changes and build a canvas diff overlay
The comparison engine now reports which loop and parallel fields changed and which blocks entered or left a container, and exposes the field lists and a shared hasChanges helper. A new overlay module merges two workflow states into one canvas: removed blocks, containers and edges ride along as ghosts at their old positions (nudged clear of live cards), deleted condition and router branches stay on the surviving card so their ghost edge keeps a handle, and every edge is classified by canonical port key. The renderer edge view gains a quiet ghost style for those removed connections.
1 parent 1b20406 commit 0a1717d

8 files changed

Lines changed: 812 additions & 24 deletions

File tree

‎apps/sim/lib/workflows/autolayout/index.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,8 +100,14 @@ export {
100100
getTargetedLayoutImpact,
101101
} from '@/lib/workflows/autolayout/change-set'
102102
export { applyTargetedLayout } from '@/lib/workflows/autolayout/targeted'
103-
export type { Edge, LayoutOptions, LayoutResult } from '@/lib/workflows/autolayout/types'
103+
export type {
104+
BoundingBox,
105+
Edge,
106+
LayoutOptions,
107+
LayoutResult,
108+
} from '@/lib/workflows/autolayout/types'
104109
export {
110+
boxesOverlap,
105111
getBlockMetrics,
106112
isContainerType,
107113
shouldSkipAutoLayout,

‎apps/sim/lib/workflows/comparison/compare.test.ts‎

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1620,3 +1620,114 @@ describe('generateWorkflowDiffSummary', () => {
16201620
})
16211621
})
16221622
})
1623+
1624+
describe('containerChanges', () => {
1625+
function stateWithLoop(loop: Record<string, any>, blocks: Record<string, any> = {}) {
1626+
return createWorkflowState({
1627+
blocks: {
1628+
loop1: createBlock('loop1', { type: 'loop', name: 'My Loop' }),
1629+
...blocks,
1630+
},
1631+
loops: { loop1: { id: 'loop1', enabled: true, ...loop } },
1632+
})
1633+
}
1634+
1635+
it.concurrent('describes a reconfigured loop field by field, named after its block', () => {
1636+
const previous = stateWithLoop({ nodes: ['a'], loopType: 'for', iterations: 2 })
1637+
const current = stateWithLoop({ nodes: ['a'], loopType: 'for', iterations: 5 })
1638+
1639+
const result = generateWorkflowDiffSummary(current, previous)
1640+
1641+
expect(result.loopChanges).toEqual({ added: 0, removed: 0, modified: 1 })
1642+
expect(result.containerChanges).toEqual([
1643+
{
1644+
id: 'loop1',
1645+
kind: 'loop',
1646+
name: 'My Loop',
1647+
changes: [{ field: 'iterations', oldValue: 2, newValue: 5 }],
1648+
nodesAdded: [],
1649+
nodesRemoved: [],
1650+
},
1651+
])
1652+
})
1653+
1654+
it.concurrent(
1655+
'reports membership moves and a loop type switch with the fields that came and went',
1656+
() => {
1657+
const previous = stateWithLoop({ nodes: ['a', 'b'], loopType: 'for', iterations: 2 })
1658+
const current = stateWithLoop({
1659+
nodes: ['b', 'c'],
1660+
loopType: 'forEach',
1661+
forEachItems: '<start.items>',
1662+
/* Stale count left behind by the switch; the normalized shape drops it for forEach. */
1663+
iterations: 2,
1664+
})
1665+
1666+
const [change] = generateWorkflowDiffSummary(current, previous).containerChanges
1667+
1668+
expect(change.nodesAdded).toEqual(['c'])
1669+
expect(change.nodesRemoved).toEqual(['a'])
1670+
expect(change.changes).toEqual([
1671+
{ field: 'loopType', oldValue: 'for', newValue: 'forEach' },
1672+
{ field: 'iterations', oldValue: 2, newValue: null },
1673+
{ field: 'forEachItems', oldValue: null, newValue: '<start.items>' },
1674+
])
1675+
}
1676+
)
1677+
1678+
it.concurrent(
1679+
'describes a parallel and skips containers that were added, removed or unchanged',
1680+
() => {
1681+
const previous = createWorkflowState({
1682+
blocks: {
1683+
par1: createBlock('par1', { type: 'parallel' }),
1684+
parGone: createBlock('parGone', { type: 'parallel' }),
1685+
loopSame: createBlock('loopSame', { type: 'loop' }),
1686+
},
1687+
parallels: {
1688+
par1: { id: 'par1', nodes: ['x'], parallelType: 'count', count: 2, enabled: true },
1689+
parGone: { id: 'parGone', nodes: [], parallelType: 'count', count: 1, enabled: true },
1690+
},
1691+
loops: { loopSame: { id: 'loopSame', nodes: ['y', 'z'], loopType: 'for', iterations: 1 } },
1692+
})
1693+
const current = createWorkflowState({
1694+
blocks: {
1695+
par1: createBlock('par1', { type: 'parallel' }),
1696+
parNew: createBlock('parNew', { type: 'parallel' }),
1697+
loopSame: createBlock('loopSame', { type: 'loop' }),
1698+
},
1699+
parallels: {
1700+
par1: {
1701+
id: 'par1',
1702+
nodes: ['x'],
1703+
parallelType: 'collection',
1704+
distribution: '<start.list>',
1705+
enabled: true,
1706+
},
1707+
parNew: { id: 'parNew', nodes: [], parallelType: 'count', count: 3, enabled: true },
1708+
},
1709+
/* Same membership in a different order is not a change. */
1710+
loops: { loopSame: { id: 'loopSame', nodes: ['z', 'y'], loopType: 'for', iterations: 1 } },
1711+
})
1712+
1713+
const result = generateWorkflowDiffSummary(current, previous)
1714+
1715+
expect(result.parallelChanges).toEqual({ added: 1, removed: 1, modified: 1 })
1716+
expect(result.loopChanges).toEqual({ added: 0, removed: 0, modified: 0 })
1717+
expect(result.containerChanges).toEqual([
1718+
{
1719+
id: 'par1',
1720+
kind: 'parallel',
1721+
name: 'Block par1',
1722+
changes: [
1723+
{ field: 'parallelType', oldValue: 'count', newValue: 'collection' },
1724+
{ field: 'count', oldValue: 2, newValue: null },
1725+
{ field: 'distribution', oldValue: null, newValue: '<start.list>' },
1726+
],
1727+
nodesAdded: [],
1728+
nodesRemoved: [],
1729+
},
1730+
])
1731+
}
1732+
)
1733+
})

‎apps/sim/lib/workflows/comparison/compare.ts‎

Lines changed: 122 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,37 @@ interface FieldChange {
4242
newValue: unknown
4343
}
4444

45+
/** The loop configuration fields a container diff compares, in the order they are reported. */
46+
export const LOOP_CONFIG_FIELDS = [
47+
'loopType',
48+
'iterations',
49+
'forEachItems',
50+
'whileCondition',
51+
'doWhileCondition',
52+
] as const
53+
54+
/** The parallel configuration fields a container diff compares, in the order they are reported. */
55+
export const PARALLEL_CONFIG_FIELDS = ['parallelType', 'count', 'distribution'] as const
56+
57+
export type ContainerConfigField =
58+
| (typeof LOOP_CONFIG_FIELDS)[number]
59+
| (typeof PARALLEL_CONFIG_FIELDS)[number]
60+
61+
/**
62+
* A loop or parallel container present on both sides whose configuration or
63+
* membership differs. Added and removed containers are reported as blocks.
64+
*/
65+
export interface ContainerChange {
66+
id: string
67+
kind: 'loop' | 'parallel'
68+
name?: string
69+
changes: FieldChange[]
70+
/** Block ids that are inside the container on the current side only */
71+
nodesAdded: string[]
72+
/** Block ids that were inside the container on the previous side only */
73+
nodesRemoved: string[]
74+
}
75+
4576
/**
4677
* Result of workflow diff analysis between two workflow states
4778
*/
@@ -57,6 +88,7 @@ export interface WorkflowDiffSummary {
5788
}
5889
loopChanges: { added: number; removed: number; modified: number }
5990
parallelChanges: { added: number; removed: number; modified: number }
91+
containerChanges: ContainerChange[]
6092
variableChanges: {
6193
added: number
6294
removed: number
@@ -68,6 +100,62 @@ export interface WorkflowDiffSummary {
68100
hasChanges: boolean
69101
}
70102

103+
/**
104+
* Field-by-field description of a container that changed, from the normalized
105+
* shapes the equality check already uses so the two can never disagree.
106+
*/
107+
function describeContainerChange(
108+
id: string,
109+
kind: ContainerChange['kind'],
110+
name: string | undefined,
111+
current: { nodes: string[] } | null,
112+
previous: { nodes: string[] } | null,
113+
fields: readonly string[]
114+
): ContainerChange {
115+
const changes: FieldChange[] = []
116+
const currentRecord = (current ?? {}) as Record<string, unknown>
117+
const previousRecord = (previous ?? {}) as Record<string, unknown>
118+
for (const field of fields) {
119+
if (normalizedStringify(currentRecord[field]) !== normalizedStringify(previousRecord[field])) {
120+
changes.push({
121+
field,
122+
oldValue: previousRecord[field] ?? null,
123+
newValue: currentRecord[field] ?? null,
124+
})
125+
}
126+
}
127+
const currentNodes = new Set(current?.nodes ?? [])
128+
const previousNodes = new Set(previous?.nodes ?? [])
129+
return {
130+
id,
131+
kind,
132+
name,
133+
changes,
134+
nodesAdded: [...currentNodes].filter((node) => !previousNodes.has(node)),
135+
nodesRemoved: [...previousNodes].filter((node) => !currentNodes.has(node)),
136+
}
137+
}
138+
139+
/** Whether any counted section of a summary reports a difference. */
140+
export function summaryHasChanges(summary: Omit<WorkflowDiffSummary, 'hasChanges'>): boolean {
141+
return (
142+
summary.addedBlocks.length > 0 ||
143+
summary.removedBlocks.length > 0 ||
144+
summary.modifiedBlocks.length > 0 ||
145+
summary.edgeChanges.added > 0 ||
146+
summary.edgeChanges.removed > 0 ||
147+
summary.loopChanges.added > 0 ||
148+
summary.loopChanges.removed > 0 ||
149+
summary.loopChanges.modified > 0 ||
150+
summary.parallelChanges.added > 0 ||
151+
summary.parallelChanges.removed > 0 ||
152+
summary.parallelChanges.modified > 0 ||
153+
summary.variableChanges.added > 0 ||
154+
summary.variableChanges.removed > 0 ||
155+
summary.variableChanges.modified > 0
156+
)
157+
}
158+
71159
/**
72160
* Generate a detailed diff summary between two workflow states
73161
*/
@@ -82,6 +170,7 @@ export function generateWorkflowDiffSummary(
82170
edgeChanges: { added: 0, removed: 0, addedDetails: [], removedDetails: [] },
83171
loopChanges: { added: 0, removed: 0, modified: 0 },
84172
parallelChanges: { added: 0, removed: 0, modified: 0 },
173+
containerChanges: [],
85174
variableChanges: {
86175
added: 0,
87176
removed: 0,
@@ -354,10 +443,23 @@ export function generateWorkflowDiffSummary(
354443
if (!previousLoopIds.includes(id)) {
355444
result.loopChanges.added++
356445
} else {
357-
const normalizedCurrent = normalizeValue(normalizeLoop(currentLoops[id]))
358-
const normalizedPrevious = normalizeValue(normalizeLoop(previousLoops[id]))
359-
if (normalizedStringify(normalizedCurrent) !== normalizedStringify(normalizedPrevious)) {
446+
const normalizedCurrent = normalizeLoop(currentLoops[id])
447+
const normalizedPrevious = normalizeLoop(previousLoops[id])
448+
if (
449+
normalizedStringify(normalizeValue(normalizedCurrent)) !==
450+
normalizedStringify(normalizeValue(normalizedPrevious))
451+
) {
360452
result.loopChanges.modified++
453+
result.containerChanges.push(
454+
describeContainerChange(
455+
id,
456+
'loop',
457+
currentBlocks[id]?.name ?? previousBlocks[id]?.name,
458+
normalizedCurrent ?? null,
459+
normalizedPrevious ?? null,
460+
LOOP_CONFIG_FIELDS
461+
)
462+
)
361463
}
362464
}
363465
}
@@ -376,10 +478,23 @@ export function generateWorkflowDiffSummary(
376478
if (!previousParallelIds.includes(id)) {
377479
result.parallelChanges.added++
378480
} else {
379-
const normalizedCurrent = normalizeValue(normalizeParallel(currentParallels[id]))
380-
const normalizedPrevious = normalizeValue(normalizeParallel(previousParallels[id]))
381-
if (normalizedStringify(normalizedCurrent) !== normalizedStringify(normalizedPrevious)) {
481+
const normalizedCurrent = normalizeParallel(currentParallels[id])
482+
const normalizedPrevious = normalizeParallel(previousParallels[id])
483+
if (
484+
normalizedStringify(normalizeValue(normalizedCurrent)) !==
485+
normalizedStringify(normalizeValue(normalizedPrevious))
486+
) {
382487
result.parallelChanges.modified++
488+
result.containerChanges.push(
489+
describeContainerChange(
490+
id,
491+
'parallel',
492+
currentBlocks[id]?.name ?? previousBlocks[id]?.name,
493+
normalizedCurrent ?? null,
494+
normalizedPrevious ?? null,
495+
PARALLEL_CONFIG_FIELDS
496+
)
497+
)
383498
}
384499
}
385500
}
@@ -417,21 +532,7 @@ export function generateWorkflowDiffSummary(
417532
}
418533
}
419534

420-
result.hasChanges =
421-
result.addedBlocks.length > 0 ||
422-
result.removedBlocks.length > 0 ||
423-
result.modifiedBlocks.length > 0 ||
424-
result.edgeChanges.added > 0 ||
425-
result.edgeChanges.removed > 0 ||
426-
result.loopChanges.added > 0 ||
427-
result.loopChanges.removed > 0 ||
428-
result.loopChanges.modified > 0 ||
429-
result.parallelChanges.added > 0 ||
430-
result.parallelChanges.removed > 0 ||
431-
result.parallelChanges.modified > 0 ||
432-
result.variableChanges.added > 0 ||
433-
result.variableChanges.removed > 0 ||
434-
result.variableChanges.modified > 0
535+
result.hasChanges = summaryHasChanges(result)
435536

436537
return result
437538
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,20 @@
11
export {
2+
type ContainerChange,
3+
type ContainerConfigField,
24
generateWorkflowDiffSummary,
35
hasWorkflowChanged,
6+
LOOP_CONFIG_FIELDS,
7+
PARALLEL_CONFIG_FIELDS,
8+
summaryHasChanges,
49
type WorkflowDiffSummary,
510
} from './compare'
611
export {
712
normalizedStringify,
813
normalizeWorkflowState,
914
} from './normalize'
15+
export {
16+
type BlockDiffStatus,
17+
buildWorkflowDiffOverlay,
18+
type EdgeDiffStatus,
19+
type WorkflowDiffOverlay,
20+
} from './overlay'

0 commit comments

Comments
 (0)