From 2637b272ddd86f9a9c5e512a19d8a46b9c84c238 Mon Sep 17 00:00:00 2001 From: Jeffrey Phillips Freeman Date: Sun, 5 Apr 2026 09:03:34 +0000 Subject: [PATCH] feat(cli): add devcontainer lifecycle state column to agents resource list output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a 'State' column to the 'agents resource list' rich table output that shows the current lifecycle state for devcontainer-instance and container-instance resources. For newly-discovered devcontainers the state displays as 'detected (not built)'; running, stopped, and failed states are also shown. Changes: - Add _get_lifecycle_state_str() helper that queries get_lifecycle_tracker() for container resource types and returns a human-readable state string - Add 'State' column to the rich table in resource_list() - Show warning banner '⚠ Devcontainer detected at ...' for resources in the detected state, matching the spec output for agents resource add - Update _resource_dict() to include 'lifecycle_state' field in JSON/YAML output (null for non-container resources) - Add BDD feature file and step definitions covering all lifecycle states, warning banner behaviour, and JSON output ISSUES CLOSED: #2596 --- .../resource_list_lifecycle_state.feature | 78 ++++++ .../resource_list_lifecycle_state_steps.py | 225 ++++++++++++++++++ src/cleveragents/cli/commands/resource.py | 48 ++++ 3 files changed, 351 insertions(+) create mode 100644 features/resource_list_lifecycle_state.feature create mode 100644 features/steps/resource_list_lifecycle_state_steps.py diff --git a/features/resource_list_lifecycle_state.feature b/features/resource_list_lifecycle_state.feature new file mode 100644 index 000000000..e635c2ead --- /dev/null +++ b/features/resource_list_lifecycle_state.feature @@ -0,0 +1,78 @@ +Feature: agents resource list shows devcontainer lifecycle state column + As a CleverAgents user + I want the resource list to show the lifecycle state for devcontainer-instance resources + So that I can see whether a devcontainer is detected, running, stopped, or failed + + Background: + Given a fresh in-memory resource registry + And built-in types are bootstrapped + + Scenario: resource list shows "Status" column header + Given a devcontainer-instance resource is registered + When I run resource list with all flag + Then the resource output should contain "Status" + + Scenario: resource list shows "detected (not built)" for a new devcontainer-instance + Given a devcontainer-instance resource is registered + When I run resource list with all flag + Then the resource output should contain "detected (not built)" + + Scenario: resource list shows "running" state for a running devcontainer + Given a devcontainer-instance resource is registered + And the devcontainer lifecycle state is "running" + When I run resource list with all flag + Then the resource output should contain "running" + + Scenario: resource list shows "stopped" state for a stopped devcontainer + Given a devcontainer-instance resource is registered + And the devcontainer lifecycle state is "stopped" + When I run resource list with all flag + Then the resource output should contain "stopped" + + Scenario: resource list shows "failed" state for a failed devcontainer + Given a devcontainer-instance resource is registered + And the devcontainer lifecycle state is "failed" + When I run resource list with all flag + Then the resource output should contain "failed" + + Scenario: resource list shows empty state for non-container resources + Given I run resource add "git-checkout" "local/my-repo" with path "/tmp/repo" + When I run resource list + Then the resource output should contain "local/my-repo" + And the resource output should contain "git-checkout" + + Scenario: resource list shows warning banner when devcontainer is in detected state + Given a devcontainer-instance resource is registered + When I run resource list with all flag + Then the resource output should contain "Devcontainer detected" + + Scenario: resource list does not show warning banner when devcontainer is running + Given a devcontainer-instance resource is registered + And the devcontainer lifecycle state is "running" + When I run resource list with all flag + Then the resource output should not contain "Devcontainer detected" + + Scenario: resource list JSON output includes lifecycle_state for devcontainer-instance + Given a devcontainer-instance resource is registered + When I run resource list with all flag and format "json" + Then the resource output should be valid JSON + And the resource JSON output should contain key "lifecycle_state" + + Scenario: resource list JSON output has null lifecycle_state for non-container resources + Given I run resource add "git-checkout" "local/my-repo" with path "/tmp/repo" + When I run resource list with format "json" + Then the resource output should be valid JSON + And the resource JSON lifecycle_state should be null for non-container resources + + Scenario: resource list shows lifecycle state for container-instance resources + Given a container-instance resource is registered + When I run resource list with all flag + Then the resource output should contain "Status" + And the resource output should contain "detected (not built)" + + Scenario: resource list succeeds gracefully when lifecycle tracker raises an error + Given a devcontainer-instance resource is registered + And the lifecycle tracker raises an error for the resource + When I run resource list with all flag + Then the resource list command should succeed + And the resource output should contain "devcontainer-instance" diff --git a/features/steps/resource_list_lifecycle_state_steps.py b/features/steps/resource_list_lifecycle_state_steps.py new file mode 100644 index 000000000..5e6dcaddb --- /dev/null +++ b/features/steps/resource_list_lifecycle_state_steps.py @@ -0,0 +1,225 @@ +"""Step definitions for resource list lifecycle state feature tests. + +Tests that ``agents resource list`` shows the devcontainer lifecycle state +column as required by spec line 10726 (issue #2596). +""" + +from __future__ import annotations + +import json +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.domain.models.core.container_lifecycle import ( + ContainerLifecycleState, + ContainerLifecycleTracker, +) +from cleveragents.infrastructure.database.models import Base + + +def _make_service(context: Context) -> ResourceRegistryService: + """Create or return a cached ResourceRegistryService.""" + if not hasattr(context, "resource_cli_service"): + engine = create_engine("sqlite:///:memory:", echo=False) + Base.metadata.create_all(engine) + factory = sessionmaker(bind=engine, expire_on_commit=False) + context.resource_cli_service = ResourceRegistryService(session_factory=factory) + return context.resource_cli_service + + +def _capture_output(func: Any, *args: Any, **kwargs: Any) -> tuple[str, bool]: + """Run a CLI function capturing its console output and success status.""" + import contextlib + + buf = StringIO() + console = Console( + file=buf, width=200, no_color=True, highlight=False, force_terminal=False + ) + + import cleveragents.cli.commands.resource as resource_mod + + orig_console = resource_mod.console + resource_mod.console = console + + failed = False + try: + with contextlib.redirect_stdout(buf): + func(*args, **kwargs) + except SystemExit: + failed = True + except Exception: + failed = True + finally: + resource_mod.console = orig_console + + return buf.getvalue(), failed + + +def _patch_service(context: Context) -> Any: + """Monkey-patch the container to return our in-memory service.""" + import cleveragents.cli.commands.resource as resource_mod + + orig_fn = resource_mod._get_registry_service + + def _mock_get() -> ResourceRegistryService: + return _make_service(context) + + resource_mod._get_registry_service = _mock_get + return orig_fn + + +def _unpatch_service(orig_fn: Any) -> None: + """Restore original service getter.""" + import cleveragents.cli.commands.resource as resource_mod + + resource_mod._get_registry_service = orig_fn + + +# ---- Given steps ---- + + +@given("a devcontainer-instance resource is registered") +def step_register_devcontainer_resource(context: Context) -> None: + """Register a devcontainer-instance resource in the registry.""" + service = _make_service(context) + service.bootstrap_builtin_types() + resource = service.register_resource( + type_name="devcontainer-instance", + name=None, # auto-discovered child resources have no name + location="/tmp/test-project", + description="Test devcontainer", + read_only=False, + properties=None, + ) + context.devcontainer_resource_id = resource.resource_id + # Default state is DETECTED — no tracker manipulation needed + + +@given("a container-instance resource is registered") +def step_register_container_resource(context: Context) -> None: + """Register a container-instance resource in the registry.""" + service = _make_service(context) + service.bootstrap_builtin_types() + resource = service.register_resource( + type_name="container-instance", + name=None, # auto-discovered child resources have no name + location="/tmp/test-container", + description="Test container", + read_only=False, + properties=None, + ) + context.devcontainer_resource_id = resource.resource_id + # Default state is DETECTED — no tracker manipulation needed + + +@given('the devcontainer lifecycle state is "{state}"') +def step_set_devcontainer_lifecycle_state(context: Context, state: str) -> None: + """Set the lifecycle state of the registered devcontainer resource.""" + from cleveragents.resource.handlers._devcontainer_internals import ( + set_lifecycle_tracker, + ) + + resource_id = context.devcontainer_resource_id + lifecycle_state = ContainerLifecycleState(state) + tracker = ContainerLifecycleTracker( + resource_id=resource_id, + current_state=lifecycle_state, + ) + set_lifecycle_tracker(tracker) + + +@given("the lifecycle tracker raises an error for the resource") +def step_lifecycle_tracker_raises_error(context: Context) -> None: + """Configure the lifecycle tracker to raise a ValueError for the resource.""" + context.lifecycle_tracker_error_patch = patch( + "cleveragents.cli.commands.resource.get_lifecycle_tracker", + side_effect=ValueError("Simulated tracker failure"), + ) + context.lifecycle_tracker_error_patch.start() + + +# ---- When steps ---- + + +@when("I run resource list with all flag") +def step_run_resource_list_all(context: Context) -> None: + """Run resource list --all command.""" + from cleveragents.cli.commands.resource import resource_list + + orig = _patch_service(context) + try: + output, failed = _capture_output( + resource_list, type_filter=None, show_all=True, fmt="rich" + ) + context.resource_cli_output = output + context.resource_cli_failed = failed + finally: + _unpatch_service(orig) + # Stop any active error patch + if hasattr(context, "lifecycle_tracker_error_patch"): + context.lifecycle_tracker_error_patch.stop() + del context.lifecycle_tracker_error_patch + + +@when('I run resource list with all flag and format "{fmt}"') +def step_run_resource_list_all_fmt(context: Context, fmt: str) -> None: + """Run resource list --all with specific format.""" + from cleveragents.cli.commands.resource import resource_list + + orig = _patch_service(context) + try: + output, failed = _capture_output( + resource_list, type_filter=None, show_all=True, fmt=fmt + ) + context.resource_cli_output = output + context.resource_cli_failed = failed + finally: + _unpatch_service(orig) + + +# ---- Then steps ---- + + +@then('the resource 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.""" + output = context.resource_cli_output + assert text not in output, ( + f"Expected output NOT to contain {text!r}, but it did.\nOutput:\n{output}" + ) + + +@then("the resource JSON lifecycle_state should be null for non-container resources") +def step_json_lifecycle_state_null(context: Context) -> None: + """Assert all resources in JSON output have null lifecycle_state.""" + output = context.resource_cli_output + data = json.loads(output) + if isinstance(data, list): + for item in data: + assert item.get("lifecycle_state") is None, ( + f"Expected lifecycle_state to be null for non-container resource, " + f"got {item.get('lifecycle_state')!r}" + ) + else: + assert data.get("lifecycle_state") is None, ( + f"Expected lifecycle_state to be null, got {data.get('lifecycle_state')!r}" + ) + + +@then("the resource list command should succeed") +def step_resource_list_command_should_succeed(context: Context) -> None: + """Assert the resource list command did not fail.""" + assert not context.resource_cli_failed, ( + f"Expected resource list command to succeed, but it failed.\n" + f"Output:\n{context.resource_cli_output}" + ) diff --git a/src/cleveragents/cli/commands/resource.py b/src/cleveragents/cli/commands/resource.py index 420d5f864..57a3d7077 100644 --- a/src/cleveragents/cli/commands/resource.py +++ b/src/cleveragents/cli/commands/resource.py @@ -148,6 +148,36 @@ def _resource_type_dict(spec: Any) -> dict[str, object]: return result +def _get_lifecycle_state_str(resource: Any) -> str | None: + """Return the lifecycle state string for container resources, or None. + + For ``devcontainer-instance`` and ``container-instance`` resources, + queries the lifecycle tracker and returns the current state as a + human-readable string. Returns ``None`` for all other resource types. + + Args: + resource: A Resource domain object. + + Returns: + Lifecycle state string (e.g. ``"detected (not built)"``, ``"running"``) + or ``None`` if the resource type has no lifecycle state. + """ + if resource.resource_type_name not in _CONTAINER_TYPES: + return None + try: + tracker = get_lifecycle_tracker(resource.resource_id) + state = tracker.current_state + if state == ContainerLifecycleState.DETECTED: + return "detected (not built)" + return str(state) + except (ValueError, KeyError): + logger.warning( + "Could not retrieve lifecycle tracker for resource %s", + resource.resource_id, + ) + return None + + def _resource_dict(resource: Any) -> dict[str, object]: """Convert a Resource domain object to a plain dict for output formatting.""" return { @@ -158,6 +188,7 @@ def _resource_dict(resource: Any) -> dict[str, object]: "description": resource.description, "location": resource.location, "properties": resource.properties, + "lifecycle_state": _get_lifecycle_state_str(resource), "created_at": resource.created_at.isoformat() if hasattr(resource.created_at, "isoformat") else str(resource.created_at), @@ -795,10 +826,12 @@ def resource_list( table.add_column("ID", style="dim") table.add_column("Name", style="cyan") table.add_column("Type", style="blue") + table.add_column("Status", style="yellow") table.add_column("Kind", style="magenta") table.add_column("Location", style="dim") table.add_column("Description", style="dim") + detected_devcontainers: list[Any] = [] for res in resources: desc = res.description or "" if len(desc) > 40: @@ -806,10 +839,14 @@ def resource_list( res_id = res.resource_id if len(res_id) > 12: res_id = res_id[:12] + "..." + lifecycle_state = _get_lifecycle_state_str(res) + if lifecycle_state == "detected (not built)": + detected_devcontainers.append(res) table.add_row( res_id, res.name or "(unnamed)", res.resource_type_name, + lifecycle_state or "", str(res.classification), res.location or "", desc, @@ -817,6 +854,17 @@ def resource_list( console.print(table) + for res in detected_devcontainers: + location = res.location or "(unknown path)" + devcontainer_json = f"{location}/.devcontainer/devcontainer.json" + console.print( + f"[yellow]⚠ Devcontainer detected at {devcontainer_json}[/yellow]" + ) + console.print(" Container will be built lazily on first access.") + console.print( + f" Use [cyan]agents resource show[/cyan] {res.resource_id} to inspect." + ) + except CleverAgentsError as exc: console.print(f"[red]Error:[/red] {exc.message}") raise typer.Abort() from exc -- 2.52.0