fix(copilot): targeted auto-layout for copilot edits + custom tool persistence (#1546)

* fix autolayout and custom tools persistence

* fix

* fix preserving positions within subflow

* more fixes

* fix resizing

* consolidate constants
This commit is contained in:
Vikhyath Mondreti
2025-10-04 14:52:37 -07:00
committed by GitHub
parent 7cc9a23f99
commit 0e838940f1
9 changed files with 996 additions and 64 deletions
@@ -7,6 +7,7 @@ import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger'
import { getUserEntityPermissions } from '@/lib/permissions/utils'
import { generateRequestId } from '@/lib/utils'
import { extractAndPersistCustomTools } from '@/lib/workflows/custom-tools-persistence'
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/db-helpers'
import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/validation'
@@ -207,6 +208,21 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
)
}
// Extract and persist custom tools to database
try {
const { saved, errors } = await extractAndPersistCustomTools(workflowState, userId)
if (saved > 0) {
logger.info(`[${requestId}] Persisted ${saved} custom tool(s) to database`, { workflowId })
}
if (errors.length > 0) {
logger.warn(`[${requestId}] Some custom tools failed to persist`, { errors, workflowId })
}
} catch (error) {
logger.error(`[${requestId}] Failed to persist custom tools`, { error, workflowId })
}
// Update workflow's lastSynced timestamp
await db
.update(workflow)
@@ -5,6 +5,7 @@ import { eq } from 'drizzle-orm'
import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool'
import { createLogger } from '@/lib/logs/console/logger'
import { getBlockOutputs } from '@/lib/workflows/block-outputs'
import { extractAndPersistCustomTools } from '@/lib/workflows/custom-tools-persistence'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers'
import { validateWorkflowState } from '@/lib/workflows/validation'
import { getAllBlocks } from '@/blocks/registry'
@@ -845,7 +846,7 @@ async function getCurrentWorkflowStateFromDb(
export const editWorkflowServerTool: BaseServerTool<EditWorkflowParams, any> = {
name: 'edit_workflow',
async execute(params: EditWorkflowParams): Promise<any> {
async execute(params: EditWorkflowParams, context?: { userId: string }): Promise<any> {
const logger = createLogger('EditWorkflowServerTool')
const { operations, workflowId, currentUserWorkflow } = params
if (!operations || operations.length === 0) throw new Error('operations are required')
@@ -891,6 +892,29 @@ export const editWorkflowServerTool: BaseServerTool<EditWorkflowParams, any> = {
})
}
// Extract and persist custom tools to database
if (context?.userId) {
try {
const finalWorkflowState = validation.sanitizedState || modifiedWorkflowState
const { saved, errors } = await extractAndPersistCustomTools(
finalWorkflowState,
context.userId
)
if (saved > 0) {
logger.info(`Persisted ${saved} custom tool(s) to database`, { workflowId })
}
if (errors.length > 0) {
logger.warn('Some custom tools failed to persist', { errors, workflowId })
}
} catch (error) {
logger.error('Failed to persist custom tools', { error, workflowId })
}
} else {
logger.warn('No userId in context - skipping custom tools persistence', { workflowId })
}
logger.info('edit_workflow successfully applied operations', {
operationCount: operations.length,
blocksCount: Object.keys(modifiedWorkflowState.blocks).length,
@@ -4,6 +4,9 @@ import { assignLayers, groupByLayer } from './layering'
import { calculatePositions } from './positioning'
import type { Edge, LayoutOptions } from './types'
import {
CONTAINER_PADDING,
CONTAINER_PADDING_X,
CONTAINER_PADDING_Y,
DEFAULT_CONTAINER_HEIGHT,
DEFAULT_CONTAINER_WIDTH,
getBlocksByParent,
@@ -12,10 +15,6 @@ import {
const logger = createLogger('AutoLayout:Containers')
const CONTAINER_PADDING = 150
const CONTAINER_HORIZONTAL_PADDING = 180
const CONTAINER_VERTICAL_PADDING = 100
export function layoutContainers(
blocks: Record<string, BlockState>,
edges: Edge[],
@@ -26,7 +25,7 @@ export function layoutContainers(
const containerOptions: LayoutOptions = {
horizontalSpacing: options.horizontalSpacing ? options.horizontalSpacing * 0.85 : 400,
verticalSpacing: options.verticalSpacing ? options.verticalSpacing : 200,
padding: { x: CONTAINER_HORIZONTAL_PADDING, y: CONTAINER_VERTICAL_PADDING },
padding: { x: CONTAINER_PADDING_X, y: CONTAINER_PADDING_Y },
alignment: options.alignment,
}
@@ -68,8 +67,8 @@ export function layoutContainers(
}
// Adjust all child positions to start at proper padding from container edges
const xOffset = CONTAINER_HORIZONTAL_PADDING - minX
const yOffset = CONTAINER_VERTICAL_PADDING - minY
const xOffset = CONTAINER_PADDING_X - minX
const yOffset = CONTAINER_PADDING_Y - minY
for (const node of childNodes.values()) {
childBlocks[node.id].position = {
@@ -100,4 +100,6 @@ export function adjustForNewBlock(
}
export type { LayoutOptions, LayoutResult, AdjustmentOptions, Edge, Loop, Parallel }
export type { TargetedLayoutOptions } from './targeted'
export { applyTargetedLayout, transferBlockHeights } from './targeted'
export { getBlockMetrics, isContainerType } from './utils'
@@ -0,0 +1,352 @@
import { createLogger } from '@/lib/logs/console/logger'
import type { BlockState } from '@/stores/workflows/workflow/types'
import { assignLayers, groupByLayer } from './layering'
import { calculatePositions } from './positioning'
import type { Edge, LayoutOptions } from './types'
import {
CONTAINER_PADDING,
CONTAINER_PADDING_X,
CONTAINER_PADDING_Y,
DEFAULT_CONTAINER_HEIGHT,
DEFAULT_CONTAINER_WIDTH,
getBlockMetrics,
getBlocksByParent,
isContainerType,
prepareBlockMetrics,
ROOT_PADDING_X,
ROOT_PADDING_Y,
} from './utils'
const logger = createLogger('AutoLayout:Targeted')
export interface TargetedLayoutOptions extends LayoutOptions {
changedBlockIds: string[]
verticalSpacing?: number
horizontalSpacing?: number
}
export function applyTargetedLayout(
blocks: Record<string, BlockState>,
edges: Edge[],
options: TargetedLayoutOptions
): Record<string, BlockState> {
const { changedBlockIds, verticalSpacing = 200, horizontalSpacing = 550 } = options
if (!changedBlockIds || changedBlockIds.length === 0) {
return blocks
}
const changedSet = new Set(changedBlockIds)
const blocksCopy: Record<string, BlockState> = JSON.parse(JSON.stringify(blocks))
const groups = getBlocksByParent(blocksCopy)
layoutGroup(null, groups.root, blocksCopy, edges, changedSet, verticalSpacing, horizontalSpacing)
for (const [parentId, childIds] of groups.children.entries()) {
layoutGroup(
parentId,
childIds,
blocksCopy,
edges,
changedSet,
verticalSpacing,
horizontalSpacing
)
}
return blocksCopy
}
function layoutGroup(
parentId: string | null,
childIds: string[],
blocks: Record<string, BlockState>,
edges: Edge[],
changedSet: Set<string>,
verticalSpacing: number,
horizontalSpacing: number
): void {
if (childIds.length === 0) return
const parentBlock = parentId ? blocks[parentId] : undefined
const requestedLayout = childIds.filter((id) => {
const block = blocks[id]
if (!block) return false
// Never reposition containers, only update their dimensions
if (isContainerType(block.type)) return false
return changedSet.has(id)
})
const missingPositions = childIds.filter((id) => {
const block = blocks[id]
if (!block) return false
// Containers with missing positions should still get positioned
return !hasPosition(block)
})
const needsLayoutSet = new Set([...requestedLayout, ...missingPositions])
const needsLayout = Array.from(needsLayoutSet)
if (parentBlock) {
updateContainerDimensions(parentBlock, childIds, blocks)
}
// Always update container dimensions even if no blocks need repositioning
// This ensures containers resize properly when children are added/removed
if (needsLayout.length === 0) {
return
}
const oldPositions = new Map<string, { x: number; y: number }>()
for (const id of childIds) {
const block = blocks[id]
if (!block) continue
oldPositions.set(id, { ...block.position })
}
const layoutPositions = computeLayoutPositions(
childIds,
blocks,
edges,
parentBlock,
horizontalSpacing,
verticalSpacing
)
if (layoutPositions.size === 0) {
// No layout positions computed, but still update container dimensions
if (parentBlock) {
updateContainerDimensions(parentBlock, childIds, blocks)
}
return
}
let offsetX = 0
let offsetY = 0
const anchorId = childIds.find((id) => !needsLayout.includes(id) && layoutPositions.has(id))
if (anchorId) {
const oldPos = oldPositions.get(anchorId)
const newPos = layoutPositions.get(anchorId)
if (oldPos && newPos) {
offsetX = oldPos.x - newPos.x
offsetY = oldPos.y - newPos.y
}
} else {
// No anchor - positions from calculatePositions are already correct relative to padding
// Container positions are parent-relative, root positions are absolute
// The normalization in computeLayoutPositions already handled the padding offset
offsetX = 0
offsetY = 0
}
for (const id of needsLayout) {
const block = blocks[id]
const newPos = layoutPositions.get(id)
if (!block || !newPos) continue
block.position = {
x: newPos.x + offsetX,
y: newPos.y + offsetY,
}
}
}
function computeLayoutPositions(
childIds: string[],
blocks: Record<string, BlockState>,
edges: Edge[],
parentBlock: BlockState | undefined,
horizontalSpacing: number,
verticalSpacing: number
): Map<string, { x: number; y: number }> {
const subsetBlocks: Record<string, BlockState> = {}
for (const id of childIds) {
subsetBlocks[id] = blocks[id]
}
const subsetEdges = edges.filter(
(edge) => childIds.includes(edge.source) && childIds.includes(edge.target)
)
if (Object.keys(subsetBlocks).length === 0) {
return new Map()
}
const nodes = assignLayers(subsetBlocks, subsetEdges)
prepareBlockMetrics(nodes)
const layoutOptions: LayoutOptions = parentBlock
? {
horizontalSpacing: horizontalSpacing * 0.85,
verticalSpacing,
padding: { x: CONTAINER_PADDING_X, y: CONTAINER_PADDING_Y },
alignment: 'center',
}
: {
horizontalSpacing,
verticalSpacing,
padding: { x: ROOT_PADDING_X, y: ROOT_PADDING_Y },
alignment: 'center',
}
calculatePositions(groupByLayer(nodes), layoutOptions)
// Now normalize positions to start from 0,0 relative to the container/root
let minX = Number.POSITIVE_INFINITY
let minY = Number.POSITIVE_INFINITY
let maxX = Number.NEGATIVE_INFINITY
let maxY = Number.NEGATIVE_INFINITY
for (const node of nodes.values()) {
minX = Math.min(minX, node.position.x)
minY = Math.min(minY, node.position.y)
maxX = Math.max(maxX, node.position.x + node.metrics.width)
maxY = Math.max(maxY, node.position.y + node.metrics.height)
}
// Adjust all positions to be relative to the padding offset
const xOffset = (parentBlock ? CONTAINER_PADDING_X : ROOT_PADDING_X) - minX
const yOffset = (parentBlock ? CONTAINER_PADDING_Y : ROOT_PADDING_Y) - minY
const positions = new Map<string, { x: number; y: number }>()
for (const node of nodes.values()) {
positions.set(node.id, {
x: node.position.x + xOffset,
y: node.position.y + yOffset,
})
}
if (parentBlock) {
const calculatedWidth = maxX - minX + CONTAINER_PADDING * 2
const calculatedHeight = maxY - minY + CONTAINER_PADDING * 2
parentBlock.data = {
...parentBlock.data,
width: Math.max(calculatedWidth, DEFAULT_CONTAINER_WIDTH),
height: Math.max(calculatedHeight, DEFAULT_CONTAINER_HEIGHT),
}
}
return positions
}
function getBounds(positions: Map<string, { x: number; y: number }>) {
let minX = Number.POSITIVE_INFINITY
let minY = Number.POSITIVE_INFINITY
for (const pos of positions.values()) {
minX = Math.min(minX, pos.x)
minY = Math.min(minY, pos.y)
}
return { minX, minY }
}
function updateContainerDimensions(
parentBlock: BlockState,
childIds: string[],
blocks: Record<string, BlockState>
): void {
if (childIds.length === 0) {
// No children - use minimum dimensions
parentBlock.data = {
...parentBlock.data,
width: DEFAULT_CONTAINER_WIDTH,
height: DEFAULT_CONTAINER_HEIGHT,
}
parentBlock.layout = {
...parentBlock.layout,
measuredWidth: DEFAULT_CONTAINER_WIDTH,
measuredHeight: DEFAULT_CONTAINER_HEIGHT,
}
return
}
let minX = Number.POSITIVE_INFINITY
let minY = Number.POSITIVE_INFINITY
let maxX = Number.NEGATIVE_INFINITY
let maxY = Number.NEGATIVE_INFINITY
for (const id of childIds) {
const child = blocks[id]
if (!child) continue
const metrics = getBlockMetrics(child)
minX = Math.min(minX, child.position.x)
minY = Math.min(minY, child.position.y)
maxX = Math.max(maxX, child.position.x + metrics.width)
maxY = Math.max(maxY, child.position.y + metrics.height)
}
if (!Number.isFinite(minX) || !Number.isFinite(minY)) {
return
}
// Match the regular autolayout's dimension calculation
const calculatedWidth = maxX - minX + CONTAINER_PADDING * 2
const calculatedHeight = maxY - minY + CONTAINER_PADDING * 2
parentBlock.data = {
...parentBlock.data,
width: Math.max(calculatedWidth, DEFAULT_CONTAINER_WIDTH),
height: Math.max(calculatedHeight, DEFAULT_CONTAINER_HEIGHT),
}
parentBlock.layout = {
...parentBlock.layout,
measuredWidth: parentBlock.data.width,
measuredHeight: parentBlock.data.height,
}
}
function hasPosition(block: BlockState): boolean {
if (!block.position) return false
const { x, y } = block.position
return Number.isFinite(x) && Number.isFinite(y)
}
/**
* Estimate block heights for diff view by using current workflow measurements
* This provides better height estimates than using default values
*/
export function transferBlockHeights(
sourceBlocks: Record<string, BlockState>,
targetBlocks: Record<string, BlockState>
): void {
// Build a map of block type+name to heights from source
const heightMap = new Map<string, { height: number; width: number; isWide: boolean }>()
for (const [id, block] of Object.entries(sourceBlocks)) {
const key = `${block.type}:${block.name}`
heightMap.set(key, {
height: block.height || 100,
width: block.layout?.measuredWidth || (block.isWide ? 480 : 350),
isWide: block.isWide || false,
})
}
// Transfer heights to target blocks
for (const block of Object.values(targetBlocks)) {
const key = `${block.type}:${block.name}`
const measurements = heightMap.get(key)
if (measurements) {
block.height = measurements.height
block.isWide = measurements.isWide
if (!block.layout) {
block.layout = {}
}
block.layout.measuredHeight = measurements.height
block.layout.measuredWidth = measurements.width
}
}
logger.debug('Transferred block heights from source workflow', {
sourceCount: Object.keys(sourceBlocks).length,
targetCount: Object.keys(targetBlocks).length,
heightsMapped: heightMap.size,
})
}
@@ -9,6 +9,12 @@ export const DEFAULT_CONTAINER_WIDTH = 500
export const DEFAULT_CONTAINER_HEIGHT = 300
const DEFAULT_PADDING = 40
export const CONTAINER_PADDING = 150
export const CONTAINER_PADDING_X = 180
export const CONTAINER_PADDING_Y = 100
export const ROOT_PADDING_X = 150
export const ROOT_PADDING_Y = 150
function resolveNumeric(value: number | undefined, fallback: number): number {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback
}
@@ -0,0 +1,203 @@
import { db } from '@sim/db'
import { customTools } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('CustomToolsPersistence')
interface CustomTool {
id?: string
type: 'custom-tool'
title: string
toolId?: string
schema: {
function: {
name?: string
description: string
parameters: Record<string, any>
}
}
code: string
usageControl?: string
}
/**
* Extract all custom tools from agent blocks in the workflow state
*/
export function extractCustomToolsFromWorkflowState(workflowState: any): CustomTool[] {
const customToolsMap = new Map<string, CustomTool>()
if (!workflowState?.blocks) {
return []
}
for (const [blockId, block] of Object.entries(workflowState.blocks)) {
try {
const blockData = block as any
// Only process agent blocks
if (!blockData || blockData.type !== 'agent') {
continue
}
const subBlocks = blockData.subBlocks || {}
const toolsSubBlock = subBlocks.tools
if (!toolsSubBlock?.value) {
continue
}
let tools = toolsSubBlock.value
// Parse if it's a string
if (typeof tools === 'string') {
try {
tools = JSON.parse(tools)
} catch (error) {
logger.warn(`Failed to parse tools in block ${blockId}`, { error })
continue
}
}
if (!Array.isArray(tools)) {
continue
}
// Extract custom tools
for (const tool of tools) {
if (
tool &&
typeof tool === 'object' &&
tool.type === 'custom-tool' &&
tool.title &&
tool.schema?.function &&
tool.code
) {
// Use toolId if available, otherwise generate one from title
const toolKey = tool.toolId || tool.title
// Deduplicate by toolKey (if same tool appears in multiple blocks)
if (!customToolsMap.has(toolKey)) {
customToolsMap.set(toolKey, tool as CustomTool)
}
}
}
} catch (error) {
logger.error(`Error extracting custom tools from block ${blockId}`, { error })
}
}
return Array.from(customToolsMap.values())
}
/**
* Persist custom tools to the database
* Creates new tools or updates existing ones
*/
export async function persistCustomToolsToDatabase(
customToolsList: CustomTool[],
userId: string
): Promise<{ saved: number; errors: string[] }> {
if (!customToolsList || customToolsList.length === 0) {
return { saved: 0, errors: [] }
}
const errors: string[] = []
let saved = 0
try {
await db.transaction(async (tx) => {
for (const tool of customToolsList) {
try {
// Extract the base identifier (without 'custom_' prefix) for database storage
// If toolId exists and has the prefix, strip it; otherwise use title as base
let baseId: string
if (tool.toolId) {
baseId = tool.toolId.startsWith('custom_')
? tool.toolId.replace('custom_', '')
: tool.toolId
} else {
// Use title as the base identifier (agent handler will add 'custom_' prefix)
baseId = tool.title
}
const nowTime = new Date()
// Check if tool already exists
const existingTool = await tx
.select()
.from(customTools)
.where(eq(customTools.id, baseId))
.limit(1)
if (existingTool.length === 0) {
// Create new tool
await tx.insert(customTools).values({
id: baseId,
userId,
title: tool.title,
schema: tool.schema,
code: tool.code,
createdAt: nowTime,
updatedAt: nowTime,
})
logger.info(`Created custom tool: ${tool.title}`, { toolId: baseId })
saved++
} else if (existingTool[0].userId === userId) {
// Update existing tool if it belongs to the user
await tx
.update(customTools)
.set({
title: tool.title,
schema: tool.schema,
code: tool.code,
updatedAt: nowTime,
})
.where(eq(customTools.id, baseId))
logger.info(`Updated custom tool: ${tool.title}`, { toolId: baseId })
saved++
} else {
// Tool exists but belongs to different user - skip
logger.warn(`Skipping custom tool - belongs to different user: ${tool.title}`, {
toolId: baseId,
})
errors.push(`Tool ${tool.title} belongs to a different user`)
}
} catch (error) {
const errorMsg = `Failed to persist tool ${tool.title}: ${error instanceof Error ? error.message : String(error)}`
logger.error(errorMsg, { error })
errors.push(errorMsg)
}
}
})
} catch (error) {
const errorMsg = `Transaction failed while persisting custom tools: ${error instanceof Error ? error.message : String(error)}`
logger.error(errorMsg, { error })
errors.push(errorMsg)
}
return { saved, errors }
}
/**
* Extract and persist custom tools from workflow state in one operation
*/
export async function extractAndPersistCustomTools(
workflowState: any,
userId: string
): Promise<{ saved: number; errors: string[] }> {
const customToolsList = extractCustomToolsFromWorkflowState(workflowState)
if (customToolsList.length === 0) {
logger.debug('No custom tools found in workflow state')
return { saved: 0, errors: [] }
}
logger.info(`Found ${customToolsList.length} custom tool(s) to persist`, {
tools: customToolsList.map((t) => t.title),
})
return await persistCustomToolsToDatabase(customToolsList, userId)
}
+378 -48
View File
@@ -7,6 +7,206 @@ import type { BlockWithDiff } from './types'
const logger = createLogger('WorkflowDiffEngine')
type ParentIdentifier = string | null
function getParentId(block?: BlockState): ParentIdentifier {
return block?.data?.parentId ?? null
}
function buildEdgeKey(edge: Edge): string {
const sourceHandle = edge.sourceHandle ?? ''
const targetHandle = edge.targetHandle ?? ''
const edgeType = edge.type ?? ''
return `${edge.source}|${sourceHandle}->${edge.target}|${targetHandle}|${edgeType}`
}
function groupBlocksByParent(blocks: Record<string, BlockState>): {
root: string[]
children: Map<string, string[]>
} {
const root: string[] = []
const children = new Map<string, string[]>()
for (const [id, block] of Object.entries(blocks)) {
const parentId = getParentId(block)
if (!parentId) {
root.push(id)
continue
}
if (!children.has(parentId)) {
children.set(parentId, [])
}
children.get(parentId)!.push(id)
}
return { root, children }
}
function buildAdjacency(edges: Edge[]): Map<string, Set<string>> {
const adjacency = new Map<string, Set<string>>()
for (const edge of edges) {
if (!adjacency.has(edge.source)) {
adjacency.set(edge.source, new Set())
}
adjacency.get(edge.source)!.add(edge.target)
}
return adjacency
}
function expandImpactedBlocks(
seeds: Set<string>,
proposedBlocks: Record<string, BlockState>,
adjacency: Map<string, Set<string>>
): Set<string> {
const impacted = new Set<string>()
// Only expand to direct downstream neighbors (targets of impacted blocks)
// This ensures we make space for new/moved blocks without relocating unaffected ones
for (const seed of seeds) {
if (!proposedBlocks[seed]) continue
impacted.add(seed)
const seedBlock = proposedBlocks[seed]
const seedParent = getParentId(seedBlock)
const neighbors = adjacency.get(seed)
if (neighbors) {
for (const next of neighbors) {
const nextBlock = proposedBlocks[next]
if (!nextBlock) continue
// Only expand within same parent
if (getParentId(nextBlock) !== seedParent) continue
impacted.add(next)
}
}
}
return impacted
}
function computeStructuralLayoutImpact(params: {
baselineBlocks: Record<string, BlockState>
baselineEdges: Edge[]
proposedBlocks: Record<string, BlockState>
proposedEdges: Edge[]
}): {
impactedBlockIds: Set<string>
parentsToRelayout: Set<ParentIdentifier>
} {
const { baselineBlocks, baselineEdges, proposedBlocks, proposedEdges } = params
const impactedBlocks = new Set<string>()
const parentsToRelayout = new Set<ParentIdentifier>()
const baselineIds = new Set(Object.keys(baselineBlocks))
const proposedIds = new Set(Object.keys(proposedBlocks))
for (const id of proposedIds) {
if (!baselineIds.has(id)) {
impactedBlocks.add(id)
parentsToRelayout.add(getParentId(proposedBlocks[id]))
}
}
for (const id of baselineIds) {
if (!proposedIds.has(id)) {
parentsToRelayout.add(getParentId(baselineBlocks[id]))
}
}
for (const id of proposedIds) {
if (!baselineIds.has(id)) {
continue
}
const baselineBlock = baselineBlocks[id]
const proposedBlock = proposedBlocks[id]
const baselineParent = getParentId(baselineBlock)
const proposedParent = getParentId(proposedBlock)
if (baselineParent !== proposedParent) {
impactedBlocks.add(id)
parentsToRelayout.add(baselineParent)
parentsToRelayout.add(proposedParent)
}
}
const baselineEdgeMap = new Map<string, Edge>()
for (const edge of baselineEdges) {
baselineEdgeMap.set(buildEdgeKey(edge), edge)
}
const proposedEdgeMap = new Map<string, Edge>()
for (const edge of proposedEdges) {
proposedEdgeMap.set(buildEdgeKey(edge), edge)
}
for (const [key, edge] of proposedEdgeMap) {
if (baselineEdgeMap.has(key)) {
continue
}
if (proposedBlocks[edge.source]) {
impactedBlocks.add(edge.source)
}
if (proposedBlocks[edge.target]) {
impactedBlocks.add(edge.target)
}
}
for (const [key, edge] of baselineEdgeMap) {
if (proposedEdgeMap.has(key)) {
continue
}
if (proposedBlocks[edge.source]) {
impactedBlocks.add(edge.source)
}
if (proposedBlocks[edge.target]) {
impactedBlocks.add(edge.target)
}
parentsToRelayout.add(getParentId(baselineBlocks[edge.source]))
parentsToRelayout.add(getParentId(baselineBlocks[edge.target]))
}
const adjacency = buildAdjacency(proposedEdges)
const seedBlocks = new Set<string>()
for (const id of impactedBlocks) {
if (proposedBlocks[id]) {
seedBlocks.add(id)
}
}
const expandedImpacts = expandImpactedBlocks(seedBlocks, proposedBlocks, adjacency)
// Add parent containers to impacted set so their updated dimensions get transferred
const parentsWithImpactedChildren = new Set<string>()
for (const blockId of expandedImpacts) {
const block = proposedBlocks[blockId]
if (!block) continue
const parentId = getParentId(block)
if (parentId && proposedBlocks[parentId]) {
parentsWithImpactedChildren.add(parentId)
}
}
for (const parentId of parentsWithImpactedChildren) {
expandedImpacts.add(parentId)
}
return {
impactedBlockIds: expandedImpacts,
parentsToRelayout,
}
}
// Helper function to check if a block has changed
function hasBlockChanged(currentBlock: BlockState, proposedBlock: BlockState): boolean {
// Compare key fields that indicate a change
@@ -122,12 +322,12 @@ export class WorkflowDiffEngine {
private currentDiff: WorkflowDiff | undefined = undefined
/**
* Create a diff from YAML content
* Create a diff from workflow state
*/
async createDiffFromYaml(yamlContent: string, diffAnalysis?: DiffAnalysis): Promise<DiffResult> {
async createDiff(jsonContent: string, diffAnalysis?: DiffAnalysis): Promise<DiffResult> {
try {
logger.info('WorkflowDiffEngine.createDiffFromYaml called with:', {
yamlContentLength: yamlContent.length,
logger.info('WorkflowDiffEngine.createDiff called with:', {
jsonContentLength: jsonContent.length,
diffAnalysis: diffAnalysis,
diffAnalysisType: typeof diffAnalysis,
diffAnalysisUndefined: diffAnalysis === undefined,
@@ -163,7 +363,7 @@ export class WorkflowDiffEngine {
// Call the API route to create the diff
const body: any = {
yamlContent,
jsonContent,
currentWorkflowState: mergedBaseline,
}
@@ -211,7 +411,7 @@ export class WorkflowDiffEngine {
const result = await response.json()
logger.info('WorkflowDiffEngine.createDiffFromYaml response:', {
logger.info('WorkflowDiffEngine.createDiff response:', {
success: result.success,
hasDiff: !!result.diff,
errors: result.errors,
@@ -283,24 +483,45 @@ export class WorkflowDiffEngine {
hasDiffAnalysis: !!diffAnalysis,
})
// Get current workflow state for comparison
// Get baseline for comparison
// If we already have a diff, use it as baseline (editing on top of diff)
// Otherwise use the current workflow state
const { useWorkflowStore } = await import('@/stores/workflows/workflow/store')
const currentWorkflowState = useWorkflowStore.getState().getWorkflowState()
// Check if we're editing on top of an existing diff
const baselineForComparison = this.currentDiff?.proposedState || currentWorkflowState
const isEditingOnTopOfDiff = !!this.currentDiff
if (isEditingOnTopOfDiff) {
logger.info('Editing on top of existing diff - using diff as baseline for comparison', {
diffBlockCount: Object.keys(this.currentDiff!.proposedState.blocks).length,
})
}
// Merge subblock values from subblock store to ensure manual edits are included
let mergedBaseline: WorkflowState = currentWorkflowState
try {
mergedBaseline = {
...currentWorkflowState,
blocks: mergeSubblockState(currentWorkflowState.blocks),
let mergedBaseline: WorkflowState = baselineForComparison
// Only merge subblock values if we're comparing against original workflow
// If editing on top of diff, use the diff state as-is
if (!isEditingOnTopOfDiff) {
try {
mergedBaseline = {
...baselineForComparison,
blocks: mergeSubblockState(baselineForComparison.blocks),
}
logger.info('Merged subblock values into baseline for diff creation', {
blockCount: Object.keys(mergedBaseline.blocks || {}).length,
})
} catch (mergeError) {
logger.warn('Failed to merge subblock values into baseline; proceeding with raw state', {
error: mergeError instanceof Error ? mergeError.message : String(mergeError),
})
}
logger.info('Merged subblock values into baseline for diff creation', {
blockCount: Object.keys(mergedBaseline.blocks || {}).length,
})
} catch (mergeError) {
logger.warn('Failed to merge subblock values into baseline; proceeding with raw state', {
error: mergeError instanceof Error ? mergeError.message : String(mergeError),
})
} else {
logger.info(
'Using diff state as baseline without merging subblocks (editing on top of diff)'
)
}
// Build a map of existing blocks by type:name for matching
@@ -407,44 +628,153 @@ export class WorkflowDiffEngine {
finalProposedState.parallels = generateParallelBlocks(finalProposedState.blocks)
}
// Transfer block heights from baseline workflow for better measurements in diff view
// If editing on top of diff, this transfers from the diff (which already has good heights)
// Otherwise transfers from original workflow
logger.info('Transferring block heights from baseline workflow', {
isEditingOnTopOfDiff,
baselineBlockCount: Object.keys(mergedBaseline.blocks).length,
})
try {
const { transferBlockHeights } = await import('@/lib/workflows/autolayout')
transferBlockHeights(mergedBaseline.blocks, finalBlocks)
} catch (error) {
logger.warn('Failed to transfer block heights', {
error: error instanceof Error ? error.message : String(error),
})
}
// Apply autolayout to the proposed state
logger.info('Applying autolayout to proposed workflow state')
try {
const { applyAutoLayout: applyNativeAutoLayout } = await import(
'@/lib/workflows/autolayout'
)
// Compute diff analysis if not already provided to determine changed blocks
let tempComputed = diffAnalysis
if (!tempComputed) {
const currentIds = new Set(Object.keys(mergedBaseline.blocks))
const newBlocks: string[] = []
const editedBlocks: string[] = []
const autoLayoutOptions = {
horizontalSpacing: 550,
verticalSpacing: 200,
padding: {
x: 150,
y: 150,
},
alignment: 'center' as const,
for (const [id, block] of Object.entries(finalBlocks)) {
if (!currentIds.has(id)) {
newBlocks.push(id)
} else {
const currentBlock = mergedBaseline.blocks[id]
if (hasBlockChanged(currentBlock, block)) {
editedBlocks.push(id)
}
}
}
tempComputed = { new_blocks: newBlocks, edited_blocks: editedBlocks, deleted_blocks: [] }
}
const layoutResult = applyNativeAutoLayout(
finalBlocks,
finalProposedState.edges,
finalProposedState.loops || {},
finalProposedState.parallels || {},
autoLayoutOptions
)
const { impactedBlockIds } = computeStructuralLayoutImpact({
baselineBlocks: mergedBaseline.blocks,
baselineEdges: mergedBaseline.edges as Edge[],
proposedBlocks: finalBlocks,
proposedEdges: finalEdges,
})
if (layoutResult.success && layoutResult.blocks) {
Object.entries(layoutResult.blocks).forEach(([id, layoutBlock]) => {
const impactedBlockArray = Array.from(impactedBlockIds)
const totalBlocks = Object.keys(finalBlocks).length
const unchangedBlocks = totalBlocks - impactedBlockArray.length
if (impactedBlockArray.length === 0) {
logger.info('No structural changes detected; skipping autolayout', {
totalBlocks,
})
} else if (unchangedBlocks > 0) {
// Use targeted layout - preserves positions of unchanged blocks
logger.info('Using targeted layout for copilot edits (has unchanged blocks)', {
changedBlocks: impactedBlockArray.length,
unchangedBlocks: unchangedBlocks,
totalBlocks: totalBlocks,
percentChanged: Math.round((impactedBlockArray.length / totalBlocks) * 100),
})
const { applyTargetedLayout } = await import('@/lib/workflows/autolayout')
const layoutedBlocks = applyTargetedLayout(finalBlocks, finalProposedState.edges, {
changedBlockIds: impactedBlockArray,
horizontalSpacing: 550,
verticalSpacing: 200,
})
Object.entries(layoutedBlocks).forEach(([id, layoutBlock]) => {
if (finalBlocks[id]) {
finalBlocks[id].position = layoutBlock.position
if (layoutBlock.data) {
finalBlocks[id].data = {
...finalBlocks[id].data,
...layoutBlock.data,
}
}
if (layoutBlock.layout) {
finalBlocks[id].layout = {
...finalBlocks[id].layout,
...layoutBlock.layout,
}
}
if (typeof layoutBlock.height === 'number') {
finalBlocks[id].height = layoutBlock.height
}
if (typeof layoutBlock.isWide === 'boolean') {
finalBlocks[id].isWide = layoutBlock.isWide
}
}
})
logger.info('Successfully applied autolayout to proposed state', {
blocksLayouted: Object.keys(layoutResult.blocks).length,
logger.info('Successfully applied targeted layout to proposed state', {
blocksLayouted: Object.keys(layoutedBlocks).length,
changedBlocks: impactedBlockArray.length,
})
} else {
logger.warn('Autolayout failed, using default positions', {
error: layoutResult.error,
// Use full autolayout only when copilot built 100% of the workflow from scratch
logger.info('Using full autolayout (copilot built 100% of workflow)', {
totalBlocks: totalBlocks,
allBlocksAreNew: impactedBlockArray.length === totalBlocks,
})
const { applyAutoLayout: applyNativeAutoLayout } = await import(
'@/lib/workflows/autolayout'
)
const autoLayoutOptions = {
horizontalSpacing: 550,
verticalSpacing: 200,
padding: {
x: 150,
y: 150,
},
alignment: 'center' as const,
}
const layoutResult = applyNativeAutoLayout(
finalBlocks,
finalProposedState.edges,
finalProposedState.loops || {},
finalProposedState.parallels || {},
autoLayoutOptions
)
if (layoutResult.success && layoutResult.blocks) {
Object.entries(layoutResult.blocks).forEach(([id, layoutBlock]) => {
if (finalBlocks[id]) {
finalBlocks[id].position = layoutBlock.position
}
})
logger.info('Successfully applied full autolayout to proposed state', {
blocksLayouted: Object.keys(layoutResult.blocks).length,
})
} else {
logger.warn('Autolayout failed, using default positions', {
error: layoutResult.error,
})
}
}
} catch (layoutError) {
logger.warn('Error applying autolayout, using default positions', {
@@ -622,23 +952,23 @@ export class WorkflowDiffEngine {
}
/**
* Merge new YAML content into existing diff
* Merge new workflow state into existing diff
* Used for cumulative updates within the same message
*/
async mergeDiffFromYaml(yamlContent: string, diffAnalysis?: DiffAnalysis): Promise<DiffResult> {
async mergeDiff(jsonContent: string, diffAnalysis?: DiffAnalysis): Promise<DiffResult> {
try {
logger.info('Merging diff from YAML content')
logger.info('Merging diff from workflow state')
// If no existing diff, create a new one
if (!this.currentDiff) {
logger.info('No existing diff, creating new diff')
return this.createDiffFromYaml(yamlContent, diffAnalysis)
return this.createDiff(jsonContent, diffAnalysis)
}
// Call the API route to merge the diff
const body: any = {
existingDiff: this.currentDiff,
yamlContent,
jsonContent,
}
if (diffAnalysis !== undefined && diffAnalysis !== null) {
+8 -8
View File
@@ -60,8 +60,8 @@ interface WorkflowDiffState {
}
interface WorkflowDiffActions {
setProposedChanges: (yamlContent: string, diffAnalysis?: DiffAnalysis) => Promise<void>
mergeProposedChanges: (yamlContent: string, diffAnalysis?: DiffAnalysis) => Promise<void>
setProposedChanges: (jsonContent: string, diffAnalysis?: DiffAnalysis) => Promise<void>
mergeProposedChanges: (jsonContent: string, diffAnalysis?: DiffAnalysis) => Promise<void>
clearDiff: () => void
getCurrentWorkflowForCanvas: () => WorkflowState
toggleDiffView: () => void
@@ -131,10 +131,10 @@ export const useWorkflowDiffStore = create<WorkflowDiffState & WorkflowDiffActio
let result: { success: boolean; diff?: WorkflowDiff; errors?: string[] }
// Handle both YAML string and direct WorkflowState object
// Handle both JSON string and direct WorkflowState object
if (typeof proposedContent === 'string') {
// Legacy YAML path (for backward compatibility)
result = await diffEngine.createDiffFromYaml(proposedContent, diffAnalysis)
// JSON string path (for backward compatibility)
result = await diffEngine.createDiff(proposedContent, diffAnalysis)
} else {
// Direct WorkflowState path (new, more efficient)
result = await diffEngine.createDiffFromWorkflowState(proposedContent, diffAnalysis)
@@ -214,13 +214,13 @@ export const useWorkflowDiffStore = create<WorkflowDiffState & WorkflowDiffActio
}
},
mergeProposedChanges: async (yamlContent: string, diffAnalysis?: DiffAnalysis) => {
logger.info('Merging proposed changes via YAML')
mergeProposedChanges: async (jsonContent: string, diffAnalysis?: DiffAnalysis) => {
logger.info('Merging proposed changes from workflow state')
// First, set isDiffReady to false to prevent premature rendering
batchedUpdate({ isDiffReady: false, diffError: null })
const result = await diffEngine.mergeDiffFromYaml(yamlContent, diffAnalysis)
const result = await diffEngine.mergeDiff(jsonContent, diffAnalysis)
if (result.success && result.diff) {
// Validate proposed workflow using serializer round-trip to catch canvas-breaking issues