"""Step definitions for Plan Resume feature tests.""" from behave import given, then, when from behave.runner import Context from ulid import ULID from cleveragents.application.services.plan_lifecycle_service import ( PlanLifecycleService, ) from cleveragents.application.services.plan_resume_service import ( PlanResumeService, ) from cleveragents.config.settings import Settings from cleveragents.core.exceptions import PlanError, ValidationError from cleveragents.domain.models.core.plan import ( PlanPhase, ProcessingState, ProjectLink, ) from cleveragents.domain.models.core.resume import ResumeMetadata # --- Background --- @given("I have a plan lifecycle service for resume tests") def step_create_resume_lifecycle_service(context: Context) -> None: """Create lifecycle service for resume tests.""" settings = Settings() context.lifecycle_service = PlanLifecycleService(settings=settings) context.error = None context.plan = None context.plan_id = None context.eligibility = None context.summary = None @given("I have a plan resume service") def step_create_resume_service(context: Context) -> None: """Create plan resume service.""" context.resume_service = PlanResumeService( lifecycle_service=context.lifecycle_service, ) # --- Helper to create plans in specific states --- def _create_action(context: Context) -> str: """Create and return an action name for testing.""" action_name = f"local/resume-test-{ULID()!s}"[:30].lower() context.lifecycle_service.create_action( name=action_name, description="Resume test action", definition_of_done="Step 1\nStep 2\nStep 3", strategy_actor="local/stub-strategy", execution_actor="local/stub-execute", ) return action_name def _create_plan_in_state( context: Context, phase: PlanPhase, state: ProcessingState, ) -> str: """Create a plan and move it to the given phase/state.""" action_name = _create_action(context) plan = context.lifecycle_service.use_action( action_name=action_name, project_links=[ProjectLink(project_name="local/test-proj")], ) plan_id = plan.identity.plan_id if phase == PlanPhase.STRATEGIZE and state == ProcessingState.QUEUED: pass elif phase == PlanPhase.STRATEGIZE and state == ProcessingState.PROCESSING: context.lifecycle_service.start_strategize(plan_id) elif phase == PlanPhase.EXECUTE and state == ProcessingState.PROCESSING: context.lifecycle_service.start_strategize(plan_id) p = context.lifecycle_service.get_plan(plan_id) p.decision_root_id = str(ULID()) context.lifecycle_service._commit_plan(p) context.lifecycle_service.complete_strategize(plan_id) context.lifecycle_service.execute_plan(plan_id) context.lifecycle_service.start_execute(plan_id) elif phase == PlanPhase.EXECUTE and state == ProcessingState.ERRORED: context.lifecycle_service.start_strategize(plan_id) p = context.lifecycle_service.get_plan(plan_id) p.decision_root_id = str(ULID()) context.lifecycle_service._commit_plan(p) context.lifecycle_service.complete_strategize(plan_id) context.lifecycle_service.execute_plan(plan_id) context.lifecycle_service.start_execute(plan_id) context.lifecycle_service.fail_execute(plan_id, "Test error") elif phase == PlanPhase.APPLY and state == ProcessingState.APPLIED: context.lifecycle_service.start_strategize(plan_id) p = context.lifecycle_service.get_plan(plan_id) p.decision_root_id = str(ULID()) context.lifecycle_service._commit_plan(p) context.lifecycle_service.complete_strategize(plan_id) context.lifecycle_service.execute_plan(plan_id) context.lifecycle_service.start_execute(plan_id) context.lifecycle_service.complete_execute(plan_id) context.lifecycle_service.apply_plan(plan_id) context.lifecycle_service.start_apply(plan_id) context.lifecycle_service.complete_apply(plan_id) elif phase == PlanPhase.APPLY and state == ProcessingState.CONSTRAINED: context.lifecycle_service.start_strategize(plan_id) p = context.lifecycle_service.get_plan(plan_id) p.decision_root_id = str(ULID()) context.lifecycle_service._commit_plan(p) context.lifecycle_service.complete_strategize(plan_id) context.lifecycle_service.execute_plan(plan_id) context.lifecycle_service.start_execute(plan_id) context.lifecycle_service.complete_execute(plan_id) context.lifecycle_service.apply_plan(plan_id) context.lifecycle_service.start_apply(plan_id) p2 = context.lifecycle_service.get_plan(plan_id) p2.processing_state = ProcessingState.PROCESSING context.lifecycle_service._commit_plan(p2) context.lifecycle_service.constrain_apply(plan_id, "constraints violated") context.plan_id = plan_id context.plan = context.lifecycle_service.get_plan(plan_id) return plan_id # --- Given steps --- @given("I have a resume-test plan in execute phase with errored state") def step_resume_plan_execute_errored(context: Context) -> None: """Create a plan in execute/errored state for resume tests.""" _create_plan_in_state(context, PlanPhase.EXECUTE, ProcessingState.ERRORED) @given("I have a resume-test plan in execute phase with processing state") def step_resume_plan_execute_processing(context: Context) -> None: """Create a plan in execute/processing state for resume tests.""" _create_plan_in_state(context, PlanPhase.EXECUTE, ProcessingState.PROCESSING) @given("I have a resume-test plan in strategize phase with queued state") def step_resume_plan_strategize_queued(context: Context) -> None: """Create a plan in strategize/queued state for resume tests.""" _create_plan_in_state(context, PlanPhase.STRATEGIZE, ProcessingState.QUEUED) @given("I have a resume-test plan in applied terminal state") def step_resume_plan_applied(context: Context) -> None: """Create a plan in applied terminal state for resume tests.""" _create_plan_in_state(context, PlanPhase.APPLY, ProcessingState.APPLIED) @given("I have a resume-test plan in cancelled terminal state") def step_resume_plan_cancelled(context: Context) -> None: """Create a plan and cancel it for resume tests.""" _create_plan_in_state(context, PlanPhase.EXECUTE, ProcessingState.PROCESSING) context.lifecycle_service.cancel_plan(context.plan_id, "test cancel") context.plan = context.lifecycle_service.get_plan(context.plan_id) @given("I have a resume-test plan in constrained terminal state") def step_resume_plan_constrained(context: Context) -> None: """Create a plan in constrained terminal state for resume tests.""" _create_plan_in_state(context, PlanPhase.APPLY, ProcessingState.CONSTRAINED) @given("I have a resume-test plan in action phase") def step_resume_plan_action_phase(context: Context) -> None: """Create a plan and set it to action phase for resume tests.""" _create_plan_in_state(context, PlanPhase.STRATEGIZE, ProcessingState.QUEUED) plan = context.lifecycle_service.get_plan(context.plan_id) plan.phase = PlanPhase.ACTION context.lifecycle_service._commit_plan(plan) context.plan = plan @given('the resume-test plan has checkpoint at step {index:d} with decision "{dec_id}"') def step_resume_plan_has_checkpoint(context: Context, index: int, dec_id: str) -> None: """Add a checkpoint to the plan at given step.""" context.resume_service.set_total_steps(context.plan_id, 5) context.resume_service.record_step_checkpoint( plan_id=context.plan_id, step_index=index, decision_id=dec_id, step_text=f"Step {index}", ) @given("I have resume metadata with last completed step {step:d}") def step_resume_metadata_with_step(context: Context, step: int) -> None: """Create resume metadata with given step.""" context.resume_metadata = ResumeMetadata( last_completed_step=step, total_steps=5, ) @given("I have fresh resume metadata") def step_fresh_resume_metadata(context: Context) -> None: """Create fresh resume metadata with no progress.""" context.resume_metadata = ResumeMetadata() @given("I have resume metadata with {total:d} total steps and last step {last:d}") def step_resume_metadata_with_totals(context: Context, total: int, last: int) -> None: """Create resume metadata with total and last step.""" context.resume_metadata = ResumeMetadata( last_completed_step=last, total_steps=total, ) # --- When steps --- @when("I validate resume eligibility") def step_validate_resume_eligibility(context: Context) -> None: """Validate resume eligibility for the current plan.""" context.eligibility = context.resume_service.validate_eligibility( context.plan_id, ) @when('I record a resume step checkpoint at index {index:d} with decision "{dec_id}"') def step_record_resume_checkpoint(context: Context, index: int, dec_id: str) -> None: """Record a step checkpoint.""" context.resume_service.set_total_steps(context.plan_id, 5) context.resume_service.record_step_checkpoint( plan_id=context.plan_id, step_index=index, decision_id=dec_id, step_text=f"Step {index}", ) context.plan = context.lifecycle_service.get_plan(context.plan_id) @when("I resume the plan with dry-run") def step_resume_dry_run(context: Context) -> None: """Resume the plan in dry-run mode.""" context.summary = context.resume_service.resume_plan(context.plan_id, dry_run=True) @when("I resume the plan without dry-run") def step_resume_live(context: Context) -> None: """Resume the plan in live mode.""" context.summary = context.resume_service.resume_plan(context.plan_id, dry_run=False) context.plan = context.lifecycle_service.get_plan(context.plan_id) @when("I record a graceful shutdown for resume") def step_record_resume_shutdown(context: Context) -> None: """Record a graceful shutdown.""" context.resume_service.record_shutdown(context.plan_id) @when("I try to resume a plan with empty ID") def step_resume_empty_id(context: Context) -> None: """Try to resume a plan with empty ID.""" try: context.resume_service.resume_plan("") except ValidationError as e: context.error = e @when("I try to validate eligibility with empty plan ID") def step_validate_eligibility_empty_id(context: Context) -> None: """Try to validate eligibility with empty plan ID.""" try: context.resume_service.validate_eligibility("") except ValidationError as e: context.error = e @when("I try to resume the terminal plan for resume test") def step_resume_terminal_plan(context: Context) -> None: """Try to resume a terminal plan.""" try: context.resume_service.resume_plan(context.plan_id) except PlanError as e: context.error = e @when("I set the resume total steps to {total:d}") def step_set_resume_total_steps(context: Context, total: int) -> None: """Set total steps for the plan.""" context.resume_service.set_total_steps(context.plan_id, total) @when("I try to set resume total steps with empty plan ID") def step_set_resume_total_steps_empty(context: Context) -> None: """Try to set total steps with empty plan ID.""" try: context.resume_service.set_total_steps("", 5) except ValidationError as e: context.error = e # --- Then steps --- @then("the plan should be eligible for resume") def step_assert_eligible(context: Context) -> None: """Assert the plan is eligible for resume.""" assert context.eligibility is not None assert context.eligibility.eligible is True, ( f"Expected eligible=True, got: {context.eligibility.reason}" ) @then("the plan should not be eligible for resume") def step_assert_not_eligible(context: Context) -> None: """Assert the plan is not eligible for resume.""" assert context.eligibility is not None assert context.eligibility.eligible is False @then('the ineligible reason should be "{reason}"') def step_assert_ineligible_reason(context: Context, reason: str) -> None: """Assert the ineligible reason code.""" assert context.eligibility is not None assert context.eligibility.ineligible_reason is not None assert context.eligibility.ineligible_reason.value == reason @then("the resume-test plan should have last completed step {step:d}") def step_assert_resume_last_step(context: Context, step: int) -> None: """Assert the plan's last completed step.""" plan = context.lifecycle_service.get_plan(context.plan_id) assert plan.last_completed_step == step @then("the resume-test plan should have a checkpoint ID set") def step_assert_resume_checkpoint_id(context: Context) -> None: """Assert the plan has a checkpoint ID.""" plan = context.lifecycle_service.get_plan(context.plan_id) assert plan.last_checkpoint_id is not None @then("the resume metadata should have {count:d} checkpoints") def step_assert_resume_checkpoint_count(context: Context, count: int) -> None: """Assert the resume metadata checkpoint count.""" metadata = context.resume_service.get_resume_metadata(context.plan_id) assert len(metadata.checkpoints) == count @then("the resume metadata next step should be {step:d}") def step_assert_resume_next_step(context: Context, step: int) -> None: """Assert the resume metadata next step index.""" metadata = context.resume_service.get_resume_metadata(context.plan_id) assert metadata.next_step_index == step @then("I should get a resume summary") def step_assert_resume_summary_exists(context: Context) -> None: """Assert a resume summary was returned.""" assert context.summary is not None @then("the resume summary next step should be {step:d}") def step_assert_resume_summary_next_step(context: Context, step: int) -> None: """Assert the resume summary next step.""" assert context.summary is not None assert context.summary.next_step_index == step @then("the resume-test plan processing state should still be errored") def step_assert_resume_still_errored(context: Context) -> None: """Assert the plan is still in errored state.""" plan = context.lifecycle_service.get_plan(context.plan_id) assert plan.processing_state == ProcessingState.ERRORED @then('the resume summary decision ID should be "{dec_id}"') def step_assert_resume_summary_decision(context: Context, dec_id: str) -> None: """Assert the resume summary decision ID.""" assert context.summary is not None assert context.summary.decision_id == dec_id @then("the resume-test plan processing state should be processing") def step_assert_resume_processing(context: Context) -> None: """Assert the plan is in processing state.""" plan = context.lifecycle_service.get_plan(context.plan_id) assert plan.processing_state == ProcessingState.PROCESSING @then("the resume-test plan error message should be cleared") def step_assert_resume_error_cleared(context: Context) -> None: """Assert the plan error message is None.""" plan = context.lifecycle_service.get_plan(context.plan_id) assert plan.error_message is None @then("the resume metadata should be marked as interrupted") def step_assert_resume_interrupted(context: Context) -> None: """Assert the resume metadata is marked interrupted.""" metadata = context.resume_service.get_resume_metadata(context.plan_id) assert metadata.interrupted is True assert metadata.interrupted_at is not None @then("a resume validation error should be raised") def step_assert_resume_validation_error(context: Context) -> None: """Assert a ValidationError was raised.""" assert context.error is not None assert isinstance(context.error, ValidationError) @then("a plan error should be raised for resume") def step_assert_resume_plan_error(context: Context) -> None: """Assert a PlanError was raised.""" assert context.error is not None assert isinstance(context.error, PlanError) @then("the resume metadata total steps should be {total:d}") def step_assert_resume_total_steps(context: Context, total: int) -> None: """Assert the resume metadata total steps.""" metadata = context.resume_service.get_resume_metadata(context.plan_id) assert metadata.total_steps == total @then("the resume metadata has_progress should be true") def step_assert_resume_has_progress_true(context: Context) -> None: """Assert has_progress is True.""" assert context.resume_metadata.has_progress is True @then("the resume metadata has_progress should be false") def step_assert_resume_has_progress_false(context: Context) -> None: """Assert has_progress is False.""" assert context.resume_metadata.has_progress is False @then("the resume metadata is_complete should be true") def step_assert_resume_is_complete_true(context: Context) -> None: """Assert is_complete is True.""" assert context.resume_metadata.is_complete is True @then("the resume metadata is_complete should be false") def step_assert_resume_is_complete_false(context: Context) -> None: """Assert is_complete is False.""" assert context.resume_metadata.is_complete is False