feat: debuggers for python and bun v0 (#7546)

This commit is contained in:
Ruben Fiszel
2026-01-13 15:20:06 +00:00
committed by GitHub
parent b593f7dfd4
commit aeff200b88
52 changed files with 15179 additions and 1596 deletions
+127
View File
@@ -0,0 +1,127 @@
env:
REGISTRY: ghcr.io
ECR_REGISTRY: 976079455550.dkr.ecr.us-east-1.amazonaws.com
IMAGE_NAME: ${{ github.repository }}-extra
name: Publish windmill-extra
on:
push:
tags:
- "v*"
workflow_dispatch:
permissions:
contents: read
id-token: write
packages: write
jobs:
sleep:
runs-on: ubicloud
steps:
- name: Sleep for 900 seconds waiting for pypi to update index
if: startsWith(github.ref, 'refs/tags/v')
run: sleep 900
shell: bash
# Build and test the image before publishing
test_extra:
runs-on: ubicloud-standard-8
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build test image
uses: docker/build-push-action@v5
with:
context: .
file: ./docker/DockerfileExtra
load: true
tags: windmill-extra:test
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Start container
run: |
docker run -d --name windmill-extra-test \
-p 3001:3001 -p 3002:3002 -p 5679:5679 \
-e ENABLE_LSP=true \
-e ENABLE_MULTIPLAYER=true \
-e ENABLE_DEBUGGER=true \
-e REQUIRE_SIGNED_DEBUG_REQUESTS=false \
windmill-extra:test
# Wait for container to start
echo "Waiting for container to initialize..."
sleep 10
# Show container logs for debugging
docker logs windmill-extra-test
- name: Run integration tests
run: |
bun run docker/test_windmill_extra.ts
- name: Show container logs on failure
if: failure()
run: |
echo "=== Container logs ==="
docker logs windmill-extra-test
- name: Cleanup
if: always()
run: |
docker stop windmill-extra-test || true
docker rm windmill-extra-test || true
publish_extra:
needs: [sleep, test_extra]
runs-on: ubicloud-standard-8
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: depot/setup-action@v1
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Login to registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push publicly
uses: depot/build-push-action@v1
with:
context: .
file: ./docker/DockerfileExtra
platforms: linux/amd64,linux/arm64
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
${{ steps.meta.outputs.tags }}
labels: |
${{ steps.meta.outputs.labels }}
org.opencontainers.image.licenses=AGPLv3
+19 -2
View File
@@ -10,9 +10,26 @@
{$BASE_URL} {
bind {$ADDRESS}
reverse_proxy /ws/* http://lsp:3001
# reverse_proxy /ws_mp/* http://multiplayer:3002
# LSP - Language Server Protocol for code intelligence (windmill_extra:3001)
reverse_proxy /ws/* http://windmill_extra:3001
# Multiplayer - Real-time collaboration, Enterprise Edition (windmill_extra:3002)
# Uncomment and set ENABLE_MULTIPLAYER=true in docker-compose.yml
# reverse_proxy /ws_mp/* http://windmill_extra:3002
# Debugger - Interactive debugging via DAP WebSocket (windmill_extra:5679)
# Set ENABLE_DEBUGGER=true in docker-compose.yml to enable
handle_path /ws_debug/* {
reverse_proxy http://windmill_extra:5679
}
# Search indexer, Enterprise Edition (windmill_indexer:8002)
# reverse_proxy /api/srch/* http://windmill_indexer:8002
# Default: Windmill server
reverse_proxy /* http://windmill_server:8000
# TLS with custom certificates
# tls /certs/cert.pem /certs/key.pem
}
@@ -0,0 +1,84 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO v2_job (\n id,\n workspace_id,\n raw_code,\n tag,\n created_by,\n permissioned_as,\n permissioned_as_email,\n kind,\n script_lang,\n args\n ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::job_kind, $9::script_lang, $10)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Varchar",
"Text",
"Varchar",
"Varchar",
"Varchar",
"Varchar",
{
"Custom": {
"name": "job_kind",
"kind": {
"Enum": [
"script",
"preview",
"flow",
"dependencies",
"flowpreview",
"script_hub",
"identity",
"flowdependencies",
"http",
"graphql",
"postgresql",
"noop",
"appdependencies",
"deploymentcallback",
"singlestepflow",
"flowscript",
"flownode",
"appscript",
"aiagent",
"unassigned_script",
"unassigned_flow",
"unassigned_singlestepflow"
]
}
}
},
{
"Custom": {
"name": "script_lang",
"kind": {
"Enum": [
"python3",
"deno",
"go",
"bash",
"postgresql",
"nativets",
"bun",
"mysql",
"bigquery",
"snowflake",
"graphql",
"powershell",
"mssql",
"php",
"bunnative",
"rust",
"ansible",
"csharp",
"oracledb",
"nu",
"java",
"duckdb",
"ruby"
]
}
}
},
"Jsonb"
]
},
"nullable": []
},
"hash": "50f81c128d644c60837e603099e169fbc7a500c11e79d2c78d6eccccf6491aec"
}
@@ -15,7 +15,7 @@
]
},
"nullable": [
null
true
]
},
"hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55"
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO v2_job_completed (\n id,\n workspace_id,\n started_at,\n completed_at,\n duration_ms,\n result,\n status,\n worker\n ) VALUES ($1, $2, $3, $3, 0, $4, 'success'::job_status, 'debugger')",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Varchar",
"Timestamptz",
"Jsonb"
]
},
"nullable": []
},
"hash": "928aa6e4fff9f60a14a51cc7a3ef507414d20c81833bc940c6323fcdbee5d9b3"
}
+1
View File
@@ -15288,6 +15288,7 @@ dependencies = [
"datafusion",
"deno_core",
"deno_error",
"ed25519-dalek",
"futures",
"git-version",
"google-cloud-googleapis",
+1
View File
@@ -247,6 +247,7 @@ argon2 = "^0"
quick_cache = "^0"
rand = "=0.9.0"
rand_core = { version = "^0", features = ["std"] }
ed25519-dalek = { version = "2", features = ["rand_core"] }
magic-crypt = "^3"
git-version = "^0"
malachite = "=0.4.18"
+6
View File
@@ -433,6 +433,12 @@ async fn windmill_main() -> anyhow::Result<()> {
println!("Windmill {}", GIT_VERSION);
return Ok(());
}
"prepare-deps" => {
// CLI command for preparing dependencies without database access
// Used by the debugger to install dependencies for scripts
windmill_worker::run_prepare_deps_cli().await?;
return Ok(());
}
_ => {}
}
+1
View File
@@ -84,6 +84,7 @@ rust-embed = { workspace = true, optional = true }
tracing-subscriber.workspace = true
quick_cache.workspace = true
rand.workspace = true
ed25519-dalek.workspace = true
time.workspace = true
native-tls.workspace = true
tokio-native-tls.workspace = true
+413
View File
@@ -0,0 +1,413 @@
/*
* Author: Ruben Fiszel
* Copyright: Windmill Labs, Inc 2022
* This file and its contents are licensed under the AGPLv3 License.
* Please see the included NOTICE for copyright information and
* LICENSE-AGPL for a copy of the license.
*/
//! Debug session signing and audit logging.
//!
//! This module provides cryptographic signing of debug requests to ensure:
//! 1. All debug sessions are logged in the audit trail
//! 2. The debugger only executes code that has been authorized by the backend
//! 3. Replay attacks are prevented via timestamp validation
//!
//! Uses Ed25519 JWT signing. The debugger fetches the public key from /api/debug/jwks
//! and verifies tokens locally.
//!
//! Each debug session creates:
//! - A job entry in v2_job (kind=preview) for traceability
//! - A completed job entry in v2_job_completed
//! - An audit log entry identical to script preview runs
use axum::{extract::Path, routing::{get, post}, Extension, Json, Router};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use chrono::Utc;
use ed25519_dalek::{SigningKey, Signer};
use serde::{Deserialize, Serialize};
use sha2::{Sha256, Digest};
use sqlx::types::Json as SqlxJson;
use std::sync::Arc;
use tokio::sync::RwLock;
use uuid::Uuid;
use windmill_audit::{audit_oss::audit_log, ActionKind};
use windmill_common::{
db::UserDB,
error::JsonResult,
jobs::JobKind,
scripts::ScriptLang,
users::username_to_permissioned_as,
};
use crate::db::ApiAuthed;
/// TTL for debug tokens in seconds (60 seconds)
pub const DEBUG_TOKEN_TTL_SECS: i64 = 60;
lazy_static::lazy_static! {
/// Ed25519 signing key for debug tokens.
/// Generated at startup if not provided via environment variable.
static ref DEBUG_SIGNING_KEY: Arc<RwLock<Option<SigningKey>>> = Arc::new(RwLock::new(None));
}
/// Initialize the debug signing key.
/// Call this at server startup.
pub async fn init_debug_signing_key() {
let mut key_guard = DEBUG_SIGNING_KEY.write().await;
// Check if key is provided via environment variable (base64-encoded seed)
if let Ok(seed_b64) = std::env::var("DEBUG_SIGNING_KEY_SEED") {
if let Ok(seed_bytes) = URL_SAFE_NO_PAD.decode(&seed_b64) {
if seed_bytes.len() >= 32 {
let mut seed = [0u8; 32];
seed.copy_from_slice(&seed_bytes[..32]);
*key_guard = Some(SigningKey::from_bytes(&seed));
tracing::info!("Debug signing key loaded from environment");
return;
}
}
tracing::warn!("Invalid DEBUG_SIGNING_KEY_SEED, generating new key");
}
// Generate a new random key using rand
let mut seed = [0u8; 32];
rand::Rng::fill(&mut rand::rng(), &mut seed);
let signing_key = SigningKey::from_bytes(&seed);
tracing::info!("Generated new debug signing key");
*key_guard = Some(signing_key);
}
pub fn global_service() -> Router {
Router::new().route("/jwks", get(get_jwks))
}
pub fn workspaced_service() -> Router {
Router::new()
.route("/sign", post(sign_debug_request))
.route("/sign_expression", post(sign_expression))
}
/// JWKS response containing the public key for debug token verification
#[derive(Serialize)]
pub struct DebugJwks {
pub keys: Vec<DebugJwk>,
}
/// JWK representation of an Ed25519 public key
#[derive(Serialize)]
pub struct DebugJwk {
pub kty: String,
pub crv: String,
pub x: String,
pub kid: String,
#[serde(rename = "use")]
pub use_: String,
pub alg: String,
}
/// Get the JWKS containing the public key for debug token verification.
/// Debugger should fetch this at startup and cache it.
async fn get_jwks() -> JsonResult<DebugJwks> {
let key_guard = DEBUG_SIGNING_KEY.read().await;
let signing_key = key_guard.as_ref().ok_or_else(|| {
windmill_common::error::Error::InternalErr("Debug signing key not initialized".to_string())
})?;
let verifying_key = signing_key.verifying_key();
let public_key_bytes = verifying_key.to_bytes();
// Compute key ID as hash of public key
let mut hasher = Sha256::new();
hasher.update(&public_key_bytes);
let kid = hex::encode(&hasher.finalize()[..8]);
Ok(Json(DebugJwks {
keys: vec![DebugJwk {
kty: "OKP".to_string(),
crv: "Ed25519".to_string(),
x: URL_SAFE_NO_PAD.encode(public_key_bytes),
kid,
use_: "sig".to_string(),
alg: "EdDSA".to_string(),
}],
}))
}
#[derive(Deserialize)]
pub struct SignDebugRequest {
/// The code to be debugged
pub code: String,
/// The programming language (python3, bun, typescript, etc.)
pub language: String,
}
/// JWT claims for debug tokens
#[derive(Serialize, Deserialize)]
pub struct DebugTokenClaims {
/// Code hash (SHA-256, first 16 bytes, hex encoded)
pub code_hash: String,
/// Programming language
pub language: String,
/// Workspace ID
pub workspace_id: String,
/// User email
pub email: String,
/// Issued at (Unix timestamp)
pub iat: i64,
/// Expiration (Unix timestamp)
pub exp: i64,
/// Job ID for traceability
pub job_id: String,
}
#[derive(Serialize)]
pub struct SignedDebugPayload {
/// JWT token containing the signed claims
pub token: String,
/// The code (passed through for convenience)
pub code: String,
/// Job ID for the debug session (can be used to view job details)
pub job_id: String,
}
/// Sign a debug request and create audit log + job entries for full traceability.
///
/// This endpoint must be called before starting a debug session.
/// Returns a JWT that the debugger will verify using the public key from /api/debug/jwks.
///
/// Creates:
/// - A job entry in v2_job (kind=preview) with the debug code
/// - A completed job entry in v2_job_completed (status=success)
/// - An audit log entry identical to "jobs.run.preview"
async fn sign_debug_request(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
Json(request): Json<SignDebugRequest>,
) -> JsonResult<SignedDebugPayload> {
let key_guard = DEBUG_SIGNING_KEY.read().await;
let signing_key = key_guard.as_ref().ok_or_else(|| {
windmill_common::error::Error::InternalErr("Debug signing key not initialized".to_string())
})?;
let now = Utc::now();
let now_ts = now.timestamp();
let exp = now_ts + DEBUG_TOKEN_TTL_SECS;
// Parse the language
let script_lang: ScriptLang = request.language.parse().unwrap_or(ScriptLang::Bun);
// Hash the code (we don't include full code in JWT to keep it small)
let mut hasher = Sha256::new();
hasher.update(request.code.as_bytes());
let code_hash = hex::encode(&hasher.finalize()[..16]);
// Generate job ID
let job_id = Uuid::new_v4();
let claims = DebugTokenClaims {
code_hash,
language: request.language.clone(),
workspace_id: w_id.clone(),
email: authed.email.clone(),
iat: now_ts,
exp,
job_id: job_id.to_string(),
};
// Create JWT manually with Ed25519 signature
let header = serde_json::json!({
"alg": "EdDSA",
"typ": "JWT"
});
let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap());
let claims_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&claims).unwrap());
let message = format!("{}.{}", header_b64, claims_b64);
let signature = signing_key.sign(message.as_bytes());
let signature_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes());
let token = format!("{}.{}", message, signature_b64);
// Create job entries and audit log in a transaction
let mut tx = user_db.begin(&authed).await?;
let tag = "debugger".to_string();
let permissioned_as = username_to_permissioned_as(&authed.username);
// Insert into v2_job (the job definition)
sqlx::query!(
"INSERT INTO v2_job (
id,
workspace_id,
raw_code,
tag,
created_by,
permissioned_as,
permissioned_as_email,
kind,
script_lang,
args
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::job_kind, $9::script_lang, $10)",
job_id,
w_id,
request.code,
tag,
authed.display_username(),
permissioned_as,
authed.email,
JobKind::Preview as JobKind,
script_lang as ScriptLang,
SqlxJson(serde_json::json!({})) as SqlxJson<serde_json::Value>,
)
.execute(&mut *tx)
.await?;
// Insert into v2_job_completed (mark as immediately completed)
sqlx::query!(
"INSERT INTO v2_job_completed (
id,
workspace_id,
started_at,
completed_at,
duration_ms,
result,
status,
worker
) VALUES ($1, $2, $3, $3, 0, $4, 'success'::job_status, 'debugger')",
job_id,
w_id,
now,
SqlxJson(serde_json::json!({"debug_session": true, "language": request.language})) as SqlxJson<serde_json::Value>,
)
.execute(&mut *tx)
.await?;
// Create audit log entry (identical to jobs.run.preview)
audit_log(
&mut *tx,
&authed,
"jobs.run.preview",
ActionKind::Execute,
&w_id,
None,
Some([("job_id", job_id.to_string().as_str())].into()),
)
.await?;
tx.commit().await?;
Ok(Json(SignedDebugPayload {
token,
code: request.code,
job_id: job_id.to_string(),
}))
}
#[derive(Deserialize)]
pub struct SignExpressionRequest {
/// The expression to evaluate
pub expression: String,
/// The job ID of the parent debug session
pub job_id: String,
}
/// JWT claims for expression evaluation tokens
#[derive(Serialize, Deserialize)]
pub struct ExpressionTokenClaims {
/// Expression hash (SHA-256, first 16 bytes, hex encoded)
pub expression_hash: String,
/// Parent debug session job ID
pub job_id: String,
/// Workspace ID
pub workspace_id: String,
/// User email
pub email: String,
/// Issued at (Unix timestamp)
pub iat: i64,
/// Expiration (Unix timestamp)
pub exp: i64,
}
#[derive(Serialize)]
pub struct SignedExpressionPayload {
/// JWT token containing the signed claims
pub token: String,
}
/// Sign a console expression for evaluation and create audit log.
///
/// This endpoint must be called before evaluating an expression in the debug console.
/// Creates an audit log entry with the full expression for traceability.
async fn sign_expression(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
Json(request): Json<SignExpressionRequest>,
) -> JsonResult<SignedExpressionPayload> {
let key_guard = DEBUG_SIGNING_KEY.read().await;
let signing_key = key_guard.as_ref().ok_or_else(|| {
windmill_common::error::Error::InternalErr("Debug signing key not initialized".to_string())
})?;
let now = Utc::now();
let now_ts = now.timestamp();
let exp = now_ts + DEBUG_TOKEN_TTL_SECS;
// Hash the expression
let mut hasher = Sha256::new();
hasher.update(request.expression.as_bytes());
let expression_hash = hex::encode(&hasher.finalize()[..16]);
let claims = ExpressionTokenClaims {
expression_hash,
job_id: request.job_id.clone(),
workspace_id: w_id.clone(),
email: authed.email.clone(),
iat: now_ts,
exp,
};
// Create JWT manually with Ed25519 signature
let header = serde_json::json!({
"alg": "EdDSA",
"typ": "JWT"
});
let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap());
let claims_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&claims).unwrap());
let message = format!("{}.{}", header_b64, claims_b64);
let signature = signing_key.sign(message.as_bytes());
let signature_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes());
let token = format!("{}.{}", message, signature_b64);
// Create audit log entry for the expression evaluation
let mut tx = user_db.begin(&authed).await?;
// Truncate expression for resource field if too long (max 255 chars)
let resource = if request.expression.len() > 200 {
format!("{}...", &request.expression[..200])
} else {
request.expression.clone()
};
audit_log(
&mut *tx,
&authed,
"debug.evaluate",
ActionKind::Execute,
&w_id,
Some(&resource),
Some([
("job_id", request.job_id.as_str()),
("expression", request.expression.as_str()),
].into()),
)
.await?;
tx.commit().await?;
Ok(Json(SignedExpressionPayload { token }))
}
+6
View File
@@ -83,6 +83,7 @@ mod capture;
mod concurrency_groups;
mod configs;
mod db;
pub mod debug;
mod drafts;
#[cfg(feature = "private")]
pub mod ee;
@@ -295,6 +296,9 @@ pub async fn run_server(
));
let argon2 = Arc::new(Argon2::default());
// Initialize debug signing key for debugger authentication
debug::init_debug_signing_key().await;
let disable_response_logs = std::env::var("DISABLE_RESPONSE_LOGS")
.ok()
.map(|x| x == "true")
@@ -476,6 +480,7 @@ pub async fn run_server(
.nest("/job_metrics", job_metrics::workspaced_service())
.nest("/job_helpers", job_helpers_service)
.nest("/jobs", jobs::workspaced_service())
.nest("/debug", debug::workspaced_service())
.nest("/oauth", {
#[cfg(feature = "oauth2")]
{
@@ -539,6 +544,7 @@ pub async fn run_server(
)
.nest("/srch/index", indexer_oss::global_service())
.nest("/oidc", oidc_oss::global_service())
.nest("/debug", debug::global_service())
.nest(
"/saml",
saml_oss::global_service().layer(Extension(Arc::clone(&sp_extension))),
+2
View File
@@ -67,6 +67,7 @@ mod schema;
pub mod scoped_dependency_map;
pub mod sql_utils;
mod universal_pkg_installer;
mod prepare_deps;
mod worker;
mod worker_flow;
mod worker_lockfiles;
@@ -85,6 +86,7 @@ pub use bun_executor::{
prebundle_bun_script, prepare_job_dir,
};
pub use deno_executor::generate_deno_lock;
pub use prepare_deps::run_prepare_deps_cli;
#[cfg(feature = "python")]
pub use python_versions::PyV;
+563
View File
@@ -0,0 +1,563 @@
/*
* CLI command for preparing dependencies for the debugger.
* This module provides a standalone dependency installation mechanism
* that works without requiring a database connection.
*/
use std::collections::{HashMap, HashSet};
use std::io::{self, BufRead};
use std::process::Stdio;
use regex::Regex;
use serde::{Deserialize, Serialize};
use tokio::process::Command;
use crate::{BUN_CACHE_DIR, BUN_PATH, HOME_ENV, PATH_ENV, PROXY_ENVS, UV_CACHE_DIR};
use windmill_common::worker::write_file;
const LOADER_BUILDER_CONTENT: &str = include_str!("../loader_builder.bun.js");
lazy_static::lazy_static! {
/// Regex to parse Python import statements
/// Matches: `import foo`, `import foo.bar`, `from foo import bar`, `from foo.bar import baz`
static ref PYTHON_IMPORT_REGEX: Regex = Regex::new(
r"(?m)^(?:from\s+([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)*)\s+import|import\s+([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)*))"
).unwrap();
/// Python standard library modules (Python 3.10+)
/// This is a subset - most common ones that users might try to import
static ref PYTHON_STDLIB: HashSet<&'static str> = {
let mut s = HashSet::new();
// Built-in modules
s.insert("abc"); s.insert("aifc"); s.insert("argparse"); s.insert("array");
s.insert("ast"); s.insert("asyncio"); s.insert("atexit"); s.insert("base64");
s.insert("bdb"); s.insert("binascii"); s.insert("binhex"); s.insert("bisect");
s.insert("builtins"); s.insert("bz2"); s.insert("calendar"); s.insert("cgi");
s.insert("cgitb"); s.insert("chunk"); s.insert("cmath"); s.insert("cmd");
s.insert("code"); s.insert("codecs"); s.insert("codeop"); s.insert("collections");
s.insert("colorsys"); s.insert("compileall"); s.insert("concurrent");
s.insert("configparser"); s.insert("contextlib"); s.insert("contextvars");
s.insert("copy"); s.insert("copyreg"); s.insert("cProfile"); s.insert("crypt");
s.insert("csv"); s.insert("ctypes"); s.insert("curses"); s.insert("dataclasses");
s.insert("datetime"); s.insert("dbm"); s.insert("decimal"); s.insert("difflib");
s.insert("dis"); s.insert("distutils"); s.insert("doctest"); s.insert("email");
s.insert("encodings"); s.insert("enum"); s.insert("errno"); s.insert("faulthandler");
s.insert("fcntl"); s.insert("filecmp"); s.insert("fileinput"); s.insert("fnmatch");
s.insert("fractions"); s.insert("ftplib"); s.insert("functools"); s.insert("gc");
s.insert("getopt"); s.insert("getpass"); s.insert("gettext"); s.insert("glob");
s.insert("graphlib"); s.insert("grp"); s.insert("gzip"); s.insert("hashlib");
s.insert("heapq"); s.insert("hmac"); s.insert("html"); s.insert("http");
s.insert("idlelib"); s.insert("imaplib"); s.insert("imghdr"); s.insert("imp");
s.insert("importlib"); s.insert("inspect"); s.insert("io"); s.insert("ipaddress");
s.insert("itertools"); s.insert("json"); s.insert("keyword"); s.insert("lib2to3");
s.insert("linecache"); s.insert("locale"); s.insert("logging"); s.insert("lzma");
s.insert("mailbox"); s.insert("mailcap"); s.insert("marshal"); s.insert("math");
s.insert("mimetypes"); s.insert("mmap"); s.insert("modulefinder"); s.insert("multiprocessing");
s.insert("netrc"); s.insert("nis"); s.insert("nntplib"); s.insert("numbers");
s.insert("operator"); s.insert("optparse"); s.insert("os"); s.insert("ossaudiodev");
s.insert("pathlib"); s.insert("pdb"); s.insert("pickle"); s.insert("pickletools");
s.insert("pipes"); s.insert("pkgutil"); s.insert("platform"); s.insert("plistlib");
s.insert("poplib"); s.insert("posix"); s.insert("posixpath"); s.insert("pprint");
s.insert("profile"); s.insert("pstats"); s.insert("pty"); s.insert("pwd");
s.insert("py_compile"); s.insert("pyclbr"); s.insert("pydoc"); s.insert("queue");
s.insert("quopri"); s.insert("random"); s.insert("re"); s.insert("readline");
s.insert("reprlib"); s.insert("resource"); s.insert("rlcompleter"); s.insert("runpy");
s.insert("sched"); s.insert("secrets"); s.insert("select"); s.insert("selectors");
s.insert("shelve"); s.insert("shlex"); s.insert("shutil"); s.insert("signal");
s.insert("site"); s.insert("smtpd"); s.insert("smtplib"); s.insert("sndhdr");
s.insert("socket"); s.insert("socketserver"); s.insert("spwd"); s.insert("sqlite3");
s.insert("ssl"); s.insert("stat"); s.insert("statistics"); s.insert("string");
s.insert("stringprep"); s.insert("struct"); s.insert("subprocess"); s.insert("sunau");
s.insert("symtable"); s.insert("sys"); s.insert("sysconfig"); s.insert("syslog");
s.insert("tabnanny"); s.insert("tarfile"); s.insert("telnetlib"); s.insert("tempfile");
s.insert("termios"); s.insert("test"); s.insert("textwrap"); s.insert("threading");
s.insert("time"); s.insert("timeit"); s.insert("tkinter"); s.insert("token");
s.insert("tokenize"); s.insert("tomllib"); s.insert("trace"); s.insert("traceback");
s.insert("tracemalloc"); s.insert("tty"); s.insert("turtle"); s.insert("turtledemo");
s.insert("types"); s.insert("typing"); s.insert("unicodedata"); s.insert("unittest");
s.insert("urllib"); s.insert("uu"); s.insert("uuid"); s.insert("venv");
s.insert("warnings"); s.insert("wave"); s.insert("weakref"); s.insert("webbrowser");
s.insert("winreg"); s.insert("winsound"); s.insert("wsgiref"); s.insert("xdrlib");
s.insert("xml"); s.insert("xmlrpc"); s.insert("zipapp"); s.insert("zipfile");
s.insert("zipimport"); s.insert("zlib"); s.insert("zoneinfo");
// Common aliases/shortcuts
s.insert("_thread"); s.insert("__future__");
s
};
/// UV binary path
static ref UV_PATH: String = std::env::var("UV_PATH").unwrap_or_else(|_| "/usr/local/bin/uv".to_string());
}
/// Simple loader that doesn't require Windmill API for relative imports
const SIMPLE_LOADER: &str = r#"
const p = {
name: "simple-resolver",
async setup(build) {
// No-op plugin - we just want to scan imports
},
};
"#;
#[derive(Deserialize)]
pub struct PrepareRequest {
pub code: String,
pub language: String,
}
#[derive(Serialize)]
pub struct PrepareResponse {
/// Path to node_modules for JS/TS scripts
pub node_modules_path: Option<String>,
/// Path to Python virtual environment's site-packages
pub venv_path: Option<String>,
pub job_dir: String,
pub success: bool,
pub error: Option<String>,
}
/// Parse Python imports and return a list of package names that need to be installed.
/// Filters out standard library modules.
fn parse_python_imports(code: &str) -> Vec<String> {
let mut packages = HashSet::new();
for cap in PYTHON_IMPORT_REGEX.captures_iter(code) {
// Get either group 1 (from X import) or group 2 (import X)
let module = cap.get(1).or_else(|| cap.get(2));
if let Some(m) = module {
let full_module = m.as_str();
// Get the top-level package name (e.g., "foo" from "foo.bar.baz")
let package = full_module.split('.').next().unwrap_or(full_module);
// Skip standard library modules
if !PYTHON_STDLIB.contains(package) {
// Skip relative imports (starting with .)
if !package.starts_with('.') {
packages.insert(package.to_string());
}
}
}
}
packages.into_iter().collect()
}
/// Get common environment variables for external processes (UV, Bun, etc.)
fn get_proc_envs(cache_env: Option<(&str, &str)>) -> HashMap<String, String> {
let mut envs = HashMap::new();
envs.insert("PATH".to_string(), PATH_ENV.to_string());
envs.insert("HOME".to_string(), HOME_ENV.to_string());
if let Some((key, value)) = cache_env {
envs.insert(key.to_string(), value.to_string());
}
// Add proxy envs
for (k, v) in PROXY_ENVS.iter() {
envs.insert(k.to_string(), v.clone());
}
envs
}
/// Prepare Python dependencies using uv
async fn prepare_python_deps_standalone(code: &str) -> PrepareResponse {
// Parse imports from the code
let packages = parse_python_imports(code);
if packages.is_empty() {
return PrepareResponse {
node_modules_path: None,
venv_path: None,
job_dir: String::new(),
success: true,
error: None,
};
}
tracing::debug!("Detected Python packages: {:?}", packages);
// Create a temporary directory for the virtual environment
let job_id = uuid::Uuid::new_v4();
let job_dir = format!("/tmp/windmill-deps/{}", job_id);
let venv_dir = format!("{}/venv", job_dir);
if let Err(e) = std::fs::create_dir_all(&job_dir) {
return PrepareResponse {
node_modules_path: None,
venv_path: None,
job_dir: job_dir.clone(),
success: false,
error: Some(format!("Failed to create job directory: {}", e)),
};
}
let common_uv_envs = get_proc_envs(Some(("UV_CACHE_DIR", &UV_CACHE_DIR)));
// Step 1: Create virtual environment using uv
let output = Command::new(UV_PATH.as_str())
.current_dir(&job_dir)
.env_clear()
.envs(common_uv_envs.clone())
.args(["venv", &venv_dir, "--seed"])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await;
if let Err(e) = output {
return PrepareResponse {
node_modules_path: None,
venv_path: None,
job_dir: job_dir.clone(),
success: false,
error: Some(format!("Failed to create venv: {}", e)),
};
}
let out = output.unwrap();
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
return PrepareResponse {
node_modules_path: None,
venv_path: None,
job_dir: job_dir.clone(),
success: false,
error: Some(format!("uv venv failed: {}", stderr)),
};
}
// Step 2: Install packages using uv pip install
let python_path = format!("{}/bin/python", venv_dir);
let mut args = vec!["pip", "install", "--python", &python_path];
let package_refs: Vec<&str> = packages.iter().map(|s| s.as_str()).collect();
args.extend(package_refs.iter());
let output = Command::new(UV_PATH.as_str())
.current_dir(&job_dir)
.env_clear()
.envs(common_uv_envs)
.args(&args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await;
match output {
Ok(out) => {
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
// Installation might fail for some packages (e.g., wrong package name)
// Log the error but continue - the script might still work if the
// package is actually installed elsewhere or the import is optional
tracing::warn!("uv pip install warning: {}", stderr);
}
}
Err(e) => {
return PrepareResponse {
node_modules_path: None,
venv_path: None,
job_dir: job_dir.clone(),
success: false,
error: Some(format!("Failed to run uv pip install: {}", e)),
};
}
}
// Find the site-packages directory
let site_packages = format!("{}/lib", venv_dir);
let site_packages_path = if let Ok(entries) = std::fs::read_dir(&site_packages) {
// Find python3.X directory
let python_dir = entries
.filter_map(|e| e.ok())
.find(|e| e.file_name().to_string_lossy().starts_with("python"));
if let Some(py_dir) = python_dir {
let sp = format!("{}/site-packages", py_dir.path().display());
if std::path::Path::new(&sp).exists() {
Some(sp)
} else {
None
}
} else {
None
}
} else {
None
};
PrepareResponse {
node_modules_path: None,
venv_path: site_packages_path.or(Some(venv_dir)),
job_dir,
success: true,
error: None,
}
}
/// Get common environment variables for Bun processes
pub fn get_simple_bun_proc_envs() -> HashMap<String, String> {
get_proc_envs(Some(("BUN_INSTALL_CACHE_DIR", &BUN_CACHE_DIR)))
}
/// Prepare dependencies for a script without requiring database access.
/// This is meant to be called from the CLI.
pub async fn prepare_deps_standalone(code: &str, language: &str) -> PrepareResponse {
// Route to the appropriate handler based on language
match language {
"python3" | "python" => {
return prepare_python_deps_standalone(code).await;
}
"bun" | "typescript" | "deno" => {
// Continue with JS/TS handling below
}
_ => {
return PrepareResponse {
node_modules_path: None,
venv_path: None,
job_dir: String::new(),
success: false,
error: Some(format!(
"Unsupported language for dependency preparation: {}",
language
)),
};
}
}
// Create a temporary directory for the job
let job_id = uuid::Uuid::new_v4();
let job_dir = format!("/tmp/windmill-deps/{}", job_id);
if let Err(e) = std::fs::create_dir_all(&job_dir) {
return PrepareResponse {
node_modules_path: None,
venv_path: None,
job_dir: job_dir.clone(),
success: false,
error: Some(format!("Failed to create job directory: {}", e)),
};
}
// Write the script code
if let Err(e) = write_file(&job_dir, "main.ts", code) {
return PrepareResponse {
node_modules_path: None,
venv_path: None,
job_dir: job_dir.clone(),
success: false,
error: Some(format!("Failed to write main.ts: {}", e)),
};
}
// Write the build.js script that scans imports and generates package.json
let build_script = format!(
r#"{}
{}
"#,
SIMPLE_LOADER, LOADER_BUILDER_CONTENT
);
if let Err(e) = write_file(&job_dir, "build.js", &build_script) {
return PrepareResponse {
node_modules_path: None,
venv_path: None,
job_dir: job_dir.clone(),
success: false,
error: Some(format!("Failed to write build.js: {}", e)),
};
}
let common_bun_proc_envs = get_simple_bun_proc_envs();
// Step 1: Run build.js to generate package.json
let output = Command::new(&*BUN_PATH)
.current_dir(&job_dir)
.env_clear()
.envs(common_bun_proc_envs.clone())
.args(vec!["run", "build.js"])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await;
match output {
Ok(out) => {
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
// If build fails, it might be because there are no external imports
// Check if package.json was created anyway
if !std::path::Path::new(&format!("{}/package.json", job_dir)).exists() {
// Create an empty package.json
let empty_pkg = r#"{"dependencies": {}}"#;
if let Err(e) = write_file(&job_dir, "package.json", empty_pkg) {
return PrepareResponse {
node_modules_path: None,
venv_path: None,
job_dir: job_dir.clone(),
success: false,
error: Some(format!("Failed to write empty package.json: {}", e)),
};
}
}
tracing::debug!("Build script stderr (may be non-fatal): {}", stderr);
}
}
Err(e) => {
return PrepareResponse {
node_modules_path: None,
venv_path: None,
job_dir: job_dir.clone(),
success: false,
error: Some(format!("Failed to run build.js: {}", e)),
};
}
}
// Check if package.json has any dependencies
let package_json_path = format!("{}/package.json", job_dir);
let package_json_content = match std::fs::read_to_string(&package_json_path) {
Ok(content) => content,
Err(_) => {
// No package.json means no dependencies needed
return PrepareResponse {
node_modules_path: None,
venv_path: None,
job_dir: job_dir.clone(),
success: true,
error: None,
};
}
};
// Parse to check if dependencies is empty
let package_json: serde_json::Value = match serde_json::from_str(&package_json_content) {
Ok(v) => v,
Err(e) => {
return PrepareResponse {
node_modules_path: None,
venv_path: None,
job_dir: job_dir.clone(),
success: false,
error: Some(format!("Failed to parse package.json: {}", e)),
};
}
};
let deps = package_json.get("dependencies").and_then(|d| d.as_object());
if deps.map(|d| d.is_empty()).unwrap_or(true) {
// No dependencies to install
return PrepareResponse {
node_modules_path: None,
venv_path: None,
job_dir: job_dir.clone(),
success: true,
error: None,
};
}
// Step 2: Run bun install
let output = Command::new(&*BUN_PATH)
.current_dir(&job_dir)
.env_clear()
.envs(common_bun_proc_envs)
.args(vec!["install"])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await;
match output {
Ok(out) => {
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
return PrepareResponse {
node_modules_path: None,
venv_path: None,
job_dir: job_dir.clone(),
success: false,
error: Some(format!("bun install failed: {}", stderr)),
};
}
}
Err(e) => {
return PrepareResponse {
node_modules_path: None,
venv_path: None,
job_dir: job_dir.clone(),
success: false,
error: Some(format!("Failed to run bun install: {}", e)),
};
}
}
let node_modules_path = format!("{}/node_modules", job_dir);
if std::path::Path::new(&node_modules_path).exists() {
PrepareResponse {
node_modules_path: Some(node_modules_path),
venv_path: None,
job_dir,
success: true,
error: None,
}
} else {
PrepareResponse {
node_modules_path: None,
venv_path: None,
job_dir,
success: true,
error: None,
}
}
}
/// CLI entry point for prepare-deps command
pub async fn run_prepare_deps_cli() -> anyhow::Result<()> {
// Read JSON from stdin
let stdin = io::stdin();
let mut input = String::new();
for line in stdin.lock().lines() {
match line {
Ok(l) => {
input.push_str(&l);
input.push('\n');
}
Err(e) => {
let response = PrepareResponse {
node_modules_path: None,
venv_path: None,
job_dir: String::new(),
success: false,
error: Some(format!("Failed to read stdin: {}", e)),
};
println!("{}", serde_json::to_string(&response)?);
return Ok(());
}
}
}
let request: PrepareRequest = match serde_json::from_str(&input) {
Ok(r) => r,
Err(e) => {
let response = PrepareResponse {
node_modules_path: None,
venv_path: None,
job_dir: String::new(),
success: false,
error: Some(format!(
"Failed to parse JSON input: {}. Expected {{\"code\": \"...\", \"language\": \"bun\" or \"python3\"}}",
e
)),
};
println!("{}", serde_json::to_string(&response)?);
return Ok(());
}
};
let response = prepare_deps_standalone(&request.code, &request.language).await;
println!("{}", serde_json::to_string(&response)?);
Ok(())
}
+31
View File
@@ -0,0 +1,31 @@
# Test files
test_*.ts
test_*.py
# Build output
out/
# Documentation
README.md
*.md
# Git
.git
.gitignore
# IDE
.vscode/
.idea/
# Node/Bun
node_modules/
bun.lockb
# Python
__pycache__/
*.pyc
.pytest_cache/
# Docker
Dockerfile
.dockerignore
+64
View File
@@ -0,0 +1,64 @@
# Dockerfile for Windmill DAP Debug Service
#
# This containerizes the unified debug service with support for:
# - TypeScript/Bun debugging
# - Python debugging
# - Automatic dependency installation via windmill CLI
# - Optional nsjail sandboxing
#
# Build:
# docker build -t windmill-debugger .
#
# Run:
# docker run -p 5679:5679 windmill-debugger
#
# With nsjail enabled:
# docker run -p 5679:5679 --privileged windmill-debugger --nsjail
# Stage 1: Get nsjail and windmill from the official windmill image
FROM ghcr.io/windmill-labs/windmill-ee-nsjail:main AS windmill-source
# Stage 2: Build the debug service
FROM oven/bun:1 AS runtime
# Install Python and required system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 \
python3-pip \
python3-venv \
libprotobuf-dev \
libnl-route-3-dev \
&& rm -rf /var/lib/apt/lists/*
# Install Python dependencies for the debug server
RUN pip3 install --break-system-packages websockets debugpy
# Copy nsjail binary and its dependencies from windmill image
COPY --from=windmill-source /bin/nsjail /bin/nsjail
COPY --from=windmill-source /etc/nsjail/ /etc/nsjail/
# Copy windmill binary for prepare-deps functionality
COPY --from=windmill-source /usr/src/app/windmill /usr/local/bin/windmill
# Create app directory
WORKDIR /app
# Copy the debug service files
COPY dap_debug_service.ts .
COPY dap_websocket_server_bun.ts .
COPY dap_websocket_server.py .
# Expose the default port
EXPOSE 5679
# Health check
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD curl -f http://localhost:5679/health || exit 1
# Default environment variables
ENV HOST=0.0.0.0
ENV PORT=5679
# Run the unified debug service with windmill path for autoinstall
ENTRYPOINT ["bun", "run", "dap_debug_service.ts", "--windmill", "/usr/local/bin/windmill"]
CMD ["--host", "0.0.0.0", "--port", "5679"]
+101
View File
@@ -0,0 +1,101 @@
# Windmill Debug Module
A DAP (Debug Adapter Protocol) implementation for debugging Python and TypeScript/Bun scripts in Windmill's Monaco editor.
## Overview
This module provides step-through debugging capabilities with breakpoints, variable inspection, and stack traces. It uses WebSocket communication between the Monaco editor frontend and language-specific debug backends.
## Supported Languages
- **Python** - Uses a bdb-based debugger via `dap_websocket_server.py`
- **TypeScript/Bun** - Uses V8 Inspector Protocol via `dap_websocket_server_bun.ts`
## Architecture
```
┌─────────────────────┐ WebSocket ┌──────────────────────────┐
│ Monaco Editor │◄──────────────────►│ DAP Debug Service │
│ (dapClient.ts) │ DAP Protocol │ (dap_debug_service.ts) │
└─────────────────────┘ └──────────┬───────────────┘
┌──────────┴───────────┐
│ │
┌──────▼──────┐ ┌───────▼───────┐
│ Python │ │ Bun/TS │
│ Debugger │ │ Debugger │
└─────────────┘ └───────────────┘
```
## Files
| File | Description |
|------|-------------|
| `dap_debug_service.ts` | Unified WebSocket server that routes to Python or Bun debuggers |
| `dap_websocket_server.py` | Python debugger backend (bdb-based) |
| `dap_websocket_server_bun.ts` | Bun/TypeScript debugger backend (V8 Inspector) |
| `dapClient.ts` | Client-side DAP WebSocket client with Svelte store |
| `MonacoDebugger.svelte` | Monaco editor integration component |
| `DebugToolbar.svelte` | Debug control buttons (step, continue, etc.) |
| `DebugPanel.svelte` | Variables and stack trace display panel |
| `index.ts` | Module exports |
## Usage
### Starting the Debug Service
```bash
bun run debug/dap_debug_service.ts
```
Options:
- `--port PORT` - Server port (default: 5679)
- `--host HOST` - Server host (default: 0.0.0.0)
- `--python-path PATH` - Python binary path (default: python3)
- `--bun-path PATH` - Bun binary path (default: bun)
- `--nsjail` - Enable nsjail sandboxing for debugger processes
- `--nsjail-config PATH` - Path to nsjail config file
- `--nsjail-path PATH` - Path to nsjail binary (default: nsjail)
### Endpoints
- `/python` - Python debugging
- `/typescript` - TypeScript/Bun debugging
- `/bun` - Alias for `/typescript`
### Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `DAP_PORT` | Server port | 5679 |
| `DAP_HOST` | Server host | 0.0.0.0 |
| `DAP_PYTHON_PATH` | Python binary path | python3 |
| `DAP_BUN_PATH` | Bun binary path | bun |
| `DAP_NSJAIL_ENABLED` | Enable nsjail sandboxing | false |
| `DAP_NSJAIL_PATH` | nsjail binary path | nsjail |
| `DAP_NSJAIL_CONFIG` | nsjail config file path | - |
### Frontend Integration
```svelte
<script>
import { MonacoDebugger } from './debug'
let editor // Monaco editor instance
let code = 'print("Hello")'
</script>
<MonacoDebugger {editor} {code} language="python3" />
```
## Testing
```bash
# Test Python debugger
bun run debug/test_dap_server.py
# Test Bun debugger
bun run debug/test_dap_server_bun.ts
# Test unified service
bun run debug/test_debug_service.ts
```
File diff suppressed because it is too large Load Diff
+962
View File
@@ -0,0 +1,962 @@
#!/usr/bin/env python3
"""
Lightweight DAP (Debug Adapter Protocol) WebSocket Server for Python debugging.
This server acts as a bridge between a WebSocket client (Monaco editor) and Python's
built-in debugging capabilities using the bdb module.
It implements a minimal subset of DAP to support basic Python debugging:
- Setting breakpoints
- Stepping through code (step in, step over, step out, continue)
- Inspecting variables and stack frames
- Evaluating expressions
Usage:
python dap_websocket_server.py [--port PORT] [--host HOST]
"""
import asyncio
import bdb
import json
import linecache
import logging
import os
import subprocess
import sys
import tempfile
import threading
import traceback
from dataclasses import dataclass, field
from enum import Enum
from io import StringIO
from typing import Any
# Configure logging - level will be set based on --debug flag in main()
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger("dap_server")
try:
import websockets
from websockets.server import serve
except ImportError:
print("websockets package required. Install with: pip install websockets")
sys.exit(1)
class DAPMessageType(Enum):
REQUEST = "request"
RESPONSE = "response"
EVENT = "event"
@dataclass
class DAPMessage:
"""Represents a DAP protocol message."""
seq: int
type: str # 'request', 'response', 'event'
command: str = ""
event: str = ""
request_seq: int = 0
success: bool = True
message: str = ""
body: dict = field(default_factory=dict)
def to_dict(self) -> dict:
result = {"seq": self.seq, "type": self.type}
if self.type == "request":
result["command"] = self.command
if self.body:
result["arguments"] = self.body
elif self.type == "response":
result["request_seq"] = self.request_seq
result["command"] = self.command
result["success"] = self.success
if self.message:
result["message"] = self.message
if self.body:
result["body"] = self.body
elif self.type == "event":
result["event"] = self.event
if self.body:
result["body"] = self.body
return result
class WindmillDebugger(bdb.Bdb):
"""A debugger based on Python's bdb module."""
def __init__(self, session: "DebugSession"):
super().__init__()
self.session = session
self.main_thread = threading.current_thread()
self._wait_for_continue = threading.Event()
self._step_mode = None # None, 'over', 'in', 'out'
self._stop_requested = False
self._current_frame = None
self._loop = None
def stop_here(self, frame):
"""Override to only stop when in step mode, not by default."""
# By default, bdb.stop_here returns True when stopframe is None,
# which causes user_line to be called for every line.
# We only want to stop at lines when we're actively stepping.
if self._step_mode is None:
# Not stepping - only stop at breakpoints (handled by break_here in dispatch_line)
return False
return super().stop_here(frame)
def user_line(self, frame):
"""Called when we stop at a line."""
if self._stop_requested:
raise bdb.BdbQuit()
self._current_frame = frame
filename = self.canonic(frame.f_code.co_filename)
lineno = frame.f_lineno
logger.debug(f"user_line called: {filename}:{lineno}, breaks={self.get_all_breaks()}")
# Check if we should stop here
should_stop = False
reason = "step"
# Check breakpoints using bdb's built-in method
if self.break_here(frame):
should_stop = True
reason = "breakpoint"
logger.info(f"Breakpoint HIT at {filename}:{lineno}")
elif self._step_mode == 'in':
should_stop = True
reason = "step"
elif self._step_mode == 'over':
should_stop = True
reason = "step"
elif self._step_mode == 'out':
# Will be handled by user_return
pass
if should_stop:
self._step_mode = None
# Notify the client that we've stopped
asyncio.run_coroutine_threadsafe(
self.session.send_event(
"stopped",
{
"reason": reason,
"threadId": 1,
"allThreadsStopped": True,
},
),
self._loop,
)
# Wait for continue/step command
self._wait_for_continue.clear()
self._wait_for_continue.wait()
if self._stop_requested:
raise bdb.BdbQuit()
def user_return(self, frame, return_value):
"""Called when a return is about to happen."""
if self._step_mode == 'out':
self._step_mode = None
self._current_frame = frame
asyncio.run_coroutine_threadsafe(
self.session.send_event(
"stopped",
{
"reason": "step",
"threadId": 1,
"allThreadsStopped": True,
},
),
self._loop,
)
self._wait_for_continue.clear()
self._wait_for_continue.wait()
def user_exception(self, frame, exc_info):
"""Called when an exception occurs."""
exc_type, exc_value, exc_tb = exc_info
self._current_frame = frame
asyncio.run_coroutine_threadsafe(
self.session.send_event(
"stopped",
{
"reason": "exception",
"threadId": 1,
"allThreadsStopped": True,
"text": str(exc_value),
},
),
self._loop,
)
self._wait_for_continue.clear()
self._wait_for_continue.wait()
def do_continue(self):
"""Continue execution."""
self._step_mode = None
self._wait_for_continue.set()
def do_step_over(self):
"""Step over (next line)."""
self._step_mode = 'over'
self.set_next(self._current_frame)
self._wait_for_continue.set()
def do_step_in(self):
"""Step into."""
self._step_mode = 'in'
self.set_step()
self._wait_for_continue.set()
def do_step_out(self):
"""Step out."""
self._step_mode = 'out'
self.set_return(self._current_frame)
self._wait_for_continue.set()
def do_stop(self):
"""Stop debugging."""
self._stop_requested = True
self._wait_for_continue.set()
def get_stack_frames(self) -> list[dict]:
"""Get current stack frames, stopping at <module> (user's script entry point)."""
frames = []
if self._current_frame is None:
return frames
frame = self._current_frame
frame_id = 1
while frame is not None:
filename = self.canonic(frame.f_code.co_filename)
name = frame.f_code.co_name
frames.append({
"id": frame_id,
"name": name,
"source": {"path": filename, "name": os.path.basename(filename)},
"line": frame.f_lineno,
"column": 0,
})
# Stop at <module> - don't include debugger/threading internals
if name == "<module>":
break
frame = frame.f_back
frame_id += 1
return frames
def get_frame_by_id(self, frame_id: int):
"""Get a frame by its ID."""
frame = self._current_frame
current_id = 1
while frame is not None:
if current_id == frame_id:
return frame
frame = frame.f_back
current_id += 1
return None
def get_locals(self, frame_id: int = 1) -> dict:
"""Get local variables for a frame."""
frame = self.get_frame_by_id(frame_id)
if frame:
return frame.f_locals.copy()
return {}
def get_globals(self, frame_id: int = 1) -> dict:
"""Get global variables for a frame."""
frame = self.get_frame_by_id(frame_id)
if frame:
return frame.f_globals.copy()
return {}
class DebugSession:
"""Manages a single debug session."""
def __init__(self, websocket, windmill_path: str | None = None):
self.websocket = websocket
self.windmill_path = windmill_path
self.seq = 1
self.initialized = False
self.configured = False
self.script_path: str | None = None
self.breakpoints: dict[str, list[int]] = {} # file -> line numbers
self.debug_thread: threading.Thread | None = None
self.debugger: WindmillDebugger | None = None
self._running = True
self._temp_file: str | None = None
self._variables_ref_counter = 1
self._scopes_map: dict[int, dict] = {} # ref -> {type, frame_id}
self._loop = asyncio.get_event_loop()
self._call_main = False
self._main_args: dict = {}
self._venv_path: str | None = None
def next_seq(self) -> int:
seq = self.seq
self.seq += 1
return seq
def prepare_dependencies(self, code: str) -> str | None:
"""
Prepare Python dependencies by calling the windmill CLI.
Returns the path to the venv's site-packages directory, or None if no dependencies needed.
"""
if not self.windmill_path:
logger.info("No windmill binary path configured, skipping dependency preparation")
return None
logger.info(f"Preparing dependencies using {self.windmill_path}")
try:
# Call the windmill CLI with the code
input_data = json.dumps({"code": code, "language": "python3"})
logger.debug(f"prepare-deps input: {input_data[:200]}...")
import time
start_time = time.time()
result = subprocess.run(
[self.windmill_path, "prepare-deps"],
input=input_data,
capture_output=True,
text=True,
timeout=120, # 2 minute timeout for dependency installation
)
elapsed = time.time() - start_time
logger.info(f"prepare-deps completed in {elapsed:.2f}s (exit code: {result.returncode})")
if result.returncode != 0:
logger.error(f"prepare-deps failed (stderr): {result.stderr}")
logger.error(f"prepare-deps failed (stdout): {result.stdout}")
return None
# Log raw output for debugging
logger.debug(f"prepare-deps stdout: {result.stdout[:500] if result.stdout else '(empty)'}")
if result.stderr:
logger.debug(f"prepare-deps stderr: {result.stderr[:500]}")
# Parse the response - may have "Running in standalone mode" prefix
output = result.stdout.strip()
# Find the JSON part (starts with '{')
json_start = output.find('{')
if json_start == -1:
logger.error(f"No JSON in prepare-deps output: {output}")
return None
json_str = output[json_start:]
response = json.loads(json_str)
logger.debug(f"prepare-deps response: {response}")
if not response.get("success"):
logger.error(f"prepare-deps error: {response.get('error')}")
return None
venv_path = response.get("venv_path")
cached = response.get("cached", False)
if venv_path:
if cached:
logger.info(f"Dependencies loaded from cache: {venv_path}")
else:
logger.info(f"Dependencies freshly installed at: {venv_path}")
else:
logger.info("No external dependencies detected in code")
return venv_path
except subprocess.TimeoutExpired:
logger.error("prepare-deps timed out after 120s")
return None
except json.JSONDecodeError as e:
logger.error(f"Failed to parse prepare-deps JSON output: {e}")
logger.error(f"Raw output was: {output[:500] if 'output' in dir() else '(not available)'}")
return None
except Exception as e:
logger.exception(f"Error preparing dependencies: {e}")
return None
def _next_var_ref(self) -> int:
ref = self._variables_ref_counter
self._variables_ref_counter += 1
return ref
async def send_message(self, msg: DAPMessage) -> None:
"""Send a DAP message to the client."""
data = json.dumps(msg.to_dict())
logger.debug(f"Sending: {data}")
await self.websocket.send(data)
async def send_response(
self,
request: dict,
success: bool = True,
body: dict | None = None,
message: str = "",
) -> None:
"""Send a response to a request."""
msg = DAPMessage(
seq=self.next_seq(),
type="response",
command=request.get("command", ""),
request_seq=request.get("seq", 0),
success=success,
message=message,
body=body or {},
)
await self.send_message(msg)
async def send_event(self, event: str, body: dict | None = None) -> None:
"""Send an event to the client."""
msg = DAPMessage(
seq=self.next_seq(),
type="event",
event=event,
body=body or {},
)
await self.send_message(msg)
async def handle_initialize(self, request: dict) -> None:
"""Handle the 'initialize' request."""
capabilities = {
"supportsConfigurationDoneRequest": True,
"supportsFunctionBreakpoints": False,
"supportsConditionalBreakpoints": False,
"supportsHitConditionalBreakpoints": False,
"supportsEvaluateForHovers": True,
"exceptionBreakpointFilters": [],
"supportsStepBack": False,
"supportsSetVariable": False,
"supportsRestartFrame": False,
"supportsGotoTargetsRequest": False,
"supportsStepInTargetsRequest": False,
"supportsCompletionsRequest": False,
"supportsModulesRequest": False,
"supportsExceptionOptions": False,
"supportsValueFormattingOptions": False,
"supportsExceptionInfoRequest": False,
"supportTerminateDebuggee": True,
"supportsDelayedStackTraceLoading": False,
"supportsLoadedSourcesRequest": False,
"supportsLogPoints": False,
"supportsTerminateThreadsRequest": False,
"supportsSetExpression": False,
"supportsTerminateRequest": True,
"supportsDataBreakpoints": False,
"supportsReadMemoryRequest": False,
"supportsDisassembleRequest": False,
"supportsCancelRequest": False,
"supportsBreakpointLocationsRequest": False,
}
await self.send_response(request, body=capabilities)
self.initialized = True
await self.send_event("initialized")
async def handle_set_breakpoints(self, request: dict) -> None:
"""Handle the 'setBreakpoints' request."""
args = request.get("arguments", {})
source = args.get("source", {})
source_path = source.get("path", "")
breakpoints_data = args.get("breakpoints", [])
verified_breakpoints = []
line_numbers = []
for bp in breakpoints_data:
line = bp.get("line", 0)
line_numbers.append(line)
verified_breakpoints.append(
{
"id": len(verified_breakpoints) + 1,
"verified": True,
"line": line,
"source": source,
}
)
# Store breakpoints - they'll be applied when launch is called
self.breakpoints[source_path] = line_numbers
logger.info(f"Stored breakpoints at lines {line_numbers} for {source_path}")
# If debugger already exists and we have a script path, update breakpoints now
if self.debugger and self.script_path:
self.debugger.clear_all_breaks()
for line in line_numbers:
self.debugger.set_break(self.script_path, line)
logger.info(f"Updated breakpoint at {self.script_path}:{line}")
await self.send_response(request, body={"breakpoints": verified_breakpoints})
async def handle_configuration_done(self, request: dict) -> None:
"""Handle the 'configurationDone' request."""
self.configured = True
await self.send_response(request)
async def handle_launch(self, request: dict) -> None:
"""Handle the 'launch' request."""
args = request.get("arguments", {})
self.script_path = args.get("program")
code = args.get("code", "")
cwd = args.get("cwd", os.getcwd())
self._call_main = args.get("callMain", False)
self._main_args = args.get("args", {})
self._env_vars = args.get("env", {})
if self._env_vars:
logger.info(f"Launch with env vars: {list(self._env_vars.keys())}")
if not self.script_path and not code:
await self.send_response(
request, success=False, message="No program or code specified"
)
return
# Prepare dependencies before modifying the code
if code:
self._venv_path = self.prepare_dependencies(code)
# If callMain is True, append a call to main() with the provided args
if self._call_main and code:
# Generate the main() call with kwargs
args_str = ", ".join(f"{k}={repr(v)}" for k, v in self._main_args.items())
code = code + f"\n\n# Auto-generated call to main entrypoint\n__windmill_result__ = main({args_str})\n"
logger.info(f"Added main() call with args: {args_str}")
# If code is provided, write it to a temp file
if code and not self.script_path:
fd, self._temp_file = tempfile.mkstemp(suffix=".py", prefix="windmill_debug_")
with os.fdopen(fd, "w") as f:
f.write(code)
self.script_path = self._temp_file
await self.send_response(request)
# Create debugger
self.debugger = WindmillDebugger(self)
self.debugger._loop = self._loop
# Set breakpoints in the debugger using the actual script path
# (breakpoints from frontend may use a different path like /tmp/script.py)
self.debugger.clear_all_breaks()
canonical_path = self.debugger.canonic(self.script_path)
logger.info(f"Script path: {self.script_path}, canonical: {canonical_path}")
logger.info(f"Stored breakpoints from frontend: {self.breakpoints}")
for file_path, lines in self.breakpoints.items():
logger.info(f"Processing breakpoints for frontend path '{file_path}': lines {lines}")
for line in lines:
# Use the actual script path, not the frontend path
error = self.debugger.set_break(self.script_path, line)
if error:
logger.error(f"Failed to set breakpoint at {self.script_path}:{line}: {error}")
else:
logger.info(f"Set breakpoint at {self.script_path}:{line}")
# Log all registered breakpoints for debugging
logger.info(f"Debugger breaks after setup: {self.debugger.get_all_breaks()}")
# Start debugging in a separate thread
self.debug_thread = threading.Thread(
target=self._run_script,
args=(self.script_path, cwd),
daemon=True,
)
self.debug_thread.start()
def _run_script(self, script_path: str, cwd: str) -> None:
"""Run the script with the debugger."""
old_cwd = os.getcwd()
old_argv = sys.argv
old_stdout = sys.stdout
old_stderr = sys.stderr
old_env = {}
old_sys_path = sys.path.copy()
# Add venv site-packages to sys.path if dependencies were prepared
if self._venv_path:
sys.path.insert(0, self._venv_path)
logger.info(f"Added {self._venv_path} to sys.path")
# Set environment variables for the script
if hasattr(self, '_env_vars') and self._env_vars:
for key, value in self._env_vars.items():
old_env[key] = os.environ.get(key)
os.environ[key] = str(value)
logger.info(f"Set {len(self._env_vars)} env vars for script")
# Create a streaming output wrapper that sends output events in real-time
session = self
loop = self._loop
class StreamingOutput:
def __init__(self, category: str):
self.category = category
self.buffer = ""
def write(self, data: str) -> int:
if data:
# Send output event immediately
asyncio.run_coroutine_threadsafe(
session.send_event("output", {"category": self.category, "output": data}),
loop,
)
return len(data)
def flush(self):
pass
streaming_stdout = StreamingOutput("stdout")
streaming_stderr = StreamingOutput("stderr")
try:
os.chdir(cwd)
sys.argv = [script_path]
sys.stdout = streaming_stdout
sys.stderr = streaming_stderr
# Read and compile the script
with open(script_path) as f:
code = f.read()
logger.info(f"Running script: {script_path}")
logger.info(f"Script content ({len(code)} chars):\n{code[:500]}...")
# Clear linecache to ensure fresh source
linecache.checkcache(script_path)
compiled = compile(code, script_path, "exec")
logger.info(f"Compiled code filename: {compiled.co_filename}")
# Create globals
globals_dict = {
"__name__": "__main__",
"__file__": script_path,
"__builtins__": __builtins__,
}
# Run with debugger
logger.info(f"Starting debugger.run() with breaks: {self.debugger.get_all_breaks()}")
self.debugger.run(compiled, globals_dict)
logger.info("debugger.run() completed normally")
# Script completed normally - get the result from main()
result = globals_dict.get("__windmill_result__")
logger.info(f"Script result: {result}")
asyncio.run_coroutine_threadsafe(
self.send_event("terminated", {"result": result}),
self._loop,
)
except bdb.BdbQuit:
# Normal termination via stop
asyncio.run_coroutine_threadsafe(
self.send_event("terminated"),
self._loop,
)
except Exception as e:
error_msg = traceback.format_exc()
logger.exception("Error running script")
asyncio.run_coroutine_threadsafe(
self.send_event("output", {"category": "stderr", "output": error_msg}),
self._loop,
)
asyncio.run_coroutine_threadsafe(
self.send_event("terminated", {"error": str(e)}),
self._loop,
)
finally:
os.chdir(old_cwd)
sys.argv = old_argv
sys.stdout = old_stdout
sys.stderr = old_stderr
sys.path = old_sys_path
# Restore environment variables
for key, old_value in old_env.items():
if old_value is None:
os.environ.pop(key, None)
else:
os.environ[key] = old_value
self._cleanup_temp_file()
def _cleanup_temp_file(self) -> None:
"""Clean up temporary file if created."""
if self._temp_file and os.path.exists(self._temp_file):
try:
os.unlink(self._temp_file)
except OSError:
pass
self._temp_file = None
async def handle_threads(self, request: dict) -> None:
"""Handle the 'threads' request."""
threads = [{"id": 1, "name": "MainThread"}]
await self.send_response(request, body={"threads": threads})
async def handle_stack_trace(self, request: dict) -> None:
"""Handle the 'stackTrace' request."""
if self.debugger:
stack_frames = self.debugger.get_stack_frames()
else:
stack_frames = []
await self.send_response(
request, body={"stackFrames": stack_frames, "totalFrames": len(stack_frames)}
)
async def handle_scopes(self, request: dict) -> None:
"""Handle the 'scopes' request."""
frame_id = request.get("arguments", {}).get("frameId", 1)
# Create scope references
local_ref = self._next_var_ref()
global_ref = self._next_var_ref()
self._scopes_map[local_ref] = {"type": "locals", "frame_id": frame_id}
self._scopes_map[global_ref] = {"type": "globals", "frame_id": frame_id}
scopes = [
{
"name": "Locals",
"variablesReference": local_ref,
"expensive": False,
},
{
"name": "Globals",
"variablesReference": global_ref,
"expensive": True,
},
]
await self.send_response(request, body={"scopes": scopes})
async def handle_variables(self, request: dict) -> None:
"""Handle the 'variables' request."""
variables_ref = request.get("arguments", {}).get("variablesReference", 0)
variables = []
scope_info = self._scopes_map.get(variables_ref)
if scope_info and self.debugger:
frame_id = scope_info["frame_id"]
if scope_info["type"] == "locals":
var_dict = self.debugger.get_locals(frame_id)
else:
var_dict = self.debugger.get_globals(frame_id)
for name, value in var_dict.items():
# Skip private/magic attributes for globals
if scope_info["type"] == "globals" and name.startswith("_"):
continue
try:
value_str = repr(value)
if len(value_str) > 100:
value_str = value_str[:97] + "..."
variables.append({
"name": name,
"value": value_str,
"type": type(value).__name__,
"variablesReference": 0,
})
except Exception:
variables.append({
"name": name,
"value": "<error getting value>",
"type": "unknown",
"variablesReference": 0,
})
await self.send_response(request, body={"variables": variables})
async def handle_evaluate(self, request: dict) -> None:
"""Handle the 'evaluate' request."""
args = request.get("arguments", {})
expression = args.get("expression", "")
frame_id = args.get("frameId", 1)
try:
if self.debugger:
frame = self.debugger.get_frame_by_id(frame_id)
if frame:
result = eval(expression, frame.f_globals, frame.f_locals)
result_str = repr(result)
else:
result_str = "<no frame>"
else:
result_str = eval(expression)
result_str = repr(result_str)
await self.send_response(
request,
body={
"result": result_str,
"variablesReference": 0,
},
)
except Exception as e:
await self.send_response(
request,
body={
"result": f"Error: {e}",
"variablesReference": 0,
},
)
async def handle_continue(self, request: dict) -> None:
"""Handle the 'continue' request."""
if self.debugger:
self.debugger.do_continue()
await self.send_response(request, body={"allThreadsContinued": True})
async def handle_next(self, request: dict) -> None:
"""Handle the 'next' (step over) request."""
if self.debugger:
self.debugger.do_step_over()
await self.send_response(request)
async def handle_step_in(self, request: dict) -> None:
"""Handle the 'stepIn' request."""
if self.debugger:
self.debugger.do_step_in()
await self.send_response(request)
async def handle_step_out(self, request: dict) -> None:
"""Handle the 'stepOut' request."""
if self.debugger:
self.debugger.do_step_out()
await self.send_response(request)
async def handle_pause(self, request: dict) -> None:
"""Handle the 'pause' request."""
await self.send_response(request)
await self.send_event(
"stopped",
{
"reason": "pause",
"threadId": 1,
"allThreadsStopped": True,
},
)
async def handle_disconnect(self, request: dict) -> None:
"""Handle the 'disconnect' request."""
self._running = False
if self.debugger:
self.debugger.do_stop()
self._cleanup_temp_file()
await self.send_response(request)
async def handle_terminate(self, request: dict) -> None:
"""Handle the 'terminate' request."""
self._running = False
if self.debugger:
self.debugger.do_stop()
self._cleanup_temp_file()
await self.send_response(request)
await self.send_event("terminated")
async def handle_request(self, request: dict) -> None:
"""Route and handle a DAP request."""
command = request.get("command", "")
logger.debug(f"Handling command: {command}")
handlers = {
"initialize": self.handle_initialize,
"setBreakpoints": self.handle_set_breakpoints,
"configurationDone": self.handle_configuration_done,
"launch": self.handle_launch,
"threads": self.handle_threads,
"stackTrace": self.handle_stack_trace,
"scopes": self.handle_scopes,
"variables": self.handle_variables,
"evaluate": self.handle_evaluate,
"continue": self.handle_continue,
"next": self.handle_next,
"stepIn": self.handle_step_in,
"stepOut": self.handle_step_out,
"pause": self.handle_pause,
"disconnect": self.handle_disconnect,
"terminate": self.handle_terminate,
}
handler = handlers.get(command)
if handler:
await handler(request)
else:
logger.warning(f"Unhandled command: {command}")
await self.send_response(
request, success=False, message=f"Unsupported command: {command}"
)
# Module-level variable to store windmill binary path
_windmill_path: str | None = None
async def handle_connection(websocket) -> None:
"""Handle a WebSocket connection."""
session = DebugSession(websocket, windmill_path=_windmill_path)
logger.info(f"New connection from {websocket.remote_address}")
try:
async for message in websocket:
try:
data = json.loads(message)
logger.debug(f"Received: {data}")
if data.get("type") == "request":
await session.handle_request(data)
except json.JSONDecodeError as e:
logger.error(f"Invalid JSON: {e}")
except Exception as e:
logger.exception(f"Error handling message: {e}")
except websockets.exceptions.ConnectionClosed:
logger.info("Connection closed")
finally:
if session.debugger:
session.debugger.do_stop()
session._cleanup_temp_file()
async def main(host: str = "localhost", port: int = 5679, windmill_path: str | None = None) -> None:
"""Start the DAP WebSocket server."""
global _windmill_path
_windmill_path = windmill_path
if windmill_path:
logger.info(f"Windmill binary path: {windmill_path}")
logger.info(f"Starting DAP WebSocket server on ws://{host}:{port}")
async with serve(handle_connection, host, port):
await asyncio.Future() # Run forever
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="DAP WebSocket Server for Python debugging")
parser.add_argument("--host", default="localhost", help="Host to bind to")
parser.add_argument("--port", type=int, default=5679, help="Port to listen on")
parser.add_argument("--windmill", help="Path to windmill binary for dependency preparation (or set WINDMILL_PATH env var)")
parser.add_argument("--debug", action="store_true", help="Enable debug logging")
args = parser.parse_args()
# Set logging level based on --debug flag
if args.debug:
logging.getLogger().setLevel(logging.DEBUG)
logger.setLevel(logging.DEBUG)
logger.debug("Debug logging enabled")
# Use --windmill arg, or fall back to WINDMILL_PATH env var
windmill_path = args.windmill or os.environ.get("WINDMILL_PATH")
try:
asyncio.run(main(args.host, args.port, windmill_path))
except KeyboardInterrupt:
logger.info("Server stopped")
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+540
View File
@@ -0,0 +1,540 @@
#!/usr/bin/env python3
"""
Test script for the DAP WebSocket server.
This script connects to the server and tests breakpoint functionality.
Run the server first:
python dap_websocket_server.py
Then run this test:
python test_dap_server.py
"""
import asyncio
import json
import sys
try:
import websockets
except ImportError:
print("websockets package required. Install with: pip install websockets")
sys.exit(1)
# Test Python script with known breakpoints
TEST_SCRIPT = """
x = 1
y = 2
z = x + y
print(f"Result: {z}")
w = z * 2
print(f"Final: {w}")
"""
# Line numbers where we want to set breakpoints (1-indexed)
BREAKPOINT_LINES = [3, 5] # z = x + y, w = z * 2
# Test script with main() function (Windmill style)
TEST_SCRIPT_WITH_MAIN = """
def main(x: str, count: int = 1):
print(f"Starting with x={x}, count={count}")
result = x * count
print(f"Result: {result}")
return result
"""
# Breakpoints for the main() test: lines 3 and 4 (inside main function)
MAIN_BREAKPOINT_LINES = [3, 4]
class DAPTestClient:
def __init__(self, url: str = "ws://localhost:5679"):
self.url = url
self.ws = None
self.seq = 1
self.pending_requests: dict[int, asyncio.Future] = {}
self.events: list[dict] = []
self.stopped_events: list[dict] = []
async def connect(self):
print(f"Connecting to {self.url}...")
self.ws = await websockets.connect(self.url)
print("Connected!")
# Start message receiver
asyncio.create_task(self._receive_messages())
async def disconnect(self):
if self.ws:
await self.ws.close()
async def _receive_messages(self):
try:
async for message in self.ws:
data = json.loads(message)
print(f"<-- Received: {json.dumps(data, indent=2)}")
if data.get("type") == "response":
req_seq = data.get("request_seq")
if req_seq in self.pending_requests:
self.pending_requests[req_seq].set_result(data)
elif data.get("type") == "event":
self.events.append(data)
if data.get("event") == "stopped":
self.stopped_events.append(data)
except websockets.exceptions.ConnectionClosed:
print("Connection closed")
async def send_request(self, command: str, arguments: dict = None) -> dict:
seq = self.seq
self.seq += 1
request = {
"seq": seq,
"type": "request",
"command": command,
}
if arguments:
request["arguments"] = arguments
future = asyncio.Future()
self.pending_requests[seq] = future
print(f"--> Sending: {json.dumps(request, indent=2)}")
await self.ws.send(json.dumps(request))
# Wait for response with timeout
try:
response = await asyncio.wait_for(future, timeout=10.0)
return response
except asyncio.TimeoutError:
print(f"Timeout waiting for response to {command}")
raise
async def initialize(self) -> dict:
return await self.send_request("initialize", {
"clientID": "test",
"clientName": "DAP Test Client",
"adapterID": "python",
"pathFormat": "path",
"linesStartAt1": True,
"columnsStartAt1": True,
})
async def set_breakpoints(self, path: str, lines: list[int]) -> dict:
return await self.send_request("setBreakpoints", {
"source": {"path": path},
"breakpoints": [{"line": line} for line in lines],
})
async def configuration_done(self) -> dict:
return await self.send_request("configurationDone")
async def launch(self, code: str, cwd: str = "/tmp", call_main: bool = False, args: dict = None) -> dict:
return await self.send_request("launch", {
"code": code,
"cwd": cwd,
"callMain": call_main,
"args": args or {},
})
async def continue_(self) -> dict:
return await self.send_request("continue", {"threadId": 1})
async def get_stack_trace(self) -> dict:
return await self.send_request("stackTrace", {
"threadId": 1,
"startFrame": 0,
"levels": 20,
})
async def get_scopes(self, frame_id: int) -> dict:
return await self.send_request("scopes", {"frameId": frame_id})
async def get_variables(self, var_ref: int) -> dict:
return await self.send_request("variables", {"variablesReference": var_ref})
async def terminate(self) -> dict:
return await self.send_request("terminate")
async def wait_for_stopped(self, timeout: float = 5.0) -> dict:
"""Wait for a stopped event."""
start = len(self.stopped_events)
for _ in range(int(timeout * 10)):
if len(self.stopped_events) > start:
return self.stopped_events[-1]
await asyncio.sleep(0.1)
raise TimeoutError("No stopped event received")
async def wait_for_event(self, event_name: str, timeout: float = 5.0) -> dict:
"""Wait for a specific event."""
start = len(self.events)
for _ in range(int(timeout * 10)):
for event in self.events[start:]:
if event.get("event") == event_name:
return event
await asyncio.sleep(0.1)
raise TimeoutError(f"No {event_name} event received")
async def run_test():
client = DAPTestClient()
try:
await client.connect()
await asyncio.sleep(0.1) # Let receiver start
# 1. Initialize
print("\n=== STEP 1: Initialize ===")
response = await client.initialize()
assert response.get("success"), f"Initialize failed: {response}"
print("Initialize: OK")
# Wait for initialized event
await asyncio.sleep(0.5)
# 2. Set breakpoints
print("\n=== STEP 2: Set Breakpoints ===")
response = await client.set_breakpoints("/tmp/script.py", BREAKPOINT_LINES)
assert response.get("success"), f"setBreakpoints failed: {response}"
breakpoints = response.get("body", {}).get("breakpoints", [])
print(f"Breakpoints set: {breakpoints}")
assert len(breakpoints) == len(BREAKPOINT_LINES), "Wrong number of breakpoints"
# 3. Configuration done
print("\n=== STEP 3: Configuration Done ===")
response = await client.configuration_done()
assert response.get("success"), f"configurationDone failed: {response}"
print("Configuration done: OK")
# 4. Launch
print("\n=== STEP 4: Launch ===")
response = await client.launch(TEST_SCRIPT)
assert response.get("success"), f"launch failed: {response}"
print("Launch: OK")
# 5. Wait for first breakpoint
print("\n=== STEP 5: Wait for First Breakpoint ===")
try:
stopped = await client.wait_for_stopped(timeout=5.0)
print(f"Stopped at: {stopped}")
reason = stopped.get("body", {}).get("reason")
print(f"Stop reason: {reason}")
if reason == "breakpoint":
print("SUCCESS: Hit first breakpoint!")
else:
print(f"WARNING: Stopped for reason '{reason}', not 'breakpoint'")
# Get stack trace
print("\n=== STEP 6: Get Stack Trace ===")
stack_response = await client.get_stack_trace()
frames = stack_response.get("body", {}).get("stackFrames", [])
if frames:
current_line = frames[0].get("line")
print(f"Current line: {current_line}")
if current_line in BREAKPOINT_LINES:
print(f"SUCCESS: Stopped at expected line {current_line}")
else:
print(f"WARNING: Stopped at line {current_line}, expected one of {BREAKPOINT_LINES}")
# Get variables
print("\n=== STEP 7: Get Variables ===")
scopes_response = await client.get_scopes(frames[0]["id"])
scopes = scopes_response.get("body", {}).get("scopes", [])
print(f"Scopes: {[s['name'] for s in scopes]}")
if scopes:
vars_response = await client.get_variables(scopes[0]["variablesReference"])
variables = vars_response.get("body", {}).get("variables", [])
print(f"Local variables: {[(v['name'], v['value']) for v in variables]}")
# Continue to next breakpoint
print("\n=== STEP 8: Continue to Next Breakpoint ===")
await client.continue_()
try:
stopped = await client.wait_for_stopped(timeout=5.0)
print(f"Stopped again at: {stopped}")
stack_response = await client.get_stack_trace()
frames = stack_response.get("body", {}).get("stackFrames", [])
if frames:
current_line = frames[0].get("line")
print(f"Current line: {current_line}")
if current_line in BREAKPOINT_LINES:
print(f"SUCCESS: Hit second breakpoint at line {current_line}!")
else:
print(f"INFO: Stopped at line {current_line}")
# Continue to end
print("\n=== STEP 9: Continue to End ===")
await client.continue_()
# Wait for terminated event
try:
await client.wait_for_event("terminated", timeout=5.0)
print("Script terminated normally")
except TimeoutError:
print("Timeout waiting for terminated event")
except TimeoutError:
print("No second breakpoint hit - script may have ended")
except TimeoutError:
print("ERROR: No breakpoint was hit!")
print("The script ran without stopping at breakpoints.")
print("\nCheck server logs for:")
print(" - 'Set breakpoint at' messages")
print(" - 'user_line called' messages")
print(" - 'Breakpoint HIT' messages")
# Terminate
print("\n=== STEP 10: Terminate ===")
try:
await client.terminate()
print("Terminated: OK")
except Exception as e:
print(f"Terminate error (may be expected): {e}")
except Exception as e:
print(f"\nERROR: {e}")
import traceback
traceback.print_exc()
finally:
await client.disconnect()
print("\n=== TEST COMPLETE ===")
async def run_main_test():
"""Test debugging a script with main() function (Windmill style)."""
client = DAPTestClient()
try:
await client.connect()
await asyncio.sleep(0.1)
# 1. Initialize
print("\n=== MAIN TEST: Initialize ===")
response = await client.initialize()
assert response.get("success"), f"Initialize failed: {response}"
print("Initialize: OK")
await asyncio.sleep(0.5)
# 2. Set breakpoints inside main()
print("\n=== MAIN TEST: Set Breakpoints ===")
response = await client.set_breakpoints("/tmp/script.py", MAIN_BREAKPOINT_LINES)
assert response.get("success"), f"setBreakpoints failed: {response}"
print(f"Breakpoints set at lines: {MAIN_BREAKPOINT_LINES}")
# 3. Configuration done
print("\n=== MAIN TEST: Configuration Done ===")
response = await client.configuration_done()
assert response.get("success"), f"configurationDone failed: {response}"
# 4. Launch with callMain=True and args
print("\n=== MAIN TEST: Launch with callMain=True ===")
test_args = {"x": "hello", "count": 3}
response = await client.launch(
TEST_SCRIPT_WITH_MAIN,
call_main=True,
args=test_args
)
assert response.get("success"), f"launch failed: {response}"
print(f"Launch with args {test_args}: OK")
# 5. Wait for breakpoint inside main()
print("\n=== MAIN TEST: Wait for Breakpoint in main() ===")
try:
stopped = await client.wait_for_stopped(timeout=5.0)
reason = stopped.get("body", {}).get("reason")
print(f"Stopped! Reason: {reason}")
# Get stack trace
stack_response = await client.get_stack_trace()
frames = stack_response.get("body", {}).get("stackFrames", [])
if frames:
current_line = frames[0].get("line")
func_name = frames[0].get("name")
print(f"Current location: {func_name}() at line {current_line}")
if func_name == "main":
print("SUCCESS: Stopped inside main() function!")
else:
print(f"INFO: Stopped in function '{func_name}'")
# Get local variables to verify args were passed
scopes_response = await client.get_scopes(frames[0]["id"])
scopes = scopes_response.get("body", {}).get("scopes", [])
if scopes:
vars_response = await client.get_variables(scopes[0]["variablesReference"])
variables = vars_response.get("body", {}).get("variables", [])
var_dict = {v["name"]: v["value"] for v in variables}
print(f"Variables: {var_dict}")
# Check if our args are present
if "x" in var_dict and "count" in var_dict:
print(f"SUCCESS: Args passed correctly! x={var_dict['x']}, count={var_dict['count']}")
# Continue to end
print("\n=== MAIN TEST: Continue to End ===")
await client.continue_()
# May hit another breakpoint or end
try:
stopped = await client.wait_for_stopped(timeout=2.0)
print(f"Hit another breakpoint")
await client.continue_()
except TimeoutError:
pass
# Wait for output/terminated
await asyncio.sleep(1.0)
for event in client.events:
if event.get("event") == "output":
output = event.get("body", {}).get("output", "")
print(f"Script output: {output}")
except TimeoutError:
print("ERROR: No breakpoint hit inside main()!")
# Terminate
print("\n=== MAIN TEST: Terminate ===")
try:
await client.terminate()
print("Terminated: OK")
except Exception as e:
print(f"Terminate: {e}")
except Exception as e:
print(f"\nERROR: {e}")
import traceback
traceback.print_exc()
finally:
await client.disconnect()
print("\n=== MAIN TEST COMPLETE ===")
# Test script with external import (requests)
TEST_SCRIPT_WITH_IMPORT = """
import requests
def main(url: str):
response = requests.get(url)
status = response.status_code
print(f"Status: {status}")
return {"status": status, "ok": response.ok}
"""
async def run_import_test():
"""Test that external dependencies are automatically installed."""
print("\n" + "=" * 60)
print("DYNAMIC IMPORT TEST")
print("=" * 60)
print("\nThis test verifies that external pip packages are automatically installed.")
print("Make sure the server is started with: --windmill /path/to/windmill\n")
client = DAPTestClient()
try:
await client.connect()
# Initialize
print("\n=== IMPORT TEST: Initialize ===")
init_response = await client.initialize()
if init_response.get("success"):
print("Initialize: OK")
else:
print(f"Initialize: FAILED - {init_response}")
return
# Wait for initialized event
await asyncio.sleep(0.5)
# Launch with code that uses requests
print("\n=== IMPORT TEST: Launch with requests import ===")
launch_response = await client.launch(
TEST_SCRIPT_WITH_IMPORT,
call_main=True,
args={"url": "https://httpbin.org/get"}
)
if launch_response.get("success"):
print("Launch: OK")
else:
print(f"Launch: FAILED - {launch_response}")
return
# Wait for script to run and complete
print("\n=== IMPORT TEST: Waiting for completion ===")
terminated = False
result = None
timeout = 30 # 30 seconds for dependency installation + execution
for _ in range(timeout * 10): # Check every 100ms
await asyncio.sleep(0.1)
for event in client.events:
if event.get("event") == "terminated":
terminated = True
result = event.get("body", {}).get("result")
break
if terminated:
break
if not terminated:
print("ERROR: Script did not terminate in time!")
return
# Check result
print(f"\n=== IMPORT TEST: Result ===")
print(f"Result: {result}")
if result and result.get("status") == 200 and result.get("ok") is True:
print("\nSUCCESS: External package (requests) was installed and worked correctly!")
else:
print(f"\nFAILED: Unexpected result - {result}")
# Check output
print("\n=== IMPORT TEST: Console Output ===")
for event in client.events:
if event.get("event") == "output":
output = event.get("body", {}).get("output", "")
print(f" {output.strip()}")
# Terminate
print("\n=== IMPORT TEST: Terminate ===")
try:
await client.terminate()
print("Terminated: OK")
except Exception:
pass # May already be terminated
except Exception as e:
print(f"\nERROR: {e}")
import traceback
traceback.print_exc()
finally:
await client.disconnect()
print("\n=== IMPORT TEST COMPLETE ===")
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("--main", action="store_true", help="Run main() function test")
parser.add_argument("--imports", action="store_true", help="Run dynamic imports test")
parser.add_argument("--all", action="store_true", help="Run all tests")
args = parser.parse_args()
if args.main:
asyncio.run(run_main_test())
elif args.imports:
asyncio.run(run_import_test())
elif args.all:
asyncio.run(run_test())
print("\n" + "=" * 60 + "\n")
asyncio.run(run_main_test())
print("\n" + "=" * 60 + "\n")
asyncio.run(run_import_test())
else:
asyncio.run(run_test())
+767
View File
@@ -0,0 +1,767 @@
#!/usr/bin/env bun
/**
* Test script for the Bun DAP WebSocket server.
*
* Tests breakpoints on every line, variable inspection, and console output.
*
* Run the server first:
* bun run dap_websocket_server_bun.ts
*
* Then run this test:
* bun run test_dap_server_bun.ts
*/
// Test script with module-level code and a main function
const TEST_SCRIPT = `let foo = 42
console.log("A") // Line 1 - module level
console.log("B") // Line 2 - module level
export async function main(x: string) {
let qwe = "xaweqw"
let foobar = 312312
console.log("A")
console.log("B")
return x
}`
// All executable lines (skipping empty line 4 and function signature line 5)
// Line numbers are 1-indexed as they appear in the source
const BREAKPOINT_LINES = [1, 2, 3, 6, 7, 8, 9, 10]
// Expected variables at each line (variable should be visible AFTER the line executes)
// For breakpoints, we're stopped BEFORE the line executes, so:
// - Line 1: `let foo = 42` - foo not yet assigned
// - Line 2: after foo assignment, foo=42 (NOTE: module-level `let` not captured by Bun inspector)
// - Line 3: still foo=42 (NOTE: module-level `let` not captured by Bun inspector)
// - Line 6: inside main, x is parameter
// - Line 7: x, qwe assigned
// - Line 8: x, qwe, foobar assigned
// - Line 9: x, qwe, foobar still there
// - Line 10: x, qwe, foobar, about to return
//
// KNOWN LIMITATION: Bun's WebKit inspector does not expose module-level `let`/`const`
// declarations in the scope chain. Only variables inside functions are visible.
const EXPECTED_VARIABLES: Record<number, string[]> = {
1: [], // stopped before foo assignment
2: [], // NOTE: foo not visible in Bun's inspector for module-level let
3: [], // NOTE: foo not visible in Bun's inspector for module-level let
6: ['x'], // inside main, x is parameter
7: ['x', 'qwe'], // qwe assigned
8: ['x', 'qwe', 'foobar'], // foobar assigned
9: ['x', 'qwe', 'foobar'], // same
10: ['x', 'qwe', 'foobar'] // same
}
// Expected console output order
const EXPECTED_LOGS = ['A', 'B', 'A', 'B']
// Test args
const TEST_ARGS = { x: 'foobar' }
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>
}
interface Variable {
name: string
value: string
type?: string
variablesReference: number
}
interface StackFrame {
id: number
name: string
line: number
column: number
source?: { path: string; name?: string }
}
interface Scope {
name: string
variablesReference: number
expensive: boolean
}
class DAPTestClient {
private url: string
private ws: WebSocket | null = null
private seq = 1
private pendingRequests = new Map<
number,
{ resolve: (value: DAPMessage) => void; reject: (error: Error) => void }
>()
private events: DAPMessage[] = []
private output: string[] = []
private eventHandlers: Map<string, ((event: DAPMessage) => void)[]> = new Map()
private terminatedResult: unknown = undefined
constructor(url = 'ws://localhost:5680') {
this.url = url
}
async connect(): Promise<void> {
console.log(`[TEST] Connecting to ${this.url}...`)
return new Promise((resolve, reject) => {
this.ws = new WebSocket(this.url)
this.ws.onopen = () => {
console.log('[TEST] Connected!')
resolve()
}
this.ws.onerror = (error) => {
console.error('[TEST] WebSocket error:', error)
reject(new Error('WebSocket connection failed'))
}
this.ws.onmessage = (event) => {
this.handleMessage(event.data as string)
}
this.ws.onclose = () => {
console.log('[TEST] WebSocket closed')
}
})
}
disconnect(): void {
if (this.ws) {
this.ws.close()
this.ws = null
}
}
private handleMessage(data: string): void {
try {
const msg = JSON.parse(data) as DAPMessage
if (msg.type === 'response') {
const reqSeq = msg.request_seq
if (reqSeq !== undefined && this.pendingRequests.has(reqSeq)) {
const pending = this.pendingRequests.get(reqSeq)!
this.pendingRequests.delete(reqSeq)
if (msg.success) {
pending.resolve(msg)
} else {
pending.reject(new Error(msg.message || 'Request failed'))
}
}
} else if (msg.type === 'event') {
console.log(`[TEST] Event: ${msg.event}`, JSON.stringify(msg.body))
this.events.push(msg)
// Capture output
if (msg.event === 'output' && msg.body?.output) {
const outputStr = String(msg.body.output).trim()
if (outputStr) {
this.output.push(outputStr)
}
}
// Capture result from terminated event
if (msg.event === 'terminated' && msg.body?.result !== undefined) {
this.terminatedResult = msg.body.result
}
// Notify handlers
const handlers = this.eventHandlers.get(msg.event!) || []
for (const handler of handlers) {
handler(msg)
}
}
} catch (error) {
console.error('[TEST] Failed to parse message:', error)
}
}
private async sendRequest(
command: string,
args?: Record<string, unknown>
): Promise<DAPMessage> {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
throw new Error('Not connected')
}
const seq = this.seq++
const request: 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}`))
}, 15000)
this.pendingRequests.set(seq, {
resolve: (value) => {
clearTimeout(timeout)
resolve(value)
},
reject: (error) => {
clearTimeout(timeout)
reject(error)
}
})
this.ws!.send(JSON.stringify(request))
})
}
waitForEvent(eventName: string, timeout: number = 10000): Promise<DAPMessage> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(`Timeout waiting for event: ${eventName}`))
}, timeout)
const handler = (event: DAPMessage) => {
clearTimeout(timer)
const handlers = this.eventHandlers.get(eventName) || []
const idx = handlers.indexOf(handler)
if (idx >= 0) handlers.splice(idx, 1)
resolve(event)
}
if (!this.eventHandlers.has(eventName)) {
this.eventHandlers.set(eventName, [])
}
this.eventHandlers.get(eventName)!.push(handler)
})
}
async initialize(): Promise<DAPMessage> {
return this.sendRequest('initialize', {
clientID: 'test',
clientName: 'DAP Test Client',
adapterID: 'bun',
pathFormat: 'path',
linesStartAt1: true,
columnsStartAt1: true,
supportsVariableType: true
})
}
async setBreakpoints(path: string, lines: number[]): Promise<DAPMessage> {
return this.sendRequest('setBreakpoints', {
source: { path },
breakpoints: lines.map((line) => ({ line }))
})
}
async configurationDone(): Promise<DAPMessage> {
return this.sendRequest('configurationDone')
}
async launch(
code: string,
callMain = false,
args: Record<string, unknown> = {}
): Promise<DAPMessage> {
return this.sendRequest('launch', {
code,
callMain,
args
})
}
async continue_(): Promise<DAPMessage> {
return this.sendRequest('continue', { threadId: 1 })
}
async next(): Promise<DAPMessage> {
return this.sendRequest('next', { threadId: 1 })
}
async getStackTrace(): Promise<StackFrame[]> {
const response = await this.sendRequest('stackTrace', {
threadId: 1,
startFrame: 0,
levels: 20
})
return (response.body?.stackFrames as StackFrame[]) || []
}
async getScopes(frameId: number): Promise<Scope[]> {
const response = await this.sendRequest('scopes', { frameId })
return (response.body?.scopes as Scope[]) || []
}
async getVariables(variablesReference: number): Promise<Variable[]> {
const response = await this.sendRequest('variables', { variablesReference })
return (response.body?.variables as Variable[]) || []
}
async terminate(): Promise<DAPMessage | null> {
// Check if still connected before sending terminate
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
console.log('[TEST] WebSocket not open, skipping terminate (script may have already ended)')
return null
}
return this.sendRequest('terminate')
}
isConnected(): boolean {
return this.ws !== null && this.ws.readyState === WebSocket.OPEN
}
getOutput(): string[] {
return this.output
}
clearOutput(): void {
this.output = []
}
getResult(): unknown {
return this.terminatedResult
}
}
// Test results tracking
interface TestResult {
test: string
passed: boolean
error?: string
details?: string
}
async function runComprehensiveTest(): Promise<void> {
const client = new DAPTestClient()
const results: TestResult[] = []
let passed = 0
let failed = 0
function assert(condition: boolean, testName: string, details?: string): void {
if (condition) {
console.log(`[PASS] ${testName}`)
passed++
results.push({ test: testName, passed: true, details })
} else {
console.log(`[FAIL] ${testName}${details ? ': ' + details : ''}`)
failed++
results.push({ test: testName, passed: false, error: details })
}
}
try {
// Connect
console.log('\n=== Connecting to DAP Server ===')
await client.connect()
assert(true, 'Connect to DAP server')
// Initialize
console.log('\n=== Initialize ===')
const initResponse = await client.initialize()
assert(initResponse.success === true, 'Initialize session')
// Set up initialized event handler before setting breakpoints
const initializedPromise = client.waitForEvent('initialized')
// Set breakpoints on all executable lines
console.log('\n=== Set Breakpoints ===')
console.log(`[TEST] Setting breakpoints on lines: ${BREAKPOINT_LINES}`)
const bpResponse = await client.setBreakpoints('/tmp/test_script.ts', BREAKPOINT_LINES)
assert(bpResponse.success === true, 'Set breakpoints request')
const breakpoints = bpResponse.body?.breakpoints as Array<{
verified: boolean
line: number
}>
assert(
breakpoints.length === BREAKPOINT_LINES.length,
`Breakpoint count (expected ${BREAKPOINT_LINES.length}, got ${breakpoints.length})`
)
// Configuration done
console.log('\n=== Configuration Done ===')
await client.configurationDone()
assert(true, 'Configuration done')
// Launch with callMain=true and args
console.log('\n=== Launch Script ===')
console.log(`[TEST] Launching with callMain=true, args=${JSON.stringify(TEST_ARGS)}`)
await client.launch(TEST_SCRIPT, true, TEST_ARGS)
assert(true, 'Launch script')
// Wait for initialized event
await initializedPromise
assert(true, 'Received initialized event')
// Track breakpoints hit and variables at each
const breakpointsHit: number[] = []
const variablesAtBreakpoints: Map<number, string[]> = new Map()
const variableValuesAtBreakpoints: Map<number, Map<string, string>> = new Map()
// Process breakpoints
console.log('\n=== Processing Breakpoints ===')
let iteration = 0
const maxIterations = BREAKPOINT_LINES.length + 5
while (iteration < maxIterations) {
iteration++
// Wait for stopped event
console.log(`\n[TEST] Waiting for stopped event (iteration ${iteration})...`)
let stoppedEvent: DAPMessage
try {
stoppedEvent = await client.waitForEvent('stopped', 15000)
} catch {
console.log('[TEST] No more stopped events (likely script completed)')
break
}
const line = stoppedEvent.body?.line as number | undefined
const reason = stoppedEvent.body?.reason as string
console.log(`[TEST] Stopped at line ${line}, reason: ${reason}`)
if (line !== undefined) {
breakpointsHit.push(line)
}
// Get stack trace
const frames = await client.getStackTrace()
console.log(`[TEST] Stack trace: ${frames.length} frames`)
for (const frame of frames) {
console.log(`[TEST] Frame ${frame.id}: ${frame.name} at line ${frame.line}`)
}
if (frames.length > 0) {
// Get scopes
const scopes = await client.getScopes(frames[0].id)
console.log(`[TEST] Scopes: ${scopes.length}`)
// Get variables from all non-expensive scopes
const allVars: string[] = []
const varValues: Map<string, string> = new Map()
for (const scope of scopes) {
if (!scope.expensive) {
console.log(
`[TEST] Scope: ${scope.name} (ref: ${scope.variablesReference})`
)
const vars = await client.getVariables(scope.variablesReference)
console.log(`[TEST] Variables in ${scope.name}: ${vars.length}`)
for (const v of vars) {
console.log(`[TEST] ${v.name} = ${v.value} (${v.type})`)
allVars.push(v.name)
varValues.set(v.name, v.value)
}
}
}
if (line !== undefined) {
variablesAtBreakpoints.set(line, allVars)
variableValuesAtBreakpoints.set(line, varValues)
}
}
// Continue to next breakpoint
console.log('[TEST] Continuing...')
await client.continue_()
}
// Wait for terminated event or timeout
console.log('\n[TEST] Waiting for terminated event...')
try {
await client.waitForEvent('terminated', 5000)
console.log('[TEST] Script terminated')
} catch {
console.log('[TEST] Timeout waiting for terminated event (script may have already ended)')
}
// Wait a bit more for final output to flush
await new Promise((resolve) => setTimeout(resolve, 1000))
// === VERIFICATION ===
console.log('\n' + '='.repeat(60))
console.log('VERIFICATION')
console.log('='.repeat(60))
// Verify breakpoints were hit
console.log('\n=== Breakpoint Analysis ===')
console.log(`[TEST] Breakpoints hit: ${breakpointsHit}`)
console.log(`[TEST] Expected breakpoints: ${BREAKPOINT_LINES}`)
for (const expectedLine of BREAKPOINT_LINES) {
const hitCount = breakpointsHit.filter((l) => l === expectedLine).length
assert(hitCount > 0, `Breakpoint at line ${expectedLine} was hit`)
}
// Verify variables at each breakpoint
console.log('\n=== Variable Analysis ===')
for (const [line, expectedVars] of Object.entries(EXPECTED_VARIABLES)) {
const lineNum = parseInt(line)
const actualVars = variablesAtBreakpoints.get(lineNum) || []
console.log(
`[TEST] Line ${lineNum}: expected [${expectedVars}], got [${actualVars}]`
)
for (const expectedVar of expectedVars) {
assert(
actualVars.includes(expectedVar),
`Variable '${expectedVar}' visible at line ${lineNum}`,
`Available: ${actualVars.join(', ')}`
)
}
}
// Verify variable x has correct value inside main() (line 6 or later)
console.log('\n=== Variable Value Check ===')
const varsAtLine6 = variableValuesAtBreakpoints.get(6)
if (varsAtLine6) {
const xValue = varsAtLine6.get('x')
console.log(`[TEST] Variable x at line 6: ${xValue}`)
assert(
xValue === '"foobar"',
'Variable x has correct value "foobar"',
`Got: ${xValue}`
)
} else {
assert(false, 'Variable x has correct value "foobar"', 'No variables at line 6')
}
// Verify console output
console.log('\n=== Console Output Analysis ===')
const output = client.getOutput()
console.log(`[TEST] Captured output: ${JSON.stringify(output)}`)
console.log(`[TEST] Expected output: ${JSON.stringify(EXPECTED_LOGS)}`)
// Filter out result output and any stderr noise
const consoleOutput = output.filter(
(o) => !o.startsWith('__WINDMILL_RESULT__') && !o.includes('[DEBUG]')
)
assert(
consoleOutput.length >= EXPECTED_LOGS.length,
`Console output count (expected >= ${EXPECTED_LOGS.length}, got ${consoleOutput.length})`
)
for (let i = 0; i < EXPECTED_LOGS.length; i++) {
assert(
consoleOutput[i] === EXPECTED_LOGS[i],
`Console output[${i}] is "${EXPECTED_LOGS[i]}"`,
`Got: "${consoleOutput[i]}"`
)
}
// Terminate and get result
console.log('\n=== Terminate ===')
const terminateResult = await client.terminate()
if (terminateResult !== null) {
assert(true, 'Terminate session')
} else {
assert(true, 'Script already terminated naturally')
}
// Wait for terminated event with result
await new Promise((resolve) => setTimeout(resolve, 500))
// Check for return value from terminated event
console.log('\n=== Return Value Analysis ===')
const result = client.getResult()
console.log(`[TEST] Result from terminated event: ${JSON.stringify(result)}`)
assert(result === 'foobar', 'Return value is "foobar"', `Got: ${JSON.stringify(result)}`)
} catch (error) {
console.error('[TEST] Error:', error)
failed++
results.push({ test: 'Unexpected error', passed: false, error: String(error) })
} finally {
client.disconnect()
}
// Summary
console.log('\n' + '='.repeat(60))
console.log(`TEST SUMMARY: ${passed} passed, ${failed} failed`)
console.log('='.repeat(60))
if (failed > 0) {
console.log('\nFailed tests:')
for (const r of results.filter((r) => !r.passed)) {
console.log(` - ${r.test}: ${r.error || 'Unknown error'}`)
}
process.exit(1)
} else {
console.log('\n✓ All tests passed!')
process.exit(0)
}
}
/**
* Test script with a dynamic import (lodash).
* This tests that the prepare-deps CLI properly installs dependencies.
*/
const TEST_SCRIPT_WITH_IMPORT = `import _ from "lodash"
export async function main(items: number[]) {
const sum = _.sum(items)
const max = _.max(items)
console.log("Sum:", sum)
console.log("Max:", max)
return { sum, max }
}`
/**
* Test dynamic import installation.
* This test requires the server to be started with --windmill pointing to the windmill binary.
*
* Usage:
* bun run dap_websocket_server_bun.ts --windmill /path/to/windmill
* bun run test_dap_server_bun.ts --test-imports
*/
async function testDynamicImports(): Promise<void> {
console.log('='.repeat(60))
console.log('DYNAMIC IMPORT TEST')
console.log('='.repeat(60))
console.log('\nThis test verifies that external npm packages are automatically installed.')
console.log('Make sure the server is started with: --windmill /path/to/windmill\n')
const client = new DAPTestClient('ws://localhost:5680')
let passed = 0
let failed = 0
const results: Array<{ test: string; passed: boolean; error?: string }> = []
function assert(condition: boolean, message: string, error?: string) {
if (condition) {
passed++
console.log(`${message}`)
results.push({ test: message, passed: true })
} else {
failed++
console.log(`${message}` + (error ? `: ${error}` : ''))
results.push({ test: message, passed: false, error })
}
}
try {
console.log('=== Setup ===')
await client.connect()
assert(true, 'Connect to server')
// Initialize
const initResult = await client.initialize()
assert(
initResult.success === true,
'Initialize session',
initResult.message
)
// Launch with code that uses lodash
console.log('\n=== Launch with lodash import ===')
const launchResult = await client.launch(TEST_SCRIPT_WITH_IMPORT, true, { items: [1, 2, 3, 4, 5] })
assert(
launchResult.success === true,
'Launch with lodash import',
launchResult.message
)
// Wait for initialization events
await client.configurationDone()
// No breakpoints - just run to completion
console.log('\n=== Running to completion ===')
// Wait for completion (with timeout)
let terminated = false
const startTime = Date.now()
while (!terminated && Date.now() - startTime < 30000) {
await new Promise(resolve => setTimeout(resolve, 100))
// Check if we've received a terminated event by checking for result
const result = client.getResult()
if (result !== undefined) {
terminated = true
}
}
if (!terminated) {
// Try to continue if we're paused at debugger statement
try {
await client.continue_()
// Wait a bit more
await new Promise(resolve => setTimeout(resolve, 2000))
} catch {
// Ignore if already terminated
}
}
// Check console output
console.log('\n=== Console Output ===')
const output = client.getOutput()
console.log(`Output: ${JSON.stringify(output)}`)
const hasSum = output.some(o => o.includes('Sum:') && o.includes('15'))
const hasMax = output.some(o => o.includes('Max:') && o.includes('5'))
assert(hasSum, 'Console shows correct sum (15)', `Output: ${output.join(', ')}`)
assert(hasMax, 'Console shows correct max (5)', `Output: ${output.join(', ')}`)
// Check return value
console.log('\n=== Return Value ===')
const result = client.getResult()
console.log(`Result: ${JSON.stringify(result)}`)
if (result && typeof result === 'object') {
const resultObj = result as { sum?: number; max?: number }
assert(resultObj.sum === 15, 'Return value sum is 15', `Got: ${resultObj.sum}`)
assert(resultObj.max === 5, 'Return value max is 5', `Got: ${resultObj.max}`)
} else {
assert(false, 'Return value has sum and max', `Result: ${JSON.stringify(result)}`)
}
// Terminate
console.log('\n=== Terminate ===')
try {
await client.terminate()
assert(true, 'Terminate session')
} catch {
assert(true, 'Script already terminated naturally')
}
} catch (error) {
console.error('[TEST] Error:', error)
failed++
results.push({ test: 'Unexpected error', passed: false, error: String(error) })
} finally {
client.disconnect()
}
// Summary
console.log('\n' + '='.repeat(60))
console.log(`DYNAMIC IMPORT TEST SUMMARY: ${passed} passed, ${failed} failed`)
console.log('='.repeat(60))
if (failed > 0) {
console.log('\nFailed tests:')
for (const r of results.filter((r) => !r.passed)) {
console.log(` - ${r.test}: ${r.error || 'Unknown error'}`)
}
process.exit(1)
} else {
console.log('\n✓ All dynamic import tests passed!')
process.exit(0)
}
}
// Parse arguments to choose which test to run
const testArgs = process.argv.slice(2)
if (testArgs.includes('--test-imports')) {
testDynamicImports().catch((error) => {
console.error('Test runner failed:', error)
process.exit(1)
})
} else {
// Run the main test
runComprehensiveTest().catch((error) => {
console.error('Test runner failed:', error)
process.exit(1)
})
}
File diff suppressed because it is too large Load Diff
+17 -12
View File
@@ -168,25 +168,30 @@ services:
- worker_logs:/tmp/windmill/logs
logging: *default-logging
lsp:
image: ghcr.io/windmill-labs/windmill-lsp:latest
# Combined extra services: LSP, Multiplayer, and Debugger
# Each service can be enabled/disabled via environment variables:
# - ENABLE_LSP=true (default) - Language Server Protocol for code intelligence
# - ENABLE_MULTIPLAYER=false - Real-time collaboration (Enterprise Edition)
# - ENABLE_DEBUGGER=false - Interactive debugging via DAP WebSocket
windmill_extra:
image: ghcr.io/windmill-labs/windmill-extra:latest
pull_policy: always
restart: unless-stopped
expose:
- 3001
- 3001 # LSP
- 3002 # Multiplayer
- 5679 # Debugger
environment:
- ENABLE_LSP=true
- ENABLE_MULTIPLAYER=false # Set to true to enable multiplayer (Enterprise Edition)
- ENABLE_DEBUGGER=true # Set to true to enable debugger
- ENABLE_NSJAIL=false # Set to true for nsjail sandboxing (requires privileged: true)
- REQUIRE_SIGNED_DEBUG_REQUESTS=false # Set to true to require JWT tokens for debug sessions
- WINDMILL_BASE_URL=http://windmill_server:8000
volumes:
- lsp_cache:/pyls/.cache
logging: *default-logging
multiplayer:
image: ghcr.io/windmill-labs/windmill-multiplayer:latest
deploy:
replicas: 0 # Set to 1 to enable multiplayer, only available on Enterprise Edition
restart: unless-stopped
expose:
- 3002
logging: *default-logging
caddy:
image: ghcr.io/windmill-labs/caddy-l4:latest
restart: unless-stopped
+165
View File
@@ -0,0 +1,165 @@
# DockerfileExtra - Combined Windmill Extra Services
#
# This image extends windmill-slim with three optional Windmill services:
# - LSP (Language Server Protocol) - Port 3001
# - Multiplayer (y-websocket) - Port 3002
# - Debugger (DAP WebSocket) - Port 5679
#
# Each service can be enabled/disabled via environment variables:
# - ENABLE_LSP=true (default: true)
# - ENABLE_MULTIPLAYER=true (default: true)
# - ENABLE_DEBUGGER=true (default: true)
#
# Build:
# docker build -f docker/DockerfileExtra -t windmill-extra .
#
# Run:
# docker run -p 3001:3001 -p 3002:3002 -p 5679:5679 windmill-extra
# ============================================================================
# Stage 1: Get nsjail from the nsjail image
# ============================================================================
FROM ghcr.io/windmill-labs/windmill-ee-nsjail:main AS nsjail-source
# ============================================================================
# Stage 2: Build final extra services image from windmill-slim
# ============================================================================
FROM ghcr.io/windmill-labs/windmill-slim:latest AS final
ARG APP=/usr/src/app
# Install Node.js 22 (needed for LSP and multiplayer)
RUN apt-get update && apt-get install -y --no-install-recommends \
gnupg \
&& mkdir -p /etc/apt/keyrings \
&& curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \
&& echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_22.x nodistro main" > /etc/apt/sources.list.d/nodesource.list \
&& apt-get update \
&& apt-get install -y nodejs \
&& rm -rf /var/lib/apt/lists/*
# Install additional system dependencies
# - shellcheck: for bash LSP
# - libprotobuf-dev, libnl-route-3-dev: for nsjail runtime
RUN apt-get update && apt-get install -y --no-install-recommends \
shellcheck \
libprotobuf-dev \
libnl-route-3-dev \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Install Go for gopls (Go LSP)
RUN set -eux; \
arch="$(dpkg --print-architecture)"; arch="${arch##*-}"; \
case "$arch" in \
'amd64') targz='go1.21.13.linux-amd64.tar.gz' ;; \
'arm64') targz='go1.21.13.linux-arm64.tar.gz' ;; \
'armhf') targz='go1.21.13.linux-armv6l.tar.gz' ;; \
*) echo >&2 "error: unsupported architecture '$arch'"; exit 1 ;; \
esac; \
wget "https://golang.org/dl/$targz" -nv && tar -C /usr/local -xzf "$targz" && rm "$targz"
ENV PATH="${PATH}:/usr/local/go/bin"
ENV GOBIN=/usr/local/go/bin
# Install gopls for Go LSP
RUN /usr/local/go/bin/go install -v golang.org/x/tools/gopls@latest
# Copy Deno for Deno LSP
COPY --from=denoland/deno:2.2.1 --chmod=755 /usr/bin/deno /usr/bin/deno
# Copy nsjail from nsjail image (for sandboxed debugging)
COPY --from=nsjail-source /bin/nsjail /bin/nsjail
# ============================================================================
# LSP Setup
# ============================================================================
ENV PIPENV_VENV_IN_PROJECT=1
ENV XDG_CACHE_HOME=/pyls/.cache
# Install Python packages for LSP using uv
RUN uv pip install --system --break-system-packages pipenv tornado python-lsp-jsonrpc ruff Cython
# Install Node-based language servers
RUN npm install -g diagnostic-languageserver pyright
# Setup LSP working directory
WORKDIR /pyls
COPY lsp/Pipfile .
RUN pipenv install
COPY lsp/pyls_launcher.py .
# Setup Monaco temp directory for LSP
RUN mkdir -p /tmp/monaco && chmod -R 777 /tmp/monaco
RUN cd /tmp/monaco && npm install --save-dev windmill-client
RUN mkdir -p /pyls/.cache
# ============================================================================
# Debugger Setup
# ============================================================================
WORKDIR /debugger
# Copy debugger files
COPY debugger/dap_debug_service.ts .
COPY debugger/dap_websocket_server_bun.ts .
COPY debugger/dap_websocket_server.py .
# Install Python debugger dependencies using uv
RUN uv pip install --system --break-system-packages websockets debugpy
# ============================================================================
# Multiplayer Setup (y-websocket with connection logging)
# ============================================================================
WORKDIR /multiplayer
# Copy multiplayer server files
COPY multiplayer/package.json .
COPY multiplayer/server.mjs .
# Install dependencies
RUN npm install
# ============================================================================
# Entrypoint Setup
# ============================================================================
WORKDIR /app
# Copy entrypoint script
COPY docker/entrypoint-extra.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Set permissions
RUN chmod -R a+rX /usr/local && \
chmod -R a+rX /pyls && \
chmod -R a+rX /debugger
# Expose all service ports
EXPOSE 3001 3002 5679
# Environment variables for service control
ENV ENABLE_LSP=true
ENV ENABLE_MULTIPLAYER=true
ENV ENABLE_DEBUGGER=true
# nsjail sandboxing for debugger (requires --privileged, off by default)
ENV ENABLE_NSJAIL=false
# LSP port
ENV LSP_PORT=3001
# Multiplayer port and host
ENV MULTIPLAYER_PORT=3002
ENV HOST=0.0.0.0
# Debugger port
ENV DEBUGGER_PORT=5679
# Windmill base URL for debugger token verification
ENV WINDMILL_BASE_URL=""
ENV BASE_INTERNAL_URL=""
ENTRYPOINT ["/entrypoint.sh"]
+104
View File
@@ -0,0 +1,104 @@
#!/bin/bash
set -e
# Windmill Extra Services Entrypoint
# Starts LSP, Multiplayer, and Debugger services based on environment variables
# Track PIDs for cleanup
PIDS=()
cleanup() {
echo "[entrypoint] Shutting down services..."
for pid in "${PIDS[@]}"; do
if kill -0 "$pid" 2>/dev/null; then
kill "$pid" 2>/dev/null || true
fi
done
wait
echo "[entrypoint] All services stopped"
exit 0
}
trap cleanup SIGTERM SIGINT
# Setup NETRC if provided (for LSP)
if [ -n "$NETRC" ]; then
echo "$NETRC" > /root/.netrc
chmod 600 /root/.netrc
fi
# Setup cache directory for LSP
if [ -d /root/.cache ]; then
export XDG_CACHE_HOME=/root/.cache
cp -r /pyls/.cache /root/.cache 2>/dev/null || true
fi
# Setup Monaco temp directory for LSP
mkdir -p /tmp/monaco
if [ ! -f /tmp/monaco/go.mod ]; then
echo -e "module mymod\ngo 1.25" > /tmp/monaco/go.mod
fi
echo "[entrypoint] Starting Windmill Extra Services"
echo "[entrypoint] ENABLE_LSP=${ENABLE_LSP:-true}"
echo "[entrypoint] ENABLE_MULTIPLAYER=${ENABLE_MULTIPLAYER:-true}"
echo "[entrypoint] ENABLE_DEBUGGER=${ENABLE_DEBUGGER:-true}"
# Start LSP service
if [ "${ENABLE_LSP:-true}" = "true" ]; then
echo "[entrypoint] Starting LSP on port ${LSP_PORT:-3001}..."
cd /pyls
PORT=${LSP_PORT:-3001} python3 pyls_launcher.py &
PIDS+=($!)
echo "[entrypoint] LSP started (PID: ${PIDS[-1]})"
fi
# Start Multiplayer service (custom y-websocket with logging)
if [ "${ENABLE_MULTIPLAYER:-true}" = "true" ]; then
echo "[entrypoint] Starting Multiplayer on port ${MULTIPLAYER_PORT:-3002}..."
cd /multiplayer
PORT=${MULTIPLAYER_PORT:-3002} HOST=${HOST:-0.0.0.0} node server.mjs &
PIDS+=($!)
echo "[entrypoint] Multiplayer started (PID: ${PIDS[-1]})"
fi
# Start Debugger service
if [ "${ENABLE_DEBUGGER:-true}" = "true" ]; then
echo "[entrypoint] Starting Debugger on port ${DEBUGGER_PORT:-5679}..."
cd /debugger
# Build debugger arguments
DEBUGGER_ARGS="--host ${HOST:-0.0.0.0} --port ${DEBUGGER_PORT:-5679}"
DEBUGGER_ARGS="$DEBUGGER_ARGS --windmill /usr/local/bin/windmill"
# Enable nsjail if requested
if [ "${ENABLE_NSJAIL:-false}" = "true" ]; then
DEBUGGER_ARGS="$DEBUGGER_ARGS --nsjail"
fi
bun run dap_debug_service.ts $DEBUGGER_ARGS &
PIDS+=($!)
echo "[entrypoint] Debugger started (PID: ${PIDS[-1]})"
fi
# Check if any services were started
if [ ${#PIDS[@]} -eq 0 ]; then
echo "[entrypoint] WARNING: No services enabled. Set ENABLE_LSP, ENABLE_MULTIPLAYER, or ENABLE_DEBUGGER to true."
echo "[entrypoint] Sleeping indefinitely..."
sleep infinity
fi
echo "[entrypoint] All enabled services started. Waiting..."
# Wait for any process to exit
wait -n "${PIDS[@]}" 2>/dev/null || true
# If one process exits, check which one and report
for i in "${!PIDS[@]}"; do
if ! kill -0 "${PIDS[$i]}" 2>/dev/null; then
echo "[entrypoint] Service (PID: ${PIDS[$i]}) has exited"
fi
done
# Keep running and wait for remaining processes
wait
+669
View File
@@ -0,0 +1,669 @@
#!/usr/bin/env bun
/**
* Integration test for the windmill-extra Docker container.
*
* Tests all three services:
* - LSP (Language Server Protocol) - Port 3001
* - Multiplayer (y-websocket) - Port 3002
* - Debugger (DAP WebSocket) - Port 5679
*
* Configuration via environment variables:
* - WINDMILL_EXTRA_HOST: Container hostname (default: localhost)
* - LSP_PORT: LSP service port (default: 3001)
* - MULTIPLAYER_PORT: Multiplayer service port (default: 3002)
* - DEBUGGER_PORT: Debugger service port (default: 5679)
* - SKIP_LSP: Skip LSP tests (default: false)
* - SKIP_MULTIPLAYER: Skip Multiplayer tests (default: false)
* - SKIP_DEBUGGER: Skip Debugger tests (default: false)
*
* Usage:
* # Start the container first
* docker run -p 3001:3001 -p 3002:3002 -p 5679:5679 \
* -e ENABLE_LSP=true -e ENABLE_MULTIPLAYER=true -e ENABLE_DEBUGGER=true \
* windmill-extra
*
* # Run tests
* bun run docker/test_windmill_extra.ts
*
* # Or with custom host
* WINDMILL_EXTRA_HOST=windmill_extra bun run docker/test_windmill_extra.ts
*/
// Configuration
const HOST = process.env.WINDMILL_EXTRA_HOST || 'localhost'
const LSP_PORT = parseInt(process.env.LSP_PORT || '3001')
const MULTIPLAYER_PORT = parseInt(process.env.MULTIPLAYER_PORT || '3002')
const DEBUGGER_PORT = parseInt(process.env.DEBUGGER_PORT || '5679')
const SKIP_LSP = process.env.SKIP_LSP === 'true'
const SKIP_MULTIPLAYER = process.env.SKIP_MULTIPLAYER === 'true'
const SKIP_DEBUGGER = process.env.SKIP_DEBUGGER === 'true'
// Test result tracking
interface TestResult {
name: string
passed: boolean
error?: string
duration?: number
}
const results: TestResult[] = []
function log(message: string) {
console.log(`[TEST] ${message}`)
}
function logSuccess(test: string) {
console.log(`${test}`)
}
function logFailure(test: string, error?: string) {
console.log(`${test}${error ? `: ${error}` : ''}`)
}
async function runTest(name: string, fn: () => Promise<void>): Promise<boolean> {
const start = Date.now()
try {
await fn()
const duration = Date.now() - start
results.push({ name, passed: true, duration })
logSuccess(`${name} (${duration}ms)`)
return true
} catch (error) {
const duration = Date.now() - start
const errorMsg = error instanceof Error ? error.message : String(error)
results.push({ name, passed: false, error: errorMsg, duration })
logFailure(name, errorMsg)
return false
}
}
// ============================================================================
// LSP Tests
// ============================================================================
async function testLspHealth(): Promise<void> {
// LSP returns "ok" on the root WebSocket endpoint
const response = await fetch(`http://${HOST}:${LSP_PORT}/`, {
method: 'GET',
signal: AbortSignal.timeout(5000)
})
if (response.status !== 200) {
throw new Error(`LSP health check failed: HTTP ${response.status}`)
}
const text = await response.text()
if (!text.includes('ok')) {
throw new Error(`LSP health check response unexpected: ${text}`)
}
}
async function testLspWebSocket(): Promise<void> {
// Test WebSocket connection to LSP
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
ws.close()
reject(new Error('LSP WebSocket connection timeout'))
}, 5000)
const ws = new WebSocket(`ws://${HOST}:${LSP_PORT}/ws/pyright`)
ws.onopen = () => {
clearTimeout(timeout)
ws.close()
resolve()
}
ws.onerror = () => {
clearTimeout(timeout)
reject(new Error('LSP WebSocket connection failed'))
}
})
}
async function runLspTests(): Promise<boolean> {
log('\n=== LSP Service Tests (Port ' + LSP_PORT + ') ===')
if (SKIP_LSP) {
log(' Skipping LSP tests (SKIP_LSP=true)')
return true
}
let passed = true
passed = (await runTest('LSP health check', testLspHealth)) && passed
passed = (await runTest('LSP WebSocket connection (pyright)', testLspWebSocket)) && passed
return passed
}
// ============================================================================
// Multiplayer Tests (y-websocket)
// ============================================================================
async function testMultiplayerWebSocket(): Promise<void> {
// y-websocket accepts WebSocket connections with room names
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
ws.close()
reject(new Error('Multiplayer WebSocket connection timeout'))
}, 5000)
// y-websocket uses room names in the URL path
const ws = new WebSocket(`ws://${HOST}:${MULTIPLAYER_PORT}/test-room`)
ws.onopen = () => {
clearTimeout(timeout)
// Send a sync message (y-websocket protocol)
// Just verifying connection works
ws.close()
resolve()
}
ws.onerror = () => {
clearTimeout(timeout)
reject(new Error('Multiplayer WebSocket connection failed'))
}
})
}
async function runMultiplayerTests(): Promise<boolean> {
log('\n=== Multiplayer Service Tests (Port ' + MULTIPLAYER_PORT + ') ===')
if (SKIP_MULTIPLAYER) {
log(' Skipping Multiplayer tests (SKIP_MULTIPLAYER=true)')
return true
}
let passed = true
passed = (await runTest('Multiplayer WebSocket connection', testMultiplayerWebSocket)) && passed
return passed
}
// ============================================================================
// Debugger Tests (DAP WebSocket)
// ============================================================================
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>
}
class DAPTestClient {
private ws: WebSocket | null = null
private seq = 1
private pendingRequests = new Map<
number,
{ resolve: (value: DAPMessage) => void; reject: (error: Error) => void }
>()
private events: DAPMessage[] = []
private output: string[] = []
private result: unknown = undefined
private eventHandlers = new Map<string, ((event: DAPMessage) => void)[]>()
async connect(endpoint: string): Promise<void> {
const url = `ws://${HOST}:${DEBUGGER_PORT}${endpoint}`
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('DAP WebSocket connection timeout'))
}, 5000)
this.ws = new WebSocket(url)
this.ws.onopen = () => {
clearTimeout(timeout)
resolve()
}
this.ws.onerror = () => {
clearTimeout(timeout)
reject(new Error('DAP WebSocket connection failed'))
}
this.ws.onmessage = (event) => {
this.handleMessage(event.data as string)
}
this.ws.onclose = () => {
// Connection closed
}
})
}
disconnect(): void {
if (this.ws) {
this.ws.close()
this.ws = null
}
}
private handleMessage(data: string): void {
try {
const msg = JSON.parse(data) as DAPMessage
if (msg.type === 'response') {
const pending = this.pendingRequests.get(msg.request_seq!)
if (pending) {
this.pendingRequests.delete(msg.request_seq!)
if (msg.success) {
pending.resolve(msg)
} else {
pending.reject(new Error(msg.message || 'Request failed'))
}
}
} else if (msg.type === 'event') {
this.events.push(msg)
if (msg.event === 'output' && msg.body?.output) {
const out = String(msg.body.output).trim()
if (out && !out.startsWith('__WINDMILL_RESULT__')) {
this.output.push(out)
}
}
if (msg.event === 'terminated' && msg.body?.result !== undefined) {
this.result = msg.body.result
}
const handlers = this.eventHandlers.get(msg.event!) || []
for (const handler of handlers) {
handler(msg)
}
}
} catch {
// Ignore parse errors
}
}
private async sendRequest(command: string, args?: Record<string, unknown>): Promise<DAPMessage> {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
throw new Error('Not connected')
}
const seq = this.seq++
const request: 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}`))
}, 15000)
this.pendingRequests.set(seq, {
resolve: (value) => {
clearTimeout(timeout)
resolve(value)
},
reject: (error) => {
clearTimeout(timeout)
reject(error)
}
})
this.ws!.send(JSON.stringify(request))
})
}
waitForEvent(eventName: string, timeout = 10000): Promise<DAPMessage> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(`Timeout waiting for event: ${eventName}`))
}, timeout)
const handler = (event: DAPMessage) => {
clearTimeout(timer)
const handlers = this.eventHandlers.get(eventName) || []
const idx = handlers.indexOf(handler)
if (idx >= 0) handlers.splice(idx, 1)
resolve(event)
}
if (!this.eventHandlers.has(eventName)) {
this.eventHandlers.set(eventName, [])
}
this.eventHandlers.get(eventName)!.push(handler)
})
}
async initialize() {
return this.sendRequest('initialize', {
clientID: 'test',
linesStartAt1: true,
columnsStartAt1: true
})
}
async setBreakpoints(path: string, lines: number[]) {
return this.sendRequest('setBreakpoints', {
source: { path },
breakpoints: lines.map((line) => ({ line }))
})
}
async configurationDone() {
return this.sendRequest('configurationDone')
}
async launch(code: string, callMain = false, args: Record<string, unknown> = {}) {
return this.sendRequest('launch', { code, callMain, args })
}
async continue_() {
return this.sendRequest('continue', { threadId: 1 })
}
async terminate() {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
return null
}
return this.sendRequest('terminate')
}
getOutput() {
return this.output
}
getResult() {
return this.result
}
clearState() {
this.output = []
this.result = undefined
this.events = []
}
}
async function testDebuggerHealth(): Promise<void> {
const response = await fetch(`http://${HOST}:${DEBUGGER_PORT}/health`, {
method: 'GET',
signal: AbortSignal.timeout(5000)
})
if (response.status !== 200) {
throw new Error(`Debugger health check failed: HTTP ${response.status}`)
}
const data = await response.json()
if (data.status !== 'ok') {
throw new Error(`Debugger health status: ${data.status}`)
}
// Verify endpoints are listed
if (!data.endpoints || !Array.isArray(data.endpoints)) {
throw new Error('Debugger health missing endpoints')
}
const requiredEndpoints = ['/python', '/typescript', '/bun']
for (const ep of requiredEndpoints) {
if (!data.endpoints.includes(ep)) {
throw new Error(`Debugger missing endpoint: ${ep}`)
}
}
}
async function testDebuggerTypescriptExecution(): Promise<void> {
const client = new DAPTestClient()
try {
await client.connect('/typescript')
await client.initialize()
const initP = client.waitForEvent('initialized')
await client.setBreakpoints('/test.ts', [])
await client.configurationDone()
const code = `export async function main(name: string) {
console.log("Hello " + name)
return { greeting: "Hello " + name }
}`
await client.launch(code, true, { name: 'World' })
await initP
// Wait for termination
try {
await client.waitForEvent('terminated', 10000)
} catch {
// May have already terminated
}
await new Promise((r) => setTimeout(r, 500))
const output = client.getOutput()
const result = client.getResult() as { greeting?: string } | undefined
if (!output.some((o) => o.includes('Hello World'))) {
throw new Error(`Missing expected output. Got: ${JSON.stringify(output)}`)
}
if (!result || result.greeting !== 'Hello World') {
throw new Error(`Incorrect result. Got: ${JSON.stringify(result)}`)
}
} finally {
client.disconnect()
}
}
async function testDebuggerPythonExecution(): Promise<void> {
const client = new DAPTestClient()
try {
await client.connect('/python')
await client.initialize()
const initP = client.waitForEvent('initialized')
await client.setBreakpoints('/test.py', [])
await client.configurationDone()
const code = `def main(name):
print(f"Hello {name}")
return {"greeting": f"Hello {name}"}
`
await client.launch(code, true, { name: 'World' })
await initP
// Wait for termination
try {
await client.waitForEvent('terminated', 10000)
} catch {
// May have already terminated
}
await new Promise((r) => setTimeout(r, 500))
const output = client.getOutput()
const result = client.getResult() as { greeting?: string } | undefined
if (!output.some((o) => o.includes('Hello World'))) {
throw new Error(`Missing expected output. Got: ${JSON.stringify(output)}`)
}
if (!result || result.greeting !== 'Hello World') {
throw new Error(`Incorrect result. Got: ${JSON.stringify(result)}`)
}
} finally {
client.disconnect()
}
}
async function testDebuggerBreakpoints(): Promise<void> {
const client = new DAPTestClient()
try {
await client.connect('/typescript')
await client.initialize()
const initP = client.waitForEvent('initialized')
// Set breakpoint on line 3 (console.log)
await client.setBreakpoints('/test.ts', [3])
await client.configurationDone()
const code = `export async function main() {
let x = 1
console.log("At breakpoint")
return x
}`
await client.launch(code, true, {})
await initP
// Wait for stopped at breakpoint
const stopped = await client.waitForEvent('stopped', 15000)
if (stopped.body?.reason !== 'breakpoint') {
throw new Error(`Expected stop reason 'breakpoint', got '${stopped.body?.reason}'`)
}
// Continue execution
await client.continue_()
// Wait for termination
try {
await client.waitForEvent('terminated', 5000)
} catch {
// May have already terminated
}
await new Promise((r) => setTimeout(r, 500))
const result = client.getResult()
if (result !== 1) {
throw new Error(`Expected result 1, got ${JSON.stringify(result)}`)
}
} finally {
client.disconnect()
}
}
async function runDebuggerTests(): Promise<boolean> {
log('\n=== Debugger Service Tests (Port ' + DEBUGGER_PORT + ') ===')
if (SKIP_DEBUGGER) {
log(' Skipping Debugger tests (SKIP_DEBUGGER=true)')
return true
}
let passed = true
passed = (await runTest('Debugger health check', testDebuggerHealth)) && passed
passed = (await runTest('Debugger TypeScript execution', testDebuggerTypescriptExecution)) && passed
passed = (await runTest('Debugger Python execution', testDebuggerPythonExecution)) && passed
passed = (await runTest('Debugger breakpoint support', testDebuggerBreakpoints)) && passed
return passed
}
// ============================================================================
// Main Test Runner
// ============================================================================
async function waitForServices(maxWait = 30000): Promise<void> {
log('Waiting for services to be ready...')
const start = Date.now()
const checkService = async (name: string, url: string): Promise<boolean> => {
try {
const response = await fetch(url, {
method: 'GET',
signal: AbortSignal.timeout(2000)
})
return response.status === 200
} catch {
return false
}
}
while (Date.now() - start < maxWait) {
const checks = await Promise.all([
SKIP_LSP || checkService('LSP', `http://${HOST}:${LSP_PORT}/`),
SKIP_MULTIPLAYER || true, // y-websocket doesn't have a health endpoint
SKIP_DEBUGGER || checkService('Debugger', `http://${HOST}:${DEBUGGER_PORT}/health`)
])
if (checks.every((c) => c)) {
log('All services are ready!')
return
}
await new Promise((r) => setTimeout(r, 1000))
}
throw new Error('Timeout waiting for services to be ready')
}
async function main() {
console.log('='.repeat(60))
console.log('Windmill Extra Integration Tests')
console.log('='.repeat(60))
console.log(`Host: ${HOST}`)
console.log(`LSP Port: ${LSP_PORT} (${SKIP_LSP ? 'SKIPPED' : 'enabled'})`)
console.log(`Multiplayer Port: ${MULTIPLAYER_PORT} (${SKIP_MULTIPLAYER ? 'SKIPPED' : 'enabled'})`)
console.log(`Debugger Port: ${DEBUGGER_PORT} (${SKIP_DEBUGGER ? 'SKIPPED' : 'enabled'})`)
console.log('='.repeat(60))
try {
await waitForServices()
} catch (error) {
console.error(`\n✗ ${error instanceof Error ? error.message : error}`)
console.error('\nMake sure the windmill-extra container is running:')
console.error(' docker run -p 3001:3001 -p 3002:3002 -p 5679:5679 \\')
console.error(' -e ENABLE_LSP=true -e ENABLE_MULTIPLAYER=true -e ENABLE_DEBUGGER=true \\')
console.error(' windmill-extra')
process.exit(1)
}
let allPassed = true
// Run LSP tests
allPassed = (await runLspTests()) && allPassed
// Run Multiplayer tests
allPassed = (await runMultiplayerTests()) && allPassed
// Run Debugger tests
allPassed = (await runDebuggerTests()) && allPassed
// Summary
console.log('\n' + '='.repeat(60))
console.log('TEST SUMMARY')
console.log('='.repeat(60))
const passed = results.filter((r) => r.passed).length
const failed = results.filter((r) => !r.passed).length
console.log(`\nTotal: ${results.length} tests`)
console.log(`Passed: ${passed}`)
console.log(`Failed: ${failed}`)
if (failed > 0) {
console.log('\nFailed tests:')
for (const r of results.filter((r) => !r.passed)) {
console.log(` - ${r.name}: ${r.error || 'Unknown error'}`)
}
}
console.log('\n' + '='.repeat(60))
if (failed > 0) {
console.log('✗ Some tests failed')
process.exit(1)
} else {
console.log('✓ All tests passed!')
process.exit(0)
}
}
main().catch((error) => {
console.error('Test runner failed:', error)
process.exit(1)
})
+2 -1350
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -123,7 +123,6 @@
"lucide-svelte": "^0.540.0",
"minimatch": "^10.0.1",
"monaco-editor": "npm:@codingame/monaco-vscode-editor-api@=24.2.0",
"monaco-graphql": "=1.7.3",
"monaco-languageclient": "10.5.0",
"monaco-vim": "^0.4.1",
"ol": "^7.4.0",
@@ -466,6 +466,10 @@
return scriptLang
}
export function getEditor(): meditor.IStandaloneCodeEditor | null {
return editor
}
/** Get lint errors and warnings from the Monaco editor */
export function getLintErrors(): ScriptLintResult {
if (!model) {
@@ -20,6 +20,7 @@
focusArg?: string
class?: string
onJobDone?: () => void
hideRunButton?: boolean
}
let {
@@ -32,7 +33,8 @@
scriptProgress = $bindable(undefined),
focusArg = undefined,
class: className = '',
onJobDone
onJobDone,
hideRunButton = false
}: Props = $props()
const { flowStore } = getContext<FlowEditorContext>('FlowEditorContext')
@@ -60,26 +62,28 @@
>
{/if}
<div class="w-full justify-center flex">
{#if testIsLoading}
<Button size="sm" on:click={moduleTest?.cancelJob} btnClasses="w-full" color="red">
<Loader2 size={16} class="animate-spin mr-1" />
Cancel
</Button>
{:else}
<Button
variant="accent"
btnClasses="truncate"
size="sm"
on:click={runTestWithStepArgs}
shortCut={{
Icon: CornerDownLeft
}}
>
Run
</Button>
{/if}
</div>
{#if !hideRunButton}
<div class="w-full justify-center flex">
{#if testIsLoading}
<Button size="sm" on:click={moduleTest?.cancelJob} btnClasses="w-full" color="red">
<Loader2 size={16} class="animate-spin mr-1" />
Cancel
</Button>
{:else}
<Button
variant="accent"
btnClasses="truncate"
size="sm"
on:click={runTestWithStepArgs}
shortCut={{
Icon: CornerDownLeft
}}
>
Run
</Button>
{/if}
</div>
{/if}
<ModulePreviewForm {pickableProperties} {mod} {schema} {focusArg} />
</div>
+617 -102
View File
@@ -4,7 +4,7 @@
import type { Schema, SupportedLanguage } from '$lib/common'
import { type CompletedJob, type Job, JobService, type Preview, type ScriptLang } from '$lib/gen'
import { enterpriseLicense, userStore, workspaceStore } from '$lib/stores'
import { copyToClipboard, emptySchema, sendUserToast } from '$lib/utils'
import { copyToClipboard, emptySchema, getLocalSetting, sendUserToast, storeLocalSetting } from '$lib/utils'
import Editor from './Editor.svelte'
import { inferArgs, inferAssets, inferAnsibleExecutionMode } from '$lib/infer'
import { Pane, Splitpanes } from 'svelte-splitpanes'
@@ -23,6 +23,8 @@
import Modal from './common/modal/Modal.svelte'
import DiffEditor from './DiffEditor.svelte'
import {
AlertTriangle,
Bug,
Copy,
CornerDownLeft,
ExternalLink,
@@ -30,8 +32,25 @@
GitBranch,
Play,
PlayIcon,
Terminal,
WandSparkles
} from 'lucide-svelte'
import {
DebugToolbar,
DebugPanel,
DebugConsole,
getDAPClient,
debugState,
resetDAPClient,
getDebugServerUrl,
type DebugLanguage,
isDebuggable,
getDebugFileExtension,
fetchContextualVariables,
signDebugRequest,
getDebugErrorMessage
} from '$lib/components/debug'
import { SvelteSet } from 'svelte/reactivity'
import { setLicense } from '$lib/enterpriseUtils'
import type { ScriptEditorWhitelabelCustomUi } from './custom_ui'
import Tabs from './common/tabs/Tabs.svelte'
@@ -222,6 +241,53 @@
>()
let ansibleGitSshIdentity = $state<string[]>([])
// Debug mode state
const DEBUG_BETA_WARNING_KEY = 'debug_beta_warning_confirmed'
let showDebugBetaWarning = $state(false)
let debugMode = $state(false)
let debugBreakpoints = new SvelteSet<number>()
let breakpointDecorations: string[] = $state([])
let currentLineDecoration: string[] = $state([])
// Get the DAP server URL based on language
const dapServerUrl = $derived(
getDebugServerUrl((lang || 'python3') as DebugLanguage)
)
const debugFilePath = $derived(`/tmp/script${getDebugFileExtension(lang || '')}`)
let dapClient = $state<ReturnType<typeof getDAPClient> | null>(null)
const isDebuggableScript = $derived(isDebuggable(lang || ''))
// Derived: show debug panel when connected and (running or stopped, but not terminated)
const showDebugPanel = $derived(
debugMode && $debugState.connected && ($debugState.running || $debugState.stopped)
)
// Derived: debug has a result (script completed)
const hasDebugResult = $derived(debugMode && $debugState.result !== undefined)
// Show debug console at bottom of editor when debugging is active
let showDebugConsole = $state(true)
const debugConsoleVisible = $derived(showDebugPanel && showDebugConsole)
// Selected stack frame ID - shared between DebugPanel and DebugConsole
let selectedDebugFrameId: number | null = $state(null)
// Use selected frame or first frame for console context
const currentDebugFrameId = $derived(selectedDebugFrameId ?? $debugState.stackFrames[0]?.id)
// Job ID of the current debug session (for expression signing/audit logging)
let debugSessionJobId: string | null = $state(null)
// Pane sizes for editor/console split (percentage)
let editorPaneSize = $state(75)
let consolePaneSize = $state(25)
// Breakpoint decoration options
// stickiness: 1 = NeverGrowsWhenTypingAtEdges - decorations track their position when code changes
const breakpointDecorationType: meditor.IModelDecorationOptions = {
glyphMarginClassName: 'debug-breakpoint-glyph',
glyphMarginHoverMessage: { value: 'Breakpoint (click to remove)' },
stickiness: 1
}
const currentLineDecorationType = {
isWholeLine: true,
className: 'debug-current-line',
glyphMarginClassName: 'debug-current-line-glyph'
}
const url = new URL(window.location.toString())
let initialCollab = /true|1/i.test(url.searchParams.get('collab') ?? '0')
@@ -366,6 +432,304 @@
inferSchema(newCode)
}
// Debug functions
function toggleBreakpoint(line: number): void {
if (debugBreakpoints.has(line)) {
debugBreakpoints.delete(line)
} else {
debugBreakpoints.add(line)
}
updateBreakpointDecorations()
}
function updateBreakpointDecorations(): void {
const monacoEditor = editor?.getEditor?.()
if (!monacoEditor) return
const decorations = Array.from(debugBreakpoints).map((line) => ({
range: { startLineNumber: line, startColumn: 1, endLineNumber: line, endColumn: 1 },
options: breakpointDecorationType
}))
// Use untrack to prevent reactive loop when reading the old decorations
const oldDecorations = untrack(() => breakpointDecorations)
breakpointDecorations = monacoEditor.deltaDecorations(oldDecorations, decorations)
}
// Refresh breakpoint line numbers from decoration positions after code edits
function refreshBreakpointPositions(): void {
const monacoEditor = editor?.getEditor?.()
if (!monacoEditor || breakpointDecorations.length === 0) return
const model = monacoEditor.getModel()
if (!model) return
// Get current line numbers from decorations (Monaco tracks positions when code changes)
const newLines = new Set<number>()
for (const decorationId of breakpointDecorations) {
const range = model.getDecorationRange(decorationId)
if (range) {
newLines.add(range.startLineNumber)
}
}
// Check if positions changed
const oldLines = Array.from(debugBreakpoints).sort((a, b) => a - b)
const updatedLines = Array.from(newLines).sort((a, b) => a - b)
const positionsChanged =
oldLines.length !== updatedLines.length ||
oldLines.some((line, i) => line !== updatedLines[i])
if (positionsChanged) {
// Update breakpoints set with new positions
debugBreakpoints.clear()
for (const line of newLines) {
debugBreakpoints.add(line)
}
// Sync updated positions with server if connected
syncBreakpointsWithServer()
}
}
// Sync breakpoints with DAP server when connected
async function syncBreakpointsWithServer(): Promise<void> {
if (!dapClient || !dapClient.isConnected()) return
try {
await dapClient.setBreakpoints(debugFilePath, Array.from(debugBreakpoints))
} catch (error) {
console.error('Failed to sync breakpoints:', error)
}
}
function updateCurrentLineDecoration(line: number | undefined): void {
const monacoEditor = editor?.getEditor?.()
if (!monacoEditor) return
// Use untrack to prevent reactive loop when reading the old decorations
const oldDecorations = untrack(() => currentLineDecoration)
if (!line) {
currentLineDecoration = monacoEditor.deltaDecorations(oldDecorations, [])
return
}
const decorations = [
{
range: { startLineNumber: line, startColumn: 1, endLineNumber: line, endColumn: 1 },
options: currentLineDecorationType
}
]
currentLineDecoration = monacoEditor.deltaDecorations(oldDecorations, decorations)
monacoEditor.revealLineInCenter(line)
}
async function startDebugging(): Promise<void> {
try {
// Show console when starting a debug session
showDebugConsole = true
// Reset selected frame when starting new session
selectedDebugFrameId = null
// Always reset and create a fresh DAP client with the correct URL for the current language
// This ensures we connect to the correct endpoint even if language changed
resetDAPClient()
dapClient = getDAPClient(dapServerUrl)
// Fetch contextual variables (WM_WORKSPACE, WM_TOKEN, etc.) from backend
const env = await fetchContextualVariables($workspaceStore ?? '')
// Sign the debug request (creates audit log entry)
let signedPayload
try {
signedPayload = await signDebugRequest($workspaceStore ?? '', code ?? '', lang ?? 'python3')
debugSessionJobId = signedPayload.job_id
} catch (signError) {
sendUserToast(getDebugErrorMessage(signError), true)
return
}
await dapClient.connect()
await dapClient.initialize()
await dapClient.setBreakpoints(debugFilePath, Array.from(debugBreakpoints))
await dapClient.configurationDone()
// Pass the signed token along with other launch parameters
await dapClient.launch({
code,
cwd: '/tmp',
args: args ?? {},
callMain: true,
env,
// JWT token for verification by the debugger
token: signedPayload.token
})
} catch (error) {
console.error('Failed to start debugging:', error)
sendUserToast(getDebugErrorMessage(error), true)
}
}
async function stopDebugging(): Promise<void> {
if (!dapClient) return
try {
await dapClient.terminate()
dapClient.disconnect()
} catch (error) {
console.error('Failed to stop debugging:', error)
} finally {
// Clear the job ID when debug session ends
debugSessionJobId = null
}
}
async function continueExecution(): Promise<void> {
if (!dapClient) return
await dapClient.continue_()
}
async function stepOver(): Promise<void> {
if (!dapClient) return
await dapClient.stepOver()
}
async function stepIn(): Promise<void> {
if (!dapClient) return
await dapClient.stepIn()
}
async function stepOut(): Promise<void> {
if (!dapClient) return
await dapClient.stepOut()
}
function clearAllBreakpoints(): void {
debugBreakpoints.clear()
updateBreakpointDecorations()
}
function toggleDebugMode(): void {
if (debugMode) {
// Exiting debug mode - clean up
debugMode = false
stopDebugging()
clearAllBreakpoints()
updateCurrentLineDecoration(undefined)
} else {
// Entering debug mode - check if beta warning was confirmed
if (getLocalSetting(DEBUG_BETA_WARNING_KEY) !== 'true') {
showDebugBetaWarning = true
} else {
debugMode = true
}
}
}
function confirmDebugBetaWarning(): void {
storeLocalSetting(DEBUG_BETA_WARNING_KEY, 'true')
showDebugBetaWarning = false
debugMode = true
}
// Subscribe to debug state changes for current line highlighting
$effect(() => {
const currentLine = $debugState.currentLine
if (debugMode) {
untrack(() => updateCurrentLineDecoration(currentLine))
}
})
// Watch for language changes - exit debug mode and reset client when language changes
let lastDebugLang: typeof lang | undefined = undefined
$effect(() => {
const currentLang = lang
if (lastDebugLang !== undefined && lastDebugLang !== currentLang && debugMode) {
// Language changed while in debug mode - exit debug mode
untrack(() => {
// Stop any running debug session
if (dapClient) {
dapClient
.terminate()
.catch(() => {})
.finally(() => {
dapClient?.disconnect()
})
}
// Reset the singleton
resetDAPClient()
dapClient = null
// Exit debug mode
debugMode = false
// Clear decorations
clearAllBreakpoints()
updateCurrentLineDecoration(undefined)
})
}
lastDebugLang = currentLang
})
// Set up glyph margin click handler for breakpoints when debug mode is enabled
$effect(() => {
const monacoEditor = editor?.getEditor?.()
if (!monacoEditor) return
if (debugMode && isDebuggableScript) {
// Enable glyph margin for breakpoints
monacoEditor.updateOptions({ glyphMargin: true })
// Add click handler for glyph margin (breakpoint toggle)
const mouseDownDisposable = monacoEditor.onMouseDown((e) => {
// MouseTargetType.GUTTER_GLYPH_MARGIN = 2
if (e.target.type === 2) {
const line = e.target.position?.lineNumber
if (line) {
toggleBreakpoint(line)
}
}
})
// Add F9 keyboard shortcut for toggling breakpoint at cursor
monacoEditor.addCommand(120, () => {
// KeyCode.F9 = 120
const position = monacoEditor.getPosition()
if (position) {
toggleBreakpoint(position.lineNumber)
}
})
// Debug stepping keyboard shortcuts (only active when stopped)
// F8 = Continue (KeyCode.F8 = 119)
monacoEditor.addCommand(119, () => {
if ($debugState.stopped) continueExecution()
})
// F6 = Step Over (KeyCode.F6 = 117)
monacoEditor.addCommand(117, () => {
if ($debugState.stopped) stepOver()
})
// F7 = Step Into (KeyCode.F7 = 118)
monacoEditor.addCommand(118, () => {
if ($debugState.stopped) stepIn()
})
// Shift+F8 = Step Out (KeyMod.Shift | KeyCode.F8 = 1024 | 119 = 1143)
monacoEditor.addCommand(1143, () => {
if ($debugState.stopped) stepOut()
})
return () => {
mouseDownDisposable.dispose()
// Disable glyph margin when exiting debug mode
monacoEditor.updateOptions({ glyphMargin: false })
}
} else {
// Ensure glyph margin is disabled when not in debug mode
monacoEditor.updateOptions({ glyphMargin: false })
}
})
onMount(() => {
inferSchema(code, { applyInitialArgs: true })
loadPastTests()
@@ -436,7 +800,6 @@
export function disableCollaboration() {
if (!wsProvider?.shouldConnect) return
peers = []
console.log('collab mode disabled')
wsProvider?.disconnect()
wsProvider.destroy()
wsProvider = undefined
@@ -450,6 +813,11 @@
aiChatManager.scriptEditorOptions = undefined
aiChatManager.saveAndClear()
aiChatManager.changeMode(AIMode.NAVIGATOR)
// Clean up debug mode
if (debugMode) {
stopDebugging()
resetDAPClient()
}
})
function asKind(str: string | undefined) {
@@ -459,7 +827,7 @@
function collabUrl() {
let url = new URL(window.location.toString().split('#')[0])
url.search = ''
return `${url}?collab=1` + (edit ? '' : `&path=${path}`)
return `${url}?collab=1&workspace=${encodeURIComponent($workspaceStore ?? '')}&lang=${encodeURIComponent(lang ?? '')}` + (edit ? '' : `&path=${path}`)
}
let showTabs = $derived(hasPreprocessor)
@@ -537,7 +905,9 @@
}
aiChatManager.scriptEditorShowDiffMode = showDiffMode
aiChatManager.scriptEditorGetLintErrors = () => {
return editor?.getLintErrors() ?? { errorCount: 0, warningCount: 0, errors: [], warnings: [] }
return (
editor?.getLintErrors() ?? { errorCount: 0, warningCount: 0, errors: [], warnings: [] }
)
}
})
})
@@ -575,6 +945,24 @@
/>
</div>
</Modal>
<Modal title="Debug Feature (Beta)" bind:open={showDebugBetaWarning}>
<div class="flex items-start gap-3">
<div class="flex-shrink-0">
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-yellow-100 dark:bg-yellow-800/50">
<AlertTriangle class="h-5 w-5 text-yellow-600 dark:text-yellow-400" />
</div>
</div>
<div class="text-secondary text-sm">
<p>The Debug feature is currently in <strong>beta</strong>. You may encounter unexpected behavior or limitations.</p>
<p class="mt-2">By continuing, you acknowledge that this feature is experimental.</p>
</div>
</div>
{#snippet actions()}
<Button size="sm" on:click={confirmDebugBetaWarning}>Continue</Button>
{/snippet}
</Modal>
<div class="border-b shadow-sm px-1 pr-4" bind:clientWidth={width}>
<div class="flex justify-between space-x-2">
{#if args}
@@ -678,6 +1066,24 @@
</div>
{/if}
{#if debugMode && isDebuggableScript}
<div transition:slide={{ duration: 200 }}>
<DebugToolbar
connected={$debugState.connected}
running={$debugState.running}
stopped={$debugState.stopped}
breakpointCount={debugBreakpoints.size}
onStart={startDebugging}
onStop={stopDebugging}
onContinue={continueExecution}
onStepOver={stepOver}
onStepIn={stepIn}
onStepOut={stepOut}
onClearBreakpoints={clearAllBreakpoints}
/>
</div>
{/if}
<div class="flex justify-center pt-1 relative">
<div class="absolute top-2 left-2">
<HideButton
@@ -691,44 +1097,51 @@
}}
/>
</div>
{#if testIsLoading}
<Button on:click={jobLoader?.cancelJob} btnClasses="w-full" color="red" size="xs">
<WindmillIcon
white={true}
class="mr-2 text-white"
height="16px"
width="20px"
spin="fast"
/>
Cancel
</Button>
{:else}
{@const disableTriggerButton = customUi?.previewPanel?.disableTriggerButton === true}
<div class="flex flex-row divide-x divide-gray-800 dark:divide-gray-300 items-stretch">
<Button
on:click={() => runTest()}
btnClasses="w-full {!disableTriggerButton ? 'rounded-r-none' : ''}"
size="xs"
variant="accent-secondary"
startIcon={{ icon: Play, classes: 'animate-none' }}
shortCut={{ Icon: CornerDownLeft, hide: testIsLoading }}
>
{#if testIsLoading}
Running
{:else}
Test
{/if}
{#if !(debugMode && isDebuggableScript)}
{#if testIsLoading}
<Button on:click={jobLoader?.cancelJob} btnClasses="w-full" color="red" size="xs">
<WindmillIcon
white={true}
class="mr-2 text-white"
height="16px"
width="20px"
spin="fast"
/>
Cancel
</Button>
{#if !disableTriggerButton}
<CaptureButton on:openTriggers />
{/if}
</div>
{:else}
{@const disableTriggerButton = customUi?.previewPanel?.disableTriggerButton === true}
<div
class="flex flex-row divide-x divide-gray-800 dark:divide-gray-300 items-stretch"
>
<Button
on:click={() => runTest()}
btnClasses="w-full {!disableTriggerButton ? 'rounded-r-none' : ''}"
size="xs"
variant="accent-secondary"
startIcon={{ icon: Play, classes: 'animate-none' }}
shortCut={{ Icon: CornerDownLeft, hide: testIsLoading }}
>
{#if testIsLoading}
Running
{:else}
Test
{/if}
</Button>
{#if !disableTriggerButton}
<CaptureButton on:openTriggers />
{/if}
</div>
{/if}
{/if}
<div class="absolute top-2 right-2"
><Toggle size="2xs" bind:checked={jsonView} options={{ right: 'JSON' }} /></div
>
</div>
<Splitpanes horizontal class="!max-h-[calc(100%-43px)]">
<Splitpanes
horizontal
class="!max-h-[calc(100%-{debugMode && isDebuggableScript ? '83' : '43'}px)]"
>
<Pane size={33}>
{#if jsonView}
<div
@@ -773,16 +1186,27 @@
<LogPanel
bind:this={logPanel}
{lang}
previewJob={testJob}
previewJob={debugMode
? ({
id: 'debug',
logs: $debugState.logs,
result: $debugState.result,
success: !$debugState.error,
type: hasDebugResult ? 'CompletedJob' : 'QueuedJob'
} as any)
: testJob}
{pastPreviews}
previewIsLoading={testIsLoading}
previewIsLoading={debugMode
? $debugState.running && !$debugState.stopped
: testIsLoading}
{editor}
{diffEditor}
{args}
{showCaptures}
customUi={customUi?.previewPanel}
showCustomResultPanel={showDebugPanel}
>
{#if scriptProgress}
{#if scriptProgress && !debugMode}
<!-- Put to the slot in logpanel -->
<JobProgressBar
job={testJob}
@@ -806,6 +1230,15 @@
/>
</div>
{/snippet}
{#snippet customResultPanel()}
<DebugPanel
stackFrames={$debugState.stackFrames}
scopes={$debugState.scopes}
variables={$debugState.variables}
client={dapClient}
bind:selectedFrameId={selectedDebugFrameId}
/>
{/snippet}
</LogPanel>
</Pane>
</Splitpanes>
@@ -820,11 +1253,37 @@
{#if assets?.length}
<AssetsDropdownButton {assets} />
{/if}
{#if isDebuggableScript}
<Button
variant={debugMode ? 'accent' : 'default'}
size="xs"
onclick={toggleDebugMode}
startIcon={{ icon: Bug }}
btnClasses={debugMode
? ''
: 'bg-surface hover:bg-surface-hover border border-tertiary/30'}
title="Toggle Debug Mode"
>
{debugMode ? 'Exit Debug' : 'Debug'}
</Button>
{/if}
{#if showDebugPanel && !showDebugConsole}
<Button
variant="default"
size="xs"
onclick={() => (showDebugConsole = true)}
startIcon={{ icon: Terminal }}
btnClasses="bg-surface hover:bg-surface-hover border border-tertiary/30"
title="Show Debug Console"
>
Console
</Button>
{/if}
{#if lang === 'ansible' && hasDelegateToGitRepo}
<Button
variant="default"
size="xs"
on:click={() => (gitRepoResourcePickerOpen = true)}
onclick={() => (gitRepoResourcePickerOpen = true)}
startIcon={{ icon: GitBranch }}
btnClasses="bg-surface hover:bg-surface-hover border border-tertiary/30"
>
@@ -884,71 +1343,100 @@
{/if}
</div>
{#key lang}
<Editor
lineNumbersMinChars={4}
folding
{path}
bind:code
bind:websocketAlive
bind:this={editor}
{yContent}
awareness={wsProvider?.awareness}
on:change={(e) => {
inferSchema(e.detail)
}}
on:saveDraft
on:toggleTestPanel={toggleTestPanel}
cmdEnterAction={async () => {
await inferSchema(code)
runTest()
}}
formatAction={async () => {
await inferSchema(code)
try {
localStorage.setItem(path ?? 'last_save', code)
} catch (e) {
console.error('Could not save last_save to local storage', e)
}
dispatch('format')
}}
class="flex flex-1 h-full !overflow-visible"
scriptLang={lang}
automaticLayout={true}
{fixedOverflowWidgets}
{args}
{enablePreprocessorSnippet}
preparedAssetsSqlQueries={preparedSqlQueries.current}
/>
<DiffEditor
className="h-full"
bind:this={diffEditor}
modifiedModel={editor?.getModel() as meditor.ITextModel}
automaticLayout
defaultLang={scriptLangToEditorLang(lang)}
{fixedOverflowWidgets}
buttons={diffMode
? [
{
text: 'See changes history',
onClick: () => {
showHistoryDrawer = true
}
},
{
text: 'Quit diff mode',
onClick: () => {
hideDiffMode()
},
color: 'red'
}
]
: []}
/>
{/key}
{#if debugConsoleVisible}
<!-- Use Splitpanes when debug console is visible for resizing -->
<Splitpanes horizontal class="h-full !overflow-visible">
<Pane bind:size={editorPaneSize} minSize={20} class="!overflow-visible">
{@render editorPane()}
</Pane>
<Pane bind:size={consolePaneSize} minSize={10}>
<DebugConsole
client={dapClient}
currentFrameId={currentDebugFrameId}
onClose={() => (showDebugConsole = false)}
workspace={$workspaceStore}
jobId={debugSessionJobId ?? undefined}
/>
</Pane>
</Splitpanes>
{:else}
<!-- Normal editor without console -->
<div class="h-full !overflow-visible">
{@render editorPane()}
</div>
{/if}
</div>
{/snippet}
{#snippet editorPane()}
{#key lang}
<Editor
lineNumbersMinChars={4}
folding
{path}
bind:code
bind:websocketAlive
bind:this={editor}
{yContent}
awareness={wsProvider?.awareness}
on:change={(e) => {
inferSchema(e.detail)
// Refresh breakpoint positions when code changes (decorations track their lines)
if (debugMode && breakpointDecorations.length > 0) {
refreshBreakpointPositions()
}
}}
on:saveDraft
on:toggleTestPanel={toggleTestPanel}
cmdEnterAction={async () => {
await inferSchema(code)
runTest()
}}
formatAction={async () => {
await inferSchema(code)
try {
localStorage.setItem(path ?? 'last_save', code)
} catch (e) {
console.error('Could not save last_save to local storage', e)
}
dispatch('format')
}}
class="flex flex-1 h-full !overflow-visible"
scriptLang={lang}
automaticLayout={true}
{fixedOverflowWidgets}
{args}
{enablePreprocessorSnippet}
preparedAssetsSqlQueries={preparedSqlQueries.current}
/>
<DiffEditor
className="h-full"
bind:this={diffEditor}
modifiedModel={editor?.getModel() as meditor.ITextModel}
automaticLayout
defaultLang={scriptLangToEditorLang(lang)}
{fixedOverflowWidgets}
buttons={diffMode
? [
{
text: 'See changes history',
onClick: () => {
showHistoryDrawer = true
}
},
{
text: 'Quit diff mode',
onClick: () => {
hideDiffMode()
},
color: 'red'
}
]
: []}
/>
{/key}
{/snippet}
<GitRepoResourcePicker
bind:open={gitRepoResourcePickerOpen}
currentResource={ansibleAlternativeExecutionMode?.resource}
@@ -959,3 +1447,30 @@
on:selected={handleDelegateConfigUpdate}
on:addInventories={handleAddInventories}
/>
<style global>
/* Debug breakpoint glyph - red circle in the glyph margin */
.debug-breakpoint-glyph {
background-color: #e51400;
border-radius: 50%;
width: 10px !important;
height: 10px !important;
margin-left: 5px;
margin-top: 4px;
}
/* Current execution line - yellow background */
.debug-current-line {
background-color: rgba(255, 238, 0, 0.2);
}
/* Current execution line glyph - yellow arrow in the glyph margin */
.debug-current-line-glyph {
background-color: #ffcc00;
clip-path: polygon(0 0, 100% 50%, 0 100%);
width: 10px !important;
height: 14px !important;
margin-left: 5px;
margin-top: 2px;
}
</style>
@@ -0,0 +1,295 @@
<script lang="ts">
import { X, AlertCircle, Trash2 } from 'lucide-svelte'
import type { DAPClient } from './dapClient'
interface Props {
client: DAPClient | null
currentFrameId?: number
onClose?: () => void
/** Workspace ID for signing expressions (audit logging) */
workspace?: string
/** Job ID of the parent debug session (for expression signing) */
jobId?: string
}
interface ConsoleEntry {
id: number
type: 'input' | 'output' | 'error'
content: string
timestamp: Date
}
let { client, currentFrameId, onClose, workspace, jobId }: Props = $props()
let inputValue = $state('')
let history: ConsoleEntry[] = $state([])
let commandHistory: string[] = $state([])
let historyIndex = $state(-1)
let isEvaluating = $state(false)
let nextId = $state(1)
let consoleRef: HTMLDivElement | null = $state(null)
let inputRef: HTMLInputElement | null = $state(null)
/**
* Sign an expression for audit logging before evaluation.
* Returns the JWT token if signing succeeds, or undefined if signing is not available.
*/
async function signExpression(expression: string): Promise<string | undefined> {
if (!workspace || !jobId) {
// If workspace or jobId not provided, skip signing (for backwards compatibility)
return undefined
}
try {
const response = await fetch(`/api/w/${workspace}/debug/sign_expression`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ expression, job_id: jobId })
})
if (!response.ok) {
console.warn('Failed to sign expression:', await response.text())
// Don't block evaluation if signing fails - just log it
return undefined
}
const result = await response.json()
return result.token
} catch (error) {
console.warn('Failed to sign expression:', error)
// Don't block evaluation if signing fails - just log it
return undefined
}
}
// Focus input - only when explicitly requested (not on every click)
function focusInput(): void {
// Use requestAnimationFrame to ensure DOM is ready
requestAnimationFrame(() => {
inputRef?.focus()
})
}
// Handle click on container - only focus if clicking on empty background areas
function handleContainerClick(e: MouseEvent): void {
// Don't focus if user has selected text
const selection = window.getSelection()
if (selection && selection.toString().length > 0) {
return
}
// Only focus if clicking directly on container divs, not on text content
const target = e.target as HTMLElement
// Skip if clicking on interactive elements
if (target.tagName === 'BUTTON' || target.tagName === 'INPUT' || target.closest('button')) {
return
}
// Skip if clicking on text content (span elements with output)
if (target.tagName === 'SPAN' && target.textContent && target.textContent.trim().length > 0) {
return
}
focusInput()
}
async function evaluate(): Promise<void> {
const expression = inputValue.trim()
if (!expression || !client || isEvaluating) return
// Add to command history
if (commandHistory[commandHistory.length - 1] !== expression) {
commandHistory = [...commandHistory, expression]
}
historyIndex = -1
// Add input entry
history = [
...history,
{
id: nextId++,
type: 'input',
content: expression,
timestamp: new Date()
}
]
inputValue = ''
isEvaluating = true
try {
// Sign the expression for audit logging (if workspace and jobId are available)
const token = await signExpression(expression)
// Evaluate with the signed token
const result = await client.evaluate(expression, currentFrameId, 'repl', token)
history = [
...history,
{
id: nextId++,
type: 'output',
content: result.result ?? 'undefined',
timestamp: new Date()
}
]
} catch (error) {
history = [
...history,
{
id: nextId++,
type: 'error',
content: error instanceof Error ? error.message : String(error),
timestamp: new Date()
}
]
} finally {
isEvaluating = false
// Scroll to bottom and refocus
setTimeout(() => {
if (consoleRef) {
consoleRef.scrollTop = consoleRef.scrollHeight
}
focusInput()
}, 0)
}
}
function handleKeydown(event: KeyboardEvent): void {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
evaluate()
} else if (event.key === 'ArrowUp') {
event.preventDefault()
if (commandHistory.length > 0) {
if (historyIndex === -1) {
historyIndex = commandHistory.length - 1
} else if (historyIndex > 0) {
historyIndex--
}
inputValue = commandHistory[historyIndex]
}
} else if (event.key === 'ArrowDown') {
event.preventDefault()
if (historyIndex !== -1) {
if (historyIndex < commandHistory.length - 1) {
historyIndex++
inputValue = commandHistory[historyIndex]
} else {
historyIndex = -1
inputValue = ''
}
}
} else if (event.key === 'l' && event.ctrlKey) {
event.preventDefault()
clearConsole()
} else if (event.key === 'Escape') {
event.preventDefault()
onClose?.()
}
}
function clearConsole(): void {
history = []
focusInput()
}
// Format value for display (Chrome-like)
function formatValue(content: string, type: 'output' | 'error'): string {
if (type === 'error') return content
// Try to detect type for coloring
if (content === 'undefined' || content === 'null') return content
if (content === 'true' || content === 'false') return content
if (/^-?\d+(\.\d+)?$/.test(content)) return content
if (content.startsWith('"') && content.endsWith('"')) return content
return content
}
function getValueClass(content: string, type: 'output' | 'error'): string {
if (type === 'error') return 'text-red-500'
if (content === 'undefined' || content === 'null') return 'text-gray-500'
if (content === 'true' || content === 'false') return 'text-blue-600 dark:text-blue-400'
if (/^-?\d+(\.\d+)?$/.test(content)) return 'text-blue-600 dark:text-blue-400'
if (content.startsWith('"') && content.endsWith('"')) return 'text-red-600 dark:text-red-400'
if (content.startsWith('{') || content.startsWith('[')) return 'text-primary'
return 'text-primary'
}
</script>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="flex flex-col h-full bg-[#242424] text-[#d4d4d4] font-mono text-xs select-text"
onclick={handleContainerClick}
>
<!-- Header -->
<div class="flex items-center justify-between px-2 py-1 bg-[#1e1e1e] border-b border-[#3c3c3c]">
<span class="text-[11px] text-[#969696]">Console</span>
<div class="flex items-center gap-1">
<button
class="p-0.5 hover:bg-[#3c3c3c] rounded text-[#969696] hover:text-[#d4d4d4]"
onclick={(e) => { e.stopPropagation(); clearConsole(); }}
title="Clear console (Ctrl+L)"
>
<Trash2 size={12} />
</button>
{#if onClose}
<button
class="p-0.5 hover:bg-[#3c3c3c] rounded text-[#969696] hover:text-[#d4d4d4]"
onclick={(e) => { e.stopPropagation(); onClose?.(); }}
title="Close console (Esc)"
>
<X size={14} />
</button>
{/if}
</div>
</div>
<!-- Console output -->
<div
bind:this={consoleRef}
class="flex-1 overflow-auto min-h-0"
>
{#if history.length === 0}
<div class="px-3 py-2 text-[#969696] text-[11px]">
Evaluate expressions in the current scope. Use ↑↓ for history.
</div>
{/if}
{#each history as entry (entry.id)}
<div
class="flex items-start px-2 py-0.5 border-b border-[#3c3c3c]/50 hover:bg-[#2a2a2a]"
class:bg-[#332222]={entry.type === 'error'}
>
{#if entry.type === 'input'}
<span class="text-[#569cd6] mr-2 select-none">&gt;</span>
<span class="text-[#ce9178] break-all whitespace-pre-wrap">{entry.content}</span>
{:else if entry.type === 'output'}
<span class="text-[#569cd6] mr-2 select-none opacity-0">&gt;</span>
<span class="{getValueClass(entry.content, 'output')} break-all whitespace-pre-wrap">{formatValue(entry.content, 'output')}</span>
{:else}
<AlertCircle size={12} class="text-red-500 mr-2 mt-0.5 flex-shrink-0" />
<span class="text-red-400 break-all whitespace-pre-wrap">{entry.content}</span>
{/if}
</div>
{/each}
</div>
<!-- Input line -->
<div class="flex items-center px-2 py-1 border-t border-[#3c3c3c] bg-[#1e1e1e]">
<span class="text-[#569cd6] mr-2 select-none">&gt;</span>
<input
bind:this={inputRef}
type="text"
bind:value={inputValue}
onkeydown={handleKeydown}
placeholder={client ? '' : 'Waiting for debugger...'}
disabled={!client}
class="flex-1 bg-transparent text-[#d4d4d4] placeholder-[#6e6e6e] focus:outline-none disabled:opacity-50"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
/>
{#if isEvaluating}
<div class="w-3 h-3 border border-[#569cd6] border-t-transparent rounded-full animate-spin ml-2"></div>
{/if}
</div>
</div>
@@ -0,0 +1,120 @@
<script lang="ts">
import { Search, Layers, Variable } from 'lucide-svelte'
import type { StackFrame, Scope, Variable as VariableType, DAPClient } from './dapClient'
import DebugVariableViewer from './DebugVariableViewer.svelte'
interface Props {
stackFrames: StackFrame[]
scopes: Scope[]
variables: Map<number, VariableType[]>
client: DAPClient | null
selectedFrameId?: number | null
}
let { stackFrames, scopes, variables, client, selectedFrameId = $bindable(null) }: Props =
$props()
let searchQuery = $state('')
// Auto-expand scopes when they become available
$effect(() => {
console.log('[DebugPanel] effect running, scopes:', scopes, 'variables:', variables)
for (const scope of scopes) {
console.log(
'[DebugPanel] checking scope:',
scope.name,
'ref:',
scope.variablesReference,
'has:',
variables.has(scope.variablesReference)
)
if (!variables.has(scope.variablesReference) && client) {
console.log('[DebugPanel] fetching variables for scope:', scope.name)
client.getVariables(scope.variablesReference)
}
}
})
async function selectFrame(frame: StackFrame): Promise<void> {
selectedFrameId = frame.id
if (client) {
await client.getScopes(frame.id)
}
}
// Get all variables from all scopes, filtered by search query
const filteredVariables = $derived.by(() => {
const allVars: { scope: string; variable: VariableType }[] = []
for (const scope of scopes) {
const scopeVars = variables.get(scope.variablesReference) || []
for (const v of scopeVars) {
if (!searchQuery || v.name.toLowerCase().includes(searchQuery.toLowerCase())) {
allVars.push({ scope: scope.name, variable: v })
}
}
}
return allVars
})
</script>
<div class="flex h-full bg-surface border-t border-surface-secondary">
<!-- Variables Panel -->
<div class="flex-1 flex flex-col border-r border-surface-secondary min-w-0">
<div
class="flex items-center gap-1 px-2 py-1 border-b border-surface-secondary bg-surface-secondary"
>
<Variable size={12} class="text-secondary" />
<span class="text-xs font-medium text-secondary">Variables</span>
</div>
<div class="px-1.5 py-1 border-b border-surface-secondary">
<div
class="flex items-center gap-1.5 px-1.5 py-0.5 bg-surface border border-surface-secondary rounded focus-within:border-blue-500"
>
<Search size={12} class="text-tertiary flex-shrink-0" />
<input
type="text"
placeholder="Filter..."
bind:value={searchQuery}
class="flex-1 text-xs bg-transparent focus:outline-none min-w-0"
/>
</div>
</div>
<div class="flex-1 overflow-auto p-1">
{#if scopes.length === 0}
<div class="text-xs text-tertiary italic px-1">No variables</div>
{:else if filteredVariables.length === 0}
<div class="text-xs text-tertiary italic px-1">No matches</div>
{:else}
{#each filteredVariables as { scope, variable } (scope + variable.name)}
<DebugVariableViewer {variable} {client} />
{/each}
{/if}
</div>
</div>
<!-- Call Stack Panel -->
<div class="w-40 flex flex-col min-w-0">
<div
class="flex items-center gap-1 px-2 py-1 border-b border-surface-secondary bg-surface-secondary"
>
<Layers size={12} class="text-secondary" />
<span class="text-xs font-medium text-secondary">Call Stack</span>
</div>
<div class="flex-1 overflow-auto p-1">
{#if stackFrames.length === 0}
<div class="text-xs text-tertiary italic px-1">No call stack</div>
{:else}
{#each stackFrames as frame (frame.id)}
<button
class="w-full text-left px-1 py-0.5 text-xs hover:bg-surface-hover rounded flex items-center gap-1 font-mono"
class:bg-surface-selected={selectedFrameId === frame.id}
onclick={() => selectFrame(frame)}
>
<span class="text-primary font-medium truncate">{frame.name}</span>
<span class="text-tertiary text-[10px] whitespace-nowrap">:{frame.line}</span>
</button>
{/each}
{/if}
</div>
</div>
</div>
@@ -0,0 +1,189 @@
<script lang="ts">
import {
Bug,
Play,
Square,
SkipForward,
ArrowDownToLine,
ArrowUpFromLine,
Trash2,
AlertTriangle,
X
} from 'lucide-svelte'
import { Button } from '$lib/components/common'
interface Props {
connected: boolean
running: boolean
stopped: boolean
breakpointCount: number
onStart: () => Promise<void>
onStop: () => Promise<void>
onContinue: () => Promise<void>
onStepOver: () => Promise<void>
onStepIn: () => Promise<void>
onStepOut: () => Promise<void>
onClearBreakpoints: () => void
onExitDebug?: () => void
}
let {
connected,
running,
stopped,
breakpointCount,
onStart,
onStop,
onContinue,
onStepOver,
onStepIn,
onStepOut,
onClearBreakpoints,
onExitDebug
}: Props = $props()
let loading = $state(false)
async function handleStart(): Promise<void> {
loading = true
try {
await onStart()
} finally {
loading = false
}
}
async function handleStop(): Promise<void> {
loading = true
try {
await onStop()
} finally {
loading = false
}
}
</script>
<div class="flex items-center gap-1 p-1 border-b border-surface-secondary bg-surface-secondary">
<div class="flex items-center gap-1 mr-2">
<Bug size={16} class="text-orange-500" />
<span class="text-xs font-medium text-secondary">Debug</span>
{#if connected}
<span class="w-2 h-2 rounded-full bg-green-500" title="Connected"></span>
{:else}
<span class="w-2 h-2 rounded-full bg-gray-400" title="Disconnected"></span>
{/if}
</div>
<div class="h-4 w-px bg-surface-tertiary mx-1"></div>
{#if !connected || (!running && !stopped)}
<Button
size="xs"
color="green"
variant="contained"
startIcon={{ icon: Play }}
onclick={handleStart}
disabled={loading}
>
Debug
</Button>
{:else}
<Button
size="xs"
color="red"
variant="contained"
startIcon={{ icon: Square }}
onclick={handleStop}
disabled={loading}
>
Stop
</Button>
{/if}
<div class="h-4 w-px bg-surface-tertiary mx-1"></div>
<!-- Step controls - only enabled when stopped -->
<div class="flex items-center gap-1">
<Button
size="xs"
color="light"
startIcon={{ icon: Play }}
onclick={onContinue}
disabled={!stopped}
title="Continue (F8) - Resume execution until the next breakpoint"
iconOnly
/>
<Button
size="xs"
color="light"
startIcon={{ icon: SkipForward }}
onclick={onStepOver}
disabled={!stopped}
title="Step Over (F6) - Execute the current line, skipping over function details"
iconOnly
/>
<Button
size="xs"
color="light"
startIcon={{ icon: ArrowDownToLine }}
onclick={onStepIn}
disabled={!stopped}
title="Step Into (F7) - Enter the function call and debug inside it"
iconOnly
/>
<Button
size="xs"
color="light"
startIcon={{ icon: ArrowUpFromLine }}
onclick={onStepOut}
disabled={!stopped}
title="Step Out (Shift+F8) - Run until the current function returns"
iconOnly
/>
</div>
<div class="h-4 w-px bg-surface-tertiary mx-1"></div>
<Button
size="xs"
color="light"
startIcon={{ icon: Trash2 }}
onclick={onClearBreakpoints}
title="Clear All Breakpoints"
iconOnly
/>
{#if onExitDebug}
<div class="h-4 w-px bg-surface-tertiary mx-1"></div>
<Button
size="xs"
color="red"
variant="border"
startIcon={{ icon: X }}
onclick={onExitDebug}
title="Exit Debug Mode"
>
Exit Debug
</Button>
{/if}
{#if running && !stopped}
<span class="ml-2 text-xs text-tertiary flex items-center gap-1">
<span class="animate-pulse">Running...</span>
</span>
{:else if stopped}
<span class="ml-2 text-xs text-orange-500 flex items-center gap-1"> Paused </span>
{/if}
</div>
{#if breakpointCount === 0 && !running && !stopped}
<div
class="flex items-center gap-1 px-2 py-1 bg-yellow-50 dark:bg-yellow-900/20 border-b border-yellow-200 dark:border-yellow-800"
>
<AlertTriangle size={14} class="text-yellow-600 dark:text-yellow-500" />
<span class="text-xs text-yellow-700 dark:text-yellow-400">
No breakpoints set - click in the gutter to add one
</span>
</div>
{/if}
@@ -0,0 +1,128 @@
<script lang="ts">
import { ChevronRight, ChevronDown } from 'lucide-svelte'
import type { Variable, DAPClient } from './dapClient'
import DebugVariableViewer from './DebugVariableViewer.svelte'
interface Props {
variable: Variable
client: DAPClient | null
level?: number
prefix?: string
}
let { variable, client, level = 0, prefix = '' }: Props = $props()
let expanded = $state(false)
let children: Variable[] = $state([])
let loading = $state(false)
let loaded = $state(false)
const hasChildren = $derived(variable.variablesReference > 0)
const isExpandable = $derived(hasChildren)
async function toggleExpand(): Promise<void> {
if (!isExpandable) return
if (!expanded && !loaded && client) {
loading = true
try {
children = await client.getVariables(variable.variablesReference)
loaded = true
} catch (error) {
console.error('Failed to fetch nested variables:', error)
} finally {
loading = false
}
}
expanded = !expanded
}
function getTypeColor(type: string | undefined): string {
switch (type) {
case 'string':
return 'text-green-600 dark:text-green-400/80'
case 'number':
return 'text-orange-600 dark:text-orange-400/90'
case 'boolean':
return 'text-blue-600 dark:text-blue-400/90'
case 'undefined':
case 'null':
return 'text-tertiary'
case 'function':
return 'text-purple-600 dark:text-purple-400/90'
default:
return 'text-primary'
}
}
function formatValue(value: string, type: string | undefined): string {
// For objects/arrays with children, just show type indicator
if (hasChildren) {
if (type === 'object' || value.startsWith('{')) {
return '{...}'
}
if (value.startsWith('[')) {
return '[...]'
}
}
return value
}
</script>
<div class="font-mono text-xs" style="padding-left: {level * 12}px">
<div class="flex items-start gap-0.5 py-0.5 hover:bg-surface-hover rounded group">
{#if isExpandable}
<button
class="flex-shrink-0 p-0.5 hover:bg-surface-secondary rounded"
onclick={toggleExpand}
>
{#if loading}
<span class="inline-block w-3 h-3 border border-tertiary border-t-transparent rounded-full animate-spin"></span>
{:else if expanded}
<ChevronDown size={12} class="text-tertiary" />
{:else}
<ChevronRight size={12} class="text-tertiary" />
{/if}
</button>
{:else}
<span class="w-4 flex-shrink-0"></span>
{/if}
<div class="flex-1 flex flex-wrap items-baseline gap-1 min-w-0">
<span class="text-blue-500 font-medium">{variable.name}</span>
<span class="text-tertiary">=</span>
<span class={getTypeColor(variable.type)} title={variable.value}>
{#if hasChildren && expanded}
{variable.type === 'object' || variable.value.startsWith('{') ? '{' : '['}
{:else}
{formatValue(variable.value, variable.type)}
{/if}
</span>
{#if variable.type && !hasChildren}
<span class="text-tertiary text-[10px]">({variable.type})</span>
{/if}
</div>
</div>
{#if expanded && hasChildren}
<div class="border-l border-dotted border-surface-secondary ml-2">
{#if children.length === 0 && loaded}
<div class="text-tertiary italic py-0.5" style="padding-left: {12}px">
No properties
</div>
{:else}
{#each children as child (child.name)}
<DebugVariableViewer
variable={child}
{client}
level={level + 1}
prefix={prefix ? `${prefix}.${variable.name}` : variable.name}
/>
{/each}
{/if}
</div>
<div class="text-primary" style="padding-left: {(level + 1) * 12}px">
{variable.type === 'object' || variable.value.startsWith('{') ? '}' : ']'}
</div>
{/if}
</div>
@@ -0,0 +1,462 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte'
import { SvelteSet } from 'svelte/reactivity'
import type { editor as meditor, IDisposable } from 'monaco-editor'
import { debugState, getDAPClient, resetDAPClient, type DAPClient } from './dapClient'
import DebugToolbar from './DebugToolbar.svelte'
import DebugPanel from './DebugPanel.svelte'
import { getDebugServerUrl, getDebugFileExtension, type DebugLanguage } from './index'
import { VariableService } from '$lib/gen'
interface Props {
editor: meditor.IStandaloneCodeEditor | null
code: string
language?: DebugLanguage
filePath?: string
dapServerUrl?: string
workspace?: string
}
let { editor, code, language = 'bun', filePath, dapServerUrl, workspace }: Props = $props()
// Derive the server URL from language if not explicitly provided
const effectiveServerUrl = $derived(dapServerUrl ?? getDebugServerUrl(language))
// Derive file path from language if not explicitly provided
const effectiveFilePath = $derived(filePath ?? `/tmp/script${getDebugFileExtension(language)}`)
let client: DAPClient | null = $state(null)
let breakpointDecorations: string[] = $state([])
let currentLineDecoration: string[] = $state([])
let disposables: IDisposable[] = []
// Breakpoint glyph margin decoration
const breakpointDecorationType: meditor.IModelDecorationOptions = {
glyphMarginClassName: 'debug-breakpoint-glyph',
glyphMarginHoverMessage: { value: 'Breakpoint' },
stickiness: 1 // NeverGrowsWhenTypingAtEdges
}
// Current line decoration (yellow background when stopped)
const currentLineDecorationType: meditor.IModelDecorationOptions = {
isWholeLine: true,
className: 'debug-current-line',
glyphMarginClassName: 'debug-current-line-glyph'
}
// Track breakpoints by line number
let breakpoints = new SvelteSet<number>()
// Track the last used server URL to detect changes
let lastServerUrl: string | undefined = undefined
// React to language/server URL changes - fully exit debug mode and reset client
$effect(() => {
const newUrl = effectiveServerUrl
if (lastServerUrl !== undefined && lastServerUrl !== newUrl) {
// Server URL changed (language switched), fully exit debug mode
console.log('[DAP] Language changed, switching from', lastServerUrl, 'to', newUrl)
// Terminate and disconnect if we have an active session
if (client) {
if (client.isConnected()) {
// Try to terminate gracefully, then disconnect
client
.terminate()
.catch(() => {})
.finally(() => {
client?.disconnect()
})
}
}
// Reset the singleton and clear local client reference
resetDAPClient()
client = null
// Clear current line decoration since we're exiting debug mode
if (editor) {
currentLineDecoration = editor.deltaDecorations(currentLineDecoration, [])
}
}
lastServerUrl = newUrl
})
// Export function to refresh breakpoint positions - called by parent when code changes
export function refreshBreakpoints(): void {
updateBreakpointPositionsFromDecorations()
}
onMount(() => {
if (!editor) return
client = getDAPClient(effectiveServerUrl)
lastServerUrl = effectiveServerUrl
// Add click handler for glyph margin (breakpoint toggle)
const mouseDownDisposable = editor.onMouseDown((e) => {
if (e.target.type === 2) {
// MouseTargetType.GUTTER_GLYPH_MARGIN
const line = e.target.position?.lineNumber
if (line) {
toggleBreakpoint(line)
}
}
})
disposables.push(mouseDownDisposable)
// Add keyboard shortcut F9 for toggling breakpoint
editor.addCommand(
120, // KeyCode.F9
() => {
const position = editor?.getPosition()
if (position) {
toggleBreakpoint(position.lineNumber)
}
}
)
// Update decorations when state changes
const unsubscribe = debugState.subscribe((state) => {
updateCurrentLineDecoration(state.currentLine)
})
return () => {
unsubscribe()
}
})
onDestroy(() => {
disposables.forEach((d) => d.dispose())
disposables = []
})
function toggleBreakpoint(line: number): void {
if (breakpoints.has(line)) {
breakpoints.delete(line)
} else {
breakpoints.add(line)
}
// SvelteSet is reactive, no need to reassign
updateBreakpointDecorations()
syncBreakpointsWithServer()
}
function updateBreakpointDecorations(): void {
if (!editor) return
const model = editor.getModel()
if (!model) return
const decorations: meditor.IModelDeltaDecoration[] = Array.from(breakpoints).map((line) => ({
range: { startLineNumber: line, startColumn: 1, endLineNumber: line, endColumn: 1 },
options: breakpointDecorationType
}))
breakpointDecorations = editor.deltaDecorations(breakpointDecorations, decorations)
}
// Update breakpoint line numbers from decoration positions after code edits
function updateBreakpointPositionsFromDecorations(): void {
console.log(
'[DAP] updateBreakpointPositionsFromDecorations called, decorations:',
breakpointDecorations.length
)
if (!editor || breakpointDecorations.length === 0) return
const model = editor.getModel()
if (!model) return
// Get current line numbers from decorations (using plain Set as this is not reactive state)
const newLines: Set<number> = new Set()
for (const decorationId of breakpointDecorations) {
const range = model.getDecorationRange(decorationId)
if (range) {
newLines.add(range.startLineNumber)
}
}
// Check if positions changed
const oldLines = Array.from(breakpoints).sort((a, b) => a - b)
const updatedLines = Array.from(newLines).sort((a, b) => a - b)
console.log(
'[DAP] Old breakpoint lines:',
oldLines,
'New lines from decorations:',
updatedLines
)
const positionsChanged =
oldLines.length !== updatedLines.length ||
oldLines.some((line, i) => line !== updatedLines[i])
if (positionsChanged) {
console.log('[DAP] Breakpoint positions changed, syncing with server')
// Update breakpoints set with new positions
breakpoints.clear()
for (const line of newLines) {
breakpoints.add(line)
}
// Sync updated positions with server
syncBreakpointsWithServer()
}
}
function updateCurrentLineDecoration(line: number | undefined): void {
if (!editor) return
if (!line) {
currentLineDecoration = editor.deltaDecorations(currentLineDecoration, [])
return
}
const decorations: meditor.IModelDeltaDecoration[] = [
{
range: { startLineNumber: line, startColumn: 1, endLineNumber: line, endColumn: 1 },
options: currentLineDecorationType
}
]
currentLineDecoration = editor.deltaDecorations(currentLineDecoration, decorations)
// Scroll to the current line
editor.revealLineInCenter(line)
}
async function syncBreakpointsWithServer(): Promise<void> {
console.log(
'[DAP] syncBreakpointsWithServer called, connected:',
client?.isConnected(),
'breakpoints:',
Array.from(breakpoints)
)
if (!client || !client.isConnected()) {
console.log('[DAP] Not syncing - client not connected')
return
}
try {
console.log(
'[DAP] Sending setBreakpoints to server:',
effectiveFilePath,
Array.from(breakpoints)
)
await client.setBreakpoints(effectiveFilePath, Array.from(breakpoints))
console.log('[DAP] setBreakpoints completed')
} catch (error) {
console.error('Failed to sync breakpoints:', error)
}
}
/**
* Fetch contextual variables from the backend to pass to the debugger
*/
async function fetchContextualVariables(): Promise<Record<string, string>> {
console.log('[DAP] fetchContextualVariables called, workspace:', workspace)
if (!workspace) {
console.log('[DAP] No workspace provided, skipping contextual variables')
return {}
}
try {
console.log('[DAP] Fetching contextual variables for workspace:', workspace)
const variables = await VariableService.listContextualVariables({ workspace })
console.log('[DAP] Got contextual variables:', variables)
const envVars: Record<string, string> = {}
for (const v of variables) {
envVars[v.name] = v.value
}
console.log('[DAP] Parsed env vars:', Object.keys(envVars))
return envVars
} catch (error) {
console.error('Failed to fetch contextual variables:', error)
return {}
}
}
async function signDebugRequest(codeToSign: string, lang: string): Promise<{
token: string
code: string
}> {
if (!workspace) {
throw new Error('No workspace selected')
}
const response = await fetch(`/api/w/${workspace}/debug/sign`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: codeToSign, language: lang })
})
if (!response.ok) {
const errorText = await response.text()
// Parse specific error cases for better user messages
if (errorText.includes('not initialized')) {
throw new Error('Debug signing is not configured on the server. Please contact your administrator.')
}
throw new Error(errorText || 'Failed to authorize debug session')
}
return await response.json()
}
function getDebugErrorMessage(error: unknown): string {
const message = error instanceof Error ? error.message : String(error)
// Handle token verification errors from debugger
if (message.includes('Token verification failed') || message.includes('Debug token required')) {
if (message.includes('expired')) {
return 'Debug session expired. Please try again.'
}
if (message.includes('Invalid JWT signature')) {
return 'Debug authorization failed. The signing key may be misconfigured.'
}
if (message.includes('Code hash mismatch')) {
return 'Code was modified after signing. Please try again.'
}
if (message.includes('Public key not available')) {
return 'Debug server cannot verify tokens. Please check WINDMILL_BASE_URL configuration.'
}
if (message.includes('Debug token required')) {
return 'Debug authorization required. The debug session must be signed by the backend.'
}
return 'Debug authorization failed. Please try again.'
}
// Handle connection errors
if (message.includes('WebSocket') || message.includes('connection failed')) {
return 'Could not connect to debug server. Make sure the DAP server is running.'
}
// Handle signing errors
if (message.includes('not configured on the server')) {
return message
}
return message || 'An unexpected error occurred while starting the debugger.'
}
async function startDebugging(): Promise<void> {
// Always reset and create a fresh client with the current server URL
// This ensures we connect to the correct endpoint for the current language
resetDAPClient()
client = getDAPClient(effectiveServerUrl)
try {
// Fetch contextual variables (WM_WORKSPACE, WM_TOKEN, etc.) from backend
const env = await fetchContextualVariables()
console.log('[DAP] Starting debug with env vars:', Object.keys(env), env)
// Sign the debug request (creates audit log entry)
let signedPayload
try {
signedPayload = await signDebugRequest(code ?? '', language)
console.log('[DAP] Got signed payload from backend')
} catch (signError) {
console.error('[DAP] Signing failed:', getDebugErrorMessage(signError))
return
}
await client.connect()
await client.initialize()
await client.setBreakpoints(effectiveFilePath, Array.from(breakpoints))
await client.configurationDone()
await client.launch({
code,
cwd: '/tmp',
env,
// JWT token for verification by the debugger
token: signedPayload.token
})
console.log('[DAP] Launch completed with env')
} catch (error) {
console.error('[DAP] Failed to start debugging:', getDebugErrorMessage(error))
}
}
async function stopDebugging(): Promise<void> {
if (!client) return
try {
await client.terminate()
client.disconnect()
} catch (error) {
console.error('Failed to stop debugging:', error)
}
}
async function continueExecution(): Promise<void> {
if (!client) return
await client.continue_()
}
async function stepOver(): Promise<void> {
if (!client) return
await client.stepOver()
}
async function stepIn(): Promise<void> {
if (!client) return
await client.stepIn()
}
async function stepOut(): Promise<void> {
if (!client) return
await client.stepOut()
}
function clearAllBreakpoints(): void {
breakpoints.clear()
updateBreakpointDecorations()
syncBreakpointsWithServer()
}
</script>
<div class="flex flex-col h-full">
<DebugToolbar
connected={$debugState.connected}
running={$debugState.running}
stopped={$debugState.stopped}
breakpointCount={breakpoints.size}
onStart={startDebugging}
onStop={stopDebugging}
onContinue={continueExecution}
onStepOver={stepOver}
onStepIn={stepIn}
onStepOut={stepOut}
onClearBreakpoints={clearAllBreakpoints}
/>
{#if $debugState.connected}
<DebugPanel
stackFrames={$debugState.stackFrames}
scopes={$debugState.scopes}
variables={$debugState.variables}
{client}
/>
{/if}
</div>
<style>
:global(.debug-breakpoint-glyph) {
background-color: #e51400;
border-radius: 50%;
width: 10px !important;
height: 10px !important;
margin-left: 5px;
margin-top: 4px;
}
:global(.debug-current-line) {
background-color: rgba(255, 238, 0, 0.2);
}
:global(.debug-current-line-glyph) {
background-color: #ffcc00;
clip-path: polygon(0 0, 100% 50%, 0 100%);
width: 10px !important;
height: 14px !important;
margin-left: 5px;
margin-top: 2px;
}
</style>
@@ -0,0 +1,553 @@
/**
* 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:5679') {
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
}
}
@@ -0,0 +1,153 @@
/**
* Shared debug utility functions used across ScriptEditor, FlowModuleComponent,
* and RawAppInlineScriptEditor.
*/
import { VariableService, UserService } from '$lib/gen'
/**
* Fetch contextual variables (WM_WORKSPACE, WM_TOKEN, etc.) for the debugger.
* Creates a fresh short-lived token for the debug session.
*/
export async function fetchContextualVariables(
workspace: string
): Promise<Record<string, string>> {
if (!workspace) {
return {}
}
try {
const variables = await VariableService.listContextualVariables({ workspace })
const envVars: Record<string, string> = {}
for (const v of variables) {
envVars[v.name] = v.value
}
// Create a fresh token with 15-minute expiration for the debugger
try {
const expirationDate = new Date(Date.now() + 15 * 60 * 1000)
const freshToken = await UserService.createToken({
requestBody: {
label: 'debugger-token',
expiration: expirationDate.toISOString(),
workspace_id: workspace
}
})
envVars['WM_TOKEN'] = freshToken
} catch (tokenError) {
console.error('Failed to create debugger token:', tokenError)
}
return envVars
} catch (error) {
console.error('Failed to fetch contextual variables:', error)
return {}
}
}
/**
* Sign a debug request with the backend. This creates an audit log entry
* and returns a signed token that authorizes the debug session.
*/
export async function signDebugRequest(
workspace: string,
code: string,
language: string
): Promise<{ token: string; code: string; job_id: string }> {
if (!workspace) {
throw new Error('No workspace selected')
}
const response = await fetch(`/api/w/${workspace}/debug/sign`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, language })
})
if (!response.ok) {
const errorText = await response.text()
if (errorText.includes('not initialized')) {
throw new Error(
'Debug signing is not configured on the server. Please contact your administrator.'
)
}
throw new Error(errorText || 'Failed to authorize debug session')
}
return await response.json()
}
/**
* Get a user-friendly error message for debug errors
*/
export function getDebugErrorMessage(error: unknown): string {
const message = error instanceof Error ? error.message : String(error)
// Handle token verification errors from debugger
if (message.includes('Token verification failed') || message.includes('Debug token required')) {
if (message.includes('expired')) {
return 'Debug session expired. Please try again.'
}
if (message.includes('Invalid JWT signature')) {
return 'Debug authorization failed. The signing key may be misconfigured.'
}
if (message.includes('Code hash mismatch')) {
return 'Code was modified after signing. Please try again.'
}
if (message.includes('Public key not available')) {
return 'Debug server cannot verify tokens. Please check WINDMILL_BASE_URL configuration.'
}
if (message.includes('Debug token required')) {
return 'Debug authorization required. The debug session must be signed by the backend.'
}
return 'Debug authorization failed. Please try again.'
}
// Handle connection errors
if (message.includes('WebSocket') || message.includes('connection failed')) {
return 'Could not connect to debug server. Make sure the DAP server is running.'
}
// Handle HTTP errors
if (message.includes('401') || message.includes('Unauthorized')) {
return 'Debug session unauthorized. Please check your permissions.'
}
if (message.includes('404')) {
return 'Debug service not available. Please check if the debugger is enabled.'
}
if (message.includes('timeout') || message.includes('ETIMEDOUT')) {
return 'Debug service connection timed out. Please try again.'
}
// Handle signing errors
if (message.includes('not configured on the server')) {
return message
}
return message || 'An unexpected error occurred while starting the debugger.'
}
/**
* Check if a language supports debugging
*/
export function isDebuggableLanguage(language: string | undefined): boolean {
if (!language) return false
return ['python3', 'bun', 'typescript', 'deno', 'nativets'].includes(language)
}
/**
* Get the file extension for a language (used for debug file path)
*/
export function getDebugFileExtension(language: string | undefined): string {
switch (language) {
case 'python3':
return '.py'
case 'bun':
case 'typescript':
case 'deno':
case 'nativets':
return '.ts'
default:
return '.txt'
}
}
+108
View File
@@ -0,0 +1,108 @@
/**
* Debug module exports for Python and TypeScript debugging in Windmill.
*
* This module provides a minimal DAP (Debug Adapter Protocol) implementation
* for debugging scripts in the Monaco editor.
*
* Supported Languages:
* - Python: Uses debugpy via the unified DAP Debug Service
* - TypeScript/Bun: Uses V8 Inspector Protocol via the unified DAP Debug Service
*
* Usage:
* 1. Start the unified DAP Debug Service:
* bun run src/lib/debug/dap_debug_service.ts
*
* 2. Import and use MonacoDebugger component in your editor
*
* The service provides path-based routing:
* - /python - Python debugging via debugpy
* - /typescript - TypeScript/Bun debugging via WebKit Inspector
* - /bun - Alias for /typescript
*
* Example:
* ```svelte
* <script>
* import MonacoDebugger from '$lib/debug/MonacoDebugger.svelte'
* let editor // Monaco editor instance
* let code = 'console.log("Hello")'
* </script>
*
* <!-- Option 1: Use language prop (recommended) -->
* <MonacoDebugger {editor} {code} language="bun" />
*
* <!-- Option 2: Explicit URL and path -->
* <MonacoDebugger
* {editor}
* {code}
* dapServerUrl="ws://localhost:5679/typescript"
* filePath="/tmp/script.ts"
* />
* ```
*/
export { default as MonacoDebugger } from './MonacoDebugger.svelte'
export { default as DebugToolbar } from './DebugToolbar.svelte'
export { default as DebugPanel } from './DebugPanel.svelte'
export { default as DebugVariableViewer } from './DebugVariableViewer.svelte'
export { default as DebugConsole } from './DebugConsole.svelte'
export {
DAPClient,
getDAPClient,
resetDAPClient,
debugState,
type DebugState,
type Breakpoint,
type StackFrame,
type Variable,
type Scope
} from './dapClient'
// Re-export shared utilities
export {
fetchContextualVariables,
signDebugRequest,
getDebugErrorMessage,
isDebuggableLanguage,
getDebugFileExtension
} from './debugUtils'
/**
* Language to debug endpoint path mapping.
* Uses the unified DAP Debug Service with path-based routing.
*/
export const DAP_ENDPOINT_PATHS = {
python3: '/python',
bun: '/bun',
typescript: '/typescript',
nativets: '/typescript',
deno: '/typescript'
} as const
/**
* Supported debug languages
*/
export type DebugLanguage = keyof typeof DAP_ENDPOINT_PATHS
/**
* Get the WebSocket URL for the DAP debug server.
* Routes through the reverse proxy at /ws_debug/* in production.
*
* @param language - The script language (python3, bun, typescript, etc.)
* @returns The full WebSocket URL for the debug server
*/
export function getDebugServerUrl(language: DebugLanguage): string {
const path = DAP_ENDPOINT_PATHS[language] || DAP_ENDPOINT_PATHS.python3
if (typeof window === 'undefined') {
// SSR fallback
return `ws://localhost:5679${path}`
}
const wsProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
return `${wsProtocol}://${window.location.host}/ws_debug${path}`
}
/**
* @deprecated Use isDebuggableLanguage instead
*/
export function isDebuggable(language: string): boolean {
return ['python3', 'bun', 'typescript', 'deno', 'nativets'].includes(language)
}
@@ -51,6 +51,8 @@
import { workspaceStore } from '$lib/stores'
import { checkIfParentLoop } from '../utils.svelte'
import ModulePreviewResultViewer from '$lib/components/ModulePreviewResultViewer.svelte'
import LogViewer from '$lib/components/LogViewer.svelte'
import DisplayResult from '$lib/components/DisplayResult.svelte'
import { refreshStateStore } from '$lib/svelte5Utils.svelte'
import { getStepHistoryLoaderContext } from '$lib/components/stepHistoryLoader.svelte'
import AssetsDropdownButton from '$lib/components/assets/AssetsDropdownButton.svelte'
@@ -58,6 +60,26 @@
import { editor as meditor } from 'monaco-editor'
import { DynamicInput } from '$lib/utils'
import { usePreparedAssetSqlQueries } from '$lib/infer.svelte'
import { SvelteSet } from 'svelte/reactivity'
import { slide } from 'svelte/transition'
import {
DebugToolbar,
DebugPanel,
DebugConsole,
getDAPClient,
debugState,
resetDAPClient,
getDebugServerUrl,
type DebugLanguage,
isDebuggable,
getDebugFileExtension,
fetchContextualVariables,
signDebugRequest,
getDebugErrorMessage
} from '$lib/components/debug'
import { AlertTriangle, Bug, Terminal } from 'lucide-svelte'
import Modal from '$lib/components/common/modal/Modal.svelte'
import { getLocalSetting, sendUserToast, storeLocalSetting } from '$lib/utils'
const {
selectionManager,
@@ -352,6 +374,330 @@
() => flowGraphAssetsCtx?.val.sqlQueries[selectedId],
() => $workspaceStore
)
// Debug mode state
const DEBUG_BETA_WARNING_KEY = 'debug_beta_warning_confirmed'
let showDebugBetaWarning = $state(false)
let debugMode = $state(false)
let debugBreakpoints = new SvelteSet<number>()
let breakpointDecorations: string[] = $state([])
let currentLineDecoration: string[] = $state([])
let dapClient = $state<ReturnType<typeof getDAPClient> | null>(null)
let selectedDebugFrameId: number | null = $state(null)
let debugSessionJobId: string | null = $state(null)
let showDebugConsole = $state(true)
let editorPaneSize = $state(75)
let consolePaneSize = $state(25)
// Get the DAP server URL based on language
const dapServerUrl = $derived(
getDebugServerUrl((rawScriptLang || 'python3') as DebugLanguage)
)
const debugFilePath = $derived(`/tmp/script${getDebugFileExtension(rawScriptLang ?? '')}`)
const isDebuggableScript = $derived(isDebuggable(rawScriptLang ?? ''))
const showDebugPanel = $derived(
debugMode && $debugState.connected && ($debugState.running || $debugState.stopped)
)
const hasDebugResult = $derived(debugMode && $debugState.result !== undefined)
const debugConsoleVisible = $derived(showDebugPanel && showDebugConsole)
const currentDebugFrameId = $derived(selectedDebugFrameId ?? $debugState.stackFrames[0]?.id)
// Breakpoint decoration options
const breakpointDecorationType: meditor.IModelDecorationOptions = {
glyphMarginClassName: 'debug-breakpoint-glyph',
glyphMarginHoverMessage: { value: 'Breakpoint (click to remove)' },
stickiness: 1
}
const currentLineDecorationType = {
isWholeLine: true,
className: 'debug-current-line',
glyphMarginClassName: 'debug-current-line-glyph'
}
// Debug functions
function toggleBreakpoint(line: number): void {
if (debugBreakpoints.has(line)) {
debugBreakpoints.delete(line)
} else {
debugBreakpoints.add(line)
}
updateBreakpointDecorations()
}
function updateBreakpointDecorations(): void {
const monacoEditor = editor?.getEditor?.()
if (!monacoEditor) return
const decorations = Array.from(debugBreakpoints).map((line) => ({
range: { startLineNumber: line, startColumn: 1, endLineNumber: line, endColumn: 1 },
options: breakpointDecorationType
}))
const oldDecorations = untrack(() => breakpointDecorations)
breakpointDecorations = monacoEditor.deltaDecorations(oldDecorations, decorations)
}
function refreshBreakpointPositions(): void {
const monacoEditor = editor?.getEditor?.()
if (!monacoEditor || breakpointDecorations.length === 0) return
const model = monacoEditor.getModel()
if (!model) return
const newLines = new Set<number>()
for (const decorationId of breakpointDecorations) {
const range = model.getDecorationRange(decorationId)
if (range) {
newLines.add(range.startLineNumber)
}
}
const oldLines = Array.from(debugBreakpoints).sort((a, b) => a - b)
const updatedLines = Array.from(newLines).sort((a, b) => a - b)
const positionsChanged =
oldLines.length !== updatedLines.length ||
oldLines.some((line, i) => line !== updatedLines[i])
if (positionsChanged) {
debugBreakpoints.clear()
for (const line of newLines) {
debugBreakpoints.add(line)
}
syncBreakpointsWithServer()
}
}
async function syncBreakpointsWithServer(): Promise<void> {
if (!dapClient || !dapClient.isConnected()) return
try {
await dapClient.setBreakpoints(debugFilePath, Array.from(debugBreakpoints))
} catch (error) {
console.error('Failed to sync breakpoints:', error)
}
}
function updateCurrentLineDecoration(line: number | undefined): void {
const monacoEditor = editor?.getEditor?.()
if (!monacoEditor) return
const oldDecorations = untrack(() => currentLineDecoration)
if (!line) {
currentLineDecoration = monacoEditor.deltaDecorations(oldDecorations, [])
return
}
const decorations = [
{
range: { startLineNumber: line, startColumn: 1, endLineNumber: line, endColumn: 1 },
options: currentLineDecorationType
}
]
currentLineDecoration = monacoEditor.deltaDecorations(oldDecorations, decorations)
monacoEditor.revealLineInCenter(line)
}
async function startDebugging(): Promise<void> {
if (flowModule.value.type !== 'rawscript') return
try {
showDebugConsole = true
selectedDebugFrameId = null
resetDAPClient()
dapClient = getDAPClient(dapServerUrl)
const env = await fetchContextualVariables($workspaceStore ?? '')
const code = flowModule.value.content
let signedPayload
try {
signedPayload = await signDebugRequest($workspaceStore ?? '', code ?? '', rawScriptLang ?? 'python3')
debugSessionJobId = signedPayload.job_id
} catch (signError) {
sendUserToast(getDebugErrorMessage(signError), true)
return
}
// Get static args from input transforms
const args = Object.entries(flowModule.value.input_transforms).reduce<Record<string, unknown>>((acc, [key, obj]) => {
if (obj.type === 'static') {
acc[key] = obj.value
}
return acc
}, {})
await dapClient.connect()
await dapClient.initialize()
await dapClient.setBreakpoints(debugFilePath, Array.from(debugBreakpoints))
await dapClient.configurationDone()
await dapClient.launch({
code,
cwd: '/tmp',
args,
callMain: true,
env,
token: signedPayload.token
})
} catch (error) {
console.error('Failed to start debugging:', error)
sendUserToast(getDebugErrorMessage(error), true)
}
}
async function stopDebugging(): Promise<void> {
if (!dapClient) return
try {
await dapClient.terminate()
dapClient.disconnect()
} catch (error) {
console.error('Failed to stop debugging:', error)
} finally {
debugSessionJobId = null
}
}
async function continueExecution(): Promise<void> {
if (!dapClient) return
await dapClient.continue_()
}
async function stepOver(): Promise<void> {
if (!dapClient) return
await dapClient.stepOver()
}
async function stepIn(): Promise<void> {
if (!dapClient) return
await dapClient.stepIn()
}
async function stepOut(): Promise<void> {
if (!dapClient) return
await dapClient.stepOut()
}
function clearAllBreakpoints(): void {
debugBreakpoints.clear()
updateBreakpointDecorations()
}
function toggleDebugMode(): void {
if (debugMode) {
// Exiting debug mode - clean up
debugMode = false
stopDebugging()
clearAllBreakpoints()
updateCurrentLineDecoration(undefined)
} else {
// Entering debug mode - check if beta warning was confirmed
if (getLocalSetting(DEBUG_BETA_WARNING_KEY) !== 'true') {
showDebugBetaWarning = true
} else {
debugMode = true
// Switch to test tab when entering debug mode
selected = 'test'
}
}
}
function confirmDebugBetaWarning(): void {
storeLocalSetting(DEBUG_BETA_WARNING_KEY, 'true')
showDebugBetaWarning = false
debugMode = true
// Switch to test tab when entering debug mode
selected = 'test'
}
// Subscribe to debug state changes for current line highlighting
$effect(() => {
const currentLine = $debugState.currentLine
if (debugMode) {
untrack(() => updateCurrentLineDecoration(currentLine))
}
})
// Watch for language changes - exit debug mode when language changes
let lastDebugLang: typeof rawScriptLang | undefined = undefined
$effect(() => {
const currentLang = rawScriptLang
if (lastDebugLang !== undefined && lastDebugLang !== currentLang && debugMode) {
untrack(() => {
if (dapClient) {
dapClient.terminate().catch(() => {}).finally(() => {
dapClient?.disconnect()
})
}
resetDAPClient()
dapClient = null
debugMode = false
clearAllBreakpoints()
updateCurrentLineDecoration(undefined)
})
}
lastDebugLang = currentLang
})
// Set up glyph margin click handler for breakpoints when debug mode is enabled
$effect(() => {
const monacoEditor = editor?.getEditor?.()
if (!monacoEditor) return
if (debugMode && isDebuggableScript) {
monacoEditor.updateOptions({ glyphMargin: true })
const mouseDownDisposable = monacoEditor.onMouseDown((e) => {
if (e.target.type === 2) {
const line = e.target.position?.lineNumber
if (line) {
toggleBreakpoint(line)
}
}
})
monacoEditor.addCommand(120, () => {
const position = monacoEditor.getPosition()
if (position) {
toggleBreakpoint(position.lineNumber)
}
})
monacoEditor.addCommand(119, () => {
if ($debugState.stopped) continueExecution()
})
monacoEditor.addCommand(117, () => {
if ($debugState.stopped) stepOver()
})
monacoEditor.addCommand(118, () => {
if ($debugState.stopped) stepIn()
})
monacoEditor.addCommand(1143, () => {
if ($debugState.stopped) stepOut()
})
return () => {
mouseDownDisposable.dispose()
monacoEditor.updateOptions({ glyphMargin: false })
}
} else {
monacoEditor.updateOptions({ glyphMargin: false })
}
})
// Clean up debug mode on destroy
import { onDestroy as onDestroyHook } from 'svelte'
onDestroyHook(() => {
if (debugMode) {
stopDebugging()
resetDAPClient()
}
})
</script>
<svelte:window onkeydown={onKeyDown} />
@@ -464,54 +810,149 @@
{#if assets?.length}
<AssetsDropdownButton {assets} />
{/if}
{#if isDebuggableScript}
<Button
variant={debugMode ? 'accent' : 'default'}
size="xs"
onclick={toggleDebugMode}
startIcon={{ icon: Bug }}
btnClasses={debugMode
? ''
: 'bg-surface hover:bg-surface-hover border border-tertiary/30'}
title="Toggle Debug Mode"
>
{debugMode ? 'Exit Debug' : 'Debug'}
</Button>
{/if}
{#if showDebugPanel && !showDebugConsole}
<Button
variant="default"
size="xs"
onclick={() => (showDebugConsole = true)}
startIcon={{ icon: Terminal }}
btnClasses="bg-surface hover:bg-surface-hover border border-tertiary/30"
title="Show Debug Console"
>
Console
</Button>
{/if}
</div>
<div id="flow-editor-code-section" class="h-full relative">
<Editor
loadAsync
folding
path={$pathStore + '/' + flowModule.id}
bind:websocketAlive
bind:this={editor}
class="h-full relative"
code={flowModule.value.content}
scriptLang={flowModule?.value?.language}
automaticLayout={true}
cmdEnterAction={async () => {
selected = 'test'
if (selectedId == flowModule.id) {
if (flowModule.value.type === 'rawscript' && editor) {
flowModule.value.content = editor.getCode()
{#if debugConsoleVisible}
<Splitpanes horizontal class="h-full">
<Pane bind:size={editorPaneSize} minSize={20}>
<div id="flow-editor-code-section" class="h-full relative">
<Editor
loadAsync
folding
path={$pathStore + '/' + flowModule.id}
bind:websocketAlive
bind:this={editor}
class="h-full relative"
code={flowModule.value.content}
scriptLang={flowModule?.value?.language}
automaticLayout={true}
cmdEnterAction={async () => {
selected = 'test'
if (selectedId == flowModule.id) {
if (flowModule.value.type === 'rawscript' && editor) {
flowModule.value.content = editor.getCode()
}
await reload(flowModule)
modulePreview?.runTestWithStepArgs()
}
}}
on:change={async (event) => {
const content = event.detail
if (flowModule.value.type === 'rawscript') {
if (flowModule.value.content !== content) {
flowModule.value.content = content
}
await reload(flowModule)
if (debugMode && breakpointDecorations.length > 0) {
refreshBreakpointPositions()
}
}
}}
formatAction={() => {
reload(flowModule)
saveDraft()
}}
fixedOverflowWidgets={true}
args={Object.entries(flowModule.value.input_transforms).reduce(
(acc, [key, obj]) => {
acc[key] = obj.type === 'static' ? obj.value : undefined
return acc
},
{}
)}
key={`flow-inline-${$workspaceStore}-${$pathStore}-${flowModule.id}`}
moduleId={flowModule.id}
preparedAssetsSqlQueries={preparedSqlQueries.current}
/>
</div>
</Pane>
<Pane bind:size={consolePaneSize} minSize={10}>
<DebugConsole
client={dapClient}
currentFrameId={currentDebugFrameId}
onClose={() => (showDebugConsole = false)}
workspace={$workspaceStore}
jobId={debugSessionJobId ?? undefined}
/>
</Pane>
</Splitpanes>
{:else}
<div id="flow-editor-code-section" class="h-full relative">
<Editor
loadAsync
folding
path={$pathStore + '/' + flowModule.id}
bind:websocketAlive
bind:this={editor}
class="h-full relative"
code={flowModule.value.content}
scriptLang={flowModule?.value?.language}
automaticLayout={true}
cmdEnterAction={async () => {
selected = 'test'
if (selectedId == flowModule.id) {
if (flowModule.value.type === 'rawscript' && editor) {
flowModule.value.content = editor.getCode()
}
await reload(flowModule)
modulePreview?.runTestWithStepArgs()
}
await reload(flowModule)
modulePreview?.runTestWithStepArgs()
}
}}
on:change={async (event) => {
const content = event.detail
if (flowModule.value.type === 'rawscript') {
if (flowModule.value.content !== content) {
flowModule.value.content = content
}}
on:change={async (event) => {
const content = event.detail
if (flowModule.value.type === 'rawscript') {
if (flowModule.value.content !== content) {
flowModule.value.content = content
}
await reload(flowModule)
if (debugMode && breakpointDecorations.length > 0) {
refreshBreakpointPositions()
}
}
await reload(flowModule)
}
}}
formatAction={() => {
reload(flowModule)
saveDraft()
}}
fixedOverflowWidgets={true}
args={Object.entries(flowModule.value.input_transforms).reduce(
(acc, [key, obj]) => {
acc[key] = obj.type === 'static' ? obj.value : undefined
return acc
},
{}
)}
key={`flow-inline-${$workspaceStore}-${$pathStore}-${flowModule.id}`}
moduleId={flowModule.id}
preparedAssetsSqlQueries={preparedSqlQueries.current}
/>
</div>
}}
formatAction={() => {
reload(flowModule)
saveDraft()
}}
fixedOverflowWidgets={true}
args={Object.entries(flowModule.value.input_transforms).reduce(
(acc, [key, obj]) => {
acc[key] = obj.type === 'static' ? obj.value : undefined
return acc
},
{}
)}
key={`flow-inline-${$workspaceStore}-${$pathStore}-${flowModule.id}`}
moduleId={flowModule.id}
preparedAssetsSqlQueries={preparedSqlQueries.current}
/>
</div>
{/if}
<DiffEditor
open={false}
bind:this={diffEditor}
@@ -616,6 +1057,24 @@
</PropPickerWrapper>
</div>
{:else if selected === 'test'}
{#if debugMode && isDebuggableScript}
<div transition:slide={{ duration: 200 }}>
<DebugToolbar
connected={$debugState.connected}
running={$debugState.running}
stopped={$debugState.stopped}
breakpointCount={debugBreakpoints.size}
onStart={startDebugging}
onStop={stopDebugging}
onContinue={continueExecution}
onStepOver={stepOver}
onStepIn={stepIn}
onStepOut={stepOut}
onClearBreakpoints={clearAllBreakpoints}
onExitDebug={toggleDebugMode}
/>
</div>
{/if}
<ModulePreview
class="flex-1"
pickableProperties={stepPropPicker.pickableProperties}
@@ -628,6 +1087,7 @@
bind:scriptProgress
focusArg={highlightArg}
{onJobDone}
hideRunButton={debugMode && isDebuggableScript}
/>
{:else if selected === 'advanced'}
<Tabs bind:selected={advancedSelected} wrapperClass="shrink-0">
@@ -922,28 +1382,78 @@
>
</div>
{/if}
<ModulePreviewResultViewer
lang={flowModule.value['language'] ?? 'deno'}
{editor}
{diffEditor}
loopStatus={parentLoop
? { type: 'inside', flow: parentLoop.type }
: undefined}
onUpdateMock={(detail) => {
flowModule.mock = detail
flowModule = flowModule
refreshStateStore(flowStore)
}}
{testJob}
{scriptProgress}
mod={flowModule}
{testIsLoading}
disableMock={preprocessorModule || failureModule}
disableHistory={failureModule}
loadingJob={stepHistoryLoader?.stepStates[flowModule.id]?.loadingJobs}
tagLabel={customUi?.tagLabel}
bind:this={modulePreviewResultViewer}
/>
{#if showDebugPanel || hasDebugResult}
<Splitpanes horizontal class="h-full">
<Pane size={50} minSize={15}>
<Splitpanes horizontal class="h-full">
<Pane size={50} minSize={10}>
<LogViewer
small
content={$debugState.logs}
isLoading={$debugState.running && !$debugState.stopped}
tag={undefined}
/>
</Pane>
<Pane size={50} minSize={10}>
{#if hasDebugResult}
<div class="h-full p-2 overflow-auto">
<DisplayResult
result={$debugState.result}
language={rawScriptLang}
/>
</div>
{:else}
<div class="h-full flex items-center justify-center text-sm text-tertiary">
{#if $debugState.running && !$debugState.stopped}
Running...
{:else if $debugState.stopped}
Paused at breakpoint
{:else}
Waiting for debug session
{/if}
</div>
{/if}
</Pane>
</Splitpanes>
</Pane>
<Pane size={50} minSize={15}>
<DebugPanel
stackFrames={$debugState.stackFrames}
scopes={$debugState.scopes}
variables={$debugState.variables}
client={dapClient}
bind:selectedFrameId={selectedDebugFrameId}
/>
</Pane>
</Splitpanes>
{:else if debugMode && isDebuggableScript}
<div class="h-full flex items-center justify-center text-sm text-tertiary">
Click "Debug" in the toolbar to start debugging
</div>
{:else}
<ModulePreviewResultViewer
lang={flowModule.value['language'] ?? 'deno'}
{editor}
{diffEditor}
loopStatus={parentLoop
? { type: 'inside', flow: parentLoop.type }
: undefined}
onUpdateMock={(detail) => {
flowModule.mock = detail
flowModule = flowModule
refreshStateStore(flowStore)
}}
{testJob}
{scriptProgress}
mod={flowModule}
{testIsLoading}
disableMock={preprocessorModule || failureModule}
disableHistory={failureModule}
loadingJob={stepHistoryLoader?.stepStates[flowModule.id]?.loadingJobs}
tagLabel={customUi?.tagLabel}
bind:this={modulePreviewResultViewer}
/>
{/if}
</Pane>
{/if}
</Splitpanes>
@@ -956,3 +1466,20 @@
{:else}
Incorrect flow module type
{/if}
<Modal title="Debug Feature (Beta)" bind:open={showDebugBetaWarning}>
<div class="flex items-start gap-3">
<div class="flex-shrink-0">
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-yellow-100 dark:bg-yellow-800/50">
<AlertTriangle class="h-5 w-5 text-yellow-600 dark:text-yellow-400" />
</div>
</div>
<div class="text-secondary text-sm">
<p>The Debug feature is currently in <strong>beta</strong>. You may encounter unexpected behavior or limitations.</p>
<p class="mt-2">By continuing, you acknowledge that this feature is experimental.</p>
</div>
</div>
{#snippet actions()}
<Button size="sm" on:click={confirmDebugBetaWarning}>Continue</Button>
{/snippet}
</Modal>
@@ -4,12 +4,13 @@
const bubble = createBubbler()
import Button from '$lib/components/common/button/Button.svelte'
import type { Preview, ScriptLang } from '$lib/gen'
import { createEventDispatcher, onMount, untrack } from 'svelte'
import { Trash2 } from 'lucide-svelte'
import { createEventDispatcher, onDestroy, onMount, untrack } from 'svelte'
import { AlertTriangle, Trash2, Bug, Terminal } from 'lucide-svelte'
import Modal from '$lib/components/common/modal/Modal.svelte'
import { inferArgs, inferAssets } from '$lib/infer'
import type { Schema } from '$lib/common'
import Editor from '$lib/components/Editor.svelte'
import { emptySchema } from '$lib/utils'
import { emptySchema, getLocalSetting, sendUserToast, storeLocalSetting } from '$lib/utils'
import { scriptLangToEditorLang } from '$lib/scripts'
import DiffEditor from '$lib/components/DiffEditor.svelte'
@@ -23,6 +24,22 @@
import { usePreparedAssetSqlQueries } from '$lib/infer.svelte'
import AssetsDropdownButton from '../assets/AssetsDropdownButton.svelte'
import { workspaceStore } from '$lib/stores'
import { SvelteSet } from 'svelte/reactivity'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import { editor as meditor } from 'monaco-editor'
import {
DebugConsole,
getDAPClient,
debugState,
resetDAPClient,
getDebugServerUrl,
type DebugLanguage,
isDebuggable,
getDebugFileExtension,
fetchContextualVariables,
signDebugRequest,
getDebugErrorMessage
} from '$lib/components/debug'
interface Props {
inlineScript: (InlineScript & { language: ScriptLang }) | undefined
@@ -135,6 +152,342 @@
if (inlineScript && inferAssetsRes.current) inlineScript.assets = inferAssetsRes.current?.assets
})
// Debug mode state
const DEBUG_BETA_WARNING_KEY = 'debug_beta_warning_confirmed'
let showDebugBetaWarning = $state(false)
let debugMode = $state(false)
let debugBreakpoints = new SvelteSet<number>()
let breakpointDecorations: string[] = $state([])
let currentLineDecoration: string[] = $state([])
let dapClient = $state<ReturnType<typeof getDAPClient> | null>(null)
let selectedDebugFrameId: number | null = $state(null)
let debugSessionJobId: string | null = $state(null)
let showDebugConsole = $state(true)
let editorPaneSize = $state(75)
let consolePaneSize = $state(25)
// Get the DAP server URL based on language
const dapServerUrl = $derived(
getDebugServerUrl((inlineScript?.language || 'python3') as DebugLanguage)
)
const debugFilePath = $derived(`/tmp/script${getDebugFileExtension(inlineScript?.language ?? '')}`)
const isDebuggableScript = $derived(isDebuggable(inlineScript?.language ?? ''))
const showDebugPanel = $derived(
debugMode && $debugState.connected && ($debugState.running || $debugState.stopped)
)
const hasDebugResult = $derived(debugMode && $debugState.result !== undefined)
const debugConsoleVisible = $derived(showDebugPanel && showDebugConsole)
const currentDebugFrameId = $derived(selectedDebugFrameId ?? $debugState.stackFrames[0]?.id)
// Export debug state for parent component
export function getDebugState() {
return {
debugMode,
isDebuggableScript,
showDebugPanel,
hasDebugResult,
dapClient,
selectedDebugFrameId,
debugSessionJobId,
debugBreakpoints
}
}
// Breakpoint decoration options
const breakpointDecorationType: meditor.IModelDecorationOptions = {
glyphMarginClassName: 'debug-breakpoint-glyph',
glyphMarginHoverMessage: { value: 'Breakpoint (click to remove)' },
stickiness: 1
}
const currentLineDecorationType = {
isWholeLine: true,
className: 'debug-current-line',
glyphMarginClassName: 'debug-current-line-glyph'
}
// Debug functions
function toggleBreakpoint(line: number): void {
if (debugBreakpoints.has(line)) {
debugBreakpoints.delete(line)
} else {
debugBreakpoints.add(line)
}
updateBreakpointDecorations()
}
function updateBreakpointDecorations(): void {
const monacoEditor = editor?.getEditor?.()
if (!monacoEditor) return
const decorations = Array.from(debugBreakpoints).map((line) => ({
range: { startLineNumber: line, startColumn: 1, endLineNumber: line, endColumn: 1 },
options: breakpointDecorationType
}))
const oldDecorations = untrack(() => breakpointDecorations)
breakpointDecorations = monacoEditor.deltaDecorations(oldDecorations, decorations)
}
function refreshBreakpointPositions(): void {
const monacoEditor = editor?.getEditor?.()
if (!monacoEditor || breakpointDecorations.length === 0) return
const model = monacoEditor.getModel()
if (!model) return
const newLines = new Set<number>()
for (const decorationId of breakpointDecorations) {
const range = model.getDecorationRange(decorationId)
if (range) {
newLines.add(range.startLineNumber)
}
}
const oldLines = Array.from(debugBreakpoints).sort((a, b) => a - b)
const updatedLines = Array.from(newLines).sort((a, b) => a - b)
const positionsChanged =
oldLines.length !== updatedLines.length ||
oldLines.some((line, i) => line !== updatedLines[i])
if (positionsChanged) {
debugBreakpoints.clear()
for (const line of newLines) {
debugBreakpoints.add(line)
}
syncBreakpointsWithServer()
}
}
async function syncBreakpointsWithServer(): Promise<void> {
if (!dapClient || !dapClient.isConnected()) return
try {
await dapClient.setBreakpoints(debugFilePath, Array.from(debugBreakpoints))
} catch (error) {
console.error('Failed to sync breakpoints:', error)
}
}
function updateCurrentLineDecoration(line: number | undefined): void {
const monacoEditor = editor?.getEditor?.()
if (!monacoEditor) return
const oldDecorations = untrack(() => currentLineDecoration)
if (!line) {
currentLineDecoration = monacoEditor.deltaDecorations(oldDecorations, [])
return
}
const decorations = [
{
range: { startLineNumber: line, startColumn: 1, endLineNumber: line, endColumn: 1 },
options: currentLineDecorationType
}
]
currentLineDecoration = monacoEditor.deltaDecorations(oldDecorations, decorations)
monacoEditor.revealLineInCenter(line)
}
export async function startDebugging(): Promise<void> {
if (!inlineScript) return
try {
showDebugConsole = true
selectedDebugFrameId = null
resetDAPClient()
dapClient = getDAPClient(dapServerUrl)
const env = await fetchContextualVariables($workspaceStore ?? '')
const code = inlineScript.content
let signedPayload
try {
signedPayload = await signDebugRequest($workspaceStore ?? '', code ?? '', inlineScript.language ?? 'python3')
debugSessionJobId = signedPayload.job_id
} catch (signError) {
sendUserToast(getDebugErrorMessage(signError), true)
return
}
// Get static args from fields
const args = Object.entries(fields ?? {}).reduce<Record<string, unknown>>((acc, [key, obj]) => {
if (obj.type === 'static') {
acc[key] = obj.value
}
return acc
}, {})
await dapClient.connect()
await dapClient.initialize()
await dapClient.setBreakpoints(debugFilePath, Array.from(debugBreakpoints))
await dapClient.configurationDone()
await dapClient.launch({
code,
cwd: '/tmp',
args,
callMain: true,
env,
token: signedPayload.token
})
} catch (error) {
console.error('Failed to start debugging:', error)
sendUserToast(getDebugErrorMessage(error), true)
}
}
export async function stopDebugging(): Promise<void> {
if (!dapClient) return
try {
await dapClient.terminate()
dapClient.disconnect()
} catch (error) {
console.error('Failed to stop debugging:', error)
} finally {
debugSessionJobId = null
}
}
export async function continueExecution(): Promise<void> {
if (!dapClient) return
await dapClient.continue_()
}
export async function stepOver(): Promise<void> {
if (!dapClient) return
await dapClient.stepOver()
}
export async function stepIn(): Promise<void> {
if (!dapClient) return
await dapClient.stepIn()
}
export async function stepOut(): Promise<void> {
if (!dapClient) return
await dapClient.stepOut()
}
export function clearAllBreakpoints(): void {
debugBreakpoints.clear()
updateBreakpointDecorations()
}
export function toggleDebugMode(): void {
if (debugMode) {
// Exiting debug mode - clean up
debugMode = false
stopDebugging()
clearAllBreakpoints()
updateCurrentLineDecoration(undefined)
} else {
// Entering debug mode - check if beta warning was confirmed
if (getLocalSetting(DEBUG_BETA_WARNING_KEY) !== 'true') {
showDebugBetaWarning = true
} else {
debugMode = true
}
}
}
function confirmDebugBetaWarning(): void {
storeLocalSetting(DEBUG_BETA_WARNING_KEY, 'true')
showDebugBetaWarning = false
debugMode = true
}
// Subscribe to debug state changes for current line highlighting
$effect(() => {
const currentLine = $debugState.currentLine
if (debugMode) {
untrack(() => updateCurrentLineDecoration(currentLine))
}
})
// Watch for language changes - exit debug mode when language changes
let lastDebugLang: ScriptLang | undefined = undefined
$effect(() => {
const currentLang = inlineScript?.language
if (lastDebugLang !== undefined && lastDebugLang !== currentLang && debugMode) {
untrack(() => {
if (dapClient) {
dapClient
.terminate()
.catch(() => {})
.finally(() => {
dapClient?.disconnect()
})
}
resetDAPClient()
dapClient = null
debugMode = false
clearAllBreakpoints()
updateCurrentLineDecoration(undefined)
})
}
lastDebugLang = currentLang
})
// Set up glyph margin click handler for breakpoints when debug mode is enabled
$effect(() => {
const monacoEditor = editor?.getEditor?.()
if (!monacoEditor) return
if (debugMode && isDebuggableScript) {
monacoEditor.updateOptions({ glyphMargin: true })
const mouseDownDisposable = monacoEditor.onMouseDown((e) => {
if (e.target.type === 2) {
const line = e.target.position?.lineNumber
if (line) {
toggleBreakpoint(line)
}
}
})
monacoEditor.addCommand(120, () => {
const position = monacoEditor.getPosition()
if (position) {
toggleBreakpoint(position.lineNumber)
}
})
monacoEditor.addCommand(119, () => {
if ($debugState.stopped) continueExecution()
})
monacoEditor.addCommand(117, () => {
if ($debugState.stopped) stepOver()
})
monacoEditor.addCommand(118, () => {
if ($debugState.stopped) stepIn()
})
monacoEditor.addCommand(1143, () => {
if ($debugState.stopped) stepOut()
})
return () => {
mouseDownDisposable.dispose()
monacoEditor.updateOptions({ glyphMargin: false })
}
} else {
monacoEditor.updateOptions({ glyphMargin: false })
}
})
// Clean up debug mode on destroy
onDestroy(() => {
if (debugMode) {
stopDebugging()
resetDAPClient()
}
})
// Track last selection to avoid duplicate events
let lastSelectionKey = $state<string | null>(null)
// Track pending selection during mouse drag
@@ -303,40 +656,119 @@
{#if inlineScript.assets?.length}
<AssetsDropdownButton assets={inlineScript.assets} />
{/if}
{#if isDebuggableScript}
<Button
variant={debugMode ? 'accent' : 'default'}
size="xs2"
on:click={toggleDebugMode}
startIcon={{ icon: Bug }}
btnClasses={debugMode
? ''
: 'bg-surface hover:bg-surface-hover border border-tertiary/30'}
title="Toggle Debug Mode"
>
{debugMode ? 'Exit Debug' : 'Debug'}
</Button>
{/if}
{#if showDebugPanel && !showDebugConsole}
<Button
variant="default"
size="xs2"
on:click={() => (showDebugConsole = true)}
startIcon={{ icon: Terminal }}
btnClasses="bg-surface hover:bg-surface-hover border border-tertiary/30"
title="Show Debug Console"
>
Console
</Button>
{/if}
</div>
<Editor
path={path + '/' + id}
bind:this={editor}
class="flex flex-1 grow h-full"
scriptLang={inlineScript.language}
bind:code={inlineScript.content}
fixedOverflowWidgets={true}
cmdEnterAction={() => onRun()}
bind:websocketAlive
rawAppRunnableKey={id}
on:change={async (e) => {
if (inlineScript) {
if (inlineScript.lock != undefined) {
inlineScript.lock = undefined
{#if debugConsoleVisible}
<Splitpanes horizontal class="h-full">
<Pane bind:size={editorPaneSize} minSize={20}>
<Editor
path={path + '/' + id}
bind:this={editor}
class="flex flex-1 grow h-full"
scriptLang={inlineScript.language}
bind:code={inlineScript.content}
fixedOverflowWidgets={true}
cmdEnterAction={() => onRun()}
bind:websocketAlive
rawAppRunnableKey={id}
on:change={async (e) => {
if (inlineScript) {
if (inlineScript.lock != undefined) {
inlineScript.lock = undefined
}
const oldSchema = JSON.stringify(inlineScript.schema)
if (inlineScript.schema == undefined) {
inlineScript.schema = emptySchema()
}
await inferInlineScriptSchema(inlineScript?.language, e.detail, inlineScript.schema)
if (JSON.stringify(inlineScript.schema) != oldSchema) {
inlineScript = inlineScript
syncFields()
}
if (debugMode && breakpointDecorations.length > 0) {
refreshBreakpointPositions()
}
}
}}
args={Object.entries(fields ?? {}).reduce((acc, [key, obj]) => {
acc[key] = obj.type === 'static' ? obj.value : undefined
return acc
}, {})}
preparedAssetsSqlQueries={preparedSqlQueries.current}
/>
</Pane>
<Pane bind:size={consolePaneSize} minSize={10}>
<DebugConsole
client={dapClient}
currentFrameId={currentDebugFrameId}
onClose={() => (showDebugConsole = false)}
workspace={$workspaceStore}
jobId={debugSessionJobId ?? undefined}
/>
</Pane>
</Splitpanes>
{:else}
<Editor
path={path + '/' + id}
bind:this={editor}
class="flex flex-1 grow h-full"
scriptLang={inlineScript.language}
bind:code={inlineScript.content}
fixedOverflowWidgets={true}
cmdEnterAction={() => onRun()}
bind:websocketAlive
rawAppRunnableKey={id}
on:change={async (e) => {
if (inlineScript) {
if (inlineScript.lock != undefined) {
inlineScript.lock = undefined
}
const oldSchema = JSON.stringify(inlineScript.schema)
if (inlineScript.schema == undefined) {
inlineScript.schema = emptySchema()
}
await inferInlineScriptSchema(inlineScript?.language, e.detail, inlineScript.schema)
if (JSON.stringify(inlineScript.schema) != oldSchema) {
inlineScript = inlineScript
syncFields()
}
if (debugMode && breakpointDecorations.length > 0) {
refreshBreakpointPositions()
}
}
const oldSchema = JSON.stringify(inlineScript.schema)
if (inlineScript.schema == undefined) {
inlineScript.schema = emptySchema()
}
await inferInlineScriptSchema(inlineScript?.language, e.detail, inlineScript.schema)
if (JSON.stringify(inlineScript.schema) != oldSchema) {
inlineScript = inlineScript
syncFields()
}
}
// $app = $app
}}
args={Object.entries(fields ?? {}).reduce((acc, [key, obj]) => {
acc[key] = obj.type === 'static' ? obj.value : undefined
return acc
}, {})}
preparedAssetsSqlQueries={preparedSqlQueries.current}
/>
}}
args={Object.entries(fields ?? {}).reduce((acc, [key, obj]) => {
acc[key] = obj.type === 'static' ? obj.value : undefined
return acc
}, {})}
preparedAssetsSqlQueries={preparedSqlQueries.current}
/>
{/if}
<DiffEditor
open={false}
@@ -350,3 +782,20 @@
</div>
</div>
{/if}
<Modal title="Debug Feature (Beta)" bind:open={showDebugBetaWarning}>
<div class="flex items-start gap-3">
<div class="flex-shrink-0">
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-yellow-100 dark:bg-yellow-800/50">
<AlertTriangle class="h-5 w-5 text-yellow-600 dark:text-yellow-400" />
</div>
</div>
<div class="text-secondary text-sm">
<p>The Debug feature is currently in <strong>beta</strong>. You may encounter unexpected behavior or limitations.</p>
<p class="mt-2">By continuing, you acknowledge that this feature is experimental.</p>
</div>
</div>
{#snippet actions()}
<Button size="sm" on:click={confirmDebugBetaWarning}>Continue</Button>
{/snippet}
</Modal>
@@ -20,6 +20,10 @@
import RunnableJobPanelInner from '../apps/editor/RunnableJobPanelInner.svelte'
import JobLoader from '../JobLoader.svelte'
import type { Job, ScriptLang } from '$lib/gen'
import { slide } from 'svelte/transition'
import { DebugToolbar, DebugPanel, debugState } from '$lib/components/debug'
import LogViewer from '$lib/components/LogViewer.svelte'
import DisplayResult from '$lib/components/DisplayResult.svelte'
type RunnableWithInlineScript = RunnableWithFields & {
inlineScript?: InlineScript & { language: ScriptLang }
@@ -78,6 +82,36 @@
let testIsLoading = $state(false)
let scriptProgress = $state(0)
// Reference to the inline script editor for debug functions
let inlineScriptEditor: RawAppInlineScriptEditor | undefined = $state()
// Get debug state from the editor
const editorDebugState = $derived(inlineScriptEditor?.getDebugState?.() ?? {
debugMode: false,
isDebuggableScript: false,
showDebugPanel: false,
hasDebugResult: false,
dapClient: null,
selectedDebugFrameId: null,
debugSessionJobId: null,
debugBreakpoints: new Set()
})
// Reactive debug state values
const debugMode = $derived(editorDebugState.debugMode)
const isDebuggableScript = $derived(editorDebugState.isDebuggableScript)
const showDebugPanel = $derived(editorDebugState.showDebugPanel)
const hasDebugResult = $derived(editorDebugState.hasDebugResult)
const dapClient = $derived(editorDebugState.dapClient)
let selectedDebugFrameId: number | null = $state(null)
// Auto-switch to test tab when debug mode is enabled
$effect(() => {
if (debugMode) {
selectedTab = 'test'
}
})
function onFieldsChange(fields: Record<string, StaticAppInput | UserAppInput>) {
if (args == undefined) {
args = {}
@@ -127,6 +161,7 @@
<Pane size={55}>
{#if isRunnableByName(runnable)}
<RawAppInlineScriptEditor
bind:this={inlineScriptEditor}
on:createScriptFromInlineScript={() => dispatch('createScriptFromInlineScript', runnable)}
{id}
bind:inlineScript={runnable.inlineScript}
@@ -195,6 +230,24 @@
<div class="text-primary text-xs">No inputs</div>
{/if}
{:else if selectedTab == 'test'}
{#if debugMode && isDebuggableScript}
<div transition:slide={{ duration: 200 }}>
<DebugToolbar
connected={$debugState.connected}
running={$debugState.running}
stopped={$debugState.stopped}
breakpointCount={editorDebugState.debugBreakpoints?.size ?? 0}
onStart={() => inlineScriptEditor?.startDebugging() ?? Promise.resolve()}
onStop={() => inlineScriptEditor?.stopDebugging() ?? Promise.resolve()}
onContinue={() => inlineScriptEditor?.continueExecution() ?? Promise.resolve()}
onStepOver={() => inlineScriptEditor?.stepOver() ?? Promise.resolve()}
onStepIn={() => inlineScriptEditor?.stepIn() ?? Promise.resolve()}
onStepOut={() => inlineScriptEditor?.stepOut() ?? Promise.resolve()}
onClearBreakpoints={() => inlineScriptEditor?.clearAllBreakpoints()}
onExitDebug={() => inlineScriptEditor?.toggleDebugMode()}
/>
</div>
{/if}
<SplitPanesWrapper>
<Splitpanes horizontal class="grow">
<Pane size={50}>
@@ -210,7 +263,57 @@
</div>
</Pane>
<Pane size={50}>
<RunnableJobPanelInner frontendJob={false} {testJob} {testIsLoading} />
{#if showDebugPanel || hasDebugResult}
<Splitpanes horizontal class="h-full">
<Pane size={50} minSize={15}>
<Splitpanes horizontal class="h-full">
<Pane size={50} minSize={10}>
<LogViewer
small
content={$debugState.logs}
isLoading={$debugState.running && !$debugState.stopped}
tag={undefined}
/>
</Pane>
<Pane size={50} minSize={10}>
{#if hasDebugResult}
<div class="h-full p-2 overflow-auto">
<DisplayResult
result={$debugState.result}
language={runnable?.inlineScript?.language}
/>
</div>
{:else}
<div class="h-full flex items-center justify-center text-sm text-tertiary">
{#if $debugState.running && !$debugState.stopped}
Running...
{:else if $debugState.stopped}
Paused at breakpoint
{:else}
Waiting for debug session
{/if}
</div>
{/if}
</Pane>
</Splitpanes>
</Pane>
<Pane size={50} minSize={15}>
<DebugPanel
stackFrames={$debugState.stackFrames}
scopes={$debugState.scopes}
variables={$debugState.variables}
client={dapClient}
bind:selectedFrameId={selectedDebugFrameId}
/>
</Pane>
</Splitpanes>
{:else if debugMode && isDebuggableScript}
<div class="h-full flex items-center justify-center text-sm text-tertiary">
Click "Debug" in the toolbar to start debugging
</div>
{:else}
<RunnableJobPanelInner frontendJob={false} {testJob} {testIsLoading} />
{/if}
</Pane>
</Splitpanes>
</SplitPanesWrapper>
@@ -46,6 +46,8 @@
customUi?: PreviewPanelUi | undefined
children?: import('svelte').Snippet
capturesTab?: import('svelte').Snippet
customResultPanel?: import('svelte').Snippet
showCustomResultPanel?: boolean
}
let {
@@ -60,7 +62,9 @@
showCaptures = false,
customUi = undefined,
children,
capturesTab
capturesTab,
customResultPanel,
showCustomResultPanel = false
}: Props = $props()
type DContent = {
@@ -151,7 +155,11 @@
</Pane>
<Pane>
{@render children?.()}
{#if previewJob != undefined && (previewJob.result_stream || previewJob.result)}
{#if showCustomResultPanel && customResultPanel}
<div class="h-full">
{@render customResultPanel()}
</div>
{:else if previewJob != undefined && (previewJob.result_stream || previewJob.result)}
<div class="relative w-full h-full p-2">
<div class="relative h-full">
<DisplayResult
@@ -28,6 +28,7 @@
const hubPath = $page.url.searchParams.get('hub')
const showMeta = /true|1/i.test($page.url.searchParams.get('show_meta') ?? '0')
const urlArgs = $page.url.searchParams.get('initial_args')
const collabLang = $page.url.searchParams.get('lang') as ScriptLang | null
let initialArgs = urlArgs ? decodeState(urlArgs) : (get(initialArgsStore) ?? {})
if (get(initialArgsStore)) $initialArgsStore = undefined
@@ -58,7 +59,7 @@
schema: schema,
is_template: false,
extra_perms: {},
language: ($defaultScripts?.order?.filter(
language: collabLang ?? ($defaultScripts?.order?.filter(
(x) => $defaultScripts?.hidden == undefined || !$defaultScripts.hidden.includes(x)
)?.[0] ?? 'bun') as ScriptLang,
kind: 'script'
+6 -1
View File
@@ -63,6 +63,12 @@ const config = {
changeOrigin: true,
ws: true
},
'^/ws_debug/.*': {
target: process.env.REMOTE_DEBUG ?? 'https://app.windmill.dev',
changeOrigin: true,
ws: true,
rewrite: (path) => path.replace(/^\/ws_debug/, '')
},
'^/ui_builder/.*': {
target: 'http://localhost:4000',
changeOrigin: true,
@@ -82,7 +88,6 @@ const config = {
exclude: [
'@codingame/monaco-vscode-standalone-typescript-language-features',
'@codingame/monaco-vscode-standalone-languages',
'vscode'
]
},
worker: { format: 'es' },
+1
View File
@@ -0,0 +1 @@
node_modules/
+129
View File
@@ -0,0 +1,129 @@
{
"name": "windmill-multiplayer",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "windmill-multiplayer",
"version": "1.0.0",
"dependencies": {
"lib0": "^0.2.117",
"ws": "^8.18.0",
"y-protocols": "^1.0.7",
"y-websocket": "^3.0.0",
"yjs": "^13.6.0"
}
},
"node_modules/isomorphic.js": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz",
"integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==",
"license": "MIT",
"funding": {
"type": "GitHub Sponsors ❤",
"url": "https://github.com/sponsors/dmonad"
}
},
"node_modules/lib0": {
"version": "0.2.117",
"resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.117.tgz",
"integrity": "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==",
"license": "MIT",
"dependencies": {
"isomorphic.js": "^0.2.4"
},
"bin": {
"0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js",
"0gentesthtml": "bin/gentesthtml.js",
"0serve": "bin/0serve.js"
},
"engines": {
"node": ">=16"
},
"funding": {
"type": "GitHub Sponsors ❤",
"url": "https://github.com/sponsors/dmonad"
}
},
"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
}
}
},
"node_modules/y-protocols": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.7.tgz",
"integrity": "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==",
"license": "MIT",
"dependencies": {
"lib0": "^0.2.85"
},
"engines": {
"node": ">=16.0.0",
"npm": ">=8.0.0"
},
"funding": {
"type": "GitHub Sponsors ❤",
"url": "https://github.com/sponsors/dmonad"
},
"peerDependencies": {
"yjs": "^13.0.0"
}
},
"node_modules/y-websocket": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/y-websocket/-/y-websocket-3.0.0.tgz",
"integrity": "sha512-mUHy7AzkOZ834T/7piqtlA8Yk6AchqKqcrCXjKW8J1w2lPtRDjz8W5/CvXz9higKAHgKRKqpI3T33YkRFLkPtg==",
"license": "MIT",
"dependencies": {
"lib0": "^0.2.102",
"y-protocols": "^1.0.5"
},
"engines": {
"node": ">=16.0.0",
"npm": ">=8.0.0"
},
"funding": {
"type": "GitHub Sponsors ❤",
"url": "https://github.com/sponsors/dmonad"
},
"peerDependencies": {
"yjs": "^13.5.6"
}
},
"node_modules/yjs": {
"version": "13.6.29",
"resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.29.tgz",
"integrity": "sha512-kHqDPdltoXH+X4w1lVmMtddE3Oeqq48nM40FD5ojTd8xYhQpzIDcfE2keMSU5bAgRPJBe225WTUdyUgj1DtbiQ==",
"license": "MIT",
"dependencies": {
"lib0": "^0.2.99"
},
"engines": {
"node": ">=16.0.0",
"npm": ">=8.0.0"
},
"funding": {
"type": "GitHub Sponsors ❤",
"url": "https://github.com/sponsors/dmonad"
}
}
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"name": "windmill-multiplayer",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node server.mjs",
"dev": "node server.mjs"
},
"dependencies": {
"lib0": "^0.2.117",
"ws": "^8.18.0",
"y-protocols": "^1.0.7",
"y-websocket": "^3.0.0",
"yjs": "^13.6.0"
}
}
+151
View File
@@ -0,0 +1,151 @@
#!/usr/bin/env node
/**
* Simple y-websocket server with connection logging
* Run with: node server.mjs
*/
import http from 'http'
import { WebSocketServer } from 'ws'
import * as Y from 'yjs'
import * as syncProtocol from 'y-protocols/sync'
import * as awarenessProtocol from 'y-protocols/awareness'
import * as encoding from 'lib0/encoding'
import * as decoding from 'lib0/decoding'
const PORT = process.env.PORT || 3002
const HOST = process.env.HOST || '0.0.0.0'
const messageSync = 0
const messageAwareness = 1
// Store docs in memory
const docs = new Map()
const getYDoc = (docname) => {
let doc = docs.get(docname)
if (!doc) {
doc = new Y.Doc()
doc.name = docname
docs.set(docname, doc)
}
return doc
}
const send = (conn, message) => {
if (conn.readyState === 1) { // WebSocket.OPEN
conn.send(message, err => { if (err) console.error(err) })
}
}
const setupWSConnection = (conn, req, docName) => {
const doc = getYDoc(docName)
// Initialize awareness
if (!doc.awareness) {
doc.awareness = new awarenessProtocol.Awareness(doc)
}
const awareness = doc.awareness
// Track connections per doc
if (!doc.conns) doc.conns = new Set()
doc.conns.add(conn)
conn.on('message', (message) => {
const data = new Uint8Array(message)
const decoder = decoding.createDecoder(data)
const messageType = decoding.readVarUint(decoder)
switch (messageType) {
case messageSync:
const encoder = encoding.createEncoder()
encoding.writeVarUint(encoder, messageSync)
syncProtocol.readSyncMessage(decoder, encoder, doc, null)
if (encoding.length(encoder) > 1) {
send(conn, encoding.toUint8Array(encoder))
}
break
case messageAwareness:
awarenessProtocol.applyAwarenessUpdate(awareness, decoding.readVarUint8Array(decoder), conn)
break
}
})
// Send initial sync step 1
{
const encoder = encoding.createEncoder()
encoding.writeVarUint(encoder, messageSync)
syncProtocol.writeSyncStep1(encoder, doc)
send(conn, encoding.toUint8Array(encoder))
}
// Send awareness states
const awarenessStates = awareness.getStates()
if (awarenessStates.size > 0) {
const encoder = encoding.createEncoder()
encoding.writeVarUint(encoder, messageAwareness)
encoding.writeVarUint8Array(encoder, awarenessProtocol.encodeAwarenessUpdate(awareness, Array.from(awarenessStates.keys())))
send(conn, encoding.toUint8Array(encoder))
}
// Broadcast awareness changes
const awarenessChangeHandler = ({ added, updated, removed }, origin) => {
const changedClients = added.concat(updated).concat(removed)
const encoder = encoding.createEncoder()
encoding.writeVarUint(encoder, messageAwareness)
encoding.writeVarUint8Array(encoder, awarenessProtocol.encodeAwarenessUpdate(awareness, changedClients))
const message = encoding.toUint8Array(encoder)
doc.conns.forEach(c => send(c, message))
}
awareness.on('update', awarenessChangeHandler)
// Broadcast doc updates
const updateHandler = (update, origin) => {
const encoder = encoding.createEncoder()
encoding.writeVarUint(encoder, messageSync)
syncProtocol.writeUpdate(encoder, update)
const message = encoding.toUint8Array(encoder)
doc.conns.forEach(c => {
if (c !== origin) send(c, message)
})
}
doc.on('update', updateHandler)
conn.on('close', () => {
doc.conns.delete(conn)
awareness.off('update', awarenessChangeHandler)
doc.off('update', updateHandler)
// Clean up awareness for this connection
awarenessProtocol.removeAwarenessStates(awareness, [doc.clientID], null)
})
}
const server = http.createServer((req, res) => {
if (req.url === '/' || req.url === '/health') {
res.writeHead(200, { 'Content-Type': 'text/plain' })
res.end('okay')
} else {
res.writeHead(404)
res.end('not found')
}
})
const wss = new WebSocketServer({ server })
wss.on('connection', (ws, req) => {
const docName = req.url?.slice(1).split('?')[0] || 'unknown'
const clientIp = req.socket.remoteAddress
console.log(`[${new Date().toISOString()}] CONNECT: doc="${docName}" from=${clientIp}`)
ws.on('close', () => {
console.log(`[${new Date().toISOString()}] DISCONNECT: doc="${docName}" from=${clientIp}`)
})
setupWSConnection(ws, req, docName)
})
server.listen(PORT, HOST, () => {
console.log(`[${new Date().toISOString()}] Multiplayer server running at ${HOST}:${PORT}`)
})