485 lines
15 KiB
Python
485 lines
15 KiB
Python
"""Step definitions for subplan model hierarchy, defaults, and guardrails."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from behave import given, then, when
|
|
from behave.runner import Context
|
|
from pydantic import ValidationError
|
|
|
|
from cleveragents.domain.models.core.plan import (
|
|
AutomationLevel,
|
|
ExecutionMode,
|
|
NamespacedName,
|
|
Plan,
|
|
PlanIdentity,
|
|
PlanPhase,
|
|
ProcessingState,
|
|
SubplanConfig,
|
|
SubplanFailureHandler,
|
|
SubplanMergeStrategy,
|
|
SubplanStatus,
|
|
can_transition,
|
|
)
|
|
|
|
_R = "01ARZ3NDEKTSV4RRFFQ69G5FAV" # root ULID
|
|
_P = "01ARZ3NDEKTSV4RRFFQ69G5FAW" # parent ULID
|
|
_C = "01ARZ3NDEKTSV4RRFFQ69G5FAX" # child ULID
|
|
_G = "01ARZ3NDEKTSV4RRFFQ69G5FAY" # grandchild ULID
|
|
_S = "01KH72MDGQMPRW3R5WPZVB8KC1" # status ULID
|
|
|
|
|
|
def _plan(
|
|
pid: str = _R,
|
|
par: str | None = None,
|
|
root: str | None = None,
|
|
att: int = 1,
|
|
ph: PlanPhase = PlanPhase.STRATEGIZE,
|
|
ps: ProcessingState = ProcessingState.QUEUED,
|
|
al: AutomationLevel = AutomationLevel.MANUAL,
|
|
sc: SubplanConfig | None = None,
|
|
ss: list[SubplanStatus] | None = None,
|
|
) -> Plan:
|
|
"""Build a Plan with sensible defaults for subplan testing."""
|
|
return Plan(
|
|
identity=PlanIdentity(
|
|
plan_id=pid,
|
|
parent_plan_id=par,
|
|
root_plan_id=root,
|
|
attempt=att,
|
|
),
|
|
namespaced_name=NamespacedName(
|
|
server=None,
|
|
namespace="local",
|
|
name="subplan-test",
|
|
),
|
|
description="Subplan test plan",
|
|
action_name="local/subplan-action",
|
|
phase=ph,
|
|
processing_state=ps,
|
|
automation_level=al,
|
|
subplan_config=sc,
|
|
subplan_statuses=ss or [],
|
|
)
|
|
|
|
|
|
def _st(err: str | None = None, att: int = 1) -> SubplanStatus:
|
|
"""Build a SubplanStatus."""
|
|
return SubplanStatus(
|
|
subplan_id=_S,
|
|
action_name="local/sub-task",
|
|
status=ProcessingState.ERRORED if err else ProcessingState.QUEUED,
|
|
error=err,
|
|
attempt_number=att,
|
|
target_resources=["src/main.py"],
|
|
)
|
|
|
|
|
|
# -- Given: plan creation --
|
|
@given("I create a subplan-test root plan")
|
|
def step_g01(ctx: Context) -> None:
|
|
"""Create root plan."""
|
|
ctx.subplan_plan = _plan()
|
|
|
|
|
|
@given("I create a subplan-test plan with root_plan_id equal to plan_id")
|
|
def step_g02(ctx: Context) -> None:
|
|
"""Create root plan with explicit root_plan_id."""
|
|
ctx.subplan_plan = _plan(root=_R)
|
|
|
|
|
|
@given("I create a subplan-test child plan with a parent")
|
|
def step_g03(ctx: Context) -> None:
|
|
"""Create child plan."""
|
|
ctx.subplan_plan = _plan(pid=_C, par=_P, root=_R)
|
|
|
|
|
|
@given("I create a subplan-test three-level hierarchy")
|
|
def step_g05(ctx: Context) -> None:
|
|
"""Create root/parent/grandchild."""
|
|
ctx.subplan_root = _plan(pid=_R, root=_R)
|
|
ctx.subplan_parent = _plan(pid=_P, par=_R, root=_R)
|
|
ctx.subplan_grandchild = _plan(pid=_G, par=_P, root=_R)
|
|
|
|
|
|
@given("I create a subplan-test default plan")
|
|
def step_g06(ctx: Context) -> None:
|
|
"""Create default plan."""
|
|
ctx.subplan_plan = _plan()
|
|
|
|
|
|
@given("I create a subplan-test plan with sequential subplan config")
|
|
def step_g07(ctx: Context) -> None:
|
|
"""Plan with sequential config."""
|
|
ctx.subplan_plan = _plan(
|
|
sc=SubplanConfig(
|
|
execution_mode=ExecutionMode.SEQUENTIAL,
|
|
merge_strategy=SubplanMergeStrategy.GIT_THREE_WAY,
|
|
fail_fast=False,
|
|
)
|
|
)
|
|
|
|
|
|
@given("I create a subplan-test plan with parallel subplan config")
|
|
def step_g08(ctx: Context) -> None:
|
|
"""Plan with parallel config."""
|
|
ctx.subplan_plan = _plan(
|
|
sc=SubplanConfig(
|
|
execution_mode=ExecutionMode.PARALLEL,
|
|
max_parallel=10,
|
|
retry_failed=True,
|
|
max_retries=3,
|
|
)
|
|
)
|
|
|
|
|
|
@given("I create a subplan-test default subplan config")
|
|
def step_g09(ctx: Context) -> None:
|
|
"""Standalone SubplanConfig with defaults."""
|
|
ctx.subplan_config = SubplanConfig()
|
|
|
|
|
|
@given("I create a subplan-test plan with one subplan status")
|
|
def step_g10(ctx: Context) -> None:
|
|
"""Plan with one status."""
|
|
ctx.subplan_plan = _plan(ss=[_st()])
|
|
|
|
|
|
@given("I create a subplan-test fail-fast config with errored status")
|
|
def step_g13(ctx: Context) -> None:
|
|
"""Fail-fast config + errored status."""
|
|
ctx.subplan_handler_config = SubplanConfig(
|
|
execution_mode=ExecutionMode.PARALLEL, fail_fast=True
|
|
)
|
|
ctx.subplan_handler_status = _st("TimeoutError: timed out")
|
|
|
|
|
|
@given("I create a subplan-test parallel-no-fail-fast config with errored status")
|
|
def step_g14(ctx: Context) -> None:
|
|
"""Parallel (no fail_fast) + errored status."""
|
|
ctx.subplan_handler_config = SubplanConfig(
|
|
execution_mode=ExecutionMode.PARALLEL, fail_fast=False
|
|
)
|
|
ctx.subplan_handler_status = _st("TimeoutError: timed out")
|
|
|
|
|
|
@given("I create a subplan-test config with retriable timeout error")
|
|
def step_g15(ctx: Context) -> None:
|
|
"""Config + retriable TimeoutError."""
|
|
ctx.subplan_handler_config = SubplanConfig(retry_failed=True, max_retries=2)
|
|
ctx.subplan_handler_status = _st("TimeoutError: op timed out", 1)
|
|
|
|
|
|
@given("I create a subplan-test config with non-retriable configuration error")
|
|
def step_g16(ctx: Context) -> None:
|
|
"""Config + non-retriable ConfigurationError."""
|
|
ctx.subplan_handler_config = SubplanConfig(retry_failed=True, max_retries=2)
|
|
ctx.subplan_handler_status = _st("ConfigurationError: bad", 1)
|
|
|
|
|
|
@given("I create a subplan-test config with retries disabled and retriable error")
|
|
def step_g17(ctx: Context) -> None:
|
|
"""Config (retries off) + retriable error."""
|
|
ctx.subplan_handler_config = SubplanConfig(retry_failed=False)
|
|
ctx.subplan_handler_status = _st("TimeoutError: timed out", 1)
|
|
|
|
|
|
@given("I create a subplan-test config with max retries exceeded")
|
|
def step_g18(ctx: Context) -> None:
|
|
"""Config + attempt > max_retries."""
|
|
ctx.subplan_handler_config = SubplanConfig(retry_failed=True, max_retries=2)
|
|
ctx.subplan_handler_status = _st("TimeoutError: timed out", 3)
|
|
|
|
|
|
@given("I build a subplan-test plan with the child identity")
|
|
def step_g19(ctx: Context) -> None:
|
|
"""Alias plan for CLI dict assertion."""
|
|
ctx.subplan_cli_plan = ctx.subplan_plan
|
|
|
|
|
|
@given("I build a subplan-test plan with the root identity")
|
|
def step_g20(ctx: Context) -> None:
|
|
"""Alias plan for CLI dict assertion."""
|
|
ctx.subplan_cli_plan = ctx.subplan_plan
|
|
|
|
|
|
# -- When: validation errors --
|
|
@when("I try to create a subplan-test identity with invalid parent ULID")
|
|
def step_w01(ctx: Context) -> None:
|
|
"""Invalid parent_plan_id."""
|
|
ctx.error = None
|
|
try:
|
|
PlanIdentity(plan_id=_R, parent_plan_id="not-a-valid-ulid")
|
|
except ValidationError as e:
|
|
ctx.error = e
|
|
|
|
|
|
@when("I try to create a subplan-test config with max_parallel {v:d}")
|
|
def step_w02(ctx: Context, v: int) -> None:
|
|
"""Invalid max_parallel."""
|
|
ctx.error = None
|
|
try:
|
|
SubplanConfig(max_parallel=v)
|
|
except ValidationError as e:
|
|
ctx.error = e
|
|
|
|
|
|
@when("I try to create a subplan-test config with max_retries {v:d}")
|
|
def step_w03(ctx: Context, v: int) -> None:
|
|
"""Invalid max_retries."""
|
|
ctx.error = None
|
|
try:
|
|
SubplanConfig(max_retries=v)
|
|
except ValidationError as e:
|
|
ctx.error = e
|
|
|
|
|
|
# -- Then: plan identity --
|
|
@then("the subplan-test plan should not be a subplan")
|
|
def step_t01(ctx: Context) -> None:
|
|
"""Not a subplan."""
|
|
assert not ctx.subplan_plan.is_subplan
|
|
|
|
|
|
@then("the subplan-test plan should be a subplan")
|
|
def step_t02(ctx: Context) -> None:
|
|
"""Is a subplan."""
|
|
assert ctx.subplan_plan.is_subplan
|
|
|
|
|
|
@then("the subplan-test plan should be a root plan")
|
|
def step_t03(ctx: Context) -> None:
|
|
"""Is root."""
|
|
assert ctx.subplan_plan.is_root_plan
|
|
|
|
|
|
@then("the subplan-test plan should not be a root plan")
|
|
def step_t04(ctx: Context) -> None:
|
|
"""Not root."""
|
|
assert not ctx.subplan_plan.is_root_plan
|
|
|
|
|
|
@then("the subplan-test plan depth should be {e:d}")
|
|
def step_t05(ctx: Context, e: int) -> None:
|
|
"""Depth check."""
|
|
assert ctx.subplan_plan.depth == e
|
|
|
|
|
|
@then("the subplan-test plan attempt should be {e:d}")
|
|
def step_t06(ctx: Context, e: int) -> None:
|
|
"""Attempt check."""
|
|
assert ctx.subplan_plan.identity.attempt == e
|
|
|
|
|
|
@then('the subplan-test plan phase should be "{e}"')
|
|
def step_t07(ctx: Context, e: str) -> None:
|
|
"""Phase check."""
|
|
assert ctx.subplan_plan.phase.value == e
|
|
|
|
|
|
@then('the subplan-test plan processing state should be "{e}"')
|
|
def step_t08(ctx: Context, e: str) -> None:
|
|
"""Processing state check."""
|
|
assert ctx.subplan_plan.processing_state.value == e
|
|
|
|
|
|
@then("the subplan-test plan should not be terminal")
|
|
def step_t09(ctx: Context) -> None:
|
|
"""Not terminal."""
|
|
assert not ctx.subplan_plan.is_terminal
|
|
|
|
|
|
@then("the subplan-test plan should not be errored")
|
|
def step_t10(ctx: Context) -> None:
|
|
"""Not errored."""
|
|
assert not ctx.subplan_plan.is_errored
|
|
|
|
|
|
@then('the subplan-test plan automation level should be "{e}"')
|
|
def step_t11(ctx: Context, e: str) -> None:
|
|
"""Automation level check."""
|
|
assert ctx.subplan_plan.automation_level.value == e
|
|
|
|
|
|
@then("the subplan-test plan subplan_config should be none")
|
|
def step_t12(ctx: Context) -> None:
|
|
"""No subplan config."""
|
|
assert ctx.subplan_plan.subplan_config is None
|
|
|
|
|
|
@then("the subplan-test plan should have {c:d} subplan statuses")
|
|
def step_t13(ctx: Context, c: int) -> None:
|
|
"""Status count check."""
|
|
assert len(ctx.subplan_plan.subplan_statuses) == c
|
|
|
|
|
|
# -- Then: subplan config on plan --
|
|
@then('the subplan-test plan subplan_config execution_mode should be "{e}"')
|
|
def step_t14(ctx: Context, e: str) -> None:
|
|
"""Config execution_mode."""
|
|
assert ctx.subplan_plan.subplan_config is not None
|
|
assert ctx.subplan_plan.subplan_config.execution_mode.value == e
|
|
|
|
|
|
@then('the subplan-test plan subplan_config merge_strategy should be "{e}"')
|
|
def step_t15(ctx: Context, e: str) -> None:
|
|
"""Config merge_strategy."""
|
|
assert ctx.subplan_plan.subplan_config is not None
|
|
assert ctx.subplan_plan.subplan_config.merge_strategy.value == e
|
|
|
|
|
|
@then("the subplan-test plan subplan_config fail_fast should be {e}")
|
|
def step_t16(ctx: Context, e: str) -> None:
|
|
"""Config fail_fast."""
|
|
assert ctx.subplan_plan.subplan_config is not None
|
|
assert ctx.subplan_plan.subplan_config.fail_fast is (e.lower() == "true")
|
|
|
|
|
|
@then("the subplan-test plan subplan_config max_parallel should be {e:d}")
|
|
def step_t17(ctx: Context, e: int) -> None:
|
|
"""Config max_parallel."""
|
|
assert ctx.subplan_plan.subplan_config is not None
|
|
assert ctx.subplan_plan.subplan_config.max_parallel == e
|
|
|
|
|
|
@then("the subplan-test plan subplan_config retry_failed should be {e}")
|
|
def step_t18(ctx: Context, e: str) -> None:
|
|
"""Config retry_failed."""
|
|
assert ctx.subplan_plan.subplan_config is not None
|
|
assert ctx.subplan_plan.subplan_config.retry_failed is (e.lower() == "true")
|
|
|
|
|
|
@then("the subplan-test plan subplan_config max_retries should be {e:d}")
|
|
def step_t19(ctx: Context, e: int) -> None:
|
|
"""Config max_retries."""
|
|
assert ctx.subplan_plan.subplan_config is not None
|
|
assert ctx.subplan_plan.subplan_config.max_retries == e
|
|
|
|
|
|
# -- Then: standalone SubplanConfig defaults --
|
|
@then('the subplan-test subplan config execution_mode should be "{e}"')
|
|
def step_t20(ctx: Context, e: str) -> None:
|
|
"""Standalone config execution_mode."""
|
|
assert ctx.subplan_config.execution_mode.value == e
|
|
|
|
|
|
@then('the subplan-test subplan config merge_strategy should be "{e}"')
|
|
def step_t21(ctx: Context, e: str) -> None:
|
|
"""Standalone config merge_strategy."""
|
|
assert ctx.subplan_config.merge_strategy.value == e
|
|
|
|
|
|
@then("the subplan-test subplan config max_parallel should be {e:d}")
|
|
def step_t22(ctx: Context, e: int) -> None:
|
|
"""Standalone config max_parallel."""
|
|
assert ctx.subplan_config.max_parallel == e
|
|
|
|
|
|
@then("the subplan-test subplan config fail_fast should be {e}")
|
|
def step_t23(ctx: Context, e: str) -> None:
|
|
"""Standalone config fail_fast."""
|
|
assert ctx.subplan_config.fail_fast is (e.lower() == "true")
|
|
|
|
|
|
@then("the subplan-test subplan config retry_failed should be {e}")
|
|
def step_t24(ctx: Context, e: str) -> None:
|
|
"""Standalone config retry_failed."""
|
|
assert ctx.subplan_config.retry_failed is (e.lower() == "true")
|
|
|
|
|
|
@then("the subplan-test subplan config max_retries should be {e:d}")
|
|
def step_t25(ctx: Context, e: int) -> None:
|
|
"""Standalone config max_retries."""
|
|
assert ctx.subplan_config.max_retries == e
|
|
|
|
|
|
@then("the subplan-test subplan config timeout should be none")
|
|
def step_t26(ctx: Context) -> None:
|
|
"""Standalone config timeout is None."""
|
|
assert ctx.subplan_config.timeout_per_subplan_seconds is None
|
|
|
|
|
|
# -- Then: has_subplans --
|
|
@then("the subplan-test plan should have subplans")
|
|
def step_t27(ctx: Context) -> None:
|
|
"""Has subplans."""
|
|
assert ctx.subplan_plan.has_subplans
|
|
|
|
|
|
@then("the subplan-test plan should not have subplans")
|
|
def step_t28(ctx: Context) -> None:
|
|
"""No subplans."""
|
|
assert not ctx.subplan_plan.has_subplans
|
|
|
|
|
|
# -- Then: hierarchy --
|
|
@then("the subplan-test grandchild root_plan_id should match the root plan_id")
|
|
def step_t29(ctx: Context) -> None:
|
|
"""Grandchild root matches root."""
|
|
assert (
|
|
ctx.subplan_grandchild.identity.root_plan_id
|
|
== ctx.subplan_root.identity.plan_id
|
|
)
|
|
|
|
|
|
# -- Then: transitions --
|
|
@then('subplan-test transition from "{f}" to "{t}" should be valid')
|
|
def step_t30(ctx: Context, f: str, t: str) -> None:
|
|
"""Valid transition."""
|
|
assert can_transition(PlanPhase(f), PlanPhase(t))
|
|
|
|
|
|
@then('subplan-test transition from "{f}" to "{t}" should be invalid')
|
|
def step_t31(ctx: Context, f: str, t: str) -> None:
|
|
"""Invalid transition."""
|
|
assert not can_transition(PlanPhase(f), PlanPhase(t))
|
|
|
|
|
|
# -- Then: failure handler --
|
|
@then("the subplan-test failure handler should stop others")
|
|
def step_t34(ctx: Context) -> None:
|
|
"""Handler stops others."""
|
|
h = SubplanFailureHandler()
|
|
assert h.should_stop_others(ctx.subplan_handler_config, ctx.subplan_handler_status)
|
|
|
|
|
|
@then("the subplan-test failure handler should not stop others")
|
|
def step_t35(ctx: Context) -> None:
|
|
"""Handler does not stop others."""
|
|
h = SubplanFailureHandler()
|
|
assert not h.should_stop_others(
|
|
ctx.subplan_handler_config, ctx.subplan_handler_status
|
|
)
|
|
|
|
|
|
@then("the subplan-test failure handler should retry")
|
|
def step_t36(ctx: Context) -> None:
|
|
"""Handler retries."""
|
|
h = SubplanFailureHandler()
|
|
assert h.should_retry(ctx.subplan_handler_config, ctx.subplan_handler_status)
|
|
|
|
|
|
@then("the subplan-test failure handler should not retry")
|
|
def step_t37(ctx: Context) -> None:
|
|
"""Handler does not retry."""
|
|
h = SubplanFailureHandler()
|
|
assert not h.should_retry(ctx.subplan_handler_config, ctx.subplan_handler_status)
|
|
|
|
|
|
# -- Then: CLI dict --
|
|
@then("the subplan-test cli dict should contain parent_plan_id")
|
|
def step_t38(ctx: Context) -> None:
|
|
"""CLI dict has parent_plan_id."""
|
|
assert "parent_plan_id" in ctx.subplan_cli_plan.as_cli_dict()
|
|
|
|
|
|
@then("the subplan-test cli dict should contain subplan_count of {e:d}")
|
|
def step_t39(ctx: Context, e: int) -> None:
|
|
"""CLI dict subplan_count."""
|
|
assert ctx.subplan_plan.as_cli_dict().get("subplan_count") == e
|
|
|
|
|
|
@then("the subplan-test cli dict should not contain parent_plan_id")
|
|
def step_t40(ctx: Context) -> None:
|
|
"""CLI dict no parent_plan_id."""
|
|
assert "parent_plan_id" not in ctx.subplan_cli_plan.as_cli_dict()
|