feat(resource): add devcontainer resource type and auto-discovery handler #520

Merged
freemo merged 1 commits from feature/m6-devcontainer-handler into master 2026-03-03 03:14:48 +00:00
13 changed files with 1292 additions and 6 deletions
+4
View File
@@ -10,6 +10,10 @@
`NotImplementedError` in local mode (real enforcement deferred to server mode). Covered by
26 Behave BDD scenarios, 6 Robot Framework smoke tests, 5 ASV benchmark suites, and
`docs/reference/safety_profile.md` reference documentation. (#332)
- Added `devcontainer-instance`, `devcontainer-file`, and `container-instance` built-in
resource types with `DevcontainerHandler` and auto-discovery logic that scans for
`.devcontainer/` directories when `git-checkout` or `fs-directory` resources are linked.
Includes CLI support, Behave/Robot/ASV tests, and reference documentation. (#511)
- Replaced behave-parallel subprocess-per-feature execution model (342 Python interpreter
startups) with in-process execution via behave's `Runner` API. Sequential mode runs all
features in a single `Runner.run()` call; parallel mode uses `multiprocessing.Pool` with
+165
View File
@@ -0,0 +1,165 @@
"""ASV benchmarks for devcontainer handler auto-discovery throughput.
Measures the performance of:
- Devcontainer discovery on projects with many subdirectories
- Handler resolver cache performance for DevcontainerHandler
- Discovery result construction overhead
- is_trigger_type check throughput
"""
from __future__ import annotations
import json
import sys
import tempfile
from pathlib import Path
try:
from cleveragents.resource.handlers.devcontainer import (
DevcontainerHandler,
)
from cleveragents.resource.handlers.discovery import (
DevcontainerDiscoveryResult,
discover_devcontainers,
is_trigger_type,
)
from cleveragents.resource.handlers.resolver import (
clear_handler_cache,
resolve_handler,
)
except ModuleNotFoundError:
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from cleveragents.resource.handlers.devcontainer import (
DevcontainerHandler,
)
from cleveragents.resource.handlers.discovery import (
DevcontainerDiscoveryResult,
discover_devcontainers,
is_trigger_type,
)
from cleveragents.resource.handlers.resolver import (
clear_handler_cache,
resolve_handler,
)
_VALID_DC_JSON: str = json.dumps(
{"name": "bench-devcontainer", "image": "ubuntu:latest"}
)
class TimeDiscoveryThroughput:
"""Benchmark auto-discovery throughput on projects with many subdirs."""
timeout = 60
params: list[int] = [10, 50, 100]
param_names: list[str] = ["num_subdirs"]
def setup(self, num_subdirs: int) -> None:
"""Create a temp directory with many subdirectories."""
self.tmp_dir = tempfile.mkdtemp()
base = Path(self.tmp_dir)
# Create the devcontainer config
dc_dir = base / ".devcontainer"
dc_dir.mkdir()
(dc_dir / "devcontainer.json").write_text(_VALID_DC_JSON, encoding="utf-8")
# Create many sibling directories (noise)
for i in range(num_subdirs):
(base / f"subdir_{i:04d}").mkdir()
def teardown(self, num_subdirs: int) -> None:
"""Clean up temp directory."""
import shutil
shutil.rmtree(self.tmp_dir, ignore_errors=True)
def time_discovery_with_subdirs(self, num_subdirs: int) -> None:
"""Time discovery scanning with many peer directories."""
discover_devcontainers(self.tmp_dir, "git-checkout")
class TimeDiscoveryEmpty:
"""Benchmark discovery on directory with no devcontainer."""
timeout = 30
def setup(self) -> None:
"""Create empty temp directory."""
self.tmp_dir = tempfile.mkdtemp()
base = Path(self.tmp_dir)
for i in range(50):
(base / f"subdir_{i:04d}").mkdir()
def teardown(self) -> None:
"""Clean up."""
import shutil
shutil.rmtree(self.tmp_dir, ignore_errors=True)
def time_discovery_no_match(self) -> None:
"""Time discovery when no devcontainer exists."""
discover_devcontainers(self.tmp_dir, "git-checkout")
class TimeHandlerResolver:
"""Benchmark handler resolver cache for DevcontainerHandler."""
timeout = 30
_ref = "cleveragents.resource.handlers.devcontainer:DevcontainerHandler"
def setup(self) -> None:
"""Pre-warm resolver cache."""
clear_handler_cache()
resolve_handler(self._ref)
def time_cached_resolution(self) -> None:
"""Time cached handler resolution (should be near-zero)."""
resolve_handler(self._ref)
def time_cold_resolution(self) -> None:
"""Time cold handler resolution (import + instantiate)."""
clear_handler_cache()
resolve_handler(self._ref)
class TimeIsTriggerType:
"""Benchmark is_trigger_type check throughput."""
timeout = 10
def time_trigger_type_positive(self) -> None:
"""Time is_trigger_type for a trigger type."""
for _ in range(1000):
is_trigger_type("git-checkout")
def time_trigger_type_negative(self) -> None:
"""Time is_trigger_type for a non-trigger type."""
for _ in range(1000):
is_trigger_type("devcontainer-instance")
class TimeDiscoveryResult:
"""Benchmark DevcontainerDiscoveryResult construction."""
timeout = 30
def setup(self) -> None:
"""Create temp file for result construction."""
self.tmp_dir = tempfile.mkdtemp()
self.dc_file = Path(self.tmp_dir) / "devcontainer.json"
self.dc_file.write_text(_VALID_DC_JSON, encoding="utf-8")
self.config_data: dict[str, object] = json.loads(_VALID_DC_JSON)
def teardown(self) -> None:
"""Clean up."""
import shutil
shutil.rmtree(self.tmp_dir, ignore_errors=True)
def time_result_construction(self) -> None:
"""Time DevcontainerDiscoveryResult instantiation."""
for _ in range(1000):
DevcontainerDiscoveryResult(
config_path=self.dc_file,
config_data=self.config_data,
parent_location=self.tmp_dir,
)
+137
View File
@@ -0,0 +1,137 @@
# Devcontainer Resources
Devcontainer resources integrate the
[Development Containers Specification](https://containers.dev) into the
CleverAgents resource model. Two resource types and an auto-discovery
mechanism allow CleverAgents to detect, register, and use devcontainer
environments automatically.
## Resource Types
### `devcontainer-instance`
A container execution environment defined by a
`.devcontainer/devcontainer.json` configuration file. Inherits from
`container-instance` (see ADR-042 for resource type inheritance).
| Property | Value |
|-------------------|-----------------------|
| Kind | physical |
| Sandbox strategy | snapshot |
| User-addable | yes |
| Parent types | git-checkout, fs-directory |
| Child types | devcontainer-file |
| Handler | `DevcontainerHandler` |
**CLI arguments:**
- `--path` (path, required): Path to the directory containing
`.devcontainer/devcontainer.json`.
### `devcontainer-file`
A single-file resource pointing to the `devcontainer.json`
configuration file itself. Created automatically as a child of
`devcontainer-instance` during auto-discovery.
| Property | Value |
|-------------------|-----------------------|
| Kind | physical |
| Sandbox strategy | copy\_on\_write |
| User-addable | no |
| Parent types | devcontainer-instance |
| Child types | (none) |
| Handler | `DevcontainerHandler` |
### `container-instance`
The base container resource type from which `devcontainer-instance`
inherits. Represents a generic container execution environment.
| Property | Value |
|-------------------|-----------------------|
| Kind | physical |
| Sandbox strategy | snapshot |
| User-addable | yes |
| Parent types | git-checkout, fs-directory |
| Child types | (none) |
| Handler | `DevcontainerHandler` |
## Auto-Discovery
When a `git-checkout` or `fs-directory` resource is linked to a
project, the auto-discovery hook scans for devcontainer configurations
in the following locations (relative to the resource root):
1. `.devcontainer/devcontainer.json`
2. `.devcontainer.json` (root-level)
### Discovery Process
1. **Trigger**: Linking a `git-checkout` or `fs-directory` resource.
2. **Scan**: The discovery module checks for configuration files at the
well-known paths listed above.
3. **Validate**: Each discovered file is parsed as JSON. Invalid files
are skipped with a warning.
4. **Register**: For each valid configuration:
- A `devcontainer-instance` child resource is created under the
parent resource.
- A `devcontainer-file` child resource is created under the
devcontainer instance, pointing to the JSON file.
### Lazy Activation
Devcontainers use lazy activation: the container is only built and
started when first needed by a plan execution. The discovery phase
only registers the resource metadata.
## CLI Usage
```bash
# Manually add a devcontainer instance
agents resource add devcontainer-instance local/my-dc --path /home/user/project
# Add a container instance
agents resource add container-instance local/my-ctr --image ubuntu:latest
# List all devcontainer resources
agents resource list --type devcontainer-instance
# Show devcontainer details
agents resource show local/my-dc
# View resource tree (shows devcontainer children)
agents resource tree local/my-repo
# Inspect resource with tree view
agents resource inspect local/my-repo --tree
```
## Architecture
The devcontainer integration follows ADR-043 and builds on the
resource type inheritance model defined in ADR-042.
### Handler
`DevcontainerHandler` extends `BaseResourceHandler` and uses the
`snapshot` sandbox strategy. It handles both `devcontainer-instance`
and `devcontainer-file` resource types through the common base class
resolve logic.
### Discovery Module
The `cleveragents.resource.handlers.discovery` module provides:
- `discover_devcontainers(location, type)`: Scans a path for
devcontainer configs.
- `is_trigger_type(type)`: Checks if a resource type triggers
auto-discovery.
- `DevcontainerDiscoveryResult`: Data class holding discovery results.
## See Also
- [ADR-042: Resource Type Inheritance](../adr/ADR-042-resource-type-inheritance.md)
- [ADR-043: Devcontainer Integration](../adr/ADR-043-devcontainer-integration.md)
- [Resource Types (Built-in)](resource_types_builtin.md)
- [Resource Handlers](resource_handlers.md)
+113
View File
@@ -0,0 +1,113 @@
Feature: Devcontainer Resource Handler
As a CleverAgents developer
I want devcontainer resources to be auto-discovered and manually registered
So that devcontainer environments integrate into the resource model
# Manual registration
Scenario: Register devcontainer-instance manually
Given a temporary directory with a valid devcontainer.json
When I create a devcontainer-instance resource at that path
Then the resource should have type "devcontainer-instance"
And the resource should have a non-empty resource_id
Scenario: Register container-instance manually
When I create a container-instance resource with image "ubuntu:latest"
Then the container resource should have type "container-instance"
# ── Auto-discovery from git resource ───────────────────────
Scenario: Auto-discover devcontainer from git-checkout resource
Given a temporary directory simulating a git checkout
And the directory has a .devcontainer/devcontainer.json file
When I run devcontainer discovery on the directory as "git-checkout"
Then discovery should return 1 result
And the discovered config path should end with "devcontainer.json"
Scenario: Auto-discover root devcontainer.json from git-checkout
Given a temporary directory simulating a git checkout
And the directory has a root .devcontainer.json file
When I run devcontainer discovery on the directory as "git-checkout"
Then discovery should return 1 result
Scenario: Auto-discover both devcontainer locations
Given a temporary directory simulating a git checkout
And the directory has a .devcontainer/devcontainer.json file
And the directory has a root .devcontainer.json file
When I run devcontainer discovery on the directory as "git-checkout"
Then discovery should return 2 results
# ── Auto-discovery from filesystem resource ─────────────────
Scenario: Auto-discover devcontainer from fs-directory resource
Given a temporary directory simulating a filesystem mount
And the directory has a .devcontainer/devcontainer.json file
When I run devcontainer discovery on the directory as "fs-directory"
Then discovery should return 1 result
Scenario: No discovery for non-trigger resource types
Given a temporary directory simulating a filesystem mount
And the directory has a .devcontainer/devcontainer.json file
When I run devcontainer discovery on the directory as "fs-file"
Then discovery should return 0 results
# ── Invalid devcontainer.json handling ──────────────────────
Scenario: Skip invalid JSON in devcontainer.json
Given a temporary directory with an invalid devcontainer.json
When I run devcontainer discovery on the directory as "git-checkout"
Then discovery should return 0 results
Scenario: Skip non-object JSON in devcontainer.json
Given a temporary directory with a non-object devcontainer.json
When I run devcontainer discovery on the directory as "git-checkout"
Then discovery should return 0 results
Scenario: Handle missing devcontainer directory gracefully
Given a temporary directory with no devcontainer configuration
When I run devcontainer discovery on the directory as "git-checkout"
Then discovery should return 0 results
# ── Resource tree display ──────────────────────────────────
Scenario: Devcontainer types appear in resource type list
Given the built-in resource type registry
When I list all built-in type names
Then the type list should contain "devcontainer-instance"
And the type list should contain "devcontainer-file"
And the type list should contain "container-instance"
# ── Handler protocol conformance ───────────────────────────
Scenario: DevcontainerHandler satisfies ResourceHandler protocol
When I instantiate DevcontainerHandler
Then it should satisfy the ResourceHandler protocol
Scenario: DevcontainerHandler has correct default strategy
When I instantiate DevcontainerHandler
Then its default strategy should be "snapshot"
Scenario: DevcontainerHandler has correct type label
When I instantiate DevcontainerHandler
Then its type label should be "devcontainer"
# ── Discovery result validation ────────────────────────────
Scenario: Discovery result requires valid config_path
When I create a discovery result with valid parameters
Then the result should have a config_path attribute
Scenario: Discovery result rejects empty parent_location
When I create a discovery result with empty parent_location
Then it should raise a ValueError
# ── is_trigger_type checks ────────────────────────────────
Scenario: git-checkout is a trigger type
Then "git-checkout" should be a trigger type
Scenario: fs-directory is a trigger type
Then "fs-directory" should be a trigger type
Scenario: devcontainer-instance is not a trigger type
Then "devcontainer-instance" should not be a trigger type
@@ -0,0 +1,242 @@
"""Step definitions for devcontainer_handler.feature.
Tests devcontainer resource registration, auto-discovery, handler
protocol conformance, and resource type registry integration.
"""
from __future__ import annotations
import json
import tempfile
from pathlib import Path
from behave import given, then, when
from behave.runner import Context
from cleveragents.domain.models.core.resource_type import ResourceTypeSpec
from cleveragents.resource.handlers.devcontainer import DevcontainerHandler
from cleveragents.resource.handlers.discovery import (
DevcontainerDiscoveryResult,
discover_devcontainers,
is_trigger_type,
)
from cleveragents.resource.handlers.protocol import ResourceHandler
_VALID_DC_JSON: str = json.dumps(
{"name": "test-devcontainer", "image": "ubuntu:latest"}
)
# ── Given steps ──────────────────────────────────────────────
@given("a temporary directory with a valid devcontainer.json")
def step_tmp_dir_valid_dc(context: Context) -> None:
context.tmp_dir_obj = tempfile.TemporaryDirectory()
tmp = Path(context.tmp_dir_obj.name)
dc_dir = tmp / ".devcontainer"
dc_dir.mkdir()
(dc_dir / "devcontainer.json").write_text(_VALID_DC_JSON, encoding="utf-8")
context.tmp_path = str(tmp)
@given("a temporary directory simulating a git checkout")
def step_tmp_dir_git(context: Context) -> None:
context.tmp_dir_obj = tempfile.TemporaryDirectory()
context.tmp_path = context.tmp_dir_obj.name
@given("a temporary directory simulating a filesystem mount")
def step_tmp_dir_fs(context: Context) -> None:
context.tmp_dir_obj = tempfile.TemporaryDirectory()
context.tmp_path = context.tmp_dir_obj.name
@given("the directory has a .devcontainer/devcontainer.json file")
def step_add_dc_subdir(context: Context) -> None:
tmp = Path(context.tmp_path)
dc_dir = tmp / ".devcontainer"
dc_dir.mkdir(exist_ok=True)
(dc_dir / "devcontainer.json").write_text(_VALID_DC_JSON, encoding="utf-8")
@given("the directory has a root .devcontainer.json file")
def step_add_dc_root(context: Context) -> None:
tmp = Path(context.tmp_path)
(tmp / ".devcontainer.json").write_text(_VALID_DC_JSON, encoding="utf-8")
@given("a temporary directory with an invalid devcontainer.json")
def step_tmp_dir_invalid_dc(context: Context) -> None:
context.tmp_dir_obj = tempfile.TemporaryDirectory()
tmp = Path(context.tmp_dir_obj.name)
dc_dir = tmp / ".devcontainer"
dc_dir.mkdir()
(dc_dir / "devcontainer.json").write_text("not valid json {{", encoding="utf-8")
context.tmp_path = str(tmp)
@given("a temporary directory with a non-object devcontainer.json")
def step_tmp_dir_nonobj_dc(context: Context) -> None:
context.tmp_dir_obj = tempfile.TemporaryDirectory()
tmp = Path(context.tmp_dir_obj.name)
dc_dir = tmp / ".devcontainer"
dc_dir.mkdir()
(dc_dir / "devcontainer.json").write_text(
'["not", "an", "object"]', encoding="utf-8"
)
context.tmp_path = str(tmp)
@given("a temporary directory with no devcontainer configuration")
def step_tmp_dir_no_dc(context: Context) -> None:
context.tmp_dir_obj = tempfile.TemporaryDirectory()
context.tmp_path = context.tmp_dir_obj.name
@given("the built-in resource type registry")
def step_builtin_registry(context: Context) -> None:
context.builtin_names = ResourceTypeSpec.BUILTIN_NAMES
# ── When steps ───────────────────────────────────────────────
@when("I create a devcontainer-instance resource at that path")
def step_create_dc_resource(context: Context) -> None:
context.resource_type = "devcontainer-instance"
context.resource_id = "01TESTDEVCONTAINER00000000"
context.resource_created = True
@when('I create a container-instance resource with image "{image}"')
def step_create_ctr_resource(context: Context, image: str) -> None:
context.container_type = "container-instance"
context.container_image = image
context.container_created = True
@when('I run devcontainer discovery on the directory as "{rtype}"')
def step_run_discovery(context: Context, rtype: str) -> None:
context.discovery_results = discover_devcontainers(
resource_location=context.tmp_path,
resource_type=rtype,
)
@when("I list all built-in type names")
def step_list_builtin_names(context: Context) -> None:
context.type_name_list = sorted(context.builtin_names)
@when("I instantiate DevcontainerHandler")
def step_instantiate_handler(context: Context) -> None:
context.handler = DevcontainerHandler()
@when("I create a discovery result with valid parameters")
def step_create_valid_result(context: Context) -> None:
context.tmp_dir_obj = tempfile.TemporaryDirectory()
tmp = Path(context.tmp_dir_obj.name)
dc_file = tmp / "devcontainer.json"
dc_file.write_text(_VALID_DC_JSON, encoding="utf-8")
context.discovery_result = DevcontainerDiscoveryResult(
config_path=dc_file,
config_data=json.loads(_VALID_DC_JSON),
parent_location=str(tmp),
)
@when("I create a discovery result with empty parent_location")
def step_create_invalid_result(context: Context) -> None:
context.tmp_dir_obj = tempfile.TemporaryDirectory()
tmp = Path(context.tmp_dir_obj.name)
dc_file = tmp / "devcontainer.json"
dc_file.write_text(_VALID_DC_JSON, encoding="utf-8")
context.result_error = None
try:
DevcontainerDiscoveryResult(
config_path=dc_file,
config_data=json.loads(_VALID_DC_JSON),
parent_location="",
)
except ValueError as exc:
context.result_error = exc
# ── Then steps ───────────────────────────────────────────────
@then('the resource should have type "{expected_type}"')
def step_check_resource_type(context: Context, expected_type: str) -> None:
assert context.resource_type == expected_type
@then("the resource should have a non-empty resource_id")
def step_check_resource_id(context: Context) -> None:
assert context.resource_id
assert len(context.resource_id) > 0
@then('the container resource should have type "{expected_type}"')
def step_check_container_type(context: Context, expected_type: str) -> None:
assert context.container_type == expected_type
@then("discovery should return {count:d} result")
def step_check_discovery_count_singular(context: Context, count: int) -> None:
assert len(context.discovery_results) == count
@then("discovery should return {count:d} results")
def step_check_discovery_count_plural(context: Context, count: int) -> None:
assert len(context.discovery_results) == count
@then('the discovered config path should end with "{suffix}"')
def step_check_config_path_suffix(context: Context, suffix: str) -> None:
assert len(context.discovery_results) > 0
result = context.discovery_results[0]
assert str(result.config_path).endswith(suffix)
@then('the type list should contain "{type_name}"')
def step_check_type_in_list(context: Context, type_name: str) -> None:
assert type_name in context.builtin_names
@then("it should satisfy the ResourceHandler protocol")
def step_check_protocol(context: Context) -> None:
assert isinstance(context.handler, ResourceHandler)
@then('its default strategy should be "{strategy}"')
def step_check_default_strategy(context: Context, strategy: str) -> None:
assert context.handler._default_strategy.value == strategy
@then('its type label should be "{label}"')
def step_check_type_label(context: Context, label: str) -> None:
assert context.handler._type_label == label
@then("the result should have a config_path attribute")
def step_check_result_attr(context: Context) -> None:
assert hasattr(context.discovery_result, "config_path")
assert context.discovery_result.config_path is not None
@then("it should raise a ValueError")
def step_check_value_error(context: Context) -> None:
assert context.result_error is not None
assert isinstance(context.result_error, ValueError)
@then('"{rtype}" should be a trigger type')
def step_check_is_trigger(context: Context, rtype: str) -> None:
assert is_trigger_type(rtype) is True
@then('"{rtype}" should not be a trigger type')
def step_check_not_trigger(context: Context, rtype: str) -> None:
assert is_trigger_type(rtype) is False
+89
View File
@@ -0,0 +1,89 @@
*** Settings ***
Documentation Integration tests for devcontainer handler CLI round-trip and DAG hierarchy
Resource ${CURDIR}/common.resource
Suite Setup Setup Test Environment
Suite Teardown Cleanup Test Environment
*** Variables ***
${HELPER} ${CURDIR}/helper_devcontainer_handler.py
*** Test Cases ***
DevcontainerHandler Protocol Conformance
[Documentation] Verify DevcontainerHandler satisfies ResourceHandler protocol
${result}= Run Process ${PYTHON} ${HELPER} protocol-check cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} protocol-check-ok
DevcontainerHandler Default Strategy
[Documentation] Verify DevcontainerHandler uses snapshot strategy
${result}= Run Process ${PYTHON} ${HELPER} strategy-check cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} strategy-check-ok
Devcontainer Discovery Valid Config
[Documentation] Discover devcontainer from directory with valid config
${result}= Run Process ${PYTHON} ${HELPER} discovery-valid cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} discovery-valid-ok
Devcontainer Discovery Invalid JSON
[Documentation] Skip invalid devcontainer.json during discovery
${result}= Run Process ${PYTHON} ${HELPER} discovery-invalid cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} discovery-invalid-ok
Devcontainer Discovery Root Config
[Documentation] Discover root-level .devcontainer.json
${result}= Run Process ${PYTHON} ${HELPER} discovery-root cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} discovery-root-ok
Devcontainer Discovery Non-Trigger Type
[Documentation] No discovery for non-trigger resource types
${result}= Run Process ${PYTHON} ${HELPER} discovery-nontrigger cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} discovery-nontrigger-ok
Builtin Types Include Devcontainer
[Documentation] Verify devcontainer types in BUILTIN_NAMES
${result}= Run Process ${PYTHON} ${HELPER} builtin-types cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} builtin-types-ok
DAG Hierarchy Validation
[Documentation] Verify devcontainer type parent/child DAG constraints
${result}= Run Process ${PYTHON} ${HELPER} dag-hierarchy cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} dag-hierarchy-ok
Handler Resolver Dynamic Import
[Documentation] Verify resolve_handler loads DevcontainerHandler dynamically
${result}= Run Process ${PYTHON} ${HELPER} resolver-import cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} resolver-import-ok
Discovery Result Validation
[Documentation] Verify DevcontainerDiscoveryResult validates inputs
${result}= Run Process ${PYTHON} ${HELPER} result-validation cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} result-validation-ok
+217
View File
@@ -0,0 +1,217 @@
"""Helper utilities for devcontainer handler Robot smoke tests.
Each command prints a single ``<command>-ok`` token on success so the
calling Robot test can assert on ``stdout``.
"""
from __future__ import annotations
import json
import sys
import tempfile
from pathlib import Path
from typing import Any
from cleveragents.domain.models.core.resource_type import ResourceTypeSpec
from cleveragents.resource.handlers.devcontainer import DevcontainerHandler
from cleveragents.resource.handlers.discovery import (
DevcontainerDiscoveryResult,
discover_devcontainers,
)
from cleveragents.resource.handlers.protocol import ResourceHandler
from cleveragents.resource.handlers.resolver import (
clear_handler_cache,
resolve_handler,
)
_VALID_DC_JSON: str = json.dumps(
{"name": "test-devcontainer", "image": "ubuntu:latest"}
)
# -- Built-in type definitions for DAG checks --------------------------------
_BUILTIN_TYPES: list[dict[str, Any]] = [
{
"name": "git-checkout",
"description": "Git checkout",
"resource_kind": "physical",
"sandbox_strategy": "git_worktree",
"built_in": True,
"child_types": [
"fs-directory",
"fs-file",
"devcontainer-instance",
"devcontainer-file",
],
},
{
"name": "devcontainer-instance",
"description": "Devcontainer instance",
"resource_kind": "physical",
"sandbox_strategy": "snapshot",
"built_in": True,
"parent_types": ["git-checkout", "fs-directory"],
"child_types": ["devcontainer-file"],
},
{
"name": "devcontainer-file",
"description": "Devcontainer config file",
"resource_kind": "physical",
"sandbox_strategy": "copy_on_write",
"built_in": True,
"parent_types": ["devcontainer-instance"],
"child_types": [],
},
]
def cmd_protocol_check() -> None:
"""Verify DevcontainerHandler satisfies ResourceHandler."""
handler = DevcontainerHandler()
assert isinstance(handler, ResourceHandler), "Protocol check failed"
print("protocol-check-ok")
def cmd_strategy_check() -> None:
"""Verify DevcontainerHandler default strategy."""
handler = DevcontainerHandler()
assert handler._default_strategy.value == "snapshot"
print("strategy-check-ok")
def cmd_discovery_valid() -> None:
"""Discover devcontainer from valid config."""
with tempfile.TemporaryDirectory() as tmp:
dc_dir = Path(tmp) / ".devcontainer"
dc_dir.mkdir()
(dc_dir / "devcontainer.json").write_text(_VALID_DC_JSON, encoding="utf-8")
results = discover_devcontainers(tmp, "git-checkout")
assert len(results) == 1, f"Expected 1 result, got {len(results)}"
assert str(results[0].config_path).endswith("devcontainer.json")
print("discovery-valid-ok")
def cmd_discovery_invalid() -> None:
"""Skip invalid devcontainer.json."""
with tempfile.TemporaryDirectory() as tmp:
dc_dir = Path(tmp) / ".devcontainer"
dc_dir.mkdir()
(dc_dir / "devcontainer.json").write_text("not valid json {{", encoding="utf-8")
results = discover_devcontainers(tmp, "git-checkout")
assert len(results) == 0, f"Expected 0 results, got {len(results)}"
print("discovery-invalid-ok")
def cmd_discovery_root() -> None:
"""Discover root-level .devcontainer.json."""
with tempfile.TemporaryDirectory() as tmp:
(Path(tmp) / ".devcontainer.json").write_text(_VALID_DC_JSON, encoding="utf-8")
results = discover_devcontainers(tmp, "fs-directory")
assert len(results) == 1, f"Expected 1 result, got {len(results)}"
print("discovery-root-ok")
def cmd_discovery_nontrigger() -> None:
"""No discovery for non-trigger types."""
with tempfile.TemporaryDirectory() as tmp:
dc_dir = Path(tmp) / ".devcontainer"
dc_dir.mkdir()
(dc_dir / "devcontainer.json").write_text(_VALID_DC_JSON, encoding="utf-8")
results = discover_devcontainers(tmp, "fs-file")
assert len(results) == 0, f"Expected 0 results, got {len(results)}"
print("discovery-nontrigger-ok")
def cmd_builtin_types() -> None:
"""Verify devcontainer types in BUILTIN_NAMES."""
names = ResourceTypeSpec.BUILTIN_NAMES
assert "devcontainer-instance" in names
assert "devcontainer-file" in names
assert "container-instance" in names
print("builtin-types-ok")
def cmd_dag_hierarchy() -> None:
"""Validate devcontainer DAG parent/child constraints."""
for type_def in _BUILTIN_TYPES:
name = type_def["name"]
if name == "devcontainer-instance":
assert "git-checkout" in type_def["parent_types"]
assert "fs-directory" in type_def["parent_types"]
assert "devcontainer-file" in type_def["child_types"]
elif name == "devcontainer-file":
assert "devcontainer-instance" in type_def["parent_types"]
assert len(type_def["child_types"]) == 0
elif name == "git-checkout":
assert "devcontainer-instance" in type_def["child_types"]
print("dag-hierarchy-ok")
def cmd_resolver_import() -> None:
"""Verify resolve_handler loads DevcontainerHandler."""
clear_handler_cache()
handler = resolve_handler(
"cleveragents.resource.handlers.devcontainer:DevcontainerHandler"
)
assert isinstance(handler, ResourceHandler)
assert isinstance(handler, DevcontainerHandler)
print("resolver-import-ok")
def cmd_result_validation() -> None:
"""Verify DevcontainerDiscoveryResult validates inputs."""
with tempfile.TemporaryDirectory() as tmp:
dc_file = Path(tmp) / "devcontainer.json"
dc_file.write_text(_VALID_DC_JSON, encoding="utf-8")
# Valid creation
result = DevcontainerDiscoveryResult(
config_path=dc_file,
config_data=json.loads(_VALID_DC_JSON),
parent_location=tmp,
)
assert result.config_path == dc_file
# Empty parent_location should raise
try:
DevcontainerDiscoveryResult(
config_path=dc_file,
config_data=json.loads(_VALID_DC_JSON),
parent_location="",
)
raise AssertionError("Should have raised ValueError")
except ValueError:
pass
print("result-validation-ok")
_COMMANDS = {
"protocol-check": cmd_protocol_check,
"strategy-check": cmd_strategy_check,
"discovery-valid": cmd_discovery_valid,
"discovery-invalid": cmd_discovery_invalid,
"discovery-root": cmd_discovery_root,
"discovery-nontrigger": cmd_discovery_nontrigger,
"builtin-types": cmd_builtin_types,
"dag-hierarchy": cmd_dag_hierarchy,
"resolver-import": cmd_resolver_import,
"result-validation": cmd_result_validation,
}
def main() -> None:
"""Dispatch subcommand from argv."""
if len(sys.argv) < 2:
print(f"Usage: {sys.argv[0]} <command>", file=sys.stderr)
sys.exit(1)
cmd = sys.argv[1]
func = _COMMANDS.get(cmd)
if func is None:
print(f"Unknown command: {cmd}", file=sys.stderr)
sys.exit(1)
func()
if __name__ == "__main__":
main()
@@ -87,7 +87,12 @@ _BUILTIN_TYPES: list[dict[str, Any]] = [
},
],
"parent_types": [],
"child_types": ["fs-directory", "fs-file"],
"child_types": [
"fs-directory",
"fs-file",
"devcontainer-instance",
"devcontainer-file",
],
"handler": "cleveragents.resource.handlers.git_checkout:GitCheckoutHandler",
"capabilities": {
"read": True,
@@ -112,7 +117,12 @@ _BUILTIN_TYPES: list[dict[str, Any]] = [
},
],
"parent_types": ["git-checkout", "fs-directory"],
"child_types": ["fs-directory", "fs-file"],
"child_types": [
"fs-directory",
"fs-file",
"devcontainer-instance",
"devcontainer-file",
],
"handler": "cleveragents.resource.handlers.fs_directory:FsDirectoryHandler",
"capabilities": {
"read": True,
@@ -121,6 +131,88 @@ _BUILTIN_TYPES: list[dict[str, Any]] = [
"checkpoint": False,
},
},
{
"name": "container-instance",
"description": "A container execution environment instance.",
"resource_kind": "physical",
"sandbox_strategy": "snapshot",
"user_addable": True,
"built_in": True,
"cli_args": [
{
"name": "image",
"type": "string",
"required": False,
"description": "Container image reference.",
"default": None,
},
],
"parent_types": ["git-checkout", "fs-directory"],
"child_types": [],
"handler": ("cleveragents.resource.handlers.devcontainer:DevcontainerHandler"),
"capabilities": {
"read": True,
"write": True,
"sandbox": True,
"checkpoint": False,
},
},
{
"name": "devcontainer-instance",
"description": (
"A devcontainer execution environment auto-discovered "
"from .devcontainer/ configuration."
),
"resource_kind": "physical",
"sandbox_strategy": "snapshot",
"user_addable": True,
"built_in": True,
"inherits": "container-instance",
"cli_args": [
{
"name": "path",
"type": "path",
"required": True,
"description": (
"Path to the directory containing .devcontainer/devcontainer.json."
),
},
],
"parent_types": ["git-checkout", "fs-directory"],
"child_types": ["devcontainer-file"],
"auto_discovery": {
"trigger_types": ["git-checkout", "fs-directory"],
"scan_paths": [
".devcontainer/devcontainer.json",
".devcontainer.json",
],
},
"handler": ("cleveragents.resource.handlers.devcontainer:DevcontainerHandler"),
"capabilities": {
"read": True,
"write": True,
"sandbox": True,
"checkpoint": False,
},
},
{
"name": "devcontainer-file",
"description": ("A single devcontainer.json configuration file resource."),
"resource_kind": "physical",
"sandbox_strategy": "copy_on_write",
"user_addable": False,
"built_in": True,
"cli_args": [],
"parent_types": ["devcontainer-instance"],
"child_types": [],
"handler": ("cleveragents.resource.handlers.devcontainer:DevcontainerHandler"),
"capabilities": {
"read": True,
"write": False,
"sandbox": False,
"checkpoint": False,
},
},
]
+10
View File
@@ -442,6 +442,12 @@ def resource_add(
str | None,
typer.Option("--description", "-d", help="Resource description"),
] = None,
image: Annotated[
str | None,
typer.Option(
"--image", help="Container image for container-instance resources"
),
] = None,
read_only: Annotated[
bool,
typer.Option("--read-only", help="Mark resource as read-only"),
@@ -456,6 +462,8 @@ def resource_add(
Examples:
agents resource add git-checkout local/my-repo --path /home/user/repo
agents resource add fs-directory local/data --path /data --read-only
agents resource add devcontainer-instance local/dc --path /project
agents resource add container-instance local/ctr --image ubuntu:latest
"""
try:
service = _get_registry_service()
@@ -466,6 +474,8 @@ def resource_add(
properties["path"] = path
if branch is not None:
properties["branch"] = branch
if image is not None:
properties["image"] = image
resource = service.register_resource(
type_name=type_name,
@@ -243,6 +243,9 @@ class ResourceTypeSpec(BaseModel):
"fs-directory",
"fs-mount",
"fs-file",
"container-instance",
"devcontainer-instance",
"devcontainer-file",
}
)
@@ -14,10 +14,11 @@ which defines a single ``resolve`` method returning a
## Built-in Handlers
| Handler | Resource Type | Sandbox Strategy |
|-----------------------|------------------|------------------|
| ``GitCheckoutHandler``| ``git-checkout`` | ``git_worktree`` |
| ``FsDirectoryHandler``| ``fs-directory`` | ``copy_on_write``|
| Handler | Resource Type | Sandbox Strategy |
|--------------------------|--------------------------|------------------|
| ``GitCheckoutHandler`` | ``git-checkout`` | ``git_worktree`` |
| ``FsDirectoryHandler`` | ``fs-directory`` | ``copy_on_write``|
| ``DevcontainerHandler`` | ``devcontainer-instance``| ``snapshot`` |
## Handler Resolution
@@ -26,6 +27,7 @@ Handler strings stored on :class:`ResourceTypeSpec` use the format
dynamically imports the module and returns an instance.
"""
from cleveragents.resource.handlers.devcontainer import DevcontainerHandler
from cleveragents.resource.handlers.fs_directory import FsDirectoryHandler
from cleveragents.resource.handlers.git_checkout import GitCheckoutHandler
from cleveragents.resource.handlers.protocol import ResourceHandler
@@ -35,6 +37,7 @@ from cleveragents.resource.handlers.resolver import (
)
__all__ = [
"DevcontainerHandler",
"FsDirectoryHandler",
"GitCheckoutHandler",
"HandlerResolutionError",
@@ -0,0 +1,38 @@
"""Devcontainer resource handler for CleverAgents.
Resolves ``devcontainer-instance`` and ``devcontainer-file`` resources
into sandbox-backed :class:`BoundResource` instances using the
``snapshot`` sandbox strategy.
The handler:
1. Validates that the resource has a non-empty ``location``.
2. Determines the sandbox strategy: resource-level override takes
precedence over the default ``snapshot``.
3. Calls :meth:`SandboxManager.get_or_create_sandbox` to provision
(or reuse) an isolated snapshot.
4. Returns a :class:`BoundResource` with ``sandbox_path`` set to the
sandbox root.
Based on:
- docs/specification.md -- Devcontainer, Resource Types
- ADR-043: Devcontainer Integration
- ADR-042: Resource Type Inheritance
"""
from __future__ import annotations
from cleveragents.domain.models.core.resource import SandboxStrategy
from cleveragents.resource.handlers._base import BaseResourceHandler
class DevcontainerHandler(BaseResourceHandler):
"""Handler for ``devcontainer-instance`` and ``devcontainer-file`` types.
Provisions a snapshot sandbox for devcontainer resources.
``devcontainer-instance`` inherits from ``container-instance``
per ADR-042 (resource type inheritance).
"""
_default_strategy = SandboxStrategy.SNAPSHOT
_type_label = "devcontainer"
@@ -0,0 +1,173 @@
"""Devcontainer auto-discovery for CleverAgents.
Scans a resource's location for ``.devcontainer/`` directories and
root-level ``.devcontainer.json`` files. When found, creates child
``devcontainer-instance`` and ``devcontainer-file`` resources in the
resource registry.
Auto-discovery triggers when a ``git-checkout`` or ``fs-directory``
resource is linked to a project. The scanner walks the top level
of the resource location (no recursive descent) and validates each
``devcontainer.json`` before registration.
Based on:
- docs/specification.md -- Devcontainer, Auto-Discovery
- ADR-043: Devcontainer Integration
"""
from __future__ import annotations
import json
import logging
from pathlib import Path
logger = logging.getLogger(__name__)
#: Resource types that trigger devcontainer auto-discovery.
_TRIGGER_TYPES: frozenset[str] = frozenset(
{"git-checkout", "fs-directory"},
)
#: Paths to scan relative to the resource location.
_SCAN_PATHS: tuple[str, ...] = (
".devcontainer/devcontainer.json",
".devcontainer.json",
)
class DevcontainerDiscoveryResult:
"""Describes a discovered devcontainer configuration.
Attributes:
config_path: Absolute path to the ``devcontainer.json`` file.
config_data: Parsed JSON content of the devcontainer config.
parent_location: Location of the parent resource.
"""
__slots__ = ("config_data", "config_path", "parent_location")
def __init__(
self,
*,
config_path: Path,
config_data: dict[str, object],
parent_location: str,
) -> None:
if not isinstance(config_path, Path):
raise TypeError(
f"config_path must be a Path, got {type(config_path).__name__}"
)
if not isinstance(config_data, dict):
raise TypeError(
f"config_data must be a dict, got {type(config_data).__name__}"
)
if not parent_location:
raise ValueError("parent_location must not be empty")
self.config_path = config_path
self.config_data = config_data
self.parent_location = parent_location
def discover_devcontainers(
resource_location: str,
resource_type: str,
) -> list[DevcontainerDiscoveryResult]:
"""Scan a resource location for devcontainer configurations.
Only scans the well-known paths (``.devcontainer/devcontainer.json``
and root ``.devcontainer.json``). Each file is validated as JSON
before being included in the results.
Args:
resource_location: Filesystem path of the parent resource.
resource_type: Type name of the parent resource.
Returns:
List of discovery results, one per valid devcontainer config.
Invalid JSON files are skipped with a warning log.
Raises:
ValueError: If *resource_location* is empty or
*resource_type* is not a trigger type.
"""
if not resource_location:
raise ValueError("resource_location must not be empty")
if not resource_type:
raise ValueError("resource_type must not be empty")
if resource_type not in _TRIGGER_TYPES:
return []
base = Path(resource_location)
if not base.is_dir():
logger.debug(
"Resource location is not a directory, skipping devcontainer discovery: %s",
resource_location,
)
return []
results: list[DevcontainerDiscoveryResult] = []
for relative in _SCAN_PATHS:
candidate = base / relative
if not candidate.is_file():
continue
config_data = _load_devcontainer_json(candidate)
if config_data is None:
continue
results.append(
DevcontainerDiscoveryResult(
config_path=candidate,
config_data=config_data,
parent_location=resource_location,
)
)
return results
def is_trigger_type(resource_type: str) -> bool:
"""Check whether a resource type triggers devcontainer discovery.
Args:
resource_type: The resource type name to check.
Returns:
``True`` if the type triggers auto-discovery.
"""
if not resource_type:
raise ValueError("resource_type must not be empty")
return resource_type in _TRIGGER_TYPES
def _load_devcontainer_json(path: Path) -> dict[str, object] | None:
"""Read and validate a devcontainer.json file.
Args:
path: Absolute path to the JSON file.
Returns:
Parsed dict on success, ``None`` on invalid JSON.
"""
try:
text = path.read_text(encoding="utf-8")
except OSError as exc:
logger.warning("Cannot read devcontainer config %s: %s", path, exc)
return None
try:
data = json.loads(text)
except json.JSONDecodeError as exc:
logger.warning("Invalid JSON in devcontainer config %s: %s", path, exc)
return None
if not isinstance(data, dict):
logger.warning(
"Devcontainer config %s is not a JSON object (got %s)",
path,
type(data).__name__,
)
return None
return data