From 0a1717d21d43f0c552fde61a23f4f986d318e367 Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Tue, 29 Sep 2026 22:49:41 -0700 Subject: [PATCH 01/14] feat(workflows): describe container changes and build a canvas diff overlay The comparison engine now reports which loop and parallel fields changed and which blocks entered or left a container, and exposes the field lists and a shared hasChanges helper. A new overlay module merges two workflow states into one canvas: removed blocks, containers and edges ride along as ghosts at their old positions (nudged clear of live cards), deleted condition and router branches stay on the surviving card so their ghost edge keeps a handle, and every edge is classified by canonical port key. The renderer edge view gains a quiet ghost style for those removed connections. --- apps/sim/lib/workflows/autolayout/index.ts | 8 +- .../lib/workflows/comparison/compare.test.ts | 111 ++++++ apps/sim/lib/workflows/comparison/compare.ts | 143 ++++++-- apps/sim/lib/workflows/comparison/index.ts | 11 + .../lib/workflows/comparison/overlay.test.ts | 337 ++++++++++++++++++ apps/sim/lib/workflows/comparison/overlay.ts | 215 +++++++++++ .../src/edge/workflow-edge-view.tsx | 5 +- packages/workflow-renderer/src/types.ts | 6 +- 8 files changed, 812 insertions(+), 24 deletions(-) create mode 100644 apps/sim/lib/workflows/comparison/overlay.test.ts create mode 100644 apps/sim/lib/workflows/comparison/overlay.ts diff --git a/apps/sim/lib/workflows/autolayout/index.ts b/apps/sim/lib/workflows/autolayout/index.ts index c9a23767c06..f7cd7457177 100644 --- a/apps/sim/lib/workflows/autolayout/index.ts +++ b/apps/sim/lib/workflows/autolayout/index.ts @@ -100,8 +100,14 @@ export { getTargetedLayoutImpact, } from '@/lib/workflows/autolayout/change-set' export { applyTargetedLayout } from '@/lib/workflows/autolayout/targeted' -export type { Edge, LayoutOptions, LayoutResult } from '@/lib/workflows/autolayout/types' +export type { + BoundingBox, + Edge, + LayoutOptions, + LayoutResult, +} from '@/lib/workflows/autolayout/types' export { + boxesOverlap, getBlockMetrics, isContainerType, shouldSkipAutoLayout, diff --git a/apps/sim/lib/workflows/comparison/compare.test.ts b/apps/sim/lib/workflows/comparison/compare.test.ts index ec363ac41ab..8fa7c6b0fe5 100644 --- a/apps/sim/lib/workflows/comparison/compare.test.ts +++ b/apps/sim/lib/workflows/comparison/compare.test.ts @@ -1620,3 +1620,114 @@ describe('generateWorkflowDiffSummary', () => { }) }) }) + +describe('containerChanges', () => { + function stateWithLoop(loop: Record, blocks: Record = {}) { + return createWorkflowState({ + blocks: { + loop1: createBlock('loop1', { type: 'loop', name: 'My Loop' }), + ...blocks, + }, + loops: { loop1: { id: 'loop1', enabled: true, ...loop } }, + }) + } + + it.concurrent('describes a reconfigured loop field by field, named after its block', () => { + const previous = stateWithLoop({ nodes: ['a'], loopType: 'for', iterations: 2 }) + const current = stateWithLoop({ nodes: ['a'], loopType: 'for', iterations: 5 }) + + const result = generateWorkflowDiffSummary(current, previous) + + expect(result.loopChanges).toEqual({ added: 0, removed: 0, modified: 1 }) + expect(result.containerChanges).toEqual([ + { + id: 'loop1', + kind: 'loop', + name: 'My Loop', + changes: [{ field: 'iterations', oldValue: 2, newValue: 5 }], + nodesAdded: [], + nodesRemoved: [], + }, + ]) + }) + + it.concurrent( + 'reports membership moves and a loop type switch with the fields that came and went', + () => { + const previous = stateWithLoop({ nodes: ['a', 'b'], loopType: 'for', iterations: 2 }) + const current = stateWithLoop({ + nodes: ['b', 'c'], + loopType: 'forEach', + forEachItems: '', + /* Stale count left behind by the switch; the normalized shape drops it for forEach. */ + iterations: 2, + }) + + const [change] = generateWorkflowDiffSummary(current, previous).containerChanges + + expect(change.nodesAdded).toEqual(['c']) + expect(change.nodesRemoved).toEqual(['a']) + expect(change.changes).toEqual([ + { field: 'loopType', oldValue: 'for', newValue: 'forEach' }, + { field: 'iterations', oldValue: 2, newValue: null }, + { field: 'forEachItems', oldValue: null, newValue: '' }, + ]) + } + ) + + it.concurrent( + 'describes a parallel and skips containers that were added, removed or unchanged', + () => { + const previous = createWorkflowState({ + blocks: { + par1: createBlock('par1', { type: 'parallel' }), + parGone: createBlock('parGone', { type: 'parallel' }), + loopSame: createBlock('loopSame', { type: 'loop' }), + }, + parallels: { + par1: { id: 'par1', nodes: ['x'], parallelType: 'count', count: 2, enabled: true }, + parGone: { id: 'parGone', nodes: [], parallelType: 'count', count: 1, enabled: true }, + }, + loops: { loopSame: { id: 'loopSame', nodes: ['y', 'z'], loopType: 'for', iterations: 1 } }, + }) + const current = createWorkflowState({ + blocks: { + par1: createBlock('par1', { type: 'parallel' }), + parNew: createBlock('parNew', { type: 'parallel' }), + loopSame: createBlock('loopSame', { type: 'loop' }), + }, + parallels: { + par1: { + id: 'par1', + nodes: ['x'], + parallelType: 'collection', + distribution: '', + enabled: true, + }, + parNew: { id: 'parNew', nodes: [], parallelType: 'count', count: 3, enabled: true }, + }, + /* Same membership in a different order is not a change. */ + loops: { loopSame: { id: 'loopSame', nodes: ['z', 'y'], loopType: 'for', iterations: 1 } }, + }) + + const result = generateWorkflowDiffSummary(current, previous) + + expect(result.parallelChanges).toEqual({ added: 1, removed: 1, modified: 1 }) + expect(result.loopChanges).toEqual({ added: 0, removed: 0, modified: 0 }) + expect(result.containerChanges).toEqual([ + { + id: 'par1', + kind: 'parallel', + name: 'Block par1', + changes: [ + { field: 'parallelType', oldValue: 'count', newValue: 'collection' }, + { field: 'count', oldValue: 2, newValue: null }, + { field: 'distribution', oldValue: null, newValue: '' }, + ], + nodesAdded: [], + nodesRemoved: [], + }, + ]) + } + ) +}) diff --git a/apps/sim/lib/workflows/comparison/compare.ts b/apps/sim/lib/workflows/comparison/compare.ts index ccc6e62c57d..e9e2bb0d248 100644 --- a/apps/sim/lib/workflows/comparison/compare.ts +++ b/apps/sim/lib/workflows/comparison/compare.ts @@ -42,6 +42,37 @@ interface FieldChange { newValue: unknown } +/** The loop configuration fields a container diff compares, in the order they are reported. */ +export const LOOP_CONFIG_FIELDS = [ + 'loopType', + 'iterations', + 'forEachItems', + 'whileCondition', + 'doWhileCondition', +] as const + +/** The parallel configuration fields a container diff compares, in the order they are reported. */ +export const PARALLEL_CONFIG_FIELDS = ['parallelType', 'count', 'distribution'] as const + +export type ContainerConfigField = + | (typeof LOOP_CONFIG_FIELDS)[number] + | (typeof PARALLEL_CONFIG_FIELDS)[number] + +/** + * A loop or parallel container present on both sides whose configuration or + * membership differs. Added and removed containers are reported as blocks. + */ +export interface ContainerChange { + id: string + kind: 'loop' | 'parallel' + name?: string + changes: FieldChange[] + /** Block ids that are inside the container on the current side only */ + nodesAdded: string[] + /** Block ids that were inside the container on the previous side only */ + nodesRemoved: string[] +} + /** * Result of workflow diff analysis between two workflow states */ @@ -57,6 +88,7 @@ export interface WorkflowDiffSummary { } loopChanges: { added: number; removed: number; modified: number } parallelChanges: { added: number; removed: number; modified: number } + containerChanges: ContainerChange[] variableChanges: { added: number removed: number @@ -68,6 +100,62 @@ export interface WorkflowDiffSummary { hasChanges: boolean } +/** + * Field-by-field description of a container that changed, from the normalized + * shapes the equality check already uses so the two can never disagree. + */ +function describeContainerChange( + id: string, + kind: ContainerChange['kind'], + name: string | undefined, + current: { nodes: string[] } | null, + previous: { nodes: string[] } | null, + fields: readonly string[] +): ContainerChange { + const changes: FieldChange[] = [] + const currentRecord = (current ?? {}) as Record + const previousRecord = (previous ?? {}) as Record + for (const field of fields) { + if (normalizedStringify(currentRecord[field]) !== normalizedStringify(previousRecord[field])) { + changes.push({ + field, + oldValue: previousRecord[field] ?? null, + newValue: currentRecord[field] ?? null, + }) + } + } + const currentNodes = new Set(current?.nodes ?? []) + const previousNodes = new Set(previous?.nodes ?? []) + return { + id, + kind, + name, + changes, + nodesAdded: [...currentNodes].filter((node) => !previousNodes.has(node)), + nodesRemoved: [...previousNodes].filter((node) => !currentNodes.has(node)), + } +} + +/** Whether any counted section of a summary reports a difference. */ +export function summaryHasChanges(summary: Omit): boolean { + return ( + summary.addedBlocks.length > 0 || + summary.removedBlocks.length > 0 || + summary.modifiedBlocks.length > 0 || + summary.edgeChanges.added > 0 || + summary.edgeChanges.removed > 0 || + summary.loopChanges.added > 0 || + summary.loopChanges.removed > 0 || + summary.loopChanges.modified > 0 || + summary.parallelChanges.added > 0 || + summary.parallelChanges.removed > 0 || + summary.parallelChanges.modified > 0 || + summary.variableChanges.added > 0 || + summary.variableChanges.removed > 0 || + summary.variableChanges.modified > 0 + ) +} + /** * Generate a detailed diff summary between two workflow states */ @@ -82,6 +170,7 @@ export function generateWorkflowDiffSummary( edgeChanges: { added: 0, removed: 0, addedDetails: [], removedDetails: [] }, loopChanges: { added: 0, removed: 0, modified: 0 }, parallelChanges: { added: 0, removed: 0, modified: 0 }, + containerChanges: [], variableChanges: { added: 0, removed: 0, @@ -354,10 +443,23 @@ export function generateWorkflowDiffSummary( if (!previousLoopIds.includes(id)) { result.loopChanges.added++ } else { - const normalizedCurrent = normalizeValue(normalizeLoop(currentLoops[id])) - const normalizedPrevious = normalizeValue(normalizeLoop(previousLoops[id])) - if (normalizedStringify(normalizedCurrent) !== normalizedStringify(normalizedPrevious)) { + const normalizedCurrent = normalizeLoop(currentLoops[id]) + const normalizedPrevious = normalizeLoop(previousLoops[id]) + if ( + normalizedStringify(normalizeValue(normalizedCurrent)) !== + normalizedStringify(normalizeValue(normalizedPrevious)) + ) { result.loopChanges.modified++ + result.containerChanges.push( + describeContainerChange( + id, + 'loop', + currentBlocks[id]?.name ?? previousBlocks[id]?.name, + normalizedCurrent ?? null, + normalizedPrevious ?? null, + LOOP_CONFIG_FIELDS + ) + ) } } } @@ -376,10 +478,23 @@ export function generateWorkflowDiffSummary( if (!previousParallelIds.includes(id)) { result.parallelChanges.added++ } else { - const normalizedCurrent = normalizeValue(normalizeParallel(currentParallels[id])) - const normalizedPrevious = normalizeValue(normalizeParallel(previousParallels[id])) - if (normalizedStringify(normalizedCurrent) !== normalizedStringify(normalizedPrevious)) { + const normalizedCurrent = normalizeParallel(currentParallels[id]) + const normalizedPrevious = normalizeParallel(previousParallels[id]) + if ( + normalizedStringify(normalizeValue(normalizedCurrent)) !== + normalizedStringify(normalizeValue(normalizedPrevious)) + ) { result.parallelChanges.modified++ + result.containerChanges.push( + describeContainerChange( + id, + 'parallel', + currentBlocks[id]?.name ?? previousBlocks[id]?.name, + normalizedCurrent ?? null, + normalizedPrevious ?? null, + PARALLEL_CONFIG_FIELDS + ) + ) } } } @@ -417,21 +532,7 @@ export function generateWorkflowDiffSummary( } } - result.hasChanges = - result.addedBlocks.length > 0 || - result.removedBlocks.length > 0 || - result.modifiedBlocks.length > 0 || - result.edgeChanges.added > 0 || - result.edgeChanges.removed > 0 || - result.loopChanges.added > 0 || - result.loopChanges.removed > 0 || - result.loopChanges.modified > 0 || - result.parallelChanges.added > 0 || - result.parallelChanges.removed > 0 || - result.parallelChanges.modified > 0 || - result.variableChanges.added > 0 || - result.variableChanges.removed > 0 || - result.variableChanges.modified > 0 + result.hasChanges = summaryHasChanges(result) return result } diff --git a/apps/sim/lib/workflows/comparison/index.ts b/apps/sim/lib/workflows/comparison/index.ts index 400dd278eee..3f460ce753f 100644 --- a/apps/sim/lib/workflows/comparison/index.ts +++ b/apps/sim/lib/workflows/comparison/index.ts @@ -1,9 +1,20 @@ export { + type ContainerChange, + type ContainerConfigField, generateWorkflowDiffSummary, hasWorkflowChanged, + LOOP_CONFIG_FIELDS, + PARALLEL_CONFIG_FIELDS, + summaryHasChanges, type WorkflowDiffSummary, } from './compare' export { normalizedStringify, normalizeWorkflowState, } from './normalize' +export { + type BlockDiffStatus, + buildWorkflowDiffOverlay, + type EdgeDiffStatus, + type WorkflowDiffOverlay, +} from './overlay' diff --git a/apps/sim/lib/workflows/comparison/overlay.test.ts b/apps/sim/lib/workflows/comparison/overlay.test.ts new file mode 100644 index 00000000000..a70662790cc --- /dev/null +++ b/apps/sim/lib/workflows/comparison/overlay.test.ts @@ -0,0 +1,337 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import type { WorkflowState } from '@/stores/workflows/workflow/types' +import { generateWorkflowDiffSummary, type WorkflowDiffSummary } from './compare' +import { buildWorkflowDiffOverlay } from './overlay' + +/** Overlay only reads position, data and ids; the cast keeps the fixtures short. */ +function asState(state: Record): WorkflowState { + return state as unknown as WorkflowState +} + +interface BlockOptions { + type?: string + position?: { x: number; y: number } + data?: Record + height?: number + subBlocks?: Record +} + +function block(id: string, options: BlockOptions = {}) { + return { + id, + type: options.type ?? 'function', + name: id, + position: options.position ?? { x: 0, y: 0 }, + subBlocks: options.subBlocks ?? {}, + outputs: {}, + enabled: true, + ...(options.height !== undefined ? { height: options.height } : {}), + ...(options.data ? { data: options.data } : {}), + } +} + +function emptySummary(): WorkflowDiffSummary { + return { + addedBlocks: [], + removedBlocks: [], + modifiedBlocks: [], + edgeChanges: { added: 0, removed: 0, addedDetails: [], removedDetails: [] }, + loopChanges: { added: 0, removed: 0, modified: 0 }, + parallelChanges: { added: 0, removed: 0, modified: 0 }, + containerChanges: [], + variableChanges: { + added: 0, + removed: 0, + modified: 0, + addedNames: [], + removedNames: [], + modifiedNames: [], + }, + hasChanges: false, + } +} + +describe('buildWorkflowDiffOverlay', () => { + it('maps the summary onto block status and changed fields, including moves and containers', () => { + const base = asState({ + blocks: { + keep: block('keep'), + gone: block('gone', { position: { x: 900, y: 900 } }), + mod: block('mod'), + mover: block('mover', { data: { parentId: 'loop1' } }), + loop1: block('loop1', { type: 'loop' }), + }, + edges: [], + loops: { loop1: { id: 'loop1', nodes: ['mover'], loopType: 'for', iterations: 1 } }, + }) + const target = asState({ + blocks: { + keep: block('keep'), + fresh: block('fresh'), + mod: block('mod'), + mover: block('mover'), + loop1: block('loop1', { type: 'loop' }), + }, + edges: [], + loops: { loop1: { id: 'loop1', nodes: [], loopType: 'for', iterations: 1 } }, + }) + const summary: WorkflowDiffSummary = { + ...emptySummary(), + addedBlocks: [{ id: 'fresh', type: 'function', name: 'fresh' }], + removedBlocks: [{ id: 'gone', type: 'function', name: 'gone' }], + modifiedBlocks: [ + { + id: 'mod', + type: 'function', + name: 'mod', + changes: [ + { field: 'code', oldValue: 'a', newValue: 'b' }, + { field: 'name', oldValue: 'x', newValue: 'y' }, + ], + }, + ], + containerChanges: [ + { + id: 'loop1', + kind: 'loop', + name: 'loop1', + changes: [], + nodesAdded: [], + nodesRemoved: ['mover'], + }, + ], + hasChanges: true, + } + + const overlay = buildWorkflowDiffOverlay(summary, base, target) + + expect(overlay.blockStatus).toEqual({ + fresh: 'added', + gone: 'removed', + mod: 'modified', + loop1: 'modified', + mover: 'modified', + }) + expect(overlay.changedFieldsByBlock).toEqual({ mod: ['code', 'name'] }) + /* The ghost is drawn at its old spot; nothing lived there so it is not nudged. */ + expect(overlay.mergedState.blocks.gone).toBe(base.blocks.gone) + expect(Object.keys(overlay.mergedState.blocks).sort()).toEqual([ + 'fresh', + 'gone', + 'keep', + 'loop1', + 'mod', + 'mover', + ]) + }) + + it('never downgrades an added or removed container to modified', () => { + const base = asState({ blocks: {}, edges: [], loops: {} }) + const target = asState({ + blocks: { loop1: block('loop1', { type: 'loop' }) }, + edges: [], + loops: { loop1: { id: 'loop1', nodes: [], loopType: 'for', iterations: 1 } }, + }) + const summary: WorkflowDiffSummary = { + ...emptySummary(), + addedBlocks: [{ id: 'loop1', type: 'loop', name: 'loop1' }], + containerChanges: [ + { id: 'loop1', kind: 'loop', changes: [], nodesAdded: ['x'], nodesRemoved: [] }, + ], + hasChanges: true, + } + + expect(buildWorkflowDiffOverlay(summary, base, target).blockStatus).toEqual({ + loop1: 'added', + }) + }) + + it('slides a ghost below the live card that took its place, but leaves container children put', () => { + const base = asState({ + blocks: { + gone: block('gone', { position: { x: 10, y: 10 }, height: 100 }), + loop1: block('loop1', { type: 'loop' }), + goneChild: block('goneChild', { + position: { x: 0, y: 0 }, + data: { parentId: 'loop1' }, + }), + }, + edges: [], + loops: { loop1: { id: 'loop1', nodes: ['goneChild'], loopType: 'for', iterations: 1 } }, + }) + const target = asState({ + blocks: { + squatter: block('squatter', { position: { x: 0, y: 0 }, data: { height: 120 } }), + /* Stacked directly below the squatter so the first nudge lands on it too. */ + second: block('second', { position: { x: 0, y: 152 }, height: 80 }), + /* A child at the same spot must not count as a collision for a top-level ghost. */ + loop1: block('loop1', { type: 'loop', position: { x: 2000, y: 2000 } }), + liveChild: block('liveChild', { + position: { x: 10, y: 10 }, + data: { parentId: 'loop1' }, + }), + }, + edges: [], + loops: { loop1: { id: 'loop1', nodes: ['liveChild'], loopType: 'for', iterations: 1 } }, + }) + const summary: WorkflowDiffSummary = { + ...emptySummary(), + removedBlocks: [ + { id: 'gone', type: 'function', name: 'gone' }, + { id: 'goneChild', type: 'function', name: 'goneChild' }, + ], + addedBlocks: [ + { id: 'squatter', type: 'function', name: 'squatter' }, + { id: 'second', type: 'function', name: 'second' }, + { id: 'liveChild', type: 'function', name: 'liveChild' }, + ], + hasChanges: true, + } + + const merged = buildWorkflowDiffOverlay(summary, base, target).mergedState + + /* 0 + 120 + 32 = 152 hits `second`, so a second nudge lands at 152 + 80 + 32. */ + expect(merged.blocks.gone.position).toEqual({ x: 10, y: 264 }) + expect(merged.blocks.gone).not.toBe(base.blocks.gone) + expect(merged.blocks.goneChild).toBe(base.blocks.goneChild) + expect(merged.blocks.goneChild.position).toEqual({ x: 0, y: 0 }) + }) + + it('ghosts removed loops and parallels only when their block was removed', () => { + const base = asState({ + blocks: { + loopGone: block('loopGone', { type: 'loop' }), + parKept: block('parKept', { type: 'parallel' }), + }, + edges: [], + loops: { loopGone: { id: 'loopGone', nodes: [], loopType: 'for', iterations: 2 } }, + parallels: { parKept: { id: 'parKept', nodes: [], parallelType: 'count', count: 2 } }, + }) + const target = asState({ + blocks: { parKept: block('parKept', { type: 'parallel' }) }, + edges: [], + /* The target dropped the parallel config while keeping the block: not a removed block. */ + parallels: {}, + }) + const summary: WorkflowDiffSummary = { + ...emptySummary(), + removedBlocks: [{ id: 'loopGone', type: 'loop', name: 'loopGone' }], + hasChanges: true, + } + + const merged = buildWorkflowDiffOverlay(summary, base, target).mergedState + + expect(merged.loops).toEqual(base.loops) + expect(merged.parallels).toEqual({}) + }) + + it('marks added and removed edges, suffixing a removed edge that shares an id with a rewired one', () => { + const blocks = { a: block('a'), b: block('b'), c: block('c') } + const base = asState({ + blocks, + edges: [ + { id: 'same', source: 'a', target: 'b', sourceHandle: 'source', targetHandle: 'target' }, + /* Same port as the target's `e-keep`, spelled with an empty handle. */ + { id: 'e-keep-old', source: 'b', target: 'c', sourceHandle: '', targetHandle: null }, + { id: 'e-gone', source: 'c', target: 'a', sourceHandle: 'error' }, + ], + }) + const target = asState({ + blocks, + edges: [ + { id: 'same', source: 'a', target: 'c', sourceHandle: 'source', targetHandle: 'target' }, + { id: 'e-keep', source: 'b', target: 'c', sourceHandle: null, targetHandle: null }, + ], + variables: { v1: { id: 'v1', name: 'kept' } }, + }) + + const overlay = buildWorkflowDiffOverlay(emptySummary(), base, target) + + expect(overlay.edgeStatus).toEqual({ + same: 'added', + same__removed: 'removed', + 'e-gone': 'removed', + }) + expect(overlay.mergedState.edges.map((edge) => edge.id)).toEqual([ + 'same', + 'e-keep', + 'same__removed', + 'e-gone', + ]) + const ghost = overlay.mergedState.edges.find((edge) => edge.id === 'same__removed') + expect(ghost).toMatchObject({ source: 'a', target: 'b' }) + /* Everything else on the target side rides along untouched. */ + expect(overlay.mergedState.variables).toEqual(target.variables) + }) + + it('agrees with generateWorkflowDiffSummary on what counts as unchanged', () => { + const base = asState({ + blocks: { a: block('a'), b: block('b', { subBlocks: { code: { value: 'x' } } }) }, + edges: [{ id: 'e1', source: 'a', target: 'b', sourceHandle: null, targetHandle: null }], + loops: {}, + parallels: {}, + }) + const target = asState({ + blocks: { a: block('a'), b: block('b', { subBlocks: { code: { value: 'y' } } }) }, + /* An empty handle and an absent one name the same port on both sides. */ + edges: [{ id: 'e1', source: 'a', target: 'b', sourceHandle: '', targetHandle: undefined }], + loops: {}, + parallels: {}, + }) + + const summary = generateWorkflowDiffSummary(target, base) + const overlay = buildWorkflowDiffOverlay(summary, base, target) + + expect(summary.edgeChanges).toMatchObject({ added: 0, removed: 0 }) + expect(overlay.edgeStatus).toEqual({}) + expect(overlay.blockStatus).toEqual({ b: 'modified' }) + expect(overlay.changedFieldsByBlock).toEqual({ b: ['code'] }) + }) + + it('keeps a deleted branch on the surviving card so its ghost edge has a handle to leave from', () => { + const conditions = (items: Array<{ id: string; value: string }>) => ({ + conditions: { value: JSON.stringify(items) }, + }) + const base = asState({ + blocks: { + cond: block('cond', { + type: 'condition', + subBlocks: conditions([ + { id: 'c-if', value: 'a' }, + { id: 'c-elif', value: 'b' }, + { id: 'c-else', value: '' }, + ]), + }), + b: block('b'), + }, + edges: [{ id: 'e', source: 'cond', target: 'b', sourceHandle: 'condition-c-elif' }], + }) + const target = asState({ + blocks: { + cond: block('cond', { + type: 'condition', + subBlocks: conditions([ + { id: 'c-if', value: 'a' }, + { id: 'c-else', value: '' }, + ]), + }), + b: block('b'), + }, + edges: [], + }) + + const overlay = buildWorkflowDiffOverlay(emptySummary(), base, target) + + expect(overlay.edgeStatus).toEqual({ e: 'removed' }) + expect( + JSON.parse(overlay.mergedState.blocks.cond.subBlocks.conditions.value as string).map( + (item: { id: string }) => item.id + ) + ).toEqual(['c-if', 'c-else', 'c-elif']) + /* An untouched block is passed through by identity. */ + expect(overlay.mergedState.blocks.b).toBe(target.blocks.b) + }) +}) diff --git a/apps/sim/lib/workflows/comparison/overlay.ts b/apps/sim/lib/workflows/comparison/overlay.ts new file mode 100644 index 00000000000..0cd02c076bf --- /dev/null +++ b/apps/sim/lib/workflows/comparison/overlay.ts @@ -0,0 +1,215 @@ +import { BLOCK_DIMENSIONS } from '@sim/workflow-renderer' +import { normalizeWorkflowEdgeHandles } from '@sim/workflow-types/workflow' +import { type BoundingBox, boxesOverlap } from '@/lib/workflows/autolayout' +import type { WorkflowDiffSummary } from '@/lib/workflows/comparison/compare' +import { normalizedStringify, normalizeEdge } from '@/lib/workflows/comparison/normalize' +import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types' + +/** How a block on the target side relates to the base side. */ +export type BlockDiffStatus = 'added' | 'removed' | 'modified' + +/** How an edge on the merged canvas relates to the base side. */ +export type EdgeDiffStatus = 'added' | 'removed' + +/** + * Everything the canvas needs to paint a comparison of two workflow states. + * + * `mergedState` is the target state plus every block, container and edge that + * only the base side has, so removed things render as ghosts at the position + * they used to occupy instead of vanishing. Status maps are keyed by the ids in + * `mergedState`, and `changedFieldsByBlock` lists the sub-block ids the summary + * reported as changed so a card can highlight its own rows. + */ +export interface WorkflowDiffOverlay { + mergedState: WorkflowState + blockStatus: Record + edgeStatus: Record + changedFieldsByBlock: Record +} + +/** + * One key per port-to-port connection, canonicalized the same way the summary + * canonicalizes edges so an edge counted as unchanged there can never paint as + * added or removed here. + */ +function edgeKey(edge: WorkflowState['edges'][number]): string { + return normalizedStringify(normalizeEdge(edge)) +} + +const GHOST_GAP = 32 +const MAX_NUDGES = 8 + +/** Sub-block fields whose list items each own a source handle on the canvas card. */ +const BRANCH_LIST_FIELDS = ['conditions', 'routes'] as const + +function boxOf(block: BlockState): BoundingBox { + const data = block.data as { width?: number; height?: number } | undefined + return { + x: block.position?.x ?? 0, + y: block.position?.y ?? 0, + width: data?.width ?? BLOCK_DIMENSIONS.FIXED_WIDTH, + height: data?.height ?? block.height ?? BLOCK_DIMENSIONS.MIN_HEIGHT, + } +} + +/** + * A removed block keeps its old position, but a live block may have moved into + * that spot since. Slide the ghost straight down until it clears every card in + * `occupied` so it reads as "used to be around here" rather than sitting + * underneath something, and record its final box so later ghosts avoid it too. + * Children of a container stay put: their coordinates are relative to the + * parent and the parent already frames them. + */ +function nudgeOutOfCollision(ghost: BlockState, occupied: BoundingBox[]): BlockState { + if (ghost.data?.parentId) return ghost + const box = boxOf(ghost) + let nudges = 0 + let hit = occupied.find((other) => boxesOverlap(box, other)) + while (hit && nudges < MAX_NUDGES) { + box.y = hit.y + hit.height + GHOST_GAP + nudges += 1 + hit = occupied.find((other) => boxesOverlap(box, other)) + } + occupied.push(box) + if (nudges === 0) return ghost + return { ...ghost, position: { x: box.x, y: box.y } } +} + +function readBranchList(value: unknown): Array> | null { + if (typeof value !== 'string') return null + let parsed: unknown + try { + parsed = JSON.parse(value) + } catch { + return null + } + if (!Array.isArray(parsed)) return null + return parsed.filter( + (item): item is Record => Boolean(item) && typeof item === 'object' + ) +} + +/** + * A surviving condition or router block draws one source handle per branch it + * still has, so a ghost edge for a branch that was deleted would have nowhere + * to start and the canvas would drop it. Carry the base side's missing branches + * onto the merged card, in the JSON string the field persists, so the handle + * exists. + */ +function withRemovedBranches(baseBlock: BlockState, targetBlock: BlockState): BlockState { + let merged = targetBlock + for (const field of BRANCH_LIST_FIELDS) { + const baseItems = readBranchList(baseBlock.subBlocks?.[field]?.value) + const targetItems = readBranchList(targetBlock.subBlocks?.[field]?.value) + if (!baseItems || !targetItems) continue + const present = new Set(targetItems.map((item) => item.id)) + const missing = baseItems.filter((item) => typeof item.id === 'string' && !present.has(item.id)) + if (missing.length === 0) continue + merged = { + ...merged, + subBlocks: { + ...merged.subBlocks, + [field]: { + ...merged.subBlocks[field], + value: JSON.stringify([...targetItems, ...missing]), + }, + }, + } + } + return merged +} + +/** + * Builds the canvas overlay for a base → target comparison. + * + * @param summary - The diff summary for the same pair (target compared against base) + * @param baseState - The older or source side + * @param targetState - The newer or destination side + */ +export function buildWorkflowDiffOverlay( + summary: WorkflowDiffSummary, + baseState: WorkflowState, + targetState: WorkflowState +): WorkflowDiffOverlay { + const blockStatus: Record = {} + const changedFieldsByBlock: Record = {} + + for (const block of summary.addedBlocks) blockStatus[block.id] = 'added' + for (const block of summary.removedBlocks) blockStatus[block.id] = 'removed' + for (const block of summary.modifiedBlocks) { + blockStatus[block.id] = 'modified' + changedFieldsByBlock[block.id] = block.changes.map((change) => change.field) + } + /* A reconfigured container, and a surviving block whose parent changed, both read as modified. */ + for (const container of summary.containerChanges) { + blockStatus[container.id] ??= 'modified' + } + const blocks: WorkflowState['blocks'] = {} + for (const [id, block] of Object.entries(targetState.blocks)) { + const before = baseState.blocks[id] + if (!before) { + blocks[id] = block + continue + } + if ((before.data?.parentId ?? null) !== (block.data?.parentId ?? null)) { + blockStatus[id] ??= 'modified' + } + blocks[id] = withRemovedBranches(before, block) + } + + const occupied = Object.values(blocks) + .filter((block) => !block.data?.parentId) + .map(boxOf) + for (const block of summary.removedBlocks) { + const baseBlock = baseState.blocks[block.id] + if (baseBlock && !blocks[block.id]) blocks[block.id] = nudgeOutOfCollision(baseBlock, occupied) + } + + const loops = { ...(targetState.loops ?? {}) } + for (const [id, loop] of Object.entries(baseState.loops ?? {})) { + if (!loops[id] && blockStatus[id] === 'removed') loops[id] = loop + } + const parallels = { ...(targetState.parallels ?? {}) } + for (const [id, parallel] of Object.entries(baseState.parallels ?? {})) { + if (!parallels[id] && blockStatus[id] === 'removed') parallels[id] = parallel + } + + const targetKeyed = normalizeWorkflowEdgeHandles(targetState.edges ?? []).map( + (edge) => [edge, edgeKey(edge)] as const + ) + const baseKeyed = normalizeWorkflowEdgeHandles(baseState.edges ?? []).map( + (edge) => [edge, edgeKey(edge)] as const + ) + const targetKeys = new Set(targetKeyed.map(([, key]) => key)) + const baseKeys = new Set(baseKeyed.map(([, key]) => key)) + + const edgeStatus: Record = {} + const edges: WorkflowState['edges'] = [] + const seenIds = new Set() + for (const [edge, key] of targetKeyed) { + edges.push(edge) + seenIds.add(edge.id) + if (!baseKeys.has(key)) edgeStatus[edge.id] = 'added' + } + for (const [edge, key] of baseKeyed) { + if (targetKeys.has(key)) continue + /* A base edge can share an id with a rewired target edge; keep both drawable. */ + const id = seenIds.has(edge.id) ? `${edge.id}__removed` : edge.id + seenIds.add(id) + edges.push({ ...edge, id }) + edgeStatus[id] = 'removed' + } + + return { + mergedState: { + ...targetState, + blocks, + edges, + loops, + parallels, + }, + blockStatus, + edgeStatus, + changedFieldsByBlock, + } +} diff --git a/packages/workflow-renderer/src/edge/workflow-edge-view.tsx b/packages/workflow-renderer/src/edge/workflow-edge-view.tsx index bfac19a23e1..d842f0fd497 100644 --- a/packages/workflow-renderer/src/edge/workflow-edge-view.tsx +++ b/packages/workflow-renderer/src/edge/workflow-edge-view.tsx @@ -169,6 +169,9 @@ export function WorkflowEdgeView({ if (diffStatus === 'deleted') { color = 'var(--text-error)' opacity = 0.7 + } else if (diffStatus === 'ghost') { + color = 'var(--text-tertiary)' + opacity = 0.7 } else if (diffStatus === 'new') { color = 'var(--brand-accent)' } else if (isWorkflowRunning) { @@ -194,7 +197,7 @@ export function WorkflowEdgeView({ } return { - strokeWidth: diffStatus ? 2.5 : hasRunStatus ? 2 : 1.5, + strokeWidth: diffStatus === 'ghost' ? 1.5 : diffStatus ? 2.5 : hasRunStatus ? 2 : 1.5, strokeDasharray: diffStatus === 'deleted' ? '10,5' : undefined, opacity, ...(style ?? {}), diff --git a/packages/workflow-renderer/src/types.ts b/packages/workflow-renderer/src/types.ts index 5cd52a10729..d3fd599b6ec 100644 --- a/packages/workflow-renderer/src/types.ts +++ b/packages/workflow-renderer/src/types.ts @@ -7,7 +7,11 @@ */ /** Diff state of an edge when comparing two workflow versions. */ -export type EdgeDiffStatus = 'new' | 'deleted' | 'unchanged' | null +/** + * `deleted` is the Copilot preview's loud red dash; `ghost` is the version + * comparison's quiet trace of a connection that no longer exists. + */ +export type EdgeDiffStatus = 'new' | 'deleted' | 'ghost' | 'unchanged' | null /** Execution outcome of an edge for run-path visualization. */ export type EdgeRunStatus = 'success' | 'error' | 'not-executed' | undefined From 463603cc101ad8e3ee1160a276259644e8c1263b Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Tue, 29 Sep 2026 22:49:44 -0700 Subject: [PATCH 02/14] feat(preview): paint comparison status on the read-only canvas The preview canvas accepts per-block and per-edge diff status: added and modified cards get a ring and a shared status label, removed cards and containers fade to a ghost, changed sub-block rows and sentence chips tint, and ghost edges sit under live ones so a rewired port shows the new line on top. --- .../workflow-edge/workflow-edge.tsx | 7 ++ .../components/block/block.tsx | 75 +++++++++++++++++-- .../components/diff-label/diff-label.tsx | 40 ++++++++++ .../components/subflow/subflow.tsx | 17 ++++- .../preview-workflow/preview-workflow.tsx | 28 ++++++- 5 files changed, 157 insertions(+), 10 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/diff-label/diff-label.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx index e3290e9050d..41f26416f17 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx @@ -6,6 +6,7 @@ import { } from '@sim/workflow-renderer' import { type EdgeProps, useStore } from '@xyflow/react' import { useShallow } from 'zustand/react/shallow' +import type { EdgeDiffStatus as PreviewEdgeDiffStatus } from '@/lib/workflows/comparison' import { isEdgeConnectedToEditor, isEdgeHighlighted, @@ -72,7 +73,12 @@ const WorkflowEdgeComponent = (props: WorkflowEdgeProps) => { )?.executionStatus const runStatus = previewExecutionStatus || lastRunEdges.get(id) + /** A preview comparing two versions hands the edge its status directly. */ + const previewDiffStatus = (data as { diffStatus?: PreviewEdgeDiffStatus } | undefined)?.diffStatus + const diffStatus = useMemo((): EdgeDiffStatus => { + if (previewDiffStatus === 'added') return 'new' + if (previewDiffStatus === 'removed') return 'ghost' if (data?.isDeleted) return 'deleted' if (!diffAnalysis?.edge_diff || !isDiffReady) return null @@ -88,6 +94,7 @@ const WorkflowEdgeComponent = (props: WorkflowEdgeProps) => { } return null }, [ + previewDiffStatus, data?.isDeleted, diffAnalysis, isDiffReady, diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx index db9692d5760..b05fb50fdf2 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx @@ -1,7 +1,7 @@ 'use client' import { type CSSProperties, memo, useMemo } from 'react' -import { OverflowText } from '@sim/emcn' +import { cn, OverflowText } from '@sim/emcn' import { CanvasSentenceView, HANDLE_POSITIONS, @@ -19,6 +19,7 @@ import { } from '@/lib/workflows/blocks/canvas-sentence' import { resolveSelectedTriggerId } from '@/lib/workflows/blocks/canvas-trigger-sentence' import { resolveCanvasCodePreview } from '@/lib/workflows/blocks/code-preview' +import type { BlockDiffStatus } from '@/lib/workflows/comparison' import { getDisplayValue, hasDisplayableRowValue, @@ -38,6 +39,7 @@ import { isSubBlockVisibleForMode, isToolInputOnlySubBlock, } from '@/lib/workflows/subblocks/visibility' +import { DiffStatusLabel } from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/diff-label/diff-label' import { getBlock } from '@/blocks' import { hasBlockAccent } from '@/blocks/accent' import { SELECTOR_TYPES_HYDRATION_REQUIRED, type SubBlockConfig } from '@/blocks/types' @@ -98,6 +100,16 @@ interface WorkflowPreviewBlockData extends Record { hasErrorConnection?: boolean /** Skips expensive subblock computations for thumbnails/template previews */ lightweight?: boolean + /** Comparison status when previewing a version diff */ + diffStatus?: BlockDiffStatus + /** Sub-block ids the comparison reported as changed on this block */ + changedFields?: string[] +} + +/** A removed card fades instead of ringing, so only the live statuses have a ring. */ +const DIFF_RING_CLASS: Record, string> = { + added: 'ring-[var(--brand-accent)]', + modified: 'ring-[var(--warning)]', } /** @@ -118,6 +130,8 @@ interface SubBlockRowProps { rawValue?: unknown workflowMap: Record workflowLabelsReady: boolean + /** The comparison reported this field as changed; tint the row */ + changed?: boolean } /** @@ -198,6 +212,7 @@ const SubBlockRow = memo(function SubBlockRow({ rawValue, workflowMap, workflowLabelsReady, + changed = false, }: SubBlockRowProps) { const displayValue = resolvePreviewDisplayValue( value, @@ -208,7 +223,12 @@ const SubBlockRow = memo(function SubBlockRow({ ) return ( -
+
{displayValue !== undefined && ( errorEnabled = false, hasErrorConnection = false, lightweight = false, + diffStatus, + changedFields, } = data + const changedFieldSet = useMemo(() => new Set(changedFields ?? []), [changedFields]) const blockConfig = getBlock(type) const effectiveTrigger = isTrigger || type === 'starter' @@ -486,13 +509,32 @@ function WorkflowPreviewBlockInner({ data }: NodeProps const hasError = executionStatus === 'error' const hasSuccess = executionStatus === 'success' + const isRemoved = diffStatus === 'removed' return ( -
+
[data-ghost-content]]:opacity-45' + )} + > + {/* Comparison label above the card */} + {diffStatus && } {/* Selection ring overlay (takes priority over execution rings) */} {isPreviewSelected && (
)} + {/* Comparison ring overlay */} + {!isPreviewSelected && diffStatus && diffStatus !== 'removed' && ( +
+ )} {/* Success ring overlay (only shown if not selected) */} {!isPreviewSelected && hasSuccess && (
@@ -514,7 +556,7 @@ function WorkflowPreviewBlockInner({ data }: NodeProps )} {/* Header - matches WorkflowBlock structure */} -
+
{/* Content area with subblocks */} {hasContentBelowHeader && ( -
+
{type === 'condition' ? ( conditionRows.map((cond) => ( value={lightweight ? undefined : getDisplayValue(cond.value)} workflowMap={workflowMap} workflowLabelsReady={workflowLabelsReady} + changed={changedFieldSet.has('conditions')} /> )) ) : sentenceSegments ? ( @@ -564,7 +607,7 @@ function WorkflowPreviewBlockInner({ data }: NodeProps back as the `-` sentinel. That reads as noise mid-sentence, so hand the slot back and let its noun stand in instead. */ if (!displayValue || displayValue === '-') return null - return ( + const chip = ( variant='inline-value' /> ) + if (!changedFieldSet.has(subBlockId)) return chip + /* The chip, not the sentence, is what changed. */ + return ( + + {chip} + + ) }} /> ) : type === 'router_v2' ? ( @@ -590,6 +640,7 @@ function WorkflowPreviewBlockInner({ data }: NodeProps value={lightweight ? undefined : getDisplayValue(route.value)} workflowMap={workflowMap} workflowLabelsReady={workflowLabelsReady} + changed={changedFieldSet.has('routes')} /> ))} @@ -610,6 +661,7 @@ function WorkflowPreviewBlockInner({ data }: NodeProps rawValue={rawValue} workflowMap={workflowMap} workflowLabelsReady={workflowLabelsReady} + changed={changedFieldSet.has(subBlock.id)} /> ) }) @@ -693,6 +745,13 @@ function WorkflowPreviewBlockInner({ data }: NodeProps * @param nextProps - Next render props * @returns True if render should be skipped (props are equal) */ +/** Same changed-field list, by identity first so the common unchanged case costs nothing. */ +function sameFields(prev: string[] | undefined, next: string[] | undefined): boolean { + if (prev === next) return true + if (!prev || !next || prev.length !== next.length) return false + return prev.every((field, index) => field === next[index]) +} + function shouldSkipPreviewBlockRender( prevProps: NodeProps, nextProps: NodeProps @@ -707,7 +766,9 @@ function shouldSkipPreviewBlockRender( prevProps.data.executionStatus !== nextProps.data.executionStatus || prevProps.data.errorEnabled !== nextProps.data.errorEnabled || prevProps.data.hasErrorConnection !== nextProps.data.hasErrorConnection || - prevProps.data.lightweight !== nextProps.data.lightweight + prevProps.data.lightweight !== nextProps.data.lightweight || + prevProps.data.diffStatus !== nextProps.data.diffStatus || + !sameFields(prevProps.data.changedFields, nextProps.data.changedFields) ) { return false } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/diff-label/diff-label.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/diff-label/diff-label.tsx new file mode 100644 index 00000000000..53ce63f8de7 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/diff-label/diff-label.tsx @@ -0,0 +1,40 @@ +'use client' + +import { cn } from '@sim/emcn' +import type { BlockDiffStatus } from '@/lib/workflows/comparison' + +const DIFF_LABEL: Record = { + added: 'Added', + modified: 'Modified', + removed: 'Removed', +} + +const DIFF_LABEL_CLASS: Record = { + added: 'bg-[var(--brand-accent)] text-white', + modified: 'bg-[var(--warning)] text-white', + removed: 'bg-[var(--surface-7)] text-[var(--text-secondary)]', +} + +interface DiffStatusLabelProps { + status: BlockDiffStatus + /** How many fields changed on a modified block; shown after the label when known */ + count?: number +} + +/** + * The comparison label floating above a canvas card or container: the same + * chrome wherever it appears so a block and a loop read as one system. + */ +export function DiffStatusLabel({ status, count }: DiffStatusLabelProps) { + return ( +
+ {DIFF_LABEL[status]} + {status === 'modified' && count ? ` · ${count}` : null} +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/subflow/subflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/subflow/subflow.tsx index 99ce82afab6..5e9dcc5e153 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/subflow/subflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/subflow/subflow.tsx @@ -3,6 +3,8 @@ import { memo } from 'react' import { SubflowNodeView } from '@sim/workflow-renderer' import type { Node, NodeProps } from '@xyflow/react' +import type { BlockDiffStatus } from '@/lib/workflows/comparison' +import { DiffStatusLabel } from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/diff-label/diff-label' /** Execution status for subflows in preview mode */ type ExecutionStatus = 'success' | 'error' | 'not-executed' @@ -19,6 +21,8 @@ interface WorkflowPreviewSubflowData extends Record { isPreviewSelected?: boolean /** Execution status for highlighting the subflow container */ executionStatus?: ExecutionStatus + /** Comparison status when previewing a version diff */ + diffStatus?: BlockDiffStatus /** Skips expensive computations for thumbnails/template previews (unused in subflow, for consistency) */ lightweight?: boolean } @@ -31,7 +35,7 @@ interface WorkflowPreviewSubflowData extends Record { type WorkflowPreviewSubflowNode = Node function WorkflowPreviewSubflowInner({ data, id }: NodeProps) { - return ( + const view = ( undefined} /> ) + if (data.diffStatus !== 'removed') return view + /* A removed container fades like a removed card; its children ghost themselves. */ + return ( +
+ + {view} +
+ ) } export const PreviewSubflow = memo(WorkflowPreviewSubflowInner) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.tsx index a4c175a64eb..ed6bd64e5c0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.tsx @@ -29,6 +29,7 @@ import { useCanvasColorMode, } from '@sim/workflow-renderer' import { normalizeWorkflowEdgeHandles } from '@sim/workflow-types/workflow' +import type { BlockDiffStatus, EdgeDiffStatus } from '@/lib/workflows/comparison' import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge' import { estimateBlockDimensions, @@ -168,6 +169,12 @@ interface PreviewWorkflowProps { selectedBlockId?: string | null /** Skips expensive subblock computations for thumbnails/template previews */ lightweight?: boolean + /** Per-block comparison status, keyed by block id, when previewing a version diff */ + blockDiffStatus?: Record + /** Per-edge comparison status, keyed by edge id, when previewing a version diff */ + edgeDiffStatus?: Record + /** Sub-block ids that changed on a modified block, keyed by block id */ + changedFieldsByBlock?: Record } /** Preview node types using minimal, hook-free components. */ @@ -246,6 +253,9 @@ export function PreviewWorkflow({ executedBlocks, selectedBlockId, lightweight = false, + blockDiffStatus, + edgeDiffStatus, + changedFieldsByBlock, }: PreviewWorkflowProps) { const params = useParams<{ workspaceId: string }>() const workspaceId = propWorkspaceId ?? params.workspaceId @@ -444,6 +454,7 @@ export function PreviewWorkflow({ enabled: block.enabled ?? true, isPreviewSelected: isSelected, executionStatus: subflowExecutionStatus, + diffStatus: blockDiffStatus?.[blockId], lightweight, }, }) @@ -493,6 +504,8 @@ export function PreviewWorkflow({ errorEnabled: block.errorEnabled === true, hasErrorConnection: blocksWithErrorEdge.has(blockId), lightweight, + diffStatus: blockDiffStatus?.[blockId], + changedFields: changedFieldsByBlock?.[blockId], }, }) }) @@ -511,6 +524,8 @@ export function PreviewWorkflow({ workflowLabelsReady, errorSourceBlockKey, lightweight, + blockDiffStatus, + changedFieldsByBlock, ]) const edges: Edge[] = useMemo(() => { @@ -573,8 +588,15 @@ export function PreviewWorkflow({ return normalizeWorkflowEdgeHandles(workflowState.edges).map((edge) => { const status = getEdgeExecutionStatus(edge) const isErrorEdge = edge.sourceHandle === 'error' - const baseZIndex = - status === 'success' ? EDGE_Z_MAX : isErrorEdge ? EDGE_Z_BASE + 2 : EDGE_Z_BASE + const isGhost = edgeDiffStatus?.[edge.id] === 'removed' + /* A ghost sits under every live line so a rewired port shows the new edge on top. */ + const baseZIndex = isGhost + ? EDGE_Z_BASE - 1 + : status === 'success' + ? EDGE_Z_MAX + : isErrorEdge + ? EDGE_Z_BASE + 2 + : EDGE_Z_BASE const targetBlock = workflowState.blocks[edge.target] const targetContainerZIndex = targetBlock?.type === 'loop' || targetBlock?.type === 'parallel' @@ -589,6 +611,7 @@ export function PreviewWorkflow({ targetHandle: edge.targetHandle, data: { ...(status ? { executionStatus: status } : {}), + ...(edgeDiffStatus?.[edge.id] ? { diffStatus: edgeDiffStatus[edge.id] } : {}), sourceHandle: edge.sourceHandle, }, /* Inside the shared edge band, so a line clears the opaque container it @@ -606,6 +629,7 @@ export function PreviewWorkflow({ isValidWorkflowState, blockExecutionMap, getBlockExecutionStatus, + edgeDiffStatus, ]) if (!isValidWorkflowState) { From d5dc86711427cc76e809bd541c9320283e715cbf Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Tue, 29 Sep 2026 22:49:46 -0700 Subject: [PATCH 03/14] feat(workflows): version diff view with a field-level change list Side-by-side view of two workflow states: the overlaid canvas on the left and a change list on the right, sharing selection. Each touched block is a collapsible card with the app's block tile; modified blocks show field rows as folded line diffs with word marks, item-by-item list diffs for tools, conditions, routes and input fields, old to new pairs for scalars, and only the fact for secrets. Added and removed blocks show every field diffed against nothing, with long one-sided bodies capped behind one expander. Fork comparisons group credentials, picked resources and trigger paths into a muted environment bindings section. --- .../change-list/binding-change-row.tsx | 47 ++ .../components/change-list/change-list.tsx | 395 ++++++++++ .../change-list/field-change-row.tsx | 165 ++++ .../components/change-list/index.ts | 5 + .../change-list/keyed-list-diff.tsx | 87 +++ .../change-list/text-diff-lines.test.ts | 126 +++ .../components/change-list/text-diff-lines.ts | 138 ++++ .../components/change-list/text-diff.tsx | 165 ++++ .../w/components/workflow-diff/index.ts | 1 + .../w/components/workflow-diff/utils.test.ts | 520 ++++++++++++ .../w/components/workflow-diff/utils.ts | 737 ++++++++++++++++++ .../workflow-diff/workflow-diff-view.tsx | 125 +++ 12 files changed, 2511 insertions(+) create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/binding-change-row.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/change-list.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/field-change-row.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/keyed-list-diff.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/text-diff-lines.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/text-diff-lines.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/text-diff.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/utils.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/utils.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/workflow-diff-view.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/binding-change-row.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/binding-change-row.tsx new file mode 100644 index 00000000000..8c9dfd3d1e4 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/binding-change-row.tsx @@ -0,0 +1,47 @@ +'use client' + +import { ArrowRight } from '@sim/emcn/icons' +import { resolveFieldLabel } from '@/lib/workflows/comparison/resolve-values' +import { + classifyChange, + formatScalar, +} from '@/app/workspace/[workspaceId]/w/components/workflow-diff/utils' +import { formatParameterLabel } from '@/tools/params' + +interface BindingChangeRowProps { + blockType: string + field: string + oldValue: unknown + newValue: unknown +} + +/** + * A workspace-bound field that differs between the two sides. Muted on purpose: + * the sync's Mappings and Trigger URLs sections decide what these become, so + * the row only records that they differ, and by what, never as a red or green + * change competing with the logic rows above it. + */ +export function BindingChangeRow({ blockType, field, oldValue, newValue }: BindingChangeRowProps) { + const resolved = resolveFieldLabel(blockType, field) + const label = resolved === field ? formatParameterLabel(field) : resolved + const kind = classifyChange(blockType, field, oldValue, newValue) + + return ( +
+ {label} + {kind === 'secret' ? ( + Differs between workspaces + ) : ( + <> + + {formatScalar(blockType, field, oldValue)} + + + + {formatScalar(blockType, field, newValue)} + + + )} +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/change-list.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/change-list.tsx new file mode 100644 index 00000000000..7446ada625d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/change-list.tsx @@ -0,0 +1,395 @@ +'use client' + +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Badge, cn } from '@sim/emcn' +import { ChevronDown } from '@sim/emcn/icons' +import { humanizeBlockName } from '@sim/workflow-renderer' +import type { WorkflowDiffSummary } from '@/lib/workflows/comparison' +import { BindingChangeRow } from '@/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/binding-change-row' +import { FieldChangeRow } from '@/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/field-change-row' +import { + type BlockChangeEntry, + type BlockChangeStatus, + listBlockChanges, + listOneSidedFields, + splitEnvironmentBindings, +} from '@/app/workspace/[workspaceId]/w/components/workflow-diff/utils' +import { BlockTile } from '@/blocks/block-tile' +import type { BlockState } from '@/stores/workflows/workflow/types' + +const STATUS_BADGE_VARIANT: Record = { + added: 'green', + modified: 'amber', + removed: 'red', +} + +const STATUS_LABEL: Record = { + added: 'Added', + modified: 'Modified', + removed: 'Removed', +} + +const SIGN_CLASS: Record = { + added: 'text-[var(--brand-accent)]', + removed: 'text-[var(--text-error)]', + modified: 'text-[var(--warning)]', +} + +const SIGN: Record = { added: '+', removed: '−', modified: '~' } + +interface ChangeListProps { + summary: WorkflowDiffSummary + /** The two sides, so cards can show an added or removed block's fields and detect moves */ + baseBlocks: Record + targetBlocks: Record + selectedBlockId: string | null + onSelectBlock: (blockId: string | null) => void + /** + * The two sides live in different workspaces, so credentials, picked + * resources and trigger paths differ by design; group them apart, muted. + */ + environmentBindings?: boolean +} + +/** + * The "what changed" pane: one card per touched block with its field diffs, + * then the connections that were drawn or cut, then variables. Selecting a + * card selects the block on the canvas and vice versa. + */ +export function ChangeList({ + summary, + baseBlocks, + targetBlocks, + selectedBlockId, + onSelectBlock, + environmentBindings = false, +}: ChangeListProps) { + const entries = useMemo( + () => listBlockChanges(summary, baseBlocks, targetBlocks), + [summary, baseBlocks, targetBlocks] + ) + const blocks = useMemo(() => ({ ...baseBlocks, ...targetBlocks }), [baseBlocks, targetBlocks]) + const cardRefs = useRef>(null) + cardRefs.current ??= new Map() + + const registerCard = useCallback((id: string, node: HTMLDivElement | null) => { + if (node) cardRefs.current?.set(id, node) + else cardRefs.current?.delete(id) + }, []) + const toggleSelected = useCallback( + (id: string) => onSelectBlock(selectedBlockId === id ? null : id), + [onSelectBlock, selectedBlockId] + ) + + useEffect(() => { + if (!selectedBlockId) return + cardRefs.current?.get(selectedBlockId)?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) + }, [selectedBlockId]) + + if (!summary.hasChanges) { + return ( +
+ These two versions are identical. +
+ ) + } + + const hasConnectionChanges = summary.edgeChanges.added > 0 || summary.edgeChanges.removed > 0 + const hasVariableChanges = + summary.variableChanges.added > 0 || + summary.variableChanges.removed > 0 || + summary.variableChanges.modified > 0 + + return ( +
+ {entries.length > 0 && ( +
+ {entries.map((entry) => ( + + ))} +
+ )} + + {hasConnectionChanges && ( +
+
+ {summary.edgeChanges.addedDetails.map((edge, index) => ( + + ))} + {summary.edgeChanges.removedDetails.map((edge, index) => ( + + ))} +
+
+ )} + + {hasVariableChanges && ( +
+
+ {summary.variableChanges.addedNames.map((name) => ( + + ))} + {summary.variableChanges.modifiedNames.map((name) => ( + + ))} + {summary.variableChanges.removedNames.map((name) => ( + + ))} +
+
+ )} +
+ ) +} + +interface SectionProps { + title: string + count: number + children: React.ReactNode +} + +function Section({ title, count, children }: SectionProps) { + return ( +
+
+ {title} + {count} +
+
{children}
+
+ ) +} + +interface BlockCardProps { + entry: BlockChangeEntry + blocks: Record + selectedBlockId: string | null + onToggleSelected: (id: string) => void + registerCard: (id: string, node: HTMLDivElement | null) => void + environmentBindings: boolean + /** Rendered inside the card of the container it was added or removed with */ + nested?: boolean +} + +/** + * One touched block. The header toggles the body (open by default) and + * selects the block on the canvas; the body is the field diffs for a modified + * block, every field of an added or removed one diffed against nothing, plus + * the blocks nested under a container that was added or removed as a whole, + * each as a card of its own so their code and prompts stay reviewable. + */ +const BlockCard = memo(function BlockCard({ + entry, + blocks, + selectedBlockId, + onToggleSelected, + registerCard, + environmentBindings, + nested = false, +}: BlockCardProps) { + const [collapsed, setCollapsed] = useState(false) + const selected = selectedBlockId === entry.id + const block = blocks[entry.id] + const { logic, bindings } = useMemo(() => { + /* A block on one side only is every field arriving or leaving; same rows, one side empty. */ + const fields = + entry.status === 'modified' + ? entry.changes + : block + ? listOneSidedFields(block, entry.status) + : [] + return environmentBindings + ? splitEnvironmentBindings(entry.type, fields) + : { logic: fields, bindings: [] } + }, [entry, block, environmentBindings]) + const bindingsOnly = entry.status === 'modified' && logic.length === 0 && bindings.length > 0 + const hasBody = + logic.length > 0 || + bindings.length > 0 || + entry.children.length > 0 || + Boolean(entry.moved) || + Boolean(entry.membership) + + const toggle = () => { + onToggleSelected(entry.id) + if (hasBody) setCollapsed((value) => !value) + } + + return ( +
registerCard(entry.id, node)} + className={cn( + 'flex flex-col rounded-md border transition-colors', + nested ? 'bg-[var(--surface-1)]' : 'bg-[var(--surface-2)]', + selected ? 'border-[var(--text-secondary)]' : 'border-[var(--border)]' + )} + > +
{ + if (event.target !== event.currentTarget) return + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + toggle() + } + }} + className={cn( + 'flex cursor-pointer items-center gap-2 rounded-md hover-hover:bg-[var(--surface-3)] focus-visible:outline-none focus-visible:ring-[1.5px] focus-visible:ring-[var(--text-secondary)]', + nested ? 'p-2' : 'p-3' + )} + > + + + {humanizeBlockName(entry.name)} + + + {bindingsOnly ? 'Bindings only' : STATUS_LABEL[entry.status]} + + {hasBody && ( + + )} +
+ + {hasBody && !collapsed && ( +
+ {entry.moved && ( +
+ {entry.moved.into && ( + + Moved into{' '} + {humanizeBlockName(entry.moved.into)} + + )} + {entry.moved.outOf && ( + + Moved out of{' '} + {humanizeBlockName(entry.moved.outOf)} + + )} +
+ )} + + {logic.map((change) => ( + + ))} + + {bindings.length > 0 && ( +
+ Environment bindings + {bindings.map((change) => ( + + ))} +
+ )} + + {(entry.membership || entry.children.length > 0) && ( +
+ Blocks inside + {entry.children.map((child) => ( + + ))} + {entry.membership?.added.map((row) => ( + + ))} + {entry.membership?.removed.map((row) => ( + + ))} +
+ )} +
+ )} +
+ ) +}) + +interface NamedRowProps { + kind: BlockChangeStatus + name: string +} + +/** A signed one-line entry: a connection, a variable, or a block that entered or left a container. */ +function NamedRow({ kind, name }: NamedRowProps) { + return ( +
+ + {SIGN[kind]} + + + {name} + +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/field-change-row.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/field-change-row.tsx new file mode 100644 index 00000000000..2fc4c2756cb --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/field-change-row.tsx @@ -0,0 +1,165 @@ +'use client' + +import { cn } from '@sim/emcn' +import { ArrowRight } from '@sim/emcn/icons' +import { resolveFieldLabel } from '@/lib/workflows/comparison/resolve-values' +import { KeyedListDiff } from '@/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/keyed-list-diff' +import { + InlineDiff, + TextDiff, +} from '@/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/text-diff' +import { + classifyChange, + containerFieldLabel, + engineFieldLabel, + formatScalar, + isBlankValue, + isContainerType, + isSentenceLike, + toDiffText, + toMessageList, +} from '@/app/workspace/[workspaceId]/w/components/workflow-diff/utils' +import { formatParameterLabel } from '@/tools/params' + +interface FieldChangeRowProps { + blockType: string + field: string + oldValue: unknown + newValue: unknown +} + +/** + * One changed field. The value kind decides the shape: prose and code get a + * folded line diff, short strings a word diff on one line, toggles and + * selections an old → new pair, secrets only the fact. A field with one side + * empty (a block that exists on one side only, or a value first set or + * cleared) shows that one side alone, in that side's colour. + */ +export function FieldChangeRow({ blockType, field, oldValue, newValue }: FieldChangeRowProps) { + const kind = classifyChange(blockType, field, oldValue, newValue) + const oneSided = isBlankValue(oldValue) !== isBlankValue(newValue) + const resolvedLabel = isContainerType(blockType) + ? containerFieldLabel(field) + : (engineFieldLabel(field) ?? resolveFieldLabel(blockType, field)) + /* A field its definition never titled comes back as the raw id; humanize it. */ + const label = resolvedLabel === field ? formatParameterLabel(field) : resolvedLabel + + return ( +
+ {label} + {kind === 'secret' && ( + + {isBlankValue(oldValue) ? 'Set' : isBlankValue(newValue) ? 'Cleared' : 'Value changed'} + + )} + {(kind === 'text' || kind === 'json') && ( + + )} + {kind === 'messages' && } + {kind === 'list' && ( + + )} + {(kind === 'scalar' || kind === 'toggle') && oneSided && ( + + )} + {kind === 'scalar' && !oneSided && isSentenceLike(oldValue) && isSentenceLike(newValue) && ( + + )} + {!oneSided && + (kind === 'toggle' || + (kind === 'scalar' && !(isSentenceLike(oldValue) && isSentenceLike(newValue)))) && ( + + )} +
+ ) +} + +interface MessagesDiffProps { + oldValue: unknown + newValue: unknown +} + +/** + * Agent messages diff one slot at a time, paired by position, so a system + * prompt edit reads as a prompt diff rather than a JSON diff of the array. + */ +function MessagesDiff({ oldValue, newValue }: MessagesDiffProps) { + const oldMessages = toMessageList(oldValue) + const newMessages = toMessageList(newValue) + const count = Math.max(oldMessages.length, newMessages.length) + const slots = Array.from({ length: count }, (_, index) => ({ + index, + old: oldMessages[index], + next: newMessages[index], + })).filter( + (slot) => slot.old?.content !== slot.next?.content || slot.old?.role !== slot.next?.role + ) + + return ( +
+ {slots.map((slot) => ( +
+ + {slot.next?.role ?? slot.old?.role} message + {!slot.old && ' (added)'} + {!slot.next && ' (removed)'} + + +
+ ))} +
+ ) +} + +const CHIP_CLASS = { + removed: 'bg-[color-mix(in_srgb,var(--text-error)_10%,transparent)] text-[var(--text-secondary)]', + added: 'bg-[color-mix(in_srgb,var(--brand-accent)_14%,transparent)] text-[var(--text-primary)]', +} as const + +interface ValueChipProps { + tone: keyof typeof CHIP_CLASS + text: string +} + +/** A single value in its side's tint; wraps rather than truncates so nothing hides. */ +function ValueChip({ tone, text }: ValueChipProps) { + return ( + + {text} + + ) +} + +interface OldNewPairProps { + oldText: string + newText: string +} + +function OldNewPair({ oldText, newText }: OldNewPairProps) { + return ( +
+ + + +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/index.ts new file mode 100644 index 00000000000..f58b60974dd --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/index.ts @@ -0,0 +1,5 @@ +export { BindingChangeRow } from './binding-change-row' +export { ChangeList } from './change-list' +export { FieldChangeRow } from './field-change-row' +export { KeyedListDiff } from './keyed-list-diff' +export { InlineDiff, TextDiff } from './text-diff' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/keyed-list-diff.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/keyed-list-diff.tsx new file mode 100644 index 00000000000..937e38bbb02 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/keyed-list-diff.tsx @@ -0,0 +1,87 @@ +'use client' + +import { cn } from '@sim/emcn' +import { TextDiff } from '@/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/text-diff' +import { + describeListItems, + isPositionalListField, + type ListRowKind, + pairListItems, + toItemList, +} from '@/app/workspace/[workspaceId]/w/components/workflow-diff/utils' + +const SIGN: Record = { added: '+', removed: '−', changed: '~' } +const SIGN_CLASS: Record = { + added: 'text-[var(--brand-accent)]', + removed: 'text-[var(--text-error)]', + changed: 'text-[var(--warning)]', +} + +interface KeyedListDiffProps { + blockType: string + field: string + oldValue: unknown + newValue: unknown +} + +/** + * Item-by-item diff for list fields (tools, conditions, routes, input fields): + * one row per item that was added, removed or changed, with the item's body + * diffed as text when it changed. Unchanged items stay out of the way. + */ +export function KeyedListDiff({ blockType, field, oldValue, newValue }: KeyedListDiffProps) { + const oldItems = describeListItems(blockType, field, toItemList(oldValue) ?? []) + const newItems = describeListItems(blockType, field, toItemList(newValue) ?? []) + const rows = pairListItems(oldItems, newItems, isPositionalListField(blockType, field)) + + return ( +
+ {rows.length === 0 && ( + Order changed + )} + {rows.map((row, index) => ( +
+ + {SIGN[row.kind]} + +
+ + {row.label} + {row.oldLabel && ( + (was {row.oldLabel}) + )} + + {row.kind === 'changed' && row.oldText !== row.newText ? ( + + ) : ( + (row.newText || row.oldText) && ( + + {row.newText || row.oldText} + + ) + )} +
+
+ ))} +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/text-diff-lines.test.ts b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/text-diff-lines.test.ts new file mode 100644 index 00000000000..bc78a6844ec --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/text-diff-lines.test.ts @@ -0,0 +1,126 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + buildDiffRows, + capOneSided, + type DiffLine, + foldRows, + markWordChanges, + ONE_SIDED_VISIBLE_LINES, + splitLines, + toLines, +} from './text-diff-lines' + +const context = (count: number): DiffLine[] => + Array.from({ length: count }, (_, index) => ({ kind: 'context', text: `ctx ${index}` })) + +describe('splitLines and toLines', () => { + it('drops only the trailing newline and keeps interior blank lines', () => { + expect(splitLines('')).toEqual(['']) + expect(splitLines('a\n')).toEqual(['a']) + expect(splitLines('a\n\nb\n')).toEqual(['a', '', 'b']) + }) + + it('flattens a line diff into kinds', () => { + expect(toLines('a\nb\n', 'a\nc\n')).toEqual([ + { kind: 'context', text: 'a' }, + { kind: 'removed', text: 'b' }, + { kind: 'added', text: 'c' }, + ]) + }) +}) + +describe('foldRows', () => { + it('keeps two lines of context on each side of a change and folds the rest', () => { + const rows = foldRows([...context(6), { kind: 'added', text: 'x' }, ...context(6)]) + + expect(rows.map((row) => row.type)).toEqual([ + 'fold', + 'line', + 'line', + 'line', + 'line', + 'line', + 'fold', + ]) + expect(rows[0]).toMatchObject({ type: 'fold', lines: context(6).slice(0, 4) }) + expect(rows[6]).toMatchObject({ type: 'fold', lines: context(6).slice(2) }) + }) + + it('never folds a run too short to be worth it', () => { + const rows = foldRows([...context(3), { kind: 'removed', text: 'x' }, ...context(5)]) + expect(rows.every((row) => row.type === 'line')).toBe(true) + }) +}) + +describe('markWordChanges', () => { + it('marks the words that differ when most of a line pair survived', () => { + const [removed, added] = markWordChanges([ + { kind: 'removed', text: 'reply warmly in one sentence' }, + { kind: 'added', text: 'reply warmly in two sentences' }, + ]) + + /* Adjacent changed words come back as one run. */ + expect(removed.parts?.filter((part) => part.changed).map((part) => part.value)).toEqual([ + 'one sentence', + ]) + expect(added.parts?.filter((part) => part.changed).map((part) => part.value)).toEqual([ + 'two sentences', + ]) + }) + + it('leaves a rewrite and an oversized pair as plain lines', () => { + const rewrite = markWordChanges([ + { kind: 'removed', text: 'alpha beta gamma' }, + { kind: 'added', text: 'one two three four' }, + ]) + expect(rewrite.every((line) => line.parts === undefined)).toBe(true) + + const huge = markWordChanges([ + { kind: 'removed', text: 'a '.repeat(3000) }, + { kind: 'added', text: 'a '.repeat(2999) + 'b' }, + ]) + expect(huge.every((line) => line.parts === undefined)).toBe(true) + }) + + it('pairs only as many lines as both runs have', () => { + const lines = markWordChanges([ + { kind: 'removed', text: 'keep this one' }, + { kind: 'removed', text: 'keep this two' }, + { kind: 'added', text: 'keep this three' }, + ]) + expect(lines[0].parts).toBeDefined() + expect(lines[1].parts).toBeUndefined() + expect(lines[2].parts).toBeDefined() + }) +}) + +describe('capOneSided', () => { + it('tucks a long one-sided body behind one tail row and folds mixed bodies normally', () => { + const added = Array.from({ length: ONE_SIDED_VISIBLE_LINES + 3 }, (_, index) => ({ + kind: 'added' as const, + text: `line ${index}`, + })) + const rows = capOneSided(added) + expect(rows).toHaveLength(ONE_SIDED_VISIBLE_LINES + 1) + expect(rows[ONE_SIDED_VISIBLE_LINES]).toMatchObject({ type: 'tail' }) + expect(rows[ONE_SIDED_VISIBLE_LINES]).toHaveProperty('lines.length', 3) + + expect( + capOneSided(added.slice(0, ONE_SIDED_VISIBLE_LINES)).every((row) => row.type === 'line') + ).toBe(true) + expect( + capOneSided([...added, { kind: 'context', text: 'c' }]).some((row) => row.type === 'tail') + ).toBe(false) + }) +}) + +describe('buildDiffRows', () => { + it('renders a brand new body as added lines only', () => { + expect(buildDiffRows('', 'return 1\n')).toEqual([ + { type: 'line', line: { kind: 'added', text: 'return 1' } }, + ]) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/text-diff-lines.ts b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/text-diff-lines.ts new file mode 100644 index 00000000000..9e856228d39 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/text-diff-lines.ts @@ -0,0 +1,138 @@ +import { diffLines, diffWords } from 'diff' + +/** Lines of context kept around each change before the rest folds away. */ +export const CONTEXT_LINES = 2 +/** An unchanged run shorter than this is never worth folding. */ +export const MIN_FOLD_LINES = 4 +/** + * A body that is entirely one side (a new block's code, a removed block's + * prompt) has no unchanged run to fold, so it shows this many lines and + * tucks the rest behind one expander instead of swallowing the card. + */ +export const ONE_SIDED_VISIBLE_LINES = 12 +/** Past this share of changed characters a line pair is a rewrite, not an edit, so no word marks. */ +const MAX_WORD_HIGHLIGHT_RATIO = 0.6 +/** + * Word diffing is quadratic in the worst case; a pair of lines longer than + * this together is shown as plain removed and added lines instead of + * stalling the pane on a pathological prompt. + */ +const MAX_WORD_DIFF_CHARS = 4000 + +export interface DiffLine { + kind: 'added' | 'removed' | 'context' + text: string + /** Word-level parts when this line pairs with its counterpart on the other side */ + parts?: Array<{ value: string; changed: boolean }> +} + +export type DiffRow = + | { type: 'line'; line: DiffLine } + | { type: 'fold'; lines: DiffLine[] } + | { type: 'tail'; lines: DiffLine[] } + +/** + * Pairs each run of removed lines with the run of added lines that follows it, + * line by line, and marks the words that differ within each pair. A one-word + * edit inside a long sentence then reads as that word, not as the whole line + * leaving and coming back. + */ +export function markWordChanges(lines: DiffLine[]): DiffLine[] { + const out = [...lines] + let index = 0 + while (index < out.length) { + if (out[index].kind !== 'removed') { + index += 1 + continue + } + let removedEnd = index + while (removedEnd < out.length && out[removedEnd].kind === 'removed') removedEnd += 1 + let addedEnd = removedEnd + while (addedEnd < out.length && out[addedEnd].kind === 'added') addedEnd += 1 + const pairs = Math.min(removedEnd - index, addedEnd - removedEnd) + for (let offset = 0; offset < pairs; offset += 1) { + const removed = out[index + offset] + const added = out[removedEnd + offset] + const total = removed.text.length + added.text.length + if (total === 0 || total > MAX_WORD_DIFF_CHARS) continue + const words = diffWords(removed.text, added.text) + const changedChars = words + .filter((part) => part.added || part.removed) + .reduce((sum, part) => sum + part.value.length, 0) + if (changedChars / total > MAX_WORD_HIGHLIGHT_RATIO) continue + out[index + offset] = { + ...removed, + parts: words + .filter((part) => !part.added) + .map((part) => ({ value: part.value, changed: Boolean(part.removed) })), + } + out[removedEnd + offset] = { + ...added, + parts: words + .filter((part) => !part.removed) + .map((part) => ({ value: part.value, changed: Boolean(part.added) })), + } + } + index = addedEnd + } + return out +} + +export function splitLines(value: string): string[] { + const lines = value.split('\n') + if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop() + return lines +} + +export function toLines(oldText: string, newText: string): DiffLine[] { + const out: DiffLine[] = [] + for (const part of diffLines(oldText, newText)) { + const kind: DiffLine['kind'] = part.added ? 'added' : part.removed ? 'removed' : 'context' + for (const text of splitLines(part.value)) out.push({ kind, text }) + } + return out +} + +/** Folds long unchanged runs, keeping a little context on either side of each change. */ +export function foldRows(lines: DiffLine[]): DiffRow[] { + const rows: DiffRow[] = [] + let run: DiffLine[] = [] + const flush = (isTail: boolean) => { + if (run.length === 0) return + const head = rows.length === 0 ? 0 : CONTEXT_LINES + const tail = isTail ? 0 : CONTEXT_LINES + if (run.length - head - tail >= MIN_FOLD_LINES) { + for (const line of run.slice(0, head)) rows.push({ type: 'line', line }) + rows.push({ type: 'fold', lines: run.slice(head, run.length - tail) }) + for (const line of run.slice(run.length - tail)) rows.push({ type: 'line', line }) + } else { + for (const line of run) rows.push({ type: 'line', line }) + } + run = [] + } + for (const line of lines) { + if (line.kind === 'context') { + run.push(line) + } else { + flush(false) + rows.push({ type: 'line', line }) + } + } + flush(true) + return rows +} + +/** Caps a body with no unchanged lines at a visible head plus one expander for the rest. */ +export function capOneSided(lines: DiffLine[]): DiffRow[] { + const oneSided = lines.length > 0 && lines.every((line) => line.kind === lines[0].kind) + if (!oneSided || lines.length <= ONE_SIDED_VISIBLE_LINES) return foldRows(lines) + return [ + ...lines.slice(0, ONE_SIDED_VISIBLE_LINES).map((line): DiffRow => ({ type: 'line', line })), + { type: 'tail', lines: lines.slice(ONE_SIDED_VISIBLE_LINES) }, + ] +} + +/** The rows a text diff renders for two bodies: line diff, word marks, folds and the one-sided cap. */ +export function buildDiffRows(oldText: string, newText: string): DiffRow[] { + return capOneSided(markWordChanges(toLines(oldText, newText))) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/text-diff.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/text-diff.tsx new file mode 100644 index 00000000000..0270d0aab6c --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/text-diff.tsx @@ -0,0 +1,165 @@ +'use client' + +import { useMemo, useState } from 'react' +import { cn } from '@sim/emcn' +import { diffWords } from 'diff' +import { + buildDiffRows, + type DiffLine, +} from '@/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/text-diff-lines' + +/** Folds beyond this many in one field get a single "expand all" control. */ +const MANY_FOLDS = 3 + +const LINE_CLASS: Record = { + added: 'bg-[color-mix(in_srgb,var(--brand-accent)_12%,transparent)] text-[var(--text-primary)]', + removed: 'bg-[color-mix(in_srgb,var(--text-error)_10%,transparent)] text-[var(--text-primary)]', + context: 'text-[var(--text-secondary)]', +} + +const GUTTER_CLASS: Record = { + added: 'text-[var(--brand-accent)]', + removed: 'text-[var(--text-error)]', + context: 'text-[var(--text-muted)]', +} + +const GUTTER_SIGN: Record = { + added: '+', + removed: '−', + context: ' ', +} + +const FOLD_BUTTON_CLASS = + 'flex w-full items-center gap-2 bg-[var(--surface-3)] px-2 py-0.5 text-left text-[var(--text-tertiary)] text-caption hover-hover:bg-[var(--surface-4)] hover-hover:text-[var(--text-secondary)] focus-visible:bg-[var(--surface-4)] focus-visible:outline-none' + +interface FoldRowProps { + count: number + /** What the hidden lines are: unchanged context, or the rest of a one-sided body */ + kind: 'unchanged' | 'more' + onExpand: () => void +} + +function FoldRow({ count, kind, onExpand }: FoldRowProps) { + return ( + + ) +} + +interface TextDiffProps { + oldText: string + newText: string +} + +/** + * A unified line diff with a sign gutter. Unchanged runs fold to a count and + * expand on click, so a long prompt shows only the hunks that moved. + */ +export function TextDiff({ oldText, newText }: TextDiffProps) { + const rows = useMemo(() => buildDiffRows(oldText, newText), [oldText, newText]) + const [expanded, setExpanded] = useState>(() => new Set()) + const [expandedAll, setExpandedAll] = useState(false) + const foldCount = rows.filter((row) => row.type === 'fold').length + + return ( +
+ {foldCount > MANY_FOLDS && ( + + )} + {rows.map((row, index) => { + if (row.type === 'fold' || row.type === 'tail') { + if (!expandedAll && !expanded.has(index)) { + return ( + setExpanded((prev) => new Set(prev).add(index))} + /> + ) + } + return row.lines.map((line, lineIndex) => ( + + )) + } + return + })} +
+ ) +} + +interface DiffLineRowProps { + line: DiffLine +} + +function DiffLineRow({ line }: DiffLineRowProps) { + return ( +
+ + {GUTTER_SIGN[line.kind]} + + + {line.parts + ? line.parts.map((part, index) => ( + + {part.value} + + )) + : line.text || ' '} + +
+ ) +} + +interface InlineDiffProps { + oldText: string + newText: string +} + +/** + * Word-level diff on one line, for short values where a before and after row + * would waste the space: removed words sit muted on a red tint, added words + * on a green tint. + */ +export function InlineDiff({ oldText, newText }: InlineDiffProps) { + const parts = useMemo(() => diffWords(oldText, newText), [oldText, newText]) + return ( + + {parts.map((part, index) => ( + + {part.value} + + ))} + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/index.ts new file mode 100644 index 00000000000..f66d413b611 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/index.ts @@ -0,0 +1 @@ +export { CHANGE_LIST_WIDTH_CLASS, WorkflowDiffView } from './workflow-diff-view' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/utils.test.ts b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/utils.test.ts new file mode 100644 index 00000000000..f86d833909f --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/utils.test.ts @@ -0,0 +1,520 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { WorkflowDiffSummary } from '@/lib/workflows/comparison' +import { getBlock } from '@/blocks/registry' +import type { SubBlockConfig } from '@/blocks/types' +import type { BlockState } from '@/stores/workflows/workflow/types' +import { + classifyChange, + describeListItems, + listBlockChanges, + listOneSidedFields, + maskSecretParams, + omitPresentationChanges, + pairListItems, + splitEnvironmentBindings, +} from './utils' + +/** The global registry mock returns no sub-blocks; tests declare the ones they need per block type. */ +function declareSubBlocks(byType: Record[]>) { + vi.mocked(getBlock).mockImplementation( + (type: string) => + ({ + name: type, + description: '', + icon: () => null, + subBlocks: byType[type] ?? [], + outputs: {}, + }) as never + ) +} + +function block(id: string, overrides: Partial = {}): BlockState { + return { + id, + type: 'function', + name: id, + position: { x: 0, y: 0 }, + subBlocks: {}, + outputs: {}, + enabled: true, + ...overrides, + } as BlockState +} + +function summary(overrides: Partial = {}): WorkflowDiffSummary { + return { + addedBlocks: [], + removedBlocks: [], + modifiedBlocks: [], + edgeChanges: { added: 0, removed: 0, addedDetails: [], removedDetails: [] }, + loopChanges: { added: 0, removed: 0, modified: 0 }, + parallelChanges: { added: 0, removed: 0, modified: 0 }, + containerChanges: [], + variableChanges: { + added: 0, + removed: 0, + modified: 0, + addedNames: [], + removedNames: [], + modifiedNames: [], + }, + hasChanges: false, + ...overrides, + } +} + +beforeEach(() => { + declareSubBlocks({}) +}) + +describe('classifyChange', () => { + it('reads the block definition first and falls back to the value shapes', () => { + declareSubBlocks({ + agent: [ + { id: 'apiKey', type: 'short-input', password: true }, + { id: 'messages', type: 'messages-input' }, + { id: 'tools', type: 'tool-input' }, + { id: 'systemPrompt', type: 'long-input' }, + ], + }) + const messages = [{ role: 'user', content: 'hi' }] + + expect(classifyChange('agent', 'apiKey', 'a', 'b')).toBe('secret') + expect(classifyChange('agent', 'messages', 'x', 'y')).toBe('messages') + expect(classifyChange('agent', 'tools', null, null)).toBe('list') + expect(classifyChange('agent', 'systemPrompt', 'a', 'b')).toBe('text') + /* Undeclared fields classify by what the values look like. */ + expect(classifyChange('agent', 'unknown', messages, null)).toBe('messages') + expect(classifyChange('agent', 'unknown', '[1]', [2])).toBe('list') + expect(classifyChange('agent', 'unknown', false, true)).toBe('toggle') + expect(classifyChange('agent', 'unknown', 'one\ntwo', 'one')).toBe('text') + expect(classifyChange('agent', 'unknown', 'x'.repeat(61), 'y')).toBe('text') + expect(classifyChange('agent', 'unknown', null, { a: 1 })).toBe('json') + expect(classifyChange('agent', 'unknown', 'gpt-4o', 'gpt-4.1')).toBe('scalar') + expect(classifyChange('agent', 'unknown', 1, 2)).toBe('scalar') + }) + + it('treats a checkbox group that persists a record of flags as json, not as a list', () => { + declareSubBlocks({ jina: [{ id: 'options', type: 'checkbox-list' }] }) + + expect(classifyChange('jina', 'options', { noCache: false }, { noCache: true })).toBe('json') + expect(classifyChange('jina', 'options', null, ['a'])).toBe('list') + }) +}) + +describe('describeListItems', () => { + it('labels conditions and routes by position and everything else by name', () => { + declareSubBlocks({ + condition: [{ id: 'conditions', type: 'condition-input' }], + router_v2: [{ id: 'routes', type: 'router-input' }], + starter: [{ id: 'inputFormat', type: 'input-format' }], + }) + + expect( + describeListItems('condition', 'conditions', [ + { id: 'c-if', value: 'a' }, + { id: 'c-elif', value: 'b' }, + { value: 'c' }, + ]) + ).toEqual([ + { key: 'c-if', label: 'if', text: 'a' }, + { key: 'c-elif', label: 'else if', text: 'b' }, + { key: 'cond-2', label: 'else', text: 'c' }, + ]) + expect(describeListItems('router_v2', 'routes', [{ id: 'r1', value: 'go left' }])).toEqual([ + { key: 'r1', label: 'Route 1', text: 'go left' }, + ]) + expect( + describeListItems('starter', 'inputFormat', [ + { name: 'email', type: 'string', description: 'Address' }, + { type: 'number' }, + ]) + ).toEqual([ + { key: 'email', label: 'email', text: 'string · Address' }, + { key: 'Field 2', label: 'Field 2', text: 'number' }, + ]) + expect(describeListItems('other', 'tags', ['a', 42, { title: 'T', extra: 1 }, {}])).toEqual([ + { key: 'a', label: 'a', text: '' }, + { key: '42', label: '42', text: '' }, + { key: 'T', label: 'T', text: JSON.stringify({ extra: 1 }, null, 2) }, + { key: 'Item 4', label: 'Item 4', text: '' }, + ]) + }) + + it('names tools by server tool, custom tool or type, and masks secret params', () => { + declareSubBlocks({ agent: [{ id: 'tools', type: 'tool-input' }] }) + + const items = describeListItems('agent', 'tools', [ + { + type: 'mcp', + title: 'ignored', + params: { serverId: 's1', toolName: 'search_docs', query: 'x', apiToken: 'sk-1' }, + }, + { type: 'custom-tool', schema: { function: { name: 'lookup' } }, params: {} }, + { type: 'slack', toolId: 'slack_send', title: 'Slack' }, + ]) + + expect(items[0]).toEqual({ + key: 'search_docs', + label: 'search_docs', + text: JSON.stringify({ params: { query: 'x', apiToken: '•••' } }, null, 2), + }) + /* A custom tool's schema is its implementation, so it stays in the comparable body. */ + expect(items[1]).toEqual({ + key: 'lookup', + label: 'lookup', + text: JSON.stringify({ schema: { function: { name: 'lookup' } } }, null, 2), + }) + expect(items[2]).toEqual({ key: 'slack_send', label: 'Slack', text: '' }) + }) + + it('keeps what a tool may do in its body so a permission change is not an order change', () => { + declareSubBlocks({ agent: [{ id: 'tools', type: 'tool-input' }] }) + const tool = (usageControl: string) => ({ + type: 'slack', + toolId: 'slack_send', + title: 'Slack', + usageControl, + isExpanded: true, + }) + + const [before] = describeListItems('agent', 'tools', [tool('none')]) + const [after] = describeListItems('agent', 'tools', [tool('auto')]) + + expect(before.text).toBe(JSON.stringify({ usageControl: 'none' }, null, 2)) + expect(pairListItems([before], [after], false)).toEqual([ + expect.objectContaining({ kind: 'changed', label: 'Slack' }), + ]) + }) + + it('includes an input field default so a changed default is visible', () => { + declareSubBlocks({ starter: [{ id: 'inputFormat', type: 'input-format' }] }) + + expect( + describeListItems('starter', 'inputFormat', [{ name: 'limit', type: 'number', value: 10 }]) + ).toEqual([{ key: 'limit', label: 'limit', text: 'number · default 10' }]) + }) +}) + +describe('pairListItems', () => { + const item = (key: string, label: string, text: string) => ({ key, label, text }) + + it('pairs by key, then by identical body, then by position for branch lists', () => { + expect(pairListItems([item('a', 'Route 1', 'x')], [item('b', 'Route 1', 'x')], true)).toEqual( + [] + ) + expect(pairListItems([item('a', 'if', '1')], [item('b', 'if', '2')], true)).toEqual([ + { kind: 'changed', label: 'if', oldText: '1', newText: '2' }, + ]) + expect(pairListItems([item('a', 'T', '1')], [item('b', 'T', '2')], false)).toEqual([ + { kind: 'added', label: 'T', oldText: '', newText: '2' }, + { kind: 'removed', label: 'T', oldText: '1', newText: '' }, + ]) + expect(pairListItems([item('a', 'else if', 'x')], [item('a', 'else', 'x')], true)).toEqual([ + { kind: 'changed', label: 'else', oldLabel: 'else if', oldText: 'x', newText: 'x' }, + ]) + }) +}) + +describe('maskSecretParams', () => { + it('masks keys that end in a secret noun and leaves ids, counts and limits readable', () => { + expect( + maskSecretParams({ + apiKey: 'k', + api_key: 'k', + accessToken: 'k', + clientSecret: 'k', + password: 'p', + privateKey: 'p', + credential: 'c', + credentialId: 'c', + maxTokens: 1000, + tokenLimit: 5, + emptyToken: '', + nullSecret: null, + query: 'keep', + count: 2, + }) + ).toEqual({ + apiKey: '•••', + api_key: '•••', + accessToken: '•••', + clientSecret: '•••', + password: '•••', + privateKey: '•••', + credential: '•••', + credentialId: 'c', + maxTokens: 1000, + tokenLimit: 5, + emptyToken: '', + nullSecret: null, + query: 'keep', + count: 2, + }) + }) +}) + +describe('splitEnvironmentBindings', () => { + it('sets workspace-bound fields apart by name, password flag or selector type', () => { + declareSubBlocks({ + slack: [ + { id: 'channel', type: 'channel-selector' }, + { id: 'token', type: 'short-input', password: true }, + { id: 'message', type: 'long-input' }, + ], + }) + const changes = [ + { field: 'credential' }, + { field: 'webhookPath' }, + { field: 'channel' }, + { field: 'token' }, + { field: 'message' }, + { field: 'undeclared' }, + ] + + expect(splitEnvironmentBindings('slack', changes)).toEqual({ + logic: [{ field: 'message' }, { field: 'undeclared' }], + bindings: [ + { field: 'credential' }, + { field: 'webhookPath' }, + { field: 'channel' }, + { field: 'token' }, + ], + }) + }) +}) + +describe('listOneSidedFields', () => { + it('lists declared fields first, skips blanks, keeps a declared secret and drops an undeclared one', () => { + declareSubBlocks({ + agent: [ + { id: 'model', type: 'dropdown' }, + { id: 'apiKey', type: 'short-input', password: true }, + { id: 'prompt', type: 'long-input' }, + ], + }) + const state = block('a1', { + type: 'agent', + subBlocks: { + prompt: { id: 'prompt', type: 'long-input', value: 'hello' }, + model: { id: 'model', type: 'dropdown', value: 'gpt' }, + apiKey: { id: 'apiKey', type: 'short-input', value: 'sk' }, + temperature: { id: 'temperature', type: 'slider', value: 0 }, + emptyList: { id: 'emptyList', type: 'tool-input', value: [] }, + emptyObject: { id: 'emptyObject', type: 'code', value: {} }, + blank: { id: 'blank', type: 'short-input', value: '' }, + botToken: { id: 'botToken', type: 'short-input', value: 'xoxb' }, + }, + }) + + expect(listOneSidedFields(state, 'added')).toEqual([ + { field: 'model', oldValue: undefined, newValue: 'gpt' }, + { field: 'apiKey', oldValue: undefined, newValue: 'sk' }, + { field: 'prompt', oldValue: undefined, newValue: 'hello' }, + { field: 'temperature', oldValue: undefined, newValue: 0 }, + ]) + expect(listOneSidedFields(state, 'removed')[0]).toEqual({ + field: 'model', + oldValue: 'gpt', + newValue: undefined, + }) + }) +}) + +describe('omitPresentationChanges', () => { + it('hides presentation-only rows, drops blocks left empty and recomputes hasChanges', () => { + const only = summary({ + modifiedBlocks: [ + { + id: 'a', + type: 'function', + name: 'a', + changes: [ + { field: 'horizontalHandles', oldValue: true, newValue: false }, + { field: 'tools.properties', oldValue: {}, newValue: {} }, + ], + }, + { + id: 'b', + type: 'function', + name: 'b', + changes: [ + { field: 'horizontalHandles', oldValue: true, newValue: false }, + { field: 'code', oldValue: 'x', newValue: 'y' }, + ], + }, + ], + hasChanges: true, + }) + + const next = omitPresentationChanges(only) + + expect(next.modifiedBlocks).toEqual([ + { + id: 'b', + type: 'function', + name: 'b', + changes: [{ field: 'code', oldValue: 'x', newValue: 'y' }], + }, + ]) + expect(next.hasChanges).toBe(true) + + /* The basic/advanced mode decides which stored value executes, so it is never hidden. */ + const modeOnly = summary({ + modifiedBlocks: [ + { + id: 'c', + type: 'slack', + name: 'c', + changes: [ + { field: 'data.canonicalModes', oldValue: {}, newValue: { channel: 'advanced' } }, + ], + }, + ], + hasChanges: true, + }) + expect(omitPresentationChanges(modeOnly).modifiedBlocks).toHaveLength(1) + + const presentationOnly = summary({ + modifiedBlocks: [only.modifiedBlocks[0]], + hasChanges: true, + }) + expect(omitPresentationChanges(presentationOnly)).toMatchObject({ + modifiedBlocks: [], + hasChanges: false, + }) + expect( + omitPresentationChanges( + summary({ + modifiedBlocks: [only.modifiedBlocks[0]], + edgeChanges: { added: 1, removed: 0, addedDetails: [], removedDetails: [] }, + hasChanges: true, + }) + ).hasChanges + ).toBe(true) + }) +}) + +describe('listBlockChanges', () => { + it('orders modified, added, removed and nests children under an added or removed container', () => { + const baseBlocks = { + keep: block('keep', { subBlocks: { code: { id: 'code', type: 'code', value: 'a' } } }), + oldLoop: block('oldLoop', { type: 'loop', name: 'Old Loop' }), + oldChild: block('oldChild', { data: { parentId: 'oldLoop' } }), + } + const targetBlocks = { + keep: block('keep', { subBlocks: { code: { id: 'code', type: 'code', value: 'b' } } }), + newPar: block('newPar', { type: 'parallel', name: 'New Par' }), + newChild: block('newChild', { data: { parentId: 'newPar' } }), + /* Survived the diff but now sits inside the new container. */ + mover: block('mover', { data: { parentId: 'newPar' } }), + } + const diff = summary({ + addedBlocks: [ + { id: 'newChild', type: 'function', name: 'newChild' }, + { id: 'newPar', type: 'parallel', name: 'New Par' }, + { id: 'mover', type: 'function', name: 'mover' }, + ], + removedBlocks: [ + { id: 'oldLoop', type: 'loop', name: 'Old Loop' }, + { id: 'oldChild', type: 'function', name: 'oldChild' }, + ], + modifiedBlocks: [ + { + id: 'keep', + type: 'function', + name: '', + changes: [{ field: 'code', oldValue: 'a', newValue: 'b' }], + }, + ], + hasChanges: true, + }) + + const entries = listBlockChanges(diff, baseBlocks, targetBlocks) + + expect(entries.map((entry) => [entry.id, entry.status])).toEqual([ + ['keep', 'modified'], + ['newPar', 'added'], + ['oldLoop', 'removed'], + ]) + expect(entries[0].name).toBe('function') + expect(entries[1].children.map((child) => child.id).sort()).toEqual(['mover', 'newChild']) + expect(entries[1].membership).toBeUndefined() + expect(entries[2].children.map((child) => child.id)).toEqual(['oldChild']) + }) + + it('reads a moved block and container membership from the sides, flagging survivors', () => { + const baseBlocks = { + loop1: block('loop1', { type: 'loop', name: 'Loop' }), + inside: block('inside', { data: { parentId: 'loop1' } }), + outside: block('outside'), + gone: block('gone', { name: 'Gone', data: { parentId: 'loop1' } }), + } + const targetBlocks = { + loop1: block('loop1', { type: 'loop', name: 'Loop' }), + inside: block('inside'), + outside: block('outside', { data: { parentId: 'loop1' } }), + fresh: block('fresh', { name: 'Fresh', data: { parentId: 'loop1' } }), + wrapper: block('wrapper', { type: 'parallel', name: 'Wrapper' }), + survivor: block('survivor', { name: 'Survivor', data: { parentId: 'wrapper' } }), + } + const diff = summary({ + addedBlocks: [ + { id: 'fresh', type: 'function', name: 'Fresh' }, + { id: 'wrapper', type: 'parallel', name: 'Wrapper' }, + ], + removedBlocks: [{ id: 'gone', type: 'function', name: 'Gone' }], + containerChanges: [ + { + id: 'loop1', + kind: 'loop', + name: 'Loop', + changes: [{ field: 'iterations', oldValue: 1, newValue: 2 }], + nodesAdded: ['outside', 'fresh'], + nodesRemoved: ['inside', 'gone'], + }, + /* An added container's own change is folded into its added entry, not listed twice. */ + { + id: 'wrapper', + kind: 'parallel', + changes: [], + nodesAdded: ['survivor'], + nodesRemoved: [], + }, + ], + hasChanges: true, + }) + + const entries = listBlockChanges(diff, baseBlocks, targetBlocks) + const byId = new Map(entries.map((entry) => [entry.id, entry])) + + expect([...byId.keys()]).toEqual(['inside', 'outside', 'loop1', 'fresh', 'wrapper', 'gone']) + expect(byId.get('inside')).toMatchObject({ status: 'modified', moved: { outOf: 'Loop' } }) + expect(byId.get('outside')).toMatchObject({ status: 'modified', moved: { into: 'Loop' } }) + expect(byId.get('loop1')).toMatchObject({ + status: 'modified', + changes: [{ field: 'iterations', oldValue: 1, newValue: 2 }], + membership: { + added: [ + { name: 'outside', moved: true }, + { name: 'Fresh', moved: false }, + ], + removed: [ + { name: 'inside', moved: true }, + { name: 'Gone', moved: false }, + ], + }, + }) + expect(byId.get('wrapper')).toMatchObject({ + status: 'added', + changes: [], + children: [], + membership: { added: [{ name: 'Survivor', moved: true }], removed: [] }, + }) + /* The survivor is not itself in the diff, so it only appears under its wrapper. */ + expect(byId.has('survivor')).toBe(false) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/utils.ts b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/utils.ts new file mode 100644 index 00000000000..8147ff3cb51 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/utils.ts @@ -0,0 +1,737 @@ +import { isContainerType } from '@/lib/workflows/autolayout' +import { + type ContainerConfigField, + LOOP_CONFIG_FIELDS, + PARALLEL_CONFIG_FIELDS, + summaryHasChanges, + type WorkflowDiffSummary, +} from '@/lib/workflows/comparison' +import { formatValueForDisplay } from '@/lib/workflows/comparison/resolve-values' +import { getBlock } from '@/blocks/registry' +import type { SubBlockConfig } from '@/blocks/types' +import type { BlockState } from '@/stores/workflows/workflow/types' + +export { isContainerType } + +/** How a changed value should be rendered in the change list. */ +export type ValueKind = 'text' | 'scalar' | 'json' | 'secret' | 'toggle' | 'messages' | 'list' + +/** Sub-block types whose values read as prose or code, so they get a line diff. */ +const TEXT_SUB_BLOCK_TYPES = new Set([ + 'long-input', + 'code', + 'condition-input', + 'text', + 'eval-input', + 'messages-input', +]) + +const INLINE_TEXT_MAX_LENGTH = 60 + +/** + * Sub-block types whose values are lists of identifiable items, diffed item by + * item. A value that turns out not to be a list (a checkbox group persists a + * record of option flags) falls back to the shape-based rules. + */ +const LIST_SUB_BLOCK_TYPES = new Set([ + 'tool-input', + 'skill-input', + 'condition-input', + 'router-input', + 'input-format', + 'checkbox-list', + 'grouped-checkbox-list', + 'variables-input', +]) + +/** Reads a stored list value, tolerating the JSON-string form some fields persist. */ +export function toItemList(value: unknown): unknown[] | null { + if (Array.isArray(value)) return value + if (typeof value === 'string' && value.trim().startsWith('[')) { + try { + const parsed: unknown = JSON.parse(value) + return Array.isArray(parsed) ? parsed : null + } catch { + return null + } + } + return null +} + +/** Lists whose items are ordered branches: a rewritten item pairs by position, not identity. */ +export function isPositionalListField(blockType: string, field: string): boolean { + const type = findSubBlockConfig(blockType, field)?.type + return type === 'condition-input' || type === 'router-input' +} + +/** How one list item is identified, named, and compared. */ +export interface ListItemView { + key: string + label: string + /** The item's comparable body; a change here renders as a text diff */ + text: string +} + +export type ListRowKind = 'added' | 'removed' | 'changed' + +/** One row of an item-by-item list diff. */ +export interface ListDiffRow { + kind: ListRowKind + label: string + /** The item's previous label when only its position or name changed */ + oldLabel?: string + oldText: string + newText: string +} + +/** + * Pairs items across the two sides: by key first, then by identical body for + * items whose keys differ (a route or condition re-created with a fresh id is + * still the same route), and finally by position so a rewritten item reads as + * changed rather than as a removal plus an addition. + */ +export function pairListItems( + oldItems: ListItemView[], + newItems: ListItemView[], + positional: boolean +): ListDiffRow[] { + const rows: ListDiffRow[] = [] + const oldLeft = [...oldItems] + const newLeft = [...newItems] + + const take = ( + list: ListItemView[], + predicate: (item: ListItemView) => boolean + ): ListItemView | undefined => { + const index = list.findIndex(predicate) + return index === -1 ? undefined : list.splice(index, 1)[0] + } + const same = (a: ListItemView, b: ListItemView) => a.text === b.text && a.label === b.label + + for (const item of newItems) { + const previous = take(oldLeft, (candidate) => candidate.key === item.key) + if (!previous) continue + take(newLeft, (candidate) => candidate === item) + if (!same(previous, item)) { + rows.push({ + kind: 'changed', + label: item.label, + oldLabel: previous.label !== item.label ? previous.label : undefined, + oldText: previous.text, + newText: item.text, + }) + } + } + for (const item of [...newLeft]) { + const previous = take(oldLeft, (candidate) => same(candidate, item)) + if (previous) take(newLeft, (candidate) => candidate === item) + } + while (positional && newLeft.length && oldLeft.length) { + const item = newLeft.shift()! + const previous = oldLeft.shift()! + rows.push({ kind: 'changed', label: item.label, oldText: previous.text, newText: item.text }) + } + for (const item of newLeft) { + rows.push({ kind: 'added', label: item.label, oldText: '', newText: item.text }) + } + for (const item of oldLeft) { + rows.push({ kind: 'removed', label: item.label, oldText: item.text, newText: '' }) + } + return rows +} + +/** Tool item fields, beyond its params, that change what the tool may do or how it runs. */ +const TOOL_EXECUTION_FIELDS = [ + 'operation', + 'usageControl', + 'usageControlExpression', + 'operationPolicy', + 'code', + 'schema', +] as const + +function pick(item: Record, keys: readonly string[]): Record { + const out: Record = {} + for (const key of keys) if (key in item) out[key] = item[key] + return out +} + +function filterBlank(record: Record): Record { + const out: Record = {} + for (const [key, value] of Object.entries(record)) if (!isBlankValue(value)) out[key] = value + return out +} + +function readString(item: Record, keys: string[]): string | undefined { + for (const key of keys) { + const value = item[key] + if (typeof value === 'string' && value) return value + } + return undefined +} + +/** + * Projects a list item to key, label and body according to the field it lives + * in. Conditions and routes are positional ("if", "else if", "Route 2") since + * their ids are opaque; everything else names itself. + */ +export function describeListItems( + blockType: string, + field: string, + items: unknown[] +): ListItemView[] { + const config = findSubBlockConfig(blockType, field) + const type = config?.type + return items.map((raw, index) => { + if (raw === null || typeof raw !== 'object') { + const text = String(raw) + return { key: text, label: text, text: '' } + } + const item = raw as Record + const id = readString(item, ['id', 'toolId', 'name']) + if (type === 'condition-input') { + const label = index === 0 ? 'if' : index === items.length - 1 ? 'else' : 'else if' + return { key: id ?? `cond-${index}`, label, text: readString(item, ['value']) ?? '' } + } + if (type === 'router-input') { + return { + key: id ?? `route-${index}`, + label: `Route ${index + 1}`, + text: readString(item, ['value']) ?? '', + } + } + if (type === 'tool-input') { + const params = item.params + const paramRecord = + params && typeof params === 'object' ? (params as Record) : {} + const toolType = readString(item, ['type']) + /* MCP tools name themselves by server tool; custom tools by their saved id or function name. */ + const mcpName = toolType === 'mcp' ? readString(paramRecord, ['toolName']) : undefined + const customName = + toolType === 'custom-tool' + ? (readString(item, ['customToolId', 'title']) ?? + readString( + ((item.schema as { function?: Record } | undefined)?.function ?? + {}) as Record, + ['name'] + )) + : undefined + const { serverId: _serverId, toolName: _toolName, ...rawParams } = paramRecord + const visibleParams = maskSecretParams(rawParams) + /* What the tool is allowed to do and how it runs matters as much as its params. */ + const body = filterBlank({ + ...pick(item, TOOL_EXECUTION_FIELDS), + params: Object.keys(visibleParams).length ? visibleParams : undefined, + }) + return { + key: + mcpName ?? customName ?? readString(item, ['toolId', 'type', 'title']) ?? `tool-${index}`, + label: + mcpName ?? + readString(item, ['title']) ?? + customName ?? + readString(item, ['type']) ?? + `Tool ${index + 1}`, + text: Object.keys(body).length ? JSON.stringify(body, null, 2) : '', + } + } + if (type === 'input-format') { + const name = readString(item, ['name']) ?? `Field ${index + 1}` + const fieldType = readString(item, ['type']) + const description = readString(item, ['description']) + const defaultValue = isBlankValue(item.value) + ? undefined + : `default ${toDiffText(item.value)}` + return { + key: id ?? name, + label: name, + text: [fieldType, description, defaultValue].filter(Boolean).join(' · '), + } + } + const label = readString(item, ['title', 'name', 'label', 'id']) ?? `Item ${index + 1}` + const { id: _id, title: _title, name: _name, label: _label, ...rest } = item + return { + key: id ?? label, + label, + text: Object.keys(rest).length ? JSON.stringify(rest, null, 2) : '', + } + }) +} + +/** + * Sub-block types whose value names something that lives in a workspace rather + * than in the workflow's logic: a credential, a picked resource, a trigger path. + * Two environments differ on these by design, so a fork comparison sets them + * apart from prompt and logic changes. + */ +const ENVIRONMENT_BOUND_TYPES = new Set([ + 'oauth-input', + 'webhook-config', + 'file-selector', + 'sheet-selector', + 'project-selector', + 'channel-selector', + 'user-selector', + 'folder-selector', + 'knowledge-base-selector', + 'document-selector', + 'mcp-server-selector', + 'mcp-tool-selector', + 'table-selector', + 'workflow-selector', +]) + +const ENVIRONMENT_BOUND_FIELDS = new Set([ + 'apiKey', + 'credential', + 'triggerPath', + 'webhookPath', +]) + +function isEnvironmentBinding(blockType: string, field: string): boolean { + if (ENVIRONMENT_BOUND_FIELDS.has(field)) return true + const config = findSubBlockConfig(blockType, field) + if (!config) return false + return Boolean(config.password) || ENVIRONMENT_BOUND_TYPES.has(config.type) +} + +/** Splits a block's changes into logic rows and workspace-bound rows. */ +export function splitEnvironmentBindings( + blockType: string, + changes: T[] +): { logic: T[]; bindings: T[] } { + const logic: T[] = [] + const bindings: T[] = [] + for (const change of changes) { + ;(isEnvironmentBinding(blockType, change.field) ? bindings : logic).push(change) + } + return { logic, bindings } +} + +/** Labels for container configuration fields, which no block definition declares. */ +const CONTAINER_FIELD_LABELS: Record = { + loopType: 'Loop type', + iterations: 'Iterations', + forEachItems: 'Collection', + whileCondition: 'While condition', + doWhileCondition: 'Do-while condition', + parallelType: 'Parallel type', + count: 'Count', + distribution: 'Collection', +} + +const CONTAINER_CONFIG_FIELDS: ReadonlySet = new Set([ + ...LOOP_CONFIG_FIELDS, + ...PARALLEL_CONFIG_FIELDS, +]) + +function isContainerConfigField(field: string): field is ContainerConfigField { + return CONTAINER_CONFIG_FIELDS.has(field) +} + +export function containerFieldLabel(field: string): string { + return isContainerConfigField(field) ? CONTAINER_FIELD_LABELS[field] : field +} + +/** + * Labels for fields the comparison engine reports outside any block + * definition: block-level settings and the editor's basic/advanced mode per + * canonical parameter, which decides which of two stored values executes. + */ +const ENGINE_FIELD_LABELS: Record = { + 'data.canonicalModes': 'Field modes', + name: 'Name', + enabled: 'Enabled', + triggerMode: 'Trigger mode', + advancedMode: 'Advanced mode', +} + +export function engineFieldLabel(field: string): string | undefined { + return ENGINE_FIELD_LABELS[field] +} + +/** A chat message as an agent block stores it. */ +export interface DiffMessage { + role: string + content: string +} + +function isMessageList(value: unknown): value is DiffMessage[] { + return ( + Array.isArray(value) && + value.length > 0 && + value.every( + (item) => + item !== null && + typeof item === 'object' && + typeof (item as DiffMessage).role === 'string' && + typeof (item as DiffMessage).content === 'string' + ) + ) +} + +/** Reads a stored messages value as a list, tolerating the JSON-string form older drafts used. */ +export function toMessageList(value: unknown): DiffMessage[] { + if (isMessageList(value)) return value + if (typeof value === 'string') { + try { + const parsed: unknown = JSON.parse(value) + if (isMessageList(parsed)) return parsed + } catch { + return [] + } + } + return [] +} + +/** Block definitions are static, so each type's sub-blocks are indexed once. */ +const subBlockIndex = new Map>() + +function findSubBlockConfig(blockType: string, field: string): SubBlockConfig | undefined { + let index = subBlockIndex.get(blockType) + if (!index) { + index = new Map() + for (const subBlock of getBlock(blockType)?.subBlocks ?? []) { + if (!index.has(subBlock.id)) index.set(subBlock.id, subBlock) + } + subBlockIndex.set(blockType, index) + } + return index.get(field) +} + +function isTextLike(value: unknown): boolean { + return ( + typeof value === 'string' && (value.includes('\n') || value.length > INLINE_TEXT_MAX_LENGTH) + ) +} + +/** A short single-token value (model id, number, enum) reads better as a pair than a word diff. */ +export function isSentenceLike(value: unknown): value is string { + return typeof value === 'string' && /\s/.test(value.trim()) +} + +/** Field rows in the order the block definition declares them; unknown fields trail. */ +function sortChangesByDefinition( + blockType: string, + changes: T[] +): T[] { + const order = new Map() + getBlock(blockType)?.subBlocks.forEach((subBlock, index) => order.set(subBlock.id, index)) + const rank = (field: string) => order.get(field) ?? Number.MAX_SAFE_INTEGER + return [...changes].sort((a, b) => rank(a.field) - rank(b.field)) +} + +/** + * Picks the row treatment for a field change from the block definition first + * and the value shapes second, so a prompt reads as a text diff even when the + * definition no longer declares the field. + */ +export function classifyChange( + blockType: string, + field: string, + oldValue: unknown, + newValue: unknown +): ValueKind { + const config = findSubBlockConfig(blockType, field) + if (config?.password) return 'secret' + if (config?.type === 'messages-input' || isMessageList(oldValue) || isMessageList(newValue)) { + return 'messages' + } + const oldList = toItemList(oldValue) + const newList = toItemList(newValue) + const eitherList = oldList !== null || newList !== null + const bothListOrBlank = + (oldList !== null || isBlankValue(oldValue)) && (newList !== null || isBlankValue(newValue)) + if (bothListOrBlank && ((config && LIST_SUB_BLOCK_TYPES.has(config.type)) || eitherList)) { + return 'list' + } + if (config && TEXT_SUB_BLOCK_TYPES.has(config.type)) return 'text' + if (typeof oldValue === 'boolean' || typeof newValue === 'boolean') return 'toggle' + if (isTextLike(oldValue) || isTextLike(newValue)) return 'text' + const sample = newValue ?? oldValue + if (sample !== null && typeof sample === 'object') return 'json' + return 'scalar' +} + +/** + * Resolves a stored dropdown id to its option label, else falls back to the + * shared display formatter. + */ +export function formatScalar(blockType: string, field: string, value: unknown): string { + const config = findSubBlockConfig(blockType, field) + if (config?.type === 'dropdown' && typeof value === 'string' && config.options) { + const options = typeof config.options === 'function' ? config.options() : config.options + const match = options.find((option) => option.id === value) + if (match?.label) return match.label + } + return formatValueForDisplay(value) +} + +/** A string for the line diff; objects are pretty-printed so structure diffs line by line. */ +export function toDiffText(value: unknown): string { + if (value === null || value === undefined) return '' + if (typeof value === 'string') return value + return JSON.stringify(value, null, 2) +} + +/** One side of a block that only exists in one version: what it has, as a change from nothing. */ +export interface OneSidedField { + field: string + oldValue: unknown + newValue: unknown +} + +/** + * Keys whose values are never rendered, wherever they appear in a stored value: + * anchored to the end of the key so `maxTokens` and `tokenLimit` stay visible + * while `accessToken`, `client_secret` and `apiKey` are masked. + */ +const SECRET_KEY_PATTERN = + /(token|secret|password|passphrase|credential|api[_-]?key|private[_-]?key)$/i +const MASKED_VALUE = '•••' + +/** Returns a copy of a params object with secret-looking values masked. */ +export function maskSecretParams(params: Record): Record { + const out: Record = {} + for (const [key, value] of Object.entries(params)) { + out[key] = SECRET_KEY_PATTERN.test(key) && value !== '' && value != null ? MASKED_VALUE : value + } + return out +} + +export function isBlankValue(value: unknown): boolean { + if (value === null || value === undefined || value === '') return true + if (Array.isArray(value)) return value.length === 0 + if (typeof value === 'object') return Object.keys(value as object).length === 0 + return false +} + +/** + * Every non-empty field on a block that exists on only one side, as field + * changes with the other side empty. An added block then renders exactly like + * a modified one whose every field went from nothing to its value, and a + * removed block the reverse, so code stays code and a prompt stays a prompt. + * Declared fields come first in definition order; a declared secret is kept + * so the card can say it is set, an undeclared secret-looking key is dropped + * because nothing would mask its raw value. + */ +export function listOneSidedFields(block: BlockState, side: 'added' | 'removed'): OneSidedField[] { + const declared = getBlock(block.type)?.subBlocks ?? [] + const out: OneSidedField[] = [] + const seen = new Set() + const push = (field: string, value: unknown) => { + if (seen.has(field)) return + seen.add(field) + if (isBlankValue(value)) return + out.push({ + field, + oldValue: side === 'removed' ? value : undefined, + newValue: side === 'added' ? value : undefined, + }) + } + for (const subBlock of declared) push(subBlock.id, block.subBlocks?.[subBlock.id]?.value) + for (const [field, state] of Object.entries(block.subBlocks ?? {})) { + if (SECRET_KEY_PATTERN.test(field)) continue + push(field, state?.value) + } + return out +} + +/** + * Fields the comparison engine counts but a reviewer never needs to see: pure + * canvas presentation. They still drive "needs redeploy", so they are hidden + * here rather than in the engine. The basic/advanced mode memory is NOT one of + * them: with both values stored, the mode decides which one executes. + */ +const PRESENTATION_FIELDS = new Set(['horizontalHandles']) + +/** + * The summary with presentation-only field changes removed, and any block that + * only had those dropped from the modified list, so the canvas and the list + * agree on what counts as a change. + */ +export function omitPresentationChanges(summary: WorkflowDiffSummary): WorkflowDiffSummary { + const modifiedBlocks = summary.modifiedBlocks + .map((block) => ({ + ...block, + changes: block.changes.filter( + (change) => !PRESENTATION_FIELDS.has(change.field) && !change.field.endsWith('.properties') + ), + })) + .filter((block) => block.changes.length > 0) + const next = { ...summary, modifiedBlocks } + next.hasChanges = summaryHasChanges(next) + return next +} + +export type BlockChangeStatus = 'added' | 'modified' | 'removed' + +/** One block listed under a container's "Blocks inside". */ +export interface MembershipRow { + name: string + /** The block exists on both sides and changed container, rather than being added or removed */ + moved: boolean +} + +/** A block that moved between containers, named for the row "Moved into X". */ +export interface BlockMove { + into?: string + outOf?: string +} + +/** One block entry in the change list. */ +export interface BlockChangeEntry { + id: string + type: string + name: string + status: BlockChangeStatus + changes: WorkflowDiffSummary['modifiedBlocks'][number]['changes'] + /** Set on a surviving block whose container changed */ + moved?: BlockMove + /** Set on a container: which blocks entered or left it; `moved` marks a block that survives elsewhere */ + membership?: { added: MembershipRow[]; removed: MembershipRow[] } + /** Blocks nested under an added or removed container, listed with it */ + children: BlockChangeEntry[] +} + +function blockName(blocks: Record, id: string): string { + const block = blocks[id] + return block?.name || block?.type || id +} + +function parentOf(block: BlockState | undefined): string | undefined { + const parentId = block?.data?.parentId + return typeof parentId === 'string' && parentId ? parentId : undefined +} + +/** Every block's children in one pass, so containers look their members up in O(1). */ +function childrenByParent(blocks: Record): Map { + const out = new Map() + for (const [id, block] of Object.entries(blocks)) { + const parentId = parentOf(block) + if (!parentId) continue + const list = out.get(parentId) + if (list) list.push(id) + else out.set(parentId, [id]) + } + return out +} + +/** + * Flattens the summary into list entries: modified blocks first because they + * carry the field rows a reviewer spends the longest on, then added, then + * removed. A surviving block whose container changed reads as modified with a + * "Moved" row; a container that changed configuration or membership reads as + * modified with its config rows and the blocks that entered or left; children + * of an added or removed container nest under it. + */ +export function listBlockChanges( + summary: WorkflowDiffSummary, + baseBlocks: Record, + targetBlocks: Record +): BlockChangeEntry[] { + const blocks = { ...baseBlocks, ...targetBlocks } + const modified = new Map() + for (const block of summary.modifiedBlocks) { + modified.set(block.id, { + id: block.id, + type: block.type, + name: block.name || block.type, + status: 'modified', + changes: sortChangesByDefinition(block.type, block.changes), + children: [], + }) + } + + const added = new Set(summary.addedBlocks.map((block) => block.id)) + const removed = new Set(summary.removedBlocks.map((block) => block.id)) + const ensureModified = (id: string): BlockChangeEntry => { + let entry = modified.get(id) + if (!entry) { + entry = { + id, + type: blocks[id]?.type ?? 'unknown', + name: blockName(blocks, id), + status: 'modified', + changes: [], + children: [], + } + modified.set(id, entry) + } + return entry + } + + /* A block on both sides whose parent changed moved, whatever became of the containers. */ + for (const id of Object.keys(targetBlocks)) { + if (!baseBlocks[id]) continue + const before = parentOf(baseBlocks[id]) + const after = parentOf(targetBlocks[id]) + if (before === after) continue + const entry = ensureModified(id) + entry.moved = { + ...(after ? { into: blockName(blocks, after) } : {}), + ...(before ? { outOf: blockName(blocks, before) } : {}), + } + } + + for (const container of summary.containerChanges) { + if (added.has(container.id) || removed.has(container.id)) continue + const entry = ensureModified(container.id) + entry.changes = [...entry.changes, ...container.changes] + if (container.nodesAdded.length || container.nodesRemoved.length) { + const row = (id: string): MembershipRow => ({ + name: blockName(blocks, id), + moved: Boolean(baseBlocks[id] && targetBlocks[id]), + }) + entry.membership = { + added: container.nodesAdded.map(row), + removed: container.nodesRemoved.map(row), + } + } + } + + const nest = ( + list: WorkflowDiffSummary['addedBlocks'], + status: 'added' | 'removed', + sameStatus: Set, + side: Record + ): BlockChangeEntry[] => { + const entries = new Map() + const children = childrenByParent(side) + for (const block of list) { + entries.set(block.id, { + id: block.id, + type: block.type, + name: block.name || block.type, + status, + changes: [], + children: [], + }) + } + const roots: BlockChangeEntry[] = [] + for (const entry of entries.values()) { + const parentId = parentOf(side[entry.id]) + const parent = parentId && sameStatus.has(parentId) ? entries.get(parentId) : undefined + if (parent) parent.children.push(entry) + else roots.push(entry) + /* A container that appeared or vanished around surviving blocks lists them too. */ + if (isContainerType(entry.type)) { + const survivors = (children.get(entry.id) ?? []).filter((id) => !sameStatus.has(id)) + if (survivors.length) { + const rows = survivors.map((id) => ({ name: blockName(blocks, id), moved: true })) + entry.membership = { + added: status === 'added' ? rows : [], + removed: status === 'removed' ? rows : [], + } + } + } + } + return roots + } + + return [ + ...modified.values(), + ...nest(summary.addedBlocks, 'added', added, targetBlocks), + ...nest(summary.removedBlocks, 'removed', removed, baseBlocks), + ] +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/workflow-diff-view.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/workflow-diff-view.tsx new file mode 100644 index 00000000000..16efa8799fe --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/workflow-diff-view.tsx @@ -0,0 +1,125 @@ +'use client' + +import { useCallback, useMemo, useState } from 'react' +import { cn } from '@sim/emcn' +import { buildWorkflowDiffOverlay, generateWorkflowDiffSummary } from '@/lib/workflows/comparison' +import { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview' +import { ChangeList } from '@/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list' +import { omitPresentationChanges } from '@/app/workspace/[workspaceId]/w/components/workflow-diff/utils' +import type { WorkflowState } from '@/stores/workflows/workflow/types' + +/** The change list pane and the skeleton that stands in for it share one width. */ +export const CHANGE_LIST_WIDTH_CLASS = 'w-[440px] max-w-[45%]' + +interface WorkflowDiffViewProps { + /** The older or source side of the comparison */ + baseState: WorkflowState + /** The newer or destination side; the canvas paints this one */ + targetState: WorkflowState + /** Shown in the strip above the panes; omit when the host already names the sides */ + baseLabel?: string + targetLabel?: string + /** The sides live in different workspaces; set workspace-bound fields apart, muted */ + environmentBindings?: boolean +} + +/** + * Side-by-side comparison of two workflow states: the target canvas on the + * left with every touched block ringed and every removed one ghosted, the + * change list on the right. Selection is shared between the two panes. + */ +export function WorkflowDiffView({ + baseState, + targetState, + baseLabel, + targetLabel, + environmentBindings = false, +}: WorkflowDiffViewProps) { + const [selectedBlockId, setSelectedBlockId] = useState(null) + + const summary = useMemo( + () => omitPresentationChanges(generateWorkflowDiffSummary(targetState, baseState)), + [targetState, baseState] + ) + const overlay = useMemo( + () => buildWorkflowDiffOverlay(summary, baseState, targetState), + [summary, baseState, targetState] + ) + + const handleNodeClick = useCallback((blockId: string) => { + setSelectedBlockId((current) => (current === blockId ? null : blockId)) + }, []) + const handlePaneClick = useCallback(() => setSelectedBlockId(null), []) + + const counts = [ + { label: 'added', value: summary.addedBlocks.length, className: 'text-[var(--brand-accent)]' }, + { label: 'modified', value: summary.modifiedBlocks.length, className: 'text-[var(--warning)]' }, + { + label: 'removed', + value: summary.removedBlocks.length, + className: 'text-[var(--text-error)]', + }, + ].filter((count) => count.value > 0) + + return ( +
+
+ {baseLabel && targetLabel && ( +
+ {baseLabel} + → + {targetLabel} +
+ )} +
+ {counts.length === 0 ? ( + No changes + ) : ( + counts.map((count) => ( + + {count.value} {count.label} + + )) + )} + {(summary.edgeChanges.added > 0 || summary.edgeChanges.removed > 0) && ( + + {summary.edgeChanges.added + summary.edgeChanges.removed} connections + + )} +
+
+ +
+
+ +
+
+ +
+
+
+ ) +} From 609c81bf25bb50e9314e42afe599e049ef16654c Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Tue, 29 Sep 2026 22:49:47 -0700 Subject: [PATCH 04/14] feat(deploy): compare deployment versions and the draft The deploy modal offers "View changes" (live against the draft) when a redeploy is pending and a "Compare" action on every version row, opening a full-width comparison whose two sides are pickable from the header. The draft state hook is shared with change detection and only subscribes while something to compare against exists. --- .../components/general/compare-pair.test.ts | 27 ++++ .../components/general/compare-pair.ts | 20 +++ .../components/compare-versions-modal.tsx | 147 ++++++++++++++++++ .../components/general/components/index.ts | 1 + .../general/components/versions.tsx | 13 +- .../components/general/general.tsx | 55 ++++++- .../components/deploy-modal/deploy-modal.tsx | 1 + .../deploy/hooks/use-change-detection.ts | 48 +----- .../deploy/hooks/use-draft-workflow-state.ts | 51 ++++++ 9 files changed, 311 insertions(+), 52 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/compare-pair.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/compare-pair.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/compare-versions-modal.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-draft-workflow-state.ts diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/compare-pair.test.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/compare-pair.test.ts new file mode 100644 index 00000000000..9003d47f729 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/compare-pair.test.ts @@ -0,0 +1,27 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { resolveComparePair } from './compare-pair' + +describe('resolveComparePair', () => { + it('puts the older version on the base side whichever was clicked', () => { + const expected = { + base: { kind: 'version', version: 3 }, + target: { kind: 'version', version: 5 }, + } + expect(resolveComparePair(5, 3)).toEqual(expected) + expect(resolveComparePair(3, 5)).toEqual(expected) + }) + + it('compares the live version, or any version with nothing live, against the draft', () => { + expect(resolveComparePair(3, 3)).toEqual({ + base: { kind: 'version', version: 3 }, + target: { kind: 'draft' }, + }) + expect(resolveComparePair(2, null)).toEqual({ + base: { kind: 'version', version: 2 }, + target: { kind: 'draft' }, + }) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/compare-pair.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/compare-pair.ts new file mode 100644 index 00000000000..e92f19afa0e --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/compare-pair.ts @@ -0,0 +1,20 @@ +import type { CompareSide } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/compare-versions-modal' + +export interface ComparePair { + base: CompareSide + target: CompareSide +} + +/** + * Which two sides a "Compare" on a version opens: the older version on the + * left and the newer on the right, except that the live version (or any + * version when nothing is live) compares against the draft, since that is + * what a redeploy would ship. + */ +export function resolveComparePair(version: number, activeVersion: number | null): ComparePair { + if (activeVersion === null || activeVersion === version) { + return { base: { kind: 'version', version }, target: { kind: 'draft' } } + } + const [low, high] = version < activeVersion ? [version, activeVersion] : [activeVersion, version] + return { base: { kind: 'version', version: low }, target: { kind: 'version', version: high } } +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/compare-versions-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/compare-versions-modal.tsx new file mode 100644 index 00000000000..3c4269517d8 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/compare-versions-modal.tsx @@ -0,0 +1,147 @@ +'use client' + +import { useId, useMemo, useState } from 'react' +import { + ChipDropdown, + type ChipDropdownOption, + ChipModal, + ChipModalBody, + ChipModalHeader, + Skeleton, +} from '@sim/emcn' +import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils' +import { formatVersionLabel } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/format-version-label' +import { useDraftWorkflowState } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-draft-workflow-state' +import { + CHANGE_LIST_WIDTH_CLASS, + WorkflowDiffView, +} from '@/app/workspace/[workspaceId]/w/components/workflow-diff' +import { useDeploymentVersionState } from '@/hooks/queries/workflows' + +/** One side of a comparison: the editor draft or a numbered deployment version. */ +export type CompareSide = { kind: 'draft' } | { kind: 'version'; version: number } + +const DRAFT_OPTION_VALUE = 'draft' + +function sideToValue(side: CompareSide): string { + return side.kind === 'draft' ? DRAFT_OPTION_VALUE : String(side.version) +} + +function valueToSide(value: string): CompareSide { + return value === DRAFT_OPTION_VALUE + ? { kind: 'draft' } + : { kind: 'version', version: Number(value) } +} + +interface CompareVersionsModalProps { + open: boolean + onOpenChange: (open: boolean) => void + workflowId: string + versions: WorkflowDeploymentVersionResponse[] + initialBase: CompareSide + initialTarget: CompareSide +} + +/** + * Full-width comparison of any two versions of this workflow, including the + * unsaved draft. Both sides are pickable from the header so a reviewer can + * move between pairs without leaving the view. + */ +export function CompareVersionsModal({ + open, + onOpenChange, + workflowId, + versions, + initialBase, + initialTarget, +}: CompareVersionsModalProps) { + const descriptionId = useId() + const [base, setBase] = useState(initialBase) + const [target, setTarget] = useState(initialTarget) + /* Subscribed only while the modal is open, and only if a side is the draft. */ + const draftState = useDraftWorkflowState( + workflowId, + base.kind === 'draft' || target.kind === 'draft' + ) + + const options = useMemo((): ChipDropdownOption[] => { + const sorted = [...versions].sort((a, b) => b.version - a.version) + return [ + { value: DRAFT_OPTION_VALUE, label: 'Draft' }, + ...sorted.map((version) => { + /* A name that merely repeats the number would read as "v2 · v2". */ + const name = version.name === `v${version.version}` ? null : version.name + const label = formatVersionLabel(version.version, name) + return { + value: String(version.version), + label: version.isActive ? `${label} (live)` : label, + } + }), + ] + }, [versions]) + + const baseQuery = useDeploymentVersionState( + workflowId, + base.kind === 'version' ? base.version : null + ) + const targetQuery = useDeploymentVersionState( + workflowId, + target.kind === 'version' ? target.version : null + ) + + const baseState = base.kind === 'draft' ? draftState : (baseQuery.data ?? null) + const targetState = target.kind === 'draft' ? draftState : (targetQuery.data ?? null) + const isLoading = + (base.kind === 'version' && baseQuery.isLoading) || + (target.kind === 'version' && targetQuery.isLoading) + const loadError = baseQuery.error ?? targetQuery.error + + return ( + + onOpenChange(false)}> +
+ Compare + setBase(valueToSide(value))} + align='start' + /> + → + setTarget(valueToSide(value))} + align='start' + /> +
+
+ +

+ Blocks, fields and connections that differ between the two selected versions. +

+ {loadError ? ( +
+ {loadError.message || 'Could not load one of the versions.'} +
+ ) : isLoading || !baseState || !targetState ? ( +
+ + +
+ ) : ( + + )} +
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/index.ts index a9946f7d520..73d6e54b826 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/index.ts @@ -1 +1,2 @@ +export { type CompareSide, CompareVersionsModal } from './compare-versions-modal' export { Versions } from './versions' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/versions.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/versions.tsx index 2245b6a3647..c636b2d5f65 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/versions.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/versions.tsx @@ -13,7 +13,7 @@ import { Skeleton, Tooltip, } from '@sim/emcn' -import { FileText, MoreVertical, Pencil, RefreshCw, SendToBack } from '@sim/emcn/icons' +import { Columns2, FileText, MoreVertical, Pencil, RefreshCw, SendToBack } from '@sim/emcn/icons' import { formatDateTime } from '@sim/utils/formatting' import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils' import { formatVersionLabel } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/format-version-label' @@ -40,6 +40,7 @@ interface VersionsProps { onSelectVersion: (version: number | null) => void onPromoteToLive: (version: number) => void onLoadDeployment: (version: number) => void + onCompare: (version: number) => void } /** @@ -55,6 +56,7 @@ export function Versions({ onSelectVersion, onPromoteToLive, onLoadDeployment, + onCompare, }: VersionsProps) { const [editingVersion, setEditingVersion] = useState(null) const [editValue, setEditValue] = useState('') @@ -129,6 +131,11 @@ export function Versions({ onLoadDeployment(version) } + const handleCompare = (version: number) => { + setOpenDropdown(null) + onCompare(version) + } + const handleOpenDescriptionModal = (version: number) => { setOpenDropdown(null) setDescriptionModalVersion(version) @@ -375,6 +382,10 @@ export function Versions({ {v.description ? 'Edit description' : 'Add description'} + handleCompare(v.version)}> + + Compare + {!v.isActive && ( handlePromote(v.version)}> diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/general.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/general.tsx index ce7b8cd9e5d..91be6f26e0c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/general.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/general.tsx @@ -22,7 +22,8 @@ import { Preview, PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/compon import { useDeploymentVersionState, useRevertToVersion } from '@/hooks/queries/workflows' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import type { WorkflowState } from '@/stores/workflows/workflow/types' -import { Versions } from './components' +import { resolveComparePair } from './compare-pair' +import { type CompareSide, CompareVersionsModal, Versions } from './components' import { formatVersionLabel } from './format-version-label' const logger = createLogger('GeneralDeploy') @@ -37,6 +38,8 @@ interface GeneralDeployProps { versionsLoading: boolean isPromotingVersion: boolean deployReadiness: DeployReadiness + /** The draft differs from the live version, so a "View changes" affordance is offered */ + needsRedeployment: boolean onPromoteToLive: (version: number) => Promise onLoadDeploymentComplete: () => void onLoadDeploymentBlocked: (message: string) => void @@ -56,13 +59,27 @@ export function GeneralDeploy({ versionsLoading, isPromotingVersion, deployReadiness, + needsRedeployment, onPromoteToLive, onLoadDeploymentComplete, onLoadDeploymentBlocked, }: GeneralDeployProps) { const expandedPreviewDescriptionId = useId() + const [comparePair, setComparePair] = useState<{ base: CompareSide; target: CompareSide } | null>( + null + ) const [selectedVersion, setSelectedVersion] = useState(null) const [showActiveDespiteSelection, setShowActiveDespiteSelection] = useState(false) + const activeVersion = versions.find((v) => v.isActive)?.version ?? null + + /** + * Opens a comparison with the older version on the left. Comparing the live + * version (or any version when nothing is live) shows it against the draft, + * which is what a redeploy would ship. + */ + const handleCompareVersion = (version: number) => { + setComparePair(resolveComparePair(version, activeVersion)) + } const previewMode: PreviewMode = selectedVersion !== null && !showActiveDespiteSelection ? 'selected' : 'active' const [showLoadDialog, setShowLoadDialog] = useState(false) @@ -198,11 +215,22 @@ export function GeneralDeploy({
- +
+ + {needsRedeployment && activeVersion !== null && !showToggle && ( + + )} +
@@ -326,6 +355,20 @@ export function GeneralDeploy({ }} /> + {workflowId && comparePair && ( + { + if (!open) setComparePair(null) + }} + workflowId={workflowId} + versions={versions} + initialBase={comparePair.base} + initialTarget={comparePair.target} + /> + )} + {workflowToShow && ( state.blocks) - const edges = useWorkflowStore((state) => state.edges) - const loops = useWorkflowStore((state) => state.loops) - const parallels = useWorkflowStore((state) => state.parallels) - const subBlockValues = useSubBlockStore((state) => - workflowId ? state.workflowValues[workflowId] : null - ) - const allVariables = useVariablesStore((state) => state.variables) - const workflowVariables = useMemo(() => { - if (!workflowId) return {} - const vars: Record = {} - for (const [id, variable] of Object.entries(allVariables)) { - if (variable.workflowId === workflowId) { - vars[id] = variable - } - } - return vars - }, [workflowId, allVariables]) - - const currentState = useMemo((): WorkflowState | null => { - if (!workflowId || !deployedState) return null - - const mergedBlocks = mergeSubblockStateWithValues(blocks, subBlockValues ?? {}) - - return { - blocks: mergedBlocks, - edges, - loops, - parallels, - variables: workflowVariables, - } as WorkflowState & { variables: Record } - }, [ - workflowId, - deployedState, - blocks, - edges, - loops, - parallels, - subBlockValues, - workflowVariables, - ]) + /* Nothing to compare against until the deployed state is in hand, so skip the merge until then. */ + const currentState = useDraftWorkflowState(workflowId, Boolean(deployedState)) const { changeDetected, changedFields } = useMemo(() => { if (!currentState || !deployedState || isLoadingDeployedState) { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-draft-workflow-state.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-draft-workflow-state.ts new file mode 100644 index 00000000000..d71febb022d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-draft-workflow-state.ts @@ -0,0 +1,51 @@ +import { useMemo } from 'react' +import { mergeSubblockStateWithValues } from '@sim/workflow-persistence/subblocks' +import { useVariablesStore } from '@/stores/variables/store' +import { useSubBlockStore } from '@/stores/workflows/subblock/store' +import { useWorkflowStore } from '@/stores/workflows/workflow/store' +import type { WorkflowState } from '@/stores/workflows/workflow/types' + +/** + * The live draft as one workflow state: canvas blocks merged with the + * sub-block values the editor holds separately, plus this workflow's + * variables. This is the shape deployment snapshots use, so it compares + * cleanly against any of them. + * + * The merge walks every block on every store change, so a consumer that has + * nothing to compare against yet passes `enabled: false` and gets null + * without paying for it. + */ +export function useDraftWorkflowState( + workflowId: string | null, + enabled = true +): WorkflowState | null { + const active = enabled && Boolean(workflowId) + const blocks = useWorkflowStore((state) => (active ? state.blocks : null)) + const edges = useWorkflowStore((state) => (active ? state.edges : null)) + const loops = useWorkflowStore((state) => (active ? state.loops : null)) + const parallels = useWorkflowStore((state) => (active ? state.parallels : null)) + const subBlockValues = useSubBlockStore((state) => + active && workflowId ? state.workflowValues[workflowId] : null + ) + const allVariables = useVariablesStore((state) => (active ? state.variables : null)) + + const workflowVariables = useMemo(() => { + const vars: WorkflowState['variables'] = {} + if (!workflowId || !allVariables) return vars + for (const [id, variable] of Object.entries(allVariables)) { + if (variable.workflowId === workflowId) vars[id] = variable + } + return vars + }, [workflowId, allVariables]) + + return useMemo((): WorkflowState | null => { + if (!workflowId || !blocks || !edges || !loops || !parallels) return null + return { + blocks: mergeSubblockStateWithValues(blocks, subBlockValues ?? {}), + edges, + loops, + parallels, + variables: workflowVariables, + } + }, [workflowId, blocks, edges, loops, parallels, subBlockValues, workflowVariables]) +} From b02ec190bb4c910c121d1e6e0ea6b34038ec4aa4 Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Tue, 29 Sep 2026 22:49:48 -0700 Subject: [PATCH 05/14] feat(forks): preview one workflow's block-level changes before a sync The synced deployed workflows list gets a "View changes" action per row. A new internal route and fork use case return the target as its editor holds it and the source deployment re-keyed into the target's block ids through the fork block map (or the derived id the sync would assign), with condition and route ids and variable ids aligned, so the two sides diff block for block like two versions of one workflow. --- .../[id]/fork/workflow-diff/route.test.ts | 140 +++++++++++ .../[id]/fork/workflow-diff/route.ts | 20 ++ .../application/sync-details.ts | 5 + .../application/sync-workflow-diff.test.ts | 225 ++++++++++++++++++ .../application/sync-workflow-diff.ts | 117 +++++++++ .../components/fork-sync/fork-sync-view.tsx | 42 +++- .../fork-sync/fork-workflow-diff-modal.tsx | 79 ++++++ .../components/fork-sync/use-fork-sync.ts | 5 + .../hooks/use-fork-workflow-diff.ts | 55 +++++ .../lib/copy/deploy-bridge.ts | 21 ++ .../lib/remap/remap-state-block-ids.test.ts | 152 ++++++++++++ .../lib/remap/remap-state-block-ids.ts | 66 +++++ apps/sim/lib/api/contracts/workspace-fork.ts | 43 +++- 13 files changed, 968 insertions(+), 2 deletions(-) create mode 100644 apps/sim/app/api/workspaces/[id]/fork/workflow-diff/route.test.ts create mode 100644 apps/sim/app/api/workspaces/[id]/fork/workflow-diff/route.ts create mode 100644 apps/sim/ee/workspace-forking/application/sync-workflow-diff.test.ts create mode 100644 apps/sim/ee/workspace-forking/application/sync-workflow-diff.ts create mode 100644 apps/sim/ee/workspace-forking/components/fork-sync/fork-workflow-diff-modal.tsx create mode 100644 apps/sim/ee/workspace-forking/hooks/use-fork-workflow-diff.ts create mode 100644 apps/sim/ee/workspace-forking/lib/remap/remap-state-block-ids.test.ts create mode 100644 apps/sim/ee/workspace-forking/lib/remap/remap-state-block-ids.ts diff --git a/apps/sim/app/api/workspaces/[id]/fork/workflow-diff/route.test.ts b/apps/sim/app/api/workspaces/[id]/fork/workflow-diff/route.test.ts new file mode 100644 index 00000000000..60ad7cb9e6b --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/fork/workflow-diff/route.test.ts @@ -0,0 +1,140 @@ +/** + * @vitest-environment node + */ +import { authMockFns, createMockRequest } from '@sim/testing' +import { createRouteContext } from '@sim/testing/helpers/http' +import { permissionsMock, permissionsMockFns } from '@sim/testing/mocks/permissions.mock' +import { + workspaceAuthorizationMock, + workspaceAuthorizationMockFns, +} from '@sim/testing/mocks/workspace-authorization.mock' +import { workspaceForkingAuthzMock } from '@sim/testing/mocks/workspace-forking-authz.mock' +import { + workspaceForkingLineageMock, + workspaceForkingLineageMockFns, +} from '@sim/testing/mocks/workspace-forking-lineage.mock' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { OrchestrationError } from '@/lib/core/orchestration/types' + +const mocks = vi.hoisted(() => ({ + loadSourceDeployedStates: vi.fn(), + loadTargetDraftState: vi.fn(), + readDeployedState: vi.fn(), + loadForkBlockMap: vi.fn(), + computeForkPromotePlan: vi.fn(), +})) + +vi.mock('@/lib/core/application/workspace-authorization', () => workspaceAuthorizationMock) +vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) +vi.mock('@/ee/workspace-forking/lib/lineage/authz', () => workspaceForkingAuthzMock) +vi.mock('@/ee/workspace-forking/lib/lineage/lineage', () => workspaceForkingLineageMock) +vi.mock('@/ee/workspace-forking/lib/copy/deploy-bridge', () => ({ + loadSourceDeployedStates: mocks.loadSourceDeployedStates, + loadTargetDraftState: mocks.loadTargetDraftState, + readDeployedState: mocks.readDeployedState, +})) +vi.mock('@/ee/workspace-forking/lib/mapping/block-map-store', () => ({ + loadForkBlockMap: mocks.loadForkBlockMap, +})) +vi.mock('@/ee/workspace-forking/lib/promote/promote-plan', () => ({ + computeForkPromotePlan: mocks.computeForkPromotePlan, +})) + +import { GET } from '@/app/api/workspaces/[id]/fork/workflow-diff/route' + +const { mockAuthorizeWorkspaceOperation } = workspaceAuthorizationMockFns +const mockGetSession = authMockFns.mockGetSession +permissionsMockFns.mockGetWorkspaceWithOwner.mockImplementation(async (id: string) => ({ + id, + name: id, + organizationId: null, + allowPersonalApiKeys: true, +})) + +const WORKSPACE_ID = 'child' +const routeContext = createRouteContext({ id: WORKSPACE_ID }) +const BASE_URL = `http://localhost/api/workspaces/${WORKSPACE_ID}/fork/workflow-diff` + +const emptyState = { blocks: {}, edges: [], loops: {}, parallels: {}, variables: {} } + +function request(query: Record) { + const url = `${BASE_URL}?${new URLSearchParams(query).toString()}` + return createMockRequest('GET', undefined, undefined, url) +} + +describe('fork workflow-diff route', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetSession.mockResolvedValue({ user: { id: 'user-1' }, session: { id: 'session-1' } }) + mockAuthorizeWorkspaceOperation.mockResolvedValue(undefined) + workspaceForkingLineageMockFns.mockResolveForkEdge.mockResolvedValue({ + parentWorkspaceId: 'parent', + childWorkspaceId: 'child', + }) + mocks.loadForkBlockMap.mockResolvedValue({ parentToChild: new Map(), childToParent: new Map() }) + mocks.loadSourceDeployedStates.mockResolvedValue({ + deployedWorkflows: [{ id: 'wf-src' }], + sourceStates: new Map([['wf-src', emptyState]]), + }) + mocks.computeForkPromotePlan.mockResolvedValue({ + items: [ + { + sourceWorkflowId: 'wf-src', + targetWorkflowId: 'wf-tgt', + mode: 'create', + sourceMeta: { name: 'Ask Biz' }, + }, + ], + archivedTargets: [], + }) + }) + + it('does not read any state when workspace authorization is refused', async () => { + mockAuthorizeWorkspaceOperation.mockRejectedValue( + new OrchestrationError('forbidden', 'Admin access required') + ) + + const response = await GET( + request({ otherWorkspaceId: 'parent', direction: 'push', sourceWorkflowId: 'wf-src' }), + routeContext + ) + + expect(response.status).toBe(403) + expect(mocks.loadSourceDeployedStates).not.toHaveBeenCalled() + }) + + it('rejects a request without the source workflow id', async () => { + const response = await GET( + request({ otherWorkspaceId: 'parent', direction: 'push' }), + routeContext + ) + + expect(response.status).toBe(400) + expect(mocks.loadSourceDeployedStates).not.toHaveBeenCalled() + }) + + it('maps a workflow outside the sync plan to 404', async () => { + const response = await GET( + request({ otherWorkspaceId: 'parent', direction: 'push', sourceWorkflowId: 'foreign' }), + routeContext + ) + + expect(response.status).toBe(404) + }) + + it('returns the before and after states with their labels', async () => { + const response = await GET( + request({ otherWorkspaceId: 'parent', direction: 'push', sourceWorkflowId: 'wf-src' }), + routeContext + ) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + targetWorkflowId: 'wf-tgt', + before: null, + after: emptyState, + beforeLabel: 'Ask Biz (current)', + afterLabel: 'Ask Biz (deployed)', + }) + }) +}) diff --git a/apps/sim/app/api/workspaces/[id]/fork/workflow-diff/route.ts b/apps/sim/app/api/workspaces/[id]/fork/workflow-diff/route.ts new file mode 100644 index 00000000000..e13179910ea --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/fork/workflow-diff/route.ts @@ -0,0 +1,20 @@ +import { getForkWorkflowDiffContract } from '@/lib/api/contracts/workspace-fork' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalForkErrorPolicy } from '@/ee/workspace-forking/api/route-policies' +import { forkOperations } from '@/ee/workspace-forking/application/operations' +import { getWorkspaceSyncWorkflowDiff } from '@/ee/workspace-forking/application/sync-workflow-diff' + +export const GET = defineInternalJsonRoute({ + contract: getForkWorkflowDiffContract, + auth: internalSessionAuth, + operation: forkOperations.syncPreview, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal fork request policy' }), + errorPolicy: internalForkErrorPolicy, + mapInput: ({ params, query }) => ({ workspaceId: params.id, ...query }), + useCase: getWorkspaceSyncWorkflowDiff, + present: (result) => result, +}) diff --git a/apps/sim/ee/workspace-forking/application/sync-details.ts b/apps/sim/ee/workspace-forking/application/sync-details.ts index fd5b8d34965..32552c05139 100644 --- a/apps/sim/ee/workspace-forking/application/sync-details.ts +++ b/apps/sim/ee/workspace-forking/application/sync-details.ts @@ -253,6 +253,8 @@ export const getWorkspaceSyncDetails = defineForkUseCase({ action: 'create' as const, currentName: item.sourceMeta.name, otherName: item.sourceMeta.name, + sourceWorkflowId: item.sourceWorkflowId, + targetWorkflowId: item.targetWorkflowId, } } const targetName = item.targetName ?? item.sourceMeta.name @@ -260,12 +262,15 @@ export const getWorkspaceSyncDetails = defineForkUseCase({ action: 'update' as const, currentName: currentIsSource ? item.sourceMeta.name : targetName, otherName: currentIsSource ? targetName : item.sourceMeta.name, + sourceWorkflowId: item.sourceWorkflowId, + targetWorkflowId: item.targetWorkflowId, } }), ...plan.archivedTargets.map((target) => ({ action: 'archive' as const, currentName: target.name, otherName: target.name, + targetWorkflowId: target.id, })), ] diff --git a/apps/sim/ee/workspace-forking/application/sync-workflow-diff.test.ts b/apps/sim/ee/workspace-forking/application/sync-workflow-diff.test.ts new file mode 100644 index 00000000000..cf96db93e7a --- /dev/null +++ b/apps/sim/ee/workspace-forking/application/sync-workflow-diff.test.ts @@ -0,0 +1,225 @@ +/** + * @vitest-environment node + */ +import { createSessionPrincipal } from '@sim/testing/factories/principal.factory' +import { permissionsMock, permissionsMockFns } from '@sim/testing/mocks/permissions.mock' +import { + workspaceAuthorizationMock, + workspaceAuthorizationMockFns, +} from '@sim/testing/mocks/workspace-authorization.mock' +import { workspaceForkingAuthzMock } from '@sim/testing/mocks/workspace-forking-authz.mock' +import { + workspaceForkingLineageMock, + workspaceForkingLineageMockFns, +} from '@sim/testing/mocks/workspace-forking-lineage.mock' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { WorkflowState } from '@/stores/workflows/workflow/types' + +const mocks = vi.hoisted(() => ({ + loadSourceDeployedStates: vi.fn(), + loadTargetDraftState: vi.fn(), + readDeployedState: vi.fn(), + loadForkBlockMap: vi.fn(), + computeForkPromotePlan: vi.fn(), +})) + +vi.mock('@/lib/core/application/workspace-authorization', () => workspaceAuthorizationMock) +vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) +vi.mock('@/ee/workspace-forking/lib/lineage/authz', () => workspaceForkingAuthzMock) +vi.mock('@/ee/workspace-forking/lib/lineage/lineage', () => workspaceForkingLineageMock) +vi.mock('@/ee/workspace-forking/lib/copy/deploy-bridge', () => ({ + loadSourceDeployedStates: mocks.loadSourceDeployedStates, + loadTargetDraftState: mocks.loadTargetDraftState, + readDeployedState: mocks.readDeployedState, +})) +vi.mock('@/ee/workspace-forking/lib/mapping/block-map-store', () => ({ + loadForkBlockMap: mocks.loadForkBlockMap, +})) +vi.mock('@/ee/workspace-forking/lib/promote/promote-plan', () => ({ + computeForkPromotePlan: mocks.computeForkPromotePlan, +})) + +import { getWorkspaceSyncWorkflowDiff } from '@/ee/workspace-forking/application/sync-workflow-diff' +import { deriveForkBlockId } from '@/ee/workspace-forking/lib/remap/block-identity' + +const principal = createSessionPrincipal({ userId: 'actor' }) +const edge = { parentWorkspaceId: 'parent', childWorkspaceId: 'child' } + +function state(blocks: Record, edges: WorkflowState['edges'] = []) { + return { + blocks: Object.fromEntries( + Object.entries(blocks).map(([id, { parentId }]) => [ + id, + { + id, + type: 'function', + name: id, + position: { x: 0, y: 0 }, + subBlocks: {}, + outputs: {}, + enabled: true, + ...(parentId ? { data: { parentId } } : {}), + }, + ]) + ), + edges, + loops: {}, + parallels: {}, + variables: {}, + } as unknown as WorkflowState +} + +function planItem(overrides: Record = {}) { + return { + sourceWorkflowId: 'wf-src', + targetWorkflowId: 'wf-tgt', + mode: 'replace', + sourceMeta: { name: 'Ask Biz' }, + targetName: 'Ask Biz (prod)', + ...overrides, + } +} + +function run( + input: Partial<{ + workspaceId: string + otherWorkspaceId: string + direction: 'push' | 'pull' + sourceWorkflowId: string + }> = {} +) { + return getWorkspaceSyncWorkflowDiff.execute({ + principal, + input: { + workspaceId: 'child', + otherWorkspaceId: 'parent', + direction: 'push', + sourceWorkflowId: 'wf-src', + ...input, + }, + }) +} + +describe('getWorkspaceSyncWorkflowDiff', () => { + beforeEach(() => { + vi.clearAllMocks() + workspaceAuthorizationMockFns.mockAuthorizeWorkspaceOperation.mockResolvedValue(undefined) + permissionsMockFns.mockGetWorkspaceWithOwner.mockImplementation(async (id: string) => ({ + id, + name: id, + organizationId: 'org', + allowPersonalApiKeys: false, + })) + workspaceForkingLineageMockFns.mockResolveForkEdge.mockResolvedValue(edge) + mocks.loadForkBlockMap.mockResolvedValue({ + parentToChild: new Map(), + childToParent: new Map([['b1', { targetBlockId: 'mapped-b1', targetWorkflowId: 'wf-tgt' }]]), + }) + mocks.loadSourceDeployedStates.mockResolvedValue({ + deployedWorkflows: [{ id: 'wf-src' }], + sourceStates: new Map([ + ['wf-src', state({ b1: {}, b2: {} }, [{ id: 'e', source: 'b1', target: 'b2' }])], + ]), + }) + mocks.computeForkPromotePlan.mockResolvedValue({ items: [planItem()], archivedTargets: [] }) + mocks.loadTargetDraftState.mockResolvedValue(state({ 'mapped-b1': {} })) + mocks.readDeployedState.mockResolvedValue(null) + }) + + it('re-keys the source through the block map and derives ids for unmapped blocks', async () => { + const result = await run() + + expect(Object.keys(result.after.blocks).sort()).toEqual( + [deriveForkBlockId('wf-tgt', 'b2'), 'mapped-b1'].sort() + ) + expect(result.after.edges[0]).toMatchObject({ + source: 'mapped-b1', + target: deriveForkBlockId('wf-tgt', 'b2'), + }) + expect(result).toMatchObject({ + targetWorkflowId: 'wf-tgt', + beforeLabel: 'Ask Biz (prod) (current)', + afterLabel: 'Ask Biz (deployed)', + }) + expect(result.before).toEqual(state({ 'mapped-b1': {} })) + }) + + it('re-keys the source variables to the target ids by name so they compare as themselves', async () => { + const source = state({ b1: {} }) + source.variables = { 'v-src': { id: 'v-src', name: 'region', type: 'string', value: 'eu' } } + const target = state({ 'mapped-b1': {} }) + target.variables = { 'v-tgt': { id: 'v-tgt', name: 'region', type: 'string', value: 'us' } } + mocks.loadSourceDeployedStates.mockResolvedValue({ + deployedWorkflows: [{ id: 'wf-src' }], + sourceStates: new Map([['wf-src', source]]), + }) + mocks.loadTargetDraftState.mockResolvedValue(target) + + const result = await run() + + expect(result.after.variables).toEqual({ + 'v-tgt': { id: 'v-tgt', name: 'region', type: 'string', value: 'eu' }, + }) + }) + + it('reports null before for a workflow the sync would create, without reading the target', async () => { + mocks.computeForkPromotePlan.mockResolvedValue({ + items: [planItem({ mode: 'create', targetName: undefined })], + archivedTargets: [], + }) + + const result = await run() + + expect(result.before).toBeNull() + expect(result.beforeLabel).toBe('Ask Biz (current)') + expect(mocks.loadTargetDraftState).not.toHaveBeenCalled() + expect(mocks.readDeployedState).not.toHaveBeenCalled() + }) + + it('falls back to the live deployment when the target has no draft', async () => { + const deployed = state({ 'mapped-b1': {}, extra: {} }) + mocks.loadTargetDraftState.mockResolvedValue(null) + mocks.readDeployedState.mockResolvedValue(deployed) + + const result = await run() + + expect(result.before).toBe(deployed) + expect(mocks.readDeployedState).toHaveBeenCalledWith('wf-tgt', 'parent') + }) + + it('rejects a workflow that is not in the sync plan before reading any target state', async () => { + await expect(run({ sourceWorkflowId: 'foreign' })).rejects.toMatchObject({ + code: 'not_found', + }) + expect(mocks.loadTargetDraftState).not.toHaveBeenCalled() + expect(mocks.readDeployedState).not.toHaveBeenCalled() + }) + + it('reads the other side as the source on a pull and pairs through the child block map', async () => { + await run({ workspaceId: 'parent', otherWorkspaceId: 'child', direction: 'pull' }) + + expect(mocks.loadSourceDeployedStates).toHaveBeenCalledWith('child') + expect(mocks.computeForkPromotePlan).toHaveBeenCalledWith( + expect.objectContaining({ + sourceWorkspaceId: 'child', + targetWorkspaceId: 'parent', + direction: 'pull', + }) + ) + expect(mocks.loadForkBlockMap).toHaveBeenCalledWith(expect.anything(), 'child') + }) + + it('refuses when the caller is not an admin on the other side', async () => { + workspaceAuthorizationMockFns.mockAuthorizeWorkspaceOperation.mockImplementation( + async (_principal: unknown, _operation: unknown, scope: { workspaceId: string }) => { + if (scope.workspaceId === 'parent') { + throw new OrchestrationError('forbidden', 'Admin access required') + } + } + ) + + await expect(run()).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.loadSourceDeployedStates).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/ee/workspace-forking/application/sync-workflow-diff.ts b/apps/sim/ee/workspace-forking/application/sync-workflow-diff.ts new file mode 100644 index 00000000000..38c324931c4 --- /dev/null +++ b/apps/sim/ee/workspace-forking/application/sync-workflow-diff.ts @@ -0,0 +1,117 @@ +import { db } from '@sim/db' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + defineForkUseCase, + type ForkApplicationContext, +} from '@/ee/workspace-forking/application/authorized-fork-use-case' +import { forkOperations } from '@/ee/workspace-forking/application/operations' +import { + loadSourceDeployedStates, + loadTargetDraftState, + readDeployedState, +} from '@/ee/workspace-forking/lib/copy/deploy-bridge' +import { loadForkBlockMap } from '@/ee/workspace-forking/lib/mapping/block-map-store' +import { computeForkPromotePlan } from '@/ee/workspace-forking/lib/promote/promote-plan' +import { buildForkBlockIdResolver } from '@/ee/workspace-forking/lib/remap/block-identity' +import { remapWorkflowStateBlockIds } from '@/ee/workspace-forking/lib/remap/remap-state-block-ids' +import type { Variable, WorkflowState } from '@/stores/workflows/workflow/types' + +/** + * A sync gives every copied variable a fresh id, so the target's variables never + * share ids with the source's. The comparison keys variables by id; re-key the + * source side to the target's ids by name so a variable that exists on both + * sides compares as itself rather than as one removed and one added. + */ +function alignVariableIds(after: WorkflowState, before: WorkflowState | null): WorkflowState { + if (!before?.variables || !after.variables) return after + const idByName = new Map( + Object.values(before.variables).map((variable) => [variable.name, variable.id] as const) + ) + const variables: Record = {} + for (const variable of Object.values(after.variables)) { + const id = idByName.get(variable.name) ?? variable.id + variables[id] = { ...variable, id } + } + return { ...after, variables } +} + +interface SyncWorkflowDiffInput { + workspaceId: string + otherWorkspaceId: string + direction: 'push' | 'pull' + sourceWorkflowId: string +} + +/** + * Block-level preview of ONE workflow in a sync: the target as its editor + * holds it (`before`: the draft the sync overwrites, else its live deployment, + * null when the sync would create the workflow) and the source's deployment + * re-keyed to the target's block ids (`after`). The plan and the block-id + * pairing are resolved exactly as the promote would resolve them. + * + * Both states carry their raw sub-block values, on par with the deployment + * version route the same session UI already reads; the caller holds admin on + * both workspaces (`bothSides`). `after` is the source re-keyed only: the + * resource remaps, custom block replacements and stored dependent values the + * sync applies at write time are shown by the sync page's own sections. + */ +export const getWorkspaceSyncWorkflowDiff = defineForkUseCase({ + operation: forkOperations.syncPreview, + bothSides: true, + edge: true, + async execute({ + input, + context, + }: { + input: SyncWorkflowDiffInput + context: ForkApplicationContext + }) { + const { workspaceId: id, direction, sourceWorkflowId } = input + const edge = context.edge! + const sourceWorkspaceId = direction === 'push' ? id : input.otherWorkspaceId + const targetWorkspaceId = direction === 'push' ? input.otherWorkspaceId : id + + const [{ deployedWorkflows, sourceStates }, blockMap] = await Promise.all([ + loadSourceDeployedStates(sourceWorkspaceId), + loadForkBlockMap(db, edge.childWorkspaceId), + ]) + const plan = await computeForkPromotePlan({ + executor: db, + edge, + sourceWorkspaceId, + targetWorkspaceId, + direction, + deployedSourceWorkflows: deployedWorkflows, + sourceStates, + }) + + const item = plan.items.find((candidate) => candidate.sourceWorkflowId === sourceWorkflowId) + const sourceState = sourceStates.get(sourceWorkflowId) + if (!item || !sourceState) { + throw new OrchestrationError('not_found', 'That workflow is not part of this sync') + } + + const sourceIsParent = sourceWorkspaceId === edge.parentWorkspaceId + const resolveBlockId = buildForkBlockIdResolver(sourceIsParent, blockMap) + const after = remapWorkflowStateBlockIds(sourceState, (blockId) => + resolveBlockId(item.targetWorkflowId, blockId) + ) + + let before: WorkflowState | null = null + if (item.mode === 'replace') { + before = + (await loadTargetDraftState(item.targetWorkflowId)) ?? + (await readDeployedState(item.targetWorkflowId, targetWorkspaceId)) + } + + const sourceName = item.sourceMeta.name + const targetName = item.targetName ?? sourceName + return { + targetWorkflowId: item.targetWorkflowId, + before, + after: alignVariableIds(after, before), + beforeLabel: `${targetName} (current)`, + afterLabel: `${sourceName} (deployed)`, + } + }, +}) diff --git a/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx b/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx index df4cf34dc98..f164d2846e5 100644 --- a/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx +++ b/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx @@ -3,6 +3,7 @@ import { type Dispatch, Fragment, type SetStateAction, useMemo, useState } from 'react' import { Badge, + Button, ChevronDown, Chip, ChipCombobox, @@ -15,13 +16,14 @@ import { OverflowText, Tooltip, } from '@sim/emcn' -import { ArrowRight } from '@sim/emcn/icons' +import { ArrowRight, Columns2 } from '@sim/emcn/icons' import type { ForkCopyableUnmapped, ForkDependentReconfig, ForkMappingEntry, ForkResourceUsage, ForkTriggerMapping, + ForkWorkflowChange, } from '@/lib/api/contracts/workspace-fork' import type { SelectorKey } from '@/lib/selectors/manifest' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' @@ -52,6 +54,7 @@ import { getDisplayedDependentFields, isDependentConfigurationActionable, } from '@/ee/workspace-forking/components/fork-sync/dependent-value' +import { ForkWorkflowDiffModal } from '@/ee/workspace-forking/components/fork-sync/fork-workflow-diff-modal' import type { ForkKindSummary, ForkMappingGroup, @@ -806,6 +809,8 @@ interface ForkSyncViewProps { * blocking references. The page header's Sync action commits it (after the overwrite confirm). */ export function ForkSyncView({ controller, onDirectionChange }: ForkSyncViewProps) { + const [diffWorkflow, setDiffWorkflow] = useState(null) + const diffSourceWorkflowId = diffWorkflow?.sourceWorkflowId const detailsError = controller.errorMessage ?? controller.diffErrorMessage const headsUp = controller.mcpReauthCount > 0 || @@ -864,6 +869,21 @@ export function ForkSyncView({ controller, onDirectionChange }: ForkSyncViewProp deployed workflow appears here, changed or not), so the muted state nudges a deploy. Unsynced workflows list greyed at the end, with a tooltip naming which workspace they are unsynced in - the sync will not touch them. */} + {diffWorkflow && diffSourceWorkflowId && controller.otherWorkspaceId && ( + { + if (!open) setDiffWorkflow(null) + }} + workspaceId={controller.workspaceId} + otherWorkspaceId={controller.otherWorkspaceId} + direction={controller.direction} + sourceWorkflowId={diffSourceWorkflowId} + workflowName={diffWorkflow.currentName} + /> + )} + {controller.hasDiff ? ( {controller.workflowChanges.length + excludedRows.length > 0 ? ( @@ -871,6 +891,10 @@ export function ForkSyncView({ controller, onDirectionChange }: ForkSyncViewProp
{controller.workflowChanges.map((change, index) => { const renamed = change.currentName !== change.otherName + const canPreview = + change.action !== 'archive' && + Boolean(change.sourceWorkflowId) && + Boolean(controller.otherWorkspaceId) return (
) : null} + {canPreview && ( + + + + + View changes + + )}
) })} diff --git a/apps/sim/ee/workspace-forking/components/fork-sync/fork-workflow-diff-modal.tsx b/apps/sim/ee/workspace-forking/components/fork-sync/fork-workflow-diff-modal.tsx new file mode 100644 index 00000000000..9ecd08aec5d --- /dev/null +++ b/apps/sim/ee/workspace-forking/components/fork-sync/fork-workflow-diff-modal.tsx @@ -0,0 +1,79 @@ +'use client' + +import { useId } from 'react' +import { ChipModal, ChipModalBody, ChipModalHeader, Skeleton } from '@sim/emcn' +import { + CHANGE_LIST_WIDTH_CLASS, + WorkflowDiffView, +} from '@/app/workspace/[workspaceId]/w/components/workflow-diff' +import { useForkWorkflowDiff } from '@/ee/workspace-forking/hooks/use-fork-workflow-diff' +import type { ForkDirection } from '@/ee/workspace-forking/hooks/workspace-fork' +import type { WorkflowState } from '@/stores/workflows/workflow/types' + +const EMPTY_STATE: WorkflowState = { blocks: {}, edges: [], loops: {}, parallels: {} } + +interface ForkWorkflowDiffModalProps { + open: boolean + onOpenChange: (open: boolean) => void + workspaceId: string + otherWorkspaceId: string + direction: ForkDirection + sourceWorkflowId: string + workflowName: string +} + +/** + * What a sync would change inside one workflow: the target as it stands on the + * left of the arrow, the source's deployment on the right, block ids already + * lined up through the fork's block map. + */ +export function ForkWorkflowDiffModal({ + open, + onOpenChange, + workspaceId, + otherWorkspaceId, + direction, + sourceWorkflowId, + workflowName, +}: ForkWorkflowDiffModalProps) { + const descriptionId = useId() + const query = useForkWorkflowDiff({ workspaceId, otherWorkspaceId, direction, sourceWorkflowId }) + + return ( + + onOpenChange(false)}>{workflowName} + +

+ Blocks, fields and connections this sync would change in the workflow. +

+ {query.error ? ( +
+ {query.error.message || 'Could not load the comparison.'} +
+ ) : !query.data ? ( +
+ + +
+ ) : ( + + )} +
+
+ ) +} diff --git a/apps/sim/ee/workspace-forking/components/fork-sync/use-fork-sync.ts b/apps/sim/ee/workspace-forking/components/fork-sync/use-fork-sync.ts index c4de4a5d719..9a382fb1f7f 100644 --- a/apps/sim/ee/workspace-forking/components/fork-sync/use-fork-sync.ts +++ b/apps/sim/ee/workspace-forking/components/fork-sync/use-fork-sync.ts @@ -191,6 +191,9 @@ export interface ForkSyncController { dependentClears: ForkClearedRef[] /** Deployed-workflow change list (update → create → archive, then by name). */ workflowChanges: ForkWorkflowChange[] + /** The workspace the sync page is open in, and its partner on this edge. */ + workspaceId: string + otherWorkspaceId?: string /** Names of target workflows this sync archives, for the confirm modal. */ archivedWorkflowNames: string[] /** @@ -1075,6 +1078,8 @@ export function useForkSync(params: { blockingRefs, dependentClears, workflowChanges, + workspaceId, + otherWorkspaceId, archivedWorkflowNames, triggerUrlChanges, triggerMappings, diff --git a/apps/sim/ee/workspace-forking/hooks/use-fork-workflow-diff.ts b/apps/sim/ee/workspace-forking/hooks/use-fork-workflow-diff.ts new file mode 100644 index 00000000000..cff5dc0f457 --- /dev/null +++ b/apps/sim/ee/workspace-forking/hooks/use-fork-workflow-diff.ts @@ -0,0 +1,55 @@ +import { useQuery } from '@tanstack/react-query' +import { requestJson } from '@/lib/api/client/request' +import { getForkWorkflowDiffContract } from '@/lib/api/contracts/workspace-fork' +import type { ForkDirection } from '@/ee/workspace-forking/hooks/workspace-fork' + +export const forkWorkflowDiffKeys = { + all: ['fork-workflow-diff'] as const, + details: () => [...forkWorkflowDiffKeys.all, 'detail'] as const, + detail: ( + workspaceId?: string, + otherWorkspaceId?: string, + direction?: ForkDirection, + sourceWorkflowId?: string + ) => + [ + ...forkWorkflowDiffKeys.details(), + workspaceId ?? '', + otherWorkspaceId ?? '', + direction ?? '', + sourceWorkflowId ?? '', + ] as const, +} + +export const FORK_WORKFLOW_DIFF_STALE_TIME = 30 * 1000 + +interface UseForkWorkflowDiffParams { + workspaceId: string + otherWorkspaceId: string + direction: ForkDirection + sourceWorkflowId: string +} + +/** The block-level before and after of one workflow in a sync. */ +export function useForkWorkflowDiff({ + workspaceId, + otherWorkspaceId, + direction, + sourceWorkflowId, +}: UseForkWorkflowDiffParams) { + return useQuery({ + queryKey: forkWorkflowDiffKeys.detail( + workspaceId, + otherWorkspaceId, + direction, + sourceWorkflowId + ), + queryFn: ({ signal }) => + requestJson(getForkWorkflowDiffContract, { + params: { id: workspaceId }, + query: { otherWorkspaceId, direction, sourceWorkflowId }, + signal, + }), + staleTime: FORK_WORKFLOW_DIFF_STALE_TIME, + }) +} diff --git a/apps/sim/ee/workspace-forking/lib/copy/deploy-bridge.ts b/apps/sim/ee/workspace-forking/lib/copy/deploy-bridge.ts index 9bb10647c97..dd09a385b5f 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/deploy-bridge.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/deploy-bridge.ts @@ -6,6 +6,7 @@ import type { DbOrTx } from '@/lib/db/types' import { loadDeployedWorkflowState, loadWorkflowDeploymentVersionState, + loadWorkflowFromNormalizedTables, materializeDeploymentState, } from '@/lib/workflows/persistence/utils' import { ForkError } from '@/ee/workspace-forking/lib/lineage/authz' @@ -309,6 +310,26 @@ async function readAdmittedSourceState( * dropping the workflow from the fork/promote. Block migrations (credential remap * to current ids) are applied so copied references reflect current resources. */ +/** + * The target workflow as its editor currently holds it: the draft tables plus + * the variables on the workflow row, in the same shape as a deployed state. + * A sync overwrites exactly this, so it is the honest "before" of a preview. + */ +export async function loadTargetDraftState(workflowId: string): Promise { + const [draft, rows] = await Promise.all([ + loadWorkflowFromNormalizedTables(workflowId), + db.select({ variables: workflow.variables }).from(workflow).where(eq(workflow.id, workflowId)), + ]) + if (!draft) return null + return { + blocks: draft.blocks, + edges: draft.edges, + loops: draft.loops, + parallels: draft.parallels, + variables: (rows[0]?.variables ?? {}) as Record, + } +} + export async function readDeployedState( workflowId: string, workspaceId: string, diff --git a/apps/sim/ee/workspace-forking/lib/remap/remap-state-block-ids.test.ts b/apps/sim/ee/workspace-forking/lib/remap/remap-state-block-ids.test.ts new file mode 100644 index 00000000000..16f2d27fb15 --- /dev/null +++ b/apps/sim/ee/workspace-forking/lib/remap/remap-state-block-ids.test.ts @@ -0,0 +1,152 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { remapWorkflowStateBlockIds } from '@/ee/workspace-forking/lib/remap/remap-state-block-ids' +import type { WorkflowState } from '@/stores/workflows/workflow/types' + +/** Test states only carry the fields the remap reads; the cast keeps the fixtures short. */ +function asState(state: Record): WorkflowState { + return state as unknown as WorkflowState +} + +const resolve = (id: string) => `t-${id}` + +describe('remapWorkflowStateBlockIds', () => { + it('re-keys blocks and their parentId, leaving a block without a parent untouched', () => { + const state = asState({ + blocks: { + loop1: { id: 'loop1', type: 'loop', subBlocks: {}, data: { width: 500 } }, + child: { + id: 'child', + type: 'function', + subBlocks: {}, + data: { parentId: 'loop1', extent: 'parent' }, + }, + orphan: { id: 'orphan', type: 'agent', subBlocks: {} }, + }, + edges: [], + }) + + const out = remapWorkflowStateBlockIds(state, resolve) + + expect(Object.keys(out.blocks).sort()).toEqual(['t-child', 't-loop1', 't-orphan']) + expect(out.blocks['t-child'].id).toBe('t-child') + expect(out.blocks['t-child'].data).toEqual({ parentId: 't-loop1', extent: 'parent' }) + expect(out.blocks['t-loop1'].data).toEqual({ width: 500 }) + expect(out.blocks['t-orphan'].data).toBeUndefined() + /* The input is not mutated. */ + expect(Object.keys(state.blocks)).toEqual(['loop1', 'child', 'orphan']) + expect(state.blocks.child.data?.parentId).toBe('loop1') + }) + + it('remaps edge endpoints and condition/router handles, passing other handles through', () => { + const state = asState({ + blocks: { + cond: { id: 'cond', type: 'condition', subBlocks: {} }, + router: { id: 'router', type: 'router_v2', subBlocks: {} }, + next: { id: 'next', type: 'function', subBlocks: {} }, + }, + edges: [ + { + id: 'e1', + source: 'cond', + target: 'next', + sourceHandle: 'condition-cond-if', + targetHandle: 'target', + }, + { id: 'e2', source: 'router', target: 'next', sourceHandle: 'router-router-abc' }, + { id: 'e3', source: 'next', target: 'cond', sourceHandle: 'error', targetHandle: null }, + { id: 'e4', source: 'next', target: 'cond', sourceHandle: null }, + /* An edge to a block outside the state keeps its unknown id. */ + { id: 'e5', source: 'next', target: 'missing', sourceHandle: 'source' }, + ], + }) + + const out = remapWorkflowStateBlockIds(state, resolve) + + expect(out.edges).toEqual([ + { + id: 'e1', + source: 't-cond', + target: 't-next', + sourceHandle: 'condition-t-cond-if', + targetHandle: 'target', + }, + { id: 'e2', source: 't-router', target: 't-next', sourceHandle: 'router-t-router-abc' }, + { id: 'e3', source: 't-next', target: 't-cond', sourceHandle: 'error', targetHandle: null }, + { id: 'e4', source: 't-next', target: 't-cond', sourceHandle: null }, + { id: 'e5', source: 't-next', target: 'missing', sourceHandle: 'source' }, + ]) + }) + + it('rewrites the condition ids embedded in a condition block and leaves other sub-blocks alone', () => { + const conditions = JSON.stringify([ + { id: 'cond-if', value: 'a > 1' }, + { id: 'cond-else', value: '' }, + ]) + const state = asState({ + blocks: { + cond: { + id: 'cond', + type: 'condition', + subBlocks: { + conditions: { id: 'conditions', type: 'condition-input', value: conditions }, + note: { id: 'note', type: 'short-input', value: 'cond-if stays' }, + }, + }, + fn: { + id: 'fn', + type: 'function', + subBlocks: { code: { id: 'code', type: 'code', value: 'return "fn-x"' } }, + }, + }, + edges: [], + }) + + const out = remapWorkflowStateBlockIds(state, resolve) + + expect(JSON.parse(out.blocks['t-cond'].subBlocks.conditions.value as string)).toEqual([ + { id: 't-cond-if', value: 'a > 1' }, + { id: 't-cond-else', value: '' }, + ]) + expect(out.blocks['t-cond'].subBlocks.note.value).toBe('cond-if stays') + expect(out.blocks['t-fn'].subBlocks.code.value).toBe('return "fn-x"') + /* The original sub-block value is untouched. */ + expect(state.blocks.cond.subBlocks.conditions.value).toBe(conditions) + }) + + it('re-keys loops and parallels along with their membership', () => { + const state = asState({ + blocks: { + loop1: { id: 'loop1', type: 'loop', subBlocks: {} }, + par1: { id: 'par1', type: 'parallel', subBlocks: {} }, + a: { id: 'a', type: 'function', subBlocks: {}, data: { parentId: 'loop1' } }, + b: { id: 'b', type: 'function', subBlocks: {}, data: { parentId: 'par1' } }, + }, + edges: [], + loops: { loop1: { id: 'loop1', nodes: ['a'], loopType: 'for', iterations: 3 } }, + parallels: { par1: { id: 'par1', nodes: ['b'], parallelType: 'count', count: 2 } }, + }) + + const out = remapWorkflowStateBlockIds(state, resolve) + + expect(out.loops).toEqual({ + 't-loop1': { id: 't-loop1', nodes: ['t-a'], loopType: 'for', iterations: 3 }, + }) + expect(out.parallels).toEqual({ + 't-par1': { id: 't-par1', nodes: ['t-b'], parallelType: 'count', count: 2 }, + }) + }) + + it('tolerates a state with no edges, loops or parallels', () => { + const state = asState({ blocks: { a: { id: 'a', type: 'agent', subBlocks: {} } } }) + + const out = remapWorkflowStateBlockIds(state, resolve) + + expect(out.edges).toEqual([]) + expect(out.loops).toEqual({}) + expect(out.parallels).toEqual({}) + expect(out.blocks['t-a'].id).toBe('t-a') + }) +}) diff --git a/apps/sim/ee/workspace-forking/lib/remap/remap-state-block-ids.ts b/apps/sim/ee/workspace-forking/lib/remap/remap-state-block-ids.ts new file mode 100644 index 00000000000..2216df71ae1 --- /dev/null +++ b/apps/sim/ee/workspace-forking/lib/remap/remap-state-block-ids.ts @@ -0,0 +1,66 @@ +import { remapConditionEdgeHandle } from '@/lib/workflows/condition-ids' +import { + remapConditionIdsInSubBlocks, + type SubBlockRecord, +} from '@/lib/workflows/persistence/remap-internal-ids' +import type { WorkflowState } from '@/stores/workflows/workflow/types' + +/** + * Re-keys a workflow state's blocks (and the edges, container membership and + * parent references that name them) through a block-id resolver, without any + * of the copy path's value sanitizing. Used to line a source workflow up with + * its fork counterpart so the two diff block for block. + */ +export function remapWorkflowStateBlockIds( + state: WorkflowState, + resolve: (blockId: string) => string +): WorkflowState { + const idMap = new Map() + for (const id of Object.keys(state.blocks)) idMap.set(id, resolve(id)) + const mapId = (id: string) => idMap.get(id) ?? id + + const blocks: WorkflowState['blocks'] = {} + for (const [id, block] of Object.entries(state.blocks)) { + const parentId = block.data?.parentId + /* Condition and route ids embed the block id; the copy rewrites them, so the diff must too. */ + // double-cast-allowed: SubBlockRecord is the persistence view of the same sub-block map + const sourceSubBlocks = (block.subBlocks ?? {}) as unknown as SubBlockRecord + const remapped = remapConditionIdsInSubBlocks(sourceSubBlocks, block.type, id, mapId(id)) + // double-cast-allowed: back from the persistence view to the canvas state's sub-block map + const subBlocks = remapped as unknown as WorkflowState['blocks'][string]['subBlocks'] + blocks[mapId(id)] = { + ...block, + id: mapId(id), + subBlocks, + data: + typeof parentId === 'string' ? { ...block.data, parentId: mapId(parentId) } : block.data, + } + } + + const edges = (state.edges ?? []).map((edge) => ({ + ...edge, + source: mapId(edge.source), + target: mapId(edge.target), + sourceHandle: edge.sourceHandle + ? remapConditionEdgeHandle(edge.sourceHandle, edge.source, mapId(edge.source)) + : edge.sourceHandle, + })) + + const remapContainers = ( + containers: Record | undefined + ): Record => { + const out: Record = {} + for (const [id, container] of Object.entries(containers ?? {})) { + out[mapId(id)] = { ...container, id: mapId(id), nodes: container.nodes.map(mapId) } + } + return out + } + + return { + ...state, + blocks, + edges, + loops: remapContainers(state.loops), + parallels: remapContainers(state.parallels), + } +} diff --git a/apps/sim/lib/api/contracts/workspace-fork.ts b/apps/sim/lib/api/contracts/workspace-fork.ts index 3f8176b97c2..894402f8fa4 100644 --- a/apps/sim/lib/api/contracts/workspace-fork.ts +++ b/apps/sim/lib/api/contracts/workspace-fork.ts @@ -1,5 +1,10 @@ import { z } from 'zod' -import { nonEmptyIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { deployedWorkflowStateSchema } from '@/lib/api/contracts/deployments' +import { + nonEmptyIdSchema, + workflowIdSchema, + workspaceIdSchema, +} from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' import { workspaceSchema } from '@/lib/api/contracts/workspaces' import { WORKFLOW_RESOURCE_KINDS } from '@/lib/workflows/references/types' @@ -320,6 +325,10 @@ export const forkWorkflowChangeSchema = z.object({ currentName: z.string(), /** Workflow name in the sync-partner workspace (differs from `currentName` after a rename). */ otherName: z.string(), + /** The deployed source workflow this sync copies; absent for an archive. */ + sourceWorkflowId: z.string().optional(), + /** The workflow the sync writes or archives in the target workspace. */ + targetWorkflowId: z.string().optional(), }) /** @@ -991,3 +1000,35 @@ export type UpdateForkExcludedWorkflowsBody = z.input + +export const getForkWorkflowDiffQuerySchema = getForkDiffQuerySchema.extend({ + /** The deployed source workflow to preview, as listed by the sync details. */ + sourceWorkflowId: workflowIdSchema, +}) + +/** + * Block-level preview of what a sync would do to ONE workflow: the target's + * current state (`before`, null when the sync would create the workflow) and + * the source's deployed state re-keyed to the target's block ids (`after`), so + * the two compare block for block the way two versions of one workflow do. + */ +export const getForkWorkflowDiffContract = defineRouteContract({ + method: 'GET', + path: '/api/workspaces/[id]/fork/workflow-diff', + params: workspaceIdParamsSchema, + query: getForkWorkflowDiffQuerySchema, + response: { + mode: 'json', + schema: z.object({ + targetWorkflowId: z.string(), + before: deployedWorkflowStateSchema.nullable(), + after: deployedWorkflowStateSchema, + beforeLabel: z.string(), + afterLabel: z.string(), + }), + }, +}) +export type GetForkWorkflowDiffQuery = z.input +export type GetForkWorkflowDiffResponse = z.output< + typeof getForkWorkflowDiffContract.response.schema +> From 90b00894799bbe96ed51016a37c86dca3aed70df Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Tue, 29 Sep 2026 23:13:33 -0700 Subject: [PATCH 06/14] fix(workflows): harden the version diff after review Comparison: keep basic/advanced mode changes visible, include a tool's permission, server and implementation fields in list diffs, include input field defaults, read checkbox records as records, show whitespace-only edits, cap word and line diffing so a pathological prompt cannot stall the pane, slot deleted branches back at their old position, never reuse a ghost edge id, and mask secret-looking keys at any depth (including key/value table rows) with a name-based fallback when a block definition is unknown. Change list: memoized cards that only re-render when their own selection changes, nested cards for blocks inside an added or removed container, a shared sign map, muted tokens that exist, hover and focus treatment, scroll edge fades, and a shared skeleton for both hosts. Fork preview: the before side is the target draft the sync overwrites, read in one snapshot and scoped to the target workspace; variables and their assignments are re-keyed by unique name; a create reports no target id; the change rows are a discriminated union; the query key sits under the fork diff keys so a sync invalidates it; a direction switch closes the preview. --- .../[id]/fork/workflow-diff/route.test.ts | 16 ++- .../components/compare-versions-modal.tsx | 15 +-- .../components/general/general.tsx | 26 ++-- .../deploy/hooks/use-draft-workflow-state.ts | 2 +- .../components/block/block.tsx | 14 +-- .../components/subflow/subflow.tsx | 11 +- .../change-list/binding-change-row.tsx | 3 +- .../components/change-list/change-list.tsx | 93 ++++++++------ .../components/change-list/diff-signs.ts | 10 ++ .../change-list/field-change-row.tsx | 36 +++--- .../change-list/keyed-list-diff.tsx | 16 +-- .../change-list/text-diff-lines.test.ts | 37 +++++- .../components/change-list/text-diff-lines.ts | 30 ++++- .../components/change-list/text-diff.tsx | 19 ++- .../w/components/workflow-diff/index.ts | 2 +- .../w/components/workflow-diff/utils.test.ts | 111 +++++++++++++++- .../w/components/workflow-diff/utils.ts | 118 +++++++++++------- .../workflow-diff/workflow-diff-view.tsx | 67 +++++++--- .../application/sync-details.ts | 3 - .../application/sync-workflow-diff.test.ts | 62 ++++++--- .../application/sync-workflow-diff.ts | 84 ++++++++++--- .../components/fork-sync/fork-sync-view.tsx | 26 ++-- .../fork-sync/fork-workflow-diff-modal.tsx | 13 +- .../hooks/use-fork-workflow-diff.ts | 8 +- .../lib/copy/deploy-bridge.ts | 54 +++++--- apps/sim/lib/api/contracts/workspace-fork.ts | 28 +++-- .../lib/workflows/comparison/overlay.test.ts | 98 ++++++++++++++- apps/sim/lib/workflows/comparison/overlay.ts | 23 ++-- packages/workflow-renderer/src/types.ts | 6 +- 29 files changed, 745 insertions(+), 286 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/diff-signs.ts diff --git a/apps/sim/app/api/workspaces/[id]/fork/workflow-diff/route.test.ts b/apps/sim/app/api/workspaces/[id]/fork/workflow-diff/route.test.ts index 60ad7cb9e6b..23f420f0a83 100644 --- a/apps/sim/app/api/workspaces/[id]/fork/workflow-diff/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/fork/workflow-diff/route.test.ts @@ -19,7 +19,6 @@ import { OrchestrationError } from '@/lib/core/orchestration/types' const mocks = vi.hoisted(() => ({ loadSourceDeployedStates: vi.fn(), loadTargetDraftState: vi.fn(), - readDeployedState: vi.fn(), loadForkBlockMap: vi.fn(), computeForkPromotePlan: vi.fn(), })) @@ -31,7 +30,6 @@ vi.mock('@/ee/workspace-forking/lib/lineage/lineage', () => workspaceForkingLine vi.mock('@/ee/workspace-forking/lib/copy/deploy-bridge', () => ({ loadSourceDeployedStates: mocks.loadSourceDeployedStates, loadTargetDraftState: mocks.loadTargetDraftState, - readDeployedState: mocks.readDeployedState, })) vi.mock('@/ee/workspace-forking/lib/mapping/block-map-store', () => ({ loadForkBlockMap: mocks.loadForkBlockMap, @@ -113,6 +111,18 @@ describe('fork workflow-diff route', () => { expect(mocks.loadSourceDeployedStates).not.toHaveBeenCalled() }) + it('rejects workspaces that are not a direct fork edge without reading state', async () => { + workspaceForkingLineageMockFns.mockResolveForkEdge.mockResolvedValue(null) + + const response = await GET( + request({ otherWorkspaceId: 'parent', direction: 'push', sourceWorkflowId: 'wf-src' }), + routeContext + ) + + expect(response.status).toBe(400) + expect(mocks.loadSourceDeployedStates).not.toHaveBeenCalled() + }) + it('maps a workflow outside the sync plan to 404', async () => { const response = await GET( request({ otherWorkspaceId: 'parent', direction: 'push', sourceWorkflowId: 'foreign' }), @@ -130,7 +140,7 @@ describe('fork workflow-diff route', () => { expect(response.status).toBe(200) await expect(response.json()).resolves.toEqual({ - targetWorkflowId: 'wf-tgt', + targetWorkflowId: null, before: null, after: emptyState, beforeLabel: 'Ask Biz (current)', diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/compare-versions-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/compare-versions-modal.tsx index 3c4269517d8..dbe40952c1d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/compare-versions-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/compare-versions-modal.tsx @@ -7,13 +7,13 @@ import { ChipModal, ChipModalBody, ChipModalHeader, - Skeleton, } from '@sim/emcn' +import { ArrowRight } from '@sim/emcn/icons' import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils' import { formatVersionLabel } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/format-version-label' import { useDraftWorkflowState } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-draft-workflow-state' import { - CHANGE_LIST_WIDTH_CLASS, + WorkflowDiffSkeleton, WorkflowDiffView, } from '@/app/workspace/[workspaceId]/w/components/workflow-diff' import { useDeploymentVersionState } from '@/hooks/queries/workflows' @@ -103,7 +103,7 @@ export function CompareVersionsModal({ srTitle='Compare versions' aria-describedby={descriptionId} size='full' - className='h-[92vh] [&>div]:h-full' + className='h-[84vh] [&>div]:h-full' > onOpenChange(false)}>
@@ -114,7 +114,7 @@ export function CompareVersionsModal({ onChange={(value) => setBase(valueToSide(value))} align='start' /> - → + ) : isLoading || !baseState || !targetState ? ( -
- - -
+ ) : ( )} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/general.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/general.tsx index 91be6f26e0c..a0fc2a418f3 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/general.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/general.tsx @@ -22,8 +22,8 @@ import { Preview, PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/compon import { useDeploymentVersionState, useRevertToVersion } from '@/hooks/queries/workflows' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import type { WorkflowState } from '@/stores/workflows/workflow/types' -import { resolveComparePair } from './compare-pair' -import { type CompareSide, CompareVersionsModal, Versions } from './components' +import { type ComparePair, resolveComparePair } from './compare-pair' +import { CompareVersionsModal, Versions } from './components' import { formatVersionLabel } from './format-version-label' const logger = createLogger('GeneralDeploy') @@ -65,21 +65,9 @@ export function GeneralDeploy({ onLoadDeploymentBlocked, }: GeneralDeployProps) { const expandedPreviewDescriptionId = useId() - const [comparePair, setComparePair] = useState<{ base: CompareSide; target: CompareSide } | null>( - null - ) + const [comparePair, setComparePair] = useState(null) const [selectedVersion, setSelectedVersion] = useState(null) const [showActiveDespiteSelection, setShowActiveDespiteSelection] = useState(false) - const activeVersion = versions.find((v) => v.isActive)?.version ?? null - - /** - * Opens a comparison with the older version on the left. Comparing the live - * version (or any version when nothing is live) shows it against the draft, - * which is what a redeploy would ship. - */ - const handleCompareVersion = (version: number) => { - setComparePair(resolveComparePair(version, activeVersion)) - } const previewMode: PreviewMode = selectedVersion !== null && !showActiveDespiteSelection ? 'selected' : 'active' const [showLoadDialog, setShowLoadDialog] = useState(false) @@ -93,6 +81,12 @@ export function GeneralDeploy({ workflowId: string version: number } | null>(null) + const activeVersion = versions.find((v) => v.isActive)?.version ?? null + + /** See resolveComparePair for which two sides a version opens against. */ + const handleCompareVersion = (version: number) => { + setComparePair(resolveComparePair(version, activeVersion)) + } const selectedVersionInfo = versions.find((v) => v.version === selectedVersion) const versionToPromoteInfo = versions.find((v) => v.version === versionToPromote?.version) @@ -225,7 +219,7 @@ export function GeneralDeploy({ diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-draft-workflow-state.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-draft-workflow-state.ts index d71febb022d..32853affe17 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-draft-workflow-state.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-draft-workflow-state.ts @@ -12,7 +12,7 @@ import type { WorkflowState } from '@/stores/workflows/workflow/types' * cleanly against any of them. * * The merge walks every block on every store change, so a consumer that has - * nothing to compare against yet passes `enabled: false` and gets null + * nothing to compare against yet passes `false` as `enabled` and gets null * without paying for it. */ export function useDraftWorkflowState( diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx index b05fb50fdf2..aaec58a460c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx @@ -738,13 +738,6 @@ function WorkflowPreviewBlockInner({ data }: NodeProps ) } -/** - * Custom comparison function for React.memo optimization. - * Uses fast-path primitive comparison before shallow comparing subBlockValues. - * @param prevProps - Previous render props - * @param nextProps - Next render props - * @returns True if render should be skipped (props are equal) - */ /** Same changed-field list, by identity first so the common unchanged case costs nothing. */ function sameFields(prev: string[] | undefined, next: string[] | undefined): boolean { if (prev === next) return true @@ -752,6 +745,13 @@ function sameFields(prev: string[] | undefined, next: string[] | undefined): boo return prev.every((field, index) => field === next[index]) } +/** + * Custom comparison function for React.memo optimization. + * Uses fast-path primitive comparison before shallow comparing subBlockValues. + * @param prevProps - Previous render props + * @param nextProps - Next render props + * @returns True if render should be skipped (props are equal) + */ function shouldSkipPreviewBlockRender( prevProps: NodeProps, nextProps: NodeProps diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/subflow/subflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/subflow/subflow.tsx index 5e9dcc5e153..8c6b009d9e1 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/subflow/subflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/subflow/subflow.tsx @@ -1,6 +1,7 @@ 'use client' import { memo } from 'react' +import { cn } from '@sim/emcn' import { SubflowNodeView } from '@sim/workflow-renderer' import type { Node, NodeProps } from '@xyflow/react' import type { BlockDiffStatus } from '@/lib/workflows/comparison' @@ -51,12 +52,12 @@ function WorkflowPreviewSubflowInner({ data, id }: NodeProps undefined} /> ) - if (data.diffStatus !== 'removed') return view - /* A removed container fades like a removed card; its children ghost themselves. */ + if (!data.diffStatus) return view + /* Same label as a card; a removed container fades like a removed card, its children ghost themselves. */ return ( -
- - {view} +
+ +
{view}
) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/binding-change-row.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/binding-change-row.tsx index 8c9dfd3d1e4..9abe93b9995 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/binding-change-row.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/binding-change-row.tsx @@ -1,5 +1,6 @@ 'use client' +import { OverflowText } from '@sim/emcn' import { ArrowRight } from '@sim/emcn/icons' import { resolveFieldLabel } from '@/lib/workflows/comparison/resolve-values' import { @@ -28,7 +29,7 @@ export function BindingChangeRow({ blockType, field, oldValue, newValue }: Bindi return (
- {label} + {kind === 'secret' ? ( Differs between workspaces ) : ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/change-list.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/change-list.tsx index 7446ada625d..cb7db9dd7e1 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/change-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/change-list.tsx @@ -1,15 +1,28 @@ 'use client' -import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { Badge, cn } from '@sim/emcn' +import { + type Dispatch, + memo, + type SetStateAction, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react' +import { Badge, cn, OverflowText } from '@sim/emcn' import { ChevronDown } from '@sim/emcn/icons' import { humanizeBlockName } from '@sim/workflow-renderer' -import type { WorkflowDiffSummary } from '@/lib/workflows/comparison' +import type { BlockDiffStatus, WorkflowDiffSummary } from '@/lib/workflows/comparison' import { BindingChangeRow } from '@/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/binding-change-row' +import { + DIFF_SIGN, + DIFF_SIGN_CLASS, + type DiffSignKind, +} from '@/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/diff-signs' import { FieldChangeRow } from '@/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/field-change-row' import { type BlockChangeEntry, - type BlockChangeStatus, listBlockChanges, listOneSidedFields, splitEnvironmentBindings, @@ -17,33 +30,25 @@ import { import { BlockTile } from '@/blocks/block-tile' import type { BlockState } from '@/stores/workflows/workflow/types' -const STATUS_BADGE_VARIANT: Record = { +const STATUS_BADGE_VARIANT: Record = { added: 'green', modified: 'amber', removed: 'red', } -const STATUS_LABEL: Record = { +const STATUS_LABEL: Record = { added: 'Added', modified: 'Modified', removed: 'Removed', } -const SIGN_CLASS: Record = { - added: 'text-[var(--brand-accent)]', - removed: 'text-[var(--text-error)]', - modified: 'text-[var(--warning)]', -} - -const SIGN: Record = { added: '+', removed: '−', modified: '~' } - interface ChangeListProps { summary: WorkflowDiffSummary /** The two sides, so cards can show an added or removed block's fields and detect moves */ baseBlocks: Record targetBlocks: Record selectedBlockId: string | null - onSelectBlock: (blockId: string | null) => void + onSelectBlock: Dispatch> /** * The two sides live in different workspaces, so credentials, picked * resources and trigger paths differ by design; group them apart, muted. @@ -51,6 +56,13 @@ interface ChangeListProps { environmentBindings?: boolean } +/** Whether the selected block is this entry or one nested under it. */ +function containsSelection(entry: BlockChangeEntry, selectedBlockId: string | null): boolean { + if (!selectedBlockId) return false + if (entry.id === selectedBlockId) return true + return entry.children.some((child) => containsSelection(child, selectedBlockId)) +} + /** * The "what changed" pane: one card per touched block with its field diffs, * then the connections that were drawn or cut, then variables. Selecting a @@ -76,9 +88,10 @@ export function ChangeList({ if (node) cardRefs.current?.set(id, node) else cardRefs.current?.delete(id) }, []) + /* Stable across selections, so only the cards whose selection changed re-render. */ const toggleSelected = useCallback( - (id: string) => onSelectBlock(selectedBlockId === id ? null : id), - [onSelectBlock, selectedBlockId] + (id: string) => onSelectBlock((current) => (current === id ? null : id)), + [onSelectBlock] ) useEffect(() => { @@ -109,7 +122,8 @@ export function ChangeList({ key={entry.id} entry={entry} blocks={blocks} - selectedBlockId={selectedBlockId} + /* Only a card holding the selection needs the id; every other card sees null. */ + selectedBlockId={containsSelection(entry, selectedBlockId) ? selectedBlockId : null} onToggleSelected={toggleSelected} registerCard={registerCard} environmentBindings={environmentBindings} @@ -156,7 +170,7 @@ export function ChangeList({ ))} {summary.variableChanges.modifiedNames.map((name) => ( - + ))} {summary.variableChanges.removedNames.map((name) => ( @@ -189,6 +203,7 @@ function Section({ title, count, children }: SectionProps) { interface BlockCardProps { entry: BlockChangeEntry blocks: Record + /** The selected block id when it is this card or one nested inside it, else null */ selectedBlockId: string | null onToggleSelected: (id: string) => void registerCard: (id: string, node: HTMLDivElement | null) => void @@ -216,6 +231,10 @@ const BlockCard = memo(function BlockCard({ const [collapsed, setCollapsed] = useState(false) const selected = selectedBlockId === entry.id const block = blocks[entry.id] + const setCardRef = useCallback( + (node: HTMLDivElement | null) => registerCard(entry.id, node), + [registerCard, entry.id] + ) const { logic, bindings } = useMemo(() => { /* A block on one side only is every field arriving or leaving; same rows, one side empty. */ const fields = @@ -243,9 +262,9 @@ const BlockCard = memo(function BlockCard({ return (
registerCard(entry.id, node)} + ref={setCardRef} className={cn( - 'flex flex-col rounded-md border transition-colors', + 'flex flex-col overflow-hidden rounded-md border transition-colors', nested ? 'bg-[var(--surface-1)]' : 'bg-[var(--surface-2)]', selected ? 'border-[var(--text-secondary)]' : 'border-[var(--border)]' )} @@ -253,6 +272,7 @@ const BlockCard = memo(function BlockCard({
{ @@ -263,14 +283,15 @@ const BlockCard = memo(function BlockCard({ } }} className={cn( - 'flex cursor-pointer items-center gap-2 rounded-md hover-hover:bg-[var(--surface-3)] focus-visible:outline-none focus-visible:ring-[1.5px] focus-visible:ring-[var(--text-secondary)]', + 'flex cursor-pointer items-center gap-2 transition-colors hover-hover:bg-[var(--surface-3)] focus-visible:outline-none focus-visible:ring-[1.5px] focus-visible:ring-[var(--text-secondary)] focus-visible:ring-inset', nested ? 'p-2' : 'p-3' )} > - - {humanizeBlockName(entry.name)} - + {bindingsOnly ? 'Bindings only' : STATUS_LABEL[entry.status]} @@ -341,7 +362,9 @@ const BlockCard = memo(function BlockCard({ key={child.id} entry={child} blocks={blocks} - selectedBlockId={selectedBlockId} + selectedBlockId={ + containsSelection(child, selectedBlockId) ? selectedBlockId : null + } onToggleSelected={onToggleSelected} registerCard={registerCard} environmentBindings={environmentBindings} @@ -371,7 +394,7 @@ const BlockCard = memo(function BlockCard({ }) interface NamedRowProps { - kind: BlockChangeStatus + kind: DiffSignKind name: string } @@ -379,17 +402,15 @@ interface NamedRowProps { function NamedRow({ kind, name }: NamedRowProps) { return (
- - {SIGN[kind]} + + {DIFF_SIGN[kind]} - - {name} - + } + />
) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/diff-signs.ts b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/diff-signs.ts new file mode 100644 index 00000000000..7d3981955c6 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/diff-signs.ts @@ -0,0 +1,10 @@ +/** The three ways a row can differ, shared by every signed row in the change list. */ +export type DiffSignKind = 'added' | 'removed' | 'changed' + +export const DIFF_SIGN: Record = { added: '+', removed: '−', changed: '~' } + +export const DIFF_SIGN_CLASS: Record = { + added: 'text-[var(--brand-accent)]', + removed: 'text-[var(--text-error)]', + changed: 'text-[var(--warning)]', +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/field-change-row.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/field-change-row.tsx index 2fc4c2756cb..b9836cc0297 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/field-change-row.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/field-change-row.tsx @@ -2,6 +2,7 @@ import { cn } from '@sim/emcn' import { ArrowRight } from '@sim/emcn/icons' +import { isContainerType } from '@/lib/workflows/autolayout' import { resolveFieldLabel } from '@/lib/workflows/comparison/resolve-values' import { KeyedListDiff } from '@/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/keyed-list-diff' import { @@ -11,10 +12,9 @@ import { import { classifyChange, containerFieldLabel, - engineFieldLabel, + ENGINE_FIELD_LABELS, formatScalar, isBlankValue, - isContainerType, isSentenceLike, toDiffText, toMessageList, @@ -38,9 +38,10 @@ interface FieldChangeRowProps { export function FieldChangeRow({ blockType, field, oldValue, newValue }: FieldChangeRowProps) { const kind = classifyChange(blockType, field, oldValue, newValue) const oneSided = isBlankValue(oldValue) !== isBlankValue(newValue) + const wordDiff = kind === 'scalar' && isSentenceLike(oldValue) && isSentenceLike(newValue) const resolvedLabel = isContainerType(blockType) ? containerFieldLabel(field) - : (engineFieldLabel(field) ?? resolveFieldLabel(blockType, field)) + : (ENGINE_FIELD_LABELS[field] ?? resolveFieldLabel(blockType, field)) /* A field its definition never titled comes back as the raw id; humanize it. */ const label = resolvedLabel === field ? formatParameterLabel(field) : resolvedLabel @@ -64,26 +65,23 @@ export function FieldChangeRow({ blockType, field, oldValue, newValue }: FieldCh newValue={newValue} /> )} - {(kind === 'scalar' || kind === 'toggle') && oneSided && ( - - )} - {kind === 'scalar' && !oneSided && isSentenceLike(oldValue) && isSentenceLike(newValue) && ( - - )} - {!oneSided && - (kind === 'toggle' || - (kind === 'scalar' && !(isSentenceLike(oldValue) && isSentenceLike(newValue)))) && ( + {(kind === 'scalar' || kind === 'toggle') && + (oneSided ? ( + + ) : wordDiff ? ( + + ) : ( - )} + ))}
) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/keyed-list-diff.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/keyed-list-diff.tsx index 937e38bbb02..6a12d1b04bb 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/keyed-list-diff.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/keyed-list-diff.tsx @@ -1,22 +1,18 @@ 'use client' import { cn } from '@sim/emcn' +import { + DIFF_SIGN, + DIFF_SIGN_CLASS, +} from '@/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/diff-signs' import { TextDiff } from '@/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/text-diff' import { describeListItems, isPositionalListField, - type ListRowKind, pairListItems, toItemList, } from '@/app/workspace/[workspaceId]/w/components/workflow-diff/utils' -const SIGN: Record = { added: '+', removed: '−', changed: '~' } -const SIGN_CLASS: Record = { - added: 'text-[var(--brand-accent)]', - removed: 'text-[var(--text-error)]', - changed: 'text-[var(--warning)]', -} - interface KeyedListDiffProps { blockType: string field: string @@ -44,10 +40,10 @@ export function KeyedListDiff({ blockType, field, oldValue, newValue }: KeyedLis - {SIGN[row.kind]} + {DIFF_SIGN[row.kind]}
{ { kind: 'added', text: 'reply warmly in two sentences' }, ]) - /* Adjacent changed words come back as one run. */ + /* Whitespace is its own token, so the unchanged space between them stays unmarked. */ expect(removed.parts?.filter((part) => part.changed).map((part) => part.value)).toEqual([ - 'one sentence', + 'one', + 'sentence', ]) expect(added.parts?.filter((part) => part.changed).map((part) => part.value)).toEqual([ - 'two sentences', + 'two', + 'sentences', ]) }) + it('keeps an indentation-only edit visible', () => { + const [removed, added] = markWordChanges([ + { kind: 'removed', text: ' return result' }, + { kind: 'added', text: ' return result' }, + ]) + + expect(removed.parts?.map((part) => part.value).join('')).toBe(' return result') + expect(added.parts?.map((part) => part.value).join('')).toBe(' return result') + expect(added.parts?.some((part) => part.changed)).toBe(true) + }) + it('leaves a rewrite and an oversized pair as plain lines', () => { const rewrite = markWordChanges([ { kind: 'removed', text: 'alpha beta gamma' }, @@ -80,7 +94,7 @@ describe('markWordChanges', () => { const huge = markWordChanges([ { kind: 'removed', text: 'a '.repeat(3000) }, - { kind: 'added', text: 'a '.repeat(2999) + 'b' }, + { kind: 'added', text: `${'a '.repeat(2999)}b` }, ]) expect(huge.every((line) => line.parts === undefined)).toBe(true) }) @@ -123,4 +137,19 @@ describe('buildDiffRows', () => { { type: 'line', line: { kind: 'added', text: 'return 1' } }, ]) }) + + it('never caps two identical long bodies as if they were new', () => { + const body = Array.from({ length: ONE_SIDED_VISIBLE_LINES + 5 }, (_, i) => `l${i}`).join('\n') + const rows = buildDiffRows(body, body) + expect(rows.some((row) => row.type === 'tail')).toBe(false) + expect(rows.every((row) => row.type === 'line' || row.type === 'fold')).toBe(true) + expect(buildDiffRows('', '')).toEqual([]) + }) + + it('summarizes bodies too long to diff inline', () => { + const lines = Array.from({ length: MAX_DIFF_LINES }, (_, i) => `a${i}`).join('\n') + expect(buildDiffRows(lines, `${lines}\nb`)).toEqual([ + { type: 'oversized', oldLines: MAX_DIFF_LINES, newLines: MAX_DIFF_LINES + 1 }, + ]) + }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/text-diff-lines.ts b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/text-diff-lines.ts index 9e856228d39..f9f6a61f51a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/text-diff-lines.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/text-diff-lines.ts @@ -1,4 +1,4 @@ -import { diffLines, diffWords } from 'diff' +import { diffLines, diffWordsWithSpace } from 'diff' /** Lines of context kept around each change before the rest folds away. */ export const CONTEXT_LINES = 2 @@ -18,6 +18,12 @@ const MAX_WORD_HIGHLIGHT_RATIO = 0.6 * stalling the pane on a pathological prompt. */ const MAX_WORD_DIFF_CHARS = 4000 +/** + * Line diffing is quadratic in the worst case too; two bodies with more lines + * than this between them are summarized instead of diffed so opening a + * comparison never hangs the tab. + */ +export const MAX_DIFF_LINES = 2000 export interface DiffLine { kind: 'added' | 'removed' | 'context' @@ -30,12 +36,14 @@ export type DiffRow = | { type: 'line'; line: DiffLine } | { type: 'fold'; lines: DiffLine[] } | { type: 'tail'; lines: DiffLine[] } + | { type: 'oversized'; oldLines: number; newLines: number } /** * Pairs each run of removed lines with the run of added lines that follows it, * line by line, and marks the words that differ within each pair. A one-word * edit inside a long sentence then reads as that word, not as the whole line - * leaving and coming back. + * leaving and coming back. Whitespace counts as a word so an indentation-only + * edit still shows. */ export function markWordChanges(lines: DiffLine[]): DiffLine[] { const out = [...lines] @@ -55,7 +63,7 @@ export function markWordChanges(lines: DiffLine[]): DiffLine[] { const added = out[removedEnd + offset] const total = removed.text.length + added.text.length if (total === 0 || total > MAX_WORD_DIFF_CHARS) continue - const words = diffWords(removed.text, added.text) + const words = diffWordsWithSpace(removed.text, added.text) const changedChars = words .filter((part) => part.added || part.removed) .reduce((sum, part) => sum + part.value.length, 0) @@ -122,9 +130,12 @@ export function foldRows(lines: DiffLine[]): DiffRow[] { return rows } -/** Caps a body with no unchanged lines at a visible head plus one expander for the rest. */ +/** Caps a body that is all added or all removed at a visible head plus one expander for the rest. */ export function capOneSided(lines: DiffLine[]): DiffRow[] { - const oneSided = lines.length > 0 && lines.every((line) => line.kind === lines[0].kind) + const oneSided = + lines.length > 0 && + lines[0].kind !== 'context' && + lines.every((line) => line.kind === lines[0].kind) if (!oneSided || lines.length <= ONE_SIDED_VISIBLE_LINES) return foldRows(lines) return [ ...lines.slice(0, ONE_SIDED_VISIBLE_LINES).map((line): DiffRow => ({ type: 'line', line })), @@ -132,7 +143,14 @@ export function capOneSided(lines: DiffLine[]): DiffRow[] { ] } -/** The rows a text diff renders for two bodies: line diff, word marks, folds and the one-sided cap. */ +/** + * The rows a text diff renders for two bodies: line diff, word marks, folds and + * the one-sided cap, or a single summary row when the bodies are too long to + * diff inline. + */ export function buildDiffRows(oldText: string, newText: string): DiffRow[] { + const oldLines = splitLines(oldText).length + const newLines = splitLines(newText).length + if (oldLines + newLines > MAX_DIFF_LINES) return [{ type: 'oversized', oldLines, newLines }] return capOneSided(markWordChanges(toLines(oldText, newText))) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/text-diff.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/text-diff.tsx index 0270d0aab6c..069101ea127 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/text-diff.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/text-diff.tsx @@ -30,7 +30,7 @@ const GUTTER_SIGN: Record = { } const FOLD_BUTTON_CLASS = - 'flex w-full items-center gap-2 bg-[var(--surface-3)] px-2 py-0.5 text-left text-[var(--text-tertiary)] text-caption hover-hover:bg-[var(--surface-4)] hover-hover:text-[var(--text-secondary)] focus-visible:bg-[var(--surface-4)] focus-visible:outline-none' + 'flex w-full items-center gap-2 bg-[var(--surface-3)] px-2 py-0.5 text-left text-[var(--text-tertiary)] text-caption transition-colors hover-hover:bg-[var(--surface-4)] hover-hover:text-[var(--text-secondary)] focus-visible:bg-[var(--surface-4)] focus-visible:outline-none' interface FoldRowProps { count: number @@ -42,7 +42,9 @@ interface FoldRowProps { function FoldRow({ count, kind, onExpand }: FoldRowProps) { return ( )} {rows.map((row, index) => { + if (row.type === 'oversized') { + return ( +
+ Too long to compare line by line: {row.oldLines} lines before, {row.newLines} lines + after. +
+ ) + } if (row.type === 'fold' || row.type === 'tail') { if (!expandedAll && !expanded.has(index)) { return ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/index.ts index f66d413b611..02eb9a10bc5 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/index.ts @@ -1 +1 @@ -export { CHANGE_LIST_WIDTH_CLASS, WorkflowDiffView } from './workflow-diff-view' +export { WorkflowDiffSkeleton, WorkflowDiffView } from './workflow-diff-view' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/utils.test.ts b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/utils.test.ts index f86d833909f..6671efc009c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/utils.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/utils.test.ts @@ -9,12 +9,16 @@ import type { BlockState } from '@/stores/workflows/workflow/types' import { classifyChange, describeListItems, + formatScalar, listBlockChanges, listOneSidedFields, - maskSecretParams, + maskSecretsDeep, omitPresentationChanges, pairListItems, splitEnvironmentBindings, + toDiffText, + toItemList, + toMessageList, } from './utils' /** The global registry mock returns no sub-blocks; tests declare the ones they need per block type. */ @@ -97,6 +101,21 @@ describe('classifyChange', () => { expect(classifyChange('agent', 'unknown', 1, 2)).toBe('scalar') }) + it('hides a secret-looking field by name when the block definition is unknown', () => { + declareSubBlocks({}) + + expect(classifyChange('vanished', 'apiKey', 'a', 'b')).toBe('secret') + expect(classifyChange('vanished', 'maxTokens', 1, 2)).toBe('scalar') + }) + + it('reads a definition re-registered under the same type name', () => { + declareSubBlocks({ agent: [{ id: 'tools', type: 'tool-input' }] }) + expect(classifyChange('agent', 'tools', null, null)).toBe('list') + + declareSubBlocks({ agent: [{ id: 'tools', type: 'long-input' }] }) + expect(classifyChange('agent', 'tools', 'a', 'b')).toBe('text') + }) + it('treats a checkbox group that persists a record of flags as json, not as a list', () => { declareSubBlocks({ jina: [{ id: 'options', type: 'checkbox-list' }] }) @@ -160,7 +179,7 @@ describe('describeListItems', () => { expect(items[0]).toEqual({ key: 'search_docs', label: 'search_docs', - text: JSON.stringify({ params: { query: 'x', apiToken: '•••' } }, null, 2), + text: JSON.stringify({ server: 's1', params: { query: 'x', apiToken: '•••' } }, null, 2), }) /* A custom tool's schema is its implementation, so it stays in the comparable body. */ expect(items[1]).toEqual({ @@ -219,10 +238,10 @@ describe('pairListItems', () => { }) }) -describe('maskSecretParams', () => { +describe('maskSecretsDeep', () => { it('masks keys that end in a secret noun and leaves ids, counts and limits readable', () => { expect( - maskSecretParams({ + maskSecretsDeep({ apiKey: 'k', api_key: 'k', accessToken: 'k', @@ -255,6 +274,63 @@ describe('maskSecretParams', () => { count: 2, }) }) + + it('reaches nested objects, arrays and key/value table rows', () => { + expect( + maskSecretsDeep({ + headers: { Authorization: 'Bearer x', Accept: 'json' }, + rows: [ + { cells: { Key: 'X-Api-Key', Value: 'k' } }, + { cells: { Key: 'Accept', Value: 'json' } }, + ], + nested: [{ auth: { token: 't' } }], + }) + ).toEqual({ + headers: { Authorization: '•••', Accept: 'json' }, + rows: [ + { cells: { Key: 'X-Api-Key', Value: '•••' } }, + { cells: { Key: 'Accept', Value: 'json' } }, + ], + nested: [{ auth: '•••' }], + }) + expect(toDiffText({ password: 'p', name: 'n' })).toBe( + JSON.stringify({ password: '•••', name: 'n' }, null, 2) + ) + }) +}) + +describe('value readers and labels', () => { + it('reads lists and messages from their stored forms and rejects the rest', () => { + expect(toItemList([1])).toEqual([1]) + expect(toItemList('[1, 2]')).toEqual([1, 2]) + expect(toItemList('[not json')).toBeNull() + expect(toItemList('{"a":1}')).toBeNull() + expect(toMessageList([{ role: 'user', content: 'hi' }])).toEqual([ + { role: 'user', content: 'hi' }, + ]) + expect(toMessageList('[{"role":"system","content":"s"}]')).toEqual([ + { role: 'system', content: 's' }, + ]) + expect(toMessageList('{oops')).toEqual([]) + expect(toMessageList([{ role: 'user' }])).toEqual([]) + expect(toDiffText(null)).toBe('') + expect(toDiffText('plain')).toBe('plain') + }) + + it('resolves dropdown ids to their labels from static and function options', () => { + declareSubBlocks({ + agent: [ + { id: 'model', type: 'dropdown', options: [{ id: 'gpt', label: 'GPT' }] }, + { id: 'mode', type: 'dropdown', options: () => [{ id: 'fast', label: 'Fast' }] }, + { id: 'temp', type: 'slider' }, + ], + }) + + expect(formatScalar('agent', 'model', 'gpt')).toBe('GPT') + expect(formatScalar('agent', 'mode', 'fast')).toBe('Fast') + expect(formatScalar('agent', 'model', 'unknown')).toBe('unknown') + expect(formatScalar('agent', 'temp', 0.5)).toBe('0.5') + }) }) describe('splitEnvironmentBindings', () => { @@ -441,6 +517,33 @@ describe('listBlockChanges', () => { ['oldLoop', 'removed'], ]) expect(entries[0].name).toBe('function') + /* Field rows follow the definition order; fields it never declared trail. */ + declareSubBlocks({ + function: [ + { id: 'language', type: 'dropdown' }, + { id: 'code', type: 'code' }, + ], + }) + const ordered = listBlockChanges( + summary({ + modifiedBlocks: [ + { + id: 'keep', + type: 'function', + name: 'keep', + changes: [ + { field: 'zzz', oldValue: 1, newValue: 2 }, + { field: 'code', oldValue: 'a', newValue: 'b' }, + { field: 'language', oldValue: 'js', newValue: 'py' }, + ], + }, + ], + hasChanges: true, + }), + baseBlocks, + targetBlocks + ) + expect(ordered[0].changes.map((change) => change.field)).toEqual(['language', 'code', 'zzz']) expect(entries[1].children.map((child) => child.id).sort()).toEqual(['mover', 'newChild']) expect(entries[1].membership).toBeUndefined() expect(entries[2].children.map((child) => child.id)).toEqual(['oldChild']) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/utils.ts b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/utils.ts index 8147ff3cb51..b14735405cf 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/utils.ts @@ -1,20 +1,17 @@ import { isContainerType } from '@/lib/workflows/autolayout' import { + type BlockDiffStatus, type ContainerConfigField, - LOOP_CONFIG_FIELDS, - PARALLEL_CONFIG_FIELDS, summaryHasChanges, type WorkflowDiffSummary, } from '@/lib/workflows/comparison' import { formatValueForDisplay } from '@/lib/workflows/comparison/resolve-values' import { getBlock } from '@/blocks/registry' -import type { SubBlockConfig } from '@/blocks/types' +import type { BlockConfig, SubBlockConfig } from '@/blocks/types' import type { BlockState } from '@/stores/workflows/workflow/types' -export { isContainerType } - /** How a changed value should be rendered in the change list. */ -export type ValueKind = 'text' | 'scalar' | 'json' | 'secret' | 'toggle' | 'messages' | 'list' +type ValueKind = 'text' | 'scalar' | 'json' | 'secret' | 'toggle' | 'messages' | 'list' /** Sub-block types whose values read as prose or code, so they get a line diff. */ const TEXT_SUB_BLOCK_TYPES = new Set([ @@ -216,11 +213,12 @@ export function describeListItems( ['name'] )) : undefined - const { serverId: _serverId, toolName: _toolName, ...rawParams } = paramRecord - const visibleParams = maskSecretParams(rawParams) - /* What the tool is allowed to do and how it runs matters as much as its params. */ + const { serverId, toolName: _toolName, ...rawParams } = paramRecord + const visibleParams = maskSecretsDeep(rawParams) as Record + /* What the tool is allowed to do, where it runs and how it runs matter as much as its params. */ const body = filterBlank({ - ...pick(item, TOOL_EXECUTION_FIELDS), + ...(maskSecretsDeep(pick(item, TOOL_EXECUTION_FIELDS)) as Record), + server: serverId, params: Object.keys(visibleParams).length ? visibleParams : undefined, }) return { @@ -253,7 +251,7 @@ export function describeListItems( return { key: id ?? label, label, - text: Object.keys(rest).length ? JSON.stringify(rest, null, 2) : '', + text: Object.keys(rest).length ? JSON.stringify(maskSecretsDeep(rest), null, 2) : '', } }) } @@ -320,17 +318,10 @@ const CONTAINER_FIELD_LABELS: Record = { distribution: 'Collection', } -const CONTAINER_CONFIG_FIELDS: ReadonlySet = new Set([ - ...LOOP_CONFIG_FIELDS, - ...PARALLEL_CONFIG_FIELDS, -]) - -function isContainerConfigField(field: string): field is ContainerConfigField { - return CONTAINER_CONFIG_FIELDS.has(field) -} - export function containerFieldLabel(field: string): string { - return isContainerConfigField(field) ? CONTAINER_FIELD_LABELS[field] : field + return Object.hasOwn(CONTAINER_FIELD_LABELS, field) + ? CONTAINER_FIELD_LABELS[field as ContainerConfigField] + : field } /** @@ -338,7 +329,7 @@ export function containerFieldLabel(field: string): string { * definition: block-level settings and the editor's basic/advanced mode per * canonical parameter, which decides which of two stored values executes. */ -const ENGINE_FIELD_LABELS: Record = { +export const ENGINE_FIELD_LABELS: Record = { 'data.canonicalModes': 'Field modes', name: 'Name', enabled: 'Enabled', @@ -346,12 +337,8 @@ const ENGINE_FIELD_LABELS: Record = { advancedMode: 'Advanced mode', } -export function engineFieldLabel(field: string): string | undefined { - return ENGINE_FIELD_LABELS[field] -} - /** A chat message as an agent block stores it. */ -export interface DiffMessage { +interface DiffMessage { role: string content: string } @@ -384,17 +371,23 @@ export function toMessageList(value: unknown): DiffMessage[] { return [] } -/** Block definitions are static, so each type's sub-blocks are indexed once. */ -const subBlockIndex = new Map>() +/** + * Each block definition's sub-blocks are indexed once, keyed by the definition + * object itself so a re-registered definition (custom blocks, tests) is never + * served from a stale index. + */ +const subBlockIndex = new WeakMap>() function findSubBlockConfig(blockType: string, field: string): SubBlockConfig | undefined { - let index = subBlockIndex.get(blockType) + const config = getBlock(blockType) + if (!config) return undefined + let index = subBlockIndex.get(config) if (!index) { index = new Map() - for (const subBlock of getBlock(blockType)?.subBlocks ?? []) { + for (const subBlock of config.subBlocks ?? []) { if (!index.has(subBlock.id)) index.set(subBlock.id, subBlock) } - subBlockIndex.set(blockType, index) + subBlockIndex.set(config, index) } return index.get(field) } @@ -433,7 +426,8 @@ export function classifyChange( newValue: unknown ): ValueKind { const config = findSubBlockConfig(blockType, field) - if (config?.password) return 'secret' + /* A definition this viewer cannot resolve (a source-side custom block, a retired block) still hides secrets by name. */ + if (config?.password || (!config && isSecretKey(field))) return 'secret' if (config?.type === 'messages-input' || isMessageList(oldValue) || isMessageList(newValue)) { return 'messages' } @@ -467,15 +461,18 @@ export function formatScalar(blockType: string, field: string, value: unknown): return formatValueForDisplay(value) } -/** A string for the line diff; objects are pretty-printed so structure diffs line by line. */ +/** + * A string for the line diff; objects are pretty-printed, with secret-looking + * leaves masked, so structure diffs line by line. + */ export function toDiffText(value: unknown): string { if (value === null || value === undefined) return '' if (typeof value === 'string') return value - return JSON.stringify(value, null, 2) + return JSON.stringify(maskSecretsDeep(value), null, 2) } /** One side of a block that only exists in one version: what it has, as a change from nothing. */ -export interface OneSidedField { +interface OneSidedField { field: string oldValue: unknown newValue: unknown @@ -484,17 +481,42 @@ export interface OneSidedField { /** * Keys whose values are never rendered, wherever they appear in a stored value: * anchored to the end of the key so `maxTokens` and `tokenLimit` stay visible - * while `accessToken`, `client_secret` and `apiKey` are masked. + * while `accessToken`, `client_secret`, `apiKey` and an `Authorization` header + * are masked. */ const SECRET_KEY_PATTERN = - /(token|secret|password|passphrase|credential|api[_-]?key|private[_-]?key)$/i + /^(auth|authorization|bearer|cookie|pwd)$|(token|secret|password|passphrase|credential|api[_-]?key|private[_-]?key|authorization)$/i const MASKED_VALUE = '•••' -/** Returns a copy of a params object with secret-looking values masked. */ -export function maskSecretParams(params: Record): Record { +function isSecretKey(key: string): boolean { + return SECRET_KEY_PATTERN.test(key.trim()) +} + +/** + * Returns a copy of a stored value with every secret-looking leaf masked, at any + * depth: object keys, and the `Value` of a key/value table row whose `Key` + * names a secret (an API block's headers). + */ +export function maskSecretsDeep(value: unknown): unknown { + if (Array.isArray(value)) return value.map(maskSecretsDeep) + if (value === null || typeof value !== 'object') return value + const record = value as Record + const cells = record.cells + if (cells && typeof cells === 'object' && !Array.isArray(cells)) { + const row = cells as Record + if ( + typeof row.Key === 'string' && + isSecretKey(row.Key) && + row.Value !== '' && + row.Value != null + ) { + return { ...record, cells: { ...row, Value: MASKED_VALUE } } + } + } const out: Record = {} - for (const [key, value] of Object.entries(params)) { - out[key] = SECRET_KEY_PATTERN.test(key) && value !== '' && value != null ? MASKED_VALUE : value + for (const [key, entry] of Object.entries(record)) { + out[key] = + isSecretKey(key) && entry !== '' && entry != null ? MASKED_VALUE : maskSecretsDeep(entry) } return out } @@ -531,7 +553,7 @@ export function listOneSidedFields(block: BlockState, side: 'added' | 'removed') } for (const subBlock of declared) push(subBlock.id, block.subBlocks?.[subBlock.id]?.value) for (const [field, state] of Object.entries(block.subBlocks ?? {})) { - if (SECRET_KEY_PATTERN.test(field)) continue + if (isSecretKey(field)) continue push(field, state?.value) } return out @@ -564,8 +586,6 @@ export function omitPresentationChanges(summary: WorkflowDiffSummary): WorkflowD return next } -export type BlockChangeStatus = 'added' | 'modified' | 'removed' - /** One block listed under a container's "Blocks inside". */ export interface MembershipRow { name: string @@ -584,7 +604,7 @@ export interface BlockChangeEntry { id: string type: string name: string - status: BlockChangeStatus + status: BlockDiffStatus changes: WorkflowDiffSummary['modifiedBlocks'][number]['changes'] /** Set on a surviving block whose container changed */ moved?: BlockMove @@ -677,7 +697,11 @@ export function listBlockChanges( for (const container of summary.containerChanges) { if (added.has(container.id) || removed.has(container.id)) continue const entry = ensureModified(container.id) - entry.changes = [...entry.changes, ...container.changes] + const seen = new Set(entry.changes.map((change) => change.field)) + entry.changes = [ + ...entry.changes, + ...container.changes.filter((change) => !seen.has(change.field)), + ] if (container.nodesAdded.length || container.nodesRemoved.length) { const row = (id: string): MembershipRow => ({ name: blockName(blocks, id), diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/workflow-diff-view.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/workflow-diff-view.tsx index 16efa8799fe..83431dc5858 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/workflow-diff-view.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/workflow-diff-view.tsx @@ -1,15 +1,37 @@ 'use client' import { useCallback, useMemo, useState } from 'react' -import { cn } from '@sim/emcn' +import { + cn, + OverflowText, + Skeleton, + scrollFadeAttributes, + scrollFadeClass, + useScrollEdges, +} from '@sim/emcn' +import { ArrowRight } from '@sim/emcn/icons' import { buildWorkflowDiffOverlay, generateWorkflowDiffSummary } from '@/lib/workflows/comparison' import { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview' import { ChangeList } from '@/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list' import { omitPresentationChanges } from '@/app/workspace/[workspaceId]/w/components/workflow-diff/utils' import type { WorkflowState } from '@/stores/workflows/workflow/types' -/** The change list pane and the skeleton that stands in for it share one width. */ -export const CHANGE_LIST_WIDTH_CLASS = 'w-[440px] max-w-[45%]' +const CHANGE_LIST_WIDTH_CLASS = 'w-[440px] max-w-[45%]' + +/** The two empty panes a host shows while it loads the sides of a comparison. */ +export function WorkflowDiffSkeleton() { + return ( +
+ + +
+ ) +} interface WorkflowDiffViewProps { /** The older or source side of the comparison */ @@ -36,6 +58,8 @@ export function WorkflowDiffView({ environmentBindings = false, }: WorkflowDiffViewProps) { const [selectedBlockId, setSelectedBlockId] = useState(null) + const [listElement, setListElement] = useState(null) + const listEdges = useScrollEdges(listElement) const summary = useMemo( () => omitPresentationChanges(generateWorkflowDiffSummary(targetState, baseState)), @@ -51,14 +75,29 @@ export function WorkflowDiffView({ }, []) const handlePaneClick = useCallback(() => setSelectedBlockId(null), []) + /* A reconfigured container counts as modified even when no field of its own changed. */ + const modifiedCount = new Set([ + ...summary.modifiedBlocks.map((block) => block.id), + ...summary.containerChanges.map((container) => container.id), + ]).size + const variableCount = + summary.variableChanges.added + + summary.variableChanges.removed + + summary.variableChanges.modified const counts = [ { label: 'added', value: summary.addedBlocks.length, className: 'text-[var(--brand-accent)]' }, - { label: 'modified', value: summary.modifiedBlocks.length, className: 'text-[var(--warning)]' }, + { label: 'modified', value: modifiedCount, className: 'text-[var(--warning)]' }, { label: 'removed', value: summary.removedBlocks.length, className: 'text-[var(--text-error)]', }, + { + label: 'connections', + value: summary.edgeChanges.added + summary.edgeChanges.removed, + className: 'text-[var(--text-tertiary)]', + }, + { label: 'variables', value: variableCount, className: 'text-[var(--text-tertiary)]' }, ].filter((count) => count.value > 0) return ( @@ -66,25 +105,20 @@ export function WorkflowDiffView({
{baseLabel && targetLabel && (
- {baseLabel} - → - {targetLabel} + + +
)}
- {counts.length === 0 ? ( - No changes - ) : ( + {summary.hasChanges ? ( counts.map((count) => ( {count.value} {count.label} )) - )} - {(summary.edgeChanges.added > 0 || summary.edgeChanges.removed > 0) && ( - - {summary.edgeChanges.added + summary.edgeChanges.removed} connections - + ) : ( + No changes )}
@@ -105,10 +139,13 @@ export function WorkflowDiffView({ />
({ action: 'archive' as const, currentName: target.name, otherName: target.name, - targetWorkflowId: target.id, })), ] diff --git a/apps/sim/ee/workspace-forking/application/sync-workflow-diff.test.ts b/apps/sim/ee/workspace-forking/application/sync-workflow-diff.test.ts index cf96db93e7a..03d42109408 100644 --- a/apps/sim/ee/workspace-forking/application/sync-workflow-diff.test.ts +++ b/apps/sim/ee/workspace-forking/application/sync-workflow-diff.test.ts @@ -19,7 +19,6 @@ import type { WorkflowState } from '@/stores/workflows/workflow/types' const mocks = vi.hoisted(() => ({ loadSourceDeployedStates: vi.fn(), loadTargetDraftState: vi.fn(), - readDeployedState: vi.fn(), loadForkBlockMap: vi.fn(), computeForkPromotePlan: vi.fn(), })) @@ -31,7 +30,6 @@ vi.mock('@/ee/workspace-forking/lib/lineage/lineage', () => workspaceForkingLine vi.mock('@/ee/workspace-forking/lib/copy/deploy-bridge', () => ({ loadSourceDeployedStates: mocks.loadSourceDeployedStates, loadTargetDraftState: mocks.loadTargetDraftState, - readDeployedState: mocks.readDeployedState, })) vi.mock('@/ee/workspace-forking/lib/mapping/block-map-store', () => ({ loadForkBlockMap: mocks.loadForkBlockMap, @@ -124,7 +122,6 @@ describe('getWorkspaceSyncWorkflowDiff', () => { }) mocks.computeForkPromotePlan.mockResolvedValue({ items: [planItem()], archivedTargets: [] }) mocks.loadTargetDraftState.mockResolvedValue(state({ 'mapped-b1': {} })) - mocks.readDeployedState.mockResolvedValue(null) }) it('re-keys the source through the block map and derives ids for unmapped blocks', async () => { @@ -143,13 +140,29 @@ describe('getWorkspaceSyncWorkflowDiff', () => { afterLabel: 'Ask Biz (deployed)', }) expect(result.before).toEqual(state({ 'mapped-b1': {} })) + expect(mocks.loadTargetDraftState).toHaveBeenCalledWith('wf-tgt', 'parent') }) - it('re-keys the source variables to the target ids by name so they compare as themselves', async () => { + it('re-keys source variables and their assignments to the target ids by unique name', async () => { const source = state({ b1: {} }) - source.variables = { 'v-src': { id: 'v-src', name: 'region', type: 'string', value: 'eu' } } + source.variables = { + 'v-src': { id: 'v-src', name: 'region', type: 'string', value: 'eu' }, + 'v-only': { id: 'v-only', name: 'extra', type: 'string', value: 'x' }, + 'v-dup-1': { id: 'v-dup-1', name: 'dup', type: 'string', value: '1' }, + 'v-dup-2': { id: 'v-dup-2', name: 'dup', type: 'string', value: '2' }, + } + source.blocks.b1.subBlocks = { + variables: { + id: 'variables', + type: 'variables-input', + value: JSON.stringify([{ variableId: 'v-src', value: 'us' }]), + }, + } const target = state({ 'mapped-b1': {} }) - target.variables = { 'v-tgt': { id: 'v-tgt', name: 'region', type: 'string', value: 'us' } } + target.variables = { + 'v-tgt': { id: 'v-tgt', name: 'region', type: 'string', value: 'us' }, + 'v-tgt-dup': { id: 'v-tgt-dup', name: 'dup', type: 'string', value: '9' }, + } mocks.loadSourceDeployedStates.mockResolvedValue({ deployedWorkflows: [{ id: 'wf-src' }], sourceStates: new Map([['wf-src', source]]), @@ -160,7 +173,15 @@ describe('getWorkspaceSyncWorkflowDiff', () => { expect(result.after.variables).toEqual({ 'v-tgt': { id: 'v-tgt', name: 'region', type: 'string', value: 'eu' }, + 'v-only': { id: 'v-only', name: 'extra', type: 'string', value: 'x' }, + /* A name shared by two source variables cannot be paired, so both keep their ids. */ + 'v-dup-1': { id: 'v-dup-1', name: 'dup', type: 'string', value: '1' }, + 'v-dup-2': { id: 'v-dup-2', name: 'dup', type: 'string', value: '2' }, }) + const assignments = result.after.blocks['mapped-b1'].subBlocks.variables.value + expect(typeof assignments === 'string' ? JSON.parse(assignments) : assignments).toEqual([ + { variableId: 'v-tgt', value: 'us' }, + ]) }) it('reports null before for a workflow the sync would create, without reading the target', async () => { @@ -172,20 +193,15 @@ describe('getWorkspaceSyncWorkflowDiff', () => { const result = await run() expect(result.before).toBeNull() + expect(result.targetWorkflowId).toBeNull() expect(result.beforeLabel).toBe('Ask Biz (current)') expect(mocks.loadTargetDraftState).not.toHaveBeenCalled() - expect(mocks.readDeployedState).not.toHaveBeenCalled() }) - it('falls back to the live deployment when the target has no draft', async () => { - const deployed = state({ 'mapped-b1': {}, extra: {} }) + it('fails instead of guessing when a replaced target cannot be loaded', async () => { mocks.loadTargetDraftState.mockResolvedValue(null) - mocks.readDeployedState.mockResolvedValue(deployed) - const result = await run() - - expect(result.before).toBe(deployed) - expect(mocks.readDeployedState).toHaveBeenCalledWith('wf-tgt', 'parent') + await expect(run()).rejects.toMatchObject({ code: 'not_found' }) }) it('rejects a workflow that is not in the sync plan before reading any target state', async () => { @@ -193,7 +209,23 @@ describe('getWorkspaceSyncWorkflowDiff', () => { code: 'not_found', }) expect(mocks.loadTargetDraftState).not.toHaveBeenCalled() - expect(mocks.readDeployedState).not.toHaveBeenCalled() + }) + + it('rejects a planned workflow whose deployed state did not load', async () => { + mocks.loadSourceDeployedStates.mockResolvedValue({ + deployedWorkflows: [{ id: 'wf-src' }], + sourceStates: new Map(), + }) + + await expect(run()).rejects.toMatchObject({ code: 'not_found' }) + expect(mocks.loadTargetDraftState).not.toHaveBeenCalled() + }) + + it('refuses two admin workspaces that are not a direct fork edge', async () => { + workspaceForkingLineageMockFns.mockResolveForkEdge.mockResolvedValue(null) + + await expect(run()).rejects.toMatchObject({ code: 'validation' }) + expect(mocks.loadSourceDeployedStates).not.toHaveBeenCalled() }) it('reads the other side as the source on a pull and pairs through the child block map', async () => { diff --git a/apps/sim/ee/workspace-forking/application/sync-workflow-diff.ts b/apps/sim/ee/workspace-forking/application/sync-workflow-diff.ts index 38c324931c4..89e26f33e1f 100644 --- a/apps/sim/ee/workspace-forking/application/sync-workflow-diff.ts +++ b/apps/sim/ee/workspace-forking/application/sync-workflow-diff.ts @@ -1,5 +1,9 @@ import { db } from '@sim/db' import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + remapVariableIdsInSubBlocks, + type SubBlockRecord, +} from '@/lib/workflows/persistence/remap-internal-ids' import { defineForkUseCase, type ForkApplicationContext, @@ -8,7 +12,6 @@ import { forkOperations } from '@/ee/workspace-forking/application/operations' import { loadSourceDeployedStates, loadTargetDraftState, - readDeployedState, } from '@/ee/workspace-forking/lib/copy/deploy-bridge' import { loadForkBlockMap } from '@/ee/workspace-forking/lib/mapping/block-map-store' import { computeForkPromotePlan } from '@/ee/workspace-forking/lib/promote/promote-plan' @@ -16,23 +19,63 @@ import { buildForkBlockIdResolver } from '@/ee/workspace-forking/lib/remap/block import { remapWorkflowStateBlockIds } from '@/ee/workspace-forking/lib/remap/remap-state-block-ids' import type { Variable, WorkflowState } from '@/stores/workflows/workflow/types' +/** Names carried by exactly one variable on a side, so a name can stand in for an id. */ +function uniqueNames(variables: Record | undefined): Map { + const counts = new Map() + for (const variable of Object.values(variables ?? {})) { + counts.set(variable.name, (counts.get(variable.name) ?? 0) + 1) + } + const out = new Map() + for (const variable of Object.values(variables ?? {})) { + if (counts.get(variable.name) === 1) out.set(variable.name, variable.id) + } + return out +} + /** - * A sync gives every copied variable a fresh id, so the target's variables never - * share ids with the source's. The comparison keys variables by id; re-key the - * source side to the target's ids by name so a variable that exists on both - * sides compares as itself rather than as one removed and one added. + * A sync gives every copied variable a fresh id and rewrites the references + * inside Variables blocks to match, so the target's variables never share ids + * with the source's. The comparison keys variables by id; re-key the source + * side to the target's ids by name, definitions and references alike, so a + * variable that exists on both sides compares as itself rather than as one + * removed and one added. A name that is not unique on both sides keeps its + * source id, since it could not be paired honestly. */ function alignVariableIds(after: WorkflowState, before: WorkflowState | null): WorkflowState { if (!before?.variables || !after.variables) return after - const idByName = new Map( - Object.values(before.variables).map((variable) => [variable.name, variable.id] as const) - ) + const targetIdByName = uniqueNames(before.variables) + const sourceIdByName = uniqueNames(after.variables) + const idMap = new Map() + for (const [name, sourceId] of sourceIdByName) { + const targetId = targetIdByName.get(name) + if (targetId && targetId !== sourceId) idMap.set(sourceId, targetId) + } + if (idMap.size === 0) return after + const variables: Record = {} for (const variable of Object.values(after.variables)) { - const id = idByName.get(variable.name) ?? variable.id + const id = idMap.get(variable.id) ?? variable.id variables[id] = { ...variable, id } } - return { ...after, variables } + const blocks: WorkflowState['blocks'] = {} + for (const [id, block] of Object.entries(after.blocks)) { + try { + // double-cast-allowed: SubBlockRecord is the persistence view of the same sub-block map + const remapped = remapVariableIdsInSubBlocks( + (block.subBlocks ?? {}) as unknown as SubBlockRecord, + idMap + ) + // double-cast-allowed: back from the persistence view to the canvas state's sub-block map + blocks[id] = { + ...block, + subBlocks: remapped as unknown as WorkflowState['blocks'][string]['subBlocks'], + } + } catch { + /* An assignments value the remap cannot parse is shown as stored. */ + blocks[id] = block + } + } + return { ...after, blocks, variables } } interface SyncWorkflowDiffInput { @@ -44,16 +87,17 @@ interface SyncWorkflowDiffInput { /** * Block-level preview of ONE workflow in a sync: the target as its editor - * holds it (`before`: the draft the sync overwrites, else its live deployment, - * null when the sync would create the workflow) and the source's deployment - * re-keyed to the target's block ids (`after`). The plan and the block-id - * pairing are resolved exactly as the promote would resolve them. + * holds it (`before`: the draft the sync overwrites, null when the sync would + * create the workflow) and the source's deployment re-keyed to the target's + * block ids (`after`). The plan and the block-id pairing are resolved exactly + * as the promote would resolve them. * * Both states carry their raw sub-block values, on par with the deployment * version route the same session UI already reads; the caller holds admin on * both workspaces (`bothSides`). `after` is the source re-keyed only: the * resource remaps, custom block replacements and stored dependent values the - * sync applies at write time are shown by the sync page's own sections. + * sync applies at write time are shown by the sync page's own sections, and a + * reference the sync would clear blocks the sync until it is dropped there. */ export const getWorkspaceSyncWorkflowDiff = defineForkUseCase({ operation: forkOperations.syncPreview, @@ -97,17 +141,19 @@ export const getWorkspaceSyncWorkflowDiff = defineForkUseCase({ resolveBlockId(item.targetWorkflowId, blockId) ) + /* A create has no target yet, and its plan id is provisional, so neither is reported. */ let before: WorkflowState | null = null if (item.mode === 'replace') { - before = - (await loadTargetDraftState(item.targetWorkflowId)) ?? - (await readDeployedState(item.targetWorkflowId, targetWorkspaceId)) + before = await loadTargetDraftState(item.targetWorkflowId, targetWorkspaceId) + if (!before) { + throw new OrchestrationError('not_found', 'The target workflow could not be loaded') + } } const sourceName = item.sourceMeta.name const targetName = item.targetName ?? sourceName return { - targetWorkflowId: item.targetWorkflowId, + targetWorkflowId: item.mode === 'replace' ? item.targetWorkflowId : null, before, after: alignVariableIds(after, before), beforeLabel: `${targetName} (current)`, diff --git a/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx b/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx index f164d2846e5..072398ac568 100644 --- a/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx +++ b/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx @@ -25,6 +25,10 @@ import type { ForkTriggerMapping, ForkWorkflowChange, } from '@/lib/api/contracts/workspace-fork' + +/** A change row that names a deployed source workflow, so it can be previewed block for block. */ +type ForkWorkflowPreviewChange = Extract + import type { SelectorKey } from '@/lib/selectors/manifest' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' @@ -809,8 +813,12 @@ interface ForkSyncViewProps { * blocking references. The page header's Sync action commits it (after the overwrite confirm). */ export function ForkSyncView({ controller, onDirectionChange }: ForkSyncViewProps) { - const [diffWorkflow, setDiffWorkflow] = useState(null) - const diffSourceWorkflowId = diffWorkflow?.sourceWorkflowId + const [diffWorkflow, setDiffWorkflow] = useState(null) + /* A source in one direction is a target in the other, so a direction switch closes the preview. */ + const handleDirectionChange = (direction: ForkDirection) => { + setDiffWorkflow(null) + onDirectionChange(direction) + } const detailsError = controller.errorMessage ?? controller.diffErrorMessage const headsUp = controller.mcpReauthCount > 0 || @@ -840,7 +848,7 @@ export function ForkSyncView({ controller, onDirectionChange }: ForkSyncViewProp
{ if (!open) setDiffWorkflow(null) @@ -879,7 +887,7 @@ export function ForkSyncView({ controller, onDirectionChange }: ForkSyncViewProp workspaceId={controller.workspaceId} otherWorkspaceId={controller.otherWorkspaceId} direction={controller.direction} - sourceWorkflowId={diffSourceWorkflowId} + sourceWorkflowId={diffWorkflow.sourceWorkflowId} workflowName={diffWorkflow.currentName} /> )} @@ -891,10 +899,6 @@ export function ForkSyncView({ controller, onDirectionChange }: ForkSyncViewProp
{controller.workflowChanges.map((change, index) => { const renamed = change.currentName !== change.otherName - const canPreview = - change.action !== 'archive' && - Boolean(change.sourceWorkflowId) && - Boolean(controller.otherWorkspaceId) return (
) : null} - {canPreview && ( + {change.action !== 'archive' && controller.otherWorkspaceId && (
) : !query.data ? ( -
- - -
+ ) : ( [...forkWorkflowDiffKeys.all, 'detail'] as const, detail: ( workspaceId?: string, diff --git a/apps/sim/ee/workspace-forking/lib/copy/deploy-bridge.ts b/apps/sim/ee/workspace-forking/lib/copy/deploy-bridge.ts index dd09a385b5f..2a1c42d0e0d 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/deploy-bridge.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/deploy-bridge.ts @@ -303,6 +303,40 @@ async function readAdmittedSourceState( } } +/** + * The target workflow as its editor currently holds it: the draft tables plus + * the variables on the workflow row, read in one repeatable-read snapshot and + * in the same shape as a deployed state. A sync overwrites exactly this, so it + * is the honest "before" of a preview. Null when the workflow is not in + * `workspaceId` (or does not exist), never for a workflow elsewhere. + */ +export async function loadTargetDraftState( + workflowId: string, + workspaceId: string +): Promise { + return db.transaction( + async (tx) => { + const [draft, [row]] = await Promise.all([ + loadWorkflowFromNormalizedTables(workflowId, tx), + tx + .select({ workspaceId: workflow.workspaceId, variables: workflow.variables }) + .from(workflow) + .where(eq(workflow.id, workflowId)) + .limit(1), + ]) + if (!draft || !row || row.workspaceId !== workspaceId) return null + return { + blocks: draft.blocks, + edges: draft.edges, + loops: draft.loops, + parallels: draft.parallels, + variables: (row.variables ?? {}) as Record, + } + }, + { isolationLevel: 'repeatable read', accessMode: 'read only' } + ) +} + /** * Read a workflow's active deployed state as a `WorkflowState`. Returns null ONLY * when the workflow genuinely has no active deployment (a legitimate skip); real @@ -310,26 +344,6 @@ async function readAdmittedSourceState( * dropping the workflow from the fork/promote. Block migrations (credential remap * to current ids) are applied so copied references reflect current resources. */ -/** - * The target workflow as its editor currently holds it: the draft tables plus - * the variables on the workflow row, in the same shape as a deployed state. - * A sync overwrites exactly this, so it is the honest "before" of a preview. - */ -export async function loadTargetDraftState(workflowId: string): Promise { - const [draft, rows] = await Promise.all([ - loadWorkflowFromNormalizedTables(workflowId), - db.select({ variables: workflow.variables }).from(workflow).where(eq(workflow.id, workflowId)), - ]) - if (!draft) return null - return { - blocks: draft.blocks, - edges: draft.edges, - loops: draft.loops, - parallels: draft.parallels, - variables: (rows[0]?.variables ?? {}) as Record, - } -} - export async function readDeployedState( workflowId: string, workspaceId: string, diff --git a/apps/sim/lib/api/contracts/workspace-fork.ts b/apps/sim/lib/api/contracts/workspace-fork.ts index 894402f8fa4..a7101deeafa 100644 --- a/apps/sim/lib/api/contracts/workspace-fork.ts +++ b/apps/sim/lib/api/contracts/workspace-fork.ts @@ -319,17 +319,28 @@ export const forkUnmappedReferenceSchema = z.object({ blockName: z.string().optional(), }) -export const forkWorkflowChangeSchema = z.object({ - action: z.enum(['update', 'create', 'archive']), +const forkWorkflowChangeNames = { /** Workflow name in the workspace the modal is open in. */ currentName: z.string(), /** Workflow name in the sync-partner workspace (differs from `currentName` after a rename). */ otherName: z.string(), - /** The deployed source workflow this sync copies; absent for an archive. */ - sourceWorkflowId: z.string().optional(), - /** The workflow the sync writes or archives in the target workspace. */ - targetWorkflowId: z.string().optional(), -}) +} + +export const forkWorkflowChangeSchema = z.discriminatedUnion('action', [ + z.object({ + action: z.literal('update'), + ...forkWorkflowChangeNames, + /** The deployed source workflow this sync copies. */ + sourceWorkflowId: workflowIdSchema, + }), + z.object({ + action: z.literal('create'), + ...forkWorkflowChangeNames, + /** The deployed source workflow this sync copies. */ + sourceWorkflowId: workflowIdSchema, + }), + z.object({ action: z.literal('archive'), ...forkWorkflowChangeNames }), +]) /** * A configured selector field (Gmail label, Slack channel, KB document, ...) that @@ -1020,7 +1031,8 @@ export const getForkWorkflowDiffContract = defineRouteContract({ response: { mode: 'json', schema: z.object({ - targetWorkflowId: z.string(), + /** The workflow the sync overwrites; null when the sync would create it (its id is provisional). */ + targetWorkflowId: workflowIdSchema.nullable(), before: deployedWorkflowStateSchema.nullable(), after: deployedWorkflowStateSchema, beforeLabel: z.string(), diff --git a/apps/sim/lib/workflows/comparison/overlay.test.ts b/apps/sim/lib/workflows/comparison/overlay.test.ts index a70662790cc..49706b3bdf0 100644 --- a/apps/sim/lib/workflows/comparison/overlay.test.ts +++ b/apps/sim/lib/workflows/comparison/overlay.test.ts @@ -330,8 +330,104 @@ describe('buildWorkflowDiffOverlay', () => { JSON.parse(overlay.mergedState.blocks.cond.subBlocks.conditions.value as string).map( (item: { id: string }) => item.id ) - ).toEqual(['c-if', 'c-else', 'c-elif']) + ).toEqual(['c-if', 'c-elif', 'c-else']) /* An untouched block is passed through by identity. */ expect(overlay.mergedState.blocks.b).toBe(target.blocks.b) }) + + it('stacks a second ghost below the first and stops sliding after the nudge cap', () => { + const base = asState({ + blocks: { + goneA: block('goneA', { position: { x: 0, y: 0 }, height: 100 }), + goneB: block('goneB', { position: { x: 0, y: 0 }, height: 60 }), + }, + edges: [], + }) + const target = asState({ + blocks: { squatter: block('squatter', { position: { x: 0, y: 0 }, height: 120 }) }, + edges: [], + }) + const summary: WorkflowDiffSummary = { + ...emptySummary(), + removedBlocks: [ + { id: 'goneA', type: 'function', name: 'goneA' }, + { id: 'goneB', type: 'function', name: 'goneB' }, + ], + hasChanges: true, + } + + const merged = buildWorkflowDiffOverlay(summary, base, target).mergedState + expect(merged.blocks.goneA.position).toEqual({ x: 0, y: 152 }) + /* Clears the squatter, then the first ghost: 152 + 100 + 32. */ + expect(merged.blocks.goneB.position).toEqual({ x: 0, y: 284 }) + + const stack = Object.fromEntries( + Array.from({ length: 12 }, (_, index) => [ + `live${index}`, + block(`live${index}`, { position: { x: 0, y: index * 132 }, height: 100 }), + ]) + ) + const capped = buildWorkflowDiffOverlay( + { ...emptySummary(), removedBlocks: [{ id: 'goneA', type: 'function', name: 'goneA' }] }, + base, + asState({ blocks: stack, edges: [] }) + ).mergedState + /* Eight nudges of one 132px card each, then the slide gives up. */ + expect(capped.blocks.goneA.position).toEqual({ x: 0, y: 8 * 132 }) + }) + + it('slots a deleted branch back where it sat so the surviving else keeps its role', () => { + const conditions = (ids: string[]) => ({ + conditions: { value: JSON.stringify(ids.map((id) => ({ id, value: id }))) }, + }) + const base = asState({ + blocks: { + cond: block('cond', { type: 'condition', subBlocks: conditions(['if', 'elif', 'else']) }), + }, + edges: [], + }) + const target = asState({ + blocks: { cond: block('cond', { type: 'condition', subBlocks: conditions(['if', 'else']) }) }, + edges: [], + }) + + const merged = buildWorkflowDiffOverlay(emptySummary(), base, target).mergedState + expect( + JSON.parse(merged.blocks.cond.subBlocks.conditions.value as string).map( + (item: { id: string }) => item.id + ) + ).toEqual(['if', 'elif', 'else']) + }) + + it('never reuses an id when suffixing ghost edges', () => { + const blocks = { a: block('a'), b: block('b'), c: block('c') } + const base = asState({ + blocks, + edges: [ + { id: 'e', source: 'a', target: 'b' }, + { id: 'e', source: 'b', target: 'c' }, + ], + }) + const target = asState({ + blocks, + edges: [ + { id: 'e', source: 'a', target: 'c' }, + { id: 'e__removed', source: 'c', target: 'a' }, + ], + }) + + const overlay = buildWorkflowDiffOverlay(emptySummary(), base, target) + expect(overlay.mergedState.edges.map((edge) => edge.id)).toEqual([ + 'e', + 'e__removed', + 'e__removed2', + 'e__removed3', + ]) + expect(overlay.edgeStatus).toEqual({ + e: 'added', + e__removed: 'added', + e__removed2: 'removed', + e__removed3: 'removed', + }) + }) }) diff --git a/apps/sim/lib/workflows/comparison/overlay.ts b/apps/sim/lib/workflows/comparison/overlay.ts index 0cd02c076bf..6ea37fa5a75 100644 --- a/apps/sim/lib/workflows/comparison/overlay.ts +++ b/apps/sim/lib/workflows/comparison/overlay.ts @@ -103,16 +103,22 @@ function withRemovedBranches(baseBlock: BlockState, targetBlock: BlockState): Bl const targetItems = readBranchList(targetBlock.subBlocks?.[field]?.value) if (!baseItems || !targetItems) continue const present = new Set(targetItems.map((item) => item.id)) - const missing = baseItems.filter((item) => typeof item.id === 'string' && !present.has(item.id)) - if (missing.length === 0) continue + if (baseItems.every((item) => typeof item.id !== 'string' || present.has(item.id))) continue + /* Slot each missing branch back where it sat, so the roles the card reads off position hold. */ + const items = [...targetItems] + for (let index = baseItems.length - 1; index >= 0; index -= 1) { + const item = baseItems[index] + if (typeof item.id !== 'string' || present.has(item.id)) continue + const successor = baseItems.slice(index + 1).find((later) => present.has(later.id)) + const at = successor ? items.findIndex((candidate) => candidate.id === successor.id) : -1 + items.splice(at === -1 ? items.length : at, 0, item) + present.add(item.id) + } merged = { ...merged, subBlocks: { ...merged.subBlocks, - [field]: { - ...merged.subBlocks[field], - value: JSON.stringify([...targetItems, ...missing]), - }, + [field]: { ...merged.subBlocks[field], value: JSON.stringify(items) }, }, } } @@ -194,7 +200,10 @@ export function buildWorkflowDiffOverlay( for (const [edge, key] of baseKeyed) { if (targetKeys.has(key)) continue /* A base edge can share an id with a rewired target edge; keep both drawable. */ - const id = seenIds.has(edge.id) ? `${edge.id}__removed` : edge.id + let id = edge.id + for (let attempt = 1; seenIds.has(id); attempt += 1) { + id = attempt === 1 ? `${edge.id}__removed` : `${edge.id}__removed${attempt}` + } seenIds.add(id) edges.push({ ...edge, id }) edgeStatus[id] = 'removed' diff --git a/packages/workflow-renderer/src/types.ts b/packages/workflow-renderer/src/types.ts index d3fd599b6ec..969cbbd0a61 100644 --- a/packages/workflow-renderer/src/types.ts +++ b/packages/workflow-renderer/src/types.ts @@ -6,10 +6,10 @@ * store, socket, or query coupling. */ -/** Diff state of an edge when comparing two workflow versions. */ /** - * `deleted` is the Copilot preview's loud red dash; `ghost` is the version - * comparison's quiet trace of a connection that no longer exists. + * Diff state of an edge when comparing two workflow versions. `deleted` is the + * Copilot preview's loud red dash; `ghost` is the version comparison's quiet + * trace of a connection that no longer exists. */ export type EdgeDiffStatus = 'new' | 'deleted' | 'ghost' | 'unchanged' | null From b610b2c1f22967629f53cb76a3fb3f3f7620d4c0 Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Tue, 29 Sep 2026 23:54:19 -0700 Subject: [PATCH 07/14] fix(workflows): scope the fork preview to one workflow and close review gaps - Fork "View changes" now loads only the previewed workflow: its deployed state, its identity mapping, its target row and its block pairs, instead of every deployed state in the source workspace plus the full promote plan. The plan item comes from the same buildForkPromotePlanItems decision the promote uses. The route gets a per-user rate limit. - Word marks give up past 64 edits per line pair, so many long rewritten lines cannot stall the tab. - Agent tool params that their block marks as password fields are masked whatever their name. - A list item whose label changed but whose body did not is no longer flagged as a masked value change. - Hoist double casts under their annotations, make sourceWorkflowId optional on the wire for rollout, lazy-load both diff modals, and re-record the settings module baseline: the block registry the diff canvas needs is reached only through the lazy chunk opened on click. --- .../[id]/fork/workflow-diff/route.test.ts | 79 +++++++++++----- .../[id]/fork/workflow-diff/route.ts | 2 +- .../components/general/components/index.ts | 2 +- .../components/general/general.tsx | 40 +++++--- .../components/diff-label/diff-label.tsx | 3 +- .../change-list/binding-change-row.tsx | 14 +-- .../components/change-list/change-list.tsx | 29 +++--- .../components/change-list/index.ts | 4 - .../change-list/keyed-list-diff.tsx | 3 + .../change-list/text-diff-lines.test.ts | 8 ++ .../components/change-list/text-diff-lines.ts | 12 ++- .../w/components/workflow-diff/utils.test.ts | 63 ++++++++++++- .../w/components/workflow-diff/utils.ts | 69 ++++++++++++-- .../application/sync-workflow-diff.test.ts | 68 +++++++------- .../application/sync-workflow-diff.ts | 78 ++++++++-------- .../components/fork-sync/fork-sync-view.tsx | 61 ++++++++---- .../lib/copy/deploy-bridge.ts | 93 ++++++++++++++----- .../lib/mapping/block-map-store.ts | 21 ++++- .../lib/mapping/mapping-store.ts | 37 +++++++- .../lib/promote/promote-plan.ts | 50 ++++++++++ apps/sim/lib/api/contracts/workspace-fork.ts | 8 +- apps/sim/lib/workflows/comparison/index.ts | 2 - ...check-tool-registry-boundary.baseline.json | 32 +++---- 23 files changed, 558 insertions(+), 220 deletions(-) diff --git a/apps/sim/app/api/workspaces/[id]/fork/workflow-diff/route.test.ts b/apps/sim/app/api/workspaces/[id]/fork/workflow-diff/route.test.ts index 23f420f0a83..55bd845689d 100644 --- a/apps/sim/app/api/workspaces/[id]/fork/workflow-diff/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/fork/workflow-diff/route.test.ts @@ -17,10 +17,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { OrchestrationError } from '@/lib/core/orchestration/types' const mocks = vi.hoisted(() => ({ - loadSourceDeployedStates: vi.fn(), + loadSourceDeployedWorkflow: vi.fn(), loadTargetDraftState: vi.fn(), loadForkBlockMap: vi.fn(), - computeForkPromotePlan: vi.fn(), + resolveForkPlanItem: vi.fn(), })) vi.mock('@/lib/core/application/workspace-authorization', () => workspaceAuthorizationMock) @@ -28,14 +28,14 @@ vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) vi.mock('@/ee/workspace-forking/lib/lineage/authz', () => workspaceForkingAuthzMock) vi.mock('@/ee/workspace-forking/lib/lineage/lineage', () => workspaceForkingLineageMock) vi.mock('@/ee/workspace-forking/lib/copy/deploy-bridge', () => ({ - loadSourceDeployedStates: mocks.loadSourceDeployedStates, + loadSourceDeployedWorkflow: mocks.loadSourceDeployedWorkflow, loadTargetDraftState: mocks.loadTargetDraftState, })) vi.mock('@/ee/workspace-forking/lib/mapping/block-map-store', () => ({ loadForkBlockMap: mocks.loadForkBlockMap, })) vi.mock('@/ee/workspace-forking/lib/promote/promote-plan', () => ({ - computeForkPromotePlan: mocks.computeForkPromotePlan, + resolveForkPlanItem: mocks.resolveForkPlanItem, })) import { GET } from '@/app/api/workspaces/[id]/fork/workflow-diff/route' @@ -70,20 +70,15 @@ describe('fork workflow-diff route', () => { childWorkspaceId: 'child', }) mocks.loadForkBlockMap.mockResolvedValue({ parentToChild: new Map(), childToParent: new Map() }) - mocks.loadSourceDeployedStates.mockResolvedValue({ - deployedWorkflows: [{ id: 'wf-src' }], - sourceStates: new Map([['wf-src', emptyState]]), - }) - mocks.computeForkPromotePlan.mockResolvedValue({ - items: [ - { - sourceWorkflowId: 'wf-src', - targetWorkflowId: 'wf-tgt', - mode: 'create', - sourceMeta: { name: 'Ask Biz' }, - }, - ], - archivedTargets: [], + mocks.loadSourceDeployedWorkflow.mockImplementation(async (_ws: string, id: string) => + id === 'wf-src' ? { summary: { id, name: 'Ask Biz' }, state: emptyState } : null + ) + mocks.resolveForkPlanItem.mockResolvedValue({ + sourceWorkflowId: 'wf-src', + targetWorkflowId: 'wf-tgt', + targetName: null, + mode: 'create', + sourceMeta: { name: 'Ask Biz' }, }) }) @@ -98,7 +93,7 @@ describe('fork workflow-diff route', () => { ) expect(response.status).toBe(403) - expect(mocks.loadSourceDeployedStates).not.toHaveBeenCalled() + expect(mocks.loadSourceDeployedWorkflow).not.toHaveBeenCalled() }) it('rejects a request without the source workflow id', async () => { @@ -108,7 +103,7 @@ describe('fork workflow-diff route', () => { ) expect(response.status).toBe(400) - expect(mocks.loadSourceDeployedStates).not.toHaveBeenCalled() + expect(mocks.loadSourceDeployedWorkflow).not.toHaveBeenCalled() }) it('rejects workspaces that are not a direct fork edge without reading state', async () => { @@ -120,7 +115,7 @@ describe('fork workflow-diff route', () => { ) expect(response.status).toBe(400) - expect(mocks.loadSourceDeployedStates).not.toHaveBeenCalled() + expect(mocks.loadSourceDeployedWorkflow).not.toHaveBeenCalled() }) it('maps a workflow outside the sync plan to 404', async () => { @@ -147,4 +142,46 @@ describe('fork workflow-diff route', () => { afterLabel: 'Ask Biz (deployed)', }) }) + it('maps a workflow whose target is excluded from sync to 404', async () => { + mocks.resolveForkPlanItem.mockResolvedValue(null) + + const response = await GET( + request({ otherWorkspaceId: 'parent', direction: 'push', sourceWorkflowId: 'wf-src' }), + routeContext + ) + + expect(response.status).toBe(404) + }) + + it('reads the target draft and only its block pairs when the sync replaces a workflow', async () => { + const targetState = { + ...emptyState, + blocks: {}, + } + mocks.resolveForkPlanItem.mockResolvedValue({ + sourceWorkflowId: 'wf-src', + targetWorkflowId: 'wf-tgt', + targetName: 'Ask Biz prod', + mode: 'replace', + sourceMeta: { name: 'Ask Biz' }, + }) + mocks.loadTargetDraftState.mockResolvedValue(targetState) + + const response = await GET( + request({ otherWorkspaceId: 'parent', direction: 'push', sourceWorkflowId: 'wf-src' }), + routeContext + ) + + expect(response.status).toBe(200) + expect(mocks.loadTargetDraftState).toHaveBeenCalledWith('wf-tgt', 'parent') + expect(mocks.loadForkBlockMap).toHaveBeenCalledWith(expect.anything(), 'child', { + side: 'parent', + workflowId: 'wf-tgt', + }) + await expect(response.json()).resolves.toMatchObject({ + targetWorkflowId: 'wf-tgt', + before: targetState, + beforeLabel: 'Ask Biz prod (current)', + }) + }) }) diff --git a/apps/sim/app/api/workspaces/[id]/fork/workflow-diff/route.ts b/apps/sim/app/api/workspaces/[id]/fork/workflow-diff/route.ts index e13179910ea..cac548176f9 100644 --- a/apps/sim/app/api/workspaces/[id]/fork/workflow-diff/route.ts +++ b/apps/sim/app/api/workspaces/[id]/fork/workflow-diff/route.ts @@ -12,7 +12,7 @@ export const GET = defineInternalJsonRoute({ contract: getForkWorkflowDiffContract, auth: internalSessionAuth, operation: forkOperations.syncPreview, - rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal fork request policy' }), + rateLimit: internalRateLimits.user({ bucketName: 'workspace-fork-workflow-diff' }), errorPolicy: internalForkErrorPolicy, mapInput: ({ params, query }) => ({ workspaceId: params.id, ...query }), useCase: getWorkspaceSyncWorkflowDiff, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/index.ts index 73d6e54b826..85cbd10dd41 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/index.ts @@ -1,2 +1,2 @@ -export { type CompareSide, CompareVersionsModal } from './compare-versions-modal' +export type { CompareSide } from './compare-versions-modal' export { Versions } from './versions' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/general.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/general.tsx index a0fc2a418f3..26a0309c05d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/general.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/general.tsx @@ -1,6 +1,6 @@ 'use client' -import { useId, useState } from 'react' +import { lazy, Suspense, useId, useState } from 'react' import { Button, ChipButtonGroup, @@ -17,17 +17,27 @@ import { } from '@sim/emcn' import { createLogger } from '@sim/logger' import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils' +import { + type ComparePair, + resolveComparePair, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/compare-pair' import type { DeployReadiness } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-deploy-readiness' import { Preview, PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview' import { useDeploymentVersionState, useRevertToVersion } from '@/hooks/queries/workflows' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import type { WorkflowState } from '@/stores/workflows/workflow/types' -import { type ComparePair, resolveComparePair } from './compare-pair' -import { CompareVersionsModal, Versions } from './components' +import { Versions } from './components' import { formatVersionLabel } from './format-version-label' const logger = createLogger('GeneralDeploy') +/** The comparison canvas is heavy and rarely opened, so it stays out of the editor's initial bundle. */ +const CompareVersionsModal = lazy(() => + import( + '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/compare-versions-modal' + ).then((module) => ({ default: module.CompareVersionsModal })) +) + interface GeneralDeployProps { workflowId: string | null deployedState?: WorkflowState | null @@ -350,17 +360,19 @@ export function GeneralDeploy({ /> {workflowId && comparePair && ( - { - if (!open) setComparePair(null) - }} - workflowId={workflowId} - versions={versions} - initialBase={comparePair.base} - initialTarget={comparePair.target} - /> + + { + if (!open) setComparePair(null) + }} + workflowId={workflowId} + versions={versions} + initialBase={comparePair.base} + initialTarget={comparePair.target} + /> + )} {workflowToShow && ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/diff-label/diff-label.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/diff-label/diff-label.tsx index 53ce63f8de7..55fcbf70130 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/diff-label/diff-label.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/diff-label/diff-label.tsx @@ -3,7 +3,8 @@ import { cn } from '@sim/emcn' import type { BlockDiffStatus } from '@/lib/workflows/comparison' -const DIFF_LABEL: Record = { +/** The word for each comparison status, shared by the canvas label and the change list badge. */ +export const DIFF_LABEL: Record = { added: 'Added', modified: 'Modified', removed: 'Removed', diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/binding-change-row.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/binding-change-row.tsx index 9abe93b9995..52bc169884d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/binding-change-row.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/binding-change-row.tsx @@ -34,13 +34,15 @@ export function BindingChangeRow({ blockType, field, oldValue, newValue }: Bindi Differs between workspaces ) : ( <> - - {formatScalar(blockType, field, oldValue)} - + - - {formatScalar(blockType, field, newValue)} - + )}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/change-list.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/change-list.tsx index cb7db9dd7e1..5d0a7f18e19 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/change-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/change-list.tsx @@ -14,6 +14,7 @@ import { Badge, cn, OverflowText } from '@sim/emcn' import { ChevronDown } from '@sim/emcn/icons' import { humanizeBlockName } from '@sim/workflow-renderer' import type { BlockDiffStatus, WorkflowDiffSummary } from '@/lib/workflows/comparison' +import { DIFF_LABEL } from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/diff-label/diff-label' import { BindingChangeRow } from '@/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/binding-change-row' import { DIFF_SIGN, @@ -36,12 +37,6 @@ const STATUS_BADGE_VARIANT: Record = removed: 'red', } -const STATUS_LABEL: Record = { - added: 'Added', - modified: 'Modified', - removed: 'Removed', -} - interface ChangeListProps { summary: WorkflowDiffSummary /** The two sides, so cards can show an added or removed block's fields and detect moves */ @@ -166,14 +161,14 @@ export function ChangeList({ } >
- {summary.variableChanges.addedNames.map((name) => ( - + {summary.variableChanges.addedNames.map((name, index) => ( + ))} - {summary.variableChanges.modifiedNames.map((name) => ( - + {summary.variableChanges.modifiedNames.map((name, index) => ( + ))} - {summary.variableChanges.removedNames.map((name) => ( - + {summary.variableChanges.removedNames.map((name, index) => ( + ))}
@@ -293,7 +288,7 @@ const BlockCard = memo(function BlockCard({ className='flex-1 font-medium text-[var(--text-primary)] text-small' /> - {bindingsOnly ? 'Bindings only' : STATUS_LABEL[entry.status]} + {bindingsOnly ? 'Bindings only' : DIFF_LABEL[entry.status]} {hasBody && ( ))} - {entry.membership?.added.map((row) => ( + {entry.membership?.added.map((row, index) => ( ))} - {entry.membership?.removed.map((row) => ( + {entry.membership?.removed.map((row, index) => ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/index.ts index f58b60974dd..2900d92fe30 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/index.ts @@ -1,5 +1 @@ -export { BindingChangeRow } from './binding-change-row' export { ChangeList } from './change-list' -export { FieldChangeRow } from './field-change-row' -export { KeyedListDiff } from './keyed-list-diff' -export { InlineDiff, TextDiff } from './text-diff' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/keyed-list-diff.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/keyed-list-diff.tsx index 6a12d1b04bb..b023da68fa8 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/keyed-list-diff.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/keyed-list-diff.tsx @@ -58,6 +58,9 @@ export function KeyedListDiff({ blockType, field, oldValue, newValue }: KeyedLis {row.oldLabel && ( (was {row.oldLabel}) )} + {row.secretChanged && ( + (a masked value changed) + )} {row.kind === 'changed' && row.oldText !== row.newText ? ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/text-diff-lines.test.ts b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/text-diff-lines.test.ts index 90f43dc4715..ce31fdb0502 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/text-diff-lines.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/text-diff-lines.test.ts @@ -152,4 +152,12 @@ describe('buildDiffRows', () => { { type: 'oversized', oldLines: MAX_DIFF_LINES, newLines: MAX_DIFF_LINES + 1 }, ]) }) + it('gives up on word marks for a heavily rewritten pair instead of diffing it word by word', () => { + const before = Array.from({ length: 150 }, (_, i) => `alpha${i}`).join(' ') + const after = Array.from({ length: 150 }, (_, i) => `beta${i}`).join(' ') + const lines = markWordChanges(toLines(before, after)) + + expect(lines.map((line) => line.kind)).toEqual(['removed', 'added']) + expect(lines.every((line) => line.parts === undefined)).toBe(true) + }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/text-diff-lines.ts b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/text-diff-lines.ts index f9f6a61f51a..aa2b69ba33f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/text-diff-lines.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/text-diff-lines.ts @@ -18,6 +18,13 @@ const MAX_WORD_HIGHLIGHT_RATIO = 0.6 * stalling the pane on a pathological prompt. */ const MAX_WORD_DIFF_CHARS = 4000 +/** + * Word marks only help when a line pair differs by a few words, so the word + * diff gives up past this many edits. That caps each pair's work at a small + * multiple of its length, so many long, heavily rewritten line pairs cannot + * add up to a stall the way a per-line size limit alone would allow. + */ +const MAX_WORD_EDITS = 64 /** * Line diffing is quadratic in the worst case too; two bodies with more lines * than this between them are summarized instead of diffed so opening a @@ -63,7 +70,10 @@ export function markWordChanges(lines: DiffLine[]): DiffLine[] { const added = out[removedEnd + offset] const total = removed.text.length + added.text.length if (total === 0 || total > MAX_WORD_DIFF_CHARS) continue - const words = diffWordsWithSpace(removed.text, added.text) + const words = diffWordsWithSpace(removed.text, added.text, { + maxEditLength: MAX_WORD_EDITS, + }) + if (!words) continue const changedChars = words .filter((part) => part.added || part.removed) .reduce((sum, part) => sum + part.value.length, 0) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/utils.test.ts b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/utils.test.ts index 6671efc009c..a7f203bf406 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/utils.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/utils.test.ts @@ -158,8 +158,13 @@ describe('describeListItems', () => { expect(describeListItems('other', 'tags', ['a', 42, { title: 'T', extra: 1 }, {}])).toEqual([ { key: 'a', label: 'a', text: '' }, { key: '42', label: '42', text: '' }, - { key: 'T', label: 'T', text: JSON.stringify({ extra: 1 }, null, 2) }, - { key: 'Item 4', label: 'Item 4', text: '' }, + { + key: 'T', + label: 'T', + text: JSON.stringify({ extra: 1 }, null, 2), + signature: JSON.stringify({ extra: 1 }), + }, + { key: 'Item 4', label: 'Item 4', text: '', signature: '' }, ]) }) @@ -180,14 +185,28 @@ describe('describeListItems', () => { key: 'search_docs', label: 'search_docs', text: JSON.stringify({ server: 's1', params: { query: 'x', apiToken: '•••' } }, null, 2), + signature: JSON.stringify({ server: 's1', params: { query: 'x', apiToken: 'sk-1' } }), }) /* A custom tool's schema is its implementation, so it stays in the comparable body. */ - expect(items[1]).toEqual({ + expect(items[1]).toMatchObject({ key: 'lookup', label: 'lookup', text: JSON.stringify({ schema: { function: { name: 'lookup' } } }, null, 2), }) - expect(items[2]).toEqual({ key: 'slack_send', label: 'Slack', text: '' }) + expect(items[2]).toMatchObject({ key: 'slack_send', label: 'Slack', text: '' }) + }) + + it('reports a change to a secret alone without showing either value', () => { + declareSubBlocks({ agent: [{ id: 'tools', type: 'tool-input' }] }) + const tool = (apiKey: string) => ({ type: 'http', toolId: 'http_call', params: { apiKey } }) + + const [before] = describeListItems('agent', 'tools', [tool('old')]) + const [after] = describeListItems('agent', 'tools', [tool('new')]) + + expect(before.text).toBe(after.text) + expect(pairListItems([before], [after], false)).toEqual([ + expect.objectContaining({ kind: 'changed', secretChanged: true }), + ]) }) it('keeps what a tool may do in its body so a permission change is not an order change', () => { @@ -239,6 +258,29 @@ describe('pairListItems', () => { }) describe('maskSecretsDeep', () => { + it("masks a block tool's params that its block marks as password fields, whatever their name", () => { + declareSubBlocks({ + agent: [{ id: 'tools', type: 'tool-input' }], + athena: [ + { id: 'awsAccessKeyId', type: 'short-input', password: true }, + { id: 'awsSecretAccessKey', type: 'short-input', password: true }, + { id: 'region', type: 'short-input' }, + ], + }) + const [tool] = describeListItems('agent', 'tools', [ + { + type: 'athena', + title: 'Athena', + params: { awsAccessKeyId: 'AKIAEXAMPLE', awsSecretAccessKey: 'shh', region: 'us-east-1' }, + }, + ]) + + expect(tool.text).not.toContain('AKIAEXAMPLE') + expect(tool.text).not.toContain('shh') + expect(tool.text).toContain('us-east-1') + expect(tool.signature).toContain('AKIAEXAMPLE') + }) + it('masks keys that end in a secret noun and leaves ids, counts and limits readable', () => { expect( maskSecretsDeep({ @@ -297,6 +339,15 @@ describe('maskSecretsDeep', () => { JSON.stringify({ password: '•••', name: 'n' }, null, 2) ) }) + + it('masks inside a JSON-encoded string the way tool params are stored', () => { + const encoded = JSON.stringify({ Authorization: 'Bearer x', Accept: 'json' }) + expect(maskSecretsDeep({ headers: encoded })).toEqual({ + headers: JSON.stringify({ Authorization: '•••', Accept: 'json' }), + }) + expect(maskSecretsDeep('plain text')).toBe('plain text') + expect(maskSecretsDeep('{not json')).toBe('{not json') + }) }) describe('value readers and labels', () => { @@ -330,6 +381,10 @@ describe('value readers and labels', () => { expect(formatScalar('agent', 'mode', 'fast')).toBe('Fast') expect(formatScalar('agent', 'model', 'unknown')).toBe('unknown') expect(formatScalar('agent', 'temp', 0.5)).toBe('0.5') + /* A comparison never truncates a string, and never prints a nested secret. */ + const long = 'x'.repeat(58) + expect(formatScalar('agent', 'temp', long)).toBe(long) + expect(formatScalar('agent', 'temp', { apiKey: 'k' })).toBe('{"apiKey":"•••"}') }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/utils.ts b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/utils.ts index b14735405cf..dbdc0b619f2 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/utils.ts @@ -65,8 +65,13 @@ export function isPositionalListField(blockType: string, field: string): boolean export interface ListItemView { key: string label: string - /** The item's comparable body; a change here renders as a text diff */ + /** The item's comparable body as shown, secrets masked; a change here renders as a text diff */ text: string + /** + * The body before masking, used only to decide whether two items differ, so + * a change to a secret alone still reads as a change without being shown. + */ + signature?: string } export type ListRowKind = 'added' | 'removed' | 'changed' @@ -79,6 +84,8 @@ export interface ListDiffRow { oldLabel?: string oldText: string newText: string + /** The only difference is in a masked value, so the texts read the same */ + secretChanged?: boolean } /** @@ -103,7 +110,8 @@ export function pairListItems( const index = list.findIndex(predicate) return index === -1 ? undefined : list.splice(index, 1)[0] } - const same = (a: ListItemView, b: ListItemView) => a.text === b.text && a.label === b.label + const body = (item: ListItemView) => item.signature ?? item.text + const same = (a: ListItemView, b: ListItemView) => body(a) === body(b) && a.label === b.label for (const item of newItems) { const previous = take(oldLeft, (candidate) => candidate.key === item.key) @@ -116,6 +124,9 @@ export function pairListItems( oldLabel: previous.label !== item.label ? previous.label : undefined, oldText: previous.text, newText: item.text, + ...(previous.text === item.text && body(previous) !== body(item) + ? { secretChanged: true } + : {}), }) } } @@ -214,13 +225,13 @@ export function describeListItems( )) : undefined const { serverId, toolName: _toolName, ...rawParams } = paramRecord - const visibleParams = maskSecretsDeep(rawParams) as Record /* What the tool is allowed to do, where it runs and how it runs matter as much as its params. */ - const body = filterBlank({ - ...(maskSecretsDeep(pick(item, TOOL_EXECUTION_FIELDS)) as Record), + const rawBody = filterBlank({ + ...pick(item, TOOL_EXECUTION_FIELDS), server: serverId, - params: Object.keys(visibleParams).length ? visibleParams : undefined, + params: Object.keys(rawParams).length ? rawParams : undefined, }) + const body = maskSecretsDeep(maskToolPasswordParams(toolType, rawBody)) return { key: mcpName ?? customName ?? readString(item, ['toolId', 'type', 'title']) ?? `tool-${index}`, @@ -230,7 +241,8 @@ export function describeListItems( customName ?? readString(item, ['type']) ?? `Tool ${index + 1}`, - text: Object.keys(body).length ? JSON.stringify(body, null, 2) : '', + text: Object.keys(rawBody).length ? JSON.stringify(body, null, 2) : '', + signature: Object.keys(rawBody).length ? JSON.stringify(rawBody) : '', } } if (type === 'input-format') { @@ -252,6 +264,7 @@ export function describeListItems( key: id ?? label, label, text: Object.keys(rest).length ? JSON.stringify(maskSecretsDeep(rest), null, 2) : '', + signature: Object.keys(rest).length ? JSON.stringify(rest) : '', } }) } @@ -458,7 +471,9 @@ export function formatScalar(blockType: string, field: string, value: unknown): const match = options.find((option) => option.id === value) if (match?.label) return match.label } - return formatValueForDisplay(value) + /* A comparison must show the whole value: a difference at character 55 is still a difference. */ + if (typeof value === 'string') return value || formatValueForDisplay(value) + return formatValueForDisplay(maskSecretsDeep(value)) } /** @@ -492,6 +507,43 @@ function isSecretKey(key: string): boolean { return SECRET_KEY_PATTERN.test(key.trim()) } +/** + * A block used as an agent tool keeps its params under the block's own + * sub-block ids, so the block's `password` flags say which ones are secrets, + * whatever they are named (an access key id is as sensitive as its secret). + */ +function maskToolPasswordParams( + toolType: string | undefined, + body: Record +): Record { + const params = body.params + if (!toolType || !params || typeof params !== 'object' || Array.isArray(params)) return body + const masked: Record = {} + for (const [key, value] of Object.entries(params as Record)) { + masked[key] = + !isBlankValue(value) && findSubBlockConfig(toolType, key)?.password ? MASKED_VALUE : value + } + return { ...body, params: masked } +} + +/** + * Tool params persist structured values as JSON strings, so a header map with + * an Authorization entry arrives encoded; decode, mask and re-encode it. + */ +function maskEncodedSecrets(value: string): string { + const trimmed = value.trimStart() + if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return value + let parsed: unknown + try { + parsed = JSON.parse(value) + } catch { + return value + } + if (parsed === null || typeof parsed !== 'object') return value + const masked = maskSecretsDeep(parsed) + return JSON.stringify(masked) === JSON.stringify(parsed) ? value : JSON.stringify(masked) +} + /** * Returns a copy of a stored value with every secret-looking leaf masked, at any * depth: object keys, and the `Value` of a key/value table row whose `Key` @@ -499,6 +551,7 @@ function isSecretKey(key: string): boolean { */ export function maskSecretsDeep(value: unknown): unknown { if (Array.isArray(value)) return value.map(maskSecretsDeep) + if (typeof value === 'string') return maskEncodedSecrets(value) if (value === null || typeof value !== 'object') return value const record = value as Record const cells = record.cells diff --git a/apps/sim/ee/workspace-forking/application/sync-workflow-diff.test.ts b/apps/sim/ee/workspace-forking/application/sync-workflow-diff.test.ts index 03d42109408..11aabaafea5 100644 --- a/apps/sim/ee/workspace-forking/application/sync-workflow-diff.test.ts +++ b/apps/sim/ee/workspace-forking/application/sync-workflow-diff.test.ts @@ -17,10 +17,10 @@ import { OrchestrationError } from '@/lib/core/orchestration/types' import type { WorkflowState } from '@/stores/workflows/workflow/types' const mocks = vi.hoisted(() => ({ - loadSourceDeployedStates: vi.fn(), + loadSourceDeployedWorkflow: vi.fn(), loadTargetDraftState: vi.fn(), loadForkBlockMap: vi.fn(), - computeForkPromotePlan: vi.fn(), + resolveForkPlanItem: vi.fn(), })) vi.mock('@/lib/core/application/workspace-authorization', () => workspaceAuthorizationMock) @@ -28,14 +28,14 @@ vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) vi.mock('@/ee/workspace-forking/lib/lineage/authz', () => workspaceForkingAuthzMock) vi.mock('@/ee/workspace-forking/lib/lineage/lineage', () => workspaceForkingLineageMock) vi.mock('@/ee/workspace-forking/lib/copy/deploy-bridge', () => ({ - loadSourceDeployedStates: mocks.loadSourceDeployedStates, + loadSourceDeployedWorkflow: mocks.loadSourceDeployedWorkflow, loadTargetDraftState: mocks.loadTargetDraftState, })) vi.mock('@/ee/workspace-forking/lib/mapping/block-map-store', () => ({ loadForkBlockMap: mocks.loadForkBlockMap, })) vi.mock('@/ee/workspace-forking/lib/promote/promote-plan', () => ({ - computeForkPromotePlan: mocks.computeForkPromotePlan, + resolveForkPlanItem: mocks.resolveForkPlanItem, })) import { getWorkspaceSyncWorkflowDiff } from '@/ee/workspace-forking/application/sync-workflow-diff' @@ -79,6 +79,13 @@ function planItem(overrides: Record = {}) { } } +/** The source loader, answering for `wf-src` only, as the real one answers for sync sources only. */ +function sourceIs(sourceState: WorkflowState | null) { + mocks.loadSourceDeployedWorkflow.mockImplementation(async (_workspaceId: string, id: string) => + id === 'wf-src' && sourceState ? { summary: { id, name: 'Ask Biz' }, state: sourceState } : null + ) +} + function run( input: Partial<{ workspaceId: string @@ -114,13 +121,8 @@ describe('getWorkspaceSyncWorkflowDiff', () => { parentToChild: new Map(), childToParent: new Map([['b1', { targetBlockId: 'mapped-b1', targetWorkflowId: 'wf-tgt' }]]), }) - mocks.loadSourceDeployedStates.mockResolvedValue({ - deployedWorkflows: [{ id: 'wf-src' }], - sourceStates: new Map([ - ['wf-src', state({ b1: {}, b2: {} }, [{ id: 'e', source: 'b1', target: 'b2' }])], - ]), - }) - mocks.computeForkPromotePlan.mockResolvedValue({ items: [planItem()], archivedTargets: [] }) + sourceIs(state({ b1: {}, b2: {} }, [{ id: 'e', source: 'b1', target: 'b2' }])) + mocks.resolveForkPlanItem.mockResolvedValue(planItem()) mocks.loadTargetDraftState.mockResolvedValue(state({ 'mapped-b1': {} })) }) @@ -141,6 +143,10 @@ describe('getWorkspaceSyncWorkflowDiff', () => { }) expect(result.before).toEqual(state({ 'mapped-b1': {} })) expect(mocks.loadTargetDraftState).toHaveBeenCalledWith('wf-tgt', 'parent') + expect(mocks.loadForkBlockMap).toHaveBeenCalledWith(expect.anything(), 'child', { + side: 'parent', + workflowId: 'wf-tgt', + }) }) it('re-keys source variables and their assignments to the target ids by unique name', async () => { @@ -163,10 +169,7 @@ describe('getWorkspaceSyncWorkflowDiff', () => { 'v-tgt': { id: 'v-tgt', name: 'region', type: 'string', value: 'us' }, 'v-tgt-dup': { id: 'v-tgt-dup', name: 'dup', type: 'string', value: '9' }, } - mocks.loadSourceDeployedStates.mockResolvedValue({ - deployedWorkflows: [{ id: 'wf-src' }], - sourceStates: new Map([['wf-src', source]]), - }) + sourceIs(source) mocks.loadTargetDraftState.mockResolvedValue(target) const result = await run() @@ -185,10 +188,7 @@ describe('getWorkspaceSyncWorkflowDiff', () => { }) it('reports null before for a workflow the sync would create, without reading the target', async () => { - mocks.computeForkPromotePlan.mockResolvedValue({ - items: [planItem({ mode: 'create', targetName: undefined })], - archivedTargets: [], - }) + mocks.resolveForkPlanItem.mockResolvedValue(planItem({ mode: 'create', targetName: null })) const result = await run() @@ -196,6 +196,7 @@ describe('getWorkspaceSyncWorkflowDiff', () => { expect(result.targetWorkflowId).toBeNull() expect(result.beforeLabel).toBe('Ask Biz (current)') expect(mocks.loadTargetDraftState).not.toHaveBeenCalled() + expect(mocks.loadForkBlockMap).not.toHaveBeenCalled() }) it('fails instead of guessing when a replaced target cannot be loaded', async () => { @@ -204,18 +205,16 @@ describe('getWorkspaceSyncWorkflowDiff', () => { await expect(run()).rejects.toMatchObject({ code: 'not_found' }) }) - it('rejects a workflow that is not in the sync plan before reading any target state', async () => { + it('rejects a workflow that is not a sync source before planning or reading the target', async () => { await expect(run({ sourceWorkflowId: 'foreign' })).rejects.toMatchObject({ code: 'not_found', }) + expect(mocks.resolveForkPlanItem).not.toHaveBeenCalled() expect(mocks.loadTargetDraftState).not.toHaveBeenCalled() }) - it('rejects a planned workflow whose deployed state did not load', async () => { - mocks.loadSourceDeployedStates.mockResolvedValue({ - deployedWorkflows: [{ id: 'wf-src' }], - sourceStates: new Map(), - }) + it('rejects a source whose target is excluded from sync, as the promote skips it', async () => { + mocks.resolveForkPlanItem.mockResolvedValue(null) await expect(run()).rejects.toMatchObject({ code: 'not_found' }) expect(mocks.loadTargetDraftState).not.toHaveBeenCalled() @@ -225,21 +224,20 @@ describe('getWorkspaceSyncWorkflowDiff', () => { workspaceForkingLineageMockFns.mockResolveForkEdge.mockResolvedValue(null) await expect(run()).rejects.toMatchObject({ code: 'validation' }) - expect(mocks.loadSourceDeployedStates).not.toHaveBeenCalled() + expect(mocks.loadSourceDeployedWorkflow).not.toHaveBeenCalled() }) it('reads the other side as the source on a pull and pairs through the child block map', async () => { await run({ workspaceId: 'parent', otherWorkspaceId: 'child', direction: 'pull' }) - expect(mocks.loadSourceDeployedStates).toHaveBeenCalledWith('child') - expect(mocks.computeForkPromotePlan).toHaveBeenCalledWith( - expect.objectContaining({ - sourceWorkspaceId: 'child', - targetWorkspaceId: 'parent', - direction: 'pull', - }) + expect(mocks.loadSourceDeployedWorkflow).toHaveBeenCalledWith('child', 'wf-src') + expect(mocks.resolveForkPlanItem).toHaveBeenCalledWith( + expect.objectContaining({ sourceWorkspaceId: 'child', targetWorkspaceId: 'parent' }) ) - expect(mocks.loadForkBlockMap).toHaveBeenCalledWith(expect.anything(), 'child') + expect(mocks.loadForkBlockMap).toHaveBeenCalledWith(expect.anything(), 'child', { + side: 'parent', + workflowId: 'wf-tgt', + }) }) it('refuses when the caller is not an admin on the other side', async () => { @@ -252,6 +250,6 @@ describe('getWorkspaceSyncWorkflowDiff', () => { ) await expect(run()).rejects.toMatchObject({ code: 'forbidden' }) - expect(mocks.loadSourceDeployedStates).not.toHaveBeenCalled() + expect(mocks.loadSourceDeployedWorkflow).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/ee/workspace-forking/application/sync-workflow-diff.ts b/apps/sim/ee/workspace-forking/application/sync-workflow-diff.ts index 89e26f33e1f..a7f5e1f2e08 100644 --- a/apps/sim/ee/workspace-forking/application/sync-workflow-diff.ts +++ b/apps/sim/ee/workspace-forking/application/sync-workflow-diff.ts @@ -10,11 +10,11 @@ import { } from '@/ee/workspace-forking/application/authorized-fork-use-case' import { forkOperations } from '@/ee/workspace-forking/application/operations' import { - loadSourceDeployedStates, + loadSourceDeployedWorkflow, loadTargetDraftState, } from '@/ee/workspace-forking/lib/copy/deploy-bridge' import { loadForkBlockMap } from '@/ee/workspace-forking/lib/mapping/block-map-store' -import { computeForkPromotePlan } from '@/ee/workspace-forking/lib/promote/promote-plan' +import { resolveForkPlanItem } from '@/ee/workspace-forking/lib/promote/promote-plan' import { buildForkBlockIdResolver } from '@/ee/workspace-forking/lib/remap/block-identity' import { remapWorkflowStateBlockIds } from '@/ee/workspace-forking/lib/remap/remap-state-block-ids' import type { Variable, WorkflowState } from '@/stores/workflows/workflow/types' @@ -61,15 +61,11 @@ function alignVariableIds(after: WorkflowState, before: WorkflowState | null): W for (const [id, block] of Object.entries(after.blocks)) { try { // double-cast-allowed: SubBlockRecord is the persistence view of the same sub-block map - const remapped = remapVariableIdsInSubBlocks( - (block.subBlocks ?? {}) as unknown as SubBlockRecord, - idMap - ) + const sourceSubBlocks = (block.subBlocks ?? {}) as unknown as SubBlockRecord + const remapped = remapVariableIdsInSubBlocks(sourceSubBlocks, idMap) // double-cast-allowed: back from the persistence view to the canvas state's sub-block map - blocks[id] = { - ...block, - subBlocks: remapped as unknown as WorkflowState['blocks'][string]['subBlocks'], - } + const subBlocks = remapped as unknown as WorkflowState['blocks'][string]['subBlocks'] + blocks[id] = { ...block, subBlocks } } catch { /* An assignments value the remap cannot parse is shown as stored. */ blocks[id] = block @@ -89,8 +85,10 @@ interface SyncWorkflowDiffInput { * Block-level preview of ONE workflow in a sync: the target as its editor * holds it (`before`: the draft the sync overwrites, null when the sync would * create the workflow) and the source's deployment re-keyed to the target's - * block ids (`after`). The plan and the block-id pairing are resolved exactly - * as the promote would resolve them. + * block ids (`after`). The plan item and the block-id pairing are resolved + * exactly as the promote would resolve them, reading only this workflow's + * state, identity mapping and block pairs, so a preview costs the same in a + * workspace of five workflows as in one of five hundred. * * Both states carry their raw sub-block values, on par with the deployment * version route the same session UI already reads; the caller holds admin on @@ -115,41 +113,43 @@ export const getWorkspaceSyncWorkflowDiff = defineForkUseCase({ const sourceWorkspaceId = direction === 'push' ? id : input.otherWorkspaceId const targetWorkspaceId = direction === 'push' ? input.otherWorkspaceId : id - const [{ deployedWorkflows, sourceStates }, blockMap] = await Promise.all([ - loadSourceDeployedStates(sourceWorkspaceId), - loadForkBlockMap(db, edge.childWorkspaceId), - ]) - const plan = await computeForkPromotePlan({ - executor: db, - edge, - sourceWorkspaceId, - targetWorkspaceId, - direction, - deployedSourceWorkflows: deployedWorkflows, - sourceStates, - }) - - const item = plan.items.find((candidate) => candidate.sourceWorkflowId === sourceWorkflowId) - const sourceState = sourceStates.get(sourceWorkflowId) - if (!item || !sourceState) { + const source = await loadSourceDeployedWorkflow(sourceWorkspaceId, sourceWorkflowId) + const item = source + ? await resolveForkPlanItem({ + executor: db, + edge, + sourceWorkspaceId, + targetWorkspaceId, + source: source.summary, + }) + : null + if (!source || !item) { throw new OrchestrationError('not_found', 'That workflow is not part of this sync') } + /* + * A create has no target yet and its plan id is provisional, so there is no + * `before`, no recorded block pairs (every id derives) and no id to report. + */ const sourceIsParent = sourceWorkspaceId === edge.parentWorkspaceId + const [blockMap, before] = + item.mode === 'replace' + ? await Promise.all([ + loadForkBlockMap(db, edge.childWorkspaceId, { + side: sourceIsParent ? 'child' : 'parent', + workflowId: item.targetWorkflowId, + }), + loadTargetDraftState(item.targetWorkflowId, targetWorkspaceId), + ]) + : [{ parentToChild: new Map(), childToParent: new Map() }, null] + if (item.mode === 'replace' && !before) { + throw new OrchestrationError('not_found', 'The target workflow could not be loaded') + } const resolveBlockId = buildForkBlockIdResolver(sourceIsParent, blockMap) - const after = remapWorkflowStateBlockIds(sourceState, (blockId) => + const after = remapWorkflowStateBlockIds(source.state, (blockId) => resolveBlockId(item.targetWorkflowId, blockId) ) - /* A create has no target yet, and its plan id is provisional, so neither is reported. */ - let before: WorkflowState | null = null - if (item.mode === 'replace') { - before = await loadTargetDraftState(item.targetWorkflowId, targetWorkspaceId) - if (!before) { - throw new OrchestrationError('not_found', 'The target workflow could not be loaded') - } - } - const sourceName = item.sourceMeta.name const targetName = item.targetName ?? sourceName return { diff --git a/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx b/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx index 072398ac568..0557905ab1d 100644 --- a/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx +++ b/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx @@ -1,6 +1,14 @@ 'use client' -import { type Dispatch, Fragment, type SetStateAction, useMemo, useState } from 'react' +import { + type Dispatch, + Fragment, + lazy, + type SetStateAction, + Suspense, + useMemo, + useState, +} from 'react' import { Badge, Button, @@ -25,10 +33,6 @@ import type { ForkTriggerMapping, ForkWorkflowChange, } from '@/lib/api/contracts/workspace-fork' - -/** A change row that names a deployed source workflow, so it can be previewed block for block. */ -type ForkWorkflowPreviewChange = Extract - import type { SelectorKey } from '@/lib/selectors/manifest' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' @@ -58,7 +62,6 @@ import { getDisplayedDependentFields, isDependentConfigurationActionable, } from '@/ee/workspace-forking/components/fork-sync/dependent-value' -import { ForkWorkflowDiffModal } from '@/ee/workspace-forking/components/fork-sync/fork-workflow-diff-modal' import type { ForkKindSummary, ForkMappingGroup, @@ -68,6 +71,18 @@ import type { ForkDirection } from '@/ee/workspace-forking/hooks/workspace-fork' import { forkSyncBlockerReasonFor } from '@/ee/workspace-forking/lib/promote/sync-blockers' import { buildWebhookTriggerUrl } from '@/triggers/webhook-url' +/** A change row that names a deployed source workflow, so it can be previewed block for block. */ +type ForkWorkflowPreviewChange = Extract & { + sourceWorkflowId: string +} + +/** The comparison canvas is heavy and rarely opened, so it stays out of the settings bundle. */ +const ForkWorkflowDiffModal = lazy(() => + import('@/ee/workspace-forking/components/fork-sync/fork-workflow-diff-modal').then((module) => ({ + default: module.ForkWorkflowDiffModal, + })) +) + /** * Copyable kinds as expandable rows in the "Copy resources" section, ordered + labeled to match * the fork modal's resource picker exactly. Files nest in a folder ▸ file tree; every other kind @@ -878,18 +893,20 @@ export function ForkSyncView({ controller, onDirectionChange }: ForkSyncViewProp Unsynced workflows list greyed at the end, with a tooltip naming which workspace they are unsynced in - the sync will not touch them. */} {diffWorkflow && controller.otherWorkspaceId && ( - { - if (!open) setDiffWorkflow(null) - }} - workspaceId={controller.workspaceId} - otherWorkspaceId={controller.otherWorkspaceId} - direction={controller.direction} - sourceWorkflowId={diffWorkflow.sourceWorkflowId} - workflowName={diffWorkflow.currentName} - /> + + { + if (!open) setDiffWorkflow(null) + }} + workspaceId={controller.workspaceId} + otherWorkspaceId={controller.otherWorkspaceId} + direction={controller.direction} + sourceWorkflowId={diffWorkflow.sourceWorkflowId} + workflowName={diffWorkflow.currentName} + /> + )} {controller.hasDiff ? ( @@ -899,6 +916,10 @@ export function ForkSyncView({ controller, onDirectionChange }: ForkSyncViewProp
{controller.workflowChanges.map((change, index) => { const renamed = change.currentName !== change.otherName + const preview: ForkWorkflowPreviewChange | null = + change.action !== 'archive' && change.sourceWorkflowId + ? { ...change, sourceWorkflowId: change.sourceWorkflowId } + : null return (
) : null} - {change.action !== 'archive' && controller.otherWorkspaceId && ( + {preview && controller.otherWorkspaceId && (
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx index aaec58a460c..79b4c460805 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx @@ -632,6 +632,7 @@ function WorkflowPreviewBlockInner({ data }: NodeProps value={lightweight ? undefined : getDisplayValue(rawValues.context)} workflowMap={workflowMap} workflowLabelsReady={workflowLabelsReady} + changed={changedFieldSet.has('context')} /> {routerRows.map((route, index) => ( { + /* + * Ghosts go first: edges sharing a z-index paint in array order, and a + * Loop/Parallel target gives its live and removed edges the same one, so + * order is what keeps a ghost under the live line into that container. + */ + const ordered = normalizeWorkflowEdgeHandles(workflowState.edges) + const edgesInPaintOrder = [ + ...ordered.filter((edge) => edgeDiffStatus?.[edge.id] === 'removed'), + ...ordered.filter((edge) => edgeDiffStatus?.[edge.id] !== 'removed'), + ] + return edgesInPaintOrder.map((edge) => { const status = getEdgeExecutionStatus(edge) const isErrorEdge = edge.sourceHandle === 'error' const isGhost = edgeDiffStatus?.[edge.id] === 'removed' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/change-list.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/change-list.tsx index 5d0a7f18e19..6d9fc504318 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/change-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/change-list.tsx @@ -29,7 +29,7 @@ import { splitEnvironmentBindings, } from '@/app/workspace/[workspaceId]/w/components/workflow-diff/utils' import { BlockTile } from '@/blocks/block-tile' -import type { BlockState } from '@/stores/workflows/workflow/types' +import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types' const STATUS_BADGE_VARIANT: Record = { added: 'green', @@ -42,6 +42,11 @@ interface ChangeListProps { /** The two sides, so cards can show an added or removed block's fields and detect moves */ baseBlocks: Record targetBlocks: Record + /** Each side's loop and parallel configs, so an added or removed container shows what it runs */ + containers: { + base: Pick + target: Pick + } selectedBlockId: string | null onSelectBlock: Dispatch> /** @@ -67,13 +72,14 @@ export function ChangeList({ summary, baseBlocks, targetBlocks, + containers, selectedBlockId, onSelectBlock, environmentBindings = false, }: ChangeListProps) { const entries = useMemo( - () => listBlockChanges(summary, baseBlocks, targetBlocks), - [summary, baseBlocks, targetBlocks] + () => listBlockChanges(summary, baseBlocks, targetBlocks, containers), + [summary, baseBlocks, targetBlocks, containers] ) const blocks = useMemo(() => ({ ...baseBlocks, ...targetBlocks }), [baseBlocks, targetBlocks]) const cardRefs = useRef>(null) @@ -224,7 +230,13 @@ const BlockCard = memo(function BlockCard({ nested = false, }: BlockCardProps) { const [collapsed, setCollapsed] = useState(false) + const [seenSelection, setSeenSelection] = useState(selectedBlockId) const selected = selectedBlockId === entry.id + /* Selecting a block nested in this card on the canvas opens the card so its row can show. */ + if (seenSelection !== selectedBlockId) { + setSeenSelection(selectedBlockId) + if (selectedBlockId !== null && !selected) setCollapsed(false) + } const block = blocks[entry.id] const setCardRef = useCallback( (node: HTMLDivElement | null) => registerCard(entry.id, node), @@ -235,9 +247,7 @@ const BlockCard = memo(function BlockCard({ const fields = entry.status === 'modified' ? entry.changes - : block - ? listOneSidedFields(block, entry.status) - : [] + : [...entry.changes, ...(block ? listOneSidedFields(block, entry.status) : [])] return environmentBindings ? splitEnvironmentBindings(entry.type, fields) : { logic: fields, bindings: [] } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/field-change-row.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/field-change-row.tsx index b9836cc0297..8ac9018857e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/field-change-row.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/field-change-row.tsx @@ -44,6 +44,20 @@ export function FieldChangeRow({ blockType, field, oldValue, newValue }: FieldCh : (ENGINE_FIELD_LABELS[field] ?? resolveFieldLabel(blockType, field)) /* A field its definition never titled comes back as the raw id; humanize it. */ const label = resolvedLabel === field ? formatParameterLabel(field) : resolvedLabel + const textual = kind === 'text' || kind === 'json' + const scalar = kind === 'scalar' || kind === 'toggle' + const oldText = textual + ? toDiffText(oldValue) + : scalar + ? formatScalar(blockType, field, oldValue) + : '' + const newText = textual + ? toDiffText(newValue) + : scalar + ? formatScalar(blockType, field, newValue) + : '' + /* The summary saw a change the masked text cannot show, so the change is inside a secret. */ + const maskedOnly = (textual || (scalar && !oneSided)) && oldText === newText return (
@@ -53,9 +67,10 @@ export function FieldChangeRow({ blockType, field, oldValue, newValue }: FieldCh {isBlankValue(oldValue) ? 'Set' : isBlankValue(newValue) ? 'Cleared' : 'Value changed'} )} - {(kind === 'text' || kind === 'json') && ( - + {maskedOnly && ( + A masked value changed )} + {textual && !maskedOnly && } {kind === 'messages' && } {kind === 'list' && ( )} - {(kind === 'scalar' || kind === 'toggle') && + {scalar && + !maskedOnly && (oneSided ? ( ) : wordDiff ? ( - + ) : ( - + ))}
) @@ -112,7 +122,10 @@ function MessagesDiff({ oldValue, newValue }: MessagesDiffProps) { {slots.map((slot) => (
- {slot.next?.role ?? slot.old?.role} message + {slot.old && slot.next && slot.old.role !== slot.next.role + ? `${slot.old.role} → ${slot.next.role}` + : (slot.next?.role ?? slot.old?.role)}{' '} + message {!slot.old && ' (added)'} {!slot.next && ' (removed)'} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/keyed-list-diff.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/keyed-list-diff.tsx index b023da68fa8..11662bb104d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/keyed-list-diff.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/keyed-list-diff.tsx @@ -9,6 +9,7 @@ import { TextDiff } from '@/app/workspace/[workspaceId]/w/components/workflow-di import { describeListItems, isPositionalListField, + listOrderChanged, pairListItems, toItemList, } from '@/app/workspace/[workspaceId]/w/components/workflow-diff/utils' @@ -29,11 +30,14 @@ export function KeyedListDiff({ blockType, field, oldValue, newValue }: KeyedLis const oldItems = describeListItems(blockType, field, toItemList(oldValue) ?? []) const newItems = describeListItems(blockType, field, toItemList(newValue) ?? []) const rows = pairListItems(oldItems, newItems, isPositionalListField(blockType, field)) + const reordered = rows.length === 0 && listOrderChanged(oldItems, newItems) return (
{rows.length === 0 && ( - Order changed + + {reordered ? 'Order changed' : 'Same items, stored differently'} + )} {rows.map((row, index) => (
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/text-diff-lines.test.ts b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/text-diff-lines.test.ts index ce31fdb0502..5f0df837d27 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/text-diff-lines.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/text-diff-lines.test.ts @@ -160,4 +160,16 @@ describe('buildDiffRows', () => { expect(lines.map((line) => line.kind)).toEqual(['removed', 'added']) expect(lines.every((line) => line.parts === undefined)).toBe(true) }) + it('reads CRLF and bare CR endings as line breaks', () => { + const rows = buildDiffRows('a\r\nb\rc', 'a\nb\nd') + + expect( + rows.map((row) => (row.type === 'line' ? [row.line.kind, row.line.text] : row.type)) + ).toEqual([ + ['context', 'a'], + ['context', 'b'], + ['removed', 'c'], + ['added', 'd'], + ]) + }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/text-diff-lines.ts b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/text-diff-lines.ts index aa2b69ba33f..e8fa856aa36 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/text-diff-lines.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/text-diff-lines.ts @@ -96,6 +96,10 @@ export function markWordChanges(lines: DiffLine[]): DiffLine[] { return out } +function normalizeLineEndings(value: string): string { + return value.replace(/\r\n?/g, '\n') +} + export function splitLines(value: string): string[] { const lines = value.split('\n') if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop() @@ -158,7 +162,10 @@ export function capOneSided(lines: DiffLine[]): DiffRow[] { * the one-sided cap, or a single summary row when the bodies are too long to * diff inline. */ -export function buildDiffRows(oldText: string, newText: string): DiffRow[] { +export function buildDiffRows(rawOld: string, rawNew: string): DiffRow[] { + /* Stored text may carry CRLF or bare CR endings; diff them as the lines they display as. */ + const oldText = normalizeLineEndings(rawOld) + const newText = normalizeLineEndings(rawNew) const oldLines = splitLines(oldText).length const newLines = splitLines(newText).length if (oldLines + newLines > MAX_DIFF_LINES) return [{ type: 'oversized', oldLines, newLines }] diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/text-diff.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/text-diff.tsx index 069101ea127..8a883697159 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/text-diff.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/text-diff.tsx @@ -73,7 +73,11 @@ export function TextDiff({ oldText, newText }: TextDiffProps) { {foldCount > MANY_FOLDS && (
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/workflow-diff-view.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/workflow-diff-view.tsx index 2b818a59c10..ec9de406540 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/workflow-diff-view.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/workflow-diff-view.tsx @@ -62,12 +62,6 @@ export function WorkflowDiffView({ environmentBindings = false, }: WorkflowDiffViewProps) { const [selectedBlockId, setSelectedBlockId] = useState(null) - /* A selection belongs to one comparison; picking another version starts unselected. */ - const [selectionFor, setSelectionFor] = useState({ baseState, targetState }) - if (selectionFor.baseState !== baseState || selectionFor.targetState !== targetState) { - setSelectionFor({ baseState, targetState }) - setSelectedBlockId(null) - } const [listElement, setListElement] = useState(null) const listEdges = useScrollEdges(listElement) diff --git a/apps/sim/ee/workspace-forking/application/sync-workflow-diff.test.ts b/apps/sim/ee/workspace-forking/application/sync-workflow-diff.test.ts index a1fae06eb8d..3fa8c005c8e 100644 --- a/apps/sim/ee/workspace-forking/application/sync-workflow-diff.test.ts +++ b/apps/sim/ee/workspace-forking/application/sync-workflow-diff.test.ts @@ -79,10 +79,10 @@ function planItem(overrides: Record = {}) { } } -/** The source loader, answering for `wf-src` only, as the real one answers for sync sources only. */ +/** The source loader, answering for `wf-src` in the child only, as the real one answers for sync sources only. */ function sourceIs(sourceState: WorkflowState | null) { - mocks.loadSourceDeployedWorkflow.mockImplementation(async (_workspaceId: string, id: string) => - id === 'wf-src' && sourceState + mocks.loadSourceDeployedWorkflow.mockImplementation(async (workspaceId: string, id: string) => + workspaceId === 'child' && id === 'wf-src' && sourceState ? { summary: { id, name: 'Support Agent' }, state: sourceState } : null ) @@ -118,13 +118,27 @@ describe('getWorkspaceSyncWorkflowDiff', () => { allowPersonalApiKeys: false, })) workspaceForkingLineageMockFns.mockResolveForkEdge.mockResolvedValue(edge) - mocks.loadForkBlockMap.mockResolvedValue({ - parentToChild: new Map(), - childToParent: new Map([['b1', { targetBlockId: 'mapped-b1', targetWorkflowId: 'wf-tgt' }]]), - }) + /* Pairs come back only for a read scoped to the target workflow, so a mapped id proves the scope. */ + mocks.loadForkBlockMap.mockImplementation( + async (_db: unknown, child: string, scope?: { side: string; workflowId: string }) => ({ + parentToChild: new Map(), + childToParent: + child === 'child' && scope?.side === 'parent' && scope.workflowId === 'wf-tgt' + ? new Map([['b1', { targetBlockId: 'mapped-b1', targetWorkflowId: 'wf-tgt' }]]) + : new Map(), + }) + ) sourceIs(state({ b1: {}, b2: {} }, [{ id: 'e', source: 'b1', target: 'b2' }])) - mocks.resolveForkPlanItem.mockResolvedValue(planItem()) - mocks.loadTargetDraftState.mockResolvedValue(state({ 'mapped-b1': {} })) + /* The item only resolves for the child-to-parent orientation both directions use here. */ + mocks.resolveForkPlanItem.mockImplementation( + async (params: { sourceWorkspaceId: string; targetWorkspaceId: string }) => + params.sourceWorkspaceId === 'child' && params.targetWorkspaceId === 'parent' + ? planItem() + : null + ) + mocks.loadTargetDraftState.mockImplementation(async (id: string, workspaceId: string) => + id === 'wf-tgt' && workspaceId === 'parent' ? state({ 'mapped-b1': {} }) : null + ) }) it('re-keys the source through the block map and derives ids for unmapped blocks', async () => { @@ -143,11 +157,6 @@ describe('getWorkspaceSyncWorkflowDiff', () => { afterLabel: 'Support Agent (deployed)', }) expect(result.before).toEqual(state({ 'mapped-b1': {} })) - expect(mocks.loadTargetDraftState).toHaveBeenCalledWith('wf-tgt', 'parent') - expect(mocks.loadForkBlockMap).toHaveBeenCalledWith(expect.anything(), 'child', { - side: 'parent', - workflowId: 'wf-tgt', - }) }) it('re-keys source variables and their assignments to the target ids by unique name', async () => { @@ -196,8 +205,6 @@ describe('getWorkspaceSyncWorkflowDiff', () => { expect(result.before).toBeNull() expect(result.targetWorkflowId).toBeNull() expect(result.beforeLabel).toBe('Support Agent (current)') - expect(mocks.loadTargetDraftState).not.toHaveBeenCalled() - expect(mocks.loadForkBlockMap).not.toHaveBeenCalled() }) it('fails instead of guessing when a replaced target cannot be loaded', async () => { @@ -210,35 +217,29 @@ describe('getWorkspaceSyncWorkflowDiff', () => { await expect(run({ sourceWorkflowId: 'foreign' })).rejects.toMatchObject({ code: 'not_found', }) - expect(mocks.resolveForkPlanItem).not.toHaveBeenCalled() - expect(mocks.loadTargetDraftState).not.toHaveBeenCalled() }) it('rejects a source whose target is excluded from sync, as the promote skips it', async () => { mocks.resolveForkPlanItem.mockResolvedValue(null) await expect(run()).rejects.toMatchObject({ code: 'not_found' }) - expect(mocks.loadTargetDraftState).not.toHaveBeenCalled() }) it('refuses two admin workspaces that are not a direct fork edge', async () => { workspaceForkingLineageMockFns.mockResolveForkEdge.mockResolvedValue(null) await expect(run()).rejects.toMatchObject({ code: 'validation' }) - expect(mocks.loadSourceDeployedWorkflow).not.toHaveBeenCalled() }) it('reads the other side as the source on a pull and pairs through the child block map', async () => { - await run({ workspaceId: 'parent', otherWorkspaceId: 'child', direction: 'pull' }) - - expect(mocks.loadSourceDeployedWorkflow).toHaveBeenCalledWith('child', 'wf-src') - expect(mocks.resolveForkPlanItem).toHaveBeenCalledWith( - expect.objectContaining({ sourceWorkspaceId: 'child', targetWorkspaceId: 'parent' }) - ) - expect(mocks.loadForkBlockMap).toHaveBeenCalledWith(expect.anything(), 'child', { - side: 'parent', - workflowId: 'wf-tgt', + const result = await run({ + workspaceId: 'parent', + otherWorkspaceId: 'child', + direction: 'pull', }) + + expect(result.targetWorkflowId).toBe('wf-tgt') + expect(Object.keys(result.after.blocks)).toContain('mapped-b1') }) it('refuses when the caller is not an admin on the other side', async () => { @@ -251,6 +252,5 @@ describe('getWorkspaceSyncWorkflowDiff', () => { ) await expect(run()).rejects.toMatchObject({ code: 'forbidden' }) - expect(mocks.loadSourceDeployedWorkflow).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/ee/workspace-forking/lib/promote/sync-preview.test.ts b/apps/sim/ee/workspace-forking/lib/promote/sync-preview.test.ts index 7e59d58a512..2339b0d8275 100644 --- a/apps/sim/ee/workspace-forking/lib/promote/sync-preview.test.ts +++ b/apps/sim/ee/workspace-forking/lib/promote/sync-preview.test.ts @@ -118,13 +118,9 @@ describe('listUnchangedSyncSources', () => { }) expect([...unchanged]).toEqual(['s-same']) - expect(mocks.forEachTargetDraft).toHaveBeenCalledWith( - ['t-same', 't-diff'], - 'ws-t', - expect.any(Function) - ) }) + /* `s-same` would be listed if any draft were read, so an empty result shows none was. */ it('reads no drafts and treats everything as changed when the drafts exceed the limit', async () => { mocks.measureTargetDraftBytes.mockResolvedValue(5000) @@ -136,6 +132,5 @@ describe('listUnchangedSyncSources', () => { }) expect(unchanged.size).toBe(0) - expect(mocks.forEachTargetDraft).not.toHaveBeenCalled() }) }) From 5c28dd5f7b4f34ca7a34c6c7e477c8a7663447af Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Wed, 30 Sep 2026 01:19:43 -0700 Subject: [PATCH 13/14] fix(workflows): only call a message change masked when the message exists on both sides --- .../components/change-list/field-change-row.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/field-change-row.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/field-change-row.tsx index 156126a3b8e..680481156d5 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/field-change-row.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/field-change-row.tsx @@ -121,7 +121,10 @@ function MessagesDiff({ oldValue, newValue }: MessagesDiffProps) { const oldText = toDiffText(slot.old?.content) const newText = toDiffText(slot.next?.content) /* The content changed but masking hides where, as with any other field. */ - const maskedOnly = oldText === newText && slot.old?.content !== slot.next?.content + const maskedOnly = + Boolean(slot.old && slot.next) && + oldText === newText && + slot.old?.content !== slot.next?.content return { ...slot, oldText, newText, maskedOnly } }) From 64529e4d2d001bd57584d5de181acea88cbf818b Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Wed, 30 Sep 2026 01:39:42 -0700 Subject: [PATCH 14/14] test(workflows): use absolute imports in new tests --- .../deploy-modal/components/general/compare-pair.test.ts | 2 +- .../components/change-list/text-diff-lines.test.ts | 2 +- .../w/components/workflow-diff/utils.test.ts | 8 ++++---- apps/sim/lib/workflows/comparison/compare.test.ts | 4 ++-- apps/sim/lib/workflows/comparison/overlay.test.ts | 7 +++++-- 5 files changed, 13 insertions(+), 10 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/compare-pair.test.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/compare-pair.test.ts index 9003d47f729..bdfc5498c81 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/compare-pair.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/compare-pair.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { resolveComparePair } from './compare-pair' +import { resolveComparePair } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/compare-pair' describe('resolveComparePair', () => { it('puts the older version on the base side whichever was clicked', () => { diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/text-diff-lines.test.ts b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/text-diff-lines.test.ts index 5f0df837d27..72849a14fc1 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/text-diff-lines.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/text-diff-lines.test.ts @@ -12,7 +12,7 @@ import { ONE_SIDED_VISIBLE_LINES, splitLines, toLines, -} from './text-diff-lines' +} from '@/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/text-diff-lines' const context = (count: number): DiffLine[] => Array.from({ length: count }, (_, index) => ({ kind: 'context', text: `ctx ${index}` })) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/utils.test.ts b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/utils.test.ts index 791956e8b1f..287ec55dd70 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/utils.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/utils.test.ts @@ -3,9 +3,6 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { WorkflowDiffSummary } from '@/lib/workflows/comparison' -import { getBlock } from '@/blocks/registry' -import type { SubBlockConfig } from '@/blocks/types' -import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types' import { classifyChange, describeListItems, @@ -19,7 +16,10 @@ import { toDiffText, toItemList, toMessageList, -} from './utils' +} from '@/app/workspace/[workspaceId]/w/components/workflow-diff/utils' +import { getBlock } from '@/blocks/registry' +import type { SubBlockConfig } from '@/blocks/types' +import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types' /** The global registry mock returns no sub-blocks; tests declare the ones they need per block type. */ function declareSubBlocks(byType: Record[]>) { diff --git a/apps/sim/lib/workflows/comparison/compare.test.ts b/apps/sim/lib/workflows/comparison/compare.test.ts index 2172263f584..419054bc6b9 100644 --- a/apps/sim/lib/workflows/comparison/compare.test.ts +++ b/apps/sim/lib/workflows/comparison/compare.test.ts @@ -6,14 +6,14 @@ import { createWorkflowState as createTestWorkflowState, } from '@sim/testing' import { describe, expect, it } from 'vitest' -import type { WorkflowState } from '@/stores/workflows/workflow/types' import { containerConfigFields, generateWorkflowDiffSummary, hasWorkflowChanged, omitPresentationChanges, type WorkflowDiffSummary, -} from './compare' +} from '@/lib/workflows/comparison/compare' +import type { WorkflowState } from '@/stores/workflows/workflow/types' /** * Type helper for converting test workflow state to app workflow state. diff --git a/apps/sim/lib/workflows/comparison/overlay.test.ts b/apps/sim/lib/workflows/comparison/overlay.test.ts index 5a17a8ee56d..3418c9b993c 100644 --- a/apps/sim/lib/workflows/comparison/overlay.test.ts +++ b/apps/sim/lib/workflows/comparison/overlay.test.ts @@ -2,9 +2,12 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' +import { + generateWorkflowDiffSummary, + type WorkflowDiffSummary, +} from '@/lib/workflows/comparison/compare' +import { buildWorkflowDiffOverlay } from '@/lib/workflows/comparison/overlay' import type { WorkflowState } from '@/stores/workflows/workflow/types' -import { generateWorkflowDiffSummary, type WorkflowDiffSummary } from './compare' -import { buildWorkflowDiffOverlay } from './overlay' /** Overlay only reads position, data and ids; the cast keeps the fixtures short. */ function asState(state: Record): WorkflowState {