mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
219 lines
7.4 KiB
Python
219 lines
7.4 KiB
Python
"""Watch service for Basic Memory."""
|
|
|
|
import dataclasses
|
|
|
|
from loguru import logger
|
|
from pydantic import BaseModel
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import List, Optional
|
|
|
|
from rich.console import Console
|
|
from rich.live import Live
|
|
from rich.table import Table
|
|
from watchfiles import awatch, Change
|
|
import os
|
|
|
|
from basic_memory.config import ProjectConfig
|
|
from basic_memory.sync.sync_service import SyncService
|
|
from basic_memory.services.file_service import FileService
|
|
|
|
|
|
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 = dataclasses.field(default_factory=datetime.now)
|
|
pid: int = dataclasses.field(default_factory=os.getpid)
|
|
|
|
# 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] = dataclasses.field(default_factory=list)
|
|
|
|
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()
|
|
|
|
def generate_table(self) -> Table:
|
|
"""Generate status display table"""
|
|
table = Table()
|
|
|
|
# Add status row
|
|
table.add_column("Status", style="cyan")
|
|
table.add_column("Last Scan", style="cyan")
|
|
table.add_column("Files", style="cyan")
|
|
table.add_column("Errors", style="red")
|
|
|
|
# Add main status row
|
|
table.add_row(
|
|
"✓ Running" if self.state.running else "✗ Stopped",
|
|
self.state.last_scan.strftime("%H:%M:%S") if self.state.last_scan else "-",
|
|
str(self.state.synced_files),
|
|
f"{self.state.error_count} ({self.state.last_error.strftime('%H:%M:%S') if self.state.last_error else 'none'})",
|
|
)
|
|
|
|
if self.state.recent_events:
|
|
# Add recent events
|
|
table.add_section()
|
|
table.add_row("Recent Events", "", "", "")
|
|
|
|
for event in self.state.recent_events[:5]: # Show last 5 events
|
|
color = {
|
|
"new": "green",
|
|
"modified": "yellow",
|
|
"moved": "blue",
|
|
"deleted": "red",
|
|
"error": "red",
|
|
}.get(event.action, "white")
|
|
|
|
icon = {
|
|
"new": "✚",
|
|
"modified": "✎",
|
|
"moved": "→",
|
|
"deleted": "✖",
|
|
"error": "!",
|
|
}.get(event.action, "*")
|
|
|
|
table.add_row(
|
|
f"[{color}]{icon} {event.action}[/{color}]",
|
|
event.timestamp.strftime("%H:%M:%S"),
|
|
f"[{color}]{event.path}[/{color}]",
|
|
f"[dim]{event.checksum[:8] if event.checksum else ''}[/dim]",
|
|
)
|
|
|
|
return table
|
|
|
|
async def run(self, console_status: bool = False): # pragma: no cover
|
|
"""Watch for file changes and sync them"""
|
|
logger.info("Watching for sync changes")
|
|
self.state.running = True
|
|
self.state.start_time = datetime.now()
|
|
await self.write_status()
|
|
|
|
if console_status:
|
|
with Live(self.generate_table(), refresh_per_second=4, console=self.console) as live:
|
|
try:
|
|
async for changes in awatch(
|
|
self.config.home,
|
|
watch_filter=self.filter_changes,
|
|
debounce=self.config.sync_delay,
|
|
recursive=True,
|
|
):
|
|
# Process changes
|
|
await self.handle_changes(self.config.home)
|
|
# Update display
|
|
live.update(self.generate_table())
|
|
|
|
except Exception as e:
|
|
self.state.record_error(str(e))
|
|
await self.write_status()
|
|
raise
|
|
finally:
|
|
self.state.running = False
|
|
await self.write_status()
|
|
|
|
else:
|
|
try:
|
|
async for changes in awatch(
|
|
self.config.home,
|
|
watch_filter=self.filter_changes,
|
|
debounce=self.config.sync_delay,
|
|
recursive=True,
|
|
):
|
|
# Process changes
|
|
await self.handle_changes(self.config.home)
|
|
# Update display
|
|
|
|
except Exception as e:
|
|
self.state.record_error(str(e))
|
|
await self.write_status()
|
|
raise
|
|
finally:
|
|
self.state.running = False
|
|
await self.write_status()
|
|
|
|
async def write_status(self):
|
|
"""Write current state to status file"""
|
|
self.status_path.write_text(WatchServiceState.model_dump_json(self.state, indent=2))
|
|
|
|
def filter_changes(self, change: Change, path: str) -> bool:
|
|
"""Filter to only watch markdown files"""
|
|
return path.endswith(".md") and not Path(path).name.startswith(".")
|
|
|
|
async def handle_changes(self, directory: Path):
|
|
"""Process a batch of file changes"""
|
|
|
|
logger.debug(f"handling change in directory: {directory} ...")
|
|
# Process changes with timeout
|
|
report = await self.sync_service.sync(directory)
|
|
self.state.last_scan = datetime.now()
|
|
self.state.synced_files = report.total
|
|
|
|
# Update stats
|
|
for path in report.new:
|
|
self.state.add_event(
|
|
path=path, action="new", status="success", checksum=report.checksums[path]
|
|
)
|
|
for path in report.modified:
|
|
self.state.add_event(
|
|
path=path, action="modified", status="success", checksum=report.checksums[path]
|
|
)
|
|
for old_path, new_path in report.moves.items():
|
|
self.state.add_event(
|
|
path=f"{old_path} -> {new_path}",
|
|
action="moved",
|
|
status="success",
|
|
checksum=report.checksums[new_path],
|
|
)
|
|
for path in report.deleted:
|
|
self.state.add_event(path=path, action="deleted", status="success")
|
|
|
|
await self.write_status()
|