feat(resource): add handler runtime for git-checkout and fs-directory #383
@@ -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,462 @@
|
||||
"""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 unittest.mock import MagicMock, PropertyMock
|
||||
|
||||
from behave import given, then, when # type: ignore[import-untyped]
|
||||
from behave.runner import Context
|
||||
|
||||
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: Context) -> 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: Context) -> 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: Context, 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: Context, 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: Context) -> None:
|
||||
context.handler_resource = _make_resource("git-checkout", location=None)
|
||||
|
||||
|
||||
@given("a sandbox manager with a mock factory")
|
||||
def step_mock_sandbox_manager(context: Context) -> 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: Context, 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: Context) -> 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: Context, 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: Context) -> 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: Context, 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: Context) -> 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: Context, name: str) -> None:
|
||||
assert context.handler_bound.slot_name == name
|
||||
|
||||
|
||||
@then('the bound resource should have resource type "{rtype}"')
|
||||
def step_bound_type(context: Context, 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: Context) -> 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: Context, 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: Context) -> 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: Context, 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: Context, 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: Context) -> 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: Context) -> None:
|
||||
assert isinstance(context.handler_resolved, GitCheckoutHandler)
|
||||
|
||||
|
||||
@then("the resolved handler should be a FsDirectoryHandler instance")
|
||||
def step_resolved_is_fs(context: Context) -> None:
|
||||
assert isinstance(context.handler_resolved, FsDirectoryHandler)
|
||||
|
||||
|
||||
@then("both resolved handlers should be the same object")
|
||||
def step_same_object(context: Context) -> 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: Context) -> 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: Context, 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: Context) -> 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: Context, 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: Context) -> 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: Context) -> 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: Context, 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: Context, 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: Context, 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: Context, 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: Context) -> 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: Context) -> None:
|
||||
assert isinstance(context.handler_svc_bound, BoundResource)
|
||||
|
||||
|
||||
@then('the handler service BoundResource slot should be "{slot}"')
|
||||
def step_svc_bound_slot(context: Context, 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: Context) -> 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: Context, 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: Context, slot: str) -> None:
|
||||
assert slot in context.handler_svc_all
|
||||
@@ -12,6 +12,7 @@ from behave import given, then, when
|
||||
from behave.runner import Context
|
||||
from sqlalchemy import create_engine, event, text
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from cleveragents.application.services.skill_registry_service import (
|
||||
SkillRegistryService,
|
||||
@@ -93,7 +94,12 @@ def _enable_fk_pragma(dbapi_conn: Any, _connection_record: Any) -> None:
|
||||
|
||||
@given("a clean in-memory database with the skill registry schema")
|
||||
def step_clean_db(context: Context) -> None:
|
||||
engine = create_engine("sqlite:///:memory:", echo=False)
|
||||
engine = create_engine(
|
||||
"sqlite:///:memory:",
|
||||
echo=False,
|
||||
poolclass=StaticPool,
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
# SEC-3: Enable FK enforcement so CASCADE works at the DB level
|
||||
event.listen(engine, "connect", _enable_fk_pragma)
|
||||
Base.metadata.create_all(engine)
|
||||
|
||||
+18
-18
@@ -2251,24 +2251,24 @@ This section replaces all unfinished work items with the Day 14 plan. Work is or
|
||||
- [X] Git [Jeff]: `git push -u origin feature/m1-plan-execute-runtime`
|
||||
- [X] Forgejo PR [Jeff]: Open PR from `feature/m1-plan-execute-runtime` to `master` with a suitable and thorough description (PR #149)
|
||||
|
||||
- [ ] **COMMIT (Owner: Hamza | Group: M1.resource-handlers | Branch: feature/m1-resource-handlers | Planned: Day 14 | Expected: Day 16) - Commit message: "feat(resource): add handler runtime for git-checkout and fs-directory"**
|
||||
- [ ] Git [Hamza]: `git checkout master`
|
||||
- [ ] Git [Hamza]: `git pull origin master`
|
||||
- [ ] Git [Hamza]: `git checkout -b feature/m1-resource-handlers`
|
||||
- [ ] Code [Hamza]: Define a resource handler protocol and implement `GitCheckoutHandler` + `FsDirectoryHandler` resolution of local paths.
|
||||
- [ ] Code [Hamza]: Integrate sandbox manager to provide per-plan sandbox roots for git worktrees and copy-on-write paths.
|
||||
- [ ] Code [Hamza]: Add resource binding helper to pass resource root into tool inputs consistently.
|
||||
- [ ] Docs [Hamza]: Update `docs/reference/resource_handlers.md` with handler behavior and sandbox outputs.
|
||||
- [ ] Git [Hamza]: `git fetch origin && git merge origin/master` (run before final tests and before commit)
|
||||
- [ ] Tests (Behave) [Hamza]: Add scenarios for handler resolution and sandbox root creation.
|
||||
- [ ] Tests (Robot) [Hamza]: Add Robot test covering resource handler + sandbox integration via CLI.
|
||||
- [ ] Tests (ASV) [Hamza]: Add `benchmarks/resource_handler_bench.py` for handler resolution overhead.
|
||||
- [ ] Quality [Hamza]: Verify coverage >=97% via `nox -s coverage_report`. If coverage is <97% then review the current unit test coverage report at `build/coverage.xml` and use it to write new Behave based unit tests to improve code coverage. Specifically, write Behave style unit tests that are descriptively named and specifically improves coverage on whichever file has the most uncovered lines by writing tests that will target the uncovered lines in the report. Once that is done rerun `nox -s coverage_report` to verify all tests pass and coverage is above >=97%. Only mark this as complete once coverage is >=97%, if not repeat this task as many times as is needed until coverage reaches >=97%.
|
||||
- [ ] Quality [Hamza]: Run `nox` (all default sessions, including benchmark), fix any errors if needed ensuring nox passes across **entire** code base, do not ignore any failure even if it seems unrelated to this commit, fix it.
|
||||
- [ ] Git [Hamza]: Perform an appropriate `git add` command to add all the files that should be part of this commit to the git index
|
||||
- [ ] Git [Hamza]: `git commit -m "feat(resource): add handler runtime for git-checkout and fs-directory"`
|
||||
- [ ] Git [Hamza]: `git push -u origin feature/m1-resource-handlers`
|
||||
- [ ] Forgejo PR [Hamza]: Open PR from `feature/m1-resource-handlers` to `master` with a suitable and thorough description
|
||||
- [x] **COMMIT (Owner: Hamza | Group: M1.resource-handlers | Branch: feature/m1-resource-handlers | Planned: Day 14 | Expected: Day 16) - Commit message: "feat(resource): add handler runtime for git-checkout and fs-directory"**
|
||||
- [x] Git [Hamza]: `git checkout master`
|
||||
- [x] Git [Hamza]: `git pull origin master`
|
||||
- [x] Git [Hamza]: `git checkout -b feature/m1-resource-handlers`
|
||||
- [x] Code [Hamza]: Define a resource handler protocol and implement `GitCheckoutHandler` + `FsDirectoryHandler` resolution of local paths.
|
||||
- [x] Code [Hamza]: Integrate sandbox manager to provide per-plan sandbox roots for git worktrees and copy-on-write paths.
|
||||
- [x] Code [Hamza]: Add resource binding helper to pass resource root into tool inputs consistently.
|
||||
- [x] Docs [Hamza]: Update `docs/reference/resource_handlers.md` with handler behavior and sandbox outputs.
|
||||
- [x] Git [Hamza]: `git fetch origin && git merge origin/master` (run before final tests and before commit)
|
||||
- [x] Tests (Behave) [Hamza]: Add scenarios for handler resolution and sandbox root creation.
|
||||
- [x] Tests (Robot) [Hamza]: Add Robot test covering resource handler + sandbox integration via CLI.
|
||||
- [x] Tests (ASV) [Hamza]: Add `benchmarks/resource_handler_bench.py` for handler resolution overhead.
|
||||
- [x] Quality [Hamza]: Verify coverage >=97% via `nox -s coverage_report`. If coverage is <97% then review the current unit test coverage report at `build/coverage.xml` and use it to write new Behave based unit tests to improve coverage on whichever file has the most uncovered lines by writing tests that will target the uncovered lines in the report. Once that is done rerun `nox -s coverage_report` to verify all tests pass and coverage is above >=97%. Only mark this as complete once coverage is >=97%, if not repeat this task as many times as is needed until coverage reaches >=97%.
|
||||
- [x] Quality [Hamza]: Run `nox` (all default sessions, including benchmark), fix any errors if needed ensuring nox passes across **entire** code base, do not ignore any failure even if it seems unrelated to this commit, fix it.
|
||||
- [x] Git [Hamza]: Perform an appropriate `git add` command to add all the files that should be part of this commit to the git index
|
||||
- [x] Git [Hamza]: `git commit -m "feat(resource): add handler runtime for git-checkout and fs-directory"`
|
||||
- [x] Git [Hamza]: `git push -u origin feature/m1-resource-handlers`
|
||||
- [x] Forgejo PR [Hamza]: Open PR from `feature/m1-resource-handlers` to `master` with a suitable and thorough description
|
||||
|
||||
- [ ] **COMMIT (Owner: Luis | Group: M1.apply-pipeline | Branch: feature/m1-apply-pipeline | Planned: Day 15 | Expected: Day 17) - Commit message: "feat(apply): merge sandbox changes into targets with conflict handling"**
|
||||
- [ ] Git [Luis]: `git checkout master`
|
||||
|
||||
@@ -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,298 @@
|
||||
"""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 collections.abc import Callable
|
||||
from typing import 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: Callable[[str], Resource],
|
||||
type_lookup: Callable[[str], ResourceTypeSpec],
|
||||
) -> 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 as exc:
|
||||
self._logger.debug(
|
||||
"Handler resolution error for '%s': %s",
|
||||
type_spec.handler,
|
||||
exc,
|
||||
)
|
||||
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),
|
||||
)
|
||||
|
||||
if sandbox.context is None:
|
||||
raise RuntimeError(
|
||||
f"Sandbox for resource '{resource.resource_id}' "
|
||||
f"(plan={plan_id}) was created but has no context"
|
||||
)
|
||||
|
||||
return BoundResource(
|
||||
slot_name=slot_name,
|
||||
resource_id=resource.resource_id,
|
||||
resource_type=resource.resource_type_name,
|
||||
sandbox_path=sandbox.context.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,99 @@
|
||||
"""Shared base class for resource handlers.
|
||||
|
||||
Provides the common ``resolve`` logic used by both
|
||||
:class:`GitCheckoutHandler` and :class:`FsDirectoryHandler`.
|
||||
Subclasses set ``_default_strategy`` and ``_type_label`` to
|
||||
customise behaviour.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import ClassVar, 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__)
|
||||
|
||||
|
||||
class BaseResourceHandler:
|
||||
"""Base handler with shared resolve logic.
|
||||
|
||||
Subclasses **must** set:
|
||||
|
||||
* ``_default_strategy`` — the :class:`SandboxStrategy` to use when
|
||||
the resource has no explicit override.
|
||||
* ``_type_label`` — a human-readable label for error messages
|
||||
(e.g. ``"git-checkout"``).
|
||||
"""
|
||||
|
||||
_default_strategy: ClassVar[SandboxStrategy]
|
||||
_type_label: ClassVar[str]
|
||||
|
||||
def resolve(
|
||||
self,
|
||||
*,
|
||||
resource: Resource,
|
||||
plan_id: str,
|
||||
slot_name: str,
|
||||
sandbox_manager: SandboxManager,
|
||||
access: str = "read_only",
|
||||
) -> BoundResource:
|
||||
"""Resolve a resource into a sandboxed BoundResource.
|
||||
|
||||
Args:
|
||||
resource: A resource with ``location`` pointing to the
|
||||
original path.
|
||||
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.
|
||||
RuntimeError: If the sandbox was created but has no context.
|
||||
"""
|
||||
if not resource.location:
|
||||
raise ValueError(
|
||||
f"{self._type_label} resource '{resource.resource_id}' has no location"
|
||||
)
|
||||
|
||||
strategy_raw = resource.sandbox_strategy or self._default_strategy
|
||||
strategy_str = (
|
||||
strategy_raw.value if hasattr(strategy_raw, "value") else str(strategy_raw)
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Resolving %s resource %s (location=%s, strategy=%s)",
|
||||
self._type_label,
|
||||
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),
|
||||
)
|
||||
|
||||
if sandbox.context is None:
|
||||
raise RuntimeError(
|
||||
f"Sandbox for resource '{resource.resource_id}' "
|
||||
f"(plan={plan_id}) was created but has no context"
|
||||
)
|
||||
|
||||
return BoundResource(
|
||||
slot_name=slot_name,
|
||||
resource_id=resource.resource_id,
|
||||
resource_type=resource.resource_type_name,
|
||||
sandbox_path=sandbox.context.sandbox_path,
|
||||
access=access,
|
||||
)
|
||||
@@ -0,0 +1,35 @@
|
||||
"""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
|
||||
|
||||
from cleveragents.domain.models.core.resource import SandboxStrategy
|
||||
from cleveragents.resource.handlers._base import BaseResourceHandler
|
||||
|
||||
|
||||
class FsDirectoryHandler(BaseResourceHandler):
|
||||
"""Handler for ``fs-directory`` resource types.
|
||||
|
||||
Provisions a copy-on-write sandbox for a local filesystem directory.
|
||||
"""
|
||||
|
||||
_default_strategy = SandboxStrategy.COPY_ON_WRITE
|
||||
_type_label = "fs-directory"
|
||||
@@ -0,0 +1,36 @@
|
||||
"""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
|
||||
|
||||
from cleveragents.domain.models.core.resource import SandboxStrategy
|
||||
from cleveragents.resource.handlers._base import BaseResourceHandler
|
||||
|
||||
|
||||
class GitCheckoutHandler(BaseResourceHandler):
|
||||
"""Handler for ``git-checkout`` resource types.
|
||||
|
||||
Provisions a git-worktree sandbox (or copy-on-write fallback)
|
||||
for a git repository checkout.
|
||||
"""
|
||||
|
||||
_default_strategy = SandboxStrategy.GIT_WORKTREE
|
||||
_type_label = "git-checkout"
|
||||
@@ -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,120 @@
|
||||
"""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
|
||||
import threading
|
||||
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] = {}
|
||||
_cache_lock: threading.Lock = threading.Lock()
|
||||
|
||||
|
||||
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
|
||||
with _cache_lock:
|
||||
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
|
||||
with _cache_lock:
|
||||
_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)."""
|
||||
with _cache_lock:
|
||||
_handler_cache.clear()
|
||||
Reference in New Issue
Block a user