Skip to content

Commit 141119e

Browse files
doquanghuyclaude
andauthored
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>
1 parent e094cbd commit 141119e

3 files changed

Lines changed: 293 additions & 6 deletions

File tree

docs/reference/workflows.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ specify workflow run <source>
1111
| Option | Description |
1212
| ------------------- | -------------------------------------------------------- |
1313
| `-i` / `--input` | Pass input values as `key=value` (repeatable) |
14+
| `--json` | Emit the run outcome as a single JSON object |
1415

1516
Runs a workflow from a catalog ID, URL, or local file path. Inputs declared by the workflow can be provided via `--input` or will be prompted interactively.
1617

@@ -20,6 +21,24 @@ Example:
2021
specify workflow run speckit -i spec="Build a kanban board with drag-and-drop task management" -i scope=full
2122
```
2223

24+
With `--json`, a single machine-readable object is printed instead of formatted text (the default output is unchanged when the flag is omitted):
25+
26+
```bash
27+
specify workflow run my-pipeline.yml --json
28+
```
29+
30+
```json
31+
{
32+
"run_id": "662bf791",
33+
"workflow_id": "build-and-review",
34+
"status": "paused",
35+
"current_step_id": "review",
36+
"current_step_index": 0
37+
}
38+
```
39+
40+
`workflow_id` is the `workflow.id` declared inside the YAML, not the file name. The object is printed exactly as shown — pretty-printed with two-space indentation, on plain stdout with no Rich markup — so it always parses. While the workflow runs under `--json`, any progress a step would print (for example a gate prompt, or output from a prompt step's CLI subprocess) is redirected to stderr, so stdout carries only the JSON object. Read the object from stdout; leave stderr attached to the terminal or capture it separately.
41+
2342
> **Note:** Most workflow commands require a project already initialized with `specify init`. The exception is `specify workflow run <local-file.{yml,yaml}>`, which can run outside a project; in that case, run state is stored under the current directory's `.specify/workflows/runs/<run_id>/`.
2443
2544
## Resume a Workflow
@@ -31,6 +50,7 @@ specify workflow resume <run_id>
3150
| Option | Description |
3251
| ------------------- | -------------------------------------------------------- |
3352
| `-i` / `--input` | Updated input values as `key=value` (repeatable) |
53+
| `--json` | Emit the resume outcome as a single JSON object |
3454

3555
Resumes a paused or failed workflow run from the exact step where it stopped. Useful after responding to a gate step or fixing an issue that caused a failure.
3656

@@ -46,6 +66,10 @@ specify workflow resume <run_id> --input cmd="exit 0"
4666
specify workflow status [<run_id>]
4767
```
4868

69+
| Option | Description |
70+
| ------------------- | -------------------------------------------------------- |
71+
| `--json` | Emit run status (or the runs list) as a JSON object |
72+
4973
Shows the status of a specific run, or lists all runs if no ID is given. Run states: `created`, `running`, `completed`, `paused`, `failed`, `aborted`.
5074

5175
## List Installed Workflows

src/specify_cli/__init__.py

Lines changed: 117 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
specify init --here
2727
"""
2828

29+
import contextlib
2930
import os
3031
import sys
3132
import zipfile
@@ -2693,12 +2694,68 @@ def _parse_input_values(input_values: list[str] | None) -> dict[str, Any]:
26932694
return inputs
26942695

26952696

2697+
def _workflow_run_payload(state: Any) -> dict[str, Any]:
2698+
"""Machine-readable summary of a run/resume outcome."""
2699+
return {
2700+
"run_id": state.run_id,
2701+
"workflow_id": state.workflow_id,
2702+
"status": state.status.value,
2703+
"current_step_id": state.current_step_id,
2704+
"current_step_index": state.current_step_index,
2705+
}
2706+
2707+
2708+
def _emit_workflow_json(payload: dict[str, Any]) -> None:
2709+
"""Write a workflow payload as machine-readable JSON to stdout.
2710+
2711+
Uses the builtin ``print`` rather than ``console.print`` so Rich
2712+
markup interpretation, syntax highlighting, and line-wrapping can
2713+
never alter the emitted JSON.
2714+
"""
2715+
print(json.dumps(payload, indent=2))
2716+
2717+
2718+
@contextlib.contextmanager
2719+
def _stdout_to_stderr_when(active: bool):
2720+
"""Redirect everything written to stdout onto stderr while *active*.
2721+
2722+
Suppressing the banner and the step-start callback is not enough to
2723+
keep a ``--json`` stream clean: individual steps may still write to
2724+
stdout while the engine runs — the gate step prints its prompt,
2725+
and the prompt step runs a subprocess that inherits the process's
2726+
stdout file descriptor. Either would corrupt the single JSON object.
2727+
2728+
Redirecting at the file-descriptor level (``dup2``) captures both
2729+
Python-level writes and inherited-fd subprocess output, so step
2730+
progress lands on stderr (still visible to a human) while stdout
2731+
carries only the emitted JSON. A no-op when *active* is false.
2732+
"""
2733+
if not active:
2734+
yield
2735+
return
2736+
sys.stdout.flush()
2737+
saved_stdout_fd = os.dup(1)
2738+
try:
2739+
os.dup2(2, 1) # fd 1 (stdout) now points at fd 2 (stderr)
2740+
with contextlib.redirect_stdout(sys.stderr):
2741+
yield
2742+
finally:
2743+
sys.stdout.flush()
2744+
os.dup2(saved_stdout_fd, 1) # restore the real stdout
2745+
os.close(saved_stdout_fd)
2746+
2747+
26962748
@workflow_app.command("run")
26972749
def workflow_run(
26982750
source: str = typer.Argument(..., help="Workflow ID or YAML file path"),
26992751
input_values: list[str] | None = typer.Option(
27002752
None, "--input", "-i", help="Input values as key=value pairs"
27012753
),
2754+
json_output: bool = typer.Option(
2755+
False,
2756+
"--json",
2757+
help="Emit the run outcome as a single JSON object instead of formatted text.",
2758+
),
27022759
):
27032760
"""Run a workflow from an installed ID or local YAML path."""
27042761
from .workflows.engine import WorkflowEngine
@@ -2721,7 +2778,8 @@ def workflow_run(
27212778
project_root = _require_specify_project()
27222779

27232780
engine = WorkflowEngine(project_root)
2724-
engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")
2781+
if not json_output:
2782+
engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")
27252783

27262784
try:
27272785
definition = engine.load_workflow(source_path if is_file_source else source)
@@ -2743,18 +2801,24 @@ def workflow_run(
27432801
# Parse inputs
27442802
inputs = _parse_input_values(input_values)
27452803

2746-
console.print(f"\n[bold cyan]Running workflow:[/bold cyan] {definition.name} ({definition.id})")
2747-
console.print(f"[dim]Version: {definition.version}[/dim]\n")
2804+
if not json_output:
2805+
console.print(f"\n[bold cyan]Running workflow:[/bold cyan] {definition.name} ({definition.id})")
2806+
console.print(f"[dim]Version: {definition.version}[/dim]\n")
27482807

27492808
try:
2750-
state = engine.execute(definition, inputs)
2809+
with _stdout_to_stderr_when(json_output):
2810+
state = engine.execute(definition, inputs)
27512811
except ValueError as exc:
27522812
console.print(f"[red]Error:[/red] {exc}")
27532813
raise typer.Exit(1)
27542814
except Exception as exc:
27552815
console.print(f"[red]Workflow failed:[/red] {exc}")
27562816
raise typer.Exit(1)
27572817

2818+
if json_output:
2819+
_emit_workflow_json(_workflow_run_payload(state))
2820+
return
2821+
27582822
status_colors = {
27592823
"completed": "green",
27602824
"paused": "yellow",
@@ -2775,18 +2839,25 @@ def workflow_resume(
27752839
input_values: list[str] | None = typer.Option(
27762840
None, "--input", "-i", help="Updated input values as key=value pairs"
27772841
),
2842+
json_output: bool = typer.Option(
2843+
False,
2844+
"--json",
2845+
help="Emit the resume outcome as a single JSON object instead of formatted text.",
2846+
),
27782847
):
27792848
"""Resume a paused or failed workflow run."""
27802849
from .workflows.engine import WorkflowEngine
27812850

27822851
project_root = _require_specify_project()
27832852
engine = WorkflowEngine(project_root)
2784-
engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")
2853+
if not json_output:
2854+
engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")
27852855

27862856
inputs = _parse_input_values(input_values)
27872857

27882858
try:
2789-
state = engine.resume(run_id, inputs or None)
2859+
with _stdout_to_stderr_when(json_output):
2860+
state = engine.resume(run_id, inputs or None)
27902861
except FileNotFoundError:
27912862
console.print(f"[red]Error:[/red] Run not found: {run_id}")
27922863
raise typer.Exit(1)
@@ -2797,6 +2868,10 @@ def workflow_resume(
27972868
console.print(f"[red]Resume failed:[/red] {exc}")
27982869
raise typer.Exit(1)
27992870

2871+
if json_output:
2872+
_emit_workflow_json(_workflow_run_payload(state))
2873+
return
2874+
28002875
status_colors = {
28012876
"completed": "green",
28022877
"paused": "yellow",
@@ -2810,6 +2885,11 @@ def workflow_resume(
28102885
@workflow_app.command("status")
28112886
def workflow_status(
28122887
run_id: str | None = typer.Argument(None, help="Run ID to inspect (shows all if omitted)"),
2888+
json_output: bool = typer.Option(
2889+
False,
2890+
"--json",
2891+
help="Emit run status as a single JSON object instead of formatted text.",
2892+
),
28132893
):
28142894
"""Show workflow run status."""
28152895
from .workflows.engine import WorkflowEngine
@@ -2825,6 +2905,21 @@ def workflow_status(
28252905
console.print(f"[red]Error:[/red] Run not found: {run_id}")
28262906
raise typer.Exit(1)
28272907

2908+
if json_output:
2909+
# Build on the shared run/resume payload so the common fields
2910+
# (including current_step_index) stay identical across commands.
2911+
payload = {
2912+
**_workflow_run_payload(state),
2913+
"created_at": state.created_at,
2914+
"updated_at": state.updated_at,
2915+
"steps": {
2916+
sid: sd.get("status", "unknown")
2917+
for sid, sd in state.step_results.items()
2918+
},
2919+
}
2920+
_emit_workflow_json(payload)
2921+
return
2922+
28282923
status_colors = {
28292924
"completed": "green",
28302925
"paused": "yellow",
@@ -2852,6 +2947,22 @@ def workflow_status(
28522947
console.print(f" [{sc}]●[/{sc}] {step_id}: {s}")
28532948
else:
28542949
runs = engine.list_runs()
2950+
2951+
if json_output:
2952+
payload = {
2953+
"runs": [
2954+
{
2955+
"run_id": r["run_id"],
2956+
"workflow_id": r.get("workflow_id"),
2957+
"status": r.get("status", "unknown"),
2958+
"updated_at": r.get("updated_at"),
2959+
}
2960+
for r in runs
2961+
]
2962+
}
2963+
_emit_workflow_json(payload)
2964+
return
2965+
28552966
if not runs:
28562967
console.print("[yellow]No workflow runs found.[/yellow]")
28572968
return

0 commit comments

Comments
 (0)