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))
@@ -0,0 +1,43 @@
"""Integration test for `bm status --wait` against a real local project.
Unlike the unit tests in tests/cli/test_json_output.py (which mock get_status
to drive deterministic poll sequences), this exercises the full stack: the CLI
runs a real disk-vs-DB scan via the API/repository layer. After write-note
indexes a file, the project is already in sync, so --wait observes total == 0
on the first poll and exits 0 immediately.
"""
import json
from typer.testing import CliRunner
from basic_memory.cli.main import app as cli_app
runner = CliRunner()
def test_status_wait_returns_once_indexed(app, app_config, test_project, config_manager):
"""status --wait exits 0 with total == 0 when the project is fully indexed."""
# Write (and index) a note so the project has real content on disk + in DB.
write_result = runner.invoke(
cli_app,
[
"tool",
"write-note",
"--title",
"Wait Test Note",
"--folder",
"test-notes",
"--content",
"# Wait Test\n\nContent that should be indexed.",
],
)
assert write_result.exit_code == 0, write_result.output
# --wait should observe a synced project (total == 0) and exit immediately.
result = runner.invoke(cli_app, ["status", "--wait", "--json"])
assert result.exit_code == 0, result.output
start = result.output.index("{")
data = json.loads(result.output[start:])
assert data["total"] == 0
+131
View File
@@ -230,6 +230,137 @@ def test_status_json_with_skipped_files(mock_get_client, mock_get_active, mock_c
assert "2025-06-15" in data["skipped_files"][0]["first_failed"]
# ---------------------------------------------------------------------------
# Status --wait
#
# Real watch/sync timing is nondeterministic (filesystem events + background
# indexing), so these tests mock ProjectClient.get_status to drive deterministic
# poll sequences and patch asyncio.sleep to a no-op to avoid wall-clock waits.
# ---------------------------------------------------------------------------
@patch("basic_memory.cli.commands.status.asyncio.sleep", new_callable=AsyncMock)
@patch("basic_memory.cli.commands.status.ConfigManager")
@patch("basic_memory.cli.commands.status.get_active_project", new_callable=AsyncMock)
@patch("basic_memory.cli.commands.status.get_client")
def test_status_wait_succeeds_after_polling(
mock_get_client, mock_get_active, mock_config_cls, mock_sleep
):
"""bm status --wait polls until total == 0, then exits 0."""
mock_config_cls.return_value = _mock_config_manager()
mock_get_active.return_value = _MOCK_PROJECT_ITEM
# First poll reports pending changes, second reports a synced project.
get_status = AsyncMock(side_effect=[SYNC_REPORT_WITH_CHANGES, SYNC_REPORT_EMPTY])
@asynccontextmanager
async def fake_get_client(project_name=None):
yield MagicMock()
mock_get_client.side_effect = fake_get_client
with patch.object(ProjectClient, "get_status", get_status):
result = runner.invoke(cli_app, ["status", "--wait"])
assert result.exit_code == 0, f"CLI failed: {result.output}"
# Polled twice: pending -> empty.
assert get_status.await_count == 2
# Slept once between the two polls.
assert mock_sleep.await_count == 1
@patch("basic_memory.cli.commands.status.asyncio.sleep", new_callable=AsyncMock)
@patch("basic_memory.cli.commands.status.ConfigManager")
@patch("basic_memory.cli.commands.status.get_active_project", new_callable=AsyncMock)
@patch("basic_memory.cli.commands.status.get_client")
def test_status_wait_times_out(mock_get_client, mock_get_active, mock_config_cls, mock_sleep):
"""bm status --wait exits 1 with a timeout message when never synced."""
mock_config_cls.return_value = _mock_config_manager()
mock_get_active.return_value = _MOCK_PROJECT_ITEM
# Always pending: --wait should hit the deadline and fail.
get_status = AsyncMock(return_value=SYNC_REPORT_WITH_CHANGES)
@asynccontextmanager
async def fake_get_client(project_name=None):
yield MagicMock()
mock_get_client.side_effect = fake_get_client
# timeout=0 makes the deadline immediate: poll once, then time out.
with patch.object(ProjectClient, "get_status", get_status):
result = runner.invoke(cli_app, ["status", "--wait", "--timeout", "0"])
assert result.exit_code == 1
assert "Timed out" in result.output
def test_status_wait_negative_timeout_is_rejected():
"""A negative --timeout fails fast with a usage error instead of a confusing
'Timed out after -5s' message. The guard runs before any client I/O, no mocks needed."""
result = runner.invoke(cli_app, ["status", "--wait", "--timeout", "-5"])
assert result.exit_code != 0
# Typer colorizes the flag name with ANSI codes (so the literal "--timeout" is split),
# but the message body renders clean — assert on that.
assert "must be >= 0" in result.output
@patch("basic_memory.cli.commands.status.asyncio.sleep", new_callable=AsyncMock)
@patch("basic_memory.cli.commands.status.ConfigManager")
@patch("basic_memory.cli.commands.status.get_active_project", new_callable=AsyncMock)
@patch("basic_memory.cli.commands.status.get_client")
def test_status_wait_json_reports_total_zero(
mock_get_client, mock_get_active, mock_config_cls, mock_sleep
):
"""bm status --wait --json emits total: 0 once indexing completes."""
mock_config_cls.return_value = _mock_config_manager()
mock_get_active.return_value = _MOCK_PROJECT_ITEM
get_status = AsyncMock(side_effect=[SYNC_REPORT_WITH_CHANGES, SYNC_REPORT_EMPTY])
@asynccontextmanager
async def fake_get_client(project_name=None):
yield MagicMock()
mock_get_client.side_effect = fake_get_client
with patch.object(ProjectClient, "get_status", get_status):
result = runner.invoke(cli_app, ["status", "--wait", "--json"])
assert result.exit_code == 0, f"CLI failed: {result.output}"
data = _parse_json_output(result.output)
assert data["total"] == 0
@patch("basic_memory.cli.commands.status.asyncio.sleep", new_callable=AsyncMock)
@patch("basic_memory.cli.commands.status.ConfigManager")
@patch("basic_memory.cli.commands.status.get_active_project", new_callable=AsyncMock)
@patch("basic_memory.cli.commands.status.get_client")
def test_status_wait_json_timeout_emits_error(
mock_get_client, mock_get_active, mock_config_cls, mock_sleep
):
"""bm status --wait --json on timeout emits a JSON error and exits 1."""
mock_config_cls.return_value = _mock_config_manager()
mock_get_active.return_value = _MOCK_PROJECT_ITEM
get_status = AsyncMock(return_value=SYNC_REPORT_WITH_CHANGES)
@asynccontextmanager
async def fake_get_client(project_name=None):
yield MagicMock()
mock_get_client.side_effect = fake_get_client
with patch.object(ProjectClient, "get_status", get_status):
result = runner.invoke(cli_app, ["status", "--wait", "--timeout", "0", "--json"])
assert result.exit_code == 1
data = _parse_json_output(result.output)
assert "error" in data
assert "Timed out" in data["error"]
# ---------------------------------------------------------------------------
# Schema validate --json
# ---------------------------------------------------------------------------