From d5cbd49ce16b317cfe7b99f110ddd23486abdc00 Mon Sep 17 00:00:00 2001 From: fuwasegu Date: Thu, 11 Dec 2025 02:13:09 +0900 Subject: [PATCH 1/3] backup --- exocortex/worker/dream.py | 66 ++++++++++++++++++++++ tests/unit/test_sleep_mechanism.py | 88 ++++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+) diff --git a/exocortex/worker/dream.py b/exocortex/worker/dream.py index 73e1a56..b734c1d 100644 --- a/exocortex/worker/dream.py +++ b/exocortex/worker/dream.py @@ -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. @@ -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 diff --git a/tests/unit/test_sleep_mechanism.py b/tests/unit/test_sleep_mechanism.py index 2f1a77f..1785a8c 100644 --- a/tests/unit/test_sleep_mechanism.py +++ b/tests/unit/test_sleep_mechanism.py @@ -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 + import time + + 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 From 86148a92c7ed0bd0bf8716a4351afe7627044ef0 Mon Sep 17 00:00:00 2001 From: fuwasegu Date: Thu, 11 Dec 2025 02:15:22 +0900 Subject: [PATCH 2/3] fmt --- tests/unit/test_sleep_mechanism.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/test_sleep_mechanism.py b/tests/unit/test_sleep_mechanism.py index 1785a8c..a3efe81 100644 --- a/tests/unit/test_sleep_mechanism.py +++ b/tests/unit/test_sleep_mechanism.py @@ -358,9 +358,9 @@ def test_backup_creates_timestamped_backup(self): 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 - import time with tempfile.TemporaryDirectory() as tmpdir: config = Config(data_dir=Path(tmpdir)) From 513f4effab14ca8cd882eaaaa44eb37835f0e31d Mon Sep 17 00:00:00 2001 From: fuwasegu Date: Thu, 11 Dec 2025 09:38:11 +0900 Subject: [PATCH 3/3] version --- exocortex/__init__.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/exocortex/__init__.py b/exocortex/__init__.py index d423256..609e35a 100644 --- a/exocortex/__init__.py +++ b/exocortex/__init__.py @@ -4,4 +4,4 @@ storing and retrieving development insights across projects. """ -__version__ = "0.7.1" +__version__ = "0.8.0" diff --git a/pyproject.toml b/pyproject.toml index dc185b3..e8f61b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"