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..dd21e77ecdc --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/fork/workflow-diff/route.test.ts @@ -0,0 +1,181 @@ +/** + * @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(() => ({ + loadSourceDeployedWorkflow: vi.fn(), + loadTargetDraftState: vi.fn(), + loadForkBlockMap: vi.fn(), + resolveForkPlanItem: 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', () => ({ + 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', () => ({ + resolveForkPlanItem: mocks.resolveForkPlanItem, +})) + +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(() => { + 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.loadSourceDeployedWorkflow.mockImplementation(async (_ws: string, id: string) => + id === 'wf-src' ? { summary: { id, name: 'Support Agent' }, state: emptyState } : null + ) + mocks.resolveForkPlanItem.mockResolvedValue({ + sourceWorkflowId: 'wf-src', + targetWorkflowId: 'wf-tgt', + targetName: null, + mode: 'create', + sourceMeta: { name: 'Support Agent' }, + }) + }) + + 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) + }) + + 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) + }) + + 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) + }) + + 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: null, + before: null, + after: emptyState, + beforeLabel: 'Support Agent (current)', + afterLabel: 'Support Agent (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('returns the target draft as before when the sync replaces a workflow', async () => { + const targetState = { + ...emptyState, + blocks: {}, + } + mocks.resolveForkPlanItem.mockResolvedValue({ + sourceWorkflowId: 'wf-src', + targetWorkflowId: 'wf-tgt', + targetName: 'Support Agent prod', + mode: 'replace', + sourceMeta: { name: 'Support Agent' }, + }) + /* Only the replaced target in the target workspace has a draft; any other read is a 404. */ + mocks.loadTargetDraftState.mockImplementation(async (id: string, workspaceId: string) => + id === 'wf-tgt' && workspaceId === 'parent' ? targetState : null + ) + + const response = await GET( + request({ otherWorkspaceId: 'parent', direction: 'push', sourceWorkflowId: 'wf-src' }), + routeContext + ) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toMatchObject({ + targetWorkflowId: 'wf-tgt', + before: targetState, + beforeLabel: 'Support Agent 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 new file mode 100644 index 00000000000..cac548176f9 --- /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.user({ bucketName: 'workspace-fork-workflow-diff' }), + errorPolicy: internalForkErrorPolicy, + mapInput: ({ params, query }) => ({ workspaceId: params.id, ...query }), + useCase: getWorkspaceSyncWorkflowDiff, + present: (result) => result, +}) 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..bdfc5498c81 --- /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 '@/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', () => { + 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..b8feeb9b28c --- /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,155 @@ +'use client' + +import { useId, useMemo, useState } from 'react' +import { + ChipDropdown, + type ChipDropdownOption, + ChipModal, + ChipModalBody, + ChipModalHeader, +} 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 { + WorkflowDiffSkeleton, + 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' + +/** The visible label of the selected option, so each picker's accessible name says what it holds. */ +function optionLabel(options: ChipDropdownOption[], value: string): string { + const label = options.find((option) => option.value === value)?.label + return typeof label === 'string' ? label : '' +} + +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' + aria-label={`Compare from ${optionLabel(options, sideToValue(base))}`} + /> + + setTarget(valueToSide(value))} + align='start' + aria-label={`Compare to ${optionLabel(options, sideToValue(target))}`} + /> +
+
+ +

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

+ {loadError ? ( +
+ {loadError.message || 'Could not load one of the versions.'} +
+ ) : isLoading || !baseState || !targetState ? ( + + ) : ( + /* One view per picked pair, so selection and folds start fresh when either side changes. */ + + )} +
+
+ ) +} 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..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 +1,2 @@ +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/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..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,6 +17,10 @@ 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' @@ -27,6 +31,13 @@ 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 @@ -37,6 +48,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,11 +69,13 @@ export function GeneralDeploy({ versionsLoading, isPromotingVersion, deployReadiness, + needsRedeployment, onPromoteToLive, onLoadDeploymentComplete, onLoadDeploymentBlocked, }: GeneralDeployProps) { const expandedPreviewDescriptionId = useId() + const [comparePair, setComparePair] = useState(null) const [selectedVersion, setSelectedVersion] = useState(null) const [showActiveDespiteSelection, setShowActiveDespiteSelection] = useState(false) const previewMode: PreviewMode = @@ -76,6 +91,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) @@ -198,11 +219,22 @@ export function GeneralDeploy({
- +
+ + {needsRedeployment && activeVersion !== null && !showToggle && ( + + )} +
@@ -326,6 +359,22 @@ 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..32853affe17 --- /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 `false` as `enabled` 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]) +} 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..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 @@ -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' ? ( @@ -582,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) => ( value={lightweight ? undefined : getDisplayValue(route.value)} workflowMap={workflowMap} workflowLabelsReady={workflowLabelsReady} + changed={changedFieldSet.has('routes')} /> ))} @@ -610,6 +662,7 @@ function WorkflowPreviewBlockInner({ data }: NodeProps rawValue={rawValue} workflowMap={workflowMap} workflowLabelsReady={workflowLabelsReady} + changed={changedFieldSet.has(subBlock.id)} /> ) }) @@ -686,6 +739,13 @@ function WorkflowPreviewBlockInner({ data }: NodeProps ) } +/** 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]) +} + /** * Custom comparison function for React.memo optimization. * Uses fast-path primitive comparison before shallow comparing subBlockValues. @@ -707,7 +767,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..55fcbf70130 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/diff-label/diff-label.tsx @@ -0,0 +1,41 @@ +'use client' + +import { cn } from '@sim/emcn' +import type { BlockDiffStatus } from '@/lib/workflows/comparison' + +/** 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', +} + +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..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,8 +1,11 @@ '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' +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 +22,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 +36,7 @@ interface WorkflowPreviewSubflowData extends Record { type WorkflowPreviewSubflowNode = Node function WorkflowPreviewSubflowInner({ data, id }: NodeProps) { - return ( + const view = ( undefined} /> ) + if (!data.diffStatus) return view + /* Same label as a card; 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-dimensions.ts b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-dimensions.ts new file mode 100644 index 00000000000..127bb766503 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-dimensions.ts @@ -0,0 +1,30 @@ +import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@sim/workflow-renderer' +import { estimateBlockDimensions } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils' +import type { BlockState } from '@/stores/workflows/workflow/types' + +/** + * The size a block is drawn at on the read-only canvas: stored values when + * present, clamped to the minimums, else the type's estimate. Shared with the + * comparison overlay so its ghost collision boxes match what is drawn. + */ +export function getPreviewBlockDimensions(block: BlockState): { width: number; height: number } { + if (block.type === 'loop' || block.type === 'parallel') { + return { + width: block.data?.width + ? Math.max(block.data.width, CONTAINER_DIMENSIONS.MIN_WIDTH) + : CONTAINER_DIMENSIONS.DEFAULT_WIDTH, + height: block.data?.height + ? Math.max(block.data.height, CONTAINER_DIMENSIONS.MIN_HEIGHT) + : CONTAINER_DIMENSIONS.DEFAULT_HEIGHT, + } + } + + if (block.height) { + return { + width: BLOCK_DIMENSIONS.FIXED_WIDTH, + height: Math.max(block.height, BLOCK_DIMENSIONS.MIN_HEIGHT), + } + } + + return estimateBlockDimensions(block.type) +} 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..960fe5b9fba 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 @@ -17,7 +17,6 @@ import '@xyflow/react/dist/style.css' import { cn } from '@sim/emcn' import { createLogger } from '@sim/logger' import { - BLOCK_DIMENSIONS, BLOCK_Z_BASE, CANVAS_Z_INDEX_MODE, CONTAINER_CHILD_Z_BASE, @@ -29,42 +28,18 @@ 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, - SUBFLOW_CHILD_NODE_CLASS, -} from '@/app/workspace/[workspaceId]/w/[workflowId]/utils' +import { SUBFLOW_CHILD_NODE_CLASS } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils' import { PreviewBlock } from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block' import { PreviewSubflow } from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/subflow' +import { getPreviewBlockDimensions } from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-dimensions' import { useWorkflowMap } from '@/hooks/queries/workflows' import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types' import '@/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.css' const logger = createLogger('PreviewWorkflow') -/** Gets block dimensions, using stored values or defaults. */ -function getPreviewBlockDimensions(block: BlockState): { width: number; height: number } { - if (block.type === 'loop' || block.type === 'parallel') { - return { - width: block.data?.width - ? Math.max(block.data.width, CONTAINER_DIMENSIONS.MIN_WIDTH) - : CONTAINER_DIMENSIONS.DEFAULT_WIDTH, - height: block.data?.height - ? Math.max(block.data.height, CONTAINER_DIMENSIONS.MIN_HEIGHT) - : CONTAINER_DIMENSIONS.DEFAULT_HEIGHT, - } - } - - if (block.height) { - return { - width: BLOCK_DIMENSIONS.FIXED_WIDTH, - height: Math.max(block.height, BLOCK_DIMENSIONS.MIN_HEIGHT), - } - } - - return estimateBlockDimensions(block.type) -} - /** Calculates container dimensions from child block positions. */ function calculateContainerDimensions( containerId: string, @@ -168,6 +143,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 +227,9 @@ export function PreviewWorkflow({ executedBlocks, selectedBlockId, lightweight = false, + blockDiffStatus, + edgeDiffStatus, + changedFieldsByBlock, }: PreviewWorkflowProps) { const params = useParams<{ workspaceId: string }>() const workspaceId = propWorkspaceId ?? params.workspaceId @@ -444,6 +428,7 @@ export function PreviewWorkflow({ enabled: block.enabled ?? true, isPreviewSelected: isSelected, executionStatus: subflowExecutionStatus, + diffStatus: blockDiffStatus?.[blockId], lightweight, }, }) @@ -493,6 +478,8 @@ export function PreviewWorkflow({ errorEnabled: block.errorEnabled === true, hasErrorConnection: blocksWithErrorEdge.has(blockId), lightweight, + diffStatus: blockDiffStatus?.[blockId], + changedFields: changedFieldsByBlock?.[blockId], }, }) }) @@ -511,6 +498,8 @@ export function PreviewWorkflow({ workflowLabelsReady, errorSourceBlockKey, lightweight, + blockDiffStatus, + changedFieldsByBlock, ]) const edges: Edge[] = useMemo(() => { @@ -570,11 +559,28 @@ export function PreviewWorkflow({ * whose handle matches no mounted handle, so without this the preview * renders the cards with no lines between them. */ - return normalizeWorkflowEdgeHandles(workflowState.edges).map((edge) => { + /* + * 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 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 +595,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 +613,7 @@ export function PreviewWorkflow({ isValidWorkflowState, blockExecutionMap, getBlockExecutionStatus, + edgeDiffStatus, ]) if (!isValidWorkflowState) { 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..52bc169884d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/binding-change-row.tsx @@ -0,0 +1,50 @@ +'use client' + +import { OverflowText } from '@sim/emcn' +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 ( +
+ + {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 new file mode 100644 index 00000000000..6d9fc504318 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/change-list.tsx @@ -0,0 +1,421 @@ +'use client' + +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 { 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, + 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, + listBlockChanges, + listOneSidedFields, + splitEnvironmentBindings, +} from '@/app/workspace/[workspaceId]/w/components/workflow-diff/utils' +import { BlockTile } from '@/blocks/block-tile' +import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types' + +const STATUS_BADGE_VARIANT: Record = { + added: 'green', + modified: 'amber', + removed: 'red', +} + +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 + /** 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> + /** + * The two sides live in different workspaces, so credentials, picked + * resources and trigger paths differ by design; group them apart, muted. + */ + 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 + * card selects the block on the canvas and vice versa. + */ +export function ChangeList({ + summary, + baseBlocks, + targetBlocks, + containers, + selectedBlockId, + onSelectBlock, + environmentBindings = false, +}: ChangeListProps) { + const entries = useMemo( + () => listBlockChanges(summary, baseBlocks, targetBlocks, containers), + [summary, baseBlocks, targetBlocks, containers] + ) + 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) + }, []) + /* Stable across selections, so only the cards whose selection changed re-render. */ + const toggleSelected = useCallback( + (id: string) => onSelectBlock((current) => (current === id ? null : id)), + [onSelectBlock] + ) + + 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, index) => ( + + ))} + {summary.variableChanges.modifiedNames.map((name, index) => ( + + ))} + {summary.variableChanges.removedNames.map((name, index) => ( + + ))} +
+
+ )} +
+ ) +} + +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 + /** 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 + 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 [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), + [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 = + entry.status === 'modified' + ? entry.changes + : [...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 ( +
+
{ + if (event.target !== event.currentTarget) return + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + toggle() + } + }} + className={cn( + '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' + )} + > + + + + {bindingsOnly ? 'Bindings only' : DIFF_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, index) => ( + + ))} + {entry.membership?.removed.map((row, index) => ( + + ))} +
+ )} +
+ )} +
+ ) +}) + +interface NamedRowProps { + kind: DiffSignKind + 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 ( +
+ + {DIFF_SIGN[kind]} + + +
+ ) +} 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 new file mode 100644 index 00000000000..680481156d5 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/field-change-row.tsx @@ -0,0 +1,191 @@ +'use client' + +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 { + InlineDiff, + TextDiff, +} from '@/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/text-diff' +import { + classifyChange, + containerFieldLabel, + ENGINE_FIELD_LABELS, + formatScalar, + isBlankValue, + 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 wordDiff = kind === 'scalar' && isSentenceLike(oldValue) && isSentenceLike(newValue) + const resolvedLabel = isContainerType(blockType) + ? containerFieldLabel(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 + 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 ( +
+ {label} + {kind === 'secret' && ( + + {isBlankValue(oldValue) ? 'Set' : isBlankValue(newValue) ? 'Cleared' : 'Value changed'} + + )} + {maskedOnly && ( + A masked value changed + )} + {textual && !maskedOnly && } + {kind === 'messages' && } + {kind === 'list' && ( + + )} + {scalar && + !maskedOnly && + (oneSided ? ( + + ) : wordDiff ? ( + + ) : ( + + ))} +
+ ) +} + +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 + ) + .map((slot) => { + 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 = + Boolean(slot.old && slot.next) && + oldText === newText && + slot.old?.content !== slot.next?.content + return { ...slot, oldText, newText, maskedOnly } + }) + + return ( +
+ {slots.map((slot) => ( +
+ + {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)'} + + {slot.maskedOnly ? ( + A masked value changed + ) : ( + + )} +
+ ))} +
+ ) +} + +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..2900d92fe30 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/index.ts @@ -0,0 +1 @@ +export { ChangeList } from './change-list' 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..11662bb104d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/keyed-list-diff.tsx @@ -0,0 +1,90 @@ +'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, + listOrderChanged, + pairListItems, + toItemList, +} from '@/app/workspace/[workspaceId]/w/components/workflow-diff/utils' + +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)) + const reordered = rows.length === 0 && listOrderChanged(oldItems, newItems) + + return ( +
+ {rows.length === 0 && ( + + {reordered ? 'Order changed' : 'Same items, stored differently'} + + )} + {rows.map((row, index) => ( +
+ + {DIFF_SIGN[row.kind]} + +
+ + {row.label} + {row.oldLabel && ( + (was {row.oldLabel}) + )} + {row.secretChanged && ( + (a masked value changed) + )} + + {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..72849a14fc1 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/text-diff-lines.test.ts @@ -0,0 +1,175 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + buildDiffRows, + capOneSided, + type DiffLine, + foldRows, + MAX_DIFF_LINES, + markWordChanges, + ONE_SIDED_VISIBLE_LINES, + splitLines, + toLines, +} 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}` })) + +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' }, + ]) + + /* 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', + ]) + expect(added.parts?.filter((part) => part.changed).map((part) => part.value)).toEqual([ + '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' }, + { 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' } }, + ]) + }) + + 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 }, + ]) + }) + 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) + }) + 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 new file mode 100644 index 00000000000..e8fa856aa36 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/text-diff-lines.ts @@ -0,0 +1,173 @@ +import { diffLines, diffWordsWithSpace } 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 +/** + * 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 + * comparison never hangs the tab. + */ +export const MAX_DIFF_LINES = 2000 + +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[] } + | { 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. Whitespace counts as a word so an indentation-only + * edit still shows. + */ +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 = 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) + 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 +} + +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() + 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 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[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 })), + { 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, or a single summary row when the bodies are too long to + * diff inline. + */ +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 }] + 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..056c928c798 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/text-diff.tsx @@ -0,0 +1,190 @@ +'use client' + +import { useMemo, useState } from 'react' +import { cn } from '@sim/emcn' +import { diffWordsWithSpace } 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 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 + /** 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) + /* Fold indexes belong to one pair of bodies; a new comparison starts folded again. */ + const [foldsFor, setFoldsFor] = useState(rows) + if (foldsFor !== rows) { + setFoldsFor(rows) + setExpanded(new Set()) + setExpandedAll(false) + } + const foldCount = rows.filter((row) => row.type === 'fold').length + + return ( +
+ {foldCount > MANY_FOLDS && ( + + )} + {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 ( + 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) { + /* Whitespace counts, so a value that differs only by a space still shows where. */ + const parts = useMemo(() => diffWordsWithSpace(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..02eb9a10bc5 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/index.ts @@ -0,0 +1 @@ +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 new file mode 100644 index 00000000000..287ec55dd70 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/utils.test.ts @@ -0,0 +1,686 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { WorkflowDiffSummary } from '@/lib/workflows/comparison' +import { + classifyChange, + describeListItems, + formatScalar, + listBlockChanges, + listOneSidedFields, + listOrderChanged, + maskSecretsDeep, + pairListItems, + splitEnvironmentBindings, + toDiffText, + toItemList, + toMessageList, +} 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[]>) { + 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('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' }] }) + + 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), + signature: JSON.stringify({ extra: 1 }), + }, + { key: 'Item 4', label: 'Item 4', text: '', signature: '' }, + ]) + }) + + 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({ 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]).toMatchObject({ + key: 'lookup', + label: 'lookup', + text: JSON.stringify({ schema: { function: { name: 'lookup' } } }, null, 2), + }) + 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', () => { + 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('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({ + 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, + }) + }) + + 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) + ) + }) + + it('masks keys with a secret word anywhere in them and keeps credential references readable', () => { + expect( + maskSecretsDeep({ + secretValue: 'a', + awsSecretAccessKey: 'b', + aws_secret_access_key: 'c', + passwordHash: 'd', + credentialId: 'cred-1', + secretaryName: 'Ada', + }) + ).toEqual({ + secretValue: '•••', + awsSecretAccessKey: '•••', + aws_secret_access_key: '•••', + passwordHash: '•••', + credentialId: 'cred-1', + secretaryName: 'Ada', + }) + }) + + 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', () => { + 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') + /* 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":"•••"}') + }) +}) + +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('masked values in text and list fields', () => { + it('masks secrets inside a JSON-encoded string field', () => { + const stored = JSON.stringify({ Authorization: 'Bearer live-key', Accept: 'json' }) + + expect(toDiffText(stored)).not.toContain('live-key') + expect(formatScalar('function', 'headers', stored)).not.toContain('live-key') + expect(toDiffText('plain')).toBe('plain') + }) + + it('reports an input default that changed only inside a secret as a masked change', () => { + declareSubBlocks({ starter: [{ id: 'inputFormat', type: 'input-format' }] }) + const field = (token: string) => + describeListItems('starter', 'inputFormat', [ + { id: 'f', name: 'headers', type: 'object', value: { Authorization: token } }, + ]) + + const [old] = field('Bearer a') + const [next] = field('Bearer b') + + expect(old.text).toBe(next.text) + expect(pairListItems([old], [next], false)).toMatchObject([ + { kind: 'changed', secretChanged: true }, + ]) + }) + + it('tells a reordered list apart from the same items stored differently', () => { + const a = { key: 'a', label: 'a', text: '1' } + const b = { key: 'b', label: 'b', text: '2' } + + expect(listOrderChanged([a, b], [b, a])).toBe(true) + expect(listOrderChanged([a, b], [a, b])).toBe(false) + }) +}) + +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') + /* 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']) + }) + + 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) + }) +}) + +describe('listBlockChanges container settings', () => { + it("gives an added loop's card its iteration settings from the loop config", () => { + const target = { loop1: block('loop1', { type: 'loop' }) } + const containers = { + base: { loops: {}, parallels: {} }, + target: { + loops: { loop1: { id: 'loop1', nodes: [], loopType: 'for', iterations: 4 } }, + parallels: {}, + }, + } as unknown as { + base: Pick + target: Pick + } + + const [entry] = listBlockChanges( + summary({ addedBlocks: [{ id: 'loop1', type: 'loop', name: 'loop1' }], hasChanges: true }), + {}, + target, + containers + ) + + expect(entry.changes).toEqual([ + { field: 'loopType', oldValue: null, newValue: 'for' }, + { field: 'iterations', oldValue: null, newValue: 4 }, + ]) + }) +}) 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..c6adfc5d2a6 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/utils.ts @@ -0,0 +1,839 @@ +import { isContainerType } from '@/lib/workflows/autolayout' +import { + type BlockDiffStatus, + type ContainerConfigField, + containerConfigFields, + type WorkflowDiffSummary, +} from '@/lib/workflows/comparison' +import { formatValueForDisplay } from '@/lib/workflows/comparison/resolve-values' +import { getBlock } from '@/blocks/registry' +import type { BlockConfig, SubBlockConfig } from '@/blocks/types' +import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types' + +/** How a changed value should be rendered in the change 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([ + '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 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' + +/** 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 + /** The only difference is in a masked value, so the texts read the same */ + secretChanged?: boolean +} + +/** + * 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 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) + 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, + ...(previous.text === item.text && body(previous) !== body(item) + ? { secretChanged: true } + : {}), + }) + } + } + 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 +} + +/** + * Whether two item lists hold the same items in a different order. Pairing + * reports nothing for a list that only moved, so this tells a reorder apart + * from a change the item view cannot see (the same items stored differently). + */ +export function listOrderChanged(oldItems: ListItemView[], newItems: ListItemView[]): boolean { + if (oldItems.length !== newItems.length) return false + const identity = (item: ListItemView) => `${item.key}\u0000${item.signature ?? item.text}` + return oldItems.some((item, index) => identity(item) !== identity(newItems[index])) +} + +/** 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, toolName: _toolName, ...rawParams } = paramRecord + /* What the tool is allowed to do, where it runs and how it runs matter as much as its params. */ + const rawBody = filterBlank({ + ...pick(item, TOOL_EXECUTION_FIELDS), + server: serverId, + params: Object.keys(rawParams).length ? rawParams : undefined, + }) + const body = maskSecretsDeep(maskToolPasswordParams(toolType, rawBody)) + 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(rawBody).length ? JSON.stringify(body, null, 2) : '', + signature: Object.keys(rawBody).length ? JSON.stringify(rawBody) : '', + } + } + if (type === 'input-format') { + const name = readString(item, ['name']) ?? `Field ${index + 1}` + const fieldType = readString(item, ['type']) + const description = readString(item, ['description']) + const hasDefault = !isBlankValue(item.value) + const describe = (defaultText: string | undefined) => + [fieldType, description, defaultText].filter(Boolean).join(' · ') + const text = describe(hasDefault ? `default ${toDiffText(item.value)}` : undefined) + const rawDefault = + typeof item.value === 'string' ? item.value : JSON.stringify(item.value, null, 2) + /* Unmasked, so a default that differs only inside a secret still reads as changed. */ + const signature = describe(hasDefault ? `default ${rawDefault}` : undefined) + return { + key: id ?? name, + label: name, + text, + ...(signature !== text ? { signature } : {}), + } + } + 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(maskSecretsDeep(rest), null, 2) : '', + signature: Object.keys(rest).length ? JSON.stringify(rest) : '', + } + }) +} + +/** + * 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', +} + +export function containerFieldLabel(field: string): string { + return Object.hasOwn(CONTAINER_FIELD_LABELS, field) + ? CONTAINER_FIELD_LABELS[field as ContainerConfigField] + : 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. + */ +export const ENGINE_FIELD_LABELS: Record = { + 'data.canonicalModes': 'Field modes', + name: 'Name', + enabled: 'Enabled', + triggerMode: 'Trigger mode', + advancedMode: 'Advanced mode', +} + +/** A chat message as an agent block stores it. */ +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 [] +} + +/** + * 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 { + const config = getBlock(blockType) + if (!config) return undefined + let index = subBlockIndex.get(config) + if (!index) { + index = new Map() + for (const subBlock of config.subBlocks ?? []) { + if (!index.has(subBlock.id)) index.set(subBlock.id, subBlock) + } + subBlockIndex.set(config, 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) + /* 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' + } + 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 + } + /* A comparison must show the whole value: a difference at character 55 is still a difference. */ + if (typeof value === 'string') return maskEncodedSecrets(value) || formatValueForDisplay(value) + return formatValueForDisplay(maskSecretsDeep(value)) +} + +/** + * 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 '' + /* Structured values are often stored as JSON text, so a string is masked the same way. */ + if (typeof value === 'string') return maskEncodedSecrets(value) + 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. */ +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`, `apiKey` and an `Authorization` header + * are masked. + */ +const SECRET_KEY_PATTERN = + /^(auth|authorization|bearer|cookie|pwd)$|(token|secret|password|passphrase|credential|api[_-]?key|private[_-]?key|authorization)$/i +/** + * Words that make a key secret wherever they sit in it: `secretAccessKey`, + * `aws_secret_access_key`, `passwordHash`. `credential` is not one of them, so + * `credentialId` (a reference, not a secret) stays readable. + */ +const SECRET_WORDS = new Set(['secret', 'password', 'passphrase', 'passwd', 'pwd']) +const MASKED_VALUE = '•••' + +/** Splits `awsSecretAccessKey`, `client-secret` and `API_KEY` into lowercase words. */ +function keyWords(key: string): string[] { + return key + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/([A-Z])([A-Z][a-z])/g, '$1 $2') + .split(/[^A-Za-z0-9]+/) + .filter(Boolean) + .map((word) => word.toLowerCase()) +} + +function isSecretKey(key: string): boolean { + const trimmed = key.trim() + return ( + SECRET_KEY_PATTERN.test(trimmed) || keyWords(trimmed).some((word) => SECRET_WORDS.has(word)) + ) +} + +/** + * 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` + * names a secret (an API block's headers). + */ +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 + 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, entry] of Object.entries(record)) { + out[key] = + isSecretKey(key) && entry !== '' && entry != null ? MASKED_VALUE : maskSecretsDeep(entry) + } + 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 (isSecretKey(field)) continue + push(field, state?.value) + } + return out +} + +/** 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: BlockDiffStatus + 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, + /** Each side's loop and parallel configs, so an added or removed container shows what it runs */ + containers?: { + base: Pick + target: Pick + } +): 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) + 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), + 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, + sideContainers: Pick | undefined + ): BlockChangeEntry[] => { + const entries = new Map() + const children = childrenByParent(side) + for (const block of list) { + /* A container's iteration settings live beside its block, not in its sub-blocks. */ + const config = + sideContainers && isContainerType(block.type) + ? containerConfigFields(sideContainers, block.id) + : [] + entries.set(block.id, { + id: block.id, + type: block.type, + name: block.name || block.type, + status, + changes: config.map(({ field, value }) => ({ + field, + oldValue: status === 'removed' ? value : null, + newValue: status === 'added' ? value : null, + })), + 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, containers?.target), + ...nest(summary.removedBlocks, 'removed', removed, baseBlocks, containers?.base), + ] +} 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..ec9de406540 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/workflow-diff-view.tsx @@ -0,0 +1,171 @@ +'use client' + +import { useCallback, useMemo, useState } from 'react' +import { + cn, + OverflowText, + Skeleton, + scrollFadeAttributes, + scrollFadeClass, + useScrollEdges, +} from '@sim/emcn' +import { ArrowRight } from '@sim/emcn/icons' +import { + buildWorkflowDiffOverlay, + generateWorkflowDiffSummary, + omitPresentationChanges, +} from '@/lib/workflows/comparison' +import { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview' +import { getPreviewBlockDimensions } from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-dimensions' +import { ChangeList } from '@/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list' +import type { WorkflowState } from '@/stores/workflows/workflow/types' + +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 */ + 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 [listElement, setListElement] = useState(null) + const listEdges = useScrollEdges(listElement) + + const summary = useMemo( + () => omitPresentationChanges(generateWorkflowDiffSummary(targetState, baseState)), + [targetState, baseState] + ) + const overlay = useMemo( + () => buildWorkflowDiffOverlay(summary, baseState, targetState, getPreviewBlockDimensions), + [summary, baseState, targetState] + ) + const containers = useMemo( + () => ({ base: baseState, target: targetState }), + [baseState, targetState] + ) + + const handleNodeClick = useCallback((blockId: string) => { + setSelectedBlockId((current) => (current === blockId ? null : blockId)) + }, []) + 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: 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 ( +
+
+ {baseLabel && targetLabel && ( +
+ + + +
+ )} +
+ {summary.hasChanges ? ( + counts.map((count) => ( + + {count.value} {count.label} + + )) + ) : ( + No changes + )} +
+
+ +
+
+ +
+
+ +
+
+
+ ) +} diff --git a/apps/sim/ee/workspace-forking/application/sync-details.ts b/apps/sim/ee/workspace-forking/application/sync-details.ts index fd5b8d34965..6b1f2ae06b8 100644 --- a/apps/sim/ee/workspace-forking/application/sync-details.ts +++ b/apps/sim/ee/workspace-forking/application/sync-details.ts @@ -26,6 +26,7 @@ import { collectForkClearedRefCandidates, } from '@/ee/workspace-forking/lib/promote/cleared-refs' import { computeForkPromotePlan } from '@/ee/workspace-forking/lib/promote/promote-plan' +import { listUnchangedSyncSources } from '@/ee/workspace-forking/lib/promote/sync-preview' import { buildForkTriggerPlan } from '@/ee/workspace-forking/lib/promote/trigger-urls' import { buildForkBlockIdResolver } from '@/ee/workspace-forking/lib/remap/block-identity' @@ -90,6 +91,7 @@ export const getWorkspaceSyncDetails = defineForkUseCase({ sourceCandidates, sourceWorkflowRows, excludedSourceWorkflows, + unchangedSourceIds, ] = await Promise.all([ loadForkDependentValues(db, auth.edge.childWorkspaceId, allTargetIds), loadTargetDraftSubBlocks(db, replaceTargetIds), @@ -101,6 +103,14 @@ export const getWorkspaceSyncDetails = defineForkUseCase({ .where(eq(workflow.workspaceId, auth.sourceWorkspaceId)), // Deployed-but-excluded source workflows, so the preview can show what a sync skips. listForkExcludedDeployedWorkflows(db, auth.sourceWorkspaceId), + // Rows the sync would not change offer no comparison. Same projection and rule as the + // per-workflow preview, so a row and its preview never disagree. + listUnchangedSyncSources({ + items: plan.items, + sourceStates, + targetWorkspaceId: auth.targetWorkspaceId, + resolveBlockId, + }), ]) const storedByKey = new Map( storedValues.map((entry) => [ @@ -253,6 +263,8 @@ export const getWorkspaceSyncDetails = defineForkUseCase({ action: 'create' as const, currentName: item.sourceMeta.name, otherName: item.sourceMeta.name, + sourceWorkflowId: item.sourceWorkflowId, + hasChanges: true, } } const targetName = item.targetName ?? item.sourceMeta.name @@ -260,6 +272,8 @@ export const getWorkspaceSyncDetails = defineForkUseCase({ action: 'update' as const, currentName: currentIsSource ? item.sourceMeta.name : targetName, otherName: currentIsSource ? targetName : item.sourceMeta.name, + sourceWorkflowId: item.sourceWorkflowId, + hasChanges: !unchangedSourceIds.has(item.sourceWorkflowId), } }), ...plan.archivedTargets.map((target) => ({ 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..3fa8c005c8e --- /dev/null +++ b/apps/sim/ee/workspace-forking/application/sync-workflow-diff.test.ts @@ -0,0 +1,256 @@ +/** + * @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(() => ({ + loadSourceDeployedWorkflow: vi.fn(), + loadTargetDraftState: vi.fn(), + loadForkBlockMap: vi.fn(), + resolveForkPlanItem: 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', () => ({ + 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', () => ({ + resolveForkPlanItem: mocks.resolveForkPlanItem, +})) + +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: 'Support Agent' }, + targetName: 'Support Agent (prod)', + ...overrides, + } +} + +/** 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) => + workspaceId === 'child' && id === 'wf-src' && sourceState + ? { summary: { id, name: 'Support Agent' }, state: sourceState } + : null + ) +} + +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(() => { + workspaceAuthorizationMockFns.mockAuthorizeWorkspaceOperation.mockResolvedValue(undefined) + permissionsMockFns.mockGetWorkspaceWithOwner.mockImplementation(async (id: string) => ({ + id, + name: id, + organizationId: 'org', + allowPersonalApiKeys: false, + })) + workspaceForkingLineageMockFns.mockResolveForkEdge.mockResolvedValue(edge) + /* 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' }])) + /* 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 () => { + 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: 'Support Agent (prod) (current)', + afterLabel: 'Support Agent (deployed)', + }) + expect(result.before).toEqual(state({ 'mapped-b1': {} })) + }) + + 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' }, + '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' }, + 'v-tgt-dup': { id: 'v-tgt-dup', name: 'dup', type: 'string', value: '9' }, + } + sourceIs(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' }, + '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 () => { + mocks.resolveForkPlanItem.mockResolvedValue(planItem({ mode: 'create', targetName: null })) + + const result = await run() + + expect(result.before).toBeNull() + expect(result.targetWorkflowId).toBeNull() + expect(result.beforeLabel).toBe('Support Agent (current)') + }) + + it('fails instead of guessing when a replaced target cannot be loaded', async () => { + mocks.loadTargetDraftState.mockResolvedValue(null) + + await expect(run()).rejects.toMatchObject({ code: 'not_found' }) + }) + + 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', + }) + }) + + 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' }) + }) + + it('refuses two admin workspaces that are not a direct fork edge', async () => { + workspaceForkingLineageMockFns.mockResolveForkEdge.mockResolvedValue(null) + + await expect(run()).rejects.toMatchObject({ code: 'validation' }) + }) + + it('reads the other side as the source on a pull and pairs through the child block map', async () => { + 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 () => { + 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' }) + }) +}) 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..55f6761b2b1 --- /dev/null +++ b/apps/sim/ee/workspace-forking/application/sync-workflow-diff.ts @@ -0,0 +1,105 @@ +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 { + loadSourceDeployedWorkflow, + loadTargetDraftState, +} from '@/ee/workspace-forking/lib/copy/deploy-bridge' +import { loadForkBlockMap } from '@/ee/workspace-forking/lib/mapping/block-map-store' +import { resolveForkPlanItem } from '@/ee/workspace-forking/lib/promote/promote-plan' +import { projectSyncSource } from '@/ee/workspace-forking/lib/promote/sync-preview' +import { buildForkBlockIdResolver } from '@/ee/workspace-forking/lib/remap/block-identity' + +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, null when the sync would + * create the workflow) and the source's deployment re-keyed to the target's + * 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 + * 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, and a + * reference the sync would clear blocks the sync until it is dropped there. + */ +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 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 after = projectSyncSource( + source.state, + before, + item.targetWorkflowId, + buildForkBlockIdResolver(sourceIsParent, blockMap) + ) + + const sourceName = item.sourceMeta.name + const targetName = item.targetName ?? sourceName + return { + targetWorkflowId: item.mode === 'replace' ? item.targetWorkflowId : null, + before, + after, + 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..b771a381a73 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,8 +1,17 @@ '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, ChevronDown, Chip, ChipCombobox, @@ -15,13 +24,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' @@ -61,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 @@ -806,6 +828,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) + /* 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 || @@ -835,7 +863,7 @@ export function ForkSyncView({ controller, onDirectionChange }: ForkSyncViewProp
+ { + if (!open) setDiffWorkflow(null) + }} + workspaceId={controller.workspaceId} + otherWorkspaceId={controller.otherWorkspaceId} + direction={controller.direction} + sourceWorkflowId={diffWorkflow.sourceWorkflowId} + workflowName={diffWorkflow.currentName} + /> + + )} + {controller.hasDiff ? ( {controller.workflowChanges.length + excludedRows.length > 0 ? ( @@ -871,10 +916,19 @@ export function ForkSyncView({ controller, onDirectionChange }: ForkSyncViewProp
{controller.workflowChanges.map((change, index) => { const renamed = change.currentName !== change.otherName + /* Only a row the sync changes has something to compare, and only once it is this direction's. */ + const preview: ForkWorkflowPreviewChange | null = + change.action !== 'archive' && + change.hasChanges && + change.sourceWorkflowId && + !controller.diffIsStale + ? { ...change, sourceWorkflowId: change.sourceWorkflowId } + : null + const unchanged = change.action === 'update' && !change.hasChanges return (
{change.currentName} @@ -887,6 +941,27 @@ export function ForkSyncView({ controller, onDirectionChange }: ForkSyncViewProp ) : null} + {unchanged && ( + + No changes + + )} + {preview && controller.otherWorkspaceId && ( + + + + + 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..aa2c7d298d3 --- /dev/null +++ b/apps/sim/ee/workspace-forking/components/fork-sync/fork-workflow-diff-modal.tsx @@ -0,0 +1,74 @@ +'use client' + +import { useId } from 'react' +import { ChipModal, ChipModalBody, ChipModalHeader } from '@sim/emcn' +import { + WorkflowDiffSkeleton, + 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..6f6cef44b65 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 @@ -121,6 +121,11 @@ export interface ForkSyncController { diffErrorMessage: string | null /** True once the diff payload for ANY direction is present (placeholder included). */ hasDiff: boolean + /** + * The diff on screen is still the previous direction's placeholder, so its rows name + * workflows that are not part of the sync now selected. + */ + diffIsStale: boolean /** True once the mapping payload is present (placeholder included), gating the Mappings section. */ hasMapping: boolean groups: ForkMappingGroup[] @@ -191,6 +196,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[] /** @@ -1046,6 +1054,7 @@ export function useForkSync(params: { ? getErrorMessage(diff.error, "Couldn't load sync details. Reload the page to retry.") : null, hasDiff: Boolean(diff.data), + diffIsStale: diff.isPlaceholderData, hasMapping: Boolean(mapping.data), groups, kindSummaries, @@ -1075,6 +1084,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..9467a0690d4 --- /dev/null +++ b/apps/sim/ee/workspace-forking/hooks/use-fork-workflow-diff.ts @@ -0,0 +1,59 @@ +import { useQuery } from '@tanstack/react-query' +import { requestJson } from '@/lib/api/client/request' +import { getForkWorkflowDiffContract } from '@/lib/api/contracts/workspace-fork' +import { type ForkDirection, forkKeys } from '@/ee/workspace-forking/hooks/workspace-fork' + +/** + * Nested under the fork diff keys so the promote, rollback and unlink + * mutations' existing invalidation of `forkKeys.diffs()` covers a preview too. + */ +export const forkWorkflowDiffKeys = { + all: [...forkKeys.diffs(), 'workflow'] 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..f4056a8057b 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/deploy-bridge.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/deploy-bridge.ts @@ -1,11 +1,12 @@ import { db, runOutsideTransactionContext } from '@sim/db' -import { webhook, workflow, workflowDeploymentVersion } from '@sim/db/schema' +import { webhook, workflow, workflowBlocks, workflowDeploymentVersion } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, exists, inArray, isNotNull, isNull, sql } from 'drizzle-orm' 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' @@ -25,6 +26,13 @@ const logger = createLogger('WorkspaceForkDeployBridge') */ export const MAX_FORK_DEPLOYED_WORKFLOWS = 1000 +/** + * Workflow states read at once from the global pool: keeps concurrent checkouts + * well under the pool max even at the workflow ceiling. Callers run these reads + * before any transaction. + */ +const READ_CONCURRENCY = 5 + /** Aggregate serialized source state admitted before any graph materialization. */ export const MAX_FORK_STATE_BYTES = 64 * 1024 * 1024 @@ -55,35 +63,82 @@ export async function listDeployedWorkflows( workspaceId: string ): Promise { return executor + .select(DEPLOYED_WORKFLOW_SUMMARY) + .from(workflow) + .where(syncableSourcePredicate(workspaceId)) + .limit(MAX_FORK_DEPLOYED_WORKFLOWS + 1) +} + +const DEPLOYED_WORKFLOW_SUMMARY = { + id: workflow.id, + name: workflow.name, + description: workflow.description, + folderId: workflow.folderId, + sortOrder: workflow.sortOrder, + isPublicApi: workflow.isPublicApi, +} + +/** The one definition of a workflow that takes part in a sync as a source; see {@link listDeployedWorkflows}. */ +function syncableSourcePredicate(workspaceId: string) { + return and( + eq(workflow.workspaceId, workspaceId), + eq(workflow.isDeployed, true), + eq(workflow.forkSyncExcluded, false), + isNull(workflow.archivedAt), + exists( + db + .select({ one: sql`1` }) + .from(workflowDeploymentVersion) + .where( + and( + eq(workflowDeploymentVersion.workflowId, workflow.id), + eq(workflowDeploymentVersion.isActive, true) + ) + ) + ) + ) +} + +/** + * One source workflow's summary and active deployed state, admitted by the + * same predicate and materialized the same way as {@link loadSourceDeployedStates}, + * without reading the rest of the workspace. Null when the workflow is not a + * sync source in `sourceWorkspaceId` (not there, not deployed, excluded, or + * archived). + */ +export async function loadSourceDeployedWorkflow( + sourceWorkspaceId: string, + workflowId: string +): Promise<{ summary: DeployedWorkflowSummary; state: WorkflowState } | null> { + const [summary] = await db + .select(DEPLOYED_WORKFLOW_SUMMARY) + .from(workflow) + .where(and(eq(workflow.id, workflowId), syncableSourcePredicate(sourceWorkspaceId))) + .limit(1) + if (!summary) return null + const [version] = await db .select({ - id: workflow.id, - name: workflow.name, - description: workflow.description, - folderId: workflow.folderId, - sortOrder: workflow.sortOrder, - isPublicApi: workflow.isPublicApi, + id: workflowDeploymentVersion.id, + bytes: sql`octet_length(${workflowDeploymentVersion.state}::text)`, + digest: sql`md5(${workflowDeploymentVersion.state}::text)`, }) - .from(workflow) + .from(workflowDeploymentVersion) .where( and( - eq(workflow.workspaceId, workspaceId), - eq(workflow.isDeployed, true), - eq(workflow.forkSyncExcluded, false), - isNull(workflow.archivedAt), - exists( - db - .select({ one: sql`1` }) - .from(workflowDeploymentVersion) - .where( - and( - eq(workflowDeploymentVersion.workflowId, workflow.id), - eq(workflowDeploymentVersion.isActive, true) - ) - ) - ) + eq(workflowDeploymentVersion.workflowId, workflowId), + eq(workflowDeploymentVersion.isActive, true) ) ) - .limit(MAX_FORK_DEPLOYED_WORKFLOWS + 1) + .limit(1) + if (!version) return null + if (Number(version.bytes) > MAX_FORK_STATE_BYTES) { + throw new ForkError( + `The deployed workflow state exceeds the ${MAX_FORK_STATE_BYTES} byte fork/sync limit`, + 413 + ) + } + const state = await readAdmittedSourceState(workflowId, sourceWorkspaceId, version) + return { summary, state } } /** @@ -246,7 +301,6 @@ export async function loadSourceDeployedStates(sourceWorkspaceId: string): Promi // under the pool max even at the workflow ceiling, and this runs BEFORE any transaction. const sourceStates = new Map() let materializedBytes = 0 - const READ_CONCURRENCY = 5 for (let i = 0; i < deployedWorkflows.length; i += READ_CONCURRENCY) { const batch = deployedWorkflows.slice(i, i + READ_CONCURRENCY) const states = await Promise.all( @@ -302,6 +356,81 @@ 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' } + ) +} + +/** + * Serialized size of the blocks in these workflows' drafts (sub-blocks, outputs + * and data, the block columns a draft load materializes), from one query, so + * a caller can decide whether reading the drafts in full is affordable before + * it reads any of them. + */ +export async function measureTargetDraftBytes(workflowIds: string[]): Promise { + if (workflowIds.length === 0) return 0 + const [row] = await db + .select({ + bytes: sql`coalesce(sum( + octet_length(${workflowBlocks.subBlocks}::text) + + octet_length(${workflowBlocks.outputs}::text) + + coalesce(octet_length(${workflowBlocks.data}::text), 0) + ), 0)`, + }) + .from(workflowBlocks) + .where(inArray(workflowBlocks.workflowId, workflowIds)) + return Number(row?.bytes ?? 0) +} + +/** + * {@link loadTargetDraftState} for many workflows, a few at a time, handing + * each draft to `visit` and keeping none of them, so memory stays at one batch + * however many workflows there are. A workflow not in `workspaceId` is skipped. + */ +export async function forEachTargetDraft( + workflowIds: string[], + workspaceId: string, + visit: (workflowId: string, draft: WorkflowState) => void +): Promise { + for (let i = 0; i < workflowIds.length; i += READ_CONCURRENCY) { + const batch = workflowIds.slice(i, i + READ_CONCURRENCY) + const loaded = await Promise.all(batch.map((id) => loadTargetDraftState(id, workspaceId))) + batch.forEach((id, index) => { + const draft = loaded[index] + if (draft) visit(id, draft) + }) + } +} + /** * 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 diff --git a/apps/sim/ee/workspace-forking/lib/mapping/block-map-store.ts b/apps/sim/ee/workspace-forking/lib/mapping/block-map-store.ts index 765fe414281..848d6957abc 100644 --- a/apps/sim/ee/workspace-forking/lib/mapping/block-map-store.ts +++ b/apps/sim/ee/workspace-forking/lib/mapping/block-map-store.ts @@ -20,7 +20,13 @@ export interface ForkBlockPair { */ export async function loadForkBlockMap( executor: DbOrTx, - childWorkspaceId: string + childWorkspaceId: string, + /** + * Keep only the pairs whose workflow on one side is this one. The resolver + * reuses a pair only for its own target workflow, so a caller resolving one + * workflow loses nothing by scoping the read to that workflow's pairs. + */ + scope?: { side: 'parent' | 'child'; workflowId: string } ): Promise { const rows = await executor .select({ @@ -30,7 +36,18 @@ export async function loadForkBlockMap( childBlockId: workspaceForkBlockMap.childBlockId, }) .from(workspaceForkBlockMap) - .where(eq(workspaceForkBlockMap.childWorkspaceId, childWorkspaceId)) + .where( + and( + eq(workspaceForkBlockMap.childWorkspaceId, childWorkspaceId), + scope && + eq( + scope.side === 'parent' + ? workspaceForkBlockMap.parentWorkflowId + : workspaceForkBlockMap.childWorkflowId, + scope.workflowId + ) + ) + ) const parentToChild = new Map() const childToParent = new Map() for (const row of rows) { diff --git a/apps/sim/ee/workspace-forking/lib/mapping/mapping-store.ts b/apps/sim/ee/workspace-forking/lib/mapping/mapping-store.ts index 68a7dd5b48d..596054a057c 100644 --- a/apps/sim/ee/workspace-forking/lib/mapping/mapping-store.ts +++ b/apps/sim/ee/workspace-forking/lib/mapping/mapping-store.ts @@ -1,6 +1,6 @@ import { workspaceForkResourceMap } from '@sim/db/schema' import { generateId } from '@sim/utils/id' -import { and, asc, eq, inArray, or, sql } from 'drizzle-orm' +import { and, asc, eq, inArray, isNotNull, or, sql } from 'drizzle-orm' import type { z } from 'zod' import type { forkResourceTypeSchema } from '@/lib/api/contracts/workspace-fork' import type { DbOrTx } from '@/lib/db/types' @@ -120,6 +120,41 @@ export async function getEdgeMappingRows( return rows as ForkMappingRow[] } +/** + * The target a source workflow is identity-mapped to on an edge, read from the + * one workflow mapping row that names it, or null when it has none. Agrees with + * the identity map {@link getEdgeMappingRows} feeds the promote plan: a row with + * no child side is ignored, and should duplicates ever exist the newest wins. + */ +export async function getMappedWorkflowTarget( + executor: DbOrTx, + childWorkspaceId: string, + sourceIsParent: boolean, + sourceWorkflowId: string +): Promise { + const sourceColumn = sourceIsParent + ? workspaceForkResourceMap.parentResourceId + : workspaceForkResourceMap.childResourceId + const rows = await executor + .select({ + parentResourceId: workspaceForkResourceMap.parentResourceId, + childResourceId: workspaceForkResourceMap.childResourceId, + }) + .from(workspaceForkResourceMap) + .where( + and( + eq(workspaceForkResourceMap.childWorkspaceId, childWorkspaceId), + eq(workspaceForkResourceMap.resourceType, 'workflow'), + eq(sourceColumn, sourceWorkflowId), + isNotNull(workspaceForkResourceMap.childResourceId) + ) + ) + .orderBy(asc(workspaceForkResourceMap.createdAt), asc(workspaceForkResourceMap.id)) + const row = rows.at(-1) + if (!row) return null + return sourceIsParent ? row.childResourceId : row.parentResourceId +} + /** * Delete workflow-identity mapping rows by the ids on one side (parent or child). * Used by rollback to dissolve the identity rows a promote created, so a later diff --git a/apps/sim/ee/workspace-forking/lib/promote/promote-plan.ts b/apps/sim/ee/workspace-forking/lib/promote/promote-plan.ts index b2237c1057f..43a9b206b81 100644 --- a/apps/sim/ee/workspace-forking/lib/promote/promote-plan.ts +++ b/apps/sim/ee/workspace-forking/lib/promote/promote-plan.ts @@ -25,6 +25,7 @@ import { buildForkResolver, type ForkMappingRow, getEdgeMappingRows, + getMappedWorkflowTarget, resourceTypeToForkKind, } from '@/ee/workspace-forking/lib/mapping/mapping-store' import type { WorkflowState } from '@/stores/workflows/workflow/types' @@ -342,6 +343,55 @@ export function collectForkUnreferencedCopyables( }) } +/** + * The plan item one deployed source workflow would get, resolved from that + * workflow's identity mapping and its target alone rather than the whole + * workspace: the same {@link buildForkPromotePlanItems} decision the full plan + * makes, fed only the rows it would read for this source. Null when the + * mapped target is marked "Exclude from sync", which the full plan skips too. + */ +export async function resolveForkPlanItem(params: { + executor: DbOrTx + edge: ForkEdge + sourceWorkspaceId: string + targetWorkspaceId: string + source: DeployedWorkflowSummary +}): Promise { + const { executor, edge, sourceWorkspaceId, targetWorkspaceId, source } = params + const sourceIsParent = sourceWorkspaceId === edge.parentWorkspaceId + const mappedTargetId = await getMappedWorkflowTarget( + executor, + edge.childWorkspaceId, + sourceIsParent, + source.id + ) + const targetWorkflows = mappedTargetId + ? await executor + .select({ + id: workflow.id, + name: workflow.name, + forkSyncExcluded: workflow.forkSyncExcluded, + }) + .from(workflow) + .where( + and( + eq(workflow.id, mappedTargetId), + eq(workflow.workspaceId, targetWorkspaceId), + isNull(workflow.archivedAt) + ) + ) + : [] + const { items } = buildForkPromotePlanItems({ + deployedSourceWorkflows: [source], + sourceStateIds: new Set([source.id]), + identityMap: new Map(mappedTargetId ? [[source.id, mappedTargetId]] : []), + targetActiveIds: new Set(targetWorkflows.map((w) => w.id)), + targetNameById: new Map(targetWorkflows.map((w) => [w.id, w.name])), + excludedTargetIds: new Set(targetWorkflows.filter((w) => w.forkSyncExcluded).map((w) => w.id)), + }) + return items[0] ?? null +} + /** * Compute everything a promote needs without mutating. Only the source's * **deployed** workflows participate; each plan item carries the source's active 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 new file mode 100644 index 00000000000..2339b0d8275 --- /dev/null +++ b/apps/sim/ee/workspace-forking/lib/promote/sync-preview.test.ts @@ -0,0 +1,136 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + measureTargetDraftBytes: vi.fn(), + forEachTargetDraft: vi.fn(), +})) + +vi.mock('@/ee/workspace-forking/lib/copy/deploy-bridge', () => ({ + MAX_FORK_STATE_BYTES: 1000, + measureTargetDraftBytes: mocks.measureTargetDraftBytes, + forEachTargetDraft: mocks.forEachTargetDraft, +})) + +import { + listUnchangedSyncSources, + projectSyncSource, + syncChangesWorkflow, +} from '@/ee/workspace-forking/lib/promote/sync-preview' +import type { WorkflowState } from '@/stores/workflows/workflow/types' + +function state(code: string, blockId = 'b1', extra: Record = {}): WorkflowState { + return { + blocks: { + [blockId]: { + id: blockId, + type: 'function', + name: 'Run', + position: { x: 0, y: 0 }, + subBlocks: { code: { id: 'code', type: 'code', value: code } }, + outputs: {}, + enabled: true, + ...extra, + }, + }, + edges: [], + loops: {}, + parallels: {}, + variables: {}, + } as unknown as WorkflowState +} + +/** Every source block pairs with the target block named `t-`, as a recorded block map would. */ +const resolve = (_targetWorkflowId: string, sourceBlockId: string) => `t-${sourceBlockId}` + +describe('syncChangesWorkflow', () => { + it('reports no change when the target draft already matches the projected source', () => { + const before = state('return 1', 't-b1') + const after = projectSyncSource(state('return 1'), before, 'wf-t', resolve) + + expect(syncChangesWorkflow(before, after)).toBe(false) + }) + + it('reports a change when a field differs', () => { + const before = state('return 1', 't-b1') + const after = projectSyncSource(state('return 2'), before, 'wf-t', resolve) + + expect(syncChangesWorkflow(before, after)).toBe(true) + }) + + it('ignores canvas-only presentation, as the comparison view does', () => { + const before = state('return 1', 't-b1') + const after = projectSyncSource( + state('return 1', 'b1', { horizontalHandles: false }), + before, + 'wf-t', + resolve + ) + + expect(syncChangesWorkflow(before, after)).toBe(false) + }) + + it('always reports a change for a workflow the sync creates', () => { + expect(syncChangesWorkflow(null, state('return 1'))).toBe(true) + }) +}) + +describe('listUnchangedSyncSources', () => { + const items = [ + { sourceWorkflowId: 's-same', targetWorkflowId: 't-same', mode: 'replace' }, + { sourceWorkflowId: 's-diff', targetWorkflowId: 't-diff', mode: 'replace' }, + { sourceWorkflowId: 's-new', targetWorkflowId: 't-new', mode: 'create' }, + ] as unknown as Parameters[0]['items'] + const sourceStates = new Map([ + ['s-same', state('return 1')], + ['s-diff', state('return 2')], + ['s-new', state('return 3')], + ]) + const drafts = new Map([ + ['t-same', state('return 1', 't-b1')], + ['t-diff', state('return 1', 't-b1')], + ]) + + beforeEach(() => { + mocks.measureTargetDraftBytes.mockResolvedValue(10) + mocks.forEachTargetDraft.mockImplementation( + async ( + ids: string[], + _workspaceId: string, + visit: (id: string, d: WorkflowState) => void + ) => { + for (const id of ids) { + const draft = drafts.get(id) + if (draft) visit(id, draft) + } + } + ) + }) + + it('lists only replaced workflows whose draft already matches, reading replaced targets only', async () => { + const unchanged = await listUnchangedSyncSources({ + items, + sourceStates, + targetWorkspaceId: 'ws-t', + resolveBlockId: resolve, + }) + + expect([...unchanged]).toEqual(['s-same']) + }) + + /* `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) + + const unchanged = await listUnchangedSyncSources({ + items, + sourceStates, + targetWorkspaceId: 'ws-t', + resolveBlockId: resolve, + }) + + expect(unchanged.size).toBe(0) + }) +}) diff --git a/apps/sim/ee/workspace-forking/lib/promote/sync-preview.ts b/apps/sim/ee/workspace-forking/lib/promote/sync-preview.ts new file mode 100644 index 00000000000..a4d8ec851cc --- /dev/null +++ b/apps/sim/ee/workspace-forking/lib/promote/sync-preview.ts @@ -0,0 +1,139 @@ +import { createLogger } from '@sim/logger' +import { generateWorkflowDiffSummary, omitPresentationChanges } from '@/lib/workflows/comparison' +import { + remapVariableIdsInSubBlocks, + type SubBlockRecord, +} from '@/lib/workflows/persistence/remap-internal-ids' +import { + forEachTargetDraft, + MAX_FORK_STATE_BYTES, + measureTargetDraftBytes, +} from '@/ee/workspace-forking/lib/copy/deploy-bridge' +import type { ForkPromotePlanItem } from '@/ee/workspace-forking/lib/promote/promote-plan' +import type { ForkBlockIdResolver } 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' + +const logger = createLogger('WorkspaceForkSyncPreview') + +/** 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 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 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 = idMap.get(variable.id) ?? variable.id + variables[id] = { ...variable, id } + } + 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 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 + 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 + } + } + return { ...after, blocks, variables } +} + +/** + * The source's deployed state in the target's terms: block ids re-keyed the way + * the promote re-keys them, then variables re-keyed to the target's by name. + * This is what a sync preview compares against the target's draft (`before`, + * null when the sync would create the workflow). + */ +export function projectSyncSource( + sourceState: WorkflowState, + before: WorkflowState | null, + targetWorkflowId: string, + resolveBlockId: ForkBlockIdResolver +): WorkflowState { + const after = remapWorkflowStateBlockIds(sourceState, (blockId) => + resolveBlockId(targetWorkflowId, blockId) + ) + return alignVariableIds(after, before) +} + +/** + * Whether syncing would change what the target's editor holds, by the same rule + * the comparison view uses to say "No changes". A workflow the sync creates + * always changes. + */ +export function syncChangesWorkflow(before: WorkflowState | null, after: WorkflowState): boolean { + if (!before) return true + return omitPresentationChanges(generateWorkflowDiffSummary(after, before)).hasChanges +} + +/** + * The source workflows a sync would replace without changing anything in the + * target's draft, so their rows need no comparison. Drafts are read a few at a + * time and dropped after comparing. When the drafts together exceed the fork + * state limit, none are read and every workflow counts as changed: the rows + * then all offer a comparison, which is the safe way to be wrong. + */ +export async function listUnchangedSyncSources(params: { + items: ForkPromotePlanItem[] + sourceStates: ReadonlyMap + targetWorkspaceId: string + resolveBlockId: ForkBlockIdResolver +}): Promise> { + const { items, sourceStates, targetWorkspaceId, resolveBlockId } = params + const unchanged = new Set() + const replaced = items.filter( + (item) => item.mode === 'replace' && sourceStates.has(item.sourceWorkflowId) + ) + if (replaced.length === 0) return unchanged + const targetIds = replaced.map((item) => item.targetWorkflowId) + const bytes = await measureTargetDraftBytes(targetIds) + if (bytes > MAX_FORK_STATE_BYTES) { + logger.info('Skipping per-workflow change check: target drafts exceed the fork state limit', { + workflows: targetIds.length, + bytes, + }) + return unchanged + } + const itemByTarget = new Map(replaced.map((item) => [item.targetWorkflowId, item])) + await forEachTargetDraft(targetIds, targetWorkspaceId, (targetWorkflowId, before) => { + const item = itemByTarget.get(targetWorkflowId) + const sourceState = item && sourceStates.get(item.sourceWorkflowId) + if (!item || !sourceState) return + const after = projectSyncSource(sourceState, before, targetWorkflowId, resolveBlockId) + if (!syncChangesWorkflow(before, after)) unchanged.add(item.sourceWorkflowId) + }) + return unchanged +} 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..bd8fa362d54 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' @@ -314,13 +319,38 @@ 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(), -}) +} + +export const forkWorkflowChangeSchema = z.discriminatedUnion('action', [ + z.object({ + action: z.literal('update'), + ...forkWorkflowChangeNames, + /** The deployed source workflow this sync copies; optional so a new client tolerates an old server during rollout. */ + sourceWorkflowId: workflowIdSchema.optional(), + /** + * Whether the sync would change the target's draft. False when the draft already matches + * the source's deployment; defaulted so a new client tolerates an old server during rollout. + */ + hasChanges: z.boolean().default(true), + }), + z.object({ + action: z.literal('create'), + ...forkWorkflowChangeNames, + /** The deployed source workflow this sync copies; optional so a new client tolerates an old server during rollout. */ + sourceWorkflowId: workflowIdSchema.optional(), + /** + * Whether the sync would change the target's draft. False when the draft already matches + * the source's deployment; defaulted so a new client tolerates an old server during rollout. + */ + hasChanges: z.boolean().default(true), + }), + z.object({ action: z.literal('archive'), ...forkWorkflowChangeNames }), +]) /** * A configured selector field (Gmail label, Slack channel, KB document, ...) that @@ -991,3 +1021,36 @@ 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({ + /** 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(), + afterLabel: z.string(), + }), + }, +}) +export type GetForkWorkflowDiffQuery = z.input +export type GetForkWorkflowDiffResponse = z.output< + typeof getForkWorkflowDiffContract.response.schema +> 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..419054bc6b9 100644 --- a/apps/sim/lib/workflows/comparison/compare.test.ts +++ b/apps/sim/lib/workflows/comparison/compare.test.ts @@ -6,8 +6,14 @@ import { createWorkflowState as createTestWorkflowState, } from '@sim/testing' import { describe, expect, it } from 'vitest' +import { + containerConfigFields, + generateWorkflowDiffSummary, + hasWorkflowChanged, + omitPresentationChanges, + type WorkflowDiffSummary, +} from '@/lib/workflows/comparison/compare' import type { WorkflowState } from '@/stores/workflows/workflow/types' -import { generateWorkflowDiffSummary, hasWorkflowChanged } from './compare' /** * Type helper for converting test workflow state to app workflow state. @@ -1620,3 +1626,239 @@ 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: [], + }, + ]) + } + ) +}) + +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, + } +} + +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('containerConfigFields', () => { + it('reports only the settings the loop or parallel type uses', () => { + const state = { + loops: { + l: { + id: 'l', + nodes: [], + loopType: 'forEach' as const, + iterations: 5, + forEachItems: '', + }, + }, + parallels: { + p: { id: 'p', nodes: [], parallelType: 'count' as const, count: 3, distribution: 'x' }, + }, + } as unknown as Pick + + expect(containerConfigFields(state, 'l')).toEqual([ + { field: 'loopType', value: 'forEach' }, + { field: 'forEachItems', value: '' }, + ]) + expect(containerConfigFields(state, 'p')).toEqual([ + { field: 'parallelType', value: 'count' }, + { field: 'count', value: 3 }, + ]) + expect(containerConfigFields(state, 'missing')).toEqual([]) + }) +}) diff --git a/apps/sim/lib/workflows/comparison/compare.ts b/apps/sim/lib/workflows/comparison/compare.ts index ccc6e62c57d..ca5f77b4abd 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,109 @@ 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)), + } +} + +/** + * A container's configuration as it would be reported field by field, keeping + * only the fields its loop or parallel type uses. For a container that exists + * on one side, so its card can show what it runs as a change from nothing. + */ +export function containerConfigFields( + state: Pick, + id: string +): Array<{ field: ContainerConfigField; value: unknown }> { + const loop = normalizeLoop(state.loops?.[id]) + const parallel = loop ? undefined : normalizeParallel(state.parallels?.[id]) + const record = (loop ?? parallel) as Record | undefined + if (!record) return [] + const fields: readonly ContainerConfigField[] = loop ? LOOP_CONFIG_FIELDS : PARALLEL_CONFIG_FIELDS + return fields + .filter((field) => record[field] !== undefined) + .map((field) => ({ field, value: record[field] })) +} + +/** 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 + ) +} + +/** + * Fields the comparison engine counts but a reviewer never needs to see: pure + * canvas presentation. They still drive "needs redeploy", so the summary keeps + * them and {@link omitPresentationChanges} hides them for review. 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 +} + /** * Generate a detailed diff summary between two workflow states */ @@ -82,6 +217,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 +490,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 +525,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 +579,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..ddc925297e9 100644 --- a/apps/sim/lib/workflows/comparison/index.ts +++ b/apps/sim/lib/workflows/comparison/index.ts @@ -1,9 +1,21 @@ export { + type ContainerChange, + type ContainerConfigField, + containerConfigFields, generateWorkflowDiffSummary, hasWorkflowChanged, + omitPresentationChanges, + summaryHasChanges, type WorkflowDiffSummary, } from './compare' export { normalizedStringify, normalizeWorkflowState, } from './normalize' +export { + type BlockDiffStatus, + buildWorkflowDiffOverlay, + type EdgeDiffStatus, + type MeasureBlock, + 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..3418c9b993c --- /dev/null +++ b/apps/sim/lib/workflows/comparison/overlay.test.ts @@ -0,0 +1,506 @@ +/** + * @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' + +/** 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 }, 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`, drawn at the 100px minimum, so it lands at 152 + 100 + 32. */ + expect(merged.blocks.gone.position).toEqual({ x: 10, y: 284 }) + 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('sizes a removed container with no stored size the way the preview draws it', () => { + const base = asState({ + blocks: { oldLoop: block('oldLoop', { type: 'loop', position: { x: 0, y: 0 } }) }, + edges: [], + loops: { oldLoop: { id: 'oldLoop', nodes: [], loopType: 'for', iterations: 2 } }, + }) + const target = asState({ + /* Past a plain card's 250px but inside the 500px default container width. */ + blocks: { live: block('live', { position: { x: 400, y: 50 }, height: 100 }) }, + edges: [], + loops: {}, + }) + const summary: WorkflowDiffSummary = { + ...emptySummary(), + removedBlocks: [{ id: 'oldLoop', type: 'loop', name: 'oldLoop' }], + addedBlocks: [{ id: 'live', type: 'function', name: 'live' }], + hasChanges: true, + } + + const merged = buildWorkflowDiffOverlay(summary, base, target).mergedState + + expect(merged.blocks.oldLoop.position).toEqual({ x: 0, y: 182 }) + }) + + it('treats a stored height of zero as the minimum card height', () => { + const base = asState({ + blocks: { gone: block('gone', { position: { x: 0, y: 60 }, height: 0 }) }, + edges: [], + }) + const target = asState({ + blocks: { live: block('live', { position: { x: 0, y: 0 }, height: 0 }) }, + edges: [], + }) + const summary: WorkflowDiffSummary = { + ...emptySummary(), + removedBlocks: [{ id: 'gone', type: 'function', name: 'gone' }], + addedBlocks: [{ id: 'live', type: 'function', name: 'live' }], + hasChanges: true, + } + + const merged = buildWorkflowDiffOverlay(summary, base, target).mergedState + + /* The live card is at least 100px tall, so the ghost at y=60 overlaps it and slides to 132. */ + expect(merged.blocks.gone.position).toEqual({ x: 0, y: 132 }) + }) + + it('sizes boxes with the measurement the canvas passes', () => { + const base = asState({ + blocks: { gone: block('gone', { position: { x: 0, y: 150 } }) }, + edges: [], + }) + const target = asState({ + blocks: { live: block('live', { position: { x: 0, y: 0 } }) }, + edges: [], + }) + const summary: WorkflowDiffSummary = { + ...emptySummary(), + removedBlocks: [{ id: 'gone', type: 'function', name: 'gone' }], + addedBlocks: [{ id: 'live', type: 'function', name: 'live' }], + hasChanges: true, + } + + const merged = buildWorkflowDiffOverlay(summary, base, target, () => ({ + width: 250, + height: 200, + })).mergedState + + expect(merged.blocks.gone.position).toEqual({ x: 0, y: 232 }) + }) + + 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-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 new file mode 100644 index 00000000000..e60ac1dcbde --- /dev/null +++ b/apps/sim/lib/workflows/comparison/overlay.ts @@ -0,0 +1,249 @@ +import { BLOCK_DIMENSIONS, CONTAINER_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 + +/** How big a block is drawn; the canvas passes its own measurement so ghost boxes match it. */ +export type MeasureBlock = (block: BlockState) => { width: number; height: number } + +/** + * Stored sizes clamped to the canvas minimums: the fallback when the caller + * does not pass the canvas's own measurement. + */ +const measureStoredSize: MeasureBlock = (block) => { + const data = block.data as { width?: number; height?: number } | undefined + if (block.type === 'loop' || block.type === 'parallel') { + return { + width: data?.width + ? Math.max(data.width, CONTAINER_DIMENSIONS.MIN_WIDTH) + : CONTAINER_DIMENSIONS.DEFAULT_WIDTH, + height: data?.height + ? Math.max(data.height, CONTAINER_DIMENSIONS.MIN_HEIGHT) + : CONTAINER_DIMENSIONS.DEFAULT_HEIGHT, + } + } + return { + width: BLOCK_DIMENSIONS.FIXED_WIDTH, + height: Math.max(block.height || 0, BLOCK_DIMENSIONS.MIN_HEIGHT), + } +} + +function boxOf(block: BlockState, measure: MeasureBlock): BoundingBox { + return { x: block.position?.x ?? 0, y: block.position?.y ?? 0, ...measure(block) } +} + +/** + * 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[], + measure: MeasureBlock +): BlockState { + if (ghost.data?.parentId) return ghost + const box = boxOf(ghost, measure) + 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)) + 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(items) }, + }, + } + } + 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, + measure: MeasureBlock = measureStoredSize +): 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((block) => boxOf(block, measure)) + for (const block of summary.removedBlocks) { + const baseBlock = baseState.blocks[block.id] + if (baseBlock && !blocks[block.id]) + blocks[block.id] = nudgeOutOfCollision(baseBlock, occupied, measure) + } + + 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. */ + 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' + } + + 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..969cbbd0a61 100644 --- a/packages/workflow-renderer/src/types.ts +++ b/packages/workflow-renderer/src/types.ts @@ -6,8 +6,12 @@ * store, socket, or query coupling. */ -/** Diff state of an edge when comparing two workflow versions. */ -export type EdgeDiffStatus = 'new' | 'deleted' | 'unchanged' | null +/** + * 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 /** Execution outcome of an edge for run-path visualization. */ export type EdgeRunStatus = 'success' | 'error' | 'not-executed' | undefined diff --git a/scripts/check-tool-registry-boundary.baseline.json b/scripts/check-tool-registry-boundary.baseline.json index d3dfa6f67d5..4f93cee5b50 100644 --- a/scripts/check-tool-registry-boundary.baseline.json +++ b/scripts/check-tool-registry-boundary.baseline.json @@ -6,68 +6,68 @@ }, "entries": { "app/api/v2/blocks/[blockId]/route.ts": { - "modules": 1692, + "modules": 1695, "gateways": { - "apps/sim/lib/api/server/routes/index.ts": 512, + "apps/sim/lib/api/server/routes/index.ts": 515, "apps/sim/triggers/index.ts": 491, "apps/sim/triggers/registry.ts": 489, - "apps/sim/lib/api/server/routes/internal-json-route.ts": 453, - "apps/sim/lib/auth/index.ts": 439, + "apps/sim/lib/api/server/routes/internal-json-route.ts": 456, + "apps/sim/lib/auth/index.ts": 442, "apps/sim/blocks/registry.ts": 367, - "apps/sim/lib/webhooks/providers/index.ts": 122, - "apps/sim/lib/webhooks/providers/registry.ts": 120 + "apps/sim/lib/webhooks/providers/index.ts": 121, + "apps/sim/lib/webhooks/providers/registry.ts": 119 } }, "app/api/v2/blocks/route.ts": { - "modules": 1689, + "modules": 1692, "gateways": { "apps/sim/blocks/registry.ts": 862, - "apps/sim/lib/api/server/routes/index.ts": 504, + "apps/sim/lib/api/server/routes/index.ts": 507, "apps/sim/triggers/index.ts": 491, "apps/sim/triggers/registry.ts": 489, - "apps/sim/lib/api/server/routes/internal-json-route.ts": 454, - "apps/sim/lib/auth/index.ts": 440, - "apps/sim/lib/webhooks/providers/index.ts": 123, - "apps/sim/lib/webhooks/providers/registry.ts": 120 + "apps/sim/lib/api/server/routes/internal-json-route.ts": 457, + "apps/sim/lib/auth/index.ts": 443, + "apps/sim/lib/webhooks/providers/index.ts": 122, + "apps/sim/lib/webhooks/providers/registry.ts": 119 } }, "app/api/v2/connector-types/route.ts": { - "modules": 1757, + "modules": 1760, "gateways": { "apps/sim/blocks/registry.ts": 863, - "apps/sim/lib/api/server/routes/index.ts": 503, + "apps/sim/lib/api/server/routes/index.ts": 506, "apps/sim/triggers/index.ts": 491, "apps/sim/triggers/registry.ts": 489, - "apps/sim/lib/api/server/routes/internal-json-route.ts": 453, - "apps/sim/lib/auth/index.ts": 439, - "apps/sim/lib/webhooks/providers/index.ts": 123, - "apps/sim/lib/webhooks/providers/registry.ts": 120 + "apps/sim/lib/api/server/routes/internal-json-route.ts": 456, + "apps/sim/lib/auth/index.ts": 442, + "apps/sim/lib/webhooks/providers/index.ts": 122, + "apps/sim/lib/webhooks/providers/registry.ts": 119 } }, "app/api/v2/tools/[toolId]/route.ts": { - "modules": 1687, + "modules": 1690, "gateways": { "apps/sim/blocks/registry.ts": 862, - "apps/sim/lib/api/server/routes/index.ts": 513, + "apps/sim/lib/api/server/routes/index.ts": 516, "apps/sim/triggers/index.ts": 491, "apps/sim/triggers/registry.ts": 489, - "apps/sim/lib/api/server/routes/internal-json-route.ts": 454, - "apps/sim/lib/auth/index.ts": 440, - "apps/sim/lib/webhooks/providers/index.ts": 123, - "apps/sim/lib/webhooks/providers/registry.ts": 120 + "apps/sim/lib/api/server/routes/internal-json-route.ts": 457, + "apps/sim/lib/auth/index.ts": 443, + "apps/sim/lib/webhooks/providers/index.ts": 122, + "apps/sim/lib/webhooks/providers/registry.ts": 119 } }, "app/api/v2/tools/route.ts": { - "modules": 1688, + "modules": 1691, "gateways": { "apps/sim/blocks/registry.ts": 862, - "apps/sim/lib/api/server/routes/index.ts": 504, + "apps/sim/lib/api/server/routes/index.ts": 507, "apps/sim/triggers/index.ts": 491, "apps/sim/triggers/registry.ts": 489, - "apps/sim/lib/api/server/routes/internal-json-route.ts": 454, - "apps/sim/lib/auth/index.ts": 440, - "apps/sim/lib/webhooks/providers/index.ts": 123, - "apps/sim/lib/webhooks/providers/registry.ts": 120 + "apps/sim/lib/api/server/routes/internal-json-route.ts": 457, + "apps/sim/lib/auth/index.ts": 443, + "apps/sim/lib/webhooks/providers/index.ts": 122, + "apps/sim/lib/webhooks/providers/registry.ts": 119 } }, "app/workspace/[workspaceId]/access-requests/loading.tsx": { @@ -87,16 +87,16 @@ "gateways": {} }, "app/workspace/[workspaceId]/chat/[chatId]/page.tsx": { - "modules": 3318, + "modules": 3342, "gateways": { - "apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1666, - "apps/sim/app/workspace/[workspaceId]/home/components/chat-resource-panel.tsx": 1028, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 1024, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts": 713, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts": 710, + "apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1687, + "apps/sim/app/workspace/[workspaceId]/home/components/chat-resource-panel.tsx": 1043, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 1039, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts": 728, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts": 725, "apps/sim/triggers/registry.ts": 489, "apps/sim/blocks/registry.ts": 336, - "apps/sim/lib/auth/index.ts": 280 + "apps/sim/lib/auth/index.ts": 283 } }, "app/workspace/[workspaceId]/dashboards/layout.tsx": { @@ -129,16 +129,16 @@ "gateways": {} }, "app/workspace/[workspaceId]/files/[fileId]/page.tsx": { - "modules": 2271, + "modules": 2282, "gateways": { "apps/sim/triggers/registry.ts": 489, - "apps/sim/app/workspace/[workspaceId]/files/files.tsx": 477, + "apps/sim/app/workspace/[workspaceId]/files/files.tsx": 485, "apps/sim/blocks/registry.ts": 363, - "apps/sim/lib/auth/index.ts": 283, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/index.ts": 265, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx": 236, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx": 165, - "apps/sim/lib/webhooks/providers/index.ts": 122 + "apps/sim/lib/auth/index.ts": 286, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/index.ts": 273, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx": 244, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx": 173, + "apps/sim/lib/webhooks/providers/index.ts": 121 } }, "app/workspace/[workspaceId]/files/[fileId]/view/page.tsx": { @@ -157,16 +157,16 @@ "gateways": {} }, "app/workspace/[workspaceId]/files/page.tsx": { - "modules": 2270, + "modules": 2281, "gateways": { "apps/sim/triggers/registry.ts": 489, - "apps/sim/app/workspace/[workspaceId]/files/files.tsx": 478, + "apps/sim/app/workspace/[workspaceId]/files/files.tsx": 486, "apps/sim/blocks/registry.ts": 363, - "apps/sim/lib/auth/index.ts": 283, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/index.ts": 265, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx": 236, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx": 165, - "apps/sim/lib/webhooks/providers/index.ts": 122 + "apps/sim/lib/auth/index.ts": 286, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/index.ts": 273, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx": 244, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx": 173, + "apps/sim/lib/webhooks/providers/index.ts": 121 } }, "app/workspace/[workspaceId]/home/error.tsx": { @@ -178,22 +178,22 @@ "gateways": {} }, "app/workspace/[workspaceId]/home/page.tsx": { - "modules": 3318, + "modules": 3342, "gateways": { - "apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1666, - "apps/sim/app/workspace/[workspaceId]/home/components/chat-resource-panel.tsx": 1028, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 1024, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts": 713, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts": 710, + "apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1687, + "apps/sim/app/workspace/[workspaceId]/home/components/chat-resource-panel.tsx": 1043, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 1039, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts": 728, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts": 725, "apps/sim/triggers/registry.ts": 489, "apps/sim/blocks/registry.ts": 336, - "apps/sim/lib/auth/index.ts": 280 + "apps/sim/lib/auth/index.ts": 283 } }, "app/workspace/[workspaceId]/integrations/[block]/page.tsx": { - "modules": 1173, + "modules": 1174, "gateways": { - "apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx": 1094, + "apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx": 1095, "apps/sim/triggers/index.ts": 530, "apps/sim/triggers/registry.ts": 528, "apps/sim/blocks/registry.ts": 370, @@ -202,9 +202,9 @@ } }, "app/workspace/[workspaceId]/integrations/connected/[credentialId]/page.tsx": { - "modules": 1249, + "modules": 1250, "gateways": { - "apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx": 1204, + "apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx": 1205, "apps/sim/triggers/registry.ts": 528, "apps/sim/blocks/registry.ts": 378, "apps/sim/app/workspace/[workspaceId]/components/credential-detail/index.ts": 97, @@ -219,9 +219,9 @@ "gateways": {} }, "app/workspace/[workspaceId]/integrations/page.tsx": { - "modules": 1147, + "modules": 1148, "gateways": { - "apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx": 1143, + "apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx": 1144, "apps/sim/blocks/registry.ts": 904, "apps/sim/triggers/index.ts": 530, "apps/sim/triggers/registry.ts": 528, @@ -235,9 +235,9 @@ "gateways": {} }, "app/workspace/[workspaceId]/knowledge/[id]/[documentId]/page.tsx": { - "modules": 1377, + "modules": 1385, "gateways": { - "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx": 1315, + "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx": 1323, "apps/sim/triggers/registry.ts": 528, "apps/sim/blocks/registry.ts": 370, "apps/sim/blocks/registry-maps.ts": 367, @@ -256,12 +256,12 @@ "gateways": {} }, "app/workspace/[workspaceId]/knowledge/[id]/page.tsx": { - "modules": 1545, + "modules": 1550, "gateways": { - "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx": 1482, + "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx": 1487, "apps/sim/triggers/registry.ts": 528, "apps/sim/blocks/registry.ts": 365, - "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/index.ts": 157, + "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/index.ts": 154, "apps/sim/connectors/registry.ts": 69, "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-scope.ts": 51, "apps/sim/app/o/[organizationId]/providers/organization-provider.tsx": 40, @@ -277,28 +277,28 @@ "gateways": {} }, "app/workspace/[workspaceId]/knowledge/page.tsx": { - "modules": 2455, + "modules": 2465, "gateways": { "apps/sim/triggers/registry.ts": 489, - "apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts": 442, - "apps/sim/lib/knowledge/application/knowledge-bases.ts": 374, + "apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts": 441, + "apps/sim/lib/knowledge/application/knowledge-bases.ts": 373, "apps/sim/blocks/registry.ts": 361, - "apps/sim/lib/auth/index.ts": 230, + "apps/sim/lib/auth/index.ts": 231, "apps/sim/lib/knowledge/orchestration/index.ts": 227, - "apps/sim/lib/knowledge/orchestration/connectors.ts": 223, - "apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx": 216 + "apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx": 224, + "apps/sim/lib/knowledge/orchestration/connectors.ts": 223 } }, "app/workspace/[workspaceId]/layout.tsx": { - "modules": 2303, + "modules": 2319, "gateways": { "apps/sim/triggers/registry.ts": 489, - "apps/sim/lib/auth/index.ts": 422, + "apps/sim/lib/auth/index.ts": 425, "apps/sim/blocks/registry.ts": 355, - "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx": 271, - "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts": 142, - "apps/sim/lib/webhooks/providers/index.ts": 122, - "apps/sim/lib/webhooks/providers/registry.ts": 120, + "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx": 279, + "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts": 150, + "apps/sim/lib/webhooks/providers/index.ts": 121, + "apps/sim/lib/webhooks/providers/registry.ts": 119, "apps/sim/app/workspace/[workspaceId]/prefetch-access.ts": 90 } }, @@ -311,15 +311,15 @@ "gateways": {} }, "app/workspace/[workspaceId]/logs/page.tsx": { - "modules": 1792, + "modules": 1801, "gateways": { - "apps/sim/app/workspace/[workspaceId]/logs/logs.tsx": 1776, + "apps/sim/app/workspace/[workspaceId]/logs/logs.tsx": 1785, + "apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/execution-snapshot.tsx": 528, "apps/sim/triggers/registry.ts": 528, - "apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/execution-snapshot.tsx": 519, - "apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/index.ts": 467, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 461, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 399, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts": 390, + "apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/index.ts": 474, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 468, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 406, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts": 397, "apps/sim/blocks/registry.ts": 357 } }, @@ -340,16 +340,16 @@ "gateways": {} }, "app/workspace/[workspaceId]/settings/[section]/page.tsx": { - "modules": 2453, + "modules": 2816, "gateways": { - "apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx": 752, - "apps/sim/app/workspace/[workspaceId]/settings/section-warmers.ts": 563, + "apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx": 1112, + "apps/sim/app/workspace/[workspaceId]/settings/section-warmers.ts": 923, "apps/sim/triggers/registry.ts": 489, - "apps/sim/lib/auth/index.ts": 406, - "apps/sim/blocks/registry.ts": 363, - "apps/sim/lib/webhooks/providers/index.ts": 122, - "apps/sim/lib/webhooks/providers/registry.ts": 120, - "apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx": 107 + "apps/sim/lib/auth/index.ts": 408, + "apps/sim/ee/workspace-forking/components/forks.tsx": 378, + "apps/sim/ee/workspace-forking/components/fork-sync-detail-view/fork-sync-detail-view.tsx": 370, + "apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx": 362, + "apps/sim/blocks/registry.ts": 356 } }, "app/workspace/[workspaceId]/settings/billing/credit-usage/layout.tsx": { @@ -361,15 +361,15 @@ "gateways": {} }, "app/workspace/[workspaceId]/settings/billing/credit-usage/page.tsx": { - "modules": 1639, + "modules": 1642, "gateways": { - "apps/sim/lib/auth/index.ts": 1482, + "apps/sim/lib/auth/index.ts": 1485, "apps/sim/blocks/registry.ts": 866, "apps/sim/blocks/registry-maps.ts": 863, "apps/sim/triggers/index.ts": 491, "apps/sim/triggers/registry.ts": 489, - "apps/sim/lib/webhooks/providers/index.ts": 125, - "apps/sim/lib/webhooks/providers/registry.ts": 122, + "apps/sim/lib/webhooks/providers/index.ts": 124, + "apps/sim/lib/webhooks/providers/registry.ts": 121, "apps/sim/lib/uploads/utils/file-utils.server.ts": 53 } }, @@ -378,16 +378,16 @@ "gateways": {} }, "app/workspace/[workspaceId]/settings/layout.tsx": { - "modules": 1819, + "modules": 2181, "gateways": { - "apps/sim/app/workspace/[workspaceId]/settings/section-warmers.ts": 1781, + "apps/sim/app/workspace/[workspaceId]/settings/section-warmers.ts": 2143, "apps/sim/triggers/registry.ts": 528, - "apps/sim/blocks/registry.ts": 364, - "apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx": 113, - "apps/sim/ee/access-control/components/access-control.tsx": 79, - "apps/sim/ee/access-control/components/group-detail.tsx": 77, - "apps/sim/connectors/registry.ts": 72, - "apps/sim/triggers/clickup/index.ts": 32 + "apps/sim/ee/workspace-forking/components/forks.tsx": 380, + "apps/sim/ee/workspace-forking/components/fork-sync-detail-view/fork-sync-detail-view.tsx": 372, + "apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx": 364, + "apps/sim/blocks/registry.ts": 357, + "apps/sim/ee/workspace-forking/components/fork-sync/fork-workflow-diff-modal.tsx": 356, + "apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/index.ts": 354 } }, "app/workspace/[workspaceId]/settings/page.tsx": { @@ -408,7 +408,7 @@ } }, "app/workspace/[workspaceId]/settings/secrets/[credentialId]/page.tsx": { - "modules": 1200, + "modules": 1201, "gateways": { "apps/sim/triggers/registry.ts": 528, "apps/sim/blocks/registry.ts": 380, @@ -429,29 +429,29 @@ "gateways": {} }, "app/workspace/[workspaceId]/settings/usage/events/page.tsx": { - "modules": 1646, + "modules": 1649, "gateways": { - "apps/sim/lib/auth/index.ts": 1482, + "apps/sim/lib/auth/index.ts": 1485, "apps/sim/blocks/registry.ts": 866, "apps/sim/blocks/registry-maps.ts": 863, "apps/sim/triggers/index.ts": 491, "apps/sim/triggers/registry.ts": 489, - "apps/sim/lib/webhooks/providers/index.ts": 125, - "apps/sim/lib/webhooks/providers/registry.ts": 122, + "apps/sim/lib/webhooks/providers/index.ts": 124, + "apps/sim/lib/webhooks/providers/registry.ts": 121, "apps/sim/lib/uploads/utils/file-utils.server.ts": 53 } }, "app/workspace/[workspaceId]/skills/[skillId]/page.tsx": { - "modules": 1421, + "modules": 1429, "gateways": { - "apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx": 1420, + "apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx": 1428, "apps/sim/triggers/registry.ts": 528, "apps/sim/blocks/registry.ts": 376, "apps/sim/blocks/registry-maps.ts": 374, - "apps/sim/app/workspace/[workspaceId]/skills/components/skill-fields/index.ts": 232, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx": 229, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts": 93, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/index.ts": 82 + "apps/sim/app/workspace/[workspaceId]/skills/components/skill-fields/index.ts": 240, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx": 237, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts": 92, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/index.ts": 90 } }, "app/workspace/[workspaceId]/skills/error.tsx": { @@ -459,23 +459,23 @@ "gateways": {} }, "app/workspace/[workspaceId]/skills/new/page.tsx": { - "modules": 1419, + "modules": 1427, "gateways": { - "apps/sim/app/workspace/[workspaceId]/skills/new/skill-create.tsx": 1418, + "apps/sim/app/workspace/[workspaceId]/skills/new/skill-create.tsx": 1426, "apps/sim/triggers/registry.ts": 528, "apps/sim/blocks/registry.ts": 376, "apps/sim/blocks/registry-maps.ts": 374, - "apps/sim/app/workspace/[workspaceId]/skills/components/skill-fields/index.ts": 232, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx": 229, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts": 93, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/index.ts": 82 + "apps/sim/app/workspace/[workspaceId]/skills/components/skill-fields/index.ts": 240, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx": 237, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts": 92, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/index.ts": 90 } }, "app/workspace/[workspaceId]/skills/page.tsx": { - "modules": 1085, + "modules": 1086, "gateways": { - "apps/sim/app/workspace/[workspaceId]/skills/skills.tsx": 1081, - "apps/sim/app/workspace/[workspaceId]/integrations/components/showcase-with-explore/index.ts": 951, + "apps/sim/app/workspace/[workspaceId]/skills/skills.tsx": 1082, + "apps/sim/app/workspace/[workspaceId]/integrations/components/showcase-with-explore/index.ts": 952, "apps/sim/blocks/registry.ts": 941, "apps/sim/blocks/registry-maps.ts": 939, "apps/sim/triggers/index.ts": 530, @@ -493,15 +493,15 @@ "gateways": {} }, "app/workspace/[workspaceId]/tables/[tableId]/page.tsx": { - "modules": 1921, + "modules": 1930, "gateways": { - "apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx": 1861, + "apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx": 1870, "apps/sim/triggers/registry.ts": 528, - "apps/sim/app/workspace/[workspaceId]/w/components/preview/index.ts": 426, - "apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/index.ts": 376, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 372, + "apps/sim/app/workspace/[workspaceId]/w/components/preview/index.ts": 427, + "apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/index.ts": 375, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 371, "apps/sim/blocks/registry.ts": 338, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 321, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 320, "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts": 312 } }, @@ -514,14 +514,14 @@ "gateways": {} }, "app/workspace/[workspaceId]/tables/page.tsx": { - "modules": 2007, + "modules": 2010, "gateways": { "apps/sim/triggers/registry.ts": 489, - "apps/sim/lib/auth/index.ts": 447, + "apps/sim/lib/auth/index.ts": 450, "apps/sim/blocks/registry.ts": 362, "apps/sim/app/workspace/[workspaceId]/tables/tables.tsx": 203, - "apps/sim/lib/webhooks/providers/index.ts": 122, - "apps/sim/lib/webhooks/providers/registry.ts": 120, + "apps/sim/lib/webhooks/providers/index.ts": 121, + "apps/sim/lib/webhooks/providers/registry.ts": 119, "apps/sim/app/workspace/[workspaceId]/tables/prefetch.ts": 104, "apps/sim/app/workspace/[workspaceId]/lib/authorize-resource-prefetch.ts": 56 } @@ -542,28 +542,28 @@ "gateways": {} }, "app/workspace/[workspaceId]/w/[workflowId]/page.tsx": { - "modules": 2289, + "modules": 2312, "gateways": { - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 2288, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 640, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 592, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 2311, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 655, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 607, "apps/sim/triggers/registry.ts": 528, "apps/sim/blocks/registry.ts": 356, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 266, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/index.ts": 181, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx": 177 + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 283, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/index.ts": 180, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx": 176 } }, "app/workspace/[workspaceId]/w/page.tsx": { - "modules": 2271, + "modules": 2294, "gateways": { - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 1048, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 776, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 1071, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 791, "apps/sim/triggers/registry.ts": 528, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 382, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 399, "apps/sim/blocks/registry.ts": 356, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/index.ts": 186, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx": 182, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/index.ts": 185, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx": 181, "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 177 } },