Skip to content

Commit 129f698

Browse files
doquanghuyclaude
andcommitted
feat(workflows): allow resume to accept updated workflow inputs
`workflow resume` now accepts `--input key=value` (the same flag and parsing as `workflow run`). 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 bad types fail fast. Keys not supplied keep their persisted values; resuming without `--input` is unchanged. 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 ed10b32 commit 129f698

3 files changed

Lines changed: 147 additions & 3 deletions

File tree

src/specify_cli/__init__.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4244,6 +4244,9 @@ def workflow_run(
42444244
@workflow_app.command("resume")
42454245
def workflow_resume(
42464246
run_id: str = typer.Argument(..., help="Run ID to resume"),
4247+
input_values: list[str] | None = typer.Option(
4248+
None, "--input", "-i", help="Updated input values as key=value pairs"
4249+
),
42474250
):
42484251
"""Resume a paused or failed workflow run."""
42494252
from .workflows.engine import WorkflowEngine
@@ -4252,8 +4255,17 @@ def workflow_resume(
42524255
engine = WorkflowEngine(project_root)
42534256
engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")
42544257

4258+
inputs: dict[str, Any] = {}
4259+
if input_values:
4260+
for kv in input_values:
4261+
if "=" not in kv:
4262+
console.print(f"[red]Error:[/red] Invalid input format: {kv!r} (expected key=value)")
4263+
raise typer.Exit(1)
4264+
key, _, value = kv.partition("=")
4265+
inputs[key.strip()] = value.strip()
4266+
42554267
try:
4256-
state = engine.resume(run_id)
4268+
state = engine.resume(run_id, inputs or None)
42574269
except FileNotFoundError:
42584270
console.print(f"[red]Error:[/red] Run not found: {run_id}")
42594271
raise typer.Exit(1)

src/specify_cli/workflows/engine.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -491,8 +491,19 @@ def execute(
491491
state.save()
492492
return state
493493

494-
def resume(self, run_id: str) -> RunState:
495-
"""Resume a paused or failed workflow run."""
494+
def resume(
495+
self,
496+
run_id: str,
497+
inputs: dict[str, Any] | None = None,
498+
) -> RunState:
499+
"""Resume a paused or failed workflow run.
500+
501+
When ``inputs`` is provided, the values are merged over the run's
502+
persisted inputs and re-resolved through the same typed validation
503+
path used by :meth:`execute`, so the resumed step sees updated
504+
workflow inputs. Keys not supplied keep their persisted values; an
505+
empty/``None`` ``inputs`` leaves the run's inputs unchanged.
506+
"""
496507
state = RunState.load(run_id, self.project_root)
497508
if state.status not in (RunStatus.PAUSED, RunStatus.FAILED):
498509
msg = f"Cannot resume run {run_id!r} with status {state.status.value!r}."
@@ -508,6 +519,12 @@ def resume(self, run_id: str) -> RunState:
508519
else:
509520
definition = self.load_workflow(state.workflow_id)
510521

522+
# Merge any newly-supplied inputs over the persisted ones and
523+
# re-validate through the same typing path as the initial run.
524+
if inputs:
525+
merged = {**state.inputs, **inputs}
526+
state.inputs = self._resolve_inputs(definition, merged)
527+
511528
# Restore context
512529
context = StepContext(
513530
inputs=state.inputs,

tests/test_workflows.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2726,3 +2726,118 @@ 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 TestResumeWithInputs:
2732+
"""Test that `workflow resume` can accept updated workflow inputs."""
2733+
2734+
_WF_CMD = """
2735+
schema_version: "1.0"
2736+
workflow:
2737+
id: "resume-cmd-wf"
2738+
name: "Resume Cmd WF"
2739+
version: "1.0.0"
2740+
inputs:
2741+
cmd:
2742+
type: string
2743+
default: "exit 1"
2744+
steps:
2745+
- id: s
2746+
type: shell
2747+
run: "{{ inputs.cmd }}"
2748+
"""
2749+
2750+
_WF_NUM = """
2751+
schema_version: "1.0"
2752+
workflow:
2753+
id: "resume-num-wf"
2754+
name: "Resume Num WF"
2755+
version: "1.0.0"
2756+
inputs:
2757+
count:
2758+
type: number
2759+
default: 1
2760+
steps:
2761+
- id: gate
2762+
type: gate
2763+
message: "Review"
2764+
options: [approve, reject]
2765+
"""
2766+
2767+
def _engine(self, project_dir):
2768+
from specify_cli.workflows.engine import WorkflowEngine
2769+
return WorkflowEngine(project_dir)
2770+
2771+
def test_resume_with_input_reruns_step_with_new_value(self, project_dir):
2772+
from specify_cli.workflows.engine import WorkflowDefinition
2773+
from specify_cli.workflows.base import RunStatus
2774+
2775+
definition = WorkflowDefinition.from_string(self._WF_CMD)
2776+
engine = self._engine(project_dir)
2777+
2778+
state = engine.execute(definition)
2779+
assert state.status == RunStatus.FAILED # "exit 1" fails
2780+
2781+
resumed = engine.resume(state.run_id, {"cmd": "exit 0"})
2782+
assert resumed.status == RunStatus.COMPLETED
2783+
assert resumed.inputs["cmd"] == "exit 0"
2784+
2785+
def test_resume_without_input_preserves_inputs(self, project_dir):
2786+
from specify_cli.workflows.engine import WorkflowDefinition
2787+
from specify_cli.workflows.base import RunStatus
2788+
2789+
definition = WorkflowDefinition.from_string(self._WF_CMD)
2790+
engine = self._engine(project_dir)
2791+
2792+
state = engine.execute(definition)
2793+
assert state.status == RunStatus.FAILED
2794+
2795+
resumed = engine.resume(state.run_id)
2796+
assert resumed.status == RunStatus.FAILED # still "exit 1"
2797+
assert resumed.inputs["cmd"] == "exit 1"
2798+
2799+
def test_resume_merges_and_coerces_typed_input(self, project_dir):
2800+
import json as _json
2801+
from specify_cli.workflows.engine import WorkflowDefinition
2802+
from specify_cli.workflows.base import RunStatus
2803+
2804+
definition = WorkflowDefinition.from_string(self._WF_NUM)
2805+
engine = self._engine(project_dir)
2806+
2807+
state = engine.execute(definition)
2808+
assert state.status == RunStatus.PAUSED
2809+
2810+
resumed = engine.resume(state.run_id, {"count": "5"})
2811+
assert resumed.inputs["count"] == 5 # coerced string -> number
2812+
2813+
inputs_file = (
2814+
project_dir / ".specify" / "workflows" / "runs" / state.run_id / "inputs.json"
2815+
)
2816+
assert _json.loads(inputs_file.read_text())["inputs"]["count"] == 5
2817+
2818+
def test_resume_invalid_typed_input_raises(self, project_dir):
2819+
from specify_cli.workflows.engine import WorkflowDefinition
2820+
2821+
definition = WorkflowDefinition.from_string(self._WF_NUM)
2822+
engine = self._engine(project_dir)
2823+
2824+
state = engine.execute(definition)
2825+
with pytest.raises(ValueError):
2826+
engine.resume(state.run_id, {"count": "not-a-number"})
2827+
2828+
def test_cli_resume_input_invalid_format_errors(self, project_dir):
2829+
from typer.testing import CliRunner
2830+
from unittest.mock import patch
2831+
from specify_cli import app
2832+
from specify_cli.workflows.engine import WorkflowDefinition
2833+
2834+
definition = WorkflowDefinition.from_string(self._WF_NUM)
2835+
state = self._engine(project_dir).execute(definition)
2836+
2837+
runner = CliRunner()
2838+
with patch.object(Path, "cwd", return_value=project_dir):
2839+
result = runner.invoke(
2840+
app, ["workflow", "resume", state.run_id, "--input", "bogus"]
2841+
)
2842+
assert result.exit_code == 1
2843+
assert "Invalid input format" in result.stdout

0 commit comments

Comments
 (0)