diff --git a/src/components/Home/RunSection/RunBulkActionsBar.tsx b/src/components/Home/RunSection/RunBulkActionsBar.tsx
new file mode 100644
index 000000000..e680d7f4c
--- /dev/null
+++ b/src/components/Home/RunSection/RunBulkActionsBar.tsx
@@ -0,0 +1,59 @@
+import { useNavigate } from "@tanstack/react-router";
+
+import { Button } from "@/components/ui/button";
+import { Icon } from "@/components/ui/icon";
+import { InlineStack } from "@/components/ui/layout";
+import { Text } from "@/components/ui/typography";
+import { APP_ROUTES } from "@/routes/appRoutes";
+import { pluralize } from "@/utils/string";
+import { tracking } from "@/utils/tracking";
+
+interface RunBulkActionsBarProps {
+ selectedRuns: string[];
+ onClearSelection: () => void;
+}
+
+const RunBulkActionsBar = ({
+ selectedRuns,
+ onClearSelection,
+}: RunBulkActionsBarProps) => {
+ const navigate = useNavigate();
+
+ const canCompare = selectedRuns.length === 2;
+
+ const handleCompare = () => {
+ if (!canCompare) return;
+ const [a, b] = selectedRuns;
+ navigate({ to: APP_ROUTES.COMPARE, search: { a, b } });
+ };
+
+ return (
+
+
+
+ {selectedRuns.length} {pluralize(selectedRuns.length, "run")} selected
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default RunBulkActionsBar;
diff --git a/src/components/Home/RunSection/RunRow.tsx b/src/components/Home/RunSection/RunRow.tsx
index 78ee0c35f..8dd98be6a 100644
--- a/src/components/Home/RunSection/RunRow.tsx
+++ b/src/components/Home/RunSection/RunRow.tsx
@@ -9,6 +9,7 @@ import { RunSourceIcon } from "@/components/shared/RunSource";
import { StatusBar, StatusIcon } from "@/components/shared/Status";
import { TagList } from "@/components/shared/Tags/TagList";
import { Button } from "@/components/ui/button";
+import { Checkbox } from "@/components/ui/checkbox";
import { InlineStack } from "@/components/ui/layout";
import { TableCell, TableRow } from "@/components/ui/table";
import {
@@ -32,9 +33,18 @@ import { getOverallExecutionStatusFromStats } from "@/utils/executionStatus";
interface RunRowProps {
run: PipelineRunResponse;
onFilterByUser?: (createdBy: string) => void;
+ selectable?: boolean;
+ isSelected?: boolean;
+ onToggleSelected?: (runId: string) => void;
}
-const RunRow = ({ run, onFilterByUser }: RunRowProps) => {
+const RunRow = ({
+ run,
+ onFilterByUser,
+ selectable = false,
+ isSelected = false,
+ onToggleSelected,
+}: RunRowProps) => {
const navigate = useNavigate();
const { backendUrl } = useBackend();
@@ -111,6 +121,20 @@ const RunRow = ({ run, onFilterByUser }: RunRowProps) => {
onClick={handleRowClick}
className="cursor-pointer text-muted-foreground text-xs h-10"
>
+ {selectable && (
+
+ e.stopPropagation()}
+ className="flex items-center"
+ >
+ onToggleSelected?.(runId)}
+ aria-label={`Select run ${runId}`}
+ />
+
+
+ )}
diff --git a/src/components/Home/RunSection/RunSection.tsx b/src/components/Home/RunSection/RunSection.tsx
index 472b31154..5cd81c927 100644
--- a/src/components/Home/RunSection/RunSection.tsx
+++ b/src/components/Home/RunSection/RunSection.tsx
@@ -6,6 +6,7 @@ import type { ListPipelineJobsResponse } from "@/api/types.gen";
import { InfoBox } from "@/components/shared/InfoBox";
import { useFlagValue } from "@/components/shared/Settings/useFlags";
import { Button } from "@/components/ui/button";
+import { Checkbox } from "@/components/ui/checkbox";
import { Icon } from "@/components/ui/icon";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -29,6 +30,7 @@ import {
parseFilterParam,
} from "@/utils/pipelineRunFilterUtils";
+import RunBulkActionsBar from "./RunBulkActionsBar";
import RunRow from "./RunRow";
const PIPELINE_RUNS_QUERY_URL = "/api/pipeline_runs/";
@@ -58,9 +60,24 @@ export const RunSection = ({
const { pathname } = useLocation();
const search = useSearch({ strict: false }) as RunSectionSearch;
const isCreatedByMeDefault = useFlagValue("created-by-me-default");
+ const compareEnabled = useFlagValue("compare-runs");
const { setFilter } = useRunSearchParams();
const dataVersion = useRef(0);
+ const [selectedRuns, setSelectedRuns] = useState>(new Set());
+
+ const toggleRun = (runId: string) => {
+ setSelectedRuns((prev) => {
+ const next = new Set(prev);
+ if (next.has(runId)) {
+ next.delete(runId);
+ } else {
+ next.add(runId);
+ }
+ return next;
+ });
+ };
+
const onFilterByUser = forcedFilter
? undefined
: (createdBy: string) => setFilter("created_by", createdBy);
@@ -279,12 +296,42 @@ export const RunSection = ({
);
}
+ const pageRuns = maxItems
+ ? data.pipeline_runs?.slice(0, maxItems)
+ : data.pipeline_runs;
+
+ const allPageRunsSelected =
+ !!pageRuns?.length &&
+ pageRuns.every((run) => selectedRuns.has(`${run.id}`));
+
+ const toggleSelectAll = () => {
+ setSelectedRuns((prev) => {
+ const next = new Set(prev);
+ const pageRunIds = pageRuns?.map((run) => `${run.id}`) ?? [];
+ if (allPageRunsSelected) {
+ pageRunIds.forEach((id) => next.delete(id));
+ } else {
+ pageRunIds.forEach((id) => next.add(id));
+ }
+ return next;
+ });
+ };
+
return (
{searchMarkup}
+ {compareEnabled && (
+
+
+
+ )}
Name
Status
Date
@@ -293,15 +340,26 @@ export const RunSection = ({
- {(maxItems
- ? data.pipeline_runs?.slice(0, maxItems)
- : data.pipeline_runs
- )?.map((run) => (
-
+ {pageRuns?.map((run) => (
+
))}
+ {compareEnabled && selectedRuns.size > 0 && (
+ setSelectedRuns(new Set())}
+ />
+ )}
+
{(data.next_page_token || previousPageTokens.length > 0) && (
path.length <= 1;
const buildPathKey = (path: string[]) => path.join(PATH_DELIMITER);
-const buildTaskExecutionStatusMap = (
- details?: GetExecutionInfoResponse,
- state?: GetGraphExecutionStateResponse,
-): Map => {
- const taskExecutionStatusMap = new Map();
-
- if (!details?.child_task_execution_ids) {
- return taskExecutionStatusMap;
- }
-
- Object.entries(details.child_task_execution_ids).forEach(
- ([taskId, executionId]) => {
- const statusStats = state?.child_execution_status_stats?.[executionId];
- const aggregated = getOverallExecutionStatusFromStats(statusStats);
-
- if (aggregated) {
- taskExecutionStatusMap.set(taskId, aggregated);
- }
- },
- );
-
- return taskExecutionStatusMap;
-};
-
const findExecutionIdAtPath = (
path: string[],
runId: string | null | undefined,
diff --git a/src/routes/appRoutes.ts b/src/routes/appRoutes.ts
index f278ca6a1..e8397a0b1 100644
--- a/src/routes/appRoutes.ts
+++ b/src/routes/appRoutes.ts
@@ -46,4 +46,5 @@ export const APP_ROUTES = {
PIPELINE_FOLDERS: "/pipeline-folders",
PLAYGROUND: "/playground",
ARTIFACT_PREVIEW: "/artifact/$artifactId",
+ COMPARE: "/compare",
} as const;
diff --git a/src/routes/router.ts b/src/routes/router.ts
index e5c07d9c1..e7c1655d7 100644
--- a/src/routes/router.ts
+++ b/src/routes/router.ts
@@ -44,6 +44,7 @@ import { BetaFeaturesSettings } from "./Settings/sections/BetaFeaturesSettings";
import { PreferencesSettings } from "./Settings/sections/PreferencesSettings";
import { SecretsSettings } from "./Settings/sections/SecretsSettings";
import { SettingsLayout } from "./Settings/SettingsLayout";
+import { CompareView } from "./v2/pages/CompareView/CompareView";
import { EditorV2 } from "./v2/pages/Editor/EditorV2";
import { PipelineFoldersPage } from "./v2/pages/PipelineFolders/PipelineFoldersPage";
import { RunViewV2 } from "./v2/pages/RunView/RunViewV2";
@@ -363,6 +364,17 @@ const runV2WithSubgraphRoute = createRoute({
},
});
+const compareRoute = createRoute({
+ getParentRoute: () => mainLayout,
+ path: APP_ROUTES.COMPARE,
+ component: CompareView,
+ beforeLoad: () => {
+ if (!isFlagEnabled("compare-runs")) {
+ throw redirect({ to: APP_ROUTES.DASHBOARD_RUNS });
+ }
+ },
+});
+
const pipelineFoldersRoute = createRoute({
getParentRoute: () => mainLayout,
path: APP_ROUTES.PIPELINE_FOLDERS,
@@ -403,6 +415,7 @@ const appRouteTree = mainLayout.addChildren([
editorV2PipelineRoute,
runV2Route,
runV2WithSubgraphRoute,
+ compareRoute,
pipelineFoldersRoute,
artifactPreviewRoute,
tourRoute,
diff --git a/src/routes/v2/pages/CompareView/CompareView.tsx b/src/routes/v2/pages/CompareView/CompareView.tsx
new file mode 100644
index 000000000..5f58e66c4
--- /dev/null
+++ b/src/routes/v2/pages/CompareView/CompareView.tsx
@@ -0,0 +1,262 @@
+import { Link, useNavigate, useSearch } from "@tanstack/react-router";
+import { useEffect, useState } from "react";
+
+import { InfoBox } from "@/components/shared/InfoBox";
+import { LoadingScreen } from "@/components/shared/LoadingScreen";
+import { RemoteAuthErrorView } from "@/components/shared/RemoteAuthErrorView";
+import { Button } from "@/components/ui/button";
+import { Icon } from "@/components/ui/icon";
+import { BlockStack, InlineStack } from "@/components/ui/layout";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { Heading, Text } from "@/components/ui/typography";
+import { cn } from "@/lib/utils";
+import { useAnalytics } from "@/providers/AnalyticsProvider";
+import { APP_ROUTES } from "@/routes/appRoutes";
+import { RemoteAuthError } from "@/utils/fetchWithErrorHandling";
+import { tracking } from "@/utils/tracking";
+
+import { CompareRunPicker } from "./components/CompareRunPicker";
+import { GraphDiffPlaceholder } from "./components/GraphDiffPlaceholder";
+import { StructuredDiffView } from "./components/StructuredDiffView";
+import { YamlDiffView } from "./components/YamlDiffView";
+import { useRunComparisonSide } from "./hooks/useRunComparisonSide";
+import { buildPipelineComparison } from "./utils/comparePipelines";
+
+interface CompareSearch {
+ a?: string;
+ b?: string;
+}
+
+const LABEL_A = "A";
+const LABEL_B = "B";
+
+export function CompareView() {
+ const search = useSearch({ strict: false }) as CompareSearch;
+ const navigate = useNavigate();
+ const { track } = useAnalytics();
+
+ const a = search.a ?? "";
+ const b = search.b ?? "";
+
+ const sideA = useRunComparisonSide(a);
+ const sideB = useRunComparisonSide(b);
+
+ const bothSelected = Boolean(a && b && a !== b);
+
+ const [activeTab, setActiveTab] = useState("structured");
+ const [yamlMounted, setYamlMounted] = useState(false);
+
+ useEffect(() => {
+ if (bothSelected) {
+ track("compare_runs.comparison.impression", { run_a: a, run_b: b });
+ }
+ }, [bothSelected, a, b, track]);
+
+ useEffect(() => {
+ if (activeTab === "yaml") {
+ setYamlMounted(true);
+ }
+ }, [activeTab]);
+
+ const comparison = buildPipelineComparison(
+ sideA.spec,
+ sideB.spec,
+ sideA.taskStatusMap,
+ sideB.taskStatusMap,
+ );
+
+ const setSide = (side: "a" | "b", id: string) => {
+ navigate({
+ to: APP_ROUTES.COMPARE,
+ search: (prev: CompareSearch) => ({ ...prev, [side]: id }),
+ });
+ };
+
+ if (!a) {
+ return (
+
+ setSide("a", id)}
+ />
+
+ );
+ }
+
+ if (!b || a === b) {
+ return (
+
+ {a === b && (
+
+ You selected the same run twice. Choose a different second run.
+
+ )}
+ setSide("b", id)}
+ />
+
+ );
+ }
+
+ const error = sideA.error ?? sideB.error;
+ if (error) {
+ if (error instanceof RemoteAuthError) {
+ return ;
+ }
+ return (
+
+
+ {error.message}
+
+
+ );
+ }
+
+ if (sideA.isLoading || sideB.isLoading || !sideA.spec || !sideB.spec) {
+ return ;
+ }
+
+ const nameA = sideA.spec.name ?? `Run #${a}`;
+ const nameB = sideB.spec.name ?? `Run #${b}`;
+
+ return (
+
+
+
+ Compare runs
+
+
+
+
+
+
+
+
+
+
+ Structured
+
+
+ YAML
+
+
+ Graph
+
+
+
+
+
+
+
+
+ {yamlMounted && (
+
+ )}
+
+
+
+
+
+
+
+ );
+}
+
+function PageShell({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+interface RunLabelProps {
+ label: string;
+ name: string;
+ runId: string;
+ tone: "a" | "b";
+}
+
+function RunLabel({ label, name, runId, tone }: RunLabelProps) {
+ const toneClass =
+ tone === "a"
+ ? "border-blue-400 bg-blue-50 text-blue-800 hover:bg-blue-100"
+ : "border-emerald-400 bg-emerald-50 text-emerald-800 hover:bg-emerald-100";
+
+ return (
+
+
+
+ {label}
+
+
+ {name}
+
+
+ #{runId}
+
+
+
+
+ );
+}
diff --git a/src/routes/v2/pages/CompareView/components/CompareRunPicker.tsx b/src/routes/v2/pages/CompareView/components/CompareRunPicker.tsx
new file mode 100644
index 000000000..23375142e
--- /dev/null
+++ b/src/routes/v2/pages/CompareView/components/CompareRunPicker.tsx
@@ -0,0 +1,102 @@
+import { useQuery } from "@tanstack/react-query";
+
+import type { ListPipelineJobsResponse } from "@/api/types.gen";
+import { InfoBox } from "@/components/shared/InfoBox";
+import { StatusIcon } from "@/components/shared/Status";
+import { Button } from "@/components/ui/button";
+import { BlockStack, InlineStack } from "@/components/ui/layout";
+import { Spinner } from "@/components/ui/spinner";
+import { Heading, Text } from "@/components/ui/typography";
+import { useBackend } from "@/providers/BackendProvider";
+import { formatDate } from "@/utils/date";
+import { getOverallExecutionStatusFromStats } from "@/utils/executionStatus";
+import { fetchWithErrorHandling } from "@/utils/fetchWithErrorHandling";
+import { tracking } from "@/utils/tracking";
+
+const PIPELINE_RUNS_QUERY_URL = "/api/pipeline_runs/";
+
+interface CompareRunPickerProps {
+ title: string;
+ excludeRunId?: string;
+ onSelect: (runId: string) => void;
+}
+
+export function CompareRunPicker({
+ title,
+ excludeRunId,
+ onSelect,
+}: CompareRunPickerProps) {
+ const { backendUrl, configured, available } = useBackend();
+
+ const { data, isLoading, error } = useQuery({
+ queryKey: ["compare-run-picker", backendUrl],
+ refetchOnWindowFocus: false,
+ enabled: configured && available,
+ queryFn: async () => {
+ const url = new URL(PIPELINE_RUNS_QUERY_URL, backendUrl);
+ url.searchParams.set("include_pipeline_names", "true");
+ url.searchParams.set("include_execution_stats", "true");
+ return fetchWithErrorHandling(url.toString());
+ },
+ });
+
+ return (
+
+ {title}
+
+ {isLoading && (
+
+ Loading runs…
+
+ )}
+
+ {error && (
+
+ {error.message}
+
+ )}
+
+ {data && (
+
+ {(data.pipeline_runs ?? [])
+ .filter((run) => `${run.id}` !== excludeRunId)
+ .map((run) => {
+ const runId = `${run.id}`;
+ const status = getOverallExecutionStatusFromStats(
+ run.execution_status_stats ?? undefined,
+ );
+
+ return (
+
+
+
+ );
+ })}
+
+ )}
+
+ );
+}
diff --git a/src/routes/v2/pages/CompareView/components/DiffStatusBadge.tsx b/src/routes/v2/pages/CompareView/components/DiffStatusBadge.tsx
new file mode 100644
index 000000000..357175c46
--- /dev/null
+++ b/src/routes/v2/pages/CompareView/components/DiffStatusBadge.tsx
@@ -0,0 +1,44 @@
+import { Icon } from "@/components/ui/icon";
+import { InlineStack } from "@/components/ui/layout";
+import { Text } from "@/components/ui/typography";
+import { cn } from "@/lib/utils";
+import type { DiffStatus } from "@/routes/v2/pages/CompareView/utils/comparePipelines";
+import {
+ DIFF_STATUS_CLASSES,
+ STATUS_ICON,
+} from "@/routes/v2/pages/Editor/components/UpgradeComponents/components/upgradePreviewConstants";
+
+const DIFF_STATUS_LABELS: Record = {
+ unchanged: "Unchanged",
+ lost: "Removed",
+ new: "Added",
+ changed: "Changed",
+};
+
+interface DiffStatusBadgeProps {
+ status: DiffStatus;
+ className?: string;
+}
+
+export function DiffStatusBadge({ status, className }: DiffStatusBadgeProps) {
+ const icon = STATUS_ICON[status];
+
+ return (
+
+ {icon && }
+
+ {DIFF_STATUS_LABELS[status]}
+
+
+ );
+}
diff --git a/src/routes/v2/pages/CompareView/components/FieldDiffRow.tsx b/src/routes/v2/pages/CompareView/components/FieldDiffRow.tsx
new file mode 100644
index 000000000..abb197c33
--- /dev/null
+++ b/src/routes/v2/pages/CompareView/components/FieldDiffRow.tsx
@@ -0,0 +1,54 @@
+import { BlockStack, InlineStack } from "@/components/ui/layout";
+import { Text } from "@/components/ui/typography";
+import type { KeyedDiffEntry } from "@/routes/v2/pages/CompareView/utils/comparePipelines";
+
+import { DiffStatusBadge } from "./DiffStatusBadge";
+
+function formatValue(value: unknown): string {
+ if (value === undefined) return "—";
+ if (typeof value === "string") return value;
+ return JSON.stringify(value);
+}
+
+interface ValueLineProps {
+ label: string;
+ value: unknown;
+}
+
+function ValueLine({ label, value }: ValueLineProps) {
+ return (
+
+
+ {label}
+
+
+ {formatValue(value)}
+
+
+ );
+}
+
+interface FieldDiffRowProps {
+ entry: KeyedDiffEntry;
+ labelA: string;
+ labelB: string;
+}
+
+export function FieldDiffRow({ entry, labelA, labelB }: FieldDiffRowProps) {
+ return (
+
+
+
+ {entry.key}
+
+
+
+ {entry.status !== "new" && }
+ {entry.status !== "lost" && }
+
+ );
+}
diff --git a/src/routes/v2/pages/CompareView/components/GraphDiffPlaceholder.tsx b/src/routes/v2/pages/CompareView/components/GraphDiffPlaceholder.tsx
new file mode 100644
index 000000000..68747d354
--- /dev/null
+++ b/src/routes/v2/pages/CompareView/components/GraphDiffPlaceholder.tsx
@@ -0,0 +1,16 @@
+import { Icon } from "@/components/ui/icon";
+import { BlockStack } from "@/components/ui/layout";
+import { Heading, Paragraph } from "@/components/ui/typography";
+
+export function GraphDiffPlaceholder() {
+ return (
+
+
+ Visual graph comparison is coming soon
+
+ A merged graph that overlays both runs and highlights where they differ
+ will live here. For now, use the Structured and YAML tabs.
+
+
+ );
+}
diff --git a/src/routes/v2/pages/CompareView/components/StructuredDiffView.tsx b/src/routes/v2/pages/CompareView/components/StructuredDiffView.tsx
new file mode 100644
index 000000000..32f4ebb9a
--- /dev/null
+++ b/src/routes/v2/pages/CompareView/components/StructuredDiffView.tsx
@@ -0,0 +1,116 @@
+import { useState } from "react";
+
+import { InfoBox } from "@/components/shared/InfoBox";
+import { Label } from "@/components/ui/label";
+import { BlockStack, InlineStack } from "@/components/ui/layout";
+import { Switch } from "@/components/ui/switch";
+import { Text } from "@/components/ui/typography";
+import type { PipelineComparison } from "@/routes/v2/pages/CompareView/utils/comparePipelines";
+import { tracking } from "@/utils/tracking";
+
+import { TaskDiffRow } from "./TaskDiffRow";
+
+interface SummaryCountProps {
+ label: string;
+ value: number;
+}
+
+function SummaryCount({ label, value }: SummaryCountProps) {
+ return (
+
+
+ {value}
+
+
+ {label}
+
+
+ );
+}
+
+interface StructuredDiffViewProps {
+ comparison: PipelineComparison;
+ labelA: string;
+ labelB: string;
+}
+
+export function StructuredDiffView({
+ comparison,
+ labelA,
+ labelB,
+}: StructuredDiffViewProps) {
+ const [showUnchanged, setShowUnchanged] = useState(false);
+
+ if (!comparison.hasComparableTasks) {
+ return (
+
+ Neither run has a graph pipeline, so there are no tasks to align. Use
+ the YAML tab to compare the raw specifications.
+
+ );
+ }
+
+ const { counts } = comparison;
+ const visibleDiffs = showUnchanged
+ ? comparison.taskDiffs
+ : comparison.taskDiffs.filter(
+ (diff) => diff.status !== "unchanged" || diff.outcomeChanged,
+ );
+
+ return (
+
+
+
+
+
+
+
+ {counts.outcomeChanged > 0 && (
+
+
+
+ {counts.outcomeChanged}
+
+
+ outcome differs
+
+
+ )}
+
+
+
+
+
+
+
+ {visibleDiffs.length === 0 ? (
+
+ These two runs have identical task configurations.
+
+ ) : (
+
+ {visibleDiffs.map((diff) => (
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/routes/v2/pages/CompareView/components/TaskDiffRow.tsx b/src/routes/v2/pages/CompareView/components/TaskDiffRow.tsx
new file mode 100644
index 000000000..4af7741d9
--- /dev/null
+++ b/src/routes/v2/pages/CompareView/components/TaskDiffRow.tsx
@@ -0,0 +1,142 @@
+import { Icon } from "@/components/ui/icon";
+import { BlockStack, InlineStack } from "@/components/ui/layout";
+import { Text } from "@/components/ui/typography";
+import { cn } from "@/lib/utils";
+import type { TaskDiff } from "@/routes/v2/pages/CompareView/utils/comparePipelines";
+import {
+ EXECUTION_STATUS_BG_COLORS,
+ getExecutionStatusLabel,
+} from "@/utils/executionStatus";
+
+import { DiffStatusBadge } from "./DiffStatusBadge";
+import { FieldDiffRow } from "./FieldDiffRow";
+
+interface ExecutionStatusPillProps {
+ label: string;
+ status: string | undefined;
+}
+
+function ExecutionStatusPill({ label, status }: ExecutionStatusPillProps) {
+ if (!status) return null;
+
+ return (
+
+
+ {label}
+
+
+
+ {getExecutionStatusLabel(status)}
+
+
+ );
+}
+
+interface TaskDiffRowProps {
+ diff: TaskDiff;
+ labelA: string;
+ labelB: string;
+}
+
+export function TaskDiffRow({ diff, labelA, labelB }: TaskDiffRowProps) {
+ const changedArguments = diff.argumentDiffs.filter(
+ (entry) => entry.status !== "unchanged",
+ );
+ const changedAnnotations = diff.annotationDiffs.filter(
+ (entry) => entry.status !== "unchanged",
+ );
+
+ const componentChanged =
+ diff.status === "changed" && !diff.sameComponentVersion;
+ const hasFieldChanges =
+ componentChanged ||
+ changedArguments.length > 0 ||
+ changedAnnotations.length > 0;
+
+ return (
+
+
+
+
+ {diff.taskId}
+
+
+
+
+
+ {diff.outcomeChanged && diff.statusA && diff.statusB && (
+
+ )}
+
+
+
+
+ {componentChanged && (
+
+
+ Component
+
+
+ {diff.digestA?.slice(0, 8) ?? "—"} →{" "}
+ {diff.digestB?.slice(0, 8) ?? "—"}
+
+
+ )}
+
+ {changedArguments.length > 0 && (
+
+
+ Arguments
+
+ {changedArguments.map((entry) => (
+
+ ))}
+
+ )}
+
+ {changedAnnotations.length > 0 && (
+
+
+ Annotations
+
+ {changedAnnotations.map((entry) => (
+
+ ))}
+
+ )}
+
+ {!hasFieldChanges && (
+
+ {diff.outcomeChanged
+ ? "No configuration changes — the execution outcome differs between runs."
+ : "No differences in this task."}
+
+ )}
+
+ );
+}
diff --git a/src/routes/v2/pages/CompareView/components/YamlDiffView.tsx b/src/routes/v2/pages/CompareView/components/YamlDiffView.tsx
new file mode 100644
index 000000000..a7ffeeb32
--- /dev/null
+++ b/src/routes/v2/pages/CompareView/components/YamlDiffView.tsx
@@ -0,0 +1,36 @@
+import { DiffEditor } from "@monaco-editor/react";
+
+import type { ComponentSpec } from "@/utils/componentSpec";
+import { componentSpecToText } from "@/utils/yaml";
+
+interface YamlDiffViewProps {
+ specA: ComponentSpec;
+ specB: ComponentSpec;
+}
+
+export function YamlDiffView({ specA, specB }: YamlDiffViewProps) {
+ const yamlA = componentSpecToText(specA);
+ const yamlB = componentSpecToText(specB);
+
+ return (
+
+ );
+}
diff --git a/src/routes/v2/pages/CompareView/hooks/useRunComparisonSide.ts b/src/routes/v2/pages/CompareView/hooks/useRunComparisonSide.ts
new file mode 100644
index 000000000..d5440c3f6
--- /dev/null
+++ b/src/routes/v2/pages/CompareView/hooks/useRunComparisonSide.ts
@@ -0,0 +1,37 @@
+import { usePipelineRunData } from "@/hooks/usePipelineRunData";
+import type { ComponentSpec } from "@/utils/componentSpec";
+import { buildTaskExecutionStatusMap } from "@/utils/executionStatus";
+
+export interface RunComparisonSide {
+ runId: string;
+ spec: ComponentSpec | undefined;
+ taskStatusMap: Map;
+ isLoading: boolean;
+ error: Error | null;
+}
+
+/**
+ * Loads a single run's spec and per-task execution status for the comparison
+ * view. Safe to call twice in one component (once per side) because
+ * `usePipelineRunData` scopes all of its queries by id. Pass an empty string
+ * for an unselected side — the underlying queries stay disabled.
+ */
+export function useRunComparisonSide(runId: string): RunComparisonSide {
+ const { executionData, isLoading, error } = usePipelineRunData(runId);
+
+ const details = executionData?.details;
+ const state = executionData?.state;
+
+ const spec = details?.task_spec.componentRef.spec as
+ ComponentSpec | undefined;
+
+ const taskStatusMap = buildTaskExecutionStatusMap(details, state);
+
+ return {
+ runId,
+ spec,
+ taskStatusMap,
+ isLoading,
+ error: error ?? null,
+ };
+}
diff --git a/src/routes/v2/pages/CompareView/utils/comparePipelines.test.ts b/src/routes/v2/pages/CompareView/utils/comparePipelines.test.ts
new file mode 100644
index 000000000..007a85f3e
--- /dev/null
+++ b/src/routes/v2/pages/CompareView/utils/comparePipelines.test.ts
@@ -0,0 +1,179 @@
+import { describe, expect, test } from "vitest";
+
+import type { ComponentSpec, TaskSpec } from "@/utils/componentSpec";
+
+import { buildPipelineComparison } from "./comparePipelines";
+
+const task = (digest: string, overrides: Partial = {}): TaskSpec => ({
+ componentRef: { name: "comp", digest },
+ ...overrides,
+});
+
+const graphSpec = (tasks: Record): ComponentSpec => ({
+ implementation: { graph: { tasks } },
+});
+
+const containerSpec = (): ComponentSpec => ({
+ implementation: { container: { image: "python:3.11" } },
+});
+
+const noStatus = new Map();
+
+describe("buildPipelineComparison()", () => {
+ test("flags added, removed, and unchanged tasks by id", () => {
+ const specA = graphSpec({ train: task("d1"), evaluate: task("d2") });
+ const specB = graphSpec({ train: task("d1"), deploy: task("d3") });
+
+ const { taskDiffs, counts } = buildPipelineComparison(
+ specA,
+ specB,
+ noStatus,
+ noStatus,
+ );
+
+ const byId = Object.fromEntries(taskDiffs.map((d) => [d.taskId, d.status]));
+ expect(byId).toEqual({
+ train: "unchanged",
+ evaluate: "lost",
+ deploy: "new",
+ });
+ expect(counts).toEqual({
+ added: 1,
+ removed: 1,
+ changed: 0,
+ unchanged: 1,
+ outcomeChanged: 0,
+ });
+ });
+
+ test("marks a task changed when the component digest differs", () => {
+ const specA = graphSpec({ train: task("d1") });
+ const specB = graphSpec({ train: task("d2") });
+
+ const [diff] = buildPipelineComparison(
+ specA,
+ specB,
+ noStatus,
+ noStatus,
+ ).taskDiffs;
+
+ expect(diff.status).toBe("changed");
+ expect(diff.sameComponentVersion).toBe(false);
+ });
+
+ test("marks a task changed when arguments differ but the component is identical", () => {
+ const specA = graphSpec({
+ train: task("d1", { arguments: { epochs: "10" } }),
+ });
+ const specB = graphSpec({
+ train: task("d1", { arguments: { epochs: "20" } }),
+ });
+
+ const [diff] = buildPipelineComparison(
+ specA,
+ specB,
+ noStatus,
+ noStatus,
+ ).taskDiffs;
+
+ expect(diff.status).toBe("changed");
+ expect(diff.sameComponentVersion).toBe(true);
+ const epochs = diff.argumentDiffs.find((a) => a.key === "epochs");
+ expect(epochs?.status).toBe("changed");
+ });
+
+ test("treats structurally equal object arguments as unchanged", () => {
+ const arg = { taskOutput: { taskId: "prep", outputName: "data" } };
+ const specA = graphSpec({ train: task("d1", { arguments: { in: arg } }) });
+ const specB = graphSpec({
+ train: task("d1", { arguments: { in: { ...arg } } }),
+ });
+
+ const [diff] = buildPipelineComparison(
+ specA,
+ specB,
+ noStatus,
+ noStatus,
+ ).taskDiffs;
+
+ expect(diff.status).toBe("unchanged");
+ });
+
+ test("carries per-run execution status onto each task diff", () => {
+ const specA = graphSpec({ train: task("d1") });
+ const specB = graphSpec({ train: task("d1") });
+
+ const [diff] = buildPipelineComparison(
+ specA,
+ specB,
+ new Map([["train", "SUCCEEDED"]]),
+ new Map([["train", "FAILED"]]),
+ ).taskDiffs;
+
+ expect(diff.statusA).toBe("SUCCEEDED");
+ expect(diff.statusB).toBe("FAILED");
+ });
+
+ test("flags an outcome difference even when the task spec is unchanged", () => {
+ const specA = graphSpec({ train: task("d1") });
+ const specB = graphSpec({ train: task("d1") });
+
+ const { taskDiffs, counts } = buildPipelineComparison(
+ specA,
+ specB,
+ new Map([["train", "SUCCEEDED"]]),
+ new Map([["train", "FAILED"]]),
+ );
+
+ expect(taskDiffs[0].status).toBe("unchanged");
+ expect(taskDiffs[0].outcomeChanged).toBe(true);
+ expect(counts.outcomeChanged).toBe(1);
+ });
+
+ test("does not flag an outcome difference when both runs share a status", () => {
+ const specA = graphSpec({ train: task("d1") });
+ const specB = graphSpec({ train: task("d1") });
+
+ const { taskDiffs, counts } = buildPipelineComparison(
+ specA,
+ specB,
+ new Map([["train", "SUCCEEDED"]]),
+ new Map([["train", "SUCCEEDED"]]),
+ );
+
+ expect(taskDiffs[0].outcomeChanged).toBe(false);
+ expect(counts.outcomeChanged).toBe(0);
+ });
+
+ test("reports no comparable tasks for container-implementation specs", () => {
+ const { taskDiffs, hasComparableTasks } = buildPipelineComparison(
+ containerSpec(),
+ containerSpec(),
+ noStatus,
+ noStatus,
+ );
+
+ expect(taskDiffs).toHaveLength(0);
+ expect(hasComparableTasks).toBe(false);
+ });
+
+ test("treats unrelated pipelines as fully added/removed", () => {
+ const specA = graphSpec({ a1: task("d1"), a2: task("d2") });
+ const specB = graphSpec({ b1: task("d3") });
+
+ const { counts } = buildPipelineComparison(
+ specA,
+ specB,
+ noStatus,
+ noStatus,
+ );
+
+ expect(counts).toEqual({
+ added: 1,
+ removed: 2,
+ changed: 0,
+ unchanged: 0,
+ outcomeChanged: 0,
+ });
+ });
+});
diff --git a/src/routes/v2/pages/CompareView/utils/comparePipelines.ts b/src/routes/v2/pages/CompareView/utils/comparePipelines.ts
new file mode 100644
index 000000000..3a5f744a8
--- /dev/null
+++ b/src/routes/v2/pages/CompareView/utils/comparePipelines.ts
@@ -0,0 +1,192 @@
+import equal from "fast-deep-equal";
+
+import type { DiffStatus } from "@/routes/v2/pages/Editor/store/actions/task.utils";
+import type {
+ ArgumentType,
+ ComponentSpec,
+ TaskSpec,
+} from "@/utils/componentSpec";
+import { isGraphImplementation } from "@/utils/componentSpec";
+
+export type { DiffStatus };
+
+export interface KeyedDiffEntry {
+ key: string;
+ a?: T;
+ b?: T;
+ status: DiffStatus;
+}
+
+export interface TaskDiff {
+ taskId: string;
+ status: DiffStatus;
+ a?: TaskSpec;
+ b?: TaskSpec;
+ digestA?: string;
+ digestB?: string;
+ sameComponentVersion: boolean;
+ statusA?: string;
+ statusB?: string;
+ outcomeChanged: boolean;
+ argumentDiffs: KeyedDiffEntry[];
+ annotationDiffs: KeyedDiffEntry[];
+}
+
+interface ComparisonCounts {
+ added: number;
+ removed: number;
+ changed: number;
+ unchanged: number;
+ outcomeChanged: number;
+}
+
+export interface PipelineComparison {
+ taskDiffs: TaskDiff[];
+ counts: ComparisonCounts;
+ hasComparableTasks: boolean;
+}
+
+/**
+ * Union of keys preserving `a`'s order first, then appending keys present only
+ * in `b` in `b`'s order. Mirrors the ordering of the editor's diff lists so the
+ * two features read consistently.
+ */
+function unionKeysAFirst(
+ a: Record,
+ b: Record,
+): string[] {
+ const seen = new Set();
+ const keys: string[] = [];
+ for (const key of Object.keys(a)) {
+ seen.add(key);
+ keys.push(key);
+ }
+ for (const key of Object.keys(b)) {
+ if (!seen.has(key)) keys.push(key);
+ }
+ return keys;
+}
+
+function diffKeyedRecords(
+ a: Record | undefined,
+ b: Record | undefined,
+): KeyedDiffEntry[] {
+ const aRecord = a ?? {};
+ const bRecord = b ?? {};
+
+ return unionKeysAFirst(aRecord, bRecord).map((key) => {
+ const inA = key in aRecord;
+ const inB = key in bRecord;
+ const aValue = inA ? aRecord[key] : undefined;
+ const bValue = inB ? bRecord[key] : undefined;
+
+ let status: DiffStatus;
+ if (inA && !inB) status = "lost";
+ else if (!inA && inB) status = "new";
+ else status = equal(aValue, bValue) ? "unchanged" : "changed";
+
+ return { key, a: aValue, b: bValue, status };
+ });
+}
+
+function isComponentChanged(a: TaskSpec, b: TaskSpec): boolean {
+ const digestA = a.componentRef.digest;
+ const digestB = b.componentRef.digest;
+ if (digestA && digestB) return digestA !== digestB;
+ return !equal(a.componentRef, b.componentRef);
+}
+
+function buildTaskDiff(
+ taskId: string,
+ a: TaskSpec | undefined,
+ b: TaskSpec | undefined,
+ statusA: string | undefined,
+ statusB: string | undefined,
+): TaskDiff {
+ const argumentDiffs = diffKeyedRecords(a?.arguments, b?.arguments);
+ const annotationDiffs = diffKeyedRecords(a?.annotations, b?.annotations);
+ const digestA = a?.componentRef.digest;
+ const digestB = b?.componentRef.digest;
+
+ let status: DiffStatus;
+ if (a && !b) status = "lost";
+ else if (!a && b) status = "new";
+ else if (a && b) {
+ const hasFieldChanges = [...argumentDiffs, ...annotationDiffs].some(
+ (entry) => entry.status !== "unchanged",
+ );
+ status =
+ isComponentChanged(a, b) || hasFieldChanges ? "changed" : "unchanged";
+ } else {
+ status = "unchanged";
+ }
+
+ return {
+ taskId,
+ status,
+ a,
+ b,
+ digestA,
+ digestB,
+ sameComponentVersion: Boolean(digestA && digestB && digestA === digestB),
+ statusA,
+ statusB,
+ outcomeChanged: (statusA ?? "") !== (statusB ?? ""),
+ argumentDiffs,
+ annotationDiffs,
+ };
+}
+
+function getGraphTasks(
+ spec: ComponentSpec | undefined,
+): Record {
+ if (!spec || !isGraphImplementation(spec.implementation)) return {};
+ return spec.implementation.graph.tasks;
+}
+
+/**
+ * Aligns two runs' pipeline specs by task id and produces a per-task diff of
+ * component version, arguments, annotations, and execution status. Task
+ * ordering follows run A first, with tasks only present in run B appended.
+ */
+export function buildPipelineComparison(
+ specA: ComponentSpec | undefined,
+ specB: ComponentSpec | undefined,
+ statusMapA: Map,
+ statusMapB: Map,
+): PipelineComparison {
+ const tasksA = getGraphTasks(specA);
+ const tasksB = getGraphTasks(specB);
+
+ const taskDiffs = unionKeysAFirst(tasksA, tasksB).map((taskId) =>
+ buildTaskDiff(
+ taskId,
+ tasksA[taskId],
+ tasksB[taskId],
+ statusMapA.get(taskId),
+ statusMapB.get(taskId),
+ ),
+ );
+
+ const counts: ComparisonCounts = {
+ added: 0,
+ removed: 0,
+ changed: 0,
+ unchanged: 0,
+ outcomeChanged: 0,
+ };
+ for (const diff of taskDiffs) {
+ if (diff.status === "new") counts.added += 1;
+ else if (diff.status === "lost") counts.removed += 1;
+ else if (diff.status === "changed") counts.changed += 1;
+ else counts.unchanged += 1;
+
+ if (diff.outcomeChanged) counts.outcomeChanged += 1;
+ }
+
+ return {
+ taskDiffs,
+ counts,
+ hasComparableTasks: taskDiffs.length > 0,
+ };
+}
diff --git a/src/routes/v2/pages/RunView/components/RunViewMenuBar/components/RunMenu.tsx b/src/routes/v2/pages/RunView/components/RunViewMenuBar/components/RunMenu.tsx
index 0ae354421..a18a38a29 100644
--- a/src/routes/v2/pages/RunView/components/RunViewMenuBar/components/RunMenu.tsx
+++ b/src/routes/v2/pages/RunView/components/RunViewMenuBar/components/RunMenu.tsx
@@ -1,4 +1,7 @@
+import { useNavigate } from "@tanstack/react-router";
+
import ConfirmationDialog from "@/components/shared/Dialogs/ConfirmationDialog";
+import { useFlagValue } from "@/components/shared/Settings/useFlags";
import TaskImplementation from "@/components/shared/TaskDetails/Implementation";
import {
DropdownMenu,
@@ -8,6 +11,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Icon } from "@/components/ui/icon";
+import { APP_ROUTES } from "@/routes/appRoutes";
import { useCancelPipelineRun } from "@/routes/v2/pages/RunView/hooks/useCancelPipelineRun";
import { useClonePipelineRun } from "@/routes/v2/pages/RunView/hooks/useClonePipelineRun";
import { useExportPipelineYaml } from "@/routes/v2/pages/RunView/hooks/useExportPipelineYaml";
@@ -19,7 +23,9 @@ import { MenuTriggerButton } from "@/routes/v2/shared/components/MenuTriggerButt
import { tracking } from "@/utils/tracking";
export function RunMenu() {
+ const navigate = useNavigate();
const actions = useRunViewActions();
+ const compareEnabled = useFlagValue("compare-runs");
const componentSpec = actions.ready ? actions.componentSpec : undefined;
const runId = actions.ready ? actions.runId : undefined;
const pipelineName = actions.ready ? actions.pipelineName : undefined;
@@ -37,6 +43,12 @@ export function RunMenu() {
const { inspect } = useInspectPipeline(pipelineName);
const { exportYaml } = useExportPipelineYaml(componentSpec, pipelineName);
+ const handleCompare = () => {
+ if (runId) {
+ navigate({ to: APP_ROUTES.COMPARE, search: { a: runId } });
+ }
+ };
+
if (!actions.ready) {
return (
@@ -84,6 +96,16 @@ export function RunMenu() {
View YAML
+ {compareEnabled && runId && (
+
+
+ Compare with another run…
+
+ )}
+
sum + (c ?? 0), 0);
return total > 0 && countInProgressFromStats(stats) === 0;
}
+
+/**
+ * Build a map of task id → aggregated execution status by joining a run's
+ * task→execution id mapping against its per-execution status stats.
+ */
+export function buildTaskExecutionStatusMap(
+ details?: GetExecutionInfoResponse,
+ state?: GetGraphExecutionStateResponse,
+): Map {
+ const taskExecutionStatusMap = new Map();
+
+ if (!details?.child_task_execution_ids) {
+ return taskExecutionStatusMap;
+ }
+
+ for (const [taskId, executionId] of Object.entries(
+ details.child_task_execution_ids,
+ )) {
+ const statusStats = state?.child_execution_status_stats?.[executionId];
+ const aggregated = getOverallExecutionStatusFromStats(statusStats);
+
+ if (aggregated) {
+ taskExecutionStatusMap.set(taskId, aggregated);
+ }
+ }
+
+ return taskExecutionStatusMap;
+}