Skip to content

Commit 43e1abe

Browse files
committed
fix: honor local project routing and filter cloud watch cycles
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent ad75bb3 commit 43e1abe

4 files changed

Lines changed: 89 additions & 5 deletions

File tree

src/basic_memory/mcp/async_client.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,13 +61,18 @@ async def get_client(
6161
OAuth token. Honored even when FORCE_LOCAL is set, because the user
6262
explicitly declared this project as cloud.
6363
64-
3. **Force-local** (BASIC_MEMORY_FORCE_LOCAL env var):
64+
3. **Per-project local mode** (project_name provided):
65+
If the project's mode is LOCAL (or unspecified, default LOCAL), route
66+
to local ASGI transport. This allows mixed local/cloud routing even when
67+
global cloud mode is enabled.
68+
69+
4. **Force-local** (BASIC_MEMORY_FORCE_LOCAL env var):
6570
Routes to local ASGI transport, ignoring global cloud settings.
6671
67-
4. **Global cloud mode** (deprecated fallback):
72+
5. **Global cloud mode** (deprecated fallback):
6873
When cloud_mode_enabled is True, uses OAuth JWT token.
6974
70-
5. **Local mode** (default):
75+
6. **Local mode** (default):
7176
Use ASGI transport for in-process requests to local FastAPI app.
7277
7378
Args:
@@ -134,6 +139,17 @@ async def get_client(
134139
) as client:
135140
yield client
136141

142+
# Trigger: project is not explicitly cloud (LOCAL is the default)
143+
# Why: project-scoped routing should honor local mode even when global
144+
# cloud mode is enabled for backward compatibility
145+
# Outcome: uses ASGI transport for in-process local API calls
146+
elif project_name and config.get_project_mode(project_name) == ProjectMode.LOCAL:
147+
logger.info(f"Project '{project_name}' is set to local mode - using ASGI transport")
148+
async with AsyncClient(
149+
transport=ASGITransport(app=fastapi_app), base_url="http://test", timeout=timeout
150+
) as client:
151+
yield client
152+
137153
# Trigger: BASIC_MEMORY_FORCE_LOCAL env var is set
138154
# Why: allows local MCP server and CLI commands to route locally
139155
# even when cloud_mode_enabled is True

src/basic_memory/sync/watch_service.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
if TYPE_CHECKING:
1111
from basic_memory.sync.sync_service import SyncService
1212

13-
from basic_memory.config import BasicMemoryConfig, WATCH_STATUS_JSON
13+
from basic_memory.config import BasicMemoryConfig, ProjectMode, WATCH_STATUS_JSON
1414
from basic_memory.ignore_utils import load_gitignore_patterns, should_ignore_path
1515
from basic_memory.models import Project
1616
from basic_memory.repository import ProjectRepository
@@ -177,6 +177,22 @@ async def run(self): # pragma: no cover
177177
# Reload projects to catch any new/removed projects
178178
projects = await self.project_repository.get_active_projects()
179179

180+
# Trigger: project is configured for cloud routing
181+
# Why: cloud projects should not be watched/synced by local file watchers
182+
# Outcome: watch cycle only observes local-mode projects
183+
cloud_projects = [
184+
p.name
185+
for p in projects
186+
if self.app_config.get_project_mode(p.name) == ProjectMode.CLOUD
187+
]
188+
if cloud_projects:
189+
projects = [
190+
p
191+
for p in projects
192+
if self.app_config.get_project_mode(p.name) != ProjectMode.CLOUD
193+
]
194+
logger.info(f"Skipping cloud-mode projects in watch cycle: {cloud_projects}")
195+
180196
project_paths = [project.path for project in projects]
181197
logger.debug(f"Starting watch cycle for directories: {project_paths}")
182198

tests/mcp/test_async_client_modes.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,21 @@ async def test_get_client_local_project_uses_asgi_transport(config_manager, conf
132132
assert isinstance(client._transport, httpx.ASGITransport) # pyright: ignore[reportPrivateUsage]
133133

134134

135+
@pytest.mark.asyncio
136+
async def test_get_client_local_project_honored_with_global_cloud_enabled(config_manager, config_home):
137+
"""LOCAL project mode should take priority over global cloud mode fallback."""
138+
cfg = config_manager.load_config()
139+
cfg.cloud_mode = True
140+
cfg.cloud_host = "https://cloud.example.test"
141+
cfg.cloud_api_key = None
142+
# "main" defaults to LOCAL since we didn't set_project_mode
143+
config_manager.save_config(cfg)
144+
145+
# Should use ASGI transport without requiring OAuth token.
146+
async with get_client(project_name="main") as client:
147+
assert isinstance(client._transport, httpx.ASGITransport) # pyright: ignore[reportPrivateUsage]
148+
149+
135150
@pytest.mark.asyncio
136151
async def test_get_client_no_project_name_uses_default_routing(config_manager, config_home):
137152
"""Test that get_client without project_name falls through to default routing."""

tests/sync/test_watch_service_reload.py

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
import pytest
1414

15-
from basic_memory.config import BasicMemoryConfig
15+
from basic_memory.config import BasicMemoryConfig, ProjectMode
1616
from basic_memory.models.project import Project
1717
from basic_memory.sync.watch_service import WatchService
1818

@@ -143,6 +143,43 @@ async def fake_write_status():
143143
assert cycle_count == 2
144144

145145

146+
@pytest.mark.asyncio
147+
async def test_run_filters_cloud_projects_each_cycle(monkeypatch, tmp_path):
148+
config = BasicMemoryConfig(
149+
watch_project_reload_interval=1,
150+
project_modes={"cloud-project": ProjectMode.CLOUD},
151+
)
152+
repo = _Repo(
153+
projects_return=[
154+
Project(id=1, name="local-project", path=str(tmp_path / "local"), permalink="local"),
155+
Project(
156+
id=2,
157+
name="cloud-project",
158+
path=str(tmp_path / "cloud"),
159+
permalink="cloud",
160+
),
161+
]
162+
)
163+
watch_service = WatchService(config, repo, quiet=True)
164+
165+
seen_project_names: list[list[str]] = []
166+
167+
async def watch_cycle_stub(projects, stop_event):
168+
seen_project_names.append([p.name for p in projects])
169+
watch_service.state.running = False
170+
stop_event.set()
171+
172+
async def fake_write_status():
173+
return None
174+
175+
monkeypatch.setattr(watch_service, "_watch_projects_cycle", watch_cycle_stub)
176+
monkeypatch.setattr(watch_service, "write_status", fake_write_status)
177+
178+
await watch_service.run()
179+
180+
assert seen_project_names == [["local-project"]]
181+
182+
146183
@pytest.mark.asyncio
147184
async def test_run_continues_after_cycle_error(monkeypatch, tmp_path):
148185
config = BasicMemoryConfig()

0 commit comments

Comments
 (0)