Files
cleveragents-core/robot/helper_plan_persistence_e2e.py
T

391 lines
12 KiB
Python

"""Helper utilities for plan persistence E2E Robot integration tests.
Covers:
- Full lifecycle persistence (all phase transitions)
- Restart persistence (close + reopen DB)
- Concurrent session access
- Action CRUD
- Plan tree hierarchy
"""
from __future__ import annotations
import sys
import tempfile
from datetime import UTC, datetime
from pathlib import Path
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from cleveragents.domain.models.core.action import (
Action,
ActionArgument,
ActionState,
ArgumentRequirement,
ArgumentType,
)
from cleveragents.domain.models.core.plan import (
NamespacedName,
Plan,
PlanIdentity,
PlanPhase,
PlanTimestamps,
ProcessingState,
ProjectLink,
)
from cleveragents.infrastructure.database.models import (
init_database,
)
from cleveragents.infrastructure.database.repositories import (
ActionRepository,
LifecyclePlanRepository,
)
def _create_temp_db() -> tuple[str, Session]:
"""Create a temporary SQLite database and return (path, session)."""
tmp = tempfile.mktemp(suffix=".db")
db_url = f"sqlite:///{tmp}"
engine = init_database(db_url)
session = Session(bind=engine)
return tmp, session
def _make_action(name: str = "local/e2e-action") -> Action:
"""Create a minimal action for FK satisfaction."""
return Action(
namespaced_name=NamespacedName.parse(name),
description="E2E test action",
definition_of_done="Done",
strategy_actor="local/s",
execution_actor="local/e",
state=ActionState.AVAILABLE,
created_at=datetime(2026, 1, 1, tzinfo=UTC),
updated_at=datetime(2026, 1, 1, tzinfo=UTC),
)
def _make_plan(
plan_id: str,
action_name: str = "local/e2e-action",
phase: PlanPhase = PlanPhase.ACTION,
state: ProcessingState = ProcessingState.QUEUED,
parent_plan_id: str | None = None,
root_plan_id: str | None = None,
) -> Plan:
"""Create a Plan domain object."""
now = 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="e2e-plan"),
action_name=action_name,
description="E2E test plan",
definition_of_done="E2E assertions pass",
phase=phase,
processing_state=state,
strategy_actor="local/s",
execution_actor="local/e",
project_links=[ProjectLink(project_name="local/proj-e2e")],
timestamps=PlanTimestamps(created_at=now, updated_at=now),
created_by="e2e-test",
tags=["e2e"],
reusable=True,
read_only=False,
)
# ---------------------------------------------------------------------------
# Full lifecycle
# ---------------------------------------------------------------------------
def _full_lifecycle() -> None:
"""E2E: Create plan, transition through all phases to applied."""
tmp, session = _create_temp_db()
factory = lambda: session # noqa: E731
try:
# Setup action
action_repo = ActionRepository(session_factory=factory)
action_repo.create(_make_action())
session.commit()
plan_repo = LifecyclePlanRepository(session_factory=factory)
# Phase 1: Create plan in action phase
plan = _make_plan("01HV00000000000000E2E0FC01")
plan_repo.create(plan)
session.commit()
retrieved = plan_repo.get("01HV00000000000000E2E0FC01")
assert retrieved is not None
assert retrieved.phase == PlanPhase.ACTION
# Phase 2: Transition to strategize
retrieved.phase = PlanPhase.STRATEGIZE
retrieved.processing_state = ProcessingState.PROCESSING
retrieved.timestamps.updated_at = datetime.now(tz=UTC)
plan_repo.update(retrieved)
session.commit()
retrieved = plan_repo.get("01HV00000000000000E2E0FC01")
assert retrieved is not None
assert retrieved.phase == PlanPhase.STRATEGIZE
assert retrieved.processing_state == ProcessingState.PROCESSING
# Phase 3: Complete strategize -> execute
retrieved.phase = PlanPhase.EXECUTE
retrieved.processing_state = ProcessingState.QUEUED
retrieved.timestamps.updated_at = datetime.now(tz=UTC)
plan_repo.update(retrieved)
session.commit()
retrieved = plan_repo.get("01HV00000000000000E2E0FC01")
assert retrieved is not None
assert retrieved.phase == PlanPhase.EXECUTE
# Phase 4: Execute -> apply -> applied
retrieved.phase = PlanPhase.APPLY
retrieved.processing_state = ProcessingState.APPLIED
retrieved.timestamps.updated_at = datetime.now(tz=UTC)
plan_repo.update(retrieved)
session.commit()
retrieved = plan_repo.get("01HV00000000000000E2E0FC01")
assert retrieved is not None
assert retrieved.phase == PlanPhase.APPLY
assert retrieved.processing_state == ProcessingState.APPLIED
assert retrieved.is_terminal
print("full-lifecycle-ok")
finally:
session.close()
Path(tmp).unlink(missing_ok=True)
# ---------------------------------------------------------------------------
# Restart persistence
# ---------------------------------------------------------------------------
def _restart_persistence() -> None:
"""E2E: Verify plan survives DB close and reopen."""
tmp, session = _create_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(
"01HV00000000000000E2ERST01",
phase=PlanPhase.EXECUTE,
state=ProcessingState.PROCESSING,
)
plan_repo.create(plan)
session.commit()
# Close session (simulate process restart)
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)
retrieved = plan_repo2.get("01HV00000000000000E2ERST01")
assert retrieved is not None
assert retrieved.phase == PlanPhase.EXECUTE
assert retrieved.processing_state == ProcessingState.PROCESSING
assert retrieved.description == "E2E test plan"
assert len(retrieved.project_links) == 1
session2.close()
engine2.dispose()
print("restart-persistence-ok")
finally:
Path(tmp).unlink(missing_ok=True)
# ---------------------------------------------------------------------------
# Concurrent access
# ---------------------------------------------------------------------------
def _concurrent_access() -> None:
"""E2E: Two sessions see each other's committed data."""
tmp, session1 = _create_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("01HV00000000000000E2ECNC01")
plan_repo1.create(plan)
session1.commit()
# Open second session
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("01HV00000000000000E2ECNC01")
assert retrieved is not None
assert retrieved.description == "E2E test plan"
session2.close()
engine2.dispose()
print("concurrent-access-ok")
finally:
session1.close()
Path(tmp).unlink(missing_ok=True)
# ---------------------------------------------------------------------------
# Action CRUD
# ---------------------------------------------------------------------------
def _action_crud() -> None:
"""E2E: Create, read, update, delete an action."""
tmp, session = _create_temp_db()
factory = lambda: session # noqa: E731
try:
repo = ActionRepository(session_factory=factory)
# Create
action = Action(
namespaced_name=NamespacedName.parse("local/crud-action"),
description="CRUD test",
definition_of_done="Done",
strategy_actor="local/s",
execution_actor="local/e",
arguments=[
ActionArgument(
name="target",
arg_type=ArgumentType.STRING,
requirement=ArgumentRequirement.REQUIRED,
description="Target path",
),
],
invariants=["No deletions"],
state=ActionState.AVAILABLE,
created_at=datetime(2026, 1, 1, tzinfo=UTC),
updated_at=datetime(2026, 1, 1, tzinfo=UTC),
)
repo.create(action)
session.commit()
# Read
fetched = repo.get_by_name("local/crud-action")
assert fetched is not None
assert fetched.description == "CRUD test"
assert len(fetched.arguments) == 1
assert len(fetched.invariants) == 1
# Update
fetched.description = "Updated CRUD test"
fetched.updated_at = datetime.now(tz=UTC)
repo.update(fetched)
session.commit()
refetched = repo.get_by_name("local/crud-action")
assert refetched is not None
assert refetched.description == "Updated CRUD test"
# Delete
deleted = repo.delete("local/crud-action")
assert deleted is True
session.commit()
gone = repo.get_by_name("local/crud-action")
assert gone is None
print("action-crud-ok")
finally:
session.close()
Path(tmp).unlink(missing_ok=True)
# ---------------------------------------------------------------------------
# Plan tree
# ---------------------------------------------------------------------------
def _plan_tree() -> None:
"""E2E: Parent-child plan hierarchy persistence."""
tmp, session = _create_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)
# Parent
parent = _make_plan("01HV00000000000000E2ETRP01")
plan_repo.create(parent)
session.commit()
# Child
child = _make_plan(
"01HV00000000000000E2ETRC01",
parent_plan_id="01HV00000000000000E2ETRP01",
root_plan_id="01HV00000000000000E2ETRP01",
)
plan_repo.create(child)
session.commit()
loaded_child = plan_repo.get("01HV00000000000000E2ETRC01")
assert loaded_child is not None
assert loaded_child.identity.parent_plan_id == "01HV00000000000000E2ETRP01"
assert loaded_child.identity.root_plan_id == "01HV00000000000000E2ETRP01"
assert loaded_child.is_subplan
loaded_parent = plan_repo.get("01HV00000000000000E2ETRP01")
assert loaded_parent is not None
assert not loaded_parent.is_subplan
print("plan-tree-ok")
finally:
session.close()
Path(tmp).unlink(missing_ok=True)
# ---------------------------------------------------------------------------
# Main dispatcher
# ---------------------------------------------------------------------------
def main() -> None:
if len(sys.argv) < 2:
raise SystemExit("Expected command argument")
command = sys.argv[1]
commands = {
"full-lifecycle": _full_lifecycle,
"restart-persistence": _restart_persistence,
"concurrent-access": _concurrent_access,
"action-crud": _action_crud,
"plan-tree": _plan_tree,
}
if command not in commands:
raise SystemExit(f"Unknown command: {command}")
commands[command]()
if __name__ == "__main__":
main()