diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 186593000c..db071d989c 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -2747,13 +2747,37 @@ def _parse_input_values(input_values: list[str] | None) -> dict[str, Any]: def _workflow_run_payload(state: Any) -> dict[str, Any]: """Machine-readable summary of a run/resume outcome.""" - return { + payload = { "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, } + gate = _gate_outcome(state) + if gate is not None: + payload["gate"] = gate + return payload + + +def _gate_outcome(state: Any) -> dict[str, Any] | None: + """Gate detail for the structured outcome, if the run sits at a gate. + + A paused run is otherwise indistinguishable from any other pause in + the machine-readable payload; surfacing the gate's prompt, options, + and (after an interactive choice) the decision lets orchestrators + drive review gates without parsing the human-facing stream. + """ + step = (getattr(state, "step_results", None) or {}).get(state.current_step_id) + if not isinstance(step, dict) or step.get("type") != "gate": + return None + output = step.get("output") or {} + return { + "step_id": state.current_step_id, + "message": output.get("message"), + "options": output.get("options"), + "choice": output.get("choice"), + } def _emit_workflow_json(payload: dict[str, Any]) -> None: diff --git a/src/specify_cli/workflows/engine.py b/src/specify_cli/workflows/engine.py index d24bc29501..657bf32639 100644 --- a/src/specify_cli/workflows/engine.py +++ b/src/specify_cli/workflows/engine.py @@ -676,6 +676,7 @@ def _execute_steps( # Record step results — prefer resolved values from step output step_data = { + "type": step_type, "integration": result.output.get("integration") or step_config.get("integration") or context.default_integration, diff --git a/tests/test_workflows.py b/tests/test_workflows.py index 51da5cc86b..812013ca2b 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -3944,3 +3944,60 @@ def fake_open_url(url, timeout=None, extra_headers=None): asset_calls = [(url, h) for url, h in captured_urls if "releases/assets/" in url] assert len(asset_calls) >= 1 assert asset_calls[0][1] == {"Accept": "application/octet-stream"} + + +class TestWorkflowRunGateOutcomeJson: + """CLI-level tests: the --json payload surfaces gate pauses.""" + + _WF_GATE = """ +schema_version: "1.0" +workflow: + id: "gate-json" + name: "Gate JSON" + version: "1.0.0" +steps: + - id: review + type: gate + message: "Approve the thing?" + options: ["approve", "reject"] +""" + + _WF_PLAIN = """ +schema_version: "1.0" +workflow: + id: "plain-json" + name: "Plain JSON" + version: "1.0.0" +steps: + - id: fine + type: shell + run: "true" +""" + + def _run_json(self, tmp_path, monkeypatch, content): + import json as _json + from typer.testing import CliRunner + from specify_cli import app + + path = tmp_path / "wf.yml" + path.write_text(content, encoding="utf-8") + monkeypatch.chdir(tmp_path) + runner = CliRunner() + result = runner.invoke(app, ["workflow", "run", str(path), "--json"]) + return _json.loads(result.stdout) + + def test_gate_pause_carries_gate_block(self, tmp_path, monkeypatch): + # CliRunner stdin is not a TTY, so the gate pauses for resume. + payload = self._run_json(tmp_path, monkeypatch, self._WF_GATE) + assert payload["status"] == "paused" + assert payload["gate"] == { + "step_id": "review", + "message": "Approve the thing?", + "options": ["approve", "reject"], + "choice": None, + } + + def test_completed_run_has_no_gate_block(self, tmp_path, monkeypatch): + payload = self._run_json(tmp_path, monkeypatch, self._WF_PLAIN) + assert payload["status"] == "completed" + assert "gate" not in payload