From 2901269825798015e1b62c8636d682b9f5e1bbfc Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Wed, 3 Jun 2026 13:54:20 +0100 Subject: [PATCH 1/3] Add job execution view model Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Runner.Worker/Dap/JobExecutionView.cs | 358 ++++++++++++++++++++++ src/Runner.Worker/InternalsVisibleTo.cs | 2 +- src/Test/L0/Worker/JobExecutionViewL0.cs | 130 ++++++++ 3 files changed, 489 insertions(+), 1 deletion(-) create mode 100644 src/Runner.Worker/Dap/JobExecutionView.cs create mode 100644 src/Test/L0/Worker/JobExecutionViewL0.cs diff --git a/src/Runner.Worker/Dap/JobExecutionView.cs b/src/Runner.Worker/Dap/JobExecutionView.cs new file mode 100644 index 00000000000..0362204c54f --- /dev/null +++ b/src/Runner.Worker/Dap/JobExecutionView.cs @@ -0,0 +1,358 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text; + +namespace GitHub.Runner.Worker.Dap +{ + internal sealed class JobExecutionView + { + private const string _sourceFileName = "execution.yml"; + + private readonly object _lock = new object(); + private readonly List _preEntries = new List(); + private readonly List _mainEntries = new List(); + private readonly List _postEntries = new List(); + private readonly List _lineByStep = new List(); + private string _content; + private int _completeJobLine; + + public JobExecutionView( + string jobId, + IEnumerable steps, + IEnumerable initialPostSteps, + IEnumerable predictedPostSteps = null) + { + JobId = string.IsNullOrWhiteSpace(jobId) ? "job" : jobId; + + _preEntries.Add(new SourceEntry("Setup job")); + AddSteps(steps); + AddPredictedPostSteps(predictedPostSteps); + AddSteps(initialPostSteps); + _postEntries.Add(SourceEntry.CreateSyntheticCompleteJob()); + Render(); + } + + public string JobId { get; } + public string SourceFileName => _sourceFileName; + + public string Content + { + get + { + lock (_lock) + { + return _content; + } + } + } + + public int CompleteJobLine + { + get + { + lock (_lock) + { + return _completeJobLine; + } + } + } + + public int? TryClaimPredictedStep(string matchKey, IStep step) + { + if (string.IsNullOrEmpty(matchKey) || step == null) + { + return null; + } + + lock (_lock) + { + var existingLine = TryGetLineForStepNoLock(step); + if (existingLine.HasValue) + { + return existingLine; + } + + foreach (var entry in _postEntries) + { + if (!string.Equals(entry.MatchKey, matchKey, StringComparison.Ordinal)) + { + continue; + } + + if (entry.Step != null && !ReferenceEquals(entry.Step, step)) + { + return null; + } + + entry.Step = step; + Render(); + return TryGetLineForStepNoLock(step); + } + + return null; + } + } + + public int? TryGetLineForStep(IStep step) + { + if (step == null) + { + return null; + } + + lock (_lock) + { + return TryGetLineForStepNoLock(step); + } + } + + private int? TryGetLineForStepNoLock(IStep step) + { + foreach (var stepLine in _lineByStep) + { + if (ReferenceEquals(stepLine.Step, step)) + { + return stepLine.Line; + } + } + + return null; + } + + private void AddSteps(IEnumerable steps) + { + if (steps == null) + { + return; + } + + foreach (var step in steps) + { + if (step == null) + { + continue; + } + + GetEntries(GetSection(step)).Add(new SourceEntry(step)); + } + } + + private void AddPredictedPostSteps(IEnumerable steps) + { + if (steps == null) + { + return; + } + + foreach (var step in steps) + { + if (step == null) + { + continue; + } + + _postEntries.Add(new SourceEntry(step.DisplayName, step.MatchKey)); + } + } + + private List GetEntries(SourceSection section) + { + switch (section) + { + case SourceSection.Pre: + return _preEntries; + case SourceSection.Post: + return _postEntries; + default: + return _mainEntries; + } + } + + private static SourceSection GetSection(IStep step) + { + if (step is IActionRunner actionRunner) + { + return GetSection(actionRunner.Stage); + } + + if (step.ExecutionContext != null) + { + return GetSection(step.ExecutionContext.Stage); + } + + return SourceSection.Main; + } + + private static SourceSection GetSection(ActionRunStage stage) + { + switch (stage) + { + case ActionRunStage.Pre: + return SourceSection.Pre; + case ActionRunStage.Post: + return SourceSection.Post; + default: + return SourceSection.Main; + } + } + + private void Render() + { + _lineByStep.Clear(); + _completeJobLine = 0; + + var sb = new StringBuilder(); + var line = 1; + + AppendSection(sb, "pre", _preEntries, ref line, appendSeparatorLine: true); + AppendSection(sb, "main", _mainEntries, ref line, appendSeparatorLine: true); + AppendSection(sb, "post", _postEntries, ref line, appendSeparatorLine: false); + + _content = sb.ToString(); + } + + private void AppendSection( + StringBuilder sb, + string sectionName, + IReadOnlyList entries, + ref int line, + bool appendSeparatorLine) + { + sb.Append(sectionName).Append(":\n"); + line++; + + foreach (var entry in entries) + { + if (entry.Step != null && TryGetLineForStepNoLock(entry.Step) == null) + { + _lineByStep.Add(new StepLine(entry.Step, line)); + } + + sb.Append(" - step: "); + sb.Append(FormatYamlString(entry.DisplayName)); + sb.Append('\n'); + if (entry.IsSyntheticCompleteJob) + { + _completeJobLine = line; + } + + line++; + } + + if (appendSeparatorLine) + { + sb.Append('\n'); + line++; + } + } + + private static string FormatYamlString(string value) + { + var sb = new StringBuilder(); + sb.Append('"'); + foreach (var c in value) + { + switch (c) + { + case '\\': + sb.Append(@"\\"); + break; + case '"': + sb.Append("\\\""); + break; + case '\r': + sb.Append(@"\r"); + break; + case '\n': + sb.Append(@"\n"); + break; + case '\t': + sb.Append(@"\t"); + break; + default: + if (char.IsControl(c)) + { + sb.Append(@"\u"); + sb.Append(((int)c).ToString("x4", CultureInfo.InvariantCulture)); + } + else + { + sb.Append(c); + } + break; + } + } + + sb.Append('"'); + return sb.ToString(); + } + + internal sealed class PredictedPostStep + { + public PredictedPostStep(string displayName, string matchKey) + { + DisplayName = string.IsNullOrEmpty(displayName) ? "step" : displayName; + MatchKey = matchKey; + } + + public string DisplayName { get; } + public string MatchKey { get; } + } + + private sealed class StepLine + { + public StepLine(IStep step, int line) + { + Step = step; + Line = line; + } + + public IStep Step { get; } + public int Line { get; } + } + + private sealed class SourceEntry + { + public SourceEntry(string displayName) + { + DisplayName = string.IsNullOrEmpty(displayName) ? "step" : displayName; + } + + public SourceEntry(string displayName, string matchKey) + : this(displayName) + { + MatchKey = matchKey; + } + + public SourceEntry(IStep step) + { + Step = step; + DisplayName = string.IsNullOrEmpty(step.DisplayName) ? "step" : step.DisplayName; + } + + private SourceEntry(string displayName, bool isSyntheticCompleteJob) + : this(displayName) + { + IsSyntheticCompleteJob = isSyntheticCompleteJob; + } + + public static SourceEntry CreateSyntheticCompleteJob() + { + return new SourceEntry("Complete job", isSyntheticCompleteJob: true); + } + + public IStep Step { get; set; } + public string DisplayName { get; } + public string MatchKey { get; } + public bool IsSyntheticCompleteJob { get; } + } + + private enum SourceSection + { + Pre, + Main, + Post + } + } +} diff --git a/src/Runner.Worker/InternalsVisibleTo.cs b/src/Runner.Worker/InternalsVisibleTo.cs index de556bce35f..5ab594fb587 100644 --- a/src/Runner.Worker/InternalsVisibleTo.cs +++ b/src/Runner.Worker/InternalsVisibleTo.cs @@ -1,4 +1,4 @@ -using System.Runtime.CompilerServices; +using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("Test")] [assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] diff --git a/src/Test/L0/Worker/JobExecutionViewL0.cs b/src/Test/L0/Worker/JobExecutionViewL0.cs new file mode 100644 index 00000000000..23e01cff3ea --- /dev/null +++ b/src/Test/L0/Worker/JobExecutionViewL0.cs @@ -0,0 +1,130 @@ +using System; +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 JobExecutionViewL0 + { + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void RendersPreMainAndPostSections() + { + var pre = CreateStep("Pre cache", ActionRunStage.Pre); + var checkout = CreateStep("Checkout"); + var post = CreateStep("Post cache", ActionRunStage.Post); + + var view = new JobExecutionView( + "job", + new[] { pre.Object, checkout.Object }, + new[] { post.Object }); + + Assert.Equal( + "pre:\n - step: \"Setup job\"\n - step: \"Pre cache\"\n\nmain:\n - step: \"Checkout\"\n\npost:\n - step: \"Post cache\"\n - step: \"Complete job\"\n", + view.Content); + Assert.Equal(3, view.TryGetLineForStep(pre.Object)); + Assert.Equal(6, view.TryGetLineForStep(checkout.Object)); + Assert.Equal(9, view.TryGetLineForStep(post.Object)); + Assert.Equal(10, view.CompleteJobLine); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void ClaimsPredictedPostStepWithoutChangingLine() + { + var action = CreateRepositoryActionStep("actions/cache"); + var checkout = CreateActionRunner("Checkout", ActionRunStage.Main, action); + var predicted = new JobExecutionView.PredictedPostStep( + "Post Checkout", + MatchKeyFor(action.Id)); + + var view = new JobExecutionView( + "job", + new[] { checkout.Object }, + Array.Empty(), + new[] { predicted }); + + var post = CreateActionRunner("Post Checkout", ActionRunStage.Post, action); + var line = view.TryClaimPredictedStep(MatchKeyFor(action.Id), post.Object); + + Assert.Equal(8, line); + Assert.Equal(8, view.TryGetLineForStep(post.Object)); + Assert.Equal( + "pre:\n - step: \"Setup job\"\n\nmain:\n - step: \"Checkout\"\n\npost:\n - step: \"Post Checkout\"\n - step: \"Complete job\"\n", + view.Content); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void UsesSyntheticCompleteJobLineWhenPostStepSharesName() + { + var checkout = CreateStep("Checkout"); + var realPost = CreateStep("Complete job", ActionRunStage.Post); + + var view = new JobExecutionView( + "job", + new[] { checkout.Object }, + new[] { realPost.Object }); + + Assert.Equal(8, view.TryGetLineForStep(realPost.Object)); + Assert.Equal(9, view.CompleteJobLine); + } + + private static Mock CreateStep(string displayName, ActionRunStage? stage = null) + { + var step = new Mock(); + step.Setup(s => s.DisplayName).Returns(displayName); + if (stage.HasValue) + { + var executionContext = new Mock(); + executionContext.Setup(x => x.Stage).Returns(stage.Value); + step.Setup(s => s.ExecutionContext).Returns(executionContext.Object); + } + else + { + step.Setup(s => s.ExecutionContext).Returns((IExecutionContext)null); + } + + return step; + } + + private static Mock CreateActionRunner(string displayName, ActionRunStage stage, ActionStep action) + { + var executionContext = new Mock(); + executionContext.Setup(x => x.Stage).Returns(stage); + + var runner = new Mock(); + runner.Setup(s => s.DisplayName).Returns(displayName); + runner.Setup(s => s.ExecutionContext).Returns(executionContext.Object); + runner.Setup(s => s.Stage).Returns(stage); + runner.Setup(s => s.Action).Returns(action); + return runner; + } + + private static ActionStep CreateRepositoryActionStep(string name) + { + return new ActionStep + { + Id = Guid.NewGuid(), + Name = name, + Reference = new RepositoryPathReference + { + Name = name, + Ref = "v1", + RepositoryType = RepositoryTypes.GitHub + } + }; + } + + private static string MatchKeyFor(Guid actionId) + { + return $"post:{actionId:N}"; + } + } +} From 3480fd20d216a9a5ca878c2c979c0bdde520e5db Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Wed, 3 Jun 2026 14:28:49 +0100 Subject: [PATCH 2/3] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/Runner.Worker/Dap/JobExecutionView.cs | 2 +- src/Test/L0/Worker/JobExecutionViewL0.cs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Runner.Worker/Dap/JobExecutionView.cs b/src/Runner.Worker/Dap/JobExecutionView.cs index 0362204c54f..eebcf176e4c 100644 --- a/src/Runner.Worker/Dap/JobExecutionView.cs +++ b/src/Runner.Worker/Dap/JobExecutionView.cs @@ -25,7 +25,7 @@ public JobExecutionView( { JobId = string.IsNullOrWhiteSpace(jobId) ? "job" : jobId; - _preEntries.Add(new SourceEntry("Setup job")); + _preEntries.Add(new SourceEntry("Set up job")); AddSteps(steps); AddPredictedPostSteps(predictedPostSteps); AddSteps(initialPostSteps); diff --git a/src/Test/L0/Worker/JobExecutionViewL0.cs b/src/Test/L0/Worker/JobExecutionViewL0.cs index 23e01cff3ea..f1f69c785fd 100644 --- a/src/Test/L0/Worker/JobExecutionViewL0.cs +++ b/src/Test/L0/Worker/JobExecutionViewL0.cs @@ -24,7 +24,7 @@ public void RendersPreMainAndPostSections() new[] { post.Object }); Assert.Equal( - "pre:\n - step: \"Setup job\"\n - step: \"Pre cache\"\n\nmain:\n - step: \"Checkout\"\n\npost:\n - step: \"Post cache\"\n - step: \"Complete job\"\n", + "pre:\n - step: \"Set up job\"\n - step: \"Pre cache\"\n\nmain:\n - step: \"Checkout\"\n\npost:\n - step: \"Post cache\"\n - step: \"Complete job\"\n", view.Content); Assert.Equal(3, view.TryGetLineForStep(pre.Object)); Assert.Equal(6, view.TryGetLineForStep(checkout.Object)); @@ -55,7 +55,7 @@ public void ClaimsPredictedPostStepWithoutChangingLine() Assert.Equal(8, line); Assert.Equal(8, view.TryGetLineForStep(post.Object)); Assert.Equal( - "pre:\n - step: \"Setup job\"\n\nmain:\n - step: \"Checkout\"\n\npost:\n - step: \"Post Checkout\"\n - step: \"Complete job\"\n", + "pre:\n - step: \"Set up job\"\n\nmain:\n - step: \"Checkout\"\n\npost:\n - step: \"Post Checkout\"\n - step: \"Complete job\"\n", view.Content); } From 9411d27428ca8ebea6d50650d0cb6c8ca98b554d Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Wed, 3 Jun 2026 14:37:36 +0100 Subject: [PATCH 3/3] Restore BOM in InternalsVisibleTo.cs --- src/Runner.Worker/InternalsVisibleTo.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Runner.Worker/InternalsVisibleTo.cs b/src/Runner.Worker/InternalsVisibleTo.cs index 5ab594fb587..de556bce35f 100644 --- a/src/Runner.Worker/InternalsVisibleTo.cs +++ b/src/Runner.Worker/InternalsVisibleTo.cs @@ -1,4 +1,4 @@ -using System.Runtime.CompilerServices; +using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("Test")] [assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")]