forked from cleveragents/cleveragents-core
feat(events): enrich PLAN_APPLIED event with changeset statistics from PlanApplyService
Enriches the PLAN_APPLIED domain event with SEC7 audit-logging statistics per docs/specification.md §Audit Logging (issue #716). Changes: - PlanLifecycleService.complete_apply() now accepts optional keyword arguments: files_changed, lines_added, lines_removed, resources_modified, apply_duration_seconds (all default to 0). These are included in the PLAN_APPLIED event details dict alongside the existing action_name, phase, and project_names fields. - PlanApplyService.apply_with_validation_gate() computes changeset statistics from SpecChangeSet.summary() and measures apply duration, then passes all statistics to complete_apply(). - New BDD feature features/plan_applied_event_enrichment.feature with 13 scenarios. Closes #716 Co-authored-by: Jeffrey Phillips Freeman <the@jeffreyfreeman.me> Co-committed-by: Jeffrey Phillips Freeman <the@jeffreyfreeman.me>
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
Feature: PLAN_APPLIED event enrichment with changeset statistics
|
||||
As an audit log consumer
|
||||
I want the PLAN_APPLIED domain event to include changeset statistics
|
||||
So that audit entries contain rich context per SEC7 requirements
|
||||
|
||||
Background:
|
||||
Given a plan applied event enrichment test environment
|
||||
|
||||
# -- complete_apply passes statistics to event -------------------------
|
||||
|
||||
Scenario: complete_apply emits PLAN_APPLIED with files_changed statistic
|
||||
Given a plan in Apply/PROCESSING state
|
||||
When complete_apply is called with files_changed=5
|
||||
Then the emitted PLAN_APPLIED event details should include files_changed=5
|
||||
|
||||
Scenario: complete_apply emits PLAN_APPLIED with lines_added statistic
|
||||
Given a plan in Apply/PROCESSING state
|
||||
When complete_apply is called with lines_added=42
|
||||
Then the emitted PLAN_APPLIED event details should include lines_added=42
|
||||
|
||||
Scenario: complete_apply emits PLAN_APPLIED with lines_removed statistic
|
||||
Given a plan in Apply/PROCESSING state
|
||||
When complete_apply is called with lines_removed=7
|
||||
Then the emitted PLAN_APPLIED event details should include lines_removed=7
|
||||
|
||||
Scenario: complete_apply emits PLAN_APPLIED with resources_modified statistic
|
||||
Given a plan in Apply/PROCESSING state
|
||||
When complete_apply is called with resources_modified=3
|
||||
Then the emitted PLAN_APPLIED event details should include resources_modified=3
|
||||
|
||||
Scenario: complete_apply emits PLAN_APPLIED with apply_duration_seconds statistic
|
||||
Given a plan in Apply/PROCESSING state
|
||||
When complete_apply is called with apply_duration_seconds=1.5
|
||||
Then the emitted PLAN_APPLIED event details should include apply_duration_seconds=1.5
|
||||
|
||||
Scenario: complete_apply emits PLAN_APPLIED with all statistics combined
|
||||
Given a plan in Apply/PROCESSING state
|
||||
When complete_apply is called with all changeset statistics
|
||||
Then the emitted PLAN_APPLIED event details should include all changeset statistics
|
||||
|
||||
Scenario: complete_apply emits PLAN_APPLIED with zero statistics by default
|
||||
Given a plan in Apply/PROCESSING state
|
||||
When complete_apply is called with no changeset statistics
|
||||
Then the emitted PLAN_APPLIED event details should include files_changed=0
|
||||
And the emitted PLAN_APPLIED event details should include lines_added=0
|
||||
And the emitted PLAN_APPLIED event details should include lines_removed=0
|
||||
And the emitted PLAN_APPLIED event details should include resources_modified=0
|
||||
And the emitted PLAN_APPLIED event details should include apply_duration_seconds=0.0
|
||||
|
||||
Scenario: complete_apply preserves existing event details alongside statistics
|
||||
Given a plan applied event enrichment test environment
|
||||
Given a plan in Apply/PROCESSING state with action_name "deploy-service"
|
||||
When complete_apply is called with files_changed=2
|
||||
Then the emitted PLAN_APPLIED event details should include action_name "deploy-service"
|
||||
And the emitted PLAN_APPLIED event details should include files_changed=2
|
||||
|
||||
# -- apply_with_validation_gate passes statistics through ---------------
|
||||
|
||||
Scenario: apply_with_validation_gate passes files_changed to complete_apply
|
||||
Given a plan with changeset containing 4 entries
|
||||
And the plan has no validation failures
|
||||
When apply_with_validation_gate is called
|
||||
Then complete_apply should be called with files_changed=4
|
||||
|
||||
Scenario: apply_with_validation_gate passes resources_modified to complete_apply
|
||||
Given a plan with changeset containing entries for 2 distinct resources
|
||||
And the plan has no validation failures
|
||||
When apply_with_validation_gate is called
|
||||
Then complete_apply should be called with resources_modified=2
|
||||
|
||||
Scenario: apply_with_validation_gate passes apply_duration_seconds to complete_apply
|
||||
Given a plan with changeset containing 1 entries
|
||||
And the plan has no validation failures
|
||||
When apply_with_validation_gate is called
|
||||
Then complete_apply should be called with a non-negative apply_duration_seconds
|
||||
|
||||
Scenario: apply_with_validation_gate passes lines_added from creates to complete_apply
|
||||
Given a plan with changeset containing 3 CREATE entries
|
||||
And the plan has no validation failures
|
||||
When apply_with_validation_gate is called
|
||||
Then complete_apply should be called with lines_added=3
|
||||
|
||||
Scenario: apply_with_validation_gate passes lines_removed from deletes to complete_apply
|
||||
Given a plan with changeset containing 2 DELETE entries
|
||||
And the plan has no validation failures
|
||||
When apply_with_validation_gate is called
|
||||
Then complete_apply should be called with lines_removed=2
|
||||
@@ -0,0 +1,489 @@
|
||||
"""Step definitions for PLAN_APPLIED event enrichment with changeset statistics.
|
||||
|
||||
Tests that:
|
||||
1. ``PlanLifecycleService.complete_apply`` accepts changeset statistics kwargs
|
||||
and includes them in the emitted ``PLAN_APPLIED`` domain event.
|
||||
2. ``PlanApplyService.apply_with_validation_gate`` computes and passes
|
||||
changeset statistics to ``complete_apply``.
|
||||
|
||||
Based on issue #716 (SEC7 audit logging enrichment).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import structlog
|
||||
from behave import given, then, when # type: ignore[import-untyped]
|
||||
from behave.runner import Context # type: ignore[import-untyped]
|
||||
|
||||
from cleveragents.application.services.plan_apply_service import (
|
||||
PlanApplyService,
|
||||
)
|
||||
from cleveragents.application.services.plan_lifecycle_service import (
|
||||
PlanLifecycleService,
|
||||
)
|
||||
from cleveragents.domain.models.core.change import (
|
||||
ChangeEntry,
|
||||
ChangeOperation,
|
||||
SpecChangeSet,
|
||||
)
|
||||
from cleveragents.domain.models.core.plan import (
|
||||
PlanPhase,
|
||||
PlanTimestamps,
|
||||
ProcessingState,
|
||||
)
|
||||
from cleveragents.infrastructure.events.models import DomainEvent
|
||||
from cleveragents.infrastructure.events.types import EventType
|
||||
|
||||
__all__: list[str] = []
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_PLAN_ID = "01ENRICHTEST00000000001"
|
||||
_CHANGESET_ID = "01CSENRICH000000000001"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mock helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_mock_plan(
|
||||
*,
|
||||
plan_id: str = _PLAN_ID,
|
||||
phase: PlanPhase = PlanPhase.APPLY,
|
||||
state: ProcessingState = ProcessingState.PROCESSING,
|
||||
changeset_id: str | None = _CHANGESET_ID,
|
||||
action_name: str = "test-action",
|
||||
project_links: list[Any] | None = None,
|
||||
created_by: str = "test-actor",
|
||||
validation_summary: dict[str, Any] | None = None,
|
||||
is_terminal: bool = False,
|
||||
error_details: dict[str, str] | None = None,
|
||||
) -> MagicMock:
|
||||
"""Create a mock Plan object for enrichment tests."""
|
||||
plan = MagicMock()
|
||||
plan.identity.plan_id = plan_id
|
||||
plan.phase = phase
|
||||
plan.processing_state = state
|
||||
plan.state = state
|
||||
plan.changeset_id = changeset_id
|
||||
plan.action_name = action_name
|
||||
plan.project_links = project_links or []
|
||||
plan.created_by = created_by
|
||||
plan.validation_summary = validation_summary
|
||||
plan.is_terminal = is_terminal
|
||||
plan.error_details = error_details
|
||||
plan.timestamps = PlanTimestamps()
|
||||
return plan
|
||||
|
||||
|
||||
def _make_changeset(
|
||||
plan_id: str = _PLAN_ID,
|
||||
n_modify: int = 0,
|
||||
n_create: int = 0,
|
||||
n_delete: int = 0,
|
||||
resource_ids: list[str] | None = None,
|
||||
) -> SpecChangeSet:
|
||||
"""Create a SpecChangeSet with specified entry counts."""
|
||||
cs = SpecChangeSet(plan_id=plan_id, changeset_id=_CHANGESET_ID)
|
||||
idx = 0
|
||||
|
||||
def _res(i: int) -> str:
|
||||
if resource_ids:
|
||||
return resource_ids[i % len(resource_ids)]
|
||||
return f"RES{i:03d}"
|
||||
|
||||
for i in range(n_create):
|
||||
cs.add_change(
|
||||
ChangeEntry(
|
||||
plan_id=plan_id,
|
||||
resource_id=_res(idx),
|
||||
tool_name="builtin/test",
|
||||
operation=ChangeOperation.CREATE,
|
||||
path=f"src/new_file_{i}.py",
|
||||
after_hash=f"after-{i}",
|
||||
)
|
||||
)
|
||||
idx += 1
|
||||
|
||||
for i in range(n_modify):
|
||||
cs.add_change(
|
||||
ChangeEntry(
|
||||
plan_id=plan_id,
|
||||
resource_id=_res(idx),
|
||||
tool_name="builtin/test",
|
||||
operation=ChangeOperation.MODIFY,
|
||||
path=f"src/file_{i}.py",
|
||||
before_hash=f"before-{i}",
|
||||
after_hash=f"after-{i}",
|
||||
)
|
||||
)
|
||||
idx += 1
|
||||
|
||||
for i in range(n_delete):
|
||||
cs.add_change(
|
||||
ChangeEntry(
|
||||
plan_id=plan_id,
|
||||
resource_id=_res(idx),
|
||||
tool_name="builtin/test",
|
||||
operation=ChangeOperation.DELETE,
|
||||
path=f"src/old_file_{i}.py",
|
||||
before_hash=f"before-del-{i}",
|
||||
)
|
||||
)
|
||||
idx += 1
|
||||
|
||||
return cs
|
||||
|
||||
|
||||
def _build_lifecycle_with_capturing_bus(
|
||||
plan: MagicMock,
|
||||
) -> tuple[MagicMock, list[DomainEvent]]:
|
||||
"""Build a lifecycle mock that calls the real complete_apply and captures events.
|
||||
|
||||
Returns:
|
||||
Tuple of (lifecycle_mock, emitted_events_list).
|
||||
"""
|
||||
emitted_events: list[DomainEvent] = []
|
||||
|
||||
event_bus = MagicMock()
|
||||
event_bus.emit.side_effect = lambda e: emitted_events.append(e)
|
||||
|
||||
# We need a lifecycle object that has all the attributes complete_apply
|
||||
# accesses, but calls the real implementation.
|
||||
lifecycle = MagicMock(spec=PlanLifecycleService)
|
||||
lifecycle.event_bus = event_bus
|
||||
lifecycle.get_plan.return_value = plan
|
||||
lifecycle._commit_plan = MagicMock()
|
||||
lifecycle._cleanup_devcontainers = MagicMock()
|
||||
lifecycle._logger = structlog.get_logger("test.lifecycle")
|
||||
|
||||
# Bind the real complete_apply to this mock instance
|
||||
def _real_complete_apply(plan_id: str, **kwargs: Any) -> Any:
|
||||
return PlanLifecycleService.complete_apply(lifecycle, plan_id, **kwargs)
|
||||
|
||||
lifecycle.complete_apply = _real_complete_apply
|
||||
|
||||
return lifecycle, emitted_events
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Background
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a plan applied event enrichment test environment")
|
||||
def step_enrichment_env(context: Context) -> None:
|
||||
"""Initialise shared context for enrichment tests."""
|
||||
context.emitted_events = []
|
||||
context.complete_apply_calls = []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Given steps — lifecycle service scenarios
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a plan in Apply/PROCESSING state")
|
||||
def step_plan_apply_processing(context: Context) -> None:
|
||||
"""Set up a plan in Apply/PROCESSING state with a capturing event bus."""
|
||||
context.plan = _make_mock_plan()
|
||||
lifecycle, emitted_events = _build_lifecycle_with_capturing_bus(context.plan)
|
||||
context.lifecycle = lifecycle
|
||||
context.emitted_events = emitted_events
|
||||
|
||||
|
||||
@given('a plan in Apply/PROCESSING state with action_name "{action_name}"')
|
||||
def step_plan_apply_processing_with_action(context: Context, action_name: str) -> None:
|
||||
"""Set up a plan in Apply/PROCESSING state with a specific action_name."""
|
||||
context.plan = _make_mock_plan(action_name=action_name)
|
||||
lifecycle, emitted_events = _build_lifecycle_with_capturing_bus(context.plan)
|
||||
context.lifecycle = lifecycle
|
||||
context.emitted_events = emitted_events
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Given steps — apply_with_validation_gate scenarios
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a plan with changeset containing {n:d} entries")
|
||||
def step_plan_with_n_entries(context: Context, n: int) -> None:
|
||||
"""Set up a plan with a changeset of n MODIFY entries."""
|
||||
context.plan = _make_mock_plan()
|
||||
context.changeset = _make_changeset(n_modify=n)
|
||||
context.complete_apply_kwargs = {}
|
||||
context.apply_result = None
|
||||
|
||||
|
||||
@given("a plan with changeset containing entries for {n:d} distinct resources")
|
||||
def step_plan_with_n_resources(context: Context, n: int) -> None:
|
||||
"""Set up a plan with a changeset spanning n distinct resources."""
|
||||
resource_ids = [f"RES{i:03d}" for i in range(n)]
|
||||
context.plan = _make_mock_plan()
|
||||
context.changeset = _make_changeset(
|
||||
n_modify=n,
|
||||
resource_ids=resource_ids,
|
||||
)
|
||||
context.complete_apply_kwargs = {}
|
||||
context.apply_result = None
|
||||
|
||||
|
||||
@given("a plan with changeset containing {n:d} CREATE entries")
|
||||
def step_plan_with_n_creates(context: Context, n: int) -> None:
|
||||
"""Set up a plan with a changeset of n CREATE entries."""
|
||||
context.plan = _make_mock_plan()
|
||||
context.changeset = _make_changeset(n_create=n)
|
||||
context.complete_apply_kwargs = {}
|
||||
context.apply_result = None
|
||||
|
||||
|
||||
@given("a plan with changeset containing {n:d} DELETE entries")
|
||||
def step_plan_with_n_deletes(context: Context, n: int) -> None:
|
||||
"""Set up a plan with a changeset of n DELETE entries."""
|
||||
context.plan = _make_mock_plan()
|
||||
context.changeset = _make_changeset(n_delete=n)
|
||||
context.complete_apply_kwargs = {}
|
||||
context.apply_result = None
|
||||
|
||||
|
||||
@given("the plan has no validation failures")
|
||||
def step_no_validation_failures(context: Context) -> None:
|
||||
"""Ensure the plan has no validation failures."""
|
||||
context.plan.validation_summary = {
|
||||
"required_passed": 0,
|
||||
"required_failed": 0,
|
||||
"total": 0,
|
||||
}
|
||||
context.plan.is_terminal = False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# When steps — lifecycle service scenarios
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when("complete_apply is called with files_changed={n:d}")
|
||||
def step_complete_apply_files_changed(context: Context, n: int) -> None:
|
||||
"""Call complete_apply with files_changed kwarg."""
|
||||
context.lifecycle.complete_apply(_PLAN_ID, files_changed=n)
|
||||
|
||||
|
||||
@when("complete_apply is called with lines_added={n:d}")
|
||||
def step_complete_apply_lines_added(context: Context, n: int) -> None:
|
||||
"""Call complete_apply with lines_added kwarg."""
|
||||
context.lifecycle.complete_apply(_PLAN_ID, lines_added=n)
|
||||
|
||||
|
||||
@when("complete_apply is called with lines_removed={n:d}")
|
||||
def step_complete_apply_lines_removed(context: Context, n: int) -> None:
|
||||
"""Call complete_apply with lines_removed kwarg."""
|
||||
context.lifecycle.complete_apply(_PLAN_ID, lines_removed=n)
|
||||
|
||||
|
||||
@when("complete_apply is called with resources_modified={n:d}")
|
||||
def step_complete_apply_resources_modified(context: Context, n: int) -> None:
|
||||
"""Call complete_apply with resources_modified kwarg."""
|
||||
context.lifecycle.complete_apply(_PLAN_ID, resources_modified=n)
|
||||
|
||||
|
||||
@when("complete_apply is called with apply_duration_seconds={v:f}")
|
||||
def step_complete_apply_duration(context: Context, v: float) -> None:
|
||||
"""Call complete_apply with apply_duration_seconds kwarg."""
|
||||
context.lifecycle.complete_apply(_PLAN_ID, apply_duration_seconds=v)
|
||||
|
||||
|
||||
@when("complete_apply is called with all changeset statistics")
|
||||
def step_complete_apply_all_stats(context: Context) -> None:
|
||||
"""Call complete_apply with all changeset statistics kwargs."""
|
||||
context.lifecycle.complete_apply(
|
||||
_PLAN_ID,
|
||||
files_changed=10,
|
||||
lines_added=200,
|
||||
lines_removed=50,
|
||||
resources_modified=4,
|
||||
apply_duration_seconds=2.75,
|
||||
)
|
||||
context.expected_stats = {
|
||||
"files_changed": 10,
|
||||
"lines_added": 200,
|
||||
"lines_removed": 50,
|
||||
"resources_modified": 4,
|
||||
"apply_duration_seconds": 2.75,
|
||||
}
|
||||
|
||||
|
||||
@when("complete_apply is called with no changeset statistics")
|
||||
def step_complete_apply_no_stats(context: Context) -> None:
|
||||
"""Call complete_apply with no changeset statistics (defaults)."""
|
||||
context.lifecycle.complete_apply(_PLAN_ID)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# When steps — apply_with_validation_gate scenarios
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when("apply_with_validation_gate is called")
|
||||
def step_apply_with_validation_gate(context: Context) -> None:
|
||||
"""Call apply_with_validation_gate and capture complete_apply kwargs."""
|
||||
plan = context.plan
|
||||
changeset = context.changeset
|
||||
|
||||
lifecycle = MagicMock()
|
||||
lifecycle.get_plan.return_value = plan
|
||||
lifecycle._commit_plan = MagicMock()
|
||||
|
||||
captured_kwargs: dict[str, Any] = {}
|
||||
|
||||
def _capture_complete_apply(plan_id: str, **kwargs: Any) -> MagicMock:
|
||||
captured_kwargs.update(kwargs)
|
||||
return plan
|
||||
|
||||
lifecycle.complete_apply.side_effect = _capture_complete_apply
|
||||
|
||||
store = MagicMock()
|
||||
store.get.return_value = changeset
|
||||
|
||||
service = PlanApplyService(
|
||||
lifecycle_service=lifecycle,
|
||||
changeset_store=store,
|
||||
)
|
||||
|
||||
context.apply_result = service.apply_with_validation_gate(plan.identity.plan_id)
|
||||
context.complete_apply_kwargs = captured_kwargs
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then steps — event details assertions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _get_plan_applied_event(context: Context) -> DomainEvent:
|
||||
"""Return the first PLAN_APPLIED event from the captured list."""
|
||||
events = [
|
||||
e for e in context.emitted_events if e.event_type == EventType.PLAN_APPLIED
|
||||
]
|
||||
assert events, "No PLAN_APPLIED event was emitted"
|
||||
return events[0]
|
||||
|
||||
|
||||
@then("the emitted PLAN_APPLIED event details should include files_changed={n:d}")
|
||||
def step_assert_files_changed(context: Context, n: int) -> None:
|
||||
"""Assert files_changed in PLAN_APPLIED event details."""
|
||||
event = _get_plan_applied_event(context)
|
||||
assert event.details.get("files_changed") == n, (
|
||||
f"Expected files_changed={n}, got {event.details.get('files_changed')!r}"
|
||||
)
|
||||
|
||||
|
||||
@then("the emitted PLAN_APPLIED event details should include lines_added={n:d}")
|
||||
def step_assert_lines_added(context: Context, n: int) -> None:
|
||||
"""Assert lines_added in PLAN_APPLIED event details."""
|
||||
event = _get_plan_applied_event(context)
|
||||
assert event.details.get("lines_added") == n, (
|
||||
f"Expected lines_added={n}, got {event.details.get('lines_added')!r}"
|
||||
)
|
||||
|
||||
|
||||
@then("the emitted PLAN_APPLIED event details should include lines_removed={n:d}")
|
||||
def step_assert_lines_removed(context: Context, n: int) -> None:
|
||||
"""Assert lines_removed in PLAN_APPLIED event details."""
|
||||
event = _get_plan_applied_event(context)
|
||||
assert event.details.get("lines_removed") == n, (
|
||||
f"Expected lines_removed={n}, got {event.details.get('lines_removed')!r}"
|
||||
)
|
||||
|
||||
|
||||
@then("the emitted PLAN_APPLIED event details should include resources_modified={n:d}")
|
||||
def step_assert_resources_modified(context: Context, n: int) -> None:
|
||||
"""Assert resources_modified in PLAN_APPLIED event details."""
|
||||
event = _get_plan_applied_event(context)
|
||||
assert event.details.get("resources_modified") == n, (
|
||||
f"Expected resources_modified={n}, got {event.details.get('resources_modified')!r}"
|
||||
)
|
||||
|
||||
|
||||
@then(
|
||||
"the emitted PLAN_APPLIED event details should include apply_duration_seconds={v:f}"
|
||||
)
|
||||
def step_assert_apply_duration(context: Context, v: float) -> None:
|
||||
"""Assert apply_duration_seconds in PLAN_APPLIED event details."""
|
||||
event = _get_plan_applied_event(context)
|
||||
actual = event.details.get("apply_duration_seconds")
|
||||
assert actual == v, f"Expected apply_duration_seconds={v}, got {actual!r}"
|
||||
|
||||
|
||||
@then("the emitted PLAN_APPLIED event details should include all changeset statistics")
|
||||
def step_assert_all_stats(context: Context) -> None:
|
||||
"""Assert all changeset statistics are present in PLAN_APPLIED event details."""
|
||||
event = _get_plan_applied_event(context)
|
||||
expected = context.expected_stats
|
||||
for key, value in expected.items():
|
||||
actual = event.details.get(key)
|
||||
assert actual == value, f"Expected {key}={value}, got {actual!r}"
|
||||
|
||||
|
||||
@then(
|
||||
'the emitted PLAN_APPLIED event details should include action_name "{action_name}"'
|
||||
)
|
||||
def step_assert_action_name(context: Context, action_name: str) -> None:
|
||||
"""Assert action_name is preserved in PLAN_APPLIED event details."""
|
||||
event = _get_plan_applied_event(context)
|
||||
assert event.details.get("action_name") == action_name, (
|
||||
f"Expected action_name={action_name!r}, got {event.details.get('action_name')!r}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then steps — complete_apply call assertions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then("complete_apply should be called with files_changed={n:d}")
|
||||
def step_assert_complete_apply_files_changed(context: Context, n: int) -> None:
|
||||
"""Assert complete_apply was called with files_changed=n."""
|
||||
actual = context.complete_apply_kwargs.get("files_changed")
|
||||
assert actual == n, (
|
||||
f"Expected complete_apply called with files_changed={n}, got {actual!r}"
|
||||
)
|
||||
|
||||
|
||||
@then("complete_apply should be called with resources_modified={n:d}")
|
||||
def step_assert_complete_apply_resources_modified(context: Context, n: int) -> None:
|
||||
"""Assert complete_apply was called with resources_modified=n."""
|
||||
actual = context.complete_apply_kwargs.get("resources_modified")
|
||||
assert actual == n, (
|
||||
f"Expected complete_apply called with resources_modified={n}, got {actual!r}"
|
||||
)
|
||||
|
||||
|
||||
@then("complete_apply should be called with a non-negative apply_duration_seconds")
|
||||
def step_assert_complete_apply_duration_nonneg(context: Context) -> None:
|
||||
"""Assert complete_apply was called with a non-negative apply_duration_seconds."""
|
||||
actual = context.complete_apply_kwargs.get("apply_duration_seconds")
|
||||
assert actual is not None, "apply_duration_seconds was not passed to complete_apply"
|
||||
assert actual >= 0.0, f"Expected apply_duration_seconds >= 0.0, got {actual!r}"
|
||||
|
||||
|
||||
@then("complete_apply should be called with lines_added={n:d}")
|
||||
def step_assert_complete_apply_lines_added(context: Context, n: int) -> None:
|
||||
"""Assert complete_apply was called with lines_added=n."""
|
||||
actual = context.complete_apply_kwargs.get("lines_added")
|
||||
assert actual == n, (
|
||||
f"Expected complete_apply called with lines_added={n}, got {actual!r}"
|
||||
)
|
||||
|
||||
|
||||
@then("complete_apply should be called with lines_removed={n:d}")
|
||||
def step_assert_complete_apply_lines_removed(context: Context, n: int) -> None:
|
||||
"""Assert complete_apply was called with lines_removed=n."""
|
||||
actual = context.complete_apply_kwargs.get("lines_removed")
|
||||
assert actual == n, (
|
||||
f"Expected complete_apply called with lines_removed={n}, got {actual!r}"
|
||||
)
|
||||
@@ -13,6 +13,10 @@ Provides the service layer for D0b.apply features:
|
||||
unless ``allow_empty`` is explicitly set.
|
||||
- **Validation-gated apply**: Block apply when required validations have not
|
||||
passed, setting plan to ``constrained`` state with actionable error.
|
||||
- **Changeset statistics enrichment**: Passes SEC7 audit statistics
|
||||
(files changed, resources modified, apply duration) to
|
||||
``PlanLifecycleService.complete_apply`` so the ``PLAN_APPLIED`` domain
|
||||
event carries rich context for audit logging (issue #716).
|
||||
|
||||
All public methods accept ``plan_id`` and delegate plan lookups to
|
||||
``PlanLifecycleService``.
|
||||
@@ -596,16 +600,24 @@ class PlanApplyService:
|
||||
# All required validations passed — proceed with apply
|
||||
files_changed = len(changeset.entries)
|
||||
|
||||
# Compute changeset statistics for SEC7 audit enrichment (issue #716)
|
||||
changeset_summary = changeset.summary()
|
||||
resources_modified = changeset_summary.get("resources_involved", 0)
|
||||
|
||||
self._logger.info(
|
||||
"Validation gate passed, applying",
|
||||
plan_id=plan_id,
|
||||
files_changed=files_changed,
|
||||
resources_modified=resources_modified,
|
||||
required_passed=req_passed,
|
||||
)
|
||||
|
||||
# Checkpoint: pre_apply snapshot
|
||||
self._try_checkpoint(plan_id, "pre_apply")
|
||||
|
||||
# Record apply start time for duration measurement
|
||||
apply_start = datetime.now(tz=UTC)
|
||||
|
||||
# Persist apply summary
|
||||
self.persist_apply_summary(
|
||||
plan_id=plan_id,
|
||||
@@ -613,9 +625,20 @@ class PlanApplyService:
|
||||
validations_run=total,
|
||||
)
|
||||
|
||||
# Transition to applied via lifecycle service
|
||||
# Compute apply duration
|
||||
apply_duration_seconds = (datetime.now(tz=UTC) - apply_start).total_seconds()
|
||||
|
||||
# Transition to applied via lifecycle service, passing changeset
|
||||
# statistics so the PLAN_APPLIED event carries rich SEC7 audit context.
|
||||
try:
|
||||
self._lifecycle.complete_apply(plan_id)
|
||||
self._lifecycle.complete_apply(
|
||||
plan_id,
|
||||
files_changed=files_changed,
|
||||
lines_added=changeset_summary.get("creates", 0),
|
||||
lines_removed=changeset_summary.get("deletes", 0),
|
||||
resources_modified=resources_modified,
|
||||
apply_duration_seconds=apply_duration_seconds,
|
||||
)
|
||||
except (PlanError, ValidationError):
|
||||
# If lifecycle transition fails, attempt rollback
|
||||
self._try_rollback(plan_id)
|
||||
|
||||
@@ -1526,14 +1526,33 @@ class PlanLifecycleService:
|
||||
|
||||
return plan
|
||||
|
||||
def complete_apply(self, plan_id: str) -> Plan:
|
||||
def complete_apply(
|
||||
self,
|
||||
plan_id: str,
|
||||
*,
|
||||
files_changed: int = 0,
|
||||
lines_added: int = 0,
|
||||
lines_removed: int = 0,
|
||||
resources_modified: int = 0,
|
||||
apply_duration_seconds: float = 0.0,
|
||||
) -> Plan:
|
||||
"""Complete the Apply phase — terminal success.
|
||||
|
||||
Sets ``processing_state`` to ``APPLIED`` (the terminal success
|
||||
state within the Apply phase). The phase remains ``APPLY``.
|
||||
|
||||
The optional changeset statistics parameters enrich the emitted
|
||||
``PLAN_APPLIED`` domain event with SEC7 audit-logging context
|
||||
(see docs/specification.md §Audit Logging).
|
||||
|
||||
Args:
|
||||
plan_id: The plan ULID
|
||||
plan_id: The plan ULID.
|
||||
files_changed: Number of files changed during apply.
|
||||
lines_added: Total lines added across all changed files.
|
||||
lines_removed: Total lines removed across all changed files.
|
||||
resources_modified: Number of distinct resources modified.
|
||||
apply_duration_seconds: Wall-clock duration of the apply
|
||||
operation in seconds.
|
||||
|
||||
Returns:
|
||||
The updated Plan with processing_state=APPLIED
|
||||
@@ -1578,6 +1597,12 @@ class PlanLifecycleService:
|
||||
"action_name": plan.action_name,
|
||||
"phase": plan.phase.value,
|
||||
"project_names": project_names,
|
||||
# SEC7 changeset statistics (issue #716)
|
||||
"files_changed": files_changed,
|
||||
"lines_added": lines_added,
|
||||
"lines_removed": lines_removed,
|
||||
"resources_modified": resources_modified,
|
||||
"apply_duration_seconds": apply_duration_seconds,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user