Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
58afa3e
Initial plan
Copilot Apr 10, 2026
9ef46b4
Add workflow engine with step registry, expression engine, catalog sy…
Copilot Apr 10, 2026
51d09c0
Add comprehensive tests for workflow engine (94 tests)
Copilot Apr 10, 2026
273dd70
Address review feedback: do-while condition preservation and URL sche…
Copilot Apr 10, 2026
c1ad7ce
Address review feedback, add CLI dispatch, interactive gates, and docs
mnriem Apr 13, 2026
eb7a764
Fix ruff lint errors: unused imports, f-string placeholders, undefine…
mnriem Apr 13, 2026
b682f90
Address second review: registry-backed validation, shell failures, lo…
mnriem Apr 13, 2026
2dace14
Potential fix for pull request finding 'Empty except'
mnriem Apr 13, 2026
88f9a36
Address third review: fan-out IDs, catalog guards, shell coercion, docs
mnriem Apr 13, 2026
4ea9483
Validate final URL after redirects in catalog fetch
mnriem Apr 13, 2026
ea14a73
Address fourth review: filter arg eval, tags normalization, install r…
mnriem Apr 13, 2026
3942369
Add explanatory comment to empty except ValueError block
mnriem Apr 13, 2026
1054708
Address fifth review: expression parsing, fan-out output, URL install…
mnriem Apr 13, 2026
97dcf01
Add comments to empty except ValueError blocks in URL install
mnriem Apr 13, 2026
e681ffe
Address sixth review: operator precedence, fan_in cleanup, registry r…
mnriem Apr 13, 2026
704f62c
Address seventh review: string literal before pipe, type annotations,…
mnriem Apr 13, 2026
65092d4
Address eighth review: fan-out namespaced IDs, early return, catalog …
mnriem Apr 13, 2026
3932af0
Address ninth review: populate catalog, fix indentation, priority, RE…
mnriem Apr 13, 2026
38b7b17
Address tenth review: max_iterations validation, catalog config guard…
mnriem Apr 14, 2026
e80dc90
Address eleventh review: command step fails without CLI, ID mismatch …
mnriem Apr 14, 2026
b3a0e33
Address twelfth review: type annotations, version examples, streaming…
mnriem Apr 14, 2026
b56d42b
Enforce catalog key matches workflow ID (fail instead of warn)
mnriem Apr 14, 2026
feee40f
Bundle speckit workflow: auto-install during specify init
mnriem Apr 14, 2026
3094425
Merge upstream/main: resolve conflicts with lean preset bundling
mnriem Apr 14, 2026
c32a4ce
Address fourteenth review: prompt fails without CLI, resolved step da…
mnriem Apr 14, 2026
18e7354
Address fifteenth review: fan_in docstring, gate defaults, validation…
mnriem Apr 14, 2026
ed7386b
Address sixteenth review: docs regex, fan_in try/finally, hyphenated …
mnriem Apr 14, 2026
c0eb5a5
Make speckit workflow integration-agnostic, document Copilot CLI requ…
mnriem Apr 14, 2026
7056924
Address seventeenth review: project checks, catalog robustness
mnriem Apr 14, 2026
db08c4f
Address eighteenth review: condition coercion, gate abort result, whi…
mnriem Apr 14, 2026
6cb1a43
Address nineteenth review: allow-all-tools opt-in, empty catalogs, ab…
mnriem Apr 14, 2026
b2a5d8c
Address twentieth review: gate abort maps to ABORTED status, do-while…
mnriem Apr 14, 2026
d299495
Coerce default_options to dict, align bundled workflow ID regex with …
mnriem Apr 14, 2026
b89dcf0
Gate validates string options, prompt uses resolved integration, loop…
mnriem Apr 14, 2026
3201ee0
Use parentId:childId convention for nested step IDs
mnriem Apr 14, 2026
9893282
Validate workflow version is semantic versioning (X.Y.Z)
mnriem Apr 14, 2026
ad2571b
Schema version validation, strict semver, load_workflow docstring, pr…
mnriem Apr 14, 2026
d274442
Path traversal prevention, loop step ID namespacing
mnriem Apr 14, 2026
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
500 changes: 500 additions & 0 deletions src/specify_cli/__init__.py

Large diffs are not rendered by default.

66 changes: 66 additions & 0 deletions src/specify_cli/workflows/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""Workflow engine for multi-step, resumable automation workflows.

Provides:
- ``StepBase`` — abstract base every step type must implement.
- ``StepContext`` — execution context passed to each step.
- ``StepResult`` — return value from step execution.
- ``STEP_REGISTRY`` — maps ``type_key`` to ``StepBase`` subclass instances.
- ``WorkflowEngine`` — orchestrator that loads, validates, and executes
workflow YAML definitions.
"""

from __future__ import annotations

from typing import TYPE_CHECKING

if TYPE_CHECKING:
from .base import StepBase

# Maps step type_key → StepBase instance.
STEP_REGISTRY: dict[str, StepBase] = {}


def _register_step(step: StepBase) -> None:
"""Register a step type instance in the global registry.

Raises ``ValueError`` for falsy keys and ``KeyError`` for duplicates.
"""
key = step.type_key
if not key:
raise ValueError("Cannot register step type with an empty type_key.")
if key in STEP_REGISTRY:
raise KeyError(f"Step type with key {key!r} is already registered.")
STEP_REGISTRY[key] = step


def get_step_type(type_key: str) -> StepBase | None:
"""Return the step type for *type_key*, or ``None`` if not registered."""
return STEP_REGISTRY.get(type_key)


# -- Register built-in step types ----------------------------------------

def _register_builtin_steps() -> None:
"""Register all built-in step types."""
from .steps.command import CommandStep
from .steps.do_while import DoWhileStep
from .steps.fan_in import FanInStep
from .steps.fan_out import FanOutStep
from .steps.gate import GateStep
from .steps.if_then import IfThenStep
from .steps.shell import ShellStep
from .steps.switch import SwitchStep
from .steps.while_loop import WhileStep

_register_step(CommandStep())
_register_step(DoWhileStep())
_register_step(FanInStep())
_register_step(FanOutStep())
_register_step(GateStep())
_register_step(IfThenStep())
_register_step(ShellStep())
_register_step(SwitchStep())
_register_step(WhileStep())


_register_builtin_steps()
132 changes: 132 additions & 0 deletions src/specify_cli/workflows/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
"""Base classes for workflow step types.

Provides:
- ``StepBase`` — abstract base every step type must implement.
- ``StepContext`` — execution context passed to each step.
- ``StepResult`` — return value from step execution.
"""

from __future__ import annotations

from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from enum import Enum
from typing import Any


class StepStatus(str, Enum):
"""Status of a step execution."""

PENDING = "pending"
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
SKIPPED = "skipped"
PAUSED = "paused"


class RunStatus(str, Enum):
"""Status of a workflow run."""

CREATED = "created"
RUNNING = "running"
PAUSED = "paused"
COMPLETED = "completed"
FAILED = "failed"
ABORTED = "aborted"


@dataclass
class StepContext:
"""Execution context passed to each step.

Contains everything the step needs to resolve expressions, dispatch
commands, and record results.
"""

#: Resolved workflow inputs (from user prompts / defaults).
inputs: dict[str, Any] = field(default_factory=dict)

#: Accumulated step results keyed by step ID.
#: Each entry is ``{"integration": ..., "model": ..., "options": ...,
#: "input": ..., "output": ...}``.
steps: dict[str, dict[str, Any]] = field(default_factory=dict)

#: Current fan-out item (set only inside fan-out iterations).
item: Any = None

#: Fan-in aggregated results (set only for fan-in steps).
fan_in: dict[str, Any] = field(default_factory=dict)

#: Workflow-level default integration key.
default_integration: str | None = None

#: Workflow-level default model.
default_model: str | None = None

#: Workflow-level default options.
default_options: dict[str, Any] = field(default_factory=dict)

#: Project root path.
project_root: str | None = None

#: Current run ID.
run_id: str | None = None


@dataclass
class StepResult:
"""Return value from a step execution."""

#: Step status.
status: StepStatus = StepStatus.COMPLETED

#: Output data (stored as ``steps.<id>.output``).
output: dict[str, Any] = field(default_factory=dict)

#: Nested steps to execute (for control-flow steps like if/then).
next_steps: list[dict[str, Any]] = field(default_factory=list)

#: Error message if step failed.
error: str | None = None


class StepBase(ABC):
"""Abstract base class for workflow step types.

Every step type — built-in or extension-provided — implements this
interface and registers in ``STEP_REGISTRY``.
"""

#: Matches the ``type:`` value in workflow YAML.
type_key: str = ""

@abstractmethod
def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
"""Execute the step with the given config and context.

Parameters
----------
config:
The step configuration from workflow YAML.
context:
The execution context with inputs, accumulated step results, etc.

Returns
-------
StepResult with status, output data, and optional nested steps.
"""

def validate(self, config: dict[str, Any]) -> list[str]:
"""Validate step configuration and return a list of error messages.

An empty list means the configuration is valid.
"""
errors: list[str] = []
if "id" not in config:
errors.append("Step is missing required 'id' field.")
return errors

def can_resume(self, state: dict[str, Any]) -> bool:
"""Return whether this step can be resumed from the given state."""
return True
Loading
Loading