Skip to content

Commit 50e15e7

Browse files
committed
fix workspace project list local state merge
1 parent 5a34a42 commit 50e15e7

2 files changed

Lines changed: 197 additions & 10 deletions

File tree

src/basic_memory/mcp/tools/project_management.py

Lines changed: 86 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,12 @@
1010
from fastmcp import Context
1111
from loguru import logger
1212

13-
from basic_memory.config import ConfigManager, has_cloud_credentials
13+
from basic_memory.config import (
14+
BasicMemoryConfig,
15+
ConfigManager,
16+
ProjectEntry,
17+
has_cloud_credentials,
18+
)
1419
from basic_memory.mcp.async_client import (
1520
_explicit_routing,
1621
_force_local_mode,
@@ -131,30 +136,102 @@ def _merge_projects(
131136
return merged
132137

133138

139+
def _workspace_entry_priority(entry: WorkspaceProjectEntry) -> tuple[bool, int, str, str]:
140+
"""Prefer default/personal workspaces when duplicate project permalinks exist."""
141+
workspace_type_rank = 0 if entry.workspace.workspace_type == "personal" else 1
142+
return (
143+
not entry.workspace.is_default,
144+
workspace_type_rank,
145+
entry.workspace.name.casefold(),
146+
entry.workspace.tenant_id,
147+
)
148+
149+
150+
def _select_attached_cloud_entry(
151+
cloud_entries: tuple[WorkspaceProjectEntry, ...],
152+
*,
153+
config_entry: ProjectEntry | None,
154+
config: BasicMemoryConfig | None,
155+
) -> WorkspaceProjectEntry | None:
156+
"""Choose the single cloud row that should inherit local project state."""
157+
if not cloud_entries:
158+
return None
159+
160+
preferred_workspace_ids: list[str] = []
161+
if config_entry and config_entry.workspace_id:
162+
preferred_workspace_ids.append(config_entry.workspace_id)
163+
if (
164+
config
165+
and config.default_workspace
166+
and config.default_workspace not in preferred_workspace_ids
167+
):
168+
preferred_workspace_ids.append(config.default_workspace)
169+
170+
default_workspace_entry = next(
171+
(entry for entry in cloud_entries if entry.workspace.is_default),
172+
None,
173+
)
174+
if (
175+
default_workspace_entry is not None
176+
and default_workspace_entry.workspace.tenant_id not in preferred_workspace_ids
177+
):
178+
preferred_workspace_ids.append(default_workspace_entry.workspace.tenant_id)
179+
180+
for workspace_id in preferred_workspace_ids:
181+
for entry in cloud_entries:
182+
if entry.workspace.tenant_id == workspace_id:
183+
return entry
184+
185+
if len(cloud_entries) == 1:
186+
return cloud_entries[0]
187+
188+
return sorted(cloud_entries, key=_workspace_entry_priority)[0]
189+
190+
134191
def _merge_workspace_projects(
135192
local_list: ProjectList | None,
136193
cloud_entries: tuple[WorkspaceProjectEntry, ...],
194+
*,
195+
config: BasicMemoryConfig | None = None,
137196
) -> list[dict]:
138197
"""Merge local projects with cloud projects from every accessible workspace."""
139198
local_by_permalink: dict[str, ProjectItem] = {}
140199
if local_list:
141200
for project in local_list.projects:
142201
local_by_permalink[project.permalink] = project
143202

203+
config_by_permalink: dict[str, ProjectEntry] = {}
204+
if config:
205+
config_by_permalink = {
206+
generate_permalink(project_name): entry
207+
for project_name, entry in config.projects.items()
208+
}
209+
210+
cloud_entries_by_permalink: dict[str, list[WorkspaceProjectEntry]] = {}
211+
for entry in cloud_entries:
212+
cloud_entries_by_permalink.setdefault(entry.project.permalink, []).append(entry)
213+
214+
attached_entry_by_permalink: dict[str, WorkspaceProjectEntry | None] = {}
215+
for permalink in local_by_permalink:
216+
attached_entry_by_permalink[permalink] = _select_attached_cloud_entry(
217+
tuple(cloud_entries_by_permalink.get(permalink, ())),
218+
config_entry=config_by_permalink.get(permalink),
219+
config=config,
220+
)
221+
144222
cloud_permalinks = {entry.project.permalink for entry in cloud_entries}
145223
merged: list[dict] = []
146224

147225
for entry in sorted(
148226
cloud_entries,
149-
key=lambda item: (
150-
not item.workspace.is_default,
151-
item.workspace.workspace_type != "personal",
152-
item.workspace.name.casefold(),
153-
item.project.permalink,
154-
),
227+
key=lambda item: (*_workspace_entry_priority(item), item.project.permalink),
155228
):
156229
permalink = entry.project.permalink
157-
local_proj = local_by_permalink.get(permalink)
230+
local_proj = (
231+
local_by_permalink.get(permalink)
232+
if attached_entry_by_permalink.get(permalink) == entry
233+
else None
234+
)
158235
cloud_proj = entry.project
159236
source = "local+cloud" if local_proj else "cloud"
160237
local_path = local_proj.path if local_proj else None
@@ -339,7 +416,7 @@ async def list_memory_projects(
339416
)
340417

341418
if cloud_entries:
342-
merged = _merge_workspace_projects(local_list, cloud_entries)
419+
merged = _merge_workspace_projects(local_list, cloud_entries, config=config)
343420
else:
344421
merged = _merge_projects(
345422
local_list,

tests/mcp/test_tool_project_management.py

Lines changed: 111 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99

1010
from basic_memory import db
1111
from basic_memory.mcp.tools import list_memory_projects, create_memory_project, delete_project
12-
from basic_memory.mcp.tools.project_management import _merge_projects
12+
from basic_memory.config import BasicMemoryConfig, ProjectEntry
13+
from basic_memory.mcp.tools.project_management import _merge_projects, _merge_workspace_projects
1314
from basic_memory.models.project import Project
1415
from basic_memory.schemas.project_info import ProjectItem, ProjectList
1516

@@ -909,6 +910,115 @@ def test_merge_projects_overlap():
909910
assert merged[0]["workspace_tenant_id"] == "org-456"
910911

911912

913+
def test_merge_workspace_projects_attaches_local_state_to_one_duplicate_workspace(tmp_path):
914+
"""A same-name team workspace project should stay cloud-only (#848)."""
915+
local_path = str(tmp_path / "main")
916+
local_main = _make_project("main", local_path, is_default=True)
917+
local_list = _make_list([local_main], default="main")
918+
personal_main = _make_project(
919+
"main",
920+
"/cloud/personal-main",
921+
id=10,
922+
external_id="personal-main-uuid",
923+
)
924+
team_main = _make_project(
925+
"main",
926+
"/cloud/team-main",
927+
id=11,
928+
external_id="team-main-uuid",
929+
)
930+
personal_ws = _make_workspace(
931+
"personal-tenant",
932+
"Personal",
933+
slug="personal",
934+
is_default=True,
935+
)
936+
team_ws = _make_workspace(
937+
"team-tenant",
938+
"Team",
939+
workspace_type="organization",
940+
slug="team",
941+
)
942+
workspace_index = _make_workspace_index(
943+
[
944+
(personal_ws, [personal_main]),
945+
(team_ws, [team_main]),
946+
]
947+
)
948+
config = BasicMemoryConfig(projects={"main": ProjectEntry(path=local_path)})
949+
950+
merged = _merge_workspace_projects(local_list, workspace_index.entries, config=config)
951+
952+
by_qualified_name = {project["qualified_name"]: project for project in merged}
953+
personal_project = by_qualified_name["personal/main"]
954+
team_project = by_qualified_name["team/main"]
955+
956+
assert personal_project["source"] == "local+cloud"
957+
assert personal_project["local_path"] == local_path
958+
assert personal_project["path"] == local_path
959+
assert team_project["source"] == "cloud"
960+
assert team_project["local_path"] is None
961+
assert team_project["path"] == "/cloud/team-main"
962+
963+
964+
def test_merge_workspace_projects_uses_configured_workspace_for_local_state(tmp_path):
965+
"""Per-project workspace_id should select the attached duplicate row."""
966+
local_path = str(tmp_path / "main")
967+
local_main = _make_project("main", local_path, is_default=True)
968+
local_list = _make_list([local_main], default="main")
969+
personal_main = _make_project(
970+
"main",
971+
"/cloud/personal-main",
972+
id=10,
973+
external_id="personal-main-uuid",
974+
)
975+
team_main = _make_project(
976+
"main",
977+
"/cloud/team-main",
978+
id=11,
979+
external_id="team-main-uuid",
980+
)
981+
personal_ws = _make_workspace(
982+
"personal-tenant",
983+
"Personal",
984+
slug="personal",
985+
is_default=True,
986+
)
987+
team_ws = _make_workspace(
988+
"team-tenant",
989+
"Team",
990+
workspace_type="organization",
991+
slug="team",
992+
)
993+
workspace_index = _make_workspace_index(
994+
[
995+
(personal_ws, [personal_main]),
996+
(team_ws, [team_main]),
997+
]
998+
)
999+
config = BasicMemoryConfig(
1000+
projects={
1001+
"main": ProjectEntry(
1002+
path=local_path,
1003+
workspace_id="team-tenant",
1004+
)
1005+
}
1006+
)
1007+
1008+
merged = _merge_workspace_projects(local_list, workspace_index.entries, config=config)
1009+
1010+
by_qualified_name = {project["qualified_name"]: project for project in merged}
1011+
personal_project = by_qualified_name["personal/main"]
1012+
team_project = by_qualified_name["team/main"]
1013+
1014+
assert personal_project["source"] == "cloud"
1015+
assert personal_project["local_path"] is None
1016+
assert personal_project["path"] == "/cloud/personal-main"
1017+
assert team_project["source"] == "local+cloud"
1018+
assert team_project["local_path"] == local_path
1019+
assert team_project["path"] == local_path
1020+
1021+
9121022
# --- Workspace passthrough tests ---
9131023

9141024

0 commit comments

Comments
 (0)