From 19a50b187b49123da70ebe2599482083f589044b Mon Sep 17 00:00:00 2001 From: phernandez Date: Thu, 9 Apr 2026 21:20:20 -0500 Subject: [PATCH 1/3] fix(cli): show cloud index freshness in project info Signed-off-by: phernandez --- src/basic_memory/cli/commands/project.py | 205 ++++++- src/basic_memory/schemas/cloud.py | 50 ++ tests/cli/test_project_info_cloud_status.py | 601 ++++++++++++++++++++ 3 files changed, 852 insertions(+), 4 deletions(-) create mode 100644 tests/cli/test_project_info_cloud_status.py diff --git a/src/basic_memory/cli/commands/project.py b/src/basic_memory/cli/commands/project.py index cee14d59..ad4129ba 100644 --- a/src/basic_memory/cli/commands/project.py +++ b/src/basic_memory/cli/commands/project.py @@ -14,6 +14,7 @@ from basic_memory.cli.app import app from basic_memory.cli.auth import CLIAuth +from basic_memory.cli.commands.cloud.api_client import CloudAPIError, make_api_request from basic_memory.cli.commands.cloud.bisync_commands import get_mount_info from basic_memory.cli.commands.cloud.project_sync import ( _has_cloud_credentials, @@ -26,9 +27,13 @@ from basic_memory.cli.commands.command_utils import get_project_info, run_with_cleanup from basic_memory.cli.commands.routing import force_routing, validate_routing_flags from basic_memory.config import ConfigManager, ProjectEntry, ProjectMode -from basic_memory.mcp.async_client import get_client +from basic_memory.mcp.async_client import get_client, resolve_configured_workspace from basic_memory.mcp.clients import ProjectClient -from basic_memory.schemas.cloud import ProjectVisibility +from basic_memory.schemas.cloud import ( + CloudProjectIndexStatus, + CloudTenantIndexStatusResponse, + ProjectVisibility, +) from basic_memory.schemas.project_info import ProjectItem, ProjectList from basic_memory.utils import generate_permalink, normalize_project_path @@ -58,6 +63,181 @@ def make_bar(value: int, max_value: int, width: int = 40) -> Text: return bar +def _uses_cloud_project_info_route(project_name: str, *, local: bool, cloud: bool) -> bool: + """Return whether project info should attempt cloud augmentation.""" + if local: + return False + if cloud: + return True + + config_manager = ConfigManager() + resolved_name, _ = config_manager.get_project(project_name) + effective_name = resolved_name or project_name + return config_manager.config.get_project_mode(effective_name) == ProjectMode.CLOUD + + +def _resolve_cloud_status_workspace_id(project_name: str) -> str: + """Resolve the tenant/workspace for cloud index status lookup.""" + config_manager = ConfigManager() + config = config_manager.config + + if not _has_cloud_credentials(config): + raise RuntimeError( + "Cloud credentials not found. Run `bm cloud api-key save ` or `bm cloud login` first." + ) + + configured_name, _ = config_manager.get_project(project_name) + effective_name = configured_name or project_name + + workspace_id = resolve_configured_workspace(config=config, project_name=effective_name) + if workspace_id is not None: + return workspace_id + + workspace_id = _resolve_workspace_id(config, None) + if workspace_id is not None: + return workspace_id + + raise RuntimeError( + f"Cloud workspace could not be resolved for project '{effective_name}'. " + "Set a project workspace with `bm project set-cloud --workspace ...` or configure a " + "default workspace with `bm cloud workspace set-default ...`." + ) + + +def _match_cloud_index_status_project( + project_name: str, projects: list[CloudProjectIndexStatus] +) -> CloudProjectIndexStatus | None: + """Match the requested project against the tenant index-status payload.""" + exact_match = next( + (project for project in projects if project.project_name == project_name), None + ) + if exact_match is not None: + return exact_match + + project_permalink = generate_permalink(project_name) + permalink_matches = [ + project + for project in projects + if generate_permalink(project.project_name) == project_permalink + ] + if len(permalink_matches) == 1: + return permalink_matches[0] + + return None + + +def _format_cloud_index_status_error(error: Exception) -> str: + """Convert cloud lookup failures into concise user-facing text.""" + if isinstance(error, CloudAPIError): + detail_message: str | None = None + detail = error.detail.get("detail") if isinstance(error.detail, dict) else None + if isinstance(detail, str): + detail_message = detail + elif isinstance(detail, dict): + if isinstance(detail.get("message"), str): + detail_message = detail["message"] + elif isinstance(detail.get("detail"), str): + detail_message = detail["detail"] + + if error.status_code and detail_message: + return f"HTTP {error.status_code}: {detail_message}" + if error.status_code: + return f"HTTP {error.status_code}" + + return str(error) + + +async def _fetch_cloud_project_index_status(project_name: str) -> CloudProjectIndexStatus: + """Fetch cloud index freshness for one project from the admin tenant endpoint.""" + workspace_id = _resolve_cloud_status_workspace_id(project_name) + host_url = ConfigManager().config.cloud_host.rstrip("/") + + try: + response = await make_api_request( + method="GET", + url=f"{host_url}/admin/tenants/{workspace_id}/index-status", + ) + except typer.Exit as exc: + if exc.exit_code not in (None, 0): + raise RuntimeError( + "Cloud credentials not found. Run `bm cloud api-key save ` or " + "`bm cloud login` first." + ) from exc + raise + + tenant_status = CloudTenantIndexStatusResponse.model_validate(response.json()) + if tenant_status.error: + raise RuntimeError(tenant_status.error) + + project_status = _match_cloud_index_status_project(project_name, tenant_status.projects) + if project_status is None: + raise RuntimeError( + f"Project '{project_name}' was not found in workspace index status " + f"for tenant '{workspace_id}'." + ) + + return project_status + + +def _load_cloud_project_index_status( + project_name: str, +) -> tuple[CloudProjectIndexStatus | None, str | None]: + """Best-effort wrapper around the cloud index freshness lookup.""" + try: + return run_with_cleanup(_fetch_cloud_project_index_status(project_name)), None + except Exception as exc: + return None, _format_cloud_index_status_error(exc) + + +def _build_cloud_index_status_section( + cloud_index_status: CloudProjectIndexStatus | None, + cloud_index_status_error: str | None, +) -> Table | None: + """Render the optional Cloud Index Status block for rich project info.""" + if cloud_index_status is None and cloud_index_status_error is None: + return None + + table = Table.grid(padding=(0, 2)) + table.add_column("property", style="cyan") + table.add_column("value", style="green") + + table.add_row("[bold]Cloud Index Status[/bold]", "") + + if cloud_index_status_error is not None: + table.add_row("[yellow]●[/yellow] Warning", f"[yellow]{cloud_index_status_error}[/yellow]") + return table + + if cloud_index_status is None: + return table + + table.add_row("Files", str(cloud_index_status.current_file_count)) + table.add_row( + "Note content", + f"{cloud_index_status.note_content_synced}/{cloud_index_status.current_file_count}", + ) + table.add_row( + "Search", + f"{cloud_index_status.total_indexed_entities}/{cloud_index_status.current_file_count}", + ) + table.add_row("Embeddable", str(cloud_index_status.embeddable_indexed_entities)) + table.add_row( + "Vectorized", + ( + f"{cloud_index_status.total_entities_with_chunks}/" + f"{cloud_index_status.embeddable_indexed_entities}" + ), + ) + + if cloud_index_status.reindex_recommended: + table.add_row("[yellow]●[/yellow] Status", "[yellow]Reindex recommended[/yellow]") + if cloud_index_status.reindex_reason: + table.add_row("Reason", f"[yellow]{cloud_index_status.reindex_reason}[/yellow]") + else: + table.add_row("[green]●[/green] Status", "[green]Up to date[/green]") + + return table + + def _normalize_project_visibility(visibility: str | None) -> ProjectVisibility: """Normalize CLI visibility input to the cloud API contract.""" if visibility is None: @@ -856,9 +1036,20 @@ def display_project_info( with force_routing(local=local, cloud=cloud): info = run_with_cleanup(get_project_info(name)) + cloud_index_status: CloudProjectIndexStatus | None = None + cloud_index_status_error: str | None = None + if _uses_cloud_project_info_route(info.project_name, local=local, cloud=cloud): + cloud_index_status, cloud_index_status_error = _load_cloud_project_index_status( + info.project_name + ) + if json_output: - # Convert to JSON and print - print(json.dumps(info.model_dump(), indent=2, default=str)) + output = info.model_dump() + output["cloud_index_status"] = ( + cloud_index_status.model_dump() if cloud_index_status is not None else None + ) + output["cloud_index_status_error"] = cloud_index_status_error + print(json.dumps(output, indent=2, default=str)) else: # --- Left column: Knowledge Graph stats --- left = Table.grid(padding=(0, 2)) @@ -916,6 +1107,10 @@ def display_project_info( columns = Table.grid(padding=(0, 4), expand=False) columns.add_row(left, right) + cloud_section = _build_cloud_index_status_section( + cloud_index_status, cloud_index_status_error + ) + # --- Note Types bar chart (top 5 by count) --- bars_section = None if info.statistics.note_types: @@ -954,6 +1149,8 @@ def display_project_info( # --- Assemble dashboard --- parts: list = [columns, ""] + if cloud_section is not None: + parts.extend([cloud_section, ""]) if bars_section: parts.extend([bars_section, ""]) parts.append(footer) diff --git a/src/basic_memory/schemas/cloud.py b/src/basic_memory/schemas/cloud.py index d1fcb831..357f9005 100644 --- a/src/basic_memory/schemas/cloud.py +++ b/src/basic_memory/schemas/cloud.py @@ -81,3 +81,53 @@ class WorkspaceListResponse(BaseModel): current_workspace_id: str | None = Field( default=None, description="Current workspace tenant ID when available" ) + + +class CloudProjectIndexStatus(BaseModel): + """Index freshness summary for one cloud project.""" + + project_name: str = Field(..., description="Project name") + project_id: int = Field(..., description="Project database identifier") + last_scan_timestamp: float | None = Field( + default=None, description="Last scan timestamp from project metadata" + ) + last_file_count: int | None = Field(default=None, description="Last observed file count") + current_file_count: int = Field(..., description="Current markdown file count") + total_entities: int = Field(..., description="Current markdown entity count") + total_note_content_rows: int = Field(..., description="Rows present in note_content") + note_content_synced: int = Field(..., description="Files fully materialized into note_content") + note_content_pending: int = Field(..., description="Pending note_content rows") + note_content_failed: int = Field(..., description="Failed note_content rows") + note_content_external_changes: int = Field( + ..., description="Rows flagged with external file changes" + ) + total_indexed_entities: int = Field(..., description="Files represented in search_index") + embedding_opt_out_entities: int = Field(..., description="Files opted out of vector embeddings") + embeddable_indexed_entities: int = Field( + ..., description="Indexed files eligible for vector embeddings" + ) + total_entities_with_chunks: int = Field(..., description="Embeddable files with vector chunks") + total_chunks: int = Field(..., description="Vector chunk row count") + total_embeddings: int = Field(..., description="Vector embedding row count") + orphaned_chunks: int = Field(..., description="Chunks missing embeddings") + vector_tables_exist: bool = Field(..., description="Whether vector tables exist") + materialization_current: bool = Field( + ..., description="Whether note content matches the current file set" + ) + search_current: bool = Field(..., description="Whether search coverage is current") + embeddings_current: bool = Field(..., description="Whether embedding coverage is current") + project_current: bool = Field(..., description="Whether all freshness checks are current") + reindex_recommended: bool = Field(..., description="Whether a reindex is recommended") + reindex_reason: str | None = Field(default=None, description="Reason a reindex is recommended") + + +class CloudTenantIndexStatusResponse(BaseModel): + """Index freshness summary for all projects in one cloud tenant.""" + + tenant_id: str = Field(..., description="Workspace tenant identifier") + fly_app_name: str = Field(..., description="Cloud tenant application identifier") + email: str | None = Field(default=None, description="Owner email when available") + projects: list[CloudProjectIndexStatus] = Field( + default_factory=list, description="Per-project freshness summaries" + ) + error: str | None = Field(default=None, description="Tenant-level lookup error") diff --git a/tests/cli/test_project_info_cloud_status.py b/tests/cli/test_project_info_cloud_status.py new file mode 100644 index 00000000..3896ce4c --- /dev/null +++ b/tests/cli/test_project_info_cloud_status.py @@ -0,0 +1,601 @@ +"""Tests for cloud index status in `bm project info`.""" + +import json +from datetime import datetime +from pathlib import Path + +import httpx +import pytest +import typer +from typer.testing import CliRunner + +from basic_memory.cli.app import app +from basic_memory.cli.commands.cloud.api_client import CloudAPIError +from basic_memory.schemas.cloud import CloudProjectIndexStatus +from basic_memory.schemas.project_info import ( + ActivityMetrics, + EmbeddingStatus, + ProjectInfoResponse, + ProjectStatistics, + SystemStatus, +) + +# Importing registers project subcommands on the shared app instance. +import basic_memory.cli.commands.project as project_cmd # noqa: F401 + + +@pytest.fixture +def runner() -> CliRunner: + return CliRunner() + + +@pytest.fixture +def write_config(tmp_path, monkeypatch): + """Write config.json under a temporary HOME and return the file path.""" + from basic_memory import config as config_module + + def _write(config_data: dict) -> Path: + config_module._CONFIG_CACHE = None + config_module._CONFIG_MTIME = None + config_module._CONFIG_SIZE = None + + config_dir = tmp_path / ".basic-memory" + config_dir.mkdir(parents=True, exist_ok=True) + config_file = config_dir / "config.json" + config_file.write_text(json.dumps(config_data, indent=2), encoding="utf-8") + monkeypatch.setenv("HOME", str(tmp_path)) + return config_file + + return _write + + +def _project_info(project_name: str = "demo") -> ProjectInfoResponse: + return ProjectInfoResponse( + project_name=project_name, + project_path=f"/tmp/{project_name}", + available_projects={ + project_name: { + "path": f"/tmp/{project_name}", + "active": True, + "id": 1, + "is_default": True, + "permalink": project_name, + } + }, + default_project=project_name, + statistics=ProjectStatistics( + total_entities=10, + total_observations=20, + total_relations=5, + total_unresolved_relations=1, + note_types={"note": 10}, + observation_categories={"fact": 20}, + relation_types={"relates_to": 5}, + most_connected_entities=[], + isolated_entities=2, + ), + activity=ActivityMetrics( + recently_created=[], + recently_updated=[], + monthly_growth={}, + ), + system=SystemStatus( + version="0.0.0-test", + database_path="/tmp/memory.db", + database_size="1.00 MB", + watch_status=None, + timestamp=datetime(2026, 4, 9, 12, 0, 0), + ), + embedding_status=EmbeddingStatus( + semantic_search_enabled=True, + embedding_provider="fastembed", + embedding_model="bge-small-en-v1.5", + total_indexed_entities=10, + total_entities_with_chunks=10, + total_chunks=30, + total_embeddings=30, + vector_tables_exist=True, + reindex_recommended=False, + reindex_reason=None, + ), + ) + + +def _cloud_index_status( + *, + project_name: str = "demo", + reindex_recommended: bool = False, + reindex_reason: str | None = None, +) -> CloudProjectIndexStatus: + return CloudProjectIndexStatus( + project_name=project_name, + project_id=1, + last_scan_timestamp=1234.5, + last_file_count=12, + current_file_count=12, + total_entities=12, + total_note_content_rows=12, + note_content_synced=11, + note_content_pending=1, + note_content_failed=0, + note_content_external_changes=0, + total_indexed_entities=10, + embedding_opt_out_entities=2, + embeddable_indexed_entities=8, + total_entities_with_chunks=7, + total_chunks=21, + total_embeddings=21, + orphaned_chunks=0, + vector_tables_exist=True, + materialization_current=False, + search_current=False, + embeddings_current=False, + project_current=not reindex_recommended, + reindex_recommended=reindex_recommended, + reindex_reason=reindex_reason, + ) + + +def test_project_info_local_output_is_unchanged(runner: CliRunner, write_config, monkeypatch): + """Local project info should not attempt cloud augmentation.""" + write_config( + { + "env": "dev", + "projects": {"demo": {"path": "/tmp/demo", "mode": "local"}}, + "default_project": "demo", + } + ) + + async def fake_get_project_info(_project_name: str) -> ProjectInfoResponse: + return _project_info() + + def fail_if_called(_project_name: str): + raise AssertionError("cloud index status should not be fetched for local projects") + + monkeypatch.setattr(project_cmd, "get_project_info", fake_get_project_info) + monkeypatch.setattr(project_cmd, "_load_cloud_project_index_status", fail_if_called) + + result = runner.invoke(app, ["project", "info", "demo"], env={"COLUMNS": "240"}) + + assert result.exit_code == 0 + assert "Knowledge Graph" in result.stdout + assert "Cloud Index Status" not in result.stdout + + +def test_project_info_cloud_output_includes_index_status( + runner: CliRunner, write_config, monkeypatch +): + """Cloud project info should render the extra Cloud Index Status block.""" + write_config( + { + "env": "dev", + "projects": { + "demo": { + "path": "/tmp/demo", + "mode": "cloud", + "workspace_id": "11111111-1111-1111-1111-111111111111", + } + }, + "default_project": "demo", + "cloud_api_key": "bmc_test_key_123", + } + ) + + async def fake_get_project_info(_project_name: str) -> ProjectInfoResponse: + return _project_info() + + def fake_load_cloud_project_index_status(_project_name: str): + return ( + _cloud_index_status( + reindex_recommended=True, + reindex_reason="Search index coverage does not match the current file count", + ), + None, + ) + + monkeypatch.setattr(project_cmd, "get_project_info", fake_get_project_info) + monkeypatch.setattr( + project_cmd, "_load_cloud_project_index_status", fake_load_cloud_project_index_status + ) + + result = runner.invoke(app, ["project", "info", "demo"], env={"COLUMNS": "240"}) + + assert result.exit_code == 0 + assert "Cloud Index Status" in result.stdout + assert "Files" in result.stdout + assert "12" in result.stdout + assert "Note content" in result.stdout + assert "11/12" in result.stdout + assert "Search" in result.stdout + assert "10/12" in result.stdout + assert "Embeddable" in result.stdout + assert "8" in result.stdout + assert "Vectorized" in result.stdout + assert "7/8" in result.stdout + assert "Reindex recommended" in result.stdout + assert "Search index coverage does not match the current file count" in result.stdout + + +def test_project_info_cloud_output_warns_when_index_lookup_fails( + runner: CliRunner, write_config, monkeypatch +): + """Cloud project info should keep rendering when the admin lookup fails.""" + write_config( + { + "env": "dev", + "projects": { + "demo": { + "path": "/tmp/demo", + "mode": "cloud", + "workspace_id": "11111111-1111-1111-1111-111111111111", + } + }, + "default_project": "demo", + "cloud_api_key": "bmc_test_key_123", + } + ) + + async def fake_get_project_info(_project_name: str) -> ProjectInfoResponse: + return _project_info() + + def fake_load_cloud_project_index_status(_project_name: str): + return None, "HTTP 503: index-status endpoint unavailable" + + monkeypatch.setattr(project_cmd, "get_project_info", fake_get_project_info) + monkeypatch.setattr( + project_cmd, "_load_cloud_project_index_status", fake_load_cloud_project_index_status + ) + + result = runner.invoke(app, ["project", "info", "demo"], env={"COLUMNS": "240"}) + + assert result.exit_code == 0 + assert "Knowledge Graph" in result.stdout + assert "Cloud Index Status" in result.stdout + assert "Warning" in result.stdout + assert "HTTP 503: index-status endpoint unavailable" in result.stdout + + +def test_project_info_json_includes_cloud_index_status( + runner: CliRunner, write_config, monkeypatch +): + """JSON output should include the matched cloud index status block.""" + write_config( + { + "env": "dev", + "projects": { + "demo": { + "path": "/tmp/demo", + "mode": "cloud", + "workspace_id": "11111111-1111-1111-1111-111111111111", + } + }, + "default_project": "demo", + "cloud_api_key": "bmc_test_key_123", + } + ) + + async def fake_get_project_info(_project_name: str) -> ProjectInfoResponse: + return _project_info() + + def fake_load_cloud_project_index_status(_project_name: str): + return _cloud_index_status(), None + + monkeypatch.setattr(project_cmd, "get_project_info", fake_get_project_info) + monkeypatch.setattr( + project_cmd, "_load_cloud_project_index_status", fake_load_cloud_project_index_status + ) + + result = runner.invoke(app, ["project", "info", "demo", "--json"]) + + assert result.exit_code == 0 + data = json.loads(result.stdout) + assert data["project_name"] == "demo" + assert data["cloud_index_status"]["project_name"] == "demo" + assert data["cloud_index_status"]["current_file_count"] == 12 + assert data["cloud_index_status"]["note_content_synced"] == 11 + assert data["cloud_index_status"]["embeddable_indexed_entities"] == 8 + assert data["cloud_index_status"]["total_entities_with_chunks"] == 7 + assert data["cloud_index_status_error"] is None + + +def test_project_info_json_includes_cloud_error_when_lookup_fails( + runner: CliRunner, write_config, monkeypatch +): + """JSON output should preserve project info when the cloud status lookup fails.""" + write_config( + { + "env": "dev", + "projects": { + "demo": { + "path": "/tmp/demo", + "mode": "cloud", + "workspace_id": "11111111-1111-1111-1111-111111111111", + } + }, + "default_project": "demo", + "cloud_api_key": "bmc_test_key_123", + } + ) + + async def fake_get_project_info(_project_name: str) -> ProjectInfoResponse: + return _project_info() + + def fake_load_cloud_project_index_status(_project_name: str): + return None, "HTTP 503: index-status endpoint unavailable" + + monkeypatch.setattr(project_cmd, "get_project_info", fake_get_project_info) + monkeypatch.setattr( + project_cmd, "_load_cloud_project_index_status", fake_load_cloud_project_index_status + ) + + result = runner.invoke(app, ["project", "info", "demo", "--json"]) + + assert result.exit_code == 0 + data = json.loads(result.stdout) + assert data["project_name"] == "demo" + assert data["cloud_index_status"] is None + assert data["cloud_index_status_error"] == "HTTP 503: index-status endpoint unavailable" + + +def test_uses_cloud_project_info_route_respects_flags_and_project_mode(write_config, monkeypatch): + """Route detection should stay local unless flags or cloud mode require augmentation.""" + write_config( + { + "env": "dev", + "projects": { + "local-demo": {"path": "/tmp/local-demo", "mode": "local"}, + "cloud-demo": {"path": "/tmp/cloud-demo", "mode": "cloud"}, + }, + "default_project": "local-demo", + "cloud_api_key": "bmc_test_key_123", + } + ) + + assert ( + project_cmd._uses_cloud_project_info_route("cloud-demo", local=False, cloud=False) is True + ) + assert ( + project_cmd._uses_cloud_project_info_route("local-demo", local=False, cloud=False) is False + ) + assert project_cmd._uses_cloud_project_info_route("local-demo", local=False, cloud=True) is True + assert ( + project_cmd._uses_cloud_project_info_route("cloud-demo", local=True, cloud=False) is False + ) + + +def test_resolve_cloud_status_workspace_id_prefers_project_workspace(write_config): + """Cloud status lookup should use the project workspace before any fallback lookup.""" + write_config( + { + "env": "dev", + "projects": { + "demo": { + "path": "/tmp/demo", + "mode": "cloud", + "workspace_id": "project-workspace", + } + }, + "default_project": "demo", + "cloud_api_key": "bmc_test_key_123", + "default_workspace": "default-workspace", + } + ) + + assert project_cmd._resolve_cloud_status_workspace_id("demo") == "project-workspace" + + +def test_resolve_cloud_status_workspace_id_uses_fallback_resolution(write_config, monkeypatch): + """Cloud status lookup should fall back to workspace discovery when config has no workspace.""" + write_config( + { + "env": "dev", + "projects": {"demo": {"path": "/tmp/demo", "mode": "cloud"}}, + "default_project": "demo", + "cloud_api_key": "bmc_test_key_123", + } + ) + + monkeypatch.setattr( + project_cmd, "_resolve_workspace_id", lambda _config, _workspace: "resolved" + ) + + assert project_cmd._resolve_cloud_status_workspace_id("demo") == "resolved" + + +def test_resolve_cloud_status_workspace_id_requires_credentials(write_config): + """Cloud status lookup should fail fast when no credentials are available.""" + write_config( + { + "env": "dev", + "projects": {"demo": {"path": "/tmp/demo", "mode": "cloud"}}, + "default_project": "demo", + } + ) + + with pytest.raises(RuntimeError, match="Cloud credentials not found"): + project_cmd._resolve_cloud_status_workspace_id("demo") + + +def test_match_cloud_index_status_project_prefers_exact_then_permalink(): + """Project matching should use exact names first, then a unique permalink match.""" + exact = _cloud_index_status(project_name="Demo Project") + permalink_match = _cloud_index_status(project_name="demo-project") + unrelated = _cloud_index_status(project_name="other") + + assert ( + project_cmd._match_cloud_index_status_project("Demo Project", [exact, unrelated]) is exact + ) + assert ( + project_cmd._match_cloud_index_status_project("Demo Project", [permalink_match, unrelated]) + is permalink_match + ) + assert ( + project_cmd._match_cloud_index_status_project( + "Demo Project", + [permalink_match, _cloud_index_status(project_name="Demo Project!!")], + ) + is None + ) + + +def test_format_cloud_index_status_error_prefers_cloud_api_detail(): + """Cloud API errors should surface the most useful available detail.""" + assert project_cmd._format_cloud_index_status_error(RuntimeError("boom")) == "boom" + assert ( + project_cmd._format_cloud_index_status_error( + CloudAPIError("fail", status_code=503, detail={"detail": "down"}) + ) + == "HTTP 503: down" + ) + assert ( + project_cmd._format_cloud_index_status_error( + CloudAPIError("fail", status_code=503, detail={"detail": {"message": "nested"}}) + ) + == "HTTP 503: nested" + ) + assert ( + project_cmd._format_cloud_index_status_error(CloudAPIError("fail", status_code=503)) + == "HTTP 503" + ) + + +@pytest.mark.asyncio +async def test_fetch_cloud_project_index_status_returns_matching_project(write_config, monkeypatch): + """Cloud index status fetch should validate the tenant payload and return the matched project.""" + write_config( + { + "env": "dev", + "projects": {"demo": {"path": "/tmp/demo", "mode": "cloud"}}, + "default_project": "demo", + "cloud_api_key": "bmc_test_key_123", + "cloud_host": "https://cloud.example.test", + } + ) + + monkeypatch.setattr( + project_cmd, + "_resolve_cloud_status_workspace_id", + lambda _project_name: "11111111-1111-1111-1111-111111111111", + ) + + async def fake_make_api_request(**kwargs): + assert kwargs["method"] == "GET" + assert ( + kwargs["url"] + == "https://cloud.example.test/admin/tenants/11111111-1111-1111-1111-111111111111/index-status" + ) + return httpx.Response( + 200, + json={ + "tenant_id": "11111111-1111-1111-1111-111111111111", + "fly_app_name": "demo-app", + "email": "demo@example.com", + "projects": [_cloud_index_status().model_dump()], + "error": None, + }, + ) + + monkeypatch.setattr(project_cmd, "make_api_request", fake_make_api_request) + + status = await project_cmd._fetch_cloud_project_index_status("demo") + + assert status.project_name == "demo" + assert status.current_file_count == 12 + + +@pytest.mark.asyncio +async def test_fetch_cloud_project_index_status_handles_exit_and_missing_project( + write_config, monkeypatch +): + """Cloud fetch should convert auth exits and fail when the project is missing.""" + write_config( + { + "env": "dev", + "projects": {"demo": {"path": "/tmp/demo", "mode": "cloud"}}, + "default_project": "demo", + "cloud_api_key": "bmc_test_key_123", + "cloud_host": "https://cloud.example.test", + } + ) + + monkeypatch.setattr(project_cmd, "_resolve_cloud_status_workspace_id", lambda _name: "tenant-1") + + async def fake_make_api_request_exit(**_kwargs): + raise typer.Exit(1) + + monkeypatch.setattr(project_cmd, "make_api_request", fake_make_api_request_exit) + + with pytest.raises(RuntimeError, match="Cloud credentials not found"): + await project_cmd._fetch_cloud_project_index_status("demo") + + async def fake_make_api_request_missing(**_kwargs): + return httpx.Response( + 200, + json={ + "tenant_id": "tenant-1", + "fly_app_name": "demo-app", + "email": "demo@example.com", + "projects": [_cloud_index_status(project_name="other").model_dump()], + "error": None, + }, + ) + + monkeypatch.setattr(project_cmd, "make_api_request", fake_make_api_request_missing) + + with pytest.raises(RuntimeError, match="was not found in workspace index status"): + await project_cmd._fetch_cloud_project_index_status("demo") + + +@pytest.mark.asyncio +async def test_fetch_cloud_project_index_status_preserves_successful_exit_and_tenant_error( + write_config, monkeypatch +): + """Only non-zero typer exits should be converted; tenant-level errors should bubble clearly.""" + write_config( + { + "env": "dev", + "projects": {"demo": {"path": "/tmp/demo", "mode": "cloud"}}, + "default_project": "demo", + "cloud_api_key": "bmc_test_key_123", + "cloud_host": "https://cloud.example.test", + } + ) + + monkeypatch.setattr(project_cmd, "_resolve_cloud_status_workspace_id", lambda _name: "tenant-1") + + async def fake_make_api_request_success_exit(**_kwargs): + raise typer.Exit(0) + + monkeypatch.setattr(project_cmd, "make_api_request", fake_make_api_request_success_exit) + + with pytest.raises(typer.Exit) as exc_info: + await project_cmd._fetch_cloud_project_index_status("demo") + assert exc_info.value.exit_code == 0 + + async def fake_make_api_request_tenant_error(**_kwargs): + return httpx.Response( + 200, + json={ + "tenant_id": "tenant-1", + "fly_app_name": "demo-app", + "email": "demo@example.com", + "projects": [], + "error": "tenant is unavailable", + }, + ) + + monkeypatch.setattr(project_cmd, "make_api_request", fake_make_api_request_tenant_error) + + with pytest.raises(RuntimeError, match="tenant is unavailable"): + await project_cmd._fetch_cloud_project_index_status("demo") + + +def test_build_cloud_index_status_section_handles_missing_status(): + """The renderer should return a safe header-only table if invariants are broken.""" + table = project_cmd._build_cloud_index_status_section(None, None) + assert table is None + + warning_table = project_cmd._build_cloud_index_status_section( + None, "HTTP 503: index-status endpoint unavailable" + ) + assert warning_table is not None From 7d4a8c68f585e90c719a41c4a000d62c606c49a0 Mon Sep 17 00:00:00 2001 From: phernandez Date: Thu, 9 Apr 2026 21:51:26 -0500 Subject: [PATCH 2/3] fix(cli): avoid nested event loop in cloud status lookup Signed-off-by: phernandez --- src/basic_memory/cli/commands/project.py | 32 +++++++++++- tests/cli/test_project_info_cloud_status.py | 57 ++++++++++++++++++--- 2 files changed, 82 insertions(+), 7 deletions(-) diff --git a/src/basic_memory/cli/commands/project.py b/src/basic_memory/cli/commands/project.py index ad4129ba..753c25b1 100644 --- a/src/basic_memory/cli/commands/project.py +++ b/src/basic_memory/cli/commands/project.py @@ -104,6 +104,36 @@ def _resolve_cloud_status_workspace_id(project_name: str) -> str: ) +async def _resolve_cloud_status_workspace_id_async(project_name: str) -> str: + """Resolve the tenant/workspace for cloud index status lookup in async contexts.""" + config_manager = ConfigManager() + config = config_manager.config + + if not _has_cloud_credentials(config): + raise RuntimeError( + "Cloud credentials not found. Run `bm cloud api-key save ` or `bm cloud login` first." + ) + + configured_name, _ = config_manager.get_project(project_name) + effective_name = configured_name or project_name + + workspace_id = resolve_configured_workspace(config=config, project_name=effective_name) + if workspace_id is not None: + return workspace_id + + from basic_memory.mcp.project_context import get_available_workspaces + + workspaces = await get_available_workspaces() + if len(workspaces) == 1: + return workspaces[0].tenant_id + + raise RuntimeError( + f"Cloud workspace could not be resolved for project '{effective_name}'. " + "Set a project workspace with `bm project set-cloud --workspace ...` or configure a " + "default workspace with `bm cloud workspace set-default ...`." + ) + + def _match_cloud_index_status_project( project_name: str, projects: list[CloudProjectIndexStatus] ) -> CloudProjectIndexStatus | None: @@ -149,7 +179,7 @@ def _format_cloud_index_status_error(error: Exception) -> str: async def _fetch_cloud_project_index_status(project_name: str) -> CloudProjectIndexStatus: """Fetch cloud index freshness for one project from the admin tenant endpoint.""" - workspace_id = _resolve_cloud_status_workspace_id(project_name) + workspace_id = await _resolve_cloud_status_workspace_id_async(project_name) host_url = ConfigManager().config.cloud_host.rstrip("/") try: diff --git a/tests/cli/test_project_info_cloud_status.py b/tests/cli/test_project_info_cloud_status.py index 3896ce4c..16126b5e 100644 --- a/tests/cli/test_project_info_cloud_status.py +++ b/tests/cli/test_project_info_cloud_status.py @@ -11,7 +11,7 @@ from basic_memory.cli.app import app from basic_memory.cli.commands.cloud.api_client import CloudAPIError -from basic_memory.schemas.cloud import CloudProjectIndexStatus +from basic_memory.schemas.cloud import CloudProjectIndexStatus, WorkspaceInfo from basic_memory.schemas.project_info import ( ActivityMetrics, EmbeddingStatus, @@ -416,6 +416,40 @@ def test_resolve_cloud_status_workspace_id_requires_credentials(write_config): project_cmd._resolve_cloud_status_workspace_id("demo") +@pytest.mark.asyncio +async def test_resolve_cloud_status_workspace_id_async_auto_discovers_single_workspace( + write_config, monkeypatch +): + """Async cloud status lookup should auto-select a single available workspace.""" + write_config( + { + "env": "dev", + "projects": {"demo": {"path": "/tmp/demo", "mode": "cloud"}}, + "default_project": "demo", + "cloud_api_key": "bmc_test_key_123", + } + ) + + async def fake_get_available_workspaces(): + return [ + WorkspaceInfo( + tenant_id="11111111-1111-1111-1111-111111111111", + workspace_type="personal", + name="Personal", + role="owner", + ) + ] + + monkeypatch.setattr( + "basic_memory.mcp.project_context.get_available_workspaces", + fake_get_available_workspaces, + ) + + workspace_id = await project_cmd._resolve_cloud_status_workspace_id_async("demo") + + assert workspace_id == "11111111-1111-1111-1111-111111111111" + + def test_match_cloud_index_status_project_prefers_exact_then_permalink(): """Project matching should use exact names first, then a unique permalink match.""" exact = _cloud_index_status(project_name="Demo Project") @@ -472,10 +506,11 @@ async def test_fetch_cloud_project_index_status_returns_matching_project(write_c } ) + async def fake_resolve_workspace(_project_name: str) -> str: + return "11111111-1111-1111-1111-111111111111" + monkeypatch.setattr( - project_cmd, - "_resolve_cloud_status_workspace_id", - lambda _project_name: "11111111-1111-1111-1111-111111111111", + project_cmd, "_resolve_cloud_status_workspace_id_async", fake_resolve_workspace ) async def fake_make_api_request(**kwargs): @@ -518,7 +553,12 @@ async def test_fetch_cloud_project_index_status_handles_exit_and_missing_project } ) - monkeypatch.setattr(project_cmd, "_resolve_cloud_status_workspace_id", lambda _name: "tenant-1") + async def fake_resolve_workspace(_project_name: str) -> str: + return "tenant-1" + + monkeypatch.setattr( + project_cmd, "_resolve_cloud_status_workspace_id_async", fake_resolve_workspace + ) async def fake_make_api_request_exit(**_kwargs): raise typer.Exit(1) @@ -561,7 +601,12 @@ async def test_fetch_cloud_project_index_status_preserves_successful_exit_and_te } ) - monkeypatch.setattr(project_cmd, "_resolve_cloud_status_workspace_id", lambda _name: "tenant-1") + async def fake_resolve_workspace(_project_name: str) -> str: + return "tenant-1" + + monkeypatch.setattr( + project_cmd, "_resolve_cloud_status_workspace_id_async", fake_resolve_workspace + ) async def fake_make_api_request_success_exit(**_kwargs): raise typer.Exit(0) From da2a94a8ea19fa58b069e339a021203f4c6edd98 Mon Sep 17 00:00:00 2001 From: phernandez Date: Thu, 9 Apr 2026 21:57:32 -0500 Subject: [PATCH 3/3] test(cli): cover remaining cloud status error detail branch Signed-off-by: phernandez --- src/basic_memory/cli/commands/project.py | 2 +- tests/cli/test_project_info_cloud_status.py | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/basic_memory/cli/commands/project.py b/src/basic_memory/cli/commands/project.py index 753c25b1..c7db3abf 100644 --- a/src/basic_memory/cli/commands/project.py +++ b/src/basic_memory/cli/commands/project.py @@ -160,7 +160,7 @@ def _format_cloud_index_status_error(error: Exception) -> str: """Convert cloud lookup failures into concise user-facing text.""" if isinstance(error, CloudAPIError): detail_message: str | None = None - detail = error.detail.get("detail") if isinstance(error.detail, dict) else None + detail = error.detail.get("detail") if isinstance(detail, str): detail_message = detail elif isinstance(detail, dict): diff --git a/tests/cli/test_project_info_cloud_status.py b/tests/cli/test_project_info_cloud_status.py index 16126b5e..6a02afcf 100644 --- a/tests/cli/test_project_info_cloud_status.py +++ b/tests/cli/test_project_info_cloud_status.py @@ -487,6 +487,12 @@ def test_format_cloud_index_status_error_prefers_cloud_api_detail(): ) == "HTTP 503: nested" ) + assert ( + project_cmd._format_cloud_index_status_error( + CloudAPIError("fail", status_code=503, detail={"detail": {"detail": "nested-detail"}}) + ) + == "HTTP 503: nested-detail" + ) assert ( project_cmd._format_cloud_index_status_error(CloudAPIError("fail", status_code=503)) == "HTTP 503"