mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-26 08:01:38 +00:00
f8cbf15d86
Updates the debugger service to use port 3003 instead of 5679 across all configuration files, documentation, and code references. This aligns the debugger with the other windmill-extra services which use ports 3001 (LSP) and 3002 (Multiplayer). Changes: - docker-compose.yml: Update port exposure and add DEBUGGER_PORT env - docker/entrypoint-extra.sh: Change default port from 5679 to 3003 - debugger/dap_debug_service.ts: Update default port in code and docs - debugger/README.md: Update port documentation - debugger/test_debug_service.ts: Update test URLs - docker/test_windmill_extra.ts: Update test configuration - .github/workflows/publish_extra.yml: Update test container ports - frontend/src/lib/components/debug/*: Update frontend examples and defaults Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
554 lines
12 KiB
TypeScript
554 lines
12 KiB
TypeScript
/**
|
|
* DAP (Debug Adapter Protocol) WebSocket Client for Monaco integration.
|
|
*
|
|
* This client implements the DAP protocol over WebSocket to communicate
|
|
* with a Python debugpy-based debug server.
|
|
*/
|
|
|
|
import { writable, get } from 'svelte/store'
|
|
|
|
export interface DAPMessage {
|
|
seq: number
|
|
type: 'request' | 'response' | 'event'
|
|
command?: string
|
|
event?: string
|
|
request_seq?: number
|
|
success?: boolean
|
|
message?: string
|
|
body?: Record<string, unknown>
|
|
arguments?: Record<string, unknown>
|
|
}
|
|
|
|
export interface Breakpoint {
|
|
id: number
|
|
verified: boolean
|
|
line: number
|
|
source?: { path: string; name?: string }
|
|
}
|
|
|
|
export interface StackFrame {
|
|
id: number
|
|
name: string
|
|
source?: { path: string; name?: string }
|
|
line: number
|
|
column: number
|
|
}
|
|
|
|
export interface Variable {
|
|
name: string
|
|
value: string
|
|
type?: string
|
|
variablesReference: number
|
|
}
|
|
|
|
export interface Scope {
|
|
name: string
|
|
variablesReference: number
|
|
expensive: boolean
|
|
}
|
|
|
|
export interface DebugState {
|
|
connected: boolean
|
|
initialized: boolean
|
|
running: boolean
|
|
stopped: boolean
|
|
stoppedReason?: string
|
|
currentLine?: number
|
|
currentFile?: string
|
|
stackFrames: StackFrame[]
|
|
scopes: Scope[]
|
|
variables: Map<number, Variable[]>
|
|
breakpoints: Map<string, Breakpoint[]>
|
|
output: string[]
|
|
logs: string
|
|
result?: unknown
|
|
error?: string
|
|
}
|
|
|
|
const initialState: DebugState = {
|
|
connected: false,
|
|
initialized: false,
|
|
running: false,
|
|
stopped: false,
|
|
stackFrames: [],
|
|
scopes: [],
|
|
variables: new Map(),
|
|
breakpoints: new Map(),
|
|
output: [],
|
|
logs: '',
|
|
result: undefined,
|
|
error: undefined
|
|
}
|
|
|
|
export const debugState = writable<DebugState>({ ...initialState })
|
|
|
|
export class DAPClient {
|
|
private ws: WebSocket | null = null
|
|
private seq = 1
|
|
private pendingRequests: Map<
|
|
number,
|
|
{ resolve: (value: DAPMessage) => void; reject: (error: Error) => void }
|
|
> = new Map()
|
|
private url: string
|
|
|
|
constructor(url: string = 'ws://localhost:3003') {
|
|
this.url = url
|
|
}
|
|
|
|
/**
|
|
* Connect to the DAP server.
|
|
*/
|
|
async connect(): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
try {
|
|
console.log('[DAP] Connecting to:', this.url)
|
|
this.ws = new WebSocket(this.url)
|
|
|
|
this.ws.onopen = () => {
|
|
console.log('[DAP] Connected successfully')
|
|
debugState.update((s) => ({ ...s, connected: true }))
|
|
resolve()
|
|
}
|
|
|
|
this.ws.onclose = () => {
|
|
console.log('[DAP] WebSocket closed')
|
|
debugState.update((s) => ({
|
|
...initialState,
|
|
breakpoints: s.breakpoints,
|
|
// Preserve result and logs after disconnect
|
|
result: s.result,
|
|
logs: s.logs,
|
|
output: s.output
|
|
}))
|
|
this.pendingRequests.clear()
|
|
}
|
|
|
|
this.ws.onerror = (error) => {
|
|
console.error('DAP WebSocket error:', error)
|
|
reject(new Error('WebSocket connection failed'))
|
|
}
|
|
|
|
this.ws.onmessage = (event) => {
|
|
this.handleMessage(event.data)
|
|
}
|
|
} catch (error) {
|
|
reject(error)
|
|
}
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Disconnect from the DAP server.
|
|
*/
|
|
disconnect(): void {
|
|
if (this.ws) {
|
|
this.ws.close()
|
|
this.ws = null
|
|
}
|
|
debugState.set({ ...initialState })
|
|
}
|
|
|
|
/**
|
|
* Send a request to the DAP server and wait for a response.
|
|
*/
|
|
private async sendRequest(command: string, args?: Record<string, unknown>): Promise<DAPMessage> {
|
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
throw new Error('Not connected to DAP server')
|
|
}
|
|
|
|
const seq = this.seq++
|
|
const message: DAPMessage = {
|
|
seq,
|
|
type: 'request',
|
|
command,
|
|
arguments: args
|
|
}
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const timeout = setTimeout(() => {
|
|
this.pendingRequests.delete(seq)
|
|
reject(new Error(`Request timeout: ${command}`))
|
|
}, 10000)
|
|
|
|
this.pendingRequests.set(seq, {
|
|
resolve: (value) => {
|
|
clearTimeout(timeout)
|
|
resolve(value)
|
|
},
|
|
reject: (error) => {
|
|
clearTimeout(timeout)
|
|
reject(error)
|
|
}
|
|
})
|
|
|
|
this.ws!.send(JSON.stringify(message))
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Handle incoming messages from the DAP server.
|
|
*/
|
|
private handleMessage(data: string): void {
|
|
try {
|
|
const message: DAPMessage = JSON.parse(data)
|
|
|
|
if (message.type === 'response') {
|
|
const pending = this.pendingRequests.get(message.request_seq!)
|
|
if (pending) {
|
|
this.pendingRequests.delete(message.request_seq!)
|
|
if (message.success) {
|
|
pending.resolve(message)
|
|
} else {
|
|
pending.reject(new Error(message.message || 'Request failed'))
|
|
}
|
|
}
|
|
} else if (message.type === 'event') {
|
|
console.log('[DAP] Event received:', message.event, message.body)
|
|
this.handleEvent(message)
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to parse DAP message:', error)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle DAP events.
|
|
*/
|
|
private handleEvent(event: DAPMessage): void {
|
|
console.log('[DAP] Handling event:', event.event)
|
|
const body = event.body as Record<string, unknown> | undefined
|
|
|
|
switch (event.event) {
|
|
case 'initialized':
|
|
// Clear logs and result when starting a new session
|
|
debugState.update((s) => ({
|
|
...s,
|
|
initialized: true,
|
|
logs: '',
|
|
output: [],
|
|
result: undefined,
|
|
error: undefined
|
|
}))
|
|
break
|
|
|
|
case 'stopped':
|
|
debugState.update((s) => ({
|
|
...s,
|
|
stopped: true,
|
|
running: false,
|
|
stoppedReason: body?.reason as string,
|
|
currentLine: body?.line as number | undefined
|
|
}))
|
|
// Automatically fetch stack trace when stopped
|
|
this.fetchStackTrace()
|
|
break
|
|
|
|
case 'continued':
|
|
debugState.update((s) => ({
|
|
...s,
|
|
stopped: false,
|
|
running: true,
|
|
stoppedReason: undefined
|
|
}))
|
|
break
|
|
|
|
case 'terminated':
|
|
debugState.update((s) => ({
|
|
...s,
|
|
running: false,
|
|
stopped: false,
|
|
initialized: false,
|
|
result: body?.result,
|
|
error: body?.error as string | undefined
|
|
}))
|
|
break
|
|
|
|
case 'output':
|
|
if (body?.output) {
|
|
debugState.update((s) => ({
|
|
...s,
|
|
output: [...s.output, body.output as string],
|
|
logs: s.logs + (body.output as string)
|
|
}))
|
|
}
|
|
break
|
|
|
|
case 'breakpoint':
|
|
// Breakpoint was verified/modified by the server
|
|
break
|
|
|
|
default:
|
|
console.log('Unhandled DAP event:', event.event)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initialize the debug session.
|
|
*/
|
|
async initialize(): Promise<DAPMessage> {
|
|
const response = await this.sendRequest('initialize', {
|
|
clientID: 'windmill',
|
|
clientName: 'Windmill Script Editor',
|
|
adapterID: 'python',
|
|
pathFormat: 'path',
|
|
linesStartAt1: true,
|
|
columnsStartAt1: true,
|
|
supportsVariableType: true,
|
|
supportsVariablePaging: false,
|
|
supportsRunInTerminalRequest: false,
|
|
locale: 'en-US'
|
|
})
|
|
return response
|
|
}
|
|
|
|
/**
|
|
* Set breakpoints in a source file.
|
|
*/
|
|
async setBreakpoints(path: string, lines: number[]): Promise<Breakpoint[]> {
|
|
console.log('[DAP] Setting breakpoints at path:', path, 'lines:', lines)
|
|
const response = await this.sendRequest('setBreakpoints', {
|
|
source: { path },
|
|
breakpoints: lines.map((line) => ({ line }))
|
|
})
|
|
console.log('[DAP] Breakpoints response:', response.body)
|
|
|
|
const breakpoints = (response.body?.breakpoints as Breakpoint[]) || []
|
|
|
|
debugState.update((s) => {
|
|
const newBreakpoints = new Map(s.breakpoints)
|
|
newBreakpoints.set(path, breakpoints)
|
|
return { ...s, breakpoints: newBreakpoints }
|
|
})
|
|
|
|
return breakpoints
|
|
}
|
|
|
|
/**
|
|
* Notify the server that configuration is done.
|
|
*/
|
|
async configurationDone(): Promise<void> {
|
|
await this.sendRequest('configurationDone')
|
|
}
|
|
|
|
/**
|
|
* Launch a script for debugging.
|
|
*/
|
|
async launch(options: {
|
|
program?: string
|
|
code?: string
|
|
args?: string[] | Record<string, unknown>
|
|
cwd?: string
|
|
callMain?: boolean
|
|
env?: Record<string, string>
|
|
// JWT token for audit/authorization (signed by backend)
|
|
token?: string
|
|
}): Promise<void> {
|
|
await this.sendRequest('launch', {
|
|
program: options.program,
|
|
code: options.code,
|
|
args: options.args || {},
|
|
cwd: options.cwd || '.',
|
|
callMain: options.callMain || false,
|
|
env: options.env || {},
|
|
// Pass JWT token to debugger for verification
|
|
token: options.token
|
|
})
|
|
debugState.update((s) => ({ ...s, running: true }))
|
|
}
|
|
|
|
/**
|
|
* Continue execution.
|
|
*/
|
|
async continue_(): Promise<void> {
|
|
await this.sendRequest('continue', { threadId: 1 })
|
|
debugState.update((s) => ({ ...s, stopped: false, running: true }))
|
|
}
|
|
|
|
/**
|
|
* Step over (next).
|
|
*/
|
|
async stepOver(): Promise<void> {
|
|
await this.sendRequest('next', { threadId: 1 })
|
|
}
|
|
|
|
/**
|
|
* Step into.
|
|
*/
|
|
async stepIn(): Promise<void> {
|
|
await this.sendRequest('stepIn', { threadId: 1 })
|
|
}
|
|
|
|
/**
|
|
* Step out.
|
|
*/
|
|
async stepOut(): Promise<void> {
|
|
await this.sendRequest('stepOut', { threadId: 1 })
|
|
}
|
|
|
|
/**
|
|
* Pause execution.
|
|
*/
|
|
async pause(): Promise<void> {
|
|
await this.sendRequest('pause', { threadId: 1 })
|
|
}
|
|
|
|
/**
|
|
* Terminate the debug session.
|
|
*/
|
|
async terminate(): Promise<void> {
|
|
await this.sendRequest('terminate')
|
|
}
|
|
|
|
/**
|
|
* Get threads.
|
|
*/
|
|
async getThreads(): Promise<{ id: number; name: string }[]> {
|
|
const response = await this.sendRequest('threads')
|
|
return (response.body?.threads as { id: number; name: string }[]) || []
|
|
}
|
|
|
|
/**
|
|
* Get stack trace.
|
|
*/
|
|
async getStackTrace(threadId: number = 1): Promise<StackFrame[]> {
|
|
const response = await this.sendRequest('stackTrace', {
|
|
threadId,
|
|
startFrame: 0,
|
|
levels: 20
|
|
})
|
|
const frames = (response.body?.stackFrames as StackFrame[]) || []
|
|
|
|
debugState.update((s) => ({
|
|
...s,
|
|
stackFrames: frames,
|
|
currentLine: frames[0]?.line,
|
|
currentFile: frames[0]?.source?.path
|
|
}))
|
|
|
|
return frames
|
|
}
|
|
|
|
/**
|
|
* Get scopes for a stack frame.
|
|
*/
|
|
async getScopes(frameId: number): Promise<Scope[]> {
|
|
console.log('[DAP] getScopes called with frameId:', frameId)
|
|
const response = await this.sendRequest('scopes', { frameId })
|
|
console.log('[DAP] getScopes response:', response)
|
|
const scopes = (response.body?.scopes as Scope[]) || []
|
|
console.log('[DAP] getScopes parsed scopes:', scopes)
|
|
|
|
debugState.update((s) => ({ ...s, scopes }))
|
|
|
|
return scopes
|
|
}
|
|
|
|
/**
|
|
* Get variables for a scope.
|
|
*/
|
|
async getVariables(variablesReference: number): Promise<Variable[]> {
|
|
console.log('[DAP] getVariables called with variablesReference:', variablesReference)
|
|
const response = await this.sendRequest('variables', { variablesReference })
|
|
console.log('[DAP] getVariables response:', response)
|
|
const variables = (response.body?.variables as Variable[]) || []
|
|
console.log('[DAP] getVariables parsed variables:', variables)
|
|
|
|
debugState.update((s) => {
|
|
const newVariables = new Map(s.variables)
|
|
newVariables.set(variablesReference, variables)
|
|
return { ...s, variables: newVariables }
|
|
})
|
|
|
|
return variables
|
|
}
|
|
|
|
/**
|
|
* Evaluate an expression.
|
|
* @param expression The expression to evaluate
|
|
* @param frameId Optional frame ID to evaluate in
|
|
* @param context The evaluation context
|
|
* @param token Optional JWT token for signed expression (audit logging)
|
|
*/
|
|
async evaluate(
|
|
expression: string,
|
|
frameId?: number,
|
|
context: 'watch' | 'repl' | 'hover' = 'repl',
|
|
token?: string
|
|
): Promise<{ result: string; variablesReference: number }> {
|
|
const response = await this.sendRequest('evaluate', {
|
|
expression,
|
|
frameId,
|
|
context,
|
|
token
|
|
})
|
|
return {
|
|
result: response.body?.result as string,
|
|
variablesReference: response.body?.variablesReference as number
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch stack trace (called automatically when stopped).
|
|
*/
|
|
private async fetchStackTrace(): Promise<void> {
|
|
console.log('[DAP] fetchStackTrace called')
|
|
try {
|
|
const frames = await this.getStackTrace()
|
|
console.log('[DAP] fetchStackTrace got frames:', frames)
|
|
if (frames.length > 0) {
|
|
console.log('[DAP] fetchStackTrace calling getScopes with frameId:', frames[0].id)
|
|
await this.getScopes(frames[0].id)
|
|
} else {
|
|
console.log('[DAP] fetchStackTrace: no frames, skipping getScopes')
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to fetch stack trace:', error)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clear output.
|
|
*/
|
|
clearOutput(): void {
|
|
debugState.update((s) => ({ ...s, output: [] }))
|
|
}
|
|
|
|
/**
|
|
* Check if connected.
|
|
*/
|
|
isConnected(): boolean {
|
|
return get(debugState).connected
|
|
}
|
|
|
|
/**
|
|
* Check if running.
|
|
*/
|
|
isRunning(): boolean {
|
|
return get(debugState).running
|
|
}
|
|
|
|
/**
|
|
* Check if stopped at a breakpoint.
|
|
*/
|
|
isStopped(): boolean {
|
|
return get(debugState).stopped
|
|
}
|
|
}
|
|
|
|
// Singleton instance
|
|
let dapClientInstance: DAPClient | null = null
|
|
|
|
export function getDAPClient(url?: string): DAPClient {
|
|
if (!dapClientInstance) {
|
|
dapClientInstance = new DAPClient(url)
|
|
}
|
|
return dapClientInstance
|
|
}
|
|
|
|
export function resetDAPClient(): void {
|
|
if (dapClientInstance) {
|
|
dapClientInstance.disconnect()
|
|
dapClientInstance = null
|
|
}
|
|
}
|