Skip to content

Commit 1732b9b

Browse files
doquanghuyclaude
andauthored
feat(workflows): allow resume to accept updated workflow inputs (#2815)
`workflow resume` now accepts `--input key=value` (the same flag and parsing as `workflow run`, via a shared `_parse_input_values` helper). Supplied values are merged over the run's persisted inputs and re-resolved through the existing typed-validation path (`_resolve_inputs`), so a resumed/re-run step sees the updated inputs and ill-typed values fail fast. Keys not supplied keep their persisted values; resuming without `--input` is unchanged. Reference docs updated. Distinct from #2405 (file-reference inputs at run time): this is about supplying inputs at resume time, reusing the existing input model. Closes #2812. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 1f9eaf3 commit 1732b9b

4 files changed

Lines changed: 167 additions & 11 deletions

File tree

docs/reference/workflows.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,18 @@ specify workflow run speckit -i spec="Build a kanban board with drag-and-drop ta
2828
specify workflow resume <run_id>
2929
```
3030

31+
| Option | Description |
32+
| ------------------- | -------------------------------------------------------- |
33+
| `-i` / `--input` | Updated input values as `key=value` (repeatable) |
34+
3135
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.
3236

37+
Supplied `--input` values are merged over the run's stored inputs and re-validated against the workflow's input types, then the blocked step is re-run with the updated values. This lets a run continue with information that only became available after it paused, or with a corrected value after a failure:
38+
39+
```bash
40+
specify workflow resume <run_id> --input cmd="exit 0"
41+
```
42+
3343
## Workflow Status
3444

3545
```bash

src/specify_cli/__init__.py

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2717,6 +2717,22 @@ def extension_set_priority(
27172717
workflow_app.add_typer(workflow_catalog_app, name="catalog")
27182718

27192719

2720+
def _parse_input_values(input_values: list[str] | None) -> dict[str, Any]:
2721+
"""Parse repeated ``key=value`` CLI inputs into a dict.
2722+
2723+
Shared by ``workflow run`` and ``workflow resume``. Exits with an error
2724+
on any entry missing ``=``.
2725+
"""
2726+
inputs: dict[str, Any] = {}
2727+
for kv in input_values or []:
2728+
if "=" not in kv:
2729+
console.print(f"[red]Error:[/red] Invalid input format: {kv!r} (expected key=value)")
2730+
raise typer.Exit(1)
2731+
key, _, value = kv.partition("=")
2732+
inputs[key.strip()] = value.strip()
2733+
return inputs
2734+
2735+
27202736
@workflow_app.command("run")
27212737
def workflow_run(
27222738
source: str = typer.Argument(..., help="Workflow ID or YAML file path"),
@@ -2749,14 +2765,7 @@ def workflow_run(
27492765
raise typer.Exit(1)
27502766

27512767
# Parse inputs
2752-
inputs: dict[str, Any] = {}
2753-
if input_values:
2754-
for kv in input_values:
2755-
if "=" not in kv:
2756-
console.print(f"[red]Error:[/red] Invalid input format: {kv!r} (expected key=value)")
2757-
raise typer.Exit(1)
2758-
key, _, value = kv.partition("=")
2759-
inputs[key.strip()] = value.strip()
2768+
inputs = _parse_input_values(input_values)
27602769

27612770
console.print(f"\n[bold cyan]Running workflow:[/bold cyan] {definition.name} ({definition.id})")
27622771
console.print(f"[dim]Version: {definition.version}[/dim]\n")
@@ -2787,6 +2796,9 @@ def workflow_run(
27872796
@workflow_app.command("resume")
27882797
def workflow_resume(
27892798
run_id: str = typer.Argument(..., help="Run ID to resume"),
2799+
input_values: list[str] | None = typer.Option(
2800+
None, "--input", "-i", help="Updated input values as key=value pairs"
2801+
),
27902802
):
27912803
"""Resume a paused or failed workflow run."""
27922804
from .workflows.engine import WorkflowEngine
@@ -2795,8 +2807,10 @@ def workflow_resume(
27952807
engine = WorkflowEngine(project_root)
27962808
engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")
27972809

2810+
inputs = _parse_input_values(input_values)
2811+
27982812
try:
2799-
state = engine.resume(run_id)
2813+
state = engine.resume(run_id, inputs or None)
28002814
except FileNotFoundError:
28012815
console.print(f"[red]Error:[/red] Run not found: {run_id}")
28022816
raise typer.Exit(1)

src/specify_cli/workflows/engine.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -507,8 +507,19 @@ def execute(
507507
state.save()
508508
return state
509509

510-
def resume(self, run_id: str) -> RunState:
511-
"""Resume a paused or failed workflow run."""
510+
def resume(
511+
self,
512+
run_id: str,
513+
inputs: dict[str, Any] | None = None,
514+
) -> RunState:
515+
"""Resume a paused or failed workflow run.
516+
517+
When ``inputs`` is provided, the values are merged over the run's
518+
persisted inputs and re-resolved through the same typed validation
519+
path used by :meth:`execute`, so the resumed step sees updated
520+
workflow inputs. Keys not supplied keep their persisted values; an
521+
empty/``None`` ``inputs`` leaves the run's inputs unchanged.
522+
"""
512523
state = RunState.load(run_id, self.project_root)
513524
if state.status not in (RunStatus.PAUSED, RunStatus.FAILED):
514525
msg = f"Cannot resume run {run_id!r} with status {state.status.value!r}."
@@ -524,6 +535,12 @@ def resume(self, run_id: str) -> RunState:
524535
else:
525536
definition = self.load_workflow(state.workflow_id)
526537

538+
# Merge any newly-supplied inputs over the persisted ones and
539+
# re-validate through the same typing path as the initial run.
540+
if inputs:
541+
merged = {**state.inputs, **inputs}
542+
state.inputs = self._resolve_inputs(definition, merged)
543+
527544
# Restore context
528545
context = StepContext(
529546
inputs=state.inputs,

tests/test_workflows.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3026,3 +3026,118 @@ def test_switch_workflow(self, project_dir):
30263026
assert state.status == RunStatus.COMPLETED
30273027
assert "do-plan" in state.step_results
30283028
assert "do-specify" not in state.step_results
3029+
3030+
3031+
class TestResumeWithInputs:
3032+
"""Test that `workflow resume` can accept updated workflow inputs."""
3033+
3034+
_WF_CMD = """
3035+
schema_version: "1.0"
3036+
workflow:
3037+
id: "resume-cmd-wf"
3038+
name: "Resume Cmd WF"
3039+
version: "1.0.0"
3040+
inputs:
3041+
cmd:
3042+
type: string
3043+
default: "exit 1"
3044+
steps:
3045+
- id: s
3046+
type: shell
3047+
run: "{{ inputs.cmd }}"
3048+
"""
3049+
3050+
_WF_NUM = """
3051+
schema_version: "1.0"
3052+
workflow:
3053+
id: "resume-num-wf"
3054+
name: "Resume Num WF"
3055+
version: "1.0.0"
3056+
inputs:
3057+
count:
3058+
type: number
3059+
default: 1
3060+
steps:
3061+
- id: gate
3062+
type: gate
3063+
message: "Review"
3064+
options: [approve, reject]
3065+
"""
3066+
3067+
def _engine(self, project_dir):
3068+
from specify_cli.workflows.engine import WorkflowEngine
3069+
return WorkflowEngine(project_dir)
3070+
3071+
def test_resume_with_input_reruns_step_with_new_value(self, project_dir):
3072+
from specify_cli.workflows.engine import WorkflowDefinition
3073+
from specify_cli.workflows.base import RunStatus
3074+
3075+
definition = WorkflowDefinition.from_string(self._WF_CMD)
3076+
engine = self._engine(project_dir)
3077+
3078+
state = engine.execute(definition)
3079+
assert state.status == RunStatus.FAILED # "exit 1" fails
3080+
3081+
resumed = engine.resume(state.run_id, {"cmd": "exit 0"})
3082+
assert resumed.status == RunStatus.COMPLETED
3083+
assert resumed.inputs["cmd"] == "exit 0"
3084+
3085+
def test_resume_without_input_preserves_inputs(self, project_dir):
3086+
from specify_cli.workflows.engine import WorkflowDefinition
3087+
from specify_cli.workflows.base import RunStatus
3088+
3089+
definition = WorkflowDefinition.from_string(self._WF_CMD)
3090+
engine = self._engine(project_dir)
3091+
3092+
state = engine.execute(definition)
3093+
assert state.status == RunStatus.FAILED
3094+
3095+
resumed = engine.resume(state.run_id)
3096+
assert resumed.status == RunStatus.FAILED # still "exit 1"
3097+
assert resumed.inputs["cmd"] == "exit 1"
3098+
3099+
def test_resume_merges_and_coerces_typed_input(self, project_dir):
3100+
import json as _json
3101+
from specify_cli.workflows.engine import WorkflowDefinition
3102+
from specify_cli.workflows.base import RunStatus
3103+
3104+
definition = WorkflowDefinition.from_string(self._WF_NUM)
3105+
engine = self._engine(project_dir)
3106+
3107+
state = engine.execute(definition)
3108+
assert state.status == RunStatus.PAUSED
3109+
3110+
resumed = engine.resume(state.run_id, {"count": "5"})
3111+
assert resumed.inputs["count"] == 5 # coerced string -> number
3112+
3113+
inputs_file = (
3114+
project_dir / ".specify" / "workflows" / "runs" / state.run_id / "inputs.json"
3115+
)
3116+
assert _json.loads(inputs_file.read_text())["inputs"]["count"] == 5
3117+
3118+
def test_resume_invalid_typed_input_raises(self, project_dir):
3119+
from specify_cli.workflows.engine import WorkflowDefinition
3120+
3121+
definition = WorkflowDefinition.from_string(self._WF_NUM)
3122+
engine = self._engine(project_dir)
3123+
3124+
state = engine.execute(definition)
3125+
with pytest.raises(ValueError):
3126+
engine.resume(state.run_id, {"count": "not-a-number"})
3127+
3128+
def test_cli_resume_input_invalid_format_errors(self, project_dir):
3129+
from typer.testing import CliRunner
3130+
from unittest.mock import patch
3131+
from specify_cli import app
3132+
from specify_cli.workflows.engine import WorkflowDefinition
3133+
3134+
definition = WorkflowDefinition.from_string(self._WF_NUM)
3135+
state = self._engine(project_dir).execute(definition)
3136+
3137+
runner = CliRunner()
3138+
with patch.object(Path, "cwd", return_value=project_dir):
3139+
result = runner.invoke(
3140+
app, ["workflow", "resume", state.run_id, "--input", "bogus"]
3141+
)
3142+
assert result.exit_code == 1
3143+
assert "Invalid input format" in result.stdout

0 commit comments

Comments
 (0)