basic-memory 0.16.1__py3-none-any.whl → 0.17.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.
Potentially problematic release.
This version of basic-memory might be problematic. Click here for more details.
- basic_memory/__init__.py +1 -1
- basic_memory/alembic/env.py +112 -26
- basic_memory/alembic/versions/314f1ea54dc4_add_postgres_full_text_search_support_.py +131 -0
- basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +15 -3
- basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +44 -36
- basic_memory/alembic/versions/6830751f5fb6_merge_multiple_heads.py +24 -0
- basic_memory/alembic/versions/a2b3c4d5e6f7_add_search_index_entity_cascade.py +56 -0
- basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +13 -0
- basic_memory/alembic/versions/f8a9b2c3d4e5_add_pg_trgm_for_fuzzy_link_resolution.py +239 -0
- basic_memory/alembic/versions/g9a0b3c4d5e6_add_external_id_to_project_and_entity.py +173 -0
- basic_memory/api/app.py +45 -24
- basic_memory/api/container.py +133 -0
- basic_memory/api/routers/knowledge_router.py +17 -5
- basic_memory/api/routers/project_router.py +68 -14
- basic_memory/api/routers/resource_router.py +37 -27
- basic_memory/api/routers/utils.py +53 -14
- basic_memory/api/v2/__init__.py +35 -0
- basic_memory/api/v2/routers/__init__.py +21 -0
- basic_memory/api/v2/routers/directory_router.py +93 -0
- basic_memory/api/v2/routers/importer_router.py +181 -0
- basic_memory/api/v2/routers/knowledge_router.py +427 -0
- basic_memory/api/v2/routers/memory_router.py +130 -0
- basic_memory/api/v2/routers/project_router.py +359 -0
- basic_memory/api/v2/routers/prompt_router.py +269 -0
- basic_memory/api/v2/routers/resource_router.py +286 -0
- basic_memory/api/v2/routers/search_router.py +73 -0
- basic_memory/cli/app.py +43 -7
- basic_memory/cli/auth.py +27 -4
- basic_memory/cli/commands/__init__.py +3 -1
- basic_memory/cli/commands/cloud/api_client.py +20 -5
- basic_memory/cli/commands/cloud/cloud_utils.py +13 -6
- basic_memory/cli/commands/cloud/rclone_commands.py +110 -14
- basic_memory/cli/commands/cloud/rclone_installer.py +18 -4
- basic_memory/cli/commands/cloud/upload.py +10 -3
- basic_memory/cli/commands/command_utils.py +52 -4
- basic_memory/cli/commands/db.py +78 -19
- basic_memory/cli/commands/format.py +198 -0
- basic_memory/cli/commands/import_chatgpt.py +12 -8
- basic_memory/cli/commands/import_claude_conversations.py +12 -8
- basic_memory/cli/commands/import_claude_projects.py +12 -8
- basic_memory/cli/commands/import_memory_json.py +12 -8
- basic_memory/cli/commands/mcp.py +8 -26
- basic_memory/cli/commands/project.py +22 -9
- basic_memory/cli/commands/status.py +3 -2
- basic_memory/cli/commands/telemetry.py +81 -0
- basic_memory/cli/container.py +84 -0
- basic_memory/cli/main.py +7 -0
- basic_memory/config.py +177 -77
- basic_memory/db.py +183 -77
- basic_memory/deps/__init__.py +293 -0
- basic_memory/deps/config.py +26 -0
- basic_memory/deps/db.py +56 -0
- basic_memory/deps/importers.py +200 -0
- basic_memory/deps/projects.py +238 -0
- basic_memory/deps/repositories.py +179 -0
- basic_memory/deps/services.py +480 -0
- basic_memory/deps.py +14 -409
- basic_memory/file_utils.py +212 -3
- basic_memory/ignore_utils.py +5 -5
- basic_memory/importers/base.py +40 -19
- basic_memory/importers/chatgpt_importer.py +17 -4
- basic_memory/importers/claude_conversations_importer.py +27 -12
- basic_memory/importers/claude_projects_importer.py +50 -14
- basic_memory/importers/memory_json_importer.py +36 -16
- basic_memory/importers/utils.py +5 -2
- basic_memory/markdown/entity_parser.py +62 -23
- basic_memory/markdown/markdown_processor.py +67 -4
- basic_memory/markdown/plugins.py +4 -2
- basic_memory/markdown/utils.py +10 -1
- basic_memory/mcp/async_client.py +1 -0
- basic_memory/mcp/clients/__init__.py +28 -0
- basic_memory/mcp/clients/directory.py +70 -0
- basic_memory/mcp/clients/knowledge.py +176 -0
- basic_memory/mcp/clients/memory.py +120 -0
- basic_memory/mcp/clients/project.py +89 -0
- basic_memory/mcp/clients/resource.py +71 -0
- basic_memory/mcp/clients/search.py +65 -0
- basic_memory/mcp/container.py +110 -0
- basic_memory/mcp/project_context.py +47 -33
- basic_memory/mcp/prompts/ai_assistant_guide.py +2 -2
- basic_memory/mcp/prompts/recent_activity.py +2 -2
- basic_memory/mcp/prompts/utils.py +3 -3
- basic_memory/mcp/server.py +58 -0
- basic_memory/mcp/tools/build_context.py +14 -14
- basic_memory/mcp/tools/canvas.py +34 -12
- basic_memory/mcp/tools/chatgpt_tools.py +4 -1
- basic_memory/mcp/tools/delete_note.py +31 -7
- basic_memory/mcp/tools/edit_note.py +14 -9
- basic_memory/mcp/tools/list_directory.py +7 -17
- basic_memory/mcp/tools/move_note.py +35 -31
- basic_memory/mcp/tools/project_management.py +29 -25
- basic_memory/mcp/tools/read_content.py +13 -3
- basic_memory/mcp/tools/read_note.py +24 -14
- basic_memory/mcp/tools/recent_activity.py +32 -38
- basic_memory/mcp/tools/search.py +17 -10
- basic_memory/mcp/tools/utils.py +28 -0
- basic_memory/mcp/tools/view_note.py +2 -1
- basic_memory/mcp/tools/write_note.py +37 -14
- basic_memory/models/knowledge.py +15 -2
- basic_memory/models/project.py +7 -1
- basic_memory/models/search.py +58 -2
- basic_memory/project_resolver.py +222 -0
- basic_memory/repository/entity_repository.py +210 -3
- basic_memory/repository/observation_repository.py +1 -0
- basic_memory/repository/postgres_search_repository.py +451 -0
- basic_memory/repository/project_repository.py +38 -1
- basic_memory/repository/relation_repository.py +58 -2
- basic_memory/repository/repository.py +1 -0
- basic_memory/repository/search_index_row.py +95 -0
- basic_memory/repository/search_repository.py +77 -615
- basic_memory/repository/search_repository_base.py +241 -0
- basic_memory/repository/sqlite_search_repository.py +437 -0
- basic_memory/runtime.py +61 -0
- basic_memory/schemas/base.py +36 -6
- basic_memory/schemas/directory.py +2 -1
- basic_memory/schemas/memory.py +9 -2
- basic_memory/schemas/project_info.py +2 -0
- basic_memory/schemas/response.py +84 -27
- basic_memory/schemas/search.py +5 -0
- basic_memory/schemas/sync_report.py +1 -1
- basic_memory/schemas/v2/__init__.py +27 -0
- basic_memory/schemas/v2/entity.py +133 -0
- basic_memory/schemas/v2/resource.py +47 -0
- basic_memory/services/context_service.py +219 -43
- basic_memory/services/directory_service.py +26 -11
- basic_memory/services/entity_service.py +68 -33
- basic_memory/services/file_service.py +131 -16
- basic_memory/services/initialization.py +51 -26
- basic_memory/services/link_resolver.py +1 -0
- basic_memory/services/project_service.py +68 -43
- basic_memory/services/search_service.py +75 -16
- basic_memory/sync/__init__.py +2 -1
- basic_memory/sync/coordinator.py +160 -0
- basic_memory/sync/sync_service.py +135 -115
- basic_memory/sync/watch_service.py +32 -12
- basic_memory/telemetry.py +249 -0
- basic_memory/utils.py +96 -75
- {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/METADATA +129 -5
- basic_memory-0.17.4.dist-info/RECORD +193 -0
- {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/WHEEL +1 -1
- basic_memory-0.16.1.dist-info/RECORD +0 -148
- {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/entry_points.txt +0 -0
- {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/licenses/LICENSE +0 -0
|
@@ -4,6 +4,7 @@ import ast
|
|
|
4
4
|
from datetime import datetime
|
|
5
5
|
from typing import List, Optional, Set
|
|
6
6
|
|
|
7
|
+
|
|
7
8
|
from dateparser import parse
|
|
8
9
|
from fastapi import BackgroundTasks
|
|
9
10
|
from loguru import logger
|
|
@@ -15,6 +16,21 @@ from basic_memory.repository.search_repository import SearchRepository, SearchIn
|
|
|
15
16
|
from basic_memory.schemas.search import SearchQuery, SearchItemType
|
|
16
17
|
from basic_memory.services import FileService
|
|
17
18
|
|
|
19
|
+
# Maximum size for content_stems field to stay under Postgres's 8KB index row limit.
|
|
20
|
+
# We use 6000 characters to leave headroom for other indexed columns and overhead.
|
|
21
|
+
MAX_CONTENT_STEMS_SIZE = 6000
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _mtime_to_datetime(entity: Entity) -> datetime:
|
|
25
|
+
"""Convert entity mtime (file modification time) to datetime.
|
|
26
|
+
|
|
27
|
+
Returns the file's actual modification time, falling back to updated_at
|
|
28
|
+
if mtime is not available.
|
|
29
|
+
"""
|
|
30
|
+
if entity.mtime:
|
|
31
|
+
return datetime.fromtimestamp(entity.mtime).astimezone()
|
|
32
|
+
return entity.updated_at
|
|
33
|
+
|
|
18
34
|
|
|
19
35
|
class SearchService:
|
|
20
36
|
"""Service for search operations.
|
|
@@ -156,23 +172,43 @@ class SearchService:
|
|
|
156
172
|
self,
|
|
157
173
|
entity: Entity,
|
|
158
174
|
background_tasks: Optional[BackgroundTasks] = None,
|
|
175
|
+
content: str | None = None,
|
|
159
176
|
) -> None:
|
|
160
177
|
if background_tasks:
|
|
161
|
-
background_tasks.add_task(self.index_entity_data, entity)
|
|
178
|
+
background_tasks.add_task(self.index_entity_data, entity, content)
|
|
162
179
|
else:
|
|
163
|
-
await self.index_entity_data(entity)
|
|
180
|
+
await self.index_entity_data(entity, content)
|
|
164
181
|
|
|
165
182
|
async def index_entity_data(
|
|
166
183
|
self,
|
|
167
184
|
entity: Entity,
|
|
185
|
+
content: str | None = None,
|
|
168
186
|
) -> None:
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
entity
|
|
175
|
-
|
|
187
|
+
logger.info(
|
|
188
|
+
f"[BackgroundTask] Starting search index for entity_id={entity.id} "
|
|
189
|
+
f"permalink={entity.permalink} project_id={entity.project_id}"
|
|
190
|
+
)
|
|
191
|
+
try:
|
|
192
|
+
# delete all search index data associated with entity
|
|
193
|
+
await self.repository.delete_by_entity_id(entity_id=entity.id)
|
|
194
|
+
|
|
195
|
+
# reindex
|
|
196
|
+
await self.index_entity_markdown(
|
|
197
|
+
entity, content
|
|
198
|
+
) if entity.is_markdown else await self.index_entity_file(entity)
|
|
199
|
+
|
|
200
|
+
logger.info(
|
|
201
|
+
f"[BackgroundTask] Completed search index for entity_id={entity.id} "
|
|
202
|
+
f"permalink={entity.permalink}"
|
|
203
|
+
)
|
|
204
|
+
except Exception as e: # pragma: no cover
|
|
205
|
+
# Background task failure logging; exceptions are re-raised.
|
|
206
|
+
# Avoid forcing synthetic failures just for line coverage.
|
|
207
|
+
logger.error( # pragma: no cover
|
|
208
|
+
f"[BackgroundTask] Failed search index for entity_id={entity.id} "
|
|
209
|
+
f"permalink={entity.permalink} error={e}"
|
|
210
|
+
)
|
|
211
|
+
raise # pragma: no cover
|
|
176
212
|
|
|
177
213
|
async def index_entity_file(
|
|
178
214
|
self,
|
|
@@ -185,12 +221,13 @@ class SearchService:
|
|
|
185
221
|
entity_id=entity.id,
|
|
186
222
|
type=SearchItemType.ENTITY.value,
|
|
187
223
|
title=entity.title,
|
|
224
|
+
permalink=entity.permalink, # Required for Postgres NOT NULL constraint
|
|
188
225
|
file_path=entity.file_path,
|
|
189
226
|
metadata={
|
|
190
227
|
"entity_type": entity.entity_type,
|
|
191
228
|
},
|
|
192
229
|
created_at=entity.created_at,
|
|
193
|
-
updated_at=entity
|
|
230
|
+
updated_at=_mtime_to_datetime(entity),
|
|
194
231
|
project_id=entity.project_id,
|
|
195
232
|
)
|
|
196
233
|
)
|
|
@@ -198,9 +235,14 @@ class SearchService:
|
|
|
198
235
|
async def index_entity_markdown(
|
|
199
236
|
self,
|
|
200
237
|
entity: Entity,
|
|
238
|
+
content: str | None = None,
|
|
201
239
|
) -> None:
|
|
202
240
|
"""Index an entity and all its observations and relations.
|
|
203
241
|
|
|
242
|
+
Args:
|
|
243
|
+
entity: The entity to index
|
|
244
|
+
content: Optional pre-loaded content (avoids file read). If None, will read from file.
|
|
245
|
+
|
|
204
246
|
Indexing structure:
|
|
205
247
|
1. Entities
|
|
206
248
|
- permalink: direct from entity (e.g., "specs/search")
|
|
@@ -229,7 +271,9 @@ class SearchService:
|
|
|
229
271
|
title_variants = self._generate_variants(entity.title)
|
|
230
272
|
content_stems.extend(title_variants)
|
|
231
273
|
|
|
232
|
-
content
|
|
274
|
+
# Use provided content or read from file
|
|
275
|
+
if content is None:
|
|
276
|
+
content = await self.file_service.read_entity_content(entity)
|
|
233
277
|
if content:
|
|
234
278
|
content_stems.append(content)
|
|
235
279
|
content_snippet = f"{content[:250]}"
|
|
@@ -246,6 +290,10 @@ class SearchService:
|
|
|
246
290
|
|
|
247
291
|
entity_content_stems = "\n".join(p for p in content_stems if p and p.strip())
|
|
248
292
|
|
|
293
|
+
# Truncate to stay under Postgres's 8KB index row limit
|
|
294
|
+
if len(entity_content_stems) > MAX_CONTENT_STEMS_SIZE: # pragma: no cover
|
|
295
|
+
entity_content_stems = entity_content_stems[:MAX_CONTENT_STEMS_SIZE] # pragma: no cover
|
|
296
|
+
|
|
249
297
|
# Add entity row
|
|
250
298
|
rows_to_index.append(
|
|
251
299
|
SearchIndexRow(
|
|
@@ -261,17 +309,28 @@ class SearchService:
|
|
|
261
309
|
"entity_type": entity.entity_type,
|
|
262
310
|
},
|
|
263
311
|
created_at=entity.created_at,
|
|
264
|
-
updated_at=entity
|
|
312
|
+
updated_at=_mtime_to_datetime(entity),
|
|
265
313
|
project_id=entity.project_id,
|
|
266
314
|
)
|
|
267
315
|
)
|
|
268
316
|
|
|
269
|
-
# Add observation rows
|
|
317
|
+
# Add observation rows - dedupe by permalink to avoid unique constraint violations
|
|
318
|
+
# Two observations with same entity/category/content generate identical permalinks
|
|
319
|
+
seen_permalinks: set[str] = {entity.permalink} if entity.permalink else set()
|
|
270
320
|
for obs in entity.observations:
|
|
321
|
+
obs_permalink = obs.permalink
|
|
322
|
+
if obs_permalink in seen_permalinks:
|
|
323
|
+
logger.debug(f"Skipping duplicate observation permalink: {obs_permalink}")
|
|
324
|
+
continue
|
|
325
|
+
seen_permalinks.add(obs_permalink)
|
|
326
|
+
|
|
271
327
|
# Index with parent entity's file path since that's where it's defined
|
|
272
328
|
obs_content_stems = "\n".join(
|
|
273
329
|
p for p in self._generate_variants(obs.content) if p and p.strip()
|
|
274
330
|
)
|
|
331
|
+
# Truncate to stay under Postgres's 8KB index row limit
|
|
332
|
+
if len(obs_content_stems) > MAX_CONTENT_STEMS_SIZE: # pragma: no cover
|
|
333
|
+
obs_content_stems = obs_content_stems[:MAX_CONTENT_STEMS_SIZE] # pragma: no cover
|
|
275
334
|
rows_to_index.append(
|
|
276
335
|
SearchIndexRow(
|
|
277
336
|
id=obs.id,
|
|
@@ -279,7 +338,7 @@ class SearchService:
|
|
|
279
338
|
title=f"{obs.category}: {obs.content[:100]}...",
|
|
280
339
|
content_stems=obs_content_stems,
|
|
281
340
|
content_snippet=obs.content,
|
|
282
|
-
permalink=
|
|
341
|
+
permalink=obs_permalink,
|
|
283
342
|
file_path=entity.file_path,
|
|
284
343
|
category=obs.category,
|
|
285
344
|
entity_id=entity.id,
|
|
@@ -287,7 +346,7 @@ class SearchService:
|
|
|
287
346
|
"tags": obs.tags,
|
|
288
347
|
},
|
|
289
348
|
created_at=entity.created_at,
|
|
290
|
-
updated_at=entity
|
|
349
|
+
updated_at=_mtime_to_datetime(entity),
|
|
291
350
|
project_id=entity.project_id,
|
|
292
351
|
)
|
|
293
352
|
)
|
|
@@ -317,7 +376,7 @@ class SearchService:
|
|
|
317
376
|
to_id=rel.to_id,
|
|
318
377
|
relation_type=rel.relation_type,
|
|
319
378
|
created_at=entity.created_at,
|
|
320
|
-
updated_at=entity
|
|
379
|
+
updated_at=_mtime_to_datetime(entity),
|
|
321
380
|
project_id=entity.project_id,
|
|
322
381
|
)
|
|
323
382
|
)
|
basic_memory/sync/__init__.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Basic Memory sync services."""
|
|
2
2
|
|
|
3
|
+
from .coordinator import SyncCoordinator, SyncStatus
|
|
3
4
|
from .sync_service import SyncService
|
|
4
5
|
from .watch_service import WatchService
|
|
5
6
|
|
|
6
|
-
__all__ = ["SyncService", "WatchService"]
|
|
7
|
+
__all__ = ["SyncService", "WatchService", "SyncCoordinator", "SyncStatus"]
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""SyncCoordinator - centralized sync/watch lifecycle management.
|
|
2
|
+
|
|
3
|
+
This module provides a single coordinator that manages the lifecycle of
|
|
4
|
+
file synchronization and watch services across all entry points (API, MCP, CLI).
|
|
5
|
+
|
|
6
|
+
The coordinator handles:
|
|
7
|
+
- Starting/stopping watch service
|
|
8
|
+
- Scheduling background sync
|
|
9
|
+
- Reporting status
|
|
10
|
+
- Clean shutdown behavior
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from enum import Enum, auto
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
from loguru import logger
|
|
19
|
+
|
|
20
|
+
from basic_memory.config import BasicMemoryConfig
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SyncStatus(Enum):
|
|
24
|
+
"""Status of the sync coordinator."""
|
|
25
|
+
|
|
26
|
+
NOT_STARTED = auto()
|
|
27
|
+
STARTING = auto()
|
|
28
|
+
RUNNING = auto()
|
|
29
|
+
STOPPING = auto()
|
|
30
|
+
STOPPED = auto()
|
|
31
|
+
ERROR = auto()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class SyncCoordinator:
|
|
36
|
+
"""Centralized coordinator for sync/watch lifecycle.
|
|
37
|
+
|
|
38
|
+
Manages the lifecycle of file synchronization services, providing:
|
|
39
|
+
- Unified start/stop interface
|
|
40
|
+
- Status tracking
|
|
41
|
+
- Clean shutdown with proper task cancellation
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
config: BasicMemoryConfig with sync settings
|
|
45
|
+
should_sync: Whether sync should be enabled (from container decision)
|
|
46
|
+
skip_reason: Human-readable reason if sync is skipped
|
|
47
|
+
|
|
48
|
+
Usage:
|
|
49
|
+
coordinator = SyncCoordinator(config=config, should_sync=True)
|
|
50
|
+
await coordinator.start()
|
|
51
|
+
# ... application runs ...
|
|
52
|
+
await coordinator.stop()
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
config: BasicMemoryConfig
|
|
56
|
+
should_sync: bool = True
|
|
57
|
+
skip_reason: Optional[str] = None
|
|
58
|
+
|
|
59
|
+
# Internal state (not constructor args)
|
|
60
|
+
_status: SyncStatus = field(default=SyncStatus.NOT_STARTED, init=False)
|
|
61
|
+
_sync_task: Optional[asyncio.Task] = field(default=None, init=False)
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def status(self) -> SyncStatus:
|
|
65
|
+
"""Current status of the coordinator."""
|
|
66
|
+
return self._status
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def is_running(self) -> bool:
|
|
70
|
+
"""Whether sync is currently running."""
|
|
71
|
+
return self._status == SyncStatus.RUNNING
|
|
72
|
+
|
|
73
|
+
async def start(self) -> None:
|
|
74
|
+
"""Start the sync/watch service if enabled.
|
|
75
|
+
|
|
76
|
+
This is a non-blocking call that starts the sync task in the background.
|
|
77
|
+
Use stop() to cleanly shut down.
|
|
78
|
+
"""
|
|
79
|
+
if not self.should_sync:
|
|
80
|
+
if self.skip_reason:
|
|
81
|
+
logger.info(f"{self.skip_reason} - skipping local file sync")
|
|
82
|
+
self._status = SyncStatus.STOPPED
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
if self._status in (SyncStatus.RUNNING, SyncStatus.STARTING):
|
|
86
|
+
logger.warning("Sync coordinator already running or starting")
|
|
87
|
+
return
|
|
88
|
+
|
|
89
|
+
self._status = SyncStatus.STARTING
|
|
90
|
+
logger.info("Starting file sync in background")
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
# Deferred import to avoid circular dependency
|
|
94
|
+
from basic_memory.services.initialization import initialize_file_sync
|
|
95
|
+
|
|
96
|
+
async def _file_sync_runner() -> None: # pragma: no cover
|
|
97
|
+
"""Run the file sync service."""
|
|
98
|
+
try:
|
|
99
|
+
await initialize_file_sync(self.config)
|
|
100
|
+
except asyncio.CancelledError:
|
|
101
|
+
logger.debug("File sync cancelled")
|
|
102
|
+
raise
|
|
103
|
+
except Exception as e:
|
|
104
|
+
logger.error(f"Error in file sync: {e}")
|
|
105
|
+
self._status = SyncStatus.ERROR
|
|
106
|
+
raise
|
|
107
|
+
|
|
108
|
+
self._sync_task = asyncio.create_task(_file_sync_runner())
|
|
109
|
+
self._status = SyncStatus.RUNNING
|
|
110
|
+
logger.info("Sync coordinator started successfully")
|
|
111
|
+
|
|
112
|
+
except Exception as e: # pragma: no cover
|
|
113
|
+
logger.error(f"Failed to start sync coordinator: {e}")
|
|
114
|
+
self._status = SyncStatus.ERROR
|
|
115
|
+
raise
|
|
116
|
+
|
|
117
|
+
async def stop(self) -> None:
|
|
118
|
+
"""Stop the sync/watch service cleanly.
|
|
119
|
+
|
|
120
|
+
Cancels the background task and waits for it to complete.
|
|
121
|
+
Safe to call even if not running.
|
|
122
|
+
"""
|
|
123
|
+
if self._status in (SyncStatus.NOT_STARTED, SyncStatus.STOPPED):
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
if self._sync_task is None: # pragma: no cover
|
|
127
|
+
self._status = SyncStatus.STOPPED
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
self._status = SyncStatus.STOPPING
|
|
131
|
+
logger.info("Stopping sync coordinator...")
|
|
132
|
+
|
|
133
|
+
self._sync_task.cancel()
|
|
134
|
+
try:
|
|
135
|
+
await self._sync_task
|
|
136
|
+
except asyncio.CancelledError:
|
|
137
|
+
logger.info("File sync task cancelled successfully")
|
|
138
|
+
|
|
139
|
+
self._sync_task = None
|
|
140
|
+
self._status = SyncStatus.STOPPED
|
|
141
|
+
logger.info("Sync coordinator stopped")
|
|
142
|
+
|
|
143
|
+
def get_status_info(self) -> dict:
|
|
144
|
+
"""Get status information for reporting.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
Dictionary with status details for diagnostics
|
|
148
|
+
"""
|
|
149
|
+
return {
|
|
150
|
+
"status": self._status.name,
|
|
151
|
+
"should_sync": self.should_sync,
|
|
152
|
+
"skip_reason": self.skip_reason,
|
|
153
|
+
"has_task": self._sync_task is not None,
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
__all__ = [
|
|
158
|
+
"SyncCoordinator",
|
|
159
|
+
"SyncStatus",
|
|
160
|
+
]
|