d365fo-client 0.2.2__py3-none-any.whl → 0.2.4__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.
@@ -0,0 +1,503 @@
1
+ """Sync management tools for MCP server."""
2
+
3
+ import json
4
+ import logging
5
+ from typing import List
6
+
7
+ from mcp import Tool
8
+ from mcp.types import TextContent
9
+
10
+ from ..client_manager import D365FOClientManager
11
+ from ...models import SyncStrategy
12
+ from ...sync_models import SyncStatus
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class SyncTools:
18
+ """Sync management tools for the MCP server."""
19
+
20
+ def __init__(self, client_manager: D365FOClientManager):
21
+ """Initialize sync tools.
22
+
23
+ Args:
24
+ client_manager: D365FO client manager instance
25
+ """
26
+ self.client_manager = client_manager
27
+
28
+ def get_tools(self) -> List[Tool]:
29
+ """Get list of sync tools.
30
+
31
+ Returns:
32
+ List of Tool definitions
33
+ """
34
+ return [
35
+ self._get_start_sync_tool(),
36
+ self._get_sync_progress_tool(),
37
+ self._get_cancel_sync_tool(),
38
+ self._get_list_sync_sessions_tool(),
39
+ self._get_sync_history_tool(),
40
+ ]
41
+
42
+ def _get_start_sync_tool(self) -> Tool:
43
+ """Get start sync tool definition."""
44
+ return Tool(
45
+ name="d365fo_start_sync",
46
+ description="Start a metadata synchronization session and return a session ID for tracking progress. This downloads and caches metadata from D365 F&O including entities, schemas, enumerations, and labels.",
47
+ inputSchema={
48
+ "type": "object",
49
+ "properties": {
50
+ "strategy": {
51
+ "type": "string",
52
+ "enum": ["full", "entities_only", "labels_only", "full_without_labels", "sharing_mode", "incremental"],
53
+ "description": "Sync strategy to use. 'full' downloads all metadata, 'entities_only' downloads just entities for quick refresh, 'labels_only' downloads only labels, 'full_without_labels' downloads all metadata except labels, 'sharing_mode' copies from compatible versions, 'incremental' updates only changes (fallback to full).",
54
+ "default": "full_without_labels"
55
+ },
56
+ "global_version_id": {
57
+ "type": "integer",
58
+ "description": "Specific global version ID to sync. If not provided, will detect current version automatically."
59
+ },
60
+ "profile": {
61
+ "type": "string",
62
+ "description": "Configuration profile to use (optional - uses default profile if not specified)"
63
+ }
64
+ },
65
+ "additionalProperties": False,
66
+ },
67
+ )
68
+
69
+ def _get_sync_progress_tool(self) -> Tool:
70
+ """Get sync progress tool definition."""
71
+ return Tool(
72
+ name="d365fo_get_sync_progress",
73
+ description="Get detailed progress information for a specific sync session including current phase, completion percentage, items processed, and estimated time remaining.",
74
+ inputSchema={
75
+ "type": "object",
76
+ "properties": {
77
+ "session_id": {
78
+ "type": "string",
79
+ "description": "Session ID of the sync operation to check progress for"
80
+ },
81
+ "profile": {
82
+ "type": "string",
83
+ "description": "Configuration profile to use (optional - uses default profile if not specified)"
84
+ }
85
+ },
86
+ "required": ["session_id"],
87
+ "additionalProperties": False,
88
+ },
89
+ )
90
+
91
+ def _get_cancel_sync_tool(self) -> Tool:
92
+ """Get cancel sync tool definition."""
93
+ return Tool(
94
+ name="d365fo_cancel_sync",
95
+ description="Cancel a running sync session. Only sessions that are currently running and marked as cancellable can be cancelled.",
96
+ inputSchema={
97
+ "type": "object",
98
+ "properties": {
99
+ "session_id": {
100
+ "type": "string",
101
+ "description": "Session ID of the sync operation to cancel"
102
+ },
103
+ "profile": {
104
+ "type": "string",
105
+ "description": "Configuration profile to use (optional - uses default profile if not specified)"
106
+ }
107
+ },
108
+ "required": ["session_id"],
109
+ "additionalProperties": False,
110
+ },
111
+ )
112
+
113
+ def _get_list_sync_sessions_tool(self) -> Tool:
114
+ """Get list sync sessions tool definition."""
115
+ return Tool(
116
+ name="d365fo_list_sync_sessions",
117
+ description="Get a list of all currently active sync sessions with their status, progress, and details.",
118
+ inputSchema={
119
+ "type": "object",
120
+ "properties": {
121
+ "profile": {
122
+ "type": "string",
123
+ "description": "Configuration profile to use (optional - uses default profile if not specified)"
124
+ }
125
+ },
126
+ "additionalProperties": False,
127
+ },
128
+ )
129
+
130
+ def _get_sync_history_tool(self) -> Tool:
131
+ """Get sync history tool definition."""
132
+ return Tool(
133
+ name="d365fo_get_sync_history",
134
+ description="Get the history of completed sync sessions including success/failure status, duration, and statistics.",
135
+ inputSchema={
136
+ "type": "object",
137
+ "properties": {
138
+ "limit": {
139
+ "type": "integer",
140
+ "description": "Maximum number of historical sessions to return (default: 20, max: 100)",
141
+ "minimum": 1,
142
+ "maximum": 100,
143
+ "default": 20
144
+ },
145
+ "profile": {
146
+ "type": "string",
147
+ "description": "Configuration profile to use (optional - uses default profile if not specified)"
148
+ }
149
+ },
150
+ "additionalProperties": False,
151
+ },
152
+ )
153
+
154
+ async def execute_start_sync(self, arguments: dict) -> List[TextContent]:
155
+ """Execute start sync operation."""
156
+ try:
157
+ profile = arguments.get("profile", "default")
158
+ client = await self.client_manager.get_client(profile)
159
+
160
+ # Initialize metadata first to ensure all components are available
161
+ await client.initialize_metadata()
162
+
163
+ if not hasattr(client, 'sync_session_manager'):
164
+ # Fall back to the original sync manager if session manager not available
165
+ return await self._fallback_start_sync(client, arguments)
166
+
167
+ strategy_str = arguments.get("strategy", "full_without_labels")
168
+ strategy = SyncStrategy(strategy_str)
169
+ global_version_id = arguments.get("global_version_id")
170
+ sync_needed = True
171
+ session_id = None
172
+
173
+ # Auto-detect version if not provided
174
+ if global_version_id is None:
175
+ sync_needed, detected_version_id = await client.metadata_cache.check_version_and_sync()
176
+ if detected_version_id is None:
177
+ raise ValueError("Could not detect global version ID")
178
+ global_version_id = detected_version_id
179
+
180
+ if sync_needed or strategy == SyncStrategy.LABELS_ONLY:
181
+ # Start sync session
182
+ session_id = await client.sync_session_manager.start_sync_session(
183
+ global_version_id=global_version_id,
184
+ strategy=strategy,
185
+ initiated_by="mcp"
186
+ )
187
+
188
+ response = {
189
+ "success": True,
190
+ "session_id": session_id if sync_needed or strategy == SyncStrategy.LABELS_ONLY else None,
191
+ "global_version_id": global_version_id,
192
+ "strategy": strategy_str,
193
+ "message": f"Sync session {session_id} started successfully" if sync_needed or strategy == SyncStrategy.LABELS_ONLY else f"Metadata already up to date at version {global_version_id}, no sync needed",
194
+ "instructions": f"Use d365fo_get_sync_progress with session_id '{session_id}' to monitor progress" if sync_needed or strategy == SyncStrategy.LABELS_ONLY else None
195
+ }
196
+
197
+ return [TextContent(type="text", text=json.dumps(response, indent=2))]
198
+
199
+ except Exception as e:
200
+ logger.error(f"Start sync failed: {e}")
201
+ error_response = {
202
+ "success": False,
203
+ "error": str(e),
204
+ "tool": "d365fo_start_sync",
205
+ "arguments": arguments,
206
+ }
207
+ return [TextContent(type="text", text=json.dumps(error_response, indent=2))]
208
+
209
+ async def execute_get_sync_progress(self, arguments: dict) -> List[TextContent]:
210
+ """Execute get sync progress operation."""
211
+ try:
212
+ profile = arguments.get("profile", "default")
213
+ client = await self.client_manager.get_client(profile)
214
+ session_id = arguments["session_id"]
215
+
216
+ # Initialize metadata to ensure sync session manager is available
217
+ await client.initialize_metadata()
218
+
219
+ if not hasattr(client, 'sync_session_manager'):
220
+ # Fall back to basic progress check
221
+ return await self._fallback_get_progress(client, session_id)
222
+
223
+ # Get session details
224
+ session = client.sync_session_manager.get_sync_session(session_id)
225
+
226
+ if not session:
227
+ error_response = {
228
+ "success": False,
229
+ "error": f"Session {session_id} not found",
230
+ "session_id": session_id
231
+ }
232
+ return [TextContent(type="text", text=json.dumps(error_response, indent=2))]
233
+
234
+ # Convert session to detailed progress response
235
+ response = {
236
+ "success": True,
237
+ "session": session.to_dict(),
238
+ "summary": {
239
+ "status": session.status,
240
+ "progress_percent": round(session.progress_percent, 1),
241
+ "current_phase": session.current_phase,
242
+ "current_activity": session.current_activity,
243
+ "estimated_remaining_seconds": session.estimate_remaining_time(),
244
+ "is_running": session.status == SyncStatus.RUNNING,
245
+ "can_cancel": session.can_cancel
246
+ }
247
+ }
248
+
249
+ return [TextContent(type="text", text=json.dumps(response, indent=2))]
250
+
251
+ except Exception as e:
252
+ logger.error(f"Get sync progress failed: {e}")
253
+ error_response = {
254
+ "success": False,
255
+ "error": str(e),
256
+ "tool": "d365fo_get_sync_progress",
257
+ "arguments": arguments,
258
+ }
259
+ return [TextContent(type="text", text=json.dumps(error_response, indent=2))]
260
+
261
+ async def execute_cancel_sync(self, arguments: dict) -> List[TextContent]:
262
+ """Execute cancel sync operation."""
263
+ try:
264
+ profile = arguments.get("profile", "default")
265
+ client = await self.client_manager.get_client(profile)
266
+ session_id = arguments["session_id"]
267
+
268
+ # Initialize metadata to ensure sync session manager is available
269
+ await client.initialize_metadata()
270
+
271
+ if not hasattr(client, 'sync_session_manager'):
272
+ error_response = {
273
+ "success": False,
274
+ "error": "Sync session management not available in this client version",
275
+ "session_id": session_id
276
+ }
277
+ return [TextContent(type="text", text=json.dumps(error_response, indent=2))]
278
+
279
+ # Cancel session
280
+ cancelled = await client.sync_session_manager.cancel_sync_session(session_id)
281
+
282
+ response = {
283
+ "success": cancelled,
284
+ "session_id": session_id,
285
+ "message": f"Session {session_id} {'cancelled' if cancelled else 'could not be cancelled'}",
286
+ "details": "Session may not be cancellable if already completed or failed"
287
+ }
288
+
289
+ return [TextContent(type="text", text=json.dumps(response, indent=2))]
290
+
291
+ except Exception as e:
292
+ logger.error(f"Cancel sync failed: {e}")
293
+ error_response = {
294
+ "success": False,
295
+ "error": str(e),
296
+ "tool": "d365fo_cancel_sync",
297
+ "arguments": arguments,
298
+ }
299
+ return [TextContent(type="text", text=json.dumps(error_response, indent=2))]
300
+
301
+ async def execute_list_sync_sessions(self, arguments: dict) -> List[TextContent]:
302
+ """Execute list sync sessions operation."""
303
+ try:
304
+ profile = arguments.get("profile", "default")
305
+ client = await self.client_manager.get_client(profile)
306
+
307
+ # Initialize metadata to ensure sync session manager is available
308
+ await client.initialize_metadata()
309
+
310
+ if not hasattr(client, 'sync_session_manager'):
311
+ # Check if legacy sync manager is running
312
+ return await self._fallback_list_sessions(client)
313
+
314
+ # Get active sessions
315
+ active_sessions = client.sync_session_manager.get_active_sessions()
316
+
317
+ response = {
318
+ "success": True,
319
+ "active_sessions": [session.to_dict() for session in active_sessions],
320
+ "total_count": len(active_sessions),
321
+ "running_count": len([s for s in active_sessions if s.status == SyncStatus.RUNNING]),
322
+ "summary": {
323
+ "has_running_sessions": any(s.status == SyncStatus.RUNNING for s in active_sessions),
324
+ "latest_session": active_sessions[-1].to_dict() if active_sessions else None
325
+ }
326
+ }
327
+
328
+ return [TextContent(type="text", text=json.dumps(response, indent=2))]
329
+
330
+ except Exception as e:
331
+ logger.error(f"List sync sessions failed: {e}")
332
+ error_response = {
333
+ "success": False,
334
+ "error": str(e),
335
+ "tool": "d365fo_list_sync_sessions",
336
+ "arguments": arguments,
337
+ }
338
+ return [TextContent(type="text", text=json.dumps(error_response, indent=2))]
339
+
340
+ async def execute_get_sync_history(self, arguments: dict) -> List[TextContent]:
341
+ """Execute get sync history operation."""
342
+ try:
343
+ profile = arguments.get("profile", "default")
344
+ client = await self.client_manager.get_client(profile)
345
+ limit = arguments.get("limit", 20)
346
+
347
+ # Initialize metadata to ensure sync session manager is available
348
+ await client.initialize_metadata()
349
+
350
+ if not hasattr(client, 'sync_session_manager'):
351
+ error_response = {
352
+ "success": False,
353
+ "error": "Sync session management not available in this client version",
354
+ "message": "Upgrade to session-based sync manager to access history"
355
+ }
356
+ return [TextContent(type="text", text=json.dumps(error_response, indent=2))]
357
+
358
+ # Get session history
359
+ history = client.sync_session_manager.get_session_history(limit)
360
+
361
+ response = {
362
+ "success": True,
363
+ "history": [session.to_dict() for session in history],
364
+ "total_count": len(history),
365
+ "summary": {
366
+ "successful_syncs": len([s for s in history if s.status == SyncStatus.COMPLETED]),
367
+ "failed_syncs": len([s for s in history if s.status == SyncStatus.FAILED]),
368
+ "cancelled_syncs": len([s for s in history if s.status == SyncStatus.CANCELLED]),
369
+ "average_duration": sum(s.duration_seconds for s in history if s.duration_seconds) / len([s for s in history if s.duration_seconds]) if history else 0
370
+ }
371
+ }
372
+
373
+ return [TextContent(type="text", text=json.dumps(response, indent=2))]
374
+
375
+ except Exception as e:
376
+ logger.error(f"Get sync history failed: {e}")
377
+ error_response = {
378
+ "success": False,
379
+ "error": str(e),
380
+ "tool": "d365fo_get_sync_history",
381
+ "arguments": arguments,
382
+ }
383
+ return [TextContent(type="text", text=json.dumps(error_response, indent=2))]
384
+
385
+ async def _fallback_start_sync(self, client, arguments: dict) -> List[TextContent]:
386
+ """Fallback to original sync manager for starting sync."""
387
+ strategy_str = arguments.get("strategy", "full")
388
+ strategy = SyncStrategy(strategy_str)
389
+ global_version_id = arguments.get("global_version_id")
390
+
391
+ # Auto-detect version if not provided
392
+ if global_version_id is None:
393
+ sync_needed, detected_version_id = await client.metadata_cache.check_version_and_sync()
394
+ if detected_version_id is None:
395
+ raise ValueError("Could not detect global version ID")
396
+ global_version_id = detected_version_id
397
+
398
+ # Check if sync already running
399
+ if hasattr(client, 'smart_sync_manager') and client.smart_sync_manager.is_syncing():
400
+ error_response = {
401
+ "success": False,
402
+ "error": "Sync already in progress",
403
+ "message": "Wait for current sync to complete before starting a new one"
404
+ }
405
+ return [TextContent(type="text", text=json.dumps(error_response, indent=2))]
406
+
407
+ # Start sync without session tracking
408
+ import asyncio
409
+ sync_task = asyncio.create_task(
410
+ client.smart_sync_manager.sync_metadata(global_version_id, strategy)
411
+ )
412
+
413
+ response = {
414
+ "success": True,
415
+ "session_id": f"legacy_{global_version_id}_{strategy_str}",
416
+ "global_version_id": global_version_id,
417
+ "strategy": strategy_str,
418
+ "message": "Sync started (legacy mode)",
419
+ "instructions": "Use d365fo_get_sync_progress to monitor progress"
420
+ }
421
+
422
+ return [TextContent(type="text", text=json.dumps(response, indent=2))]
423
+
424
+ async def _fallback_get_progress(self, client, session_id: str) -> List[TextContent]:
425
+ """Fallback progress check for original sync manager."""
426
+ if hasattr(client, 'smart_sync_manager'):
427
+ progress = client.smart_sync_manager.get_sync_progress()
428
+ is_syncing = client.smart_sync_manager.is_syncing()
429
+
430
+ if progress:
431
+ response = {
432
+ "success": True,
433
+ "session_id": session_id,
434
+ "legacy_mode": True,
435
+ "progress": {
436
+ "global_version_id": progress.global_version_id,
437
+ "strategy": progress.strategy,
438
+ "phase": progress.phase,
439
+ "total_steps": progress.total_steps,
440
+ "completed_steps": progress.completed_steps,
441
+ "current_operation": progress.current_operation,
442
+ "start_time": progress.start_time.isoformat() if progress.start_time else None,
443
+ "estimated_completion": progress.estimated_completion.isoformat() if progress.estimated_completion else None,
444
+ "error": progress.error,
445
+ "is_running": is_syncing
446
+ }
447
+ }
448
+ else:
449
+ response = {
450
+ "success": True,
451
+ "session_id": session_id,
452
+ "legacy_mode": True,
453
+ "message": "No sync currently running",
454
+ "is_running": False
455
+ }
456
+
457
+ return [TextContent(type="text", text=json.dumps(response, indent=2))]
458
+
459
+ error_response = {
460
+ "success": False,
461
+ "error": "No sync manager available",
462
+ "session_id": session_id
463
+ }
464
+ return [TextContent(type="text", text=json.dumps(error_response, indent=2))]
465
+
466
+ async def _fallback_list_sessions(self, client) -> List[TextContent]:
467
+ """Fallback session list for original sync manager."""
468
+ if hasattr(client, 'smart_sync_manager'):
469
+ is_syncing = client.smart_sync_manager.is_syncing()
470
+ progress = client.smart_sync_manager.get_sync_progress()
471
+
472
+ active_sessions = []
473
+ if is_syncing and progress:
474
+ session = {
475
+ "session_id": f"legacy_{progress.global_version_id}_{progress.strategy}",
476
+ "global_version_id": progress.global_version_id,
477
+ "strategy": progress.strategy,
478
+ "status": "running",
479
+ "start_time": progress.start_time.isoformat() if progress.start_time else None,
480
+ "progress_percent": (progress.completed_steps / progress.total_steps * 100) if progress.total_steps > 0 else 0,
481
+ "current_phase": progress.phase,
482
+ "current_activity": progress.current_operation,
483
+ "initiated_by": "legacy",
484
+ "legacy_mode": True
485
+ }
486
+ active_sessions.append(session)
487
+
488
+ response = {
489
+ "success": True,
490
+ "active_sessions": active_sessions,
491
+ "total_count": len(active_sessions),
492
+ "running_count": len(active_sessions),
493
+ "legacy_mode": True,
494
+ "message": "Using legacy sync manager - limited session information available"
495
+ }
496
+
497
+ return [TextContent(type="text", text=json.dumps(response, indent=2))]
498
+
499
+ error_response = {
500
+ "success": False,
501
+ "error": "No sync manager available"
502
+ }
503
+ return [TextContent(type="text", text=json.dumps(error_response, indent=2))]
@@ -360,6 +360,73 @@ class MetadataAPIOperations:
360
360
 
361
361
  return entities
362
362
 
363
+ async def get_all_data_entities(self) -> List[DataEntityInfo]:
364
+ """Get all data entities without pagination limits
365
+
366
+ Returns:
367
+ List of all data entities
368
+ """
369
+ try:
370
+ # Use the search method without any filters to get all entities
371
+ # We could also use get_data_entities() with no top limit, but search handles pagination better
372
+ entities = []
373
+
374
+ # Get all entities by calling the raw endpoint with no top limit
375
+ options = QueryOptions()
376
+ options.select = [
377
+ "Name",
378
+ "PublicEntityName",
379
+ "PublicCollectionName",
380
+ "LabelId",
381
+ "DataServiceEnabled",
382
+ "DataManagementEnabled",
383
+ "EntityCategory",
384
+ "IsReadOnly"
385
+ ]
386
+
387
+ data = await self.get_data_entities(options)
388
+
389
+ for item in data.get("value", []):
390
+ # Convert entity category string to enum
391
+ entity_category_str = item.get("EntityCategory")
392
+ entity_category = None
393
+ if entity_category_str:
394
+ try:
395
+ # Try to map the string to the enum
396
+ if entity_category_str == "Master":
397
+ entity_category = EntityCategory.MASTER
398
+ elif entity_category_str == "Transaction":
399
+ entity_category = EntityCategory.TRANSACTION
400
+ elif entity_category_str == "Configuration":
401
+ entity_category = EntityCategory.CONFIGURATION
402
+ elif entity_category_str == "Reference":
403
+ entity_category = EntityCategory.REFERENCE
404
+ elif entity_category_str == "Document":
405
+ entity_category = EntityCategory.DOCUMENT
406
+ elif entity_category_str == "Parameters":
407
+ entity_category = EntityCategory.PARAMETERS
408
+ # If no match, leave as None
409
+ except Exception:
410
+ entity_category = None
411
+
412
+ entity = DataEntityInfo(
413
+ name=item.get("Name", ""),
414
+ public_entity_name=item.get("PublicEntityName", ""),
415
+ public_collection_name=item.get("PublicCollectionName", ""),
416
+ label_id=item.get("LabelId"),
417
+ data_service_enabled=item.get("DataServiceEnabled", False),
418
+ data_management_enabled=item.get("DataManagementEnabled", False),
419
+ entity_category=entity_category,
420
+ is_read_only=item.get("IsReadOnly", False),
421
+ )
422
+ entities.append(entity)
423
+
424
+ return entities
425
+
426
+ except Exception as e:
427
+ logger.error(f"Error getting all data entities: {e}")
428
+ raise
429
+
363
430
  async def get_data_entity_info(
364
431
  self, entity_name: str, resolve_labels: bool = True, language: str = "en-US"
365
432
  ) -> Optional[DataEntityInfo]:
@@ -213,17 +213,19 @@ class MetadataCacheV2:
213
213
  entities: List of data entity information
214
214
  """
215
215
  async with aiosqlite.connect(self.db_path) as db:
216
- # Clear existing entities for this version
217
- await db.execute(
218
- "DELETE FROM data_entities WHERE global_version_id = ?",
219
- (global_version_id,),
220
- )
216
+
221
217
 
222
218
  # Insert new entities with label processing
223
219
  for entity in entities:
224
220
  # Process label fallback for this entity
225
221
  processed_label_text = process_label_fallback(entity.label_id, entity.label_text)
226
-
222
+
223
+ # Clear existing entity for this version
224
+ await db.execute(
225
+ "DELETE FROM data_entities WHERE global_version_id = ? and name = ?",
226
+ (global_version_id, entity.name,),
227
+ )
228
+
227
229
  await db.execute(
228
230
  """INSERT INTO data_entities
229
231
  (global_version_id, name, public_entity_name, public_collection_name,
@@ -245,7 +247,7 @@ class MetadataCacheV2:
245
247
  )
246
248
 
247
249
  await db.commit()
248
- logger.info(
250
+ logger.debug(
249
251
  f"Stored {len(entities)} data entities for version {global_version_id}"
250
252
  )
251
253
 
@@ -1357,7 +1359,7 @@ class MetadataCacheV2:
1357
1359
  )
1358
1360
  await db.commit()
1359
1361
 
1360
- logger.info(
1362
+ logger.debug(
1361
1363
  f"Batch cached {len(labels)} labels for version {global_version_id}"
1362
1364
  )
1363
1365
 
@@ -1522,7 +1524,7 @@ class MetadataCacheV2:
1522
1524
  stats["current_version"] = {
1523
1525
  "global_version_id": version_info.id,
1524
1526
  "version_hash": version_info.version_hash,
1525
- "modules_count": len(version_info.sample_modules),
1527
+ "modules_count": len(version_info.modules),
1526
1528
  "reference_count": version_info.reference_count,
1527
1529
  }
1528
1530
 
@@ -325,13 +325,11 @@ class GlobalVersionManager:
325
325
  first_seen_at=datetime.fromisoformat(row[3]),
326
326
  last_used_at=datetime.fromisoformat(row[4]),
327
327
  reference_count=row[5],
328
- sample_modules=(
329
- modules[:10] if modules else []
330
- ), # Use first 10 modules as sample
328
+ modules=modules
331
329
  )
332
330
 
333
331
  async def find_compatible_versions(
334
- self, modules: List[ModuleVersionInfo], exact_match: bool = False
332
+ self, modules: List[ModuleVersionInfo], exact_match: bool = True
335
333
  ) -> List[GlobalVersionInfo]:
336
334
  """Find compatible global versions
337
335
 
@@ -656,7 +656,7 @@ class SmartSyncManagerV2:
656
656
 
657
657
  # Check for compatible versions (sharing opportunity)
658
658
  compatible_versions = await self.version_manager.find_compatible_versions(
659
- version_info.sample_modules, exact_match=True
659
+ version_info.modules, exact_match=True
660
660
  )
661
661
 
662
662
  for version in compatible_versions: