diff --git a/autogpt_platform/backend/backend/data/graph.py b/autogpt_platform/backend/backend/data/graph.py index 94f99852e8..77b9e3367d 100644 --- a/autogpt_platform/backend/backend/data/graph.py +++ b/autogpt_platform/backend/backend/data/graph.py @@ -867,9 +867,66 @@ class GraphModel(Graph, GraphMeta): return node_errors + @staticmethod + def prune_invalid_links(graph: BaseGraph) -> int: + """ + Remove invalid/orphan links from the graph. + + This removes links that: + - Reference non-existent source or sink nodes + - Reference invalid block IDs + - Reference invalid pin names + + Returns the number of links pruned. + """ + node_map = {v.id: v for v in graph.nodes} + original_count = len(graph.links) + valid_links = [] + + for link in graph.links: + source_node = node_map.get(link.source_id) + sink_node = node_map.get(link.sink_id) + + # Skip if either node doesn't exist + if not source_node or not sink_node: + logger.warning( + f"Pruning orphan link: source={link.source_id}, sink={link.sink_id} " + f"- node(s) not found" + ) + continue + + # Skip if source block doesn't exist + source_block = get_block(source_node.block_id) + if not source_block: + logger.warning( + f"Pruning link with invalid source block: {source_node.block_id}" + ) + continue + + # Skip if sink block doesn't exist + sink_block = get_block(sink_node.block_id) + if not sink_block: + logger.warning( + f"Pruning link with invalid sink block: {sink_node.block_id}" + ) + continue + + valid_links.append(link) + + graph.links = valid_links + pruned_count = original_count - len(valid_links) + + if pruned_count > 0: + logger.info(f"Pruned {pruned_count} invalid link(s) from graph {graph.id}") + + return pruned_count + @staticmethod def _validate_graph_structure(graph: BaseGraph): """Validate graph structure (links, connections, etc.)""" + # First, prune invalid links to clean up orphan edges + GraphModel.prune_invalid_links(graph) + node_map = {v.id: v for v in graph.nodes} def is_static_output_block(nid: str) -> bool: diff --git a/autogpt_platform/frontend/src/app/(platform)/build/hooks/useSaveGraph.ts b/autogpt_platform/frontend/src/app/(platform)/build/hooks/useSaveGraph.ts index 505303cc1e..22bea488a6 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/hooks/useSaveGraph.ts +++ b/autogpt_platform/frontend/src/app/(platform)/build/hooks/useSaveGraph.ts @@ -13,6 +13,7 @@ import { Graph } from "@/app/api/__generated__/models/graph"; import { useNodeStore } from "../stores/nodeStore"; import { useEdgeStore } from "../stores/edgeStore"; import { graphsEquivalent } from "../components/NewControlPanel/NewSaveControl/helpers"; +import { linkToCustomEdge } from "../components/helper"; import { useGraphStore } from "../stores/graphStore"; import { useShallow } from "zustand/react/shallow"; import { @@ -21,6 +22,18 @@ import { getTempFlowId, } from "@/services/builder-draft/draft-service"; +/** + * Sync the edge store with the authoritative backend state. + * This ensures the frontend matches what the backend accepted after save. + */ +function syncEdgesWithBackend(links: GraphModel["links"]) { + if (links !== undefined) { + // Replace all edges with the authoritative backend state + const newEdges = links.map(linkToCustomEdge); + useEdgeStore.getState().setEdges(newEdges); + } +} + export type SaveGraphOptions = { showToast?: boolean; onSuccess?: (graph: GraphModel) => void; @@ -64,6 +77,9 @@ export const useSaveGraph = ({ flowVersion: data.version, }); + // Sync edge store with authoritative backend state + syncEdgesWithBackend(data.links); + const tempFlowId = getTempFlowId(); if (tempFlowId) { await draftService.deleteDraft(tempFlowId); @@ -101,6 +117,9 @@ export const useSaveGraph = ({ flowVersion: data.version, }); + // Sync edge store with authoritative backend state + syncEdgesWithBackend(data.links); + // Clear the draft for this flow after successful save if (data.id) { await draftService.deleteDraft(data.id); diff --git a/autogpt_platform/frontend/src/app/(platform)/build/stores/edgeStore.ts b/autogpt_platform/frontend/src/app/(platform)/build/stores/edgeStore.ts index 6a45b9e1e2..74bf67820a 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/stores/edgeStore.ts +++ b/autogpt_platform/frontend/src/app/(platform)/build/stores/edgeStore.ts @@ -120,10 +120,33 @@ export const useEdgeStore = create((set, get) => ({ isOutputConnected: (nodeId, handle) => get().edges.some((e) => e.source === nodeId && e.sourceHandle === handle), - getBackendLinks: () => get().edges.map(customEdgeToLink), + getBackendLinks: () => { + // Filter out edges referencing non-existent nodes before converting to links + const nodeIds = new Set(useNodeStore.getState().nodes.map((n) => n.id)); + const validEdges = get().edges.filter((edge) => { + const isValid = nodeIds.has(edge.source) && nodeIds.has(edge.target); + if (!isValid) { + console.warn( + `[EdgeStore] Filtering out invalid edge during save: source=${edge.source}, target=${edge.target}`, + ); + } + return isValid; + }); + return validEdges.map(customEdgeToLink); + }, addLinks: (links) => { + // Get current node IDs to validate links + const nodeIds = new Set(useNodeStore.getState().nodes.map((n) => n.id)); + links.forEach((link) => { + // Skip invalid links (orphan edges referencing non-existent nodes) + if (!nodeIds.has(link.source_id) || !nodeIds.has(link.sink_id)) { + console.warn( + `[EdgeStore] Skipping invalid link: source=${link.source_id}, sink=${link.sink_id} - node(s) not found`, + ); + return; + } get().addEdge(linkToCustomEdge(link)); }); },