Compare commits

...

2 Commits

Author SHA1 Message Date
HAL9000 dfa701b354 fix(cli): wrap plan status --format json output in spec-required JSON envelope 2026-05-17 02:54:16 +00:00
HAL9000 f79c18a3ec fix(data-integrity): remove silent argument swap in ValidationAttachmentRepository.attach (#8177)
Fixed a critical data integrity bug where validation_name and resource_id
arguments were being silently swapped based on the fragile heuristic
('/' in resource_id and '/' not in validation_name). This caused silent
data corruption without any error or warning. The conditional swap block
has been removed, ensuring arguments flow directly from caller to the
persistence layer in their declared order.
2026-05-16 08:14:39 +00:00
+227 -5
View File
@@ -307,6 +307,223 @@ def _plan_spec_dict(plan: Any) -> dict[str, object]:
return {"plan": str(plan)}
def _status_output_dict(
plan: Any,
duration_ms: int | None = None,
) -> dict[str, object]:
"""Build the spec-required status output envelope.
Returns the structured JSON envelope for ``agents plan status --format json``
as defined in the specification §agents plan status.
The envelope structure is::
{
"command": "plan status",
"status": "ok",
"exit_code": 0,
"data": {
"plan_id": "...",
"phase": "...",
"state": "...",
"action": "...",
"project": "...",
"automation": "...",
"attempt": 1,
"progress": [...],
"timing": {"started": "HH:MM:SS", ...},
"execution": {...},
"cost": {...}
},
"timing": {"started": "...", "duration_ms": ...},
"messages": ["Status refreshed"]
}
Args:
plan: The ``Plan`` domain model.
duration_ms: Elapsed milliseconds for command execution. ``None`` when
not available.
Returns:
A JSON-serialisable dict matching the spec-required envelope.
"""
from cleveragents.domain.models.core.plan import ExecutionEnvPriority
from cleveragents.domain.models.core.plan import Plan as LifecyclePlan
if not isinstance(plan, LifecyclePlan):
# Legacy plan fallback — return minimal spec-aligned envelope
return {
"command": "plan status",
"status": "ok",
"exit_code": 0,
"data": {"plan": str(plan)},
"timing": {},
"messages": ["Status refreshed"],
}
plan_id = plan.identity.plan_id
# ── Phase → progress steps mapping (spec §Example 17) ────────────────────
phase_map: dict[str, list[dict[str, str]]] = {
"strategize": [
{"step": "Strategize", "status": "running"},
{"step": "Execute", "status": "queued"},
{"step": "Apply", "status": "queued"},
{"step": "Applied", "status": "queued"},
],
"execute": [
{"step": "Strategize", "status": "done"},
{"step": "Execute", "status": "running"},
{"step": "Apply", "status": "queued"},
{"step": "Applied", "status": "queued"},
],
"apply": [
{"step": "Strategize", "status": "done"},
{"step": "Execute", "status": "done"},
{"step": "Apply", "status": "running"},
{"step": "Applied", "status": "queued"},
],
"applied": [
{"step": "Strategize", "status": "done"},
{"step": "Execute", "status": "done"},
{"step": "Apply", "status": "done"},
{"step": "Applied", "status": "done"},
],
}
# ── Progress steps (spec §Example 17) ────────────────────────────────────
progress: list[dict[str, str]] = phase_map.get(
plan.phase.value,
[{"step": "Unknown", "status": "queued"}],
)
# Deep-copy to avoid mutating the template dict.
progress = [{**step} for step in progress]
# ── Apply processing-state adjustments to progress ───────────────────────
if plan.processing_state == ProcessingState.COMPLETE:
for entry in progress:
if entry["status"] == "running":
entry["status"] = "done"
elif plan.processing_state == ProcessingState.ERRORED:
for entry in progress:
if entry["status"] == "running":
entry["status"] = "error"
# ── Timing (internal to data) ────────────────────────────────────────────
internal_timing: dict[str, object] = {}
strategy_start = plan.timestamps.strategize_started_at
if strategy_start is not None:
internal_timing["started"] = strategy_start.strftime("%H:%M:%S")
# Elapsed: time since strategize started (or creation) until now or
# the last phase transition.
ref_time = strategy_start or plan.timestamps.created_at
elapsed_seconds: float = 0
end_ref = (
plan.timestamps.updated_at
if plan.processing_state in (
ProcessingState.COMPLETE,
ProcessingState.CANCELLED,
)
else datetime.now(UTC)
)
if end_ref.tzinfo is None:
end_ref = end_ref.replace(tzinfo=UTC)
if ref_time.tzinfo is None:
ref_time = ref_time.replace(tzinfo=UTC)
delta = (end_ref - ref_time).total_seconds()
elapsed_seconds = max(0, delta)
hours, rem = divmod(int(elapsed_seconds), 3600)
minutes, seconds = divmod(rem, 60)
internal_timing["elapsed"] = f"{hours:02d}:{minutes:02d}:{seconds:02d}"
# ETA: rough estimate based on total time divided by phase count.
if elapsed_seconds > 0 and plan.processing_state not in (
ProcessingState.COMPLETE,
ProcessingState.CANCELLED,
):
phases_elapsed = {"strategize": 1, "execute": 2, "apply": 3}.get(
plan.phase.value, 1
)
total_phases = 4
eta_seconds = (elapsed_seconds / max(phases_elapsed, 1)) * (
total_phases - phases_elapsed
)
eh, erem = divmod(int(eta_seconds), 3600)
em, es = divmod(erem, 60)
internal_timing["eta"] = f"{eh:02d}:{em:02d}:{es:02d}"
# ── Execution detail (internal to data) ──────────────────────────────────
exec_info: dict[str, object] = {}
if plan.execution_environment:
exec_info["sandbox"] = plan.execution_environment
else:
exec_info["sandbox"] = "git_worktree"
if plan.last_completed_step >= 0:
exec_info["tool_calls"] = plan.last_completed_step + 1
if plan.validation_summary and plan.validation_summary.get("total_files_changed"):
exec_info["files_modified"] = plan.validation_summary["total_files_changed"]
if hasattr(plan, "child_plans") and plan.child_plans is not None:
completed = sum(1 for cp in plan.child_plans if cp.is_terminal)
total = len(plan.child_plans)
exec_info["child_plans"] = f"{completed}/{total} complete"
if hasattr(plan, "checkpoint_count"):
exec_info["checkpoints"] = plan.checkpoint_count
# ── Cost (internal to data) ──────────────────────────────────────────────
cost_info: dict[str, object] = {}
if plan.estimation_result is not None:
est = plan.estimation_result.as_display_dict()
if est.get("estimated_cost_usd") is not None:
cost_info["tokens_used"] = est.get("estimated_tokens", 0)
cost_info["cost_so_far"] = est.get("estimated_cost_usd", 0)
cost_info["estimated"] = est.get("total_estimated_cost_usd", 0)
# ── Top-level timing envelope ────────────────────────────────────────────
top_timing: dict[str, object] = {}
strategy_ts = plan.timestamps.strategize_started_at or plan.timestamps.created_at
if strategy_ts is not None:
top_timing["started"] = strategy_ts.isoformat()
if duration_ms is not None:
top_timing["duration_ms"] = duration_ms
# ── Build data payload (spec §13674) ─────────────────────────────────────
data: dict[str, object] = {
"plan_id": plan_id,
"phase": plan.phase.value,
"state": plan.processing_state.value,
"action": plan.action_name,
# Project: use the first project link; fall back to "(none)"
"project": (
plan.project_links[0].project_name if plan.project_links else "(none)"
),
}
auto_profile = plan.automation_profile
data["automation"] = (
auto_profile.profile_name if auto_profile else "(unset)"
)
data["attempt"] = plan.identity.attempt
data["progress"] = progress
if internal_timing:
data["timing"] = internal_timing
if exec_info:
data["execution"] = exec_info
if cost_info:
data["cost"] = cost_info
return {
"command": "plan status",
"status": "ok",
"exit_code": 0,
"data": data,
"timing": top_timing,
"messages": ["Status refreshed"],
}
def _execute_output_dict(
plan: Any,
started_at: datetime | None = None,
@@ -2354,10 +2571,15 @@ def plan_status(
)
return
# Non-rich formats
# Non-rich formats (spec-required JSON envelope)
if fmt != OutputFormat.RICH.value:
data = [_plan_spec_dict(p) for p in plans]
console.print(format_output(data, fmt))
envelope = {
"command": "plan status",
"status": "ok",
"exit_code": 0,
"data": [_plan_spec_dict(p) for p in plans],
}
console.print(format_output(envelope, fmt))
return
# Show summary table
@@ -2400,8 +2622,8 @@ def plan_status(
plan = service.get_plan(plan_id)
if fmt != OutputFormat.RICH.value:
data = _plan_spec_dict(plan)
console.print(format_output(data, fmt))
envelope = _status_output_dict(plan)
console.print(format_output(envelope, fmt))
return
_print_lifecycle_plan(plan, title="Plan Status")