mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
b667bca5a2
Signed-off-by: phernandez <paul@basicmachines.co>
355 lines
13 KiB
Python
355 lines
13 KiB
Python
"""Watch service for Basic Memory."""
|
|
|
|
import os
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import List, Optional, Set
|
|
|
|
from basic_memory.config import ProjectConfig
|
|
from basic_memory.services.file_service import FileService
|
|
from basic_memory.sync.sync_service import SyncService
|
|
from loguru import logger
|
|
from pydantic import BaseModel
|
|
from rich.console import Console
|
|
from watchfiles import awatch
|
|
from watchfiles.main import FileChange, Change
|
|
|
|
WATCH_STATUS_JSON = "watch-status.json"
|
|
|
|
|
|
class WatchEvent(BaseModel):
|
|
timestamp: datetime
|
|
path: str
|
|
action: str # new, delete, etc
|
|
status: str # success, error
|
|
checksum: Optional[str]
|
|
error: Optional[str] = None
|
|
|
|
|
|
class WatchServiceState(BaseModel):
|
|
# Service status
|
|
running: bool = False
|
|
start_time: datetime = datetime.now() # Use directly with Pydantic model
|
|
pid: int = os.getpid() # Use directly with Pydantic model
|
|
|
|
# Stats
|
|
error_count: int = 0
|
|
last_error: Optional[datetime] = None
|
|
last_scan: Optional[datetime] = None
|
|
|
|
# File counts
|
|
synced_files: int = 0
|
|
|
|
# Recent activity
|
|
recent_events: List[WatchEvent] = [] # Use directly with Pydantic model
|
|
|
|
def add_event(
|
|
self,
|
|
path: str,
|
|
action: str,
|
|
status: str,
|
|
checksum: Optional[str] = None,
|
|
error: Optional[str] = None,
|
|
) -> WatchEvent:
|
|
event = WatchEvent(
|
|
timestamp=datetime.now(),
|
|
path=path,
|
|
action=action,
|
|
status=status,
|
|
checksum=checksum,
|
|
error=error,
|
|
)
|
|
self.recent_events.insert(0, event)
|
|
self.recent_events = self.recent_events[:100] # Keep last 100
|
|
return event
|
|
|
|
def record_error(self, error: str):
|
|
self.error_count += 1
|
|
self.add_event(path="", action="sync", status="error", error=error)
|
|
self.last_error = datetime.now()
|
|
|
|
|
|
class WatchService:
|
|
def __init__(self, sync_service: SyncService, file_service: FileService, config: ProjectConfig):
|
|
self.sync_service = sync_service
|
|
self.file_service = file_service
|
|
self.config = config
|
|
self.state = WatchServiceState()
|
|
self.status_path = config.home / ".basic-memory" / WATCH_STATUS_JSON
|
|
self.status_path.parent.mkdir(parents=True, exist_ok=True)
|
|
self.console = Console()
|
|
|
|
async def run(self): # pragma: no cover
|
|
"""Watch for file changes and sync them"""
|
|
logger.info(
|
|
"Watch service started",
|
|
directory=str(self.config.home),
|
|
debounce_ms=self.config.sync_delay,
|
|
pid=os.getpid(),
|
|
)
|
|
|
|
self.state.running = True
|
|
self.state.start_time = datetime.now()
|
|
await self.write_status()
|
|
|
|
try:
|
|
async for changes in awatch(
|
|
self.config.home,
|
|
debounce=self.config.sync_delay,
|
|
watch_filter=self.filter_changes,
|
|
recursive=True,
|
|
):
|
|
await self.handle_changes(self.config.home, changes)
|
|
|
|
except Exception as e:
|
|
logger.exception("Watch service error", error=str(e), directory=str(self.config.home))
|
|
|
|
self.state.record_error(str(e))
|
|
await self.write_status()
|
|
raise
|
|
|
|
finally:
|
|
logger.info(
|
|
"Watch service stopped",
|
|
directory=str(self.config.home),
|
|
runtime_seconds=int((datetime.now() - self.state.start_time).total_seconds()),
|
|
)
|
|
|
|
self.state.running = False
|
|
await self.write_status()
|
|
|
|
def filter_changes(self, change: Change, path: str) -> bool: # pragma: no cover
|
|
"""Filter to only watch non-hidden files and directories.
|
|
|
|
Returns:
|
|
True if the file should be watched, False if it should be ignored
|
|
"""
|
|
# Skip if path is invalid
|
|
try:
|
|
relative_path = Path(path).relative_to(self.config.home)
|
|
except ValueError:
|
|
# This is a defensive check for paths outside our home directory
|
|
return False
|
|
|
|
# Skip hidden directories and files
|
|
path_parts = relative_path.parts
|
|
for part in path_parts:
|
|
if part.startswith("."):
|
|
return False
|
|
|
|
# Skip temp files used in atomic operations
|
|
if path.endswith(".tmp"):
|
|
return False
|
|
|
|
return True
|
|
|
|
async def write_status(self):
|
|
"""Write current state to status file"""
|
|
self.status_path.write_text(WatchServiceState.model_dump_json(self.state, indent=2))
|
|
|
|
async def handle_changes(self, directory: Path, changes: Set[FileChange]):
|
|
"""Process a batch of file changes"""
|
|
import time
|
|
from typing import List, Set
|
|
|
|
start_time = time.time()
|
|
|
|
logger.info("Processing file changes", change_count=len(changes), directory=str(directory))
|
|
|
|
# Group changes by type
|
|
adds: List[str] = []
|
|
deletes: List[str] = []
|
|
modifies: List[str] = []
|
|
|
|
for change, path in changes:
|
|
# convert to relative path
|
|
relative_path = str(Path(path).relative_to(directory))
|
|
|
|
# Skip .tmp files - they're temporary and shouldn't be synced
|
|
if relative_path.endswith(".tmp"):
|
|
continue
|
|
|
|
if change == Change.added:
|
|
adds.append(relative_path)
|
|
elif change == Change.deleted:
|
|
deletes.append(relative_path)
|
|
elif change == Change.modified:
|
|
modifies.append(relative_path)
|
|
|
|
logger.debug(
|
|
"Grouped file changes", added=len(adds), deleted=len(deletes), modified=len(modifies)
|
|
)
|
|
|
|
# Track processed files to avoid duplicates
|
|
processed: Set[str] = set()
|
|
|
|
# First handle potential moves
|
|
for added_path in adds:
|
|
if added_path in processed:
|
|
continue # pragma: no cover
|
|
|
|
# Skip directories for added paths
|
|
# We don't need to process directories, only the files inside them
|
|
# This prevents errors when trying to compute checksums or read directories as files
|
|
added_full_path = directory / added_path
|
|
if not added_full_path.exists() or added_full_path.is_dir():
|
|
logger.debug("Skipping non-existent or directory path", path=added_path)
|
|
processed.add(added_path)
|
|
continue
|
|
|
|
for deleted_path in deletes:
|
|
if deleted_path in processed:
|
|
continue # pragma: no cover
|
|
|
|
# Skip directories for deleted paths (based on entity type in db)
|
|
deleted_entity = await self.sync_service.entity_repository.get_by_file_path(
|
|
deleted_path
|
|
)
|
|
if deleted_entity is None:
|
|
# If this was a directory, it wouldn't have an entity
|
|
logger.debug("Skipping unknown path for move detection", path=deleted_path)
|
|
continue
|
|
|
|
if added_path != deleted_path:
|
|
# Compare checksums to detect moves
|
|
try:
|
|
added_checksum = await self.file_service.compute_checksum(added_path)
|
|
|
|
if deleted_entity and deleted_entity.checksum == added_checksum:
|
|
await self.sync_service.handle_move(deleted_path, added_path)
|
|
self.state.add_event(
|
|
path=f"{deleted_path} -> {added_path}",
|
|
action="moved",
|
|
status="success",
|
|
)
|
|
self.console.print(f"[blue]→[/blue] {deleted_path} → {added_path}")
|
|
processed.add(added_path)
|
|
processed.add(deleted_path)
|
|
break
|
|
except Exception as e: # pragma: no cover
|
|
logger.warning(
|
|
"Error checking for move",
|
|
old_path=deleted_path,
|
|
new_path=added_path,
|
|
error=str(e),
|
|
)
|
|
|
|
# Handle remaining changes - group them by type for concise output
|
|
moved_count = len([p for p in processed if p in deletes or p in adds])
|
|
delete_count = 0
|
|
add_count = 0
|
|
modify_count = 0
|
|
|
|
# Process deletes
|
|
for path in deletes:
|
|
if path not in processed:
|
|
logger.debug("Processing deleted file", path=path)
|
|
await self.sync_service.handle_delete(path)
|
|
self.state.add_event(path=path, action="deleted", status="success")
|
|
self.console.print(f"[red]✕[/red] {path}")
|
|
processed.add(path)
|
|
delete_count += 1
|
|
|
|
# Process adds
|
|
for path in adds:
|
|
if path not in processed:
|
|
# Skip directories - only process files
|
|
full_path = directory / path
|
|
if not full_path.exists() or full_path.is_dir():
|
|
logger.debug(
|
|
"Skipping non-existent or directory path", path=path
|
|
) # pragma: no cover
|
|
processed.add(path) # pragma: no cover
|
|
continue # pragma: no cover
|
|
|
|
logger.debug("Processing new file", path=path)
|
|
entity, checksum = await self.sync_service.sync_file(path, new=True)
|
|
if checksum:
|
|
self.state.add_event(
|
|
path=path, action="new", status="success", checksum=checksum
|
|
)
|
|
self.console.print(f"[green]✓[/green] {path}")
|
|
logger.debug(
|
|
"Added file processed",
|
|
path=path,
|
|
entity_id=entity.id if entity else None,
|
|
checksum=checksum,
|
|
)
|
|
processed.add(path)
|
|
add_count += 1
|
|
else: # pragma: no cover
|
|
logger.warning("Error syncing new file", path=path) # pragma: no cover
|
|
self.console.print(
|
|
f"[orange]?[/orange] Error syncing: {path}"
|
|
) # pragma: no cover
|
|
|
|
# Process modifies - detect repeats
|
|
last_modified_path = None
|
|
repeat_count = 0
|
|
|
|
for path in modifies:
|
|
if path not in processed:
|
|
# Skip directories - only process files
|
|
full_path = directory / path
|
|
if not full_path.exists() or full_path.is_dir():
|
|
logger.debug("Skipping non-existent or directory path", path=path)
|
|
processed.add(path)
|
|
continue
|
|
|
|
logger.debug("Processing modified file", path=path)
|
|
entity, checksum = await self.sync_service.sync_file(path, new=False)
|
|
self.state.add_event(
|
|
path=path, action="modified", status="success", checksum=checksum
|
|
)
|
|
|
|
# Check if this is a repeat of the last modified file
|
|
if path == last_modified_path: # pragma: no cover
|
|
repeat_count += 1 # pragma: no cover
|
|
# Only show a message for the first repeat
|
|
if repeat_count == 1: # pragma: no cover
|
|
self.console.print(
|
|
f"[yellow]...[/yellow] Repeated changes to {path}"
|
|
) # pragma: no cover
|
|
else:
|
|
# New file being modified
|
|
self.console.print(f"[yellow]✎[/yellow] {path}")
|
|
last_modified_path = path
|
|
repeat_count = 0
|
|
modify_count += 1
|
|
|
|
logger.debug(
|
|
"Modified file processed",
|
|
path=path,
|
|
entity_id=entity.id if entity else None,
|
|
checksum=checksum,
|
|
)
|
|
processed.add(path)
|
|
|
|
# Add a concise summary instead of a divider
|
|
if processed:
|
|
changes = [] # pyright: ignore
|
|
if add_count > 0:
|
|
changes.append(f"[green]{add_count} added[/green]") # pyright: ignore
|
|
if modify_count > 0:
|
|
changes.append(f"[yellow]{modify_count} modified[/yellow]") # pyright: ignore
|
|
if moved_count > 0:
|
|
changes.append(f"[blue]{moved_count} moved[/blue]") # pyright: ignore
|
|
if delete_count > 0:
|
|
changes.append(f"[red]{delete_count} deleted[/red]") # pyright: ignore
|
|
|
|
if changes:
|
|
self.console.print(f"{', '.join(changes)}", style="dim") # pyright: ignore
|
|
|
|
duration_ms = int((time.time() - start_time) * 1000)
|
|
self.state.last_scan = datetime.now()
|
|
self.state.synced_files += len(processed)
|
|
|
|
logger.info(
|
|
"File change processing completed",
|
|
processed_files=len(processed),
|
|
total_synced_files=self.state.synced_files,
|
|
duration_ms=duration_ms,
|
|
)
|
|
|
|
await self.write_status()
|