feat(resource): add handler runtime for git-checkout and fs-directory
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
"""ASV benchmarks for resource handler resolution overhead.
|
||||
|
||||
Measures the performance of:
|
||||
- Handler resolver (import + cache lookup)
|
||||
- GitCheckoutHandler.resolve() with mock sandbox
|
||||
- FsDirectoryHandler.resolve() with mock sandbox
|
||||
- ResourceHandlerService.resolve_binding() end-to-end
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, PropertyMock
|
||||
|
||||
try:
|
||||
from cleveragents.domain.models.core.resource import (
|
||||
PhysVirt,
|
||||
Resource,
|
||||
ResourceCapabilities,
|
||||
)
|
||||
from cleveragents.domain.models.core.resource_slot import BindingResult
|
||||
from cleveragents.domain.models.core.resource_type import (
|
||||
ResourceKind,
|
||||
ResourceTypeSpec,
|
||||
)
|
||||
from cleveragents.domain.models.core.resource_type import (
|
||||
SandboxStrategy as TypeSandboxStrategy,
|
||||
)
|
||||
from cleveragents.infrastructure.sandbox.factory import SandboxFactory
|
||||
from cleveragents.infrastructure.sandbox.manager import SandboxManager
|
||||
from cleveragents.infrastructure.sandbox.protocol import (
|
||||
SandboxContext,
|
||||
SandboxStatus,
|
||||
)
|
||||
from cleveragents.resource.handlers.fs_directory import FsDirectoryHandler
|
||||
from cleveragents.resource.handlers.git_checkout import GitCheckoutHandler
|
||||
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.domain.models.core.resource import (
|
||||
PhysVirt,
|
||||
Resource,
|
||||
ResourceCapabilities,
|
||||
)
|
||||
from cleveragents.domain.models.core.resource_slot import BindingResult
|
||||
from cleveragents.domain.models.core.resource_type import (
|
||||
ResourceKind,
|
||||
ResourceTypeSpec,
|
||||
)
|
||||
from cleveragents.domain.models.core.resource_type import (
|
||||
SandboxStrategy as TypeSandboxStrategy,
|
||||
)
|
||||
from cleveragents.infrastructure.sandbox.factory import SandboxFactory
|
||||
from cleveragents.infrastructure.sandbox.manager import SandboxManager
|
||||
from cleveragents.infrastructure.sandbox.protocol import (
|
||||
SandboxContext,
|
||||
SandboxStatus,
|
||||
)
|
||||
from cleveragents.resource.handlers.fs_directory import FsDirectoryHandler
|
||||
from cleveragents.resource.handlers.git_checkout import GitCheckoutHandler
|
||||
from cleveragents.resource.handlers.resolver import (
|
||||
clear_handler_cache,
|
||||
resolve_handler,
|
||||
)
|
||||
|
||||
|
||||
def _make_resource(rtype: str, location: str) -> Resource:
|
||||
return Resource(
|
||||
resource_id="01KJ5C5TPMP8GGX3QC83E2MAQS",
|
||||
resource_type_name=rtype,
|
||||
classification=PhysVirt.PHYSICAL,
|
||||
location=location,
|
||||
capabilities=ResourceCapabilities(
|
||||
readable=True, writable=True, sandboxable=True
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _make_mock_manager() -> SandboxManager:
|
||||
mock_factory = MagicMock(spec=SandboxFactory)
|
||||
mock_sandbox = MagicMock()
|
||||
mock_sandbox.sandbox_id = "sb-bench-001"
|
||||
type(mock_sandbox).status = PropertyMock(return_value=SandboxStatus.CREATED)
|
||||
mock_sandbox.context = SandboxContext(
|
||||
sandbox_id="sb-bench-001",
|
||||
sandbox_path="/tmp/sandbox/sb-bench-001",
|
||||
original_path="/tmp/original",
|
||||
resource_id="res-bench",
|
||||
plan_id="plan-bench",
|
||||
created_at=datetime.now(),
|
||||
)
|
||||
mock_sandbox.create.return_value = mock_sandbox.context
|
||||
mock_factory.create_sandbox.return_value = mock_sandbox
|
||||
return SandboxManager(factory=mock_factory, cleanup_on_exit=False)
|
||||
|
||||
|
||||
class HandlerResolverSuite:
|
||||
"""Benchmark handler resolver import and cache performance."""
|
||||
|
||||
def time_resolve_git_handler_cold(self) -> None:
|
||||
"""Benchmark cold resolve (no cache)."""
|
||||
clear_handler_cache()
|
||||
resolve_handler(
|
||||
"cleveragents.resource.handlers.git_checkout:GitCheckoutHandler"
|
||||
)
|
||||
|
||||
def time_resolve_git_handler_cached(self) -> None:
|
||||
"""Benchmark cached resolve."""
|
||||
resolve_handler(
|
||||
"cleveragents.resource.handlers.git_checkout:GitCheckoutHandler"
|
||||
)
|
||||
|
||||
def time_resolve_fs_handler_cold(self) -> None:
|
||||
"""Benchmark cold resolve for fs-directory."""
|
||||
clear_handler_cache()
|
||||
resolve_handler(
|
||||
"cleveragents.resource.handlers.fs_directory:FsDirectoryHandler"
|
||||
)
|
||||
|
||||
|
||||
class GitCheckoutHandlerSuite:
|
||||
"""Benchmark GitCheckoutHandler.resolve() overhead."""
|
||||
|
||||
def setup(self) -> None:
|
||||
self.resource = _make_resource("git-checkout", "/tmp/bench-repo")
|
||||
self.manager = _make_mock_manager()
|
||||
self.handler = GitCheckoutHandler()
|
||||
|
||||
def time_resolve_git_checkout(self) -> None:
|
||||
"""Benchmark single git-checkout resolution."""
|
||||
self.handler.resolve(
|
||||
resource=self.resource,
|
||||
plan_id="PLAN_BENCH",
|
||||
slot_name="repo",
|
||||
sandbox_manager=self.manager,
|
||||
)
|
||||
|
||||
|
||||
class FsDirectoryHandlerSuite:
|
||||
"""Benchmark FsDirectoryHandler.resolve() overhead."""
|
||||
|
||||
def setup(self) -> None:
|
||||
self.resource = _make_resource("fs-directory", "/tmp/bench-dir")
|
||||
self.manager = _make_mock_manager()
|
||||
self.handler = FsDirectoryHandler()
|
||||
|
||||
def time_resolve_fs_directory(self) -> None:
|
||||
"""Benchmark single fs-directory resolution."""
|
||||
self.handler.resolve(
|
||||
resource=self.resource,
|
||||
plan_id="PLAN_BENCH",
|
||||
slot_name="workdir",
|
||||
sandbox_manager=self.manager,
|
||||
)
|
||||
|
||||
|
||||
class ResourceHandlerServiceSuite:
|
||||
"""Benchmark ResourceHandlerService end-to-end resolution."""
|
||||
|
||||
def setup(self) -> None:
|
||||
from cleveragents.application.services.resource_handler_service import (
|
||||
ResourceHandlerService,
|
||||
)
|
||||
|
||||
self.resource = _make_resource("git-checkout", "/tmp/bench-repo")
|
||||
self.type_spec = ResourceTypeSpec(
|
||||
name="git-checkout",
|
||||
description="Test git-checkout type",
|
||||
resource_kind=ResourceKind.PHYSICAL,
|
||||
sandbox_strategy=TypeSandboxStrategy.GIT_WORKTREE,
|
||||
user_addable=True,
|
||||
built_in=True,
|
||||
handler="cleveragents.resource.handlers.git_checkout:GitCheckoutHandler",
|
||||
)
|
||||
self.manager = _make_mock_manager()
|
||||
self.service = ResourceHandlerService(
|
||||
sandbox_manager=self.manager,
|
||||
resource_lookup=lambda _: self.resource,
|
||||
type_lookup=lambda _: self.type_spec,
|
||||
)
|
||||
self.binding = BindingResult(
|
||||
slot_name="repo",
|
||||
resource_id="01KJ5C5TPMP8GGX3QC83E2MAQS",
|
||||
resource_name="test-repo",
|
||||
binding_mode="contextual",
|
||||
)
|
||||
|
||||
def time_resolve_binding(self) -> None:
|
||||
"""Benchmark single binding resolution."""
|
||||
self.service.resolve_binding(
|
||||
binding=self.binding,
|
||||
plan_id="PLAN_BENCH",
|
||||
)
|
||||
|
||||
def time_resolve_bindings_batch(self) -> None:
|
||||
"""Benchmark batch binding resolution (5 bindings)."""
|
||||
bindings = [
|
||||
BindingResult(
|
||||
slot_name=f"slot-{i}",
|
||||
resource_id="01KJ5C5TPMP8GGX3QC83E2MAQS",
|
||||
resource_name=f"res-{i}",
|
||||
binding_mode="contextual",
|
||||
)
|
||||
for i in range(5)
|
||||
]
|
||||
self.service.resolve_bindings(
|
||||
bindings=bindings,
|
||||
plan_id="PLAN_BENCH",
|
||||
)
|
||||
@@ -0,0 +1,172 @@
|
||||
# Resource Handlers
|
||||
|
||||
Resource handlers bridge resource types to sandbox provisioning. When a plan
|
||||
executes, each tool's resource slot is resolved to a physical sandbox path
|
||||
through the handler pipeline.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
BindingResolutionService ResourceHandlerService
|
||||
resolve(tool, project) resolve_binding(binding, plan_id)
|
||||
│ │
|
||||
▼ ▼
|
||||
BindingResult(resource_id) ┌── Resource (location)
|
||||
│ ResourceTypeSpec (handler, strategy)
|
||||
│ │
|
||||
│ ▼
|
||||
│ resolve_handler("module:Class")
|
||||
│ │
|
||||
│ ▼
|
||||
│ handler.resolve(resource, sandbox_manager)
|
||||
│ │
|
||||
│ ▼
|
||||
└── BoundResource(sandbox_path="/tmp/sandbox/...")
|
||||
```
|
||||
|
||||
## Handler Protocol
|
||||
|
||||
All handlers implement the `ResourceHandler` protocol:
|
||||
|
||||
```python
|
||||
class ResourceHandler(Protocol):
|
||||
def resolve(
|
||||
self,
|
||||
*,
|
||||
resource: Resource,
|
||||
plan_id: str,
|
||||
slot_name: str,
|
||||
sandbox_manager: SandboxManager,
|
||||
access: str = "read_only",
|
||||
) -> BoundResource: ...
|
||||
```
|
||||
|
||||
## Built-in Handlers
|
||||
|
||||
### GitCheckoutHandler
|
||||
|
||||
Resolves `git-checkout` resources using the `git_worktree` sandbox strategy.
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Module | `cleveragents.resource.handlers.git_checkout` |
|
||||
| Class | `GitCheckoutHandler` |
|
||||
| Default strategy | `git_worktree` |
|
||||
| Fallback strategy | `copy_on_write` |
|
||||
| Required fields | `resource.location` (path to git repo root) |
|
||||
|
||||
### FsDirectoryHandler
|
||||
|
||||
Resolves `fs-directory` resources using the `copy_on_write` sandbox strategy.
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Module | `cleveragents.resource.handlers.fs_directory` |
|
||||
| Class | `FsDirectoryHandler` |
|
||||
| Default strategy | `copy_on_write` |
|
||||
| Required fields | `resource.location` (path to directory) |
|
||||
|
||||
## Handler Resolution
|
||||
|
||||
Handler strings use the `module.path:ClassName` format and are stored on
|
||||
`ResourceTypeSpec.handler`. Resolution is dynamic via `importlib`:
|
||||
|
||||
```python
|
||||
from cleveragents.resource.handlers import resolve_handler
|
||||
|
||||
handler = resolve_handler(
|
||||
"cleveragents.resource.handlers.git_checkout:GitCheckoutHandler"
|
||||
)
|
||||
```
|
||||
|
||||
Resolved handlers are cached for the process lifetime. Call
|
||||
`clear_handler_cache()` to reset.
|
||||
|
||||
### Fallback Behavior
|
||||
|
||||
If no handler string is set (or resolution fails), the
|
||||
`ResourceHandlerService` uses a default handler that delegates directly
|
||||
to `SandboxManager` using the type's `sandbox_strategy`.
|
||||
|
||||
## Strategy Precedence
|
||||
|
||||
The sandbox strategy is determined in this order:
|
||||
|
||||
1. **Resource-level override** (`resource.sandbox_strategy`) — per-resource
|
||||
2. **Type default** (`ResourceTypeSpec.sandbox_strategy`) — per-type
|
||||
3. **Fallback** (`none`) — no sandboxing
|
||||
|
||||
## ResourceHandlerService
|
||||
|
||||
The orchestration service that chains the resolution pipeline:
|
||||
|
||||
```python
|
||||
from cleveragents.application.services.resource_handler_service import (
|
||||
ResourceHandlerService,
|
||||
)
|
||||
|
||||
service = ResourceHandlerService(
|
||||
sandbox_manager=sandbox_manager,
|
||||
resource_lookup=registry_service.show_resource,
|
||||
type_lookup=registry_service.show_type,
|
||||
)
|
||||
|
||||
# Resolve a single binding
|
||||
bound = service.resolve_binding(binding, plan_id="01ARZ3...")
|
||||
|
||||
# Resolve all bindings for a tool
|
||||
bindings_map = service.resolve_bindings(bindings, plan_id="01ARZ3...")
|
||||
# -> {"repo": BoundResource(sandbox_path="/tmp/sandbox/...")}
|
||||
```
|
||||
|
||||
## Sandbox Outputs
|
||||
|
||||
After resolution, each `BoundResource` carries:
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `slot_name` | Tool slot this binding fills |
|
||||
| `resource_id` | ULID of the resolved resource |
|
||||
| `resource_type` | Type name (e.g. `git-checkout`) |
|
||||
| `sandbox_path` | Root path of the provisioned sandbox |
|
||||
| `access` | `read_only` or `read_write` |
|
||||
|
||||
The `sandbox_path` points to an isolated directory managed by
|
||||
`SandboxManager`. Changes are committed or rolled back via
|
||||
`SandboxManager.commit_all()` / `rollback_all()`.
|
||||
|
||||
## Writing Custom Handlers
|
||||
|
||||
To add a handler for a new resource type:
|
||||
|
||||
1. Create a module under `cleveragents/resource/handlers/`.
|
||||
2. Implement a class satisfying `ResourceHandler`.
|
||||
3. Register it on the `ResourceTypeSpec` via the `handler` field:
|
||||
`"cleveragents.resource.handlers.my_handler:MyHandler"`.
|
||||
|
||||
```python
|
||||
class MyHandler:
|
||||
def resolve(
|
||||
self,
|
||||
*,
|
||||
resource: Resource,
|
||||
plan_id: str,
|
||||
slot_name: str,
|
||||
sandbox_manager: SandboxManager,
|
||||
access: str = "read_only",
|
||||
) -> BoundResource:
|
||||
# Custom validation / setup
|
||||
sandbox = sandbox_manager.get_or_create_sandbox(
|
||||
plan_id=plan_id,
|
||||
resource_id=resource.resource_id,
|
||||
original_path=resource.location,
|
||||
sandbox_strategy="copy_on_write",
|
||||
)
|
||||
return BoundResource(
|
||||
slot_name=slot_name,
|
||||
resource_id=resource.resource_id,
|
||||
resource_type=resource.resource_type_name,
|
||||
sandbox_path=sandbox.context.sandbox_path if sandbox.context else "",
|
||||
access=access,
|
||||
)
|
||||
```
|
||||
@@ -0,0 +1,124 @@
|
||||
Feature: Resource handler runtime
|
||||
As a developer
|
||||
I want resource handlers that resolve resources into sandbox-backed paths
|
||||
So that tools receive isolated working directories during plan execution
|
||||
|
||||
# === Handler Protocol ===
|
||||
|
||||
Scenario: GitCheckoutHandler satisfies ResourceHandler protocol
|
||||
Then GitCheckoutHandler should satisfy the ResourceHandler protocol
|
||||
|
||||
Scenario: FsDirectoryHandler satisfies ResourceHandler protocol
|
||||
Then FsDirectoryHandler should satisfy the ResourceHandler protocol
|
||||
|
||||
# === GitCheckoutHandler ===
|
||||
|
||||
Scenario: GitCheckoutHandler resolves a git-checkout resource
|
||||
Given a git-checkout resource with location "/tmp/test-repo"
|
||||
And a sandbox manager with a mock factory
|
||||
When I resolve the resource with GitCheckoutHandler for plan "PLAN001"
|
||||
Then the bound resource should have slot name "repo"
|
||||
And the bound resource should have resource type "git-checkout"
|
||||
And the bound resource sandbox path should not be empty
|
||||
|
||||
Scenario: GitCheckoutHandler uses resource-level strategy override
|
||||
Given a git-checkout resource at "/tmp/test-repo" using strategy "copy_on_write"
|
||||
And a sandbox manager with a mock factory
|
||||
When I resolve the resource with GitCheckoutHandler for plan "PLAN002"
|
||||
Then the sandbox was created with strategy "copy_on_write"
|
||||
|
||||
Scenario: GitCheckoutHandler rejects resource without location
|
||||
Given a git-checkout resource without location
|
||||
And a sandbox manager with a mock factory
|
||||
When I try to resolve the resource with GitCheckoutHandler
|
||||
Then a handler ValueError should be raised
|
||||
And the handler error should mention "no location"
|
||||
|
||||
# === FsDirectoryHandler ===
|
||||
|
||||
Scenario: FsDirectoryHandler resolves an fs-directory resource
|
||||
Given an fs-directory resource with location "/tmp/test-dir"
|
||||
And a sandbox manager with a mock factory
|
||||
When I resolve the resource with FsDirectoryHandler for plan "PLAN003"
|
||||
Then the bound resource should have slot name "workdir"
|
||||
And the bound resource should have resource type "fs-directory"
|
||||
And the bound resource sandbox path should not be empty
|
||||
|
||||
Scenario: FsDirectoryHandler uses copy_on_write by default
|
||||
Given an fs-directory resource with location "/tmp/test-dir"
|
||||
And a sandbox manager with a mock factory
|
||||
When I resolve the resource with FsDirectoryHandler for plan "PLAN004"
|
||||
Then the sandbox was created with strategy "copy_on_write"
|
||||
|
||||
Scenario: FsDirectoryHandler rejects resource without location
|
||||
Given an fs-directory resource without location
|
||||
And a sandbox manager with a mock factory
|
||||
When I try to resolve the resource with FsDirectoryHandler
|
||||
Then a handler ValueError should be raised
|
||||
|
||||
# === Handler Resolver ===
|
||||
|
||||
Scenario: Resolve handler from valid module:class string
|
||||
When I resolve handler "cleveragents.resource.handlers.git_checkout:GitCheckoutHandler"
|
||||
Then the resolved handler should be a GitCheckoutHandler instance
|
||||
|
||||
Scenario: Resolve handler from fs-directory reference
|
||||
When I resolve handler "cleveragents.resource.handlers.fs_directory:FsDirectoryHandler"
|
||||
Then the resolved handler should be a FsDirectoryHandler instance
|
||||
|
||||
Scenario: Resolve handler caches instances
|
||||
When I resolve handler "cleveragents.resource.handlers.git_checkout:GitCheckoutHandler"
|
||||
And I resolve the same handler reference again
|
||||
Then both resolved handlers should be the same object
|
||||
|
||||
Scenario: Resolve handler rejects empty string
|
||||
When I try to resolve an empty handler reference
|
||||
Then a HandlerResolutionError should be raised
|
||||
And the resolution error should mention "empty"
|
||||
|
||||
Scenario: Resolve handler rejects missing colon separator
|
||||
When I try to resolve handler "cleveragents.resource.handlers.git_checkout"
|
||||
Then a HandlerResolutionError should be raised
|
||||
And the resolution error should mention "module.path:ClassName"
|
||||
|
||||
Scenario: Resolve handler rejects nonexistent module
|
||||
When I try to resolve handler "nonexistent.module:SomeClass"
|
||||
Then a HandlerResolutionError should be raised
|
||||
And the resolution error should mention "Cannot import"
|
||||
|
||||
Scenario: Resolve handler rejects nonexistent class in valid module
|
||||
When I try to resolve handler "cleveragents.resource.handlers.git_checkout:NonExistentClass"
|
||||
Then a HandlerResolutionError should be raised
|
||||
And the resolution error should mention "not found"
|
||||
|
||||
# === ResourceHandlerService ===
|
||||
|
||||
Scenario: ResourceHandlerService resolves a binding to BoundResource
|
||||
Given a resource handler service with mock lookups
|
||||
And a binding result for slot "repo" with resource id "RES001"
|
||||
When I resolve the binding via resource handler service for plan "PLAN010"
|
||||
Then the handler service should return a BoundResource
|
||||
And the handler service BoundResource slot should be "repo"
|
||||
And the handler service BoundResource sandbox path should not be empty
|
||||
|
||||
Scenario: ResourceHandlerService skips deferred bindings
|
||||
Given a resource handler service with mock lookups
|
||||
And a deferred binding result for slot "extra"
|
||||
And a binding result for slot "repo" with resource id "RES001"
|
||||
When I resolve all bindings via resource handler service for plan "PLAN011"
|
||||
Then the handler service should return 1 bound resource
|
||||
And the handler service should have resolved slot "repo"
|
||||
|
||||
Scenario: ResourceHandlerService rejects deferred single binding
|
||||
Given a resource handler service with mock lookups
|
||||
And a deferred binding result for slot "extra"
|
||||
When I try to resolve the single deferred binding via resource handler service
|
||||
Then a handler ValueError should be raised
|
||||
And the handler error should mention "deferred"
|
||||
|
||||
Scenario: ResourceHandlerService uses fallback handler when type has no handler string
|
||||
Given a resource handler service with mock lookups and no handler string
|
||||
And a binding result for slot "data" with resource id "RES002"
|
||||
When I resolve the binding via resource handler service for plan "PLAN012"
|
||||
Then the handler service should return a BoundResource
|
||||
And the handler service BoundResource sandbox path should not be empty
|
||||
@@ -0,0 +1,460 @@
|
||||
"""Step definitions for resource_handlers.feature.
|
||||
|
||||
Tests the resource handler protocol, GitCheckoutHandler, FsDirectoryHandler,
|
||||
handler resolver, and ResourceHandlerService orchestration bridge.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, PropertyMock
|
||||
|
||||
from behave import given, then, when # type: ignore[import-untyped]
|
||||
|
||||
from cleveragents.domain.models.core.resource import (
|
||||
PhysVirt,
|
||||
Resource,
|
||||
ResourceCapabilities,
|
||||
SandboxStrategy,
|
||||
)
|
||||
from cleveragents.domain.models.core.resource_slot import BindingResult
|
||||
from cleveragents.domain.models.core.resource_type import (
|
||||
ResourceKind,
|
||||
ResourceTypeSpec,
|
||||
)
|
||||
from cleveragents.domain.models.core.resource_type import (
|
||||
SandboxStrategy as TypeSandboxStrategy,
|
||||
)
|
||||
from cleveragents.infrastructure.sandbox.factory import SandboxFactory
|
||||
from cleveragents.infrastructure.sandbox.manager import SandboxManager
|
||||
from cleveragents.infrastructure.sandbox.protocol import (
|
||||
SandboxContext,
|
||||
SandboxStatus,
|
||||
)
|
||||
from cleveragents.resource.handlers.fs_directory import FsDirectoryHandler
|
||||
from cleveragents.resource.handlers.git_checkout import GitCheckoutHandler
|
||||
from cleveragents.resource.handlers.protocol import ResourceHandler
|
||||
from cleveragents.resource.handlers.resolver import (
|
||||
HandlerResolutionError,
|
||||
clear_handler_cache,
|
||||
resolve_handler,
|
||||
)
|
||||
from cleveragents.tool.context import BoundResource
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_resource(
|
||||
resource_type_name: str,
|
||||
location: str | None = None,
|
||||
sandbox_strategy: SandboxStrategy | None = None,
|
||||
resource_id: str = "01KJ5C5TPMP8GGX3QC83E2MAQS",
|
||||
) -> Resource:
|
||||
"""Create a test Resource domain object."""
|
||||
return Resource(
|
||||
resource_id=resource_id,
|
||||
resource_type_name=resource_type_name,
|
||||
classification=PhysVirt.PHYSICAL,
|
||||
location=location,
|
||||
sandbox_strategy=sandbox_strategy,
|
||||
capabilities=ResourceCapabilities(
|
||||
readable=True, writable=True, sandboxable=True
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _make_mock_sandbox_manager() -> tuple[SandboxManager, MagicMock]:
|
||||
"""Create a SandboxManager with a mock factory that tracks calls."""
|
||||
mock_factory = MagicMock(spec=SandboxFactory)
|
||||
|
||||
mock_sandbox = MagicMock()
|
||||
mock_sandbox.sandbox_id = "sb-test-001"
|
||||
type(mock_sandbox).status = PropertyMock(return_value=SandboxStatus.CREATED)
|
||||
mock_sandbox.context = SandboxContext(
|
||||
sandbox_id="sb-test-001",
|
||||
sandbox_path="/tmp/sandbox/sb-test-001",
|
||||
original_path="/tmp/original",
|
||||
resource_id="res-test",
|
||||
plan_id="plan-test",
|
||||
created_at=datetime.now(),
|
||||
)
|
||||
mock_sandbox.create.return_value = mock_sandbox.context
|
||||
|
||||
mock_factory.create_sandbox.return_value = mock_sandbox
|
||||
|
||||
manager = SandboxManager(factory=mock_factory, cleanup_on_exit=False)
|
||||
return manager, mock_factory
|
||||
|
||||
|
||||
def _make_type_spec(
|
||||
name: str = "git-checkout",
|
||||
sandbox_strategy: str = "git_worktree",
|
||||
handler: str
|
||||
| None = "cleveragents.resource.handlers.git_checkout:GitCheckoutHandler",
|
||||
) -> ResourceTypeSpec:
|
||||
"""Create a minimal ResourceTypeSpec for testing."""
|
||||
return ResourceTypeSpec(
|
||||
name=name,
|
||||
description=f"Test {name} type",
|
||||
resource_kind=ResourceKind.PHYSICAL,
|
||||
sandbox_strategy=TypeSandboxStrategy(sandbox_strategy),
|
||||
user_addable=True,
|
||||
built_in=True,
|
||||
handler=handler,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Protocol conformance
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then("GitCheckoutHandler should satisfy the ResourceHandler protocol")
|
||||
def step_git_handler_protocol(context: Any) -> None:
|
||||
handler = GitCheckoutHandler()
|
||||
assert isinstance(handler, ResourceHandler), (
|
||||
"GitCheckoutHandler does not satisfy ResourceHandler protocol"
|
||||
)
|
||||
|
||||
|
||||
@then("FsDirectoryHandler should satisfy the ResourceHandler protocol")
|
||||
def step_fs_handler_protocol(context: Any) -> None:
|
||||
handler = FsDirectoryHandler()
|
||||
assert isinstance(handler, ResourceHandler), (
|
||||
"FsDirectoryHandler does not satisfy ResourceHandler protocol"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GitCheckoutHandler
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given('a git-checkout resource with location "{location}"')
|
||||
def step_git_resource(context: Any, location: str) -> None:
|
||||
context.handler_resource = _make_resource("git-checkout", location=location)
|
||||
|
||||
|
||||
@given('a git-checkout resource at "{location}" using strategy "{strategy}"')
|
||||
def step_git_resource_with_strategy(context: Any, location: str, strategy: str) -> None:
|
||||
context.handler_resource = _make_resource(
|
||||
"git-checkout",
|
||||
location=location,
|
||||
sandbox_strategy=SandboxStrategy(strategy),
|
||||
)
|
||||
|
||||
|
||||
@given("a git-checkout resource without location")
|
||||
def step_git_resource_no_location(context: Any) -> None:
|
||||
context.handler_resource = _make_resource("git-checkout", location=None)
|
||||
|
||||
|
||||
@given("a sandbox manager with a mock factory")
|
||||
def step_mock_sandbox_manager(context: Any) -> None:
|
||||
context.handler_sandbox_manager, context.handler_mock_factory = (
|
||||
_make_mock_sandbox_manager()
|
||||
)
|
||||
|
||||
|
||||
@when('I resolve the resource with GitCheckoutHandler for plan "{plan_id}"')
|
||||
def step_resolve_git(context: Any, plan_id: str) -> None:
|
||||
handler = GitCheckoutHandler()
|
||||
context.handler_bound = handler.resolve(
|
||||
resource=context.handler_resource,
|
||||
plan_id=plan_id,
|
||||
slot_name="repo",
|
||||
sandbox_manager=context.handler_sandbox_manager,
|
||||
)
|
||||
|
||||
|
||||
@when("I try to resolve the resource with GitCheckoutHandler")
|
||||
def step_try_resolve_git(context: Any) -> None:
|
||||
handler = GitCheckoutHandler()
|
||||
try:
|
||||
handler.resolve(
|
||||
resource=context.handler_resource,
|
||||
plan_id="PLAN_ERR",
|
||||
slot_name="repo",
|
||||
sandbox_manager=context.handler_sandbox_manager,
|
||||
)
|
||||
context.handler_error = None
|
||||
except ValueError as exc:
|
||||
context.handler_error = exc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FsDirectoryHandler
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given('an fs-directory resource with location "{location}"')
|
||||
def step_fs_resource(context: Any, location: str) -> None:
|
||||
context.handler_resource = _make_resource("fs-directory", location=location)
|
||||
|
||||
|
||||
@given("an fs-directory resource without location")
|
||||
def step_fs_resource_no_location(context: Any) -> None:
|
||||
context.handler_resource = _make_resource("fs-directory", location=None)
|
||||
|
||||
|
||||
@when('I resolve the resource with FsDirectoryHandler for plan "{plan_id}"')
|
||||
def step_resolve_fs(context: Any, plan_id: str) -> None:
|
||||
handler = FsDirectoryHandler()
|
||||
context.handler_bound = handler.resolve(
|
||||
resource=context.handler_resource,
|
||||
plan_id=plan_id,
|
||||
slot_name="workdir",
|
||||
sandbox_manager=context.handler_sandbox_manager,
|
||||
)
|
||||
|
||||
|
||||
@when("I try to resolve the resource with FsDirectoryHandler")
|
||||
def step_try_resolve_fs(context: Any) -> None:
|
||||
handler = FsDirectoryHandler()
|
||||
try:
|
||||
handler.resolve(
|
||||
resource=context.handler_resource,
|
||||
plan_id="PLAN_ERR",
|
||||
slot_name="workdir",
|
||||
sandbox_manager=context.handler_sandbox_manager,
|
||||
)
|
||||
context.handler_error = None
|
||||
except ValueError as exc:
|
||||
context.handler_error = exc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared handler assertions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then('the bound resource should have slot name "{name}"')
|
||||
def step_bound_slot(context: Any, name: str) -> None:
|
||||
assert context.handler_bound.slot_name == name
|
||||
|
||||
|
||||
@then('the bound resource should have resource type "{rtype}"')
|
||||
def step_bound_type(context: Any, rtype: str) -> None:
|
||||
assert context.handler_bound.resource_type == rtype
|
||||
|
||||
|
||||
@then("the bound resource sandbox path should not be empty")
|
||||
def step_bound_sandbox_not_empty(context: Any) -> None:
|
||||
assert context.handler_bound.sandbox_path, (
|
||||
f"sandbox_path is empty: {context.handler_bound.sandbox_path!r}"
|
||||
)
|
||||
|
||||
|
||||
@then('the sandbox was created with strategy "{strategy}"')
|
||||
def step_sandbox_strategy(context: Any, strategy: str) -> None:
|
||||
call_args = context.handler_mock_factory.create_sandbox.call_args
|
||||
assert call_args is not None, "create_sandbox was not called"
|
||||
assert call_args.kwargs.get("sandbox_strategy") == strategy or (
|
||||
len(call_args.args) >= 3 and call_args.args[2] == strategy
|
||||
), f"Expected strategy {strategy}, got {call_args}"
|
||||
|
||||
|
||||
@then("a handler ValueError should be raised")
|
||||
def step_handler_value_error(context: Any) -> None:
|
||||
assert context.handler_error is not None
|
||||
assert isinstance(context.handler_error, ValueError)
|
||||
|
||||
|
||||
@then('the handler error should mention "{text}"')
|
||||
def step_handler_error_text(context: Any, text: str) -> None:
|
||||
assert text.lower() in str(context.handler_error).lower(), (
|
||||
f"Expected '{text}' in error: {context.handler_error}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Handler Resolver
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when('I resolve handler "{ref}"')
|
||||
def step_resolve_handler(context: Any, ref: str) -> None:
|
||||
clear_handler_cache()
|
||||
context.handler_resolved = resolve_handler(ref)
|
||||
context.handler_ref_used = ref
|
||||
|
||||
|
||||
@when("I resolve the same handler reference again")
|
||||
def step_resolve_handler_again(context: Any) -> None:
|
||||
context.handler_resolved_second = resolve_handler(context.handler_ref_used)
|
||||
|
||||
|
||||
@then("the resolved handler should be a GitCheckoutHandler instance")
|
||||
def step_resolved_is_git(context: Any) -> None:
|
||||
assert isinstance(context.handler_resolved, GitCheckoutHandler)
|
||||
|
||||
|
||||
@then("the resolved handler should be a FsDirectoryHandler instance")
|
||||
def step_resolved_is_fs(context: Any) -> None:
|
||||
assert isinstance(context.handler_resolved, FsDirectoryHandler)
|
||||
|
||||
|
||||
@then("both resolved handlers should be the same object")
|
||||
def step_same_object(context: Any) -> None:
|
||||
assert context.handler_resolved is context.handler_resolved_second
|
||||
|
||||
|
||||
@when("I try to resolve an empty handler reference")
|
||||
def step_try_resolve_empty_handler(context: Any) -> None:
|
||||
clear_handler_cache()
|
||||
try:
|
||||
resolve_handler("")
|
||||
context.handler_resolution_error = None
|
||||
except HandlerResolutionError as exc:
|
||||
context.handler_resolution_error = exc
|
||||
|
||||
|
||||
@when('I try to resolve handler "{ref}"')
|
||||
def step_try_resolve_handler(context: Any, ref: str) -> None:
|
||||
clear_handler_cache()
|
||||
try:
|
||||
resolve_handler(ref)
|
||||
context.handler_resolution_error = None
|
||||
except HandlerResolutionError as exc:
|
||||
context.handler_resolution_error = exc
|
||||
|
||||
|
||||
@then("a HandlerResolutionError should be raised")
|
||||
def step_resolution_error(context: Any) -> None:
|
||||
assert context.handler_resolution_error is not None
|
||||
assert isinstance(context.handler_resolution_error, HandlerResolutionError)
|
||||
|
||||
|
||||
@then('the resolution error should mention "{text}"')
|
||||
def step_resolution_error_text(context: Any, text: str) -> None:
|
||||
assert text.lower() in str(context.handler_resolution_error).lower(), (
|
||||
f"Expected '{text}' in error: {context.handler_resolution_error}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ResourceHandlerService
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a resource handler service with mock lookups")
|
||||
def step_handler_service(context: Any) -> None:
|
||||
from cleveragents.application.services.resource_handler_service import (
|
||||
ResourceHandlerService,
|
||||
)
|
||||
|
||||
manager, _mock_factory = _make_mock_sandbox_manager()
|
||||
resource = _make_resource("git-checkout", location="/tmp/test-repo")
|
||||
type_spec = _make_type_spec()
|
||||
|
||||
context.handler_svc = ResourceHandlerService(
|
||||
sandbox_manager=manager,
|
||||
resource_lookup=lambda _name_or_id: resource,
|
||||
type_lookup=lambda _name: type_spec,
|
||||
)
|
||||
context.handler_svc_manager = manager
|
||||
|
||||
|
||||
@given("a resource handler service with mock lookups and no handler string")
|
||||
def step_handler_service_no_handler(context: Any) -> None:
|
||||
from cleveragents.application.services.resource_handler_service import (
|
||||
ResourceHandlerService,
|
||||
)
|
||||
|
||||
manager, _mock_factory = _make_mock_sandbox_manager()
|
||||
resource = _make_resource("fs-directory", location="/tmp/test-dir")
|
||||
type_spec = _make_type_spec(
|
||||
name="fs-directory",
|
||||
sandbox_strategy="copy_on_write",
|
||||
handler=None,
|
||||
)
|
||||
|
||||
context.handler_svc = ResourceHandlerService(
|
||||
sandbox_manager=manager,
|
||||
resource_lookup=lambda _name_or_id: resource,
|
||||
type_lookup=lambda _name: type_spec,
|
||||
)
|
||||
|
||||
|
||||
@given('a binding result for slot "{slot}" with resource id "{res_id}"')
|
||||
def step_binding_result(context: Any, slot: str, res_id: str) -> None:
|
||||
if not hasattr(context, "handler_bindings"):
|
||||
context.handler_bindings = []
|
||||
context.handler_binding = BindingResult(
|
||||
slot_name=slot,
|
||||
resource_id=res_id,
|
||||
resource_name=f"test-{res_id}",
|
||||
binding_mode="contextual",
|
||||
)
|
||||
context.handler_bindings.append(context.handler_binding)
|
||||
|
||||
|
||||
@given('a deferred binding result for slot "{slot}"')
|
||||
def step_deferred_binding(context: Any, slot: str) -> None:
|
||||
if not hasattr(context, "handler_bindings"):
|
||||
context.handler_bindings = []
|
||||
context.handler_deferred_binding = BindingResult(
|
||||
slot_name=slot,
|
||||
resource_id=None,
|
||||
binding_mode="parameter",
|
||||
deferred=True,
|
||||
)
|
||||
context.handler_bindings.append(context.handler_deferred_binding)
|
||||
|
||||
|
||||
@when('I resolve the binding via resource handler service for plan "{plan_id}"')
|
||||
def step_svc_resolve_binding(context: Any, plan_id: str) -> None:
|
||||
context.handler_svc_bound = context.handler_svc.resolve_binding(
|
||||
binding=context.handler_binding,
|
||||
plan_id=plan_id,
|
||||
)
|
||||
|
||||
|
||||
@when('I resolve all bindings via resource handler service for plan "{plan_id}"')
|
||||
def step_svc_resolve_all(context: Any, plan_id: str) -> None:
|
||||
context.handler_svc_all = context.handler_svc.resolve_bindings(
|
||||
bindings=context.handler_bindings,
|
||||
plan_id=plan_id,
|
||||
)
|
||||
|
||||
|
||||
@when("I try to resolve the single deferred binding via resource handler service")
|
||||
def step_svc_try_resolve_deferred(context: Any) -> None:
|
||||
try:
|
||||
context.handler_svc.resolve_binding(
|
||||
binding=context.handler_deferred_binding,
|
||||
plan_id="PLAN_ERR",
|
||||
)
|
||||
context.handler_error = None
|
||||
except ValueError as exc:
|
||||
context.handler_error = exc
|
||||
|
||||
|
||||
@then("the handler service should return a BoundResource")
|
||||
def step_svc_is_bound(context: Any) -> None:
|
||||
assert isinstance(context.handler_svc_bound, BoundResource)
|
||||
|
||||
|
||||
@then('the handler service BoundResource slot should be "{slot}"')
|
||||
def step_svc_bound_slot(context: Any, slot: str) -> None:
|
||||
assert context.handler_svc_bound.slot_name == slot
|
||||
|
||||
|
||||
@then("the handler service BoundResource sandbox path should not be empty")
|
||||
def step_svc_bound_path(context: Any) -> None:
|
||||
assert context.handler_svc_bound.sandbox_path, (
|
||||
f"sandbox_path is empty: {context.handler_svc_bound.sandbox_path!r}"
|
||||
)
|
||||
|
||||
|
||||
@then("the handler service should return {count:d} bound resource")
|
||||
def step_svc_count(context: Any, count: int) -> None:
|
||||
assert len(context.handler_svc_all) == count
|
||||
|
||||
|
||||
@then('the handler service should have resolved slot "{slot}"')
|
||||
def step_svc_has_slot(context: Any, slot: str) -> None:
|
||||
assert slot in context.handler_svc_all
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Helper utilities for resource 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 sys
|
||||
from datetime import datetime
|
||||
from unittest.mock import MagicMock, PropertyMock
|
||||
|
||||
from cleveragents.domain.models.core.resource import (
|
||||
PhysVirt,
|
||||
Resource,
|
||||
ResourceCapabilities,
|
||||
)
|
||||
from cleveragents.infrastructure.sandbox.factory import SandboxFactory
|
||||
from cleveragents.infrastructure.sandbox.manager import SandboxManager
|
||||
from cleveragents.infrastructure.sandbox.protocol import (
|
||||
SandboxContext,
|
||||
SandboxStatus,
|
||||
)
|
||||
from cleveragents.resource.handlers.fs_directory import FsDirectoryHandler
|
||||
from cleveragents.resource.handlers.git_checkout import GitCheckoutHandler
|
||||
from cleveragents.resource.handlers.protocol import ResourceHandler
|
||||
from cleveragents.resource.handlers.resolver import (
|
||||
HandlerResolutionError,
|
||||
clear_handler_cache,
|
||||
resolve_handler,
|
||||
)
|
||||
from cleveragents.tool.context import BoundResource
|
||||
|
||||
|
||||
def _make_resource(rtype: str, location: str) -> Resource:
|
||||
"""Create a test resource with a valid ULID."""
|
||||
return Resource(
|
||||
resource_id="01KJ5C5TPMP8GGX3QC83E2MAQS",
|
||||
resource_type_name=rtype,
|
||||
classification=PhysVirt.PHYSICAL,
|
||||
location=location,
|
||||
capabilities=ResourceCapabilities(
|
||||
readable=True, writable=True, sandboxable=True
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _make_mock_manager() -> SandboxManager:
|
||||
"""Create a SandboxManager with a mock factory."""
|
||||
mock_factory = MagicMock(spec=SandboxFactory)
|
||||
mock_sandbox = MagicMock()
|
||||
mock_sandbox.sandbox_id = "sb-robot-001"
|
||||
type(mock_sandbox).status = PropertyMock(return_value=SandboxStatus.CREATED)
|
||||
mock_sandbox.context = SandboxContext(
|
||||
sandbox_id="sb-robot-001",
|
||||
sandbox_path="/tmp/sandbox/sb-robot-001",
|
||||
original_path="/tmp/original",
|
||||
resource_id="res-robot",
|
||||
plan_id="plan-robot",
|
||||
created_at=datetime.now(),
|
||||
)
|
||||
mock_sandbox.create.return_value = mock_sandbox.context
|
||||
mock_factory.create_sandbox.return_value = mock_sandbox
|
||||
return SandboxManager(factory=mock_factory, cleanup_on_exit=False)
|
||||
|
||||
|
||||
def _protocol_check() -> None:
|
||||
"""Verify both handlers satisfy the ResourceHandler protocol."""
|
||||
git = GitCheckoutHandler()
|
||||
fs = FsDirectoryHandler()
|
||||
assert isinstance(git, ResourceHandler), "GitCheckoutHandler not a ResourceHandler"
|
||||
assert isinstance(fs, ResourceHandler), "FsDirectoryHandler not a ResourceHandler"
|
||||
print("protocol-check-ok")
|
||||
|
||||
|
||||
def _git_resolve() -> None:
|
||||
"""Verify GitCheckoutHandler resolves a resource."""
|
||||
resource = _make_resource("git-checkout", "/tmp/test-repo")
|
||||
manager = _make_mock_manager()
|
||||
handler = GitCheckoutHandler()
|
||||
bound = handler.resolve(
|
||||
resource=resource,
|
||||
plan_id="PLAN_ROBOT",
|
||||
slot_name="repo",
|
||||
sandbox_manager=manager,
|
||||
)
|
||||
assert isinstance(bound, BoundResource)
|
||||
assert bound.sandbox_path, "sandbox_path empty"
|
||||
assert bound.resource_type == "git-checkout"
|
||||
print("git-resolve-ok")
|
||||
|
||||
|
||||
def _fs_resolve() -> None:
|
||||
"""Verify FsDirectoryHandler resolves a resource."""
|
||||
resource = _make_resource("fs-directory", "/tmp/test-dir")
|
||||
manager = _make_mock_manager()
|
||||
handler = FsDirectoryHandler()
|
||||
bound = handler.resolve(
|
||||
resource=resource,
|
||||
plan_id="PLAN_ROBOT",
|
||||
slot_name="workdir",
|
||||
sandbox_manager=manager,
|
||||
)
|
||||
assert isinstance(bound, BoundResource)
|
||||
assert bound.sandbox_path, "sandbox_path empty"
|
||||
assert bound.resource_type == "fs-directory"
|
||||
print("fs-resolve-ok")
|
||||
|
||||
|
||||
def _resolver_import() -> None:
|
||||
"""Verify handler resolver loads handlers from module:class strings."""
|
||||
clear_handler_cache()
|
||||
git = resolve_handler(
|
||||
"cleveragents.resource.handlers.git_checkout:GitCheckoutHandler"
|
||||
)
|
||||
assert isinstance(git, GitCheckoutHandler)
|
||||
fs = resolve_handler(
|
||||
"cleveragents.resource.handlers.fs_directory:FsDirectoryHandler"
|
||||
)
|
||||
assert isinstance(fs, FsDirectoryHandler)
|
||||
print("resolver-import-ok")
|
||||
|
||||
|
||||
def _resolver_error() -> None:
|
||||
"""Verify handler resolver raises on bad references."""
|
||||
clear_handler_cache()
|
||||
try:
|
||||
resolve_handler("nonexistent.module:FakeClass")
|
||||
print("resolver-error-FAIL")
|
||||
except HandlerResolutionError:
|
||||
print("resolver-error-ok")
|
||||
|
||||
|
||||
_COMMANDS = {
|
||||
"protocol-check": _protocol_check,
|
||||
"git-resolve": _git_resolve,
|
||||
"fs-resolve": _fs_resolve,
|
||||
"resolver-import": _resolver_import,
|
||||
"resolver-error": _resolver_error,
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2 or sys.argv[1] not in _COMMANDS:
|
||||
print(f"Usage: {sys.argv[0]} <{'|'.join(_COMMANDS)}>", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
_COMMANDS[sys.argv[1]]()
|
||||
@@ -0,0 +1,49 @@
|
||||
*** Settings ***
|
||||
Documentation Smoke tests for resource handler runtime
|
||||
Resource ${CURDIR}/common.resource
|
||||
Suite Setup Setup Test Environment
|
||||
Suite Teardown Cleanup Test Environment
|
||||
|
||||
*** Variables ***
|
||||
${HELPER} ${CURDIR}/helper_resource_handlers.py
|
||||
|
||||
*** Test Cases ***
|
||||
Handler Protocol Conformance
|
||||
[Documentation] Verify GitCheckoutHandler and FsDirectoryHandler satisfy ResourceHandler
|
||||
${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
|
||||
|
||||
GitCheckout Handler Resolution
|
||||
[Documentation] Verify GitCheckoutHandler resolves a git-checkout resource to a sandbox
|
||||
${result}= Run Process ${PYTHON} ${HELPER} git-resolve cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} git-resolve-ok
|
||||
|
||||
FsDirectory Handler Resolution
|
||||
[Documentation] Verify FsDirectoryHandler resolves an fs-directory resource to a sandbox
|
||||
${result}= Run Process ${PYTHON} ${HELPER} fs-resolve cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} fs-resolve-ok
|
||||
|
||||
Handler Resolver Dynamic Import
|
||||
[Documentation] Verify resolve_handler loads handlers from module:class strings
|
||||
${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
|
||||
|
||||
Handler Resolver Error On Bad Reference
|
||||
[Documentation] Verify resolve_handler raises HandlerResolutionError on bad references
|
||||
${result}= Run Process ${PYTHON} ${HELPER} resolver-error cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} resolver-error-ok
|
||||
@@ -0,0 +1,290 @@
|
||||
"""Resource handler orchestration service.
|
||||
|
||||
The :class:`ResourceHandlerService` is the **orchestration bridge** that
|
||||
connects the existing infrastructure pieces into a complete resource
|
||||
resolution pipeline:
|
||||
|
||||
1. :class:`BindingResolutionService` resolves tool slots to
|
||||
:class:`BindingResult` (resource_id).
|
||||
2. :class:`ResourceRegistryService` looks up the :class:`Resource` to
|
||||
get ``location`` and ``resource_type_name``.
|
||||
3. :class:`ResourceRegistryService` looks up the :class:`ResourceTypeSpec`
|
||||
to get ``sandbox_strategy`` and ``handler`` reference.
|
||||
4. :func:`resolve_handler` dynamically loads the handler class.
|
||||
5. The handler calls :meth:`SandboxManager.get_or_create_sandbox` and
|
||||
returns a :class:`BoundResource` with ``sandbox_path`` populated.
|
||||
|
||||
The service also provides a convenience method that resolves ALL tool
|
||||
bindings in one call, producing a ``dict[str, BoundResource]`` ready
|
||||
for :class:`PlanExecutionContext`.
|
||||
|
||||
Based on:
|
||||
- implementation_plan.md group M1.resource-handlers (L2254-L2271)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, cast
|
||||
|
||||
from cleveragents.core.exceptions import NotFoundError
|
||||
from cleveragents.domain.models.core.resource import Resource
|
||||
from cleveragents.domain.models.core.resource_slot import BindingResult
|
||||
from cleveragents.domain.models.core.resource_type import ResourceTypeSpec
|
||||
from cleveragents.infrastructure.sandbox.factory import SandboxStrategyStr
|
||||
from cleveragents.infrastructure.sandbox.manager import SandboxManager
|
||||
from cleveragents.resource.handlers.protocol import ResourceHandler
|
||||
from cleveragents.resource.handlers.resolver import (
|
||||
HandlerResolutionError,
|
||||
resolve_handler,
|
||||
)
|
||||
from cleveragents.tool.context import BoundResource
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ResourceHandlerService:
|
||||
"""Orchestrates resource resolution from binding results to bound resources.
|
||||
|
||||
Bridges the gap between :class:`BindingResolutionService` output
|
||||
(``BindingResult`` with ``resource_id``) and the execution context's
|
||||
``BoundResource`` (with ``sandbox_path``).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
sandbox_manager:
|
||||
The sandbox lifecycle manager for creating/reusing sandboxes.
|
||||
resource_lookup:
|
||||
Callable that takes a resource name-or-id and returns a
|
||||
:class:`Resource`. Typically
|
||||
``ResourceRegistryService.show_resource``.
|
||||
type_lookup:
|
||||
Callable that takes a type name and returns a
|
||||
:class:`ResourceTypeSpec`. Typically
|
||||
``ResourceRegistryService.show_type``.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
sandbox_manager: SandboxManager,
|
||||
resource_lookup: Any,
|
||||
type_lookup: Any,
|
||||
) -> None:
|
||||
self._sandbox_manager = sandbox_manager
|
||||
self._resource_lookup = resource_lookup
|
||||
self._type_lookup = type_lookup
|
||||
self._logger = logger
|
||||
|
||||
def resolve_binding(
|
||||
self,
|
||||
binding: BindingResult,
|
||||
plan_id: str,
|
||||
access: str = "read_only",
|
||||
) -> BoundResource:
|
||||
"""Resolve a single binding result into a BoundResource.
|
||||
|
||||
Steps:
|
||||
1. Look up the Resource by ``binding.resource_id``.
|
||||
2. Look up the ResourceTypeSpec by ``resource.resource_type_name``.
|
||||
3. Resolve or fallback the handler.
|
||||
4. Call ``handler.resolve()`` to provision a sandbox and
|
||||
produce a BoundResource.
|
||||
|
||||
Args:
|
||||
binding: A resolved binding with a ``resource_id``.
|
||||
plan_id: The plan requesting the resolution.
|
||||
access: Access mode for the bound resource.
|
||||
|
||||
Returns:
|
||||
A :class:`BoundResource` with ``sandbox_path`` populated.
|
||||
|
||||
Raises:
|
||||
ValueError: If the binding has no resource_id (deferred).
|
||||
NotFoundError: If the resource or type is not found.
|
||||
HandlerResolutionError: If the handler cannot be loaded.
|
||||
"""
|
||||
if binding.deferred or not binding.resource_id:
|
||||
raise ValueError(
|
||||
f"Cannot resolve deferred binding for slot '{binding.slot_name}'"
|
||||
)
|
||||
|
||||
# Step 1: Look up resource
|
||||
resource: Resource = self._resource_lookup(binding.resource_id)
|
||||
|
||||
# Step 2: Look up type spec
|
||||
type_spec: ResourceTypeSpec = self._type_lookup(resource.resource_type_name)
|
||||
|
||||
# Step 3: Resolve handler
|
||||
handler = self._resolve_handler_for_type(type_spec, resource)
|
||||
|
||||
# Step 4: Delegate to handler
|
||||
return handler.resolve(
|
||||
resource=resource,
|
||||
plan_id=plan_id,
|
||||
slot_name=binding.slot_name,
|
||||
sandbox_manager=self._sandbox_manager,
|
||||
access=access,
|
||||
)
|
||||
|
||||
def resolve_bindings(
|
||||
self,
|
||||
bindings: list[BindingResult],
|
||||
plan_id: str,
|
||||
access: str = "read_only",
|
||||
) -> dict[str, BoundResource]:
|
||||
"""Resolve all non-deferred bindings into BoundResources.
|
||||
|
||||
Deferred bindings (parameter mode) are silently skipped.
|
||||
|
||||
Args:
|
||||
bindings: List of binding results from
|
||||
:class:`BindingResolutionService`.
|
||||
plan_id: The plan requesting the resolution.
|
||||
access: Default access mode for all bindings.
|
||||
|
||||
Returns:
|
||||
A dict mapping slot names to :class:`BoundResource` objects.
|
||||
"""
|
||||
result: dict[str, BoundResource] = {}
|
||||
for binding in bindings:
|
||||
if binding.deferred or not binding.resource_id:
|
||||
self._logger.debug(
|
||||
"Skipping deferred binding for slot '%s'",
|
||||
binding.slot_name,
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
bound = self.resolve_binding(
|
||||
binding=binding,
|
||||
plan_id=plan_id,
|
||||
access=access,
|
||||
)
|
||||
result[binding.slot_name] = bound
|
||||
self._logger.info(
|
||||
"Resolved binding slot='%s' -> resource='%s' sandbox='%s'",
|
||||
binding.slot_name,
|
||||
binding.resource_id,
|
||||
bound.sandbox_path,
|
||||
)
|
||||
except (NotFoundError, HandlerResolutionError, ValueError) as exc:
|
||||
self._logger.warning(
|
||||
"Failed to resolve binding for slot '%s': %s",
|
||||
binding.slot_name,
|
||||
exc,
|
||||
)
|
||||
raise
|
||||
|
||||
return result
|
||||
|
||||
def resolve_resource(
|
||||
self,
|
||||
resource: Resource,
|
||||
plan_id: str,
|
||||
slot_name: str,
|
||||
access: str = "read_only",
|
||||
) -> BoundResource:
|
||||
"""Resolve a resource directly (without going through BindingResult).
|
||||
|
||||
Convenience method when you already have the Resource object.
|
||||
|
||||
Args:
|
||||
resource: The resource to resolve.
|
||||
plan_id: The plan requesting the resolution.
|
||||
slot_name: Name for the binding slot.
|
||||
access: Access mode.
|
||||
|
||||
Returns:
|
||||
A :class:`BoundResource` with ``sandbox_path`` populated.
|
||||
"""
|
||||
type_spec = self._type_lookup(resource.resource_type_name)
|
||||
handler = self._resolve_handler_for_type(type_spec, resource)
|
||||
return handler.resolve(
|
||||
resource=resource,
|
||||
plan_id=plan_id,
|
||||
slot_name=slot_name,
|
||||
sandbox_manager=self._sandbox_manager,
|
||||
access=access,
|
||||
)
|
||||
|
||||
def _resolve_handler_for_type(
|
||||
self,
|
||||
type_spec: ResourceTypeSpec,
|
||||
resource: Resource,
|
||||
) -> ResourceHandler:
|
||||
"""Resolve the handler for a resource type spec.
|
||||
|
||||
If the type spec has a handler reference string, resolves it
|
||||
dynamically. Otherwise falls back to a default handler based
|
||||
on the sandbox strategy.
|
||||
|
||||
Args:
|
||||
type_spec: The resource type specification.
|
||||
resource: The resource being resolved.
|
||||
|
||||
Returns:
|
||||
A handler instance satisfying :class:`ResourceHandler`.
|
||||
"""
|
||||
if type_spec.handler:
|
||||
try:
|
||||
return resolve_handler(type_spec.handler)
|
||||
except HandlerResolutionError:
|
||||
self._logger.warning(
|
||||
"Failed to resolve handler '%s' for type '%s', "
|
||||
"falling back to default handler",
|
||||
type_spec.handler,
|
||||
type_spec.name,
|
||||
)
|
||||
|
||||
# Fallback: use DefaultHandler which delegates to SandboxManager
|
||||
return _DefaultHandler(type_spec=type_spec)
|
||||
|
||||
|
||||
class _DefaultHandler:
|
||||
"""Fallback handler when no handler class is specified or resolution fails.
|
||||
|
||||
Delegates directly to :class:`SandboxManager` using the type's
|
||||
default sandbox strategy (or resource override).
|
||||
"""
|
||||
|
||||
def __init__(self, type_spec: ResourceTypeSpec) -> None:
|
||||
self._type_spec = type_spec
|
||||
|
||||
def resolve(
|
||||
self,
|
||||
*,
|
||||
resource: Resource,
|
||||
plan_id: str,
|
||||
slot_name: str,
|
||||
sandbox_manager: SandboxManager,
|
||||
access: str = "read_only",
|
||||
) -> BoundResource:
|
||||
"""Resolve using the default sandbox strategy."""
|
||||
if not resource.location:
|
||||
raise ValueError(f"Resource '{resource.resource_id}' has no location")
|
||||
|
||||
# resource.sandbox_strategy may be a StrEnum or plain str (Pydantic coercion)
|
||||
if resource.sandbox_strategy:
|
||||
strategy_str = str(resource.sandbox_strategy)
|
||||
else:
|
||||
strategy_str = str(self._type_spec.sandbox_strategy)
|
||||
|
||||
sandbox = sandbox_manager.get_or_create_sandbox(
|
||||
plan_id=plan_id,
|
||||
resource_id=resource.resource_id,
|
||||
original_path=resource.location,
|
||||
sandbox_strategy=cast(SandboxStrategyStr, strategy_str),
|
||||
)
|
||||
|
||||
sandbox_path = ""
|
||||
if sandbox.context is not None:
|
||||
sandbox_path = sandbox.context.sandbox_path
|
||||
|
||||
return BoundResource(
|
||||
slot_name=slot_name,
|
||||
resource_id=resource.resource_id,
|
||||
resource_type=resource.resource_type_name,
|
||||
sandbox_path=sandbox_path,
|
||||
access=access,
|
||||
)
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Resource handler implementations for CleverAgents.
|
||||
|
||||
This package provides the handler runtime that bridges resource types
|
||||
to sandbox provisioning. Each handler resolves a :class:`Resource`
|
||||
into a sandboxed working path via the :class:`SandboxManager`.
|
||||
|
||||
## Handler Protocol
|
||||
|
||||
All handlers implement
|
||||
:class:`~cleveragents.resource.handlers.protocol.ResourceHandler`,
|
||||
which defines a single ``resolve`` method returning a
|
||||
:class:`~cleveragents.tool.context.BoundResource` with a populated
|
||||
``sandbox_path``.
|
||||
|
||||
## Built-in Handlers
|
||||
|
||||
| Handler | Resource Type | Sandbox Strategy |
|
||||
|-----------------------|------------------|------------------|
|
||||
| ``GitCheckoutHandler``| ``git-checkout`` | ``git_worktree`` |
|
||||
| ``FsDirectoryHandler``| ``fs-directory`` | ``copy_on_write``|
|
||||
|
||||
## Handler Resolution
|
||||
|
||||
Handler strings stored on :class:`ResourceTypeSpec` use the format
|
||||
``module.path:ClassName``. The :func:`resolve_handler` function
|
||||
dynamically imports the module and returns an instance.
|
||||
"""
|
||||
|
||||
from cleveragents.resource.handlers.fs_directory import FsDirectoryHandler
|
||||
from cleveragents.resource.handlers.git_checkout import GitCheckoutHandler
|
||||
from cleveragents.resource.handlers.protocol import ResourceHandler
|
||||
from cleveragents.resource.handlers.resolver import (
|
||||
HandlerResolutionError,
|
||||
resolve_handler,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"FsDirectoryHandler",
|
||||
"GitCheckoutHandler",
|
||||
"HandlerResolutionError",
|
||||
"ResourceHandler",
|
||||
"resolve_handler",
|
||||
]
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Filesystem-directory resource handler.
|
||||
|
||||
Resolves ``fs-directory`` resources into sandbox-backed
|
||||
:class:`BoundResource` instances using the ``copy_on_write`` 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 ``copy_on_write``.
|
||||
3. Calls :meth:`SandboxManager.get_or_create_sandbox` to provision
|
||||
(or reuse) an isolated copy-on-write directory.
|
||||
4. Returns a :class:`BoundResource` with ``sandbox_path`` set to the
|
||||
sandbox root.
|
||||
|
||||
Based on:
|
||||
- implementation_plan.md group M1.resource-handlers (L2254-L2271)
|
||||
- Built-in type definition in resource_registry_service.py L99-123
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import cast
|
||||
|
||||
from cleveragents.domain.models.core.resource import Resource, SandboxStrategy
|
||||
from cleveragents.infrastructure.sandbox.factory import SandboxStrategyStr
|
||||
from cleveragents.infrastructure.sandbox.manager import SandboxManager
|
||||
from cleveragents.tool.context import BoundResource
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default strategy for fs-directory resources
|
||||
_DEFAULT_STRATEGY = SandboxStrategy.COPY_ON_WRITE
|
||||
|
||||
|
||||
class FsDirectoryHandler:
|
||||
"""Handler for ``fs-directory`` resource types.
|
||||
|
||||
Provisions a copy-on-write sandbox for a local filesystem directory.
|
||||
"""
|
||||
|
||||
def resolve(
|
||||
self,
|
||||
*,
|
||||
resource: Resource,
|
||||
plan_id: str,
|
||||
slot_name: str,
|
||||
sandbox_manager: SandboxManager,
|
||||
access: str = "read_only",
|
||||
) -> BoundResource:
|
||||
"""Resolve an fs-directory resource into a sandboxed BoundResource.
|
||||
|
||||
Args:
|
||||
resource: An ``fs-directory`` resource with ``location``
|
||||
pointing to the directory.
|
||||
plan_id: The plan requesting the sandbox.
|
||||
slot_name: Name of the tool resource slot being filled.
|
||||
sandbox_manager: The sandbox lifecycle manager.
|
||||
access: Access mode (``read_only`` or ``read_write``).
|
||||
|
||||
Returns:
|
||||
A :class:`BoundResource` with ``sandbox_path`` populated.
|
||||
|
||||
Raises:
|
||||
ValueError: If the resource has no location.
|
||||
"""
|
||||
if not resource.location:
|
||||
raise ValueError(
|
||||
f"fs-directory resource '{resource.resource_id}' has no location"
|
||||
)
|
||||
|
||||
strategy_raw = resource.sandbox_strategy or _DEFAULT_STRATEGY
|
||||
strategy_str = (
|
||||
strategy_raw.value if hasattr(strategy_raw, "value") else str(strategy_raw)
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Resolving fs-directory resource %s (location=%s, strategy=%s)",
|
||||
resource.resource_id,
|
||||
resource.location,
|
||||
strategy_str,
|
||||
)
|
||||
|
||||
sandbox = sandbox_manager.get_or_create_sandbox(
|
||||
plan_id=plan_id,
|
||||
resource_id=resource.resource_id,
|
||||
original_path=resource.location,
|
||||
sandbox_strategy=cast(SandboxStrategyStr, strategy_str),
|
||||
)
|
||||
|
||||
sandbox_path = ""
|
||||
if sandbox.context is not None:
|
||||
sandbox_path = sandbox.context.sandbox_path
|
||||
|
||||
return BoundResource(
|
||||
slot_name=slot_name,
|
||||
resource_id=resource.resource_id,
|
||||
resource_type=resource.resource_type_name,
|
||||
sandbox_path=sandbox_path,
|
||||
access=access,
|
||||
)
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Git-checkout resource handler.
|
||||
|
||||
Resolves ``git-checkout`` resources into sandbox-backed
|
||||
:class:`BoundResource` instances using the ``git_worktree`` sandbox
|
||||
strategy (with fallback to ``copy_on_write``).
|
||||
|
||||
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 ``git_worktree``.
|
||||
3. Calls :meth:`SandboxManager.get_or_create_sandbox` to provision
|
||||
(or reuse) an isolated git worktree.
|
||||
4. Returns a :class:`BoundResource` with ``sandbox_path`` set to the
|
||||
sandbox root.
|
||||
|
||||
Based on:
|
||||
- implementation_plan.md group M1.resource-handlers (L2254-L2271)
|
||||
- Built-in type definition in resource_registry_service.py L62-98
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import cast
|
||||
|
||||
from cleveragents.domain.models.core.resource import Resource, SandboxStrategy
|
||||
from cleveragents.infrastructure.sandbox.factory import SandboxStrategyStr
|
||||
from cleveragents.infrastructure.sandbox.manager import SandboxManager
|
||||
from cleveragents.tool.context import BoundResource
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default strategy for git-checkout resources
|
||||
_DEFAULT_STRATEGY = SandboxStrategy.GIT_WORKTREE
|
||||
|
||||
|
||||
class GitCheckoutHandler:
|
||||
"""Handler for ``git-checkout`` resource types.
|
||||
|
||||
Provisions a git-worktree sandbox (or copy-on-write fallback)
|
||||
for a git repository checkout.
|
||||
"""
|
||||
|
||||
def resolve(
|
||||
self,
|
||||
*,
|
||||
resource: Resource,
|
||||
plan_id: str,
|
||||
slot_name: str,
|
||||
sandbox_manager: SandboxManager,
|
||||
access: str = "read_only",
|
||||
) -> BoundResource:
|
||||
"""Resolve a git-checkout resource into a sandboxed BoundResource.
|
||||
|
||||
Args:
|
||||
resource: A ``git-checkout`` resource with ``location``
|
||||
pointing to the repository root.
|
||||
plan_id: The plan requesting the sandbox.
|
||||
slot_name: Name of the tool resource slot being filled.
|
||||
sandbox_manager: The sandbox lifecycle manager.
|
||||
access: Access mode (``read_only`` or ``read_write``).
|
||||
|
||||
Returns:
|
||||
A :class:`BoundResource` with ``sandbox_path`` populated.
|
||||
|
||||
Raises:
|
||||
ValueError: If the resource has no location.
|
||||
"""
|
||||
if not resource.location:
|
||||
raise ValueError(
|
||||
f"git-checkout resource '{resource.resource_id}' has no location"
|
||||
)
|
||||
|
||||
strategy_raw = resource.sandbox_strategy or _DEFAULT_STRATEGY
|
||||
strategy_str = (
|
||||
strategy_raw.value if hasattr(strategy_raw, "value") else str(strategy_raw)
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Resolving git-checkout resource %s (location=%s, strategy=%s)",
|
||||
resource.resource_id,
|
||||
resource.location,
|
||||
strategy_str,
|
||||
)
|
||||
|
||||
sandbox = sandbox_manager.get_or_create_sandbox(
|
||||
plan_id=plan_id,
|
||||
resource_id=resource.resource_id,
|
||||
original_path=resource.location,
|
||||
sandbox_strategy=cast(SandboxStrategyStr, strategy_str),
|
||||
)
|
||||
|
||||
sandbox_path = ""
|
||||
if sandbox.context is not None:
|
||||
sandbox_path = sandbox.context.sandbox_path
|
||||
|
||||
return BoundResource(
|
||||
slot_name=slot_name,
|
||||
resource_id=resource.resource_id,
|
||||
resource_type=resource.resource_type_name,
|
||||
sandbox_path=sandbox_path,
|
||||
access=access,
|
||||
)
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Resource handler protocol for CleverAgents.
|
||||
|
||||
Defines the :class:`ResourceHandler` protocol that all resource type
|
||||
handlers must satisfy. A handler bridges a :class:`Resource` domain
|
||||
object to sandbox provisioning by:
|
||||
|
||||
1. Reading the resource's ``location`` (the original filesystem path).
|
||||
2. Determining the sandbox strategy (from resource override or type default).
|
||||
3. Calling :meth:`SandboxManager.get_or_create_sandbox` to provision an
|
||||
isolated working directory.
|
||||
4. Returning a :class:`BoundResource` with ``sandbox_path`` populated.
|
||||
|
||||
Based on:
|
||||
- implementation_plan.md group M1.resource-handlers (L2254-L2271)
|
||||
- docs/specification.md Resource Handler architecture
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from cleveragents.domain.models.core.resource import Resource
|
||||
from cleveragents.infrastructure.sandbox.manager import SandboxManager
|
||||
from cleveragents.tool.context import BoundResource
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ResourceHandler(Protocol):
|
||||
"""Protocol for resource type handlers.
|
||||
|
||||
Each handler knows how to resolve a specific resource type into a
|
||||
sandboxed working path. Implementations are registered via the
|
||||
``handler`` field on :class:`ResourceTypeSpec` using the
|
||||
``module:ClassName`` string format.
|
||||
|
||||
Lifecycle::
|
||||
|
||||
handler = resolve_handler(
|
||||
"cleveragents.resource.handlers.git_checkout:GitCheckoutHandler"
|
||||
)
|
||||
bound = handler.resolve(
|
||||
resource=resource,
|
||||
plan_id="01ARZ3...",
|
||||
slot_name="repo",
|
||||
sandbox_manager=sandbox_manager,
|
||||
access="read_write",
|
||||
)
|
||||
# bound.sandbox_path is now populated
|
||||
"""
|
||||
|
||||
def resolve(
|
||||
self,
|
||||
*,
|
||||
resource: Resource,
|
||||
plan_id: str,
|
||||
slot_name: str,
|
||||
sandbox_manager: SandboxManager,
|
||||
access: str = "read_only",
|
||||
) -> BoundResource:
|
||||
"""Resolve a resource into a sandbox-backed BoundResource.
|
||||
|
||||
Args:
|
||||
resource: The resource domain object to resolve.
|
||||
plan_id: The plan requesting the sandbox.
|
||||
slot_name: Name of the tool resource slot being filled.
|
||||
sandbox_manager: The sandbox lifecycle manager.
|
||||
access: Access mode (``read_only`` or ``read_write``).
|
||||
|
||||
Returns:
|
||||
A :class:`BoundResource` with ``sandbox_path`` populated
|
||||
from the provisioned sandbox.
|
||||
|
||||
Raises:
|
||||
ValueError: If the resource lacks a location or has an
|
||||
incompatible type.
|
||||
SandboxError: If sandbox creation fails.
|
||||
"""
|
||||
...
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Dynamic handler resolution from ``module:ClassName`` strings.
|
||||
|
||||
The :func:`resolve_handler` function takes a handler reference string
|
||||
(as stored on :class:`ResourceTypeSpec`) and returns an instantiated
|
||||
handler object that satisfies the :class:`ResourceHandler` protocol.
|
||||
|
||||
Format::
|
||||
|
||||
"cleveragents.resource.handlers.git_checkout:GitCheckoutHandler"
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^
|
||||
module path class name
|
||||
|
||||
The function uses :func:`importlib.import_module` for dynamic loading,
|
||||
with a cache to avoid repeated imports.
|
||||
|
||||
Based on:
|
||||
- implementation_plan.md group M1.resource-handlers (L2254-L2271)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from cleveragents.resource.handlers.protocol import ResourceHandler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Cache of already-resolved handler instances keyed by reference string
|
||||
_handler_cache: dict[str, ResourceHandler] = {}
|
||||
|
||||
|
||||
class HandlerResolutionError(Exception):
|
||||
"""Raised when a handler reference string cannot be resolved."""
|
||||
|
||||
|
||||
def resolve_handler(handler_ref: str) -> ResourceHandler:
|
||||
"""Resolve a handler reference string to an instance.
|
||||
|
||||
Args:
|
||||
handler_ref: A string in ``module.path:ClassName`` format.
|
||||
|
||||
Returns:
|
||||
An instantiated handler that satisfies :class:`ResourceHandler`.
|
||||
|
||||
Raises:
|
||||
HandlerResolutionError: If the reference is malformed, the
|
||||
module cannot be imported, or the class does not exist.
|
||||
"""
|
||||
if not handler_ref or not handler_ref.strip():
|
||||
raise HandlerResolutionError("Handler reference must not be empty")
|
||||
|
||||
handler_ref = handler_ref.strip()
|
||||
|
||||
# Return cached instance if available
|
||||
if handler_ref in _handler_cache:
|
||||
return _handler_cache[handler_ref]
|
||||
|
||||
# Parse module:class format
|
||||
if ":" not in handler_ref:
|
||||
raise HandlerResolutionError(
|
||||
f"Invalid handler reference format '{handler_ref}': "
|
||||
"expected 'module.path:ClassName'"
|
||||
)
|
||||
|
||||
module_path, class_name = handler_ref.rsplit(":", 1)
|
||||
if not module_path or not class_name:
|
||||
raise HandlerResolutionError(
|
||||
f"Invalid handler reference '{handler_ref}': "
|
||||
"both module path and class name are required"
|
||||
)
|
||||
|
||||
# Import module
|
||||
try:
|
||||
module = importlib.import_module(module_path)
|
||||
except ModuleNotFoundError as exc:
|
||||
raise HandlerResolutionError(
|
||||
f"Cannot import handler module '{module_path}': {exc}"
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
raise HandlerResolutionError(
|
||||
f"Error importing handler module '{module_path}': {exc}"
|
||||
) from exc
|
||||
|
||||
# Get class
|
||||
handler_cls: Any = getattr(module, class_name, None)
|
||||
if handler_cls is None:
|
||||
raise HandlerResolutionError(
|
||||
f"Handler class '{class_name}' not found in module '{module_path}'"
|
||||
)
|
||||
|
||||
# Instantiate
|
||||
try:
|
||||
instance = handler_cls()
|
||||
except Exception as exc:
|
||||
raise HandlerResolutionError(
|
||||
f"Cannot instantiate handler '{handler_ref}': {exc}"
|
||||
) from exc
|
||||
|
||||
# Validate protocol conformance
|
||||
if not isinstance(instance, ResourceHandler):
|
||||
raise HandlerResolutionError(
|
||||
f"Handler '{handler_ref}' does not satisfy ResourceHandler protocol"
|
||||
)
|
||||
|
||||
# Cache and return
|
||||
_handler_cache[handler_ref] = instance
|
||||
logger.debug("Resolved handler: %s", handler_ref)
|
||||
return instance
|
||||
|
||||
|
||||
def clear_handler_cache() -> None:
|
||||
"""Clear the handler instance cache (useful for testing)."""
|
||||
_handler_cache.clear()
|
||||
Reference in New Issue
Block a user