diff --git a/CHANGELOG.md b/CHANGELOG.md index a1eebf31..899c47c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,17 @@ and this project uses [Calendar Versioning](https://calver.org/) in the format ### Changed +- **No more cross-branch engine imports — `aipass init update` calls spawn via + subprocess (TDPLAN-0006 P3).** `init_flow.py` previously did + `from aipass.spawn.apps.modules.sync_registry import sync_registry` — the one + place aipass reached directly into spawn's Python. Replaced with a subprocess + call to the already-existing `drone @spawn sync-registry --fix` (same pattern as + `aipass init agent` → `drone @spawn create`), preserving graceful degradation + (a missing `drone`, non-zero exit, or timeout is silently skipped — registry + sync never hard-fails an update). The aipass branch now has **zero** direct + imports of another branch's engine code; the remaining cross-branch imports are + shared service layers only (cli Rich UI, prax logging, trigger events). (438 + tests, seedgo 100%.) - **`aipass.common` shared library — dedup spawn/aipass scaffold machinery (TDPLAN-0006 P2).** `@spawn` and `@aipass` each carried their own copy of the JSON merge/handler utilities and registry discovery. Extracted them into a new diff --git a/src/aipass/aipass/apps/modules/init_flow.py b/src/aipass/aipass/apps/modules/init_flow.py index 2077aac4..1e1dc744 100644 --- a/src/aipass/aipass/apps/modules/init_flow.py +++ b/src/aipass/aipass/apps/modules/init_flow.py @@ -893,12 +893,14 @@ def _handle_init_update(args: list[str]) -> int: console.print(f" ({len(current)} already up to date)") # Heal registry: prune stale entries (e.g. cross-project ../paths) try: - from aipass.spawn.apps.modules.sync_registry import sync_registry - - sync_result = sync_registry(fix=True) - pruned = sync_result.get("stale", []) - if pruned: - console.print(f" [green]Registry healed:[/green] removed {len(pruned)} stale entry(ies)") + sync_proc = subprocess.run( + ["drone", "@spawn", "sync-registry", "--fix"], + capture_output=True, + text=True, + timeout=30, + ) + if sync_proc.returncode == 0: + console.print(" [green]Registry synced.[/green]") except Exception as sync_exc: logger.warning("[init_flow] registry sync during update skipped: %s", sync_exc) diff --git a/src/aipass/aipass/tests/test_init_flow.py b/src/aipass/aipass/tests/test_init_flow.py index e0817c1c..f8c64b57 100644 --- a/src/aipass/aipass/tests/test_init_flow.py +++ b/src/aipass/aipass/tests/test_init_flow.py @@ -19,6 +19,7 @@ from aipass.aipass.apps.modules.init_flow import ( TOTAL_STAGES, _get_last_completed_stage, _get_setup_progress, + _handle_init_update, _save_stage, handle_command, print_help, @@ -574,3 +575,87 @@ class TestStages: assert result == {} stored = json.loads(tmp_local_json.read_text()) assert stored["setup_progress"]["last_completed_stage"] == 12 + + +# ============================================================================= +# init_update_registry_sync: subprocess_sync +# ============================================================================= + + +_MOD_UPDATE = "aipass.aipass.apps.modules.init_flow" + + +class TestInitUpdateRegistrySync: + """Tests for registry sync subprocess call in _handle_init_update.""" + + def test_sync_success_prints_message(self, tmp_path: Path) -> None: + """Successful drone sync-registry prints 'Registry synced.'""" + mock_result = MagicMock(returncode=0) + with ( + patch( + "aipass.aipass.apps.handlers.init.bootstrap.update_project", + return_value={"updated_files": [], "already_current": []}, + ), + patch(f"{_MOD_UPDATE}.subprocess.run", return_value=mock_result) as mock_run, + patch(f"{_MOD_UPDATE}.console") as mock_console, + patch(f"{_MOD_UPDATE}.json_handler"), + ): + rc = _handle_init_update([str(tmp_path)]) + assert rc == 0 + mock_run.assert_called_once_with( + ["drone", "@spawn", "sync-registry", "--fix"], + capture_output=True, + text=True, + timeout=30, + ) + sync_calls = [c for c in mock_console.print.call_args_list if "Registry synced" in str(c)] + assert len(sync_calls) == 1 + + def test_sync_failure_degrades_silently(self, tmp_path: Path) -> None: + """Non-zero exit from drone sync-registry is silently skipped.""" + mock_result = MagicMock(returncode=1) + with ( + patch( + "aipass.aipass.apps.handlers.init.bootstrap.update_project", + return_value={"updated_files": [], "already_current": []}, + ), + patch(f"{_MOD_UPDATE}.subprocess.run", return_value=mock_result), + patch(f"{_MOD_UPDATE}.console") as mock_console, + patch(f"{_MOD_UPDATE}.json_handler"), + ): + rc = _handle_init_update([str(tmp_path)]) + assert rc == 0 + sync_calls = [c for c in mock_console.print.call_args_list if "Registry synced" in str(c)] + assert len(sync_calls) == 0 + + def test_sync_missing_drone_degrades_silently(self, tmp_path: Path) -> None: + """FileNotFoundError (no drone binary) degrades gracefully.""" + with ( + patch( + "aipass.aipass.apps.handlers.init.bootstrap.update_project", + return_value={"updated_files": [], "already_current": []}, + ), + patch(f"{_MOD_UPDATE}.subprocess.run", side_effect=FileNotFoundError("drone not found")), + patch(f"{_MOD_UPDATE}.console") as mock_console, + patch(f"{_MOD_UPDATE}.json_handler"), + ): + rc = _handle_init_update([str(tmp_path)]) + assert rc == 0 + sync_calls = [c for c in mock_console.print.call_args_list if "Registry synced" in str(c)] + assert len(sync_calls) == 0 + + def test_sync_timeout_degrades_silently(self, tmp_path: Path) -> None: + """subprocess.TimeoutExpired degrades gracefully.""" + import subprocess as _sp + + with ( + patch( + "aipass.aipass.apps.handlers.init.bootstrap.update_project", + return_value={"updated_files": [], "already_current": []}, + ), + patch(f"{_MOD_UPDATE}.subprocess.run", side_effect=_sp.TimeoutExpired(cmd="drone", timeout=30)), + patch(f"{_MOD_UPDATE}.console"), + patch(f"{_MOD_UPDATE}.json_handler"), + ): + rc = _handle_init_update([str(tmp_path)]) + assert rc == 0