Skip to content

Commit 381ce3b

Browse files
markuswondrakMarkusCopilotCopilotMarkus Wondrak
authored
feat(workflows): compose workflows with a unified execution tree (#4764)
* feat(workflows): compose workflows with a unified execution tree Keep invocation results and workflow bindings on the same execution occurrence. Isolate fan-out contexts and resume persisted expansions through one executor. Assisted-by: OpenCode (model: gpt-6-astra, autonomous) * fix(workflows): complete composition cleanup phase one Assisted-by: OpenCode (model: gpt-5.6-terra, autonomous) * refactor(workflows): centralize execution tree access Assisted-by: OpenCode (model: gpt-5.6-terra, autonomous) * fix(workflows): narrow composition call failures Assisted-by: OpenCode (model: gpt-5.6-terra, autonomous) * fix(workflows): replay execution projections Assisted-by: OpenCode (model: gpt-5.6-terra, autonomous) * fix(workflows): qualify execution events Assisted-by: OpenCode (model: gpt-5.6-terra, autonomous) * fix(workflows): share execution snapshots Assisted-by: OpenCode (model: gpt-5.6-terra, autonomous) * docs(workflows): document composition resume Assisted-by: OpenCode (model: gpt-5.6-terra, autonomous) * fix(workflows): preserve replay projections Assisted-by: OpenCode (model: gpt-5.6-terra, autonomous) * fix(workflows): reject inconsistent execution trees Assisted-by: OpenCode (model: deepseek-v4.1-flash, autonomous) * fix(workflows): narrow workflow target resolution failures Assisted-by: OpenCode (model: deepseek-v4.1-flash, autonomous) * refactor(workflows): replay fan-out through the live path Assisted-by: OpenCode (model: deepseek-v4.1-flash, autonomous) * fix(workflows): match main events for unknown step types Assisted-by: OpenCode (model: deepseek-v4.1-flash, autonomous) * fix(workflows): omit unprojected fan-out item results Preserve the item traversal's projection decision for missing step types. Cover sequential and parallel execution, inherited aliases, unnamed templates, and resume after reinstalling the implementation. Assisted-by: OpenCode (model: gpt-6-astra, autonomous) * fix(workflows): preserve composed fan-out aliases Keep fan-out item aliases in the enclosing workflow context without exposing child workflow internals in root results. Normalize unnamed templates to the item ID for result aliases and lifecycle events, and recognize generated item aliases during fan-in validation. Assisted-by: OpenCode (model: gpt-5.6-terra, autonomous) * Refactor condition for raising ValueError on execution Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix(workflows): validate composition checkpoint boundaries Assisted-by: OpenCode (model: gpt-5.6-terra, autonomous) * fix(workflows): make fan-out item aliases reporting-only Fan-out item aliases (fan:template:N) are not valid fan-in wait_for targets. In a loop iteration >= 1 the authored alias is never refreshed, so a fan-in that waited on it silently joined the previous iteration's values. Joins now use the fan-out step ID and its ordered output.results instead. Revert the fan_out_aliases validation allowance so wait_for again requires declared step IDs (matching main), and reject ':' wait_for entries at runtime in FanInStep.execute() so unvalidated runs fail loudly rather than joining a stale or empty item result. Assisted-by: OpenCode (model: deepseek-v4.1-flash, autonomous) * fix(workflows): stop logging after checkpoint failure; treat unknown child steps as call failures RunState.append_log() now checks _checkpoint_failed under the state lock (lock order _lock -> _log_lock), so no concurrent fan-out worker can append a completion or nested start event after another worker's checkpoint failed. This restores the documented guarantee that a checkpoint failure prevents further writes. Remove the error-text classification of unavailable child implementations at the workflow-call boundary. A missing implementation stays terminal where the step occurs (root and fan-out, as on main); at a workflow call it is a reported child failure, whether detected at bind time or after binding, so call-level continue_on_error treats both consistently and step-controlled error strings are no longer interpreted. Assisted-by: GitHub Copilot (model: claude-opus-5.5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test(workflows): cover resume snapshot check with native YAML scalars Add a resume regression for a workflow snapshot containing `.nan` (max_concurrency sequential fallback) and reordered keys, and cover the previously untested rejection of a changed workflow snapshot. Both pass on the existing comparison: PyYAML decodes `.nan` to a shared object, so the snapshots compare equal without a production change. Assisted-by: GitHub Copilot (model: claude-opus-5.5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Refactor executor run with error handling Refactor execution handling to ensure active step is updated even if an error occurs during execution. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix(workflows): report qualified current_step_id; keep typed gate messages; halt fan-out on worker exceptions current_step_id again uses the occurrence ID that events and step_results use, as on main: a paused or failed root fan-out item reports `fan:item:0` and a later loop iteration `loop:body:1`. Inside a workflow call, the ID is relative to the called workflow. walk_execution() now derives this ID with the executor's qualification rules, so the post-run reconciliation (kept for exception paths) and the gate payload's step_id report the same value. Gate `message` keeps its typed template result (for example 42 stays an int). Only values the JSON checkpoint cannot persist, such as native YAML dates, are stored as text; presentation still stringifies. A fan-out worker exception now sets the halt flag before re-raising, so earlier items finishing in order no longer dispatch further items after a later item has already raised. Assisted-by: GitHub Copilot CLI (model: claude-opus-5.5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(workflows): persist the active step before it executes The executor updated current_step_id and current_step_index only in memory and wrote them with the next checkpoint, after the step had finished. While a long step ran, and after a hard crash, state.json still named the previous step, so `workflow status` and the run payload reported the wrong occurrence. main saves before each step starts. Checkpoint the active occurrence before it executes, so root steps, later loop iterations, and fan-out items are persisted under their qualified IDs while they run. This adds one state write per started step, as on main; the checkpoint-count tests are updated accordingly. Assisted-by: OpenCode (model: claude-opus-5.5, autonomous) * fix(workflows): persist workflow calls before announcing them 1b0a3a4 checkpointed the active occurrence before step_started, but only on the shared step path. Workflow calls return to Execution.workflow() before that path and emitted step_started without a checkpoint. Until binding was saved, and on resume of an already-bound call until the first child step, state.json named the previous step, and a crash or checkpoint failure could leave a start event for the call that no checkpoint reflected. Move the start sequence (set current_step_id, checkpoint, then log and callback) into one Execution.start() helper used by both paths, so the rule lives in one place. The ID update and checkpoint now happen under one lock hold. Tests cover top-level, nested, and resumed bound calls, and a failed start checkpoint that must leave no start event; all three failed before this change. Assisted-by: OpenCode (model: claude-opus-5.5, autonomous) * fix(workflows): let interrupts during checkpoints pause the run RunState.save() caught BaseException around the checkpoint write, so a KeyboardInterrupt during any checkpoint became CheckpointError, marked the state as failed to checkpoint, and bypassed the KeyboardInterrupt handler in execute()/resume(). The run stayed "running" and could not be resumed. On main the interrupt reaches that handler and the run pauses. Introduced in 7aa424d. Convert only Exception to CheckpointError. Write failures still stop further writes; interrupts propagate unchanged, and the pause re-saves the tree from memory. Tests interrupt a checkpoint at the root, inside a workflow call, and during resume; all failed before this change. Assisted-by: OpenCode (model: claude-opus-5.5, autonomous) * fix(workflows): replay the result of aborted fan-out items 6792bea made fan-out items publish only the result their traversal projected, so missing step implementations publish nothing. The replay of an already-aborted occurrence returned without projecting its stored result, so after a sibling item resumed, the aborted item appeared as {} in the fan-out's ordered results. Project the stored result before returning "aborted", as the live run did when the occurrence aborted. Nothing is re-executed and no events are emitted. The aborted-sibling regression now checks the item output for a step, an if container, and a workflow call template; all three failed before this change. Assisted-by: OpenCode (model: claude-opus-5.5, autonomous) * fix(workflows): hide partial fan-out results from resumed items A halted fan-out checkpoints its partial results in its record for reporting. On resume that record is projected before the items restart, so re-executed and not-yet-started items saw steps.<fan>.output.results with a stale prefix, including the paused item's own earlier output. Items in a live run, and on main, never see results: the engine adds them only after the items finish. Present since 7aa424d. Remove the engine-added results from the fan-out's own entry in the item-local snapshot. The parent context and checkpoint keep them. Add an invariant test: a resumed item must see exactly the context it has in a run that never paused, for step, if-container, and workflow-call templates, sequentially and in parallel. It found only this difference; the four affected cases failed before this change. Assisted-by: OpenCode (model: claude-opus-5.5, autonomous) * fix(workflows): validate occurrence lifecycle and report interrupted scopes Persist fan-out templates as YAML, reject invalid resume offsets and tree states, and check only changed nodes during lifecycle transitions. Preserve original step exceptions during best-effort cleanup and derive interrupted bound-call status from the run. Cover regressions and document the contract. Assisted-by: OpenCode (model: gpt-6-sol, autonomous) * test(workflows): exercise legacy migration checkpoint failure Assisted-by: GitHub Copilot (model: GPT-5.6 Luna, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(workflows): discard a resumed call's previous attempt status A workflow call that paused kept its recorded `status: paused` result while blocked. When resume re-entered the call and a later child step raised, the call stayed blocked with that stale result, so status reported the scope as paused even though the run had failed. Re-entering a blocked, bound call now returns it to its children phase and drops the previous attempt's result, outcome, and error. Its children hold the authoritative progress; an exception leaves no recorded call status, so the scope reflects the run status. Leaf steps keep their result because finish replaces it on re-execution. Assisted-by: GitHub Copilot (model: Claude Opus 5.5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs(workflows): describe current_step_id as a compatibility view The public reference and the architecture document promised that `current_step_id` always identifies the active execution-tree leaf. It does not: it is updated as occurrences are entered, but completion does not reconcile it, so a parallel fan-out can leave it naming an item that has already finished while another item remains active. Apply the reviewer's suggested wording: document `current_step_id` as a compatibility occurrence-ID view rather than an exact active-step set, and identify the tree's per-occurrence `active` flags as authoritative. Assisted-by: OpenCode (model: deepseek-v4.1-flash, autonomous) * fix(workflows): discard stale status of every re-entered blocked call A workflow call whose binding failed (for example, an uninstalled target) is blocked with a failed result but no binding. The re-entry cleanup from 53a434d only ran for bound calls, so a retry that then bound successfully carried the old result into the children phase. If the child was interrupted, status reported the scope as failed while the run was paused; after an exception, the call kept the stale binding error. A call's recorded result summarizes its previous attempt, so begin now drops result, outcome, and error for every blocked workflow call. A bound call continues at its children; an unbound call returns to ready and binds again. Leaf steps keep their record until finish replaces it, because it describes that same step: an interrupted resumed gate still reports its prompt in status, as on main. The unbound-call regression fails before this change (scope reported failed instead of paused; stale error retained). The gate test guards against extending the reset to leaf steps. Assisted-by: OpenCode (model: claude-opus-5.5, autonomous) * docs(workflows): state interrupt handling of the setup checkpoint The RunState.save() comment said a KeyboardInterrupt always reaches the pause path of execute()/resume(). That holds during step execution, but the setup save before execution is outside that handler: an interrupt after it replaces state.json leaves the run `running`. Describe that accurately. Also drop comparisons with the main branch from code comments, a test comment, and the architecture document; once merged they no longer describe anything. No behavior change. Assisted-by: OpenCode (model: claude-opus-5.5, autonomous) * fix(workflows): stop gate interrupt test leaking into later tests test_interrupted_resumed_gate_keeps_its_prompt_in_status patched execute on the shared STEP_REGISTRY["gate"] instance. Undoing that patch leaves the original bound method as an instance attribute, which shadows later class-level patches of GateStep.execute. In the full suite this made test_resume_preserves_original_workflow_dir stay paused after resume. Patch the GateStep class instead so teardown restores it cleanly. Assisted-by: OpenCode (model: claude-opus-5.5, autonomous) * test(workflows): pin and document the fan-out step_template removal The rewrite stopped publishing a fan-out's raw `step_template` in its step output, but nothing checked it end to end: the only `step_template` assertions call FanOutStep.execute() directly, before the engine strips the field. The public workflow reference did not mention it either. Add an engine-level test that runs a fan-out, pauses, and resumes. It checks that the template stays in the private execution tree and is absent from the live, replayed, and persisted fan-out output, from a fan-in aggregate, and from expressions. It fails when the engine publishes the template again. Add a migration note to docs/reference/workflows.md describing the removed field and where to read the template instead. Assisted-by: OpenCode (model: claude-opus-5.5, autonomous) * docs(workflows): clarify rebind failure recovery Document that retrying a previously bound workflow call discards its stale result, outcome, and error while preserving the binding and child subtree. Also describe the ready state used when an initial binding failed.\n\nAssisted-by: GitHub Copilot (model: GPT-5.6 Luna, autonomous)\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Markus <markus@example.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Markus Wondrak <markuswondrak@gmail.com>
1 parent 1a25202 commit 381ce3b

20 files changed

Lines changed: 5626 additions & 605 deletions

File tree

‎design/workflow-step.md‎

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,30 @@ unless `continue_on_error: true` is set (an explicit abort always stops).
4141
The engine calls `validate()` during workflow validation but does not
4242
automatically validate a definition passed to `execute()`. Guard invalid
4343
configurations in `execute()` too, returning a failed result rather than a
44-
successful default or an unhandled exception. Resume restarts the current
45-
top-level step; a pause inside nested steps re-runs their parent and nested
46-
body. Design side effects accordingly.
44+
successful default or an unhandled exception.
45+
46+
New runs persist an execution tree. Resume replays completed occurrences into
47+
their expression contexts without calling their implementations, then continues
48+
at the unfinished occurrence. Selected branches, custom expansions, loop
49+
iterations, fan-out items, and bound workflow definitions therefore remain
50+
frozen across resume. This is intentionally forward-only: new resume inputs do
51+
not reinterpret or repeat completed work. Legacy states enter the tree once
52+
through their saved top-level index.
53+
54+
The common occurrence runner owns start, checkpoint, replay, and finalization.
55+
Step implementations return results or expansions; they do not manage persisted
56+
lifecycle state. Internal workflow-call and registered-step paths share the
57+
same checked transitions and post-checkpoint notifications. Per-occurrence
58+
activity belongs to the execution tree, never to the shared step instance.
59+
60+
Fan-out items have independent expression contexts. Their internal results do
61+
not enter the shared parent context; the parent receives the qualified item
62+
result and the fan-out step's ordered `output.results`. Fan-out item aliases are
63+
reporting-only; a `fan-in` `wait_for` targets declared step IDs (the fan-out
64+
step's own `id`) and reads its ordered `output.results`, never a generated item
65+
alias. A side effect performed
66+
before its completion checkpoint can still repeat after an interruption, so the
67+
guarantee is at-least-once rather than exactly-once.
4768

4869
The registry holds one shared instance per type. Concurrent `fan-out` can
4970
invoke that instance from multiple threads: keep execution stateless and

‎docs/reference/workflows.md‎

Lines changed: 183 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,17 @@ specify workflow resume <run_id>
7575
| `-i` / `--input` | Updated input values as `key=value` (repeatable) |
7676
| `--json` | Emit the resume outcome as a single JSON object |
7777

78-
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.
79-
80-
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:
78+
Resumes a paused or failed workflow run from its persisted execution state. A
79+
`running` run is not resumable. Resume replays completed work into its
80+
expression contexts without running step implementations again, then continues
81+
at the unfinished occurrence. This lets a run continue with information that
82+
only became available after it paused, or with a corrected value after a
83+
failure.
84+
85+
Supplied `--input` values are merged over the run's stored root inputs and
86+
re-validated against the workflow's input types. Unknown root input names are
87+
ignored. Updated values affect only unfinished work; completed steps and calls
88+
are not reinterpreted or repeated:
8189

8290
```bash
8391
specify workflow resume <run_id> --input cmd="exit 0"
@@ -589,6 +597,175 @@ specify workflow run speckit -i spec="Build a kanban board with drag-and-drop ta
589597
| `do-while` | Execute at least once, then loop on condition |
590598
| `fan-out` | Dispatch a step for each item in a list |
591599
| `fan-in` | Aggregate results from a fan-out step |
600+
| `workflow` | Call an installed workflow with private inputs and declared outputs |
601+
602+
### Workflow composition
603+
604+
A `workflow` step executes an installed, enabled workflow in the current project
605+
as a private scope of the same run. Targets can be literal IDs or expressions;
606+
the resolved string must match the ID exactly, including case and whitespace.
607+
608+
```yaml
609+
inputs:
610+
target: {type: string, required: true}
611+
report: {type: string, required: true}
612+
steps:
613+
- id: investigate
614+
type: workflow
615+
workflow: "{{ inputs.target }}"
616+
input:
617+
report: "{{ inputs.report }}"
618+
```
619+
620+
The included workflow sees only declared inputs passed through `input` and its
621+
own step results. It does not inherit the caller's `inputs`, step results,
622+
fan-out `item`/`fan_in` values, or workflow defaults. Unknown input names,
623+
missing required values, and invalid types/enums fail the call. Existing child
624+
defaults and `integration: auto` resolution apply. Values cross back only
625+
through explicit declarations:
626+
627+
```yaml
628+
outputs:
629+
report:
630+
value: "{{ steps.analyze.output.stdout }}"
631+
```
632+
633+
The caller reads `{{ steps.investigate.output.report }}`. Output includes
634+
`workflow` and `status`; failures include `error` when the failed operation
635+
reported one, and an abort includes `aborted: true`. These names and
636+
`integration`, `model`, `options`, and `input` are reserved. Returned values
637+
must be JSON-safe. Private inputs, step records, and logs are not part of the
638+
return value. No separate child run is created.
639+
640+
`continue_on_error: true` on a workflow call handles a returned child failure
641+
and call-boundary contract failures, such as an unavailable target, invalid
642+
mapped input, cycle, depth limit, or invalid declared output. It does not catch
643+
step exceptions, expression errors (including `from_json` errors in `input:` or
644+
`outputs:`), interruptions, or checkpoint failures; those propagate exactly as
645+
they do at the root. An unavailable step implementation is terminal where the
646+
step occurs; at a workflow call it is a reported child failure, whether it is
647+
detected while binding the target or later. Pauses and explicit aborts always
648+
stop execution.
649+
650+
Nested composition is allowed, but repeated workflow IDs on the active call
651+
path are cycles. Diamonds are allowed. The maximum included depth is 16, with
652+
the root at depth zero.
653+
654+
### Calling a Child Gate
655+
656+
Only root-declared inputs can be supplied to `workflow resume --input`. Map a
657+
root input through each workflow boundary when a child gate uses it as a
658+
`verdict_input`:
659+
660+
```yaml
661+
# Parent workflow
662+
inputs:
663+
approval:
664+
type: string
665+
default: ""
666+
steps:
667+
- id: review-release
668+
type: workflow
669+
workflow: release-notes
670+
input:
671+
approval: "{{ inputs.approval }}"
672+
```
673+
674+
```yaml
675+
# Installed release-notes workflow
676+
inputs:
677+
approval:
678+
type: string
679+
default: ""
680+
steps:
681+
- id: review
682+
type: gate
683+
message: "Approve the release notes?"
684+
options: [approve, reject]
685+
verdict_input: approval
686+
```
687+
688+
The initial run pauses if `approval` is empty. Its structured status identifies
689+
the nested gate and its enclosing call with `gate.scope_path`. Resume the root
690+
run, not the child, to continue it:
691+
692+
```bash
693+
specify workflow status <run-id> --json
694+
specify workflow resume <run-id> --input approval=approve
695+
```
696+
697+
A gate with `verdict_input` is not supported inside a fan-out item, including
698+
through one or more workflow calls. The `inside_fan_out` runtime condition is
699+
preserved across workflow boundaries.
700+
701+
### Defaults and Errors at the Call Boundary
702+
703+
Parent `integration`, `model`, and `options` defaults apply only to steps in
704+
the parent workflow. A child uses its own defaults or automatic resolution. To
705+
make a value common to both, declare it as a child input and map it explicitly.
706+
The call result intentionally has an empty `input` field and does not expose
707+
the child's private inputs, step records, or logs.
708+
709+
An initial binding or output-finalization contract failure can be handled with
710+
`continue_on_error`. A failure while rebinding an incomplete call during
711+
`workflow resume --input` is different: it propagates. For a previously bound
712+
call, resume discards the prior `result`, `outcome`, and `error` while
713+
preserving its binding and child subtree, so it can be retried with corrected
714+
root inputs. A call whose initial binding failed returns to `ready` and retries
715+
target resolution and binding.
716+
717+
### Execution identity and resume
718+
719+
Each step occurrence owns a record in a persisted execution tree. An authored
720+
step ID is a local expression alias, not a global execution ID. Fan-out items
721+
have independent alias contexts and ordered item results. Public reporting uses
722+
qualified occurrence IDs where needed, for example `fan:template:0` for a
723+
fan-out item and `loop:step:1` for a later loop iteration. Qualified IDs are
724+
not expression names. Fan-out item aliases are reporting-only: a `fan-in`
725+
`wait_for` references declared step IDs, in particular the fan-out step's own
726+
`id`, whose ordered item results are available as
727+
`steps.<fan-out-id>.output.results`.
728+
729+
> **Migration note:** a fan-out's output no longer includes `step_template`.
730+
> `steps.<fan-out-id>.output` and the fan-out's entry in a `fan-in`'s
731+
> `output.results` contain `items`, `max_concurrency`, `item_count`, and
732+
> `results`; the template is kept only in the private execution tree that runs
733+
> the items. An expression such as `{{ steps.<fan-out-id>.output.step_template }}`
734+
> now resolves to empty. Read the template from the fan-out's `step` in the
735+
> workflow definition instead.
736+
737+
New runs persist selected branches, dynamic custom-step expansions, loop
738+
iterations, and fan-out items. Resume retains completed work without
739+
reevaluating already selected branches. Workflow targets and overlay-resolved
740+
definitions remain bound even if installations change. An unbound call still
741+
checks that its target is installed and enabled when execution reaches it.
742+
743+
Ordinary resume retains bound inputs. Explicit `--input` rebinds reached,
744+
incomplete calls through their original mappings: newly mapped values override
745+
the prior binding, while values not mapped again retain their bound values.
746+
Completed calls retain their results. Failed output evaluation retries only
747+
finalization, without repeating completed child commands.
748+
749+
Snapshots are stored as YAML strings inside the private JSON execution tree,
750+
preserving YAML scalar types. Inputs and results remain JSON values. The
751+
snapshot freezes workflow definitions and expansions, not files under
752+
`context.workflow_dir` or step implementations; missing resources can still
753+
cause ordinary step failures. Legacy runs without a tree enter through their
754+
saved root index, then use tree-backed resume. Resume is available only for
755+
runs in the `paused` or `failed` state.
756+
757+
`current_step_index` is the root-sequence index; `current_step_id` is a
758+
compatibility occurrence-ID view updated as steps are entered. It is not an
759+
exact active-step set: during parallel fan-out it may name an item that has
760+
already completed. Qualified values include `fan:item:0` for a fan-out item
761+
and `loop:body:1` for a later loop iteration.
762+
Inside a workflow call, IDs are relative to the called workflow. Structured
763+
run/status output includes `workflow_scopes`
764+
summaries when calls exist and reports an active nested gate with its
765+
`scope_path`. Private log events add `workflow_id` and `execution_path` to the
766+
qualified `step_id`. A step emits completion after its checkpoint; containers
767+
emit before their children, while replayed completed occurrences emit no step
768+
events.
592769

593770
> **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.
594771

@@ -935,7 +1112,9 @@ Each workflow run persists its state at `.specify/workflows/runs/<run_id>/`:
9351112
- `inputs.json` — resolved input values
9361113
- `log.jsonl` — step-by-step execution log
9371114

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

9401119
### Gate Verdict Inputs
9411120

‎src/specify_cli/workflows/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ def _register_builtin_steps() -> None:
4949
from .step.fan_in import FanInStep
5050
from .step.fan_out import FanOutStep
5151
from .step.gate import GateStep
52+
from .step.workflow import WorkflowStep
5253
from .step.if_then import IfThenStep
5354
from .step.init import InitStep
5455
from .step.prompt import PromptStep
@@ -62,6 +63,7 @@ def _register_builtin_steps() -> None:
6263
_register_step(FanInStep())
6364
_register_step(FanOutStep())
6465
_register_step(GateStep())
66+
_register_step(WorkflowStep())
6567
_register_step(IfThenStep())
6668
_register_step(InitStep())
6769
_register_step(PromptStep())

‎src/specify_cli/workflows/_commands.py‎

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -941,6 +941,12 @@ def _workflow_run_payload(state: Any) -> dict[str, Any]:
941941
error = _failed_step_error(state)
942942
if error is not None:
943943
payload["error"] = error
944+
if getattr(state, "execution", None):
945+
from ._execution import scope_summaries
946+
947+
scopes = scope_summaries(state.execution, state.status.value)
948+
if scopes:
949+
payload["workflow_scopes"] = scopes
944950
return payload
945951

946952

@@ -979,21 +985,35 @@ def _gate_outcome(state: Any) -> dict[str, Any] | None:
979985
if getattr(state.status, "value", state.status) not in ("paused", "aborted"):
980986
return None
981987
step = (getattr(state, "step_results", None) or {}).get(state.current_step_id)
988+
step_id = state.current_step_id
989+
scope_path = None
990+
if getattr(state, "execution", None):
991+
from ._execution import active_step
992+
993+
active = active_step(state.execution)
994+
if active is None:
995+
return None
996+
path, node, step_id = active
997+
scope_path = path[:-1]
998+
step = node.get("result")
982999
if not isinstance(step, dict) or not _is_gate_step(step):
9831000
return None
9841001
output = step.get("output") or {}
985-
# `message`, `options`, and `choice` may be non-string YAML literals in an
986-
# unvalidated workflow (GateStep coerces none of them for the payload), so
1002+
# `message`, `options`, and `choice` may be non-string YAML literals in
1003+
# legacy or synthetic records, so
9871004
# normalise all three for a stable JSON schema: message → str, options →
9881005
# list[str] | None, choice → str | None (None means no decision yet).
9891006
message = output.get("message")
9901007
choice = output.get("choice")
991-
return {
992-
"step_id": state.current_step_id,
1008+
detail = {
1009+
"step_id": step_id,
9931010
"message": None if message is None else str(message),
9941011
"options": _normalize_gate_options(output.get("options")),
9951012
"choice": None if choice is None else str(choice),
9961013
}
1014+
if scope_path:
1015+
detail["scope_path"] = scope_path
1016+
return detail
9971017

9981018

9991019
def _normalize_gate_options(options: Any) -> list[str] | None:

0 commit comments

Comments
 (0)