mirror of
https://github.com/actions/runner.git
synced 2026-01-16 08:42:55 +08:00
Compare commits
15 Commits
main
...
rentziass/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b652350bda | ||
|
|
2525a1f9a3 | ||
|
|
ff85ab7fe0 | ||
|
|
2800573f56 | ||
|
|
f1a0d1a9f8 | ||
|
|
15b7034088 | ||
|
|
bbe97ff1c8 | ||
|
|
7a36a68b15 | ||
|
|
f45c5d0785 | ||
|
|
7e4f99337f | ||
|
|
186656e153 | ||
|
|
2e02381901 | ||
|
|
a55696a429 | ||
|
|
379ac038b2 | ||
|
|
14e8e1f667 |
1176
.opencode/plans/dap-browser-extension.md
Normal file
1176
.opencode/plans/dap-browser-extension.md
Normal file
File diff suppressed because it is too large
Load Diff
346
.opencode/plans/dap-cancellation-support.md
Normal file
346
.opencode/plans/dap-cancellation-support.md
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
# DAP Cancellation Support
|
||||||
|
|
||||||
|
**Status:** Implemented
|
||||||
|
**Author:** OpenCode
|
||||||
|
**Date:** January 2026
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
When a cancellation signal for the current job comes in from the server, the DAP debugging session doesn't properly respond. If the runner is paused at a breakpoint waiting for debugger commands (or if a debugger never connects), the job gets stuck forever and requires manually deleting the runner.
|
||||||
|
|
||||||
|
### Root Cause
|
||||||
|
|
||||||
|
The `DapDebugSession.WaitForCommandAsync()` method uses a `TaskCompletionSource` that only completes when a DAP command arrives from the debugger. There's no mechanism to interrupt this wait when the job is cancelled externally.
|
||||||
|
|
||||||
|
Additionally, REPL shell commands use `CancellationToken.None`, so they also ignore job cancellation.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
Add proper cancellation token support throughout the DAP debugging flow:
|
||||||
|
|
||||||
|
1. Pass the job cancellation token to `OnStepStartingAsync` and `WaitForCommandAsync`
|
||||||
|
2. Register cancellation callbacks to release blocking waits
|
||||||
|
3. Add a `CancelSession()` method for external cancellation
|
||||||
|
4. Send DAP `terminated` and `exited` events to notify the debugger before cancelling
|
||||||
|
5. Use the cancellation token for REPL shell command execution
|
||||||
|
|
||||||
|
## Progress Checklist
|
||||||
|
|
||||||
|
- [x] **Phase 1:** Update IDapDebugSession interface
|
||||||
|
- [x] **Phase 2:** Update DapDebugSession implementation
|
||||||
|
- [x] **Phase 3:** Update StepsRunner to pass cancellation token
|
||||||
|
- [x] **Phase 4:** Update JobRunner to register cancellation handler
|
||||||
|
- [ ] **Phase 5:** Testing
|
||||||
|
|
||||||
|
## Files to Modify
|
||||||
|
|
||||||
|
| File | Changes |
|
||||||
|
|------|---------|
|
||||||
|
| `src/Runner.Worker/Dap/DapDebugSession.cs` | Add cancellation support to `OnStepStartingAsync`, `WaitForCommandAsync`, `ExecuteShellCommandAsync`, add `CancelSession` method |
|
||||||
|
| `src/Runner.Worker/StepsRunner.cs` | Pass `jobContext.CancellationToken` to `OnStepStartingAsync` |
|
||||||
|
| `src/Runner.Worker/JobRunner.cs` | Register cancellation callback to call `CancelSession` on the debug session |
|
||||||
|
|
||||||
|
## Detailed Implementation
|
||||||
|
|
||||||
|
### Phase 1: Update IDapDebugSession Interface
|
||||||
|
|
||||||
|
**File:** `src/Runner.Worker/Dap/DapDebugSession.cs` (lines ~144-242)
|
||||||
|
|
||||||
|
Add new method to interface:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
/// <summary>
|
||||||
|
/// Cancels the debug session externally (e.g., job cancellation).
|
||||||
|
/// Sends terminated event to debugger and releases any blocking waits.
|
||||||
|
/// </summary>
|
||||||
|
void CancelSession();
|
||||||
|
```
|
||||||
|
|
||||||
|
Update existing method signature:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Change from:
|
||||||
|
Task OnStepStartingAsync(IStep step, IExecutionContext jobContext, bool isFirstStep);
|
||||||
|
|
||||||
|
// Change to:
|
||||||
|
Task OnStepStartingAsync(IStep step, IExecutionContext jobContext, bool isFirstStep, CancellationToken cancellationToken);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 2: Update DapDebugSession Implementation
|
||||||
|
|
||||||
|
#### 2.1 Add cancellation token field
|
||||||
|
|
||||||
|
**Location:** Around line 260-300 (field declarations section)
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Add field to store the job cancellation token for use by REPL commands
|
||||||
|
private CancellationToken _jobCancellationToken;
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2.2 Update OnStepStartingAsync
|
||||||
|
|
||||||
|
**Location:** Line 1159
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public async Task OnStepStartingAsync(IStep step, IExecutionContext jobContext, bool isFirstStep, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!IsActive)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_currentStep = step;
|
||||||
|
_jobContext = jobContext;
|
||||||
|
_jobCancellationToken = cancellationToken; // Store for REPL commands
|
||||||
|
|
||||||
|
// ... rest of existing implementation ...
|
||||||
|
|
||||||
|
// Update the WaitForCommandAsync call at line 1212:
|
||||||
|
await WaitForCommandAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2.3 Update WaitForCommandAsync
|
||||||
|
|
||||||
|
**Location:** Line 1288
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private async Task WaitForCommandAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
lock (_stateLock)
|
||||||
|
{
|
||||||
|
_state = DapSessionState.Paused;
|
||||||
|
_commandTcs = new TaskCompletionSource<DapCommand>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
}
|
||||||
|
|
||||||
|
Trace.Info("Waiting for debugger command...");
|
||||||
|
|
||||||
|
// Register cancellation to release the wait
|
||||||
|
using (cancellationToken.Register(() =>
|
||||||
|
{
|
||||||
|
Trace.Info("Job cancellation detected, releasing debugger wait");
|
||||||
|
_commandTcs?.TrySetResult(DapCommand.Disconnect);
|
||||||
|
}))
|
||||||
|
{
|
||||||
|
var command = await _commandTcs.Task;
|
||||||
|
|
||||||
|
Trace.Info($"Received command: {command}");
|
||||||
|
|
||||||
|
lock (_stateLock)
|
||||||
|
{
|
||||||
|
if (_state == DapSessionState.Paused)
|
||||||
|
{
|
||||||
|
_state = DapSessionState.Running;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send continued event (only for normal commands, not cancellation)
|
||||||
|
if (!cancellationToken.IsCancellationRequested &&
|
||||||
|
(command == DapCommand.Continue || command == DapCommand.Next))
|
||||||
|
{
|
||||||
|
_server?.SendEvent(new Event
|
||||||
|
{
|
||||||
|
EventType = "continued",
|
||||||
|
Body = new ContinuedEventBody
|
||||||
|
{
|
||||||
|
ThreadId = JobThreadId,
|
||||||
|
AllThreadsContinued = true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2.4 Add CancelSession method
|
||||||
|
|
||||||
|
**Location:** After `OnJobCompleted()` method, around line 1286
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
/// <summary>
|
||||||
|
/// Cancels the debug session externally (e.g., job cancellation).
|
||||||
|
/// Sends terminated/exited events to debugger and releases any blocking waits.
|
||||||
|
/// </summary>
|
||||||
|
public void CancelSession()
|
||||||
|
{
|
||||||
|
Trace.Info("CancelSession called - terminating debug session");
|
||||||
|
|
||||||
|
lock (_stateLock)
|
||||||
|
{
|
||||||
|
if (_state == DapSessionState.Terminated)
|
||||||
|
{
|
||||||
|
Trace.Info("Session already terminated, ignoring CancelSession");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_state = DapSessionState.Terminated;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send terminated event to debugger so it updates its UI
|
||||||
|
_server?.SendEvent(new Event
|
||||||
|
{
|
||||||
|
EventType = "terminated",
|
||||||
|
Body = new TerminatedEventBody()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send exited event with cancellation exit code (130 = SIGINT convention)
|
||||||
|
_server?.SendEvent(new Event
|
||||||
|
{
|
||||||
|
EventType = "exited",
|
||||||
|
Body = new ExitedEventBody { ExitCode = 130 }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Release any pending command waits
|
||||||
|
_commandTcs?.TrySetResult(DapCommand.Disconnect);
|
||||||
|
|
||||||
|
Trace.Info("Debug session cancelled");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2.5 Update ExecuteShellCommandAsync
|
||||||
|
|
||||||
|
**Location:** Line 889-895
|
||||||
|
|
||||||
|
Change the `ExecuteAsync` call to use the stored cancellation token:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
int exitCode;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
exitCode = await processInvoker.ExecuteAsync(
|
||||||
|
workingDirectory: workingDirectory,
|
||||||
|
fileName: shell,
|
||||||
|
arguments: string.Format(shellArgs, command),
|
||||||
|
environment: env,
|
||||||
|
requireExitCodeZero: false,
|
||||||
|
cancellationToken: _jobCancellationToken); // Changed from CancellationToken.None
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
Trace.Info("Shell command cancelled due to job cancellation");
|
||||||
|
return new EvaluateResponseBody
|
||||||
|
{
|
||||||
|
Result = "(cancelled)",
|
||||||
|
Type = "error",
|
||||||
|
VariablesReference = 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Trace.Error($"Shell execution failed: {ex}");
|
||||||
|
return new EvaluateResponseBody
|
||||||
|
{
|
||||||
|
Result = $"Error: {ex.Message}",
|
||||||
|
Type = "error",
|
||||||
|
VariablesReference = 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 3: Update StepsRunner
|
||||||
|
|
||||||
|
**File:** `src/Runner.Worker/StepsRunner.cs`
|
||||||
|
**Location:** Line 204
|
||||||
|
|
||||||
|
Change:
|
||||||
|
```csharp
|
||||||
|
await debugSession.OnStepStartingAsync(step, jobContext, isFirstStep);
|
||||||
|
```
|
||||||
|
|
||||||
|
To:
|
||||||
|
```csharp
|
||||||
|
await debugSession.OnStepStartingAsync(step, jobContext, isFirstStep, jobContext.CancellationToken);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 4: Update JobRunner
|
||||||
|
|
||||||
|
**File:** `src/Runner.Worker/JobRunner.cs`
|
||||||
|
|
||||||
|
#### 4.1 Add cancellation registration
|
||||||
|
|
||||||
|
**Location:** After line 191 (after "Debugger connected" output), inside the debug mode block:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Register cancellation handler to properly terminate DAP session on job cancellation
|
||||||
|
CancellationTokenRegistration? dapCancellationRegistration = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
dapCancellationRegistration = jobRequestCancellationToken.Register(() =>
|
||||||
|
{
|
||||||
|
Trace.Info("Job cancelled - terminating DAP session");
|
||||||
|
debugSession.CancelSession();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Trace.Warning($"Failed to register DAP cancellation handler: {ex.Message}");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: The `dapCancellationRegistration` variable should be declared at a higher scope (around line 116 with other declarations) so it can be disposed in the finally block.
|
||||||
|
|
||||||
|
#### 4.2 Dispose the registration
|
||||||
|
|
||||||
|
**Location:** In the finally block (after line 316, alongside dapServer cleanup):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Dispose DAP cancellation registration
|
||||||
|
dapCancellationRegistration?.Dispose();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Behavior Summary
|
||||||
|
|
||||||
|
| Scenario | Before | After |
|
||||||
|
|----------|--------|-------|
|
||||||
|
| Paused at breakpoint, job cancelled | **Stuck forever** | DAP terminated event sent, wait released, job cancels normally |
|
||||||
|
| REPL command running, job cancelled | Command runs forever | Command cancelled, job cancels normally |
|
||||||
|
| Waiting for debugger connection, job cancelled | Already handled | No change (already works) |
|
||||||
|
| Debugger disconnects voluntarily | Works | No change |
|
||||||
|
| Normal step execution, job cancelled | Works | No change (existing cancellation logic handles this) |
|
||||||
|
|
||||||
|
## Exit Code Semantics
|
||||||
|
|
||||||
|
The `exited` event uses these exit codes:
|
||||||
|
- `0` = job succeeded
|
||||||
|
- `1` = job failed
|
||||||
|
- `130` = job cancelled (standard Unix convention for SIGINT/Ctrl+C)
|
||||||
|
|
||||||
|
## Testing Scenarios
|
||||||
|
|
||||||
|
1. **Basic cancellation while paused:**
|
||||||
|
- Start a debug job, let it pause at first step
|
||||||
|
- Cancel the job from GitHub UI
|
||||||
|
- Verify: DAP client receives terminated event, runner exits cleanly
|
||||||
|
|
||||||
|
2. **Cancellation during REPL command:**
|
||||||
|
- Pause at a step, run `!sleep 60` in REPL
|
||||||
|
- Cancel the job from GitHub UI
|
||||||
|
- Verify: Sleep command terminates, DAP client receives terminated event, runner exits cleanly
|
||||||
|
|
||||||
|
3. **Cancellation before debugger connects:**
|
||||||
|
- Start a debug job (it waits for connection)
|
||||||
|
- Cancel the job before connecting a debugger
|
||||||
|
- Verify: Runner exits cleanly (this already works, just verify no regression)
|
||||||
|
|
||||||
|
4. **Normal operation (no cancellation):**
|
||||||
|
- Run through a debug session normally with step/continue
|
||||||
|
- Verify: No change in behavior
|
||||||
|
|
||||||
|
5. **Debugger disconnect:**
|
||||||
|
- Connect debugger, then disconnect it manually
|
||||||
|
- Verify: Job continues to completion (existing behavior preserved)
|
||||||
|
|
||||||
|
## Estimated Effort
|
||||||
|
|
||||||
|
| Phase | Effort |
|
||||||
|
|-------|--------|
|
||||||
|
| Phase 1: Interface update | 15 min |
|
||||||
|
| Phase 2: DapDebugSession implementation | 45 min |
|
||||||
|
| Phase 3: StepsRunner update | 5 min |
|
||||||
|
| Phase 4: JobRunner update | 15 min |
|
||||||
|
| Phase 5: Testing | 30 min |
|
||||||
|
| **Total** | **~2 hours** |
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- DAP Specification: https://microsoft.github.io/debug-adapter-protocol/specification
|
||||||
|
- Related plan: `dap-debugging.md` (original DAP implementation)
|
||||||
511
.opencode/plans/dap-debug-logging.md
Normal file
511
.opencode/plans/dap-debug-logging.md
Normal file
@@ -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();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional callback for debug logging. When set, will be called with debug messages
|
||||||
|
/// for all StepsContext mutations.
|
||||||
|
/// </summary>
|
||||||
|
public Action<string> 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<string, StepStateSnapshot> 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
|
||||||
299
.opencode/plans/dap-debugging-fixes.md
Normal file
299
.opencode/plans/dap-debugging-fixes.md
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
# DAP Debugging - Bug Fixes and Enhancements
|
||||||
|
|
||||||
|
**Status:** Planned
|
||||||
|
**Date:** January 2026
|
||||||
|
**Related:** [dap-debugging.md](./dap-debugging.md)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This document tracks bug fixes and enhancements for the DAP debugging implementation after the initial phases were completed.
|
||||||
|
|
||||||
|
## Issues
|
||||||
|
|
||||||
|
### Bug 1: Double Output in REPL Shell Commands
|
||||||
|
|
||||||
|
**Symptom:** Running commands in the REPL shell produces double output - the first one unmasked, the second one with secrets masked.
|
||||||
|
|
||||||
|
**Root Cause:** In `DapDebugSession.ExecuteShellCommandAsync()` (lines 670-773), output is sent to the debugger twice:
|
||||||
|
|
||||||
|
1. **Real-time streaming (unmasked):** Lines 678-712 stream output via DAP `output` events as data arrives from the process - but this output is NOT masked
|
||||||
|
2. **Final result (masked):** Lines 765-769 return the combined output as `EvaluateResponseBody.Result` with secrets masked
|
||||||
|
|
||||||
|
The DAP client displays both the streamed events AND the evaluate response result, causing duplication.
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
1. Mask secrets in the real-time streaming output (add `HostContext.SecretMasker.MaskSecrets()` to lines ~690 and ~708)
|
||||||
|
2. Change the final `Result` to only show exit code summary instead of full output
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Bug 2: Expressions Interpreted as Shell Commands
|
||||||
|
|
||||||
|
**Symptom:** Evaluating expressions like `${{github.event_name}} == 'push'` in the Watch/Expressions pane results in them being executed as shell commands instead of being evaluated as GitHub Actions expressions.
|
||||||
|
|
||||||
|
**Root Cause:** In `DapDebugSession.HandleEvaluateAsync()` (line 514), the condition to detect shell commands is too broad:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
if (evalContext == "repl" || expression.StartsWith("!") || expression.StartsWith("$"))
|
||||||
|
```
|
||||||
|
|
||||||
|
Since `${{github.event_name}}` starts with `$`, it gets routed to shell execution instead of expression evaluation.
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
1. Check for `${{` prefix first - these are always GitHub Actions expressions
|
||||||
|
2. Remove the `expression.StartsWith("$")` condition entirely (ambiguous and unnecessary since REPL context handles shell commands)
|
||||||
|
3. Keep `expression.StartsWith("!")` for explicit shell override in non-REPL contexts
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Enhancement: Expression Interpolation in REPL Commands
|
||||||
|
|
||||||
|
**Request:** When running REPL commands like `echo ${{github.event_name}}`, the `${{ }}` expressions should be expanded before shell execution, similar to how `run:` steps work.
|
||||||
|
|
||||||
|
**Approach:** Add a helper method that uses the existing `PipelineTemplateEvaluator` infrastructure to expand expressions in the command string before passing it to the shell.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
### File: `src/Runner.Worker/Dap/DapDebugSession.cs`
|
||||||
|
|
||||||
|
#### Change 1: Mask Real-Time Streaming Output
|
||||||
|
|
||||||
|
**Location:** Lines ~678-712 (OutputDataReceived and ErrorDataReceived handlers)
|
||||||
|
|
||||||
|
**Before:**
|
||||||
|
```csharp
|
||||||
|
processInvoker.OutputDataReceived += (sender, args) =>
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(args.Data))
|
||||||
|
{
|
||||||
|
output.AppendLine(args.Data);
|
||||||
|
_server?.SendEvent(new Event
|
||||||
|
{
|
||||||
|
EventType = "output",
|
||||||
|
Body = new OutputEventBody
|
||||||
|
{
|
||||||
|
Category = "stdout",
|
||||||
|
Output = args.Data + "\n" // NOT MASKED
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**After:**
|
||||||
|
```csharp
|
||||||
|
processInvoker.OutputDataReceived += (sender, args) =>
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(args.Data))
|
||||||
|
{
|
||||||
|
output.AppendLine(args.Data);
|
||||||
|
var maskedData = HostContext.SecretMasker.MaskSecrets(args.Data);
|
||||||
|
_server?.SendEvent(new Event
|
||||||
|
{
|
||||||
|
EventType = "output",
|
||||||
|
Body = new OutputEventBody
|
||||||
|
{
|
||||||
|
Category = "stdout",
|
||||||
|
Output = maskedData + "\n"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Apply the same change to `ErrorDataReceived` handler (~lines 696-712).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Change 2: Return Only Exit Code in Result
|
||||||
|
|
||||||
|
**Location:** Lines ~767-772 (return statement in ExecuteShellCommandAsync)
|
||||||
|
|
||||||
|
**Before:**
|
||||||
|
```csharp
|
||||||
|
return new EvaluateResponseBody
|
||||||
|
{
|
||||||
|
Result = result.TrimEnd('\r', '\n'),
|
||||||
|
Type = exitCode == 0 ? "string" : "error",
|
||||||
|
VariablesReference = 0
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**After:**
|
||||||
|
```csharp
|
||||||
|
return new EvaluateResponseBody
|
||||||
|
{
|
||||||
|
Result = $"(exit code: {exitCode})",
|
||||||
|
Type = exitCode == 0 ? "string" : "error",
|
||||||
|
VariablesReference = 0
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Also remove the result combination logic (lines ~747-762) since we no longer need to build the full result string for the response.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Change 3: Fix Expression vs Shell Routing
|
||||||
|
|
||||||
|
**Location:** Lines ~511-536 (HandleEvaluateAsync method)
|
||||||
|
|
||||||
|
**Before:**
|
||||||
|
```csharp
|
||||||
|
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();
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Expression evaluation mode
|
||||||
|
var result = EvaluateExpression(expression, executionContext);
|
||||||
|
return CreateSuccessResponse(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**After:**
|
||||||
|
```csharp
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// GitHub Actions expressions start with "${{" - always evaluate as expressions
|
||||||
|
if (expression.StartsWith("${{"))
|
||||||
|
{
|
||||||
|
var result = EvaluateExpression(expression, executionContext);
|
||||||
|
return CreateSuccessResponse(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is a REPL/shell command:
|
||||||
|
// - context is "repl" (from Debug Console pane)
|
||||||
|
// - expression starts with "!" (explicit shell prefix for Watch pane)
|
||||||
|
if (evalContext == "repl" || 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 (Watch pane, hover, etc.)
|
||||||
|
var result = EvaluateExpression(expression, executionContext);
|
||||||
|
return CreateSuccessResponse(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Change 4: Add Expression Expansion Helper Method
|
||||||
|
|
||||||
|
**Location:** Add new method before `ExecuteShellCommandAsync` (~line 667)
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
/// <summary>
|
||||||
|
/// Expands ${{ }} expressions within a command string.
|
||||||
|
/// For example: "echo ${{github.event_name}}" -> "echo push"
|
||||||
|
/// </summary>
|
||||||
|
private string ExpandExpressionsInCommand(string command, IExecutionContext context)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(command) || !command.Contains("${{"))
|
||||||
|
{
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Create a StringToken with the command
|
||||||
|
var token = new StringToken(null, null, null, command);
|
||||||
|
|
||||||
|
// Use the template evaluator to expand expressions
|
||||||
|
var templateEvaluator = context.ToPipelineTemplateEvaluator();
|
||||||
|
var result = templateEvaluator.EvaluateStepDisplayName(
|
||||||
|
token,
|
||||||
|
context.ExpressionValues,
|
||||||
|
context.ExpressionFunctions);
|
||||||
|
|
||||||
|
// Mask secrets in the expanded command
|
||||||
|
result = HostContext.SecretMasker.MaskSecrets(result ?? command);
|
||||||
|
|
||||||
|
Trace.Info($"Expanded command: {result}");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Trace.Info($"Expression expansion failed, using original command: {ex.Message}");
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Required import:** Add `using GitHub.DistributedTask.ObjectTemplating.Tokens;` at the top of the file if not already present.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Change 5: Use Expression Expansion in Shell Execution
|
||||||
|
|
||||||
|
**Location:** Beginning of `ExecuteShellCommandAsync` method (~line 670)
|
||||||
|
|
||||||
|
**Before:**
|
||||||
|
```csharp
|
||||||
|
private async Task<EvaluateResponseBody> ExecuteShellCommandAsync(string command, IExecutionContext context)
|
||||||
|
{
|
||||||
|
Trace.Info($"Executing shell command: {command}");
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**After:**
|
||||||
|
```csharp
|
||||||
|
private async Task<EvaluateResponseBody> ExecuteShellCommandAsync(string command, IExecutionContext context)
|
||||||
|
{
|
||||||
|
// Expand ${{ }} expressions in the command first
|
||||||
|
command = ExpandExpressionsInCommand(command, context);
|
||||||
|
|
||||||
|
Trace.Info($"Executing shell command: {command}");
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DAP Context Reference
|
||||||
|
|
||||||
|
For future reference, these are the DAP evaluate context values:
|
||||||
|
|
||||||
|
| DAP Context | Source UI | Behavior |
|
||||||
|
|-------------|-----------|----------|
|
||||||
|
| `"repl"` | Debug Console / REPL pane | Shell execution (with expression expansion) |
|
||||||
|
| `"watch"` | Watch / Expressions pane | Expression evaluation |
|
||||||
|
| `"hover"` | Editor hover (default) | Expression evaluation |
|
||||||
|
| `"variables"` | Variables pane | Expression evaluation |
|
||||||
|
| `"clipboard"` | Copy to clipboard | Expression evaluation |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
- [ ] REPL command output is masked and appears only once
|
||||||
|
- [ ] REPL command shows exit code in result field
|
||||||
|
- [ ] Expression `${{github.event_name}}` evaluates correctly in Watch pane
|
||||||
|
- [ ] Expression `${{github.event_name}} == 'push'` evaluates correctly
|
||||||
|
- [ ] REPL command `echo ${{github.event_name}}` expands and executes correctly
|
||||||
|
- [ ] REPL command `!ls -la` from Watch pane works (explicit shell prefix)
|
||||||
|
- [ ] Secrets are masked in all outputs (streaming and expanded commands)
|
||||||
536
.opencode/plans/dap-debugging.md
Normal file
536
.opencode/plans/dap-debugging.md
Normal file
@@ -0,0 +1,536 @@
|
|||||||
|
# DAP-Based Debugging for GitHub Actions Runner
|
||||||
|
|
||||||
|
**Status:** Draft
|
||||||
|
**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)
|
||||||
|
- [x] **Phase 3:** StepsRunner Integration (pause hooks before/after step execution)
|
||||||
|
- [x] **Phase 4:** Expression Evaluation & Shell (REPL)
|
||||||
|
- [x] **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.).
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- **Primary:** Create a working demo to demonstrate the feasibility of DAP-based workflow debugging
|
||||||
|
- **Non-goal:** Production-ready, polished implementation (this is proof-of-concept)
|
||||||
|
|
||||||
|
## User Experience
|
||||||
|
|
||||||
|
1. User re-runs a failed job with "Enable debug logging" checked in GitHub UI
|
||||||
|
2. Runner (running locally) detects debug mode and starts DAP server on port 4711
|
||||||
|
3. Runner prints "Waiting for debugger on port 4711..." and pauses
|
||||||
|
4. User opens editor (nvim with nvim-dap), connects to debugger
|
||||||
|
5. Job execution begins, pausing before the first step
|
||||||
|
6. User can:
|
||||||
|
- **Inspect variables:** View `github`, `env`, `inputs`, `steps`, `secrets` (redacted), `runner`, `job` contexts
|
||||||
|
- **Evaluate expressions:** `${{ github.event.pull_request.title }}`
|
||||||
|
- **Execute shell commands:** Run arbitrary commands in the job's environment (REPL)
|
||||||
|
- **Step through job:** `next` moves to next step, `continue` runs to end
|
||||||
|
- **Pause after steps:** Inspect step outputs before continuing
|
||||||
|
|
||||||
|
## Activation
|
||||||
|
|
||||||
|
DAP debugging activates automatically when the job is in debug mode:
|
||||||
|
|
||||||
|
- User enables "Enable debug logging" when re-running a job in GitHub UI
|
||||||
|
- Server sends `ACTIONS_STEP_DEBUG=true` in job variables
|
||||||
|
- Runner sets `Global.WriteDebug = true` and `runner.debug = "1"`
|
||||||
|
- DAP server starts on port 4711
|
||||||
|
|
||||||
|
**No additional configuration required.**
|
||||||
|
|
||||||
|
### Optional Configuration
|
||||||
|
|
||||||
|
| Environment Variable | Default | Description |
|
||||||
|
|---------------------|---------|-------------|
|
||||||
|
| `ACTIONS_DAP_PORT` | `4711` | TCP port for DAP server (optional override) |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────┐ ┌─────────────────────────────────────────┐
|
||||||
|
│ nvim-dap │ │ Runner.Worker │
|
||||||
|
│ (DAP Client) │◄───TCP:4711───────►│ ┌─────────────────────────────────┐ │
|
||||||
|
│ │ │ │ DapServer │ │
|
||||||
|
└─────────────────────┘ │ │ - TCP listener │ │
|
||||||
|
│ │ - DAP JSON protocol │ │
|
||||||
|
│ └──────────────┬──────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌──────────────▼──────────────────┐ │
|
||||||
|
│ │ DapDebugSession │ │
|
||||||
|
│ │ - Debug state management │ │
|
||||||
|
│ │ - Step coordination │ │
|
||||||
|
│ │ - Variable exposure │ │
|
||||||
|
│ │ - Expression evaluation │ │
|
||||||
|
│ │ - Shell execution (REPL) │ │
|
||||||
|
│ └──────────────┬──────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌──────────────▼──────────────────┐ │
|
||||||
|
│ │ StepsRunner (modified) │ │
|
||||||
|
│ │ - Pause before/after steps │ │
|
||||||
|
│ │ - Notify debug session │ │
|
||||||
|
│ └─────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## DAP Concept Mapping
|
||||||
|
|
||||||
|
| DAP Concept | Actions Runner Equivalent |
|
||||||
|
|-------------|---------------------------|
|
||||||
|
| Thread | Single job execution |
|
||||||
|
| Stack Frame | Current step + completed steps (step history) |
|
||||||
|
| Scope | Context category: `github`, `env`, `inputs`, `steps`, `secrets`, `runner`, `job` |
|
||||||
|
| Variable | Individual context values |
|
||||||
|
| Breakpoint | Pause before specific step (future enhancement) |
|
||||||
|
| Step Over (Next) | Execute current step, pause before next |
|
||||||
|
| Continue | Run until job end |
|
||||||
|
| Evaluate | Evaluate `${{ }}` expressions OR execute shell commands (REPL) |
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/Runner.Worker/
|
||||||
|
├── Dap/
|
||||||
|
│ ├── DapServer.cs # TCP listener, JSON protocol handling
|
||||||
|
│ ├── DapDebugSession.cs # Debug state, step coordination
|
||||||
|
│ ├── DapMessages.cs # DAP protocol message types
|
||||||
|
│ └── DapVariableProvider.cs # Converts ExecutionContext to DAP variables
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Phases
|
||||||
|
|
||||||
|
### Phase 1: DAP Protocol Infrastructure
|
||||||
|
|
||||||
|
#### 1.1 Protocol Messages (`Dap/DapMessages.cs`)
|
||||||
|
|
||||||
|
Base message types following DAP spec:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public abstract class ProtocolMessage
|
||||||
|
{
|
||||||
|
public int seq { get; set; }
|
||||||
|
public string type { get; set; } // "request", "response", "event"
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Request : ProtocolMessage
|
||||||
|
{
|
||||||
|
public string command { get; set; }
|
||||||
|
public object arguments { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Response : ProtocolMessage
|
||||||
|
{
|
||||||
|
public int request_seq { get; set; }
|
||||||
|
public bool success { get; set; }
|
||||||
|
public string command { get; set; }
|
||||||
|
public string message { get; set; }
|
||||||
|
public object body { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Event : ProtocolMessage
|
||||||
|
{
|
||||||
|
public string @event { get; set; }
|
||||||
|
public object body { get; set; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Message framing: `Content-Length: N\r\n\r\n{json}`
|
||||||
|
|
||||||
|
#### 1.2 DAP Server (`Dap/DapServer.cs`)
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[ServiceLocator(Default = typeof(DapServer))]
|
||||||
|
public interface IDapServer : IRunnerService
|
||||||
|
{
|
||||||
|
Task StartAsync(int port);
|
||||||
|
Task WaitForConnectionAsync();
|
||||||
|
Task StopAsync();
|
||||||
|
void SendEvent(Event evt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class DapServer : RunnerService, IDapServer
|
||||||
|
{
|
||||||
|
private TcpListener _listener;
|
||||||
|
private TcpClient _client;
|
||||||
|
private IDapDebugSession _session;
|
||||||
|
|
||||||
|
// TCP listener on configurable port
|
||||||
|
// Single-client connection
|
||||||
|
// Async read/write loop
|
||||||
|
// Dispatch requests to DapDebugSession
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 2: Debug Session Logic
|
||||||
|
|
||||||
|
#### 2.1 Debug Session (`Dap/DapDebugSession.cs`)
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public enum DapCommand { Continue, Next, Pause, Disconnect }
|
||||||
|
public enum PauseReason { Entry, Step, Breakpoint, Pause }
|
||||||
|
|
||||||
|
[ServiceLocator(Default = typeof(DapDebugSession))]
|
||||||
|
public interface IDapDebugSession : IRunnerService
|
||||||
|
{
|
||||||
|
bool IsActive { get; }
|
||||||
|
|
||||||
|
// Called by DapServer
|
||||||
|
void Initialize(InitializeRequestArguments args);
|
||||||
|
void Attach(AttachRequestArguments args);
|
||||||
|
void ConfigurationDone();
|
||||||
|
Task<DapCommand> WaitForCommandAsync();
|
||||||
|
|
||||||
|
// Called by StepsRunner
|
||||||
|
Task OnStepStartingAsync(IStep step, IExecutionContext jobContext);
|
||||||
|
void OnStepCompleted(IStep step);
|
||||||
|
|
||||||
|
// DAP requests
|
||||||
|
ThreadsResponse GetThreads();
|
||||||
|
StackTraceResponse GetStackTrace(int threadId);
|
||||||
|
ScopesResponse GetScopes(int frameId);
|
||||||
|
VariablesResponse GetVariables(int variablesReference);
|
||||||
|
EvaluateResponse Evaluate(string expression, string context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class DapDebugSession : RunnerService, IDapDebugSession
|
||||||
|
{
|
||||||
|
private IExecutionContext _jobContext;
|
||||||
|
private IStep _currentStep;
|
||||||
|
private readonly List<IStep> _completedSteps = new();
|
||||||
|
private TaskCompletionSource<DapCommand> _commandTcs;
|
||||||
|
private bool _pauseAfterStep = false;
|
||||||
|
|
||||||
|
// Object reference management for nested variables
|
||||||
|
private int _nextVariableReference = 1;
|
||||||
|
private readonly Dictionary<int, object> _variableReferences = new();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Core state machine:
|
||||||
|
1. **Waiting for client:** Server started, no client connected
|
||||||
|
2. **Initializing:** Client connected, exchanging capabilities
|
||||||
|
3. **Ready:** `configurationDone` received, waiting to start
|
||||||
|
4. **Paused (before step):** Stopped before step execution, waiting for command
|
||||||
|
5. **Running:** Executing a step
|
||||||
|
6. **Paused (after step):** Stopped after step execution, waiting for command
|
||||||
|
|
||||||
|
#### 2.2 Variable Provider (`Dap/DapVariableProvider.cs`)
|
||||||
|
|
||||||
|
Maps `ExecutionContext.ExpressionValues` to DAP scopes and variables:
|
||||||
|
|
||||||
|
| Scope | Source | Notes |
|
||||||
|
|-------|--------|-------|
|
||||||
|
| `github` | `ExpressionValues["github"]` | Full github context |
|
||||||
|
| `env` | `ExpressionValues["env"]` | Environment variables |
|
||||||
|
| `inputs` | `ExpressionValues["inputs"]` | Step inputs (when available) |
|
||||||
|
| `steps` | `Global.StepsContext.GetScope()` | Completed step outputs |
|
||||||
|
| `secrets` | `ExpressionValues["secrets"]` | Keys shown, values = `[REDACTED]` |
|
||||||
|
| `runner` | `ExpressionValues["runner"]` | Runner context |
|
||||||
|
| `job` | `ExpressionValues["job"]` | Job status |
|
||||||
|
|
||||||
|
Nested objects (e.g., `github.event.pull_request`) become expandable variables with child references.
|
||||||
|
|
||||||
|
### Phase 3: StepsRunner Integration
|
||||||
|
|
||||||
|
#### 3.1 Modify `StepsRunner.cs`
|
||||||
|
|
||||||
|
Add debug hooks at step boundaries:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public async Task RunAsync(IExecutionContext jobContext)
|
||||||
|
{
|
||||||
|
// Get debug session if available
|
||||||
|
var debugSession = HostContext.TryGetService<IDapDebugSession>();
|
||||||
|
bool isFirstStep = true;
|
||||||
|
|
||||||
|
while (jobContext.JobSteps.Count > 0 || !checkPostJobActions)
|
||||||
|
{
|
||||||
|
// ... existing dequeue logic ...
|
||||||
|
|
||||||
|
var step = jobContext.JobSteps.Dequeue();
|
||||||
|
|
||||||
|
// Pause BEFORE step execution
|
||||||
|
if (debugSession?.IsActive == true)
|
||||||
|
{
|
||||||
|
var reason = isFirstStep ? PauseReason.Entry : PauseReason.Step;
|
||||||
|
await debugSession.OnStepStartingAsync(step, jobContext, reason);
|
||||||
|
isFirstStep = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ... existing step execution (condition eval, RunStepAsync, etc.) ...
|
||||||
|
|
||||||
|
// Pause AFTER step execution (if requested)
|
||||||
|
if (debugSession?.IsActive == true)
|
||||||
|
{
|
||||||
|
debugSession.OnStepCompleted(step);
|
||||||
|
// Session may pause here to let user inspect outputs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 4: Expression Evaluation & Shell (REPL)
|
||||||
|
|
||||||
|
#### 4.1 Expression Evaluation
|
||||||
|
|
||||||
|
Reuse existing `PipelineTemplateEvaluator`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
var expressionToken = new BasicExpressionToken(fileId: null, line: null, column: null, expression: expr);
|
||||||
|
var templateEvaluator = context.ToPipelineTemplateEvaluator();
|
||||||
|
|
||||||
|
var result = templateEvaluator.EvaluateStepDisplayName(
|
||||||
|
expressionToken,
|
||||||
|
context.ExpressionValues,
|
||||||
|
context.ExpressionFunctions
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
|
||||||
|
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
|
||||||
|
private async Task<EvaluateResponseBody> ExecuteShellCommandAsync(string command, IExecutionContext context)
|
||||||
|
{
|
||||||
|
var processInvoker = HostContext.CreateService<IProcessInvoker>();
|
||||||
|
var output = new StringBuilder();
|
||||||
|
|
||||||
|
processInvoker.OutputDataReceived += (sender, args) =>
|
||||||
|
{
|
||||||
|
output.AppendLine(args.Data);
|
||||||
|
// Stream to client in real-time via DAP output event
|
||||||
|
_server?.SendEvent(new Event
|
||||||
|
{
|
||||||
|
EventType = "output",
|
||||||
|
Body = new OutputEventBody { Category = "stdout", Output = args.Data + "\n" }
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
processInvoker.ErrorDataReceived += (sender, args) =>
|
||||||
|
{
|
||||||
|
_server?.SendEvent(new Event
|
||||||
|
{
|
||||||
|
EventType = "output",
|
||||||
|
Body = new OutputEventBody { Category = "stderr", Output = args.Data + "\n" }
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build environment from job context (includes GITHUB_*, env context, prepend path)
|
||||||
|
var env = BuildShellEnvironment(context);
|
||||||
|
var workDir = GetWorkingDirectory(context); // Uses github.workspace
|
||||||
|
var (shell, shellArgs) = GetDefaultShell(); // Platform-specific detection
|
||||||
|
|
||||||
|
int exitCode = await processInvoker.ExecuteAsync(
|
||||||
|
workingDirectory: workDir,
|
||||||
|
fileName: shell,
|
||||||
|
arguments: string.Format(shellArgs, command),
|
||||||
|
environment: env,
|
||||||
|
requireExitCodeZero: false,
|
||||||
|
cancellationToken: CancellationToken.None
|
||||||
|
);
|
||||||
|
|
||||||
|
return new EvaluateResponseBody
|
||||||
|
{
|
||||||
|
Result = HostContext.SecretMasker.MaskSecrets(output.ToString()),
|
||||||
|
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
|
||||||
|
|
||||||
|
#### 5.1 Modify `JobRunner.cs`
|
||||||
|
|
||||||
|
Add DAP server startup after debug mode is detected (around line 159):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
if (jobContext.Global.WriteDebug)
|
||||||
|
{
|
||||||
|
jobContext.SetRunnerContext("debug", "1");
|
||||||
|
|
||||||
|
// Start DAP server for interactive debugging
|
||||||
|
var dapServer = HostContext.GetService<IDapServer>();
|
||||||
|
var port = int.Parse(
|
||||||
|
Environment.GetEnvironmentVariable("ACTIONS_DAP_PORT") ?? "4711");
|
||||||
|
|
||||||
|
await dapServer.StartAsync(port);
|
||||||
|
Trace.Info($"DAP server listening on port {port}");
|
||||||
|
jobContext.Output($"DAP debugger waiting for connection on port {port}...");
|
||||||
|
|
||||||
|
// Block until debugger connects
|
||||||
|
await dapServer.WaitForConnectionAsync();
|
||||||
|
Trace.Info("DAP client connected, continuing job execution");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## DAP Capabilities
|
||||||
|
|
||||||
|
Capabilities to advertise in `InitializeResponse`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"supportsConfigurationDoneRequest": true,
|
||||||
|
"supportsEvaluateForHovers": true,
|
||||||
|
"supportsTerminateDebuggee": true,
|
||||||
|
"supportsStepBack": false,
|
||||||
|
"supportsSetVariable": false,
|
||||||
|
"supportsRestartFrame": false,
|
||||||
|
"supportsGotoTargetsRequest": false,
|
||||||
|
"supportsStepInTargetsRequest": false,
|
||||||
|
"supportsCompletionsRequest": false,
|
||||||
|
"supportsModulesRequest": false,
|
||||||
|
"supportsExceptionOptions": false,
|
||||||
|
"supportsValueFormattingOptions": false,
|
||||||
|
"supportsExceptionInfoRequest": false,
|
||||||
|
"supportsDelayedStackTraceLoading": false,
|
||||||
|
"supportsLoadedSourcesRequest": false,
|
||||||
|
"supportsProgressReporting": false,
|
||||||
|
"supportsRunInTerminalRequest": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Client Configuration (nvim-dap)
|
||||||
|
|
||||||
|
Example configuration for nvim-dap:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local dap = require('dap')
|
||||||
|
|
||||||
|
dap.adapters.actions = {
|
||||||
|
type = 'server',
|
||||||
|
host = '127.0.0.1',
|
||||||
|
port = 4711,
|
||||||
|
}
|
||||||
|
|
||||||
|
dap.configurations.yaml = {
|
||||||
|
{
|
||||||
|
type = 'actions',
|
||||||
|
request = 'attach',
|
||||||
|
name = 'Attach to Actions Runner',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Demo Flow
|
||||||
|
|
||||||
|
1. Trigger job re-run with "Enable debug logging" checked in GitHub UI
|
||||||
|
2. Runner starts, detects debug mode (`Global.WriteDebug == true`)
|
||||||
|
3. DAP server starts, console shows: `DAP debugger waiting for connection on port 4711...`
|
||||||
|
4. In nvim: `:lua require('dap').continue()`
|
||||||
|
5. Connection established, capabilities exchanged
|
||||||
|
6. Job begins, pauses before first step
|
||||||
|
7. nvim shows "stopped" state, variables panel shows contexts
|
||||||
|
8. User explores variables, evaluates expressions, runs shell commands
|
||||||
|
9. User presses `n` (next) to advance to next step
|
||||||
|
10. After step completes, user can inspect outputs before continuing
|
||||||
|
11. Repeat until job completes
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
1. **Unit tests:** DAP protocol serialization, variable provider mapping
|
||||||
|
2. **Integration tests:** Mock DAP client verifying request/response sequences
|
||||||
|
3. **Manual testing:** Real job with nvim-dap attached
|
||||||
|
|
||||||
|
## Future Enhancements (Out of Scope for Demo)
|
||||||
|
|
||||||
|
- Composite action step-in (expand into sub-steps)
|
||||||
|
- Breakpoints on specific step names
|
||||||
|
- Watch expressions
|
||||||
|
- Conditional breakpoints
|
||||||
|
- Remote debugging (runner not on localhost)
|
||||||
|
- VS Code extension
|
||||||
|
|
||||||
|
## Estimated Effort
|
||||||
|
|
||||||
|
| Phase | Effort |
|
||||||
|
|-------|--------|
|
||||||
|
| Phase 1: Protocol Infrastructure | 4-6 hours |
|
||||||
|
| Phase 2: Debug Session Logic | 4-6 hours |
|
||||||
|
| Phase 3: StepsRunner Integration | 2-3 hours |
|
||||||
|
| Phase 4: Expression & Shell | 3-4 hours |
|
||||||
|
| Phase 5: Startup & Polish | 2-3 hours |
|
||||||
|
| **Total** | **~2-3 days** |
|
||||||
|
|
||||||
|
## Key Files to Modify
|
||||||
|
|
||||||
|
| File | Changes |
|
||||||
|
|------|---------|
|
||||||
|
| `src/Runner.Worker/JobRunner.cs` | Start DAP server when debug mode enabled |
|
||||||
|
| `src/Runner.Worker/StepsRunner.cs` | Add pause hooks before/after step execution |
|
||||||
|
| `src/Runner.Worker/Runner.Worker.csproj` | Add new Dap/ folder files |
|
||||||
|
|
||||||
|
## Key Files to Create
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `src/Runner.Worker/Dap/DapServer.cs` | TCP server, protocol framing |
|
||||||
|
| `src/Runner.Worker/Dap/DapDebugSession.cs` | Debug state machine, command handling |
|
||||||
|
| `src/Runner.Worker/Dap/DapMessages.cs` | Protocol message types |
|
||||||
|
| `src/Runner.Worker/Dap/DapVariableProvider.cs` | Context → DAP variable conversion |
|
||||||
|
|
||||||
|
## Reference Links
|
||||||
|
|
||||||
|
- [DAP Overview](https://microsoft.github.io/debug-adapter-protocol/overview)
|
||||||
|
- [DAP Specification](https://microsoft.github.io/debug-adapter-protocol/specification)
|
||||||
|
- [Enable Debug Logging (GitHub Docs)](https://docs.github.com/en/actions/how-tos/monitor-workflows/enable-debug-logging)
|
||||||
1116
.opencode/plans/dap-step-backwards.md
Normal file
1116
.opencode/plans/dap-step-backwards.md
Normal file
File diff suppressed because it is too large
Load Diff
176
browser-ext/README.md
Normal file
176
browser-ext/README.md
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
# Actions DAP Debugger - Browser Extension
|
||||||
|
|
||||||
|
A Chrome extension that enables interactive debugging of GitHub Actions workflows directly in the browser. Connects to the runner's DAP server via a WebSocket proxy.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Variable Inspection**: Browse workflow context variables (`github`, `env`, `steps`, etc.)
|
||||||
|
- **REPL Console**: Evaluate expressions and run shell commands
|
||||||
|
- **Step Control**: Step forward, step back, continue, and reverse continue
|
||||||
|
- **GitHub Integration**: Debugger pane injects directly into the job page
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### 1. Start the WebSocket Proxy
|
||||||
|
|
||||||
|
The proxy bridges WebSocket connections from the browser to the DAP TCP server.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd browser-ext/proxy
|
||||||
|
npm install
|
||||||
|
node proxy.js
|
||||||
|
```
|
||||||
|
|
||||||
|
The proxy listens on `ws://localhost:4712` and connects to the DAP server at `tcp://localhost:4711`.
|
||||||
|
|
||||||
|
### 2. Load the Extension in Chrome
|
||||||
|
|
||||||
|
1. Open Chrome and navigate to `chrome://extensions/`
|
||||||
|
2. Enable "Developer mode" (toggle in top right)
|
||||||
|
3. Click "Load unpacked"
|
||||||
|
4. Select the `browser-ext` directory
|
||||||
|
|
||||||
|
### 3. Start a Debug Session
|
||||||
|
|
||||||
|
1. Go to your GitHub repository
|
||||||
|
2. Navigate to Actions and select a workflow run
|
||||||
|
3. Click "Re-run jobs" → check "Enable debug logging"
|
||||||
|
4. Wait for the runner to display "DAP debugger waiting for connection..."
|
||||||
|
|
||||||
|
### 4. Connect the Extension
|
||||||
|
|
||||||
|
1. Navigate to the job page (`github.com/.../actions/runs/.../job/...`)
|
||||||
|
2. Click the extension icon in Chrome toolbar
|
||||||
|
3. Click "Connect"
|
||||||
|
4. The debugger pane will appear above the first workflow step
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Variable Browser (Left Panel)
|
||||||
|
|
||||||
|
Click on scope names to expand and view variables:
|
||||||
|
- **Globals**: `github`, `env`, `runner` contexts
|
||||||
|
- **Job Outputs**: Outputs from previous jobs
|
||||||
|
- **Step Outputs**: Outputs from previous steps
|
||||||
|
|
||||||
|
### Console (Right Panel)
|
||||||
|
|
||||||
|
Enter expressions or commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Evaluate expressions
|
||||||
|
${{ github.ref }}
|
||||||
|
${{ github.event_name }}
|
||||||
|
${{ env.MY_VAR }}
|
||||||
|
|
||||||
|
# Run shell commands (prefix with !)
|
||||||
|
!ls -la
|
||||||
|
!cat package.json
|
||||||
|
!env | grep GITHUB
|
||||||
|
|
||||||
|
# Modify variables
|
||||||
|
!export MY_VAR=new_value
|
||||||
|
```
|
||||||
|
|
||||||
|
### Control Buttons
|
||||||
|
|
||||||
|
| Button | Action | Description |
|
||||||
|
|--------|--------|-------------|
|
||||||
|
| ⏮ | Reverse Continue | Go back to first checkpoint |
|
||||||
|
| ◀ | Step Back | Go to previous checkpoint |
|
||||||
|
| ▶ | Continue | Run until next breakpoint/end |
|
||||||
|
| ⏭ | Step (Next) | Step to next workflow step |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser Extension ──WebSocket──► Proxy ──TCP──► Runner DAP Server
|
||||||
|
(port 4712) (port 4711)
|
||||||
|
```
|
||||||
|
|
||||||
|
The WebSocket proxy handles DAP message framing (Content-Length headers) and provides a browser-compatible connection.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Proxy Settings
|
||||||
|
|
||||||
|
| Environment Variable | Default | Description |
|
||||||
|
|---------------------|---------|-------------|
|
||||||
|
| `WS_PORT` | 4712 | WebSocket server port |
|
||||||
|
| `DAP_HOST` | 127.0.0.1 | DAP server host |
|
||||||
|
| `DAP_PORT` | 4711 | DAP server port |
|
||||||
|
|
||||||
|
Or use CLI arguments:
|
||||||
|
```bash
|
||||||
|
node proxy.js --ws-port 4712 --dap-host 127.0.0.1 --dap-port 4711
|
||||||
|
```
|
||||||
|
|
||||||
|
### Extension Settings
|
||||||
|
|
||||||
|
Click the extension popup to configure:
|
||||||
|
- **Proxy Host**: Default `localhost`
|
||||||
|
- **Proxy Port**: Default `4712`
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
browser-ext/
|
||||||
|
├── manifest.json # Extension configuration
|
||||||
|
├── background/
|
||||||
|
│ └── background.js # Service worker - DAP client
|
||||||
|
├── content/
|
||||||
|
│ ├── content.js # UI injection and interaction
|
||||||
|
│ └── content.css # Debugger pane styling
|
||||||
|
├── popup/
|
||||||
|
│ ├── popup.html # Extension popup UI
|
||||||
|
│ ├── popup.js # Popup logic
|
||||||
|
│ └── popup.css # Popup styling
|
||||||
|
├── lib/
|
||||||
|
│ └── dap-protocol.js # DAP message helpers
|
||||||
|
├── proxy/
|
||||||
|
│ ├── proxy.js # WebSocket-to-TCP bridge
|
||||||
|
│ └── package.json # Proxy dependencies
|
||||||
|
└── icons/
|
||||||
|
├── icon16.png
|
||||||
|
├── icon48.png
|
||||||
|
└── icon128.png
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "Failed to connect to DAP server"
|
||||||
|
|
||||||
|
1. Ensure the proxy is running: `node proxy.js`
|
||||||
|
2. Ensure the runner is waiting for a debugger connection
|
||||||
|
3. Check that debug logging is enabled for the job
|
||||||
|
|
||||||
|
### Debugger pane doesn't appear
|
||||||
|
|
||||||
|
1. Verify you're on a job page (`/actions/runs/*/job/*`)
|
||||||
|
2. Open DevTools and check for console errors
|
||||||
|
3. Reload the page after loading the extension
|
||||||
|
|
||||||
|
### Variables don't load
|
||||||
|
|
||||||
|
1. Wait for the "stopped" event (status shows PAUSED)
|
||||||
|
2. Click on a scope to expand it
|
||||||
|
3. Check the console for error messages
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Modifying the Extension
|
||||||
|
|
||||||
|
After making changes:
|
||||||
|
1. Go to `chrome://extensions/`
|
||||||
|
2. Click the refresh icon on the extension card
|
||||||
|
3. Reload the GitHub job page
|
||||||
|
|
||||||
|
### Debugging
|
||||||
|
|
||||||
|
- **Background script**: Inspect via `chrome://extensions/` → "Inspect views: service worker"
|
||||||
|
- **Content script**: Use DevTools on the GitHub page
|
||||||
|
- **Proxy**: Watch terminal output for message logs
|
||||||
|
|
||||||
|
## Security Note
|
||||||
|
|
||||||
|
The proxy and extension are designed for local development. The proxy only accepts connections from localhost. Do not expose the proxy to the network without additional security measures.
|
||||||
528
browser-ext/background/background.js
Normal file
528
browser-ext/background/background.js
Normal file
@@ -0,0 +1,528 @@
|
|||||||
|
/**
|
||||||
|
* Background Script - DAP Client
|
||||||
|
*
|
||||||
|
* Service worker that manages WebSocket connection to the proxy
|
||||||
|
* and handles DAP protocol communication.
|
||||||
|
*
|
||||||
|
* NOTE: Chrome MV3 service workers can be terminated after ~30s of inactivity.
|
||||||
|
* We handle this with:
|
||||||
|
* 1. Keepalive pings to keep the WebSocket active
|
||||||
|
* 2. Automatic reconnection when the service worker restarts
|
||||||
|
* 3. Storing connection state in chrome.storage.session
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Connection state
|
||||||
|
let ws = null;
|
||||||
|
let connectionStatus = 'disconnected'; // disconnected, connecting, connected, paused, running, error
|
||||||
|
let sequenceNumber = 1;
|
||||||
|
const pendingRequests = new Map(); // seq -> { resolve, reject, command, timeout }
|
||||||
|
|
||||||
|
// Reconnection state
|
||||||
|
let reconnectAttempts = 0;
|
||||||
|
const MAX_RECONNECT_ATTEMPTS = 10;
|
||||||
|
const RECONNECT_BASE_DELAY = 1000; // Start with 1s, exponential backoff
|
||||||
|
let reconnectTimer = null;
|
||||||
|
let lastConnectedUrl = null;
|
||||||
|
let wasConnectedBeforeIdle = false;
|
||||||
|
|
||||||
|
// Keepalive interval - send ping every 15s to keep service worker AND WebSocket alive
|
||||||
|
// Chrome MV3 service workers get suspended after ~30s of inactivity
|
||||||
|
// We need to send actual WebSocket messages to keep both alive
|
||||||
|
const KEEPALIVE_INTERVAL = 15000;
|
||||||
|
let keepaliveTimer = null;
|
||||||
|
|
||||||
|
// Default configuration
|
||||||
|
const DEFAULT_URL = 'ws://localhost:4712';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize on service worker startup - check if we should reconnect
|
||||||
|
*/
|
||||||
|
async function initializeOnStartup() {
|
||||||
|
console.log('[Background] Service worker starting up...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Restore state from session storage
|
||||||
|
const data = await chrome.storage.session.get(['connectionUrl', 'shouldBeConnected', 'lastStatus']);
|
||||||
|
|
||||||
|
if (data.shouldBeConnected && data.connectionUrl) {
|
||||||
|
console.log('[Background] Restoring connection after service worker restart');
|
||||||
|
lastConnectedUrl = data.connectionUrl;
|
||||||
|
wasConnectedBeforeIdle = true;
|
||||||
|
|
||||||
|
// Small delay to let things settle
|
||||||
|
setTimeout(() => {
|
||||||
|
connect(data.connectionUrl);
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log('[Background] No session state to restore');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save connection state to session storage (survives service worker restart)
|
||||||
|
*/
|
||||||
|
async function saveConnectionState() {
|
||||||
|
try {
|
||||||
|
await chrome.storage.session.set({
|
||||||
|
connectionUrl: lastConnectedUrl,
|
||||||
|
shouldBeConnected: connectionStatus !== 'disconnected' && connectionStatus !== 'error',
|
||||||
|
lastStatus: connectionStatus,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[Background] Failed to save connection state:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear connection state from session storage
|
||||||
|
*/
|
||||||
|
async function clearConnectionState() {
|
||||||
|
try {
|
||||||
|
await chrome.storage.session.remove(['connectionUrl', 'shouldBeConnected', 'lastStatus']);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[Background] Failed to clear connection state:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start keepalive ping to prevent service worker termination
|
||||||
|
* CRITICAL: We must send actual WebSocket messages to keep the connection alive.
|
||||||
|
* Just having a timer is not enough - Chrome will suspend the service worker
|
||||||
|
* and close the WebSocket with code 1001 after ~30s of inactivity.
|
||||||
|
*/
|
||||||
|
function startKeepalive() {
|
||||||
|
stopKeepalive();
|
||||||
|
|
||||||
|
keepaliveTimer = setInterval(() => {
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
|
try {
|
||||||
|
// Send a lightweight keepalive message over WebSocket
|
||||||
|
// This does two things:
|
||||||
|
// 1. Keeps the WebSocket connection active (prevents proxy timeout)
|
||||||
|
// 2. Creates activity that keeps the Chrome service worker alive
|
||||||
|
const keepaliveMsg = JSON.stringify({ type: 'keepalive', timestamp: Date.now() });
|
||||||
|
ws.send(keepaliveMsg);
|
||||||
|
console.log('[Background] Keepalive sent');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Background] Keepalive error:', e);
|
||||||
|
handleUnexpectedClose();
|
||||||
|
}
|
||||||
|
} else if (wasConnectedBeforeIdle || lastConnectedUrl) {
|
||||||
|
// Connection was lost, try to reconnect
|
||||||
|
console.log('[Background] Connection lost during keepalive check');
|
||||||
|
handleUnexpectedClose();
|
||||||
|
}
|
||||||
|
}, KEEPALIVE_INTERVAL);
|
||||||
|
|
||||||
|
console.log('[Background] Keepalive timer started (interval: ' + KEEPALIVE_INTERVAL + 'ms)');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop keepalive ping
|
||||||
|
*/
|
||||||
|
function stopKeepalive() {
|
||||||
|
if (keepaliveTimer) {
|
||||||
|
clearInterval(keepaliveTimer);
|
||||||
|
keepaliveTimer = null;
|
||||||
|
console.log('[Background] Keepalive timer stopped');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle unexpected connection close - attempt reconnection
|
||||||
|
*/
|
||||||
|
function handleUnexpectedClose() {
|
||||||
|
if (reconnectTimer) {
|
||||||
|
return; // Already trying to reconnect
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!lastConnectedUrl) {
|
||||||
|
console.log('[Background] No URL to reconnect to');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
||||||
|
console.error('[Background] Max reconnection attempts reached');
|
||||||
|
connectionStatus = 'error';
|
||||||
|
broadcastStatus();
|
||||||
|
clearConnectionState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const delay = Math.min(RECONNECT_BASE_DELAY * Math.pow(2, reconnectAttempts), 30000);
|
||||||
|
reconnectAttempts++;
|
||||||
|
|
||||||
|
console.log(`[Background] Scheduling reconnect attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS} in ${delay}ms`);
|
||||||
|
connectionStatus = 'connecting';
|
||||||
|
broadcastStatus();
|
||||||
|
|
||||||
|
reconnectTimer = setTimeout(() => {
|
||||||
|
reconnectTimer = null;
|
||||||
|
if (connectionStatus !== 'connected' && connectionStatus !== 'paused' && connectionStatus !== 'running') {
|
||||||
|
connect(lastConnectedUrl);
|
||||||
|
}
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect to the WebSocket proxy
|
||||||
|
*/
|
||||||
|
function connect(url) {
|
||||||
|
// Clean up existing connection
|
||||||
|
if (ws) {
|
||||||
|
try {
|
||||||
|
ws.onclose = null; // Prevent triggering reconnect
|
||||||
|
ws.close(1000, 'Reconnecting');
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
ws = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear any pending reconnect
|
||||||
|
if (reconnectTimer) {
|
||||||
|
clearTimeout(reconnectTimer);
|
||||||
|
reconnectTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
connectionStatus = 'connecting';
|
||||||
|
broadcastStatus();
|
||||||
|
|
||||||
|
// Use provided URL or default
|
||||||
|
const wsUrl = url || DEFAULT_URL;
|
||||||
|
lastConnectedUrl = wsUrl;
|
||||||
|
console.log(`[Background] Connecting to ${wsUrl}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
ws = new WebSocket(wsUrl);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Background] Failed to create WebSocket:', e);
|
||||||
|
connectionStatus = 'error';
|
||||||
|
broadcastStatus();
|
||||||
|
handleUnexpectedClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onopen = async () => {
|
||||||
|
console.log('[Background] WebSocket connected');
|
||||||
|
connectionStatus = 'connected';
|
||||||
|
reconnectAttempts = 0; // Reset on successful connection
|
||||||
|
wasConnectedBeforeIdle = true;
|
||||||
|
broadcastStatus();
|
||||||
|
saveConnectionState();
|
||||||
|
startKeepalive();
|
||||||
|
|
||||||
|
// Initialize DAP session
|
||||||
|
try {
|
||||||
|
await initializeDapSession();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Background] Failed to initialize DAP session:', error);
|
||||||
|
// Don't set error status - the connection might still be usable
|
||||||
|
// The DAP server might just need the job to progress
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(event.data);
|
||||||
|
handleDapMessage(message);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Background] Failed to parse message:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = (event) => {
|
||||||
|
console.log(`[Background] WebSocket closed: ${event.code} ${event.reason || '(no reason)'}`);
|
||||||
|
ws = null;
|
||||||
|
stopKeepalive();
|
||||||
|
|
||||||
|
// Reject any pending requests
|
||||||
|
for (const [seq, pending] of pendingRequests) {
|
||||||
|
if (pending.timeout) clearTimeout(pending.timeout);
|
||||||
|
pending.reject(new Error('Connection closed'));
|
||||||
|
}
|
||||||
|
pendingRequests.clear();
|
||||||
|
|
||||||
|
// Determine if we should reconnect
|
||||||
|
// Code 1000 = normal closure (user initiated)
|
||||||
|
// Code 1001 = going away (service worker idle, browser closing, etc.)
|
||||||
|
// Code 1006 = abnormal closure (connection lost)
|
||||||
|
// Code 1011 = server error
|
||||||
|
const shouldReconnect = event.code !== 1000;
|
||||||
|
|
||||||
|
if (shouldReconnect && wasConnectedBeforeIdle) {
|
||||||
|
console.log('[Background] Unexpected close, will attempt reconnect');
|
||||||
|
connectionStatus = 'connecting';
|
||||||
|
broadcastStatus();
|
||||||
|
handleUnexpectedClose();
|
||||||
|
} else {
|
||||||
|
connectionStatus = 'disconnected';
|
||||||
|
wasConnectedBeforeIdle = false;
|
||||||
|
broadcastStatus();
|
||||||
|
clearConnectionState();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = (event) => {
|
||||||
|
console.error('[Background] WebSocket error:', event);
|
||||||
|
// onclose will be called after onerror, so we handle reconnection there
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disconnect from the WebSocket proxy
|
||||||
|
*/
|
||||||
|
function disconnect() {
|
||||||
|
// Stop any reconnection attempts
|
||||||
|
if (reconnectTimer) {
|
||||||
|
clearTimeout(reconnectTimer);
|
||||||
|
reconnectTimer = null;
|
||||||
|
}
|
||||||
|
reconnectAttempts = 0;
|
||||||
|
wasConnectedBeforeIdle = false;
|
||||||
|
stopKeepalive();
|
||||||
|
|
||||||
|
if (ws) {
|
||||||
|
// Send disconnect request to DAP server first
|
||||||
|
sendDapRequest('disconnect', {}).catch(() => {});
|
||||||
|
|
||||||
|
// Prevent reconnection on this close
|
||||||
|
const socket = ws;
|
||||||
|
ws = null;
|
||||||
|
socket.onclose = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
socket.close(1000, 'User disconnected');
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
connectionStatus = 'disconnected';
|
||||||
|
broadcastStatus();
|
||||||
|
clearConnectionState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize DAP session (initialize + attach + configurationDone)
|
||||||
|
*/
|
||||||
|
async function initializeDapSession() {
|
||||||
|
// 1. Initialize
|
||||||
|
const initResponse = await sendDapRequest('initialize', {
|
||||||
|
clientID: 'browser-extension',
|
||||||
|
clientName: 'Actions DAP Debugger',
|
||||||
|
adapterID: 'github-actions-runner',
|
||||||
|
pathFormat: 'path',
|
||||||
|
linesStartAt1: true,
|
||||||
|
columnsStartAt1: true,
|
||||||
|
supportsVariableType: true,
|
||||||
|
supportsVariablePaging: true,
|
||||||
|
supportsRunInTerminalRequest: false,
|
||||||
|
supportsProgressReporting: false,
|
||||||
|
supportsInvalidatedEvent: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[Background] Initialize response:', initResponse);
|
||||||
|
|
||||||
|
// 2. Attach to running session
|
||||||
|
const attachResponse = await sendDapRequest('attach', {});
|
||||||
|
console.log('[Background] Attach response:', attachResponse);
|
||||||
|
|
||||||
|
// 3. Configuration done
|
||||||
|
const configResponse = await sendDapRequest('configurationDone', {});
|
||||||
|
console.log('[Background] ConfigurationDone response:', configResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a DAP request and return a promise for the response
|
||||||
|
*/
|
||||||
|
function sendDapRequest(command, args = {}) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||||
|
reject(new Error('Not connected'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const seq = sequenceNumber++;
|
||||||
|
const request = {
|
||||||
|
seq,
|
||||||
|
type: 'request',
|
||||||
|
command,
|
||||||
|
arguments: args,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`[Background] Sending DAP request: ${command} (seq: ${seq})`);
|
||||||
|
|
||||||
|
// Set timeout for request
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
if (pendingRequests.has(seq)) {
|
||||||
|
pendingRequests.delete(seq);
|
||||||
|
reject(new Error(`Request timed out: ${command}`));
|
||||||
|
}
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
pendingRequests.set(seq, { resolve, reject, command, timeout });
|
||||||
|
|
||||||
|
try {
|
||||||
|
ws.send(JSON.stringify(request));
|
||||||
|
} catch (e) {
|
||||||
|
pendingRequests.delete(seq);
|
||||||
|
clearTimeout(timeout);
|
||||||
|
reject(new Error(`Failed to send request: ${e.message}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle incoming DAP message (response or event)
|
||||||
|
*/
|
||||||
|
function handleDapMessage(message) {
|
||||||
|
if (message.type === 'response') {
|
||||||
|
handleDapResponse(message);
|
||||||
|
} else if (message.type === 'event') {
|
||||||
|
handleDapEvent(message);
|
||||||
|
} else if (message.type === 'proxy-error') {
|
||||||
|
console.error('[Background] Proxy error:', message.message);
|
||||||
|
// Don't immediately set error status - might be transient
|
||||||
|
} else if (message.type === 'keepalive-ack') {
|
||||||
|
// Keepalive acknowledged by proxy - connection is healthy
|
||||||
|
console.log('[Background] Keepalive acknowledged');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle DAP response
|
||||||
|
*/
|
||||||
|
function handleDapResponse(response) {
|
||||||
|
const pending = pendingRequests.get(response.request_seq);
|
||||||
|
if (!pending) {
|
||||||
|
console.warn(`[Background] No pending request for seq ${response.request_seq}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingRequests.delete(response.request_seq);
|
||||||
|
if (pending.timeout) clearTimeout(pending.timeout);
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
console.log(`[Background] DAP response success: ${response.command}`);
|
||||||
|
pending.resolve(response.body || {});
|
||||||
|
} else {
|
||||||
|
console.error(`[Background] DAP response error: ${response.command} - ${response.message}`);
|
||||||
|
pending.reject(new Error(response.message || 'Unknown error'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle DAP event
|
||||||
|
*/
|
||||||
|
function handleDapEvent(event) {
|
||||||
|
console.log(`[Background] DAP event: ${event.event}`, event.body);
|
||||||
|
|
||||||
|
switch (event.event) {
|
||||||
|
case 'initialized':
|
||||||
|
// DAP server is ready
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'stopped':
|
||||||
|
connectionStatus = 'paused';
|
||||||
|
broadcastStatus();
|
||||||
|
saveConnectionState();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'continued':
|
||||||
|
connectionStatus = 'running';
|
||||||
|
broadcastStatus();
|
||||||
|
saveConnectionState();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'terminated':
|
||||||
|
connectionStatus = 'disconnected';
|
||||||
|
wasConnectedBeforeIdle = false;
|
||||||
|
broadcastStatus();
|
||||||
|
clearConnectionState();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'output':
|
||||||
|
// Output event - forward to content scripts
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast event to all content scripts
|
||||||
|
broadcastEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Broadcast connection status to popup and content scripts
|
||||||
|
*/
|
||||||
|
function broadcastStatus() {
|
||||||
|
const statusMessage = { type: 'status-changed', status: connectionStatus };
|
||||||
|
|
||||||
|
// Broadcast to all extension contexts (popup)
|
||||||
|
chrome.runtime.sendMessage(statusMessage).catch(() => {});
|
||||||
|
|
||||||
|
// Broadcast to content scripts
|
||||||
|
chrome.tabs.query({ url: 'https://github.com/*/*/actions/runs/*/job/*' }, (tabs) => {
|
||||||
|
if (chrome.runtime.lastError) return;
|
||||||
|
tabs.forEach((tab) => {
|
||||||
|
chrome.tabs.sendMessage(tab.id, statusMessage).catch(() => {});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Broadcast DAP event to content scripts
|
||||||
|
*/
|
||||||
|
function broadcastEvent(event) {
|
||||||
|
chrome.tabs.query({ url: 'https://github.com/*/*/actions/runs/*/job/*' }, (tabs) => {
|
||||||
|
if (chrome.runtime.lastError) return;
|
||||||
|
tabs.forEach((tab) => {
|
||||||
|
chrome.tabs.sendMessage(tab.id, { type: 'dap-event', event }).catch(() => {});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Message handler for requests from popup and content scripts
|
||||||
|
*/
|
||||||
|
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||||
|
console.log('[Background] Received message:', message.type);
|
||||||
|
|
||||||
|
switch (message.type) {
|
||||||
|
case 'get-status':
|
||||||
|
sendResponse({ status: connectionStatus, reconnecting: reconnectTimer !== null });
|
||||||
|
return false;
|
||||||
|
|
||||||
|
case 'connect':
|
||||||
|
reconnectAttempts = 0; // Reset attempts on manual connect
|
||||||
|
connect(message.url || DEFAULT_URL);
|
||||||
|
sendResponse({ status: connectionStatus });
|
||||||
|
return false;
|
||||||
|
|
||||||
|
case 'disconnect':
|
||||||
|
disconnect();
|
||||||
|
sendResponse({ status: connectionStatus });
|
||||||
|
return false;
|
||||||
|
|
||||||
|
case 'dap-request':
|
||||||
|
// Handle DAP request from content script
|
||||||
|
sendDapRequest(message.command, message.args || {})
|
||||||
|
.then((body) => {
|
||||||
|
sendResponse({ success: true, body });
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
sendResponse({ success: false, error: error.message });
|
||||||
|
});
|
||||||
|
return true; // Will respond asynchronously
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.warn('[Background] Unknown message type:', message.type);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize on startup
|
||||||
|
initializeOnStartup();
|
||||||
|
|
||||||
|
// Log startup
|
||||||
|
console.log('[Background] Actions DAP Debugger background script loaded');
|
||||||
337
browser-ext/content/content.css
Normal file
337
browser-ext/content/content.css
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
/**
|
||||||
|
* Content Script Styles
|
||||||
|
*
|
||||||
|
* Matches GitHub's Primer design system for seamless integration.
|
||||||
|
* Uses CSS custom properties for light/dark mode support.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Debugger Pane Container */
|
||||||
|
.dap-debugger-pane {
|
||||||
|
background-color: var(--bgColor-default, #0d1117);
|
||||||
|
border-color: var(--borderColor-default, #30363d) !important;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.dap-header {
|
||||||
|
background-color: var(--bgColor-muted, #161b22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dap-header .octicon {
|
||||||
|
color: var(--fgColor-muted, #8b949e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dap-step-info {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status Labels */
|
||||||
|
.dap-status-label {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Label--attention {
|
||||||
|
background-color: #9e6a03 !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
border: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Label--success {
|
||||||
|
background-color: #238636 !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
border: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Label--danger {
|
||||||
|
background-color: #da3633 !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
border: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Label--secondary {
|
||||||
|
background-color: #30363d !important;
|
||||||
|
color: #8b949e !important;
|
||||||
|
border: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content Area */
|
||||||
|
.dap-content {
|
||||||
|
min-height: 200px;
|
||||||
|
max-height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scopes Panel */
|
||||||
|
.dap-scopes {
|
||||||
|
border-color: var(--borderColor-default, #30363d) !important;
|
||||||
|
min-width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dap-scope-header {
|
||||||
|
background-color: var(--bgColor-muted, #161b22);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dap-scope-tree {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tree Nodes */
|
||||||
|
.dap-tree-node {
|
||||||
|
padding: 1px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dap-tree-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dap-tree-content:hover {
|
||||||
|
background-color: var(--bgColor-muted, #161b22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dap-tree-children {
|
||||||
|
margin-left: 16px;
|
||||||
|
border-left: 1px solid var(--borderColor-muted, #21262d);
|
||||||
|
padding-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dap-expand-icon {
|
||||||
|
display: inline-block;
|
||||||
|
width: 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--fgColor-muted, #8b949e);
|
||||||
|
font-size: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dap-tree-node .text-bold {
|
||||||
|
color: var(--fgColor-default, #e6edf3);
|
||||||
|
font-weight: 600;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dap-tree-node .color-fg-muted {
|
||||||
|
color: var(--fgColor-muted, #8b949e);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* REPL Console */
|
||||||
|
.dap-repl {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dap-repl-header {
|
||||||
|
background-color: var(--bgColor-muted, #161b22);
|
||||||
|
font-size: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dap-repl-output {
|
||||||
|
background-color: var(--bgColor-inset, #010409);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
padding: 8px;
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dap-output-input {
|
||||||
|
color: var(--fgColor-muted, #8b949e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dap-output-result {
|
||||||
|
color: var(--fgColor-default, #e6edf3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dap-output-stdout {
|
||||||
|
color: var(--fgColor-default, #e6edf3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dap-output-error {
|
||||||
|
color: var(--fgColor-danger, #f85149);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* REPL Input */
|
||||||
|
.dap-repl-input {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dap-repl-input input {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
background-color: var(--bgColor-inset, #010409) !important;
|
||||||
|
border-color: var(--borderColor-default, #30363d) !important;
|
||||||
|
color: var(--fgColor-default, #e6edf3) !important;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dap-repl-input input:focus {
|
||||||
|
border-color: var(--focus-outlineColor, #1f6feb) !important;
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 3px rgba(31, 111, 235, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dap-repl-input input:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dap-repl-input input::placeholder {
|
||||||
|
color: var(--fgColor-muted, #8b949e);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Control Buttons */
|
||||||
|
.dap-controls {
|
||||||
|
background-color: var(--bgColor-muted, #161b22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dap-controls button {
|
||||||
|
min-width: 32px;
|
||||||
|
height: 28px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dap-controls button svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dap-controls button:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dap-controls button:not(:disabled):hover {
|
||||||
|
background-color: var(--bgColor-accent-muted, #388bfd26);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dap-step-counter {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Utility Classes (in case GitHub's aren't loaded) */
|
||||||
|
.d-flex { display: flex; }
|
||||||
|
.flex-column { flex-direction: column; }
|
||||||
|
.flex-items-center { align-items: center; }
|
||||||
|
.flex-auto { flex: 1 1 auto; }
|
||||||
|
|
||||||
|
.p-2 { padding: 8px; }
|
||||||
|
.px-2 { padding-left: 8px; padding-right: 8px; }
|
||||||
|
.mx-2 { margin-left: 8px; margin-right: 8px; }
|
||||||
|
.mb-2 { margin-bottom: 8px; }
|
||||||
|
.ml-2 { margin-left: 8px; }
|
||||||
|
.ml-3 { margin-left: 16px; }
|
||||||
|
.mr-2 { margin-right: 8px; }
|
||||||
|
.ml-auto { margin-left: auto; }
|
||||||
|
|
||||||
|
.border { border: 1px solid var(--borderColor-default, #30363d); }
|
||||||
|
.border-bottom { border-bottom: 1px solid var(--borderColor-default, #30363d); }
|
||||||
|
.border-top { border-top: 1px solid var(--borderColor-default, #30363d); }
|
||||||
|
.border-right { border-right: 1px solid var(--borderColor-default, #30363d); }
|
||||||
|
.rounded-2 { border-radius: 6px; }
|
||||||
|
|
||||||
|
.overflow-auto { overflow: auto; }
|
||||||
|
.text-bold { font-weight: 600; }
|
||||||
|
.text-mono { font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; }
|
||||||
|
.text-small { font-size: 12px; }
|
||||||
|
|
||||||
|
.color-fg-muted { color: var(--fgColor-muted, #8b949e); }
|
||||||
|
.color-fg-danger { color: var(--fgColor-danger, #f85149); }
|
||||||
|
.color-fg-default { color: var(--fgColor-default, #e6edf3); }
|
||||||
|
|
||||||
|
/* Light mode overrides */
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
.dap-debugger-pane {
|
||||||
|
background-color: var(--bgColor-default, #ffffff);
|
||||||
|
border-color: var(--borderColor-default, #d0d7de) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dap-header,
|
||||||
|
.dap-scope-header,
|
||||||
|
.dap-repl-header,
|
||||||
|
.dap-controls {
|
||||||
|
background-color: var(--bgColor-muted, #f6f8fa);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dap-repl-output,
|
||||||
|
.dap-repl-input input {
|
||||||
|
background-color: var(--bgColor-inset, #f6f8fa) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dap-tree-node .text-bold {
|
||||||
|
color: var(--fgColor-default, #1f2328);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-fg-muted {
|
||||||
|
color: var(--fgColor-muted, #656d76);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Respect GitHub's color mode data attribute */
|
||||||
|
[data-color-mode="light"] .dap-debugger-pane,
|
||||||
|
html[data-color-mode="light"] .dap-debugger-pane {
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-color: #d0d7de !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-color-mode="light"] .dap-header,
|
||||||
|
[data-color-mode="light"] .dap-scope-header,
|
||||||
|
[data-color-mode="light"] .dap-repl-header,
|
||||||
|
[data-color-mode="light"] .dap-controls,
|
||||||
|
html[data-color-mode="light"] .dap-header,
|
||||||
|
html[data-color-mode="light"] .dap-scope-header,
|
||||||
|
html[data-color-mode="light"] .dap-repl-header,
|
||||||
|
html[data-color-mode="light"] .dap-controls {
|
||||||
|
background-color: #f6f8fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-color-mode="light"] .dap-repl-output,
|
||||||
|
[data-color-mode="light"] .dap-repl-input input,
|
||||||
|
html[data-color-mode="light"] .dap-repl-output,
|
||||||
|
html[data-color-mode="light"] .dap-repl-input input {
|
||||||
|
background-color: #f6f8fa !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Debug Button in Header */
|
||||||
|
.dap-debug-btn-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dap-debug-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dap-debug-btn.selected {
|
||||||
|
background-color: var(--bgColor-accent-muted, #388bfd26);
|
||||||
|
border-color: var(--borderColor-accent-emphasis, #388bfd);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dap-debug-btn:hover:not(:disabled) {
|
||||||
|
background-color: var(--bgColor-neutral-muted, #6e768166);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Light mode for debug button */
|
||||||
|
[data-color-mode="light"] .dap-debug-btn.selected,
|
||||||
|
html[data-color-mode="light"] .dap-debug-btn.selected {
|
||||||
|
background-color: #ddf4ff;
|
||||||
|
border-color: #54aeff;
|
||||||
|
}
|
||||||
767
browser-ext/content/content.js
Normal file
767
browser-ext/content/content.js
Normal file
@@ -0,0 +1,767 @@
|
|||||||
|
/**
|
||||||
|
* Content Script - Debugger UI
|
||||||
|
*
|
||||||
|
* Injects the debugger pane into GitHub Actions job pages and handles
|
||||||
|
* all UI interactions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// State
|
||||||
|
let debuggerPane = null;
|
||||||
|
let currentFrameId = 0;
|
||||||
|
let isConnected = false;
|
||||||
|
let replHistory = [];
|
||||||
|
let replHistoryIndex = -1;
|
||||||
|
|
||||||
|
// HTML escape helper
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip result indicator suffix from step name
|
||||||
|
* e.g., "Run tests [running]" -> "Run tests"
|
||||||
|
*/
|
||||||
|
function stripResultIndicator(name) {
|
||||||
|
return name.replace(/\s*\[(running|success|failure|skipped|cancelled)\]$/i, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send DAP request to background script
|
||||||
|
*/
|
||||||
|
function sendDapRequest(command, args = {}) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
chrome.runtime.sendMessage({ type: 'dap-request', command, args }, (response) => {
|
||||||
|
if (chrome.runtime.lastError) {
|
||||||
|
reject(new Error(chrome.runtime.lastError.message));
|
||||||
|
} else if (response && response.success) {
|
||||||
|
resolve(response.body);
|
||||||
|
} else {
|
||||||
|
reject(new Error(response?.error || 'Unknown error'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build map of steps from DOM
|
||||||
|
*/
|
||||||
|
function buildStepMap() {
|
||||||
|
const steps = document.querySelectorAll('check-step');
|
||||||
|
const map = new Map();
|
||||||
|
steps.forEach((el, idx) => {
|
||||||
|
map.set(idx, {
|
||||||
|
element: el,
|
||||||
|
number: parseInt(el.dataset.number),
|
||||||
|
name: el.dataset.name,
|
||||||
|
conclusion: el.dataset.conclusion,
|
||||||
|
externalId: el.dataset.externalId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find step element by name
|
||||||
|
*/
|
||||||
|
function findStepByName(stepName) {
|
||||||
|
return document.querySelector(`check-step[data-name="${CSS.escape(stepName)}"]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find step element by number
|
||||||
|
*/
|
||||||
|
function findStepByNumber(stepNumber) {
|
||||||
|
return document.querySelector(`check-step[data-number="${stepNumber}"]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all step elements
|
||||||
|
*/
|
||||||
|
function getAllSteps() {
|
||||||
|
return document.querySelectorAll('check-step');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the debugger pane HTML
|
||||||
|
*/
|
||||||
|
function createDebuggerPaneHTML() {
|
||||||
|
return `
|
||||||
|
<div class="dap-header d-flex flex-items-center p-2 border-bottom">
|
||||||
|
<svg class="octicon mr-2" viewBox="0 0 16 16" width="16" height="16">
|
||||||
|
<path fill="currentColor" d="M4.72.22a.75.75 0 0 1 1.06 0l1 1a.75.75 0 0 1-1.06 1.06l-.22-.22-.22.22a.75.75 0 0 1-1.06-1.06l1-1Z"/>
|
||||||
|
<path fill="currentColor" d="M11.28.22a.75.75 0 0 0-1.06 0l-1 1a.75.75 0 0 0 1.06 1.06l.22-.22.22.22a.75.75 0 0 0 1.06-1.06l-1-1Z"/>
|
||||||
|
<path fill="currentColor" d="M8 4a4 4 0 0 0-4 4v1h1v2.5a2.5 2.5 0 0 0 2.5 2.5h1a2.5 2.5 0 0 0 2.5-2.5V9h1V8a4 4 0 0 0-4-4Z"/>
|
||||||
|
<path fill="currentColor" d="M5 9H3.5a.5.5 0 0 0-.5.5v2a.5.5 0 0 0 .5.5H5V9ZM11 9h1.5a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.5.5H11V9Z"/>
|
||||||
|
</svg>
|
||||||
|
<span class="text-bold">Debugger</span>
|
||||||
|
<span class="dap-step-info color-fg-muted ml-2">Connecting...</span>
|
||||||
|
<span class="Label dap-status-label ml-auto">CONNECTING</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dap-content d-flex" style="height: 300px;">
|
||||||
|
<!-- Scopes Panel -->
|
||||||
|
<div class="dap-scopes border-right overflow-auto" style="width: 33%;">
|
||||||
|
<div class="dap-scope-header p-2 text-bold border-bottom">Variables</div>
|
||||||
|
<div class="dap-scope-tree p-2">
|
||||||
|
<div class="color-fg-muted">Connect to view variables</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- REPL Console -->
|
||||||
|
<div class="dap-repl d-flex flex-column" style="width: 67%;">
|
||||||
|
<div class="dap-repl-header p-2 text-bold border-bottom">Console</div>
|
||||||
|
<div class="dap-repl-output overflow-auto flex-auto p-2 text-mono text-small">
|
||||||
|
<div class="color-fg-muted">Welcome to Actions DAP Debugger</div>
|
||||||
|
<div class="color-fg-muted">Enter expressions like: \${{ github.ref }}</div>
|
||||||
|
<div class="color-fg-muted">Or shell commands: !ls -la</div>
|
||||||
|
</div>
|
||||||
|
<div class="dap-repl-input border-top p-2">
|
||||||
|
<input type="text" class="form-control input-sm text-mono"
|
||||||
|
placeholder="Enter expression or !command" disabled>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Control buttons -->
|
||||||
|
<div class="dap-controls d-flex flex-items-center p-2 border-top">
|
||||||
|
<button class="btn btn-sm mr-2" data-action="reverseContinue" title="Reverse Continue (go to first checkpoint)" disabled>
|
||||||
|
<svg viewBox="0 0 16 16" width="16" height="16"><path fill="currentColor" d="M2 2v12h2V8.5l5 4V8.5l5 4V2.5l-5 4V2.5l-5 4V2z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm mr-2" data-action="stepBack" title="Step Back" disabled>
|
||||||
|
<svg viewBox="0 0 16 16" width="16" height="16"><path fill="currentColor" d="M2 2v12h2V2H2zm3 6 7 5V3L5 8z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-primary mr-2" data-action="continue" title="Continue" disabled>
|
||||||
|
<svg viewBox="0 0 16 16" width="16" height="16"><path fill="currentColor" d="M4 2l10 6-10 6z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm mr-2" data-action="next" title="Step to Next" disabled>
|
||||||
|
<svg viewBox="0 0 16 16" width="16" height="16"><path fill="currentColor" d="M2 3l7 5-7 5V3zm7 5l5 0V2h2v12h-2V8.5l-5 0z"/></svg>
|
||||||
|
</button>
|
||||||
|
<span class="dap-step-counter color-fg-muted ml-auto text-small">
|
||||||
|
Not connected
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inject debugger pane into the page
|
||||||
|
*/
|
||||||
|
function injectDebuggerPane() {
|
||||||
|
// Remove existing pane if any
|
||||||
|
const existing = document.querySelector('.dap-debugger-pane');
|
||||||
|
if (existing) existing.remove();
|
||||||
|
|
||||||
|
// Find where to inject
|
||||||
|
const stepsContainer = document.querySelector('check-steps');
|
||||||
|
if (!stepsContainer) {
|
||||||
|
console.warn('[Content] No check-steps container found');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create pane
|
||||||
|
const pane = document.createElement('div');
|
||||||
|
pane.className = 'dap-debugger-pane mx-2 mb-2 border rounded-2';
|
||||||
|
pane.innerHTML = createDebuggerPaneHTML();
|
||||||
|
|
||||||
|
// Insert before the first real workflow step (skip "Set up job" at index 0)
|
||||||
|
const steps = stepsContainer.querySelectorAll('check-step');
|
||||||
|
const targetStep = steps.length > 1 ? steps[1] : stepsContainer.firstChild;
|
||||||
|
stepsContainer.insertBefore(pane, targetStep);
|
||||||
|
|
||||||
|
// Setup event handlers
|
||||||
|
setupPaneEventHandlers(pane);
|
||||||
|
|
||||||
|
debuggerPane = pane;
|
||||||
|
return pane;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move debugger pane to before a specific step
|
||||||
|
*/
|
||||||
|
function moveDebuggerPane(stepElement, stepName) {
|
||||||
|
if (!debuggerPane || !stepElement) return;
|
||||||
|
|
||||||
|
// Move the pane
|
||||||
|
stepElement.parentNode.insertBefore(debuggerPane, stepElement);
|
||||||
|
|
||||||
|
// Update step info
|
||||||
|
const stepInfo = debuggerPane.querySelector('.dap-step-info');
|
||||||
|
if (stepInfo) {
|
||||||
|
stepInfo.textContent = `Paused before: ${stepName}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup event handlers for debugger pane
|
||||||
|
*/
|
||||||
|
function setupPaneEventHandlers(pane) {
|
||||||
|
// Control buttons
|
||||||
|
pane.querySelectorAll('[data-action]').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const action = btn.dataset.action;
|
||||||
|
enableControls(false);
|
||||||
|
updateStatus('RUNNING');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendDapRequest(action, { threadId: 1 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[Content] DAP ${action} failed:`, error);
|
||||||
|
appendOutput(`Error: ${error.message}`, 'error');
|
||||||
|
enableControls(true);
|
||||||
|
updateStatus('ERROR');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// REPL input
|
||||||
|
const input = pane.querySelector('.dap-repl-input input');
|
||||||
|
if (input) {
|
||||||
|
input.addEventListener('keydown', handleReplKeydown);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle REPL input keydown
|
||||||
|
*/
|
||||||
|
async function handleReplKeydown(e) {
|
||||||
|
const input = e.target;
|
||||||
|
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
const command = input.value.trim();
|
||||||
|
if (!command) return;
|
||||||
|
|
||||||
|
replHistory.push(command);
|
||||||
|
replHistoryIndex = replHistory.length;
|
||||||
|
input.value = '';
|
||||||
|
|
||||||
|
// Show command
|
||||||
|
appendOutput(`> ${command}`, 'input');
|
||||||
|
|
||||||
|
// Send to DAP
|
||||||
|
try {
|
||||||
|
const response = await sendDapRequest('evaluate', {
|
||||||
|
expression: command,
|
||||||
|
frameId: currentFrameId,
|
||||||
|
context: command.startsWith('!') ? 'repl' : 'watch',
|
||||||
|
});
|
||||||
|
// Only show result if it's NOT an exit code summary
|
||||||
|
// (shell command output is already streamed via output events)
|
||||||
|
if (response.result && !/^\(exit code: -?\d+\)$/.test(response.result)) {
|
||||||
|
appendOutput(response.result, 'result');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
appendOutput(error.message, 'error');
|
||||||
|
}
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
if (replHistoryIndex > 0) {
|
||||||
|
replHistoryIndex--;
|
||||||
|
input.value = replHistory[replHistoryIndex];
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
} else if (e.key === 'ArrowDown') {
|
||||||
|
if (replHistoryIndex < replHistory.length - 1) {
|
||||||
|
replHistoryIndex++;
|
||||||
|
input.value = replHistory[replHistoryIndex];
|
||||||
|
} else {
|
||||||
|
replHistoryIndex = replHistory.length;
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append output to REPL console
|
||||||
|
*/
|
||||||
|
function appendOutput(text, type) {
|
||||||
|
const output = document.querySelector('.dap-repl-output');
|
||||||
|
if (!output) return;
|
||||||
|
|
||||||
|
// Handle multi-line output - each line gets its own div
|
||||||
|
const lines = text.split('\n');
|
||||||
|
lines.forEach((l) => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = `dap-output-${type}`;
|
||||||
|
if (type === 'error') div.classList.add('color-fg-danger');
|
||||||
|
if (type === 'input') div.classList.add('color-fg-muted');
|
||||||
|
div.textContent = l;
|
||||||
|
output.appendChild(div);
|
||||||
|
});
|
||||||
|
|
||||||
|
output.scrollTop = output.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable/disable control buttons
|
||||||
|
*/
|
||||||
|
function enableControls(enabled) {
|
||||||
|
if (!debuggerPane) return;
|
||||||
|
|
||||||
|
debuggerPane.querySelectorAll('.dap-controls button').forEach((btn) => {
|
||||||
|
btn.disabled = !enabled;
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = debuggerPane.querySelector('.dap-repl-input input');
|
||||||
|
if (input) {
|
||||||
|
input.disabled = !enabled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update status display
|
||||||
|
*/
|
||||||
|
function updateStatus(status, extra) {
|
||||||
|
if (!debuggerPane) return;
|
||||||
|
|
||||||
|
const label = debuggerPane.querySelector('.dap-status-label');
|
||||||
|
if (label) {
|
||||||
|
label.textContent = status;
|
||||||
|
label.className = 'Label dap-status-label ml-auto ';
|
||||||
|
|
||||||
|
switch (status) {
|
||||||
|
case 'PAUSED':
|
||||||
|
label.classList.add('Label--attention');
|
||||||
|
break;
|
||||||
|
case 'RUNNING':
|
||||||
|
label.classList.add('Label--success');
|
||||||
|
break;
|
||||||
|
case 'TERMINATED':
|
||||||
|
case 'DISCONNECTED':
|
||||||
|
label.classList.add('Label--secondary');
|
||||||
|
break;
|
||||||
|
case 'ERROR':
|
||||||
|
label.classList.add('Label--danger');
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
label.classList.add('Label--secondary');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update step counter if extra info provided
|
||||||
|
if (extra) {
|
||||||
|
const counter = debuggerPane.querySelector('.dap-step-counter');
|
||||||
|
if (counter) {
|
||||||
|
counter.textContent = extra;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load scopes for current frame
|
||||||
|
*/
|
||||||
|
async function loadScopes(frameId) {
|
||||||
|
const scopesContainer = document.querySelector('.dap-scope-tree');
|
||||||
|
if (!scopesContainer) return;
|
||||||
|
|
||||||
|
scopesContainer.innerHTML = '<div class="color-fg-muted">Loading...</div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('[Content] Loading scopes for frame:', frameId);
|
||||||
|
const response = await sendDapRequest('scopes', { frameId });
|
||||||
|
console.log('[Content] Scopes response:', response);
|
||||||
|
|
||||||
|
scopesContainer.innerHTML = '';
|
||||||
|
|
||||||
|
if (!response.scopes || response.scopes.length === 0) {
|
||||||
|
scopesContainer.innerHTML = '<div class="color-fg-muted">No scopes available</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const scope of response.scopes) {
|
||||||
|
console.log('[Content] Creating tree node for scope:', scope.name, 'variablesRef:', scope.variablesReference);
|
||||||
|
// Only mark as expandable if variablesReference > 0
|
||||||
|
const isExpandable = scope.variablesReference > 0;
|
||||||
|
const node = createTreeNode(scope.name, scope.variablesReference, isExpandable);
|
||||||
|
scopesContainer.appendChild(node);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Content] Failed to load scopes:', error);
|
||||||
|
scopesContainer.innerHTML = `<div class="color-fg-danger">Error: ${escapeHtml(error.message)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a tree node for scope/variable display
|
||||||
|
*/
|
||||||
|
function createTreeNode(name, variablesReference, isExpandable, value) {
|
||||||
|
const node = document.createElement('div');
|
||||||
|
node.className = 'dap-tree-node';
|
||||||
|
node.dataset.variablesRef = variablesReference;
|
||||||
|
|
||||||
|
const content = document.createElement('div');
|
||||||
|
content.className = 'dap-tree-content';
|
||||||
|
|
||||||
|
// Expand icon
|
||||||
|
const expandIcon = document.createElement('span');
|
||||||
|
expandIcon.className = 'dap-expand-icon';
|
||||||
|
expandIcon.textContent = isExpandable ? '\u25B6' : ' '; // ▶ or space
|
||||||
|
content.appendChild(expandIcon);
|
||||||
|
|
||||||
|
// Name
|
||||||
|
const nameSpan = document.createElement('span');
|
||||||
|
nameSpan.className = 'text-bold';
|
||||||
|
nameSpan.textContent = name;
|
||||||
|
content.appendChild(nameSpan);
|
||||||
|
|
||||||
|
// Value (if provided)
|
||||||
|
if (value !== undefined) {
|
||||||
|
const valueSpan = document.createElement('span');
|
||||||
|
valueSpan.className = 'color-fg-muted';
|
||||||
|
valueSpan.textContent = `: ${value}`;
|
||||||
|
content.appendChild(valueSpan);
|
||||||
|
}
|
||||||
|
|
||||||
|
node.appendChild(content);
|
||||||
|
|
||||||
|
if (isExpandable && variablesReference > 0) {
|
||||||
|
content.style.cursor = 'pointer';
|
||||||
|
content.addEventListener('click', () => toggleTreeNode(node));
|
||||||
|
}
|
||||||
|
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle tree node expansion
|
||||||
|
*/
|
||||||
|
async function toggleTreeNode(node) {
|
||||||
|
const children = node.querySelector('.dap-tree-children');
|
||||||
|
const expandIcon = node.querySelector('.dap-expand-icon');
|
||||||
|
|
||||||
|
if (children) {
|
||||||
|
// Toggle visibility
|
||||||
|
children.hidden = !children.hidden;
|
||||||
|
expandIcon.textContent = children.hidden ? '\u25B6' : '\u25BC'; // ▶ or ▼
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch children
|
||||||
|
const variablesRef = parseInt(node.dataset.variablesRef);
|
||||||
|
if (!variablesRef) return;
|
||||||
|
|
||||||
|
expandIcon.textContent = '...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await sendDapRequest('variables', { variablesReference: variablesRef });
|
||||||
|
|
||||||
|
const childContainer = document.createElement('div');
|
||||||
|
childContainer.className = 'dap-tree-children ml-3';
|
||||||
|
|
||||||
|
for (const variable of response.variables) {
|
||||||
|
const hasChildren = variable.variablesReference > 0;
|
||||||
|
const childNode = createTreeNode(
|
||||||
|
variable.name,
|
||||||
|
variable.variablesReference,
|
||||||
|
hasChildren,
|
||||||
|
variable.value
|
||||||
|
);
|
||||||
|
childContainer.appendChild(childNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
node.appendChild(childContainer);
|
||||||
|
expandIcon.textContent = '\u25BC'; // ▼
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Content] Failed to load variables:', error);
|
||||||
|
expandIcon.textContent = '\u25B6'; // ▶
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle stopped event from DAP
|
||||||
|
*/
|
||||||
|
async function handleStoppedEvent(body) {
|
||||||
|
console.log('[Content] Stopped event:', body);
|
||||||
|
|
||||||
|
isConnected = true;
|
||||||
|
updateStatus('PAUSED', body.reason || 'paused');
|
||||||
|
enableControls(true);
|
||||||
|
|
||||||
|
// Get current location
|
||||||
|
try {
|
||||||
|
const stackTrace = await sendDapRequest('stackTrace', { threadId: 1 });
|
||||||
|
|
||||||
|
if (stackTrace.stackFrames && stackTrace.stackFrames.length > 0) {
|
||||||
|
const currentFrame = stackTrace.stackFrames[0];
|
||||||
|
currentFrameId = currentFrame.id;
|
||||||
|
|
||||||
|
// Strip result indicator from step name for DOM lookup
|
||||||
|
// e.g., "Run tests [running]" -> "Run tests"
|
||||||
|
const rawStepName = stripResultIndicator(currentFrame.name);
|
||||||
|
let stepElement = findStepByName(rawStepName);
|
||||||
|
|
||||||
|
if (!stepElement) {
|
||||||
|
// Fallback: use step index
|
||||||
|
// Note: GitHub Actions UI shows "Set up job" at index 0, which is not a real workflow step
|
||||||
|
// DAP uses 1-based frame IDs, so frame ID 1 maps to UI step index 1 (skipping "Set up job")
|
||||||
|
const steps = getAllSteps();
|
||||||
|
const adjustedIndex = currentFrame.id; // 1-based, happens to match after skipping "Set up job"
|
||||||
|
if (adjustedIndex > 0 && adjustedIndex < steps.length) {
|
||||||
|
stepElement = steps[adjustedIndex];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stepElement) {
|
||||||
|
moveDebuggerPane(stepElement, rawStepName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update step counter
|
||||||
|
const counter = debuggerPane?.querySelector('.dap-step-counter');
|
||||||
|
if (counter) {
|
||||||
|
counter.textContent = `Step ${currentFrame.id} of ${stackTrace.stackFrames.length}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load scopes
|
||||||
|
await loadScopes(currentFrame.id);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Content] Failed to get stack trace:', error);
|
||||||
|
appendOutput(`Error: ${error.message}`, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle output event from DAP
|
||||||
|
*/
|
||||||
|
function handleOutputEvent(body) {
|
||||||
|
if (body.output) {
|
||||||
|
const category = body.category === 'stderr' ? 'error' : 'stdout';
|
||||||
|
appendOutput(body.output.trimEnd(), category);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle terminated event from DAP
|
||||||
|
*/
|
||||||
|
function handleTerminatedEvent() {
|
||||||
|
isConnected = false;
|
||||||
|
updateStatus('TERMINATED');
|
||||||
|
enableControls(false);
|
||||||
|
|
||||||
|
const stepInfo = debuggerPane?.querySelector('.dap-step-info');
|
||||||
|
if (stepInfo) {
|
||||||
|
stepInfo.textContent = 'Session ended';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load current debug state (used when page loads while already paused)
|
||||||
|
*/
|
||||||
|
async function loadCurrentDebugState() {
|
||||||
|
if (!debuggerPane) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stackTrace = await sendDapRequest('stackTrace', { threadId: 1 });
|
||||||
|
if (stackTrace.stackFrames && stackTrace.stackFrames.length > 0) {
|
||||||
|
const currentFrame = stackTrace.stackFrames[0];
|
||||||
|
currentFrameId = currentFrame.id;
|
||||||
|
|
||||||
|
// Move pane to current step
|
||||||
|
// Strip result indicator from step name for DOM lookup
|
||||||
|
const rawStepName = stripResultIndicator(currentFrame.name);
|
||||||
|
let stepElement = findStepByName(rawStepName);
|
||||||
|
|
||||||
|
if (!stepElement) {
|
||||||
|
// Fallback: use step index (skip "Set up job" at index 0)
|
||||||
|
const steps = getAllSteps();
|
||||||
|
const adjustedIndex = currentFrame.id; // 1-based, matches after skipping "Set up job"
|
||||||
|
if (adjustedIndex > 0 && adjustedIndex < steps.length) {
|
||||||
|
stepElement = steps[adjustedIndex];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stepElement) {
|
||||||
|
moveDebuggerPane(stepElement, rawStepName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update step counter
|
||||||
|
const counter = debuggerPane.querySelector('.dap-step-counter');
|
||||||
|
if (counter) {
|
||||||
|
counter.textContent = `Step ${currentFrame.id + 1} of ${stackTrace.stackFrames.length}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load scopes
|
||||||
|
await loadScopes(currentFrame.id);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Content] Failed to load current debug state:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle status change from background
|
||||||
|
*/
|
||||||
|
function handleStatusChange(status) {
|
||||||
|
console.log('[Content] Status changed:', status);
|
||||||
|
|
||||||
|
switch (status) {
|
||||||
|
case 'connected':
|
||||||
|
isConnected = true;
|
||||||
|
updateStatus('CONNECTED');
|
||||||
|
const stepInfo = debuggerPane?.querySelector('.dap-step-info');
|
||||||
|
if (stepInfo) {
|
||||||
|
stepInfo.textContent = 'Waiting for debug event...';
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'paused':
|
||||||
|
isConnected = true;
|
||||||
|
updateStatus('PAUSED');
|
||||||
|
enableControls(true);
|
||||||
|
loadCurrentDebugState();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'running':
|
||||||
|
isConnected = true;
|
||||||
|
updateStatus('RUNNING');
|
||||||
|
enableControls(false);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'disconnected':
|
||||||
|
isConnected = false;
|
||||||
|
updateStatus('DISCONNECTED');
|
||||||
|
enableControls(false);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'error':
|
||||||
|
isConnected = false;
|
||||||
|
updateStatus('ERROR');
|
||||||
|
enableControls(false);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listen for messages from background script
|
||||||
|
*/
|
||||||
|
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||||
|
console.log('[Content] Received message:', message.type);
|
||||||
|
|
||||||
|
switch (message.type) {
|
||||||
|
case 'dap-event':
|
||||||
|
const event = message.event;
|
||||||
|
switch (event.event) {
|
||||||
|
case 'stopped':
|
||||||
|
handleStoppedEvent(event.body);
|
||||||
|
break;
|
||||||
|
case 'output':
|
||||||
|
handleOutputEvent(event.body);
|
||||||
|
break;
|
||||||
|
case 'terminated':
|
||||||
|
handleTerminatedEvent();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'status-changed':
|
||||||
|
handleStatusChange(message.status);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inject debug button into GitHub Actions UI header
|
||||||
|
*/
|
||||||
|
function injectDebugButton() {
|
||||||
|
const container = document.querySelector('.js-check-run-search');
|
||||||
|
if (!container || container.querySelector('.dap-debug-btn-container')) {
|
||||||
|
return; // Already injected or container not found
|
||||||
|
}
|
||||||
|
|
||||||
|
const buttonContainer = document.createElement('div');
|
||||||
|
buttonContainer.className = 'ml-2 dap-debug-btn-container';
|
||||||
|
buttonContainer.innerHTML = `
|
||||||
|
<button type="button" class="btn btn-sm dap-debug-btn" title="Toggle DAP Debugger">
|
||||||
|
<svg viewBox="0 0 16 16" width="16" height="16" class="octicon mr-1" style="vertical-align: text-bottom;">
|
||||||
|
<path fill="currentColor" d="M4.72.22a.75.75 0 0 1 1.06 0l1 1a.75.75 0 0 1-1.06 1.06l-.22-.22-.22.22a.75.75 0 0 1-1.06-1.06l1-1Z"/>
|
||||||
|
<path fill="currentColor" d="M11.28.22a.75.75 0 0 0-1.06 0l-1 1a.75.75 0 0 0 1.06 1.06l.22-.22.22.22a.75.75 0 0 0 1.06-1.06l-1-1Z"/>
|
||||||
|
<path fill="currentColor" d="M8 4a4 4 0 0 0-4 4v1h1v2.5a2.5 2.5 0 0 0 2.5 2.5h1a2.5 2.5 0 0 0 2.5-2.5V9h1V8a4 4 0 0 0-4-4Z"/>
|
||||||
|
<path fill="currentColor" d="M5 9H3.5a.5.5 0 0 0-.5.5v2a.5.5 0 0 0 .5.5H5V9ZM11 9h1.5a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.5.5H11V9Z"/>
|
||||||
|
</svg>
|
||||||
|
Debug
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const button = buttonContainer.querySelector('button');
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
let pane = document.querySelector('.dap-debugger-pane');
|
||||||
|
if (pane) {
|
||||||
|
// Toggle visibility
|
||||||
|
pane.hidden = !pane.hidden;
|
||||||
|
button.classList.toggle('selected', !pane.hidden);
|
||||||
|
} else {
|
||||||
|
// Create and show pane
|
||||||
|
pane = injectDebuggerPane();
|
||||||
|
if (pane) {
|
||||||
|
button.classList.add('selected');
|
||||||
|
// Check connection status after creating pane
|
||||||
|
chrome.runtime.sendMessage({ type: 'get-status' }, (response) => {
|
||||||
|
if (response && response.status) {
|
||||||
|
handleStatusChange(response.status);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Insert at the beginning of the container
|
||||||
|
container.insertBefore(buttonContainer, container.firstChild);
|
||||||
|
console.log('[Content] Debug button injected');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize content script
|
||||||
|
*/
|
||||||
|
function init() {
|
||||||
|
console.log('[Content] Actions DAP Debugger content script loaded');
|
||||||
|
|
||||||
|
// Check if we're on a job page
|
||||||
|
const steps = getAllSteps();
|
||||||
|
if (steps.length === 0) {
|
||||||
|
console.log('[Content] No steps found, waiting for DOM...');
|
||||||
|
// Wait for steps to appear
|
||||||
|
const observer = new MutationObserver((mutations) => {
|
||||||
|
const steps = getAllSteps();
|
||||||
|
if (steps.length > 0) {
|
||||||
|
observer.disconnect();
|
||||||
|
console.log('[Content] Steps found, injecting debug button');
|
||||||
|
injectDebugButton();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
observer.observe(document.body, { childList: true, subtree: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject debug button in header (user can click to show debugger pane)
|
||||||
|
injectDebugButton();
|
||||||
|
|
||||||
|
// Check current connection status
|
||||||
|
chrome.runtime.sendMessage({ type: 'get-status' }, async (response) => {
|
||||||
|
if (response && response.status) {
|
||||||
|
handleStatusChange(response.status);
|
||||||
|
|
||||||
|
// If already connected/paused, auto-show the debugger pane
|
||||||
|
if (response.status === 'paused' || response.status === 'connected') {
|
||||||
|
const pane = document.querySelector('.dap-debugger-pane');
|
||||||
|
if (!pane) {
|
||||||
|
injectDebuggerPane();
|
||||||
|
const btn = document.querySelector('.dap-debug-btn');
|
||||||
|
if (btn) btn.classList.add('selected');
|
||||||
|
}
|
||||||
|
|
||||||
|
// If already paused, load the current debug state
|
||||||
|
if (response.status === 'paused') {
|
||||||
|
await loadCurrentDebugState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize when DOM is ready
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
135
browser-ext/icons/generate.js
Normal file
135
browser-ext/icons/generate.js
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Create simple green circle PNG icons
|
||||||
|
* No dependencies required - uses pure JavaScript to create valid PNG files
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const zlib = require('zlib');
|
||||||
|
|
||||||
|
function createPNG(size) {
|
||||||
|
// PNG uses RGBA format, one pixel = 4 bytes
|
||||||
|
const pixelData = [];
|
||||||
|
|
||||||
|
const centerX = size / 2;
|
||||||
|
const centerY = size / 2;
|
||||||
|
const radius = size / 2 - 1;
|
||||||
|
const innerRadius = radius * 0.4;
|
||||||
|
|
||||||
|
for (let y = 0; y < size; y++) {
|
||||||
|
pixelData.push(0); // Filter byte for each row
|
||||||
|
for (let x = 0; x < size; x++) {
|
||||||
|
const dx = x - centerX;
|
||||||
|
const dy = y - centerY;
|
||||||
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
|
||||||
|
if (dist <= radius) {
|
||||||
|
// Green circle (#238636)
|
||||||
|
pixelData.push(35, 134, 54, 255);
|
||||||
|
} else {
|
||||||
|
// Transparent
|
||||||
|
pixelData.push(0, 0, 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a white "bug" shape in the center
|
||||||
|
for (let y = 0; y < size; y++) {
|
||||||
|
for (let x = 0; x < size; x++) {
|
||||||
|
const dx = x - centerX;
|
||||||
|
const dy = y - centerY;
|
||||||
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
|
||||||
|
// Bug body (oval)
|
||||||
|
const bodyDx = dx;
|
||||||
|
const bodyDy = (dy - size * 0.05) / 1.3;
|
||||||
|
const bodyDist = Math.sqrt(bodyDx * bodyDx + bodyDy * bodyDy);
|
||||||
|
|
||||||
|
// Bug head (circle above body)
|
||||||
|
const headDx = dx;
|
||||||
|
const headDy = dy + size * 0.15;
|
||||||
|
const headDist = Math.sqrt(headDx * headDx + headDy * headDy);
|
||||||
|
|
||||||
|
if (bodyDist < innerRadius || headDist < innerRadius * 0.6) {
|
||||||
|
const idx = 1 + y * (1 + size * 4) + x * 4;
|
||||||
|
pixelData[idx] = 255;
|
||||||
|
pixelData[idx + 1] = 255;
|
||||||
|
pixelData[idx + 2] = 255;
|
||||||
|
pixelData[idx + 3] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawData = Buffer.from(pixelData);
|
||||||
|
const compressed = zlib.deflateSync(rawData);
|
||||||
|
|
||||||
|
// Build PNG file
|
||||||
|
const chunks = [];
|
||||||
|
|
||||||
|
// PNG signature
|
||||||
|
chunks.push(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
|
||||||
|
|
||||||
|
// IHDR chunk
|
||||||
|
const ihdr = Buffer.alloc(13);
|
||||||
|
ihdr.writeUInt32BE(size, 0); // width
|
||||||
|
ihdr.writeUInt32BE(size, 4); // height
|
||||||
|
ihdr.writeUInt8(8, 8); // bit depth
|
||||||
|
ihdr.writeUInt8(6, 9); // color type (RGBA)
|
||||||
|
ihdr.writeUInt8(0, 10); // compression
|
||||||
|
ihdr.writeUInt8(0, 11); // filter
|
||||||
|
ihdr.writeUInt8(0, 12); // interlace
|
||||||
|
chunks.push(createChunk('IHDR', ihdr));
|
||||||
|
|
||||||
|
// IDAT chunk
|
||||||
|
chunks.push(createChunk('IDAT', compressed));
|
||||||
|
|
||||||
|
// IEND chunk
|
||||||
|
chunks.push(createChunk('IEND', Buffer.alloc(0)));
|
||||||
|
|
||||||
|
return Buffer.concat(chunks);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createChunk(type, data) {
|
||||||
|
const typeBuffer = Buffer.from(type);
|
||||||
|
const length = Buffer.alloc(4);
|
||||||
|
length.writeUInt32BE(data.length, 0);
|
||||||
|
|
||||||
|
const crcData = Buffer.concat([typeBuffer, data]);
|
||||||
|
const crc = Buffer.alloc(4);
|
||||||
|
crc.writeUInt32BE(crc32(crcData), 0);
|
||||||
|
|
||||||
|
return Buffer.concat([length, typeBuffer, data, crc]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRC32 implementation
|
||||||
|
function crc32(buf) {
|
||||||
|
let crc = 0xffffffff;
|
||||||
|
for (let i = 0; i < buf.length; i++) {
|
||||||
|
crc = crc32Table[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8);
|
||||||
|
}
|
||||||
|
return (crc ^ 0xffffffff) >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRC32 lookup table
|
||||||
|
const crc32Table = new Uint32Array(256);
|
||||||
|
for (let i = 0; i < 256; i++) {
|
||||||
|
let c = i;
|
||||||
|
for (let j = 0; j < 8; j++) {
|
||||||
|
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||||
|
}
|
||||||
|
crc32Table[i] = c;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate icons
|
||||||
|
const iconsDir = path.join(__dirname);
|
||||||
|
const sizes = [16, 48, 128];
|
||||||
|
|
||||||
|
sizes.forEach((size) => {
|
||||||
|
const png = createPNG(size);
|
||||||
|
const filename = `icon${size}.png`;
|
||||||
|
fs.writeFileSync(path.join(iconsDir, filename), png);
|
||||||
|
console.log(`Created ${filename} (${size}x${size})`);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Done!');
|
||||||
BIN
browser-ext/icons/icon128.png
Normal file
BIN
browser-ext/icons/icon128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 872 B |
BIN
browser-ext/icons/icon16.png
Normal file
BIN
browser-ext/icons/icon16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 126 B |
BIN
browser-ext/icons/icon48.png
Normal file
BIN
browser-ext/icons/icon48.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 258 B |
32
browser-ext/manifest.json
Normal file
32
browser-ext/manifest.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"manifest_version": 3,
|
||||||
|
"name": "Actions DAP Debugger",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Debug GitHub Actions workflows with DAP - interactive debugging directly in the browser",
|
||||||
|
"permissions": ["activeTab", "storage"],
|
||||||
|
"host_permissions": ["https://github.com/*"],
|
||||||
|
"background": {
|
||||||
|
"service_worker": "background/background.js"
|
||||||
|
},
|
||||||
|
"content_scripts": [
|
||||||
|
{
|
||||||
|
"matches": ["https://github.com/*/*/actions/runs/*/job/*"],
|
||||||
|
"js": ["lib/dap-protocol.js", "content/content.js"],
|
||||||
|
"css": ["content/content.css"],
|
||||||
|
"run_at": "document_idle"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"action": {
|
||||||
|
"default_popup": "popup/popup.html",
|
||||||
|
"default_icon": {
|
||||||
|
"16": "icons/icon16.png",
|
||||||
|
"48": "icons/icon48.png",
|
||||||
|
"128": "icons/icon128.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"icons": {
|
||||||
|
"16": "icons/icon16.png",
|
||||||
|
"48": "icons/icon48.png",
|
||||||
|
"128": "icons/icon128.png"
|
||||||
|
}
|
||||||
|
}
|
||||||
228
browser-ext/popup/popup.css
Normal file
228
browser-ext/popup/popup.css
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
/**
|
||||||
|
* Popup Styles
|
||||||
|
*
|
||||||
|
* GitHub-inspired dark theme for the extension popup.
|
||||||
|
*/
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
width: 320px;
|
||||||
|
padding: 16px;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
background-color: #0d1117;
|
||||||
|
color: #e6edf3;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 .icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status Section */
|
||||||
|
.status-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 12px;
|
||||||
|
background-color: #161b22;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #30363d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-disconnected {
|
||||||
|
background-color: #6e7681;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-connecting {
|
||||||
|
background-color: #9e6a03;
|
||||||
|
animation: pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-connected {
|
||||||
|
background-color: #238636;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-paused {
|
||||||
|
background-color: #9e6a03;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-running {
|
||||||
|
background-color: #238636;
|
||||||
|
animation: pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-error {
|
||||||
|
background-color: #da3633;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#status-text {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Config Section */
|
||||||
|
.config-section {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-section label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #8b949e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-section input {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin-top: 6px;
|
||||||
|
background-color: #0d1117;
|
||||||
|
border: 1px solid #30363d;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #e6edf3;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-section input:focus {
|
||||||
|
border-color: #1f6feb;
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 3px rgba(31, 111, 235, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-section input:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #6e7681;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Actions Section */
|
||||||
|
.actions-section {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: #238636;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background-color: #2ea043;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background-color: #21262d;
|
||||||
|
color: #e6edf3;
|
||||||
|
border: 1px solid #30363d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover:not(:disabled) {
|
||||||
|
background-color: #30363d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Help Section */
|
||||||
|
.help-section {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #8b949e;
|
||||||
|
background-color: #161b22;
|
||||||
|
border: 1px solid #30363d;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-section p {
|
||||||
|
margin: 6px 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-section p:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-section strong {
|
||||||
|
color: #e6edf3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-section code {
|
||||||
|
display: block;
|
||||||
|
background-color: #0d1117;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 8px 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
.footer {
|
||||||
|
text-align: center;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid #21262d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer a {
|
||||||
|
color: #58a6ff;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
52
browser-ext/popup/popup.html
Normal file
52
browser-ext/popup/popup.html
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<link rel="stylesheet" href="popup.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="popup-container">
|
||||||
|
<h3>
|
||||||
|
<svg class="icon" viewBox="0 0 16 16" width="16" height="16">
|
||||||
|
<path fill="currentColor" d="M4.72.22a.75.75 0 0 1 1.06 0l1 1a.75.75 0 0 1-1.06 1.06l-.22-.22-.22.22a.75.75 0 0 1-1.06-1.06l1-1Z"/>
|
||||||
|
<path fill="currentColor" d="M11.28.22a.75.75 0 0 0-1.06 0l-1 1a.75.75 0 0 0 1.06 1.06l.22-.22.22.22a.75.75 0 0 0 1.06-1.06l-1-1Z"/>
|
||||||
|
<path fill="currentColor" d="M8 4a4 4 0 0 0-4 4v1h1v2.5a2.5 2.5 0 0 0 2.5 2.5h1a2.5 2.5 0 0 0 2.5-2.5V9h1V8a4 4 0 0 0-4-4Z"/>
|
||||||
|
<path fill="currentColor" d="M5 9H3.5a.5.5 0 0 0-.5.5v2a.5.5 0 0 0 .5.5H5V9ZM11 9h1.5a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.5.5H11V9Z"/>
|
||||||
|
</svg>
|
||||||
|
Actions DAP Debugger
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="status-section">
|
||||||
|
<div class="status-indicator" id="status-indicator"></div>
|
||||||
|
<span id="status-text">Disconnected</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="config-section">
|
||||||
|
<label>
|
||||||
|
Proxy URL
|
||||||
|
<input type="text" id="proxy-url" value="ws://localhost:4712"
|
||||||
|
placeholder="ws://localhost:4712 or wss://...">
|
||||||
|
</label>
|
||||||
|
<p class="config-hint">For codespaces, use the forwarded URL (wss://...)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions-section">
|
||||||
|
<button id="connect-btn" class="btn-primary">Connect</button>
|
||||||
|
<button id="disconnect-btn" class="btn-secondary" disabled>Disconnect</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help-section">
|
||||||
|
<p><strong>Quick Start:</strong></p>
|
||||||
|
<p>1. Start the proxy:</p>
|
||||||
|
<code>cd browser-ext/proxy && npm install && node proxy.js</code>
|
||||||
|
<p>2. Re-run your GitHub Actions job with "Enable debug logging"</p>
|
||||||
|
<p>3. Click Connect when the job is waiting for debugger</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<a href="https://github.com/actions/runner" target="_blank">Documentation</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="popup.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
95
browser-ext/popup/popup.js
Normal file
95
browser-ext/popup/popup.js
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* Popup Script
|
||||||
|
*
|
||||||
|
* Handles extension popup UI and connection management.
|
||||||
|
*/
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const statusIndicator = document.getElementById('status-indicator');
|
||||||
|
const statusText = document.getElementById('status-text');
|
||||||
|
const connectBtn = document.getElementById('connect-btn');
|
||||||
|
const disconnectBtn = document.getElementById('disconnect-btn');
|
||||||
|
const urlInput = document.getElementById('proxy-url');
|
||||||
|
|
||||||
|
// Load saved config
|
||||||
|
chrome.storage.local.get(['proxyUrl'], (data) => {
|
||||||
|
if (data.proxyUrl) urlInput.value = data.proxyUrl;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get current status from background
|
||||||
|
chrome.runtime.sendMessage({ type: 'get-status' }, (response) => {
|
||||||
|
if (response) {
|
||||||
|
updateStatusUI(response.status, response.reconnecting);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for status changes
|
||||||
|
chrome.runtime.onMessage.addListener((message) => {
|
||||||
|
if (message.type === 'status-changed') {
|
||||||
|
updateStatusUI(message.status, message.reconnecting);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Connect button
|
||||||
|
connectBtn.addEventListener('click', () => {
|
||||||
|
const url = urlInput.value.trim() || 'ws://localhost:4712';
|
||||||
|
|
||||||
|
// Save config
|
||||||
|
chrome.storage.local.set({ proxyUrl: url });
|
||||||
|
|
||||||
|
// Update UI immediately
|
||||||
|
updateStatusUI('connecting');
|
||||||
|
|
||||||
|
// Connect
|
||||||
|
chrome.runtime.sendMessage({ type: 'connect', url }, (response) => {
|
||||||
|
if (response && response.status) {
|
||||||
|
updateStatusUI(response.status);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Disconnect button
|
||||||
|
disconnectBtn.addEventListener('click', () => {
|
||||||
|
chrome.runtime.sendMessage({ type: 'disconnect' }, (response) => {
|
||||||
|
if (response && response.status) {
|
||||||
|
updateStatusUI(response.status);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the UI to reflect current status
|
||||||
|
*/
|
||||||
|
function updateStatusUI(status, reconnecting = false) {
|
||||||
|
// Update text
|
||||||
|
const statusNames = {
|
||||||
|
disconnected: 'Disconnected',
|
||||||
|
connecting: reconnecting ? 'Reconnecting...' : 'Connecting...',
|
||||||
|
connected: 'Connected',
|
||||||
|
paused: 'Paused',
|
||||||
|
running: 'Running',
|
||||||
|
error: 'Connection Error',
|
||||||
|
};
|
||||||
|
statusText.textContent = statusNames[status] || status;
|
||||||
|
|
||||||
|
// Update indicator color
|
||||||
|
statusIndicator.className = 'status-indicator status-' + status;
|
||||||
|
|
||||||
|
// Update button states
|
||||||
|
const isConnected = ['connected', 'paused', 'running'].includes(status);
|
||||||
|
const isConnecting = status === 'connecting';
|
||||||
|
|
||||||
|
connectBtn.disabled = isConnected || isConnecting;
|
||||||
|
disconnectBtn.disabled = status === 'disconnected';
|
||||||
|
|
||||||
|
// Update connect button text
|
||||||
|
if (isConnecting) {
|
||||||
|
connectBtn.textContent = reconnecting ? 'Reconnecting...' : 'Connecting...';
|
||||||
|
} else {
|
||||||
|
connectBtn.textContent = 'Connect';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable inputs when connected
|
||||||
|
urlInput.disabled = isConnected || isConnecting;
|
||||||
|
}
|
||||||
|
});
|
||||||
36
browser-ext/proxy/package-lock.json
generated
Normal file
36
browser-ext/proxy/package-lock.json
generated
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "dap-websocket-proxy",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "dap-websocket-proxy",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"ws": "^8.16.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.19.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||||
|
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
browser-ext/proxy/package.json
Normal file
12
browser-ext/proxy/package.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "dap-websocket-proxy",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "WebSocket-to-TCP bridge for DAP debugging",
|
||||||
|
"main": "proxy.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node proxy.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"ws": "^8.16.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
207
browser-ext/proxy/proxy.js
Normal file
207
browser-ext/proxy/proxy.js
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
/**
|
||||||
|
* DAP WebSocket-to-TCP Proxy
|
||||||
|
*
|
||||||
|
* Bridges WebSocket connections from browser extensions to the DAP TCP server.
|
||||||
|
* Handles DAP message framing (Content-Length headers).
|
||||||
|
*
|
||||||
|
* Usage: node proxy.js [--ws-port 4712] [--dap-host 127.0.0.1] [--dap-port 4711]
|
||||||
|
*/
|
||||||
|
|
||||||
|
const WebSocket = require('ws');
|
||||||
|
const net = require('net');
|
||||||
|
|
||||||
|
// Configuration (can be overridden via CLI args)
|
||||||
|
const config = {
|
||||||
|
wsPort: parseInt(process.env.WS_PORT) || 4712,
|
||||||
|
dapHost: process.env.DAP_HOST || '127.0.0.1',
|
||||||
|
dapPort: parseInt(process.env.DAP_PORT) || 4711,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse CLI arguments
|
||||||
|
for (let i = 2; i < process.argv.length; i++) {
|
||||||
|
switch (process.argv[i]) {
|
||||||
|
case '--ws-port':
|
||||||
|
config.wsPort = parseInt(process.argv[++i]);
|
||||||
|
break;
|
||||||
|
case '--dap-host':
|
||||||
|
config.dapHost = process.argv[++i];
|
||||||
|
break;
|
||||||
|
case '--dap-port':
|
||||||
|
config.dapPort = parseInt(process.argv[++i]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Proxy] Starting WebSocket-to-TCP proxy`);
|
||||||
|
console.log(`[Proxy] WebSocket: ws://localhost:${config.wsPort}`);
|
||||||
|
console.log(`[Proxy] DAP Server: tcp://${config.dapHost}:${config.dapPort}`);
|
||||||
|
|
||||||
|
const wss = new WebSocket.Server({
|
||||||
|
port: config.wsPort,
|
||||||
|
// Enable ping/pong for connection health checks
|
||||||
|
clientTracking: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[Proxy] WebSocket server listening on port ${config.wsPort}`);
|
||||||
|
|
||||||
|
// Ping all clients every 25 seconds to detect dead connections
|
||||||
|
// This is shorter than Chrome's service worker timeout (~30s)
|
||||||
|
const PING_INTERVAL = 25000;
|
||||||
|
const pingInterval = setInterval(() => {
|
||||||
|
wss.clients.forEach((ws) => {
|
||||||
|
if (ws.isAlive === false) {
|
||||||
|
console.log(`[Proxy] Client failed to respond to ping, terminating`);
|
||||||
|
return ws.terminate();
|
||||||
|
}
|
||||||
|
ws.isAlive = false;
|
||||||
|
ws.ping();
|
||||||
|
});
|
||||||
|
}, PING_INTERVAL);
|
||||||
|
|
||||||
|
wss.on('connection', (ws, req) => {
|
||||||
|
const clientId = `${req.socket.remoteAddress}:${req.socket.remotePort}`;
|
||||||
|
console.log(`[Proxy] WebSocket client connected: ${clientId}`);
|
||||||
|
|
||||||
|
// Mark as alive for ping/pong tracking
|
||||||
|
ws.isAlive = true;
|
||||||
|
ws.on('pong', () => {
|
||||||
|
ws.isAlive = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Connect to DAP TCP server
|
||||||
|
const tcp = net.createConnection({
|
||||||
|
host: config.dapHost,
|
||||||
|
port: config.dapPort,
|
||||||
|
});
|
||||||
|
|
||||||
|
let tcpBuffer = '';
|
||||||
|
let tcpConnected = false;
|
||||||
|
|
||||||
|
tcp.on('connect', () => {
|
||||||
|
tcpConnected = true;
|
||||||
|
console.log(`[Proxy] Connected to DAP server at ${config.dapHost}:${config.dapPort}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
tcp.on('error', (err) => {
|
||||||
|
console.error(`[Proxy] TCP error: ${err.message}`);
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'proxy-error',
|
||||||
|
message: `Failed to connect to DAP server: ${err.message}`,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
ws.close(1011, 'DAP server connection failed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tcp.on('close', () => {
|
||||||
|
console.log(`[Proxy] TCP connection closed`);
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.close(1000, 'DAP server disconnected');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// WebSocket → TCP: Add Content-Length framing
|
||||||
|
ws.on('message', (data) => {
|
||||||
|
const json = data.toString();
|
||||||
|
try {
|
||||||
|
// Validate it's valid JSON
|
||||||
|
const parsed = JSON.parse(json);
|
||||||
|
|
||||||
|
// Handle keepalive messages from the browser extension - don't forward to DAP server
|
||||||
|
if (parsed.type === 'keepalive') {
|
||||||
|
console.log(`[Proxy] Keepalive received from client`);
|
||||||
|
// Respond with a keepalive-ack to confirm the connection is alive
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({ type: 'keepalive-ack', timestamp: Date.now() }));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tcpConnected) {
|
||||||
|
console.warn(`[Proxy] TCP not connected, dropping message`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Proxy] WS→TCP: ${parsed.command || parsed.event || 'message'}`);
|
||||||
|
|
||||||
|
// Add DAP framing
|
||||||
|
const framed = `Content-Length: ${Buffer.byteLength(json)}\r\n\r\n${json}`;
|
||||||
|
tcp.write(framed);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[Proxy] Invalid JSON from WebSocket: ${err.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// TCP → WebSocket: Parse Content-Length framing
|
||||||
|
tcp.on('data', (chunk) => {
|
||||||
|
tcpBuffer += chunk.toString();
|
||||||
|
|
||||||
|
// Process complete DAP messages from buffer
|
||||||
|
while (true) {
|
||||||
|
// Look for Content-Length header
|
||||||
|
const headerEnd = tcpBuffer.indexOf('\r\n\r\n');
|
||||||
|
if (headerEnd === -1) break;
|
||||||
|
|
||||||
|
const header = tcpBuffer.substring(0, headerEnd);
|
||||||
|
const match = header.match(/Content-Length:\s*(\d+)/i);
|
||||||
|
if (!match) {
|
||||||
|
console.error(`[Proxy] Invalid DAP header: ${header}`);
|
||||||
|
tcpBuffer = tcpBuffer.substring(headerEnd + 4);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentLength = parseInt(match[1]);
|
||||||
|
const messageStart = headerEnd + 4;
|
||||||
|
const messageEnd = messageStart + contentLength;
|
||||||
|
|
||||||
|
// Check if we have the complete message
|
||||||
|
if (tcpBuffer.length < messageEnd) break;
|
||||||
|
|
||||||
|
// Extract the JSON message
|
||||||
|
const json = tcpBuffer.substring(messageStart, messageEnd);
|
||||||
|
tcpBuffer = tcpBuffer.substring(messageEnd);
|
||||||
|
|
||||||
|
// Send to WebSocket
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(json);
|
||||||
|
console.log(
|
||||||
|
`[Proxy] TCP→WS: ${parsed.type} ${parsed.command || parsed.event || ''} ${parsed.request_seq ? `(req_seq: ${parsed.request_seq})` : ''}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(json);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[Proxy] Invalid JSON from TCP: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle WebSocket close
|
||||||
|
ws.on('close', (code, reason) => {
|
||||||
|
console.log(`[Proxy] WebSocket closed: ${code} ${reason}`);
|
||||||
|
tcp.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('error', (err) => {
|
||||||
|
console.error(`[Proxy] WebSocket error: ${err.message}`);
|
||||||
|
tcp.end();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
wss.on('error', (err) => {
|
||||||
|
console.error(`[Proxy] WebSocket server error: ${err.message}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
process.on('SIGINT', () => {
|
||||||
|
console.log(`\n[Proxy] Shutting down...`);
|
||||||
|
clearInterval(pingInterval);
|
||||||
|
wss.clients.forEach((ws) => ws.close(1001, 'Server shutting down'));
|
||||||
|
wss.close(() => {
|
||||||
|
console.log(`[Proxy] Goodbye!`);
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
1845
src/Runner.Worker/Dap/DapDebugSession.cs
Normal file
1845
src/Runner.Worker/Dap/DapDebugSession.cs
Normal file
File diff suppressed because it is too large
Load Diff
1125
src/Runner.Worker/Dap/DapMessages.cs
Normal file
1125
src/Runner.Worker/Dap/DapMessages.cs
Normal file
File diff suppressed because it is too large
Load Diff
480
src/Runner.Worker/Dap/DapServer.cs
Normal file
480
src/Runner.Worker/Dap/DapServer.cs
Normal file
@@ -0,0 +1,480 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using GitHub.Runner.Common;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
|
namespace GitHub.Runner.Worker.Dap
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// DAP Server interface for handling Debug Adapter Protocol connections.
|
||||||
|
/// </summary>
|
||||||
|
[ServiceLocator(Default = typeof(DapServer))]
|
||||||
|
public interface IDapServer : IRunnerService, IDisposable
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Starts the DAP TCP server on the specified port.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="port">The port to listen on (default: 4711)</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token</param>
|
||||||
|
Task StartAsync(int port, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Blocks until a debug client connects.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cancellationToken">Cancellation token</param>
|
||||||
|
Task WaitForConnectionAsync(CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stops the DAP server and closes all connections.
|
||||||
|
/// </summary>
|
||||||
|
Task StopAsync();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sets the debug session that will handle DAP requests.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="session">The debug session</param>
|
||||||
|
void SetSession(IDapDebugSession session);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sends an event to the connected debug client.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="evt">The event to send</param>
|
||||||
|
void SendEvent(Event evt);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets whether a debug client is currently connected.
|
||||||
|
/// </summary>
|
||||||
|
bool IsConnected { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// TCP server implementation of the Debug Adapter Protocol.
|
||||||
|
/// Handles message framing (Content-Length headers) and JSON serialization.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DapServer : RunnerService, IDapServer
|
||||||
|
{
|
||||||
|
private const string ContentLengthHeader = "Content-Length: ";
|
||||||
|
private const string HeaderTerminator = "\r\n\r\n";
|
||||||
|
|
||||||
|
private TcpListener _listener;
|
||||||
|
private TcpClient _client;
|
||||||
|
private NetworkStream _stream;
|
||||||
|
private IDapDebugSession _session;
|
||||||
|
private CancellationTokenSource _cts;
|
||||||
|
private Task _messageLoopTask;
|
||||||
|
private TaskCompletionSource<bool> _connectionTcs;
|
||||||
|
private int _nextSeq = 1;
|
||||||
|
private readonly object _sendLock = new object();
|
||||||
|
private bool _disposed = false;
|
||||||
|
|
||||||
|
public bool IsConnected => _client?.Connected == true;
|
||||||
|
|
||||||
|
public override void Initialize(IHostContext hostContext)
|
||||||
|
{
|
||||||
|
base.Initialize(hostContext);
|
||||||
|
Trace.Info("DapServer initialized");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetSession(IDapDebugSession session)
|
||||||
|
{
|
||||||
|
_session = session;
|
||||||
|
Trace.Info("Debug session set");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task StartAsync(int port, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Trace.Info($"Starting DAP server on port {port}");
|
||||||
|
|
||||||
|
_cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
_connectionTcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_listener = new TcpListener(IPAddress.Loopback, port);
|
||||||
|
_listener.Start();
|
||||||
|
Trace.Info($"DAP server listening on 127.0.0.1:{port}");
|
||||||
|
|
||||||
|
// Start accepting connections in the background
|
||||||
|
_ = AcceptConnectionAsync(_cts.Token);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Trace.Error($"Failed to start DAP server: {ex.Message}");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AcceptConnectionAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Trace.Info("Waiting for debug client connection...");
|
||||||
|
|
||||||
|
// Use cancellation-aware accept
|
||||||
|
using (cancellationToken.Register(() => _listener?.Stop()))
|
||||||
|
{
|
||||||
|
_client = await _listener.AcceptTcpClientAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_stream = _client.GetStream();
|
||||||
|
var remoteEndPoint = _client.Client.RemoteEndPoint;
|
||||||
|
Trace.Info($"Debug client connected from {remoteEndPoint}");
|
||||||
|
|
||||||
|
// Signal that connection is established
|
||||||
|
_connectionTcs.TrySetResult(true);
|
||||||
|
|
||||||
|
// Start processing messages
|
||||||
|
_messageLoopTask = ProcessMessagesAsync(_cts.Token);
|
||||||
|
}
|
||||||
|
catch (ObjectDisposedException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
// Expected when cancellation stops the listener
|
||||||
|
Trace.Info("Connection accept cancelled");
|
||||||
|
_connectionTcs.TrySetCanceled();
|
||||||
|
}
|
||||||
|
catch (SocketException ex) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
// Expected when cancellation stops the listener
|
||||||
|
Trace.Info($"Connection accept cancelled: {ex.Message}");
|
||||||
|
_connectionTcs.TrySetCanceled();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Trace.Error($"Error accepting connection: {ex.Message}");
|
||||||
|
_connectionTcs.TrySetException(ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task WaitForConnectionAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Trace.Info("Waiting for debug client to connect...");
|
||||||
|
|
||||||
|
using (cancellationToken.Register(() => _connectionTcs.TrySetCanceled()))
|
||||||
|
{
|
||||||
|
await _connectionTcs.Task;
|
||||||
|
}
|
||||||
|
|
||||||
|
Trace.Info("Debug client connected");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task StopAsync()
|
||||||
|
{
|
||||||
|
Trace.Info("Stopping DAP server");
|
||||||
|
|
||||||
|
_cts?.Cancel();
|
||||||
|
|
||||||
|
// Wait for message loop to complete
|
||||||
|
if (_messageLoopTask != null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _messageLoopTask;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// Expected
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Trace.Warning($"Message loop ended with error: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up resources
|
||||||
|
_stream?.Close();
|
||||||
|
_client?.Close();
|
||||||
|
_listener?.Stop();
|
||||||
|
|
||||||
|
Trace.Info("DAP server stopped");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SendEvent(Event evt)
|
||||||
|
{
|
||||||
|
if (!IsConnected)
|
||||||
|
{
|
||||||
|
Trace.Warning($"Cannot send event '{evt.EventType}': no client connected");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
lock (_sendLock)
|
||||||
|
{
|
||||||
|
evt.Seq = _nextSeq++;
|
||||||
|
SendMessageInternal(evt);
|
||||||
|
}
|
||||||
|
Trace.Info($"Sent event: {evt.EventType}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Trace.Error($"Failed to send event '{evt.EventType}': {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ProcessMessagesAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Trace.Info("Starting DAP message processing loop");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (!cancellationToken.IsCancellationRequested && IsConnected)
|
||||||
|
{
|
||||||
|
var json = await ReadMessageAsync(cancellationToken);
|
||||||
|
if (json == null)
|
||||||
|
{
|
||||||
|
Trace.Info("Client disconnected (end of stream)");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ProcessMessageAsync(json, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
Trace.Info("Message processing cancelled");
|
||||||
|
}
|
||||||
|
catch (IOException ex)
|
||||||
|
{
|
||||||
|
Trace.Info($"Connection closed: {ex.Message}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Trace.Error($"Error in message loop: {ex}");
|
||||||
|
}
|
||||||
|
|
||||||
|
Trace.Info("DAP message processing loop ended");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ProcessMessageAsync(string json, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Request request = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Parse the incoming message
|
||||||
|
request = JsonConvert.DeserializeObject<Request>(json);
|
||||||
|
if (request == null || request.Type != "request")
|
||||||
|
{
|
||||||
|
Trace.Warning($"Received non-request message: {json}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Trace.Info($"Received request: seq={request.Seq}, command={request.Command}");
|
||||||
|
|
||||||
|
// Dispatch to session for handling
|
||||||
|
if (_session == null)
|
||||||
|
{
|
||||||
|
Trace.Error("No debug session configured");
|
||||||
|
SendErrorResponse(request, "No debug session configured");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var response = await _session.HandleRequestAsync(request);
|
||||||
|
response.RequestSeq = request.Seq;
|
||||||
|
response.Command = request.Command;
|
||||||
|
response.Type = "response";
|
||||||
|
|
||||||
|
lock (_sendLock)
|
||||||
|
{
|
||||||
|
response.Seq = _nextSeq++;
|
||||||
|
SendMessageInternal(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
Trace.Info($"Sent response: seq={response.Seq}, command={response.Command}, success={response.Success}");
|
||||||
|
}
|
||||||
|
catch (JsonException ex)
|
||||||
|
{
|
||||||
|
Trace.Error($"Failed to parse request: {ex.Message}");
|
||||||
|
Trace.Error($"JSON: {json}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Trace.Error($"Error processing request: {ex}");
|
||||||
|
if (request != null)
|
||||||
|
{
|
||||||
|
SendErrorResponse(request, ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SendErrorResponse(Request request, string message)
|
||||||
|
{
|
||||||
|
var response = new Response
|
||||||
|
{
|
||||||
|
Type = "response",
|
||||||
|
RequestSeq = request.Seq,
|
||||||
|
Command = request.Command,
|
||||||
|
Success = false,
|
||||||
|
Message = message,
|
||||||
|
Body = new ErrorResponseBody
|
||||||
|
{
|
||||||
|
Error = new Message
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
Format = message,
|
||||||
|
ShowUser = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
lock (_sendLock)
|
||||||
|
{
|
||||||
|
response.Seq = _nextSeq++;
|
||||||
|
SendMessageInternal(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads a DAP message from the stream.
|
||||||
|
/// DAP uses HTTP-like message framing: Content-Length: N\r\n\r\n{json}
|
||||||
|
/// </summary>
|
||||||
|
private async Task<string> ReadMessageAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// Read headers until we find Content-Length
|
||||||
|
var headerBuilder = new StringBuilder();
|
||||||
|
int contentLength = -1;
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var line = await ReadLineAsync(cancellationToken);
|
||||||
|
if (line == null)
|
||||||
|
{
|
||||||
|
// End of stream
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.Length == 0)
|
||||||
|
{
|
||||||
|
// Empty line marks end of headers
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
headerBuilder.AppendLine(line);
|
||||||
|
|
||||||
|
if (line.StartsWith(ContentLengthHeader, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var lengthStr = line.Substring(ContentLengthHeader.Length).Trim();
|
||||||
|
if (!int.TryParse(lengthStr, out contentLength))
|
||||||
|
{
|
||||||
|
throw new InvalidDataException($"Invalid Content-Length: {lengthStr}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contentLength < 0)
|
||||||
|
{
|
||||||
|
throw new InvalidDataException("Missing Content-Length header");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the JSON body
|
||||||
|
var buffer = new byte[contentLength];
|
||||||
|
var totalRead = 0;
|
||||||
|
while (totalRead < contentLength)
|
||||||
|
{
|
||||||
|
var bytesRead = await _stream.ReadAsync(buffer, totalRead, contentLength - totalRead, cancellationToken);
|
||||||
|
if (bytesRead == 0)
|
||||||
|
{
|
||||||
|
throw new EndOfStreamException("Connection closed while reading message body");
|
||||||
|
}
|
||||||
|
totalRead += bytesRead;
|
||||||
|
}
|
||||||
|
|
||||||
|
var json = Encoding.UTF8.GetString(buffer);
|
||||||
|
Trace.Verbose($"Received: {json}");
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads a line from the stream (terminated by \r\n).
|
||||||
|
/// </summary>
|
||||||
|
private async Task<string> ReadLineAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var lineBuilder = new StringBuilder();
|
||||||
|
var buffer = new byte[1];
|
||||||
|
var previousWasCr = false;
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var bytesRead = await _stream.ReadAsync(buffer, 0, 1, cancellationToken);
|
||||||
|
if (bytesRead == 0)
|
||||||
|
{
|
||||||
|
// End of stream
|
||||||
|
return lineBuilder.Length > 0 ? lineBuilder.ToString() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var c = (char)buffer[0];
|
||||||
|
|
||||||
|
if (c == '\n' && previousWasCr)
|
||||||
|
{
|
||||||
|
// Found \r\n, return the line (without the \r)
|
||||||
|
if (lineBuilder.Length > 0 && lineBuilder[lineBuilder.Length - 1] == '\r')
|
||||||
|
{
|
||||||
|
lineBuilder.Length--;
|
||||||
|
}
|
||||||
|
return lineBuilder.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
previousWasCr = (c == '\r');
|
||||||
|
lineBuilder.Append(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sends a DAP message to the stream with Content-Length framing.
|
||||||
|
/// Must be called within the _sendLock.
|
||||||
|
/// </summary>
|
||||||
|
private void SendMessageInternal(ProtocolMessage message)
|
||||||
|
{
|
||||||
|
var json = JsonConvert.SerializeObject(message, new JsonSerializerSettings
|
||||||
|
{
|
||||||
|
NullValueHandling = NullValueHandling.Ignore
|
||||||
|
});
|
||||||
|
|
||||||
|
var bodyBytes = Encoding.UTF8.GetBytes(json);
|
||||||
|
var header = $"Content-Length: {bodyBytes.Length}\r\n\r\n";
|
||||||
|
var headerBytes = Encoding.UTF8.GetBytes(header);
|
||||||
|
|
||||||
|
_stream.Write(headerBytes, 0, headerBytes.Length);
|
||||||
|
_stream.Write(bodyBytes, 0, bodyBytes.Length);
|
||||||
|
_stream.Flush();
|
||||||
|
|
||||||
|
Trace.Verbose($"Sent: {json}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Dispose(true);
|
||||||
|
GC.SuppressFinalize(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Dispose(bool disposing)
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (disposing)
|
||||||
|
{
|
||||||
|
_cts?.Cancel();
|
||||||
|
_stream?.Dispose();
|
||||||
|
_client?.Dispose();
|
||||||
|
_listener?.Stop();
|
||||||
|
_cts?.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
_disposed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
293
src/Runner.Worker/Dap/DapVariableProvider.cs
Normal file
293
src/Runner.Worker/Dap/DapVariableProvider.cs
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using GitHub.DistributedTask.Pipelines.ContextData;
|
||||||
|
using GitHub.Runner.Common;
|
||||||
|
|
||||||
|
namespace GitHub.Runner.Worker.Dap
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Provides DAP variable information from the execution context.
|
||||||
|
/// Maps workflow contexts (github, env, runner, job, steps, secrets) to DAP scopes and variables.
|
||||||
|
/// </summary>
|
||||||
|
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<int, (PipelineContextData Data, string Path)> _variableReferences = new();
|
||||||
|
private int _nextVariableReference = DynamicReferenceBase;
|
||||||
|
|
||||||
|
public DapVariableProvider(IHostContext hostContext)
|
||||||
|
{
|
||||||
|
_hostContext = hostContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resets the variable reference state. Call this when the execution context changes.
|
||||||
|
/// </summary>
|
||||||
|
public void Reset()
|
||||||
|
{
|
||||||
|
_variableReferences.Clear();
|
||||||
|
_nextVariableReference = DynamicReferenceBase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the list of scopes for a given execution context.
|
||||||
|
/// Each scope represents a top-level context like 'github', 'env', etc.
|
||||||
|
/// </summary>
|
||||||
|
public List<Scope> GetScopes(IExecutionContext context, int frameId)
|
||||||
|
{
|
||||||
|
var scopes = new List<Scope>();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets variables for a given variable reference.
|
||||||
|
/// </summary>
|
||||||
|
public List<Variable> GetVariables(IExecutionContext context, int variablesReference)
|
||||||
|
{
|
||||||
|
var variables = new List<Variable>();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts PipelineContextData to DAP Variable objects.
|
||||||
|
/// </summary>
|
||||||
|
private void ConvertToVariables(PipelineContextData data, string basePath, bool isSecretsScope, List<Variable> 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<Variable> 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<Variable> 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<Variable> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers a nested variable reference and returns its ID.
|
||||||
|
/// </summary>
|
||||||
|
private int RegisterVariableReference(PipelineContextData data, string path)
|
||||||
|
{
|
||||||
|
var reference = _nextVariableReference++;
|
||||||
|
_variableReferences[reference] = (data, path);
|
||||||
|
return reference;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Masks any secret values in the string using the host context's secret masker.
|
||||||
|
/// </summary>
|
||||||
|
private string MaskSecrets(string value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(value))
|
||||||
|
{
|
||||||
|
return value ?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
return _hostContext.SecretMasker.MaskSecrets(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
87
src/Runner.Worker/Dap/StepCheckpoint.cs
Normal file
87
src/Runner.Worker/Dap/StepCheckpoint.cs
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using GitHub.DistributedTask.WebApi;
|
||||||
|
using GitHub.Runner.Common;
|
||||||
|
|
||||||
|
namespace GitHub.Runner.Worker.Dap
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a snapshot of job state captured just before a step executes.
|
||||||
|
/// Created when user issues next/continue command, after any REPL modifications.
|
||||||
|
/// Used for step-back (time-travel) debugging.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class StepCheckpoint
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Index of this checkpoint in the checkpoints list.
|
||||||
|
/// Used when restoring to identify which checkpoint to restore to.
|
||||||
|
/// </summary>
|
||||||
|
public int CheckpointIndex { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Zero-based index of the step in the job.
|
||||||
|
/// </summary>
|
||||||
|
public int StepIndex { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Display name of the step this checkpoint was created for.
|
||||||
|
/// </summary>
|
||||||
|
public string StepDisplayName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Snapshot of Global.EnvironmentVariables.
|
||||||
|
/// </summary>
|
||||||
|
public Dictionary<string, string> EnvironmentVariables { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Snapshot of ExpressionValues["env"] context data.
|
||||||
|
/// </summary>
|
||||||
|
public Dictionary<string, string> EnvContextData { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Snapshot of Global.PrependPath.
|
||||||
|
/// </summary>
|
||||||
|
public List<string> PrependPath { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Snapshot of job result.
|
||||||
|
/// </summary>
|
||||||
|
public TaskResult? JobResult { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Snapshot of job status.
|
||||||
|
/// </summary>
|
||||||
|
public ActionResult? JobStatus { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Snapshot of steps context (outputs, outcomes, conclusions).
|
||||||
|
/// Key is "{scopeName}/{stepName}", value is the step's state.
|
||||||
|
/// </summary>
|
||||||
|
public Dictionary<string, StepStateSnapshot> StepsSnapshot { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The step that was about to execute (for re-running).
|
||||||
|
/// </summary>
|
||||||
|
public IStep CurrentStep { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Steps remaining in the queue after CurrentStep.
|
||||||
|
/// </summary>
|
||||||
|
public List<IStep> RemainingSteps { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When this checkpoint was created.
|
||||||
|
/// </summary>
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Snapshot of a single step's state in the steps context.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class StepStateSnapshot
|
||||||
|
{
|
||||||
|
public ActionResult? Outcome { get; set; }
|
||||||
|
public ActionResult? Conclusion { get; set; }
|
||||||
|
public Dictionary<string, string> Outputs { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
@@ -13,6 +13,7 @@ using GitHub.DistributedTask.WebApi;
|
|||||||
using GitHub.Runner.Common;
|
using GitHub.Runner.Common;
|
||||||
using GitHub.Runner.Common.Util;
|
using GitHub.Runner.Common.Util;
|
||||||
using GitHub.Runner.Sdk;
|
using GitHub.Runner.Sdk;
|
||||||
|
using GitHub.Runner.Worker.Dap;
|
||||||
using GitHub.Services.Common;
|
using GitHub.Services.Common;
|
||||||
using GitHub.Services.WebApi;
|
using GitHub.Services.WebApi;
|
||||||
using Sdk.RSWebApi.Contracts;
|
using Sdk.RSWebApi.Contracts;
|
||||||
@@ -112,6 +113,8 @@ namespace GitHub.Runner.Worker
|
|||||||
|
|
||||||
IExecutionContext jobContext = null;
|
IExecutionContext jobContext = null;
|
||||||
CancellationTokenRegistration? runnerShutdownRegistration = null;
|
CancellationTokenRegistration? runnerShutdownRegistration = null;
|
||||||
|
IDapServer dapServer = null;
|
||||||
|
CancellationTokenRegistration? dapCancellationRegistration = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Create the job execution context.
|
// Create the job execution context.
|
||||||
@@ -159,6 +162,61 @@ namespace GitHub.Runner.Worker
|
|||||||
if (jobContext.Global.WriteDebug)
|
if (jobContext.Global.WriteDebug)
|
||||||
{
|
{
|
||||||
jobContext.SetRunnerContext("debug", "1");
|
jobContext.SetRunnerContext("debug", "1");
|
||||||
|
|
||||||
|
// Start DAP server for interactive debugging
|
||||||
|
// This allows debugging workflow jobs with DAP-compatible editors (nvim-dap, VS Code, etc.)
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var port = 4711;
|
||||||
|
var portEnv = Environment.GetEnvironmentVariable("ACTIONS_DAP_PORT");
|
||||||
|
if (!string.IsNullOrEmpty(portEnv) && int.TryParse(portEnv, out var customPort))
|
||||||
|
{
|
||||||
|
port = customPort;
|
||||||
|
}
|
||||||
|
|
||||||
|
dapServer = HostContext.GetService<IDapServer>();
|
||||||
|
var debugSession = HostContext.GetService<IDapDebugSession>();
|
||||||
|
|
||||||
|
// Wire up the server and session
|
||||||
|
dapServer.SetSession(debugSession);
|
||||||
|
debugSession.SetDapServer(dapServer);
|
||||||
|
|
||||||
|
await dapServer.StartAsync(port, jobRequestCancellationToken);
|
||||||
|
Trace.Info($"DAP server listening on port {port}");
|
||||||
|
jobContext.Output($"DAP debugger waiting for connection on port {port}...");
|
||||||
|
jobContext.Output($"Connect your DAP client (nvim-dap, VS Code, etc.) to attach to this job.");
|
||||||
|
|
||||||
|
// Block until debugger connects
|
||||||
|
await dapServer.WaitForConnectionAsync(jobRequestCancellationToken);
|
||||||
|
Trace.Info("DAP client connected, continuing job execution");
|
||||||
|
jobContext.Output("Debugger connected. Job execution will pause before each step.");
|
||||||
|
|
||||||
|
// Register cancellation handler to properly terminate DAP session on job cancellation
|
||||||
|
try
|
||||||
|
{
|
||||||
|
dapCancellationRegistration = jobRequestCancellationToken.Register(() =>
|
||||||
|
{
|
||||||
|
Trace.Info("Job cancelled - terminating DAP session");
|
||||||
|
debugSession.CancelSession();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Trace.Warning($"Failed to register DAP cancellation handler: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// Job was cancelled before debugger connected
|
||||||
|
Trace.Info("Job cancelled while waiting for DAP client connection");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Log but don't fail the job if DAP server fails to start
|
||||||
|
Trace.Warning($"Failed to start DAP server: {ex.Message}");
|
||||||
|
jobContext.Warning($"DAP debugging unavailable: {ex.Message}");
|
||||||
|
dapServer = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
jobContext.SetRunnerContext("os", VarUtil.OS);
|
jobContext.SetRunnerContext("os", VarUtil.OS);
|
||||||
@@ -259,6 +317,23 @@ namespace GitHub.Runner.Worker
|
|||||||
runnerShutdownRegistration = null;
|
runnerShutdownRegistration = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dispose DAP cancellation registration
|
||||||
|
dapCancellationRegistration?.Dispose();
|
||||||
|
|
||||||
|
// Stop DAP server if it was started
|
||||||
|
if (dapServer != null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Trace.Info("Stopping DAP server");
|
||||||
|
await dapServer.StopAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Trace.Warning($"Error stopping DAP server: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await ShutdownQueue(throwOnFailure: false);
|
await ShutdownQueue(throwOnFailure: false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using GitHub.DistributedTask.Pipelines.ContextData;
|
using GitHub.DistributedTask.Pipelines.ContextData;
|
||||||
using GitHub.DistributedTask.WebApi;
|
using GitHub.DistributedTask.WebApi;
|
||||||
using GitHub.Runner.Common.Util;
|
using GitHub.Runner.Common.Util;
|
||||||
using System;
|
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 static readonly Regex _propertyRegex = new("^[a-zA-Z_][a-zA-Z0-9_]*$", RegexOptions.Compiled);
|
||||||
private readonly DictionaryContextData _contextData = new();
|
private readonly DictionaryContextData _contextData = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional callback for debug logging. When set, will be called with debug messages
|
||||||
|
/// for all StepsContext mutations.
|
||||||
|
/// </summary>
|
||||||
|
public Action<string> 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) + "...";
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Clears memory for a composite action's isolated "steps" context, after the action
|
/// Clears memory for a composite action's isolated "steps" context, after the action
|
||||||
/// is finished executing.
|
/// is finished executing.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void ClearScope(string scopeName)
|
public void ClearScope(string scopeName)
|
||||||
{
|
{
|
||||||
|
DebugLog($"[StepsContext] ClearScope: scope='{scopeName ?? "(root)"}'");
|
||||||
if (_contextData.TryGetValue(scopeName, out _))
|
if (_contextData.TryGetValue(scopeName, out _))
|
||||||
{
|
{
|
||||||
_contextData[scopeName] = new DictionaryContextData();
|
_contextData[scopeName] = new DictionaryContextData();
|
||||||
@@ -78,6 +97,7 @@ namespace GitHub.Runner.Worker
|
|||||||
{
|
{
|
||||||
reference = $"steps['{stepName}']['outputs']['{outputName}']";
|
reference = $"steps['{stepName}']['outputs']['{outputName}']";
|
||||||
}
|
}
|
||||||
|
DebugLog($"[StepsContext] SetOutput: step='{stepName}', output='{outputName}', value='{TruncateValue(value)}'");
|
||||||
}
|
}
|
||||||
|
|
||||||
public void SetConclusion(
|
public void SetConclusion(
|
||||||
@@ -86,7 +106,9 @@ namespace GitHub.Runner.Worker
|
|||||||
ActionResult conclusion)
|
ActionResult conclusion)
|
||||||
{
|
{
|
||||||
var step = GetStep(scopeName, stepName);
|
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(
|
public void SetOutcome(
|
||||||
@@ -95,7 +117,9 @@ namespace GitHub.Runner.Worker
|
|||||||
ActionResult outcome)
|
ActionResult outcome)
|
||||||
{
|
{
|
||||||
var step = GetStep(scopeName, stepName);
|
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)
|
private DictionaryContextData GetStep(string scopeName, string stepName)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using GitHub.DistributedTask.Expressions2;
|
using GitHub.DistributedTask.Expressions2;
|
||||||
@@ -10,6 +11,7 @@ using GitHub.DistributedTask.WebApi;
|
|||||||
using GitHub.Runner.Common;
|
using GitHub.Runner.Common;
|
||||||
using GitHub.Runner.Common.Util;
|
using GitHub.Runner.Common.Util;
|
||||||
using GitHub.Runner.Sdk;
|
using GitHub.Runner.Sdk;
|
||||||
|
using GitHub.Runner.Worker.Dap;
|
||||||
using GitHub.Runner.Worker.Expressions;
|
using GitHub.Runner.Worker.Expressions;
|
||||||
|
|
||||||
namespace GitHub.Runner.Worker
|
namespace GitHub.Runner.Worker
|
||||||
@@ -50,6 +52,13 @@ namespace GitHub.Runner.Worker
|
|||||||
jobContext.JobContext.Status = (jobContext.Result ?? TaskResult.Succeeded).ToActionResult();
|
jobContext.JobContext.Status = (jobContext.Result ?? TaskResult.Succeeded).ToActionResult();
|
||||||
var scopeInputs = new Dictionary<string, PipelineContextData>(StringComparer.OrdinalIgnoreCase);
|
var scopeInputs = new Dictionary<string, PipelineContextData>(StringComparer.OrdinalIgnoreCase);
|
||||||
bool checkPostJobActions = false;
|
bool checkPostJobActions = false;
|
||||||
|
|
||||||
|
// Get debug session for DAP debugging support
|
||||||
|
// The session's IsActive property determines if debugging is actually enabled
|
||||||
|
var debugSession = HostContext.GetService<IDapDebugSession>();
|
||||||
|
bool isFirstStep = true;
|
||||||
|
int stepIndex = 0; // Track step index for checkpoints
|
||||||
|
|
||||||
while (jobContext.JobSteps.Count > 0 || !checkPostJobActions)
|
while (jobContext.JobSteps.Count > 0 || !checkPostJobActions)
|
||||||
{
|
{
|
||||||
if (jobContext.JobSteps.Count == 0 && !checkPostJobActions)
|
if (jobContext.JobSteps.Count == 0 && !checkPostJobActions)
|
||||||
@@ -65,6 +74,9 @@ namespace GitHub.Runner.Worker
|
|||||||
|
|
||||||
var step = jobContext.JobSteps.Dequeue();
|
var step = jobContext.JobSteps.Dequeue();
|
||||||
|
|
||||||
|
// Capture remaining steps for potential checkpoint (before we modify the queue)
|
||||||
|
var remainingSteps = jobContext.JobSteps.ToList();
|
||||||
|
|
||||||
Trace.Info($"Processing step: DisplayName='{step.DisplayName}'");
|
Trace.Info($"Processing step: DisplayName='{step.DisplayName}'");
|
||||||
ArgUtil.NotNull(step.ExecutionContext, nameof(step.ExecutionContext));
|
ArgUtil.NotNull(step.ExecutionContext, nameof(step.ExecutionContext));
|
||||||
ArgUtil.NotNull(step.ExecutionContext.Global, nameof(step.ExecutionContext.Global));
|
ArgUtil.NotNull(step.ExecutionContext.Global, nameof(step.ExecutionContext.Global));
|
||||||
@@ -181,6 +193,58 @@ namespace GitHub.Runner.Worker
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pause for DAP debugger BEFORE step execution
|
||||||
|
// This happens after expression values are set up so the debugger can inspect variables
|
||||||
|
if (debugSession?.IsActive == true)
|
||||||
|
{
|
||||||
|
// Store step info for checkpoint creation later
|
||||||
|
debugSession.SetPendingStepInfo(step, jobContext, stepIndex, remainingSteps);
|
||||||
|
|
||||||
|
// Pause and wait for user command (next/continue/stepBack/reverseContinue)
|
||||||
|
await debugSession.OnStepStartingAsync(step, jobContext, isFirstStep, jobContext.CancellationToken);
|
||||||
|
isFirstStep = false;
|
||||||
|
|
||||||
|
// Check if user requested to step back
|
||||||
|
if (debugSession.HasPendingRestore)
|
||||||
|
{
|
||||||
|
var checkpoint = debugSession.ConsumeRestoredCheckpoint();
|
||||||
|
if (checkpoint != null)
|
||||||
|
{
|
||||||
|
// Restore the checkpoint state using the correct checkpoint index
|
||||||
|
debugSession.RestoreCheckpoint(checkpoint.CheckpointIndex, jobContext);
|
||||||
|
|
||||||
|
// Re-queue the steps from checkpoint
|
||||||
|
while (jobContext.JobSteps.Count > 0)
|
||||||
|
{
|
||||||
|
jobContext.JobSteps.Dequeue();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queue the checkpoint's step and remaining steps
|
||||||
|
jobContext.JobSteps.Enqueue(checkpoint.CurrentStep);
|
||||||
|
foreach (var remainingStep in checkpoint.RemainingSteps)
|
||||||
|
{
|
||||||
|
jobContext.JobSteps.Enqueue(remainingStep);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset step index to checkpoint's index
|
||||||
|
stepIndex = checkpoint.StepIndex;
|
||||||
|
|
||||||
|
// Clear pending step info since we're not executing this step
|
||||||
|
debugSession.ClearPendingStepInfo();
|
||||||
|
|
||||||
|
// Skip to next iteration - will process restored step
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// User pressed next/continue - create checkpoint NOW
|
||||||
|
// This captures any REPL modifications made while paused
|
||||||
|
if (debugSession.ShouldCreateCheckpoint())
|
||||||
|
{
|
||||||
|
debugSession.CreateCheckpointForPendingStep(jobContext);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Evaluate condition
|
// Evaluate condition
|
||||||
step.ExecutionContext.Debug($"Evaluating condition for step: '{step.DisplayName}'");
|
step.ExecutionContext.Debug($"Evaluating condition for step: '{step.DisplayName}'");
|
||||||
var conditionTraceWriter = new ConditionTraceWriter(Trace, step.ExecutionContext);
|
var conditionTraceWriter = new ConditionTraceWriter(Trace, step.ExecutionContext);
|
||||||
@@ -238,6 +302,9 @@ namespace GitHub.Runner.Worker
|
|||||||
jobCancelRegister?.Dispose();
|
jobCancelRegister?.Dispose();
|
||||||
jobCancelRegister = null;
|
jobCancelRegister = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear pending step info after step completes
|
||||||
|
debugSession?.ClearPendingStepInfo();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,8 +320,20 @@ namespace GitHub.Runner.Worker
|
|||||||
Trace.Info($"No need for updating job result with current step result '{step.ExecutionContext.Result}'.");
|
Trace.Info($"No need for updating job result with current step result '{step.ExecutionContext.Result}'.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Notify DAP debugger AFTER step execution
|
||||||
|
if (debugSession?.IsActive == true)
|
||||||
|
{
|
||||||
|
debugSession.OnStepCompleted(step);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment step index for checkpoint tracking
|
||||||
|
stepIndex++;
|
||||||
|
|
||||||
Trace.Info($"Current state: job state = '{jobContext.Result}'");
|
Trace.Info($"Current state: job state = '{jobContext.Result}'");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Notify DAP debugger that the job has completed
|
||||||
|
debugSession?.OnJobCompleted();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task RunStepAsync(IStep step, CancellationToken jobCancellationToken)
|
private async Task RunStepAsync(IStep step, CancellationToken jobCancellationToken)
|
||||||
|
|||||||
Reference in New Issue
Block a user