Skip to content

Commit c6afe4c

Browse files
authored
feat(workflows): expose {{ context.run_id }} template variable (#2664)
* 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). * test(workflows): drop inline double-quotes in run_id shell tests `test_shell_run_resolves_run_id` and `test_switch_expression_matches_on_run_id` used `run: 'echo "RUN_ID={{ context.run_id }}"'` with inner double-quotes around the echo argument. Bash/sh strips those quotes before invoking echo, but cmd.exe (used on Windows when `shell=True`) treats them as literal characters and emits `"RUN_ID=abc12345"` — failing the exact-match assertion. Linux passed; all three Windows-latest matrix entries failed with `assert '"RUN_ID=abc12345"' == 'RUN_ID=abc12345'`. Resolve by dropping the inner double-quotes (the value has no spaces or shell metacharacters) and wrapping the YAML scalar in plain double-quotes the same way other shell-step tests in this file do (e.g. `run: "echo b-saw-..."`). Behaviour-equivalent on POSIX, portable to cmd.exe.
1 parent 66884db commit c6afe4c

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
@@ -333,6 +333,44 @@ def test_list_indexing(self):
333333
result = evaluate_expression("{{ steps.tasks.output.task_list[0].file }}", ctx)
334334
assert result == "a.md"
335335

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

337375
# ===== Integration Dispatch Tests =====
338376

@@ -2154,6 +2192,147 @@ def test_while_loop_multi_step_body_inter_step_refs(self, project_dir):
21542192
assert "retry-loop:step-b:2" in state.step_results
21552193

21562194

2195+
# ===== context.run_id Tests =====
2196+
#
2197+
# End-to-end coverage for the `{{ context.run_id }}` template
2198+
# variable introduced in issue #2590. Locks resolution inside the
2199+
# three step types the acceptance criteria called out — shell `run:`,
2200+
# command `input.args:`, and switch `expression:` — plus the
2201+
# "workflow doesn't reference it" backward-compat path.
2202+
2203+
2204+
class TestContextRunId:
2205+
"""End-to-end tests for `{{ context.run_id }}` in workflow YAML."""
2206+
2207+
def test_shell_run_resolves_run_id(self, project_dir):
2208+
"""`run: "echo {{ context.run_id }}"` substitutes the
2209+
engine-assigned run id into the spawned shell, and the
2210+
same value appears on `state.run_id`.
2211+
"""
2212+
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine
2213+
2214+
definition = WorkflowDefinition.from_string("""
2215+
schema_version: "1.0"
2216+
workflow:
2217+
id: "stamp-run-id"
2218+
name: "Stamp Run Id"
2219+
version: "1.0.0"
2220+
steps:
2221+
- id: stamp
2222+
type: shell
2223+
run: "echo RUN_ID={{ context.run_id }}"
2224+
""")
2225+
engine = WorkflowEngine(project_dir)
2226+
state = engine.execute(definition, run_id="abc12345")
2227+
2228+
assert state.run_id == "abc12345"
2229+
stdout = state.step_results["stamp"]["output"]["stdout"]
2230+
assert stdout.strip() == "RUN_ID=abc12345"
2231+
2232+
def test_command_input_args_resolves_run_id(self, project_dir):
2233+
"""`input.args: "{{ context.run_id }}"` is resolved by
2234+
`CommandStep` and recorded in step output, even when CLI
2235+
dispatch is unavailable (no integration installed). Covers
2236+
the artifact-metadata use case from the issue.
2237+
"""
2238+
from unittest.mock import patch
2239+
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine
2240+
2241+
definition = WorkflowDefinition.from_string("""
2242+
schema_version: "1.0"
2243+
workflow:
2244+
id: "command-stamp"
2245+
name: "Command Stamp"
2246+
version: "1.0.0"
2247+
integration: claude
2248+
steps:
2249+
- id: tag-artifact
2250+
command: speckit.specify
2251+
input:
2252+
args: "{{ context.run_id }}"
2253+
""")
2254+
engine = WorkflowEngine(project_dir)
2255+
with patch(
2256+
"specify_cli.workflows.steps.command.shutil.which",
2257+
return_value=None,
2258+
):
2259+
state = engine.execute(definition, run_id="cafef00d")
2260+
2261+
# Even when dispatch fails (no CLI), the resolved input is
2262+
# recorded so downstream observers see the run id in artifact
2263+
# metadata.
2264+
assert state.step_results["tag-artifact"]["output"]["input"]["args"] == "cafef00d"
2265+
2266+
def test_switch_expression_matches_on_run_id(self, project_dir):
2267+
"""`switch` over `{{ context.run_id }}` matches against case
2268+
keys, and the nested branch can ALSO reference
2269+
`{{ context.run_id }}`. Demonstrates the run id is a
2270+
first-class value in the expression engine (not just a
2271+
string-interpolation token) AND that it propagates into
2272+
nested step execution via the recursive `_execute_steps`
2273+
traversal.
2274+
"""
2275+
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine
2276+
from specify_cli.workflows.base import RunStatus
2277+
2278+
definition = WorkflowDefinition.from_string("""
2279+
schema_version: "1.0"
2280+
workflow:
2281+
id: "switch-on-run-id"
2282+
name: "Switch On Run Id"
2283+
version: "1.0.0"
2284+
steps:
2285+
- id: route
2286+
type: switch
2287+
expression: "{{ context.run_id }}"
2288+
cases:
2289+
target-run:
2290+
- id: matched-branch
2291+
type: shell
2292+
run: "echo nested-run-id={{ context.run_id }}"
2293+
default:
2294+
- id: default-branch
2295+
type: shell
2296+
run: "echo defaulted"
2297+
""")
2298+
engine = WorkflowEngine(project_dir)
2299+
state = engine.execute(definition, run_id="target-run")
2300+
2301+
assert state.status == RunStatus.COMPLETED
2302+
assert state.step_results["route"]["output"]["matched_case"] == "target-run"
2303+
assert "matched-branch" in state.step_results
2304+
assert "default-branch" not in state.step_results
2305+
# The nested branch sees the same run id — propagation through
2306+
# recursive `_execute_steps` is intact.
2307+
nested_stdout = state.step_results["matched-branch"]["output"]["stdout"]
2308+
assert nested_stdout.strip() == "nested-run-id=target-run"
2309+
2310+
def test_workflow_without_context_reference_unchanged(self, project_dir):
2311+
"""Workflows that do not reference `{{ context.run_id }}`
2312+
continue to run exactly as before. Locks the byte-equivalent
2313+
default required by the issue's acceptance criteria.
2314+
"""
2315+
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine
2316+
from specify_cli.workflows.base import RunStatus
2317+
2318+
definition = WorkflowDefinition.from_string("""
2319+
schema_version: "1.0"
2320+
workflow:
2321+
id: "no-context-ref"
2322+
name: "No Context Ref"
2323+
version: "1.0.0"
2324+
steps:
2325+
- id: only-step
2326+
type: shell
2327+
run: "echo hello"
2328+
""")
2329+
engine = WorkflowEngine(project_dir)
2330+
state = engine.execute(definition)
2331+
2332+
assert state.status == RunStatus.COMPLETED
2333+
assert state.step_results["only-step"]["output"]["stdout"].strip() == "hello"
2334+
2335+
21572336
# ===== State Persistence Tests =====
21582337

21592338
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)