Skip to content

Commit 68634d8

Browse files
committed
feat(workflows): expose {{ context.run_id }} template variable
Closes #2590. Surfaces the engine-assigned run id (the same 8-character hex string Spec Kit prints as `Run ID:` at the end of `workflow run`) as a workflow template variable so YAML authors can reference it from shell `run:`, command `input.args:`, switch `expression:`, and any other field that already evaluates `{{ ... }}` templates. ### Why The run id is the natural join key between a Spec Kit workflow run and downstream artifacts, telemetry, or per-run scratch state. Today the operator sees it in stdout but workflows themselves cannot reference it — there was no way to stamp a log line, name a scratch directory, or tag an artifact with the same id Spec Kit assigned. The three motivating use cases from the issue: 1. Telemetry / observability — stamp logs and events with the run id so external systems can join workflow runs to downstream artifacts. 2. Per-run scratch / isolation — interactive operator commands that need their own state directory under `/tmp/run-<id>/`. 3. Run-id in artifact metadata — stable join key from artifact back to the producing run. ### Implementation `StepContext.run_id` is already populated by `WorkflowEngine` in both `execute()` and `resume()`. The only gap was the template namespace builder. `_build_namespace` (in `workflows/expressions.py`) now adds a `context` key alongside the existing `inputs`, `steps`, `item`, and `fan_in` namespaces: ```python ns["context"] = {"run_id": run_id} ``` The value is always present (even outside a run) and falls back to an empty string when no run is active. Workflows referencing `{{ context.run_id }}` therefore never error — a hard requirement from the issue's acceptance criteria for dry-run, validation, and ad-hoc evaluator usage. ### Default behaviour preserved Workflows that do not reference `{{ context.run_id }}` are byte-equivalent to before this change. The `context` namespace is added unconditionally to keep template resolution branch-free, but its presence has no observable effect when nothing references it. ### Tests `TestExpressions` (unit-level) gains three tests: - `test_context_run_id_resolves` — direct lookup against a `StepContext(run_id=...)`. - `test_context_run_id_defaults_to_empty_when_unset` — graceful default outside a run context. - `test_context_run_id_string_interpolation` — mixed template (e.g. `"RUN_ID={{ context.run_id }}"`). `TestContextRunId` (end-to-end) covers the three step types the acceptance criteria called out: - `test_shell_run_resolves_run_id` — `run:` field substitution, verified via captured stdout. - `test_command_input_args_resolves_run_id` — `input.args:` resolution, captured in step output even when CLI dispatch is unavailable (the artifact-metadata use case). - `test_switch_expression_matches_on_run_id` — switch matches against the resolved value, proving the run id is a first-class value in the expression engine, not just an interpolation token. - `test_workflow_without_context_reference_unchanged` — locks the byte-equivalent default required by the issue. ### Docs `workflows/README.md` gains a "Runtime Context" subsection under "Expressions" documenting the new namespace and the three canonical use patterns (telemetry, per-run scratch, artifact metadata).
1 parent 1bf4a6e commit 68634d8

3 files changed

Lines changed: 215 additions & 0 deletions

File tree

src/specify_cli/workflows/expressions.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,15 @@ def _build_namespace(context: Any) -> dict[str, Any]:
102102
ns["item"] = context.item
103103
if hasattr(context, "fan_in"):
104104
ns["fan_in"] = context.fan_in or {}
105+
# Engine-managed runtime metadata. Always present (even outside a
106+
# run) so templates referencing it never error: `run_id` falls back
107+
# to an empty string when no run is active (dry-run, validation,
108+
# ad-hoc evaluator usage). The value is the same one Spec Kit
109+
# prints as `Run ID:` at the end of `workflow run` — auto-generated
110+
# runs use an 8-character uuid4 hex; operator-supplied ids may be
111+
# any alphanumeric string with hyphens or underscores.
112+
run_id = getattr(context, "run_id", None) or ""
113+
ns["context"] = {"run_id": run_id}
105114
return ns
106115

107116

tests/test_workflows.py

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,44 @@ def test_list_indexing(self):
332332
result = evaluate_expression("{{ steps.tasks.output.task_list[0].file }}", ctx)
333333
assert result == "a.md"
334334

335+
def test_context_run_id_resolves(self):
336+
"""``{{ context.run_id }}`` resolves to ``StepContext.run_id``.
337+
338+
Locks the contract from issue #2590: workflow templates can
339+
reference the engine-assigned run id for telemetry, artifact
340+
metadata, or per-run scratch isolation.
341+
"""
342+
from specify_cli.workflows.expressions import evaluate_expression
343+
from specify_cli.workflows.base import StepContext
344+
345+
ctx = StepContext(run_id="a1b2c3d4")
346+
assert evaluate_expression("{{ context.run_id }}", ctx) == "a1b2c3d4"
347+
348+
def test_context_run_id_defaults_to_empty_when_unset(self):
349+
"""``{{ context.run_id }}`` resolves to ``""`` when no run is
350+
active (dry-run, validation, ad-hoc evaluator usage) rather
351+
than raising — workflows referencing the variable never error
352+
outside a run context.
353+
"""
354+
from specify_cli.workflows.expressions import evaluate_expression
355+
from specify_cli.workflows.base import StepContext
356+
357+
# No run_id set on the context.
358+
ctx = StepContext()
359+
assert evaluate_expression("{{ context.run_id }}", ctx) == ""
360+
361+
def test_context_run_id_string_interpolation(self):
362+
"""Run id interpolates inside a larger template string — the
363+
common pattern for stamping shell commands and artifact paths
364+
with the run id.
365+
"""
366+
from specify_cli.workflows.expressions import evaluate_expression
367+
from specify_cli.workflows.base import StepContext
368+
369+
ctx = StepContext(run_id="deadbeef")
370+
result = evaluate_expression("RUN_ID={{ context.run_id }}", ctx)
371+
assert result == "RUN_ID=deadbeef"
372+
335373

336374
# ===== Integration Dispatch Tests =====
337375

@@ -1890,6 +1928,147 @@ def test_validate_workflow_rejects_non_string_default_for_string_type(self):
18901928
assert any("invalid default" in e for e in errors), errors
18911929

18921930

1931+
# ===== context.run_id Tests =====
1932+
#
1933+
# End-to-end coverage for the `{{ context.run_id }}` template
1934+
# variable introduced in issue #2590. Locks resolution inside the
1935+
# three step types the acceptance criteria called out — shell `run:`,
1936+
# command `input.args:`, and switch `expression:` — plus the
1937+
# "workflow doesn't reference it" backward-compat path.
1938+
1939+
1940+
class TestContextRunId:
1941+
"""End-to-end tests for `{{ context.run_id }}` in workflow YAML."""
1942+
1943+
def test_shell_run_resolves_run_id(self, project_dir):
1944+
"""`run: "echo {{ context.run_id }}"` substitutes the
1945+
engine-assigned run id into the spawned shell, and the
1946+
same value appears on `state.run_id`.
1947+
"""
1948+
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine
1949+
1950+
definition = WorkflowDefinition.from_string("""
1951+
schema_version: "1.0"
1952+
workflow:
1953+
id: "stamp-run-id"
1954+
name: "Stamp Run Id"
1955+
version: "1.0.0"
1956+
steps:
1957+
- id: stamp
1958+
type: shell
1959+
run: 'echo "RUN_ID={{ context.run_id }}"'
1960+
""")
1961+
engine = WorkflowEngine(project_dir)
1962+
state = engine.execute(definition, run_id="abc12345")
1963+
1964+
assert state.run_id == "abc12345"
1965+
stdout = state.step_results["stamp"]["output"]["stdout"]
1966+
assert stdout.strip() == "RUN_ID=abc12345"
1967+
1968+
def test_command_input_args_resolves_run_id(self, project_dir):
1969+
"""`input.args: "{{ context.run_id }}"` is resolved by
1970+
`CommandStep` and recorded in step output, even when CLI
1971+
dispatch is unavailable (no integration installed). Covers
1972+
the artifact-metadata use case from the issue.
1973+
"""
1974+
from unittest.mock import patch
1975+
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine
1976+
1977+
definition = WorkflowDefinition.from_string("""
1978+
schema_version: "1.0"
1979+
workflow:
1980+
id: "command-stamp"
1981+
name: "Command Stamp"
1982+
version: "1.0.0"
1983+
integration: claude
1984+
steps:
1985+
- id: tag-artifact
1986+
command: speckit.specify
1987+
input:
1988+
args: "{{ context.run_id }}"
1989+
""")
1990+
engine = WorkflowEngine(project_dir)
1991+
with patch(
1992+
"specify_cli.workflows.steps.command.shutil.which",
1993+
return_value=None,
1994+
):
1995+
state = engine.execute(definition, run_id="cafef00d")
1996+
1997+
# Even when dispatch fails (no CLI), the resolved input is
1998+
# recorded so downstream observers see the run id in artifact
1999+
# metadata.
2000+
assert state.step_results["tag-artifact"]["output"]["input"]["args"] == "cafef00d"
2001+
2002+
def test_switch_expression_matches_on_run_id(self, project_dir):
2003+
"""`switch` over `{{ context.run_id }}` matches against case
2004+
keys, and the nested branch can ALSO reference
2005+
`{{ context.run_id }}`. Demonstrates the run id is a
2006+
first-class value in the expression engine (not just a
2007+
string-interpolation token) AND that it propagates into
2008+
nested step execution via the recursive `_execute_steps`
2009+
traversal.
2010+
"""
2011+
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine
2012+
from specify_cli.workflows.base import RunStatus
2013+
2014+
definition = WorkflowDefinition.from_string("""
2015+
schema_version: "1.0"
2016+
workflow:
2017+
id: "switch-on-run-id"
2018+
name: "Switch On Run Id"
2019+
version: "1.0.0"
2020+
steps:
2021+
- id: route
2022+
type: switch
2023+
expression: "{{ context.run_id }}"
2024+
cases:
2025+
target-run:
2026+
- id: matched-branch
2027+
type: shell
2028+
run: 'echo "nested-run-id={{ context.run_id }}"'
2029+
default:
2030+
- id: default-branch
2031+
type: shell
2032+
run: "echo defaulted"
2033+
""")
2034+
engine = WorkflowEngine(project_dir)
2035+
state = engine.execute(definition, run_id="target-run")
2036+
2037+
assert state.status == RunStatus.COMPLETED
2038+
assert state.step_results["route"]["output"]["matched_case"] == "target-run"
2039+
assert "matched-branch" in state.step_results
2040+
assert "default-branch" not in state.step_results
2041+
# The nested branch sees the same run id — propagation through
2042+
# recursive `_execute_steps` is intact.
2043+
nested_stdout = state.step_results["matched-branch"]["output"]["stdout"]
2044+
assert nested_stdout.strip() == "nested-run-id=target-run"
2045+
2046+
def test_workflow_without_context_reference_unchanged(self, project_dir):
2047+
"""Workflows that do not reference `{{ context.run_id }}`
2048+
continue to run exactly as before. Locks the byte-equivalent
2049+
default required by the issue's acceptance criteria.
2050+
"""
2051+
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine
2052+
from specify_cli.workflows.base import RunStatus
2053+
2054+
definition = WorkflowDefinition.from_string("""
2055+
schema_version: "1.0"
2056+
workflow:
2057+
id: "no-context-ref"
2058+
name: "No Context Ref"
2059+
version: "1.0.0"
2060+
steps:
2061+
- id: only-step
2062+
type: shell
2063+
run: "echo hello"
2064+
""")
2065+
engine = WorkflowEngine(project_dir)
2066+
state = engine.execute(definition)
2067+
2068+
assert state.status == RunStatus.COMPLETED
2069+
assert state.step_results["only-step"]["output"]["stdout"].strip() == "hello"
2070+
2071+
18932072
# ===== State Persistence Tests =====
18942073

18952074
class TestRunState:

workflows/README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,33 @@ message: "{{ status | default('pending') }}"
239239

240240
Supported filters: `default`, `join`, `contains`, `map`.
241241

242+
### Runtime Context
243+
244+
`{{ context.* }}` exposes engine-managed runtime metadata for the
245+
current run:
246+
247+
| Variable | Description |
248+
|----------|-------------|
249+
| `context.run_id` | The current workflow run id (the same value Spec Kit prints as `Run ID:` at the end of `workflow run`). Auto-generated runs are 8-character hex from `uuid4`; operator-supplied ids may be any alphanumeric string with hyphens or underscores. Empty string outside a run context. |
250+
251+
```yaml
252+
# Stamp telemetry events with the run id for cross-system join.
253+
- id: emit-event
254+
type: shell
255+
run: 'echo "{\"run_id\":\"{{ context.run_id }}\",\"event\":\"started\"}" >> events.jsonl'
256+
257+
# Per-run scratch directory.
258+
- id: prep-scratch
259+
type: shell
260+
run: 'mkdir -p /tmp/run-{{ context.run_id }}'
261+
262+
# Pass run id into a command for artifact metadata.
263+
- id: tag-artifact
264+
command: speckit.specify
265+
input:
266+
args: "{{ context.run_id }}"
267+
```
268+
242269
## Input Types
243270

244271
Workflow inputs are type-checked and coerced from CLI string values:

0 commit comments

Comments
 (0)