feat(cli): add devcontainer lifecycle state column to agents resource list output
CI / lint (pull_request) Successful in 39s
CI / typecheck (pull_request) Successful in 54s
CI / security (pull_request) Successful in 54s
CI / quality (pull_request) Successful in 34s
CI / build (pull_request) Successful in 18s
CI / helm (pull_request) Successful in 24s
CI / unit_tests (pull_request) Successful in 6m48s
CI / e2e_tests (pull_request) Successful in 19m50s
CI / integration_tests (pull_request) Failing after 22m57s
CI / docker (pull_request) Successful in 1m22s
CI / coverage (pull_request) Successful in 11m8s
CI / status-check (pull_request) Failing after 1s
CI / benchmark-publish (pull_request) Has been skipped
CI / benchmark-regression (pull_request) Successful in 56m59s

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
This commit is contained in:
2026-04-05 09:03:34 +00:00
parent b82a9b6962
commit 2637b272dd
3 changed files with 351 additions and 0 deletions
@@ -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"
@@ -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}"
)
+48
View File
@@ -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