diff --git a/src/Runner.Worker/Dap/StepEntryTranslator.cs b/src/Runner.Worker/Dap/StepEntryTranslator.cs
new file mode 100644
index 000000000..9065c370e
--- /dev/null
+++ b/src/Runner.Worker/Dap/StepEntryTranslator.cs
@@ -0,0 +1,240 @@
+using System;
+using System.Collections.Generic;
+using GitHub.DistributedTask.ObjectTemplating.Tokens;
+using GitHub.DistributedTask.Pipelines;
+using GitHub.Runner.Sdk;
+
+namespace GitHub.Runner.Worker.Dap
+{
+ ///
+ /// Translates runner instances into pure-data
+ /// records used by the DAP debugger
+ /// execution view. Filters out runner-internal steps (e.g.
+ /// ) so the rendered view only shows
+ /// user-visible workflow steps.
+ ///
+ internal static class StepEntryTranslator
+ {
+ // Run-step internals carried on ActionStep.Inputs that are NOT
+ // user-authored `with:` entries. The runner stores these under
+ // the keys defined in PipelineConstants.ScriptStepInputs, NOT
+ // their kebab-case workflow-YAML spellings.
+ private static readonly HashSet RunStepInternalKeys = new(StringComparer.Ordinal)
+ {
+ PipelineConstants.ScriptStepInputs.Script,
+ PipelineConstants.ScriptStepInputs.Shell,
+ PipelineConstants.ScriptStepInputs.WorkingDirectory,
+ };
+
+ ///
+ /// Translate an IStep into a JobExecutionViewEntry.
+ ///
+ /// The IStep to translate. Must not be null.
+ ///
+ /// A JobExecutionViewEntry, or null if the step is not user-visible
+ /// (JobExtensionRunner and any other non-IActionRunner IStep impls).
+ ///
+ public static JobExecutionViewEntry TryTranslate(IStep step)
+ {
+ ArgUtil.NotNull(step, nameof(step));
+
+ if (step is JobExtensionRunner)
+ {
+ return null;
+ }
+
+ if (step is not IActionRunner actionRunner)
+ {
+ return null;
+ }
+
+ var phase = actionRunner.Stage switch
+ {
+ ActionRunStage.Pre => JobExecutionPhase.Pre,
+ ActionRunStage.Post => JobExecutionPhase.Post,
+ _ => JobExecutionPhase.Main,
+ };
+
+ string displayName = actionRunner.DisplayName;
+ if (string.IsNullOrWhiteSpace(displayName))
+ {
+ displayName = "run";
+ }
+
+ string uses = null;
+ string run = null;
+ string id = null;
+ string ifCond = null;
+ string continueOnError = null;
+ string timeoutMinutes = null;
+ string envYaml = null;
+ string withYaml = null;
+ string shell = null;
+ string workingDirectory = null;
+
+ var action = actionRunner.Action;
+ var reference = action?.Reference;
+ bool isScript = reference?.Type == ActionSourceType.Script;
+
+ if (reference != null && !isScript)
+ {
+ uses = FormatActionReference(reference);
+ }
+
+ // Only the user-visible Main entry surfaces authored params.
+ // Pre/Post stay minimal (step + action) — they reference the
+ // same Action as the Main entry, and duplicating params adds
+ // noise without information.
+ if (phase == JobExecutionPhase.Main && action != null)
+ {
+ id = FilterAuthoredId(action.ContextName);
+
+ if (!string.IsNullOrEmpty(action.Condition))
+ {
+ ifCond = action.Condition;
+ }
+
+ if (action.ContinueOnError != null)
+ {
+ continueOnError = TemplateTokenYamlAdapter.Serialize(action.ContinueOnError, indentSpaces: 0);
+ }
+ if (action.TimeoutInMinutes != null)
+ {
+ timeoutMinutes = TemplateTokenYamlAdapter.Serialize(action.TimeoutInMinutes, indentSpaces: 0);
+ }
+
+ if (action.Environment is MappingToken envMap && envMap.Count > 0)
+ {
+ envYaml = TemplateTokenYamlAdapter.Serialize(envMap, indentSpaces: 6);
+ }
+ else if (action.Environment != null && !(action.Environment is MappingToken))
+ {
+ // Unusual but possible: env: ${{ ... }} expression form.
+ envYaml = TemplateTokenYamlAdapter.Serialize(action.Environment, indentSpaces: 6);
+ }
+
+ if (isScript)
+ {
+ var inputs = action.Inputs as MappingToken;
+ if (inputs != null)
+ {
+ if (TryGetMapValue(inputs, PipelineConstants.ScriptStepInputs.Script, out var scriptTok) && scriptTok != null)
+ {
+ run = scriptTok.ToString();
+ }
+ if (TryGetMapValue(inputs, PipelineConstants.ScriptStepInputs.Shell, out var shellTok) && shellTok != null)
+ {
+ string shellText = shellTok.ToString();
+ if (!string.IsNullOrEmpty(shellText))
+ {
+ shell = shellText;
+ }
+ }
+ if (TryGetMapValue(inputs, PipelineConstants.ScriptStepInputs.WorkingDirectory, out var wdTok) && wdTok != null)
+ {
+ string wdText = wdTok.ToString();
+ if (!string.IsNullOrEmpty(wdText))
+ {
+ workingDirectory = wdText;
+ }
+ }
+ }
+ }
+ else
+ {
+ // Action step: surface `with:` entries, filtering any
+ // run-step internal keys defensively.
+ if (action.Inputs is MappingToken withMap && withMap.Count > 0)
+ {
+ var filtered = FilterMapping(withMap, RunStepInternalKeys);
+ if (filtered != null && filtered.Count > 0)
+ {
+ withYaml = TemplateTokenYamlAdapter.Serialize(filtered, indentSpaces: 6);
+ }
+ }
+ }
+ }
+
+ // Source annotation (SourcePath/SourceLine) requires a public
+ // seam onto TemplateToken position info — not wired yet.
+ return new JobExecutionViewEntry(
+ phase: phase,
+ displayName: displayName,
+ uses: uses,
+ run: run,
+ sourcePath: null,
+ sourceLine: 0,
+ id: id,
+ @if: ifCond,
+ continueOnError: continueOnError,
+ timeoutMinutes: timeoutMinutes,
+ envYaml: envYaml,
+ withYaml: withYaml,
+ shell: shell,
+ workingDirectory: workingDirectory);
+ }
+
+ ///
+ /// Auto-generated step IDs are noise in the view: filter them out.
+ /// The runner's convention (see ExecutionContext) is that auto-
+ /// generated context names start with __. Only user-authored
+ /// IDs survive the filter.
+ ///
+ internal static string FilterAuthoredId(string contextName)
+ {
+ if (string.IsNullOrWhiteSpace(contextName))
+ {
+ return null;
+ }
+ if (contextName.StartsWith("__", StringComparison.Ordinal))
+ {
+ return null;
+ }
+ return contextName;
+ }
+
+ private static bool TryGetMapValue(MappingToken map, string key, out TemplateToken value)
+ {
+ foreach (var pair in map)
+ {
+ if (pair.Key is StringToken s && string.Equals(s.Value, key, StringComparison.Ordinal))
+ {
+ value = pair.Value;
+ return true;
+ }
+ }
+ value = null;
+ return false;
+ }
+
+ private static MappingToken FilterMapping(MappingToken source, HashSet excludeKeys)
+ {
+ var copy = new MappingToken(source.FileId, source.Line, source.Column);
+ foreach (var pair in source)
+ {
+ if (pair.Key is StringToken sk && excludeKeys.Contains(sk.Value))
+ {
+ continue;
+ }
+ copy.Add(pair);
+ }
+ return copy;
+ }
+
+ internal static string FormatActionReference(ActionStepDefinitionReference reference)
+ {
+ switch (reference)
+ {
+ case RepositoryPathReference repo:
+ var path = string.IsNullOrEmpty(repo.Path) ? string.Empty : $"/{repo.Path}";
+ return string.IsNullOrEmpty(repo.Ref)
+ ? $"{repo.Name}{path}"
+ : $"{repo.Name}{path}@{repo.Ref}";
+ case ContainerRegistryReference container:
+ return container.Image;
+ default:
+ return reference.ToString();
+ }
+ }
+ }
+}
diff --git a/src/Test/L0/Worker/StepEntryTranslatorL0.cs b/src/Test/L0/Worker/StepEntryTranslatorL0.cs
new file mode 100644
index 000000000..3f7c07c15
--- /dev/null
+++ b/src/Test/L0/Worker/StepEntryTranslatorL0.cs
@@ -0,0 +1,428 @@
+using System;
+using GitHub.DistributedTask.ObjectTemplating.Tokens;
+using GitHub.DistributedTask.Pipelines;
+using GitHub.Runner.Worker;
+using GitHub.Runner.Worker.Dap;
+using Moq;
+using Xunit;
+
+namespace GitHub.Runner.Common.Tests.Worker
+{
+ public sealed class StepEntryTranslatorL0
+ {
+ private static StringToken Str(string s) => new(null, null, null, s);
+
+ private static MappingToken Map(params (string Key, TemplateToken Value)[] pairs)
+ {
+ var m = new MappingToken(null, null, null);
+ foreach (var (k, v) in pairs)
+ {
+ m.Add(Str(k), v);
+ }
+ return m;
+ }
+
+ private static Mock NewActionRunnerMock(
+ ActionRunStage stage,
+ string displayName,
+ ActionStepDefinitionReference reference,
+ ActionStep actionOverride = null)
+ {
+ var mock = new Mock();
+ mock.SetupGet(x => x.Stage).Returns(stage);
+ mock.SetupGet(x => x.DisplayName).Returns(displayName);
+ mock.SetupGet(x => x.Action).Returns(actionOverride ?? new ActionStep
+ {
+ Reference = reference,
+ });
+ return mock;
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void Translate_NullStep_Throws()
+ {
+ Assert.Throws(() =>
+ StepEntryTranslator.TryTranslate(null));
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void Translate_JobExtensionRunner_ReturnsNull()
+ {
+ var step = new JobExtensionRunner(
+ runAsync: (_, __) => System.Threading.Tasks.Task.CompletedTask,
+ condition: null,
+ displayName: "Set up job",
+ data: null);
+
+ Assert.Null(StepEntryTranslator.TryTranslate(step));
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void Translate_OtherIStepType_ReturnsNull()
+ {
+ var mock = new Mock();
+ mock.SetupGet(x => x.DisplayName).Returns("custom");
+
+ Assert.Null(StepEntryTranslator.TryTranslate(mock.Object));
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void Translate_ActionRunnerPre_ReturnsPreEntry()
+ {
+ var reference = new RepositoryPathReference
+ {
+ Name = "actions/checkout",
+ Ref = "v4",
+ };
+ var mock = NewActionRunnerMock(ActionRunStage.Pre, "Pre Run actions/checkout@v4", reference);
+
+ var entry = StepEntryTranslator.TryTranslate(mock.Object);
+
+ Assert.NotNull(entry);
+ Assert.Equal(JobExecutionPhase.Pre, entry.Phase);
+ Assert.Equal("Pre Run actions/checkout@v4", entry.DisplayName);
+ Assert.Equal("actions/checkout@v4", entry.Uses);
+ Assert.Null(entry.Run);
+ Assert.Null(entry.SourcePath);
+ Assert.Equal(0, entry.SourceLine);
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void Translate_ActionRunnerMain_ReturnsMainEntryWithUses()
+ {
+ var reference = new RepositoryPathReference
+ {
+ Name = "actions/setup-node",
+ Path = "subdir",
+ Ref = "v3",
+ };
+ var mock = NewActionRunnerMock(ActionRunStage.Main, "Run actions/setup-node@v3", reference);
+
+ var entry = StepEntryTranslator.TryTranslate(mock.Object);
+
+ Assert.NotNull(entry);
+ Assert.Equal(JobExecutionPhase.Main, entry.Phase);
+ Assert.Equal("actions/setup-node/subdir@v3", entry.Uses);
+ Assert.Null(entry.Run);
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void Translate_ActionRunnerMain_ScriptReference_LeavesUsesNull()
+ {
+ var mock = NewActionRunnerMock(ActionRunStage.Main, "Run echo hi", new ScriptReference());
+
+ var entry = StepEntryTranslator.TryTranslate(mock.Object);
+
+ Assert.NotNull(entry);
+ Assert.Equal(JobExecutionPhase.Main, entry.Phase);
+ Assert.Null(entry.Uses);
+ Assert.Null(entry.Run);
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void Translate_ActionRunnerMain_ContainerReference_UsesImage()
+ {
+ var reference = new ContainerRegistryReference { Image = "alpine:3.18" };
+ var mock = NewActionRunnerMock(ActionRunStage.Main, "Run alpine", reference);
+
+ var entry = StepEntryTranslator.TryTranslate(mock.Object);
+
+ Assert.NotNull(entry);
+ Assert.Equal("alpine:3.18", entry.Uses);
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void Translate_ActionRunnerPost_ReturnsPostEntry()
+ {
+ var reference = new RepositoryPathReference { Name = "actions/cache", Ref = "v3" };
+ var mock = NewActionRunnerMock(ActionRunStage.Post, "Post Run actions/cache@v3", reference);
+
+ var entry = StepEntryTranslator.TryTranslate(mock.Object);
+
+ Assert.NotNull(entry);
+ Assert.Equal(JobExecutionPhase.Post, entry.Phase);
+ Assert.Equal("Post Run actions/cache@v3", entry.DisplayName);
+ Assert.Equal("actions/cache@v3", entry.Uses);
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void Translate_ActionRunner_NullAction_LeavesUsesNull()
+ {
+ var mock = new Mock();
+ mock.SetupGet(x => x.Stage).Returns(ActionRunStage.Main);
+ mock.SetupGet(x => x.DisplayName).Returns("anonymous");
+ mock.SetupGet(x => x.Action).Returns((ActionStep)null);
+
+ var entry = StepEntryTranslator.TryTranslate(mock.Object);
+
+ Assert.NotNull(entry);
+ Assert.Equal("anonymous", entry.DisplayName);
+ Assert.Null(entry.Uses);
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void Translate_ActionStep_ExtractsWith()
+ {
+ var reference = new RepositoryPathReference { Name = "actions/cache", Ref = "v5" };
+ var action = new ActionStep
+ {
+ Reference = reference,
+ Inputs = Map(("path", Str("prime-numbers")), ("key", Str("k"))),
+ };
+ var mock = NewActionRunnerMock(ActionRunStage.Main, "Cache", reference, action);
+
+ var entry = StepEntryTranslator.TryTranslate(mock.Object);
+
+ Assert.NotNull(entry);
+ Assert.NotNull(entry.WithYaml);
+ Assert.Contains("path: prime-numbers", entry.WithYaml);
+ Assert.Contains("key: k", entry.WithYaml);
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void Translate_ActionStep_PreservesExpressionInWith()
+ {
+ var reference = new RepositoryPathReference { Name = "actions/cache", Ref = "v5" };
+ var action = new ActionStep
+ {
+ Reference = reference,
+ Inputs = Map(("key", Str("${{ runner.os }}-primes"))),
+ };
+ var mock = NewActionRunnerMock(ActionRunStage.Main, "Cache", reference, action);
+
+ var entry = StepEntryTranslator.TryTranslate(mock.Object);
+
+ Assert.NotNull(entry);
+ Assert.Contains("${{ runner.os }}-primes", entry.WithYaml);
+ Assert.DoesNotContain("Linux", entry.WithYaml);
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void Translate_RunStep_ExtractsScript()
+ {
+ var action = new ActionStep
+ {
+ Reference = new ScriptReference(),
+ Inputs = Map(("script", Str("echo hi"))),
+ };
+ var mock = NewActionRunnerMock(ActionRunStage.Main, "Run echo", new ScriptReference(), action);
+
+ var entry = StepEntryTranslator.TryTranslate(mock.Object);
+
+ Assert.NotNull(entry);
+ Assert.Null(entry.Uses);
+ Assert.Equal("echo hi", entry.Run);
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void Translate_RunStep_ExtractsShellAndWorkingDirectory()
+ {
+ // The runner stores run-step inputs under the keys defined in
+ // PipelineConstants.ScriptStepInputs (camelCase), NOT their
+ // kebab-case workflow-YAML spellings — see
+ // ActionManifestManagerWrapper:244.
+ var action = new ActionStep
+ {
+ Reference = new ScriptReference(),
+ Inputs = Map(
+ (PipelineConstants.ScriptStepInputs.Script, Str("npm test")),
+ (PipelineConstants.ScriptStepInputs.Shell, Str("bash")),
+ (PipelineConstants.ScriptStepInputs.WorkingDirectory, Str("./api"))),
+ };
+ var mock = NewActionRunnerMock(ActionRunStage.Main, "Run", new ScriptReference(), action);
+
+ var entry = StepEntryTranslator.TryTranslate(mock.Object);
+
+ Assert.NotNull(entry);
+ Assert.Equal("npm test", entry.Run);
+ Assert.Equal("bash", entry.Shell);
+ Assert.Equal("./api", entry.WorkingDirectory);
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void Translate_ActionStep_FiltersRunStepKeysFromWith()
+ {
+ // Defensive: an action step's Inputs should not contain
+ // run-step internal keys, but if it did, they must not
+ // surface in the with: rendering.
+ var reference = new RepositoryPathReference { Name = "a/b", Ref = "v1" };
+ var action = new ActionStep
+ {
+ Reference = reference,
+ Inputs = Map(
+ ("mode", Str("ci")),
+ (PipelineConstants.ScriptStepInputs.Script, Str("leak")),
+ (PipelineConstants.ScriptStepInputs.Shell, Str("leak")),
+ (PipelineConstants.ScriptStepInputs.WorkingDirectory, Str("leak"))),
+ };
+ var mock = NewActionRunnerMock(ActionRunStage.Main, "Run", reference, action);
+
+ var entry = StepEntryTranslator.TryTranslate(mock.Object);
+
+ Assert.NotNull(entry);
+ Assert.NotNull(entry.WithYaml);
+ Assert.Contains("mode: ci", entry.WithYaml);
+ Assert.DoesNotContain("leak", entry.WithYaml);
+ Assert.DoesNotContain(PipelineConstants.ScriptStepInputs.Script, entry.WithYaml);
+ Assert.DoesNotContain(PipelineConstants.ScriptStepInputs.Shell, entry.WithYaml);
+ Assert.DoesNotContain(PipelineConstants.ScriptStepInputs.WorkingDirectory, entry.WithYaml);
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void Translate_ActionStep_OmitsEmptyEnv()
+ {
+ var reference = new RepositoryPathReference { Name = "a/b", Ref = "v1" };
+ var action = new ActionStep
+ {
+ Reference = reference,
+ Environment = new MappingToken(null, null, null),
+ };
+ var mock = NewActionRunnerMock(ActionRunStage.Main, "Run", reference, action);
+
+ var entry = StepEntryTranslator.TryTranslate(mock.Object);
+
+ Assert.NotNull(entry);
+ Assert.Null(entry.EnvYaml);
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void Translate_ActionStep_ExtractsEnv()
+ {
+ var reference = new RepositoryPathReference { Name = "a/b", Ref = "v1" };
+ var action = new ActionStep
+ {
+ Reference = reference,
+ Environment = Map(("NODE_ENV", Str("production"))),
+ };
+ var mock = NewActionRunnerMock(ActionRunStage.Main, "Run", reference, action);
+
+ var entry = StepEntryTranslator.TryTranslate(mock.Object);
+
+ Assert.NotNull(entry);
+ Assert.NotNull(entry.EnvYaml);
+ Assert.Contains("NODE_ENV: production", entry.EnvYaml);
+ }
+
+ [Theory]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("__1")]
+ [InlineData("__123")]
+ public void Translate_FiltersAutoGeneratedId(string contextName)
+ {
+ var reference = new RepositoryPathReference { Name = "a/b", Ref = "v1" };
+ var action = new ActionStep
+ {
+ Reference = reference,
+ ContextName = contextName,
+ };
+ var mock = NewActionRunnerMock(ActionRunStage.Main, "Run", reference, action);
+
+ var entry = StepEntryTranslator.TryTranslate(mock.Object);
+
+ Assert.NotNull(entry);
+ Assert.Null(entry.Id);
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void Translate_PreservesUserId()
+ {
+ var reference = new RepositoryPathReference { Name = "a/b", Ref = "v1" };
+ var action = new ActionStep
+ {
+ Reference = reference,
+ ContextName = "cache-primes",
+ };
+ var mock = NewActionRunnerMock(ActionRunStage.Main, "Cache", reference, action);
+
+ var entry = StepEntryTranslator.TryTranslate(mock.Object);
+
+ Assert.NotNull(entry);
+ Assert.Equal("cache-primes", entry.Id);
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void Translate_ActionStep_ExtractsCondition()
+ {
+ var reference = new RepositoryPathReference { Name = "a/b", Ref = "v1" };
+ var action = new ActionStep
+ {
+ Reference = reference,
+ Condition = "always()",
+ };
+ var mock = NewActionRunnerMock(ActionRunStage.Main, "Run", reference, action);
+
+ var entry = StepEntryTranslator.TryTranslate(mock.Object);
+
+ Assert.NotNull(entry);
+ Assert.Equal("always()", entry.If);
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void Translate_PreEntry_OmitsUserParams()
+ {
+ // Pre entries stay minimal — they reference the same Action as
+ // Main, and duplicating params adds noise.
+ var reference = new RepositoryPathReference { Name = "a/b", Ref = "v1" };
+ var action = new ActionStep
+ {
+ Reference = reference,
+ ContextName = "user-id",
+ Condition = "always()",
+ Environment = Map(("X", Str("y"))),
+ Inputs = Map(("k", Str("v"))),
+ };
+ var mock = NewActionRunnerMock(ActionRunStage.Pre, "Pre a/b@v1", reference, action);
+
+ var entry = StepEntryTranslator.TryTranslate(mock.Object);
+
+ Assert.NotNull(entry);
+ Assert.Equal(JobExecutionPhase.Pre, entry.Phase);
+ Assert.Null(entry.Id);
+ Assert.Null(entry.If);
+ Assert.Null(entry.EnvYaml);
+ Assert.Null(entry.WithYaml);
+ }
+ }
+}