Skip to content

Commit 90e40f7

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). JSON is written with plain stdout so it is never altered by Rich markup, highlighting, or wrapping. Default human-readable output is unchanged when `--json` is omitted, and exit codes are unchanged. Mirrors the existing `specify version --features --json` precedent. Closes #2811. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ed10b32 commit 90e40f7

2 files changed

Lines changed: 175 additions & 4 deletions

File tree

src/specify_cli/__init__.py

Lines changed: 73 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4174,19 +4174,36 @@ 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+
41774188
@workflow_app.command("run")
41784189
def workflow_run(
41794190
source: str = typer.Argument(..., help="Workflow ID or YAML file path"),
41804191
input_values: list[str] | None = typer.Option(
41814192
None, "--input", "-i", help="Input values as key=value pairs"
41824193
),
4194+
json_output: bool = typer.Option(
4195+
False,
4196+
"--json",
4197+
help="Emit the run outcome as a single JSON object instead of formatted text.",
4198+
),
41834199
):
41844200
"""Run a workflow from an installed ID or local YAML path."""
41854201
from .workflows.engine import WorkflowEngine
41864202

41874203
project_root = _require_specify_project()
41884204
engine = WorkflowEngine(project_root)
4189-
engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")
4205+
if not json_output:
4206+
engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")
41904207

41914208
try:
41924209
definition = engine.load_workflow(source)
@@ -4215,8 +4232,9 @@ def workflow_run(
42154232
key, _, value = kv.partition("=")
42164233
inputs[key.strip()] = value.strip()
42174234

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")
4235+
if not json_output:
4236+
console.print(f"\n[bold cyan]Running workflow:[/bold cyan] {definition.name} ({definition.id})")
4237+
console.print(f"[dim]Version: {definition.version}[/dim]\n")
42204238

42214239
try:
42224240
state = engine.execute(definition, inputs)
@@ -4227,6 +4245,10 @@ def workflow_run(
42274245
console.print(f"[red]Workflow failed:[/red] {exc}")
42284246
raise typer.Exit(1)
42294247

4248+
if json_output:
4249+
print(json.dumps(_workflow_run_payload(state), indent=2))
4250+
return
4251+
42304252
status_colors = {
42314253
"completed": "green",
42324254
"paused": "yellow",
@@ -4244,13 +4266,19 @@ def workflow_run(
42444266
@workflow_app.command("resume")
42454267
def workflow_resume(
42464268
run_id: str = typer.Argument(..., help="Run ID to resume"),
4269+
json_output: bool = typer.Option(
4270+
False,
4271+
"--json",
4272+
help="Emit the resume outcome as a single JSON object instead of formatted text.",
4273+
),
42474274
):
42484275
"""Resume a paused or failed workflow run."""
42494276
from .workflows.engine import WorkflowEngine
42504277

42514278
project_root = _require_specify_project()
42524279
engine = WorkflowEngine(project_root)
4253-
engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")
4280+
if not json_output:
4281+
engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")
42544282

42554283
try:
42564284
state = engine.resume(run_id)
@@ -4264,6 +4292,10 @@ def workflow_resume(
42644292
console.print(f"[red]Resume failed:[/red] {exc}")
42654293
raise typer.Exit(1)
42664294

4295+
if json_output:
4296+
print(json.dumps(_workflow_run_payload(state), indent=2))
4297+
return
4298+
42674299
status_colors = {
42684300
"completed": "green",
42694301
"paused": "yellow",
@@ -4277,6 +4309,11 @@ def workflow_resume(
42774309
@workflow_app.command("status")
42784310
def workflow_status(
42794311
run_id: str | None = typer.Argument(None, help="Run ID to inspect (shows all if omitted)"),
4312+
json_output: bool = typer.Option(
4313+
False,
4314+
"--json",
4315+
help="Emit run status as a single JSON object instead of formatted text.",
4316+
),
42804317
):
42814318
"""Show workflow run status."""
42824319
from .workflows.engine import WorkflowEngine
@@ -4292,6 +4329,22 @@ def workflow_status(
42924329
console.print(f"[red]Error:[/red] Run not found: {run_id}")
42934330
raise typer.Exit(1)
42944331

4332+
if json_output:
4333+
payload = {
4334+
"run_id": state.run_id,
4335+
"workflow_id": state.workflow_id,
4336+
"status": state.status.value,
4337+
"created_at": state.created_at,
4338+
"updated_at": state.updated_at,
4339+
"current_step_id": state.current_step_id,
4340+
"steps": {
4341+
sid: sd.get("status", "unknown")
4342+
for sid, sd in state.step_results.items()
4343+
},
4344+
}
4345+
print(json.dumps(payload, indent=2))
4346+
return
4347+
42954348
status_colors = {
42964349
"completed": "green",
42974350
"paused": "yellow",
@@ -4319,6 +4372,22 @@ def workflow_status(
43194372
console.print(f" [{sc}]●[/{sc}] {step_id}: {s}")
43204373
else:
43214374
runs = engine.list_runs()
4375+
4376+
if json_output:
4377+
payload = {
4378+
"runs": [
4379+
{
4380+
"run_id": r["run_id"],
4381+
"workflow_id": r.get("workflow_id"),
4382+
"status": r.get("status", "unknown"),
4383+
"updated_at": r.get("updated_at"),
4384+
}
4385+
for r in runs
4386+
]
4387+
}
4388+
print(json.dumps(payload, indent=2))
4389+
return
4390+
43224391
if not runs:
43234392
console.print("[yellow]No workflow runs found.[/yellow]")
43244393
return

tests/test_workflows.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2726,3 +2726,105 @@ 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+
2793+
def test_run_default_output_is_human_not_json(self, project_dir):
2794+
wf = self._write_wf(project_dir, self._WF_DONE, "done2")
2795+
result = self._invoke(project_dir, ["workflow", "run", str(wf)])
2796+
assert result.exit_code == 0
2797+
assert "Running workflow" in result.stdout
2798+
with pytest.raises(json.JSONDecodeError):
2799+
json.loads(result.stdout)
2800+
2801+
def test_status_json_single_and_list(self, project_dir):
2802+
wf = self._write_wf(project_dir, self._WF, "gated2")
2803+
run = json.loads(
2804+
self._invoke(project_dir, ["workflow", "run", str(wf), "--json"]).stdout
2805+
)
2806+
rid = run["run_id"]
2807+
2808+
single = json.loads(
2809+
self._invoke(project_dir, ["workflow", "status", rid, "--json"]).stdout
2810+
)
2811+
assert single["run_id"] == rid
2812+
assert single["status"] == "paused"
2813+
assert single["steps"]["ask"] == "paused"
2814+
2815+
listing = json.loads(
2816+
self._invoke(project_dir, ["workflow", "status", "--json"]).stdout
2817+
)
2818+
assert any(r["run_id"] == rid for r in listing["runs"])
2819+
2820+
def test_resume_json(self, project_dir):
2821+
wf = self._write_wf(project_dir, self._WF, "gated3")
2822+
rid = json.loads(
2823+
self._invoke(project_dir, ["workflow", "run", str(wf), "--json"]).stdout
2824+
)["run_id"]
2825+
# Non-interactive resume re-runs the gate, which pauses again.
2826+
resumed = json.loads(
2827+
self._invoke(project_dir, ["workflow", "resume", rid, "--json"]).stdout
2828+
)
2829+
assert resumed["run_id"] == rid
2830+
assert resumed["status"] == "paused"

0 commit comments

Comments
 (0)