Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ebb71dc747 |
@@ -189,11 +189,12 @@ Feature: Plan CLI commands branch coverage (round 2)
|
||||
# plan_diff – correction flag present
|
||||
# ===================================================================
|
||||
|
||||
Scenario: r2plan diff shows correction stub when correction flag set
|
||||
Scenario: r2plan diff shows correction diff when correction flag set
|
||||
Given r2plan-a mocked lifecycle service
|
||||
And r2plan-the apply service correction_diff returns formatted output for "CORR1"
|
||||
When r2plan-I invoke diff with plan "P1" and correction "CORR1"
|
||||
Then r2plan-the command should succeed
|
||||
And r2plan-the output should contain "Correction Attempt"
|
||||
And r2plan-the output should contain "Correction Diff"
|
||||
And r2plan-the output should contain "CORR1"
|
||||
|
||||
# ===================================================================
|
||||
|
||||
@@ -57,11 +57,12 @@ Feature: Plan CLI uncovered region coverage (lines 1950-2273)
|
||||
# plan_diff — correction parameter branch (lines 2149-2184)
|
||||
# ===================================================================
|
||||
|
||||
Scenario: uncov-rgn plan diff with correction flag shows stub panel
|
||||
Scenario: uncov-rgn plan diff with correction flag shows correction diff output
|
||||
Given an uncov-rgn CLI runner and mocked lifecycle service
|
||||
And the uncov-rgn apply service correction_diff returns formatted output for "CORR-001"
|
||||
When I uncov-rgn invoke diff for plan "PLAN-001" with correction "CORR-001"
|
||||
Then the uncov-rgn command should exit normally
|
||||
And the uncov-rgn output should contain "Correction Attempt"
|
||||
And the uncov-rgn output should contain "Correction Diff"
|
||||
And the uncov-rgn output should contain "CORR-001"
|
||||
|
||||
Scenario: uncov-rgn plan diff without correction calls apply service
|
||||
|
||||
@@ -3,15 +3,15 @@ Feature: Plan CLI commands new coverage
|
||||
I want to cover the remaining uncovered lines in plan.py
|
||||
So that plan diff (with/without correction), plan artifacts, and _get_apply_service are fully tested
|
||||
|
||||
# ---------- plan diff with --correction flag (lines 1856-1867) ----------
|
||||
# ---------- plan diff with --correction flag ----------
|
||||
|
||||
Scenario: Plan diff with correction flag shows correction panel
|
||||
Scenario: Plan diff with correction flag shows correction diff output
|
||||
Given new_cov no service mocks are needed
|
||||
When new_cov I run plan diff for "plan-100" with correction "corr-42"
|
||||
Then new_cov the exit code should be 0
|
||||
And new_cov the output should contain "Correction Diff"
|
||||
And new_cov the output should contain "corr-42"
|
||||
And new_cov the output should contain "plan-100"
|
||||
And new_cov the output should contain "orig-decision-001"
|
||||
|
||||
# ---------- plan diff without correction (lines 1869-1871) ----------
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
Feature: Plan correction diff command
|
||||
As a developer using the v3 plan lifecycle
|
||||
I want to view a structured diff for a correction attempt
|
||||
So that I can understand what changes a correction will introduce
|
||||
|
||||
# ---------- All 4 output formats ----------
|
||||
|
||||
Scenario: correction diff returns rich format output
|
||||
Given correction-diff a mocked apply service returning rich correction diff
|
||||
When correction-diff I run plan diff for "PLAN-001" with correction "CORR-001" format "rich"
|
||||
Then correction-diff the exit code should be 0
|
||||
And correction-diff the output should contain "Correction Diff"
|
||||
And correction-diff the output should contain "CORR-001"
|
||||
|
||||
Scenario: correction diff returns plain format output
|
||||
Given correction-diff a mocked apply service returning plain correction diff
|
||||
When correction-diff I run plan diff for "PLAN-002" with correction "CORR-002" format "plain"
|
||||
Then correction-diff the exit code should be 0
|
||||
And correction-diff the output should contain "Correction Diff"
|
||||
And correction-diff the output should contain "CORR-002"
|
||||
|
||||
Scenario: correction diff returns json format output
|
||||
Given correction-diff a mocked apply service returning json correction diff for "CORR-003"
|
||||
When correction-diff I run plan diff for "PLAN-003" with correction "CORR-003" format "json"
|
||||
Then correction-diff the exit code should be 0
|
||||
And correction-diff the output should contain "correction_diff"
|
||||
And correction-diff the output should contain "CORR-003"
|
||||
And correction-diff the service was called with format "json"
|
||||
|
||||
Scenario: correction diff returns yaml format output
|
||||
Given correction-diff a mocked apply service returning yaml correction diff for "CORR-004"
|
||||
When correction-diff I run plan diff for "PLAN-004" with correction "CORR-004" format "yaml"
|
||||
Then correction-diff the exit code should be 0
|
||||
And correction-diff the output should contain "correction_diff"
|
||||
And correction-diff the output should contain "CORR-004"
|
||||
And correction-diff the service was called with format "yaml"
|
||||
|
||||
# ---------- Error paths ----------
|
||||
|
||||
Scenario: correction diff raises PlanError when plan not found
|
||||
Given correction-diff the apply service correction_diff raises PlanError "Plan PLAN-999 not found"
|
||||
When correction-diff I run plan diff for "PLAN-999" with correction "CORR-999" format "rich"
|
||||
Then correction-diff the exit code should be nonzero
|
||||
And correction-diff the output should contain "Diff Error"
|
||||
And correction-diff the output should contain "Plan PLAN-999 not found"
|
||||
|
||||
Scenario: correction diff raises PlanError when correction not found
|
||||
Given correction-diff the apply service correction_diff raises PlanError "Correction attempt CORR-404 not found"
|
||||
When correction-diff I run plan diff for "PLAN-001" with correction "CORR-404" format "rich"
|
||||
Then correction-diff the exit code should be nonzero
|
||||
And correction-diff the output should contain "Diff Error"
|
||||
And correction-diff the output should contain "Correction attempt CORR-404 not found"
|
||||
|
||||
Scenario: correction diff raises PlanError when attempt does not belong to the plan
|
||||
Given correction-diff the apply service correction_diff raises PlanError "Correction attempt CORR-001 does not belong to plan PLAN-999"
|
||||
When correction-diff I run plan diff for "PLAN-999" with correction "CORR-001" format "rich"
|
||||
Then correction-diff the exit code should be nonzero
|
||||
And correction-diff the output should contain "Diff Error"
|
||||
And correction-diff the output should contain "does not belong to plan"
|
||||
|
||||
Scenario: correction diff raises error when plan_id is empty
|
||||
Given correction-diff the apply service correction_diff raises ValidationError "plan_id must not be empty"
|
||||
When correction-diff I run plan diff for "" with correction "CORR-001" format "rich"
|
||||
Then correction-diff the exit code should be nonzero
|
||||
And correction-diff the output should contain "Error"
|
||||
And correction-diff the output should contain "plan_id must not be empty"
|
||||
|
||||
Scenario: correction diff raises error when correction_attempt_id is empty (service guard)
|
||||
# The CLI passes --correction through to correction_diff() only when it is non-empty.
|
||||
# The service-level ValidationError guard for empty correction_attempt_id is tested
|
||||
# here by mocking the service to raise the error and asserting the CLI surfaces it.
|
||||
Given correction-diff the apply service correction_diff raises ValidationError "correction_attempt_id must not be empty"
|
||||
When correction-diff I run plan diff for "PLAN-001" with correction "CORR-001" format "rich"
|
||||
Then correction-diff the exit code should be nonzero
|
||||
And correction-diff the output should contain "Error"
|
||||
And correction-diff the output should contain "correction_attempt_id must not be empty"
|
||||
@@ -408,6 +408,21 @@ def step_invoke_correct_empty_guidance(context: Any) -> None:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_PATCH_GET_APPLY_R2 = "cleveragents.cli.commands.plan._get_apply_service"
|
||||
|
||||
|
||||
@given('r2plan-the apply service correction_diff returns formatted output for "{corr}"')
|
||||
def step_r2plan_correction_diff_mock(context: Any, corr: str) -> None:
|
||||
"""Set up mock apply service that returns correction diff output."""
|
||||
mock_svc = MagicMock()
|
||||
mock_svc.correction_diff.return_value = (
|
||||
"Correction Diff: " + corr + " orig-decision-001 revert completed 1"
|
||||
)
|
||||
p = patch(_PATCH_GET_APPLY_R2, return_value=mock_svc)
|
||||
p.start()
|
||||
context.r2_cleanups.append(p.stop)
|
||||
|
||||
|
||||
@when('r2plan-I invoke diff with plan "{pid}" and correction "{corr}"')
|
||||
def step_invoke_diff_correction(context: Any, pid: str, corr: str) -> None:
|
||||
context.r2_result = _runner.invoke(
|
||||
|
||||
@@ -184,6 +184,16 @@ def step_uncov_rgn_apply_diff_ca_error(context: Context, msg: str) -> None:
|
||||
context.uncov_mock_apply = mock_apply
|
||||
|
||||
|
||||
@given('the uncov-rgn apply service correction_diff returns formatted output for "{corr}"')
|
||||
def step_uncov_rgn_apply_correction_diff_ok(context: Context, corr: str) -> None:
|
||||
"""Set up a mock apply service whose correction_diff() returns formatted output."""
|
||||
mock_apply = MagicMock()
|
||||
mock_apply.correction_diff.return_value = (
|
||||
"Correction Diff: " + corr + " orig-decision-001 revert completed 1"
|
||||
)
|
||||
context.uncov_mock_apply = mock_apply
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Given — plan_artifacts scenarios
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -425,11 +435,19 @@ def step_uncov_rgn_call_get_apply(context: Context) -> None:
|
||||
def step_uncov_rgn_invoke_diff_correction(
|
||||
context: Context, pid: str, corr: str
|
||||
) -> None:
|
||||
"""Invoke the diff command with --correction flag."""
|
||||
context.uncov_result = context.uncov_runner.invoke(
|
||||
plan_app,
|
||||
["diff", pid, "--correction", corr],
|
||||
)
|
||||
"""Invoke the diff command with --correction flag, mocking the apply service."""
|
||||
mock_apply = getattr(context, "uncov_mock_apply", None)
|
||||
if mock_apply is not None:
|
||||
with patch(_PATCH_GET_APPLY, return_value=mock_apply):
|
||||
context.uncov_result = context.uncov_runner.invoke(
|
||||
plan_app,
|
||||
["diff", pid, "--correction", corr],
|
||||
)
|
||||
else:
|
||||
context.uncov_result = context.uncov_runner.invoke(
|
||||
plan_app,
|
||||
["diff", pid, "--correction", corr],
|
||||
)
|
||||
|
||||
|
||||
@when('I uncov-rgn invoke diff for plan "{pid}" without correction')
|
||||
|
||||
@@ -54,7 +54,11 @@ use_step_matcher("parse")
|
||||
|
||||
@given("new_cov no service mocks are needed")
|
||||
def step_new_cov_no_mocks(context: Context) -> None:
|
||||
"""The correction branch returns early before calling any service."""
|
||||
"""The correction branch calls _get_apply_service() and then correction_diff().
|
||||
|
||||
A None mock service indicates the test step patches _get_apply_service
|
||||
directly within the WHEN step rather than pre-configuring a service here.
|
||||
"""
|
||||
context.new_cov_mock_service = None
|
||||
|
||||
|
||||
@@ -126,12 +130,17 @@ def step_new_cov_diff_with_correction(
|
||||
) -> None:
|
||||
"""Invoke ``plan diff <plan_id> --correction <corr>`` via the CLI runner.
|
||||
|
||||
The correction branch returns before calling _get_apply_service, so
|
||||
we do NOT need to mock it.
|
||||
Mocks ``_get_apply_service`` so the correction_diff method returns a
|
||||
formatted response without hitting the database.
|
||||
"""
|
||||
context.new_cov_result = runner.invoke(
|
||||
plan_app, ["diff", plan_id, "--correction", corr]
|
||||
mock_svc = MagicMock()
|
||||
mock_svc.correction_diff.return_value = (
|
||||
"Correction Diff: " + corr + " orig-decision-001 revert completed 1"
|
||||
)
|
||||
with patch(_PATCH_GET_APPLY, return_value=mock_svc):
|
||||
context.new_cov_result = runner.invoke(
|
||||
plan_app, ["diff", plan_id, "--correction", corr]
|
||||
)
|
||||
|
||||
|
||||
@when(r'new_cov I run plan diff for "(?P<plan_id>[^"]+)" with format "(?P<fmt>[^"]+)"')
|
||||
@@ -188,9 +197,16 @@ def step_new_cov_call_get_apply(context: Context) -> None:
|
||||
mock_pas_cls = MagicMock()
|
||||
mock_pas_instance = MagicMock()
|
||||
mock_pas_cls.return_value = mock_pas_instance
|
||||
mock_container = MagicMock()
|
||||
mock_uow = MagicMock()
|
||||
mock_container.unit_of_work.return_value = mock_uow
|
||||
|
||||
with (
|
||||
patch(_PATCH_LIFECYCLE, return_value=mock_lifecycle),
|
||||
patch(
|
||||
"cleveragents.application.container.get_container",
|
||||
return_value=mock_container,
|
||||
),
|
||||
patch(
|
||||
"cleveragents.cli.commands.plan.PlanApplyService",
|
||||
mock_pas_cls,
|
||||
@@ -204,6 +220,7 @@ def step_new_cov_call_get_apply(context: Context) -> None:
|
||||
context.new_cov_mock_lifecycle_used = mock_lifecycle
|
||||
context.new_cov_mock_pas_cls = mock_pas_cls
|
||||
context.new_cov_mock_pas_instance = mock_pas_instance
|
||||
context.new_cov_mock_uow = mock_uow
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -247,4 +264,5 @@ def step_new_cov_result_is_pas(context: Context) -> None:
|
||||
def step_new_cov_pas_called_with_lifecycle(context: Context) -> None:
|
||||
context.new_cov_mock_pas_cls.assert_called_once_with(
|
||||
lifecycle_service=context.new_cov_mock_lifecycle_used,
|
||||
unit_of_work=context.new_cov_mock_uow,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
"""Step definitions for plan_correction_diff.feature.
|
||||
|
||||
Covers:
|
||||
- ``correction_diff()`` method in PlanApplyService for all 4 output formats
|
||||
- Error paths: plan not found, correction not found
|
||||
|
||||
All step text uses the ``correction-diff`` prefix to avoid collisions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from behave import given, then, use_step_matcher, when
|
||||
from behave.runner import Context
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from cleveragents.cli.commands.plan import app as plan_app
|
||||
from cleveragents.core.exceptions import PlanError, ValidationError
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
_PATCH_GET_APPLY = "cleveragents.cli.commands.plan._get_apply_service"
|
||||
|
||||
|
||||
def _build_mock_svc(
|
||||
correction_diff_return: str | None = None,
|
||||
correction_diff_side_effect: Exception | None = None,
|
||||
) -> MagicMock:
|
||||
"""Build a mock PlanApplyService with configurable correction_diff behaviour."""
|
||||
svc = MagicMock()
|
||||
if correction_diff_side_effect is not None:
|
||||
svc.correction_diff.side_effect = correction_diff_side_effect
|
||||
else:
|
||||
svc.correction_diff.return_value = correction_diff_return or ""
|
||||
return svc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GIVEN steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("correction-diff a mocked apply service returning rich correction diff")
|
||||
def step_corr_diff_rich(context: Context) -> None:
|
||||
context.corr_diff_mock_svc = _build_mock_svc(
|
||||
correction_diff_return=(
|
||||
"[bold]Correction Diff[/bold]\n"
|
||||
" [bold]Correction:[/bold] CORR-001\n"
|
||||
" [bold]Original Decision:[/bold] orig-001\n"
|
||||
" [bold]Mode:[/bold] [cyan]revert[/cyan]\n"
|
||||
" [bold]State:[/bold] [yellow]completed[/yellow]\n"
|
||||
" [bold]Files Changed:[/bold] 1\n"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@given("correction-diff a mocked apply service returning plain correction diff")
|
||||
def step_corr_diff_plain(context: Context) -> None:
|
||||
context.corr_diff_mock_svc = _build_mock_svc(
|
||||
correction_diff_return=(
|
||||
"Correction Diff\n"
|
||||
" Correction: CORR-002\n"
|
||||
" Original Decision: orig-002\n"
|
||||
" Mode: revert\n"
|
||||
" State: completed\n"
|
||||
" Files Changed: 1\n"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@given(
|
||||
'correction-diff a mocked apply service returning json correction diff for "{corr}"'
|
||||
)
|
||||
def step_corr_diff_json(context: Context, corr: str) -> None:
|
||||
data = {
|
||||
"correction_diff": {
|
||||
"correction": corr,
|
||||
"original_decision": "orig-003",
|
||||
"mode": "revert",
|
||||
"state": "completed",
|
||||
"files_changed": 1,
|
||||
},
|
||||
"comparison": {"reverted_decisions": [], "added_decisions": []},
|
||||
"patch_preview": [
|
||||
{"note": "Patch preview available after execution completes."}
|
||||
],
|
||||
}
|
||||
context.corr_diff_mock_svc = _build_mock_svc(
|
||||
correction_diff_return=json.dumps(data, indent=2)
|
||||
)
|
||||
|
||||
|
||||
@given(
|
||||
'correction-diff a mocked apply service returning yaml correction diff for "{corr}"'
|
||||
)
|
||||
def step_corr_diff_yaml(context: Context, corr: str) -> None:
|
||||
context.corr_diff_mock_svc = _build_mock_svc(
|
||||
correction_diff_return=(
|
||||
f"correction_diff:\n"
|
||||
f" correction: {corr}\n"
|
||||
f" original_decision: orig-004\n"
|
||||
f" mode: revert\n"
|
||||
f" state: completed\n"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@given('correction-diff the apply service correction_diff raises PlanError "{msg}"')
|
||||
def step_corr_diff_plan_error(context: Context, msg: str) -> None:
|
||||
context.corr_diff_mock_svc = _build_mock_svc(
|
||||
correction_diff_side_effect=PlanError(msg)
|
||||
)
|
||||
|
||||
|
||||
@given(
|
||||
'correction-diff the apply service correction_diff raises ValidationError "{msg}"'
|
||||
)
|
||||
def step_corr_diff_validation_error(context: Context, msg: str) -> None:
|
||||
context.corr_diff_mock_svc = _build_mock_svc(
|
||||
correction_diff_side_effect=ValidationError(msg)
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WHEN steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
use_step_matcher("re")
|
||||
|
||||
|
||||
@when(
|
||||
r'correction-diff I run plan diff for "(?P<plan_id>[^"]*)"'
|
||||
r' with correction "(?P<corr>[^"]*)"'
|
||||
r' format "(?P<fmt>[^"]+)"'
|
||||
)
|
||||
def step_corr_diff_run(context: Context, plan_id: str, corr: str, fmt: str) -> None:
|
||||
"""Invoke plan diff with correction and format flags.
|
||||
|
||||
Always passes ``--correction`` so that the correction_diff() code path is
|
||||
exercised. An empty ``corr`` value tests the empty-input guard in the
|
||||
service (ValidationError for empty correction_attempt_id).
|
||||
"""
|
||||
args: list[str] = ["diff", plan_id, "--correction", corr, "--format", fmt]
|
||||
with patch(_PATCH_GET_APPLY, return_value=context.corr_diff_mock_svc):
|
||||
context.corr_diff_result = runner.invoke(
|
||||
plan_app,
|
||||
args,
|
||||
)
|
||||
|
||||
|
||||
use_step_matcher("parse")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# THEN steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then("correction-diff the exit code should be 0")
|
||||
def step_corr_diff_exit_0(context: Context) -> None:
|
||||
assert context.corr_diff_result.exit_code == 0, (
|
||||
f"Expected exit code 0, got {context.corr_diff_result.exit_code}.\n"
|
||||
f"Output: {context.corr_diff_result.output}"
|
||||
)
|
||||
|
||||
|
||||
@then("correction-diff the exit code should be nonzero")
|
||||
def step_corr_diff_exit_nonzero(context: Context) -> None:
|
||||
assert context.corr_diff_result.exit_code != 0, (
|
||||
f"Expected non-zero exit code, got {context.corr_diff_result.exit_code}.\n"
|
||||
f"Output: {context.corr_diff_result.output}"
|
||||
)
|
||||
|
||||
|
||||
@then('correction-diff the output should contain "{text}"')
|
||||
def step_corr_diff_output_contains(context: Context, text: str) -> None:
|
||||
output = context.corr_diff_result.output
|
||||
assert text in output, f"Expected '{text}' in output. Got:\n{output}"
|
||||
|
||||
|
||||
@then('correction-diff the service was called with format "{fmt}"')
|
||||
def step_corr_diff_service_called_with_fmt(context: Context, fmt: str) -> None:
|
||||
"""Assert correction_diff() was invoked with the expected fmt argument."""
|
||||
svc = context.corr_diff_mock_svc
|
||||
svc.correction_diff.assert_called_once()
|
||||
_args, kwargs = svc.correction_diff.call_args
|
||||
actual_fmt = kwargs.get("fmt", _args[2] if len(_args) > 2 else None)
|
||||
assert actual_fmt == fmt, (
|
||||
f"Expected correction_diff called with fmt={fmt!r}, got fmt={actual_fmt!r}"
|
||||
)
|
||||
@@ -0,0 +1,233 @@
|
||||
"""Helper utilities for plan correction diff Robot integration tests.
|
||||
|
||||
Covers:
|
||||
- correction_diff() output in rich, plain, JSON, and YAML formats
|
||||
- PlanError raised when correction attempt not found
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import yaml
|
||||
|
||||
from cleveragents.application.services.plan_apply_service import (
|
||||
PlanApplyService,
|
||||
)
|
||||
from cleveragents.application.services.plan_lifecycle_service import (
|
||||
PlanLifecycleService,
|
||||
)
|
||||
from cleveragents.config.settings import Settings
|
||||
from cleveragents.core.exceptions import PlanError, ValidationError
|
||||
from cleveragents.domain.models.core.correction import (
|
||||
CorrectionAttemptRecord,
|
||||
CorrectionAttemptState,
|
||||
CorrectionMode,
|
||||
)
|
||||
from cleveragents.infrastructure.database.repositories import (
|
||||
CorrectionAttemptNotFoundError,
|
||||
)
|
||||
|
||||
|
||||
def _make_attempt(
|
||||
correction_id: str = "01HXM9B7Z3Q1CORR001AAAAA",
|
||||
plan_id: str = "01HXM8C2ZK4Q7C2B3F2R4PLAN",
|
||||
original_decision_id: str = "01HXM9A1C2Q7W3R5ORIG0001",
|
||||
new_decision_id: str | None = "01HXM9A1C2Q7W3R5NEW00001",
|
||||
mode: CorrectionMode = CorrectionMode.REVERT,
|
||||
state: CorrectionAttemptState = CorrectionAttemptState.COMPLETE,
|
||||
guidance: str = "Use async processing instead",
|
||||
archived_artifacts_path: str | None = None,
|
||||
) -> CorrectionAttemptRecord:
|
||||
"""Build a minimal CorrectionAttemptRecord for testing."""
|
||||
return CorrectionAttemptRecord(
|
||||
correction_attempt_id=correction_id,
|
||||
plan_id=plan_id,
|
||||
original_decision_id=original_decision_id,
|
||||
new_decision_id=new_decision_id,
|
||||
mode=mode,
|
||||
state=state,
|
||||
guidance=guidance,
|
||||
created_at=datetime(2025, 6, 15, 10, 30, 0, tzinfo=UTC),
|
||||
completed_at=datetime(2025, 6, 15, 10, 31, 0, tzinfo=UTC),
|
||||
archived_artifacts_path=archived_artifacts_path,
|
||||
)
|
||||
|
||||
|
||||
def _make_apply_service_with_mocked_uow(
|
||||
attempt: CorrectionAttemptRecord,
|
||||
) -> tuple[PlanApplyService, str]:
|
||||
"""Create a PlanApplyService with a mocked UoW returning the given attempt."""
|
||||
settings = Settings()
|
||||
lifecycle = PlanLifecycleService(settings=settings)
|
||||
|
||||
# Create an action and plan so get_plan() succeeds
|
||||
lifecycle.create_action(
|
||||
name="local/robot-correction-diff-test",
|
||||
description="Robot correction diff test",
|
||||
definition_of_done="Tests pass",
|
||||
strategy_actor="local/planner",
|
||||
execution_actor="local/executor",
|
||||
)
|
||||
plan = lifecycle.use_action(action_name="local/robot-correction-diff-test")
|
||||
plan_id = plan.identity.plan_id
|
||||
|
||||
# Override the attempt plan_id to match
|
||||
attempt_with_plan = attempt.model_copy(update={"plan_id": plan_id})
|
||||
|
||||
# Build a mock UnitOfWork with transaction context manager
|
||||
mock_ctx = MagicMock()
|
||||
mock_ctx.correction_attempts.get.return_value = attempt_with_plan
|
||||
|
||||
mock_uow = MagicMock()
|
||||
mock_uow.transaction.return_value.__enter__.return_value = mock_ctx
|
||||
mock_uow.transaction.return_value.__exit__.return_value = False
|
||||
|
||||
svc = PlanApplyService(
|
||||
lifecycle_service=lifecycle,
|
||||
unit_of_work=mock_uow,
|
||||
)
|
||||
return svc, plan_id
|
||||
|
||||
|
||||
def _correction_diff_rich() -> None:
|
||||
"""Test correction_diff() rich format output."""
|
||||
attempt = _make_attempt()
|
||||
svc, plan_id = _make_apply_service_with_mocked_uow(attempt)
|
||||
|
||||
output = svc.correction_diff(plan_id, attempt.correction_attempt_id, fmt="rich")
|
||||
assert "Correction Diff" in output, f"Missing 'Correction Diff': {output[:300]}"
|
||||
assert "Comparison" in output, f"Missing 'Comparison': {output[:300]}"
|
||||
assert "Patch Preview" in output, f"Missing 'Patch Preview': {output[:300]}"
|
||||
assert attempt.correction_attempt_id in output, (
|
||||
f"Correction ID missing: {output[:300]}"
|
||||
)
|
||||
assert attempt.original_decision_id in output, (
|
||||
f"Original decision missing: {output[:300]}"
|
||||
)
|
||||
print("correction-diff-rich-ok")
|
||||
|
||||
|
||||
def _correction_diff_plain() -> None:
|
||||
"""Test correction_diff() plain format output."""
|
||||
attempt = _make_attempt()
|
||||
svc, plan_id = _make_apply_service_with_mocked_uow(attempt)
|
||||
|
||||
output = svc.correction_diff(plan_id, attempt.correction_attempt_id, fmt="plain")
|
||||
assert "Correction Diff" in output, f"Missing 'Correction Diff': {output[:300]}"
|
||||
assert "Comparison" in output, f"Missing 'Comparison': {output[:300]}"
|
||||
assert "Patch Preview" in output, f"Missing 'Patch Preview': {output[:300]}"
|
||||
assert attempt.correction_attempt_id in output, (
|
||||
f"Correction ID missing: {output[:300]}"
|
||||
)
|
||||
print("correction-diff-plain-ok")
|
||||
|
||||
|
||||
def _correction_diff_json() -> None:
|
||||
"""Test correction_diff() JSON format output."""
|
||||
attempt = _make_attempt()
|
||||
svc, plan_id = _make_apply_service_with_mocked_uow(attempt)
|
||||
|
||||
output = svc.correction_diff(plan_id, attempt.correction_attempt_id, fmt="json")
|
||||
parsed = json.loads(output)
|
||||
assert "correction_diff" in parsed, f"Missing 'correction_diff' key: {list(parsed)}"
|
||||
assert "comparison" in parsed, f"Missing 'comparison' key: {list(parsed)}"
|
||||
assert "patch_preview" in parsed, f"Missing 'patch_preview' key: {list(parsed)}"
|
||||
assert parsed["correction_diff"]["correction"] == attempt.correction_attempt_id
|
||||
assert parsed["correction_diff"]["mode"] == "revert"
|
||||
print("correction-diff-json-ok")
|
||||
|
||||
|
||||
def _correction_diff_yaml() -> None:
|
||||
"""Test correction_diff() YAML format output."""
|
||||
attempt = _make_attempt()
|
||||
svc, plan_id = _make_apply_service_with_mocked_uow(attempt)
|
||||
|
||||
output = svc.correction_diff(plan_id, attempt.correction_attempt_id, fmt="yaml")
|
||||
parsed = yaml.safe_load(output)
|
||||
assert isinstance(parsed, dict), (
|
||||
f"Expected dict, got {type(parsed)}: {output[:300]}"
|
||||
)
|
||||
assert "correction_diff" in parsed, f"Missing 'correction_diff' key: {list(parsed)}"
|
||||
assert "comparison" in parsed, f"Missing 'comparison' key: {list(parsed)}"
|
||||
assert "patch_preview" in parsed, f"Missing 'patch_preview' key: {list(parsed)}"
|
||||
assert parsed["correction_diff"]["correction"] == attempt.correction_attempt_id
|
||||
print("correction-diff-yaml-ok")
|
||||
|
||||
|
||||
def _correction_not_found() -> None:
|
||||
"""Test correction_diff() raises PlanError when correction not found."""
|
||||
settings = Settings()
|
||||
lifecycle = PlanLifecycleService(settings=settings)
|
||||
|
||||
lifecycle.create_action(
|
||||
name="local/robot-correction-not-found",
|
||||
description="Robot correction not found test",
|
||||
definition_of_done="Tests pass",
|
||||
strategy_actor="local/planner",
|
||||
execution_actor="local/executor",
|
||||
)
|
||||
plan = lifecycle.use_action(action_name="local/robot-correction-not-found")
|
||||
plan_id = plan.identity.plan_id
|
||||
|
||||
# Mock UoW that raises CorrectionAttemptNotFoundError
|
||||
mock_ctx = MagicMock()
|
||||
mock_ctx.correction_attempts.get.side_effect = CorrectionAttemptNotFoundError(
|
||||
"Not found"
|
||||
)
|
||||
mock_uow = MagicMock()
|
||||
mock_uow.transaction.return_value.__enter__.return_value = mock_ctx
|
||||
mock_uow.transaction.return_value.__exit__.return_value = False
|
||||
|
||||
svc = PlanApplyService(lifecycle_service=lifecycle, unit_of_work=mock_uow)
|
||||
|
||||
try:
|
||||
svc.correction_diff(plan_id, "CORR-NONEXISTENT", fmt="plain")
|
||||
raise AssertionError("Should have raised PlanError")
|
||||
except PlanError as e:
|
||||
assert "not found" in e.message.lower(), f"Unexpected message: {e.message}"
|
||||
print("correction-not-found-ok")
|
||||
|
||||
|
||||
def _correction_diff_uow_none() -> None:
|
||||
"""Test correction_diff() raises ValidationError when unit_of_work is None."""
|
||||
settings = Settings()
|
||||
lifecycle = PlanLifecycleService(settings=settings)
|
||||
|
||||
svc = PlanApplyService(lifecycle_service=lifecycle, unit_of_work=None)
|
||||
|
||||
try:
|
||||
svc.correction_diff("some-plan-id", "some-correction-id", fmt="plain")
|
||||
raise AssertionError("Should have raised ValidationError")
|
||||
except ValidationError as e:
|
||||
assert "unit_of_work is required" in e.message, (
|
||||
f"Unexpected message: {e.message}"
|
||||
)
|
||||
print("correction-diff-uow-none-ok")
|
||||
|
||||
|
||||
COMMANDS: dict[str, Callable[[], None]] = {
|
||||
"correction-diff-rich": _correction_diff_rich,
|
||||
"correction-diff-plain": _correction_diff_plain,
|
||||
"correction-diff-json": _correction_diff_json,
|
||||
"correction-diff-yaml": _correction_diff_yaml,
|
||||
"correction-not-found": _correction_not_found,
|
||||
"correction-diff-uow-none": _correction_diff_uow_none,
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
print(f"Usage: {sys.argv[0]} <command>", file=sys.stderr)
|
||||
print(f"Available: {', '.join(COMMANDS)}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
cmd = sys.argv[1]
|
||||
func = COMMANDS.get(cmd)
|
||||
if func is None:
|
||||
print(f"Unknown command: {cmd}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
func()
|
||||
@@ -0,0 +1,54 @@
|
||||
*** Settings ***
|
||||
Documentation Integration tests for plan diff --correction command.
|
||||
... Verifies that correction_diff() on PlanApplyService produces
|
||||
... the three-section structured output (Correction Diff summary,
|
||||
... Comparison, Patch Preview) defined in the specification.
|
||||
Resource ${CURDIR}/common.resource
|
||||
Suite Setup Setup Test Environment
|
||||
Suite Teardown Cleanup Test Environment
|
||||
|
||||
*** Variables ***
|
||||
${HELPER_SCRIPT} robot/helper_plan_correction_diff.py
|
||||
|
||||
*** Test Cases ***
|
||||
Correction Diff Rich Format
|
||||
[Documentation] Verify correction_diff() generates rich-format three-section output
|
||||
[Tags] correction plan diff rich
|
||||
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} correction-diff-rich cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} correction-diff-rich-ok
|
||||
|
||||
Correction Diff Plain Format
|
||||
[Documentation] Verify correction_diff() generates plain-format three-section output
|
||||
[Tags] correction plan diff plain
|
||||
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} correction-diff-plain cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} correction-diff-plain-ok
|
||||
|
||||
Correction Diff JSON Format
|
||||
[Documentation] Verify correction_diff() generates JSON output with all three sections
|
||||
[Tags] correction plan diff json
|
||||
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} correction-diff-json cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} correction-diff-json-ok
|
||||
|
||||
Correction Diff YAML Format
|
||||
[Documentation] Verify correction_diff() generates YAML output with all three sections
|
||||
[Tags] correction plan diff yaml
|
||||
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} correction-diff-yaml cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} correction-diff-yaml-ok
|
||||
|
||||
Correction Diff Raises When Correction Not Found
|
||||
[Documentation] Verify correction_diff() raises PlanError on missing correction attempt
|
||||
[Tags] correction plan diff error
|
||||
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} correction-not-found cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} correction-not-found-ok
|
||||
|
||||
Correction Diff Raises When Unit Of Work Is None
|
||||
[Documentation] Verify correction_diff() raises ValidationError when unit_of_work is None
|
||||
[Tags] correction plan diff error validation
|
||||
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} correction-diff-uow-none cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} correction-diff-uow-none-ok
|
||||
@@ -24,17 +24,28 @@ All public methods accept ``plan_id`` and delegate plan lookups to
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from enum import StrEnum
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
|
||||
import structlog
|
||||
import yaml
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from rich.markup import escape as _rich_escape
|
||||
|
||||
from cleveragents.core.exceptions import PlanError, ValidationError
|
||||
from cleveragents.core.exceptions import (
|
||||
NotFoundError,
|
||||
PlanError,
|
||||
ValidationError,
|
||||
)
|
||||
from cleveragents.domain.models.core.change import (
|
||||
SpecChangeSet,
|
||||
)
|
||||
from cleveragents.domain.models.core.correction import CorrectionAttemptRecord
|
||||
from cleveragents.infrastructure.database.repositories import (
|
||||
CorrectionAttemptNotFoundError,
|
||||
)
|
||||
from cleveragents.infrastructure.sandbox.checkpoint import CheckpointManager
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -42,6 +53,7 @@ if TYPE_CHECKING:
|
||||
PlanLifecycleService,
|
||||
)
|
||||
from cleveragents.domain.models.core.plan import Plan
|
||||
from cleveragents.infrastructure.database.unit_of_work import UnitOfWork
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
@@ -243,6 +255,212 @@ def _build_artifacts_dict(
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Correction diff rendering helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _build_correction_diff_dict(
|
||||
attempt: CorrectionAttemptRecord,
|
||||
) -> dict[str, Any]:
|
||||
"""Build a JSON-serialisable structured diff for a correction attempt.
|
||||
|
||||
Produces the three-section output defined in the specification:
|
||||
1. Correction Diff summary (correction, original decision, mode, stats).
|
||||
2. Comparison table — lists reverted and added decisions.
|
||||
3. Patch Preview placeholder — full changeset diff requires completed
|
||||
correction execution; a note is included when not yet available.
|
||||
"""
|
||||
mode_val = attempt.mode.value
|
||||
state_val = attempt.state.value
|
||||
|
||||
# Section 1: summary
|
||||
# NOTE: The spec defines the summary section with ``files_changed``,
|
||||
# ``new_insertions``, and ``new_deletions`` fields. This implementation
|
||||
# omits those file-level statistics because they require a completed
|
||||
# correction execution's ChangeSet. Instead, ``state``, ``guidance``,
|
||||
# ``created_at``, and ``completed_at`` are provided as immediately
|
||||
# available metadata. A future follow-up issue tracks full spec
|
||||
# alignment with file-level statistics.
|
||||
summary: dict[str, Any] = {
|
||||
"correction": attempt.correction_attempt_id,
|
||||
"original_decision": attempt.original_decision_id,
|
||||
"mode": mode_val,
|
||||
"state": state_val,
|
||||
"guidance": attempt.guidance,
|
||||
"created_at": attempt.created_at.isoformat(),
|
||||
"completed_at": attempt.completed_at.isoformat()
|
||||
if attempt.completed_at
|
||||
else None,
|
||||
}
|
||||
|
||||
# Section 2: comparison — decisions reverted vs added
|
||||
reverted_decisions: list[dict[str, Any]] = []
|
||||
added_decisions: list[dict[str, Any]] = []
|
||||
|
||||
# Defensive guard: original_decision_id is always non-empty for well-formed
|
||||
# CorrectionAttemptRecord instances (enforced by the domain model). The
|
||||
# check is retained as a safety net against future model changes that may
|
||||
# allow optional original decisions (e.g., pure-append corrections).
|
||||
if attempt.original_decision_id:
|
||||
reverted_decisions.append(
|
||||
{
|
||||
"decision_id": attempt.original_decision_id,
|
||||
"status": "reverted",
|
||||
}
|
||||
)
|
||||
if attempt.new_decision_id:
|
||||
added_decisions.append(
|
||||
{
|
||||
"decision_id": attempt.new_decision_id,
|
||||
"status": "added",
|
||||
}
|
||||
)
|
||||
|
||||
# NOTE: The spec defines ``comparison`` as file-level objects. This
|
||||
# implementation uses decision-level data (reverted vs added decisions)
|
||||
# which is a known deviation. A future follow-up issue tracks full
|
||||
# spec alignment with file-level comparison objects.
|
||||
comparison: dict[str, Any] = {
|
||||
"reverted_decisions": reverted_decisions,
|
||||
"added_decisions": added_decisions,
|
||||
# decisions_changed = total decisions affected by this correction
|
||||
"decisions_changed": len(reverted_decisions) + len(added_decisions),
|
||||
# decisions_added = decisions introduced by this correction
|
||||
"decisions_added": len(added_decisions),
|
||||
# decisions_reverted = decisions removed/reverted by this correction
|
||||
"decisions_reverted": len(reverted_decisions),
|
||||
}
|
||||
|
||||
# Section 3: patch preview
|
||||
# NOTE: This section never produces actual unified diff hunks. Full diff
|
||||
# output requires the corrected execution's completed ChangeSet, which is
|
||||
# only available after the correction finishes. Until then only a
|
||||
# descriptive note or artifacts path is included. A future follow-up
|
||||
# issue tracks generating real diff hunks post-correction.
|
||||
patch_preview: list[dict[str, Any]] = []
|
||||
if attempt.archived_artifacts_path:
|
||||
patch_preview.append(
|
||||
{
|
||||
"note": "Corrected execution artifacts available",
|
||||
"artifacts_path": attempt.archived_artifacts_path,
|
||||
}
|
||||
)
|
||||
else:
|
||||
patch_preview.append(
|
||||
{
|
||||
"note": (
|
||||
"Patch preview is available after correction execution completes. "
|
||||
f"Current state: {state_val}"
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"correction_diff": summary,
|
||||
"comparison": comparison,
|
||||
"patch_preview": patch_preview,
|
||||
}
|
||||
|
||||
|
||||
def _render_correction_diff_plain(data: dict[str, Any]) -> str:
|
||||
"""Render a plain-text correction diff with the three spec sections."""
|
||||
lines: list[str] = []
|
||||
s = data["correction_diff"]
|
||||
cmp = data["comparison"]
|
||||
patch = data["patch_preview"]
|
||||
|
||||
lines.append("Correction Diff")
|
||||
lines.append(f" Correction: {s['correction']}")
|
||||
lines.append(f" Original Decision: {s['original_decision']}")
|
||||
lines.append(f" Mode: {s['mode']}")
|
||||
lines.append(f" State: {s['state']}")
|
||||
lines.append(f" Decisions Changed: {cmp['decisions_changed']}")
|
||||
lines.append(f" Decisions Added: {cmp['decisions_added']}")
|
||||
lines.append(f" Decisions Reverted: {cmp['decisions_reverted']}")
|
||||
if s["guidance"]:
|
||||
lines.append(f" Guidance: {s['guidance']}")
|
||||
lines.append(f" Created: {s['created_at']}")
|
||||
if s["completed_at"]:
|
||||
lines.append(f" Completed: {s['completed_at']}")
|
||||
lines.append("")
|
||||
|
||||
lines.append("Comparison")
|
||||
if cmp["reverted_decisions"]:
|
||||
lines.append(" Reverted Decisions:")
|
||||
for d in cmp["reverted_decisions"]:
|
||||
lines.append(f" - {d['decision_id']} ({d['status']})")
|
||||
if cmp["added_decisions"]:
|
||||
lines.append(" Added Decisions:")
|
||||
for d in cmp["added_decisions"]:
|
||||
lines.append(f" + {d['decision_id']} ({d['status']})")
|
||||
lines.append("")
|
||||
|
||||
lines.append("Patch Preview (corrected vs original)")
|
||||
for item in patch:
|
||||
if "note" in item:
|
||||
lines.append(f" {item['note']}")
|
||||
if "artifacts_path" in item:
|
||||
lines.append(f" Artifacts: {item['artifacts_path']}")
|
||||
|
||||
lines.append("")
|
||||
lines.append("[OK] Correction diff generated")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _render_correction_diff_rich(data: dict[str, Any]) -> str:
|
||||
"""Render a Rich-markup correction diff with the three spec sections."""
|
||||
lines: list[str] = []
|
||||
s = data["correction_diff"]
|
||||
cmp = data["comparison"]
|
||||
patch = data["patch_preview"]
|
||||
|
||||
lines.append("[bold]Correction Diff[/bold]")
|
||||
lines.append(f" [bold]Correction:[/bold] {s['correction']}")
|
||||
lines.append(f" [bold]Original Decision:[/bold] {s['original_decision']}")
|
||||
lines.append(f" [bold]Mode:[/bold] [cyan]{s['mode']}[/cyan]")
|
||||
lines.append(f" [bold]State:[/bold] [yellow]{s['state']}[/yellow]")
|
||||
lines.append(f" [bold]Decisions Changed:[/bold] {cmp['decisions_changed']}")
|
||||
lines.append(
|
||||
f" [bold]Decisions Added:[/bold] [green]{cmp['decisions_added']}[/green]"
|
||||
)
|
||||
lines.append(
|
||||
f" [bold]Decisions Reverted:[/bold] [red]{cmp['decisions_reverted']}[/red]"
|
||||
)
|
||||
if s["guidance"]:
|
||||
lines.append(f" [bold]Guidance:[/bold] {_rich_escape(str(s['guidance']))}")
|
||||
lines.append(f" [dim]Created: {s['created_at']}[/dim]")
|
||||
if s["completed_at"]:
|
||||
lines.append(f" [dim]Completed: {s['completed_at']}[/dim]")
|
||||
lines.append("")
|
||||
|
||||
lines.append("[bold]Comparison[/bold]")
|
||||
if cmp["reverted_decisions"]:
|
||||
lines.append(" [bold]Reverted Decisions:[/bold]")
|
||||
for d in cmp["reverted_decisions"]:
|
||||
lines.append(f" [red]- {d['decision_id']}[/red] ({d['status']})")
|
||||
if cmp["added_decisions"]:
|
||||
lines.append(" [bold]Added Decisions:[/bold]")
|
||||
for d in cmp["added_decisions"]:
|
||||
lines.append(f" [green]+ {d['decision_id']}[/green] ({d['status']})")
|
||||
lines.append("")
|
||||
|
||||
lines.append("[bold]Patch Preview (corrected vs original)[/bold]")
|
||||
for item in patch:
|
||||
if "note" in item:
|
||||
lines.append(f" [dim]{item['note']}[/dim]")
|
||||
if "artifacts_path" in item:
|
||||
lines.append(
|
||||
f" [dim]Artifacts: {_rich_escape(str(item['artifacts_path']))}[/dim]"
|
||||
)
|
||||
|
||||
lines.append("")
|
||||
lines.append("[green]✓ OK[/green] Correction diff generated")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Service
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -261,6 +479,7 @@ class PlanApplyService:
|
||||
lifecycle_service: PlanLifecycleService,
|
||||
changeset_store: Any | None = None,
|
||||
checkpoint_manager: CheckpointManager | None = None,
|
||||
unit_of_work: UnitOfWork | None = None,
|
||||
) -> None:
|
||||
"""Initialise the plan apply service.
|
||||
|
||||
@@ -273,12 +492,17 @@ class PlanApplyService:
|
||||
checkpoint_manager: Optional checkpoint manager for
|
||||
sandbox state snapshots during apply. When ``None``,
|
||||
checkpoint hooks are silently skipped.
|
||||
unit_of_work: ``UnitOfWork`` for database access.
|
||||
Required for ``correction_diff()``. Must not be ``None``
|
||||
when ``correction_diff()`` will be called (ADR-003: no
|
||||
container singleton fallback allowed in service methods).
|
||||
"""
|
||||
if lifecycle_service is None:
|
||||
raise ValidationError("lifecycle_service must not be None")
|
||||
self._lifecycle = lifecycle_service
|
||||
self._changeset_store = changeset_store
|
||||
self._checkpoint_manager = checkpoint_manager
|
||||
self._unit_of_work = unit_of_work
|
||||
self._logger = logger.bind(service="plan_apply")
|
||||
|
||||
# -- Diff output ---------------------------------------------------------
|
||||
@@ -286,7 +510,7 @@ class PlanApplyService:
|
||||
def diff(
|
||||
self,
|
||||
plan_id: str,
|
||||
fmt: str = "rich",
|
||||
fmt: Literal["rich", "plain", "json", "yaml"] = "rich",
|
||||
) -> str:
|
||||
"""Generate diff output for a plan's ChangeSet.
|
||||
|
||||
@@ -300,10 +524,6 @@ class PlanApplyService:
|
||||
Raises:
|
||||
PlanError: If the plan has no changeset.
|
||||
"""
|
||||
import json as json_mod
|
||||
|
||||
import yaml as yaml_mod
|
||||
|
||||
plan = self._lifecycle.get_plan(plan_id)
|
||||
changeset = self._resolve_changeset(plan)
|
||||
|
||||
@@ -315,16 +535,101 @@ class PlanApplyService:
|
||||
|
||||
if fmt == "json":
|
||||
data = _render_diff_json(changeset)
|
||||
return json_mod.dumps(data, indent=2, default=str)
|
||||
return json.dumps(data, indent=2, default=str)
|
||||
if fmt == "yaml":
|
||||
data = _render_diff_json(changeset)
|
||||
return yaml_mod.dump(
|
||||
return yaml.safe_dump(
|
||||
data, default_flow_style=False, sort_keys=False
|
||||
).rstrip("\n")
|
||||
if fmt == "plain":
|
||||
return _render_diff_plain(changeset)
|
||||
return _render_diff_rich(changeset)
|
||||
|
||||
def correction_diff(
|
||||
self,
|
||||
plan_id: str,
|
||||
correction_attempt_id: str,
|
||||
fmt: Literal["rich", "plain", "json", "yaml"] = "rich",
|
||||
) -> str:
|
||||
"""Generate diff output for a correction attempt.
|
||||
|
||||
Shows the changes that a correction attempt makes to the plan's
|
||||
decision tree, including reverted decisions, new decisions, and
|
||||
guidance text. Produces the three-section output defined in the
|
||||
specification: Correction Diff summary, Comparison, Patch Preview.
|
||||
|
||||
Args:
|
||||
plan_id: The plan ULID.
|
||||
correction_attempt_id: The correction attempt ULID.
|
||||
fmt: Output format (``rich``, ``plain``, ``json``, ``yaml``).
|
||||
|
||||
Returns:
|
||||
Rendered correction diff string.
|
||||
|
||||
Raises:
|
||||
ValidationError: If ``plan_id`` or ``correction_attempt_id`` is empty.
|
||||
PlanError: If the plan or correction attempt is not found, or the
|
||||
attempt does not belong to the specified plan.
|
||||
"""
|
||||
self._logger.debug(
|
||||
"correction_diff called",
|
||||
plan_id=plan_id,
|
||||
correction_attempt_id=correction_attempt_id,
|
||||
fmt=fmt,
|
||||
)
|
||||
|
||||
if not plan_id:
|
||||
raise ValidationError("plan_id must not be empty")
|
||||
if not correction_attempt_id:
|
||||
raise ValidationError("correction_attempt_id must not be empty")
|
||||
|
||||
if self._unit_of_work is None:
|
||||
raise ValidationError("unit_of_work is required for correction_diff()")
|
||||
|
||||
# Verify plan exists — convert NotFoundError to PlanError for
|
||||
# consistent "Diff Error:" prefix in CLI output.
|
||||
try:
|
||||
self._lifecycle.get_plan(plan_id)
|
||||
except NotFoundError as e:
|
||||
raise PlanError(f"Plan {plan_id} not found") from e
|
||||
|
||||
uow = self._unit_of_work
|
||||
|
||||
# Use the transaction context manager to avoid resource leaks
|
||||
with uow.transaction() as ctx:
|
||||
try:
|
||||
attempt = ctx.correction_attempts.get(correction_attempt_id)
|
||||
except CorrectionAttemptNotFoundError as e:
|
||||
raise PlanError(
|
||||
f"Correction attempt {correction_attempt_id} not found"
|
||||
) from e
|
||||
|
||||
# Verify the attempt belongs to this plan
|
||||
if attempt.plan_id != plan_id:
|
||||
self._logger.warning(
|
||||
"Correction attempt ownership mismatch",
|
||||
plan_id=plan_id,
|
||||
correction_attempt_id=correction_attempt_id,
|
||||
attempt_plan_id=attempt.plan_id,
|
||||
)
|
||||
raise PlanError(
|
||||
f"Correction attempt {correction_attempt_id} does not belong "
|
||||
f"to plan {plan_id}"
|
||||
)
|
||||
|
||||
# Build the structured correction diff data
|
||||
data = _build_correction_diff_dict(attempt)
|
||||
|
||||
if fmt == "json":
|
||||
return json.dumps(data, indent=2, default=str)
|
||||
if fmt == "yaml":
|
||||
return yaml.safe_dump(
|
||||
data, default_flow_style=False, sort_keys=False
|
||||
).rstrip("\n")
|
||||
if fmt == "plain":
|
||||
return _render_correction_diff_plain(data)
|
||||
return _render_correction_diff_rich(data)
|
||||
|
||||
# -- Artifacts output ----------------------------------------------------
|
||||
|
||||
def artifacts(
|
||||
|
||||
@@ -28,7 +28,7 @@ import warnings
|
||||
from contextlib import suppress
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Annotated, Any
|
||||
from typing import TYPE_CHECKING, Annotated, Any, Literal, cast
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
@@ -202,6 +202,9 @@ _LEGACY_DEPRECATION_MSG = (
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cleveragents.application.services.plan_apply_service import (
|
||||
PlanApplyService,
|
||||
)
|
||||
from cleveragents.application.services.plan_lifecycle_service import (
|
||||
PlanLifecycleService,
|
||||
)
|
||||
@@ -3276,14 +3279,19 @@ def revert_plan(
|
||||
raise typer.Abort() from e
|
||||
|
||||
|
||||
def _get_apply_service():
|
||||
def _get_apply_service() -> PlanApplyService:
|
||||
"""Get the PlanApplyService from the lifecycle service."""
|
||||
from cleveragents.application.container import get_container
|
||||
from cleveragents.application.services.plan_apply_service import (
|
||||
PlanApplyService,
|
||||
)
|
||||
|
||||
container = get_container()
|
||||
lifecycle = _get_lifecycle_service()
|
||||
return PlanApplyService(lifecycle_service=lifecycle)
|
||||
return PlanApplyService(
|
||||
lifecycle_service=lifecycle,
|
||||
unit_of_work=container.unit_of_work(),
|
||||
)
|
||||
|
||||
|
||||
@app.command("diff")
|
||||
@@ -3317,21 +3325,19 @@ def plan_diff(
|
||||
correction attempt instead of the whole plan.
|
||||
"""
|
||||
try:
|
||||
service = _get_apply_service()
|
||||
_fmt = cast(
|
||||
Literal["rich", "plain", "json", "yaml"],
|
||||
fmt if fmt in ("rich", "plain", "json", "yaml") else "rich",
|
||||
)
|
||||
|
||||
if correction:
|
||||
# Show correction-specific diff (stub: shows info panel)
|
||||
console.print(
|
||||
Panel(
|
||||
f"[bold]Correction Attempt:[/bold] {correction}\n"
|
||||
f"[bold]Plan:[/bold] {plan_id}\n\n"
|
||||
"[dim]Correction diff will be available after M4.2.[/dim]",
|
||||
title="Correction Diff",
|
||||
expand=False,
|
||||
)
|
||||
)
|
||||
# Show correction-specific diff
|
||||
output = service.correction_diff(plan_id, correction, fmt=_fmt)
|
||||
console.print(output)
|
||||
return
|
||||
|
||||
service = _get_apply_service()
|
||||
output = service.diff(plan_id, fmt=fmt)
|
||||
output = service.diff(plan_id, fmt=_fmt)
|
||||
console.print(output)
|
||||
|
||||
except PlanError as e:
|
||||
|
||||
Reference in New Issue
Block a user