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:
phernandez
2025-10-28 10:35:04 -05:00
parent a92741985a
commit 8bc550f613
3 changed files with 128 additions and 4 deletions
+1 -1
View File
@@ -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"
+30 -2
View File
@@ -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
View File
@@ -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")}