Skip to content

Commit 95d6bba

Browse files
committed
fix workspace project list local state merge
Signed-off-by: Drew Cain <groksrc@gmail.com>
1 parent 5a34a42 commit 95d6bba

2 files changed

Lines changed: 307 additions & 10 deletions

File tree

src/basic_memory/mcp/tools/project_management.py

Lines changed: 91 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,107 @@ 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+
# False sorts before True, so the cloud/default workspace comes first.
144+
not entry.workspace.is_default,
145+
workspace_type_rank,
146+
entry.workspace.name.casefold(),
147+
entry.workspace.tenant_id,
148+
)
149+
150+
151+
def _select_attached_cloud_entry(
152+
cloud_entries: tuple[WorkspaceProjectEntry, ...],
153+
*,
154+
config_entry: ProjectEntry | None,
155+
config: BasicMemoryConfig | None,
156+
) -> WorkspaceProjectEntry | None:
157+
"""Choose the single cloud row that should inherit local project state."""
158+
if not cloud_entries:
159+
return None
160+
161+
preferred_workspace_ids: list[str] = []
162+
if config_entry and config_entry.workspace_id:
163+
preferred_workspace_ids.append(config_entry.workspace_id)
164+
if (
165+
config
166+
and config.default_workspace
167+
and config.default_workspace not in preferred_workspace_ids
168+
):
169+
preferred_workspace_ids.append(config.default_workspace)
170+
171+
# The configured default workspace can differ from the cloud-side default.
172+
# Use the cloud default only after explicit local config preferences.
173+
default_workspace_entry = next(
174+
(entry for entry in cloud_entries if entry.workspace.is_default),
175+
None,
176+
)
177+
if (
178+
default_workspace_entry is not None
179+
and default_workspace_entry.workspace.tenant_id not in preferred_workspace_ids
180+
):
181+
preferred_workspace_ids.append(default_workspace_entry.workspace.tenant_id)
182+
183+
for workspace_id in preferred_workspace_ids:
184+
for entry in cloud_entries:
185+
if entry.workspace.tenant_id == workspace_id:
186+
return entry
187+
188+
if len(cloud_entries) == 1:
189+
return cloud_entries[0]
190+
191+
return sorted(cloud_entries, key=_workspace_entry_priority)[0]
192+
193+
134194
def _merge_workspace_projects(
135195
local_list: ProjectList | None,
136196
cloud_entries: tuple[WorkspaceProjectEntry, ...],
197+
*,
198+
config: BasicMemoryConfig | None = None,
137199
) -> list[dict]:
138200
"""Merge local projects with cloud projects from every accessible workspace."""
139201
local_by_permalink: dict[str, ProjectItem] = {}
140202
if local_list:
141203
for project in local_list.projects:
142204
local_by_permalink[project.permalink] = project
143205

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

147228
for entry in sorted(
148229
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-
),
230+
key=lambda item: (*_workspace_entry_priority(item), item.project.permalink),
155231
):
156232
permalink = entry.project.permalink
157-
local_proj = local_by_permalink.get(permalink)
233+
local_proj = (
234+
local_by_permalink.get(permalink)
235+
# WorkspaceProjectEntry is a frozen dataclass containing Pydantic
236+
# models, so value equality is the intended comparison here.
237+
if attached_entry_by_permalink.get(permalink) == entry
238+
else None
239+
)
158240
cloud_proj = entry.project
159241
source = "local+cloud" if local_proj else "cloud"
160242
local_path = local_proj.path if local_proj else None
@@ -339,7 +421,7 @@ async def list_memory_projects(
339421
)
340422

341423
if cloud_entries:
342-
merged = _merge_workspace_projects(local_list, cloud_entries)
424+
merged = _merge_workspace_projects(local_list, cloud_entries, config=config)
343425
else:
344426
merged = _merge_projects(
345427
local_list,

tests/mcp/test_tool_project_management.py

Lines changed: 216 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,220 @@ 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+
1022+
def test_merge_workspace_projects_uses_default_workspace_for_local_state(tmp_path):
1023+
"""Global default_workspace should attach local state before cloud default fallback."""
1024+
local_path = str(tmp_path / "main")
1025+
local_main = _make_project("main", local_path, is_default=True)
1026+
local_list = _make_list([local_main], default="main")
1027+
personal_main = _make_project(
1028+
"main",
1029+
"/cloud/personal-main",
1030+
id=10,
1031+
external_id="personal-main-uuid",
1032+
)
1033+
team_main = _make_project(
1034+
"main",
1035+
"/cloud/team-main",
1036+
id=11,
1037+
external_id="team-main-uuid",
1038+
)
1039+
personal_ws = _make_workspace(
1040+
"personal-tenant",
1041+
"Personal",
1042+
slug="personal",
1043+
is_default=True,
1044+
)
1045+
team_ws = _make_workspace(
1046+
"team-tenant",
1047+
"Team",
1048+
workspace_type="organization",
1049+
slug="team",
1050+
)
1051+
workspace_index = _make_workspace_index(
1052+
[
1053+
(personal_ws, [personal_main]),
1054+
(team_ws, [team_main]),
1055+
]
1056+
)
1057+
config = BasicMemoryConfig(
1058+
projects={"main": ProjectEntry(path=local_path)},
1059+
default_workspace="team-tenant",
1060+
)
1061+
1062+
merged = _merge_workspace_projects(local_list, workspace_index.entries, config=config)
1063+
1064+
by_qualified_name = {project["qualified_name"]: project for project in merged}
1065+
personal_project = by_qualified_name["personal/main"]
1066+
team_project = by_qualified_name["team/main"]
1067+
1068+
assert personal_project["source"] == "cloud"
1069+
assert personal_project["local_path"] is None
1070+
assert personal_project["path"] == "/cloud/personal-main"
1071+
assert team_project["source"] == "local+cloud"
1072+
assert team_project["local_path"] == local_path
1073+
assert team_project["path"] == local_path
1074+
1075+
1076+
def test_merge_workspace_projects_sorted_fallback_attaches_personal_workspace(tmp_path):
1077+
"""When config has no preference and no cloud default exists, use stable priority."""
1078+
local_path = str(tmp_path / "main")
1079+
local_main = _make_project("main", local_path, is_default=True)
1080+
local_list = _make_list([local_main], default="main")
1081+
personal_main = _make_project(
1082+
"main",
1083+
"/cloud/personal-main",
1084+
id=10,
1085+
external_id="personal-main-uuid",
1086+
)
1087+
team_main = _make_project(
1088+
"main",
1089+
"/cloud/team-main",
1090+
id=11,
1091+
external_id="team-main-uuid",
1092+
)
1093+
personal_ws = _make_workspace(
1094+
"personal-tenant",
1095+
"Personal",
1096+
slug="personal",
1097+
is_default=False,
1098+
)
1099+
team_ws = _make_workspace(
1100+
"team-tenant",
1101+
"Team",
1102+
workspace_type="organization",
1103+
slug="team",
1104+
)
1105+
workspace_index = _make_workspace_index(
1106+
[
1107+
(team_ws, [team_main]),
1108+
(personal_ws, [personal_main]),
1109+
]
1110+
)
1111+
config = BasicMemoryConfig(projects={"main": ProjectEntry(path=local_path)})
1112+
1113+
merged = _merge_workspace_projects(local_list, workspace_index.entries, config=config)
1114+
1115+
by_qualified_name = {project["qualified_name"]: project for project in merged}
1116+
personal_project = by_qualified_name["personal/main"]
1117+
team_project = by_qualified_name["team/main"]
1118+
1119+
assert personal_project["source"] == "local+cloud"
1120+
assert personal_project["local_path"] == local_path
1121+
assert personal_project["path"] == local_path
1122+
assert team_project["source"] == "cloud"
1123+
assert team_project["local_path"] is None
1124+
assert team_project["path"] == "/cloud/team-main"
1125+
1126+
9121127
# --- Workspace passthrough tests ---
9131128

9141129

0 commit comments

Comments
 (0)