Feature/workflow history (#3432)
Co-authored-by: Shuchang Zheng <wintonzheng0325@gmail.com>
This commit is contained in:
@@ -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<string>();
|
||||||
|
|
||||||
|
// Create maps for quick lookup
|
||||||
|
const blocks1Map = new Map<string, WorkflowBlock>();
|
||||||
|
const blocks2Map = new Map<string, WorkflowBlock>();
|
||||||
|
|
||||||
|
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<string, string>;
|
||||||
|
}) {
|
||||||
|
// 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<AppNode>[]) => {
|
||||||
|
onNodesChange(changes);
|
||||||
|
},
|
||||||
|
[onNodesChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleEdgesChange = useCallback(
|
||||||
|
(changes: EdgeChange[]) => {
|
||||||
|
onEdgesChange(changes);
|
||||||
|
},
|
||||||
|
[onEdgesChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full">
|
||||||
|
<div className="mb-4 flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="mb-1 flex items-center justify-center gap-2">
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{version.title}, version: {version.version}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{version.workflow_definition?.blocks?.length || 0} block
|
||||||
|
{(version.workflow_definition?.blocks?.length || 0) !== 1
|
||||||
|
? "s"
|
||||||
|
: ""}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="h-[calc(100%-3rem)] rounded-lg border bg-white">
|
||||||
|
<FlowRenderer
|
||||||
|
hideBackground={false}
|
||||||
|
hideControls={true}
|
||||||
|
nodes={nodes}
|
||||||
|
edges={edges}
|
||||||
|
setNodes={setNodes}
|
||||||
|
setEdges={setEdges}
|
||||||
|
onNodesChange={handleNodesChange}
|
||||||
|
onEdgesChange={handleEdgesChange}
|
||||||
|
initialTitle={version.title}
|
||||||
|
workflow={version}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, string>();
|
||||||
|
const version2BlockColors = new Map<string, string>();
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="fixed inset-0 z-50 flex bg-black bg-opacity-50">
|
||||||
|
{/* Main Drawer */}
|
||||||
|
<div className="bg-navy mx-auto my-4 flex w-full max-w-[95vw] flex-col rounded-lg shadow-xl">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between border-b p-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<h2 className="text-xl font-semibold">
|
||||||
|
Visual Workflow Versions Comparison
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex gap-3 text-sm">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="h-3 w-3 rounded-full bg-green-300"></div>
|
||||||
|
<span>Identical ({stats.identical})</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="h-3 w-3 rounded-full bg-yellow-400"></div>
|
||||||
|
<span>Modified ({stats.modified})</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="h-3 w-3 rounded-full bg-orange-700"></div>
|
||||||
|
<span>Added ({stats.added})</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="h-3 w-3 rounded-full bg-orange-700"></div>
|
||||||
|
<span>Removed ({stats.removed})</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="icon" onClick={onClose}>
|
||||||
|
<Cross1Icon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex flex-1 overflow-hidden">
|
||||||
|
<ReactFlowProvider>
|
||||||
|
<div className="grid flex-1 grid-cols-2 gap-4 p-6">
|
||||||
|
{/* Version 1 Column */}
|
||||||
|
<WorkflowComparisonRenderer
|
||||||
|
version={version1}
|
||||||
|
title={`Version ${version1.version}`}
|
||||||
|
blockColors={version1BlockColors}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Version 2 Column */}
|
||||||
|
<WorkflowComparisonRenderer
|
||||||
|
version={version2}
|
||||||
|
title={`Version ${version2.version}`}
|
||||||
|
blockColors={version2BlockColors}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ReactFlowProvider>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { WorkflowVisualComparisonDrawer };
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
ChevronUpIcon,
|
ChevronUpIcon,
|
||||||
|
ClockIcon,
|
||||||
CodeIcon,
|
CodeIcon,
|
||||||
CopyIcon,
|
CopyIcon,
|
||||||
PlayIcon,
|
PlayIcon,
|
||||||
@@ -53,6 +54,7 @@ type Props = {
|
|||||||
onCacheKeyValuesClick: () => void;
|
onCacheKeyValuesClick: () => void;
|
||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
onRun?: () => void;
|
onRun?: () => void;
|
||||||
|
onHistory?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function WorkflowHeader({
|
function WorkflowHeader({
|
||||||
@@ -72,6 +74,7 @@ function WorkflowHeader({
|
|||||||
onCacheKeyValuesClick,
|
onCacheKeyValuesClick,
|
||||||
onSave,
|
onSave,
|
||||||
onRun,
|
onRun,
|
||||||
|
onHistory,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { title, setTitle } = useWorkflowTitleStore();
|
const { title, setTitle } = useWorkflowTitleStore();
|
||||||
const workflowChangesStore = useWorkflowHasChangesStore();
|
const workflowChangesStore = useWorkflowHasChangesStore();
|
||||||
@@ -280,6 +283,23 @@ function WorkflowHeader({
|
|||||||
<TooltipContent>Save</TooltipContent>
|
<TooltipContent>Save</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="tertiary"
|
||||||
|
className="size-10 min-w-[2.5rem]"
|
||||||
|
onClick={() => {
|
||||||
|
onHistory?.();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ClockIcon className="size-6" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>History</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
<Button variant="tertiary" size="lg" onClick={onParametersClick}>
|
<Button variant="tertiary" size="lg" onClick={onParametersClick}>
|
||||||
<span className="mr-2">Parameters</span>
|
<span className="mr-2">Parameters</span>
|
||||||
{parametersPanelOpen ? (
|
{parametersPanelOpen ? (
|
||||||
|
|||||||
@@ -62,8 +62,13 @@ import { AppNode, isWorkflowBlockNode, WorkflowBlockNode } from "./nodes";
|
|||||||
import { WorkflowNodeLibraryPanel } from "./panels/WorkflowNodeLibraryPanel";
|
import { WorkflowNodeLibraryPanel } from "./panels/WorkflowNodeLibraryPanel";
|
||||||
import { WorkflowParametersPanel } from "./panels/WorkflowParametersPanel";
|
import { WorkflowParametersPanel } from "./panels/WorkflowParametersPanel";
|
||||||
import { WorkflowCacheKeyValuesPanel } from "./panels/WorkflowCacheKeyValuesPanel";
|
import { WorkflowCacheKeyValuesPanel } from "./panels/WorkflowCacheKeyValuesPanel";
|
||||||
import { getWorkflowErrors } from "./workflowEditorUtils";
|
import { WorkflowComparisonPanel } from "./panels/WorkflowComparisonPanel";
|
||||||
|
import { getWorkflowErrors, getElements } from "./workflowEditorUtils";
|
||||||
import { WorkflowHeader } from "./WorkflowHeader";
|
import { WorkflowHeader } from "./WorkflowHeader";
|
||||||
|
import { WorkflowHistoryPanel } from "./panels/WorkflowHistoryPanel";
|
||||||
|
import { WorkflowVersion } from "../hooks/useWorkflowVersionsQuery";
|
||||||
|
import { WorkflowSettings } from "../types/workflowTypes";
|
||||||
|
import { ProxyLocation } from "@/api/types";
|
||||||
import {
|
import {
|
||||||
nodeAdderNode,
|
nodeAdderNode,
|
||||||
createNode,
|
createNode,
|
||||||
@@ -567,9 +572,134 @@ function Workspace({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Centralized function to manage comparison and panel states
|
||||||
|
function clearComparisonViewAndShowFreshIfActive(active: boolean) {
|
||||||
|
setWorkflowPanelState({
|
||||||
|
active,
|
||||||
|
content: "history",
|
||||||
|
data: {
|
||||||
|
showComparison: false,
|
||||||
|
version1: undefined,
|
||||||
|
version2: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleHistoryPanel() {
|
||||||
|
// Capture current state before making changes
|
||||||
|
const wasInComparisonMode = workflowPanelState.data?.showComparison;
|
||||||
|
const isHistoryPanelOpen =
|
||||||
|
workflowPanelState.active && workflowPanelState.content === "history";
|
||||||
|
|
||||||
|
// Always reset code view when toggling history
|
||||||
|
setShowAllCode(false);
|
||||||
|
|
||||||
|
if (wasInComparisonMode || isHistoryPanelOpen) {
|
||||||
|
// If in comparison mode or history panel is open, close it
|
||||||
|
clearComparisonViewAndShowFreshIfActive(false);
|
||||||
|
} else {
|
||||||
|
// Open history panel fresh
|
||||||
|
clearComparisonViewAndShowFreshIfActive(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCodeView() {
|
||||||
|
// Check comparison state BEFORE clearing it
|
||||||
|
const wasInComparisonMode = workflowPanelState.data?.showComparison;
|
||||||
|
|
||||||
|
// Always clear comparison state first
|
||||||
|
clearComparisonViewAndShowFreshIfActive(false);
|
||||||
|
|
||||||
|
if (wasInComparisonMode) {
|
||||||
|
// If we were in comparison mode, exit it and show code
|
||||||
|
setShowAllCode(true);
|
||||||
|
} else {
|
||||||
|
// Normal toggle when not in comparison mode
|
||||||
|
setShowAllCode(!showAllCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const orderedBlockLabels = getOrderedBlockLabels(workflow);
|
const orderedBlockLabels = getOrderedBlockLabels(workflow);
|
||||||
const code = getCode(orderedBlockLabels, blockScripts).join("");
|
const code = getCode(orderedBlockLabels, blockScripts).join("");
|
||||||
|
|
||||||
|
const handleCompareVersions = (
|
||||||
|
version1: WorkflowVersion,
|
||||||
|
version2: WorkflowVersion,
|
||||||
|
mode: "visual" | "json" = "visual",
|
||||||
|
) => {
|
||||||
|
console.log(
|
||||||
|
`${mode === "visual" ? "Visual" : "JSON"} comparison between versions:`,
|
||||||
|
version1.version,
|
||||||
|
"and",
|
||||||
|
version2.version,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Implement visual drawer comparison
|
||||||
|
if (mode === "visual") {
|
||||||
|
console.log("Opening visual comparison panel...");
|
||||||
|
// Keep history panel active but add comparison data
|
||||||
|
setWorkflowPanelState({
|
||||||
|
active: true,
|
||||||
|
content: "history", // Keep history panel active
|
||||||
|
data: {
|
||||||
|
version1: JSON.parse(JSON.stringify(version1)),
|
||||||
|
version2: JSON.parse(JSON.stringify(version2)),
|
||||||
|
showComparison: true, // Add flag to show comparison
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Implement JSON diff comparison
|
||||||
|
if (mode === "json") {
|
||||||
|
// This will open a JSON diff view
|
||||||
|
console.log("Opening JSON diff view...");
|
||||||
|
// Future: setJsonDiffOpen(true);
|
||||||
|
// Future: setJsonDiffVersions({ version1, version2 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectState = (selectedVersion: WorkflowVersion) => {
|
||||||
|
console.log("Loading version into main editor:", selectedVersion.version);
|
||||||
|
|
||||||
|
// Close panels
|
||||||
|
setWorkflowPanelState({
|
||||||
|
active: false,
|
||||||
|
content: "parameters",
|
||||||
|
data: {
|
||||||
|
showComparison: false,
|
||||||
|
version1: undefined,
|
||||||
|
version2: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load the selected version into the main editor
|
||||||
|
const settings: WorkflowSettings = {
|
||||||
|
proxyLocation:
|
||||||
|
selectedVersion.proxy_location || ProxyLocation.Residential,
|
||||||
|
webhookCallbackUrl: selectedVersion.webhook_callback_url || "",
|
||||||
|
persistBrowserSession: selectedVersion.persist_browser_session,
|
||||||
|
model: selectedVersion.model,
|
||||||
|
maxScreenshotScrolls: selectedVersion.max_screenshot_scrolls || 3,
|
||||||
|
extraHttpHeaders: selectedVersion.extra_http_headers
|
||||||
|
? JSON.stringify(selectedVersion.extra_http_headers)
|
||||||
|
: null,
|
||||||
|
useScriptCache: selectedVersion.generate_script,
|
||||||
|
scriptCacheKey: selectedVersion.cache_key,
|
||||||
|
aiFallback: selectedVersion.ai_fallback ?? true,
|
||||||
|
runSequentially: selectedVersion.run_sequentially ?? false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const elements = getElements(
|
||||||
|
selectedVersion.workflow_definition?.blocks || [],
|
||||||
|
settings,
|
||||||
|
true, // editable
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update the main editor with the selected version
|
||||||
|
setNodes(elements.nodes);
|
||||||
|
setEdges(elements.edges);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-full w-full">
|
<div className="relative h-full w-full">
|
||||||
{/* cycle browser dialog */}
|
{/* cycle browser dialog */}
|
||||||
@@ -759,16 +889,33 @@ function Workspace({
|
|||||||
onRun={() => {
|
onRun={() => {
|
||||||
closeWorkflowPanel();
|
closeWorkflowPanel();
|
||||||
}}
|
}}
|
||||||
onShowAllCodeClick={() => {
|
onShowAllCodeClick={toggleCodeView}
|
||||||
setShowAllCode(!showAllCode);
|
onHistory={toggleHistoryPanel}
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* infinite canvas and sub panels when not in debug mode */}
|
{/* infinite canvas and sub panels when not in debug mode */}
|
||||||
{!showBrowser && (
|
{!showBrowser && (
|
||||||
<div className="relative flex h-full w-full overflow-hidden overflow-x-hidden">
|
<div className="relative flex h-full w-full overflow-hidden overflow-x-hidden">
|
||||||
{/* infinite canvas */}
|
{/* infinite canvas or comparison view */}
|
||||||
|
{workflowPanelState.data?.showComparison &&
|
||||||
|
workflowPanelState.data?.version1 &&
|
||||||
|
workflowPanelState.data?.version2 ? (
|
||||||
|
<div
|
||||||
|
className="absolute left-6 top-[6rem]"
|
||||||
|
style={{
|
||||||
|
width: "calc(100% - 32rem)",
|
||||||
|
height: "calc(100vh - 11rem)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<WorkflowComparisonPanel
|
||||||
|
key={`${workflowPanelState.data.version1.workflow_id}v${workflowPanelState.data.version1.version}-${workflowPanelState.data.version2.workflow_id}v${workflowPanelState.data.version2.version}`}
|
||||||
|
version1={workflowPanelState.data.version1}
|
||||||
|
version2={workflowPanelState.data.version2}
|
||||||
|
onSelectState={handleSelectState}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<FlowRenderer
|
<FlowRenderer
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
edges={edges}
|
edges={edges}
|
||||||
@@ -779,6 +926,7 @@ function Workspace({
|
|||||||
initialTitle={initialTitle}
|
initialTitle={initialTitle}
|
||||||
workflow={workflow}
|
workflow={workflow}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* sub panels */}
|
{/* sub panels */}
|
||||||
{workflowPanelState.active && (
|
{workflowPanelState.active && (
|
||||||
@@ -815,6 +963,14 @@ function Workspace({
|
|||||||
<WorkflowParametersPanel />
|
<WorkflowParametersPanel />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{workflowPanelState.content === "history" && (
|
||||||
|
<div className="pointer-events-auto relative right-0 top-[3.5rem] z-30 h-[calc(100vh-14rem)]">
|
||||||
|
<WorkflowHistoryPanel
|
||||||
|
workflowPermanentId={workflowPermanentId!}
|
||||||
|
onCompare={handleCompareVersions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{workflowPanelState.content === "nodeLibrary" && (
|
{workflowPanelState.content === "nodeLibrary" && (
|
||||||
<div className="z-30 h-full w-[25rem]">
|
<div className="z-30 h-full w-[25rem]">
|
||||||
<WorkflowNodeLibraryPanel
|
<WorkflowNodeLibraryPanel
|
||||||
@@ -882,8 +1038,25 @@ function Workspace({
|
|||||||
split={{ left: workflowWidth }}
|
split={{ left: workflowWidth }}
|
||||||
onResize={() => setContainerResizeTrigger((prev) => prev + 1)}
|
onResize={() => setContainerResizeTrigger((prev) => prev + 1)}
|
||||||
>
|
>
|
||||||
{/* code and infinite canvas */}
|
{/* code and infinite canvas or comparison view */}
|
||||||
<div className="relative h-full w-full">
|
<div className="relative h-full w-full">
|
||||||
|
{workflowPanelState.data?.showComparison &&
|
||||||
|
workflowPanelState.data?.version1 &&
|
||||||
|
workflowPanelState.data?.version2 ? (
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 top-[8.5rem] p-6"
|
||||||
|
style={{
|
||||||
|
height: "calc(100vh - 14.5rem)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<WorkflowComparisonPanel
|
||||||
|
key={`${workflowPanelState.data.version1.workflow_id}v${workflowPanelState.data.version1.version}-${workflowPanelState.data.version2.workflow_id}v${workflowPanelState.data.version2.version}`}
|
||||||
|
version1={workflowPanelState.data.version1}
|
||||||
|
version2={workflowPanelState.data.version2}
|
||||||
|
onSelectState={handleSelectState}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"skyvern-split-left flex h-full w-[200%] translate-x-[-50%] transition-none duration-300",
|
"skyvern-split-left flex h-full w-[200%] translate-x-[-50%] transition-none duration-300",
|
||||||
@@ -940,10 +1113,63 @@ function Workspace({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* browser & timeline */}
|
{/* browser & timeline */}
|
||||||
<div className="skyvern-split-right relative flex h-full items-end justify-center bg-[#020617] p-4 pl-6">
|
<div className="skyvern-split-right relative flex h-full items-end justify-center bg-[#020617] p-4 pl-6">
|
||||||
|
{/* sub panels */}
|
||||||
|
{workflowPanelState.active && (
|
||||||
|
<div
|
||||||
|
className={cn("absolute right-6 top-[8.5rem] z-30", {
|
||||||
|
"left-6": workflowPanelState.content === "nodeLibrary",
|
||||||
|
})}
|
||||||
|
style={{
|
||||||
|
height:
|
||||||
|
workflowPanelState.content === "nodeLibrary"
|
||||||
|
? "calc(100vh - 14rem)"
|
||||||
|
: "unset",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{workflowPanelState.content === "cacheKeyValues" && (
|
||||||
|
<WorkflowCacheKeyValuesPanel
|
||||||
|
cacheKeyValues={cacheKeyValues}
|
||||||
|
pending={cacheKeyValuesLoading}
|
||||||
|
scriptKey={workflow.cache_key ?? "default"}
|
||||||
|
onDelete={(cacheKeyValue) => {
|
||||||
|
setToDeleteCacheKeyValue(cacheKeyValue);
|
||||||
|
setOpenConfirmCacheKeyValueDeleteDialogue(true);
|
||||||
|
}}
|
||||||
|
onPaginate={(page) => {
|
||||||
|
setPage(page);
|
||||||
|
}}
|
||||||
|
onSelect={(cacheKeyValue) => {
|
||||||
|
setCacheKeyValue(cacheKeyValue);
|
||||||
|
setCacheKeyValueFilter("");
|
||||||
|
closeWorkflowPanel();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{workflowPanelState.content === "parameters" && (
|
||||||
|
<WorkflowParametersPanel />
|
||||||
|
)}
|
||||||
|
{workflowPanelState.content === "history" && (
|
||||||
|
<WorkflowHistoryPanel
|
||||||
|
workflowPermanentId={workflowPermanentId!}
|
||||||
|
onCompare={handleCompareVersions}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{workflowPanelState.content === "nodeLibrary" && (
|
||||||
|
<WorkflowNodeLibraryPanel
|
||||||
|
onNodeClick={(props) => {
|
||||||
|
addNode(props);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* browser & timeline */}
|
||||||
<div className="flex h-[calc(100%_-_8rem)] w-full gap-6">
|
<div className="flex h-[calc(100%_-_8rem)] w-full gap-6">
|
||||||
{/* VNC browser */}
|
{/* VNC browser */}
|
||||||
{!activeDebugSession ||
|
{!activeDebugSession ||
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ function ActionNode({ id, data, type }: NodeProps<ActionNode>) {
|
|||||||
"bg-slate-950 outline outline-2 outline-slate-300":
|
"bg-slate-950 outline outline-2 outline-slate-300":
|
||||||
thisBlockIsTargetted,
|
thisBlockIsTargetted,
|
||||||
},
|
},
|
||||||
|
data.comparisonColor,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<NodeHeader
|
<NodeHeader
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ function CodeBlockNode({ id, data }: NodeProps<CodeBlockNode>) {
|
|||||||
"bg-slate-950 outline outline-2 outline-slate-300":
|
"bg-slate-950 outline outline-2 outline-slate-300":
|
||||||
thisBlockIsTargetted,
|
thisBlockIsTargetted,
|
||||||
},
|
},
|
||||||
|
data.comparisonColor,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<NodeHeader
|
<NodeHeader
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ function ExtractionNode({ id, data, type }: NodeProps<ExtractionNode>) {
|
|||||||
"pointer-events-none bg-slate-950": thisBlockIsPlaying,
|
"pointer-events-none bg-slate-950": thisBlockIsPlaying,
|
||||||
"outline outline-2 outline-slate-300": thisBlockIsTargetted,
|
"outline outline-2 outline-slate-300": thisBlockIsTargetted,
|
||||||
},
|
},
|
||||||
|
data.comparisonColor,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<NodeHeader
|
<NodeHeader
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ function LoginNode({ id, data, type }: NodeProps<LoginNode>) {
|
|||||||
"bg-slate-950 outline outline-2 outline-slate-300":
|
"bg-slate-950 outline outline-2 outline-slate-300":
|
||||||
thisBlockIsTargetted,
|
thisBlockIsTargetted,
|
||||||
},
|
},
|
||||||
|
data.comparisonColor,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<NodeHeader
|
<NodeHeader
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ function LoopNode({ id, data }: NodeProps<LoopNode>) {
|
|||||||
"bg-slate-950 outline outline-2 outline-slate-300":
|
"bg-slate-950 outline outline-2 outline-slate-300":
|
||||||
thisBlockIsTargetted,
|
thisBlockIsTargetted,
|
||||||
},
|
},
|
||||||
|
data.comparisonColor,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<NodeHeader
|
<NodeHeader
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ function NavigationNode({ id, data, type }: NodeProps<NavigationNode>) {
|
|||||||
"bg-slate-950 outline outline-2 outline-slate-300":
|
"bg-slate-950 outline outline-2 outline-slate-300":
|
||||||
thisBlockIsTargetted,
|
thisBlockIsTargetted,
|
||||||
},
|
},
|
||||||
|
data.comparisonColor,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<NodeHeader
|
<NodeHeader
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ function TaskNode({ id, data, type }: NodeProps<TaskNode>) {
|
|||||||
"bg-slate-950 outline outline-2 outline-slate-300":
|
"bg-slate-950 outline outline-2 outline-slate-300":
|
||||||
thisBlockIsTargetted,
|
thisBlockIsTargetted,
|
||||||
},
|
},
|
||||||
|
data.comparisonColor,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<NodeHeader
|
<NodeHeader
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ function ValidationNode({ id, data, type }: NodeProps<ValidationNode>) {
|
|||||||
"bg-slate-950 outline outline-2 outline-slate-300":
|
"bg-slate-950 outline outline-2 outline-slate-300":
|
||||||
thisBlockIsTargetted,
|
thisBlockIsTargetted,
|
||||||
},
|
},
|
||||||
|
data.comparisonColor,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<NodeHeader
|
<NodeHeader
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type NodeBaseData = {
|
|||||||
editable: boolean;
|
editable: boolean;
|
||||||
model: WorkflowModel | null;
|
model: WorkflowModel | null;
|
||||||
showCode?: boolean;
|
showCode?: boolean;
|
||||||
|
comparisonColor?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const errorMappingExampleValue = {
|
export const errorMappingExampleValue = {
|
||||||
|
|||||||
@@ -0,0 +1,402 @@
|
|||||||
|
import { useCallback, useMemo } from "react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import {
|
||||||
|
ReactFlowProvider,
|
||||||
|
useNodesState,
|
||||||
|
useEdgesState,
|
||||||
|
NodeChange,
|
||||||
|
EdgeChange,
|
||||||
|
} from "@xyflow/react";
|
||||||
|
import { WorkflowVersion } from "../../hooks/useWorkflowVersionsQuery";
|
||||||
|
import { WorkflowBlock, WorkflowSettings } from "../../types/workflowTypes";
|
||||||
|
import { FlowRenderer } from "../FlowRenderer";
|
||||||
|
import { getElements } from "../workflowEditorUtils";
|
||||||
|
import { ProxyLocation } from "@/api/types";
|
||||||
|
import { AppNode } from "../nodes";
|
||||||
|
|
||||||
|
type BlockComparison = {
|
||||||
|
leftBlock?: WorkflowBlock;
|
||||||
|
rightBlock?: WorkflowBlock;
|
||||||
|
status: "identical" | "modified" | "added" | "removed";
|
||||||
|
identifier: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
version1: WorkflowVersion;
|
||||||
|
version2: WorkflowVersion;
|
||||||
|
onSelectState?: (version: WorkflowVersion) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mapping from WorkflowBlock.block_type to ReactFlow node.type
|
||||||
|
const BLOCK_TYPE_TO_NODE_TYPE: Record<string, string> = {
|
||||||
|
task: "task",
|
||||||
|
task_v2: "taskv2",
|
||||||
|
validation: "validation",
|
||||||
|
action: "action",
|
||||||
|
navigation: "navigation",
|
||||||
|
extraction: "extraction",
|
||||||
|
login: "login",
|
||||||
|
wait: "wait",
|
||||||
|
file_download: "fileDownload",
|
||||||
|
code: "codeBlock",
|
||||||
|
send_email: "sendEmail",
|
||||||
|
text_prompt: "textPrompt",
|
||||||
|
for_loop: "loop",
|
||||||
|
file_url_parser: "fileParser",
|
||||||
|
pdf_parser: "pdfParser",
|
||||||
|
download_to_s3: "download",
|
||||||
|
upload_to_s3: "upload",
|
||||||
|
file_upload: "fileUpload",
|
||||||
|
goto_url: "url",
|
||||||
|
http_request: "http_request",
|
||||||
|
};
|
||||||
|
|
||||||
|
function getBlockIdentifier(block: WorkflowBlock): string {
|
||||||
|
// Convert block_type to node type for consistent comparison
|
||||||
|
const nodeType =
|
||||||
|
BLOCK_TYPE_TO_NODE_TYPE[block.block_type] || block.block_type;
|
||||||
|
return `${nodeType}:${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
|
||||||
|
const { output_parameter, ...rest } = normalized;
|
||||||
|
console.log(output_parameter);
|
||||||
|
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<string>();
|
||||||
|
|
||||||
|
// Create maps for quick lookup
|
||||||
|
const blocks1Map = new Map<string, WorkflowBlock>();
|
||||||
|
const blocks2Map = new Map<string, WorkflowBlock>();
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Deep clone the blocks to ensure complete isolation from main editor
|
||||||
|
const blocks = JSON.parse(
|
||||||
|
JSON.stringify(version.workflow_definition?.blocks || []),
|
||||||
|
);
|
||||||
|
|
||||||
|
return getElements(
|
||||||
|
blocks,
|
||||||
|
settings,
|
||||||
|
false, // not editable in comparison view
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WorkflowComparisonRenderer({
|
||||||
|
version,
|
||||||
|
onSelectState,
|
||||||
|
blockColors,
|
||||||
|
}: {
|
||||||
|
version: WorkflowVersion;
|
||||||
|
onSelectState?: (version: WorkflowVersion) => void;
|
||||||
|
blockColors?: Map<string, string>;
|
||||||
|
}) {
|
||||||
|
// 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<AppNode>[]) => {
|
||||||
|
onNodesChange(changes);
|
||||||
|
},
|
||||||
|
[onNodesChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleEdgesChange = useCallback(
|
||||||
|
(changes: EdgeChange[]) => {
|
||||||
|
onEdgesChange(changes);
|
||||||
|
},
|
||||||
|
[onEdgesChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full">
|
||||||
|
<div className="mb-3 flex flex-col items-center justify-center gap-2">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="mb-1 flex items-center justify-center gap-2">
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{version.title}, version: {version.version}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{version.workflow_definition?.blocks?.length || 0} block
|
||||||
|
{(version.workflow_definition?.blocks?.length || 0) !== 1
|
||||||
|
? "s"
|
||||||
|
: ""}
|
||||||
|
</Badge>
|
||||||
|
{onSelectState && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onSelectState(version)}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
Select this state
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="h-[calc(100%-4rem)] rounded-lg border bg-white">
|
||||||
|
<FlowRenderer
|
||||||
|
hideBackground={false}
|
||||||
|
hideControls={true}
|
||||||
|
nodes={nodes}
|
||||||
|
edges={edges}
|
||||||
|
setNodes={setNodes}
|
||||||
|
setEdges={setEdges}
|
||||||
|
onNodesChange={handleNodesChange}
|
||||||
|
onEdgesChange={handleEdgesChange}
|
||||||
|
initialTitle={version.title}
|
||||||
|
workflow={version}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, string>();
|
||||||
|
const v2Colors = new Map<string, string>();
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="flex h-full w-full flex-col rounded-lg bg-slate-elevation2">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex-shrink-0 p-4 pb-3">
|
||||||
|
<h2 className="mb-2 text-lg font-semibold">Version Comparison</h2>
|
||||||
|
<div className="flex gap-3 text-sm">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="h-3 w-3 rounded-full bg-green-300"></div>
|
||||||
|
<span>Identical ({stats.identical})</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="h-3 w-3 rounded-full bg-yellow-400"></div>
|
||||||
|
<span>Modified ({stats.modified})</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="h-3 w-3 rounded-full bg-orange-700"></div>
|
||||||
|
<span>Added ({stats.added})</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="h-3 w-3 rounded-full bg-orange-700"></div>
|
||||||
|
<span>Removed ({stats.removed})</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Content - Two columns for comparison */}
|
||||||
|
<div className="flex-1 overflow-hidden p-4">
|
||||||
|
<div className="grid h-full grid-cols-2 gap-4">
|
||||||
|
{/* Version 1 Column */}
|
||||||
|
<ReactFlowProvider>
|
||||||
|
<WorkflowComparisonRenderer
|
||||||
|
key={`k1-${version1.workflow_id}v${version1.version}`}
|
||||||
|
version={version1}
|
||||||
|
onSelectState={onSelectState}
|
||||||
|
blockColors={version1BlockColors}
|
||||||
|
/>
|
||||||
|
</ReactFlowProvider>
|
||||||
|
|
||||||
|
{/* Version 2 Column */}
|
||||||
|
<ReactFlowProvider>
|
||||||
|
<WorkflowComparisonRenderer
|
||||||
|
key={`k2-${version2.workflow_id}v${version2.version}`}
|
||||||
|
version={version2}
|
||||||
|
onSelectState={onSelectState}
|
||||||
|
blockColors={version2BlockColors}
|
||||||
|
/>
|
||||||
|
</ReactFlowProvider>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { WorkflowComparisonPanel };
|
||||||
@@ -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<Set<number>>(
|
||||||
|
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<number>();
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<div className="flex h-full w-[25rem] flex-col rounded-lg bg-slate-elevation2">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex-shrink-0 p-4 pb-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold">Workflow History</h2>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{selectedVersions.size}/2 selected
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
Select up to 2 versions to compare. Current and previous versions are
|
||||||
|
selected by default.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Version List */}
|
||||||
|
<ScrollArea className="flex-1">
|
||||||
|
<div className="p-4">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex items-center space-x-3 rounded-lg border p-3"
|
||||||
|
>
|
||||||
|
<Skeleton className="h-4 w-4" />
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<Skeleton className="h-4 w-20" />
|
||||||
|
<Skeleton className="h-3 w-32" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : sortedVersions.length === 0 ? (
|
||||||
|
<div className="py-8 text-center text-muted-foreground">
|
||||||
|
No version history found
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{sortedVersions.map((workflow, index) => {
|
||||||
|
const isSelected = selectedVersions.has(workflow.version);
|
||||||
|
const isCurrent = index === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={workflow.version}
|
||||||
|
className={`flex cursor-pointer items-center space-x-3 rounded-lg border p-3 transition-colors ${
|
||||||
|
isSelected
|
||||||
|
? "border-primary/20 bg-primary/5"
|
||||||
|
: "hover:bg-muted/50"
|
||||||
|
}`}
|
||||||
|
onClick={() => handleVersionToggle(workflow.version)}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={() => {}} // Handled by parent click
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">
|
||||||
|
Version {workflow.version}
|
||||||
|
</span>
|
||||||
|
{isCurrent && (
|
||||||
|
<Badge variant="secondary">Current</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Modified: {basicLocalTimeFormat(workflow.modified_at)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex-shrink-0 p-4 pt-3">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => handleCompare("json")}
|
||||||
|
disabled={!canCompare || isLoading}
|
||||||
|
>
|
||||||
|
JSON Diff
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => handleCompare("visual")}
|
||||||
|
disabled={!canCompare || isLoading}
|
||||||
|
>
|
||||||
|
Visual Compare
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { WorkflowHistoryPanel };
|
||||||
@@ -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<WorkflowVersion[]>({
|
||||||
|
queryKey: ["workflowVersions", workflowPermanentId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const client = await getClient(credentialGetter);
|
||||||
|
return client
|
||||||
|
.get(`/workflows/${workflowPermanentId}/versions`)
|
||||||
|
.then((response) => response.data);
|
||||||
|
},
|
||||||
|
enabled: !!workflowPermanentId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export { useWorkflowVersionsQuery };
|
||||||
@@ -1,14 +1,24 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
|
import { WorkflowVersion } from "@/routes/workflows/hooks/useWorkflowVersionsQuery";
|
||||||
|
|
||||||
type WorkflowPanelState = {
|
type WorkflowPanelState = {
|
||||||
active: boolean;
|
active: boolean;
|
||||||
content: "cacheKeyValues" | "parameters" | "nodeLibrary";
|
content:
|
||||||
|
| "cacheKeyValues"
|
||||||
|
| "parameters"
|
||||||
|
| "nodeLibrary"
|
||||||
|
| "history"
|
||||||
|
| "comparison";
|
||||||
data?: {
|
data?: {
|
||||||
previous?: string | null;
|
previous?: string | null;
|
||||||
next?: string | null;
|
next?: string | null;
|
||||||
parent?: string;
|
parent?: string;
|
||||||
connectingEdgeType?: string;
|
connectingEdgeType?: string;
|
||||||
disableLoop?: boolean;
|
disableLoop?: boolean;
|
||||||
|
// For comparison panel
|
||||||
|
version1?: WorkflowVersion;
|
||||||
|
version2?: WorkflowVersion;
|
||||||
|
showComparison?: boolean;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1459,6 +1459,30 @@ class AgentDB:
|
|||||||
LOG.error("SQLAlchemyError", exc_info=True)
|
LOG.error("SQLAlchemyError", exc_info=True)
|
||||||
raise
|
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(
|
async def get_workflows_by_permanent_ids(
|
||||||
self,
|
self,
|
||||||
workflow_permanent_ids: list[str],
|
workflow_permanent_ids: list[str],
|
||||||
|
|||||||
@@ -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(
|
@legacy_base_router.post(
|
||||||
"/suggest/{ai_suggestion_type}",
|
"/suggest/{ai_suggestion_type}",
|
||||||
include_in_schema=False,
|
include_in_schema=False,
|
||||||
|
|||||||
@@ -746,6 +746,23 @@ class WorkflowService:
|
|||||||
|
|
||||||
return workflow
|
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(
|
async def get_block_outputs_for_debug_session(
|
||||||
self,
|
self,
|
||||||
workflow_permanent_id: str,
|
workflow_permanent_id: str,
|
||||||
|
|||||||
Reference in New Issue
Block a user