From da5424fb55765cd716ad969f1a0fde41b84c9e25 Mon Sep 17 00:00:00 2001 From: phernandez Date: Fri, 28 Aug 2026 16:56:44 -0500 Subject: [PATCH 01/10] fix(cli): seed config projects into the database on CLI-only installs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fresh install writes `main` into config.json, but only the API/MCP server lifespan (initialize_app) reconciled config projects into the projects table. The CLI skips that initialization for `project`, `status`, `tool`, and most other commands, so a CLI-only flow hit "Project not found: 'main'" on every default-project command while `project add main` refused with "already exists" — the #974 wedge again. Run the same reconciliation once per process from the API dependency that opens the local database outside a server lifespan (get_engine_factory's CLI/MCP fallback), gated on skip_local_initialization like initialize_app so cloud/stateless deployments are untouched. Regression tests: a pristine-HOME subprocess `bm status` must succeed and see `main`; the once-guard runs reconciliation a single time and skips cloud mode. Fixes #1334 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017STCpbNsYjZgUdftxgEAZ4 Signed-off-by: phernandez --- src/basic_memory/deps/db.py | 7 +++ src/basic_memory/services/initialization.py | 26 +++++++++ tests/cli/test_fresh_install.py | 56 +++++++++++++++++++ .../test_initialization_reconcile_once.py | 40 +++++++++++++ 4 files changed, 129 insertions(+) create mode 100644 tests/cli/test_fresh_install.py create mode 100644 tests/services/test_initialization_reconcile_once.py diff --git a/src/basic_memory/deps/db.py b/src/basic_memory/deps/db.py index daecb107c..a24a365c9 100644 --- a/src/basic_memory/deps/db.py +++ b/src/basic_memory/deps/db.py @@ -44,6 +44,13 @@ async def get_engine_factory( app_config = resolve_container().config engine, session_maker = await db.get_or_create_db(app_config.database_path) + # Deferred import for the same reason as resolve_container above: the + # services layer is reached through api.app -> routers -> deps. + from basic_memory.services.initialization import reconcile_projects_with_config_once + + # No server lifespan ran initialize_app() for this process, so seed + # config.json's projects into the database here (#1334). + await reconcile_projects_with_config_once(app_config) return engine, session_maker diff --git a/src/basic_memory/services/initialization.py b/src/basic_memory/services/initialization.py index c25965874..180a947d8 100644 --- a/src/basic_memory/services/initialization.py +++ b/src/basic_memory/services/initialization.py @@ -177,6 +177,32 @@ async def reconcile_projects_with_config(app_config: BasicMemoryConfig): logger.info("Continuing with initialization despite synchronization error") +# Database paths this process has already reconciled config projects into. +_reconciled_database_paths: set[Path] = set() + + +async def reconcile_projects_with_config_once(app_config: BasicMemoryConfig) -> None: + """Reconcile config projects into the database the first time a process opens it. + + Trigger: a request opens the local database outside a server lifespan — the + one-shot CLI and the MCP local flow drive the API over an in-process ASGI + transport, so nothing else seeds config.json's projects into a fresh database. + Why: initialize_app() only runs for the API/MCP servers and a few CLI + commands, so a CLI-only fresh install had `main` in config.json but no + projects row, and every default-project command failed while + `project add main` refused with "already exists" (#1334, regression of #974). + Outcome: the same reconciliation the servers run, once per process; cloud and + stateless deployments stay untouched, matching initialize_app(). + """ + if app_config.skip_local_initialization: + return + database_path = Path(app_config.database_path) + if database_path in _reconciled_database_paths: + return + _reconciled_database_paths.add(database_path) + await reconcile_projects_with_config(app_config) + + # Strong references for fire-and-forget startup index tasks; the event loop # alone would hold only weak references (asyncio.create_task docs). _initial_index_tasks: set[asyncio.Task[None]] = set() diff --git a/tests/cli/test_fresh_install.py b/tests/cli/test_fresh_install.py new file mode 100644 index 000000000..bf344bcbb --- /dev/null +++ b/tests/cli/test_fresh_install.py @@ -0,0 +1,56 @@ +"""Regression tests for a fresh, CLI-only install (#1334, #974).""" + +import os +import subprocess +from pathlib import Path + +PROJECT_ROOT = Path(__file__).parent.parent.parent + + +def _pristine_env(home: Path) -> dict[str, str]: + """A profile with no Basic Memory state, and none inherited from the developer or CI.""" + env = { + key: value + for key, value in os.environ.items() + if not key.startswith("BASIC_MEMORY_") and key != "PYTEST_CURRENT_TEST" + } + env.update( + HOME=str(home), + USERPROFILE=str(home), + BASIC_MEMORY_HOME=str(home / "basic-memory"), + BASIC_MEMORY_CONFIG_DIR=str(home / ".basic-memory"), + BASIC_MEMORY_NO_PROMOS="1", + ) + return env + + +def _bm(args: list[str], env: dict[str, str]) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["uv", "run", "bm", *args], + capture_output=True, + text=True, + env=env, + # Guards a wedged install, not a performance budget: first run creates + # the database and runs migrations. + timeout=90, + cwd=PROJECT_ROOT, + ) + + +def test_fresh_cli_install_can_use_the_bootstrapped_default_project(tmp_path): + """The auto-created `main` project must exist in the database, not just config.json. + + A fresh install seeds `main` into config.json. Only the API/MCP server lifespan + used to reconcile that into the projects table, so a CLI-only flow hit + "Project not found: 'main'" on every default-project command while + `project add main` refused with "already exists" (#1334). + """ + home = tmp_path / "home" + home.mkdir() + env = _pristine_env(home) + + status = _bm(["status"], env) + + assert status.returncode == 0, status.stderr + assert "Project not found" not in status.stdout + status.stderr + assert "main" in status.stdout diff --git a/tests/services/test_initialization_reconcile_once.py b/tests/services/test_initialization_reconcile_once.py new file mode 100644 index 000000000..e8144a73c --- /dev/null +++ b/tests/services/test_initialization_reconcile_once.py @@ -0,0 +1,40 @@ +"""The once-per-process project reconciliation used by the CLI's API path (#1334).""" + +import pytest + +from basic_memory.services import initialization + + +@pytest.mark.asyncio +async def test_reconcile_projects_with_config_once_runs_once_per_database(app_config, monkeypatch): + """Every local ASGI request hits the dependency; only the first may reconcile.""" + calls = [] + + async def fake_reconcile(config): + calls.append(config) + + monkeypatch.setattr(initialization, "reconcile_projects_with_config", fake_reconcile) + monkeypatch.setattr(initialization, "_reconciled_database_paths", set()) + + await initialization.reconcile_projects_with_config_once(app_config) + await initialization.reconcile_projects_with_config_once(app_config) + + assert calls == [app_config] + + +@pytest.mark.asyncio +async def test_reconcile_projects_with_config_once_skips_cloud_deployments(app_config, monkeypatch): + """Cloud/stateless deployments own their project rows; reconciling would delete them.""" + calls = [] + + async def fake_reconcile(config): + calls.append(config) + + monkeypatch.setattr(initialization, "reconcile_projects_with_config", fake_reconcile) + monkeypatch.setattr(initialization, "_reconciled_database_paths", set()) + monkeypatch.setenv("BASIC_MEMORY_CLOUD_MODE", "true") + assert app_config.skip_local_initialization + + await initialization.reconcile_projects_with_config_once(app_config) + + assert calls == [] From aebe9cf60069149f9cd885e264deb6672aa7acbe Mon Sep 17 00:00:00 2001 From: phernandez Date: Fri, 28 Aug 2026 17:06:46 -0500 Subject: [PATCH 02/10] test(mcp): stop relying on an empty local database as the routing signal Two tests used "project validation fails against the empty local DB" to prove --local routing, and faked get_or_create_db with stand-ins. The CLI/MCP local ASGI client now seeds config projects into the database (#1334), so validation succeeds: assert routing directly (no workspace lookup, ASGI transport) and skip reconciliation where the engine is a stand-in. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017STCpbNsYjZgUdftxgEAZ4 Signed-off-by: phernandez --- tests/mcp/test_async_client_modes.py | 13 +++++++++++++ tests/mcp/test_project_context.py | 20 +++++++++++++------- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/tests/mcp/test_async_client_modes.py b/tests/mcp/test_async_client_modes.py index 70235fd0c..f09d86984 100644 --- a/tests/mcp/test_async_client_modes.py +++ b/tests/mcp/test_async_client_modes.py @@ -7,6 +7,7 @@ from basic_memory.cli.auth import CLIAuth from basic_memory.config import ProjectMode from basic_memory.mcp import async_client as async_client_module +from basic_memory.services import initialization from basic_memory.mcp.async_client import ( get_client, get_cloud_control_plane_client, @@ -78,7 +79,13 @@ async def fake_get_or_create_db(db_path): calls.append(db_path) return engine, session_maker + async def skip_project_reconciliation(app_config): + """The engine/session maker here are stand-ins; reconciliation needs a real database.""" + monkeypatch.setattr(db, "get_or_create_db", fake_get_or_create_db) + monkeypatch.setattr( + initialization, "reconcile_projects_with_config_once", skip_project_reconciliation + ) try: async with get_client() as client: @@ -238,7 +245,13 @@ async def fake_get_or_create_db(db_path): calls.append(db_path) return engine, session_maker + async def skip_project_reconciliation(app_config): + """The engine/session maker here are stand-ins; reconciliation needs a real database.""" + monkeypatch.setattr(db, "get_or_create_db", fake_get_or_create_db) + monkeypatch.setattr( + initialization, "reconcile_projects_with_config_once", skip_project_reconciliation + ) first_context = get_client() second_context = get_client() diff --git a/tests/mcp/test_project_context.py b/tests/mcp/test_project_context.py index c6474e5b6..850840a2e 100644 --- a/tests/mcp/test_project_context.py +++ b/tests/mcp/test_project_context.py @@ -2407,6 +2407,9 @@ class TestGetProjectClientRoutingOrder: @pytest.mark.asyncio async def test_local_flag_skips_workspace_resolution(self, config_manager, monkeypatch): """--local flag should never trigger workspace resolution, even for cloud projects.""" + import httpx + + import basic_memory.mcp.project_context as project_context from basic_memory.mcp.project_context import get_project_client from basic_memory.config import ProjectEntry, ProjectMode @@ -2422,14 +2425,17 @@ async def test_local_flag_skips_workspace_resolution(self, config_manager, monke monkeypatch.setenv("BASIC_MEMORY_FORCE_LOCAL", "true") monkeypatch.delenv("BASIC_MEMORY_FORCE_CLOUD", raising=False) - # Should not raise "Multiple workspaces" — it should skip workspace entirely - # It will fail at project validation (no API running), which proves routing worked - with pytest.raises(Exception) as exc_info: - async with get_project_client(project="cloud-proj"): - pass + # Any workspace lookup is the failure being guarded against. + async def fail_on_workspace_lookup(*args, **kwargs): + raise AssertionError("--local must not resolve workspaces") + + monkeypatch.setattr(project_context, "get_available_workspaces", fail_on_workspace_lookup) - # The error should NOT be about workspaces - assert "workspace" not in str(exc_info.value).lower() + # The local ASGI client seeds config projects into the database (#1334), + # so the cloud-mode entry validates locally and the context enters. + async with get_project_client(project="cloud-proj") as (client, active_project): + assert isinstance(client._transport, httpx.ASGITransport) # pyright: ignore[reportPrivateUsage] + assert active_project.name == "cloud-proj" @pytest.mark.asyncio async def test_local_route_clears_stale_cached_workspace(self, config_manager, monkeypatch): From cc8b54a97e1bd75ea3a886e2e44a1203823ecce5 Mon Sep 17 00:00:00 2001 From: phernandez Date: Fri, 28 Aug 2026 17:15:59 -0500 Subject: [PATCH 03/10] fix(core): keep cloud-mode projects out of local reconciliation synchronize_projects() created a local projects row for every config entry regardless of mode, and let the database default overwrite the configured one. With reconciliation now running on every CLI process (#1334) that undid `set-cloud` on each run: the row set-cloud had deliberately deleted came back, and a cloud default could be flipped to a local project. Skip cloud-mode entries when seeding rows and leave a cloud-mode default alone; the database default is only the local fallback. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017STCpbNsYjZgUdftxgEAZ4 Signed-off-by: phernandez --- src/basic_memory/services/project_service.py | 17 ++++++++- tests/services/test_project_service.py | 40 ++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/src/basic_memory/services/project_service.py b/src/basic_memory/services/project_service.py index d866bbd70..79cb04579 100644 --- a/src/basic_memory/services/project_service.py +++ b/src/basic_memory/services/project_service.py @@ -39,6 +39,7 @@ WATCH_STATUS_JSON, ConfigManager, ProjectEntry, + ProjectMode, get_project_config, ProjectConfig, ) @@ -540,6 +541,13 @@ async def synchronize_projects(self) -> None: # pragma: no cover # Add projects that exist in config but not in DB for name, entry in config_project_names.items(): + # Trigger: the entry routes to the cloud (`set-cloud`, `add --cloud`). + # Why: set-cloud deliberately deletes the local row so the project's + # configured state is purely cloud; recreating it here would + # undo that cutover on every reconciliation (#1334 review). + # Outcome: cloud-mode entries never get a local projects row. + if entry.mode == ProjectMode.CLOUD: + continue if name not in db_projects_by_permalink: logger.info(f"Adding project '{name}' to database") project_data = { @@ -572,8 +580,15 @@ async def synchronize_projects(self) -> None: # pragma: no cover # Make sure default project is synchronized between config and database db_default = await self.repository.get_default_project(session) config_default = self.config_manager.default_project + config_default_entry = config_project_names.get(config_default) - if db_default and db_default.name != config_default: + # Trigger: the configured default routes to the cloud. + # Why: it has no local row by design, so the database default is only + # the local fallback and must not overwrite the user's choice. + # Outcome: config keeps the cloud default; the DB default stays local. + if config_default_entry is not None and config_default_entry.mode == ProjectMode.CLOUD: + pass + elif db_default and db_default.name != config_default: # Update config to match DB default logger.info(f"Updating default project in config to '{db_default.name}'") self.config_manager.set_default_project(db_default.name) diff --git a/tests/services/test_project_service.py b/tests/services/test_project_service.py index ac351c8bd..01531c697 100644 --- a/tests/services/test_project_service.py +++ b/tests/services/test_project_service.py @@ -1781,3 +1781,43 @@ async def test_add_doctor_project_requires_project_root(project_service: Project """Without a configured root there is no nesting problem for doctor to solve.""" with pytest.raises(ValueError, match="BASIC_MEMORY_PROJECT_ROOT"): await project_service.add_doctor_project() + + +@pytest.mark.asyncio +async def test_synchronize_projects_skips_cloud_mode_entries(project_service: ProjectService): + """A cloud-mode config entry must not get a local projects row (#1334 review). + + `set-cloud` deletes the local row on purpose so the project's configured state + is purely cloud. Reconciliation now runs on every CLI process, so recreating the + row here would undo that cutover each time. + """ + from basic_memory.config import ProjectEntry, ProjectMode + + config_manager = project_service.config_manager + config = config_manager.load_config() + config.projects["research-cloud"] = ProjectEntry(path="", mode=ProjectMode.CLOUD) + config_manager.save_config(config) + + await project_service.synchronize_projects() + + assert await _get_project(project_service, "research-cloud") is None + + +@pytest.mark.asyncio +async def test_synchronize_projects_keeps_a_cloud_default_in_config( + project_service: ProjectService, +): + """The database default is only the local fallback; a cloud default must survive.""" + from basic_memory.config import ProjectEntry, ProjectMode + + config_manager = project_service.config_manager + config = config_manager.load_config() + config.projects["research-cloud"] = ProjectEntry(path="", mode=ProjectMode.CLOUD) + config.default_project = "research-cloud" + config_manager.save_config(config) + + await project_service.synchronize_projects() + + assert config_manager.load_config().default_project == "research-cloud" + db_default = await _get_default_project(project_service) + assert db_default is not None and db_default.name != "research-cloud" From e78be5000ac43cb231a4799bfb1eb60ae97ea685 Mon Sep 17 00:00:00 2001 From: phernandez Date: Fri, 28 Aug 2026 17:24:44 -0500 Subject: [PATCH 04/10] test(mcp): expect the local miss for a cloud-mode entry under --local MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reconciliation now skips cloud-mode entries, so the routing test's cloud project has no local row and validation 404s. That miss — not a workspace error — is the signal that routing stayed local; assert it while keeping the workspace-lookup guard. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017STCpbNsYjZgUdftxgEAZ4 Signed-off-by: phernandez --- tests/mcp/test_project_context.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/tests/mcp/test_project_context.py b/tests/mcp/test_project_context.py index 850840a2e..c98929016 100644 --- a/tests/mcp/test_project_context.py +++ b/tests/mcp/test_project_context.py @@ -2407,8 +2407,6 @@ class TestGetProjectClientRoutingOrder: @pytest.mark.asyncio async def test_local_flag_skips_workspace_resolution(self, config_manager, monkeypatch): """--local flag should never trigger workspace resolution, even for cloud projects.""" - import httpx - import basic_memory.mcp.project_context as project_context from basic_memory.mcp.project_context import get_project_client from basic_memory.config import ProjectEntry, ProjectMode @@ -2431,11 +2429,14 @@ async def fail_on_workspace_lookup(*args, **kwargs): monkeypatch.setattr(project_context, "get_available_workspaces", fail_on_workspace_lookup) - # The local ASGI client seeds config projects into the database (#1334), - # so the cloud-mode entry validates locally and the context enters. - async with get_project_client(project="cloud-proj") as (client, active_project): - assert isinstance(client._transport, httpx.ASGITransport) # pyright: ignore[reportPrivateUsage] - assert active_project.name == "cloud-proj" + # The local ASGI client seeds config projects into the database (#1334) + # but deliberately skips cloud-mode entries, so local validation misses — + # and that miss, not a workspace error, is what proves routing stayed local. + from fastmcp.exceptions import ToolError + + with pytest.raises(ToolError, match="Project not found: 'cloud-proj'"): + async with get_project_client(project="cloud-proj"): + pass @pytest.mark.asyncio async def test_local_route_clears_stale_cached_workspace(self, config_manager, monkeypatch): From c78d8fbf6a2e31224db5c0fcdb7a4bcd5743e6ff Mon Sep 17 00:00:00 2001 From: phernandez Date: Fri, 28 Aug 2026 17:30:11 -0500 Subject: [PATCH 05/10] fix(core): keep local rows for cloud projects that have a local sync copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex round two on #1354: `add --cloud --local-path` writes a cloud-mode entry that still owns a local sync directory, and local-side commands (`project ls --local`, startup watching) look it up in the projects table. Skip only cloud-only entries — cloud mode with no local_sync_path — when seeding rows and when protecting the configured default. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017STCpbNsYjZgUdftxgEAZ4 Signed-off-by: phernandez --- src/basic_memory/services/project_service.py | 19 ++++++++++++------ tests/services/test_project_service.py | 21 ++++++++++++++++++++ 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/src/basic_memory/services/project_service.py b/src/basic_memory/services/project_service.py index 79cb04579..6166f115e 100644 --- a/src/basic_memory/services/project_service.py +++ b/src/basic_memory/services/project_service.py @@ -541,12 +541,15 @@ async def synchronize_projects(self) -> None: # pragma: no cover # Add projects that exist in config but not in DB for name, entry in config_project_names.items(): - # Trigger: the entry routes to the cloud (`set-cloud`, `add --cloud`). + # Trigger: a cloud-only entry — cloud mode with no local sync copy + # (`set-cloud`, `add --cloud` without --local-path). # Why: set-cloud deliberately deletes the local row so the project's # configured state is purely cloud; recreating it here would - # undo that cutover on every reconciliation (#1334 review). - # Outcome: cloud-mode entries never get a local projects row. - if entry.mode == ProjectMode.CLOUD: + # undo that cutover on every reconciliation (#1334 review). A + # cloud project with a local sync path still needs its row for + # local-side operations (`project ls --local`, watching). + # Outcome: cloud-only entries never get a local projects row. + if entry.mode == ProjectMode.CLOUD and not entry.local_sync_path: continue if name not in db_projects_by_permalink: logger.info(f"Adding project '{name}' to database") @@ -582,11 +585,15 @@ async def synchronize_projects(self) -> None: # pragma: no cover config_default = self.config_manager.default_project config_default_entry = config_project_names.get(config_default) - # Trigger: the configured default routes to the cloud. + # Trigger: the configured default is cloud-only (no local sync copy). # Why: it has no local row by design, so the database default is only # the local fallback and must not overwrite the user's choice. # Outcome: config keeps the cloud default; the DB default stays local. - if config_default_entry is not None and config_default_entry.mode == ProjectMode.CLOUD: + if ( + config_default_entry is not None + and config_default_entry.mode == ProjectMode.CLOUD + and not config_default_entry.local_sync_path + ): pass elif db_default and db_default.name != config_default: # Update config to match DB default diff --git a/tests/services/test_project_service.py b/tests/services/test_project_service.py index 01531c697..c45b8b5b3 100644 --- a/tests/services/test_project_service.py +++ b/tests/services/test_project_service.py @@ -1821,3 +1821,24 @@ async def test_synchronize_projects_keeps_a_cloud_default_in_config( assert config_manager.load_config().default_project == "research-cloud" db_default = await _get_default_project(project_service) assert db_default is not None and db_default.name != "research-cloud" + + +@pytest.mark.asyncio +async def test_synchronize_projects_keeps_a_row_for_cloud_projects_with_a_local_sync_copy( + project_service: ProjectService, tmp_path +): + """`add --cloud --local-path` entries stay cloud-routed but still need their local row.""" + from basic_memory.config import ProjectEntry, ProjectMode + + sync_dir = tmp_path / "research-sync" + sync_dir.mkdir() + config_manager = project_service.config_manager + config = config_manager.load_config() + config.projects["research-synced"] = ProjectEntry( + path=str(sync_dir), mode=ProjectMode.CLOUD, local_sync_path=str(sync_dir) + ) + config_manager.save_config(config) + + await project_service.synchronize_projects() + + assert await _get_project(project_service, "research-synced") is not None From a143d2583638426969b8b00735886d778b5a857b Mon Sep 17 00:00:00 2001 From: phernandez Date: Fri, 28 Aug 2026 17:42:33 -0500 Subject: [PATCH 06/10] fix(core): clear sync metadata on set-cloud and retry reconciliation after failure Codex round three on #1354: - set-cloud blanked `path` but left `local_sync_path` (and bisync flags) behind, so an entry created with `add --cloud --local-path` and then cut over still read as a local copy and reconciliation recreated the row set-cloud had just dropped. The cutover now clears the sync metadata too, which is what "purely cloud" already promised. - The once-per-process guard recorded the database path before reconciliation ran, and reconcile_projects_with_config() swallows synchronize failures, so a transient error on the first request pinned a long-lived MCP process to an unseeded database. It now reports success, and the path is only retired after a completed run. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017STCpbNsYjZgUdftxgEAZ4 Signed-off-by: phernandez --- src/basic_memory/cli/commands/project.py | 7 +++++- src/basic_memory/services/initialization.py | 16 +++++++++---- src/basic_memory/services/project_service.py | 3 ++- tests/cli/test_project_set_cloud_local.py | 22 +++++++++++++++++ .../test_initialization_reconcile_once.py | 24 +++++++++++++++++++ 5 files changed, 66 insertions(+), 6 deletions(-) diff --git a/src/basic_memory/cli/commands/project.py b/src/basic_memory/cli/commands/project.py index 0173073a0..73a069ec1 100644 --- a/src/basic_memory/cli/commands/project.py +++ b/src/basic_memory/cli/commands/project.py @@ -1184,8 +1184,13 @@ def set_cloud( config.set_project_mode(name, ProjectMode.CLOUD) if resolved_workspace_id: config.projects[name].workspace_id = resolved_workspace_id - # Clear local path: source-of-truth for this project is now the cloud + # Clear the local path and sync metadata: the source of truth for this + # project is now the cloud. A leftover local_sync_path would read as a local + # copy and let reconciliation recreate the row this cutover just dropped. config.projects[name].path = "" + config.projects[name].local_sync_path = None + config.projects[name].bisync_initialized = False + config.projects[name].last_sync = None config_manager.save_config(config) console.print(f"[green]Project '{name}' set to cloud mode[/green]") diff --git a/src/basic_memory/services/initialization.py b/src/basic_memory/services/initialization.py index 180a947d8..1864e3926 100644 --- a/src/basic_memory/services/initialization.py +++ b/src/basic_memory/services/initialization.py @@ -146,9 +146,12 @@ async def initialize_database(app_config: BasicMemoryConfig) -> None: raise -async def reconcile_projects_with_config(app_config: BasicMemoryConfig): +async def reconcile_projects_with_config(app_config: BasicMemoryConfig) -> bool: """Ensure all projects in config.json exist in the projects table and vice versa. + Returns True when synchronization completed, False when it failed (the + failure is logged; startup continues either way). + This uses the ProjectService's synchronize_projects method to ensure bidirectional synchronization between the configuration file and the database. @@ -171,10 +174,12 @@ async def reconcile_projects_with_config(app_config: BasicMemoryConfig): project_service = ProjectService(repository=project_repository, session_maker=session_maker) try: await project_service.synchronize_projects() - logger.info("Projects successfully reconciled between config and database") except Exception as e: logger.error(f"Error during project synchronization: {e}") logger.info("Continuing with initialization despite synchronization error") + return False + logger.info("Projects successfully reconciled between config and database") + return True # Database paths this process has already reconciled config projects into. @@ -199,8 +204,11 @@ async def reconcile_projects_with_config_once(app_config: BasicMemoryConfig) -> database_path = Path(app_config.database_path) if database_path in _reconciled_database_paths: return - _reconciled_database_paths.add(database_path) - await reconcile_projects_with_config(app_config) + # Only a completed reconciliation retires this path; a transient failure + # (logged inside) leaves the next request to try again rather than pinning + # a long-lived MCP process to an unseeded database. + if await reconcile_projects_with_config(app_config): + _reconciled_database_paths.add(database_path) # Strong references for fire-and-forget startup index tasks; the event loop diff --git a/src/basic_memory/services/project_service.py b/src/basic_memory/services/project_service.py index 6166f115e..c8a769575 100644 --- a/src/basic_memory/services/project_service.py +++ b/src/basic_memory/services/project_service.py @@ -542,7 +542,8 @@ async def synchronize_projects(self) -> None: # pragma: no cover # Add projects that exist in config but not in DB for name, entry in config_project_names.items(): # Trigger: a cloud-only entry — cloud mode with no local sync copy - # (`set-cloud`, `add --cloud` without --local-path). + # (`add --cloud` without --local-path, or a `set-cloud` + # cutover, which clears the sync metadata). # Why: set-cloud deliberately deletes the local row so the project's # configured state is purely cloud; recreating it here would # undo that cutover on every reconciliation (#1334 review). A diff --git a/tests/cli/test_project_set_cloud_local.py b/tests/cli/test_project_set_cloud_local.py index a00cf8b63..e627902c9 100644 --- a/tests/cli/test_project_set_cloud_local.py +++ b/tests/cli/test_project_set_cloud_local.py @@ -383,3 +383,25 @@ def test_set_cloud_uses_default_workspace_when_no_flag(self, runner, mock_config config_module._CONFIG_SIZE = None updated_data = json.loads(mock_config.read_text()) assert updated_data["projects"]["research"]["workspace_id"] == "global-default-tenant-id" + + +def test_set_cloud_clears_sync_metadata(runner, mock_config, tmp_path): + """A cutover leaves no local_sync_path behind, so reconciliation cannot recreate the row. + + `add --cloud --local-path` entries carry a sync path; set-cloud blanked only + `path`, and a surviving local_sync_path read as a local copy (#1334 review). + """ + config = json.loads(mock_config.read_text()) + config["projects"]["research"].update( + {"local_sync_path": str(tmp_path / "research"), "bisync_initialized": True} + ) + mock_config.write_text(json.dumps(config, indent=2)) + + result = runner.invoke(app, ["project", "set-cloud", "research"]) + + assert result.exit_code == 0, result.stdout + entry = json.loads(mock_config.read_text())["projects"]["research"] + assert entry["mode"] == "cloud" + assert entry["path"] == "" + assert entry["local_sync_path"] is None + assert entry["bisync_initialized"] is False diff --git a/tests/services/test_initialization_reconcile_once.py b/tests/services/test_initialization_reconcile_once.py index e8144a73c..584b9bfaf 100644 --- a/tests/services/test_initialization_reconcile_once.py +++ b/tests/services/test_initialization_reconcile_once.py @@ -12,6 +12,7 @@ async def test_reconcile_projects_with_config_once_runs_once_per_database(app_co async def fake_reconcile(config): calls.append(config) + return True monkeypatch.setattr(initialization, "reconcile_projects_with_config", fake_reconcile) monkeypatch.setattr(initialization, "_reconciled_database_paths", set()) @@ -29,6 +30,7 @@ async def test_reconcile_projects_with_config_once_skips_cloud_deployments(app_c async def fake_reconcile(config): calls.append(config) + return True monkeypatch.setattr(initialization, "reconcile_projects_with_config", fake_reconcile) monkeypatch.setattr(initialization, "_reconciled_database_paths", set()) @@ -38,3 +40,25 @@ async def fake_reconcile(config): await initialization.reconcile_projects_with_config_once(app_config) assert calls == [] + + +@pytest.mark.asyncio +async def test_reconcile_projects_with_config_once_retries_after_a_failed_attempt( + app_config, monkeypatch +): + """A transient failure must not pin a long-lived process to an unseeded database.""" + outcomes = iter([False, True]) + calls = [] + + async def fake_reconcile(config): + calls.append(config) + return next(outcomes) + + monkeypatch.setattr(initialization, "reconcile_projects_with_config", fake_reconcile) + monkeypatch.setattr(initialization, "_reconciled_database_paths", set()) + + await initialization.reconcile_projects_with_config_once(app_config) + await initialization.reconcile_projects_with_config_once(app_config) + await initialization.reconcile_projects_with_config_once(app_config) + + assert calls == [app_config, app_config], "retry once after failure, then stop" From 662964b71e9e827d4ea563ce18bfc99934f915d4 Mon Sep 17 00:00:00 2001 From: phernandez Date: Fri, 28 Aug 2026 18:01:04 -0500 Subject: [PATCH 07/10] fix(core): converge stale rows for cloud-only entries and keep a normalized cloud default Codex round four on #1354: a local row an earlier reconciliation had recreated for a cut-over project never converged away, because the deletion pass treated every config key as a local claim; and a default written in display form (`Research Cloud`) stopped matching its normalized key, so config validation reset it to the first local project. Cloud-only entries are now absent from the local-row set for both passes, and the default is renamed together with its key. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017STCpbNsYjZgUdftxgEAZ4 Signed-off-by: phernandez --- src/basic_memory/services/project_service.py | 52 +++++++++++++------- tests/services/test_project_service.py | 47 ++++++++++++++++++ 2 files changed, 80 insertions(+), 19 deletions(-) diff --git a/src/basic_memory/services/project_service.py b/src/basic_memory/services/project_service.py index c8a769575..84b6a8454 100644 --- a/src/basic_memory/services/project_service.py +++ b/src/basic_memory/services/project_service.py @@ -51,6 +51,11 @@ type ProjectSearchRepositoryFactory = Callable[[int], SearchRepository] +def _is_cloud_only(entry: ProjectEntry) -> bool: + """Whether a config entry routes to the cloud with no local copy of the notes.""" + return entry.mode == ProjectMode.CLOUD and not entry.local_sync_path + + class ProjectService: """Service for managing Basic Memory projects.""" @@ -522,6 +527,11 @@ async def synchronize_projects(self) -> None: # pragma: no cover if normalized_name != name: logger.info(f"Normalizing project name in config: '{name}' -> '{normalized_name}'") config_updated = True + # The default must follow its key: config validation resets a + # default that no longer matches a project key to the first + # project, which would silently replace a cloud default. + if config.default_project == name: + config.default_project = normalized_name updated_config[normalized_name] = entry @@ -539,19 +549,25 @@ async def synchronize_projects(self) -> None: # pragma: no cover db_projects = await self.repository.get_active_projects(session) db_projects_by_permalink = {p.permalink: p for p in db_projects} + # Trigger: a cloud-only entry — cloud mode with no local sync copy + # (`add --cloud` without --local-path, or a `set-cloud` + # cutover, which clears the sync metadata). + # Why: set-cloud deliberately deletes the local row so the project's + # configured state is purely cloud; a local row for it would undo + # that cutover and admit the project to local indexing/watching + # (#1334 review). A cloud project with a local sync path still + # needs its row for local-side operations. + # Outcome: cloud-only entries are absent from the local-row set — never + # created, and a stale row left by an older reconciliation is + # removed like any other row config no longer claims. + local_entries = { + name: entry + for name, entry in config_project_names.items() + if not _is_cloud_only(entry) + } + # Add projects that exist in config but not in DB - for name, entry in config_project_names.items(): - # Trigger: a cloud-only entry — cloud mode with no local sync copy - # (`add --cloud` without --local-path, or a `set-cloud` - # cutover, which clears the sync metadata). - # Why: set-cloud deliberately deletes the local row so the project's - # configured state is purely cloud; recreating it here would - # undo that cutover on every reconciliation (#1334 review). A - # cloud project with a local sync path still needs its row for - # local-side operations (`project ls --local`, watching). - # Outcome: cloud-only entries never get a local projects row. - if entry.mode == ProjectMode.CLOUD and not entry.local_sync_path: - continue + for name, entry in local_entries.items(): if name not in db_projects_by_permalink: logger.info(f"Adding project '{name}' to database") project_data = { @@ -567,7 +583,7 @@ async def synchronize_projects(self) -> None: # pragma: no cover # Config is the source of truth - if a project was deleted from config, # it should be deleted from DB too (fixes issue #193) for name, project in db_projects_by_permalink.items(): - if name not in config_project_names: + if name not in local_entries: logger.info( f"Removing project '{name}' from database (deleted from config, source of truth)" ) @@ -584,17 +600,15 @@ async def synchronize_projects(self) -> None: # pragma: no cover # Make sure default project is synchronized between config and database db_default = await self.repository.get_default_project(session) config_default = self.config_manager.default_project - config_default_entry = config_project_names.get(config_default) + config_default_entry = ( + config_project_names.get(config_default) if config_default else None + ) # Trigger: the configured default is cloud-only (no local sync copy). # Why: it has no local row by design, so the database default is only # the local fallback and must not overwrite the user's choice. # Outcome: config keeps the cloud default; the DB default stays local. - if ( - config_default_entry is not None - and config_default_entry.mode == ProjectMode.CLOUD - and not config_default_entry.local_sync_path - ): + if config_default_entry is not None and _is_cloud_only(config_default_entry): pass elif db_default and db_default.name != config_default: # Update config to match DB default diff --git a/tests/services/test_project_service.py b/tests/services/test_project_service.py index c45b8b5b3..cff1ddbd7 100644 --- a/tests/services/test_project_service.py +++ b/tests/services/test_project_service.py @@ -1842,3 +1842,50 @@ async def test_synchronize_projects_keeps_a_row_for_cloud_projects_with_a_local_ await project_service.synchronize_projects() assert await _get_project(project_service, "research-synced") is not None + + +@pytest.mark.asyncio +async def test_synchronize_projects_removes_a_stale_row_for_a_cloud_only_entry( + project_service: ProjectService, tmp_path +): + """A row an older reconciliation recreated for a cut-over project converges away.""" + from basic_memory.config import ProjectEntry, ProjectMode + + config_manager = project_service.config_manager + config = config_manager.load_config() + config.projects["research-cloud"] = ProjectEntry(path="", mode=ProjectMode.CLOUD) + config_manager.save_config(config) + await _create_project( + project_service, + { + "name": "research-cloud", + "path": str(tmp_path / "stale-research"), + "permalink": "research-cloud", + "is_active": True, + }, + ) + assert await _get_project(project_service, "research-cloud") is not None + + await project_service.synchronize_projects() + + assert await _get_project(project_service, "research-cloud") is None + + +@pytest.mark.asyncio +async def test_synchronize_projects_keeps_a_cloud_default_written_in_display_form( + project_service: ProjectService, +): + """`default_project: Research Cloud` must match the normalized `research-cloud` key.""" + from basic_memory.config import ProjectEntry, ProjectMode + + config_manager = project_service.config_manager + config = config_manager.load_config() + config.projects["Research Cloud"] = ProjectEntry(path="", mode=ProjectMode.CLOUD) + config.default_project = "Research Cloud" + config_manager.save_config(config) + + await project_service.synchronize_projects() + + # Keys are normalized on sync; the default must follow its key, not fall + # back to the first local project. + assert config_manager.load_config().default_project == "research-cloud" From 00d5206e234b8c3a972af72d9454d13a27be6d1b Mon Sep 17 00:00:00 2001 From: phernandez Date: Fri, 28 Aug 2026 18:20:02 -0500 Subject: [PATCH 08/10] fix(core): count a legacy path-only cloud copy as a local copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex round five on #1354: older cloud-mode entries record the local sync copy only in `path`, and `_require_local_sync_path` still honors that fallback. Treating them as cloud-only would drop their local row and make local-side commands report the project missing. A cloud entry is cloud-only only when it has neither local_sync_path nor path — the state set-cloud and `add --cloud` without --local-path produce. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017STCpbNsYjZgUdftxgEAZ4 Signed-off-by: phernandez --- src/basic_memory/services/project_service.py | 10 ++++++++-- tests/mcp/test_project_context.py | 16 ++++++++-------- tests/services/test_project_service.py | 19 +++++++++++++++++++ 3 files changed, 35 insertions(+), 10 deletions(-) diff --git a/src/basic_memory/services/project_service.py b/src/basic_memory/services/project_service.py index 84b6a8454..0424c4981 100644 --- a/src/basic_memory/services/project_service.py +++ b/src/basic_memory/services/project_service.py @@ -52,8 +52,14 @@ def _is_cloud_only(entry: ProjectEntry) -> bool: - """Whether a config entry routes to the cloud with no local copy of the notes.""" - return entry.mode == ProjectMode.CLOUD and not entry.local_sync_path + """Whether a config entry routes to the cloud with no local copy of the notes. + + A local copy is recorded in ``local_sync_path``; older entries recorded it only + in ``path``, which ``_require_local_sync_path`` still honors, so both count. + ``set-cloud`` clears both, and ``add --cloud`` without ``--local-path`` writes + neither. + """ + return entry.mode == ProjectMode.CLOUD and not (entry.local_sync_path or entry.path) class ProjectService: diff --git a/tests/mcp/test_project_context.py b/tests/mcp/test_project_context.py index c98929016..431b4ffa3 100644 --- a/tests/mcp/test_project_context.py +++ b/tests/mcp/test_project_context.py @@ -2407,6 +2407,8 @@ class TestGetProjectClientRoutingOrder: @pytest.mark.asyncio async def test_local_flag_skips_workspace_resolution(self, config_manager, monkeypatch): """--local flag should never trigger workspace resolution, even for cloud projects.""" + import httpx + import basic_memory.mcp.project_context as project_context from basic_memory.mcp.project_context import get_project_client from basic_memory.config import ProjectEntry, ProjectMode @@ -2429,14 +2431,12 @@ async def fail_on_workspace_lookup(*args, **kwargs): monkeypatch.setattr(project_context, "get_available_workspaces", fail_on_workspace_lookup) - # The local ASGI client seeds config projects into the database (#1334) - # but deliberately skips cloud-mode entries, so local validation misses — - # and that miss, not a workspace error, is what proves routing stayed local. - from fastmcp.exceptions import ToolError - - with pytest.raises(ToolError, match="Project not found: 'cloud-proj'"): - async with get_project_client(project="cloud-proj"): - pass + # This entry is the legacy cloud-with-local-path shape, so the local ASGI + # client seeds it into the database (#1334) and validation succeeds on the + # local route; any workspace lookup would have tripped the guard above. + async with get_project_client(project="cloud-proj") as (client, active_project): + assert isinstance(client._transport, httpx.ASGITransport) # pyright: ignore[reportPrivateUsage] + assert active_project.name == "cloud-proj" @pytest.mark.asyncio async def test_local_route_clears_stale_cached_workspace(self, config_manager, monkeypatch): diff --git a/tests/services/test_project_service.py b/tests/services/test_project_service.py index cff1ddbd7..81a5683f4 100644 --- a/tests/services/test_project_service.py +++ b/tests/services/test_project_service.py @@ -1889,3 +1889,22 @@ async def test_synchronize_projects_keeps_a_cloud_default_written_in_display_for # Keys are normalized on sync; the default must follow its key, not fall # back to the first local project. assert config_manager.load_config().default_project == "research-cloud" + + +@pytest.mark.asyncio +async def test_synchronize_projects_keeps_a_row_for_a_legacy_path_only_cloud_copy( + project_service: ProjectService, tmp_path +): + """Older entries record the local copy only in `path`; that is still a local copy.""" + from basic_memory.config import ProjectEntry, ProjectMode + + sync_dir = tmp_path / "legacy-sync" + sync_dir.mkdir() + config_manager = project_service.config_manager + config = config_manager.load_config() + config.projects["research-legacy"] = ProjectEntry(path=str(sync_dir), mode=ProjectMode.CLOUD) + config_manager.save_config(config) + + await project_service.synchronize_projects() + + assert await _get_project(project_service, "research-legacy") is not None From f057cd46b8bc6f50f22dc44597c766bf94cc8852 Mon Sep 17 00:00:00 2001 From: phernandez Date: Fri, 28 Aug 2026 18:36:16 -0500 Subject: [PATCH 09/10] fix(core): only an absolute legacy path counts as a local cloud copy Codex round six on #1354: a legacy cloud-only entry can carry the remote slug (`research`) in `path`. Treating any non-empty path as a local copy would seed a row whose base resolves against the process cwd. Mirror is_locally_syncable and _require_local_sync_path: the fallback path must be absolute. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017STCpbNsYjZgUdftxgEAZ4 Signed-off-by: phernandez --- src/basic_memory/services/project_service.py | 13 +++++++++---- tests/services/test_project_service.py | 17 +++++++++++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/basic_memory/services/project_service.py b/src/basic_memory/services/project_service.py index 0424c4981..8ef86d193 100644 --- a/src/basic_memory/services/project_service.py +++ b/src/basic_memory/services/project_service.py @@ -55,11 +55,16 @@ def _is_cloud_only(entry: ProjectEntry) -> bool: """Whether a config entry routes to the cloud with no local copy of the notes. A local copy is recorded in ``local_sync_path``; older entries recorded it only - in ``path``, which ``_require_local_sync_path`` still honors, so both count. - ``set-cloud`` clears both, and ``add --cloud`` without ``--local-path`` writes - neither. + in ``path``, which ``_require_local_sync_path`` still honors — but only when it + is absolute. A relative ``path`` on a cloud entry is the remote slug + (``config_migrations``), and, like ``is_locally_syncable``, it must never be + taken for a local directory: a row for it would resolve against the process + cwd. ``set-cloud`` clears both fields; ``add --cloud`` without ``--local-path`` + writes neither. """ - return entry.mode == ProjectMode.CLOUD and not (entry.local_sync_path or entry.path) + if entry.mode != ProjectMode.CLOUD: + return False + return not (entry.local_sync_path or os.path.isabs(entry.path)) class ProjectService: diff --git a/tests/services/test_project_service.py b/tests/services/test_project_service.py index 81a5683f4..e08609097 100644 --- a/tests/services/test_project_service.py +++ b/tests/services/test_project_service.py @@ -1908,3 +1908,20 @@ async def test_synchronize_projects_keeps_a_row_for_a_legacy_path_only_cloud_cop await project_service.synchronize_projects() assert await _get_project(project_service, "research-legacy") is not None + + +@pytest.mark.asyncio +async def test_synchronize_projects_treats_a_relative_cloud_path_as_the_remote_slug( + project_service: ProjectService, +): + """A cloud entry whose `path` is a slug like `research` has no local copy.""" + from basic_memory.config import ProjectEntry, ProjectMode + + config_manager = project_service.config_manager + config = config_manager.load_config() + config.projects["research-slug"] = ProjectEntry(path="research", mode=ProjectMode.CLOUD) + config_manager.save_config(config) + + await project_service.synchronize_projects() + + assert await _get_project(project_service, "research-slug") is None From 33805d6dc8f908a5537ac13acd2b2d8497d50c2c Mon Sep 17 00:00:00 2001 From: phernandez Date: Fri, 28 Aug 2026 18:47:52 -0500 Subject: [PATCH 10/10] fix(core): require the cloud sync copy to be an absolute path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex round seven on #1354: ProjectEntry accepts a relative local_sync_path, and _require_local_sync_path rejects it, so reconciliation must not seed a row for it either. Apply that function's rule as written — local_sync_path, falling back to path, absolute only. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017STCpbNsYjZgUdftxgEAZ4 Signed-off-by: phernandez --- src/basic_memory/services/project_service.py | 10 +++++++--- tests/services/test_project_service.py | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/basic_memory/services/project_service.py b/src/basic_memory/services/project_service.py index 8ef86d193..336744238 100644 --- a/src/basic_memory/services/project_service.py +++ b/src/basic_memory/services/project_service.py @@ -55,8 +55,8 @@ def _is_cloud_only(entry: ProjectEntry) -> bool: """Whether a config entry routes to the cloud with no local copy of the notes. A local copy is recorded in ``local_sync_path``; older entries recorded it only - in ``path``, which ``_require_local_sync_path`` still honors — but only when it - is absolute. A relative ``path`` on a cloud entry is the remote slug + in ``path``, which ``_require_local_sync_path`` still honors — but only when + absolute. A relative value on a cloud entry is the remote slug (``config_migrations``), and, like ``is_locally_syncable``, it must never be taken for a local directory: a row for it would resolve against the process cwd. ``set-cloud`` clears both fields; ``add --cloud`` without ``--local-path`` @@ -64,7 +64,11 @@ def _is_cloud_only(entry: ProjectEntry) -> bool: """ if entry.mode != ProjectMode.CLOUD: return False - return not (entry.local_sync_path or os.path.isabs(entry.path)) + # Same rule as _require_local_sync_path: the sync copy is local_sync_path, + # falling back to path, and only an absolute one is a directory on this + # machine. ProjectEntry accepts a relative local_sync_path, so check it too. + local_copy = entry.local_sync_path or entry.path + return not (local_copy and os.path.isabs(local_copy)) class ProjectService: diff --git a/tests/services/test_project_service.py b/tests/services/test_project_service.py index e08609097..b22d6d21b 100644 --- a/tests/services/test_project_service.py +++ b/tests/services/test_project_service.py @@ -1925,3 +1925,22 @@ async def test_synchronize_projects_treats_a_relative_cloud_path_as_the_remote_s await project_service.synchronize_projects() assert await _get_project(project_service, "research-slug") is None + + +@pytest.mark.asyncio +async def test_synchronize_projects_treats_a_relative_local_sync_path_as_no_local_copy( + project_service: ProjectService, +): + """`_require_local_sync_path` rejects a relative sync path; reconciliation must agree.""" + from basic_memory.config import ProjectEntry, ProjectMode + + config_manager = project_service.config_manager + config = config_manager.load_config() + config.projects["research-rel"] = ProjectEntry( + path="", mode=ProjectMode.CLOUD, local_sync_path="research" + ) + config_manager.save_config(config) + + await project_service.synchronize_projects() + + assert await _get_project(project_service, "research-rel") is None