Skip to content

Commit 9ef46b4

Browse files
Copilotmnriem
andauthored
Add workflow engine with step registry, expression engine, catalog system, and CLI commands
Agent-Logs-Url: https://github.com/github/spec-kit/sessions/72a7bb5d-071f-4d67-a507-7e1abae2384d Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>
1 parent 58afa3e commit 9ef46b4

19 files changed

Lines changed: 2657 additions & 0 deletions

File tree

src/specify_cli/__init__.py

Lines changed: 500 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
"""Workflow engine for multi-step, resumable automation workflows.
2+
3+
Provides:
4+
- ``StepBase`` — abstract base every step type must implement.
5+
- ``StepContext`` — execution context passed to each step.
6+
- ``StepResult`` — return value from step execution.
7+
- ``STEP_REGISTRY`` — maps ``type_key`` to ``StepBase`` subclass instances.
8+
- ``WorkflowEngine`` — orchestrator that loads, validates, and executes
9+
workflow YAML definitions.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
from typing import TYPE_CHECKING
15+
16+
if TYPE_CHECKING:
17+
from .base import StepBase
18+
19+
# Maps step type_key → StepBase instance.
20+
STEP_REGISTRY: dict[str, StepBase] = {}
21+
22+
23+
def _register_step(step: StepBase) -> None:
24+
"""Register a step type instance in the global registry.
25+
26+
Raises ``ValueError`` for falsy keys and ``KeyError`` for duplicates.
27+
"""
28+
key = step.type_key
29+
if not key:
30+
raise ValueError("Cannot register step type with an empty type_key.")
31+
if key in STEP_REGISTRY:
32+
raise KeyError(f"Step type with key {key!r} is already registered.")
33+
STEP_REGISTRY[key] = step
34+
35+
36+
def get_step_type(type_key: str) -> StepBase | None:
37+
"""Return the step type for *type_key*, or ``None`` if not registered."""
38+
return STEP_REGISTRY.get(type_key)
39+
40+
41+
# -- Register built-in step types ----------------------------------------
42+
43+
def _register_builtin_steps() -> None:
44+
"""Register all built-in step types."""
45+
from .steps.command import CommandStep
46+
from .steps.do_while import DoWhileStep
47+
from .steps.fan_in import FanInStep
48+
from .steps.fan_out import FanOutStep
49+
from .steps.gate import GateStep
50+
from .steps.if_then import IfThenStep
51+
from .steps.shell import ShellStep
52+
from .steps.switch import SwitchStep
53+
from .steps.while_loop import WhileStep
54+
55+
_register_step(CommandStep())
56+
_register_step(DoWhileStep())
57+
_register_step(FanInStep())
58+
_register_step(FanOutStep())
59+
_register_step(GateStep())
60+
_register_step(IfThenStep())
61+
_register_step(ShellStep())
62+
_register_step(SwitchStep())
63+
_register_step(WhileStep())
64+
65+
66+
_register_builtin_steps()

src/specify_cli/workflows/base.py

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
"""Base classes for workflow step types.
2+
3+
Provides:
4+
- ``StepBase`` — abstract base every step type must implement.
5+
- ``StepContext`` — execution context passed to each step.
6+
- ``StepResult`` — return value from step execution.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
from abc import ABC, abstractmethod
12+
from dataclasses import dataclass, field
13+
from enum import Enum
14+
from typing import Any
15+
16+
17+
class StepStatus(str, Enum):
18+
"""Status of a step execution."""
19+
20+
PENDING = "pending"
21+
RUNNING = "running"
22+
COMPLETED = "completed"
23+
FAILED = "failed"
24+
SKIPPED = "skipped"
25+
PAUSED = "paused"
26+
27+
28+
class RunStatus(str, Enum):
29+
"""Status of a workflow run."""
30+
31+
CREATED = "created"
32+
RUNNING = "running"
33+
PAUSED = "paused"
34+
COMPLETED = "completed"
35+
FAILED = "failed"
36+
ABORTED = "aborted"
37+
38+
39+
@dataclass
40+
class StepContext:
41+
"""Execution context passed to each step.
42+
43+
Contains everything the step needs to resolve expressions, dispatch
44+
commands, and record results.
45+
"""
46+
47+
#: Resolved workflow inputs (from user prompts / defaults).
48+
inputs: dict[str, Any] = field(default_factory=dict)
49+
50+
#: Accumulated step results keyed by step ID.
51+
#: Each entry is ``{"integration": ..., "model": ..., "options": ...,
52+
#: "input": ..., "output": ...}``.
53+
steps: dict[str, dict[str, Any]] = field(default_factory=dict)
54+
55+
#: Current fan-out item (set only inside fan-out iterations).
56+
item: Any = None
57+
58+
#: Fan-in aggregated results (set only for fan-in steps).
59+
fan_in: dict[str, Any] = field(default_factory=dict)
60+
61+
#: Workflow-level default integration key.
62+
default_integration: str | None = None
63+
64+
#: Workflow-level default model.
65+
default_model: str | None = None
66+
67+
#: Workflow-level default options.
68+
default_options: dict[str, Any] = field(default_factory=dict)
69+
70+
#: Project root path.
71+
project_root: str | None = None
72+
73+
#: Current run ID.
74+
run_id: str | None = None
75+
76+
77+
@dataclass
78+
class StepResult:
79+
"""Return value from a step execution."""
80+
81+
#: Step status.
82+
status: StepStatus = StepStatus.COMPLETED
83+
84+
#: Output data (stored as ``steps.<id>.output``).
85+
output: dict[str, Any] = field(default_factory=dict)
86+
87+
#: Nested steps to execute (for control-flow steps like if/then).
88+
next_steps: list[dict[str, Any]] = field(default_factory=list)
89+
90+
#: Error message if step failed.
91+
error: str | None = None
92+
93+
94+
class StepBase(ABC):
95+
"""Abstract base class for workflow step types.
96+
97+
Every step type — built-in or extension-provided — implements this
98+
interface and registers in ``STEP_REGISTRY``.
99+
"""
100+
101+
#: Matches the ``type:`` value in workflow YAML.
102+
type_key: str = ""
103+
104+
@abstractmethod
105+
def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
106+
"""Execute the step with the given config and context.
107+
108+
Parameters
109+
----------
110+
config:
111+
The step configuration from workflow YAML.
112+
context:
113+
The execution context with inputs, accumulated step results, etc.
114+
115+
Returns
116+
-------
117+
StepResult with status, output data, and optional nested steps.
118+
"""
119+
120+
def validate(self, config: dict[str, Any]) -> list[str]:
121+
"""Validate step configuration and return a list of error messages.
122+
123+
An empty list means the configuration is valid.
124+
"""
125+
errors: list[str] = []
126+
if "id" not in config:
127+
errors.append("Step is missing required 'id' field.")
128+
return errors
129+
130+
def can_resume(self, state: dict[str, Any]) -> bool:
131+
"""Return whether this step can be resumed from the given state."""
132+
return True

0 commit comments

Comments
 (0)