You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
feat: add specify workflow definition for run-scoped definitions - #4912
Adds specify workflow definition <run_id> --json, a read-only command that
returns the workflow definition a run was started with. It reads only the
persisted run snapshot and frozen definitions for workflow calls that were
actually bound during execution, so output does not drift after installed
workflows are edited, overlaid, or removed.
definition is the persisted, overlay-composed root definition, passed
through without filling defaults or restructuring fields.
workflow_scopes is always present and contains each bound workflow call's scope_path, workflow ID, and frozen definition. Its paths and workflow IDs
match workflow status --json.
RunState.definition_path, load_definition(), and load_workflow_scopes() make the snapshot read surface explicit; resume and
definition share the same persisted snapshot path.
--json is required. Successful output is one JSON object on stdout; errors
are one {"error": ...} object on stderr.
Strict JSON output stringifies YAML-native values, non-string mapping keys,
and non-finite floats, and rejects recursive YAML aliases with the same
strict JSON error contract.
Decisions that diverge from the original issue
Output mirrors the persisted workflow structure rather than defining a new,
normalized definition model. schema_version therefore versions the exposed
definition shape; the JSON envelope is additive-only. This avoids partially
applying engine defaults and preserves the definition that was executed.
The command is JSON-only for now. It requires --json and exits 2 without it;
a human text renderer is intentionally deferred rather than introducing a
second output contract.
A composed run includes only definitions that are already bound in its
execution tree. Unreached calls are not resolved from current installed
workflows, because doing so would reintroduce definition drift.
The root definition remains authored: call steps are not expanded inline.
Frozen callees appear separately in workflow_scopes, which also supports a
call invoked more than once through loops or fan-out.
Errors follow the established artifact strict-JSON contract, including
project-resolution and unsafe-workflow-storage failures. workflow status --json intentionally retains its existing Rich error rendering.
Dynamically generated steps are out of scope. Steps that a custom step type
produces at run time through StepResult.next_steps are persisted in the
execution state but are not exported; definition and workflow_scopes
cover only the authored definition and bound workflow calls. The
documentation states this limit.
Follow-ups
[Bug]: Harden persisted workflow run artifact access against symlinks #4914 will harden persisted workflow-run artifact access at the shared
storage boundary. The symlink concern predates this command: resume already
reads the same persisted snapshot and run-state loading reads sibling files.
A command-local check would leave those consumers inconsistent.
Ran focused workflow, overlay, preset, extension, and engine tests: 1996 passed, 1 skipped.
Ran uvx ruff@0.15.0 check src tests.
Ran git diff --check.
Ran the primary workflow suites again: 850 passed.
Ran tests/specify_cli/workflows/test_command_definition.py: 36 passed,
including recursive YAML alias rejection.
Manually tested the working-tree CLI in /tmp/test-workflow-run-definition:
initialized a project, ran root and composed paused workflows, verified the
persisted snapshot and JSON envelope, checked workflow_scopes parity with status --json, exercised required-flag and unknown-run errors, and confirmed
unchanged output after editing then removing the installed child workflow.
The full suite was attempted twice. Both runs showed the known pre-existing
failure tests/integration/test_preset_update_workflow.py::test_preset_update_cli_contract,
which expects an older Typer missing-argument string; the isolated test still
fails unchanged. The second full run continued cleanly through 83% before the
available execution timeout.
AI Disclosure
I did not use AI assistance for this contribution
I did use AI assistance (fill in the disclosure below)
AI disclosure: Implemented and updated with OpenCode using gpt-5.6-terra
(github-copilot/gpt-5.6-terra) in autonomous mode with default reasoning.
The agent generated implementation, tests, documentation, follow-up issues, and
review responses from the reviewed issue plan and review decisions, and ran the
listed automated and manual checks. No claim of human line-by-line review is
made.
Add a strict JSON command for inspecting the definition persisted with a workflow run, including frozen definitions for bound workflow calls.\n\nCloses github#4792.\n\nAssisted-by: OpenCode (model: gpt-5.6-terra, autonomous)
Manual validation completed after opening this PR. Using the working-tree CLI in a disposable initialized project, I verified root and composed paused runs, persisted snapshots, definition/status scope parity, required-flag and unknown-run errors, and stable definition output after editing and removing the installed child workflow.
AI disclosure: Posted by Markus Wondrak with OpenCode (github-copilot/gpt-5.6-terra), default reasoning, autonomous mode; the agent performed and summarized the manual CLI validation.
Fixed the recursive YAML-alias finding. Strict workflow JSON conversion now rejects cyclic mappings, lists, and tuples, while allowing non-recursive aliases reused across separate branches. workflow definition --json reports this through its single JSON stderr error envelope.
Added a command-level regression test covering snapshot persistence of a recursive YAML alias and the strict error contract.
Validation: 36 passed for tests/specify_cli/workflows/test_command_definition.py; 133 passed across definition, run, resume, and status command suites; Ruff and git diff --check passed.
AI disclosure: Posted by Markus Wondrak with OpenCode (github-copilot/gpt-5.6-terra), default reasoning, autonomous mode. The agent implemented the fix, tests, follow-up issues, and this review summary, and ran the stated automated checks; no claim of human line-by-line review is made.
Stringified mapping keys can silently overwrite existing keys
src/specify_cli/workflows/_commands.py:1082
Stringifying a non-string mapping key inside this comprehension can silently overwrite an existing string key. For example, YAML containing both an unquoted 2026-01-01 key (loaded as date) and a quoted "2026-01-01" key has two entries before this code and only the latter afterward, so the advertised persisted definition is returned with data missing. Normalize keys explicitly and reject collisions (with a negative test) rather than allowing the comprehension to drop an entry.
Scope rendering adds avoidable traversal and per-node memory
src/specify_cli/workflows/_execution.py:368
This refactor turns scope rendering from one tree walk into two and retains an additional entry for every execution node in nodes_by_path. Since workflow trees grow with loop iterations and fan-out items and status is commonly polled, this adds avoidable O(nodes) memory and a full traversal even when there are few or no bound scopes. Iterate the nodes directly (as before), or make the shared iterator yield the node together with the path and binding.
Canonicalize every mapping key to the JSON property name json.dumps emits
before inserting it, and raise when two keys collapse to the same name.
Previously the definition command could emit duplicate JSON properties for
keys such as 1 and "1", and json.loads then silently dropped an entry.
Assisted-by: OpenCode (model: deepseek-v4.1-flash, autonomous)
Addressed the duplicate-canonical-key finding in a08f50e.
_emit_workflow_json now canonicalizes every mapping key to the JSON property name json.dumps emits before insertion, and raises ValueError when two keys collapse to the same name (e.g. 1/"1", true/"true", or a date and its string form). That routes through the existing strict-JSON error path (stdout empty, exit 1) instead of emitting duplicate properties that json.loads silently truncates. Added a parametrized regression test for int/string, bool/string and date/string collisions, and documented the behavior in docs/reference/workflows.md.
AI disclosure: posted by OpenCode (model: deepseek-v4.1-flash, autonomous) on behalf of @markuswondrak. The human selected and directed the fix and reviewed the plan; the code was not line-by-line human-reviewed before commit.
YAML alias DAG expansion causes exponential resource usage
src/specify_cli/workflows/_commands.py:1109
active_containers rejects cycles but forgets each container after returning, so a non-recursive YAML alias DAG is expanded independently at every reference. A linear-size definition such as xN: &xN [*xN-1, *xN-1] therefore causes exponential CPU, memory, and stdout growth; the repository already treats this exact pattern as a regression hazard in tests/test_resolve_template_python_parity.py:1088-1095. Reject repeated container identities or enforce a bounded expansion/output policy, and add a negative test for a deep non-cyclic alias graph.
`_emit_workflow_json` rejected recursive YAML aliases but re-expanded a
non-recursive alias DAG independently at every reference, so a linear-size
definition such as `xN: &xN [*xN-1, *xN-1]` produced exponential CPU,
memory, and stdout. Add a node budget that raises the existing strict-JSON
error instead of rejecting every reused alias, and cover both a bounded
shared alias and a deep non-cyclic DAG.
Assisted-by: opencode (model: deepseek-v4.1-flash, autonomous)
Addresses the alias-expansion finding from review 5494817862.
_emit_workflow_json now enforces a node budget in plain_json, so a non-recursive YAML alias DAG raises the strict-JSON error instead of expanding exponentially. The budget bounds expansion rather than rejecting every reused alias, so legitimate shared anchors still emit.
Added a negative test (deep non-cyclic DAG) and a positive test (bounded shared alias).
Regression evidence: the negative test fails on the parent commit (exit_code 0, ~31s emitting the exponential payload at depth 20) and passes after.
Updated docs/reference/workflows.md (Plain JSON and Errors).
100000 still leaves >1000x headroom over the largest shipped workflow
definition (~67 nodes) while capping worst-case memory and stdout.
Assisted-by: opencode (model: deepseek-v4.1-flash, autonomous)
Deep nesting causes RecursionError instead of a documented error response
src/specify_cli/workflows/_commands.py:1134
The node-count limit does not protect against nesting depth. A deeply nested acyclic value reaches Python's recursion limit after only about 1,000 nodes, so plain_json() (or the following json.dumps) raises RecursionError; workflow_definition catches only ValueError, causing a traceback instead of the documented {"error": ...} response. Convert RecursionError to ValueError here and add a deeply nested snapshot regression test.
Status polling traverses and reparses execution trees twice
src/specify_cli/workflows/_execution.py:354
This now traverses the complete execution tree twice: once to build nodes_by_path and again inside bound_scopes(). Each traversal also reparses every nested workflow definition in walk_execution() (_execution.py:91-93), so frequently polled status calls become unnecessarily more expensive as composed loop/fan-out trees grow. Keep the previous single-pass node/path walk here (or have bound_scopes yield the node) instead of allocating and then looking up this map.
Restore the module-level `_MAX_JSON_NODES` constant, matching the existing
`_MAX_WORKFLOW_YAML_BYTES` convention, and reuse it in the error message.
Assisted-by: opencode (model: deepseek-v4.1-flash, autonomous)
Handle RecursionError when loading deeply nested JSON
src/specify_cli/workflows/_commands.py:1143
json.load() can raise RecursionError for an excessively nested persisted state.json; _load_run_state() currently lets that escape, so both the new definition command and refactored status path can leak a traceback rather than a user-facing error. Normalize it alongside filesystem failures (and cover the strict-JSON definition path with a regression test).
Avoid materializing the execution tree twice
src/specify_cli/workflows/_execution.py:354
This materializes a path -> node entry for every occurrence and then walks the entire tree again in bound_scopes(). Large loop/fan-out runs therefore add O(total execution nodes) temporary memory—even when they contain no workflow calls—where the previous implementation streamed the tree once. Keep this path one-pass (or have bound_scopes yield the node) so workflow status does not regress on large execution trees.
Handle RecursionError when loading deeply nested YAML
A syntactically valid but deeply nested persisted YAML document makes PyYAML raise RecursionError (for example, roughly 600 nested mappings), which bypasses this handler and prints a traceback instead of the promised {"error": ...} object. Include RecursionError in this boundary and add a deeply nested snapshot regression test.
Require a valid workflow ID in execution bindings
src/specify_cli/workflows/engine.py:779
validate_execution() does not guarantee this key exists: its binding check only compares bound_definition["workflow"].get("id") with binding.get("workflow"), so a corrupted state with both IDs omitted passes validation and this indexing raises an uncaught KeyError (or a null/non-string ID can enter the public payload). Explicitly require a string binding.workflow in check_node() and add a malformed-binding regression case so corrupt execution state stays on the documented JSON error contract.
load_workflow_scopes() failures are caught by this same block, so a corrupt execution tree is reported as an “Invalid workflow definition.” For example, the added unsupported-execution-version test now emits that misleading prefix. Handle root snapshot loading and scope loading separately so execution-state/binding failures are identified accurately.
- Make the JSON node budget opt-in so it applies only to
`workflow definition`, not to `workflow status --json` run lists.
- Report excessive nesting as a ValueError in the JSON emitter, the run-state
loader and the definition command instead of a RecursionError traceback.
- Have bound_scopes yield the node so scope_summaries walks the execution
tree once.
- Document that dynamically generated steps are out of scope.
Assisted-by: OpenCode (model: claude-sonnet-5.5, autonomous)
The previous commit accidentally included an unrelated staged rename of
expressions.py to expressions/__init__.py. Restore the original file.
Assisted-by: OpenCode (model: claude-sonnet-5.5, autonomous)
Review update for commit db9d616 (changes in 28fdd4f; db9d616 only restores an unrelated file that 28fdd4f included by mistake):
JSON node budget: the budget is now opt-in (max_nodes) and applied only by workflow definition. workflow status --json, run and resume are no longer capped, so large run lists keep working.
Nesting depth: excessive nesting is reported as the strict-JSON error in the emitter, the run-state loader and the definition command, instead of a RecursionError traceback. Through the CLI, YAML parsing fails before the emitter can recurse and the node budget bounds alias chains, so the emitter case is covered by a unit test, not a CLI one.
Status scope summaries:bound_scopes now yields the node, so scope_summaries walks the execution tree once. A new test fails on the previous code (two walks) and passes now.
Dynamically generated steps: deliberately out of scope. The documentation and PR description now state that steps a custom step generates through StepResult.next_steps are not exported.
Scope loading error label (not changed): execution-state errors are already reported at load time, in RunState.load via validate_execution, and carry no "Invalid workflow definition" prefix. The existing tests for an unsupported execution version, a malformed tree and an invalid bound definition show the unprefixed messages.
Validation: tests/specify_cli/workflows/test_command_definition.py 46 passed; workflow suites (tests/specify_cli/workflows, tests/workflows, tests/test_workflows.py) 1812 passed, 1 skipped; uvx ruff@0.15.0 check src tests clean. The four new tests for the budget and nesting fixes fail on the previous code and pass now.
AI disclosure: posted on behalf of @markuswondrak by OpenCode (model: claude-sonnet-5.5), autonomous mode. The agent investigated the findings, implemented the fixes and tests, and ran the checks above; no claim of human line-by-line review is made.
Use cause-neutral message for envelope size-limit errors
src/specify_cli/workflows/_commands.py:1105
This counter is decremented for every value in the entire envelope, including ordinary definition nodes and repeated workflow_scopes, so a sufficiently large valid run reaches this branch without any YAML aliases. The error then incorrectly diagnoses the cause as “nested YAML aliases.” Use a cause-neutral size-limit message; the alias-specific cases already have their own recursive-alias error.
Size-limit error (review 5499014157, medium): the node-budget message no longer names YAML aliases. It now reads "Workflow JSON output is too large to represent: it exceeds N nodes". A new test covers a large alias-free payload.
Disclosure correction: the round summary (comment 6138547138) and the commit trailers before d389d7b name the model as claude-sonnet-5.5. That was wrong. The agent was OpenCode with github-copilot/claude-haiku-5.5. The earlier commits keep the wrong name because the pushed history was not rewritten; this comment is the correction.
AI disclosure: posted on behalf of @markuswondrak by OpenCode (model: github-copilot/claude-haiku-5.5), autonomous mode. The agent made the change and ran the checks below; no human reviewed the change line by line before commit.
Validation: tests/specify_cli/workflows/test_command_definition.py 47 passed; workflow suites (tests/specify_cli/workflows, tests/workflows, tests/test_workflows.py) 1813 passed, 1 skipped; the new test fails on the previous message and passes now; ruff check clean.
default=str makes YAML set serialization nondeterministic
src/specify_cli/workflows/_commands.py:1142
Using default=str makes valid YAML !!set values nondeterministic: workflow definitions allow unknown fields, PyYAML loads a set as set, and str(set) ordering changes across CLI processes because of hash randomization. Consequently an unchanged persisted snapshot can produce different JSON, violating the run-scoped no-drift contract. Canonicalize sets/frozensets deterministically or reject them with the strict-JSON error contract instead of delegating them to str.
The node budget counts every value, so a large alias-free definition hit
the message "(nested YAML aliases)". Report the size limit without naming
a cause and cover the alias-free case with a test.
Output size is also bounded by bytes: string keys and values are charged
by their JSON length against a 5 MiB budget, so a large scalar that an
alias repeats many times cannot expand past the cap. The node budget still
bounds alias graph expansion.
Assisted-by: OpenCode (model: github-copilot/claude-haiku-5.5, autonomous)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Closes #4792.
Adds
specify workflow definition <run_id> --json, a read-only command thatreturns the workflow definition a run was started with. It reads only the
persisted run snapshot and frozen definitions for workflow calls that were
actually bound during execution, so output does not drift after installed
workflows are edited, overlaid, or removed.
definitionis the persisted, overlay-composed root definition, passedthrough without filling defaults or restructuring fields.
workflow_scopesis always present and contains each bound workflow call'sscope_path, workflow ID, and frozen definition. Its paths and workflow IDsmatch
workflow status --json.RunState.definition_path,load_definition(), andload_workflow_scopes()make the snapshot read surface explicit; resume anddefinition share the same persisted snapshot path.
--jsonis required. Successful output is one JSON object on stdout; errorsare one
{"error": ...}object on stderr.and non-finite floats, and rejects recursive YAML aliases with the same
strict JSON error contract.
Decisions that diverge from the original issue
Output mirrors the persisted workflow structure rather than defining a new,
normalized definition model.
schema_versiontherefore versions the exposeddefinition shape; the JSON envelope is additive-only. This avoids partially
applying engine defaults and preserves the definition that was executed.
The command is JSON-only for now. It requires
--jsonand exits 2 without it;a human text renderer is intentionally deferred rather than introducing a
second output contract.
A composed run includes only definitions that are already bound in its
execution tree. Unreached calls are not resolved from current installed
workflows, because doing so would reintroduce definition drift.
The root definition remains authored: call steps are not expanded inline.
Frozen callees appear separately in
workflow_scopes, which also supports acall invoked more than once through loops or fan-out.
Errors follow the established artifact strict-JSON contract, including
project-resolution and unsafe-workflow-storage failures.
workflow status --jsonintentionally retains its existing Rich error rendering.Dynamically generated steps are out of scope. Steps that a custom step type
produces at run time through
StepResult.next_stepsare persisted in theexecution state but are not exported;
definitionandworkflow_scopescover only the authored definition and bound workflow calls. The
documentation states this limit.
Follow-ups
storage boundary. The symlink concern predates this command: resume already
reads the same persisted snapshot and run-state loading reads sibling files.
A command-local check would leave those consumers inconsistent.
coverage. A
workflow.definition-only disposition would leave every existingworkflow leaf unaccounted for.
Testing
1996 passed, 1 skipped.uvx ruff@0.15.0 check src tests.git diff --check.850 passed.tests/specify_cli/workflows/test_command_definition.py:36 passed,including recursive YAML alias rejection.
/tmp/test-workflow-run-definition:initialized a project, ran root and composed paused workflows, verified the
persisted snapshot and JSON envelope, checked
workflow_scopesparity withstatus --json, exercised required-flag and unknown-run errors, and confirmedunchanged output after editing then removing the installed child workflow.
The full suite was attempted twice. Both runs showed the known pre-existing
failure
tests/integration/test_preset_update_workflow.py::test_preset_update_cli_contract,which expects an older Typer missing-argument string; the isolated test still
fails unchanged. The second full run continued cleanly through 83% before the
available execution timeout.
AI Disclosure
AI disclosure: Implemented and updated with OpenCode using
gpt-5.6-terra(
github-copilot/gpt-5.6-terra) in autonomous mode with default reasoning.The agent generated implementation, tests, documentation, follow-up issues, and
review responses from the reviewed issue plan and review decisions, and ran the
listed automated and manual checks. No claim of human line-by-line review is
made.