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
32 changes: 27 additions & 5 deletions apps/sim/app/api/workflows/[id]/autolayout/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ import { createLogger } from '@/lib/logs/console/logger'
import { getUserEntityPermissions } from '@/lib/permissions/utils'
import { generateRequestId } from '@/lib/utils'
import { applyAutoLayout } from '@/lib/workflows/autolayout'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers'
import {
loadWorkflowFromNormalizedTables,
type NormalizedWorkflowData,
} from '@/lib/workflows/db-helpers'

export const dynamic = 'force-dynamic'

Expand Down Expand Up @@ -36,10 +39,14 @@ const AutoLayoutRequestSchema = z.object({
})
.optional()
.default({}),
// Optional: if provided, use these blocks instead of loading from DB
// This allows using blocks with live measurements from the UI
blocks: z.record(z.any()).optional(),
edges: z.array(z.any()).optional(),
loops: z.record(z.any()).optional(),
parallels: z.record(z.any()).optional(),
})

type AutoLayoutRequest = z.infer<typeof AutoLayoutRequestSchema>

/**
* POST /api/workflows/[id]/autolayout
* Apply autolayout to an existing workflow
Expand Down Expand Up @@ -108,8 +115,23 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}

// Load current workflow state
const currentWorkflowData = await loadWorkflowFromNormalizedTables(workflowId)
// Use provided blocks/edges if available (with live measurements from UI),
// otherwise load from database
let currentWorkflowData: NormalizedWorkflowData | null

if (layoutOptions.blocks && layoutOptions.edges) {
logger.info(`[${requestId}] Using provided blocks with live measurements`)
currentWorkflowData = {
blocks: layoutOptions.blocks,
edges: layoutOptions.edges,
loops: layoutOptions.loops || {},
parallels: layoutOptions.parallels || {},
isFromNormalizedTables: false,
}
} else {
logger.info(`[${requestId}] Loading blocks from database`)
currentWorkflowData = await loadWorkflowFromNormalizedTables(workflowId)
}

if (!currentWorkflowData) {
logger.error(`[${requestId}] Could not load workflow ${workflowId} for autolayout`)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
)
const storeIsWide = useWorkflowStore((state) => state.blocks[id]?.isWide ?? false)
const storeBlockHeight = useWorkflowStore((state) => state.blocks[id]?.height ?? 0)
const storeBlockLayout = useWorkflowStore((state) => state.blocks[id]?.layout)
const storeBlockAdvancedMode = useWorkflowStore(
(state) => state.blocks[id]?.advancedMode ?? false
)
Expand All @@ -168,6 +169,10 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
? (currentWorkflow.blocks[id]?.height ?? 0)
: storeBlockHeight

const blockWidth = currentWorkflow.isDiffMode
? (currentWorkflow.blocks[id]?.layout?.measuredWidth ?? 0)
: (storeBlockLayout?.measuredWidth ?? 0)

// Get per-block webhook status by checking if webhook is configured
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)

Expand Down Expand Up @@ -240,7 +245,7 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
}, [id, collaborativeSetSubblockValue])

// Workflow store actions
const updateBlockHeight = useWorkflowStore((state) => state.updateBlockHeight)
const updateBlockLayoutMetrics = useWorkflowStore((state) => state.updateBlockLayoutMetrics)

// Execution store
const isActiveBlock = useExecutionStore((state) => state.activeBlockIds.has(id))
Expand Down Expand Up @@ -419,9 +424,9 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
if (!contentRef.current) return

let rafId: number
const debouncedUpdate = debounce((height: number) => {
if (height !== blockHeight) {
updateBlockHeight(id, height)
const debouncedUpdate = debounce((dimensions: { width: number; height: number }) => {
if (dimensions.height !== blockHeight || dimensions.width !== blockWidth) {
updateBlockLayoutMetrics(id, dimensions)
updateNodeInternals(id)
}
}, 100)
Expand All @@ -435,9 +440,10 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
// Schedule the update on the next animation frame
rafId = requestAnimationFrame(() => {
for (const entry of entries) {
const height =
entry.borderBoxSize[0]?.blockSize ?? entry.target.getBoundingClientRect().height
debouncedUpdate(height)
const rect = entry.target.getBoundingClientRect()
const height = entry.borderBoxSize[0]?.blockSize ?? rect.height
const width = entry.borderBoxSize[0]?.inlineSize ?? rect.width
debouncedUpdate({ width, height })
}
})
})
Expand All @@ -450,7 +456,7 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
cancelAnimationFrame(rafId)
}
}
}, [id, blockHeight, updateBlockHeight, updateNodeInternals, lastUpdate])
}, [id, blockHeight, blockWidth, updateBlockLayoutMetrics, updateNodeInternals, lastUpdate])

// SubBlock layout management
function groupSubBlocks(subBlocks: SubBlockConfig[], blockId: string) {
Expand Down
16 changes: 5 additions & 11 deletions apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,18 +98,12 @@ const getBlockDimensions = (
}
}

if (block.type === 'workflowBlock') {
const nodeWidth = block.data?.width || block.width
const nodeHeight = block.data?.height || block.height

if (nodeWidth && nodeHeight) {
return { width: nodeWidth, height: nodeHeight }
}
}

return {
width: block.isWide ? 450 : block.data?.width || block.width || 350,
height: Math.max(block.height || block.data?.height || 150, 100),
width: block.layout?.measuredWidth || (block.isWide ? 450 : block.data?.width || 350),
height: Math.max(
block.layout?.measuredHeight || block.height || block.data?.height || 150,
100
),
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,13 +78,19 @@ export async function applyAutoLayoutToWorkflow(
},
}

// Call the autolayout API route which has access to the server-side API key
// Call the autolayout API route, sending blocks with live measurements
const response = await fetch(`/api/workflows/${workflowId}/autolayout`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(layoutOptions),
body: JSON.stringify({
...layoutOptions,
blocks,
edges,
loops,
parallels,
}),
})

if (!response.ok) {
Expand Down
12 changes: 9 additions & 3 deletions apps/sim/lib/workflows/autolayout/containers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ import type { BlockState } from '@/stores/workflows/workflow/types'
import { assignLayers, groupByLayer } from './layering'
import { calculatePositions } from './positioning'
import type { Edge, LayoutOptions } from './types'
import { DEFAULT_CONTAINER_HEIGHT, DEFAULT_CONTAINER_WIDTH, getBlocksByParent } from './utils'
import {
DEFAULT_CONTAINER_HEIGHT,
DEFAULT_CONTAINER_WIDTH,
getBlocksByParent,
prepareBlockMetrics,
} from './utils'

const logger = createLogger('AutoLayout:Containers')

Expand Down Expand Up @@ -45,6 +50,7 @@ export function layoutContainers(
}

const childNodes = assignLayers(childBlocks, childEdges)
prepareBlockMetrics(childNodes)
const childLayers = groupByLayer(childNodes)
calculatePositions(childLayers, containerOptions)

Expand All @@ -57,8 +63,8 @@ export function layoutContainers(
for (const node of childNodes.values()) {
minX = Math.min(minX, node.position.x)
minY = Math.min(minY, node.position.y)
maxX = Math.max(maxX, node.position.x + node.dimensions.width)
maxY = Math.max(maxY, node.position.y + node.dimensions.height)
maxX = Math.max(maxX, node.position.x + node.metrics.width)
maxY = Math.max(maxY, node.position.y + node.metrics.height)
}

// Adjust all child positions to start at proper padding from container edges
Expand Down
16 changes: 8 additions & 8 deletions apps/sim/lib/workflows/autolayout/incremental.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { createLogger } from '@/lib/logs/console/logger'
import type { BlockState } from '@/stores/workflows/workflow/types'
import type { AdjustmentOptions, Edge } from './types'
import { boxesOverlap, createBoundingBox, getBlockDimensions } from './utils'
import { boxesOverlap, createBoundingBox, getBlockMetrics } from './utils'

const logger = createLogger('AutoLayout:Incremental')

Expand Down Expand Up @@ -70,8 +70,8 @@ export function adjustForNewBlock(
})
}

const newBlockDims = getBlockDimensions(newBlock)
const newBlockBox = createBoundingBox(newBlock.position, newBlockDims)
const newBlockMetrics = getBlockMetrics(newBlock)
const newBlockBox = createBoundingBox(newBlock.position, newBlockMetrics)

const blocksToShift: Array<{ block: BlockState; shiftAmount: number }> = []

Expand All @@ -80,11 +80,11 @@ export function adjustForNewBlock(
if (block.data?.parentId) continue

if (block.position.x >= newBlock.position.x) {
const blockDims = getBlockDimensions(block)
const blockBox = createBoundingBox(block.position, blockDims)
const blockMetrics = getBlockMetrics(block)
const blockBox = createBoundingBox(block.position, blockMetrics)

if (boxesOverlap(newBlockBox, blockBox, 50)) {
const requiredShift = newBlock.position.x + newBlockDims.width + 50 - block.position.x
const requiredShift = newBlock.position.x + newBlockMetrics.width + 50 - block.position.x
if (requiredShift > 0) {
blocksToShift.push({ block, shiftAmount: requiredShift })
}
Expand Down Expand Up @@ -115,8 +115,8 @@ export function compactHorizontally(blocks: Record<string, BlockState>, edges: E
const prevBlock = blockArray[i - 1]
const currentBlock = blockArray[i]

const prevDims = getBlockDimensions(prevBlock)
const expectedX = prevBlock.position.x + prevDims.width + MIN_SPACING
const prevMetrics = getBlockMetrics(prevBlock)
const expectedX = prevBlock.position.x + prevMetrics.width + MIN_SPACING

if (currentBlock.position.x > expectedX + 150) {
const shift = currentBlock.position.x - expectedX
Expand Down
5 changes: 3 additions & 2 deletions apps/sim/lib/workflows/autolayout/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { adjustForNewBlock as adjustForNewBlockInternal, compactHorizontally } f
import { assignLayers, groupByLayer } from './layering'
import { calculatePositions } from './positioning'
import type { AdjustmentOptions, Edge, LayoutOptions, LayoutResult, Loop, Parallel } from './types'
import { getBlocksByParent } from './utils'
import { getBlocksByParent, prepareBlockMetrics } from './utils'

const logger = createLogger('AutoLayout')

Expand Down Expand Up @@ -39,6 +39,7 @@ export function applyAutoLayout(

if (Object.keys(rootBlocks).length > 0) {
const nodes = assignLayers(rootBlocks, rootEdges)
prepareBlockMetrics(nodes)
const layers = groupByLayer(nodes)
calculatePositions(layers, options)

Expand Down Expand Up @@ -99,4 +100,4 @@ export function adjustForNewBlock(
}

export type { LayoutOptions, LayoutResult, AdjustmentOptions, Edge, Loop, Parallel }
export { getBlockDimensions, isContainerType } from './utils'
export { getBlockMetrics, isContainerType } from './utils'
59 changes: 37 additions & 22 deletions apps/sim/lib/workflows/autolayout/layering.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { createLogger } from '@/lib/logs/console/logger'
import type { BlockState } from '@/stores/workflows/workflow/types'
import type { Edge, GraphNode } from './types'
import { getBlockDimensions, isStarterBlock } from './utils'
import { getBlockMetrics } from './utils'

const logger = createLogger('AutoLayout:Layering')

Expand All @@ -15,7 +15,7 @@ export function assignLayers(
nodes.set(id, {
id,
block,
dimensions: getBlockDimensions(block),
metrics: getBlockMetrics(block),
incoming: new Set(),
outgoing: new Set(),
layer: 0,
Expand All @@ -33,45 +33,60 @@ export function assignLayers(
}
}

const starterNodes = Array.from(nodes.values()).filter(
(node) => node.incoming.size === 0 || isStarterBlock(node.block)
)
// Only treat blocks as starters if they have no incoming edges
// This prevents triggers that are mid-flow from being forced to layer 0
const starterNodes = Array.from(nodes.values()).filter((node) => node.incoming.size === 0)

if (starterNodes.length === 0 && nodes.size > 0) {
const firstNode = Array.from(nodes.values())[0]
starterNodes.push(firstNode)
logger.warn('No starter blocks found, using first block as starter', { blockId: firstNode.id })
}

const visited = new Set<string>()
const queue: Array<{ nodeId: string; layer: number }> = []
// Use topological sort to ensure proper layering based on dependencies
// Each node's layer = max(all incoming nodes' layers) + 1
const inDegreeCount = new Map<string, number>()

for (const starter of starterNodes) {
starter.layer = 0
queue.push({ nodeId: starter.id, layer: 0 })
for (const node of nodes.values()) {
inDegreeCount.set(node.id, node.incoming.size)
if (starterNodes.includes(node)) {
node.layer = 0
}
}

while (queue.length > 0) {
const { nodeId, layer } = queue.shift()!

if (visited.has(nodeId)) {
continue
}
const queue: string[] = starterNodes.map((n) => n.id)
const processed = new Set<string>()

visited.add(nodeId)
while (queue.length > 0) {
const nodeId = queue.shift()!
const node = nodes.get(nodeId)!
node.layer = Math.max(node.layer, layer)
processed.add(nodeId)

// Calculate this node's layer based on all incoming edges
if (node.incoming.size > 0) {
let maxIncomingLayer = -1
for (const incomingId of node.incoming) {
const incomingNode = nodes.get(incomingId)
if (incomingNode) {
maxIncomingLayer = Math.max(maxIncomingLayer, incomingNode.layer)
}
}
node.layer = maxIncomingLayer + 1
}

// Add outgoing nodes to queue when all their dependencies are processed
for (const targetId of node.outgoing) {
const targetNode = nodes.get(targetId)
if (targetNode) {
queue.push({ nodeId: targetId, layer: layer + 1 })
const currentCount = inDegreeCount.get(targetId) || 0
inDegreeCount.set(targetId, currentCount - 1)

if (inDegreeCount.get(targetId) === 0 && !processed.has(targetId)) {
queue.push(targetId)
}
}
}

for (const node of nodes.values()) {
if (!visited.has(node.id)) {
if (!processed.has(node.id)) {
logger.debug('Isolated node detected, assigning to layer 0', { blockId: node.id })
node.layer = 0
}
Expand Down
Loading