Skip to content

Commit 683377b

Browse files
committed
fix(cli): show cloud index freshness in project info
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent cc104f7 commit 683377b

File tree

3 files changed

+542
-4
lines changed

3 files changed

+542
-4
lines changed

src/basic_memory/cli/commands/project.py

Lines changed: 197 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
from basic_memory.cli.app import app
1616
from basic_memory.cli.auth import CLIAuth
17+
from basic_memory.cli.commands.cloud.api_client import CloudAPIError, make_api_request
1718
from basic_memory.cli.commands.cloud.bisync_commands import get_mount_info
1819
from basic_memory.cli.commands.cloud.project_sync import (
1920
_has_cloud_credentials,
@@ -26,9 +27,13 @@
2627
from basic_memory.cli.commands.command_utils import get_project_info, run_with_cleanup
2728
from basic_memory.cli.commands.routing import force_routing, validate_routing_flags
2829
from basic_memory.config import ConfigManager, ProjectEntry, ProjectMode
29-
from basic_memory.mcp.async_client import get_client
30+
from basic_memory.mcp.async_client import get_client, resolve_configured_workspace
3031
from basic_memory.mcp.clients import ProjectClient
31-
from basic_memory.schemas.cloud import ProjectVisibility
32+
from basic_memory.schemas.cloud import (
33+
CloudProjectIndexStatus,
34+
CloudTenantIndexStatusResponse,
35+
ProjectVisibility,
36+
)
3237
from basic_memory.schemas.project_info import ProjectItem, ProjectList
3338
from basic_memory.utils import generate_permalink, normalize_project_path
3439

@@ -58,6 +63,177 @@ def make_bar(value: int, max_value: int, width: int = 40) -> Text:
5863
return bar
5964

6065

66+
def _uses_cloud_project_info_route(project_name: str, *, local: bool, cloud: bool) -> bool:
67+
"""Return whether project info should attempt cloud augmentation."""
68+
if local:
69+
return False
70+
if cloud:
71+
return True
72+
73+
config_manager = ConfigManager()
74+
resolved_name, _ = config_manager.get_project(project_name)
75+
effective_name = resolved_name or project_name
76+
return config_manager.config.get_project_mode(effective_name) == ProjectMode.CLOUD
77+
78+
79+
def _resolve_cloud_status_workspace_id(project_name: str) -> str:
80+
"""Resolve the tenant/workspace for cloud index status lookup."""
81+
config_manager = ConfigManager()
82+
config = config_manager.config
83+
84+
if not _has_cloud_credentials(config):
85+
raise RuntimeError(
86+
"Cloud credentials not found. Run `bm cloud api-key save <key>` or `bm cloud login` first."
87+
)
88+
89+
configured_name, _ = config_manager.get_project(project_name)
90+
effective_name = configured_name or project_name
91+
92+
workspace_id = resolve_configured_workspace(config=config, project_name=effective_name)
93+
if workspace_id is not None:
94+
return workspace_id
95+
96+
workspace_id = _resolve_workspace_id(config, None)
97+
if workspace_id is not None:
98+
return workspace_id
99+
100+
raise RuntimeError(
101+
f"Cloud workspace could not be resolved for project '{effective_name}'. "
102+
"Set a project workspace with `bm project set-cloud --workspace ...` or configure a "
103+
"default workspace with `bm cloud workspace set-default ...`."
104+
)
105+
106+
107+
def _match_cloud_index_status_project(
108+
project_name: str, projects: list[CloudProjectIndexStatus]
109+
) -> CloudProjectIndexStatus | None:
110+
"""Match the requested project against the tenant index-status payload."""
111+
exact_match = next(
112+
(project for project in projects if project.project_name == project_name), None
113+
)
114+
if exact_match is not None:
115+
return exact_match
116+
117+
project_permalink = generate_permalink(project_name)
118+
permalink_matches = [
119+
project
120+
for project in projects
121+
if generate_permalink(project.project_name) == project_permalink
122+
]
123+
if len(permalink_matches) == 1:
124+
return permalink_matches[0]
125+
126+
return None
127+
128+
129+
def _format_cloud_index_status_error(error: Exception) -> str:
130+
"""Convert cloud lookup failures into concise user-facing text."""
131+
if isinstance(error, CloudAPIError):
132+
detail_message: str | None = None
133+
detail = error.detail.get("detail") if isinstance(error.detail, dict) else None
134+
if isinstance(detail, str):
135+
detail_message = detail
136+
elif isinstance(detail, dict):
137+
if isinstance(detail.get("message"), str):
138+
detail_message = detail["message"]
139+
elif isinstance(detail.get("detail"), str):
140+
detail_message = detail["detail"]
141+
142+
if error.status_code and detail_message:
143+
return f"HTTP {error.status_code}: {detail_message}"
144+
if error.status_code:
145+
return f"HTTP {error.status_code}"
146+
147+
return str(error)
148+
149+
150+
async def _fetch_cloud_project_index_status(project_name: str) -> CloudProjectIndexStatus:
151+
"""Fetch cloud index freshness for one project from the admin tenant endpoint."""
152+
workspace_id = _resolve_cloud_status_workspace_id(project_name)
153+
host_url = ConfigManager().config.cloud_host.rstrip("/")
154+
155+
try:
156+
response = await make_api_request(
157+
method="GET",
158+
url=f"{host_url}/admin/tenants/{workspace_id}/index-status",
159+
)
160+
except typer.Exit as exc:
161+
raise RuntimeError(
162+
"Cloud credentials not found. Run `bm cloud api-key save <key>` or `bm cloud login` first."
163+
) from exc
164+
165+
tenant_status = CloudTenantIndexStatusResponse.model_validate(response.json())
166+
if tenant_status.error:
167+
raise RuntimeError(tenant_status.error)
168+
169+
project_status = _match_cloud_index_status_project(project_name, tenant_status.projects)
170+
if project_status is None:
171+
raise RuntimeError(
172+
f"Project '{project_name}' was not found in workspace index status "
173+
f"for tenant '{workspace_id}'."
174+
)
175+
176+
return project_status
177+
178+
179+
def _load_cloud_project_index_status(
180+
project_name: str,
181+
) -> tuple[CloudProjectIndexStatus | None, str | None]:
182+
"""Best-effort wrapper around the cloud index freshness lookup."""
183+
try:
184+
return run_with_cleanup(_fetch_cloud_project_index_status(project_name)), None
185+
except Exception as exc:
186+
return None, _format_cloud_index_status_error(exc)
187+
188+
189+
def _build_cloud_index_status_section(
190+
cloud_index_status: CloudProjectIndexStatus | None,
191+
cloud_index_status_error: str | None,
192+
) -> Table | None:
193+
"""Render the optional Cloud Index Status block for rich project info."""
194+
if cloud_index_status is None and cloud_index_status_error is None:
195+
return None
196+
197+
table = Table.grid(padding=(0, 2))
198+
table.add_column("property", style="cyan")
199+
table.add_column("value", style="green")
200+
201+
table.add_row("[bold]Cloud Index Status[/bold]", "")
202+
203+
if cloud_index_status_error is not None:
204+
table.add_row("[yellow]●[/yellow] Warning", f"[yellow]{cloud_index_status_error}[/yellow]")
205+
return table
206+
207+
assert cloud_index_status is not None
208+
209+
table.add_row("Files", str(cloud_index_status.current_file_count))
210+
table.add_row(
211+
"Note content",
212+
f"{cloud_index_status.note_content_synced}/{cloud_index_status.current_file_count}",
213+
)
214+
table.add_row(
215+
"Search",
216+
f"{cloud_index_status.total_indexed_entities}/{cloud_index_status.current_file_count}",
217+
)
218+
table.add_row("Embeddable", str(cloud_index_status.embeddable_indexed_entities))
219+
table.add_row(
220+
"Vectorized",
221+
(
222+
f"{cloud_index_status.total_entities_with_chunks}/"
223+
f"{cloud_index_status.embeddable_indexed_entities}"
224+
),
225+
)
226+
227+
if cloud_index_status.reindex_recommended:
228+
table.add_row("[yellow]●[/yellow] Status", "[yellow]Reindex recommended[/yellow]")
229+
if cloud_index_status.reindex_reason:
230+
table.add_row("Reason", f"[yellow]{cloud_index_status.reindex_reason}[/yellow]")
231+
else:
232+
table.add_row("[green]●[/green] Status", "[green]Up to date[/green]")
233+
234+
return table
235+
236+
61237
def _normalize_project_visibility(visibility: str | None) -> ProjectVisibility:
62238
"""Normalize CLI visibility input to the cloud API contract."""
63239
if visibility is None:
@@ -856,9 +1032,20 @@ def display_project_info(
8561032
with force_routing(local=local, cloud=cloud):
8571033
info = run_with_cleanup(get_project_info(name))
8581034

1035+
cloud_index_status: CloudProjectIndexStatus | None = None
1036+
cloud_index_status_error: str | None = None
1037+
if _uses_cloud_project_info_route(info.project_name, local=local, cloud=cloud):
1038+
cloud_index_status, cloud_index_status_error = _load_cloud_project_index_status(
1039+
info.project_name
1040+
)
1041+
8591042
if json_output:
860-
# Convert to JSON and print
861-
print(json.dumps(info.model_dump(), indent=2, default=str))
1043+
output = info.model_dump()
1044+
output["cloud_index_status"] = (
1045+
cloud_index_status.model_dump() if cloud_index_status is not None else None
1046+
)
1047+
output["cloud_index_status_error"] = cloud_index_status_error
1048+
print(json.dumps(output, indent=2, default=str))
8621049
else:
8631050
# --- Left column: Knowledge Graph stats ---
8641051
left = Table.grid(padding=(0, 2))
@@ -916,6 +1103,10 @@ def display_project_info(
9161103
columns = Table.grid(padding=(0, 4), expand=False)
9171104
columns.add_row(left, right)
9181105

1106+
cloud_section = _build_cloud_index_status_section(
1107+
cloud_index_status, cloud_index_status_error
1108+
)
1109+
9191110
# --- Note Types bar chart (top 5 by count) ---
9201111
bars_section = None
9211112
if info.statistics.note_types:
@@ -954,6 +1145,8 @@ def display_project_info(
9541145

9551146
# --- Assemble dashboard ---
9561147
parts: list = [columns, ""]
1148+
if cloud_section is not None:
1149+
parts.extend([cloud_section, ""])
9571150
if bars_section:
9581151
parts.extend([bars_section, ""])
9591152
parts.append(footer)

src/basic_memory/schemas/cloud.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,3 +81,53 @@ class WorkspaceListResponse(BaseModel):
8181
current_workspace_id: str | None = Field(
8282
default=None, description="Current workspace tenant ID when available"
8383
)
84+
85+
86+
class CloudProjectIndexStatus(BaseModel):
87+
"""Index freshness summary for one cloud project."""
88+
89+
project_name: str = Field(..., description="Project name")
90+
project_id: int = Field(..., description="Project database identifier")
91+
last_scan_timestamp: float | None = Field(
92+
default=None, description="Last scan timestamp from project metadata"
93+
)
94+
last_file_count: int | None = Field(default=None, description="Last observed file count")
95+
current_file_count: int = Field(..., description="Current markdown file count")
96+
total_entities: int = Field(..., description="Current markdown entity count")
97+
total_note_content_rows: int = Field(..., description="Rows present in note_content")
98+
note_content_synced: int = Field(..., description="Files fully materialized into note_content")
99+
note_content_pending: int = Field(..., description="Pending note_content rows")
100+
note_content_failed: int = Field(..., description="Failed note_content rows")
101+
note_content_external_changes: int = Field(
102+
..., description="Rows flagged with external file changes"
103+
)
104+
total_indexed_entities: int = Field(..., description="Files represented in search_index")
105+
embedding_opt_out_entities: int = Field(..., description="Files opted out of vector embeddings")
106+
embeddable_indexed_entities: int = Field(
107+
..., description="Indexed files eligible for vector embeddings"
108+
)
109+
total_entities_with_chunks: int = Field(..., description="Embeddable files with vector chunks")
110+
total_chunks: int = Field(..., description="Vector chunk row count")
111+
total_embeddings: int = Field(..., description="Vector embedding row count")
112+
orphaned_chunks: int = Field(..., description="Chunks missing embeddings")
113+
vector_tables_exist: bool = Field(..., description="Whether vector tables exist")
114+
materialization_current: bool = Field(
115+
..., description="Whether note content matches the current file set"
116+
)
117+
search_current: bool = Field(..., description="Whether search coverage is current")
118+
embeddings_current: bool = Field(..., description="Whether embedding coverage is current")
119+
project_current: bool = Field(..., description="Whether all freshness checks are current")
120+
reindex_recommended: bool = Field(..., description="Whether a reindex is recommended")
121+
reindex_reason: str | None = Field(default=None, description="Reason a reindex is recommended")
122+
123+
124+
class CloudTenantIndexStatusResponse(BaseModel):
125+
"""Index freshness summary for all projects in one cloud tenant."""
126+
127+
tenant_id: str = Field(..., description="Workspace tenant identifier")
128+
fly_app_name: str = Field(..., description="Cloud tenant application identifier")
129+
email: str | None = Field(default=None, description="Owner email when available")
130+
projects: list[CloudProjectIndexStatus] = Field(
131+
default_factory=list, description="Per-project freshness summaries"
132+
)
133+
error: str | None = Field(default=None, description="Tenant-level lookup error")

0 commit comments

Comments
 (0)