OpenRAG 0.6.0.dev17__py3-none-any.whl → 0.6.0.dev19__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
api/flows.py CHANGED
@@ -1,18 +1,26 @@
1
1
  """Reset Flow API endpoints"""
2
+
2
3
  from typing import Literal
3
4
 
4
5
  from fastapi import Depends
5
6
  from fastapi.responses import JSONResponse
6
- from utils.logging_config import get_logger
7
+ from pydantic import BaseModel
7
8
 
8
- from dependencies import get_flows_service, get_current_user, require_permission
9
+ from dependencies import get_flows_service, require_permission
9
10
  from session_manager import User
11
+ from utils.logging_config import get_logger
10
12
 
11
13
  logger = get_logger(__name__)
12
14
 
13
15
  FlowType = Literal["nudges", "retrieval", "ingest"]
14
16
 
15
17
 
18
+ def _is_oss_mode() -> bool:
19
+ from config.settings import IBM_AUTH_ENABLED, is_dev_ibm_cos_enabled
20
+
21
+ return not (IBM_AUTH_ENABLED or is_dev_ibm_cos_enabled())
22
+
23
+
16
24
  async def reset_flow_endpoint(
17
25
  flow_type: str,
18
26
  flows_service=Depends(get_flows_service),
@@ -23,27 +31,19 @@ async def reset_flow_endpoint(
23
31
  return JSONResponse(
24
32
  {
25
33
  "success": False,
26
- "error": "Invalid flow type. Must be 'nudges', 'retrieval', or 'ingest'"
34
+ "error": "Invalid flow type. Must be 'nudges', 'retrieval', or 'ingest'",
27
35
  },
28
- status_code=400
36
+ status_code=400,
29
37
  )
30
38
 
31
39
  try:
32
40
  result = await flows_service.reset_langflow_flow(flow_type)
33
41
 
34
42
  if result.get("success"):
35
- logger.info(
36
- "Flow reset successful",
37
- flow_type=flow_type,
38
- flow_id=result.get("flow_id")
39
- )
43
+ logger.info("Flow reset successful", flow_type=flow_type, flow_id=result.get("flow_id"))
40
44
  return JSONResponse(result, status_code=200)
41
45
  else:
42
- logger.error(
43
- "Flow reset failed",
44
- flow_type=flow_type,
45
- error=result.get("error")
46
- )
46
+ logger.error("Flow reset failed", flow_type=flow_type, error=result.get("error"))
47
47
  return JSONResponse(result, status_code=500)
48
48
 
49
49
  except ValueError as e:
@@ -51,7 +51,67 @@ async def reset_flow_endpoint(
51
51
  return JSONResponse({"success": False, "error": str(e)}, status_code=400)
52
52
  except Exception as e:
53
53
  logger.error("Unexpected error in flow reset", error=str(e))
54
+ return JSONResponse({"success": False, "error": "Internal server error"}, status_code=500)
55
+
56
+
57
+ class BulkUpdateFlowsRequest(BaseModel):
58
+ flow_types: list[str]
59
+ backup_custom: bool = True
60
+
61
+
62
+ async def get_flows_updates_endpoint(
63
+ flows_service=Depends(get_flows_service),
64
+ user: User = Depends(require_permission("flows:read")),
65
+ ):
66
+ """Get available updates for core flows"""
67
+ if not _is_oss_mode():
68
+ return JSONResponse({"success": True, "updates": []}, status_code=200)
69
+ try:
70
+ user_id = getattr(user, "db_user_id", None) or getattr(user, "user_id", None)
71
+ updates = await flows_service.get_flows_updates_available(user_id=user_id)
72
+ return JSONResponse({"success": True, "updates": updates}, status_code=200)
73
+ except Exception as e:
74
+ logger.error("Error getting flow updates", error=str(e))
75
+ return JSONResponse({"success": False, "error": "Internal server error"}, status_code=500)
76
+
77
+
78
+ async def bulk_update_flows_endpoint(
79
+ request: BulkUpdateFlowsRequest,
80
+ flows_service=Depends(get_flows_service),
81
+ user: User = Depends(require_permission("flows:edit")),
82
+ ):
83
+ """Bulk update multiple flows and optionally backup custom flows"""
84
+ if not _is_oss_mode():
54
85
  return JSONResponse(
55
- {"success": False, "error": f"Internal server error: {str(e)}"},
56
- status_code=500
86
+ {"success": False, "error": "Flow updates are only available in OSS mode"},
87
+ status_code=403,
57
88
  )
89
+ try:
90
+ results = await flows_service.bulk_update_flows(request.flow_types, request.backup_custom)
91
+ overall_success = all(r.get("success", False) for r in results)
92
+ return JSONResponse({"success": overall_success, "results": results}, status_code=200)
93
+ except Exception as e:
94
+ logger.error("Error bulk updating flows", error=str(e))
95
+ return JSONResponse({"success": False, "error": "Internal server error"}, status_code=500)
96
+
97
+
98
+ class DismissFlowsUpdateRequest(BaseModel):
99
+ flow_types: list[str] | None = None
100
+
101
+
102
+ async def dismiss_flows_update_endpoint(
103
+ request: DismissFlowsUpdateRequest | None = None,
104
+ flows_service=Depends(get_flows_service),
105
+ user: User = Depends(require_permission("flows:read")),
106
+ ):
107
+ """Dismiss available flow update notifications ephemerally in backend memory"""
108
+ if not _is_oss_mode():
109
+ return JSONResponse({"success": True}, status_code=200)
110
+ try:
111
+ flow_types = request.flow_types if request else None
112
+ user_id = getattr(user, "db_user_id", None) or getattr(user, "user_id", None)
113
+ flows_service.dismiss_flows_updates(flow_types, user_id=user_id)
114
+ return JSONResponse({"success": True}, status_code=200)
115
+ except Exception as e:
116
+ logger.error("Error dismissing flow updates", error=str(e))
117
+ return JSONResponse({"success": False, "error": "Internal server error"}, status_code=500)
app/routes/internal.py CHANGED
@@ -426,6 +426,26 @@ def register_internal_routes(app: FastAPI):
426
426
  tags=["internal"],
427
427
  )
428
428
 
429
+ # Flow updates endpoints
430
+ app.add_api_route(
431
+ "/settings/flows/updates-available",
432
+ flows.get_flows_updates_endpoint,
433
+ methods=["GET"],
434
+ tags=["internal"],
435
+ )
436
+ app.add_api_route(
437
+ "/settings/flows/update",
438
+ flows.bulk_update_flows_endpoint,
439
+ methods=["POST"],
440
+ tags=["internal"],
441
+ )
442
+ app.add_api_route(
443
+ "/settings/flows/dismiss-update",
444
+ flows.dismiss_flows_update_endpoint,
445
+ methods=["POST"],
446
+ tags=["internal"],
447
+ )
448
+
429
449
  # Router upload ingest endpoint
430
450
  app.add_api_route(
431
451
  "/router/upload_ingest",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openrag
3
- Version: 0.6.0.dev17
3
+ Version: 0.6.0.dev19
4
4
  Summary: OpenRAG is a comprehensive Retrieval-Augmented Generation platform that enables intelligent document search and AI-powered conversations.
5
5
  Classifier: Development Status :: 4 - Beta
6
6
  Classifier: Environment :: Console
@@ -13,7 +13,7 @@ api/connectors.py,sha256=XgNnPjBIB-FGlZ0krEPbXen017BFbS-1rDzpbpeMJg4,103927
13
13
  api/docling.py,sha256=PKdLaunKpSB3PaIheogbbRSTPdpGQDV7jc1HuD3d2J8,2430
14
14
  api/documents.py,sha256=R3IfnpGzaE73MJxE5kREJRCX6JmTEyFQGNjF3ib5fWk,11841
15
15
  api/files.py,sha256=TMxR-NG-e9BLaZccN_bE3uP-CEbdZWThPfKTHvgG6E4,3626
16
- api/flows.py,sha256=FklETisPSjNTjAkTbF9Jl9Wn17D_Cy78rPN_Dv3D9SI,1864
16
+ api/flows.py,sha256=9dEUSEdoxo4tBBGG0IJVqFDj1SbO1pCrv5nCcliE70o,4592
17
17
  api/health.py,sha256=pAEEVl9uVG4zcoR0ZqL_zPfDLz4ficHtALcpzOXjgDI,3102
18
18
  api/ingest_preview.py,sha256=mffMLdBuzc--JT8gHvxfRDQcWoWRK5vVQg8uljLNn6o,2431
19
19
  api/keys.py,sha256=cPocE-ocfbtSWf3YEvMTHZgrxUcLJIm_7Pex6UVggwY,3477
@@ -51,7 +51,7 @@ app/lifespan.py,sha256=3KJ5pSAg_rILTJrzojFVS9ULJE0D0SZL74-S7fA4UVc,18556
51
51
  app/middleware.py,sha256=iMl8TP7XzS8UWB1ZKnjPhAPlbQHWTUXZ2e-Y_evSUNI,2334
52
52
  app/router_app.py,sha256=pOxCVnsakUgBIxs42UeE94EJ5peCNRYTP7jk6Eo7on0,5873
53
53
  app/routes/__init__.py,sha256=QESovwz429ht1sXMHL1ciQKZOgxCcvuYgLMjoDtxyDA,698
54
- app/routes/internal.py,sha256=8Ix0PYhH7Bu3NlPAF0dDwD1KH08pIp_HqfsRpHUGuEQ,14094
54
+ app/routes/internal.py,sha256=DjDdYaDs1cPjwGsyUWxRvznMnLrqxmmJznupceJ714U,14619
55
55
  app/routes/mcp.py,sha256=JF_JEy6ATnraACeQFk4KDIuaT9LkVSZqzvabHkL51xQ,1159
56
56
  app/routes/public_v1.py,sha256=qEzuSIPwXR5FrqaZ0lqOjYqDxH2knqsJyW6L7t0mZak,3406
57
57
  auth/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -128,7 +128,7 @@ models/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
128
128
  models/processors.py,sha256=F0LOVNOOrDS9zCfb-piYf3uRYawiPbmNGO2Da4g6G4I,70039
129
129
  models/tasks.py,sha256=BByR2xDl4zqvbCbTGgBC3aChJ_uJoRpHy0TTJrvW5Dc,3152
130
130
  models/url.py,sha256=KMleXhlwd3W59axcubMJxIEtHpyBQTvfxkycc-o0kOY,5601
131
- openrag-0.6.0.dev17.dist-info/licenses/LICENSE,sha256=jWcnPXkUxEinX60Yn-_zjsY9i84vz2QSb0fi53djOkg,11334
131
+ openrag-0.6.0.dev19.dist-info/licenses/LICENSE,sha256=jWcnPXkUxEinX60Yn-_zjsY9i84vz2QSb0fi53djOkg,11334
132
132
  services/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
133
133
  services/api_key_service.py,sha256=m04jVSJyDqIl-NgNROKLIfy4QYArZxlvz_WkjqQK7TA,13269
134
134
  services/auth_service.py,sha256=qxg5Mj1KvR23dBaZnwe2gjQia5DqvNNi7VQCkkKxq8k,23794
@@ -145,7 +145,7 @@ services/docling_service.py,sha256=C9nXzg4eRsmnjS0kv3SH1MliNqL87cPr6cgbhG8hjKs,1
145
145
  services/document_index_writer.py,sha256=204buyb7bHBKciuci3MGF-Q8_bI8SiLMOfuje9BnsE0,12270
146
146
  services/document_service.py,sha256=ObGyfDgCdQsJkFiFUoN34yqkjCJyy-6aSvxqAqjfXEw,7604
147
147
  services/file_service.py,sha256=aBNwMUTsZqRAsg31IBRlbDMHNZkP9LsUJM6_IEgesVM,8701
148
- services/flows_service.py,sha256=idV4CmoEQn2zX-Jt-xrTUc4osojnR9AG9GlDKXgbHzs,59891
148
+ services/flows_service.py,sha256=vdr-chLE7VkhxqqIWBzXb7vTA3SaJL4Qen0gfVaiS30,68434
149
149
  services/group_acl_service.py,sha256=_Pt1vf8SAgxKuaqnnu4pJ_G2MzyV8czmNBXflkwIg6A,4170
150
150
  services/ingest_preview_service.py,sha256=BfH054zIQ_osYnr8rSLs_CW-zDFDsYLuXXK26jIlwi0,6238
151
151
  services/knowledge_filter_service.py,sha256=4SXHaFfMJzAZGMjAU8qpdh4H_X3cEnyJp0ijyL9WAiE,15495
@@ -159,7 +159,7 @@ services/onboarding_reset.py,sha256=LiIsmK5mnj4SbGAtma8Hsc6U-I3WjWdsoDeK_cPOtIo,
159
159
  services/rbac_service.py,sha256=6lfthiLIRQEk-ahDeiDg_eNDbDyrXNHShfb4qxfirSM,4962
160
160
  services/search_service.py,sha256=Qu9cAZTYChuy-L47N1z04HHGI43VIJFqemUdcAC-ZMg,30354
161
161
  services/session_ownership_service.py,sha256=RHM2N7n9bETNqOpqXrgEK3F54XHJn2AkoMcXJoBzrEM,9868
162
- services/startup_orchestrator.py,sha256=hGpXAdfRdnhgi2vt30griDLKZ50thvhoviMF_RChDUo,9247
162
+ services/startup_orchestrator.py,sha256=iLkoPHXyOIB4mFzTapF6viMntSSb-PEJkxRXhk6VY-0,7385
163
163
  services/task_service.py,sha256=wnvmd9MEYl_BDWUqmEtjmN08Iik1ANaZrJYyp71lqNA,64551
164
164
  services/user_service.py,sha256=KsqQN0qPSwxQsBUBkIxE60TDJNveaDk-5rhQE9bN2Fs,11993
165
165
  services/workspace_config_service.py,sha256=Ij-fvy0zd3OSsML4ZwG4WG-QmOuxLWcCf9f3HvtD6iY,16544
@@ -240,8 +240,8 @@ utils/telemetry/__init__.py,sha256=xoStY8hoYWnVe0B_jNthSdzRb7AxCT0rhM8ba9Q0Obg,2
240
240
  utils/telemetry/category.py,sha256=wei3V5WyUaiadYjEE5s0ys9PY0TjnebIvTykQU0P_2k,1114
241
241
  utils/telemetry/client.py,sha256=RhjW1a27w7W6gHt9X7AxdRPdZGmCEQDPskkxj5nsuiQ,12500
242
242
  utils/telemetry/message_id.py,sha256=05CvlKW_LaD00INxd3WV8uXBZ9f_5Cvy-jAyOPmpxTk,9600
243
- openrag-0.6.0.dev17.dist-info/METADATA,sha256=BGDTqTDPIuXkze9ZxCunCjYk9m4lkGeSDT9zz2-i7oU,8728
244
- openrag-0.6.0.dev17.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
245
- openrag-0.6.0.dev17.dist-info/entry_points.txt,sha256=ArKNHB63I3K8vynNkogvZOLmmQFpIsHDL7MvreCmju4,45
246
- openrag-0.6.0.dev17.dist-info/top_level.txt,sha256=TpFyXTBO-cpphRZHx7Vgwgzg4KmPXwNkkfOo93D5qOk,132
247
- openrag-0.6.0.dev17.dist-info/RECORD,,
243
+ openrag-0.6.0.dev19.dist-info/METADATA,sha256=CF2Z-QYMINwAljaYaWpYsUO56NM-ILJqbMwPuzVT_YA,8728
244
+ openrag-0.6.0.dev19.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
245
+ openrag-0.6.0.dev19.dist-info/entry_points.txt,sha256=ArKNHB63I3K8vynNkogvZOLmmQFpIsHDL7MvreCmju4,45
246
+ openrag-0.6.0.dev19.dist-info/top_level.txt,sha256=TpFyXTBO-cpphRZHx7Vgwgzg4KmPXwNkkfOo93D5qOk,132
247
+ openrag-0.6.0.dev19.dist-info/RECORD,,
services/flows_service.py CHANGED
@@ -1,7 +1,7 @@
1
1
  import asyncio
2
2
  import json
3
3
  import os
4
- from datetime import datetime
4
+ from datetime import UTC, datetime
5
5
  from typing import Any
6
6
 
7
7
  from cachetools import LRUCache
@@ -25,6 +25,48 @@ logger = get_logger(__name__)
25
25
 
26
26
 
27
27
  class FlowsService:
28
+ def __init__(self) -> None:
29
+ # Ephemeral dict of dismissed flow update notifications per user_id
30
+ self._dismissed_updates: dict[str, set[str]] = {}
31
+ # Cache for flow file mappings to avoid repeated filesystem scans
32
+ self._flow_file_cache: dict[str, str] = {}
33
+ # Semaphores to prevent race conditions when updating the same flow concurrently
34
+ self._flow_locks = LRUCache(maxsize=128)
35
+ self._lock_creation_lock = asyncio.Lock()
36
+ # Cache for enabled models to avoid redundant API calls
37
+ self._enabled_models_cache: dict[str, Any] | None = None
38
+ self._enabled_models_cache_time: datetime | None = None
39
+ self._enabled_models_cache_ttl = 60 # seconds
40
+ self._enabled_models_lock = asyncio.Lock()
41
+ # Cache for available custom flow updates
42
+ self._custom_updates_cache: list[dict[str, Any]] | None = None
43
+
44
+ def dismiss_flows_updates(
45
+ self, flow_types: list[str] | None = None, user_id: str | None = None
46
+ ) -> None:
47
+ """Dismiss flow update notifications ephemerally in backend memory per user."""
48
+ if not hasattr(self, "_dismissed_updates") or not isinstance(self._dismissed_updates, dict):
49
+ self._dismissed_updates = {}
50
+ target_user = user_id or "default"
51
+ if target_user not in self._dismissed_updates:
52
+ self._dismissed_updates[target_user] = set()
53
+
54
+ if flow_types:
55
+ self._dismissed_updates[target_user].update(flow_types)
56
+ else:
57
+ self._dismissed_updates[target_user].update(
58
+ ["nudges", "retrieval", "ingest", "url_ingest"]
59
+ )
60
+
61
+ def clear_dismissed_update(self, flow_type: str, user_id: str | None = None):
62
+ if hasattr(self, "_dismissed_updates") and isinstance(self._dismissed_updates, dict):
63
+ if user_id:
64
+ if user_id in self._dismissed_updates:
65
+ self._dismissed_updates[user_id].discard(flow_type)
66
+ else:
67
+ for user_set in self._dismissed_updates.values():
68
+ user_set.discard(flow_type)
69
+
28
70
  async def resolve_ollama_url(self, endpoint: str, force_refresh: bool = False) -> str:
29
71
  """Find the correct Ollama URL by probing candidates via Langflow's validate-provider API."""
30
72
  from config.config_manager import config_manager
@@ -87,18 +129,6 @@ class FlowsService:
87
129
 
88
130
  return resolved_url
89
131
 
90
- def __init__(self):
91
- # Cache for flow file mappings to avoid repeated filesystem scans
92
- self._flow_file_cache = {}
93
- # Semaphores to prevent race conditions when updating the same flow concurrently
94
- self._flow_locks = LRUCache(maxsize=128)
95
- self._lock_creation_lock = asyncio.Lock()
96
- # Cache for enabled models to avoid redundant API calls
97
- self._enabled_models_cache = None
98
- self._enabled_models_cache_time = None
99
- self._enabled_models_cache_ttl = 60 # seconds
100
- self._enabled_models_lock = asyncio.Lock()
101
-
102
132
  async def _get_flow_lock(self, flow_id: str) -> asyncio.Lock:
103
133
  """Get or create an asyncio.Lock for a specific flow ID"""
104
134
  async with self._lock_creation_lock:
@@ -305,8 +335,12 @@ class FlowsService:
305
335
  for result in results:
306
336
  if not result:
307
337
  continue
308
- backup_results[result["type"]].append(result["data"])
309
- if result["type"] == "failed":
338
+ res_type = result.get("type")
339
+ if res_type in ("backed_up", "skipped", "failed"):
340
+ target_list = backup_results[res_type]
341
+ if isinstance(target_list, list):
342
+ target_list.append(result.get("data"))
343
+ if res_type == "failed":
310
344
  backup_results["success"] = False
311
345
 
312
346
  logger.info(
@@ -328,7 +362,7 @@ class FlowsService:
328
362
 
329
363
  return backup_results
330
364
 
331
- async def _backup_flow(self, flow_id: str, flow_type: str, flow_data: dict = None):
365
+ async def _backup_flow(self, flow_id: str, flow_type: str, flow_data: dict | None = None):
332
366
  """
333
367
  Backup a single flow to the backup folder.
334
368
 
@@ -435,19 +469,7 @@ class FlowsService:
435
469
  logger.warning(f"Flow with ID {flow_id} not found in flows directory")
436
470
  return None
437
471
 
438
- async def reset_langflow_flow(self, flow_type: str):
439
- """Reset a Langflow flow by uploading the corresponding JSON file
440
-
441
- Args:
442
- flow_type: Either 'nudges', 'retrieval', or 'ingest'
443
-
444
- Returns:
445
- dict: Success/error response
446
- """
447
- if not LANGFLOW_URL:
448
- raise ValueError("LANGFLOW_URL environment variable is required")
449
-
450
- # Determine flow ID based on type
472
+ def _get_flow_id_by_type(self, flow_type: str) -> str:
451
473
  if flow_type == "nudges":
452
474
  flow_id = NUDGES_FLOW_ID
453
475
  elif flow_type == "retrieval":
@@ -460,9 +482,64 @@ class FlowsService:
460
482
  raise ValueError(
461
483
  "flow_type must be either 'nudges', 'retrieval', 'ingest', or 'url_ingest'"
462
484
  )
463
-
464
485
  if not flow_id:
465
486
  raise ValueError(f"Flow ID not configured for flow_type '{flow_type}'")
487
+ return flow_id
488
+
489
+ async def reset_langflow_flow(self, flow_type: str):
490
+ """Reset a Langflow flow by uploading the corresponding JSON file
491
+
492
+ Args:
493
+ flow_type: Either 'nudges', 'retrieval', 'ingest', or 'url_ingest'
494
+
495
+ Returns:
496
+ dict: Success/error response
497
+ """
498
+ flow_id = self._get_flow_id_by_type(flow_type)
499
+ flow_lock = await self._get_flow_lock(flow_id)
500
+ async with flow_lock:
501
+ result = await self._reset_langflow_flow_locked(flow_type, flow_id)
502
+ if result.get("success"):
503
+ self._custom_updates_cache = None
504
+ try:
505
+ config = get_openrag_config()
506
+ if config.edited:
507
+ from api.settings import reapply_all_settings
508
+
509
+ await reapply_all_settings()
510
+ except Exception as e:
511
+ logger.error(f"Error reapplying settings after flow reset: {e}")
512
+ return result
513
+
514
+ async def _unlock_flow(self, flow_id: str):
515
+ """Unlock a flow before making changes."""
516
+ try:
517
+ response = await clients.langflow_request(
518
+ "PATCH", f"/api/v1/flows/{flow_id}", json={"locked": False}
519
+ )
520
+ if response.status_code not in (200, 404):
521
+ logger.warning(
522
+ f"Failed to unlock flow {flow_id}: HTTP {response.status_code} - {response.text}"
523
+ )
524
+ except Exception as e:
525
+ logger.warning(f"Error unlocking flow {flow_id}: {e}")
526
+
527
+ async def _lock_flow(self, flow_id: str):
528
+ """Lock a flow after making changes."""
529
+ try:
530
+ response = await clients.langflow_request(
531
+ "PATCH", f"/api/v1/flows/{flow_id}", json={"locked": True}
532
+ )
533
+ if response.status_code not in (200, 404):
534
+ logger.warning(
535
+ f"Failed to lock flow {flow_id}: HTTP {response.status_code} - {response.text}"
536
+ )
537
+ except Exception as e:
538
+ logger.warning(f"Error locking flow {flow_id}: {e}")
539
+
540
+ async def _reset_langflow_flow_locked(self, flow_type: str, flow_id: str):
541
+ if not LANGFLOW_URL:
542
+ raise ValueError("LANGFLOW_URL environment variable is required")
466
543
 
467
544
  # Dynamically find the flow file by ID
468
545
  flow_path = self._find_flow_file_by_id(flow_id)
@@ -481,6 +558,8 @@ class FlowsService:
481
558
  except FileNotFoundError as e:
482
559
  raise ValueError(f"Flow file not found: {flow_path}") from e
483
560
 
561
+ await self._unlock_flow(flow_id)
562
+
484
563
  # Make PATCH request to Langflow API to update the flow using shared client
485
564
  try:
486
565
  response = await clients.langflow_request(
@@ -488,73 +567,12 @@ class FlowsService:
488
567
  )
489
568
 
490
569
  if response.status_code == 200:
570
+ await self._lock_flow(flow_id)
491
571
  logger.info(
492
572
  f"Successfully reset {flow_type} flow",
493
573
  flow_id=flow_id,
494
574
  flow_file=os.path.basename(flow_path),
495
575
  )
496
-
497
- # Now update the flow with current configuration settings
498
- try:
499
- config = get_openrag_config()
500
-
501
- # Check if configuration has been edited (onboarding completed)
502
- if config.edited:
503
- logger.info(
504
- f"Updating {flow_type} flow with current configuration settings"
505
- )
506
-
507
- # Update current LLM provider
508
- update_result = await self._update_provider_components(
509
- {"name": flow_type, "flow_id": flow_id},
510
- config.agent.llm_provider.lower(),
511
- llm_model=config.agent.llm_model,
512
- force_llm_update=True,
513
- )
514
-
515
- # Update all configured embedding providers
516
- embedding_providers = []
517
- if config.providers.openai.configured:
518
- embedding_providers.append("openai")
519
- if config.providers.watsonx.configured:
520
- embedding_providers.append("watsonx")
521
- if config.providers.ollama.configured:
522
- embedding_providers.append("ollama")
523
-
524
- current_emb_provider = config.knowledge.embedding_provider.lower()
525
- for provider in embedding_providers:
526
- model = (
527
- config.knowledge.embedding_model
528
- if provider == current_emb_provider
529
- else None
530
- )
531
- await self._update_provider_components(
532
- {"name": flow_type, "flow_id": flow_id},
533
- provider,
534
- embedding_model=model,
535
- force_embedding_update=True,
536
- )
537
-
538
- if update_result.get("success"):
539
- logger.info(
540
- f"Successfully updated {flow_type} flow with current configuration"
541
- )
542
- else:
543
- logger.warning(
544
- f"Failed to update {flow_type} flow with current configuration: {update_result.get('error', 'Unknown error')}"
545
- )
546
- else:
547
- logger.info(
548
- f"Configuration not yet edited (onboarding not completed), skipping model updates for {flow_type} flow"
549
- )
550
-
551
- except Exception as e:
552
- logger.error(
553
- f"Error updating {flow_type} flow with current configuration",
554
- error=str(e),
555
- )
556
- # Don't fail the entire reset operation if configuration update fails
557
-
558
576
  return {
559
577
  "success": True,
560
578
  "message": f"Successfully reset {flow_type} flow",
@@ -626,7 +644,7 @@ class FlowsService:
626
644
  return "OpenAI"
627
645
 
628
646
  async def _update_flow_field(
629
- self, flow_id: str, field_name: str, field_value: Any, node_display_name: str = None
647
+ self, flow_id: str, field_name: str, field_value: Any, node_display_name: str | None = None
630
648
  ):
631
649
  """
632
650
  Generic helper function to update any field in any Langflow component.
@@ -683,6 +701,8 @@ class FlowsService:
683
701
  identifier = node_display_name
684
702
  raise Exception(f"{field_name} field not found in {identifier} component")
685
703
 
704
+ await self._unlock_flow(flow_id)
705
+
686
706
  # Update the flow via PATCH request
687
707
  patch_response = await clients.langflow_request(
688
708
  "PATCH", f"/api/v1/flows/{flow_id}", json=flow_data
@@ -692,6 +712,7 @@ class FlowsService:
692
712
  raise Exception(
693
713
  f"Failed to update flow: HTTP {patch_response.status_code} - {patch_response.text}"
694
714
  )
715
+ await self._lock_flow(flow_id)
695
716
 
696
717
  async def update_chat_flow_model(self, model_name: str, provider: str):
697
718
  """Helper function to update the model in the chat flow"""
@@ -774,20 +795,34 @@ class FlowsService:
774
795
  return True
775
796
  return False
776
797
 
777
- def _normalize_flow_structure(self, flow_data):
798
+ def _normalize_flow_structure(self, flow_data: dict[str, Any]) -> dict[str, Any]:
778
799
  """
779
800
  Normalize flow structure for comparison by removing dynamic fields.
780
- Keeps structural elements: nodes (types, display names, templates), edges (connections).
781
- Removes: IDs, timestamps, positions, etc. but keeps template structure.
801
+ Keeps structural elements: nodes (types, display names, template definitions), edges (connections).
802
+ Removes: IDs, timestamps, positions, dynamic runtime field values, and dynamic options lists.
782
803
  """
783
- normalized = {"data": {"nodes": [], "edges": []}}
804
+ normalized: dict[str, Any] = {"data": {"nodes": [], "edges": []}}
784
805
 
785
- # Normalize nodes - keep structural info including templates
806
+ # Normalize nodes - keep structural info including template definitions
786
807
  nodes = flow_data.get("data", {}).get("nodes", [])
787
808
  for node in nodes:
788
809
  node_data = node.get("data", {})
789
810
  node_template = node_data.get("node", {})
790
811
 
812
+ template = node_template.get("template", {})
813
+ normalized_template: dict[str, Any] = {}
814
+ if isinstance(template, dict):
815
+ for field_name, field_def in template.items():
816
+ if isinstance(field_def, dict):
817
+ normalized_template[field_name] = {
818
+ "type": field_def.get("type"),
819
+ "display_name": field_def.get("display_name"),
820
+ "required": field_def.get("required"),
821
+ "input_types": field_def.get("input_types"),
822
+ }
823
+ else:
824
+ normalized_template[field_name] = {"value": str(field_def)}
825
+
791
826
  normalized_node = {
792
827
  "id": node.get("id"), # Keep ID for edge matching
793
828
  "type": node.get("type"),
@@ -796,7 +831,7 @@ class FlowsService:
796
831
  "display_name": node_template.get("display_name"),
797
832
  "name": node_template.get("name"),
798
833
  "base_classes": node_template.get("base_classes", []),
799
- "template": node_template.get("template", {}), # Include template structure
834
+ "template": normalized_template,
800
835
  }
801
836
  },
802
837
  }
@@ -815,70 +850,51 @@ class FlowsService:
815
850
 
816
851
  return normalized
817
852
 
818
- async def _compare_flow_with_file(self, flow_id: str):
853
+ async def _check_flow_update(
854
+ self, flow_type: str, flow_id: str, flow_data: dict[str, Any] | None = None
855
+ ) -> dict[str, Any] | None:
819
856
  """
820
- Compare a Langflow flow with its JSON file.
821
- Returns True if flows match (indicating a reset), False otherwise.
857
+ Check if a flow has a newer version on disk compared to Langflow.
858
+ Returns flow update info dict if an update is available, None otherwise.
822
859
  """
823
- try:
824
- # Get flow from Langflow API
825
- response = await clients.langflow_request("GET", f"/api/v1/flows/{flow_id}")
826
- if response.status_code != 200:
827
- logger.warning(
828
- f"Failed to get flow {flow_id} from Langflow: HTTP {response.status_code}"
829
- )
830
- return False
831
-
832
- langflow_flow = response.json()
833
-
834
- # Find and load the corresponding JSON file
835
- flow_path = self._find_flow_file_by_id(flow_id)
836
- if not flow_path:
837
- logger.warning(f"Flow file not found for flow ID: {flow_id}")
838
- return False
839
-
840
- with open(flow_path) as f:
841
- file_flow = json.load(f)
842
-
843
- # Normalize both flows for comparison
844
- normalized_langflow = self._normalize_flow_structure(langflow_flow)
845
- normalized_file = self._normalize_flow_structure(file_flow)
846
-
847
- # Compare entire normalized structures exactly
848
- # Sort nodes and edges for consistent comparison
849
- normalized_langflow["data"]["nodes"] = sorted(
850
- normalized_langflow["data"]["nodes"],
851
- key=lambda x: (x.get("id", ""), x.get("type", "")),
852
- )
853
- normalized_file["data"]["nodes"] = sorted(
854
- normalized_file["data"]["nodes"], key=lambda x: (x.get("id", ""), x.get("type", ""))
855
- )
856
-
857
- normalized_langflow["data"]["edges"] = sorted(
858
- normalized_langflow["data"]["edges"],
859
- key=lambda x: (
860
- x.get("source", ""),
861
- x.get("target", ""),
862
- x.get("sourceHandle", ""),
863
- x.get("targetHandle", ""),
864
- ),
865
- )
866
- normalized_file["data"]["edges"] = sorted(
867
- normalized_file["data"]["edges"],
868
- key=lambda x: (
869
- x.get("source", ""),
870
- x.get("target", ""),
871
- x.get("sourceHandle", ""),
872
- x.get("targetHandle", ""),
873
- ),
874
- )
860
+ if not flow_id:
861
+ return None
862
+ flow_path = self._find_flow_file_by_id(flow_id)
863
+ if not flow_path or not os.path.exists(flow_path):
864
+ return None
865
+ local_mtime = os.path.getmtime(flow_path)
875
866
 
876
- # Compare entire normalized structures
877
- return normalized_langflow == normalized_file
867
+ if flow_data is None:
868
+ try:
869
+ response = await clients.langflow_request("GET", f"/api/v1/flows/{flow_id}")
870
+ if response.status_code != 200:
871
+ return None
872
+ flow_data = response.json()
873
+ except Exception as e:
874
+ logger.error(f"Failed to fetch flow {flow_id} for update check: {e}")
875
+ return None
878
876
 
879
- except Exception as e:
880
- logger.error(f"Error comparing flow {flow_id} with file: {str(e)}")
881
- return False
877
+ langflow_updated_at_str = flow_data.get("updated_at")
878
+ langflow_mtime: float = 0.0
879
+ if langflow_updated_at_str:
880
+ if langflow_updated_at_str.endswith("Z"):
881
+ langflow_updated_at_str = langflow_updated_at_str[:-1] + "+00:00"
882
+ try:
883
+ dt = datetime.fromisoformat(langflow_updated_at_str)
884
+ if dt.tzinfo is None:
885
+ dt = dt.replace(tzinfo=UTC)
886
+ langflow_mtime = dt.timestamp()
887
+ except ValueError:
888
+ pass
889
+
890
+ if local_mtime > langflow_mtime + 1.0:
891
+ is_locked = flow_data.get("locked", False)
892
+ return {
893
+ "flow_type": flow_type,
894
+ "flow_id": flow_id,
895
+ "is_custom": not is_locked,
896
+ }
897
+ return None
882
898
 
883
899
  async def ensure_flows_exist(self) -> set[str]:
884
900
  """
@@ -889,9 +905,6 @@ class FlowsService:
889
905
  patches or overwrites an existing flow, preserving any edits the user
890
906
  has made in the Langflow UI.
891
907
 
892
- This replaces the LANGFLOW_LOAD_FLOWS_PATH mechanism, which performed a
893
- blind upsert on every container start and discarded user edits.
894
-
895
908
  Returns the set of flow type names that were actually created.
896
909
  """
897
910
  flow_configs = [
@@ -951,47 +964,31 @@ class FlowsService:
951
964
  tasks = [ensure_single_flow_exists(ft, fi) for ft, fi in flow_configs]
952
965
  results = await asyncio.gather(*tasks)
953
966
  created_flow_types = {r for r in results if r is not None}
954
- return created_flow_types
955
967
 
956
- async def check_flows_reset(self):
957
- """
958
- Check if any flows have been reset by comparing with JSON files.
959
- Returns list of flow types that were reset.
960
- """
961
- reset_flows = []
962
-
963
- flow_configs = [
964
- ("nudges", NUDGES_FLOW_ID),
965
- ("retrieval", LANGFLOW_CHAT_FLOW_ID),
966
- ("ingest", LANGFLOW_INGEST_FLOW_ID),
967
- ("url_ingest", LANGFLOW_URL_INGEST_FLOW_ID),
968
- ]
968
+ if created_flow_types:
969
+ try:
970
+ config = get_openrag_config()
971
+ if config.edited:
972
+ logger.info(
973
+ f"Newly created flows detected ({', '.join(created_flow_types)}), reapplying settings."
974
+ )
975
+ from api.settings import reapply_all_settings
969
976
 
970
- async def check_single_flow_reset(flow_type, flow_id):
971
- if not flow_id:
972
- return None
973
- logger.info(f"Checking if {flow_type} flow (ID: {flow_id}) was reset")
974
- is_reset = await self._compare_flow_with_file(flow_id)
975
- if is_reset:
976
- logger.info(f"Flow {flow_type} (ID: {flow_id}) appears to have been reset")
977
- return flow_type
978
- else:
979
- logger.info(f"Flow {flow_type} (ID: {flow_id}) does not match reset state")
980
- return None
977
+ await reapply_all_settings()
978
+ logger.info("Successfully reapplied settings for newly created flows")
979
+ except Exception as e:
980
+ logger.error(f"Failed to reapply settings for newly created flows: {e}")
981
981
 
982
- tasks = [check_single_flow_reset(ft, fi) for ft, fi in flow_configs]
983
- results = await asyncio.gather(*tasks)
984
- reset_flows = [r for r in results if r is not None]
985
- return reset_flows
982
+ return created_flow_types
986
983
 
987
984
  async def change_langflow_model_value(
988
985
  self,
989
986
  provider: str,
990
- embedding_model: str = None,
991
- llm_model: str = None,
987
+ embedding_model: str | None = None,
988
+ llm_model: str | None = None,
992
989
  force_embedding_update: bool = False,
993
990
  force_llm_update: bool = False,
994
- flow_configs: list = None,
991
+ flow_configs: list | None = None,
995
992
  ):
996
993
  """
997
994
  Change dropdown values for provider-specific components across flows
@@ -1052,35 +1049,55 @@ class FlowsService:
1052
1049
 
1053
1050
  async def _update_provider_components(
1054
1051
  self,
1055
- config,
1056
- provider: str,
1057
- embedding_model: str = None,
1058
- llm_model: str = None,
1052
+ config: dict,
1053
+ provider_name: str,
1054
+ embedding_model: str | None = None,
1055
+ llm_model: str | None = None,
1059
1056
  force_embedding_update: bool = False,
1060
1057
  force_llm_update: bool = False,
1061
1058
  ):
1062
1059
  """Update provider components and their dropdown values in a flow"""
1063
- flow_name = config["name"]
1064
1060
  flow_id = config["flow_id"]
1065
- # Use a flow-specific lock to prevent race conditions during the GET-Modify-PATCH cycle
1066
1061
  flow_lock = await self._get_flow_lock(flow_id)
1067
1062
  async with flow_lock:
1068
- # Get flow data from Langflow API instead of file
1069
- response = await clients.langflow_request("GET", f"/api/v1/flows/{flow_id}")
1063
+ return await self._update_provider_components_locked(
1064
+ config,
1065
+ provider_name,
1066
+ embedding_model=embedding_model,
1067
+ llm_model=llm_model,
1068
+ force_embedding_update=force_embedding_update,
1069
+ force_llm_update=force_llm_update,
1070
+ )
1070
1071
 
1071
- if response.status_code != 200:
1072
- raise Exception(
1073
- f"Failed to get flow from Langflow: HTTP {response.status_code} - {response.text}"
1074
- )
1072
+ async def _update_provider_components_locked(
1073
+ self,
1074
+ config: dict,
1075
+ provider_name: str,
1076
+ embedding_model: str | None = None,
1077
+ llm_model: str | None = None,
1078
+ force_embedding_update: bool = False,
1079
+ force_llm_update: bool = False,
1080
+ ):
1081
+ """Internal helper to update provider components when flow_lock is already held"""
1082
+ flow_name = config["name"]
1083
+ flow_id = config["flow_id"]
1084
+ # Get flow data from Langflow API instead of file
1085
+ response = await clients.langflow_request("GET", f"/api/v1/flows/{flow_id}")
1075
1086
 
1076
- flow_data = response.json()
1077
- updates_made = []
1078
- node_tasks = []
1087
+ if response.status_code != 200:
1088
+ raise Exception(
1089
+ f"Failed to get flow from Langflow: HTTP {response.status_code} - {response.text}"
1090
+ )
1079
1091
 
1080
- async def wrap_node_update(node, p, m, label):
1081
- if await self._update_component_fields(node, p, m):
1082
- return label
1083
- return None
1092
+ flow_data = response.json()
1093
+ provider = provider_name
1094
+ updates_made = []
1095
+ node_tasks: list[Any] = []
1096
+
1097
+ async def wrap_node_update(node, p, m, label):
1098
+ if await self._update_component_fields(node, p, m):
1099
+ return label
1100
+ return None
1084
1101
 
1085
1102
  # Update embedding component
1086
1103
  if not get_openrag_config().knowledge.disable_ingest_with_langflow and (
@@ -1205,39 +1222,41 @@ class FlowsService:
1205
1222
  )
1206
1223
  )
1207
1224
 
1208
- # Execute all node updates simultaneously
1209
- if node_tasks:
1210
- node_results = await asyncio.gather(*node_tasks)
1211
- updates_made.extend([r for r in node_results if r])
1212
-
1213
- # If no updates were made, return skip message
1214
- if not updates_made:
1215
- return {
1216
- "flow": flow_name,
1217
- "success": True,
1218
- "message": f"No compatible components found in {flow_name} flow (skipped)",
1219
- "flow_id": flow_id,
1220
- }
1221
-
1222
- logger.info(f"Updated {', '.join(updates_made)} in {flow_name} flow")
1223
-
1224
- # PATCH the updated flow
1225
- response = await clients.langflow_request(
1226
- "PATCH", f"/api/v1/flows/{flow_id}", json=flow_data
1227
- )
1228
-
1229
- if response.status_code != 200:
1230
- raise Exception(
1231
- f"Failed to update flow: HTTP {response.status_code} - {response.text}"
1232
- )
1225
+ # Execute all node updates simultaneously
1226
+ if node_tasks:
1227
+ node_results = await asyncio.gather(*node_tasks)
1228
+ updates_made.extend([r for r in node_results if r])
1233
1229
 
1230
+ # If no updates were made, return skip message
1231
+ if not updates_made:
1234
1232
  return {
1235
1233
  "flow": flow_name,
1236
1234
  "success": True,
1237
- "message": f"Successfully updated {', '.join(updates_made)}",
1235
+ "message": f"No compatible components found in {flow_name} flow (skipped)",
1238
1236
  "flow_id": flow_id,
1239
1237
  }
1240
1238
 
1239
+ logger.info(f"Updated {', '.join(updates_made)} in {flow_name} flow")
1240
+
1241
+ await self._unlock_flow(flow_id)
1242
+
1243
+ # PATCH the updated flow
1244
+ response = await clients.langflow_request(
1245
+ "PATCH", f"/api/v1/flows/{flow_id}", json=flow_data
1246
+ )
1247
+
1248
+ if response.status_code != 200:
1249
+ raise Exception(f"Failed to update flow: HTTP {response.status_code} - {response.text}")
1250
+
1251
+ await self._lock_flow(flow_id)
1252
+
1253
+ return {
1254
+ "flow": flow_name,
1255
+ "success": True,
1256
+ "message": f"Successfully updated {', '.join(updates_made)}",
1257
+ "flow_id": flow_id,
1258
+ }
1259
+
1241
1260
  async def _update_component_langflow(self, template, model: Any):
1242
1261
  # Call custom_component/update endpoint to get updated template
1243
1262
  # Only call if code field exists (custom components should have code)
@@ -1445,3 +1464,174 @@ class FlowsService:
1445
1464
  elif provider == "anthropic":
1446
1465
  return (None, "Anthropic")
1447
1466
  return (None, None)
1467
+
1468
+ async def get_flows_updates_available(self, user_id: str | None = None) -> list[dict[str, Any]]:
1469
+ """
1470
+ Check for available flow updates.
1471
+ Uses cached update results from startup/previous checks if available.
1472
+ Surfaces all available updates regardless of locked status.
1473
+ """
1474
+ target_user = user_id or "default"
1475
+ if not hasattr(self, "_custom_updates_cache") or self._custom_updates_cache is None:
1476
+ flow_configs = [
1477
+ ("nudges", NUDGES_FLOW_ID),
1478
+ ("retrieval", LANGFLOW_CHAT_FLOW_ID),
1479
+ ("ingest", LANGFLOW_INGEST_FLOW_ID),
1480
+ ("url_ingest", LANGFLOW_URL_INGEST_FLOW_ID),
1481
+ ]
1482
+ cache = []
1483
+ for flow_type, flow_id in flow_configs:
1484
+ update_info = await self._check_flow_update(flow_type, flow_id)
1485
+ if update_info:
1486
+ cache.append(update_info)
1487
+ self._custom_updates_cache = cache
1488
+
1489
+ updates = []
1490
+ for item in self._custom_updates_cache:
1491
+ is_dismissed = False
1492
+ if hasattr(self, "_dismissed_updates") and isinstance(self._dismissed_updates, dict):
1493
+ is_dismissed = item["flow_type"] in self._dismissed_updates.get(target_user, set())
1494
+ updates.append(
1495
+ {
1496
+ **item,
1497
+ "dismissed": is_dismissed,
1498
+ }
1499
+ )
1500
+
1501
+ return updates
1502
+
1503
+ async def bulk_update_flows(self, flow_types: list[str], backup_custom: bool = True):
1504
+ """
1505
+ Bulk updates multiple flows.
1506
+ If backup_custom is True, flows are backed up in Langflow before resetting.
1507
+ Local file backups are always saved via _backup_flow prior to resetting.
1508
+ Returns the backup flow details if any.
1509
+ """
1510
+ results: list[dict[str, Any]] = []
1511
+ flow_configs_dict = {
1512
+ "nudges": NUDGES_FLOW_ID,
1513
+ "retrieval": LANGFLOW_CHAT_FLOW_ID,
1514
+ "ingest": LANGFLOW_INGEST_FLOW_ID,
1515
+ "url_ingest": LANGFLOW_URL_INGEST_FLOW_ID,
1516
+ }
1517
+
1518
+ for flow_type in flow_types:
1519
+ flow_id = flow_configs_dict.get(flow_type)
1520
+ if not flow_id:
1521
+ results.append(
1522
+ {"flow_type": flow_type, "success": False, "error": "Invalid flow_type"}
1523
+ )
1524
+ continue
1525
+
1526
+ flow_lock = await self._get_flow_lock(flow_id)
1527
+ async with flow_lock:
1528
+ backup_path = None
1529
+ backup_flow_id = None
1530
+ flow_check_ok = False
1531
+
1532
+ try:
1533
+ response = await clients.langflow_request("GET", f"/api/v1/flows/{flow_id}")
1534
+ if response.status_code == 200:
1535
+ flow_check_ok = True
1536
+ flow_data = response.json()
1537
+
1538
+ # Always attempt local file backup for safety
1539
+ backup_path = await self._backup_flow(flow_id, flow_type, flow_data)
1540
+
1541
+ if backup_custom:
1542
+ logger.info(f"Backing up flow {flow_type} in Langflow before update.")
1543
+ # Create a backup flow directly in Langflow
1544
+ backup_payload = dict(flow_data)
1545
+ backup_payload.pop("id", None)
1546
+ flow_name = flow_data.get("name", flow_type.capitalize())
1547
+ timestamp = datetime.now().strftime("%Y-%m-%d %H:%M")
1548
+ backup_payload["name"] = f"Backup - {flow_name} ({timestamp})"
1549
+ backup_payload["description"] = (
1550
+ f"Backup of flow '{flow_name}' created before flow update on {timestamp}. "
1551
+ "Use this flow to reference or redo your modifications."
1552
+ )
1553
+ backup_payload["locked"] = False
1554
+
1555
+ lf_create_res = await clients.langflow_request(
1556
+ "POST", "/api/v1/flows/", json=backup_payload
1557
+ )
1558
+ if lf_create_res.status_code in (200, 201):
1559
+ created_flow = lf_create_res.json()
1560
+ backup_flow_id = created_flow.get("id")
1561
+ logger.info(
1562
+ f"Created backup flow in Langflow for {flow_type}: '{backup_payload['name']}' (ID: {backup_flow_id})"
1563
+ )
1564
+ else:
1565
+ logger.warning(
1566
+ f"Failed to create backup flow in Langflow for {flow_type}: HTTP {lf_create_res.status_code}"
1567
+ )
1568
+ except Exception as e:
1569
+ logger.error(f"Failed to check/backup flow {flow_id} before update: {e}")
1570
+
1571
+ if not flow_check_ok:
1572
+ logger.error(
1573
+ f"Pre-flight check failed for flow {flow_type}; aborting update to protect flow modifications."
1574
+ )
1575
+ results.append(
1576
+ {
1577
+ "flow_type": flow_type,
1578
+ "success": False,
1579
+ "error": "Pre-flight check failed; update aborted to avoid losing changes",
1580
+ "backup_path": None,
1581
+ "backup_flow_id": None,
1582
+ }
1583
+ )
1584
+ continue
1585
+
1586
+ # If backup_custom is enabled, abort update if BOTH backups failed
1587
+ if backup_custom and backup_path is None and backup_flow_id is None:
1588
+ logger.error(
1589
+ f"Backup failed for flow {flow_type}; aborting update to protect flow modifications."
1590
+ )
1591
+ results.append(
1592
+ {
1593
+ "flow_type": flow_type,
1594
+ "success": False,
1595
+ "error": "Backup failed; update aborted to avoid losing changes",
1596
+ "backup_path": None,
1597
+ "backup_flow_id": None,
1598
+ }
1599
+ )
1600
+ continue
1601
+
1602
+ try:
1603
+ reset_res = await self._reset_langflow_flow_locked(flow_type, flow_id)
1604
+ if reset_res.get("success"):
1605
+ self.clear_dismissed_update(flow_type)
1606
+ results.append(
1607
+ {
1608
+ "flow_type": flow_type,
1609
+ "success": reset_res.get("success", False),
1610
+ "error": reset_res.get("error"),
1611
+ "backup_path": backup_path,
1612
+ "backup_flow_id": backup_flow_id,
1613
+ }
1614
+ )
1615
+ except Exception as e:
1616
+ results.append(
1617
+ {
1618
+ "flow_type": flow_type,
1619
+ "success": False,
1620
+ "error": str(e),
1621
+ "backup_path": backup_path,
1622
+ "backup_flow_id": backup_flow_id,
1623
+ }
1624
+ )
1625
+
1626
+ if results and any(r.get("success") for r in results):
1627
+ self._custom_updates_cache = None
1628
+ try:
1629
+ config = get_openrag_config()
1630
+ if config.edited:
1631
+ from api.settings import reapply_all_settings
1632
+
1633
+ await reapply_all_settings()
1634
+ except Exception as e:
1635
+ logger.error(f"Error reapplying settings after bulk update: {e}")
1636
+
1637
+ return results
@@ -170,13 +170,11 @@ async def startup_tasks(services):
170
170
  # Update MCP server URLs (patch localhost and convert to streamable HTTP)
171
171
  await _update_mcp_server_urls(services["langflow_mcp_service"])
172
172
 
173
- # Ensure all configured flows exist in Langflow (create-only, never overwrites).
174
- # This replaces LANGFLOW_LOAD_FLOWS_PATH, which performed a blind upsert on
175
- # every container start and discarded any user edits made in the Langflow UI.
176
- newly_created: set[str] = set()
173
+ # Ensure all configured flows exist in Langflow (create-only, never overwrites existing).
174
+ # If a flow does not exist, it is created and active configuration settings are reapplied.
177
175
  try:
178
176
  flows_service = services["flows_service"]
179
- newly_created = await flows_service.ensure_flows_exist()
177
+ await flows_service.ensure_flows_exist()
180
178
  except Exception as e:
181
179
  logger.error(
182
180
  "Failed to ensure Langflow flows exist at startup — "
@@ -184,38 +182,4 @@ async def startup_tasks(services):
184
182
  error=str(e),
185
183
  )
186
184
 
187
- # Check if flows were reset and reapply settings if config is edited
188
- try:
189
- config = get_openrag_config()
190
- if config.edited:
191
- logger.info("Checking if Langflow flows were reset")
192
- flows_service = services["flows_service"]
193
- reset_flows = await flows_service.check_flows_reset()
194
- # Exclude flows that were just seeded — they match the JSON by design,
195
- # not because they were externally reset.
196
- reset_flows = [f for f in reset_flows if f not in newly_created]
197
-
198
- if reset_flows:
199
- logger.info(
200
- f"Detected reset flows: {', '.join(reset_flows)}. Reapplying all settings."
201
- )
202
- await TelemetryClient.send_event(
203
- Category.FLOW_OPERATIONS, MessageId.ORB_FLOW_RESET_DETECTED
204
- )
205
- from api.settings import reapply_all_settings
206
185
 
207
- await reapply_all_settings(session_manager=services["session_manager"])
208
- logger.info("Successfully reapplied settings after detecting flow resets")
209
- await TelemetryClient.send_event(
210
- Category.FLOW_OPERATIONS, MessageId.ORB_FLOW_SETTINGS_REAPPLIED
211
- )
212
- else:
213
- logger.info("No flows detected as reset, skipping settings reapplication")
214
- else:
215
- logger.debug("Configuration not yet edited, skipping flow reset check")
216
- except Exception as e:
217
- logger.error(f"Failed to check flows reset or reapply settings: {str(e)}")
218
- await TelemetryClient.send_event(
219
- Category.FLOW_OPERATIONS, MessageId.ORB_FLOW_RESET_CHECK_FAIL
220
- )
221
- # Don't fail startup if this check fails