diff --git a/src/specify_cli/workflows/steps/fan_out/__init__.py b/src/specify_cli/workflows/steps/fan_out/__init__.py index c2fff1face..22b9c37d43 100644 --- a/src/specify_cli/workflows/steps/fan_out/__init__.py +++ b/src/specify_cli/workflows/steps/fan_out/__init__.py @@ -22,12 +22,28 @@ class FanOutStep(StepBase): def execute(self, config: dict[str, Any], context: StepContext) -> StepResult: items_expr = config.get("items", "[]") items = evaluate_expression(items_expr, context) - if not isinstance(items, list): - items = [] - max_concurrency = config.get("max_concurrency", 1) step_template = config.get("step", {}) + if not isinstance(items, list): + # A non-list here is a wiring error (the expression did not + # resolve to a collection); silently fanning out over zero + # items hides it. An explicit empty list remains valid input. + return StepResult( + status=StepStatus.FAILED, + error=( + f"Fan-out step {config.get('id', '?')!r}: 'items' must " + f"resolve to a list, got {type(items).__name__} from " + f"{items_expr!r}." + ), + output={ + "items": [], + "max_concurrency": max_concurrency, + "step_template": step_template, + "item_count": 0, + }, + ) + return StepResult( status=StepStatus.COMPLETED, output={ diff --git a/tests/test_workflows.py b/tests/test_workflows.py index 51da5cc86b..36b105fd4e 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -1475,9 +1475,9 @@ def test_execute_with_items(self): assert result.output["item_count"] == 2 assert result.output["max_concurrency"] == 3 - def test_execute_non_list_items_resolves_empty(self): + def test_execute_non_list_items_fails_loudly(self): from specify_cli.workflows.steps.fan_out import FanOutStep - from specify_cli.workflows.base import StepContext + from specify_cli.workflows.base import StepContext, StepStatus step = FanOutStep() ctx = StepContext() @@ -1487,8 +1487,24 @@ def test_execute_non_list_items_resolves_empty(self): "step": {"id": "impl", "command": "speckit.implement"}, } result = step.execute(config, ctx) + assert result.status == StepStatus.FAILED + assert "'items' must resolve to a list" in (result.error or "") + assert result.output["item_count"] == 0 + + def test_execute_empty_list_items_is_valid(self): + from specify_cli.workflows.steps.fan_out import FanOutStep + from specify_cli.workflows.base import StepContext, StepStatus + + step = FanOutStep() + ctx = StepContext(steps={"tasks": {"output": {"task_list": []}}}) + config = { + "id": "parallel", + "items": "{{ steps.tasks.output.task_list }}", + "step": {"id": "impl", "command": "speckit.implement"}, + } + result = step.execute(config, ctx) + assert result.status == StepStatus.COMPLETED assert result.output["item_count"] == 0 - assert result.output["items"] == [] def test_validate_missing_fields(self): from specify_cli.workflows.steps.fan_out import FanOutStep