From bfc6ab0fb1d396a0752d032f8ebd14b4f75f0c67 Mon Sep 17 00:00:00 2001 From: khyari hamza Date: Mon, 23 Feb 2026 14:06:05 +0000 Subject: [PATCH] feat(resource): add handler runtime for git-checkout and fs-directory --- benchmarks/resource_handler_bench.py | 214 ++++++++ docs/reference/resource_handlers.md | 172 +++++++ features/resource_handlers.feature | 124 +++++ features/steps/resource_handlers_steps.py | 460 ++++++++++++++++++ robot/helper_resource_handlers.py | 147 ++++++ robot/resource_handlers.robot | 49 ++ .../services/resource_handler_service.py | 290 +++++++++++ .../resource/handlers/__init__.py | 43 ++ .../resource/handlers/fs_directory.py | 103 ++++ .../resource/handlers/git_checkout.py | 104 ++++ .../resource/handlers/protocol.py | 78 +++ .../resource/handlers/resolver.py | 115 +++++ 12 files changed, 1899 insertions(+) create mode 100644 benchmarks/resource_handler_bench.py create mode 100644 docs/reference/resource_handlers.md create mode 100644 features/resource_handlers.feature create mode 100644 features/steps/resource_handlers_steps.py create mode 100644 robot/helper_resource_handlers.py create mode 100644 robot/resource_handlers.robot create mode 100644 src/cleveragents/application/services/resource_handler_service.py create mode 100644 src/cleveragents/resource/handlers/__init__.py create mode 100644 src/cleveragents/resource/handlers/fs_directory.py create mode 100644 src/cleveragents/resource/handlers/git_checkout.py create mode 100644 src/cleveragents/resource/handlers/protocol.py create mode 100644 src/cleveragents/resource/handlers/resolver.py diff --git a/benchmarks/resource_handler_bench.py b/benchmarks/resource_handler_bench.py new file mode 100644 index 000000000..24f483d92 --- /dev/null +++ b/benchmarks/resource_handler_bench.py @@ -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", + ) diff --git a/docs/reference/resource_handlers.md b/docs/reference/resource_handlers.md new file mode 100644 index 000000000..46d36bbd9 --- /dev/null +++ b/docs/reference/resource_handlers.md @@ -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, + ) +``` diff --git a/features/resource_handlers.feature b/features/resource_handlers.feature new file mode 100644 index 000000000..2f8dc2c48 --- /dev/null +++ b/features/resource_handlers.feature @@ -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 diff --git a/features/steps/resource_handlers_steps.py b/features/steps/resource_handlers_steps.py new file mode 100644 index 000000000..7197c67fa --- /dev/null +++ b/features/steps/resource_handlers_steps.py @@ -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 diff --git a/robot/helper_resource_handlers.py b/robot/helper_resource_handlers.py new file mode 100644 index 000000000..a38f38ecb --- /dev/null +++ b/robot/helper_resource_handlers.py @@ -0,0 +1,147 @@ +"""Helper utilities for resource handler Robot smoke tests. + +Each command prints a single ``-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]]() diff --git a/robot/resource_handlers.robot b/robot/resource_handlers.robot new file mode 100644 index 000000000..ad4730bde --- /dev/null +++ b/robot/resource_handlers.robot @@ -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 diff --git a/src/cleveragents/application/services/resource_handler_service.py b/src/cleveragents/application/services/resource_handler_service.py new file mode 100644 index 000000000..56825df13 --- /dev/null +++ b/src/cleveragents/application/services/resource_handler_service.py @@ -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, + ) diff --git a/src/cleveragents/resource/handlers/__init__.py b/src/cleveragents/resource/handlers/__init__.py new file mode 100644 index 000000000..57ae71361 --- /dev/null +++ b/src/cleveragents/resource/handlers/__init__.py @@ -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", +] diff --git a/src/cleveragents/resource/handlers/fs_directory.py b/src/cleveragents/resource/handlers/fs_directory.py new file mode 100644 index 000000000..b0d4ebf4a --- /dev/null +++ b/src/cleveragents/resource/handlers/fs_directory.py @@ -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, + ) diff --git a/src/cleveragents/resource/handlers/git_checkout.py b/src/cleveragents/resource/handlers/git_checkout.py new file mode 100644 index 000000000..dae540ae3 --- /dev/null +++ b/src/cleveragents/resource/handlers/git_checkout.py @@ -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, + ) diff --git a/src/cleveragents/resource/handlers/protocol.py b/src/cleveragents/resource/handlers/protocol.py new file mode 100644 index 000000000..84ba4e5fc --- /dev/null +++ b/src/cleveragents/resource/handlers/protocol.py @@ -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. + """ + ... diff --git a/src/cleveragents/resource/handlers/resolver.py b/src/cleveragents/resource/handlers/resolver.py new file mode 100644 index 000000000..40e82cfa1 --- /dev/null +++ b/src/cleveragents/resource/handlers/resolver.py @@ -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()