forked from HAL9000/cleveragents-core
f2f7aa5dc9
Implement multiple Stage A/B/E/SEC milestones for the v3 lifecycle system: - Stage A5.3+A5.4: Add LifecycleActionModel and LifecyclePlanModel SQLAlchemy models with to_domain()/from_domain() conversion methods - Stage A5.6: Implement ActionRepository with full CRUD, namespace/state queries, referential integrity checks, and retry decorator - Stage E1: Add subplan domain models (ExecutionMode, SubplanMergeStrategy, SubplanConfig, SubplanStatus, SubplanAttempt, SubplanFailureHandler) with computed properties on Plan (is_subplan, is_root_plan, depth, has_subplans) - Stage A6: Add AutomationLevel enum (MANUAL, REVIEW_BEFORE_APPLY, FULL_AUTOMATION), settings integration, PlanLifecycleService auto-progression, pause/resume, and CLI commands (--automation-level, set-automation-level) - Stage SEC1: Remove eval()/exec() from stream_router.py, replace with named operation and transform registries; code blocks and unregistered transforms now raise StreamRoutingError - Add langchain-anthropic dependency - Update BDD tests for security changes and relax ADR directory requirement
1124 lines
41 KiB
Python
1124 lines
41 KiB
Python
"""Step definitions for lifecycle data persistence and retrieval tests."""
|
|
|
|
import json
|
|
from datetime import datetime
|
|
|
|
from behave import given, then, when
|
|
from sqlalchemy import create_engine
|
|
from sqlalchemy.orm import sessionmaker
|
|
|
|
from cleveragents.infrastructure.database.models import (
|
|
Base,
|
|
LifecycleActionModel,
|
|
LifecyclePlanModel,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Background steps
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("the lifecycle database is ready")
|
|
def step_import_lifecycle_modules(context):
|
|
"""Set up an in-memory database with lifecycle tables."""
|
|
context.database_url = "sqlite:///:memory:"
|
|
context.engine = create_engine(context.database_url)
|
|
context.SessionLocal = sessionmaker(
|
|
bind=context.engine, autoflush=False, autocommit=False
|
|
)
|
|
Base.metadata.create_all(context.engine)
|
|
|
|
|
|
@given("a lifecycle database session is open")
|
|
def step_create_lifecycle_test_session(context):
|
|
"""Open a database session for the lifecycle tests."""
|
|
if not hasattr(context, "SessionLocal"):
|
|
step_import_lifecycle_modules(context)
|
|
context.db_session = context.SessionLocal()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helper: create a fully populated LifecycleActionModel
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
VALID_ULID_1 = "01HGZ6FE0AQDYTR4BXVQZ6E001"
|
|
VALID_ULID_2 = "01HGZ6FE0AQDYTR4BXVQZ6E002"
|
|
VALID_ULID_3 = "01HGZ6FE0AQDYTR4BXVQZ6E003"
|
|
VALID_ULID_4 = "01HGZ6FE0AQDYTR4BXVQZ6E004"
|
|
VALID_ULID_5 = "01HGZ6FE0AQDYTR4BXVQZ6E005"
|
|
|
|
|
|
def _make_action_model(
|
|
action_id=VALID_ULID_1,
|
|
name="local/test-action",
|
|
namespace="local",
|
|
short_name="test-action",
|
|
short_description="A short description",
|
|
long_description="A long description of the action",
|
|
definition_of_done="All tests pass and coverage > 80%",
|
|
strategy_actor="local/strategy-actor",
|
|
execution_actor="local/execution-actor",
|
|
estimation_actor="local/estimation-actor",
|
|
review_actor="local/review-actor",
|
|
inputs_schema="[]",
|
|
state="draft",
|
|
reusable=True,
|
|
read_only=False,
|
|
created_at="2025-06-01T12:00:00",
|
|
updated_at="2025-06-01T13:00:00",
|
|
created_by="test-user",
|
|
tags="[]",
|
|
):
|
|
"""Create a LifecycleActionModel with sensible defaults."""
|
|
return LifecycleActionModel(
|
|
action_id=action_id,
|
|
name=name,
|
|
namespace=namespace,
|
|
short_name=short_name,
|
|
short_description=short_description,
|
|
long_description=long_description,
|
|
definition_of_done=definition_of_done,
|
|
strategy_actor=strategy_actor,
|
|
execution_actor=execution_actor,
|
|
estimation_actor=estimation_actor,
|
|
review_actor=review_actor,
|
|
inputs_schema=inputs_schema,
|
|
state=state,
|
|
reusable=reusable,
|
|
read_only=read_only,
|
|
created_at=created_at,
|
|
updated_at=updated_at,
|
|
created_by=created_by,
|
|
tags=tags,
|
|
)
|
|
|
|
|
|
def _make_plan_model(
|
|
plan_id=VALID_ULID_1,
|
|
parent_plan_id=None,
|
|
root_plan_id=None,
|
|
action_id=VALID_ULID_1,
|
|
phase="action",
|
|
state="draft",
|
|
attempt=1,
|
|
automation_level="manual",
|
|
namespaced_name="local/test-plan",
|
|
description="A test plan description",
|
|
definition_of_done="Tests pass",
|
|
project_ids="[]",
|
|
strategy_actor="local/strategy-actor",
|
|
execution_actor="local/execution-actor",
|
|
error_message=None,
|
|
created_at="2025-06-01T12:00:00",
|
|
updated_at="2025-06-01T13:00:00",
|
|
completed_at=None,
|
|
strategize_started_at=None,
|
|
strategize_completed_at=None,
|
|
execute_started_at=None,
|
|
execute_completed_at=None,
|
|
apply_started_at=None,
|
|
applied_at=None,
|
|
created_by="test-user",
|
|
tags="[]",
|
|
reusable=True,
|
|
read_only=False,
|
|
):
|
|
"""Create a LifecyclePlanModel with sensible defaults."""
|
|
return LifecyclePlanModel(
|
|
plan_id=plan_id,
|
|
parent_plan_id=parent_plan_id,
|
|
root_plan_id=root_plan_id,
|
|
action_id=action_id,
|
|
phase=phase,
|
|
state=state,
|
|
attempt=attempt,
|
|
automation_level=automation_level,
|
|
namespaced_name=namespaced_name,
|
|
description=description,
|
|
definition_of_done=definition_of_done,
|
|
project_ids=project_ids,
|
|
strategy_actor=strategy_actor,
|
|
execution_actor=execution_actor,
|
|
error_message=error_message,
|
|
created_at=created_at,
|
|
updated_at=updated_at,
|
|
completed_at=completed_at,
|
|
strategize_started_at=strategize_started_at,
|
|
strategize_completed_at=strategize_completed_at,
|
|
execute_started_at=execute_started_at,
|
|
execute_completed_at=execute_completed_at,
|
|
apply_started_at=apply_started_at,
|
|
applied_at=applied_at,
|
|
created_by=created_by,
|
|
tags=tags,
|
|
reusable=reusable,
|
|
read_only=read_only,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Action: loading a stored record as a domain object
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("an action record exists with valid attributes and tags")
|
|
def step_create_action_model_valid(context):
|
|
"""Persist a valid action record with tags."""
|
|
context.action_model = _make_action_model(
|
|
tags='["tag1", "tag2"]',
|
|
)
|
|
context.db_session.add(context.action_model)
|
|
context.db_session.commit()
|
|
|
|
|
|
@when("the action record is loaded as a domain object")
|
|
def step_call_to_domain_on_action(context):
|
|
"""Load the persisted action record as a domain object."""
|
|
context.action_domain = context.action_model.to_domain()
|
|
|
|
|
|
@then("the loaded action should preserve its original identifier")
|
|
def step_verify_action_domain_id(context):
|
|
"""Verify the loaded action retains the original identifier."""
|
|
assert context.action_domain.action_id == VALID_ULID_1
|
|
|
|
|
|
@then("the loaded action should preserve its namespace and short name")
|
|
def step_verify_action_domain_namespaced_name(context):
|
|
"""Verify the loaded action retains its namespace and short name."""
|
|
assert context.action_domain.namespaced_name.namespace == "local"
|
|
assert context.action_domain.namespaced_name.name == "test-action"
|
|
|
|
|
|
@then("the loaded action should preserve its description fields")
|
|
def step_verify_action_domain_descriptions(context):
|
|
"""Verify the loaded action retains all description fields."""
|
|
assert context.action_domain.short_description == "A short description"
|
|
assert context.action_domain.long_description == "A long description of the action"
|
|
assert (
|
|
context.action_domain.definition_of_done == "All tests pass and coverage > 80%"
|
|
)
|
|
|
|
|
|
@then("the loaded action should preserve its actor assignments")
|
|
def step_verify_action_domain_actors(context):
|
|
"""Verify the loaded action retains all actor assignments."""
|
|
assert context.action_domain.strategy_actor == "local/strategy-actor"
|
|
assert context.action_domain.execution_actor == "local/execution-actor"
|
|
assert context.action_domain.estimation_actor == "local/estimation-actor"
|
|
assert context.action_domain.review_actor == "local/review-actor"
|
|
|
|
|
|
@then("the loaded action should preserve its state and flags")
|
|
def step_verify_action_domain_state_flags(context):
|
|
"""Verify the loaded action retains its state and boolean flags."""
|
|
from cleveragents.domain.models.core.plan import ActionState
|
|
|
|
assert context.action_domain.state == ActionState.DRAFT
|
|
assert context.action_domain.reusable is True
|
|
assert context.action_domain.read_only is False
|
|
|
|
|
|
@then("the loaded action should preserve its timestamps")
|
|
def step_verify_action_domain_timestamps(context):
|
|
"""Verify the loaded action retains its timestamps."""
|
|
assert context.action_domain.created_at == datetime.fromisoformat(
|
|
"2025-06-01T12:00:00"
|
|
)
|
|
assert context.action_domain.updated_at == datetime.fromisoformat(
|
|
"2025-06-01T13:00:00"
|
|
)
|
|
|
|
|
|
@then("the loaded action should preserve its tags")
|
|
def step_verify_action_domain_tags(context):
|
|
"""Verify the loaded action retains its tags."""
|
|
assert context.action_domain.tags == ["tag1", "tag2"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Action: loading a stored record with arguments
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("an action record exists with a structured arguments schema")
|
|
def step_create_action_model_with_args(context):
|
|
"""Persist an action record that has structured input arguments."""
|
|
args_json = json.dumps(
|
|
[
|
|
{
|
|
"name": "target_coverage",
|
|
"arg_type": "int",
|
|
"requirement": "required",
|
|
"description": "Target coverage percentage",
|
|
},
|
|
{
|
|
"name": "framework",
|
|
"arg_type": "str",
|
|
"requirement": "optional",
|
|
"description": "Test framework to use",
|
|
},
|
|
]
|
|
)
|
|
context.action_model = _make_action_model(inputs_schema=args_json)
|
|
context.db_session.add(context.action_model)
|
|
context.db_session.commit()
|
|
|
|
|
|
@then("the loaded action should contain the expected argument entries")
|
|
def step_verify_action_arguments_parsed(context):
|
|
"""Verify the loaded action contains the expected number of arguments."""
|
|
assert len(context.action_domain.arguments) == 2
|
|
|
|
|
|
@then("each argument entry should have the correct name and type")
|
|
def step_verify_action_argument_details(context):
|
|
"""Verify each argument entry has the correct name."""
|
|
args = context.action_domain.arguments
|
|
assert args[0].name == "target_coverage"
|
|
assert args[1].name == "framework"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Action: loading a record with no inputs or tags
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("an action record exists with no inputs and no tags")
|
|
def step_create_action_model_empty_inputs_tags(context):
|
|
"""Persist an action record with empty inputs and tags."""
|
|
context.action_model = _make_action_model(inputs_schema="[]", tags="[]")
|
|
context.db_session.add(context.action_model)
|
|
context.db_session.commit()
|
|
|
|
|
|
@then("the loaded action should have an empty arguments collection")
|
|
def step_verify_empty_arguments(context):
|
|
"""Verify the loaded action has no arguments."""
|
|
assert context.action_domain.arguments == []
|
|
|
|
|
|
@then("the loaded action should have an empty tags collection")
|
|
def step_verify_empty_tags(context):
|
|
"""Verify the loaded action has no tags."""
|
|
assert context.action_domain.tags == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Action: storing a domain object as a database record
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a complete action domain object is prepared")
|
|
def step_create_action_domain_object(context):
|
|
"""Prepare a fully populated action domain object."""
|
|
from cleveragents.domain.models.core.action import Action, ActionArgument
|
|
from cleveragents.domain.models.core.plan import ActionState, NamespacedName
|
|
|
|
context.action_domain_input = Action(
|
|
action_id=VALID_ULID_1,
|
|
namespaced_name=NamespacedName(namespace="local", name="my-action"),
|
|
short_description="Short desc",
|
|
long_description="Long desc",
|
|
definition_of_done="All tests pass",
|
|
strategy_actor="local/strategy",
|
|
execution_actor="local/executor",
|
|
estimation_actor="local/estimator",
|
|
review_actor="local/reviewer",
|
|
arguments=[
|
|
ActionArgument(
|
|
name="coverage",
|
|
arg_type="int",
|
|
requirement="required",
|
|
description="Coverage target",
|
|
),
|
|
],
|
|
reusable=True,
|
|
read_only=False,
|
|
state=ActionState.DRAFT,
|
|
created_at=datetime(2025, 6, 1, 12, 0, 0),
|
|
updated_at=datetime(2025, 6, 1, 13, 0, 0),
|
|
created_by="test-user",
|
|
tags=["ci", "testing"],
|
|
)
|
|
|
|
|
|
@when("the action domain object is stored as a database record")
|
|
def step_call_from_domain_on_action(context):
|
|
"""Store the action domain object as a database record."""
|
|
context.action_model_result = LifecycleActionModel.from_domain(
|
|
context.action_domain_input
|
|
)
|
|
|
|
|
|
@then("the stored record should preserve the action identifier")
|
|
def step_verify_from_domain_action_id(context):
|
|
"""Verify the stored record retains the action identifier."""
|
|
assert context.action_model_result.action_id == VALID_ULID_1
|
|
|
|
|
|
@then("the stored record should preserve the name components")
|
|
def step_verify_from_domain_name_fields(context):
|
|
"""Verify the stored record retains name, namespace, and short name."""
|
|
assert context.action_model_result.name == "local/my-action"
|
|
assert context.action_model_result.namespace == "local"
|
|
assert context.action_model_result.short_name == "my-action"
|
|
|
|
|
|
@then("the stored record should preserve the description fields")
|
|
def step_verify_from_domain_descriptions(context):
|
|
"""Verify the stored record retains all description fields."""
|
|
assert context.action_model_result.short_description == "Short desc"
|
|
assert context.action_model_result.long_description == "Long desc"
|
|
assert context.action_model_result.definition_of_done == "All tests pass"
|
|
|
|
|
|
@then("the stored record should preserve the actor assignments")
|
|
def step_verify_from_domain_actors(context):
|
|
"""Verify the stored record retains all actor assignments."""
|
|
assert context.action_model_result.strategy_actor == "local/strategy"
|
|
assert context.action_model_result.execution_actor == "local/executor"
|
|
assert context.action_model_result.estimation_actor == "local/estimator"
|
|
assert context.action_model_result.review_actor == "local/reviewer"
|
|
|
|
|
|
@then("the stored record should serialize the arguments as JSON")
|
|
def step_verify_from_domain_inputs_schema(context):
|
|
"""Verify the stored record has arguments serialized as JSON."""
|
|
parsed = json.loads(context.action_model_result.inputs_schema)
|
|
assert len(parsed) == 1
|
|
assert parsed[0]["name"] == "coverage"
|
|
|
|
|
|
@then("the stored record should serialize the tags as JSON")
|
|
def step_verify_from_domain_tags_json(context):
|
|
"""Verify the stored record has tags serialized as JSON."""
|
|
parsed = json.loads(context.action_model_result.tags)
|
|
assert parsed == ["ci", "testing"]
|
|
|
|
|
|
@then("the stored record should preserve the state value")
|
|
def step_verify_from_domain_state(context):
|
|
"""Verify the stored record retains the state value."""
|
|
assert context.action_model_result.state == "draft"
|
|
|
|
|
|
@then("the stored record should format timestamps as ISO strings")
|
|
def step_verify_from_domain_timestamps(context):
|
|
"""Verify the stored record formats timestamps as ISO strings."""
|
|
assert context.action_model_result.created_at == "2025-06-01T12:00:00"
|
|
assert context.action_model_result.updated_at == "2025-06-01T13:00:00"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Action: storing with an enumerated state
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("an action domain object is prepared with an enumerated state")
|
|
def step_create_action_with_enum_state(context):
|
|
"""Prepare an action domain object that uses an ActionState enum value."""
|
|
from cleveragents.domain.models.core.action import Action
|
|
from cleveragents.domain.models.core.plan import ActionState, NamespacedName
|
|
|
|
context.action_domain_input = Action(
|
|
action_id=VALID_ULID_1,
|
|
namespaced_name=NamespacedName(namespace="local", name="enum-action"),
|
|
definition_of_done="Tests pass",
|
|
strategy_actor="local/strategy",
|
|
execution_actor="local/executor",
|
|
state=ActionState.AVAILABLE,
|
|
created_at=datetime(2025, 6, 1, 12, 0, 0),
|
|
updated_at=datetime(2025, 6, 1, 13, 0, 0),
|
|
)
|
|
|
|
|
|
@then("the stored record state should equal the enum value")
|
|
def step_verify_enum_state_value(context):
|
|
"""Verify the stored record state is the string value of the enum."""
|
|
assert context.action_model_result.state == "available"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Action: storing with a custom string state
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("an action-like object is prepared with a custom string state")
|
|
def step_create_action_with_string_state(context):
|
|
"""Prepare an action-like object with a plain string state."""
|
|
from types import SimpleNamespace
|
|
|
|
from cleveragents.domain.models.core.plan import NamespacedName
|
|
|
|
context.action_domain_string_state = SimpleNamespace(
|
|
action_id=VALID_ULID_1,
|
|
namespaced_name=NamespacedName(namespace="local", name="str-action"),
|
|
short_description="Desc",
|
|
long_description="Long",
|
|
definition_of_done="Done",
|
|
strategy_actor="local/strategy",
|
|
execution_actor="local/executor",
|
|
estimation_actor=None,
|
|
review_actor=None,
|
|
arguments=[],
|
|
reusable=True,
|
|
read_only=False,
|
|
state="custom-state",
|
|
created_at=datetime(2025, 6, 1, 12, 0, 0),
|
|
updated_at=datetime(2025, 6, 1, 13, 0, 0),
|
|
created_by=None,
|
|
tags=[],
|
|
)
|
|
|
|
|
|
@when("the custom-state action is stored as a database record")
|
|
def step_call_from_domain_string_state(context):
|
|
"""Store the custom-state action as a database record."""
|
|
context.action_model_result = LifecycleActionModel.from_domain(
|
|
context.action_domain_string_state
|
|
)
|
|
|
|
|
|
@then("the stored record state should equal the custom string")
|
|
def step_verify_plain_string_state(context):
|
|
"""Verify the stored record state is the plain custom string."""
|
|
assert context.action_model_result.state == "custom-state"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Plan: parsing ISO timestamps
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("a valid ISO-8601 string is parsed as a timestamp")
|
|
def step_call_parse_iso_valid(context):
|
|
"""Parse a valid ISO-8601 string as a timestamp."""
|
|
context.parse_iso_result = LifecyclePlanModel._parse_iso("2025-06-01T12:00:00")
|
|
|
|
|
|
@then("the parsed timestamp should be the expected datetime value")
|
|
def step_verify_parse_iso_datetime(context):
|
|
"""Verify the parsed timestamp matches the expected datetime."""
|
|
assert isinstance(context.parse_iso_result, datetime)
|
|
assert context.parse_iso_result == datetime(2025, 6, 1, 12, 0, 0)
|
|
|
|
|
|
@when("an absent value is parsed as a timestamp")
|
|
def step_call_parse_iso_none(context):
|
|
"""Parse an absent (None) value as a timestamp."""
|
|
context.parse_iso_result = LifecyclePlanModel._parse_iso(None)
|
|
|
|
|
|
@then("the parsed timestamp should be absent")
|
|
def step_verify_parse_iso_none(context):
|
|
"""Verify parsing an absent value returns nothing."""
|
|
assert context.parse_iso_result is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Plan: formatting datetimes as ISO strings
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("a datetime value is formatted as an ISO string")
|
|
def step_call_to_iso_datetime(context):
|
|
"""Format a datetime value as an ISO string."""
|
|
context.to_iso_result = LifecyclePlanModel._to_iso(datetime(2025, 6, 1, 12, 0, 0))
|
|
|
|
|
|
@then("the formatted string should match ISO-8601 format")
|
|
def step_verify_to_iso_string(context):
|
|
"""Verify the formatted string matches ISO-8601 format."""
|
|
assert context.to_iso_result == "2025-06-01T12:00:00"
|
|
|
|
|
|
@when("an absent datetime is formatted as an ISO string")
|
|
def step_call_to_iso_none(context):
|
|
"""Format an absent (None) datetime as an ISO string."""
|
|
context.to_iso_result = LifecyclePlanModel._to_iso(None)
|
|
|
|
|
|
@then("the formatted value should be absent")
|
|
def step_verify_to_iso_none(context):
|
|
"""Verify formatting an absent datetime returns nothing."""
|
|
assert context.to_iso_result is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Plan: loading a stored record in the action phase
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a plan record exists in the action phase with draft state")
|
|
def step_create_plan_model_action_draft(context):
|
|
"""Persist a plan record in the action phase with draft state."""
|
|
action_model = _make_action_model()
|
|
context.db_session.add(action_model)
|
|
context.db_session.commit()
|
|
|
|
context.plan_model = _make_plan_model(
|
|
phase="action",
|
|
state="draft",
|
|
project_ids='["proj-1", "proj-2"]',
|
|
tags='["important"]',
|
|
)
|
|
context.db_session.add(context.plan_model)
|
|
context.db_session.commit()
|
|
|
|
|
|
@when("the plan record is loaded as a domain object")
|
|
def step_call_to_domain_on_plan(context):
|
|
"""Load the persisted plan record as a domain object."""
|
|
context.plan_domain = context.plan_model.to_domain()
|
|
|
|
|
|
@then("the loaded plan should preserve its identity")
|
|
def step_verify_plan_identity(context):
|
|
"""Verify the loaded plan retains its identifier and attempt."""
|
|
assert context.plan_domain.identity.plan_id == VALID_ULID_1
|
|
assert context.plan_domain.identity.attempt == 1
|
|
|
|
|
|
@then("the loaded plan should be in the action phase")
|
|
def step_verify_plan_action_phase(context):
|
|
"""Verify the loaded plan is in the action phase."""
|
|
from cleveragents.domain.models.core.plan import PlanPhase
|
|
|
|
assert context.plan_domain.phase == PlanPhase.ACTION
|
|
|
|
|
|
@then("the loaded plan action state should be draft")
|
|
def step_verify_plan_action_state_draft(context):
|
|
"""Verify the loaded plan action state is draft."""
|
|
from cleveragents.domain.models.core.plan import ActionState
|
|
|
|
assert context.plan_domain.action_state == ActionState.DRAFT
|
|
|
|
|
|
@then("the loaded plan processing state should be absent")
|
|
def step_verify_plan_processing_state_none(context):
|
|
"""Verify the loaded plan has no processing state."""
|
|
assert context.plan_domain.processing_state is None
|
|
|
|
|
|
@then("the loaded plan should preserve its description")
|
|
def step_verify_plan_description(context):
|
|
"""Verify the loaded plan retains its description."""
|
|
assert context.plan_domain.description == "A test plan description"
|
|
|
|
|
|
@then("the loaded plan should preserve its timestamps")
|
|
def step_verify_plan_timestamps(context):
|
|
"""Verify the loaded plan retains its timestamps."""
|
|
assert context.plan_domain.timestamps.created_at == datetime.fromisoformat(
|
|
"2025-06-01T12:00:00"
|
|
)
|
|
assert context.plan_domain.timestamps.updated_at == datetime.fromisoformat(
|
|
"2025-06-01T13:00:00"
|
|
)
|
|
|
|
|
|
@then("the loaded plan should preserve its metadata")
|
|
def step_verify_plan_metadata(context):
|
|
"""Verify the loaded plan retains its metadata fields."""
|
|
assert context.plan_domain.created_by == "test-user"
|
|
assert context.plan_domain.reusable is True
|
|
assert context.plan_domain.read_only is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Plan: loading a stored record in the strategize phase
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a plan record exists in the strategize phase with processing state")
|
|
def step_create_plan_model_strategize(context):
|
|
"""Persist a plan record in the strategize phase."""
|
|
existing = (
|
|
context.db_session.query(LifecycleActionModel)
|
|
.filter_by(action_id=VALID_ULID_1)
|
|
.first()
|
|
)
|
|
if not existing:
|
|
action_model = _make_action_model()
|
|
context.db_session.add(action_model)
|
|
context.db_session.commit()
|
|
|
|
context.plan_model = _make_plan_model(
|
|
plan_id=VALID_ULID_2,
|
|
phase="strategize",
|
|
state="processing",
|
|
)
|
|
context.db_session.add(context.plan_model)
|
|
context.db_session.commit()
|
|
|
|
|
|
@then("the loaded plan should be in the strategize phase")
|
|
def step_verify_plan_strategize_phase(context):
|
|
"""Verify the loaded plan is in the strategize phase."""
|
|
from cleveragents.domain.models.core.plan import PlanPhase
|
|
|
|
assert context.plan_domain.phase == PlanPhase.STRATEGIZE
|
|
|
|
|
|
@then("the loaded plan processing state should be processing")
|
|
def step_verify_plan_processing_state(context):
|
|
"""Verify the loaded plan processing state is processing."""
|
|
from cleveragents.domain.models.core.plan import ProcessingState
|
|
|
|
assert context.plan_domain.processing_state == ProcessingState.PROCESSING
|
|
|
|
|
|
@then("the loaded plan action state should be absent")
|
|
def step_verify_plan_action_state_none(context):
|
|
"""Verify the loaded plan has no action state."""
|
|
assert context.plan_domain.action_state is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Plan: loading a record with all phase timestamps
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a plan record exists with all phase timestamps populated")
|
|
def step_create_plan_model_all_timestamps(context):
|
|
"""Persist a plan record with all phase timestamps filled."""
|
|
existing = (
|
|
context.db_session.query(LifecycleActionModel)
|
|
.filter_by(action_id=VALID_ULID_1)
|
|
.first()
|
|
)
|
|
if not existing:
|
|
action_model = _make_action_model()
|
|
context.db_session.add(action_model)
|
|
context.db_session.commit()
|
|
|
|
context.plan_model = _make_plan_model(
|
|
plan_id=VALID_ULID_3,
|
|
phase="apply",
|
|
state="complete",
|
|
strategize_started_at="2025-06-01T14:00:00",
|
|
strategize_completed_at="2025-06-01T14:30:00",
|
|
execute_started_at="2025-06-01T15:00:00",
|
|
execute_completed_at="2025-06-01T15:30:00",
|
|
apply_started_at="2025-06-01T16:00:00",
|
|
applied_at="2025-06-01T16:30:00",
|
|
)
|
|
context.db_session.add(context.plan_model)
|
|
context.db_session.commit()
|
|
|
|
|
|
@then("the loaded plan timestamps should include the strategize window")
|
|
def step_verify_strategize_timestamps(context):
|
|
"""Verify the loaded plan has correct strategize timestamps."""
|
|
ts = context.plan_domain.timestamps
|
|
assert ts.strategize_started_at == datetime(2025, 6, 1, 14, 0, 0)
|
|
assert ts.strategize_completed_at == datetime(2025, 6, 1, 14, 30, 0)
|
|
|
|
|
|
@then("the loaded plan timestamps should include the execute window")
|
|
def step_verify_execute_timestamps(context):
|
|
"""Verify the loaded plan has correct execute timestamps."""
|
|
ts = context.plan_domain.timestamps
|
|
assert ts.execute_started_at == datetime(2025, 6, 1, 15, 0, 0)
|
|
assert ts.execute_completed_at == datetime(2025, 6, 1, 15, 30, 0)
|
|
|
|
|
|
@then("the loaded plan timestamps should include the apply window")
|
|
def step_verify_apply_timestamps(context):
|
|
"""Verify the loaded plan has correct apply timestamps."""
|
|
ts = context.plan_domain.timestamps
|
|
assert ts.apply_started_at == datetime(2025, 6, 1, 16, 0, 0)
|
|
assert ts.applied_at == datetime(2025, 6, 1, 16, 30, 0)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Plan: loading a record with project IDs and tags
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a plan record exists with associated project identifiers and tags")
|
|
def step_create_plan_model_with_ids_tags(context):
|
|
"""Persist a plan record with project identifiers and tags."""
|
|
existing = (
|
|
context.db_session.query(LifecycleActionModel)
|
|
.filter_by(action_id=VALID_ULID_1)
|
|
.first()
|
|
)
|
|
if not existing:
|
|
action_model = _make_action_model()
|
|
context.db_session.add(action_model)
|
|
context.db_session.commit()
|
|
|
|
context.plan_model = _make_plan_model(
|
|
plan_id=VALID_ULID_4,
|
|
phase="strategize",
|
|
state="queued",
|
|
project_ids='["proj-a", "proj-b", "proj-c"]',
|
|
tags='["urgent", "backend"]',
|
|
)
|
|
context.db_session.add(context.plan_model)
|
|
context.db_session.commit()
|
|
|
|
|
|
@then("the loaded plan should contain the expected project identifiers")
|
|
def step_verify_plan_project_ids(context):
|
|
"""Verify the loaded plan contains the expected project identifiers."""
|
|
assert context.plan_domain.project_ids == ["proj-a", "proj-b", "proj-c"]
|
|
|
|
|
|
@then("the loaded plan should contain the expected tags")
|
|
def step_verify_plan_tags(context):
|
|
"""Verify the loaded plan contains the expected tags."""
|
|
assert context.plan_domain.tags == ["urgent", "backend"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Plan: storing a domain object with action state
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_plan_domain(
|
|
phase="action",
|
|
action_state=None,
|
|
processing_state=None,
|
|
plan_id=VALID_ULID_1,
|
|
project_ids=None,
|
|
tags=None,
|
|
timestamps=None,
|
|
automation_level=None,
|
|
):
|
|
"""Create a Plan domain object for testing storage."""
|
|
from cleveragents.domain.models.core.plan import (
|
|
AutomationLevel,
|
|
NamespacedName,
|
|
Plan,
|
|
PlanIdentity,
|
|
PlanPhase,
|
|
PlanTimestamps,
|
|
)
|
|
|
|
if project_ids is None:
|
|
project_ids = ["proj-1"]
|
|
if tags is None:
|
|
tags = ["test"]
|
|
if timestamps is None:
|
|
timestamps = PlanTimestamps(
|
|
created_at=datetime(2025, 6, 1, 12, 0, 0),
|
|
updated_at=datetime(2025, 6, 1, 13, 0, 0),
|
|
)
|
|
|
|
resolved_automation = (
|
|
automation_level if automation_level is not None else AutomationLevel.MANUAL
|
|
)
|
|
|
|
plan_kwargs = dict(
|
|
identity=PlanIdentity(plan_id=plan_id),
|
|
namespaced_name=NamespacedName(namespace="local", name="test-plan"),
|
|
description="Test plan description",
|
|
definition_of_done="Tests pass",
|
|
phase=PlanPhase(phase),
|
|
automation_level=resolved_automation,
|
|
strategy_actor="local/strategy",
|
|
execution_actor="local/executor",
|
|
project_ids=project_ids,
|
|
timestamps=timestamps,
|
|
created_by="test-user",
|
|
tags=tags,
|
|
reusable=True,
|
|
read_only=False,
|
|
)
|
|
|
|
if action_state is not None:
|
|
plan_kwargs["action_state"] = action_state
|
|
if processing_state is not None:
|
|
plan_kwargs["processing_state"] = processing_state
|
|
|
|
return Plan(**plan_kwargs)
|
|
|
|
|
|
@given("a plan domain object is prepared in the action phase with an available state")
|
|
def step_create_plan_domain_action_available(context):
|
|
"""Prepare a plan domain object in the action phase with available state."""
|
|
from cleveragents.domain.models.core.plan import ActionState
|
|
|
|
context.plan_domain_input = _make_plan_domain(
|
|
phase="action",
|
|
action_state=ActionState.AVAILABLE,
|
|
project_ids=["proj-1", "proj-2"],
|
|
tags=["ci", "test"],
|
|
)
|
|
|
|
|
|
@when("the plan domain object is stored as a database record")
|
|
def step_call_from_domain_on_plan(context):
|
|
"""Store the plan domain object as a database record."""
|
|
context.plan_model_result = LifecyclePlanModel.from_domain(
|
|
context.plan_domain_input
|
|
)
|
|
|
|
|
|
@then('the stored plan record should have state "{expected_state}"')
|
|
def step_verify_from_domain_plan_state(context, expected_state):
|
|
"""Verify the stored plan record has the expected state."""
|
|
assert context.plan_model_result.state == expected_state
|
|
|
|
|
|
@then("the stored plan record should preserve the plan identifier")
|
|
def step_verify_from_domain_plan_id(context):
|
|
"""Verify the stored plan record retains the plan identifier."""
|
|
assert context.plan_model_result.plan_id == VALID_ULID_1
|
|
|
|
|
|
@then("the stored plan record should preserve the phase")
|
|
def step_verify_from_domain_plan_phase(context):
|
|
"""Verify the stored plan record retains the phase."""
|
|
assert context.plan_model_result.phase == "action"
|
|
|
|
|
|
@then("the stored plan record should serialize the project identifiers as JSON")
|
|
def step_verify_from_domain_project_ids(context):
|
|
"""Verify the stored plan record has project identifiers as JSON."""
|
|
parsed = json.loads(context.plan_model_result.project_ids)
|
|
assert parsed == ["proj-1", "proj-2"]
|
|
|
|
|
|
@then("the stored plan record should serialize the plan tags as JSON")
|
|
def step_verify_from_domain_plan_tags(context):
|
|
"""Verify the stored plan record has tags as JSON."""
|
|
parsed = json.loads(context.plan_model_result.tags)
|
|
assert parsed == ["ci", "test"]
|
|
|
|
|
|
@then("the stored plan record should format plan timestamps as ISO strings")
|
|
def step_verify_from_domain_plan_timestamps(context):
|
|
"""Verify the stored plan record has timestamps as ISO strings."""
|
|
assert context.plan_model_result.created_at == "2025-06-01T12:00:00"
|
|
assert context.plan_model_result.updated_at == "2025-06-01T13:00:00"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Plan: storing a domain object with processing state
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a plan domain object is prepared in the strategize phase with a queued state")
|
|
def step_create_plan_domain_strategize_queued(context):
|
|
"""Prepare a plan domain object in the strategize phase with queued state."""
|
|
from cleveragents.domain.models.core.plan import ProcessingState
|
|
|
|
context.plan_domain_input = _make_plan_domain(
|
|
phase="strategize",
|
|
processing_state=ProcessingState.QUEUED,
|
|
)
|
|
|
|
|
|
@then('the stored plan record phase should be "{expected_phase}"')
|
|
def step_verify_from_domain_plan_phase_value(context, expected_phase):
|
|
"""Verify the stored plan record has the expected phase."""
|
|
assert context.plan_model_result.phase == expected_phase
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Plan: storing a domain object with no explicit state
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a plan domain object is prepared with neither action nor processing state")
|
|
def step_create_plan_domain_null_states(context):
|
|
"""Prepare a plan-like object with no explicit state to test the fallback."""
|
|
from types import SimpleNamespace
|
|
|
|
from cleveragents.domain.models.core.plan import (
|
|
NamespacedName,
|
|
PlanIdentity,
|
|
PlanPhase,
|
|
PlanTimestamps,
|
|
)
|
|
|
|
context.plan_domain_stateless = SimpleNamespace(
|
|
identity=PlanIdentity(plan_id=VALID_ULID_1),
|
|
namespaced_name=NamespacedName(namespace="local", name="null-plan"),
|
|
description="Null state plan",
|
|
definition_of_done="Done",
|
|
phase=PlanPhase.STRATEGIZE,
|
|
action_state=None,
|
|
processing_state=None,
|
|
automation_level="manual",
|
|
strategy_actor="local/strategy",
|
|
execution_actor="local/executor",
|
|
project_ids=["proj-1"],
|
|
timestamps=PlanTimestamps(
|
|
created_at=datetime(2025, 6, 1, 12, 0, 0),
|
|
updated_at=datetime(2025, 6, 1, 13, 0, 0),
|
|
),
|
|
error_message=None,
|
|
created_by=None,
|
|
tags=[],
|
|
reusable=True,
|
|
read_only=False,
|
|
)
|
|
|
|
|
|
@when("the stateless plan domain object is stored as a database record")
|
|
def step_call_from_domain_stateless(context):
|
|
"""Store the stateless plan as a database record."""
|
|
context.plan_model_result = LifecyclePlanModel.from_domain(
|
|
context.plan_domain_stateless
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Plan: storing a domain object with all phase timestamps
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a plan domain object is prepared with all phase timestamps")
|
|
def step_create_plan_domain_all_timestamps(context):
|
|
"""Prepare a plan domain object with all phase timestamps."""
|
|
from cleveragents.domain.models.core.plan import (
|
|
ActionState,
|
|
PlanTimestamps,
|
|
)
|
|
|
|
timestamps = PlanTimestamps(
|
|
created_at=datetime(2025, 6, 1, 12, 0, 0),
|
|
updated_at=datetime(2025, 6, 1, 13, 0, 0),
|
|
strategize_started_at=datetime(2025, 6, 1, 14, 0, 0),
|
|
strategize_completed_at=datetime(2025, 6, 1, 14, 30, 0),
|
|
execute_started_at=datetime(2025, 6, 1, 15, 0, 0),
|
|
execute_completed_at=datetime(2025, 6, 1, 15, 30, 0),
|
|
apply_started_at=datetime(2025, 6, 1, 16, 0, 0),
|
|
applied_at=datetime(2025, 6, 1, 16, 30, 0),
|
|
)
|
|
|
|
context.plan_domain_input = _make_plan_domain(
|
|
phase="action",
|
|
action_state=ActionState.AVAILABLE,
|
|
timestamps=timestamps,
|
|
)
|
|
|
|
|
|
@then("the stored plan record should have all phase timestamps as ISO strings")
|
|
def step_verify_from_domain_all_timestamps(context):
|
|
"""Verify all phase timestamp columns are formatted as ISO strings."""
|
|
m = context.plan_model_result
|
|
assert m.strategize_started_at == "2025-06-01T14:00:00"
|
|
assert m.strategize_completed_at == "2025-06-01T14:30:00"
|
|
assert m.execute_started_at == "2025-06-01T15:00:00"
|
|
assert m.execute_completed_at == "2025-06-01T15:30:00"
|
|
assert m.apply_started_at == "2025-06-01T16:00:00"
|
|
assert m.applied_at == "2025-06-01T16:30:00"
|
|
assert m.completed_at == "2025-06-01T16:30:00"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Plan: storing a domain object with non-default automation level
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given('a plan domain object is prepared with automation level "{level}"')
|
|
def step_create_plan_domain_with_automation_level(context, level):
|
|
"""Prepare a plan domain object with a specific automation level."""
|
|
from cleveragents.domain.models.core.plan import ActionState, AutomationLevel
|
|
|
|
context.plan_domain_input = _make_plan_domain(
|
|
phase="action",
|
|
action_state=ActionState.AVAILABLE,
|
|
automation_level=AutomationLevel(level),
|
|
)
|
|
|
|
|
|
@then('the stored plan record automation level should be "{expected_level}"')
|
|
def step_verify_from_domain_automation_level(context, expected_level):
|
|
"""Verify the stored plan record has the expected automation level."""
|
|
assert context.plan_model_result.automation_level == expected_level
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Plan: storing a domain object with an explicit action_id
|
|
# ---------------------------------------------------------------------------
|
|
|
|
VALID_ULID_ACTION = "01JACTION000000000000ACTION"
|
|
|
|
|
|
@when("the plan domain object is stored with an explicit action identifier")
|
|
def step_call_from_domain_with_action_id(context):
|
|
"""Store the plan domain object with an explicit action_id."""
|
|
context.plan_model_result = LifecyclePlanModel.from_domain(
|
|
context.plan_domain_input,
|
|
action_id=VALID_ULID_ACTION,
|
|
)
|
|
|
|
|
|
@then("the stored plan record should preserve the supplied action identifier")
|
|
def step_verify_from_domain_action_id(context):
|
|
"""Verify the stored plan record retains the supplied action_id."""
|
|
assert context.plan_model_result.action_id == VALID_ULID_ACTION
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Round-trip persistence tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("the action is saved to the database and reloaded as a domain object")
|
|
def step_convert_and_persist_action(context):
|
|
"""Save the action to the database and reload it as a domain object."""
|
|
context.action_model_persisted = LifecycleActionModel.from_domain(
|
|
context.action_domain_input
|
|
)
|
|
context.db_session.add(context.action_model_persisted)
|
|
context.db_session.commit()
|
|
|
|
context.action_model_retrieved = (
|
|
context.db_session.query(LifecycleActionModel)
|
|
.filter_by(action_id=context.action_model_persisted.action_id)
|
|
.first()
|
|
)
|
|
assert context.action_model_retrieved is not None
|
|
context.action_round_tripped = context.action_model_retrieved.to_domain()
|
|
|
|
|
|
@then("the reloaded action should match the original action")
|
|
def step_verify_round_trip_action(context):
|
|
"""Verify the reloaded action matches the original."""
|
|
original = context.action_domain_input
|
|
result = context.action_round_tripped
|
|
assert result.action_id == original.action_id
|
|
assert str(result.namespaced_name) == str(original.namespaced_name)
|
|
assert result.definition_of_done == original.definition_of_done
|
|
assert result.strategy_actor == original.strategy_actor
|
|
assert result.execution_actor == original.execution_actor
|
|
assert result.reusable == original.reusable
|
|
assert result.read_only == original.read_only
|
|
assert result.tags == original.tags
|
|
assert len(result.arguments) == len(original.arguments)
|
|
|
|
|
|
@when("the plan record is saved and then reloaded as a domain object")
|
|
def step_persist_and_reload_plan(context):
|
|
"""Save the plan record and reload it as a domain object."""
|
|
# Plan was already persisted in the Given step
|
|
context.plan_model_retrieved = (
|
|
context.db_session.query(LifecyclePlanModel)
|
|
.filter_by(plan_id=context.plan_model.plan_id)
|
|
.first()
|
|
)
|
|
assert context.plan_model_retrieved is not None
|
|
context.plan_round_tripped = context.plan_model_retrieved.to_domain()
|
|
|
|
|
|
@then("the reloaded plan should preserve its identity and phase")
|
|
def step_verify_round_trip_plan(context):
|
|
"""Verify the reloaded plan retains its identity and phase."""
|
|
from cleveragents.domain.models.core.plan import PlanPhase
|
|
|
|
result = context.plan_round_tripped
|
|
assert result.identity.plan_id == VALID_ULID_1
|
|
assert result.phase == PlanPhase.ACTION
|
|
assert result.description == "A test plan description"
|
|
assert result.created_by == "test-user"
|