mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
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:
@@ -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
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user