OpenRAG 0.5.0.dev35__py3-none-any.whl → 0.5.0.dev37__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/connectors.py +809 -114
- api/documents.py +74 -18
- api/langflow_files.py +23 -20
- config/settings.py +1 -0
- connectors/google_drive/connector.py +2 -7
- connectors/onedrive/connector.py +43 -9
- connectors/service.py +174 -87
- connectors/sharepoint/connector.py +326 -232
- dependencies.py +22 -14
- main.py +11 -14
- models/processors.py +389 -296
- models/tasks.py +10 -1
- {openrag-0.5.0.dev35.dist-info → openrag-0.5.0.dev37.dist-info}/METADATA +1 -1
- {openrag-0.5.0.dev35.dist-info → openrag-0.5.0.dev37.dist-info}/RECORD +23 -24
- services/langflow_file_service.py +17 -9
- services/task_service.py +17 -7
- utils/file_utils.py +20 -1
- utils/opensearch_delete.py +105 -0
- utils/opensearch_utils.py +15 -0
- api/connector_router.py +0 -82
- connectors/langflow_connector_service.py +0 -420
- {openrag-0.5.0.dev35.dist-info → openrag-0.5.0.dev37.dist-info}/WHEEL +0 -0
- {openrag-0.5.0.dev35.dist-info → openrag-0.5.0.dev37.dist-info}/entry_points.txt +0 -0
- {openrag-0.5.0.dev35.dist-info → openrag-0.5.0.dev37.dist-info}/licenses/LICENSE +0 -0
- {openrag-0.5.0.dev35.dist-info → openrag-0.5.0.dev37.dist-info}/top_level.txt +0 -0
api/connectors.py
CHANGED
|
@@ -1,100 +1,498 @@
|
|
|
1
|
-
from typing import Any
|
|
1
|
+
from typing import Any
|
|
2
2
|
|
|
3
3
|
from fastapi import Depends, Request
|
|
4
|
-
from pydantic import BaseModel
|
|
5
4
|
from fastapi.responses import JSONResponse, PlainTextResponse
|
|
6
|
-
from
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
7
|
from config.settings import get_index_name
|
|
8
|
-
from utils
|
|
9
|
-
from
|
|
10
|
-
|
|
8
|
+
from connectors.sharepoint.utils import is_valid_sharepoint_url
|
|
9
|
+
from dependencies import (
|
|
10
|
+
get_connector_service,
|
|
11
|
+
get_current_user,
|
|
12
|
+
get_session_manager,
|
|
13
|
+
require_permission,
|
|
14
|
+
)
|
|
11
15
|
from session_manager import User
|
|
16
|
+
from utils.logging_config import get_logger
|
|
17
|
+
from utils.telemetry import Category, MessageId, TelemetryClient
|
|
12
18
|
|
|
13
19
|
logger = get_logger(__name__)
|
|
14
20
|
|
|
15
21
|
|
|
22
|
+
def _connector_sync_should_replace(connector_type: str) -> bool:
|
|
23
|
+
"""Return True for connector types where sync should replace existing indexed files."""
|
|
24
|
+
return connector_type in ["google_drive", "sharepoint", "onedrive"]
|
|
25
|
+
|
|
26
|
+
|
|
16
27
|
async def get_synced_file_ids_for_connector(
|
|
17
28
|
connector_type: str,
|
|
18
29
|
user_id: str,
|
|
19
30
|
session_manager,
|
|
20
31
|
jwt_token: str = None,
|
|
21
|
-
) -> tuple:
|
|
32
|
+
) -> tuple[list[str], list[str], str]:
|
|
22
33
|
"""
|
|
23
|
-
Query OpenSearch for unique
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
34
|
+
Query OpenSearch for unique file IDs where connector_type matches.
|
|
35
|
+
|
|
36
|
+
Returns a 3-tuple ``(file_ids, filenames, id_field)``:
|
|
37
|
+
|
|
38
|
+
- ``file_ids``: connector source IDs to use for orphan detection and sync.
|
|
39
|
+
Comes from the ``connector_file_id`` field when chunks were indexed via
|
|
40
|
+
``ConnectorFileProcessor`` (non-Langflow path); falls back to ``document_id``
|
|
41
|
+
for Langflow-indexed chunks where ``document_id`` already holds the connector
|
|
42
|
+
source ID.
|
|
43
|
+
- ``filenames``: unique filenames as a fallback when ``file_ids`` is empty.
|
|
44
|
+
- ``id_field``: the OpenSearch field name that ``file_ids`` came from
|
|
45
|
+
(``"connector_file_id"`` or ``"document_id"``). Callers must pass this to
|
|
46
|
+
``delete_orphan_documents`` so deletions target the correct field.
|
|
28
47
|
"""
|
|
29
48
|
try:
|
|
30
49
|
opensearch_client = session_manager.get_user_opensearch_client(user_id, jwt_token)
|
|
31
|
-
|
|
32
|
-
# Query for both document_id and filename aggregations
|
|
50
|
+
|
|
33
51
|
query_body = {
|
|
34
52
|
"size": 0,
|
|
35
|
-
"query": {
|
|
36
|
-
"term": {
|
|
37
|
-
"connector_type": connector_type
|
|
38
|
-
}
|
|
39
|
-
},
|
|
53
|
+
"query": {"term": {"connector_type": connector_type}},
|
|
40
54
|
"aggs": {
|
|
41
|
-
"
|
|
42
|
-
"terms": {
|
|
43
|
-
"field": "document_id",
|
|
44
|
-
"size": 10000
|
|
45
|
-
}
|
|
55
|
+
"unique_connector_file_ids": {
|
|
56
|
+
"terms": {"field": "connector_file_id", "size": 10000}
|
|
46
57
|
},
|
|
47
|
-
"
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
"size": 10000
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
}
|
|
58
|
+
"unique_document_ids": {"terms": {"field": "document_id", "size": 10000}},
|
|
59
|
+
"unique_filenames": {"terms": {"field": "filename", "size": 10000}},
|
|
60
|
+
},
|
|
54
61
|
}
|
|
55
|
-
|
|
56
|
-
result = await opensearch_client.search(
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
)
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
62
|
+
|
|
63
|
+
result = await opensearch_client.search(index=get_index_name(), body=query_body)
|
|
64
|
+
|
|
65
|
+
# Prefer connector_file_id — these are set by ConnectorFileProcessor (non-Langflow)
|
|
66
|
+
# and hold the actual connector source IDs (e.g. SharePoint GUIDs), not SHA hashes.
|
|
67
|
+
connector_file_id_buckets = (
|
|
68
|
+
result.get("aggregations", {}).get("unique_connector_file_ids", {}).get("buckets", [])
|
|
69
|
+
)
|
|
70
|
+
connector_file_ids = [b["key"] for b in connector_file_id_buckets if b["key"]]
|
|
71
|
+
|
|
72
|
+
if connector_file_ids:
|
|
73
|
+
file_ids = connector_file_ids
|
|
74
|
+
id_field = "connector_file_id"
|
|
75
|
+
else:
|
|
76
|
+
# Langflow path: document_id already holds the connector source ID.
|
|
77
|
+
doc_id_buckets = (
|
|
78
|
+
result.get("aggregations", {}).get("unique_document_ids", {}).get("buckets", [])
|
|
79
|
+
)
|
|
80
|
+
file_ids = [b["key"] for b in doc_id_buckets if b["key"]]
|
|
81
|
+
id_field = "document_id"
|
|
82
|
+
|
|
83
|
+
filename_buckets = (
|
|
84
|
+
result.get("aggregations", {}).get("unique_filenames", {}).get("buckets", [])
|
|
85
|
+
)
|
|
86
|
+
filenames = [b["key"] for b in filename_buckets if b["key"]]
|
|
87
|
+
|
|
69
88
|
logger.debug(
|
|
70
89
|
"Found synced files for connector",
|
|
71
90
|
connector_type=connector_type,
|
|
72
91
|
file_ids_count=len(file_ids),
|
|
92
|
+
id_field=id_field,
|
|
73
93
|
filenames_count=len(filenames),
|
|
74
94
|
)
|
|
75
|
-
|
|
76
|
-
return file_ids, filenames
|
|
77
|
-
|
|
95
|
+
|
|
96
|
+
return file_ids, filenames, id_field
|
|
97
|
+
|
|
78
98
|
except Exception as e:
|
|
79
99
|
logger.error(
|
|
80
100
|
"Failed to get synced file IDs",
|
|
81
101
|
connector_type=connector_type,
|
|
82
102
|
error=str(e),
|
|
83
103
|
)
|
|
84
|
-
return [], []
|
|
104
|
+
return [], [], "document_id"
|
|
85
105
|
|
|
86
106
|
|
|
107
|
+
async def get_synced_id_to_filename_map(
|
|
108
|
+
connector_type: str,
|
|
109
|
+
user_id: str,
|
|
110
|
+
session_manager,
|
|
111
|
+
jwt_token: str | None = None,
|
|
112
|
+
) -> dict[str, str]:
|
|
113
|
+
"""Return a {document_id: filename} map for files ingested under this connector_type.
|
|
114
|
+
|
|
115
|
+
Uses a sub-aggregation so each document_id is paired with its top filename in
|
|
116
|
+
a single OpenSearch round trip.
|
|
117
|
+
"""
|
|
118
|
+
try:
|
|
119
|
+
opensearch_client = session_manager.get_user_opensearch_client(user_id, jwt_token)
|
|
120
|
+
|
|
121
|
+
query_body = {
|
|
122
|
+
"size": 0,
|
|
123
|
+
"query": {"term": {"connector_type": connector_type}},
|
|
124
|
+
"aggs": {
|
|
125
|
+
"by_document_id": {
|
|
126
|
+
"terms": {"field": "document_id", "size": 10000},
|
|
127
|
+
"aggs": {
|
|
128
|
+
"top_filename": {"terms": {"field": "filename", "size": 1}},
|
|
129
|
+
},
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
result = await opensearch_client.search(index=get_index_name(), body=query_body)
|
|
135
|
+
buckets = result.get("aggregations", {}).get("by_document_id", {}).get("buckets", [])
|
|
136
|
+
|
|
137
|
+
mapping: dict[str, str] = {}
|
|
138
|
+
for bucket in buckets:
|
|
139
|
+
doc_id = bucket.get("key")
|
|
140
|
+
if not doc_id:
|
|
141
|
+
continue
|
|
142
|
+
fn_buckets = bucket.get("top_filename", {}).get("buckets", [])
|
|
143
|
+
mapping[doc_id] = fn_buckets[0]["key"] if fn_buckets else ""
|
|
144
|
+
return mapping
|
|
145
|
+
except Exception as e:
|
|
146
|
+
logger.error(
|
|
147
|
+
"Failed to build id→filename map",
|
|
148
|
+
connector_type=connector_type,
|
|
149
|
+
error=str(e),
|
|
150
|
+
)
|
|
151
|
+
return {}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
async def compute_orphans_for_connector_type(
|
|
155
|
+
connector_type: str,
|
|
156
|
+
user_id: str,
|
|
157
|
+
connector_service,
|
|
158
|
+
session_manager,
|
|
159
|
+
jwt_token: str | None,
|
|
160
|
+
existing_file_ids: list[str],
|
|
161
|
+
id_to_filename: dict[str, str] | None = None,
|
|
162
|
+
) -> list[dict[str, str]] | None:
|
|
163
|
+
"""Compute orphan documents (ingested but no longer present at the source)
|
|
164
|
+
for this connector_type without deleting them.
|
|
165
|
+
|
|
166
|
+
Returns a list of {"document_id", "filename"} dicts. Returns None when strict
|
|
167
|
+
gating aborts the pass (unauthenticated connection or listing exception) so
|
|
168
|
+
callers can distinguish "no orphans" from "could not determine safely".
|
|
169
|
+
"""
|
|
170
|
+
if not existing_file_ids:
|
|
171
|
+
return []
|
|
172
|
+
|
|
173
|
+
connections = await connector_service.connection_manager.list_connections(
|
|
174
|
+
user_id=user_id, connector_type=connector_type
|
|
175
|
+
)
|
|
176
|
+
active = [c for c in connections if c.is_active]
|
|
177
|
+
if not active:
|
|
178
|
+
logger.info(
|
|
179
|
+
"Skipping orphan compute — no active connections",
|
|
180
|
+
connector_type=connector_type,
|
|
181
|
+
)
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
remote_ids: set = set()
|
|
185
|
+
for conn in active:
|
|
186
|
+
try:
|
|
187
|
+
connector = await connector_service.get_connector(conn.connection_id)
|
|
188
|
+
if not connector or not connector.is_authenticated:
|
|
189
|
+
logger.info(
|
|
190
|
+
"Skipping orphan compute — connection unauthenticated",
|
|
191
|
+
connector_type=connector_type,
|
|
192
|
+
connection_id=conn.connection_id,
|
|
193
|
+
)
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
# Drive the per-id existence check via cfg.file_ids when the
|
|
197
|
+
# connector supports it (SharePoint / OneDrive / Google Drive).
|
|
198
|
+
# The flat default of list_files() only returns the *root* listing
|
|
199
|
+
# (e.g. /drive/root/children for SharePoint, files-only, no folder
|
|
200
|
+
# traversal), so any folder-internal file in OpenSearch would be
|
|
201
|
+
# absent from remote_ids and wrongly flagged as an orphan.
|
|
202
|
+
# _list_selected_files iterates each id via _get_file_metadata_by_id
|
|
203
|
+
# and silently drops missing ids, so the resulting `remote_ids` is
|
|
204
|
+
# exactly "the subset of existing_file_ids that still exists at
|
|
205
|
+
# source" — which is what orphan detection actually needs.
|
|
206
|
+
cfg = getattr(connector, "cfg", None)
|
|
207
|
+
scoped_listing = cfg is not None and bool(existing_file_ids)
|
|
208
|
+
|
|
209
|
+
original_file_ids = None
|
|
210
|
+
original_folder_ids = None
|
|
211
|
+
if scoped_listing:
|
|
212
|
+
original_file_ids = getattr(cfg, "file_ids", None)
|
|
213
|
+
original_folder_ids = getattr(cfg, "folder_ids", None)
|
|
214
|
+
cfg.file_ids = list(existing_file_ids)
|
|
215
|
+
cfg.folder_ids = None
|
|
216
|
+
|
|
217
|
+
try:
|
|
218
|
+
page_token = None
|
|
219
|
+
while True:
|
|
220
|
+
page = await connector.list_files(page_token=page_token)
|
|
221
|
+
for f in page.get("files", []):
|
|
222
|
+
fid = f.get("id")
|
|
223
|
+
if fid:
|
|
224
|
+
remote_ids.add(fid)
|
|
225
|
+
page_token = page.get("nextPageToken") or page.get("next_page_token")
|
|
226
|
+
if not page_token:
|
|
227
|
+
break
|
|
228
|
+
finally:
|
|
229
|
+
if scoped_listing:
|
|
230
|
+
cfg.file_ids = original_file_ids
|
|
231
|
+
cfg.folder_ids = original_folder_ids
|
|
232
|
+
except Exception as e:
|
|
233
|
+
logger.warning(
|
|
234
|
+
"Skipping orphan compute — listing failed",
|
|
235
|
+
connector_type=connector_type,
|
|
236
|
+
connection_id=conn.connection_id,
|
|
237
|
+
error=str(e),
|
|
238
|
+
)
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
orphan_ids = [fid for fid in existing_file_ids if fid not in remote_ids]
|
|
242
|
+
if not orphan_ids:
|
|
243
|
+
return []
|
|
244
|
+
|
|
245
|
+
fn_map = id_to_filename or {}
|
|
246
|
+
return [{"document_id": fid, "filename": fn_map.get(fid, "")} for fid in orphan_ids]
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
async def delete_orphan_documents(
|
|
250
|
+
orphan_ids: list[str],
|
|
251
|
+
user_id: str,
|
|
252
|
+
session_manager,
|
|
253
|
+
jwt_token: str | None,
|
|
254
|
+
id_field: str = "document_id",
|
|
255
|
+
) -> int:
|
|
256
|
+
"""Delete OpenSearch chunks for the given orphan IDs. Returns the number of
|
|
257
|
+
chunks deleted (0 on failure).
|
|
258
|
+
|
|
259
|
+
``id_field`` must match the OpenSearch field that ``orphan_ids`` came from —
|
|
260
|
+
either ``"connector_file_id"`` (ConnectorFileProcessor / non-Langflow path)
|
|
261
|
+
or ``"document_id"`` (Langflow path, where document_id holds the connector
|
|
262
|
+
source ID). Pass the value returned as the third element of
|
|
263
|
+
``get_synced_file_ids_for_connector()``.
|
|
264
|
+
"""
|
|
265
|
+
if not orphan_ids:
|
|
266
|
+
return 0
|
|
267
|
+
from .documents import delete_chunks_by_document_ids
|
|
268
|
+
|
|
269
|
+
try:
|
|
270
|
+
opensearch_client = session_manager.get_user_opensearch_client(user_id, jwt_token)
|
|
271
|
+
return await delete_chunks_by_document_ids(
|
|
272
|
+
orphan_ids, opensearch_client, get_index_name(), field=id_field
|
|
273
|
+
)
|
|
274
|
+
except Exception as e:
|
|
275
|
+
logger.error(
|
|
276
|
+
"Orphan delete failed",
|
|
277
|
+
orphan_count=len(orphan_ids),
|
|
278
|
+
id_field=id_field,
|
|
279
|
+
error=str(e),
|
|
280
|
+
)
|
|
281
|
+
return 0
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
async def reconcile_orphans_for_connector_type(
|
|
285
|
+
connector_type: str,
|
|
286
|
+
user_id: str,
|
|
287
|
+
connector_service,
|
|
288
|
+
session_manager,
|
|
289
|
+
jwt_token: str | None,
|
|
290
|
+
existing_file_ids: list[str],
|
|
291
|
+
id_field: str = "document_id",
|
|
292
|
+
) -> list[str]:
|
|
293
|
+
"""Compute and delete orphans for a connector type. Thin wrapper around
|
|
294
|
+
compute_orphans_for_connector_type + delete_orphan_documents preserved for
|
|
295
|
+
callers that perform sync immediately after reconcile.
|
|
296
|
+
|
|
297
|
+
``id_field`` must match the OpenSearch field that ``existing_file_ids`` came
|
|
298
|
+
from. Pass the value returned as the third element of
|
|
299
|
+
``get_synced_file_ids_for_connector()``.
|
|
300
|
+
|
|
301
|
+
Returns the list of orphan file IDs that were deleted (or []).
|
|
302
|
+
"""
|
|
303
|
+
orphans = await compute_orphans_for_connector_type(
|
|
304
|
+
connector_type=connector_type,
|
|
305
|
+
user_id=user_id,
|
|
306
|
+
connector_service=connector_service,
|
|
307
|
+
session_manager=session_manager,
|
|
308
|
+
jwt_token=jwt_token,
|
|
309
|
+
existing_file_ids=existing_file_ids,
|
|
310
|
+
)
|
|
311
|
+
if not orphans:
|
|
312
|
+
return []
|
|
313
|
+
|
|
314
|
+
orphan_ids = [o["document_id"] for o in orphans]
|
|
315
|
+
deleted = await delete_orphan_documents(
|
|
316
|
+
orphan_ids=orphan_ids,
|
|
317
|
+
user_id=user_id,
|
|
318
|
+
session_manager=session_manager,
|
|
319
|
+
jwt_token=jwt_token,
|
|
320
|
+
id_field=id_field,
|
|
321
|
+
)
|
|
322
|
+
logger.info(
|
|
323
|
+
"Orphan reconcile complete",
|
|
324
|
+
connector_type=connector_type,
|
|
325
|
+
orphan_count=len(orphan_ids),
|
|
326
|
+
deleted_chunks=deleted,
|
|
327
|
+
id_field=id_field,
|
|
328
|
+
)
|
|
329
|
+
return orphan_ids
|
|
330
|
+
|
|
87
331
|
|
|
88
332
|
class ConnectorSyncBody(BaseModel):
|
|
89
|
-
max_files:
|
|
90
|
-
selected_files:
|
|
333
|
+
max_files: int | None = None
|
|
334
|
+
selected_files: list[Any] | None = None
|
|
91
335
|
# When True, ingest ALL files from the connector (bypasses the existing-files gate).
|
|
92
336
|
# Used by direct-sync providers like IBM COS on initial ingest.
|
|
93
337
|
sync_all: bool = False
|
|
94
338
|
# When set, only ingest files from these buckets (IBM COS specific).
|
|
95
|
-
bucket_filter:
|
|
339
|
+
bucket_filter: list[str] | None = None
|
|
96
340
|
# Per-request ingest options from the connector upload UI (overrides saved Knowledge for this sync).
|
|
97
|
-
settings:
|
|
341
|
+
settings: dict[str, Any] | None = None
|
|
342
|
+
# When True, files whose filename already exists in the index are replaced
|
|
343
|
+
# rather than failing. Set by the provider upload UI after the user confirms
|
|
344
|
+
# overwrite in the duplicate dialog.
|
|
345
|
+
replace_duplicates: bool = False
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
class ConnectorCheckDuplicatesBody(BaseModel):
|
|
349
|
+
connection_id: str | None = None
|
|
350
|
+
selected_files: list[Any] | None = None
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
async def connector_check_duplicates(
|
|
354
|
+
connector_type: str,
|
|
355
|
+
body: ConnectorCheckDuplicatesBody,
|
|
356
|
+
connector_service=Depends(get_connector_service),
|
|
357
|
+
session_manager=Depends(get_session_manager),
|
|
358
|
+
user: User = Depends(require_permission("connectors:use")),
|
|
359
|
+
):
|
|
360
|
+
"""Check if any of the selected files or folders contain files that already exist in the index"""
|
|
361
|
+
selected_files_raw = body.selected_files
|
|
362
|
+
if not selected_files_raw:
|
|
363
|
+
return JSONResponse({"duplicate_names": []})
|
|
364
|
+
|
|
365
|
+
try:
|
|
366
|
+
jwt_token = user.jwt_token
|
|
367
|
+
# Get all active connections for this connector type and user
|
|
368
|
+
connections = await connector_service.connection_manager.list_connections(
|
|
369
|
+
user_id=user.user_id, connector_type=connector_type
|
|
370
|
+
)
|
|
371
|
+
active_connections = [conn for conn in connections if conn.is_active]
|
|
372
|
+
|
|
373
|
+
# If connection_id is provided, find it, otherwise find the first working connection
|
|
374
|
+
working_connection = None
|
|
375
|
+
if body.connection_id:
|
|
376
|
+
for conn in active_connections:
|
|
377
|
+
if conn.connection_id == body.connection_id:
|
|
378
|
+
working_connection = conn
|
|
379
|
+
break
|
|
380
|
+
|
|
381
|
+
if not working_connection:
|
|
382
|
+
for conn in active_connections:
|
|
383
|
+
try:
|
|
384
|
+
connector = await connector_service.get_connector(conn.connection_id)
|
|
385
|
+
if connector and await connector.authenticate():
|
|
386
|
+
working_connection = conn
|
|
387
|
+
break
|
|
388
|
+
except Exception:
|
|
389
|
+
continue
|
|
390
|
+
|
|
391
|
+
if not working_connection:
|
|
392
|
+
return JSONResponse(
|
|
393
|
+
{"error": f"No working {connector_type} connections found"},
|
|
394
|
+
status_code=404,
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
connector = await connector_service.get_connector(working_connection.connection_id)
|
|
398
|
+
if not connector:
|
|
399
|
+
return JSONResponse(
|
|
400
|
+
{"error": f"Connection '{working_connection.connection_id}' not found"},
|
|
401
|
+
status_code=404,
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
# Get list of file IDs to expand
|
|
405
|
+
file_ids = [f.get("id") for f in selected_files_raw if f.get("id")]
|
|
406
|
+
|
|
407
|
+
expanded_files_info = []
|
|
408
|
+
|
|
409
|
+
# Expand files (handling folders) if possible
|
|
410
|
+
if file_ids and hasattr(connector, "cfg"):
|
|
411
|
+
original_file_ids = getattr(connector.cfg, "file_ids", None)
|
|
412
|
+
original_folder_ids = getattr(connector.cfg, "folder_ids", None)
|
|
413
|
+
try:
|
|
414
|
+
connector.cfg.file_ids = file_ids
|
|
415
|
+
connector.cfg.folder_ids = None
|
|
416
|
+
|
|
417
|
+
result = await connector.list_files()
|
|
418
|
+
expanded_files = result.get("files", [])
|
|
419
|
+
for f in expanded_files:
|
|
420
|
+
expanded_files_info.append(
|
|
421
|
+
{
|
|
422
|
+
"name": f.get("name", ""),
|
|
423
|
+
"mimeType": f.get("mime_type") or f.get("mimeType") or "",
|
|
424
|
+
}
|
|
425
|
+
)
|
|
426
|
+
except Exception as e:
|
|
427
|
+
logger.error("Failed to expand files in duplicate check", error=str(e))
|
|
428
|
+
finally:
|
|
429
|
+
connector.cfg.file_ids = original_file_ids
|
|
430
|
+
connector.cfg.folder_ids = original_folder_ids
|
|
431
|
+
|
|
432
|
+
# If expansion returned nothing, fall back to non-folders in selected_files_raw
|
|
433
|
+
if not expanded_files_info:
|
|
434
|
+
for f in selected_files_raw:
|
|
435
|
+
if not f.get("isFolder"):
|
|
436
|
+
expanded_files_info.append(
|
|
437
|
+
{"name": f.get("name", ""), "mimeType": f.get("mimeType") or ""}
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
if not expanded_files_info:
|
|
441
|
+
return JSONResponse({"duplicate_names": []})
|
|
442
|
+
|
|
443
|
+
# Process and clean names using clean_connector_filename
|
|
444
|
+
from utils.file_utils import clean_connector_filename, get_filename_aliases
|
|
445
|
+
|
|
446
|
+
cleaned_names = []
|
|
447
|
+
for f in expanded_files_info:
|
|
448
|
+
cleaned_name = clean_connector_filename(f["name"], f["mimeType"])
|
|
449
|
+
cleaned_names.append(cleaned_name)
|
|
450
|
+
|
|
451
|
+
# Build candidate filenames (including aliases like .txt / .md mapping)
|
|
452
|
+
all_candidates = set()
|
|
453
|
+
for name in cleaned_names:
|
|
454
|
+
all_candidates.update(get_filename_aliases(name))
|
|
455
|
+
|
|
456
|
+
if not all_candidates:
|
|
457
|
+
return JSONResponse({"duplicate_names": []})
|
|
458
|
+
|
|
459
|
+
# Query OpenSearch in one batch
|
|
460
|
+
opensearch_client = session_manager.get_user_opensearch_client(user.user_id, jwt_token)
|
|
461
|
+
|
|
462
|
+
query_body = {
|
|
463
|
+
"size": 10000,
|
|
464
|
+
"query": {"terms": {"filename": list(all_candidates)}},
|
|
465
|
+
"_source": ["filename"],
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
existing_filenames = set()
|
|
469
|
+
try:
|
|
470
|
+
response = await opensearch_client.search(index=get_index_name(), body=query_body)
|
|
471
|
+
hits = response.get("hits", {}).get("hits", [])
|
|
472
|
+
for hit in hits:
|
|
473
|
+
fn = hit.get("_source", {}).get("filename")
|
|
474
|
+
if fn:
|
|
475
|
+
existing_filenames.add(fn)
|
|
476
|
+
except Exception as search_err:
|
|
477
|
+
if "index_not_found_exception" in str(search_err):
|
|
478
|
+
# Index doesn't exist yet, so no duplicates can exist
|
|
479
|
+
return JSONResponse({"duplicate_names": [], "total_files": len(cleaned_names)})
|
|
480
|
+
raise
|
|
481
|
+
|
|
482
|
+
# Check which of the cleaned names have duplicates (based on existing_filenames matching any alias)
|
|
483
|
+
duplicate_names = []
|
|
484
|
+
for name in cleaned_names:
|
|
485
|
+
aliases = get_filename_aliases(name)
|
|
486
|
+
if any(alias in existing_filenames for alias in aliases):
|
|
487
|
+
duplicate_names.append(name)
|
|
488
|
+
|
|
489
|
+
return JSONResponse(
|
|
490
|
+
{"duplicate_names": list(set(duplicate_names)), "total_files": len(cleaned_names)}
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
except Exception:
|
|
494
|
+
logger.exception("[CONNECTOR] Error checking duplicates")
|
|
495
|
+
return JSONResponse({"error": "An internal error has occurred."}, status_code=500)
|
|
98
496
|
|
|
99
497
|
|
|
100
498
|
async def list_connectors(
|
|
@@ -117,7 +515,7 @@ async def connector_sync(
|
|
|
117
515
|
body: ConnectorSyncBody,
|
|
118
516
|
connector_service=Depends(get_connector_service),
|
|
119
517
|
session_manager=Depends(get_session_manager),
|
|
120
|
-
user: User = Depends(
|
|
518
|
+
user: User = Depends(require_permission("connectors:use")),
|
|
121
519
|
):
|
|
122
520
|
"""Sync files from all active connections of a connector type"""
|
|
123
521
|
max_files = body.max_files
|
|
@@ -134,7 +532,9 @@ async def connector_sync(
|
|
|
134
532
|
file_infos = selected_files_raw
|
|
135
533
|
|
|
136
534
|
try:
|
|
137
|
-
await TelemetryClient.send_event(
|
|
535
|
+
await TelemetryClient.send_event(
|
|
536
|
+
Category.CONNECTOR_OPERATIONS, MessageId.ORB_CONN_SYNC_START
|
|
537
|
+
)
|
|
138
538
|
logger.debug(
|
|
139
539
|
"Starting connector sync",
|
|
140
540
|
connector_type=connector_type,
|
|
@@ -195,10 +595,11 @@ async def connector_sync(
|
|
|
195
595
|
"Starting sync with working connection",
|
|
196
596
|
connection_id=working_connection.connection_id,
|
|
197
597
|
)
|
|
198
|
-
|
|
598
|
+
|
|
199
599
|
if selected_files:
|
|
200
600
|
# Explicit files selected (e.g., from file picker) - sync those specific files
|
|
201
601
|
from .documents import _ensure_index_exists
|
|
602
|
+
|
|
202
603
|
await _ensure_index_exists(jwt_token)
|
|
203
604
|
task_id = await connector_service.sync_specific_files(
|
|
204
605
|
working_connection.connection_id,
|
|
@@ -207,6 +608,7 @@ async def connector_sync(
|
|
|
207
608
|
jwt_token=jwt_token,
|
|
208
609
|
file_infos=file_infos,
|
|
209
610
|
ingest_settings=body.settings,
|
|
611
|
+
replace_duplicates=body.replace_duplicates,
|
|
210
612
|
)
|
|
211
613
|
elif body.sync_all or body.bucket_filter:
|
|
212
614
|
# Full ingest: discover and ingest all files (or files from specific buckets).
|
|
@@ -236,7 +638,10 @@ async def connector_sync(
|
|
|
236
638
|
|
|
237
639
|
if not all_file_ids:
|
|
238
640
|
return JSONResponse(
|
|
239
|
-
{
|
|
641
|
+
{
|
|
642
|
+
"status": "no_files",
|
|
643
|
+
"message": "No files found in the selected buckets.",
|
|
644
|
+
},
|
|
240
645
|
status_code=200,
|
|
241
646
|
)
|
|
242
647
|
task_id = await connector_service.sync_specific_files(
|
|
@@ -257,7 +662,11 @@ async def connector_sync(
|
|
|
257
662
|
else:
|
|
258
663
|
# No files specified - sync only files already in OpenSearch for this connector
|
|
259
664
|
# This ensures deleted files stay deleted
|
|
260
|
-
|
|
665
|
+
(
|
|
666
|
+
existing_file_ids,
|
|
667
|
+
existing_filenames,
|
|
668
|
+
id_field,
|
|
669
|
+
) = await get_synced_file_ids_for_connector(
|
|
261
670
|
connector_type=connector_type,
|
|
262
671
|
user_id=user.user_id,
|
|
263
672
|
session_manager=session_manager,
|
|
@@ -273,19 +682,34 @@ async def connector_sync(
|
|
|
273
682
|
status_code=200,
|
|
274
683
|
)
|
|
275
684
|
|
|
276
|
-
# If we have
|
|
685
|
+
# If we have connector file IDs, use sync_specific_files
|
|
277
686
|
# Otherwise, use filename filtering with sync_connector_files
|
|
278
687
|
if existing_file_ids:
|
|
279
688
|
logger.info(
|
|
280
|
-
"Syncing specific files by
|
|
689
|
+
"Syncing specific files by connector file ID",
|
|
281
690
|
connector_type=connector_type,
|
|
282
691
|
file_count=len(existing_file_ids),
|
|
692
|
+
id_field=id_field,
|
|
283
693
|
)
|
|
694
|
+
# Reconcile orphans (files deleted at the source) before re-syncing.
|
|
695
|
+
# Strict gating: skip when sync is capped — we'd see a partial remote
|
|
696
|
+
# listing and delete legitimate files.
|
|
697
|
+
if body.max_files is None:
|
|
698
|
+
await reconcile_orphans_for_connector_type(
|
|
699
|
+
connector_type=connector_type,
|
|
700
|
+
user_id=user.user_id,
|
|
701
|
+
connector_service=connector_service,
|
|
702
|
+
session_manager=session_manager,
|
|
703
|
+
jwt_token=jwt_token,
|
|
704
|
+
existing_file_ids=existing_file_ids,
|
|
705
|
+
id_field=id_field,
|
|
706
|
+
)
|
|
284
707
|
task_id = await connector_service.sync_specific_files(
|
|
285
708
|
working_connection.connection_id,
|
|
286
709
|
user.user_id,
|
|
287
710
|
existing_file_ids,
|
|
288
711
|
jwt_token=jwt_token,
|
|
712
|
+
replace_duplicates=_connector_sync_should_replace(connector_type),
|
|
289
713
|
)
|
|
290
714
|
else:
|
|
291
715
|
# Fallback: use filename filtering (for Langflow-ingested files without document_id)
|
|
@@ -300,9 +724,12 @@ async def connector_sync(
|
|
|
300
724
|
max_files=None,
|
|
301
725
|
jwt_token=jwt_token,
|
|
302
726
|
filename_filter=set(existing_filenames),
|
|
727
|
+
replace_duplicates=_connector_sync_should_replace(connector_type),
|
|
303
728
|
)
|
|
304
729
|
task_ids = [task_id]
|
|
305
|
-
await TelemetryClient.send_event(
|
|
730
|
+
await TelemetryClient.send_event(
|
|
731
|
+
Category.CONNECTOR_OPERATIONS, MessageId.ORB_CONN_SYNC_COMPLETE
|
|
732
|
+
)
|
|
306
733
|
return JSONResponse(
|
|
307
734
|
{
|
|
308
735
|
"task_ids": task_ids,
|
|
@@ -315,7 +742,9 @@ async def connector_sync(
|
|
|
315
742
|
|
|
316
743
|
except Exception as e:
|
|
317
744
|
logger.error("Connector sync failed", error=str(e))
|
|
318
|
-
await TelemetryClient.send_event(
|
|
745
|
+
await TelemetryClient.send_event(
|
|
746
|
+
Category.CONNECTOR_OPERATIONS, MessageId.ORB_CONN_SYNC_FAILED
|
|
747
|
+
)
|
|
319
748
|
return JSONResponse({"error": f"Sync failed: {str(e)}"}, status_code=500)
|
|
320
749
|
|
|
321
750
|
|
|
@@ -334,25 +763,31 @@ async def connector_status(
|
|
|
334
763
|
# Get the connector for each connection and verify authentication
|
|
335
764
|
connection_details = {}
|
|
336
765
|
verified_active_connections = []
|
|
337
|
-
|
|
766
|
+
|
|
338
767
|
for connection in connections:
|
|
339
768
|
try:
|
|
340
769
|
connector = await connector_service._get_connector(connection.connection_id)
|
|
341
770
|
if connector is not None:
|
|
342
771
|
# Actually verify the connection by trying to authenticate
|
|
343
772
|
is_authenticated = await connector.authenticate()
|
|
344
|
-
|
|
773
|
+
|
|
345
774
|
# Get base URL if available (for SharePoint/OneDrive connectors)
|
|
346
775
|
base_url = None
|
|
347
|
-
if hasattr(connector,
|
|
776
|
+
if hasattr(connector, "base_url"):
|
|
348
777
|
base_url = connector.base_url
|
|
349
|
-
logger.debug(
|
|
350
|
-
|
|
778
|
+
logger.debug(
|
|
779
|
+
f"connector_status: Got base_url from connector.base_url: {base_url}"
|
|
780
|
+
)
|
|
781
|
+
elif hasattr(connector, "sharepoint_url"):
|
|
351
782
|
base_url = connector.sharepoint_url # Backward compatibility
|
|
352
|
-
logger.debug(
|
|
783
|
+
logger.debug(
|
|
784
|
+
f"connector_status: Got base_url from connector.sharepoint_url: {base_url}"
|
|
785
|
+
)
|
|
353
786
|
else:
|
|
354
|
-
logger.debug(
|
|
355
|
-
|
|
787
|
+
logger.debug(
|
|
788
|
+
"connector_status: Connector has no base_url or sharepoint_url attribute"
|
|
789
|
+
)
|
|
790
|
+
|
|
356
791
|
connection_details[connection.connection_id] = {
|
|
357
792
|
"client_id": connector.get_client_id(),
|
|
358
793
|
"is_authenticated": is_authenticated,
|
|
@@ -391,8 +826,13 @@ async def connector_status(
|
|
|
391
826
|
"connection_id": conn.connection_id,
|
|
392
827
|
"name": conn.name,
|
|
393
828
|
"client_id": connection_details.get(conn.connection_id, {}).get("client_id"),
|
|
394
|
-
"is_active": conn.is_active
|
|
395
|
-
|
|
829
|
+
"is_active": conn.is_active
|
|
830
|
+
and connection_details.get(conn.connection_id, {}).get(
|
|
831
|
+
"is_authenticated", False
|
|
832
|
+
),
|
|
833
|
+
"is_authenticated": connection_details.get(conn.connection_id, {}).get(
|
|
834
|
+
"is_authenticated", False
|
|
835
|
+
),
|
|
396
836
|
"base_url": connection_details.get(conn.connection_id, {}).get("base_url"),
|
|
397
837
|
"created_at": conn.created_at.isoformat(),
|
|
398
838
|
"last_sync": conn.last_sync.isoformat() if conn.last_sync else None,
|
|
@@ -422,10 +862,10 @@ async def connector_webhook(
|
|
|
422
862
|
config=temp_config,
|
|
423
863
|
)
|
|
424
864
|
try:
|
|
425
|
-
await TelemetryClient.send_event(
|
|
426
|
-
|
|
427
|
-
temp_connection
|
|
865
|
+
await TelemetryClient.send_event(
|
|
866
|
+
Category.CONNECTOR_OPERATIONS, MessageId.ORB_CONN_WEBHOOK_RECV
|
|
428
867
|
)
|
|
868
|
+
temp_connector = connector_service.connection_manager._create_connector(temp_connection)
|
|
429
869
|
validation_response = temp_connector.handle_webhook_validation(
|
|
430
870
|
request.method, dict(request.headers), dict(request.query_params)
|
|
431
871
|
)
|
|
@@ -460,32 +900,22 @@ async def connector_webhook(
|
|
|
460
900
|
|
|
461
901
|
# Extract channel/subscription ID using connector-specific method
|
|
462
902
|
try:
|
|
463
|
-
temp_connector = connector_service.connection_manager._create_connector(
|
|
464
|
-
temp_connection
|
|
465
|
-
)
|
|
903
|
+
temp_connector = connector_service.connection_manager._create_connector(temp_connection)
|
|
466
904
|
channel_id = temp_connector.extract_webhook_channel_id(payload, headers)
|
|
467
905
|
except (NotImplementedError, ValueError):
|
|
468
906
|
channel_id = None
|
|
469
907
|
|
|
470
908
|
if not channel_id:
|
|
471
|
-
logger.warning(
|
|
472
|
-
"No channel ID found in webhook", connector_type=connector_type
|
|
473
|
-
)
|
|
909
|
+
logger.warning("No channel ID found in webhook", connector_type=connector_type)
|
|
474
910
|
return JSONResponse({"status": "ignored", "reason": "no_channel_id"})
|
|
475
911
|
|
|
476
912
|
# Find the specific connection for this webhook
|
|
477
|
-
connection = (
|
|
478
|
-
|
|
479
|
-
channel_id
|
|
480
|
-
)
|
|
913
|
+
connection = await connector_service.connection_manager.get_connection_by_webhook_id(
|
|
914
|
+
channel_id
|
|
481
915
|
)
|
|
482
916
|
if not connection or not connection.is_active:
|
|
483
|
-
logger.info(
|
|
484
|
-
|
|
485
|
-
)
|
|
486
|
-
return JSONResponse(
|
|
487
|
-
{"status": "ignored_unknown_channel", "channel_id": channel_id}
|
|
488
|
-
)
|
|
917
|
+
logger.info("Unknown webhook channel, will auto-expire", channel_id=channel_id)
|
|
918
|
+
return JSONResponse({"status": "ignored_unknown_channel", "channel_id": channel_id})
|
|
489
919
|
|
|
490
920
|
# Process webhook for the specific connection
|
|
491
921
|
try:
|
|
@@ -496,9 +926,7 @@ async def connector_webhook(
|
|
|
496
926
|
"Could not get connector for connection",
|
|
497
927
|
connection_id=connection.connection_id,
|
|
498
928
|
)
|
|
499
|
-
return JSONResponse(
|
|
500
|
-
{"status": "error", "reason": "connector_not_found"}
|
|
501
|
-
)
|
|
929
|
+
return JSONResponse({"status": "error", "reason": "connector_not_found"})
|
|
502
930
|
|
|
503
931
|
# Let the connector handle the webhook and return affected file IDs
|
|
504
932
|
affected_files = await connector.handle_webhook(payload)
|
|
@@ -569,15 +997,16 @@ async def connector_webhook(
|
|
|
569
997
|
|
|
570
998
|
except Exception as e:
|
|
571
999
|
logger.error("Webhook processing failed", error=str(e))
|
|
572
|
-
await TelemetryClient.send_event(
|
|
573
|
-
|
|
574
|
-
{"error": f"Webhook processing failed: {str(e)}"}, status_code=500
|
|
1000
|
+
await TelemetryClient.send_event(
|
|
1001
|
+
Category.CONNECTOR_OPERATIONS, MessageId.ORB_CONN_WEBHOOK_FAILED
|
|
575
1002
|
)
|
|
1003
|
+
return JSONResponse({"error": f"Webhook processing failed: {str(e)}"}, status_code=500)
|
|
1004
|
+
|
|
576
1005
|
|
|
577
1006
|
async def connector_disconnect(
|
|
578
1007
|
connector_type: str,
|
|
579
1008
|
connector_service=Depends(get_connector_service),
|
|
580
|
-
user: User = Depends(
|
|
1009
|
+
user: User = Depends(require_permission("connectors:delete:own")),
|
|
581
1010
|
):
|
|
582
1011
|
"""Disconnect a connector by deleting its connection"""
|
|
583
1012
|
|
|
@@ -599,7 +1028,7 @@ async def connector_disconnect(
|
|
|
599
1028
|
try:
|
|
600
1029
|
# Get the connector to cleanup any subscriptions
|
|
601
1030
|
connector = await connector_service._get_connector(connection.connection_id)
|
|
602
|
-
if connector and hasattr(connector,
|
|
1031
|
+
if connector and hasattr(connector, "cleanup_subscription"):
|
|
603
1032
|
subscription_id = connection.config.get("webhook_channel_id")
|
|
604
1033
|
if subscription_id:
|
|
605
1034
|
try:
|
|
@@ -653,21 +1082,24 @@ async def connector_disconnect(
|
|
|
653
1082
|
|
|
654
1083
|
# ---------------------------------------------------------------------------
|
|
655
1084
|
|
|
1085
|
+
|
|
656
1086
|
async def sync_all_connectors(
|
|
657
1087
|
connector_service=Depends(get_connector_service),
|
|
658
1088
|
session_manager=Depends(get_session_manager),
|
|
659
|
-
user: User = Depends(
|
|
1089
|
+
user: User = Depends(require_permission("connectors:use")),
|
|
660
1090
|
):
|
|
661
1091
|
"""
|
|
662
1092
|
Sync files from all active cloud connector connections.
|
|
663
1093
|
"""
|
|
664
1094
|
try:
|
|
665
|
-
await TelemetryClient.send_event(
|
|
1095
|
+
await TelemetryClient.send_event(
|
|
1096
|
+
Category.CONNECTOR_OPERATIONS, MessageId.ORB_CONN_SYNC_START
|
|
1097
|
+
)
|
|
666
1098
|
jwt_token = user.jwt_token
|
|
667
1099
|
|
|
668
1100
|
# Cloud connector types to sync
|
|
669
1101
|
cloud_connector_types = ["google_drive", "onedrive", "sharepoint", "ibm_cos", "aws_s3"]
|
|
670
|
-
|
|
1102
|
+
|
|
671
1103
|
all_task_ids = []
|
|
672
1104
|
synced_connectors = []
|
|
673
1105
|
skipped_connectors = []
|
|
@@ -676,13 +1108,17 @@ async def sync_all_connectors(
|
|
|
676
1108
|
for connector_type in cloud_connector_types:
|
|
677
1109
|
try:
|
|
678
1110
|
# First, get existing file IDs/filenames from OpenSearch for this connector type
|
|
679
|
-
|
|
1111
|
+
(
|
|
1112
|
+
existing_file_ids,
|
|
1113
|
+
existing_filenames,
|
|
1114
|
+
id_field,
|
|
1115
|
+
) = await get_synced_file_ids_for_connector(
|
|
680
1116
|
connector_type=connector_type,
|
|
681
1117
|
user_id=user.user_id,
|
|
682
1118
|
session_manager=session_manager,
|
|
683
1119
|
jwt_token=jwt_token,
|
|
684
1120
|
)
|
|
685
|
-
|
|
1121
|
+
|
|
686
1122
|
if not existing_file_ids and not existing_filenames:
|
|
687
1123
|
logger.debug(
|
|
688
1124
|
"No existing files in OpenSearch for connector type, skipping",
|
|
@@ -727,18 +1163,32 @@ async def sync_all_connectors(
|
|
|
727
1163
|
)
|
|
728
1164
|
continue
|
|
729
1165
|
|
|
730
|
-
# Sync using
|
|
1166
|
+
# Sync using connector file IDs if available, else use filename filter
|
|
731
1167
|
if existing_file_ids:
|
|
732
1168
|
logger.info(
|
|
733
|
-
"Syncing specific files by
|
|
1169
|
+
"Syncing specific files by connector file ID",
|
|
734
1170
|
connector_type=connector_type,
|
|
735
1171
|
file_count=len(existing_file_ids),
|
|
1172
|
+
id_field=id_field,
|
|
1173
|
+
)
|
|
1174
|
+
# Reconcile orphans (files deleted at the source) before re-syncing.
|
|
1175
|
+
# sync_all_connectors has no caps or filters, so gating reduces
|
|
1176
|
+
# to the strict checks inside the helper.
|
|
1177
|
+
await reconcile_orphans_for_connector_type(
|
|
1178
|
+
connector_type=connector_type,
|
|
1179
|
+
user_id=user.user_id,
|
|
1180
|
+
connector_service=connector_service,
|
|
1181
|
+
session_manager=session_manager,
|
|
1182
|
+
jwt_token=jwt_token,
|
|
1183
|
+
existing_file_ids=existing_file_ids,
|
|
1184
|
+
id_field=id_field,
|
|
736
1185
|
)
|
|
737
1186
|
task_id = await connector_service.sync_specific_files(
|
|
738
1187
|
working_connection.connection_id,
|
|
739
1188
|
user.user_id,
|
|
740
1189
|
existing_file_ids,
|
|
741
1190
|
jwt_token=jwt_token,
|
|
1191
|
+
replace_duplicates=_connector_sync_should_replace(connector_type),
|
|
742
1192
|
)
|
|
743
1193
|
else:
|
|
744
1194
|
# Fallback: use filename filtering
|
|
@@ -753,15 +1203,18 @@ async def sync_all_connectors(
|
|
|
753
1203
|
max_files=None,
|
|
754
1204
|
jwt_token=jwt_token,
|
|
755
1205
|
filename_filter=set(existing_filenames),
|
|
1206
|
+
replace_duplicates=_connector_sync_should_replace(connector_type),
|
|
756
1207
|
)
|
|
757
|
-
|
|
1208
|
+
|
|
758
1209
|
all_task_ids.append(task_id)
|
|
759
1210
|
synced_connectors.append(connector_type)
|
|
760
1211
|
logger.info(
|
|
761
1212
|
"Started sync for connector type",
|
|
762
1213
|
connector_type=connector_type,
|
|
763
1214
|
task_id=task_id,
|
|
764
|
-
file_count=len(existing_file_ids)
|
|
1215
|
+
file_count=len(existing_file_ids)
|
|
1216
|
+
if existing_file_ids
|
|
1217
|
+
else len(existing_filenames),
|
|
765
1218
|
)
|
|
766
1219
|
|
|
767
1220
|
except Exception as e:
|
|
@@ -787,7 +1240,9 @@ async def sync_all_connectors(
|
|
|
787
1240
|
status_code=404,
|
|
788
1241
|
)
|
|
789
1242
|
|
|
790
|
-
await TelemetryClient.send_event(
|
|
1243
|
+
await TelemetryClient.send_event(
|
|
1244
|
+
Category.CONNECTOR_OPERATIONS, MessageId.ORB_CONN_SYNC_COMPLETE
|
|
1245
|
+
)
|
|
791
1246
|
return JSONResponse(
|
|
792
1247
|
{
|
|
793
1248
|
"task_ids": all_task_ids,
|
|
@@ -802,10 +1257,143 @@ async def sync_all_connectors(
|
|
|
802
1257
|
|
|
803
1258
|
except Exception as e:
|
|
804
1259
|
logger.error("Sync all connectors failed", error=str(e))
|
|
805
|
-
await TelemetryClient.send_event(
|
|
1260
|
+
await TelemetryClient.send_event(
|
|
1261
|
+
Category.CONNECTOR_OPERATIONS, MessageId.ORB_CONN_SYNC_FAILED
|
|
1262
|
+
)
|
|
806
1263
|
return JSONResponse({"error": f"Sync failed: {str(e)}"}, status_code=500)
|
|
807
1264
|
|
|
808
1265
|
|
|
1266
|
+
CLOUD_CONNECTOR_TYPES = ["google_drive", "onedrive", "sharepoint", "ibm_cos", "aws_s3"]
|
|
1267
|
+
|
|
1268
|
+
|
|
1269
|
+
async def _preview_orphans_for_connector_type(
|
|
1270
|
+
connector_type: str,
|
|
1271
|
+
user_id: str,
|
|
1272
|
+
connector_service,
|
|
1273
|
+
session_manager,
|
|
1274
|
+
jwt_token: str | None,
|
|
1275
|
+
) -> tuple[list[dict[str, str]] | None, int]:
|
|
1276
|
+
"""Helper: compute orphans (no deletion) + return total synced count.
|
|
1277
|
+
|
|
1278
|
+
Returns (orphans, synced_count). `orphans` is None when strict gating aborts
|
|
1279
|
+
(so the caller can surface a "couldn't determine" state); [] when no orphans.
|
|
1280
|
+
"""
|
|
1281
|
+
existing_file_ids, existing_filenames, _ = await get_synced_file_ids_for_connector(
|
|
1282
|
+
connector_type=connector_type,
|
|
1283
|
+
user_id=user_id,
|
|
1284
|
+
session_manager=session_manager,
|
|
1285
|
+
jwt_token=jwt_token,
|
|
1286
|
+
)
|
|
1287
|
+
|
|
1288
|
+
synced_count = len(existing_file_ids) if existing_file_ids else len(existing_filenames)
|
|
1289
|
+
if not existing_file_ids:
|
|
1290
|
+
# No document_ids to diff against (e.g. Langflow-only ingest). Filename-only
|
|
1291
|
+
# fallback can't detect orphans safely — surface empty list.
|
|
1292
|
+
return [], synced_count
|
|
1293
|
+
|
|
1294
|
+
id_to_filename = await get_synced_id_to_filename_map(
|
|
1295
|
+
connector_type=connector_type,
|
|
1296
|
+
user_id=user_id,
|
|
1297
|
+
session_manager=session_manager,
|
|
1298
|
+
jwt_token=jwt_token,
|
|
1299
|
+
)
|
|
1300
|
+
|
|
1301
|
+
orphans = await compute_orphans_for_connector_type(
|
|
1302
|
+
connector_type=connector_type,
|
|
1303
|
+
user_id=user_id,
|
|
1304
|
+
connector_service=connector_service,
|
|
1305
|
+
session_manager=session_manager,
|
|
1306
|
+
jwt_token=jwt_token,
|
|
1307
|
+
existing_file_ids=existing_file_ids,
|
|
1308
|
+
id_to_filename=id_to_filename,
|
|
1309
|
+
)
|
|
1310
|
+
return orphans, synced_count
|
|
1311
|
+
|
|
1312
|
+
|
|
1313
|
+
async def connector_sync_preview(
|
|
1314
|
+
connector_type: str,
|
|
1315
|
+
connector_service=Depends(get_connector_service),
|
|
1316
|
+
session_manager=Depends(get_session_manager),
|
|
1317
|
+
user: User = Depends(require_permission("connectors:use")),
|
|
1318
|
+
):
|
|
1319
|
+
"""Preview the impact of syncing a connector type without performing any
|
|
1320
|
+
deletion or ingest. Returns the list of orphan files (present in OpenSearch
|
|
1321
|
+
but no longer at the source) by filename, plus the total synced count.
|
|
1322
|
+
"""
|
|
1323
|
+
try:
|
|
1324
|
+
orphans, synced_count = await _preview_orphans_for_connector_type(
|
|
1325
|
+
connector_type=connector_type,
|
|
1326
|
+
user_id=user.user_id,
|
|
1327
|
+
connector_service=connector_service,
|
|
1328
|
+
session_manager=session_manager,
|
|
1329
|
+
jwt_token=user.jwt_token,
|
|
1330
|
+
)
|
|
1331
|
+
return JSONResponse(
|
|
1332
|
+
{
|
|
1333
|
+
"connector_type": connector_type,
|
|
1334
|
+
"synced_count": synced_count,
|
|
1335
|
+
"orphans": orphans or [],
|
|
1336
|
+
"orphans_available": orphans is not None,
|
|
1337
|
+
},
|
|
1338
|
+
status_code=200,
|
|
1339
|
+
)
|
|
1340
|
+
except Exception as e:
|
|
1341
|
+
logger.error("Sync preview failed", connector_type=connector_type, error=str(e))
|
|
1342
|
+
return JSONResponse({"error": f"Sync preview failed: {str(e)}"}, status_code=500)
|
|
1343
|
+
|
|
1344
|
+
|
|
1345
|
+
async def connectors_sync_all_preview(
|
|
1346
|
+
connector_service=Depends(get_connector_service),
|
|
1347
|
+
session_manager=Depends(get_session_manager),
|
|
1348
|
+
user: User = Depends(require_permission("connectors:use")),
|
|
1349
|
+
):
|
|
1350
|
+
"""Preview the impact of sync-all-connectors across every cloud connector
|
|
1351
|
+
type. Returns orphan filenames grouped by connector_type plus a per-type
|
|
1352
|
+
synced count.
|
|
1353
|
+
"""
|
|
1354
|
+
try:
|
|
1355
|
+
orphans_by_type: dict[str, list[dict[str, str]]] = {}
|
|
1356
|
+
synced_count_by_type: dict[str, int] = {}
|
|
1357
|
+
orphans_available_by_type: dict[str, bool] = {}
|
|
1358
|
+
|
|
1359
|
+
for connector_type in CLOUD_CONNECTOR_TYPES:
|
|
1360
|
+
try:
|
|
1361
|
+
orphans, synced_count = await _preview_orphans_for_connector_type(
|
|
1362
|
+
connector_type=connector_type,
|
|
1363
|
+
user_id=user.user_id,
|
|
1364
|
+
connector_service=connector_service,
|
|
1365
|
+
session_manager=session_manager,
|
|
1366
|
+
jwt_token=user.jwt_token,
|
|
1367
|
+
)
|
|
1368
|
+
except Exception as e:
|
|
1369
|
+
logger.warning(
|
|
1370
|
+
"Sync-all preview: per-connector failure",
|
|
1371
|
+
connector_type=connector_type,
|
|
1372
|
+
error=str(e),
|
|
1373
|
+
)
|
|
1374
|
+
orphans, synced_count = None, 0
|
|
1375
|
+
|
|
1376
|
+
# Only include connector types that have something synced.
|
|
1377
|
+
if synced_count == 0 and not orphans:
|
|
1378
|
+
continue
|
|
1379
|
+
|
|
1380
|
+
synced_count_by_type[connector_type] = synced_count
|
|
1381
|
+
orphans_by_type[connector_type] = orphans or []
|
|
1382
|
+
orphans_available_by_type[connector_type] = orphans is not None
|
|
1383
|
+
|
|
1384
|
+
return JSONResponse(
|
|
1385
|
+
{
|
|
1386
|
+
"orphans_by_type": orphans_by_type,
|
|
1387
|
+
"synced_count_by_type": synced_count_by_type,
|
|
1388
|
+
"orphans_available_by_type": orphans_available_by_type,
|
|
1389
|
+
},
|
|
1390
|
+
status_code=200,
|
|
1391
|
+
)
|
|
1392
|
+
except Exception as e:
|
|
1393
|
+
logger.error("Sync-all preview failed", error=str(e))
|
|
1394
|
+
return JSONResponse({"error": f"Sync-all preview failed: {str(e)}"}, status_code=500)
|
|
1395
|
+
|
|
1396
|
+
|
|
809
1397
|
async def connector_token(
|
|
810
1398
|
connector_type: str,
|
|
811
1399
|
connection_id: str,
|
|
@@ -826,7 +1414,9 @@ async def connector_token(
|
|
|
826
1414
|
connector = await connector_service._get_connector(connection_id)
|
|
827
1415
|
if not connector:
|
|
828
1416
|
return JSONResponse(
|
|
829
|
-
{
|
|
1417
|
+
{
|
|
1418
|
+
"error": f"Connector not available - authentication may have failed for {url_connector_type}"
|
|
1419
|
+
},
|
|
830
1420
|
status_code=404,
|
|
831
1421
|
)
|
|
832
1422
|
|
|
@@ -858,7 +1448,10 @@ async def connector_token(
|
|
|
858
1448
|
try:
|
|
859
1449
|
if connector.oauth.creds.expiry:
|
|
860
1450
|
import time
|
|
861
|
-
|
|
1451
|
+
|
|
1452
|
+
expires_in = max(
|
|
1453
|
+
0, int(connector.oauth.creds.expiry.timestamp() - time.time())
|
|
1454
|
+
)
|
|
862
1455
|
except Exception:
|
|
863
1456
|
expires_in = None
|
|
864
1457
|
|
|
@@ -905,12 +1498,114 @@ async def connector_token(
|
|
|
905
1498
|
return JSONResponse({"access_token": access_token, "expires_in": None})
|
|
906
1499
|
except ValueError as e:
|
|
907
1500
|
# Typical when acquire_token_silent fails (e.g., needs re-auth)
|
|
908
|
-
return JSONResponse(
|
|
1501
|
+
return JSONResponse(
|
|
1502
|
+
{"error": f"Failed to get access token: {str(e)}"}, status_code=401
|
|
1503
|
+
)
|
|
909
1504
|
except Exception as e:
|
|
910
1505
|
return JSONResponse({"error": f"Authentication error: {str(e)}"}, status_code=500)
|
|
911
1506
|
|
|
912
|
-
return JSONResponse(
|
|
1507
|
+
return JSONResponse(
|
|
1508
|
+
{"error": "Token not available for this connector type"}, status_code=400
|
|
1509
|
+
)
|
|
913
1510
|
|
|
914
1511
|
except Exception as e:
|
|
915
1512
|
logger.error("Error getting connector token", exc_info=True)
|
|
916
1513
|
return JSONResponse({"error": str(e)}, status_code=500)
|
|
1514
|
+
|
|
1515
|
+
|
|
1516
|
+
async def browse_connection_files(
|
|
1517
|
+
connector_type: str,
|
|
1518
|
+
connection_id: str,
|
|
1519
|
+
connector_service=Depends(get_connector_service),
|
|
1520
|
+
session_manager=Depends(get_session_manager),
|
|
1521
|
+
user: User = Depends(get_current_user),
|
|
1522
|
+
bucket: str | None = None,
|
|
1523
|
+
search: str | None = None,
|
|
1524
|
+
page_token: str | None = None,
|
|
1525
|
+
max_files: int = 100,
|
|
1526
|
+
):
|
|
1527
|
+
"""
|
|
1528
|
+
Browse remote files in a connector with ingestion status.
|
|
1529
|
+
|
|
1530
|
+
Lists files from the remote source (e.g., S3 bucket) and marks each
|
|
1531
|
+
as ingested or not by cross-referencing with OpenSearch.
|
|
1532
|
+
"""
|
|
1533
|
+
try:
|
|
1534
|
+
connector = await connector_service.get_connector(connection_id)
|
|
1535
|
+
if not connector:
|
|
1536
|
+
return JSONResponse(
|
|
1537
|
+
{"error": "Connection not found or connector unavailable"},
|
|
1538
|
+
status_code=404,
|
|
1539
|
+
)
|
|
1540
|
+
|
|
1541
|
+
if not await connector.authenticate():
|
|
1542
|
+
return JSONResponse(
|
|
1543
|
+
{"error": "Connector authentication failed"},
|
|
1544
|
+
status_code=401,
|
|
1545
|
+
)
|
|
1546
|
+
|
|
1547
|
+
# Temporarily override bucket filter if specified
|
|
1548
|
+
original_buckets = None
|
|
1549
|
+
if bucket and hasattr(connector, "bucket_names"):
|
|
1550
|
+
original_buckets = connector.bucket_names
|
|
1551
|
+
connector.bucket_names = [bucket]
|
|
1552
|
+
|
|
1553
|
+
try:
|
|
1554
|
+
files_result = await connector.list_files(page_token=page_token, max_files=max_files)
|
|
1555
|
+
finally:
|
|
1556
|
+
if original_buckets is not None:
|
|
1557
|
+
connector.bucket_names = original_buckets
|
|
1558
|
+
|
|
1559
|
+
remote_files = files_result.get("files", [])
|
|
1560
|
+
next_page_token = files_result.get("next_page_token")
|
|
1561
|
+
|
|
1562
|
+
# Filter by filename search if provided
|
|
1563
|
+
if search:
|
|
1564
|
+
search_lower = search.lower()
|
|
1565
|
+
remote_files = [f for f in remote_files if search_lower in f.get("name", "").lower()]
|
|
1566
|
+
|
|
1567
|
+
# Get already-ingested file IDs from OpenSearch
|
|
1568
|
+
ingested_ids, ingested_filenames, _ = await get_synced_file_ids_for_connector(
|
|
1569
|
+
connector_type=connector_type,
|
|
1570
|
+
user_id=user.user_id,
|
|
1571
|
+
session_manager=session_manager,
|
|
1572
|
+
jwt_token=user.jwt_token,
|
|
1573
|
+
)
|
|
1574
|
+
ingested_set = set(ingested_ids) | set(ingested_filenames)
|
|
1575
|
+
|
|
1576
|
+
# Merge ingestion status into remote file list
|
|
1577
|
+
enriched_files = []
|
|
1578
|
+
for f in remote_files:
|
|
1579
|
+
is_ingested = f.get("id", "") in ingested_set or f.get("name", "") in ingested_set
|
|
1580
|
+
enriched_files.append(
|
|
1581
|
+
{
|
|
1582
|
+
"id": f.get("id", ""),
|
|
1583
|
+
"name": f.get("name", ""),
|
|
1584
|
+
"bucket": f.get("bucket", ""),
|
|
1585
|
+
"key": f.get("key", ""),
|
|
1586
|
+
"size": f.get("size", 0),
|
|
1587
|
+
"modified_time": f.get("modified_time", ""),
|
|
1588
|
+
"is_ingested": is_ingested,
|
|
1589
|
+
}
|
|
1590
|
+
)
|
|
1591
|
+
|
|
1592
|
+
return JSONResponse(
|
|
1593
|
+
{
|
|
1594
|
+
"files": enriched_files,
|
|
1595
|
+
"next_page_token": next_page_token,
|
|
1596
|
+
"total_remote": len(enriched_files),
|
|
1597
|
+
"total_ingested": sum(1 for f in enriched_files if f["is_ingested"]),
|
|
1598
|
+
}
|
|
1599
|
+
)
|
|
1600
|
+
|
|
1601
|
+
except Exception as e:
|
|
1602
|
+
logger.error(
|
|
1603
|
+
"Failed to browse connection files",
|
|
1604
|
+
connector_type=connector_type,
|
|
1605
|
+
connection_id=connection_id,
|
|
1606
|
+
error=str(e),
|
|
1607
|
+
)
|
|
1608
|
+
return JSONResponse(
|
|
1609
|
+
{"error": f"Failed to browse files: {str(e)}"},
|
|
1610
|
+
status_code=500,
|
|
1611
|
+
)
|