diff --git a/features/resource_cli.feature b/features/resource_cli.feature index 62467ddc..9baafbd9 100644 --- a/features/resource_cli.feature +++ b/features/resource_cli.feature @@ -8,12 +8,7 @@ Feature: Resource CLI commands # ---- Resource Type List ---- - Scenario: List resource types when empty - When I run resource type list - Then the resource output should contain "No resource types registered" - - Scenario: List resource types after bootstrap - Given built-in types are bootstrapped + Scenario: List resource types shows built-in types after init When I run resource type list Then the resource output should contain "git-checkout" And the resource output should contain "fs-directory" diff --git a/features/steps/plan_commands_new_coverage_steps.py b/features/steps/plan_commands_new_coverage_steps.py index 6688ff8c..935f8ee6 100644 --- a/features/steps/plan_commands_new_coverage_steps.py +++ b/features/steps/plan_commands_new_coverage_steps.py @@ -170,23 +170,40 @@ use_step_matcher("parse") @when("new_cov I call _get_apply_service directly") def step_new_cov_call_get_apply(context: Context) -> None: - """Call the real _get_apply_service, mocking only its dependencies. + """Call the real ``_get_apply_service``, mocking its dependencies. This exercises the actual function body (import, lifecycle lookup, - PlanApplyService construction) to cover lines 1817, 1821, 1822. + ``PlanApplyService`` construction) to cover lines 1817, 1821, 1822. + + Both ``_get_lifecycle_service`` **and** ``PlanApplyService`` are + patched. The class is patched at two locations — the canonical + source module and the plan module (with ``create=True``) — so the + lazy ``from … import PlanApplyService`` inside the function always + resolves to our mock, even under ``behave-parallel``'s + ``fork()``-based workers. """ from cleveragents.cli.commands.plan import _get_apply_service + mock_lifecycle = MagicMock() + mock_pas_cls = MagicMock() mock_pas_instance = MagicMock() - mock_pas_class = MagicMock(return_value=mock_pas_instance) + mock_pas_cls.return_value = mock_pas_instance with ( - patch(_PATCH_LIFECYCLE, return_value=context.new_cov_mock_lifecycle), - patch(_PATCH_PAS_CLASS, mock_pas_class), + patch(_PATCH_LIFECYCLE, return_value=mock_lifecycle), + patch( + "cleveragents.cli.commands.plan.PlanApplyService", + mock_pas_cls, + create=True, + ), + patch(_PATCH_PAS_CLASS, mock_pas_cls), ): - context.new_cov_apply_result = _get_apply_service() - context.new_cov_pas_class = mock_pas_class - context.new_cov_pas_instance = mock_pas_instance + result = _get_apply_service() + + context.new_cov_apply_result = result + context.new_cov_mock_lifecycle_used = mock_lifecycle + context.new_cov_mock_pas_cls = mock_pas_cls + context.new_cov_mock_pas_instance = mock_pas_instance # --------------------------------------------------------------------------- @@ -218,13 +235,16 @@ def step_new_cov_output_contains(context: Context, text: str) -> None: @then("new_cov the returned object should be a PlanApplyService instance") def step_new_cov_result_is_pas(context: Context) -> None: - assert context.new_cov_apply_result is context.new_cov_pas_instance, ( - f"Expected the mock PAS instance, got {type(context.new_cov_apply_result).__name__}" + result = context.new_cov_apply_result + expected = context.new_cov_mock_pas_instance + assert result is expected, ( + f"Expected _get_apply_service to return the mock PlanApplyService " + f"instance, got {type(result).__name__}" ) @then("new_cov PlanApplyService was constructed with the lifecycle service") def step_new_cov_pas_called_with_lifecycle(context: Context) -> None: - context.new_cov_pas_class.assert_called_once_with( - lifecycle_service=context.new_cov_mock_lifecycle, + context.new_cov_mock_pas_cls.assert_called_once_with( + lifecycle_service=context.new_cov_mock_lifecycle_used, ) diff --git a/features/steps/resource_project_services_steps.py b/features/steps/resource_project_services_steps.py index a5d303e4..f493ee1f 100644 --- a/features/steps/resource_project_services_steps.py +++ b/features/steps/resource_project_services_steps.py @@ -111,7 +111,14 @@ def step_init_resource_svc_db(context: Any) -> None: def step_bootstrap_builtins(context: Any) -> None: result = context.resource_service.bootstrap_builtin_types() context.last_bootstrap_count = len(result) - context.bootstrap_names = result + # Types may have been auto-bootstrapped in __init__; fall back to + # querying the database so assertion steps see all registered types. + if result: + context.bootstrap_names = result + else: + context.bootstrap_names = [ + t.name for t in context.resource_service.list_types() + ] @then('the bootstrap should register "{name}"') diff --git a/features/steps/resource_type_bootstrap_git_steps.py b/features/steps/resource_type_bootstrap_git_steps.py index b57ee791..ee36d695 100644 --- a/features/steps/resource_type_bootstrap_git_steps.py +++ b/features/steps/resource_type_bootstrap_git_steps.py @@ -37,15 +37,20 @@ _PATCH_CONSOLE = "cleveragents.cli.commands.resource.console" def _make_service(context: Context, *, run_bootstrap: bool) -> ResourceRegistryService: """Create an in-memory ResourceRegistryService. - When *run_bootstrap* is ``True`` the built-in types are seeded; when - ``False`` the registry is left empty (reproducing the bug). + Built-in types are now auto-bootstrapped in ``__init__``. When + *run_bootstrap* is ``True`` the step records the list of registered + type names for assertion steps; when ``False`` the service is still + created (and auto-bootstrapped) but no list is recorded. """ engine = create_engine("sqlite:///:memory:", echo=False) Base.metadata.create_all(engine) factory = sessionmaker(bind=engine, expire_on_commit=False) service = ResourceRegistryService(session_factory=factory) if run_bootstrap: - context.bootstrap_git_registered = service.bootstrap_builtin_types() # type: ignore[attr-defined] + # Types are auto-bootstrapped in __init__; record them for assertions. + context.bootstrap_git_registered = [ # type: ignore[attr-defined] + t.name for t in service.list_types() + ] context.bootstrap_git_service = service # type: ignore[attr-defined] return service diff --git a/robot/resource_project_services.robot b/robot/resource_project_services.robot index 66d591a0..6ab906aa 100644 --- a/robot/resource_project_services.robot +++ b/robot/resource_project_services.robot @@ -26,9 +26,9 @@ Resource Registry Bootstrap Creates Built-in Types ... Base.metadata.create_all(engine) ... sf = sessionmaker(bind=engine) ... svc = ResourceRegistryService(session_factory=sf) - ... result = svc.bootstrap_builtin_types() - ... assert "git-checkout" in result - ... assert "fs-directory" in result + ... types = [t.name for t in svc.list_types()] + ... assert "git-checkout" in types, f"git-checkout not in {types}" + ... assert "fs-directory" in types, f"fs-directory not in {types}" ... print("PASS") ${result}= Run Process ${PYTHON} -c ${script} env:PYTHONPATH=src Should Be Equal As Integers ${result.rc} 0 diff --git a/robot/resource_type_bootstrap_fs.robot b/robot/resource_type_bootstrap_fs.robot index 826f8811..a847a78a 100644 --- a/robot/resource_type_bootstrap_fs.robot +++ b/robot/resource_type_bootstrap_fs.robot @@ -23,7 +23,7 @@ Bootstrap Seeds Fs Directory Type Into Registry ... Base.metadata.create_all(engine) ... factory = sessionmaker(bind=engine, expire_on_commit=False) ... service = ResourceRegistryService(session_factory=factory) - ... registered = service.bootstrap_builtin_types() + ... registered = [t.name for t in service.list_types()] ... assert "fs-directory" in registered, f"fs-directory not in registered: {registered}" ... spec = service.show_type("fs-directory") ... assert spec.name == "fs-directory", f"name mismatch: {spec.name}" diff --git a/robot/resource_type_bootstrap_git.robot b/robot/resource_type_bootstrap_git.robot index 15b49ede..6ea9a2b0 100644 --- a/robot/resource_type_bootstrap_git.robot +++ b/robot/resource_type_bootstrap_git.robot @@ -51,7 +51,7 @@ Git Checkout Type Exists After Bootstrap ... Base.metadata.create_all(engine) ... factory = sessionmaker(bind=engine, expire_on_commit=False) ... service = ResourceRegistryService(session_factory=factory) - ... registered = service.bootstrap_builtin_types() + ... registered = [t.name for t in service.list_types()] ... assert "git-checkout" in registered, f"git-checkout not in registered: {registered}" ... spec = service.show_type("git-checkout") ... assert spec.name == "git-checkout", f"name mismatch: {spec.name}" diff --git a/src/cleveragents/application/services/resource_registry_service.py b/src/cleveragents/application/services/resource_registry_service.py index cc602ccd..db20e760 100644 --- a/src/cleveragents/application/services/resource_registry_service.py +++ b/src/cleveragents/application/services/resource_registry_service.py @@ -229,10 +229,27 @@ class ResourceRegistryService: ) -> None: """Initialize the resource registry service. + Automatically seeds built-in resource types (``fs-directory``, + ``git-checkout``, etc.) on construction so they are available + immediately after ``agents init``. The bootstrap is idempotent + and gracefully skipped when the database tables do not yet exist + (e.g. before migrations have run). + Args: session_factory: Callable returning a SQLAlchemy Session. """ self._session_factory = session_factory + try: + self.bootstrap_builtin_types() + except Exception: + # Tables may not exist yet (pre-migration) or the session may + # be a test mock. Callers that need built-in types before + # migrations complete should invoke bootstrap_builtin_types() + # explicitly after the schema is ready. + logger.debug( + "Auto-bootstrap of built-in resource types deferred " + "(tables may not exist yet)" + ) def _session(self) -> Any: """Convenience helper to obtain a session."""