837ff4217b
CI / lint (pull_request) Successful in 15s
CI / benchmark-publish (pull_request) Has been skipped
CI / quality (pull_request) Successful in 18s
CI / build (pull_request) Successful in 14s
CI / typecheck (pull_request) Successful in 38s
CI / security (pull_request) Successful in 46s
CI / integration_tests (pull_request) Successful in 3m3s
CI / unit_tests (pull_request) Successful in 4m39s
CI / coverage (pull_request) Successful in 5m8s
CI / docker (pull_request) Successful in 55s
CI / lint (push) Successful in 12s
CI / quality (push) Successful in 16s
CI / security (push) Successful in 30s
CI / typecheck (push) Successful in 39s
CI / build (push) Successful in 15s
CI / benchmark-regression (push) Has been skipped
CI / unit_tests (push) Successful in 2m20s
CI / docker (push) Successful in 39s
CI / integration_tests (push) Successful in 3m2s
CI / coverage (push) Successful in 4m37s
CI / benchmark-regression (pull_request) Failing after 16m37s
CI / benchmark-publish (push) Failing after 9m16s
- Add AsyncJob domain model with status state machine and Pydantic validation
- Add AsyncWorker service with configurable concurrency and job store
- Add CancellationToken, WorkerHealthReport, InMemoryJobStore
- Add AsyncJobModel SQLAlchemy model and Alembic migration (m6_003)
- Add 5 async config keys to Settings (worker_id, concurrency, poll_interval, max_retries, timeout)
- Add _check_async_worker_health diagnostic check in system.py
- Add comprehensive Behave BDD tests (~60 scenarios) with full step definitions
- Add Robot Framework integration tests (6 smoke tests)
- Add ASV benchmark suite for async execution
- Add architecture documentation
- Update vulture_whitelist with new public API symbols
- All quality gates pass: lint, typecheck, unit_tests, integration_tests, coverage_report (97%)
1. Wire async job creation into PlanLifecycleService:
- Add optional job_store parameter to __init__
- Add _maybe_enqueue_async_job() helper that checks settings.async_enabled
and job store presence before creating and enqueuing an AsyncJob
- Call helper from execute_plan() (phase="execute") and apply_plan()
(phase="apply") after phase transitions
- When async is disabled or no job store is configured, behaviour is
unchanged (silent no-op)
2. Redact secrets in failed job error messages:
- Apply shared.redaction.redact_value() to the error string before
persisting to AsyncJob.error_message, preventing accidental secret
leakage (e.g. API keys in exception text) into the audit trail
Documentation:
- S1: Added specification reconciliation note (ADR-style) to
async_architecture.md addressing tension between "No Plan Queuing"
clause and the async subsystem authorised by issue #312
ISSUES CLOSED: #312
244 lines
8.8 KiB
Python
244 lines
8.8 KiB
Python
"""Step definitions for decision_di_wiring.feature.
|
|
|
|
Covers DI container wiring of DecisionService and its integration with
|
|
PlanLifecycleService for automatic decision recording.
|
|
|
|
All step text uses the ``decdi-`` prefix to avoid collisions with
|
|
existing step definitions.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from typing import TYPE_CHECKING
|
|
from unittest.mock import create_autospec
|
|
|
|
from behave import given, then, when
|
|
from behave.runner import Context
|
|
|
|
if TYPE_CHECKING:
|
|
from cleveragents.infrastructure.database.unit_of_work import UnitOfWork
|
|
|
|
# -------------------------------------------------------------------
|
|
# Helpers
|
|
# -------------------------------------------------------------------
|
|
|
|
|
|
def _make_test_uow() -> UnitOfWork:
|
|
"""Create a UnitOfWork backed by an in-memory SQLite for testing.
|
|
|
|
Uses ``UnitOfWork.__init__`` + ``init_database()`` rather than the
|
|
``__new__()`` anti-pattern to stay in sync with UoW internals.
|
|
"""
|
|
from cleveragents.infrastructure.database.unit_of_work import UnitOfWork
|
|
|
|
uow = UnitOfWork("sqlite:///:memory:")
|
|
uow.init_database()
|
|
return uow
|
|
|
|
|
|
def _ensure_test_db_env(context: Context) -> None:
|
|
"""Set up a test database URL if not already set."""
|
|
if not hasattr(context, "_decdi_env_saved"):
|
|
context._decdi_env_saved = os.environ.get("CLEVERAGENTS_DATABASE_URL")
|
|
os.environ["CLEVERAGENTS_DATABASE_URL"] = "sqlite:///:memory:"
|
|
|
|
|
|
def _restore_env(context: Context) -> None:
|
|
"""Restore environment variables."""
|
|
saved = getattr(context, "_decdi_env_saved", None)
|
|
if saved is None:
|
|
os.environ.pop("CLEVERAGENTS_DATABASE_URL", None)
|
|
else:
|
|
os.environ["CLEVERAGENTS_DATABASE_URL"] = saved
|
|
|
|
|
|
def _cleanup_container(context: Context) -> None:
|
|
"""Reset the global container after the scenario."""
|
|
from cleveragents.application.container import reset_container
|
|
|
|
reset_container()
|
|
_restore_env(context)
|
|
|
|
|
|
# -------------------------------------------------------------------
|
|
# Scenario: DI container resolves DecisionService
|
|
# -------------------------------------------------------------------
|
|
|
|
|
|
@given("decdi- the application container is initialized")
|
|
def step_init_container(context: Context) -> None:
|
|
from cleveragents.application.container import get_container, reset_container
|
|
|
|
_ensure_test_db_env(context)
|
|
reset_container()
|
|
context.container = get_container()
|
|
context.add_cleanup(_cleanup_container, context)
|
|
|
|
|
|
@when("decdi- I request a DecisionService from the container")
|
|
def step_request_decision_service(context: Context) -> None:
|
|
context.decision_service = context.container.decision_service()
|
|
|
|
|
|
@then("decdi- the container should return a valid DecisionService instance")
|
|
def step_check_decision_service(context: Context) -> None:
|
|
from cleveragents.application.services.decision_service import DecisionService
|
|
|
|
assert isinstance(context.decision_service, DecisionService), (
|
|
f"Expected DecisionService, got {type(context.decision_service).__name__}"
|
|
)
|
|
|
|
|
|
# -------------------------------------------------------------------
|
|
# Scenario: DecisionService records decision during plan strategize
|
|
# -------------------------------------------------------------------
|
|
|
|
|
|
@given("decdi- a PlanLifecycleService with a DecisionService")
|
|
def step_create_lifecycle_with_decision(context: Context) -> None:
|
|
"""Build an in-memory PlanLifecycleService wired with DecisionService."""
|
|
from cleveragents.application.services.decision_service import DecisionService
|
|
from cleveragents.application.services.plan_lifecycle_service import (
|
|
PlanLifecycleService,
|
|
)
|
|
from cleveragents.config.settings import Settings
|
|
|
|
uow = _make_test_uow()
|
|
|
|
mock_settings = create_autospec(Settings, instance=True)
|
|
mock_settings.database_url = "sqlite:///:memory:"
|
|
mock_settings.async_enabled = False
|
|
|
|
decision_svc = DecisionService(settings=mock_settings, unit_of_work=uow)
|
|
lifecycle_svc = PlanLifecycleService(
|
|
settings=mock_settings,
|
|
unit_of_work=uow,
|
|
decision_service=decision_svc,
|
|
)
|
|
|
|
context.decision_svc = decision_svc
|
|
context.lifecycle_svc = lifecycle_svc
|
|
context.uow = uow
|
|
|
|
|
|
@given("decdi- an action and plan exist in strategize phase")
|
|
def step_create_action_and_plan_strategize(context: Context) -> None:
|
|
context.action = context.lifecycle_svc.create_action(
|
|
name="local/decdi-strategize-test",
|
|
description="Test action for strategize",
|
|
definition_of_done="Done",
|
|
strategy_actor="local/s",
|
|
execution_actor="local/e",
|
|
)
|
|
context.plan = context.lifecycle_svc.use_action("local/decdi-strategize-test")
|
|
context.plan_id = context.plan.identity.plan_id
|
|
|
|
|
|
@when("decdi- I start the strategize phase")
|
|
def step_start_strategize(context: Context) -> None:
|
|
context.lifecycle_svc.start_strategize(context.plan_id)
|
|
|
|
|
|
@then("decdi- a strategy_choice decision should be recorded for the plan")
|
|
def step_check_strategize_decision(context: Context) -> None:
|
|
decisions = context.decision_svc.list_decisions(context.plan_id)
|
|
strategy_decisions = [
|
|
d for d in decisions if str(d.decision_type) == "strategy_choice"
|
|
]
|
|
assert len(strategy_decisions) == 1, (
|
|
f"Expected exactly 1 strategy_choice decision, got {len(strategy_decisions)}"
|
|
)
|
|
|
|
|
|
# -------------------------------------------------------------------
|
|
# Scenario: DecisionService records decision during plan execute
|
|
# -------------------------------------------------------------------
|
|
|
|
|
|
@given("decdi- an action and plan exist in execute phase")
|
|
def step_create_action_and_plan_execute(context: Context) -> None:
|
|
context.lifecycle_svc.create_action(
|
|
name="local/decdi-execute-test",
|
|
description="Test action for execute",
|
|
definition_of_done="Done",
|
|
strategy_actor="local/s",
|
|
execution_actor="local/e",
|
|
)
|
|
plan = context.lifecycle_svc.use_action("local/decdi-execute-test")
|
|
plan_id = plan.identity.plan_id
|
|
|
|
# Progress to execute phase: start -> complete strategize -> execute_plan
|
|
context.lifecycle_svc.start_strategize(plan_id)
|
|
context.lifecycle_svc.complete_strategize(plan_id)
|
|
|
|
# After complete_strategize, auto_progress may transition to execute
|
|
# depending on the automation profile. Fetch the latest state.
|
|
plan = context.lifecycle_svc.get_plan(plan_id)
|
|
from cleveragents.domain.models.core.plan import PlanPhase
|
|
|
|
if plan.phase == PlanPhase.STRATEGIZE:
|
|
context.lifecycle_svc.execute_plan(plan_id)
|
|
|
|
context.plan = context.lifecycle_svc.get_plan(plan_id)
|
|
context.plan_id = plan_id
|
|
|
|
|
|
@when("decdi- I start the execute phase")
|
|
def step_start_execute(context: Context) -> None:
|
|
context.lifecycle_svc.start_execute(context.plan_id)
|
|
|
|
|
|
@then("decdi- an implementation_choice decision should be recorded for the plan")
|
|
def step_check_execute_decision(context: Context) -> None:
|
|
decisions = context.decision_svc.list_decisions(context.plan_id)
|
|
impl_decisions = [
|
|
d for d in decisions if str(d.decision_type) == "implementation_choice"
|
|
]
|
|
assert len(impl_decisions) == 1, (
|
|
f"Expected exactly 1 implementation_choice decision, got {len(impl_decisions)}"
|
|
)
|
|
|
|
|
|
# -------------------------------------------------------------------
|
|
# Scenario: DecisionRepository is accessible via DI
|
|
# -------------------------------------------------------------------
|
|
|
|
|
|
@when("decdi- I open a UnitOfWork transaction from the container")
|
|
def step_open_uow_transaction(context: Context) -> None:
|
|
uow = context.container.unit_of_work()
|
|
uow.init_database()
|
|
with uow.transaction() as ctx:
|
|
context.uow_ctx = ctx
|
|
context.has_decisions = hasattr(ctx, "decisions")
|
|
context.decisions_type_name = type(ctx.decisions).__name__
|
|
|
|
|
|
@then("decdi- the transaction context should expose a DecisionRepository")
|
|
def step_check_decision_repo(context: Context) -> None:
|
|
assert context.has_decisions, (
|
|
"UnitOfWorkContext does not have 'decisions' attribute"
|
|
)
|
|
assert context.decisions_type_name == "DecisionRepository", (
|
|
f"Expected DecisionRepository, got {context.decisions_type_name}"
|
|
)
|
|
|
|
|
|
# -------------------------------------------------------------------
|
|
# Scenario: DecisionService scoping is correct
|
|
# -------------------------------------------------------------------
|
|
|
|
|
|
@when("decdi- I request two DecisionService instances from the container")
|
|
def step_request_two_instances(context: Context) -> None:
|
|
context.svc_a = context.container.decision_service()
|
|
context.svc_b = context.container.decision_service()
|
|
|
|
|
|
@then("decdi- the instances should be different objects because it is a Factory")
|
|
def step_check_different_instances(context: Context) -> None:
|
|
assert context.svc_a is not context.svc_b, (
|
|
"Factory provider should return different instances per call"
|
|
)
|