Skip to content

Commit e3323fd

Browse files
groksrcclaude
andcommitted
fix: add workspace routing to cloud upload and API client
Cloud upload, project existence checks, and project creation now resolve and pass the X-Workspace-ID header for teams workspace support. The get_client() per-project routing also auto-resolves workspace from project config when not explicitly provided. Fixes #703 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Drew Cain <groksrc@gmail.com>
1 parent cfa7000 commit e3323fd

3 files changed

Lines changed: 61 additions & 10 deletions

File tree

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

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,16 @@ class CloudUtilsError(Exception):
1616
pass
1717

1818

19+
def _workspace_headers(workspace: str | None = None) -> dict[str, str]:
20+
"""Build workspace header if workspace is specified."""
21+
if workspace:
22+
return {"X-Workspace-ID": workspace}
23+
return {}
24+
25+
1926
async def fetch_cloud_projects(
2027
*,
28+
workspace: str | None = None,
2129
api_request=make_api_request,
2230
) -> CloudProjectList:
2331
"""Fetch list of projects from cloud API.
@@ -30,7 +38,11 @@ async def fetch_cloud_projects(
3038
config = config_manager.config
3139
host_url = config.cloud_host.rstrip("/")
3240

33-
response = await api_request(method="GET", url=f"{host_url}/proxy/v2/projects/")
41+
response = await api_request(
42+
method="GET",
43+
url=f"{host_url}/proxy/v2/projects/",
44+
headers=_workspace_headers(workspace),
45+
)
3446

3547
return CloudProjectList.model_validate(response.json())
3648
except Exception as e:
@@ -40,12 +52,14 @@ async def fetch_cloud_projects(
4052
async def create_cloud_project(
4153
project_name: str,
4254
*,
55+
workspace: str | None = None,
4356
api_request=make_api_request,
4457
) -> CloudProjectCreateResponse:
4558
"""Create a new project on cloud.
4659
4760
Args:
4861
project_name: Name of project to create
62+
workspace: Cloud workspace tenant_id to create project in
4963
5064
Returns:
5165
CloudProjectCreateResponse with project details from API
@@ -64,10 +78,13 @@ async def create_cloud_project(
6478
set_default=False,
6579
)
6680

81+
headers = {"Content-Type": "application/json"}
82+
headers.update(_workspace_headers(workspace))
83+
6784
response = await api_request(
6885
method="POST",
6986
url=f"{host_url}/proxy/v2/projects/",
70-
headers={"Content-Type": "application/json"},
87+
headers=headers,
7188
json_data=project_data.model_dump(),
7289
)
7390

@@ -91,17 +108,23 @@ async def sync_project(project_name: str, force_full: bool = False) -> None:
91108
raise CloudUtilsError(f"Failed to sync project '{project_name}': {e}") from e
92109

93110

94-
async def project_exists(project_name: str, *, api_request=make_api_request) -> bool:
111+
async def project_exists(
112+
project_name: str,
113+
*,
114+
workspace: str | None = None,
115+
api_request=make_api_request,
116+
) -> bool:
95117
"""Check if a project exists on cloud.
96118
97119
Args:
98120
project_name: Name of project to check
121+
workspace: Cloud workspace tenant_id to check in
99122
100123
Returns:
101124
True if project exists, False otherwise
102125
"""
103126
try:
104-
projects = await fetch_cloud_projects(api_request=api_request)
127+
projects = await fetch_cloud_projects(workspace=workspace, api_request=api_request)
105128
project_names = {p.name for p in projects.projects}
106129
return project_name in project_names
107130
except Exception:

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

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
sync_project,
1414
)
1515
from basic_memory.cli.commands.cloud.upload import upload_path
16+
from basic_memory.config import ConfigManager
1617
from basic_memory.mcp.async_client import get_cloud_control_plane_client
1718

1819
console = Console()
@@ -73,12 +74,21 @@ def upload(
7374
"""
7475

7576
async def _upload():
77+
# Resolve workspace: per-project workspace_id, then default_workspace
78+
config = ConfigManager().config
79+
workspace = None
80+
entry = config.projects.get(project)
81+
if entry and entry.workspace_id:
82+
workspace = entry.workspace_id
83+
elif config.default_workspace:
84+
workspace = config.default_workspace
85+
7686
# Check if project exists
77-
if not await project_exists(project):
87+
if not await project_exists(project, workspace=workspace):
7888
if create_project:
7989
console.print(f"[blue]Creating cloud project '{project}'...[/blue]")
8090
try:
81-
await create_cloud_project(project)
91+
await create_cloud_project(project, workspace=workspace)
8292
console.print(f"[green]Created project '{project}'[/green]")
8393
except Exception as e:
8494
console.print(f"[red]Failed to create project: {e}[/red]")
@@ -93,20 +103,25 @@ async def _upload():
93103
raise typer.Exit(1)
94104

95105
# Perform upload (or dry run)
106+
if workspace:
107+
console.print(f"[dim]Using workspace: {workspace}[/dim]")
96108
if dry_run:
97109
console.print(
98110
f"[yellow]DRY RUN: Showing what would be uploaded to '{project}'[/yellow]"
99111
)
100112
else:
101113
console.print(f"[blue]Uploading {path} to project '{project}'...[/blue]")
102114

115+
def _client_factory():
116+
return get_cloud_control_plane_client(workspace=workspace)
117+
103118
success = await upload_path(
104119
path,
105120
project,
106121
verbose=verbose,
107122
use_gitignore=not no_gitignore,
108123
dry_run=dry_run,
109-
client_cm_factory=get_cloud_control_plane_client,
124+
client_cm_factory=_client_factory,
110125
)
111126
if not success:
112127
console.print("[red]Upload failed[/red]")

src/basic_memory/mcp/async_client.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,15 +88,20 @@ async def _cloud_client(
8888

8989

9090
@asynccontextmanager
91-
async def get_cloud_control_plane_client() -> AsyncIterator[AsyncClient]:
91+
async def get_cloud_control_plane_client(
92+
workspace: Optional[str] = None,
93+
) -> AsyncIterator[AsyncClient]:
9294
"""Create a control-plane cloud client for endpoints outside /proxy."""
9395
config = ConfigManager().config
9496
timeout = _build_timeout()
9597
token = await _resolve_cloud_token(config)
98+
headers: dict[str, str] = {"Authorization": f"Bearer {token}"}
99+
if workspace:
100+
headers["X-Workspace-ID"] = workspace
96101
logger.info(f"Creating HTTP client for cloud control plane at: {config.cloud_host}")
97102
async with AsyncClient(
98103
base_url=config.cloud_host,
99-
headers={"Authorization": f"Bearer {token}"},
104+
headers=headers,
100105
timeout=timeout,
101106
) as client:
102107
yield client
@@ -179,8 +184,16 @@ async def get_client(
179184
project_mode = config.get_project_mode(project_name)
180185
if project_mode == ProjectMode.CLOUD:
181186
logger.debug(f"Project '{project_name}' is cloud mode - using cloud proxy client")
187+
# Resolve workspace from project config if not explicitly provided
188+
effective_workspace = workspace
189+
if effective_workspace is None:
190+
entry = config.projects.get(project_name)
191+
if entry and entry.workspace_id:
192+
effective_workspace = entry.workspace_id
193+
elif config.default_workspace:
194+
effective_workspace = config.default_workspace
182195
try:
183-
async with _cloud_client(config, timeout, workspace=workspace) as client:
196+
async with _cloud_client(config, timeout, workspace=effective_workspace) as client:
184197
yield client
185198
except RuntimeError as exc:
186199
raise RuntimeError(

0 commit comments

Comments
 (0)