Files
cleveragents-core/robot/helper_persistence_lifecycle.py
brent.edwards ab8ed19aa7
CI / benchmark-publish (pull_request) Has been skipped
CI / quality (pull_request) Successful in 19s
CI / lint (pull_request) Failing after 22s
CI / build (pull_request) Successful in 27s
CI / typecheck (pull_request) Successful in 55s
CI / coverage (pull_request) Has been skipped
CI / benchmark-regression (pull_request) Has been skipped
CI / security (pull_request) Successful in 1m7s
CI / unit_tests (pull_request) Has been cancelled
CI / integration_tests (pull_request) Has been cancelled
CI / docker (pull_request) Has been cancelled
fix(tests): remove AutomationLevel references after legacy field removal
Master's refactor(automation) commit removed the AutomationLevel enum
and automation_level field from Plan. Update all behave steps, robot
helpers, and feature scenarios that referenced the removed type:

- cli_lifecycle_robot_alignment_steps.py: drop import and field assignment
- persistence_robot_alignment_steps.py: drop import and field assignment
- subplan_model_steps.py: drop import, parameter, and field assignment
- subplan_model.feature: replace automation level scenario with profile check
- helper_cli_lifecycle.py: drop import and field assignment
- helper_persistence_lifecycle.py: drop import, field assignment, and assertion
2026-02-20 20:34:29 +00:00

478 lines
16 KiB
Python

"""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 <subcommand>
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()