feat(workflows): add JSON output for workflow run resume and status (#2814)

* feat(workflows): add --json output to workflow run, resume, and status

Adds an opt-in `--json` flag to `workflow run`, `workflow resume`, and
`workflow status` that emits a single machine-readable object (run_id,
workflow_id, status, current step; status also reports per-step states
and a runs list) for automation and external orchestrators.

JSON is written via a small `_emit_workflow_json` helper using plain
stdout, so Rich markup, highlighting, and line-wrapping can never alter
the emitted object. Default human-readable output and exit codes are
unchanged when `--json` is omitted. Reference docs updated.

Closes #2811.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(workflows): keep --json stdout clean while steps write output

Suppressing the banner and the step-start callback was not enough to
guarantee a single parseable JSON object on stdout: individual steps still
write there while the engine runs. The gate step prints its prompt, and the
prompt step runs a CLI subprocess that inherits the process's stdout file
descriptor — either can corrupt the JSON stream for interactive runs or
integration-backed workflows.

Wrap engine.execute()/engine.resume() in a file-descriptor-level redirect
(dup2) when --json is set, so both Python-level writes and inherited-fd
subprocess output go to stderr while stdout carries only the emitted JSON.
Step progress stays visible on stderr. status does not run the engine, so
it is unaffected.

Tests cover both pollution channels (a Python print and a real subprocess)
via fd-level capture, and the inactive no-op path. Docs note the
stdout/stderr split.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(workflows): fix stray escape sequence in --json redirect comments

The redirect helper's docstring and its test comment wrote ``print``\s,
which renders as "print\s" rather than "prints". Replace with plain
"prints".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huy Do
2026-06-04 23:11:39 +07:00
committed by GitHub
parent e094cbdb6e
commit 141119efea
3 changed files with 293 additions and 6 deletions

View File

@@ -26,6 +26,7 @@ Or install globally:
specify init --here
"""
import contextlib
import os
import sys
import zipfile
@@ -2693,12 +2694,68 @@ def _parse_input_values(input_values: list[str] | None) -> dict[str, Any]:
return inputs
def _workflow_run_payload(state: Any) -> dict[str, Any]:
"""Machine-readable summary of a run/resume outcome."""
return {
"run_id": state.run_id,
"workflow_id": state.workflow_id,
"status": state.status.value,
"current_step_id": state.current_step_id,
"current_step_index": state.current_step_index,
}
def _emit_workflow_json(payload: dict[str, Any]) -> None:
"""Write a workflow payload as machine-readable JSON to stdout.
Uses the builtin ``print`` rather than ``console.print`` so Rich
markup interpretation, syntax highlighting, and line-wrapping can
never alter the emitted JSON.
"""
print(json.dumps(payload, indent=2))
@contextlib.contextmanager
def _stdout_to_stderr_when(active: bool):
"""Redirect everything written to stdout onto stderr while *active*.
Suppressing the banner and the step-start callback is not enough to
keep a ``--json`` stream clean: individual steps may still write to
stdout while the engine runs — the gate step prints its prompt,
and the prompt step runs a subprocess that inherits the process's
stdout file descriptor. Either would corrupt the single JSON object.
Redirecting at the file-descriptor level (``dup2``) captures both
Python-level writes and inherited-fd subprocess output, so step
progress lands on stderr (still visible to a human) while stdout
carries only the emitted JSON. A no-op when *active* is false.
"""
if not active:
yield
return
sys.stdout.flush()
saved_stdout_fd = os.dup(1)
try:
os.dup2(2, 1) # fd 1 (stdout) now points at fd 2 (stderr)
with contextlib.redirect_stdout(sys.stderr):
yield
finally:
sys.stdout.flush()
os.dup2(saved_stdout_fd, 1) # restore the real stdout
os.close(saved_stdout_fd)
@workflow_app.command("run")
def workflow_run(
source: str = typer.Argument(..., help="Workflow ID or YAML file path"),
input_values: list[str] | None = typer.Option(
None, "--input", "-i", help="Input values as key=value pairs"
),
json_output: bool = typer.Option(
False,
"--json",
help="Emit the run outcome as a single JSON object instead of formatted text.",
),
):
"""Run a workflow from an installed ID or local YAML path."""
from .workflows.engine import WorkflowEngine
@@ -2721,7 +2778,8 @@ def workflow_run(
project_root = _require_specify_project()
engine = WorkflowEngine(project_root)
engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")
if not json_output:
engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")
try:
definition = engine.load_workflow(source_path if is_file_source else source)
@@ -2743,11 +2801,13 @@ def workflow_run(
# Parse inputs
inputs = _parse_input_values(input_values)
console.print(f"\n[bold cyan]Running workflow:[/bold cyan] {definition.name} ({definition.id})")
console.print(f"[dim]Version: {definition.version}[/dim]\n")
if not json_output:
console.print(f"\n[bold cyan]Running workflow:[/bold cyan] {definition.name} ({definition.id})")
console.print(f"[dim]Version: {definition.version}[/dim]\n")
try:
state = engine.execute(definition, inputs)
with _stdout_to_stderr_when(json_output):
state = engine.execute(definition, inputs)
except ValueError as exc:
console.print(f"[red]Error:[/red] {exc}")
raise typer.Exit(1)
@@ -2755,6 +2815,10 @@ def workflow_run(
console.print(f"[red]Workflow failed:[/red] {exc}")
raise typer.Exit(1)
if json_output:
_emit_workflow_json(_workflow_run_payload(state))
return
status_colors = {
"completed": "green",
"paused": "yellow",
@@ -2775,18 +2839,25 @@ def workflow_resume(
input_values: list[str] | None = typer.Option(
None, "--input", "-i", help="Updated input values as key=value pairs"
),
json_output: bool = typer.Option(
False,
"--json",
help="Emit the resume outcome as a single JSON object instead of formatted text.",
),
):
"""Resume a paused or failed workflow run."""
from .workflows.engine import WorkflowEngine
project_root = _require_specify_project()
engine = WorkflowEngine(project_root)
engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")
if not json_output:
engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")
inputs = _parse_input_values(input_values)
try:
state = engine.resume(run_id, inputs or None)
with _stdout_to_stderr_when(json_output):
state = engine.resume(run_id, inputs or None)
except FileNotFoundError:
console.print(f"[red]Error:[/red] Run not found: {run_id}")
raise typer.Exit(1)
@@ -2797,6 +2868,10 @@ def workflow_resume(
console.print(f"[red]Resume failed:[/red] {exc}")
raise typer.Exit(1)
if json_output:
_emit_workflow_json(_workflow_run_payload(state))
return
status_colors = {
"completed": "green",
"paused": "yellow",
@@ -2810,6 +2885,11 @@ def workflow_resume(
@workflow_app.command("status")
def workflow_status(
run_id: str | None = typer.Argument(None, help="Run ID to inspect (shows all if omitted)"),
json_output: bool = typer.Option(
False,
"--json",
help="Emit run status as a single JSON object instead of formatted text.",
),
):
"""Show workflow run status."""
from .workflows.engine import WorkflowEngine
@@ -2825,6 +2905,21 @@ def workflow_status(
console.print(f"[red]Error:[/red] Run not found: {run_id}")
raise typer.Exit(1)
if json_output:
# Build on the shared run/resume payload so the common fields
# (including current_step_index) stay identical across commands.
payload = {
**_workflow_run_payload(state),
"created_at": state.created_at,
"updated_at": state.updated_at,
"steps": {
sid: sd.get("status", "unknown")
for sid, sd in state.step_results.items()
},
}
_emit_workflow_json(payload)
return
status_colors = {
"completed": "green",
"paused": "yellow",
@@ -2852,6 +2947,22 @@ def workflow_status(
console.print(f" [{sc}]●[/{sc}] {step_id}: {s}")
else:
runs = engine.list_runs()
if json_output:
payload = {
"runs": [
{
"run_id": r["run_id"],
"workflow_id": r.get("workflow_id"),
"status": r.get("status", "unknown"),
"updated_at": r.get("updated_at"),
}
for r in runs
]
}
_emit_workflow_json(payload)
return
if not runs:
console.print("[yellow]No workflow runs found.[/yellow]")
return