phase 4 complete

This commit is contained in:
Francesco Renzi
2026-01-14 21:14:10 +00:00
committed by GitHub
parent a55696a429
commit 2e02381901
2 changed files with 459 additions and 35 deletions

View File

@@ -9,7 +9,7 @@
- [x] **Phase 1:** DAP Protocol Infrastructure (DapMessages.cs, DapServer.cs, basic DapDebugSession.cs) - [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) - [x] **Phase 2:** Debug Session Logic (DapVariableProvider.cs, variable inspection, step history tracking)
- [x] **Phase 3:** StepsRunner Integration (pause hooks before/after step execution) - [x] **Phase 3:** StepsRunner Integration (pause hooks before/after step execution)
- [ ] **Phase 4:** Expression Evaluation & Shell (REPL) - [x] **Phase 4:** Expression Evaluation & Shell (REPL)
- [ ] **Phase 5:** Startup Integration (JobRunner.cs modifications) - [ ] **Phase 5:** Startup Integration (JobRunner.cs modifications)
## Overview ## Overview
@@ -283,7 +283,7 @@ public async Task RunAsync(IExecutionContext jobContext)
Reuse existing `PipelineTemplateEvaluator`: Reuse existing `PipelineTemplateEvaluator`:
```csharp ```csharp
private string EvaluateExpression(string expression) private EvaluateResponseBody EvaluateExpression(string expression, IExecutionContext context)
{ {
// Strip ${{ }} wrapper if present // Strip ${{ }} wrapper if present
var expr = expression.Trim(); var expr = expression.Trim();
@@ -292,69 +292,112 @@ private string EvaluateExpression(string expression)
expr = expr.Substring(3, expr.Length - 5).Trim(); expr = expr.Substring(3, expr.Length - 5).Trim();
} }
var templateEvaluator = _currentStep.ExecutionContext.ToPipelineTemplateEvaluator(); var expressionToken = new BasicExpressionToken(fileId: null, line: null, column: null, expression: expr);
var token = new BasicExpressionToken(null, null, null, expr); var templateEvaluator = context.ToPipelineTemplateEvaluator();
var result = templateEvaluator.EvaluateStepDisplayName( var result = templateEvaluator.EvaluateStepDisplayName(
token, expressionToken,
_currentStep.ExecutionContext.ExpressionValues, context.ExpressionValues,
_currentStep.ExecutionContext.ExpressionFunctions context.ExpressionFunctions
); );
return result; // Mask secrets and determine type
result = HostContext.SecretMasker.MaskSecrets(result ?? "null");
return new EvaluateResponseBody
{
Result = result,
Type = DetermineResultType(result),
VariablesReference = 0
};
} }
``` ```
**Supported expression formats:**
- Plain expression: `github.ref`, `steps.build.outputs.result`
- Wrapped expression: `${{ github.event.pull_request.title }}`
#### 4.2 Shell Execution (REPL) #### 4.2 Shell Execution (REPL)
When `evaluate` is called with `context: "repl"`, spawn shell in step's environment: Shell execution is triggered when:
1. The evaluate request has `context: "repl"`, OR
2. The expression starts with `!` (e.g., `!ls -la`), OR
3. The expression starts with `$` followed by a shell command (e.g., `$env`)
**Usage examples in debug console:**
```
!ls -la # List files in workspace
!env | grep GITHUB # Show GitHub environment variables
!cat $GITHUB_EVENT_PATH # View the event payload
!echo ${{ github.ref }} # Mix shell and expression (evaluated first)
```
**Implementation:**
```csharp ```csharp
private async Task<EvaluateResponse> ExecuteShellCommand(string command) private async Task<EvaluateResponseBody> ExecuteShellCommandAsync(string command, IExecutionContext context)
{ {
var processInvoker = HostContext.CreateService<IProcessInvoker>(); var processInvoker = HostContext.CreateService<IProcessInvoker>();
var output = new StringBuilder(); var output = new StringBuilder();
processInvoker.OutputDataReceived += (_, line) => processInvoker.OutputDataReceived += (sender, args) =>
{ {
output.AppendLine(line); output.AppendLine(args.Data);
// Stream to client in real-time // Stream to client in real-time via DAP output event
_server.SendEvent(new OutputEvent _server?.SendEvent(new Event
{ {
category = "stdout", EventType = "output",
output = line + "\n" Body = new OutputEventBody { Category = "stdout", Output = args.Data + "\n" }
}); });
}; };
processInvoker.ErrorDataReceived += (_, line) => processInvoker.ErrorDataReceived += (sender, args) =>
{ {
_server.SendEvent(new OutputEvent _server?.SendEvent(new Event
{ {
category = "stderr", EventType = "output",
output = line + "\n" Body = new OutputEventBody { Category = "stderr", Output = args.Data + "\n" }
}); });
}; };
var env = BuildStepEnvironment(_currentStep); // Build environment from job context (includes GITHUB_*, env context, prepend path)
var workDir = _currentStep.ExecutionContext.GetGitHubContext("workspace"); var env = BuildShellEnvironment(context);
var workDir = GetWorkingDirectory(context); // Uses github.workspace
var (shell, shellArgs) = GetDefaultShell(); // Platform-specific detection
int exitCode = await processInvoker.ExecuteAsync( int exitCode = await processInvoker.ExecuteAsync(
workingDirectory: workDir, workingDirectory: workDir,
fileName: GetDefaultShell(), // /bin/bash on unix, pwsh/powershell on windows fileName: shell,
arguments: BuildShellArgs(command), arguments: string.Format(shellArgs, command),
environment: env, environment: env,
requireExitCodeZero: false, requireExitCodeZero: false,
cancellationToken: CancellationToken.None cancellationToken: CancellationToken.None
); );
return new EvaluateResponse return new EvaluateResponseBody
{ {
result = output.ToString(), Result = HostContext.SecretMasker.MaskSecrets(output.ToString()),
variablesReference = 0 Type = exitCode == 0 ? "string" : "error",
VariablesReference = 0
}; };
} }
``` ```
**Shell detection by platform:**
| Platform | Priority | Shell | Arguments |
|----------|----------|-------|-----------|
| Windows | 1 | `pwsh` | `-NoProfile -NonInteractive -Command "{0}"` |
| Windows | 2 | `powershell` | `-NoProfile -NonInteractive -Command "{0}"` |
| Windows | 3 | `cmd.exe` | `/C "{0}"` |
| Unix | 1 | `bash` | `-c "{0}"` |
| Unix | 2 | `sh` | `-c "{0}"` |
**Environment built for shell commands:**
- Current system environment variables
- GitHub Actions context variables (from `IEnvironmentContextData.GetRuntimeEnvironmentVariables()`)
- Prepend path from job context added to `PATH`
### Phase 5: Startup Integration ### Phase 5: Startup Integration
#### 5.1 Modify `JobRunner.cs` #### 5.1 Modify `JobRunner.cs`

View File

@@ -1,9 +1,18 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using GitHub.DistributedTask.Expressions2;
using GitHub.DistributedTask.ObjectTemplating.Tokens;
using GitHub.DistributedTask.Pipelines.ContextData;
using GitHub.DistributedTask.Pipelines.ObjectTemplating;
using GitHub.DistributedTask.WebApi; using GitHub.DistributedTask.WebApi;
using GitHub.Runner.Common; using GitHub.Runner.Common;
using GitHub.Runner.Common.Util;
using GitHub.Runner.Sdk;
using Newtonsoft.Json; using Newtonsoft.Json;
namespace GitHub.Runner.Worker.Dap namespace GitHub.Runner.Worker.Dap
@@ -484,23 +493,395 @@ namespace GitHub.Runner.Worker.Dap
return CreateSuccessResponse(null); return CreateSuccessResponse(null);
} }
private Task<Response> HandleEvaluateAsync(Request request) private async Task<Response> HandleEvaluateAsync(Request request)
{ {
var args = request.Arguments?.ToObject<EvaluateArguments>(); var args = request.Arguments?.ToObject<EvaluateArguments>();
var expression = args?.Expression ?? ""; var expression = args?.Expression ?? "";
var context = args?.Context ?? "hover"; var evalContext = args?.Context ?? "hover";
Trace.Info($"Evaluate: '{expression}' (context: {context})"); Trace.Info($"Evaluate: '{expression}' (context: {evalContext})");
// Stub implementation - Phase 4 will implement expression evaluation // Get the current execution context
var body = new EvaluateResponseBody var executionContext = _currentStep?.ExecutionContext ?? _jobContext;
if (executionContext == null)
{ {
Result = $"(evaluation of '{expression}' will be implemented in Phase 4)", return CreateErrorResponse("No execution context available for evaluation");
Type = "string", }
try
{
// Check if this is a REPL/shell command (context: "repl") or starts with shell prefix
if (evalContext == "repl" || expression.StartsWith("!") || expression.StartsWith("$"))
{
// Shell execution mode
var command = expression.TrimStart('!', '$').Trim();
if (string.IsNullOrEmpty(command))
{
return CreateSuccessResponse(new EvaluateResponseBody
{
Result = "(empty command)",
Type = "string",
VariablesReference = 0
});
}
var result = await ExecuteShellCommandAsync(command, executionContext);
return CreateSuccessResponse(result);
}
else
{
// Expression evaluation mode
var result = EvaluateExpression(expression, executionContext);
return CreateSuccessResponse(result);
}
}
catch (Exception ex)
{
Trace.Error($"Evaluation failed: {ex}");
return CreateErrorResponse($"Evaluation failed: {ex.Message}");
}
}
/// <summary>
/// Evaluates a GitHub Actions expression (e.g., "github.event.pull_request.title" or "${{ github.ref }}")
/// </summary>
private EvaluateResponseBody EvaluateExpression(string expression, IExecutionContext context)
{
// Strip ${{ }} wrapper if present
var expr = expression.Trim();
if (expr.StartsWith("${{") && expr.EndsWith("}}"))
{
expr = expr.Substring(3, expr.Length - 5).Trim();
}
Trace.Info($"Evaluating expression: {expr}");
// Create an expression token from the string
var expressionToken = new BasicExpressionToken(fileId: null, line: null, column: null, expression: expr);
// Get the template evaluator
var templateEvaluator = context.ToPipelineTemplateEvaluator();
// Evaluate using the display name evaluator which can handle arbitrary expressions
string result;
try
{
result = templateEvaluator.EvaluateStepDisplayName(
expressionToken,
context.ExpressionValues,
context.ExpressionFunctions);
}
catch (Exception ex)
{
// If the template evaluator fails, try direct expression evaluation
Trace.Info($"Template evaluation failed, trying direct: {ex.Message}");
result = EvaluateDirectExpression(expr, context);
}
// Mask secrets in the result
result = HostContext.SecretMasker.MaskSecrets(result ?? "null");
// Determine the type based on the result
var type = DetermineResultType(result);
return new EvaluateResponseBody
{
Result = result,
Type = type,
VariablesReference = 0 VariablesReference = 0
}; };
}
return Task.FromResult(CreateSuccessResponse(body)); /// <summary>
/// Directly evaluates an expression by looking up values in the context data.
/// Used as a fallback when the template evaluator doesn't work.
/// </summary>
private string EvaluateDirectExpression(string expression, IExecutionContext context)
{
// Try to look up the value directly in expression values
var parts = expression.Split('.');
if (parts.Length == 0 || !context.ExpressionValues.TryGetValue(parts[0], out var value))
{
return $"(unknown: {expression})";
}
// Navigate through nested properties
object current = value;
for (int i = 1; i < parts.Length && current != null; i++)
{
current = GetNestedValue(current, parts[i]);
}
if (current == null)
{
return "null";
}
// Convert to string representation
if (current is PipelineContextData pcd)
{
return pcd.ToJToken()?.ToString() ?? "null";
}
return current.ToString();
}
/// <summary>
/// Gets a nested value from a context data object.
/// </summary>
private object GetNestedValue(object data, string key)
{
switch (data)
{
case DictionaryContextData dict:
return dict.TryGetValue(key, out var dictValue) ? dictValue : null;
case CaseSensitiveDictionaryContextData csDict:
return csDict.TryGetValue(key, out var csDictValue) ? csDictValue : null;
case ArrayContextData array when int.TryParse(key.Trim('[', ']'), out var index):
return index >= 0 && index < array.Count ? array[index] : null;
default:
return null;
}
}
/// <summary>
/// Determines the type string for a result value.
/// </summary>
private string DetermineResultType(string value)
{
if (value == null || value == "null")
return "null";
if (value == "true" || value == "false")
return "boolean";
if (double.TryParse(value, out _))
return "number";
if (value.StartsWith("{") || value.StartsWith("["))
return "object";
return "string";
}
/// <summary>
/// Executes a shell command in the job's environment and streams output to the debugger.
/// </summary>
private async Task<EvaluateResponseBody> ExecuteShellCommandAsync(string command, IExecutionContext context)
{
Trace.Info($"Executing shell command: {command}");
var output = new StringBuilder();
var errorOutput = new StringBuilder();
var processInvoker = HostContext.CreateService<IProcessInvoker>();
processInvoker.OutputDataReceived += (sender, args) =>
{
if (!string.IsNullOrEmpty(args.Data))
{
output.AppendLine(args.Data);
// Stream output to the debugger in real-time
_server?.SendEvent(new Event
{
EventType = "output",
Body = new OutputEventBody
{
Category = "stdout",
Output = args.Data + "\n"
}
});
}
};
processInvoker.ErrorDataReceived += (sender, args) =>
{
if (!string.IsNullOrEmpty(args.Data))
{
errorOutput.AppendLine(args.Data);
// Stream error output to the debugger
_server?.SendEvent(new Event
{
EventType = "output",
Body = new OutputEventBody
{
Category = "stderr",
Output = args.Data + "\n"
}
});
}
};
// Build the environment for the shell command
var env = BuildShellEnvironment(context);
// Get the working directory
var workingDirectory = GetWorkingDirectory(context);
// Get the default shell and arguments
var (shell, shellArgs) = GetDefaultShell();
Trace.Info($"Shell: {shell}, WorkDir: {workingDirectory}");
int exitCode;
try
{
exitCode = await processInvoker.ExecuteAsync(
workingDirectory: workingDirectory,
fileName: shell,
arguments: string.Format(shellArgs, command),
environment: env,
requireExitCodeZero: false,
cancellationToken: CancellationToken.None);
}
catch (Exception ex)
{
Trace.Error($"Shell execution failed: {ex}");
return new EvaluateResponseBody
{
Result = $"Error: {ex.Message}",
Type = "error",
VariablesReference = 0
};
}
// Combine stdout and stderr
var result = output.ToString();
if (errorOutput.Length > 0)
{
if (result.Length > 0)
{
result += "\n--- stderr ---\n";
}
result += errorOutput.ToString();
}
// Add exit code if non-zero
if (exitCode != 0)
{
result += $"\n(exit code: {exitCode})";
}
// Mask secrets in the output
result = HostContext.SecretMasker.MaskSecrets(result);
return new EvaluateResponseBody
{
Result = result.TrimEnd('\r', '\n'),
Type = exitCode == 0 ? "string" : "error",
VariablesReference = 0
};
}
/// <summary>
/// Builds the environment dictionary for shell command execution.
/// </summary>
private IDictionary<string, string> BuildShellEnvironment(IExecutionContext context)
{
var env = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
// Copy current environment
foreach (var entry in System.Environment.GetEnvironmentVariables())
{
if (entry is System.Collections.DictionaryEntry de)
{
env[de.Key.ToString()] = de.Value?.ToString() ?? "";
}
}
// Add context data as environment variables
foreach (var contextEntry in context.ExpressionValues)
{
if (contextEntry.Value is IEnvironmentContextData runtimeContext)
{
foreach (var envVar in runtimeContext.GetRuntimeEnvironmentVariables())
{
env[envVar.Key] = envVar.Value;
}
}
}
// Add prepend path if available
if (context.Global.PrependPath.Count > 0)
{
var prependPath = string.Join(Path.PathSeparator.ToString(), context.Global.PrependPath.Reverse<string>());
if (env.TryGetValue("PATH", out var existingPath))
{
env["PATH"] = $"{prependPath}{Path.PathSeparator}{existingPath}";
}
else
{
env["PATH"] = prependPath;
}
}
return env;
}
/// <summary>
/// Gets the working directory for shell command execution.
/// </summary>
private string GetWorkingDirectory(IExecutionContext context)
{
// Try to get workspace from github context
var githubContext = context.ExpressionValues["github"] as GitHubContext;
if (githubContext != null)
{
var workspace = githubContext["workspace"] as StringContextData;
if (workspace != null && Directory.Exists(workspace.Value))
{
return workspace.Value;
}
}
// Fallback to runner work directory
var workDir = HostContext.GetDirectory(WellKnownDirectory.Work);
if (Directory.Exists(workDir))
{
return workDir;
}
// Final fallback to current directory
return System.Environment.CurrentDirectory;
}
/// <summary>
/// Gets the default shell command and argument format for the current platform.
/// </summary>
private (string shell, string args) GetDefaultShell()
{
#if OS_WINDOWS
// Try pwsh first, then fall back to powershell
var pwshPath = WhichUtil.Which("pwsh", false, Trace, null);
if (!string.IsNullOrEmpty(pwshPath))
{
return (pwshPath, "-NoProfile -NonInteractive -Command \"{0}\"");
}
var psPath = WhichUtil.Which("powershell", false, Trace, null);
if (!string.IsNullOrEmpty(psPath))
{
return (psPath, "-NoProfile -NonInteractive -Command \"{0}\"");
}
// Fallback to cmd
return ("cmd.exe", "/C \"{0}\"");
#else
// Try bash first, then sh
var bashPath = WhichUtil.Which("bash", false, Trace, null);
if (!string.IsNullOrEmpty(bashPath))
{
return (bashPath, "-c \"{0}\"");
}
var shPath = WhichUtil.Which("sh", false, Trace, null);
if (!string.IsNullOrEmpty(shPath))
{
return (shPath, "-c \"{0}\"");
}
// Fallback
return ("/bin/sh", "-c \"{0}\"");
#endif
} }
private Response HandleSetBreakpoints(Request request) private Response HandleSetBreakpoints(Request request)