|
9 | 9 | """ |
10 | 10 |
|
11 | 11 | import asyncio |
12 | | -from contextlib import asynccontextmanager |
| 12 | +from contextlib import asynccontextmanager, nullcontext |
13 | 13 | from dataclasses import dataclass, field |
14 | 14 | from typing import AsyncIterator, Awaitable, Callable, Optional, List, Tuple, cast |
15 | 15 | from uuid import UUID |
|
30 | 30 | from basic_memory.schemas.v2 import ProjectResolveResponse |
31 | 31 | from basic_memory.schemas.memory import memory_url_path |
32 | 32 | from basic_memory.utils import generate_permalink, normalize_project_reference |
| 33 | +from basic_memory.workspace_context import ( |
| 34 | + current_workspace_permalink_context, |
| 35 | + workspace_permalink_context, |
| 36 | +) |
33 | 37 |
|
34 | 38 | # --- Workspace provider injection --- |
35 | 39 | # Mirrors the set_client_factory() pattern in async_client.py. |
@@ -62,6 +66,18 @@ class WorkspaceProjectIndex: |
62 | 66 | failed_workspaces: tuple[WorkspaceInfo, ...] = () |
63 | 67 |
|
64 | 68 |
|
| 69 | +@dataclass(frozen=True) |
| 70 | +class WorkspaceMemoryUrlResolution: |
| 71 | + """Resolved workspace/project route for a workspace-qualified memory URL.""" |
| 72 | + |
| 73 | + entry: WorkspaceProjectEntry |
| 74 | + canonical_path: str |
| 75 | + |
| 76 | + @property |
| 77 | + def project_identifier(self) -> str: |
| 78 | + return self.entry.qualified_name |
| 79 | + |
| 80 | + |
65 | 81 | def set_workspace_provider(provider: Callable[[], Awaitable[list[WorkspaceInfo]]]) -> None: |
66 | 82 | """Override workspace discovery (for cloud app, testing, etc).""" |
67 | 83 | global _workspace_provider |
@@ -390,6 +406,89 @@ def _unqualified_project_identifier(identifier: str) -> str: |
390 | 406 | return project_identifier |
391 | 407 |
|
392 | 408 |
|
| 409 | +def _split_workspace_memory_url_segments(identifier: str) -> tuple[str, str, str] | None: |
| 410 | + """Split ``memory://<workspace>/<project>/<path>`` into route segments.""" |
| 411 | + if not identifier.strip().startswith("memory://"): |
| 412 | + return None |
| 413 | + |
| 414 | + normalized = normalize_project_reference(memory_url_path(identifier)) |
| 415 | + parts = normalized.split("/", 2) |
| 416 | + if len(parts) != 3: |
| 417 | + return None |
| 418 | + |
| 419 | + workspace_slug, project_identifier, remainder = parts |
| 420 | + if not workspace_slug or not project_identifier or not remainder: |
| 421 | + return None |
| 422 | + return workspace_slug, project_identifier, remainder |
| 423 | + |
| 424 | + |
| 425 | +def _cloud_workspace_discovery_available(config: BasicMemoryConfig) -> bool: |
| 426 | + """Return True when workspace discovery can be used without forcing local routing.""" |
| 427 | + from basic_memory.mcp.async_client import ( |
| 428 | + _explicit_routing, |
| 429 | + _force_local_mode, |
| 430 | + is_factory_mode, |
| 431 | + ) |
| 432 | + |
| 433 | + if _explicit_routing() and _force_local_mode(): |
| 434 | + return False |
| 435 | + |
| 436 | + return ( |
| 437 | + is_factory_mode() |
| 438 | + or (_explicit_routing() and not _force_local_mode()) |
| 439 | + or has_cloud_credentials(config) |
| 440 | + ) |
| 441 | + |
| 442 | + |
| 443 | +async def resolve_workspace_qualified_memory_url( |
| 444 | + identifier: str, |
| 445 | + context: Optional[Context] = None, |
| 446 | +) -> WorkspaceMemoryUrlResolution | None: |
| 447 | + """Resolve a workspace-qualified memory URL against accessible workspaces.""" |
| 448 | + segments = _split_workspace_memory_url_segments(identifier) |
| 449 | + if segments is None: |
| 450 | + return None |
| 451 | + |
| 452 | + workspace_slug, project_identifier, remainder = segments |
| 453 | + index = await _ensure_workspace_project_index(context=context) |
| 454 | + workspace = next( |
| 455 | + (item for item in index.workspaces if item.slug.casefold() == workspace_slug.casefold()), |
| 456 | + None, |
| 457 | + ) |
| 458 | + if workspace is None: |
| 459 | + return None |
| 460 | + |
| 461 | + project_permalink = generate_permalink(project_identifier) |
| 462 | + matches = [ |
| 463 | + entry |
| 464 | + for entry in index.entries_by_permalink.get(project_permalink, ()) |
| 465 | + if entry.workspace.tenant_id == workspace.tenant_id |
| 466 | + ] |
| 467 | + if not matches: |
| 468 | + if any( |
| 469 | + failed_workspace.tenant_id == workspace.tenant_id |
| 470 | + for failed_workspace in index.failed_workspaces |
| 471 | + ): |
| 472 | + raise ValueError( |
| 473 | + f"Projects for workspace '{workspace.name}' ({workspace.slug}) " |
| 474 | + "could not be loaded. Retry after workspace discovery recovers." |
| 475 | + ) |
| 476 | + |
| 477 | + available = ", ".join( |
| 478 | + entry.qualified_name |
| 479 | + for entry in index.entries |
| 480 | + if entry.workspace.tenant_id == workspace.tenant_id |
| 481 | + ) |
| 482 | + raise ValueError( |
| 483 | + f"Project '{project_identifier}' was not found in workspace " |
| 484 | + f"'{workspace.name}' ({workspace.slug}). Available projects: {available}" |
| 485 | + ) |
| 486 | + |
| 487 | + entry = matches[0] |
| 488 | + canonical_path = f"{entry.workspace.slug}/{entry.project.permalink}/{remainder}" |
| 489 | + return WorkspaceMemoryUrlResolution(entry=entry, canonical_path=canonical_path) |
| 490 | + |
| 491 | + |
393 | 492 | def _format_qualified_choices(entries: tuple[WorkspaceProjectEntry, ...]) -> str: |
394 | 493 | """Format qualified project choices for collision errors.""" |
395 | 494 | return " or ".join(entry.qualified_name for entry in entries) |
@@ -856,13 +955,33 @@ async def resolve_project_and_path( |
856 | 955 | return active_project, identifier, False |
857 | 956 |
|
858 | 957 | normalized_path = normalize_project_reference(memory_url_path(identifier)) |
| 958 | + cached_project = await _get_cached_active_project(context) |
| 959 | + cached_workspace = await _get_cached_active_workspace(context) |
| 960 | + if cached_project and cached_workspace: |
| 961 | + workspace_prefix = generate_permalink(cached_workspace.slug) |
| 962 | + qualified_prefix = f"{workspace_prefix}/{cached_project.permalink}" |
| 963 | + if normalized_path == qualified_prefix or normalized_path.startswith( |
| 964 | + f"{qualified_prefix}/" |
| 965 | + ): |
| 966 | + return cached_project, normalized_path, True |
| 967 | + |
| 968 | + workspace_context = current_workspace_permalink_context() |
| 969 | + if workspace_context and project: |
| 970 | + workspace_prefix = generate_permalink(workspace_context.workspace_slug) |
| 971 | + project_permalink = generate_permalink(_unqualified_project_identifier(project)) |
| 972 | + qualified_prefix = f"{workspace_prefix}/{project_permalink}" |
| 973 | + if normalized_path == qualified_prefix or normalized_path.startswith( |
| 974 | + f"{qualified_prefix}/" |
| 975 | + ): |
| 976 | + active_project = await get_active_project(client, project, context, headers) |
| 977 | + return active_project, normalized_path, True |
| 978 | + |
859 | 979 | project_prefix, remainder = _split_project_prefix(normalized_path) |
860 | 980 | include_project = config.permalinks_include_project |
861 | 981 | # Trigger: memory URL begins with a potential project segment |
862 | 982 | # Why: allow project-scoped memory URLs without requiring a separate project parameter |
863 | 983 | # Outcome: attempt to resolve the prefix as a project and route to it |
864 | 984 | if project_prefix: |
865 | | - cached_project = await _get_cached_active_project(context) |
866 | 985 | if cached_project and _project_matches_identifier(cached_project, project_prefix): |
867 | 986 | resolved_project = await resolve_project_parameter(project_prefix, context=context) |
868 | 987 | if resolved_project and generate_permalink(resolved_project) != generate_permalink( |
@@ -975,6 +1094,26 @@ def detect_project_from_url_prefix(identifier: str, config: BasicMemoryConfig) - |
975 | 1094 | return None |
976 | 1095 |
|
977 | 1096 |
|
| 1097 | +async def detect_project_from_memory_url_prefix( |
| 1098 | + identifier: str, |
| 1099 | + config: BasicMemoryConfig, |
| 1100 | + context: Optional[Context] = None, |
| 1101 | +) -> Optional[str]: |
| 1102 | + """Resolve a project from a memory URL prefix, including workspace-qualified URLs.""" |
| 1103 | + if not identifier.strip().startswith("memory://"): |
| 1104 | + return None |
| 1105 | + |
| 1106 | + if _cloud_workspace_discovery_available(config): |
| 1107 | + resolution = await resolve_workspace_qualified_memory_url( |
| 1108 | + identifier, |
| 1109 | + context=context, |
| 1110 | + ) |
| 1111 | + if resolution is not None: |
| 1112 | + return resolution.project_identifier |
| 1113 | + |
| 1114 | + return detect_project_from_url_prefix(identifier, config) |
| 1115 | + |
| 1116 | + |
978 | 1117 | @asynccontextmanager |
979 | 1118 | async def get_project_client( |
980 | 1119 | project: Optional[str] = None, |
@@ -1113,12 +1252,18 @@ async def get_project_client( |
1113 | 1252 | workspace_id=workspace_id, |
1114 | 1253 | ): |
1115 | 1254 | logger.debug("Using resolved workspace for cloud project routing") |
1116 | | - async with get_client( |
1117 | | - project_name=project_for_api, |
1118 | | - workspace=workspace_id, |
1119 | | - ) as client: |
1120 | | - active_project = await get_active_project(client, project_for_api, context) |
1121 | | - yield client, active_project |
| 1255 | + permalink_context = ( |
| 1256 | + workspace_permalink_context(active_ws.slug, active_ws.workspace_type) |
| 1257 | + if active_ws is not None |
| 1258 | + else nullcontext() |
| 1259 | + ) |
| 1260 | + with permalink_context: |
| 1261 | + async with get_client( |
| 1262 | + project_name=project_for_api, |
| 1263 | + workspace=workspace_id, |
| 1264 | + ) as client: |
| 1265 | + active_project = await get_active_project(client, project_for_api, context) |
| 1266 | + yield client, active_project |
1122 | 1267 | return |
1123 | 1268 |
|
1124 | 1269 | # Step 4: Local routing (default) |
|
0 commit comments