|
| 1 | +"""Doctor command for local consistency checks.""" |
| 2 | + |
| 3 | +from __future__ import annotations |
| 4 | + |
| 5 | +import tempfile |
| 6 | +import uuid |
| 7 | +from pathlib import Path |
| 8 | + |
| 9 | +from loguru import logger |
| 10 | +from mcp.server.fastmcp.exceptions import ToolError |
| 11 | +from rich.console import Console |
| 12 | +import typer |
| 13 | + |
| 14 | +from basic_memory.cli.app import app |
| 15 | +from basic_memory.cli.commands.command_utils import run_with_cleanup |
| 16 | +from basic_memory.cli.commands.routing import force_routing, validate_routing_flags |
| 17 | +from basic_memory.markdown.entity_parser import EntityParser |
| 18 | +from basic_memory.markdown.markdown_processor import MarkdownProcessor |
| 19 | +from basic_memory.markdown.schemas import EntityFrontmatter, EntityMarkdown |
| 20 | +from basic_memory.mcp.async_client import get_client |
| 21 | +from basic_memory.mcp.clients import KnowledgeClient, ProjectClient, SearchClient |
| 22 | +from basic_memory.mcp.tools.utils import call_post |
| 23 | +from basic_memory.schemas.base import Entity |
| 24 | +from basic_memory.schemas.project_info import ProjectInfoRequest |
| 25 | +from basic_memory.schemas.search import SearchQuery |
| 26 | +from basic_memory.schemas import SyncReportResponse |
| 27 | + |
| 28 | +console = Console() |
| 29 | + |
| 30 | + |
| 31 | +async def run_doctor() -> None: |
| 32 | + """Run local consistency checks for file <-> database flows.""" |
| 33 | + console.print("[blue]Running Basic Memory doctor checks...[/blue]") |
| 34 | + |
| 35 | + project_name = f"doctor-{uuid.uuid4().hex[:8]}" |
| 36 | + api_note_title = "Doctor API Note" |
| 37 | + manual_note_title = "Doctor Manual Note" |
| 38 | + manual_permalink = "doctor/manual-note" |
| 39 | + |
| 40 | + with tempfile.TemporaryDirectory() as temp_dir: |
| 41 | + temp_path = Path(temp_dir) |
| 42 | + |
| 43 | + async with get_client() as client: |
| 44 | + project_client = ProjectClient(client) |
| 45 | + project_request = ProjectInfoRequest( |
| 46 | + name=project_name, |
| 47 | + path=str(temp_path), |
| 48 | + set_default=False, |
| 49 | + ) |
| 50 | + |
| 51 | + project_id: str | None = None |
| 52 | + |
| 53 | + try: |
| 54 | + status = await project_client.create_project(project_request.model_dump()) |
| 55 | + if not status.new_project: |
| 56 | + raise ValueError("Failed to create doctor project") |
| 57 | + project_id = status.new_project.external_id |
| 58 | + console.print(f"[green]OK[/green] Created doctor project: {project_name}") |
| 59 | + |
| 60 | + # --- DB -> File: create an entity via API --- |
| 61 | + knowledge_client = KnowledgeClient(client, project_id) |
| 62 | + api_note = Entity( |
| 63 | + title=api_note_title, |
| 64 | + directory="doctor", |
| 65 | + entity_type="note", |
| 66 | + content_type="text/markdown", |
| 67 | + content=f"# {api_note_title}\n\n- [note] API to file check", |
| 68 | + entity_metadata={"tags": ["doctor"]}, |
| 69 | + ) |
| 70 | + api_result = await knowledge_client.create_entity(api_note.model_dump(), fast=False) |
| 71 | + |
| 72 | + api_file = temp_path / api_result.file_path |
| 73 | + if not api_file.exists(): |
| 74 | + raise ValueError(f"API note file missing: {api_result.file_path}") |
| 75 | + |
| 76 | + api_text = api_file.read_text(encoding="utf-8") |
| 77 | + if api_note_title not in api_text: |
| 78 | + raise ValueError("API note content missing from file") |
| 79 | + |
| 80 | + console.print("[green]OK[/green] API write created file") |
| 81 | + |
| 82 | + # --- File -> DB: write markdown file directly, then sync --- |
| 83 | + parser = EntityParser(temp_path) |
| 84 | + processor = MarkdownProcessor(parser) |
| 85 | + manual_markdown = EntityMarkdown( |
| 86 | + frontmatter=EntityFrontmatter( |
| 87 | + metadata={ |
| 88 | + "title": manual_note_title, |
| 89 | + "type": "note", |
| 90 | + "permalink": manual_permalink, |
| 91 | + "tags": ["doctor"], |
| 92 | + } |
| 93 | + ), |
| 94 | + content=f"# {manual_note_title}\n\n- [note] File to DB check", |
| 95 | + ) |
| 96 | + |
| 97 | + manual_path = temp_path / "doctor" / "manual-note.md" |
| 98 | + await processor.write_file(manual_path, manual_markdown) |
| 99 | + console.print("[green]OK[/green] Manual file written") |
| 100 | + |
| 101 | + sync_response = await call_post( |
| 102 | + client, |
| 103 | + f"/v2/projects/{project_id}/sync?force_full=true&run_in_background=false", |
| 104 | + ) |
| 105 | + sync_report = SyncReportResponse.model_validate(sync_response.json()) |
| 106 | + if sync_report.total == 0: |
| 107 | + raise ValueError("Sync did not detect any changes") |
| 108 | + |
| 109 | + console.print("[green]OK[/green] Sync indexed manual file") |
| 110 | + |
| 111 | + search_client = SearchClient(client, project_id) |
| 112 | + search_query = SearchQuery(title=manual_note_title) |
| 113 | + search_results = await search_client.search( |
| 114 | + search_query.model_dump(), page=1, page_size=5 |
| 115 | + ) |
| 116 | + if not any(result.title == manual_note_title for result in search_results.results): |
| 117 | + raise ValueError("Manual note not found in search index") |
| 118 | + |
| 119 | + console.print("[green]OK[/green] Search confirmed manual file") |
| 120 | + |
| 121 | + status_response = await call_post(client, f"/v2/projects/{project_id}/status") |
| 122 | + status_report = SyncReportResponse.model_validate(status_response.json()) |
| 123 | + if status_report.total != 0: |
| 124 | + raise ValueError("Project status not clean after sync") |
| 125 | + |
| 126 | + console.print("[green]OK[/green] Status clean after sync") |
| 127 | + |
| 128 | + finally: |
| 129 | + if project_id: |
| 130 | + await project_client.delete_project(project_id) |
| 131 | + |
| 132 | + console.print("[green]Doctor checks passed.[/green]") |
| 133 | + |
| 134 | + |
| 135 | +@app.command() |
| 136 | +def doctor( |
| 137 | + local: bool = typer.Option( |
| 138 | + False, "--local", help="Force local API routing (ignore cloud mode)" |
| 139 | + ), |
| 140 | + cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"), |
| 141 | +) -> None: |
| 142 | + """Run local consistency checks to verify file/database sync.""" |
| 143 | + try: |
| 144 | + validate_routing_flags(local, cloud) |
| 145 | + with force_routing(local=local, cloud=cloud): |
| 146 | + run_with_cleanup(run_doctor()) |
| 147 | + except (ToolError, ValueError) as e: |
| 148 | + console.print(f"[red]Doctor failed: {e}[/red]") |
| 149 | + raise typer.Exit(code=1) |
| 150 | + except Exception as e: |
| 151 | + logger.error(f"Doctor failed: {e}") |
| 152 | + typer.echo(f"Doctor failed: {e}", err=True) |
| 153 | + raise typer.Exit(code=1) # pragma: no cover |
0 commit comments