From d8a18c194c1715f5f8a4d318608cf2968249c846 Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Wed, 3 Jun 2026 13:47:12 +0100 Subject: [PATCH] Add DAP job execution view source Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Runner.Worker/Dap/DapDebugger.cs | 290 +++++++++++++++++- src/Runner.Worker/Dap/DapMessages.cs | 40 +++ src/Runner.Worker/Dap/IDapDebugger.cs | 5 +- src/Runner.Worker/Dap/JobExecutionView.cs | 358 ++++++++++++++++++++++ src/Runner.Worker/ExecutionContext.cs | 13 + src/Runner.Worker/JobRunner.cs | 7 + 6 files changed, 706 insertions(+), 7 deletions(-) create mode 100644 src/Runner.Worker/Dap/JobExecutionView.cs diff --git a/src/Runner.Worker/Dap/DapDebugger.cs b/src/Runner.Worker/Dap/DapDebugger.cs index d5dba2fe2..8c6a66110 100644 --- a/src/Runner.Worker/Dap/DapDebugger.cs +++ b/src/Runner.Worker/Dap/DapDebugger.cs @@ -16,6 +16,7 @@ using Microsoft.DevTunnels.Connections; using Microsoft.DevTunnels.Contracts; using Microsoft.DevTunnels.Management; using Newtonsoft.Json; +using Pipelines = GitHub.DistributedTask.Pipelines; namespace GitHub.Runner.Worker.Dap { @@ -27,6 +28,7 @@ namespace GitHub.Runner.Worker.Dap public string DisplayName { get; set; } public TaskResult? Result { get; set; } public int FrameId { get; set; } + public int? SourceLine { get; set; } } /// @@ -54,6 +56,9 @@ namespace GitHub.Runner.Worker.Dap // Frame IDs for completed steps start at 1000 private const int _completedFrameIdBase = 1000; + // Stable session-scoped source reference for the synthesized job step list. + private const int _jobStepsSourceReference = 1; + private TcpListener _listener; private TcpClient _client; private NetworkStream _stream; @@ -98,6 +103,8 @@ namespace GitHub.Runner.Worker.Dap // Track completed steps for stack trace private readonly List _completedSteps = new List(); private int _nextCompletedFrameId = _completedFrameIdBase; + private JobExecutionView _jobStepsSource; + private bool _jobCompleted; // Client connection tracking for reconnection support private volatile bool _isClientConnected; @@ -240,6 +247,179 @@ namespace GitHub.Runner.Worker.Dap } } + public Task OnJobStepsInitializedAsync(IEnumerable steps, IEnumerable initialPostSteps) + { + if (!IsActive) + { + return Task.CompletedTask; + } + + try + { + IExecutionContext jobContext; + lock (_stateLock) + { + if (_state != DapSessionState.Ready && + _state != DapSessionState.Paused && + _state != DapSessionState.Running) + { + return Task.CompletedTask; + } + + jobContext = _jobContext; + } + + var stepList = steps?.Where(step => step != null).ToList() ?? new List(); + var initialPostStepList = initialPostSteps?.Where(step => step != null).ToList() ?? new List(); + var jobId = jobContext?.GetGitHubContext("job"); + var snapshot = new JobExecutionView( + jobId, + stepList, + initialPostStepList, + PredictPostSteps(jobContext, stepList, initialPostStepList)); + + lock (_stateLock) + { + _jobStepsSource = snapshot; + _jobCompleted = false; + } + Trace.Info("DAP job steps source initialized"); + } + catch (Exception ex) + { + Trace.Warning("DAP OnJobStepsInitialized error."); + Trace.Error(ex); + } + + return Task.CompletedTask; + } + + public void OnPostStepRegistered(IStep step) + { + try + { + if (step is IActionRunner postRunner && postRunner.Action != null) + { + JobExecutionView snapshot; + lock (_stateLock) + { + snapshot = _jobStepsSource; + } + + var line = snapshot?.TryClaimPredictedStep(MatchKeyFor(postRunner.Action.Id), step); + if (line.HasValue) + { + Trace.Info($"DAP job steps source claimed predicted post step '{step.DisplayName}' at line {line.Value}."); + } + else + { + Trace.Info($"DAP job steps source had no predicted line for post step '{step.DisplayName}'."); + } + } + } + catch (Exception ex) + { + Trace.Warning("DAP OnPostStepRegistered error."); + Trace.Error(ex); + } + } + + private IReadOnlyList PredictPostSteps( + IExecutionContext jobContext, + IReadOnlyList steps, + IReadOnlyList initialPostSteps) + { + if (jobContext == null || steps == null || steps.Count == 0) + { + return Array.Empty(); + } + + IActionManager actionManager; + try + { + actionManager = HostContext.GetService(); + } + catch (Exception ex) + { + Trace.Info($"DAP post-step predictor skipped because IActionManager is unavailable ({ex.Message})."); + return Array.Empty(); + } + + var predictions = new List(); + var seenActionIds = new HashSet(); + if (initialPostSteps != null) + { + foreach (var postStep in initialPostSteps) + { + if (postStep is IActionRunner postRunner && postRunner.Action != null) + { + seenActionIds.Add(postRunner.Action.Id); + } + } + } + + foreach (var step in steps) + { + if (step is not IActionRunner runner || + runner.Stage == ActionRunStage.Post || + runner.Action == null) + { + continue; + } + + var action = runner.Action; + if (action.Reference is not Pipelines.RepositoryPathReference repoRef) + { + continue; + } + + if (!seenActionIds.Add(action.Id)) + { + continue; + } + + Definition definition; + try + { + definition = actionManager.LoadAction(jobContext, action); + } + catch (Exception ex) + { + Trace.Info($"DAP post-step predictor could not load action '{repoRef.Name}' ({ex.Message})."); + continue; + } + + if (definition?.Data?.Execution?.HasPost != true) + { + continue; + } + + predictions.Add(new JobExecutionView.PredictedPostStep( + GetPostDisplayName(runner), + MatchKeyFor(action.Id))); + } + + predictions.Reverse(); + return predictions; + } + + private static string GetPostDisplayName(IActionRunner runner) + { + var displayName = string.IsNullOrEmpty(runner.DisplayName) ? "step" : runner.DisplayName; + if (runner.Stage == ActionRunStage.Pre && + displayName.StartsWith("Pre ", StringComparison.OrdinalIgnoreCase)) + { + displayName = displayName.Substring("Pre ".Length); + } + + return $"Post {displayName}"; + } + + private static string MatchKeyFor(Guid actionId) + { + return $"post:{actionId:N}"; + } + public async Task OnJobCompletedAsync() { if (_state != DapSessionState.NotStarted) @@ -253,6 +433,11 @@ namespace GitHub.Runner.Worker.Dap if (_jobContext != null) { Trace.Info("Job completed — pausing for inspection"); + lock (_stateLock) + { + _jobCompleted = true; + } + SendStoppedEvent("completed", "Job completed — inspect variables before the session ends."); await WaitForCommandAsync(_jobContext.CancellationToken); @@ -359,6 +544,7 @@ namespace GitHub.Runner.Worker.Dap { _state = DapSessionState.Terminated; } + _jobStepsSource = null; } _isClientConnected = false; @@ -417,7 +603,8 @@ namespace GitHub.Runner.Worker.Dap { DisplayName = step.DisplayName, Result = result, - FrameId = _nextCompletedFrameId++ + FrameId = _nextCompletedFrameId++, + SourceLine = _jobStepsSource?.TryGetLineForStep(step) }); } } @@ -468,6 +655,7 @@ namespace GitHub.Runner.Worker.Dap "next" => HandleNext(request), "setBreakpoints" => HandleSetBreakpoints(request), "setExceptionBreakpoints" => HandleSetExceptionBreakpoints(request), + "source" => HandleSource(request), "completions" => HandleCompletions(request), "stepIn" => CreateResponse(request, false, "Step In is not supported. Actions jobs debug at the step level - use 'next' to advance to the next step.", body: null), "stepOut" => CreateResponse(request, false, "Step Out is not supported. Actions jobs debug at the step level - use 'continue' to resume.", body: null), @@ -857,6 +1045,7 @@ namespace GitHub.Runner.Worker.Dap { bool pauseOnNextStep; CancellationToken cancellationToken; + lock (_stateLock) { if (_state != DapSessionState.Ready && @@ -868,6 +1057,7 @@ namespace GitHub.Runner.Worker.Dap _currentStep = step; _currentStepIndex = _completedSteps.Count; + _jobCompleted = false; pauseOnNextStep = _pauseOnNextStep; cancellationToken = _jobContext?.CancellationToken ?? CancellationToken.None; } @@ -1050,29 +1240,46 @@ namespace GitHub.Runner.Worker.Dap private Response HandleStackTrace(Request request) { IStep currentStep; - int currentStepIndex; CompletedStepInfo[] completedSteps; + JobExecutionView jobStepsSource; + bool jobCompleted; lock (_stateLock) { currentStep = _currentStep; - currentStepIndex = _currentStepIndex; completedSteps = _completedSteps.ToArray(); + jobStepsSource = _jobStepsSource; + jobCompleted = _jobCompleted; } var frames = new List(); + var source = jobStepsSource != null ? BuildJobStepsSource(jobStepsSource) : null; // Add current step as the top frame - if (currentStep != null) + if (jobCompleted && jobStepsSource != null) + { + frames.Add(new StackFrame + { + Id = _currentFrameId, + Name = "Complete job [completed]", + Source = source, + Line = jobStepsSource.CompleteJobLine, + Column = 1, + PresentationHint = "normal" + }); + } + else if (currentStep != null) { var resultIndicator = currentStep.ExecutionContext?.Result != null ? $" [{currentStep.ExecutionContext.Result}]" : " [running]"; + var currentSourceLine = jobStepsSource?.TryGetLineForStep(currentStep); frames.Add(new StackFrame { Id = _currentFrameId, Name = MaskUserVisibleText($"{currentStep.DisplayName ?? "Current Step"}{resultIndicator}"), - Line = currentStepIndex + 1, + Source = currentSourceLine.HasValue ? source : null, + Line = currentSourceLine ?? 0, Column = 1, PresentationHint = "normal" }); @@ -1098,7 +1305,8 @@ namespace GitHub.Runner.Worker.Dap { Id = completedStep.FrameId, Name = MaskUserVisibleText($"{completedStep.DisplayName}{resultStr}"), - Line = 1, + Source = completedStep.SourceLine.HasValue ? source : null, + Line = completedStep.SourceLine ?? 0, Column = 1, PresentationHint = "subtle" }); @@ -1113,6 +1321,76 @@ namespace GitHub.Runner.Worker.Dap return CreateResponse(request, true, body: body); } + private Source BuildJobStepsSource(JobExecutionView snapshot) + { + return new Source + { + Name = MaskUserVisibleText(snapshot.SourceFileName), + Path = MaskUserVisibleText($"{SanitizeSourcePathSegment(snapshot.JobId)}/{snapshot.SourceFileName}"), + SourceReference = _jobStepsSourceReference, + PresentationHint = "normal" + }; + } + + private static string SanitizeSourcePathSegment(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return "job"; + } + + var builder = new StringBuilder(value.Length); + foreach (var character in value) + { + builder.Append(char.IsControl(character) || character == '/' || character == '\\' + ? '_' + : character); + } + + return builder.Length == 0 ? "job" : builder.ToString(); + } + + internal Response HandleSource(Request request) + { + SourceArguments args; + try + { + args = request.Arguments?.ToObject(); + } + catch (Exception ex) + { + Trace.Warning($"Failed to parse source arguments: {ex.GetType().Name}"); + return CreateResponse(request, false, "Invalid source arguments.", body: null); + } + + var sourceReference = args?.Source?.SourceReference ?? args?.SourceReference; + if (!sourceReference.HasValue) + { + return CreateResponse(request, false, "Missing source reference.", body: null); + } + + JobExecutionView snapshot; + lock (_stateLock) + { + snapshot = _jobStepsSource; + } + + if (snapshot == null) + { + return CreateResponse(request, false, "Job steps source not yet available.", body: null); + } + + if (sourceReference.Value != _jobStepsSourceReference) + { + return CreateResponse(request, false, $"Unknown source reference: {sourceReference.Value}.", body: null); + } + + return CreateResponse(request, true, body: new SourceResponseBody + { + Content = MaskUserVisibleText(snapshot.Content) + }); + } + private Response HandleScopes(Request request) { var args = request.Arguments?.ToObject(); diff --git a/src/Runner.Worker/Dap/DapMessages.cs b/src/Runner.Worker/Dap/DapMessages.cs index 53cd7a436..53cdd5d64 100644 --- a/src/Runner.Worker/Dap/DapMessages.cs +++ b/src/Runner.Worker/Dap/DapMessages.cs @@ -537,6 +537,46 @@ namespace GitHub.Runner.Worker.Dap #endregion + #region Source Request/Response + + /// + /// Arguments for 'source' request. + /// + public class SourceArguments + { + /// + /// Source descriptor. Some clients send sourceReference only here. + /// + [JsonProperty("source", NullValueHandling = NullValueHandling.Ignore)] + public Source Source { get; set; } + + /// + /// The reference to the source. + /// + [JsonProperty("sourceReference", NullValueHandling = NullValueHandling.Ignore)] + public int? SourceReference { get; set; } + } + + /// + /// Response body for 'source' request. + /// + public class SourceResponseBody + { + /// + /// Content of the source as a string. + /// + [JsonProperty("content")] + public string Content { get; set; } + + /// + /// Optional content type / mime type of the source. + /// + [JsonProperty("mimeType", NullValueHandling = NullValueHandling.Ignore)] + public string MimeType { get; set; } + } + + #endregion + #region Scopes Request/Response /// diff --git a/src/Runner.Worker/Dap/IDapDebugger.cs b/src/Runner.Worker/Dap/IDapDebugger.cs index 07ebcab69..f19351bf3 100644 --- a/src/Runner.Worker/Dap/IDapDebugger.cs +++ b/src/Runner.Worker/Dap/IDapDebugger.cs @@ -1,4 +1,5 @@ -using System.Threading.Tasks; +using System.Collections.Generic; +using System.Threading.Tasks; using GitHub.Runner.Common; namespace GitHub.Runner.Worker.Dap @@ -19,6 +20,8 @@ namespace GitHub.Runner.Worker.Dap { Task StartAsync(IExecutionContext jobContext); Task WaitUntilReadyAsync(); + Task OnJobStepsInitializedAsync(IEnumerable steps, IEnumerable initialPostSteps); + void OnPostStepRegistered(IStep step); Task OnStepStartingAsync(IStep step); void OnStepCompleted(IStep step); Task OnJobCompletedAsync(); diff --git a/src/Runner.Worker/Dap/JobExecutionView.cs b/src/Runner.Worker/Dap/JobExecutionView.cs new file mode 100644 index 000000000..0362204c5 --- /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/ExecutionContext.cs b/src/Runner.Worker/ExecutionContext.cs index b753c152b..f48dc16f1 100644 --- a/src/Runner.Worker/ExecutionContext.cs +++ b/src/Runner.Worker/ExecutionContext.cs @@ -343,6 +343,19 @@ namespace GitHub.Runner.Worker step.ExecutionContext.StepTelemetry.Action = step.DisplayName.ToLowerInvariant().Replace(' ', '_'); } Root.PostJobSteps.Push(step); + + if (Root.Global.Debugger?.Enabled == true) + { + try + { + HostContext.GetService().OnPostStepRegistered(step); + } + catch (Exception ex) + { + Trace.Warning("Failed to notify DAP debugger about registered post job step."); + Trace.Error(ex); + } + } } public IExecutionContext CreateChild( diff --git a/src/Runner.Worker/JobRunner.cs b/src/Runner.Worker/JobRunner.cs index 8308b4342..3c4799a29 100644 --- a/src/Runner.Worker/JobRunner.cs +++ b/src/Runner.Worker/JobRunner.cs @@ -13,6 +13,7 @@ using GitHub.DistributedTask.WebApi; using GitHub.Runner.Common; using GitHub.Runner.Common.Util; using GitHub.Runner.Sdk; +using GitHub.Runner.Worker.Dap; using GitHub.Services.Common; using GitHub.Services.WebApi; using Sdk.RSWebApi.Contracts; @@ -230,6 +231,12 @@ namespace GitHub.Runner.Worker jobContext.JobSteps.Enqueue(step); } + if (jobContext.Global.Debugger?.Enabled == true) + { + var dapDebugger = HostContext.GetService(); + await dapDebugger.OnJobStepsInitializedAsync(jobContext.JobSteps, jobContext.PostJobSteps); + } + await stepsRunner.RunAsync(jobContext); } catch (Exception ex)