feat(resource): add devcontainer resource type and auto-discovery handler #520
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user