Skip to content

Commit c734b8e

Browse files
doquanghuyclaude
andcommitted
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>
1 parent ed10b32 commit c734b8e

3 files changed

Lines changed: 214 additions & 4 deletions

File tree

docs/reference/workflows.md

Lines changed: 17 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,14 @@ 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 wf.yml --json
28+
# {"run_id": "662bf791", "workflow_id": "wf", "status": "paused",
29+
# "current_step_id": "review", "current_step_index": 0}
30+
```
31+
2332
> **Note:** All workflow commands require a project already initialized with `specify init`.
2433
2534
## Resume a Workflow
@@ -28,6 +37,10 @@ specify workflow run speckit -i spec="Build a kanban board with drag-and-drop ta
2837
specify workflow resume <run_id>
2938
```
3039

40+
| Option | Description |
41+
| ------------------- | -------------------------------------------------------- |
42+
| `--json` | Emit the resume outcome as a single JSON object |
43+
3144
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.
3245

3346
## Workflow Status
@@ -36,6 +49,10 @@ Resumes a paused or failed workflow run from the exact step where it stopped. Us
3649
specify workflow status [<run_id>]
3750
```
3851

52+
| Option | Description |
53+
| ------------------- | -------------------------------------------------------- |
54+
| `--json` | Emit run status (or the runs list) as a JSON object |
55+
3956
Shows the status of a specific run, or lists all runs if no ID is given. Run states: `created`, `running`, `completed`, `paused`, `failed`, `aborted`.
4057

4158
## List Installed Workflows

src/specify_cli/__init__.py

Lines changed: 83 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4174,19 +4174,46 @@ def extension_set_priority(
41744174
workflow_app.add_typer(workflow_catalog_app, name="catalog")
41754175

41764176

4177+
def _workflow_run_payload(state: Any) -> dict[str, Any]:
4178+
"""Machine-readable summary of a run/resume outcome."""
4179+
return {
4180+
"run_id": state.run_id,
4181+
"workflow_id": state.workflow_id,
4182+
"status": state.status.value,
4183+
"current_step_id": state.current_step_id,
4184+
"current_step_index": state.current_step_index,
4185+
}
4186+
4187+
4188+
def _emit_workflow_json(payload: dict[str, Any]) -> None:
4189+
"""Write a workflow payload as machine-readable JSON to stdout.
4190+
4191+
Uses the builtin ``print`` rather than ``console.print`` so Rich
4192+
markup interpretation, syntax highlighting, and line-wrapping can
4193+
never alter the emitted JSON.
4194+
"""
4195+
print(json.dumps(payload, indent=2))
4196+
4197+
41774198
@workflow_app.command("run")
41784199
def workflow_run(
41794200
source: str = typer.Argument(..., help="Workflow ID or YAML file path"),
41804201
input_values: list[str] | None = typer.Option(
41814202
None, "--input", "-i", help="Input values as key=value pairs"
41824203
),
4204+
json_output: bool = typer.Option(
4205+
False,
4206+
"--json",
4207+
help="Emit the run outcome as a single JSON object instead of formatted text.",
4208+
),
41834209
):
41844210
"""Run a workflow from an installed ID or local YAML path."""
41854211
from .workflows.engine import WorkflowEngine
41864212

41874213
project_root = _require_specify_project()
41884214
engine = WorkflowEngine(project_root)
4189-
engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")
4215+
if not json_output:
4216+
engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")
41904217

41914218
try:
41924219
definition = engine.load_workflow(source)
@@ -4215,8 +4242,9 @@ def workflow_run(
42154242
key, _, value = kv.partition("=")
42164243
inputs[key.strip()] = value.strip()
42174244

4218-
console.print(f"\n[bold cyan]Running workflow:[/bold cyan] {definition.name} ({definition.id})")
4219-
console.print(f"[dim]Version: {definition.version}[/dim]\n")
4245+
if not json_output:
4246+
console.print(f"\n[bold cyan]Running workflow:[/bold cyan] {definition.name} ({definition.id})")
4247+
console.print(f"[dim]Version: {definition.version}[/dim]\n")
42204248

42214249
try:
42224250
state = engine.execute(definition, inputs)
@@ -4227,6 +4255,10 @@ def workflow_run(
42274255
console.print(f"[red]Workflow failed:[/red] {exc}")
42284256
raise typer.Exit(1)
42294257

4258+
if json_output:
4259+
_emit_workflow_json(_workflow_run_payload(state))
4260+
return
4261+
42304262
status_colors = {
42314263
"completed": "green",
42324264
"paused": "yellow",
@@ -4244,13 +4276,19 @@ def workflow_run(
42444276
@workflow_app.command("resume")
42454277
def workflow_resume(
42464278
run_id: str = typer.Argument(..., help="Run ID to resume"),
4279+
json_output: bool = typer.Option(
4280+
False,
4281+
"--json",
4282+
help="Emit the resume outcome as a single JSON object instead of formatted text.",
4283+
),
42474284
):
42484285
"""Resume a paused or failed workflow run."""
42494286
from .workflows.engine import WorkflowEngine
42504287

42514288
project_root = _require_specify_project()
42524289
engine = WorkflowEngine(project_root)
4253-
engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")
4290+
if not json_output:
4291+
engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")
42544292

42554293
try:
42564294
state = engine.resume(run_id)
@@ -4264,6 +4302,10 @@ def workflow_resume(
42644302
console.print(f"[red]Resume failed:[/red] {exc}")
42654303
raise typer.Exit(1)
42664304

4305+
if json_output:
4306+
_emit_workflow_json(_workflow_run_payload(state))
4307+
return
4308+
42674309
status_colors = {
42684310
"completed": "green",
42694311
"paused": "yellow",
@@ -4277,6 +4319,11 @@ def workflow_resume(
42774319
@workflow_app.command("status")
42784320
def workflow_status(
42794321
run_id: str | None = typer.Argument(None, help="Run ID to inspect (shows all if omitted)"),
4322+
json_output: bool = typer.Option(
4323+
False,
4324+
"--json",
4325+
help="Emit run status as a single JSON object instead of formatted text.",
4326+
),
42804327
):
42814328
"""Show workflow run status."""
42824329
from .workflows.engine import WorkflowEngine
@@ -4292,6 +4339,22 @@ def workflow_status(
42924339
console.print(f"[red]Error:[/red] Run not found: {run_id}")
42934340
raise typer.Exit(1)
42944341

4342+
if json_output:
4343+
payload = {
4344+
"run_id": state.run_id,
4345+
"workflow_id": state.workflow_id,
4346+
"status": state.status.value,
4347+
"created_at": state.created_at,
4348+
"updated_at": state.updated_at,
4349+
"current_step_id": state.current_step_id,
4350+
"steps": {
4351+
sid: sd.get("status", "unknown")
4352+
for sid, sd in state.step_results.items()
4353+
},
4354+
}
4355+
_emit_workflow_json(payload)
4356+
return
4357+
42954358
status_colors = {
42964359
"completed": "green",
42974360
"paused": "yellow",
@@ -4319,6 +4382,22 @@ def workflow_status(
43194382
console.print(f" [{sc}]●[/{sc}] {step_id}: {s}")
43204383
else:
43214384
runs = engine.list_runs()
4385+
4386+
if json_output:
4387+
payload = {
4388+
"runs": [
4389+
{
4390+
"run_id": r["run_id"],
4391+
"workflow_id": r.get("workflow_id"),
4392+
"status": r.get("status", "unknown"),
4393+
"updated_at": r.get("updated_at"),
4394+
}
4395+
for r in runs
4396+
]
4397+
}
4398+
_emit_workflow_json(payload)
4399+
return
4400+
43224401
if not runs:
43234402
console.print("[yellow]No workflow runs found.[/yellow]")
43244403
return

tests/test_workflows.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2726,3 +2726,117 @@ def test_switch_workflow(self, project_dir):
27262726
assert state.status == RunStatus.COMPLETED
27272727
assert "do-plan" in state.step_results
27282728
assert "do-specify" not in state.step_results
2729+
2730+
2731+
class TestWorkflowJsonOutput:
2732+
"""Test the --json machine-readable output for run/resume/status."""
2733+
2734+
_WF = """
2735+
schema_version: "1.0"
2736+
workflow:
2737+
id: "json-wf"
2738+
name: "JSON WF"
2739+
version: "1.0.0"
2740+
steps:
2741+
- id: ask
2742+
type: gate
2743+
message: "Review"
2744+
options: [approve, reject]
2745+
- id: after
2746+
type: shell
2747+
run: "echo done"
2748+
"""
2749+
2750+
_WF_DONE = """
2751+
schema_version: "1.0"
2752+
workflow:
2753+
id: "json-done"
2754+
name: "JSON Done"
2755+
version: "1.0.0"
2756+
steps:
2757+
- id: only
2758+
type: shell
2759+
run: "echo done"
2760+
"""
2761+
2762+
def _write_wf(self, project_dir, text, name):
2763+
path = project_dir / f"{name}.yml"
2764+
path.write_text(text, encoding="utf-8")
2765+
return path
2766+
2767+
def _invoke(self, project_dir, args):
2768+
from typer.testing import CliRunner
2769+
from unittest.mock import patch
2770+
from specify_cli import app
2771+
2772+
runner = CliRunner()
2773+
with patch.object(Path, "cwd", return_value=project_dir):
2774+
return runner.invoke(app, args, catch_exceptions=False)
2775+
2776+
def test_run_json_completed(self, project_dir):
2777+
wf = self._write_wf(project_dir, self._WF_DONE, "done")
2778+
result = self._invoke(project_dir, ["workflow", "run", str(wf), "--json"])
2779+
assert result.exit_code == 0
2780+
payload = json.loads(result.stdout)
2781+
assert payload["workflow_id"] == "json-done"
2782+
assert payload["status"] == "completed"
2783+
assert "run_id" in payload
2784+
2785+
def test_run_json_paused(self, project_dir):
2786+
wf = self._write_wf(project_dir, self._WF, "gated")
2787+
result = self._invoke(project_dir, ["workflow", "run", str(wf), "--json"])
2788+
assert result.exit_code == 0
2789+
payload = json.loads(result.stdout)
2790+
assert payload["status"] == "paused"
2791+
assert payload["current_step_id"] == "ask"
2792+
assert payload["current_step_index"] == 0
2793+
2794+
def test_run_json_output_has_no_markup_or_ansi(self, project_dir):
2795+
wf = self._write_wf(project_dir, self._WF_DONE, "clean")
2796+
out = self._invoke(
2797+
project_dir, ["workflow", "run", str(wf), "--json"]
2798+
).stdout
2799+
# Machine output must be exactly the JSON object: no Rich markup
2800+
# tags and no ANSI escape sequences leaking in.
2801+
assert "\x1b[" not in out
2802+
assert "[/" not in out
2803+
assert out.strip() == json.dumps(json.loads(out), indent=2)
2804+
2805+
def test_run_default_output_is_human_not_json(self, project_dir):
2806+
wf = self._write_wf(project_dir, self._WF_DONE, "done2")
2807+
result = self._invoke(project_dir, ["workflow", "run", str(wf)])
2808+
assert result.exit_code == 0
2809+
assert "Running workflow" in result.stdout
2810+
with pytest.raises(json.JSONDecodeError):
2811+
json.loads(result.stdout)
2812+
2813+
def test_status_json_single_and_list(self, project_dir):
2814+
wf = self._write_wf(project_dir, self._WF, "gated2")
2815+
run = json.loads(
2816+
self._invoke(project_dir, ["workflow", "run", str(wf), "--json"]).stdout
2817+
)
2818+
rid = run["run_id"]
2819+
2820+
single = json.loads(
2821+
self._invoke(project_dir, ["workflow", "status", rid, "--json"]).stdout
2822+
)
2823+
assert single["run_id"] == rid
2824+
assert single["status"] == "paused"
2825+
assert single["steps"]["ask"] == "paused"
2826+
2827+
listing = json.loads(
2828+
self._invoke(project_dir, ["workflow", "status", "--json"]).stdout
2829+
)
2830+
assert any(r["run_id"] == rid for r in listing["runs"])
2831+
2832+
def test_resume_json(self, project_dir):
2833+
wf = self._write_wf(project_dir, self._WF, "gated3")
2834+
rid = json.loads(
2835+
self._invoke(project_dir, ["workflow", "run", str(wf), "--json"]).stdout
2836+
)["run_id"]
2837+
# Non-interactive resume re-runs the gate, which pauses again.
2838+
resumed = json.loads(
2839+
self._invoke(project_dir, ["workflow", "resume", rid, "--json"]).stdout
2840+
)
2841+
assert resumed["run_id"] == rid
2842+
assert resumed["status"] == "paused"

0 commit comments

Comments
 (0)