mirror of
https://github.com/actions/runner.git
synced 2026-01-23 04:51:23 +08:00
Compare commits
37 Commits
users/eric
...
rentziass/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0358cca636 | ||
|
|
e2bafea9de | ||
|
|
9ecb88f16e | ||
|
|
9f96f7f3d6 | ||
|
|
656a4c71b1 | ||
|
|
7156f0a195 | ||
|
|
514d122c9d | ||
|
|
8fbe9aa963 | ||
|
|
8210dab8d4 | ||
|
|
d334ab3f0a | ||
|
|
1bba60b475 | ||
|
|
008594a3ee | ||
|
|
9bc9aff86f | ||
|
|
38514d5278 | ||
|
|
11ca211a3a | ||
|
|
fd26cf5276 | ||
|
|
1760f5f37a | ||
|
|
556d3f7a93 | ||
|
|
f3cc4d2211 | ||
|
|
842b3e64b0 | ||
|
|
39808903ea | ||
|
|
2b812b527c | ||
|
|
1f258e06ee | ||
|
|
f26d0a31a3 | ||
|
|
e2654d01f8 | ||
|
|
d9e983d87e | ||
|
|
b837c99a81 | ||
|
|
7efe30c032 | ||
|
|
d722c947da | ||
|
|
576fd09010 | ||
|
|
1af83dbfee | ||
|
|
c83675bc98 | ||
|
|
4fbe409a78 | ||
|
|
50d05627e3 | ||
|
|
4e46e0aae3 | ||
|
|
ae2b412889 | ||
|
|
167e886fd0 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -14,6 +14,7 @@
|
||||
.vscode
|
||||
!.vscode/launch.json
|
||||
!.vscode/tasks.json
|
||||
!browser-ext/lib
|
||||
|
||||
# output
|
||||
node_modules
|
||||
@@ -27,4 +28,4 @@ TestResults
|
||||
TestLogs
|
||||
.DS_Store
|
||||
.mono
|
||||
**/*.DotSettings.user
|
||||
**/*.DotSettings.user
|
||||
|
||||
214
.opencode/plans/dap-browser-extension-ui-improvements.md
Normal file
214
.opencode/plans/dap-browser-extension-ui-improvements.md
Normal file
@@ -0,0 +1,214 @@
|
||||
# DAP Browser Extension UI Improvements
|
||||
|
||||
**Status:** Implemented
|
||||
**Date:** January 2026
|
||||
**Related:** [dap-browser-extension.md](./dap-browser-extension.md), [dap-debugging.md](./dap-debugging.md)
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes UI improvements made to the DAP browser extension debugger based on designer feedback. The original implementation inserted the debugger pane inline between workflow steps, causing it to "bounce around" as the user stepped through the job. The new implementation uses fixed positioning with two layout options.
|
||||
|
||||
## Problem Statement
|
||||
|
||||
The original debugger UI had these issues:
|
||||
1. **Bouncing pane**: The debugger pane was inserted between steps in the DOM, so it moved position each time the user stepped to a new step
|
||||
2. **No layout flexibility**: Users couldn't choose where they wanted the debugger positioned
|
||||
3. **No breakpoint indicator**: There was no visual indication of which step the debugger was currently paused at
|
||||
|
||||
## Solution
|
||||
|
||||
Implemented two fixed-position layout options inspired by browser DevTools:
|
||||
|
||||
### 1. Bottom Panel Layout (Default)
|
||||
- Fixed at the bottom of the viewport
|
||||
- Height: 280px
|
||||
- Variables panel on left (33%), Console on right (67%)
|
||||
- Control buttons in the header row (right side)
|
||||
- Similar to Chrome/Firefox DevTools
|
||||
|
||||
```
|
||||
+------------------------------------------------------------------+
|
||||
| Debugger [step info] [controls] [layout] [X] |
|
||||
+------------------------------------------------------------------+
|
||||
| Variables (1/3) | Console (2/3) |
|
||||
| > github | Welcome message... |
|
||||
| > env | > command output |
|
||||
| > runner | |
|
||||
| | [input field ] |
|
||||
+------------------------------------------------------------------+
|
||||
```
|
||||
|
||||
### 2. Sidebar Layout
|
||||
- Fixed on the right side of the viewport
|
||||
- Width: 350px
|
||||
- Full height of viewport
|
||||
- Variables on top, Console in middle, Controls at bottom
|
||||
|
||||
```
|
||||
+------------------+
|
||||
| Debugger [X] |
|
||||
| [layout btns] |
|
||||
+------------------+
|
||||
| Variables |
|
||||
| > github |
|
||||
| > env |
|
||||
+------------------+
|
||||
| Console |
|
||||
| output... |
|
||||
| |
|
||||
| [input ] |
|
||||
+------------------+
|
||||
| [ controls ] |
|
||||
+------------------+
|
||||
```
|
||||
|
||||
### 3. Breakpoint Indicator
|
||||
Visual marker showing the current step where the debugger is paused:
|
||||
- Red accent bar on the right edge of the step row
|
||||
- Red bottom border on the step header
|
||||
- Triangle pointer pointing toward the debugger panel
|
||||
- Subtle gradient background highlight
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Files Modified
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `browser-ext/content/content.js` | Complete refactor: new layout system, breakpoint indicator, layout toggle |
|
||||
| `browser-ext/content/content.css` | New styles for layouts, breakpoint indicator, toggle buttons |
|
||||
|
||||
### Key Functions Added (`content.js`)
|
||||
|
||||
#### Layout Management
|
||||
```javascript
|
||||
// Load layout preference from chrome.storage.local
|
||||
async function loadLayoutPreference()
|
||||
|
||||
// Save layout preference to chrome.storage.local
|
||||
function saveLayoutPreference(layout)
|
||||
|
||||
// Switch between 'bottom' and 'sidebar' layouts
|
||||
// Preserves console output and variable tree state during switch
|
||||
function switchLayout(newLayout)
|
||||
|
||||
// Create the bottom panel HTML structure
|
||||
function createBottomPaneHTML()
|
||||
|
||||
// Create the sidebar panel HTML structure
|
||||
function createSidebarPaneHTML()
|
||||
```
|
||||
|
||||
#### Breakpoint Indicator
|
||||
```javascript
|
||||
// Highlights the current step with CSS class 'dap-current-step'
|
||||
function updateBreakpointIndicator(stepElement)
|
||||
|
||||
// Clears the indicator from all steps
|
||||
function clearBreakpointIndicator()
|
||||
```
|
||||
|
||||
#### Panel Controls
|
||||
```javascript
|
||||
// Close the debugger panel and clear indicators
|
||||
function closeDebuggerPane()
|
||||
|
||||
// Update layout toggle button active states
|
||||
function updateLayoutToggleButtons()
|
||||
```
|
||||
|
||||
### CSS Classes Added (`content.css`)
|
||||
|
||||
| Class | Purpose |
|
||||
|-------|---------|
|
||||
| `.dap-debugger-bottom` | Bottom panel layout (fixed position) |
|
||||
| `.dap-debugger-sidebar` | Sidebar layout (fixed position) |
|
||||
| `.dap-layout-toggles` | Container for layout toggle buttons |
|
||||
| `.dap-layout-btn` | Individual layout toggle button |
|
||||
| `.dap-layout-btn.active` | Active state for selected layout |
|
||||
| `.dap-close-btn` | Close button styling |
|
||||
| `check-step.dap-current-step` | Breakpoint indicator on step element |
|
||||
|
||||
### State Variables
|
||||
|
||||
```javascript
|
||||
let currentLayout = 'bottom'; // 'bottom' | 'sidebar'
|
||||
let currentStepElement = null; // Track current step for breakpoint indicator
|
||||
```
|
||||
|
||||
### Storage
|
||||
|
||||
Layout preference is persisted to `chrome.storage.local` under the key `debuggerLayout`.
|
||||
|
||||
## Removed Functionality
|
||||
|
||||
- `moveDebuggerPane()` - No longer needed since debugger uses fixed positioning
|
||||
- Inline pane injection between steps - Replaced with fixed position panels
|
||||
|
||||
## Design Mockups Reference
|
||||
|
||||
The implementation was based on these mockup frames:
|
||||
- **Frame 4/5/6**: Sidebar layout on right side
|
||||
- **Frame 7**: Bottom panel layout with controls in header
|
||||
- All frames showed the breakpoint indicator as a red/orange accent on the current step
|
||||
|
||||
## Future Improvements
|
||||
|
||||
Potential enhancements for future iterations:
|
||||
|
||||
1. **Resizable panels**: Allow users to drag to resize the panel width/height
|
||||
2. **Minimize/maximize**: Add ability to minimize the panel to just a header bar
|
||||
3. **Detached window**: Option to pop out debugger into separate browser window
|
||||
4. **Keyboard shortcuts**: Add shortcuts for layout switching and panel toggle
|
||||
5. **Remember panel size**: Persist user's preferred panel dimensions
|
||||
6. **Breakpoint list**: Show list of all breakpoints with ability to navigate
|
||||
7. **Step indicator in panel**: Show step name/number in the panel header with prev/next navigation
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Bottom panel displays correctly at viewport bottom
|
||||
- [ ] Sidebar panel displays correctly on right side
|
||||
- [ ] Layout toggle buttons switch between layouts
|
||||
- [ ] Layout preference persists across page reloads
|
||||
- [ ] Close button removes panel and updates Debug button state
|
||||
- [ ] Breakpoint indicator appears on current step when paused
|
||||
- [ ] Breakpoint indicator moves when stepping to next step
|
||||
- [ ] Breakpoint indicator clears when disconnected/terminated
|
||||
- [ ] Console output preserves when switching layouts
|
||||
- [ ] Variables tree preserves when switching layouts
|
||||
- [ ] Works correctly in both light and dark mode
|
||||
- [ ] Panel doesn't interfere with page scrolling
|
||||
- [ ] Step scrolls into view when breakpoint changes
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
### Why Fixed Positioning?
|
||||
|
||||
The original inline injection approach had issues:
|
||||
1. Required complex DOM manipulation to move the pane between steps
|
||||
2. Caused layout shifts in the GitHub page
|
||||
3. Made it difficult to maintain console scroll position
|
||||
4. Required finding the correct insertion point for each step
|
||||
|
||||
Fixed positioning solves these:
|
||||
1. Panel stays in place - no DOM movement needed
|
||||
2. No layout shifts in the main page content
|
||||
3. Panel state (console, variables) naturally preserved
|
||||
4. Simpler CSS and JavaScript
|
||||
|
||||
### Layout Toggle UX
|
||||
|
||||
The toggle is a button group in the panel header showing both layout options:
|
||||
- Sidebar icon (vertical split)
|
||||
- Bottom icon (horizontal split)
|
||||
|
||||
The active layout is highlighted. Clicking the other option triggers `switchLayout()`.
|
||||
|
||||
### Breakpoint Indicator Implementation
|
||||
|
||||
Uses CSS class `.dap-current-step` added to the `<check-step>` element:
|
||||
- `::after` pseudo-element creates the red accent bar
|
||||
- `::before` pseudo-element creates the triangle pointer
|
||||
- Direct child selectors style the step header background and border
|
||||
|
||||
The indicator is updated in `handleStoppedEvent()` when the debugger pauses at a new step.
|
||||
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)
|
||||
155
.opencode/plans/dap-step-backward-duplicate-function-fix.md
Normal file
155
.opencode/plans/dap-step-backward-duplicate-function-fix.md
Normal file
@@ -0,0 +1,155 @@
|
||||
# DAP Step Backward: Duplicate Expression Function Fix
|
||||
|
||||
**Status:** Ready for Implementation
|
||||
**Date:** January 2026
|
||||
**Related:** [dap-step-backwards.md](./dap-step-backwards.md)
|
||||
|
||||
## Problem
|
||||
|
||||
When stepping backward and then forward again during DAP debugging, the runner crashes with:
|
||||
|
||||
```
|
||||
System.ArgumentException: An item with the same key has already been added. Key: always
|
||||
at System.Collections.Generic.Dictionary`2.TryInsert(...)
|
||||
at GitHub.DistributedTask.Expressions2.ExpressionParser.ParseContext..ctor(...)
|
||||
```
|
||||
|
||||
### Reproduction Steps
|
||||
|
||||
1. Run a workflow with DAP debugging enabled
|
||||
2. Let a step execute (e.g., `cat doesnotexist`)
|
||||
3. Before the next step runs, step backward
|
||||
4. Optionally run REPL commands
|
||||
5. Step forward to re-run the step
|
||||
6. Step forward again → **CRASH**
|
||||
|
||||
## Root Cause Analysis
|
||||
|
||||
### The Bug
|
||||
|
||||
In `StepsRunner.cs:89-93`, expression functions are added to `step.ExecutionContext.ExpressionFunctions` every time a step is processed:
|
||||
|
||||
```csharp
|
||||
// Expression functions
|
||||
step.ExecutionContext.ExpressionFunctions.Add(new FunctionInfo<AlwaysFunction>(PipelineTemplateConstants.Always, 0, 0));
|
||||
step.ExecutionContext.ExpressionFunctions.Add(new FunctionInfo<CancelledFunction>(PipelineTemplateConstants.Cancelled, 0, 0));
|
||||
step.ExecutionContext.ExpressionFunctions.Add(new FunctionInfo<FailureFunction>(PipelineTemplateConstants.Failure, 0, 0));
|
||||
step.ExecutionContext.ExpressionFunctions.Add(new FunctionInfo<SuccessFunction>(PipelineTemplateConstants.Success, 0, 0));
|
||||
step.ExecutionContext.ExpressionFunctions.Add(new FunctionInfo<HashFilesFunction>(PipelineTemplateConstants.HashFiles, 1, byte.MaxValue));
|
||||
```
|
||||
|
||||
### Why It Fails on Step-Back
|
||||
|
||||
1. **First execution:** Step is dequeued, functions added to `ExpressionFunctions`, step runs
|
||||
2. **Checkpoint created:** Stores a **reference** to the `IStep` object (not a deep copy) - see `StepCheckpoint.cs:65`
|
||||
3. **Step backward:** Checkpoint is restored, the **same** `IStep` object is re-queued to `jobContext.JobSteps`
|
||||
4. **Second execution:** Step is dequeued again, functions added **again** to the same `ExpressionFunctions` list
|
||||
5. **Duplicate entries:** The list now has two `AlwaysFunction` entries, two `CancelledFunction` entries, etc.
|
||||
6. **Crash:** When `ExpressionParser.ParseContext` constructor iterates over functions and adds them to a `Dictionary` (`ExpressionParser.cs:460-465`), it throws on the duplicate key "always"
|
||||
|
||||
### Key Insight
|
||||
|
||||
The `ExpressionFunctions` property on `ExecutionContext` is a `List<IFunctionInfo>` (`ExecutionContext.cs:199`). `List<T>.Add()` doesn't check for duplicates, so the functions get added twice. The error only manifests later when the expression parser builds its internal dictionary.
|
||||
|
||||
## Solution
|
||||
|
||||
### Chosen Approach: Clear ExpressionFunctions Before Adding
|
||||
|
||||
Clear the `ExpressionFunctions` list before adding the functions. This ensures a known state regardless of how the step arrived in the queue (fresh or restored from checkpoint).
|
||||
|
||||
### Why This Approach
|
||||
|
||||
| Approach | Pros | Cons |
|
||||
|----------|------|------|
|
||||
| **Clear before adding (chosen)** | Simple, explicit, ensures known state, works for any re-processing scenario | Slightly more work than strictly necessary on first run |
|
||||
| Check before adding | Defensive | More complex, multiple conditions to check |
|
||||
| Reset on checkpoint restore | Localized to DAP | Requires changes in multiple places, easy to miss edge cases |
|
||||
|
||||
The "clear before adding" approach is:
|
||||
- **Simple:** One line of code
|
||||
- **Robust:** Works regardless of why the step is being re-processed
|
||||
- **Safe:** The functions are always the same set, so clearing and re-adding has no side effects
|
||||
- **Future-proof:** If other code paths ever re-queue steps, this handles it automatically
|
||||
|
||||
## Implementation
|
||||
|
||||
### File to Modify
|
||||
|
||||
`src/Runner.Worker/StepsRunner.cs`
|
||||
|
||||
### Change
|
||||
|
||||
```csharp
|
||||
// Before line 88, add:
|
||||
step.ExecutionContext.ExpressionFunctions.Clear();
|
||||
|
||||
// Expression functions
|
||||
step.ExecutionContext.ExpressionFunctions.Add(new FunctionInfo<AlwaysFunction>(PipelineTemplateConstants.Always, 0, 0));
|
||||
// ... rest of the adds
|
||||
```
|
||||
|
||||
### Full Context (lines ~85-94)
|
||||
|
||||
**Before:**
|
||||
```csharp
|
||||
// Start
|
||||
step.ExecutionContext.Start();
|
||||
|
||||
// Expression functions
|
||||
step.ExecutionContext.ExpressionFunctions.Add(new FunctionInfo<AlwaysFunction>(PipelineTemplateConstants.Always, 0, 0));
|
||||
step.ExecutionContext.ExpressionFunctions.Add(new FunctionInfo<CancelledFunction>(PipelineTemplateConstants.Cancelled, 0, 0));
|
||||
step.ExecutionContext.ExpressionFunctions.Add(new FunctionInfo<FailureFunction>(PipelineTemplateConstants.Failure, 0, 0));
|
||||
step.ExecutionContext.ExpressionFunctions.Add(new FunctionInfo<SuccessFunction>(PipelineTemplateConstants.Success, 0, 0));
|
||||
step.ExecutionContext.ExpressionFunctions.Add(new FunctionInfo<HashFilesFunction>(PipelineTemplateConstants.HashFiles, 1, byte.MaxValue));
|
||||
```
|
||||
|
||||
**After:**
|
||||
```csharp
|
||||
// Start
|
||||
step.ExecutionContext.Start();
|
||||
|
||||
// Expression functions
|
||||
// Clear first to handle step-back scenarios where the same step may be re-processed
|
||||
step.ExecutionContext.ExpressionFunctions.Clear();
|
||||
step.ExecutionContext.ExpressionFunctions.Add(new FunctionInfo<AlwaysFunction>(PipelineTemplateConstants.Always, 0, 0));
|
||||
step.ExecutionContext.ExpressionFunctions.Add(new FunctionInfo<CancelledFunction>(PipelineTemplateConstants.Cancelled, 0, 0));
|
||||
step.ExecutionContext.ExpressionFunctions.Add(new FunctionInfo<FailureFunction>(PipelineTemplateConstants.Failure, 0, 0));
|
||||
step.ExecutionContext.ExpressionFunctions.Add(new FunctionInfo<SuccessFunction>(PipelineTemplateConstants.Success, 0, 0));
|
||||
step.ExecutionContext.ExpressionFunctions.Add(new FunctionInfo<HashFilesFunction>(PipelineTemplateConstants.HashFiles, 1, byte.MaxValue));
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Manual Test Scenario
|
||||
|
||||
1. Create a workflow with multiple steps
|
||||
2. Enable DAP debugging
|
||||
3. Let step 1 execute
|
||||
4. Pause before step 2
|
||||
5. Step backward (restore to before step 1)
|
||||
6. Step forward (re-run step 1)
|
||||
7. Step forward again (run step 2)
|
||||
8. **Verify:** No crash, step 2's condition evaluates correctly
|
||||
|
||||
### Edge Cases to Verify
|
||||
|
||||
- [ ] Step backward multiple times in a row
|
||||
- [ ] Step backward then run REPL commands, then step forward
|
||||
- [ ] `reverseContinue` to beginning, then step through all steps again
|
||||
- [ ] Steps with `if: always()` condition (the specific function that was failing)
|
||||
- [ ] Steps with `if: failure()` or `if: cancelled()` conditions
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
**Risk: Low**
|
||||
|
||||
- The fix is minimal (one line)
|
||||
- `ExpressionFunctions` is always populated with the same 5 functions at this point
|
||||
- No other code depends on functions being accumulated across step re-runs
|
||||
- Normal (non-DAP) execution is unaffected since steps are never re-queued
|
||||
|
||||
## Files Summary
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `src/Runner.Worker/StepsRunner.cs` | Add `Clear()` call before adding expression functions |
|
||||
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
853
.opencode/plans/dap-step-commands-refinements.md
Normal file
853
.opencode/plans/dap-step-commands-refinements.md
Normal file
@@ -0,0 +1,853 @@
|
||||
# Step Commands Refinements: --here, --id, and Help
|
||||
|
||||
**Status:** Draft
|
||||
**Author:** GitHub Actions Team
|
||||
**Date:** January 2026
|
||||
**Prerequisites:** dap-step-manipulation.md (completed)
|
||||
|
||||
## Progress Checklist
|
||||
|
||||
- [x] **Chunk 1:** `--here` Position Option
|
||||
- [x] **Chunk 2:** `--id` Option for Step Identification
|
||||
- [x] **Chunk 3:** Help Commands (`--help`)
|
||||
- [x] **Chunk 4:** Browser Extension UI Updates
|
||||
|
||||
## Overview
|
||||
|
||||
This plan addresses three refinements to the step manipulation commands based on user feedback:
|
||||
|
||||
1. **`--here` position option**: Insert a step before the current step (the one you're paused at), so it runs immediately when stepping forward
|
||||
2. **`--id` option**: Allow users to specify a custom step ID for later reference (e.g., `steps.<id>.outputs`)
|
||||
3. **Help commands**: Add `--help` flag support to all step commands for discoverability
|
||||
|
||||
## Problem Statement
|
||||
|
||||
### Issue 1: "First pending position" inserts in the wrong place
|
||||
|
||||
When paused before a step (e.g., checkout at position 1), using `--first` inserts the new step *after* the current step, not before it:
|
||||
|
||||
```
|
||||
Before (paused at step 1):
|
||||
▶ 1. Checkout
|
||||
|
||||
After "steps add run 'echo hello' --first":
|
||||
▶ 1. Checkout
|
||||
2. hello [ADDED] <-- Wrong! Should be before Checkout
|
||||
```
|
||||
|
||||
**Root cause:** `PositionType.First` returns index 0 of the `JobSteps` queue, which contains steps *after* the current step. The current step is held separately in `_currentStep`.
|
||||
|
||||
**Expected behavior:** User wants to insert a step that will run immediately when they continue, i.e., before the current step.
|
||||
|
||||
### Issue 2: No way to specify step ID
|
||||
|
||||
Dynamically added steps get auto-generated IDs like `_dynamic_<guid>`, making them impossible to reference in expressions like `steps.<id>.outputs.foo`.
|
||||
|
||||
### Issue 3: Command options are hard to remember
|
||||
|
||||
With growing options (`--name`, `--shell`, `--after`, `--before`, `--at`, `--first`, `--last`, etc.), users need a way to quickly see available options without consulting documentation.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: `--here` Position Option
|
||||
|
||||
**Goal:** Add a new position option that inserts a step before the current step (the one paused at a breakpoint).
|
||||
|
||||
### Design
|
||||
|
||||
| Flag | Meaning |
|
||||
|------|---------|
|
||||
| `--here` | Insert before the current step, so it becomes the next step to run |
|
||||
|
||||
**Behavior:**
|
||||
- Only valid when paused at a breakpoint
|
||||
- Returns error if not paused: "Can only use --here when paused at a breakpoint"
|
||||
- Inserts the new step such that it will execute immediately when the user continues/steps forward
|
||||
|
||||
**Example:**
|
||||
```
|
||||
Before (paused at step 1):
|
||||
▶ 1. Checkout
|
||||
2. Build
|
||||
3. Test
|
||||
|
||||
After "steps add run 'echo hello' --here":
|
||||
▶ 1. hello [ADDED] <-- New step runs next
|
||||
2. Checkout
|
||||
3. Build
|
||||
4. Test
|
||||
```
|
||||
|
||||
### Files to Modify
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `StepCommandParser.cs` | Add `Here` to `PositionType` enum; add `StepPosition.Here()` factory; parse `--here` flag in add/move commands |
|
||||
| `StepManipulator.cs` | Handle `PositionType.Here` in `CalculateInsertIndex()` and `CalculateMoveTargetIndex()` |
|
||||
|
||||
### Implementation Details
|
||||
|
||||
**StepCommandParser.cs:**
|
||||
|
||||
```csharp
|
||||
// Add to PositionType enum
|
||||
public enum PositionType
|
||||
{
|
||||
At,
|
||||
After,
|
||||
Before,
|
||||
First,
|
||||
Last,
|
||||
Here // NEW: Insert before current step (requires paused state)
|
||||
}
|
||||
|
||||
// Add factory method to StepPosition
|
||||
public static StepPosition Here() => new StepPosition { Type = PositionType.Here };
|
||||
|
||||
// Update ToString()
|
||||
PositionType.Here => "here",
|
||||
|
||||
// In ParseReplAddRunCommand and ParseReplAddUsesCommand, add case:
|
||||
case "--here":
|
||||
cmd.Position = StepPosition.Here();
|
||||
break;
|
||||
|
||||
// Same for ParseReplMoveCommand
|
||||
```
|
||||
|
||||
**StepManipulator.cs:**
|
||||
|
||||
```csharp
|
||||
// In CalculateInsertIndex():
|
||||
case PositionType.Here:
|
||||
{
|
||||
// "Here" means before the current step
|
||||
// Since current step is held separately (not in JobSteps queue),
|
||||
// we need to:
|
||||
// 1. Verify we're paused (have a current step)
|
||||
// 2. Insert at position 0 of pending AND move current step after it
|
||||
|
||||
if (_currentStep == null)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.InvalidPosition,
|
||||
"Can only use --here when paused at a breakpoint.");
|
||||
}
|
||||
|
||||
// The new step goes at index 0, and we need to re-queue the current step
|
||||
// Actually, we need a different approach - see "Special handling" below
|
||||
}
|
||||
```
|
||||
|
||||
**Special handling for `--here`:**
|
||||
|
||||
The current architecture has `_currentStep` held separately from `JobSteps`. To insert "before" the current step, we need to:
|
||||
|
||||
1. Insert the new step at position 0 of `JobSteps`
|
||||
2. Move `_currentStep` back into `JobSteps` at position 1
|
||||
3. Set the new step as `_currentStep`
|
||||
|
||||
Alternative (simpler): Modify `InsertStep` to handle `Here` specially:
|
||||
|
||||
```csharp
|
||||
public int InsertStep(IStep step, StepPosition position)
|
||||
{
|
||||
// Special case: --here inserts before current step
|
||||
if (position.Type == PositionType.Here)
|
||||
{
|
||||
if (_currentStep == null)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.InvalidPosition,
|
||||
"Can only use --here when paused at a breakpoint.");
|
||||
}
|
||||
|
||||
// Re-queue current step at the front
|
||||
var pending = _jobContext.JobSteps.ToList();
|
||||
pending.Insert(0, _currentStep);
|
||||
|
||||
// Insert new step before it (at position 0)
|
||||
pending.Insert(0, step);
|
||||
|
||||
// Clear and re-queue
|
||||
_jobContext.JobSteps.Clear();
|
||||
foreach (var s in pending)
|
||||
_jobContext.JobSteps.Enqueue(s);
|
||||
|
||||
// New step becomes current
|
||||
_currentStep = step;
|
||||
|
||||
// Track change and return index
|
||||
var newIndex = _completedSteps.Count + 1;
|
||||
// ... track change ...
|
||||
return newIndex;
|
||||
}
|
||||
|
||||
// ... existing logic for other position types ...
|
||||
}
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
- [ ] `steps add run "echo test" --here` when paused at step 1 inserts at position 1
|
||||
- [ ] New step becomes the current step (shows as `▶` in list)
|
||||
- [ ] Original current step moves to position 2
|
||||
- [ ] Stepping forward runs the new step first
|
||||
- [ ] `--here` when not paused returns appropriate error
|
||||
- [ ] `steps move 3 --here` moves step 3 to before current step
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: `--id` Option for Step Identification
|
||||
|
||||
**Goal:** Allow users to specify a custom ID for dynamically added steps.
|
||||
|
||||
### Design
|
||||
|
||||
| Flag | Meaning |
|
||||
|------|---------|
|
||||
| `--id <identifier>` | Set the step's ID (used in `steps.<id>.outputs`, etc.) |
|
||||
|
||||
**Validation:**
|
||||
- ID must be a non-empty string
|
||||
- No format restrictions (matches YAML behavior - users can use any string)
|
||||
|
||||
**Duplicate handling:**
|
||||
- If a step with the same ID already exists, return error: "Step with ID '<id>' already exists"
|
||||
|
||||
**Default behavior (unchanged):**
|
||||
- If `--id` is not provided, auto-generate `_dynamic_<guid>` as before
|
||||
|
||||
### Files to Modify
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `StepCommandParser.cs` | Add `Id` property to `AddRunCommand` and `AddUsesCommand`; parse `--id` flag |
|
||||
| `StepFactory.cs` | Add `id` parameter to `CreateRunStep()` and `CreateUsesStep()`; use provided ID or generate one |
|
||||
| `StepCommandHandler.cs` | Pass `Id` from command to factory; validate uniqueness |
|
||||
| `StepManipulator.cs` | Add `HasStepWithId(string id)` method for uniqueness check |
|
||||
|
||||
### Implementation Details
|
||||
|
||||
**StepCommandParser.cs:**
|
||||
|
||||
```csharp
|
||||
// Add to AddRunCommand and AddUsesCommand classes:
|
||||
public string Id { get; set; }
|
||||
|
||||
// In ParseReplAddRunCommand and ParseReplAddUsesCommand:
|
||||
case "--id":
|
||||
cmd.Id = GetNextArg(tokens, ref i, "--id");
|
||||
break;
|
||||
```
|
||||
|
||||
**StepFactory.cs:**
|
||||
|
||||
```csharp
|
||||
// Update method signatures:
|
||||
ActionStep CreateRunStep(
|
||||
string script,
|
||||
string id = null, // NEW
|
||||
string name = null,
|
||||
// ... rest unchanged
|
||||
);
|
||||
|
||||
ActionStep CreateUsesStep(
|
||||
string actionReference,
|
||||
string id = null, // NEW
|
||||
string name = null,
|
||||
// ... rest unchanged
|
||||
);
|
||||
|
||||
// In implementation:
|
||||
public ActionStep CreateRunStep(string script, string id = null, ...)
|
||||
{
|
||||
var stepId = Guid.NewGuid();
|
||||
var step = new ActionStep
|
||||
{
|
||||
Id = stepId,
|
||||
Name = id ?? $"_dynamic_{stepId:N}", // Use provided ID or generate
|
||||
DisplayName = name ?? "Run script",
|
||||
// ...
|
||||
};
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**StepManipulator.cs:**
|
||||
|
||||
```csharp
|
||||
// Add method to check for duplicate IDs:
|
||||
public bool HasStepWithId(string id)
|
||||
{
|
||||
if (string.IsNullOrEmpty(id))
|
||||
return false;
|
||||
|
||||
// Check completed steps
|
||||
foreach (var step in _completedSteps)
|
||||
{
|
||||
if (step is IActionRunner runner && runner.Action?.Name == id)
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check current step
|
||||
if (_currentStep is IActionRunner currentRunner && currentRunner.Action?.Name == id)
|
||||
return true;
|
||||
|
||||
// Check pending steps
|
||||
foreach (var step in _jobContext.JobSteps)
|
||||
{
|
||||
if (step is IActionRunner pendingRunner && pendingRunner.Action?.Name == id)
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
```
|
||||
|
||||
**StepCommandHandler.cs:**
|
||||
|
||||
```csharp
|
||||
// In HandleAddRunCommand and HandleAddUsesCommand:
|
||||
if (!string.IsNullOrEmpty(cmd.Id) && _manipulator.HasStepWithId(cmd.Id))
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.DuplicateId,
|
||||
$"Step with ID '{cmd.Id}' already exists.");
|
||||
}
|
||||
|
||||
var actionStep = _factory.CreateRunStep(
|
||||
cmd.Script,
|
||||
cmd.Id, // NEW
|
||||
cmd.Name,
|
||||
// ...
|
||||
);
|
||||
```
|
||||
|
||||
### Command Examples
|
||||
|
||||
```bash
|
||||
# Add step with custom ID
|
||||
steps add run "echo hello" --id greet --name "Greeting"
|
||||
|
||||
# Reference in later step
|
||||
steps add run "echo ${{ steps.greet.outputs.result }}"
|
||||
|
||||
# Duplicate ID returns error
|
||||
steps add run "echo bye" --id greet
|
||||
# Error: Step with ID 'greet' already exists
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
- [ ] `steps add run "echo test" --id my_step` creates step with ID `my_step`
|
||||
- [ ] Step ID appears correctly in `steps list` output
|
||||
- [ ] Attempting duplicate ID returns clear error
|
||||
- [ ] Omitting `--id` still generates `_dynamic_<guid>` IDs
|
||||
- [ ] ID is correctly set on the underlying `ActionStep.Name` property
|
||||
|
||||
---
|
||||
|
||||
## Chunk 3: Help Commands (`--help`)
|
||||
|
||||
**Goal:** Add `--help` flag support to provide usage information for all step commands.
|
||||
|
||||
### Design
|
||||
|
||||
| Command | Output |
|
||||
|---------|--------|
|
||||
| `steps` | List of available subcommands |
|
||||
| `steps --help` | Same as above |
|
||||
| `steps add --help` | Help for `add` command (shows `run` and `uses` subcommands) |
|
||||
| `steps add run --help` | Help for `add run` with all options |
|
||||
| `steps add uses --help` | Help for `add uses` with all options |
|
||||
| `steps edit --help` | Help for `edit` command |
|
||||
| `steps remove --help` | Help for `remove` command |
|
||||
| `steps move --help` | Help for `move` command |
|
||||
| `steps list --help` | Help for `list` command |
|
||||
| `steps export --help` | Help for `export` command |
|
||||
|
||||
**Output format:** Text only (no JSON support needed)
|
||||
|
||||
### Files to Modify
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `StepCommandParser.cs` | Add `HelpCommand` class; detect `--help` flag and return appropriate help command |
|
||||
| `StepCommandHandler.cs` | Add `HandleHelpCommand()` with help text for each command |
|
||||
|
||||
### Implementation Details
|
||||
|
||||
**StepCommandParser.cs:**
|
||||
|
||||
```csharp
|
||||
// Add new command class:
|
||||
public class HelpCommand : StepCommand
|
||||
{
|
||||
/// <summary>
|
||||
/// The command to show help for (null = top-level help)
|
||||
/// </summary>
|
||||
public string Command { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Sub-command if applicable (e.g., "run" for "steps add run --help")
|
||||
/// </summary>
|
||||
public string SubCommand { get; set; }
|
||||
}
|
||||
|
||||
// Modify ParseReplCommand to detect --help:
|
||||
private StepCommand ParseReplCommand(string input)
|
||||
{
|
||||
var tokens = Tokenize(input);
|
||||
|
||||
// Handle bare "steps" command
|
||||
if (tokens.Count == 1 && tokens[0].Equals("steps", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new HelpCommand { Command = null };
|
||||
}
|
||||
|
||||
if (tokens.Count < 2 || !tokens[0].Equals("steps", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.ParseError,
|
||||
"Invalid command format. Expected: steps <command> [args...]");
|
||||
}
|
||||
|
||||
// Check for --help anywhere in tokens
|
||||
if (tokens.Contains("--help") || tokens.Contains("-h"))
|
||||
{
|
||||
return ParseHelpCommand(tokens);
|
||||
}
|
||||
|
||||
var subCommand = tokens[1].ToLower();
|
||||
// ... existing switch ...
|
||||
}
|
||||
|
||||
private HelpCommand ParseHelpCommand(List<string> tokens)
|
||||
{
|
||||
// Remove --help/-h from tokens
|
||||
tokens.RemoveAll(t => t == "--help" || t == "-h");
|
||||
|
||||
// "steps --help" or "steps"
|
||||
if (tokens.Count == 1)
|
||||
{
|
||||
return new HelpCommand { Command = null };
|
||||
}
|
||||
|
||||
// "steps add --help"
|
||||
var cmd = tokens[1].ToLower();
|
||||
|
||||
// "steps add run --help"
|
||||
string subCmd = null;
|
||||
if (tokens.Count >= 3 && (cmd == "add"))
|
||||
{
|
||||
subCmd = tokens[2].ToLower();
|
||||
if (subCmd != "run" && subCmd != "uses")
|
||||
subCmd = null;
|
||||
}
|
||||
|
||||
return new HelpCommand { Command = cmd, SubCommand = subCmd };
|
||||
}
|
||||
```
|
||||
|
||||
**StepCommandHandler.cs:**
|
||||
|
||||
```csharp
|
||||
private StepCommandResult HandleHelpCommand(HelpCommand cmd)
|
||||
{
|
||||
string helpText = (cmd.Command, cmd.SubCommand) switch
|
||||
{
|
||||
(null, _) => GetTopLevelHelp(),
|
||||
("add", null) => GetAddHelp(),
|
||||
("add", "run") => GetAddRunHelp(),
|
||||
("add", "uses") => GetAddUsesHelp(),
|
||||
("edit", _) => GetEditHelp(),
|
||||
("remove", _) => GetRemoveHelp(),
|
||||
("move", _) => GetMoveHelp(),
|
||||
("list", _) => GetListHelp(),
|
||||
("export", _) => GetExportHelp(),
|
||||
_ => $"Unknown command: {cmd.Command}"
|
||||
};
|
||||
|
||||
return new StepCommandResult
|
||||
{
|
||||
Success = true,
|
||||
Message = helpText
|
||||
};
|
||||
}
|
||||
|
||||
private string GetTopLevelHelp() => @"
|
||||
steps - Manipulate job steps during debug session
|
||||
|
||||
COMMANDS:
|
||||
list Show all steps with status
|
||||
add Add a new step (run or uses)
|
||||
edit Modify a pending step
|
||||
remove Delete a pending step
|
||||
move Reorder a pending step
|
||||
export Generate YAML for modified steps
|
||||
|
||||
Use 'steps <command> --help' for more information about a command.
|
||||
".Trim();
|
||||
|
||||
private string GetAddHelp() => @"
|
||||
steps add - Add a new step to the job
|
||||
|
||||
USAGE:
|
||||
steps add run <script> [options] Add a shell command step
|
||||
steps add uses <action> [options] Add an action step
|
||||
|
||||
Use 'steps add run --help' or 'steps add uses --help' for detailed options.
|
||||
".Trim();
|
||||
|
||||
private string GetAddRunHelp() => @"
|
||||
steps add run - Add a shell command step
|
||||
|
||||
USAGE:
|
||||
steps add run ""<script>"" [options]
|
||||
|
||||
OPTIONS:
|
||||
--id <id> Step ID for referencing in expressions
|
||||
--name ""<name>"" Display name for the step
|
||||
--shell <shell> Shell to use (bash, sh, pwsh, python, cmd)
|
||||
--working-directory <dir> Working directory for the script
|
||||
--if ""<condition>"" Condition expression (default: success())
|
||||
--env KEY=value Environment variable (can repeat)
|
||||
--continue-on-error Don't fail job if step fails
|
||||
--timeout <minutes> Step timeout in minutes
|
||||
|
||||
POSITION OPTIONS:
|
||||
--here Insert before current step (default)
|
||||
--after <index> Insert after step at index
|
||||
--before <index> Insert before step at index
|
||||
--at <index> Insert at specific index
|
||||
--first Insert at first pending position
|
||||
--last Insert at end of job
|
||||
|
||||
EXAMPLES:
|
||||
steps add run ""npm test""
|
||||
steps add run ""echo hello"" --name ""Greeting"" --id greet
|
||||
steps add run ""./build.sh"" --shell bash --after 3
|
||||
".Trim();
|
||||
|
||||
private string GetAddUsesHelp() => @"
|
||||
steps add uses - Add an action step
|
||||
|
||||
USAGE:
|
||||
steps add uses <action@ref> [options]
|
||||
|
||||
OPTIONS:
|
||||
--id <id> Step ID for referencing in expressions
|
||||
--name ""<name>"" Display name for the step
|
||||
--with key=value Action input (can repeat)
|
||||
--env KEY=value Environment variable (can repeat)
|
||||
--if ""<condition>"" Condition expression (default: success())
|
||||
--continue-on-error Don't fail job if step fails
|
||||
--timeout <minutes> Step timeout in minutes
|
||||
|
||||
POSITION OPTIONS:
|
||||
--here Insert before current step (default)
|
||||
--after <index> Insert after step at index
|
||||
--before <index> Insert before step at index
|
||||
--at <index> Insert at specific index
|
||||
--first Insert at first pending position
|
||||
--last Insert at end of job
|
||||
|
||||
EXAMPLES:
|
||||
steps add uses actions/checkout@v4
|
||||
steps add uses actions/setup-node@v4 --with node-version=20
|
||||
steps add uses ./my-action --name ""Local Action"" --after 2
|
||||
".Trim();
|
||||
|
||||
private string GetEditHelp() => @"
|
||||
steps edit - Modify a pending step
|
||||
|
||||
USAGE:
|
||||
steps edit <index> [modifications]
|
||||
|
||||
MODIFICATIONS:
|
||||
--name ""<name>"" Change display name
|
||||
--script ""<script>"" Change script (run steps only)
|
||||
--shell <shell> Change shell (run steps only)
|
||||
--working-directory <dir> Change working directory
|
||||
--if ""<condition>"" Change condition expression
|
||||
--with key=value Set/update action input (uses steps only)
|
||||
--env KEY=value Set/update environment variable
|
||||
--remove-with <key> Remove action input
|
||||
--remove-env <key> Remove environment variable
|
||||
--continue-on-error Enable continue-on-error
|
||||
--no-continue-on-error Disable continue-on-error
|
||||
--timeout <minutes> Change timeout
|
||||
|
||||
EXAMPLES:
|
||||
steps edit 3 --name ""Updated Name""
|
||||
steps edit 4 --script ""npm run test:ci""
|
||||
steps edit 2 --env DEBUG=true --timeout 30
|
||||
".Trim();
|
||||
|
||||
private string GetRemoveHelp() => @"
|
||||
steps remove - Delete a pending step
|
||||
|
||||
USAGE:
|
||||
steps remove <index>
|
||||
|
||||
ARGUMENTS:
|
||||
<index> 1-based index of the step to remove (must be pending)
|
||||
|
||||
EXAMPLES:
|
||||
steps remove 5
|
||||
steps remove 3
|
||||
".Trim();
|
||||
|
||||
private string GetMoveHelp() => @"
|
||||
steps move - Reorder a pending step
|
||||
|
||||
USAGE:
|
||||
steps move <from> <position>
|
||||
|
||||
ARGUMENTS:
|
||||
<from> 1-based index of the step to move (must be pending)
|
||||
|
||||
POSITION OPTIONS:
|
||||
--here Move before current step
|
||||
--after <index> Move after step at index
|
||||
--before <index> Move before step at index
|
||||
--to <index> Move to specific index
|
||||
--first Move to first pending position
|
||||
--last Move to end of job
|
||||
|
||||
EXAMPLES:
|
||||
steps move 5 --after 2
|
||||
steps move 4 --first
|
||||
steps move 3 --here
|
||||
".Trim();
|
||||
|
||||
private string GetListHelp() => @"
|
||||
steps list - Show all steps with status
|
||||
|
||||
USAGE:
|
||||
steps list [options]
|
||||
|
||||
OPTIONS:
|
||||
--verbose Show additional step details
|
||||
--output json|text Output format (default: text)
|
||||
|
||||
OUTPUT:
|
||||
Shows all steps with:
|
||||
- Index number
|
||||
- Status indicator (completed, current, pending)
|
||||
- Step name
|
||||
- Step type (run/uses) and details
|
||||
- Change indicator ([ADDED], [MODIFIED], [MOVED])
|
||||
".Trim();
|
||||
|
||||
private string GetExportHelp() => @"
|
||||
steps export - Generate YAML for modified steps
|
||||
|
||||
USAGE:
|
||||
steps export [options]
|
||||
|
||||
OPTIONS:
|
||||
--changes-only Only export added/modified steps
|
||||
--with-comments Include change markers as YAML comments
|
||||
--output json|text Output format (default: text)
|
||||
|
||||
OUTPUT:
|
||||
Generates valid YAML that can be pasted into a workflow file.
|
||||
|
||||
EXAMPLES:
|
||||
steps export
|
||||
steps export --changes-only --with-comments
|
||||
".Trim();
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
- [ ] `steps` shows top-level help
|
||||
- [ ] `steps --help` shows top-level help
|
||||
- [ ] `steps -h` shows top-level help
|
||||
- [ ] `steps add --help` shows add command help
|
||||
- [ ] `steps add run --help` shows add run help with all options
|
||||
- [ ] `steps add uses --help` shows add uses help with all options
|
||||
- [ ] `steps edit --help` shows edit help
|
||||
- [ ] `steps remove --help` shows remove help
|
||||
- [ ] `steps move --help` shows move help
|
||||
- [ ] `steps list --help` shows list help
|
||||
- [ ] `steps export --help` shows export help
|
||||
- [ ] `--help` can appear anywhere in command (e.g., `steps add --help run`)
|
||||
|
||||
---
|
||||
|
||||
## Chunk 4: Browser Extension UI Updates
|
||||
|
||||
**Goal:** Update the Add Step form to use `--here` as default and add the ID field.
|
||||
|
||||
### Changes to `browser-ext/content/content.js`
|
||||
|
||||
#### 1. Update Position Dropdown
|
||||
|
||||
**Current options:**
|
||||
- "At end (default)"
|
||||
- "At first pending position"
|
||||
- "After current step"
|
||||
|
||||
**New options:**
|
||||
- "Before next step" (default) - uses `--here`
|
||||
- "At end"
|
||||
- "After current step"
|
||||
|
||||
```javascript
|
||||
// In showAddStepDialog():
|
||||
<div class="dap-form-group">
|
||||
<label class="dap-label">Position</label>
|
||||
<select class="form-control dap-position-select">
|
||||
<option value="here" selected>Before next step</option>
|
||||
<option value="last">At end</option>
|
||||
<option value="after">After current step</option>
|
||||
</select>
|
||||
</div>
|
||||
```
|
||||
|
||||
#### 2. Add ID Field
|
||||
|
||||
Add after the Name field:
|
||||
|
||||
```javascript
|
||||
<div class="dap-form-group">
|
||||
<label class="dap-label">ID (optional)</label>
|
||||
<input type="text" class="form-control dap-id-input"
|
||||
placeholder="my_step_id">
|
||||
<span class="dap-help-text">Used to reference step outputs: steps.<id>.outputs</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
#### 3. Update `handleAddStep()`
|
||||
|
||||
```javascript
|
||||
async function handleAddStep(modal) {
|
||||
const type = modal.querySelector('.dap-step-type-select').value;
|
||||
const name = modal.querySelector('.dap-name-input').value.trim() || undefined;
|
||||
const id = modal.querySelector('.dap-id-input').value.trim() || undefined; // NEW
|
||||
const positionSelect = modal.querySelector('.dap-position-select').value;
|
||||
|
||||
let position = {};
|
||||
if (positionSelect === 'here') {
|
||||
position.here = true; // NEW
|
||||
} else if (positionSelect === 'after') {
|
||||
const currentStep = stepsList.find((s) => s.status === 'current');
|
||||
if (currentStep) {
|
||||
position.after = currentStep.index;
|
||||
} else {
|
||||
position.here = true;
|
||||
}
|
||||
} else {
|
||||
position.last = true;
|
||||
}
|
||||
|
||||
// Pass id to sendStepCommand
|
||||
result = await sendStepCommand('step.add', {
|
||||
type: 'run',
|
||||
script,
|
||||
id, // NEW
|
||||
name,
|
||||
shell,
|
||||
position,
|
||||
});
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. Update `buildAddStepCommand()`
|
||||
|
||||
```javascript
|
||||
function buildAddStepCommand(options) {
|
||||
let cmd = 'steps add';
|
||||
|
||||
if (options.type === 'run') {
|
||||
cmd += ` run ${quoteString(options.script)}`;
|
||||
if (options.shell) cmd += ` --shell ${options.shell}`;
|
||||
if (options.workingDirectory) cmd += ` --working-directory ${quoteString(options.workingDirectory)}`;
|
||||
} else if (options.type === 'uses') {
|
||||
cmd += ` uses ${options.action}`;
|
||||
if (options.with) {
|
||||
for (const [key, value] of Object.entries(options.with)) {
|
||||
cmd += ` --with ${key}=${value}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (options.id) cmd += ` --id ${quoteString(options.id)}`; // NEW
|
||||
if (options.name) cmd += ` --name ${quoteString(options.name)}`;
|
||||
if (options.if) cmd += ` --if ${quoteString(options.if)}`;
|
||||
// ... rest of options ...
|
||||
|
||||
// Position
|
||||
if (options.position) {
|
||||
if (options.position.here) cmd += ' --here'; // NEW
|
||||
else if (options.position.after !== undefined) cmd += ` --after ${options.position.after}`;
|
||||
else if (options.position.before !== undefined) cmd += ` --before ${options.position.before}`;
|
||||
else if (options.position.at !== undefined) cmd += ` --at ${options.position.at}`;
|
||||
else if (options.position.first) cmd += ' --first';
|
||||
// --last is default, no need to specify
|
||||
}
|
||||
|
||||
return cmd;
|
||||
}
|
||||
```
|
||||
|
||||
### CSS Updates (`browser-ext/content/content.css`)
|
||||
|
||||
```css
|
||||
.dap-help-text {
|
||||
font-size: 11px;
|
||||
color: var(--fgColor-muted, #8b949e);
|
||||
margin-top: 4px;
|
||||
display: block;
|
||||
}
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
- [ ] Position dropdown defaults to "Before next step"
|
||||
- [ ] ID field is visible and optional
|
||||
- [ ] ID placeholder text is helpful
|
||||
- [ ] Help text explains the purpose of ID
|
||||
- [ ] Adding step with ID works correctly
|
||||
- [ ] Adding step with "Before next step" uses `--here` flag
|
||||
- [ ] Form validation doesn't require ID
|
||||
|
||||
---
|
||||
|
||||
## File Summary
|
||||
|
||||
### Files to Create
|
||||
|
||||
None - all changes are modifications to existing files.
|
||||
|
||||
### Files to Modify
|
||||
|
||||
| File | Chunks | Changes |
|
||||
|------|--------|---------|
|
||||
| `src/Runner.Worker/Dap/StepCommands/StepCommandParser.cs` | 1, 2, 3 | Add `Here` position type, `Id` property, `HelpCommand` class |
|
||||
| `src/Runner.Worker/Dap/StepCommands/StepManipulator.cs` | 1, 2 | Handle `Here` position, add `HasStepWithId()` method |
|
||||
| `src/Runner.Worker/Dap/StepCommands/StepFactory.cs` | 2 | Add `id` parameter to create methods |
|
||||
| `src/Runner.Worker/Dap/StepCommands/StepCommandHandler.cs` | 2, 3 | Pass ID to factory, add help text handlers |
|
||||
| `browser-ext/content/content.js` | 4 | Update form with ID field and position options |
|
||||
| `browser-ext/content/content.css` | 4 | Add help text styling |
|
||||
|
||||
---
|
||||
|
||||
## Error Messages
|
||||
|
||||
| Code | Message |
|
||||
|------|---------|
|
||||
| `INVALID_POSITION` | Can only use --here when paused at a breakpoint |
|
||||
| `DUPLICATE_ID` | Step with ID '<id>' already exists |
|
||||
|
||||
---
|
||||
|
||||
## Estimated Effort
|
||||
|
||||
| Chunk | Effort |
|
||||
|-------|--------|
|
||||
| Chunk 1: `--here` position | ~1-2 hours |
|
||||
| Chunk 2: `--id` option | ~1 hour |
|
||||
| Chunk 3: Help commands | ~1-2 hours |
|
||||
| Chunk 4: Browser extension UI | ~30 min |
|
||||
| **Total** | **~4-5 hours** |
|
||||
650
.opencode/plans/dap-step-commands-simplification.md
Normal file
650
.opencode/plans/dap-step-commands-simplification.md
Normal file
@@ -0,0 +1,650 @@
|
||||
# Plan: Simplify Step Commands to Use REPL Format
|
||||
|
||||
**Status:** Complete
|
||||
**Date:** January 2026
|
||||
**Prerequisites:** dap-step-manipulation.md (Chunks 1-9 completed)
|
||||
|
||||
## Overview
|
||||
|
||||
Remove the JSON API for step commands and use a single REPL command format (`steps <command>`) for both human input and browser extension UI. Add `--output` flag for controlling response format.
|
||||
|
||||
## Problem
|
||||
|
||||
Currently the step command system has two input formats:
|
||||
1. REPL format: `!step list` (for humans typing in console)
|
||||
2. JSON format: `{"cmd":"step.list"}` (for browser extension UI)
|
||||
|
||||
This causes issues:
|
||||
- The `!` prefix is awkward for humans typing commands
|
||||
- The JSON API is unnecessary complexity (browser extension is just another DAP client)
|
||||
- Debugging is harder because UI sends different format than humans would type
|
||||
- Two code paths to maintain and test
|
||||
|
||||
## Goals
|
||||
|
||||
1. Replace `!step` prefix with `steps` (more ergonomic, no special character)
|
||||
2. Remove JSON command parsing (unnecessary complexity)
|
||||
3. Add `--output` flag for response format control (`text` or `json`)
|
||||
4. Browser extension sends same command strings a human would type
|
||||
5. Single code path for all step command input
|
||||
|
||||
## Progress Checklist
|
||||
|
||||
- [x] **Chunk 1:** Update StepCommandParser - `steps` prefix, `--output` flag, remove JSON parsing
|
||||
- [x] **Chunk 2:** Update StepCommandHandler - format responses based on OutputFormat
|
||||
- [x] **Chunk 3:** Update Browser Extension - build REPL command strings
|
||||
- [x] **Chunk 4:** Update REPL context detection in browser extension
|
||||
- [x] **Chunk 5:** Update/remove tests
|
||||
- [x] **Chunk 6:** Update plan documentation
|
||||
|
||||
---
|
||||
|
||||
## Implementation Chunks
|
||||
|
||||
### Chunk 1: Update StepCommandParser to Use `steps` Prefix
|
||||
|
||||
**Files to modify:**
|
||||
- `src/Runner.Worker/Dap/StepCommands/StepCommandParser.cs`
|
||||
|
||||
**Changes:**
|
||||
|
||||
1. **Add `OutputFormat` enum and update `StepCommand` base class:**
|
||||
```csharp
|
||||
public enum OutputFormat
|
||||
{
|
||||
Text,
|
||||
Json
|
||||
}
|
||||
|
||||
public abstract class StepCommand
|
||||
{
|
||||
/// <summary>
|
||||
/// Output format for the command response.
|
||||
/// </summary>
|
||||
public OutputFormat Output { get; set; } = OutputFormat.Text;
|
||||
}
|
||||
```
|
||||
|
||||
Remove the `WasJsonInput` property (replaced by `OutputFormat`).
|
||||
|
||||
2. **Update `IsStepCommand()`** - recognize `steps` prefix, remove JSON detection:
|
||||
```csharp
|
||||
public bool IsStepCommand(string input)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input))
|
||||
return false;
|
||||
|
||||
var trimmed = input.Trim();
|
||||
|
||||
// Command format: steps ...
|
||||
if (trimmed.StartsWith("steps ", StringComparison.OrdinalIgnoreCase) ||
|
||||
trimmed.Equals("steps", StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
```
|
||||
|
||||
3. **Update `Parse()`** - remove JSON branch:
|
||||
```csharp
|
||||
public StepCommand Parse(string input)
|
||||
{
|
||||
var trimmed = input?.Trim() ?? "";
|
||||
return ParseReplCommand(trimmed);
|
||||
}
|
||||
```
|
||||
|
||||
4. **Update `ParseReplCommand()`** - expect `steps` as first token:
|
||||
```csharp
|
||||
if (tokens.Count < 2 || !tokens[0].Equals("steps", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.ParseError,
|
||||
"Invalid command format. Expected: steps <command> [args...]");
|
||||
}
|
||||
```
|
||||
|
||||
5. **Add `--output` flag parsing** - create a helper method and call it in each Parse*Command method:
|
||||
```csharp
|
||||
private OutputFormat ParseOutputFlag(List<string> tokens, ref int index)
|
||||
{
|
||||
// Look for --output, --output=json, --output=text, -o json, -o text
|
||||
for (int i = index; i < tokens.Count; i++)
|
||||
{
|
||||
var token = tokens[i].ToLower();
|
||||
if (token == "--output" || token == "-o")
|
||||
{
|
||||
if (i + 1 < tokens.Count)
|
||||
{
|
||||
var format = tokens[i + 1].ToLower();
|
||||
tokens.RemoveAt(i); // Remove flag
|
||||
tokens.RemoveAt(i); // Remove value
|
||||
return format == "json" ? OutputFormat.Json : OutputFormat.Text;
|
||||
}
|
||||
}
|
||||
else if (token.StartsWith("--output="))
|
||||
{
|
||||
var format = token.Substring("--output=".Length);
|
||||
tokens.RemoveAt(i);
|
||||
return format == "json" ? OutputFormat.Json : OutputFormat.Text;
|
||||
}
|
||||
}
|
||||
return OutputFormat.Text;
|
||||
}
|
||||
```
|
||||
|
||||
Apply to each command parser before processing other flags.
|
||||
|
||||
6. **Delete all JSON parsing methods:**
|
||||
- `ParseJsonCommand()`
|
||||
- `ParseJsonListCommand()`
|
||||
- `ParseJsonAddCommand()`
|
||||
- `ParseJsonEditCommand()`
|
||||
- `ParseJsonRemoveCommand()`
|
||||
- `ParseJsonMoveCommand()`
|
||||
- `ParseJsonExportCommand()`
|
||||
- `ParseJsonPosition()`
|
||||
- `ParseJsonDictionary()`
|
||||
- `ParseJsonStringList()`
|
||||
|
||||
7. **Update error messages** to reference `steps <command>` format.
|
||||
|
||||
**Estimated effort:** Small-medium
|
||||
|
||||
---
|
||||
|
||||
### Chunk 2: Update StepCommandHandler Response Format
|
||||
|
||||
**Files to modify:**
|
||||
- `src/Runner.Worker/Dap/StepCommands/StepCommandHandler.cs`
|
||||
|
||||
**Changes:**
|
||||
|
||||
1. **Update each command handler** to format response based on `command.Output`:
|
||||
|
||||
For `ListCommand`:
|
||||
```csharp
|
||||
if (command.Output == OutputFormat.Json)
|
||||
{
|
||||
return new StepCommandResult
|
||||
{
|
||||
Success = true,
|
||||
Message = JsonConvert.SerializeObject(new { Success = true, Result = steps }),
|
||||
Result = steps
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
return new StepCommandResult
|
||||
{
|
||||
Success = true,
|
||||
Message = FormatStepListAsText(steps),
|
||||
Result = steps
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
2. **Add text formatting helpers:**
|
||||
```csharp
|
||||
private string FormatStepListAsText(IReadOnlyList<StepInfo> steps)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("Steps:");
|
||||
foreach (var step in steps)
|
||||
{
|
||||
var statusIcon = step.Status switch
|
||||
{
|
||||
StepStatus.Completed => "✓",
|
||||
StepStatus.Current => "▶",
|
||||
_ => " "
|
||||
};
|
||||
var changeBadge = step.Change.HasValue ? $"[{step.Change}]" : "";
|
||||
sb.AppendLine($" {statusIcon} {step.Index}. {step.Name,-30} {changeBadge,-12} {step.Type,-5} {step.TypeDetail}");
|
||||
}
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("Legend: ✓ = completed, ▶ = current, [ADDED] = new, [MODIFIED] = edited");
|
||||
return sb.ToString();
|
||||
}
|
||||
```
|
||||
|
||||
3. **Update error responses** to also respect output format.
|
||||
|
||||
4. **Remove `WasJsonInput` checks** throughout the handler.
|
||||
|
||||
**Estimated effort:** Small
|
||||
|
||||
---
|
||||
|
||||
### Chunk 3: Update Browser Extension - Build Command Strings
|
||||
|
||||
**Files to modify:**
|
||||
- `browser-ext/content/content.js`
|
||||
|
||||
**Changes:**
|
||||
|
||||
1. **Replace `sendStepCommand()` implementation:**
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* Send step command via REPL format
|
||||
*/
|
||||
async function sendStepCommand(action, options = {}) {
|
||||
const expression = buildStepCommand(action, options);
|
||||
try {
|
||||
const response = await sendDapRequest('evaluate', {
|
||||
expression,
|
||||
frameId: currentFrameId,
|
||||
context: 'repl',
|
||||
});
|
||||
|
||||
if (response.result) {
|
||||
try {
|
||||
return JSON.parse(response.result);
|
||||
} catch (e) {
|
||||
// Response might be plain text for non-JSON output
|
||||
return { Success: true, Message: response.result };
|
||||
}
|
||||
}
|
||||
return { Success: false, Error: 'NO_RESPONSE', Message: 'No response from server' };
|
||||
} catch (error) {
|
||||
return { Success: false, Error: 'REQUEST_FAILED', Message: error.message };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. **Add `buildStepCommand()` function:**
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* Build REPL command string from action and options
|
||||
*/
|
||||
function buildStepCommand(action, options) {
|
||||
let cmd;
|
||||
switch (action) {
|
||||
case 'step.list':
|
||||
cmd = options.verbose ? 'steps list --verbose' : 'steps list';
|
||||
break;
|
||||
case 'step.add':
|
||||
cmd = buildAddStepCommand(options);
|
||||
break;
|
||||
case 'step.edit':
|
||||
cmd = buildEditStepCommand(options);
|
||||
break;
|
||||
case 'step.remove':
|
||||
cmd = `steps remove ${options.index}`;
|
||||
break;
|
||||
case 'step.move':
|
||||
cmd = buildMoveStepCommand(options);
|
||||
break;
|
||||
case 'step.export':
|
||||
cmd = buildExportCommand(options);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown step command: ${action}`);
|
||||
}
|
||||
// Always request JSON output for programmatic use
|
||||
return cmd + ' --output json';
|
||||
}
|
||||
```
|
||||
|
||||
3. **Add command builder helpers:**
|
||||
|
||||
```javascript
|
||||
function buildAddStepCommand(options) {
|
||||
let cmd = 'steps add';
|
||||
|
||||
if (options.type === 'run') {
|
||||
cmd += ` run ${quoteString(options.script)}`;
|
||||
if (options.shell) cmd += ` --shell ${options.shell}`;
|
||||
if (options.workingDirectory) cmd += ` --working-directory ${quoteString(options.workingDirectory)}`;
|
||||
} else if (options.type === 'uses') {
|
||||
cmd += ` uses ${options.action}`;
|
||||
if (options.with) {
|
||||
for (const [key, value] of Object.entries(options.with)) {
|
||||
cmd += ` --with ${key}=${value}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (options.name) cmd += ` --name ${quoteString(options.name)}`;
|
||||
if (options.if) cmd += ` --if ${quoteString(options.if)}`;
|
||||
if (options.env) {
|
||||
for (const [key, value] of Object.entries(options.env)) {
|
||||
cmd += ` --env ${key}=${value}`;
|
||||
}
|
||||
}
|
||||
if (options.continueOnError) cmd += ' --continue-on-error';
|
||||
if (options.timeout) cmd += ` --timeout ${options.timeout}`;
|
||||
|
||||
// Position
|
||||
if (options.position) {
|
||||
if (options.position.after) cmd += ` --after ${options.position.after}`;
|
||||
else if (options.position.before) cmd += ` --before ${options.position.before}`;
|
||||
else if (options.position.at) cmd += ` --at ${options.position.at}`;
|
||||
else if (options.position.first) cmd += ' --first';
|
||||
// --last is default, no need to specify
|
||||
}
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
function buildEditStepCommand(options) {
|
||||
let cmd = `steps edit ${options.index}`;
|
||||
if (options.name) cmd += ` --name ${quoteString(options.name)}`;
|
||||
if (options.script) cmd += ` --script ${quoteString(options.script)}`;
|
||||
if (options.if) cmd += ` --if ${quoteString(options.if)}`;
|
||||
if (options.shell) cmd += ` --shell ${options.shell}`;
|
||||
if (options.workingDirectory) cmd += ` --working-directory ${quoteString(options.workingDirectory)}`;
|
||||
return cmd;
|
||||
}
|
||||
|
||||
function buildMoveStepCommand(options) {
|
||||
let cmd = `steps move ${options.from}`;
|
||||
const pos = options.position;
|
||||
if (pos.after) cmd += ` --after ${pos.after}`;
|
||||
else if (pos.before) cmd += ` --before ${pos.before}`;
|
||||
else if (pos.at) cmd += ` --to ${pos.at}`;
|
||||
else if (pos.first) cmd += ' --first';
|
||||
else if (pos.last) cmd += ' --last';
|
||||
return cmd;
|
||||
}
|
||||
|
||||
function buildExportCommand(options) {
|
||||
let cmd = 'steps export';
|
||||
if (options.changesOnly) cmd += ' --changes-only';
|
||||
if (options.withComments) cmd += ' --with-comments';
|
||||
return cmd;
|
||||
}
|
||||
|
||||
/**
|
||||
* Quote a string for use in command, escaping as needed
|
||||
*/
|
||||
function quoteString(str) {
|
||||
// Escape backslashes and quotes, wrap in quotes
|
||||
return `"${str.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
||||
}
|
||||
```
|
||||
|
||||
4. **Update `loadSteps()`:**
|
||||
```javascript
|
||||
async function loadSteps() {
|
||||
try {
|
||||
const response = await sendDapRequest('evaluate', {
|
||||
expression: 'steps list --output json',
|
||||
frameId: currentFrameId,
|
||||
context: 'repl',
|
||||
});
|
||||
// ... rest of parsing logic unchanged
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Estimated effort:** Medium
|
||||
|
||||
---
|
||||
|
||||
### Chunk 4: Update REPL Context Detection
|
||||
|
||||
**Files to modify:**
|
||||
- `browser-ext/content/content.js`
|
||||
|
||||
**Changes:**
|
||||
|
||||
Update `handleReplKeydown()` to set context to 'repl' for `steps` commands:
|
||||
|
||||
```javascript
|
||||
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,
|
||||
// Use 'repl' context for shell commands (!) and step commands
|
||||
context: (command.startsWith('!') || command.startsWith('steps')) ? 'repl' : 'watch',
|
||||
});
|
||||
// ... rest unchanged
|
||||
}
|
||||
}
|
||||
// ... arrow key handling unchanged
|
||||
}
|
||||
```
|
||||
|
||||
**Estimated effort:** Trivial
|
||||
|
||||
---
|
||||
|
||||
### Chunk 5: Update/Remove Tests
|
||||
|
||||
**Files to modify:**
|
||||
- `src/Test/L0/Worker/Dap/StepCommands/StepCommandParserJsonL0.cs` - **Delete**
|
||||
- `src/Test/L0/Worker/Dap/StepCommands/StepCommandParserL0.cs` - Modify
|
||||
|
||||
**Changes:**
|
||||
|
||||
1. **Delete `StepCommandParserJsonL0.cs`** entirely (JSON parsing tests no longer needed)
|
||||
|
||||
2. **Update `StepCommandParserL0.cs`:**
|
||||
|
||||
a. Update `IsStepCommand` tests:
|
||||
```csharp
|
||||
[Fact]
|
||||
public void IsStepCommand_DetectsStepsPrefix()
|
||||
{
|
||||
Assert.True(_parser.IsStepCommand("steps list"));
|
||||
Assert.True(_parser.IsStepCommand("steps add run \"test\""));
|
||||
Assert.True(_parser.IsStepCommand("STEPS LIST")); // case insensitive
|
||||
Assert.True(_parser.IsStepCommand(" steps list ")); // whitespace
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsStepCommand_RejectsInvalid()
|
||||
{
|
||||
Assert.False(_parser.IsStepCommand("step list")); // missing 's'
|
||||
Assert.False(_parser.IsStepCommand("!step list")); // old format
|
||||
Assert.False(_parser.IsStepCommand("stepslist")); // no space
|
||||
Assert.False(_parser.IsStepCommand(""));
|
||||
Assert.False(_parser.IsStepCommand(null));
|
||||
}
|
||||
```
|
||||
|
||||
b. Change all `!step` to `steps` in existing test cases:
|
||||
```csharp
|
||||
// Before:
|
||||
var cmd = _parser.Parse("!step list --verbose");
|
||||
|
||||
// After:
|
||||
var cmd = _parser.Parse("steps list --verbose");
|
||||
```
|
||||
|
||||
c. Add tests for `--output` flag:
|
||||
```csharp
|
||||
[Fact]
|
||||
public void Parse_ListCommand_WithOutputJson()
|
||||
{
|
||||
var cmd = _parser.Parse("steps list --output json") as ListCommand;
|
||||
Assert.NotNull(cmd);
|
||||
Assert.Equal(OutputFormat.Json, cmd.Output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_ListCommand_WithOutputText()
|
||||
{
|
||||
var cmd = _parser.Parse("steps list --output text") as ListCommand;
|
||||
Assert.NotNull(cmd);
|
||||
Assert.Equal(OutputFormat.Text, cmd.Output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_ListCommand_DefaultOutputIsText()
|
||||
{
|
||||
var cmd = _parser.Parse("steps list") as ListCommand;
|
||||
Assert.NotNull(cmd);
|
||||
Assert.Equal(OutputFormat.Text, cmd.Output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_AddCommand_WithOutputFlag()
|
||||
{
|
||||
var cmd = _parser.Parse("steps add run \"echo test\" --output json") as AddRunCommand;
|
||||
Assert.NotNull(cmd);
|
||||
Assert.Equal(OutputFormat.Json, cmd.Output);
|
||||
Assert.Equal("echo test", cmd.Script);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_OutputFlag_ShortForm()
|
||||
{
|
||||
var cmd = _parser.Parse("steps list -o json") as ListCommand;
|
||||
Assert.NotNull(cmd);
|
||||
Assert.Equal(OutputFormat.Json, cmd.Output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_OutputFlag_EqualsForm()
|
||||
{
|
||||
var cmd = _parser.Parse("steps list --output=json") as ListCommand;
|
||||
Assert.NotNull(cmd);
|
||||
Assert.Equal(OutputFormat.Json, cmd.Output);
|
||||
}
|
||||
```
|
||||
|
||||
d. Update error message expectations to reference `steps` format.
|
||||
|
||||
**Estimated effort:** Small
|
||||
|
||||
---
|
||||
|
||||
### Chunk 6: Update Plan Documentation
|
||||
|
||||
**Files to modify:**
|
||||
- `.opencode/plans/dap-step-manipulation.md`
|
||||
|
||||
**Changes:**
|
||||
|
||||
1. **Update command format documentation** - change all `!step` references to `steps`
|
||||
|
||||
2. **Document `--output` flag** in command reference:
|
||||
```
|
||||
### Output Format
|
||||
|
||||
All commands support the `--output` flag to control response format:
|
||||
- `--output text` (default) - Human-readable text output
|
||||
- `--output json` - JSON output for programmatic use
|
||||
- Short form: `-o json`, `-o text`
|
||||
- Equals form: `--output=json`, `--output=text`
|
||||
```
|
||||
|
||||
3. **Update Chunk 8 description** - note that JSON API was replaced with `--output` flag
|
||||
|
||||
4. **Update command reference table:**
|
||||
```
|
||||
| Command | Purpose | Example |
|
||||
|---------|---------|---------|
|
||||
| `steps list` | Show all steps | `steps list --verbose` |
|
||||
| `steps add` | Add new step | `steps add run "npm test" --after 3` |
|
||||
| `steps edit` | Modify step | `steps edit 4 --script "npm run test:ci"` |
|
||||
| `steps remove` | Delete step | `steps remove 5` |
|
||||
| `steps move` | Reorder step | `steps move 5 --after 2` |
|
||||
| `steps export` | Generate YAML | `steps export --with-comments` |
|
||||
```
|
||||
|
||||
**Estimated effort:** Trivial
|
||||
|
||||
---
|
||||
|
||||
## File Summary
|
||||
|
||||
| File | Action | Chunk | Description |
|
||||
|------|--------|-------|-------------|
|
||||
| `src/Runner.Worker/Dap/StepCommands/StepCommandParser.cs` | Modify | 1 | Change prefix to `steps`, add `--output` flag, remove JSON parsing |
|
||||
| `src/Runner.Worker/Dap/StepCommands/StepCommandHandler.cs` | Modify | 2 | Format responses based on `OutputFormat` |
|
||||
| `browser-ext/content/content.js` | Modify | 3, 4 | Build REPL command strings, update context detection |
|
||||
| `src/Test/L0/Worker/Dap/StepCommands/StepCommandParserJsonL0.cs` | Delete | 5 | No longer needed |
|
||||
| `src/Test/L0/Worker/Dap/StepCommands/StepCommandParserL0.cs` | Modify | 5 | Update for `steps` prefix, add `--output` tests |
|
||||
| `.opencode/plans/dap-step-manipulation.md` | Modify | 6 | Update documentation |
|
||||
|
||||
---
|
||||
|
||||
## Command Reference (After Changes)
|
||||
|
||||
### Human Usage (text output, default)
|
||||
|
||||
| Action | Command |
|
||||
|--------|---------|
|
||||
| List steps | `steps list` |
|
||||
| List verbose | `steps list --verbose` |
|
||||
| Add run step | `steps add run "echo hello"` |
|
||||
| Add run with options | `steps add run "npm test" --name "Run tests" --shell bash` |
|
||||
| Add uses step | `steps add uses actions/checkout@v4` |
|
||||
| Add uses with inputs | `steps add uses actions/setup-node@v4 --with node-version=20` |
|
||||
| Edit step | `steps edit 4 --name "New name" --script "new script"` |
|
||||
| Remove step | `steps remove 5` |
|
||||
| Move step | `steps move 5 --after 2` |
|
||||
| Export | `steps export` |
|
||||
| Export with options | `steps export --changes-only --with-comments` |
|
||||
|
||||
### Browser Extension (JSON output)
|
||||
|
||||
The browser extension appends `--output json` to all commands:
|
||||
|
||||
| Action | Command Sent |
|
||||
|--------|--------------|
|
||||
| List steps | `steps list --output json` |
|
||||
| Add step | `steps add uses actions/checkout@v4 --output json` |
|
||||
| Remove step | `steps remove 5 --output json` |
|
||||
|
||||
---
|
||||
|
||||
## Output Format Examples
|
||||
|
||||
**`steps list` (text, default):**
|
||||
```
|
||||
Steps:
|
||||
✓ 1. Checkout uses actions/checkout@v4
|
||||
✓ 2. Setup Node uses actions/setup-node@v4
|
||||
▶ 3. Install deps run npm ci
|
||||
4. Run tests [MODIFIED] run npm test
|
||||
5. Build [ADDED] run npm run build
|
||||
|
||||
Legend: ✓ = completed, ▶ = current, [ADDED] = new, [MODIFIED] = edited
|
||||
```
|
||||
|
||||
**`steps list --output json`:**
|
||||
```json
|
||||
{
|
||||
"Success": true,
|
||||
"Result": [
|
||||
{"index": 1, "name": "Checkout", "type": "uses", "typeDetail": "actions/checkout@v4", "status": "completed"},
|
||||
{"index": 2, "name": "Setup Node", "type": "uses", "typeDetail": "actions/setup-node@v4", "status": "completed"},
|
||||
{"index": 3, "name": "Install deps", "type": "run", "typeDetail": "npm ci", "status": "current"},
|
||||
{"index": 4, "name": "Run tests", "type": "run", "typeDetail": "npm test", "status": "pending", "change": "MODIFIED"},
|
||||
{"index": 5, "name": "Build", "type": "run", "typeDetail": "npm run build", "status": "pending", "change": "ADDED"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**`steps add run "echo hello" --name "Greeting"` (text):**
|
||||
```
|
||||
Step added at position 6: Greeting
|
||||
```
|
||||
|
||||
**`steps add run "echo hello" --name "Greeting" --output json`:**
|
||||
```json
|
||||
{
|
||||
"Success": true,
|
||||
"Message": "Step added at position 6",
|
||||
"Result": {"index": 6, "name": "Greeting", "type": "run", "typeDetail": "echo hello", "status": "pending", "change": "ADDED"}
|
||||
}
|
||||
```
|
||||
1266
.opencode/plans/dap-step-manipulation.md
Normal file
1266
.opencode/plans/dap-step-manipulation.md
Normal file
File diff suppressed because it is too large
Load Diff
179
.opencode/plans/fix-step-back-duplicate-in-steps-list.md
Normal file
179
.opencode/plans/fix-step-back-duplicate-in-steps-list.md
Normal file
@@ -0,0 +1,179 @@
|
||||
# Fix Step-Back Duplicating Steps in `steps list`
|
||||
|
||||
**Status:** Ready for Implementation
|
||||
**Date:** January 2026
|
||||
**Related:** [dap-step-backwards.md](./dap-step-backwards.md)
|
||||
|
||||
## Problem Summary
|
||||
|
||||
When stepping backwards during DAP debugging, the step that should be re-run appears **twice** in `steps list`:
|
||||
1. Once as a **completed** step (from `StepManipulator._completedSteps`)
|
||||
2. Once as a **pending** step (from the re-queued `JobSteps`)
|
||||
|
||||
### Reproduction Steps
|
||||
|
||||
1. Run a workflow with DAP debugging enabled
|
||||
2. Let steps 1-4 execute (checkpoints created)
|
||||
3. Pause at step 5
|
||||
4. Step backward
|
||||
5. Run `steps list`
|
||||
|
||||
**Expected:**
|
||||
```
|
||||
✓ 1. hello
|
||||
✓ 2. Run actions/checkout@v6
|
||||
✓ 3. Run echo "foo=bar" >> "$GITHUB_OUTPUT"
|
||||
▶ 4. Run cat doesnotexist
|
||||
5. Run a one-line script
|
||||
6. Run a multi-line script
|
||||
```
|
||||
|
||||
**Actual (bug):**
|
||||
```
|
||||
✓ 1. hello
|
||||
✓ 2. Run actions/checkout@v6
|
||||
✓ 3. Run echo "foo=bar" >> "$GITHUB_OUTPUT"
|
||||
✓ 4. Run cat doesnotexist ← Still in _completedSteps!
|
||||
▶ 5. Run cat doesnotexist ← Re-queued as current
|
||||
6. Run a one-line script
|
||||
7. Run a multi-line script
|
||||
```
|
||||
|
||||
## Root Cause
|
||||
|
||||
In `DapDebugSession.RestoreCheckpoint()` (line 1713), the session's `_completedSteps` and `_completedStepsTracker` lists are trimmed to match the checkpoint index:
|
||||
|
||||
```csharp
|
||||
// Clear completed steps list for frames after this checkpoint
|
||||
while (_completedSteps.Count > checkpointIndex)
|
||||
{
|
||||
_completedSteps.RemoveAt(_completedSteps.Count - 1);
|
||||
}
|
||||
|
||||
// Also clear the step tracker for manipulator sync
|
||||
while (_completedStepsTracker.Count > checkpointIndex)
|
||||
{
|
||||
_completedStepsTracker.RemoveAt(_completedStepsTracker.Count - 1);
|
||||
}
|
||||
```
|
||||
|
||||
However, **`StepManipulator` has its own separate `_completedSteps` list** that is **not** being synced. When `ClearChanges()` is called (line 1774), it only clears change tracking data (`_changes`, `_modifiedStepIds`, `_addedStepIds`, `_originalSteps`), not the `_completedSteps` list.
|
||||
|
||||
### Data Flow During Step-Back
|
||||
|
||||
1. Steps 1-4 complete → `StepManipulator._completedSteps` = [step1, step2, step3, step4]
|
||||
2. Paused at step 5
|
||||
3. Step back → `RestoreCheckpoint(3, ...)` is called (checkpoint index 3 = before step 4)
|
||||
4. `DapDebugSession._completedSteps` trimmed to 3 items ✓
|
||||
5. `DapDebugSession._completedStepsTracker` trimmed to 3 items ✓
|
||||
6. `StepManipulator._completedSteps` **NOT trimmed** ✗ (still has 4 items)
|
||||
7. Step 4 and remaining steps re-queued to `JobSteps`
|
||||
8. `steps list` called → `GetAllSteps()` combines:
|
||||
- `_completedSteps` (4 items) + queue (step4, step5, step6, step7)
|
||||
- Result: step4 appears twice
|
||||
|
||||
## Solution
|
||||
|
||||
Add a method to `IStepManipulator` interface to trim completed steps to a specific count, then call it from `RestoreCheckpoint`.
|
||||
|
||||
## Files to Modify
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `src/Runner.Worker/Dap/StepCommands/StepManipulator.cs` | Add `TrimCompletedSteps(int count)` method to interface and implementation |
|
||||
| `src/Runner.Worker/Dap/DapDebugSession.cs` | Call `TrimCompletedSteps` in `RestoreCheckpoint()` |
|
||||
|
||||
## Detailed Changes
|
||||
|
||||
### 1. `StepManipulator.cs` - Add Interface Method and Implementation
|
||||
|
||||
**Add to `IStepManipulator` interface (around line 115, after `ClearChanges`):**
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// Trims the completed steps list to the specified count.
|
||||
/// Used when restoring a checkpoint to sync state with the debug session.
|
||||
/// </summary>
|
||||
/// <param name="count">The number of completed steps to keep.</param>
|
||||
void TrimCompletedSteps(int count);
|
||||
```
|
||||
|
||||
**Add implementation to `StepManipulator` class (after `ClearChanges()` method, around line 577):**
|
||||
|
||||
```csharp
|
||||
/// <inheritdoc/>
|
||||
public void TrimCompletedSteps(int count)
|
||||
{
|
||||
var originalCount = _completedSteps.Count;
|
||||
while (_completedSteps.Count > count)
|
||||
{
|
||||
_completedSteps.RemoveAt(_completedSteps.Count - 1);
|
||||
}
|
||||
Trace.Info($"Trimmed completed steps from {originalCount} to {_completedSteps.Count}");
|
||||
}
|
||||
```
|
||||
|
||||
### 2. `DapDebugSession.cs` - Call TrimCompletedSteps in RestoreCheckpoint
|
||||
|
||||
**Modify `RestoreCheckpoint()` (around line 1774), change:**
|
||||
|
||||
```csharp
|
||||
// Reset the step manipulator to match the restored state
|
||||
// It will be re-initialized when the restored step starts
|
||||
_stepManipulator?.ClearChanges();
|
||||
```
|
||||
|
||||
**To:**
|
||||
|
||||
```csharp
|
||||
// Reset the step manipulator to match the restored state
|
||||
_stepManipulator?.ClearChanges();
|
||||
_stepManipulator?.TrimCompletedSteps(checkpointIndex);
|
||||
```
|
||||
|
||||
## Why This Approach
|
||||
|
||||
| Approach | Pros | Cons |
|
||||
|----------|------|------|
|
||||
| **Add `TrimCompletedSteps()` (chosen)** | Minimal change, explicit, mirrors existing DapDebugSession pattern | Requires new interface method |
|
||||
| Re-initialize StepManipulator completely | Clean slate | Would lose all state, more disruptive, harder to reason about |
|
||||
| Share completed steps list between DapDebugSession and StepManipulator | Single source of truth | Major refactoring, tight coupling between components |
|
||||
|
||||
The chosen approach is the least invasive and follows the existing pattern used by `DapDebugSession` for its own `_completedSteps` list.
|
||||
|
||||
## Test Scenarios
|
||||
|
||||
After implementing this fix, verify:
|
||||
|
||||
1. **Basic step-back:**
|
||||
- Run steps 1, 2, 3, 4
|
||||
- Pause at step 5
|
||||
- Step back
|
||||
- `steps list` should show: ✓ 1-3 completed, ▶ 4 current, 5-6 pending (no duplicates)
|
||||
|
||||
2. **Multiple step-backs:**
|
||||
- Run steps 1, 2, 3
|
||||
- Step back twice (back to step 1)
|
||||
- `steps list` should show: ▶ 1 current, 2-N pending (no completed steps)
|
||||
|
||||
3. **Step back then forward:**
|
||||
- Run steps 1, 2
|
||||
- Step back (to step 2)
|
||||
- Step forward, let step 2 re-run and complete
|
||||
- Step forward again
|
||||
- `steps list` should show correct state without duplicates at any point
|
||||
|
||||
4. **Reverse continue:**
|
||||
- Run steps 1, 2, 3, 4
|
||||
- Reverse continue (back to step 1)
|
||||
- `steps list` should show: ▶ 1 current, 2-N pending (no completed steps)
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
- [ ] Add `TrimCompletedSteps(int count)` to `IStepManipulator` interface
|
||||
- [ ] Implement `TrimCompletedSteps` in `StepManipulator` class
|
||||
- [ ] Call `TrimCompletedSteps(checkpointIndex)` in `DapDebugSession.RestoreCheckpoint()`
|
||||
- [ ] Test basic step-back scenario
|
||||
- [ ] Test multiple step-backs
|
||||
- [ ] Test step back then forward
|
||||
- [ ] Test reverse continue
|
||||
281
.opencode/plans/fix-step-here-insertion.md
Normal file
281
.opencode/plans/fix-step-here-insertion.md
Normal file
@@ -0,0 +1,281 @@
|
||||
# Fix Step Addition with `--here` Position
|
||||
|
||||
## Problem Summary
|
||||
|
||||
When adding a step with `--here` position, two bugs occur:
|
||||
|
||||
1. **Duplicate display**: The newly added step appears twice in `steps list` - once as "current" and once in "pending"
|
||||
2. **Wrong execution order**: After stepping over, the original step (checkout) runs instead of the newly added step
|
||||
|
||||
### Root Cause
|
||||
|
||||
When paused at a breakpoint, the current step has already been dequeued from `JobSteps` by `StepsRunner`. The `InsertStepHere` function:
|
||||
1. Incorrectly re-inserts `_currentStep` back into the queue
|
||||
2. Sets `_currentStep = newStep`, but newStep is also in the queue → duplicate
|
||||
3. When execution continues, the loop iteration that already dequeued the original step continues to execute it, not the new step
|
||||
|
||||
### Why This Matters
|
||||
|
||||
The ability to insert a step that runs BEFORE the current step is essential for debugging workflows. Example scenario:
|
||||
- Debugging a job, a step fails because it's missing something
|
||||
- Step backwards in the debugger
|
||||
- Need to add a new step here that downloads a dependency to fix the failing step
|
||||
- Without proper `--here` support, you'd have to go back two steps (what if you don't have them?)
|
||||
|
||||
## Solution Overview
|
||||
|
||||
Implement a mechanism similar to step-back: when a step is inserted "here", signal `StepsRunner` to skip the current step execution and re-process from the modified queue.
|
||||
|
||||
## Files to Modify
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `src/Runner.Worker/Dap/DapDebugSession.cs` | Add `HasStepInsertedHere` flag and `ConsumeStepInsertedHere()` method |
|
||||
| `src/Runner.Worker/Dap/StepCommands/StepManipulator.cs` | Fix `InsertStepHere()` logic |
|
||||
| `src/Runner.Worker/StepsRunner.cs` | Add check for `HasStepInsertedHere` after `OnStepStartingAsync` |
|
||||
|
||||
## Detailed Changes
|
||||
|
||||
### 1. `DapDebugSession.cs` - Add Interface and Implementation
|
||||
|
||||
**Add to `IDapDebugSession` interface (around line 166):**
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// Gets whether a step was inserted "here" (before current step) while paused.
|
||||
/// When true, StepsRunner should skip current step execution and re-process from queue.
|
||||
/// </summary>
|
||||
bool HasStepInsertedHere { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Consumes the "step inserted here" flag (resets it to false).
|
||||
/// Called by StepsRunner after handling the insertion.
|
||||
/// </summary>
|
||||
void ConsumeStepInsertedHere();
|
||||
|
||||
/// <summary>
|
||||
/// Sets the "step inserted here" flag.
|
||||
/// Called by StepManipulator when --here insertion occurs.
|
||||
/// </summary>
|
||||
void SetStepInsertedHere();
|
||||
```
|
||||
|
||||
**Add field (around line 299):**
|
||||
```csharp
|
||||
private bool _hasStepInsertedHere;
|
||||
```
|
||||
|
||||
**Add property implementation (around line 329):**
|
||||
```csharp
|
||||
public bool HasStepInsertedHere => _hasStepInsertedHere;
|
||||
```
|
||||
|
||||
**Add methods:**
|
||||
```csharp
|
||||
public void ConsumeStepInsertedHere()
|
||||
{
|
||||
_hasStepInsertedHere = false;
|
||||
}
|
||||
|
||||
public void SetStepInsertedHere()
|
||||
{
|
||||
_hasStepInsertedHere = true;
|
||||
}
|
||||
```
|
||||
|
||||
### 2. `StepManipulator.cs` - Fix `InsertStepHere()`
|
||||
|
||||
The manipulator needs access to the debug session to set the `HasStepInsertedHere` flag. Access via HostContext since `DapDebugSession` is already registered as a service.
|
||||
|
||||
**Modify `InsertStepHere()` method (lines 307-350):**
|
||||
|
||||
```csharp
|
||||
private int InsertStepHere(IStep step)
|
||||
{
|
||||
if (_currentStep == null)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.InvalidPosition,
|
||||
"Can only use --here when paused at a breakpoint.");
|
||||
}
|
||||
|
||||
// Convert queue to list for manipulation
|
||||
var pending = _jobContext.JobSteps.ToList();
|
||||
_jobContext.JobSteps.Clear();
|
||||
|
||||
// Insert the new step at the front (it will run first)
|
||||
pending.Insert(0, step);
|
||||
|
||||
// Insert the original current step after it (it will run second)
|
||||
// This re-queues the step that was already dequeued by StepsRunner
|
||||
pending.Insert(1, _currentStep);
|
||||
|
||||
// Re-queue all steps
|
||||
foreach (var s in pending)
|
||||
{
|
||||
_jobContext.JobSteps.Enqueue(s);
|
||||
}
|
||||
|
||||
// Signal to StepsRunner that it should skip the current iteration
|
||||
// and re-process from the queue (which now has our new step first)
|
||||
var debugSession = HostContext.GetService<IDapDebugSession>();
|
||||
debugSession?.SetStepInsertedHere();
|
||||
|
||||
// Calculate the 1-based index (new step takes position after completed steps)
|
||||
var newIndex = _completedSteps.Count + 1;
|
||||
|
||||
// Track the change
|
||||
var stepInfo = StepInfo.FromStep(step, newIndex, StepStatus.Pending);
|
||||
stepInfo.Change = ChangeType.Added;
|
||||
|
||||
if (step is IActionRunner runner && runner.Action != null)
|
||||
{
|
||||
_addedStepIds.Add(runner.Action.Id);
|
||||
}
|
||||
|
||||
_changes.Add(StepChange.Added(stepInfo, newIndex));
|
||||
|
||||
// Note: We do NOT update _currentStep here. The StepsRunner will
|
||||
// pick up the new step from the queue and that will become current.
|
||||
|
||||
Trace.Info($"Inserted step '{step.DisplayName}' at position {newIndex} (--here, before current step)");
|
||||
return newIndex;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. `StepsRunner.cs` - Handle the Flag
|
||||
|
||||
**Add check after `OnStepStartingAsync` (after line 243, following the step-back pattern):**
|
||||
|
||||
```csharp
|
||||
// Check if a step was inserted "here" (before current step)
|
||||
if (debugSession.HasStepInsertedHere)
|
||||
{
|
||||
debugSession.ConsumeStepInsertedHere();
|
||||
|
||||
// The queue now contains: [new step, original current step, rest...]
|
||||
// We need to skip this iteration and let the loop pick up the new step
|
||||
|
||||
// Clear pending step info since we're not executing this step now
|
||||
debugSession.ClearPendingStepInfo();
|
||||
|
||||
// Don't increment stepIndex - the new step takes this position
|
||||
|
||||
Trace.Info("Step inserted here - skipping current iteration to process new step");
|
||||
|
||||
// Skip to next iteration - will dequeue and process the new step
|
||||
continue;
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Fix `GetAllSteps()` Display in `StepManipulator.cs`
|
||||
|
||||
After the changes above, when `InsertStepHere` is called:
|
||||
- `_currentStep` is NOT changed (still points to original step)
|
||||
- Queue contains: [new step, original step, rest...]
|
||||
- The `HasStepInsertedHere` flag is set
|
||||
|
||||
When `GetAllSteps()` is called while paused (before `continue` in StepsRunner):
|
||||
- completed = []
|
||||
- current = original step (from `_currentStep`)
|
||||
- pending = [new step, original step, rest...] (from queue)
|
||||
|
||||
This would show the original step twice (as current AND in pending). Need to handle this.
|
||||
|
||||
**Modify `GetAllSteps()` to check the flag:**
|
||||
|
||||
```csharp
|
||||
public IReadOnlyList<StepInfo> GetAllSteps()
|
||||
{
|
||||
var result = new List<StepInfo>();
|
||||
int index = 1;
|
||||
|
||||
// Add completed steps
|
||||
foreach (var step in _completedSteps)
|
||||
{
|
||||
// ... existing code ...
|
||||
}
|
||||
|
||||
// Check if we're in "step inserted here" state
|
||||
var debugSession = HostContext.GetService<IDapDebugSession>();
|
||||
var stepInsertedHere = debugSession?.HasStepInsertedHere ?? false;
|
||||
|
||||
// Add current step if present AND not in "step inserted here" state
|
||||
// (In that state, the current step has been re-queued and will show in pending)
|
||||
if (_currentStep != null && !stepInsertedHere)
|
||||
{
|
||||
var info = StepInfo.FromStep(_currentStep, index, StepStatus.Current);
|
||||
ApplyChangeInfo(info);
|
||||
result.Add(info);
|
||||
index++;
|
||||
}
|
||||
|
||||
// Add pending steps from queue
|
||||
if (_jobContext?.JobSteps != null)
|
||||
{
|
||||
bool isFirstPending = true;
|
||||
foreach (var step in _jobContext.JobSteps)
|
||||
{
|
||||
// In "step inserted here" state, mark the first pending step as current
|
||||
var status = (stepInsertedHere && isFirstPending)
|
||||
? StepStatus.Current
|
||||
: StepStatus.Pending;
|
||||
|
||||
var info = StepInfo.FromStep(step, index, status);
|
||||
ApplyChangeInfo(info);
|
||||
result.Add(info);
|
||||
index++;
|
||||
isFirstPending = false;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
```
|
||||
|
||||
## Execution Context Handling
|
||||
|
||||
The new step needs a proper execution context. `StepFactory.WrapInRunner()` creates a child context via `jobContext.CreateChild()`.
|
||||
|
||||
When `StepsRunner` picks up the new step from the queue, it goes through the normal initialization (lines 86-137 in StepsRunner.cs) which sets up expression values, env context, etc. This should work correctly because the new step already has an `ExecutionContext` from `WrapInRunner()`.
|
||||
|
||||
The `Start()` method on ExecutionContext just sets up timing and state, so dynamically created steps should work fine.
|
||||
|
||||
## Test Scenarios
|
||||
|
||||
After implementing this fix, verify:
|
||||
|
||||
1. **Basic `--here` insertion**:
|
||||
- Pause at step 1 (checkout)
|
||||
- `steps add run "echo hello" --here`
|
||||
- `steps list` shows: `▶ 1. hello [ADDED]`, `2. checkout`, `3. ...`
|
||||
- Step over → hello runs
|
||||
- `steps list` shows: `✓ 1. hello`, `▶ 2. checkout`, `3. ...`
|
||||
|
||||
2. **Multiple `--here` insertions**:
|
||||
- Pause at step 1
|
||||
- Add step A with `--here`
|
||||
- Add step B with `--here` (should insert before A)
|
||||
- Verify order: B, A, original
|
||||
|
||||
3. **`--here` after step-back**:
|
||||
- Run step 1, pause at step 2
|
||||
- Step back to step 1
|
||||
- Add new step with `--here`
|
||||
- Verify new step runs before step 1
|
||||
|
||||
4. **Other position options still work**:
|
||||
- `--first`, `--last`, `--after N`, `--before N` should be unaffected
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
- [ ] Add `HasStepInsertedHere` flag and methods to `IDapDebugSession` interface
|
||||
- [ ] Implement the flag and methods in `DapDebugSession` class
|
||||
- [ ] Modify `InsertStepHere()` in `StepManipulator` to set the flag and NOT modify `_currentStep`
|
||||
- [ ] Add check in `StepsRunner` to handle `HasStepInsertedHere` with `continue`
|
||||
- [ ] Update `GetAllSteps()` to correctly display steps when flag is set
|
||||
- [ ] Test the scenarios listed above
|
||||
|
||||
## Additional Note: Default Position
|
||||
|
||||
There's also a documentation inconsistency: the help text says `--here` is the default, but the code defaults to `--last` (see `AddRunCommand` and `AddUsesCommand` in `StepCommandParser.cs`). This should be reviewed and either:
|
||||
- Update the code to default to `--here` (matches docs)
|
||||
- Update the docs to say `--last` is the default (matches code)
|
||||
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');
|
||||
1038
browser-ext/content/content.css
Normal file
1038
browser-ext/content/content.css
Normal file
File diff suppressed because it is too large
Load Diff
1849
browser-ext/content/content.js
Normal file
1849
browser-ext/content/content.js
Normal file
File diff suppressed because it is too large
Load Diff
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 |
226
browser-ext/lib/dap-protocol.js
Normal file
226
browser-ext/lib/dap-protocol.js
Normal file
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* DAP Protocol Helpers
|
||||
*
|
||||
* Type definitions and utilities for the Debug Adapter Protocol.
|
||||
* Used by both content and background scripts.
|
||||
*/
|
||||
|
||||
// DAP Request Commands
|
||||
const DapCommands = {
|
||||
// Lifecycle
|
||||
INITIALIZE: 'initialize',
|
||||
ATTACH: 'attach',
|
||||
CONFIGURATION_DONE: 'configurationDone',
|
||||
DISCONNECT: 'disconnect',
|
||||
|
||||
// Execution Control
|
||||
CONTINUE: 'continue',
|
||||
NEXT: 'next',
|
||||
STEP_IN: 'stepIn',
|
||||
STEP_OUT: 'stepOut',
|
||||
PAUSE: 'pause',
|
||||
TERMINATE: 'terminate',
|
||||
|
||||
// Reverse Execution
|
||||
STEP_BACK: 'stepBack',
|
||||
REVERSE_CONTINUE: 'reverseContinue',
|
||||
|
||||
// Information
|
||||
THREADS: 'threads',
|
||||
STACK_TRACE: 'stackTrace',
|
||||
SCOPES: 'scopes',
|
||||
VARIABLES: 'variables',
|
||||
SOURCE: 'source',
|
||||
|
||||
// Evaluation
|
||||
EVALUATE: 'evaluate',
|
||||
SET_VARIABLE: 'setVariable',
|
||||
|
||||
// Breakpoints
|
||||
SET_BREAKPOINTS: 'setBreakpoints',
|
||||
SET_FUNCTION_BREAKPOINTS: 'setFunctionBreakpoints',
|
||||
SET_EXCEPTION_BREAKPOINTS: 'setExceptionBreakpoints',
|
||||
};
|
||||
|
||||
// DAP Event Types
|
||||
const DapEvents = {
|
||||
// Lifecycle
|
||||
INITIALIZED: 'initialized',
|
||||
TERMINATED: 'terminated',
|
||||
EXITED: 'exited',
|
||||
|
||||
// Execution
|
||||
STOPPED: 'stopped',
|
||||
CONTINUED: 'continued',
|
||||
|
||||
// Output
|
||||
OUTPUT: 'output',
|
||||
|
||||
// Other
|
||||
THREAD: 'thread',
|
||||
BREAKPOINT: 'breakpoint',
|
||||
MODULE: 'module',
|
||||
LOADED_SOURCE: 'loadedSource',
|
||||
PROCESS: 'process',
|
||||
CAPABILITIES: 'capabilities',
|
||||
PROGRESS_START: 'progressStart',
|
||||
PROGRESS_UPDATE: 'progressUpdate',
|
||||
PROGRESS_END: 'progressEnd',
|
||||
INVALIDATED: 'invalidated',
|
||||
MEMORY: 'memory',
|
||||
};
|
||||
|
||||
// Stopped Event Reasons
|
||||
const StoppedReasons = {
|
||||
STEP: 'step',
|
||||
BREAKPOINT: 'breakpoint',
|
||||
EXCEPTION: 'exception',
|
||||
PAUSE: 'pause',
|
||||
ENTRY: 'entry',
|
||||
GOTO: 'goto',
|
||||
FUNCTION_BREAKPOINT: 'function breakpoint',
|
||||
DATA_BREAKPOINT: 'data breakpoint',
|
||||
INSTRUCTION_BREAKPOINT: 'instruction breakpoint',
|
||||
};
|
||||
|
||||
// Output Categories
|
||||
const OutputCategories = {
|
||||
CONSOLE: 'console',
|
||||
IMPORTANT: 'important',
|
||||
STDOUT: 'stdout',
|
||||
STDERR: 'stderr',
|
||||
TELEMETRY: 'telemetry',
|
||||
};
|
||||
|
||||
// Evaluate Contexts
|
||||
const EvaluateContexts = {
|
||||
WATCH: 'watch',
|
||||
REPL: 'repl',
|
||||
HOVER: 'hover',
|
||||
CLIPBOARD: 'clipboard',
|
||||
VARIABLES: 'variables',
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a DAP request message
|
||||
*
|
||||
* @param {number} seq - Sequence number
|
||||
* @param {string} command - DAP command name
|
||||
* @param {object} args - Command arguments
|
||||
* @returns {object} DAP request message
|
||||
*/
|
||||
function createDapRequest(seq, command, args = {}) {
|
||||
return {
|
||||
seq,
|
||||
type: 'request',
|
||||
command,
|
||||
arguments: args,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a DAP response message
|
||||
*
|
||||
* @param {number} seq - Sequence number
|
||||
* @param {number} requestSeq - Original request sequence number
|
||||
* @param {string} command - DAP command name
|
||||
* @param {boolean} success - Whether request succeeded
|
||||
* @param {object} body - Response body
|
||||
* @param {string} message - Error message (if success is false)
|
||||
* @returns {object} DAP response message
|
||||
*/
|
||||
function createDapResponse(seq, requestSeq, command, success, body = {}, message = '') {
|
||||
return {
|
||||
seq,
|
||||
type: 'response',
|
||||
request_seq: requestSeq,
|
||||
command,
|
||||
success,
|
||||
body,
|
||||
message: success ? undefined : message,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a DAP event message
|
||||
*
|
||||
* @param {number} seq - Sequence number
|
||||
* @param {string} event - Event type
|
||||
* @param {object} body - Event body
|
||||
* @returns {object} DAP event message
|
||||
*/
|
||||
function createDapEvent(seq, event, body = {}) {
|
||||
return {
|
||||
seq,
|
||||
type: 'event',
|
||||
event,
|
||||
body,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a DAP message
|
||||
*
|
||||
* @param {string|object} message - JSON string or parsed object
|
||||
* @returns {object} Parsed message with helper properties
|
||||
*/
|
||||
function parseDapMessage(message) {
|
||||
const msg = typeof message === 'string' ? JSON.parse(message) : message;
|
||||
|
||||
return {
|
||||
raw: msg,
|
||||
isRequest: msg.type === 'request',
|
||||
isResponse: msg.type === 'response',
|
||||
isEvent: msg.type === 'event',
|
||||
seq: msg.seq,
|
||||
requestSeq: msg.request_seq,
|
||||
command: msg.command,
|
||||
event: msg.event,
|
||||
success: msg.success,
|
||||
body: msg.body || {},
|
||||
message: msg.message,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a message indicates a capability
|
||||
*
|
||||
* @param {object} capabilities - Capabilities object from initialize response
|
||||
* @param {string} name - Capability name
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function hasCapability(capabilities, name) {
|
||||
return capabilities && capabilities[name] === true;
|
||||
}
|
||||
|
||||
// Export for use in other modules
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = {
|
||||
DapCommands,
|
||||
DapEvents,
|
||||
StoppedReasons,
|
||||
OutputCategories,
|
||||
EvaluateContexts,
|
||||
createDapRequest,
|
||||
createDapResponse,
|
||||
createDapEvent,
|
||||
parseDapMessage,
|
||||
hasCapability,
|
||||
};
|
||||
}
|
||||
|
||||
// Make available globally for browser scripts
|
||||
if (typeof window !== 'undefined') {
|
||||
window.DapProtocol = {
|
||||
DapCommands,
|
||||
DapEvents,
|
||||
StoppedReasons,
|
||||
OutputCategories,
|
||||
EvaluateContexts,
|
||||
createDapRequest,
|
||||
createDapResponse,
|
||||
createDapEvent,
|
||||
parseDapMessage,
|
||||
hasCapability,
|
||||
};
|
||||
}
|
||||
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"
|
||||
}
|
||||
}
|
||||
220
browser-ext/proxy/proxy.js
Normal file
220
browser-ext/proxy/proxy.js
Normal file
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* 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 = Buffer.alloc(0);
|
||||
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
|
||||
// IMPORTANT: We use Buffer (byte-based) operations because DAP's Content-Length
|
||||
// header specifies the message length in bytes, not characters. Using string
|
||||
// operations would cause buffer misalignment when the JSON contains multi-byte
|
||||
// UTF-8 characters.
|
||||
tcp.on('data', (chunk) => {
|
||||
// Append chunk to buffer (chunk is already a Buffer)
|
||||
tcpBuffer = Buffer.concat([tcpBuffer, chunk]);
|
||||
|
||||
// Process complete DAP messages from buffer
|
||||
const headerEndMarker = Buffer.from('\r\n\r\n');
|
||||
|
||||
while (true) {
|
||||
// Look for header end (\r\n\r\n)
|
||||
const headerEnd = tcpBuffer.indexOf(headerEndMarker);
|
||||
if (headerEnd === -1) break;
|
||||
|
||||
// Extract header as string to parse Content-Length
|
||||
const header = tcpBuffer.slice(0, headerEnd).toString('utf8');
|
||||
const match = header.match(/Content-Length:\s*(\d+)/i);
|
||||
if (!match) {
|
||||
console.error(`[Proxy] Invalid DAP header: ${header}`);
|
||||
// Skip past the invalid header
|
||||
tcpBuffer = tcpBuffer.slice(headerEnd + 4);
|
||||
continue;
|
||||
}
|
||||
|
||||
const contentLength = parseInt(match[1], 10);
|
||||
const messageStart = headerEnd + 4; // After \r\n\r\n
|
||||
const messageEnd = messageStart + contentLength;
|
||||
|
||||
// Check if we have the complete message (in bytes)
|
||||
if (tcpBuffer.length < messageEnd) break;
|
||||
|
||||
// Extract the JSON message (as bytes, then decode to string)
|
||||
const jsonBuffer = tcpBuffer.slice(messageStart, messageEnd);
|
||||
const json = jsonBuffer.toString('utf8');
|
||||
|
||||
// Remove processed message from buffer
|
||||
tcpBuffer = tcpBuffer.slice(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}`);
|
||||
console.error(`[Proxy] JSON content (first 200 chars): ${json.substring(0, 200)}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
57
instructions.md
Normal file
57
instructions.md
Normal file
@@ -0,0 +1,57 @@
|
||||
## How to demo
|
||||
|
||||
Create a new codespace for actions/runner, then
|
||||
|
||||
```bash
|
||||
git pull
|
||||
git checkout rentziass/dap
|
||||
```
|
||||
|
||||
Then build the runner:
|
||||
|
||||
```bash
|
||||
cd src && ./dev.sh layout && cd ..
|
||||
```
|
||||
|
||||
Then register your runner (grab registration command from [here](https://github.com/organizations/galactic-potatoes/settings/actions/runners/new)):
|
||||
|
||||
```bash
|
||||
cd _layout
|
||||
```
|
||||
|
||||
```
|
||||
./config.sh --url <REPO_URL> --token <TOKEN> ## the command from above
|
||||
```
|
||||
|
||||
Then start the runner:
|
||||
```bash
|
||||
./run.sh
|
||||
```
|
||||
|
||||
### WebSocket Proxy
|
||||
|
||||
In a new terminal we need to start the WebSocket proxy:
|
||||
|
||||
```bash
|
||||
cd ./browser-ext/proxy && npm install && node proxy.js
|
||||
```
|
||||
|
||||
After that starts, in VS Code we need to add the port forwarding for port `4712`
|
||||
(whereas the TCP DAP server will be on `4711`, but we don't need to forward that
|
||||
one).
|
||||
|
||||
### Browser Extension
|
||||
|
||||
For this I'd recommend cloning the repo (`gh repo clone actions/runner`) locally
|
||||
for simplicity, then loading the `browser-ext` folder as an unpacked extension
|
||||
in Chrome/Edge.
|
||||
|
||||
### Debugging a job
|
||||
|
||||
Now we want to re-run a failed job for [this workflow](https://github.com/galactic-potatoes/rentziass-test/actions/workflows/self-hosted.yaml) with debug logging enabled. **Once we're on the new job page** we can open the extension and connect. Upon successful connection we'll see steps appear, and clicking the `Debug` button will launch the debugger in page. If anything goes south with connection here simply cancelling the job allows to try again.
|
||||
|
||||
> IMPORTANT: after job cleanup is currently broken, after each job make sure to
|
||||
> open a new terminal in the codespace and run
|
||||
```bash
|
||||
cd _layout/_work/rentziass-test/rentziass-test && rm -rf .git .github README.md doesnotexist result
|
||||
```
|
||||
2014
src/Runner.Worker/Dap/DapDebugSession.cs
Normal file
2014
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; }
|
||||
}
|
||||
}
|
||||
132
src/Runner.Worker/Dap/StepCommands/StepChange.cs
Normal file
132
src/Runner.Worker/Dap/StepCommands/StepChange.cs
Normal file
@@ -0,0 +1,132 @@
|
||||
using System;
|
||||
|
||||
namespace GitHub.Runner.Worker.Dap.StepCommands
|
||||
{
|
||||
/// <summary>
|
||||
/// Records a modification made to a step during a debug session.
|
||||
/// Used for change tracking and export diff generation.
|
||||
/// </summary>
|
||||
public class StepChange
|
||||
{
|
||||
/// <summary>
|
||||
/// The type of change made.
|
||||
/// </summary>
|
||||
public ChangeType Type { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The original 1-based index of the step (before any modifications).
|
||||
/// For Added steps, this is -1.
|
||||
/// </summary>
|
||||
public int OriginalIndex { get; set; } = -1;
|
||||
|
||||
/// <summary>
|
||||
/// The current 1-based index of the step (after all modifications).
|
||||
/// For Removed steps, this is -1.
|
||||
/// </summary>
|
||||
public int CurrentIndex { get; set; } = -1;
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of the step before modification (for Modified/Moved/Removed).
|
||||
/// </summary>
|
||||
public StepInfo OriginalStep { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The step after modification (for Added/Modified/Moved).
|
||||
/// For Removed steps, this is null.
|
||||
/// </summary>
|
||||
public StepInfo ModifiedStep { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when the change was made.
|
||||
/// </summary>
|
||||
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Description of the change for display purposes.
|
||||
/// </summary>
|
||||
public string Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a change record for an added step.
|
||||
/// </summary>
|
||||
public static StepChange Added(StepInfo step, int index)
|
||||
{
|
||||
return new StepChange
|
||||
{
|
||||
Type = ChangeType.Added,
|
||||
OriginalIndex = -1,
|
||||
CurrentIndex = index,
|
||||
ModifiedStep = step,
|
||||
Description = $"Added step '{step.Name}' at position {index}"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a change record for a modified step.
|
||||
/// </summary>
|
||||
public static StepChange Modified(StepInfo original, StepInfo modified, string changeDescription = null)
|
||||
{
|
||||
return new StepChange
|
||||
{
|
||||
Type = ChangeType.Modified,
|
||||
OriginalIndex = original.Index,
|
||||
CurrentIndex = modified.Index,
|
||||
OriginalStep = original,
|
||||
ModifiedStep = modified,
|
||||
Description = changeDescription ?? $"Modified step '{original.Name}' at position {original.Index}"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a change record for a removed step.
|
||||
/// </summary>
|
||||
public static StepChange Removed(StepInfo step)
|
||||
{
|
||||
return new StepChange
|
||||
{
|
||||
Type = ChangeType.Removed,
|
||||
OriginalIndex = step.Index,
|
||||
CurrentIndex = -1,
|
||||
OriginalStep = step,
|
||||
Description = $"Removed step '{step.Name}' from position {step.Index}"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a change record for a moved step.
|
||||
/// </summary>
|
||||
public static StepChange Moved(StepInfo original, int newIndex)
|
||||
{
|
||||
var modified = new StepInfo
|
||||
{
|
||||
Index = newIndex,
|
||||
Name = original.Name,
|
||||
Type = original.Type,
|
||||
TypeDetail = original.TypeDetail,
|
||||
Status = original.Status,
|
||||
Action = original.Action,
|
||||
Step = original.Step,
|
||||
OriginalIndex = original.Index,
|
||||
Change = ChangeType.Moved
|
||||
};
|
||||
|
||||
return new StepChange
|
||||
{
|
||||
Type = ChangeType.Moved,
|
||||
OriginalIndex = original.Index,
|
||||
CurrentIndex = newIndex,
|
||||
OriginalStep = original,
|
||||
ModifiedStep = modified,
|
||||
Description = $"Moved step '{original.Name}' from position {original.Index} to {newIndex}"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a human-readable summary of this change.
|
||||
/// </summary>
|
||||
public override string ToString()
|
||||
{
|
||||
return Description ?? $"{Type} step at index {OriginalIndex} -> {CurrentIndex}";
|
||||
}
|
||||
}
|
||||
}
|
||||
1139
src/Runner.Worker/Dap/StepCommands/StepCommandHandler.cs
Normal file
1139
src/Runner.Worker/Dap/StepCommands/StepCommandHandler.cs
Normal file
File diff suppressed because it is too large
Load Diff
847
src/Runner.Worker/Dap/StepCommands/StepCommandParser.cs
Normal file
847
src/Runner.Worker/Dap/StepCommands/StepCommandParser.cs
Normal file
@@ -0,0 +1,847 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using GitHub.Runner.Common;
|
||||
|
||||
namespace GitHub.Runner.Worker.Dap.StepCommands
|
||||
{
|
||||
/// <summary>
|
||||
/// Output format for step command responses.
|
||||
/// </summary>
|
||||
public enum OutputFormat
|
||||
{
|
||||
/// <summary>Human-readable text output (default)</summary>
|
||||
Text,
|
||||
/// <summary>JSON output for programmatic use</summary>
|
||||
Json
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for parsing step commands from REPL strings.
|
||||
/// </summary>
|
||||
[ServiceLocator(Default = typeof(StepCommandParser))]
|
||||
public interface IStepCommandParser : IRunnerService
|
||||
{
|
||||
/// <summary>
|
||||
/// Parses a command string into a structured StepCommand.
|
||||
/// </summary>
|
||||
/// <param name="input">The input string (e.g., "steps list --verbose")</param>
|
||||
/// <returns>Parsed StepCommand</returns>
|
||||
/// <exception cref="StepCommandException">If parsing fails</exception>
|
||||
StepCommand Parse(string input);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the input is a step command.
|
||||
/// </summary>
|
||||
/// <param name="input">The input string to check</param>
|
||||
/// <returns>True if this is a step command (starts with "steps")</returns>
|
||||
bool IsStepCommand(string input);
|
||||
}
|
||||
|
||||
#region Command Classes
|
||||
|
||||
/// <summary>
|
||||
/// Base class for all step commands.
|
||||
/// </summary>
|
||||
public abstract class StepCommand
|
||||
{
|
||||
/// <summary>
|
||||
/// Output format for the command response.
|
||||
/// </summary>
|
||||
public OutputFormat Output { get; set; } = OutputFormat.Text;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// steps list [--verbose]
|
||||
/// </summary>
|
||||
public class ListCommand : StepCommand
|
||||
{
|
||||
public bool Verbose { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// steps add run "script" [options]
|
||||
/// </summary>
|
||||
public class AddRunCommand : StepCommand
|
||||
{
|
||||
public string Script { get; set; }
|
||||
public string Id { get; set; }
|
||||
public string Name { get; set; }
|
||||
public string Shell { get; set; }
|
||||
public string WorkingDirectory { get; set; }
|
||||
public Dictionary<string, string> Env { get; set; }
|
||||
public string Condition { get; set; }
|
||||
public bool ContinueOnError { get; set; }
|
||||
public int? Timeout { get; set; }
|
||||
public StepPosition Position { get; set; } = StepPosition.Last();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// steps add uses "action@ref" [options]
|
||||
/// </summary>
|
||||
public class AddUsesCommand : StepCommand
|
||||
{
|
||||
public string Action { get; set; }
|
||||
public string Id { get; set; }
|
||||
public string Name { get; set; }
|
||||
public Dictionary<string, string> With { get; set; }
|
||||
public Dictionary<string, string> Env { get; set; }
|
||||
public string Condition { get; set; }
|
||||
public bool ContinueOnError { get; set; }
|
||||
public int? Timeout { get; set; }
|
||||
public StepPosition Position { get; set; } = StepPosition.Last();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// steps edit <index> [modifications]
|
||||
/// </summary>
|
||||
public class EditCommand : StepCommand
|
||||
{
|
||||
public int Index { get; set; }
|
||||
public string Name { get; set; }
|
||||
public string Script { get; set; }
|
||||
public string Action { get; set; }
|
||||
public string Shell { get; set; }
|
||||
public string WorkingDirectory { get; set; }
|
||||
public string Condition { get; set; }
|
||||
public Dictionary<string, string> With { get; set; }
|
||||
public Dictionary<string, string> Env { get; set; }
|
||||
public List<string> RemoveWith { get; set; }
|
||||
public List<string> RemoveEnv { get; set; }
|
||||
public bool? ContinueOnError { get; set; }
|
||||
public int? Timeout { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// steps remove <index>
|
||||
/// </summary>
|
||||
public class RemoveCommand : StepCommand
|
||||
{
|
||||
public int Index { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// steps move <from> [position options]
|
||||
/// </summary>
|
||||
public class MoveCommand : StepCommand
|
||||
{
|
||||
public int FromIndex { get; set; }
|
||||
public StepPosition Position { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// steps export [--changes-only] [--with-comments]
|
||||
/// </summary>
|
||||
public class ExportCommand : StepCommand
|
||||
{
|
||||
public bool ChangesOnly { get; set; }
|
||||
public bool WithComments { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// steps [command] --help
|
||||
/// Shows help information for step commands.
|
||||
/// </summary>
|
||||
public class HelpCommand : StepCommand
|
||||
{
|
||||
/// <summary>
|
||||
/// The command to show help for (null = top-level help)
|
||||
/// </summary>
|
||||
public string Command { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Sub-command if applicable (e.g., "run" for "steps add run --help")
|
||||
/// </summary>
|
||||
public string SubCommand { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Position Types
|
||||
|
||||
/// <summary>
|
||||
/// Types of position specifications for inserting/moving steps.
|
||||
/// </summary>
|
||||
public enum PositionType
|
||||
{
|
||||
/// <summary>Insert at specific index (1-based)</summary>
|
||||
At,
|
||||
/// <summary>Insert after specific index (1-based)</summary>
|
||||
After,
|
||||
/// <summary>Insert before specific index (1-based)</summary>
|
||||
Before,
|
||||
/// <summary>Insert at first pending position</summary>
|
||||
First,
|
||||
/// <summary>Insert at end (default)</summary>
|
||||
Last,
|
||||
/// <summary>Insert before current step (requires paused state)</summary>
|
||||
Here
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a position for inserting or moving steps.
|
||||
/// </summary>
|
||||
public class StepPosition
|
||||
{
|
||||
public PositionType Type { get; set; }
|
||||
public int? Index { get; set; }
|
||||
|
||||
public static StepPosition At(int index) => new StepPosition { Type = PositionType.At, Index = index };
|
||||
public static StepPosition After(int index) => new StepPosition { Type = PositionType.After, Index = index };
|
||||
public static StepPosition Before(int index) => new StepPosition { Type = PositionType.Before, Index = index };
|
||||
public static StepPosition First() => new StepPosition { Type = PositionType.First };
|
||||
public static StepPosition Last() => new StepPosition { Type = PositionType.Last };
|
||||
public static StepPosition Here() => new StepPosition { Type = PositionType.Here };
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return Type switch
|
||||
{
|
||||
PositionType.At => $"at {Index}",
|
||||
PositionType.After => $"after {Index}",
|
||||
PositionType.Before => $"before {Index}",
|
||||
PositionType.First => "first",
|
||||
PositionType.Last => "last",
|
||||
PositionType.Here => "here",
|
||||
_ => "unknown"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// Parser implementation for step commands.
|
||||
/// </summary>
|
||||
public sealed class StepCommandParser : RunnerService, IStepCommandParser
|
||||
{
|
||||
// Regex to match quoted strings (handles escaped quotes)
|
||||
private static readonly Regex QuotedStringRegex = new Regex(
|
||||
@"""(?:[^""\\]|\\.)*""|'(?:[^'\\]|\\.)*'",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
public bool IsStepCommand(string input)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input))
|
||||
return false;
|
||||
|
||||
var trimmed = input.Trim();
|
||||
|
||||
// Command format: steps ...
|
||||
if (trimmed.StartsWith("steps ", StringComparison.OrdinalIgnoreCase) ||
|
||||
trimmed.Equals("steps", StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public StepCommand Parse(string input)
|
||||
{
|
||||
var trimmed = input?.Trim() ?? "";
|
||||
return ParseReplCommand(trimmed);
|
||||
}
|
||||
|
||||
#region REPL Parsing
|
||||
|
||||
private StepCommand ParseReplCommand(string input)
|
||||
{
|
||||
// Tokenize the input, respecting quoted strings
|
||||
var tokens = Tokenize(input);
|
||||
|
||||
// Handle bare "steps" command - show top-level help
|
||||
if (tokens.Count == 1 && tokens[0].Equals("steps", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new HelpCommand { Command = null };
|
||||
}
|
||||
|
||||
if (tokens.Count < 2 || !tokens[0].Equals("steps", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.ParseError,
|
||||
"Invalid command format. Expected: steps <command> [args...]");
|
||||
}
|
||||
|
||||
// Check for --help or -h anywhere in tokens
|
||||
if (tokens.Any(t => t.Equals("--help", StringComparison.OrdinalIgnoreCase) ||
|
||||
t.Equals("-h", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return ParseHelpCommand(tokens);
|
||||
}
|
||||
|
||||
var subCommand = tokens[1].ToLower();
|
||||
|
||||
return subCommand switch
|
||||
{
|
||||
"list" => ParseReplListCommand(tokens),
|
||||
"add" => ParseReplAddCommand(tokens),
|
||||
"edit" => ParseReplEditCommand(tokens),
|
||||
"remove" => ParseReplRemoveCommand(tokens),
|
||||
"move" => ParseReplMoveCommand(tokens),
|
||||
"export" => ParseReplExportCommand(tokens),
|
||||
_ => throw new StepCommandException(StepCommandErrors.InvalidCommand, $"Unknown sub-command: {subCommand}")
|
||||
};
|
||||
}
|
||||
|
||||
private List<string> Tokenize(string input)
|
||||
{
|
||||
var tokens = new List<string>();
|
||||
var remaining = input;
|
||||
|
||||
while (!string.IsNullOrEmpty(remaining))
|
||||
{
|
||||
remaining = remaining.TrimStart();
|
||||
if (string.IsNullOrEmpty(remaining))
|
||||
break;
|
||||
|
||||
// Check for quoted string
|
||||
var match = QuotedStringRegex.Match(remaining);
|
||||
if (match.Success && match.Index == 0)
|
||||
{
|
||||
// Extract the quoted content (without quotes)
|
||||
var quoted = match.Value;
|
||||
var content = quoted.Substring(1, quoted.Length - 2);
|
||||
// Unescape
|
||||
content = content.Replace("\\\"", "\"").Replace("\\'", "'").Replace("\\\\", "\\");
|
||||
tokens.Add(content);
|
||||
remaining = remaining.Substring(match.Length);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Non-quoted token
|
||||
var spaceIndex = remaining.IndexOfAny(new[] { ' ', '\t' });
|
||||
if (spaceIndex == -1)
|
||||
{
|
||||
tokens.Add(remaining);
|
||||
break;
|
||||
}
|
||||
tokens.Add(remaining.Substring(0, spaceIndex));
|
||||
remaining = remaining.Substring(spaceIndex);
|
||||
}
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
private ListCommand ParseReplListCommand(List<string> tokens)
|
||||
{
|
||||
// Extract --output flag before processing other options
|
||||
var outputFormat = ExtractOutputFlag(tokens);
|
||||
|
||||
var cmd = new ListCommand { Output = outputFormat };
|
||||
|
||||
for (int i = 2; i < tokens.Count; i++)
|
||||
{
|
||||
var token = tokens[i].ToLower();
|
||||
if (token == "--verbose" || token == "-v")
|
||||
{
|
||||
cmd.Verbose = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.InvalidOption,
|
||||
$"Unknown option for list: {tokens[i]}");
|
||||
}
|
||||
}
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private StepCommand ParseReplAddCommand(List<string> tokens)
|
||||
{
|
||||
// Extract --output flag before processing other options
|
||||
var outputFormat = ExtractOutputFlag(tokens);
|
||||
|
||||
if (tokens.Count < 3)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.ParseError,
|
||||
"Usage: steps add <run|uses> <script|action> [options]");
|
||||
}
|
||||
|
||||
var type = tokens[2].ToLower();
|
||||
|
||||
StepCommand cmd;
|
||||
if (type == "run")
|
||||
{
|
||||
cmd = ParseReplAddRunCommand(tokens);
|
||||
}
|
||||
else if (type == "uses")
|
||||
{
|
||||
cmd = ParseReplAddUsesCommand(tokens);
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.InvalidType,
|
||||
$"Invalid step type: '{type}'. Must be 'run' or 'uses'.");
|
||||
}
|
||||
|
||||
cmd.Output = outputFormat;
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private AddRunCommand ParseReplAddRunCommand(List<string> tokens)
|
||||
{
|
||||
// steps add run "script" [options]
|
||||
if (tokens.Count < 4)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.ParseError,
|
||||
"Usage: steps add run \"<script>\" [--name \"...\"] [--shell <shell>] [--at|--after|--before <n>]");
|
||||
}
|
||||
|
||||
var cmd = new AddRunCommand
|
||||
{
|
||||
Script = tokens[3],
|
||||
Env = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
// Parse options
|
||||
for (int i = 4; i < tokens.Count; i++)
|
||||
{
|
||||
var opt = tokens[i].ToLower();
|
||||
|
||||
switch (opt)
|
||||
{
|
||||
case "--id":
|
||||
cmd.Id = GetNextArg(tokens, ref i, "--id");
|
||||
break;
|
||||
case "--name":
|
||||
cmd.Name = GetNextArg(tokens, ref i, "--name");
|
||||
break;
|
||||
case "--shell":
|
||||
cmd.Shell = GetNextArg(tokens, ref i, "--shell");
|
||||
break;
|
||||
case "--working-directory":
|
||||
cmd.WorkingDirectory = GetNextArg(tokens, ref i, "--working-directory");
|
||||
break;
|
||||
case "--if":
|
||||
cmd.Condition = GetNextArg(tokens, ref i, "--if");
|
||||
break;
|
||||
case "--env":
|
||||
ParseEnvArg(tokens, ref i, cmd.Env);
|
||||
break;
|
||||
case "--continue-on-error":
|
||||
cmd.ContinueOnError = true;
|
||||
break;
|
||||
case "--timeout":
|
||||
cmd.Timeout = GetNextArgInt(tokens, ref i, "--timeout");
|
||||
break;
|
||||
case "--at":
|
||||
cmd.Position = StepPosition.At(GetNextArgInt(tokens, ref i, "--at"));
|
||||
break;
|
||||
case "--after":
|
||||
cmd.Position = StepPosition.After(GetNextArgInt(tokens, ref i, "--after"));
|
||||
break;
|
||||
case "--before":
|
||||
cmd.Position = StepPosition.Before(GetNextArgInt(tokens, ref i, "--before"));
|
||||
break;
|
||||
case "--first":
|
||||
cmd.Position = StepPosition.First();
|
||||
break;
|
||||
case "--last":
|
||||
cmd.Position = StepPosition.Last();
|
||||
break;
|
||||
case "--here":
|
||||
cmd.Position = StepPosition.Here();
|
||||
break;
|
||||
default:
|
||||
throw new StepCommandException(StepCommandErrors.InvalidOption,
|
||||
$"Unknown option: {tokens[i]}");
|
||||
}
|
||||
}
|
||||
|
||||
if (cmd.Env.Count == 0)
|
||||
cmd.Env = null;
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private AddUsesCommand ParseReplAddUsesCommand(List<string> tokens)
|
||||
{
|
||||
// steps add uses "action@ref" [options]
|
||||
if (tokens.Count < 4)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.ParseError,
|
||||
"Usage: steps add uses <action@ref> [--name \"...\"] [--with key=value] [--at|--after|--before <n>]");
|
||||
}
|
||||
|
||||
var cmd = new AddUsesCommand
|
||||
{
|
||||
Action = tokens[3],
|
||||
With = new Dictionary<string, string>(),
|
||||
Env = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
// Parse options
|
||||
for (int i = 4; i < tokens.Count; i++)
|
||||
{
|
||||
var opt = tokens[i].ToLower();
|
||||
|
||||
switch (opt)
|
||||
{
|
||||
case "--id":
|
||||
cmd.Id = GetNextArg(tokens, ref i, "--id");
|
||||
break;
|
||||
case "--name":
|
||||
cmd.Name = GetNextArg(tokens, ref i, "--name");
|
||||
break;
|
||||
case "--with":
|
||||
ParseKeyValueArg(tokens, ref i, cmd.With);
|
||||
break;
|
||||
case "--if":
|
||||
cmd.Condition = GetNextArg(tokens, ref i, "--if");
|
||||
break;
|
||||
case "--env":
|
||||
ParseEnvArg(tokens, ref i, cmd.Env);
|
||||
break;
|
||||
case "--continue-on-error":
|
||||
cmd.ContinueOnError = true;
|
||||
break;
|
||||
case "--timeout":
|
||||
cmd.Timeout = GetNextArgInt(tokens, ref i, "--timeout");
|
||||
break;
|
||||
case "--at":
|
||||
cmd.Position = StepPosition.At(GetNextArgInt(tokens, ref i, "--at"));
|
||||
break;
|
||||
case "--after":
|
||||
cmd.Position = StepPosition.After(GetNextArgInt(tokens, ref i, "--after"));
|
||||
break;
|
||||
case "--before":
|
||||
cmd.Position = StepPosition.Before(GetNextArgInt(tokens, ref i, "--before"));
|
||||
break;
|
||||
case "--first":
|
||||
cmd.Position = StepPosition.First();
|
||||
break;
|
||||
case "--last":
|
||||
cmd.Position = StepPosition.Last();
|
||||
break;
|
||||
case "--here":
|
||||
cmd.Position = StepPosition.Here();
|
||||
break;
|
||||
default:
|
||||
throw new StepCommandException(StepCommandErrors.InvalidOption,
|
||||
$"Unknown option: {tokens[i]}");
|
||||
}
|
||||
}
|
||||
|
||||
if (cmd.With.Count == 0)
|
||||
cmd.With = null;
|
||||
if (cmd.Env.Count == 0)
|
||||
cmd.Env = null;
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private EditCommand ParseReplEditCommand(List<string> tokens)
|
||||
{
|
||||
// Extract --output flag before processing other options
|
||||
var outputFormat = ExtractOutputFlag(tokens);
|
||||
|
||||
// steps edit <index> [modifications]
|
||||
if (tokens.Count < 3)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.ParseError,
|
||||
"Usage: steps edit <index> [--name \"...\"] [--script \"...\"] [--if \"...\"]");
|
||||
}
|
||||
|
||||
if (!int.TryParse(tokens[2], out var index))
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.ParseError,
|
||||
$"Invalid index: {tokens[2]}. Must be a number.");
|
||||
}
|
||||
|
||||
var cmd = new EditCommand
|
||||
{
|
||||
Index = index,
|
||||
Output = outputFormat,
|
||||
With = new Dictionary<string, string>(),
|
||||
Env = new Dictionary<string, string>(),
|
||||
RemoveWith = new List<string>(),
|
||||
RemoveEnv = new List<string>()
|
||||
};
|
||||
|
||||
// Parse options
|
||||
for (int i = 3; i < tokens.Count; i++)
|
||||
{
|
||||
var opt = tokens[i].ToLower();
|
||||
|
||||
switch (opt)
|
||||
{
|
||||
case "--name":
|
||||
cmd.Name = GetNextArg(tokens, ref i, "--name");
|
||||
break;
|
||||
case "--script":
|
||||
cmd.Script = GetNextArg(tokens, ref i, "--script");
|
||||
break;
|
||||
case "--action":
|
||||
cmd.Action = GetNextArg(tokens, ref i, "--action");
|
||||
break;
|
||||
case "--shell":
|
||||
cmd.Shell = GetNextArg(tokens, ref i, "--shell");
|
||||
break;
|
||||
case "--working-directory":
|
||||
cmd.WorkingDirectory = GetNextArg(tokens, ref i, "--working-directory");
|
||||
break;
|
||||
case "--if":
|
||||
cmd.Condition = GetNextArg(tokens, ref i, "--if");
|
||||
break;
|
||||
case "--with":
|
||||
ParseKeyValueArg(tokens, ref i, cmd.With);
|
||||
break;
|
||||
case "--env":
|
||||
ParseEnvArg(tokens, ref i, cmd.Env);
|
||||
break;
|
||||
case "--remove-with":
|
||||
cmd.RemoveWith.Add(GetNextArg(tokens, ref i, "--remove-with"));
|
||||
break;
|
||||
case "--remove-env":
|
||||
cmd.RemoveEnv.Add(GetNextArg(tokens, ref i, "--remove-env"));
|
||||
break;
|
||||
case "--continue-on-error":
|
||||
cmd.ContinueOnError = true;
|
||||
break;
|
||||
case "--no-continue-on-error":
|
||||
cmd.ContinueOnError = false;
|
||||
break;
|
||||
case "--timeout":
|
||||
cmd.Timeout = GetNextArgInt(tokens, ref i, "--timeout");
|
||||
break;
|
||||
default:
|
||||
throw new StepCommandException(StepCommandErrors.InvalidOption,
|
||||
$"Unknown option: {tokens[i]}");
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up empty collections
|
||||
if (cmd.With.Count == 0)
|
||||
cmd.With = null;
|
||||
if (cmd.Env.Count == 0)
|
||||
cmd.Env = null;
|
||||
if (cmd.RemoveWith.Count == 0)
|
||||
cmd.RemoveWith = null;
|
||||
if (cmd.RemoveEnv.Count == 0)
|
||||
cmd.RemoveEnv = null;
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private RemoveCommand ParseReplRemoveCommand(List<string> tokens)
|
||||
{
|
||||
// Extract --output flag before processing other options
|
||||
var outputFormat = ExtractOutputFlag(tokens);
|
||||
|
||||
// steps remove <index>
|
||||
if (tokens.Count < 3)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.ParseError,
|
||||
"Usage: steps remove <index>");
|
||||
}
|
||||
|
||||
if (!int.TryParse(tokens[2], out var index))
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.ParseError,
|
||||
$"Invalid index: {tokens[2]}. Must be a number.");
|
||||
}
|
||||
|
||||
return new RemoveCommand { Index = index, Output = outputFormat };
|
||||
}
|
||||
|
||||
private MoveCommand ParseReplMoveCommand(List<string> tokens)
|
||||
{
|
||||
// Extract --output flag before processing other options
|
||||
var outputFormat = ExtractOutputFlag(tokens);
|
||||
|
||||
// steps move <from> --to|--after|--before <index>|--first|--last
|
||||
if (tokens.Count < 3)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.ParseError,
|
||||
"Usage: steps move <from> --to|--after|--before <index>|--first|--last");
|
||||
}
|
||||
|
||||
if (!int.TryParse(tokens[2], out var fromIndex))
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.ParseError,
|
||||
$"Invalid from index: {tokens[2]}. Must be a number.");
|
||||
}
|
||||
|
||||
var cmd = new MoveCommand { FromIndex = fromIndex, Output = outputFormat };
|
||||
|
||||
// Parse position
|
||||
for (int i = 3; i < tokens.Count; i++)
|
||||
{
|
||||
var opt = tokens[i].ToLower();
|
||||
|
||||
switch (opt)
|
||||
{
|
||||
case "--to":
|
||||
case "--at":
|
||||
cmd.Position = StepPosition.At(GetNextArgInt(tokens, ref i, opt));
|
||||
break;
|
||||
case "--after":
|
||||
cmd.Position = StepPosition.After(GetNextArgInt(tokens, ref i, "--after"));
|
||||
break;
|
||||
case "--before":
|
||||
cmd.Position = StepPosition.Before(GetNextArgInt(tokens, ref i, "--before"));
|
||||
break;
|
||||
case "--first":
|
||||
cmd.Position = StepPosition.First();
|
||||
break;
|
||||
case "--last":
|
||||
cmd.Position = StepPosition.Last();
|
||||
break;
|
||||
case "--here":
|
||||
cmd.Position = StepPosition.Here();
|
||||
break;
|
||||
default:
|
||||
throw new StepCommandException(StepCommandErrors.InvalidOption,
|
||||
$"Unknown option: {tokens[i]}");
|
||||
}
|
||||
}
|
||||
|
||||
if (cmd.Position == null)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.ParseError,
|
||||
"Move command requires a position (--to, --after, --before, --first, --last, or --here)");
|
||||
}
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private ExportCommand ParseReplExportCommand(List<string> tokens)
|
||||
{
|
||||
// Extract --output flag before processing other options
|
||||
var outputFormat = ExtractOutputFlag(tokens);
|
||||
|
||||
var cmd = new ExportCommand { Output = outputFormat };
|
||||
|
||||
for (int i = 2; i < tokens.Count; i++)
|
||||
{
|
||||
var opt = tokens[i].ToLower();
|
||||
|
||||
switch (opt)
|
||||
{
|
||||
case "--changes-only":
|
||||
cmd.ChangesOnly = true;
|
||||
break;
|
||||
case "--with-comments":
|
||||
cmd.WithComments = true;
|
||||
break;
|
||||
default:
|
||||
throw new StepCommandException(StepCommandErrors.InvalidOption,
|
||||
$"Unknown option: {tokens[i]}");
|
||||
}
|
||||
}
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a help command from tokens containing --help or -h.
|
||||
/// </summary>
|
||||
private HelpCommand ParseHelpCommand(List<string> tokens)
|
||||
{
|
||||
// Create a copy to avoid modifying the original
|
||||
var workingTokens = new List<string>(tokens);
|
||||
|
||||
// Remove --help/-h from tokens
|
||||
workingTokens.RemoveAll(t => t.Equals("--help", StringComparison.OrdinalIgnoreCase) ||
|
||||
t.Equals("-h", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
// "steps --help" or just "steps" (after removing --help)
|
||||
if (workingTokens.Count <= 1)
|
||||
{
|
||||
return new HelpCommand { Command = null };
|
||||
}
|
||||
|
||||
// "steps <command> --help"
|
||||
var cmd = workingTokens[1].ToLower();
|
||||
|
||||
// Check for "steps add run --help" or "steps add uses --help"
|
||||
string subCmd = null;
|
||||
if (workingTokens.Count >= 3 && cmd == "add")
|
||||
{
|
||||
var possibleSubCmd = workingTokens[2].ToLower();
|
||||
if (possibleSubCmd == "run" || possibleSubCmd == "uses")
|
||||
{
|
||||
subCmd = possibleSubCmd;
|
||||
}
|
||||
}
|
||||
|
||||
return new HelpCommand { Command = cmd, SubCommand = subCmd };
|
||||
}
|
||||
|
||||
#region Argument Helpers
|
||||
|
||||
/// <summary>
|
||||
/// Extracts and removes the --output flag from tokens, returning the output format.
|
||||
/// Supports: --output json, --output text, -o json, -o text, --output=json, --output=text
|
||||
/// </summary>
|
||||
private OutputFormat ExtractOutputFlag(List<string> tokens)
|
||||
{
|
||||
for (int i = 0; i < tokens.Count; i++)
|
||||
{
|
||||
var token = tokens[i].ToLower();
|
||||
|
||||
if (token == "--output" || token == "-o")
|
||||
{
|
||||
if (i + 1 < tokens.Count)
|
||||
{
|
||||
var format = tokens[i + 1].ToLower();
|
||||
tokens.RemoveAt(i); // Remove flag
|
||||
tokens.RemoveAt(i); // Remove value (now at same index)
|
||||
return format == "json" ? OutputFormat.Json : OutputFormat.Text;
|
||||
}
|
||||
}
|
||||
else if (token.StartsWith("--output="))
|
||||
{
|
||||
var format = token.Substring("--output=".Length);
|
||||
tokens.RemoveAt(i);
|
||||
return format == "json" ? OutputFormat.Json : OutputFormat.Text;
|
||||
}
|
||||
}
|
||||
return OutputFormat.Text;
|
||||
}
|
||||
|
||||
private string GetNextArg(List<string> tokens, ref int index, string optName)
|
||||
{
|
||||
if (index + 1 >= tokens.Count)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.ParseError,
|
||||
$"Option {optName} requires a value");
|
||||
}
|
||||
return tokens[++index];
|
||||
}
|
||||
|
||||
private int GetNextArgInt(List<string> tokens, ref int index, string optName)
|
||||
{
|
||||
var value = GetNextArg(tokens, ref index, optName);
|
||||
if (!int.TryParse(value, out var result))
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.ParseError,
|
||||
$"Option {optName} requires an integer value, got: {value}");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private void ParseEnvArg(List<string> tokens, ref int index, Dictionary<string, string> env)
|
||||
{
|
||||
ParseKeyValueArg(tokens, ref index, env);
|
||||
}
|
||||
|
||||
private void ParseKeyValueArg(List<string> tokens, ref int index, Dictionary<string, string> dict)
|
||||
{
|
||||
var value = GetNextArg(tokens, ref index, "key=value");
|
||||
var eqIndex = value.IndexOf('=');
|
||||
if (eqIndex <= 0)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.ParseError,
|
||||
$"Expected key=value format, got: {value}");
|
||||
}
|
||||
var key = value.Substring(0, eqIndex);
|
||||
var val = value.Substring(eqIndex + 1);
|
||||
dict[key] = val;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
94
src/Runner.Worker/Dap/StepCommands/StepCommandResult.cs
Normal file
94
src/Runner.Worker/Dap/StepCommands/StepCommandResult.cs
Normal file
@@ -0,0 +1,94 @@
|
||||
using System;
|
||||
|
||||
namespace GitHub.Runner.Worker.Dap.StepCommands
|
||||
{
|
||||
/// <summary>
|
||||
/// Standardized result from step command execution.
|
||||
/// Used by both REPL and JSON API handlers to return consistent responses.
|
||||
/// </summary>
|
||||
public class StepCommandResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the command executed successfully.
|
||||
/// </summary>
|
||||
public bool Success { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable message describing the result (for REPL display).
|
||||
/// </summary>
|
||||
public string Message { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Error code for programmatic handling (e.g., "INVALID_INDEX", "PARSE_ERROR").
|
||||
/// Null if successful.
|
||||
/// </summary>
|
||||
public string Error { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Command-specific result data (e.g., list of steps, step info, YAML export).
|
||||
/// Type varies by command - consumers should check Success before using.
|
||||
/// </summary>
|
||||
public object Result { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a successful result.
|
||||
/// </summary>
|
||||
public static StepCommandResult Ok(string message, object result = null)
|
||||
{
|
||||
return new StepCommandResult
|
||||
{
|
||||
Success = true,
|
||||
Message = message,
|
||||
Result = result
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failed result.
|
||||
/// </summary>
|
||||
public static StepCommandResult Fail(string errorCode, string message)
|
||||
{
|
||||
return new StepCommandResult
|
||||
{
|
||||
Success = false,
|
||||
Error = errorCode,
|
||||
Message = message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Error codes for step commands.
|
||||
/// </summary>
|
||||
public static class StepCommandErrors
|
||||
{
|
||||
public const string InvalidIndex = "INVALID_INDEX";
|
||||
public const string InvalidCommand = "INVALID_COMMAND";
|
||||
public const string InvalidOption = "INVALID_OPTION";
|
||||
public const string InvalidType = "INVALID_TYPE";
|
||||
public const string InvalidPosition = "INVALID_POSITION";
|
||||
public const string ActionDownloadFailed = "ACTION_DOWNLOAD_FAILED";
|
||||
public const string ParseError = "PARSE_ERROR";
|
||||
public const string NotPaused = "NOT_PAUSED";
|
||||
public const string NoContext = "NO_CONTEXT";
|
||||
public const string DuplicateId = "DUPLICATE_ID";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown during step command parsing or execution.
|
||||
/// </summary>
|
||||
public class StepCommandException : Exception
|
||||
{
|
||||
public string ErrorCode { get; }
|
||||
|
||||
public StepCommandException(string errorCode, string message) : base(message)
|
||||
{
|
||||
ErrorCode = errorCode;
|
||||
}
|
||||
|
||||
public StepCommandResult ToResult()
|
||||
{
|
||||
return StepCommandResult.Fail(ErrorCode, Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
452
src/Runner.Worker/Dap/StepCommands/StepFactory.cs
Normal file
452
src/Runner.Worker/Dap/StepCommands/StepFactory.cs
Normal file
@@ -0,0 +1,452 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.RegularExpressions;
|
||||
using GitHub.DistributedTask.ObjectTemplating.Tokens;
|
||||
using GitHub.DistributedTask.Pipelines;
|
||||
using GitHub.Runner.Common;
|
||||
|
||||
namespace GitHub.Runner.Worker.Dap.StepCommands
|
||||
{
|
||||
/// <summary>
|
||||
/// Interface for creating ActionStep and IActionRunner objects at runtime.
|
||||
/// Used by step commands to dynamically add steps during debug sessions.
|
||||
/// </summary>
|
||||
[ServiceLocator(Default = typeof(StepFactory))]
|
||||
public interface IStepFactory : IRunnerService
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new run step (script step).
|
||||
/// </summary>
|
||||
/// <param name="script">The script to execute</param>
|
||||
/// <param name="id">Optional step ID for referencing in expressions (e.g., steps.<id>.outputs)</param>
|
||||
/// <param name="name">Optional display name for the step</param>
|
||||
/// <param name="shell">Optional shell (bash, sh, pwsh, python, etc.)</param>
|
||||
/// <param name="workingDirectory">Optional working directory</param>
|
||||
/// <param name="env">Optional environment variables</param>
|
||||
/// <param name="condition">Optional condition expression (defaults to "success()")</param>
|
||||
/// <param name="continueOnError">Whether to continue on error (defaults to false)</param>
|
||||
/// <param name="timeoutMinutes">Optional timeout in minutes</param>
|
||||
/// <returns>A configured ActionStep with ScriptReference</returns>
|
||||
ActionStep CreateRunStep(
|
||||
string script,
|
||||
string id = null,
|
||||
string name = null,
|
||||
string shell = null,
|
||||
string workingDirectory = null,
|
||||
Dictionary<string, string> env = null,
|
||||
string condition = null,
|
||||
bool continueOnError = false,
|
||||
int? timeoutMinutes = null);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new uses step (action step).
|
||||
/// </summary>
|
||||
/// <param name="actionReference">The action reference (e.g., "actions/checkout@v4", "owner/repo@ref", "./local-action")</param>
|
||||
/// <param name="id">Optional step ID for referencing in expressions (e.g., steps.<id>.outputs)</param>
|
||||
/// <param name="name">Optional display name for the step</param>
|
||||
/// <param name="with">Optional input parameters for the action</param>
|
||||
/// <param name="env">Optional environment variables</param>
|
||||
/// <param name="condition">Optional condition expression (defaults to "success()")</param>
|
||||
/// <param name="continueOnError">Whether to continue on error (defaults to false)</param>
|
||||
/// <param name="timeoutMinutes">Optional timeout in minutes</param>
|
||||
/// <returns>A configured ActionStep with RepositoryPathReference or ContainerRegistryReference</returns>
|
||||
ActionStep CreateUsesStep(
|
||||
string actionReference,
|
||||
string id = null,
|
||||
string name = null,
|
||||
Dictionary<string, string> with = null,
|
||||
Dictionary<string, string> env = null,
|
||||
string condition = null,
|
||||
bool continueOnError = false,
|
||||
int? timeoutMinutes = null);
|
||||
|
||||
/// <summary>
|
||||
/// Wraps an ActionStep in an IActionRunner for execution.
|
||||
/// </summary>
|
||||
/// <param name="step">The ActionStep to wrap</param>
|
||||
/// <param name="jobContext">The job execution context</param>
|
||||
/// <param name="stage">The execution stage (Main, Pre, or Post)</param>
|
||||
/// <returns>An IActionRunner ready for execution</returns>
|
||||
IActionRunner WrapInRunner(
|
||||
ActionStep step,
|
||||
IExecutionContext jobContext,
|
||||
ActionRunStage stage = ActionRunStage.Main);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parsed components of an action reference string.
|
||||
/// </summary>
|
||||
public class ParsedActionReference
|
||||
{
|
||||
/// <summary>
|
||||
/// The type of action reference.
|
||||
/// </summary>
|
||||
public ActionReferenceType Type { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// For GitHub actions: "owner/repo". For local: null. For docker: null.
|
||||
/// </summary>
|
||||
public string Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// For GitHub actions: the git ref (tag/branch/commit). For local/docker: null.
|
||||
/// </summary>
|
||||
public string Ref { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// For actions in subdirectories: the path within the repo. For local: the full path.
|
||||
/// </summary>
|
||||
public string Path { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// For docker actions: the image reference.
|
||||
/// </summary>
|
||||
public string Image { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Types of action references.
|
||||
/// </summary>
|
||||
public enum ActionReferenceType
|
||||
{
|
||||
/// <summary>GitHub repository action (e.g., "actions/checkout@v4")</summary>
|
||||
Repository,
|
||||
/// <summary>Local action (e.g., "./.github/actions/my-action")</summary>
|
||||
Local,
|
||||
/// <summary>Docker container action (e.g., "docker://alpine:latest")</summary>
|
||||
Docker
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating ActionStep and IActionRunner objects at runtime.
|
||||
/// </summary>
|
||||
public sealed class StepFactory : RunnerService, IStepFactory
|
||||
{
|
||||
// Constants for script step inputs (matching PipelineConstants.ScriptStepInputs)
|
||||
private const string ScriptInputKey = "script";
|
||||
private const string ShellInputKey = "shell";
|
||||
private const string WorkingDirectoryInputKey = "workingDirectory";
|
||||
|
||||
// Regex for parsing action references
|
||||
// Matches: owner/repo@ref, owner/repo/path@ref, owner/repo@ref/path (unusual but valid)
|
||||
private static readonly Regex ActionRefRegex = new Regex(
|
||||
@"^(?<name>[^/@]+/[^/@]+)(?:/(?<path>[^@]+))?@(?<ref>.+)$",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public ActionStep CreateRunStep(
|
||||
string script,
|
||||
string id = null,
|
||||
string name = null,
|
||||
string shell = null,
|
||||
string workingDirectory = null,
|
||||
Dictionary<string, string> env = null,
|
||||
string condition = null,
|
||||
bool continueOnError = false,
|
||||
int? timeoutMinutes = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(script))
|
||||
{
|
||||
throw new ArgumentException("Script cannot be null or empty", nameof(script));
|
||||
}
|
||||
|
||||
var stepId = Guid.NewGuid();
|
||||
var step = new ActionStep
|
||||
{
|
||||
Id = stepId,
|
||||
Name = id ?? $"_dynamic_{stepId:N}",
|
||||
ContextName = id, // Required for step to appear in steps context (steps.<id>.outputs, etc.)
|
||||
DisplayName = name ?? "Run script",
|
||||
Reference = new ScriptReference(),
|
||||
Condition = condition ?? "success()",
|
||||
Enabled = true
|
||||
};
|
||||
|
||||
// Build Inputs mapping with script, shell, working-directory
|
||||
step.Inputs = CreateRunInputs(script, shell, workingDirectory);
|
||||
|
||||
// Build Environment mapping
|
||||
if (env?.Count > 0)
|
||||
{
|
||||
step.Environment = CreateEnvToken(env);
|
||||
}
|
||||
|
||||
// Set continue-on-error
|
||||
if (continueOnError)
|
||||
{
|
||||
step.ContinueOnError = new BooleanToken(null, null, null, true);
|
||||
}
|
||||
|
||||
// Set timeout
|
||||
if (timeoutMinutes.HasValue)
|
||||
{
|
||||
step.TimeoutInMinutes = new NumberToken(null, null, null, timeoutMinutes.Value);
|
||||
}
|
||||
|
||||
return step;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public ActionStep CreateUsesStep(
|
||||
string actionReference,
|
||||
string id = null,
|
||||
string name = null,
|
||||
Dictionary<string, string> with = null,
|
||||
Dictionary<string, string> env = null,
|
||||
string condition = null,
|
||||
bool continueOnError = false,
|
||||
int? timeoutMinutes = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(actionReference))
|
||||
{
|
||||
throw new ArgumentException("Action reference cannot be null or empty", nameof(actionReference));
|
||||
}
|
||||
|
||||
var parsed = ParseActionReference(actionReference);
|
||||
var stepId = Guid.NewGuid();
|
||||
|
||||
var step = new ActionStep
|
||||
{
|
||||
Id = stepId,
|
||||
Name = id ?? $"_dynamic_{stepId:N}",
|
||||
ContextName = id, // Required for step to appear in steps context (steps.<id>.outputs, etc.)
|
||||
DisplayName = name ?? actionReference,
|
||||
Condition = condition ?? "success()",
|
||||
Enabled = true
|
||||
};
|
||||
|
||||
// Set reference based on action type
|
||||
switch (parsed.Type)
|
||||
{
|
||||
case ActionReferenceType.Repository:
|
||||
step.Reference = new RepositoryPathReference
|
||||
{
|
||||
Name = parsed.Name,
|
||||
Ref = parsed.Ref,
|
||||
Path = parsed.Path,
|
||||
RepositoryType = "GitHub"
|
||||
};
|
||||
break;
|
||||
|
||||
case ActionReferenceType.Local:
|
||||
step.Reference = new RepositoryPathReference
|
||||
{
|
||||
RepositoryType = PipelineConstants.SelfAlias,
|
||||
Path = parsed.Path
|
||||
};
|
||||
break;
|
||||
|
||||
case ActionReferenceType.Docker:
|
||||
step.Reference = new ContainerRegistryReference
|
||||
{
|
||||
Image = parsed.Image
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
// Build with inputs
|
||||
if (with?.Count > 0)
|
||||
{
|
||||
step.Inputs = CreateWithInputs(with);
|
||||
}
|
||||
|
||||
// Build Environment mapping
|
||||
if (env?.Count > 0)
|
||||
{
|
||||
step.Environment = CreateEnvToken(env);
|
||||
}
|
||||
|
||||
// Set continue-on-error
|
||||
if (continueOnError)
|
||||
{
|
||||
step.ContinueOnError = new BooleanToken(null, null, null, true);
|
||||
}
|
||||
|
||||
// Set timeout
|
||||
if (timeoutMinutes.HasValue)
|
||||
{
|
||||
step.TimeoutInMinutes = new NumberToken(null, null, null, timeoutMinutes.Value);
|
||||
}
|
||||
|
||||
return step;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IActionRunner WrapInRunner(
|
||||
ActionStep step,
|
||||
IExecutionContext jobContext,
|
||||
ActionRunStage stage = ActionRunStage.Main)
|
||||
{
|
||||
if (step == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(step));
|
||||
}
|
||||
if (jobContext == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(jobContext));
|
||||
}
|
||||
|
||||
var runner = HostContext.CreateService<IActionRunner>();
|
||||
runner.Action = step;
|
||||
runner.Stage = stage;
|
||||
runner.Condition = step.Condition;
|
||||
|
||||
// Create a child execution context for this step
|
||||
// The child context gets its own scope for outputs, logging, etc.
|
||||
// Following the pattern from JobExtension.cs line ~401
|
||||
runner.ExecutionContext = jobContext.CreateChild(
|
||||
recordId: step.Id,
|
||||
displayName: step.DisplayName,
|
||||
refName: step.Name,
|
||||
scopeName: null,
|
||||
contextName: step.ContextName,
|
||||
stage: stage
|
||||
);
|
||||
|
||||
return runner;
|
||||
}
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
/// <summary>
|
||||
/// Parses an action reference string into its components.
|
||||
/// </summary>
|
||||
/// <param name="actionReference">The action reference (e.g., "actions/checkout@v4")</param>
|
||||
/// <returns>Parsed action reference components</returns>
|
||||
public static ParsedActionReference ParseActionReference(string actionReference)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(actionReference))
|
||||
{
|
||||
throw new ArgumentException("Action reference cannot be null or empty", nameof(actionReference));
|
||||
}
|
||||
|
||||
var trimmed = actionReference.Trim();
|
||||
|
||||
// Check for docker action: docker://image:tag
|
||||
if (trimmed.StartsWith("docker://", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new ParsedActionReference
|
||||
{
|
||||
Type = ActionReferenceType.Docker,
|
||||
Image = trimmed.Substring("docker://".Length)
|
||||
};
|
||||
}
|
||||
|
||||
// Check for local action: ./ or ../ prefix
|
||||
if (trimmed.StartsWith("./") || trimmed.StartsWith("../"))
|
||||
{
|
||||
return new ParsedActionReference
|
||||
{
|
||||
Type = ActionReferenceType.Local,
|
||||
Path = trimmed
|
||||
};
|
||||
}
|
||||
|
||||
// Parse as GitHub repository action: owner/repo@ref or owner/repo/path@ref
|
||||
var match = ActionRefRegex.Match(trimmed);
|
||||
if (match.Success)
|
||||
{
|
||||
var result = new ParsedActionReference
|
||||
{
|
||||
Type = ActionReferenceType.Repository,
|
||||
Name = match.Groups["name"].Value,
|
||||
Ref = match.Groups["ref"].Value
|
||||
};
|
||||
|
||||
if (match.Groups["path"].Success && !string.IsNullOrEmpty(match.Groups["path"].Value))
|
||||
{
|
||||
result.Path = match.Groups["path"].Value;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// If no @ sign, assume it's a local action path
|
||||
if (!trimmed.Contains("@"))
|
||||
{
|
||||
return new ParsedActionReference
|
||||
{
|
||||
Type = ActionReferenceType.Local,
|
||||
Path = trimmed
|
||||
};
|
||||
}
|
||||
|
||||
// Invalid format
|
||||
throw new StepCommandException(
|
||||
StepCommandErrors.ParseError,
|
||||
$"Invalid action reference format: '{actionReference}'. Expected: 'owner/repo@ref', './local-path', or 'docker://image:tag'");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a MappingToken for run step inputs (script, shell, working-directory).
|
||||
/// </summary>
|
||||
private MappingToken CreateRunInputs(string script, string shell, string workingDirectory)
|
||||
{
|
||||
var inputs = new MappingToken(null, null, null);
|
||||
|
||||
// Script is always required
|
||||
inputs.Add(
|
||||
new StringToken(null, null, null, ScriptInputKey),
|
||||
new StringToken(null, null, null, script)
|
||||
);
|
||||
|
||||
// Shell is optional
|
||||
if (!string.IsNullOrEmpty(shell))
|
||||
{
|
||||
inputs.Add(
|
||||
new StringToken(null, null, null, ShellInputKey),
|
||||
new StringToken(null, null, null, shell)
|
||||
);
|
||||
}
|
||||
|
||||
// Working directory is optional
|
||||
if (!string.IsNullOrEmpty(workingDirectory))
|
||||
{
|
||||
inputs.Add(
|
||||
new StringToken(null, null, null, WorkingDirectoryInputKey),
|
||||
new StringToken(null, null, null, workingDirectory)
|
||||
);
|
||||
}
|
||||
|
||||
return inputs;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a MappingToken for action "with" inputs.
|
||||
/// </summary>
|
||||
private MappingToken CreateWithInputs(Dictionary<string, string> with)
|
||||
{
|
||||
var inputs = new MappingToken(null, null, null);
|
||||
|
||||
foreach (var kvp in with)
|
||||
{
|
||||
inputs.Add(
|
||||
new StringToken(null, null, null, kvp.Key),
|
||||
new StringToken(null, null, null, kvp.Value ?? "")
|
||||
);
|
||||
}
|
||||
|
||||
return inputs;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a MappingToken for environment variables.
|
||||
/// </summary>
|
||||
private MappingToken CreateEnvToken(Dictionary<string, string> env)
|
||||
{
|
||||
var envMapping = new MappingToken(null, null, null);
|
||||
|
||||
foreach (var kvp in env)
|
||||
{
|
||||
envMapping.Add(
|
||||
new StringToken(null, null, null, kvp.Key),
|
||||
new StringToken(null, null, null, kvp.Value ?? "")
|
||||
);
|
||||
}
|
||||
|
||||
return envMapping;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
222
src/Runner.Worker/Dap/StepCommands/StepInfo.cs
Normal file
222
src/Runner.Worker/Dap/StepCommands/StepInfo.cs
Normal file
@@ -0,0 +1,222 @@
|
||||
using GitHub.DistributedTask.Pipelines;
|
||||
using GitHub.DistributedTask.WebApi;
|
||||
|
||||
namespace GitHub.Runner.Worker.Dap.StepCommands
|
||||
{
|
||||
/// <summary>
|
||||
/// Step status for display and manipulation.
|
||||
/// </summary>
|
||||
public enum StepStatus
|
||||
{
|
||||
/// <summary>Step has completed execution.</summary>
|
||||
Completed,
|
||||
/// <summary>Step is currently executing or paused.</summary>
|
||||
Current,
|
||||
/// <summary>Step is pending execution.</summary>
|
||||
Pending
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of change applied to a step.
|
||||
/// </summary>
|
||||
public enum ChangeType
|
||||
{
|
||||
/// <summary>Step was added during debug session.</summary>
|
||||
Added,
|
||||
/// <summary>Step was modified during debug session.</summary>
|
||||
Modified,
|
||||
/// <summary>Step was removed during debug session.</summary>
|
||||
Removed,
|
||||
/// <summary>Step was moved during debug session.</summary>
|
||||
Moved
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unified step information for display, manipulation, and serialization.
|
||||
/// Wraps both the underlying ActionStep and IStep with metadata about status and changes.
|
||||
/// </summary>
|
||||
public class StepInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// 1-based index of the step in the combined list (completed + current + pending).
|
||||
/// </summary>
|
||||
public int Index { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Display name of the step.
|
||||
/// </summary>
|
||||
public string Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Step type: "run" or "uses"
|
||||
/// </summary>
|
||||
public string Type { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Type detail: action reference for uses steps, script preview for run steps.
|
||||
/// </summary>
|
||||
public string TypeDetail { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Current execution status of the step.
|
||||
/// </summary>
|
||||
public StepStatus Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Result of step execution (for completed steps).
|
||||
/// </summary>
|
||||
public TaskResult? Result { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Change type if the step was modified during this debug session, null otherwise.
|
||||
/// </summary>
|
||||
public ChangeType? Change { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The underlying ActionStep (for serialization and modification).
|
||||
/// May be null for non-action steps (e.g., JobExtensionRunner).
|
||||
/// </summary>
|
||||
public ActionStep Action { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The underlying IStep (for execution).
|
||||
/// </summary>
|
||||
public IStep Step { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Original index before any moves (for change tracking).
|
||||
/// Only set when Change == Moved.
|
||||
/// </summary>
|
||||
public int? OriginalIndex { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a StepInfo from an IStep.
|
||||
/// </summary>
|
||||
public static StepInfo FromStep(IStep step, int index, StepStatus status)
|
||||
{
|
||||
var info = new StepInfo
|
||||
{
|
||||
Index = index,
|
||||
Name = step.DisplayName ?? "Unknown step",
|
||||
Status = status,
|
||||
Step = step,
|
||||
Result = step.ExecutionContext?.Result
|
||||
};
|
||||
|
||||
// Try to extract ActionStep from IActionRunner
|
||||
if (step is IActionRunner actionRunner && actionRunner.Action != null)
|
||||
{
|
||||
info.Action = actionRunner.Action;
|
||||
PopulateFromActionStep(info, actionRunner.Action);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Non-action step (e.g., JobExtensionRunner)
|
||||
info.Type = "extension";
|
||||
info.TypeDetail = step.GetType().Name;
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a StepInfo from an ActionStep.
|
||||
/// </summary>
|
||||
public static StepInfo FromActionStep(ActionStep action, int index, StepStatus status)
|
||||
{
|
||||
var info = new StepInfo
|
||||
{
|
||||
Index = index,
|
||||
Name = action.DisplayName ?? "Unknown step",
|
||||
Status = status,
|
||||
Action = action
|
||||
};
|
||||
|
||||
PopulateFromActionStep(info, action);
|
||||
return info;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Populates type information from an ActionStep.
|
||||
/// </summary>
|
||||
private static void PopulateFromActionStep(StepInfo info, ActionStep action)
|
||||
{
|
||||
switch (action.Reference)
|
||||
{
|
||||
case ScriptReference:
|
||||
info.Type = "run";
|
||||
info.TypeDetail = GetScriptPreview(action);
|
||||
break;
|
||||
|
||||
case RepositoryPathReference repoRef:
|
||||
info.Type = "uses";
|
||||
info.TypeDetail = BuildUsesReference(repoRef);
|
||||
break;
|
||||
|
||||
case ContainerRegistryReference containerRef:
|
||||
info.Type = "uses";
|
||||
info.TypeDetail = $"docker://{containerRef.Image}";
|
||||
break;
|
||||
|
||||
default:
|
||||
info.Type = "unknown";
|
||||
info.TypeDetail = action.Reference?.GetType().Name ?? "null";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a preview of the script (first line, truncated).
|
||||
/// </summary>
|
||||
private static string GetScriptPreview(ActionStep action)
|
||||
{
|
||||
if (action.Inputs is GitHub.DistributedTask.ObjectTemplating.Tokens.MappingToken mapping)
|
||||
{
|
||||
foreach (var pair in mapping)
|
||||
{
|
||||
var key = pair.Key?.ToString();
|
||||
if (string.Equals(key, "script", System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var script = pair.Value?.ToString() ?? "";
|
||||
// Get first line, truncate if too long
|
||||
var firstLine = script.Split('\n')[0].Trim();
|
||||
if (firstLine.Length > 40)
|
||||
{
|
||||
return firstLine.Substring(0, 37) + "...";
|
||||
}
|
||||
return firstLine;
|
||||
}
|
||||
}
|
||||
}
|
||||
return "(script)";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a uses reference string from a RepositoryPathReference.
|
||||
/// </summary>
|
||||
private static string BuildUsesReference(RepositoryPathReference repoRef)
|
||||
{
|
||||
// Local action
|
||||
if (string.Equals(repoRef.RepositoryType, "self", System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return repoRef.Path ?? ".";
|
||||
}
|
||||
|
||||
// Remote action
|
||||
var name = repoRef.Name ?? "";
|
||||
var refValue = repoRef.Ref ?? "";
|
||||
var path = repoRef.Path;
|
||||
|
||||
if (!string.IsNullOrEmpty(path) && path != "/" && path != ".")
|
||||
{
|
||||
if (path.StartsWith("/"))
|
||||
{
|
||||
path = path.Substring(1);
|
||||
}
|
||||
return $"{name}/{path}@{refValue}";
|
||||
}
|
||||
|
||||
return $"{name}@{refValue}";
|
||||
}
|
||||
}
|
||||
}
|
||||
843
src/Runner.Worker/Dap/StepCommands/StepManipulator.cs
Normal file
843
src/Runner.Worker/Dap/StepCommands/StepManipulator.cs
Normal file
@@ -0,0 +1,843 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using GitHub.DistributedTask.Pipelines;
|
||||
using GitHub.Runner.Common;
|
||||
using GitHub.Runner.Sdk;
|
||||
using GitHub.Runner.Worker.Dap;
|
||||
|
||||
namespace GitHub.Runner.Worker.Dap.StepCommands
|
||||
{
|
||||
/// <summary>
|
||||
/// Interface for manipulating job steps during a debug session.
|
||||
/// Provides query and mutation operations on the step queue with change tracking.
|
||||
/// </summary>
|
||||
[ServiceLocator(Default = typeof(StepManipulator))]
|
||||
public interface IStepManipulator : IRunnerService
|
||||
{
|
||||
/// <summary>
|
||||
/// Initialize the manipulator with job context and current execution state.
|
||||
/// Must be called before any other operations.
|
||||
/// </summary>
|
||||
/// <param name="jobContext">The job execution context containing the step queues.</param>
|
||||
/// <param name="currentStepIndex">The 1-based index of the currently executing step, or 0 if no step is executing.</param>
|
||||
void Initialize(IExecutionContext jobContext, int currentStepIndex);
|
||||
|
||||
/// <summary>
|
||||
/// Updates the current step index (called as steps complete).
|
||||
/// </summary>
|
||||
/// <param name="index">The new 1-based index of the current step.</param>
|
||||
void UpdateCurrentIndex(int index);
|
||||
|
||||
/// <summary>
|
||||
/// Adds a completed step to the history (for step-back support).
|
||||
/// </summary>
|
||||
/// <param name="step">The step that completed.</param>
|
||||
void AddCompletedStep(IStep step);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current step that is executing/paused (if any).
|
||||
/// </summary>
|
||||
IStep CurrentStep { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets all steps (completed + current + pending) as a unified list.
|
||||
/// </summary>
|
||||
/// <returns>List of StepInfo ordered by index (1-based).</returns>
|
||||
IReadOnlyList<StepInfo> GetAllSteps();
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific step by 1-based index.
|
||||
/// </summary>
|
||||
/// <param name="index">1-based index of the step.</param>
|
||||
/// <returns>The StepInfo, or null if index is out of range.</returns>
|
||||
StepInfo GetStep(int index);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the count of pending steps (not yet executed).
|
||||
/// </summary>
|
||||
int GetPendingCount();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the 1-based index of the first pending step.
|
||||
/// </summary>
|
||||
/// <returns>The first pending index, or -1 if no pending steps.</returns>
|
||||
int GetFirstPendingIndex();
|
||||
|
||||
/// <summary>
|
||||
/// Inserts a step at the specified position.
|
||||
/// </summary>
|
||||
/// <param name="step">The step to insert.</param>
|
||||
/// <param name="position">The position specification (At, After, Before, First, Last).</param>
|
||||
/// <returns>The 1-based index where the step was inserted.</returns>
|
||||
/// <exception cref="StepCommandException">If the position is invalid.</exception>
|
||||
int InsertStep(IStep step, StepPosition position);
|
||||
|
||||
/// <summary>
|
||||
/// Removes a step at the specified 1-based index.
|
||||
/// </summary>
|
||||
/// <param name="index">The 1-based index of the step to remove.</param>
|
||||
/// <exception cref="StepCommandException">If the index is invalid or step cannot be removed.</exception>
|
||||
void RemoveStep(int index);
|
||||
|
||||
/// <summary>
|
||||
/// Moves a step from one position to another.
|
||||
/// </summary>
|
||||
/// <param name="fromIndex">The 1-based index of the step to move.</param>
|
||||
/// <param name="position">The target position specification.</param>
|
||||
/// <returns>The new 1-based index of the step.</returns>
|
||||
/// <exception cref="StepCommandException">If the move is invalid.</exception>
|
||||
int MoveStep(int fromIndex, StepPosition position);
|
||||
|
||||
/// <summary>
|
||||
/// Applies an edit to a step's ActionStep.
|
||||
/// </summary>
|
||||
/// <param name="index">The 1-based index of the step to edit.</param>
|
||||
/// <param name="edit">The edit action to apply.</param>
|
||||
/// <exception cref="StepCommandException">If the step cannot be edited.</exception>
|
||||
void EditStep(int index, Action<ActionStep> edit);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all changes made during this session.
|
||||
/// </summary>
|
||||
/// <returns>List of change records in chronological order.</returns>
|
||||
IReadOnlyList<StepChange> GetChanges();
|
||||
|
||||
/// <summary>
|
||||
/// Records the original state for change tracking.
|
||||
/// Should be called when the debug session starts.
|
||||
/// </summary>
|
||||
void RecordOriginalState();
|
||||
|
||||
/// <summary>
|
||||
/// Clears all recorded changes (for testing or reset).
|
||||
/// </summary>
|
||||
void ClearChanges();
|
||||
|
||||
/// <summary>
|
||||
/// Trims the completed steps list to the specified count.
|
||||
/// Used when restoring a checkpoint to sync state with the debug session.
|
||||
/// </summary>
|
||||
/// <param name="count">The number of completed steps to keep.</param>
|
||||
void TrimCompletedSteps(int count);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a step with the given ID already exists.
|
||||
/// </summary>
|
||||
/// <param name="id">The step ID to check for.</param>
|
||||
/// <returns>True if a step with that ID exists, false otherwise.</returns>
|
||||
bool HasStepWithId(string id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of step manipulation operations.
|
||||
/// Manages the job step queue and tracks all modifications for export.
|
||||
/// </summary>
|
||||
public sealed class StepManipulator : RunnerService, IStepManipulator
|
||||
{
|
||||
private IExecutionContext _jobContext;
|
||||
private int _currentStepIndex;
|
||||
private IStep _currentStep;
|
||||
|
||||
// Completed steps (for display and step-back)
|
||||
private readonly List<IStep> _completedSteps = new List<IStep>();
|
||||
|
||||
// Original state for change tracking
|
||||
private List<StepInfo> _originalSteps;
|
||||
|
||||
// Change history
|
||||
private readonly List<StepChange> _changes = new List<StepChange>();
|
||||
|
||||
// Track which steps have been modified (by step Name/Id)
|
||||
private readonly HashSet<Guid> _modifiedStepIds = new HashSet<Guid>();
|
||||
private readonly HashSet<Guid> _addedStepIds = new HashSet<Guid>();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IStep CurrentStep => _currentStep;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Initialize(IExecutionContext jobContext, int currentStepIndex)
|
||||
{
|
||||
ArgUtil.NotNull(jobContext, nameof(jobContext));
|
||||
|
||||
_jobContext = jobContext;
|
||||
_currentStepIndex = currentStepIndex;
|
||||
_currentStep = null;
|
||||
_completedSteps.Clear();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void UpdateCurrentIndex(int index)
|
||||
{
|
||||
_currentStepIndex = index;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void AddCompletedStep(IStep step)
|
||||
{
|
||||
ArgUtil.NotNull(step, nameof(step));
|
||||
_completedSteps.Add(step);
|
||||
_currentStep = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the current step (the one that is executing/paused).
|
||||
/// </summary>
|
||||
public void SetCurrentStep(IStep step)
|
||||
{
|
||||
_currentStep = step;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IReadOnlyList<StepInfo> GetAllSteps()
|
||||
{
|
||||
var result = new List<StepInfo>();
|
||||
int index = 1;
|
||||
|
||||
// Add completed steps
|
||||
foreach (var step in _completedSteps)
|
||||
{
|
||||
var info = StepInfo.FromStep(step, index, StepStatus.Completed);
|
||||
ApplyChangeInfo(info);
|
||||
result.Add(info);
|
||||
index++;
|
||||
}
|
||||
|
||||
// Check if we're in "step inserted here" state
|
||||
var debugSession = HostContext.GetService<IDapDebugSession>();
|
||||
var stepInsertedHere = debugSession?.HasStepInsertedHere ?? false;
|
||||
|
||||
// Add current step if present AND not in "step inserted here" state
|
||||
// (In that state, the current step has been re-queued and will show in pending)
|
||||
if (_currentStep != null && !stepInsertedHere)
|
||||
{
|
||||
var info = StepInfo.FromStep(_currentStep, index, StepStatus.Current);
|
||||
ApplyChangeInfo(info);
|
||||
result.Add(info);
|
||||
index++;
|
||||
}
|
||||
|
||||
// Add pending steps from queue
|
||||
if (_jobContext?.JobSteps != null)
|
||||
{
|
||||
bool isFirstPending = true;
|
||||
foreach (var step in _jobContext.JobSteps)
|
||||
{
|
||||
// In "step inserted here" state, mark the first pending step as current
|
||||
var status = (stepInsertedHere && isFirstPending)
|
||||
? StepStatus.Current
|
||||
: StepStatus.Pending;
|
||||
|
||||
var info = StepInfo.FromStep(step, index, status);
|
||||
ApplyChangeInfo(info);
|
||||
result.Add(info);
|
||||
index++;
|
||||
isFirstPending = false;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public StepInfo GetStep(int index)
|
||||
{
|
||||
if (index < 1)
|
||||
return null;
|
||||
|
||||
var allSteps = GetAllSteps();
|
||||
if (index > allSteps.Count)
|
||||
return null;
|
||||
|
||||
return allSteps[index - 1];
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public int GetPendingCount()
|
||||
{
|
||||
return _jobContext?.JobSteps?.Count ?? 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public int GetFirstPendingIndex()
|
||||
{
|
||||
var completedCount = _completedSteps.Count;
|
||||
var currentCount = _currentStep != null ? 1 : 0;
|
||||
var pendingCount = GetPendingCount();
|
||||
|
||||
if (pendingCount == 0)
|
||||
return -1;
|
||||
|
||||
return completedCount + currentCount + 1;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public int InsertStep(IStep step, StepPosition position)
|
||||
{
|
||||
ArgUtil.NotNull(step, nameof(step));
|
||||
ValidateInitialized();
|
||||
|
||||
// Special case: --here inserts before current step
|
||||
if (position.Type == PositionType.Here)
|
||||
{
|
||||
return InsertStepHere(step);
|
||||
}
|
||||
|
||||
// Calculate the insertion index within the pending queue (0-based)
|
||||
int insertAt = CalculateInsertIndex(position);
|
||||
|
||||
// Convert queue to list for manipulation
|
||||
var pending = _jobContext.JobSteps.ToList();
|
||||
_jobContext.JobSteps.Clear();
|
||||
|
||||
// Insert the step
|
||||
pending.Insert(insertAt, step);
|
||||
|
||||
// Re-queue all steps
|
||||
foreach (var s in pending)
|
||||
{
|
||||
_jobContext.JobSteps.Enqueue(s);
|
||||
}
|
||||
|
||||
// Calculate the 1-based index in the overall step list
|
||||
var firstPendingIndex = GetFirstPendingIndex();
|
||||
var newIndex = firstPendingIndex + insertAt;
|
||||
|
||||
// Track the change
|
||||
var stepInfo = StepInfo.FromStep(step, newIndex, StepStatus.Pending);
|
||||
stepInfo.Change = ChangeType.Added;
|
||||
|
||||
if (step is IActionRunner runner && runner.Action != null)
|
||||
{
|
||||
_addedStepIds.Add(runner.Action.Id);
|
||||
}
|
||||
|
||||
_changes.Add(StepChange.Added(stepInfo, newIndex));
|
||||
|
||||
Trace.Info($"Inserted step '{step.DisplayName}' at position {newIndex} (queue index {insertAt})");
|
||||
return newIndex;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inserts a step before the current step (the one paused at a breakpoint).
|
||||
/// The new step becomes the next step to run when the user continues/steps forward.
|
||||
/// </summary>
|
||||
/// <param name="step">The step to insert.</param>
|
||||
/// <returns>The 1-based index where the step was inserted.</returns>
|
||||
private int InsertStepHere(IStep step)
|
||||
{
|
||||
if (_currentStep == null)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.InvalidPosition,
|
||||
"Can only use --here when paused at a breakpoint.");
|
||||
}
|
||||
|
||||
// Convert queue to list for manipulation
|
||||
var pending = _jobContext.JobSteps.ToList();
|
||||
_jobContext.JobSteps.Clear();
|
||||
|
||||
// Insert the new step at the front (it will run first)
|
||||
pending.Insert(0, step);
|
||||
|
||||
// Insert the original current step after it (it will run second)
|
||||
// This re-queues the step that was already dequeued by StepsRunner
|
||||
pending.Insert(1, _currentStep);
|
||||
|
||||
// Re-queue all steps
|
||||
foreach (var s in pending)
|
||||
{
|
||||
_jobContext.JobSteps.Enqueue(s);
|
||||
}
|
||||
|
||||
// Signal to StepsRunner that it should skip the current iteration
|
||||
// and re-process from the queue (which now has our new step first)
|
||||
var debugSession = HostContext.GetService<IDapDebugSession>();
|
||||
debugSession?.SetStepInsertedHere();
|
||||
|
||||
// Calculate the 1-based index (new step takes position after completed steps)
|
||||
var newIndex = _completedSteps.Count + 1;
|
||||
|
||||
// Track the change
|
||||
var stepInfo = StepInfo.FromStep(step, newIndex, StepStatus.Pending);
|
||||
stepInfo.Change = ChangeType.Added;
|
||||
|
||||
if (step is IActionRunner runner && runner.Action != null)
|
||||
{
|
||||
_addedStepIds.Add(runner.Action.Id);
|
||||
}
|
||||
|
||||
_changes.Add(StepChange.Added(stepInfo, newIndex));
|
||||
|
||||
// Note: We do NOT update _currentStep here. The StepsRunner will
|
||||
// pick up the new step from the queue and that will become current.
|
||||
|
||||
Trace.Info($"Inserted step '{step.DisplayName}' at position {newIndex} (--here, before current step)");
|
||||
return newIndex;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void RemoveStep(int index)
|
||||
{
|
||||
ValidateInitialized();
|
||||
var stepInfo = ValidatePendingIndex(index);
|
||||
|
||||
// Calculate queue index (0-based)
|
||||
var firstPendingIndex = GetFirstPendingIndex();
|
||||
var queueIndex = index - firstPendingIndex;
|
||||
|
||||
// Convert queue to list
|
||||
var pending = _jobContext.JobSteps.ToList();
|
||||
_jobContext.JobSteps.Clear();
|
||||
|
||||
// Remove the step
|
||||
var removedStep = pending[queueIndex];
|
||||
pending.RemoveAt(queueIndex);
|
||||
|
||||
// Re-queue remaining steps
|
||||
foreach (var s in pending)
|
||||
{
|
||||
_jobContext.JobSteps.Enqueue(s);
|
||||
}
|
||||
|
||||
// Track the change
|
||||
var removedInfo = StepInfo.FromStep(removedStep, index, StepStatus.Pending);
|
||||
removedInfo.Change = ChangeType.Removed;
|
||||
_changes.Add(StepChange.Removed(removedInfo));
|
||||
|
||||
Trace.Info($"Removed step '{removedStep.DisplayName}' from position {index}");
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public int MoveStep(int fromIndex, StepPosition position)
|
||||
{
|
||||
ValidateInitialized();
|
||||
var stepInfo = ValidatePendingIndex(fromIndex);
|
||||
|
||||
// Special case: --here moves step to before current step
|
||||
if (position.Type == PositionType.Here)
|
||||
{
|
||||
return MoveStepHere(fromIndex, stepInfo);
|
||||
}
|
||||
|
||||
// Calculate queue indices - BEFORE modifying the queue
|
||||
var firstPendingIndex = GetFirstPendingIndex();
|
||||
var fromQueueIndex = fromIndex - firstPendingIndex;
|
||||
|
||||
// Convert queue to list
|
||||
var pending = _jobContext.JobSteps.ToList();
|
||||
_jobContext.JobSteps.Clear();
|
||||
|
||||
// Remove from original position
|
||||
var step = pending[fromQueueIndex];
|
||||
pending.RemoveAt(fromQueueIndex);
|
||||
|
||||
// Calculate new position (within the now-smaller list)
|
||||
// Pass firstPendingIndex since the queue is now cleared
|
||||
var toQueueIndex = CalculateMoveTargetIndex(position, pending.Count, fromQueueIndex, firstPendingIndex);
|
||||
|
||||
// Insert at new position
|
||||
pending.Insert(toQueueIndex, step);
|
||||
|
||||
// Re-queue all steps
|
||||
foreach (var s in pending)
|
||||
{
|
||||
_jobContext.JobSteps.Enqueue(s);
|
||||
}
|
||||
|
||||
// Calculate new 1-based index
|
||||
var newIndex = firstPendingIndex + toQueueIndex;
|
||||
|
||||
// Track the change
|
||||
var originalInfo = StepInfo.FromStep(step, fromIndex, StepStatus.Pending);
|
||||
_changes.Add(StepChange.Moved(originalInfo, newIndex));
|
||||
|
||||
if (step is IActionRunner runner && runner.Action != null)
|
||||
{
|
||||
_modifiedStepIds.Add(runner.Action.Id);
|
||||
}
|
||||
|
||||
Trace.Info($"Moved step '{step.DisplayName}' from position {fromIndex} to {newIndex}");
|
||||
return newIndex;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Moves a step to before the current step (the one paused at a breakpoint).
|
||||
/// The moved step becomes the next step to run when the user continues/steps forward.
|
||||
/// </summary>
|
||||
/// <param name="fromIndex">The 1-based index of the step to move.</param>
|
||||
/// <param name="stepInfo">The StepInfo for the step being moved.</param>
|
||||
/// <returns>The new 1-based index of the step.</returns>
|
||||
private int MoveStepHere(int fromIndex, StepInfo stepInfo)
|
||||
{
|
||||
if (_currentStep == null)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.InvalidPosition,
|
||||
"Can only use --here when paused at a breakpoint.");
|
||||
}
|
||||
|
||||
// Calculate queue indices - BEFORE modifying the queue
|
||||
var firstPendingIndex = GetFirstPendingIndex();
|
||||
var fromQueueIndex = fromIndex - firstPendingIndex;
|
||||
|
||||
// Convert queue to list
|
||||
var pending = _jobContext.JobSteps.ToList();
|
||||
_jobContext.JobSteps.Clear();
|
||||
|
||||
// Remove the step from its original position
|
||||
var step = pending[fromQueueIndex];
|
||||
pending.RemoveAt(fromQueueIndex);
|
||||
|
||||
// Insert the moved step at the front (it will run first)
|
||||
pending.Insert(0, step);
|
||||
|
||||
// Insert the original current step after it (it will run second)
|
||||
// This re-queues the step that was already dequeued by StepsRunner
|
||||
pending.Insert(1, _currentStep);
|
||||
|
||||
// Re-queue all steps
|
||||
foreach (var s in pending)
|
||||
{
|
||||
_jobContext.JobSteps.Enqueue(s);
|
||||
}
|
||||
|
||||
// Signal to StepsRunner that it should skip the current iteration
|
||||
// and re-process from the queue (which now has our moved step first)
|
||||
var debugSession = HostContext.GetService<IDapDebugSession>();
|
||||
debugSession?.SetStepInsertedHere();
|
||||
|
||||
// Calculate the new 1-based index (step takes position after completed steps)
|
||||
var newIndex = _completedSteps.Count + 1;
|
||||
|
||||
// Track the change
|
||||
var originalInfo = StepInfo.FromStep(step, fromIndex, StepStatus.Pending);
|
||||
_changes.Add(StepChange.Moved(originalInfo, newIndex));
|
||||
|
||||
if (step is IActionRunner runner && runner.Action != null)
|
||||
{
|
||||
_modifiedStepIds.Add(runner.Action.Id);
|
||||
}
|
||||
|
||||
// Note: We do NOT update _currentStep here. The StepsRunner will
|
||||
// pick up the moved step from the queue and that will become current.
|
||||
|
||||
Trace.Info($"Moved step '{step.DisplayName}' from position {fromIndex} to {newIndex} (--here, before current step)");
|
||||
return newIndex;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void EditStep(int index, Action<ActionStep> edit)
|
||||
{
|
||||
ArgUtil.NotNull(edit, nameof(edit));
|
||||
ValidateInitialized();
|
||||
|
||||
var stepInfo = ValidatePendingIndex(index);
|
||||
|
||||
// Get the IActionRunner to access the ActionStep
|
||||
if (stepInfo.Step is not IActionRunner runner || runner.Action == null)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.InvalidIndex,
|
||||
$"Step at index {index} is not an action step and cannot be edited.");
|
||||
}
|
||||
|
||||
// Capture original state for change tracking
|
||||
var originalInfo = StepInfo.FromStep(stepInfo.Step, index, StepStatus.Pending);
|
||||
|
||||
// Apply the edit
|
||||
edit(runner.Action);
|
||||
|
||||
// Update display name if it changed
|
||||
if (runner.Action.DisplayName != originalInfo.Name)
|
||||
{
|
||||
stepInfo.Name = runner.Action.DisplayName;
|
||||
}
|
||||
|
||||
// Track the change
|
||||
var modifiedInfo = StepInfo.FromStep(stepInfo.Step, index, StepStatus.Pending);
|
||||
modifiedInfo.Change = ChangeType.Modified;
|
||||
_modifiedStepIds.Add(runner.Action.Id);
|
||||
|
||||
_changes.Add(StepChange.Modified(originalInfo, modifiedInfo));
|
||||
|
||||
Trace.Info($"Edited step '{runner.Action.DisplayName}' at position {index}");
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IReadOnlyList<StepChange> GetChanges()
|
||||
{
|
||||
return _changes.AsReadOnly();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void RecordOriginalState()
|
||||
{
|
||||
_originalSteps = GetAllSteps().ToList();
|
||||
Trace.Info($"Recorded original state: {_originalSteps.Count} steps");
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void ClearChanges()
|
||||
{
|
||||
_changes.Clear();
|
||||
_modifiedStepIds.Clear();
|
||||
_addedStepIds.Clear();
|
||||
_originalSteps = null;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void TrimCompletedSteps(int count)
|
||||
{
|
||||
var originalCount = _completedSteps.Count;
|
||||
while (_completedSteps.Count > count)
|
||||
{
|
||||
_completedSteps.RemoveAt(_completedSteps.Count - 1);
|
||||
}
|
||||
Trace.Info($"Trimmed completed steps from {originalCount} to {_completedSteps.Count}");
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool HasStepWithId(string id)
|
||||
{
|
||||
if (string.IsNullOrEmpty(id))
|
||||
return false;
|
||||
|
||||
// Check completed steps
|
||||
foreach (var step in _completedSteps)
|
||||
{
|
||||
if (step is IActionRunner runner && runner.Action?.Name == id)
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check current step
|
||||
if (_currentStep is IActionRunner currentRunner && currentRunner.Action?.Name == id)
|
||||
return true;
|
||||
|
||||
// Check pending steps
|
||||
if (_jobContext?.JobSteps != null)
|
||||
{
|
||||
foreach (var step in _jobContext.JobSteps)
|
||||
{
|
||||
if (step is IActionRunner pendingRunner && pendingRunner.Action?.Name == id)
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
/// <summary>
|
||||
/// Validates that the manipulator has been initialized.
|
||||
/// </summary>
|
||||
private void ValidateInitialized()
|
||||
{
|
||||
if (_jobContext == null)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.NoContext,
|
||||
"StepManipulator has not been initialized. Call Initialize() first.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that the given index refers to a pending step that can be manipulated.
|
||||
/// </summary>
|
||||
/// <param name="index">1-based step index</param>
|
||||
/// <returns>The StepInfo at that index</returns>
|
||||
private StepInfo ValidatePendingIndex(int index)
|
||||
{
|
||||
var allSteps = GetAllSteps();
|
||||
var totalCount = allSteps.Count;
|
||||
var completedCount = _completedSteps.Count;
|
||||
var currentCount = _currentStep != null ? 1 : 0;
|
||||
var firstPendingIndex = completedCount + currentCount + 1;
|
||||
|
||||
if (index < 1 || index > totalCount)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.InvalidIndex,
|
||||
$"Index {index} is out of range. Valid range: 1 to {totalCount}.");
|
||||
}
|
||||
|
||||
if (index <= completedCount)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.InvalidIndex,
|
||||
$"Step {index} has already completed and cannot be modified.");
|
||||
}
|
||||
|
||||
if (index == completedCount + 1 && _currentStep != null)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.InvalidIndex,
|
||||
$"Step {index} is currently executing. Use step-back first to modify it.");
|
||||
}
|
||||
|
||||
return allSteps[index - 1];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the 0-based index within the pending queue for insertion.
|
||||
/// </summary>
|
||||
private int CalculateInsertIndex(StepPosition position)
|
||||
{
|
||||
var pendingCount = GetPendingCount();
|
||||
|
||||
switch (position.Type)
|
||||
{
|
||||
case PositionType.Last:
|
||||
return pendingCount;
|
||||
|
||||
case PositionType.First:
|
||||
return 0;
|
||||
|
||||
case PositionType.At:
|
||||
{
|
||||
// Position.Index is 1-based overall index
|
||||
var firstPendingIndex = GetFirstPendingIndex();
|
||||
var queueIndex = position.Index.Value - firstPendingIndex;
|
||||
|
||||
if (queueIndex < 0)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.InvalidIndex,
|
||||
$"Cannot insert at position {position.Index} - that is before the first pending step.");
|
||||
}
|
||||
if (queueIndex > pendingCount)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.InvalidIndex,
|
||||
$"Cannot insert at position {position.Index} - only {pendingCount} pending steps.");
|
||||
}
|
||||
return queueIndex;
|
||||
}
|
||||
|
||||
case PositionType.After:
|
||||
{
|
||||
var firstPendingIndex = GetFirstPendingIndex();
|
||||
var afterOverallIndex = position.Index.Value;
|
||||
|
||||
// If "after" points to a completed/current step, insert at beginning of pending
|
||||
if (afterOverallIndex < firstPendingIndex)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Calculate queue index (after means +1)
|
||||
var queueIndex = afterOverallIndex - firstPendingIndex + 1;
|
||||
return Math.Min(queueIndex, pendingCount);
|
||||
}
|
||||
|
||||
case PositionType.Before:
|
||||
{
|
||||
var firstPendingIndex = GetFirstPendingIndex();
|
||||
var beforeOverallIndex = position.Index.Value;
|
||||
|
||||
// If "before" points to a completed/current step, that's an error
|
||||
if (beforeOverallIndex < firstPendingIndex)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.InvalidIndex,
|
||||
$"Cannot insert before position {beforeOverallIndex} - it is not a pending step.");
|
||||
}
|
||||
|
||||
// Calculate queue index
|
||||
var queueIndex = beforeOverallIndex - firstPendingIndex;
|
||||
return Math.Max(0, queueIndex);
|
||||
}
|
||||
|
||||
default:
|
||||
return pendingCount; // Default to last
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the target index for a move operation.
|
||||
/// Note: This is called AFTER the item has been removed from the list,
|
||||
/// so we need to adjust indices that were after the removed item.
|
||||
/// </summary>
|
||||
/// <param name="position">The target position specification.</param>
|
||||
/// <param name="listCount">The count of items in the list after removal.</param>
|
||||
/// <param name="fromQueueIndex">The original queue index of the removed item.</param>
|
||||
/// <param name="firstPendingIndex">The first pending index (captured before queue was modified).</param>
|
||||
private int CalculateMoveTargetIndex(StepPosition position, int listCount, int fromQueueIndex, int firstPendingIndex)
|
||||
{
|
||||
switch (position.Type)
|
||||
{
|
||||
case PositionType.Last:
|
||||
return listCount;
|
||||
|
||||
case PositionType.First:
|
||||
return 0;
|
||||
|
||||
case PositionType.At:
|
||||
{
|
||||
var targetQueueIndex = position.Index.Value - firstPendingIndex;
|
||||
// Adjust for the fact that we removed the item first
|
||||
// Items that were after the removed item have shifted down by 1
|
||||
if (targetQueueIndex > fromQueueIndex)
|
||||
targetQueueIndex--;
|
||||
return Math.Max(0, Math.Min(targetQueueIndex, listCount));
|
||||
}
|
||||
|
||||
case PositionType.After:
|
||||
{
|
||||
var afterOverallIndex = position.Index.Value;
|
||||
if (afterOverallIndex < firstPendingIndex)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
// Convert to queue index
|
||||
var afterQueueIndex = afterOverallIndex - firstPendingIndex;
|
||||
// Adjust for removal: items that were after the removed item
|
||||
// have shifted down by 1 in the list
|
||||
if (afterQueueIndex > fromQueueIndex)
|
||||
afterQueueIndex--;
|
||||
// Insert after that position (so +1)
|
||||
var targetQueueIndex = afterQueueIndex + 1;
|
||||
return Math.Max(0, Math.Min(targetQueueIndex, listCount));
|
||||
}
|
||||
|
||||
case PositionType.Before:
|
||||
{
|
||||
var beforeOverallIndex = position.Index.Value;
|
||||
if (beforeOverallIndex < firstPendingIndex)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.InvalidIndex,
|
||||
$"Cannot move before position {beforeOverallIndex} - it is not a pending step.");
|
||||
}
|
||||
var beforeQueueIndex = beforeOverallIndex - firstPendingIndex;
|
||||
// Adjust for removal: items that were after the removed item
|
||||
// have shifted down by 1 in the list
|
||||
if (beforeQueueIndex > fromQueueIndex)
|
||||
beforeQueueIndex--;
|
||||
return Math.Max(0, Math.Min(beforeQueueIndex, listCount));
|
||||
}
|
||||
|
||||
default:
|
||||
return listCount;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies change tracking info to a StepInfo based on recorded changes.
|
||||
/// </summary>
|
||||
private void ApplyChangeInfo(StepInfo info)
|
||||
{
|
||||
if (info.Step is IActionRunner runner && runner.Action != null)
|
||||
{
|
||||
var actionId = runner.Action.Id;
|
||||
|
||||
if (_addedStepIds.Contains(actionId))
|
||||
{
|
||||
info.Change = ChangeType.Added;
|
||||
}
|
||||
else if (_modifiedStepIds.Contains(actionId))
|
||||
{
|
||||
// Check if it was moved or just modified
|
||||
var moveChange = _changes.LastOrDefault(c =>
|
||||
c.Type == ChangeType.Moved &&
|
||||
c.ModifiedStep?.Action?.Id == actionId);
|
||||
|
||||
info.Change = moveChange != null ? ChangeType.Moved : ChangeType.Modified;
|
||||
info.OriginalIndex = moveChange?.OriginalIndex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
557
src/Runner.Worker/Dap/StepCommands/StepSerializer.cs
Normal file
557
src/Runner.Worker/Dap/StepCommands/StepSerializer.cs
Normal file
@@ -0,0 +1,557 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using GitHub.DistributedTask.ObjectTemplating.Tokens;
|
||||
using GitHub.DistributedTask.Pipelines;
|
||||
using GitHub.Runner.Common;
|
||||
|
||||
namespace GitHub.Runner.Worker.Dap.StepCommands
|
||||
{
|
||||
// Note: StepStatus, ChangeType, and StepInfo are now defined in StepInfo.cs
|
||||
|
||||
/// <summary>
|
||||
/// Interface for serializing ActionStep objects to YAML.
|
||||
/// </summary>
|
||||
[ServiceLocator(Default = typeof(StepSerializer))]
|
||||
public interface IStepSerializer : IRunnerService
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts a single ActionStep to YAML string.
|
||||
/// </summary>
|
||||
/// <param name="step">The step to serialize</param>
|
||||
/// <returns>YAML representation of the step</returns>
|
||||
string ToYaml(ActionStep step);
|
||||
|
||||
/// <summary>
|
||||
/// Converts a collection of steps to YAML string.
|
||||
/// </summary>
|
||||
/// <param name="steps">The steps to serialize</param>
|
||||
/// <param name="withComments">Whether to include change comments (# ADDED, # MODIFIED)</param>
|
||||
/// <returns>YAML representation of the steps</returns>
|
||||
string ToYaml(IEnumerable<StepInfo> steps, bool withComments = false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes ActionStep objects to YAML string representation.
|
||||
/// Handles run steps (ScriptReference), uses steps (RepositoryPathReference),
|
||||
/// and docker steps (ContainerRegistryReference).
|
||||
/// </summary>
|
||||
public sealed class StepSerializer : RunnerService, IStepSerializer
|
||||
{
|
||||
// Input keys for script steps (from Inputs MappingToken)
|
||||
private const string ScriptInputKey = "script";
|
||||
private const string ShellInputKey = "shell";
|
||||
private const string WorkingDirectoryInputKey = "workingDirectory";
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string ToYaml(ActionStep step)
|
||||
{
|
||||
if (step == null)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
WriteStep(sb, step, indent: 0, comment: null);
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string ToYaml(IEnumerable<StepInfo> steps, bool withComments = false)
|
||||
{
|
||||
if (steps == null)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("steps:");
|
||||
|
||||
foreach (var stepInfo in steps)
|
||||
{
|
||||
if (stepInfo.Action == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string comment = null;
|
||||
if (withComments && stepInfo.Change.HasValue)
|
||||
{
|
||||
comment = stepInfo.Change.Value switch
|
||||
{
|
||||
ChangeType.Added => "ADDED",
|
||||
ChangeType.Modified => "MODIFIED",
|
||||
ChangeType.Moved => "MOVED",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
WriteStep(sb, stepInfo.Action, indent: 2, comment: comment);
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
return sb.ToString().TrimEnd() + Environment.NewLine;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a single step to the StringBuilder with proper YAML formatting.
|
||||
/// </summary>
|
||||
private void WriteStep(StringBuilder sb, ActionStep step, int indent, string comment)
|
||||
{
|
||||
var indentStr = new string(' ', indent);
|
||||
|
||||
// Determine step type and write accordingly
|
||||
switch (step.Reference)
|
||||
{
|
||||
case ScriptReference:
|
||||
WriteScriptStep(sb, step, indentStr, comment);
|
||||
break;
|
||||
|
||||
case RepositoryPathReference repoRef:
|
||||
WriteUsesStep(sb, step, repoRef, indentStr, comment);
|
||||
break;
|
||||
|
||||
case ContainerRegistryReference containerRef:
|
||||
WriteDockerStep(sb, step, containerRef, indentStr, comment);
|
||||
break;
|
||||
|
||||
default:
|
||||
// Unknown reference type - write minimal info
|
||||
sb.AppendLine($"{indentStr}- name: {EscapeYamlString(step.DisplayName ?? "Unknown step")}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a run step (ScriptReference) to YAML.
|
||||
/// </summary>
|
||||
private void WriteScriptStep(StringBuilder sb, ActionStep step, string indent, string comment)
|
||||
{
|
||||
// Extract script-specific inputs
|
||||
var script = GetInputValue(step.Inputs, ScriptInputKey);
|
||||
var shell = GetInputValue(step.Inputs, ShellInputKey);
|
||||
var workingDirectory = GetInputValue(step.Inputs, WorkingDirectoryInputKey);
|
||||
|
||||
// - name: ... # COMMENT
|
||||
var nameComment = comment != null ? $" # {comment}" : "";
|
||||
if (!string.IsNullOrEmpty(step.DisplayName))
|
||||
{
|
||||
sb.AppendLine($"{indent}- name: {EscapeYamlString(step.DisplayName)}{nameComment}");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append($"{indent}-");
|
||||
if (!string.IsNullOrEmpty(nameComment))
|
||||
{
|
||||
sb.AppendLine($"{nameComment}");
|
||||
sb.Append($"{indent} ");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(" ");
|
||||
}
|
||||
}
|
||||
|
||||
// run: ...
|
||||
if (!string.IsNullOrEmpty(script))
|
||||
{
|
||||
WriteRunScript(sb, script, indent);
|
||||
}
|
||||
|
||||
// shell: ...
|
||||
if (!string.IsNullOrEmpty(shell))
|
||||
{
|
||||
sb.AppendLine($"{indent} shell: {shell}");
|
||||
}
|
||||
|
||||
// working-directory: ...
|
||||
if (!string.IsNullOrEmpty(workingDirectory))
|
||||
{
|
||||
sb.AppendLine($"{indent} working-directory: {EscapeYamlString(workingDirectory)}");
|
||||
}
|
||||
|
||||
// if: ...
|
||||
WriteCondition(sb, step.Condition, indent);
|
||||
|
||||
// env: ...
|
||||
WriteMappingProperty(sb, "env", step.Environment, indent);
|
||||
|
||||
// continue-on-error: ...
|
||||
WriteBoolOrExprProperty(sb, "continue-on-error", step.ContinueOnError, indent);
|
||||
|
||||
// timeout-minutes: ...
|
||||
WriteNumberOrExprProperty(sb, "timeout-minutes", step.TimeoutInMinutes, indent);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a uses step (RepositoryPathReference) to YAML.
|
||||
/// </summary>
|
||||
private void WriteUsesStep(StringBuilder sb, ActionStep step, RepositoryPathReference repoRef, string indent, string comment)
|
||||
{
|
||||
// Build the uses value
|
||||
var usesValue = BuildUsesValue(repoRef);
|
||||
|
||||
// - name: ... # COMMENT
|
||||
var nameComment = comment != null ? $" # {comment}" : "";
|
||||
if (!string.IsNullOrEmpty(step.DisplayName))
|
||||
{
|
||||
sb.AppendLine($"{indent}- name: {EscapeYamlString(step.DisplayName)}{nameComment}");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append($"{indent}-");
|
||||
if (!string.IsNullOrEmpty(nameComment))
|
||||
{
|
||||
sb.AppendLine($"{nameComment}");
|
||||
sb.Append($"{indent} ");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(" ");
|
||||
}
|
||||
}
|
||||
|
||||
// uses: ...
|
||||
sb.AppendLine($"{indent} uses: {usesValue}");
|
||||
|
||||
// if: ...
|
||||
WriteCondition(sb, step.Condition, indent);
|
||||
|
||||
// with: ...
|
||||
WriteMappingProperty(sb, "with", step.Inputs, indent);
|
||||
|
||||
// env: ...
|
||||
WriteMappingProperty(sb, "env", step.Environment, indent);
|
||||
|
||||
// continue-on-error: ...
|
||||
WriteBoolOrExprProperty(sb, "continue-on-error", step.ContinueOnError, indent);
|
||||
|
||||
// timeout-minutes: ...
|
||||
WriteNumberOrExprProperty(sb, "timeout-minutes", step.TimeoutInMinutes, indent);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a docker step (ContainerRegistryReference) to YAML.
|
||||
/// </summary>
|
||||
private void WriteDockerStep(StringBuilder sb, ActionStep step, ContainerRegistryReference containerRef, string indent, string comment)
|
||||
{
|
||||
// - name: ... # COMMENT
|
||||
var nameComment = comment != null ? $" # {comment}" : "";
|
||||
if (!string.IsNullOrEmpty(step.DisplayName))
|
||||
{
|
||||
sb.AppendLine($"{indent}- name: {EscapeYamlString(step.DisplayName)}{nameComment}");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append($"{indent}-");
|
||||
if (!string.IsNullOrEmpty(nameComment))
|
||||
{
|
||||
sb.AppendLine($"{nameComment}");
|
||||
sb.Append($"{indent} ");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(" ");
|
||||
}
|
||||
}
|
||||
|
||||
// uses: docker://...
|
||||
sb.AppendLine($"{indent} uses: docker://{containerRef.Image}");
|
||||
|
||||
// if: ...
|
||||
WriteCondition(sb, step.Condition, indent);
|
||||
|
||||
// with: ...
|
||||
WriteMappingProperty(sb, "with", step.Inputs, indent);
|
||||
|
||||
// env: ...
|
||||
WriteMappingProperty(sb, "env", step.Environment, indent);
|
||||
|
||||
// continue-on-error: ...
|
||||
WriteBoolOrExprProperty(sb, "continue-on-error", step.ContinueOnError, indent);
|
||||
|
||||
// timeout-minutes: ...
|
||||
WriteNumberOrExprProperty(sb, "timeout-minutes", step.TimeoutInMinutes, indent);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the uses value from a RepositoryPathReference.
|
||||
/// </summary>
|
||||
private string BuildUsesValue(RepositoryPathReference repoRef)
|
||||
{
|
||||
// Local action: uses: ./path
|
||||
if (string.Equals(repoRef.RepositoryType, "self", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return repoRef.Path ?? ".";
|
||||
}
|
||||
|
||||
// Remote action: uses: owner/repo@ref or uses: owner/repo/path@ref
|
||||
var name = repoRef.Name ?? "";
|
||||
var refValue = repoRef.Ref ?? "";
|
||||
var path = repoRef.Path;
|
||||
|
||||
if (!string.IsNullOrEmpty(path) && path != "/" && path != ".")
|
||||
{
|
||||
// Normalize path - remove leading slash if present
|
||||
if (path.StartsWith("/"))
|
||||
{
|
||||
path = path.Substring(1);
|
||||
}
|
||||
return $"{name}/{path}@{refValue}";
|
||||
}
|
||||
|
||||
return $"{name}@{refValue}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a multi-line or single-line run script.
|
||||
/// </summary>
|
||||
private void WriteRunScript(StringBuilder sb, string script, string indent)
|
||||
{
|
||||
if (script.Contains("\n"))
|
||||
{
|
||||
// Multi-line script: use literal block scalar
|
||||
sb.AppendLine($"{indent} run: |");
|
||||
foreach (var line in script.Split('\n'))
|
||||
{
|
||||
// Trim trailing \r if present (Windows line endings)
|
||||
var cleanLine = line.TrimEnd('\r');
|
||||
sb.AppendLine($"{indent} {cleanLine}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Single-line script
|
||||
sb.AppendLine($"{indent} run: {EscapeYamlString(script)}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the if condition if present and not default.
|
||||
/// </summary>
|
||||
private void WriteCondition(StringBuilder sb, string condition, string indent)
|
||||
{
|
||||
if (string.IsNullOrEmpty(condition))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't write default condition
|
||||
if (condition == "success()")
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
sb.AppendLine($"{indent} if: {EscapeYamlString(condition)}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a mapping property (env, with) if it has values.
|
||||
/// </summary>
|
||||
private void WriteMappingProperty(StringBuilder sb, string propertyName, TemplateToken token, string indent)
|
||||
{
|
||||
if (token is not MappingToken mapping || mapping.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
sb.AppendLine($"{indent} {propertyName}:");
|
||||
foreach (var pair in mapping)
|
||||
{
|
||||
var key = pair.Key?.ToString() ?? "";
|
||||
var value = TokenToYamlValue(pair.Value);
|
||||
sb.AppendLine($"{indent} {key}: {value}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a boolean or expression property if not default.
|
||||
/// </summary>
|
||||
private void WriteBoolOrExprProperty(StringBuilder sb, string propertyName, TemplateToken token, string indent)
|
||||
{
|
||||
if (token == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
switch (token)
|
||||
{
|
||||
case BooleanToken boolToken:
|
||||
// Only write if true (false is default for continue-on-error)
|
||||
if (boolToken.Value)
|
||||
{
|
||||
sb.AppendLine($"{indent} {propertyName}: true");
|
||||
}
|
||||
break;
|
||||
|
||||
case BasicExpressionToken exprToken:
|
||||
sb.AppendLine($"{indent} {propertyName}: {exprToken.ToString()}");
|
||||
break;
|
||||
|
||||
case StringToken strToken when strToken.Value == "true":
|
||||
sb.AppendLine($"{indent} {propertyName}: true");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a number or expression property if present.
|
||||
/// </summary>
|
||||
private void WriteNumberOrExprProperty(StringBuilder sb, string propertyName, TemplateToken token, string indent)
|
||||
{
|
||||
if (token == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
switch (token)
|
||||
{
|
||||
case NumberToken numToken:
|
||||
sb.AppendLine($"{indent} {propertyName}: {(int)numToken.Value}");
|
||||
break;
|
||||
|
||||
case BasicExpressionToken exprToken:
|
||||
sb.AppendLine($"{indent} {propertyName}: {exprToken.ToString()}");
|
||||
break;
|
||||
|
||||
case StringToken strToken when int.TryParse(strToken.Value, out var intVal):
|
||||
sb.AppendLine($"{indent} {propertyName}: {intVal}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts a string value from a MappingToken by key.
|
||||
/// </summary>
|
||||
private string GetInputValue(TemplateToken inputs, string key)
|
||||
{
|
||||
if (inputs is not MappingToken mapping)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var pair in mapping)
|
||||
{
|
||||
var keyStr = pair.Key?.ToString();
|
||||
if (string.Equals(keyStr, key, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return pair.Value?.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a TemplateToken to a YAML value string.
|
||||
/// </summary>
|
||||
private string TokenToYamlValue(TemplateToken token)
|
||||
{
|
||||
if (token == null)
|
||||
{
|
||||
return "null";
|
||||
}
|
||||
|
||||
switch (token)
|
||||
{
|
||||
case NullToken:
|
||||
return "null";
|
||||
|
||||
case BooleanToken boolToken:
|
||||
return boolToken.Value ? "true" : "false";
|
||||
|
||||
case NumberToken numToken:
|
||||
// Use integer if possible, otherwise double
|
||||
if (numToken.Value == Math.Floor(numToken.Value))
|
||||
{
|
||||
return ((long)numToken.Value).ToString();
|
||||
}
|
||||
return numToken.Value.ToString("G15", System.Globalization.CultureInfo.InvariantCulture);
|
||||
|
||||
case StringToken strToken:
|
||||
return EscapeYamlString(strToken.Value);
|
||||
|
||||
case BasicExpressionToken exprToken:
|
||||
return exprToken.ToString();
|
||||
|
||||
default:
|
||||
// For complex types, just use ToString
|
||||
return EscapeYamlString(token.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Escapes a string for YAML output if necessary.
|
||||
/// </summary>
|
||||
private string EscapeYamlString(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
return "''";
|
||||
}
|
||||
|
||||
// Check if value needs quoting
|
||||
var needsQuoting = false;
|
||||
|
||||
// Quote if starts/ends with whitespace
|
||||
if (char.IsWhiteSpace(value[0]) || char.IsWhiteSpace(value[value.Length - 1]))
|
||||
{
|
||||
needsQuoting = true;
|
||||
}
|
||||
|
||||
// Quote if contains special characters that could be misinterpreted
|
||||
if (!needsQuoting)
|
||||
{
|
||||
foreach (var c in value)
|
||||
{
|
||||
if (c == ':' || c == '#' || c == '\'' || c == '"' ||
|
||||
c == '{' || c == '}' || c == '[' || c == ']' ||
|
||||
c == ',' || c == '&' || c == '*' || c == '!' ||
|
||||
c == '|' || c == '>' || c == '%' || c == '@' ||
|
||||
c == '`' || c == '\n' || c == '\r')
|
||||
{
|
||||
needsQuoting = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Quote if it looks like a boolean, null, or number
|
||||
if (!needsQuoting)
|
||||
{
|
||||
var lower = value.ToLowerInvariant();
|
||||
if (lower == "true" || lower == "false" || lower == "null" ||
|
||||
lower == "yes" || lower == "no" || lower == "on" || lower == "off" ||
|
||||
lower == "~" || lower == "")
|
||||
{
|
||||
needsQuoting = true;
|
||||
}
|
||||
|
||||
// Check if it looks like a number
|
||||
if (double.TryParse(value, out _))
|
||||
{
|
||||
needsQuoting = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!needsQuoting)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
// Use single quotes and escape single quotes by doubling them
|
||||
if (!value.Contains('\n') && !value.Contains('\r'))
|
||||
{
|
||||
return "'" + value.Replace("'", "''") + "'";
|
||||
}
|
||||
|
||||
// For multi-line strings, use double quotes with escapes
|
||||
return "\"" + value
|
||||
.Replace("\\", "\\\\")
|
||||
.Replace("\"", "\\\"")
|
||||
.Replace("\n", "\\n")
|
||||
.Replace("\r", "\\r")
|
||||
.Replace("\t", "\\t") + "\"";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
@@ -95,6 +95,7 @@ namespace GitHub.Runner.Worker
|
||||
// timeline record update methods
|
||||
void Start(string currentOperation = null);
|
||||
TaskResult Complete(TaskResult? result = null, string currentOperation = null, string resultCode = null);
|
||||
void ResetForRerun();
|
||||
void SetEnvContext(string name, string value);
|
||||
void SetRunnerContext(string name, string value);
|
||||
string GetGitHubContext(string name);
|
||||
@@ -545,6 +546,29 @@ namespace GitHub.Runner.Worker
|
||||
return Result.Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets the execution context for re-running (e.g., after step-back in DAP debugging).
|
||||
/// Creates a new CancellationTokenSource since the previous one was disposed in Complete().
|
||||
/// </summary>
|
||||
public void ResetForRerun()
|
||||
{
|
||||
// Create a new CancellationTokenSource since the old one was disposed
|
||||
_cancellationTokenSource = new CancellationTokenSource();
|
||||
|
||||
// Reset record state to allow re-execution
|
||||
_record.State = TimelineRecordState.Pending;
|
||||
_record.FinishTime = null;
|
||||
_record.PercentComplete = 0;
|
||||
_record.ResultCode = null;
|
||||
|
||||
// Reset result
|
||||
Result = null;
|
||||
Outcome = null;
|
||||
|
||||
// Reset the force completed task
|
||||
_forceCompleted = new TaskCompletionSource<int>();
|
||||
}
|
||||
|
||||
public void UpdateGlobalStepsContext()
|
||||
{
|
||||
// Skip if generated context name. Generated context names start with "__". After 3.2 the server will never send an empty context name.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
@@ -13,6 +13,7 @@ using GitHub.DistributedTask.WebApi;
|
||||
using GitHub.Runner.Common;
|
||||
using GitHub.Runner.Common.Util;
|
||||
using GitHub.Runner.Sdk;
|
||||
using GitHub.Runner.Worker.Dap;
|
||||
using GitHub.Services.Common;
|
||||
using GitHub.Services.WebApi;
|
||||
using Sdk.RSWebApi.Contracts;
|
||||
@@ -112,6 +113,8 @@ namespace GitHub.Runner.Worker
|
||||
|
||||
IExecutionContext jobContext = null;
|
||||
CancellationTokenRegistration? runnerShutdownRegistration = null;
|
||||
IDapServer dapServer = null;
|
||||
CancellationTokenRegistration? dapCancellationRegistration = null;
|
||||
try
|
||||
{
|
||||
// Create the job execution context.
|
||||
@@ -159,6 +162,61 @@ namespace GitHub.Runner.Worker
|
||||
if (jobContext.Global.WriteDebug)
|
||||
{
|
||||
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);
|
||||
@@ -259,6 +317,23 @@ namespace GitHub.Runner.Worker
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using GitHub.DistributedTask.Pipelines.ContextData;
|
||||
using GitHub.DistributedTask.Pipelines.ContextData;
|
||||
using GitHub.DistributedTask.WebApi;
|
||||
using GitHub.Runner.Common.Util;
|
||||
using System;
|
||||
@@ -19,12 +19,31 @@ namespace GitHub.Runner.Worker
|
||||
private static readonly Regex _propertyRegex = new("^[a-zA-Z_][a-zA-Z0-9_]*$", RegexOptions.Compiled);
|
||||
private readonly DictionaryContextData _contextData = new();
|
||||
|
||||
/// <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>
|
||||
/// Clears memory for a composite action's isolated "steps" context, after the action
|
||||
/// is finished executing.
|
||||
/// </summary>
|
||||
public void ClearScope(string scopeName)
|
||||
{
|
||||
DebugLog($"[StepsContext] ClearScope: scope='{scopeName ?? "(root)"}'");
|
||||
if (_contextData.TryGetValue(scopeName, out _))
|
||||
{
|
||||
_contextData[scopeName] = new DictionaryContextData();
|
||||
@@ -78,6 +97,7 @@ namespace GitHub.Runner.Worker
|
||||
{
|
||||
reference = $"steps['{stepName}']['outputs']['{outputName}']";
|
||||
}
|
||||
DebugLog($"[StepsContext] SetOutput: step='{stepName}', output='{outputName}', value='{TruncateValue(value)}'");
|
||||
}
|
||||
|
||||
public void SetConclusion(
|
||||
@@ -86,7 +106,9 @@ namespace GitHub.Runner.Worker
|
||||
ActionResult conclusion)
|
||||
{
|
||||
var step = GetStep(scopeName, stepName);
|
||||
step["conclusion"] = new StringContextData(conclusion.ToString().ToLowerInvariant());
|
||||
var conclusionStr = conclusion.ToString().ToLowerInvariant();
|
||||
step["conclusion"] = new StringContextData(conclusionStr);
|
||||
DebugLog($"[StepsContext] SetConclusion: step='{stepName}', conclusion={conclusionStr}");
|
||||
}
|
||||
|
||||
public void SetOutcome(
|
||||
@@ -95,7 +117,9 @@ namespace GitHub.Runner.Worker
|
||||
ActionResult outcome)
|
||||
{
|
||||
var step = GetStep(scopeName, stepName);
|
||||
step["outcome"] = new StringContextData(outcome.ToString().ToLowerInvariant());
|
||||
var outcomeStr = outcome.ToString().ToLowerInvariant();
|
||||
step["outcome"] = new StringContextData(outcomeStr);
|
||||
DebugLog($"[StepsContext] SetOutcome: step='{stepName}', outcome={outcomeStr}");
|
||||
}
|
||||
|
||||
private DictionaryContextData GetStep(string scopeName, string stepName)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.DistributedTask.Expressions2;
|
||||
@@ -10,6 +11,7 @@ using GitHub.DistributedTask.WebApi;
|
||||
using GitHub.Runner.Common;
|
||||
using GitHub.Runner.Common.Util;
|
||||
using GitHub.Runner.Sdk;
|
||||
using GitHub.Runner.Worker.Dap;
|
||||
using GitHub.Runner.Worker.Expressions;
|
||||
|
||||
namespace GitHub.Runner.Worker
|
||||
@@ -50,6 +52,13 @@ namespace GitHub.Runner.Worker
|
||||
jobContext.JobContext.Status = (jobContext.Result ?? TaskResult.Succeeded).ToActionResult();
|
||||
var scopeInputs = new Dictionary<string, PipelineContextData>(StringComparer.OrdinalIgnoreCase);
|
||||
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)
|
||||
{
|
||||
if (jobContext.JobSteps.Count == 0 && !checkPostJobActions)
|
||||
@@ -65,6 +74,9 @@ namespace GitHub.Runner.Worker
|
||||
|
||||
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}'");
|
||||
ArgUtil.NotNull(step.ExecutionContext, nameof(step.ExecutionContext));
|
||||
ArgUtil.NotNull(step.ExecutionContext.Global, nameof(step.ExecutionContext.Global));
|
||||
@@ -74,6 +86,8 @@ namespace GitHub.Runner.Worker
|
||||
step.ExecutionContext.Start();
|
||||
|
||||
// Expression functions
|
||||
// Clear first to handle step-back scenarios where the same step may be re-processed
|
||||
step.ExecutionContext.ExpressionFunctions.Clear();
|
||||
step.ExecutionContext.ExpressionFunctions.Add(new FunctionInfo<AlwaysFunction>(PipelineTemplateConstants.Always, 0, 0));
|
||||
step.ExecutionContext.ExpressionFunctions.Add(new FunctionInfo<CancelledFunction>(PipelineTemplateConstants.Cancelled, 0, 0));
|
||||
step.ExecutionContext.ExpressionFunctions.Add(new FunctionInfo<FailureFunction>(PipelineTemplateConstants.Failure, 0, 0));
|
||||
@@ -181,6 +195,80 @@ 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
|
||||
// Reset execution context for rerun since CancellationTokenSource was disposed in Complete()
|
||||
checkpoint.CurrentStep.ExecutionContext.ResetForRerun();
|
||||
jobContext.JobSteps.Enqueue(checkpoint.CurrentStep);
|
||||
foreach (var remainingStep in checkpoint.RemainingSteps)
|
||||
{
|
||||
remainingStep.ExecutionContext.ResetForRerun();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if a step was inserted "here" (before current step)
|
||||
if (debugSession.HasStepInsertedHere)
|
||||
{
|
||||
debugSession.ConsumeStepInsertedHere();
|
||||
|
||||
// The queue now contains: [new step, original current step, rest...]
|
||||
// We need to skip this iteration and let the loop pick up the new step
|
||||
|
||||
// Clear pending step info since we're not executing this step now
|
||||
debugSession.ClearPendingStepInfo();
|
||||
|
||||
// Don't increment stepIndex - the new step takes this position
|
||||
|
||||
Trace.Info("Step inserted here - skipping current iteration to process new step");
|
||||
|
||||
// Skip to next iteration - will dequeue and process the new 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
|
||||
step.ExecutionContext.Debug($"Evaluating condition for step: '{step.DisplayName}'");
|
||||
var conditionTraceWriter = new ConditionTraceWriter(Trace, step.ExecutionContext);
|
||||
@@ -238,6 +326,9 @@ namespace GitHub.Runner.Worker
|
||||
jobCancelRegister?.Dispose();
|
||||
jobCancelRegister = null;
|
||||
}
|
||||
|
||||
// Clear pending step info after step completes
|
||||
debugSession?.ClearPendingStepInfo();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,8 +344,20 @@ namespace GitHub.Runner.Worker
|
||||
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}'");
|
||||
}
|
||||
|
||||
// Notify DAP debugger that the job has completed
|
||||
debugSession?.OnJobCompleted();
|
||||
}
|
||||
|
||||
private async Task RunStepAsync(IStep step, CancellationToken jobCancellationToken)
|
||||
|
||||
766
src/Test/L0/Worker/Dap/StepCommands/StepCommandParserL0.cs
Normal file
766
src/Test/L0/Worker/Dap/StepCommands/StepCommandParserL0.cs
Normal file
@@ -0,0 +1,766 @@
|
||||
using System;
|
||||
using GitHub.Runner.Worker.Dap.StepCommands;
|
||||
using Xunit;
|
||||
|
||||
namespace GitHub.Runner.Common.Tests.Worker.Dap.StepCommands
|
||||
{
|
||||
/// <summary>
|
||||
/// Unit tests for StepCommandParser.
|
||||
/// Tests parsing of "steps" commands with the --output flag.
|
||||
/// </summary>
|
||||
public sealed class StepCommandParserL0 : IDisposable
|
||||
{
|
||||
private TestHostContext _hc;
|
||||
private StepCommandParser _parser;
|
||||
|
||||
public StepCommandParserL0()
|
||||
{
|
||||
_hc = new TestHostContext(this);
|
||||
_parser = new StepCommandParser();
|
||||
_parser.Initialize(_hc);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_hc?.Dispose();
|
||||
}
|
||||
|
||||
#region IsStepCommand Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void IsStepCommand_DetectsStepsPrefix()
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
Assert.True(_parser.IsStepCommand("steps list"));
|
||||
Assert.True(_parser.IsStepCommand("steps add run \"test\""));
|
||||
Assert.True(_parser.IsStepCommand("STEPS LIST")); // case insensitive
|
||||
Assert.True(_parser.IsStepCommand(" steps list ")); // whitespace
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void IsStepCommand_RejectsInvalid()
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
Assert.False(_parser.IsStepCommand("step list")); // missing 's'
|
||||
Assert.False(_parser.IsStepCommand("!step list")); // old format
|
||||
Assert.False(_parser.IsStepCommand("stepslist")); // no space
|
||||
Assert.False(_parser.IsStepCommand(""));
|
||||
Assert.False(_parser.IsStepCommand(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void IsStepCommand_AllowsStepsAlone()
|
||||
{
|
||||
// "steps" alone should be detected (even if parsing will fail for lack of subcommand)
|
||||
Assert.True(_parser.IsStepCommand("steps"));
|
||||
Assert.True(_parser.IsStepCommand(" steps "));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Output Format Flag Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_ListCommand_WithOutputJson()
|
||||
{
|
||||
// Arrange & Act
|
||||
var cmd = _parser.Parse("steps list --output json") as ListCommand;
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(cmd);
|
||||
Assert.Equal(OutputFormat.Json, cmd.Output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_ListCommand_WithOutputText()
|
||||
{
|
||||
// Arrange & Act
|
||||
var cmd = _parser.Parse("steps list --output text") as ListCommand;
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(cmd);
|
||||
Assert.Equal(OutputFormat.Text, cmd.Output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_ListCommand_DefaultOutputIsText()
|
||||
{
|
||||
// Arrange & Act
|
||||
var cmd = _parser.Parse("steps list") as ListCommand;
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(cmd);
|
||||
Assert.Equal(OutputFormat.Text, cmd.Output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_AddCommand_WithOutputFlag()
|
||||
{
|
||||
// Arrange & Act
|
||||
var cmd = _parser.Parse("steps add run \"echo test\" --output json") as AddRunCommand;
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(cmd);
|
||||
Assert.Equal(OutputFormat.Json, cmd.Output);
|
||||
Assert.Equal("echo test", cmd.Script);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_OutputFlag_ShortForm()
|
||||
{
|
||||
// Arrange & Act
|
||||
var cmd = _parser.Parse("steps list -o json") as ListCommand;
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(cmd);
|
||||
Assert.Equal(OutputFormat.Json, cmd.Output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_OutputFlag_EqualsForm()
|
||||
{
|
||||
// Arrange & Act
|
||||
var cmd = _parser.Parse("steps list --output=json") as ListCommand;
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(cmd);
|
||||
Assert.Equal(OutputFormat.Json, cmd.Output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_OutputFlag_TextEqualsForm()
|
||||
{
|
||||
// Arrange & Act
|
||||
var cmd = _parser.Parse("steps list --output=text") as ListCommand;
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(cmd);
|
||||
Assert.Equal(OutputFormat.Text, cmd.Output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_EditCommand_WithOutputJson()
|
||||
{
|
||||
// Arrange & Act
|
||||
var cmd = _parser.Parse("steps edit 3 --name \"New Name\" --output json") as EditCommand;
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(cmd);
|
||||
Assert.Equal(OutputFormat.Json, cmd.Output);
|
||||
Assert.Equal(3, cmd.Index);
|
||||
Assert.Equal("New Name", cmd.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_RemoveCommand_WithOutputJson()
|
||||
{
|
||||
// Arrange & Act
|
||||
var cmd = _parser.Parse("steps remove 5 --output json") as RemoveCommand;
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(cmd);
|
||||
Assert.Equal(OutputFormat.Json, cmd.Output);
|
||||
Assert.Equal(5, cmd.Index);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_MoveCommand_WithOutputJson()
|
||||
{
|
||||
// Arrange & Act
|
||||
var cmd = _parser.Parse("steps move 3 --after 5 --output json") as MoveCommand;
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(cmd);
|
||||
Assert.Equal(OutputFormat.Json, cmd.Output);
|
||||
Assert.Equal(3, cmd.FromIndex);
|
||||
Assert.Equal(PositionType.After, cmd.Position.Type);
|
||||
Assert.Equal(5, cmd.Position.Index);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_ExportCommand_WithOutputJson()
|
||||
{
|
||||
// Arrange & Act
|
||||
var cmd = _parser.Parse("steps export --output json") as ExportCommand;
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(cmd);
|
||||
Assert.Equal(OutputFormat.Json, cmd.Output);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region List Command Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_ListCommand_Basic()
|
||||
{
|
||||
// Arrange & Act
|
||||
var cmd = _parser.Parse("steps list");
|
||||
|
||||
// Assert
|
||||
var listCmd = Assert.IsType<ListCommand>(cmd);
|
||||
Assert.False(listCmd.Verbose);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_ListCommand_WithVerbose()
|
||||
{
|
||||
// Arrange & Act
|
||||
var cmd = _parser.Parse("steps list --verbose");
|
||||
|
||||
// Assert
|
||||
var listCmd = Assert.IsType<ListCommand>(cmd);
|
||||
Assert.True(listCmd.Verbose);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_ListCommand_WithVerboseShort()
|
||||
{
|
||||
// Arrange & Act
|
||||
var cmd = _parser.Parse("steps list -v");
|
||||
|
||||
// Assert
|
||||
var listCmd = Assert.IsType<ListCommand>(cmd);
|
||||
Assert.True(listCmd.Verbose);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Add Run Command Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_AddRunCommand_Basic()
|
||||
{
|
||||
// Arrange & Act
|
||||
var cmd = _parser.Parse("steps add run \"npm test\"");
|
||||
|
||||
// Assert
|
||||
var addCmd = Assert.IsType<AddRunCommand>(cmd);
|
||||
Assert.Equal("npm test", addCmd.Script);
|
||||
Assert.Equal(PositionType.Last, addCmd.Position.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_AddRunCommand_AllOptions()
|
||||
{
|
||||
// Arrange & Act
|
||||
var cmd = _parser.Parse("steps add run \"npm run build\" --name \"Build App\" --shell bash --after 3");
|
||||
|
||||
// Assert
|
||||
var addCmd = Assert.IsType<AddRunCommand>(cmd);
|
||||
Assert.Equal("npm run build", addCmd.Script);
|
||||
Assert.Equal("Build App", addCmd.Name);
|
||||
Assert.Equal("bash", addCmd.Shell);
|
||||
Assert.Equal(PositionType.After, addCmd.Position.Type);
|
||||
Assert.Equal(3, addCmd.Position.Index);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_AddRunCommand_WithEnv()
|
||||
{
|
||||
// Arrange & Act
|
||||
var cmd = _parser.Parse("steps add run \"npm test\" --env NODE_ENV=test --env CI=true");
|
||||
|
||||
// Assert
|
||||
var addCmd = Assert.IsType<AddRunCommand>(cmd);
|
||||
Assert.NotNull(addCmd.Env);
|
||||
Assert.Equal("test", addCmd.Env["NODE_ENV"]);
|
||||
Assert.Equal("true", addCmd.Env["CI"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_AddRunCommand_WithContinueOnError()
|
||||
{
|
||||
// Arrange & Act
|
||||
var cmd = _parser.Parse("steps add run \"npm test\" --continue-on-error");
|
||||
|
||||
// Assert
|
||||
var addCmd = Assert.IsType<AddRunCommand>(cmd);
|
||||
Assert.True(addCmd.ContinueOnError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_AddRunCommand_WithTimeout()
|
||||
{
|
||||
// Arrange & Act
|
||||
var cmd = _parser.Parse("steps add run \"npm test\" --timeout 30");
|
||||
|
||||
// Assert
|
||||
var addCmd = Assert.IsType<AddRunCommand>(cmd);
|
||||
Assert.Equal(30, addCmd.Timeout);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_AddRunCommand_PositionFirst()
|
||||
{
|
||||
// Arrange & Act
|
||||
var cmd = _parser.Parse("steps add run \"echo first\" --first");
|
||||
|
||||
// Assert
|
||||
var addCmd = Assert.IsType<AddRunCommand>(cmd);
|
||||
Assert.Equal(PositionType.First, addCmd.Position.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_AddRunCommand_PositionAt()
|
||||
{
|
||||
// Arrange & Act
|
||||
var cmd = _parser.Parse("steps add run \"echo at\" --at 5");
|
||||
|
||||
// Assert
|
||||
var addCmd = Assert.IsType<AddRunCommand>(cmd);
|
||||
Assert.Equal(PositionType.At, addCmd.Position.Type);
|
||||
Assert.Equal(5, addCmd.Position.Index);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_AddRunCommand_PositionBefore()
|
||||
{
|
||||
// Arrange & Act
|
||||
var cmd = _parser.Parse("steps add run \"echo before\" --before 3");
|
||||
|
||||
// Assert
|
||||
var addCmd = Assert.IsType<AddRunCommand>(cmd);
|
||||
Assert.Equal(PositionType.Before, addCmd.Position.Type);
|
||||
Assert.Equal(3, addCmd.Position.Index);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_AddRunCommand_PositionHere()
|
||||
{
|
||||
// Arrange & Act
|
||||
var cmd = _parser.Parse("steps add run \"echo here\" --here");
|
||||
|
||||
// Assert
|
||||
var addCmd = Assert.IsType<AddRunCommand>(cmd);
|
||||
Assert.Equal(PositionType.Here, addCmd.Position.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_AddRunCommand_MissingScript_Throws()
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
var ex = Assert.Throws<StepCommandException>(() => _parser.Parse("steps add run"));
|
||||
Assert.Equal(StepCommandErrors.ParseError, ex.ErrorCode);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Add Uses Command Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_AddUsesCommand_Basic()
|
||||
{
|
||||
// Arrange & Act
|
||||
var cmd = _parser.Parse("steps add uses actions/checkout@v4");
|
||||
|
||||
// Assert
|
||||
var addCmd = Assert.IsType<AddUsesCommand>(cmd);
|
||||
Assert.Equal("actions/checkout@v4", addCmd.Action);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_AddUsesCommand_AllOptions()
|
||||
{
|
||||
// Arrange & Act
|
||||
var cmd = _parser.Parse("steps add uses actions/setup-node@v4 --name \"Setup Node\" --with node-version=20 --with cache=npm");
|
||||
|
||||
// Assert
|
||||
var addCmd = Assert.IsType<AddUsesCommand>(cmd);
|
||||
Assert.Equal("actions/setup-node@v4", addCmd.Action);
|
||||
Assert.Equal("Setup Node", addCmd.Name);
|
||||
Assert.NotNull(addCmd.With);
|
||||
Assert.Equal("20", addCmd.With["node-version"]);
|
||||
Assert.Equal("npm", addCmd.With["cache"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_AddUsesCommand_PositionHere()
|
||||
{
|
||||
// Arrange & Act
|
||||
var cmd = _parser.Parse("steps add uses actions/checkout@v4 --here");
|
||||
|
||||
// Assert
|
||||
var addCmd = Assert.IsType<AddUsesCommand>(cmd);
|
||||
Assert.Equal("actions/checkout@v4", addCmd.Action);
|
||||
Assert.Equal(PositionType.Here, addCmd.Position.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_AddUsesCommand_MissingAction_Throws()
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
var ex = Assert.Throws<StepCommandException>(() => _parser.Parse("steps add uses"));
|
||||
Assert.Equal(StepCommandErrors.ParseError, ex.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_AddCommand_InvalidType_Throws()
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
var ex = Assert.Throws<StepCommandException>(() => _parser.Parse("steps add invalid \"test\""));
|
||||
Assert.Equal(StepCommandErrors.InvalidType, ex.ErrorCode);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Edit Command Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_EditCommand_Basic()
|
||||
{
|
||||
// Arrange & Act
|
||||
var cmd = _parser.Parse("steps edit 3 --name \"New Name\"");
|
||||
|
||||
// Assert
|
||||
var editCmd = Assert.IsType<EditCommand>(cmd);
|
||||
Assert.Equal(3, editCmd.Index);
|
||||
Assert.Equal("New Name", editCmd.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_EditCommand_AllOptions()
|
||||
{
|
||||
// Arrange & Act
|
||||
var cmd = _parser.Parse("steps edit 4 --name \"Updated\" --script \"npm test\" --shell pwsh --if \"failure()\"");
|
||||
|
||||
// Assert
|
||||
var editCmd = Assert.IsType<EditCommand>(cmd);
|
||||
Assert.Equal(4, editCmd.Index);
|
||||
Assert.Equal("Updated", editCmd.Name);
|
||||
Assert.Equal("npm test", editCmd.Script);
|
||||
Assert.Equal("pwsh", editCmd.Shell);
|
||||
Assert.Equal("failure()", editCmd.Condition);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_EditCommand_MissingIndex_Throws()
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
var ex = Assert.Throws<StepCommandException>(() => _parser.Parse("steps edit --name \"Test\""));
|
||||
Assert.Equal(StepCommandErrors.ParseError, ex.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_EditCommand_InvalidIndex_Throws()
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
var ex = Assert.Throws<StepCommandException>(() => _parser.Parse("steps edit abc --name \"Test\""));
|
||||
Assert.Equal(StepCommandErrors.ParseError, ex.ErrorCode);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Remove Command Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_RemoveCommand()
|
||||
{
|
||||
// Arrange & Act
|
||||
var cmd = _parser.Parse("steps remove 5");
|
||||
|
||||
// Assert
|
||||
var removeCmd = Assert.IsType<RemoveCommand>(cmd);
|
||||
Assert.Equal(5, removeCmd.Index);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_RemoveCommand_MissingIndex_Throws()
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
var ex = Assert.Throws<StepCommandException>(() => _parser.Parse("steps remove"));
|
||||
Assert.Equal(StepCommandErrors.ParseError, ex.ErrorCode);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Move Command Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_MoveCommand_After()
|
||||
{
|
||||
// Arrange & Act
|
||||
var cmd = _parser.Parse("steps move 5 --after 2");
|
||||
|
||||
// Assert
|
||||
var moveCmd = Assert.IsType<MoveCommand>(cmd);
|
||||
Assert.Equal(5, moveCmd.FromIndex);
|
||||
Assert.Equal(PositionType.After, moveCmd.Position.Type);
|
||||
Assert.Equal(2, moveCmd.Position.Index);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_MoveCommand_Before()
|
||||
{
|
||||
// Arrange & Act
|
||||
var cmd = _parser.Parse("steps move 3 --before 5");
|
||||
|
||||
// Assert
|
||||
var moveCmd = Assert.IsType<MoveCommand>(cmd);
|
||||
Assert.Equal(3, moveCmd.FromIndex);
|
||||
Assert.Equal(PositionType.Before, moveCmd.Position.Type);
|
||||
Assert.Equal(5, moveCmd.Position.Index);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_MoveCommand_First()
|
||||
{
|
||||
// Arrange & Act
|
||||
var cmd = _parser.Parse("steps move 5 --first");
|
||||
|
||||
// Assert
|
||||
var moveCmd = Assert.IsType<MoveCommand>(cmd);
|
||||
Assert.Equal(PositionType.First, moveCmd.Position.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_MoveCommand_Last()
|
||||
{
|
||||
// Arrange & Act
|
||||
var cmd = _parser.Parse("steps move 2 --last");
|
||||
|
||||
// Assert
|
||||
var moveCmd = Assert.IsType<MoveCommand>(cmd);
|
||||
Assert.Equal(PositionType.Last, moveCmd.Position.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_MoveCommand_To()
|
||||
{
|
||||
// Arrange & Act
|
||||
var cmd = _parser.Parse("steps move 5 --to 3");
|
||||
|
||||
// Assert
|
||||
var moveCmd = Assert.IsType<MoveCommand>(cmd);
|
||||
Assert.Equal(PositionType.At, moveCmd.Position.Type);
|
||||
Assert.Equal(3, moveCmd.Position.Index);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_MoveCommand_Here()
|
||||
{
|
||||
// Arrange & Act
|
||||
var cmd = _parser.Parse("steps move 5 --here");
|
||||
|
||||
// Assert
|
||||
var moveCmd = Assert.IsType<MoveCommand>(cmd);
|
||||
Assert.Equal(PositionType.Here, moveCmd.Position.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_MoveCommand_MissingFrom_Throws()
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
var ex = Assert.Throws<StepCommandException>(() => _parser.Parse("steps move --after 2"));
|
||||
Assert.Equal(StepCommandErrors.ParseError, ex.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_MoveCommand_MissingPosition_Throws()
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
var ex = Assert.Throws<StepCommandException>(() => _parser.Parse("steps move 5"));
|
||||
Assert.Equal(StepCommandErrors.ParseError, ex.ErrorCode);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Export Command Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_ExportCommand_Default()
|
||||
{
|
||||
// Arrange & Act
|
||||
var cmd = _parser.Parse("steps export");
|
||||
|
||||
// Assert
|
||||
var exportCmd = Assert.IsType<ExportCommand>(cmd);
|
||||
Assert.False(exportCmd.ChangesOnly);
|
||||
Assert.False(exportCmd.WithComments);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_ExportCommand_WithOptions()
|
||||
{
|
||||
// Arrange & Act
|
||||
var cmd = _parser.Parse("steps export --changes-only --with-comments");
|
||||
|
||||
// Assert
|
||||
var exportCmd = Assert.IsType<ExportCommand>(cmd);
|
||||
Assert.True(exportCmd.ChangesOnly);
|
||||
Assert.True(exportCmd.WithComments);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Error Handling Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_InvalidFormat_Throws()
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
var ex = Assert.Throws<StepCommandException>(() => _parser.Parse("step list"));
|
||||
Assert.Equal(StepCommandErrors.ParseError, ex.ErrorCode);
|
||||
Assert.Contains("steps", ex.Message.ToLower());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_UnknownCommand_Throws()
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
var ex = Assert.Throws<StepCommandException>(() => _parser.Parse("steps unknown"));
|
||||
Assert.Equal(StepCommandErrors.InvalidCommand, ex.ErrorCode);
|
||||
Assert.Contains("unknown", ex.Message.ToLower());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_Empty_Throws()
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
var ex = Assert.Throws<StepCommandException>(() => _parser.Parse(""));
|
||||
Assert.Equal(StepCommandErrors.ParseError, ex.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_StepsAlone_Throws()
|
||||
{
|
||||
// "steps" without a subcommand should throw
|
||||
var ex = Assert.Throws<StepCommandException>(() => _parser.Parse("steps"));
|
||||
Assert.Equal(StepCommandErrors.ParseError, ex.ErrorCode);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Case Insensitivity Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_CaseInsensitive_StepsKeyword()
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
Assert.IsType<ListCommand>(_parser.Parse("STEPS list"));
|
||||
Assert.IsType<ListCommand>(_parser.Parse("Steps list"));
|
||||
Assert.IsType<ListCommand>(_parser.Parse("sTePs list"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_CaseInsensitive_Subcommand()
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
Assert.IsType<ListCommand>(_parser.Parse("steps LIST"));
|
||||
Assert.IsType<ListCommand>(_parser.Parse("steps List"));
|
||||
Assert.IsType<AddRunCommand>(_parser.Parse("steps ADD run \"test\""));
|
||||
Assert.IsType<EditCommand>(_parser.Parse("steps EDIT 1"));
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
725
src/Test/L0/Worker/Dap/StepCommands/StepManipulatorL0.cs
Normal file
725
src/Test/L0/Worker/Dap/StepCommands/StepManipulatorL0.cs
Normal file
@@ -0,0 +1,725 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using GitHub.DistributedTask.ObjectTemplating.Tokens;
|
||||
using GitHub.DistributedTask.Pipelines;
|
||||
using GitHub.Runner.Worker;
|
||||
using GitHub.Runner.Worker.Dap.StepCommands;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace GitHub.Runner.Common.Tests.Worker.Dap.StepCommands
|
||||
{
|
||||
public sealed class StepManipulatorL0 : IDisposable
|
||||
{
|
||||
private TestHostContext _hc;
|
||||
private Mock<IExecutionContext> _ec;
|
||||
private StepManipulator _manipulator;
|
||||
private Queue<IStep> _jobSteps;
|
||||
|
||||
public StepManipulatorL0()
|
||||
{
|
||||
_hc = new TestHostContext(this);
|
||||
_manipulator = new StepManipulator();
|
||||
_manipulator.Initialize(_hc);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_hc?.Dispose();
|
||||
}
|
||||
|
||||
private void SetupJobContext(int pendingStepCount = 3)
|
||||
{
|
||||
_jobSteps = new Queue<IStep>();
|
||||
for (int i = 0; i < pendingStepCount; i++)
|
||||
{
|
||||
var step = CreateMockStep($"Step {i + 1}");
|
||||
_jobSteps.Enqueue(step);
|
||||
}
|
||||
|
||||
_ec = new Mock<IExecutionContext>();
|
||||
_ec.Setup(x => x.JobSteps).Returns(_jobSteps);
|
||||
|
||||
_manipulator.Initialize(_ec.Object, 0);
|
||||
}
|
||||
|
||||
private IStep CreateMockStep(string displayName, bool isActionRunner = true)
|
||||
{
|
||||
if (isActionRunner)
|
||||
{
|
||||
var actionRunner = new Mock<IActionRunner>();
|
||||
var actionStep = new ActionStep
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = $"_step_{Guid.NewGuid():N}",
|
||||
DisplayName = displayName,
|
||||
Reference = new ScriptReference(),
|
||||
Inputs = CreateScriptInputs("echo hello"),
|
||||
Condition = "success()"
|
||||
};
|
||||
actionRunner.Setup(x => x.DisplayName).Returns(displayName);
|
||||
actionRunner.Setup(x => x.Action).Returns(actionStep);
|
||||
actionRunner.Setup(x => x.Condition).Returns("success()");
|
||||
return actionRunner.Object;
|
||||
}
|
||||
else
|
||||
{
|
||||
var step = new Mock<IStep>();
|
||||
step.Setup(x => x.DisplayName).Returns(displayName);
|
||||
step.Setup(x => x.Condition).Returns("success()");
|
||||
return step.Object;
|
||||
}
|
||||
}
|
||||
|
||||
private MappingToken CreateScriptInputs(string script)
|
||||
{
|
||||
var inputs = new MappingToken(null, null, null);
|
||||
inputs.Add(
|
||||
new StringToken(null, null, null, "script"),
|
||||
new StringToken(null, null, null, script));
|
||||
return inputs;
|
||||
}
|
||||
|
||||
#region Initialization Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Initialize_SetsJobContext()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext();
|
||||
|
||||
// Act & Assert - no exception means success
|
||||
var steps = _manipulator.GetAllSteps();
|
||||
Assert.Equal(3, steps.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Initialize_ThrowsOnNullContext()
|
||||
{
|
||||
// Arrange
|
||||
var manipulator = new StepManipulator();
|
||||
manipulator.Initialize(_hc);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => manipulator.Initialize(null, 0));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetAllSteps Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void GetAllSteps_ReturnsPendingSteps()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(3);
|
||||
|
||||
// Act
|
||||
var steps = _manipulator.GetAllSteps();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, steps.Count);
|
||||
Assert.All(steps, s => Assert.Equal(StepStatus.Pending, s.Status));
|
||||
Assert.Equal(1, steps[0].Index);
|
||||
Assert.Equal(2, steps[1].Index);
|
||||
Assert.Equal(3, steps[2].Index);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void GetAllSteps_IncludesCompletedSteps()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(2);
|
||||
var completedStep = CreateMockStep("Completed Step");
|
||||
_manipulator.AddCompletedStep(completedStep);
|
||||
|
||||
// Act
|
||||
var steps = _manipulator.GetAllSteps();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, steps.Count);
|
||||
Assert.Equal(StepStatus.Completed, steps[0].Status);
|
||||
Assert.Equal("Completed Step", steps[0].Name);
|
||||
Assert.Equal(StepStatus.Pending, steps[1].Status);
|
||||
Assert.Equal(StepStatus.Pending, steps[2].Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void GetAllSteps_IncludesCurrentStep()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(2);
|
||||
var currentStep = CreateMockStep("Current Step");
|
||||
_manipulator.SetCurrentStep(currentStep);
|
||||
|
||||
// Act
|
||||
var steps = _manipulator.GetAllSteps();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, steps.Count);
|
||||
Assert.Equal(StepStatus.Current, steps[0].Status);
|
||||
Assert.Equal("Current Step", steps[0].Name);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetStep Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void GetStep_ReturnsCorrectStep()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(3);
|
||||
|
||||
// Act
|
||||
var step = _manipulator.GetStep(2);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(step);
|
||||
Assert.Equal(2, step.Index);
|
||||
Assert.Equal("Step 2", step.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void GetStep_ReturnsNullForInvalidIndex()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(3);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Null(_manipulator.GetStep(0));
|
||||
Assert.Null(_manipulator.GetStep(4));
|
||||
Assert.Null(_manipulator.GetStep(-1));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetPendingCount Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void GetPendingCount_ReturnsCorrectCount()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(5);
|
||||
|
||||
// Act
|
||||
var count = _manipulator.GetPendingCount();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(5, count);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetFirstPendingIndex Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void GetFirstPendingIndex_WithNoPriorSteps_ReturnsOne()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(3);
|
||||
|
||||
// Act
|
||||
var index = _manipulator.GetFirstPendingIndex();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(1, index);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void GetFirstPendingIndex_WithCompletedSteps_ReturnsCorrectIndex()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(2);
|
||||
_manipulator.AddCompletedStep(CreateMockStep("Completed 1"));
|
||||
_manipulator.AddCompletedStep(CreateMockStep("Completed 2"));
|
||||
|
||||
// Act
|
||||
var index = _manipulator.GetFirstPendingIndex();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, index);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void GetFirstPendingIndex_WithNoSteps_ReturnsNegativeOne()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(0);
|
||||
|
||||
// Act
|
||||
var index = _manipulator.GetFirstPendingIndex();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(-1, index);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region InsertStep Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void InsertStep_AtLast_AppendsToQueue()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(2);
|
||||
var newStep = CreateMockStep("New Step");
|
||||
|
||||
// Act
|
||||
var index = _manipulator.InsertStep(newStep, StepPosition.Last());
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, index);
|
||||
Assert.Equal(3, _jobSteps.Count);
|
||||
var steps = _manipulator.GetAllSteps();
|
||||
Assert.Equal("New Step", steps[2].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void InsertStep_AtFirst_PrependsToQueue()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(2);
|
||||
var newStep = CreateMockStep("New Step");
|
||||
|
||||
// Act
|
||||
var index = _manipulator.InsertStep(newStep, StepPosition.First());
|
||||
|
||||
// Assert
|
||||
Assert.Equal(1, index);
|
||||
var steps = _manipulator.GetAllSteps();
|
||||
Assert.Equal("New Step", steps[0].Name);
|
||||
Assert.Equal("Step 1", steps[1].Name);
|
||||
Assert.Equal("Step 2", steps[2].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void InsertStep_AtPosition_InsertsCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(3);
|
||||
var newStep = CreateMockStep("New Step");
|
||||
|
||||
// Act
|
||||
var index = _manipulator.InsertStep(newStep, StepPosition.At(2));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, index);
|
||||
var steps = _manipulator.GetAllSteps();
|
||||
Assert.Equal("Step 1", steps[0].Name);
|
||||
Assert.Equal("New Step", steps[1].Name);
|
||||
Assert.Equal("Step 2", steps[2].Name);
|
||||
Assert.Equal("Step 3", steps[3].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void InsertStep_AfterPosition_InsertsCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(3);
|
||||
var newStep = CreateMockStep("New Step");
|
||||
|
||||
// Act
|
||||
var index = _manipulator.InsertStep(newStep, StepPosition.After(1));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, index);
|
||||
var steps = _manipulator.GetAllSteps();
|
||||
Assert.Equal("Step 1", steps[0].Name);
|
||||
Assert.Equal("New Step", steps[1].Name);
|
||||
Assert.Equal("Step 2", steps[2].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void InsertStep_BeforePosition_InsertsCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(3);
|
||||
var newStep = CreateMockStep("New Step");
|
||||
|
||||
// Act
|
||||
var index = _manipulator.InsertStep(newStep, StepPosition.Before(3));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, index);
|
||||
var steps = _manipulator.GetAllSteps();
|
||||
Assert.Equal("Step 1", steps[0].Name);
|
||||
Assert.Equal("Step 2", steps[1].Name);
|
||||
Assert.Equal("New Step", steps[2].Name);
|
||||
Assert.Equal("Step 3", steps[3].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void InsertStep_TracksChange()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(2);
|
||||
var newStep = CreateMockStep("New Step");
|
||||
|
||||
// Act
|
||||
_manipulator.InsertStep(newStep, StepPosition.Last());
|
||||
|
||||
// Assert
|
||||
var changes = _manipulator.GetChanges();
|
||||
Assert.Single(changes);
|
||||
Assert.Equal(ChangeType.Added, changes[0].Type);
|
||||
Assert.Equal(3, changes[0].CurrentIndex);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region RemoveStep Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void RemoveStep_RemovesFromQueue()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(3);
|
||||
|
||||
// Act
|
||||
_manipulator.RemoveStep(2);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, _jobSteps.Count);
|
||||
var steps = _manipulator.GetAllSteps();
|
||||
Assert.Equal("Step 1", steps[0].Name);
|
||||
Assert.Equal("Step 3", steps[1].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void RemoveStep_TracksChange()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(3);
|
||||
|
||||
// Act
|
||||
_manipulator.RemoveStep(2);
|
||||
|
||||
// Assert
|
||||
var changes = _manipulator.GetChanges();
|
||||
Assert.Single(changes);
|
||||
Assert.Equal(ChangeType.Removed, changes[0].Type);
|
||||
Assert.Equal(2, changes[0].OriginalIndex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void RemoveStep_ThrowsForCompletedStep()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(2);
|
||||
_manipulator.AddCompletedStep(CreateMockStep("Completed Step"));
|
||||
|
||||
// Act & Assert
|
||||
var ex = Assert.Throws<StepCommandException>(() => _manipulator.RemoveStep(1));
|
||||
Assert.Equal(StepCommandErrors.InvalidIndex, ex.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void RemoveStep_ThrowsForCurrentStep()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(2);
|
||||
_manipulator.SetCurrentStep(CreateMockStep("Current Step"));
|
||||
|
||||
// Act & Assert
|
||||
var ex = Assert.Throws<StepCommandException>(() => _manipulator.RemoveStep(1));
|
||||
Assert.Equal(StepCommandErrors.InvalidIndex, ex.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void RemoveStep_ThrowsForInvalidIndex()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(3);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<StepCommandException>(() => _manipulator.RemoveStep(0));
|
||||
Assert.Throws<StepCommandException>(() => _manipulator.RemoveStep(4));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region MoveStep Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void MoveStep_ToLast_MovesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(3);
|
||||
|
||||
// Act
|
||||
var newIndex = _manipulator.MoveStep(1, StepPosition.Last());
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, newIndex);
|
||||
var steps = _manipulator.GetAllSteps();
|
||||
Assert.Equal("Step 2", steps[0].Name);
|
||||
Assert.Equal("Step 3", steps[1].Name);
|
||||
Assert.Equal("Step 1", steps[2].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void MoveStep_ToFirst_MovesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(3);
|
||||
|
||||
// Act
|
||||
var newIndex = _manipulator.MoveStep(3, StepPosition.First());
|
||||
|
||||
// Assert
|
||||
Assert.Equal(1, newIndex);
|
||||
var steps = _manipulator.GetAllSteps();
|
||||
Assert.Equal("Step 3", steps[0].Name);
|
||||
Assert.Equal("Step 1", steps[1].Name);
|
||||
Assert.Equal("Step 2", steps[2].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void MoveStep_ToMiddle_MovesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(4);
|
||||
|
||||
// Act - move step 1 to after step 2 (which becomes position 2)
|
||||
var newIndex = _manipulator.MoveStep(1, StepPosition.After(2));
|
||||
|
||||
// Assert
|
||||
var steps = _manipulator.GetAllSteps();
|
||||
Assert.Equal("Step 2", steps[0].Name);
|
||||
Assert.Equal("Step 1", steps[1].Name);
|
||||
Assert.Equal("Step 3", steps[2].Name);
|
||||
Assert.Equal("Step 4", steps[3].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void MoveStep_TracksChange()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(3);
|
||||
|
||||
// Act
|
||||
_manipulator.MoveStep(1, StepPosition.Last());
|
||||
|
||||
// Assert
|
||||
var changes = _manipulator.GetChanges();
|
||||
Assert.Single(changes);
|
||||
Assert.Equal(ChangeType.Moved, changes[0].Type);
|
||||
Assert.Equal(1, changes[0].OriginalIndex);
|
||||
Assert.Equal(3, changes[0].CurrentIndex);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region EditStep Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EditStep_ModifiesActionStep()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(3);
|
||||
|
||||
// Act
|
||||
_manipulator.EditStep(2, step =>
|
||||
{
|
||||
step.DisplayName = "Modified Step";
|
||||
});
|
||||
|
||||
// Assert
|
||||
var steps = _manipulator.GetAllSteps();
|
||||
var actionRunner = steps[1].Step as IActionRunner;
|
||||
Assert.NotNull(actionRunner);
|
||||
Assert.Equal("Modified Step", actionRunner.Action.DisplayName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EditStep_TracksChange()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(3);
|
||||
|
||||
// Act
|
||||
_manipulator.EditStep(2, step =>
|
||||
{
|
||||
step.DisplayName = "Modified Step";
|
||||
});
|
||||
|
||||
// Assert
|
||||
var changes = _manipulator.GetChanges();
|
||||
Assert.Single(changes);
|
||||
Assert.Equal(ChangeType.Modified, changes[0].Type);
|
||||
Assert.Equal(2, changes[0].CurrentIndex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EditStep_ThrowsForCompletedStep()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(2);
|
||||
_manipulator.AddCompletedStep(CreateMockStep("Completed Step"));
|
||||
|
||||
// Act & Assert
|
||||
var ex = Assert.Throws<StepCommandException>(() =>
|
||||
_manipulator.EditStep(1, step => { }));
|
||||
Assert.Equal(StepCommandErrors.InvalidIndex, ex.ErrorCode);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Change Tracking Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void RecordOriginalState_CapturesSteps()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(3);
|
||||
|
||||
// Act
|
||||
_manipulator.RecordOriginalState();
|
||||
_manipulator.InsertStep(CreateMockStep("New Step"), StepPosition.Last());
|
||||
|
||||
// Assert - changes should be tracked
|
||||
var changes = _manipulator.GetChanges();
|
||||
Assert.Single(changes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ClearChanges_RemovesAllChanges()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(3);
|
||||
_manipulator.InsertStep(CreateMockStep("New Step"), StepPosition.Last());
|
||||
|
||||
// Act
|
||||
_manipulator.ClearChanges();
|
||||
|
||||
// Assert
|
||||
Assert.Empty(_manipulator.GetChanges());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void MultipleOperations_TrackAllChanges()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(3);
|
||||
|
||||
// Act
|
||||
_manipulator.InsertStep(CreateMockStep("New Step"), StepPosition.Last());
|
||||
_manipulator.RemoveStep(1);
|
||||
_manipulator.MoveStep(2, StepPosition.First());
|
||||
|
||||
// Assert
|
||||
var changes = _manipulator.GetChanges();
|
||||
Assert.Equal(3, changes.Count);
|
||||
Assert.Equal(ChangeType.Added, changes[0].Type);
|
||||
Assert.Equal(ChangeType.Removed, changes[1].Type);
|
||||
Assert.Equal(ChangeType.Moved, changes[2].Type);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region StepInfo Factory Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void StepInfo_FromStep_ExtractsRunStepInfo()
|
||||
{
|
||||
// Arrange
|
||||
var step = CreateMockStep("Test Run Step");
|
||||
|
||||
// Act
|
||||
var info = StepInfo.FromStep(step, 1, StepStatus.Pending);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Test Run Step", info.Name);
|
||||
Assert.Equal("run", info.Type);
|
||||
Assert.Equal(StepStatus.Pending, info.Status);
|
||||
Assert.NotNull(info.Action);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void StepInfo_FromStep_HandlesNonActionRunner()
|
||||
{
|
||||
// Arrange
|
||||
var step = CreateMockStep("Extension Step", isActionRunner: false);
|
||||
|
||||
// Act
|
||||
var info = StepInfo.FromStep(step, 1, StepStatus.Pending);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Extension Step", info.Name);
|
||||
Assert.Equal("extension", info.Type);
|
||||
Assert.Null(info.Action);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user