chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,830 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage, toError } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import type { Edge } from 'reactflow'
|
||||
import { getTargetedLayoutImpact } from '@/lib/workflows/autolayout'
|
||||
import type { BlockWithDiff } from '@/lib/workflows/diff/types'
|
||||
import { isValidKey } from '@/lib/workflows/sanitization/key-validation'
|
||||
import { isUuid } from '@/executor/constants'
|
||||
import { mergeSubblockState } from '@/stores/workflows/utils'
|
||||
import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const logger = createLogger('WorkflowDiffEngine')
|
||||
|
||||
function parseWorkflowStateJson(jsonContent: string): WorkflowState {
|
||||
const parsed = JSON.parse(jsonContent) as unknown
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
throw new Error('Diff content must be a workflow state object')
|
||||
}
|
||||
|
||||
const candidate = parsed as Partial<WorkflowState>
|
||||
if (
|
||||
!candidate.blocks ||
|
||||
typeof candidate.blocks !== 'object' ||
|
||||
Array.isArray(candidate.blocks) ||
|
||||
!Array.isArray(candidate.edges)
|
||||
) {
|
||||
throw new Error('Diff content is missing workflow blocks or edges')
|
||||
}
|
||||
|
||||
return {
|
||||
blocks: candidate.blocks,
|
||||
edges: candidate.edges,
|
||||
loops: candidate.loops ?? {},
|
||||
parallels: candidate.parallels ?? {},
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to check if a block has changed
|
||||
function hasBlockChanged(currentBlock: BlockState, proposedBlock: BlockState): boolean {
|
||||
// Compare key fields that indicate a change
|
||||
if (currentBlock.type !== proposedBlock.type) return true
|
||||
if (currentBlock.name !== proposedBlock.name) return true
|
||||
if (currentBlock.enabled !== proposedBlock.enabled) return true
|
||||
if (currentBlock.triggerMode !== proposedBlock.triggerMode) return true
|
||||
if ((currentBlock.data?.parentId ?? null) !== (proposedBlock.data?.parentId ?? null)) return true
|
||||
|
||||
// Compare subBlocks
|
||||
const currentSubKeys = Object.keys(currentBlock.subBlocks || {})
|
||||
const proposedSubKeys = Object.keys(proposedBlock.subBlocks || {})
|
||||
|
||||
if (currentSubKeys.length !== proposedSubKeys.length) return true
|
||||
|
||||
for (const key of currentSubKeys) {
|
||||
if (!proposedSubKeys.includes(key)) return true
|
||||
const currentSub = currentBlock.subBlocks[key]
|
||||
const proposedSub = proposedBlock.subBlocks?.[key]
|
||||
if (!proposedSub) return true
|
||||
if (JSON.stringify(currentSub.value) !== JSON.stringify(proposedSub.value)) return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Helper function to compute field differences between blocks
|
||||
function computeFieldDiff(
|
||||
currentBlock: BlockState,
|
||||
proposedBlock: BlockState
|
||||
): {
|
||||
changedFields: string[]
|
||||
unchangedFields: string[]
|
||||
} {
|
||||
const changedFields: string[] = []
|
||||
const unchangedFields: string[] = []
|
||||
|
||||
// Check basic fields
|
||||
const fieldsToCheck = ['type', 'name', 'enabled', 'triggerMode', 'horizontalHandles'] as const
|
||||
for (const field of fieldsToCheck) {
|
||||
const currentValue = currentBlock[field]
|
||||
const proposedValue = proposedBlock[field]
|
||||
if (JSON.stringify(currentValue) !== JSON.stringify(proposedValue)) {
|
||||
changedFields.push(field)
|
||||
} else if (currentValue !== undefined) {
|
||||
unchangedFields.push(field)
|
||||
}
|
||||
}
|
||||
|
||||
if ((currentBlock.data?.parentId ?? null) !== (proposedBlock.data?.parentId ?? null)) {
|
||||
changedFields.push('parentId')
|
||||
} else {
|
||||
unchangedFields.push('parentId')
|
||||
}
|
||||
|
||||
// Check subBlocks - use just the key name for UI compatibility
|
||||
const currentSubKeys = Object.keys(currentBlock.subBlocks || {})
|
||||
const proposedSubKeys = Object.keys(proposedBlock.subBlocks || {})
|
||||
const allSubKeys = new Set([...currentSubKeys, ...proposedSubKeys])
|
||||
|
||||
for (const key of allSubKeys) {
|
||||
const currentSub = currentBlock.subBlocks?.[key]
|
||||
const proposedSub = proposedBlock.subBlocks?.[key]
|
||||
|
||||
if (!currentSub && proposedSub) {
|
||||
// New subblock
|
||||
changedFields.push(key)
|
||||
} else if (currentSub && !proposedSub) {
|
||||
// Deleted subblock
|
||||
changedFields.push(key)
|
||||
} else if (currentSub && proposedSub) {
|
||||
// Check if value changed
|
||||
if (JSON.stringify(currentSub.value) !== JSON.stringify(proposedSub.value)) {
|
||||
changedFields.push(key)
|
||||
} else {
|
||||
unchangedFields.push(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { changedFields, unchangedFields }
|
||||
}
|
||||
|
||||
interface DiffMetadata {
|
||||
source: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
interface EdgeDiff {
|
||||
new_edges: string[]
|
||||
deleted_edges: string[]
|
||||
unchanged_edges: string[]
|
||||
}
|
||||
|
||||
export interface DiffAnalysis {
|
||||
new_blocks: string[]
|
||||
edited_blocks: string[]
|
||||
deleted_blocks: string[]
|
||||
field_diffs?: Record<string, { changed_fields: string[]; unchanged_fields: string[] }>
|
||||
edge_diff?: EdgeDiff
|
||||
}
|
||||
|
||||
export interface WorkflowDiff {
|
||||
proposedState: WorkflowState
|
||||
diffAnalysis?: DiffAnalysis
|
||||
metadata: DiffMetadata
|
||||
}
|
||||
|
||||
export interface DiffResult {
|
||||
success: boolean
|
||||
diff?: WorkflowDiff
|
||||
errors?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean diff engine that handles workflow diff operations
|
||||
* without polluting core workflow stores
|
||||
*/
|
||||
export class WorkflowDiffEngine {
|
||||
private currentDiff: WorkflowDiff | undefined = undefined
|
||||
|
||||
/**
|
||||
* Create a diff from workflow state
|
||||
*/
|
||||
async createDiff(jsonContent: string, diffAnalysis?: DiffAnalysis): Promise<DiffResult> {
|
||||
try {
|
||||
logger.info('WorkflowDiffEngine.createDiff called with:', {
|
||||
jsonContentLength: jsonContent.length,
|
||||
diffAnalysis: diffAnalysis,
|
||||
diffAnalysisType: typeof diffAnalysis,
|
||||
diffAnalysisUndefined: diffAnalysis === undefined,
|
||||
diffAnalysisNull: diffAnalysis === null,
|
||||
})
|
||||
|
||||
// Get current workflow state for comparison
|
||||
const { useWorkflowStore } = await import('@/stores/workflows/workflow/store')
|
||||
const currentWorkflowState = useWorkflowStore.getState().getWorkflowState()
|
||||
|
||||
logger.info('WorkflowDiffEngine current workflow state:', {
|
||||
blockCount: Object.keys(currentWorkflowState.blocks || {}).length,
|
||||
edgeCount: currentWorkflowState.edges?.length || 0,
|
||||
hasLoops: Object.keys(currentWorkflowState.loops || {}).length > 0,
|
||||
hasParallels: Object.keys(currentWorkflowState.parallels || {}).length > 0,
|
||||
})
|
||||
|
||||
// Merge subblock values from subblock store to ensure manual edits are included in baseline
|
||||
let mergedBaseline: WorkflowState = currentWorkflowState
|
||||
try {
|
||||
mergedBaseline = {
|
||||
...currentWorkflowState,
|
||||
blocks: mergeSubblockState(currentWorkflowState.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: toError(mergeError).message,
|
||||
})
|
||||
}
|
||||
|
||||
const proposedState = parseWorkflowStateJson(jsonContent)
|
||||
return this.createDiffFromWorkflowState(proposedState, diffAnalysis, mergedBaseline)
|
||||
} catch (error) {
|
||||
logger.error('Failed to create diff:', error)
|
||||
return {
|
||||
success: false,
|
||||
errors: [getErrorMessage(error, 'Failed to create diff')],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a diff from a WorkflowState object directly (more efficient than YAML)
|
||||
* This follows the same logic as sim-agent's YamlDiffCreate handler
|
||||
*/
|
||||
async createDiffFromWorkflowState(
|
||||
proposedState: WorkflowState,
|
||||
diffAnalysis?: DiffAnalysis,
|
||||
baselineOverride?: WorkflowState
|
||||
): Promise<DiffResult & { diff?: WorkflowDiff }> {
|
||||
try {
|
||||
logger.info('WorkflowDiffEngine.createDiffFromWorkflowState called with:', {
|
||||
blockCount: Object.keys(proposedState.blocks || {}).length,
|
||||
edgeCount: proposedState.edges?.length || 0,
|
||||
hasDiffAnalysis: !!diffAnalysis,
|
||||
})
|
||||
|
||||
// Determine baseline for comparison
|
||||
const { useWorkflowStore } = await import('@/stores/workflows/workflow/store')
|
||||
const currentWorkflowState = useWorkflowStore.getState().getWorkflowState()
|
||||
|
||||
const hasBaselineOverride = !!baselineOverride
|
||||
const baselineForComparison =
|
||||
baselineOverride ?? this.currentDiff?.proposedState ?? currentWorkflowState
|
||||
const isEditingOnTopOfDiff = !baselineOverride && !!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 = baselineForComparison
|
||||
|
||||
// Only merge subblock values if we're comparing against original workflow
|
||||
// If editing on top of diff or using an explicit override, trust provided values
|
||||
if (!isEditingOnTopOfDiff && !hasBaselineOverride) {
|
||||
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: toError(mergeError).message,
|
||||
})
|
||||
}
|
||||
} 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
|
||||
const existingBlockMap: Record<string, { id: string; block: BlockState }> = {}
|
||||
for (const [id, block] of Object.entries(mergedBaseline.blocks)) {
|
||||
const key = `${block.type}:${block.name}`
|
||||
existingBlockMap[key] = { id, block }
|
||||
}
|
||||
|
||||
// Create ID mapping - preserve existing IDs where blocks match by type:name
|
||||
const idMap: Record<string, string> = {}
|
||||
const finalBlocks: Record<string, BlockState & BlockWithDiff> = {}
|
||||
|
||||
// First pass: build ID mappings
|
||||
for (const [proposedId, proposedBlock] of Object.entries(proposedState.blocks)) {
|
||||
// CRITICAL: Skip invalid block IDs to prevent "undefined" keys in workflow state
|
||||
if (!isValidKey(proposedId)) {
|
||||
logger.error('Invalid proposedId detected in proposed state', {
|
||||
proposedId,
|
||||
proposedId_type: typeof proposedId,
|
||||
blockType: proposedBlock?.type,
|
||||
blockName: proposedBlock?.name,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const key = `${proposedBlock.type}:${proposedBlock.name}`
|
||||
|
||||
// Check if this block exists in current state by type:name
|
||||
if (existingBlockMap[key]) {
|
||||
// Preserve existing ID
|
||||
idMap[proposedId] = existingBlockMap[key].id
|
||||
} else if (isUuid(proposedId)) {
|
||||
// New block with a valid UUID (e.g., from server's DB save) — keep it.
|
||||
// Minting a new UUID would create a mismatch between the client store
|
||||
// and the database, causing foreign key violations on subsequent
|
||||
// socket operations.
|
||||
idMap[proposedId] = proposedId
|
||||
} else {
|
||||
// New block with a non-UUID ID (e.g., from YAML parsing) — mint a UUID
|
||||
idMap[proposedId] = generateId()
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: build final blocks with mapped IDs
|
||||
for (const [proposedId, proposedBlock] of Object.entries(proposedState.blocks)) {
|
||||
// CRITICAL: Skip invalid block IDs to prevent "undefined" keys in workflow state
|
||||
if (!isValidKey(proposedId)) {
|
||||
logger.error('Invalid proposedId detected in proposed state (second pass)', {
|
||||
proposedId,
|
||||
proposedId_type: typeof proposedId,
|
||||
blockType: proposedBlock?.type,
|
||||
blockName: proposedBlock?.name,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const finalId = idMap[proposedId]
|
||||
|
||||
// CRITICAL: Validate finalId before using as key
|
||||
if (!isValidKey(finalId)) {
|
||||
logger.error('Invalid finalId generated from idMap', {
|
||||
proposedId,
|
||||
finalId,
|
||||
finalId_type: typeof finalId,
|
||||
blockType: proposedBlock?.type,
|
||||
blockName: proposedBlock?.name,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const key = `${proposedBlock.type}:${proposedBlock.name}`
|
||||
const existingBlock = existingBlockMap[key]?.block
|
||||
|
||||
// Merge with existing block if found, otherwise use proposed.
|
||||
// Position strategy depends on whether the block's scope changed:
|
||||
// - Same scope (parentId unchanged): preserve existing position so
|
||||
// unchanged blocks anchor correctly for targeted layout.
|
||||
// - Different scope (parentId changed): use proposed position because
|
||||
// the coordinate system changed (absolute ↔ relative-to-container).
|
||||
const existingParent = existingBlock?.data?.parentId ?? null
|
||||
const proposedParentRaw = proposedBlock.data?.parentId ?? null
|
||||
const proposedParent = proposedParentRaw
|
||||
? (idMap[proposedParentRaw] ?? proposedParentRaw)
|
||||
: null
|
||||
const scopeChanged = existingBlock ? existingParent !== proposedParent : false
|
||||
|
||||
const finalBlock: BlockState & BlockWithDiff = existingBlock
|
||||
? {
|
||||
...existingBlock,
|
||||
...proposedBlock,
|
||||
id: finalId,
|
||||
position: scopeChanged ? proposedBlock.position : existingBlock.position,
|
||||
}
|
||||
: {
|
||||
...proposedBlock,
|
||||
id: finalId,
|
||||
}
|
||||
|
||||
// Update parentId in data if it exists and has been remapped
|
||||
if (finalBlock.data?.parentId && idMap[finalBlock.data.parentId]) {
|
||||
finalBlock.data = {
|
||||
...finalBlock.data,
|
||||
parentId: idMap[finalBlock.data.parentId],
|
||||
}
|
||||
}
|
||||
|
||||
finalBlocks[finalId] = finalBlock
|
||||
}
|
||||
|
||||
// Map edges with new IDs and standardized handles
|
||||
const edgeMap = new Map<string, Edge>()
|
||||
|
||||
proposedState.edges.forEach((edge) => {
|
||||
const source = idMap[edge.source] || edge.source
|
||||
const target = idMap[edge.target] || edge.target
|
||||
const sourceHandle = edge.sourceHandle || 'source'
|
||||
const targetHandle = edge.targetHandle || 'target'
|
||||
|
||||
// Create a unique key for deduplication
|
||||
const edgeKey = `${source}-${sourceHandle}-${target}-${targetHandle}`
|
||||
|
||||
// Only add if we haven't seen this edge combination before
|
||||
if (!edgeMap.has(edgeKey)) {
|
||||
edgeMap.set(edgeKey, {
|
||||
...edge,
|
||||
id: generateId(), // Use UUID for unique edge IDs
|
||||
source,
|
||||
target,
|
||||
sourceHandle,
|
||||
targetHandle,
|
||||
type: edge.type || 'workflowEdge',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const finalEdges: Edge[] = Array.from(edgeMap.values())
|
||||
|
||||
// Build final proposed state
|
||||
// Always regenerate loops and parallels from finalBlocks because the block IDs may have
|
||||
// been remapped (via idMap) and the server's loops/parallels would have stale references.
|
||||
// This ensures the nodes arrays in loops/parallels contain the correct (remapped) block IDs,
|
||||
// which is critical for variable resolution in the tag dropdown.
|
||||
const { generateLoopBlocks, generateParallelBlocks } = await import(
|
||||
'@/stores/workflows/workflow/utils'
|
||||
)
|
||||
|
||||
// Build the proposed state
|
||||
const finalProposedState: WorkflowState = {
|
||||
blocks: finalBlocks,
|
||||
edges: finalEdges,
|
||||
loops: generateLoopBlocks(finalBlocks),
|
||||
parallels: generateParallelBlocks(finalBlocks),
|
||||
lastSaved: Date.now(),
|
||||
}
|
||||
|
||||
// Use the proposed state directly - validation happens at the source
|
||||
const fullyCleanedState = finalProposedState
|
||||
|
||||
// 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: toError(error).message,
|
||||
})
|
||||
}
|
||||
|
||||
// Apply autolayout to the proposed state
|
||||
logger.info('Applying autolayout to proposed workflow state')
|
||||
try {
|
||||
const { layoutBlockIds, resizedBlockIds, shiftSourceBlockIds } = getTargetedLayoutImpact({
|
||||
before: mergedBaseline,
|
||||
after: fullyCleanedState,
|
||||
})
|
||||
|
||||
const totalBlocks = Object.keys(finalBlocks).length
|
||||
|
||||
if (
|
||||
layoutBlockIds.length === 0 &&
|
||||
resizedBlockIds.length === 0 &&
|
||||
shiftSourceBlockIds.length === 0
|
||||
) {
|
||||
logger.info('No blocks need layout; skipping autolayout', {
|
||||
totalBlocks,
|
||||
})
|
||||
} else {
|
||||
// Always use targeted layout for copilot edits. When anchors exist
|
||||
// (some blocks unchanged), they preserve user positions. When no
|
||||
// anchors exist (all blocks are new), targeted layout degrades
|
||||
// gracefully to a full layout from the padding origin — same result
|
||||
// as applyAutoLayout but with one unified code path.
|
||||
logger.info('Using targeted layout for copilot edits', {
|
||||
blocksNeedingLayout: layoutBlockIds.length,
|
||||
resizedAnchorBlocks: resizedBlockIds.length,
|
||||
shiftSourceBlocks: shiftSourceBlockIds.length,
|
||||
anchors: totalBlocks - layoutBlockIds.length,
|
||||
totalBlocks,
|
||||
})
|
||||
|
||||
const { applyTargetedLayout } = await import('@/lib/workflows/autolayout')
|
||||
const { DEFAULT_HORIZONTAL_SPACING, DEFAULT_VERTICAL_SPACING } = await import(
|
||||
'@/lib/workflows/autolayout/constants'
|
||||
)
|
||||
|
||||
const layoutedBlocks = applyTargetedLayout(finalBlocks, fullyCleanedState.edges, {
|
||||
changedBlockIds: layoutBlockIds,
|
||||
resizedBlockIds,
|
||||
shiftSourceBlockIds,
|
||||
horizontalSpacing: DEFAULT_HORIZONTAL_SPACING,
|
||||
verticalSpacing: DEFAULT_VERTICAL_SPACING,
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
logger.info('Successfully applied targeted layout to proposed state', {
|
||||
blocksLayouted: Object.keys(layoutedBlocks).length,
|
||||
blocksNeedingLayout: layoutBlockIds.length,
|
||||
})
|
||||
}
|
||||
} catch (layoutError) {
|
||||
logger.warn('Error applying autolayout, using default positions', {
|
||||
error: toError(layoutError).message,
|
||||
})
|
||||
}
|
||||
|
||||
// Compute diff analysis if not provided
|
||||
let computed = diffAnalysis
|
||||
if (!computed) {
|
||||
// Generate diff analysis between current and proposed states
|
||||
const currentIds = new Set(Object.keys(mergedBaseline.blocks))
|
||||
const proposedIds = new Set(Object.keys(finalBlocks))
|
||||
|
||||
const newBlocks: string[] = []
|
||||
const editedBlocks: string[] = []
|
||||
const deletedBlocks: string[] = []
|
||||
|
||||
// Find new and edited blocks
|
||||
for (const [id, block] of Object.entries(finalBlocks)) {
|
||||
if (!currentIds.has(id)) {
|
||||
newBlocks.push(id)
|
||||
} else {
|
||||
// Check if block was edited by comparing key fields
|
||||
const currentBlock = mergedBaseline.blocks[id]
|
||||
if (hasBlockChanged(currentBlock, block)) {
|
||||
editedBlocks.push(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find deleted blocks
|
||||
for (const id of currentIds) {
|
||||
if (!proposedIds.has(id)) {
|
||||
deletedBlocks.push(id)
|
||||
}
|
||||
}
|
||||
|
||||
// Compute field diffs for edited blocks
|
||||
const fieldDiffs: Record<string, { changed_fields: string[]; unchanged_fields: string[] }> =
|
||||
{}
|
||||
for (const id of editedBlocks) {
|
||||
const currentBlock = mergedBaseline.blocks[id]
|
||||
const proposedBlock = finalBlocks[id]
|
||||
const { changedFields, unchangedFields } = computeFieldDiff(currentBlock, proposedBlock)
|
||||
if (changedFields.length > 0) {
|
||||
fieldDiffs[id] = {
|
||||
changed_fields: changedFields,
|
||||
unchanged_fields: unchangedFields,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Compute edge diffs
|
||||
const currentEdgeSet = new Set<string>()
|
||||
const proposedEdgeSet = new Set<string>()
|
||||
|
||||
// Create edge identifiers for current state (using sim-agent format)
|
||||
mergedBaseline.edges.forEach((edge: Edge) => {
|
||||
const edgeId = `${edge.source}-${edge.sourceHandle || 'source'}-${edge.target}-${edge.targetHandle || 'target'}`
|
||||
currentEdgeSet.add(edgeId)
|
||||
})
|
||||
|
||||
// Create edge identifiers for proposed state
|
||||
fullyCleanedState.edges.forEach((edge) => {
|
||||
const edgeId = `${edge.source}-${edge.sourceHandle || 'source'}-${edge.target}-${edge.targetHandle || 'target'}`
|
||||
proposedEdgeSet.add(edgeId)
|
||||
})
|
||||
|
||||
// Classify edges
|
||||
const newEdges: string[] = []
|
||||
const deletedEdges: string[] = []
|
||||
const unchangedEdges: string[] = []
|
||||
|
||||
// Find new edges (in proposed but not current)
|
||||
proposedEdgeSet.forEach((edgeId) => {
|
||||
if (!currentEdgeSet.has(edgeId)) {
|
||||
newEdges.push(edgeId)
|
||||
} else {
|
||||
unchangedEdges.push(edgeId)
|
||||
}
|
||||
})
|
||||
|
||||
// Find deleted edges (in current but not proposed)
|
||||
currentEdgeSet.forEach((edgeId) => {
|
||||
if (!proposedEdgeSet.has(edgeId)) {
|
||||
deletedEdges.push(edgeId)
|
||||
}
|
||||
})
|
||||
|
||||
computed = {
|
||||
new_blocks: newBlocks,
|
||||
edited_blocks: editedBlocks,
|
||||
deleted_blocks: deletedBlocks,
|
||||
field_diffs: Object.keys(fieldDiffs).length > 0 ? fieldDiffs : undefined,
|
||||
edge_diff: {
|
||||
new_edges: newEdges,
|
||||
deleted_edges: deletedEdges,
|
||||
unchanged_edges: unchangedEdges,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Apply diff markers to blocks in the fully cleaned state
|
||||
if (computed) {
|
||||
for (const id of computed.new_blocks || []) {
|
||||
if (fullyCleanedState.blocks[id]) {
|
||||
;(fullyCleanedState.blocks[id] as any).is_diff = 'new'
|
||||
}
|
||||
}
|
||||
for (const id of computed.edited_blocks || []) {
|
||||
if (fullyCleanedState.blocks[id]) {
|
||||
;(fullyCleanedState.blocks[id] as any).is_diff = 'edited'
|
||||
|
||||
// Also mark specific subblocks that changed
|
||||
if (computed.field_diffs?.[id]) {
|
||||
const fieldDiff = computed.field_diffs[id]
|
||||
const block = fullyCleanedState.blocks[id]
|
||||
|
||||
// Apply diff markers to changed subblocks
|
||||
for (const changedField of fieldDiff.changed_fields) {
|
||||
if (block.subBlocks?.[changedField]) {
|
||||
// Add a diff marker to the subblock itself
|
||||
;(block.subBlocks[changedField] as any).is_diff = 'changed'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Note: We don't remove deleted blocks from fullyCleanedState, just mark them
|
||||
}
|
||||
|
||||
// Store the diff with the fully sanitized state
|
||||
this.currentDiff = {
|
||||
proposedState: fullyCleanedState,
|
||||
diffAnalysis: computed,
|
||||
metadata: {
|
||||
source: 'workflow_state',
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
}
|
||||
|
||||
logger.info('Successfully created diff from workflow state', {
|
||||
blockCount: Object.keys(fullyCleanedState.blocks).length,
|
||||
edgeCount: fullyCleanedState.edges.length,
|
||||
hasLoops: Object.keys(fullyCleanedState.loops || {}).length > 0,
|
||||
hasParallels: Object.keys(fullyCleanedState.parallels || {}).length > 0,
|
||||
newBlocks: computed?.new_blocks?.length || 0,
|
||||
editedBlocks: computed?.edited_blocks?.length || 0,
|
||||
deletedBlocks: computed?.deleted_blocks?.length || 0,
|
||||
newEdges: computed?.edge_diff?.new_edges?.length || 0,
|
||||
deletedEdges: computed?.edge_diff?.deleted_edges?.length || 0,
|
||||
unchangedEdges: computed?.edge_diff?.unchanged_edges?.length || 0,
|
||||
})
|
||||
|
||||
if (computed?.edge_diff?.deleted_edges && computed.edge_diff.deleted_edges.length > 0) {
|
||||
logger.info('Deleted edges detected:', {
|
||||
deletedEdges: computed.edge_diff.deleted_edges,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
diff: this.currentDiff,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to create diff from workflow state:', error)
|
||||
return {
|
||||
success: false,
|
||||
errors: [getErrorMessage(error, 'Failed to create diff from workflow state')],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge new workflow state into existing diff
|
||||
* Used for cumulative updates within the same message
|
||||
*/
|
||||
async mergeDiff(jsonContent: string, diffAnalysis?: DiffAnalysis): Promise<DiffResult> {
|
||||
try {
|
||||
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.createDiff(jsonContent, diffAnalysis)
|
||||
}
|
||||
|
||||
const proposedState = parseWorkflowStateJson(jsonContent)
|
||||
const result = await this.createDiffFromWorkflowState(
|
||||
proposedState,
|
||||
diffAnalysis,
|
||||
this.currentDiff.proposedState
|
||||
)
|
||||
|
||||
if (result.success && result.diff) {
|
||||
logger.info('Diff merged successfully', {
|
||||
totalBlocksCount: Object.keys(result.diff.proposedState.blocks).length,
|
||||
totalEdgesCount: result.diff.proposedState.edges.length,
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
logger.error('Failed to merge diff:', error)
|
||||
return {
|
||||
success: false,
|
||||
errors: [getErrorMessage(error, 'Failed to merge diff')],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current diff
|
||||
*/
|
||||
getCurrentDiff(): WorkflowDiff | undefined {
|
||||
return this.currentDiff
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the current diff
|
||||
*/
|
||||
clearDiff(): void {
|
||||
this.currentDiff = undefined
|
||||
logger.info('Diff cleared')
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a diff is active
|
||||
*/
|
||||
hasDiff(): boolean {
|
||||
return this.currentDiff !== undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the workflow state for display (either diff or provided state)
|
||||
*/
|
||||
getDisplayState(currentState: WorkflowState): WorkflowState {
|
||||
if (this.currentDiff) {
|
||||
return this.currentDiff.proposedState
|
||||
}
|
||||
return currentState
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept the diff and return the clean state
|
||||
*/
|
||||
acceptDiff(): WorkflowState | null {
|
||||
if (!this.currentDiff) {
|
||||
logger.warn('No diff to accept')
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
// Clean up the proposed state by removing diff markers
|
||||
const cleanState = stripWorkflowDiffMarkers(this.currentDiff.proposedState)
|
||||
|
||||
logger.info('Diff accepted', {
|
||||
blocksCount: Object.keys(cleanState.blocks).length,
|
||||
edgesCount: cleanState.edges.length,
|
||||
loopsCount: Object.keys(cleanState.loops).length,
|
||||
parallelsCount: Object.keys(cleanState.parallels).length,
|
||||
})
|
||||
|
||||
this.clearDiff()
|
||||
return cleanState
|
||||
} catch (error) {
|
||||
logger.error('Failed to accept diff:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes diff metadata from a workflow state so it can be persisted or re-used safely.
|
||||
*/
|
||||
export function stripWorkflowDiffMarkers(state: WorkflowState): WorkflowState {
|
||||
const cleanBlocks: Record<string, BlockState> = {}
|
||||
|
||||
for (const [blockId, block] of Object.entries(state.blocks || {})) {
|
||||
// Validate block ID at the source - skip invalid IDs
|
||||
if (!isValidKey(blockId)) {
|
||||
logger.error('Invalid blockId detected in stripWorkflowDiffMarkers', {
|
||||
blockId,
|
||||
blockId_type: typeof blockId,
|
||||
blockType: block?.type,
|
||||
blockName: block?.name,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const cleanBlock: BlockState = structuredClone(block)
|
||||
const blockWithDiff = cleanBlock as BlockState & BlockWithDiff
|
||||
blockWithDiff.is_diff = undefined
|
||||
blockWithDiff.field_diffs = undefined
|
||||
|
||||
if (cleanBlock.subBlocks) {
|
||||
Object.values(cleanBlock.subBlocks).forEach((subBlock) => {
|
||||
if (subBlock && typeof subBlock === 'object') {
|
||||
;(subBlock as any).is_diff = undefined
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (cleanBlock.outputs === undefined || cleanBlock.outputs === null) {
|
||||
cleanBlock.outputs = {}
|
||||
}
|
||||
|
||||
cleanBlocks[blockId] = cleanBlock
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
blocks: cleanBlocks,
|
||||
edges: structuredClone(state.edges || []),
|
||||
loops: structuredClone(state.loops || {}),
|
||||
parallels: structuredClone(state.parallels || {}),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user