a4a6b061a6
CI / benchmark-publish (pull_request) Has been skipped
CI / build (pull_request) Successful in 18s
CI / lint (pull_request) Successful in 20s
CI / helm (pull_request) Successful in 22s
CI / typecheck (pull_request) Successful in 51s
CI / quality (pull_request) Successful in 3m45s
CI / integration_tests (pull_request) Successful in 3m59s
CI / security (pull_request) Successful in 4m8s
CI / unit_tests (pull_request) Successful in 5m44s
CI / docker (pull_request) Successful in 1m32s
CI / coverage (pull_request) Successful in 11m50s
CI / e2e_tests (pull_request) Successful in 20m28s
CI / status-check (pull_request) Successful in 1s
CI / lint (push) Successful in 17s
CI / build (push) Successful in 18s
CI / helm (push) Successful in 22s
CI / quality (push) Successful in 31s
CI / typecheck (push) Successful in 1m1s
CI / benchmark-regression (push) Has been skipped
CI / unit_tests (push) Successful in 3m57s
CI / security (push) Successful in 4m3s
CI / docker (push) Successful in 1m21s
CI / integration_tests (push) Successful in 7m11s
CI / coverage (push) Successful in 11m56s
CI / e2e_tests (push) Successful in 20m56s
CI / status-check (push) Successful in 1s
CI / benchmark-publish (push) Successful in 28m22s
CI / benchmark-regression (pull_request) Successful in 55m20s
Aligned v3_plans table with specification DDL:
1. Added effective_profile_snapshot column (TEXT NOT NULL) for
storing frozen JSON snapshot of automation profile at plan
creation time. Added Pydantic field_validator ensuring the
value is well-formed JSON. Validator catches RecursionError
for deeply nested JSON, consistent with automation_profile
deserialization hardening. Validator error message uses
length-only to avoid potential information disclosure.
Documented that the default "{}" exists for backward
compatibility; new plans should explicitly set the snapshot.
2. Made root_plan_id NOT NULL — root plans self-reference their
own plan_id, child plans reference the root ancestor. Added
explicit ondelete="RESTRICT" FK policy for consistency with
other FKs in the model. Documented known FK policy drift
between ORM model (RESTRICT) and migrated databases (retained
SET NULL) in the migration; data integrity is preserved by
the NOT NULL constraint regardless. Moved root_plan_id
self-reference resolution into a PlanIdentity model_validator
so the domain model is consistent with the DB NOT NULL
constraint before and after persistence (previously the
resolution only happened in from_domain(), creating an
asymmetry where root_plan_id was None in-memory but non-null
after round-tripping through the database).
3. Made automation_profile NOT NULL with default "balanced".
4. Documented intentional deviation: phase default is "action"
(code) vs "strategize" (spec) because the Action phase was
added as a pre-Strategize setup step.
5. Created Alembic migration with backfill logic for existing
rows. Root-ancestor backfill uses level-by-level propagation
with a parent-readiness guard to correctly resolve plans at
arbitrary hierarchy depth (3+ levels). Added safety bound
(max 100 iterations) with logged error on exhaustion to guard
against cycles in parent_plan_id. Merged batch_alter_table
operations to avoid redundant full-table copies in SQLite
batch mode. Migration backfill also handles empty-string
automation_profile values. Documented downgrade limitation
(backfill is not reversible). Orphan-row fallback now logs
affected row count at WARNING level. Migration cycle-detection
now logs affected plan_id values before the orphan fallback
overwrites them. All migration SQL uses sa.text() for
consistency with SQLAlchemy best practices.
6. Hardened automation_profile deserialization in to_domain() to
catch ValueError (invalid StrEnum provenance), Pydantic
ValidationError, and RecursionError (deeply nested JSON) in
addition to JSONDecodeError and KeyError, preventing
unreadable plans from corrupted DB rows. Applied the same
defensive deserialization pattern to effective_profile_snapshot
in to_domain(): corrupted JSON falls back to '{}' with a
WARNING log instead of crashing the read path. Added TypeError
to the effective_profile_snapshot exception list in to_domain()
for consistency with the Pydantic validator. Logging of
unparseable values uses length only to avoid potential
information disclosure.
7. Used explicit None check (is not None) instead of truthiness
for root_plan_id resolution in from_domain(), for
effective_profile_snapshot in to_domain(), and in
_serialize_automation_profile() for consistency.
8. Documented intentional column naming conventions vs spec DDL
(e.g. automation_profile vs automation_profile_name, *_actor
vs *_actor_name, processing_state vs state, v3_plans vs
plans). Documented the semantic difference: automation_profile
stores either a bare name or structured JSON with provenance,
whereas the spec automation_profile_name stores a plain name.
9. Fixed benchmark plan constructors
(plan_phase_migration_bench.py) that were missing the now-
required root_plan_id and effective_profile_snapshot fields.
10. Replaced defensive getattr() with direct attribute access for
effective_profile_snapshot in from_domain() and update(),
since the field is now defined on the Plan domain model.
11. Fixed Any type annotation in test helper _make_plan() to use
AutomationProfileRef | None for proper type safety.
12. Added BDD scenarios for PlanIdentity self-reference
resolution, NULL effective_profile_snapshot constraint
enforcement, valid-JSON-missing-profile_name-key
deserialization, invalid-JSON and empty-string snapshot
rejection by Pydantic validator, and corrupted
effective_profile_snapshot DB fallback in to_domain().
13. Extracted default automation profile name to a module-level
constant (DEFAULT_AUTOMATION_PROFILE) to reduce sentinel
duplication across models.py and repositories.py.
14. Centralised automation-profile serialisation into
LifecyclePlanModel._serialize_automation_profile() to
eliminate duplication between from_domain() and
LifecyclePlanRepository.update().
15. Fixed to_domain() root_plan_id type cast from str | None
to str, reflecting the NOT NULL column constraint.
16. Added PlanIdentity model_validator that resolves None
root_plan_id to plan_id at domain construction time, ensuring
the domain model honours the spec DDL NOT NULL constraint
regardless of persistence state. Simplified from_domain()
root resolution accordingly.
ISSUES CLOSED: #921
761 lines
25 KiB
Python
761 lines
25 KiB
Python
"""Step definitions for plan lifecycle coverage scenarios.
|
|
|
|
Targets uncovered lines in:
|
|
- repositories.py: get_by_name, update (children), delete, list_plans filters,
|
|
count, error paths, action update with inputs_schema/arguments/invariants
|
|
- plan_lifecycle_service.py: get_plan from persistence fallback,
|
|
constrain_apply, auto_progress no-op, _persist_action_update (archive)
|
|
- unit_of_work.py: lifecycle_plans lazy property, add/flush/refresh
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
from typing import Any
|
|
|
|
from behave import given, then, when
|
|
from behave.runner import Context
|
|
from sqlalchemy import create_engine, event
|
|
from sqlalchemy.orm import Session, sessionmaker
|
|
|
|
from cleveragents.application.services.plan_lifecycle_service import (
|
|
PlanLifecycleService,
|
|
)
|
|
from cleveragents.config.settings import Settings
|
|
from cleveragents.domain.models.core.action import (
|
|
ActionArgument,
|
|
ArgumentRequirement,
|
|
ArgumentType,
|
|
)
|
|
from cleveragents.domain.models.core.plan import (
|
|
InvariantSource,
|
|
PlanInvariant,
|
|
PlanPhase,
|
|
ProcessingState,
|
|
ProjectLink,
|
|
)
|
|
from cleveragents.infrastructure.database.models import (
|
|
Base,
|
|
LifecyclePlanModel,
|
|
)
|
|
from cleveragents.infrastructure.database.repositories import (
|
|
DuplicatePlanError,
|
|
LifecyclePlanRepository,
|
|
PlanNotFoundError,
|
|
)
|
|
from cleveragents.infrastructure.database.unit_of_work import UnitOfWork
|
|
|
|
# ── Helpers ──
|
|
|
|
|
|
def _build_cov_uow() -> tuple[UnitOfWork, sessionmaker[Session], Any]:
|
|
"""Build an in-memory UoW for coverage testing."""
|
|
engine = create_engine(
|
|
"sqlite:///:memory:",
|
|
echo=False,
|
|
future=True,
|
|
connect_args={"check_same_thread": False},
|
|
)
|
|
|
|
@event.listens_for(engine, "connect")
|
|
def _fk(dbapi_conn: Any, _rec: Any) -> None:
|
|
cursor = dbapi_conn.cursor()
|
|
cursor.execute("PRAGMA foreign_keys=ON")
|
|
cursor.close()
|
|
|
|
Base.metadata.create_all(engine)
|
|
sf: sessionmaker[Session] = sessionmaker(
|
|
bind=engine,
|
|
expire_on_commit=False,
|
|
autoflush=False,
|
|
autocommit=False,
|
|
class_=Session,
|
|
)
|
|
|
|
uow = UnitOfWork.__new__(UnitOfWork)
|
|
uow.database_url = "sqlite:///:memory:"
|
|
uow._engine = engine
|
|
uow._session_factory = sf
|
|
uow._database_initialized = True
|
|
uow._prompt_for_migration = None
|
|
|
|
return uow, sf, engine
|
|
|
|
|
|
# ── Background ──
|
|
|
|
|
|
@given("a coverage test service backed by SQLite")
|
|
def step_cov_bg(context: Context) -> None:
|
|
uow, sf, engine = _build_cov_uow()
|
|
settings = Settings()
|
|
service = PlanLifecycleService(settings=settings, unit_of_work=uow)
|
|
context.cov_service = service
|
|
context.cov_uow = uow
|
|
context.cov_sf = sf
|
|
context.cov_engine = engine
|
|
context.cov_plan = None
|
|
context.cov_error = None
|
|
context.cov_result = None
|
|
context.cov_action_name = None
|
|
|
|
|
|
# ── Helpers for creating plans ──
|
|
|
|
|
|
def _create_cov_plan(context: Context) -> Any:
|
|
"""Create a plan via the service and store it on context."""
|
|
svc: PlanLifecycleService = context.cov_service
|
|
action_name = "local/cov-basic"
|
|
with contextlib.suppress(Exception):
|
|
svc.create_action(
|
|
name=action_name,
|
|
description="Coverage basic action",
|
|
definition_of_done="Tests pass",
|
|
strategy_actor="openai/gpt-4",
|
|
execution_actor="openai/gpt-4",
|
|
reusable=True,
|
|
)
|
|
plan = svc.use_action(action_name=action_name)
|
|
context.cov_plan = plan
|
|
context.cov_action_name = action_name
|
|
return plan
|
|
|
|
|
|
@given("a coverage plan exists via the service")
|
|
def step_cov_plan_exists(context: Context) -> None:
|
|
_create_cov_plan(context)
|
|
|
|
|
|
@given("a coverage plan with project links and invariants exists")
|
|
def step_cov_plan_with_children(context: Context) -> None:
|
|
svc: PlanLifecycleService = context.cov_service
|
|
action_name = "local/cov-children"
|
|
with contextlib.suppress(Exception):
|
|
svc.create_action(
|
|
name=action_name,
|
|
description="Action with children",
|
|
definition_of_done="All green",
|
|
strategy_actor="openai/gpt-4",
|
|
execution_actor="openai/gpt-4",
|
|
invariants=["Code must compile", "No regressions"],
|
|
reusable=True,
|
|
)
|
|
|
|
links = [
|
|
ProjectLink(project_name="local/api-service", alias="api", read_only=False),
|
|
ProjectLink(project_name="local/frontend", alias="web", read_only=True),
|
|
]
|
|
invariants = [
|
|
PlanInvariant(text="Security audit required", source=InvariantSource.PLAN),
|
|
]
|
|
plan = svc.use_action(
|
|
action_name=action_name,
|
|
project_links=links,
|
|
invariants=invariants,
|
|
arguments={},
|
|
)
|
|
context.cov_plan = plan
|
|
context.cov_action_name = action_name
|
|
|
|
|
|
@given("a coverage plan in apply-queued state")
|
|
def step_cov_plan_apply_queued(context: Context) -> None:
|
|
svc: PlanLifecycleService = context.cov_service
|
|
action_name = "local/cov-apply-q"
|
|
with contextlib.suppress(Exception):
|
|
svc.create_action(
|
|
name=action_name,
|
|
description="Apply test action",
|
|
definition_of_done="Done",
|
|
strategy_actor="openai/gpt-4",
|
|
execution_actor="openai/gpt-4",
|
|
reusable=True,
|
|
)
|
|
plan = svc.use_action(action_name=action_name)
|
|
pid = plan.identity.plan_id
|
|
svc.start_strategize(pid)
|
|
svc.complete_strategize(pid)
|
|
svc.execute_plan(pid)
|
|
svc.start_execute(pid)
|
|
svc.complete_execute(pid)
|
|
svc.apply_plan(pid)
|
|
context.cov_plan = svc.get_plan(pid)
|
|
|
|
|
|
@given('a coverage action "{name}" exists')
|
|
def step_cov_action_exists(context: Context, name: str) -> None:
|
|
svc: PlanLifecycleService = context.cov_service
|
|
svc.create_action(
|
|
name=name,
|
|
description=f"Coverage action {name}",
|
|
definition_of_done="Tests pass",
|
|
strategy_actor="openai/gpt-4",
|
|
execution_actor="openai/gpt-4",
|
|
reusable=True,
|
|
)
|
|
|
|
|
|
@given("a coverage action with inputs_schema and arguments exists")
|
|
def step_cov_action_with_inputs(context: Context) -> None:
|
|
svc: PlanLifecycleService = context.cov_service
|
|
args = [
|
|
ActionArgument(
|
|
name="target_dir",
|
|
arg_type=ArgumentType.STRING,
|
|
requirement=ArgumentRequirement.REQUIRED,
|
|
description="Target directory",
|
|
default_value="/tmp",
|
|
),
|
|
ActionArgument(
|
|
name="max_retries",
|
|
arg_type=ArgumentType.INTEGER,
|
|
requirement=ArgumentRequirement.OPTIONAL,
|
|
description="Max retries",
|
|
default_value=3,
|
|
),
|
|
]
|
|
svc.create_action(
|
|
name="local/cov-inputs-act",
|
|
description="Action with inputs",
|
|
definition_of_done="Done",
|
|
strategy_actor="openai/gpt-4",
|
|
execution_actor="openai/gpt-4",
|
|
arguments=args,
|
|
invariants=["Must not break API", "Must be backwards compatible"],
|
|
reusable=True,
|
|
tags=["test", "coverage"],
|
|
)
|
|
# Manually set inputs_schema on the action to cover line 924
|
|
action = svc.get_action("local/cov-inputs-act")
|
|
action.inputs_schema = {
|
|
"type": "object",
|
|
"properties": {"target_dir": {"type": "string"}},
|
|
}
|
|
context.cov_action_name = "local/cov-inputs-act"
|
|
|
|
|
|
# ── Get plan by name ──
|
|
|
|
|
|
@when("I retrieve the plan by its namespaced name from the repository")
|
|
def step_get_by_name(context: Context) -> None:
|
|
plan = context.cov_plan
|
|
name = str(plan.namespaced_name)
|
|
sf = context.cov_sf
|
|
session = sf()
|
|
repo = LifecyclePlanRepository(session_factory=lambda: session)
|
|
context.cov_result = repo.get_by_name(name)
|
|
session.close()
|
|
|
|
|
|
@then("the plan retrieved by name should match the original")
|
|
def step_verify_get_by_name(context: Context) -> None:
|
|
result = context.cov_result
|
|
assert result is not None, "get_by_name returned None"
|
|
assert result.identity.plan_id == context.cov_plan.identity.plan_id
|
|
|
|
|
|
@when('I retrieve a plan by name "{name}" from the repository')
|
|
def step_get_by_name_unknown(context: Context, name: str) -> None:
|
|
sf = context.cov_sf
|
|
session = sf()
|
|
repo = LifecyclePlanRepository(session_factory=lambda: session)
|
|
context.cov_result = repo.get_by_name(name)
|
|
session.close()
|
|
|
|
|
|
@then("the plan-by-name result should be None")
|
|
def step_verify_get_by_name_none(context: Context) -> None:
|
|
assert context.cov_result is None, f"Expected None, got {context.cov_result}"
|
|
|
|
|
|
# ── Update plan with children ──
|
|
|
|
|
|
@when("I update the plan description and phase through the repository")
|
|
def step_update_plan(context: Context) -> None:
|
|
plan = context.cov_plan
|
|
plan.description = "Updated description for coverage"
|
|
sf = context.cov_sf
|
|
session = sf()
|
|
repo = LifecyclePlanRepository(session_factory=lambda: session)
|
|
repo.update(plan)
|
|
session.commit()
|
|
session.close()
|
|
|
|
|
|
@then("the updated plan should have the new description in the DB")
|
|
def step_verify_updated_desc(context: Context) -> None:
|
|
plan_id = context.cov_plan.identity.plan_id
|
|
sf = context.cov_sf
|
|
session = sf()
|
|
repo = LifecyclePlanRepository(session_factory=lambda: session)
|
|
loaded = repo.get(plan_id)
|
|
session.close()
|
|
assert loaded is not None
|
|
assert loaded.description == "Updated description for coverage"
|
|
|
|
|
|
@then("the updated plan should retain project links")
|
|
def step_verify_project_links(context: Context) -> None:
|
|
plan_id = context.cov_plan.identity.plan_id
|
|
sf = context.cov_sf
|
|
session = sf()
|
|
repo = LifecyclePlanRepository(session_factory=lambda: session)
|
|
loaded = repo.get(plan_id)
|
|
session.close()
|
|
assert loaded is not None
|
|
links = getattr(loaded, "project_links", []) or []
|
|
assert len(links) >= 2, f"Expected >= 2 project links, got {len(links)}"
|
|
|
|
|
|
@then("the updated plan should retain arguments")
|
|
def step_verify_arguments(context: Context) -> None:
|
|
plan_id = context.cov_plan.identity.plan_id
|
|
sf = context.cov_sf
|
|
session = sf()
|
|
repo = LifecyclePlanRepository(session_factory=lambda: session)
|
|
loaded = repo.get(plan_id)
|
|
session.close()
|
|
assert loaded is not None
|
|
# Arguments dict may be empty, but it should be present
|
|
args = getattr(loaded, "arguments", None)
|
|
assert args is not None, "arguments should not be None"
|
|
|
|
|
|
@then("the updated plan should retain invariants")
|
|
def step_verify_invariants(context: Context) -> None:
|
|
plan_id = context.cov_plan.identity.plan_id
|
|
sf = context.cov_sf
|
|
session = sf()
|
|
repo = LifecyclePlanRepository(session_factory=lambda: session)
|
|
loaded = repo.get(plan_id)
|
|
session.close()
|
|
assert loaded is not None
|
|
invs = getattr(loaded, "invariants", []) or []
|
|
# Should have action invariants + plan invariants
|
|
assert len(invs) >= 1, f"Expected >= 1 invariants, got {len(invs)}"
|
|
|
|
|
|
# ── Delete plan ──
|
|
|
|
|
|
@when("I delete the plan from the repository")
|
|
def step_delete_plan(context: Context) -> None:
|
|
plan_id = context.cov_plan.identity.plan_id
|
|
sf = context.cov_sf
|
|
session = sf()
|
|
repo = LifecyclePlanRepository(session_factory=lambda: session)
|
|
context.cov_result = repo.delete(plan_id)
|
|
session.commit()
|
|
session.close()
|
|
|
|
|
|
@then("the plan should no longer be retrievable from the DB")
|
|
def step_verify_deleted(context: Context) -> None:
|
|
assert context.cov_result is True, "delete should return True"
|
|
plan_id = context.cov_plan.identity.plan_id
|
|
sf = context.cov_sf
|
|
session = sf()
|
|
repo = LifecyclePlanRepository(session_factory=lambda: session)
|
|
loaded = repo.get(plan_id)
|
|
session.close()
|
|
assert loaded is None, "Plan should be deleted"
|
|
|
|
|
|
@when('I delete a non-existent plan "{plan_id}" from the repository')
|
|
def step_delete_nonexistent(context: Context, plan_id: str) -> None:
|
|
sf = context.cov_sf
|
|
session = sf()
|
|
repo = LifecyclePlanRepository(session_factory=lambda: session)
|
|
context.cov_result = repo.delete(plan_id)
|
|
session.close()
|
|
|
|
|
|
@then("the plan delete result should be false")
|
|
def step_verify_delete_false(context: Context) -> None:
|
|
assert context.cov_result is False, f"Expected False, got {context.cov_result}"
|
|
|
|
|
|
# ── List plans with filters ──
|
|
|
|
|
|
@when('I list plans filtered by phase "{phase}"')
|
|
def step_list_by_phase(context: Context, phase: str) -> None:
|
|
sf = context.cov_sf
|
|
session = sf()
|
|
repo = LifecyclePlanRepository(session_factory=lambda: session)
|
|
context.cov_result = repo.list_plans(phase=phase)
|
|
session.close()
|
|
|
|
|
|
@when('I list plans filtered by namespace "{namespace}"')
|
|
def step_list_by_namespace(context: Context, namespace: str) -> None:
|
|
sf = context.cov_sf
|
|
session = sf()
|
|
repo = LifecyclePlanRepository(session_factory=lambda: session)
|
|
context.cov_result = repo.list_plans(namespace=namespace)
|
|
session.close()
|
|
|
|
|
|
@when("plcov I list plans filtered by action_name")
|
|
def step_list_by_action_name(context: Context) -> None:
|
|
action_name = context.cov_action_name or str(context.cov_plan.action_name)
|
|
sf = context.cov_sf
|
|
session = sf()
|
|
repo = LifecyclePlanRepository(session_factory=lambda: session)
|
|
context.cov_result = repo.list_plans(action_name=action_name)
|
|
session.close()
|
|
|
|
|
|
@then("the listed plans should include the coverage plan")
|
|
def step_verify_listed(context: Context) -> None:
|
|
plans = context.cov_result
|
|
assert len(plans) >= 1, f"Expected >= 1 plans, got {len(plans)}"
|
|
plan_ids = [p.identity.plan_id for p in plans]
|
|
assert context.cov_plan.identity.plan_id in plan_ids
|
|
|
|
|
|
# ── Count plans ──
|
|
|
|
|
|
@when("I count plans with no filters")
|
|
def step_count_no_filter(context: Context) -> None:
|
|
sf = context.cov_sf
|
|
session = sf()
|
|
repo = LifecyclePlanRepository(session_factory=lambda: session)
|
|
context.cov_result = repo.count()
|
|
session.close()
|
|
|
|
|
|
@then("the count should be at least 1")
|
|
def step_verify_count(context: Context) -> None:
|
|
assert context.cov_result >= 1, f"Expected >= 1, got {context.cov_result}"
|
|
|
|
|
|
@when('I count plans filtered by phase "{phase}"')
|
|
def step_count_by_phase(context: Context, phase: str) -> None:
|
|
sf = context.cov_sf
|
|
session = sf()
|
|
repo = LifecyclePlanRepository(session_factory=lambda: session)
|
|
context.cov_result = repo.count(phase=phase)
|
|
session.close()
|
|
|
|
|
|
@when('I count plans filtered by processing_state "{state}"')
|
|
def step_count_by_state(context: Context, state: str) -> None:
|
|
sf = context.cov_sf
|
|
session = sf()
|
|
repo = LifecyclePlanRepository(session_factory=lambda: session)
|
|
context.cov_result = repo.count(processing_state=state)
|
|
session.close()
|
|
|
|
|
|
@then("the filtered count should be at least 1")
|
|
def step_verify_filtered_count(context: Context) -> None:
|
|
assert context.cov_result >= 1, f"Expected >= 1, got {context.cov_result}"
|
|
|
|
|
|
# ── Duplicate plan error ──
|
|
|
|
|
|
@when("I try to create a duplicate plan with the same ID")
|
|
def step_create_duplicate_plan(context: Context) -> None:
|
|
plan = context.cov_plan
|
|
sf = context.cov_sf
|
|
session = sf()
|
|
repo = LifecyclePlanRepository(session_factory=lambda: session)
|
|
try:
|
|
repo.create(plan)
|
|
session.commit()
|
|
context.cov_error = None
|
|
except DuplicatePlanError as exc:
|
|
context.cov_error = exc
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
@then("a DuplicatePlanError should be raised")
|
|
def step_verify_duplicate_error(context: Context) -> None:
|
|
assert context.cov_error is not None, "Expected DuplicatePlanError"
|
|
assert isinstance(context.cov_error, DuplicatePlanError)
|
|
|
|
|
|
# ── Update non-existent plan error ──
|
|
|
|
|
|
@when("I try to update a plan that does not exist in the DB")
|
|
def step_update_nonexistent_plan(context: Context) -> None:
|
|
from datetime import datetime
|
|
|
|
from cleveragents.domain.models.core.plan import (
|
|
NamespacedName,
|
|
Plan,
|
|
PlanIdentity,
|
|
PlanTimestamps,
|
|
)
|
|
|
|
fake_plan = Plan(
|
|
identity=PlanIdentity(plan_id="01ZZZZZZZZZZZZZZZZZZZZZZZZ"),
|
|
namespaced_name=NamespacedName(
|
|
server=None, namespace="local", name="fake-plan"
|
|
),
|
|
action_name="local/fake-action",
|
|
description="Fake plan",
|
|
definition_of_done="Never",
|
|
phase=PlanPhase.STRATEGIZE,
|
|
processing_state=ProcessingState.QUEUED,
|
|
strategy_actor="openai/gpt-4",
|
|
execution_actor="openai/gpt-4",
|
|
timestamps=PlanTimestamps(
|
|
created_at=datetime.now(),
|
|
updated_at=datetime.now(),
|
|
),
|
|
reusable=True,
|
|
read_only=False,
|
|
)
|
|
sf = context.cov_sf
|
|
session = sf()
|
|
repo = LifecyclePlanRepository(session_factory=lambda: session)
|
|
try:
|
|
repo.update(fake_plan)
|
|
context.cov_error = None
|
|
except PlanNotFoundError as exc:
|
|
context.cov_error = exc
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
@then("a PlanNotFoundError should be raised")
|
|
def step_verify_not_found_error(context: Context) -> None:
|
|
assert context.cov_error is not None, "Expected PlanNotFoundError"
|
|
assert isinstance(context.cov_error, PlanNotFoundError)
|
|
|
|
|
|
# ── Archive action (triggers ActionRepository.update) ──
|
|
|
|
|
|
@when('I archive the coverage action "{name}"')
|
|
def step_archive_action(context: Context, name: str) -> None:
|
|
svc: PlanLifecycleService = context.cov_service
|
|
context.cov_result = svc.archive_action(name)
|
|
|
|
|
|
@then('the action "{name}" should be archived in the DB')
|
|
def step_verify_archived(context: Context, name: str) -> None:
|
|
from cleveragents.infrastructure.database.repositories import ActionRepository
|
|
|
|
sf = context.cov_sf
|
|
session = sf()
|
|
repo = ActionRepository(session_factory=lambda: session)
|
|
action = repo.get_by_name(name)
|
|
session.close()
|
|
assert action is not None, f"Action {name} not found"
|
|
assert action.state.value == "archived" or str(action.state) == "archived"
|
|
|
|
|
|
# ── Action update with inputs_schema/arguments/invariants ──
|
|
|
|
|
|
@when("I update the coverage action with new description")
|
|
def step_update_action(context: Context) -> None:
|
|
svc: PlanLifecycleService = context.cov_service
|
|
action = svc.get_action("local/cov-inputs-act")
|
|
action.description = "Updated description"
|
|
# Persist via archive (which calls update in the repository)
|
|
# Instead, let's call update directly via UoW to hit the exact lines
|
|
with context.cov_uow.transaction() as ctx:
|
|
ctx.actions.update(action)
|
|
|
|
|
|
@then("the action should retain its inputs_schema in the DB")
|
|
def step_verify_inputs_schema(context: Context) -> None:
|
|
from cleveragents.infrastructure.database.repositories import ActionRepository
|
|
|
|
sf = context.cov_sf
|
|
session = sf()
|
|
repo = ActionRepository(session_factory=lambda: session)
|
|
action = repo.get_by_name("local/cov-inputs-act")
|
|
session.close()
|
|
assert action is not None
|
|
assert action.inputs_schema is not None, "inputs_schema should be set"
|
|
|
|
|
|
@then("the action should retain its arguments with defaults in the DB")
|
|
def step_verify_action_args(context: Context) -> None:
|
|
from cleveragents.infrastructure.database.repositories import ActionRepository
|
|
|
|
sf = context.cov_sf
|
|
session = sf()
|
|
repo = ActionRepository(session_factory=lambda: session)
|
|
action = repo.get_by_name("local/cov-inputs-act")
|
|
session.close()
|
|
assert action is not None
|
|
assert len(action.arguments) >= 2, (
|
|
f"Expected >= 2 args, got {len(action.arguments)}"
|
|
)
|
|
# Check default values were preserved
|
|
defaults = [
|
|
a.default_value for a in action.arguments if a.default_value is not None
|
|
]
|
|
assert len(defaults) >= 1, "Should have at least 1 argument with default"
|
|
|
|
|
|
@then("the action should retain its invariants in the DB")
|
|
def step_verify_action_invariants(context: Context) -> None:
|
|
from cleveragents.infrastructure.database.repositories import ActionRepository
|
|
|
|
sf = context.cov_sf
|
|
session = sf()
|
|
repo = ActionRepository(session_factory=lambda: session)
|
|
action = repo.get_by_name("local/cov-inputs-act")
|
|
session.close()
|
|
assert action is not None
|
|
assert len(action.invariants) >= 2, (
|
|
f"Expected >= 2 invariants, got {len(action.invariants)}"
|
|
)
|
|
|
|
|
|
# ── Service get_plan from persistence fallback ──
|
|
|
|
|
|
@when("I clear the in-memory plan cache")
|
|
def step_clear_cache(context: Context) -> None:
|
|
svc: PlanLifecycleService = context.cov_service
|
|
# Clear in-memory plans dict to force fallback to persistence
|
|
svc._plans.clear()
|
|
|
|
|
|
@when("I retrieve the plan by ID through the service")
|
|
def step_get_plan_by_id(context: Context) -> None:
|
|
svc: PlanLifecycleService = context.cov_service
|
|
plan_id = context.cov_plan.identity.plan_id
|
|
context.cov_result = svc.get_plan(plan_id)
|
|
|
|
|
|
@then("the plan should be loaded from the persistence layer")
|
|
def step_verify_from_persistence(context: Context) -> None:
|
|
result = context.cov_result
|
|
assert result is not None, "Plan not loaded from persistence"
|
|
assert result.identity.plan_id == context.cov_plan.identity.plan_id
|
|
|
|
|
|
# ── Constrain apply ──
|
|
|
|
|
|
@when('I constrain the apply with reason "{reason}"')
|
|
def step_constrain_apply(context: Context, reason: str) -> None:
|
|
svc: PlanLifecycleService = context.cov_service
|
|
plan_id = context.cov_plan.identity.plan_id
|
|
# start the apply phase first
|
|
svc.start_apply(plan_id)
|
|
context.cov_plan = svc.constrain_apply(plan_id, reason)
|
|
|
|
|
|
@then('the coverage plan processing state should be "{state}"')
|
|
def step_cov_check_proc_state(context: Context, state: str) -> None:
|
|
assert context.cov_plan.processing_state.value == state, (
|
|
f"Expected {state}, got {context.cov_plan.processing_state.value}"
|
|
)
|
|
|
|
|
|
@then('the coverage plan error message should be "{message}"')
|
|
def step_cov_check_error_msg(context: Context, message: str) -> None:
|
|
assert context.cov_plan.error_message == message, (
|
|
f"Expected '{message}', got '{context.cov_plan.error_message}'"
|
|
)
|
|
|
|
|
|
# ── Auto progress no-op ──
|
|
|
|
|
|
@when("I call auto_progress on the plan")
|
|
def step_auto_progress(context: Context) -> None:
|
|
svc: PlanLifecycleService = context.cov_service
|
|
plan_id = context.cov_plan.identity.plan_id
|
|
context.cov_result = svc.auto_progress(plan_id)
|
|
|
|
|
|
@then("the plan should be returned unchanged")
|
|
def step_verify_unchanged(context: Context) -> None:
|
|
result = context.cov_result
|
|
assert result is not None
|
|
assert result.identity.plan_id == context.cov_plan.identity.plan_id
|
|
# Phase should still be strategize/queued
|
|
assert result.phase == PlanPhase.STRATEGIZE
|
|
|
|
|
|
# ── UoW lifecycle_plans property ──
|
|
|
|
|
|
@when("I open a UoW transaction and access lifecycle_plans")
|
|
def step_uow_lifecycle_plans(context: Context) -> None:
|
|
uow = context.cov_uow
|
|
with uow.transaction() as ctx:
|
|
context.cov_result = ctx.lifecycle_plans
|
|
|
|
|
|
@then("the lifecycle_plans property should return a LifecyclePlanRepository")
|
|
def step_verify_lifecycle_plans_type(context: Context) -> None:
|
|
assert isinstance(context.cov_result, LifecyclePlanRepository)
|
|
|
|
|
|
# ── UoW add/flush ──
|
|
|
|
|
|
@when("I use UoW context to add a raw model entity and flush")
|
|
def step_uow_add_flush(context: Context) -> None:
|
|
from datetime import datetime
|
|
|
|
# First, create an action (to satisfy FK constraint on v3_plans.action_name)
|
|
svc: PlanLifecycleService = context.cov_service
|
|
with contextlib.suppress(Exception):
|
|
svc.create_action(
|
|
name="local/uow-flush-test",
|
|
description="UoW flush test action",
|
|
definition_of_done="Done",
|
|
strategy_actor="openai/gpt-4",
|
|
execution_actor="openai/gpt-4",
|
|
reusable=True,
|
|
)
|
|
|
|
uow = context.cov_uow
|
|
with uow.transaction() as ctx:
|
|
# Create a raw model and add it via the context
|
|
model = LifecyclePlanModel()
|
|
model.plan_id = "01TESTADD00000000000000000" # type: ignore[assignment]
|
|
model.root_plan_id = "01TESTADD00000000000000000" # type: ignore[assignment]
|
|
model.namespaced_name = "local/uow-add-test" # type: ignore[assignment]
|
|
model.namespace = "local" # type: ignore[assignment]
|
|
model.name = "uow-add-test" # type: ignore[assignment]
|
|
model.action_name = "local/uow-flush-test" # type: ignore[assignment]
|
|
model.description = "UoW add test" # type: ignore[assignment]
|
|
model.definition_of_done = "Pass" # type: ignore[assignment]
|
|
model.phase = "strategize" # type: ignore[assignment]
|
|
model.processing_state = "queued" # type: ignore[assignment]
|
|
model.attempt = 1 # type: ignore[assignment]
|
|
model.strategy_actor = "openai/gpt-4" # type: ignore[assignment]
|
|
model.execution_actor = "openai/gpt-4" # type: ignore[assignment]
|
|
model.reusable = True # type: ignore[assignment]
|
|
model.read_only = False # type: ignore[assignment]
|
|
model.tags_json = "[]" # type: ignore[assignment]
|
|
model.sandbox_refs_json = "[]" # type: ignore[assignment]
|
|
model.effective_profile_snapshot = "{}" # type: ignore[assignment]
|
|
now = datetime.now().isoformat()
|
|
model.created_at = now # type: ignore[assignment]
|
|
model.updated_at = now # type: ignore[assignment]
|
|
ctx.add(model)
|
|
ctx.flush()
|
|
context.cov_result = model.plan_id
|
|
|
|
|
|
@then("the entity should be queryable in the same session")
|
|
def step_verify_entity_added(context: Context) -> None:
|
|
sf = context.cov_sf
|
|
session = sf()
|
|
repo = LifecyclePlanRepository(session_factory=lambda: session)
|
|
plan = repo.get("01TESTADD00000000000000000")
|
|
session.close()
|
|
assert plan is not None, "Added entity should be queryable"
|