From 68634d89cfde42b0cd963d8eb3420379359a28b2 Mon Sep 17 00:00:00 2001 From: doquanghuy Date: Thu, 21 May 2026 22:03:52 +0700 Subject: [PATCH] feat(workflows): expose `{{ context.run_id }}` template variable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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-/`. 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). --- src/specify_cli/workflows/expressions.py | 9 ++ tests/test_workflows.py | 179 +++++++++++++++++++++++ workflows/README.md | 27 ++++ 3 files changed, 215 insertions(+) diff --git a/src/specify_cli/workflows/expressions.py b/src/specify_cli/workflows/expressions.py index eb39a31e79..3cc74c7646 100644 --- a/src/specify_cli/workflows/expressions.py +++ b/src/specify_cli/workflows/expressions.py @@ -102,6 +102,15 @@ def _build_namespace(context: Any) -> dict[str, Any]: ns["item"] = context.item if hasattr(context, "fan_in"): ns["fan_in"] = context.fan_in or {} + # Engine-managed runtime metadata. Always present (even outside a + # run) so templates referencing it never error: `run_id` falls back + # to an empty string when no run is active (dry-run, validation, + # ad-hoc evaluator usage). The value is the same one Spec Kit + # prints as `Run ID:` at the end of `workflow run` — auto-generated + # runs use an 8-character uuid4 hex; operator-supplied ids may be + # any alphanumeric string with hyphens or underscores. + run_id = getattr(context, "run_id", None) or "" + ns["context"] = {"run_id": run_id} return ns diff --git a/tests/test_workflows.py b/tests/test_workflows.py index 3fa71f3404..bcd2b6361b 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -332,6 +332,44 @@ def test_list_indexing(self): result = evaluate_expression("{{ steps.tasks.output.task_list[0].file }}", ctx) assert result == "a.md" + def test_context_run_id_resolves(self): + """``{{ context.run_id }}`` resolves to ``StepContext.run_id``. + + Locks the contract from issue #2590: workflow templates can + reference the engine-assigned run id for telemetry, artifact + metadata, or per-run scratch isolation. + """ + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext(run_id="a1b2c3d4") + assert evaluate_expression("{{ context.run_id }}", ctx) == "a1b2c3d4" + + def test_context_run_id_defaults_to_empty_when_unset(self): + """``{{ context.run_id }}`` resolves to ``""`` when no run is + active (dry-run, validation, ad-hoc evaluator usage) rather + than raising — workflows referencing the variable never error + outside a run context. + """ + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + # No run_id set on the context. + ctx = StepContext() + assert evaluate_expression("{{ context.run_id }}", ctx) == "" + + def test_context_run_id_string_interpolation(self): + """Run id interpolates inside a larger template string — the + common pattern for stamping shell commands and artifact paths + with the run id. + """ + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext(run_id="deadbeef") + result = evaluate_expression("RUN_ID={{ context.run_id }}", ctx) + assert result == "RUN_ID=deadbeef" + # ===== Integration Dispatch Tests ===== @@ -1890,6 +1928,147 @@ def test_validate_workflow_rejects_non_string_default_for_string_type(self): assert any("invalid default" in e for e in errors), errors +# ===== context.run_id Tests ===== +# +# End-to-end coverage for the `{{ context.run_id }}` template +# variable introduced in issue #2590. Locks resolution inside the +# three step types the acceptance criteria called out — shell `run:`, +# command `input.args:`, and switch `expression:` — plus the +# "workflow doesn't reference it" backward-compat path. + + +class TestContextRunId: + """End-to-end tests for `{{ context.run_id }}` in workflow YAML.""" + + def test_shell_run_resolves_run_id(self, project_dir): + """`run: "echo {{ context.run_id }}"` substitutes the + engine-assigned run id into the spawned shell, and the + same value appears on `state.run_id`. + """ + from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine + + definition = WorkflowDefinition.from_string(""" +schema_version: "1.0" +workflow: + id: "stamp-run-id" + name: "Stamp Run Id" + version: "1.0.0" +steps: + - id: stamp + type: shell + run: 'echo "RUN_ID={{ context.run_id }}"' +""") + engine = WorkflowEngine(project_dir) + state = engine.execute(definition, run_id="abc12345") + + assert state.run_id == "abc12345" + stdout = state.step_results["stamp"]["output"]["stdout"] + assert stdout.strip() == "RUN_ID=abc12345" + + def test_command_input_args_resolves_run_id(self, project_dir): + """`input.args: "{{ context.run_id }}"` is resolved by + `CommandStep` and recorded in step output, even when CLI + dispatch is unavailable (no integration installed). Covers + the artifact-metadata use case from the issue. + """ + from unittest.mock import patch + from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine + + definition = WorkflowDefinition.from_string(""" +schema_version: "1.0" +workflow: + id: "command-stamp" + name: "Command Stamp" + version: "1.0.0" + integration: claude +steps: + - id: tag-artifact + command: speckit.specify + input: + args: "{{ context.run_id }}" +""") + engine = WorkflowEngine(project_dir) + with patch( + "specify_cli.workflows.steps.command.shutil.which", + return_value=None, + ): + state = engine.execute(definition, run_id="cafef00d") + + # Even when dispatch fails (no CLI), the resolved input is + # recorded so downstream observers see the run id in artifact + # metadata. + assert state.step_results["tag-artifact"]["output"]["input"]["args"] == "cafef00d" + + def test_switch_expression_matches_on_run_id(self, project_dir): + """`switch` over `{{ context.run_id }}` matches against case + keys, and the nested branch can ALSO reference + `{{ context.run_id }}`. Demonstrates the run id is a + first-class value in the expression engine (not just a + string-interpolation token) AND that it propagates into + nested step execution via the recursive `_execute_steps` + traversal. + """ + from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine + from specify_cli.workflows.base import RunStatus + + definition = WorkflowDefinition.from_string(""" +schema_version: "1.0" +workflow: + id: "switch-on-run-id" + name: "Switch On Run Id" + version: "1.0.0" +steps: + - id: route + type: switch + expression: "{{ context.run_id }}" + cases: + target-run: + - id: matched-branch + type: shell + run: 'echo "nested-run-id={{ context.run_id }}"' + default: + - id: default-branch + type: shell + run: "echo defaulted" +""") + engine = WorkflowEngine(project_dir) + state = engine.execute(definition, run_id="target-run") + + assert state.status == RunStatus.COMPLETED + assert state.step_results["route"]["output"]["matched_case"] == "target-run" + assert "matched-branch" in state.step_results + assert "default-branch" not in state.step_results + # The nested branch sees the same run id — propagation through + # recursive `_execute_steps` is intact. + nested_stdout = state.step_results["matched-branch"]["output"]["stdout"] + assert nested_stdout.strip() == "nested-run-id=target-run" + + def test_workflow_without_context_reference_unchanged(self, project_dir): + """Workflows that do not reference `{{ context.run_id }}` + continue to run exactly as before. Locks the byte-equivalent + default required by the issue's acceptance criteria. + """ + from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine + from specify_cli.workflows.base import RunStatus + + definition = WorkflowDefinition.from_string(""" +schema_version: "1.0" +workflow: + id: "no-context-ref" + name: "No Context Ref" + version: "1.0.0" +steps: + - id: only-step + type: shell + run: "echo hello" +""") + engine = WorkflowEngine(project_dir) + state = engine.execute(definition) + + assert state.status == RunStatus.COMPLETED + assert state.step_results["only-step"]["output"]["stdout"].strip() == "hello" + + # ===== State Persistence Tests ===== class TestRunState: diff --git a/workflows/README.md b/workflows/README.md index 31f736ff76..eb3d090ad0 100644 --- a/workflows/README.md +++ b/workflows/README.md @@ -239,6 +239,33 @@ message: "{{ status | default('pending') }}" Supported filters: `default`, `join`, `contains`, `map`. +### Runtime Context + +`{{ context.* }}` exposes engine-managed runtime metadata for the +current run: + +| Variable | Description | +|----------|-------------| +| `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. | + +```yaml +# Stamp telemetry events with the run id for cross-system join. +- id: emit-event + type: shell + run: 'echo "{\"run_id\":\"{{ context.run_id }}\",\"event\":\"started\"}" >> events.jsonl' + +# Per-run scratch directory. +- id: prep-scratch + type: shell + run: 'mkdir -p /tmp/run-{{ context.run_id }}' + +# Pass run id into a command for artifact metadata. +- id: tag-artifact + command: speckit.specify + input: + args: "{{ context.run_id }}" +``` + ## Input Types Workflow inputs are type-checked and coerced from CLI string values: