From bbe97ff1c84dc7f53acf7b90c54f317b4ff34b07 Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Thu, 15 Jan 2026 18:11:34 +0000 Subject: [PATCH] logging --- .opencode/plans/dap-debug-logging.md | 511 +++++++++++++++++++++++ src/Runner.Worker/Dap/DapDebugSession.cs | 242 ++++++++++- src/Runner.Worker/StepsContext.cs | 30 +- 3 files changed, 777 insertions(+), 6 deletions(-) create mode 100644 .opencode/plans/dap-debug-logging.md diff --git a/.opencode/plans/dap-debug-logging.md b/.opencode/plans/dap-debug-logging.md new file mode 100644 index 000000000..aba77f8f9 --- /dev/null +++ b/.opencode/plans/dap-debug-logging.md @@ -0,0 +1,511 @@ +# DAP Debug Logging Feature + +**Status:** Implemented +**Date:** January 2026 +**Related:** [dap-debugging.md](./dap-debugging.md), [dap-step-backwards.md](./dap-step-backwards.md) + +## Overview + +Add comprehensive debug logging to the DAP debugging infrastructure that can be toggled from the DAP client. This helps diagnose issues like step conclusions not updating correctly after step-back operations. + +## Features + +### 1. Debug Log Levels + +| Level | Value | What Gets Logged | +|-------|-------|------------------| +| `Off` | 0 | Nothing | +| `Minimal` | 1 | Errors, critical state changes | +| `Normal` | 2 | Step lifecycle, checkpoint operations | +| `Verbose` | 3 | Everything including outputs, expressions | + +### 2. Enabling Debug Logging + +#### Via Attach Arguments (nvim-dap config) + +```lua +{ + type = "runner", + request = "attach", + debugLogging = true, -- Enable debug logging (defaults to "normal" level) + debugLogLevel = "verbose", -- Optional: "off", "minimal", "normal", "verbose" +} +``` + +#### Via REPL Commands (runtime toggle) + +| Command | Description | +|---------|-------------| +| `!debug on` | Enable debug logging (level: normal) | +| `!debug off` | Disable debug logging | +| `!debug minimal` | Set level to minimal | +| `!debug normal` | Set level to normal | +| `!debug verbose` | Set level to verbose | +| `!debug status` | Show current debug settings | + +### 3. Log Output Format + +All debug logs are sent to the DAP console with the format: + +``` +[DEBUG] [Category] Message +``` + +Categories include: +- `[Step]` - Step lifecycle events +- `[Checkpoint]` - Checkpoint creation/restoration +- `[StepsContext]` - Steps context mutations (SetOutcome, SetConclusion, SetOutput, ClearScope) + +### 4. Example Output + +With `!debug verbose` enabled: + +``` +[DEBUG] [Step] Starting: 'cat doesnotexist' (index=2) +[DEBUG] [Step] Checkpoints available: 2 +[DEBUG] [StepsContext] SetOutcome: step='thecat', outcome=failure +[DEBUG] [StepsContext] SetConclusion: step='thecat', conclusion=failure +[DEBUG] [Step] Completed: 'cat doesnotexist', result=Failed +[DEBUG] [Step] Context state: outcome=failure, conclusion=failure + +# After step-back: +[DEBUG] [Checkpoint] Restoring checkpoint [1] for step 'cat doesnotexist' +[DEBUG] [StepsContext] ClearScope: scope='(root)' +[DEBUG] [StepsContext] Restoring: clearing scope '(root)', restoring 2 step(s) +[DEBUG] [StepsContext] Restored: step='thefoo', outcome=success, conclusion=success + +# After re-running with file created: +[DEBUG] [Step] Starting: 'cat doesnotexist' (index=2) +[DEBUG] [StepsContext] SetOutcome: step='thecat', outcome=success +[DEBUG] [StepsContext] SetConclusion: step='thecat', conclusion=success +[DEBUG] [Step] Completed: 'cat doesnotexist', result=Succeeded +[DEBUG] [Step] Context state: outcome=success, conclusion=success +``` + +## Implementation + +### Progress Checklist + +- [x] **Phase 1:** Add debug logging infrastructure to DapDebugSession +- [x] **Phase 2:** Add REPL `!debug` command handling +- [x] **Phase 3:** Add OnDebugLog callback to StepsContext +- [x] **Phase 4:** Add debug logging calls throughout DapDebugSession +- [x] **Phase 5:** Hook up StepsContext logging to DapDebugSession +- [ ] **Phase 6:** Testing + +--- + +### Phase 1: Debug Logging Infrastructure + +**File:** `src/Runner.Worker/Dap/DapDebugSession.cs` + +Add enum and helper method: + +```csharp +// Add enum for debug log levels (near top of file with other enums) +public enum DebugLogLevel +{ + Off = 0, + Minimal = 1, // Errors, critical state changes + Normal = 2, // Step lifecycle, checkpoints + Verbose = 3 // Everything including outputs, expressions +} + +// Add field (with other private fields) +private DebugLogLevel _debugLogLevel = DebugLogLevel.Off; + +// Add helper method (in a #region Debug Logging) +private void DebugLog(string message, DebugLogLevel minLevel = DebugLogLevel.Normal) +{ + if (_debugLogLevel >= minLevel) + { + _server?.SendEvent(new Event + { + EventType = "output", + Body = new OutputEventBody + { + Category = "console", + Output = $"[DEBUG] {message}\n" + } + }); + } +} +``` + +Update `HandleAttach` to parse debug logging arguments: + +```csharp +private Response HandleAttach(Request request) +{ + Trace.Info("Attach request handled"); + + // Parse debug logging from attach args + if (request.Arguments is JsonElement args) + { + if (args.TryGetProperty("debugLogging", out var debugLogging)) + { + if (debugLogging.ValueKind == JsonValueKind.True) + { + _debugLogLevel = DebugLogLevel.Normal; + Trace.Info("Debug logging enabled via attach args (level: normal)"); + } + } + if (args.TryGetProperty("debugLogLevel", out var level) && level.ValueKind == JsonValueKind.String) + { + _debugLogLevel = level.GetString()?.ToLower() switch + { + "minimal" => DebugLogLevel.Minimal, + "normal" => DebugLogLevel.Normal, + "verbose" => DebugLogLevel.Verbose, + "off" => DebugLogLevel.Off, + _ => _debugLogLevel + }; + Trace.Info($"Debug log level set via attach args: {_debugLogLevel}"); + } + } + + return CreateSuccessResponse(null); +} +``` + +--- + +### Phase 2: REPL `!debug` Command + +**File:** `src/Runner.Worker/Dap/DapDebugSession.cs` + +In `HandleEvaluateAsync`, add handling for `!debug` command before other shell command handling: + +```csharp +// Near the start of HandleEvaluateAsync, after getting the expression: + +// Check for debug command +if (expression.StartsWith("!debug", StringComparison.OrdinalIgnoreCase)) +{ + return HandleDebugCommand(expression); +} + +// ... rest of existing HandleEvaluateAsync code +``` + +Add the handler method: + +```csharp +private Response HandleDebugCommand(string command) +{ + var parts = command.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var arg = parts.Length > 1 ? parts[1].ToLower() : "status"; + + string result; + switch (arg) + { + case "on": + _debugLogLevel = DebugLogLevel.Normal; + result = "Debug logging enabled (level: normal)"; + break; + case "off": + _debugLogLevel = DebugLogLevel.Off; + result = "Debug logging disabled"; + break; + case "minimal": + _debugLogLevel = DebugLogLevel.Minimal; + result = "Debug logging set to minimal"; + break; + case "normal": + _debugLogLevel = DebugLogLevel.Normal; + result = "Debug logging set to normal"; + break; + case "verbose": + _debugLogLevel = DebugLogLevel.Verbose; + result = "Debug logging set to verbose"; + break; + case "status": + default: + result = $"Debug logging: {_debugLogLevel}"; + break; + } + + return CreateSuccessResponse(new EvaluateResponseBody + { + Result = result, + VariablesReference = 0 + }); +} +``` + +--- + +### Phase 3: StepsContext OnDebugLog Callback + +**File:** `src/Runner.Worker/StepsContext.cs` + +Add callback property and helper: + +```csharp +public sealed class StepsContext +{ + private static readonly Regex _propertyRegex = new("^[a-zA-Z_][a-zA-Z0-9_]*$", RegexOptions.Compiled); + private readonly DictionaryContextData _contextData = new(); + + /// + /// Optional callback for debug logging. When set, will be called with debug messages + /// for all StepsContext mutations. + /// + public Action OnDebugLog { get; set; } + + private void DebugLog(string message) + { + OnDebugLog?.Invoke(message); + } + + // ... rest of class +} +``` + +Update `ClearScope`: + +```csharp +public void ClearScope(string scopeName) +{ + DebugLog($"[StepsContext] ClearScope: scope='{scopeName ?? "(root)"}'"); + if (_contextData.TryGetValue(scopeName, out _)) + { + _contextData[scopeName] = new DictionaryContextData(); + } +} +``` + +Update `SetOutput`: + +```csharp +public void SetOutput( + string scopeName, + string stepName, + string outputName, + string value, + out string reference) +{ + var step = GetStep(scopeName, stepName); + var outputs = step["outputs"].AssertDictionary("outputs"); + outputs[outputName] = new StringContextData(value); + if (_propertyRegex.IsMatch(outputName)) + { + reference = $"steps.{stepName}.outputs.{outputName}"; + } + else + { + reference = $"steps['{stepName}']['outputs']['{outputName}']"; + } + DebugLog($"[StepsContext] SetOutput: step='{stepName}', output='{outputName}', value='{TruncateValue(value)}'"); +} + +private static string TruncateValue(string value, int maxLength = 50) +{ + if (string.IsNullOrEmpty(value)) return "(empty)"; + if (value.Length <= maxLength) return value; + return value.Substring(0, maxLength) + "..."; +} +``` + +Update `SetConclusion`: + +```csharp +public void SetConclusion( + string scopeName, + string stepName, + ActionResult conclusion) +{ + var step = GetStep(scopeName, stepName); + var conclusionStr = conclusion.ToString().ToLowerInvariant(); + step["conclusion"] = new StringContextData(conclusionStr); + DebugLog($"[StepsContext] SetConclusion: step='{stepName}', conclusion={conclusionStr}"); +} +``` + +Update `SetOutcome`: + +```csharp +public void SetOutcome( + string scopeName, + string stepName, + ActionResult outcome) +{ + var step = GetStep(scopeName, stepName); + var outcomeStr = outcome.ToString().ToLowerInvariant(); + step["outcome"] = new StringContextData(outcomeStr); + DebugLog($"[StepsContext] SetOutcome: step='{stepName}', outcome={outcomeStr}"); +} +``` + +--- + +### Phase 4: DapDebugSession Logging Calls + +**File:** `src/Runner.Worker/Dap/DapDebugSession.cs` + +#### In `OnStepStartingAsync` (after setting `_currentStep` and `_jobContext`): + +```csharp +DebugLog($"[Step] Starting: '{step.DisplayName}' (index={stepIndex})"); +DebugLog($"[Step] Checkpoints available: {_checkpoints.Count}"); +``` + +#### In `OnStepCompleted` (after logging to Trace): + +```csharp +DebugLog($"[Step] Completed: '{step.DisplayName}', result={result}"); + +// Log current steps context state for this step +if (_debugLogLevel >= DebugLogLevel.Normal) +{ + var stepsScope = step.ExecutionContext?.Global?.StepsContext?.GetScope(step.ExecutionContext.ScopeName); + if (stepsScope != null && !string.IsNullOrEmpty(step.ExecutionContext?.ContextName)) + { + if (stepsScope.TryGetValue(step.ExecutionContext.ContextName, out var stepData) && stepData is DictionaryContextData sd) + { + var outcome = sd.TryGetValue("outcome", out var o) && o is StringContextData os ? os.Value : "null"; + var conclusion = sd.TryGetValue("conclusion", out var c) && c is StringContextData cs ? cs.Value : "null"; + DebugLog($"[Step] Context state: outcome={outcome}, conclusion={conclusion}"); + } + } +} +``` + +#### In `CreateCheckpointForPendingStep` (after creating checkpoint): + +```csharp +DebugLog($"[Checkpoint] Created [{_checkpoints.Count - 1}] for step '{_pendingStep.DisplayName}'"); +if (_debugLogLevel >= DebugLogLevel.Verbose) +{ + DebugLog($"[Checkpoint] Snapshot contains {checkpoint.StepsSnapshot.Count} step(s)", DebugLogLevel.Verbose); + foreach (var entry in checkpoint.StepsSnapshot) + { + DebugLog($"[Checkpoint] {entry.Key}: outcome={entry.Value.Outcome}, conclusion={entry.Value.Conclusion}", DebugLogLevel.Verbose); + } +} +``` + +#### In `RestoreCheckpoint` (at start of method): + +```csharp +DebugLog($"[Checkpoint] Restoring [{checkpointIndex}] for step '{checkpoint.StepDisplayName}'"); +if (_debugLogLevel >= DebugLogLevel.Verbose) +{ + DebugLog($"[Checkpoint] Snapshot has {checkpoint.StepsSnapshot.Count} step(s)", DebugLogLevel.Verbose); +} +``` + +#### In `RestoreStepsContext` (update existing method): + +```csharp +private void RestoreStepsContext(StepsContext stepsContext, Dictionary snapshot, string scopeName) +{ + scopeName = scopeName ?? string.Empty; + + DebugLog($"[StepsContext] Restoring: clearing scope '{(string.IsNullOrEmpty(scopeName) ? "(root)" : scopeName)}', will restore {snapshot.Count} step(s)"); + + stepsContext.ClearScope(scopeName); + + foreach (var entry in snapshot) + { + var key = entry.Key; + var slashIndex = key.IndexOf('/'); + + if (slashIndex >= 0) + { + var snapshotScopeName = slashIndex > 0 ? key.Substring(0, slashIndex) : string.Empty; + var stepName = key.Substring(slashIndex + 1); + + if (snapshotScopeName == scopeName) + { + var state = entry.Value; + + if (state.Outcome.HasValue) + { + stepsContext.SetOutcome(scopeName, stepName, state.Outcome.Value); + } + if (state.Conclusion.HasValue) + { + stepsContext.SetConclusion(scopeName, stepName, state.Conclusion.Value); + } + + if (state.Outputs != null) + { + foreach (var output in state.Outputs) + { + stepsContext.SetOutput(scopeName, stepName, output.Key, output.Value, out _); + } + } + + DebugLog($"[StepsContext] Restored: step='{stepName}', outcome={state.Outcome}, conclusion={state.Conclusion}", DebugLogLevel.Verbose); + } + } + } + + Trace.Info($"Steps context restored: cleared scope '{scopeName}' and restored {snapshot.Count} step(s) from snapshot"); +} +``` + +--- + +### Phase 5: Hook Up StepsContext Logging + +**File:** `src/Runner.Worker/Dap/DapDebugSession.cs` + +In `OnStepStartingAsync`, after setting `_jobContext`, hook up the callback (only once): + +```csharp +// Hook up StepsContext debug logging (do this once when we first get jobContext) +if (jobContext.Global.StepsContext.OnDebugLog == null) +{ + jobContext.Global.StepsContext.OnDebugLog = (msg) => DebugLog(msg, DebugLogLevel.Verbose); +} +``` + +**Note:** StepsContext logging is set to `Verbose` level since `SetOutput` can be noisy. `SetConclusion` and `SetOutcome` will still appear at `Verbose` level, but all the important state changes are also logged directly in `OnStepCompleted` at `Normal` level. + +--- + +### Phase 6: Testing + +#### Manual Testing Checklist + +- [ ] `!debug status` shows "Off" by default +- [ ] `!debug on` enables logging, shows step lifecycle +- [ ] `!debug verbose` shows StepsContext mutations +- [ ] `!debug off` disables logging +- [ ] Attach with `debugLogging: true` enables logging on connect +- [ ] Attach with `debugLogLevel: "verbose"` sets correct level +- [ ] Step-back scenario shows restoration logs +- [ ] Logs help identify why conclusion might not update + +#### Test Workflow + +Use the test workflow with `thecat` step: +1. Run workflow, let `thecat` fail +2. Enable `!debug verbose` +3. Step back +4. Create the missing file +5. Step forward +6. Observe logs to see if `SetConclusion` is called with `success` + +--- + +## Files Summary + +### Modified Files + +| File | Changes | +|------|---------| +| `src/Runner.Worker/Dap/DapDebugSession.cs` | Add `DebugLogLevel` enum, `_debugLogLevel` field, `DebugLog()` helper, `HandleDebugCommand()`, update `HandleAttach`, add logging calls throughout, hook up StepsContext callback | +| `src/Runner.Worker/StepsContext.cs` | Add `OnDebugLog` callback, `DebugLog()` helper, `TruncateValue()` helper, add logging to `ClearScope`, `SetOutput`, `SetConclusion`, `SetOutcome` | + +--- + +## Future Enhancements (Out of Scope) + +- Additional debug commands (`!debug checkpoints`, `!debug steps`, `!debug env`) +- Log to file option +- Structured logging with timestamps +- Category-based filtering (e.g., only show `[StepsContext]` logs) +- Integration with nvim-dap's virtual text for inline debug info diff --git a/src/Runner.Worker/Dap/DapDebugSession.cs b/src/Runner.Worker/Dap/DapDebugSession.cs index 5bffbec5a..99ad8b6fa 100644 --- a/src/Runner.Worker/Dap/DapDebugSession.cs +++ b/src/Runner.Worker/Dap/DapDebugSession.cs @@ -89,6 +89,32 @@ namespace GitHub.Runner.Worker.Dap ReverseContinue } + /// + /// Debug log levels for controlling verbosity of debug output. + /// + public enum DebugLogLevel + { + /// + /// No debug logging. + /// + Off = 0, + + /// + /// Errors and critical state changes only. + /// + Minimal = 1, + + /// + /// Step lifecycle and checkpoint operations. + /// + Normal = 2, + + /// + /// Everything including outputs, expressions, and StepsContext mutations. + /// + Verbose = 3 + } + /// /// Reasons for stopping/pausing execution. /// @@ -268,6 +294,9 @@ namespace GitHub.Runner.Worker.Dap private int? _pendingRestoreCheckpoint = null; private StepCheckpoint _restoredCheckpoint = null; + // Debug logging level (controlled via attach args or REPL command) + private DebugLogLevel _debugLogLevel = DebugLogLevel.Off; + public bool IsActive => _state == DapSessionState.Ready || _state == DapSessionState.Paused || _state == DapSessionState.Running; public DapSessionState State => _state; @@ -383,6 +412,34 @@ namespace GitHub.Runner.Worker.Dap private Response HandleAttach(Request request) { Trace.Info("Attach request handled"); + + // Parse debug logging from attach args + if (request.Arguments != null) + { + // Check for debugLogging (boolean) + var debugLogging = request.Arguments["debugLogging"]; + if (debugLogging != null && debugLogging.Type == Newtonsoft.Json.Linq.JTokenType.Boolean && (bool)debugLogging) + { + _debugLogLevel = DebugLogLevel.Normal; + Trace.Info("Debug logging enabled via attach args (level: normal)"); + } + + // Check for debugLogLevel (string) + var debugLogLevel = request.Arguments["debugLogLevel"]; + if (debugLogLevel != null && debugLogLevel.Type == Newtonsoft.Json.Linq.JTokenType.String) + { + _debugLogLevel = ((string)debugLogLevel)?.ToLower() switch + { + "minimal" => DebugLogLevel.Minimal, + "normal" => DebugLogLevel.Normal, + "verbose" => DebugLogLevel.Verbose, + "off" => DebugLogLevel.Off, + _ => _debugLogLevel + }; + Trace.Info($"Debug log level set via attach args: {_debugLogLevel}"); + } + } + return CreateSuccessResponse(null); } @@ -588,6 +645,12 @@ namespace GitHub.Runner.Worker.Dap Trace.Info($"Evaluate: '{expression}' (context: {evalContext})"); + // Check for !debug command (works in any context) + if (expression.StartsWith("!debug", StringComparison.OrdinalIgnoreCase)) + { + return HandleDebugCommand(expression); + } + // Get the current execution context var executionContext = _currentStep?.ExecutionContext ?? _jobContext; if (executionContext == null) @@ -963,6 +1026,56 @@ namespace GitHub.Runner.Worker.Dap #endif } + /// + /// Handles the !debug REPL command for controlling debug logging. + /// + private Response HandleDebugCommand(string command) + { + var parts = command.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var arg = parts.Length > 1 ? parts[1].ToLower() : "status"; + + string result; + switch (arg) + { + case "on": + _debugLogLevel = DebugLogLevel.Normal; + result = "Debug logging enabled (level: normal)"; + Trace.Info("Debug logging enabled via REPL command"); + break; + case "off": + _debugLogLevel = DebugLogLevel.Off; + result = "Debug logging disabled"; + Trace.Info("Debug logging disabled via REPL command"); + break; + case "minimal": + _debugLogLevel = DebugLogLevel.Minimal; + result = "Debug logging set to minimal"; + Trace.Info("Debug log level set to minimal via REPL command"); + break; + case "normal": + _debugLogLevel = DebugLogLevel.Normal; + result = "Debug logging set to normal"; + Trace.Info("Debug log level set to normal via REPL command"); + break; + case "verbose": + _debugLogLevel = DebugLogLevel.Verbose; + result = "Debug logging set to verbose"; + Trace.Info("Debug log level set to verbose via REPL command"); + break; + case "status": + default: + result = $"Debug logging: {_debugLogLevel}\n" + + $"Commands: !debug [on|off|minimal|normal|verbose|status]"; + break; + } + + return CreateSuccessResponse(new EvaluateResponseBody + { + Result = result, + VariablesReference = 0 + }); + } + private Response HandleSetBreakpoints(Request request) { // Stub - breakpoints not implemented in demo @@ -1053,9 +1166,19 @@ namespace GitHub.Runner.Worker.Dap _currentStep = step; _jobContext = jobContext; + // Hook up StepsContext debug logging (do this once when we first get jobContext) + if (jobContext.Global.StepsContext.OnDebugLog == null) + { + jobContext.Global.StepsContext.OnDebugLog = (msg) => DebugLog(msg, DebugLogLevel.Verbose); + } + // Reset variable provider state for new step context _variableProvider.Reset(); + // Log step starting + DebugLog($"[Step] Starting: '{step.DisplayName}' (index={_pendingStepIndex})"); + DebugLog($"[Step] Checkpoints available: {_checkpoints.Count}"); + // Determine if we should pause bool shouldPause = isFirstStep || _pauseOnNextStep; @@ -1099,6 +1222,24 @@ namespace GitHub.Runner.Worker.Dap var result = step.ExecutionContext?.Result; Trace.Info($"Step completed: {step.DisplayName}, result: {result}"); + // Log step completion + DebugLog($"[Step] Completed: '{step.DisplayName}', result={result}"); + + // Log current steps context state for this step at Normal level + if (_debugLogLevel >= DebugLogLevel.Normal) + { + var stepsScope = step.ExecutionContext?.Global?.StepsContext?.GetScope(step.ExecutionContext.ScopeName); + if (stepsScope != null && !string.IsNullOrEmpty(step.ExecutionContext?.ContextName)) + { + if (stepsScope.TryGetValue(step.ExecutionContext.ContextName, out var stepData) && stepData is DictionaryContextData sd) + { + var outcome = sd.TryGetValue("outcome", out var o) && o is StringContextData os ? os.Value : "null"; + var conclusion = sd.TryGetValue("conclusion", out var c) && c is StringContextData cs ? cs.Value : "null"; + DebugLog($"[Step] Context state: outcome={outcome}, conclusion={conclusion}"); + } + } + } + // Add to completed steps list _completedSteps.Add(new CompletedStepInfo { @@ -1285,6 +1426,17 @@ namespace GitHub.Runner.Worker.Dap $"(env vars: {checkpoint.EnvironmentVariables.Count}, " + $"prepend paths: {checkpoint.PrependPath.Count})"); + // Debug logging for checkpoint creation + DebugLog($"[Checkpoint] Created [{_checkpoints.Count - 1}] for step '{_pendingStep.DisplayName}'"); + if (_debugLogLevel >= DebugLogLevel.Verbose) + { + DebugLog($"[Checkpoint] Snapshot contains {checkpoint.StepsSnapshot.Count} step(s)", DebugLogLevel.Verbose); + foreach (var entry in checkpoint.StepsSnapshot) + { + DebugLog($"[Checkpoint] {entry.Key}: outcome={entry.Value.Outcome}, conclusion={entry.Value.Conclusion}", DebugLogLevel.Verbose); + } + } + // Send notification to debugger _server?.SendEvent(new Event { @@ -1342,6 +1494,13 @@ namespace GitHub.Runner.Worker.Dap var checkpoint = _checkpoints[checkpointIndex]; Trace.Info($"Restoring checkpoint [{checkpointIndex}] for step '{checkpoint.StepDisplayName}'"); + // Debug logging for checkpoint restoration + DebugLog($"[Checkpoint] Restoring [{checkpointIndex}] for step '{checkpoint.StepDisplayName}'"); + if (_debugLogLevel >= DebugLogLevel.Verbose) + { + DebugLog($"[Checkpoint] Snapshot has {checkpoint.StepsSnapshot.Count} step(s)", DebugLogLevel.Verbose); + } + // Restore environment variables jobContext.Global.EnvironmentVariables.Clear(); foreach (var kvp in checkpoint.EnvironmentVariables) @@ -1360,9 +1519,8 @@ namespace GitHub.Runner.Worker.Dap jobContext.Result = checkpoint.JobResult; jobContext.JobContext.Status = checkpoint.JobStatus; - // Note: StepsContext restoration is complex and requires internal access - // For now, we just log this limitation - Trace.Info($"Steps context restoration: {checkpoint.StepsSnapshot.Count} steps in snapshot (partial restoration)"); + // Restore steps context - clear scope and restore from snapshot + RestoreStepsContext(jobContext.Global.StepsContext, checkpoint.StepsSnapshot, jobContext.ScopeName); // Clear checkpoints after this one (they're now invalid) if (checkpointIndex + 1 < _checkpoints.Count) @@ -1444,6 +1602,59 @@ namespace GitHub.Runner.Worker.Dap context.ExpressionValues["env"] = newEnvContext; } + private void RestoreStepsContext(StepsContext stepsContext, Dictionary snapshot, string scopeName) + { + // Normalize scope name (null -> empty string) + scopeName = scopeName ?? string.Empty; + + DebugLog($"[StepsContext] Restoring: clearing scope '{(string.IsNullOrEmpty(scopeName) ? "(root)" : scopeName)}', will restore {snapshot.Count} step(s)"); + + // Clear the entire scope - removes all step data that shouldn't exist yet in this timeline + stepsContext.ClearScope(scopeName); + + // Restore each step's state from the snapshot + foreach (var entry in snapshot) + { + // Key format is "{scopeName}/{stepName}" - e.g., "/thefoo" for root-level steps (empty scope) + var key = entry.Key; + var slashIndex = key.IndexOf('/'); + + if (slashIndex >= 0) + { + var snapshotScopeName = slashIndex > 0 ? key.Substring(0, slashIndex) : string.Empty; + var stepName = key.Substring(slashIndex + 1); + + // Only restore steps for the current scope + if (snapshotScopeName == scopeName) + { + var state = entry.Value; + + if (state.Outcome.HasValue) + { + stepsContext.SetOutcome(scopeName, stepName, state.Outcome.Value); + } + if (state.Conclusion.HasValue) + { + stepsContext.SetConclusion(scopeName, stepName, state.Conclusion.Value); + } + + // Restore outputs + if (state.Outputs != null) + { + foreach (var output in state.Outputs) + { + stepsContext.SetOutput(scopeName, stepName, output.Key, output.Value, out _); + } + } + + DebugLog($"[StepsContext] Restored: step='{stepName}', outcome={state.Outcome}, conclusion={state.Conclusion}", DebugLogLevel.Verbose); + } + } + } + + Trace.Info($"Steps context restored: cleared scope '{scopeName}' and restored {snapshot.Count} step(s) from snapshot"); + } + private Dictionary SnapshotStepsContext(StepsContext stepsContext, string scopeName) { var result = new Dictionary(); @@ -1507,6 +1718,31 @@ namespace GitHub.Runner.Worker.Dap #endregion + #region Debug Logging + + /// + /// Sends a debug log message to the DAP console if the current log level permits. + /// + /// The message to log (will be prefixed with [DEBUG]) + /// Minimum level required for this message to be logged + private void DebugLog(string message, DebugLogLevel minLevel = DebugLogLevel.Normal) + { + if (_debugLogLevel >= minLevel) + { + _server?.SendEvent(new Event + { + EventType = "output", + Body = new OutputEventBody + { + Category = "console", + Output = $"[DEBUG] {message}\n" + } + }); + } + } + + #endregion + #region Response Helpers private Response CreateSuccessResponse(object body) diff --git a/src/Runner.Worker/StepsContext.cs b/src/Runner.Worker/StepsContext.cs index 6f16956e5..c2e0e32ee 100644 --- a/src/Runner.Worker/StepsContext.cs +++ b/src/Runner.Worker/StepsContext.cs @@ -1,4 +1,4 @@ -using GitHub.DistributedTask.Pipelines.ContextData; +using GitHub.DistributedTask.Pipelines.ContextData; using GitHub.DistributedTask.WebApi; using GitHub.Runner.Common.Util; using System; @@ -19,12 +19,31 @@ namespace GitHub.Runner.Worker private static readonly Regex _propertyRegex = new("^[a-zA-Z_][a-zA-Z0-9_]*$", RegexOptions.Compiled); private readonly DictionaryContextData _contextData = new(); + /// + /// Optional callback for debug logging. When set, will be called with debug messages + /// for all StepsContext mutations. + /// + public Action OnDebugLog { get; set; } + + private void DebugLog(string message) + { + OnDebugLog?.Invoke(message); + } + + private static string TruncateValue(string value, int maxLength = 50) + { + if (string.IsNullOrEmpty(value)) return "(empty)"; + if (value.Length <= maxLength) return value; + return value.Substring(0, maxLength) + "..."; + } + /// /// Clears memory for a composite action's isolated "steps" context, after the action /// is finished executing. /// public void ClearScope(string scopeName) { + DebugLog($"[StepsContext] ClearScope: scope='{scopeName ?? "(root)"}'"); if (_contextData.TryGetValue(scopeName, out _)) { _contextData[scopeName] = new DictionaryContextData(); @@ -78,6 +97,7 @@ namespace GitHub.Runner.Worker { reference = $"steps['{stepName}']['outputs']['{outputName}']"; } + DebugLog($"[StepsContext] SetOutput: step='{stepName}', output='{outputName}', value='{TruncateValue(value)}'"); } public void SetConclusion( @@ -86,7 +106,9 @@ namespace GitHub.Runner.Worker ActionResult conclusion) { var step = GetStep(scopeName, stepName); - step["conclusion"] = new StringContextData(conclusion.ToString().ToLowerInvariant()); + var conclusionStr = conclusion.ToString().ToLowerInvariant(); + step["conclusion"] = new StringContextData(conclusionStr); + DebugLog($"[StepsContext] SetConclusion: step='{stepName}', conclusion={conclusionStr}"); } public void SetOutcome( @@ -95,7 +117,9 @@ namespace GitHub.Runner.Worker ActionResult outcome) { var step = GetStep(scopeName, stepName); - step["outcome"] = new StringContextData(outcome.ToString().ToLowerInvariant()); + var outcomeStr = outcome.ToString().ToLowerInvariant(); + step["outcome"] = new StringContextData(outcomeStr); + DebugLog($"[StepsContext] SetOutcome: step='{stepName}', outcome={outcomeStr}"); } private DictionaryContextData GetStep(string scopeName, string stepName)