Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
7aa424d
feat(workflows): compose workflows with a unified execution tree
Sep 26, 2026
4dde245
fix(workflows): complete composition cleanup phase one
Sep 27, 2026
1d509d2
refactor(workflows): centralize execution tree access
Sep 27, 2026
e487b53
fix(workflows): narrow composition call failures
Sep 27, 2026
cc8e9de
fix(workflows): replay execution projections
Sep 27, 2026
b84ec98
fix(workflows): qualify execution events
Sep 27, 2026
8a43164
fix(workflows): share execution snapshots
Sep 27, 2026
5565ecb
docs(workflows): document composition resume
Sep 27, 2026
fa0dda8
fix(workflows): preserve replay projections
Sep 27, 2026
1a3ca48
fix(workflows): reject inconsistent execution trees
Sep 27, 2026
612cdf3
fix(workflows): narrow workflow target resolution failures
Sep 27, 2026
9fe722a
refactor(workflows): replay fan-out through the live path
Sep 27, 2026
cd911dc
fix(workflows): match main events for unknown step types
Sep 27, 2026
6792bea
fix(workflows): omit unprojected fan-out item results
Sep 27, 2026
6b776ce
fix(workflows): preserve composed fan-out aliases
Sep 28, 2026
21d6eaf
Refactor condition for raising ValueError on execution
markuswondrak Sep 28, 2026
01bee4d
fix(workflows): validate composition checkpoint boundaries
Sep 28, 2026
9375b86
fix(workflows): make fan-out item aliases reporting-only
Sep 28, 2026
3c50326
fix(workflows): stop logging after checkpoint failure; treat unknown …
markuswondrak Sep 29, 2026
f2dca00
test(workflows): cover resume snapshot check with native YAML scalars
markuswondrak Sep 29, 2026
97874f3
Refactor executor run with error handling
markuswondrak Sep 29, 2026
2c0e141
fix(workflows): report qualified current_step_id; keep typed gate mes…
Sep 29, 2026
9711489
Merge remote-tracking branch 'upstream/main' into feat/4680-compositi…
Sep 29, 2026
1b0a3a4
fix(workflows): persist the active step before it executes
Sep 29, 2026
3e5e66e
fix(workflows): persist workflow calls before announcing them
Sep 30, 2026
e5a4223
fix(workflows): let interrupts during checkpoints pause the run
Sep 30, 2026
8e9c5fa
fix(workflows): replay the result of aborted fan-out items
Sep 30, 2026
4db5a07
fix(workflows): hide partial fan-out results from resumed items
Sep 30, 2026
c049f69
fix(workflows): validate occurrence lifecycle and report interrupted …
Oct 1, 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
27 changes: 24 additions & 3 deletions design/workflow-step.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,30 @@ unless `continue_on_error: true` is set (an explicit abort always stops).
The engine calls `validate()` during workflow validation but does not
automatically validate a definition passed to `execute()`. Guard invalid
configurations in `execute()` too, returning a failed result rather than a
successful default or an unhandled exception. Resume restarts the current
top-level step; a pause inside nested steps re-runs their parent and nested
body. Design side effects accordingly.
successful default or an unhandled exception.

New runs persist an execution tree. Resume replays completed occurrences into
their expression contexts without calling their implementations, then continues
at the unfinished occurrence. Selected branches, custom expansions, loop
iterations, fan-out items, and bound workflow definitions therefore remain
frozen across resume. This is intentionally forward-only: new resume inputs do
not reinterpret or repeat completed work. Legacy states enter the tree once
through their saved top-level index.

The common occurrence runner owns start, checkpoint, replay, and finalization.
Step implementations return results or expansions; they do not manage persisted
lifecycle state. Internal workflow-call and registered-step paths share the
same checked transitions and post-checkpoint notifications. Per-occurrence
activity belongs to the execution tree, never to the shared step instance.

Fan-out items have independent expression contexts. Their internal results do
not enter the shared parent context; the parent receives the qualified item
result and the fan-out step's ordered `output.results`. Fan-out item aliases are
reporting-only; a `fan-in` `wait_for` targets declared step IDs (the fan-out
step's own `id`) and reads its ordered `output.results`, never a generated item
alias. A side effect performed
before its completion checkpoint can still repeat after an interruption, so the
guarantee is at-least-once rather than exactly-once.

The registry holds one shared instance per type. Concurrent `fan-out` can
invoke that instance from multiple threads: keep execution stateless and
Expand Down
174 changes: 170 additions & 4 deletions docs/reference/workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,17 @@ specify workflow resume <run_id>
| `-i` / `--input` | Updated input values as `key=value` (repeatable) |
| `--json` | Emit the resume outcome as a single JSON object |

Resumes a paused or failed workflow run from the exact step where it stopped. Useful after responding to a gate step or fixing an issue that caused a failure.

Supplied `--input` values are merged over the run's stored inputs and re-validated against the workflow's input types, then the blocked step is re-run with the updated values. This lets a run continue with information that only became available after it paused, or with a corrected value after a failure:
Resumes a paused or failed workflow run from its persisted execution state. A
`running` run is not resumable. Resume replays completed work into its
expression contexts without running step implementations again, then continues
at the unfinished occurrence. This lets a run continue with information that
only became available after it paused, or with a corrected value after a
failure.

Supplied `--input` values are merged over the run's stored root inputs and
re-validated against the workflow's input types. Unknown root input names are
ignored. Updated values affect only unfinished work; completed steps and calls
are not reinterpreted or repeated:

```bash
specify workflow resume <run_id> --input cmd="exit 0"
Expand Down Expand Up @@ -579,6 +587,162 @@ specify workflow run speckit -i spec="Build a kanban board with drag-and-drop ta
| `do-while` | Execute at least once, then loop on condition |
| `fan-out` | Dispatch a step for each item in a list |
| `fan-in` | Aggregate results from a fan-out step |
| `workflow` | Call an installed workflow with private inputs and declared outputs |

### Workflow composition

A `workflow` step executes an installed, enabled workflow in the current project
as a private scope of the same run. Targets can be literal IDs or expressions;
the resolved string must match the ID exactly, including case and whitespace.

```yaml
inputs:
target: {type: string, required: true}
report: {type: string, required: true}
steps:
- id: investigate
type: workflow
workflow: "{{ inputs.target }}"
input:
report: "{{ inputs.report }}"
```

The included workflow sees only declared inputs passed through `input` and its
own step results. It does not inherit the caller's `inputs`, step results,
fan-out `item`/`fan_in` values, or workflow defaults. Unknown input names,
missing required values, and invalid types/enums fail the call. Existing child
defaults and `integration: auto` resolution apply. Values cross back only
through explicit declarations:

```yaml
outputs:
report:
value: "{{ steps.analyze.output.stdout }}"
```

The caller reads `{{ steps.investigate.output.report }}`. Output includes
`workflow` and `status`; failures include `error` when the failed operation
reported one, and an abort includes `aborted: true`. These names and
`integration`, `model`, `options`, and `input` are reserved. Returned values
must be JSON-safe. Private inputs, step records, and logs are not part of the
return value. No separate child run is created.

`continue_on_error: true` on a workflow call handles a returned child failure
and call-boundary contract failures, such as an unavailable target, invalid
mapped input, cycle, depth limit, or invalid declared output. It does not catch
step exceptions, expression errors (including `from_json` errors in `input:` or
`outputs:`), interruptions, or checkpoint failures; those propagate exactly as
they do at the root. An unavailable step implementation is terminal where the
step occurs; at a workflow call it is a reported child failure, whether it is
detected while binding the target or later. Pauses and explicit aborts always
stop execution.

Nested composition is allowed, but repeated workflow IDs on the active call
path are cycles. Diamonds are allowed. The maximum included depth is 16, with
the root at depth zero.

### Calling a Child Gate

Only root-declared inputs can be supplied to `workflow resume --input`. Map a
root input through each workflow boundary when a child gate uses it as a
`verdict_input`:

```yaml
# Parent workflow
inputs:
approval:
type: string
default: ""
steps:
- id: review-release
type: workflow
workflow: release-notes
input:
approval: "{{ inputs.approval }}"
```

```yaml
# Installed release-notes workflow
inputs:
approval:
type: string
default: ""
steps:
- id: review
type: gate
message: "Approve the release notes?"
options: [approve, reject]
verdict_input: approval
```

The initial run pauses if `approval` is empty. Its structured status identifies
the nested gate and its enclosing call with `gate.scope_path`. Resume the root
run, not the child, to continue it:

```bash
specify workflow status <run-id> --json
specify workflow resume <run-id> --input approval=approve
```

A gate with `verdict_input` is not supported inside a fan-out item, including
through one or more workflow calls. The `inside_fan_out` runtime condition is
preserved across workflow boundaries.

### Defaults and Errors at the Call Boundary

Parent `integration`, `model`, and `options` defaults apply only to steps in
the parent workflow. A child uses its own defaults or automatic resolution. To
make a value common to both, declare it as a child input and map it explicitly.
The call result intentionally has an empty `input` field and does not expose
the child's private inputs, step records, or logs.

An initial binding or output-finalization contract failure can be handled with
`continue_on_error`. A failure while rebinding an incomplete call during
`workflow resume --input` is different: it propagates, leaves the call and its
child subtree unchanged, and can be retried with corrected root inputs.

### Execution identity and resume

Each step occurrence owns a record in a persisted execution tree. An authored
step ID is a local expression alias, not a global execution ID. Fan-out items
have independent alias contexts and ordered item results. Public reporting uses
qualified occurrence IDs where needed, for example `fan:template:0` for a
fan-out item and `loop:step:1` for a later loop iteration. Qualified IDs are
not expression names. Fan-out item aliases are reporting-only: a `fan-in`
`wait_for` references declared step IDs, in particular the fan-out step's own
`id`, whose ordered item results are available as
`steps.<fan-out-id>.output.results`.

New runs persist selected branches, dynamic custom-step expansions, loop
iterations, and fan-out items. Resume retains completed work without
reevaluating already selected branches. Workflow targets and overlay-resolved
definitions remain bound even if installations change. An unbound call still
checks that its target is installed and enabled when execution reaches it.

Ordinary resume retains bound inputs. Explicit `--input` rebinds reached,
incomplete calls through their original mappings: newly mapped values override
the prior binding, while values not mapped again retain their bound values.
Completed calls retain their results. Failed output evaluation retries only
finalization, without repeating completed child commands.

Snapshots are stored as YAML strings inside the private JSON execution tree,
preserving YAML scalar types. Inputs and results remain JSON values. The
snapshot freezes workflow definitions and expansions, not files under
`context.workflow_dir` or step implementations; missing resources can still
cause ordinary step failures. Legacy runs without a tree enter through their
saved root index, then use tree-backed resume. Resume is available only for
runs in the `paused` or `failed` state.

`current_step_index` is the root-sequence index; `current_step_id` is the
occurrence ID of the active leaf, matching the event `step_id` (for example
`fan:item:0` for a fan-out item or `loop:body:1` for a later loop iteration).
Inside a workflow call, IDs are relative to the called workflow. Structured
run/status output includes `workflow_scopes`
summaries when calls exist and reports an active nested gate with its
`scope_path`. Private log events add `workflow_id` and `execution_path` to the
qualified `step_id`. A step emits completion after its checkpoint; containers
emit before their children, while replayed completed occurrences emit no step
events.

> **Security note:** a `shell` step runs a local command with **your** privileges. There is no capability sandbox — `requires` is an advisory pre-condition block (spec-kit version, integrations), not a runtime gate, so it does **not** restrict what a step can do. In particular there is no `requires.permissions` capability gate: it is rejected by validation precisely because it would imply a sandbox that does not exist. Review any catalog or downloaded workflow before running it, and use a `gate` step to require explicit approval before sensitive or destructive shell commands.

Expand Down Expand Up @@ -692,7 +856,9 @@ Each workflow run persists its state at `.specify/workflows/runs/<run_id>/`:
- `inputs.json` — resolved input values
- `log.jsonl` — step-by-step execution log

This enables `specify workflow resume` to continue from the exact step where a run was paused (e.g., at a gate) or failed.
This enables `specify workflow resume` to replay completed occurrences and
continue at the unfinished occurrence where a run paused (for example, at a
gate) or failed.

### Gate Verdict Inputs

Expand Down
2 changes: 2 additions & 0 deletions src/specify_cli/workflows/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ def _register_builtin_steps() -> None:
from .step.fan_in import FanInStep
from .step.fan_out import FanOutStep
from .step.gate import GateStep
from .step.workflow import WorkflowStep
from .step.if_then import IfThenStep
from .step.init import InitStep
from .step.prompt import PromptStep
Expand All @@ -62,6 +63,7 @@ def _register_builtin_steps() -> None:
_register_step(FanInStep())
_register_step(FanOutStep())
_register_step(GateStep())
_register_step(WorkflowStep())
_register_step(IfThenStep())
_register_step(InitStep())
_register_step(PromptStep())
Expand Down
28 changes: 24 additions & 4 deletions src/specify_cli/workflows/_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -947,6 +947,12 @@ def _workflow_run_payload(state: Any) -> dict[str, Any]:
error = _failed_step_error(state)
if error is not None:
payload["error"] = error
if getattr(state, "execution", None):
from ._execution import scope_summaries

scopes = scope_summaries(state.execution, state.status.value)
if scopes:
payload["workflow_scopes"] = scopes
return payload


Expand Down Expand Up @@ -985,21 +991,35 @@ def _gate_outcome(state: Any) -> dict[str, Any] | None:
if getattr(state.status, "value", state.status) not in ("paused", "aborted"):
return None
step = (getattr(state, "step_results", None) or {}).get(state.current_step_id)
step_id = state.current_step_id
scope_path = None
if getattr(state, "execution", None):
from ._execution import active_step

active = active_step(state.execution)
if active is None:
return None
path, node, step_id = active
scope_path = path[:-1]
step = node.get("result")
if not isinstance(step, dict) or not _is_gate_step(step):
return None
output = step.get("output") or {}
# `message`, `options`, and `choice` may be non-string YAML literals in an
# unvalidated workflow (GateStep coerces none of them for the payload), so
# `message`, `options`, and `choice` may be non-string YAML literals in
# legacy or synthetic records, so
# normalise all three for a stable JSON schema: message → str, options →
# list[str] | None, choice → str | None (None means no decision yet).
message = output.get("message")
choice = output.get("choice")
return {
"step_id": state.current_step_id,
detail = {
"step_id": step_id,
"message": None if message is None else str(message),
"options": _normalize_gate_options(output.get("options")),
"choice": None if choice is None else str(choice),
}
if scope_path:
detail["scope_path"] = scope_path
return detail


def _normalize_gate_options(options: Any) -> list[str] | None:
Expand Down
Loading
Loading