feat(cli): add --wait and --timeout to bm status (#906)

Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Hernandez
2026-06-07 19:58:02 -05:00
committed by GitHub
parent 4fe6fe09c8
commit efe43a10ea
3 changed files with 244 additions and 5 deletions
+70 -5
View File
@@ -1,8 +1,9 @@
"""Status command for basic-memory CLI."""
import asyncio
import json
from typing import Set, Dict
from typing import Annotated, Optional
import time
from typing import Annotated, Dict, Optional, Set
from mcp.server.fastmcp.exceptions import ToolError
import typer
@@ -142,20 +143,58 @@ def display_changes(
console.print(Panel(tree, expand=False))
class StatusTimeout(Exception):
"""Raised when --wait does not reach a synced state before the deadline."""
async def run_status(
project: Optional[str] = None,
wait: bool = False,
timeout: float = 30.0,
poll_interval: float = 0.5,
) -> tuple[str, SyncReportResponse]:
"""Fetch sync status of files vs database.
When ``wait`` is False this performs a single live disk-vs-DB scan and
returns immediately. When ``wait`` is True it polls until the project has
no pending changes (``sync_report.total == 0``) or the timeout elapses.
Returns (project_name, sync_report) for the caller to render.
Raises:
StatusTimeout: If ``wait`` is True and the deadline passes before the
project reaches a synced state.
"""
# Resolve default project so get_client() can route per-project
project = project or ConfigManager().default_project
# Reuse a single client/context across polls so we don't reconnect each loop.
async with get_client(project_name=project) as client:
project_item = await get_active_project(client, project, None)
sync_report = await ProjectClient(client).get_status(project_item.external_id)
return project_item.name, sync_report
project_client = ProjectClient(client)
# Trigger: caller did not request --wait
# Why: preserve the original single-scan behavior for the common case
# Outcome: one status scan, returned as-is
if not wait:
sync_report = await project_client.get_status(project_item.external_id)
return project_item.name, sync_report
# Trigger: --wait requested
# Why: callers (bulk imports, benchmarks, tests) need to block until the
# index has caught up instead of polling externally
# Outcome: poll get_status until total == 0 or the deadline is reached
deadline = time.monotonic() + timeout
while True:
sync_report = await project_client.get_status(project_item.external_id)
if sync_report.total == 0:
return project_item.name, sync_report
if time.monotonic() >= deadline:
raise StatusTimeout(
f"Timed out after {timeout:g}s waiting for '{project_item.name}' "
f"to finish indexing ({sync_report.total} pending change(s) remaining)."
)
await asyncio.sleep(poll_interval)
@app.command()
@@ -166,6 +205,10 @@ def status(
] = None,
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed file information"),
json_output: bool = typer.Option(False, "--json", help="Output in JSON format"),
wait: bool = typer.Option(
False, "--wait", help="Block until indexing is complete (no pending changes)"
),
timeout: float = typer.Option(30.0, "--timeout", help="Max seconds to wait when --wait is set"),
local: bool = typer.Option(
False, "--local", help="Force local API routing (ignore cloud mode)"
),
@@ -174,11 +217,21 @@ def status(
"""Show sync status between files and database.
Use --json for machine-readable output.
Use --wait to block until indexing is complete (e.g. after a bulk import);
combine with --timeout to bound the wait. On timeout the command exits 1.
Use --local to force local routing when cloud mode is enabled.
Use --cloud to force cloud routing when cloud mode is disabled.
"""
from basic_memory.cli.commands.command_utils import run_with_cleanup
# Trigger: --wait with a negative --timeout
# Why: a negative deadline times out on the very first poll, producing a confusing
# "Timed out after -5s" message instead of flagging the bad input. Raised
# before the try/except so typer renders a clean usage error (exit 2).
# Outcome: reject it up front with a clear parameter error.
if wait and timeout < 0:
raise typer.BadParameter("--timeout must be >= 0", param_hint="'--timeout'")
try:
validate_routing_flags(local, cloud)
# Trigger: no explicit routing flag provided
@@ -189,12 +242,24 @@ def status(
if not local and not cloud:
local = True
with force_routing(local=local, cloud=cloud):
project_name, sync_report = run_with_cleanup(run_status(project))
project_name, sync_report = run_with_cleanup(
run_status(project, wait=wait, timeout=timeout)
)
if json_output:
print(json.dumps(sync_report.model_dump(mode="json"), indent=2, default=str))
else:
display_changes(project_name, "Status", sync_report, verbose)
except StatusTimeout as e:
# Trigger: --wait deadline passed before the project finished indexing
# Why: callers depend on exit code 1 to detect that indexing did not
# complete in time, while still getting a clear machine/human message
# Outcome: emit the timeout message (JSON-shaped under --json) and exit 1
if json_output:
print(json.dumps({"error": str(e)}, indent=2))
else:
console.print(f"[red]Error: {e}[/red]")
raise typer.Exit(code=1)
except (ValueError, ToolError) as e:
if json_output:
print(json.dumps({"error": str(e)}, indent=2))