Skip to content

feat(workflows): add --dry-run flag to specify workflow run#3124

Open
fuleinist wants to merge 4 commits into
github:mainfrom
fuleinist:feat/dry-run-workflow-run
Open

feat(workflows): add --dry-run flag to specify workflow run#3124
fuleinist wants to merge 4 commits into
github:mainfrom
fuleinist:feat/dry-run-workflow-run

Conversation

@fuleinist

Copy link
Copy Markdown

Summary

Adds a --dry-run\ flag to \specify workflow run\ that previews step outputs without dispatching AI or shell commands. Per maintainer design guidance (\#2704), the flag lives only on the step-based invocation path — no new CLI commands introduced.

Changes

Engine layer

  • \StepContext.dry_run\ flag propagated from \WorkflowEngine.execute(dry_run=...)\
  • \RunState.dry_run\ persisted via save/load, restored on
    esume()\
  • Engine attaches \partial_state\ to exceptions so mid-run failures still surface previews from earlier steps

Step implementations

  • CommandStep: short-circuits on \context.dry_run, renders \�uild_command_invocation()\ preview when integration is configured, falls back to bare command string
  • PromptStep: short-circuits with synthetic preview message
  • GateStep: short-circuits with deterministic first-non-sentinel choice via _coerce_options\ / _first_non_sentinel\ helpers

CLI

  • \specify workflow run --dry-run\ flag with preview rendering after execution
  • --json\ suppresses dry-run banner (clean JSON stdout contract)
  • _print_dry_run_previews()\ shared between \workflow run\ and \workflow resume\

Tests

  • \ ests/test_dry_run.py: 16 tests covering CommandStep/PromptStep/GateStep dry-run paths, engine execution, resume, and CLI integration

Closes: #2661
Ref: #2704 (closed per maintainer request to split into smaller PRs)

Scoped to engine-only changes per maintainer design guidance:
- StepContext.dry_run flag
- RunState.dry_run field (persisted via save/load, restored on resume)
- WorkflowEngine.execute(..., dry_run=...) + resume() restores it
- CommandStep / PromptStep / GateStep short-circuit on context.dry_run
  with synthetic dry_run_message previews
- specify workflow run --dry-run CLI flag + preview rendering
- _coerce_options / _first_non_sentinel helpers for deterministic
  GateStep dry-run branch

No new CLI commands introduced. --dry-run lives only on the step-based
invocation path (specify workflow run), not on scaffolding commands.

Closes: github#2661
Ref: github#2704 (closed per maintainer request, split into smaller PRs)
@fuleinist fuleinist requested a review from mnriem as a code owner June 23, 2026 16:25
Copilot AI review requested due to automatic review settings June 23, 2026 16:25

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a --dry-run mode to specify workflow run by propagating a dry-run flag through the workflow engine, persisting it in run state for resume, and teaching key step types (command, prompt, gate) to short-circuit and emit preview output instead of performing side effects.

Changes:

  • Persist and propagate dry_run across WorkflowEngine.execute(...), RunState.save/load, and resume().
  • Add dry-run short-circuit behavior to CommandStep, PromptStep, and GateStep.
  • Add specify workflow run --dry-run CLI flag plus preview rendering, and introduce a dedicated dry-run test suite.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
tests/test_dry_run.py Adds coverage for step-level dry-run behavior, engine persistence/resume, and CLI output expectations (including --json behavior).
src/specify_cli/workflows/steps/prompt/__init__.py Implements dry-run short-circuit for prompt steps and standardizes output fields (dry_run, executed, dispatched, etc.).
src/specify_cli/workflows/steps/gate/__init__.py Implements dry-run non-interactive gate resolution with deterministic choice selection and option coercion helpers.
src/specify_cli/workflows/steps/command/__init__.py Implements dry-run preview generation for command invocations without dispatching integration CLIs.
src/specify_cli/workflows/engine.py Adds dry_run plumbing, persistence, and partial_state attachment for mid-run failures.
src/specify_cli/workflows/base.py Adds dry_run to StepContext and documents intended preview semantics.
src/specify_cli/__init__.py Adds the --dry-run flag, prints previews in non-JSON mode, and adds preview-print helper utilities.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/specify_cli/workflows/steps/gate/__init__.py
Comment thread src/specify_cli/__init__.py
Comment thread src/specify_cli/__init__.py Outdated
Comment thread src/specify_cli/__init__.py
Comment thread src/specify_cli/workflows/base.py Outdated
Comment thread src/specify_cli/__init__.py Outdated

@mnriem mnriem left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please address Copilot feedback

1. GateStep: preserve original output['message'] in dry-run (don't overwrite)
2. CommandStep: preserve original message, set dry_run_message separately
3. PromptStep: same pattern — dry_run_message separate from message
4. --dry-run help text: clarify not all step types short-circuit
5. base.py docstring: accurate about message vs dry_run_message contract
6. _print_dry_run_previews: escape Rich markup in preview text
7. JSON error path: emit valid JSON payload on engine exception
8. resume path: remove stale docstring claim about shared preview helper

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot's findings

  • Files reviewed: 7/7 changed files
  • Comments generated: 9

Comment thread src/specify_cli/workflows/steps/prompt/__init__.py
Comment thread src/specify_cli/workflows/steps/command/__init__.py
Comment thread src/specify_cli/workflows/steps/gate/__init__.py
Comment thread src/specify_cli/workflows/steps/gate/__init__.py
Comment thread src/specify_cli/workflows/base.py Outdated
Comment thread src/specify_cli/__init__.py
Comment thread src/specify_cli/__init__.py
Comment thread src/specify_cli/__init__.py Outdated
Comment thread src/specify_cli/__init__.py
@mnriem

mnriem commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

Please address Copilot feedback

- PromptStep: preserve original prompt in output['message']
- CommandStep: same — keep original command in message
- GateStep validate(): accept tuple/list options
- _first_non_sentinel: case-insensitive comparison
- StepContext docstring: dry_run_message is stable preview surface
- Banner: narrow wording to match actual scope
- ValueError handler: print partial_state for dry-run
- _escape_markup: use rich.markup.escape
- workflow_resume: wire dry-run preview output
@fuleinist

Copy link
Copy Markdown
Author

Addressed all 9 remaining Copilot review items in c19f6e1:

  1. PromptStep: output["message"] now preserves the original (resolved) prompt instead of the preview string
  2. CommandStep: same — output["message"] keeps the command name
  3. GateStep validate(): now accepts tuple options in addition to list
  4. _first_non_sentinel: case-insensitive comparison (opt.lower() not in ("reject", "abort"))
  5. StepContext docstring: clarifies dry_run_message is the stable preview surface; message preserves original value
  6. Banner: narrowed to "previewing built-in command, prompt, and gate steps without dispatching. Other step types (shell, init) may still execute."
  7. ValueError handler: now prints partial_state dry-run previews (same as generic Exception handler)
  8. _escape_markup: replaced manual [/] escaping with rich.markup.escape
  9. workflow_resume: wired _print_dry_run_previews() for both successful resume and exception handlers when state.dry_run is true

All 15 dry-run tests pass.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot's findings

  • Files reviewed: 7/7 changed files
  • Comments generated: 3

Comment thread src/specify_cli/__init__.py Outdated
Comment on lines +965 to +973
from rich.markup import escape as _rich_escape_markup


def _escape_markup(text: str) -> str:
"""Escape Rich markup characters so user-controlled text can be
printed safely. Delegates to ``rich.markup.escape`` for canonical
handling of ``[``, ``]``, ``{``, ``}``, and other special chars.
"""
return _rich_escape_markup(text)
Comment on lines 1006 to 1015
except ValueError as exc:
if getattr(state, "dry_run", False) and not json_output:
_print_dry_run_previews(getattr(exc, "partial_state", None))
console.print(f"[red]Error:[/red] {exc}")
raise typer.Exit(1)
except Exception as exc:
if getattr(state, "dry_run", False) and not json_output:
_print_dry_run_previews(getattr(exc, "partial_state", None))
console.print(f"[red]Resume failed:[/red] {exc}")
raise typer.Exit(1)
Comment on lines +79 to +83
try:
preview_invocation = impl.build_command_invocation(
command, args_str
)
except (ImportError, AttributeError, TypeError):
- Move rich.markup.escape import to top-level (fixes E402)
- Use exc.partial_state instead of unbound state var in except handlers
- Copilot build_command_invocation: return full agent invocation for
  meaningful dry-run previews in default (non-skills) mode

@fuleinist fuleinist left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed all 3 remaining Copilot items from the latest review round:

  1. E402: moved \ rom rich.markup import escape\ to top-level imports
  2. UnboundLocalError: except handlers now use \exc.partial_state\ instead of the unbound \state\ variable
  3. Copilot CommandStep preview: \�uild_command_invocation\ now returns \speckit. \ in default mode (was returning just \�rgs), so dry-run previews show the full agent invocation

@mnriem ready for re-review when you have a moment

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot's findings

  • Files reviewed: 8/8 changed files
  • Comments generated: 4

Comment on lines +953 to +963
console.print("\n[bold yellow]DRY RUN previews:[/bold yellow]")
for step_id, result in step_results.items():
if not isinstance(result, dict):
continue
output = result.get("output") or {}
if not output.get("dry_run"):
continue
step_id_display = _escape_markup(str(step_id))
preview = output.get("dry_run_message") or output.get("message") or ""
preview_escaped = _escape_markup(preview)
console.print(f" [cyan][{step_id_display}][/cyan] {preview_escaped}")
Comment on lines 234 to 238
options = config.get("options", ["approve", "reject"])
if not isinstance(options, list) or not options:
if not isinstance(options, (list, tuple)) or not options:
errors.append(
f"Gate step {config.get('id', '?')!r}: 'options' must be a non-empty list."
f"Gate step {config.get('id', '?')!r}: 'options' must be a non-empty list or tuple."
)
Comment on lines +817 to +824
help=(
"Preview the workflow without dispatching any AI or shell "
"commands for built-in command, prompt, and gate steps. "
"Those steps emit a synthetic preview message; the run is "
"persisted so it can be inspected but not resumed to a "
"real run. Other step types (e.g. init, shell) may still "
"perform their normal work during dry-run."
),
Comment on lines 1004 to 1015
except ValueError as exc:
partial = getattr(exc, "partial_state", None)
if getattr(partial, "dry_run", False) and not json_output:
_print_dry_run_previews(partial)
console.print(f"[red]Error:[/red] {exc}")
raise typer.Exit(1)
except Exception as exc:
partial = getattr(exc, "partial_state", None)
if getattr(partial, "dry_run", False) and not json_output:
_print_dry_run_previews(partial)
console.print(f"[red]Resume failed:[/red] {exc}")
raise typer.Exit(1)
@mnriem

mnriem commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator

Please address Copilot feedback

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: Add dry-run flag to preview spec output without AI invocation

3 participants