Skip to content

Commit e53f92d

Browse files
authored
fix(initial_setup): preserve variable bindings when loading flows from disk (#14053)
fix(initial_setup): preserve variable bindings on flow load
1 parent 7157a10 commit e53f92d

5 files changed

Lines changed: 294 additions & 3 deletions

File tree

docs/docs/Develop/environment-variables.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,7 @@ See [Telemetry](/contributing-telemetry).
446446
| `LANGFLOW_ALLOW_COMPONENTS_PATHS_OVERRIDE` | Boolean | `True` | When `false` alongside `LANGFLOW_ALLOW_CUSTOM_COMPONENTS=false`, components contributed by `LANGFLOW_COMPONENTS_PATH` and `LANGFLOW_COMPONENTS_INDEX_PATH` no longer bypass the block. Has no effect when `LANGFLOW_ALLOW_CUSTOM_COMPONENTS=true`. For more information, see [Block custom components](../Deployment/deployment-block-custom-components.mdx). |
447447
| `LANGFLOW_LOAD_FLOWS_PATH` | String | Not set | Path to a directory containing flow JSON files to be loaded on startup. Typically used when creating a Docker image with prepackaged flows. Requires `LANGFLOW_AUTO_LOGIN=True`. |
448448
| `LANGFLOW_LOAD_FLOWS_OVERWRITE_ON_NAME_MATCH` | Boolean | `False` | When a flow file in `LANGFLOW_LOAD_FLOWS_PATH` shares a name with an existing DB row but has a different `id`, controls whether to overwrite the existing row. `False` (default) skips with a warning so UI edits are preserved on restart when file UUIDs regenerate. Set to `True` to opt into prepackaged-flows-are-source-of-truth semantics, typically for CI/CD pipelines. |
449+
| `LANGFLOW_LOAD_FLOWS_PRESERVE_VARIABLE_BINDINGS` | Boolean | `True` | Preserve global-variable bindings configured in the visual editor when an existing flow is reloaded from `LANGFLOW_LOAD_FLOWS_PATH`. Bindings explicitly configured in the flow file still take precedence. Set to `False` to restore blind replacement of the flow data on restart. |
449450
| `LANGFLOW_CREATE_STARTER_PROJECTS` | Boolean | `True` | Whether to create templates during initialization. If `false`, Langflow doesn't create templates, and `LANGFLOW_UPDATE_STARTER_PROJECTS` is treated as `false`. |
450451
| `LANGFLOW_UPDATE_STARTER_PROJECTS` | Boolean | `True` | Whether to update templates with the latest component versions when initializing after an upgrade. |
451452
| `LANGFLOW_LAZY_LOAD_COMPONENTS` | Boolean | `False` | If `true`, Langflow only partially loads components at startup and fully loads them on demand. This significantly reduces startup time but can cause a slight delay when a component is first used. |
@@ -485,4 +486,4 @@ You can hide individual elements without enabling the umbrella flag.
485486
| `LANGFLOW_HIDE_NEW_PROJECT_BUTTON` | Boolean | `False` | If `true`, hides the new project/folder button in the sidebar. Automatically enabled when `LANGFLOW_EMBEDDED_MODE=true`. |
486487
| `LANGFLOW_HIDE_NEW_FLOW_BUTTON` | Boolean | `False` | If `true`, hides the new flow button in the header. Automatically enabled when `LANGFLOW_EMBEDDED_MODE=true`. |
487488
| `LANGFLOW_HIDE_STARTER_PROJECTS` | Boolean | `False` | If `true`, hides the starter projects tab in the templates modal. Does not affect database seeding of starter projects. Automatically enabled when `LANGFLOW_EMBEDDED_MODE=true`. |
488-
| `LANGFLOW_HIDE_GETTING_STARTED_PROGRESS` | Boolean | `False` | If `true`, hides the getting-started onboarding progress UI. Not automatically enabled when `LANGFLOW_EMBEDDED_MODE=true`. |
489+
| `LANGFLOW_HIDE_GETTING_STARTED_PROGRESS` | Boolean | `False` | If `true`, hides the getting-started onboarding progress UI. Not automatically enabled when `LANGFLOW_EMBEDDED_MODE=true`. |

src/backend/base/langflow/initial_setup/setup.py

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1120,6 +1120,82 @@ async def load_bundles_from_urls() -> tuple[list[TemporaryDirectory], list[str]]
11201120
)
11211121

11221122

1123+
def _get_component_data(node):
1124+
if not isinstance(node, dict):
1125+
return None
1126+
node_data = node.get("data")
1127+
if not isinstance(node_data, dict):
1128+
return None
1129+
component_data = node_data.get("node")
1130+
return component_data if isinstance(component_data, dict) else None
1131+
1132+
1133+
def _get_node_template(node):
1134+
component_data = _get_component_data(node)
1135+
if component_data is None:
1136+
return None
1137+
template = component_data.get("template")
1138+
return template if isinstance(template, dict) else None
1139+
1140+
1141+
def _get_nested_flow(node):
1142+
component_data = _get_component_data(node)
1143+
if component_data is None:
1144+
return None
1145+
nested_flow = component_data.get("flow")
1146+
return nested_flow if isinstance(nested_flow, dict) else None
1147+
1148+
1149+
def _is_variable_binding(field):
1150+
if not isinstance(field, dict) or field.get("load_from_db") is not True:
1151+
return False
1152+
variable_name = field.get("value")
1153+
return isinstance(variable_name, str) and bool(variable_name)
1154+
1155+
1156+
def _merge_variable_bindings(existing_data, incoming_data):
1157+
"""Preserve DB-backed field bindings while taking flow structure from the incoming file."""
1158+
merged_data = deepcopy(incoming_data)
1159+
if not isinstance(existing_data, dict) or not isinstance(merged_data, dict):
1160+
return merged_data
1161+
1162+
existing_nodes = existing_data.get("nodes")
1163+
incoming_nodes = merged_data.get("nodes")
1164+
if not isinstance(existing_nodes, list) or not isinstance(incoming_nodes, list):
1165+
return merged_data
1166+
1167+
existing_nodes_by_id = {
1168+
node["id"]: node for node in existing_nodes if isinstance(node, dict) and isinstance(node.get("id"), str)
1169+
}
1170+
for incoming_node in incoming_nodes:
1171+
if not isinstance(incoming_node, dict):
1172+
continue
1173+
node_id = incoming_node.get("id")
1174+
if not isinstance(node_id, str) or node_id not in existing_nodes_by_id:
1175+
continue
1176+
1177+
existing_node = existing_nodes_by_id[node_id]
1178+
existing_template = _get_node_template(existing_node)
1179+
incoming_template = _get_node_template(incoming_node)
1180+
if existing_template is not None and incoming_template is not None:
1181+
for field_name, incoming_field in incoming_template.items():
1182+
if not isinstance(incoming_field, dict):
1183+
continue
1184+
existing_field = existing_template.get(field_name)
1185+
if _is_variable_binding(existing_field) and not _is_variable_binding(incoming_field):
1186+
incoming_field["value"] = deepcopy(existing_field["value"])
1187+
incoming_field["load_from_db"] = True
1188+
1189+
existing_nested_flow = _get_nested_flow(existing_node)
1190+
incoming_nested_flow = _get_nested_flow(incoming_node)
1191+
if existing_nested_flow is not None and incoming_nested_flow is not None and "data" in incoming_nested_flow:
1192+
incoming_nested_flow["data"] = _merge_variable_bindings(
1193+
existing_nested_flow.get("data"), incoming_nested_flow["data"]
1194+
)
1195+
1196+
return merged_data
1197+
1198+
11231199
async def upsert_flow_from_file(file_content: AnyStr, filename: str, session: AsyncSession, user_id: UUID) -> None:
11241200
flow = orjson.loads(file_content)
11251201
flow_endpoint_name = flow.get("endpoint_name")
@@ -1143,6 +1219,7 @@ async def upsert_flow_from_file(file_content: AnyStr, filename: str, session: As
11431219
name=flow_name,
11441220
)
11451221
if existing:
1222+
settings = get_settings_service().settings
11461223
await logger.adebug(f"Found existing flow: {existing.name}")
11471224
# Normalize the DB id to UUID for comparison without mutating the attached
11481225
# row: SQLAlchemy can return ids as strings on SQLite, but assigning back
@@ -1157,7 +1234,7 @@ async def upsert_flow_from_file(file_content: AnyStr, filename: str, session: As
11571234
else:
11581235
db_id = db_id_raw
11591236
matched_by_id = flow_id is not None and db_id == flow_id
1160-
if not matched_by_id and not get_settings_service().settings.load_flows_overwrite_on_name_match:
1237+
if not matched_by_id and not settings.load_flows_overwrite_on_name_match:
11611238
await logger.awarning(
11621239
f"Skipping flow update: db_id={db_id} name={existing.name!r} matched by "
11631240
f"name/endpoint_name but file id differs (file id={flow_id}). "
@@ -1174,7 +1251,10 @@ async def upsert_flow_from_file(file_content: AnyStr, filename: str, session: As
11741251
# lazy load outside greenlet context and raises ``MissingGreenlet``.
11751252
for key in _FLOW_UPDATABLE_COLUMNS:
11761253
if key in flow:
1177-
setattr(existing, key, flow[key])
1254+
incoming_value = flow[key]
1255+
if key == "data" and settings.load_flows_preserve_variable_bindings:
1256+
incoming_value = _merge_variable_bindings(existing.data, incoming_value)
1257+
setattr(existing, key, incoming_value)
11781258
existing.updated_at = datetime.now(tz=timezone.utc).astimezone()
11791259
existing.user_id = user_id
11801260

src/backend/tests/unit/initial_setup/test_upsert_flow_from_file.py

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,46 @@ def _overwrite_on_name_match(*, enabled: bool):
3636
settings.load_flows_overwrite_on_name_match = original
3737

3838

39+
@contextmanager
40+
def _preserve_variable_bindings(*, enabled: bool):
41+
"""Temporarily override the load_flows_preserve_variable_bindings setting."""
42+
settings = get_settings_service().settings
43+
original = settings.load_flows_preserve_variable_bindings
44+
settings.load_flows_preserve_variable_bindings = enabled
45+
try:
46+
yield
47+
finally:
48+
settings.load_flows_preserve_variable_bindings = original
49+
50+
51+
def _node_with_field(node_id: str, *, value: str, load_from_db: bool) -> dict:
52+
return {
53+
"id": node_id,
54+
"data": {
55+
"node": {
56+
"template": {
57+
"api_key": {
58+
"value": value,
59+
"load_from_db": load_from_db,
60+
}
61+
}
62+
}
63+
},
64+
}
65+
66+
67+
def _group_node(node_id: str, *nodes: dict) -> dict:
68+
return {
69+
"id": node_id,
70+
"data": {
71+
"node": {
72+
"template": {},
73+
"flow": {"data": {"nodes": list(nodes), "edges": []}},
74+
}
75+
},
76+
}
77+
78+
3979
async def _create_flow(
4080
*,
4181
name: str,
@@ -310,6 +350,162 @@ async def test_upsert_flow_from_file_id_match_still_overwrites_id_field() -> Non
310350
assert rows[0].description == "updated"
311351

312352

353+
@pytest.mark.usefixtures("client")
354+
@pytest.mark.parametrize("incoming_load_from_db", [False, True])
355+
async def test_upsert_flow_from_file_preserves_existing_variable_binding(incoming_load_from_db) -> None:
356+
"""A UI-configured global-variable binding survives a same-id startup re-import."""
357+
user_id = uuid4()
358+
existing_node = _node_with_field("n1", value="OPENAI_API_KEY", load_from_db=True)
359+
existing_node["data"]["node"]["template"]["api_key"]["display_name"] = "Old label"
360+
original = await _create_flow(
361+
name="BoundFlow",
362+
user_id=user_id,
363+
data={"nodes": [existing_node], "edges": []},
364+
)
365+
incoming_data = {
366+
"nodes": [_node_with_field("n1", value="", load_from_db=incoming_load_from_db)],
367+
"edges": [{"id": "updated-edge"}],
368+
}
369+
incoming_data["nodes"][0]["data"]["node"]["template"]["api_key"]["display_name"] = "New label"
370+
file_content = orjson.dumps({"id": str(original.id), "name": original.name, "data": incoming_data})
371+
372+
with _preserve_variable_bindings(enabled=True):
373+
async with session_scope() as session:
374+
await upsert_flow_from_file(file_content, original.name, session, user_id)
375+
await session.commit()
376+
377+
async with session_scope() as session:
378+
updated = (await session.exec(select(Flow).where(Flow.id == original.id))).one()
379+
field = updated.data["nodes"][0]["data"]["node"]["template"]["api_key"]
380+
assert field == {"value": "OPENAI_API_KEY", "load_from_db": True, "display_name": "New label"}
381+
assert updated.data["edges"] == incoming_data["edges"]
382+
383+
384+
@pytest.mark.usefixtures("client")
385+
async def test_upsert_flow_from_file_explicit_file_variable_binding_wins() -> None:
386+
"""An explicit binding in the file remains the source of truth."""
387+
user_id = uuid4()
388+
original = await _create_flow(
389+
name="ReboundFlow",
390+
user_id=user_id,
391+
data={"nodes": [_node_with_field("n1", value="OLD_API_KEY", load_from_db=True)], "edges": []},
392+
)
393+
incoming_data = {
394+
"nodes": [_node_with_field("n1", value="NEW_API_KEY", load_from_db=True)],
395+
"edges": [],
396+
}
397+
file_content = orjson.dumps({"id": str(original.id), "name": original.name, "data": incoming_data})
398+
399+
with _preserve_variable_bindings(enabled=True):
400+
async with session_scope() as session:
401+
await upsert_flow_from_file(file_content, original.name, session, user_id)
402+
await session.commit()
403+
404+
async with session_scope() as session:
405+
updated = (await session.exec(select(Flow).where(Flow.id == original.id))).one()
406+
field = updated.data["nodes"][0]["data"]["node"]["template"]["api_key"]
407+
assert field == {"value": "NEW_API_KEY", "load_from_db": True}
408+
409+
410+
@pytest.mark.usefixtures("client")
411+
async def test_upsert_flow_from_file_preserves_nested_variable_binding() -> None:
412+
"""Bindings inside grouped flows survive the same recursive startup merge."""
413+
user_id = uuid4()
414+
existing_group = _group_node("group", _node_with_field("nested", value="NESTED_API_KEY", load_from_db=True))
415+
original = await _create_flow(
416+
name="GroupedFlow",
417+
user_id=user_id,
418+
data={"nodes": [existing_group], "edges": []},
419+
)
420+
incoming_group = _group_node("group", _node_with_field("nested", value="", load_from_db=False))
421+
incoming_data = {"nodes": [incoming_group], "edges": []}
422+
file_content = orjson.dumps({"id": str(original.id), "name": original.name, "data": incoming_data})
423+
424+
with _preserve_variable_bindings(enabled=True):
425+
async with session_scope() as session:
426+
await upsert_flow_from_file(file_content, original.name, session, user_id)
427+
await session.commit()
428+
429+
async with session_scope() as session:
430+
updated = (await session.exec(select(Flow).where(Flow.id == original.id))).one()
431+
nested_node = updated.data["nodes"][0]["data"]["node"]["flow"]["data"]["nodes"][0]
432+
field = nested_node["data"]["node"]["template"]["api_key"]
433+
assert field == {"value": "NESTED_API_KEY", "load_from_db": True}
434+
435+
436+
@pytest.mark.usefixtures("client")
437+
async def test_upsert_flow_from_file_keeps_new_file_nodes_unchanged() -> None:
438+
"""Nodes without a DB counterpart pass through the merge unchanged."""
439+
user_id = uuid4()
440+
original = await _create_flow(
441+
name="ExpandedFlow",
442+
user_id=user_id,
443+
data={"nodes": [_node_with_field("existing", value="API_KEY", load_from_db=True)], "edges": []},
444+
)
445+
new_node = _node_with_field("new", value="literal-from-file", load_from_db=False)
446+
incoming_data = {"nodes": [new_node], "edges": []}
447+
file_content = orjson.dumps({"id": str(original.id), "name": original.name, "data": incoming_data})
448+
449+
with _preserve_variable_bindings(enabled=True):
450+
async with session_scope() as session:
451+
await upsert_flow_from_file(file_content, original.name, session, user_id)
452+
await session.commit()
453+
454+
async with session_scope() as session:
455+
updated = (await session.exec(select(Flow).where(Flow.id == original.id))).one()
456+
assert updated.data == incoming_data
457+
458+
459+
@pytest.mark.usefixtures("client")
460+
async def test_upsert_flow_from_file_can_disable_variable_binding_preservation() -> None:
461+
"""The opt-out restores the previous blind-overwrite behavior."""
462+
user_id = uuid4()
463+
original = await _create_flow(
464+
name="GitOpsFlow",
465+
user_id=user_id,
466+
data={"nodes": [_node_with_field("n1", value="API_KEY", load_from_db=True)], "edges": []},
467+
)
468+
incoming_data = {
469+
"nodes": [_node_with_field("n1", value="literal-from-file", load_from_db=False)],
470+
"edges": [],
471+
}
472+
file_content = orjson.dumps({"id": str(original.id), "name": original.name, "data": incoming_data})
473+
474+
with _preserve_variable_bindings(enabled=False):
475+
async with session_scope() as session:
476+
await upsert_flow_from_file(file_content, original.name, session, user_id)
477+
await session.commit()
478+
479+
async with session_scope() as session:
480+
updated = (await session.exec(select(Flow).where(Flow.id == original.id))).one()
481+
assert updated.data == incoming_data
482+
483+
484+
@pytest.mark.usefixtures("client")
485+
async def test_upsert_flow_from_file_does_not_preserve_empty_default_binding() -> None:
486+
"""An empty load-from-DB default must not override a literal value from the file."""
487+
user_id = uuid4()
488+
original = await _create_flow(
489+
name="DefaultSecretFlow",
490+
user_id=user_id,
491+
data={"nodes": [_node_with_field("n1", value="", load_from_db=True)], "edges": []},
492+
)
493+
incoming_data = {
494+
"nodes": [_node_with_field("n1", value="literal-from-file", load_from_db=False)],
495+
"edges": [],
496+
}
497+
file_content = orjson.dumps({"id": str(original.id), "name": original.name, "data": incoming_data})
498+
499+
with _preserve_variable_bindings(enabled=True):
500+
async with session_scope() as session:
501+
await upsert_flow_from_file(file_content, original.name, session, user_id)
502+
await session.commit()
503+
504+
async with session_scope() as session:
505+
updated = (await session.exec(select(Flow).where(Flow.id == original.id))).one()
506+
assert updated.data == incoming_data
507+
508+
313509
@pytest.mark.usefixtures("client")
314510
async def test_upsert_flow_from_file_skips_name_match_when_overwrite_disabled() -> None:
315511
"""When load_flows_overwrite_on_name_match=False, name-matched rows are NOT overwritten.

src/lfx/src/lfx/services/settings/groups/components.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@ class ComponentsSettings(BaseModel):
3535
successfully either way.) Set ``True`` to opt into "prepackaged flows are the source of
3636
truth on restart" semantics, typically for CI/CD pipelines.
3737
"""
38+
load_flows_preserve_variable_bindings: bool = True
39+
"""Preserve global-variable bindings configured in the UI when reloading an existing flow.
40+
41+
The flow file remains authoritative for the rest of the flow structure and for bindings it
42+
explicitly defines. Set this to ``False`` to restore blind ``data`` replacement on restart.
43+
"""
3844
bundle_urls: list[str] = []
3945

4046
lazy_load_components: bool = False

src/lfx/tests/unit/services/settings/test_settings_composition.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@
160160
"mcp_servers_locked",
161161
# ComponentsSettings
162162
"load_flows_overwrite_on_name_match",
163+
"load_flows_preserve_variable_bindings",
163164
"enable_extension_reload",
164165
# SecuritySettings
165166
"rate_limit_enabled",
@@ -257,6 +258,7 @@ def test_critical_defaults_unchanged():
257258
assert settings.mcp_server_allowed_packages is None
258259
assert settings.mcp_server_enabled is True
259260
assert settings.mcp_composer_enabled is True
261+
assert settings.load_flows_preserve_variable_bindings is True
260262
assert settings.do_not_track is False
261263
assert settings.dev is False
262264
assert settings.agentic_experience is False
@@ -389,6 +391,12 @@ def test_yaml_round_trip():
389391
("LANGFLOW_BACKEND_ONLY", "true", "backend_only", True),
390392
("LANGFLOW_AUTO_SAVING", "false", "auto_saving", False),
391393
("LANGFLOW_FALLBACK_TO_ENV_VAR", "false", "fallback_to_env_var", False),
394+
(
395+
"LANGFLOW_LOAD_FLOWS_PRESERVE_VARIABLE_BINDINGS",
396+
"false",
397+
"load_flows_preserve_variable_bindings",
398+
False,
399+
),
392400
("LANGFLOW_VARIABLE_STORE", "kubernetes", "variable_store", "kubernetes"),
393401
],
394402
)

0 commit comments

Comments
 (0)