mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
@@ -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)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user