mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
feat(config): Add cloud_projects schema for project-scoped sync (SPEC-20 Phase 1)
Add CloudProjectConfig model and cloud_projects dict to BasicMemoryConfig: - CloudProjectConfig tracks local_path, last_sync, bisync_initialized - cloud_projects: dict[str, CloudProjectConfig] in config - Fix datetime serialization with mode='json' in model_dump() - Add comprehensive tests for cloud_projects functionality - Backward compatible: old configs without cloud_projects load correctly This enables project-scoped sync configuration tracked in config file rather than filesystem discovery, preventing phantom projects. Part of SPEC-20 Simplified Project-Scoped Rclone Sync. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: phernandez <paul@basicmachines.co>
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
# Install dependencies
|
||||
install:
|
||||
pip install -e ".[dev]"
|
||||
uv pip install -e ".[dev]"
|
||||
uv sync
|
||||
@echo ""
|
||||
@echo "💡 Remember to activate the virtual environment by running: source .venv/bin/activate"
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Literal, Optional, List, Tuple
|
||||
|
||||
from loguru import logger
|
||||
from pydantic import Field, field_validator
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
import basic_memory
|
||||
@@ -39,6 +40,26 @@ class ProjectConfig:
|
||||
return f"/{generate_permalink(self.name)}"
|
||||
|
||||
|
||||
class CloudProjectConfig(BaseModel):
|
||||
"""Sync configuration for a cloud project.
|
||||
|
||||
This tracks the local working directory and sync state for a project
|
||||
that is synced with Basic Memory Cloud.
|
||||
"""
|
||||
|
||||
local_path: str = Field(
|
||||
description="Local working directory path for this cloud project"
|
||||
)
|
||||
last_sync: Optional[datetime] = Field(
|
||||
default=None,
|
||||
description="Timestamp of last successful sync operation"
|
||||
)
|
||||
bisync_initialized: bool = Field(
|
||||
default=False,
|
||||
description="Whether rclone bisync baseline has been established"
|
||||
)
|
||||
|
||||
|
||||
class BasicMemoryConfig(BaseSettings):
|
||||
"""Pydantic model for Basic Memory global configuration."""
|
||||
|
||||
@@ -138,6 +159,11 @@ class BasicMemoryConfig(BaseSettings):
|
||||
description="Enable cloud mode - all requests go to cloud instead of local (config file value)",
|
||||
)
|
||||
|
||||
cloud_projects: Dict[str, CloudProjectConfig] = Field(
|
||||
default_factory=dict,
|
||||
description="Cloud project sync configuration mapping project names to their local paths and sync state",
|
||||
)
|
||||
|
||||
@property
|
||||
def cloud_mode_enabled(self) -> bool:
|
||||
"""Check if cloud mode is enabled.
|
||||
@@ -427,7 +453,9 @@ def get_project_config(project_name: Optional[str] = None) -> ProjectConfig:
|
||||
def save_basic_memory_config(file_path: Path, config: BasicMemoryConfig) -> None:
|
||||
"""Save configuration to file."""
|
||||
try:
|
||||
file_path.write_text(json.dumps(config.model_dump(), indent=2))
|
||||
# Use model_dump with mode='json' to serialize datetime objects properly
|
||||
config_dict = config.model_dump(mode='json')
|
||||
file_path.write_text(json.dumps(config_dict, indent=2))
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.error(f"Failed to save config: {e}")
|
||||
|
||||
|
||||
+97
-1
@@ -2,8 +2,9 @@
|
||||
|
||||
import tempfile
|
||||
import pytest
|
||||
from datetime import datetime
|
||||
|
||||
from basic_memory.config import BasicMemoryConfig, ConfigManager
|
||||
from basic_memory.config import BasicMemoryConfig, CloudProjectConfig, ConfigManager
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@@ -268,3 +269,98 @@ class TestConfigManager:
|
||||
# Try to remove the default project
|
||||
with pytest.raises(ValueError, match="Cannot remove the default project"):
|
||||
config_manager.remove_project("main")
|
||||
|
||||
def test_config_with_cloud_projects_empty_by_default(self, temp_config_manager):
|
||||
"""Test that cloud_projects field exists and defaults to empty dict."""
|
||||
config_manager = temp_config_manager
|
||||
config = config_manager.load_config()
|
||||
|
||||
assert hasattr(config, "cloud_projects")
|
||||
assert config.cloud_projects == {}
|
||||
|
||||
def test_save_and_load_config_with_cloud_projects(self):
|
||||
"""Test that config with cloud_projects can be saved and loaded."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
|
||||
config_manager = ConfigManager()
|
||||
config_manager.config_dir = temp_path / "basic-memory"
|
||||
config_manager.config_file = config_manager.config_dir / "config.json"
|
||||
config_manager.config_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create config with cloud_projects
|
||||
now = datetime.now()
|
||||
test_config = BasicMemoryConfig(
|
||||
projects={"main": str(temp_path / "main")},
|
||||
cloud_projects={
|
||||
"research": CloudProjectConfig(
|
||||
local_path=str(temp_path / "research-local"),
|
||||
last_sync=now,
|
||||
bisync_initialized=True
|
||||
)
|
||||
}
|
||||
)
|
||||
config_manager.save_config(test_config)
|
||||
|
||||
# Load and verify
|
||||
loaded_config = config_manager.load_config()
|
||||
assert "research" in loaded_config.cloud_projects
|
||||
assert loaded_config.cloud_projects["research"].local_path == str(temp_path / "research-local")
|
||||
assert loaded_config.cloud_projects["research"].bisync_initialized is True
|
||||
assert loaded_config.cloud_projects["research"].last_sync == now
|
||||
|
||||
def test_add_cloud_project_to_existing_config(self):
|
||||
"""Test adding cloud projects to an existing config file."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
|
||||
config_manager = ConfigManager()
|
||||
config_manager.config_dir = temp_path / "basic-memory"
|
||||
config_manager.config_file = config_manager.config_dir / "config.json"
|
||||
config_manager.config_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create initial config without cloud projects
|
||||
initial_config = BasicMemoryConfig(
|
||||
projects={"main": str(temp_path / "main")}
|
||||
)
|
||||
config_manager.save_config(initial_config)
|
||||
|
||||
# Load, modify, and save
|
||||
config = config_manager.load_config()
|
||||
assert config.cloud_projects == {}
|
||||
|
||||
config.cloud_projects["work"] = CloudProjectConfig(
|
||||
local_path=str(temp_path / "work-local")
|
||||
)
|
||||
config_manager.save_config(config)
|
||||
|
||||
# Reload and verify persistence
|
||||
reloaded_config = config_manager.load_config()
|
||||
assert "work" in reloaded_config.cloud_projects
|
||||
assert reloaded_config.cloud_projects["work"].local_path == str(temp_path / "work-local")
|
||||
assert reloaded_config.cloud_projects["work"].bisync_initialized is False
|
||||
|
||||
def test_backward_compatibility_loading_config_without_cloud_projects(self):
|
||||
"""Test that old config files without cloud_projects field can be loaded."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
|
||||
config_manager = ConfigManager()
|
||||
config_manager.config_dir = temp_path / "basic-memory"
|
||||
config_manager.config_file = config_manager.config_dir / "config.json"
|
||||
config_manager.config_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Manually write old-style config without cloud_projects
|
||||
import json
|
||||
old_config_data = {
|
||||
"env": "dev",
|
||||
"projects": {"main": str(temp_path / "main")},
|
||||
"default_project": "main",
|
||||
"log_level": "INFO"
|
||||
}
|
||||
config_manager.config_file.write_text(json.dumps(old_config_data, indent=2))
|
||||
|
||||
# Should load successfully with cloud_projects defaulting to empty dict
|
||||
config = config_manager.load_config()
|
||||
assert config.cloud_projects == {}
|
||||
assert config.projects == {"main": str(temp_path / "main")}
|
||||
|
||||
Reference in New Issue
Block a user