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
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:
@@ -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}"
|
||||
)
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user