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);
+ }
+ }
+}