Skip to content

Commit ae867c5

Browse files
committed
feat(collab): use three-way merge for component refresh updates
Replace broad refresh/rebuild node diffs with explicit field-level update_nodes operations so concurrent local edits are preserved. Reject whole-node update paths on the backend and add regression tests for refresh, rebuild, and outputs merge behavior.
1 parent 7e59837 commit ae867c5

16 files changed

Lines changed: 1246 additions & 111 deletions

File tree

src/backend/base/langflow/api/utils/collab/operations.py

Lines changed: 7 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from langflow.services.storage.service import StorageService
2727

2828

29+
TEMPLATE_PATH_LENGTH = 3
2930
TEMPLATE_FIELD_PATH_LENGTH = 4
3031
TEMPLATE_FIELD_VALUE_PATH_LENGTH = 5
3132

@@ -113,32 +114,17 @@ def _remove_api_keys_from_inner_node(inner_node: dict[str, Any]) -> None:
113114
def _sanitize_update_node_value(update: dict[str, Any], flow_data: dict[str, Any]) -> None:
114115
path = tuple(update["path"])
115116
value = update["value"]
116-
if path == ("data",):
117-
if isinstance(value, dict):
118-
_remove_api_keys_from_node_data(value)
117+
if len(path) <= TEMPLATE_PATH_LENGTH or path[:TEMPLATE_PATH_LENGTH] != ("data", "node", "template"):
119118
return
120-
if path == ("data", "node"):
121-
if isinstance(value, dict):
122-
_remove_api_keys_from_inner_node(value)
123-
return
124-
if path == ("data", "node", "template"):
125-
if isinstance(value, dict):
126-
remove_api_keys_from_template(value)
127-
return
128-
if (
129-
len(path) == TEMPLATE_FIELD_PATH_LENGTH
130-
and path[:3] == ("data", "node", "template")
131-
and isinstance(path[3], str)
132-
):
133-
if is_api_key_template_field(value):
134-
value["value"] = None
119+
120+
template_field_name = path[TEMPLATE_PATH_LENGTH]
121+
if len(path) == TEMPLATE_FIELD_PATH_LENGTH and is_api_key_template_field(value):
122+
value["value"] = None
135123
return
136124
if (
137125
len(path) == TEMPLATE_FIELD_VALUE_PATH_LENGTH
138-
and path[:3] == ("data", "node", "template")
139-
and isinstance(path[3], str)
140126
and path[4] == "value"
141-
and _is_api_key_template_value_update(flow_data, update["id"], path[3])
127+
and _is_api_key_template_value_update(flow_data, update["id"], template_field_name)
142128
):
143129
update["value"] = None
144130

src/backend/tests/unit/services/test_flow_operations.py

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -387,7 +387,7 @@ def test_delete_nodes_removes_incident_edges(self):
387387
def test_delete_nodes_rejects_missing_ids(self):
388388
flow_data = _base_flow_data()
389389

390-
with pytest.raises(FlowOperationValidationError, match="node was not in base flow"):
390+
with pytest.raises(FlowOperationValidationError, match="does not exist in the original flow"):
391391
apply_flow_operations(flow_data, [{"type": "delete_nodes", "ids": ["missing"]}])
392392

393393
def test_delete_edges_ignores_missing_ids(self):
@@ -432,7 +432,7 @@ def test_rejects_update_node_added_earlier_in_same_batch(self):
432432
flow_data = _base_flow_data()
433433
new_node = {"id": "c", "type": "generic", "position": {"x": 0, "y": 0}, "data": {}}
434434

435-
with pytest.raises(FlowOperationValidationError, match="cannot update node added earlier"):
435+
with pytest.raises(FlowOperationValidationError, match="does not exist in the original flow"):
436436
apply_flow_operations(
437437
flow_data,
438438
[
@@ -448,7 +448,7 @@ def test_rejects_delete_node_added_earlier_in_same_batch(self):
448448
flow_data = _base_flow_data()
449449
new_node = {"id": "c", "type": "generic", "position": {"x": 0, "y": 0}, "data": {}}
450450

451-
with pytest.raises(FlowOperationValidationError, match="node was not in base flow"):
451+
with pytest.raises(FlowOperationValidationError, match="does not exist in the original flow"):
452452
apply_flow_operations(
453453
flow_data,
454454
[
@@ -520,6 +520,34 @@ def test_rejects_invalid_update_node_paths(self, path, message):
520520
[{"type": "update_nodes", "updates": [{"id": "a", "op": "set_field", "path": path, "value": 1}]}],
521521
)
522522

523+
@pytest.mark.parametrize("path", [["data"], ["data", "node"], ["data", "node", "template"]])
524+
def test_rejects_broad_node_refresh_roots(self, path):
525+
flow_data = _base_flow_data()
526+
flow_data["nodes"][0]["data"]["node"] = {"template": {}, "outputs": []}
527+
528+
with pytest.raises(FlowOperationValidationError, match="cannot update entire node data objects"):
529+
apply_flow_operations(
530+
flow_data,
531+
[{"type": "update_nodes", "updates": [{"id": "a", "op": "set_field", "path": path, "value": {}}]}],
532+
)
533+
534+
def test_update_nodes_allows_merged_outputs_array(self):
535+
flow_data = _base_flow_data()
536+
flow_data["nodes"][0]["data"]["node"] = {"template": {}, "outputs": []}
537+
outputs = [{"name": "result", "display_name": "Result", "types": ["Message"]}]
538+
result = apply_flow_operations(
539+
flow_data,
540+
[
541+
{
542+
"type": "update_nodes",
543+
"updates": [{"id": "a", "op": "set_field", "path": ["data", "node", "outputs"], "value": outputs}],
544+
}
545+
],
546+
)
547+
548+
stored = next(node for node in result.flow_data["nodes"] if node["id"] == "a")
549+
assert stored["data"]["node"]["outputs"] == outputs
550+
523551
def test_rejects_array_delete(self):
524552
flow_data = _base_flow_data()
525553
flow_data["nodes"][0]["data"]["items"] = ["a", "b"]
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import type { UseMutationResult } from "@tanstack/react-query";
2+
import type { APIClassType, ResponseErrorDetailAPI } from "@/types/api";
3+
import type { AllNodeType } from "@/types/flow";
4+
import { mutateTemplate } from "../mutate-template";
5+
6+
const mockSetNode = jest.fn();
7+
let mockLatestNode: AllNodeType | undefined;
8+
9+
jest.mock("@/constants/constants", () => ({
10+
SAVE_DEBOUNCE_TIME: 0,
11+
}));
12+
13+
jest.mock("@/stores/flowStore", () => ({
14+
__esModule: true,
15+
default: {
16+
getState: () => ({
17+
getNode: () => mockLatestNode,
18+
setNode: mockSetNode,
19+
}),
20+
},
21+
}));
22+
23+
describe("mutateTemplate", () => {
24+
beforeEach(() => {
25+
jest.clearAllMocks();
26+
jest.useFakeTimers();
27+
mockLatestNode = {
28+
id: "node-1",
29+
type: "genericNode",
30+
position: { x: 0, y: 0 },
31+
data: {
32+
id: "node-1",
33+
type: "TestComponent",
34+
node: {
35+
display_name: "Test Component",
36+
template: {
37+
prompt: { value: "local prompt", show: true },
38+
other: { value: "local other" },
39+
},
40+
outputs: [{ name: "result", display_name: "Result", types: ["str"] }],
41+
},
42+
},
43+
} as unknown as AllNodeType;
44+
});
45+
46+
afterEach(() => {
47+
jest.useRealTimers();
48+
});
49+
50+
it("applies refresh responses through explicit three-way collaboration updates", async () => {
51+
const baseNode = {
52+
display_name: "Test Component",
53+
template: {
54+
prompt: { value: "base prompt", show: true },
55+
other: { value: "base other" },
56+
},
57+
outputs: [{ name: "result", display_name: "Result", types: ["str"] }],
58+
} as unknown as APIClassType;
59+
const postTemplateValue = {
60+
mutateAsync: jest.fn().mockResolvedValue({
61+
template: {
62+
prompt: { value: "generated prompt", show: false },
63+
other: { value: "generated other" },
64+
},
65+
outputs: [
66+
{
67+
name: "result",
68+
display_name: "Generated Result",
69+
types: ["Message"],
70+
},
71+
],
72+
last_updated: "2026-06-08T00:00:00Z",
73+
}),
74+
} as unknown as UseMutationResult<
75+
APIClassType | undefined,
76+
ResponseErrorDetailAPI,
77+
unknown
78+
>;
79+
const setNodeClass = jest.fn();
80+
const setErrorData = jest.fn();
81+
82+
await mutateTemplate(
83+
"next prompt",
84+
"node-1",
85+
baseNode,
86+
setNodeClass,
87+
postTemplateValue,
88+
setErrorData,
89+
"prompt",
90+
);
91+
await jest.runOnlyPendingTimersAsync();
92+
93+
const updatedNode = mockSetNode.mock.calls[0][1] as AllNodeType;
94+
const mutationOptions = mockSetNode.mock.calls[0][4];
95+
96+
expect(updatedNode.data.node!.template.prompt.value).toBe("local prompt");
97+
expect(updatedNode.data.node!.template.prompt.show).toBe(false);
98+
expect(updatedNode.data.node!.template.other.value).toBe("local other");
99+
expect(mutationOptions.collaborationUpdates).toEqual(
100+
expect.arrayContaining([
101+
expect.objectContaining({
102+
path: ["data", "node", "template", "prompt", "show"],
103+
value: false,
104+
}),
105+
expect.objectContaining({
106+
path: ["data", "node", "outputs"],
107+
}),
108+
]),
109+
);
110+
expect(
111+
mutationOptions.collaborationUpdates.some(
112+
(update) => update.path.join(".") === "data.node.template",
113+
),
114+
).toBe(false);
115+
expect(setNodeClass).toHaveBeenCalledWith(updatedNode.data.node);
116+
expect(setErrorData).not.toHaveBeenCalled();
117+
});
118+
});

src/frontend/src/CustomNodes/helpers/mutate-template.ts

Lines changed: 95 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,75 @@
11
import type { UseMutationResult } from "@tanstack/react-query";
22
import { cloneDeep, debounce } from "lodash";
33
import { SAVE_DEBOUNCE_TIME } from "@/constants/constants";
4-
import i18n from "../../i18n";
4+
import {
5+
applyNodeFieldUpdates,
6+
buildThreeWayComponentNodeUpdates,
7+
type ThreeWayComponentDiffPolicy,
8+
} from "@/hooks/flows/flow-operation-diff";
9+
import useFlowStore from "@/stores/flowStore";
510
import type { APIClassType, ResponseErrorDetailAPI } from "@/types/api";
11+
import type { AllNodeType } from "@/types/flow";
12+
import type { NodeFieldPath } from "@/types/flow-operations";
13+
import i18n from "../../i18n";
614
import { updateHiddenOutputs } from "./update-hidden-outputs";
715

816
// Map to store debounced functions for each node ID + parameter combination
917
const debouncedFunctions = new Map<string, ReturnType<typeof debounce>>();
1018

19+
type PostTemplateValueVariables = {
20+
value: unknown;
21+
field_name?: string;
22+
tool_mode?: boolean;
23+
is_refresh?: boolean;
24+
};
25+
26+
type PostTemplateValueMutation = UseMutationResult<
27+
APIClassType | undefined,
28+
ResponseErrorDetailAPI,
29+
PostTemplateValueVariables
30+
>;
31+
32+
function pathStartsWith(path: NodeFieldPath, prefix: string[]): boolean {
33+
return prefix.every((segment, index) => path[index] === segment);
34+
}
35+
36+
function buildRefreshPolicy(
37+
parameterName?: string,
38+
toolMode?: boolean,
39+
isRefresh?: boolean,
40+
): ThreeWayComponentDiffPolicy {
41+
return {
42+
generatedWinsOnOverlap: (path) => {
43+
if (parameterName === "tool_mode" && toolMode !== undefined) {
44+
return (
45+
pathStartsWith(path, ["data", "node", "tool_mode"]) ||
46+
pathStartsWith(path, ["data", "node", "outputs"])
47+
);
48+
}
49+
if (
50+
parameterName &&
51+
(isRefresh ||
52+
parameterName.includes("auth") ||
53+
parameterName.includes("connection"))
54+
) {
55+
return pathStartsWith(path, [
56+
"data",
57+
"node",
58+
"template",
59+
parameterName,
60+
]);
61+
}
62+
return false;
63+
},
64+
};
65+
}
66+
1167
export const mutateTemplate = async (
1268
newValue,
1369
nodeId: string,
1470
node: APIClassType,
1571
setNodeClass,
16-
postTemplateValue: UseMutationResult<
17-
APIClassType | undefined,
18-
ResponseErrorDetailAPI,
19-
any
20-
>,
72+
postTemplateValue: PostTemplateValueMutation,
2173
setErrorData,
2274
parameterName?: string,
2375
callback?: () => void,
@@ -35,11 +87,7 @@ export const mutateTemplate = async (
3587
newValue,
3688
node: APIClassType,
3789
setNodeClass,
38-
postTemplateValue: UseMutationResult<
39-
APIClassType | undefined,
40-
ResponseErrorDetailAPI,
41-
any
42-
>,
90+
postTemplateValue: PostTemplateValueMutation,
4391
setErrorData,
4492
parameterName?: string,
4593
callback?: () => void,
@@ -56,14 +104,45 @@ export const mutateTemplate = async (
56104
});
57105
if (newTemplate) {
58106
newNode.template = newTemplate.template;
59-
newNode.outputs = updateHiddenOutputs(
60-
newNode.outputs ?? [],
61-
newTemplate.outputs ?? [],
62-
);
107+
newNode.outputs = newTemplate.outputs;
63108
newNode.tool_mode = toolMode ?? node.tool_mode;
64109
newNode.last_updated = newTemplate.last_updated;
110+
const localGraphNode = useFlowStore.getState().getNode(nodeId);
111+
const localNode =
112+
(localGraphNode?.data?.node as APIClassType | undefined) ??
113+
node;
114+
const collaborationUpdates = buildThreeWayComponentNodeUpdates(
115+
nodeId,
116+
node as unknown as Record<string, unknown>,
117+
localNode as unknown as Record<string, unknown>,
118+
newNode as unknown as Record<string, unknown>,
119+
buildRefreshPolicy(parameterName, toolMode, isRefresh),
120+
);
65121
try {
66-
setNodeClass(newNode);
122+
if (localGraphNode) {
123+
const mergedGraphNode = applyNodeFieldUpdates(
124+
localGraphNode as unknown as Record<string, unknown>,
125+
collaborationUpdates,
126+
) as unknown as AllNodeType;
127+
useFlowStore
128+
.getState()
129+
.setNode(
130+
nodeId,
131+
mergedGraphNode as typeof localGraphNode,
132+
true,
133+
undefined,
134+
collaborationUpdates.length > 0
135+
? { collaborationUpdates }
136+
: undefined,
137+
);
138+
setNodeClass(mergedGraphNode.data.node);
139+
} else {
140+
newNode.outputs = updateHiddenOutputs(
141+
node.outputs ?? [],
142+
newTemplate.outputs ?? [],
143+
);
144+
setNodeClass(newNode);
145+
}
67146
} catch (e) {
68147
if (e instanceof Error && e.message === "Node not found") {
69148
console.error("Node not found");

0 commit comments

Comments
 (0)