fix(cli): wrap plan status --format json output in spec-required JSON envelope #11083
@@ -0,0 +1,184 @@
|
||||
Feature: plan status --format json produces spec-required JSON envelope
|
||||
|
||||
The ``agents plan status`` command must produce a spec-compliant JSON
|
||||
envelope on its stdout when invoked with ``--format json``.
|
||||
The envelope carries the structure {command, status, exit_code, data, timing,
|
||||
messages} so downstream dashboards and scripts can parse it uniformly.
|
||||
|
||||
Rule: every non-RICH output from plan status must include at minimum the four
|
||||
envelope fields (command == "plan status", status == "ok", exit_code == 0,
|
||||
data with at least plan_id, phase, state) plus timing and messages.
|
||||
|
||||
@tdd_issue
|
||||
@tdd_issue_11034
|
||||
Scenario: single-plan JSON output contains the envelope header
|
||||
Given a lifecycle service that returns a plan with:
|
||||
| plan_id | 01HQEXAMPLE0000000000000AB |
|
||||
| phase | Strategize |
|
||||
| processing_state | queued |
|
||||
| action_name | local/code-coverage |
|
||||
When I run "agents plan status 01HQEXAMPLE0000000000000AB --format json"
|
||||
Then the output is valid JSON with:
|
||||
| field | value |
|
||||
| command | "plan status" |
|
||||
| status | "ok" |
|
||||
| exit_code | 0 |
|
||||
|
||||
@tdd_issue_11034
|
||||
Scenario: single-plan JSON output data contains required fields
|
||||
Given a lifecycle service that returns a plan with:
|
||||
| plan_id | 01HQEXAMPLE0000000000000AB |
|
||||
| phase | Execute |
|
||||
| processing_state | processing |
|
||||
| action_name | local/refactor-db |
|
||||
When I run "agents plan status 01HQEXAMPLE0000000000000AB --format json"
|
||||
Then the output JSON "data" contains:
|
||||
| field | value |
|
||||
| plan_id | "01HQEXAMPLE0000000000000AB" |
|
||||
| phase | "execute" |
|
||||
| state | "processing" |
|
||||
| action | "local/refactor-db" |
|
||||
|
||||
@tdd_issue_11034
|
||||
Scenario: single-plan JSON output contains timing envelope with started time
|
||||
Given a lifecycle service that returns a plan with:
|
||||
| plan_id | 01HQEXAMPLE0000000000000AB |
|
||||
| phase | Apply |
|
||||
| processing_state | complete |
|
||||
| action_name | local/docs-update |
|
||||
When I run "agents plan status 01HQEXAMPLE0000000000000AB --format json"
|
||||
Then the output JSON "timing" contains a "started" field matching an ISO 8601 datetime
|
||||
|
||||
@tdd_issue_11034
|
||||
Scenario: single-plan JSON output includes progress steps
|
||||
Given a lifecycle service that returns a plan with:
|
||||
| plan_id | 01HQEXAMPLE0000000000000AB |
|
||||
| phase | Strategize |
|
||||
| processing_state | queued |
|
||||
| action_name | local/new-feature |
|
||||
When I run "agents plan status 01HQEXAMPLE0000000000000AB --format json"
|
||||
Then the output JSON "data"."progress" is a list of 3 steps:
|
||||
| step | expected_status |
|
||||
| Strategize | "queued" |
|
||||
| Execute | "queued" |
|
||||
| Apply | "queued" |
|
||||
|
||||
@tdd_issue_11034
|
||||
Scenario: completed plan shows progress status "done"
|
||||
Given a lifecycle service that returns a plan with:
|
||||
| Plan ID | 01HQEXAMPLE0000000000000AB |
|
||||
| Phase | Apply |
|
||||
| ProcessingState | applied |
|
||||
| Action Name | local/release |
|
||||
When I run "agents plan status 01HQEXAMPLE0000000000000AB --format json"
|
||||
Then the output JSON "data"."progress" has all three step statuses equal to "done"
|
||||
|
||||
@tdd_issue_11034
|
||||
Scenario: errored plan shows progress status "error"
|
||||
Given a lifecycle service that returns a plan with:
|
||||
| Plan ID | 01HQEXAMPLE0000000000000AB |
|
||||
| Phase | Execute |
|
||||
| ProcessingState | errored |
|
||||
| Action Name | local/migrate-schema |
|
||||
When I run "agents plan status 01HQEXAMPLE0000000000000AB --format json"
|
||||
Then the output JSON "data"."progress" has all three step statuses equal to "error"
|
||||
|
||||
@tdd_issue_11034
|
||||
Scenario: in-progress execute plan shows progress status "running" then "queued"
|
||||
Given a lifecycle service that returns a plan with:
|
||||
| Plan ID | 01HQEXAMPLE0000000000000AB |
|
||||
| Phase | Execute |
|
||||
| ProcessingState | processing |
|
||||
| Action Name | local/perf-tuning |
|
||||
When I run "agents plan status 01HQEXAMPLE0000000000000AB --format json"
|
||||
Then the output JSON "data"."progress" has:
|
||||
| step | expected_status |
|
||||
| Strategize | "done" |
|
||||
| Execute | "running" |
|
||||
| Apply | "queued" |
|
||||
|
||||
@tdd_issue_11034
|
||||
Scenario: plan with project_name includes optional project field in data
|
||||
Given a lifecycle service that returns a plan with:
|
||||
| Plan ID | 01HQEXAMPLE0000000000000AB |
|
||||
| Phase | Strategize |
|
||||
| ProcessingState | queued |
|
||||
| Action Name | local/code-coverage |
|
||||
| Project Name | myproject |
|
||||
When I run "agents plan status 01HQEXAMPLE0000000000000AB --format json"
|
||||
Then the output JSON "data"."project" equals "myproject"
|
||||
|
||||
@tdd_issue_11034
|
||||
Scenario: plan with automation_profile includes optional automation field in data
|
||||
Given a lifecycle service that returns a plan with:
|
||||
| Plan ID | 01HQEXAMPLE0000000000000AB |
|
||||
| Phase | Execute |
|
||||
| ProcessingState | queued |
|
||||
| Action Name | local/new-feature |
|
||||
| AutomationProfile | full-auto |
|
||||
When I run "agents plan status 01HQEXAMPLE0000000000000AB --format json"
|
||||
Then the output JSON "data"."automation" equals "full-auto"
|
||||
|
||||
@tdd_issue_11034
|
||||
Scenario: plan execution duration_ms appears in timing envelope
|
||||
Given a lifecycle service that returns a plan with:
|
||||
| Plan ID | 01HQEXAMPLE0000000000000AB |
|
||||
| Phase | Apply |
|
||||
| ProcessingState | complete |
|
||||
| Action Name | local/release |
|
||||
When I run "agents plan status 01HQEXAMPLE0000000000000AB --format json"
|
||||
Then the output JSON "timing" contains a "duration_ms" field with a positive integer value
|
||||
|
||||
@tdd_issue_11034
|
||||
Scenario: YAML output for plan status includes envelope fields in format_output call
|
||||
Given a lifecycle service that returns a plan with:
|
||||
| Plan ID | 01HQEXAMPLE0000000000000AB |
|
||||
| Phase | Strategize |
|
||||
| ProcessingState | queued |
|
||||
| Action Name | local/lint |
|
||||
When I run "agents plan status 01HQEXAMPLE0000000000000AB --format yaml"
|
||||
Then the output contains:
|
||||
| field | expected_content |
|
||||
| plan_id | any string |
|
||||
| phase | "strategize" or the raw phase value |
|
||||
| state | "queued" |
|
||||
|
||||
@tdd_issue_11034
|
||||
Scenario: rich output for plan status shows lifecycle panel (unchanged)
|
||||
Given a lifecycle service that returns a plan with:
|
||||
| Plan ID | 01HQEXAMPLE0000000000000AB |
|
||||
| Phase | Apply |
|
||||
| ProcessingState | complete |
|
||||
| Action Name | local/release |
|
||||
When I run "agents plan status 01HQEXAMPLE0000000000000AB --format rich"
|
||||
Then the output contains "Plan Status"
|
||||
|
||||
@tdd_issue_11034
|
||||
Scenario: timing envelope includes started ISO datetime
|
||||
Given a lifecycle service that returns a plan with phase Apply and state complete
|
||||
When I run "agents plan status <plan_id> --format json" (any valid ULID)
|
||||
Then the output JSON "timing"."started" matches the regex ``^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?$``
|
||||
|
||||
@tdd_issue_11034
|
||||
Scenario: cost data section is present in plan status JSON output
|
||||
Given a lifecycle service that returns a plan with estimation_result=None (no cost info)
|
||||
When I run "agents plan status <plan_id> --format json" (any valid ULID)
|
||||
Then the output JSON "data"."cost" contains:
|
||||
| field | expected_value |
|
||||
| tokens_used | 0 |
|
||||
| cost_so_far | 0.0 |
|
||||
| estimated | 0.0 |
|
||||
|
||||
@tdd_issue_11034
|
||||
Scenario: execution section includes child_plans info
|
||||
Given a lifecycle service that returns a plan with:
|
||||
| ChildPlanIds | [id1, id2] |
|
||||
| CompletedChildPlanIds | [id1] |
|
||||
When I run "agents plan status <plan_id> --format json"
|
||||
Then the output JSON "data"."execution"."child_plans" equals "1/2 complete"
|
||||
|
||||
@tdd_issue_11034
|
||||
Scenario: multi-plan list (no plan ID) still works with existing behavior
|
||||
Given a lifecycle service that returns three plans
|
||||
When I run "agents plan status --format json"
|
||||
Then the output is valid JSON and does NOT crash
|
||||
@@ -0,0 +1,319 @@
|
||||
"""Step definitions for plan status JSON-envelope BDD tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json as _json
|
||||
import re as _re
|
||||
import sys as _sys
|
||||
from io import StringIO as _StringIO
|
||||
from typing import Any
|
||||
|
||||
from behave import given, then, when # type: ignore[import-untyped]
|
||||
from contextlib import suppress as _suppress
|
||||
from unittest.mock import patch as _patch
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mock Plan object (minimal domain-model attributes the envelope reads)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _MockPlan:
|
||||
id: str = ""
|
||||
phase: str = ""
|
||||
processing_state: str | None = None
|
||||
action_name: str | None = None
|
||||
project_name: str | None = None
|
||||
automation_profile: Any | None = None
|
||||
child_plan_ids: list[str] | None = None
|
||||
completed_child_plan_ids: list[str] | None = None
|
||||
|
||||
|
||||
class _MockService:
|
||||
"""Minimal lifecycle-service mock for plan-status envelope tests."""
|
||||
|
||||
_plans_by_id: dict[str, _MockPlan] = {}
|
||||
|
||||
@classmethod
|
||||
def make_plan(
|
||||
cls,
|
||||
*,
|
||||
plan_id: str,
|
||||
phase: str = "strategize",
|
||||
processing_state: str = "queued",
|
||||
action_name: str | None = "local/default",
|
||||
project_name: str | None = None,
|
||||
automation_profile: str | None = None,
|
||||
) -> _MockPlan:
|
||||
plan = _MockPlan()
|
||||
plan.id = plan_id
|
||||
plan.phase = phase.lower()[:1].upper() + phase[1:].lower()
|
||||
plan.processing_state = processing_state.lower()
|
||||
plan.action_name = action_name
|
||||
plan.project_name = project_name
|
||||
plan.automation_profile = automation_profile
|
||||
cls._plans_by_id[plan_id] = plan
|
||||
return plan
|
||||
|
||||
def get_plan(self, plan_id: str) -> _MockPlan: # type: ignore[override]
|
||||
p = self._plans_by_id.get(plan_id)
|
||||
if p is None:
|
||||
raise AssertionError(f"Mock plan '{plan_id}' not found")
|
||||
return p
|
||||
|
||||
|
||||
# Scenario context stored between steps
|
||||
_scenario_ctx: dict[str, Any] = dict()
|
||||
|
||||
|
||||
def _run_status(*, plan_id_arg: str, fmt_flags: list[str] | None = None) -> tuple[int, str]:
|
||||
"""Run ``agents plan status`` via the CLI module without real subprocess."""
|
||||
from cleveragents.cli.commands.plan import app as _cli_app
|
||||
|
||||
captured = _StringIO()
|
||||
code = 0
|
||||
try:
|
||||
_cli_app.console = type(_cli_app.console)(file=captured, force_terminal=False)
|
||||
ms = _MockService()
|
||||
if plan_id_arg not in _MockService._plans_by_id:
|
||||
_MockService.make_plan(plan_id=plan_id_arg)
|
||||
|
||||
args = ["plan", "status", plan_id_arg] + (fmt_flags or [])
|
||||
|
||||
with _patch(
|
||||
"cleveragents.cli.commands.plan._get_lifecycle_service",
|
||||
return_value=ms,
|
||||
):
|
||||
old_stdout = _sys.stdout
|
||||
_sys.stdout = captured
|
||||
try:
|
||||
_cli_app(args)
|
||||
except SystemExit as exc:
|
||||
code = int(str(exc)) if str(exc).isdigit() else 0
|
||||
|
||||
finally:
|
||||
_sys.stdout = old_stdout
|
||||
|
||||
return code, captured.getvalue().strip()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Given steps — setup is implicit; a default plan is created by default in
|
||||
# the CLI. These steps let BDD tests override defaults.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@given('a lifecycle service that returns a plan with:')
|
||||
def given_plan_fields(context: Any, table: Any) -> None: # type: ignore[name-defined]
|
||||
pid = _scenario_ctx.get("plan_id", "01HQEXAMPLE0000000000000AB")
|
||||
data = {dict(r): dict(dict(r))[k] for k in ["phase", "processing_state", "action_name",
|
||||
"project_name", "automation_profile"]}
|
||||
_MockService.make_plan(
|
||||
plan_id=pid,
|
||||
phase=data.get("Phase", "strategize"),
|
||||
processing_state=data.get("Processingstate", data.get("Processing_State", "")) or data.get("ProcessingState", "queued"),
|
||||
action_name=data.get("ActionName", data.get("Action_Name", "")) or data.get("Action name", "local/test"),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# When steps — actual CLI invocation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@when('I run "agents plan status <plan_id> --format json"')
|
||||
def when_run_json(context: Any, plan_id: str) -> None: # type: ignore[name-defined]
|
||||
_scenario_ctx["plan_id"] = plan_id
|
||||
code, raw = _run_status(plan_id_arg=plan_id, fmt_flags=["--format", "json"])
|
||||
context.exit_code = code
|
||||
context.raw_output = raw
|
||||
|
||||
@when('I run "agents plan status {plan_id} --format json"')
|
||||
def when_run_json_var(context: Any, plan_id: str) -> None: # type: ignore[name-defined]
|
||||
_scenario_ctx["plan_id"] = plan_id
|
||||
code, raw = _run_status(plan_id_arg=plan_id, fmt_flags=["--format", "json"])
|
||||
context.exit_code = code
|
||||
context.raw_output = raw
|
||||
|
||||
|
||||
@when('I run "agents plan status <plan_id> --format yaml"')
|
||||
def when_run_yaml(context: Any, plan_id: str) -> None: # type: ignore[name-defined]
|
||||
_scenario_ctx["plan_id"] = plan_id
|
||||
code, raw = _run_status(plan_id_arg=plan_id, fmt_flags=["--format", "yaml"])
|
||||
context.exit_code = code
|
||||
context.raw_output = raw
|
||||
|
||||
|
||||
@when('I run "agents plan status <plan_id> --format rich"')
|
||||
def when_run_rich(context: Any, plan_id: str) -> None: # type: ignore[name-defined]
|
||||
_scenario_ctx["plan_id"] = plan_id
|
||||
code, raw = _run_status(plan_id_arg=plan_id) # default is rich
|
||||
context.exit_code = code
|
||||
context.raw_output = raw
|
||||
|
||||
|
||||
@when('I run "agents plan status --format json"')
|
||||
def when_run_multi(context: Any) -> None: # type: ignore[name-defined]
|
||||
"""Multi-plan listing path — keep existing behaviour."""
|
||||
pid = _scenario_ctx.get("plan_id", list(_MockService._plans_by_id.keys())[0]) if _MockService._plans_by_id else "01HQEXAMPLE0000000000000AB"
|
||||
code, raw = _run_status(plan_id_arg=pid, fmt_flags=["--format", "json"])
|
||||
context.exit_code = code
|
||||
context.raw_output = raw
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then steps — JSON envelope validation helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _assert_json(context: Any) -> dict[str, Any]:
|
||||
with _suppress(ValueError):
|
||||
ctx = _json.loads(context.raw_output)
|
||||
_scenario_ctx["parsed"] = ctx
|
||||
return ctx
|
||||
raise AssertionError(f"Not valid JSON: {context.raw_output[:200]}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then steps — header fields (command, status, exit_code)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@then('the output is valid JSON')
|
||||
def then_valid_json(context: Any) -> None: # type: ignore[name-defined]
|
||||
_assert_json(context)
|
||||
assert "parsed" in _scenario_ctx
|
||||
|
||||
@then("the output is valid JSON with:")
|
||||
def then_envelope_header(context: Any, table: Any) -> None: # type: ignore[name-defined]
|
||||
parsed = _assert_json(context)
|
||||
for row in table.rows:
|
||||
fd, val = row["field"], row["value"]
|
||||
if fd == "command":
|
||||
assert isinstance(parsed.get("command"), str), f"command missing or wrong type in {parsed}"
|
||||
elif fd == "status":
|
||||
assert isinstance(parsed.get("status"), str), f"status missing or wrong type in {parsed}"
|
||||
elif fd == "exit_code":
|
||||
ec = parsed.get("exit_code")
|
||||
assert ec is not None and isinstance(ec, int), f"exit_code: {ec} (expected 0)"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then steps — data fields
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@then('the output JSON "data" contains:')
|
||||
def then_data_fields(context: Any, table: Any) -> None: # type: ignore[name-defined]
|
||||
parsed = _assert_json(context)
|
||||
for row in table.rows:
|
||||
key = row["field"]
|
||||
assert key in parsed["data"], f"'{key}' not in parsed['data']. Available: {list(parsed['data'].keys())}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then steps — progress sub-section
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@then('the output JSON "data"."progress" is a list of 3 steps:')
|
||||
def then_progress_list(context: Any, table: Any) -> None: # type: ignore[name-defined]
|
||||
parsed = _assert_json(context)
|
||||
prog = parsed["data"]["progress"]
|
||||
assert isinstance(prog, list), f"'progress' not a list"
|
||||
assert len(prog) == 3, f"'progress' has {len(prog)} steps; expected 3"
|
||||
|
||||
@then('the output JSON "data"."progress" has all three step statuses equal to')
|
||||
def then_progress_all_eq(context: Any, value: str) -> None: # type: ignore[name-defined]
|
||||
parsed = _assert_json(context)
|
||||
prog = parsed["data"]["progress"]
|
||||
statuses = [s["status"] for s in prog]
|
||||
assert all(st == value for st in statuses), f"Statuses {statuses} != '{value}'"
|
||||
|
||||
@then('the output JSON "data"."progress" has:')
|
||||
def then_progress_steps(context: Any, table: Any) -> None: # type: ignore[name-defined]
|
||||
parsed = _assert_json(context)
|
||||
prog = parsed["data"]["progress"]
|
||||
step_map = {r["step"]: r["expected_status"] for r in table.rows}
|
||||
status_lookup = {s["step"]: s["status"] for s in prog}
|
||||
assert step_map == status_lookup, f"Step statuses mismatch: expected {step_map}, got {status_lookup}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then steps — timing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@then('the output JSON "timing" contains a "started" field matching an ISO 8601 datetime')
|
||||
def then_timing_started(context: Any) -> None: # type: ignore[name-defined]
|
||||
parsed = _assert_json(context)
|
||||
timing = parsed["timing"]
|
||||
assert "started" in timing, f"'started' not in timing keys: {list(timing.keys())}"
|
||||
|
||||
@then('the output JSON "timing"."started" matches the regex')
|
||||
def then_started_regex(context: Any) -> None: # type: ignore[name-defined]
|
||||
parsed = _assert_json(context)
|
||||
started = parsed["timing"]["started"]
|
||||
iso_re = _re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}")
|
||||
assert iso_re.search(started), f"'started'={started!r} doesn't match ISO 8601"
|
||||
|
||||
@then('the output JSON "timing" contains a "duration_ms" field with a positive integer value')
|
||||
def then_duration(context: Any) -> None: # type: ignore[name-defined]
|
||||
parsed = _assert_json(context)
|
||||
dur = parsed["timing"].get("duration_ms")
|
||||
assert dur is not None, f"'duration_ms' missing from timing"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then steps — optional fields (project, automation)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@then('the output JSON "data"."project" equals')
|
||||
def then_project(context: Any, expected: str) -> None: # type: ignore[name-defined]
|
||||
parsed = _assert_json(context)
|
||||
assert parsed["data"]["project"] == expected, (
|
||||
f'Expected project "{expected}" but got "{parsed["data"]["project"]}"')
|
||||
|
||||
@then('the output JSON "data"."automation" equals')
|
||||
def then_automation(context: Any, expected: str) -> None: # type: ignore[name-defined]
|
||||
parsed = _assert_json(context)
|
||||
assert parsed["data"]["automation"] == expected, (
|
||||
f'Expected automation "{expected}" but got "{parsed["data"]["automation"]}"')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then steps — cost & execution sections
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@then('the output JSON "data"."cost" contains:')
|
||||
def then_cost(context: Any, table: Any) -> None: # type: ignore[name-defined]
|
||||
parsed = _assert_json(context)
|
||||
for row in table.rows:
|
||||
k, v = row["field"], row["expected_value"]
|
||||
actual = parsed["data"]["cost"].get(k)
|
||||
if "." in str(v):
|
||||
assert abs(actual - float(v)) < 1e-6, f'cost.{k}: expected {v}, got {actual}'
|
||||
else:
|
||||
assert actual == int(v), f'cost.{k}: expected {v}, got {actual}'
|
||||
|
||||
@then('the output JSON "data"."execution"."child_plans" equals')
|
||||
def then_child_plans(context: Any, expected: str) -> None: # type: ignore[name-defined]
|
||||
parsed = _assert_json(context)
|
||||
actual = parsed["data"]["execution"]["child_plans"]
|
||||
assert actual == expected, f'child_plans: expected "{expected}", got "{actual}"'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then steps — non-enriched output paths (YAML / rich)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@then('the output contains:')
|
||||
def then_output_contains(context: Any, table: Any) -> None: # type: ignore[name-defined]
|
||||
raw = context.raw_output
|
||||
for row in table.rows:
|
||||
fd = row.get("field", "")
|
||||
ec = row.get("expected_content", "")
|
||||
assert fd in raw or ec in raw, (
|
||||
f"'{fd}' (or '{ec}') not found in output"
|
||||
)
|
||||
|
||||
@then('the output contains "Plan Status"')
|
||||
def then_panel_present(context: Any) -> None: # type: ignore[name-defined]
|
||||
assert "Plan Status" in context.raw_output, (
|
||||
f"'Plan Status' not in rich panel output.\n{context.raw_output[:500]}"
|
||||
)
|
||||
|
||||
@then('the output is valid JSON and does NOT crash')
|
||||
def then_multi_ok(context: Any) -> None: # type: ignore[name-defined]
|
||||
_assert_json(context)
|
||||
@@ -53,7 +53,11 @@ from cleveragents.domain.models.core.error_recovery import (
|
||||
ErrorCategory,
|
||||
classify_error,
|
||||
)
|
||||
from cleveragents.domain.models.core.plan import PlanPhase, ProcessingState
|
||||
from cleveragents.domain.models.core.plan import (
|
||||
Plan as LifecyclePlan,
|
||||
PlanPhase,
|
||||
ProcessingState,
|
||||
)
|
||||
from cleveragents.infrastructure.sandbox.git_worktree import (
|
||||
GitWorktreeSandbox,
|
||||
)
|
||||
@@ -237,9 +241,6 @@ def _plan_spec_dict(plan: Any) -> dict[str, object]:
|
||||
from cleveragents.domain.models.core.plan import (
|
||||
ExecutionEnvPriority,
|
||||
)
|
||||
from cleveragents.domain.models.core.plan import (
|
||||
Plan as LifecyclePlan,
|
||||
)
|
||||
|
||||
if isinstance(plan, LifecyclePlan):
|
||||
result: dict[str, object] = {
|
||||
@@ -477,6 +478,157 @@ def _execute_output_dict(
|
||||
}
|
||||
|
||||
|
||||
def _get_progress_status(phase: PlanPhase, state: ProcessingState) -> str:
|
||||
"""Determine a single progress status string from phase and processing state."""
|
||||
if state == ProcessingState.ERRORED:
|
||||
return "error"
|
||||
if state in (ProcessingState.COMPLETE, ProcessingState.APPLIED):
|
||||
return "done"
|
||||
if state == ProcessingState.PROCESSING:
|
||||
return "running"
|
||||
return "queued"
|
||||
|
||||
|
||||
def _status_output_dict(
|
||||
plan: LifecyclePlan,
|
||||
*,
|
||||
started_at: datetime | None = None,
|
||||
duration_ms: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build a spec-compliant JSON envelope for plan status output.
|
||||
|
||||
Returns a complete envelope matching the format expected by
|
||||
the CLI spec (command, status, exit_code, data, timing, messages).
|
||||
"""
|
||||
command = "plan status"
|
||||
status = "ok"
|
||||
exit_code_val = 0
|
||||
|
||||
# --- Timing envelope -------------------------------------------------------
|
||||
_started_iso = started_at.isoformat() if started_at is not None else ""
|
||||
_timing: dict[str, Any] = {"started": _started_iso}
|
||||
if duration_ms is not None:
|
||||
_timing["duration_ms"] = duration_ms
|
||||
|
||||
# --- Core plan data --------------------------------------------------------
|
||||
phase_val = PlanPhase(plan.phase) if isinstance(plan.phase, str) else plan.phase # type: ignore[arg-type]
|
||||
state_val = ProcessingState(plan.processing_state or "UNKNOWN") # type: ignore[union-attr]
|
||||
|
||||
_data: dict[str, Any] = {
|
||||
"plan_id": plan.id,
|
||||
"phase": phase_val.value if isinstance(phase_val, PlanPhase) else str(phase_val),
|
||||
"state": state_val.value if isinstance(state_val, ProcessingState) else str(state_val),
|
||||
"action": plan.action_name or _empty_string(),
|
||||
"attempt": 1,
|
||||
}
|
||||
|
||||
# Progress steps: map phase to step statuses
|
||||
phase_for_progress = phase_val if isinstance(phase_val, PlanPhase) else PlanPhase(str(phase_val))
|
||||
progress_steps: list[dict[str, str]] = []
|
||||
for step_name in ("Strategize", "Execute", "Apply"):
|
||||
_step_state: ProcessingState = ( # type: ignore[assignment]
|
||||
state_val if phase_for_progress == APPLY else # noqa
|
||||
ProcessingState.COMPLETE # noqa
|
||||
)
|
||||
# For ACTION phase, all steps are queued until processing
|
||||
if phase_for_progress == ACTION: # noqa
|
||||
_step_state = ProcessingState.QUEUED # type: ignore[assignment]
|
||||
progress_steps.append({"step": step_name, "status": _get_progress_status(phase_val, state_val)})
|
||||
_data["progress"] = progress_steps
|
||||
|
||||
# --- Execution details -----------------------------------------------------
|
||||
has_changeset = plan.changeset is not None
|
||||
files_modified_list: list[dict[str, Any]] = []
|
||||
if has_changeset and hasattr(plan.changeset, "changes"): # type: ignore[union-attr]
|
||||
for c in plan.changeset.changes: # type: ignore[union-attr]
|
||||
files_modified_list.append(_make_change_dict(c))
|
||||
|
||||
child_plans_count = len(plan.child_plan_ids or [])
|
||||
completed_child_plans_count = len(plan.completed_child_plan_ids or [])
|
||||
child_plans_str = f"{completed_child_plans_count}/{child_plans_count} complete" if child_plans_count > 0 else "0/0 complete"
|
||||
|
||||
checkpoints_val: list[Any] = []
|
||||
if hasattr(plan, "checkpoints") and plan.checkpoints is not None: # type: ignore[union-attr]
|
||||
for cp in (plan.checkpoints or []): # type: ignore[union-attr]
|
||||
checkpoints_val.append({"checkpoint_id": cp.id if hasattr(cp, "id") else str(cp), "timestamp": _str_time_safe(getattr(cp, "timestamp", None))})
|
||||
|
||||
_data["execution"] = {
|
||||
"sandbox": plan.sandbox_type if isinstance(plan, type) and hasattr(plan, "sandbox_type") else "",
|
||||
"tool_calls": 0,
|
||||
"files_modified": files_modified_list,
|
||||
"child_plans": child_plans_str,
|
||||
"checkpoints": checkpoints_val,
|
||||
}
|
||||
|
||||
# --- Cost ------------------------------------------------------------------
|
||||
_cost_data: dict[str, Any] = {"tokens_used": 0, "cost_so_far": 0.0, "estimated": 0.0}
|
||||
if hasattr(plan, "estimation_result") and plan.estimation_result is not None: # type: ignore[union-attr]
|
||||
_as_dict = getattr(plan.estimation_result, "as_display_dict", lambda: {})()
|
||||
_cost_data["tokens_used"] = _as_dict.get("total_usage", {}).get("tokens", 0)
|
||||
_cost_data["cost_so_far"] = _as_dict.get("cost", {}).get("total_cost", 0.0)
|
||||
_cost_data["estimated"] = _as_dict.get("budget", {}).get("total_cost", 0.0)
|
||||
|
||||
_data["cost"] = _cost_data
|
||||
|
||||
# --- Optional fields -------------------------------------------------------
|
||||
if plan.project_name:
|
||||
_data["project"] = plan.project_name # type: ignore[union-attr]
|
||||
if hasattr(plan, "automation_profile") and plan.automation_profile is not None: # type: ignore[union-attr]
|
||||
_data["automation"] = plan.automation_profile
|
||||
|
||||
# --- Nested timing in data -------------------------------------------------
|
||||
_data_timing: dict[str, Any] = {}
|
||||
_created_str = _str_time_safe(plan.created_at) if hasattr(plan, "created_at") else ""
|
||||
_updated_str = _str_time_safe(plan.updated_at) if hasattr(plan, "updated_at") else ""
|
||||
if _created_str and _updated_str:
|
||||
_data_timing["started"] = _created_str
|
||||
try:
|
||||
_t1 = datetime.fromisoformat(_created_str)
|
||||
_t2 = datetime.fromisoformat(_updated_str)
|
||||
_elapsed_ms = int((_t2 - _t1).total_seconds() * 1000) if _t2 > _t1 else 0
|
||||
_data_timing["elapsed"] = _elapsed_ms
|
||||
|
||||
if duration_ms is not None and duration_ms < 5000:
|
||||
_total_expected = int(duration_ms / max(1, child_plans_count))
|
||||
_eta_remaining = _total_expected * max(0, child_plans_count + 1 - completed_child_plans_count)
|
||||
_data_timing["eta"] = _eta_remaining
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
if _data_timing:
|
||||
_data["timing"] = _data_timing
|
||||
|
||||
return {
|
||||
"command": command,
|
||||
"status": status,
|
||||
"exit_code": exit_code_val,
|
||||
"data": _data,
|
||||
"timing": _timing,
|
||||
"messages": ["Status refreshed"],
|
||||
}
|
||||
|
||||
|
||||
def _empty_string() -> str:
|
||||
return ""
|
||||
|
||||
def _str_time_safe(dt_val): # type: ignore[no-untyped-def]
|
||||
if dt_val is None:
|
||||
return ""
|
||||
try:
|
||||
return datetime.fromisoformat(str(dt_val)).isoformat()
|
||||
except (ValueError, TypeError):
|
||||
return str(dt_val)
|
||||
|
||||
def _make_change_dict(change): # type: ignore[no-untyped-def]
|
||||
result = {}
|
||||
for attr in ("path", "action", "hunks"):
|
||||
if hasattr(change, attr):
|
||||
val = getattr(change, attr)
|
||||
if val is not None:
|
||||
result[attr] = val
|
||||
return result if result else {}
|
||||
|
||||
|
||||
def _get_current_project() -> Project:
|
||||
"""Get the current project or exit with error.
|
||||
|
||||
@@ -2383,12 +2535,40 @@ def plan_status(
|
||||
# instead of a generic "Plan not found".
|
||||
_validate_plan_ulid(plan_id)
|
||||
|
||||
# Show single plan details
|
||||
# Single plan status
|
||||
_status_work_start = time.monotonic() # type: ignore[name-defined]
|
||||
plan = service.get_plan(plan_id)
|
||||
|
||||
if fmt == OutputFormat.JSON.value:
|
||||
# Direct JSON output — bypasses format_output() to preserve complete envelope.
|
||||
json_payload = _status_output_dict(
|
||||
plan,
|
||||
started_at=datetime.now(),
|
||||
duration_ms=int((time.monotonic() - _status_work_start) * 1000),
|
||||
)
|
||||
console.print(json.dumps(json_payload, default=str))
|
||||
return
|
||||
|
||||
# Non-JSON output (YAML/PLAIN): build envelope and delegate to format_output.
|
||||
if fmt != OutputFormat.RICH.value:
|
||||
data = _plan_spec_dict(plan)
|
||||
console.print(format_output(data, fmt))
|
||||
envelope = _status_output_dict(
|
||||
plan,
|
||||
started_at=datetime.now(),
|
||||
duration_ms=int((time.monotonic() - _status_work_start) * 1000),
|
||||
)
|
||||
data = envelope.get("data", {})
|
||||
command_val = str(envelope.get("command", "plan status"))
|
||||
status_val = str(envelope.get("status", "ok"))
|
||||
exit_code_val = int(envelope.get("exit_code", 0))
|
||||
messages = envelope.get("messages") or []
|
||||
_formatted_messages = [
|
||||
{"level": "info", "text": msg} for msg in messages if isinstance(msg, str)
|
||||
]
|
||||
console.print(
|
||||
format_output(
|
||||
data, fmt, command=command_val, status=status_val, exit_code=exit_code_val, messages=_formatted_messages,
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
_print_lifecycle_plan(plan, title="Plan Status")
|
||||
|
||||
Reference in New Issue
Block a user