fix(resource): call bootstrap_builtin_types during initialization #625

Closed
freemo wants to merge 7 commits from feature/m3-fix-resource-bootstrap into master
7 changed files with 363 additions and 12 deletions
+5
View File
@@ -7,6 +7,11 @@
called during init), and two regression tests verifying `bootstrap_builtin_types()`
seeds correct data and `agents resource add fs-directory` succeeds. Includes Robot
Framework regression tests. (#537)
- Added TDD-style Behave BDD tests for the built-in `git-checkout` resource type
bootstrap. Three scenarios: one failing TDD test reproducing bug #524 (no bootstrap
called during init), and two regression tests verifying `bootstrap_builtin_types()`
seeds correct data and `agents resource add git-checkout` succeeds. Includes Robot
Framework regression tests. (#553)
- Added general-purpose domain event system under
`cleveragents.infrastructure.events`. `EventType` StrEnum defines 38 typed
event identifiers across 9 domains (plan lifecycle, decision, invariant, actor,
+1
View File
@@ -1,4 +1,5 @@
[behave]
paths = features
tags = ~@wip
stdout_capture = no
stderr_capture = no
@@ -0,0 +1,44 @@
# These tests target bug #524 and are expected to fail until the fix is applied.
# bootstrap_builtin_types() is never called during initialization, so the
# git-checkout built-in resource type is missing from the registry at runtime.
Feature: Built-in git-checkout type bootstrap on initialization
As a CleverAgents user
I want the built-in git-checkout resource type to be available after initialization
So that I can run "agents resource add git-checkout" without "Resource type not found"
# Bug reproduction: init path does NOT call bootstrap
# This scenario is the TDD failing test. It creates a registry WITHOUT
# calling bootstrap_builtin_types() and asserts git-checkout exists.
# It will fail until the fix integrates bootstrap into the init path.
#
# NOTE FOR FIX AUTHOR: If the chosen fix is to call bootstrap inside
# init_command() / initialize_project() rather than in
# ResourceRegistryService.__init__(), you will need to update the Given
# step to exercise the init path instead of constructing a bare service.
@tdd @bug524 @wip
Scenario: git-checkout type is missing when bootstrap is not called during init
Given a bootstrap-git fresh in-memory resource registry without bootstrap
When I query the bootstrap-git resource type registry for "git-checkout"
Then the bootstrap-git resource type "git-checkout" should exist
# ── Regression: bootstrap function itself works correctly ──
@tdd @bug524
Scenario: After initialization the git-checkout type exists in the resource type registry
Given a bootstrap-git fresh in-memory resource registry with bootstrap
When I query the bootstrap-git resource type registry for "git-checkout"
Then the bootstrap-git resource type "git-checkout" should exist
And the bootstrap-git resource type "git-checkout" should have kind "physical"
And the bootstrap-git resource type "git-checkout" should have sandbox_strategy "git_worktree"
And the bootstrap-git resource type "git-checkout" should be user_addable
And the bootstrap-git registered types should include "git-checkout"
# ── CLI resource add succeeds ──────────────────────────────
@tdd @bug524
Scenario: agents resource add git-checkout succeeds without Resource type not found error
Given a bootstrap-git fresh in-memory resource registry with bootstrap
When I run bootstrap-git resource add for type "git-checkout" named "local/test" with path "/tmp/repo" and branch "main"
Then the bootstrap-git resource add command should succeed
And the bootstrap-git resource add output should not contain "Resource type not found"
@@ -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,
)
@@ -0,0 +1,211 @@
"""Step definitions for resource_type_bootstrap_git.feature.
These tests target bug #524. The first scenario (without bootstrap) is a
genuine TDD failing test: it creates a fresh registry *without* calling
``bootstrap_builtin_types()`` and asserts ``git-checkout`` exists — which
fails until the init path is fixed to call bootstrap.
The remaining scenarios verify that ``bootstrap_builtin_types()`` itself
seeds the correct data and that the CLI ``resource add`` path works once
the types are present.
"""
from __future__ import annotations
from io import StringIO
from typing import Any
from unittest.mock import patch
from behave import given, then, when # type: ignore[import-untyped]
from behave.runner import Context # type: ignore[import-untyped]
from rich.console import Console
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from cleveragents.application.services.resource_registry_service import (
ResourceRegistryService,
)
from cleveragents.core.exceptions import NotFoundError
from cleveragents.infrastructure.database.models import Base
# ── Helpers ──────────────────────────────────────────────────
_PATCH_SERVICE = "cleveragents.cli.commands.resource._get_registry_service"
_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).
"""
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]
context.bootstrap_git_service = service # type: ignore[attr-defined]
return service
def _capture_output(func: Any, *args: Any, **kwargs: Any) -> tuple[str, bool]:
"""Run a CLI function capturing its console output and success status."""
buf = StringIO()
console = Console(
file=buf, width=200, no_color=True, highlight=False, force_terminal=False
)
failed = False
failure_reason = ""
with patch(_PATCH_CONSOLE, console):
try:
func(*args, **kwargs)
except SystemExit as exc:
if exc.code not in (None, 0):
failed = True
failure_reason = f"SystemExit: {exc.code}"
except Exception as exc:
failed = True
failure_reason = f"{type(exc).__name__}: {exc}"
output = buf.getvalue()
if failure_reason:
output = output + "\n" + failure_reason
return output, failed
# ── Given steps ──────────────────────────────────────────────
@given("a bootstrap-git fresh in-memory resource registry with bootstrap")
def step_fresh_registry_with_bootstrap(context: Context) -> None:
"""Set up a fresh in-memory database and run bootstrap_builtin_types."""
_make_service(context, run_bootstrap=True)
context.bootstrap_git_output = "" # type: ignore[attr-defined]
context.bootstrap_git_failed = False # type: ignore[attr-defined]
@given("a bootstrap-git fresh in-memory resource registry without bootstrap")
def step_fresh_registry_without_bootstrap(context: Context) -> None:
"""Set up a fresh in-memory database WITHOUT running bootstrap.
This reproduces bug #524: the registry is empty because
``bootstrap_builtin_types()`` is never called during initialization.
"""
_make_service(context, run_bootstrap=False)
context.bootstrap_git_output = "" # type: ignore[attr-defined]
context.bootstrap_git_failed = False # type: ignore[attr-defined]
# ── When steps ───────────────────────────────────────────────
@when('I query the bootstrap-git resource type registry for "{name}"')
def step_query_registry_for_type(context: Context, name: str) -> None:
"""Query the registry for a specific resource type by name."""
service: ResourceRegistryService = context.bootstrap_git_service # type: ignore[attr-defined]
try:
context.bootstrap_git_type_spec = service.show_type(name) # type: ignore[attr-defined]
context.bootstrap_git_type_found = True # type: ignore[attr-defined]
except NotFoundError:
context.bootstrap_git_type_spec = None # type: ignore[attr-defined]
context.bootstrap_git_type_found = False # type: ignore[attr-defined]
@when(
'I run bootstrap-git resource add for type "{type_name}" named "{name}" with path "{path}" and branch "{branch}"'
)
def step_run_resource_add_git(
context: Context, type_name: str, name: str, path: str, branch: str
) -> None:
"""Run resource add command via the CLI function."""
from cleveragents.cli.commands.resource import resource_add
with patch(
_PATCH_SERVICE,
return_value=context.bootstrap_git_service, # type: ignore[attr-defined]
):
output, failed = _capture_output(
resource_add,
type_name=type_name,
name=name,
path=path,
branch=branch,
description=None,
image=None,
read_only=False,
fmt="rich",
)
context.bootstrap_git_output = output # type: ignore[attr-defined]
context.bootstrap_git_failed = failed # type: ignore[attr-defined]
# ── Then steps ───────────────────────────────────────────────
@then('the bootstrap-git resource type "{name}" should exist')
def step_type_should_exist(context: Context, name: str) -> None:
"""Assert the resource type was found in the registry."""
assert context.bootstrap_git_type_found is True, ( # type: ignore[attr-defined]
f"Expected resource type '{name}' to exist in the registry after "
f"bootstrap, but it was not found. This is bug #524: "
f"bootstrap_builtin_types() is never called during initialization."
)
@then('the bootstrap-git resource type "{name}" should have kind "{kind}"')
def step_type_kind(context: Context, name: str, kind: str) -> None:
"""Assert the resource type has the expected kind."""
spec = context.bootstrap_git_type_spec # type: ignore[attr-defined]
actual = spec.resource_kind.value
assert actual == kind, f"Expected kind '{kind}', got '{actual}'"
@then(
'the bootstrap-git resource type "{name}" should have sandbox_strategy "{strategy}"'
)
def step_type_sandbox(context: Context, name: str, strategy: str) -> None:
"""Assert the resource type has the expected sandbox strategy."""
spec = context.bootstrap_git_type_spec # type: ignore[attr-defined]
actual = spec.sandbox_strategy.value
assert actual == strategy, f"Expected sandbox_strategy '{strategy}', got '{actual}'"
@then('the bootstrap-git resource type "{name}" should be user_addable')
def step_type_user_addable(context: Context, name: str) -> None:
"""Assert the resource type is user-addable."""
spec = context.bootstrap_git_type_spec # type: ignore[attr-defined]
assert spec.user_addable is True, (
f"Expected user_addable=True, got {spec.user_addable}"
)
@then('the bootstrap-git registered types should include "{name}"')
def step_registered_includes(context: Context, name: str) -> None:
"""Assert the bootstrap return value includes the named type."""
registered = context.bootstrap_git_registered # type: ignore[attr-defined]
assert name in registered, (
f"Expected '{name}' in bootstrap_builtin_types() return value, "
f"got: {registered}"
)
@then("the bootstrap-git resource add command should succeed")
def step_resource_add_should_succeed(context: Context) -> None:
"""Assert the resource add command did not fail."""
assert context.bootstrap_git_failed is not True, ( # type: ignore[attr-defined]
f"Expected 'resource add' to succeed, but it failed. "
f"Output: {context.bootstrap_git_output!r}. "
f"This is bug #524: bootstrap_builtin_types() is never called."
)
@then('the bootstrap-git resource add output should not contain "{text}"')
def step_output_should_not_contain(context: Context, text: str) -> None:
"""Assert the output does not contain the given text."""
assert text not in context.bootstrap_git_output, ( # type: ignore[attr-defined]
f"Output unexpectedly contained '{text}'. "
f"Full output: {context.bootstrap_git_output!r}"
)
+58
View File
@@ -0,0 +1,58 @@
*** Settings ***
Documentation Regression tests for built-in git-checkout type bootstrap (bug #524).
... These tests verify that bootstrap_builtin_types() correctly seeds
... git-checkout into the registry and that resource registration succeeds
... after bootstrap. Both tests call bootstrap explicitly and should pass.
Library Process
Library OperatingSystem
Resource common.resource
Suite Setup Setup Test Environment
Suite Teardown Cleanup Test Environment
*** Test Cases ***
Resource Add Git Checkout Should Not Fail With Type Not Found
[Documentation] Initialize a project then run "agents resource add git-checkout"
... and verify it does not produce a "Resource type not found" error.
... bootstrap_builtin_types() is called during project init.
${proj_dir}= Set Variable ${TEMPDIR}${/}git_checkout_bootstrap_test
Create Directory ${proj_dir}
${init_result}= Run Process ${PYTHON} -m cleveragents init test-git-bootstrap
... cwd=${proj_dir} timeout=120s
Should Be Equal As Integers ${init_result.rc} 0
... msg=Project init failed: rc=${init_result.rc} stderr=${init_result.stderr}
${result}= Run Process ${PYTHON} -m cleveragents resource add
... git-checkout local/test --path /tmp/repo --branch main
... cwd=${proj_dir} timeout=60s
Should Not Contain ${result.stdout} Resource type not found
Should Not Contain ${result.stderr} Resource type not found
Should Be Equal As Integers ${result.rc} 0
... msg=Expected exit code 0 but got ${result.rc}. stdout: ${result.stdout} stderr: ${result.stderr}
[Teardown] Run Keyword And Ignore Error Remove Directory ${proj_dir} recursive=True
Git Checkout Type Exists After Bootstrap
[Documentation] Directly call bootstrap_builtin_types() and verify git-checkout
... is present in the type registry.
${script}= Catenate SEPARATOR=\n
... from sqlalchemy import create_engine
... from sqlalchemy.orm import sessionmaker
... from cleveragents.infrastructure.database.models import Base
... from cleveragents.application.services.resource_registry_service import ResourceRegistryService
... 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)
... registered = service.bootstrap_builtin_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}"
... rk = spec.resource_kind.value if hasattr(spec.resource_kind, "value") else str(spec.resource_kind)
... assert rk == "physical", f"kind mismatch: {rk}"
... ss = spec.sandbox_strategy.value if hasattr(spec.sandbox_strategy, "value") else str(spec.sandbox_strategy)
... assert ss == "git_worktree", f"sandbox mismatch: {ss}"
... assert spec.user_addable is True, f"user_addable: {spec.user_addable}"
... print("git-checkout bootstrap validated successfully")
${result}= Run Process ${PYTHON} -c ${script} timeout=60s
Should Be Equal As Integers ${result.rc} 0
... msg=Bootstrap validation failed: ${result.stderr}
Should Contain ${result.stdout} git-checkout bootstrap validated successfully
+12
View File
@@ -200,6 +200,18 @@ def init_command(
apply_default_filters=default_filters,
)
# Bootstrap built-in resource types (fs-directory, git-checkout, etc.)
# into the database after schema creation. This must happen after
# initialize_project() because the database tables are created there
# via unit_of_work.init_database(). The call is idempotent — calling
# it multiple times will not create duplicate types.
from cleveragents.application.services.resource_registry_service import (
ResourceRegistryService,
)
resource_registry: ResourceRegistryService = container.resource_registry_service()
resource_registry.bootstrap_builtin_types()
console.print(
Panel(
f"[green]✓[/green] Project '{project.name}' "