mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 148e07c580 | |||
| 21334cc29b | |||
| db60942267 | |||
| 7bfac158df |
@@ -2,6 +2,19 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
## v0.20.1 (2026-03-10)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **#661**: Fix `bm project list` MCP column to show transport type (stdio/https) instead of DB presence
|
||||
- Renamed "MCP (stdio)" column to "MCP"
|
||||
- Shows actual routing mode: `stdio` for local, `https` for cloud projects
|
||||
- Clears local path display for cloud-mode projects
|
||||
- **#662**: Invalidate config cache when file is modified by another process
|
||||
- Adds mtime-based cache validation to `ConfigManager.load_config()`
|
||||
- Long-lived processes (MCP stdio server) now detect external config changes
|
||||
- Fixes `bm project set-cloud` having no effect on running MCP server
|
||||
|
||||
## v0.20.0 (2026-03-10)
|
||||
|
||||
### Features
|
||||
|
||||
+2
-2
@@ -6,12 +6,12 @@
|
||||
"url": "https://github.com/basicmachines-co/basic-memory.git",
|
||||
"source": "github"
|
||||
},
|
||||
"version": "0.20.0",
|
||||
"version": "0.20.1",
|
||||
"packages": [
|
||||
{
|
||||
"registryType": "pypi",
|
||||
"identifier": "basic-memory",
|
||||
"version": "0.20.0",
|
||||
"version": "0.20.1",
|
||||
"runtimeHint": "uvx",
|
||||
"runtimeArguments": [
|
||||
{"type": "positional", "value": "basic-memory"},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""basic-memory - Local-first knowledge management combining Zettelkasten with knowledge graphs"""
|
||||
|
||||
# Package version - updated by release automation
|
||||
__version__ = "0.20.0"
|
||||
__version__ = "0.20.1"
|
||||
|
||||
# API version for FastAPI - independent of package version
|
||||
__api_version__ = "v0"
|
||||
|
||||
@@ -128,7 +128,7 @@ def list_projects(
|
||||
table.add_column("Cloud Path", style="green")
|
||||
table.add_column("Workspace", style="green")
|
||||
table.add_column("CLI Route", style="blue")
|
||||
table.add_column("MCP (stdio)", style="blue")
|
||||
table.add_column("MCP", style="blue")
|
||||
table.add_column("Sync", style="green")
|
||||
table.add_column("Default", style="magenta")
|
||||
|
||||
@@ -164,6 +164,11 @@ def list_projects(
|
||||
elif entry and entry.mode == ProjectMode.LOCAL and entry.path:
|
||||
local_path = format_path(normalize_project_path(entry.path))
|
||||
|
||||
# Clear local path for cloud-mode projects — only local projects
|
||||
# should display a local path
|
||||
if entry and entry.mode == ProjectMode.CLOUD:
|
||||
local_path = ""
|
||||
|
||||
cloud_path = ""
|
||||
if cloud_project is not None:
|
||||
cloud_path = normalize_project_path(cloud_project.path)
|
||||
@@ -182,7 +187,13 @@ def list_projects(
|
||||
is_default = config.default_project == project_name
|
||||
|
||||
has_sync = bool(entry and entry.local_sync_path)
|
||||
mcp_stdio_target = "local" if local_project is not None else "n/a"
|
||||
# Determine MCP transport based on project routing mode
|
||||
if entry and entry.mode == ProjectMode.CLOUD:
|
||||
mcp_transport = "https"
|
||||
elif entry is None and cloud_project is not None:
|
||||
mcp_transport = "https"
|
||||
else:
|
||||
mcp_transport = "stdio"
|
||||
|
||||
# Show workspace name (type) for cloud-sourced projects
|
||||
ws_label = ""
|
||||
@@ -195,7 +206,7 @@ def list_projects(
|
||||
"local_path": local_path,
|
||||
"cloud_path": cloud_path,
|
||||
"cli_route": cli_route,
|
||||
"mcp_stdio": mcp_stdio_target,
|
||||
"mcp_stdio": mcp_transport,
|
||||
"sync": has_sync,
|
||||
"is_default": is_default,
|
||||
}
|
||||
|
||||
@@ -645,6 +645,12 @@ class BasicMemoryConfig(BaseSettings):
|
||||
|
||||
# Module-level cache for configuration
|
||||
_CONFIG_CACHE: Optional[BasicMemoryConfig] = None
|
||||
# Track config file mtime+size so cross-process changes (e.g. `bm project set-cloud`
|
||||
# in a separate terminal) invalidate the cache in long-lived processes like the
|
||||
# MCP stdio server. Using both mtime and size guards against coarse-granularity
|
||||
# filesystems where two writes within the same second share the same mtime.
|
||||
_CONFIG_MTIME: Optional[float] = None
|
||||
_CONFIG_SIZE: Optional[int] = None
|
||||
|
||||
|
||||
class ConfigManager:
|
||||
@@ -678,13 +684,38 @@ class ConfigManager:
|
||||
Environment variables take precedence over file config values,
|
||||
following Pydantic Settings best practices.
|
||||
|
||||
Uses module-level cache for performance across ConfigManager instances.
|
||||
Uses module-level cache with file mtime validation so that
|
||||
cross-process config changes (e.g. `bm project set-cloud` in a
|
||||
separate terminal) are picked up by long-lived processes like
|
||||
the MCP stdio server.
|
||||
"""
|
||||
global _CONFIG_CACHE
|
||||
global _CONFIG_CACHE, _CONFIG_MTIME, _CONFIG_SIZE
|
||||
|
||||
# Return cached config if available
|
||||
# Trigger: cached config exists but the on-disk file may have been
|
||||
# modified by another process (CLI command in a different terminal).
|
||||
# Why: the MCP server is long-lived; without this check it would
|
||||
# serve stale project routing forever.
|
||||
# Outcome: cheap os.stat() per access; re-read only when mtime or size differs.
|
||||
if _CONFIG_CACHE is not None:
|
||||
return _CONFIG_CACHE
|
||||
try:
|
||||
st = self.config_file.stat()
|
||||
current_mtime = st.st_mtime
|
||||
current_size = st.st_size
|
||||
except OSError:
|
||||
current_mtime = None
|
||||
current_size = None
|
||||
|
||||
if (
|
||||
current_mtime is not None
|
||||
and current_mtime == _CONFIG_MTIME
|
||||
and current_size == _CONFIG_SIZE
|
||||
):
|
||||
return _CONFIG_CACHE
|
||||
|
||||
# mtime/size changed or file gone — invalidate and fall through to re-read
|
||||
_CONFIG_CACHE = None
|
||||
_CONFIG_MTIME = None
|
||||
_CONFIG_SIZE = None
|
||||
|
||||
if self.config_file.exists():
|
||||
try:
|
||||
@@ -739,6 +770,15 @@ class ConfigManager:
|
||||
|
||||
_CONFIG_CACHE = BasicMemoryConfig(**merged_data)
|
||||
|
||||
# Record mtime+size so subsequent calls detect cross-process changes
|
||||
try:
|
||||
st = self.config_file.stat()
|
||||
_CONFIG_MTIME = st.st_mtime
|
||||
_CONFIG_SIZE = st.st_size
|
||||
except OSError:
|
||||
_CONFIG_MTIME = None
|
||||
_CONFIG_SIZE = None
|
||||
|
||||
# Re-save to normalize legacy config into current format
|
||||
if needs_resave:
|
||||
# Create backup before overwriting so users can revert if needed
|
||||
@@ -769,10 +809,12 @@ class ConfigManager:
|
||||
|
||||
def save_config(self, config: BasicMemoryConfig) -> None:
|
||||
"""Save configuration to file and invalidate cache."""
|
||||
global _CONFIG_CACHE
|
||||
global _CONFIG_CACHE, _CONFIG_MTIME, _CONFIG_SIZE
|
||||
save_basic_memory_config(self.config_file, config)
|
||||
# Invalidate cache so next load_config() reads fresh data
|
||||
_CONFIG_CACHE = None
|
||||
_CONFIG_MTIME = None
|
||||
_CONFIG_SIZE = None
|
||||
|
||||
@property
|
||||
def projects(self) -> Dict[str, str]:
|
||||
|
||||
@@ -258,6 +258,8 @@ def config_manager(app_config: BasicMemoryConfig, config_home) -> ConfigManager:
|
||||
from basic_memory import config as config_module
|
||||
|
||||
config_module._CONFIG_CACHE = None
|
||||
config_module._CONFIG_MTIME = None
|
||||
config_module._CONFIG_SIZE = None
|
||||
|
||||
config_manager = ConfigManager()
|
||||
# Update its paths to use the test directory
|
||||
|
||||
@@ -25,6 +25,8 @@ def isolated_home(tmp_path, monkeypatch) -> Path:
|
||||
from basic_memory import config as config_module
|
||||
|
||||
config_module._CONFIG_CACHE = None
|
||||
config_module._CONFIG_MTIME = None
|
||||
config_module._CONFIG_SIZE = None
|
||||
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
if os.name == "nt":
|
||||
|
||||
@@ -350,6 +350,8 @@ def write_config(tmp_path, monkeypatch):
|
||||
from basic_memory import config as config_module
|
||||
|
||||
config_module._CONFIG_CACHE = None
|
||||
config_module._CONFIG_MTIME = None
|
||||
config_module._CONFIG_SIZE = None
|
||||
|
||||
config_dir = tmp_path / ".basic-memory"
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -27,6 +27,8 @@ def mock_config(tmp_path, monkeypatch):
|
||||
from basic_memory import config as config_module
|
||||
|
||||
config_module._CONFIG_CACHE = None
|
||||
config_module._CONFIG_MTIME = None
|
||||
config_module._CONFIG_SIZE = None
|
||||
|
||||
config_dir = tmp_path / ".basic-memory"
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -29,6 +29,8 @@ def write_config(tmp_path, monkeypatch):
|
||||
from basic_memory import config as config_module
|
||||
|
||||
config_module._CONFIG_CACHE = None
|
||||
config_module._CONFIG_MTIME = None
|
||||
config_module._CONFIG_SIZE = None
|
||||
|
||||
config_dir = tmp_path / ".basic-memory"
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -122,15 +124,16 @@ def test_project_list_shows_local_cloud_presence_and_routes(
|
||||
assert "Local Path" in result.stdout
|
||||
assert "Cloud Path" in result.stdout
|
||||
assert "CLI Route" in result.stdout
|
||||
assert "MCP (stdio)" in result.stdout
|
||||
assert "MCP" in result.stdout
|
||||
|
||||
lines = result.stdout.splitlines()
|
||||
alpha_line = next(line for line in lines if "│ alpha" in line)
|
||||
beta_line = next(line for line in lines if "│ beta" in line)
|
||||
|
||||
assert "local" in alpha_line # CLI route for alpha
|
||||
assert "stdio" in alpha_line # Local projects use stdio transport
|
||||
assert "cloud" in beta_line # CLI route for beta
|
||||
assert "n/a" in beta_line # MCP stdio route is unavailable for cloud-only projects
|
||||
assert "https" in beta_line # Cloud projects use HTTPS transport
|
||||
assert "alpha-local" in result.stdout
|
||||
assert "/alpha" in result.stdout
|
||||
assert "/beta" in result.stdout
|
||||
|
||||
@@ -22,6 +22,8 @@ def mock_config(tmp_path, monkeypatch):
|
||||
from basic_memory import config as config_module
|
||||
|
||||
config_module._CONFIG_CACHE = None
|
||||
config_module._CONFIG_MTIME = None
|
||||
config_module._CONFIG_SIZE = None
|
||||
|
||||
config_dir = tmp_path / ".basic-memory"
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -68,6 +70,8 @@ class TestSetCloud:
|
||||
from basic_memory import config as config_module
|
||||
|
||||
config_module._CONFIG_CACHE = None
|
||||
config_module._CONFIG_MTIME = None
|
||||
config_module._CONFIG_SIZE = None
|
||||
|
||||
config_dir = tmp_path / ".basic-memory"
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -91,6 +95,8 @@ class TestSetCloud:
|
||||
from basic_memory import config as config_module
|
||||
|
||||
config_module._CONFIG_CACHE = None
|
||||
config_module._CONFIG_MTIME = None
|
||||
config_module._CONFIG_SIZE = None
|
||||
|
||||
config_dir = tmp_path / ".basic-memory"
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -161,11 +167,15 @@ class TestSetLocal:
|
||||
|
||||
# Manually set workspace_id on the project
|
||||
config_module._CONFIG_CACHE = None
|
||||
config_module._CONFIG_MTIME = None
|
||||
config_module._CONFIG_SIZE = None
|
||||
config_data = json.loads(mock_config.read_text())
|
||||
config_data["projects"]["research"]["mode"] = "cloud"
|
||||
config_data["projects"]["research"]["workspace_id"] = "11111111-1111-1111-1111-111111111111"
|
||||
mock_config.write_text(json.dumps(config_data, indent=2))
|
||||
config_module._CONFIG_CACHE = None
|
||||
config_module._CONFIG_MTIME = None
|
||||
config_module._CONFIG_SIZE = None
|
||||
|
||||
# Set back to local
|
||||
result = runner.invoke(app, ["project", "set-local", "research"])
|
||||
@@ -173,6 +183,8 @@ class TestSetLocal:
|
||||
|
||||
# Verify workspace_id was cleared
|
||||
config_module._CONFIG_CACHE = None
|
||||
config_module._CONFIG_MTIME = None
|
||||
config_module._CONFIG_SIZE = None
|
||||
updated_data = json.loads(mock_config.read_text())
|
||||
assert updated_data["projects"]["research"]["workspace_id"] is None
|
||||
assert updated_data["projects"]["research"]["mode"] == "local"
|
||||
@@ -187,6 +199,8 @@ class TestSetCloudWithWorkspace:
|
||||
from basic_memory.schemas.cloud import WorkspaceInfo
|
||||
|
||||
config_module._CONFIG_CACHE = None
|
||||
config_module._CONFIG_MTIME = None
|
||||
config_module._CONFIG_SIZE = None
|
||||
|
||||
async def fake_get_available_workspaces():
|
||||
return [
|
||||
@@ -210,6 +224,8 @@ class TestSetCloudWithWorkspace:
|
||||
|
||||
# Verify workspace_id was persisted
|
||||
config_module._CONFIG_CACHE = None
|
||||
config_module._CONFIG_MTIME = None
|
||||
config_module._CONFIG_SIZE = None
|
||||
updated_data = json.loads(mock_config.read_text())
|
||||
assert (
|
||||
updated_data["projects"]["research"]["workspace_id"]
|
||||
@@ -222,6 +238,8 @@ class TestSetCloudWithWorkspace:
|
||||
from basic_memory.schemas.cloud import WorkspaceInfo
|
||||
|
||||
config_module._CONFIG_CACHE = None
|
||||
config_module._CONFIG_MTIME = None
|
||||
config_module._CONFIG_SIZE = None
|
||||
|
||||
async def fake_get_available_workspaces():
|
||||
return [
|
||||
@@ -249,17 +267,23 @@ class TestSetCloudWithWorkspace:
|
||||
from basic_memory import config as config_module
|
||||
|
||||
config_module._CONFIG_CACHE = None
|
||||
config_module._CONFIG_MTIME = None
|
||||
config_module._CONFIG_SIZE = None
|
||||
|
||||
# Set default_workspace in config
|
||||
config_data = json.loads(mock_config.read_text())
|
||||
config_data["default_workspace"] = "global-default-tenant-id"
|
||||
mock_config.write_text(json.dumps(config_data, indent=2))
|
||||
config_module._CONFIG_CACHE = None
|
||||
config_module._CONFIG_MTIME = None
|
||||
config_module._CONFIG_SIZE = None
|
||||
|
||||
result = runner.invoke(app, ["project", "set-cloud", "research"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
# Verify workspace_id was set from default
|
||||
config_module._CONFIG_CACHE = None
|
||||
config_module._CONFIG_MTIME = None
|
||||
config_module._CONFIG_SIZE = None
|
||||
updated_data = json.loads(mock_config.read_text())
|
||||
assert updated_data["projects"]["research"]["workspace_id"] == "global-default-tenant-id"
|
||||
|
||||
@@ -76,6 +76,8 @@ class TestWorkspaceSetDefault:
|
||||
monkeypatch.setenv("HOME", str(temp_path))
|
||||
monkeypatch.setenv("BASIC_MEMORY_CONFIG_DIR", str(config_dir))
|
||||
basic_memory.config._CONFIG_CACHE = None
|
||||
basic_memory.config._CONFIG_MTIME = None
|
||||
basic_memory.config._CONFIG_SIZE = None
|
||||
|
||||
config_manager = ConfigManager()
|
||||
test_config = BasicMemoryConfig(
|
||||
@@ -106,6 +108,8 @@ class TestWorkspaceSetDefault:
|
||||
|
||||
# Verify config was updated
|
||||
basic_memory.config._CONFIG_CACHE = None
|
||||
basic_memory.config._CONFIG_MTIME = None
|
||||
basic_memory.config._CONFIG_SIZE = None
|
||||
config = ConfigManager().config
|
||||
assert config.default_workspace == "11111111-1111-1111-1111-111111111111"
|
||||
|
||||
|
||||
@@ -138,6 +138,8 @@ def config_manager(app_config: BasicMemoryConfig, config_home: Path, monkeypatch
|
||||
from basic_memory import config as config_module
|
||||
|
||||
config_module._CONFIG_CACHE = None
|
||||
config_module._CONFIG_MTIME = None
|
||||
config_module._CONFIG_SIZE = None
|
||||
|
||||
# Create a new ConfigManager that uses the test home directory
|
||||
config_manager = ConfigManager()
|
||||
|
||||
@@ -1257,6 +1257,11 @@ class TestWriteNoteOverwriteGuard:
|
||||
# Set config to allow overwrites by default
|
||||
app_config.write_note_overwrite_default = True
|
||||
config_module._CONFIG_CACHE = app_config
|
||||
# Pin mtime+size to the on-disk file so the cache guard sees a match
|
||||
# and keeps our injected config instead of re-reading from disk.
|
||||
_st = config_manager.config_file.stat()
|
||||
config_module._CONFIG_MTIME = _st.st_mtime
|
||||
config_module._CONFIG_SIZE = _st.st_size
|
||||
|
||||
try:
|
||||
await write_note(
|
||||
@@ -1281,6 +1286,9 @@ class TestWriteNoteOverwriteGuard:
|
||||
# Restore config
|
||||
app_config.write_note_overwrite_default = False
|
||||
config_module._CONFIG_CACHE = app_config
|
||||
_st = config_manager.config_file.stat()
|
||||
config_module._CONFIG_MTIME = _st.st_mtime
|
||||
config_module._CONFIG_SIZE = _st.st_size
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_note_new_note_unaffected(self, app, test_project):
|
||||
|
||||
@@ -778,6 +778,8 @@ async def test_add_project_with_project_root_sanitizes_paths(
|
||||
from basic_memory import config as config_module
|
||||
|
||||
config_module._CONFIG_CACHE = None
|
||||
config_module._CONFIG_MTIME = None
|
||||
config_module._CONFIG_SIZE = None
|
||||
|
||||
test_cases = [
|
||||
# (project_name, user_path, expected_sanitized_name)
|
||||
@@ -845,6 +847,8 @@ async def test_add_project_with_project_root_rejects_escape_attempts(
|
||||
from basic_memory import config as config_module
|
||||
|
||||
config_module._CONFIG_CACHE = None
|
||||
config_module._CONFIG_MTIME = None
|
||||
config_module._CONFIG_SIZE = None
|
||||
|
||||
# All of these should succeed by being sanitized to paths under project_root
|
||||
# The sanitization removes dangerous patterns, so they don't escape
|
||||
@@ -931,6 +935,8 @@ async def test_add_project_with_project_root_normalizes_case(
|
||||
from basic_memory import config as config_module
|
||||
|
||||
config_module._CONFIG_CACHE = None
|
||||
config_module._CONFIG_MTIME = None
|
||||
config_module._CONFIG_SIZE = None
|
||||
|
||||
test_cases = [
|
||||
# (input_path, expected_normalized_path)
|
||||
@@ -985,6 +991,8 @@ async def test_add_project_with_project_root_detects_case_collisions(
|
||||
from basic_memory import config as config_module
|
||||
|
||||
config_module._CONFIG_CACHE = None
|
||||
config_module._CONFIG_MTIME = None
|
||||
config_module._CONFIG_SIZE = None
|
||||
|
||||
# First, create a project with lowercase path
|
||||
first_project = "documents-project"
|
||||
@@ -1159,6 +1167,8 @@ async def test_add_project_nested_validation_with_project_root(
|
||||
from basic_memory import config as config_module
|
||||
|
||||
config_module._CONFIG_CACHE = None
|
||||
config_module._CONFIG_MTIME = None
|
||||
config_module._CONFIG_SIZE = None
|
||||
|
||||
parent_project_name = f"cloud-parent-{os.urandom(4).hex()}"
|
||||
child_project_name = f"cloud-child-{os.urandom(4).hex()}"
|
||||
|
||||
@@ -213,6 +213,8 @@ class TestBasicMemoryConfig:
|
||||
}
|
||||
config_manager.config_file.write_text(json.dumps(config_data, indent=2))
|
||||
basic_memory.config._CONFIG_CACHE = None
|
||||
basic_memory.config._CONFIG_MTIME = None
|
||||
basic_memory.config._CONFIG_SIZE = None
|
||||
|
||||
loaded = config_manager.load_config()
|
||||
assert loaded.default_project == "research"
|
||||
@@ -238,6 +240,8 @@ class TestBasicMemoryConfig:
|
||||
}
|
||||
config_manager.config_file.write_text(json.dumps(config_data, indent=2))
|
||||
basic_memory.config._CONFIG_CACHE = None
|
||||
basic_memory.config._CONFIG_MTIME = None
|
||||
basic_memory.config._CONFIG_SIZE = None
|
||||
|
||||
loaded = config_manager.load_config()
|
||||
assert loaded.default_project == "work"
|
||||
@@ -545,6 +549,8 @@ class TestConfigManager:
|
||||
import basic_memory.config
|
||||
|
||||
basic_memory.config._CONFIG_CACHE = None
|
||||
basic_memory.config._CONFIG_MTIME = None
|
||||
basic_memory.config._CONFIG_SIZE = None
|
||||
|
||||
# Should load successfully with migration to ProjectEntry
|
||||
config = config_manager.load_config()
|
||||
@@ -585,6 +591,8 @@ class TestConfigManager:
|
||||
import basic_memory.config
|
||||
|
||||
basic_memory.config._CONFIG_CACHE = None
|
||||
basic_memory.config._CONFIG_MTIME = None
|
||||
basic_memory.config._CONFIG_SIZE = None
|
||||
|
||||
config = config_manager.load_config()
|
||||
|
||||
@@ -617,6 +625,8 @@ class TestConfigManager:
|
||||
import basic_memory.config
|
||||
|
||||
basic_memory.config._CONFIG_CACHE = None
|
||||
basic_memory.config._CONFIG_MTIME = None
|
||||
basic_memory.config._CONFIG_SIZE = None
|
||||
|
||||
loaded = config_manager.load_config()
|
||||
assert isinstance(loaded, BasicMemoryConfig)
|
||||
@@ -647,6 +657,8 @@ class TestConfigManager:
|
||||
import basic_memory.config
|
||||
|
||||
basic_memory.config._CONFIG_CACHE = None
|
||||
basic_memory.config._CONFIG_MTIME = None
|
||||
basic_memory.config._CONFIG_SIZE = None
|
||||
|
||||
config_manager.load_config()
|
||||
|
||||
@@ -678,6 +690,8 @@ class TestConfigManager:
|
||||
import basic_memory.config
|
||||
|
||||
basic_memory.config._CONFIG_CACHE = None
|
||||
basic_memory.config._CONFIG_MTIME = None
|
||||
basic_memory.config._CONFIG_SIZE = None
|
||||
|
||||
config_manager.load_config()
|
||||
|
||||
@@ -1099,6 +1113,8 @@ class TestProjectMode:
|
||||
import basic_memory.config
|
||||
|
||||
basic_memory.config._CONFIG_CACHE = None
|
||||
basic_memory.config._CONFIG_MTIME = None
|
||||
basic_memory.config._CONFIG_SIZE = None
|
||||
|
||||
# Should load successfully with migration
|
||||
config = config_manager.load_config()
|
||||
@@ -1174,6 +1190,116 @@ class TestProjectMode:
|
||||
assert loaded.projects["main"].workspace_id is None
|
||||
|
||||
|
||||
class TestConfigCacheMtimeInvalidation:
|
||||
"""Test that config cache is invalidated when file is modified externally."""
|
||||
|
||||
def test_cache_returns_same_config_when_file_unchanged(self, config_home):
|
||||
"""Verify cache hit when config file mtime has not changed."""
|
||||
import basic_memory.config
|
||||
|
||||
basic_memory.config._CONFIG_CACHE = None
|
||||
basic_memory.config._CONFIG_MTIME = None
|
||||
basic_memory.config._CONFIG_SIZE = None
|
||||
|
||||
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)
|
||||
|
||||
test_config = BasicMemoryConfig(
|
||||
projects={"main": {"path": str(temp_path / "main")}},
|
||||
default_project="main",
|
||||
)
|
||||
config_manager.save_config(test_config)
|
||||
|
||||
# First load populates cache
|
||||
config1 = config_manager.load_config()
|
||||
assert config1.default_project == "main"
|
||||
|
||||
# Second load should return cached config (same object)
|
||||
config2 = config_manager.load_config()
|
||||
assert config1 is config2
|
||||
|
||||
def test_cache_invalidated_when_file_modified(self, config_home):
|
||||
"""Verify cache miss when config file is modified by another process."""
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
|
||||
import basic_memory.config
|
||||
|
||||
basic_memory.config._CONFIG_CACHE = None
|
||||
basic_memory.config._CONFIG_MTIME = None
|
||||
basic_memory.config._CONFIG_SIZE = None
|
||||
|
||||
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)
|
||||
|
||||
test_config = BasicMemoryConfig(
|
||||
projects={"main": {"path": str(temp_path / "main")}},
|
||||
default_project="main",
|
||||
)
|
||||
config_manager.save_config(test_config)
|
||||
|
||||
# First load populates cache
|
||||
config1 = config_manager.load_config()
|
||||
assert config1.get_project_mode("main") == ProjectMode.LOCAL
|
||||
|
||||
# Simulate external process modifying the config file
|
||||
config_data = json.loads(config_manager.config_file.read_text())
|
||||
config_data["projects"]["main"]["mode"] = "cloud"
|
||||
|
||||
# Ensure mtime actually changes (some filesystems have 1s granularity)
|
||||
time.sleep(0.05)
|
||||
config_manager.config_file.write_text(json.dumps(config_data, indent=2))
|
||||
# Force mtime change on filesystems with coarse granularity
|
||||
new_mtime = os.path.getmtime(config_manager.config_file) + 1
|
||||
os.utime(config_manager.config_file, (new_mtime, new_mtime))
|
||||
|
||||
# Next load should detect mtime change and re-read
|
||||
config2 = config_manager.load_config()
|
||||
assert config2.get_project_mode("main") == ProjectMode.CLOUD
|
||||
assert config1 is not config2
|
||||
|
||||
def test_save_config_resets_mtime(self, config_home):
|
||||
"""Verify save_config clears both cache and mtime."""
|
||||
import basic_memory.config
|
||||
|
||||
basic_memory.config._CONFIG_CACHE = None
|
||||
basic_memory.config._CONFIG_MTIME = None
|
||||
basic_memory.config._CONFIG_SIZE = None
|
||||
|
||||
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)
|
||||
|
||||
test_config = BasicMemoryConfig(
|
||||
projects={"main": {"path": str(temp_path / "main")}},
|
||||
)
|
||||
config_manager.save_config(test_config)
|
||||
|
||||
# Load to populate cache
|
||||
config_manager.load_config()
|
||||
assert basic_memory.config._CONFIG_CACHE is not None
|
||||
assert basic_memory.config._CONFIG_MTIME is not None
|
||||
assert basic_memory.config._CONFIG_SIZE is not None
|
||||
|
||||
# Save should clear all cache state
|
||||
config_manager.save_config(test_config)
|
||||
assert basic_memory.config._CONFIG_CACHE is None
|
||||
assert basic_memory.config._CONFIG_MTIME is None
|
||||
assert basic_memory.config._CONFIG_SIZE is None
|
||||
|
||||
|
||||
class TestLocalSyncPathMigration:
|
||||
"""Test migration that promotes local_sync_path into path for cloud projects."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user