Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions src/specify_cli/workflows/steps/fan_in/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,24 @@ def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
if not isinstance(output_config, dict):
output_config = {}

# The engine does not auto-validate step config, so an unvalidated run
# with a non-list ``wait_for`` reaches here raw. Iterating it then
# either crashes the whole run (a scalar like an int or None raises
# TypeError) or, worse, silently iterates a string's characters and
# yields a bogus join of empty results with a COMPLETED status — the
# exact "silent empty result + COMPLETED" wiring bug the engine's
# fan-in validation guards against. Fail this step loudly instead,
# mirroring the fan-out step's non-list ``items`` handling.
if not isinstance(wait_for, list):
return StepResult(
status=StepStatus.FAILED,
error=(
f"Fan-in step {config.get('id', '?')!r}: 'wait_for' must be "
f"a list of step IDs, got {type(wait_for).__name__}."
),
output={"results": []},
)

# Collect results from referenced steps
results = []
for step_id in wait_for:
Expand Down
23 changes: 23 additions & 0 deletions tests/test_workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -2355,6 +2355,29 @@ def test_execute_missing_wait_for_step(self):
result = step.execute(config, ctx)
assert result.output["results"] == [{}]

@pytest.mark.parametrize("bad_wait_for", ["stepA", 5, None, {"a": 1}])
def test_execute_non_list_wait_for_fails_loudly(self, bad_wait_for):
"""A non-list ``wait_for`` must fail the step, not crash the run or
silently produce a bogus join.

``validate`` rejects a non-list ``wait_for``, but the engine's
``execute()`` does not auto-validate. Before the guard, ``execute``
iterated the raw value: a scalar (int/None) raised TypeError and took
down the whole run, while a string silently iterated its characters and
returned a join of empty results with a COMPLETED status — the exact
"silent empty result + COMPLETED" wiring bug the engine's fan-in
validation warns against. Mirrors the fan-out non-list ``items`` guard.
"""
from specify_cli.workflows.steps.fan_in import FanInStep
from specify_cli.workflows.base import StepContext, StepStatus

step = FanInStep()
ctx = StepContext(steps={"a": {"output": {"x": 1}}})
result = step.execute({"id": "collect", "wait_for": bad_wait_for}, ctx)
assert result.status == StepStatus.FAILED
assert "'wait_for' must be a list" in (result.error or "")
assert result.output["results"] == []

def test_validate_empty_wait_for(self):
from specify_cli.workflows.steps.fan_in import FanInStep

Expand Down
Loading