diff --git a/.opencode/plans/dap-debugging.md b/.opencode/plans/dap-debugging.md index c1143a04f..206b29f18 100644 --- a/.opencode/plans/dap-debugging.md +++ b/.opencode/plans/dap-debugging.md @@ -4,6 +4,14 @@ **Author:** GitHub Actions Team **Date:** January 2026 +## Progress Checklist + +- [x] **Phase 1:** DAP Protocol Infrastructure (DapMessages.cs, DapServer.cs, basic DapDebugSession.cs) +- [x] **Phase 2:** Debug Session Logic (DapVariableProvider.cs, variable inspection, step history tracking) +- [ ] **Phase 3:** StepsRunner Integration (pause hooks before/after step execution) +- [ ] **Phase 4:** Expression Evaluation & Shell (REPL) +- [ ] **Phase 5:** Startup Integration (JobRunner.cs modifications) + ## Overview This document describes the implementation of Debug Adapter Protocol (DAP) support in the GitHub Actions runner, enabling rich debugging of workflow jobs from any DAP-compatible editor (nvim-dap, VS Code, etc.). diff --git a/src/Runner.Worker/Dap/DapDebugSession.cs b/src/Runner.Worker/Dap/DapDebugSession.cs index ba3868f30..25460a5d5 100644 --- a/src/Runner.Worker/Dap/DapDebugSession.cs +++ b/src/Runner.Worker/Dap/DapDebugSession.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using GitHub.DistributedTask.WebApi; @@ -81,6 +82,16 @@ namespace GitHub.Runner.Worker.Dap public const string Exception = "exception"; } + /// + /// Stores information about a completed step for stack trace display. + /// + internal sealed class CompletedStepInfo + { + public string DisplayName { get; set; } + public TaskResult? Result { get; set; } + public int FrameId { get; set; } + } + /// /// Interface for the DAP debug session. /// Handles debug state, step coordination, and DAP request processing. @@ -142,9 +153,12 @@ namespace GitHub.Runner.Worker.Dap // Thread ID for the single job execution thread private const int JobThreadId = 1; - // Frame ID for the current step + // Frame ID base for the current step (always 1) private const int CurrentFrameId = 1; + // Frame IDs for completed steps start at 1000 + private const int CompletedFrameIdBase = 1000; + private IDapServer _server; private DapSessionState _state = DapSessionState.WaitingForConnection; private InitializeRequestArguments _clientCapabilities; @@ -153,10 +167,20 @@ namespace GitHub.Runner.Worker.Dap private TaskCompletionSource _commandTcs; private readonly object _stateLock = new object(); + // Whether to pause before the next step (set by 'next' command) + private bool _pauseOnNextStep = true; + // Current execution context (set during OnStepStartingAsync) private IStep _currentStep; private IExecutionContext _jobContext; + // Track completed steps for stack trace + private readonly List _completedSteps = new List(); + private int _nextCompletedFrameId = CompletedFrameIdBase; + + // Variable provider for converting contexts to DAP variables + private DapVariableProvider _variableProvider; + public bool IsActive => _state == DapSessionState.Ready || _state == DapSessionState.Paused || _state == DapSessionState.Running; public DapSessionState State => _state; @@ -164,6 +188,7 @@ namespace GitHub.Runner.Worker.Dap public override void Initialize(IHostContext hostContext) { base.Initialize(hostContext); + _variableProvider = new DapVariableProvider(hostContext); Trace.Info("DapDebugSession initialized"); } @@ -299,7 +324,7 @@ namespace GitHub.Runner.Worker.Dap // We have a single thread representing the job execution var body = new ThreadsResponseBody { - Threads = new System.Collections.Generic.List + Threads = new List { new Thread { @@ -318,15 +343,19 @@ namespace GitHub.Runner.Worker.Dap { var args = request.Arguments?.ToObject(); - var frames = new System.Collections.Generic.List(); + var frames = new List(); // Add current step as the top frame if (_currentStep != null) { + var resultIndicator = _currentStep.ExecutionContext?.Result != null + ? $" [{_currentStep.ExecutionContext.Result}]" + : " [running]"; + frames.Add(new StackFrame { Id = CurrentFrameId, - Name = _currentStep.DisplayName ?? "Current Step", + Name = $"{_currentStep.DisplayName ?? "Current Step"}{resultIndicator}", Line = 1, Column = 1, PresentationHint = "normal" @@ -344,7 +373,20 @@ namespace GitHub.Runner.Worker.Dap }); } - // TODO: In Phase 2, add completed steps as additional frames + // Add completed steps as additional frames (most recent first) + for (int i = _completedSteps.Count - 1; i >= 0; i--) + { + var completedStep = _completedSteps[i]; + var resultStr = completedStep.Result.HasValue ? $" [{completedStep.Result}]" : ""; + frames.Add(new StackFrame + { + Id = completedStep.FrameId, + Name = $"{completedStep.DisplayName}{resultStr}", + Line = 1, + Column = 1, + PresentationHint = "subtle" + }); + } var body = new StackTraceResponseBody { @@ -357,44 +399,39 @@ namespace GitHub.Runner.Worker.Dap private Response HandleScopes(Request request) { - // Stub implementation - Phase 2 will populate with actual contexts - var body = new ScopesResponseBody - { - Scopes = new System.Collections.Generic.List - { - new Scope { Name = "github", VariablesReference = 1, Expensive = false }, - new Scope { Name = "env", VariablesReference = 2, Expensive = false }, - new Scope { Name = "runner", VariablesReference = 3, Expensive = false }, - new Scope { Name = "job", VariablesReference = 4, Expensive = false }, - new Scope { Name = "steps", VariablesReference = 5, Expensive = false }, - new Scope { Name = "secrets", VariablesReference = 6, Expensive = false, PresentationHint = "registers" }, - } - }; + var args = request.Arguments?.ToObject(); + var frameId = args?.FrameId ?? CurrentFrameId; - return CreateSuccessResponse(body); + // Get the execution context for the requested frame + var context = GetExecutionContextForFrame(frameId); + if (context == null) + { + // Return empty scopes if no context available + return CreateSuccessResponse(new ScopesResponseBody { Scopes = new List() }); + } + + // Use the variable provider to get scopes + var scopes = _variableProvider.GetScopes(context, frameId); + + return CreateSuccessResponse(new ScopesResponseBody { Scopes = scopes }); } private Response HandleVariables(Request request) { - // Stub implementation - Phase 2 will populate with actual variable values var args = request.Arguments?.ToObject(); var variablesRef = args?.VariablesReference ?? 0; - var body = new VariablesResponseBody + // Get the current execution context + var context = _currentStep?.ExecutionContext ?? _jobContext; + if (context == null) { - Variables = new System.Collections.Generic.List - { - new Variable - { - Name = "(stub)", - Value = $"Variables for scope {variablesRef} will be implemented in Phase 2", - Type = "string", - VariablesReference = 0 - } - } - }; + return CreateSuccessResponse(new VariablesResponseBody { Variables = new List() }); + } - return CreateSuccessResponse(body); + // Use the variable provider to get variables + var variables = _variableProvider.GetVariables(context, variablesRef); + + return CreateSuccessResponse(new VariablesResponseBody { Variables = variables }); } private Response HandleContinue(Request request) @@ -406,6 +443,7 @@ namespace GitHub.Runner.Worker.Dap if (_state == DapSessionState.Paused) { _state = DapSessionState.Running; + _pauseOnNextStep = false; // Don't pause on next step _commandTcs?.TrySetResult(DapCommand.Continue); } } @@ -425,6 +463,7 @@ namespace GitHub.Runner.Worker.Dap if (_state == DapSessionState.Paused) { _state = DapSessionState.Running; + _pauseOnNextStep = true; // Pause before next step _commandTcs?.TrySetResult(DapCommand.Next); } } @@ -439,13 +478,13 @@ namespace GitHub.Runner.Worker.Dap // The runner will pause at the next step boundary lock (_stateLock) { - // Just acknowledge - actual pause happens at step boundary + _pauseOnNextStep = true; } return CreateSuccessResponse(null); } - private async Task HandleEvaluateAsync(Request request) + private Task HandleEvaluateAsync(Request request) { var args = request.Arguments?.ToObject(); var expression = args?.Expression ?? ""; @@ -461,7 +500,7 @@ namespace GitHub.Runner.Worker.Dap VariablesReference = 0 }; - return CreateSuccessResponse(body); + return Task.FromResult(CreateSuccessResponse(body)); } private Response HandleSetBreakpoints(Request request) @@ -500,6 +539,18 @@ namespace GitHub.Runner.Worker.Dap _currentStep = step; _jobContext = jobContext; + // Reset variable provider state for new step context + _variableProvider.Reset(); + + // Determine if we should pause + bool shouldPause = isFirstStep || _pauseOnNextStep; + + if (!shouldPause) + { + Trace.Info($"Step starting (not pausing): {step.DisplayName}"); + return; + } + var reason = isFirstStep ? StopReason.Entry : StopReason.Step; var description = isFirstStep ? $"Stopped at job entry: {step.DisplayName}" @@ -531,10 +582,19 @@ namespace GitHub.Runner.Worker.Dap return; } - Trace.Info($"Step completed: {step.DisplayName}, result: {step.ExecutionContext?.Result}"); + var result = step.ExecutionContext?.Result; + Trace.Info($"Step completed: {step.DisplayName}, result: {result}"); - // The step context will be available for inspection - // Future: could pause here if "pause after step" is enabled + // Add to completed steps list + _completedSteps.Add(new CompletedStepInfo + { + DisplayName = step.DisplayName, + Result = result, + FrameId = _nextCompletedFrameId++ + }); + + // Clear current step reference since it's done + // (will be set again when next step starts) } public void OnJobCompleted() @@ -559,7 +619,7 @@ namespace GitHub.Runner.Worker.Dap }); // Send exited event - var exitCode = _jobContext?.Result == GitHub.DistributedTask.WebApi.TaskResult.Succeeded ? 0 : 1; + var exitCode = _jobContext?.Result == TaskResult.Succeeded ? 0 : 1; _server?.SendEvent(new Event { EventType = "exited", @@ -607,6 +667,22 @@ namespace GitHub.Runner.Worker.Dap } } + /// + /// Gets the execution context for a given frame ID. + /// Currently only supports the current frame (completed steps don't have saved contexts). + /// + private IExecutionContext GetExecutionContextForFrame(int frameId) + { + if (frameId == CurrentFrameId) + { + return _currentStep?.ExecutionContext ?? _jobContext; + } + + // For completed steps, we would need to save their execution contexts + // For now, return null (variables won't be available for completed steps) + return null; + } + #endregion #region Response Helpers diff --git a/src/Runner.Worker/Dap/DapVariableProvider.cs b/src/Runner.Worker/Dap/DapVariableProvider.cs new file mode 100644 index 000000000..b4098fd21 --- /dev/null +++ b/src/Runner.Worker/Dap/DapVariableProvider.cs @@ -0,0 +1,293 @@ +using System; +using System.Collections.Generic; +using GitHub.DistributedTask.Pipelines.ContextData; +using GitHub.Runner.Common; + +namespace GitHub.Runner.Worker.Dap +{ + /// + /// Provides DAP variable information from the execution context. + /// Maps workflow contexts (github, env, runner, job, steps, secrets) to DAP scopes and variables. + /// + public sealed class DapVariableProvider + { + // Well-known scope names that map to top-level contexts + private static readonly string[] ScopeNames = { "github", "env", "runner", "job", "steps", "secrets", "inputs", "vars", "matrix", "needs" }; + + // Reserved variable reference ranges for scopes (1-100) + private const int ScopeReferenceBase = 1; + private const int ScopeReferenceMax = 100; + + // Dynamic variable references start after scope range + private const int DynamicReferenceBase = 101; + + private readonly IHostContext _hostContext; + private readonly Dictionary _variableReferences = new(); + private int _nextVariableReference = DynamicReferenceBase; + + public DapVariableProvider(IHostContext hostContext) + { + _hostContext = hostContext; + } + + /// + /// Resets the variable reference state. Call this when the execution context changes. + /// + public void Reset() + { + _variableReferences.Clear(); + _nextVariableReference = DynamicReferenceBase; + } + + /// + /// Gets the list of scopes for a given execution context. + /// Each scope represents a top-level context like 'github', 'env', etc. + /// + public List GetScopes(IExecutionContext context, int frameId) + { + var scopes = new List(); + + if (context?.ExpressionValues == null) + { + return scopes; + } + + for (int i = 0; i < ScopeNames.Length; i++) + { + var scopeName = ScopeNames[i]; + if (context.ExpressionValues.TryGetValue(scopeName, out var value) && value != null) + { + var variablesRef = ScopeReferenceBase + i; + var scope = new Scope + { + Name = scopeName, + VariablesReference = variablesRef, + Expensive = false, + // Secrets get a special presentation hint + PresentationHint = scopeName == "secrets" ? "registers" : null + }; + + // Count named variables if it's a dictionary + if (value is DictionaryContextData dict) + { + scope.NamedVariables = dict.Count; + } + else if (value is CaseSensitiveDictionaryContextData csDict) + { + scope.NamedVariables = csDict.Count; + } + + scopes.Add(scope); + } + } + + return scopes; + } + + /// + /// Gets variables for a given variable reference. + /// + public List GetVariables(IExecutionContext context, int variablesReference) + { + var variables = new List(); + + if (context?.ExpressionValues == null) + { + return variables; + } + + PipelineContextData data = null; + string basePath = null; + bool isSecretsScope = false; + + // Check if this is a scope reference (1-100) + if (variablesReference >= ScopeReferenceBase && variablesReference <= ScopeReferenceMax) + { + var scopeIndex = variablesReference - ScopeReferenceBase; + if (scopeIndex < ScopeNames.Length) + { + var scopeName = ScopeNames[scopeIndex]; + isSecretsScope = scopeName == "secrets"; + if (context.ExpressionValues.TryGetValue(scopeName, out data)) + { + basePath = scopeName; + } + } + } + // Check dynamic references + else if (_variableReferences.TryGetValue(variablesReference, out var refData)) + { + data = refData.Data; + basePath = refData.Path; + // Check if we're inside the secrets scope + isSecretsScope = basePath?.StartsWith("secrets", StringComparison.OrdinalIgnoreCase) == true; + } + + if (data == null) + { + return variables; + } + + // Convert the data to variables + ConvertToVariables(data, basePath, isSecretsScope, variables); + + return variables; + } + + /// + /// Converts PipelineContextData to DAP Variable objects. + /// + private void ConvertToVariables(PipelineContextData data, string basePath, bool isSecretsScope, List variables) + { + switch (data) + { + case DictionaryContextData dict: + ConvertDictionaryToVariables(dict, basePath, isSecretsScope, variables); + break; + + case CaseSensitiveDictionaryContextData csDict: + ConvertCaseSensitiveDictionaryToVariables(csDict, basePath, isSecretsScope, variables); + break; + + case ArrayContextData array: + ConvertArrayToVariables(array, basePath, isSecretsScope, variables); + break; + + default: + // Scalar value - shouldn't typically get here for a container + break; + } + } + + private void ConvertDictionaryToVariables(DictionaryContextData dict, string basePath, bool isSecretsScope, List variables) + { + foreach (var pair in dict) + { + var variable = CreateVariable(pair.Key, pair.Value, basePath, isSecretsScope); + variables.Add(variable); + } + } + + private void ConvertCaseSensitiveDictionaryToVariables(CaseSensitiveDictionaryContextData dict, string basePath, bool isSecretsScope, List variables) + { + foreach (var pair in dict) + { + var variable = CreateVariable(pair.Key, pair.Value, basePath, isSecretsScope); + variables.Add(variable); + } + } + + private void ConvertArrayToVariables(ArrayContextData array, string basePath, bool isSecretsScope, List variables) + { + for (int i = 0; i < array.Count; i++) + { + var item = array[i]; + var variable = CreateVariable($"[{i}]", item, basePath, isSecretsScope); + variable.Name = $"[{i}]"; + variables.Add(variable); + } + } + + private Variable CreateVariable(string name, PipelineContextData value, string basePath, bool isSecretsScope) + { + var childPath = string.IsNullOrEmpty(basePath) ? name : $"{basePath}.{name}"; + var variable = new Variable + { + Name = name, + EvaluateName = $"${{{{ {childPath} }}}}" + }; + + if (value == null) + { + variable.Value = "null"; + variable.Type = "null"; + variable.VariablesReference = 0; + return variable; + } + + switch (value) + { + case StringContextData str: + if (isSecretsScope) + { + // Always mask secrets regardless of value + variable.Value = "[REDACTED]"; + } + else + { + // Mask any secret values that might be in non-secret contexts + variable.Value = MaskSecrets(str.Value); + } + variable.Type = "string"; + variable.VariablesReference = 0; + break; + + case NumberContextData num: + variable.Value = num.ToString(); + variable.Type = "number"; + variable.VariablesReference = 0; + break; + + case BooleanContextData boolVal: + variable.Value = boolVal.Value ? "true" : "false"; + variable.Type = "boolean"; + variable.VariablesReference = 0; + break; + + case DictionaryContextData dict: + variable.Value = $"Object ({dict.Count} properties)"; + variable.Type = "object"; + variable.VariablesReference = RegisterVariableReference(dict, childPath); + variable.NamedVariables = dict.Count; + break; + + case CaseSensitiveDictionaryContextData csDict: + variable.Value = $"Object ({csDict.Count} properties)"; + variable.Type = "object"; + variable.VariablesReference = RegisterVariableReference(csDict, childPath); + variable.NamedVariables = csDict.Count; + break; + + case ArrayContextData array: + variable.Value = $"Array ({array.Count} items)"; + variable.Type = "array"; + variable.VariablesReference = RegisterVariableReference(array, childPath); + variable.IndexedVariables = array.Count; + break; + + default: + // Unknown type - convert to string representation + var rawValue = value.ToJToken()?.ToString() ?? "unknown"; + variable.Value = MaskSecrets(rawValue); + variable.Type = value.GetType().Name; + variable.VariablesReference = 0; + break; + } + + return variable; + } + + /// + /// Registers a nested variable reference and returns its ID. + /// + private int RegisterVariableReference(PipelineContextData data, string path) + { + var reference = _nextVariableReference++; + _variableReferences[reference] = (data, path); + return reference; + } + + /// + /// Masks any secret values in the string using the host context's secret masker. + /// + private string MaskSecrets(string value) + { + if (string.IsNullOrEmpty(value)) + { + return value ?? string.Empty; + } + + return _hostContext.SecretMasker.MaskSecrets(value); + } + } +}