diff --git a/alembic/versions/71cd40eb661f_merge_resource_links_and_automation_.py b/alembic/versions/71cd40eb661f_merge_resource_links_and_automation_.py new file mode 100644 index 000000000..e920bbe8e --- /dev/null +++ b/alembic/versions/71cd40eb661f_merge_resource_links_and_automation_.py @@ -0,0 +1,28 @@ +"""Merge resource_links and automation_profiles branches + +Revision ID: 71cd40eb661f +Revises: a6_001_automation_profiles, b1_001_resource_links +Create Date: 2026-02-17 15:14:18.061795 + +""" + +from collections.abc import Sequence + +# revision identifiers, used by Alembic. +revision: str = "71cd40eb661f" +down_revision: str | Sequence[str] | None = ( + "a6_001_automation_profiles", + "b1_001_resource_links", +) +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + pass + + +def downgrade() -> None: + """Downgrade schema.""" + pass diff --git a/alembic/versions/a6_001_automation_profiles.py b/alembic/versions/a6_001_automation_profiles.py index 1d6ea80da..e4f4535a3 100644 --- a/alembic/versions/a6_001_automation_profiles.py +++ b/alembic/versions/a6_001_automation_profiles.py @@ -27,9 +27,7 @@ def upgrade() -> None: op.create_table( "automation_profiles", sa.Column("name", sa.Text(), nullable=False), - sa.Column( - "description", sa.Text(), nullable=True - ), + sa.Column("description", sa.Text(), nullable=True), sa.Column( "schema_version", sa.Text(), diff --git a/benchmarks/automation_profile_resolution_bench.py b/benchmarks/automation_profile_resolution_bench.py index 884240cad..83ce3d4fd 100644 --- a/benchmarks/automation_profile_resolution_bench.py +++ b/benchmarks/automation_profile_resolution_bench.py @@ -32,12 +32,8 @@ class ProfileResolutionSuite: def setup(self) -> None: """Create a service instance for benchmarks.""" - os.environ.pop( - "CLEVERAGENTS_AUTOMATION_PROFILE", None - ) - self.svc = AutomationProfileService( - repo=None, global_default="manual" - ) + os.environ.pop("CLEVERAGENTS_AUTOMATION_PROFILE", None) + self.svc = AutomationProfileService(repo=None, global_default="manual") def time_resolve_plan_level(self) -> None: """Benchmark plan-level resolution.""" diff --git a/benchmarks/binding_resolution_bench.py b/benchmarks/binding_resolution_bench.py index 6a3244602..9e6934fd8 100644 --- a/benchmarks/binding_resolution_bench.py +++ b/benchmarks/binding_resolution_bench.py @@ -98,11 +98,7 @@ class BindingResolutionContextualBench: ) resources[rid] = res alias = "repo" if i == 0 else f"res-{i}" - links.append( - LinkedResource( - resource_id=rid, alias=alias - ) - ) + links.append(LinkedResource(resource_id=rid, alias=alias)) types: dict[str, ResourceTypeSpec] = { "git-checkout": ResourceTypeSpec( @@ -135,9 +131,7 @@ class BindingResolutionContextualBench: ) self.service = BindingResolutionService(self.registry) - def time_resolve_contextual( - self, num_resources: int - ) -> None: + def time_resolve_contextual(self, num_resources: int) -> None: self.service.resolve(self.tool, self.project) @@ -171,9 +165,7 @@ class BindingResolutionStaticBench: } self.registry = _make_registry(resources, types) - self.project = NamespacedProject( - name="bench", namespace="local" - ) + self.project = NamespacedProject(name="bench", namespace="local") self.tool = Tool( name="bench/static", description="Benchmark static tool", @@ -202,9 +194,7 @@ class BindingResolutionParameterBench: def setup(self) -> None: registry = MagicMock() - self.project = NamespacedProject( - name="bench", namespace="local" - ) + self.project = NamespacedProject(name="bench", namespace="local") self.tool = Tool( name="bench/param", description="Benchmark param tool", diff --git a/benchmarks/project_context_cli_bench.py b/benchmarks/project_context_cli_bench.py index b05f80eb0..2d0d3ff93 100644 --- a/benchmarks/project_context_cli_bench.py +++ b/benchmarks/project_context_cli_bench.py @@ -8,7 +8,6 @@ Measures the cost of: from __future__ import annotations -import json import sys from pathlib import Path from typing import Any @@ -27,9 +26,7 @@ try: NamespacedProjectRepository, ) except ModuleNotFoundError: - sys.path.insert( - 0, str(Path(__file__).resolve().parents[1] / "src") - ) + sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) from cleveragents.domain.models.core.context_policy import ( ContextView, ProjectContextPolicy, @@ -57,29 +54,19 @@ class _NoCloseSession: pass def __getattr__(self, name: str) -> Any: - return getattr( - object.__getattribute__(self, "_real"), name - ) + return getattr(object.__getattribute__(self, "_real"), name) -def _make_repo_and_factory() -> ( - tuple[NamespacedProjectRepository, Any] -): - engine = create_engine( - "sqlite:///:memory:", echo=False - ) +def _make_repo_and_factory() -> tuple[NamespacedProjectRepository, Any]: + engine = create_engine("sqlite:///:memory:", echo=False) Base.metadata.create_all(engine) - sess = sessionmaker( - bind=engine, expire_on_commit=False - )() + sess = sessionmaker(bind=engine, expire_on_commit=False)() wrapper = _NoCloseSession(sess) def factory() -> Any: return wrapper - repo = NamespacedProjectRepository( - session_factory=factory - ) + repo = NamespacedProjectRepository(session_factory=factory) return repo, factory @@ -119,9 +106,7 @@ class ContextPolicyReadWriteSuite: namespace=parsed.namespace, ) self.repo.create(proj) - _write_policy( - self.sf, "local/bench-ctx", _make_policy() - ) + _write_policy(self.sf, "local/bench-ctx", _make_policy()) def time_read_policy(self) -> None: from cleveragents.cli.commands.project_context import ( @@ -135,9 +120,7 @@ class ContextPolicyReadWriteSuite: _write_policy, ) - _write_policy( - self.sf, "local/bench-ctx", _make_policy() - ) + _write_policy(self.sf, "local/bench-ctx", _make_policy()) class ContextPolicySerializeSuite: diff --git a/benchmarks/resource_dag_bench.py b/benchmarks/resource_dag_bench.py index f735cf164..ec47baed9 100644 --- a/benchmarks/resource_dag_bench.py +++ b/benchmarks/resource_dag_bench.py @@ -2,7 +2,6 @@ from __future__ import annotations -import json from datetime import UTC, datetime from typing import Any diff --git a/features/environment.py b/features/environment.py index 0ac9f0f8a..bd3af84a6 100644 --- a/features/environment.py +++ b/features/environment.py @@ -62,6 +62,27 @@ def before_scenario(context, scenario): # Ensure mock AI flag is always set so plan service tests can resolve actors os.environ["CLEVERAGENTS_TESTING_USE_MOCK_AI"] = "true" + # Re-establish protective env vars every scenario. These are set once + # in before_all, but individual scenarios (e.g. migration_runner tests) + # temporarily remove them. If any cleanup path fails to restore them + # the remaining scenarios in a serial run would be affected. + os.environ["BEHAVE_TESTING"] = "true" + os.environ["CLEVERAGENTS_AUTO_APPLY_MIGRATIONS"] = "true" + + # Flush the in-memory engine cache so no stale engines leak between + # scenarios. In serial mode (coverage_report) every feature shares + # the same process, so a previous scenario's real-or-fake engine can + # survive into the next one. + try: + from cleveragents.infrastructure.database.engine_cache import MEMORY_ENGINES + + for _url, engine in list(MEMORY_ENGINES.items()): + with contextlib.suppress(Exception): + engine.dispose() + MEMORY_ENGINES.clear() + except ImportError: + pass + # Clean up any lingering test environment variables from previous tests for env_var in [ "CLEVERAGENTS_MOCK_SHOULD_FAIL", diff --git a/features/steps/binding_resolution_steps.py b/features/steps/binding_resolution_steps.py index d8fa0c9ed..f6c0f8e53 100644 --- a/features/steps/binding_resolution_steps.py +++ b/features/steps/binding_resolution_steps.py @@ -78,9 +78,7 @@ def step_mock_registry(context: Context) -> None: registry = MagicMock() registry._resources: dict[str, Resource] = {} registry._types: dict[str, ResourceTypeSpec] = { - "git-checkout": _make_type_spec( - "git-checkout", parent_types=[] - ), + "git-checkout": _make_type_spec("git-checkout", parent_types=[]), "fs-directory": _make_type_spec( "fs-directory", parent_types=["git-checkout", "fs-directory"], @@ -153,9 +151,7 @@ def step_project_one_resource(context: Context, name: str) -> None: @given('a project "{name}" with linked resources:') -def step_project_multi_resources( - context: Context, name: str -) -> None: +def step_project_multi_resources(context: Context, name: str) -> None: parts = name.split("/", 1) namespace = parts[0] if len(parts) == 2 else "local" proj_name = parts[1] if len(parts) == 2 else parts[0] @@ -201,9 +197,7 @@ def step_project_empty(context: Context, name: str) -> None: # ------------------------------------------------------------------ -@given( - 'a resource "{res_name}" of type "{type_name}" in the registry' -) +@given('a resource "{res_name}" of type "{type_name}" in the registry') def step_resource_in_registry( context: Context, res_name: str, @@ -222,27 +216,18 @@ def step_resource_in_registry( # ------------------------------------------------------------------ -@given( - 'the resource type "{tname}" has parent_types including' - ' "{parent}"' -) -def step_type_parent( - context: Context, tname: str, parent: str -) -> None: +@given('the resource type "{tname}" has parent_types including "{parent}"') +def step_type_parent(context: Context, tname: str, parent: str) -> None: existing = context.mock_registry._types.get(tname) parents = list(existing.parent_types) if existing else [] if parent not in parents: parents.append(parent) - context.mock_registry._types[tname] = _make_type_spec( - tname, parent_types=parents - ) + context.mock_registry._types[tname] = _make_type_spec(tname, parent_types=parents) @given('the resource type "{tname}" has no parent_types') def step_type_no_parents(context: Context, tname: str) -> None: - context.mock_registry._types[tname] = _make_type_spec( - tname, parent_types=[] - ) + context.mock_registry._types[tname] = _make_type_spec(tname, parent_types=[]) # ------------------------------------------------------------------ @@ -250,10 +235,7 @@ def step_type_no_parents(context: Context, tname: str) -> None: # ------------------------------------------------------------------ -@given( - 'a tool "{tool_name}" with a contextual slot "{slot}" of type' - ' "{rtype}"' -) +@given('a tool "{tool_name}" with a contextual slot "{slot}" of type "{rtype}"') def step_tool_contextual( context: Context, tool_name: str, @@ -304,10 +286,7 @@ def step_tool_static( ) -@given( - 'a tool "{tool_name}" with a parameter slot "{slot}" of type' - ' "{rtype}"' -) +@given('a tool "{tool_name}" with a parameter slot "{slot}" of type "{rtype}"') def step_tool_parameter( context: Context, tool_name: str, @@ -336,9 +315,7 @@ def step_tool_mixed(context: Context, tool_name: str) -> None: for row in context.table: binding = BindingMode(row["binding"]) static_res: str | None = ( - row["static_resource"] - if row["static_resource"] - else None + row["static_resource"] if row["static_resource"] else None ) slots.append( ResourceSlot( @@ -389,47 +366,32 @@ def step_resolve_attempt(context: Context) -> None: # ------------------------------------------------------------------ -@then( - 'the binding for slot "{slot}" should have mode "{mode}"' -) -def step_check_mode( - context: Context, slot: str, mode: str -) -> None: +@then('the binding for slot "{slot}" should have mode "{mode}"') +def step_check_mode(context: Context, slot: str, mode: str) -> None: result = _find_result(context, slot) assert result.binding_mode == mode, ( - f"Expected mode '{mode}', " - f"got '{result.binding_mode}'" + f"Expected mode '{mode}', got '{result.binding_mode}'" ) -@then( - 'the binding for slot "{slot}" should have resource_id' - ' "{rid}"' -) -def step_check_rid( - context: Context, slot: str, rid: str -) -> None: +@then('the binding for slot "{slot}" should have resource_id "{rid}"') +def step_check_rid(context: Context, slot: str, rid: str) -> None: result = _find_result(context, slot) assert result.resource_id == rid, ( - f"Expected resource_id '{rid}', " - f"got '{result.resource_id}'" + f"Expected resource_id '{rid}', got '{result.resource_id}'" ) @then('the binding for slot "{slot}" should not be deferred') def step_not_deferred(context: Context, slot: str) -> None: result = _find_result(context, slot) - assert not result.deferred, ( - f"Slot '{slot}' should not be deferred" - ) + assert not result.deferred, f"Slot '{slot}' should not be deferred" @then('the binding for slot "{slot}" should be deferred') def step_is_deferred(context: Context, slot: str) -> None: result = _find_result(context, slot) - assert result.deferred, ( - f"Slot '{slot}' should be deferred" - ) + assert result.deferred, f"Slot '{slot}' should be deferred" @then('the binding for slot "{slot}" should have no resource_id') @@ -440,29 +402,23 @@ def step_no_rid(context: Context, slot: str) -> None: ) -@then( - 'a binding ValidationError should be raised mentioning' - ' "{text}"' -) +@then('a binding ValidationError should be raised mentioning "{text}"') def step_binding_error(context: Context, text: str) -> None: assert context.binding_error is not None, ( "Expected ValidationError but none was raised" ) assert isinstance(context.binding_error, ValidationError), ( - f"Expected ValidationError, " - f"got {type(context.binding_error).__name__}" + f"Expected ValidationError, got {type(context.binding_error).__name__}" ) assert text in str(context.binding_error), ( - f"Expected '{text}' in error, " - f"got '{context.binding_error}'" + f"Expected '{text}' in error, got '{context.binding_error}'" ) @then("there should be {count:d} binding results") def step_count_results(context: Context, count: int) -> None: assert len(context.binding_results) == count, ( - f"Expected {count} results, " - f"got {len(context.binding_results)}" + f"Expected {count} results, got {len(context.binding_results)}" ) @@ -471,12 +427,8 @@ def step_count_results(context: Context, count: int) -> None: # ------------------------------------------------------------------ -def _find_result( - context: Context, slot_name: str -) -> BindingResult: +def _find_result(context: Context, slot_name: str) -> BindingResult: for result in context.binding_results: if result.slot_name == slot_name: return result - raise AssertionError( - f"No binding result for slot '{slot_name}'" - ) + raise AssertionError(f"No binding result for slot '{slot_name}'") diff --git a/features/steps/cli_coverage_steps.py b/features/steps/cli_coverage_steps.py index 86b1fc015..fce3c228b 100644 --- a/features/steps/cli_coverage_steps.py +++ b/features/steps/cli_coverage_steps.py @@ -195,10 +195,18 @@ def step_test_cli_commands(context): ) # Test init - use temp directory to avoid conflicts + import os + + from cleveragents.application.container import reset_container + from cleveragents.config.settings import Settings + with tempfile.TemporaryDirectory() as tmpdir: orig_cwd = Path.cwd() try: - import os + # Reset container and settings so init uses the temp directory's + # database path instead of any previously-cached state + reset_container() + Settings._instance = None os.chdir(tmpdir) result = runner.invoke(app, ["init", "testproj"]) @@ -207,6 +215,9 @@ def step_test_cli_commands(context): ) finally: os.chdir(orig_cwd) + # Reset again so subsequent tests don't use temp directory state + reset_container() + Settings._instance = None @then("all CLI commands should work correctly") diff --git a/features/steps/cli_plan_context_commands_steps.py b/features/steps/cli_plan_context_commands_steps.py index 87dbdc46e..4052d6e81 100644 --- a/features/steps/cli_plan_context_commands_steps.py +++ b/features/steps/cli_plan_context_commands_steps.py @@ -14,6 +14,66 @@ from behave.runner import Context # type: ignore[import-not-found] from cleveragents.application.container import get_container, reset_container +def _run_cleveragents_inprocess(context, args): + """Run a cleveragents CLI command in-process using the ``main()`` entry point. + + This avoids the ~6 s overhead per ``subprocess.run`` call (Python startup, + module imports, Alembic migration check) by reusing the already-imported + modules in the test process. + + The function temporarily changes the working directory to ``context.test_dir`` + (if set) so that project discovery works correctly, and widens the virtual + terminal so Rich doesn't wrap output. + """ + import contextlib as _contextlib + import io + import re + + from cleveragents.cli.main import main as cli_main + + prev_cwd = os.getcwd() + prev_columns = os.environ.get("COLUMNS") + os.environ["COLUMNS"] = "300" + + if hasattr(context, "test_dir") and context.test_dir: + os.chdir(context.test_dir) + + stdout_buf = io.StringIO() + stderr_buf = io.StringIO() + + try: + with ( + _contextlib.redirect_stdout(stdout_buf), + _contextlib.redirect_stderr(stderr_buf), + ): + exit_code = cli_main(list(args)) + except SystemExit as exc: + exit_code = exc.code if exc.code is not None else 0 + finally: + os.chdir(prev_cwd) + if prev_columns is None: + os.environ.pop("COLUMNS", None) + else: + os.environ["COLUMNS"] = prev_columns + + stdout_text = stdout_buf.getvalue() + stderr_text = stderr_buf.getvalue() + + # Strip ANSI escape codes that Rich may emit even to StringIO + _ansi_re = re.compile(r"\x1b\[[0-9;]*[a-zA-Z]") + stdout_text = _ansi_re.sub("", stdout_text) + stderr_text = _ansi_re.sub("", stderr_text) + + output = stdout_text + if stderr_text: + output += stderr_text + + context.command_output = output + context.command_error = stderr_text + context.command_exit_code = exit_code if isinstance(exit_code, int) else 1 + context.exit_code = context.command_exit_code + + @given("I have a clean test environment") def step_clean_test_environment(context: Context) -> None: """Create a clean test environment.""" @@ -277,33 +337,27 @@ def step_given_run_command(context, command): @when('I run "{command}"') @when("I run '{command}'") def step_run_command(context, command): - """Run a CLI command.""" - import subprocess - import sys + """Run a CLI command. + + For ``cleveragents`` / ``agents`` commands we invoke the Typer app + **in-process** via ``CliRunner`` to avoid the ~6 s Python-startup + + Alembic-migration overhead that a ``subprocess.run`` call incurs per + invocation. Non-cleveragents commands still use ``subprocess``. + """ + import subprocess - # Run command using subprocess to capture output properly try: # Use shlex to properly handle quoted strings in the command command_parts = shlex.split(command) - # Widen terminal so Rich does not wrap long paths mid-word - env = os.environ.copy() - env.setdefault("COLUMNS", "300") - - # If the command starts with "agents" or "cleveragents", run it via the cleveragents module + # If the command starts with "agents" or "cleveragents", run in-process if command_parts[0] in ["agents", "cleveragents"]: - # Replace "agents" or "cleveragents" with the proper module invocation - command_parts = command_parts[1:] # Remove the command name - result = subprocess.run( - [sys.executable, "-m", "cleveragents", *command_parts], - capture_output=True, - text=True, - cwd=context.test_dir if hasattr(context, "test_dir") else None, - timeout=60, - env=env, - ) + _run_cleveragents_inprocess(context, command_parts[1:]) else: - # Run other commands as-is + # Widen terminal so Rich does not wrap long paths mid-word + env = os.environ.copy() + env.setdefault("COLUMNS", "300") + # Run other commands as-is via subprocess result = subprocess.run( command_parts, capture_output=True, @@ -313,13 +367,13 @@ def step_run_command(context, command): env=env, ) - output = result.stdout or "" - if result.stderr: - output += result.stderr - context.command_output = output - context.command_error = result.stderr - context.command_exit_code = result.returncode - context.exit_code = result.returncode # For compatibility with other steps + output = result.stdout or "" + if result.stderr: + output += result.stderr + context.command_output = output + context.command_error = result.stderr + context.command_exit_code = result.returncode + context.exit_code = result.returncode except subprocess.TimeoutExpired: context.command_output = "" context.command_error = "Command timed out" diff --git a/features/steps/cli_plan_context_steps.py b/features/steps/cli_plan_context_steps.py index 16762e83a..a306094f0 100644 --- a/features/steps/cli_plan_context_steps.py +++ b/features/steps/cli_plan_context_steps.py @@ -2,8 +2,6 @@ import os import shutil -import subprocess -import sys import tempfile from pathlib import Path @@ -51,16 +49,25 @@ def step_in_temp_directory(context: Context) -> None: @given('I have initialized a project "{name}"') def step_initialize_project_simple(context: Context, name: str) -> None: - """Initialize a project in the current directory.""" + """Initialize a project in the current directory. - result = subprocess.run( - [sys.executable, "-m", "cleveragents", "init", name], - capture_output=True, - text=True, - timeout=60, - cwd=context.test_dir if hasattr(context, "test_dir") else None, + Uses in-process CLI invocation to avoid the ~6 s subprocess overhead. + """ + from cleveragents.cli.commands.project import init_command + + prev_cwd = os.getcwd() + target = ( + context.test_dir + if hasattr(context, "test_dir") and context.test_dir + else prev_cwd ) - assert result.returncode == 0, f"Failed to initialize project: {result.stderr}" + os.chdir(target) + try: + init_command(name, Path(target)) + except Exception as exc: + raise AssertionError(f"Failed to initialize project: {exc}") from exc + finally: + os.chdir(prev_cwd) @given('I have created a file "{filename}" with content "{content}"') @@ -73,28 +80,31 @@ def step_create_file_with_content( @given('I have added path "{path}" to context') def step_add_path_to_context(context: Context, path: str) -> None: - """Add a file or directory path to context.""" - # First ensure we have a plan - if not hasattr(context, "has_plan"): - # Create a default plan first - result = subprocess.run( - [sys.executable, "-m", "cleveragents", "tell", "Default test plan"], - capture_output=True, - text=True, - timeout=60, - cwd=context.test_dir if hasattr(context, "test_dir") else None, - ) - assert result.returncode == 0, f"Failed to create plan: {result.stderr}" - context.has_plan = True + """Add a file or directory path to context. - result = subprocess.run( - [sys.executable, "-m", "cleveragents", "context", "add", path], - capture_output=True, - text=True, - timeout=30, - cwd=context.test_dir if hasattr(context, "test_dir") else None, + Uses in-process CLI invocation to avoid subprocess overhead. + """ + from cleveragents.cli.commands.context import context_add + from cleveragents.cli.commands.plan import tell as plan_tell + + prev_cwd = os.getcwd() + target = ( + context.test_dir + if hasattr(context, "test_dir") and context.test_dir + else prev_cwd ) - assert result.returncode == 0, f"Failed to add to context: {result.stderr}" + os.chdir(target) + try: + # First ensure we have a plan + if not hasattr(context, "has_plan"): + plan_tell(prompt="Default test plan") + context.has_plan = True + + context_add(paths=[path], recursive=True) + except Exception as exc: + raise AssertionError(f"Failed to add to context: {exc}") from exc + finally: + os.chdir(prev_cwd) @then('the directory "{dirname}" should exist') diff --git a/features/steps/service_steps.py b/features/steps/service_steps.py index ce87759f8..3d17feaeb 100644 --- a/features/steps/service_steps.py +++ b/features/steps/service_steps.py @@ -724,26 +724,20 @@ def step_given_created_plan_with_prompt(context: Context, prompt: str) -> None: project=context.test_project, prompt=prompt ) elif hasattr(context, "test_dir"): - # Use CLI for core_cli_commands tests - import subprocess - import sys + # Use in-process CLI for core_cli_commands tests (avoids subprocess overhead) + import os + from cleveragents.cli.commands.plan import tell as plan_tell + + prev_cwd = os.getcwd() + os.chdir(context.test_dir) try: - result = subprocess.run( - [sys.executable, "-m", "cleveragents", "tell", prompt], - capture_output=True, - text=True, - cwd=context.test_dir, - timeout=10, # Add timeout to prevent hanging - ) - assert result.returncode == 0, f"Failed to create plan: {result.stderr}" + plan_tell(prompt=prompt) context.has_plan = True - except subprocess.TimeoutExpired: - # Skip test if it times out - likely no AI model configured - context.scenario.skip( - "Skipping test - command timed out (no AI model configured?)" - ) - return + except Exception as exc: + raise AssertionError(f"Failed to create plan: {exc}") from exc + finally: + os.chdir(prev_cwd) else: # No context set up - need to create plan service step_have_plan_service(context) @@ -804,52 +798,27 @@ def step_given_created_and_built_plan(context: Context) -> None: # Build it to generate changes context.changes = context.plan_service.build_plan(project=context.test_project) elif hasattr(context, "test_dir"): - # Use CLI for core_cli_commands tests - import subprocess - import sys + # Use in-process CLI for core_cli_commands tests (avoids subprocess overhead) + import os - # Create plan with timeout - try: - result = subprocess.run( - [sys.executable, "-m", "cleveragents", "tell", "Add example code"], - capture_output=True, - text=True, - cwd=context.test_dir, - timeout=10, # Add 10 second timeout - ) - assert result.returncode == 0, f"Failed to create plan: {result.stderr}" - except subprocess.TimeoutExpired: - # Skip test if it times out - likely no AI model configured - context.scenario.skip( - "Skipping test - command timed out (no AI model configured?)" - ) - return + from cleveragents.cli.commands.plan import build as plan_build + from cleveragents.cli.commands.plan import tell as plan_tell - # Build plan with timeout + prev_cwd = os.getcwd() + os.chdir(context.test_dir) try: - result = subprocess.run( - [sys.executable, "-m", "cleveragents", "build"], - capture_output=True, - text=True, - cwd=context.test_dir, - timeout=10, # Add 10 second timeout - ) - # Check if error is due to no AI provider - if result.returncode != 0: - output = result.stderr + result.stdout - if "No AI provider configured" in output: - # Skip test if no AI provider - this is expected in test environments - context.scenario.skip( - "Skipping test - no AI provider configured (expected in test env)" - ) - return - assert result.returncode == 0, f"Failed to build plan: {result.stderr}" - except subprocess.TimeoutExpired: - # Skip test if it times out - likely no AI model configured - context.scenario.skip( - "Skipping test - command timed out (no AI model configured?)" - ) - return + plan_tell(prompt="Add example code") + plan_build() + except Exception as exc: + err_msg = str(exc) + if "No AI provider configured" in err_msg: + context.scenario.skip( + "Skipping test - no AI provider configured (expected in test env)" + ) + return + raise AssertionError(f"Failed to create/build plan: {exc}") from exc + finally: + os.chdir(prev_cwd) else: # No context set up - need to create plan service step_have_plan_service(context) diff --git a/robot/changeset_capture.robot b/robot/changeset_capture.robot index 588fe995f..ddb1c9496 100644 --- a/robot/changeset_capture.robot +++ b/robot/changeset_capture.robot @@ -94,7 +94,7 @@ ${STORE_SCRIPT} SEPARATOR=\n ... store.record(cid, entry) ... cs = store.get(cid) ... if cs is not None: -... print("PASS:store_get_ok") -... print(f"PASS:entry_count={len(cs.entries)}") +... ${SPACE}${SPACE}${SPACE}${SPACE}print("PASS:store_get_ok") +... ${SPACE}${SPACE}${SPACE}${SPACE}print(f"PASS:entry_count={len(cs.entries)}") ... else: -... print("FAIL:store_get_returned_none") +... ${SPACE}${SPACE}${SPACE}${SPACE}print("FAIL:store_get_returned_none") diff --git a/robot/resource_dag.robot b/robot/resource_dag.robot index 3895071e5..b21c2f43e 100644 --- a/robot/resource_dag.robot +++ b/robot/resource_dag.robot @@ -30,13 +30,13 @@ Link Child And Verify Tree ... rt_repo.create(parent_spec) ... rt_repo.create(child_spec) ... p = Resource(resource_id="01HDAGR0B0T0000000PARENT01", name=None, resource_type_name="robot/dag-parent", classification=PhysVirt.PHYSICAL, properties={}, location=None, capabilities=ResourceCapabilities(), created_at=datetime.now(tz=UTC), updated_at=datetime.now(tz=UTC)) - ... c = Resource(resource_id="01HDAGR0B0T000000CHILD001", name=None, resource_type_name="robot/dag-child", classification=PhysVirt.PHYSICAL, properties={}, location=None, capabilities=ResourceCapabilities(), created_at=datetime.now(tz=UTC), updated_at=datetime.now(tz=UTC)) + ... c = Resource(resource_id="01HDAGR0B0T00000CHXND00001", name=None, resource_type_name="robot/dag-child", classification=PhysVirt.PHYSICAL, properties={}, location=None, capabilities=ResourceCapabilities(), created_at=datetime.now(tz=UTC), updated_at=datetime.now(tz=UTC)) ... res_repo.create(p) ... res_repo.create(c) - ... res_repo.link_child("01HDAGR0B0T0000000PARENT01", "01HDAGR0B0T000000CHILD001") + ... res_repo.link_child("01HDAGR0B0T0000000PARENT01", "01HDAGR0B0T00000CHXND00001") ... children = res_repo.get_children("01HDAGR0B0T0000000PARENT01") ... assert len(children) == 1, f"Expected 1 child, got {len(children)}" - ... assert children[0].resource_id == "01HDAGR0B0T000000CHILD001" + ... assert children[0].resource_id == "01HDAGR0B0T00000CHXND00001" ... print("Link child and verify tree passed") ${result}= Run Process ${PYTHON} -c ${script} Should Be Equal As Integers ${result.rc} 0 Link test failed: ${result.stderr} @@ -61,16 +61,16 @@ Cycle Detection Rejects A To B To A ... res_repo = ResourceRepository(factory) ... spec = ResourceTypeSpec(name="robot/cycle-type", description="Cycle", resource_kind=ResourceKind.PHYSICAL, sandbox_strategy=SandboxStrategy.NONE, user_addable=True, cli_args=[], parent_types=[], child_types=["robot/cycle-type"], auto_discovery=None, equivalence=None, handler=None, capabilities={"read": True, "write": True, "sandbox": True, "checkpoint": False}, built_in=False) ... rt_repo.create(spec) - ... a = Resource(resource_id="01HDAGR0B0TCYCLE0000000A1", name=None, resource_type_name="robot/cycle-type", classification=PhysVirt.PHYSICAL, properties={}, location=None, capabilities=ResourceCapabilities(), created_at=datetime.now(tz=UTC), updated_at=datetime.now(tz=UTC)) - ... b = Resource(resource_id="01HDAGR0B0TCYCLE0000000B1", name=None, resource_type_name="robot/cycle-type", classification=PhysVirt.PHYSICAL, properties={}, location=None, capabilities=ResourceCapabilities(), created_at=datetime.now(tz=UTC), updated_at=datetime.now(tz=UTC)) + ... a = Resource(resource_id="01HDAGCYC000000000000000A1", name=None, resource_type_name="robot/cycle-type", classification=PhysVirt.PHYSICAL, properties={}, location=None, capabilities=ResourceCapabilities(), created_at=datetime.now(tz=UTC), updated_at=datetime.now(tz=UTC)) + ... b = Resource(resource_id="01HDAGCYC000000000000000B1", name=None, resource_type_name="robot/cycle-type", classification=PhysVirt.PHYSICAL, properties={}, location=None, capabilities=ResourceCapabilities(), created_at=datetime.now(tz=UTC), updated_at=datetime.now(tz=UTC)) ... res_repo.create(a) ... res_repo.create(b) - ... res_repo.link_child("01HDAGR0B0TCYCLE0000000A1", "01HDAGR0B0TCYCLE0000000B1") + ... res_repo.link_child("01HDAGCYC000000000000000A1", "01HDAGCYC000000000000000B1") ... try: - ... res_repo.link_child("01HDAGR0B0TCYCLE0000000B1", "01HDAGR0B0TCYCLE0000000A1") - ... assert False, "Should have raised CycleDetectedError" + ... ${SPACE * 4}res_repo.link_child("01HDAGCYC000000000000000B1", "01HDAGCYC000000000000000A1") + ... ${SPACE * 4}assert False, "Should have raised CycleDetectedError" ... except CycleDetectedError: - ... print("Cycle detection passed") + ... ${SPACE * 4}print("Cycle detection passed") ${result}= Run Process ${PYTHON} -c ${script} Should Be Equal As Integers ${result.rc} 0 Cycle test failed: ${result.stderr} Should Contain ${result.stdout} Cycle detection passed @@ -97,12 +97,12 @@ Auto Discover Children ... child_spec = ResourceTypeSpec(name="robot/disc-child", description="Discovered", resource_kind=ResourceKind.PHYSICAL, sandbox_strategy=SandboxStrategy.NONE, user_addable=True, cli_args=[], parent_types=[], child_types=[], auto_discovery=None, equivalence=None, handler=None, capabilities={"read": True, "write": True, "sandbox": True, "checkpoint": False}, built_in=False) ... rt_repo.create(parent_spec) ... rt_repo.create(child_spec) - ... p = Resource(resource_id="01HDAGR0B0TDISC00PARENT01", name=None, resource_type_name="robot/disc-parent", classification=PhysVirt.PHYSICAL, properties={}, location=None, capabilities=ResourceCapabilities(), created_at=datetime.now(tz=UTC), updated_at=datetime.now(tz=UTC)) + ... p = Resource(resource_id="01HDAGR0B0TDSC00PARENT0001", name=None, resource_type_name="robot/disc-parent", classification=PhysVirt.PHYSICAL, properties={}, location=None, capabilities=ResourceCapabilities(), created_at=datetime.now(tz=UTC), updated_at=datetime.now(tz=UTC)) ... res_repo.create(p) - ... created = res_repo.auto_discover_children("01HDAGR0B0TDISC00PARENT01") + ... created = res_repo.auto_discover_children("01HDAGR0B0TDSC00PARENT0001") ... assert len(created) >= 1, f"Expected >=1 children, got {len(created)}" ... assert created[0].resource_type_name == "robot/disc-child" - ... children = res_repo.get_children("01HDAGR0B0TDISC00PARENT01") + ... children = res_repo.get_children("01HDAGR0B0TDSC00PARENT0001") ... assert len(children) >= 1, f"Expected >=1 linked children, got {len(children)}" ... print("Auto discover children passed") ${result}= Run Process ${PYTHON} -c ${script} diff --git a/src/cleveragents/application/services/binding_resolution_service.py b/src/cleveragents/application/services/binding_resolution_service.py index 9023d7556..3b37f8a45 100644 --- a/src/cleveragents/application/services/binding_resolution_service.py +++ b/src/cleveragents/application/services/binding_resolution_service.py @@ -334,9 +334,7 @@ class BindingResolutionService: deferred=False, ) - names = [ - r.name or r.resource_id for r, _ in candidates - ] + names = [r.name or r.resource_id for r, _ in candidates] raise ValidationError( message=( f"Ambiguous contextual binding for slot " diff --git a/src/cleveragents/cli/main.py b/src/cleveragents/cli/main.py index 46242ecd0..e394d245a 100644 --- a/src/cleveragents/cli/main.py +++ b/src/cleveragents/cli/main.py @@ -286,13 +286,25 @@ def init( """Initialize a new CleverAgents project in the current directory.""" from cleveragents.cli.commands.project import init_command as project_init_command - project_init_command( - name=name, - path=path, - force=force, - create_ignore_file=create_ignore_file, - default_filters=default_filters, - ) + try: + project_init_command( + name=name, + path=path, + force=force, + create_ignore_file=create_ignore_file, + default_filters=default_filters, + ) + except CleverAgentsError as e: + err_console = get_err_console() + err_console.print(f"[red]Error:[/red] {e.message}") + if hasattr(e, "details") and e.details: + for key, value in e.details.items(): + err_console.print(f" {key}: {value}") + raise typer.Exit(1) from e + except Exception as e: + err_console = get_err_console() + err_console.print(f"[red]Unexpected error:[/red] {e}") + raise typer.Exit(1) from e # Shortcuts for most common commands diff --git a/src/cleveragents/domain/models/core/resource_slot.py b/src/cleveragents/domain/models/core/resource_slot.py index 7535f318f..ae708b2e9 100644 --- a/src/cleveragents/domain/models/core/resource_slot.py +++ b/src/cleveragents/domain/models/core/resource_slot.py @@ -44,8 +44,7 @@ class BindingResult(BaseModel): resource_id: str | None = Field( default=None, description=( - "ULID of the resolved resource " - "(None for deferred parameter bindings)" + "ULID of the resolved resource (None for deferred parameter bindings)" ), ) resource_name: str | None = Field( @@ -55,15 +54,13 @@ class BindingResult(BaseModel): binding_mode: str = Field( ..., description=( - "How the binding was resolved: " - "'contextual', 'static', or 'parameter'" + "How the binding was resolved: 'contextual', 'static', or 'parameter'" ), ) deferred: bool = Field( default=False, description=( - "True when the binding is deferred to invocation " - "time (parameter binding)" + "True when the binding is deferred to invocation time (parameter binding)" ), ) diff --git a/src/cleveragents/infrastructure/database/migration_runner.py b/src/cleveragents/infrastructure/database/migration_runner.py index e0f026d5c..766c34710 100644 --- a/src/cleveragents/infrastructure/database/migration_runner.py +++ b/src/cleveragents/infrastructure/database/migration_runner.py @@ -31,20 +31,68 @@ class MigrationRunner: self.database_url = database_url self._alembic_cfg: Config | None = None + @staticmethod + def _find_alembic_ini() -> Path: + """Locate the ``alembic.ini`` configuration file. + + Searches for ``alembic.ini`` using two strategies: + + 1. **Package-relative**: Walk upward from the top-level + ``cleveragents`` package directory. This is the most reliable + anchor because ``cleveragents.__file__`` is always resolvable + regardless of the current working directory. + 2. **Module-relative**: Walk upward from *this* source file as a + fallback for non-standard layouts. + + Only the presence of ``alembic.ini`` is checked (not an + ``alembic/`` sibling directory) because the ini file's own + ``script_location`` setting already tells Alembic where to find + the migration scripts. + + Raises: + FileNotFoundError: If ``alembic.ini`` cannot be located. + """ + search_roots: list[Path] = [] + + # Strategy 1 - start from the cleveragents package root. + # In an editable install this is e.g. /app/src/cleveragents/; + # walking up two levels reaches /app/ where alembic.ini lives. + try: + import cleveragents as _pkg + + pkg_init = getattr(_pkg, "__file__", None) + if pkg_init is not None: + search_roots.append(Path(pkg_init).resolve().parent) + except Exception: # pragma: no cover - defensive + pass + + # Strategy 2 - start from *this* module's directory. + search_roots.append(Path(__file__).resolve().parent) + + for start in search_roots: + current = start + # Walk up at most 10 levels to avoid runaway traversal + for _ in range(10): + candidate = current / "alembic.ini" + if candidate.exists(): + return candidate + parent = current.parent + if parent == current: + # Reached filesystem root + break + current = parent + + searched = ", ".join(str(s) for s in search_roots) + raise FileNotFoundError( + "Alembic configuration file (alembic.ini) not found. " + f"Searched upward from: {searched}" + ) + @property def alembic_cfg(self) -> Config: """Get Alembic configuration.""" if self._alembic_cfg is None: - # Find the alembic.ini file - # It should be in the project root - alembic_ini = ( - Path(__file__).parent.parent.parent.parent.parent / "alembic.ini" - ) - - if not alembic_ini.exists(): - raise FileNotFoundError( - f"Alembic configuration file not found at {alembic_ini}" - ) + alembic_ini = self._find_alembic_ini() self._alembic_cfg = Config(str(alembic_ini))