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
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
478 lines
16 KiB
Python
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()
|