mirror of
https://github.com/github/spec-kit.git
synced 2026-08-03 06:26:30 +08:00
feat: bind gate verdict to workflow input via verdict_input (#3725)
* feat: bind gate verdict to workflow input via verdict_input Add an optional `verdict_input` field to gate steps that lets an external system supply a verdict through a declared workflow input instead of an interactive TTY prompt. When the referenced input carries a non-empty string value that matches one of the gate's `options` (case-insensitive), the gate auto-decides, records the matched spelling in `output.choice`, and applies the existing `on_reject` / abort / skip / retry semantics. If the value is present but does not match an option, or is a non-string, the gate fails immediately with a clear error message. When the input is absent, null, or empty, the gate falls back to today's TTY-prompt-or-pause behaviour unchanged. The engine now persists `result.error` alongside each step's status and output so that failed-step error messages survive across runs. The CLI (`workflow run` and `workflow resume`) surfaces these persisted errors after a failed or aborted run. `validate_workflow` cross-references `verdict_input` against the workflow's declared inputs block and reports an error for undeclared names, consistent with the existing `wait_for` id cross-check. Closes discussion: https://github.com/github/spec-kit/discussions/3717 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Fix workflow JSON error payloads Include persisted step errors in _workflow_run_payload so workflow run/resume/status --json all surface failure reasons consistently. Add JSON-path tests for failed and successful runs. Assisted-by: GitHub Copilot (model: gpt-5.3-codex, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix(workflows): reject verdict inputs in fan-out Fan-out items share workflow inputs and cannot safely consume a bound gate verdict. Reject verdict_input bindings during validation and at runtime while preserving unbound gates. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: update workflow command handling Assisted-by: GitHub Copilot (model: gpt-5.3-codex, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Markus <markus@example.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -889,6 +889,18 @@ def _require_specify_project(*args, **kwargs):
|
||||
return project_root
|
||||
|
||||
|
||||
def _failed_step_error(state: Any) -> str | None:
|
||||
"""Terminal error for a failed/aborted run, if any.
|
||||
|
||||
Returns the run-level error persisted by the engine at the moment
|
||||
the run terminated. Returns ``None`` for non-terminal statuses so
|
||||
the caller can print unconditionally.
|
||||
"""
|
||||
if getattr(state.status, "value", state.status) not in ("failed", "aborted"):
|
||||
return None
|
||||
return getattr(state, "error", None)
|
||||
|
||||
|
||||
def _workflow_run_payload(state: Any) -> dict[str, Any]:
|
||||
"""Machine-readable summary of a run/resume outcome."""
|
||||
payload = {
|
||||
@@ -901,6 +913,9 @@ def _workflow_run_payload(state: Any) -> dict[str, Any]:
|
||||
gate = _gate_outcome(state)
|
||||
if gate is not None:
|
||||
payload["gate"] = gate
|
||||
error = _failed_step_error(state)
|
||||
if error is not None:
|
||||
payload["error"] = error
|
||||
return payload
|
||||
|
||||
|
||||
@@ -1161,6 +1176,10 @@ def workflow_run(
|
||||
console.print(f"\n[{color}]Status: {state.status.value}[/{color}]")
|
||||
console.print(f"[dim]Run ID: {state.run_id}[/dim]")
|
||||
|
||||
err_msg = _failed_step_error(state)
|
||||
if err_msg:
|
||||
console.print(f"[red]Error:[/red] {_escape_markup(err_msg)}")
|
||||
|
||||
if state.status.value == "paused":
|
||||
console.print(f"\nResume with: [cyan]specify workflow resume {state.run_id}[/cyan]")
|
||||
|
||||
@@ -1271,6 +1290,10 @@ def workflow_resume(
|
||||
color = status_colors.get(state.status.value, "white")
|
||||
console.print(f"\n[{color}]Status: {state.status.value}[/{color}]")
|
||||
|
||||
err_msg = _failed_step_error(state)
|
||||
if err_msg:
|
||||
console.print(f"[red]Error:[/red] {_escape_markup(err_msg)}")
|
||||
|
||||
raise typer.Exit(_run_outcome_exit_code(state.status.value))
|
||||
|
||||
|
||||
@@ -1338,6 +1361,10 @@ def workflow_status(
|
||||
if state.current_step_id:
|
||||
console.print(f" Current: {state.current_step_id}")
|
||||
|
||||
err_msg = _failed_step_error(state)
|
||||
if err_msg:
|
||||
console.print(f" [red]Error: {_escape_markup(err_msg)}[/red]")
|
||||
|
||||
if state.step_results:
|
||||
console.print(f"\n [bold]Steps ({len(state.step_results)}):[/bold]")
|
||||
for step_id, step_data in state.step_results.items():
|
||||
|
||||
@@ -56,6 +56,9 @@ class StepContext:
|
||||
#: Current fan-out item (set only inside fan-out iterations).
|
||||
item: Any = None
|
||||
|
||||
#: Whether the current step is executing inside a fan-out template.
|
||||
inside_fan_out: bool = False
|
||||
|
||||
#: Fan-in aggregated results (set only for fan-in steps).
|
||||
fan_in: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@@ -308,7 +308,15 @@ def validate_workflow(definition: WorkflowDefinition) -> list[str]:
|
||||
errors.append("Workflow has no steps defined.")
|
||||
|
||||
seen_ids: set[str] = set()
|
||||
_validate_steps(definition.steps, seen_ids, errors)
|
||||
# ``input_names`` is the set of declared workflow input names — used by
|
||||
# ``_validate_steps`` to cross-reference gate ``verdict_input`` bindings.
|
||||
# ``None`` means the inputs block itself is malformed (already reported
|
||||
# above); the cross-check is then disabled so one authoring mistake does
|
||||
# not cascade into N spurious "undeclared" errors.
|
||||
input_names: set[str] | None = (
|
||||
set(definition.inputs) if isinstance(definition.inputs, dict) else None
|
||||
)
|
||||
_validate_steps(definition.steps, seen_ids, errors, input_names)
|
||||
|
||||
return errors
|
||||
|
||||
@@ -317,8 +325,16 @@ def _validate_steps(
|
||||
steps: list[dict[str, Any]],
|
||||
seen_ids: set[str],
|
||||
errors: list[str],
|
||||
input_names: set[str] | None = None,
|
||||
inside_fan_out: bool = False,
|
||||
) -> None:
|
||||
"""Recursively validate a list of steps."""
|
||||
"""Recursively validate a list of steps.
|
||||
|
||||
``input_names`` is the set of declared workflow input names (or ``None``
|
||||
when the inputs block is malformed). ``inside_fan_out`` is threaded
|
||||
through nested control-flow steps so gate verdict bindings can be rejected
|
||||
anywhere inside a fan-out template.
|
||||
"""
|
||||
from . import STEP_REGISTRY
|
||||
|
||||
for step_config in steps:
|
||||
@@ -411,30 +427,73 @@ def _validate_steps(
|
||||
f"unknown or not-yet-declared step id {wid!r}."
|
||||
)
|
||||
|
||||
# Gate verdict_input: fan-out items cannot bind shared workflow inputs
|
||||
# as per-item verdicts. Outside fan-out, the binding must reference a
|
||||
# declared workflow input because ``_resolve_inputs`` drops undeclared
|
||||
# names at both initial run and resume. Only check a non-empty string;
|
||||
# malformed shapes are already reported by ``GateStep.validate()``.
|
||||
if step_type == "gate":
|
||||
verdict_input = step_config.get("verdict_input")
|
||||
if isinstance(verdict_input, str) and verdict_input:
|
||||
if inside_fan_out:
|
||||
errors.append(
|
||||
f"Gate step {step_id!r}: 'verdict_input' is not "
|
||||
"supported inside fan-out templates."
|
||||
)
|
||||
elif input_names is not None and verdict_input not in input_names:
|
||||
errors.append(
|
||||
f"Gate step {step_id!r}: 'verdict_input' references "
|
||||
f"undeclared input {verdict_input!r}."
|
||||
)
|
||||
|
||||
# Recursively validate nested steps
|
||||
for nested_key in ("then", "else", "steps"):
|
||||
nested = step_config.get(nested_key)
|
||||
if isinstance(nested, list):
|
||||
_validate_steps(nested, seen_ids, errors)
|
||||
_validate_steps(
|
||||
nested,
|
||||
seen_ids,
|
||||
errors,
|
||||
input_names,
|
||||
inside_fan_out=inside_fan_out,
|
||||
)
|
||||
|
||||
# Validate switch cases
|
||||
cases = step_config.get("cases")
|
||||
if isinstance(cases, dict):
|
||||
for _case_key, case_steps in cases.items():
|
||||
if isinstance(case_steps, list):
|
||||
_validate_steps(case_steps, seen_ids, errors)
|
||||
_validate_steps(
|
||||
case_steps,
|
||||
seen_ids,
|
||||
errors,
|
||||
input_names,
|
||||
inside_fan_out=inside_fan_out,
|
||||
)
|
||||
|
||||
# Validate switch default
|
||||
default = step_config.get("default")
|
||||
if isinstance(default, list):
|
||||
_validate_steps(default, seen_ids, errors)
|
||||
_validate_steps(
|
||||
default,
|
||||
seen_ids,
|
||||
errors,
|
||||
input_names,
|
||||
inside_fan_out=inside_fan_out,
|
||||
)
|
||||
|
||||
# Validate fan-out nested step (template — not added to seen_ids
|
||||
# since the engine generates parentId:templateId:index at runtime)
|
||||
fan_step = step_config.get("step")
|
||||
if isinstance(fan_step, dict):
|
||||
fan_errors: list[str] = []
|
||||
_validate_steps([fan_step], set(), fan_errors)
|
||||
_validate_steps(
|
||||
[fan_step],
|
||||
set(),
|
||||
fan_errors,
|
||||
input_names,
|
||||
inside_fan_out=True,
|
||||
)
|
||||
errors.extend(fan_errors)
|
||||
|
||||
|
||||
@@ -560,6 +619,7 @@ class RunState:
|
||||
self.created_at = datetime.now(timezone.utc).isoformat()
|
||||
self.updated_at = self.created_at
|
||||
self.log_entries: list[dict[str, Any]] = []
|
||||
self.error: str | None = None
|
||||
|
||||
@property
|
||||
def runs_dir(self) -> Path:
|
||||
@@ -614,6 +674,7 @@ class RunState:
|
||||
"workflow_dir": self.workflow_dir,
|
||||
"created_at": self.created_at,
|
||||
"updated_at": self.updated_at,
|
||||
"error": self.error,
|
||||
}
|
||||
self._atomic_write_json(runs_dir / "state.json", state_data)
|
||||
self._atomic_write_json(runs_dir / "inputs.json", {"inputs": self.inputs})
|
||||
@@ -707,6 +768,7 @@ class RunState:
|
||||
state.workflow_dir = state_data.get("workflow_dir")
|
||||
state.created_at = state_data.get("created_at", "")
|
||||
state.updated_at = state_data.get("updated_at", "")
|
||||
state.error = state_data.get("error")
|
||||
|
||||
inputs_path = runs_dir / "inputs.json"
|
||||
if inputs_path.exists():
|
||||
@@ -901,6 +963,7 @@ class WorkflowEngine:
|
||||
return state
|
||||
except Exception as exc:
|
||||
state.status = RunStatus.FAILED
|
||||
state.error = str(exc)
|
||||
state.append_log({"event": "workflow_failed", "error": str(exc)})
|
||||
state.save()
|
||||
raise
|
||||
@@ -959,6 +1022,7 @@ class WorkflowEngine:
|
||||
|
||||
from . import STEP_REGISTRY
|
||||
|
||||
state.error = None
|
||||
state.status = RunStatus.RUNNING
|
||||
state.save()
|
||||
|
||||
@@ -979,6 +1043,7 @@ class WorkflowEngine:
|
||||
return state
|
||||
except Exception as exc:
|
||||
state.status = RunStatus.FAILED
|
||||
state.error = str(exc)
|
||||
state.append_log({"event": "resume_failed", "error": str(exc)})
|
||||
state.save()
|
||||
raise
|
||||
@@ -1038,6 +1103,7 @@ class WorkflowEngine:
|
||||
step_impl = registry.get(step_type)
|
||||
if not step_impl:
|
||||
state.status = RunStatus.FAILED
|
||||
state.error = f"Unknown step type: {step_type!r}"
|
||||
state.append_log(
|
||||
{
|
||||
"event": "step_failed",
|
||||
@@ -1065,6 +1131,7 @@ class WorkflowEngine:
|
||||
or step_config.get("input", {}),
|
||||
"output": result.output,
|
||||
"status": result.status.value,
|
||||
"error": result.error,
|
||||
}
|
||||
self._record_result(context, state, step_id, step_data)
|
||||
|
||||
@@ -1090,6 +1157,7 @@ class WorkflowEngine:
|
||||
# is for transient/expected step failures only.
|
||||
if result.output.get("aborted"):
|
||||
state.status = RunStatus.ABORTED
|
||||
state.error = result.error
|
||||
state.append_log(
|
||||
{
|
||||
"event": "workflow_aborted",
|
||||
@@ -1132,6 +1200,7 @@ class WorkflowEngine:
|
||||
continue
|
||||
|
||||
state.status = RunStatus.FAILED
|
||||
state.error = result.error
|
||||
state.append_log(
|
||||
{
|
||||
"event": "step_failed",
|
||||
@@ -1305,11 +1374,18 @@ class WorkflowEngine:
|
||||
# Sequential path — identical to the historical behavior.
|
||||
if workers <= 1:
|
||||
results: list[Any] = []
|
||||
for item_idx, item_val in enumerate(items):
|
||||
context.item = item_val
|
||||
results.append(run_item(item_idx, context))
|
||||
if state.status in halting:
|
||||
break
|
||||
previous_item = context.item
|
||||
previous_inside_fan_out = context.inside_fan_out
|
||||
context.inside_fan_out = True
|
||||
try:
|
||||
for item_idx, item_val in enumerate(items):
|
||||
context.item = item_val
|
||||
results.append(run_item(item_idx, context))
|
||||
if state.status in halting:
|
||||
break
|
||||
finally:
|
||||
context.item = previous_item
|
||||
context.inside_fan_out = previous_inside_fan_out
|
||||
return results
|
||||
|
||||
# Concurrent path — bounded sliding window; results assembled in item order.
|
||||
@@ -1320,7 +1396,14 @@ class WorkflowEngine:
|
||||
# Each item runs against its own context copy so context.item is not
|
||||
# clobbered across threads; the shared steps dict is written only on the
|
||||
# disjoint parentId:templateId:index key (GIL-safe on distinct keys).
|
||||
return run_item(idx, dataclasses.replace(context, item=items[idx]))
|
||||
return run_item(
|
||||
idx,
|
||||
dataclasses.replace(
|
||||
context,
|
||||
item=items[idx],
|
||||
inside_fan_out=True,
|
||||
),
|
||||
)
|
||||
|
||||
def item_halt_status(idx: int) -> RunStatus | None:
|
||||
# If THIS item's own execution halted the run, return the resulting run
|
||||
@@ -1401,6 +1484,16 @@ class WorkflowEngine:
|
||||
# pool joined; restore the halting item's own outcome so the final run
|
||||
# status matches the sequential semantics.
|
||||
state.status = halted_status
|
||||
# Restore the halting item's error so it matches the terminal
|
||||
# status — a concurrent item may have overwritten state.error
|
||||
# before the pool joined. Assign unconditionally when a record
|
||||
# exists (even when the halting item's own error is falsy) so a
|
||||
# third-party step returning FAILED with no message never inherits
|
||||
# an unrelated concurrent item's error; this mirrors the sequential
|
||||
# path, which sets state.error = result.error verbatim.
|
||||
halt_rec = context.steps.get(item_id(halted_at))
|
||||
if isinstance(halt_rec, dict):
|
||||
state.error = halt_rec.get("error")
|
||||
return slots[: halted_at + 1]
|
||||
return slots[:collected]
|
||||
|
||||
|
||||
@@ -26,7 +26,8 @@ class GateStep(StepBase):
|
||||
later with ``specify workflow resume``.
|
||||
|
||||
The user's choice is stored in ``output.choice``. ``on_reject``
|
||||
controls abort / skip / retry behaviour.
|
||||
controls abort / skip / retry behaviour. ``verdict_input`` can name a
|
||||
workflow input to use as the choice when resuming non-interactively.
|
||||
"""
|
||||
|
||||
type_key = "gate"
|
||||
@@ -42,6 +43,8 @@ class GateStep(StepBase):
|
||||
|
||||
options = config.get("options", ["approve", "reject"])
|
||||
on_reject = config.get("on_reject", "abort")
|
||||
has_verdict_input = "verdict_input" in config
|
||||
verdict_input = config.get("verdict_input")
|
||||
|
||||
# ``validate`` rejects a non-list (or empty) ``options``, and requires
|
||||
# every option to be a string, but the engine does not auto-validate
|
||||
@@ -72,6 +75,26 @@ class GateStep(StepBase):
|
||||
},
|
||||
)
|
||||
|
||||
if has_verdict_input and (
|
||||
not isinstance(verdict_input, str) or not verdict_input
|
||||
):
|
||||
return StepResult(
|
||||
status=StepStatus.FAILED,
|
||||
error=(
|
||||
f"Gate step {config.get('id', '?')!r}: 'verdict_input' must be "
|
||||
"a non-empty string."
|
||||
),
|
||||
)
|
||||
|
||||
if has_verdict_input and context.inside_fan_out:
|
||||
return StepResult(
|
||||
status=StepStatus.FAILED,
|
||||
error=(
|
||||
f"Gate step {config.get('id', '?')!r}: 'verdict_input' is "
|
||||
"not supported inside fan-out templates."
|
||||
),
|
||||
)
|
||||
|
||||
show_file = config.get("show_file")
|
||||
if isinstance(show_file, str) and "{{" in show_file:
|
||||
show_file = evaluate_expression(show_file, context)
|
||||
@@ -90,16 +113,48 @@ class GateStep(StepBase):
|
||||
"choice": None,
|
||||
}
|
||||
|
||||
# Non-interactive: pause for later resume (the file is not read here)
|
||||
if not sys.stdin.isatty():
|
||||
return StepResult(status=StepStatus.PAUSED, output=output)
|
||||
choice: str | None = None
|
||||
bound_verdict_input: str | None = None
|
||||
if verdict_input is not None:
|
||||
value = context.inputs.get(verdict_input)
|
||||
if value is not None and value != "":
|
||||
if not isinstance(value, str):
|
||||
return StepResult(
|
||||
status=StepStatus.FAILED,
|
||||
output=output,
|
||||
error=(
|
||||
f"Gate step {config.get('id', '?')!r}: verdict input "
|
||||
f"{verdict_input!r} must be a string, got "
|
||||
f"{type(value).__name__}."
|
||||
),
|
||||
)
|
||||
choice = next(
|
||||
(option for option in options if option.lower() == value.lower()),
|
||||
None,
|
||||
)
|
||||
if choice is None:
|
||||
return StepResult(
|
||||
status=StepStatus.FAILED,
|
||||
output=output,
|
||||
error=(
|
||||
f"Gate step {config.get('id', '?')!r}: verdict input "
|
||||
f"{verdict_input!r} value {value!r} does not match any "
|
||||
"configured option."
|
||||
),
|
||||
)
|
||||
bound_verdict_input = verdict_input
|
||||
|
||||
# Interactive: prompt the user. ``show_file`` contents are folded
|
||||
# into the displayed message so the operator can review the
|
||||
# referenced material before choosing. Composing the prompt text
|
||||
# here keeps ``_prompt`` to its ``(message, options)`` contract, so
|
||||
# adding review material never widens the interactive seam.
|
||||
choice = self._prompt(self._compose_prompt(message, show_file), options)
|
||||
if choice is None:
|
||||
# Non-interactive: pause for later resume (the file is not read here)
|
||||
if not sys.stdin.isatty():
|
||||
return StepResult(status=StepStatus.PAUSED, output=output)
|
||||
|
||||
# Interactive: prompt the user. ``show_file`` contents are folded
|
||||
# into the displayed message so the operator can review the
|
||||
# referenced material before choosing. Composing the prompt text
|
||||
# here keeps ``_prompt`` to its ``(message, options)`` contract, so
|
||||
# adding review material never widens the interactive seam.
|
||||
choice = self._prompt(self._compose_prompt(message, show_file), options)
|
||||
output["choice"] = choice
|
||||
|
||||
# Match rejection case-insensitively. ``_prompt`` echoes the option's
|
||||
@@ -119,6 +174,8 @@ class GateStep(StepBase):
|
||||
)
|
||||
if on_reject == "retry":
|
||||
# Pause so the next resume re-executes this gate
|
||||
if bound_verdict_input is not None:
|
||||
context.inputs[bound_verdict_input] = ""
|
||||
return StepResult(status=StepStatus.PAUSED, output=output)
|
||||
# on_reject == "skip" → completed, downstream steps decide
|
||||
return StepResult(status=StepStatus.COMPLETED, output=output)
|
||||
@@ -234,6 +291,13 @@ class GateStep(StepBase):
|
||||
f"Gate step {config.get('id', '?')!r}: 'on_reject' must be "
|
||||
f"'abort', 'skip', or 'retry'."
|
||||
)
|
||||
if "verdict_input" in config and (
|
||||
not isinstance(config["verdict_input"], str) or not config["verdict_input"]
|
||||
):
|
||||
errors.append(
|
||||
f"Gate step {config.get('id', '?')!r}: 'verdict_input' must be "
|
||||
"a non-empty string."
|
||||
)
|
||||
# Only inspect option text when every option is a string; otherwise the
|
||||
# `o.lower()` below would raise AttributeError on a non-string option
|
||||
# (already reported above) and break validate_workflow's never-raise contract.
|
||||
|
||||
Reference in New Issue
Block a user