Skip to content

Commit ca978b7

Browse files
committed
fix cloud workspace routing and incremental sync
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent cfa7000 commit ca978b7

8 files changed

Lines changed: 331 additions & 20 deletions

File tree

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

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from basic_memory.cli.commands.cloud.api_client import make_api_request
44
from basic_memory.config import ConfigManager
5+
from basic_memory.mcp.async_client import resolve_configured_workspace
56
from basic_memory.schemas.cloud import (
67
CloudProjectList,
78
CloudProjectCreateRequest,
@@ -16,8 +17,25 @@ class CloudUtilsError(Exception):
1617
pass
1718

1819

20+
def _workspace_headers(
21+
*,
22+
project_name: str | None = None,
23+
workspace: str | None = None,
24+
) -> dict[str, str]:
25+
"""Build optional workspace headers using the CLI config resolution chain."""
26+
resolved_workspace = resolve_configured_workspace(
27+
project_name=project_name,
28+
workspace=workspace,
29+
)
30+
if resolved_workspace is None:
31+
return {}
32+
return {"X-Workspace-ID": resolved_workspace}
33+
34+
1935
async def fetch_cloud_projects(
2036
*,
37+
project_name: str | None = None,
38+
workspace: str | None = None,
2139
api_request=make_api_request,
2240
) -> CloudProjectList:
2341
"""Fetch list of projects from cloud API.
@@ -30,7 +48,11 @@ async def fetch_cloud_projects(
3048
config = config_manager.config
3149
host_url = config.cloud_host.rstrip("/")
3250

33-
response = await api_request(method="GET", url=f"{host_url}/proxy/v2/projects/")
51+
response = await api_request(
52+
method="GET",
53+
url=f"{host_url}/proxy/v2/projects/",
54+
headers=_workspace_headers(project_name=project_name, workspace=workspace),
55+
)
3456

3557
return CloudProjectList.model_validate(response.json())
3658
except Exception as e:
@@ -40,6 +62,7 @@ async def fetch_cloud_projects(
4062
async def create_cloud_project(
4163
project_name: str,
4264
*,
65+
workspace: str | None = None,
4366
api_request=make_api_request,
4467
) -> CloudProjectCreateResponse:
4568
"""Create a new project on cloud.
@@ -67,7 +90,10 @@ async def create_cloud_project(
6790
response = await api_request(
6891
method="POST",
6992
url=f"{host_url}/proxy/v2/projects/",
70-
headers={"Content-Type": "application/json"},
93+
headers={
94+
"Content-Type": "application/json",
95+
**_workspace_headers(project_name=project_name, workspace=workspace),
96+
},
7197
json_data=project_data.model_dump(),
7298
)
7399

@@ -91,7 +117,12 @@ async def sync_project(project_name: str, force_full: bool = False) -> None:
91117
raise CloudUtilsError(f"Failed to sync project '{project_name}': {e}") from e
92118

93119

94-
async def project_exists(project_name: str, *, api_request=make_api_request) -> bool:
120+
async def project_exists(
121+
project_name: str,
122+
*,
123+
workspace: str | None = None,
124+
api_request=make_api_request,
125+
) -> bool:
95126
"""Check if a project exists on cloud.
96127
97128
Args:
@@ -101,7 +132,11 @@ async def project_exists(project_name: str, *, api_request=make_api_request) ->
101132
True if project exists, False otherwise
102133
"""
103134
try:
104-
projects = await fetch_cloud_projects(api_request=api_request)
135+
projects = await fetch_cloud_projects(
136+
project_name=project_name,
137+
workspace=workspace,
138+
api_request=api_request,
139+
)
105140
project_names = {p.name for p in projects.projects}
106141
return project_name in project_names
107142
except Exception:

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

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ def _require_cloud_credentials(config) -> None:
5454

5555
async def _get_cloud_project(name: str) -> ProjectItem | None:
5656
"""Fetch a project by name from the cloud API."""
57-
async with get_client() as client:
57+
async with get_client(project_name=name) as client:
5858
projects_list = await ProjectClient(client).list_projects()
5959
for proj in projects_list.projects:
6060
if generate_permalink(proj.name) == generate_permalink(name):
@@ -129,9 +129,9 @@ def sync_project_command(
129129
if not dry_run:
130130

131131
async def _trigger_db_sync():
132-
async with get_client() as client:
132+
async with get_client(project_name=name) as client:
133133
return await ProjectClient(client).sync(
134-
project_data.external_id, force_full=True
134+
project_data.external_id, force_full=False
135135
)
136136

137137
try:
@@ -204,9 +204,9 @@ def bisync_project_command(
204204
if not dry_run:
205205

206206
async def _trigger_db_sync():
207-
async with get_client() as client:
207+
async with get_client(project_name=name) as client:
208208
return await ProjectClient(client).sync(
209-
project_data.external_id, force_full=True
209+
project_data.external_id, force_full=False
210210
)
211211

212212
try:
@@ -320,7 +320,7 @@ def setup_project_sync(
320320

321321
async def _verify_project_exists():
322322
"""Verify the project exists on cloud by listing all projects."""
323-
async with get_client() as client:
323+
async with get_client(project_name=name) as client:
324324
projects_list = await ProjectClient(client).list_projects()
325325
project_names = [p.name for p in projects_list.projects]
326326
if name not in project_names:

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

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Upload CLI commands for basic-memory projects."""
22

3+
from functools import partial
34
from pathlib import Path
45

56
import typer
@@ -13,7 +14,10 @@
1314
sync_project,
1415
)
1516
from basic_memory.cli.commands.cloud.upload import upload_path
16-
from basic_memory.mcp.async_client import get_cloud_control_plane_client
17+
from basic_memory.mcp.async_client import (
18+
get_cloud_control_plane_client,
19+
resolve_configured_workspace,
20+
)
1721

1822
console = Console()
1923

@@ -73,12 +77,14 @@ def upload(
7377
"""
7478

7579
async def _upload():
80+
resolved_workspace = resolve_configured_workspace(project_name=project)
81+
7682
# Check if project exists
77-
if not await project_exists(project):
83+
if not await project_exists(project, workspace=resolved_workspace):
7884
if create_project:
7985
console.print(f"[blue]Creating cloud project '{project}'...[/blue]")
8086
try:
81-
await create_cloud_project(project)
87+
await create_cloud_project(project, workspace=resolved_workspace)
8288
console.print(f"[green]Created project '{project}'[/green]")
8389
except Exception as e:
8490
console.print(f"[red]Failed to create project: {e}[/red]")
@@ -106,7 +112,10 @@ async def _upload():
106112
verbose=verbose,
107113
use_gitignore=not no_gitignore,
108114
dry_run=dry_run,
109-
client_cm_factory=get_cloud_control_plane_client,
115+
client_cm_factory=partial(
116+
get_cloud_control_plane_client,
117+
workspace=resolved_workspace,
118+
),
110119
)
111120
if not success:
112121
console.print("[red]Upload failed[/red]")

src/basic_memory/mcp/async_client.py

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,27 @@ async def _resolve_cloud_token(config) -> str:
6666
)
6767

6868

69+
def resolve_configured_workspace(
70+
*,
71+
config=None,
72+
project_name: Optional[str] = None,
73+
workspace: Optional[str] = None,
74+
) -> Optional[str]:
75+
"""Resolve workspace from explicit input, per-project config, then global default."""
76+
if workspace is not None:
77+
return workspace
78+
79+
if config is None:
80+
config = ConfigManager().config
81+
82+
if project_name is not None:
83+
project_entry = config.projects.get(project_name)
84+
if project_entry and project_entry.workspace_id:
85+
return project_entry.workspace_id
86+
87+
return config.default_workspace
88+
89+
6990
@asynccontextmanager
7091
async def _cloud_client(
7192
config,
@@ -88,15 +109,20 @@ async def _cloud_client(
88109

89110

90111
@asynccontextmanager
91-
async def get_cloud_control_plane_client() -> AsyncIterator[AsyncClient]:
112+
async def get_cloud_control_plane_client(
113+
workspace: Optional[str] = None,
114+
) -> AsyncIterator[AsyncClient]:
92115
"""Create a control-plane cloud client for endpoints outside /proxy."""
93116
config = ConfigManager().config
94117
timeout = _build_timeout()
95118
token = await _resolve_cloud_token(config)
119+
headers = {"Authorization": f"Bearer {token}"}
120+
if workspace:
121+
headers["X-Workspace-ID"] = workspace
96122
logger.info(f"Creating HTTP client for cloud control plane at: {config.cloud_host}")
97123
async with AsyncClient(
98124
base_url=config.cloud_host,
99-
headers={"Authorization": f"Bearer {token}"},
125+
headers=headers,
100126
timeout=timeout,
101127
) as client:
102128
yield client
@@ -167,7 +193,12 @@ async def get_client(
167193

168194
if _force_cloud_mode():
169195
logger.debug("Explicit cloud routing enabled - using cloud proxy client")
170-
async with _cloud_client(config, timeout, workspace=workspace) as client:
196+
effective_workspace = resolve_configured_workspace(
197+
config=config,
198+
project_name=project_name,
199+
workspace=workspace,
200+
)
201+
async with _cloud_client(config, timeout, workspace=effective_workspace) as client:
171202
yield client
172203
return
173204

@@ -179,8 +210,13 @@ async def get_client(
179210
project_mode = config.get_project_mode(project_name)
180211
if project_mode == ProjectMode.CLOUD:
181212
logger.debug(f"Project '{project_name}' is cloud mode - using cloud proxy client")
213+
effective_workspace = resolve_configured_workspace(
214+
config=config,
215+
project_name=project_name,
216+
workspace=workspace,
217+
)
182218
try:
183-
async with _cloud_client(config, timeout, workspace=workspace) as client:
219+
async with _cloud_client(config, timeout, workspace=effective_workspace) as client:
184220
yield client
185221
except RuntimeError as exc:
186222
raise RuntimeError(

tests/cli/cloud/test_cloud_api_client_and_utils.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
fetch_cloud_projects,
1515
project_exists,
1616
)
17+
from basic_memory.config import ProjectMode
1718

1819

1920
@pytest.mark.asyncio
@@ -165,6 +166,56 @@ async def api_request(**kwargs):
165166
assert seen["create_payload"]["path"] == "my-project"
166167

167168

169+
@pytest.mark.asyncio
170+
async def test_cloud_utils_use_configured_workspace_headers(config_home, config_manager):
171+
"""Workspace-aware cloud helpers should prefer project workspace over global default."""
172+
config = config_manager.load_config()
173+
config.cloud_host = "https://cloud.example.test"
174+
config.default_workspace = "default-workspace"
175+
config.set_project_mode("alpha", ProjectMode.CLOUD)
176+
config.projects["alpha"].workspace_id = "project-workspace"
177+
config_manager.save_config(config)
178+
179+
seen: list[tuple[str, str | None]] = []
180+
181+
async def api_request(**kwargs):
182+
seen.append(
183+
(
184+
kwargs["method"],
185+
(kwargs.get("headers") or {}).get("X-Workspace-ID"),
186+
)
187+
)
188+
189+
if kwargs["method"] == "GET":
190+
return httpx.Response(
191+
200,
192+
json={
193+
"projects": [{"id": 1, "name": "alpha", "path": "alpha", "is_default": True}]
194+
},
195+
)
196+
197+
return httpx.Response(
198+
200,
199+
json={
200+
"message": "created",
201+
"status": "success",
202+
"default": False,
203+
"old_project": None,
204+
"new_project": {"name": "alpha", "path": "alpha"},
205+
},
206+
)
207+
208+
assert await project_exists("alpha", api_request=api_request) is True
209+
await create_cloud_project("alpha", api_request=api_request)
210+
await fetch_cloud_projects(project_name="missing", api_request=api_request)
211+
212+
assert seen == [
213+
("GET", "project-workspace"),
214+
("POST", "project-workspace"),
215+
("GET", "default-workspace"),
216+
]
217+
218+
168219
@pytest.mark.asyncio
169220
async def test_make_api_request_prefers_api_key_over_oauth(config_home, config_manager):
170221
"""API key in config should be used without needing an OAuth token on disk."""

0 commit comments

Comments
 (0)