"""Helper script for persistence_lifecycle.robot E2E tests. Provides subcommands exercising plan/action persistence through full lifecycle transitions, restart simulation, argument/invariant ordering, project link persistence, and concurrent session safeguards. Usage: python robot/helper_persistence_lifecycle.py Subcommands: full-lifecycle Create action + plan, transition all phases restart-simulation Write data, close DB, reopen, verify state reopen-plan-status Close/reopen and check plan status survives concurrent-access Two sessions against same DB file arguments-ordering Verify ActionArgument ordering roundtrip invariants-ordering Verify invariant list ordering roundtrip project-links Verify ProjectLink persistence roundtrip """ from __future__ import annotations import sys import tempfile from datetime import UTC, datetime from pathlib import Path # Ensure src is on the import path. _SRC = str(Path(__file__).resolve().parents[1] / "src") sys.path.insert(0, _SRC) from sqlalchemy import create_engine # noqa: E402 from sqlalchemy.orm import Session # noqa: E402 from cleveragents.domain.models.core.action import ( # noqa: E402 Action, ActionArgument, ActionState, ArgumentRequirement, ArgumentType, ) from cleveragents.domain.models.core.plan import ( # noqa: E402 NamespacedName, Plan, PlanIdentity, PlanPhase, PlanTimestamps, ProcessingState, ProjectLink, ) from cleveragents.infrastructure.database.models import init_database # noqa: E402 from cleveragents.infrastructure.database.repositories import ( # noqa: E402 ActionRepository, LifecyclePlanRepository, ) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _temp_db() -> tuple[str, Session]: """Create a file-backed temp SQLite DB and return (path, session).""" tmp: str = tempfile.mktemp(suffix=".db") db_url: str = f"sqlite:///{tmp}" engine = init_database(db_url) session = Session(bind=engine) return tmp, session def _make_action(name: str = "local/lifecycle-action") -> Action: """Create a minimal Action for FK satisfaction.""" now: datetime = datetime(2026, 2, 1, tzinfo=UTC) return Action( namespaced_name=NamespacedName.parse(name), description="Lifecycle test action", definition_of_done="All phases pass", strategy_actor="local/strat", execution_actor="local/exec", state=ActionState.AVAILABLE, created_at=now, updated_at=now, ) def _make_plan( plan_id: str, action_name: str = "local/lifecycle-action", phase: PlanPhase = PlanPhase.ACTION, state: ProcessingState = ProcessingState.QUEUED, parent_plan_id: str | None = None, root_plan_id: str | None = None, project_links: list[ProjectLink] | None = None, ) -> Plan: """Create a Plan domain object with sensible defaults.""" now: datetime = datetime(2026, 3, 1, tzinfo=UTC) return Plan( identity=PlanIdentity( plan_id=plan_id, parent_plan_id=parent_plan_id, root_plan_id=root_plan_id, attempt=1, ), namespaced_name=NamespacedName(namespace="local", name="lifecycle-plan"), action_name=action_name, description="Lifecycle test plan", definition_of_done="E2E assertions pass", phase=phase, processing_state=state, strategy_actor="local/strat", execution_actor="local/exec", project_links=project_links or [ProjectLink(project_name="local/proj-lc")], timestamps=PlanTimestamps(created_at=now, updated_at=now), created_by="robot-lifecycle", tags=["lifecycle"], reusable=True, read_only=False, ) # --------------------------------------------------------------------------- # Subcommand: full-lifecycle # --------------------------------------------------------------------------- def _full_lifecycle() -> None: """Create plan, walk through every phase to terminal applied state.""" tmp, session = _temp_db() factory = lambda: session # noqa: E731 try: action_repo = ActionRepository(session_factory=factory) action_repo.create(_make_action()) session.commit() plan_repo = LifecyclePlanRepository(session_factory=factory) plan = _make_plan("01HV00000000000000MCFM0001") plan_repo.create(plan) session.commit() # Verify initial state got = plan_repo.get("01HV00000000000000MCFM0001") assert got is not None, "plan not found after create" assert got.phase == PlanPhase.ACTION # action -> strategize got.phase = PlanPhase.STRATEGIZE got.processing_state = ProcessingState.PROCESSING got.timestamps.updated_at = datetime.now(tz=UTC) plan_repo.update(got) session.commit() got = plan_repo.get("01HV00000000000000MCFM0001") assert got is not None assert got.phase == PlanPhase.STRATEGIZE # strategize -> execute got.phase = PlanPhase.EXECUTE got.processing_state = ProcessingState.QUEUED got.timestamps.updated_at = datetime.now(tz=UTC) plan_repo.update(got) session.commit() got = plan_repo.get("01HV00000000000000MCFM0001") assert got is not None assert got.phase == PlanPhase.EXECUTE # execute -> apply (terminal) got.phase = PlanPhase.APPLY got.processing_state = ProcessingState.APPLIED got.timestamps.updated_at = datetime.now(tz=UTC) plan_repo.update(got) session.commit() got = plan_repo.get("01HV00000000000000MCFM0001") assert got is not None assert got.phase == PlanPhase.APPLY assert got.processing_state == ProcessingState.APPLIED assert got.is_terminal print("full-lifecycle-ok") finally: session.close() Path(tmp).unlink(missing_ok=True) # --------------------------------------------------------------------------- # Subcommand: restart-simulation # --------------------------------------------------------------------------- def _restart_simulation() -> None: """Write plan, close DB entirely, reopen from disk and verify.""" tmp, session = _temp_db() factory = lambda: session # noqa: E731 try: action_repo = ActionRepository(session_factory=factory) action_repo.create(_make_action()) session.commit() plan_repo = LifecyclePlanRepository(session_factory=factory) plan = _make_plan( "01HV00000000000000MCRS0001", phase=PlanPhase.EXECUTE, state=ProcessingState.PROCESSING, ) plan_repo.create(plan) session.commit() # Simulate process restart session.close() # Reopen from disk engine2 = create_engine(f"sqlite:///{tmp}", echo=False) session2 = Session(bind=engine2) factory2 = lambda: session2 # noqa: E731 plan_repo2 = LifecyclePlanRepository(session_factory=factory2) retrieved = plan_repo2.get("01HV00000000000000MCRS0001") assert retrieved is not None, "plan missing after restart" assert retrieved.phase == PlanPhase.EXECUTE assert retrieved.processing_state == ProcessingState.PROCESSING assert retrieved.description == "Lifecycle test plan" assert len(retrieved.project_links) == 1 assert retrieved.project_links[0].project_name == "local/proj-lc" session2.close() engine2.dispose() print("restart-simulation-ok") finally: Path(tmp).unlink(missing_ok=True) # --------------------------------------------------------------------------- # Subcommand: reopen-plan-status # --------------------------------------------------------------------------- def _reopen_plan_status() -> None: """Close + reopen DB and verify plan phase/state unchanged.""" tmp, session = _temp_db() factory = lambda: session # noqa: E731 try: action_repo = ActionRepository(session_factory=factory) action_repo.create(_make_action()) session.commit() plan_repo = LifecyclePlanRepository(session_factory=factory) plan = _make_plan( "01HV00000000000000MCRQ0001", phase=PlanPhase.STRATEGIZE, state=ProcessingState.PROCESSING, ) plan_repo.create(plan) session.commit() session.close() # Reopen engine2 = create_engine(f"sqlite:///{tmp}", echo=False) session2 = Session(bind=engine2) factory2 = lambda: session2 # noqa: E731 plan_repo2 = LifecyclePlanRepository(session_factory=factory2) got = plan_repo2.get("01HV00000000000000MCRQ0001") assert got is not None, "plan missing after reopen" assert got.phase == PlanPhase.STRATEGIZE, f"bad phase: {got.phase}" assert got.processing_state == ProcessingState.PROCESSING assert got.tags == ["lifecycle"] session2.close() engine2.dispose() print("reopen-plan-status-ok") finally: Path(tmp).unlink(missing_ok=True) # --------------------------------------------------------------------------- # Subcommand: concurrent-access # --------------------------------------------------------------------------- def _concurrent_access() -> None: """Two independent sessions see each other's committed data.""" tmp, session1 = _temp_db() factory1 = lambda: session1 # noqa: E731 try: action_repo = ActionRepository(session_factory=factory1) action_repo.create(_make_action()) session1.commit() plan_repo1 = LifecyclePlanRepository(session_factory=factory1) plan = _make_plan("01HV00000000000000MCCC0001") plan_repo1.create(plan) session1.commit() # Second session on same DB engine2 = create_engine(f"sqlite:///{tmp}", echo=False) session2 = Session(bind=engine2) factory2 = lambda: session2 # noqa: E731 plan_repo2 = LifecyclePlanRepository(session_factory=factory2) got = plan_repo2.get("01HV00000000000000MCCC0001") assert got is not None, "concurrent session cannot see committed plan" assert got.description == "Lifecycle test plan" session2.close() engine2.dispose() print("concurrent-access-ok") finally: session1.close() Path(tmp).unlink(missing_ok=True) # --------------------------------------------------------------------------- # Subcommand: arguments-ordering # --------------------------------------------------------------------------- def _arguments_ordering() -> None: """Verify ActionArgument ordering survives persistence roundtrip.""" tmp, session = _temp_db() factory = lambda: session # noqa: E731 try: arg_names: list[str] = ["alpha", "beta", "gamma", "delta"] action = Action( namespaced_name=NamespacedName.parse("local/ordered-args-action"), description="Action with ordered arguments", definition_of_done="Args order preserved", strategy_actor="local/strat", execution_actor="local/exec", arguments=[ ActionArgument( name=n, arg_type=ArgumentType.STRING, requirement=ArgumentRequirement.REQUIRED, description=f"Arg {n}", position=i, ) for i, n in enumerate(arg_names) ], state=ActionState.AVAILABLE, created_at=datetime(2026, 2, 1, tzinfo=UTC), updated_at=datetime(2026, 2, 1, tzinfo=UTC), ) repo = ActionRepository(session_factory=factory) repo.create(action) session.commit() fetched = repo.get_by_name("local/ordered-args-action") assert fetched is not None, "action not found" retrieved_names: list[str] = [a.name for a in fetched.arguments] assert retrieved_names == arg_names, ( f"ordering mismatch: {retrieved_names} != {arg_names}" ) print("arguments-ordering-ok") finally: session.close() Path(tmp).unlink(missing_ok=True) # --------------------------------------------------------------------------- # Subcommand: invariants-ordering # --------------------------------------------------------------------------- def _invariants_ordering() -> None: """Verify invariant list ordering survives persistence roundtrip.""" tmp, session = _temp_db() factory = lambda: session # noqa: E731 try: invariants: list[str] = [ "No breaking changes", "All tests pass", "Coverage above 97%", ] action = Action( namespaced_name=NamespacedName.parse("local/ordered-inv-action"), description="Action with ordered invariants", definition_of_done="Invariants order preserved", strategy_actor="local/strat", execution_actor="local/exec", invariants=invariants, state=ActionState.AVAILABLE, created_at=datetime(2026, 2, 1, tzinfo=UTC), updated_at=datetime(2026, 2, 1, tzinfo=UTC), ) repo = ActionRepository(session_factory=factory) repo.create(action) session.commit() fetched = repo.get_by_name("local/ordered-inv-action") assert fetched is not None, "action not found" assert fetched.invariants == invariants, ( f"ordering mismatch: {fetched.invariants} != {invariants}" ) print("invariants-ordering-ok") finally: session.close() Path(tmp).unlink(missing_ok=True) # --------------------------------------------------------------------------- # Subcommand: project-links # --------------------------------------------------------------------------- def _project_links() -> None: """Verify ProjectLink persistence through create/restart cycle.""" tmp, session = _temp_db() factory = lambda: session # noqa: E731 try: action_repo = ActionRepository(session_factory=factory) action_repo.create(_make_action()) session.commit() links: list[ProjectLink] = [ ProjectLink(project_name="local/proj-alpha"), ProjectLink(project_name="local/proj-beta"), ] plan_repo = LifecyclePlanRepository(session_factory=factory) plan = _make_plan( "01HV00000000000000MCPM0001", project_links=links, ) plan_repo.create(plan) session.commit() # Close and reopen (restart simulation) session.close() engine2 = create_engine(f"sqlite:///{tmp}", echo=False) session2 = Session(bind=engine2) factory2 = lambda: session2 # noqa: E731 plan_repo2 = LifecyclePlanRepository(session_factory=factory2) got = plan_repo2.get("01HV00000000000000MCPM0001") assert got is not None, "plan with project links missing" retrieved_names: list[str] = [pl.project_name for pl in got.project_links] assert "local/proj-alpha" in retrieved_names, "proj-alpha missing" assert "local/proj-beta" in retrieved_names, "proj-beta missing" assert len(got.project_links) == 2, ( f"expected 2 links, got {len(got.project_links)}" ) session2.close() engine2.dispose() print("project-links-ok") finally: Path(tmp).unlink(missing_ok=True) # --------------------------------------------------------------------------- # Main dispatcher # --------------------------------------------------------------------------- _COMMANDS: dict[str, object] = { "full-lifecycle": _full_lifecycle, "restart-simulation": _restart_simulation, "reopen-plan-status": _reopen_plan_status, "concurrent-access": _concurrent_access, "arguments-ordering": _arguments_ordering, "invariants-ordering": _invariants_ordering, "project-links": _project_links, } def main() -> None: """Dispatch to the requested subcommand.""" if len(sys.argv) < 2: raise SystemExit(f"Usage: {sys.argv[0]} <{'|'.join(_COMMANDS)}>") command: str = sys.argv[1] handler = _COMMANDS.get(command) if handler is None: raise SystemExit(f"Unknown command: {command}") handler() # type: ignore[operator] if __name__ == "__main__": main()