Skip to content

Commit 19a50b1

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

File tree

3 files changed

+852
-4
lines changed

3 files changed

+852
-4
lines changed

src/basic_memory/cli/commands/project.py

Lines changed: 201 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,181 @@ 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+
if exc.exit_code not in (None, 0):
162+
raise RuntimeError(
163+
"Cloud credentials not found. Run `bm cloud api-key save <key>` or "
164+
"`bm cloud login` first."
165+
) from exc
166+
raise
167+
168+
tenant_status = CloudTenantIndexStatusResponse.model_validate(response.json())
169+
if tenant_status.error:
170+
raise RuntimeError(tenant_status.error)
171+
172+
project_status = _match_cloud_index_status_project(project_name, tenant_status.projects)
173+
if project_status is None:
174+
raise RuntimeError(
175+
f"Project '{project_name}' was not found in workspace index status "
176+
f"for tenant '{workspace_id}'."
177+
)
178+
179+
return project_status
180+
181+
182+
def _load_cloud_project_index_status(
183+
project_name: str,
184+
) -> tuple[CloudProjectIndexStatus | None, str | None]:
185+
"""Best-effort wrapper around the cloud index freshness lookup."""
186+
try:
187+
return run_with_cleanup(_fetch_cloud_project_index_status(project_name)), None
188+
except Exception as exc:
189+
return None, _format_cloud_index_status_error(exc)
190+
191+
192+
def _build_cloud_index_status_section(
193+
cloud_index_status: CloudProjectIndexStatus | None,
194+
cloud_index_status_error: str | None,
195+
) -> Table | None:
196+
"""Render the optional Cloud Index Status block for rich project info."""
197+
if cloud_index_status is None and cloud_index_status_error is None:
198+
return None
199+
200+
table = Table.grid(padding=(0, 2))
201+
table.add_column("property", style="cyan")
202+
table.add_column("value", style="green")
203+
204+
table.add_row("[bold]Cloud Index Status[/bold]", "")
205+
206+
if cloud_index_status_error is not None:
207+
table.add_row("[yellow]●[/yellow] Warning", f"[yellow]{cloud_index_status_error}[/yellow]")
208+
return table
209+
210+
if cloud_index_status is None:
211+
return table
212+
213+
table.add_row("Files", str(cloud_index_status.current_file_count))
214+
table.add_row(
215+
"Note content",
216+
f"{cloud_index_status.note_content_synced}/{cloud_index_status.current_file_count}",
217+
)
218+
table.add_row(
219+
"Search",
220+
f"{cloud_index_status.total_indexed_entities}/{cloud_index_status.current_file_count}",
221+
)
222+
table.add_row("Embeddable", str(cloud_index_status.embeddable_indexed_entities))
223+
table.add_row(
224+
"Vectorized",
225+
(
226+
f"{cloud_index_status.total_entities_with_chunks}/"
227+
f"{cloud_index_status.embeddable_indexed_entities}"
228+
),
229+
)
230+
231+
if cloud_index_status.reindex_recommended:
232+
table.add_row("[yellow]●[/yellow] Status", "[yellow]Reindex recommended[/yellow]")
233+
if cloud_index_status.reindex_reason:
234+
table.add_row("Reason", f"[yellow]{cloud_index_status.reindex_reason}[/yellow]")
235+
else:
236+
table.add_row("[green]●[/green] Status", "[green]Up to date[/green]")
237+
238+
return table
239+
240+
61241
def _normalize_project_visibility(visibility: str | None) -> ProjectVisibility:
62242
"""Normalize CLI visibility input to the cloud API contract."""
63243
if visibility is None:
@@ -856,9 +1036,20 @@ def display_project_info(
8561036
with force_routing(local=local, cloud=cloud):
8571037
info = run_with_cleanup(get_project_info(name))
8581038

1039+
cloud_index_status: CloudProjectIndexStatus | None = None
1040+
cloud_index_status_error: str | None = None
1041+
if _uses_cloud_project_info_route(info.project_name, local=local, cloud=cloud):
1042+
cloud_index_status, cloud_index_status_error = _load_cloud_project_index_status(
1043+
info.project_name
1044+
)
1045+
8591046
if json_output:
860-
# Convert to JSON and print
861-
print(json.dumps(info.model_dump(), indent=2, default=str))
1047+
output = info.model_dump()
1048+
output["cloud_index_status"] = (
1049+
cloud_index_status.model_dump() if cloud_index_status is not None else None
1050+
)
1051+
output["cloud_index_status_error"] = cloud_index_status_error
1052+
print(json.dumps(output, indent=2, default=str))
8621053
else:
8631054
# --- Left column: Knowledge Graph stats ---
8641055
left = Table.grid(padding=(0, 2))
@@ -916,6 +1107,10 @@ def display_project_info(
9161107
columns = Table.grid(padding=(0, 4), expand=False)
9171108
columns.add_row(left, right)
9181109

1110+
cloud_section = _build_cloud_index_status_section(
1111+
cloud_index_status, cloud_index_status_error
1112+
)
1113+
9191114
# --- Note Types bar chart (top 5 by count) ---
9201115
bars_section = None
9211116
if info.statistics.note_types:
@@ -954,6 +1149,8 @@ def display_project_info(
9541149

9551150
# --- Assemble dashboard ---
9561151
parts: list = [columns, ""]
1152+
if cloud_section is not None:
1153+
parts.extend([cloud_section, ""])
9571154
if bars_section:
9581155
parts.extend([bars_section, ""])
9591156
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)