diff --git a/skyvern-frontend/src/routes/workflows/components/WorkflowVisualComparisonDrawer.tsx b/skyvern-frontend/src/routes/workflows/components/WorkflowVisualComparisonDrawer.tsx new file mode 100644 index 00000000..673648f3 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/components/WorkflowVisualComparisonDrawer.tsx @@ -0,0 +1,359 @@ +import { useCallback, useMemo } from "react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Cross1Icon } from "@radix-ui/react-icons"; +import { + ReactFlowProvider, + useNodesState, + useEdgesState, + NodeChange, + EdgeChange, +} from "@xyflow/react"; +import { WorkflowVersion } from "../hooks/useWorkflowVersionsQuery"; +import { WorkflowBlock, WorkflowSettings } from "../types/workflowTypes"; +import { FlowRenderer } from "../editor/FlowRenderer"; +import { getElements } from "../editor/workflowEditorUtils"; +import { ProxyLocation } from "@/api/types"; +import { AppNode } from "../editor/nodes"; + +type BlockComparison = { + leftBlock?: WorkflowBlock; + rightBlock?: WorkflowBlock; + status: "identical" | "modified" | "added" | "removed"; + identifier: string; +}; + +type Props = { + version1: WorkflowVersion; + version2: WorkflowVersion; + isOpen: boolean; + onClose: () => void; +}; + +function getBlockIdentifier(block: WorkflowBlock): string { + return `${block.block_type}:${block.label}`; +} + +function areBlocksIdentical( + block1: WorkflowBlock, + block2: WorkflowBlock, +): boolean { + // Convert blocks to string representation for comparison + // Remove dynamic fields that shouldn't affect equality + const normalize = (block: WorkflowBlock) => { + const normalized = { ...block }; + // Remove output_parameter as it might have different IDs + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { output_parameter, ...rest } = normalized; + return JSON.stringify(rest, Object.keys(rest).sort()); + }; + + return normalize(block1) === normalize(block2); +} + +function compareWorkflowBlocks( + blocks1: WorkflowBlock[], + blocks2: WorkflowBlock[], +): BlockComparison[] { + const comparisons: BlockComparison[] = []; + const processedBlocks = new Set(); + + // Create maps for quick lookup + const blocks1Map = new Map(); + const blocks2Map = new Map(); + + blocks1.forEach((block) => { + const identifier = getBlockIdentifier(block); + blocks1Map.set(identifier, block); + }); + + blocks2.forEach((block) => { + const identifier = getBlockIdentifier(block); + blocks2Map.set(identifier, block); + }); + + // Compare blocks that exist in the first version + blocks1.forEach((block1) => { + const identifier = getBlockIdentifier(block1); + const block2 = blocks2Map.get(identifier); + processedBlocks.add(identifier); + + if (block2) { + // Block exists in both versions + const isIdentical = areBlocksIdentical(block1, block2); + comparisons.push({ + leftBlock: block1, + rightBlock: block2, + status: isIdentical ? "identical" : "modified", + identifier, + }); + } else { + // Block was removed in version 2 + comparisons.push({ + leftBlock: block1, + rightBlock: undefined, + status: "removed", + identifier, + }); + } + }); + + // Check for blocks that were added in version 2 + blocks2.forEach((block2) => { + const identifier = getBlockIdentifier(block2); + if (!processedBlocks.has(identifier)) { + comparisons.push({ + leftBlock: undefined, + rightBlock: block2, + status: "added", + identifier, + }); + } + }); + + return comparisons; +} + +function getWorkflowElements(version: WorkflowVersion) { + const settings: WorkflowSettings = { + proxyLocation: version.proxy_location || ProxyLocation.Residential, + webhookCallbackUrl: version.webhook_callback_url || "", + persistBrowserSession: version.persist_browser_session, + model: version.model, + maxScreenshotScrolls: version.max_screenshot_scrolls || 3, + extraHttpHeaders: version.extra_http_headers + ? JSON.stringify(version.extra_http_headers) + : null, + useScriptCache: version.generate_script, + scriptCacheKey: version.cache_key, + aiFallback: version.ai_fallback ?? true, + runSequentially: version.run_sequentially ?? false, + }; + + return getElements( + version.workflow_definition?.blocks || [], + settings, + false, // not editable in comparison view + ); +} + +function WorkflowComparisonRenderer({ + version, + blockColors, +}: { + version: WorkflowVersion; + title: string; + blockColors?: Map; +}) { + // Memoize elements creation to prevent unnecessary re-renders + const elements = useMemo(() => getWorkflowElements(version), [version]); + + // Memoize the colored nodes to prevent re-computation + const coloredNodes = useMemo(() => { + if (!blockColors || blockColors.size === 0) { + return elements.nodes; + } + + // Apply comparison colors to block nodes + return elements.nodes.map((node) => { + // Check if this is a workflow block node (not start/nodeAdder) + if ( + node.type !== "nodeAdder" && + node.type !== "start" && + node.data && + node.data.label + ) { + // This is a workflow block node - get its identifier and color + const identifier = `${node.type}:${node.data.label}`; + const color = blockColors.get(identifier); + + if (color) { + return { + ...node, + data: { + ...node.data, + comparisonColor: color, + }, + style: { + ...node.style, + backgroundColor: color, + border: `2px solid ${color}`, + }, + }; + } + } + return node; + }); + }, [elements.nodes, blockColors]); + + const [nodes, setNodes, onNodesChange] = useNodesState( + coloredNodes as AppNode[], + ); + const [edges, setEdges, onEdgesChange] = useEdgesState(elements.edges); + + const handleNodesChange = useCallback( + (changes: NodeChange[]) => { + onNodesChange(changes); + }, + [onNodesChange], + ); + + const handleEdgesChange = useCallback( + (changes: EdgeChange[]) => { + onEdgesChange(changes); + }, + [onEdgesChange], + ); + + return ( +
+
+
+
+ + {version.title}, version: {version.version} + + + {version.workflow_definition?.blocks?.length || 0} block + {(version.workflow_definition?.blocks?.length || 0) !== 1 + ? "s" + : ""} + +
+
+
+
+ +
+
+ ); +} + +function WorkflowVisualComparisonDrawer({ + version1, + version2, + isOpen, + onClose, +}: Props) { + if (!isOpen) return null; + + const blocks1 = version1.workflow_definition?.blocks || []; + const blocks2 = version2.workflow_definition?.blocks || []; + const comparisons = compareWorkflowBlocks(blocks1, blocks2); + + // Statistics + const stats = { + identical: comparisons.filter((c) => c.status === "identical").length, + modified: comparisons.filter((c) => c.status === "modified").length, + added: comparisons.filter((c) => c.status === "added").length, + removed: comparisons.filter((c) => c.status === "removed").length, + }; + + // Create color mapping for block identifiers + const getComparisonColor = ( + status: "identical" | "modified" | "added" | "removed", + ): string => { + switch (status) { + case "identical": + return "#86efac"; // green-300 + case "modified": + return "#facc15"; // yellow-400 + case "added": + case "removed": + return "#c2410c"; // orange-700 + default: + return ""; + } + }; + + // Create maps for each version's block colors + const version1BlockColors = new Map(); + const version2BlockColors = new Map(); + + comparisons.forEach((comparison) => { + const color = getComparisonColor(comparison.status); + + // For version1 blocks + if (comparison.leftBlock) { + version1BlockColors.set(comparison.identifier, color); + } + + // For version2 blocks + if (comparison.rightBlock) { + version2BlockColors.set(comparison.identifier, color); + } + }); + + return ( +
+ {/* Main Drawer */} +
+ {/* Header */} +
+
+

+ Visual Workflow Versions Comparison +

+
+
+
+
+
+ Identical ({stats.identical}) +
+
+
+ Modified ({stats.modified}) +
+
+
+ Added ({stats.added}) +
+
+
+ Removed ({stats.removed}) +
+
+ +
+
+ + {/* Content */} +
+ +
+ {/* Version 1 Column */} + + + {/* Version 2 Column */} + +
+
+
+
+
+ ); +} + +export { WorkflowVisualComparisonDrawer }; diff --git a/skyvern-frontend/src/routes/workflows/editor/WorkflowHeader.tsx b/skyvern-frontend/src/routes/workflows/editor/WorkflowHeader.tsx index 0183020f..31fc150d 100644 --- a/skyvern-frontend/src/routes/workflows/editor/WorkflowHeader.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/WorkflowHeader.tsx @@ -1,6 +1,7 @@ import { ChevronDownIcon, ChevronUpIcon, + ClockIcon, CodeIcon, CopyIcon, PlayIcon, @@ -53,6 +54,7 @@ type Props = { onCacheKeyValuesClick: () => void; onSave: () => void; onRun?: () => void; + onHistory?: () => void; }; function WorkflowHeader({ @@ -72,6 +74,7 @@ function WorkflowHeader({ onCacheKeyValuesClick, onSave, onRun, + onHistory, }: Props) { const { title, setTitle } = useWorkflowTitleStore(); const workflowChangesStore = useWorkflowHasChangesStore(); @@ -280,6 +283,23 @@ function WorkflowHeader({ Save + + + + + + History + + + )} + + + +
+ +
+ + ); +} + +function WorkflowComparisonPanel({ version1, version2, onSelectState }: Props) { + const comparisons = useMemo(() => { + const blocks1 = version1?.workflow_definition?.blocks || []; + const blocks2 = version2?.workflow_definition?.blocks || []; + return compareWorkflowBlocks(blocks1, blocks2); + }, [ + version1?.workflow_definition?.blocks, + version2?.workflow_definition?.blocks, + ]); + + // Statistics + const stats = useMemo( + () => ({ + identical: comparisons.filter((c) => c.status === "identical").length, + modified: comparisons.filter((c) => c.status === "modified").length, + added: comparisons.filter((c) => c.status === "added").length, + removed: comparisons.filter((c) => c.status === "removed").length, + }), + [comparisons], + ); + + // Create color mapping for block identifiers + const getComparisonColor = ( + status: "identical" | "modified" | "added" | "removed", + ): string => { + switch (status) { + case "identical": + return "#86efac"; // green-300 + case "modified": + return "#facc15"; // yellow-400 + case "added": + case "removed": + return "#c2410c"; // orange-700 + default: + return ""; + } + }; + + // Create memoized maps for each version's block colors + const { version1BlockColors, version2BlockColors } = useMemo(() => { + const v1Colors = new Map(); + const v2Colors = new Map(); + + comparisons.forEach((comparison) => { + const color = getComparisonColor(comparison.status); + + // For version1 blocks + if (comparison.leftBlock) { + v1Colors.set(comparison.identifier, color); + } + + // For version2 blocks + if (comparison.rightBlock) { + v2Colors.set(comparison.identifier, color); + } + }); + + return { + version1BlockColors: v1Colors, + version2BlockColors: v2Colors, + }; + }, [comparisons]); + + return ( +
+ {/* Header */} +
+

Version Comparison

+
+
+
+ Identical ({stats.identical}) +
+
+
+ Modified ({stats.modified}) +
+
+
+ Added ({stats.added}) +
+
+
+ Removed ({stats.removed}) +
+
+
+ + + + {/* Content - Two columns for comparison */} +
+
+ {/* Version 1 Column */} + + + + + {/* Version 2 Column */} + + + +
+
+
+ ); +} + +export { WorkflowComparisonPanel }; diff --git a/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowHistoryPanel.tsx b/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowHistoryPanel.tsx new file mode 100644 index 00000000..c23fe213 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowHistoryPanel.tsx @@ -0,0 +1,196 @@ +import { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Badge } from "@/components/ui/badge"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Separator } from "@/components/ui/separator"; +import { basicLocalTimeFormat } from "@/util/timeFormat"; +import { + useWorkflowVersionsQuery, + WorkflowVersion, +} from "../../hooks/useWorkflowVersionsQuery"; + +type Props = { + workflowPermanentId: string; + onCompare?: ( + version1: WorkflowVersion, + version2: WorkflowVersion, + mode?: "visual" | "json", + ) => void; +}; + +function WorkflowHistoryPanel({ workflowPermanentId, onCompare }: Props) { + const { data: versions, isLoading } = useWorkflowVersionsQuery({ + workflowPermanentId, + }); + const [selectedVersions, setSelectedVersions] = useState>( + new Set(), + ); + + // Set default selection: current (latest) and previous version + useEffect(() => { + if (versions && versions.length > 0) { + // Versions are already sorted by version descending from the backend + const defaultSelection = new Set(); + + // Select the latest version (current) + const firstVersion = versions[0]; + if (firstVersion) defaultSelection.add(firstVersion.version); + + // Select the previous version if it exists + const secondVersion = versions[1]; + if (secondVersion) defaultSelection.add(secondVersion.version); + + setSelectedVersions(defaultSelection); + } + }, [versions]); + + const handleVersionToggle = (version: number) => { + const newSelected = new Set(selectedVersions); + + if (newSelected.has(version)) { + newSelected.delete(version); + } else { + // If already at max 2 selections, remove the oldest selection + if (newSelected.size >= 2) { + const versionsArray = Array.from(newSelected); + // Remove first (oldest) selection + const versionToDelete = versionsArray[0]; + if (versionToDelete) newSelected.delete(versionToDelete); + } + newSelected.add(version); + } + + setSelectedVersions(newSelected); + }; + + const handleCompare = (mode: "visual" | "json" = "visual") => { + if (selectedVersions.size === 2 && versions) { + const selectedVersionsArray = Array.from(selectedVersions); + const version1 = versions.find( + (v) => v.version === selectedVersionsArray[0], + ); + const version2 = versions.find( + (v) => v.version === selectedVersionsArray[1], + ); + + if (version1 && version2) { + onCompare?.(version1, version2, mode); + } + } + }; + + // Versions are already sorted by the backend, no need to sort again + const sortedVersions = versions || []; + const canCompare = selectedVersions.size === 2; + + return ( +
+ {/* Header */} +
+
+

Workflow History

+
+ {selectedVersions.size}/2 selected +
+
+

+ Select up to 2 versions to compare. Current and previous versions are + selected by default. +

+
+ + + + {/* Version List */} + +
+ {isLoading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ +
+ + +
+
+ ))} +
+ ) : sortedVersions.length === 0 ? ( +
+ No version history found +
+ ) : ( +
+ {sortedVersions.map((workflow, index) => { + const isSelected = selectedVersions.has(workflow.version); + const isCurrent = index === 0; + + return ( +
handleVersionToggle(workflow.version)} + > + {}} // Handled by parent click + /> + +
+
+ + Version {workflow.version} + + {isCurrent && ( + Current + )} +
+
+ Modified: {basicLocalTimeFormat(workflow.modified_at)} +
+
+
+ ); + })} +
+ )} +
+
+ + + + {/* Footer */} +
+
+ + +
+
+
+ ); +} + +export { WorkflowHistoryPanel }; diff --git a/skyvern-frontend/src/routes/workflows/hooks/useWorkflowVersionsQuery.ts b/skyvern-frontend/src/routes/workflows/hooks/useWorkflowVersionsQuery.ts new file mode 100644 index 00000000..4c079cf9 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/hooks/useWorkflowVersionsQuery.ts @@ -0,0 +1,27 @@ +import { getClient } from "@/api/AxiosClient"; +import { useCredentialGetter } from "@/hooks/useCredentialGetter"; +import { useQuery } from "@tanstack/react-query"; +import { WorkflowApiResponse } from "../types/workflowTypes"; + +type Props = { + workflowPermanentId?: string; +}; + +export type WorkflowVersion = WorkflowApiResponse; + +function useWorkflowVersionsQuery({ workflowPermanentId }: Props) { + const credentialGetter = useCredentialGetter(); + + return useQuery({ + queryKey: ["workflowVersions", workflowPermanentId], + queryFn: async () => { + const client = await getClient(credentialGetter); + return client + .get(`/workflows/${workflowPermanentId}/versions`) + .then((response) => response.data); + }, + enabled: !!workflowPermanentId, + }); +} + +export { useWorkflowVersionsQuery }; diff --git a/skyvern-frontend/src/store/WorkflowPanelStore.ts b/skyvern-frontend/src/store/WorkflowPanelStore.ts index 87c3e5cd..b966529d 100644 --- a/skyvern-frontend/src/store/WorkflowPanelStore.ts +++ b/skyvern-frontend/src/store/WorkflowPanelStore.ts @@ -1,14 +1,24 @@ import { create } from "zustand"; +import { WorkflowVersion } from "@/routes/workflows/hooks/useWorkflowVersionsQuery"; type WorkflowPanelState = { active: boolean; - content: "cacheKeyValues" | "parameters" | "nodeLibrary"; + content: + | "cacheKeyValues" + | "parameters" + | "nodeLibrary" + | "history" + | "comparison"; data?: { previous?: string | null; next?: string | null; parent?: string; connectingEdgeType?: string; disableLoop?: boolean; + // For comparison panel + version1?: WorkflowVersion; + version2?: WorkflowVersion; + showComparison?: boolean; }; }; diff --git a/skyvern/forge/sdk/db/client.py b/skyvern/forge/sdk/db/client.py index d9fae239..8704b955 100644 --- a/skyvern/forge/sdk/db/client.py +++ b/skyvern/forge/sdk/db/client.py @@ -1459,6 +1459,30 @@ class AgentDB: LOG.error("SQLAlchemyError", exc_info=True) raise + async def get_workflow_versions_by_permanent_id( + self, + workflow_permanent_id: str, + organization_id: str | None = None, + exclude_deleted: bool = True, + ) -> list[Workflow]: + """ + Get all versions of a workflow by its permanent ID, ordered by version descending (newest first). + """ + try: + get_workflows_query = select(WorkflowModel).filter_by(workflow_permanent_id=workflow_permanent_id) + if exclude_deleted: + get_workflows_query = get_workflows_query.filter(WorkflowModel.deleted_at.is_(None)) + if organization_id: + get_workflows_query = get_workflows_query.filter_by(organization_id=organization_id) + get_workflows_query = get_workflows_query.order_by(WorkflowModel.version.desc()) + + async with self.Session() as session: + workflows = (await session.scalars(get_workflows_query)).all() + return [convert_to_workflow(workflow, self.debug_enabled) for workflow in workflows] + except SQLAlchemyError: + LOG.error("SQLAlchemyError", exc_info=True) + raise + async def get_workflows_by_permanent_ids( self, workflow_permanent_ids: list[str], diff --git a/skyvern/forge/sdk/routes/agent_protocol.py b/skyvern/forge/sdk/routes/agent_protocol.py index 86992186..60b1e70b 100644 --- a/skyvern/forge/sdk/routes/agent_protocol.py +++ b/skyvern/forge/sdk/routes/agent_protocol.py @@ -1840,6 +1840,36 @@ async def get_workflow( ) +@legacy_base_router.get( + "/workflows/{workflow_permanent_id}/versions", + response_model=list[Workflow], + tags=["agent"], + openapi_extra={ + "x-fern-sdk-method-name": "get_workflow_versions", + }, +) +@legacy_base_router.get( + "/workflows/{workflow_permanent_id}/versions/", response_model=list[Workflow], include_in_schema=False +) +async def get_workflow_versions( + workflow_permanent_id: str, + current_org: Organization = Depends(org_auth_service.get_current_org), + template: bool = Query(False), +) -> list[Workflow]: + """ + Get all versions of a workflow by its permanent ID. + """ + analytics.capture("skyvern-oss-agent-workflow-versions-get") + if template: + if workflow_permanent_id not in await app.STORAGE.retrieve_global_workflows(): + raise InvalidTemplateWorkflowPermanentId(workflow_permanent_id=workflow_permanent_id) + + return await app.WORKFLOW_SERVICE.get_workflow_versions_by_permanent_id( + workflow_permanent_id=workflow_permanent_id, + organization_id=None if template else current_org.organization_id, + ) + + @legacy_base_router.post( "/suggest/{ai_suggestion_type}", include_in_schema=False, diff --git a/skyvern/forge/sdk/workflow/service.py b/skyvern/forge/sdk/workflow/service.py index 8111f9f8..6f428456 100644 --- a/skyvern/forge/sdk/workflow/service.py +++ b/skyvern/forge/sdk/workflow/service.py @@ -746,6 +746,23 @@ class WorkflowService: return workflow + async def get_workflow_versions_by_permanent_id( + self, + workflow_permanent_id: str, + organization_id: str | None = None, + exclude_deleted: bool = True, + ) -> list[Workflow]: + """ + Get all versions of a workflow by its permanent ID. + Returns an empty list if no workflow is found with that permanent ID. + """ + workflows = await app.DATABASE.get_workflow_versions_by_permanent_id( + workflow_permanent_id, + organization_id=organization_id, + exclude_deleted=exclude_deleted, + ) + return workflows + async def get_block_outputs_for_debug_session( self, workflow_permanent_id: str,