Skip to content
Merged
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
61 changes: 56 additions & 5 deletions src/specify_cli/workflows/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,15 @@ def __init__(self, data: dict[str, Any], source_path: Path | None = None) -> Non
self.schema_version: str = data.get("schema_version", "1.0")

# Defaults
self.default_integration: str | None = workflow.get("integration")
self.default_model: str | None = workflow.get("model")
self.default_options: dict[str, Any] = workflow.get("options") or {}
if not isinstance(self.default_options, dict):
self.default_options = {}
# Keep malformed values intact until ``validate_workflow`` can report
# them. ``None`` remains the supported "no defaults" form for options
# and retains its existing runtime representation as an empty mapping.
self.default_integration: Any = workflow.get("integration")
self.default_model: Any = workflow.get("model")
raw_default_options = workflow.get("options")
self.default_options: Any = (
{} if raw_default_options is None else raw_default_options
)

# Advisory pre-conditions (spec-kit version / integrations a workflow
# expects). Validated by ``validate_workflow`` (recognized keys only;
Expand Down Expand Up @@ -140,6 +144,40 @@ def _get_valid_step_types() -> set[str]:
}


def _dispatch_default_errors(definition: WorkflowDefinition) -> list[str]:
"""Return validation errors for workflow defaults inherited by dispatch steps."""
errors: list[str] = []

if (
definition.default_integration is not None
and not isinstance(definition.default_integration, str)
):
errors.append(
"'workflow.integration' must be a string or null, got "
f"{type(definition.default_integration).__name__} "
f"({definition.default_integration!r})."
)

if (
definition.default_model is not None
and not isinstance(definition.default_model, str)
):
errors.append(
"'workflow.model' must be a string or null, got "
f"{type(definition.default_model).__name__} "
f"({definition.default_model!r})."
)

if not isinstance(definition.default_options, dict):
errors.append(
"'workflow.options' must be a mapping or null, got "
f"{type(definition.default_options).__name__} "
f"({definition.default_options!r})."
)

return errors


def validate_workflow(definition: WorkflowDefinition) -> list[str]:
"""Validate a workflow definition and return a list of error messages.

Expand Down Expand Up @@ -197,6 +235,11 @@ def validate_workflow(definition: WorkflowDefinition) -> list[str]:
f"semantic versioning (expected X.Y.Z)."
)

# Workflow-level dispatch defaults are inherited by command and prompt
# steps. Validate their shapes before an invalid value reaches dispatch, or
# (for options) is silently normalized away during construction.
errors.extend(_dispatch_default_errors(definition))

# -- Inputs -----------------------------------------------------------
if not isinstance(definition.inputs, dict):
errors.append("'inputs' must be a mapping (or omitted).")
Expand Down Expand Up @@ -947,6 +990,10 @@ def execute(
-------
The final ``RunState`` after execution completes (or pauses).
"""
dispatch_default_errors = _dispatch_default_errors(definition)
if dispatch_default_errors:
raise ValueError(" ".join(dispatch_default_errors))
Comment thread
mnriem marked this conversation as resolved.

from . import STEP_REGISTRY

effective_run_id = run_id
Expand Down Expand Up @@ -1048,6 +1095,10 @@ def resume(
else:
definition = self.load_workflow(state.workflow_id)

dispatch_default_errors = _dispatch_default_errors(definition)
if dispatch_default_errors:
raise ValueError(" ".join(dispatch_default_errors))

# Merge any newly-supplied inputs over the persisted ones and
# re-validate through the same typing path as the initial run.
if inputs:
Expand Down
196 changes: 196 additions & 0 deletions tests/test_workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -4520,6 +4520,94 @@ def test_unquoted_schema_version_accepted(self):
errors = validate_workflow(definition)
assert errors == []

@pytest.mark.parametrize(
"field, bad_value",
[
("integration", ["claude"]),
("integration", {"name": "claude"}),
("integration", False),
("model", ["gpt-5"]),
("model", {"name": "gpt-5"}),
("model", 0),
("options", ["max_tokens"]),
("options", "max_tokens"),
("options", False),
],
)
def test_rejects_invalid_workflow_dispatch_defaults(self, field, bad_value):
"""Top-level dispatch defaults must retain their invalid shape for
validation instead of being passed to a step or normalized to ``{}``.
"""
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow

definition = WorkflowDefinition(
{
"workflow": {
"id": "test",
"name": "Test",
"version": "1.0.0",
field: bad_value,
},
"steps": [{"id": "step-one", "command": "speckit.specify"}],
}
)

errors = validate_workflow(definition)

assert any(f"workflow.{field}" in error for error in errors), errors
assert any(type(bad_value).__name__ in error for error in errors), errors
if field == "options":
assert definition.default_options == bad_value

def test_preserves_valid_workflow_dispatch_defaults(self):
"""String and mapping defaults stay available unchanged to steps."""
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow

defaults = {
"integration": "claude",
"model": "gpt-5",
"options": {"max_tokens": 8000},
}
definition = WorkflowDefinition(
{
"workflow": {
"id": "test",
"name": "Test",
"version": "1.0.0",
**defaults,
},
"steps": [{"id": "step-one", "command": "speckit.specify"}],
}
)

assert definition.default_integration == defaults["integration"]
assert definition.default_model == defaults["model"]
assert definition.default_options == defaults["options"]
assert validate_workflow(definition) == []

def test_accepts_null_workflow_dispatch_defaults(self):
"""Null integration/model inherit at runtime and null options stays {}."""
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow

definition = WorkflowDefinition(
{
"workflow": {
"id": "test",
"name": "Test",
"version": "1.0.0",
"integration": None,
"model": None,
"options": None,
},
"steps": [{"id": "step-one", "command": "speckit.specify"}],
}
)

assert definition.default_integration is None
assert definition.default_model is None
assert definition.default_options == {}
assert validate_workflow(definition) == []

def test_no_steps(self):
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow

Expand Down Expand Up @@ -5165,6 +5253,36 @@ def test_malformed_inputs_block_no_cascade(self):
class TestWorkflowEngine:
"""Test WorkflowEngine execution."""

@pytest.mark.parametrize(
("field", "value"),
[
("integration", ["claude"]),
("model", {"name": "gpt-5"}),
("options", ["max_tokens"]),
],
)
def test_execute_rejects_invalid_workflow_dispatch_defaults(
self, project_dir, field, value
):
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine

definition = WorkflowDefinition(
{
"workflow": {
"id": "invalid-dispatch-defaults",
"name": "Invalid dispatch defaults",
"version": "1.0.0",
field: value,
},
"steps": [],
}
)

with pytest.raises(ValueError, match=f"workflow.{field}"):
WorkflowEngine(project_dir).execute(definition)

assert not (project_dir / ".specify" / "workflows" / "runs").exists()

def test_load_from_file(self, sample_workflow_file, project_dir):
from specify_cli.workflows.engine import WorkflowEngine

Expand Down Expand Up @@ -6684,6 +6802,45 @@ def test_workflow_dir_is_resolved_to_absolute(self, project_dir):
# and abort the run.


class TestWorkflowDispatchDefaultExecution:
"""Execution safeguards for defaults inherited by dispatch steps."""

@pytest.mark.parametrize(
"defaults",
[
{
"integration": "claude",
"model": "gpt-5",
"options": {"max_tokens": 8000},
},
{"integration": None, "model": None, "options": None},
],
)
def test_execute_accepts_valid_and_null_dispatch_defaults(
self, project_dir, defaults
):
"""Defaults with supported shapes remain executable without validation."""
from specify_cli.workflows.base import RunStatus
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine

definition = WorkflowDefinition(
{
"workflow": {
"id": "valid-defaults",
"name": "Valid Defaults",
"version": "1.0.0",
**defaults,
},
"steps": [],
}
)

state = WorkflowEngine(project_dir).execute(definition)

assert state.status == RunStatus.COMPLETED
assert state.step_results == {}


class TestContinueOnError:
"""Test the `continue_on_error` step-level field."""

Expand Down Expand Up @@ -10962,6 +11119,45 @@ def test_resume_invalid_typed_input_raises(self, project_dir):
with pytest.raises(ValueError):
engine.resume(state.run_id, {"count": "not-a-number"})

def test_resume_rejects_legacy_invalid_options_before_state_mutation(
self, project_dir, monkeypatch
):
from specify_cli.workflows.base import RunStatus
from specify_cli.workflows.engine import RunState, WorkflowDefinition

definition = WorkflowDefinition.from_string(self._WF_NUM)
engine = self._engine(project_dir)
state = engine.execute(definition)
assert state.status == RunStatus.PAUSED

workflow_copy = (
project_dir
/ ".specify"
/ "workflows"
/ "runs"
/ state.run_id
/ "workflow.yml"
)
workflow_copy.write_text(
self._WF_NUM.replace(
'version: "1.0.0"', 'version: "1.0.0"\n options: [max_tokens]'
),
encoding="utf-8",
)

def fail_step_context(*args, **kwargs):
raise AssertionError("StepContext must not be created")

monkeypatch.setattr("specify_cli.workflows.engine.StepContext", fail_step_context)

with pytest.raises(ValueError, match="'workflow.options' must be a mapping or null"):
engine.resume(state.run_id, {"count": "5"})

reloaded = RunState.load(state.run_id, project_dir)
assert reloaded.status == RunStatus.PAUSED
assert reloaded.error is None
assert reloaded.inputs["count"] == 1

def test_retry_verdict_input_is_consumed_and_can_be_replaced(self, project_dir):
import json as _json
from specify_cli.workflows.engine import WorkflowDefinition
Expand Down