Skip to content

Commit d763d86

Browse files
phernandezclaude
andcommitted
fix: unify project path so path is always the local filesystem path
Cloud projects with bisync had a split-brain problem: `path` held a cloud slug while the actual local directory lived in `local_sync_path`. This caused `bm status` and file sync to fail for bisync'd cloud projects. Changes: - Config migration promotes `local_sync_path` → `path` for entries where `path` is a non-absolute cloud slug - `ensure_project_paths_exists` skips cloud-only projects with slug paths - `initialize_file_sync` and watch service now keep cloud projects that have an absolute local path (bisync copy) instead of skipping all cloud projects - `sync-setup` and `project add --cloud --local-path` set both `path` and `local_sync_path` to the local directory - `sync-setup` creates the project in the local DB for immediate MCP use - `_get_sync_project` falls back from `local_sync_path` to `path` - Config load errors now show user-friendly messages instead of stack traces Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: phernandez <paul@basicmachines.co>
1 parent 538af97 commit d763d86

7 files changed

Lines changed: 192 additions & 35 deletions

File tree

src/basic_memory/cli/commands/cloud/project_sync.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
they are cloud-specific operations.
66
"""
77

8+
import os
89
from datetime import datetime
910

1011
import typer
@@ -69,10 +70,11 @@ def _get_sync_project(
6970
Returns (sync_project, local_sync_path). Exits if no local_sync_path configured.
7071
"""
7172
sync_entry = config.projects.get(name)
72-
local_sync_path = sync_entry.local_sync_path if sync_entry else None
73+
# Support both new (path) and legacy (local_sync_path) configs
74+
local_sync_path = (sync_entry.local_sync_path or sync_entry.path) if sync_entry else None
7375

74-
if not local_sync_path:
75-
console.print(f"[red]Error: Project '{name}' has no local_sync_path configured[/red]")
76+
if not local_sync_path or not os.path.isabs(local_sync_path):
77+
console.print(f"[red]Error: Project '{name}' has no local sync path configured[/red]")
7678
console.print(f"\nConfigure sync with: bm cloud sync-setup {name} ~/path/to/local")
7779
raise typer.Exit(1)
7880

@@ -334,9 +336,10 @@ async def _verify_project_exists():
334336
resolved_path = Path(os.path.abspath(os.path.expanduser(local_path)))
335337
resolved_path.mkdir(parents=True, exist_ok=True)
336338

337-
# Update project entry with sync path
339+
# Update project entry with sync path — path is always the local directory
338340
entry = config.projects.get(name)
339341
if entry:
342+
entry.path = resolved_path.as_posix()
340343
entry.local_sync_path = resolved_path.as_posix()
341344
entry.bisync_initialized = False
342345
entry.last_sync = None
@@ -347,6 +350,18 @@ async def _verify_project_exists():
347350
)
348351
config_manager.save_config(config)
349352

353+
# Create the project in the local DB so the MCP server can immediately use it
354+
async def _create_local_project():
355+
async with get_client() as client:
356+
data = {"name": name, "path": resolved_path.as_posix(), "set_default": False}
357+
return await ProjectClient(client).create_project(data)
358+
359+
with force_routing(local=True):
360+
try:
361+
run_with_cleanup(_create_local_project())
362+
except Exception:
363+
pass # Project may already exist locally; reconcile on next startup
364+
350365
console.print(f"[green]Sync configured for project '{name}'[/green]")
351366
console.print(f"\nLocal sync path: {resolved_path}")
352367
console.print("\nNext steps:")

src/basic_memory/cli/commands/project.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -285,9 +285,10 @@ async def _add_project():
285285
local_dir = Path(local_sync_path)
286286
local_dir.mkdir(parents=True, exist_ok=True)
287287

288-
# Update project entry with sync path
288+
# Update project entry path is always the local directory
289289
entry = config.projects.get(name)
290290
if entry:
291+
entry.path = local_sync_path
291292
entry.local_sync_path = local_sync_path
292293
else:
293294
# Project may not be in local config yet (cloud-only add)

src/basic_memory/config.py

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,18 @@ def migrate_legacy_projects(cls, data: Any) -> Any:
403403
data.pop("project_modes", None)
404404
data.pop("cloud_projects", None)
405405

406+
# --- Promote local_sync_path into path for cloud projects with slug paths ---
407+
# Trigger: project entry has local_sync_path set but path is a cloud slug (not absolute)
408+
# Why: path must always be the local filesystem path; the cloud remote is derivable
409+
# Outcome: path becomes the local directory, local_sync_path kept for backwards compat
410+
projects = data.get("projects", {})
411+
for name, entry in projects.items():
412+
if isinstance(entry, dict):
413+
lsp = entry.get("local_sync_path")
414+
path = entry.get("path", "")
415+
if lsp and not os.path.isabs(path):
416+
entry["path"] = lsp
417+
406418
return data
407419

408420
@property
@@ -564,6 +576,9 @@ def ensure_project_paths_exists(self) -> "BasicMemoryConfig": # pragma: no cove
564576

565577
for name, entry in self.projects.items():
566578
path = Path(entry.path)
579+
# Skip cloud-only projects whose path is a slug, not a local directory
580+
if not path.is_absolute():
581+
continue
567582
if not path.exists():
568583
try:
569584
path.mkdir(parents=True)
@@ -645,6 +660,17 @@ def load_config(self) -> BasicMemoryConfig:
645660
if isinstance(first_val, str):
646661
needs_resave = True
647662

663+
# Check if any project has local_sync_path set but path is a cloud slug
664+
# (will be migrated by migrate_legacy_projects validator)
665+
if not needs_resave:
666+
for entry_data in projects_raw.values():
667+
if isinstance(entry_data, dict):
668+
lsp = entry_data.get("local_sync_path")
669+
p = entry_data.get("path", "")
670+
if lsp and not os.path.isabs(p):
671+
needs_resave = True
672+
break
673+
648674
# First, create config from environment variables (Pydantic will read them)
649675
# Then overlay with file data for fields that aren't set via env vars
650676
# This ensures env vars take precedence
@@ -673,9 +699,20 @@ def load_config(self) -> BasicMemoryConfig:
673699
save_basic_memory_config(self.config_file, _CONFIG_CACHE)
674700

675701
return _CONFIG_CACHE
702+
except json.JSONDecodeError as e: # pragma: no cover
703+
logger.error(f"Invalid JSON in config file {self.config_file}: {e}")
704+
raise SystemExit(
705+
f"Error: config file is not valid JSON: {self.config_file}\n"
706+
f" {e}\n"
707+
f"Fix or delete the file and re-run."
708+
)
676709
except Exception as e: # pragma: no cover
677-
logger.exception(f"Failed to load config: {e}")
678-
raise e
710+
logger.error(f"Failed to load config from {self.config_file}: {e}")
711+
raise SystemExit(
712+
f"Error: failed to load config from {self.config_file}\n"
713+
f" {e}\n"
714+
f"Fix or delete the file and re-run."
715+
)
679716
else:
680717
config = BasicMemoryConfig()
681718
self.save_config(config)

src/basic_memory/services/initialization.py

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -115,15 +115,18 @@ async def initialize_file_sync(
115115
active_projects = [p for p in active_projects if p.name == constrained_project]
116116
logger.info(f"Background sync constrained to project: {constrained_project}")
117117

118-
# Skip cloud-mode projects — their files live on the cloud instance, not locally
119-
cloud_projects = [
120-
p.name for p in active_projects if app_config.get_project_mode(p.name) == ProjectMode.CLOUD
121-
]
122-
if cloud_projects:
123-
active_projects = [
124-
p for p in active_projects if app_config.get_project_mode(p.name) != ProjectMode.CLOUD
125-
]
126-
logger.info(f"Skipping cloud-mode projects for local sync: {cloud_projects}")
118+
# Skip cloud-mode projects that have no local directory.
119+
# Cloud projects with a local bisync copy (absolute path) are kept for local sync.
120+
cloud_skip = []
121+
for p in active_projects:
122+
if app_config.get_project_mode(p.name) == ProjectMode.CLOUD:
123+
entry = app_config.projects.get(p.name)
124+
if entry and Path(entry.path).is_absolute():
125+
continue # Cloud project with local bisync copy — keep for local sync
126+
cloud_skip.append(p.name)
127+
if cloud_skip:
128+
active_projects = [p for p in active_projects if p.name not in cloud_skip]
129+
logger.info(f"Skipping cloud-mode projects for local sync: {cloud_skip}")
127130

128131
# Start sync for all projects as background tasks (non-blocking)
129132
async def sync_project_background(project: Project):

src/basic_memory/sync/watch_service.py

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -178,20 +178,19 @@ async def run(self): # pragma: no cover
178178
projects = await self.project_repository.get_active_projects()
179179

180180
# 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.debug(f"Skipping cloud-mode projects in watch cycle: {cloud_projects}")
181+
# Why: cloud-only projects (no local directory) should not be watched;
182+
# cloud projects with a local bisync copy (absolute path) need watching
183+
# Outcome: watch cycle skips cloud projects without a local directory
184+
cloud_skip = []
185+
for p in projects:
186+
if self.app_config.get_project_mode(p.name) == ProjectMode.CLOUD:
187+
entry = self.app_config.projects.get(p.name)
188+
if entry and Path(entry.path).is_absolute():
189+
continue # Cloud project with local bisync copy — keep watching
190+
cloud_skip.append(p.name)
191+
if cloud_skip:
192+
projects = [p for p in projects if p.name not in cloud_skip]
193+
logger.debug(f"Skipping cloud-mode projects in watch cycle: {cloud_skip}")
195194

196195
project_paths = [project.path for project in projects]
197196
logger.debug(f"Starting watch cycle for directories: {project_paths}")

tests/sync/test_watch_service_reload.py

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -144,22 +144,59 @@ async def fake_write_status():
144144

145145

146146
@pytest.mark.asyncio
147-
async def test_run_filters_cloud_projects_each_cycle(monkeypatch, tmp_path):
147+
async def test_run_filters_cloud_only_projects_each_cycle(monkeypatch, tmp_path):
148+
"""Cloud-only projects (slug path, no local directory) are filtered out."""
148149
config = BasicMemoryConfig(
149150
watch_project_reload_interval=1,
150151
projects={
151152
"local-project": {"path": str(tmp_path / "local"), "mode": "local"},
152-
"cloud-project": {"path": str(tmp_path / "cloud"), "mode": "cloud"},
153+
"cloud-only": {"path": "cloud-slug", "mode": "cloud"},
154+
},
155+
)
156+
repo = _Repo(
157+
projects_return=[
158+
Project(id=1, name="local-project", path=str(tmp_path / "local"), permalink="local"),
159+
Project(id=2, name="cloud-only", path="cloud-slug", permalink="cloud-only"),
160+
]
161+
)
162+
watch_service = WatchService(config, repo, quiet=True)
163+
164+
seen_project_names: list[list[str]] = []
165+
166+
async def watch_cycle_stub(projects, stop_event):
167+
seen_project_names.append([p.name for p in projects])
168+
watch_service.state.running = False
169+
stop_event.set()
170+
171+
async def fake_write_status():
172+
return None
173+
174+
monkeypatch.setattr(watch_service, "_watch_projects_cycle", watch_cycle_stub)
175+
monkeypatch.setattr(watch_service, "write_status", fake_write_status)
176+
177+
await watch_service.run()
178+
179+
assert seen_project_names == [["local-project"]]
180+
181+
182+
@pytest.mark.asyncio
183+
async def test_run_keeps_cloud_projects_with_local_bisync(monkeypatch, tmp_path):
184+
"""Cloud projects with an absolute path (local bisync copy) are kept for watching."""
185+
config = BasicMemoryConfig(
186+
watch_project_reload_interval=1,
187+
projects={
188+
"local-project": {"path": str(tmp_path / "local"), "mode": "local"},
189+
"cloud-bisync": {"path": str(tmp_path / "cloud"), "mode": "cloud"},
153190
},
154191
)
155192
repo = _Repo(
156193
projects_return=[
157194
Project(id=1, name="local-project", path=str(tmp_path / "local"), permalink="local"),
158195
Project(
159196
id=2,
160-
name="cloud-project",
197+
name="cloud-bisync",
161198
path=str(tmp_path / "cloud"),
162-
permalink="cloud",
199+
permalink="cloud-bisync",
163200
),
164201
]
165202
)
@@ -180,7 +217,7 @@ async def fake_write_status():
180217

181218
await watch_service.run()
182219

183-
assert seen_project_names == [["local-project"]]
220+
assert seen_project_names == [["local-project", "cloud-bisync"]]
184221

185222

186223
@pytest.mark.asyncio

tests/test_config.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1112,3 +1112,68 @@ def test_workspace_fields_round_trip(self):
11121112
loaded.projects["research"].workspace_id == "11111111-1111-1111-1111-111111111111"
11131113
)
11141114
assert loaded.projects["main"].workspace_id is None
1115+
1116+
1117+
class TestLocalSyncPathMigration:
1118+
"""Test migration that promotes local_sync_path into path for cloud projects."""
1119+
1120+
def test_migrate_promotes_local_sync_path_to_path(self):
1121+
"""When path is a cloud slug and local_sync_path is set, path becomes local_sync_path."""
1122+
data = {
1123+
"projects": {
1124+
"specs": {
1125+
"path": "specs",
1126+
"mode": "cloud",
1127+
"local_sync_path": "/Users/test/Documents/specs",
1128+
}
1129+
}
1130+
}
1131+
result = BasicMemoryConfig.migrate_legacy_projects(data)
1132+
assert result["projects"]["specs"]["path"] == "/Users/test/Documents/specs"
1133+
1134+
def test_migrate_does_not_overwrite_absolute_path(self):
1135+
"""When path is already absolute, migration should not change it."""
1136+
data = {
1137+
"projects": {
1138+
"specs": {
1139+
"path": "/Users/test/Documents/specs",
1140+
"mode": "cloud",
1141+
"local_sync_path": "/Users/test/Documents/specs",
1142+
}
1143+
}
1144+
}
1145+
result = BasicMemoryConfig.migrate_legacy_projects(data)
1146+
assert result["projects"]["specs"]["path"] == "/Users/test/Documents/specs"
1147+
1148+
def test_migrate_skips_entries_without_local_sync_path(self):
1149+
"""Entries without local_sync_path should not be modified."""
1150+
data = {
1151+
"projects": {
1152+
"cloud-only": {
1153+
"path": "cloud-only",
1154+
"mode": "cloud",
1155+
}
1156+
}
1157+
}
1158+
result = BasicMemoryConfig.migrate_legacy_projects(data)
1159+
assert result["projects"]["cloud-only"]["path"] == "cloud-only"
1160+
1161+
def test_migrate_handles_mixed_projects(self, tmp_path):
1162+
"""Migration handles a mix of local, cloud-only, and cloud-with-bisync projects."""
1163+
local_path = str(tmp_path / "local")
1164+
bisync_path = str(tmp_path / "bisync")
1165+
data = {
1166+
"projects": {
1167+
"local-proj": {"path": local_path, "mode": "local"},
1168+
"cloud-only": {"path": "cloud-only", "mode": "cloud"},
1169+
"cloud-bisync": {
1170+
"path": "cloud-bisync",
1171+
"mode": "cloud",
1172+
"local_sync_path": bisync_path,
1173+
},
1174+
}
1175+
}
1176+
result = BasicMemoryConfig.migrate_legacy_projects(data)
1177+
assert result["projects"]["local-proj"]["path"] == local_path
1178+
assert result["projects"]["cloud-only"]["path"] == "cloud-only"
1179+
assert result["projects"]["cloud-bisync"]["path"] == bisync_path

0 commit comments

Comments
 (0)