Skip to content

Commit 5d1bf3d

Browse files
kmcallorumclaude
andcommitted
feat: add dry-run mode for workflow command
Add --dry-run flag to preview workflow execution without running agents. Shows execution plan with step order, dependencies, and quality gates. Supports all output formats (Rich, JSON, Table). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 7be93b2 commit 5d1bf3d

6 files changed

Lines changed: 594 additions & 4 deletions

File tree

src/multi_agent_cli/cli.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
from multi_agent_cli.factory import get_default_factory
3030
from multi_agent_cli.metrics import get_metrics, start_metrics_server
3131
from multi_agent_cli.models.agent import AgentsConfig
32+
from multi_agent_cli.models.results import DryRunResult, DryRunStep
3233
from multi_agent_cli.reporters import (
3334
JSONReporter,
3435
RichReporter,
@@ -308,13 +309,15 @@ def parallel(
308309
@click.argument("workflow_file", type=click.Path(exists=True))
309310
@click.option("--strict", is_flag=True, help="Fail on first error")
310311
@click.option("--continue-on-error", is_flag=True, help="Continue even if steps fail")
312+
@click.option("--dry-run", is_flag=True, help="Preview execution plan without running")
311313
@click.option("--output", "-o", help="Save workflow results to file")
312314
@pass_context
313315
def workflow(
314316
ctx: CLIContext,
315317
workflow_file: str,
316318
strict: bool,
317319
continue_on_error: bool,
320+
dry_run: bool,
318321
output: str | None,
319322
) -> None:
320323
"""Run sequential workflow.
@@ -324,11 +327,31 @@ def workflow(
324327
Examples:
325328
multi-agent-cli workflow code-review.yaml
326329
multi-agent-cli workflow compliance-check.yaml --strict
330+
multi-agent-cli workflow analysis.yaml --dry-run
327331
"""
328332
try:
329333
# Load workflow
330334
wf = load_workflow(workflow_file)
331335

336+
# Handle dry-run mode
337+
if dry_run:
338+
dry_result = _build_dry_run_result(wf)
339+
ctx.reporter.display_dry_run_result(dry_result)
340+
341+
# Save if requested
342+
if output:
343+
save_result_to_file(dry_result, output)
344+
if not ctx.quiet:
345+
console.print(f"[dim]Dry run results saved to {output}[/dim]")
346+
347+
# Exit with error if invalid
348+
# Note: This is currently unreachable because load_workflow validates
349+
# dependencies first. Kept for defensive programming if validation
350+
# is ever changed or bypassed.
351+
if not dry_result.is_valid: # pragma: no cover
352+
sys.exit(1)
353+
return
354+
332355
config = ctx.config or create_default_config()
333356
factory = get_default_factory()
334357
bridge = factory.create(config)
@@ -377,6 +400,51 @@ def workflow(
377400
sys.exit(2)
378401

379402

403+
def _build_dry_run_result(wf: Any) -> DryRunResult:
404+
"""Build dry-run result from workflow.
405+
406+
Args:
407+
wf: Workflow to analyze.
408+
409+
Returns:
410+
DryRunResult with execution plan preview.
411+
"""
412+
# Validate dependencies
413+
validation_errors = wf.validate_dependencies()
414+
415+
# Build steps
416+
steps = []
417+
for i, step in enumerate(wf.steps, start=1):
418+
dry_step = DryRunStep(
419+
order=i,
420+
name=step.name,
421+
agent=step.agent,
422+
action=step.action,
423+
params=step.params,
424+
depends_on=step.depends_on,
425+
timeout=step.timeout,
426+
on_error=step.on_error,
427+
)
428+
steps.append(dry_step)
429+
430+
# Build quality gates dict
431+
quality_gates = {
432+
"max_fixmes": wf.quality_gates.max_fixmes,
433+
"min_documentation_score": wf.quality_gates.min_documentation_score,
434+
"max_dead_code_percent": wf.quality_gates.max_dead_code_percent,
435+
}
436+
437+
return DryRunResult(
438+
workflow_name=wf.name,
439+
workflow_description=wf.description,
440+
total_steps=len(wf.steps),
441+
steps=steps,
442+
validation_errors=validation_errors,
443+
quality_gates=quality_gates,
444+
is_valid=len(validation_errors) == 0,
445+
)
446+
447+
380448
@cli.command("list")
381449
@click.option("--verbose", "-v", is_flag=True, help="Show agent capabilities")
382450
@pass_context

src/multi_agent_cli/models/results.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,39 @@ def failure(
5858
)
5959

6060

61+
class DryRunStep(BaseModel):
62+
"""Represents a step in dry-run preview."""
63+
64+
order: int = Field(..., description="Execution order (1-based)")
65+
name: str = Field(..., description="Step name")
66+
agent: str = Field(..., description="Agent to execute")
67+
action: str = Field(..., description="Action to perform")
68+
params: dict[str, Any] = Field(
69+
default_factory=dict, description="Action parameters"
70+
)
71+
depends_on: list[str] = Field(default_factory=list, description="Step dependencies")
72+
timeout: int | None = Field(default=None, description="Step-specific timeout")
73+
on_error: str = Field(default="fail", description="Error handling behavior")
74+
75+
76+
class DryRunResult(BaseModel):
77+
"""Result from dry-run workflow validation."""
78+
79+
workflow_name: str = Field(..., description="Workflow name")
80+
workflow_description: str = Field(default="", description="Workflow description")
81+
total_steps: int = Field(..., description="Total number of steps")
82+
steps: list[DryRunStep] = Field(
83+
default_factory=list, description="Steps to execute"
84+
)
85+
validation_errors: list[str] = Field(
86+
default_factory=list, description="Validation errors found"
87+
)
88+
quality_gates: dict[str, Any] = Field(
89+
default_factory=dict, description="Quality gate configuration"
90+
)
91+
is_valid: bool = Field(..., description="Whether workflow is valid")
92+
93+
6194
class WorkflowResult(BaseModel):
6295
"""Result from workflow execution."""
6396

src/multi_agent_cli/reporters.py

Lines changed: 123 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from rich.panel import Panel
1111
from rich.table import Table
1212

13-
from multi_agent_cli.models.results import AgentResult, WorkflowResult
13+
from multi_agent_cli.models.results import AgentResult, DryRunResult, WorkflowResult
1414

1515

1616
class Reporter(Protocol):
@@ -188,6 +188,63 @@ def display_info(self, message: str) -> None:
188188
"""
189189
self.console.print(f"[blue]i[/blue] {message}")
190190

191+
def display_dry_run_result(self, result: DryRunResult) -> None:
192+
"""Display dry-run preview.
193+
194+
Args:
195+
result: Dry-run result to display.
196+
"""
197+
# Header
198+
status_color = "green" if result.is_valid else "red"
199+
status_text = "VALID" if result.is_valid else "INVALID"
200+
201+
self.console.print(
202+
f"\n[bold cyan]DRY RUN[/bold cyan] - "
203+
f"[{status_color}]{status_text}[/{status_color}]"
204+
)
205+
self.console.print(f"[bold]{result.workflow_name}[/bold]")
206+
if result.workflow_description:
207+
self.console.print(f"[dim]{result.workflow_description}[/dim]")
208+
self.console.print()
209+
210+
# Validation errors if any
211+
if result.validation_errors:
212+
self.console.print("[red]Validation Errors:[/red]")
213+
for error in result.validation_errors:
214+
self.console.print(f" [red]x[/red] {error}")
215+
self.console.print()
216+
217+
# Steps table
218+
table = Table(title="Execution Plan", show_header=True)
219+
table.add_column("#", style="dim", width=3)
220+
table.add_column("Step", style="cyan")
221+
table.add_column("Agent", style="magenta")
222+
table.add_column("Action", style="blue")
223+
table.add_column("Depends On", style="dim")
224+
table.add_column("On Error", style="yellow")
225+
226+
for step in result.steps:
227+
deps = ", ".join(step.depends_on) if step.depends_on else "-"
228+
table.add_row(
229+
str(step.order),
230+
step.name,
231+
step.agent,
232+
step.action,
233+
deps,
234+
step.on_error,
235+
)
236+
237+
self.console.print(table)
238+
239+
# Quality gates
240+
if any(v is not None for v in result.quality_gates.values()):
241+
self.console.print("\n[bold]Quality Gates:[/bold]")
242+
for gate, value in result.quality_gates.items():
243+
if value is not None:
244+
self.console.print(f" [dim]-[/dim] {gate}: {value}")
245+
246+
self.console.print(f"\n[dim]Total steps: {result.total_steps}[/dim]")
247+
191248

192249
class JSONReporter:
193250
"""Output results as JSON."""
@@ -247,6 +304,14 @@ def display_success(self, message: str) -> None:
247304
"""
248305
self.console.print(json.dumps({"success": message}, indent=self.indent))
249306

307+
def display_dry_run_result(self, result: DryRunResult) -> None:
308+
"""Display dry-run result as JSON.
309+
310+
Args:
311+
result: Dry-run result to display.
312+
"""
313+
self.console.print(result.model_dump_json(indent=self.indent))
314+
250315

251316
class TableReporter:
252317
"""Output results as formatted tables."""
@@ -351,6 +416,62 @@ def display_success(self, message: str) -> None:
351416
"""
352417
self.console.print(f"[green]Success:[/green] {message}")
353418

419+
def display_dry_run_result(self, result: DryRunResult) -> None:
420+
"""Display dry-run result as table.
421+
422+
Args:
423+
result: Dry-run result to display.
424+
"""
425+
# Status
426+
status = "VALID" if result.is_valid else "INVALID"
427+
status_style = "green" if result.is_valid else "red"
428+
self.console.print(
429+
f"[bold]DRY RUN[/bold] - [{status_style}]{status}[/{status_style}]"
430+
)
431+
self.console.print(f"Workflow: {result.workflow_name}")
432+
self.console.print()
433+
434+
# Validation errors
435+
if result.validation_errors:
436+
self.console.print("[red]Validation Errors:[/red]")
437+
for error in result.validation_errors:
438+
self.console.print(f" - {error}")
439+
self.console.print()
440+
441+
# Steps table
442+
table = Table(title="Execution Plan", show_header=True)
443+
table.add_column("#", width=3)
444+
table.add_column("Step", style="cyan")
445+
table.add_column("Agent")
446+
table.add_column("Action")
447+
table.add_column("Depends On")
448+
table.add_column("On Error")
449+
450+
for step in result.steps:
451+
deps = ", ".join(step.depends_on) if step.depends_on else "-"
452+
table.add_row(
453+
str(step.order),
454+
step.name,
455+
step.agent,
456+
step.action,
457+
deps,
458+
step.on_error,
459+
)
460+
461+
self.console.print(table)
462+
463+
# Quality gates
464+
gates_table = Table(title="Quality Gates", show_header=True)
465+
gates_table.add_column("Gate", style="cyan")
466+
gates_table.add_column("Value")
467+
468+
for gate, value in result.quality_gates.items():
469+
if value is not None:
470+
gates_table.add_row(gate, str(value))
471+
472+
if any(v is not None for v in result.quality_gates.values()):
473+
self.console.print(gates_table)
474+
354475

355476
def get_reporter(
356477
format_type: str,
@@ -376,7 +497,7 @@ def get_reporter(
376497

377498

378499
def save_result_to_file(
379-
result: AgentResult | WorkflowResult,
500+
result: AgentResult | WorkflowResult | DryRunResult,
380501
path: str | Path,
381502
) -> None:
382503
"""Save result to JSON file.

0 commit comments

Comments
 (0)