Skip to content

Commit 77ebd5f

Browse files
fix(workflows): fail while/do-while steps on non-list steps instead of crashing (#3519)
`WhileStep.validate()` and `DoWhileStep.validate()` already reject a non-list `steps` body, but the engine's `execute()` path does not auto-validate (see `WorkflowEngine.load_workflow`, whose docstring notes the definition is "not yet validated"). On an unvalidated run the body is returned as `next_steps`, and the engine feeds it straight into `_execute_steps`, which iterates it as step mappings. A non-list `steps` — a single mapping or scalar authoring mistake — was iterated element-wise (a dict yields its string keys, a str its characters) and raised `AttributeError` on `.get()`, taking down the whole run; the engine invokes `step_impl.execute()` with no surrounding try/except. Guard both `execute` paths to return a FAILED StepResult naming the type error instead, mirroring the if/switch non-list-branch and fan-out non-list `items` handling. The do-while body always dispatches on the first call, so its guard is unconditional; the while body only dispatches when the condition is truthy, so its guard fires only then — a false condition leaves a non-list `steps` benign and the step completes, unchanged. The condition/expression is still evaluated first, so its result is surfaced in the step output for downstream context. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent faeb956 commit 77ebd5f

3 files changed

Lines changed: 115 additions & 0 deletions

File tree

src/specify_cli/workflows/steps/do_while/__init__.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,30 @@ def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
2727
nested_steps = config.get("steps", [])
2828
condition = config.get("condition", "false")
2929

30+
# The engine does not auto-validate step config (see
31+
# ``WorkflowEngine.load_workflow``) and feeds ``next_steps`` straight
32+
# into ``_execute_steps``, which iterates them as step mappings. A
33+
# non-list ``steps`` (a single mapping or scalar authoring mistake)
34+
# would otherwise be iterated element-wise — a dict yields its string
35+
# keys, a str its characters — and crash the whole run with
36+
# AttributeError on ``.get()``. ``validate`` already rejects a non-list
37+
# ``steps``; fail this step loudly on an unvalidated run instead,
38+
# mirroring the if/switch/fan-out steps. The body always runs on the
39+
# first call, so unlike the while step this guard is unconditional.
40+
if not isinstance(nested_steps, list):
41+
return StepResult(
42+
status=StepStatus.FAILED,
43+
output={
44+
"condition": condition,
45+
"max_iterations": max_iterations,
46+
"loop_type": "do-while",
47+
},
48+
error=(
49+
f"Do-while step {config.get('id', '?')!r}: 'steps' must be "
50+
f"a list of steps, got {type(nested_steps).__name__}."
51+
),
52+
)
53+
3054
# Always execute body at least once; the engine layer evaluates
3155
# `condition` after each iteration to decide whether to loop.
3256
return StepResult(

src/specify_cli/workflows/steps/while_loop/__init__.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,32 @@ def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
2626
nested_steps = config.get("steps", [])
2727

2828
result = evaluate_condition(condition, context)
29+
30+
# The engine does not auto-validate step config (see
31+
# ``WorkflowEngine.load_workflow``) and feeds ``next_steps`` straight
32+
# into ``_execute_steps``, which iterates them as step mappings. A
33+
# non-list ``steps`` (a single mapping or scalar authoring mistake)
34+
# would otherwise be iterated element-wise — a dict yields its string
35+
# keys, a str its characters — and crash the whole run with
36+
# AttributeError on ``.get()``. ``validate`` already rejects a non-list
37+
# ``steps``; fail this step loudly on an unvalidated run instead,
38+
# mirroring the if/switch/fan-out steps. The guard fires only when the
39+
# body would actually be dispatched (condition truthy). The condition is
40+
# still evaluated first, so its result is surfaced for downstream context.
41+
if result and not isinstance(nested_steps, list):
42+
return StepResult(
43+
status=StepStatus.FAILED,
44+
output={
45+
"condition_result": True,
46+
"max_iterations": max_iterations,
47+
"loop_type": "while",
48+
},
49+
error=(
50+
f"While step {config.get('id', '?')!r}: 'steps' must be a "
51+
f"list of steps, got {type(nested_steps).__name__}."
52+
),
53+
)
54+
2955
if result:
3056
return StepResult(
3157
status=StepStatus.COMPLETED,

tests/test_workflows.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2260,6 +2260,47 @@ def test_execute_condition_false(self):
22602260
assert result.output["condition_result"] is False
22612261
assert result.next_steps == []
22622262

2263+
@pytest.mark.parametrize("bad_steps", [{"id": "x"}, "oops", 5])
2264+
def test_execute_non_list_steps_fails_loudly(self, bad_steps):
2265+
"""A non-list ``steps`` reached at runtime must fail the step, not crash.
2266+
2267+
``validate`` rejects a non-list ``steps``, but the engine does not
2268+
auto-validate (see ``WorkflowEngine.load_workflow``) and feeds
2269+
``next_steps`` straight into ``_execute_steps``, which iterates them as
2270+
step mappings. The while body only dispatches when the condition is
2271+
truthy, so a non-list ``steps`` reaches ``next_steps`` and would crash
2272+
the engine's step iteration on an unvalidated run. Mirrors the
2273+
if/switch/fan-out non-list handling.
2274+
"""
2275+
from specify_cli.workflows.steps.while_loop import WhileStep
2276+
from specify_cli.workflows.base import StepContext, StepStatus
2277+
2278+
step = WhileStep()
2279+
ctx = StepContext(inputs={})
2280+
result = step.execute(
2281+
{"id": "retry", "condition": "true", "steps": bad_steps}, ctx
2282+
)
2283+
assert result.status == StepStatus.FAILED
2284+
assert "'steps' must be a list of steps" in (result.error or "")
2285+
assert result.next_steps == []
2286+
2287+
@pytest.mark.parametrize("bad_steps", [{"id": "x"}, "oops", 5])
2288+
def test_execute_non_list_steps_ok_when_condition_false(self, bad_steps):
2289+
"""A false condition never dispatches the body, so a non-list ``steps``
2290+
stays benign — the step completes without touching ``next_steps``.
2291+
"""
2292+
from specify_cli.workflows.steps.while_loop import WhileStep
2293+
from specify_cli.workflows.base import StepContext, StepStatus
2294+
2295+
step = WhileStep()
2296+
ctx = StepContext(inputs={})
2297+
result = step.execute(
2298+
{"id": "retry", "condition": "false", "steps": bad_steps}, ctx
2299+
)
2300+
assert result.status == StepStatus.COMPLETED
2301+
assert result.output["condition_result"] is False
2302+
assert result.next_steps == []
2303+
22632304
def test_validate_missing_fields(self):
22642305
from specify_cli.workflows.steps.while_loop import WhileStep
22652306

@@ -2350,6 +2391,30 @@ def test_execute_empty_steps(self):
23502391
assert result.next_steps == []
23512392
assert result.status.value == "completed"
23522393

2394+
@pytest.mark.parametrize("bad_steps", [{"id": "x"}, "oops", 5])
2395+
def test_execute_non_list_steps_fails_loudly(self, bad_steps):
2396+
"""A non-list ``steps`` must fail the step, not crash the run.
2397+
2398+
``validate`` rejects a non-list ``steps``, but the engine does not
2399+
auto-validate (see ``WorkflowEngine.load_workflow``) and feeds
2400+
``next_steps`` straight into ``_execute_steps``, which iterates them as
2401+
step mappings. The do-while body always dispatches on the first call
2402+
regardless of condition, so a non-list ``steps`` always reaches
2403+
``next_steps`` and would crash the engine's step iteration on an
2404+
unvalidated run. Mirrors the if/switch/fan-out non-list handling.
2405+
"""
2406+
from specify_cli.workflows.steps.do_while import DoWhileStep
2407+
from specify_cli.workflows.base import StepContext, StepStatus
2408+
2409+
step = DoWhileStep()
2410+
ctx = StepContext(inputs={})
2411+
result = step.execute(
2412+
{"id": "cycle", "condition": "false", "steps": bad_steps}, ctx
2413+
)
2414+
assert result.status == StepStatus.FAILED
2415+
assert "'steps' must be a list of steps" in (result.error or "")
2416+
assert result.next_steps == []
2417+
23532418
def test_validate_missing_fields(self):
23542419
from specify_cli.workflows.steps.do_while import DoWhileStep
23552420

0 commit comments

Comments
 (0)