Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion exocortex/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@
storing and retrieving development insights across projects.
"""

__version__ = "0.7.1"
__version__ = "0.8.0"
66 changes: 66 additions & 0 deletions exocortex/worker/dream.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,66 @@ def _is_kuzu_locked(self) -> bool:
"""
return self._kuzu_lock_path.exists()

def _backup_database(self, max_backups: int = 3) -> bool:
"""Create a backup of the database before consolidation.

Creates timestamped backups in ~/.exocortex/backups/ and maintains
only the most recent N backups to save disk space.

Args:
max_backups: Maximum number of backups to keep.

Returns:
True if backup was successful, False otherwise.
"""
import shutil
from datetime import datetime

db_path = self.config.data_dir / self.config.db_name
if not db_path.exists():
logger.info("No database found, skipping backup")
return True

backup_dir = self.config.data_dir / "backups"
backup_dir.mkdir(parents=True, exist_ok=True)

# Create timestamped backup
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
backup_name = f"{self.config.db_name}_{timestamp}"
backup_path = backup_dir / backup_name

try:
# Copy database (works for both file and directory)
if db_path.is_dir():
shutil.copytree(db_path, backup_path)
else:
shutil.copy2(db_path, backup_path)

logger.info(f"💾 Database backup created: {backup_path.name}")

# Cleanup old backups (keep only max_backups most recent)
backups = sorted(
backup_dir.glob(f"{self.config.db_name}_*"),
key=lambda p: p.stat().st_mtime,
reverse=True,
)

for old_backup in backups[max_backups:]:
try:
if old_backup.is_dir():
shutil.rmtree(old_backup)
else:
old_backup.unlink()
logger.info(f"🗑️ Removed old backup: {old_backup.name}")
except Exception as e:
logger.warning(f"Failed to remove old backup {old_backup}: {e}")

return True

except Exception as e:
logger.error(f"❌ Backup failed: {e}")
return False

def run(self) -> None:
"""Main entry point for the dream worker.

Expand Down Expand Up @@ -169,6 +229,12 @@ def run(self) -> None:
self._running = True
start_time = time.time()

# Create backup before any modifications
if not self._backup_database():
logger.warning(
"Backup failed, but continuing with consolidation..."
)

self._run_consolidation_tasks()

elapsed = time.time() - start_time
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "exocortex"
version = "0.7.1"
version = "0.8.0"
description = "Local MCP Server acting as your external brain for development insights."
readme = "README.md"
requires-python = ">=3.10"
Expand Down
88 changes: 88 additions & 0 deletions tests/unit/test_sleep_mechanism.py
Original file line number Diff line number Diff line change
Expand Up @@ -313,3 +313,91 @@ def test_signal_handler_sets_running_false(self):
worker._handle_signal(15, None) # SIGTERM

assert worker._running is False


class TestDreamWorkerBackup:
"""Tests for DreamWorker backup functionality."""

def test_backup_creates_backup_directory(self):
"""Test backup creates backups directory if not exists."""
from exocortex.config import Config
from exocortex.worker.dream import DreamWorker

with tempfile.TemporaryDirectory() as tmpdir:
config = Config(data_dir=Path(tmpdir))
worker = DreamWorker(config=config)

# Create a dummy database file
db_path = Path(tmpdir) / config.db_name
db_path.write_text("dummy db content")

result = worker._backup_database()

assert result is True
backup_dir = Path(tmpdir) / "backups"
assert backup_dir.exists()

def test_backup_creates_timestamped_backup(self):
"""Test backup creates a timestamped backup file."""
from exocortex.config import Config
from exocortex.worker.dream import DreamWorker

with tempfile.TemporaryDirectory() as tmpdir:
config = Config(data_dir=Path(tmpdir))
worker = DreamWorker(config=config)

# Create a dummy database file
db_path = Path(tmpdir) / config.db_name
db_path.write_text("dummy db content")

worker._backup_database()

backup_dir = Path(tmpdir) / "backups"
backups = list(backup_dir.glob(f"{config.db_name}_*"))
assert len(backups) == 1

def test_backup_limits_to_max_backups(self):
"""Test backup keeps only max_backups most recent."""

from exocortex.config import Config
from exocortex.worker.dream import DreamWorker

with tempfile.TemporaryDirectory() as tmpdir:
config = Config(data_dir=Path(tmpdir))
worker = DreamWorker(config=config)

# Create a dummy database file
db_path = Path(tmpdir) / config.db_name
db_path.write_text("dummy db content")

# Pre-create old backups manually with different timestamps
backup_dir = Path(tmpdir) / "backups"
backup_dir.mkdir(parents=True, exist_ok=True)

for i in range(4):
old_backup = backup_dir / f"{config.db_name}_2024010{i}_120000"
old_backup.write_text("old backup")
# Set different modification times
import os

os.utime(old_backup, (1000000 + i * 1000, 1000000 + i * 1000))

# Now create a new backup with max_backups=3
worker._backup_database(max_backups=3)

backups = list(backup_dir.glob(f"{config.db_name}_*"))
assert len(backups) == 3

def test_backup_returns_true_when_no_database(self):
"""Test backup returns True when database doesn't exist."""
from exocortex.config import Config
from exocortex.worker.dream import DreamWorker

with tempfile.TemporaryDirectory() as tmpdir:
config = Config(data_dir=Path(tmpdir))
worker = DreamWorker(config=config)

# No database file created
result = worker._backup_database()

assert result is True