basic-memory 0.13.0b3__py3-none-any.whl → 0.13.0b5__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.
Potentially problematic release.
This version of basic-memory might be problematic. Click here for more details.
- basic_memory/__init__.py +1 -7
- basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +104 -0
- basic_memory/api/routers/knowledge_router.py +13 -0
- basic_memory/api/routers/memory_router.py +3 -4
- basic_memory/api/routers/project_router.py +9 -9
- basic_memory/api/routers/prompt_router.py +2 -2
- basic_memory/cli/commands/project.py +2 -2
- basic_memory/cli/commands/status.py +1 -1
- basic_memory/cli/commands/sync.py +1 -1
- basic_memory/mcp/prompts/__init__.py +2 -0
- basic_memory/mcp/prompts/sync_status.py +116 -0
- basic_memory/mcp/server.py +6 -6
- basic_memory/mcp/tools/__init__.py +4 -0
- basic_memory/mcp/tools/build_context.py +32 -7
- basic_memory/mcp/tools/canvas.py +2 -1
- basic_memory/mcp/tools/delete_note.py +159 -4
- basic_memory/mcp/tools/edit_note.py +17 -11
- basic_memory/mcp/tools/move_note.py +252 -40
- basic_memory/mcp/tools/project_management.py +35 -3
- basic_memory/mcp/tools/read_note.py +9 -2
- basic_memory/mcp/tools/search.py +180 -8
- basic_memory/mcp/tools/sync_status.py +254 -0
- basic_memory/mcp/tools/utils.py +47 -0
- basic_memory/mcp/tools/view_note.py +66 -0
- basic_memory/mcp/tools/write_note.py +13 -2
- basic_memory/models/project.py +1 -3
- basic_memory/repository/search_repository.py +99 -26
- basic_memory/schemas/base.py +33 -5
- basic_memory/schemas/memory.py +58 -1
- basic_memory/services/entity_service.py +4 -4
- basic_memory/services/initialization.py +32 -5
- basic_memory/services/link_resolver.py +20 -5
- basic_memory/services/migration_service.py +168 -0
- basic_memory/services/project_service.py +157 -56
- basic_memory/services/sync_status_service.py +181 -0
- basic_memory/sync/sync_service.py +55 -2
- {basic_memory-0.13.0b3.dist-info → basic_memory-0.13.0b5.dist-info}/METADATA +2 -2
- {basic_memory-0.13.0b3.dist-info → basic_memory-0.13.0b5.dist-info}/RECORD +41 -35
- {basic_memory-0.13.0b3.dist-info → basic_memory-0.13.0b5.dist-info}/WHEEL +0 -0
- {basic_memory-0.13.0b3.dist-info → basic_memory-0.13.0b5.dist-info}/entry_points.txt +0 -0
- {basic_memory-0.13.0b3.dist-info → basic_memory-0.13.0b5.dist-info}/licenses/LICENSE +0 -0
|
@@ -67,12 +67,13 @@ class ProjectService:
|
|
|
67
67
|
"""Get the file path for a project by name."""
|
|
68
68
|
return await self.repository.get_by_name(name)
|
|
69
69
|
|
|
70
|
-
async def add_project(self, name: str, path: str) -> None:
|
|
70
|
+
async def add_project(self, name: str, path: str, set_default: bool = False) -> None:
|
|
71
71
|
"""Add a new project to the configuration and database.
|
|
72
72
|
|
|
73
73
|
Args:
|
|
74
74
|
name: The name of the project
|
|
75
75
|
path: The file path to the project directory
|
|
76
|
+
set_default: Whether to set this project as the default
|
|
76
77
|
|
|
77
78
|
Raises:
|
|
78
79
|
ValueError: If the project already exists
|
|
@@ -92,9 +93,16 @@ class ProjectService:
|
|
|
92
93
|
"path": resolved_path,
|
|
93
94
|
"permalink": generate_permalink(project_config.name),
|
|
94
95
|
"is_active": True,
|
|
95
|
-
|
|
96
|
+
# Don't set is_default=False to avoid UNIQUE constraint issues
|
|
97
|
+
# Let it default to NULL, only set to True when explicitly making default
|
|
96
98
|
}
|
|
97
|
-
await self.repository.create(project_data)
|
|
99
|
+
created_project = await self.repository.create(project_data)
|
|
100
|
+
|
|
101
|
+
# If this should be the default project, ensure only one default exists
|
|
102
|
+
if set_default:
|
|
103
|
+
await self.repository.set_as_default(created_project.id)
|
|
104
|
+
config_manager.set_default_project(name)
|
|
105
|
+
logger.info(f"Project '{name}' set as default")
|
|
98
106
|
|
|
99
107
|
logger.info(f"Project '{name}' added at {resolved_path}")
|
|
100
108
|
|
|
@@ -144,6 +152,47 @@ class ProjectService:
|
|
|
144
152
|
|
|
145
153
|
logger.info(f"Project '{name}' set as default in configuration and database")
|
|
146
154
|
|
|
155
|
+
async def _ensure_single_default_project(self) -> None:
|
|
156
|
+
"""Ensure only one project has is_default=True.
|
|
157
|
+
|
|
158
|
+
This method validates the database state and fixes any issues where
|
|
159
|
+
multiple projects might have is_default=True or no project is marked as default.
|
|
160
|
+
"""
|
|
161
|
+
if not self.repository:
|
|
162
|
+
raise ValueError(
|
|
163
|
+
"Repository is required for _ensure_single_default_project"
|
|
164
|
+
) # pragma: no cover
|
|
165
|
+
|
|
166
|
+
# Get all projects with is_default=True
|
|
167
|
+
db_projects = await self.repository.find_all()
|
|
168
|
+
default_projects = [p for p in db_projects if p.is_default is True]
|
|
169
|
+
|
|
170
|
+
if len(default_projects) > 1: # pragma: no cover
|
|
171
|
+
# Multiple defaults found - fix by keeping the first one and clearing others
|
|
172
|
+
# This is defensive code that should rarely execute due to business logic enforcement
|
|
173
|
+
logger.warning( # pragma: no cover
|
|
174
|
+
f"Found {len(default_projects)} projects with is_default=True, fixing..."
|
|
175
|
+
)
|
|
176
|
+
keep_default = default_projects[0] # pragma: no cover
|
|
177
|
+
|
|
178
|
+
# Clear all defaults first, then set only the first one as default
|
|
179
|
+
await self.repository.set_as_default(keep_default.id) # pragma: no cover
|
|
180
|
+
|
|
181
|
+
logger.info(
|
|
182
|
+
f"Fixed default project conflicts, kept '{keep_default.name}' as default"
|
|
183
|
+
) # pragma: no cover
|
|
184
|
+
|
|
185
|
+
elif len(default_projects) == 0: # pragma: no cover
|
|
186
|
+
# No default project - set the config default as default
|
|
187
|
+
# This is defensive code for edge cases where no default exists
|
|
188
|
+
config_default = config_manager.default_project # pragma: no cover
|
|
189
|
+
config_project = await self.repository.get_by_name(config_default) # pragma: no cover
|
|
190
|
+
if config_project: # pragma: no cover
|
|
191
|
+
await self.repository.set_as_default(config_project.id) # pragma: no cover
|
|
192
|
+
logger.info(
|
|
193
|
+
f"Set '{config_default}' as default project (was missing)"
|
|
194
|
+
) # pragma: no cover
|
|
195
|
+
|
|
147
196
|
async def synchronize_projects(self) -> None: # pragma: no cover
|
|
148
197
|
"""Synchronize projects between database and configuration.
|
|
149
198
|
|
|
@@ -172,7 +221,7 @@ class ProjectService:
|
|
|
172
221
|
"path": path,
|
|
173
222
|
"permalink": name.lower().replace(" ", "-"),
|
|
174
223
|
"is_active": True,
|
|
175
|
-
|
|
224
|
+
# Don't set is_default here - let the enforcement logic handle it
|
|
176
225
|
}
|
|
177
226
|
await self.repository.create(project_data)
|
|
178
227
|
|
|
@@ -182,19 +231,23 @@ class ProjectService:
|
|
|
182
231
|
logger.info(f"Adding project '{name}' to configuration")
|
|
183
232
|
config_manager.add_project(name, project.path)
|
|
184
233
|
|
|
185
|
-
#
|
|
186
|
-
|
|
234
|
+
# Ensure database default project state is consistent
|
|
235
|
+
await self._ensure_single_default_project()
|
|
236
|
+
|
|
237
|
+
# Make sure default project is synchronized between config and database
|
|
238
|
+
db_default = await self.repository.get_default_project()
|
|
187
239
|
config_default = config_manager.default_project
|
|
188
240
|
|
|
189
241
|
if db_default and db_default.name != config_default:
|
|
190
242
|
# Update config to match DB default
|
|
191
243
|
logger.info(f"Updating default project in config to '{db_default.name}'")
|
|
192
244
|
config_manager.set_default_project(db_default.name)
|
|
193
|
-
elif not db_default and config_default
|
|
194
|
-
# Update DB to match config default
|
|
195
|
-
|
|
196
|
-
project
|
|
197
|
-
|
|
245
|
+
elif not db_default and config_default:
|
|
246
|
+
# Update DB to match config default (if the project exists)
|
|
247
|
+
project = await self.repository.get_by_name(config_default)
|
|
248
|
+
if project:
|
|
249
|
+
logger.info(f"Updating default project in database to '{config_default}'")
|
|
250
|
+
await self.repository.set_as_default(project.id)
|
|
198
251
|
|
|
199
252
|
logger.info("Project synchronization complete")
|
|
200
253
|
|
|
@@ -258,8 +311,11 @@ class ProjectService:
|
|
|
258
311
|
f"Changed default project to '{new_default.name}' as '{name}' was deactivated"
|
|
259
312
|
)
|
|
260
313
|
|
|
261
|
-
async def get_project_info(self) -> ProjectInfoResponse:
|
|
262
|
-
"""Get comprehensive information about the
|
|
314
|
+
async def get_project_info(self, project_name: Optional[str] = None) -> ProjectInfoResponse:
|
|
315
|
+
"""Get comprehensive information about the specified Basic Memory project.
|
|
316
|
+
|
|
317
|
+
Args:
|
|
318
|
+
project_name: Name of the project to get info for. If None, uses the current config project.
|
|
263
319
|
|
|
264
320
|
Returns:
|
|
265
321
|
Comprehensive project information and statistics
|
|
@@ -267,19 +323,27 @@ class ProjectService:
|
|
|
267
323
|
if not self.repository: # pragma: no cover
|
|
268
324
|
raise ValueError("Repository is required for get_project_info")
|
|
269
325
|
|
|
270
|
-
#
|
|
271
|
-
|
|
326
|
+
# Use specified project or fall back to config project
|
|
327
|
+
project_name = project_name or config.project
|
|
328
|
+
# Get project path from configuration
|
|
329
|
+
project_path = config_manager.projects.get(project_name)
|
|
330
|
+
if not project_path: # pragma: no cover
|
|
331
|
+
raise ValueError(f"Project '{project_name}' not found in configuration")
|
|
332
|
+
|
|
333
|
+
# Get project from database to get project_id
|
|
334
|
+
db_project = await self.repository.get_by_name(project_name)
|
|
335
|
+
if not db_project: # pragma: no cover
|
|
336
|
+
raise ValueError(f"Project '{project_name}' not found in database")
|
|
337
|
+
|
|
338
|
+
# Get statistics for the specified project
|
|
339
|
+
statistics = await self.get_statistics(db_project.id)
|
|
272
340
|
|
|
273
|
-
# Get activity metrics
|
|
274
|
-
activity = await self.get_activity_metrics()
|
|
341
|
+
# Get activity metrics for the specified project
|
|
342
|
+
activity = await self.get_activity_metrics(db_project.id)
|
|
275
343
|
|
|
276
344
|
# Get system status
|
|
277
345
|
system = self.get_system_status()
|
|
278
346
|
|
|
279
|
-
# Get current project information from config
|
|
280
|
-
project_name = config.project
|
|
281
|
-
project_path = str(config.home)
|
|
282
|
-
|
|
283
347
|
# Get enhanced project information from database
|
|
284
348
|
db_projects = await self.repository.get_active_projects()
|
|
285
349
|
db_projects_by_name = {p.name: p for p in db_projects}
|
|
@@ -310,60 +374,85 @@ class ProjectService:
|
|
|
310
374
|
system=system,
|
|
311
375
|
)
|
|
312
376
|
|
|
313
|
-
async def get_statistics(self) -> ProjectStatistics:
|
|
314
|
-
"""Get statistics about the
|
|
377
|
+
async def get_statistics(self, project_id: int) -> ProjectStatistics:
|
|
378
|
+
"""Get statistics about the specified project.
|
|
379
|
+
|
|
380
|
+
Args:
|
|
381
|
+
project_id: ID of the project to get statistics for (required).
|
|
382
|
+
"""
|
|
315
383
|
if not self.repository: # pragma: no cover
|
|
316
384
|
raise ValueError("Repository is required for get_statistics")
|
|
317
385
|
|
|
318
386
|
# Get basic counts
|
|
319
387
|
entity_count_result = await self.repository.execute_query(
|
|
320
|
-
text("SELECT COUNT(*) FROM entity")
|
|
388
|
+
text("SELECT COUNT(*) FROM entity WHERE project_id = :project_id"),
|
|
389
|
+
{"project_id": project_id},
|
|
321
390
|
)
|
|
322
391
|
total_entities = entity_count_result.scalar() or 0
|
|
323
392
|
|
|
324
393
|
observation_count_result = await self.repository.execute_query(
|
|
325
|
-
text(
|
|
394
|
+
text(
|
|
395
|
+
"SELECT COUNT(*) FROM observation o JOIN entity e ON o.entity_id = e.id WHERE e.project_id = :project_id"
|
|
396
|
+
),
|
|
397
|
+
{"project_id": project_id},
|
|
326
398
|
)
|
|
327
399
|
total_observations = observation_count_result.scalar() or 0
|
|
328
400
|
|
|
329
401
|
relation_count_result = await self.repository.execute_query(
|
|
330
|
-
text(
|
|
402
|
+
text(
|
|
403
|
+
"SELECT COUNT(*) FROM relation r JOIN entity e ON r.from_id = e.id WHERE e.project_id = :project_id"
|
|
404
|
+
),
|
|
405
|
+
{"project_id": project_id},
|
|
331
406
|
)
|
|
332
407
|
total_relations = relation_count_result.scalar() or 0
|
|
333
408
|
|
|
334
409
|
unresolved_count_result = await self.repository.execute_query(
|
|
335
|
-
text(
|
|
410
|
+
text(
|
|
411
|
+
"SELECT COUNT(*) FROM relation r JOIN entity e ON r.from_id = e.id WHERE r.to_id IS NULL AND e.project_id = :project_id"
|
|
412
|
+
),
|
|
413
|
+
{"project_id": project_id},
|
|
336
414
|
)
|
|
337
415
|
total_unresolved = unresolved_count_result.scalar() or 0
|
|
338
416
|
|
|
339
417
|
# Get entity counts by type
|
|
340
418
|
entity_types_result = await self.repository.execute_query(
|
|
341
|
-
text(
|
|
419
|
+
text(
|
|
420
|
+
"SELECT entity_type, COUNT(*) FROM entity WHERE project_id = :project_id GROUP BY entity_type"
|
|
421
|
+
),
|
|
422
|
+
{"project_id": project_id},
|
|
342
423
|
)
|
|
343
424
|
entity_types = {row[0]: row[1] for row in entity_types_result.fetchall()}
|
|
344
425
|
|
|
345
426
|
# Get observation counts by category
|
|
346
427
|
category_result = await self.repository.execute_query(
|
|
347
|
-
text(
|
|
428
|
+
text(
|
|
429
|
+
"SELECT o.category, COUNT(*) FROM observation o JOIN entity e ON o.entity_id = e.id WHERE e.project_id = :project_id GROUP BY o.category"
|
|
430
|
+
),
|
|
431
|
+
{"project_id": project_id},
|
|
348
432
|
)
|
|
349
433
|
observation_categories = {row[0]: row[1] for row in category_result.fetchall()}
|
|
350
434
|
|
|
351
435
|
# Get relation counts by type
|
|
352
436
|
relation_types_result = await self.repository.execute_query(
|
|
353
|
-
text(
|
|
437
|
+
text(
|
|
438
|
+
"SELECT r.relation_type, COUNT(*) FROM relation r JOIN entity e ON r.from_id = e.id WHERE e.project_id = :project_id GROUP BY r.relation_type"
|
|
439
|
+
),
|
|
440
|
+
{"project_id": project_id},
|
|
354
441
|
)
|
|
355
442
|
relation_types = {row[0]: row[1] for row in relation_types_result.fetchall()}
|
|
356
443
|
|
|
357
|
-
# Find most connected entities (most outgoing relations)
|
|
444
|
+
# Find most connected entities (most outgoing relations) - project filtered
|
|
358
445
|
connected_result = await self.repository.execute_query(
|
|
359
446
|
text("""
|
|
360
|
-
SELECT e.id, e.title, e.permalink, COUNT(r.id) AS relation_count, file_path
|
|
447
|
+
SELECT e.id, e.title, e.permalink, COUNT(r.id) AS relation_count, e.file_path
|
|
361
448
|
FROM entity e
|
|
362
449
|
JOIN relation r ON e.id = r.from_id
|
|
450
|
+
WHERE e.project_id = :project_id
|
|
363
451
|
GROUP BY e.id
|
|
364
452
|
ORDER BY relation_count DESC
|
|
365
453
|
LIMIT 10
|
|
366
|
-
""")
|
|
454
|
+
"""),
|
|
455
|
+
{"project_id": project_id},
|
|
367
456
|
)
|
|
368
457
|
most_connected = [
|
|
369
458
|
{
|
|
@@ -376,15 +465,16 @@ class ProjectService:
|
|
|
376
465
|
for row in connected_result.fetchall()
|
|
377
466
|
]
|
|
378
467
|
|
|
379
|
-
# Count isolated entities (no relations)
|
|
468
|
+
# Count isolated entities (no relations) - project filtered
|
|
380
469
|
isolated_result = await self.repository.execute_query(
|
|
381
470
|
text("""
|
|
382
471
|
SELECT COUNT(e.id)
|
|
383
472
|
FROM entity e
|
|
384
473
|
LEFT JOIN relation r1 ON e.id = r1.from_id
|
|
385
474
|
LEFT JOIN relation r2 ON e.id = r2.to_id
|
|
386
|
-
WHERE r1.id IS NULL AND r2.id IS NULL
|
|
387
|
-
""")
|
|
475
|
+
WHERE e.project_id = :project_id AND r1.id IS NULL AND r2.id IS NULL
|
|
476
|
+
"""),
|
|
477
|
+
{"project_id": project_id},
|
|
388
478
|
)
|
|
389
479
|
isolated_count = isolated_result.scalar() or 0
|
|
390
480
|
|
|
@@ -400,19 +490,25 @@ class ProjectService:
|
|
|
400
490
|
isolated_entities=isolated_count,
|
|
401
491
|
)
|
|
402
492
|
|
|
403
|
-
async def get_activity_metrics(self) -> ActivityMetrics:
|
|
404
|
-
"""Get activity metrics for the
|
|
493
|
+
async def get_activity_metrics(self, project_id: int) -> ActivityMetrics:
|
|
494
|
+
"""Get activity metrics for the specified project.
|
|
495
|
+
|
|
496
|
+
Args:
|
|
497
|
+
project_id: ID of the project to get activity metrics for (required).
|
|
498
|
+
"""
|
|
405
499
|
if not self.repository: # pragma: no cover
|
|
406
500
|
raise ValueError("Repository is required for get_activity_metrics")
|
|
407
501
|
|
|
408
|
-
# Get recently created entities
|
|
502
|
+
# Get recently created entities (project filtered)
|
|
409
503
|
created_result = await self.repository.execute_query(
|
|
410
504
|
text("""
|
|
411
505
|
SELECT id, title, permalink, entity_type, created_at, file_path
|
|
412
506
|
FROM entity
|
|
507
|
+
WHERE project_id = :project_id
|
|
413
508
|
ORDER BY created_at DESC
|
|
414
509
|
LIMIT 10
|
|
415
|
-
""")
|
|
510
|
+
"""),
|
|
511
|
+
{"project_id": project_id},
|
|
416
512
|
)
|
|
417
513
|
recently_created = [
|
|
418
514
|
{
|
|
@@ -426,14 +522,16 @@ class ProjectService:
|
|
|
426
522
|
for row in created_result.fetchall()
|
|
427
523
|
]
|
|
428
524
|
|
|
429
|
-
# Get recently updated entities
|
|
525
|
+
# Get recently updated entities (project filtered)
|
|
430
526
|
updated_result = await self.repository.execute_query(
|
|
431
527
|
text("""
|
|
432
528
|
SELECT id, title, permalink, entity_type, updated_at, file_path
|
|
433
529
|
FROM entity
|
|
530
|
+
WHERE project_id = :project_id
|
|
434
531
|
ORDER BY updated_at DESC
|
|
435
532
|
LIMIT 10
|
|
436
|
-
""")
|
|
533
|
+
"""),
|
|
534
|
+
{"project_id": project_id},
|
|
437
535
|
)
|
|
438
536
|
recently_updated = [
|
|
439
537
|
{
|
|
@@ -454,47 +552,50 @@ class ProjectService:
|
|
|
454
552
|
now.year - (1 if now.month <= 6 else 0), ((now.month - 6) % 12) or 12, 1
|
|
455
553
|
)
|
|
456
554
|
|
|
457
|
-
# Query for monthly entity creation
|
|
555
|
+
# Query for monthly entity creation (project filtered)
|
|
458
556
|
entity_growth_result = await self.repository.execute_query(
|
|
459
|
-
text(
|
|
557
|
+
text("""
|
|
460
558
|
SELECT
|
|
461
559
|
strftime('%Y-%m', created_at) AS month,
|
|
462
560
|
COUNT(*) AS count
|
|
463
561
|
FROM entity
|
|
464
|
-
WHERE created_at >=
|
|
562
|
+
WHERE created_at >= :six_months_ago AND project_id = :project_id
|
|
465
563
|
GROUP BY month
|
|
466
564
|
ORDER BY month
|
|
467
|
-
""")
|
|
565
|
+
"""),
|
|
566
|
+
{"six_months_ago": six_months_ago.isoformat(), "project_id": project_id},
|
|
468
567
|
)
|
|
469
568
|
entity_growth = {row[0]: row[1] for row in entity_growth_result.fetchall()}
|
|
470
569
|
|
|
471
|
-
# Query for monthly observation creation
|
|
570
|
+
# Query for monthly observation creation (project filtered)
|
|
472
571
|
observation_growth_result = await self.repository.execute_query(
|
|
473
|
-
text(
|
|
572
|
+
text("""
|
|
474
573
|
SELECT
|
|
475
|
-
strftime('%Y-%m', created_at) AS month,
|
|
574
|
+
strftime('%Y-%m', entity.created_at) AS month,
|
|
476
575
|
COUNT(*) AS count
|
|
477
576
|
FROM observation
|
|
478
577
|
INNER JOIN entity ON observation.entity_id = entity.id
|
|
479
|
-
WHERE entity.created_at >=
|
|
578
|
+
WHERE entity.created_at >= :six_months_ago AND entity.project_id = :project_id
|
|
480
579
|
GROUP BY month
|
|
481
580
|
ORDER BY month
|
|
482
|
-
""")
|
|
581
|
+
"""),
|
|
582
|
+
{"six_months_ago": six_months_ago.isoformat(), "project_id": project_id},
|
|
483
583
|
)
|
|
484
584
|
observation_growth = {row[0]: row[1] for row in observation_growth_result.fetchall()}
|
|
485
585
|
|
|
486
|
-
# Query for monthly relation creation
|
|
586
|
+
# Query for monthly relation creation (project filtered)
|
|
487
587
|
relation_growth_result = await self.repository.execute_query(
|
|
488
|
-
text(
|
|
588
|
+
text("""
|
|
489
589
|
SELECT
|
|
490
|
-
strftime('%Y-%m', created_at) AS month,
|
|
590
|
+
strftime('%Y-%m', entity.created_at) AS month,
|
|
491
591
|
COUNT(*) AS count
|
|
492
592
|
FROM relation
|
|
493
593
|
INNER JOIN entity ON relation.from_id = entity.id
|
|
494
|
-
WHERE entity.created_at >=
|
|
594
|
+
WHERE entity.created_at >= :six_months_ago AND entity.project_id = :project_id
|
|
495
595
|
GROUP BY month
|
|
496
596
|
ORDER BY month
|
|
497
|
-
""")
|
|
597
|
+
"""),
|
|
598
|
+
{"six_months_ago": six_months_ago.isoformat(), "project_id": project_id},
|
|
498
599
|
)
|
|
499
600
|
relation_growth = {row[0]: row[1] for row in relation_growth_result.fetchall()}
|
|
500
601
|
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Simple sync status tracking service."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from typing import Dict, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SyncStatus(Enum):
|
|
9
|
+
"""Status of sync operations."""
|
|
10
|
+
|
|
11
|
+
IDLE = "idle"
|
|
12
|
+
SCANNING = "scanning"
|
|
13
|
+
SYNCING = "syncing"
|
|
14
|
+
COMPLETED = "completed"
|
|
15
|
+
FAILED = "failed"
|
|
16
|
+
WATCHING = "watching"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class ProjectSyncStatus:
|
|
21
|
+
"""Sync status for a single project."""
|
|
22
|
+
|
|
23
|
+
project_name: str
|
|
24
|
+
status: SyncStatus
|
|
25
|
+
message: str = ""
|
|
26
|
+
files_total: int = 0
|
|
27
|
+
files_processed: int = 0
|
|
28
|
+
error: Optional[str] = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SyncStatusTracker:
|
|
32
|
+
"""Global tracker for all sync operations."""
|
|
33
|
+
|
|
34
|
+
def __init__(self):
|
|
35
|
+
self._project_statuses: Dict[str, ProjectSyncStatus] = {}
|
|
36
|
+
self._global_status: SyncStatus = SyncStatus.IDLE
|
|
37
|
+
|
|
38
|
+
def start_project_sync(self, project_name: str, files_total: int = 0) -> None:
|
|
39
|
+
"""Start tracking sync for a project."""
|
|
40
|
+
self._project_statuses[project_name] = ProjectSyncStatus(
|
|
41
|
+
project_name=project_name,
|
|
42
|
+
status=SyncStatus.SCANNING,
|
|
43
|
+
message="Scanning files",
|
|
44
|
+
files_total=files_total,
|
|
45
|
+
files_processed=0,
|
|
46
|
+
)
|
|
47
|
+
self._update_global_status()
|
|
48
|
+
|
|
49
|
+
def update_project_progress( # pragma: no cover
|
|
50
|
+
self,
|
|
51
|
+
project_name: str,
|
|
52
|
+
status: SyncStatus,
|
|
53
|
+
message: str = "",
|
|
54
|
+
files_processed: int = 0,
|
|
55
|
+
files_total: Optional[int] = None,
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Update progress for a project."""
|
|
58
|
+
if project_name not in self._project_statuses: # pragma: no cover
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
project_status = self._project_statuses[project_name]
|
|
62
|
+
project_status.status = status
|
|
63
|
+
project_status.message = message
|
|
64
|
+
project_status.files_processed = files_processed
|
|
65
|
+
|
|
66
|
+
if files_total is not None:
|
|
67
|
+
project_status.files_total = files_total
|
|
68
|
+
|
|
69
|
+
self._update_global_status()
|
|
70
|
+
|
|
71
|
+
def complete_project_sync(self, project_name: str) -> None:
|
|
72
|
+
"""Mark project sync as completed."""
|
|
73
|
+
if project_name in self._project_statuses:
|
|
74
|
+
self._project_statuses[project_name].status = SyncStatus.COMPLETED
|
|
75
|
+
self._project_statuses[project_name].message = "Sync completed"
|
|
76
|
+
self._update_global_status()
|
|
77
|
+
|
|
78
|
+
def fail_project_sync(self, project_name: str, error: str) -> None:
|
|
79
|
+
"""Mark project sync as failed."""
|
|
80
|
+
if project_name in self._project_statuses:
|
|
81
|
+
self._project_statuses[project_name].status = SyncStatus.FAILED
|
|
82
|
+
self._project_statuses[project_name].error = error
|
|
83
|
+
self._update_global_status()
|
|
84
|
+
|
|
85
|
+
def start_project_watch(self, project_name: str) -> None:
|
|
86
|
+
"""Mark project as watching for changes (steady state after sync)."""
|
|
87
|
+
if project_name in self._project_statuses:
|
|
88
|
+
self._project_statuses[project_name].status = SyncStatus.WATCHING
|
|
89
|
+
self._project_statuses[project_name].message = "Watching for changes"
|
|
90
|
+
self._update_global_status()
|
|
91
|
+
else:
|
|
92
|
+
# Create new status if project isn't tracked yet
|
|
93
|
+
self._project_statuses[project_name] = ProjectSyncStatus(
|
|
94
|
+
project_name=project_name,
|
|
95
|
+
status=SyncStatus.WATCHING,
|
|
96
|
+
message="Watching for changes",
|
|
97
|
+
files_total=0,
|
|
98
|
+
files_processed=0,
|
|
99
|
+
)
|
|
100
|
+
self._update_global_status()
|
|
101
|
+
|
|
102
|
+
def _update_global_status(self) -> None:
|
|
103
|
+
"""Update global status based on project statuses."""
|
|
104
|
+
if not self._project_statuses: # pragma: no cover
|
|
105
|
+
self._global_status = SyncStatus.IDLE
|
|
106
|
+
return
|
|
107
|
+
|
|
108
|
+
statuses = [p.status for p in self._project_statuses.values()]
|
|
109
|
+
|
|
110
|
+
if any(s == SyncStatus.FAILED for s in statuses):
|
|
111
|
+
self._global_status = SyncStatus.FAILED
|
|
112
|
+
elif any(s in (SyncStatus.SCANNING, SyncStatus.SYNCING) for s in statuses):
|
|
113
|
+
self._global_status = SyncStatus.SYNCING
|
|
114
|
+
elif all(s in (SyncStatus.COMPLETED, SyncStatus.WATCHING) for s in statuses):
|
|
115
|
+
self._global_status = SyncStatus.COMPLETED
|
|
116
|
+
else:
|
|
117
|
+
self._global_status = SyncStatus.SYNCING
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def global_status(self) -> SyncStatus:
|
|
121
|
+
"""Get overall sync status."""
|
|
122
|
+
return self._global_status
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def is_syncing(self) -> bool:
|
|
126
|
+
"""Check if any sync operation is in progress."""
|
|
127
|
+
return self._global_status in (SyncStatus.SCANNING, SyncStatus.SYNCING)
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def is_ready(self) -> bool: # pragma: no cover
|
|
131
|
+
"""Check if system is ready (no sync in progress)."""
|
|
132
|
+
return self._global_status in (SyncStatus.IDLE, SyncStatus.COMPLETED)
|
|
133
|
+
|
|
134
|
+
def get_project_status(self, project_name: str) -> Optional[ProjectSyncStatus]:
|
|
135
|
+
"""Get status for a specific project."""
|
|
136
|
+
return self._project_statuses.get(project_name)
|
|
137
|
+
|
|
138
|
+
def get_all_projects(self) -> Dict[str, ProjectSyncStatus]:
|
|
139
|
+
"""Get all project statuses."""
|
|
140
|
+
return self._project_statuses.copy()
|
|
141
|
+
|
|
142
|
+
def get_summary(self) -> str: # pragma: no cover
|
|
143
|
+
"""Get a user-friendly summary of sync status."""
|
|
144
|
+
if self._global_status == SyncStatus.IDLE:
|
|
145
|
+
return "✅ System ready"
|
|
146
|
+
elif self._global_status == SyncStatus.COMPLETED:
|
|
147
|
+
return "✅ All projects synced successfully"
|
|
148
|
+
elif self._global_status == SyncStatus.FAILED:
|
|
149
|
+
failed_projects = [
|
|
150
|
+
p.project_name
|
|
151
|
+
for p in self._project_statuses.values()
|
|
152
|
+
if p.status == SyncStatus.FAILED
|
|
153
|
+
]
|
|
154
|
+
return f"❌ Sync failed for: {', '.join(failed_projects)}"
|
|
155
|
+
else:
|
|
156
|
+
active_projects = [
|
|
157
|
+
p.project_name
|
|
158
|
+
for p in self._project_statuses.values()
|
|
159
|
+
if p.status in (SyncStatus.SCANNING, SyncStatus.SYNCING)
|
|
160
|
+
]
|
|
161
|
+
total_files = sum(p.files_total for p in self._project_statuses.values())
|
|
162
|
+
processed_files = sum(p.files_processed for p in self._project_statuses.values())
|
|
163
|
+
|
|
164
|
+
if total_files > 0:
|
|
165
|
+
progress_pct = (processed_files / total_files) * 100
|
|
166
|
+
return f"🔄 Syncing {len(active_projects)} projects ({processed_files}/{total_files} files, {progress_pct:.0f}%)"
|
|
167
|
+
else:
|
|
168
|
+
return f"🔄 Syncing {len(active_projects)} projects"
|
|
169
|
+
|
|
170
|
+
def clear_completed(self) -> None:
|
|
171
|
+
"""Remove completed project statuses to clean up memory."""
|
|
172
|
+
self._project_statuses = {
|
|
173
|
+
name: status
|
|
174
|
+
for name, status in self._project_statuses.items()
|
|
175
|
+
if status.status != SyncStatus.COMPLETED
|
|
176
|
+
}
|
|
177
|
+
self._update_global_status()
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# Global sync status tracker instance
|
|
181
|
+
sync_status_tracker = SyncStatusTracker()
|