diff --git a/src/basic_memory/api/routers/project_router.py b/src/basic_memory/api/routers/project_router.py index a646faa9d..3461b3f1c 100644 --- a/src/basic_memory/api/routers/project_router.py +++ b/src/basic_memory/api/routers/project_router.py @@ -113,6 +113,7 @@ async def sync_project( force_full: bool = Query( False, description="Force full scan, bypassing watermark optimization" ), + run_in_background: bool = Query(True, description="Run in background") ): """Force project filesystem sync to database. @@ -123,21 +124,29 @@ async def sync_project( sync_service: Sync service for this project project_config: Project configuration force_full: If True, force a full scan even if watermark exists + run_in_background: If True, run sync in background and return immediately Returns: - Response confirming sync was initiated + Response confirming sync was initiated (background) or SyncReportResponse (foreground) """ - background_tasks.add_task( - sync_service.sync, project_config.home, project_config.name, force_full=force_full - ) - logger.info( - f"Filesystem sync initiated for project: {project_config.name} (force_full={force_full})" - ) + if run_in_background: + background_tasks.add_task( + sync_service.sync, project_config.home, project_config.name, force_full=force_full + ) + logger.info( + f"Filesystem sync initiated for project: {project_config.name} (force_full={force_full})" + ) - return { - "status": "sync_started", - "message": f"Filesystem sync initiated for project '{project_config.name}'", - } + return { + "status": "sync_started", + "message": f"Filesystem sync initiated for project '{project_config.name}'", + } + else: + report = await sync_service.sync(project_config.home, project_config.name, force_full=force_full) + logger.info( + f"Filesystem sync completed for project: {project_config.name} (force_full={force_full})" + ) + return SyncReportResponse.from_sync_report(report) @project_router.post("/status", response_model=SyncReportResponse) diff --git a/tests/api/test_project_router.py b/tests/api/test_project_router.py index 19498da3f..26fbbd6b2 100644 --- a/tests/api/test_project_router.py +++ b/tests/api/test_project_router.py @@ -510,6 +510,95 @@ async def test_sync_project_endpoint_not_found(client): assert response.status_code == 404 +@pytest.mark.asyncio +async def test_sync_project_endpoint_foreground(test_graph, client, project_url): + """Test the project sync endpoint with run_in_background=false returns sync report.""" + # Call the sync endpoint with run_in_background=false + response = await client.post(f"{project_url}/project/sync?run_in_background=false") + + # Verify response + assert response.status_code == 200 + data = response.json() + + # Check that we get a sync report instead of status message + assert "new" in data + assert "modified" in data + assert "deleted" in data + assert "moves" in data + assert "checksums" in data + assert "skipped_files" in data + assert "total" in data + + # Verify these are the right types + assert isinstance(data["new"], list) + assert isinstance(data["modified"], list) + assert isinstance(data["deleted"], list) + assert isinstance(data["moves"], dict) + assert isinstance(data["checksums"], dict) + assert isinstance(data["skipped_files"], list) + assert isinstance(data["total"], int) + + +@pytest.mark.asyncio +async def test_sync_project_endpoint_foreground_with_force_full(test_graph, client, project_url): + """Test the project sync endpoint with run_in_background=false and force_full=true.""" + # Call the sync endpoint with both parameters + response = await client.post( + f"{project_url}/project/sync?run_in_background=false&force_full=true" + ) + + # Verify response + assert response.status_code == 200 + data = response.json() + + # Check that we get a sync report with all expected fields + assert "new" in data + assert "modified" in data + assert "deleted" in data + assert "moves" in data + assert "checksums" in data + assert "skipped_files" in data + assert "total" in data + + +@pytest.mark.asyncio +async def test_sync_project_endpoint_foreground_with_changes( + test_graph, client, project_config, project_url, tmpdir +): + """Test foreground sync detects actual file changes.""" + # Create a new file in the project directory + import os + from pathlib import Path + + test_file = Path(project_config.home) / "new_test_file.md" + test_file.write_text("# New Test File\n\nThis is a test file for sync detection.") + + try: + # Call the sync endpoint with run_in_background=false + response = await client.post(f"{project_url}/project/sync?run_in_background=false") + + # Verify response + assert response.status_code == 200 + data = response.json() + + # The sync report should show changes (the new file we created) + assert data["total"] >= 0 # Should have at least detected changes + assert "new" in data + assert "modified" in data + assert "deleted" in data + + # At least one of these should have changes + has_changes = ( + len(data["new"]) > 0 or len(data["modified"]) > 0 or len(data["deleted"]) > 0 + ) + assert has_changes or data["total"] >= 0 # Either changes detected or empty sync is valid + + finally: + # Clean up the test file + if test_file.exists(): + os.remove(test_file) + + @pytest.mark.asyncio async def test_remove_default_project_fails(test_config, client, project_service): """Test that removing the default project returns an error."""