diff --git a/package-lock.json b/package-lock.json index fb120997..3ab5a9ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@actions/languageserver": "^0.3.46", "@actions/workflow-parser": "^0.3.46", "@octokit/rest": "^21.1.1", + "@vscode/debugprotocol": "^1.68.0", "@vscode/vsce": "^2.19.0", "buffer": "^6.0.3", "crypto-browserify": "^3.12.0", @@ -2219,6 +2220,12 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@vscode/debugprotocol": { + "version": "1.68.0", + "resolved": "https://registry.npmjs.org/@vscode/debugprotocol/-/debugprotocol-1.68.0.tgz", + "integrity": "sha512-2J27dysaXmvnfuhFGhfeuxfHRXunqNPxtBoR3koiTOA9rdxWNDTa1zIFLCFMSHJ9MPTPKFcBeblsyaCJCIlQxg==", + "license": "MIT" + }, "node_modules/@vscode/test-web": { "version": "0.0.69", "resolved": "https://registry.npmjs.org/@vscode/test-web/-/test-web-0.0.69.tgz", @@ -12164,6 +12171,11 @@ "eslint-visitor-keys": "^3.3.0" } }, + "@vscode/debugprotocol": { + "version": "1.68.0", + "resolved": "https://registry.npmjs.org/@vscode/debugprotocol/-/debugprotocol-1.68.0.tgz", + "integrity": "sha512-2J27dysaXmvnfuhFGhfeuxfHRXunqNPxtBoR3koiTOA9rdxWNDTa1zIFLCFMSHJ9MPTPKFcBeblsyaCJCIlQxg==" + }, "@vscode/test-web": { "version": "0.0.69", "resolved": "https://registry.npmjs.org/@vscode/test-web/-/test-web-0.0.69.tgz", diff --git a/package.json b/package.json index 7c175bad..78759e6f 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "activationEvents": [ "onView:workflows", "onView:settings", + "onView:github-actions.workflow-debug", "workspaceContains:**/.github/workflows/**", "workspaceContains:**/action.yml", "workspaceContains:**/action.yaml" @@ -63,6 +64,21 @@ ] } ], + "breakpoints": [ + { + "language": "github-actions-workflow" + } + ], + "debuggers": [ + { + "type": "github-actions", + "label": "GitHub Actions", + "languages": [ + "github-actions-workflow" + ], + "initialConfigurations": [] + } + ], "configuration": { "title": "GitHub Actions", "properties": { @@ -155,6 +171,20 @@ "light": "resources/icons/light/logs.svg" } }, + { + "command": "github-actions.workflow.job.attachDebugger", + "category": "GitHub Actions", + "title": "Attach debugger to job", + "when": "viewItem =~ /job/ && viewItem =~ /running/", + "icon": "$(debug)" + }, + { + "command": "github-actions.workflow.job.rerunDebug", + "category": "GitHub Actions", + "title": "Re-run and debug job", + "when": "viewItem =~ /job/ && viewItem =~ /failed/", + "icon": "$(debug)" + }, { "command": "github-actions.step.logs", "category": "GitHub Actions", @@ -267,6 +297,13 @@ } ], "views": { + "debug": [ + { + "id": "github-actions.workflow-debug", + "name": "Actions Remote File System", + "when": "github-actions.debugging" + } + ], "github-actions": [ { "id": "github-actions.current-branch", @@ -373,6 +410,16 @@ "when": "viewItem =~ /run\\s/", "group": "inline" }, + { + "command": "github-actions.workflow.job.attachDebugger", + "group": "inline@0", + "when": "viewItem =~ /job/ && viewItem =~ /running/" + }, + { + "command": "github-actions.workflow.job.rerunDebug", + "group": "inline@1", + "when": "viewItem =~ /job/ && viewItem =~ /failed/" + }, { "command": "github-actions.workflow.logs", "group": "inline", @@ -458,6 +505,14 @@ "command": "github-actions.workflow.logs", "when": "false" }, + { + "command": "github-actions.workflow.job.attachDebugger", + "when": "false" + }, + { + "command": "github-actions.workflow.job.rerunDebug", + "when": "false" + }, { "command": "github-actions.step.logs", "when": "false" @@ -566,6 +621,7 @@ "@actions/languageserver": "^0.3.46", "@actions/workflow-parser": "^0.3.46", "@octokit/rest": "^21.1.1", + "@vscode/debugprotocol": "^1.68.0", "@vscode/vsce": "^2.19.0", "buffer": "^6.0.3", "crypto-browserify": "^3.12.0", @@ -589,4 +645,4 @@ "elliptic": "6.6.1" } } -} +} \ No newline at end of file diff --git a/src/commands/attachWorkflowJobDebugger.ts b/src/commands/attachWorkflowJobDebugger.ts new file mode 100644 index 00000000..40f40850 --- /dev/null +++ b/src/commands/attachWorkflowJobDebugger.ts @@ -0,0 +1,43 @@ +import * as vscode from "vscode"; +import {WorkflowJobNode} from "../treeViews/shared/workflowJobNode"; +import {getGitHubContext} from "../git/repository"; + +export type AttachWorkflowJobDebuggerArgs = Pick; + +export function registerAttachWorkflowJobDebugger(context: vscode.ExtensionContext) { + context.subscriptions.push( + vscode.commands.registerCommand( + "github-actions.workflow.job.attachDebugger", + async (args: AttachWorkflowJobDebuggerArgs) => { + const job = args.job.job; + const repoContext = args.gitHubRepoContext; + const workflowName = job.workflow_name || undefined; + const jobName = job.name; + const title = workflowName ? `Workflow "${workflowName}" job "${jobName}"` : `Job "${jobName}"`; + + // Get current GitHub user + const gitHubContext = await getGitHubContext(); + const username = gitHubContext?.username || "unknown"; + + const debugConfig: vscode.DebugConfiguration = { + name: `GitHub Actions: ${title}`, + type: "github-actions", + request: "attach", + workflowName, + jobName, + // Identity fields for DAP proxy audit logging + githubActor: username, + githubRepository: `${repoContext.owner}/${repoContext.name}`, + githubRunID: String(job.run_id), + githubJobID: String(job.id) + }; + + const folder = vscode.workspace.workspaceFolders?.[0]; + const started = await vscode.debug.startDebugging(folder, debugConfig); + if (!started) { + await vscode.window.showErrorMessage("Failed to start GitHub Actions debug session."); + } + } + ) + ); +} diff --git a/src/commands/rerunWorkflowJobDebug.ts b/src/commands/rerunWorkflowJobDebug.ts new file mode 100644 index 00000000..c9b1346f --- /dev/null +++ b/src/commands/rerunWorkflowJobDebug.ts @@ -0,0 +1,133 @@ +import * as vscode from "vscode"; + +import {WorkflowJob as WorkflowJobModel} from "../model"; +import {WorkflowJob} from "../store/WorkflowJob"; +import {WorkflowJobCommandArgs, WorkflowJobNode} from "../treeViews/shared/workflowJobNode"; + +export function registerReRunWorkflowJobWithDebug(context: vscode.ExtensionContext) { + context.subscriptions.push( + vscode.commands.registerCommand("github-actions.workflow.job.rerunDebug", async (args: WorkflowJobCommandArgs) => { + const gitHubRepoContext = args.gitHubRepoContext; + const job = args.job; + const jobId = job.job.id; + const runId = job.job.run_id; + const jobName = job.job.name; + + if (!jobId) { + await vscode.window.showErrorMessage("Unable to re-run workflow job: missing job id."); + return; + } + + if (!runId) { + await vscode.window.showErrorMessage("Unable to re-run workflow job: missing run id."); + return; + } + + try { + await gitHubRepoContext.client.request("POST /repos/{owner}/{repo}/actions/jobs/{job_id}/rerun", { + owner: gitHubRepoContext.owner, + repo: gitHubRepoContext.name, + job_id: jobId, + enable_debug_logging: true + }); + } catch (e) { + await vscode.window.showErrorMessage( + `Could not re-run workflow job with debug logging: '${(e as Error).message}'` + ); + return; + } + + WorkflowJobNode.setStatusOverride(runId, jobName, "pending", null); + await refreshWorkflowViews(); + + const updatedJob = await pollJobRunning(gitHubRepoContext, runId, jobName, 15, 1000); + if (!updatedJob) { + await vscode.window.showWarningMessage("Job did not start running within 15 seconds."); + return; + } + + await vscode.commands.executeCommand("github-actions.workflow.job.attachDebugger", { + gitHubRepoContext, + job: new WorkflowJob(gitHubRepoContext, updatedJob) + }); + }) + ); +} + +async function pollJobRunning( + gitHubRepoContext: WorkflowJobCommandArgs["gitHubRepoContext"], + runId: number, + jobName: string, + attempts: number, + delayMs: number +): Promise { + const rerunStart = Date.now(); + for (let attempt = 0; attempt < attempts; attempt++) { + const job = await getJobByName(gitHubRepoContext, runId, jobName, rerunStart); + if (job?.status === "in_progress") { + await clearStatusOverride(runId, jobName); + return job; + } + + await delay(delayMs); + } + + await clearStatusOverride(runId, jobName); + return undefined; +} + +async function getJobByName( + gitHubRepoContext: WorkflowJobCommandArgs["gitHubRepoContext"], + runId: number, + jobName: string, + rerunStart: number +): Promise { + try { + const response = await gitHubRepoContext.client.actions.listJobsForWorkflowRun({ + owner: gitHubRepoContext.owner, + repo: gitHubRepoContext.name, + run_id: runId, + per_page: 100 + }); + + const jobs = response.data.jobs ?? []; + const matching = jobs.filter(job => job.name === jobName); + if (matching.length === 0) { + return undefined; + } + + const sorted = matching.sort((left, right) => { + const leftStart = left.started_at ? Date.parse(left.started_at) : 0; + const rightStart = right.started_at ? Date.parse(right.started_at) : 0; + return rightStart - leftStart; + }); + + const newest = sorted[0]; + if (newest.started_at) { + const startedAt = Date.parse(newest.started_at); + if (!Number.isNaN(startedAt) && startedAt < rerunStart - 5000) { + return undefined; + } + } + + return newest; + } catch { + return undefined; + } +} + +async function refreshWorkflowViews(): Promise { + await Promise.all([ + vscode.commands.executeCommand("github-actions.explorer.refresh"), + vscode.commands.executeCommand("github-actions.explorer.current-branch.refresh") + ]); +} + +async function clearStatusOverride(runId: number, jobName: string): Promise { + WorkflowJobNode.clearStatusOverride(runId, jobName); + await refreshWorkflowViews(); +} + +function delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} diff --git a/src/debug/dapFileSystemMessages.ts b/src/debug/dapFileSystemMessages.ts new file mode 100644 index 00000000..7c64b4dd --- /dev/null +++ b/src/debug/dapFileSystemMessages.ts @@ -0,0 +1,147 @@ +import type {DebugProtocol as dap} from "@vscode/debugprotocol"; + +export const DapFileSystemCommand = { + Stat: "fs.stat", + ReadFile: "fs.readFile", + WriteFile: "fs.writeFile", + ReadDirectory: "fs.readDirectory", + CreateDirectory: "fs.createDirectory", + Delete: "fs.delete", + Rename: "fs.rename", + Copy: "fs.copy", + Watch: "fs.watch", + Unwatch: "fs.unwatch" +} as const; + +export const DapFileSystemEvent = { + Changed: "fs.changed" +} as const; + +export const DapFileSystemStatus = { + Ok: "ok", + InvalidArgs: "invalidArgs", + NotFound: "notFound", + Exists: "exists", + AccessDenied: "accessDenied", + IOError: "ioError", + FormatError: "formatError", + Unavailable: "unavailable", + UnknownError: "unknownError" +} as const; +export type DapFileSystemStatus = (typeof DapFileSystemStatus)[keyof typeof DapFileSystemStatus]; + +export const DapFileSystemEntryType = { + Unknown: "unknown", + File: "file", + Directory: "directory", + SymbolicLink: "symbolicLink" +} as const; +export type DapFileSystemEntryType = (typeof DapFileSystemEntryType)[keyof typeof DapFileSystemEntryType]; + +export const DapFileChangeType = { + Created: "created", + Changed: "changed", + Deleted: "deleted" +} as const; +export type DapFileChangeType = (typeof DapFileChangeType)[keyof typeof DapFileChangeType]; + +export const DapFileSystemContentEncoding = { + Base64: "base64" +} as const; +export type DapFileSystemContentEncoding = + (typeof DapFileSystemContentEncoding)[keyof typeof DapFileSystemContentEncoding]; + +export interface FileSystemResponseBody { + status: DapFileSystemStatus; +} + +export interface FileSystemErrorResponseBody extends FileSystemResponseBody { + error?: dap.Message; +} + +export interface FileSystemStatRequestArguments { + path: string; +} + +export interface FileSystemStatResponseBody extends FileSystemResponseBody { + type: DapFileSystemEntryType; + readOnly: boolean; + ctime: number; + mtime: number; + size: number; +} + +export interface FileSystemReadFileRequestArguments { + path: string; + encoding?: DapFileSystemContentEncoding; +} + +export interface FileSystemReadFileResponseBody extends FileSystemResponseBody { + content: string; + encoding: DapFileSystemContentEncoding; +} + +export interface FileSystemWriteFileRequestArguments { + path: string; + content: string; + encoding?: DapFileSystemContentEncoding; + create?: boolean; + overwrite?: boolean; +} + +export interface FileSystemReadDirectoryRequestArguments { + path: string; +} + +export interface FileSystemReadDirectoryResponseBody extends FileSystemResponseBody { + entries: FileSystemEntry[]; +} + +export interface FileSystemEntry { + name: string; + type: DapFileSystemEntryType; +} + +export interface FileSystemCreateDirectoryRequestArguments { + path: string; +} + +export interface FileSystemDeleteRequestArguments { + path: string; + recursive?: boolean; +} + +export interface FileSystemRenameRequestArguments { + oldPath: string; + newPath: string; + overwrite?: boolean; +} + +export interface FileSystemCopyRequestArguments { + sourcePath: string; + destinationPath: string; + overwrite?: boolean; +} + +export interface FileSystemWatchRequestArguments { + path: string; + recursive?: boolean; +} + +export interface FileSystemWatchResponseBody extends FileSystemResponseBody { + watchId: number; +} + +export interface FileSystemUnwatchRequestArguments { + watchId: number; +} + +export interface FileSystemChangeEventBody { + watchId: number; + changes: FileSystemChange[]; +} + +export interface FileSystemChange { + type: DapFileChangeType; + path: string; +} diff --git a/src/debug/workflowDebug.ts b/src/debug/workflowDebug.ts new file mode 100644 index 00000000..b94e282b --- /dev/null +++ b/src/debug/workflowDebug.ts @@ -0,0 +1,445 @@ +import * as crypto from "crypto"; +import * as path from "path"; +import * as vscode from "vscode"; +import type {DebugProtocol as dap} from "@vscode/debugprotocol"; + +import {registerWorkflowDebugProviders} from "./workflowDebugTree"; + +export const DEBUG_SESSION_TYPE = "github-actions"; + +const DEFAULT_DEBUG_HOST = "127.0.0.1"; +const DEFAULT_DEBUG_PORT = 4711; + +// The workflow debug adapter currently uses only sha256 checksums. +const CHECKSUM_ALGORITHM = "sha256"; + +export function registerWorkflowDebugging(context: vscode.ExtensionContext) { + registerWorkflowDebugProviders(context); + + const activeSessions = new Set(); + const updateDebuggingContext = async () => { + await vscode.commands.executeCommand("setContext", "github-actions.debugging", activeSessions.size > 0); + }; + + const handleStart = (session: vscode.DebugSession) => { + if (session.type !== DEBUG_SESSION_TYPE) { + return; + } + activeSessions.add(session.id); + void updateDebuggingContext(); + }; + + const handleTerminate = (session: vscode.DebugSession) => { + if (session.type !== DEBUG_SESSION_TYPE) { + return; + } + activeSessions.delete(session.id); + void updateDebuggingContext(); + }; + + if (vscode.debug.activeDebugSession?.type === DEBUG_SESSION_TYPE) { + activeSessions.add(vscode.debug.activeDebugSession.id); + } + + void updateDebuggingContext(); + + context.subscriptions.push(vscode.debug.onDidStartDebugSession(handleStart)); + context.subscriptions.push(vscode.debug.onDidTerminateDebugSession(handleTerminate)); + + context.subscriptions.push( + vscode.debug.registerDebugAdapterDescriptorFactory(DEBUG_SESSION_TYPE, new WorkflowDebugAdapterDescriptorFactory()) + ); + + context.subscriptions.push( + vscode.debug.registerDebugConfigurationProvider(DEBUG_SESSION_TYPE, new WorkflowDebugConfigurationProvider()) + ); + + context.subscriptions.push( + vscode.debug.registerDebugAdapterTrackerFactory(DEBUG_SESSION_TYPE, new WorkflowDebugAdapterTrackerFactory()) + ); +} + +class WorkflowDebugAdapterDescriptorFactory implements vscode.DebugAdapterDescriptorFactory { + createDebugAdapterDescriptor(session: vscode.DebugSession): vscode.ProviderResult { + const port = Number(session.configuration.port) || DEFAULT_DEBUG_PORT; + const host = session.configuration.host || DEFAULT_DEBUG_HOST; + return new vscode.DebugAdapterServer(port, host); + } +} + +class WorkflowDebugConfigurationProvider implements vscode.DebugConfigurationProvider { + resolveDebugConfiguration( + _folder: vscode.WorkspaceFolder | undefined, + config: vscode.DebugConfiguration + ): vscode.ProviderResult { + if (!config.type) { + config.type = DEBUG_SESSION_TYPE; + } + + if (!config.request) { + config.request = "attach"; + } + + if (!config.name) { + config.name = "GitHub Actions"; + } + + return config; + } +} + +class WorkflowDebugAdapterTrackerFactory implements vscode.DebugAdapterTrackerFactory { + createDebugAdapterTracker(session: vscode.DebugSession): vscode.DebugAdapterTracker { + const debugToLocalPaths = new Map(); + const localToDebugPaths = new Map(); + const pendingResolutions = new Map>(); + + // Get identity from session configuration (passed from attachWorkflowJobDebugger) + const config = session.configuration; + const githubActor = config.githubActor || "unknown"; + const githubRepository = config.githubRepository || "unknown"; + const githubRunID = config.githubRunID || "unknown"; + const githubJobID = config.githubJobID || ""; + + // Precompute workflow file checksums to enable mapping repo-relative DAP sources to + // local files on first stack trace. + let precomputedIndex: Map> | undefined; + void buildWorkflowChecksumIndex().then(index => { + precomputedIndex = index; + }); + + return { + // Apply transformations to messages as they are sent to the debug adapter. + onWillReceiveMessage: (message: dap.ProtocolMessage) => { + // Inject identity fields into initialize request for DAP proxy + if (isDapRequest(message) && (message as dap.Request).command === "initialize") { + const initRequest = message as dap.InitializeRequest; + const args = initRequest.arguments as unknown as Record; + args.githubActor = githubActor; + args.githubRepository = githubRepository; + args.githubRunID = githubRunID; + args.githubJobID = githubJobID; + } + + transformSentSourcePaths(message, { + debugToLocalPaths, + localToDebugPaths, + pendingResolutions, + precomputedIndex + }); + }, + // Apply transformations to messages as they are received from the debug adapter. + onDidSendMessage: (message: dap.ProtocolMessage) => { + if (isDapResponse(message) && message.command === "initialize") { + const initResponseBody = (message as dap.InitializeResponse).body; + if (initResponseBody) { + // Report that the debug adapter supports restart requests, so that VS Code will + // try to send them instead of terminate+launch when the user clicks "Restart". + // An error response is expected, and the restart will be blocked. + initResponseBody.supportsRestartRequest = true; + } + } + + transformReceivedSourcePaths(message, { + debugToLocalPaths, + localToDebugPaths, + pendingResolutions, + precomputedIndex + }); + } + }; + } +} + +type StackTraceTransformContext = { + debugToLocalPaths: Map; + localToDebugPaths: Map; + pendingResolutions: Map>; + precomputedIndex?: Map>; +}; + +function isDapRequest(message: dap.ProtocolMessage): message is dap.Request { + return message.type === "request"; +} + +function isDapResponse(message: dap.ProtocolMessage): message is dap.Response { + return message.type === "response"; +} + +function isDapEvent(message: dap.ProtocolMessage): message is dap.Event { + return message.type === "event"; +} + +/** + * Replace repo-relative source paths with local workspace file paths, if the checksum matches, + * when receiving messages from the debug adapter. + */ +function transformReceivedSourcePaths(message: dap.ProtocolMessage, context: StackTraceTransformContext): void { + if (isDapResponse(message) && message.command === "stackTrace") { + const stackFrames = (message as dap.StackTraceResponse).body?.stackFrames; + if (Array.isArray(stackFrames)) { + for (const frame of stackFrames) { + transformReceivedSourcePath(frame?.source, context); + } + } + } else if (isDapResponse(message) && message.command === "setBreakpoints") { + const breakpoints = (message as dap.SetBreakpointsResponse).body?.breakpoints; + if (Array.isArray(breakpoints)) { + for (const breakpoint of breakpoints) { + transformReceivedSourcePath(breakpoint?.source, context); + } + } + } else if (isDapResponse(message) && message.command === "scopes") { + const scopes = (message as dap.ScopesResponse).body?.scopes; + if (Array.isArray(scopes)) { + for (const scope of scopes) { + transformReceivedSourcePath(scope?.source, context); + } + } + } else if (isDapEvent(message) && message.event === "output") { + const source = (message as dap.OutputEvent).body?.source; + transformReceivedSourcePath(source, context); + } +} + +function transformReceivedSourcePath(source: dap.Source | undefined, context: StackTraceTransformContext): void { + if (!source || !source.path) { + return; + } + + const repoPath = normalizeRepoRelativePath(source.path); + if (!repoPath || path.isAbsolute(repoPath)) { + return; + } + + const checksum = source.checksums && source.checksums[0]; + if ( + !checksum || + typeof checksum.checksum !== "string" || + typeof checksum.algorithm !== "string" || + checksum.algorithm.toLowerCase() !== CHECKSUM_ALGORITHM + ) { + return; + } + + const cachedPath = context.debugToLocalPaths.get(repoPath); + if (cachedPath) { + // Fast path: reuse an already-mapped repo-relative path. + context.localToDebugPaths.set(cachedPath, repoPath); + source.path = cachedPath; + source.sourceReference = 0; + if (!source.name) { + source.name = path.basename(cachedPath); + } + return; + } + + if (context.precomputedIndex) { + // Use the precomputed checksum index to map without hitting the filesystem. + const candidates = context.precomputedIndex.get(repoPath); + const match = candidates?.find(entry => equalsIgnoreCase(entry.checksum, checksum.checksum)); + if (match) { + context.debugToLocalPaths.set(repoPath, match.fsPath); + context.localToDebugPaths.set(match.fsPath, repoPath); + source.path = match.fsPath; + source.sourceReference = 0; + if (!source.name) { + source.name = path.basename(match.fsPath); + } + return; + } + } + + if (!context.pendingResolutions.has(repoPath)) { + // Fallback: resolve the file by hashing it on demand and cache the result. + context.pendingResolutions.set( + repoPath, + resolveLocalSourcePath(repoPath, checksum.checksum).then(resolved => { + if (resolved) { + context.debugToLocalPaths.set(repoPath, resolved); + context.localToDebugPaths.set(resolved, repoPath); + } + context.pendingResolutions.delete(repoPath); + }) + ); + } +} + +/** + * Replace local workspace file paths with repo-relative source paths when sending requests + * to the adapter. + */ +function transformSentSourcePaths(message: dap.ProtocolMessage, context: StackTraceTransformContext): void { + if (!isDapRequest(message)) { + return; + } + + let source: dap.Source | undefined; + if (message.command === "setBreakpoints") { + source = (message as dap.SetBreakpointsRequest).arguments?.source as dap.Source | undefined; + } else if (message.command === "breakpointLocations") { + source = (message as dap.BreakpointLocationsRequest).arguments?.source as dap.Source | undefined; + } else if (message.command === "evaluate") { + source = (message as dap.EvaluateRequest).arguments?.source as dap.Source | undefined; + } + + transformSentSourcePath(source, context); +} + +function transformSentSourcePath(source: dap.Source | undefined, context: StackTraceTransformContext): void { + if (source?.path) { + const repoPath = mapLocalPathToRepoRelative(source.path, context.localToDebugPaths); + if (repoPath) { + source.path = repoPath; + } + } +} + +function mapLocalPathToRepoRelative(localPath: string, localToDebugPaths: Map): string | undefined { + const normalized = path.normalize(localPath); + const cached = localToDebugPaths.get(normalized); + if (cached) { + return cached; + } + + if (!path.isAbsolute(normalized)) { + return undefined; + } + + const uri = vscode.Uri.file(normalized); + const folder = vscode.workspace.getWorkspaceFolder(uri); + if (!folder) { + return undefined; + } + + const relative = path.posix.relative(folder.uri.path, uri.path).replace(/^\/+/, ""); + return normalizeRepoRelativePath(relative); +} + +function normalizeRepoRelativePath(rawPath: string): string | undefined { + if (!rawPath) { + return undefined; + } + + const normalized = rawPath.replace(/\\/g, "/"); + if (normalized.startsWith("/")) { + return normalized.slice(1); + } + + return normalized; +} + +function equalsIgnoreCase(left: string, right: string): boolean { + if (left === right) { + return true; + } + + if (left.length !== right.length) { + return false; + } + + return left.localeCompare(right, undefined, {sensitivity: "accent"}) === 0; +} + +async function resolveLocalSourcePath(repoPath: string, checksum: string): Promise { + const folders = vscode.workspace.workspaceFolders ?? []; + for (const folder of folders) { + const uri = joinRepoPath(folder.uri, repoPath); + if (!uri) { + continue; + } + + try { + const content = await vscode.workspace.fs.readFile(uri); + const localChecksum = computeChecksum(content); + if (equalsIgnoreCase(localChecksum, checksum)) { + return uri.fsPath; + } + } catch { + // Ignore and try next folder + } + } + + return undefined; +} + +async function buildWorkflowChecksumIndex(): Promise>> { + const index = new Map>(); + const workflowFiles = await vscode.workspace.findFiles( + "**/.github/workflows/*.{yml,yaml}", + "**/node_modules/**", + 200 + ); + + for (const uri of workflowFiles) { + const repoPath = getRepoRelativePath(uri); + if (!repoPath) { + continue; + } + + try { + const content = await vscode.workspace.fs.readFile(uri); + const checksum = computeChecksum(content); + const entries = index.get(repoPath) ?? []; + entries.push({fsPath: uri.fsPath, checksum}); + index.set(repoPath, entries); + } catch { + // Ignore unreadable files + } + } + + return index; +} + +function computeChecksum(content: Uint8Array): string { + const normalized = normalizeLineEndings(content); + return crypto.createHash(CHECKSUM_ALGORITHM).update(normalized).digest("hex"); +} + +/** + * Normalize line endings before checksum computation. This avoids checksum + * mismatches due to git's 'autocrlf' or other line ending conversions. + */ +function normalizeLineEndings(content: Uint8Array): Uint8Array { + let hasCrlf = false; + for (let i = 0; i < content.length - 1; i++) { + if (content[i] === 13 && content[i + 1] === 10) { + hasCrlf = true; + break; + } + } + + if (!hasCrlf) { + return content; + } + + const stripped: number[] = []; + for (let i = 0; i < content.length; i++) { + const byte = content[i]; + if (byte === 13 && content[i + 1] === 10) { + continue; + } + stripped.push(byte); + } + + return Uint8Array.from(stripped); +} + +function getRepoRelativePath(uri: vscode.Uri): string | undefined { + const folder = vscode.workspace.getWorkspaceFolder(uri); + if (!folder) { + return undefined; + } + + const relative = path.posix.relative(folder.uri.path, uri.path).replace(/^\/+/, ""); + return normalizeRepoRelativePath(relative); +} + +function joinRepoPath(base: vscode.Uri, repoPath: string): vscode.Uri | undefined { + const segments = repoPath.split("/").filter(Boolean); + if (segments.length === 0) { + return undefined; + } + + return vscode.Uri.joinPath(base, ...segments); +} diff --git a/src/debug/workflowDebugFileSystemProvider.ts b/src/debug/workflowDebugFileSystemProvider.ts new file mode 100644 index 00000000..5fe00933 --- /dev/null +++ b/src/debug/workflowDebugFileSystemProvider.ts @@ -0,0 +1,236 @@ +import * as vscode from "vscode"; + +import { + DapFileChangeType, + DapFileSystemCommand, + DapFileSystemContentEncoding, + DapFileSystemEntryType, + DapFileSystemEvent, + DapFileSystemStatus, + type FileSystemErrorResponseBody, + type FileSystemChangeEventBody, + type FileSystemCreateDirectoryRequestArguments, + type FileSystemDeleteRequestArguments, + type FileSystemReadDirectoryRequestArguments, + type FileSystemReadDirectoryResponseBody, + type FileSystemReadFileRequestArguments, + type FileSystemReadFileResponseBody, + type FileSystemStatRequestArguments, + type FileSystemStatResponseBody, + type FileSystemRenameRequestArguments, + type FileSystemCopyRequestArguments, + type FileSystemUnwatchRequestArguments, + type FileSystemWatchRequestArguments, + type FileSystemWatchResponseBody, + type FileSystemWriteFileRequestArguments, + FileSystemResponseBody +} from "./dapFileSystemMessages"; +import {DEBUG_SESSION_TYPE} from "./workflowDebug"; + +export class WorkflowDebugFileSystemProvider implements vscode.FileSystemProvider, vscode.Disposable { + private readonly onDidChangeFileEmitter = new vscode.EventEmitter(); + readonly onDidChangeFile = this.onDidChangeFileEmitter.event; + private readonly disposables: vscode.Disposable[] = []; + + constructor(private readonly scheme: string) { + this.disposables.push( + vscode.debug.onDidReceiveDebugSessionCustomEvent(event => { + if (event.session.type !== DEBUG_SESSION_TYPE || event.event !== DapFileSystemEvent.Changed) { + return; + } + + const body = event.body as FileSystemChangeEventBody | undefined; + if (!body?.changes?.length) { + return; + } + + const changes = body.changes + .map(change => ({ + type: toVsCodeFileChangeType(change.type), + uri: vscode.Uri.from({scheme: this.scheme, path: toUriPath(change.path)}) + })) + .filter(change => change.type !== undefined); + + if (changes.length) { + this.onDidChangeFileEmitter.fire(changes as vscode.FileChangeEvent[]); + } + }) + ); + } + + dispose(): void { + this.onDidChangeFileEmitter.dispose(); + for (const disposable of this.disposables) { + disposable.dispose(); + } + this.disposables.length = 0; + } + + watch(uri: vscode.Uri, options: {recursive: boolean}): vscode.Disposable { + const path = uri.path || "/"; + const request: FileSystemWatchRequestArguments = {path, recursive: options.recursive}; + const watchIdPromise = this.sendRequest(DapFileSystemCommand.Watch, request) + .then(body => body.watchId) + .catch(() => undefined); + + return new vscode.Disposable(() => { + void watchIdPromise.then(watchId => { + if (watchId === undefined) { + return; + } + + const unwatchRequest: FileSystemUnwatchRequestArguments = {watchId}; + void this.sendRequest(DapFileSystemCommand.Unwatch, unwatchRequest).catch(() => undefined); + }); + }); + } + + async stat(uri: vscode.Uri): Promise { + const request: FileSystemStatRequestArguments = {path: uri.path}; + const body = await this.sendRequest(DapFileSystemCommand.Stat, request); + return { + type: toVsCodeFileType(body.type), + ctime: body.ctime, + mtime: body.mtime, + size: body.size, + permissions: body.readOnly ? vscode.FilePermission.Readonly : undefined + }; + } + + async readDirectory(uri: vscode.Uri): Promise<[string, vscode.FileType][]> { + const request: FileSystemReadDirectoryRequestArguments = {path: uri.path}; + const body = await this.sendRequest( + DapFileSystemCommand.ReadDirectory, + request + ); + + return body.entries.map(entry => [entry.name, toVsCodeFileType(entry.type)]); + } + + async createDirectory(uri: vscode.Uri): Promise { + const request: FileSystemCreateDirectoryRequestArguments = {path: uri.path}; + await this.sendRequest(DapFileSystemCommand.CreateDirectory, request); + } + + async readFile(uri: vscode.Uri): Promise { + const request: FileSystemReadFileRequestArguments = { + path: uri.path, + encoding: DapFileSystemContentEncoding.Base64 + }; + const body = await this.sendRequest(DapFileSystemCommand.ReadFile, request); + return decodeBase64(body.content); + } + + async writeFile(uri: vscode.Uri, content: Uint8Array, options: {create: boolean; overwrite: boolean}): Promise { + const request: FileSystemWriteFileRequestArguments = { + path: uri.path, + content: encodeBase64(content), + encoding: DapFileSystemContentEncoding.Base64, + create: options.create, + overwrite: options.overwrite + }; + await this.sendRequest(DapFileSystemCommand.WriteFile, request); + } + + async delete(uri: vscode.Uri, options: {recursive: boolean}): Promise { + const request: FileSystemDeleteRequestArguments = {path: uri.path, recursive: options.recursive}; + await this.sendRequest(DapFileSystemCommand.Delete, request); + } + + async rename(oldUri: vscode.Uri, newUri: vscode.Uri, options: {overwrite: boolean}): Promise { + const request: FileSystemRenameRequestArguments = { + oldPath: oldUri.path, + newPath: newUri.path, + overwrite: options.overwrite + }; + await this.sendRequest(DapFileSystemCommand.Rename, request); + } + + async copy(source: vscode.Uri, destination: vscode.Uri, options: {overwrite: boolean}): Promise { + const request: FileSystemCopyRequestArguments = { + sourcePath: source.path, + destinationPath: destination.path, + overwrite: options.overwrite + }; + await this.sendRequest(DapFileSystemCommand.Copy, request); + } + + private async sendRequest( + command: string, + args: unknown + ): Promise { + const session = vscode.debug.activeDebugSession; + if (!session || session.type !== DEBUG_SESSION_TYPE) { + throw vscode.FileSystemError.Unavailable("No active GitHub Actions debug session."); + } + + const responseBody: T = await session.customRequest(command, args); + + if (responseBody?.status && responseBody.status !== DapFileSystemStatus.Ok) { + throw toFileSystemError(responseBody as FileSystemErrorResponseBody); + } + + return responseBody as T; + } +} + +function toVsCodeFileType(type: DapFileSystemEntryType): vscode.FileType { + switch (type) { + case DapFileSystemEntryType.File: + return vscode.FileType.File; + case DapFileSystemEntryType.Directory: + return vscode.FileType.Directory; + case DapFileSystemEntryType.SymbolicLink: + return vscode.FileType.SymbolicLink; + default: + return vscode.FileType.Unknown; + } +} + +function toVsCodeFileChangeType(type: DapFileChangeType): vscode.FileChangeType | undefined { + switch (type) { + case DapFileChangeType.Created: + return vscode.FileChangeType.Created; + case DapFileChangeType.Changed: + return vscode.FileChangeType.Changed; + case DapFileChangeType.Deleted: + return vscode.FileChangeType.Deleted; + default: + return undefined; + } +} + +function toUriPath(remotePath: string): string { + if (!remotePath) { + return "/"; + } + return remotePath.startsWith("/") ? remotePath : `/${remotePath}`; +} + +function decodeBase64(content: string): Uint8Array { + return Uint8Array.from(Buffer.from(content, "base64")); +} + +function encodeBase64(content: Uint8Array): string { + return Buffer.from(content).toString("base64"); +} + +function toFileSystemError(error: FileSystemErrorResponseBody): vscode.FileSystemError { + const message = error.error?.format ?? "An unknown file system error occurred."; + + switch (error.status) { + case DapFileSystemStatus.NotFound: + return vscode.FileSystemError.FileNotFound(message); + case DapFileSystemStatus.Exists: + return vscode.FileSystemError.FileExists(message); + case DapFileSystemStatus.AccessDenied: + return vscode.FileSystemError.NoPermissions(message); + case DapFileSystemStatus.IOError: + case DapFileSystemStatus.Unavailable: + return vscode.FileSystemError.Unavailable(message); + case DapFileSystemStatus.FormatError: + case DapFileSystemStatus.InvalidArgs: + default: + return new vscode.FileSystemError(message); + } +} diff --git a/src/debug/workflowDebugTree.ts b/src/debug/workflowDebugTree.ts new file mode 100644 index 00000000..767d3da2 --- /dev/null +++ b/src/debug/workflowDebugTree.ts @@ -0,0 +1,152 @@ +import * as vscode from "vscode"; + +import {WorkflowDebugFileSystemProvider} from "./workflowDebugFileSystemProvider"; + +export const WorkflowDebugScheme = "github-actions-debug"; + +export function registerWorkflowDebugProviders(context: vscode.ExtensionContext): void { + const fsProvider = new WorkflowDebugFileSystemProvider(WorkflowDebugScheme); + context.subscriptions.push(fsProvider); + context.subscriptions.push( + vscode.workspace.registerFileSystemProvider(WorkflowDebugScheme, fsProvider, {isCaseSensitive: true}) + ); + + const treeProvider = new WorkflowDebugTreeProvider(WorkflowDebugScheme, fsProvider); + context.subscriptions.push(vscode.window.registerTreeDataProvider("github-actions.workflow-debug", treeProvider)); +} + +class WorkflowDebugTreeProvider implements vscode.TreeDataProvider { + private readonly onDidChangeTreeDataEmitter = new vscode.EventEmitter(); + readonly onDidChangeTreeData = this.onDidChangeTreeDataEmitter.event; + private readonly rootUri: vscode.Uri; + private watchDisposable: vscode.Disposable | undefined; + + constructor(private readonly scheme: string, private readonly fileSystemProvider: WorkflowDebugFileSystemProvider) { + this.rootUri = vscode.Uri.from({scheme: this.scheme, path: "/"}); + this.fileSystemProvider.onDidChangeFile(() => this.refresh()); + vscode.debug.onDidStartDebugSession(session => { + if (session.type === "github-actions") { + this.startRootWatch(); + this.refresh(); + } + }); + vscode.debug.onDidTerminateDebugSession(session => { + if (session.type === "github-actions") { + this.stopRootWatch(); + this.refresh(); + } + }); + + if (vscode.debug.activeDebugSession?.type === "github-actions") { + this.startRootWatch(); + } + } + + refresh(): void { + this.onDidChangeTreeDataEmitter.fire(undefined); + } + + getTreeItem(element: WorkflowDebugNode): vscode.TreeItem { + return element; + } + + async getChildren(element?: WorkflowDebugNode): Promise { + if (!element) { + const root = new WorkflowDebugNode( + getRootLabel(), + vscode.TreeItemCollapsibleState.Expanded, + this.rootUri, + "root" + ); + return [root]; + } + + if (element.type !== vscode.FileType.Directory) { + return []; + } + + try { + const entries = await vscode.workspace.fs.readDirectory(element.resourceUri!); + return entries.map(([name, type]) => { + const childUri = vscode.Uri.joinPath(element.resourceUri!, name); + return new WorkflowDebugNode( + name, + type === vscode.FileType.Directory + ? vscode.TreeItemCollapsibleState.Collapsed + : vscode.TreeItemCollapsibleState.None, + childUri, + type === vscode.FileType.Directory ? "directory" : "file", + type + ); + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Remote filesystem unavailable"; + return [ + new WorkflowDebugNode( + message, + vscode.TreeItemCollapsibleState.None, + undefined, + "message", + vscode.FileType.Unknown + ) + ]; + } + } + + private startRootWatch(): void { + if (this.watchDisposable) { + return; + } + + this.watchDisposable = this.fileSystemProvider.watch(this.rootUri, {recursive: true}); + } + + private stopRootWatch(): void { + if (!this.watchDisposable) { + return; + } + + this.watchDisposable.dispose(); + this.watchDisposable = undefined; + } +} + +function getRootLabel(): string { + const session = vscode.debug.activeDebugSession; + if (!session || session.type !== "github-actions") { + return "Workflow Debug"; + } + + const workflowName = + typeof session.configuration?.workflowName === "string" ? session.configuration.workflowName : undefined; + const jobName = typeof session.configuration?.jobName === "string" ? session.configuration.jobName : undefined; + + if (!workflowName && !jobName) { + return "Workflow Job"; + } else if (!workflowName) { + return `Job '${jobName ?? "Unknown"}'`; + } else { + return `Workflow '${workflowName}' job '${jobName ?? "Unknown"}'`; + } +} + +class WorkflowDebugNode extends vscode.TreeItem { + constructor( + label: string, + collapsibleState: vscode.TreeItemCollapsibleState, + uri?: vscode.Uri, + contextValue?: string, + readonly type: vscode.FileType = vscode.FileType.Directory + ) { + super(label, collapsibleState); + this.resourceUri = uri; + this.contextValue = contextValue; + if (this.type === vscode.FileType.File && this.resourceUri) { + this.command = { + command: "vscode.open", + title: "Open Remote File", + arguments: [this.resourceUri] + }; + } + } +} diff --git a/src/extension.ts b/src/extension.ts index 210c9548..63bd8a41 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -3,12 +3,14 @@ import * as vscode from "vscode"; import {canReachGitHubAPI} from "./api/canReachGitHubAPI"; import {getSession} from "./auth/auth"; import {registerCancelWorkflowRun} from "./commands/cancelWorkflowRun"; +import {registerAttachWorkflowJobDebugger} from "./commands/attachWorkflowJobDebugger"; import {registerOpenWorkflowFile} from "./commands/openWorkflowFile"; import {registerOpenWorkflowJobLogs} from "./commands/openWorkflowJobLogs"; import {registerOpenWorkflowStepLogs} from "./commands/openWorkflowStepLogs"; import {registerOpenWorkflowRun} from "./commands/openWorkflowRun"; import {registerPinWorkflow} from "./commands/pinWorkflow"; import {registerReRunWorkflowRun} from "./commands/rerunWorkflowRun"; +import {registerReRunWorkflowJobWithDebug} from "./commands/rerunWorkflowJobDebug"; import {registerAddSecret} from "./commands/secrets/addSecret"; import {registerCopySecret} from "./commands/secrets/copySecret"; import {registerDeleteSecret} from "./commands/secrets/deleteSecret"; @@ -34,6 +36,7 @@ import {initResources} from "./treeViews/icons"; import {initTreeViews} from "./treeViews/treeViews"; import {deactivateLanguageServer, initLanguageServer} from "./workflow/languageServer"; import {registerSignIn} from "./commands/signIn"; +import {registerWorkflowDebugging} from "./debug/workflowDebug"; export async function activate(context: vscode.ExtensionContext) { initLogger(); @@ -75,7 +78,9 @@ export async function activate(context: vscode.ExtensionContext) { registerOpenWorkflowStepLogs(context); registerTriggerWorkflowRun(context); registerReRunWorkflowRun(context); + registerReRunWorkflowJobWithDebug(context); registerCancelWorkflowRun(context); + registerAttachWorkflowJobDebugger(context); registerAddSecret(context); registerDeleteSecret(context); @@ -113,6 +118,9 @@ export async function activate(context: vscode.ExtensionContext) { // Editing features await initLanguageServer(context); + // Debugging support + registerWorkflowDebugging(context); + log("...initialized"); if (!PRODUCTION) { diff --git a/src/treeViews/shared/workflowJobNode.ts b/src/treeViews/shared/workflowJobNode.ts index 9b5967eb..944ba236 100644 --- a/src/treeViews/shared/workflowJobNode.ts +++ b/src/treeViews/shared/workflowJobNode.ts @@ -4,19 +4,68 @@ import {WorkflowJob} from "../../store/WorkflowJob"; import {getIconForWorkflowRun} from "../icons"; import {WorkflowStepNode} from "../workflows/workflowStepNode"; +export type WorkflowJobCommandArgs = Pick; + export class WorkflowJobNode extends vscode.TreeItem { + private static readonly statusOverrides = new Map(); + + static setStatusOverride(runId: number, jobName: string, status: string, conclusion?: string | null): void { + const key = this.buildStatusKey(runId, jobName); + if (!key) { + return; + } + + this.statusOverrides.set(key, {status, conclusion}); + } + + static clearStatusOverride(runId: number, jobName: string): void { + const key = this.buildStatusKey(runId, jobName); + if (!key) { + return; + } + + this.statusOverrides.delete(key); + } + + private static getStatusOverride(job: WorkflowJob): {status: string; conclusion?: string | null} | undefined { + const key = this.buildStatusKey(job.job.run_id, job.job.name); + if (!key) { + return undefined; + } + + return this.statusOverrides.get(key); + } + + private static buildStatusKey(runId: number | undefined, jobName: string | undefined): string | undefined { + if (!runId || !jobName) { + return undefined; + } + + return `${runId}:${jobName}`; + } + constructor(public readonly gitHubRepoContext: GitHubRepoContext, public readonly job: WorkflowJob) { super( job.job.name, (job.job.steps && job.job.steps.length > 0 && vscode.TreeItemCollapsibleState.Collapsed) || undefined ); + const override = WorkflowJobNode.getStatusOverride(job); + const status = override?.status ?? job.job.status; + const conclusion = override?.conclusion ?? job.job.conclusion; + this.contextValue = "job"; - if (this.job.job.status === "completed") { + if (status === "completed") { this.contextValue += " completed"; + } else if (status === "in_progress") { + this.contextValue += " running"; + } + + if (conclusion === "failure") { + this.contextValue += " failed"; } - this.iconPath = getIconForWorkflowRun(this.job.job); + this.iconPath = getIconForWorkflowRun({status: status ?? "", conclusion: conclusion ?? null}); } hasSteps(): boolean {