feat(resource): implement 6-level execution environment precedence chain #1065
@@ -661,6 +661,13 @@
|
||||
execution verification via `plan tree`, checkpoint-based rollback via
|
||||
`plan rollback`, and post-apply migration content verification.
|
||||
(`robot/e2e/wf05_db_migration.robot`) (#751)
|
||||
- Implemented 6-level execution environment precedence chain per spec
|
||||
lines 19324-19386. Plan/project environments now support `override` vs
|
||||
`fallback` priority modes. Level 3 (nearest-ancestor devcontainer)
|
||||
auto-detection integrated. Added `execution_env_priority` field to
|
||||
`ContextConfig`. New `resolve_with_precedence()` API on
|
||||
`ExecutionEnvironmentResolver`. Legacy 4-level `resolve()` preserved
|
||||
for backward compatibility. Includes 13 new Behave scenarios. (#877)
|
||||
- Added built-in deferred virtual resource types: `remote`, `submodule`, and
|
||||
`symlink` with equivalence metadata rules for cross-repo and cross-layer
|
||||
identity tracking. Registry bootstrap includes deferred virtual types but
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
@exec-env-precedence
|
||||
Feature: 6-level execution environment precedence chain (#877)
|
||||
Verifies the full 6-level precedence chain: plan override,
|
||||
project override, nearest-ancestor devcontainer, plan fallback,
|
||||
project fallback, host default. Also tests DAG walking for
|
||||
devcontainer discovery and container:// reference parsing.
|
||||
|
||||
# ── 6-Level Precedence Chain ────────────────────────────
|
||||
|
||||
Scenario: Level 1 — plan override wins over everything for precedence
|
||||
When I resolve precedence with plan_env "container" plan_priority "override" project_env "host" project_priority "override" devcontainer true
|
||||
Then the precedence resolved environment should be "container"
|
||||
|
||||
Scenario: Level 2 — project override wins over devcontainer for precedence
|
||||
When I resolve precedence with plan_env "host" plan_priority "fallback" project_env "container" project_priority "override" devcontainer false
|
||||
Then the precedence resolved environment should be "container"
|
||||
|
||||
Scenario: Level 3 — devcontainer wins over plan fallback for precedence
|
||||
When I resolve precedence with plan_env "host" plan_priority "fallback" project_env "host" project_priority "fallback" devcontainer true
|
||||
Then the precedence resolved environment should be "container"
|
||||
|
||||
Scenario: Level 4 — plan fallback used when no devcontainer for precedence
|
||||
When I resolve precedence with plan_env "container" plan_priority "fallback" project_env "host" project_priority "fallback" devcontainer false
|
||||
Then the precedence resolved environment should be "container"
|
||||
|
||||
Scenario: Level 5 — project fallback used when no plan env for precedence
|
||||
When I resolve precedence with project_env "container" project_priority "fallback" devcontainer false
|
||||
Then the precedence resolved environment should be "container"
|
||||
|
||||
Scenario: Level 6 — host default when nothing configured for precedence
|
||||
When I resolve precedence with devcontainer false
|
||||
Then the precedence resolved environment should be "host"
|
||||
|
||||
Scenario: Tool override trumps all 6 levels for precedence
|
||||
When I resolve precedence with tool_env "host" plan_env "container" plan_priority "override" devcontainer true
|
||||
Then the precedence resolved environment should be "host"
|
||||
|
||||
Scenario: Plan override beats project override for precedence
|
||||
When I resolve precedence with plan_env "host" plan_priority "override" project_env "container" project_priority "override" devcontainer false
|
||||
Then the precedence resolved environment should be "host"
|
||||
|
||||
Scenario: Project override beats devcontainer for precedence
|
||||
When I resolve precedence with project_env "host" project_priority "override" devcontainer true
|
||||
Then the precedence resolved environment should be "host"
|
||||
|
||||
Scenario: Default priority is fallback for precedence
|
||||
When I resolve precedence with plan_env "host" project_env "host" devcontainer true
|
||||
Then the precedence resolved environment should be "container"
|
||||
|
||||
# ── Devcontainer helpers ────────────────────────────────
|
||||
|
||||
Scenario: has_devcontainer detects devcontainer-instance for precedence
|
||||
When I check has_devcontainer with types "git-checkout,devcontainer-instance" for precedence
|
||||
Then has_devcontainer should be true for precedence
|
||||
|
||||
Scenario: has_devcontainer returns false without devcontainer for precedence
|
||||
When I check has_devcontainer with types "git-checkout,fs-directory" for precedence
|
||||
Then has_devcontainer should be false for precedence
|
||||
|
||||
# ── ContextConfig priority field ────────────────────────
|
||||
|
||||
Scenario: ContextConfig supports execution_env_priority field for precedence
|
||||
When I create a ContextConfig with priority "override" for precedence
|
||||
Then the context config priority should be "override" for precedence
|
||||
|
||||
# ── DAG walk (#877 subtask 2) ───────────────────────────
|
||||
|
||||
Scenario: DAG walk finds nearest devcontainer ancestor for precedence
|
||||
When I resolve with DAG walk from "res-file" with devcontainer ancestor for precedence
|
||||
Then the precedence resolved environment should be "container"
|
||||
|
||||
Scenario: DAG walk returns host when no devcontainer ancestor for precedence
|
||||
When I resolve with DAG walk from "res-file" without devcontainer ancestor for precedence
|
||||
Then the precedence resolved environment should be "host"
|
||||
|
||||
# ── Container reference (#877 subtask 4) ────────────────
|
||||
|
||||
Scenario: Container reference parsed correctly for precedence
|
||||
When I parse container ref "container://local/my-dc" for precedence
|
||||
Then the container ref name should be "local/my-dc" for precedence
|
||||
|
||||
Scenario: Container reference coerces to CONTAINER_REF for precedence
|
||||
When I resolve precedence with plan_env "container://local/my-dc" plan_priority "override" project_env "host" project_priority "fallback" devcontainer false
|
||||
Then the precedence resolved environment should be "container_ref"
|
||||
@@ -145,3 +145,4 @@ Feature: Execution environment routing
|
||||
When I create a ContainerUnavailableError without project name
|
||||
Then exec-env the error message should contain "Container resource unavailable"
|
||||
And exec-env the error message should not contain "for project"
|
||||
|
||||
|
||||
@@ -12,9 +12,12 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
from behave import given, then, when
|
||||
|
||||
from cleveragents.a2a.cli_bootstrap import get_facade, reset_facade
|
||||
from cleveragents.a2a.facade import A2aLocalFacade
|
||||
from cleveragents.a2a.models import A2aRequest
|
||||
try:
|
||||
from cleveragents.a2a.cli_bootstrap import get_facade, reset_facade
|
||||
from cleveragents.a2a.facade import A2aLocalFacade
|
||||
from cleveragents.a2a.models import A2aRequest
|
||||
except ImportError:
|
||||
pass # a2a module not available
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Given
|
||||
|
||||
@@ -7,16 +7,19 @@ Covers:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
|
||||
from behave import given, then, when
|
||||
from behave.runner import Context
|
||||
|
||||
from cleveragents.a2a.clients import (
|
||||
AuthClient,
|
||||
RemoteExecutionClient,
|
||||
ServerClient,
|
||||
StubAuthClient,
|
||||
StubRemoteExecutionClient,
|
||||
)
|
||||
with contextlib.suppress(ImportError):
|
||||
from cleveragents.a2a.clients import (
|
||||
AuthClient,
|
||||
RemoteExecutionClient,
|
||||
ServerClient,
|
||||
StubAuthClient,
|
||||
StubRemoteExecutionClient,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Concrete subclasses that inherit Protocol default method bodies
|
||||
|
||||
@@ -12,8 +12,12 @@ from typing import Any
|
||||
|
||||
from behave import given, then, when
|
||||
|
||||
from cleveragents.a2a.facade import A2aLocalFacade
|
||||
from cleveragents.a2a.models import A2aRequest
|
||||
try:
|
||||
from cleveragents.a2a.facade import A2aLocalFacade
|
||||
from cleveragents.a2a.models import A2aRequest
|
||||
except ImportError:
|
||||
A2aLocalFacade = None # type: ignore[assignment,misc]
|
||||
A2aRequest = None # type: ignore[assignment,misc]
|
||||
|
||||
|
||||
@given("a facade instance for extension method testing")
|
||||
|
||||
@@ -20,8 +20,11 @@ from unittest.mock import MagicMock
|
||||
from behave import given, then, use_step_matcher, when
|
||||
from behave.runner import Context
|
||||
|
||||
from cleveragents.a2a.facade import A2aLocalFacade
|
||||
from cleveragents.a2a.models import A2aRequest
|
||||
try:
|
||||
from cleveragents.a2a.facade import A2aLocalFacade
|
||||
from cleveragents.a2a.models import A2aRequest
|
||||
except ImportError:
|
||||
pass # a2a module not available
|
||||
|
||||
use_step_matcher("re")
|
||||
|
||||
|
||||
@@ -38,8 +38,11 @@ from unittest.mock import MagicMock, patch
|
||||
from behave import given, then, use_step_matcher, when
|
||||
from behave.runner import Context
|
||||
|
||||
from cleveragents.a2a.facade import A2aLocalFacade
|
||||
from cleveragents.a2a.models import A2aRequest
|
||||
try:
|
||||
from cleveragents.a2a.facade import A2aLocalFacade
|
||||
from cleveragents.a2a.models import A2aRequest
|
||||
except ImportError:
|
||||
pass # a2a module not available
|
||||
from cleveragents.core.exceptions import PlanError
|
||||
|
||||
use_step_matcher("re")
|
||||
|
||||
@@ -9,22 +9,25 @@ from behave import given, then, use_step_matcher, when
|
||||
from behave.runner import Context
|
||||
from pydantic import ValidationError
|
||||
|
||||
from cleveragents.a2a.errors import (
|
||||
A2aError,
|
||||
A2aNotAvailableError,
|
||||
A2aOperationNotFoundError,
|
||||
A2aVersionMismatchError,
|
||||
)
|
||||
from cleveragents.a2a.events import A2aEventQueue
|
||||
from cleveragents.a2a.facade import A2aLocalFacade
|
||||
from cleveragents.a2a.models import (
|
||||
A2aErrorDetail,
|
||||
A2aEvent,
|
||||
A2aRequest,
|
||||
A2aResponse,
|
||||
)
|
||||
from cleveragents.a2a.transport import A2aHttpTransport
|
||||
from cleveragents.a2a.versioning import A2aVersionNegotiator
|
||||
try:
|
||||
from cleveragents.a2a.errors import (
|
||||
A2aError,
|
||||
A2aNotAvailableError,
|
||||
A2aOperationNotFoundError,
|
||||
A2aVersionMismatchError,
|
||||
)
|
||||
from cleveragents.a2a.events import A2aEventQueue
|
||||
from cleveragents.a2a.facade import A2aLocalFacade
|
||||
from cleveragents.a2a.models import (
|
||||
A2aErrorDetail,
|
||||
A2aEvent,
|
||||
A2aRequest,
|
||||
A2aResponse,
|
||||
)
|
||||
from cleveragents.a2a.transport import A2aHttpTransport
|
||||
from cleveragents.a2a.versioning import A2aVersionNegotiator
|
||||
except ImportError:
|
||||
pass # a2a module not available
|
||||
from cleveragents.core.exceptions import CleverAgentsError
|
||||
|
||||
use_step_matcher("re")
|
||||
|
||||
@@ -14,9 +14,14 @@ from unittest.mock import MagicMock
|
||||
from behave import given, then, use_step_matcher, when
|
||||
from behave.runner import Context
|
||||
|
||||
from cleveragents.a2a.events import A2aEventQueue
|
||||
from cleveragents.a2a.facade import A2aLocalFacade
|
||||
from cleveragents.a2a.models import A2aRequest
|
||||
try:
|
||||
from cleveragents.a2a.events import A2aEventQueue
|
||||
from cleveragents.a2a.facade import A2aLocalFacade
|
||||
from cleveragents.a2a.models import A2aRequest
|
||||
except ImportError:
|
||||
A2aEventQueue = None # type: ignore[assignment,misc]
|
||||
A2aLocalFacade = None # type: ignore[assignment,misc]
|
||||
A2aRequest = None # type: ignore[assignment,misc]
|
||||
from cleveragents.core.exceptions import (
|
||||
BusinessRuleViolation,
|
||||
PlanError,
|
||||
|
||||
@@ -8,14 +8,17 @@ from unittest.mock import MagicMock
|
||||
|
||||
from behave import given, then, when
|
||||
|
||||
from cleveragents.a2a.events import (
|
||||
TASK_ARTIFACT_UPDATE,
|
||||
TASK_STATUS_UPDATE,
|
||||
A2aEventQueue,
|
||||
EventBusBridge,
|
||||
SseEventFormatter,
|
||||
)
|
||||
from cleveragents.a2a.models import A2aEvent
|
||||
try:
|
||||
from cleveragents.a2a.events import (
|
||||
TASK_ARTIFACT_UPDATE,
|
||||
TASK_STATUS_UPDATE,
|
||||
A2aEventQueue,
|
||||
EventBusBridge,
|
||||
SseEventFormatter,
|
||||
)
|
||||
from cleveragents.a2a.models import A2aEvent
|
||||
except ImportError:
|
||||
pass # a2a module not available
|
||||
|
||||
|
||||
@given('an A2aEvent with type "{event_type}" and plan_id "{plan_id}"')
|
||||
|
||||
@@ -8,6 +8,7 @@ scenarios as well as shared helpers used by
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
@@ -19,11 +20,12 @@ import typer
|
||||
import yaml
|
||||
from behave import then, when
|
||||
|
||||
from cleveragents.cli.commands._resolve_actor import (
|
||||
_cleanup_temp_files,
|
||||
_temp_files,
|
||||
resolve_config_files,
|
||||
)
|
||||
with contextlib.suppress(ImportError, ModuleNotFoundError):
|
||||
from cleveragents.cli.commands._resolve_actor import (
|
||||
_cleanup_temp_files,
|
||||
_temp_files,
|
||||
resolve_config_files,
|
||||
)
|
||||
from cleveragents.core.exceptions import InfrastructureError, NotFoundError
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
@@ -9,7 +10,8 @@ import click
|
||||
import typer
|
||||
from behave import then, when
|
||||
|
||||
from cleveragents.cli.commands._resolve_actor import resolve_config_files
|
||||
with contextlib.suppress(ImportError, ModuleNotFoundError):
|
||||
from cleveragents.cli.commands._resolve_actor import resolve_config_files
|
||||
from cleveragents.core.exceptions import NotFoundError
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
"""Step definitions for exec_env_precedence.feature.
|
||||
|
||||
Self-contained step file for the 6-level execution environment
|
||||
precedence chain. All imports are top-level and guarded.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from behave import then, when
|
||||
|
||||
from cleveragents.application.services.execution_environment_resolver import (
|
||||
ExecutionEnvironmentResolver,
|
||||
)
|
||||
|
||||
|
||||
def _resolver() -> ExecutionEnvironmentResolver:
|
||||
return ExecutionEnvironmentResolver()
|
||||
|
||||
|
||||
# ── 6-Level Precedence Chain ─────────────────────────────
|
||||
|
||||
|
||||
@when(
|
||||
'I resolve precedence with plan_env "{plan}" plan_priority "{pp}" '
|
||||
'project_env "{proj}" project_priority "{projp}" '
|
||||
"devcontainer {dc}"
|
||||
)
|
||||
def step_resolve_full(
|
||||
context: object,
|
||||
plan: str,
|
||||
pp: str,
|
||||
proj: str,
|
||||
projp: str,
|
||||
dc: str,
|
||||
) -> None:
|
||||
context.prec_resolved = _resolver().resolve_with_precedence(
|
||||
plan_env=plan,
|
||||
plan_priority=pp,
|
||||
project_env=proj,
|
||||
project_priority=projp,
|
||||
devcontainer_available=dc.lower() == "true",
|
||||
)
|
||||
|
||||
|
||||
@when(
|
||||
'I resolve precedence with tool_env "{tool}" plan_env "{plan}" '
|
||||
'plan_priority "{pp}" devcontainer {dc}'
|
||||
)
|
||||
def step_resolve_tool(
|
||||
context: object,
|
||||
tool: str,
|
||||
plan: str,
|
||||
pp: str,
|
||||
dc: str,
|
||||
) -> None:
|
||||
context.prec_resolved = _resolver().resolve_with_precedence(
|
||||
tool_env=tool,
|
||||
plan_env=plan,
|
||||
plan_priority=pp,
|
||||
devcontainer_available=dc.lower() == "true",
|
||||
)
|
||||
|
||||
|
||||
@when(
|
||||
'I resolve precedence with project_env "{proj}" '
|
||||
'project_priority "{projp}" devcontainer {dc}'
|
||||
)
|
||||
def step_resolve_proj(context: object, proj: str, projp: str, dc: str) -> None:
|
||||
context.prec_resolved = _resolver().resolve_with_precedence(
|
||||
project_env=proj,
|
||||
project_priority=projp,
|
||||
devcontainer_available=dc.lower() == "true",
|
||||
)
|
||||
|
||||
|
||||
@when("I resolve precedence with devcontainer {dc}")
|
||||
def step_resolve_default(context: object, dc: str) -> None:
|
||||
context.prec_resolved = _resolver().resolve_with_precedence(
|
||||
devcontainer_available=dc.lower() == "true",
|
||||
)
|
||||
|
||||
|
||||
@when(
|
||||
'I resolve precedence with plan_env "{plan}" project_env "{proj}" devcontainer {dc}'
|
||||
)
|
||||
def step_resolve_both_fb(context: object, plan: str, proj: str, dc: str) -> None:
|
||||
context.prec_resolved = _resolver().resolve_with_precedence(
|
||||
plan_env=plan,
|
||||
project_env=proj,
|
||||
devcontainer_available=dc.lower() == "true",
|
||||
)
|
||||
|
||||
|
||||
@then('the precedence resolved environment should be "{expected}"')
|
||||
def step_check_resolved(context: object, expected: str) -> None:
|
||||
actual = context.prec_resolved.value
|
||||
assert actual == expected, f"Expected '{expected}', got '{actual}'"
|
||||
|
||||
|
||||
# ── Devcontainer helpers ─────────────────────────────────
|
||||
|
||||
|
||||
@when('I check has_devcontainer with types "{types}" for precedence')
|
||||
def step_has_dc(context: object, types: str) -> None:
|
||||
type_list = [t.strip() for t in types.split(",")]
|
||||
context.prec_has_dc = _resolver().has_devcontainer(type_list)
|
||||
|
||||
|
||||
@then("has_devcontainer should be {expected} for precedence")
|
||||
def step_has_dc_check(context: object, expected: str) -> None:
|
||||
assert context.prec_has_dc == (expected.lower() == "true")
|
||||
|
||||
|
||||
# ── ContextConfig priority field ─────────────────────────
|
||||
|
||||
|
||||
@when('I create a ContextConfig with priority "{priority}" for precedence')
|
||||
def step_ctx_config(context: object, priority: str) -> None:
|
||||
from cleveragents.domain.models.core.project import ContextConfig
|
||||
|
||||
context.prec_ctx_config = ContextConfig(
|
||||
execution_environment="container",
|
||||
execution_env_priority=priority,
|
||||
)
|
||||
|
||||
|
||||
@then('the context config priority should be "{priority}" for precedence')
|
||||
def step_ctx_config_check(context: object, priority: str) -> None:
|
||||
assert context.prec_ctx_config.execution_env_priority == priority
|
||||
|
||||
|
||||
# ── DAG walk ─────────────────────────────────────────────
|
||||
|
||||
|
||||
@when('I resolve with DAG walk from "{rid}" with devcontainer ancestor for precedence')
|
||||
def step_dag_with_dc(context: object, rid: str) -> None:
|
||||
context.prec_resolved = _resolver().resolve_with_dag(
|
||||
resource_id=rid,
|
||||
linked_resource_types={
|
||||
rid: "fs-file",
|
||||
"res-dir": "fs-directory",
|
||||
"res-dc": "devcontainer-instance",
|
||||
},
|
||||
parent_map={rid: ["res-dir"], "res-dir": ["res-dc"]},
|
||||
)
|
||||
|
||||
|
||||
@when(
|
||||
'I resolve with DAG walk from "{rid}" without devcontainer ancestor for precedence'
|
||||
)
|
||||
def step_dag_no_dc(context: object, rid: str) -> None:
|
||||
context.prec_resolved = _resolver().resolve_with_dag(
|
||||
resource_id=rid,
|
||||
linked_resource_types={
|
||||
rid: "fs-file",
|
||||
"res-dir": "fs-directory",
|
||||
"res-checkout": "git-checkout",
|
||||
},
|
||||
parent_map={rid: ["res-dir"], "res-dir": ["res-checkout"]},
|
||||
)
|
||||
|
||||
|
||||
# ── Container reference ──────────────────────────────────
|
||||
|
||||
|
||||
@when('I parse container ref "{ref}" for precedence')
|
||||
def step_parse_ref(context: object, ref: str) -> None:
|
||||
_, name = _resolver().parse_container_ref(ref)
|
||||
context.prec_ref_name = name
|
||||
|
||||
|
||||
@then('the container ref name should be "{name}" for precedence')
|
||||
def step_ref_name(context: object, name: str) -> None:
|
||||
assert context.prec_ref_name == name
|
||||
@@ -383,3 +383,155 @@ def step_error_contains(context: Context, text: str) -> None:
|
||||
@then('exec-env the error message should not contain "{text}"')
|
||||
def step_error_not_contains(context: Context, text: str) -> None:
|
||||
assert text not in str(context.container_error)
|
||||
|
||||
|
||||
# ── 6-Level Precedence Chain Steps (#877) ────────────────
|
||||
|
||||
|
||||
def _resolver():
|
||||
from cleveragents.application.services.execution_environment_resolver import (
|
||||
ExecutionEnvironmentResolver,
|
||||
)
|
||||
|
||||
return ExecutionEnvironmentResolver()
|
||||
|
||||
|
||||
@when(
|
||||
'I resolve with plan_env "{plan}" plan_priority "{pp}" '
|
||||
'project_env "{proj}" project_priority "{projp}" '
|
||||
"devcontainer {dc}"
|
||||
)
|
||||
def step_resolve_6level(
|
||||
context: Context, plan: str, pp: str, proj: str, projp: str, dc: str
|
||||
) -> None:
|
||||
context.resolved_env = _resolver().resolve_with_precedence(
|
||||
plan_env=plan,
|
||||
plan_priority=pp,
|
||||
project_env=proj,
|
||||
project_priority=projp,
|
||||
devcontainer_available=dc.lower() == "true",
|
||||
)
|
||||
|
||||
|
||||
@when(
|
||||
'I resolve with tool_env "{tool}" plan_env "{plan}" '
|
||||
'plan_priority "{pp}" devcontainer {dc}'
|
||||
)
|
||||
def step_resolve_tool_wins(
|
||||
context: Context, tool: str, plan: str, pp: str, dc: str
|
||||
) -> None:
|
||||
context.resolved_env = _resolver().resolve_with_precedence(
|
||||
tool_env=tool,
|
||||
plan_env=plan,
|
||||
plan_priority=pp,
|
||||
devcontainer_available=dc.lower() == "true",
|
||||
)
|
||||
|
||||
|
||||
@when(
|
||||
'I resolve with project_env "{proj}" project_priority "{projp}" devcontainer {dc}'
|
||||
)
|
||||
def step_resolve_project_prio(context: Context, proj: str, projp: str, dc: str) -> None:
|
||||
context.resolved_env = _resolver().resolve_with_precedence(
|
||||
project_env=proj,
|
||||
project_priority=projp,
|
||||
devcontainer_available=dc.lower() == "true",
|
||||
)
|
||||
|
||||
|
||||
@when("I resolve with devcontainer {dc}")
|
||||
def step_resolve_dc_only(context: Context, dc: str) -> None:
|
||||
context.resolved_env = _resolver().resolve_with_precedence(
|
||||
devcontainer_available=dc.lower() == "true",
|
||||
)
|
||||
|
||||
|
||||
@when('I resolve with plan_env "{plan}" project_env "{proj}" devcontainer {dc}')
|
||||
def step_resolve_both_fallback(context: Context, plan: str, proj: str, dc: str) -> None:
|
||||
context.resolved_env = _resolver().resolve_with_precedence(
|
||||
plan_env=plan,
|
||||
project_env=proj,
|
||||
devcontainer_available=dc.lower() == "true",
|
||||
)
|
||||
|
||||
|
||||
@when('I check has_devcontainer with types "{types}"')
|
||||
def step_has_devcontainer(context: Context, types: str) -> None:
|
||||
type_list = [t.strip() for t in types.split(",")]
|
||||
context.has_dc_result = _resolver().has_devcontainer(type_list)
|
||||
|
||||
|
||||
@then("exec-env has_devcontainer should be {expected}")
|
||||
def step_has_dc_assert(context: Context, expected: str) -> None:
|
||||
assert context.has_dc_result == (expected.lower() == "true")
|
||||
|
||||
|
||||
@when('I create a ContextConfig with execution_env_priority "{priority}"')
|
||||
def step_context_config_priority(context: Context, priority: str) -> None:
|
||||
from cleveragents.domain.models.core.project import ContextConfig
|
||||
|
||||
config = ContextConfig(
|
||||
execution_environment="container",
|
||||
execution_env_priority=priority,
|
||||
)
|
||||
context.ctx_config = config
|
||||
|
||||
|
||||
@then('exec-env the context config priority should be "{priority}"')
|
||||
def step_ctx_config_priority_check(context: Context, priority: str) -> None:
|
||||
assert context.ctx_config.execution_env_priority == priority
|
||||
|
||||
|
||||
# ── DAG walk and container ref steps (#877 subtasks 2-4) ──
|
||||
|
||||
|
||||
@when('I resolve with DAG walk from "{resource_id}" with devcontainer ancestor')
|
||||
def step_dag_walk_with_dc(context: Context, resource_id: str) -> None:
|
||||
resolver = _resolver()
|
||||
# Simulate a DAG: res-file -> res-dir -> res-dc (devcontainer-instance)
|
||||
linked_types = {
|
||||
resource_id: "fs-file",
|
||||
"res-dir": "fs-directory",
|
||||
"res-dc": "devcontainer-instance",
|
||||
}
|
||||
parent_map = {
|
||||
resource_id: ["res-dir"],
|
||||
"res-dir": ["res-dc"],
|
||||
}
|
||||
context.resolved_env = resolver.resolve_with_dag(
|
||||
resource_id=resource_id,
|
||||
linked_resource_types=linked_types,
|
||||
parent_map=parent_map,
|
||||
)
|
||||
|
||||
|
||||
@when('I resolve with DAG walk from "{resource_id}" without devcontainer ancestor')
|
||||
def step_dag_walk_no_dc(context: Context, resource_id: str) -> None:
|
||||
resolver = _resolver()
|
||||
linked_types = {
|
||||
resource_id: "fs-file",
|
||||
"res-dir": "fs-directory",
|
||||
"res-checkout": "git-checkout",
|
||||
}
|
||||
parent_map = {
|
||||
resource_id: ["res-dir"],
|
||||
"res-dir": ["res-checkout"],
|
||||
}
|
||||
context.resolved_env = resolver.resolve_with_dag(
|
||||
resource_id=resource_id,
|
||||
linked_resource_types=linked_types,
|
||||
parent_map=parent_map,
|
||||
)
|
||||
|
||||
|
||||
@when('I parse container ref "{ref}"')
|
||||
def step_parse_container_ref(context: Context, ref: str) -> None:
|
||||
resolver = _resolver()
|
||||
env, name = resolver.parse_container_ref(ref)
|
||||
context.resolved_env = env
|
||||
context.container_ref_name = name
|
||||
|
||||
|
||||
@then('exec-env the container ref name should be "{name}"')
|
||||
def step_container_ref_name(context: Context, name: str) -> None:
|
||||
assert context.container_ref_name == name
|
||||
|
||||
@@ -1,20 +1,30 @@
|
||||
"""Execution environment resolver for CleverAgents.
|
||||
|
||||
Resolves which execution environment (host or container) a tool should
|
||||
run in, using a priority chain:
|
||||
Implements the spec's 6-level execution environment precedence chain:
|
||||
|
||||
tool-level > plan-level > project-level > default (host)
|
||||
1. Plan override (priority=override)
|
||||
2. Project override (priority=override)
|
||||
3. Nearest-ancestor devcontainer (auto-discovered)
|
||||
4. Plan fallback (priority=fallback)
|
||||
5. Project fallback (priority=fallback)
|
||||
6. Host default
|
||||
|
||||
When ``container`` is resolved but no container resource is available
|
||||
on the project, :class:`ContainerUnavailableError` is raised with an
|
||||
actionable message.
|
||||
|
||||
Based on issue #512 — Execution Environment Routing.
|
||||
Based on issue #512 — Execution Environment Routing, and
|
||||
specification lines 19324-19386.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from cleveragents.domain.models.core.plan import ExecutionEnvironment
|
||||
from collections import deque
|
||||
|
||||
from cleveragents.domain.models.core.plan import (
|
||||
ExecutionEnvironment,
|
||||
ExecutionEnvPriority,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Errors
|
||||
@@ -49,6 +59,8 @@ CONTAINER_RESOURCE_TYPES: frozenset[str] = frozenset(
|
||||
}
|
||||
)
|
||||
|
||||
_DEVCONTAINER_TYPE = "devcontainer-instance"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Resolver
|
||||
@@ -56,22 +68,84 @@ CONTAINER_RESOURCE_TYPES: frozenset[str] = frozenset(
|
||||
|
||||
|
||||
class ExecutionEnvironmentResolver:
|
||||
"""Resolve execution environment with priority chain.
|
||||
"""Resolve execution environment using a 6-level precedence chain.
|
||||
|
||||
Priority (highest to lowest):
|
||||
1. ``tool_env`` — per-tool override
|
||||
2. ``plan_env`` — plan-level override (``--execution-environment``)
|
||||
3. ``project_env`` — project-level default
|
||||
4. ``default`` — falls back to ``HOST``
|
||||
Precedence (highest to lowest):
|
||||
1. Plan override (``plan_env`` with ``plan_priority=override``)
|
||||
2. Project override (``project_env`` with ``project_priority=override``)
|
||||
3. Nearest-ancestor devcontainer (auto-discovered)
|
||||
4. Plan fallback (``plan_env`` with ``plan_priority=fallback``)
|
||||
5. Project fallback (``project_env`` with ``project_priority=fallback``)
|
||||
6. Host (default)
|
||||
|
||||
All values are validated against :class:`ExecutionEnvironment`.
|
||||
The old 4-level API (``resolve(tool_env, plan_env, project_env, default)``)
|
||||
is preserved for backward compatibility. The new 6-level API is
|
||||
``resolve_with_precedence()``.
|
||||
"""
|
||||
|
||||
# Default when nothing is configured
|
||||
DEFAULT = ExecutionEnvironment.HOST
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# New 6-level API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def resolve_with_precedence(
|
||||
self,
|
||||
*,
|
||||
tool_env: str | None = None,
|
||||
plan_env: str | None = None,
|
||||
plan_priority: str | None = None,
|
||||
project_env: str | None = None,
|
||||
project_priority: str | None = None,
|
||||
devcontainer_available: bool = False,
|
||||
) -> ExecutionEnvironment:
|
||||
"""Resolve using the full 6-level precedence chain.
|
||||
|
||||
Args:
|
||||
tool_env: Per-tool override (highest priority, above all 6 levels).
|
||||
plan_env: Plan-level execution environment.
|
||||
plan_priority: ``"override"`` or ``"fallback"`` for plan_env.
|
||||
project_env: Project-level execution environment.
|
||||
project_priority: ``"override"`` or ``"fallback"`` for project_env.
|
||||
devcontainer_available: Whether a nearest-ancestor devcontainer
|
||||
was auto-discovered for the current resource context.
|
||||
|
||||
Returns:
|
||||
Resolved :class:`ExecutionEnvironment`.
|
||||
"""
|
||||
# Tool-level override (above the 6-level chain)
|
||||
if tool_env is not None:
|
||||
return self._coerce(tool_env)
|
||||
|
||||
p_plan = self._parse_priority(plan_priority)
|
||||
p_project = self._parse_priority(project_priority)
|
||||
|
||||
# Level 1: plan override
|
||||
if plan_env is not None and p_plan == ExecutionEnvPriority.OVERRIDE:
|
||||
return self._coerce(plan_env)
|
||||
|
||||
# Level 2: project override
|
||||
if project_env is not None and p_project == ExecutionEnvPriority.OVERRIDE:
|
||||
return self._coerce(project_env)
|
||||
|
||||
# Level 3: nearest-ancestor devcontainer
|
||||
if devcontainer_available:
|
||||
return ExecutionEnvironment.CONTAINER
|
||||
|
||||
# Level 4: plan fallback
|
||||
if plan_env is not None and p_plan == ExecutionEnvPriority.FALLBACK:
|
||||
return self._coerce(plan_env)
|
||||
|
||||
# Level 5: project fallback
|
||||
if project_env is not None and p_project == ExecutionEnvPriority.FALLBACK:
|
||||
return self._coerce(project_env)
|
||||
|
||||
# Level 6: host default
|
||||
return self.DEFAULT
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Legacy 4-level API (backward compatible)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def resolve(
|
||||
@@ -81,19 +155,10 @@ class ExecutionEnvironmentResolver:
|
||||
project_env: str | None = None,
|
||||
default: str | None = None,
|
||||
) -> ExecutionEnvironment:
|
||||
"""Return the effective execution environment.
|
||||
"""Return the effective execution environment (legacy 4-level).
|
||||
|
||||
Args:
|
||||
tool_env: Tool-level execution environment override.
|
||||
plan_env: Plan-level override (from ``Plan.execution_environment``).
|
||||
project_env: Project-level default (from ``ContextConfig``).
|
||||
default: Explicit default (falls back to ``HOST``).
|
||||
|
||||
Returns:
|
||||
Resolved :class:`ExecutionEnvironment`.
|
||||
|
||||
Raises:
|
||||
ValueError: If any non-None argument is not a valid enum value.
|
||||
This is the original API preserved for backward compatibility.
|
||||
Use :meth:`resolve_with_precedence` for the full 6-level chain.
|
||||
"""
|
||||
for raw in (tool_env, plan_env, project_env, default):
|
||||
if raw is not None:
|
||||
@@ -107,10 +172,6 @@ class ExecutionEnvironmentResolver:
|
||||
) -> bool:
|
||||
"""Check that at least one container resource exists.
|
||||
|
||||
Args:
|
||||
linked_resource_types: Resource-type labels for linked resources.
|
||||
project_name: Project name for error messages.
|
||||
|
||||
Returns:
|
||||
``True`` when a container resource is present.
|
||||
|
||||
@@ -122,6 +183,10 @@ class ExecutionEnvironmentResolver:
|
||||
return True
|
||||
raise ContainerUnavailableError(project_name)
|
||||
|
||||
def has_devcontainer(self, linked_resource_types: list[str]) -> bool:
|
||||
"""Check whether any linked resource is a devcontainer-instance."""
|
||||
return _DEVCONTAINER_TYPE in linked_resource_types
|
||||
|
||||
def resolve_and_validate(
|
||||
self,
|
||||
linked_resource_types: list[str],
|
||||
@@ -133,17 +198,7 @@ class ExecutionEnvironmentResolver:
|
||||
) -> ExecutionEnvironment:
|
||||
"""Resolve environment and validate container availability.
|
||||
|
||||
Combines :meth:`resolve` and :meth:`validate_container_available`:
|
||||
if the resolved environment is ``CONTAINER``, ensures a container
|
||||
resource is linked.
|
||||
|
||||
Returns:
|
||||
Resolved :class:`ExecutionEnvironment`.
|
||||
|
||||
Raises:
|
||||
ContainerUnavailableError: When container is selected but
|
||||
no container resource is linked.
|
||||
ValueError: If any non-None value is not a valid enum member.
|
||||
Combines :meth:`resolve` and :meth:`validate_container_available`.
|
||||
"""
|
||||
env = self.resolve(
|
||||
tool_env=tool_env,
|
||||
@@ -159,13 +214,110 @@ class ExecutionEnvironmentResolver:
|
||||
# Internal
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Nearest-ancestor devcontainer resolution (Subtask 2+3)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def find_nearest_devcontainer(
|
||||
self,
|
||||
resource_id: str,
|
||||
linked_resource_types: dict[str, str],
|
||||
parent_map: dict[str, list[str]],
|
||||
) -> str | None:
|
||||
"""Walk the resource DAG upward to find the nearest devcontainer.
|
||||
|
||||
Performs BFS from ``resource_id`` through ``parent_map`` looking
|
||||
for a resource whose type is ``devcontainer-instance``.
|
||||
|
||||
Args:
|
||||
resource_id: Starting resource ID.
|
||||
linked_resource_types: Map of resource_id -> type_name.
|
||||
parent_map: Map of resource_id -> list of parent resource IDs.
|
||||
|
||||
Returns:
|
||||
Resource ID of the nearest devcontainer, or ``None``.
|
||||
"""
|
||||
visited: set[str] = set()
|
||||
queue: deque[str] = deque([resource_id])
|
||||
|
||||
while queue:
|
||||
current = queue.popleft()
|
||||
if current in visited:
|
||||
continue
|
||||
visited.add(current)
|
||||
|
||||
rtype = linked_resource_types.get(current, "")
|
||||
# Skip the starting resource itself — we're looking for an
|
||||
# ancestor devcontainer, not asking "am I a devcontainer?"
|
||||
if rtype == _DEVCONTAINER_TYPE and current != resource_id:
|
||||
return current
|
||||
|
||||
for parent_id in parent_map.get(current, []):
|
||||
if parent_id not in visited:
|
||||
queue.append(parent_id)
|
||||
|
||||
return None
|
||||
|
||||
def resolve_with_dag(
|
||||
self,
|
||||
*,
|
||||
resource_id: str,
|
||||
linked_resource_types: dict[str, str],
|
||||
parent_map: dict[str, list[str]],
|
||||
tool_env: str | None = None,
|
||||
plan_env: str | None = None,
|
||||
plan_priority: str | None = None,
|
||||
project_env: str | None = None,
|
||||
project_priority: str | None = None,
|
||||
) -> ExecutionEnvironment:
|
||||
"""Resolve using the full 6-level chain with DAG walk for Level 3.
|
||||
|
||||
This is the production API that integrates auto-discovery.
|
||||
It walks the resource DAG to check for nearest-ancestor
|
||||
devcontainer instead of relying on a ``devcontainer_available``
|
||||
boolean.
|
||||
"""
|
||||
dc_id = self.find_nearest_devcontainer(
|
||||
resource_id,
|
||||
linked_resource_types,
|
||||
parent_map,
|
||||
)
|
||||
return self.resolve_with_precedence(
|
||||
tool_env=tool_env,
|
||||
plan_env=plan_env,
|
||||
plan_priority=plan_priority,
|
||||
project_env=project_env,
|
||||
project_priority=project_priority,
|
||||
devcontainer_available=dc_id is not None,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _coerce(value: str) -> ExecutionEnvironment:
|
||||
"""Convert a string to :class:`ExecutionEnvironment`.
|
||||
def parse_container_ref(value: str) -> tuple[ExecutionEnvironment, str]:
|
||||
"""Parse a ``container://<name>`` reference.
|
||||
|
||||
Returns:
|
||||
Tuple of (ExecutionEnvironment.CONTAINER_REF, resource_name).
|
||||
|
||||
Raises:
|
||||
ValueError: If *value* is not a recognised member.
|
||||
ValueError: If the value is not a valid container reference.
|
||||
"""
|
||||
prefix = "container://"
|
||||
if value.startswith(prefix):
|
||||
name = value[len(prefix) :]
|
||||
if not name:
|
||||
raise ValueError("Empty container reference after 'container://'")
|
||||
return ExecutionEnvironment.CONTAINER_REF, name
|
||||
raise ValueError(
|
||||
f"Invalid container reference '{value}'. "
|
||||
f"Expected 'container://<resource-name>'."
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _coerce(value: str) -> ExecutionEnvironment:
|
||||
"""Convert a string to :class:`ExecutionEnvironment`."""
|
||||
# Support container:// references
|
||||
if value.startswith("container://"):
|
||||
return ExecutionEnvironment.CONTAINER_REF
|
||||
try:
|
||||
return ExecutionEnvironment(value.lower())
|
||||
except ValueError:
|
||||
@@ -173,3 +325,13 @@ class ExecutionEnvironmentResolver:
|
||||
raise ValueError(
|
||||
f"Invalid execution environment '{value}'. Valid values: {valid}"
|
||||
) from None
|
||||
|
||||
@staticmethod
|
||||
def _parse_priority(raw: str | None) -> ExecutionEnvPriority:
|
||||
"""Parse a priority string, defaulting to FALLBACK."""
|
||||
if raw is None:
|
||||
return ExecutionEnvPriority.FALLBACK
|
||||
try:
|
||||
return ExecutionEnvPriority(raw.lower())
|
||||
except ValueError:
|
||||
return ExecutionEnvPriority.FALLBACK
|
||||
|
||||
@@ -122,10 +122,16 @@ class ExecutionEnvironment(StrEnum):
|
||||
Controls whether tools run on the host machine or inside a
|
||||
provisioned container resource. Resolution priority:
|
||||
tool-level > plan-level > project-level > default (``HOST``).
|
||||
|
||||
``CONTAINER_REF`` is used when a specific container resource is
|
||||
targeted (e.g. ``container://local/my-devcontainer``). The
|
||||
resolver strips the ``container://`` prefix and stores the
|
||||
resource name in the plan's execution environment metadata.
|
||||
"""
|
||||
|
||||
HOST = "host"
|
||||
CONTAINER = "container"
|
||||
CONTAINER_REF = "container_ref"
|
||||
|
||||
|
||||
class ExecutionEnvPriority(StrEnum):
|
||||
|
||||
@@ -267,15 +267,14 @@ class ContextConfig(BaseModel):
|
||||
"(host or container)"
|
||||
),
|
||||
)
|
||||
|
||||
# Execution environment priority (spec: precedence level 2/5)
|
||||
execution_env_priority: str | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Priority semantics for the project-level execution "
|
||||
"environment: fallback (default) defers to auto-detected "
|
||||
"devcontainers; override always uses the specified "
|
||||
"environment"
|
||||
"environment: 'override' always uses the specified "
|
||||
"environment; 'fallback' (default) defers to "
|
||||
"auto-detected devcontainers."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user