mirror of
https://github.com/github/spec-kit.git
synced 2026-07-06 05:53:12 +08:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user